Files
improvise/harness/tests/shell.spec.js
T
Edward Langley 12714c7527 feat(browser): a boot failure is a state with a reason, not silence
improvise-1wft and the five beads it blocked were one failure: nothing
carried a boot failure's REASON from the worker to the page. The channel
is one rule -- a protocol frame crosses the port as a STRING, a shell
notice as an OBJECT -- so it needs no protocol change and the
discriminator is total.

A boot failure is a STATE, not a rejected promise. A document another
context holds the exclusive handle to now OPENS read-only: measured first
(harness probe, Chromium + Firefox), getFile() reads the bytes while the
handle is held, with the second-handle refusal asserted so the probe
cannot pass vacuously.

- improvise-1wft  the channel: Notice/NoticeKind, boot_notice, take_notice
- improvise-i0v9  read-only boot over vfs::ReadOnly + OpfsFs::read_unattached
- improvise-86nw  start() rejecting for any reason now posts BootFailed
- improvise-9cma  boot::document_name -- and a/b/c.improv was ACCEPTED as
                  c.improv, not rejected, which is worse than the report
- improvise-iobc  vfs::Origin: a seed error is a notice only when the
                  document really had to be created without it
- improvise-vep8  the watchdog arms BEFORE transport.ready; its message
                  stopped claiming the connection was accepted
- improvise-7gip  DedicatedWorkerGlobalScope (needs a web-sys feature)
- improvise-zre1  a persist that fails raises a notice; the trigger is the
                  dirty flag surviving, observed rather than predicted
- improvise-7gf8  the render breaker trips once instead of retrying forever
- improvise-ociy  non-OPEN sends are loud; a viewport announcement is not
- improvise-y01k  the three ws-server drops are logged
- improvise-r4nf  serde failures throw instead of reading as "nothing to send"

Every notice routes through shell.fail, so gotcha 48's disarm half is not
re-broken. Two comments in shell.spec.js stated the opposite of the new
arming order and were corrected rather than left.

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

