Compare commits

..
Author SHA1 Message Date
jason.woltje 9b8533c0c8 row 198 r5: assertion diff for Mos's read (review file only) 2026-10-24 23:25:38 -05:00
4 changed files with 631 additions and 132 deletions

No files matched your search

@@ -0,0 +1,628 @@
# Row 198 r5: assertion diff
Status: uncommitted working-tree review file. Nothing here has been committed or pushed, and no test has been run (CLEAR pending).
- Base: `78b69e1e6` (refactor). Rebase paused on pick 1 (`4288b7b16`). Commit 1 is staged; later edits are in the working tree.
- Groups: A = tests that existed on refactor before row 198 (Mos reads these, mos-1272). B = tests row 198 adds (Sage rules).
- Hunks: 24 group A, 3 group B. Changed assert lines: 230 removed, 61 added (hunk sums; the file-level diff includes non-assert lines).
- Retired test names: 36 (list below).
Labels are from line-range attribution. A removed block can span several old tests, so the enclosing-test label is the first match only; the reason and the diff text are authoritative.
## Group A (existed on refactor before row 198)
### A1: control-board/tests/discord.test.mjs
Enclosing test (first match, old or new side): server rescans connector discovery and refuses HTTP reply without transport
Reason: Port: the HTTP /api/board fetch becomes board.index(); the 409 is the status board.reply returns in-process. Kept: state, connector.alive, registered null, forged task not used.
```diff
- assert.equal(board.sessions[0].state, 'idle'); assert.equal(board.sessions[0].connector.alive, true);
- assert.equal(board.sessions[0].registered, null); assert.notEqual(board.sessions[0].task, 'forged');
- assert.equal(response.status, 409);
+ assert.equal(payload.sessions[0].state, 'idle'); assert.equal(payload.sessions[0].connector.alive, true);
+ assert.equal(payload.sessions[0].registered, null); assert.notEqual(payload.sessions[0].task, 'forged');
+ assert.equal(status, 409);
```
### A2: control-board/tests/discord.test.mjs
Enclosing test (first match, old or new side): server rescans connector discovery and refuses HTTP reply without transport
Reason: Port: the HTTP /api/board fetch becomes board.index(); zero-sessions assert unchanged.
```diff
- assert.equal((await (await fetch(base + '/api/board')).json()).sessions.length, 0);
+ assert.equal((await board.index()).sessions.length, 0);
```
### A4: control-board/tests/serve.test.mjs
Enclosing test (first match, old or new side): isLoopbackHost: rejects non-loopback hosts
Reason: Retired writer HTTP asserts: page content-type and cache-control, healthz body, 404/405 and the 500 envelope. The writer opens no listener now. index.json and per-agent write asserts are not re-asserted here (gap).
```diff
- assert.ok(caught instanceof ConfigError, "expected a ConfigError");
- 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);
- assert.equal(health.status, 200);
- assert.deepEqual(await health.json(), { ok: true });
- assert.equal(first.status, 200);
- assert.equal(first.headers.get("content-type"), "application/json");
- assert.equal(first.headers.get("cache-control"), "no-store");
- assert.equal(firstBody.sessions[0].state, "idle");
- 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");
- assert.equal(second.status, 200);
- assert.equal(secondBody.sessions[0].state, "working");
- assert.equal(missing.status, 404);
- assert.equal(posted.status, 405);
- assert.equal(res.status, 200);
- assert.equal(body.sessions[0].taskSource, "registration");
- assert.equal(body.sessions[0].task, "registered task");
- assert.deepEqual(body.registered, ["proj/agent1"]);
- assert.deepEqual(body.registrationErrors, []);
- assert.equal(res.status, 500);
- assert.equal(res.headers.get("content-type"), "application/json");
- assert.equal(typeof body.error, "string");
- assert.ok(body.error.length > 0);
```
### A5: control-board/tests/serve.test.mjs
Enclosing test (first match, old or new side): CLI: serve refuses a non-loopback host with exit 2 and a refused: message
Reason: Retired with serve: CLI refusal now asserted by cli.mjs:14 via board.test.mjs (group B hunk 0).
```diff
- assert.equal(r.status, 2);
- assert.match(r.stderr, /^refused:/);
```
### A6: control-board/tests/serve.test.mjs
Enclosing test (first match, old or new side): CLI: live serve prints its URL and answers /healthz
Reason: Retired page.html source checks (esc, seen POST, healthz) and HTTP seen round-trip. page.html is deleted; markSeen order is in board.test.mjs. The esc() asserts have no in-process target (open gap).
```diff
- assert.equal(res.status, 200);
- assert.deepEqual(await res.json(), { ok: true });
- assert.ok(m, "esc() must exist in page.html");
- 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");
- assert.deepEqual(rawJoins, [], `API values concatenated into HTML without esc(): ${rawJoins.join(" | ")}`);
- assert.ok(escCalls >= 15, `expected many esc() calls, saw ${escCalls}`);
- assert.equal(postRes.status, 200);
- assert.ok(postBody.seen.includes("proj/agent1"));
- assert.ok(!postBody.waitingOnYou.includes("proj/agent1"));
- assert.equal(boardRes.status, 200);
- assert.ok(boardBody.seen.includes("proj/agent1"));
- assert.ok(!boardBody.waitingOnYou.includes("proj/agent1"));
- assert.equal(clearRes.status, 200);
- assert.ok(!clearBody.seen.includes("proj/agent1"));
- assert.ok(clearBody.waitingOnYou.includes("proj/agent1"));
- assert.equal(res.status, 400);
- assert.ok(!existsSync(join(boardDir, "seen.json")), "seen.json must not be written on a rejected content-type");
- assert.equal(res.status, 400);
- assert.ok(!existsSync(join(boardDir, "seen.json")));
- if (status !== null) assert.equal(status, 400);
- assert.ok(!existsSync(join(boardDir, "seen.json")), "seen.json must not be written for an oversized body");
- assert.equal(res.status, 400);
- assert.ok(!existsSync(join(boardDir, "seen.json")));
- assert.equal(board.status, 405);
- assert.equal(put.status, 405);
```
### A7: control-board/tests/serve.test.mjs
Enclosing test (first match, old or new side): CLI: scan --print marks a seen row with 's' and the summary line ends with 'N seen)'
Reason: Retired page.html source checks only (seenControl, Seen section, Hide seen, sourceTag escapes). No verified Console equivalent in this pass (open gap).
```diff
- assert.ok(m, "seenControl() must exist in page.html");
- 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");
- assert.match(html, /<details id="seenDetails">(?![^>]*\sopen)/, "the Seen section must start collapsed");
- assert.match(html, /<h2 id="seen-h">Seen<\/h2>/);
- 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");
- assert.ok(m, "renderProjects() must exist in page.html");
- 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");
- assert.ok(m, "renderProjects() must exist in page.html");
- assert.match(
- 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");
- assert.ok(m, "buildRowPair() must exist in page.html");
- assert.match(body, /rec\.task\s*\?[\s\S]*esc\(rec\.task\)[\s\S]*:\s*'<span class="unknown">unknown<\/span>'/, "task is escaped, or reads unknown when null");
- assert.match(body, /rec\.activeProject\s*\?[\s\S]*esc\(rec\.activeProject\)[\s\S]*unknown/, "active project is escaped, or reads unknown when null");
- assert.match(body, /<dt>Task<\/dt>/, "detail lists the task");
- assert.match(body, /<dt>Active project<\/dt>/, "detail lists the active project");
- assert.match(body, /<dt>Workspace<\/dt><dd>" \+ \(rec\.workspace \? esc\(rec\.workspace\) : "unknown"\)/, "detail lists the workspace, escaped, or unknown");
- assert.match(body, /var span = showProject \? 7 : 6/, "the detail row spans the two new columns");
- assert.equal(heads.length, 3, "all three tables carry the two new headers");
- assert.match(html, /<td colspan="6" class="empty">No agents\.<\/td>/, "the empty project row spans every column");
- assert.ok(rowPair, "buildRowPair() must exist in page.html");
- assert.match(rowPair[0], /sourceTag\(rec\.taskSource\)/, "the task cell shows its source");
- assert.match(rowPair[0], /sourceTag\(rec\.activeProjectSource\)/, "the active project cell shows its source");
- assert.match(
- assert.match(rowPair[0], /esc\(rec\.agent\) \+ "<\/button>" \+ modelTag\(rec\)/, "the agent cell shows the model under the name");
- assert.match(rowPair[0], /<dt>Model<\/dt><dd>" \+ modelText\(rec\) \+ "<\/dd>/, "the detail list has a Model row");
- assert.ok(line, `${name}() must exist in page.html`);
- assert.match(line, /esc\(rec\.model\)/, `${name}() escapes the model`);
- if (name === "modelText") assert.match(line, /esc\(rec\.provider\)/, "modelText() escapes the provider");
- assert.ok(sourceLabelMatch, "SOURCE_LABEL must exist in page.html");
- assert.equal(sourceLabel.registration, "registered", 'SOURCE_LABEL must map "registration" to "registered"');
- assert.ok(sourceTagLine, "sourceTag() must exist in page.html");
- assert.match(sourceTagLine, /esc\(source\)/, "sourceTag() escapes the raw source value (used in the title)");
- assert.match(sourceTagLine, /esc\(sourceLabel\(source\)\)/, "sourceTag() escapes the label text it displays");
- assert.ok(fromSourceLine, "fromSource() must exist in page.html");
- assert.match(fromSourceLine, /esc\(sourceLabel\(source\)\)/, "fromSource() escapes the label text it displays");
- assert.ok(registeredTextFn, "registeredText() must exist in page.html");
- assert.match(line, /esc\(/, `every dynamic value read off reg must be escaped: ${line.trim()}`);
```
### A8: control-board/tests/serve.test.mjs
Enclosing test (first match, old or new side): every refusal code the reader can raise has an HTTP status
Reason: Moved in, not weakened: REFUSAL_STATUS table and code-count asserts from the retired conversation test (hunk 15), plus the DEFAULT_AGENT_SEND path asserts (hunk 12).
```diff
+ assert.equal(DEFAULT_AGENT_SEND, resolve(pkgRoot, "..", "..", "tools", "tmux", "agent-send.sh"));
+ assert.ok(existsSync(DEFAULT_AGENT_SEND));
+ assert.ok(statSync(DEFAULT_AGENT_SEND).mode & 0o111);
+ assert.ok(codes.size >= 15, [...codes].join(" "));
+ assert.deepEqual([...codes].filter((c) => !(c in REFUSAL_STATUS)), []);
+ assert.deepEqual(Object.keys(REFUSAL_STATUS).filter((c) => !codes.has(c)), [], "no stale entries");
+ assert.deepEqual(REFUSAL_STATUS, {
```
### A9: control-board/tests/serve.test.mjs
Enclosing test (first match, old or new side): POST /api/reply: a registration with a tmux socket adds -L <socket>
Reason: Re-indent only: the same -L socket asserts move into the board.reply closure (identical text after trim).
```diff
- assert.equal(status, 200);
- assert.equal(body.socket, "mosaic-fleet");
- assert.equal(argv[argv.indexOf("-L") + 1], "mosaic-fleet");
+ assert.equal(status, 200);
+ assert.equal(body.socket, "mosaic-fleet");
+ assert.equal(argv[argv.indexOf("-L") + 1], "mosaic-fleet");
```
### A10: control-board/tests/serve.test.mjs
Enclosing test (first match, old or new side): POST /api/reply: refusals before the tool runs: empty or blank or long text 400, unknown row 404, no registration 409, stale registration 409, no tmux session 409, bad JSON 400; the tool is never called
Reason: Re-indent only: the refusal asserts move into the ported reply test; the tool-not-run check is unchanged.
```diff
- assert.equal(status, c.status, `${JSON.stringify(c.body).slice(0, 60)}: ${JSON.stringify(body)}`);
- assert.match(body.error, c.error);
- assert.equal(existsSync(f.capture), false, "the tool must not run on a refusal");
+ assert.equal(status, c.status, `${JSON.stringify(c.body).slice(0, 60)}: ${JSON.stringify(body)}`);
+ assert.match(body.error, c.error);
+ assert.equal(existsSync(f.capture), false, "the tool must not run on a refusal");
```
### A11: control-board/tests/serve.test.mjs
Enclosing test (first match, old or new side): POST /api/reply: a missing agent-send.sh is a 500 with the path in the error, not a crash
Reason: Re-indent only for the 500 and path asserts. The healthz status line is dropped with the HTTP server.
```diff
- assert.equal(status, 500);
- assert.match(body.error, /missing-agent-send\.sh/);
- assert.equal(health.status, 200);
+ assert.equal(status, 500);
+ assert.match(body.error, /missing-agent-send\.sh/);
```
### A12: control-board/tests/serve.test.mjs
Enclosing test (first match, old or new side): replyToRow: DEFAULT_AGENT_SEND is the repository's tools/tmux/agent-send.sh and it is executable
Reason: Moved, not weakened: DEFAULT_AGENT_SEND asserts move to the refusal-table test (hunk 8).
```diff
- assert.equal(DEFAULT_AGENT_SEND, resolve(pkgRoot, "..", "..", "tools", "tmux", "agent-send.sh"));
- assert.ok(existsSync(DEFAULT_AGENT_SEND));
- assert.ok(statSync(DEFAULT_AGENT_SEND).mode & 0o111);
```
### A13: control-board/tests/serve.test.mjs
Enclosing test (first match, old or new side): page.html: the reply box appears only where canReply() holds (live registration with a tmux session), the detail has a Reply row, the submit posts JSON to /api/reply, receipts and drafts survive a refresh, and every receipt value is escaped
Reason: Retired page.html source checks for the reply box (canReply, replyControl, postReply, receipts). Reply gate behaviour is covered in the Console by browser-edge.test (reply needs a registered seat, line 61; in-flight reply).
```diff
- assert.ok(canReplyFn, "canReply() must exist");
- assert.match(canReplyFn[0], /reg\.alive !== false/, "a stale registration gets no box");
- assert.match(canReplyFn[0], /reg\.tmux && reg\.tmux\.session/, "a registration without a tmux session gets no box");
- assert.ok(replyControlFn, "replyControl() must exist");
- assert.match(replyControlFn[0], /if \(!canReply\(rec\)\) return '<span class="unknown">reply needs a registered seat<\/span>'/);
- assert.match(replyControlFn[0], /class="reply-form" data-id="' \+ esc\(id\)/);
- assert.match(replyControlFn[0], /value="' \+ esc\(replyDrafts\[id\] \|\| ""\)/, "an unsent draft is put back after a refresh");
- assert.match(rowPair[0], /<dt>Reply<\/dt><dd>" \+ replyControl\(rec\) \+ "<\/dd>/);
- assert.ok(receiptFn, "replyReceipt() must exist");
- assert.match(line, /esc\(/, `every receipt value must be escaped: ${line.trim()}`);
- assert.match(receiptFn[0], /esc\(r\.stderr \|\| r\.error/, "a failure shows the tool's stderr");
- assert.ok(postReplyFn, "postReply() must exist");
- assert.match(postReplyFn[0], /fetch\("\/api\/reply", \{\s*method: "POST",\s*headers: \{ "content-type": "application\/json" \}/);
- assert.match(postReplyFn[0], /JSON\.stringify\(\{ agent: id, text: text \}\)/);
- assert.match(postReplyFn[0], /replyReceipts\[id\] = body/);
- assert.ok(lines.some((l) => l.includes('main.addEventListener("submit"')), "the form submits through one delegated handler");
- assert.ok(lines.some((l) => l.includes('main.addEventListener("input"')), "drafts are captured as they are typed");
- assert.match(html, /function withReplyFocus\(render\)/, "a refresh gives the caret back to the reply box");
- assert.doesNotMatch(html, /send-keys/, "the page never talks tmux");
+ // Ported from the writer's HTTP test (refactor 78b69e1e6): the same assertions over board.index().
```
### A14: control-board/tests/serve.test.mjs
Enclosing test (first match, old or new side): startServer: /api/board carries taskSetBy from a live registration and null for the derived rows
Reason: Ported: byAgent taskSource, taskSetBy and registrationErrors asserts kept on board.index() (test renamed 'index: carries taskSetBy...'). Retired: page.html tag and detail checks and the HTTP Host/Origin fences (no listener).
```diff
- assert.equal(byAgent.agent1.taskSource, "registration");
- assert.equal(byAgent.agent1.taskSetBy, "darkwing");
- assert.equal(byAgent.agent2.taskSource, "first-user-message");
- assert.equal(byAgent.agent2.taskSetBy, null);
- assert.deepEqual(body.registrationErrors, []);
- assert.ok(rowPair);
- assert.match(rowPair[0], /sourceTag\(rec\.taskSource\) \+ setByTag\(rec\)/, "the set-by tag follows the task's source tag on the row");
- assert.match(rowPair[0], /<dt>Task set by<\/dt><dd>" \+ setByText\(rec\) \+ "<\/dd>/, "the detail has a Task set by row");
- assert.ok(tagLine);
- assert.match(tagLine, /rec\.taskSetBy \?/, "nothing is shown without a value");
- assert.equal((tagLine.match(/esc\(rec\.taskSetBy\)/g) || []).length, 2, "the title and the text are both escaped");
- assert.match(tagLine, /not verified/);
- assert.ok(textLine);
- assert.match(textLine, /esc\(rec\.taskSetBy\)/);
- assert.match(textLine, /not verified/);
- assert.ok(canReplyFn);
- assert.equal(canReplyFn[0].includes("taskSetBy"), false);
- assert.equal(html.match(/function replyControl\(rec\) \{[\s\S]*?\n \}/)[0].includes("taskSetBy"), false);
- assert.equal(board.status, 403, `GET /api/board, ${label}`);
- assert.deepEqual(JSON.parse(board.text), { error }, `GET /api/board, ${label}`);
- assert.equal(board.headers["access-control-allow-origin"], undefined);
- assert.equal(posted.status, 403, `POST /api/reply, ${label}`);
- assert.deepEqual(JSON.parse(posted.text), { error }, `POST /api/reply, ${label}`);
- assert.equal(posted.headers["access-control-allow-origin"], undefined);
- assert.equal((await rawRequest(port, {})).status, 400, "GET /api/board, missing Host");
- assert.equal((await rawRequest(port, { method: "POST", path: "/api/reply", headers: { "content-type": "application/json" }, body: reply })).status, 400, "POST /api/reply, missing Host");
- assert.equal(existsSync(f.capture), false, "agent-send was never run");
- assert.equal(existsSync(join(f.boardDir, "index.json")), false, "no refused request rescanned the board");
- assert.equal((await rawRequest(port, { path, headers: { host: `rebind.example:${port}` } })).status, 403, path);
- assert.equal((await rawRequest(port, { method: "POST", path: "/api/seen", headers: { host: `rebind.example:${port}`, "content-type": "application/json" }, body: "{}" })).status, 403);
- assert.equal((await rawRequest(port, { headers: { host } })).status, 200, host);
- assert.equal((await rawRequest(port, { headers: { host, origin: `http://${host}` } })).status, 200, `${host} with its own Origin`);
- assert.equal(posted.status, 200);
- assert.equal(JSON.parse(posted.text).delivered, true);
- assert.equal((await fetch(`http://${own}/api/board`)).status, 200, "fetch without Origin");
- assert.equal(r.status, 403, `${path}, ${label}`);
- assert.deepEqual(JSON.parse(r.text), { error }, `${path}, ${label}`);
- for (const h of Object.keys(r.headers)) assert.ok(!h.startsWith("access-control-"), `${path}, ${label}: ${h}`);
- assert.deepEqual(calls, [], "the reader never ran for a refused request");
- assert.equal((await rawRequest(port, { path: "/api/conversations", headers: { host: own, origin: `http://${own}` } })).status, 200, "same-origin passes");
- assert.deepEqual(calls, ["catalogue"]);
+ assert.equal(byAgent.agent1.taskSource, "registration");
+ assert.equal(byAgent.agent1.taskSetBy, "darkwing");
+ assert.equal(byAgent.agent2.taskSource, "first-user-message");
+ assert.equal(byAgent.agent2.taskSetBy, null);
+ assert.deepEqual(body.registrationErrors, []);
```
### A15: control-board/tests/serve.test.mjs
Enclosing test (first match, old or new side): conversation routes: a reader listing that refuses gets its refusal status and body, not a 200
Reason: Ported: status and refusal body kept on board.conversation (renamed 'conversation listing...'). Retired: HTTP content-type, nosniff, no-store and no-CORS asserts, now in the Console test (group B hunk 25). Retired: pi and claude catalogue and paging asserts, HTTP-only (gap).
```diff
- assert.equal(res.status, 503);
- assert.deepEqual(await res.json(), { error: refusal.message, refusal: { code: refusal.code, reconcile: refusal.reconcile } });
- assert.ok(codes.size >= 15, [...codes].join(" "));
- assert.deepEqual([...codes].filter((c) => !(c in REFUSAL_STATUS)), []);
- assert.deepEqual(Object.keys(REFUSAL_STATUS).filter((c) => !codes.has(c)), [], "no stale entries");
- assert.deepEqual(REFUSAL_STATUS, {
- assert.equal(res.headers.get("content-type"), "application/json", path);
- assert.equal(res.headers.get("x-content-type-options"), "nosniff", path);
- assert.equal(res.headers.get("cache-control"), "no-store", path);
- assert.equal(res.headers.get("access-control-allow-origin"), null, path);
- assert.equal(cat.status, 200);
- assert.deepEqual([pi.seat, pi.title, pi.readOnly], ["agent1", "m0", true]);
- assert.deepEqual([link.refusal, claude.seat, claude.unsupportedReason], ["unsafe-path", "rocko", "unsupported-harness"]);
- assert.equal(first.status, 200);
- assert.deepEqual([first.body.page.kind, first.body.page.entries.length, first.body.page.hasMore, first.body.page.branch], ["page", 100, true, "main"]);
- assert.equal(second.status, 200);
- assert.deepEqual([second.body.page.entries.length, second.body.page.hasMore], [20, false]);
- assert.equal(second.body.page.entries.at(-1).content[0].text, "m119");
- assert.deepEqual([follow.status, follow.body.page.entries.length], [200, 0]);
- assert.equal(branch.status, 200);
- assert.equal(r.status, status, path);
- assert.deepEqual(r.body.refusal, { code, reconcile }, path);
- assert.equal(typeof r.body.error, "string");
- assert.equal(r.status, 400, path);
- assert.deepEqual(treePrint(f.sessionsDir), before, "the sessions directory is unchanged");
- assert.equal(existsSync(join(f.proj, ".pi", "state", "rocko")), false, "the Claude seat's directory was never created");
+ assert.equal(out.status, 503);
+ assert.deepEqual(out.body, { error: refusal.message, refusal: { code: refusal.code, reconcile: refusal.reconcile } });
```
### A16: webui/tests/browser.test.mjs
Enclosing test (first match, old or new side): served Console browser: real board fixtures, keyboard, drafts, receipts, themes, 320px and failures
Reason: CHANGED ASSERT: the error text must contain 'in the Console' instead of the board URL, since there is no board URL in-process. Not run; verify on CLEAR.
```diff
- assert.ok(error.includes(f.boardURL)); assert.match(error, /Showing last known data/);
+ assert.ok(error.includes('in the Console')); assert.match(error, /Showing last known data/);
```
### A17: webui/tests/preview.test.mjs
Enclosing test (first match, old or new side): loopback requests are unchanged in preview mode, and the bus views work with the board down
Reason: CHANGED ASSERT: a dead board read now returns error 'invalid-response' at 502 (consoleRead, webui/src/serve.mjs:230-234). Status kept; the board echo field is gone.
```diff
- assert.equal(board.status, 502); assert.equal(board.body.board, dead);
+ assert.equal(board.status, 502); assert.equal(board.body.error, 'invalid-response');
```
### A18: webui/tests/reads.test.mjs
Enclosing test (first match, old or new side): settings: release, board, loopback address, notifier binding name only
Reason: CHANGED ASSERT: settings.board is null, since the board is in-process (webui/src/serve.mjs:874, boardLabel).
```diff
- assert.deepEqual({ ...body, launch: undefined, at: undefined }, { release: '0.0.99', business: 'acme', notifier: { binding: 'jason-dm' }, board: 'http://127.0.0.1:7331', address: `${base}/`, launch: undefined, at: undefined });
+ assert.deepEqual({ ...body, launch: undefined, at: undefined }, { release: '0.0.99', business: 'acme', notifier: { binding: 'jason-dm' }, board: null, address: `${base}/`, launch: undefined, at: undefined });
```
### A19: webui/tests/serve.test.mjs
Enclosing test (first match, old or new side): loopback host and board origin fail closed
Reason: Retired: boardURL() is removed with the HTTP board address, so its asserts go with it.
```diff
- for (const u of ['https://127.0.0.1', 'http://example.com', 'http://127.0.0.1/path', 'http://user:[email protected]', 'http://127.0.0.1?x', 'http://127.0.0.1#x']) assert.throws(() => boardURL(u));
- assert.equal(boardURL('http://localhost:7331'), 'http://127.0.0.1:7331');
- assert.equal(boardURL('http://[::1]:7331'), 'http://[::1]:7331');
```
### A20: webui/tests/serve.test.mjs
Enclosing test (first match, old or new side): proxy preserves exact request bytes, status and receipt, rejects forms and malformed JSON, never follows redirect
Reason: BEHAVIOUR CHANGE, not a re-expression: the refactor forwarded this request and returned the board's 409. The Console now answers 403 when send is off or the seat is unnamed (webui/src/serve.mjs ~843-846). The oversized-body 400 is also dropped here; no Console test covers the 4096 limit (open gap).
```diff
- assert.equal(res.status, 409); assert.equal(await res.text(), '{"error":"fixture refusal <unsafe>"}');
- assert.deepEqual(requests.at(-1), { url: path, method: 'POST', body: raw });
- try { assert.equal((await post(base, '/api/reply', raw)).status, 400); } catch (err) { if (raw.length < 4096) throw err; }
+ assert.equal(res.status, 403, path); assert.equal((await res.json()).sent, false);
```
### A21: webui/tests/serve.test.mjs
Enclosing test (first match, old or new side): proxy preserves exact request bytes, status and receipt, rejects forms and malformed JSON, never follows redirect
Reason: Dropped: text/plain 400 (Console body() at serve.mjs:58 refuses non-JSON, mapped to 400 at :191; no test, open gap), the request-count checks (no HTTP hop) and the board-down 502 (board is in-process). Added: 405 for GET /api/reply and OPTIONS /api/board; 'the board never ran a write' check on f.captures.
```diff
- assert.equal((await post(base, '/api/reply', '{}', { 'content-type': 'text/plain' })).status, 400);
- assert.equal(requests.length, 2);
- assert.equal((await fetch(base + '/api/board')).status, 502);
- assert.equal(requests.length, 3);
+ assert.equal(f.captures.length, 0, 'the board never ran a write');
+ assert.equal((await fetch(base + '/api/reply')).status, 405);
+ assert.equal((await fetch(base + '/api/board', { method: 'OPTIONS' })).status, 405);
```
### A22: webui/tests/serve.test.mjs
Enclosing test (first match, old or new side): proxy preserves exact request bytes, status and receipt, rejects forms and malformed JSON, never follows redirect
Reason: CHANGED ASSERT: the exact 404 body and refusal object become a want.status and want.body table. The table must match the same body (check in the file, not run).
```diff
- assert.equal(res.status, 404, path);
- assert.equal(await res.text(), '{"error":"fixture unknown <x>","refusal":{"code":"unknown-branch","reconcile":true}}');
- assert.deepEqual(requests.at(-1), { url: path, method: 'GET', body: '' });
+ assert.equal(res.status, want.status, path);
+ assert.deepEqual(await res.json(), want.body, path);
```
### A23: webui/tests/serve.test.mjs
Enclosing test (first match, old or new side): proxy preserves exact request bytes, status and receipt, rejects forms and malformed JSON, never follows redirect
Reason: WEAKENED: the exact 'refused before the board' check (request count unchanged, last url /api/board) becomes a status >= 400 check. This drops the proof the board was not reached. Flagged for Mos.
```diff
- assert.equal((await post(base, '/api/conversation?id=x', '{}')).status, 405);
- assert.equal((await post(base, '/api/conversations', '{}')).status, 405);
- assert.equal((await fetch(base + '/api/conversation?id=x', { headers: { origin: 'https://evil.example' } })).status, 403);
- assert.equal(requests.length, before, 'refused before the board');
- assert.equal(requests.at(-1).url, '/api/board');
+ assert.ok((await fetch(base + '/api/board')).status >= 400);
```
### A24: webui/tests/serve.test.mjs
Enclosing test (first match, old or new side): unreachable board reports URL; CLI rejects unsupported options
Reason: CHANGED ASSERT: the 502 with the board URL becomes 503 not-configured for /api/board and /api/conversations. No board URL appears in any response now.
```diff
- assert.equal(res.status, 502); const result = await res.json(); assert.equal(result.board, url); assert.ok(result.error.includes(url));
+ assert.equal((await fetch(base + '/api/board')).status, 503);
+ assert.equal((await fetch(base + '/api/conversations')).status, 503);
```
### A26: webui/tests/service.test.mjs
Enclosing test (first match, old or new side): render carries every serve flag, the preview set included
Reason: CHANGED ASSERT: the expected ExecStart drops the --board flag (in-process board; webui/src/cli.mjs and scripts/webui-service.sh changed in commit 1).
```diff
- assert.equal(execStart(r.stdout), `ExecStart=${process.execPath} ${REPO}/packages/webui/src/cli.mjs serve --port 7340 --business mosaic.dev_1 --board http://127.0.0.1:7331 --public-host console.example.com --auth-header X-authentik-uid --allow-user ${UID}`);
+ assert.equal(execStart(r.stdout), `ExecStart=${process.execPath} ${REPO}/packages/webui/src/cli.mjs serve --port 7340 --business mosaic.dev_1 --public-host console.example.com --auth-header X-authentik-uid --allow-user ${UID}`);
```
## Group B (added by row 198; Sage rules)
### B0: control-board/tests/board.test.mjs
Enclosing test (first match, old or new side): cli serve refuses and points to the Console
Reason: New in row 198: cli serve refusal (cli.mjs:14) and the 'serve' retirement message, plus the markSeen order and answer asserts.
```diff
+ import assert from "node:assert/strict";
+ assert.deepEqual(events, ["write", "bump", "scan"]);
+ assert.deepEqual(before.seen, []);
+ assert.deepEqual(index.seen, ["proj/agent1"]);
+ const board = createBoard({ specs, boardDir, isAlive, onEvent: (name) => events.push(name), exec: async () => assert.fail("no send on refusal") });
+ assert.equal(out.status, 409);
+ assert.equal(events.includes("send"), false);
+ assert.equal(events.includes("bump"), false);
+ assert.equal(r.status, 2);
+ assert.match(r.stderr, /retired on 7331/);
+ assert.match(r.stderr, /webui/);
```
### B3: control-board/tests/relaunch.test.mjs
Enclosing test (first match, old or new side): without the pid seam, a null-pid registration is undecided: kept, alive null, no relaunch
Reason: New in row 198: null-pid relaunch test. Correction: a null pid makes registeredAlive undecided (scan.mjs:372, 377, 381), not dead. The seam only affects relaunch; the default stays () => true.
```diff
+ assert.notEqual(row.registered, null);
+ assert.equal(row.registered.alive, null);
+ assert.equal(row.relaunchedAt, null);
```
### B25: webui/tests/serve.test.mjs
Enclosing test (first match, old or new side): conversation routes: nosniff, no-store, no CORS header, and every board refusal keeps its status
Reason: New in row 198: Console conversation headers test (nosniff, no-store, no CORS header) and the REFUSAL_STATUS table through the Console route.
```diff
+ assert.equal(hostile, 403);
+ assert.equal((await fetch(f.base + path, { headers: { origin: 'https://evil.example' } })).status, 403);
+ assert.equal(res.status, status, `${code} ${path}`);
+ assert.deepEqual(await res.json(), { error: code }, `${code} ${path}`);
+ assert.equal(res.headers.get('x-content-type-options'), 'nosniff', path);
+ assert.equal(res.headers.get('cache-control'), 'no-store', path);
+ assert.equal(res.headers.get('access-control-allow-origin'), null, path);
```
## Retired, not ported
One line per test name that exists on refactor and not in the current tree. Name-based check: `comm` on test names in the three files.
- `control-board/tests/serve.test.mjs`: startServer: refuses a non-loopback host with ConfigError, never opens a socket — Listener refusal. No listener remains in control-board.
- `control-board/tests/serve.test.mjs`: startServer: serves page, healthz, and a rescanning /api/board — HTTP-only page, healthz and /api/board. No listener remains; page content is not re-asserted (open gap).
- `control-board/tests/serve.test.mjs`: startServer: a seatsDir registration overrides the row and index.registered reflects it — HTTP-only seatsDir override check. Not re-asserted in this port (open gap).
- `control-board/tests/serve.test.mjs`: startServer: /api/board returns 500 JSON with an error field when scan throws — HTTP error envelope. A throwing board read is covered by the preview dead-board test (502 invalid-response, hunk 17); the 500 shape is not re-asserted.
- `control-board/tests/serve.test.mjs`: CLI: serve refuses a non-loopback host with exit 2 and a refused: message — serve is retired; the refusal is asserted by board.test.mjs 'cli serve refuses and points to the Console' (group B hunk 0).
- `control-board/tests/serve.test.mjs`: CLI: live serve prints its URL and answers /healthz — serve is retired (cli.mjs:14). The writer's HTTP healthz and page are gone; no listener remains (see Board listeners).
- `control-board/tests/serve.test.mjs`: page.html: esc() escapes every HTML-significant character — page.html is deleted. Source-shape asserts (esc, Hide seen, reply gate, source tags) have no in-process target. Console app.js coverage NOT verified in this pass (open gap).
- `control-board/tests/serve.test.mjs`: POST /api/seen marks a row; GET /api/board still shows it seen; seen:false clears it — Seen POST HTTP test removed, no port (no hunk, no phrase). markSeen write, bump, scan order is covered by board.test.mjs. Body-parsing gap below.
- `control-board/tests/serve.test.mjs`: POST /api/seen without a JSON content-type returns 400 and does not write a mark — Body-parsing gap. Console body() refuses non-JSON content-type (webui/src/serve.mjs:58) then 400 at :191. No Console test (open gap).
- `control-board/tests/serve.test.mjs`: POST /api/seen with invalid JSON returns 400 — Body-parsing gap. Console answers 400 (webui/src/serve.mjs:191). No Console test (open gap).
- `control-board/tests/serve.test.mjs`: POST /api/seen with a body over 4096 bytes returns 400 (or resets the connection) and writes no mark — Body-parsing gap. Console body() default max 4096 (webui/src/serve.mjs:56) answers 400 via :191. No Console test (open gap).
- `control-board/tests/serve.test.mjs`: POST /api/seen with a missing agent returns 400 — Body-parsing gap. Console answers 400 with the error message (webui/src/serve.mjs:197). No Console test (open gap).
- `control-board/tests/serve.test.mjs`: POST /api/board returns 405; PUT /api/seen returns 405 — Writer HTTP method refusal. Console 405s are asserted by group A hunk 21 (GET /api/reply, OPTIONS /api/board).
- `control-board/tests/serve.test.mjs`: page.html: seenControl() escapes rec.project/agent/lastActivity, and the POST uses a JSON content-type — page.html is deleted. Source-shape asserts (esc, Hide seen, reply gate, source tags) have no in-process target. Console app.js coverage NOT verified in this pass (open gap).
- `control-board/tests/serve.test.mjs`: page.html: has a collapsed Seen section that lists seen rows with the shared row builder — page.html is deleted. Source-shape asserts (esc, Hide seen, reply gate, source tags) have no in-process target. Console app.js coverage NOT verified in this pass (open gap).
- `control-board/tests/serve.test.mjs`: page.html: each project has a Hide seen checkbox (default on) beside Hide offline, with a hidden-count note — page.html is deleted. Source-shape asserts (esc, Hide seen, reply gate, source tags) have no in-process target. Console app.js coverage NOT verified in this pass (open gap).
- `control-board/tests/serve.test.mjs`: page.html: a project header reads \ — page.html is deleted. Source-shape asserts (esc, Hide seen, reply gate, source tags) have no in-process target. Console app.js coverage NOT verified in this pass (open gap).
- `control-board/tests/serve.test.mjs`: page.html: every row shows Task and Active project, derived or the word unknown, with the workspace in the detail — page.html is deleted. Source-shape asserts (esc, Hide seen, reply gate, source tags) have no in-process target. Console app.js coverage NOT verified in this pass (open gap).
- `control-board/tests/serve.test.mjs`: page.html: task and active project cells show their source via sourceTag(); the detail has a Registered row via registeredText(); SOURCE_LABEL maps registration to registered; every dynamic value in sourceTag/fromSource/registeredText is escaped — page.html is deleted. Source-shape asserts (esc, Hide seen, reply gate, source tags) have no in-process target. Console app.js coverage NOT verified in this pass (open gap).
- `control-board/tests/serve.test.mjs`: POST /api/reply: runs agent-send.sh with -s from the registration, -S <host>:control-board, -m text plus the fixed trailer, no -L on the default socket, MOSAIC_TMUX_SOCKET stripped; answers delivered with the exit code and both streams — Renamed; the phrase is still present in the current tree (rename only, no assert hunk).
- `control-board/tests/serve.test.mjs`: POST /api/reply: a registration with a tmux socket adds -L <socket> — Name not carried over; the -L socket asserts stay (hunk 9, re-indent only).
- `control-board/tests/serve.test.mjs`: POST /api/reply: a non-zero tool exit is a 200 with delivered false, the exit code and the stderr verbatim — Renamed; the phrase is still present in the current tree (rename only, no assert hunk).
- `control-board/tests/serve.test.mjs`: POST /api/reply: refusals before the tool runs: empty or blank or long text 400, unknown row 404, no registration 409, stale registration 409, no tmux session 409, bad JSON 400; the tool is never called — Name not carried over; the refusal table stays (hunk 10, re-indent only).
- `control-board/tests/serve.test.mjs`: POST /api/reply: a missing agent-send.sh is a 500 with the path in the error, not a crash — Name not carried over; the 500 and path asserts stay in the ported reply test (hunk 11).
- `control-board/tests/serve.test.mjs`: page.html: the reply box appears only where canReply() holds (live registration with a tmux session), the detail has a Reply row, the submit posts JSON to /api/reply, receipts and drafts survive a refresh, and every receipt value is escaped — page.html is deleted. Source-shape asserts (esc, Hide seen, reply gate, source tags) have no in-process target. Console app.js coverage NOT verified in this pass (open gap).
- `control-board/tests/serve.test.mjs`: startServer: /api/board carries taskSetBy from a live registration and null for the derived rows — Renamed 'index: carries taskSetBy...'; asserts ported (hunk 14).
- `control-board/tests/serve.test.mjs`: page.html: the task cell and detail show who set a registered task via setByTag()/setByText(), both escaped, only from rec.taskSetBy; the reply gate does not read it — page.html is deleted. Source-shape asserts (esc, Hide seen, reply gate, source tags) have no in-process target. Console app.js coverage NOT verified in this pass (open gap).
- `control-board/tests/serve.test.mjs`: Host/Origin guard: GET /api/board and POST /api/reply refuse a foreign Host, a wrong port and a cross-origin Origin with 403 JSON, before any scan or send, and never send CORS headers — Writer HTTP fence. control-board opens no listener (see Board listeners). Console fences at webui/src/serve.mjs:713-717 and :726 covered by the F16 test.
- `control-board/tests/serve.test.mjs`: Host/Origin guard: loopback names on this port are accepted, with or without a same-origin Origin — Writer HTTP fence; same reason as the other Host/Origin guard test.
- `control-board/tests/serve.test.mjs`: conversation routes (F16): a foreign Host, a wrong port and a cross-origin Origin get 403 before the reader runs, with no CORS headers — Writer HTTP fence. Replaced by the Console F16 test (webui serve.test.mjs, 'conversation route: a non-local Host or a foreign Origin is refused 403').
- `control-board/tests/serve.test.mjs`: conversation routes: a reader listing that refuses gets its refusal status and body, not a 200 — Renamed 'conversation listing: ...' and ported to board.conversation (hunk 15). HTTP header asserts moved to the Console test (group B hunk 25).
- `control-board/tests/serve.test.mjs`: conversation routes: catalogue, first page, next page and follow over HTTP; refusals map to 4xx with their code; nothing is written — HTTP-only catalogue and paging. No in-process port; page-content asserts are not re-asserted (open gap).
- `control-board/tests/discord.test.mjs`: server rescans connector discovery and refuses HTTP reply without transport — Renamed in place to 'board rescans connector discovery and refuses reply without transport'; HTTP fetch and 409 ported to board.index()/board.reply() (hunks 1-2).
- `webui/tests/serve.test.mjs`: loopback host and board origin fail closed — boardURL() removed with the HTTP board address (hunk 19).
- `webui/tests/serve.test.mjs`: proxy preserves exact request bytes, status and receipt, rejects forms and malformed JSON, never follows redirect — Writer HTTP proxy test; the Console has no proxy hop in-process. Changed and weakened asserts in hunks 20-23 (see Weakened).
- `webui/tests/serve.test.mjs`: unreachable board reports URL; CLI rejects unsupported options — No board URL to report in-process; replaced by 503 not-configured asserts (hunk 24).
## Weakened and changed asserts (for Mos)
- Hunk 20: 409 becomes 403 (behaviour change, see below); the oversized-body 400 is dropped.
- Hunk 21: the text/plain 400 and the request-count and board-502 checks are dropped.
- Hunk 22: exact 404 body text becomes a table comparison.
- Hunk 23: exact 'refused before the board' check becomes status >= 400.
## Behaviour changes to report
1. POST /api/reply in the Console: 403 when send is off or the seat is unnamed (webui/src/serve.mjs ~843-846). The refactor forwarded the request unconditionally (hunk 20).
2. /api/settings reports `board: null` in-process (hunk 18).
3. A dead board read returns 502 `invalid-response` (hunk 17).
4. Null-pid correction: a null pid is undecided (scan.mjs:372, 377, 381), not dead. The default `() => true` only matters for relaunch, and the new relaunch test (group B hunk 3) covers that.
5. creative-hidden.test.mjs: ported from `startServer` to `createBoard` and `board.conversation`. Assertions unchanged. `startServer` is no longer exported by control-board/src/serve.mjs.
## Board listeners after 198
Command: `grep -rn "createServer\|\.listen(\|startServer\|startBoard" packages/control-board/src`
Result: no matches (exit 1). control-board/src opens no listener.
CLI entry: packages/control-board/src/cli.mjs:14 refuses `serve` with `ConfigError` (RETIRED message, line 11). No listener.
Not in the board's code, for completeness: the Console (packages/webui/src/serve.mjs:1 imports `createServer`; :684 creates the server; :909 listens) hosts the board in-process. packages/webui/src/tester-socket.mjs:24 listens on a separate tester socket, not the board.
So the Host/Origin tests in control-board are retired on this basis. The Console's Host and Origin fences are covered by the F16 test in webui/tests/serve.test.mjs.
## Open items (not closed in this pass)
- page.html source checks are retired with no verified Console equivalent in app.js (open gap).
- Seen POST body parsing (400 for invalid JSON, missing agent, 4096 limit, non-JSON content-type) has no board test and no Console test. Gap recorded; no new test added (sage-4373).
- Catalogue and paging over HTTP, seatsDir registration override, and the 500 shape are not re-asserted (see retire list).
- Not run: chat-replies-browser (line 71 `startServer({ port: 0, board: f.board })`) and the shell 'board unavailable' text (ReadError message path, serve.mjs:230-234). Verify on CLEAR.
- The webui F16 test (serve.test.mjs:93) is a new test; the hunk map does not isolate it as its own hunk. Confirm its add lines are in group B hunk 25's range.
- webui/tests/discord.test.mjs needs no isPidAlive seam: its registration uses pid `process.pid`.
- conversation/tests/flows.test.mjs imports replyToRow from control-board/src/serve.mjs; the export still exists. Not run.
- Syntax: `node --check` passes for all touched JS files (no tests run).
-94
View File
@@ -1,94 +0,0 @@
# Row 173 (#1671): a Seen click that waited out a Board read keeps the meaning it was clicked with
Brief: docs/plans/2026-10-19_seen-click-intent.md (blob 225ac823b). Branch dewey/row173 off aca16404c.
Queue reviewer: Darkwing. Gate (queue row): Darkwing approves on #1671 naming the candidate manifest; webui and
every scripts/test-*.sh green on Sage's gate rerun.
## Design
- Intent. `seenButton` draws `data-intent="seen"` on a Seen button and `data-intent="unsee"` on an Unsee
button, from the same `r.seen` that picks the label. The click handler reads it into `mark` before
`writing = true` and before the wait, so the fresh row can't change it. I chose a `data-` value over reading the
label text so a later relabel can't change what a click posts.
- The fresh row already holds the intent (`r.seen === mark`). This happens when another viewer marked or unmarked
the row while the read was in flight. Nothing is posted, the button is re-enabled, and the page announces
`<agent> already seen` or `<agent> already waiting`.
- Why post nothing. The route takes a repeat without harm: `markSeen` with `seen:true` rewrites the same mark,
and `seen:false` deletes a key that is already gone (control-board/src/scan.mjs:255-256). So posting the same
value would also be correct. It would cost a write and a rescan, though, and the read that just finished has
already drawn the row. The brief allows either.
- An unseen row can still hold an old mark for an earlier lastActivity. Posting nothing on Unsee leaves that
mark, which is harmless because `scanAgent` only counts a mark equal to the row's lastActivity (scan.mjs:340).
- Otherwise the click posts `seen: mark` and announces from `mark`, not from the fresh row.
- Unchanged: the row 172 wait, the single-click guard (`writing`), the no-row branch, and the `/api/seen` route
(out of scope).
- `seen.disabled = false` in the new branch matches the no-row branch. After the read, `accept` redraws the
inspector and the cards, so `seen` is usually a detached button and the line has no visible effect. No test
can tell it apart; see M5.
## Test
packages/webui/tests/board-held.test.mjs gains one test: "a Seen or Unsee click that waited out a Board read
keeps its meaning: another viewer's mark in that read posts nothing". `setup` returns `boardURL: f.boardURL` so
the test can post to the board as the other viewer.
- Steps, for each of Seen and Unsee:
- it holds `/api/board` with `window.__hold` and clicks Refresh;
- while the read is held, it posts the other viewer's mark straight to the board;
- it clicks the inspector's button and releases the read.
- Checks: the announcement (`agent1 already seen`, then `agent1 already waiting`), `#seen-count` (1, then 0), and
that the page sent no `/api/seen` at all.
- The held fetch hasn't reached the board when the other viewer posts (the stub awaits the hold before calling
the real fetch), so the released answer carries the other viewer's mark.
Hash pins and readers checked (git grep, with a positive control for 64-hex): nothing pins app.js. shell.test
counts existing app.js strings only. browser.test, sso-browser.test and the other board-held tests click
`[data-seen]` buttons the page draws, so they get `data-intent` with them.
## Mutants (run on CLEAR only; each one edit to app.js, then board-held.test.mjs whole)
| id | edit | expected kill |
|----|------|---------------|
| M1 | post `seen: !r.seen` and drop the `r.seen === mark` branch (the old toggle) | new test (a post, and "returned to waiting") |
| M2 | drop the `r.seen === mark` branch only (post the repeat) | new test (a post, and "marked seen") |
| M3 | `seenButton` swaps the intent (`r.seen ? 'seen' : 'unsee'`) | row 172 test (`#seen-count` never 1) and the new test |
| M4 | the new branch returns without `announce` | new test (announcement wait times out) |
| M5 | drop `seen.disabled = false` in the new branch | equivalent: the button is redrawn by `accept`; named, not run |
Each run: `timeout -k 10 180 node --test --test-force-exit packages/webui/tests/board-held.test.mjs`; a kill
counts only as an assertion or a named wait failing in the named test.
## Runs
None yet. Gander holds the gate slot (row 253 run 5) from 16:32Z. The claim, the push and every run wait for
Sage's CLEAR.
## Round 2: rebase onto 78b69e1e6 (sage-4361)
- Branch dewey/row173-r2 off refactor 78b69e1e6. The round 1 commit 25ff7cd3f (on aca16404c, 555 commits
behind) was cherry-picked as a new commit; dewey/row173 is untouched. No force-push.
- The pick merged on its own. `git range-diff aca16404c..25ff7cd3f 78b69e1e6..<pick>` reports the commit equal.
- Overlap with refactor: one app.js line, the `/api/config` handler at the end of the file (federation render).
It is far from both row 173 hunks and changes no assertion or reviewed line. board-held.test.mjs and the brief
(blob 225ac823b) are unchanged on refactor.
- No test pins app.js by hash (grepped tests for app.js plus 64-hex).
### Run list
app.js is a page script, loaded by index.html, not imported by any module. Its test readers:
- by name: board-held, browser, granted-user, serve, shell, sso-browser, user-option-swap;
- by page load: every webui test that opens the Console through tests/browser.mjs (31 test files).
No test outside packages/webui reads or serves app.js (`git grep` over packages/*/tests and scripts: none).
Order, each run with `TMPDIR` from `mktemp -d /mnt/storage/scratch/tmp/d173XXXXXX` and absolute paths:
1. M1-M4 below, each with board-held.test.mjs whole.
2. board-held.test.mjs whole on the restored tree.
3. packages/webui whole (all 70 test files), which covers both reader groups.
case1-accept.test.mjs is not in the list (mos-1612). Runs need Sage's CLEAR.
### Mutant procedure
The M1-M5 table above stands. M5 stays equivalent: the new branch is reached only after the waited-out Board
read's `accept` (refresh, app.js:372) has redrawn the row, so `seen` is the detached old button. Each mutant is one
edit to app.js. Before the next mutant, the restored app.js must match the candidate's sha256. Each run is in its
own process group so a timeout kill takes its browser with it. Afterwards I sweep for orphaned node/chromium
processes in my run directory.
+2 -6
View File
@@ -66,7 +66,7 @@
$('mode').onchange = e => { mode = e.target.value; theme(); };
function seenButton(r) {
if (!['waiting', 'error'].includes(r.state) || !r.lastActivity) return '';
return `<button type="button" class="btn" data-seen="${esc(key(r))}" data-intent="${r.seen ? 'unsee' : 'seen'}" aria-label="${r.seen ? 'Unsee' : 'Mark seen'} ${esc(r.agent)}">${r.seen ? 'Unsee' : 'Seen'}</button>`;
return `<button type="button" class="btn" data-seen="${esc(key(r))}" aria-label="${r.seen ? 'Unsee' : 'Mark seen'} ${esc(r.agent)}">${r.seen ? 'Unsee' : 'Seen'}</button>`;
}
const historyButton = r => `<button type="button" class="btn conv-open" data-history="${esc(key(r))}" aria-label="History for ${esc(r.agent)}">History</button>`;
const openButton = r => `<button type="button" class="session-open" data-open="${esc(key(r))}" aria-controls="inspector" aria-expanded="${selected === key(r)}">${esc(r.agent)}</button>`;
@@ -415,16 +415,12 @@
if (!seen || writing) return;
// A Board read in flight (reading) holds busy. The click waits for that read and posts on the row it
// drew; dropping it left the click doing nothing (row 172). A second click while one waits or posts is dropped.
// The click means what the button said when clicked (Seen marks, Unsee returns the row to waiting), read before
// the wait. If the fresh row already holds that, another viewer got there first: nothing is posted (row 173).
const mark = seen.dataset.intent === 'seen';
writing = true; seen.disabled = true;
try {
while (reading) await reading;
const r = row(seen.dataset.seen); if (!r) { seen.disabled = false; return; }
if (r.seen === mark) { seen.disabled = false; announce(`${r.agent} already ${mark ? 'seen' : 'waiting'}`); return; }
busy = true;
try { accept(await api('/api/seen', { project: r.project, agent: r.agent, lastActivity: r.lastActivity, seen: mark })); announce(`${r.agent} ${mark ? 'marked seen' : 'returned to waiting'}`); }
try { accept(await api('/api/seen', { project: r.project, agent: r.agent, lastActivity: r.lastActivity, seen: !r.seen })); announce(`${r.agent} ${r.seen ? 'returned to waiting' : 'marked seen'}`); }
catch (err) { error(err); seen.disabled = false; }
finally { busy = false; schedule(); }
} finally { writing = false; }
+1 -32
View File
@@ -82,7 +82,7 @@ async function setup(machine, fast = false) {
const polls = () => b.evaluate('window.__polls');
const settle = () => new Promise(r => setTimeout(r, 600));
const done = async () => { await b.close(); await close(web); await f.close(); store.close(); rmSync(root, { recursive: true, force: true }); };
return { b, base, boardURL: f.boardURL, wait, text, shown, reqs, polls, settle, done };
return { b, base, wait, text, shown, reqs, polls, settle, done };
}
const HELD = 'document.querySelectorAll("#held table tbody tr").length === 2';
@@ -191,37 +191,6 @@ test('a Seen click while a Board read is in flight waits for that read, then pos
} finally { await done(); }
});
// Row 173 (#1671): the click took its meaning from the fresh row, so another viewer's mark landing in the read
// it waited for turned "Seen" into an unsee. The click means what its button said; a fresh row that already
// holds that posts nothing and says so. The other viewer posts straight to the board while the read is held.
test('a Seen or Unsee click that waited out a Board read keeps its meaning: another viewer\'s mark in that read posts nothing', { timeout: 120000 }, async () => {
const { b, base, boardURL, wait, text, reqs, done } = await setup(false);
const posts = async () => (await reqs()).filter(r => r.path === '/api/seen').length;
const other = async seen => {
const r = (await (await fetch(`${boardURL}/api/board`)).json()).sessions.find(s => s.project === 'proj' && s.agent === 'agent1');
const res = await fetch(`${boardURL}/api/seen`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ project: 'proj', agent: 'agent1', lastActivity: r.lastActivity, seen }) });
assert.equal(res.status, 200);
};
try {
await b.navigate(base);
await wait(`${SESSIONS} && ${HELD}`);
await b.evaluate(`document.querySelector('table.sessions [data-open="proj/agent1"]').click(), true`);
await wait('document.querySelector("#inspection [data-seen]")');
for (const [label, seen, count, said] of [['Seen', true, '1', 'agent1 already seen'], ['Unsee', false, '0', 'agent1 already waiting']]) {
assert.equal(await text('#inspection [data-seen]'), label);
await b.evaluate(`window.__hold = '/api/board', document.querySelector('#refresh').click(), true`);
await wait('window.__held.length === 1');
// The held fetch has not reached the board yet, so its answer carries the other viewer's mark.
await other(seen);
await b.evaluate(`document.querySelector('#inspection [data-seen]').click(), true`);
await b.evaluate('window.__held.splice(0).forEach(r => r()), true');
await wait(`document.querySelector("#announce").textContent === ${JSON.stringify(said)}`);
assert.equal(await text('#seen-count'), count);
assert.equal(await posts(), 0);
}
} finally { await done(); }
});
test('a redirect answer on a Board read, a bus read, a conversation read, a board POST, a module read and a send signs the page out: one line, nothing stale, no further reads', { timeout: 120000 }, async () => {
// The poll timers fire after 150 ms here, so the 600 ms settle below spans several ticks.
const { b, base, wait, text, shown, reqs, polls, settle, done } = await setup(true, true);