v0.3.14 9eb66e1

manager_test.go RawHistoryFolder

1package extensions
2
3import (
4	"context"
5	"encoding/json"
6	"os"
7
8	"github.com/patriceckhart/zot/packages/agent/extproto"
9	"path/filepath"
10	"runtime"
11	"strings"
12	"sync"
13	"testing"
14	"time"
15)
16
17// stubHooks records every callback so the test can assert on them.
18type stubHooks struct {
19	mu          sync.Mutex
20	notifies    []string
21	displays    []string
22	submits     []string
23	submitSlash []string
24	clearNotes  []string
25	panels      []extproto.PanelSpec
26	panelExts   []string
27}
28
29func (s *stubHooks) Notify(name, level, message string) {
30	s.mu.Lock()
31	defer s.mu.Unlock()
32	s.notifies = append(s.notifies, name+":"+level+":"+message)
33}
34func (s *stubHooks) Submit(text string) {
35	s.mu.Lock()
36	defer s.mu.Unlock()
37	s.submits = append(s.submits, text)
38}
39func (s *stubHooks) SubmitSlash(text string) {
40	s.mu.Lock()
41	defer s.mu.Unlock()
42	s.submitSlash = append(s.submitSlash, text)
43}
44func (s *stubHooks) Insert(string) {}
45func (s *stubHooks) Display(name, text string) {
46	s.mu.Lock()
47	defer s.mu.Unlock()
48	s.displays = append(s.displays, name+":"+text)
49}
50func (s *stubHooks) ClearNotes(name string) {
51	s.mu.Lock()
52	defer s.mu.Unlock()
53	s.clearNotes = append(s.clearNotes, name)
54}
55func (s *stubHooks) OpenPanel(extName string, spec extproto.PanelSpec) {
56	s.mu.Lock()
57	defer s.mu.Unlock()
58	s.panelExts = append(s.panelExts, extName)
59	s.panels = append(s.panels, spec)
60}
61func (s *stubHooks) UpdatePanel(string, string, string, []string, string) {}
62func (s *stubHooks) ClosePanel(string, string)                            {}
63
64// writeMockExtension creates a minimal extension on disk that uses a
65// shell script (or batch file on windows) to drive the protocol. The
66// script reads commands from stdin and emits hard-coded responses,
67// exercising the manager's spawn/handshake/dispatch path without
68// needing the SDK.
69func writeMockExtension(t *testing.T, root string) {
70	t.Helper()
71	if runtime.GOOS == "windows" {
72		t.Skip("mock extension uses /bin/sh; skip on windows")
73	}
74
75	dir := filepath.Join(root, "mock")
76	if err := os.MkdirAll(dir, 0o755); err != nil {
77		t.Fatal(err)
78	}
79
80	// Shell script: emit hello, read frames, respond. Reads until
81	// stdin closes; tail's -F keeps the pipe alive long enough for
82	// the manager to send command_invoked.
83	script := `#!/bin/sh
84printf '%s\n' '{"type":"hello","name":"mock","version":"0.0.1","capabilities":["commands"]}'
85printf '%s\n' '{"type":"register_command","name":"Ping","description":"ping/pong"}'
86while IFS= read -r line; do
87  case "$line" in
88    *'"type":"command_invoked"'*)
89      id=$(printf '%s' "$line" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p')
90      case "$line" in
91        *'"name":"Ping"'*)
92          printf '%s\n' "{\"type\":\"command_response\",\"id\":\"$id\",\"action\":\"display\",\"display\":\"pong\"}"
93          ;;
94        *)
95          printf '%s\n' "{\"type\":\"command_response\",\"id\":\"$id\",\"error\":\"non-canonical command name\"}"
96          ;;
97      esac
98      ;;
99    *'"type":"shutdown"'*)
100      printf '%s\n' '{"type":"shutdown_ack"}'
101      exit 0
102      ;;
103  esac
104done
105`
106	scriptPath := filepath.Join(dir, "run.sh")
107	if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
108		t.Fatal(err)
109	}
110
111	manifest := map[string]any{
112		"name":    "mock",
113		"version": "0.0.1",
114		"exec":    "./run.sh",
115	}
116	mfb, _ := json.Marshal(manifest)
117	if err := os.WriteFile(filepath.Join(dir, "extension.json"), mfb, 0o644); err != nil {
118		t.Fatal(err)
119	}
120}
121
122func TestDiscoverReportsExitStatusAndLogWhenExtensionExitsBeforeHello(t *testing.T) {
123	if runtime.GOOS == "windows" {
124		t.Skip("mock extension uses /bin/sh; skip on windows")
125	}
126
127	tmp := t.TempDir()
128	extDir := filepath.Join(tmp, "extensions", "broken")
129	if err := os.MkdirAll(extDir, 0o755); err != nil {
130		t.Fatal(err)
131	}
132	script := "#!/bin/sh\nprintf '%s\\n' 'compile failed' >&2\nexit 23\n"
133	if err := os.WriteFile(filepath.Join(extDir, "run.sh"), []byte(script), 0o755); err != nil {
134		t.Fatal(err)
135	}
136	if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(`{"name":"broken","exec":"./run.sh"}`), 0o644); err != nil {
137		t.Fatal(err)
138	}
139
140	mgr := New(tmp, "", "0.0.0-test", "", "", nil)
141	errs := mgr.Discover(context.Background())
142	if len(errs) != 1 {
143		t.Fatalf("discover errors = %v, want one", errs)
144	}
145	logPath := filepath.Join(tmp, "logs", "ext-broken.log")
146	got := errs[0].Error()
147	if strings.Contains(got, "%!w") {
148		t.Fatalf("error contains formatting artifact: %s", got)
149	}
150	if !strings.Contains(got, "extension exited before hello: exit status 23") || !strings.Contains(got, logPath) {
151		t.Fatalf("error does not report exit status and identify stderr log: %s", got)
152	}
153	logData, err := os.ReadFile(logPath)
154	if err != nil {
155		t.Fatalf("read extension log: %v", err)
156	}
157	if !strings.Contains(string(logData), "compile failed") {
158		t.Fatalf("extension stderr missing from log: %s", logData)
159	}
160}
161
162func TestDiscoverReportsSignalWhenExtensionExitsBeforeHello(t *testing.T) {
163	if runtime.GOOS == "windows" {
164		t.Skip("mock extension uses Unix signals; skip on windows")
165	}
166
167	tmp := t.TempDir()
168	extDir := filepath.Join(tmp, "extensions", "signaled")
169	if err := os.MkdirAll(extDir, 0o755); err != nil {
170		t.Fatal(err)
171	}
172	script := "#!/bin/sh\nkill -TERM $$\n"
173	if err := os.WriteFile(filepath.Join(extDir, "run.sh"), []byte(script), 0o755); err != nil {
174		t.Fatal(err)
175	}
176	if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(`{"name":"signaled","exec":"./run.sh"}`), 0o644); err != nil {
177		t.Fatal(err)
178	}
179
180	mgr := New(tmp, "", "0.0.0-test", "", "", nil)
181	errs := mgr.Discover(context.Background())
182	if len(errs) != 1 {
183		t.Fatalf("discover errors = %v, want one", errs)
184	}
185	got := errs[0].Error()
186	if !strings.Contains(got, "extension exited before hello: signal:") || !strings.Contains(got, "ext-signaled.log") {
187		t.Fatalf("error does not report signal and identify stderr log: %s", got)
188	}
189}
190
191func TestSpawnCleansUpProcessAfterInvalidHello(t *testing.T) {
192	if runtime.GOOS == "windows" {
193		t.Skip("mock extension uses /bin/sh; skip on windows")
194	}
195
196	tmp := t.TempDir()
197	extDir := filepath.Join(tmp, "broken")
198	if err := os.MkdirAll(extDir, 0o755); err != nil {
199		t.Fatal(err)
200	}
201	scriptPath := filepath.Join(extDir, "run.sh")
202	script := "#!/bin/sh\nprintf '%s\\n' 'not-json'\nwhile IFS= read -r line; do :; done\n"
203	if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
204		t.Fatal(err)
205	}
206
207	ext := &Extension{Manifest: Manifest{Name: "broken", Exec: "./run.sh"}, Dir: extDir}
208	mgr := New(tmp, "", "0.0.0-test", "", "", nil)
209	err := mgr.spawn(context.Background(), ext)
210	if err == nil || !strings.Contains(err.Error(), "parse hello") {
211		t.Fatalf("spawn error = %v, want parse hello failure", err)
212	}
213	if ext.cmd == nil || ext.cmd.ProcessState == nil {
214		t.Fatal("failed handshake process was not reaped")
215	}
216	if _, err := ext.logFile.WriteString("after cleanup"); err == nil {
217		t.Fatal("failed handshake log file was not closed")
218	}
219}
220
221func TestDiscoverLoadsThemeOnlyExtension(t *testing.T) {
222	tmp := t.TempDir()
223	extDir := filepath.Join(tmp, "extensions", "theme-only")
224	if err := os.MkdirAll(extDir, 0o755); err != nil {
225		t.Fatal(err)
226	}
227	manifest := `{"name":"theme-only","version":"1.0.0","description":"theme only"}`
228	if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(manifest), 0o644); err != nil {
229		t.Fatal(err)
230	}
231	theme := `{"name":"Theme Only","description":"theme from extension","colors":{"dark":{"accent":204}}}`
232	if err := os.WriteFile(filepath.Join(extDir, "theme.json"), []byte(theme), 0o644); err != nil {
233		t.Fatal(err)
234	}
235
236	mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-opus-4-7", nil)
237	if errs := mgr.Discover(context.Background()); len(errs) > 0 {
238		t.Fatalf("discover errors: %v", errs)
239	}
240	defer mgr.Stop(10 * time.Millisecond)
241
242	opts := mgr.ThemeOptions()
243	if len(opts) != 1 {
244		t.Fatalf("theme options = %d, want 1", len(opts))
245	}
246	if opts[0].Label != "Theme Only" || opts[0].Path != filepath.Join(extDir, "theme.json") {
247		t.Fatalf("unexpected theme option: %#v", opts[0])
248	}
249	if !strings.Contains(opts[0].Description, "from extension theme-only") {
250		t.Fatalf("description missing extension source: %q", opts[0].Description)
251	}
252}
253
254func TestManagerSpawnAndInvoke(t *testing.T) {
255	tmp := t.TempDir()
256	extRoot := filepath.Join(tmp, "extensions")
257	if err := os.MkdirAll(extRoot, 0o755); err != nil {
258		t.Fatal(err)
259	}
260	writeMockExtension(t, extRoot)
261
262	hooks := &stubHooks{}
263	mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-opus-4-7", hooks)
264
265	if errs := mgr.Discover(context.Background()); len(errs) > 0 {
266		t.Fatalf("discover errors: %v", errs)
267	}
268	defer mgr.Stop(2 * time.Second)
269
270	// Give the extension a beat to send register_command frames after
271	// the hello handshake.
272	time.Sleep(150 * time.Millisecond)
273
274	cmds := mgr.Commands()
275	found := false
276	for _, c := range cmds {
277		if c.Name == "Ping" {
278			found = true
279			break
280		}
281	}
282	if !found {
283		t.Fatalf("expected canonical command 'Ping', got %#v", cmds)
284	}
285	if !mgr.HasCommand("PING") {
286		t.Fatal("HasCommand(\"PING\") = false")
287	}
288	if owner := mgr.CommandOwner("pInG"); owner != "mock" {
289		t.Fatalf("CommandOwner(\"pInG\") = %q, want mock", owner)
290	}
291
292	resp, err := mgr.Invoke(context.Background(), "PING", "", 2*time.Second)
293	if err != nil {
294		t.Fatalf("invoke: %v", err)
295	}
296	if resp.Action != "display" {
297		t.Errorf("expected action=display, got %q", resp.Action)
298	}
299	if resp.Display != "pong" {
300		t.Errorf("expected display=pong, got %q", resp.Display)
301	}
302}
303
304func TestDiagnosticsReportMalformedFramesAndConflicts(t *testing.T) {
305	if runtime.GOOS == "windows" {
306		t.Skip("mock extension uses /bin/sh; skip on windows")
307	}
308
309	tmp := t.TempDir()
310	extRoot := filepath.Join(tmp, "extensions")
311	writeDiagExtension := func(name, script string) {
312		t.Helper()
313		dir := filepath.Join(extRoot, name)
314		if err := os.MkdirAll(dir, 0o755); err != nil {
315			t.Fatal(err)
316		}
317		if err := os.WriteFile(filepath.Join(dir, "run.sh"), []byte(script), 0o755); err != nil {
318			t.Fatal(err)
319		}
320		mfb, _ := json.Marshal(map[string]any{"name": name, "exec": "./run.sh"})
321		if err := os.WriteFile(filepath.Join(dir, "extension.json"), mfb, 0o644); err != nil {
322			t.Fatal(err)
323		}
324	}
325
326	writeDiagExtension("a-first", `#!/bin/sh
327printf '%s\n' '{"type":"hello","name":"a-first","version":"0.1","capabilities":["commands","tools"]}'
328printf '%s\n' '{"type":"register_command","name":"CaseTest","description":"first"}'
329printf '%s\n' '{"type":"register_tool","name":"shared","description":"first","schema":{"type":"object"}}'
330printf '%s\n' '{"type":"register_tool","name":"broken","description":"bad","schema":'
331printf '%s\n' '{"type":"ready"}'
332while IFS= read -r line; do
333  case "$line" in
334    *'"type":"shutdown"'*) exit 0 ;;
335  esac
336done
337`)
338	writeDiagExtension("b-second", `#!/bin/sh
339printf '%s\n' '{"type":"hello","name":"b-second","version":"0.1","capabilities":["commands","tools"]}'
340printf '%s\n' '{"type":"register_command","name":"casetest","description":"second"}'
341printf '%s\n' '{"type":"register_tool","name":"shared","description":"second","schema":{"type":"object"}}'
342printf '%s\n' '{"type":"ready"}'
343while IFS= read -r line; do
344  case "$line" in
345    *'"type":"shutdown"'*) exit 0 ;;
346  esac
347done
348`)
349
350	mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-opus-4-7", &stubHooks{})
351	if errs := mgr.Discover(context.Background()); len(errs) > 0 {
352		t.Fatalf("discover errors: %v", errs)
353	}
354	defer mgr.Stop(2 * time.Second)
355	mgr.WaitForReady(2 * time.Second)
356
357	diags := mgr.Diagnostics()
358	byName := map[string]ExtensionDiagnostic{}
359	for _, d := range diags {
360		byName[d.Name] = d
361	}
362
363	first := byName["a-first"]
364	if len(first.Messages) == 0 || !strings.Contains(strings.Join(first.Messages, "\n"), "malformed json frame") {
365		t.Fatalf("expected malformed-frame diagnostic, got %#v", first.Messages)
366	}
367
368	var shadowedTool bool
369	var activeCommands, shadowedCommands int
370	var conflictMessage bool
371	for _, d := range diags {
372		if strings.Contains(strings.Join(d.Messages, "\n"), "conflicts with another extension") {
373			conflictMessage = true
374		}
375		for _, tool := range d.Tools {
376			if tool.Name == "shared" && !tool.Active {
377				shadowedTool = true
378			}
379		}
380		for _, command := range d.Commands {
381			if !strings.EqualFold(command.Name, "casetest") {
382				continue
383			}
384			if command.Active {
385				activeCommands++
386			} else {
387				shadowedCommands++
388			}
389		}
390	}
391	if !shadowedTool {
392		t.Fatalf("expected one shared tool registration to be inactive, got %#v", diags)
393	}
394	if activeCommands != 1 || shadowedCommands != 1 {
395		t.Fatalf("case-only command registrations: active=%d shadowed=%d; diagnostics=%#v", activeCommands, shadowedCommands, diags)
396	}
397	if !conflictMessage {
398		t.Fatalf("expected conflict diagnostic, got %#v", diags)
399	}
400}
401
402// TestSpontaneousSubmit verifies that an extension can submit a
403// non-empty prompt outside of any command response.
404func TestSpontaneousSubmit(t *testing.T) {
405	if runtime.GOOS == "windows" {
406		t.Skip("mock extension uses /bin/sh; skip on windows")
407	}
408
409	tmp := t.TempDir()
410	extDir := filepath.Join(tmp, "extensions", "submit-mock")
411	if err := os.MkdirAll(extDir, 0o755); err != nil {
412		t.Fatal(err)
413	}
414
415	script := `#!/bin/sh
416printf '%s\n' '{"type":"hello","name":"submit-mock","version":"0.1","capabilities":["submit"]}'
417printf '%s\n' '{"type":"ready"}'
418printf '%s\n' '{"type":"submit","text":"  explain this repository briefly  "}'
419printf '%s\n' '{"type":"submit","text":"   "}'
420while IFS= read -r line; do
421  case "$line" in
422    *'"type":"shutdown"'*)
423      printf '%s\n' '{"type":"shutdown_ack"}'
424      exit 0
425      ;;
426  esac
427done
428`
429	if err := os.WriteFile(filepath.Join(extDir, "run.sh"), []byte(script), 0o755); err != nil {
430		t.Fatal(err)
431	}
432	mfb, _ := json.Marshal(map[string]any{"name": "submit-mock", "exec": "./run.sh"})
433	if err := os.WriteFile(filepath.Join(extDir, "extension.json"), mfb, 0o644); err != nil {
434		t.Fatal(err)
435	}
436
437	hooks := &stubHooks{}
438	mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-opus-4-7", hooks)
439	if errs := mgr.Discover(context.Background()); len(errs) > 0 {
440		t.Fatalf("discover errors: %v", errs)
441	}
442	defer mgr.Stop(2 * time.Second)
443
444	deadline := time.Now().Add(2 * time.Second)
445	for time.Now().Before(deadline) {
446		hooks.mu.Lock()
447		n := len(hooks.submits)
448		hooks.mu.Unlock()
449		if n > 0 {
450			break
451		}
452		time.Sleep(20 * time.Millisecond)
453	}
454
455	hooks.mu.Lock()
456	defer hooks.mu.Unlock()
457	if len(hooks.submits) != 1 {
458		t.Fatalf("Submit calls = %#v, want one non-empty call", hooks.submits)
459	}
460	if hooks.submits[0] != "explain this repository briefly" {
461		t.Fatalf("Submit text = %q", hooks.submits[0])
462	}
463}
464
465// TestSpontaneousOpenPanel verifies that an extension sending an
466// open_panel frame outside of any command response causes the manager
467// to call hooks.OpenPanel with the correct PanelSpec fields.
468func TestSpontaneousOpenPanel(t *testing.T) {
469	if runtime.GOOS == "windows" {
470		t.Skip("mock extension uses /bin/sh; skip on windows")
471	}
472
473	tmp := t.TempDir()
474	extDir := filepath.Join(tmp, "extensions", "panel-mock")
475	if err := os.MkdirAll(extDir, 0o755); err != nil {
476		t.Fatal(err)
477	}
478
479	// Extension emits hello + ready, then immediately fires a
480	// spontaneous open_panel, then waits for shutdown.
481	script := `#!/bin/sh
482printf '%s\n' '{"type":"hello","name":"panel-mock","version":"0.1","capabilities":["panels"]}'
483printf '%s\n' '{"type":"ready"}'
484printf '%s\n' '{"type":"open_panel","panel":{"id":"test-panel","title":"Hello Panel","lines":["line one","line two"],"footer":"esc close"}}'
485while IFS= read -r line; do
486  case "$line" in
487    *'"type":"shutdown"'*)
488      printf '%s\n' '{"type":"shutdown_ack"}'
489      exit 0
490      ;;
491  esac
492done
493`
494	if err := os.WriteFile(filepath.Join(extDir, "run.sh"), []byte(script), 0o755); err != nil {
495		t.Fatal(err)
496	}
497	mfb, _ := json.Marshal(map[string]any{"name": "panel-mock", "exec": "./run.sh"})
498	if err := os.WriteFile(filepath.Join(extDir, "extension.json"), mfb, 0o644); err != nil {
499		t.Fatal(err)
500	}
501
502	hooks := &stubHooks{}
503	mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-opus-4-7", hooks)
504	if errs := mgr.Discover(context.Background()); len(errs) > 0 {
505		t.Fatalf("discover errors: %v", errs)
506	}
507	defer mgr.Stop(2 * time.Second)
508
509	// Give the extension time to flush its open_panel frame.
510	deadline := time.Now().Add(2 * time.Second)
511	for time.Now().Before(deadline) {
512		hooks.mu.Lock()
513		n := len(hooks.panels)
514		hooks.mu.Unlock()
515		if n > 0 {
516			break
517		}
518		time.Sleep(20 * time.Millisecond)
519	}
520
521	hooks.mu.Lock()
522	defer hooks.mu.Unlock()
523
524	if len(hooks.panels) == 0 {
525		t.Fatal("hooks.OpenPanel was never called")
526	}
527	spec := hooks.panels[0]
528	if spec.ID != "test-panel" {
529		t.Errorf("panel id: want %q, got %q", "test-panel", spec.ID)
530	}
531	if spec.Title != "Hello Panel" {
532		t.Errorf("panel title: want %q, got %q", "Hello Panel", spec.Title)
533	}
534	if len(spec.Lines) != 2 || spec.Lines[0] != "line one" || spec.Lines[1] != "line two" {
535		t.Errorf("panel lines: want [line one line two], got %v", spec.Lines)
536	}
537	if spec.Footer != "esc close" {
538		t.Errorf("panel footer: want %q, got %q", "esc close", spec.Footer)
539	}
540	if hooks.panelExts[0] != "panel-mock" {
541		t.Errorf("ext name: want %q, got %q", "panel-mock", hooks.panelExts[0])
542	}
543}

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