From 1dd18fe98c9c8c623bb49edbefddad23d661bf99 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Wed, 12 Aug 2026 09:34:18 +0700 Subject: [PATCH] feat(run): add tmux command wrapper with an injectable exec seam --- server/__tests__/tmux.test.js | 83 +++++++++++++++++++++++++++++++++++ server/lib/tmux.js | 82 ++++++++++++++++++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 server/__tests__/tmux.test.js create mode 100644 server/lib/tmux.js diff --git a/server/__tests__/tmux.test.js b/server/__tests__/tmux.test.js new file mode 100644 index 0000000..951fc23 --- /dev/null +++ b/server/__tests__/tmux.test.js @@ -0,0 +1,83 @@ +/** + * @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("ccam-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("ccam-lane-1"), false); + }); + + it("newSession builds the correct argv", () => { + const calls = []; + tmux.__setExecImpl((args) => { + calls.push(args); + return ""; + }); + tmux.newSession({ name: "ccam-lane-1", cwd: "/tmp/repo", argv: ["claude", "--model", "opus"] }); + assert.deepEqual(calls[0], [ + "new-session", + "-d", + "-s", + "ccam-lane-1", + "-c", + "/tmp/repo", + "--", + "claude", + "--model", + "opus", + ]); + }); + + 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("ccam-lane-1")); + }); + + it("listSessions filters by prefix and ignores unrelated sessions", () => { + tmux.__setExecImpl(() => "ccam-lane-1\nccam-lane-2\nsome-other-session\n"); + assert.deepEqual(tmux.listSessions("ccam-lane-"), ["ccam-lane-1", "ccam-lane-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("ccam-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); + }); +}); diff --git a/server/lib/tmux.js b/server/lib/tmux.js new file mode 100644 index 0000000..26e91fb --- /dev/null +++ b/server/lib/tmux.js @@ -0,0 +1,82 @@ +/** + * @file tmux.js + * @description Thin wrapper around the `tmux` CLI for the terminal-run + * feature. Every dashboard-managed session is named `ccam-lane-` (see + * `pty-run.js`) so a real terminal can attach to the exact same session with + * `tmux attach -t ccam-lane-` (or `ccam lanes shell`). Never builds a + * shell string — every call is `execFileSync("tmux", [...argv])` with an + * explicit argument array (matches this repo's rule for git in worktree.js). + * @author Nguyễn Ngọc Trí Vĩ + */ + +const { execFileSync } = require("node:child_process"); + +// Test seam: swap the exec implementation so unit tests never invoke a real +// tmux binary. Mirrors run-spawner.js's __injectChildForTest/__reset style. +let execImpl = (args) => execFileSync("tmux", args, { encoding: "utf8" }); + +function __setExecImpl(fn) { + execImpl = fn; +} +function __reset() { + execImpl = (args) => execFileSync("tmux", args, { encoding: "utf8" }); +} + +function hasSession(name) { + try { + execImpl(["has-session", "-t", name]); + return true; + } catch { + return false; + } +} + +/** + * Create a detached tmux session running `argv` as the pane's command. Throws + * if tmux itself fails to start (caller decides how to surface that). + */ +function newSession({ name, cwd, argv }) { + execImpl(["new-session", "-d", "-s", name, "-c", cwd, "--", ...argv]); +} + +/** Idempotent — a session that's already gone is not an error. */ +function killSession(name) { + try { + execImpl(["kill-session", "-t", name]); + } catch { + /* already gone */ + } +} + +/** Session names starting with `prefix`. Empty array if tmux has no server running at all. */ +function listSessions(prefix) { + let out; + try { + out = execImpl(["list-sessions", "-F", "#{session_name}"]); + } catch { + return []; + } + return out + .split("\n") + .map((s) => s.trim()) + .filter((s) => s && s.startsWith(prefix)); +} + +function isTmuxAvailable() { + try { + execImpl(["-V"]); + return true; + } catch { + return false; + } +} + +module.exports = { + hasSession, + newSession, + killSession, + listSessions, + isTmuxAvailable, + __setExecImpl, + __reset, +};