Merge pull request #1526 from trheyi/main

feat: sui backend script test and fix streamline release process
This commit is contained in:
Max 2026-04-24 09:13:34 +08:00 committed by GitHub
commit 142c53889e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1448 additions and 19 deletions

25
.github/workflows/create-release.yml vendored Normal file
View file

@ -0,0 +1,25 @@
name: Create Release
on:
push:
tags:
- "v*"
permissions:
contents: write
jobs:
create:
runs-on: ubuntu-latest
steps:
- name: Create Draft Release
env:
GH_TOKEN: ${{ github.token }}
run: |
TAG="${GITHUB_REF#refs/tags/}"
VERSION="${TAG#v}"
gh release create "$TAG" \
--repo "$GITHUB_REPOSITORY" \
--title "Yao v${VERSION}" \
--generate-notes \
--draft

View file

@ -1,7 +1,6 @@
name: Release Linux
on:
workflow_dispatch:
push:
tags:
- "v*"
@ -174,16 +173,49 @@ jobs:
cp "artifacts/yao-${VERSION}-linux-amd64" "release/yao-${VERSION}-linux-amd64-dev"
cp "artifacts/yao-${VERSION}-linux-arm64" "release/yao-${VERSION}-linux-arm64-dev"
chmod +x release/yao-*
for ARCH in amd64 arm64; do
sha256sum "release/yao-${VERSION}-linux-${ARCH}" | awk '{print $1}' > "release/yao-linux-${ARCH}-prod.sha256"
sha256sum "release/yao-${VERSION}-linux-${ARCH}-dev" | awk '{print $1}' > "release/yao-linux-${ARCH}-dev.sha256"
done
ls -lh release/
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.version.outputs.tag }}
name: Yao v${{ steps.version.outputs.version }}
files: release/*
generate_release_notes: true
make_latest: false
- name: Wait for Draft Release
env:
GH_TOKEN: ${{ github.token }}
run: |
TAG="${{ steps.version.outputs.tag }}"
for i in $(seq 1 30); do
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" &>/dev/null; then
echo "Draft release found for $TAG."
exit 0
fi
echo "Waiting for draft release... ($i/30)"
sleep 10
done
echo "::error::Timed out waiting for draft release $TAG"
exit 1
- name: Upload Assets to GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: |
TAG="${{ steps.version.outputs.tag }}"
gh release upload "$TAG" release/* --repo "$GITHUB_REPOSITORY" --clobber
- name: Publish Release if Complete
env:
GH_TOKEN: ${{ github.token }}
run: |
TAG="${{ steps.version.outputs.tag }}"
ASSET_COUNT=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets | length')
echo "Current assets: $ASSET_COUNT / 16"
if [ "$ASSET_COUNT" -ge 16 ]; then
echo "All assets present, publishing release..."
gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft=false --latest
else
echo "Assets incomplete ($ASSET_COUNT/16), waiting for other workflow to publish."
fi
- name: Upload Linux binaries to R2
env:

View file

@ -1,7 +1,6 @@
name: Release macOS
on:
workflow_dispatch:
push:
tags:
- "v*"
@ -280,13 +279,42 @@ jobs:
chmod +x release/yao-*
ls -lh release/
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.version.outputs.tag }}
name: Yao v${{ steps.version.outputs.version }}
files: release/*
generate_release_notes: true
- name: Wait for Draft Release
env:
GH_TOKEN: ${{ github.token }}
run: |
TAG="${{ steps.version.outputs.tag }}"
for i in $(seq 1 30); do
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" &>/dev/null; then
echo "Draft release found for $TAG."
exit 0
fi
echo "Waiting for draft release... ($i/30)"
sleep 10
done
echo "::error::Timed out waiting for draft release $TAG"
exit 1
- name: Upload Assets to GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: |
TAG="${{ steps.version.outputs.tag }}"
gh release upload "$TAG" release/* --repo "$GITHUB_REPOSITORY" --clobber
- name: Publish Release if Complete
env:
GH_TOKEN: ${{ github.token }}
run: |
TAG="${{ steps.version.outputs.tag }}"
ASSET_COUNT=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets | length')
echo "Current assets: $ASSET_COUNT / 16"
if [ "$ASSET_COUNT" -ge 16 ]; then
echo "All assets present, publishing release..."
gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft=false --latest
else
echo "Assets incomplete ($ASSET_COUNT/16), waiting for other workflow to publish."
fi
- name: Upload macOS binaries to R2
env:

View file

@ -16,7 +16,7 @@ import (
"github.com/yaoapp/yao/test"
)
// testConnectorID uses the cheapest model (Claude Haiku 3.5) to save tokens
// testConnectorID uses the cheapest model (Claude Haiku 4.5) to save tokens
const testConnectorID = "claude.haiku-3_0"
// TestAnthropicStreamBasic tests basic streaming completion with Anthropic API
@ -214,7 +214,7 @@ func TestAnthropicStreamRetry(t *testing.T) {
connDSL := `{
"type": "anthropic",
"options": {
"model": "claude-3-5-haiku-20241022",
"model": "claude-haiku-4-5-20251001",
"key": "sk-ant-invalid-key-should-fail"
}
}`

View file

@ -187,6 +187,7 @@ func init() {
suiCmd.AddCommand(sui.WatchCmd)
suiCmd.AddCommand(sui.BuildCmd)
suiCmd.AddCommand(sui.TransCmd)
suiCmd.AddCommand(sui.TestCmd)
// Agent
agentCmd.AddCommand(agent.TestCmd)

View file

@ -11,4 +11,12 @@ func init() {
TransCmd.PersistentFlags().StringVarP(&data, "data", "d", "::{}", L("Session Data"))
TransCmd.PersistentFlags().BoolVarP(&debug, "debug", "D", false, L("Debug mode"))
TransCmd.PersistentFlags().StringVarP(&locales, "locales", "l", "", L("Locales, separated by commas"))
TestCmd.PersistentFlags().StringVarP(&data, "data", "d", "::{}", L("Session Data"))
TestCmd.Flags().StringVar(&testPage, "page", "", L("Filter by page route (substring match)"))
TestCmd.Flags().StringVar(&testRun, "run", "", L("Filter test functions by regex"))
TestCmd.Flags().BoolVarP(&testVerbose, "verbose", "v", false, L("Verbose output"))
TestCmd.Flags().BoolVar(&testJSON, "json", false, L("Output report in JSON format"))
TestCmd.Flags().BoolVar(&testFailFast, "fail-fast", false, L("Stop on first failure"))
TestCmd.Flags().StringVar(&testTimeout, "timeout", "30s", L("Timeout per test"))
}

91
cmd/sui/test.go Normal file
View file

@ -0,0 +1,91 @@
package sui
import (
"fmt"
"os"
"time"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/engine"
suitest "github.com/yaoapp/yao/sui/test"
)
var (
testPage string
testRun string
testVerbose bool
testJSON bool
testFailFast bool
testTimeout string
)
// TestCmd runs SUI backend tests
var TestCmd = &cobra.Command{
Use: "test",
Short: L("Test SUI backend scripts"),
Long: L("Run unit tests for SUI backend scripts (*.backend_test.ts)"),
Run: func(cmd *cobra.Command, args []string) {
if len(args) < 1 {
fmt.Fprintln(os.Stderr, color.RedString(L("Usage: yao sui test <sui> [template]")))
os.Exit(1)
}
Boot()
cfg := config.Conf
_, err := engine.Load(cfg, engine.LoadOption{Action: "sui.test"})
if err != nil {
fmt.Fprintln(os.Stderr, color.RedString(err.Error()))
os.Exit(1)
}
suiID := args[0]
template := "default"
if len(args) >= 2 {
template = args[1]
}
if suiID == "agent" && template == "default" {
template = "agent"
}
timeout := 30 * time.Second
if testTimeout != "" {
d, err := time.ParseDuration(testTimeout)
if err != nil {
fmt.Fprintln(os.Stderr, color.RedString("Invalid --timeout: %s", testTimeout))
os.Exit(1)
}
timeout = d
}
opts := &suitest.Options{
SUIID: suiID,
Template: template,
Page: testPage,
Run: testRun,
Data: data,
Verbose: testVerbose,
JSON: testJSON,
FailFast: testFailFast,
Timeout: timeout,
}
runner, err := suitest.NewRunner(opts)
if err != nil {
fmt.Fprintln(os.Stderr, color.RedString(err.Error()))
os.Exit(1)
}
report, err := runner.Run()
if err != nil {
fmt.Fprintln(os.Stderr, color.RedString("Error: %s", err.Error()))
os.Exit(1)
}
if report.HasFailures() {
os.Exit(1)
}
},
}

206
sui/test/context.go Normal file
View file

@ -0,0 +1,206 @@
package test
import (
"fmt"
"github.com/yaoapp/gou/runtime/v8/bridge"
"github.com/yaoapp/yao/sui/core"
"rogchap.com/v8go"
)
// SUITestContext provides the test execution context for SUI backend scripts.
// It wraps a loaded Script and a mock Request, exposing call/callWithRequest
// to JS test functions.
type SUITestContext struct {
Script *core.Script
Request *core.Request
Prefix string
Sid string
}
// NewSUITestContext creates a new SUI test context for a page
func NewSUITestContext(script *core.Script, prefix string, sid string) *SUITestContext {
return &SUITestContext{
Script: script,
Prefix: prefix,
Sid: sid,
Request: &core.Request{
Method: "GET",
Sid: sid,
Payload: map[string]interface{}{},
Params: map[string]string{},
},
}
}
// Call invokes an Api-prefixed method on the backend script (sui.Run path).
// The JS function is looked up as <Prefix><method> (e.g. "ApiGetDashboard").
func (ctx *SUITestContext) Call(method string, args ...interface{}) (interface{}, error) {
scriptCtx, err := ctx.Script.NewContext(ctx.Sid, nil)
if err != nil {
return nil, err
}
defer scriptCtx.Close()
if ctx.Request.Authorized != nil {
scriptCtx.WithAuthorized(ctx.Request.Authorized)
}
fnName := ctx.Prefix + method
global := scriptCtx.Global()
if !global.Has(fnName) {
return nil, fmt.Errorf("method %s not found (looked for %s)", method, fnName)
}
return scriptCtx.Call(fnName, args...)
}
// CallWithRequest invokes a method on the backend script, appending *Request
// as the last argument (page-render @Method path).
func (ctx *SUITestContext) CallWithRequest(method string, args ...interface{}) (interface{}, error) {
return ctx.Script.Call(ctx.Request, method, args...)
}
// NewSUITestContextObject creates a JavaScript object exposing the SUITestContext to V8
func NewSUITestContextObject(v8ctx *v8go.Context, ctx *SUITestContext) (*v8go.Value, error) {
iso := v8ctx.Isolate()
tmpl := v8go.NewObjectTemplate(iso)
tmpl.Set("call", ctx.callMethod(iso, v8ctx))
tmpl.Set("callWithRequest", ctx.callWithRequestMethod(iso, v8ctx))
tmpl.Set("setAuthorized", ctx.setAuthorizedMethod(iso, v8ctx))
tmpl.Set("reset", ctx.resetMethod(iso))
instance, err := tmpl.NewInstance(v8ctx)
if err != nil {
return nil, err
}
obj, err := instance.Value.AsObject()
if err != nil {
return nil, err
}
reqObj, err := ctx.buildRequestObject(v8ctx)
if err != nil {
return nil, err
}
obj.Set("request", reqObj)
return instance.Value, nil
}
// callMethod implements ctx.call(method, ...args) in JS
func (ctx *SUITestContext) callMethod(iso *v8go.Isolate, v8ctx *v8go.Context) *v8go.FunctionTemplate {
return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
jsArgs := info.Args()
if len(jsArgs) < 1 {
throwJSError(v8ctx, "call requires at least 1 argument (method name)")
return v8go.Undefined(iso)
}
method := jsArgs[0].String()
goArgs := make([]interface{}, 0, len(jsArgs)-1)
for _, arg := range jsArgs[1:] {
val, err := bridge.GoValue(arg, v8ctx)
if err != nil {
goArgs = append(goArgs, arg.String())
continue
}
goArgs = append(goArgs, val)
}
result, err := ctx.Call(method, goArgs...)
if err != nil {
return bridge.JsException(v8ctx, err)
}
jsVal, err := bridge.JsValue(v8ctx, result)
if err != nil {
return bridge.JsException(v8ctx, err)
}
return jsVal
})
}
// callWithRequestMethod implements ctx.callWithRequest(method, ...args) in JS
func (ctx *SUITestContext) callWithRequestMethod(iso *v8go.Isolate, v8ctx *v8go.Context) *v8go.FunctionTemplate {
return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
jsArgs := info.Args()
if len(jsArgs) < 1 {
throwJSError(v8ctx, "callWithRequest requires at least 1 argument (method name)")
return v8go.Undefined(iso)
}
method := jsArgs[0].String()
goArgs := make([]interface{}, 0, len(jsArgs)-1)
for _, arg := range jsArgs[1:] {
val, err := bridge.GoValue(arg, v8ctx)
if err != nil {
goArgs = append(goArgs, arg.String())
continue
}
goArgs = append(goArgs, val)
}
result, err := ctx.CallWithRequest(method, goArgs...)
if err != nil {
return bridge.JsException(v8ctx, err)
}
jsVal, err := bridge.JsValue(v8ctx, result)
if err != nil {
return bridge.JsException(v8ctx, err)
}
return jsVal
})
}
// setAuthorizedMethod implements ctx.setAuthorized(auth) in JS
func (ctx *SUITestContext) setAuthorizedMethod(iso *v8go.Isolate, v8ctx *v8go.Context) *v8go.FunctionTemplate {
return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
jsArgs := info.Args()
if len(jsArgs) < 1 {
return v8go.Undefined(iso)
}
val, err := bridge.GoValue(jsArgs[0], v8ctx)
if err != nil {
return v8go.Undefined(iso)
}
if authMap, ok := val.(map[string]interface{}); ok {
ctx.Request.Authorized = authMap
}
return v8go.Undefined(iso)
})
}
// resetMethod implements ctx.reset() in JS — clears authorized, payload, params
func (ctx *SUITestContext) resetMethod(iso *v8go.Isolate) *v8go.FunctionTemplate {
return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
ctx.Request.Authorized = nil
ctx.Request.Payload = map[string]interface{}{}
ctx.Request.Params = map[string]string{}
ctx.Request.Query = nil
ctx.Request.Headers = nil
ctx.Request.Body = nil
return v8go.Undefined(iso)
})
}
// buildRequestObject creates a JS object representing ctx.request
func (ctx *SUITestContext) buildRequestObject(v8ctx *v8go.Context) (*v8go.Value, error) {
reqMap := map[string]interface{}{
"sid": ctx.Request.Sid,
"method": ctx.Request.Method,
"payload": ctx.Request.Payload,
"params": ctx.Request.Params,
"authorized": ctx.Request.Authorized,
}
return bridge.JsValue(v8ctx, reqMap)
}
func throwJSError(v8ctx *v8go.Context, msg string) {
bridge.JsException(v8ctx, fmt.Errorf("%s", msg))
}

110
sui/test/helpers_test.go Normal file
View file

@ -0,0 +1,110 @@
package test
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
v8 "github.com/yaoapp/gou/runtime/v8"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/engine"
"github.com/yaoapp/yao/sui/core"
yaotest "github.com/yaoapp/yao/test"
)
func prepareInternal(t *testing.T) {
t.Helper()
yaotest.Prepare(t, config.Conf)
_, err := engine.Load(config.Conf, engine.LoadOption{Action: "sui.test"})
require.NoError(t, err)
}
func TestExtractFuncName(t *testing.T) {
cases := []struct {
line string
want string
}{
{"export function TestFoo(t, ctx) {", "TestFoo"},
{"function TestBar(t, ctx) {", "TestBar"},
{"export function TestBaz() {", "TestBaz"},
{"// function TestComment(t) {", ""},
{"const x = 1", ""},
{"function notTest(t) {", "notTest"},
{"export function (t) {", ""},
{"function ", ""},
{"function TestNoParens", ""},
}
for _, tt := range cases {
got := extractFuncName(tt.line)
assert.Equal(t, tt.want, got, "extractFuncName(%q)", tt.line)
}
}
func TestFilterTestsInvalidRegex(t *testing.T) {
tc := []*TestCase{{Name: "TestFoo"}}
_, err := filterTests(tc, "[invalid")
assert.Error(t, err)
}
func TestFilterTestsNoMatch(t *testing.T) {
tc := []*TestCase{{Name: "TestFoo"}, {Name: "TestBar"}}
filtered, err := filterTests(tc, "^TestZzz$")
assert.NoError(t, err)
assert.Empty(t, filtered)
}
func TestFilterTestsPartialMatch(t *testing.T) {
tc := []*TestCase{{Name: "TestFoo"}, {Name: "TestBar"}, {Name: "TestFooBar"}}
filtered, err := filterTests(tc, "Foo")
assert.NoError(t, err)
assert.Len(t, filtered, 2)
}
func TestExecuteTestScriptNotLoaded(t *testing.T) {
r := &Runner{opts: &Options{Timeout: 30 * time.Second}}
tc := &TestCase{Name: "TestX", Function: "TestX"}
result := r.executeTest(tc, nil, "nonexistent-script-id", "Api", "test-sid")
assert.Equal(t, "error", result.Status)
assert.Contains(t, result.Error, "not loaded")
}
func TestExecuteTestFunctionNotFound(t *testing.T) {
prepareInternal(t)
defer yaotest.Clean()
_, has := core.SUIs["agent"]
if !has {
t.Skip("no agent SUI")
}
// Load the errors test script
testFile := "assistants/tests/sui-pages/pages/errors/errors.backend_test.ts"
testScriptID := "sui-test.helpers-coverage"
_, err := v8.Load(testFile, testScriptID)
require.NoError(t, err)
backendPath := "assistants/tests/sui-pages/pages/errors/errors"
script, err := core.LoadScript(backendPath, true)
require.NoError(t, err)
require.NotNil(t, script)
r := &Runner{opts: &Options{Timeout: 30 * time.Second}}
// Call with a function name that doesn't exist in the script
tc := &TestCase{Name: "NonExistent", Function: "NonExistentFunction"}
result := r.executeTest(tc, script, testScriptID, "Api", "test-sid")
assert.Equal(t, "error", result.Status)
assert.Contains(t, result.Error, "is not a function")
}
func TestLoadPageConfigBadJSON(t *testing.T) {
prepareInternal(t)
defer yaotest.Clean()
// dashboard.html exists but is not valid JSON
cfg, err := LoadPageConfig("assistants/tests/sui-pages/pages/dashboard/dashboard.html")
assert.Error(t, err)
assert.Nil(t, cfg)
}

456
sui/test/runner.go Normal file
View file

@ -0,0 +1,456 @@
package test
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/fatih/color"
"github.com/google/uuid"
"github.com/yaoapp/gou/application"
v8 "github.com/yaoapp/gou/runtime/v8"
"github.com/yaoapp/gou/runtime/v8/bridge"
agenttest "github.com/yaoapp/yao/agent/test"
"github.com/yaoapp/yao/sui/core"
)
// Runner discovers and executes SUI backend tests
type Runner struct {
opts *Options
sui core.SUI
tmpl core.ITemplate
}
// NewRunner creates a new SUI test runner
func NewRunner(opts *Options) (*Runner, error) {
sui, has := core.SUIs[opts.SUIID]
if !has {
return nil, fmt.Errorf("SUI %q not found", opts.SUIID)
}
sid := uuid.New().String()
sui.WithSid(sid)
tmpl, err := sui.GetTemplate(opts.Template)
if err != nil {
return nil, fmt.Errorf("template %q: %w", opts.Template, err)
}
return &Runner{opts: opts, sui: sui, tmpl: tmpl}, nil
}
// Run discovers and executes all matching backend tests, returning a report
func (r *Runner) Run() (*Report, error) {
startTime := time.Now()
sid := r.sui.GetSid()
if !r.opts.JSON {
r.printHeader(sid)
}
pageInfos, err := r.discoverPageTests()
if err != nil {
return nil, err
}
if !r.opts.JSON {
fmt.Printf("Found: %d test files\n\n", len(pageInfos))
}
report := &Report{
Type: "sui_backend_test",
SUIID: r.opts.SUIID,
Template: r.opts.Template,
Summary: &TestSummary{},
Pages: make([]*PageReport, 0, len(pageInfos)),
Metadata: &TestMetadata{StartedAt: startTime},
}
for _, pi := range pageInfos {
pageReport, stop := r.runPageTests(pi, sid, report.Summary)
if pageReport != nil {
report.Pages = append(report.Pages, pageReport)
}
if stop {
break
}
}
report.Summary.DurationMs = time.Since(startTime).Milliseconds()
report.Metadata.CompletedAt = time.Now()
if r.opts.JSON {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
enc.Encode(report)
} else {
r.printSummary(report.Summary, time.Since(startTime))
}
return report, nil
}
// discoverPageTests walks template pages and finds those with backend_test.ts files
func (r *Runner) discoverPageTests() ([]*PageTestInfo, error) {
pages, err := r.tmpl.Pages()
if err != nil {
return nil, fmt.Errorf("listing pages: %w", err)
}
var infos []*PageTestInfo
for _, page := range pages {
pg := page.Get()
if pg == nil {
continue
}
if r.opts.Page != "" && !strings.Contains(pg.Route, r.opts.Page) {
continue
}
dir := pg.Path
testFile := filepath.Join(dir, pg.Name+".backend_test.ts")
exists, _ := application.App.Exists(testFile)
if !exists {
testFile = filepath.Join(dir, pg.Name+".backend_test.js")
exists, _ = application.App.Exists(testFile)
}
if !exists {
continue
}
backendFile := filepath.Join(dir, pg.Name+".backend.ts")
if ex, _ := application.App.Exists(backendFile); !ex {
backendFile = filepath.Join(dir, pg.Name+".backend.js")
}
prefix := "Api"
cfgFile := filepath.Join(dir, pg.Name+".config")
cfg, _ := LoadPageConfig(cfgFile)
if cfg == nil {
cfgFile = filepath.Join(dir, pg.Name+".cfg")
cfg, _ = LoadPageConfig(cfgFile)
}
if cfg != nil && cfg.API != nil && cfg.API.Prefix != "" {
prefix = cfg.API.Prefix
}
infos = append(infos, &PageTestInfo{
Route: pg.Route,
Name: pg.Name,
BackendFile: backendFile,
TestFile: testFile,
Prefix: prefix,
})
}
return infos, nil
}
// discoverTests scans a test file for exported Test* functions
func discoverTests(testFile string) ([]*TestCase, error) {
content, err := application.App.Read(testFile)
if err != nil {
return nil, fmt.Errorf("reading %s: %w", testFile, err)
}
var tests []*TestCase
for _, line := range strings.Split(string(content), "\n") {
line = strings.TrimSpace(line)
if !strings.Contains(line, "function Test") {
continue
}
name := extractFuncName(line)
if name != "" && strings.HasPrefix(name, "Test") {
tests = append(tests, &TestCase{Name: name, Function: name})
}
}
return tests, nil
}
func extractFuncName(line string) string {
line = strings.TrimPrefix(line, "export ")
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "function ") {
return ""
}
line = strings.TrimPrefix(line, "function ")
idx := strings.Index(line, "(")
if idx == -1 {
return ""
}
return strings.TrimSpace(line[:idx])
}
// filterTests applies --run regex filter
func filterTests(tests []*TestCase, pattern string) ([]*TestCase, error) {
re, err := regexp.Compile(pattern)
if err != nil {
return nil, err
}
var filtered []*TestCase
for _, tc := range tests {
if re.MatchString(tc.Name) {
filtered = append(filtered, tc)
}
}
return filtered, nil
}
// runPageTests loads and executes all tests for one page
func (r *Runner) runPageTests(pi *PageTestInfo, sid string, summary *TestSummary) (*PageReport, bool) {
tests, err := discoverTests(pi.TestFile)
if err != nil {
if !r.opts.JSON {
color.Red(" Error discovering tests in %s: %v\n", pi.TestFile, err)
}
return nil, false
}
if r.opts.Run != "" {
tests, err = filterTests(tests, r.opts.Run)
if err != nil {
if !r.opts.JSON {
color.Red(" Invalid --run pattern: %v\n", err)
}
return nil, false
}
}
if len(tests) == 0 {
return nil, false
}
if !r.opts.JSON {
color.New(color.FgWhite, color.Bold).Printf("--- %s ---\n", pi.Route)
}
// The backend script is loaded via core.LoadScript which finds *.backend.ts
// from the page .sui path. We derive the .sui path from the backend file path.
suiPath := strings.TrimSuffix(strings.TrimSuffix(pi.BackendFile, ".backend.ts"), ".backend.js")
script, err := core.LoadScript(suiPath, true)
if err != nil {
if !r.opts.JSON {
color.Red(" Failed to load backend script: %v\n", err)
}
return nil, false
}
if script == nil {
if !r.opts.JSON {
color.Yellow(" No backend script found for %s\n", pi.Route)
}
return nil, false
}
// Load the test file into V8
testScriptID := "sui-test." + strings.ReplaceAll(strings.Trim(pi.Route, "/"), "/", ".")
_, err = v8.Load(pi.TestFile, testScriptID)
if err != nil {
if !r.opts.JSON {
color.Red(" Failed to load test script: %v\n", err)
}
return nil, false
}
// Only count tests after scripts are successfully loaded
summary.Total += len(tests)
pageReport := &PageReport{Route: pi.Route, Results: make([]*TestResult, 0, len(tests))}
stop := false
for _, tc := range tests {
result := r.executeTest(tc, script, testScriptID, pi.Prefix, sid)
pageReport.Results = append(pageReport.Results, result)
switch result.Status {
case "passed":
summary.Passed++
case "failed", "error":
summary.Failed++
case "skipped":
summary.Skipped++
}
if !r.opts.JSON {
r.printTestResult(tc.Name, result)
}
if r.opts.FailFast && (result.Status == "failed" || result.Status == "error") {
stop = true
break
}
}
if !r.opts.JSON {
fmt.Println()
}
return pageReport, stop
}
// executeTest runs a single Test* function in V8
func (r *Runner) executeTest(tc *TestCase, script *core.Script, testScriptID, prefix, sid string) (result *TestResult) {
startTime := time.Now()
result = &TestResult{Name: tc.Name, Status: "passed"}
defer func() {
if rec := recover(); rec != nil {
result.Status = "error"
result.Error = fmt.Sprintf("panic: %v", rec)
}
result.DurationMs = time.Since(startTime).Milliseconds()
}()
testScript, ok := v8.Scripts[testScriptID]
if !ok {
result.Status = "error"
result.Error = fmt.Sprintf("test script %q not loaded", testScriptID)
return
}
scriptCtx, err := testScript.NewContext(sid, nil)
if err != nil {
result.Status = "error"
result.Error = fmt.Sprintf("create context: %v", err)
return
}
defer scriptCtx.Close()
v8ctx := scriptCtx.Context
// Set share data for Process calls within tests
err = bridge.SetShareData(v8ctx, v8ctx.Global(), &bridge.Share{
Sid: sid,
Root: false,
Global: nil,
})
if err != nil {
result.Status = "error"
result.Error = fmt.Sprintf("set share data: %v", err)
return
}
testingT := agenttest.NewTestingT(tc.Name)
tObj, err := agenttest.NewTestingTObject(v8ctx, testingT)
if err != nil {
result.Status = "error"
result.Error = fmt.Sprintf("create testing.T: %v", err)
return
}
suiCtx := NewSUITestContext(script, prefix, sid)
ctxObj, err := NewSUITestContextObject(v8ctx, suiCtx)
if err != nil {
result.Status = "error"
result.Error = fmt.Sprintf("create SUIContext: %v", err)
return
}
global := v8ctx.Global()
fnValue, err := global.Get(tc.Function)
if err != nil {
result.Status = "error"
result.Error = fmt.Sprintf("get function %s: %v", tc.Function, err)
return
}
if !fnValue.IsFunction() {
result.Status = "error"
result.Error = fmt.Sprintf("%s is not a function", tc.Function)
return
}
fn, err := fnValue.AsFunction()
if err != nil {
result.Status = "error"
result.Error = fmt.Sprintf("as function: %v", err)
return
}
_, err = fn.Call(global, tObj, ctxObj)
if err != nil {
if testingT.Failed() {
goto collectResult
}
result.Status = "error"
result.Error = fmt.Sprintf("call error: %v", err)
return
}
collectResult:
result.Logs = testingT.Logs()
if testingT.Skipped() {
result.Status = "skipped"
return
}
if testingT.Failed() {
result.Status = "failed"
errors := testingT.Errors()
if len(errors) > 0 {
result.Error = errors[0]
}
if info := testingT.AssertionInfo(); info != nil {
result.Assertion = &AssertionInfo{
Type: info.Type,
Expected: info.Expected,
Actual: info.Actual,
Message: info.Message,
}
}
return
}
return
}
func (r *Runner) printHeader(sid string) {
fmt.Println(color.WhiteString("-----------------------"))
fmt.Println(color.WhiteString("SUI Backend Test"))
fmt.Printf(color.WhiteString(" SUI: %s\n"), r.opts.SUIID)
fmt.Printf(color.WhiteString(" Template: %s\n"), r.opts.Template)
if r.opts.Data != "" {
fmt.Printf(color.WhiteString(" Session: %s\n"), r.opts.Data)
}
fmt.Println(color.WhiteString("-----------------------"))
}
func (r *Runner) printTestResult(name string, result *TestResult) {
switch result.Status {
case "passed":
color.Green(" %-40s PASS (%dms)\n", name, result.DurationMs)
case "failed":
color.Red(" %-40s FAIL (%dms)\n", name, result.DurationMs)
if result.Error != "" {
color.Red(" %s\n", result.Error)
}
case "error":
color.Red(" %-40s ERROR (%dms)\n", name, result.DurationMs)
if result.Error != "" {
color.Red(" %s\n", result.Error)
}
case "skipped":
color.Yellow(" %-40s SKIP (%dms)\n", name, result.DurationMs)
}
}
func (r *Runner) printSummary(s *TestSummary, elapsed time.Duration) {
passColor := color.New(color.FgGreen)
failColor := color.New(color.FgRed)
fmt.Print("RESULTS: ")
passColor.Printf("%d passed", s.Passed)
fmt.Print(", ")
if s.Failed > 0 {
failColor.Printf("%d failed", s.Failed)
} else {
fmt.Printf("%d failed", s.Failed)
}
fmt.Printf(", %d skipped (%s)\n", s.Skipped, elapsed.Truncate(time.Millisecond))
}

362
sui/test/runner_test.go Normal file
View file

@ -0,0 +1,362 @@
package test_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/gou/application"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/engine"
"github.com/yaoapp/yao/sui/core"
suitest "github.com/yaoapp/yao/sui/test"
"github.com/yaoapp/yao/test"
)
func prepare(t *testing.T) {
t.Helper()
test.Prepare(t, config.Conf)
_, err := engine.Load(config.Conf, engine.LoadOption{Action: "sui.test"})
require.NoError(t, err)
}
func requireAgentSUI(t *testing.T) {
t.Helper()
_, has := core.SUIs["agent"]
if !has {
t.Skip("no 'agent' SUI loaded")
}
}
// --- happy path (dashboard page: 4 pass) ---
func TestDiscoverPageTests(t *testing.T) {
prepare(t)
defer test.Clean()
requireAgentSUI(t)
opts := &suitest.Options{
SUIID: "agent",
Template: "agent",
Page: "tests.sui-pages/dashboard",
JSON: true,
}
runner, err := suitest.NewRunner(opts)
require.NoError(t, err)
report, err := runner.Run()
require.NoError(t, err)
require.NotNil(t, report)
assert.Greater(t, report.Summary.Total, 0)
assert.Greater(t, len(report.Pages), 0)
}
func TestRunnerExecuteTests(t *testing.T) {
prepare(t)
defer test.Clean()
requireAgentSUI(t)
opts := &suitest.Options{
SUIID: "agent",
Template: "agent",
Page: "tests.sui-pages/dashboard",
JSON: true,
}
runner, err := suitest.NewRunner(opts)
require.NoError(t, err)
report, err := runner.Run()
require.NoError(t, err)
assert.Equal(t, "sui_backend_test", report.Type)
assert.False(t, report.Metadata.StartedAt.IsZero())
assert.False(t, report.Metadata.CompletedAt.IsZero())
assert.Equal(t, 4, report.Summary.Total)
assert.Equal(t, 4, report.Summary.Passed)
assert.Equal(t, 0, report.Summary.Failed)
assert.False(t, report.HasFailures())
}
func TestRunnerWithRunFilter(t *testing.T) {
prepare(t)
defer test.Clean()
requireAgentSUI(t)
opts := &suitest.Options{
SUIID: "agent",
Template: "agent",
Page: "tests.sui-pages/dashboard",
Run: "TestGetDashboard",
JSON: true,
}
runner, err := suitest.NewRunner(opts)
require.NoError(t, err)
report, err := runner.Run()
require.NoError(t, err)
assert.Equal(t, 1, report.Summary.Total)
assert.Equal(t, 1, report.Summary.Passed)
}
func TestRunnerFailFast(t *testing.T) {
prepare(t)
defer test.Clean()
requireAgentSUI(t)
opts := &suitest.Options{
SUIID: "agent",
Template: "agent",
Page: "tests.sui-pages/dashboard",
FailFast: true,
JSON: true,
}
runner, err := suitest.NewRunner(opts)
require.NoError(t, err)
report, err := runner.Run()
require.NoError(t, err)
assert.Equal(t, 0, report.Summary.Failed)
}
// --- errors page: covers fail, skip, setAuthorized, reset ---
func TestRunnerWithFailAndSkip(t *testing.T) {
prepare(t)
defer test.Clean()
requireAgentSUI(t)
opts := &suitest.Options{
SUIID: "agent",
Template: "agent",
Page: "tests.sui-pages/errors",
JSON: true,
}
runner, err := suitest.NewRunner(opts)
require.NoError(t, err)
report, err := runner.Run()
require.NoError(t, err)
require.NotNil(t, report)
assert.Equal(t, 6, report.Summary.Total)
assert.Equal(t, 2, report.Summary.Failed, "TestAlwaysFail + TestRuntimeError should fail")
assert.Equal(t, 1, report.Summary.Skipped, "TestAlwaysSkip should be skipped")
assert.Equal(t, 3, report.Summary.Passed)
assert.True(t, report.HasFailures())
page := report.Pages[0]
for _, r := range page.Results {
switch r.Name {
case "TestAlwaysFail":
assert.Equal(t, "failed", r.Status)
assert.NotEmpty(t, r.Error)
case "TestAlwaysSkip":
assert.Equal(t, "skipped", r.Status)
case "TestSetAuthorizedAndReset":
assert.Equal(t, "passed", r.Status)
case "TestCallWithRequestRender":
assert.Equal(t, "passed", r.Status)
case "TestCallNonExistentMethod":
assert.Equal(t, "passed", r.Status)
case "TestRuntimeError":
assert.Equal(t, "error", r.Status)
assert.Contains(t, r.Error, "deliberate runtime error")
}
}
}
func TestRunnerFailFastStopsOnError(t *testing.T) {
prepare(t)
defer test.Clean()
requireAgentSUI(t)
opts := &suitest.Options{
SUIID: "agent",
Template: "agent",
Page: "tests.sui-pages/errors",
FailFast: true,
JSON: true,
}
runner, err := suitest.NewRunner(opts)
require.NoError(t, err)
report, err := runner.Run()
require.NoError(t, err)
assert.Equal(t, 1, report.Summary.Failed)
// fail-fast: executed only up to the first failure
executed := report.Summary.Passed + report.Summary.Failed + report.Summary.Skipped
assert.Less(t, executed, report.Summary.Total, "fail-fast should stop before running all tests")
}
// --- verbose (non-JSON) output: covers printHeader, printTestResult, printSummary ---
func TestRunnerVerboseAllPass(t *testing.T) {
prepare(t)
defer test.Clean()
requireAgentSUI(t)
opts := &suitest.Options{
SUIID: "agent",
Template: "agent",
Page: "tests.sui-pages/dashboard",
Verbose: true,
JSON: false,
}
runner, err := suitest.NewRunner(opts)
require.NoError(t, err)
report, err := runner.Run()
require.NoError(t, err)
assert.Equal(t, 4, report.Summary.Passed)
}
func TestRunnerVerboseWithFailures(t *testing.T) {
prepare(t)
defer test.Clean()
requireAgentSUI(t)
opts := &suitest.Options{
SUIID: "agent",
Template: "agent",
Page: "tests.sui-pages/errors",
Verbose: true,
JSON: false,
}
runner, err := suitest.NewRunner(opts)
require.NoError(t, err)
report, err := runner.Run()
require.NoError(t, err)
assert.Greater(t, report.Summary.Failed, 0)
}
func TestRunnerVerboseWithData(t *testing.T) {
prepare(t)
defer test.Clean()
requireAgentSUI(t)
opts := &suitest.Options{
SUIID: "agent",
Template: "agent",
Page: "tests.sui-pages/dashboard",
Data: `{"key":"value"}`,
Verbose: true,
JSON: false,
}
runner, err := suitest.NewRunner(opts)
require.NoError(t, err)
report, err := runner.Run()
require.NoError(t, err)
assert.Equal(t, 4, report.Summary.Passed)
}
// --- error paths ---
func TestNewRunnerInvalidSUI(t *testing.T) {
prepare(t)
defer test.Clean()
_, err := suitest.NewRunner(&suitest.Options{SUIID: "nonexistent", Template: "default"})
assert.Error(t, err)
assert.Contains(t, err.Error(), "not found")
}
func TestNewRunnerInvalidTemplate(t *testing.T) {
prepare(t)
defer test.Clean()
requireAgentSUI(t)
_, err := suitest.NewRunner(&suitest.Options{SUIID: "agent", Template: "nonexistent-tmpl"})
assert.Error(t, err)
}
func TestRunnerNoMatchingPage(t *testing.T) {
prepare(t)
defer test.Clean()
requireAgentSUI(t)
runner, err := suitest.NewRunner(&suitest.Options{
SUIID: "agent", Template: "agent", Page: "xyz-no-match", JSON: true,
})
require.NoError(t, err)
report, err := runner.Run()
require.NoError(t, err)
assert.Equal(t, 0, report.Summary.Total)
}
func TestRunnerNoMatchingRun(t *testing.T) {
prepare(t)
defer test.Clean()
requireAgentSUI(t)
runner, err := suitest.NewRunner(&suitest.Options{
SUIID: "agent", Template: "agent", Page: "tests.sui-pages/dashboard",
Run: "NoSuchFunction", JSON: true,
})
require.NoError(t, err)
report, err := runner.Run()
require.NoError(t, err)
assert.Equal(t, 0, report.Summary.Total)
}
func TestRunnerVerboseNoTestFiles(t *testing.T) {
prepare(t)
defer test.Clean()
requireAgentSUI(t)
runner, err := suitest.NewRunner(&suitest.Options{
SUIID: "agent", Template: "agent", Page: "xyz-no-match", JSON: false,
})
require.NoError(t, err)
report, err := runner.Run()
require.NoError(t, err)
assert.Equal(t, 0, report.Summary.Total)
}
// --- types ---
func TestLoadPageConfig(t *testing.T) {
prepare(t)
defer test.Clean()
cfgFile := "assistants/tests/sui-pages/pages/dashboard/dashboard.config"
exists, _ := application.App.Exists(cfgFile)
if !exists {
t.Skipf("test config %s not found", cfgFile)
}
cfg, err := suitest.LoadPageConfig(cfgFile)
assert.NoError(t, err)
assert.NotNil(t, cfg)
assert.Equal(t, "Test Dashboard", cfg.Title)
}
func TestLoadPageConfigNotExist(t *testing.T) {
prepare(t)
defer test.Clean()
cfg, err := suitest.LoadPageConfig("nonexistent/path.config")
assert.NoError(t, err)
assert.Nil(t, cfg)
}
func TestReportHasFailures(t *testing.T) {
r := &suitest.Report{Summary: &suitest.TestSummary{Failed: 1}}
assert.True(t, r.HasFailures())
r.Summary.Failed = 0
assert.False(t, r.HasFailures())
}

110
sui/test/types.go Normal file
View file

@ -0,0 +1,110 @@
package test
import (
"time"
jsoniter "github.com/json-iterator/go"
"github.com/yaoapp/gou/application"
"github.com/yaoapp/yao/sui/core"
)
// Options holds configuration for a SUI backend test run
type Options struct {
SUIID string `json:"sui_id"`
Template string `json:"template"`
Page string `json:"page,omitempty"`
Run string `json:"run,omitempty"`
Data string `json:"data,omitempty"`
Verbose bool `json:"verbose,omitempty"`
JSON bool `json:"json,omitempty"`
FailFast bool `json:"fail_fast,omitempty"`
Timeout time.Duration `json:"timeout,omitempty"`
}
// PageTestInfo describes a page that has backend tests
type PageTestInfo struct {
Route string `json:"route"`
Name string `json:"name"`
BackendFile string `json:"backend_file"`
TestFile string `json:"test_file"`
PageConfigFile string `json:"page_config_file,omitempty"`
Prefix string `json:"prefix"`
}
// TestCase represents a single test function discovered in a backend_test.ts file
type TestCase struct {
Name string `json:"name"`
Function string `json:"function"`
}
// TestResult represents the outcome of a single test function
type TestResult struct {
Name string `json:"name"`
Status string `json:"status"`
DurationMs int64 `json:"duration_ms"`
Error string `json:"error,omitempty"`
Assertion *AssertionInfo `json:"assertion,omitempty"`
Logs []string `json:"logs,omitempty"`
}
// AssertionInfo contains details about an assertion failure
type AssertionInfo struct {
Type string `json:"type"`
Expected interface{} `json:"expected,omitempty"`
Actual interface{} `json:"actual,omitempty"`
Message string `json:"message,omitempty"`
}
// TestSummary contains aggregated statistics
type TestSummary struct {
Total int `json:"total"`
Passed int `json:"passed"`
Failed int `json:"failed"`
Skipped int `json:"skipped"`
DurationMs int64 `json:"duration_ms"`
}
// Report represents the complete test report for yao sui test
type Report struct {
Type string `json:"type"`
SUIID string `json:"sui_id"`
Template string `json:"template"`
Summary *TestSummary `json:"summary"`
Pages []*PageReport `json:"pages"`
Metadata *TestMetadata `json:"metadata"`
}
// PageReport contains results for a single page
type PageReport struct {
Route string `json:"route"`
Results []*TestResult `json:"results"`
}
// TestMetadata contains metadata about the test run
type TestMetadata struct {
StartedAt time.Time `json:"started_at"`
CompletedAt time.Time `json:"completed_at"`
}
// HasFailures returns true if any tests failed
func (r *Report) HasFailures() bool {
return r.Summary.Failed > 0
}
// LoadPageConfig reads and parses a .cfg file for a SUI page
func LoadPageConfig(file string) (*core.PageConfig, error) {
if exist, _ := application.App.Exists(file); !exist {
return nil, nil
}
source, err := application.App.Read(file)
if err != nil {
return nil, err
}
cfg := core.PageConfig{}
if err := jsoniter.Unmarshal(source, &cfg); err != nil {
return nil, err
}
return &cfg, nil
}