// The RenderModel→DOM boundary and the pointer's half of the JS→wasm one: the // two contracts only a browser can establish. Why these and not more, and what // moved to `node --test web/tests`: context/code/clients.md. import { test, expect } from "@playwright/test"; // The page's own key, imported rather than spelled: a spec with its own // copy would pass while reading a key nothing writes (shell.spec.js does // the same with the handshake window). import { NOTICE_KEY } from "../../web/shell.js"; async function openApp(page) { await page.goto("/app/index.html"); await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 }); await page.locator("#grid").focus(); } test.describe("every field the render model projects reaches the screen", () => { // The four widgets a user reported as "not working" — the tile bar, the view // list, the formula panel and the category sidebar ignoring its flag — were // ONE failure at this boundary: a projected field with no element painting // it. This is that contract, and it is what catches the fifth. test("the widgets the demo document fills are drawn, in the state that fills them", async ({ page, }) => { await openApp(page); // `rows`, `col_levels`, `row_levels`: the grid itself. web/demo.improv is // Product on row, _Measure on column (gotcha 46). await expect(page.locator("#grid thead th", { hasText: "Revenue" })).toBeVisible(); await expect(page.locator("#grid tbody th").first()).toHaveText("Espresso"); await expect(page.locator("#grid tbody td").first()).not.toHaveText(""); // `doc_name`, `doc_stored_as`: the title bar. improvise-z754 projected // these and nothing painted them for a wave, which `render_surface`'s // substring search cannot see — only a loaded page can (improvise-5jna). // The dirty marker's own spelling is `web/tests/shell.test.js`: this // deployment persists on every accepted batch, so `[+]` is transient. await expect(page.locator("#doc-name")).toHaveText( "Corner Coffee — 2025 (demo.improv)", ); // `mode`, `hint`, `view_name`, `status`: the header's four facts. await expect(page.locator("#mode")).toHaveText("NORMAL"); await expect(page.locator("#hint")).not.toHaveText(""); await expect(page.locator("#view-name")).toHaveText("Plan"); await expect(page.locator("#status")).toContainText("in-page worker"); // `tiles`: drawn on every frame, as the TUI draws them. The axis label is // `Axis::plain_label` here; the three-column one is the TUI's (771e). await expect(page.locator("#tiles .tile")).toHaveCount(5); await expect(page.locator("#tiles .tile").filter({ hasText: "Product" })).toHaveText( "Product Row", ); // `categories` + `category_panel_open`: the flag is the client's answer, // and the sidebar was drawn always until it read it (improvise-wsaw). await expect(page.locator("#categories")).toBeVisible(); await expect(page.locator("#categories li").first()).toBeVisible(); // Open at launch by the shared default, so the first press only FOCUSES: // `toggle-panel-and-focus` closes only when open AND focused. await page.keyboard.press("Shift+C"); await expect(page.locator("#mode")).toHaveText("CATEGORIES"); await expect(page.locator("#categories")).toBeVisible(); // This is the assertion that catches wsaw — a sidebar drawn unconditionally // passes the launch one. Visible-at-launch is the ruling, not the guard. await page.keyboard.press("Shift+C"); await expect(page.locator("#categories")).toBeHidden(); await page.keyboard.press("Shift+C"); await expect(page.locator("#categories")).toBeVisible(); await page.keyboard.press("Escape"); // `views`: empty unless open, so the page asks no question about the mode. await expect(page.locator("#views")).toBeHidden(); await page.keyboard.press("Shift+V"); await expect(page.locator("#views li")).toHaveCount(2); await expect(page.locator("#views li.active")).toContainText("Plan"); // The axis line is the shared `view_axis_summary`; its absence would mean // a second derivation had crept in. await expect(page.locator("#views li").nth(0).locator(".axes")).toHaveText( "R:Product P:Quarter", ); await page.keyboard.press("Escape"); // `formulas`: the third of `Panel::ALL`, on the same terms. await page.keyboard.press("Shift+F"); await expect(page.locator("#formulas li")).toHaveText(["Profit = Revenue - Cost"]); await page.keyboard.press("Escape"); // `help`, `help_page`, `help_titles`: the rows and their keys are // `improvise_protocol::help`'s, the same ones the TUI draws. Asserted by // TEXT, because an empty `dt` is a visible `dt` and a page drawing the // right number of blank rows is the failure this row is here to catch // (improvise-igxp). What each page holds is `help.spec.js`. await expect(page.locator("#help")).toBeHidden(); await page.keyboard.press("?"); await expect(page.locator(".help-tab.active")).toHaveText("Welcome"); await expect(page.locator(".help-body dt.help-banner")).toHaveText("improvise"); await page.keyboard.press("l"); await expect(page.locator(".help-tab.active")).toHaveText("Navigation"); await expect(page.locator(".help-body dt.help-keys").first()).toHaveText( "h j k l ← ↑ → ↓", ); await page.keyboard.press("Escape"); await expect(page.locator("#help")).toBeHidden(); // `editing`: what has been typed, drawn into the selected cell with a // caret. The decision that an editor is open is made in Rust. await page.keyboard.press("i"); await expect(page.locator("#grid td.typing")).toHaveCount(1); // The class alone would pass against a cell drawn blank: what is on the // screen is the buffer AND the caret. await expect(page.locator("#grid td.typing")).toContainText("▏"); await page.keyboard.press("Escape"); await expect(page.locator("#grid td.typing")).toHaveCount(0); }); // The other half of the same contract: a change the client resolved must // reach the pixels, not merely the cache. One instance is enough — what // each key MEANS is `improvise_protocol::resolve`'s, and tested there. test("a resolved change repaints the widget it moved and the grid with it", async ({ page, }) => { await openApp(page); const product = page.locator("#tiles .tile").filter({ hasText: "Product" }); const rowHeaders = page.locator("#grid tbody th"); await expect(rowHeaders).toHaveText(["Espresso", "Latte", "Cold Brew", "Pastry"]); await page.keyboard.press("Shift+T"); for (let i = 0; i < 3; i++) await page.keyboard.press("ArrowRight"); await expect(product).toHaveClass(/selected/); await page.keyboard.press("c"); await expect(product).toHaveText("Product Col", { timeout: 10_000 }); // The label alone would be the bar reporting an axis the document has not // got: the grid must have moved with it. await expect( page.locator("#grid thead th").filter({ hasText: "Espresso" }).first(), ).toBeVisible(); await expect(rowHeaders).not.toHaveText(["Espresso", "Latte", "Cold Brew", "Pastry"]); }); // improvise-p7lh. `page_coords` and `totals` were projected to every session // and folded correctly, and NO front end read either: the shipped front page // showed a grid filtered to Q1 with nothing on screen saying so, and `]` // changed every number in silence. test("the page bar names the quarter, and both it and the totals follow `]`", async ({ page, }) => { await openApp(page); // `web/demo.improv` is `Quarter: page, Q1` — the fact this bead is about. await expect(page.locator("#pages")).toBeVisible(); await expect(page.locator("#pages .page-axis")).toHaveText(/Quarter = Q1 \(1\/4\)/); // Page is navigable where Filter is not (gotcha 27), and that is what // says `[`/`]` will move it. await expect(page.locator("#pages .page-axis")).toHaveClass(/navigable/); // The totals strip. Q1 Revenue is 18400+24600+9200+11500 — a sum over // EVERY row, which is why it is projected and not added up here. await expect(page.locator("#grid tfoot th")).toHaveText("Total"); await expect(page.locator("#grid tfoot td").first()).toHaveText("63,700.00"); // A total is not a cell anyone may type into. await expect(page.locator("#grid tfoot td.editable")).toHaveCount(0); await page.keyboard.press("]"); // BOTH move, and the numbers are Q2's: a bar that showed a stale quarter // over fresh numbers is the exact lie this bead names. await expect(page.locator("#pages .page-axis")).toHaveText(/Quarter = Q2 \(2\/4\)/, { timeout: 10_000, }); await expect(page.locator("#grid tfoot td").first()).toHaveText("66,400.00"); }); }); test.describe("a pointer event reaches the client and its answer is painted", () => { // improvise-7ybz. What a click MEANS is resolved in Rust and tested in // `improvise_protocol::resolve`; what this pins is the wiring — the handler // is on the element, it passes THAT element's index, and the result repaints. test("each clickable surface is wired to its own index", async ({ page }) => { await openApp(page); // A grid cell: the coordinate is the data row, which group headers do not // consume — `gridPlan` counts it and `web/tests/grid_plan.test.js` pins it. const cells = page.locator("#grid tbody td"); await expect(cells.nth(0)).toHaveClass(/selected/); await cells.nth(4).click(); await page.keyboard.press("Escape"); await expect(cells.nth(4)).toHaveClass(/selected/); await expect(page.locator("#grid tbody td.selected")).toHaveCount(1); // The cursor a click leaves is the one the keyboard moves from: one // cursor, two input paths. await page.keyboard.press("ArrowLeft"); await expect(cells.nth(3)).toHaveClass(/selected/); // A tile: the first click SELECTS and does not touch the document; the // second, on the same tile, cycles. The user's ruling — clients.md. const product = page.locator("#tiles .tile").filter({ hasText: "Product" }); await product.click(); await expect(page.locator("#mode")).toHaveText("TILES"); await expect(product).toHaveClass(/selected/); // Waited out, not asserted instantly: a cycle would arrive asynchronously. await page.waitForTimeout(500); await expect(product).toHaveText("Product Row"); await product.click(); await expect(product).toHaveText("Product Col", { timeout: 10_000 }); await page.keyboard.press("Escape"); // A category row: focuses the panel, moves its cursor and activates the // row — three things from one click, and the index must be the row's own. await expect(page.locator("#categories")).toBeVisible(); const productRow = page.locator("#categories li").filter({ hasText: "Product" }); await expect(page.locator("#categories li.selected")).toHaveText(/_Index/); await productRow.click(); await expect(page.locator("#mode")).toHaveText("CATEGORIES"); await expect(page.locator("#categories li.selected")).toHaveText(/Product/); await expect(page.locator("#categories li.item")).toHaveText([ "Espresso", "Latte", "Cold Brew", "Pastry", ]); // The OTHER arm of the same click, which PR #61 dropped with // `pointer.spec.js` and nothing replaced (found by the test-name diff // improvise-78v6 asks for): on an ITEM row the rule is "do what Enter // does here", which is filter — not expand. Only the status line says // which arm ran, so a click wired to the category arm passes everything // above and fails here. await page.locator("#categories li.item").filter({ hasText: "Latte" }).click(); await expect(page.locator("#toast")).toContainText("Filter: Product = Latte"); await expect(page.locator("#categories li.selected")).toHaveText("Latte"); await page.keyboard.press("Escape"); // A view row (improvise-iioi). The activation must read the CLICKED index, // not `view_panel_cursor` — so the cursor must not already be on the row // clicked, or both readings give the same answer. `Plan` is index 0 and // holds the cursor by default, so move to it and click `Season`. await page.keyboard.press("Shift+V"); await expect(page.locator("#views li.selected")).toContainText("Plan"); // Switch to Plan by KEYBOARD, leaving the cursor on it. The click then // names a different row than the cursor does, which is the whole point. await page.keyboard.press("Enter"); await expect(page.locator("#view-name")).toHaveText("Plan"); const season = page.locator("#views li").filter({ hasText: "Season" }).first(); await season.click(); // Reading the cursor instead of the index re-selects Plan and fails. await expect(page.locator("#view-name")).toHaveText("Season"); await expect(page.locator("#views li.active")).toContainText("Season"); await expect(page.locator("#views li.selected")).toContainText("Season"); // A formula row: navigation only, because FormulaPanel binds no Enter and // a click mirrors Enter. The Rust guard is on the KEYMAP — clients.md. // `#grid input` toHaveCount(0) belongs here and is deliberately absent: // it cannot fail, measured by injection (improvise-qf57, clients.md). await page.keyboard.press("Shift+F"); await expect(page.locator("#formulas li").first()).toBeVisible(); await page.keyboard.press("Escape"); await page.locator("#formulas li").first().click(); await expect(page.locator("#mode")).toHaveText("FORMULAS"); await expect(page.locator("#formulas li.selected")).toHaveCount(1); }); // improvise-0x9u. The neighbouring state the assertion above cannot see: it // presses Escape first, so it only ever clicks from a NON-editing page. test("a click moves the selection from an editing state too", async ({ page }) => { await openApp(page); const cells = page.locator("#grid tbody td"); await cells.nth(0).click(); await expect(cells.nth(0).locator("input")).toHaveCount(1); // No Escape between the two clicks — that is the whole test. The blur used // to repaint and `replaceChildren` destroyed the the mousedown landed // on, so no click event was ever dispatched and the pointer was dead. await cells.nth(4).click(); await expect(cells.nth(4).locator("input")).toHaveCount(1); await expect(cells.nth(0).locator("input")).toHaveCount(0); // The third edit of the same repair: `close` restores the DRAWN text, not // the seed. `repaint` early-returns while `editing` is set, so a `18400` // put back here stays on screen (improvise-0x9u). await expect(cells.nth(0)).toHaveText("18,400.00"); await page.keyboard.press("Escape"); await expect(cells.nth(4)).toHaveClass(/selected/); await expect(page.locator("#grid tbody td.selected")).toHaveCount(1); }); // improvise-b8ot moved these five surfaces onto one handler per stable root. // The three tests below are the properties a naive delegation loses, each of // which is silent: the page still paints, and only the pointer stops working. // The fourth — closing a live editor FIRST — is the test above, which asserts // the old editor is gone and the new one is open with no Escape between. test("a mousedown alone reaches the grid, because click comes too late", async ({ page, }) => { await openApp(page); const cells = page.locator("#grid tbody td"); // MOUSEDOWN ONLY, dispatched with no click behind it. A handler bound to // `click` does nothing here, which is the whole of improvise-0x9u: from an // editing state the blur repaints and the is gone before click fires. await cells.nth(4).dispatchEvent("mousedown"); await expect(cells.nth(4).locator("input")).toHaveCount(1); // And the repaint is SKIPPED on that branch: a repaint would take the // away, so its survival is the assertion. await page.waitForTimeout(300); await expect(page.locator("#grid input")).toHaveCount(1); }); test("the grid prevents its own default, and only over a cell", async ({ page }) => { await openApp(page); // `window` is last in the bubble path, so it sees what #grid's handler did. await page.evaluate(() => { window.__defaultPrevented = null; window.addEventListener("mousedown", (e) => { window.__defaultPrevented = e.defaultPrevented; }); }); await page.locator("#grid tbody td").nth(1).dispatchEvent("mousedown"); expect(await page.evaluate(() => window.__defaultPrevented)).toBe(true); // A header carried no listener before delegation, so preventing ITS // default would be new behaviour — the check must be on the cell, not the // root. Without this the first assertion passes for a blanket handler. await page.evaluate(() => { window.__defaultPrevented = null; }); await page.locator("#grid thead th").last().dispatchEvent("mousedown"); expect(await page.evaluate(() => window.__defaultPrevented)).toBe(false); }); test("a click's coordinates are read off the node, not closed over", async ({ page, }) => { await openApp(page); const cells = page.locator("#grid tbody td"); await expect(cells.nth(0)).toHaveClass(/selected/); // Rewriting the attribute must change where the click lands. A per-node // closure over `gridPlan`'s answer ignores it and this fails — which is // what makes the delegated handler a LOOKUP rather than a second // derivation of which data row was clicked (improvise-b8ot). await cells.nth(0).evaluate((td) => { td.dataset.row = "2"; }); await cells.nth(0).dispatchEvent("mousedown"); await page.keyboard.press("Escape"); const thirdRow = page.locator("#grid tbody tr").nth(2).locator("td").first(); await expect(thirdRow).toHaveClass(/selected/); await expect(page.locator("#grid tbody td.selected")).toHaveCount(1); }); }); test.describe("the selection is on screen", () => { // improvise-jq8l: `web/app.js` called `scrollIntoView` NOWHERE, so a selection // moved past the bottom of `#grid` simply stayed there, invisible, with no way // back but scrolling by hand. Only a browser can see this: it is a fact about // `#grid`'s own scroll box, not about the projection, which had already sent // the row — the page renders more rows than fit, for the reason below. test("a selection driven below the fold scrolls itself into view", async ({ page }) => { // `harness/fixtures/tall.improv`: 30 rows in three groups, which is more // than the box can show because `ROW_PX` (26) under-estimates the real // 28.6px row and inline group rows are deliberately not charged for. await page.goto("/app/index.html?doc=tall-scroll.improv&seed=tall.improv"); await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 }); await expect(page.locator("#view-name")).toHaveText("Tall"); await expect(page.locator("#grid tbody th[scope=row]").first()).toHaveText("R01"); await page.locator("#grid").focus(); // Not vacuous by construction: if `#grid` does not overflow there is // nothing to scroll and every assertion below passes on a broken page. const overflow = await page .locator("#grid") .evaluate((g) => g.scrollHeight - g.clientHeight); expect(overflow).toBeGreaterThan(0); for (let i = 0; i < 25; i++) await page.keyboard.press("ArrowDown"); // The selected cell must be inside the box the user can see. Measured // against `#grid`'s own rect rather than the window's, because `#grid` is // the scroll container — the page itself does not scroll. await expect .poll(async () => page.locator("#grid").evaluate((g) => { const sel = g.querySelector("td.selected"); if (!sel) return "no selected cell"; const cell = sel.getBoundingClientRect(); const box = g.getBoundingClientRect(); return cell.bottom <= box.bottom + 1 && cell.top >= box.top - 1; }), ) .toBe(true); // And it really did have to move: a page that never scrolls leaves this 0. expect(await page.locator("#grid").evaluate((g) => g.scrollTop)).toBeGreaterThan(0); }); }); // The page's own chrome: what a visitor lands on, and where the prompt is. // Only a browser can answer either — both are facts about layout and about // storage that survives a reload. What these CANNOT see is the rest of the // styling; that residue is named in context/code/clients.md. test.describe("the shell puts the application first", () => { const notice = (page) => page.locator("#backend-notice"); const stored = (page) => page.evaluate((k) => localStorage.getItem(k), NOTICE_KEY); // The deployment notice is ten lines of configuration reference — ?doc=, // ?seed=, ?ws=, wss:// and reverse proxies — and it was above the fold on // every visit. See context/test-evidence/clients.md. test("the deployment notice starts collapsed and remembers being opened", async ({ page, }) => { await page.goto("/app/index.html?doc=notice-memory.improv"); await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 }); // The one sentence a visitor needs is never collapsed; the reference is. await expect(page.locator("#backend-notice .notice-lede")).toBeVisible(); await expect(page.locator("#backend-notice .notice-body")).toBeHidden(); // BOTH directions, because a page that ignores storage passes the first // half and a page that always opens passes the second (design-principles // §7: assert the direction the default cannot supply). await notice(page).locator("summary").click(); await expect(page.locator("#backend-notice .notice-body")).toBeVisible(); // `toggle` is dispatched as a task, not synchronously with the click, so // the answer must be OBSERVED as stored before a reload can be said to // have read it back. Without this the reload races the write. await expect.poll(() => stored(page)).toBe("open"); await page.reload(); await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 }); await expect(page.locator("#backend-notice .notice-body")).toBeVisible(); await notice(page).locator("summary").click(); await expect(page.locator("#backend-notice .notice-body")).toBeHidden(); await expect.poll(() => stored(page)).toBe("closed"); await page.reload(); await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 }); await expect(page.locator("#backend-notice .notice-body")).toBeHidden(); }); // The `
` is the browser's control, and the page's own keydown // handler claims Enter. Without the guard a keyboard visitor could focus // the summary and not open it. See context/test-evidence/clients.md. test("the notice opens from the keyboard, which the grid's own handler must not claim", async ({ page, }) => { await page.goto("/app/index.html?doc=notice-keyboard.improv"); await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 }); await expect(page.locator("#backend-notice .notice-body")).toBeHidden(); await notice(page).locator("summary").focus(); await page.keyboard.press("Enter"); await expect(page.locator("#backend-notice .notice-body")).toBeVisible(); }); // The prompt was a small line in the page's bottom-left corner, 800px from // the cell being typed into. See context/test-evidence/clients.md. test("the minibuffer is raised over the grid rather than filed under it", async ({ page, }) => { await page.goto("/app/index.html?doc=minibuffer-place.improv"); await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 }); await page.locator("#grid").focus(); await expect(page.locator("#minibuffer")).toBeHidden(); // `a` is the one text-entry room this client can both enter and commit in // (`keys.spec.js` drives the formula one). The precondition is that it // really opened, or every measurement below is of a hidden element. await page.keyboard.press("a"); await expect(page.locator("#mode")).toHaveText("INSERT"); await expect(page.locator("#minibuffer")).toBeVisible(); const boxes = await page.evaluate(() => { const rect = (id) => { const { top, bottom, left, right } = document .getElementById(id) .getBoundingClientRect(); return { top, bottom, left, right }; }; return { mb: rect("minibuffer"), grid: rect("grid"), viewport: { height: window.innerHeight, width: window.innerWidth }, mbFont: parseFloat(getComputedStyle(document.getElementById("minibuffer")).fontSize), bodyFont: parseFloat(getComputedStyle(document.body).fontSize), }; }); // On screen without scrolling: the property a bottom-of-document line // does not have once the page is taller than the window. expect(boxes.mb.bottom).toBeLessThanOrEqual(boxes.viewport.height); expect(boxes.mb.top).toBeGreaterThanOrEqual(0); // And OVER the grid, not in a strip beneath it. This is what separates // "raised" from "moved": a static bottom bar cannot overlap #grid at all. expect(boxes.mb.top).toBeLessThan(boxes.grid.bottom); expect(boxes.mb.bottom).toBeGreaterThan(boxes.grid.top); // The one prominence claim a browser can check. It was body-sized before. expect(boxes.mbFont).toBeGreaterThan(boxes.bodyFont); await page.keyboard.press("Escape"); await expect(page.locator("#minibuffer")).toBeHidden(); }); }); // The stylesheet's own cascade, which no other test here can reach: an // error is a colour now, where it used to be a colour AND an opacity, so // the order of two equally-specific rules decides it. See // context/test-evidence/clients.md. test.describe("the grid's semantic colours survive the cascade", () => { test("an error cell stays an error cell when it is also dimmed or selected", async ({ page, }) => { await page.goto("/app/index.html?doc=error-colour.improv"); await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 }); // The question is about the STYLESHEET, so the combinations are put to it // directly: the document that produces each of them is a separate matter, // and a fixture per combination would test the fixture. const colours = await page.evaluate(() => { const row = document.querySelector("#grid tbody tr"); const read = (classes) => { const td = document.createElement("td"); td.className = classes; td.textContent = "#err"; row.appendChild(td); const colour = getComputedStyle(td).color; td.remove(); return colour; }; return { // An aggregate error, which is the common case: `editable` is false // for every cell that is not a stored record. dimmed: read("error"), selected: read("error selected"), typing: read("error typing"), // What "dimmed" must NOT be, read off a plain unsettable cell. dim: read(""), // …and what it must be, read off an error the dimming cannot reach. error: read("error editable"), }; }); expect(colours.error).not.toBe(colours.dim); expect(colours.dimmed).toBe(colours.error); expect(colours.selected).toBe(colours.error); expect(colours.typing).toBe(colours.error); }); });