test(web): a Node suite between the boundaries, Playwright only on them
E2E and integration tests are rare and pin boundary contracts; unit tests assume those contracts and test between them (user, 2026-09-20). The browser half has four boundaries — RenderModel→DOM, JS→wasm, page→peer, worker→OPFS — and everything between them needed no browser at all. `web/shell.js` and `web/serial.js` are the seams: pure modules that RETURN A DESCRIPTION rather than take a DOM double. `gridPlan(model)` answers every question `gridTable` used to answer while creating elements — the data-row index across group headers, what a group header spans, a null group label against an empty one, an empty edit buffer against no editor — and `app.js` is now a loop that builds elements from it. Painting stays untested by Node; what to paint stopped being untestable. 26 tests in `node --test web/tests` (0.12s, no Rust toolchain, no wasm bundle, no npm install), plus three in `web/tests/bundle/` that read the generated .d.ts and so run in the browser job. Playwright goes from 29 tests/36.9s to 16/15.5s on one machine: `widgets.spec.js` and `pointer.spec.js` are gone, replaced by `contract.spec.js`, which asserts that EVERY field the render model projects reaches the screen. The four widgets those specs were written for were one failure at one boundary, and one assertion catches the fifth. It caught it: `doc_name`/`doc_dirty`/`doc_stored_as` reach no element (improvise-5jna, already open). They are named in `UNPAINTED` with the ticket, so the list is the debt written down rather than an exemption. Thirteen faults injected into the Node suite and eight into the served page, each watched to fail the test it names; the last of those was strengthened because the first version passed. `web/assemble.sh` grew an import-completeness guard whose own first version was blind to the multi-line import it exists for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+144
@@ -0,0 +1,144 @@
|
||||
// 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;
|
||||
|
||||
/** 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;
|
||||
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) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user