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}