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:
+61
-117
@@ -2,30 +2,26 @@
|
||||
// string calls, no protocol knowledge. See context/code/clients.md.
|
||||
|
||||
import init, { Client } from "./pkg/improvise_wasm_client.js";
|
||||
import {
|
||||
COL_PX,
|
||||
ROW_PX,
|
||||
createShell,
|
||||
extentOf,
|
||||
frameCostOf,
|
||||
gridPlan,
|
||||
indicator,
|
||||
socketUrl,
|
||||
} from "./shell.js";
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
// present => socket, absent => in-page worker. The value is a host:port, and
|
||||
// the scheme is NOT the caller's to get wrong: see `socketUrl`.
|
||||
const WS_PEER = params.get("ws");
|
||||
|
||||
/** The websocket URL for `?ws=`, defaulting to TLS. Why, and when an explicit
|
||||
* `ws://` is upgraded rather than honoured: context/code/clients.md. */
|
||||
function socketUrl(peer) {
|
||||
const secure = location.protocol === "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}`;
|
||||
}
|
||||
|
||||
const WS_URL = WS_PEER ? socketUrl(WS_PEER) : null;
|
||||
const WS_URL = WS_PEER ? socketUrl(WS_PEER, location.protocol) : null;
|
||||
const DOC = params.get("doc") ?? "demo.improv";
|
||||
const SEED = params.get("seed") ?? "demo.improv";
|
||||
|
||||
// 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.
|
||||
const ROW_PX = 26;
|
||||
const COL_PX = 110;
|
||||
|
||||
const el = {
|
||||
grid: document.getElementById("grid"),
|
||||
categories: document.getElementById("categories"),
|
||||
@@ -42,64 +38,38 @@ const el = {
|
||||
await init();
|
||||
const client = new Client();
|
||||
|
||||
// `#status` carries two independent facts, composed rather than overwritten:
|
||||
// a repaint must not quietly erase a connection error.
|
||||
let shellStatus = "";
|
||||
let modelStatus = "";
|
||||
function paintStatus() {
|
||||
el.status.textContent = [shellStatus, modelStatus].filter(Boolean).join(" · ");
|
||||
}
|
||||
function setShellStatus(text) {
|
||||
shellStatus = text;
|
||||
paintStatus();
|
||||
}
|
||||
// A peer that never answers the handshake, and the two facts `#status` shows.
|
||||
// Both are decided in shell.js; this is the wire to the element.
|
||||
const HANDSHAKE_TIMEOUT_MS = 8000;
|
||||
const shell = createShell({
|
||||
onStatus: (text) => {
|
||||
el.status.textContent = text;
|
||||
},
|
||||
timeoutMs: HANDSHAKE_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
// What the grid's own frame costs, in whole cells, as of the last repaint —
|
||||
// measured off the painted model, never predicted. context/code/clients.md.
|
||||
let frameCost = { rows: 1, cols: 1 };
|
||||
|
||||
/** The extent this window can show, in whole DATA cells. */
|
||||
function extent() {
|
||||
const box = el.grid.getBoundingClientRect();
|
||||
return {
|
||||
rows: Math.max(1, Math.floor(box.height / ROW_PX) - frameCost.rows),
|
||||
cols: Math.max(1, Math.floor(box.width / COL_PX) - frameCost.cols),
|
||||
};
|
||||
}
|
||||
|
||||
function sendViewport() {
|
||||
const { rows, cols } = extent();
|
||||
const box = el.grid.getBoundingClientRect();
|
||||
const metrics = { rowPx: ROW_PX, colPx: COL_PX };
|
||||
const { rows, cols } = extentOf(box, frameCost, metrics);
|
||||
// `client.set_viewport`, NOT the associated `Client.viewport`: the same
|
||||
// frame, but it also RECORDS the window. Why that matters: clients.md.
|
||||
transport.send(client.set_viewport(rows, cols));
|
||||
}
|
||||
|
||||
// A peer that never answers the handshake. Deliberately about the *first
|
||||
// frame*, not either peer's own failure modes — context/code/clients.md.
|
||||
const HANDSHAKE_TIMEOUT_MS = 8000;
|
||||
let watchdog = null;
|
||||
function armWatchdog(message) {
|
||||
clearTimeout(watchdog);
|
||||
watchdog = setTimeout(() => setShellStatus(message), HANDSHAKE_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
// A peer that failed *loudly* has already said something more precise, so the
|
||||
// watchdog must be DISARMED and not merely pre-empted (PR #13 review).
|
||||
function setShellFailure(text) {
|
||||
clearTimeout(watchdog);
|
||||
watchdog = null;
|
||||
setShellStatus(text);
|
||||
}
|
||||
|
||||
/** One frame, one fold, in arrival order. Never batch or reorder these. */
|
||||
function onFrame(text) {
|
||||
clearTimeout(watchdog);
|
||||
shell.sawFrame();
|
||||
try {
|
||||
client.on_frame(text);
|
||||
} catch (err) {
|
||||
// `on_frame` returns a Result, which wasm-bindgen throws; thrown in a bare
|
||||
// listener it reached the console alone and the grid just stopped.
|
||||
setShellStatus("bad frame — the grid has stopped updating (see console)");
|
||||
shell.setShell("bad frame — the grid has stopped updating (see console)");
|
||||
console.error("improvise: frame rejected", text, err);
|
||||
return;
|
||||
}
|
||||
@@ -116,13 +86,13 @@ function workerTransport() {
|
||||
const worker = new Worker(`./worker.js?${query}`, { type: "module" });
|
||||
worker.addEventListener("message", (event) => onFrame(event.data));
|
||||
worker.addEventListener("messageerror", () =>
|
||||
setShellFailure("the worker sent a frame this page could not read"),
|
||||
shell.fail("the worker sent a frame this page could not read"),
|
||||
);
|
||||
worker.addEventListener("error", (event) => {
|
||||
setShellFailure(`worker failed to start: ${event.message ?? "see console"}`);
|
||||
shell.fail(`worker failed to start: ${event.message ?? "see console"}`);
|
||||
console.error("improvise: worker error", event);
|
||||
});
|
||||
setShellStatus(`in-page worker · ${DOC}`);
|
||||
shell.setShell(`in-page worker · ${DOC}`);
|
||||
// Frames posted before the worker has finished `start()` are held by
|
||||
// worker.js in arrival order, so the handshake can go out immediately.
|
||||
return { send: (text) => worker.postMessage(text), ready: Promise.resolve() };
|
||||
@@ -138,20 +108,20 @@ function socketTransport() {
|
||||
const ready = new Promise((resolve) => {
|
||||
socket.addEventListener("open", () => {
|
||||
opened = true;
|
||||
setShellStatus(`connected · ${WS_URL}`);
|
||||
shell.setShell(`connected · ${WS_URL}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
socket.addEventListener("message", (event) => onFrame(event.data));
|
||||
// Without this, a connection that never opens left #status blank and
|
||||
// "server not running" read exactly like "still connecting".
|
||||
socket.addEventListener("error", () => setShellFailure(unreachable));
|
||||
socket.addEventListener("error", () => shell.fail(unreachable));
|
||||
// A socket that never opened did not "disconnect" (improvise-taxx). BOTH arms
|
||||
// route through `setShellFailure`, which disarms the watchdog — clients.md.
|
||||
// route through `shell.fail`, which disarms the watchdog — clients.md.
|
||||
socket.addEventListener("close", () =>
|
||||
setShellFailure(opened ? "disconnected" : unreachable),
|
||||
shell.fail(opened ? "disconnected" : unreachable),
|
||||
);
|
||||
setShellStatus(`connecting to ${WS_URL}…`);
|
||||
shell.setShell(`connecting to ${WS_URL}…`);
|
||||
return {
|
||||
send: (text) => {
|
||||
if (socket.readyState === WebSocket.OPEN) socket.send(text);
|
||||
@@ -173,7 +143,7 @@ function repaint() {
|
||||
try {
|
||||
model = JSON.parse(client.render_model());
|
||||
} catch (err) {
|
||||
setShellStatus("cannot render the current state (see console)");
|
||||
shell.setShell("cannot render the current state (see console)");
|
||||
console.error("improvise: render failed", err);
|
||||
return;
|
||||
}
|
||||
@@ -185,8 +155,7 @@ function repaint() {
|
||||
// itself; see `RenderModel::hint`.
|
||||
el.hint.textContent = model.hint;
|
||||
paintHelp(model);
|
||||
modelStatus = model.status;
|
||||
paintStatus();
|
||||
shell.setModel(model.status);
|
||||
el.viewName.textContent = model.view_name;
|
||||
el.grid.replaceChildren(gridTable(model));
|
||||
// The flag is the client's answer, not the page's: `C` toggles it and the
|
||||
@@ -199,10 +168,7 @@ function repaint() {
|
||||
|
||||
// What the frame costs, so the NEXT viewport announcement is honest.
|
||||
// Inline group ROWS are deliberately NOT counted — context/code/clients.md.
|
||||
const next = {
|
||||
rows: model.col_levels.length + (model.col_groups.length > 0 ? 1 : 0),
|
||||
cols: model.row_levels,
|
||||
};
|
||||
const next = frameCostOf(model);
|
||||
const changed = next.rows !== frameCost.rows || next.cols !== frameCost.cols;
|
||||
frameCost = next;
|
||||
if (changed) sendViewport();
|
||||
@@ -247,32 +213,30 @@ function paintHelp(model) {
|
||||
el.help.replaceChildren(tabs, body);
|
||||
}
|
||||
|
||||
/** The grid, painted from a model in which every frame decision is already
|
||||
* made (improvise-anr9). The page decides nothing here: which labels repeat
|
||||
* and are therefore blank, how many header levels there are, which columns
|
||||
* share a group and whether it is collapsed all arrive answered, by the same
|
||||
* `AxisFrame` the TUI widget reads. */
|
||||
/** The grid, painted from a plan in which every decision is already made
|
||||
* (improvise-anr9, and `shell.js::gridPlan`). Nothing below decides anything:
|
||||
* which labels repeat and are therefore blank, how many header levels there
|
||||
* are, which columns share a group and whether it is collapsed all arrive
|
||||
* answered, by the same `AxisFrame` the TUI widget reads. */
|
||||
function gridTable(model) {
|
||||
const plan = gridPlan(model);
|
||||
const table = document.createElement("table");
|
||||
const head = table.createTHead();
|
||||
const corner = () => {
|
||||
const th = document.createElement("th");
|
||||
th.colSpan = model.row_levels;
|
||||
th.colSpan = plan.cornerSpan;
|
||||
return th;
|
||||
};
|
||||
|
||||
// The column-group line, when there is one. An empty `col_groups` means
|
||||
// this grid has no column groups at all -- not that every group is blank.
|
||||
if (model.col_groups.length > 0) {
|
||||
// The column-group line, when there is one. An empty `groups` means this
|
||||
// grid has no column groups at all -- not that every group is blank.
|
||||
if (plan.groups.length > 0) {
|
||||
const groups = head.insertRow();
|
||||
groups.appendChild(corner());
|
||||
for (const group of model.col_groups) {
|
||||
for (const group of plan.groups) {
|
||||
const th = document.createElement("th");
|
||||
th.colSpan = 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.
|
||||
th.textContent =
|
||||
group.label === null ? "" : indicator(group.collapsed) + " " + group.label;
|
||||
th.textContent = group.text;
|
||||
th.classList.add("group");
|
||||
groups.appendChild(th);
|
||||
}
|
||||
@@ -280,7 +244,7 @@ function gridTable(model) {
|
||||
|
||||
// One header line per level. A blank entry is repeat suppression, decided
|
||||
// in Rust -- never an absent label.
|
||||
for (const level of model.col_levels) {
|
||||
for (const level of plan.levels) {
|
||||
const tr = head.insertRow();
|
||||
tr.appendChild(corner());
|
||||
for (const label of level) {
|
||||
@@ -291,23 +255,16 @@ function gridTable(model) {
|
||||
}
|
||||
|
||||
const body = table.createTBody();
|
||||
// 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 `model.rows`.
|
||||
let r = -1;
|
||||
for (const row of model.rows) {
|
||||
for (const row of plan.body) {
|
||||
const tr = body.insertRow();
|
||||
if (row.kind === "group") {
|
||||
const th = document.createElement("th");
|
||||
th.colSpan = model.row_levels + model.col_levels[0].length;
|
||||
th.textContent = indicator(row.collapsed) + " " + row.label;
|
||||
th.colSpan = row.colSpan;
|
||||
th.textContent = row.text;
|
||||
th.classList.add("group");
|
||||
tr.appendChild(th);
|
||||
continue;
|
||||
}
|
||||
r += 1;
|
||||
// `r` is one binding shared by every closure below, so a click handler
|
||||
// that captured it would fire with the LAST row's index. Snapshot it.
|
||||
const dataRow = r;
|
||||
|
||||
for (const label of row.labels) {
|
||||
const header = document.createElement("th");
|
||||
@@ -316,39 +273,26 @@ function gridTable(model) {
|
||||
tr.appendChild(header);
|
||||
}
|
||||
|
||||
row.cells.forEach((cell, c) => {
|
||||
for (const cell of row.cells) {
|
||||
const td = tr.insertCell();
|
||||
// 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";
|
||||
td.textContent = typing ? model.editing + "\u258f" : cell.text;
|
||||
td.classList.toggle("numeric", cell.numeric && !typing);
|
||||
td.classList.toggle("error", cell.error);
|
||||
td.classList.toggle("selected", cell.selected);
|
||||
td.classList.toggle("typing", typing);
|
||||
// `editable` is the server's answer relayed through Rust: a stored cell.
|
||||
// Drill is not on the wire yet, so an aggregate is not offered.
|
||||
td.classList.toggle("editable", cell.editable);
|
||||
td.textContent = cell.text;
|
||||
for (const [name, on] of Object.entries(cell.classes)) {
|
||||
td.classList.toggle(name, on);
|
||||
}
|
||||
// Every cell moves the selection; an editable one also opens its editor,
|
||||
// which is the pointer's `i`. Not repainted in that branch: `edit` has
|
||||
// set `editing`, and a repaint would take the <input> away.
|
||||
td.addEventListener("click", () => {
|
||||
const frame = client.click_cell(dataRow, c);
|
||||
const frame = client.click_cell(cell.row, cell.col);
|
||||
if (frame) transport.send(frame);
|
||||
if (cell.editable) edit(td, dataRow, c, cell.edit_text);
|
||||
if (cell.editable) edit(td, cell.row, cell.col, cell.editText);
|
||||
else repaint();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
/** 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). */
|
||||
function indicator(collapsed) {
|
||||
return collapsed ? "\u25b6" : "\u25bc";
|
||||
}
|
||||
|
||||
/** Open an inline editor. Committing sends a frame; the value that comes
|
||||
* back is the server's, never the string typed here. */
|
||||
function edit(td, row, col, seed) {
|
||||
@@ -489,7 +433,7 @@ window.addEventListener("keydown", (e) => {
|
||||
|
||||
await transport.ready;
|
||||
sendViewport(); // the handshake: the snapshot is built at this extent
|
||||
armWatchdog(
|
||||
shell.arm(
|
||||
WS_URL
|
||||
? `${WS_URL} accepted the connection but sent no document`
|
||||
: "the in-page worker did not answer — this browser may not allow it (see console)",
|
||||
|
||||
Reference in New Issue
Block a user