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 or scripts.xxx.yyy.zzz format // // Resolution strategy: // 1. Find the assistant directory by detecting package.yao from longest to shortest path // 2. Remaining parts after the assistant boundary map to src/ subdirectories + module name // // Examples: // - scripts.expense.setup -> assistants/expense/src/setup_test.ts // - scripts.yao.keeper.seed -> assistants/yao/keeper/src/seed_test.ts // - scripts.yao.keeper.tests.seed -> assistants/yao/keeper/src/tests/seed_test.ts func ResolveScript(input string) (*ScriptInfo, error) { // Remove "scripts." prefix path := strings.TrimPrefix(input, "scripts.") // Split into parts: // "expense.setup" -> ["expense", "setup"] // "yao.keeper.tests.seed" -> ["yao", "keeper", "tests", "seed"] parts := strings.Split(path, ".") if len(parts) < 2 { return nil, fmt.Errorf("invalid script path: %s (expected format: scripts.assistant.module or scripts.assistant.sub.module)", input) } // Strategy: detect assistant boundary by looking for package.yao // Try from the longest possible assistant path down to the shortest var assistantDir, modulePath string assistantFound := false for i := len(parts) - 1; i >= 1; i-- { candidateDir := strings.Join(parts[:i], "/") for _, prefix := range []string{"assistants/", ""} { packagePath := filepath.Join(prefix+candidateDir, "package.yao") exists, err := application.App.Exists(packagePath) if err == nil && exists { assistantDir = candidateDir // Remaining parts form the module path (may include subdirectories) // e.g., parts[i:] = ["tests", "seed"] -> "tests/seed" modulePath = strings.Join(parts[i:], "/") assistantFound = true break } } if assistantFound { break } } // Fallback: original behavior — last part is module, rest is assistant dir if !assistantFound { assistantDir = strings.Join(parts[:len(parts)-1], "/") modulePath = parts[len(parts)-1] } // modulePath may contain subdirectories: "tests/seed" -> dir="tests", module="seed" moduleName := filepath.Base(modulePath) moduleSubDir := filepath.Dir(modulePath) if moduleSubDir == "." { moduleSubDir = "" } // Build candidate base paths basePaths := []string{ filepath.Join("assistants", assistantDir, "src", moduleSubDir), filepath.Join(assistantDir, "src", moduleSubDir), } 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 } quiet := r.opts.JSONOutput if !quiet { 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) } if !quiet { r.output.Info("Tests: %d functions (filtered by: %s)", len(tests), r.opts.Run) } } else if !quiet { r.output.Info("Tests: %d functions", len(tests)) } if len(tests) == 0 && !quiet { 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) } if !quiet { 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) } if !quiet { 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) } if !quiet { 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 if !quiet { 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 (skip in JSON mode, handled by caller) if !r.opts.JSONOutput { 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 { quiet := r.opts.JSONOutput if !quiet { 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() if jse, ok := err.(*jsErrorWithTrace); ok { result.StackTrace = jse.stackTrace } if !quiet { r.output.TestResult(result.Status, duration) r.output.TestError(result.Error) if result.StackTrace != "" { r.output.TestError(result.StackTrace) } } return result } if testingT.Skipped() { result.Status = StatusSkipped if !quiet { 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() if !quiet { r.output.TestResult(result.Status, duration) r.output.TestError(result.Error) } return result } if !quiet { 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 // Set share data with authorized info for Process calls // This is needed because we call fn.Call directly instead of scriptCtx.Call var authorized map[string]interface{} if agentCtx.Authorized != nil { authorized = agentCtx.Authorized.AuthorizedToMap() } err = bridge.SetShareData(v8ctx, v8ctx.Global(), &bridge.Share{ Sid: "", Root: false, Global: nil, Authorized: authorized, }) if err != nil { return fmt.Errorf("failed to set share data: %w", err) } // 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 } if jserr, ok := err.(*v8go.JSError); ok { return &jsErrorWithTrace{ message: jserr.Message, stackTrace: v8.StackTrace(jserr, 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) } // jsErrorWithTrace wraps a V8 JS error with source-mapped stack trace type jsErrorWithTrace struct { message string stackTrace string } func (e *jsErrorWithTrace) Error() string { return e.message } // 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) }) }