Files
nntrivi2001 99a2a8277c fix(tmux,run,hooks): the four pre-existing bugs the #37 evaluation found
- tmux targets are exact (`=name`, `=name:` for panes/options): a bare
  `-t agenthub-lane-1` resolves by prefix to agenthub-lane-12 once lane 1
  is gone, so killing a finished lane could kill another.
- getRun/killRun refuse ids that are not `agenthub-lane-<n>`, so
  DELETE /api/run/main can no longer kill a user's own tmux session.
- router/apply reports restart_required only for a running pane; getRun
  returns a `gone` handle, so it was always true.
- needs_action is cleared only by the session that raised it (new nullable
  lanes.needs_action_session); a second session in the same cwd used to
  clear it on its second hook.
2026-09-25 11:30:02 +07:00

186 lines
7.2 KiB
JavaScript

/**
* @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ĩ <vinnt@smartgift.vn>
*/
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, []);
});
});