Each project table now has "Hide seen" beside "Hide offline", both on by default, with a note saying how many rows each one hides. The choice survives the 10-second refresh. Static test pins the markup, the filter, the persistence guard, and the change handler. Sonnet review: APPROVED. Co-Authored-By: Claude Fable 5.1 <[email protected]>
561 lines
23 KiB
JavaScript
561 lines
23 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>&\''), "<script>alert("x")</script>&'");
|
|
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");
|
|
});
|