diff --git a/package.json b/package.json index 485e2640..a8c03220 100644 --- a/package.json +++ b/package.json @@ -62,8 +62,8 @@ "import-history": "node scripts/import-history.js", "reconcile-tokens": "node scripts/import-history.js --reconcile-tokens", "clear-data": "node scripts/clear-data.js", - "test": "node --test server/__tests__/*.test.js && cd client && npm test", - "test:server": "node --test server/__tests__/*.test.js", + "test": "node scripts/test-server.js && cd client && npm test", + "test:server": "node scripts/test-server.js", "test:client": "cd client && npm test", "format": "prettier --write .", "format:check": "prettier --check .", diff --git a/scripts/test-server.js b/scripts/test-server.js new file mode 100644 index 00000000..6496c97d --- /dev/null +++ b/scripts/test-server.js @@ -0,0 +1,42 @@ +#!/usr/bin/env node +/** + * @file test-server.js + * @description Runs the server test suite against a PRIVATE tmux server. + * Several suites start real `agenthub-lane-` tmux sessions and then kill + * or reset them, with lane ids from their own throwaway database — on the + * user's default tmux server those names collide with the dashboard's real + * lane panes, and a test run kills whichever live session shares an id + * (including the pane the test was launched from). Dropping `TMUX` and + * pointing `TMUX_TMPDIR` at a fresh directory gives every tmux call — the + * app's own and the tests' direct `execFileSync("tmux", …)` — its own socket. + * @author Nguyễn Ngọc Trí Vĩ + */ + +const { spawnSync } = require("node:child_process"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const root = path.join(__dirname, ".."); +const dir = path.join(root, "server", "__tests__"); +const files = fs + .readdirSync(dir) + .filter((f) => f.endsWith(".test.js")) + .map((f) => path.join(dir, f)); +const args = process.argv.slice(2); + +const tmuxDir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-test-tmux-")); +const env = { ...process.env, TMUX_TMPDIR: tmuxDir }; +delete env.TMUX; +delete env.TMUX_PANE; + +const result = spawnSync(process.execPath, ["--test", ...args, ...(args.length ? [] : files)], { + stdio: "inherit", + env, + cwd: root, +}); + +// Whatever a test leaked dies with the private server, never a real pane. +spawnSync("tmux", ["kill-server"], { env, stdio: "ignore" }); +fs.rmSync(tmuxDir, { recursive: true, force: true }); +process.exit(result.status ?? 1); diff --git a/server/__tests__/lane-lifecycle.test.js b/server/__tests__/lane-lifecycle.test.js index dc6996e9..d36c65ba 100644 --- a/server/__tests__/lane-lifecycle.test.js +++ b/server/__tests__/lane-lifecycle.test.js @@ -848,17 +848,12 @@ describe("destructive lane lifecycle actions", () => { const runId = started.body.lane.run_id; // Mock tmux so has-session always returns 0 (session exists), simulating a stuck session - tmux.__setExecImpl(async (cmd, args) => { - if (cmd === "tmux" && args[0] === "has-session" && args[1] === "-t" && args[2] === runId) { - return { code: 0, stdout: "", stderr: "" }; - } - if (cmd === "tmux" && args[0] === "list-sessions") { - return { code: 0, stdout: "", stderr: "" }; - } - if (cmd === "tmux" && args[0] === "kill-session" && args[1] === "-t" && args[2] === runId) { - return { code: 0, stdout: "", stderr: "" }; - } - return { code: 1, stdout: "", stderr: "" }; + // Same shape as tmux.js's own exec: sync, argv only, throws on failure. + tmux.__setExecImpl((args) => { + if (args[0] === "has-session" && args[2] === runId) return ""; + if (args[0] === "list-sessions") return `${runId}\n`; + if (args[0] === "kill-session" && args[2] === runId) return ""; + throw Object.assign(new Error("mocked tmux failure"), { status: 1 }); }); try {