8546748759
Dependencies
- Security: body-parser and multer DoS advisories fixed (npm audit: 0).
- Server: Express 5 (query parser kept "extended", req.body defaults to {},
SPA catch-all /{*splat}, send-file helper restores Express 4's dot-folder
rule for sendFile/download, alerts ack route without inline regex),
better-sqlite3 13, js-yaml 5 (server/lib/yaml.js keeps js-yaml 4's default
schema), uuid dropped for crypto.randomUUID, node-pty beta.18, multer 2.5.
- Client: React 19, xterm 6 (WebGL renderer replaces the removed canvas one,
DOM fallback on context loss; Alt+arrow word moves re-mapped), lucide-react 1,
TypeScript 7, i18next/react-i18next/codemirror minors.
Workspace rename step 4
- `agenthub lanes` and `--lane` exit 1 with "renamed: use …"; client api.ts,
the CLI and the MCP server call /api/workspaces; /api/lanes stays mounted.
Server mode (S3): members manage their own Claude Code config
- server/lib/cc-target.js resolves whose config a /cc-config request touches
and gates the router by allowlist; /profiles* and /plugins/update-all stay
admin-only. Member writes go through write-as, CLI calls through sudo as the
member (never -y), reads through openSafe; cc_config_changed reaches only
its owner. Not verified live on an S3 install yet.
Other
- Chat: an HTML file is an artifact only when written whole and not a project
page (index.html, or under src/, public/, templates/, …).
- OpenAPI covers every /api route; openapi-coverage.test.js guards it.
- read-pool test keeps its event loop alive (its worker is unref'd).
- Docs: plan statuses, checklists, CLI.md, LANES.md, SERVER-MODE.md, API.md.
197 lines
7.8 KiB
JavaScript
197 lines
7.8 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(), []);
|
|
});
|
|
|
|
it("keeps the host's panes and one member's apart (S3)", (t) => {
|
|
stubTmux(["agenthub-lane-1", "agenthub-lane-2", "agenthub-lane-3"]);
|
|
const owners = { "agenthub-lane-2": { name: "mia" }, "agenthub-lane-3": { name: "bob" } };
|
|
t.mock.method(require("../lib/run-as"), "accountForSession", (id) => owners[id] || null);
|
|
const ids = (list) => list.map((l) => l.id);
|
|
// The host's reload never types into a member's pane…
|
|
assert.deepEqual(ids(reloadableLanes()), ["agenthub-lane-1"]);
|
|
// …and a member's reaches their own panes only.
|
|
assert.deepEqual(ids(reloadableLanes({ name: "mia" })), ["agenthub-lane-2"]);
|
|
});
|
|
});
|
|
|
|
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, []);
|
|
});
|
|
});
|