yao/sui/test/runner.go
Max a1e745ec90 chore(tests): update model versions in Anthropic tests and add Test command to SUI
- Updated testConnectorID comment and model version in TestAnthropicStreamRetry to reflect the latest model (Claude Haiku 4.5).
- Added Test command to the SUI command set with associated flags for improved testing capabilities.
2026-04-24 08:45:19 +08:00

456 lines
11 KiB
Go

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))
}