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}
24
25func (s *stubHooks) Notify(name, level, message string) {
26 s.mu.Lock()
27 defer s.mu.Unlock()
28 s.notifies = append(s.notifies, name+":"+level+":"+message)
29}
30func (s *stubHooks) Submit(string) {}
31func (s *stubHooks) SubmitSlash(string) {}
32func (s *stubHooks) Insert(string) {}
33func (s *stubHooks) Display(name, text string) {
34 s.mu.Lock()
35 defer s.mu.Unlock()
36 s.displays = append(s.displays, name+":"+text)
37}
38func (s *stubHooks) ClearNotes(name string) {
39 s.mu.Lock()
40 defer s.mu.Unlock()
41 s.clearNotes = append(s.clearNotes, name)
42}
43func (s *stubHooks) OpenPanel(string, extproto.PanelSpec) {}
44func (s *stubHooks) UpdatePanel(string, string, string, []string, string) {}
45func (s *stubHooks) ClosePanel(string, string) {}
46
47// writeMockExtension creates a minimal extension on disk that uses a
48// shell script (or batch file on windows) to drive the protocol. The
49// script reads commands from stdin and emits hard-coded responses,
50// exercising the manager's spawn/handshake/dispatch path without
51// needing the SDK.
52func writeMockExtension(t *testing.T, root string) {
53 t.Helper()
54 if runtime.GOOS == "windows" {
55 t.Skip("mock extension uses /bin/sh; skip on windows")
56 }
57
58 dir := filepath.Join(root, "mock")
59 if err := os.MkdirAll(dir, 0o755); err != nil {
60 t.Fatal(err)
61 }
62
63 // Shell script: emit hello, read frames, respond. Reads until
64 // stdin closes; tail's -F keeps the pipe alive long enough for
65 // the manager to send command_invoked.
66 script := `#!/bin/sh
67printf '%s\n' '{"type":"hello","name":"mock","version":"0.0.1","capabilities":["commands"]}'
68printf '%s\n' '{"type":"register_command","name":"ping","description":"ping/pong"}'
69while IFS= read -r line; do
70 case "$line" in
71 *'"type":"command_invoked"'*)
72 id=$(printf '%s' "$line" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p')
73 printf '%s\n' "{\"type\":\"command_response\",\"id\":\"$id\",\"action\":\"display\",\"display\":\"pong\"}"
74 ;;
75 *'"type":"shutdown"'*)
76 printf '%s\n' '{"type":"shutdown_ack"}'
77 exit 0
78 ;;
79 esac
80done
81`
82 scriptPath := filepath.Join(dir, "run.sh")
83 if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
84 t.Fatal(err)
85 }
86
87 manifest := map[string]any{
88 "name": "mock",
89 "version": "0.0.1",
90 "exec": "./run.sh",
91 }
92 mfb, _ := json.Marshal(manifest)
93 if err := os.WriteFile(filepath.Join(dir, "extension.json"), mfb, 0o644); err != nil {
94 t.Fatal(err)
95 }
96}
97
98func TestDiscoverLoadsThemeOnlyExtension(t *testing.T) {
99 tmp := t.TempDir()
100 extDir := filepath.Join(tmp, "extensions", "theme-only")
101 if err := os.MkdirAll(extDir, 0o755); err != nil {
102 t.Fatal(err)
103 }
104 manifest := `{"name":"theme-only","version":"1.0.0","description":"theme only"}`
105 if err := os.WriteFile(filepath.Join(extDir, "extension.json"), []byte(manifest), 0o644); err != nil {
106 t.Fatal(err)
107 }
108 theme := `{"name":"Theme Only","description":"theme from extension","colors":{"dark":{"accent":204}}}`
109 if err := os.WriteFile(filepath.Join(extDir, "theme.json"), []byte(theme), 0o644); err != nil {
110 t.Fatal(err)
111 }
112
113 mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-opus-4-7", nil)
114 if errs := mgr.Discover(context.Background()); len(errs) > 0 {
115 t.Fatalf("discover errors: %v", errs)
116 }
117 defer mgr.Stop(10 * time.Millisecond)
118
119 opts := mgr.ThemeOptions()
120 if len(opts) != 1 {
121 t.Fatalf("theme options = %d, want 1", len(opts))
122 }
123 if opts[0].Label != "Theme Only" || opts[0].Path != filepath.Join(extDir, "theme.json") {
124 t.Fatalf("unexpected theme option: %#v", opts[0])
125 }
126 if !strings.Contains(opts[0].Description, "from extension theme-only") {
127 t.Fatalf("description missing extension source: %q", opts[0].Description)
128 }
129}
130
131func TestManagerSpawnAndInvoke(t *testing.T) {
132 tmp := t.TempDir()
133 extRoot := filepath.Join(tmp, "extensions")
134 if err := os.MkdirAll(extRoot, 0o755); err != nil {
135 t.Fatal(err)
136 }
137 writeMockExtension(t, extRoot)
138
139 hooks := &stubHooks{}
140 mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-opus-4-7", hooks)
141
142 if errs := mgr.Discover(context.Background()); len(errs) > 0 {
143 t.Fatalf("discover errors: %v", errs)
144 }
145 defer mgr.Stop(2 * time.Second)
146
147 // Give the extension a beat to send register_command frames after
148 // the hello handshake.
149 time.Sleep(150 * time.Millisecond)
150
151 cmds := mgr.Commands()
152 if len(cmds) != 1 || cmds[0].Name != "ping" {
153 t.Fatalf("expected one command 'ping', got %#v", cmds)
154 }
155 if !mgr.HasCommand("ping") {
156 t.Fatal("HasCommand(\"ping\") = false")
157 }
158
159 resp, err := mgr.Invoke(context.Background(), "ping", "", 2*time.Second)
160 if err != nil {
161 t.Fatalf("invoke: %v", err)
162 }
163 if resp.Action != "display" {
164 t.Errorf("expected action=display, got %q", resp.Action)
165 }
166 if resp.Display != "pong" {
167 t.Errorf("expected display=pong, got %q", resp.Display)
168 }
169}