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}