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 "sync"
12 "testing"
13 "time"
14)
15
16// stubHooks records every callback so the test can assert on them.
17type stubHooks struct {
18 mu sync.Mutex
19 notifies []string
20 displays []string
21}
22
23func (s *stubHooks) Notify(name, level, message string) {
24 s.mu.Lock()
25 defer s.mu.Unlock()
26 s.notifies = append(s.notifies, name+":"+level+":"+message)
27}
28func (s *stubHooks) Submit(string) {}
29func (s *stubHooks) SubmitSlash(string) {}
30func (s *stubHooks) Insert(string) {}
31func (s *stubHooks) Display(name, text string) {
32 s.mu.Lock()
33 defer s.mu.Unlock()
34 s.displays = append(s.displays, name+":"+text)
35}
36func (s *stubHooks) OpenPanel(string, extproto.PanelSpec) {}
37func (s *stubHooks) UpdatePanel(string, string, string, []string, string) {}
38func (s *stubHooks) ClosePanel(string, string) {}
39
40// writeMockExtension creates a minimal extension on disk that uses a
41// shell script (or batch file on windows) to drive the protocol. The
42// script reads commands from stdin and emits hard-coded responses,
43// exercising the manager's spawn/handshake/dispatch path without
44// needing the SDK.
45func writeMockExtension(t *testing.T, root string) {
46 t.Helper()
47 if runtime.GOOS == "windows" {
48 t.Skip("mock extension uses /bin/sh; skip on windows")
49 }
50
51 dir := filepath.Join(root, "mock")
52 if err := os.MkdirAll(dir, 0o755); err != nil {
53 t.Fatal(err)
54 }
55
56 // Shell script: emit hello, read frames, respond. Reads until
57 // stdin closes; tail's -F keeps the pipe alive long enough for
58 // the manager to send command_invoked.
59 script := `#!/bin/sh
60printf '%s\n' '{"type":"hello","name":"mock","version":"0.0.1","capabilities":["commands"]}'
61printf '%s\n' '{"type":"register_command","name":"ping","description":"ping/pong"}'
62while IFS= read -r line; do
63 case "$line" in
64 *'"type":"command_invoked"'*)
65 id=$(printf '%s' "$line" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p')
66 printf '%s\n' "{\"type\":\"command_response\",\"id\":\"$id\",\"action\":\"display\",\"display\":\"pong\"}"
67 ;;
68 *'"type":"shutdown"'*)
69 printf '%s\n' '{"type":"shutdown_ack"}'
70 exit 0
71 ;;
72 esac
73done
74`
75 scriptPath := filepath.Join(dir, "run.sh")
76 if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
77 t.Fatal(err)
78 }
79
80 manifest := map[string]any{
81 "name": "mock",
82 "version": "0.0.1",
83 "exec": "./run.sh",
84 }
85 mfb, _ := json.Marshal(manifest)
86 if err := os.WriteFile(filepath.Join(dir, "extension.json"), mfb, 0o644); err != nil {
87 t.Fatal(err)
88 }
89}
90
91func TestManagerSpawnAndInvoke(t *testing.T) {
92 tmp := t.TempDir()
93 extRoot := filepath.Join(tmp, "extensions")
94 if err := os.MkdirAll(extRoot, 0o755); err != nil {
95 t.Fatal(err)
96 }
97 writeMockExtension(t, extRoot)
98
99 hooks := &stubHooks{}
100 mgr := New(tmp, "", "0.0.0-test", "anthropic", "claude-opus-4-7", hooks)
101
102 if errs := mgr.Discover(context.Background()); len(errs) > 0 {
103 t.Fatalf("discover errors: %v", errs)
104 }
105 defer mgr.Stop(2 * time.Second)
106
107 // Give the extension a beat to send register_command frames after
108 // the hello handshake.
109 time.Sleep(150 * time.Millisecond)
110
111 cmds := mgr.Commands()
112 if len(cmds) != 1 || cmds[0].Name != "ping" {
113 t.Fatalf("expected one command 'ping', got %#v", cmds)
114 }
115 if !mgr.HasCommand("ping") {
116 t.Fatal("HasCommand(\"ping\") = false")
117 }
118
119 resp, err := mgr.Invoke(context.Background(), "ping", "", 2*time.Second)
120 if err != nil {
121 t.Fatalf("invoke: %v", err)
122 }
123 if resp.Action != "display" {
124 t.Errorf("expected action=display, got %q", resp.Action)
125 }
126 if resp.Display != "pong" {
127 t.Errorf("expected display=pong, got %q", resp.Display)
128 }
129}