// fileedit_test.go -- FileEdit 工具的单元测试. // // 覆盖场景: // - 精确替换成功 // - old_string == new_string 报错 // - 文件不存在报错 // - old_string 在文件中不存在 - 智能提示 // - 多处匹配报错(列出行号) // - replace_all 替换所有匹配 // - 空白差异检测(tab vs space) // - CRLF 换行符处理 // - levenshtein 编辑距离计算 // - findMatchLineNumbers 行号查找 // - 反消毒(Desanitization) // - Curly Quote 智能匹配 // - validate + apply 两阶段分离 // - 文件缓存集成 package builtin import ( "context" "encoding/json" "fmt" "os" "path/filepath" "strings" "testing" "git.flytoex.net/yuanwei/flyto-agent/pkg/tools" ) // ───────────────────────────────────────────────────────────────────── // 测试辅助:Mock 文件缓存 // ───────────────────────────────────────────────────────────────────── // mockFileCache 是 FileCacheRecorder 的测试 mock. type mockFileCache struct { records map[string][]byte } func newMockFileCache() *mockFileCache { return &mockFileCache{records: make(map[string][]byte)} } func (m *mockFileCache) Record(path string, content []byte) { m.records[path] = append([]byte(nil), content...) // 深拷贝 } // ───────────────────────────────────────────────────────────────────── // 基础功能测试(保留原有测试 + 增强) // ───────────────────────────────────────────────────────────────────── // TestFileEditTool_BasicReplace 测试基本的精确替换 func TestFileEditTool_BasicReplace(t *testing.T) { tool := NewFileEditTool() dir := t.TempDir() filePath := filepath.Join(dir, "test.txt") os.WriteFile(filePath, []byte("hello world"), 0644) input, _ := json.Marshal(fileEditInput{ FilePath: filePath, OldString: "hello", NewString: "goodbye", }) result, err := tool.Execute(context.Background(), input, nil) if err != nil { t.Fatalf("执行失败: %v", err) } if result.IsError { t.Fatalf("不应标记为错误: %s", result.Output) } data, _ := os.ReadFile(filePath) if string(data) != "goodbye world" { t.Errorf("文件内容不匹配, 期望 'goodbye world', 实际 %q", string(data)) } } // TestFileEditTool_SameOldNew 测试 old_string == new_string 报错 func TestFileEditTool_SameOldNew(t *testing.T) { tool := NewFileEditTool() input, _ := json.Marshal(fileEditInput{ FilePath: "/any/path", OldString: "same", NewString: "same", }) result, err := tool.Execute(context.Background(), input, nil) if err != nil { t.Fatalf("不应返回 Go error: %v", err) } if !result.IsError { t.Error("old_string == new_string 应报错") } if !strings.Contains(result.Output, "must be different") { t.Errorf("错误信息不匹配: %s", result.Output) } } // TestFileEditTool_FileNotFound 测试文件不存在报错 func TestFileEditTool_FileNotFound(t *testing.T) { tool := NewFileEditTool() input, _ := json.Marshal(fileEditInput{ FilePath: "/nonexistent/path/file.txt", OldString: "a", NewString: "b", }) result, err := tool.Execute(context.Background(), input, nil) if err != nil { t.Fatalf("不应返回 Go error: %v", err) } if !result.IsError { t.Error("文件不存在应报错") } if !strings.Contains(result.Output, "not found") { t.Errorf("错误信息不匹配: %s", result.Output) } } // TestFileEditTool_OldStringNotFound 测试 old_string 不存在时的智能提示 func TestFileEditTool_OldStringNotFound(t *testing.T) { tool := NewFileEditTool() dir := t.TempDir() filePath := filepath.Join(dir, "test.txt") os.WriteFile(filePath, []byte("line one\nline two\nline three"), 0644) input, _ := json.Marshal(fileEditInput{ FilePath: filePath, OldString: "not here", NewString: "replacement", }) result, err := tool.Execute(context.Background(), input, nil) if err != nil { t.Fatalf("不应返回 Go error: %v", err) } if !result.IsError { t.Error("old_string 不存在应报错") } if !strings.Contains(result.Output, "not found") { t.Errorf("错误信息应包含 'not found': %s", result.Output) } } // TestFileEditTool_MultipleMatches 测试多处匹配时列出行号 func TestFileEditTool_MultipleMatches(t *testing.T) { tool := NewFileEditTool() dir := t.TempDir() filePath := filepath.Join(dir, "test.txt") os.WriteFile(filePath, []byte("foo\nbar\nfoo\nbaz\nfoo"), 0644) input, _ := json.Marshal(fileEditInput{ FilePath: filePath, OldString: "foo", NewString: "qux", }) result, err := tool.Execute(context.Background(), input, nil) if err != nil { t.Fatalf("不应返回 Go error: %v", err) } if !result.IsError { t.Error("多处匹配应报错") } if !strings.Contains(result.Output, "3 matches") { t.Errorf("应提示 3 处匹配: %s", result.Output) } } // TestFileEditTool_ReplaceAll 测试 replace_all 模式 func TestFileEditTool_ReplaceAll(t *testing.T) { tool := NewFileEditTool() dir := t.TempDir() filePath := filepath.Join(dir, "test.txt") os.WriteFile(filePath, []byte("foo bar foo baz foo"), 0644) input, _ := json.Marshal(fileEditInput{ FilePath: filePath, OldString: "foo", NewString: "qux", ReplaceAll: true, }) result, err := tool.Execute(context.Background(), input, nil) if err != nil { t.Fatalf("执行失败: %v", err) } if result.IsError { t.Fatalf("不应标记为错误: %s", result.Output) } data, _ := os.ReadFile(filePath) if string(data) != "qux bar qux baz qux" { t.Errorf("replace_all 失败, 实际: %q", string(data)) } if !strings.Contains(result.Output, "3 occurrences") { t.Errorf("应报告替换数量: %s", result.Output) } } // TestFileEditTool_CRLFHandling 测试 CRLF 换行符处理 func TestFileEditTool_CRLFHandling(t *testing.T) { tool := NewFileEditTool() dir := t.TempDir() filePath := filepath.Join(dir, "test.txt") // 写入 CRLF 文件 os.WriteFile(filePath, []byte("hello\r\nworld\r\n"), 0644) input, _ := json.Marshal(fileEditInput{ FilePath: filePath, OldString: "hello", NewString: "goodbye", }) result, err := tool.Execute(context.Background(), input, nil) if err != nil { t.Fatalf("执行失败: %v", err) } if result.IsError { t.Fatalf("不应标记为错误: %s", result.Output) } data, _ := os.ReadFile(filePath) // CRLF 应该被保留 if !strings.Contains(string(data), "\r\n") { t.Error("CRLF 换行符应被保留") } if !strings.Contains(string(data), "goodbye") { t.Errorf("替换失败, 实际: %q", string(data)) } } // TestFileEditTool_EmptyFilePath 测试空文件路径 func TestFileEditTool_EmptyFilePath(t *testing.T) { tool := NewFileEditTool() input, _ := json.Marshal(fileEditInput{ FilePath: "", OldString: "a", NewString: "b", }) result, err := tool.Execute(context.Background(), input, nil) if err != nil { t.Fatalf("不应返回 Go error: %v", err) } if !result.IsError { t.Error("空文件路径应报错") } } // ───────────────────────────────────────────────────────────────────── // validate + apply 两阶段分离测试 // ───────────────────────────────────────────────────────────────────── // TestFileEditTool_ValidateOnly 测试 validate 阶段可独立使用 func TestFileEditTool_ValidateOnly(t *testing.T) { tool := NewFileEditTool() dir := t.TempDir() filePath := filepath.Join(dir, "test.txt") os.WriteFile(filePath, []byte("hello world"), 0644) v, errResult := tool.validate(fileEditInput{ FilePath: filePath, OldString: "hello", NewString: "goodbye", }) if errResult != nil { t.Fatalf("validate 不应返回错误: %s", errResult.Output) } // 验证 validate 结果的正确性 if v.matchCount != 1 { t.Errorf("matchCount 应为 1, 实际 %d", v.matchCount) } if v.oldString != "hello" { t.Errorf("oldString 应为 'hello', 实际 %q", v.oldString) } if v.newString != "goodbye" { t.Errorf("newString 应为 'goodbye', 实际 %q", v.newString) } // 验证文件没有被修改(validate 是纯验证) data, _ := os.ReadFile(filePath) if string(data) != "hello world" { t.Error("validate 不应修改文件") } } // TestFileEditTool_ValidateFailsGracefully 测试 validate 失败时不修改文件 func TestFileEditTool_ValidateFailsGracefully(t *testing.T) { tool := NewFileEditTool() dir := t.TempDir() filePath := filepath.Join(dir, "test.txt") os.WriteFile(filePath, []byte("hello world"), 0644) _, errResult := tool.validate(fileEditInput{ FilePath: filePath, OldString: "nonexistent", NewString: "replacement", }) if errResult == nil { t.Fatal("应返回错误结果") } if !errResult.IsError { t.Error("应标记为错误") } // 文件未被修改 data, _ := os.ReadFile(filePath) if string(data) != "hello world" { t.Error("validate 失败后文件不应被修改") } } // TestFileEditTool_ValidateMultipleMatches 测试 validate 检测多处匹配 func TestFileEditTool_ValidateMultipleMatches(t *testing.T) { tool := NewFileEditTool() dir := t.TempDir() filePath := filepath.Join(dir, "test.txt") os.WriteFile(filePath, []byte("aaa\nbbb\naaa"), 0644) _, errResult := tool.validate(fileEditInput{ FilePath: filePath, OldString: "aaa", NewString: "zzz", }) if errResult == nil { t.Fatal("多处匹配应返回错误结果") } if !strings.Contains(errResult.Output, "2 matches") { t.Errorf("应提示 2 处匹配: %s", errResult.Output) } } // ───────────────────────────────────────────────────────────────────── // 反消毒测试(Desanitization) // ───────────────────────────────────────────────────────────────────── // TestApplyDesanitizations 测试反消毒功能 func TestApplyDesanitizations(t *testing.T) { tests := []struct { name string input string expected string }{ { "fnr 标签还原", "before after", "before after", }, { "Human 前缀还原", "text\n\nH: content", "text\n\nHuman: content", }, { "Assistant 前缀还原", "text\n\nA: content", "text\n\nAssistant: content", }, { "META_START 还原", "before < META_START > after", "before after", }, { "META_END 还原", "before < META_END > after", "before after", }, { "无需反消毒", "plain text without special tokens", "plain text without special tokens", }, { "多个消毒标记同时存在", "a b\n\nH: c", "a b\n\nHuman: c", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := applyDesanitizations(tt.input) if result != tt.expected { t.Errorf("applyDesanitizations(%q) = %q, 期望 %q", tt.input, result, tt.expected) } }) } } // TestFileEditTool_DesanitizationIntegration 测试反消毒在完整编辑流程中的工作 func TestFileEditTool_DesanitizationIntegration(t *testing.T) { tool := NewFileEditTool() dir := t.TempDir() filePath := filepath.Join(dir, "test.txt") // 文件包含原始的 "\n\nHuman:" 标记 os.WriteFile(filePath, []byte("before\n\nHuman: after"), 0644) // 模型输出的是消毒版 "\n\nH:" input, _ := json.Marshal(fileEditInput{ FilePath: filePath, OldString: "before\n\nH: after", NewString: "before\n\nH: modified", }) result, err := tool.Execute(context.Background(), input, nil) if err != nil { t.Fatalf("执行失败: %v", err) } if result.IsError { t.Fatalf("不应标记为错误: %s", result.Output) } data, _ := os.ReadFile(filePath) expected := "before\n\nHuman: modified" if string(data) != expected { t.Errorf("反消毒替换失败, 期望 %q, 实际 %q", expected, string(data)) } } // ───────────────────────────────────────────────────────────────────── // Curly Quote 测试(弯引号/智能引号) // ───────────────────────────────────────────────────────────────────── // TestNormalizeQuotes 测试引号规范化 func TestNormalizeQuotes(t *testing.T) { tests := []struct { name string input string expected string }{ {"无弯引号", `plain "text"`, `plain "text"`}, {"左右双引号", "\u201chello\u201d", `"hello"`}, {"左右单引号", "\u2018hello\u2019", `'hello'`}, {"混合引号", "\u201chello\u201d and \u2018world\u2019", `"hello" and 'world'`}, {"空字符串", "", ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := normalizeQuotes(tt.input) if result != tt.expected { t.Errorf("normalizeQuotes(%q) = %q, 期望 %q", tt.input, result, tt.expected) } }) } } // TestFindActualString 测试引号规范化匹配 func TestFindActualString(t *testing.T) { tests := []struct { name string content string oldString string expectedFound string expectedViaQuote bool }{ { "精确匹配(无弯引号)", `say "hello"`, `"hello"`, `"hello"`, false, }, { "通过引号规范化匹配", "say \u201chello\u201d", `say "hello"`, "say \u201chello\u201d", true, }, { "完全找不到", "no match here", "something else", "", false, }, { "精确匹配优先于引号规范化", "both \u201chello\u201d and \"hello\"", `"hello"`, `"hello"`, false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { actual, viaQuote := findActualString(tt.content, tt.oldString) if actual != tt.expectedFound { t.Errorf("findActualString: actual = %q, 期望 %q", actual, tt.expectedFound) } if viaQuote != tt.expectedViaQuote { t.Errorf("findActualString: viaQuote = %v, 期望 %v", viaQuote, tt.expectedViaQuote) } }) } } // TestPreserveQuoteStyle 测试引号风格保留 func TestPreserveQuoteStyle(t *testing.T) { tests := []struct { name string actualOld string normalizedOld string newString string expected string }{ { "双引号保留", "\u201chello\u201d", `"hello"`, `"goodbye"`, "\u201cgoodbye\u201d", }, { "缩写撇号", "don\u2019t", "don't", "won't", "won\u2019t", }, { "无弯引号 — 原样返回", `"hello"`, `"hello"`, `"goodbye"`, `"goodbye"`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := preserveQuoteStyle(tt.actualOld, tt.normalizedOld, tt.newString) if result != tt.expected { t.Errorf("preserveQuoteStyle = %q, 期望 %q", result, tt.expected) } }) } } // TestFileEditTool_CurlyQuoteIntegration 测试弯引号在完整编辑流程中的工作 func TestFileEditTool_CurlyQuoteIntegration(t *testing.T) { tool := NewFileEditTool() dir := t.TempDir() filePath := filepath.Join(dir, "test.txt") // 文件包含弯引号 os.WriteFile(filePath, []byte("She said \u201chello\u201d to him"), 0644) // 模型输出直引号 input, _ := json.Marshal(fileEditInput{ FilePath: filePath, OldString: `She said "hello" to him`, NewString: `She said "goodbye" to him`, }) result, err := tool.Execute(context.Background(), input, nil) if err != nil { t.Fatalf("执行失败: %v", err) } if result.IsError { t.Fatalf("不应标记为错误: %s", result.Output) } data, _ := os.ReadFile(filePath) expected := "She said \u201cgoodbye\u201d to him" if string(data) != expected { t.Errorf("弯引号保留失败, 期望 %q, 实际 %q", expected, string(data)) } } // TestIsOpeningPosition 测试开引号位置判断 func TestIsOpeningPosition(t *testing.T) { tests := []struct { name string runes []rune idx int expected bool }{ {"字符串开头", []rune(`"hello`), 0, true}, {"空格后", []rune(` "hello`), 1, true}, {"字母后", []rune(`a"hello`), 1, false}, {"左括号后", []rune(`("hello`), 1, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := isOpeningPosition(tt.runes, tt.idx) if result != tt.expected { t.Errorf("isOpeningPosition = %v, 期望 %v", result, tt.expected) } }) } } // TestIsApostrophe 测试缩写撇号判断 func TestIsApostrophe(t *testing.T) { tests := []struct { name string runes []rune idx int expected bool }{ {"don't 中的撇号", []rune("don't"), 3, true}, {"单引号在开头", []rune("'hello"), 0, false}, {"单引号在末尾", []rune("hello'"), 5, false}, {"空格后的单引号", []rune("a 'b"), 2, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := isApostrophe(tt.runes, tt.idx) if result != tt.expected { t.Errorf("isApostrophe = %v, 期望 %v", result, tt.expected) } }) } } // ───────────────────────────────────────────────────────────────────── // 文件缓存集成测试 // ───────────────────────────────────────────────────────────────────── // TestFileEditTool_WithCache 测试编辑后文件缓存被更新 func TestFileEditTool_WithCache(t *testing.T) { cache := newMockFileCache() tool := NewFileEditToolWithCache(cache) dir := t.TempDir() filePath := filepath.Join(dir, "test.txt") os.WriteFile(filePath, []byte("hello world"), 0644) input, _ := json.Marshal(fileEditInput{ FilePath: filePath, OldString: "hello", NewString: "goodbye", }) result, err := tool.Execute(context.Background(), input, nil) if err != nil { t.Fatalf("执行失败: %v", err) } if result.IsError { t.Fatalf("不应标记为错误: %s", result.Output) } // 验证缓存被更新 cached, ok := cache.records[filePath] if !ok { t.Fatal("编辑后缓存应被更新") } if string(cached) != "goodbye world" { t.Errorf("缓存内容不匹配, 期望 'goodbye world', 实际 %q", string(cached)) } } // TestFileEditTool_WithoutCache 测试无缓存时正常工作 func TestFileEditTool_WithoutCache(t *testing.T) { tool := NewFileEditTool() // 无缓存 dir := t.TempDir() filePath := filepath.Join(dir, "test.txt") os.WriteFile(filePath, []byte("hello world"), 0644) input, _ := json.Marshal(fileEditInput{ FilePath: filePath, OldString: "hello", NewString: "goodbye", }) result, err := tool.Execute(context.Background(), input, nil) if err != nil { t.Fatalf("执行失败: %v", err) } if result.IsError { t.Fatalf("不应标记为错误: %s", result.Output) } // 无缓存时也应正常工作 data, _ := os.ReadFile(filePath) if string(data) != "goodbye world" { t.Errorf("编辑失败, 实际: %q", string(data)) } } // TestFileEditTool_CacheNotUpdatedOnError 测试编辑失败时缓存不更新 func TestFileEditTool_CacheNotUpdatedOnError(t *testing.T) { cache := newMockFileCache() tool := NewFileEditToolWithCache(cache) dir := t.TempDir() filePath := filepath.Join(dir, "test.txt") os.WriteFile(filePath, []byte("hello world"), 0644) // old_string 不存在 → 编辑失败 input, _ := json.Marshal(fileEditInput{ FilePath: filePath, OldString: "nonexistent", NewString: "replacement", }) result, _ := tool.Execute(context.Background(), input, nil) if !result.IsError { t.Fatal("应返回错误") } // 缓存不应被更新 if _, ok := cache.records[filePath]; ok { t.Error("编辑失败时缓存不应被更新") } } // ───────────────────────────────────────────────────────────────────── // 辅助函数测试(保留原有测试) // ───────────────────────────────────────────────────────────────────── // TestCheckWhitespaceDifference 测试空白差异检测 func TestCheckWhitespaceDifference(t *testing.T) { tests := []struct { name string content string old string expected bool }{ {"tab vs space", " hello world", "\thello world", true}, {"trailing space", "hello world ", "hello world", true}, {"完全不同", "hello", "goodbye", false}, {"完全相同", "hello", "hello", true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := checkWhitespaceDifference(tt.content, tt.old) if result != tt.expected { t.Errorf("checkWhitespaceDifference(%q, %q) = %v, 期望 %v", tt.content, tt.old, result, tt.expected) } }) } } // TestLevenshtein 测试编辑距离计算 func TestLevenshtein(t *testing.T) { tests := []struct { name string a, b string expected int }{ {"相同字符串", "hello", "hello", 0}, {"空字符串到非空", "", "hello", 5}, {"非空到空", "hello", "", 5}, {"单字符替换", "hello", "hallo", 1}, {"插入一个字符", "hell", "hello", 1}, {"删除一个字符", "hello", "hell", 1}, {"完全不同", "abc", "xyz", 3}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := levenshtein(tt.a, tt.b) if result != tt.expected { t.Errorf("levenshtein(%q, %q) = %d, 期望 %d", tt.a, tt.b, result, tt.expected) } }) } } // TestFindMatchLineNumbers 测试行号查找 func TestFindMatchLineNumbers(t *testing.T) { content := "foo\nbar\nfoo\nbaz\nfoo" lineNums := findMatchLineNumbers(content, "foo") if len(lineNums) != 3 { t.Fatalf("期望 3 个匹配, 实际 %d", len(lineNums)) } expected := []int{1, 3, 5} for i, ln := range lineNums { if ln != expected[i] { t.Errorf("第 %d 个匹配行号: %d, 期望 %d", i, ln, expected[i]) } } } // TestMinInt 测试最小值函数 func TestMinInt(t *testing.T) { if minInt(3, 5) != 3 { t.Error("minInt(3, 5) 应返回 3") } if minInt(5, 3) != 3 { t.Error("minInt(5, 3) 应返回 3") } if minInt(3, 3) != 3 { t.Error("minInt(3, 3) 应返回 3") } } // TestRuneOffsetToByteOffset 测试 rune 偏移到字节偏移的转换 func TestRuneOffsetToByteOffset(t *testing.T) { // ASCII 字符串:字节偏移 == rune 偏移 original := "hello" normalized := "hello" offset := runeOffsetToByteOffset(original, normalized, 2) if offset != 2 { t.Errorf("ASCII: 期望 2, 实际 %d", offset) } // 包含多字节字符的字符串 original2 := "a\u201cb\u201dc" // a + 左双引号(3字节) + b + 右双引号(3字节) + c normalized2 := normalizeQuotes(original2) // a"b"c (5字节) // normalized2 中 idx=0 → original2 中 idx=0 (字符 'a') offset2 := runeOffsetToByteOffset(original2, normalized2, 0) if offset2 != 0 { t.Errorf("多字节起始: 期望 0, 实际 %d", offset2) } // normalized2 中 idx=1 → original2 中 idx=1 (跳过'a') offset3 := runeOffsetToByteOffset(original2, normalized2, 1) if offset3 != 1 { t.Errorf("多字节跳过a: 期望 1, 实际 %d", offset3) } // normalized2 中 idx=2 → original2 中要跳过 'a' + 左双引号(3字节) = idx 4 offset4 := runeOffsetToByteOffset(original2, normalized2, 2) if offset4 != 4 { t.Errorf("多字节跳过弯引号: 期望 4, 实际 %d", offset4) } } // ───────────────────────────────────────────────────────────────────── // Unified Diff 生成(多 hunk 验证) // ───────────────────────────────────────────────────────────────────── func TestFileEdit_DiffMultiHunk(t *testing.T) { // 构造一个 20 行文件,替换一行, // 验证 diff 输出使用 internal/diff 包 dir := t.TempDir() file := filepath.Join(dir, "multi.txt") lines := make([]string, 20) for i := range lines { lines[i] = fmt.Sprintf("row_%02d_content", i+1) } os.WriteFile(file, []byte(strings.Join(lines, "\n")), 0644) tool := NewFileEditTool() input, _ := json.Marshal(map[string]any{ "file_path": file, "old_string": "row_02_content", "new_string": "ROW_02_MODIFIED", }) result, err := tool.Execute(context.Background(), input, nil) if err != nil { t.Fatalf("执行失败: %v", err) } if result.IsError { t.Fatalf("不应报错: %s", result.Output) } // 输出应包含 unified diff 标记 if !strings.Contains(result.Output, "@@") { t.Errorf("输出应包含 hunk header (@@): %s", result.Output) } if !strings.Contains(result.Output, "-row_02_content") { t.Errorf("diff 应包含删除行: %s", result.Output) } if !strings.Contains(result.Output, "+ROW_02_MODIFIED") { t.Errorf("diff 应包含插入行: %s", result.Output) } } func TestFileEdit_DiffNoChange(t *testing.T) { // 虽然 validate 会阻止 old==new,但验证 diff 包对相同内容返回空 dir := t.TempDir() file := filepath.Join(dir, "nochange.txt") os.WriteFile(file, []byte("hello world"), 0644) tool := NewFileEditTool() input, _ := json.Marshal(map[string]any{ "file_path": file, "old_string": "hello", "new_string": "goodbye", }) result, err := tool.Execute(context.Background(), input, nil) if err != nil { t.Fatalf("执行失败: %v", err) } if result.IsError { t.Fatalf("不应报错: %s", result.Output) } // 应有 diff 输出 if !strings.Contains(result.Output, "-hello") || !strings.Contains(result.Output, "+goodbye") { t.Errorf("输出应包含 diff: %s", result.Output) } } // ───────────────────────────────────────────────────────────────────── // 符号链接穿透写入 + 沙箱检查 // ───────────────────────────────────────────────────────────────────── func TestFileEdit_SymlinkPassthrough(t *testing.T) { // 符号链接指向同目录内的文件 → 应穿透写入 dir := t.TempDir() // 创建真实文件 realFile := filepath.Join(dir, "real.txt") os.WriteFile(realFile, []byte("hello world"), 0644) // 创建符号链接 linkFile := filepath.Join(dir, "link.txt") if err := os.Symlink(realFile, linkFile); err != nil { t.Skip("无法创建符号链接(可能在 Windows 或无权限环境)") } // 使用 dir 作为 cwd,链接目标在 cwd 内 tool := NewFileEditToolWithCwd(dir) input, _ := json.Marshal(map[string]any{ "file_path": linkFile, "old_string": "hello", "new_string": "goodbye", }) result, err := tool.Execute(context.Background(), input, nil) if err != nil { t.Fatalf("不应返回 error: %v", err) } if result.IsError { t.Fatalf("cwd 内的符号链接应允许穿透写入: %s", result.Output) } // 验证真实文件已被修改 content, _ := os.ReadFile(realFile) if !strings.Contains(string(content), "goodbye") { t.Errorf("真实文件应被修改为 goodbye,实际: %s", content) } } func TestFileEdit_SymlinkSandboxEscape(t *testing.T) { // 符号链接指向沙箱外的文件 → 应被阻止 workDir := t.TempDir() outsideDir := t.TempDir() // 创建沙箱外的真实文件 realFile := filepath.Join(outsideDir, "secret.txt") os.WriteFile(realFile, []byte("secret data"), 0644) // 在工作目录内创建指向沙箱外文件的符号链接 linkFile := filepath.Join(workDir, "escape.txt") if err := os.Symlink(realFile, linkFile); err != nil { t.Skip("无法创建符号链接") } // cwd 设为 workDir,链接目标在 outsideDir → 沙箱外 tool := NewFileEditToolWithCwd(workDir) input, _ := json.Marshal(map[string]any{ "file_path": linkFile, "old_string": "secret", "new_string": "hacked", }) result, err := tool.Execute(context.Background(), input, nil) if err != nil { t.Fatalf("不应返回 Go error: %v", err) } if !result.IsError { t.Fatal("沙箱外的符号链接应被阻止") } if !strings.Contains(result.Output, "outside working directory") { t.Errorf("错误消息应提示在工作目录外: %s", result.Output) } // 验证沙箱外的文件未被修改 content, _ := os.ReadFile(realFile) if string(content) != "secret data" { t.Errorf("沙箱外文件不应被修改: %s", content) } } // ───────────────────────────────────────────────────────────────────── // 原子写入(PID+时间戳临时文件 + 无残留) // ───────────────────────────────────────────────────────────────────── func TestFileEdit_AtomicWrite(t *testing.T) { dir := t.TempDir() file := filepath.Join(dir, "atomic.txt") os.WriteFile(file, []byte("original content"), 0644) tool := NewFileEditTool() input, _ := json.Marshal(map[string]any{ "file_path": file, "old_string": "original", "new_string": "modified", }) result, err := tool.Execute(context.Background(), input, nil) if err != nil { t.Fatalf("执行失败: %v", err) } if result.IsError { t.Fatalf("不应报错: %s", result.Output) } // 验证内容已更新 content, _ := os.ReadFile(file) if !strings.Contains(string(content), "modified") { t.Errorf("文件内容应包含 modified: %s", content) } // 验证没有残留临时文件(新格式:.tmp.PID.TIMESTAMP) entries, err := os.ReadDir(dir) if err != nil { t.Fatalf("无法读取目录: %v", err) } for _, entry := range entries { if strings.Contains(entry.Name(), ".tmp.") { t.Errorf("不应有残留的临时文件: %s", entry.Name()) } } } func TestFileEdit_AtomicWrite_PreservesPermissions(t *testing.T) { dir := t.TempDir() file := filepath.Join(dir, "perms.txt") os.WriteFile(file, []byte("content"), 0755) tool := NewFileEditTool() input, _ := json.Marshal(map[string]any{ "file_path": file, "old_string": "content", "new_string": "new content", }) result, err := tool.Execute(context.Background(), input, nil) if err != nil { t.Fatalf("执行失败: %v", err) } if result.IsError { t.Fatalf("不应报错: %s", result.Output) } // 验证权限被保留 info, err := os.Stat(file) if err != nil { t.Fatalf("stat 失败: %v", err) } if info.Mode().Perm() != 0755 { t.Errorf("权限应为 0755,实际 %o", info.Mode().Perm()) } } func TestFileEdit_DiffInOutput(t *testing.T) { dir := t.TempDir() file := filepath.Join(dir, "diff.txt") os.WriteFile(file, []byte("line1\nold_line\nline3"), 0644) tool := NewFileEditTool() input, _ := json.Marshal(map[string]any{ "file_path": file, "old_string": "old_line", "new_string": "new_line", }) result, err := tool.Execute(context.Background(), input, nil) if err != nil { t.Fatalf("执行失败: %v", err) } // 输出应包含 diff if !strings.Contains(result.Output, "-old_line") || !strings.Contains(result.Output, "+new_line") { t.Errorf("输出应包含 unified diff: %s", result.Output) } } // ───────────────────────────────────────────────────────────────────── // isPathUnder 辅助函数测试 // ───────────────────────────────────────────────────────────────────── func TestIsPathUnder(t *testing.T) { tests := []struct { name string child string parent string expected bool }{ {"子目录", "/a/b/c", "/a/b", true}, {"同级目录", "/a/b", "/a/b", true}, {"父目录外", "/a/c", "/a/b", false}, {"完全不同", "/x/y", "/a/b", false}, {"空父目录", "/a/b", "", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := isPathUnder(tt.child, tt.parent) if result != tt.expected { t.Errorf("isPathUnder(%q, %q) = %v, 期望 %v", tt.child, tt.parent, result, tt.expected) } }) } } // ───────────────────────────────────────────────────────────────────── // ToolCapability 协议测试 // ───────────────────────────────────────────────────────────────────── // TestFileEditTool_Capability 测试 FileEdit 的能力声明 func TestFileEditTool_Capability(t *testing.T) { tool := NewFileEditTool() cap := tool.Capability() if !cap.DryRun { t.Error("FileEdit 应该支持 DryRun") } if !cap.Reversible { t.Error("FileEdit 应该支持 Reversible") } if cap.UndoMethod != "tool" { t.Errorf("UndoMethod 应该是 'tool',实际: %q", cap.UndoMethod) } if cap.UndoToolName != "Write" { t.Errorf("UndoToolName 应该是 'Write',实际: %q", cap.UndoToolName) } if len(cap.AffectedResources) == 0 || cap.AffectedResources[0] != "file" { t.Errorf("AffectedResources 应包含 'file': %v", cap.AffectedResources) } } // TestFileEditTool_DryRun 测试 FileEdit 的模拟执行 func TestFileEditTool_DryRun(t *testing.T) { tool := NewFileEditTool() dir := t.TempDir() filePath := filepath.Join(dir, "test.txt") os.WriteFile(filePath, []byte("hello world"), 0644) input, _ := json.Marshal(fileEditInput{ FilePath: filePath, OldString: "hello", NewString: "goodbye", }) result, err := tool.DryRun(context.Background(), input) if err != nil { t.Fatalf("DryRun 失败: %v", err) } if result.WouldAffect != filePath { t.Errorf("WouldAffect 应该是文件路径: %q", result.WouldAffect) } if !strings.Contains(result.Preview, "hello") || !strings.Contains(result.Preview, "goodbye") { t.Errorf("Preview 应该包含 diff 信息: %q", result.Preview) } // 验证文件未被修改 data, _ := os.ReadFile(filePath) if string(data) != "hello world" { t.Error("DryRun 不应该修改文件") } } // TestFileEditTool_DryRun_FileNotFound 测试 DryRun 对不存在文件的处理 func TestFileEditTool_DryRun_FileNotFound(t *testing.T) { tool := NewFileEditTool() input, _ := json.Marshal(fileEditInput{ FilePath: "/nonexistent/file.txt", OldString: "foo", NewString: "bar", }) result, err := tool.DryRun(context.Background(), input) if err != nil { t.Fatalf("DryRun 不应返回 error: %v", err) } if !strings.Contains(result.Preview, "not found") { t.Errorf("DryRun 对不存在的文件应包含 'not found' 提示: %q", result.Preview) } } // TestFileEditTool_GenerateUndo 测试 FileEdit 的撤销信息生成 func TestFileEditTool_GenerateUndo(t *testing.T) { tool := NewFileEditTool() input, _ := json.Marshal(fileEditInput{ FilePath: "/tmp/test.txt", OldString: "foo", NewString: "bar", }) undo, err := tool.GenerateUndo(context.Background(), input, &tools.Result{Output: "ok"}) if err != nil { t.Fatalf("GenerateUndo 失败: %v", err) } if undo.ToolName != "Write" { t.Errorf("撤销工具应该是 'Write',实际: %q", undo.ToolName) } if undo.Input["file_path"] != "/tmp/test.txt" { t.Errorf("撤销输入应包含文件路径: %v", undo.Input) } } // TestFileEditTool_WithFileHistory 测试 FileEdit 配合文件历史 func TestFileEditTool_WithFileHistory(t *testing.T) { dir := t.TempDir() filePath := filepath.Join(dir, "test.txt") os.WriteFile(filePath, []byte("hello world"), 0644) // 创建 mock 文件历史 history := &mockFileHistory{} tool := NewFileEditToolComplete(nil, history, dir) tool.SetMessageID("msg-1") input, _ := json.Marshal(fileEditInput{ FilePath: filePath, OldString: "hello", NewString: "goodbye", }) result, err := tool.Execute(context.Background(), input, nil) if err != nil { t.Fatalf("执行失败: %v", err) } if result.IsError { t.Fatalf("不应标记为错误: %s", result.Output) } // 验证文件历史被调用 if len(history.calls) != 1 { t.Errorf("文件历史应该被调用 1 次,实际: %d", len(history.calls)) } if history.calls[0].method != "BeforeEdit" { t.Errorf("应该调用 BeforeEdit,实际: %q", history.calls[0].method) } if history.calls[0].filePath != filePath { t.Errorf("文件路径不匹配: %q", history.calls[0].filePath) } if history.calls[0].messageID != "msg-1" { t.Errorf("消息 ID 不匹配: %q", history.calls[0].messageID) } } // ───────────────────────────────────────────────────────────────────── // Mock FileHistoryRecorder // ───────────────────────────────────────────────────────────────────── type historyCall struct { method string filePath string messageID string } type mockFileHistory struct { calls []historyCall } func (m *mockFileHistory) BeforeEdit(filePath string, messageID string) error { m.calls = append(m.calls, historyCall{"BeforeEdit", filePath, messageID}) return nil } func (m *mockFileHistory) BeforeWrite(filePath string, messageID string) error { m.calls = append(m.calls, historyCall{"BeforeWrite", filePath, messageID}) return nil }