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