/** * @file Unit tests for typing `/reload-plugins` / `/reload-skills` into the * live Claude Code lane panes. Uses tmux.js's exec seam, so no real tmux binary * is invoked and no real session is ever typed into. * * The behaviours worth locking down are the ones that would silently do the * wrong thing: sending to a pane sitting at a shell prompt (the slash command * would run as a shell command instead), forwarding a caller-supplied string * into a REPL, and letting one dead pane fail the whole batch. The `tool === * "claude"` filter is not covered here — it reads a dashboard-runs DB row, * which these stubs do not populate. * @author Nguyễn Ngọc Trí Vĩ */ const { describe, it, beforeEach, after } = require("node:test"); const assert = require("node:assert/strict"); const path = require("node:path"); const fs = require("node:fs"); const os = require("node:os"); const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "cc-reload-test-")); process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db"); const tmux = require("../lib/tmux"); const { reloadOpenSessions, reloadableLanes } = require("../lib/cc-reload"); after(() => { try { fs.rmSync(TMP, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } catch { /* best effort */ } }); /** * Stub tmux so `listRuns()` sees `sessions` and each pane reports the command * given in `panes` (defaulting to a live `claude`). * @returns {any[][]} the recorded argv arrays */ function stubTmux(sessions, panes = {}) { const calls = []; tmux.__setExecImpl((args) => { calls.push(args); if (args[0] === "list-sessions") return sessions.join("\n"); if (args[0] === "has-session") { if (sessions.includes(args[2].slice(1))) return ""; const e = new Error("no such session"); e.status = 1; throw e; } if (args[0] === "display-message") { const name = args[3].slice(1, -1); const cmd = panes[name] ?? "claude"; if (cmd === null) { const e = new Error("pane gone"); e.status = 1; throw e; } return `${cmd}\n`; } if (args[0] === "send-keys") { if (panes[args[2].slice(1, -1)] === "__send-fails__") throw new Error("pane died mid-send"); return ""; } return ""; }); return calls; } describe("reloadableLanes", () => { beforeEach(() => tmux.__reset()); it("lists a lane whose pane is running claude", () => { stubTmux(["agenthub-lane-1"]); const lanes = reloadableLanes(); assert.equal(lanes.length, 1); assert.equal(lanes[0].id, "agenthub-lane-1"); assert.equal(lanes[0].laneId, 1); }); it("skips a pane sitting at a shell prompt", () => { // The claude process there has exited, so `/reload-plugins` would be typed // at a bash prompt and run as a shell command instead. stubTmux(["agenthub-lane-1"], { "agenthub-lane-1": "bash" }); assert.deepEqual(reloadableLanes(), []); }); it("skips every shell name, not just bash", () => { stubTmux(["agenthub-lane-1", "agenthub-lane-2", "agenthub-lane-3"], { "agenthub-lane-1": "zsh", "agenthub-lane-2": "fish", "agenthub-lane-3": "dash", }); assert.deepEqual(reloadableLanes(), []); }); it("returns nothing when no lane sessions exist at all", () => { stubTmux([]); assert.deepEqual(reloadableLanes(), []); }); }); describe("reloadOpenSessions", () => { beforeEach(() => tmux.__reset()); it("types both reload commands, plugins before skills", async () => { // Order matters: a skill shipped BY a plugin only exists on disk once the // plugin itself has been reloaded. const calls = stubTmux(["agenthub-lane-7"]); const out = await reloadOpenSessions({ settleMs: 0 }); assert.deepEqual(out.commands, ["/reload-plugins", "/reload-skills"]); assert.equal(out.lanes_total, 1); assert.deepEqual(out.lanes[0].sent, ["/reload-plugins", "/reload-skills"]); const typed = calls.filter((c) => c[0] === "send-keys" && c[3] === "-l").map((c) => c[4]); assert.deepEqual(typed, ["/reload-plugins", "/reload-skills"]); // Each command must be submitted, or it just sits in the input box. assert.equal(calls.filter((c) => c[0] === "send-keys" && c[3] === "Enter").length, 2); }); it("waits between the two commands so they cannot share one composer line", async () => { // Without the pause the first Enter lands on Claude Code's slash-command // menu, the second command is appended to the line still in the composer, // and the pane submits `/reload-plugins/reload-skills` as a prompt — the // reported bug. Timing is the behaviour, so timing is what is asserted. const calls = stubTmux(["agenthub-lane-1"]); const at = []; const typed = () => calls.filter((c) => c[0] === "send-keys" && c[3] === "-l").length; const before = typed(); const started = Date.now(); const done = reloadOpenSessions({ settleMs: 40 }); at.push(typed() - before); await done; assert.equal(at[0], 1, "only the first command is typed before the wait"); assert.ok(Date.now() - started >= 35, "the second command waited for the first to submit"); }); it("honours a narrowed kinds list", async () => { const calls = stubTmux(["agenthub-lane-1"]); const out = await reloadOpenSessions({ kinds: ["skills"], settleMs: 0 }); assert.deepEqual(out.commands, ["/reload-skills"]); const typed = calls.filter((c) => c[0] === "send-keys" && c[3] === "-l").map((c) => c[4]); assert.deepEqual(typed, ["/reload-skills"]); }); it("ignores an unknown kind rather than typing it into a REPL", async () => { // The command set is fixed in the module; a pane's stdin is not a place to // forward caller-supplied strings. const calls = stubTmux(["agenthub-lane-1"]); const out = await reloadOpenSessions({ kinds: ["plugins", "rm -rf /"], settleMs: 0 }); assert.deepEqual(out.commands, ["/reload-plugins"]); const typed = calls.filter((c) => c[0] === "send-keys" && c[3] === "-l").map((c) => c[4]); assert.deepEqual(typed, ["/reload-plugins"]); }); it("types into several lanes in one call", async () => { stubTmux(["agenthub-lane-1", "agenthub-lane-2"]); const out = await reloadOpenSessions({ settleMs: 0 }); assert.equal(out.lanes_total, 2); assert.deepEqual( out.lanes.map((l) => l.laneId).sort((a, b) => a - b), [1, 2] ); }); it("records a pane that dies mid-send without failing the others", async () => { stubTmux(["agenthub-lane-1", "agenthub-lane-2"], { "agenthub-lane-1": "__send-fails__" }); const out = await reloadOpenSessions({ settleMs: 0 }); const failed = out.lanes.find((l) => l.id === "agenthub-lane-1"); const ok = out.lanes.find((l) => l.id === "agenthub-lane-2"); assert.match(failed.error, /died mid-send/); assert.deepEqual(failed.sent, [], "nothing was delivered to the dead pane"); assert.deepEqual(ok.sent, ["/reload-plugins", "/reload-skills"]); assert.equal(ok.error, undefined); }); it("reports an empty result instead of throwing when nothing is open", async () => { stubTmux([]); const out = await reloadOpenSessions({ settleMs: 0 }); assert.equal(out.lanes_total, 0); assert.deepEqual(out.lanes, []); }); });