Merge pull request #1526 from trheyi/main
feat: sui backend script test and fix streamline release process
This commit is contained in:
commit
142c53889e
12 changed files with 1448 additions and 19 deletions
25
.github/workflows/create-release.yml
vendored
Normal file
25
.github/workflows/create-release.yml
vendored
Normal 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
|
||||
50
.github/workflows/release-linux.yml
vendored
50
.github/workflows/release-linux.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
44
.github/workflows/release-macos.yml
vendored
44
.github/workflows/release-macos.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
91
cmd/sui/test.go
Normal 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
206
sui/test/context.go
Normal 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
110
sui/test/helpers_test.go
Normal 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
456
sui/test/runner.go
Normal 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
362
sui/test/runner_test.go
Normal 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
110
sui/test/types.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue