Files
improvise/harness/tests/shell.spec.js
T
Edward Langley 36ade35240 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>
2026-09-20 13:33:49 -07:00

95 lines
4.4 KiB
JavaScript

// The page↔peer boundary: the handshake, a frame answered whole, and what the
// page says when a peer fails. What the arm/disarm RULE is, and every other
// decision between these seams, is `node --test web/tests` — clients.md.
import { test, expect } from "@playwright/test";
const status = (page) => page.locator("#status");
/// A peer that accepts the TCP connection and then refuses the WebSocket
/// upgrade — deterministic where a closed port is not (evidence file). It is
/// the harness's OWN server, so it follows the port the config chose.
const NO_WEBSOCKET_HERE = `127.0.0.1:${process.env.IMPROVISE_HARNESS_PORT ?? "8099"}`;
/// `web/app.js`'s own HANDSHAKE_TIMEOUT_MS. One test has to outlast it, and a
/// number that silently disagreed with the page would pass for the wrong reason.
const HANDSHAKE_TIMEOUT_MS = 8_000;
test.describe("the shell reports what its peer is doing", () => {
// improvise-hwpi. The fault is injected at the *peer*, not in the page, so
// this exercises the real `on_frame` and the real catch.
test("a frame the client cannot fold says so on screen", async ({ page }) => {
await page.routeWebSocket("ws://127.0.0.1:8787/", (ws) => {
ws.onMessage(() => ws.send("{\"NotAFrame\":{}}"));
});
await page.goto("/app/index.html?ws=127.0.0.1:8787");
await expect(status(page)).toContainText("bad frame", { timeout: 15_000 });
});
// A socket that never opened did not "disconnect" (improvise-taxx): nothing
// on the close event can discriminate, so the page records `opened` itself.
// That it then STAYS is the watchdog's disarming, which is Node's now.
test("a peer that cannot be reached is named, and one that dropped is not confused with it", async ({
page,
}) => {
await page.goto(`/app/index.html?ws=${NO_WEBSOCKET_HERE}`);
await expect(status(page)).toContainText("cannot reach", { timeout: 15_000 });
// Closes on the page's first frame, so "it opened first" is true by
// construction rather than by timer.
await page.routeWebSocket("ws://127.0.0.1:8790/", (ws) => {
ws.onMessage(() => ws.close());
});
await page.goto("/app/index.html?ws=127.0.0.1:8790");
await expect(status(page)).toContainText("connected", { timeout: 15_000 });
await expect(status(page)).toContainText("disconnected", { timeout: 15_000 });
});
// The one assertion that needs the real timer: that the watchdog is ARMED
// after the handshake goes out. What it then says, and that a loud failure
// disarms it, are unit tests. The mock accepts and never answers.
test("a peer that accepts and then says nothing trips the watchdog", async ({
page,
}) => {
await page.routeWebSocket("ws://127.0.0.1:8788/", () => {});
await page.goto("/app/index.html?ws=127.0.0.1:8788");
await expect(status(page)).toContainText("sent no document", {
timeout: HANDSHAKE_TIMEOUT_MS + 12_000,
});
});
});
test.describe("the standalone deployment", () => {
// No `?ws=`, so the page spawns the in-page worker over OPFS. If this draws
// a grid, worker.js, bindings.rs, OpfsFs and the seed fetch all ran.
test("the page draws the demo document out of the worker", async ({ page }) => {
await page.goto("/app/index.html");
await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 });
await expect(status(page)).toContainText("in-page worker");
await expect(page.locator("#grid td.editable").first()).toBeVisible();
});
// What lands on screen is the HOST's value, not the string typed — the
// round trip through a real worker, which no unit test can stand in for.
test("committing an edit sends one frame and shows the host's value", async ({
page,
}) => {
await page.goto("/app/index.html?doc=commit-test.improv");
await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 });
const cell = page.locator("#grid td.editable").first();
const before = await cell.textContent();
await cell.click();
const input = cell.locator("input");
await expect(input).toBeFocused();
await input.fill("20000");
await input.press("Enter");
await expect(cell).toHaveText("20,000", { timeout: 10_000 });
expect(before).not.toBe("20,000");
// The editor is gone and did not come back: the half of the re-entry guard
// that IS observable from the DOM.
await expect(page.locator("#grid input")).toHaveCount(0);
});
});