package test import ( "fmt" "os" "path/filepath" "strings" "time" jsoniter "github.com/json-iterator/go" "github.com/yaoapp/gou/application" ) // PathResolver resolves agent information from file paths type PathResolver struct{} // NewResolver creates a new path resolver func NewResolver() Resolver { return &PathResolver{} } // Resolve resolves the agent from options // Priority: explicit AgentID > path-based detection (from input file or cwd) func (r *PathResolver) Resolve(opts *Options) (*AgentInfo, error) { // If explicit agent ID is provided, use it if opts.AgentID != "" { return r.ResolveByID(opts.AgentID) } // For file mode, resolve from input file path if opts.InputMode == InputModeFile { if opts.Input == "" { return nil, fmt.Errorf("no agent ID or input file specified") } return r.ResolveFromPath(opts.Input) } // For message mode, try to resolve from current working directory return r.ResolveFromCwd() } // ResolveFromCwd resolves the agent from the current working directory // It looks for package.yao in the current directory or parent directories func (r *PathResolver) ResolveFromCwd() (*AgentInfo, error) { cwd, err := os.Getwd() if err != nil { return nil, fmt.Errorf("failed to get current working directory: %w", err) } info, err := r.ResolveFromPath(cwd) if err != nil { return nil, fmt.Errorf("no agent found in current directory. Use -n to specify agent explicitly") } return info, nil } // ResolveFromPath resolves the agent by traversing up from the input file path // It looks for package.yao in parent directories // If YAO_ROOT is set, it also considers paths relative to YAO_ROOT func (r *PathResolver) ResolveFromPath(inputPath string) (*AgentInfo, error) { // Get absolute path absPath, err := filepath.Abs(inputPath) if err != nil { return nil, fmt.Errorf("failed to get absolute path: %w", err) } // Start from the directory containing the input file dir := filepath.Dir(absPath) // Traverse up to find package.yao for { packagePath := filepath.Join(dir, "package.yao") if _, err := os.Stat(packagePath); err == nil { // Found package.yao return r.loadAgentFromPath(dir, packagePath) } // Move to parent directory parent := filepath.Dir(dir) if parent == dir { // Reached root, no package.yao found break } dir = parent } // If YAO_ROOT is set, try resolving relative to it yaoRoot := os.Getenv("YAO_ROOT") if yaoRoot != "" { // Try the input path relative to YAO_ROOT relPath := inputPath // If inputPath is absolute, try to make it relative if filepath.IsAbs(inputPath) { // Check if inputPath is under YAO_ROOT if rel, err := filepath.Rel(yaoRoot, inputPath); err == nil && !strings.HasPrefix(rel, "..") { relPath = rel } } // Traverse up from YAO_ROOT + relPath dir = filepath.Join(yaoRoot, filepath.Dir(relPath)) for { packagePath := filepath.Join(dir, "package.yao") if _, err := os.Stat(packagePath); err == nil { // Found package.yao return r.loadAgentFromPath(dir, packagePath) } // Move to parent directory, but don't go above YAO_ROOT parent := filepath.Dir(dir) if parent == dir || !strings.HasPrefix(parent, yaoRoot) { break } dir = parent } } return nil, fmt.Errorf("no package.yao found in path hierarchy of %s", inputPath) } // ResolveByID resolves an agent by its ID // This would integrate with the assistant loading system func (r *PathResolver) ResolveByID(agentID string) (*AgentInfo, error) { // This is a placeholder - actual implementation would use assistant.Get() // For now, return basic info return &AgentInfo{ ID: agentID, Name: agentID, }, nil } // loadAgentFromPath loads agent information from a package.yao file func (r *PathResolver) loadAgentFromPath(agentDir, packagePath string) (*AgentInfo, error) { // Read package.yao data, err := os.ReadFile(packagePath) if err != nil { return nil, fmt.Errorf("failed to read package.yao: %w", err) } // Parse package.yao var pkg PackageYao if err := jsoniter.Unmarshal(data, &pkg); err != nil { return nil, fmt.Errorf("failed to parse package.yao: %w", err) } // Derive agent ID from directory path agentID := deriveAgentID(agentDir) return &AgentInfo{ ID: agentID, Name: pkg.Name, Description: pkg.Description, Path: agentDir, Connector: pkg.Connector, Type: pkg.Type, }, nil } // PackageYao represents the structure of package.yao type PackageYao struct { Name string `json:"name"` Description string `json:"description,omitempty"` Connector string `json:"connector,omitempty"` Type string `json:"type,omitempty"` Uses map[string]interface{} `json:"uses,omitempty"` Options map[string]interface{} `json:"options,omitempty"` } // deriveAgentID derives an agent ID from the directory path // e.g., /app/assistants/workers/system/keyword -> workers.system.keyword func deriveAgentID(dir string) string { // Find "assistants" in path and use everything after it parts := strings.Split(filepath.ToSlash(dir), "/") // Look for "assistants" marker startIdx := -1 for i, part := range parts { if part == "assistants" { startIdx = i + 1 break } } if startIdx == -1 || startIdx >= len(parts) { // No "assistants" found, use the last directory name return filepath.Base(dir) } // Join remaining parts with dots return strings.Join(parts[startIdx:], ".") } // GetOutputFormat determines the output format from file extension func GetOutputFormat(outputPath string) OutputFormat { ext := strings.ToLower(filepath.Ext(outputPath)) switch ext { case ".json": return FormatJSON case ".html", ".htm": return FormatHTML case ".md", ".markdown": return FormatMarkdown default: return FormatJSON // Default to JSON } } // ValidateOptions validates test options func ValidateOptions(opts *Options) error { if opts.Input == "" { return fmt.Errorf("input is required (-i flag)") } // For file mode, check input file exists if opts.InputMode == InputModeFile { resolvedPath := ResolvePathWithYaoRoot(opts.Input) if _, err := os.Stat(resolvedPath); os.IsNotExist(err) { return fmt.Errorf("input file not found: %s", opts.Input) } } // Note: For message mode, agent can be resolved from cwd, so no validation here // The resolver will return an error if agent cannot be found // Validate timeout if opts.Timeout < 0 { return fmt.Errorf("timeout cannot be negative") } // Validate parallel if opts.Parallel < 0 { return fmt.Errorf("parallel cannot be negative") } return nil } // DefaultOptions returns options with default values func DefaultOptions() *Options { return &Options{ Timeout: 120 * time.Second, // 2 minutes default timeout Parallel: 1, Runs: 1, Verbose: false, FailFast: false, } } // DetectInputMode detects the input mode from the input string // Returns: // - InputModeScript: if input starts with "scripts." // - InputModeFile: if input ends with ".jsonl" or is an existing file // - InputModeMessage: otherwise (direct message mode) func DetectInputMode(input string) InputMode { // Check for script test prefix if strings.HasPrefix(input, "scripts.") { return InputModeScript } // If input ends with .jsonl or .json, treat as file if strings.HasSuffix(input, ".jsonl") || strings.HasSuffix(input, ".json") { return InputModeFile } // If input contains path separator, check if file exists if strings.Contains(input, string(filepath.Separator)) || strings.Contains(input, "/") { if _, err := os.Stat(input); err == nil { return InputModeFile } } // Otherwise treat as direct message return InputModeMessage } // MergeOptions merges user options with defaults func MergeOptions(opts *Options, defaults *Options) *Options { result := *defaults if opts.Input != "" { result.Input = opts.Input result.InputMode = DetectInputMode(opts.Input) } if opts.OutputFile != "" { result.OutputFile = opts.OutputFile } if opts.AgentID != "" { result.AgentID = opts.AgentID } if opts.Connector != "" { result.Connector = opts.Connector } if opts.UserID != "" { result.UserID = opts.UserID } if opts.TeamID != "" { result.TeamID = opts.TeamID } if opts.Locale != "" { result.Locale = opts.Locale } if opts.Timeout > 0 { result.Timeout = opts.Timeout } if opts.Parallel > 0 { result.Parallel = opts.Parallel } if opts.Runs > 0 { result.Runs = opts.Runs } if opts.ReporterID != "" { result.ReporterID = opts.ReporterID } if opts.ContextFile != "" { result.ContextFile = opts.ContextFile } if opts.Run != "" { result.Run = opts.Run } if opts.Verbose { result.Verbose = opts.Verbose } if opts.FailFast { result.FailFast = opts.FailFast } if opts.BeforeAll != "" { result.BeforeAll = opts.BeforeAll } if opts.AfterAll != "" { result.AfterAll = opts.AfterAll } if opts.DryRun { result.DryRun = opts.DryRun } if opts.Simulator != "" { result.Simulator = opts.Simulator } if opts.JSONOutput { result.JSONOutput = opts.JSONOutput } return &result } // GenerateDefaultOutputPath generates the default output path based on input file // Format: {input_directory}/output-{timestamp}.jsonl // Timestamp format: YYYYMMDDHHMMSS func GenerateDefaultOutputPath(inputPath string) string { // Resolve input path considering YAO_ROOT resolvedPath := ResolvePathWithYaoRoot(inputPath) dir := filepath.Dir(resolvedPath) timestamp := time.Now().Format("20060102150405") filename := fmt.Sprintf("output-%s.jsonl", timestamp) return filepath.Join(dir, filename) } // ResolveOutputPath resolves the output path based on input mode // - File mode: generate default path in same directory as input // - Message mode: return empty string (output to stdout) // If outputPath is explicitly specified, always use it func ResolveOutputPath(opts *Options) string { if opts.OutputFile != "" { return opts.OutputFile } // For file mode, generate default output path if opts.InputMode == InputModeFile { return GenerateDefaultOutputPath(opts.Input) } // For message mode, output to stdout (empty string) return "" } // CreateTestCaseFromMessage creates a single test case from a direct message func CreateTestCaseFromMessage(message string) *Case { return &Case{ ID: "T001", Input: message, } } // ResolvePathWithYaoRoot resolves a file path relative to current directory // No fallback to YAO_ROOT - paths are always resolved from current working directory func ResolvePathWithYaoRoot(path string) string { if filepath.IsAbs(path) { return path } // Try resolving relative to the application root first if application.App != nil { appRoot := application.App.Root() if appRoot != "" { candidate := filepath.Join(appRoot, path) if _, err := os.Stat(candidate); err == nil { return candidate } } } // Fallback: resolve relative to cwd absPath, err := filepath.Abs(path) if err != nil { return path } return absPath }