- Updated logger methods to respect the new `config.Silent` flag, allowing suppression of console output in development mode. - Refactored `buildTurnResponse` to utilize a shared `buildTrace` function for improved tool call association. - Introduced JSON output capabilities in `OutputWriter` for detailed trace and duration reporting. - Enhanced reporting mechanisms to include trace details in JSON and Markdown formats for better diagnostics. - Added support for a new `Trace` structure to capture detailed execution information, including tool calls and completion data.
464 lines
15 KiB
Go
464 lines
15 KiB
Go
package test
|
||
|
||
import (
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/fatih/color"
|
||
jsoniter "github.com/json-iterator/go"
|
||
)
|
||
|
||
// OutputWriter handles colored console output for test execution
|
||
type OutputWriter struct {
|
||
verbose bool
|
||
}
|
||
|
||
// NewOutputWriter creates a new output writer
|
||
func NewOutputWriter(verbose bool) *OutputWriter {
|
||
return &OutputWriter{verbose: verbose}
|
||
}
|
||
|
||
// Header prints a header section
|
||
func (w *OutputWriter) Header(title string) {
|
||
fmt.Println()
|
||
color.New(color.FgCyan, color.Bold).Println("═══════════════════════════════════════════════════════════════")
|
||
color.New(color.FgCyan, color.Bold).Printf(" %s\n", title)
|
||
color.New(color.FgCyan, color.Bold).Println("═══════════════════════════════════════════════════════════════")
|
||
}
|
||
|
||
// SubHeader prints a sub-header
|
||
func (w *OutputWriter) SubHeader(title string) {
|
||
fmt.Println()
|
||
color.New(color.FgWhite, color.Bold).Println("───────────────────────────────────────────────────────────────")
|
||
color.New(color.FgWhite, color.Bold).Printf(" %s\n", title)
|
||
color.New(color.FgWhite, color.Bold).Println("───────────────────────────────────────────────────────────────")
|
||
}
|
||
|
||
// Info prints an info message
|
||
func (w *OutputWriter) Info(format string, args ...interface{}) {
|
||
color.New(color.FgBlue).Printf("ℹ ")
|
||
fmt.Printf(format+"\n", args...)
|
||
}
|
||
|
||
// Success prints a success message
|
||
func (w *OutputWriter) Success(format string, args ...interface{}) {
|
||
color.New(color.FgGreen).Printf("✓ ")
|
||
fmt.Printf(format+"\n", args...)
|
||
}
|
||
|
||
// Error prints an error message
|
||
func (w *OutputWriter) Error(format string, args ...interface{}) {
|
||
color.New(color.FgRed).Printf("✗ ")
|
||
fmt.Printf(format+"\n", args...)
|
||
}
|
||
|
||
// Warning prints a warning message
|
||
func (w *OutputWriter) Warning(format string, args ...interface{}) {
|
||
color.New(color.FgYellow).Printf("⚠ ")
|
||
fmt.Printf(format+"\n", args...)
|
||
}
|
||
|
||
// Skip prints a skip message
|
||
func (w *OutputWriter) Skip(format string, args ...interface{}) {
|
||
color.New(color.FgYellow).Printf("○ ")
|
||
fmt.Printf(format+"\n", args...)
|
||
}
|
||
|
||
// Verbose prints a verbose message (only if verbose mode is enabled)
|
||
func (w *OutputWriter) Verbose(format string, args ...interface{}) {
|
||
if w.verbose {
|
||
color.New(color.FgHiBlack).Printf(" │ ")
|
||
fmt.Printf(format+"\n", args...)
|
||
}
|
||
}
|
||
|
||
// TestStart prints test case start
|
||
func (w *OutputWriter) TestStart(id string, input string, runNum int) {
|
||
inputPreview := truncateString(input, 50)
|
||
if runNum > 1 {
|
||
color.New(color.FgWhite).Printf("► [%s] Run %d: ", id, runNum)
|
||
} else {
|
||
color.New(color.FgWhite).Printf("► [%s] ", id)
|
||
}
|
||
color.New(color.FgHiBlack).Printf("%s", inputPreview)
|
||
fmt.Print(" ")
|
||
}
|
||
|
||
// TestResult prints test case result
|
||
func (w *OutputWriter) TestResult(status Status, duration time.Duration) {
|
||
switch status {
|
||
case StatusPassed:
|
||
color.New(color.FgGreen, color.Bold).Printf("PASSED")
|
||
case StatusFailed:
|
||
color.New(color.FgRed, color.Bold).Printf("FAILED")
|
||
case StatusSkipped:
|
||
color.New(color.FgYellow).Printf("SKIPPED")
|
||
case StatusError:
|
||
color.New(color.FgRed, color.Bold).Printf("ERROR")
|
||
case StatusTimeout:
|
||
color.New(color.FgRed).Printf("TIMEOUT")
|
||
}
|
||
color.New(color.FgHiBlack).Printf(" (%s)\n", formatDuration(duration))
|
||
}
|
||
|
||
// TestError prints test error details
|
||
func (w *OutputWriter) TestError(err string) {
|
||
color.New(color.FgRed).Printf(" └─ %s\n", err)
|
||
}
|
||
|
||
// TestOutput prints test output (verbose mode)
|
||
func (w *OutputWriter) TestOutput(output string) {
|
||
if w.verbose && output != "" {
|
||
outputPreview := truncateString(output, 100)
|
||
color.New(color.FgHiBlack).Printf(" └─ Output: %s\n", outputPreview)
|
||
}
|
||
}
|
||
|
||
// Progress prints progress information
|
||
func (w *OutputWriter) Progress(current, total int) {
|
||
percentage := float64(current) / float64(total) * 100
|
||
color.New(color.FgHiBlack).Printf("\r Progress: %d/%d (%.0f%%)", current, total, percentage)
|
||
}
|
||
|
||
// Summary prints the test summary
|
||
func (w *OutputWriter) Summary(summary *Summary, duration time.Duration) {
|
||
w.SubHeader("Summary")
|
||
|
||
// Agent info
|
||
color.New(color.FgWhite).Printf(" Agent: ")
|
||
color.New(color.FgCyan).Printf("%s\n", summary.AgentID)
|
||
|
||
if summary.Connector != "" {
|
||
color.New(color.FgWhite).Printf(" Connector: ")
|
||
color.New(color.FgCyan).Printf("%s\n", summary.Connector)
|
||
}
|
||
|
||
// Results
|
||
color.New(color.FgWhite).Printf(" Total: ")
|
||
fmt.Printf("%d\n", summary.Total)
|
||
|
||
color.New(color.FgWhite).Printf(" Passed: ")
|
||
if summary.Passed > 0 {
|
||
color.New(color.FgGreen).Printf("%d\n", summary.Passed)
|
||
} else {
|
||
fmt.Printf("%d\n", summary.Passed)
|
||
}
|
||
|
||
color.New(color.FgWhite).Printf(" Failed: ")
|
||
if summary.Failed > 0 {
|
||
color.New(color.FgRed).Printf("%d\n", summary.Failed)
|
||
} else {
|
||
fmt.Printf("%d\n", summary.Failed)
|
||
}
|
||
|
||
if summary.Skipped > 0 {
|
||
color.New(color.FgWhite).Printf(" Skipped: ")
|
||
color.New(color.FgYellow).Printf("%d\n", summary.Skipped)
|
||
}
|
||
|
||
if summary.Errors > 0 {
|
||
color.New(color.FgWhite).Printf(" Errors: ")
|
||
color.New(color.FgRed).Printf("%d\n", summary.Errors)
|
||
}
|
||
|
||
if summary.Timeouts > 0 {
|
||
color.New(color.FgWhite).Printf(" Timeouts: ")
|
||
color.New(color.FgRed).Printf("%d\n", summary.Timeouts)
|
||
}
|
||
|
||
// Pass rate
|
||
passRate := float64(0)
|
||
if summary.Total > 0 {
|
||
passRate = float64(summary.Passed) / float64(summary.Total) * 100
|
||
}
|
||
color.New(color.FgWhite).Printf(" Pass Rate: ")
|
||
if passRate == 100 {
|
||
color.New(color.FgGreen, color.Bold).Printf("%.1f%%\n", passRate)
|
||
} else if passRate >= 80 {
|
||
color.New(color.FgYellow).Printf("%.1f%%\n", passRate)
|
||
} else {
|
||
color.New(color.FgRed).Printf("%.1f%%\n", passRate)
|
||
}
|
||
|
||
// Duration
|
||
color.New(color.FgWhite).Printf(" Duration: ")
|
||
fmt.Printf("%s\n", formatDuration(duration))
|
||
|
||
// Stability info (if runs > 1)
|
||
if summary.RunsPerCase > 1 {
|
||
fmt.Println()
|
||
color.New(color.FgWhite, color.Bold).Println(" Stability Analysis:")
|
||
color.New(color.FgWhite).Printf(" Runs/Case: %d\n", summary.RunsPerCase)
|
||
color.New(color.FgWhite).Printf(" Total Runs: %d\n", summary.TotalRuns)
|
||
color.New(color.FgWhite).Printf(" Stable Cases: ")
|
||
if summary.StableCases == summary.Total {
|
||
color.New(color.FgGreen).Printf("%d\n", summary.StableCases)
|
||
} else {
|
||
color.New(color.FgYellow).Printf("%d\n", summary.StableCases)
|
||
}
|
||
color.New(color.FgWhite).Printf(" Unstable: ")
|
||
if summary.UnstableCases > 0 {
|
||
color.New(color.FgRed).Printf("%d\n", summary.UnstableCases)
|
||
} else {
|
||
fmt.Printf("%d\n", summary.UnstableCases)
|
||
}
|
||
}
|
||
}
|
||
|
||
// OutputFile prints the output file path
|
||
func (w *OutputWriter) OutputFile(path string) {
|
||
fmt.Println()
|
||
color.New(color.FgWhite).Printf(" Output: ")
|
||
color.New(color.FgCyan).Printf("%s\n", path)
|
||
}
|
||
|
||
// FinalResult prints the final result banner
|
||
func (w *OutputWriter) FinalResult(passed bool) {
|
||
fmt.Println()
|
||
if passed {
|
||
color.New(color.FgGreen, color.Bold).Println("═══════════════════════════════════════════════════════════════")
|
||
color.New(color.FgGreen, color.Bold).Println(" ✨ ALL TESTS PASSED ✨")
|
||
color.New(color.FgGreen, color.Bold).Println("═══════════════════════════════════════════════════════════════")
|
||
} else {
|
||
color.New(color.FgRed, color.Bold).Println("═══════════════════════════════════════════════════════════════")
|
||
color.New(color.FgRed, color.Bold).Println(" ❌ TESTS FAILED")
|
||
color.New(color.FgRed, color.Bold).Println("═══════════════════════════════════════════════════════════════")
|
||
}
|
||
fmt.Println()
|
||
}
|
||
|
||
// DirectOutput prints the agent output directly (for development mode)
|
||
func (w *OutputWriter) DirectOutput(output interface{}) {
|
||
if output == nil {
|
||
return
|
||
}
|
||
|
||
// Try to format as JSON if it's a complex type
|
||
switch v := output.(type) {
|
||
case string:
|
||
fmt.Println(v)
|
||
case map[string]interface{}, []interface{}:
|
||
// Pretty print JSON
|
||
jsonBytes, err := jsoniter.MarshalIndent(v, "", " ")
|
||
if err != nil {
|
||
fmt.Printf("%v\n", output)
|
||
} else {
|
||
fmt.Println(string(jsonBytes))
|
||
}
|
||
default:
|
||
// Try to marshal as JSON
|
||
jsonBytes, err := jsoniter.MarshalIndent(output, "", " ")
|
||
if err != nil {
|
||
fmt.Printf("%v\n", output)
|
||
} else {
|
||
fmt.Println(string(jsonBytes))
|
||
}
|
||
}
|
||
}
|
||
|
||
// DirectOutputJSON outputs a complete JSON object with output, trace and duration.
|
||
// Designed for AI/script consumption via --json flag.
|
||
func (w *OutputWriter) DirectOutputJSON(output interface{}, trace *Trace, duration time.Duration) {
|
||
payload := map[string]interface{}{
|
||
"output": output,
|
||
"duration_ms": duration.Milliseconds(),
|
||
}
|
||
if trace != nil {
|
||
payload["trace"] = trace
|
||
}
|
||
jsonBytes, err := jsoniter.MarshalIndent(payload, "", " ")
|
||
if err != nil {
|
||
fmt.Printf("%v\n", output)
|
||
return
|
||
}
|
||
fmt.Println(string(jsonBytes))
|
||
}
|
||
|
||
// DirectTrace prints a human-readable summary of tool calls from a Trace.
|
||
// Only prints when there are tool calls; skipped when trace is nil or empty.
|
||
func (w *OutputWriter) DirectTrace(trace *Trace) {
|
||
if trace == nil || len(trace.ToolCalls) == 0 {
|
||
return
|
||
}
|
||
|
||
fmt.Println()
|
||
color.New(color.FgHiBlack).Println("--- Tool Calls ---")
|
||
for _, tc := range trace.ToolCalls {
|
||
prefix := tc.Tool
|
||
if tc.Server != "" {
|
||
prefix = tc.Server + "/" + tc.Tool
|
||
}
|
||
|
||
status := "OK"
|
||
if tc.Error != "" {
|
||
status = "ERR: " + truncateString(tc.Error, 60)
|
||
}
|
||
|
||
argsStr := ""
|
||
if tc.Arguments != nil {
|
||
if b, err := jsoniter.Marshal(tc.Arguments); err == nil {
|
||
argsStr = truncateString(string(b), 80)
|
||
}
|
||
}
|
||
|
||
if argsStr != "" {
|
||
color.New(color.FgHiBlack).Printf(" %s → %s (args: %s)\n", prefix, status, argsStr)
|
||
} else {
|
||
color.New(color.FgHiBlack).Printf(" %s → %s\n", prefix, status)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ScriptOutputJSON outputs the complete script test report as JSON.
|
||
func (w *OutputWriter) ScriptOutputJSON(report *ScriptTestReport) {
|
||
jsonBytes, err := jsoniter.MarshalIndent(report, "", " ")
|
||
if err != nil {
|
||
fmt.Printf("{\"error\": %q}\n", err.Error())
|
||
return
|
||
}
|
||
fmt.Println(string(jsonBytes))
|
||
}
|
||
|
||
// ScriptTestSummary prints the script test summary
|
||
func (w *OutputWriter) ScriptTestSummary(summary *ScriptTestSummary, duration time.Duration) {
|
||
w.SubHeader("Summary")
|
||
|
||
// Results
|
||
color.New(color.FgWhite).Printf(" Total: ")
|
||
fmt.Printf("%d\n", summary.Total)
|
||
|
||
color.New(color.FgWhite).Printf(" Passed: ")
|
||
if summary.Passed > 0 {
|
||
color.New(color.FgGreen).Printf("%d\n", summary.Passed)
|
||
} else {
|
||
fmt.Printf("%d\n", summary.Passed)
|
||
}
|
||
|
||
color.New(color.FgWhite).Printf(" Failed: ")
|
||
if summary.Failed > 0 {
|
||
color.New(color.FgRed).Printf("%d\n", summary.Failed)
|
||
} else {
|
||
fmt.Printf("%d\n", summary.Failed)
|
||
}
|
||
|
||
if summary.Skipped > 0 {
|
||
color.New(color.FgWhite).Printf(" Skipped: ")
|
||
color.New(color.FgYellow).Printf("%d\n", summary.Skipped)
|
||
}
|
||
|
||
// Pass rate
|
||
passRate := float64(0)
|
||
if summary.Total > 0 {
|
||
passRate = float64(summary.Passed) / float64(summary.Total) * 100
|
||
}
|
||
color.New(color.FgWhite).Printf(" Pass Rate: ")
|
||
if passRate == 100 {
|
||
color.New(color.FgGreen, color.Bold).Printf("%.1f%%\n", passRate)
|
||
} else if passRate >= 80 {
|
||
color.New(color.FgYellow).Printf("%.1f%%\n", passRate)
|
||
} else {
|
||
color.New(color.FgRed).Printf("%.1f%%\n", passRate)
|
||
}
|
||
|
||
// Duration
|
||
color.New(color.FgWhite).Printf(" Duration: ")
|
||
fmt.Printf("%s\n", formatDuration(duration))
|
||
}
|
||
|
||
// DynamicTestStart outputs the start of a dynamic test
|
||
func (w *OutputWriter) DynamicTestStart(id string, checkpointCount int) {
|
||
color.New(color.FgWhite).Printf("► [%s] ", id)
|
||
color.New(color.FgCyan).Printf("(dynamic, %d checkpoints)\n", checkpointCount)
|
||
}
|
||
|
||
// DynamicTurn outputs a single turn in dynamic testing
|
||
func (w *OutputWriter) DynamicTurn(turn int, inputSummary string, checkpointsReached, total int) {
|
||
if w.verbose {
|
||
color.New(color.FgHiBlack).Printf("│ ├─ Turn %d: %s ", turn, inputSummary)
|
||
color.New(color.FgCyan).Printf("[%d/%d checkpoints]\n", checkpointsReached, total)
|
||
}
|
||
}
|
||
|
||
// DynamicCheckpoint outputs a checkpoint being reached
|
||
func (w *OutputWriter) DynamicCheckpoint(checkpointID string) {
|
||
if w.verbose {
|
||
color.New(color.FgGreen).Printf("│ │ └─ ✓ checkpoint: %s\n", checkpointID)
|
||
}
|
||
}
|
||
|
||
// DynamicTestResult outputs the result of a dynamic test
|
||
func (w *OutputWriter) DynamicTestResult(status Status, turns int, checkpoints int, duration time.Duration) {
|
||
color.New(color.FgHiBlack).Printf(" └─ ")
|
||
|
||
switch status {
|
||
case StatusPassed:
|
||
color.New(color.FgGreen).Printf("PASSED")
|
||
case StatusFailed:
|
||
color.New(color.FgRed).Printf("FAILED")
|
||
case StatusError:
|
||
color.New(color.FgRed).Printf("ERROR")
|
||
case StatusTimeout:
|
||
color.New(color.FgRed).Printf("TIMEOUT")
|
||
}
|
||
|
||
color.New(color.FgHiBlack).Printf(" (%d turns, %d checkpoints, %s)\n", turns, checkpoints, formatDuration(duration))
|
||
}
|
||
|
||
// StabilityResult prints stability analysis result for a test case
|
||
func (w *OutputWriter) StabilityResult(sr *StabilityResult) {
|
||
color.New(color.FgWhite).Printf(" [%s] ", sr.ID)
|
||
|
||
// Pass rate
|
||
if sr.PassRate == 100 {
|
||
color.New(color.FgGreen).Printf("%.0f%%", sr.PassRate)
|
||
} else if sr.PassRate >= 80 {
|
||
color.New(color.FgYellow).Printf("%.0f%%", sr.PassRate)
|
||
} else {
|
||
color.New(color.FgRed).Printf("%.0f%%", sr.PassRate)
|
||
}
|
||
|
||
// Classification
|
||
color.New(color.FgHiBlack).Printf(" (%d/%d) ", sr.Passed, sr.Runs)
|
||
|
||
switch sr.StabilityClass {
|
||
case StabilityStable:
|
||
color.New(color.FgGreen).Printf("Stable")
|
||
case StabilityMostlyStable:
|
||
color.New(color.FgYellow).Printf("Mostly Stable")
|
||
case StabilityUnstable:
|
||
color.New(color.FgRed).Printf("Unstable")
|
||
case StabilityHighlyUnstable:
|
||
color.New(color.FgRed, color.Bold).Printf("Highly Unstable")
|
||
}
|
||
|
||
// Timing
|
||
color.New(color.FgHiBlack).Printf(" avg:%.0fms\n", sr.AvgDurationMs)
|
||
}
|
||
|
||
// Helper functions
|
||
|
||
func truncateString(s string, maxLen int) string {
|
||
// Remove newlines and extra spaces
|
||
s = strings.ReplaceAll(s, "\n", " ")
|
||
s = strings.ReplaceAll(s, "\r", "")
|
||
s = strings.Join(strings.Fields(s), " ")
|
||
|
||
if len(s) <= maxLen {
|
||
return s
|
||
}
|
||
return s[:maxLen-3] + "..."
|
||
}
|
||
|
||
func formatDuration(d time.Duration) string {
|
||
if d < time.Millisecond {
|
||
return fmt.Sprintf("%dµs", d.Microseconds())
|
||
}
|
||
if d < time.Second {
|
||
return fmt.Sprintf("%dms", d.Milliseconds())
|
||
}
|
||
if d < time.Minute {
|
||
return fmt.Sprintf("%.1fs", d.Seconds())
|
||
}
|
||
return fmt.Sprintf("%.1fm", d.Minutes())
|
||
}
|