v0.2.57 be0642f

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	clearNotes []string
23	panels     []extproto.PanelSpec
24	panelExts  []string
25}
26
27func (s *stubHooks) Notify(name, level, message string) {
28	s.mu.Lock()
29	defer s.mu.Unlock()
30	s.notifies = append(s.notifies, name+":"+level+":"+message)
31}
32func (s *stubHooks) Submit(string)      {}
33func (s *stubHooks) SubmitSlash(string) {}
34func (s *stubHooks) Insert(string)      {}
35func (s *stubHooks) Display(name, text string) {
36	s.mu.Lock()
37	defer s.mu.Unlock()
38	s.displays = append(s.displays, name+":"+text)
39}
40func (s *stubHooks) ClearNotes(name string) {
41	s.mu.Lock()
42	defer s.mu.Unlock()
43	s.clearNotes = append(s.clearNotes, name)
44}
45func (s *stubHooks) OpenPanel(extName string, spec extproto.PanelSpec) {
46	s.mu.Lock()
47	defer s.mu.Unlock()
48	s.panelExts = append(s.panelExts, extName)
49	s.panels = append(s.panels, spec)
50}
51func (s *stubHooks) UpdatePanel(string, string, string, []string, string) {}
52func (s *stubHooks) ClosePanel(string, string)                            {}
53
54// writeMockExtension creates a minimal extension on disk that uses a
55// shell script (or batch file on windows) to drive the protocol. The
56// script reads commands from stdin and emits hard-coded responses,
57// exercising the manager's spawn/handshake/dispatch path without
58// needing the SDK.
59func writeMockExtension(t *testing.T, root string) {
60	t.Helper()
61	if runtime.GOOS == "windows" {
62		t.Skip("mock extension uses /bin/sh; skip on windows")
63	}
64
65	dir := filepath.Join(root, "mock")
66	if err := os.MkdirAll(dir, 0o755); err != nil {
67		t.Fatal(err)
68	}
69
70	// Shell script: emit hello, read frames, respond. Reads until
71	// stdin closes; tail's -F keeps the pipe alive long enough for
72	// the manager to send command_invoked.
73	script := `#!/bin/sh
74printf '%s\n' '{"type":"hello","name":"mock","version":"0.0.1","capabilities":["commands"]}'
75printf '%s\n' '{"type":"register_command","name":"ping","description":"ping/pong"}'
76while IFS= read -r line; do
77  case "$line" in
78    *'"type":"command_invoked"'*)
79      id=$(printf '%s' "$line" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p')
80      printf '%s\n' "{\"type\":\"command_response\",\"id\":\"$id\",\"action\":\"display\",\"display\":\"pong\"}"
81      ;;
82    *'"type":"shutdown"'*)
83      printf '%s\n' '{"type":"shutdown_ack"}'
84      exit 0
85      ;;
86  esac
87done
88`
89	scriptPath := filepath.Join(dir, "run.sh")
90	if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
91		t.Fatal(err)
92	}
93
94	manifest := map[string]any{
95		"name":    "mock",
96		"version": "0.0.1",
97		"exec":    "./run.sh",
98	}
99	mfb, _ := json.Marshal(manifest)
100	if err := os.WriteFile(filepath.Join(dir, "extension.json"), mfb, 0o644); err != nil {
101		t.Fatal(err)
102	}
103}
104
105func TestDiscoverLoadsThemeOnlyExtension(t *testing.T) {
106	tmp := t.TempDir()
107	extDir := filepath.Join(tmp, "extensions", "theme-only")
108	if err := os.MkdirAll(extDir, 0o755); err != nil {
109		t.Fatal(err)
110	}
111	manifest := `{"name":"theme-only","version":"1.0.0","description":"theme only"}`
112	if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(manifest), 0o644); err != nil {
113		t.Fatal(err)
114	}
115	theme := `{"name":"Theme Only","description":"theme from extension","colors":{"dark":{"accent":204}}}`
116	if err := os.WriteFile(filepath.Join(extDir, "theme.json"), []byte(theme), 0o644); err != nil {
117		t.Fatal(err)
118	}
119
120	mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-opus-4-7", nil)
121	if errs := mgr.Discover(context.Background()); len(errs) > 0 {
122		t.Fatalf("discover errors: %v", errs)
123	}
124	defer mgr.Stop(10 * time.Millisecond)
125
126	opts := mgr.ThemeOptions()
127	if len(opts) != 1 {
128		t.Fatalf("theme options = %d, want 1", len(opts))
129	}
130	if opts[0].Label != "Theme Only" || opts[0].Path != filepath.Join(extDir, "theme.json") {
131		t.Fatalf("unexpected theme option: %#v", opts[0])
132	}
133	if !strings.Contains(opts[0].Description, "from extension theme-only") {
134		t.Fatalf("description missing extension source: %q", opts[0].Description)
135	}
136}
137
138func TestManagerSpawnAndInvoke(t *testing.T) {
139	tmp := t.TempDir()
140	extRoot := filepath.Join(tmp, "extensions")
141	if err := os.MkdirAll(extRoot, 0o755); err != nil {
142		t.Fatal(err)
143	}
144	writeMockExtension(t, extRoot)
145
146	hooks := &stubHooks{}
147	mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-opus-4-7", hooks)
148
149	if errs := mgr.Discover(context.Background()); len(errs) > 0 {
150		t.Fatalf("discover errors: %v", errs)
151	}
152	defer mgr.Stop(2 * time.Second)
153
154	// Give the extension a beat to send register_command frames after
155	// the hello handshake.
156	time.Sleep(150 * time.Millisecond)
157
158	cmds := mgr.Commands()
159	found := false
160	for _, c := range cmds {
161		if c.Name == "ping" {
162			found = true
163			break
164		}
165	}
166	if !found {
167		t.Fatalf("expected command 'ping', got %#v", cmds)
168	}
169	if !mgr.HasCommand("ping") {
170		t.Fatal("HasCommand(\"ping\") = false")
171	}
172
173	resp, err := mgr.Invoke(context.Background(), "ping", "", 2*time.Second)
174	if err != nil {
175		t.Fatalf("invoke: %v", err)
176	}
177	if resp.Action != "display" {
178		t.Errorf("expected action=display, got %q", resp.Action)
179	}
180	if resp.Display != "pong" {
181		t.Errorf("expected display=pong, got %q", resp.Display)
182	}
183}
184
185func TestDiagnosticsReportMalformedFramesAndConflicts(t *testing.T) {
186	if runtime.GOOS == "windows" {
187		t.Skip("mock extension uses /bin/sh; skip on windows")
188	}
189
190	tmp := t.TempDir()
191	extRoot := filepath.Join(tmp, "extensions")
192	writeDiagExtension := func(name, script string) {
193		t.Helper()
194		dir := filepath.Join(extRoot, name)
195		if err := os.MkdirAll(dir, 0o755); err != nil {
196			t.Fatal(err)
197		}
198		if err := os.WriteFile(filepath.Join(dir, "run.sh"), []byte(script), 0o755); err != nil {
199			t.Fatal(err)
200		}
201		mfb, _ := json.Marshal(map[string]any{"name": name, "exec": "./run.sh"})
202		if err := os.WriteFile(filepath.Join(dir, "extension.json"), mfb, 0o644); err != nil {
203			t.Fatal(err)
204		}
205	}
206
207	writeDiagExtension("a-first", `#!/bin/sh
208printf '%s\n' '{"type":"hello","name":"a-first","version":"0.1","capabilities":["tools"]}'
209printf '%s\n' '{"type":"register_tool","name":"shared","description":"first","schema":{"type":"object"}}'
210printf '%s\n' '{"type":"register_tool","name":"broken","description":"bad","schema":'
211printf '%s\n' '{"type":"ready"}'
212while IFS= read -r line; do
213  case "$line" in
214    *'"type":"shutdown"'*) exit 0 ;;
215  esac
216done
217`)
218	writeDiagExtension("b-second", `#!/bin/sh
219printf '%s\n' '{"type":"hello","name":"b-second","version":"0.1","capabilities":["tools"]}'
220printf '%s\n' '{"type":"register_tool","name":"shared","description":"second","schema":{"type":"object"}}'
221printf '%s\n' '{"type":"ready"}'
222while IFS= read -r line; do
223  case "$line" in
224    *'"type":"shutdown"'*) exit 0 ;;
225  esac
226done
227`)
228
229	mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-opus-4-7", &stubHooks{})
230	if errs := mgr.Discover(context.Background()); len(errs) > 0 {
231		t.Fatalf("discover errors: %v", errs)
232	}
233	defer mgr.Stop(2 * time.Second)
234	mgr.WaitForReady(2 * time.Second)
235
236	diags := mgr.Diagnostics()
237	byName := map[string]ExtensionDiagnostic{}
238	for _, d := range diags {
239		byName[d.Name] = d
240	}
241
242	first := byName["a-first"]
243	if len(first.Messages) == 0 || !strings.Contains(strings.Join(first.Messages, "\n"), "malformed json frame") {
244		t.Fatalf("expected malformed-frame diagnostic, got %#v", first.Messages)
245	}
246
247	var shadowedTool bool
248	var conflictMessage bool
249	for _, d := range diags {
250		if strings.Contains(strings.Join(d.Messages, "\n"), "conflicts with another extension") {
251			conflictMessage = true
252		}
253		for _, tool := range d.Tools {
254			if tool.Name == "shared" && !tool.Active {
255				shadowedTool = true
256			}
257		}
258	}
259	if !shadowedTool {
260		t.Fatalf("expected one shared tool registration to be inactive, got %#v", diags)
261	}
262	if !conflictMessage {
263		t.Fatalf("expected conflict diagnostic, got %#v", diags)
264	}
265}
266
267// TestSpontaneousOpenPanel verifies that an extension sending an
268// open_panel frame outside of any command response causes the manager
269// to call hooks.OpenPanel with the correct PanelSpec fields.
270func TestSpontaneousOpenPanel(t *testing.T) {
271	if runtime.GOOS == "windows" {
272		t.Skip("mock extension uses /bin/sh; skip on windows")
273	}
274
275	tmp := t.TempDir()
276	extDir := filepath.Join(tmp, "extensions", "panel-mock")
277	if err := os.MkdirAll(extDir, 0o755); err != nil {
278		t.Fatal(err)
279	}
280
281	// Extension emits hello + ready, then immediately fires a
282	// spontaneous open_panel, then waits for shutdown.
283	script := `#!/bin/sh
284printf '%s\n' '{"type":"hello","name":"panel-mock","version":"0.1","capabilities":["panels"]}'
285printf '%s\n' '{"type":"ready"}'
286printf '%s\n' '{"type":"open_panel","panel":{"id":"test-panel","title":"Hello Panel","lines":["line one","line two"],"footer":"esc close"}}'
287while IFS= read -r line; do
288  case "$line" in
289    *'"type":"shutdown"'*)
290      printf '%s\n' '{"type":"shutdown_ack"}'
291      exit 0
292      ;;
293  esac
294done
295`
296	if err := os.WriteFile(filepath.Join(extDir, "run.sh"), []byte(script), 0o755); err != nil {
297		t.Fatal(err)
298	}
299	mfb, _ := json.Marshal(map[string]any{"name": "panel-mock", "exec": "./run.sh"})
300	if err := os.WriteFile(filepath.Join(extDir, "extension.json"), mfb, 0o644); err != nil {
301		t.Fatal(err)
302	}
303
304	hooks := &stubHooks{}
305	mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-opus-4-7", hooks)
306	if errs := mgr.Discover(context.Background()); len(errs) > 0 {
307		t.Fatalf("discover errors: %v", errs)
308	}
309	defer mgr.Stop(2 * time.Second)
310
311	// Give the extension time to flush its open_panel frame.
312	deadline := time.Now().Add(2 * time.Second)
313	for time.Now().Before(deadline) {
314		hooks.mu.Lock()
315		n := len(hooks.panels)
316		hooks.mu.Unlock()
317		if n > 0 {
318			break
319		}
320		time.Sleep(20 * time.Millisecond)
321	}
322
323	hooks.mu.Lock()
324	defer hooks.mu.Unlock()
325
326	if len(hooks.panels) == 0 {
327		t.Fatal("hooks.OpenPanel was never called")
328	}
329	spec := hooks.panels[0]
330	if spec.ID != "test-panel" {
331		t.Errorf("panel id: want %q, got %q", "test-panel", spec.ID)
332	}
333	if spec.Title != "Hello Panel" {
334		t.Errorf("panel title: want %q, got %q", "Hello Panel", spec.Title)
335	}
336	if len(spec.Lines) != 2 || spec.Lines[0] != "line one" || spec.Lines[1] != "line two" {
337		t.Errorf("panel lines: want [line one line two], got %v", spec.Lines)
338	}
339	if spec.Footer != "esc close" {
340		t.Errorf("panel footer: want %q, got %q", "esc close", spec.Footer)
341	}
342	if hooks.panelExts[0] != "panel-mock" {
343		t.Errorf("ext name: want %q, got %q", "panel-mock", hooks.panelExts[0])
344	}
345}

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