yao/agent/test/script.go
Max 4c80c3ffa0 Enhance Script Runner Error Handling and Fail-Fast Logic
- Updated the ScriptRunner to treat both StatusFailed and StatusError as failures, improving failure reporting in test results.
- Modified the fail-fast logic to stop execution on both failure statuses, ensuring quicker feedback during test runs.
- Enhanced error handling in executeTestFunction to recover from panics and provide clearer error messages for JavaScript exceptions, improving test reliability and debugging.
2025-12-22 18:41:13 +08:00

505 lines
13 KiB
Go

package test
import (
"fmt"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/yaoapp/gou/application"
v8 "github.com/yaoapp/gou/runtime/v8"
"github.com/yaoapp/gou/runtime/v8/bridge"
"github.com/yaoapp/yao/agent/context"
"rogchap.com/v8go"
)
// ScriptRunner executes script tests
type ScriptRunner struct {
opts *Options
output *OutputWriter
}
// NewScriptRunner creates a new script test runner
func NewScriptRunner(opts *Options) *ScriptRunner {
return &ScriptRunner{
opts: opts,
output: NewOutputWriter(opts.Verbose),
}
}
// ResolveScript resolves the script path from scripts.xxx.yyy format
func ResolveScript(input string) (*ScriptInfo, error) {
// Remove "scripts." prefix
path := strings.TrimPrefix(input, "scripts.")
// Split into parts: "expense.setup" -> ["expense", "setup"]
parts := strings.Split(path, ".")
if len(parts) < 2 {
return nil, fmt.Errorf("invalid script path: %s (expected format: scripts.assistant.module)", input)
}
// Build paths
// assistantDir: expense
// moduleName: setup
// scriptPath: expense/src/setup.ts (or assistants/expense/src/setup.ts)
// testPath: expense/src/setup_test.ts
assistantDir := parts[0]
moduleName := parts[1]
// Try different path patterns
basePaths := []string{
filepath.Join("assistants", assistantDir, "src"),
filepath.Join(assistantDir, "src"),
}
var scriptPath, testPath string
for _, basePath := range basePaths {
// Check for TypeScript files first, then JavaScript
for _, ext := range []string{".ts", ".js"} {
candidateScript := filepath.Join(basePath, moduleName+ext)
candidateTest := filepath.Join(basePath, moduleName+"_test"+ext)
// Check if test file exists
exists, err := application.App.Exists(candidateTest)
if err == nil && exists {
scriptPath = candidateScript
testPath = candidateTest
break
}
}
if testPath != "" {
break
}
}
if testPath == "" {
return nil, fmt.Errorf("test file not found for %s (tried: %s)", input, strings.Join(basePaths, ", "))
}
return &ScriptInfo{
ID: input,
Assistant: assistantDir,
Module: moduleName,
ScriptPath: scriptPath,
TestPath: testPath,
}, nil
}
// DiscoverTests finds all Test* functions in the script
func DiscoverTests(scriptPath string) ([]*ScriptTestCase, error) {
// Read the script file
content, err := application.App.Read(scriptPath)
if err != nil {
return nil, fmt.Errorf("failed to read script: %w", err)
}
// Parse the script to find Test* functions
// We use a simple regex-like approach to find function declarations
tests := make([]*ScriptTestCase, 0)
lines := strings.Split(string(content), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
// Match function declarations: function TestXxx( or export function TestXxx(
if strings.Contains(line, "function Test") {
// Extract function name
name := extractFunctionName(line)
if name != "" && strings.HasPrefix(name, "Test") {
tests = append(tests, &ScriptTestCase{
Name: name,
Function: name,
})
}
}
}
return tests, nil
}
// extractFunctionName extracts the function name from a line
func extractFunctionName(line string) string {
// Remove "export" prefix if present
line = strings.TrimPrefix(line, "export ")
line = strings.TrimSpace(line)
// Match "function Name("
if !strings.HasPrefix(line, "function ") {
return ""
}
line = strings.TrimPrefix(line, "function ")
// Find the opening parenthesis
idx := strings.Index(line, "(")
if idx == -1 {
return ""
}
return strings.TrimSpace(line[:idx])
}
// filterTests filters test cases by a regex pattern (similar to go test -run)
func (r *ScriptRunner) filterTests(tests []*ScriptTestCase, pattern string) ([]*ScriptTestCase, error) {
re, err := regexp.Compile(pattern)
if err != nil {
return nil, err
}
filtered := make([]*ScriptTestCase, 0)
for _, tc := range tests {
if re.MatchString(tc.Name) {
filtered = append(filtered, tc)
}
}
return filtered, nil
}
// Run executes all script tests and returns a report
func (r *ScriptRunner) Run() (*ScriptTestReport, error) {
startTime := time.Now()
// Resolve script
scriptInfo, err := ResolveScript(r.opts.Input)
if err != nil {
return nil, err
}
// Print header
r.output.Header("Script Test")
r.output.Info("Script: %s", scriptInfo.TestPath)
// Discover tests
tests, err := DiscoverTests(scriptInfo.TestPath)
if err != nil {
return nil, err
}
// Filter tests by -run pattern if specified
if r.opts.Run != "" {
tests, err = r.filterTests(tests, r.opts.Run)
if err != nil {
return nil, fmt.Errorf("invalid -run pattern: %w", err)
}
r.output.Info("Tests: %d functions (filtered by: %s)", len(tests), r.opts.Run)
} else {
r.output.Info("Tests: %d functions", len(tests))
}
if len(tests) == 0 {
r.output.Warning("No tests to run")
}
// Load context config if specified
var ctxConfig *ContextConfig
if r.opts.ContextFile != "" {
var err error
ctxConfig, err = LoadContextConfig(r.opts.ContextFile)
if err != nil {
return nil, fmt.Errorf("failed to load context file: %w", err)
}
r.output.Info("Context: %s", r.opts.ContextFile)
}
// Create environment with optional context config
var env *Environment
if ctxConfig != nil {
env = NewEnvironmentWithContext(r.opts.UserID, r.opts.TeamID, ctxConfig)
} else {
env = NewEnvironment(r.opts.UserID, r.opts.TeamID)
}
r.output.Info("User: %s", env.UserID)
r.output.Info("Team: %s", env.TeamID)
// Load all scripts from src directory (including the test file)
// This ensures imports can be resolved properly
srcDir := filepath.Dir(scriptInfo.TestPath)
loadedCount, err := r.loadAllScripts(srcDir)
if err != nil {
return nil, fmt.Errorf("failed to load scripts: %w", err)
}
r.output.Info("Loaded: %d scripts", loadedCount)
// Create report
report := &ScriptTestReport{
Type: "script_test",
Script: scriptInfo.ID,
ScriptPath: scriptInfo.TestPath,
Summary: &ScriptTestSummary{Total: len(tests)},
Environment: env,
Results: make([]*ScriptTestResult, 0, len(tests)),
Metadata: &ScriptTestMetadata{
StartedAt: startTime,
},
}
// Run tests
r.output.SubHeader("Running Tests")
for _, tc := range tests {
result := r.runScriptTest(tc, scriptInfo, env)
report.Results = append(report.Results, result)
// Update summary
switch result.Status {
case StatusPassed:
report.Summary.Passed++
case StatusFailed, StatusError:
// Both Failed and Error count as failures
report.Summary.Failed++
case StatusSkipped:
report.Summary.Skipped++
}
// Check fail-fast (stop on both Failed and Error)
if r.opts.FailFast && (result.Status == StatusFailed || result.Status == StatusError) {
r.output.Warning("Stopping due to --fail-fast")
break
}
}
// Complete report
report.Summary.DurationMs = time.Since(startTime).Milliseconds()
report.Metadata.CompletedAt = time.Now()
// Print summary
r.output.ScriptTestSummary(report.Summary, time.Since(startTime))
return report, nil
}
// runScriptTest runs a single script test function
func (r *ScriptRunner) runScriptTest(tc *ScriptTestCase, scriptInfo *ScriptInfo, env *Environment) *ScriptTestResult {
r.output.TestStart(tc.Name, "", 1)
startTime := time.Now()
result := &ScriptTestResult{
Name: tc.Name,
Status: StatusPassed,
}
// Create testing.T object
testingT := NewTestingT(tc.Name)
// Create agent context
chatID := fmt.Sprintf("script-test-%s", tc.Name)
agentCtx := NewTestContext(chatID, scriptInfo.Assistant, env)
defer agentCtx.Release()
// Execute the test function
err := r.executeTestFunction(tc, scriptInfo, testingT, agentCtx)
duration := time.Since(startTime)
result.DurationMs = duration.Milliseconds()
result.Logs = testingT.Logs()
if err != nil {
result.Status = StatusError
result.Error = err.Error()
r.output.TestResult(result.Status, duration)
r.output.TestError(result.Error)
return result
}
if testingT.Skipped() {
result.Status = StatusSkipped
r.output.TestResult(result.Status, duration)
return result
}
if testingT.Failed() {
result.Status = StatusFailed
errors := testingT.Errors()
if len(errors) > 0 {
result.Error = errors[0]
}
result.Assertion = testingT.AssertionInfo()
r.output.TestResult(result.Status, duration)
r.output.TestError(result.Error)
return result
}
r.output.TestResult(result.Status, duration)
return result
}
// loadAllScripts loads all scripts from the src directory
// This ensures that imports can be resolved properly
func (r *ScriptRunner) loadAllScripts(srcDir string) (int, error) {
count := 0
// Check if src directory exists
exists, err := application.App.Exists(srcDir)
if err != nil {
return 0, err
}
if !exists {
return 0, fmt.Errorf("src directory not found: %s", srcDir)
}
// Walk through src directory to find all script files
exts := []string{"*.ts", "*.js"}
err = application.App.Walk(srcDir, func(root, file string, isdir bool) error {
if isdir {
return nil
}
// Get relative path
relPath := strings.TrimPrefix(file, root+"/")
// Generate script ID from file path
scriptID := generateTestScriptID(file, root)
// Load the script
_, err := v8.Load(file, scriptID)
if err != nil {
// Log warning but continue loading other scripts
if r.opts.Verbose {
r.output.Warning("Failed to load %s: %v", relPath, err)
}
return nil
}
count++
if r.opts.Verbose {
r.output.Verbose("Loaded: %s", relPath)
}
return nil
}, exts...)
if err != nil {
return count, fmt.Errorf("failed to walk src directory: %w", err)
}
return count, nil
}
// generateTestScriptID generates a script ID from file path for testing
func generateTestScriptID(filePath string, srcDir string) string {
// Normalize path separators
filePath = filepath.ToSlash(filePath)
srcDir = filepath.ToSlash(srcDir)
// Remove src directory prefix
relPath := strings.TrimPrefix(filePath, srcDir+"/")
relPath = strings.TrimPrefix(relPath, "/")
// Remove file extension
relPath = strings.TrimSuffix(relPath, filepath.Ext(relPath))
// Replace path separators with dots and add test prefix
scriptID := "test." + strings.ReplaceAll(relPath, "/", ".")
return scriptID
}
// executeTestFunction executes a single test function using V8
func (r *ScriptRunner) executeTestFunction(tc *ScriptTestCase, scriptInfo *ScriptInfo, testingT *TestingT, agentCtx *context.Context) (execErr error) {
// Recover from panics thrown by Process calls
// Even if JavaScript try-catch catches the error, we want to fail the test
defer func() {
if r := recover(); r != nil {
execErr = fmt.Errorf("panic in test function: %v", r)
}
}()
// Get the test script (already loaded by loadAllScripts)
testScriptID := generateTestScriptID(scriptInfo.TestPath, filepath.Dir(scriptInfo.TestPath))
script, ok := v8.Scripts[testScriptID]
if !ok {
return fmt.Errorf("test script not found: %s (id: %s)", scriptInfo.TestPath, testScriptID)
}
// Create a new script context
scriptCtx, err := script.NewContext("", nil)
if err != nil {
return fmt.Errorf("failed to create script context: %w", err)
}
defer scriptCtx.Close()
// Get the V8 context
v8ctx := scriptCtx.Context
// Create testing.T JavaScript object
testingTObj, err := NewTestingTObject(v8ctx, testingT)
if err != nil {
return fmt.Errorf("failed to create testing.T object: %w", err)
}
// Create agent context JavaScript object
agentCtxObj, err := agentCtx.JsValue(v8ctx)
if err != nil {
return fmt.Errorf("failed to create agent context object: %w", err)
}
// Get the test function
global := v8ctx.Global()
fnValue, err := global.Get(tc.Function)
if err != nil {
return fmt.Errorf("failed to get test function %s: %w", tc.Function, err)
}
if !fnValue.IsFunction() {
return fmt.Errorf("test function %s is not a function", tc.Function)
}
fn, err := fnValue.AsFunction()
if err != nil {
return fmt.Errorf("failed to convert to function: %w", err)
}
// Call the test function with (t, ctx)
result, err := fn.Call(global, testingTObj, agentCtxObj)
if err != nil {
// Check if this is an assertion failure or a real error
if testingT.Failed() {
// Assertion failure - already recorded
return nil
}
return fmt.Errorf("test function error: %w", err)
}
// Check if the result is a JavaScript Error (thrown by bridge.JsException)
if result != nil && result.IsNativeError() {
// Get error message from Error object
if result.IsObject() {
obj, err := result.AsObject()
if err == nil {
if msgVal, err := obj.Get("message"); err == nil && !msgVal.IsUndefined() {
return fmt.Errorf("test threw exception: %s", msgVal.String())
}
}
}
return fmt.Errorf("test threw exception: %s", result.String())
}
return nil
}
// RegisterTestingGlobals registers testing-related global functions for V8
// This is called once during initialization
func RegisterTestingGlobals() {
v8.RegisterFunction("__testing_log", testingLogEmbed)
}
// testingLogEmbed provides a console.log-like function for tests
func testingLogEmbed(iso *v8go.Isolate) *v8go.FunctionTemplate {
return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
args := info.Args()
parts := make([]string, len(args))
for i, arg := range args {
goVal, err := bridge.GoValue(arg, info.Context())
if err != nil {
parts[i] = arg.String()
} else {
parts[i] = fmt.Sprintf("%v", goVal)
}
}
fmt.Println(strings.Join(parts, " "))
return v8go.Undefined(iso)
})
}