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