Files
improvise/web/shell.js
T
Edward Langley 89f67afd4c fix(harness): make a vacuous watchdog denial fail, and single-home the window
PR #65's review found this PR had planted a fresh instance of the defect it
exists to repair. Both blocking findings fixed, each demonstrated.

FINDING 1. "a peer that cannot be reached is named, and the watchdog stays out
of it" could not see the property its comment claimed. `shell.arm` runs only
after `await transport.ready`, which never resolves on a socket that never
opens — so the watchdog is never armed there and denying "sent no document"
denies an unreachable event. Reverting PR #21's routing left that clause
passing.

The aggravating half: `context/test-evidence/clients.md` already said so, and
this PR EDITED that paragraph — inserting "runFor(10s) advances the watchdog"
immediately above the sentence explaining it is never armed. Not text the PR
left alone; text the PR walked past mid-sentence.

The clause is gone and the test keeps the naming property it really guards: a
`close` on a socket that never opened must not read as "disconnected". And the
shape cannot return — `outlastTheWatchdog` now asks the page `timesArmed() > 0`
before jumping, so a watchdog denial re-added to that path FAILS BY NAME. It
asks *ever armed* rather than *armed now* on purpose: by the time the disconnect
test jumps, a correct page has already disarmed, which is the property, so the
obvious spelling would have denied the passing case. Building the guard is what
surfaced that distinction; reading had not.

FINDING 2. The spec kept its own `HANDSHAKE_TIMEOUT_MS` beside a comment saying
a silent disagreement "would pass for the wrong reason". It did: the spec at
1000 against the page's 8000 made the suite green over the reverted routing,
because the jump landed inside the window. The constant is now exported from
`web/shell.js` and imported by both, so the disagreement is unconstructible.
Shrinking it in its one home still catches the routing revert — that is the
demonstration the guard did not go quiet.

Filed, not fixed: improvise-78v6 (#61 also deleted the help-overlay spec and
nothing replaced it — a PR about a dropped guard that did not diff the dropping
PR's test names) and improvise-5ray (--check-evidence collects only `### ` and
resolves only Rust fns, so every browser entry's `####` is invisible; appending
one that names nothing exits 0, which is how the dangling heading cleared the
gate for a release).

Verified on the merged tree: node 26/26, bundle 3/3, Playwright 17 passed + 3
skipped, cargo test --workspace 1378 passed, comment-runs clean,
--check-evidence 501 resolving, all cited ids resolve, no .beads diff.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 14:24:25 -07:00

155 lines
5.8 KiB
JavaScript

