v0.25.0 c3476a8

extcmd_test.go RawHistoryFolder

1package agent
2
3import (
4	"bytes"
5	"os"
6	osexec "os/exec"
7	"path/filepath"
8	"runtime"
9	"strings"
10	"testing"
11
12	"github.com/bnema/zut/packages/agent/extensions"
13)
14
15// TestExtInstallDotSource verifies that `zut ext install .` resolves the
16// source directory correctly instead of collapsing it to the extensions/
17// parent directory.
18func TestExtInstallDotSource(t *testing.T) {
19	home := t.TempDir()
20	t.Setenv("ZUT_HOME", home)
21
22	// Pre-create extensions/ to mimic a normal first run.
23	if err := os.MkdirAll(filepath.Join(home, "extensions"), 0o755); err != nil {
24		t.Fatal(err)
25	}
26
27	srcParent := t.TempDir()
28	src := filepath.Join(srcParent, "kagi")
29	if err := os.MkdirAll(src, 0o755); err != nil {
30		t.Fatal(err)
31	}
32	if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"kagi","exec":"go"}`), 0o644); err != nil {
33		t.Fatal(err)
34	}
35
36	cwd, err := os.Getwd()
37	if err != nil {
38		t.Fatal(err)
39	}
40	defer os.Chdir(cwd)
41	if err := os.Chdir(src); err != nil {
42		t.Fatal(err)
43	}
44
45	if err := extInstall([]string{"."}); err != nil {
46		t.Fatalf("install with '.' failed: %v", err)
47	}
48
49	out := filepath.Join(home, "extensions", "kagi")
50	if _, err := os.Stat(filepath.Join(out, "extension.json")); err != nil {
51		t.Fatalf("expected installed extension at %s: %v", out, err)
52	}
53}
54
55// TestExtInstallNamedDir verifies that a normal named source installs
56// successfully when its manifest uses a PATH-resolved executable.
57func TestExtInstallNamedDir(t *testing.T) {
58	home := t.TempDir()
59	t.Setenv("ZUT_HOME", home)
60
61	src := filepath.Join(t.TempDir(), "myext")
62	if err := os.MkdirAll(src, 0o755); err != nil {
63		t.Fatal(err)
64	}
65	if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"myext","exec":"go"}`), 0o644); err != nil {
66		t.Fatal(err)
67	}
68
69	if err := extInstall([]string{src}); err != nil {
70		t.Fatalf("install failed: %v", err)
71	}
72	if _, err := os.Stat(filepath.Join(home, "extensions", "myext", "extension.json")); err != nil {
73		t.Fatalf("expected installed extension: %v", err)
74	}
75}
76
77func TestExtInstallThemeOnly(t *testing.T) {
78	home := t.TempDir()
79	t.Setenv("ZUT_HOME", home)
80
81	src := filepath.Join(t.TempDir(), "theme-source")
82	if err := os.MkdirAll(src, 0o755); err != nil {
83		t.Fatal(err)
84	}
85	if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"theme-only"}`), 0o644); err != nil {
86		t.Fatal(err)
87	}
88	if err := os.WriteFile(filepath.Join(src, "theme.json"), []byte(`{"name":"Theme"}`), 0o644); err != nil {
89		t.Fatal(err)
90	}
91
92	if err := extInstall([]string{src}); err != nil {
93		t.Fatalf("theme-only install failed: %v", err)
94	}
95	out := filepath.Join(home, "extensions", "theme-only")
96	for _, file := range []string{"extension.json", "theme.json"} {
97		if _, err := os.Stat(filepath.Join(out, file)); err != nil {
98			t.Fatalf("installed theme missing %s: %v", file, err)
99		}
100	}
101}
102
103func TestExtInstallRejectsMissingExecutable(t *testing.T) {
104	home := t.TempDir()
105	t.Setenv("ZUT_HOME", home)
106
107	src := filepath.Join(t.TempDir(), "tasked-phases")
108	if err := os.MkdirAll(src, 0o755); err != nil {
109		t.Fatal(err)
110	}
111	if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"tasked-phases","exec":"./tasked-phases","language":"go"}`), 0o644); err != nil {
112		t.Fatal(err)
113	}
114
115	err := extInstall([]string{src})
116	if err == nil || !strings.Contains(err.Error(), "--build=go") {
117		t.Fatalf("install error = %v, want explicit Go builder guidance", err)
118	}
119	if _, statErr := os.Stat(filepath.Join(home, "extensions", "tasked-phases")); !os.IsNotExist(statErr) {
120		t.Fatalf("failed install left a destination: %v", statErr)
121	}
122}
123
124func TestExtInstallBuildGo(t *testing.T) {
125	if _, err := osexec.LookPath("go"); err != nil {
126		t.Skip("go is not installed")
127	}
128
129	home := t.TempDir()
130	t.Setenv("ZUT_HOME", home)
131	src := filepath.Join(t.TempDir(), "go-extension")
132	if err := os.MkdirAll(src, 0o755); err != nil {
133		t.Fatal(err)
134	}
135	mustWrite := func(name, body string) {
136		t.Helper()
137		if err := os.WriteFile(filepath.Join(src, name), []byte(body), 0o644); err != nil {
138			t.Fatal(err)
139		}
140	}
141	mustWrite("extension.json", `{"name":"built-go","exec":"./built-go","language":"go"}`)
142	mustWrite("go.mod", "module example.com/built-go\n\ngo 1.25.0\n")
143	mustWrite("main.go", "package main\n\nfunc main() {}\n")
144
145	if err := extInstall([]string{"--build=go", src}); err != nil {
146		t.Fatalf("build install failed: %v", err)
147	}
148
149	installed := filepath.Join(home, "extensions", "built-go", "built-go")
150	info, err := os.Stat(installed)
151	if err != nil {
152		t.Fatalf("built executable was not installed: %v", err)
153	}
154	if runtime.GOOS != "windows" && info.Mode().Perm()&0o111 == 0 {
155		t.Fatalf("installed executable mode = %o, want an execute bit", info.Mode().Perm())
156	}
157	if _, err := os.Stat(filepath.Join(src, "built-go")); !os.IsNotExist(err) {
158		t.Fatalf("build wrote into the source directory: %v", err)
159	}
160}
161
162func TestExtInstallBuildFailureLeavesNoDestination(t *testing.T) {
163	if _, err := osexec.LookPath("go"); err != nil {
164		t.Skip("go is not installed")
165	}
166
167	home := t.TempDir()
168	t.Setenv("ZUT_HOME", home)
169	src := filepath.Join(t.TempDir(), "broken-go")
170	if err := os.MkdirAll(src, 0o755); err != nil {
171		t.Fatal(err)
172	}
173	if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"broken-go","exec":"./broken-go"}`), 0o644); err != nil {
174		t.Fatal(err)
175	}
176	if err := os.WriteFile(filepath.Join(src, "go.mod"), []byte("module example.com/broken-go\n\ngo 1.25.0\n"), 0o644); err != nil {
177		t.Fatal(err)
178	}
179	if err := os.WriteFile(filepath.Join(src, "main.go"), []byte("package main\n\nfunc main( {\n"), 0o644); err != nil {
180		t.Fatal(err)
181	}
182
183	err := extInstall([]string{"--build=go", src})
184	if err == nil || !strings.Contains(err.Error(), "build extension with go") {
185		t.Fatalf("build error = %v, want Go build failure", err)
186	}
187	if _, statErr := os.Stat(filepath.Join(home, "extensions", "broken-go")); !os.IsNotExist(statErr) {
188		t.Fatalf("failed build left a destination: %v", statErr)
189	}
190	entries, err := os.ReadDir(filepath.Join(home, "extensions"))
191	if err != nil {
192		t.Fatal(err)
193	}
194	for _, entry := range entries {
195		if strings.HasPrefix(entry.Name(), ".zut-extension-install-") {
196			t.Fatalf("failed build left staging directory %s", entry.Name())
197		}
198	}
199}
200
201func TestExtInstallRejectsUnknownBuilder(t *testing.T) {
202	err := extInstall([]string{"--build=make", t.TempDir()})
203	if err == nil || !strings.Contains(err.Error(), "unsupported extension builder") {
204		t.Fatalf("error = %v, want unsupported builder error", err)
205	}
206}
207
208func TestParseExtInstallArgs(t *testing.T) {
209	for _, test := range []struct {
210		name    string
211		args    []string
212		builder string
213		wantErr string
214	}{
215		{name: "equals", args: []string{"--build=go", "./ext"}, builder: "go"},
216		{name: "separate", args: []string{"./ext", "--build", "go"}, builder: "go"},
217		{name: "missing builder", args: []string{"--build=", "./ext"}, wantErr: "requires a builder"},
218		{name: "missing separate builder", args: []string{"./ext", "--build"}, wantErr: "requires a builder"},
219		{name: "missing source", args: []string{"--build=go"}, wantErr: "usage:"},
220		{name: "extra source", args: []string{"./one", "./two"}, wantErr: "usage:"},
221		{name: "unknown option", args: []string{"--wat", "./ext"}, wantErr: "unknown ext install option"},
222	} {
223		t.Run(test.name, func(t *testing.T) {
224			got, err := parseExtInstallArgs(test.args)
225			if test.wantErr != "" {
226				if err == nil || !strings.Contains(err.Error(), test.wantErr) {
227					t.Fatalf("error = %v, want substring %q", err, test.wantErr)
228				}
229				return
230			}
231			if err != nil {
232				t.Fatalf("parse failed: %v", err)
233			}
234			if got.source != "./ext" || got.builder != test.builder {
235				t.Fatalf("options = %#v, want source ./ext and builder %q", got, test.builder)
236			}
237		})
238	}
239}
240
241func TestExtInstallRejectsBuildForThemeOnly(t *testing.T) {
242	home := t.TempDir()
243	t.Setenv("ZUT_HOME", home)
244	src := filepath.Join(t.TempDir(), "theme-source")
245	if err := os.MkdirAll(src, 0o755); err != nil {
246		t.Fatal(err)
247	}
248	if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"theme-only"}`), 0o644); err != nil {
249		t.Fatal(err)
250	}
251	if err := os.WriteFile(filepath.Join(src, "theme.json"), []byte(`{"name":"Theme"}`), 0o644); err != nil {
252		t.Fatal(err)
253	}
254
255	err := extInstall([]string{"--build=go", src})
256	if err == nil || !strings.Contains(err.Error(), "theme-only") {
257		t.Fatalf("error = %v, want theme-only builder error", err)
258	}
259	if _, statErr := os.Stat(filepath.Join(home, "extensions", "theme-only")); !os.IsNotExist(statErr) {
260		t.Fatalf("failed build left a destination: %v", statErr)
261	}
262}
263
264func TestExtInstallRejectsWhitespaceManifestName(t *testing.T) {
265	home := t.TempDir()
266	t.Setenv("ZUT_HOME", home)
267	src := filepath.Join(t.TempDir(), "whitespace-name")
268	if err := os.MkdirAll(src, 0o755); err != nil {
269		t.Fatal(err)
270	}
271	if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":" whitespace-name ","exec":"go"}`), 0o644); err != nil {
272		t.Fatal(err)
273	}
274
275	err := extInstall([]string{src})
276	if err == nil || !strings.Contains(err.Error(), "leading or trailing whitespace") {
277		t.Fatalf("error = %v, want whitespace validation error", err)
278	}
279}
280
281func TestExtInstallRejectsDotManifestName(t *testing.T) {
282	home := t.TempDir()
283	t.Setenv("ZUT_HOME", home)
284	src := filepath.Join(t.TempDir(), "dot-name")
285	if err := os.MkdirAll(src, 0o755); err != nil {
286		t.Fatal(err)
287	}
288	if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":".example","exec":"go"}`), 0o644); err != nil {
289		t.Fatal(err)
290	}
291
292	err := extInstall([]string{src})
293	if err == nil || !strings.Contains(err.Error(), "invalid extension name") {
294		t.Fatalf("error = %v, want invalid-name validation error", err)
295	}
296}
297
298func TestExtInstallRejectsBuildForPATHExecutable(t *testing.T) {
299	home := t.TempDir()
300	t.Setenv("ZUT_HOME", home)
301	src := filepath.Join(t.TempDir(), "path-extension")
302	if err := os.MkdirAll(src, 0o755); err != nil {
303		t.Fatal(err)
304	}
305	if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"path-extension","exec":"node"}`), 0o644); err != nil {
306		t.Fatal(err)
307	}
308
309	err := extInstall([]string{"--build=go", src})
310	if err == nil || !strings.Contains(err.Error(), "extension-relative executable path") {
311		t.Fatalf("error = %v, want local executable requirement", err)
312	}
313	if _, statErr := os.Stat(filepath.Join(home, "extensions", "path-extension")); !os.IsNotExist(statErr) {
314		t.Fatalf("failed build left a destination: %v", statErr)
315	}
316}
317
318func TestExtInstallRejectsNonExecutable(t *testing.T) {
319	if runtime.GOOS == "windows" {
320		t.Skip("Unix executable permissions are not enforced on Windows")
321	}
322
323	home := t.TempDir()
324	t.Setenv("ZUT_HOME", home)
325	src := filepath.Join(t.TempDir(), "non-executable")
326	if err := os.MkdirAll(src, 0o755); err != nil {
327		t.Fatal(err)
328	}
329	if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"non-executable","exec":"./run.sh","language":"shell"}`), 0o644); err != nil {
330		t.Fatal(err)
331	}
332	if err := os.WriteFile(filepath.Join(src, "run.sh"), []byte("#!/bin/sh\nexit 0\n"), 0o644); err != nil {
333		t.Fatal(err)
334	}
335
336	err := extInstall([]string{src})
337	if err == nil || !strings.Contains(err.Error(), "not executable") {
338		t.Fatalf("install error = %v, want executable permission guidance", err)
339	}
340	if _, statErr := os.Stat(filepath.Join(home, "extensions", "non-executable")); !os.IsNotExist(statErr) {
341		t.Fatalf("failed install left a destination: %v", statErr)
342	}
343}
344
345// TestExtInstallCopiesIgnoredExecutableAndUsesManifestName verifies that a
346// declared runtime file is copied even when .gitignore matches it, and that
347// the manifest name controls the installed directory.
348func TestExtInstallCopiesIgnoredExecutableAndUsesManifestName(t *testing.T) {
349	home := t.TempDir()
350	t.Setenv("ZUT_HOME", home)
351
352	src := filepath.Join(t.TempDir(), "source-dir")
353	if err := os.MkdirAll(src, 0o755); err != nil {
354		t.Fatal(err)
355	}
356	if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"tasked-phases","exec":"./tasked-phases","language":"go"}`), 0o644); err != nil {
357		t.Fatal(err)
358	}
359	if err := os.WriteFile(filepath.Join(src, ".gitignore"), []byte("tasked-phases\n"), 0o644); err != nil {
360		t.Fatal(err)
361	}
362	if err := os.WriteFile(filepath.Join(src, "tasked-phases"), []byte("not really a binary"), 0o755); err != nil {
363		t.Fatal(err)
364	}
365
366	if err := extInstall([]string{src}); err != nil {
367		t.Fatalf("install failed: %v", err)
368	}
369
370	out := filepath.Join(home, "extensions", "tasked-phases")
371	info, err := os.Stat(filepath.Join(out, "tasked-phases"))
372	if err != nil {
373		t.Fatalf("declared executable was not copied: %v", err)
374	}
375	if runtime.GOOS != "windows" && info.Mode().Perm()&0o111 == 0 {
376		t.Fatalf("copied executable mode = %o, want an execute bit", info.Mode().Perm())
377	}
378	if _, err := os.Stat(filepath.Join(home, "extensions", "source-dir")); !os.IsNotExist(err) {
379		t.Fatalf("install used source directory name instead of manifest name: %v", err)
380	}
381}
382
383func TestCopyDirRespectsGitignore(t *testing.T) {
384	src := t.TempDir()
385	dst := filepath.Join(t.TempDir(), "out")
386
387	mustWrite := func(rel, content string) {
388		p := filepath.Join(src, filepath.FromSlash(rel))
389		if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
390			t.Fatal(err)
391		}
392		if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
393			t.Fatal(err)
394		}
395	}
396
397	mustWrite("extension.json", `{"name":"x"}`)
398	mustWrite("main.py", "print('hi')")
399	mustWrite(".gitignore", ".venv/\nnode_modules/\n*.log\n")
400	mustWrite(".venv/bin/python", "binary")
401	mustWrite("node_modules/pkg/index.js", "module")
402	mustWrite("debug.log", "noise")
403	mustWrite("src/app.py", "code")
404	mustWrite(".git/config", "gitdir")
405
406	if err := copyDir(src, dst); err != nil {
407		t.Fatal(err)
408	}
409
410	wantPresent := []string{"extension.json", "main.py", "src/app.py", ".gitignore"}
411	for _, rel := range wantPresent {
412		if _, err := os.Stat(filepath.Join(dst, filepath.FromSlash(rel))); err != nil {
413			t.Fatalf("expected %s to be copied: %v", rel, err)
414		}
415	}
416
417	wantAbsent := []string{".venv", "node_modules", "debug.log", ".git"}
418	for _, rel := range wantAbsent {
419		if _, err := os.Stat(filepath.Join(dst, filepath.FromSlash(rel))); err == nil {
420			t.Fatalf("expected %s to be skipped, but it was copied", rel)
421		}
422	}
423}
424
425func TestGitignoreNegation(t *testing.T) {
426	g := loadGitignoreFromString("build/\n!build/keep.txt\n")
427	if !g.Match("build", true) {
428		t.Fatal("expected build/ dir to be ignored")
429	}
430	if g.Match("build/keep.txt", false) {
431		t.Fatal("expected build/keep.txt to be re-included by negation")
432	}
433}
434
435func TestExtDoctorStaticScanAndRender(t *testing.T) {
436	home := t.TempDir()
437	t.Setenv("ZUT_HOME", home)
438	cwd := t.TempDir()
439	oldCWD, err := os.Getwd()
440	if err != nil {
441		t.Fatal(err)
442	}
443	defer os.Chdir(oldCWD)
444	if err := os.Chdir(cwd); err != nil {
445		t.Fatal(err)
446	}
447
448	mustWrite := func(path, body string) {
449		t.Helper()
450		if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
451			t.Fatal(err)
452		}
453		if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
454			t.Fatal(err)
455		}
456	}
457
458	mustWrite(filepath.Join(cwd, ".zut", "extensions", "disabled", "extension.json"), `{"name":"disabled","exec":"./run.sh","enabled":false}`)
459	mustWrite(filepath.Join(cwd, ".zut", "extensions", "theme", "extension.json"), `{"name":"theme"}`)
460	mustWrite(filepath.Join(cwd, ".zut", "extensions", "theme", "theme.json"), `{"name":"Theme"}`)
461	mustWrite(filepath.Join(cwd, ".zut", "extensions", "bad", "extension.json"), `{bad json`)
462	mustWrite(filepath.Join(cwd, ".zut", "extensions", "dup", "extension.json"), `{"name":"dup","exec":"./project.sh"}`)
463	mustWrite(filepath.Join(home, "extensions", "dup", "extension.json"), `{"name":"dup","exec":"./global.sh"}`)
464
465	rows := scanExtDoctorStatic()
466	if len(rows) != 5 {
467		t.Fatalf("rows = %d, want 5: %#v", len(rows), rows)
468	}
469
470	var out bytes.Buffer
471	for _, row := range rows {
472		printExtDoctorRow(&out, row, extensions.ExtensionDiagnostic{})
473	}
474	got := out.String()
475	for _, want := range []string{
476		"disabled [project] disabled",
477		"theme [project] theme-only",
478		"bad [project] error",
479		"dup [global] shadowed",
480		"parse manifest:",
481	} {
482		if !strings.Contains(got, want) {
483			t.Fatalf("doctor output missing %q:\n%s", want, got)
484		}
485	}
486}

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 476

↑↓ moveenter openesc close