v0.48.0 ad3af57

grep_test.go RawHistoryFolder

1package tools
2
3import (
4	"context"
5	"encoding/json"
6	"errors"
7	"os"
8	"path/filepath"
9	"runtime"
10	"strings"
11	"testing"
12	"time"
13
14	"github.com/bnema/zut/packages/core"
15	"github.com/bnema/zut/packages/provider"
16)
17
18func TestGrepPrefersRGAndFallsBackToGrep(t *testing.T) {
19	skipGrepScriptTestsOnWindows(t)
20	root := t.TempDir()
21	rg := writeGrepScript(t, "#!/bin/sh\nprintf 'rg selected\\n'\n")
22	grep := writeGrepScript(t, "#!/bin/sh\nprintf 'grep selected\\n'\n")
23
24	t.Run("rg", func(t *testing.T) {
25		tool := &GrepTool{CWD: root, LookPath: func(name string) (string, error) {
26			switch name {
27			case "rg":
28				return rg, nil
29			case "grep":
30				return grep, nil
31			default:
32				return "", os.ErrNotExist
33			}
34		}}
35		result := executeGrepTest(t, tool, root)
36		if got := grepText(result); got != "rg selected\n" {
37			t.Fatalf("output = %q, want rg output", got)
38		}
39		if grepDetails(result)["engine"] != "rg" {
40			t.Fatalf("engine = %v, want rg", grepDetails(result)["engine"])
41		}
42	})
43
44	t.Run("grep fallback", func(t *testing.T) {
45		tool := &GrepTool{CWD: root, LookPath: func(name string) (string, error) {
46			if name == "rg" {
47				return "", os.ErrNotExist
48			}
49			return grep, nil
50		}}
51		result := executeGrepTest(t, tool, root)
52		if got := grepText(result); got != "grep selected\n" {
53			t.Fatalf("output = %q, want grep output", got)
54		}
55		if grepDetails(result)["engine"] != "grep" {
56			t.Fatalf("engine = %v, want grep", grepDetails(result)["engine"])
57		}
58	})
59}
60
61func TestGrepChecksSandboxReadScopeBeforeRunning(t *testing.T) {
62	skipGrepScriptTestsOnWindows(t)
63	base := t.TempDir()
64	root := filepath.Join(base, "workspace")
65	if err := os.Mkdir(root, 0o755); err != nil {
66		t.Fatal(err)
67	}
68	outside := filepath.Join(base, "outside")
69	if err := os.Mkdir(outside, 0o755); err != nil {
70		t.Fatal(err)
71	}
72	sandbox := NewSandbox(root)
73	sandbox.Lock()
74	called := false
75	tool := &GrepTool{
76		CWD:     root,
77		Sandbox: sandbox,
78		LookPath: func(string) (string, error) {
79			called = true
80			return "", errors.New("lookup should not run")
81		},
82	}
83	_, err := tool.Execute(context.Background(), grepArgsJSON("needle", outside), nil)
84	if err == nil || !strings.Contains(err.Error(), "jailed") {
85		t.Fatalf("error = %v, want sandbox denial", err)
86	}
87	if called {
88		t.Fatal("executable lookup ran before the sandbox check")
89	}
90}
91
92func TestGrepNoMatchIsSuccessful(t *testing.T) {
93	skipGrepScriptTestsOnWindows(t)
94	root := t.TempDir()
95	noMatch := writeGrepScript(t, "#!/bin/sh\nexit 1\n")
96	tool := &GrepTool{CWD: root, LookPath: lookupScripts(map[string]string{"rg": noMatch})}
97	result := executeGrepTest(t, tool, root)
98	if result.IsError {
99		t.Fatal("no-match result is an error")
100	}
101	if got := grepDetails(result)["exit_code"]; got != 0 {
102		t.Fatalf("reported exit code = %v, want 0", got)
103	}
104	if got := grepDetails(result)["process_exit_code"]; got != 1 {
105		t.Fatalf("process exit code = %v, want 1", got)
106	}
107	if got := grepText(result); got != "No matches found.\n" {
108		t.Fatalf("output = %q, want no-match message", got)
109	}
110}
111
112func TestGrepBoundsOutput(t *testing.T) {
113	skipGrepScriptTestsOnWindows(t)
114	root := t.TempDir()
115	script := writeGrepScript(t, "#!/bin/sh\ni=0\nwhile [ $i -lt 4000 ]; do\n  printf 'matching line %04d with enough padding to hit the cap\\n' $i\n  i=$((i + 1))\ndone\n")
116	tool := &GrepTool{CWD: root, LookPath: lookupScripts(map[string]string{"rg": script})}
117	result := executeGrepTest(t, tool, root)
118	text := grepText(result)
119	if !grepDetails(result)["bytes_truncated"].(bool) {
120		t.Fatal("bytes_truncated = false, want true")
121	}
122	if !strings.Contains(text, "truncated at") {
123		t.Fatalf("bounded output has no truncation marker: %q", text[len(text)-100:])
124	}
125	if len(text) > maxGrepOutputBytes {
126		t.Fatalf("bounded output length = %d, cap exceeded", len(text))
127	}
128}
129
130func TestGrepExactlyMaxOutputLinesIsNotTruncated(t *testing.T) {
131	input := strings.Repeat("matching line\n", maxGrepOutputLines)
132	output := &grepOutput{}
133	if _, err := output.Write([]byte(input)); err != nil {
134		t.Fatal(err)
135	}
136	got, truncated := output.text()
137	if truncated {
138		t.Fatal("exactly maxGrepOutputLines was marked truncated")
139	}
140	if got != input {
141		t.Fatalf("output was altered: got %d bytes, want %d", len(got), len(input))
142	}
143}
144
145func TestGrepCancellationPropagates(t *testing.T) {
146	skipGrepScriptTestsOnWindows(t)
147	root := t.TempDir()
148	started := filepath.Join(root, "started")
149	script := writeGrepScript(t, "#!/bin/sh\nprintf started > started\nsleep 10\n")
150	tool := &GrepTool{CWD: root, LookPath: lookupScripts(map[string]string{"rg": script})}
151	ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
152	defer cancel()
153	resultCh := make(chan error, 1)
154	go func() {
155		_, err := tool.Execute(ctx, grepArgsJSON("needle", root), nil)
156		resultCh <- err
157	}()
158
159	startupDeadline := time.Now().Add(time.Second)
160	for {
161		if _, err := os.Stat(started); err == nil {
162			break
163		}
164		if time.Now().After(startupDeadline) {
165			t.Fatal("grep fixture did not start before the test deadline")
166		}
167		time.Sleep(5 * time.Millisecond)
168	}
169
170	select {
171	case err := <-resultCh:
172		if !errors.Is(err, context.DeadlineExceeded) {
173			t.Fatalf("error = %v, want deadline exceeded", err)
174		}
175	case <-time.After(time.Second):
176		t.Fatal("grep did not stop after context cancellation")
177	}
178}
179
180func TestGrepInternalTimeoutStopsSearch(t *testing.T) {
181	skipGrepScriptTestsOnWindows(t)
182	root := t.TempDir()
183	if err := os.WriteFile(filepath.Join(root, "hold"), nil, 0o644); err != nil {
184		t.Fatal(err)
185	}
186	script := writeGrepScript(t, "#!/bin/sh\nprintf 'started\\n'\nexec tail -f hold\n")
187	tool := &GrepTool{CWD: root, LookPath: lookupScripts(map[string]string{"rg": script})}
188
189	resultCh := make(chan error, 1)
190	go func() {
191		_, err := tool.executeWithTimeout(context.Background(), grepArgsJSON("needle", root), nil, 100*time.Millisecond)
192		resultCh <- err
193	}()
194
195	err := waitForGrepResult(t, resultCh)
196	var timeoutErr *grepTimeoutError
197	if !errors.As(err, &timeoutErr) {
198		t.Fatalf("error = %v, want internal grep timeout", err)
199	}
200	if errors.Is(err, context.DeadlineExceeded) {
201		t.Fatalf("internal timeout error = %v, want distinct caller deadline error", err)
202	}
203	if !strings.Contains(err.Error(), "try narrowing the path or glob") {
204		t.Fatalf("error = %v, want path/glob narrowing guidance", err)
205	}
206}
207
208func TestGrepCallerDeadlineTakesPrecedenceOverInternalTimeout(t *testing.T) {
209	skipGrepScriptTestsOnWindows(t)
210	root := t.TempDir()
211	if err := os.WriteFile(filepath.Join(root, "hold"), nil, 0o644); err != nil {
212		t.Fatal(err)
213	}
214	script := writeGrepScript(t, "#!/bin/sh\nprintf 'started\\n'\nexec tail -f hold\n")
215	tool := &GrepTool{CWD: root, LookPath: lookupScripts(map[string]string{"rg": script})}
216	ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
217	defer cancel()
218
219	resultCh := make(chan error, 1)
220	go func() {
221		_, err := tool.executeWithTimeout(ctx, grepArgsJSON("needle", root), nil, time.Second)
222		resultCh <- err
223	}()
224
225	err := waitForGrepResult(t, resultCh)
226	if !errors.Is(err, context.DeadlineExceeded) {
227		t.Fatalf("error = %v, want caller deadline exceeded", err)
228	}
229	var timeoutErr *grepTimeoutError
230	if errors.As(err, &timeoutErr) {
231		t.Fatalf("error = %v, want caller deadline rather than internal timeout", err)
232	}
233}
234
235func waitForGrepResult(t *testing.T, resultCh <-chan error) error {
236	t.Helper()
237	timer := time.NewTimer(2 * time.Second)
238	defer timer.Stop()
239	select {
240	case err := <-resultCh:
241		return err
242	case <-timer.C:
243		t.Fatal("grep fixture did not stop before the test deadline")
244		return nil
245	}
246}
247
248func TestGrepDoesNotRequireBashPermission(t *testing.T) {
249	skipGrepScriptTestsOnWindows(t)
250	root := t.TempDir()
251	script := writeGrepScript(t, "#!/bin/sh\nprintf 'read-only search\\n'\n")
252	sandbox := NewSandbox(root)
253	sandbox.Lock()
254	permissions := &PermissionSet{}
255	permissions.FS.Read = []string{root}
256	permissions.Bash.Mode = "none"
257	sandbox.SetPermissions(permissions)
258	tool := &GrepTool{CWD: root, Sandbox: sandbox, LookPath: lookupScripts(map[string]string{"rg": script})}
259	result := executeGrepTest(t, tool, root)
260	if got := grepText(result); got != "read-only search\n" {
261		t.Fatalf("output = %q", got)
262	}
263}
264
265func TestGrepSchemaValidation(t *testing.T) {
266	tool := &GrepTool{LookPath: func(string) (string, error) {
267		t.Fatal("schema-invalid input reached executable lookup")
268		return "", nil
269	}}
270	for _, tc := range []struct {
271		name string
272		raw  string
273	}{
274		{"not object", `[]`},
275		{"missing pattern", `{"path":"."}`},
276		{"missing path", `{"pattern":"needle"}`},
277		{"empty pattern", `{"pattern":"","path":"."}`},
278		{"wrong type", `{"pattern":true,"path":"."}`},
279		{"unknown field", `{"pattern":"needle","path":".","shell":"echo"}`},
280		{"nul", "{\"pattern\":\"needle\\u0000\",\"path\":\".\"}"},
281	} {
282		t.Run(tc.name, func(t *testing.T) {
283			if _, err := tool.Execute(context.Background(), []byte(tc.raw), nil); err == nil {
284				t.Fatal("invalid arguments were accepted")
285			}
286		})
287	}
288
289	var schema map[string]any
290	if err := json.Unmarshal(tool.Schema(), &schema); err != nil {
291		t.Fatal(err)
292	}
293	if schema["type"] != "object" {
294		t.Fatalf("schema type = %v", schema["type"])
295	}
296	if schema["additionalProperties"] != false {
297		t.Fatalf("schema additionalProperties = %v", schema["additionalProperties"])
298	}
299}
300
301func executeGrepTest(t *testing.T, tool *GrepTool, root string) core.ToolResult {
302	t.Helper()
303	result, err := tool.Execute(context.Background(), grepArgsJSON("needle", root), nil)
304	if err != nil {
305		t.Fatal(err)
306	}
307	return result
308}
309
310func grepDetails(result core.ToolResult) map[string]any {
311	return result.Details.(map[string]any)
312}
313
314func grepText(result core.ToolResult) string {
315	if len(result.Content) == 0 {
316		return ""
317	}
318	block, ok := result.Content[0].(provider.TextBlock)
319	if !ok {
320		return ""
321	}
322	return block.Text
323}
324
325func grepArgsJSON(pattern, path string) []byte {
326	return []byte(`{"pattern":` + strconvQuote(pattern) + `,"path":` + strconvQuote(path) + `}`)
327}
328
329func strconvQuote(s string) string {
330	data, _ := json.Marshal(s)
331	return string(data)
332}
333
334func lookupScripts(paths map[string]string) func(string) (string, error) {
335	return func(name string) (string, error) {
336		if path, ok := paths[name]; ok {
337			return path, nil
338		}
339		return "", os.ErrNotExist
340	}
341}
342
343func writeGrepScript(t *testing.T, content string) string {
344	t.Helper()
345	path := filepath.Join(t.TempDir(), "fake-grep")
346	if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
347		t.Fatal(err)
348	}
349	return path
350}
351
352func skipGrepScriptTestsOnWindows(t *testing.T) {
353	t.Helper()
354	if runtime.GOOS == "windows" {
355		t.Skip("test uses a POSIX executable fixture")
356	}
357}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close