v0.2.5 16b95cb

manager_test.go RawHistoryFolder

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close