/** * @file tmux.test.js * @description Unit tests for the tmux command wrapper. Injects a fake exec * implementation so the suite never shells out to a real `tmux` binary (CI * has none installed). * @author Nguyễn Ngọc Trí Vĩ */ const { describe, it, beforeEach } = require("node:test"); const assert = require("node:assert/strict"); const tmux = require("../lib/tmux"); describe("tmux wrapper", () => { beforeEach(() => { tmux.__reset(); }); it("hasSession returns true when execFileSync exits 0", () => { tmux.__setExecImpl(() => ""); assert.equal(tmux.hasSession("agenthub-lane-1"), true); }); it("hasSession returns false when execFileSync throws", () => { tmux.__setExecImpl(() => { const e = new Error("no such session"); e.status = 1; throw e; }); assert.equal(tmux.hasSession("agenthub-lane-1"), false); }); it("liveSessions answers every live name with ONE list-sessions call", () => { const calls = []; tmux.__setExecImpl((args) => { calls.push(args[0]); return "agenthub-lane-1\nagenthub-lane-12\nmain\n"; }); assert.deepEqual([...tmux.liveSessions()], ["agenthub-lane-1", "agenthub-lane-12", "main"]); assert.deepEqual(calls, ["list-sessions"]); }); it("reads psmux's list-sessions, which ignores -F, as bare names (Windows)", () => { // psmux prints its default line whatever `-F` asks for; read as names, // every pane looked dead and the heal beat closed its run within seconds. tmux.__setExecImpl((args) => args[0] === "list-sessions" ? "agenthub-lane-2-5: 1 windows (created Sat Oct 10 11:44:39 2026)\r\n" + "agenthub-lane-2: 1 windows (created Sat Oct 10 10:43:16 2026)\r\n" : "" ); assert.deepEqual([...tmux.liveSessions()], ["agenthub-lane-2-5", "agenthub-lane-2"]); assert.deepEqual(tmux.listSessions("agenthub-lane-"), ["agenthub-lane-2-5", "agenthub-lane-2"]); }); it("liveSessions is empty when tmux answers that no server runs (or is not installed)", () => { tmux.__setExecImpl(() => { throw Object.assign(new Error("no server running"), { status: 1 }); }); assert.equal(tmux.liveSessions().size, 0); tmux.__setExecImpl(() => { throw Object.assign(new Error("spawn tmux ENOENT"), { code: "ENOENT" }); }); assert.equal(tmux.liveSessions().size, 0); }); it("liveSessions is null when tmux could not be asked — not 'every pane is gone'", () => { // Out of memory/processes: the panes are fine, the question failed. tmux.__setExecImpl(() => { throw Object.assign(new Error("spawnSync tmux EAGAIN"), { code: "EAGAIN" }); }); assert.equal(tmux.liveSessions(), null); }); describe( "the tmux server outlives the dashboard (Linux)", { skip: process.platform !== "linux" }, () => { // Fake tmux: no server until a new-session, then every call succeeds. const fake = (calls, { running = false } = {}) => (args) => { calls.push(["tmux", ...args].join(" ")); if (args[0] === "display-message" && !running) throw Object.assign(new Error("no server"), { status: 1 }); if (args[0] === "has-session" && !running) throw Object.assign(new Error("no session"), { status: 1 }); return args[0] === "display-message" ? "4242\n" : ""; }; it("starts the FIRST session in its own systemd scope, not the dashboard's cgroup", () => { const calls = []; tmux.__setExecImpl(fake(calls)); tmux.__setScopeImpl((args) => calls.push(["systemd-run", "tmux", ...args].join(" "))); tmux.newSession({ name: "agenthub-lane-1", cwd: "/w", argv: ["claude"], env: {} }); assert.ok( calls.includes("systemd-run tmux new-session -d -s agenthub-lane-1 -c /w -- claude") ); assert.ok(!calls.some((c) => c.startsWith("tmux new-session"))); }); it("adds a session to a running server directly", () => { const calls = []; tmux.__setExecImpl(fake(calls, { running: true })); tmux.__setScopeImpl(() => assert.fail("a running server needs no scope")); tmux.newSession({ name: "agenthub-lane-1", cwd: "/w", argv: ["claude"], env: {} }); assert.ok(calls.includes("tmux new-session -d -s agenthub-lane-1 -c /w -- claude")); }); it("starts it plainly when no systemd user manager answers", () => { const calls = []; tmux.__setExecImpl(fake(calls)); tmux.__setScopeImpl(() => { throw Object.assign(new Error("Failed to connect to bus"), { status: 1 }); }); tmux.newSession({ name: "agenthub-lane-1", cwd: "/w", argv: ["claude"], env: {} }); assert.ok(calls.includes("tmux new-session -d -s agenthub-lane-1 -c /w -- claude")); }); } ); it("newSession names a command that exited at once instead of tmux's set-option error", () => { tmux.__setExecImpl((args) => { if (args[0] === "new-session") return ""; throw Object.assign(new Error("Command failed: tmux " + args.join(" ")), { status: 1 }); }); assert.throws( () => tmux.newSession({ name: "agenthub-lane-4", cwd: "/tmp/repo", argv: ["claude", "-p", "x"] }), (err) => err.code === "EPANEEXITED" && /exited as soon as it started: claude$/.test(err.message) ); }); it("newSession with no argv leaves tmux its default shell (no trailing --)", () => { const calls = []; tmux.__setExecImpl((args) => { calls.push(args); return ""; }); tmux.newSession({ name: "agenthub-lane-1", cwd: "C:\\repo", argv: [] }); assert.deepEqual(calls[0], ["new-session", "-d", "-s", "agenthub-lane-1", "-c", "C:\\repo"]); }); it("names sessions bare on psmux, which has no `=name` targets, and keeps `=` on tmux", () => { // psmux (the WinGet `tmux` on Windows) answers "no server running on // session '=agenthub-lane-1'" for a session that is alive, so every // `=` target read a live pane as gone. const fake = (supportsExact) => { const calls = []; tmux.__setExecImpl( (args) => { calls.push(args); if (args[0] === "list-sessions") return "agenthub-lane-1\n"; const t = args[args.indexOf("-t") + 1]; if (t && t.startsWith("=") && !supportsExact) throw new Error("no server running"); return ""; }, { exact: null } ); return calls; }; let calls = fake(false); assert.equal(tmux.hasSession("agenthub-lane-1"), true); tmux.enableMouse("agenthub-lane-1"); assert.ok(calls.some((c) => c[0] === "set-option" && c[2] === "agenthub-lane-1")); assert.ok(!calls.some((c) => c[0] === "set-option" && c[2].startsWith("="))); calls = fake(true); tmux.enableMouse("agenthub-lane-1"); assert.ok(calls.some((c) => c[0] === "set-option" && c[2] === "=agenthub-lane-1:")); // tmux chains the four options into two spawns; psmux above got four. const sets = calls.filter((c) => c[0] === "set-option"); assert.equal(sets.length, 2); assert.deepEqual(sets[0], [ "set-option", "-t", "=agenthub-lane-1:", "mouse", "on", ";", "set-option", "-t", "=agenthub-lane-1:", "status", "off", ]); }); it("newSession builds the correct argv", () => { const calls = []; tmux.__setExecImpl((args) => { calls.push(args); return ""; }); tmux.newSession({ name: "agenthub-lane-1", cwd: "/tmp/repo", argv: ["claude", "--model", "opus"], }); assert.deepEqual(calls[0], [ "new-session", "-d", "-s", "agenthub-lane-1", "-c", "/tmp/repo", "--", "claude", "--model", "opus", ]); // One tmux call per `;`-chained group: the options every tmux knows, then // the 3.7+ scrollbar pair (allowed to fail on its own). assert.deepEqual(calls[1], [ ...["set-option", "-t", "=agenthub-lane-1:", "mouse", "on", ";"], ...["set-option", "-t", "=agenthub-lane-1:", "status", "off"], ]); assert.deepEqual(calls[2], [ ...["set-option", "-t", "=agenthub-lane-1:", "pane-scrollbars", "on", ";"], ...["set-option", "-t", "=agenthub-lane-1:", "pane-scrollbars-style"], "fg=brightwhite,bg=black,width=1,pad=0", ]); assert.equal(calls.length, 3); }); it("newSession still succeeds on a tmux older than 3.7 (no pane-scrollbars)", () => { tmux.__setExecImpl((args) => { if (args.includes("pane-scrollbars")) throw new Error("invalid option: pane-scrollbars"); return ""; }); assert.doesNotThrow(() => tmux.newSession({ name: "agenthub-lane-1", cwd: "/tmp/repo", argv: ["claude"] }) ); }); it("sendText types the literal string and presses Enter, with no quoting", () => { const calls = []; tmux.__setExecImpl((args) => { calls.push(args); return ""; }); tmux.sendText("agenthub-lane-1", 'Reminder: run "agenthub stage implement"'); assert.deepEqual(calls[0], [ "send-keys", "-t", "=agenthub-lane-1:", "-l", 'Reminder: run "agenthub stage implement"', ]); assert.deepEqual(calls[1], ["send-keys", "-t", "=agenthub-lane-1:", "Enter"]); }); it("targets a session exactly, so lane 1 never resolves to lane 12 by prefix", () => { const calls = []; tmux.__setExecImpl((args) => { calls.push(args); return ""; }); tmux.hasSession("agenthub-lane-1"); tmux.killSession("agenthub-lane-1"); assert.deepEqual(calls, [ ["has-session", "-t", "=agenthub-lane-1"], ["kill-session", "-t", "=agenthub-lane-1"], ]); }); it("killSession never throws when the session is already gone", () => { tmux.__setExecImpl(() => { const e = new Error("no such session"); e.status = 1; throw e; }); assert.doesNotThrow(() => tmux.killSession("agenthub-lane-1")); }); it("listSessions filters by prefix and ignores unrelated sessions", () => { tmux.__setExecImpl(() => "agenthub-lane-1\nagenthub-lane-2\nsome-other-session\n"); assert.deepEqual(tmux.listSessions("agenthub-lane-"), ["agenthub-lane-1", "agenthub-lane-2"]); }); it("shares one list-sessions answer for a second, until this process creates or kills one", () => { const calls = []; let names = "agenthub-lane-1\n"; tmux.__setExecImpl((args) => { calls.push(args[0]); return args[0] === "list-sessions" ? names : ""; }); assert.deepEqual(tmux.listSessions("agenthub-lane-"), ["agenthub-lane-1"]); assert.deepEqual([...tmux.liveSessions()], ["agenthub-lane-1"]); assert.equal(calls.filter((c) => c === "list-sessions").length, 1); tmux.killSession("agenthub-lane-1"); names = ""; assert.deepEqual(tmux.listSessions("agenthub-lane-"), []); assert.equal(calls.filter((c) => c === "list-sessions").length, 2); }); it("listSessions returns [] when tmux has no sessions at all (exit 1)", () => { tmux.__setExecImpl(() => { const e = new Error("no server running"); e.status = 1; throw e; }); assert.deepEqual(tmux.listSessions("agenthub-lane-"), []); }); it("isTmuxAvailable reflects whether the binary resolves on PATH", () => { tmux.__setExecImpl(() => "tmux 3.4"); assert.equal(tmux.isTmuxAvailable(), true); tmux.__setExecImpl(() => { throw new Error("ENOENT"); }); assert.equal(tmux.isTmuxAvailable(), false); }); });