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 TestDiscoverReportsSignalWhenExtensionExitsBeforeHello(t *testing.T) {
164 if runtime.GOOS == "windows" {
165 t.Skip("mock extension uses Unix signals; skip on windows")
166 }
167
168 tmp := t.TempDir()
169 extDir := filepath.Join(tmp, "extensions", "signaled")
170 if err := os.MkdirAll(extDir, 0o755); err != nil {
171 t.Fatal(err)
172 }
173 script := "#!/bin/sh\nkill -TERM $$\n"
174 if err := os.WriteFile(filepath.Join(extDir, "run.sh"), []byte(script), 0o755); err != nil {
175 t.Fatal(err)
176 }
177 if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(`{"name":"signaled","exec":"./run.sh"}`), 0o644); err != nil {
178 t.Fatal(err)
179 }
180
181 mgr := New(tmp, "", "0.0.0-test", "", "", nil)
182 errs := mgr.Discover(context.Background())
183 if len(errs) != 1 {
184 t.Fatalf("discover errors = %v, want one", errs)
185 }
186 got := errs[0].Error()
187 if !strings.Contains(got, "extension exited before hello: signal:") || !strings.Contains(got, "ext-signaled.log") {
188 t.Fatalf("error does not report signal and identify stderr log: %s", got)
189 }
190}
191
192func TestSpawnCleansUpProcessAfterInvalidHello(t *testing.T) {
193 if runtime.GOOS == "windows" {
194 t.Skip("mock extension uses /bin/sh; skip on windows")
195 }
196
197 tmp := t.TempDir()
198 extDir := filepath.Join(tmp, "broken")
199 if err := os.MkdirAll(extDir, 0o755); err != nil {
200 t.Fatal(err)
201 }
202 scriptPath := filepath.Join(extDir, "run.sh")
203 script := "#!/bin/sh\nprintf '%s\\n' 'not-json'\nwhile IFS= read -r line; do :; done\n"
204 if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
205 t.Fatal(err)
206 }
207
208 ext := &Extension{Manifest: Manifest{Name: "broken", Exec: "./run.sh"}, Dir: extDir}
209 mgr := New(tmp, "", "0.0.0-test", "", "", nil)
210 err := mgr.spawn(context.Background(), ext)
211 if err == nil || !strings.Contains(err.Error(), "parse hello") {
212 t.Fatalf("spawn error = %v, want parse hello failure", err)
213 }
214 if ext.cmd == nil || ext.cmd.ProcessState == nil {
215 t.Fatal("failed handshake process was not reaped")
216 }
217 if _, err := ext.logFile.WriteString("after cleanup"); err == nil {
218 t.Fatal("failed handshake log file was not closed")
219 }
220}
221
222func TestPlanSkillSourcesFollowsSymlinkedExtension(t *testing.T) {
223 if runtime.GOOS == "windows" {
224 t.Skip("directory symlinks require elevated privileges on Windows")
225 }
226
227 tmp := t.TempDir()
228 extRoot := filepath.Join(tmp, "extensions")
229 target := filepath.Join(tmp, "target")
230 if err := os.MkdirAll(filepath.Join(target, "skills", "review"), 0o755); err != nil {
231 t.Fatal(err)
232 }
233 if err := os.WriteFile(filepath.Join(target, "extension.json"), []byte(`{"name":"linked","skills":["./skills"]}`), 0o644); err != nil {
234 t.Fatal(err)
235 }
236 if err := os.WriteFile(filepath.Join(target, "skills", "review", "SKILL.md"), []byte("---\ndescription: linked\n---\nbody"), 0o644); err != nil {
237 t.Fatal(err)
238 }
239 if err := os.MkdirAll(extRoot, 0o755); err != nil {
240 t.Fatal(err)
241 }
242 if err := os.Symlink(target, filepath.Join(extRoot, "linked")); err != nil {
243 t.Fatal(err)
244 }
245
246 sources, errs := PlanSkillSources(tmp, "", nil, true)
247 if len(errs) != 0 || len(sources) != 1 {
248 t.Fatalf("sources=%#v errs=%v", sources, errs)
249 }
250 got, errs := skills.DiscoverSources(sources, false)
251 if len(errs) != 0 || len(got) != 1 || got[0].Name != "linked:review" {
252 t.Fatalf("skills=%#v errs=%v", got, errs)
253 }
254}
255
256func TestPlanSkillSources(t *testing.T) {
257 tmp := t.TempDir()
258 extDir := filepath.Join(tmp, "bundle")
259 skillDir := filepath.Join(extDir, "skills", "Writing Git Commits")
260 if err := os.MkdirAll(skillDir, 0o755); err != nil {
261 t.Fatal(err)
262 }
263 if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\ndescription: commits\n---\nbody"), 0o644); err != nil {
264 t.Fatal(err)
265 }
266 manifest := `{"name":"Git Tools","skills":["./skills"],"enabled":true}`
267 if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(manifest), 0o644); err != nil {
268 t.Fatal(err)
269 }
270 sources, errs := PlanSkillSources("", "", []string{extDir}, false)
271 if len(errs) != 0 || len(sources) != 1 {
272 t.Fatalf("sources=%#v errs=%v", sources, errs)
273 }
274 got, errs := skills.DiscoverSources(sources, false)
275 if len(errs) != 0 || len(got) != 1 || got[0].Name != "git-tools:writing-git-commits" {
276 t.Fatalf("skills=%#v errs=%v", got, errs)
277 }
278}
279
280func TestPlanSkillSourcesExplicitBundleNamedExtensions(t *testing.T) {
281 tmp := t.TempDir()
282 extDir := filepath.Join(tmp, "extensions")
283 skillDir := filepath.Join(extDir, "skills", "review")
284 if err := os.MkdirAll(skillDir, 0o755); err != nil {
285 t.Fatal(err)
286 }
287 if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(`{"name":"named-extensions","skills":["./skills"]}`), 0o644); err != nil {
288 t.Fatal(err)
289 }
290 if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\ndescription: review\n---\nbody"), 0o644); err != nil {
291 t.Fatal(err)
292 }
293
294 sources, errs := PlanSkillSources("", "", []string{extDir}, false)
295 if len(errs) != 0 || len(sources) != 1 {
296 t.Fatalf("sources=%#v errs=%v", sources, errs)
297 }
298}
299
300func TestPlanSkillSourcesRejectsTraversal(t *testing.T) {
301 tmp := t.TempDir()
302 extDir := filepath.Join(tmp, "bundle")
303 if err := os.MkdirAll(extDir, 0o755); err != nil {
304 t.Fatal(err)
305 }
306 if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(`{"name":"bad","skills":["../outside"]}`), 0o644); err != nil {
307 t.Fatal(err)
308 }
309 _, errs := PlanSkillSources("", "", []string{extDir}, false)
310 if len(errs) == 0 || !strings.Contains(errs[0].Error(), "escapes") {
311 t.Fatalf("errs=%v", errs)
312 }
313}
314
315func TestDiscoverLoadsThemeOnlyExtension(t *testing.T) {
316 tmp := t.TempDir()
317 extDir := filepath.Join(tmp, "extensions", "theme-only")
318 if err := os.MkdirAll(extDir, 0o755); err != nil {
319 t.Fatal(err)
320 }
321 manifest := `{"name":"theme-only","version":"1.0.0","description":"theme only"}`
322 if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(manifest), 0o644); err != nil {
323 t.Fatal(err)
324 }
325 theme := `{"name":"Theme Only","description":"theme from extension","colors":{"dark":{"accent":204}}}`
326 if err := os.WriteFile(filepath.Join(extDir, "theme.json"), []byte(theme), 0o644); err != nil {
327 t.Fatal(err)
328 }
329
330 mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-opus-4-7", nil)
331 if errs := mgr.Discover(context.Background()); len(errs) > 0 {
332 t.Fatalf("discover errors: %v", errs)
333 }
334 defer mgr.Stop(10 * time.Millisecond)
335
336 opts := mgr.ThemeOptions()
337 if len(opts) != 1 {
338 t.Fatalf("theme options = %d, want 1", len(opts))
339 }
340 if opts[0].Label != "Theme Only" || opts[0].Path != filepath.Join(extDir, "theme.json") {
341 t.Fatalf("unexpected theme option: %#v", opts[0])
342 }
343 if !strings.Contains(opts[0].Description, "from extension theme-only") {
344 t.Fatalf("description missing extension source: %q", opts[0].Description)
345 }
346}
347
348func TestManagerSpawnAndInvoke(t *testing.T) {
349 tmp := t.TempDir()
350 extRoot := filepath.Join(tmp, "extensions")
351 if err := os.MkdirAll(extRoot, 0o755); err != nil {
352 t.Fatal(err)
353 }
354 writeMockExtension(t, extRoot)
355
356 hooks := &stubHooks{}
357 mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-opus-4-7", hooks)
358
359 if errs := mgr.Discover(context.Background()); len(errs) > 0 {
360 t.Fatalf("discover errors: %v", errs)
361 }
362 defer mgr.Stop(2 * time.Second)
363
364 // Give the extension a beat to send register_command frames after
365 // the hello handshake.
366 time.Sleep(150 * time.Millisecond)
367
368 cmds := mgr.Commands()
369 found := false
370 for _, c := range cmds {
371 if c.Name == "Ping" {
372 found = true
373 break
374 }
375 }
376 if !found {
377 t.Fatalf("expected canonical command 'Ping', got %#v", cmds)
378 }
379 if !mgr.HasCommand("PING") {
380 t.Fatal("HasCommand(\"PING\") = false")
381 }
382 if owner := mgr.CommandOwner("pInG"); owner != "mock" {
383 t.Fatalf("CommandOwner(\"pInG\") = %q, want mock", owner)
384 }
385
386 resp, err := mgr.Invoke(context.Background(), "PING", "", 2*time.Second)
387 if err != nil {
388 t.Fatalf("invoke: %v", err)
389 }
390 if resp.Action != "display" {
391 t.Errorf("expected action=display, got %q", resp.Action)
392 }
393 if resp.Display != "pong" {
394 t.Errorf("expected display=pong, got %q", resp.Display)
395 }
396}
397
398func TestDiagnosticsReportMalformedFramesAndConflicts(t *testing.T) {
399 if runtime.GOOS == "windows" {
400 t.Skip("mock extension uses /bin/sh; skip on windows")
401 }
402
403 tmp := t.TempDir()
404 extRoot := filepath.Join(tmp, "extensions")
405 writeDiagExtension := func(name, script string) {
406 t.Helper()
407 dir := filepath.Join(extRoot, name)
408 if err := os.MkdirAll(dir, 0o755); err != nil {
409 t.Fatal(err)
410 }
411 if err := os.WriteFile(filepath.Join(dir, "run.sh"), []byte(script), 0o755); err != nil {
412 t.Fatal(err)
413 }
414 mfb, _ := json.Marshal(map[string]any{"name": name, "exec": "./run.sh"})
415 if err := os.WriteFile(filepath.Join(dir, "extension.json"), mfb, 0o644); err != nil {
416 t.Fatal(err)
417 }
418 }
419
420 writeDiagExtension("a-first", `#!/bin/sh
421printf '%s\n' '{"type":"hello","name":"a-first","version":"0.1","capabilities":["commands","tools"]}'
422printf '%s\n' '{"type":"register_command","name":"CaseTest","description":"first"}'
423printf '%s\n' '{"type":"register_tool","name":"shared","description":"first","schema":{"type":"object"}}'
424printf '%s\n' '{"type":"register_tool","name":"broken","description":"bad","schema":'
425printf '%s\n' '{"type":"ready"}'
426while IFS= read -r line; do
427 case "$line" in
428 *'"type":"shutdown"'*) exit 0 ;;
429 esac
430done
431`)
432 writeDiagExtension("b-second", `#!/bin/sh
433printf '%s\n' '{"type":"hello","name":"b-second","version":"0.1","capabilities":["commands","tools"]}'
434printf '%s\n' '{"type":"register_command","name":"casetest","description":"second"}'
435printf '%s\n' '{"type":"register_tool","name":"shared","description":"second","schema":{"type":"object"}}'
436printf '%s\n' '{"type":"ready"}'
437while IFS= read -r line; do
438 case "$line" in
439 *'"type":"shutdown"'*) exit 0 ;;
440 esac
441done
442`)
443
444 mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-opus-4-7", &stubHooks{})
445 if errs := mgr.Discover(context.Background()); len(errs) > 0 {
446 t.Fatalf("discover errors: %v", errs)
447 }
448 defer mgr.Stop(2 * time.Second)
449 mgr.WaitForReady(2 * time.Second)
450
451 diags := mgr.Diagnostics()
452 byName := map[string]ExtensionDiagnostic{}
453 for _, d := range diags {
454 byName[d.Name] = d
455 }
456
457 first := byName["a-first"]
458 if len(first.Messages) == 0 || !strings.Contains(strings.Join(first.Messages, "\n"), "malformed json frame") {
459 t.Fatalf("expected malformed-frame diagnostic, got %#v", first.Messages)
460 }
461
462 var shadowedTool bool
463 var activeCommands, shadowedCommands int
464 var conflictMessage bool
465 for _, d := range diags {
466 if strings.Contains(strings.Join(d.Messages, "\n"), "conflicts with another extension") {
467 conflictMessage = true
468 }
469 for _, tool := range d.Tools {
470 if tool.Name == "shared" && !tool.Active {
471 shadowedTool = true
472 }
473 }
474 for _, command := range d.Commands {
475 if !strings.EqualFold(command.Name, "casetest") {
476 continue
477 }
478 if command.Active {
479 activeCommands++
480 } else {
481 shadowedCommands++
482 }
483 }
484 }
485 if !shadowedTool {
486 t.Fatalf("expected one shared tool registration to be inactive, got %#v", diags)
487 }
488 if activeCommands != 1 || shadowedCommands != 1 {
489 t.Fatalf("case-only command registrations: active=%d shadowed=%d; diagnostics=%#v", activeCommands, shadowedCommands, diags)
490 }
491 if !conflictMessage {
492 t.Fatalf("expected conflict diagnostic, got %#v", diags)
493 }
494}
495
496// TestSpontaneousSubmit verifies that an extension can submit a
497// non-empty prompt outside of any command response.
498func TestSpontaneousSubmit(t *testing.T) {
499 if runtime.GOOS == "windows" {
500 t.Skip("mock extension uses /bin/sh; skip on windows")
501 }
502
503 tmp := t.TempDir()
504 extDir := filepath.Join(tmp, "extensions", "submit-mock")
505 if err := os.MkdirAll(extDir, 0o755); err != nil {
506 t.Fatal(err)
507 }
508
509 script := `#!/bin/sh
510printf '%s\n' '{"type":"hello","name":"submit-mock","version":"0.1","capabilities":["submit"]}'
511printf '%s\n' '{"type":"ready"}'
512printf '%s\n' '{"type":"submit","text":" explain this repository briefly "}'
513printf '%s\n' '{"type":"submit","text":" "}'
514while IFS= read -r line; do
515 case "$line" in
516 *'"type":"shutdown"'*)
517 printf '%s\n' '{"type":"shutdown_ack"}'
518 exit 0
519 ;;
520 esac
521done
522`
523 if err := os.WriteFile(filepath.Join(extDir, "run.sh"), []byte(script), 0o755); err != nil {
524 t.Fatal(err)
525 }
526 mfb, _ := json.Marshal(map[string]any{"name": "submit-mock", "exec": "./run.sh"})
527 if err := os.WriteFile(filepath.Join(extDir, "extension.json"), mfb, 0o644); err != nil {
528 t.Fatal(err)
529 }
530
531 hooks := &stubHooks{}
532 mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-opus-4-7", hooks)
533 if errs := mgr.Discover(context.Background()); len(errs) > 0 {
534 t.Fatalf("discover errors: %v", errs)
535 }
536 defer mgr.Stop(2 * time.Second)
537
538 deadline := time.Now().Add(2 * time.Second)
539 for time.Now().Before(deadline) {
540 hooks.mu.Lock()
541 n := len(hooks.submits)
542 hooks.mu.Unlock()
543 if n > 0 {
544 break
545 }
546 time.Sleep(20 * time.Millisecond)
547 }
548
549 hooks.mu.Lock()
550 defer hooks.mu.Unlock()
551 if len(hooks.submits) != 1 {
552 t.Fatalf("Submit calls = %#v, want one non-empty call", hooks.submits)
553 }
554 if hooks.submits[0] != "explain this repository briefly" {
555 t.Fatalf("Submit text = %q", hooks.submits[0])
556 }
557}
558
559// TestSpontaneousOpenPanel verifies that an extension sending an
560// open_panel frame outside of any command response causes the manager
561// to call hooks.OpenPanel with the correct PanelSpec fields.
562func TestSpontaneousOpenPanel(t *testing.T) {
563 if runtime.GOOS == "windows" {
564 t.Skip("mock extension uses /bin/sh; skip on windows")
565 }
566
567 tmp := t.TempDir()
568 extDir := filepath.Join(tmp, "extensions", "panel-mock")
569 if err := os.MkdirAll(extDir, 0o755); err != nil {
570 t.Fatal(err)
571 }
572
573 // Extension emits hello + ready, then immediately fires a
574 // spontaneous open_panel, then waits for shutdown.
575 script := `#!/bin/sh
576printf '%s\n' '{"type":"hello","name":"panel-mock","version":"0.1","capabilities":["panels"]}'
577printf '%s\n' '{"type":"ready"}'
578printf '%s\n' '{"type":"open_panel","panel":{"id":"test-panel","title":"Hello Panel","lines":["line one","line two"],"footer":"esc close"}}'
579while IFS= read -r line; do
580 case "$line" in
581 *'"type":"shutdown"'*)
582 printf '%s\n' '{"type":"shutdown_ack"}'
583 exit 0
584 ;;
585 esac
586done
587`
588 if err := os.WriteFile(filepath.Join(extDir, "run.sh"), []byte(script), 0o755); err != nil {
589 t.Fatal(err)
590 }
591 mfb, _ := json.Marshal(map[string]any{"name": "panel-mock", "exec": "./run.sh"})
592 if err := os.WriteFile(filepath.Join(extDir, "extension.json"), mfb, 0o644); err != nil {
593 t.Fatal(err)
594 }
595
596 hooks := &stubHooks{}
597 mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-opus-4-7", hooks)
598 if errs := mgr.Discover(context.Background()); len(errs) > 0 {
599 t.Fatalf("discover errors: %v", errs)
600 }
601 defer mgr.Stop(2 * time.Second)
602
603 // Give the extension time to flush its open_panel frame.
604 deadline := time.Now().Add(2 * time.Second)
605 for time.Now().Before(deadline) {
606 hooks.mu.Lock()
607 n := len(hooks.panels)
608 hooks.mu.Unlock()
609 if n > 0 {
610 break
611 }
612 time.Sleep(20 * time.Millisecond)
613 }
614
615 hooks.mu.Lock()
616 defer hooks.mu.Unlock()
617
618 if len(hooks.panels) == 0 {
619 t.Fatal("hooks.OpenPanel was never called")
620 }
621 spec := hooks.panels[0]
622 if spec.ID != "test-panel" {
623 t.Errorf("panel id: want %q, got %q", "test-panel", spec.ID)
624 }
625 if spec.Title != "Hello Panel" {
626 t.Errorf("panel title: want %q, got %q", "Hello Panel", spec.Title)
627 }
628 if len(spec.Lines) != 2 || spec.Lines[0] != "line one" || spec.Lines[1] != "line two" {
629 t.Errorf("panel lines: want [line one line two], got %v", spec.Lines)
630 }
631 if spec.Footer != "esc close" {
632 t.Errorf("panel footer: want %q, got %q", "esc close", spec.Footer)
633 }
634 if hooks.panelExts[0] != "panel-mock" {
635 t.Errorf("ext name: want %q, got %q", "panel-mock", hooks.panelExts[0])
636 }
637}