// exec_tool_test.go 测试外部工具执行器. // // 测试覆盖: // - 正常调用(echo 命令返回 JSON 输出) // - 子进程超时(sleep + 短超时) // - 子进程 exit 非 0(is_error=true) // - LoadExecToolsFromFile 解析正确/文件不存在/数组/单对象格式 package builtin import ( "context" "encoding/json" "os" "path/filepath" "strings" "testing" "time" "git.flytoex.net/yuanwei/flyto-agent/pkg/execenv" ) // ── 辅助函数 ────────────────────────────────────────────────────────────────── // newTestExecTool 创建一个用于测试的 ExecTool. func newTestExecTool(t *testing.T, exec []string, timeoutSeconds int) *ExecTool { t.Helper() if timeoutSeconds <= 0 { timeoutSeconds = 5 } tool, err := NewExecTool(ExecToolDef{ Name: "test_tool", Description: "测试用外部工具", InputSchema: json.RawMessage(`{"type":"object","properties":{}}`), Exec: exec, TimeoutSeconds: timeoutSeconds, }, execenv.DefaultExecutor{}) if err != nil { t.Fatalf("NewExecTool: %v", err) } return tool } // ── 单元测试 ────────────────────────────────────────────────────────────────── // TestExecTool_Name 验证工具名称返回正确. func TestExecTool_Name(t *testing.T) { tool, err := NewExecTool(ExecToolDef{ Name: "my_tool", Description: "test", Exec: []string{"echo"}, }, execenv.DefaultExecutor{}) if err != nil { t.Fatal(err) } if tool.Name() != "my_tool" { t.Errorf("Name() = %q, want %q", tool.Name(), "my_tool") } } // TestExecTool_Description 验证工具描述返回正确. func TestExecTool_Description(t *testing.T) { tool, _ := NewExecTool(ExecToolDef{ Name: "t", Description: "hello world", Exec: []string{"echo"}, }, execenv.DefaultExecutor{}) if tool.Description(context.Background()) != "hello world" { t.Errorf("Description() = %q, want %q", tool.Description(context.Background()), "hello world") } } // TestExecTool_InputSchemaFallback 验证 InputSchema 为空时返回兜底 schema. func TestExecTool_InputSchemaFallback(t *testing.T) { tool, _ := NewExecTool(ExecToolDef{ Name: "t", Exec: []string{"echo"}, }, execenv.DefaultExecutor{}) schema := tool.InputSchema() if len(schema) == 0 { t.Error("InputSchema() returned empty, want fallback schema") } var m map[string]any if err := json.Unmarshal(schema, &m); err != nil { t.Errorf("InputSchema() returned invalid JSON: %v", err) } } // TestNewExecTool_Validation 验证构造函数的参数校验. func TestNewExecTool_Validation(t *testing.T) { tests := []struct { name string def ExecToolDef wantErr bool }{ { name: "missing name", def: ExecToolDef{Exec: []string{"echo"}}, wantErr: true, }, { name: "missing exec", def: ExecToolDef{Name: "t"}, wantErr: true, }, { name: "valid", def: ExecToolDef{Name: "t", Exec: []string{"echo"}}, wantErr: false, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { _, err := NewExecTool(tc.def, execenv.DefaultExecutor{}) if (err != nil) != tc.wantErr { t.Errorf("NewExecTool() error = %v, wantErr %v", err, tc.wantErr) } }) } } // TestExecTool_DefaultTimeout 验证超时默认值设置为 30 秒. func TestExecTool_DefaultTimeout(t *testing.T) { tool, _ := NewExecTool(ExecToolDef{ Name: "t", Exec: []string{"echo"}, }, execenv.DefaultExecutor{}) if tool.def.TimeoutSeconds != 30 { t.Errorf("default TimeoutSeconds = %d, want 30", tool.def.TimeoutSeconds) } } // TestExecTool_NormalExecution 正常调用:echo 命令输出 JSON,exit code=0. func TestExecTool_NormalExecution(t *testing.T) { // 使用 echo 命令输出固定字符串,模拟工具返回 JSON 响应 tool := newTestExecTool(t, []string{"echo", `{"result":"ok"}`}, 5) input := json.RawMessage(`{"query":"test"}`) result, err := tool.Execute(context.Background(), input, nil) if err != nil { t.Fatalf("Execute() error = %v", err) } if result.IsError { t.Errorf("Execute() IsError = true, want false; Output = %q", result.Output) } if !strings.Contains(result.Output, `{"result":"ok"}`) { t.Errorf("Execute() Output = %q, want to contain %q", result.Output, `{"result":"ok"}`) } } // TestExecTool_NormalExecution_StdinPassthrough 验证 stdin 输入被子进程收到. // 使用 cat 命令将 stdin 原样输出到 stdout,验证输入传递正确. func TestExecTool_NormalExecution_StdinPassthrough(t *testing.T) { tool := newTestExecTool(t, []string{"cat"}, 5) input := json.RawMessage(`{"key":"value","num":42}`) result, err := tool.Execute(context.Background(), input, nil) if err != nil { t.Fatalf("Execute() error = %v", err) } if result.IsError { t.Errorf("Execute() IsError = true; Output = %q", result.Output) } // cat 原样输出 stdin,所以输出应包含输入内容 if !strings.Contains(result.Output, `"key":"value"`) { t.Errorf("Execute() Output = %q, want to contain input JSON", result.Output) } } // TestExecTool_Timeout 子进程超时:sleep 命令 + 极短超时. // 验证超时后 IsError=true 且输出包含 "timed out". func TestExecTool_Timeout(t *testing.T) { // sleep 10 秒,但超时设置为 1 秒 tool := newTestExecTool(t, []string{"sleep", "10"}, 1) start := time.Now() result, err := tool.Execute(context.Background(), json.RawMessage(`{}`), nil) elapsed := time.Since(start) if err != nil { t.Fatalf("Execute() unexpected error = %v", err) } if !result.IsError { t.Error("Execute() IsError = false after timeout, want true") } if !strings.Contains(strings.ToLower(result.Output), "timed out") { t.Errorf("Execute() Output = %q, want to contain 'timed out'", result.Output) } // 验证实际等待时间合理(应在 1-3 秒内完成,不会等到 10 秒) if elapsed > 5*time.Second { t.Errorf("Execute() took %v, want < 5s (timeout should have triggered)", elapsed) } } // TestExecTool_Timeout_ContextCancel 验证外部 context cancel 也能正常终止子进程. func TestExecTool_Timeout_ContextCancel(t *testing.T) { tool := newTestExecTool(t, []string{"sleep", "30"}, 60) ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) defer cancel() start := time.Now() result, err := tool.Execute(ctx, json.RawMessage(`{}`), nil) elapsed := time.Since(start) if err != nil { t.Fatalf("Execute() unexpected error = %v", err) } // context cancel 或 deadline exceeded 都应该返回 IsError=true if !result.IsError { t.Errorf("Execute() IsError = false after context cancel, want true; Output=%q", result.Output) } if elapsed > 3*time.Second { t.Errorf("Execute() took %v, want < 3s (context cancel should have triggered)", elapsed) } } // TestExecTool_NonZeroExitCode 子进程 exit 非 0 → IsError=true. func TestExecTool_NonZeroExitCode(t *testing.T) { // false 命令总是以 exit code 1 退出 tool := newTestExecTool(t, []string{"false"}, 5) result, err := tool.Execute(context.Background(), json.RawMessage(`{}`), nil) if err != nil { t.Fatalf("Execute() unexpected error = %v", err) } if !result.IsError { t.Error("Execute() IsError = false for exit code 1, want true") } } // TestExecTool_NonZeroExitCode_WithStderrFallback 验证 exit 非 0 且无 stdout 时使用 stderr 作为输出. func TestExecTool_NonZeroExitCode_WithStderrFallback(t *testing.T) { // bash -c "echo 'err msg' >&2; exit 2" - 只有 stderr,exit code=2 tool := newTestExecTool(t, []string{"bash", "-c", "echo 'err msg' >&2; exit 2"}, 5) result, err := tool.Execute(context.Background(), json.RawMessage(`{}`), nil) if err != nil { t.Fatalf("Execute() unexpected error = %v", err) } if !result.IsError { t.Error("Execute() IsError = false for exit code 2, want true") } if !strings.Contains(result.Output, "err msg") { t.Errorf("Execute() Output = %q, want to contain stderr 'err msg'", result.Output) } } // TestExecTool_CommandNotFound 命令不存在时返回 IsError=true. func TestExecTool_CommandNotFound(t *testing.T) { tool := newTestExecTool(t, []string{"/nonexistent/command_that_does_not_exist"}, 5) result, err := tool.Execute(context.Background(), json.RawMessage(`{}`), nil) if err != nil { t.Fatalf("Execute() unexpected error = %v (expected nil error, IsError=true in Result)", err) } if !result.IsError { t.Error("Execute() IsError = false for non-existent command, want true") } } // ── LoadExecToolsFromFile 测试 ───────────────────────────────────────────── // TestLoadExecToolsFromFile_FileNotFound 文件不存在时返回错误. func TestLoadExecToolsFromFile_FileNotFound(t *testing.T) { _, err := LoadExecToolsFromFile("/nonexistent/path/tools.json", execenv.DefaultExecutor{}) if err == nil { t.Error("LoadExecToolsFromFile() expected error for non-existent file, got nil") } } // TestLoadExecToolsFromFile_ArrayFormat JSON 数组格式正确解析. func TestLoadExecToolsFromFile_ArrayFormat(t *testing.T) { content := `[ { "name": "tool_a", "description": "工具 A", "input_schema": {"type":"object","properties":{"x":{"type":"string"}}}, "exec": ["echo", "hello"], "timeout_seconds": 10 }, { "name": "tool_b", "description": "工具 B", "exec": ["cat"], "timeout_seconds": 5 } ]` path := writeTestFile(t, "tools.json", content) loaded, err := LoadExecToolsFromFile(path, execenv.DefaultExecutor{}) if err != nil { t.Fatalf("LoadExecToolsFromFile() error = %v", err) } if len(loaded) != 2 { t.Fatalf("LoadExecToolsFromFile() len = %d, want 2", len(loaded)) } if loaded[0].Name() != "tool_a" { t.Errorf("loaded[0].Name() = %q, want %q", loaded[0].Name(), "tool_a") } if loaded[1].Name() != "tool_b" { t.Errorf("loaded[1].Name() = %q, want %q", loaded[1].Name(), "tool_b") } // 验证超时正确设置 if loaded[0].def.TimeoutSeconds != 10 { t.Errorf("loaded[0].TimeoutSeconds = %d, want 10", loaded[0].def.TimeoutSeconds) } // 验证 input_schema 正确解析 var schema map[string]any if err := json.Unmarshal(loaded[0].def.InputSchema, &schema); err != nil { t.Errorf("loaded[0].InputSchema is invalid JSON: %v", err) } } // TestLoadExecToolsFromFile_SingleObjectFormat JSON 单对象格式正确解析. func TestLoadExecToolsFromFile_SingleObjectFormat(t *testing.T) { content := `{ "name": "single_tool", "description": "单个工具", "exec": ["python3", "/opt/tools/my_tool.py"], "timeout_seconds": 15 }` path := writeTestFile(t, "single_tool.json", content) loaded, err := LoadExecToolsFromFile(path, execenv.DefaultExecutor{}) if err != nil { t.Fatalf("LoadExecToolsFromFile() error = %v", err) } if len(loaded) != 1 { t.Fatalf("LoadExecToolsFromFile() len = %d, want 1", len(loaded)) } if loaded[0].Name() != "single_tool" { t.Errorf("loaded[0].Name() = %q, want %q", loaded[0].Name(), "single_tool") } if loaded[0].def.TimeoutSeconds != 15 { t.Errorf("loaded[0].TimeoutSeconds = %d, want 15", loaded[0].def.TimeoutSeconds) } } // TestLoadExecToolsFromFile_DefaultTimeout 未设置 timeout_seconds 时默认 30 秒. func TestLoadExecToolsFromFile_DefaultTimeout(t *testing.T) { content := `[{"name":"t","exec":["echo"]}]` path := writeTestFile(t, "default_timeout.json", content) loaded, err := LoadExecToolsFromFile(path, execenv.DefaultExecutor{}) if err != nil { t.Fatalf("LoadExecToolsFromFile() error = %v", err) } if loaded[0].def.TimeoutSeconds != 30 { t.Errorf("default TimeoutSeconds = %d, want 30", loaded[0].def.TimeoutSeconds) } } // TestLoadExecToolsFromFile_InvalidJSON 无效 JSON 返回错误. func TestLoadExecToolsFromFile_InvalidJSON(t *testing.T) { path := writeTestFile(t, "invalid.json", `{not valid json`) _, err := LoadExecToolsFromFile(path, execenv.DefaultExecutor{}) if err == nil { t.Error("LoadExecToolsFromFile() expected error for invalid JSON, got nil") } } // TestLoadExecToolsFromFile_EmptyFile 空文件返回错误. func TestLoadExecToolsFromFile_EmptyFile(t *testing.T) { path := writeTestFile(t, "empty.json", "") _, err := LoadExecToolsFromFile(path, execenv.DefaultExecutor{}) if err == nil { t.Error("LoadExecToolsFromFile() expected error for empty file, got nil") } } // TestLoadExecToolsFromFile_MissingName 工具名缺失时返回错误. func TestLoadExecToolsFromFile_MissingName(t *testing.T) { content := `[{"exec":["echo"]}]` path := writeTestFile(t, "missing_name.json", content) _, err := LoadExecToolsFromFile(path, execenv.DefaultExecutor{}) if err == nil { t.Error("LoadExecToolsFromFile() expected error for missing name, got nil") } } // TestLoadExecToolsFromFile_MissingExec 工具 exec 缺失时返回错误. func TestLoadExecToolsFromFile_MissingExec(t *testing.T) { content := `[{"name":"t"}]` path := writeTestFile(t, "missing_exec.json", content) _, err := LoadExecToolsFromFile(path, execenv.DefaultExecutor{}) if err == nil { t.Error("LoadExecToolsFromFile() expected error for missing exec, got nil") } } // TestLoadExecToolsFromFile_Execute_Integration 从文件加载工具后实际执行. // 集成测试:验证文件加载 + 工具执行的完整路径. func TestLoadExecToolsFromFile_Execute_Integration(t *testing.T) { // 定义一个 cat 工具(输入 JSON → 原样返回) content := `[{ "name": "passthrough", "description": "输入输出透传", "input_schema": {"type":"object","properties":{}}, "exec": ["echo", "loaded_ok"], "timeout_seconds": 5 }]` path := writeTestFile(t, "integration.json", content) loaded, err := LoadExecToolsFromFile(path, execenv.DefaultExecutor{}) if err != nil { t.Fatalf("LoadExecToolsFromFile() error = %v", err) } if len(loaded) != 1 { t.Fatalf("expected 1 tool, got %d", len(loaded)) } result, err := loaded[0].Execute(context.Background(), json.RawMessage(`{}`), nil) if err != nil { t.Fatalf("Execute() error = %v", err) } if result.IsError { t.Errorf("Execute() IsError = true; Output = %q", result.Output) } if !strings.Contains(result.Output, "loaded_ok") { t.Errorf("Execute() Output = %q, want to contain 'loaded_ok'", result.Output) } } // ── 辅助工具函数 ────────────────────────────────────────────────────────────── // writeTestFile 在临时目录写入测试文件,返回文件路径. func writeTestFile(t *testing.T, name, content string) string { t.Helper() dir := t.TempDir() path := filepath.Join(dir, name) if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatalf("writeTestFile: %v", err) } return path }