222 lines
10 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";
import { HANDSHAKE_TIMEOUT_MS } from "../../web/shell.js";
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"}`;
/// The page's own constant, imported rather than copied: a spec that
/// understated it would jump short of the window and every watchdog assertion
/// below would pass for the wrong reason — demonstrated, PR #65 review.
/// Record every distinct #status the page shows, not just the last one: the
/// properties below are about a message being REPLACED, which a final read
/// cannot see. Installed before the page's own script runs.
function logStatus() {
window.__statusLog = [];
new MutationObserver(() => {
const text = document.getElementById("status")?.textContent ?? "";
if (text && window.__statusLog.at(-1) !== text) window.__statusLog.push(text);
}).observe(document, { childList: true, subtree: true, characterData: true });
}
const json = (v) => JSON.stringify(v);
/// Walk past the watchdog without waiting for it. `page.clock` fakes the
/// page's own timers, so the 8s is the PAGE's and the wall clock is not.
async function outlastTheWatchdog(page) {
// A negative assertion about the watchdog is vacuous where it was never
// armed. It asks whether it was EVER armed: by here a correct page has
// disarmed it, which is the property, so `armed()` would deny the passing
// case. Since improvise-vep8 arming precedes `transport.ready`.
expect(
await page.evaluate(() => window.__improviseShell?.timesArmed() ?? 0),
"the watchdog was never armed on this path: denying its message proves nothing",
).toBeGreaterThan(0);
// The import is the SOURCE tree's constant; the page runs the ASSEMBLED
// copy. A stale `serve/` makes them differ — improvise-669b, clients.md.
expect(
await page.evaluate(() => window.__improviseShell?.timeoutMs() ?? null),
"the page's handshake window is not the one this spec imported: re-run harness/build.sh",
).toBe(HANDSHAKE_TIMEOUT_MS);
const jump = HANDSHAKE_TIMEOUT_MS + 2_000;
const before = await page.evaluate(() => Date.now());
await page.clock.runFor(jump);
// `runFor` without `clock.install()` does NOTHING and throws nothing, so a
// test that forgot it would assert "the watchdog never spoke" vacuously.
const after = await page.evaluate(() => Date.now());
expect(
after - before,
"page.clock.install() was not called: the page's timers never advanced",
).toBeGreaterThanOrEqual(jump);
}
// Tagged, and the tag is what the config's `grepInvert` reads: these four
// mock their peer with `routeWebSocket`, which is slower and flakier across
// three engines. That argument is about THESE, and was never made about the
// standalone block below (improvise-zkkw).
test.describe("the shell reports what its peer is doing", { tag: "@mocked-peer" }, () => {
// 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 });
});
// improvise-hwpi. This peer never opens; since improvise-vep8 the watchdog
// IS armed here, and `error`/`close` disarm it with the precise reason.
test("a peer that cannot be reached is named, and not called a disconnect", async ({
page,
}) => {
await page.addInitScript(logStatus);
await page.goto(`/app/index.html?ws=${NO_WEBSOCKET_HERE}`);
await expect(status(page)).toContainText("cannot reach", { timeout: 15_000 });
// The naming is the whole property here: `close` on a socket that never
// opened must not read as "disconnected" (improvise-taxx).
const log = await page.evaluate(() => window.__statusLog);
expect(log.some((t) => t.includes("disconnected")), `#status said: ${json(log)}`).toBe(
false,
);
});
// The arm PR #21 fixed and that gotcha 48 says keeps getting lost: a socket
// that never opened did not "disconnect", and the one that did STAYS so.
test("a socket that opened and then dropped reads as disconnected, and stays that way", async ({
page,
}) => {
await page.clock.install();
await page.addInitScript(logStatus);
// 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 });
// Past the watchdog it was armed against: the routing, not the rule, is
// what this can see and `shell.test.js` cannot.
await outlastTheWatchdog(page);
await expect(status(page)).toContainText("disconnected");
const log = await page.evaluate(() => window.__statusLog);
expect(log.some((t) => t.includes("sent no document")), `#status said: ${json(log)}`).toBe(
false,
);
});
// The one assertion that needs the real timer: that the watchdog is ARMED
// and speaks. Since improvise-vep8 it is armed BEFORE `transport.ready`, so
// its message covers a peer that never opened as well as one that went
// quiet — which is why it no longer claims the connection was accepted.
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("never answered", {
timeout: HANDSHAKE_TIMEOUT_MS + 12_000,
});
});
// improvise-vep8: a peer that never OPENS never resolves `transport.ready`,
// so arming after it left this path with no watchdog at all. The socket is
// mocked to hang, so neither `error` nor `close` ever fires to cover for it.
test("a connection that hangs forever is still caught by the watchdog", async ({
page,
}) => {
// Accepts the TCP/WS handshake at the Playwright level but never lets the
// page see `open`: the page sits in `await transport.ready`.
await page.routeWebSocket("ws://127.0.0.1:8791/", () => new Promise(() => {}));
await page.goto("/app/index.html?ws=127.0.0.1:8791");
await expect(status(page)).toContainText("never answered", {
timeout: HANDSHAKE_TIMEOUT_MS + 12_000,
});
});
});
// Chromium AND Firefox: this is the deployment improvise-bck's criterion (1)
// is about, it mocks nothing, and the engine's OPFS is already exercised by
// `opfs.spec.js` — so the one recorded reason for Chromium-only does not
// reach it (improvise-zkkw).
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();
});
// improvise-9cma: a `?doc=` this backend cannot hold used to reach the
// generic watchdog 8s later. The REASON now crosses the port (improvise-1wft).
test("an invalid ?doc= is named on screen, and not blamed on the browser", async ({
page,
}) => {
await page.clock.install();
await page.addInitScript(logStatus);
await page.goto("/app/index.html?doc=a/b/c.improv");
await expect(status(page)).toContainText("could not start", { timeout: 20_000 });
await expect(status(page)).toContainText("does not name a document");
// The property is that the precise reason ARRIVES and STANDS: the watchdog
// must not replace it eight seconds later (gotcha 48).
await outlastTheWatchdog(page);
await expect(status(page)).toContainText("does not name a document");
const log = await page.evaluate(() => window.__statusLog);
expect(
log.some((t) => t.includes("did not answer")),
`#status said: ${json(log)}`,
).toBe(false);
});
// improvise-iobc: an empty document is indistinguishable from an intended
// one unless the page says the seed is what failed.
test("a seed that 404s says so rather than showing a silent empty grid", async ({
page,
}) => {
await page.goto("/app/index.html?doc=no-seed.improv&seed=nope.improv");
// It still OPENS — an unfetchable seed is not a fatal boot failure.
await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 });
await expect(status(page)).toContainText("EMPTY");
await expect(status(page)).toContainText("nope.improv");
});
// 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);
});
});