Check pi liveness per tmux pane and add "Seen" marks to the control board (#1503)
First step-3 refinement from Jason's daily use. Liveness now lists the panes of the agent's tmux session and counts it alive only if a pane runs pi, so killed pi sessions whose tmux session still exists show offline instead of waiting. A "Seen" button on waiting and error rows stores the row's lastActivity in <dataRoot>/board/seen.json (clicks only, never rewritten by a scan, fail closed if corrupt) and drops the row from "Waiting on you" until the agent writes anything newer; "Unsee" reverses it. New POST /api/seen route: JSON only, 4 KB limit, 400 on bad input. Tests: control-board 63/63 (30 new), registry 69/69. Review APPROVED; receipt docs/plans/reviews/2026-09-12_control-board-step3-seen-marks.md. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
@@ -14,10 +14,15 @@ rewritable; they are not run records and are not evidence.
|
||||
| working | The agent is in the middle of a turn: thinking or running a tool. |
|
||||
| waiting | The agent finished its turn. It is your move now. |
|
||||
| error | The agent's last turn ended in an error, was aborted, or was cut off. Go look at it. |
|
||||
| offline | There is no live tmux session for this agent right now. |
|
||||
| offline | There is no live tmux session for this agent right now, or its session exists but no longer runs `pi`. |
|
||||
| idle | The agent is live but has not had a conversation yet. |
|
||||
| unknown | The scanner could not ask tmux (missing or not answering). It does not assume the agent is alive. |
|
||||
|
||||
Liveness means a pane in the agent's tmux session is actually running `pi`
|
||||
(`tmux list-panes -s -t '=<session>' -F '#{pane_current_command}'`). A tmux session that still
|
||||
exists but only runs bash or some other program counts as offline, not
|
||||
waiting.
|
||||
|
||||
## Commands
|
||||
|
||||
```
|
||||
@@ -42,13 +47,24 @@ bind to anything but a loopback address.
|
||||
- `--liveness tmux|assume-alive` — how to decide if an agent is alive.
|
||||
`tmux` (default) checks the real tmux session. `assume-alive` treats
|
||||
every agent as alive, useful for tests or environments without tmux.
|
||||
- `--print` — (`scan`) also print a one-line-per-agent table to stdout.
|
||||
- `--print` — (`scan`) also print a one-line-per-agent table to stdout. The
|
||||
first column is `*` for a row waiting on you, `s` for a seen row, or blank
|
||||
otherwise. The final summary line reads
|
||||
`board: <dir> (N sessions, N waiting on you, N seen)`.
|
||||
- `--port N` — (`serve`) port to listen on. Default `7331`; `0` picks a free port.
|
||||
- `--host ADDR` — (`serve`) loopback address to bind. Default `127.0.0.1`.
|
||||
Any non-loopback address is refused.
|
||||
|
||||
Routes served: `/` (the page), `/api/board` (rescan, returns `index.json`),
|
||||
`/healthz`.
|
||||
Routes served:
|
||||
|
||||
- `GET /` — the page.
|
||||
- `GET /api/board` — rescan, returns `index.json`.
|
||||
- `POST /api/seen` — mark or unmark a row as seen, then rescan and return
|
||||
`index.json`. Body must be JSON (`Content-Type: application/json`, no
|
||||
more than 4096 bytes): `{"project", "agent", "lastActivity", "seen"}`.
|
||||
`seen` defaults to `true`; pass `false` to unsee. Bad input, a missing
|
||||
header, or an oversized body gets a `400`.
|
||||
- `GET /healthz`.
|
||||
|
||||
## Exit codes
|
||||
|
||||
@@ -66,12 +82,22 @@ node --test packages/control-board/tests/
|
||||
|
||||
```
|
||||
<dataRoot>/board/
|
||||
index.json # summary: counts, waiting-on-you list, all records
|
||||
index.json # summary: counts, waiting-on-you list, seen list, all records
|
||||
seen.json # Jason's "seen" marks (see below)
|
||||
sessions/
|
||||
<project>/
|
||||
<agent>.json # one status record per agent
|
||||
```
|
||||
|
||||
`seen.json` holds `{ "<project>/<agent>": "<lastActivity>" }`. It is written
|
||||
only when Jason clicks "Seen" or "Unsee" on the page (via `POST
|
||||
/api/seen`); a scan reads it but never writes it. A mark applies only while
|
||||
the agent's newest message still has that exact `lastActivity` timestamp —
|
||||
as soon as the agent writes anything new, `lastActivity` changes, the mark
|
||||
no longer matches, and the row falls back into "Waiting on you" on its own.
|
||||
If `seen.json` exists but is not valid JSON (or not an object of string
|
||||
values), the scan refuses rather than silently dropping every mark.
|
||||
|
||||
## Example status record
|
||||
|
||||
```json
|
||||
@@ -80,6 +106,7 @@ node --test packages/control-board/tests/
|
||||
"project": "mosaic-stack",
|
||||
"state": "waiting",
|
||||
"waitingOnYou": true,
|
||||
"seen": false,
|
||||
"alive": true,
|
||||
"tmux": { "socket": null, "session": "darkwing" },
|
||||
"sessionFile": "/mnt/storage/src/mosaic-stack/.pi/state/darkwing/sessions/2026-09-12.jsonl",
|
||||
|
||||
@@ -52,12 +52,12 @@ async function main() {
|
||||
const index = scan(specs, { boardDir, isAlive });
|
||||
if (opts.print) {
|
||||
for (const s of index.sessions) {
|
||||
const flag = s.waitingOnYou ? "*" : " ";
|
||||
const flag = s.waitingOnYou ? "*" : s.seen ? "s" : " ";
|
||||
const age = s.ageSeconds == null ? "-" : `${Math.round(s.ageSeconds / 60)}m`;
|
||||
process.stdout.write(`${flag} ${s.state.padEnd(8)} ${s.project.padEnd(14)} ${s.agent.padEnd(16)} ${age.padStart(7)} ${s.lastAssistantText ? s.lastAssistantText.slice(0, 80) : ""}\n`);
|
||||
}
|
||||
}
|
||||
process.stdout.write(`board: ${boardDir} (${index.sessions.length} sessions, ${index.waitingOnYou.length} waiting on you)\n`);
|
||||
process.stdout.write(`board: ${boardDir} (${index.sessions.length} sessions, ${index.waitingOnYou.length} waiting on you, ${index.seen.length} seen)\n`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
|
||||
@@ -52,6 +52,10 @@
|
||||
.row-toggle{font:inherit;font-weight:600;background:none;border:0;padding:2px 0;color:var(--action);
|
||||
cursor:pointer;text-align:left}
|
||||
.row-toggle:hover{text-decoration:underline}
|
||||
.seen-toggle{font:inherit;font-size:.76rem;background:none;border:1px solid var(--border);border-radius:6px;
|
||||
padding:1px 8px;color:var(--action);cursor:pointer;margin-left:6px;vertical-align:middle}
|
||||
.seen-toggle:hover{border-color:var(--action)}
|
||||
.seen-tag{font-size:.72rem;color:var(--muted);margin-left:6px;vertical-align:middle}
|
||||
.msg-error{color:var(--danger)}
|
||||
.msg-text,.msg-error{display:block;max-width:36ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.detail-row td{background:var(--raised)}
|
||||
@@ -151,6 +155,18 @@
|
||||
return '<span class="badge ' + cls + '">' + esc(state) + "</span>";
|
||||
}
|
||||
|
||||
// A "Seen" button on rows that need you; a "seen" tag plus "Unsee" on rows
|
||||
// you have already marked. The mark clears itself when the agent writes again.
|
||||
function seenControl(rec) {
|
||||
var needsYou = rec.state === "waiting" || rec.state === "error";
|
||||
if (!needsYou || !rec.lastActivity) return "";
|
||||
var attrs = ' data-project="' + esc(rec.project) + '" data-agent="' + esc(rec.agent) + '" data-last="' + esc(rec.lastActivity) + '"';
|
||||
if (rec.seen) {
|
||||
return '<span class="seen-tag">seen</span><button type="button" class="seen-toggle" data-seen="false"' + attrs + ' title="Put this row back under Waiting on you">Unsee</button>';
|
||||
}
|
||||
return '<button type="button" class="seen-toggle" data-seen="true"' + attrs + ' title="I have read this; hide it from Waiting on you until the agent writes again">Seen</button>';
|
||||
}
|
||||
|
||||
function buildRowPair(rec, showProject) {
|
||||
var idx = rowIdx++;
|
||||
var key = (showProject ? "waiting:" : "group:") + [rec.project, rec.agent].join("/");
|
||||
@@ -164,7 +180,7 @@
|
||||
'<tr class="' + cls + '">' +
|
||||
projectCell +
|
||||
'<td><button type="button" class="row-toggle" data-idx="' + idx + '" data-key="' + esc(key) + '" aria-expanded="' + (open ? "true" : "false") + '" aria-controls="detail-' + idx + '">' + esc(rec.agent) + "</button></td>" +
|
||||
"<td>" + badge(rec.state) + "</td>" +
|
||||
"<td>" + badge(rec.state) + seenControl(rec) + "</td>" +
|
||||
"<td>" + esc(humanAge(rec.ageSeconds)) + "</td>" +
|
||||
"<td>" + msg + "</td>" +
|
||||
"</tr>";
|
||||
@@ -250,6 +266,7 @@
|
||||
function renderFooter(data) {
|
||||
var counts = data.counts || {};
|
||||
var parts = STATES.map(function (s) { return cap(s) + " " + (counts[s] || 0); }).join(" · ");
|
||||
parts += " · Seen " + ((data.seen || []).length);
|
||||
footer.innerHTML = "<p>" + esc(parts) + " — generated " + esc(timeAgo(data.generatedAt)) + " (" + esc(data.generatedAt || "") + ")</p>";
|
||||
}
|
||||
|
||||
@@ -320,7 +337,43 @@
|
||||
renderHeaderStatus();
|
||||
}
|
||||
|
||||
function postSeen(btn) {
|
||||
if (fetching) return;
|
||||
fetching = true;
|
||||
btn.disabled = true;
|
||||
fetch("/api/seen", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ project: btn.dataset.project, agent: btn.dataset.agent, lastActivity: btn.dataset.last, seen: btn.dataset.seen === "true" })
|
||||
})
|
||||
.then(function (res) {
|
||||
return res.json().catch(function () {
|
||||
throw new Error("the server sent a response that was not valid JSON");
|
||||
}).then(function (body) {
|
||||
if (!res.ok) throw new Error((body && body.error) || "HTTP " + res.status);
|
||||
return body;
|
||||
});
|
||||
})
|
||||
.then(function (body) {
|
||||
lastData = body;
|
||||
hideError();
|
||||
renderAll();
|
||||
})
|
||||
.catch(function (err) {
|
||||
console.error("marking seen failed", err);
|
||||
showError(err && err.message ? err.message : "unknown error");
|
||||
btn.disabled = false;
|
||||
})
|
||||
.then(function () {
|
||||
fetching = false;
|
||||
renderHeaderStatus();
|
||||
scheduleNextFetch();
|
||||
});
|
||||
}
|
||||
|
||||
main.addEventListener("click", function (e) {
|
||||
var seenBtn = e.target.closest(".seen-toggle");
|
||||
if (seenBtn) return postSeen(seenBtn);
|
||||
var btn = e.target.closest(".row-toggle");
|
||||
if (!btn) return;
|
||||
var detail = document.getElementById("detail-" + btn.dataset.idx);
|
||||
|
||||
@@ -5,11 +5,13 @@
|
||||
// working - the agent is in the middle of a turn (thinking or running tools)
|
||||
// waiting - the agent finished its turn; it is your move
|
||||
// error - the agent's last turn ended in an error, was aborted, or was cut off; look at it
|
||||
// offline - no live tmux session for this agent
|
||||
// offline - no tmux session for this agent, or its session no longer runs pi
|
||||
// idle - the agent is live but has no conversation yet
|
||||
// unknown - liveness could not be checked (tmux missing or unresponsive); not a guess
|
||||
//
|
||||
// Board files are derived and rewritable. They are not run records.
|
||||
// Board files are derived and rewritable. They are not run records. The one
|
||||
// exception is <boardDir>/seen.json, which holds Jason's "seen" marks and is
|
||||
// only changed when he clicks; a scan reads it and never rewrites it.
|
||||
|
||||
import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSync, renameSync } from "node:fs";
|
||||
import { join, basename, isAbsolute, resolve } from "node:path";
|
||||
@@ -108,17 +110,82 @@ export function deriveState({ alive, session }) {
|
||||
return "working";
|
||||
}
|
||||
|
||||
export function tmuxIsAlive({ socket, session }) {
|
||||
// Programs that count as a live pi agent in a tmux pane. A tmux session that
|
||||
// still exists but only runs a shell (or another harness) is not alive: its
|
||||
// pi session log is history, not status.
|
||||
export const PI_COMMANDS = Object.freeze(["pi"]);
|
||||
|
||||
export function panesRunPi(listPanesOutput) {
|
||||
return String(listPanesOutput)
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.some((cmd) => PI_COMMANDS.includes(cmd));
|
||||
}
|
||||
|
||||
// true: a pane in the tmux session runs pi. false: no such session, or no pane
|
||||
// runs pi. null: tmux could not be run at all (reported as "unknown", never
|
||||
// assumed alive). `exec` is injectable for tests.
|
||||
export function tmuxIsAlive({ socket, session }, { exec = spawnSync } = {}) {
|
||||
const args = [];
|
||||
if (socket) args.push("-L", socket);
|
||||
args.push("has-session", "-t", `=${session}`);
|
||||
const r = spawnSync("tmux", args, { encoding: "utf8", timeout: 5000 });
|
||||
args.push("list-panes", "-s", "-t", `=${session}`, "-F", "#{pane_current_command}");
|
||||
const r = exec("tmux", args, { encoding: "utf8", timeout: 5000 });
|
||||
if (r.error) return null;
|
||||
return r.status === 0;
|
||||
if (r.status !== 0) return false;
|
||||
return panesRunPi(r.stdout ?? "");
|
||||
}
|
||||
|
||||
// "Seen" marks: { "<project>/<agent>": "<lastActivity ISO>" }. A mark only
|
||||
// applies while the agent's newest message still has that timestamp; anything
|
||||
// the agent writes afterwards clears it automatically.
|
||||
export function seenKey(rec) {
|
||||
return `${rec.project}/${rec.agent}`;
|
||||
}
|
||||
|
||||
export function seenPath(boardDir) {
|
||||
return join(boardDir, "seen.json");
|
||||
}
|
||||
|
||||
// Fail closed: a present but unreadable seen.json refuses the scan rather than
|
||||
// silently dropping every mark.
|
||||
export function loadSeen(boardDir) {
|
||||
const path = seenPath(boardDir);
|
||||
if (!existsSync(path)) return {};
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(readFileSync(path, "utf8"));
|
||||
} catch (err) {
|
||||
throw new ConfigError(`seen marks file is not valid JSON: ${path} (${err.message})`);
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new ConfigError(`seen marks file must be a JSON object: ${path}`);
|
||||
for (const [k, v] of Object.entries(parsed)) {
|
||||
if (typeof v !== "string") throw new ConfigError(`seen marks file has a non-string value for ${JSON.stringify(k)}: ${path}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function saveSeen(boardDir, marks) {
|
||||
mkdirSync(boardDir, { recursive: true, mode: 0o700 });
|
||||
writeAtomic(seenPath(boardDir), marks);
|
||||
}
|
||||
|
||||
// Set or clear one mark. Returns the updated map.
|
||||
export function markSeen(boardDir, { project, agent, lastActivity, seen = true }) {
|
||||
for (const [name, v] of Object.entries({ project, agent, lastActivity })) {
|
||||
if (typeof v !== "string" || v.length === 0 || v.length > 512) throw new ConfigError(`${name} must be a non-empty string`);
|
||||
}
|
||||
if (project.includes("/")) throw new ConfigError("project must not contain '/'");
|
||||
if (typeof seen !== "boolean") throw new ConfigError("seen must be true or false");
|
||||
const marks = loadSeen(boardDir);
|
||||
const key = seenKey({ project, agent });
|
||||
if (seen) marks[key] = lastActivity;
|
||||
else delete marks[key];
|
||||
saveSeen(boardDir, marks);
|
||||
return marks;
|
||||
}
|
||||
|
||||
// One agent -> one status record.
|
||||
export function scanAgent(spec, { isAlive = tmuxIsAlive, now = () => new Date() } = {}) {
|
||||
export function scanAgent(spec, { isAlive = tmuxIsAlive, now = () => new Date(), seen = {} } = {}) {
|
||||
const alive = isAlive(spec.tmux);
|
||||
const file = findNewestSession(spec.sessionsDir);
|
||||
const session = file ? readSession(file) : null;
|
||||
@@ -126,11 +193,14 @@ export function scanAgent(spec, { isAlive = tmuxIsAlive, now = () => new Date()
|
||||
const scannedAt = now();
|
||||
const lastActivity = session?.lastTimestamp ?? null;
|
||||
const ageSeconds = lastActivity ? Math.max(0, Math.round((scannedAt.getTime() - Date.parse(lastActivity)) / 1000)) : null;
|
||||
const needsYou = state === "waiting" || state === "error";
|
||||
const isSeen = needsYou && lastActivity !== null && seen[seenKey(spec)] === lastActivity;
|
||||
return {
|
||||
agent: spec.agent,
|
||||
project: spec.project,
|
||||
state,
|
||||
waitingOnYou: state === "waiting" || state === "error",
|
||||
waitingOnYou: needsYou && !isSeen,
|
||||
seen: isSeen,
|
||||
alive,
|
||||
tmux: spec.tmux,
|
||||
sessionFile: file,
|
||||
@@ -174,7 +244,8 @@ function writeAtomic(path, data) {
|
||||
// Scan every spec and write <boardDir>/sessions/<project>/<agent>.json plus index.json.
|
||||
export function scan(specs, { boardDir, isAlive, now } = {}) {
|
||||
if (!boardDir || !isAbsolute(boardDir)) throw new ConfigError("boardDir must be an absolute path");
|
||||
const records = specs.map((spec) => scanAgent(spec, { isAlive, now }));
|
||||
const seen = loadSeen(boardDir);
|
||||
const records = specs.map((spec) => scanAgent(spec, { isAlive, now, seen }));
|
||||
for (const rec of records) {
|
||||
const dir = join(boardDir, "sessions", rec.project);
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
@@ -184,7 +255,8 @@ export function scan(specs, { boardDir, isAlive, now } = {}) {
|
||||
const index = {
|
||||
generatedAt,
|
||||
counts: Object.fromEntries(STATES.map((s) => [s, records.filter((r) => r.state === s).length])),
|
||||
waitingOnYou: records.filter((r) => r.waitingOnYou).map((r) => `${r.project}/${r.agent}`),
|
||||
waitingOnYou: records.filter((r) => r.waitingOnYou).map(seenKey),
|
||||
seen: records.filter((r) => r.seen).map(seenKey),
|
||||
sessions: records,
|
||||
};
|
||||
mkdirSync(boardDir, { recursive: true, mode: 0o700 });
|
||||
|
||||
@@ -3,8 +3,14 @@
|
||||
//
|
||||
// GET / the page (src/page.html)
|
||||
// GET /api/board re-runs the scanner and returns index.json as JSON
|
||||
// POST /api/seen {project, agent, lastActivity, seen?} marks a row as seen
|
||||
// (or clears the mark with seen:false), rescans, returns index
|
||||
// GET /healthz {"ok":true}
|
||||
//
|
||||
// POST requires Content-Type: application/json. A plain form post from another
|
||||
// site in the browser cannot set that header without a CORS preflight, and this
|
||||
// server answers no preflight, so a stray page cannot flip marks.
|
||||
//
|
||||
// Every /api/board request rescans, so the page is never staler than its
|
||||
// refresh timer. The scan rewrites the derived board files as a side effect.
|
||||
|
||||
@@ -12,7 +18,42 @@ import { createServer as createHttpServer } from "node:http";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { isIP } from "node:net";
|
||||
import { scan, ConfigError } from "./scan.mjs";
|
||||
import { scan, markSeen, ConfigError } from "./scan.mjs";
|
||||
|
||||
const MAX_BODY = 4096;
|
||||
|
||||
function sendJson(res, status, body) {
|
||||
res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
|
||||
res.end(JSON.stringify(body) + "\n");
|
||||
}
|
||||
|
||||
function readJsonBody(req) {
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
const type = String(req.headers["content-type"] || "").split(";")[0].trim().toLowerCase();
|
||||
if (type !== "application/json") return reject(new Error("Content-Type must be application/json"));
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
req.on("data", (c) => {
|
||||
size += c.length;
|
||||
if (size > MAX_BODY) {
|
||||
req.destroy();
|
||||
reject(new Error(`body larger than ${MAX_BODY} bytes`));
|
||||
return;
|
||||
}
|
||||
chunks.push(c);
|
||||
});
|
||||
req.on("end", () => {
|
||||
try {
|
||||
const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("body must be a JSON object");
|
||||
resolvePromise(parsed);
|
||||
} catch (err) {
|
||||
reject(new Error(`invalid JSON body: ${err.message}`));
|
||||
}
|
||||
});
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
const LOOPBACK = new Set(["127.0.0.1", "::1", "localhost"]);
|
||||
|
||||
@@ -29,6 +70,22 @@ export function loadPage(path = join(import.meta.dirname, "page.html")) {
|
||||
export function createServer({ specs, boardDir, isAlive, now, page = loadPage() }) {
|
||||
return createHttpServer((req, res) => {
|
||||
const url = new URL(req.url, "http://localhost");
|
||||
if (req.method === "POST" && url.pathname === "/api/seen") {
|
||||
return readJsonBody(req)
|
||||
.then((body) => {
|
||||
try {
|
||||
markSeen(boardDir, { project: body.project, agent: body.agent, lastActivity: body.lastActivity, seen: body.seen ?? true });
|
||||
} catch (err) {
|
||||
return sendJson(res, 400, { error: err.message });
|
||||
}
|
||||
try {
|
||||
sendJson(res, 200, scan(specs, { boardDir, isAlive, now }));
|
||||
} catch (err) {
|
||||
sendJson(res, 500, { error: err.message });
|
||||
}
|
||||
})
|
||||
.catch((err) => sendJson(res, 400, { error: err.message }));
|
||||
}
|
||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||
res.writeHead(405, { "content-type": "text/plain" });
|
||||
return res.end("method not allowed\n");
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
existsSync,
|
||||
statSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve, basename } from "node:path";
|
||||
@@ -23,6 +24,10 @@ import {
|
||||
discoverRepoAgents,
|
||||
discoverFleetAgents,
|
||||
scan,
|
||||
panesRunPi,
|
||||
tmuxIsAlive,
|
||||
loadSeen,
|
||||
markSeen,
|
||||
} from "../src/scan.mjs";
|
||||
|
||||
const pkgRoot = resolve(import.meta.dirname, "..");
|
||||
@@ -464,3 +469,229 @@ test("CLI: unknown --liveness value exits 2", () => {
|
||||
assert.equal(r.status, 2);
|
||||
assert.match(r.stderr, /^refused:/);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 9. panesRunPi / tmuxIsAlive: liveness means a pane actually runs pi
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("panesRunPi: true when any trimmed line equals 'pi'", () => {
|
||||
assert.equal(panesRunPi("bash\npi\n"), true);
|
||||
assert.equal(panesRunPi("pi"), true);
|
||||
assert.equal(panesRunPi(" pi \n"), true);
|
||||
});
|
||||
|
||||
test("panesRunPi: false for bash-only, claude, empty, or node-pi-style lines", () => {
|
||||
assert.equal(panesRunPi("bash"), false);
|
||||
assert.equal(panesRunPi("claude"), false);
|
||||
assert.equal(panesRunPi(""), false);
|
||||
assert.equal(panesRunPi("node pi"), false);
|
||||
});
|
||||
|
||||
function fakeExec(result) {
|
||||
const calls = [];
|
||||
const exec = (cmd, args, opts) => {
|
||||
calls.push({ cmd, args, opts });
|
||||
return result;
|
||||
};
|
||||
exec.calls = calls;
|
||||
return exec;
|
||||
}
|
||||
|
||||
test("tmuxIsAlive: a pane running pi is alive", () => {
|
||||
const exec = fakeExec({ status: 0, stdout: "bash\npi\n" });
|
||||
assert.equal(tmuxIsAlive({ socket: null, session: "a" }, { exec }), true);
|
||||
});
|
||||
|
||||
test("tmuxIsAlive: session exists but pi has exited is not alive", () => {
|
||||
const exec = fakeExec({ status: 0, stdout: "bash\n" });
|
||||
assert.equal(tmuxIsAlive({ socket: null, session: "a" }, { exec }), false);
|
||||
});
|
||||
|
||||
test("tmuxIsAlive: no such tmux session is not alive", () => {
|
||||
const exec = fakeExec({ status: 1, stdout: "" });
|
||||
assert.equal(tmuxIsAlive({ socket: null, session: "a" }, { exec }), false);
|
||||
});
|
||||
|
||||
test("tmuxIsAlive: tmux could not be run at all is unknown (null), never assumed alive", () => {
|
||||
const exec = fakeExec({ error: new Error("ENOENT") });
|
||||
assert.equal(tmuxIsAlive({ socket: null, session: "a" }, { exec }), null);
|
||||
});
|
||||
|
||||
test("tmuxIsAlive: passes -L <socket> only when a socket is given", () => {
|
||||
const withSocket = fakeExec({ status: 0, stdout: "pi\n" });
|
||||
tmuxIsAlive({ socket: "mosaic-fleet", session: "name" }, { exec: withSocket });
|
||||
assert.equal(withSocket.calls[0].cmd, "tmux");
|
||||
assert.deepEqual(withSocket.calls[0].args, ["-L", "mosaic-fleet", "list-panes", "-s", "-t", "=name", "-F", "#{pane_current_command}"]);
|
||||
|
||||
const noSocket = fakeExec({ status: 0, stdout: "pi\n" });
|
||||
tmuxIsAlive({ socket: null, session: "name" }, { exec: noSocket });
|
||||
assert.deepEqual(noSocket.calls[0].args, ["list-panes", "-s", "-t", "=name", "-F", "#{pane_current_command}"]);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 10. Seen marks: loadSeen / markSeen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("loadSeen: missing file returns {}", () => {
|
||||
const root = makeRoot();
|
||||
assert.deepEqual(loadSeen(root), {});
|
||||
});
|
||||
|
||||
test("loadSeen: invalid JSON throws ConfigError", () => {
|
||||
const root = makeRoot();
|
||||
writeFile(join(root, "seen.json"), "{ not json");
|
||||
assert.throws(() => loadSeen(root), ConfigError);
|
||||
});
|
||||
|
||||
test("loadSeen: a JSON array throws ConfigError", () => {
|
||||
const root = makeRoot();
|
||||
writeFile(join(root, "seen.json"), "[]");
|
||||
assert.throws(() => loadSeen(root), ConfigError);
|
||||
});
|
||||
|
||||
test("loadSeen: a non-string value throws ConfigError", () => {
|
||||
const root = makeRoot();
|
||||
writeFile(join(root, "seen.json"), JSON.stringify({ "p/a": 123 }));
|
||||
assert.throws(() => loadSeen(root), ConfigError);
|
||||
});
|
||||
|
||||
test("markSeen: seen true adds the key and writes seen.json mode 0600, no leftover tmp files", () => {
|
||||
const root = makeRoot();
|
||||
const boardDir = join(root, "board");
|
||||
const marks = markSeen(boardDir, { project: "p", agent: "a", lastActivity: "2026-09-01T00:00:00Z", seen: true });
|
||||
assert.deepEqual(marks, { "p/a": "2026-09-01T00:00:00Z" });
|
||||
const path = join(boardDir, "seen.json");
|
||||
assert.ok(existsSync(path));
|
||||
assert.equal(statSync(path).mode & 0o777, 0o600);
|
||||
const names = readdirSync(boardDir);
|
||||
assert.ok(!names.some((n) => n.includes(".tmp-")), `leftover tmp file among: ${names.join(", ")}`);
|
||||
});
|
||||
|
||||
test("markSeen: seen false deletes the key", () => {
|
||||
const root = makeRoot();
|
||||
const boardDir = join(root, "board");
|
||||
markSeen(boardDir, { project: "p", agent: "a", lastActivity: "t1", seen: true });
|
||||
const marks = markSeen(boardDir, { project: "p", agent: "a", lastActivity: "t1", seen: false });
|
||||
assert.deepEqual(marks, {});
|
||||
});
|
||||
|
||||
test("markSeen: missing, empty, or non-string fields throw ConfigError", () => {
|
||||
const root = makeRoot();
|
||||
const boardDir = join(root, "board");
|
||||
assert.throws(() => markSeen(boardDir, { project: "p", agent: "a", lastActivity: "" }), ConfigError);
|
||||
assert.throws(() => markSeen(boardDir, { project: "p", agent: "a" }), ConfigError);
|
||||
assert.throws(() => markSeen(boardDir, { project: "p", agent: 5, lastActivity: "t" }), ConfigError);
|
||||
assert.throws(() => markSeen(boardDir, { agent: "a", lastActivity: "t" }), ConfigError);
|
||||
});
|
||||
|
||||
test("markSeen: project containing '/' throws ConfigError", () => {
|
||||
const root = makeRoot();
|
||||
const boardDir = join(root, "board");
|
||||
assert.throws(() => markSeen(boardDir, { project: "p/x", agent: "a", lastActivity: "t" }), ConfigError);
|
||||
});
|
||||
|
||||
test("markSeen: non-boolean seen throws ConfigError", () => {
|
||||
const root = makeRoot();
|
||||
const boardDir = join(root, "board");
|
||||
assert.throws(() => markSeen(boardDir, { project: "p", agent: "a", lastActivity: "t", seen: "true" }), ConfigError);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 11. scanAgent: seen marks apply only to waiting/error, and only while the
|
||||
// mark's timestamp still matches the agent's newest message
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("scanAgent: a seen mark matching the waiting session's lastTimestamp clears waitingOnYou", () => {
|
||||
const root = makeRoot();
|
||||
const sessionsDir = join(root, "sessions");
|
||||
mkdirSync(sessionsDir, { recursive: true });
|
||||
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 seen = { "p/a": "2026-09-01T00:00:01Z" };
|
||||
const rec = scanAgent({ agent: "a", project: "p", sessionsDir, tmux: {} }, { isAlive: () => true, now: () => new Date("2026-09-01T00:01:00Z"), seen });
|
||||
assert.equal(rec.state, "waiting");
|
||||
assert.equal(rec.seen, true);
|
||||
assert.equal(rec.waitingOnYou, false);
|
||||
});
|
||||
|
||||
test("scanAgent: a stale mark (agent wrote something newer) is not seen and waitingOnYou is true", () => {
|
||||
const root = makeRoot();
|
||||
const sessionsDir = join(root, "sessions");
|
||||
mkdirSync(sessionsDir, { recursive: true });
|
||||
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 seen = { "p/a": "2026-09-01T00:00:00Z" }; // stale: older than lastTimestamp
|
||||
const rec = scanAgent({ agent: "a", project: "p", sessionsDir, tmux: {} }, { isAlive: () => true, now: () => new Date("2026-09-01T00:01:00Z"), seen });
|
||||
assert.equal(rec.state, "waiting");
|
||||
assert.equal(rec.seen, false);
|
||||
assert.equal(rec.waitingOnYou, true);
|
||||
});
|
||||
|
||||
test("scanAgent: a working session with a matching mark is not seen (marks only apply to waiting/error)", () => {
|
||||
const root = makeRoot();
|
||||
const sessionsDir = join(root, "sessions");
|
||||
mkdirSync(sessionsDir, { recursive: true });
|
||||
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: "toolUse", texts: ["thinking"] }),
|
||||
]);
|
||||
const seen = { "p/a": "2026-09-01T00:00:01Z" };
|
||||
const rec = scanAgent({ agent: "a", project: "p", sessionsDir, tmux: {} }, { isAlive: () => true, now: () => new Date("2026-09-01T00:01:00Z"), seen });
|
||||
assert.equal(rec.state, "working");
|
||||
assert.equal(rec.seen, false);
|
||||
assert.equal(rec.waitingOnYou, false);
|
||||
});
|
||||
|
||||
test("scanAgent: an error-state session with a matching mark is seen", () => {
|
||||
const root = makeRoot();
|
||||
const sessionsDir = join(root, "sessions");
|
||||
mkdirSync(sessionsDir, { recursive: true });
|
||||
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: "aborted" }),
|
||||
]);
|
||||
const seen = { "p/a": "2026-09-01T00:00:01Z" };
|
||||
const rec = scanAgent({ agent: "a", project: "p", sessionsDir, tmux: {} }, { isAlive: () => true, now: () => new Date("2026-09-01T00:01:00Z"), seen });
|
||||
assert.equal(rec.state, "error");
|
||||
assert.equal(rec.seen, true);
|
||||
assert.equal(rec.waitingOnYou, false);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 12. scan: reads seen.json, index reflects it, and scanning never rewrites it
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("scan: index.seen and waitingOnYou reflect seen.json, which scan never rewrites or deletes", () => {
|
||||
const root = makeRoot();
|
||||
const boardDir = join(root, "board");
|
||||
const sessionsDir = join(root, "sessions");
|
||||
mkdirSync(sessionsDir, { recursive: true });
|
||||
const lastActivity = "2026-09-01T00:00:01Z";
|
||||
writeSessionFile(sessionsDir, "s.jsonl", [
|
||||
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
|
||||
messageLine({ timestamp: lastActivity, role: "assistant", stopReason: "stop", texts: ["done"] }),
|
||||
]);
|
||||
mkdirSync(boardDir, { recursive: true });
|
||||
const seenPath = join(boardDir, "seen.json");
|
||||
writeFile(seenPath, JSON.stringify({ "p/agent1": lastActivity }));
|
||||
const before = readFileSync(seenPath);
|
||||
|
||||
const index = scan([{ agent: "agent1", project: "p", sessionsDir, tmux: {} }], { boardDir, isAlive: () => true, now: () => new Date("2026-09-01T00:01:00Z") });
|
||||
assert.deepEqual(index.seen, ["p/agent1"]);
|
||||
assert.deepEqual(index.waitingOnYou, []);
|
||||
|
||||
const after = readFileSync(seenPath);
|
||||
assert.deepEqual(before, after, "scan must never rewrite seen.json");
|
||||
});
|
||||
|
||||
test("scan: a corrupt seen.json makes scan throw ConfigError (fail closed)", () => {
|
||||
const root = makeRoot();
|
||||
const boardDir = join(root, "board");
|
||||
mkdirSync(boardDir, { recursive: true });
|
||||
writeFile(join(boardDir, "seen.json"), "{ bad json");
|
||||
assert.throws(() => scan([], { boardDir, isAlive: () => true }), ConfigError);
|
||||
});
|
||||
|
||||
@@ -9,10 +9,10 @@ import {
|
||||
existsSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { join, resolve, basename } from "node:path";
|
||||
import { spawnSync, spawn } from "node:child_process";
|
||||
import { createServer as createNetServer } from "node:net";
|
||||
import { ConfigError } from "../src/scan.mjs";
|
||||
import { ConfigError, markSeen } from "../src/scan.mjs";
|
||||
import { isLoopbackHost, startServer } from "../src/serve.mjs";
|
||||
|
||||
const pkgRoot = resolve(import.meta.dirname, "..");
|
||||
@@ -336,3 +336,202 @@ test("page.html: esc() escapes every HTML-significant character", () => {
|
||||
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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user