feat: workspace aliases, tab groups of split panes, native path picker (v2.1.0)
- /api/workspaces and `agenthub workspace(s)` / `--workspace` as aliases of /api/lanes and `agenthub lanes` / `--lane`; old names print one stderr note. REPL `help lanes|workspace|workspaces` resolves through the aliases. - A workspace holds up to ten sessions (slots 0-9); every console tab is a group of 1-4 split panes (PaneGroups, agenthub.workspace.paneLayout); parseGroups keeps the stored active tab when an earlier one is dropped. - Browse button beside path fields: POST /api/lanes/pick-path opens the OS picker on the server's desktop — loopback only and no forwarding header (EREMOTE), a display failure is ENODIALOG not a Cancel, the dialog is killed when the request closes, a folder field falls back to the in-app browser on any failure. - Changes rail counts chip, image viewer (/git/raw), status bar session counts and per-day cost (`date=` on /api/pricing/cost). - /agenthub-open and /agenthub-doctor run from the INSTALLED build (supersededBy), doctor offers fixes behind two confirmations; plugin-open treats a locked bootstrap as non-fatal. - New-terminal shell waits for the first attach (issue #55). - repo-onboarding / debug-live-issue skills removed. - OpenAPI: pick-path, lane/date on /pricing/cost, tool on /run/history, /api/workspaces mount.
This commit is contained in:
@@ -139,6 +139,13 @@ describe("GET /api/pricing/cost daily split", () => {
|
||||
"2026-10-05": 3,
|
||||
});
|
||||
assert.equal(ict.body.total_cost, 7);
|
||||
|
||||
// `date=` prices that one local day, breakdown included.
|
||||
const day = await call("GET", "/api/pricing/cost?tz_offset=-420&date=2026-10-03");
|
||||
assert.equal(day.body.total_cost, 2);
|
||||
assert.equal(day.body.breakdown.length, 1);
|
||||
assert.equal(day.body.breakdown[0].input_tokens, 2 * M);
|
||||
assert.equal((await call("GET", "/api/pricing/cost?date=10/03")).status, 400);
|
||||
});
|
||||
|
||||
it("scales hours above the total down to it, so the days still add up", async () => {
|
||||
|
||||
@@ -41,6 +41,17 @@ describe("dashboard-runs", () => {
|
||||
assert.ok(old.ended_at);
|
||||
});
|
||||
|
||||
it("narrows a lane's history to one tool", () => {
|
||||
start("agenthub-lane-8", { laneId: 8, tool: "claude" });
|
||||
start("agenthub-lane-8-1", { laneId: 8, tool: "shell" });
|
||||
const shells = runs.listRuns({ laneId: 8, tool: "shell" });
|
||||
assert.deepEqual(
|
||||
shells.map((r) => r.id),
|
||||
["agenthub-lane-8-1"]
|
||||
);
|
||||
assert.equal(runs.listRuns({ laneId: 8 }).length, 2);
|
||||
});
|
||||
|
||||
it("flags as abandoned only the running rows whose pane is gone", () => {
|
||||
start("agenthub-lane-5", { laneId: 5 });
|
||||
start("agenthub-lane-6", { laneId: 6 });
|
||||
|
||||
@@ -15,7 +15,7 @@ const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
const { collectChanges, parseNumstat, readFileAt } = require("../lib/git-diff");
|
||||
const { collectChanges, parseNumstat, readFileAt, readImageAt } = require("../lib/git-diff");
|
||||
const { runGitAction } = require("../lib/git-ops");
|
||||
const { gitFacts } = require("../lib/worktree");
|
||||
|
||||
@@ -179,6 +179,73 @@ describe("discard of a staged rename", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("readImageAt: an image's bytes for the viewer", () => {
|
||||
// Every byte value, so a utf8 round-trip anywhere would show.
|
||||
const bytes = Buffer.from(Array.from({ length: 256 }, (_, i) => i));
|
||||
|
||||
it("reads the working file and a committed one byte for byte", async () => {
|
||||
write(LANE, "img/logo.PNG", bytes);
|
||||
g(LANE, "add", "-A");
|
||||
g(LANE, "commit", "-qm", "img");
|
||||
const work = await readImageAt(LANE, "img/logo.PNG", "work");
|
||||
assert.equal(work.type, "image/png");
|
||||
assert.deepEqual(work.buf, bytes);
|
||||
const head = await readImageAt(LANE, "img/logo.PNG", "HEAD");
|
||||
assert.deepEqual(head.buf, bytes);
|
||||
});
|
||||
|
||||
it("refuses a non-image, a missing file and a symlink out of the lane", async () => {
|
||||
await assert.rejects(readImageAt(LANE, "a.txt", "work"), { code: "ENOTIMAGE" });
|
||||
await assert.rejects(readImageAt(LANE, "none.png", "work"), { code: "ENOFILE" });
|
||||
fs.writeFileSync(path.join(ROOT, "outside.png"), bytes);
|
||||
fs.symlinkSync(path.join(ROOT, "outside.png"), path.join(LANE, "leak.png"));
|
||||
await assert.rejects(readImageAt(LANE, "leak.png", "work"));
|
||||
await assert.rejects(readImageAt(LANE, "../outside.png", "work"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/lanes/:id/git/raw", () => {
|
||||
it("serves an image with its type, and an SVG sandboxed", async () => {
|
||||
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "raw.db");
|
||||
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
const { createApp, startServer } = require("../index");
|
||||
const server = await startServer(createApp(), 0);
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
try {
|
||||
write(LANE, "pic.png", Buffer.from([0x89, 0x50, 0x4e, 0x47, 0, 255]));
|
||||
write(
|
||||
LANE,
|
||||
"x.svg",
|
||||
'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'
|
||||
);
|
||||
const created = await fetch(`${base}/api/lanes`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ cwd: LANE, title: "raw" }),
|
||||
}).then((r) => r.json());
|
||||
const id = created.lane.id;
|
||||
const png = await fetch(`${base}/api/lanes/${id}/git/raw?path=pic.png`);
|
||||
assert.equal(png.status, 200);
|
||||
assert.equal(png.headers.get("content-type"), "image/png");
|
||||
assert.deepEqual(
|
||||
Buffer.from(await png.arrayBuffer()),
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0, 255])
|
||||
);
|
||||
const svg = await fetch(`${base}/api/lanes/${id}/git/raw?path=x.svg`);
|
||||
assert.equal(svg.headers.get("content-type"), "image/svg+xml");
|
||||
assert.match(svg.headers.get("content-security-policy"), /sandbox/);
|
||||
assert.equal(svg.headers.get("x-content-type-options"), "nosniff");
|
||||
const txt = await fetch(`${base}/api/lanes/${id}/git/raw?path=a.txt`);
|
||||
assert.equal(txt.status, 400);
|
||||
assert.equal((await txt.json()).error.code, "ENOTIMAGE");
|
||||
await fetch(`${base}/api/lanes/${id}`, { method: "DELETE" });
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/lanes/:id/git/file maps a secret to 403 ESECRET", () => {
|
||||
it("answers 403 for the router's settings file", async () => {
|
||||
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "api.db");
|
||||
|
||||
@@ -129,6 +129,23 @@ describe("/api/lanes", () => {
|
||||
assert.equal(typeof r.body.counts.needs_you, "number");
|
||||
});
|
||||
|
||||
it("serves the same routes under /api/workspaces, the UI's word", async () => {
|
||||
const lanes = await request("GET", "/api/lanes");
|
||||
const list = await request("GET", "/api/workspaces");
|
||||
assert.equal(list.status, 200);
|
||||
assert.deepEqual(
|
||||
list.body.lanes.map((l) => l.id),
|
||||
lanes.body.lanes.map((l) => l.id)
|
||||
);
|
||||
const one = await request("GET", `/api/workspaces/${laneId}`);
|
||||
assert.equal(one.status, 200);
|
||||
assert.equal(one.body.lane.id, laneId);
|
||||
// Mounted through the sources router, which sits before the lanes router.
|
||||
const sources = await request("GET", `/api/workspaces/${laneId}/sources`);
|
||||
assert.equal(sources.status, 200);
|
||||
assert.deepEqual(sources.body, (await request("GET", `/api/lanes/${laneId}/sources`)).body);
|
||||
});
|
||||
|
||||
it("exposes pipeline templates", async () => {
|
||||
const r = await request("GET", "/api/lanes/pipelines");
|
||||
assert.equal(r.status, 200);
|
||||
@@ -220,6 +237,42 @@ describe("/api/lanes", () => {
|
||||
assert.equal((await request("DELETE", `/api/lanes/${id}`)).status, 200);
|
||||
});
|
||||
|
||||
it("opens the native picker only for a local, unproxied, same-origin browser", async () => {
|
||||
const nd = require("../lib/native-dialog");
|
||||
const desktop = require("../lib/desktop-env");
|
||||
const calls = [];
|
||||
nd.__setExecImpl(async (cmd) => {
|
||||
calls.push(cmd);
|
||||
return { code: 0, stdout: "/tmp/picked\n", stderr: "" };
|
||||
});
|
||||
desktop.__setExecImpl(() => "DISPLAY=:0\n");
|
||||
try {
|
||||
for (const headers of [
|
||||
{ Origin: "https://attacker.example" },
|
||||
{ "X-Forwarded-For": "203.0.113.9" },
|
||||
{ Forwarded: "for=203.0.113.9" },
|
||||
]) {
|
||||
const r = await request("POST", "/api/lanes/pick-path", { kind: "dir" }, headers);
|
||||
assert.equal(r.status, 403, JSON.stringify(headers));
|
||||
}
|
||||
assert.deepEqual(calls, []);
|
||||
if (process.platform === "linux") {
|
||||
const r = await request("POST", "/api/lanes/pick-path", { kind: "dir" });
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.path, "/tmp/picked");
|
||||
nd.__setExecImpl(async () => ({ missing: true }));
|
||||
const none = await request("POST", "/api/lanes/pick-path", { kind: "file" });
|
||||
assert.equal(none.status, 501);
|
||||
assert.equal(none.body.error.code, "ENODIALOG");
|
||||
}
|
||||
} finally {
|
||||
nd.__setExecImpl(null);
|
||||
desktop.__setExecImpl(() => {
|
||||
throw new Error("no systemd user manager in this test");
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("broadcasts lane_update on create", async () => {
|
||||
const wsUrl = `ws://127.0.0.1:${server.address().port}/ws`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
@@ -425,8 +478,13 @@ describe("lane start input", () => {
|
||||
assert.equal(r.body.run_id, `agenthub-lane-${id}-1`);
|
||||
assert.equal(created[created.indexOf("-s") + 1], `agenthub-lane-${id}-1`);
|
||||
assert.equal(created[created.indexOf("-c") + 1], cwd);
|
||||
// Nothing after `--`: tmux runs the user's own default shell.
|
||||
assert.equal(created[created.length - 1], "--");
|
||||
// After `--` only the fixed size-wait wrapper that execs the user's own
|
||||
// default shell (issue #55) — nothing from the request.
|
||||
const argv = created.slice(created.indexOf("--") + 1);
|
||||
assert.deepEqual(argv.slice(0, 2), ["sh", "-c"]);
|
||||
assert.equal(argv.length, 3);
|
||||
assert.match(argv[2], /exec "\$SHELL" -l$/);
|
||||
assert.ok(!created.includes("ignored"));
|
||||
const lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
|
||||
assert.equal(lane.run_id, null);
|
||||
assert.notEqual(lane.status, "running");
|
||||
@@ -1958,13 +2016,16 @@ describe("start: slot auto and a session already live", () => {
|
||||
assert.equal(r.body.run_id, `agenthub-lane-${id}-2`);
|
||||
});
|
||||
|
||||
it("answers ERUNLIVE when every slot is busy", async () => {
|
||||
live.add(`agenthub-lane-${id}-3`);
|
||||
it("answers ERUNLIVE when all ten slots are busy", async () => {
|
||||
for (let s = 3; s <= 8; s++) live.add(`agenthub-lane-${id}-${s}`);
|
||||
// Slot 9 is the tenth and last.
|
||||
const ninth = await request("POST", `/api/lanes/${id}/start`, { slot: "auto", shell: true });
|
||||
assert.equal(ninth.body.run_id, `agenthub-lane-${id}-9`);
|
||||
live.add(`agenthub-lane-${id}-9`);
|
||||
const r = await request("POST", `/api/lanes/${id}/start`, { slot: "auto" });
|
||||
assert.equal(r.status, 409);
|
||||
assert.equal(r.body.error.code, "ERUNLIVE");
|
||||
live.delete(`agenthub-lane-${id}-3`);
|
||||
live.delete(`agenthub-lane-${id}-2`);
|
||||
for (let s = 2; s <= 9; s++) live.delete(`agenthub-lane-${id}-${s}`);
|
||||
});
|
||||
|
||||
it("refuses to resume a session a live pane already holds, naming that pane", async () => {
|
||||
|
||||
@@ -193,6 +193,24 @@ after(() => {
|
||||
}
|
||||
});
|
||||
|
||||
describe("agenthub workspaces", () => {
|
||||
it("is the lanes command under the UI's word", async () => {
|
||||
const [lanes, workspaces, workspace] = await Promise.all([
|
||||
cli(["lanes"], LANE_DIR),
|
||||
cli(["workspaces"], LANE_DIR),
|
||||
cli(["workspace"], LANE_DIR),
|
||||
]);
|
||||
assert.equal(workspaces.status, 0, workspaces.stderr);
|
||||
assert.match(workspaces.stdout, /CLI lane/);
|
||||
assert.equal(workspaces.stdout, lanes.stdout);
|
||||
assert.equal(workspace.stdout, lanes.stdout);
|
||||
// The old name still works but says so — on stderr, never in stdout.
|
||||
assert.match(lanes.stderr, /old name.*agenthub workspace/);
|
||||
assert.doesNotMatch(workspaces.stderr, /old name/);
|
||||
assert.doesNotMatch(lanes.stdout, /old name/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("agenthub stage", () => {
|
||||
it("reports a stage for the lane owning the current directory", async () => {
|
||||
const r = await cli(["stage", "review", "--evidence", "3 findings"], LANE_DIR);
|
||||
@@ -280,6 +298,17 @@ describe("agenthub stage in a directory shared by two lanes", () => {
|
||||
assert.notEqual(await stageOf(codexLane.id), "implement");
|
||||
});
|
||||
|
||||
it("--workspace names the lane like --lane", async () => {
|
||||
const r = await cli(
|
||||
["stage", "review", "--workspace", String(codexLane.id)],
|
||||
DUAL_DIR,
|
||||
NO_HARNESS
|
||||
);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.equal(await stageOf(codexLane.id), "review");
|
||||
assert.notEqual(await stageOf(claudeLane.id), "review");
|
||||
});
|
||||
|
||||
it("fails loudly, naming both candidates, when nothing identifies the tool", async () => {
|
||||
const before = [await stageOf(claudeLane.id), await stageOf(codexLane.id)];
|
||||
const r = await cli(["stage", "done"], DUAL_DIR, NO_HARNESS);
|
||||
@@ -686,7 +715,7 @@ describe("agenthub lanes node", () => {
|
||||
assert.notEqual(r.status, 0);
|
||||
assert.match(r.stdout, /\bintake\b/);
|
||||
assert.match(r.stdout, /\bdone\b/);
|
||||
assert.match(r.stderr, /usage: agenthub lanes node/);
|
||||
assert.match(r.stderr, /usage: agenthub workspace node/);
|
||||
});
|
||||
|
||||
it("--print shows a slash-command node as a claude prompt, extra args appended", async () => {
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* @file native-dialog.test.js
|
||||
* @description Unit tests for the server-side native folder/file picker: the
|
||||
* argv each platform gets, the fallback from zenity to kdialog, Cancel versus a
|
||||
* chosen path, and the "no dialog program" answer the client falls back on.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
const { describe, it, afterEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const os = require("node:os");
|
||||
const nd = require("../lib/native-dialog");
|
||||
const desktop = require("../lib/desktop-env");
|
||||
|
||||
const linux = process.platform === "linux";
|
||||
|
||||
describe("native-dialog", () => {
|
||||
afterEach(() => nd.__setExecImpl(null));
|
||||
|
||||
it("starts in the given folder, a file's folder, or home", () => {
|
||||
assert.equal(nd.startDir(os.tmpdir()), os.tmpdir());
|
||||
assert.equal(nd.startDir("/definitely/not/here/x"), os.homedir());
|
||||
assert.equal(nd.startDir("~"), os.homedir());
|
||||
assert.equal(nd.startDir(undefined), os.homedir());
|
||||
});
|
||||
|
||||
it("builds zenity then kdialog on Linux, the request's folder one argv element", () => {
|
||||
const [[z, zargs], [k, kargs]] = nd.dialogCommands("dir", "/tmp/a b", "linux");
|
||||
assert.equal(z, "zenity");
|
||||
assert.deepEqual(zargs, [
|
||||
"--file-selection",
|
||||
"--title=Select a folder",
|
||||
"--filename=/tmp/a b/",
|
||||
"--directory",
|
||||
]);
|
||||
assert.equal(k, "kdialog");
|
||||
assert.deepEqual(kargs.slice(0, 2), ["--getexistingdirectory", "/tmp/a b"]);
|
||||
assert.ok(!nd.dialogCommands("file", "/tmp", "linux")[0][1].includes("--directory"));
|
||||
});
|
||||
|
||||
it("passes the folder to osascript as an argument, never inside the script", () => {
|
||||
const [[cmd, args]] = nd.dialogCommands("file", '/x"; do shell script "id', "darwin");
|
||||
assert.equal(cmd, "osascript");
|
||||
assert.equal(args.at(-1), '/x"; do shell script "id');
|
||||
assert.ok(!args.slice(0, -2).join(" ").includes("shell script"));
|
||||
});
|
||||
|
||||
it(
|
||||
"answers the chosen path, null on Cancel, and falls through a missing program",
|
||||
{
|
||||
skip: !linux,
|
||||
},
|
||||
async () => {
|
||||
desktop.__setExecImpl(() => "DISPLAY=:0\n");
|
||||
const tried = [];
|
||||
nd.__setExecImpl(async (cmd) => {
|
||||
tried.push(cmd);
|
||||
return cmd === "zenity" ? { missing: true } : { code: 0, stdout: "/home/u/proj\n" };
|
||||
});
|
||||
assert.equal(await nd.pickPath("dir"), "/home/u/proj");
|
||||
assert.deepEqual(tried, ["zenity", "kdialog"]);
|
||||
|
||||
nd.__setExecImpl(async () => ({ code: 1, stdout: "" }));
|
||||
assert.equal(await nd.pickPath("file"), null);
|
||||
|
||||
nd.__setExecImpl(async () => ({ missing: true }));
|
||||
await assert.rejects(nd.pickPath("dir"), { code: "ENODIALOG" });
|
||||
}
|
||||
);
|
||||
|
||||
it(
|
||||
"answers ENODIALOG, not a Cancel, when the program could not reach the desktop",
|
||||
{ skip: !linux },
|
||||
async () => {
|
||||
desktop.__setExecImpl(() => "DISPLAY=:0\n");
|
||||
nd.__setExecImpl(async () => ({
|
||||
code: 1,
|
||||
stdout: "",
|
||||
stderr: "(zenity:1): Gtk-WARNING **: cannot open display: :0\n",
|
||||
}));
|
||||
await assert.rejects(nd.pickPath("dir"), { code: "ENODIALOG" });
|
||||
}
|
||||
);
|
||||
|
||||
it("passes the abort signal through to the dialog program", { skip: !linux }, async () => {
|
||||
desktop.__setExecImpl(() => "DISPLAY=:0\n");
|
||||
const ac = new AbortController();
|
||||
let seen;
|
||||
nd.__setExecImpl(async (cmd, args, env, signal) => {
|
||||
seen = signal;
|
||||
return { code: 1, stdout: "", stderr: "" };
|
||||
});
|
||||
assert.equal(await nd.pickPath("dir", undefined, ac.signal), null);
|
||||
assert.equal(seen, ac.signal);
|
||||
});
|
||||
|
||||
it("tells a Cancel from a failure per program", () => {
|
||||
assert.equal(nd.isCancel("zenity", ""), true);
|
||||
assert.equal(nd.isCancel("zenity", "Gtk-Message: GtkDialog mapped without a transient parent"), true);
|
||||
assert.equal(nd.isCancel("kdialog", "qt.qpa.xcb: could not connect to display :1"), false);
|
||||
assert.equal(nd.isCancel("osascript", "execution error: User canceled. (-128)"), true);
|
||||
assert.equal(nd.isCancel("osascript", "execution error: No user interaction allowed. (-1713)"), false);
|
||||
assert.equal(nd.isCancel("powershell.exe", ""), true);
|
||||
assert.equal(nd.isCancel("powershell.exe", "Add-Type : Cannot add type"), false);
|
||||
});
|
||||
});
|
||||
@@ -47,7 +47,7 @@ describe("plugin-doctor: tmux row", () => {
|
||||
const row = rowWithTmux(null);
|
||||
|
||||
assert.equal(row.level, "warn", "a missing tmux must not fail the doctor run");
|
||||
assert.match(row.detail, /lanes shell/, "the reader needs to know WHAT breaks");
|
||||
assert.match(row.detail, /workspace shell/, "the reader needs to know WHAT breaks");
|
||||
assert.match(row.detail, /install/i, "and how to fix it");
|
||||
});
|
||||
|
||||
|
||||
@@ -56,7 +56,9 @@ describe("pty-attach", () => {
|
||||
it("accepts a well-formed runId", () => {
|
||||
assert.doesNotThrow(() => ptyAttach.validateRunId("agenthub-lane-42"));
|
||||
assert.doesNotThrow(() => ptyAttach.validateRunId("agenthub-lane-42-3"));
|
||||
assert.throws(() => ptyAttach.validateRunId("agenthub-lane-42-4"), /EBADRUNID/);
|
||||
assert.doesNotThrow(() => ptyAttach.validateRunId("agenthub-lane-42-9"));
|
||||
assert.throws(() => ptyAttach.validateRunId("agenthub-lane-42-10"), /EBADRUNID/);
|
||||
assert.throws(() => ptyAttach.validateRunId("agenthub-lane-42-0"), /EBADRUNID/);
|
||||
});
|
||||
|
||||
it("turns tmux mouse mode on for the session it attaches to", () => {
|
||||
|
||||
@@ -58,7 +58,49 @@ describe("pty-run", () => {
|
||||
assert.ok(newSessionCall.includes("opus"));
|
||||
});
|
||||
|
||||
it("spawnRun names a split pane's session agenthub-lane-<id>-<slot>, and refuses a slot past 3", () => {
|
||||
it("spawnShell starts the login shell only once the pane has its real size (issue #55)", async () => {
|
||||
const calls = [];
|
||||
tmux.__setExecImpl((args) => {
|
||||
calls.push(args);
|
||||
return "";
|
||||
});
|
||||
pty.spawnShell({ laneId: 43, cwd: "/tmp/repo" });
|
||||
const call = calls.find((c) => c[0] === "new-session");
|
||||
const argv = call.slice(call.indexOf("--") + 1);
|
||||
assert.equal(argv[0], "sh");
|
||||
assert.equal(argv[1], "-c");
|
||||
// Run the wrapper with a stand-in shell and a fake `tmux` that reports no
|
||||
// client until a flag file appears: the shell must wait for the attach,
|
||||
// then start at once rather than at the 2 s cap.
|
||||
const { spawn } = require("node:child_process");
|
||||
const bin = fs.mkdtempSync(path.join(os.tmpdir(), "fake-tmux-"));
|
||||
const flag = path.join(bin, "attached");
|
||||
fs.writeFileSync(
|
||||
path.join(bin, "tmux"),
|
||||
`#!/bin/sh\n[ -e '${flag}' ] && echo 1 || echo 0\n`,
|
||||
{ mode: 0o755 }
|
||||
);
|
||||
try {
|
||||
const t0 = Date.now();
|
||||
const child = spawn("sh", ["-c", argv[2]], {
|
||||
env: { ...process.env, SHELL: "/bin/echo", PATH: `${bin}:${process.env.PATH}` },
|
||||
});
|
||||
let out = "";
|
||||
let startedAt = 0;
|
||||
child.stdout.on("data", (d) => {
|
||||
out += d;
|
||||
startedAt ||= Date.now() - t0;
|
||||
});
|
||||
setTimeout(() => fs.writeFileSync(flag, ""), 400);
|
||||
await new Promise((r) => child.on("exit", r));
|
||||
assert.equal(out.trim(), "-l");
|
||||
assert.ok(startedAt >= 400 && startedAt < 1500, `shell started after ${startedAt} ms`);
|
||||
} finally {
|
||||
fs.rmSync(bin, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("spawnRun names a split pane's session agenthub-lane-<id>-<slot>, and refuses a slot past 9 (ten per workspace)", () => {
|
||||
tmux.__setExecImpl((args) => {
|
||||
if (args[0] === "has-session") {
|
||||
const e = new Error("no such session");
|
||||
@@ -72,8 +114,10 @@ describe("pty-run", () => {
|
||||
assert.equal(handle.laneId, 42);
|
||||
assert.equal(handle.slot, 2);
|
||||
assert.equal(pty.laneIdFromRunId("agenthub-lane-42-2"), 42);
|
||||
assert.equal(pty.spawnRun({ laneId: 42, slot: 9, cwd: "/tmp/repo" }).id, "agenthub-lane-42-9");
|
||||
assert.equal(pty.laneIdFromRunId("agenthub-lane-42-9"), 42);
|
||||
assert.throws(
|
||||
() => pty.spawnRun({ laneId: 42, slot: 4, cwd: "/tmp/repo" }),
|
||||
() => pty.spawnRun({ laneId: 42, slot: 10, cwd: "/tmp/repo" }),
|
||||
(err) => err.code === "EBADSLOT"
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user