Files
stack/packages/control-board/tests/serve.test.mjs
T
jason.woltjeandClaude Fable 5.1 bf1391a424 Show "shown of total" in control board project headers while rows are hidden (#1503)
A project header now reads "fleet (13 of 38)" while Hide offline or Hide
seen hides at least one row, and "fleet (38)" when nothing is hidden. The
note under the table still says which filter hid how many. Numbers only,
so nothing new needs escaping. Static test pins the expression and the
removal of the raw-length header. Sonnet review: APPROVED, no findings.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-12 09:00:21 -05:00

575 lines
24 KiB
JavaScript

import { test, after } from "node:test";
import assert from "node:assert/strict";
import {
mkdtempSync,
mkdirSync,
writeFileSync,
rmSync,
readFileSync,
existsSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve, basename } from "node:path";
import { spawnSync, spawn } from "node:child_process";
import { createServer as createNetServer } from "node:net";
import { ConfigError, markSeen } from "../src/scan.mjs";
import { isLoopbackHost, startServer } from "../src/serve.mjs";
const pkgRoot = resolve(import.meta.dirname, "..");
const cli = join(pkgRoot, "src", "cli.mjs");
// ---------------------------------------------------------------------------
// Fixture helpers, copied from scan.test.mjs (kept local so that file stays
// untouched; do not import unexported helpers across test files).
// ---------------------------------------------------------------------------
// Track every tmpdir we create so a stray failure never leaves fixtures behind.
const roots = [];
function makeRoot() {
const root = mkdtempSync(join(tmpdir(), "control-board-serve-test-"));
roots.push(root);
return root;
}
after(() => {
for (const root of roots) rmSync(root, { recursive: true, force: true });
});
function writeFile(path, content) {
mkdirSync(resolve(path, ".."), { recursive: true });
writeFileSync(path, content);
}
function sessionLine({ id, timestamp, cwd }) {
return JSON.stringify({ type: "session", id, timestamp, cwd });
}
function messageLine({ timestamp, role, stopReason, texts }) {
const message = { role };
if (stopReason !== undefined) message.stopReason = stopReason;
if (texts) message.content = texts.map((text) => ({ type: "text", text }));
return JSON.stringify({ type: "message", timestamp, message });
}
function writeSessionFile(dir, name, lines, { trailingNewline = true } = {}) {
const path = join(dir, name);
writeFile(path, lines.join("\n") + (trailingNewline ? "\n" : ""));
return path;
}
// ---------------------------------------------------------------------------
// serve.mjs-specific helpers
// ---------------------------------------------------------------------------
function closeServer(server) {
return new Promise((resolvePromise) => server.close(resolvePromise));
}
// Grab an ephemeral free port, then hand it back immediately so a caller can
// try to bind it themselves (used to prove startServer never opened a socket).
function getFreePort() {
return new Promise((resolvePromise, reject) => {
const probe = createNetServer();
probe.once("error", reject);
probe.listen(0, "127.0.0.1", () => {
const port = probe.address().port;
probe.close(() => resolvePromise(port));
});
});
}
function runCli(args) {
return spawnSync(process.execPath, [cli, ...args], { encoding: "utf8", timeout: 15000 });
}
// ---------------------------------------------------------------------------
// 1. isLoopbackHost
// ---------------------------------------------------------------------------
test("isLoopbackHost: recognizes loopback hosts", () => {
assert.equal(isLoopbackHost("127.0.0.1"), true);
assert.equal(isLoopbackHost("::1"), true);
assert.equal(isLoopbackHost("localhost"), true);
assert.equal(isLoopbackHost("127.5.5.5"), true);
});
test("isLoopbackHost: rejects non-loopback hosts", () => {
assert.equal(isLoopbackHost("0.0.0.0"), false);
assert.equal(isLoopbackHost("192.168.1.2"), false);
assert.equal(isLoopbackHost("::"), false);
assert.equal(isLoopbackHost(""), false);
assert.equal(isLoopbackHost("evil.example"), false);
});
// ---------------------------------------------------------------------------
// 2. startServer: fail-closed on a non-loopback host
// ---------------------------------------------------------------------------
test("startServer: refuses a non-loopback host with ConfigError, never opens a socket", async () => {
const root = makeRoot();
const port = await getFreePort();
let caught = null;
try {
// startServer is async, so the host refusal surfaces as a rejection
// before any listen() call happens.
await startServer({
host: "192.168.1.2",
port,
specs: [],
boardDir: join(root, "board"),
isAlive: () => true,
});
} catch (err) {
caught = err;
}
assert.ok(caught instanceof ConfigError, "expected a ConfigError");
// The port must still be free: startServer must never have called listen().
await new Promise((resolvePromise, reject) => {
const probe = createNetServer();
probe.once("error", reject);
probe.listen(port, "127.0.0.1", () => probe.close(resolvePromise));
});
});
// ---------------------------------------------------------------------------
// 3. createServer routes, via startServer on port 0
// ---------------------------------------------------------------------------
test("startServer: serves page, healthz, and a rescanning /api/board", async () => {
const root = makeRoot();
const sessionsDir = join(root, "sessions");
writeSessionFile(sessionsDir, "s.jsonl", [
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["done"] }),
]);
const boardDir = join(root, "board");
const page = "<html><body>injected test page</body></html>";
const specs = [{ agent: "agent1", project: "proj", sessionsDir, tmux: {} }];
const server = await startServer({
host: "127.0.0.1",
port: 0,
specs,
boardDir,
isAlive: () => true,
page,
});
const base = `http://127.0.0.1:${server.address().port}`;
try {
for (const path of ["/", "/index.html"]) {
const res = await fetch(`${base}${path}`);
assert.equal(res.status, 200, path);
assert.equal(res.headers.get("content-type"), "text/html; charset=utf-8", path);
assert.equal(res.headers.get("cache-control"), "no-store", path);
assert.equal(await res.text(), page, path);
}
const health = await fetch(`${base}/healthz`);
assert.equal(health.status, 200);
assert.deepEqual(await health.json(), { ok: true });
const first = await fetch(`${base}/api/board`);
assert.equal(first.status, 200);
assert.equal(first.headers.get("content-type"), "application/json");
assert.equal(first.headers.get("cache-control"), "no-store");
const firstBody = await first.json();
assert.equal(firstBody.sessions[0].state, "waiting");
assert.ok(firstBody.waitingOnYou.includes("proj/agent1"));
assert.ok(existsSync(join(boardDir, "index.json")), "index.json must be written by the scan");
assert.ok(existsSync(join(boardDir, "sessions", "proj", "agent1.json")), "per-agent file must be written by the scan");
// Rewrite the fixture to a "user" last message (working state) and hit
// /api/board again: a fresh scan must reflect the new state, proving
// each request rescans instead of caching.
writeSessionFile(sessionsDir, "s.jsonl", [
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
messageLine({ timestamp: "2026-09-01T00:05:00Z", role: "user", texts: ["go again"] }),
]);
const second = await fetch(`${base}/api/board`);
assert.equal(second.status, 200);
const secondBody = await second.json();
assert.equal(secondBody.sessions[0].state, "working");
const missing = await fetch(`${base}/nope`);
assert.equal(missing.status, 404);
const posted = await fetch(`${base}/api/board`, { method: "POST" });
assert.equal(posted.status, 405);
} finally {
await closeServer(server);
}
});
// ---------------------------------------------------------------------------
// 4. /api/board: scan failure surfaces as a 500 with an error field
// ---------------------------------------------------------------------------
test("startServer: /api/board returns 500 JSON with an error field when scan throws", async () => {
const server = await startServer({
host: "127.0.0.1",
port: 0,
specs: [],
// Relative boardDir: scan() throws ConfigError("boardDir must be an absolute path").
boardDir: "relative/board",
isAlive: () => true,
page: "<html></html>",
});
const base = `http://127.0.0.1:${server.address().port}`;
try {
const res = await fetch(`${base}/api/board`);
assert.equal(res.status, 500);
assert.equal(res.headers.get("content-type"), "application/json");
const body = await res.json();
assert.equal(typeof body.error, "string");
assert.ok(body.error.length > 0);
} finally {
await closeServer(server);
}
});
// ---------------------------------------------------------------------------
// 5. CLI
// ---------------------------------------------------------------------------
test("CLI: serve refuses a non-loopback host with exit 2 and a refused: message", () => {
const root = makeRoot();
const dataRoot = join(root, "data");
const configPath = join(root, "config.json");
writeFile(configPath, JSON.stringify({ dataRoot }));
const repoRoot = join(root, "repo");
mkdirSync(repoRoot, { recursive: true });
const r = runCli(["serve", "--host", "0.0.0.0", "--config", configPath, "--repo", repoRoot, "--fleet", "none"]);
assert.equal(r.status, 2);
assert.match(r.stderr, /^refused:/);
});
test("CLI: serve rejects a non-numeric --port with exit 2", () => {
const r = runCli(["serve", "--port", "abc"]);
assert.equal(r.status, 2);
assert.match(r.stderr, /^refused:/);
});
test("CLI: scan still works after the async cli refactor", () => {
const root = makeRoot();
const dataRoot = join(root, "data");
const configPath = join(root, "config.json");
writeFile(configPath, JSON.stringify({ dataRoot }));
const repoRoot = join(root, "repo");
mkdirSync(repoRoot, { recursive: true });
const r = runCli(["scan", "--config", configPath, "--repo", repoRoot, "--fleet", "none", "--liveness", "assume-alive"]);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /^board: /m);
});
test("CLI: live serve prints its URL and answers /healthz", async () => {
const root = makeRoot();
const dataRoot = join(root, "data");
const configPath = join(root, "config.json");
writeFile(configPath, JSON.stringify({ dataRoot }));
const repoRoot = join(root, "repo");
mkdirSync(repoRoot, { recursive: true });
const child = spawn(
process.execPath,
[cli, "serve", "--port", "0", "--liveness", "assume-alive", "--config", configPath, "--repo", repoRoot, "--fleet", "none"],
{ stdio: ["ignore", "pipe", "pipe"] }
);
let stdoutBuf = "";
let stderrBuf = "";
child.stderr.on("data", (chunk) => {
stderrBuf += chunk.toString();
});
let url;
try {
url = await new Promise((resolvePromise, reject) => {
const timer = setTimeout(() => {
reject(new Error(`timed out waiting for the server line; stdout=${JSON.stringify(stdoutBuf)} stderr=${JSON.stringify(stderrBuf)}`));
}, 15000);
child.stdout.on("data", (chunk) => {
stdoutBuf += chunk.toString();
const match = stdoutBuf.match(/^control board: (http:\/\/127\.0\.0\.1:\d+)\//m);
if (match) {
clearTimeout(timer);
resolvePromise(match[1]);
}
});
child.on("exit", (code) => {
clearTimeout(timer);
reject(new Error(`child exited early with code ${code}; stderr=${stderrBuf}`));
});
});
const res = await fetch(`${url}/healthz`);
assert.equal(res.status, 200);
assert.deepEqual(await res.json(), { ok: true });
} finally {
child.kill("SIGTERM");
await new Promise((resolvePromise) => {
if (child.exitCode !== null || child.signalCode !== null) return resolvePromise();
child.on("exit", resolvePromise);
});
}
});
// The page's only XSS defence is its inline esc() helper. Pull that function
// out of page.html by name and check it inerts every HTML-significant char.
test("page.html: esc() escapes every HTML-significant character", () => {
const html = readFileSync(join(pkgRoot, "src", "page.html"), "utf8");
const m = html.match(/function esc\(v\) \{[\s\S]*?\n \}/);
assert.ok(m, "esc() must exist in page.html");
const esc = new Function(`${m[0]}; return esc;`)();
assert.equal(esc('<script>alert("x")</script>&\''), "&lt;script&gt;alert(&quot;x&quot;)&lt;/script&gt;&amp;&#39;");
assert.equal(esc(null), "");
assert.equal(esc(undefined), "");
assert.equal(esc(42), "42");
// The page builds HTML by string concatenation. Any API value joined
// straight into markup ("+ rec.x" / "+ project" / "+ data.x") would bypass
// esc(); require zero such joins so a regression is caught here.
const rawJoins = [...html.matchAll(/\+\s*(rec\.[\w.]+|project|data\.[\w.]+)\b(?!\s*\|\|)/g)].map((x) => x[0]);
assert.deepEqual(rawJoins, [], `API values concatenated into HTML without esc(): ${rawJoins.join(" | ")}`);
const escCalls = (html.match(/\besc\(/g) || []).length;
assert.ok(escCalls >= 15, `expected many esc() calls, saw ${escCalls}`);
});
// ---------------------------------------------------------------------------
// 6. POST /api/seen
// ---------------------------------------------------------------------------
test("POST /api/seen marks a row; GET /api/board still shows it seen; seen:false clears it", async () => {
const root = makeRoot();
const sessionsDir = join(root, "sessions");
writeSessionFile(sessionsDir, "s.jsonl", [
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["done"] }),
]);
const boardDir = join(root, "board");
const specs = [{ agent: "agent1", project: "proj", sessionsDir, tmux: {} }];
const server = await startServer({ host: "127.0.0.1", port: 0, specs, boardDir, isAlive: () => true, page: "<html></html>" });
const base = `http://127.0.0.1:${server.address().port}`;
try {
const postRes = await fetch(`${base}/api/seen`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ project: "proj", agent: "agent1", lastActivity: "2026-09-01T00:00:01Z" }),
});
assert.equal(postRes.status, 200);
const postBody = await postRes.json();
assert.ok(postBody.seen.includes("proj/agent1"));
assert.ok(!postBody.waitingOnYou.includes("proj/agent1"));
const boardRes = await fetch(`${base}/api/board`);
assert.equal(boardRes.status, 200);
const boardBody = await boardRes.json();
assert.ok(boardBody.seen.includes("proj/agent1"));
assert.ok(!boardBody.waitingOnYou.includes("proj/agent1"));
const clearRes = await fetch(`${base}/api/seen`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ project: "proj", agent: "agent1", lastActivity: "2026-09-01T00:00:01Z", seen: false }),
});
assert.equal(clearRes.status, 200);
const clearBody = await clearRes.json();
assert.ok(!clearBody.seen.includes("proj/agent1"));
assert.ok(clearBody.waitingOnYou.includes("proj/agent1"));
} finally {
await closeServer(server);
}
});
test("POST /api/seen without a JSON content-type returns 400 and does not write a mark", async () => {
const root = makeRoot();
const sessionsDir = join(root, "sessions");
writeSessionFile(sessionsDir, "s.jsonl", [
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["done"] }),
]);
const boardDir = join(root, "board");
const specs = [{ agent: "agent1", project: "proj", sessionsDir, tmux: {} }];
const server = await startServer({ host: "127.0.0.1", port: 0, specs, boardDir, isAlive: () => true, page: "<html></html>" });
const base = `http://127.0.0.1:${server.address().port}`;
try {
const res = await fetch(`${base}/api/seen`, {
method: "POST",
headers: { "content-type": "text/plain" },
body: JSON.stringify({ project: "proj", agent: "agent1", lastActivity: "2026-09-01T00:00:01Z" }),
});
assert.equal(res.status, 400);
assert.ok(!existsSync(join(boardDir, "seen.json")), "seen.json must not be written on a rejected content-type");
} finally {
await closeServer(server);
}
});
test("POST /api/seen with invalid JSON returns 400", async () => {
const root = makeRoot();
const boardDir = join(root, "board");
const server = await startServer({ host: "127.0.0.1", port: 0, specs: [], boardDir, isAlive: () => true, page: "<html></html>" });
const base = `http://127.0.0.1:${server.address().port}`;
try {
const res = await fetch(`${base}/api/seen`, {
method: "POST",
headers: { "content-type": "application/json" },
body: "{ not json",
});
assert.equal(res.status, 400);
assert.ok(!existsSync(join(boardDir, "seen.json")));
} finally {
await closeServer(server);
}
});
test("POST /api/seen with a body over 4096 bytes returns 400 (or resets the connection) and writes no mark", async () => {
const root = makeRoot();
const boardDir = join(root, "board");
const server = await startServer({ host: "127.0.0.1", port: 0, specs: [], boardDir, isAlive: () => true, page: "<html></html>" });
const base = `http://127.0.0.1:${server.address().port}`;
const big = JSON.stringify({ project: "proj", agent: "agent1", lastActivity: "x".repeat(5000) });
try {
let status = null;
try {
const res = await fetch(`${base}/api/seen`, {
method: "POST",
headers: { "content-type": "application/json" },
body: big,
});
status = res.status;
} catch {
status = null; // a destroyed connection is an acceptable outcome too
}
if (status !== null) assert.equal(status, 400);
assert.ok(!existsSync(join(boardDir, "seen.json")), "seen.json must not be written for an oversized body");
} finally {
await closeServer(server);
}
});
test("POST /api/seen with a missing agent returns 400", async () => {
const root = makeRoot();
const boardDir = join(root, "board");
const server = await startServer({ host: "127.0.0.1", port: 0, specs: [], boardDir, isAlive: () => true, page: "<html></html>" });
const base = `http://127.0.0.1:${server.address().port}`;
try {
const res = await fetch(`${base}/api/seen`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ project: "proj", lastActivity: "2026-09-01T00:00:01Z" }),
});
assert.equal(res.status, 400);
assert.ok(!existsSync(join(boardDir, "seen.json")));
} finally {
await closeServer(server);
}
});
test("POST /api/board returns 405; PUT /api/seen returns 405", async () => {
const root = makeRoot();
const boardDir = join(root, "board");
const server = await startServer({ host: "127.0.0.1", port: 0, specs: [], boardDir, isAlive: () => true, page: "<html></html>" });
const base = `http://127.0.0.1:${server.address().port}`;
try {
const board = await fetch(`${base}/api/board`, { method: "POST" });
assert.equal(board.status, 405);
const put = await fetch(`${base}/api/seen`, { method: "PUT" });
assert.equal(put.status, 405);
} finally {
await closeServer(server);
}
});
// ---------------------------------------------------------------------------
// 7. CLI: seen rows in --print
// ---------------------------------------------------------------------------
test("CLI: scan --print marks a seen row with 's' and the summary line ends with 'N seen)'", () => {
const root = makeRoot();
const dataRoot = join(root, "data");
const configPath = join(root, "config.json");
writeFile(configPath, JSON.stringify({ dataRoot }));
const repoRoot = join(root, "repo");
const sessionsDir = join(repoRoot, ".pi", "state", "agent1", "sessions");
mkdirSync(sessionsDir, { recursive: true });
const lastActivity = "2026-09-01T00:00:01Z";
writeFile(
join(sessionsDir, "s.jsonl"),
[
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
messageLine({ timestamp: lastActivity, role: "assistant", stopReason: "stop", texts: ["done"] }),
].join("\n") + "\n"
);
const boardDir = join(dataRoot, "board");
const project = basename(resolve(repoRoot));
markSeen(boardDir, { project, agent: "agent1", lastActivity, seen: true });
const r = runCli(["scan", "--config", configPath, "--repo", repoRoot, "--fleet", "none", "--liveness", "assume-alive", "--print"]);
assert.equal(r.status, 0, r.stderr);
const row = r.stdout.split("\n").find((l) => l.includes("agent1"));
assert.ok(row, `expected an agent1 row in:\n${r.stdout}`);
assert.equal(row[0], "s");
assert.match(r.stdout, /1 seen\)\s*$/m);
});
// ---------------------------------------------------------------------------
// 8. page.html: the seen-toggle control escapes its values and posts JSON
// ---------------------------------------------------------------------------
test("page.html: seenControl() escapes rec.project/agent/lastActivity, and the POST uses a JSON content-type", () => {
const html = readFileSync(join(pkgRoot, "src", "page.html"), "utf8");
const m = html.match(/function seenControl\(rec\) \{[\s\S]*?\n \}/);
assert.ok(m, "seenControl() must exist in page.html");
const body = m[0];
assert.match(body, /esc\(rec\.project\)/);
assert.match(body, /esc\(rec\.agent\)/);
assert.match(body, /esc\(rec\.lastActivity\)/);
assert.match(html, /headers:\s*\{\s*"content-type":\s*"application\/json"\s*\}/, "the seen POST must send content-type: application/json");
});
test("page.html: has a collapsed Seen section that lists seen rows with the shared row builder", () => {
const html = readFileSync(join(pkgRoot, "src", "page.html"), "utf8");
assert.match(html, /<details id="seenDetails">(?![^>]*\sopen)/, "the Seen section must start collapsed");
assert.match(html, /<h2 id="seen-h">Seen<\/h2>/);
const m = html.match(/function renderSeen\(data\) \{[\s\S]*?\n \}/);
assert.ok(m, "renderSeen() must exist in page.html");
assert.match(m[0], /filter\(function \(r\) \{ return r\.seen; \}\)/, "the Seen section lists exactly the rows the API marks seen");
assert.match(m[0], /buildRowPair\(r, true\)/, "seen rows reuse the escaped row builder, project column included");
assert.match(html, /renderWaiting\(lastData\);\n\s*renderSeen\(lastData\);/, "renderAll must render the Seen section on every refresh");
});
test("page.html: each project has a Hide seen checkbox (default on) beside Hide offline, with a hidden-count note", () => {
const html = readFileSync(join(pkgRoot, "src", "page.html"), "utf8");
const m = html.match(/function renderProjects\(data\) \{[\s\S]*?\n \}/);
assert.ok(m, "renderProjects() must exist in page.html");
const body = m[0];
assert.match(body, /if \(!\(project in hideSeenState\)\) hideSeenState\[project\] = true/, "Hide seen defaults to on and the choice survives re-render");
assert.match(body, /class="hide-seen-toggle" data-project="' \+ esc\(project\)/, "the checkbox carries the escaped project name");
assert.match(body, /hideSeen && r\.seen/, "seen rows are filtered when the box is ticked");
assert.match(body, /" seen hidden"/, "the note reports how many seen rows are hidden");
assert.match(html, /closest\("\.hide-offline-toggle, \.hide-seen-toggle"\)/, "one change handler serves both checkboxes");
});
test("page.html: a project header reads \"N of N\" only while a checkbox hides rows", () => {
const html = readFileSync(join(pkgRoot, "src", "page.html"), "utf8");
const m = html.match(/function renderProjects\(data\) \{[\s\S]*?\n \}/);
assert.ok(m, "renderProjects() must exist in page.html");
const body = m[0];
assert.match(
body,
/var headCount = visible\.length < group\.length \? visible\.length \+ " of " \+ group\.length : String\(group\.length\)/,
"shown-of-total only when something is hidden; the plain total otherwise",
);
assert.match(body, /<span>\(' \+ headCount \+ "\)<\/span>/, "the header uses headCount, not the raw group length");
assert.doesNotMatch(body, /<span>\(' \+ group\.length/, "the raw group length must no longer be rendered in the header");
});