// 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, ); }); // improvise-ociy: a send on a non-OPEN socket was dropped in silence, so a // user edit vanished with nothing on screen. The edit is LOUD; the viewport // announcement beside it is deliberately not (it would overwrite taxx). test("an edit sent after the socket dropped says it was not sent", async ({ page }) => { await page.addInitScript(logStatus); await page.routeWebSocket("ws://127.0.0.1:8792/", (ws) => { ws.onMessage(() => ws.close()); }); await page.goto("/app/index.html?ws=127.0.0.1:8792"); // The precondition: it really opened and really dropped, so the send // below is on a CLOSED socket rather than one that never connected. await expect(status(page)).toContainText("connected", { timeout: 15_000 }); await expect(status(page)).toContainText("disconnected", { timeout: 15_000 }); // Enter is forwarded UPSTREAM, so it composes a frame even on a client // that never got a snapshot — a cursor key here composes nothing at all. await page.keyboard.press("Enter"); await expect(status(page)).toContainText("not sent", { timeout: 15_000 }); const log = await page.evaluate(() => window.__statusLog); expect( log.some((t) => t.includes("disconnected")), `the drop must still have been named first: ${json(log)}`, ).toBe(true); }); // 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-i0v9: "in-page worker · " was written at 13ms, before the // worker had answered anything, and read as success over a blank page. test("the worker is not called up until its first frame has arrived", async ({ page }) => { const doc = `first-frame-${Date.now()}.improv`; await page.addInitScript(logStatus); await page.goto(`/app/index.html?doc=${doc}`); await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 }); await expect(status(page)).toHaveText(`in-page worker · ${doc}`); const log = await page.evaluate(() => window.__statusLog); expect(log, `#status said: ${json(log)}`).toEqual([ `starting the worker · ${doc}…`, `in-page worker · ${doc}`, ]); }); // 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"); }); // improvise-i0v9's headline, end to end: two tabs in ONE context on one // document. The second cannot take the exclusive handle, and used to sit // blank for 8s and then blame the browser. test("a second tab on the same document opens read-only and says why", async ({ context, }) => { const doc = `two-tab-${Date.now()}.improv`; const first = await context.newPage(); await first.goto(`/app/index.html?doc=${doc}`); // The precondition: tab one really is up and really is holding the handle. await expect(first.locator("#grid table")).toBeVisible({ timeout: 20_000 }); const second = await context.newPage(); await second.clock.install(); await second.addInitScript(logStatus); await second.goto(`/app/index.html?doc=${doc}`); // It PAINTS. That is the whole point: a locked document is read-only, not // dead — boot succeeds without a handle. await expect(second.locator("#grid table")).toBeVisible({ timeout: 20_000 }); await expect(second.locator("#status")).toContainText("read-only"); await expect(second.locator("#status")).toContainText("another tab"); // The page's clock has not been walked, so a reason that arrived AFTER the // watchdog would have been logged behind "did not answer". Nor was success // ever claimed: the Snapshot that follows the notice must not overwrite it. const early = await second.evaluate(() => window.__statusLog); expect(early.at(-1), `#status said: ${json(early)}`).toContain("read-only"); expect( early.filter((t) => t.includes("did not answer") || t === `in-page worker · ${doc}`), `#status said: ${json(early)}`, ).toEqual([]); // And the reason STANDS: it is not replaced 8s later by a watchdog // blaming the browser for something the code knew precisely. expect( await second.evaluate(() => window.__improviseShell?.timesArmed() ?? 0), "the watchdog never armed here, so denying its message proves nothing", ).toBeGreaterThan(0); await second.clock.runFor(HANDSHAKE_TIMEOUT_MS + 2_000); await expect(second.locator("#status")).toContainText("read-only"); await expect(second.locator("#status")).not.toContainText("did not answer"); const late = await second.evaluate(() => window.__statusLog); expect(late.some((t) => t.includes("did not answer")), `#status said: ${json(late)}`).toBe( false, ); await second.close(); await first.close(); }); // improvise-j4qt: the read-only tab used to APPLY an edit to its own copy // and paint it, a number the document never had. It must refuse the edit. test("a read-only tab refuses an edit and never paints it", async ({ context }) => { const doc = `read-only-edit-${Date.now()}.improv`; const first = await context.newPage(); await first.goto(`/app/index.html?doc=${doc}`); await expect(first.locator("#grid table")).toBeVisible({ timeout: 20_000 }); const second = await context.newPage(); await second.goto(`/app/index.html?doc=${doc}`); await expect(second.locator("#grid table")).toBeVisible({ timeout: 20_000 }); await expect(second.locator("#status")).toContainText("read-only"); const cell = second.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("4242"); await input.press("Enter"); // The refusal is what arrives, and a document message is a toast. Waiting // on it first means the grid below is asserted AFTER the answer landed. await expect(second.locator("#toast")).toContainText("read-only", { timeout: 10_000 }); await expect(cell).toHaveText(before); await expect(second.locator("#grid")).not.toContainText("4,242"); // The boot's reason stands; no failed save replaced it (the old symptom). await expect(second.locator("#status")).toContainText("read-only"); await expect(second.locator("#status")).not.toContainText("NOT SAVED"); await second.close(); await first.close(); }); // 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.00", { timeout: 10_000 }); expect(before).not.toBe("20,000.00"); // 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); }); });