main
1/**
2 * Tests for the Herdr extension.
3 *
4 * Run with: bun test herdr.test.ts
5 */
6
7import { describe, expect, test } from "bun:test";
8import {
9 buildArgs,
10 buildConfirmation,
11 currentPaneId,
12 errorFromFailure,
13 extractPaneId,
14 extractWorkspaceId,
15 formatAgents,
16 formatCreated,
17 formatPane,
18 formatWorkspaces,
19 isInsideHerdr,
20 isRawOutput,
21 isWriteAction,
22 parseAgents,
23 parseEnvelope,
24 parsePane,
25 parseResponse,
26 parseWorkspaces,
27 statusIcon,
28 truncate,
29} from "./utils";
30
31// Captured from the real CLI (`herdr agent list`).
32const AGENT_LIST = {
33 agents: [
34 {
35 agent: "pi",
36 agent_session: { agent: "pi", kind: "path", source: "herdr:pi", value: "/home/u/.pi/s.jsonl" },
37 agent_status: "working",
38 cwd: "/home/u/src/home",
39 focused: false,
40 foreground_cwd: "/home/u/src/home",
41 pane_id: "w2:p1",
42 tab_id: "w2:t1",
43 terminal_title: "π home",
44 terminal_title_stripped: "π home",
45 workspace_id: "w2",
46 },
47 {
48 agent: "pi",
49 agent_status: "idle",
50 cwd: "/home/u/desktop/org",
51 focused: true,
52 pane_id: "w8:p2",
53 tab_id: "w8:t2",
54 workspace_id: "w8",
55 },
56 ],
57 type: "agent_list",
58};
59
60const WORKSPACE_LIST = {
61 type: "workspace_list",
62 workspaces: [
63 { active_tab_id: "w2:t1", agent_status: "working", focused: false, label: "home", number: 1, pane_count: 3, tab_count: 3, workspace_id: "w2" },
64 { active_tab_id: "wA:t1", agent_status: "idle", focused: true, label: "community", number: 3, pane_count: 1, tab_count: 1, workspace_id: "wA" },
65 ],
66};
67
68// ============================================================================
69// Environment
70// ============================================================================
71
72describe("environment", () => {
73 test("isInsideHerdr only when HERDR_ENV=1", () => {
74 expect(isInsideHerdr({ HERDR_ENV: "1" } as any)).toBe(true);
75 expect(isInsideHerdr({ HERDR_ENV: "0" } as any)).toBe(false);
76 expect(isInsideHerdr({} as any)).toBe(false);
77 });
78
79 test("currentPaneId reads HERDR_PANE_ID", () => {
80 expect(currentPaneId({ HERDR_PANE_ID: "w2:p1" } as any)).toBe("w2:p1");
81 expect(currentPaneId({} as any)).toBeUndefined();
82 });
83});
84
85// ============================================================================
86// Envelope parsing
87// ============================================================================
88
89describe("parseEnvelope", () => {
90 test("unwraps result", () => {
91 const res = parseEnvelope(JSON.stringify({ id: "cli:agent:list", result: AGENT_LIST }));
92 expect(res.ok).toBe(true);
93 if (res.ok) expect(res.result.type).toBe("agent_list");
94 });
95
96 test("surfaces error message", () => {
97 const res = parseEnvelope(JSON.stringify({ id: "x", error: { code: 3, message: "pane not found" } }));
98 expect(res).toEqual({ ok: false, error: "pane not found" });
99 });
100
101 test("handles empty and non-JSON output", () => {
102 expect(parseEnvelope(" ").ok).toBe(false);
103 expect(parseEnvelope("herdr: command failed").ok).toBe(false);
104 });
105
106 test("handles result-less payloads", () => {
107 expect(parseEnvelope(JSON.stringify({ id: "x" })).ok).toBe(false);
108 });
109});
110
111describe("errorFromFailure", () => {
112 test("unwraps the error envelope herdr writes to stderr", () => {
113 const stderr = JSON.stringify({ error: { code: "pane_not_found", message: "pane zz:p9 not found" }, id: "cli:pane:read" });
114 expect(errorFromFailure(1, stderr)).toBe("pane zz:p9 not found");
115 });
116
117 test("falls back to raw stderr, then to the exit code", () => {
118 expect(errorFromFailure(127, "herdr: not found")).toBe("herdr: not found");
119 expect(errorFromFailure(2, " ")).toBe("herdr exited with 2");
120 });
121});
122
123describe("isRawOutput", () => {
124 test("only agent-read returns raw terminal text", () => {
125 expect(isRawOutput("agent-read")).toBe(true);
126 expect(isRawOutput("agent-list")).toBe(false);
127 expect(isRawOutput("pane-current")).toBe(false);
128 });
129});
130
131// ============================================================================
132// Result parsing
133// ============================================================================
134
135describe("parseAgents", () => {
136 test("maps snake_case fields", () => {
137 const [first, second] = parseAgents(AGENT_LIST);
138 expect(first).toEqual({
139 agent: "pi",
140 status: "working",
141 paneId: "w2:p1",
142 tabId: "w2:t1",
143 workspaceId: "w2",
144 cwd: "/home/u/src/home",
145 focused: false,
146 title: "π home",
147 sessionPath: "/home/u/.pi/s.jsonl",
148 });
149 expect(second.focused).toBe(true);
150 expect(second.sessionPath).toBe("");
151 expect(second.cwd).toBe("/home/u/desktop/org"); // falls back to cwd
152 });
153
154 test("returns [] on garbage", () => {
155 expect(parseAgents(undefined)).toEqual([]);
156 expect(parseAgents({ agents: "nope" })).toEqual([]);
157 });
158
159 test("normalizes unknown status", () => {
160 expect(parseAgents({ agents: [{ agent_status: "exploded" }] })[0].status).toBe("unknown");
161 });
162});
163
164describe("parsePane / parseWorkspaces", () => {
165 test("parsePane prefers foreground_cwd", () => {
166 const pane = parsePane({ pane_id: "w2:p1", cwd: "/a", foreground_cwd: "/b", agent_status: "idle", focused: true });
167 expect(pane.paneId).toBe("w2:p1");
168 expect(pane.cwd).toBe("/b");
169 expect(pane.focused).toBe(true);
170 });
171
172 test("parseWorkspaces maps counts", () => {
173 const workspaces = parseWorkspaces(WORKSPACE_LIST);
174 expect(workspaces).toHaveLength(2);
175 expect(workspaces[0]).toMatchObject({ workspaceId: "w2", label: "home", tabCount: 3, paneCount: 3 });
176 expect(workspaces[1].focused).toBe(true);
177 });
178
179 test("parseWorkspaces returns [] on garbage", () => {
180 expect(parseWorkspaces({})).toEqual([]);
181 });
182});
183
184describe("id extraction", () => {
185 // Captured from `herdr workspace create` / `tab create` / `pane split`.
186 const WORKSPACE_CREATED = {
187 type: "workspace_created",
188 root_pane: { pane_id: "wB:p1", tab_id: "wB:t1", workspace_id: "wB", cwd: "/tmp" },
189 tab: { tab_id: "wB:t1", label: "1", workspace_id: "wB" },
190 workspace: { workspace_id: "wB", label: "pi-ext-test", active_tab_id: "wB:t1" },
191 };
192 const TAB_CREATED = {
193 type: "tab_created",
194 root_pane: { pane_id: "wB:p2", tab_id: "wB:t2", workspace_id: "wB" },
195 tab: { tab_id: "wB:t2", label: "2", workspace_id: "wB" },
196 };
197 const PANE_SPLIT = {
198 type: "pane_info",
199 pane: { pane_id: "wB:p3", tab_id: "wB:t1", workspace_id: "wB" },
200 };
201
202 test("finds pane id in every creation shape", () => {
203 expect(extractPaneId(WORKSPACE_CREATED)).toBe("wB:p1");
204 expect(extractPaneId(TAB_CREATED)).toBe("wB:p2");
205 expect(extractPaneId(PANE_SPLIT)).toBe("wB:p3");
206 expect(extractPaneId({ pane_id: "w3:p2" })).toBe("w3:p2");
207 expect(extractPaneId({})).toBeUndefined();
208 });
209
210 test("finds workspace id", () => {
211 expect(extractWorkspaceId(WORKSPACE_CREATED)).toBe("wB");
212 expect(extractWorkspaceId(TAB_CREATED)).toBe("wB");
213 expect(extractWorkspaceId(PANE_SPLIT)).toBe("wB");
214 expect(extractWorkspaceId({})).toBeUndefined();
215 });
216
217 test("formatCreated summarises ids instead of dumping JSON", () => {
218 expect(formatCreated("workspace-create", WORKSPACE_CREATED)).toBe(
219 'workspace-create: ok, pane wB:p1, workspace wB, label "pi-ext-test"',
220 );
221 expect(formatCreated("pane-split", PANE_SPLIT)).toBe("pane-split: ok, pane wB:p3, workspace wB");
222 expect(formatCreated("pane-run", {})).toBe("pane-run: ok");
223 });
224});
225
226describe("parseResponse", () => {
227 test("non-zero exit reports the stderr envelope message", () => {
228 const stderr = JSON.stringify({ error: { code: "pane_not_found", message: "pane zz:p9 not found" } });
229 expect(parseResponse(1, "", stderr)).toEqual({ ok: false, error: "pane zz:p9 not found" });
230 });
231
232 test("silent success (e.g. pane run) is not an error", () => {
233 expect(parseResponse(0, "", "")).toEqual({ ok: true, result: {} });
234 });
235
236 test("raw mode returns terminal text untouched", () => {
237 expect(parseResponse(0, "line1\nline2\n", "", true)).toEqual({ ok: true, result: "line1\nline2\n" });
238 });
239
240 test("otherwise unwraps the envelope", () => {
241 const res = parseResponse(0, JSON.stringify({ id: "x", result: { type: "pane_info" } }), "");
242 expect(res.ok).toBe(true);
243 if (res.ok) expect(res.result.type).toBe("pane_info");
244 });
245});
246
247// ============================================================================
248// Argument building
249// ============================================================================
250
251describe("buildArgs: read actions", () => {
252 test("list actions", () => {
253 expect(buildArgs({ action: "agent-list" })).toEqual(["agent", "list"]);
254 expect(buildArgs({ action: "workspace-list" })).toEqual(["workspace", "list"]);
255 expect(buildArgs({ action: "pane-current" })).toEqual(["pane", "current"]);
256 });
257
258 test("agent-read defaults to 100 lines", () => {
259 expect(buildArgs({ action: "agent-read", target: "w2:p1" })).toEqual([
260 "pane", "read", "w2:p1", "--lines", "100",
261 ]);
262 expect(buildArgs({ action: "agent-read", target: "w2:p1", lines: 20, source: "visible" })).toEqual([
263 "pane", "read", "w2:p1", "--lines", "20", "--source", "visible",
264 ]);
265 });
266
267 test("agent-wait repeats --until", () => {
268 expect(buildArgs({ action: "agent-wait", target: "w2:p1", until: ["idle", "blocked"], timeout: 5000 })).toEqual([
269 "agent", "wait", "w2:p1", "--until", "idle", "--until", "blocked", "--timeout", "5000",
270 ]);
271 });
272});
273
274describe("buildArgs: write actions", () => {
275 test("agent-prompt without wait", () => {
276 expect(buildArgs({ action: "agent-prompt", target: "w2:p1", text: "run the tests" })).toEqual([
277 "agent", "prompt", "w2:p1", "run the tests",
278 ]);
279 });
280
281 test("agent-prompt adds --wait when until/timeout given", () => {
282 expect(buildArgs({ action: "agent-prompt", target: "w2:p1", text: "go", until: ["idle"] })).toEqual([
283 "agent", "prompt", "w2:p1", "go", "--wait", "--until", "idle",
284 ]);
285 expect(buildArgs({ action: "agent-prompt", target: "w2:p1", text: "go", timeout: 1000 })).toEqual([
286 "agent", "prompt", "w2:p1", "go", "--wait", "--timeout", "1000",
287 ]);
288 });
289
290 test("workspace-create with label, cwd, env and focus", () => {
291 expect(buildArgs({ action: "workspace-create", cwd: "/src/x", label: "x", env: ["A=1", "B=2"], focus: true })).toEqual([
292 "workspace", "create", "--cwd", "/src/x", "--label", "x", "--env", "A=1", "--env", "B=2", "--focus",
293 ]);
294 });
295
296 test("focus:false becomes --no-focus", () => {
297 expect(buildArgs({ action: "tab-create", workspace: "w2", focus: false })).toEqual([
298 "tab", "create", "--workspace", "w2", "--no-focus",
299 ]);
300 });
301
302 test("pane-split defaults to current pane and right", () => {
303 expect(buildArgs({ action: "pane-split" })).toEqual(["pane", "split", "--current", "--direction", "right"]);
304 expect(buildArgs({ action: "pane-split", target: "w2:p1", direction: "down" })).toEqual([
305 "pane", "split", "w2:p1", "--direction", "down",
306 ]);
307 });
308
309 test("pane-run passes command verbatim", () => {
310 expect(buildArgs({ action: "pane-run", target: "w2:p3", command: ["make", "test"] })).toEqual([
311 "pane", "run", "w2:p3", "make", "test",
312 ]);
313 });
314
315 test("agent-start uses name or kind", () => {
316 expect(buildArgs({ action: "agent-start", kind: "pi", target: "w2:p3" })).toEqual([
317 "agent", "start", "pi", "--kind", "pi", "--pane", "w2:p3",
318 ]);
319 expect(buildArgs({ action: "agent-start", kind: "claude", name: "reviewer", target: "w2:p3", timeout: 60000 })).toEqual([
320 "agent", "start", "reviewer", "--kind", "claude", "--pane", "w2:p3", "--timeout", "60000",
321 ]);
322 });
323
324 test("worktree-create requests JSON", () => {
325 expect(buildArgs({ action: "worktree-create", branch: "feat/x", base: "main", cwd: "/src/home" })).toEqual([
326 "worktree", "create", "--json", "--branch", "feat/x", "--base", "main", "--cwd", "/src/home",
327 ]);
328 });
329
330 test("notify", () => {
331 expect(buildArgs({ action: "notify", text: "Done", body: "tests passed" })).toEqual([
332 "notification", "show", "Done", "--body", "tests passed",
333 ]);
334 });
335});
336
337describe("buildArgs: validation", () => {
338 test("missing required params throw with actionable message", () => {
339 expect(() => buildArgs({ action: "agent-read" })).toThrow('agent-read requires "target"');
340 expect(() => buildArgs({ action: "agent-prompt", target: "w2:p1" })).toThrow('requires "text"');
341 expect(() => buildArgs({ action: "pane-run", target: "w2:p1" })).toThrow('requires "command"');
342 expect(() => buildArgs({ action: "pane-run", target: "w2:p1", command: [] })).toThrow('requires "command"');
343 expect(() => buildArgs({ action: "agent-start", target: "w2:p1" })).toThrow('requires "kind"');
344 expect(() => buildArgs({ action: "agent-start", kind: "pi" })).toThrow('requires "target"');
345 expect(() => buildArgs({ action: "worktree-create" })).toThrow('requires "branch"');
346 expect(() => buildArgs({ action: "notify" })).toThrow('requires "text"');
347 expect(() => buildArgs({ action: "bogus" } as any)).toThrow("unknown action");
348 });
349});
350
351// ============================================================================
352// Approval classification
353// ============================================================================
354
355describe("isWriteAction", () => {
356 test("mutating actions require approval", () => {
357 for (const action of ["agent-prompt", "workspace-create", "tab-create", "pane-split", "pane-run", "agent-start", "worktree-create"] as const) {
358 expect(isWriteAction(action)).toBe(true);
359 }
360 });
361
362 test("read-only actions do not", () => {
363 for (const action of ["agent-list", "agent-read", "pane-current", "workspace-list", "agent-wait", "notify"] as const) {
364 expect(isWriteAction(action)).toBe(false);
365 }
366 });
367
368 test("confirmations mention the target", () => {
369 expect(buildConfirmation({ action: "agent-prompt", target: "w2:p1", text: "hi" })).toContain("w2:p1");
370 expect(buildConfirmation({ action: "pane-run", target: "w2:p3", command: ["make", "test"] })).toContain("make test");
371 expect(buildConfirmation({ action: "worktree-create", branch: "feat/x" })).toContain("feat/x");
372 });
373});
374
375// ============================================================================
376// Formatting
377// ============================================================================
378
379describe("formatting", () => {
380 test("truncate", () => {
381 expect(truncate("abc", 10)).toBe("abc");
382 expect(truncate("abcdefghij", 6)).toBe("abc...");
383 });
384
385 test("statusIcon covers every state", () => {
386 expect(statusIcon("working")).toBe("⏳");
387 expect(statusIcon("blocked")).toBe("⚠");
388 expect(statusIcon("idle")).toBe("●");
389 expect(statusIcon("done")).toBe("✓");
390 expect(statusIcon("unknown")).toBe("·");
391 });
392
393 test("formatAgents renders a table and marks the focused agent", () => {
394 const out = formatAgents(parseAgents(AGENT_LIST));
395 expect(out).toContain("w2:p1");
396 expect(out).toContain("working");
397 expect(out.split("\n")).toHaveLength(4); // header + separator + 2 rows
398 expect(out).toContain("| → | w8:p2");
399 });
400
401 test("empty lists produce readable text", () => {
402 expect(formatAgents([])).toBe("No agents running in Herdr.");
403 expect(formatWorkspaces([])).toBe("No workspaces.");
404 });
405
406 test("formatWorkspaces lists ids and labels", () => {
407 const out = formatWorkspaces(parseWorkspaces(WORKSPACE_LIST));
408 expect(out).toContain("home");
409 expect(out).toContain("community");
410 });
411
412 test("formatPane omits missing agent info", () => {
413 const out = formatPane(parsePane({ pane_id: "w2:p2", cwd: "/tmp", agent_status: "unknown" }));
414 expect(out).toContain("pane: w2:p2");
415 expect(out).not.toContain("agent:");
416 });
417});