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
|
name: Release Linux
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- "v*"
|
- "v*"
|
||||||
|
|
@ -174,16 +173,49 @@ jobs:
|
||||||
cp "artifacts/yao-${VERSION}-linux-amd64" "release/yao-${VERSION}-linux-amd64-dev"
|
cp "artifacts/yao-${VERSION}-linux-amd64" "release/yao-${VERSION}-linux-amd64-dev"
|
||||||
cp "artifacts/yao-${VERSION}-linux-arm64" "release/yao-${VERSION}-linux-arm64-dev"
|
cp "artifacts/yao-${VERSION}-linux-arm64" "release/yao-${VERSION}-linux-arm64-dev"
|
||||||
chmod +x release/yao-*
|
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/
|
ls -lh release/
|
||||||
|
|
||||||
- name: Create GitHub Release
|
- name: Wait for Draft Release
|
||||||
uses: softprops/action-gh-release@v2
|
env:
|
||||||
with:
|
GH_TOKEN: ${{ github.token }}
|
||||||
tag_name: ${{ steps.version.outputs.tag }}
|
run: |
|
||||||
name: Yao v${{ steps.version.outputs.version }}
|
TAG="${{ steps.version.outputs.tag }}"
|
||||||
files: release/*
|
for i in $(seq 1 30); do
|
||||||
generate_release_notes: true
|
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" &>/dev/null; then
|
||||||
make_latest: false
|
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
|
- name: Upload Linux binaries to R2
|
||||||
env:
|
env:
|
||||||
|
|
|
||||||
44
.github/workflows/release-macos.yml
vendored
44
.github/workflows/release-macos.yml
vendored
|
|
@ -1,7 +1,6 @@
|
||||||
name: Release macOS
|
name: Release macOS
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- "v*"
|
- "v*"
|
||||||
|
|
@ -280,13 +279,42 @@ jobs:
|
||||||
chmod +x release/yao-*
|
chmod +x release/yao-*
|
||||||
ls -lh release/
|
ls -lh release/
|
||||||
|
|
||||||
- name: Create GitHub Release
|
- name: Wait for Draft Release
|
||||||
uses: softprops/action-gh-release@v2
|
env:
|
||||||
with:
|
GH_TOKEN: ${{ github.token }}
|
||||||
tag_name: ${{ steps.version.outputs.tag }}
|
run: |
|
||||||
name: Yao v${{ steps.version.outputs.version }}
|
TAG="${{ steps.version.outputs.tag }}"
|
||||||
files: release/*
|
for i in $(seq 1 30); do
|
||||||
generate_release_notes: true
|
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
|
- name: Upload macOS binaries to R2
|
||||||
env:
|
env:
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import (
|
||||||
"github.com/yaoapp/yao/test"
|
"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"
|
const testConnectorID = "claude.haiku-3_0"
|
||||||
|
|
||||||
// TestAnthropicStreamBasic tests basic streaming completion with Anthropic API
|
// TestAnthropicStreamBasic tests basic streaming completion with Anthropic API
|
||||||
|
|
@ -214,7 +214,7 @@ func TestAnthropicStreamRetry(t *testing.T) {
|
||||||
connDSL := `{
|
connDSL := `{
|
||||||
"type": "anthropic",
|
"type": "anthropic",
|
||||||
"options": {
|
"options": {
|
||||||
"model": "claude-3-5-haiku-20241022",
|
"model": "claude-haiku-4-5-20251001",
|
||||||
"key": "sk-ant-invalid-key-should-fail"
|
"key": "sk-ant-invalid-key-should-fail"
|
||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
|
|
|
||||||
|
|
@ -187,6 +187,7 @@ func init() {
|
||||||
suiCmd.AddCommand(sui.WatchCmd)
|
suiCmd.AddCommand(sui.WatchCmd)
|
||||||
suiCmd.AddCommand(sui.BuildCmd)
|
suiCmd.AddCommand(sui.BuildCmd)
|
||||||
suiCmd.AddCommand(sui.TransCmd)
|
suiCmd.AddCommand(sui.TransCmd)
|
||||||
|
suiCmd.AddCommand(sui.TestCmd)
|
||||||
|
|
||||||
// Agent
|
// Agent
|
||||||
agentCmd.AddCommand(agent.TestCmd)
|
agentCmd.AddCommand(agent.TestCmd)
|
||||||
|
|
|
||||||
|
|
@ -11,4 +11,12 @@ func init() {
|
||||||
TransCmd.PersistentFlags().StringVarP(&data, "data", "d", "::{}", L("Session Data"))
|
TransCmd.PersistentFlags().StringVarP(&data, "data", "d", "::{}", L("Session Data"))
|
||||||
TransCmd.PersistentFlags().BoolVarP(&debug, "debug", "D", false, L("Debug mode"))
|
TransCmd.PersistentFlags().BoolVarP(&debug, "debug", "D", false, L("Debug mode"))
|
||||||
TransCmd.PersistentFlags().StringVarP(&locales, "locales", "l", "", L("Locales, separated by commas"))
|
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