// Everything `web/app.js` decides that is not painting: no DOM, no browser
// API, one `node --test` away. What belongs here: context/code/clients.md.
/** The websocket URL for `?ws=`, defaulting to TLS. Why, and when an explicit
* `ws://` is upgraded rather than honoured: context/code/clients.md. */
export function socketUrl(peer, pageProtocol) {
const secure = pageProtocol === "https:";
if (/^wss:\/\//i.test(peer)) return peer;
if (/^ws:\/\//i.test(peer)) return secure ? peer.replace(/^ws:/i, "wss:") : peer;
return `${secure ? "wss" : "ws"}://${peer}`;
}
/** `#status` carries two independent facts, composed rather than overwritten:
* a repaint must not quietly erase a connection error. */
export function composeStatus(shell, model) {
return [shell, model].filter(Boolean).join(" · ");
}
/** The collapse glyph. `collapsed` is the fact, decided by the view and
* carried on the frame; only this spelling is the page's (gotcha 26). */
export function indicator(collapsed) {
return collapsed ? "▶" : "▼";
}
// Rough character metrics, used only to turn the window size into an extent.
// Being wrong here costs a row of over- or under-fetch, nothing more.
export const ROW_PX = 26;
export const COL_PX = 110;
/** How long a peer has to answer the handshake. Exported because the harness
* must jump *past* it: a spec with its own copy can understate it silently
* and every watchdog assertion then passes vacuously (clients.md). */
export const HANDSHAKE_TIMEOUT_MS = 8000;
/** The extent a box of this size can show, in whole DATA cells: the frame is
* charged for first, and at least one cell is always asked for. */
export function extentOf(box, frameCost, metrics = { rowPx: ROW_PX, colPx: COL_PX }) {
return {
rows: Math.max(1, Math.floor(box.height / metrics.rowPx) - frameCost.rows),
cols: Math.max(1, Math.floor(box.width / metrics.colPx) - frameCost.cols),
};
}
/** What the grid's own frame costs, in whole cells — measured off the painted
* model, never predicted. Inline group ROWS are deliberately not counted. */
export function frameCostOf(model) {
return {
rows: model.col_levels.length + (model.col_groups.length > 0 ? 1 : 0),
cols: model.row_levels,
};
}
/** The grid, as a description: every decision made, nothing created. The page
* decides nothing here and neither does this — which labels repeat, how many
* header levels there are and which columns share a group all arrive
* answered, by the same `AxisFrame` the TUI widget reads (improvise-anr9). */
export function gridPlan(model) {
const cornerSpan = model.row_levels;
const groups = model.col_groups.map((group) => ({
span: group.span,
// `label` is null for a run in no group; an empty STRING is a legal group
// name, so this is a null check and not a truthiness test.
text: group.label === null ? "" : `${indicator(group.collapsed)} ${group.label}`,
}));
// The cell coordinates a click sends are DATA row indices and a group header
// is not a data row, so this counts rather than trusting the row's position.
let dataRow = -1;
const body = model.rows.map((row) => {
if (row.kind === "group") {
return {
kind: "group",
colSpan: model.row_levels + model.col_levels[0].length,
text: `${indicator(row.collapsed)} ${row.label}`,
};
}
dataRow += 1;
return {
kind: "data",
row: dataRow,
labels: row.labels,
cells: row.cells.map((cell, col) => {
// A keyboard edit is open when `model.editing` is a string, and it
// holds what has been typed. Both facts are decided in Rust.
const typing = cell.selected && typeof model.editing === "string";
return {
row: dataRow,
col,
text: typing ? `${model.editing}▏` : cell.text,
editText: cell.edit_text,
editable: cell.editable,
classes: {
numeric: cell.numeric && !typing,
error: cell.error,
selected: cell.selected,
typing,
// `editable` is the server's answer relayed through Rust: a stored
// cell. Drill is not on the wire, so an aggregate is not offered.
editable: cell.editable,
},
};
}),
};
});
return { cornerSpan, groups, levels: model.col_levels, body };
}
/** The page's two status facts and the handshake watchdog, which share a home
* because the one rule between them is that a *failure disarms the timer*. */
export function createShell({ onStatus, timeoutMs, setTimer = setTimeout, clearTimer = clearTimeout }) {
let shell = "";
let model = "";
let watchdog = null;
let arms = 0;
const paint = () => onStatus(composeStatus(shell, model));
return {
setShell(text) {
shell = text;
paint();
},
setModel(text) {
model = text;
paint();
},
/** A peer that never answers the handshake. Deliberately about the *first
* frame*, not either peer's own failure modes — clients.md. */
arm(message) {
arms += 1;
clearTimer(watchdog);
watchdog = setTimer(() => {
watchdog = null;
this.setShell(message);
}, timeoutMs);
},
/** A frame arrived, so the peer answered: the watchdog has no question
* left to ask. */
sawFrame() {
clearTimer(watchdog);
watchdog = null;
},
/** A peer that failed *loudly* has already said something more precise, so
* the watchdog must be DISARMED and not merely pre-empted (PR #13). */
fail(text) {
this.sawFrame();
this.setShell(text);
},
armed: () => watchdog !== null,
/** How many times the watchdog has been armed. A test denying its message
* needs this to be nonzero, or it is denying an unreachable event. */
timesArmed: () => arms,
};
}