177 lines
4.5 KiB
Go
177 lines
4.5 KiB
Go
package builtin
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"nub/internal/tool"
|
|
)
|
|
|
|
func testEnv(t *testing.T) tool.Env {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
return tool.Env{Cwd: dir, RepoRoot: dir}
|
|
}
|
|
|
|
func writeFile(t *testing.T, env tool.Env, name, content string) string {
|
|
t.Helper()
|
|
path := filepath.Join(env.RepoRoot, name)
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return path
|
|
}
|
|
|
|
func TestEdit_AmbiguousMatchErrorsWithCount(t *testing.T) {
|
|
env := testEnv(t)
|
|
writeFile(t, env, "f.go", "foo\nfoo\nfoo\n")
|
|
|
|
in, _ := json.Marshal(map[string]any{"path": "f.go", "old_string": "foo", "new_string": "bar"})
|
|
res, err := EditTool{}.Run(context.Background(), in, env)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !res.IsError {
|
|
t.Fatal("expected IsError for ambiguous match")
|
|
}
|
|
if !contains(res.ForModel, "3 matches") {
|
|
t.Errorf("error message should mention match count, got: %q", res.ForModel)
|
|
}
|
|
}
|
|
|
|
func TestEdit_ReplaceAllReplacesEveryMatch(t *testing.T) {
|
|
env := testEnv(t)
|
|
path := writeFile(t, env, "f.go", "foo\nfoo\nfoo\n")
|
|
|
|
in, _ := json.Marshal(map[string]any{"path": "f.go", "old_string": "foo", "new_string": "bar", "replace_all": true})
|
|
res, err := EditTool{}.Run(context.Background(), in, env)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res.IsError {
|
|
t.Fatalf("unexpected error: %s", res.ForModel)
|
|
}
|
|
data, _ := os.ReadFile(path)
|
|
if string(data) != "bar\nbar\nbar\n" {
|
|
t.Errorf("content = %q", data)
|
|
}
|
|
}
|
|
|
|
func TestEdit_UniqueMatchSucceeds(t *testing.T) {
|
|
env := testEnv(t)
|
|
writeFile(t, env, "f.go", "unique_marker\nother\n")
|
|
|
|
in, _ := json.Marshal(map[string]any{"path": "f.go", "old_string": "unique_marker", "new_string": "replaced"})
|
|
res, err := EditTool{}.Run(context.Background(), in, env)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res.IsError {
|
|
t.Fatalf("unexpected error: %s", res.ForModel)
|
|
}
|
|
}
|
|
|
|
func TestPath_EscapeViaDotDotIsRejected(t *testing.T) {
|
|
env := testEnv(t)
|
|
in, _ := json.Marshal(map[string]any{"path": "../outside.txt", "old_string": "a", "new_string": "b"})
|
|
res, err := EditTool{}.Run(context.Background(), in, env)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !res.IsError {
|
|
t.Fatal("expected path escape to be rejected")
|
|
}
|
|
}
|
|
|
|
func TestPath_EscapeViaSymlinkIsRejected(t *testing.T) {
|
|
env := testEnv(t)
|
|
outsideDir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(outsideDir, "secret.txt"), []byte("secret"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
link := filepath.Join(env.RepoRoot, "link")
|
|
if err := os.Symlink(outsideDir, link); err != nil {
|
|
t.Skipf("symlinks not supported: %v", err)
|
|
}
|
|
|
|
in, _ := json.Marshal(map[string]any{"path": "link/secret.txt"})
|
|
res, err := ReadTool{}.Run(context.Background(), in, env)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !res.IsError {
|
|
t.Fatal("expected symlink escape to be rejected")
|
|
}
|
|
}
|
|
|
|
func TestBash_TimeoutKillsProcessGroup(t *testing.T) {
|
|
env := testEnv(t)
|
|
in, _ := json.Marshal(map[string]any{"command": "sleep 30", "timeout_seconds": 1})
|
|
|
|
start := time.Now()
|
|
res, err := BashTool{}.Run(context.Background(), in, env)
|
|
elapsed := time.Since(start)
|
|
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !res.IsError {
|
|
t.Fatal("expected timeout to be reported as error")
|
|
}
|
|
if elapsed > 5*time.Second {
|
|
t.Errorf("timeout took too long: %s", elapsed)
|
|
}
|
|
}
|
|
|
|
func TestBash_CapturesStdoutAndExitCode(t *testing.T) {
|
|
env := testEnv(t)
|
|
in, _ := json.Marshal(map[string]any{"command": "echo hi && exit 3"})
|
|
|
|
res, err := BashTool{}.Run(context.Background(), in, env)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !res.IsError {
|
|
t.Fatal("expected non-zero exit to be reported as error")
|
|
}
|
|
if !contains(res.ForModel, "hi") {
|
|
t.Errorf("expected stdout captured, got: %q", res.ForModel)
|
|
}
|
|
}
|
|
|
|
func TestGlob_RespectsGitignore(t *testing.T) {
|
|
env := testEnv(t)
|
|
writeFile(t, env, ".gitignore", "*.log\n")
|
|
writeFile(t, env, "keep.txt", "x")
|
|
writeFile(t, env, "skip.log", "x")
|
|
|
|
in, _ := json.Marshal(map[string]any{"pattern": "**/*"})
|
|
res, err := GlobTool{}.Run(context.Background(), in, env)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if contains(res.ForModel, "skip.log") {
|
|
t.Errorf("gitignored file leaked into glob result: %q", res.ForModel)
|
|
}
|
|
if !contains(res.ForModel, "keep.txt") {
|
|
t.Errorf("expected keep.txt in result: %q", res.ForModel)
|
|
}
|
|
}
|
|
|
|
func contains(s, sub string) bool {
|
|
return len(s) >= len(sub) && (func() bool {
|
|
for i := 0; i+len(sub) <= len(s); i++ {
|
|
if s[i:i+len(sub)] == sub {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
})()
|
|
}
|