import { test, after } from "node:test"; import assert from "node:assert/strict"; import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, rmSync, chmodSync, symlinkSync, statSync, existsSync, } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve, dirname, basename } from "node:path"; import { spawnSync } from "node:child_process"; import { SeatError, loadDataRoot, seatsDir, registrationPath, resolveSeat, tmuxContext, validateRegistration, makeRegistration, writeRegistration, readRegistration, updateTask, findRegistrations, samePath, TASK_LIMIT, } from "../src/seat.mjs"; const pkgRoot = resolve(import.meta.dirname, ".."); const cli = join(pkgRoot, "src", "cli.mjs"); // Track every tmpdir so a stray failure never leaves fixtures behind. const roots = []; function makeRoot() { const root = mkdtempSync(join(tmpdir(), "seat-test-")); roots.push(root); return root; } after(() => { for (const root of roots) rmSync(root, { recursive: true, force: true }); }); function writeFile(path, content) { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, content); } function writeExecutable(path, content) { writeFile(path, content); chmodSync(path, 0o755); } const FAKE_LAUNCH_SH = [ "#!/usr/bin/env bash", 'printf \'%s\\n\' "$@" > "$(dirname "$0")/args.txt"', 'printf \'%s\\n\' "${MOSAIC_LAUNCH_REGISTERED:-unset}" > "$(dirname "$0")/env.txt"', 'exit "${FAKE_EXIT:-0}"', "", ].join("\n"); // Repo layout fixture: /.git, /agents//launch.sh. function buildRepoSeat(root, seat, { launchContent = FAKE_LAUNCH_SH, executable = true } = {}) { mkdirSync(join(root, ".git"), { recursive: true }); const seatDir = join(root, "agents", seat); const launchScript = join(seatDir, "launch.sh"); if (executable) writeExecutable(launchScript, launchContent); else writeFile(launchScript, launchContent); return seatDir; } // Fleet layout fixture: /fleet/agents//{launch.sh,.pi/}, no /.git. function buildFleetSeat(root, seat) { const seatDir = join(root, "fleet", "agents", seat); writeExecutable(join(seatDir, "launch.sh"), FAKE_LAUNCH_SH); mkdirSync(join(seatDir, ".pi"), { recursive: true }); return seatDir; } function buildConfig(root, dataRoot = join(root, "data")) { const configPath = join(root, "config.json"); writeFile(configPath, JSON.stringify({ configVersion: 1, dataRoot })); return { configPath, dataRoot }; } function cliEnv(overrides = {}) { const env = { ...process.env, TMUX: "", TMUX_PANE: "" }; delete env.MOSAIC_LAUNCH_REGISTERED; return { ...env, ...overrides }; } function runCli(args, { cwd, env = cliEnv() } = {}) { return spawnSync(process.execPath, [cli, ...args], { cwd, encoding: "utf8", env, timeout: 15000 }); } function baseResolved(seatDir, seat = "myseat") { return { seat, seatDir, launchScript: join(seatDir, "launch.sh"), layout: "repo", project: "someproject", sessionsDir: join(seatDir, "sessions"), defaultWorkspace: dirname(dirname(seatDir)), }; } // --------------------------------------------------------------------------- // 1-3. resolveSeat // --------------------------------------------------------------------------- test("resolveSeat: by name under --repo resolves the repo layout", () => { const root = makeRoot(); const seatDir = buildRepoSeat(root, "myseat"); const resolved = resolveSeat("myseat", { repo: root }); assert.equal(resolved.seat, "myseat"); assert.equal(resolved.seatDir, seatDir); assert.equal(resolved.layout, "repo"); assert.equal(resolved.project, basename(root)); assert.equal(resolved.sessionsDir, join(root, ".pi", "state", "myseat", "sessions")); assert.equal(resolved.defaultWorkspace, root); assert.equal(resolved.launchScript, join(seatDir, "launch.sh")); }); test("resolveSeat: by path resolves the fleet layout", () => { const root = makeRoot(); const seatDir = buildFleetSeat(root, "myseat"); const resolved = resolveSeat(seatDir); assert.equal(resolved.seat, "myseat"); assert.equal(resolved.layout, "fleet"); assert.equal(resolved.project, null); assert.equal(resolved.sessionsDir, join(seatDir, ".pi", "agent", "sessions")); assert.equal(resolved.defaultWorkspace, null); }); test("resolveSeat: refusals for missing dir, missing launch.sh, non-executable launch.sh, invalid name, and unknown layout", () => { const root = makeRoot(); // Missing seat directory entirely. assert.throws(() => resolveSeat(join(root, "agents", "ghost")), (err) => { assert.ok(err instanceof SeatError); assert.equal(err.exitCode, 2); return true; }); // Seat directory exists but has no launch.sh. const noLaunchDir = join(root, "agents", "nolaunch"); mkdirSync(noLaunchDir, { recursive: true }); assert.throws(() => resolveSeat(noLaunchDir), (err) => { assert.ok(err instanceof SeatError); assert.equal(err.exitCode, 2); return true; }); // launch.sh present but not executable. const notExecDir = join(root, "agents", "notexec"); writeFile(join(notExecDir, "launch.sh"), FAKE_LAUNCH_SH); chmodSync(join(notExecDir, "launch.sh"), 0o644); assert.throws(() => resolveSeat(notExecDir), (err) => { assert.ok(err instanceof SeatError); assert.equal(err.exitCode, 2); return true; }); // Invalid seat name. assert.throws(() => resolveSeat("Bad Name"), (err) => { assert.ok(err instanceof SeatError); assert.equal(err.exitCode, 4); return true; }); // Parent not named "agents", no .git, no .pi: layout "unknown". const looseDir = join(root, "loose", "myseat"); writeExecutable(join(looseDir, "launch.sh"), FAKE_LAUNCH_SH); const resolved = resolveSeat(looseDir); assert.equal(resolved.layout, "unknown"); assert.equal(resolved.project, null); assert.equal(resolved.sessionsDir, null); assert.equal(resolved.defaultWorkspace, null); }); // --------------------------------------------------------------------------- // 4. tmuxContext // --------------------------------------------------------------------------- test("tmuxContext: outside tmux, default socket, custom socket, and exec failure", () => { // No TMUX var at all. assert.equal(tmuxContext({ env: {} }), null); // Empty TMUX (matches TMUX: "" in CLI fixtures). assert.equal(tmuxContext({ env: { TMUX: "" } }), null); // Default socket, with TMUX_PANE set: session name comes from exec stdout. let captured = null; const okExec = (cmd, args) => { captured = { cmd, args }; return { status: 0, stdout: "sess\n" }; }; const result = tmuxContext({ env: { TMUX: "/tmp/tmux-1000/default,123,0", TMUX_PANE: "%3" }, exec: okExec, }); assert.deepEqual(result, { socket: null, session: "sess" }); assert.equal(captured.cmd, "tmux"); assert.deepEqual(captured.args.slice(0, 4), ["-S", "/tmp/tmux-1000/default", "display-message", "-p"]); assert.ok(captured.args.includes("-t")); assert.equal(captured.args[captured.args.indexOf("-t") + 1], "%3"); // Non-default socket name is surfaced. const namedSocket = tmuxContext({ env: { TMUX: "/tmp/tmux-1000/mosaic-fleet,1,0" }, exec: () => ({ status: 0, stdout: "other\n" }), }); assert.deepEqual(namedSocket, { socket: "mosaic-fleet", session: "other" }); // Non-zero exit and exec error both yield null. assert.equal(tmuxContext({ env: { TMUX: "/tmp/tmux-1000/default,1,0" }, exec: () => ({ status: 1, stdout: "" }) }), null); assert.equal( tmuxContext({ env: { TMUX: "/tmp/tmux-1000/default,1,0" }, exec: () => ({ error: new Error("no tmux"), status: null }) }), null ); }); // --------------------------------------------------------------------------- // 5. makeRegistration + validateRegistration // --------------------------------------------------------------------------- test("makeRegistration produces a record that validates; each shape violation throws SeatError", () => { const root = makeRoot(); const seatDir = buildRepoSeat(root, "myseat"); const resolved = resolveSeat("myseat", { repo: root }); const record = makeRegistration({ resolved, task: "do things", pid: 1234 }); assert.deepEqual(validateRegistration(record), record); const mutate = (patch) => ({ ...record, ...patch }); assert.throws(() => validateRegistration(mutate({ bogusField: "x" })), SeatError); assert.throws(() => validateRegistration(mutate({ version: 2 })), SeatError); assert.throws(() => validateRegistration(mutate({ task: "x".repeat(TASK_LIMIT + 1) })), SeatError); assert.throws(() => validateRegistration(mutate({ pid: 1.5 })), SeatError); assert.throws(() => validateRegistration(mutate({ pid: "1234" })), SeatError); assert.throws(() => validateRegistration(mutate({ tmux: { socket: 5, session: "s" } })), SeatError); assert.throws(() => validateRegistration(mutate({ tmux: { socket: null } })), SeatError); }); // --------------------------------------------------------------------------- // 6. writeRegistration / readRegistration // --------------------------------------------------------------------------- test("writeRegistration/readRegistration: round trip, permissions, absence, and malformed records", () => { const root = makeRoot(); const seatDir = buildRepoSeat(root, "myseat"); const resolved = resolveSeat("myseat", { repo: root }); const seats = seatsDir(join(root, "data")); const record = makeRegistration({ resolved, task: "hello", pid: 42 }); const path = writeRegistration(seats, record); assert.deepEqual(readRegistration(seats, "myseat", "repo"), record); assert.equal(statSync(path).mode & 0o777, 0o600); assert.equal(statSync(dirname(path)).mode & 0o777, 0o700); // No leftover *.tmp-* artifacts after the atomic write. for (const name of readdirSync(dirname(path))) assert.ok(!name.includes(".tmp-"), `leftover tmp file: ${name}`); // Missing registration reads as null. assert.equal(readRegistration(seats, "ghost", "repo"), null); // Malformed JSON throws. const malformedSeat = "malformed"; writeFile(registrationPath(seats, malformedSeat, "repo"), "{ not json"); assert.throws(() => readRegistration(seats, malformedSeat, "repo"), SeatError); // A record with an unknown field throws rather than reading as absent. const unknownFieldSeat = "unknownfield"; const badRecord = { ...record, seat: unknownFieldSeat, extra: "nope" }; writeFile(registrationPath(seats, unknownFieldSeat, "repo"), JSON.stringify(badRecord)); assert.throws(() => readRegistration(seats, unknownFieldSeat, "repo"), (err) => { assert.ok(err instanceof SeatError); assert.match(err.message, /unknown field/); return true; }); }); // --------------------------------------------------------------------------- // 7. updateTask // --------------------------------------------------------------------------- test("updateTask: changes task and updatedAt only, and refuses appropriately", () => { const root = makeRoot(); const seatDir = buildRepoSeat(root, "myseat"); const resolved = resolveSeat("myseat", { repo: root }); const seats = seatsDir(join(root, "data")); const record = makeRegistration({ resolved, task: "original", pid: 1 }); writeRegistration(seats, record); const updated = updateTask(seats, "myseat", "revised"); assert.equal(updated.task, "revised"); assert.notEqual(updated.updatedAt, null); for (const key of Object.keys(record)) { if (key === "task" || key === "updatedAt") continue; assert.deepEqual(updated[key], record[key], `field ${key} changed unexpectedly`); } // Refuses when there is no registration to attach to. assert.throws(() => updateTask(seats, "ghost", "x"), (err) => { assert.ok(err instanceof SeatError); assert.equal(err.exitCode, 1); return true; }); // Refuses an over-limit task. assert.throws(() => updateTask(seats, "myseat", "x".repeat(TASK_LIMIT + 1)), (err) => { assert.ok(err instanceof SeatError); assert.equal(err.exitCode, 4); return true; }); // The same name in a second layout makes the bare name ambiguous; the // layout qualifier resolves it and each layout keeps its own record. const fleetRecord = { ...record, layout: "fleet", sessionsDir: join(root, "fleet", "myseat", ".pi", "agent", "sessions"), task: "fleet task" }; writeRegistration(seats, fleetRecord); assert.throws(() => updateTask(seats, "myseat", "x"), (err) => { assert.ok(err instanceof SeatError); assert.equal(err.exitCode, 4); assert.match(err.message, /more than one layout/); return true; }); assert.equal(updateTask(seats, "myseat", "fleet revised", { layout: "fleet" }).task, "fleet revised"); assert.equal(readRegistration(seats, "myseat", "repo").task, "revised"); assert.equal(readRegistration(seats, "myseat", "fleet").task, "fleet revised"); assert.equal(findRegistrations(seats, "myseat").length, 2); }); // --------------------------------------------------------------------------- // 8. CLI launch end to end // --------------------------------------------------------------------------- test("CLI launch: registers, execs the fake launch script, and passes args through", () => { const root = makeRoot(); buildRepoSeat(root, "myseat"); const { configPath, dataRoot } = buildConfig(root); const r = runCli(["launch", "myseat", "--task", "do x", "--config", configPath, "--", "--foo", "bar"], { cwd: root, env: cliEnv({ MOSAIC_CONFIG: configPath }), }); assert.equal(r.status, 0, r.stderr); const seatDir = join(root, "agents", "myseat"); assert.equal(readFileSync(join(seatDir, "args.txt"), "utf8"), "--foo\nbar\n"); const path = registrationPath(seatsDir(dataRoot), "myseat", "repo"); assert.equal(readFileSync(join(seatDir, "env.txt"), "utf8").trim(), path); const record = JSON.parse(readFileSync(path, "utf8")); assert.equal(record.seat, "myseat"); assert.equal(record.project, basename(root)); assert.equal(record.task, "do x"); assert.equal(record.workspace, root); assert.equal(record.harness, null); assert.equal(record.tmux, null); assert.ok(Number.isInteger(record.pid) && record.pid > 0); assert.equal(record.layout, "repo"); assert.ok(Number.isFinite(Date.parse(record.startedAt))); }); test("CLI launch: --harness lands in the record", () => { const root = makeRoot(); buildRepoSeat(root, "myseat"); const { configPath, dataRoot } = buildConfig(root); const r = runCli(["launch", "myseat", "--task", "t", "--harness", "pi", "--config", configPath], { cwd: root, env: cliEnv({ MOSAIC_CONFIG: configPath }), }); assert.equal(r.status, 0, r.stderr); const path = registrationPath(seatsDir(dataRoot), "myseat", "repo"); const record = JSON.parse(readFileSync(path, "utf8")); assert.equal(record.harness, "pi"); }); test("CLI launch: the launch script's own exit code passes through", () => { const root = makeRoot(); buildRepoSeat(root, "myseat"); const { configPath } = buildConfig(root); const r = runCli(["launch", "myseat", "--config", configPath], { cwd: root, env: cliEnv({ MOSAIC_CONFIG: configPath, FAKE_EXIT: "7" }), }); assert.equal(r.status, 7, r.stderr); }); // --------------------------------------------------------------------------- // 9. CLI relaunch rewrites the single registration file // --------------------------------------------------------------------------- test("CLI launch: relaunching a seat rewrites the one registration record", () => { const root = makeRoot(); buildRepoSeat(root, "myseat"); const { configPath, dataRoot } = buildConfig(root); const r1 = runCli(["launch", "myseat", "--task", "first", "--config", configPath], { cwd: root, env: cliEnv({ MOSAIC_CONFIG: configPath }), }); assert.equal(r1.status, 0, r1.stderr); const path = registrationPath(seatsDir(dataRoot), "myseat", "repo"); const record1 = JSON.parse(readFileSync(path, "utf8")); const r2 = runCli(["launch", "myseat", "--task", "second", "--config", configPath], { cwd: root, env: cliEnv({ MOSAIC_CONFIG: configPath }), }); assert.equal(r2.status, 0, r2.stderr); const record2 = JSON.parse(readFileSync(path, "utf8")); assert.deepEqual(readdirSync(dirname(path)), ["registration.json"]); assert.equal(record2.task, "second"); assert.ok(Date.parse(record2.startedAt) >= Date.parse(record1.startedAt)); }); // --------------------------------------------------------------------------- // 10. CLI launch with no --task // --------------------------------------------------------------------------- test("CLI launch: omitting --task records an empty string, not null", () => { const root = makeRoot(); buildRepoSeat(root, "myseat"); const { configPath, dataRoot } = buildConfig(root); const r = runCli(["launch", "myseat", "--config", configPath], { cwd: root, env: cliEnv({ MOSAIC_CONFIG: configPath }), }); assert.equal(r.status, 0, r.stderr); const path = registrationPath(seatsDir(dataRoot), "myseat", "repo"); const record = JSON.parse(readFileSync(path, "utf8")); assert.equal(record.task, ""); }); // --------------------------------------------------------------------------- // 11. CLI seat task // --------------------------------------------------------------------------- test("CLI seat task: updates only the task after a launch, and refuses on an unlaunched seat", () => { const root = makeRoot(); buildRepoSeat(root, "myseat"); const { configPath, dataRoot } = buildConfig(root); const launchResult = runCli(["launch", "myseat", "--task", "before", "--config", configPath], { cwd: root, env: cliEnv({ MOSAIC_CONFIG: configPath }), }); assert.equal(launchResult.status, 0, launchResult.stderr); const path = registrationPath(seatsDir(dataRoot), "myseat", "repo"); const before = JSON.parse(readFileSync(path, "utf8")); const r = runCli(["seat", "task", "myseat", "new text", "--config", configPath], { cwd: root, env: cliEnv({ MOSAIC_CONFIG: configPath }), }); assert.equal(r.status, 0, r.stderr); const after1 = JSON.parse(readFileSync(path, "utf8")); assert.equal(after1.task, "new text"); for (const key of Object.keys(before)) { if (key === "task" || key === "updatedAt") continue; assert.deepEqual(after1[key], before[key], `field ${key} changed unexpectedly`); } // No registration for a seat that was never launched. const root2 = makeRoot(); buildRepoSeat(root2, "unlaunched"); const { configPath: configPath2 } = buildConfig(root2); const r2 = runCli(["seat", "task", "unlaunched", "text", "--config", configPath2], { cwd: root2, env: cliEnv({ MOSAIC_CONFIG: configPath2 }), }); assert.equal(r2.status, 1); assert.match(r2.stderr, /no registration/); }); // --------------------------------------------------------------------------- // 12. CLI refusals // --------------------------------------------------------------------------- test("CLI refusals: no args, unknown flag, missing config, already-registered env, and exec failure", () => { // No args: usage, exit 4. const rNoArgs = runCli([]); assert.equal(rNoArgs.status, 4); assert.match(rNoArgs.stderr, /usage: mosaic launch/); // Unknown flag: exit 4. const rUnknown = runCli(["launch", "myseat", "--bogus"]); assert.equal(rUnknown.status, 4); assert.match(rUnknown.stderr, /unknown argument/); // Missing config with an otherwise-valid seat: exit 2. const root = makeRoot(); buildRepoSeat(root, "myseat"); const missingConfig = join(root, "missing.json"); assert.ok(!existsSync(missingConfig)); const rMissingConfig = runCli(["launch", "myseat", "--config", missingConfig], { cwd: root, env: cliEnv({ MOSAIC_CONFIG: missingConfig }), }); assert.equal(rMissingConfig.status, 2); assert.match(rMissingConfig.stderr, /config not found/); // MOSAIC_LAUNCH_REGISTERED already set: exit 4, no registration written. const root2 = makeRoot(); buildRepoSeat(root2, "myseat"); const { configPath: configPath2, dataRoot: dataRoot2 } = buildConfig(root2); const rRegistered = runCli(["launch", "myseat", "--config", configPath2], { cwd: root2, env: cliEnv({ MOSAIC_CONFIG: configPath2, MOSAIC_LAUNCH_REGISTERED: "/some/path" }), }); assert.equal(rRegistered.status, 4); assert.match(rRegistered.stderr, /MOSAIC_LAUNCH_REGISTERED/); assert.ok(!existsSync(registrationPath(seatsDir(dataRoot2), "myseat", "repo"))); // Launch script that fails to exec: exit 1, no registration left behind. const root3 = makeRoot(); buildRepoSeat(root3, "myseat", { launchContent: "#!/nonexistent/interp\necho hi\n" }); const { configPath: configPath3, dataRoot: dataRoot3 } = buildConfig(root3); const rExecFail = runCli(["launch", "myseat", "--config", configPath3], { cwd: root3, env: cliEnv({ MOSAIC_CONFIG: configPath3 }), }); assert.equal(rExecFail.status, 1); assert.match(rExecFail.stderr, /could not run/); assert.ok(!existsSync(registrationPath(seatsDir(dataRoot3), "myseat", "repo"))); }); // --------------------------------------------------------------------------- // 13. samePath // --------------------------------------------------------------------------- test("samePath: equal paths, symlinked dirs, distinct dirs, and non-strings", () => { const root = makeRoot(); const target = join(root, "target"); mkdirSync(target); const other = join(root, "other"); mkdirSync(other); const link = join(root, "link"); symlinkSync(target, link, "dir"); assert.equal(samePath(target, target), true); assert.equal(samePath(target, link), true); assert.equal(samePath(target, other), false); assert.equal(samePath(1, "x"), false); assert.equal(samePath(null, target), false); });