v0.18.0 f294dc4

tools_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/provider"
15)
16
17func mustJSON(t *testing.T, v any) json.RawMessage {
18	t.Helper()
19	b, err := json.Marshal(v)
20	if err != nil {
21		t.Fatal(err)
22	}
23	return b
24}
25
26func TestReadText(t *testing.T) {
27	dir := t.TempDir()
28	p := filepath.Join(dir, "a.txt")
29	if err := os.WriteFile(p, []byte("hello\nworld\n"), 0o644); err != nil {
30		t.Fatal(err)
31	}
32	tool := &ReadTool{CWD: dir}
33	res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"path": "a.txt"}), nil)
34	if err != nil {
35		t.Fatal(err)
36	}
37	got := res.Content[0].(provider.TextBlock).Text
38	if !strings.Contains(got, "hello") || !strings.Contains(got, "world") {
39		t.Fatalf("got %q", got)
40	}
41}
42
43func TestReadImageMimeFromContentNotExtension(t *testing.T) {
44	// A file named .png whose bytes are actually JPEG. The MIME must be
45	// sniffed from the content (image/jpeg), not the extension, or
46	// providers that validate the declared media type reject the request.
47	dir := t.TempDir()
48	p := filepath.Join(dir, "shot.png")
49	jpegBytes := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 'J', 'F', 'I', 'F'}
50	if err := os.WriteFile(p, jpegBytes, 0o644); err != nil {
51		t.Fatal(err)
52	}
53	tool := &ReadTool{CWD: dir}
54	res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"path": "shot.png"}), nil)
55	if err != nil {
56		t.Fatal(err)
57	}
58	img, ok := res.Content[0].(provider.ImageBlock)
59	if !ok {
60		t.Fatalf("expected ImageBlock, got %T", res.Content[0])
61	}
62	if img.MimeType != "image/jpeg" {
63		t.Fatalf("mime from extension not corrected: got %s want image/jpeg", img.MimeType)
64	}
65}
66
67func TestReadOffsetLimit(t *testing.T) {
68	dir := t.TempDir()
69	p := filepath.Join(dir, "a.txt")
70	os.WriteFile(p, []byte("1\n2\n3\n4\n5\n"), 0o644)
71	tool := &ReadTool{CWD: dir}
72	res, _ := tool.Execute(context.Background(), mustJSON(t, map[string]any{"path": "a.txt", "offset": 2, "limit": 2}), nil)
73	got := res.Content[0].(provider.TextBlock).Text
74	// Current output format is raw bytes (no embedded line numbers):
75	// the tui draws its own gutter from the `start_line` detail.
76	if got != "2\n3\n" {
77		t.Fatalf("want \"2\\n3\\n\", got %q", got)
78	}
79	if start, ok := res.Details.(map[string]any)["start_line"]; !ok || start != 2 {
80		t.Errorf("start_line detail want 2, got %v", start)
81	}
82}
83
84func TestReadBinaryRejected(t *testing.T) {
85	dir := t.TempDir()
86	p := filepath.Join(dir, "b.bin")
87	os.WriteFile(p, []byte{0x00, 0x01, 0x02}, 0o644)
88	tool := &ReadTool{CWD: dir}
89	if _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"path": "b.bin"}), nil); err == nil {
90		t.Fatal("want binary rejection")
91	}
92}
93
94func TestWriteCreatesDirs(t *testing.T) {
95	dir := t.TempDir()
96	tool := &WriteTool{CWD: dir}
97	_, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"path": "sub/a.txt", "content": "hi"}), nil)
98	if err != nil {
99		t.Fatal(err)
100	}
101	b, err := os.ReadFile(filepath.Join(dir, "sub", "a.txt"))
102	if err != nil {
103		t.Fatal(err)
104	}
105	if string(b) != "hi" {
106		t.Fatalf("got %q", string(b))
107	}
108}
109
110func TestEditSingle(t *testing.T) {
111	dir := t.TempDir()
112	p := filepath.Join(dir, "a.txt")
113	os.WriteFile(p, []byte("hello world\n"), 0o644)
114	tool := &EditTool{CWD: dir}
115	_, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{
116		"path":  "a.txt",
117		"edits": []map[string]any{{"oldText": "world", "newText": "gopher"}},
118	}), nil)
119	if err != nil {
120		t.Fatal(err)
121	}
122	b, _ := os.ReadFile(p)
123	if string(b) != "hello gopher\n" {
124		t.Fatalf("got %q", string(b))
125	}
126}
127
128func TestEditPreviewReturnsDiffWithoutWriting(t *testing.T) {
129	dir := t.TempDir()
130	p := filepath.Join(dir, "a.txt")
131	if err := os.WriteFile(p, []byte("hello world\n"), 0o644); err != nil {
132		t.Fatal(err)
133	}
134	tool := &EditTool{CWD: dir}
135	args := mustJSON(t, map[string]any{
136		"path":  "a.txt",
137		"edits": []map[string]any{{"oldText": "world", "newText": "gopher"}},
138	})
139	preview, err := tool.Preview(context.Background(), args)
140	if err != nil {
141		t.Fatal(err)
142	}
143	text := preview.Content[0].(provider.TextBlock).Text
144	for _, want := range []string{"-hello world", "+hello gopher"} {
145		if !strings.Contains(text, want) {
146			t.Fatalf("preview missing %q:\n%s", want, text)
147		}
148	}
149	b, err := os.ReadFile(p)
150	if err != nil {
151		t.Fatal(err)
152	}
153	if string(b) != "hello world\n" {
154		t.Fatalf("preview modified file: %q", b)
155	}
156
157	result, err := tool.Execute(context.Background(), args, nil)
158	if err != nil {
159		t.Fatal(err)
160	}
161	if got := result.Content[0].(provider.TextBlock).Text; got != text {
162		t.Fatalf("executed diff differs from preview:\npreview:\n%s\nresult:\n%s", text, got)
163	}
164}
165
166func TestEditMultiple(t *testing.T) {
167	dir := t.TempDir()
168	p := filepath.Join(dir, "a.txt")
169	os.WriteFile(p, []byte("a\nb\nc\n"), 0o644)
170	tool := &EditTool{CWD: dir}
171	_, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{
172		"path": "a.txt",
173		"edits": []map[string]any{
174			{"oldText": "a", "newText": "A"},
175			{"oldText": "c", "newText": "C"},
176		},
177	}), nil)
178	if err != nil {
179		t.Fatal(err)
180	}
181	b, _ := os.ReadFile(p)
182	if string(b) != "A\nb\nC\n" {
183		t.Fatalf("got %q", string(b))
184	}
185}
186
187func TestEditAmbiguous(t *testing.T) {
188	dir := t.TempDir()
189	p := filepath.Join(dir, "a.txt")
190	os.WriteFile(p, []byte("x\nx\n"), 0o644)
191	tool := &EditTool{CWD: dir}
192	_, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{
193		"path":  "a.txt",
194		"edits": []map[string]any{{"oldText": "x", "newText": "y"}},
195	}), nil)
196	if err == nil {
197		t.Fatal("want ambiguous error")
198	}
199}
200
201func TestEditGuidance(t *testing.T) {
202	desc := (&EditTool{}).Description()
203	for _, want := range []string{"Inspect that file", "directly from its current contents", "short excerpts", "write"} {
204		if !strings.Contains(desc, want) {
205			t.Errorf("description missing %q: %q", want, desc)
206		}
207	}
208
209	var schema map[string]any
210	if err := json.Unmarshal((&EditTool{}).Schema(), &schema); err != nil {
211		t.Fatal(err)
212	}
213	properties := schema["properties"].(map[string]any)
214	edits := properties["edits"].(map[string]any)
215	items := edits["items"].(map[string]any)
216	editProperties := items["properties"].(map[string]any)
217	oldText := editProperties["oldText"].(map[string]any)
218	if got, _ := oldText["description"].(string); !strings.Contains(got, "file being modified") {
219		t.Fatalf("oldText schema description missing target-file guidance: %q", got)
220	}
221}
222
223func TestEditNotFoundGuidesRecovery(t *testing.T) {
224	dir := t.TempDir()
225	p := filepath.Join(dir, "destination.txt")
226	if err := os.WriteFile(p, []byte("destination content\n"), 0o644); err != nil {
227		t.Fatal(err)
228	}
229	tool := &EditTool{CWD: dir}
230	_, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{
231		"path":  "destination.txt",
232		"edits": []map[string]any{{"oldText": "content from another file", "newText": "replacement"}},
233	}), nil)
234	if err == nil {
235		t.Fatal("want oldText not found error")
236	}
237	for _, want := range []string{"oldText not found", "destination.txt", "inspect that file again", "matching spaces and line breaks"} {
238		if !strings.Contains(err.Error(), want) {
239			t.Errorf("error missing %q: %q", want, err)
240		}
241	}
242}
243
244func TestEditPreservesCRLF(t *testing.T) {
245	dir := t.TempDir()
246	p := filepath.Join(dir, "a.txt")
247	os.WriteFile(p, []byte("hello\r\nworld\r\n"), 0o644)
248	tool := &EditTool{CWD: dir}
249	_, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{
250		"path":  "a.txt",
251		"edits": []map[string]any{{"oldText": "world", "newText": "gopher"}},
252	}), nil)
253	if err != nil {
254		t.Fatal(err)
255	}
256	b, _ := os.ReadFile(p)
257	if string(b) != "hello\r\ngopher\r\n" {
258		t.Fatalf("got %q", string(b))
259	}
260}
261
262func TestResolveShell(t *testing.T) {
263	notFound := errors.New("not found")
264	tests := []struct {
265		name     string
266		goos     string
267		binBash  bool
268		pathBash string
269		pathErr  error
270		want     shellCommand
271	}{
272		{
273			name: "prefers /bin/bash",
274			goos: "linux", binBash: true, pathBash: "/usr/local/bin/bash",
275			want: shellCommand{path: "/bin/bash", flag: "-c", isBash: true},
276		},
277		{
278			name: "uses bash from PATH",
279			goos: "linux", pathBash: "/usr/local/bin/bash",
280			want: shellCommand{path: "/usr/local/bin/bash", flag: "-c", isBash: true},
281		},
282		{
283			name: "falls back to POSIX sh",
284			goos: "linux", pathErr: notFound,
285			want: shellCommand{path: "/bin/sh", flag: "-c"},
286		},
287		{
288			name: "uses Command Prompt on Windows",
289			goos: "windows", binBash: true, pathBash: "/usr/local/bin/bash",
290			want: shellCommand{path: "cmd", flag: "/C"},
291		},
292	}
293	for _, tt := range tests {
294		t.Run(tt.name, func(t *testing.T) {
295			got := resolveShell(tt.goos, func(path string) bool {
296				return path == "/bin/bash" && tt.binBash
297			}, func(name string) (string, error) {
298				if name != "bash" {
299					t.Fatalf("unexpected PATH lookup for %q", name)
300				}
301				return tt.pathBash, tt.pathErr
302			})
303			if got != tt.want {
304				t.Fatalf("resolveShell() = %+v, want %+v", got, tt.want)
305			}
306		})
307	}
308}
309
310func TestShellDescription(t *testing.T) {
311	tests := []struct {
312		name  string
313		shell shellCommand
314		want  string
315	}{
316		{
317			name:  "bash",
318			shell: shellCommand{path: "/opt/bin/bash", flag: "-c", isBash: true},
319			want:  "Run a Bash command via /opt/bin/bash -c. stdout+stderr merged.",
320		},
321		{
322			name:  "POSIX fallback",
323			shell: shellCommand{path: "/bin/sh", flag: "-c"},
324			want:  "Bash is unavailable; run a POSIX sh command via /bin/sh -c. stdout+stderr merged.",
325		},
326		{
327			name:  "Windows",
328			shell: shellCommand{path: "cmd", flag: "/C"},
329			want:  "Run a Windows Command Prompt command via cmd /C. stdout+stderr merged.",
330		},
331	}
332	for _, tt := range tests {
333		t.Run(tt.name, func(t *testing.T) {
334			if got := shellDescription(tt.shell); got != tt.want {
335				t.Fatalf("shellDescription() = %q, want %q", got, tt.want)
336			}
337		})
338	}
339}
340
341func TestBashSchemaRequiresTimeoutInSeconds(t *testing.T) {
342	var schema struct {
343		Properties map[string]struct {
344			Type        string `json:"type"`
345			Minimum     *int64 `json:"minimum"`
346			Maximum     *int64 `json:"maximum"`
347			Description string `json:"description"`
348		} `json:"properties"`
349		Required []string `json:"required"`
350	}
351	if err := json.Unmarshal((&BashTool{}).Schema(), &schema); err != nil {
352		t.Fatal(err)
353	}
354
355	timeout, ok := schema.Properties["timeout"]
356	if !ok {
357		t.Fatal("timeout property missing from schema")
358	}
359	if timeout.Type != "integer" {
360		t.Fatalf("timeout type = %q, want integer", timeout.Type)
361	}
362	if timeout.Minimum == nil || *timeout.Minimum != 1 {
363		if timeout.Minimum == nil {
364			t.Fatal("timeout minimum missing from schema")
365		}
366		t.Fatalf("timeout minimum = %d, want 1", *timeout.Minimum)
367	}
368	if timeout.Maximum == nil {
369		t.Fatal("timeout maximum missing from schema")
370	}
371	if *timeout.Maximum != maxBashTimeoutSeconds {
372		t.Fatalf("timeout maximum = %d, want %d", *timeout.Maximum, maxBashTimeoutSeconds)
373	}
374	if !strings.Contains(strings.ToLower(timeout.Description), "second") {
375		t.Fatalf("timeout description = %q, want seconds wording", timeout.Description)
376	}
377
378	required := make(map[string]bool, len(schema.Required))
379	for _, name := range schema.Required {
380		required[name] = true
381	}
382	for _, name := range []string{"command", "timeout"} {
383		if !required[name] {
384			t.Errorf("required fields missing %q: %v", name, schema.Required)
385		}
386	}
387}
388
389func TestBashTimeoutDurationBounds(t *testing.T) {
390	tests := []struct {
391		name    string
392		seconds int64
393		want    time.Duration
394		wantErr bool
395	}{
396		{name: "minimum", seconds: 1, want: time.Second},
397		{name: "maximum", seconds: maxBashTimeoutSeconds, want: time.Duration(maxBashTimeoutSeconds) * time.Second},
398		{name: "one over maximum", seconds: maxBashTimeoutSeconds + 1, wantErr: true},
399	}
400	for _, tt := range tests {
401		t.Run(tt.name, func(t *testing.T) {
402			got, err := bashTimeoutDuration(tt.seconds)
403			if tt.wantErr {
404				if err == nil {
405					t.Fatalf("bashTimeoutDuration(%d) returned nil error", tt.seconds)
406				}
407				return
408			}
409			if err != nil {
410				t.Fatalf("bashTimeoutDuration(%d): %v", tt.seconds, err)
411			}
412			if got != tt.want {
413				t.Fatalf("bashTimeoutDuration(%d) = %s, want %s", tt.seconds, got, tt.want)
414			}
415		})
416	}
417}
418
419func TestBashSuccess(t *testing.T) {
420	if runtime.GOOS == "windows" {
421		t.Skip("posix shell only")
422	}
423	tool := &BashTool{CWD: t.TempDir()}
424	res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"command": "echo hi", "timeout": 1}), nil)
425	if err != nil {
426		t.Fatal(err)
427	}
428	got := res.Content[0].(provider.TextBlock).Text
429	if !strings.Contains(got, "hi") || !strings.Contains(got, "[exit 0]") {
430		t.Fatalf("got %q", got)
431	}
432	if strings.Contains(got, "Took ") {
433		t.Fatalf("bash output still contains duplicate timing: %q", got)
434	}
435	details, ok := res.Details.(map[string]any)
436	if !ok || details == nil {
437		t.Fatalf("bash details = %#v (%T), want non-nil map[string]any", res.Details, res.Details)
438	}
439	if _, exists := details["duration_ms"]; exists {
440		t.Fatalf("bash details still contain duplicate duration: %#v", details)
441	}
442	if res.IsError {
443		t.Fatal("unexpected error flag")
444	}
445}
446
447func TestBashSyntax(t *testing.T) {
448	if runtime.GOOS == "windows" {
449		t.Skip("bash syntax is not used on Windows")
450	}
451	if !currentShell().isBash {
452		t.Skip("bash is unavailable")
453	}
454	tool := &BashTool{CWD: t.TempDir()}
455	res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{
456		"command": `items=(one two); [[ ${#items[@]} -eq 2 ]] && printf 'bash syntax works\n'`,
457		"timeout": 1,
458	}), nil)
459	if err != nil {
460		t.Fatal(err)
461	}
462	got := res.Content[0].(provider.TextBlock).Text
463	if res.IsError || !strings.Contains(got, "bash syntax works") {
464		t.Fatalf("got %q", got)
465	}
466}
467
468func TestBashFailure(t *testing.T) {
469	if runtime.GOOS == "windows" {
470		t.Skip("posix shell only")
471	}
472	tool := &BashTool{CWD: t.TempDir()}
473	res, _ := tool.Execute(context.Background(), mustJSON(t, map[string]any{"command": "false", "timeout": 1}), nil)
474	if !res.IsError {
475		t.Fatal("want error")
476	}
477	got := res.Content[0].(provider.TextBlock).Text
478	if !strings.Contains(got, "[exit 1]") {
479		t.Fatalf("got %q", got)
480	}
481}
482
483func TestBashRejectsInvalidTimeout(t *testing.T) {
484	tool := &BashTool{CWD: t.TempDir()}
485	tests := []struct {
486		name string
487		args map[string]any
488		want string
489	}{
490		{name: "missing", args: map[string]any{"command": "echo hi"}, want: "timeout is required"},
491		{name: "null", args: map[string]any{"command": "echo hi", "timeout": nil}, want: "timeout is required"},
492		{name: "zero", args: map[string]any{"command": "echo hi", "timeout": 0}, want: "timeout must be between 1 and"},
493		{name: "negative", args: map[string]any{"command": "echo hi", "timeout": -1}, want: "timeout must be between 1 and"},
494		{name: "one over maximum", args: map[string]any{"command": "echo hi", "timeout": maxBashTimeoutSeconds + 1}, want: "timeout must be between 1 and"},
495	}
496	for _, tt := range tests {
497		t.Run(tt.name, func(t *testing.T) {
498			_, err := tool.Execute(context.Background(), mustJSON(t, tt.args), nil)
499			if err == nil || !strings.Contains(err.Error(), tt.want) {
500				t.Fatalf("error = %v, want substring %q", err, tt.want)
501			}
502		})
503	}
504
505	_, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{
506		"command": "echo hi",
507		"timeout": "1",
508	}), nil)
509	if err == nil || !strings.Contains(err.Error(), "invalid args") {
510		t.Fatalf("wrong-type error = %v, want invalid args", err)
511	}
512}
513
514func TestBashTimeoutCancelsCommand(t *testing.T) {
515	if runtime.GOOS == "windows" {
516		t.Skip("posix shell only")
517	}
518	tool := &BashTool{CWD: t.TempDir()}
519	start := time.Now()
520	res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{
521		"command": "sleep 5",
522		"timeout": 1,
523	}), nil)
524	elapsed := time.Since(start)
525	if err != nil {
526		t.Fatal(err)
527	}
528	if !res.IsError {
529		t.Fatal("timed-out command should be an error")
530	}
531	if got := res.Content[0].(provider.TextBlock).Text; !strings.Contains(got, "[timed out after 1 second]") {
532		t.Fatalf("timeout result = %q, want timeout diagnostic", got)
533	}
534	if elapsed >= 3*time.Second {
535		t.Fatalf("timeout took %s; command was not cancelled promptly", elapsed)
536	}
537}

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