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 TestDiscoverReportsLogWhenExtensionExitsBeforeHello(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 1\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") || !strings.Contains(got, logPath) {
151 t.Fatalf("error does not explain failure 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 TestSpawnCleansUpProcessAfterInvalidHello(t *testing.T) {
163 if runtime.GOOS == "windows" {
164 t.Skip("mock extension uses /bin/sh; skip on windows")
165 }
166
167 tmp := t.TempDir()
168 extDir := filepath.Join(tmp, "broken")
169 if err := os.MkdirAll(extDir, 0o755); err != nil {
170 t.Fatal(err)
171 }
172 scriptPath := filepath.Join(extDir, "run.sh")
173 script := "#!/bin/sh\nprintf '%s\\n' 'not-json'\nwhile IFS= read -r line; do :; done\n"
174 if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
175 t.Fatal(err)
176 }
177
178 ext := &Extension{Manifest: Manifest{Name: "broken", Exec: "./run.sh"}, Dir: extDir}
179 mgr := New(tmp, "", "0.0.0-test", "", "", nil)
180 err := mgr.spawn(context.Background(), ext)
181 if err == nil || !strings.Contains(err.Error(), "parse hello") {
182 t.Fatalf("spawn error = %v, want parse hello failure", err)
183 }
184 if ext.cmd == nil || ext.cmd.ProcessState == nil {
185 t.Fatal("failed handshake process was not reaped")
186 }
187 if _, err := ext.logFile.WriteString("after cleanup"); err == nil {
188 t.Fatal("failed handshake log file was not closed")
189 }
190}
191
192func TestDiscoverLoadsThemeOnlyExtension(t *testing.T) {
193 tmp := t.TempDir()
194 extDir := filepath.Join(tmp, "extensions", "theme-only")
195 if err := os.MkdirAll(extDir, 0o755); err != nil {
196 t.Fatal(err)
197 }
198 manifest := `{"name":"theme-only","version":"1.0.0","description":"theme only"}`
199 if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(manifest), 0o644); err != nil {
200 t.Fatal(err)
201 }
202 theme := `{"name":"Theme Only","description":"theme from extension","colors":{"dark":{"accent":204}}}`
203 if err := os.WriteFile(filepath.Join(extDir, "theme.json"), []byte(theme), 0o644); err != nil {
204 t.Fatal(err)
205 }
206
207 mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-opus-4-7", nil)
208 if errs := mgr.Discover(context.Background()); len(errs) > 0 {
209 t.Fatalf("discover errors: %v", errs)
210 }
211 defer mgr.Stop(10 * time.Millisecond)
212
213 opts := mgr.ThemeOptions()
214 if len(opts) != 1 {
215 t.Fatalf("theme options = %d, want 1", len(opts))
216 }
217 if opts[0].Label != "Theme Only" || opts[0].Path != filepath.Join(extDir, "theme.json") {
218 t.Fatalf("unexpected theme option: %#v", opts[0])
219 }
220 if !strings.Contains(opts[0].Description, "from extension theme-only") {
221 t.Fatalf("description missing extension source: %q", opts[0].Description)
222 }
223}
224
225func TestManagerSpawnAndInvoke(t *testing.T) {
226 tmp := t.TempDir()
227 extRoot := filepath.Join(tmp, "extensions")
228 if err := os.MkdirAll(extRoot, 0o755); err != nil {
229 t.Fatal(err)
230 }
231 writeMockExtension(t, extRoot)
232
233 hooks := &stubHooks{}
234 mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-opus-4-7", hooks)
235
236 if errs := mgr.Discover(context.Background()); len(errs) > 0 {
237 t.Fatalf("discover errors: %v", errs)
238 }
239 defer mgr.Stop(2 * time.Second)
240
241 // Give the extension a beat to send register_command frames after
242 // the hello handshake.
243 time.Sleep(150 * time.Millisecond)
244
245 cmds := mgr.Commands()
246 found := false
247 for _, c := range cmds {
248 if c.Name == "Ping" {
249 found = true
250 break
251 }
252 }
253 if !found {
254 t.Fatalf("expected canonical command 'Ping', got %#v", cmds)
255 }
256 if !mgr.HasCommand("PING") {
257 t.Fatal("HasCommand(\"PING\") = false")
258 }
259 if owner := mgr.CommandOwner("pInG"); owner != "mock" {
260 t.Fatalf("CommandOwner(\"pInG\") = %q, want mock", owner)
261 }
262
263 resp, err := mgr.Invoke(context.Background(), "PING", "", 2*time.Second)
264 if err != nil {
265 t.Fatalf("invoke: %v", err)
266 }
267 if resp.Action != "display" {
268 t.Errorf("expected action=display, got %q", resp.Action)
269 }
270 if resp.Display != "pong" {
271 t.Errorf("expected display=pong, got %q", resp.Display)
272 }
273}
274
275func TestDiagnosticsReportMalformedFramesAndConflicts(t *testing.T) {
276 if runtime.GOOS == "windows" {
277 t.Skip("mock extension uses /bin/sh; skip on windows")
278 }
279
280 tmp := t.TempDir()
281 extRoot := filepath.Join(tmp, "extensions")
282 writeDiagExtension := func(name, script string) {
283 t.Helper()
284 dir := filepath.Join(extRoot, name)
285 if err := os.MkdirAll(dir, 0o755); err != nil {
286 t.Fatal(err)
287 }
288 if err := os.WriteFile(filepath.Join(dir, "run.sh"), []byte(script), 0o755); err != nil {
289 t.Fatal(err)
290 }
291 mfb, _ := json.Marshal(map[string]any{"name": name, "exec": "./run.sh"})
292 if err := os.WriteFile(filepath.Join(dir, "extension.json"), mfb, 0o644); err != nil {
293 t.Fatal(err)
294 }
295 }
296
297 writeDiagExtension("a-first", `#!/bin/sh
298printf '%s\n' '{"type":"hello","name":"a-first","version":"0.1","capabilities":["commands","tools"]}'
299printf '%s\n' '{"type":"register_command","name":"CaseTest","description":"first"}'
300printf '%s\n' '{"type":"register_tool","name":"shared","description":"first","schema":{"type":"object"}}'
301printf '%s\n' '{"type":"register_tool","name":"broken","description":"bad","schema":'
302printf '%s\n' '{"type":"ready"}'
303while IFS= read -r line; do
304 case "$line" in
305 *'"type":"shutdown"'*) exit 0 ;;
306 esac
307done
308`)
309 writeDiagExtension("b-second", `#!/bin/sh
310printf '%s\n' '{"type":"hello","name":"b-second","version":"0.1","capabilities":["commands","tools"]}'
311printf '%s\n' '{"type":"register_command","name":"casetest","description":"second"}'
312printf '%s\n' '{"type":"register_tool","name":"shared","description":"second","schema":{"type":"object"}}'
313printf '%s\n' '{"type":"ready"}'
314while IFS= read -r line; do
315 case "$line" in
316 *'"type":"shutdown"'*) exit 0 ;;
317 esac
318done
319`)
320
321 mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-opus-4-7", &stubHooks{})
322 if errs := mgr.Discover(context.Background()); len(errs) > 0 {
323 t.Fatalf("discover errors: %v", errs)
324 }
325 defer mgr.Stop(2 * time.Second)
326 mgr.WaitForReady(2 * time.Second)
327
328 diags := mgr.Diagnostics()
329 byName := map[string]ExtensionDiagnostic{}
330 for _, d := range diags {
331 byName[d.Name] = d
332 }
333
334 first := byName["a-first"]
335 if len(first.Messages) == 0 || !strings.Contains(strings.Join(first.Messages, "\n"), "malformed json frame") {
336 t.Fatalf("expected malformed-frame diagnostic, got %#v", first.Messages)
337 }
338
339 var shadowedTool bool
340 var activeCommands, shadowedCommands int
341 var conflictMessage bool
342 for _, d := range diags {
343 if strings.Contains(strings.Join(d.Messages, "\n"), "conflicts with another extension") {
344 conflictMessage = true
345 }
346 for _, tool := range d.Tools {
347 if tool.Name == "shared" && !tool.Active {
348 shadowedTool = true
349 }
350 }
351 for _, command := range d.Commands {
352 if !strings.EqualFold(command.Name, "casetest") {
353 continue
354 }
355 if command.Active {
356 activeCommands++
357 } else {
358 shadowedCommands++
359 }
360 }
361 }
362 if !shadowedTool {
363 t.Fatalf("expected one shared tool registration to be inactive, got %#v", diags)
364 }
365 if activeCommands != 1 || shadowedCommands != 1 {
366 t.Fatalf("case-only command registrations: active=%d shadowed=%d; diagnostics=%#v", activeCommands, shadowedCommands, diags)
367 }
368 if !conflictMessage {
369 t.Fatalf("expected conflict diagnostic, got %#v", diags)
370 }
371}
372
373// TestSpontaneousSubmit verifies that an extension can submit a
374// non-empty prompt outside of any command response.
375func TestSpontaneousSubmit(t *testing.T) {
376 if runtime.GOOS == "windows" {
377 t.Skip("mock extension uses /bin/sh; skip on windows")
378 }
379
380 tmp := t.TempDir()
381 extDir := filepath.Join(tmp, "extensions", "submit-mock")
382 if err := os.MkdirAll(extDir, 0o755); err != nil {
383 t.Fatal(err)
384 }
385
386 script := `#!/bin/sh
387printf '%s\n' '{"type":"hello","name":"submit-mock","version":"0.1","capabilities":["submit"]}'
388printf '%s\n' '{"type":"ready"}'
389printf '%s\n' '{"type":"submit","text":" explain this repository briefly "}'
390printf '%s\n' '{"type":"submit","text":" "}'
391while IFS= read -r line; do
392 case "$line" in
393 *'"type":"shutdown"'*)
394 printf '%s\n' '{"type":"shutdown_ack"}'
395 exit 0
396 ;;
397 esac
398done
399`
400 if err := os.WriteFile(filepath.Join(extDir, "run.sh"), []byte(script), 0o755); err != nil {
401 t.Fatal(err)
402 }
403 mfb, _ := json.Marshal(map[string]any{"name": "submit-mock", "exec": "./run.sh"})
404 if err := os.WriteFile(filepath.Join(extDir, "extension.json"), mfb, 0o644); err != nil {
405 t.Fatal(err)
406 }
407
408 hooks := &stubHooks{}
409 mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-opus-4-7", hooks)
410 if errs := mgr.Discover(context.Background()); len(errs) > 0 {
411 t.Fatalf("discover errors: %v", errs)
412 }
413 defer mgr.Stop(2 * time.Second)
414
415 deadline := time.Now().Add(2 * time.Second)
416 for time.Now().Before(deadline) {
417 hooks.mu.Lock()
418 n := len(hooks.submits)
419 hooks.mu.Unlock()
420 if n > 0 {
421 break
422 }
423 time.Sleep(20 * time.Millisecond)
424 }
425
426 hooks.mu.Lock()
427 defer hooks.mu.Unlock()
428 if len(hooks.submits) != 1 {
429 t.Fatalf("Submit calls = %#v, want one non-empty call", hooks.submits)
430 }
431 if hooks.submits[0] != "explain this repository briefly" {
432 t.Fatalf("Submit text = %q", hooks.submits[0])
433 }
434}
435
436// TestSpontaneousOpenPanel verifies that an extension sending an
437// open_panel frame outside of any command response causes the manager
438// to call hooks.OpenPanel with the correct PanelSpec fields.
439func TestSpontaneousOpenPanel(t *testing.T) {
440 if runtime.GOOS == "windows" {
441 t.Skip("mock extension uses /bin/sh; skip on windows")
442 }
443
444 tmp := t.TempDir()
445 extDir := filepath.Join(tmp, "extensions", "panel-mock")
446 if err := os.MkdirAll(extDir, 0o755); err != nil {
447 t.Fatal(err)
448 }
449
450 // Extension emits hello + ready, then immediately fires a
451 // spontaneous open_panel, then waits for shutdown.
452 script := `#!/bin/sh
453printf '%s\n' '{"type":"hello","name":"panel-mock","version":"0.1","capabilities":["panels"]}'
454printf '%s\n' '{"type":"ready"}'
455printf '%s\n' '{"type":"open_panel","panel":{"id":"test-panel","title":"Hello Panel","lines":["line one","line two"],"footer":"esc close"}}'
456while IFS= read -r line; do
457 case "$line" in
458 *'"type":"shutdown"'*)
459 printf '%s\n' '{"type":"shutdown_ack"}'
460 exit 0
461 ;;
462 esac
463done
464`
465 if err := os.WriteFile(filepath.Join(extDir, "run.sh"), []byte(script), 0o755); err != nil {
466 t.Fatal(err)
467 }
468 mfb, _ := json.Marshal(map[string]any{"name": "panel-mock", "exec": "./run.sh"})
469 if err := os.WriteFile(filepath.Join(extDir, "extension.json"), mfb, 0o644); err != nil {
470 t.Fatal(err)
471 }
472
473 hooks := &stubHooks{}
474 mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-opus-4-7", hooks)
475 if errs := mgr.Discover(context.Background()); len(errs) > 0 {
476 t.Fatalf("discover errors: %v", errs)
477 }
478 defer mgr.Stop(2 * time.Second)
479
480 // Give the extension time to flush its open_panel frame.
481 deadline := time.Now().Add(2 * time.Second)
482 for time.Now().Before(deadline) {
483 hooks.mu.Lock()
484 n := len(hooks.panels)
485 hooks.mu.Unlock()
486 if n > 0 {
487 break
488 }
489 time.Sleep(20 * time.Millisecond)
490 }
491
492 hooks.mu.Lock()
493 defer hooks.mu.Unlock()
494
495 if len(hooks.panels) == 0 {
496 t.Fatal("hooks.OpenPanel was never called")
497 }
498 spec := hooks.panels[0]
499 if spec.ID != "test-panel" {
500 t.Errorf("panel id: want %q, got %q", "test-panel", spec.ID)
501 }
502 if spec.Title != "Hello Panel" {
503 t.Errorf("panel title: want %q, got %q", "Hello Panel", spec.Title)
504 }
505 if len(spec.Lines) != 2 || spec.Lines[0] != "line one" || spec.Lines[1] != "line two" {
506 t.Errorf("panel lines: want [line one line two], got %v", spec.Lines)
507 }
508 if spec.Footer != "esc close" {
509 t.Errorf("panel footer: want %q, got %q", "esc close", spec.Footer)
510 }
511 if hooks.panelExts[0] != "panel-mock" {
512 t.Errorf("ext name: want %q, got %q", "panel-mock", hooks.panelExts[0])
513 }
514}