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});