cdefeeb9b8
improvise-fo0w. Three black-hole keys closed over vocabulary that already exists, each mirroring its Cmd line for line with a lockstep case: - enter-advance (Normal Enter) resolves as cursor arithmetic over GridExtent; the host's bottom-right AbortChain is Ignored here, which loses nothing because Enter is never bound inside a Sequence. - hide-selected-row-item (H) resolves to ModelCommand::HideItem over the outer row category and the selected tuple's first label, and refuses on a fold with the host's own FOLD_REFUSAL, now in improvise-protocol. - search (/) and search-navigate (n) decline out loud as a pair: n needs the whole model, and / alone would open a box that finds nothing. NEEDS_THE_HOST drops to exit-search-mode, force-quit and toggle-group-under-cursor. Tests that used Enter or "Start search" as their upstream example move to Ctrl-Q and "Exit search mode", including shell.spec.js's not-sent case. Also improvise-0629: the help render test now asserts every ex row is drawn dim, not just that its description is marked. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
346 lines
16 KiB
JavaScript
346 lines
16 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,
|
|
);
|
|
});
|
|
|
|
// 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 });
|
|
|
|
// Ctrl-Q is forwarded UPSTREAM, so it composes a frame with no snapshot;
|
|
// Enter resolves locally since improvise-fo0w, and composes nothing here.
|
|
await page.keyboard.press("Control+q");
|
|
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 · <doc>" 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);
|
|
});
|
|
});
|