yao/agent/test/runner.go
Max 5e64c78435 Implement Script Testing Framework and Enhance Test Context Management
- Introduced a new script testing mode to allow testing of agent handler scripts (hooks, tools, etc.) using a Go-like interface, enabling better unit testing of TypeScript/JavaScript code.
- Enhanced the `LoadScripts` function to skip test files during script loading, ensuring only relevant scripts are processed.
- Refactored the test context creation to support custom context configurations via a JSON file, allowing for flexible authorization and metadata management during tests.
- Updated the test runner to handle script tests, including the ability to filter tests using regex patterns and manage custom context data.
- Improved documentation to include details on script testing usage, input formats, and available assertions, enhancing developer experience and clarity.
2025-12-21 10:44:59 +08:00

615 lines
15 KiB
Go

package test
import (
"bufio"
stdContext "context"
"fmt"
"os"
"sync"
"time"
jsoniter "github.com/json-iterator/go"
"github.com/yaoapp/yao/agent/assistant"
"github.com/yaoapp/yao/agent/context"
)
// Executor executes test cases against an agent
type Executor struct {
opts *Options
output *OutputWriter
resolver Resolver
loader Loader
}
// NewRunner creates a new test runner
func NewRunner(opts *Options) *Executor {
return &Executor{
opts: opts,
output: NewOutputWriter(opts.Verbose),
resolver: NewResolver(),
loader: NewLoader(),
}
}
// Run executes all test cases and returns a report
func (r *Executor) Run() (*Report, error) {
// For script test mode, use script runner
if r.opts.InputMode == InputModeScript {
return r.RunScriptTests()
}
// For direct message mode, use simplified output (development mode)
if r.opts.InputMode == InputModeMessage {
return r.RunDirect()
}
return r.RunTests()
}
// RunScriptTests executes script tests and returns a report
func (r *Executor) RunScriptTests() (*Report, error) {
scriptRunner := NewScriptRunner(r.opts)
scriptReport, err := scriptRunner.Run()
if err != nil {
return nil, err
}
// Convert to standard report for unified output handling
report := scriptReport.ToReport()
// Write output if specified
if r.opts.OutputFile != "" {
err = r.writeOutput(report)
if err != nil {
r.output.Error("Failed to write output: %s", err.Error())
} else {
r.output.OutputFile(r.opts.OutputFile)
}
}
// Print final result
r.output.FinalResult(!report.HasFailures())
return report, nil
}
// RunDirect executes a single direct message and outputs the result directly
// This is optimized for development/debugging scenarios
func (r *Executor) RunDirect() (*Report, error) {
// Resolve agent
agentInfo, err := r.resolver.Resolve(r.opts)
if err != nil {
return nil, fmt.Errorf("failed to resolve agent: %w", err)
}
// Get assistant
ast, err := assistant.Get(agentInfo.ID)
if err != nil {
return nil, fmt.Errorf("failed to get assistant: %w", err)
}
// Create test case from message
tc := CreateTestCaseFromMessage(r.opts.Input)
// Create context
chatID := GenerateChatID(tc.ID, 1)
ctx := NewTestContextFromOptions(chatID, agentInfo.ID, r.opts, tc)
defer ctx.Release()
// Build context options
opts := buildContextOptions(tc, r.opts)
// Create timeout context
timeout := tc.GetTimeout(r.opts.Timeout)
timeoutCtx, cancel := stdContext.WithTimeout(ctx.Context, timeout)
defer cancel()
ctx.Context = timeoutCtx
// Parse input to messages
messages, err := tc.GetMessages()
if err != nil {
return nil, fmt.Errorf("failed to parse input: %w", err)
}
// Run the agent
response, err := ast.Stream(ctx, messages, opts)
// Check for timeout
if timeoutCtx.Err() != nil {
return nil, fmt.Errorf("timeout after %s", timeout)
}
// Check for error
if err != nil {
return nil, err
}
// Extract and print output directly
output := extractOutput(response)
r.output.DirectOutput(output)
// Determine connector: user-specified > agent default
connector := r.opts.Connector
if connector == "" {
connector = agentInfo.Connector
}
// Return minimal report (for exit code handling)
return &Report{
Summary: &Summary{
Total: 1,
Passed: 1,
AgentID: agentInfo.ID,
Connector: connector,
},
}, nil
}
// RunTests executes test cases from file and generates a report
func (r *Executor) RunTests() (*Report, error) {
startTime := time.Now()
// Print header
r.output.Header("Agent Test")
// Resolve agent
agentInfo, err := r.resolver.Resolve(r.opts)
if err != nil {
return nil, fmt.Errorf("failed to resolve agent: %w", err)
}
r.output.Info("Agent: %s", agentInfo.ID)
if r.opts.Connector != "" {
r.output.Info("Connector: %s (override)", r.opts.Connector)
} else if agentInfo.Connector != "" {
r.output.Info("Connector: %s", agentInfo.Connector)
}
// Load test cases
var testCases []*Case
// File mode - load from JSONL
testCases, err = r.loader.LoadFile(r.opts.Input)
if err != nil {
return nil, fmt.Errorf("failed to load test cases: %w", err)
}
r.output.Info("Input: %s (%d test cases)", r.opts.Input, len(testCases))
// Filter skipped tests
activeTests := FilterSkipped(testCases)
skippedCount := len(testCases) - len(activeTests)
if skippedCount > 0 {
r.output.Warning("Skipped: %d test cases", skippedCount)
}
// Print test info
if r.opts.Runs > 1 {
r.output.Info("Runs: %d per test case (stability analysis)", r.opts.Runs)
}
r.output.Info("Timeout: %s", r.opts.Timeout)
if r.opts.Parallel > 1 {
r.output.Info("Parallel: %d", r.opts.Parallel)
}
// Get assistant
ast, err := assistant.Get(agentInfo.ID)
if err != nil {
return nil, fmt.Errorf("failed to get assistant: %w", err)
}
// Determine connector: user-specified > agent default
connector := r.opts.Connector
if connector == "" {
connector = agentInfo.Connector
}
// Create report
report := &Report{
Summary: &Summary{
Total: len(testCases),
AgentID: agentInfo.ID,
AgentPath: agentInfo.Path,
Connector: connector,
RunsPerCase: r.opts.Runs,
},
Environment: NewEnvironment(r.opts.UserID, r.opts.TeamID),
Metadata: &ReportMetadata{
StartedAt: startTime,
InputFile: r.opts.Input,
Options: r.opts,
},
}
// Run tests
r.output.SubHeader("Running Tests")
if r.opts.Runs > 1 {
// Stability testing mode
report.StabilityResults = r.runStabilityTests(ast, activeTests, agentInfo.ID)
r.calculateStabilitySummary(report)
} else {
// Single run mode
report.Results = r.runSingleTests(ast, activeTests, agentInfo.ID)
r.calculateSingleSummary(report)
}
// Add skipped count
report.Summary.Skipped = skippedCount
// Complete report
report.Summary.DurationMs = time.Since(startTime).Milliseconds()
report.Metadata.CompletedAt = time.Now()
// Print summary
r.output.Summary(report.Summary, time.Since(startTime))
// Write output
if r.opts.OutputFile != "" {
err = r.writeOutput(report)
if err != nil {
r.output.Error("Failed to write output: %s", err.Error())
} else {
r.output.OutputFile(r.opts.OutputFile)
}
}
// Print final result
r.output.FinalResult(!report.HasFailures())
return report, nil
}
// runSingleTests runs each test case once
func (r *Executor) runSingleTests(ast *assistant.Assistant, testCases []*Case, agentID string) []*Result {
results := make([]*Result, 0, len(testCases))
if r.opts.Parallel > 1 {
// Parallel execution
results = r.runParallel(ast, testCases, agentID)
} else {
// Sequential execution
for i, tc := range testCases {
result := r.runSingleTest(ast, tc, agentID, 1)
results = append(results, result)
// Check fail-fast
if r.opts.FailFast && result.Status != StatusPassed && result.Status != StatusSkipped {
r.output.Warning("Stopping due to --fail-fast (failed at test %d/%d)", i+1, len(testCases))
break
}
}
}
return results
}
// runParallel runs tests in parallel
func (r *Executor) runParallel(ast *assistant.Assistant, testCases []*Case, agentID string) []*Result {
results := make([]*Result, len(testCases))
var wg sync.WaitGroup
sem := make(chan struct{}, r.opts.Parallel)
for i, tc := range testCases {
wg.Add(1)
go func(idx int, testCase *Case) {
defer wg.Done()
sem <- struct{}{} // Acquire
defer func() { <-sem }() // Release
results[idx] = r.runSingleTest(ast, testCase, agentID, 1)
}(i, tc)
}
wg.Wait()
return results
}
// runSingleTest runs a single test case
func (r *Executor) runSingleTest(ast *assistant.Assistant, tc *Case, agentID string, runNum int) *Result {
// Get input summary for display
inputSummary := SummarizeInput(tc.Input, 50)
r.output.TestStart(tc.ID, inputSummary, runNum)
startTime := time.Now()
// Create result
result := &Result{
ID: tc.ID,
Input: tc.Input,
Expected: tc.Expected,
Options: tc.Options,
}
// Parse input to messages
messages, err := tc.GetMessages()
if err != nil {
result.Status = StatusError
result.Error = fmt.Sprintf("failed to parse input: %s", err.Error())
result.DurationMs = time.Since(startTime).Milliseconds()
r.output.TestResult(result.Status, time.Since(startTime))
r.output.TestError(result.Error)
return result
}
// Create context
chatID := GenerateChatID(tc.ID, runNum)
ctx := NewTestContextFromOptions(chatID, agentID, r.opts, tc)
defer ctx.Release()
// Build context options from test case and runner options
opts := buildContextOptions(tc, r.opts)
// Create timeout context
timeout := tc.GetTimeout(r.opts.Timeout)
timeoutCtx, cancel := stdContext.WithTimeout(ctx.Context, timeout)
defer cancel()
ctx.Context = timeoutCtx
// Run the test
response, err := ast.Stream(ctx, messages, opts)
duration := time.Since(startTime)
result.DurationMs = duration.Milliseconds()
// Check for timeout
if timeoutCtx.Err() != nil {
result.Status = StatusTimeout
result.Error = fmt.Sprintf("timeout after %s", timeout)
r.output.TestResult(result.Status, duration)
r.output.TestError(result.Error)
return result
}
// Check for error
if err != nil {
result.Status = StatusError
result.Error = err.Error()
r.output.TestResult(result.Status, duration)
r.output.TestError(result.Error)
return result
}
// Extract output
result.Output = extractOutput(response)
// Validate result using asserter
asserter := NewAsserter()
passed, errMsg := asserter.Validate(tc, result.Output)
if passed {
result.Status = StatusPassed
} else {
result.Status = StatusFailed
result.Error = errMsg
}
r.output.TestResult(result.Status, duration)
if result.Status == StatusFailed {
r.output.TestError(result.Error)
}
r.output.TestOutput(fmt.Sprintf("%v", result.Output))
return result
}
// runStabilityTests runs each test case multiple times for stability analysis
func (r *Executor) runStabilityTests(ast *assistant.Assistant, testCases []*Case, agentID string) []*StabilityResult {
results := make([]*StabilityResult, 0, len(testCases))
for _, tc := range testCases {
sr := &StabilityResult{
ID: tc.ID,
Input: tc.Input,
Expected: tc.Expected,
RunDetails: make([]*RunDetail, 0, r.opts.Runs),
}
// Run multiple times
for run := 1; run <= r.opts.Runs; run++ {
result := r.runSingleTest(ast, tc, agentID, run)
rd := &RunDetail{
Run: run,
Status: result.Status,
DurationMs: result.DurationMs,
Output: result.Output,
Error: result.Error,
}
sr.RunDetails = append(sr.RunDetails, rd)
}
// Calculate stability metrics
sr.CalculateStability()
// Print stability result
r.output.StabilityResult(sr)
results = append(results, sr)
// Check fail-fast
if r.opts.FailFast && !sr.Stable {
r.output.Warning("Stopping due to --fail-fast (unstable test: %s)", tc.ID)
break
}
}
return results
}
// calculateSingleSummary calculates summary for single run mode
func (r *Executor) calculateSingleSummary(report *Report) {
for _, result := range report.Results {
switch result.Status {
case StatusPassed:
report.Summary.Passed++
case StatusFailed:
report.Summary.Failed++
case StatusError:
report.Summary.Errors++
case StatusTimeout:
report.Summary.Timeouts++
}
}
}
// calculateStabilitySummary calculates summary for stability mode
func (r *Executor) calculateStabilitySummary(report *Report) {
report.Summary.TotalRuns = len(report.StabilityResults) * r.opts.Runs
var totalPassRate float64
for _, sr := range report.StabilityResults {
if sr.Stable {
report.Summary.StableCases++
report.Summary.Passed++
} else {
report.Summary.UnstableCases++
report.Summary.Failed++
}
totalPassRate += sr.PassRate
}
if len(report.StabilityResults) > 0 {
report.Summary.OverallPassRate = totalPassRate / float64(len(report.StabilityResults))
}
}
// writeOutput writes the test report to the output file
func (r *Executor) writeOutput(report *Report) error {
file, err := os.Create(r.opts.OutputFile)
if err != nil {
return fmt.Errorf("failed to create output file: %w", err)
}
defer file.Close()
// Get reporter based on -r flag or file extension
reporter := GetReporterWithAgent(r.opts.ReporterID, r.opts.OutputFile, r.opts.Verbose)
// If using agent reporter, set context
if agentReporter, ok := reporter.(*AgentReporter); ok {
// Create a context for the reporter agent call
ctx := NewTestContext("reporter", r.opts.ReporterID, report.Environment)
defer ctx.Release()
agentReporter.SetContext(ctx)
}
// Write report using the reporter
return reporter.Write(report, file)
}
// writeJSONLine writes a JSON line to the writer
func writeJSONLine(writer *bufio.Writer, data interface{}) error {
line, err := jsoniter.Marshal(data)
if err != nil {
return err
}
_, err = writer.Write(line)
if err != nil {
return err
}
_, err = writer.WriteString("\n")
return err
}
// buildContextOptions builds context.Options from test case and runner options
// Priority: test case options > runner options > defaults
func buildContextOptions(tc *Case, runnerOpts *Options) *context.Options {
opts := &context.Options{
Skip: &context.Skip{
History: true, // Default: skip history loading - input already contains full conversation
},
}
// Apply test case options if specified
if tc.Options != nil {
// Connector: test case > runner
if tc.Options.Connector != "" {
opts.Connector = tc.Options.Connector
}
// Mode
if tc.Options.Mode != "" {
opts.Mode = tc.Options.Mode
}
// DisableGlobalPrompts
if tc.Options.DisableGlobalPrompts {
opts.DisableGlobalPrompts = true
}
// Search (pointer to distinguish unset from false)
if tc.Options.Search != nil {
opts.Search = tc.Options.Search
}
// Metadata for hooks
if tc.Options.Metadata != nil {
opts.Metadata = tc.Options.Metadata
}
// Skip options from test case
if tc.Options.Skip != nil {
opts.Skip.Trace = tc.Options.Skip.Trace
opts.Skip.Output = tc.Options.Skip.Output
opts.Skip.Keyword = tc.Options.Skip.Keyword
opts.Skip.Search = tc.Options.Skip.Search
// Note: History defaults to true for tests
}
}
// Runner connector override (highest priority)
if runnerOpts != nil && runnerOpts.Connector != "" {
opts.Connector = runnerOpts.Connector
}
return opts
}
// extractOutput extracts the output from the agent response
// Priority: Next hook data (if non-empty) > Completion content > nil
func extractOutput(response *context.Response) interface{} {
if response == nil {
return nil
}
// Prefer Next hook data if available and non-empty
// response.Next is already the Data value (not NextHookResponse struct)
if response.Next != nil && !isEmptyValue(response.Next) {
return response.Next
}
// Fall back to raw completion content
if response.Completion != nil {
return response.Completion.Content
}
return nil
}
// isEmptyValue checks if a value is considered "empty" for output purposes
func isEmptyValue(v interface{}) bool {
if v == nil {
return true
}
switch val := v.(type) {
case string:
return val == ""
case map[string]interface{}:
return len(val) == 0
case []interface{}:
return len(val) == 0
}
return false
}
// validateOutput validates the actual output against expected
func validateOutput(actual, expected interface{}) bool {
// Simple JSON comparison
actualJSON, err1 := jsoniter.Marshal(actual)
expectedJSON, err2 := jsoniter.Marshal(expected)
if err1 != nil || err2 != nil {
return false
}
return string(actualJSON) == string(expectedJSON)
}