From 283561107c11ed23299ebdbef41134d4c4280053 Mon Sep 17 00:00:00 2001 From: Daniel Zyto Date: Wed, 26 Aug 2026 14:57:52 +0200 Subject: [PATCH] test(runner): close the DEV-2530 guide/tests parity gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seven remaining items from the DEV-2203 parity audit, all deterministic — every test runs in PR CI with no deployed API, no bundler, no fixture demo: - revoked card UI: badge stays, kebab disappears entirely, live card as the control for both (authed-actions) - share dialog: the three labelled link fields pinned by exact accessible names, and My-demos Copy link proven byte-equal to the dialog's client link via the real clipboard (authed-actions) - versionLocked on /share/:id: no trigger, no pencil, no input, and a click that opens nothing — with the playground as the interactive contrast, and the pinned value steered off the DEFAULT_VERSION fallback so it cannot pass by accident (all-demos) - FILES-header download hands over the same zip as the top bar: per-entry byte comparison, marker present in both so two stale zips cannot satisfy it (editor-download) - docs picker search: cross-category and cross-section reach (the searchLeaves flattening), click and keyboard selection both land the ?docs= URL, garbage query shows the empty state (docs-picker, new) - container boot UX: 'Starting the live dev server…', the Details log tail verbatim, and both resolutions — failure to the error card with the cause line first, success to the pointed iframe (container-boot-ux, new; the session lifecycle stubbed as three HTTP shapes) - /admin smoke: login wall for the signed-out (redirect polled — the navigation is an effect), one row per session with honest awake/slept labels, the usd() formatting branches, the billable-basis tooltip, and a pager that asks for the right next offset (admin-panel, new) - the MIN_BARE_NUMERIC_PKG_PR_NEW_REF boundary: 999 refused with the validator's exact message, 1000 accepted and pinned to the tarball URL, and the same threshold governing package.json derivation (ht-version-resolve) Every test mutation-checked: 18 targeted product regressions (guard dropped, label reworded, lock removed, zippers diverged, haystack narrowed, tail reordered, wall dropped, rounding broken, threshold moved both ways) each redden exactly the tests that claim to guard them; sources restored, tree verified. Full deterministic run: 48 passed; pipeline suite 893/0. --- runner/e2e/admin-panel.spec.ts | 256 ++++++++++++++++++ runner/e2e/all-demos.spec.ts | 127 +++++++++ runner/e2e/authed-actions.spec.ts | 172 ++++++++++++ runner/e2e/container-boot-ux.spec.ts | 228 ++++++++++++++++ runner/e2e/docs-picker.spec.ts | 284 ++++++++++++++++++++ runner/e2e/editor-download.spec.ts | 68 +++++ runner/pipeline/ht-version-resolve.test.mjs | 61 +++++ 7 files changed, 1196 insertions(+) create mode 100644 runner/e2e/admin-panel.spec.ts create mode 100644 runner/e2e/container-boot-ux.spec.ts create mode 100644 runner/e2e/docs-picker.spec.ts diff --git a/runner/e2e/admin-panel.spec.ts b/runner/e2e/admin-panel.spec.ts new file mode 100644 index 000000000..a8b9b74d4 --- /dev/null +++ b/runner/e2e/admin-panel.spec.ts @@ -0,0 +1,256 @@ +import { test, expect, type Page } from "@playwright/test"; +import { signIn, stubShell } from "./helpers"; + +// The /admin BROWSER surface (DEV-2530 item 7, second half). +// +// session-abandoned-create.spec.ts already exercises `/api/admin/sessions` +// against the deployed Worker nightly — the *data* is covered. What nothing +// covered is the page an operator actually looks at: AdminGate's login wall, +// and the LiveSessionsSection table that DEV-2567 rebuilt (state labels, +// billable-time pricing, the pager that replaced the silent `limit: 50` cap). +// A rendering regression there is invisible to an API test by construction. +// +// Everything is stubbed with `page.route`, so the whole file runs in PR CI +// with no deployed API: the panel is one `GET /api/admin/usage` plus +// `GET /api/admin/sessions` for paging/filtering (Admin.tsx's own header +// comment), and both answer deterministic payloads built here. The payload +// shapes mirror `LiveSessionsPage` in Admin.tsx / `admin.ts` in the Worker — +// if those drift, this spec fails the way production would. +// +// The oracles are the rendered strings, not the stub echoed back: `usd()` and +// `duration()` in Admin.tsx decide what an operator reads ($1.50 vs $0.048 — +// the sub-dollar branch keeps three decimals so a cheap session doesn't +// render as $0.00), so the assertions pin the formatted VALUE the stub +// implies, magnitude branches included. + +/** One `LiveSession` row as `/api/admin/sessions` serves it (field names + * confirmed against workers/api/src/admin.ts). Refs are 8-hex digests — + * session ids are bearer capabilities and never reach the browser. */ +function session(overrides: { + ref: string; + framework: string; + awakeSeconds: number; + billableSeconds: number; + quietSeconds: number; + state: "awake" | "slept"; + estimatedUsd: number; +}) { + return { startedAt: 1_700_000_000_000, ...overrides }; +} + +/** A page envelope in the `LiveSessionsPage` shape. */ +function sessionsPage( + rows: ReturnType[], + counts: { offset: number; limit: number; total: number; awakeCount: number; meterCount: number }, +) { + return { rows, ...counts, truncated: false }; +} + +/** + * The smallest `UsageReport` the panel renders without throwing — every field + * Admin.tsx dereferences, empty where emptiness has a designed rendering + * ("Nothing metered yet this month.", the LITELLM hint). The interesting part, + * `liveSessions`, is the caller's: it is the embedded first page of the table, + * which is why the main flow needs no `/api/admin/sessions` call at all. + */ +function usageReport(liveSessions: ReturnType) { + return { + generatedAt: 1_700_000_100_000, + windowDays: 30, + budget: { tier: "ok", pct: 0.1, spendUsd: 10, limitUsd: 100, reconciled: true, enforced: true }, + settings: { + limitUsd: 100, warnUsd: 40, anonBlockUsd: 60, newBlockUsd: 80, closedUsd: 95, + enforce: true, alertsUsd: [50], source: "defaults" as const, updatedAt: null, updatedBy: null, + }, + audience: { + totals: { views: 0, visitors: 0, bots: 0 }, + daily: [], pages: [], demos: [], referrers: [], countries: [], devices: [], browsers: [], languages: [], + }, + spendBySku: {}, + ledger: [], + usage: [], + demos: { total: 0, revoked: 0, createdInWindow: 0, byFramework: [], topViewed: [] }, + liveSessions, + }; +} + +/** The Live sessions section — scoped by its own h2, because the SKU table and + * the top-viewed table are also ``s and a bare role query is ambiguous. */ +function liveSessionsSection(page: Page) { + return page.locator("section").filter({ + has: page.getByRole("heading", { name: "Live sessions", exact: true }), + }); +} + +test("signed out, /admin is a login wall — the panel never renders and no admin data is fetched", async ({ page }) => { + // The contrast case. AdminGate answers a null user by calling `login()` + // (App.tsx), a top-level `location.href` to the broker. stubShell's abort of + // `**/broker/login**` is not enough here: an *aborted* top-level navigation + // dumps Chromium on chrome-error://chromewebdata and takes the splash with + // it (probed while writing this spec). A 204 is the neutering that keeps the + // page: Chromium ignores no-content main-frame navigations and stays put — + // registered after stubShell, so it wins (last-registered matches first, + // the same override trick style-panel.spec.ts uses for /api/versions). + await stubShell(page); + let loginRedirects = 0; + await page.route("**/broker/login**", (route) => { + loginRedirects += 1; + return route.fulfill({ status: 204 }); + }); + + // The sharpest oracle for "the panel did not render" is that it never asked + // for its data: AdminPanel fetches /api/admin/usage from a mount effect, so + // any rendering of it — even a flash before a redirect — trips this flag. + let adminApiHit = false; + await page.route("**/api/admin/**", (route) => { + adminApiHit = true; + return route.fulfill({ status: 401, json: { error: "unauthorized" } }); + }); + + await page.goto("/admin"); + + await expect(page.getByText("Sign in to view usage…")).toBeVisible(); + // No panel chrome: the h1 belongs to AdminPanel alone. + await expect(page.getByRole("heading", { name: /usage & cost/ })).toHaveCount(0); + // Both halves of the gate: it sent the visitor to the broker, and it never + // touched the admin API on their behalf. Polled: the redirect is a + // location.href navigation from AdminGate's effect, so nothing orders the + // route interception before a synchronous read of the counter. + await expect.poll(() => loginRedirects, { + message: "the gate redirected to the broker login", + }).toBeGreaterThan(0); + expect(adminApiHit, "no admin endpoint was called while signed out").toBe(false); +}); + +test("signed in, the table renders one row per session with honest state labels and derived costs", async ({ page }) => { + await stubShell(page); + await signIn(page); + + // Two awake rows in the report's embedded first page (the default view is + // awake-only — DEV-2567's whole point), plus one slept meter behind the + // filter. Cost inputs chosen to land on both `usd()` branches: + // 1.5 -> "$1.50" (>= $1: two decimals) + // 0.048 -> "$0.048" (sub-dollar: three decimals — the branch that keeps a + // cheap session from rendering as $0.00) + // 0.12 -> "$0.120" (slept row — the sub-dollar branch again, on a row + // whose age (2h) and billable time (15m) diverge; the + // pricing itself is the server's estimatedUsd, rendered + // verbatim, so what this row pins client-side is the + // formatting plus the billable-basis tooltip below) + const awake = [ + session({ ref: "aaaa1111", framework: "react", awakeSeconds: 4260, billableSeconds: 4260, quietSeconds: 30, state: "awake", estimatedUsd: 1.5 }), + session({ ref: "bbbb2222", framework: "angular", awakeSeconds: 300, billableSeconds: 300, quietSeconds: 45, state: "awake", estimatedUsd: 0.048 }), + ]; + const slept = session({ ref: "cccc3333", framework: "vue", awakeSeconds: 7200, billableSeconds: 900, quietSeconds: 600, state: "slept", estimatedUsd: 0.12 }); + + await page.route("**/api/admin/usage**", (route) => + route.fulfill({ json: usageReport(sessionsPage(awake, { offset: 0, limit: 25, total: 2, awakeCount: 2, meterCount: 3 })) }), + ); + // Filter changes re-read `/api/admin/sessions` alone (Admin.tsx). Answer by + // the `awake` query param, exactly as parseSessionQuery reads it. + await page.route("**/api/admin/sessions?*", (route) => { + const awakeOnly = new URL(route.request().url()).searchParams.get("awake") !== "0"; + const rows = awakeOnly ? awake : [...awake, slept]; + return route.fulfill({ + json: sessionsPage(rows, { offset: 0, limit: 25, total: rows.length, awakeCount: 2, meterCount: 3 }), + }); + }); + + await page.goto("/admin"); + await expect(page.getByRole("heading", { name: /usage & cost/ })).toBeVisible(); + + const section = liveSessionsSection(page); + const rows = section.locator("tbody tr"); + + // One row per stubbed session, addressed by ref — never by session id. + await expect(rows).toHaveCount(2); + const react = rows.filter({ hasText: "aaaa1111" }); + await expect(react.getByRole("cell", { name: "react", exact: true })).toBeVisible(); + await expect(react.getByRole("cell", { name: "awake", exact: true })).toBeVisible(); + // Age is duration(4260s) = "1h 11m"; cost is usd(1.5) = "$1.50". + await expect(react.getByRole("cell", { name: "1h 11m", exact: true })).toBeVisible(); + await expect(react.getByRole("cell", { name: "$1.50", exact: true })).toBeVisible(); + // The sub-dollar branch: three decimals, not a rounded-to-nothing "$0.05". + await expect(rows.filter({ hasText: "bbbb2222" }).getByRole("cell", { name: "$0.048", exact: true })).toBeVisible(); + + // The counts line prices the filter honestly: 2 awake now, 1 more meter + // hiding behind the checkbox. Fed by awakeCount/meterCount, not rows.length — + // a table that recomputes these from the visible page is the regression. + await expect(section.getByText("2 awake · 1 slept but still metered (24h window)")).toBeVisible(); + + // Two rows fit one 25-row page, so no pager — the other half of the pager + // test below, asserted here where the fixture makes it true. + await expect(section.getByRole("button", { name: "Next →" })).toHaveCount(0); + + // Unchecking the filter is the documented route to the 24h tail (DEV-2567: + // that tail is where a phantom row has to be visible to be killed). + await section.getByRole("checkbox", { name: "Only sessions still awake" }).uncheck(); + + await expect(rows).toHaveCount(3); + const sleptRow = rows.filter({ hasText: "cccc3333" }); + // The state cell says slept AND how long the meter has been quiet — + // duration(600s) = "10m 0s". A bare "slept" would hide the one number that + // tells an operator whether the row is a phantom or a backgrounded tab. + await expect(sleptRow.getByRole("cell", { name: "slept · quiet 10m 0s", exact: true })).toBeVisible(); + // "$0.120" is the stub's own estimatedUsd through the usd() formatter — the + // client renders the server's figure verbatim, so the assertion pins the + // sub-dollar formatting branch, not the pricing math (that lives in + // admin.ts and is the API tests' job). The tooltip IS client behavior: the + // billable basis ("15m 0s billable"), the documented title contract that + // stops a 2h-old slept row from reading as a 2h bill (DEV-2567's misread). + await expect(sleptRow.getByRole("cell", { name: "$0.120", exact: true })).toBeVisible(); + await expect(sleptRow.locator('td[title="15m 0s billable"]')).toBeVisible(); +}); + +test("past one page the pager appears, and Next asks the API for the next offset", async ({ page }) => { + await stubShell(page); + await signIn(page); + + // 27 awake meters against the server's 25-row page (SESSIONS_PAGE_SIZE in + // session-listing.ts). Refs generated as real 8-hex digests so the fixture + // stays contract-plausible. + const ref = (i: number) => (0x10000000 + i).toString(16); + const rowAt = (i: number) => + session({ ref: ref(i), framework: "react", awakeSeconds: 120, billableSeconds: 120, quietSeconds: 10, state: "awake", estimatedUsd: 0.01 }); + const firstPage = sessionsPage( + Array.from({ length: 25 }, (_, i) => rowAt(i)), + { offset: 0, limit: 25, total: 27, awakeCount: 27, meterCount: 27 }, + ); + const secondPage = sessionsPage( + [rowAt(25), rowAt(26)], + { offset: 25, limit: 25, total: 27, awakeCount: 27, meterCount: 27 }, + ); + + await page.route("**/api/admin/usage**", (route) => route.fulfill({ json: usageReport(firstPage) })); + // Record the offsets the panel actually sends: the pager's promise is the + // query contract (`?awake=1&offset=25`), not a client-side slice of rows it + // already holds — the old table's silent 50-row cap was exactly a client + // that never asked for more. + const askedOffsets: number[] = []; + await page.route("**/api/admin/sessions?*", (route) => { + const params = new URL(route.request().url()).searchParams; + const offset = Number(params.get("offset") ?? 0); + askedOffsets.push(offset); + return route.fulfill({ json: offset >= 25 ? secondPage : firstPage }); + }); + + await page.goto("/admin"); + + const section = liveSessionsSection(page); + await expect(section.locator("tbody tr")).toHaveCount(25); + + // The pager renders because total (27) > limit (25), and says where you are. + await expect(section.getByText("1–25 of 27")).toBeVisible(); + await expect(section.getByRole("button", { name: "← Previous" })).toBeDisabled(); + const next = section.getByRole("button", { name: "Next →" }); + await expect(next).toBeEnabled(); + + await next.click(); + + await expect(section.locator("tbody tr")).toHaveCount(2); + await expect(section.getByText("26–27 of 27")).toBeVisible(); + // On the last page the roles flip — Next has nothing left to ask for. + await expect(section.getByRole("button", { name: "Next →" })).toBeDisabled(); + await expect(section.getByRole("button", { name: "← Previous" })).toBeEnabled(); + expect(askedOffsets, "Next re-read /api/admin/sessions at the next offset").toEqual([25]); +}); diff --git a/runner/e2e/all-demos.spec.ts b/runner/e2e/all-demos.spec.ts index 9eee91874..ab94696cd 100644 --- a/runner/e2e/all-demos.spec.ts +++ b/runner/e2e/all-demos.spec.ts @@ -277,6 +277,133 @@ test("an access check that fails does not lock the owner out", async ({ page }) await expect(page.getByRole("button", { name: /^Save/ })).toBeVisible(); }); +/** The top bar's background profile refresh, kept alive. Unstubbed it reaches + * the real `VITE_API_BASE`, 401s for the faked token, and since DEV-2534 a 401 + * clears the session — so any later assertion that leans on "still signed in" + * would be testing a signed-out page without saying so. Same stub as the + * revoke test below, hoisted for the DEV-2530 pair. */ +async function stubProfile(page: Page) { + await page.route("**/api/profile", (route) => + route.fulfill({ + json: { + email: EMAIL, + display_name: "Dev", + saved_name: null, + description: null, + avatar_url: null, + initial: "D", + }, + }), + ); +} + +// DEV-2530. The guide sells one safety property for a share link: a shared demo +// cannot change its version — the recipient sees exactly the build the author +// pinned. `EditorShell` implements it as `versionLocked` on the share route and +// `PreviewBar` renders the version as inert muted text instead of the picker, +// but until now nothing asserted it, so the lock could be dropped without a +// single test going red. + +/** The locked version pill: the bar's static "Handsontable " text, where the + * picker sits on every other route. The preview *status* bar prints the same + * string (`aria-label="Preview status"`), so a bare `getByText` resolves to + * two elements — the pill is the span that is not inside the status bar. */ +function lockedVersionPill(page: Page, version: string) { + return page.locator('span:not([aria-label="Preview status"] span)', { + hasText: new RegExp(`^Handsontable ${version.replaceAll(".", "\\.")}$`), + }); +} + +/** Pin one demo's saved metadata to a version other than `DEFAULT_VERSION` + * ("18.0.0", which `stubDemos` serves): with the default, "the pill shows the + * author's pin" and "the pill fell back to the app default" are the same + * green. Registered after `stubDemos`, so it wins the meta GET for this id + * and defers everything else (`/access`, `/source`, other ids) to the rig. */ +async function pinDemoMeta(page: Page, id: string, version: string) { + await page.route("**/api/demos/*", (route) => { + if (!new URL(route.request().url()).pathname.endsWith(`/api/demos/${id}`)) return route.fallback(); + return route.fulfill({ + json: { title: "Their grid", description: null, ht_version: version, created_at: null }, + }); + }); +} +test("the share page locks the version: text to read, not a menu to open", async ({ page }) => { + await stubShell(page); + await signIn(page); + await stubDemos(page); + await stubProfile(page); + // Two versions instead of stubShell's one: interactivity is only proven by a + // pick that lands, and a one-option menu has nowhere to move. Registered + // after stubShell, so this route wins (Playwright matches newest-first). + await page.route("**/api/versions", (route) => + route.fulfill({ json: { latest: "18.0.0", next: "19.0.0-next.1", versions: ["18.0.0", "17.1.0"] } }), + ); + // Three distinct versions in play — the app default (18.0.0), this visitor's + // playground pick (17.1.0), and the author's pin (16.1.0) — so the value on + // the share pill can only be the author's. + await pinDemoMeta(page, THEIRS, "16.1.0"); + + // First, the contrast that makes the lock falsifiable: on the playground the + // same control is a live picker under exactly these accessible names. Without + // this half, renaming the trigger (or the pencil) would turn every "absent on + // the share page" assertion below into a vacuous pass. + await page.goto("/?example=react"); + const trigger = page.getByRole("button", { name: "Handsontable version", exact: true }); + await expect(trigger).toContainText("18.0.0"); + await trigger.click(); + await expect(page.getByRole("listbox", { name: "Handsontable version" })).toBeVisible(); + await page.getByRole("option", { name: "17.1.0", exact: true }).click(); + await expect(trigger).toContainText("17.1.0"); + // Signed in and off the share route, the custom-version pencil is offered + // too — seeing it here is what gives its absence on the share page teeth. + await expect(page.getByRole("button", { name: "Set a custom Handsontable version" })).toBeVisible(); + + // Now the share page, same signed-in visitor. The version is readable, and + // it is the author's pin — not the default this run booted with, and not the + // 17.1.0 this visitor just picked one navigation ago… + await page.goto(`/share/${THEIRS}`); + await expect(lockedVersionPill(page, "16.1.0")).toBeVisible(); + // …but it is nobody's control: no picker trigger, no pencil, no free-text field. + await expect(page.getByRole("button", { name: "Handsontable version", exact: true })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Set a custom Handsontable version" })).toHaveCount(0); + await expect(page.getByLabel("Custom Handsontable version")).toHaveCount(0); + + // What the lock means for a user, not just for the DOM: clicking where the + // picker sits on every other route opens nothing. MenuButton renders its + // popover synchronously on the trigger's click, so if this text were still a + // live trigger the listbox would exist by the time the click resolves. + await lockedVersionPill(page, "16.1.0").click(); + await expect(page.getByRole("listbox", { name: "Handsontable version" })).toHaveCount(0); + await expect(page.getByRole("option")).toHaveCount(0); + + // And none of those absences were the signed-out fallback: the session is intact. + expect(await page.evaluate(() => sessionStorage.getItem("hot_token"))).toBe("e2e-token"); +}); + +test("the lock belongs to the route, not the visitor: your own share page is pinned too", async ({ page }) => { + // The dangerous regression here is keying the lock off ownership (`/access`) + // instead of the route: the owner would get a live picker on the very page + // their recipients have open, and one switch there rewrites what everyone + // else is looking at. The signed-in owner on their own `/share/:id` is the + // only visitor who can catch that — the previous test's stranger cannot. + await stubShell(page); + await signIn(page); + await stubDemos(page); + await stubProfile(page); + await pinDemoMeta(page, MINE, "16.1.0"); + + await page.goto(`/share/${MINE}`); + + // Still the share playground — owning the demo redirects nowhere… + await expect(page).toHaveURL(new RegExp(`/share/${MINE}$`)); + // …and still pinned: the author's version, as text, with no picker and no pencil. + await expect(lockedVersionPill(page, "16.1.0")).toBeVisible(); + await expect(page.getByRole("button", { name: "Handsontable version", exact: true })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Set a custom Handsontable version" })).toHaveCount(0); + // Signed in throughout, so the absences above are the lock, not a lost session. + expect(await page.evaluate(() => sessionStorage.getItem("hot_token"))).toBe("e2e-token"); +}); + // DEV-2534 / DEV-2544. Delete is only drawn on a card this page believes is // yours, so a 403 here is a genuine disagreement between the UI and the server — // which is why the message names ownership instead of echoing the wire string, diff --git a/runner/e2e/authed-actions.spec.ts b/runner/e2e/authed-actions.spec.ts index 55233e6f9..67637b423 100644 --- a/runner/e2e/authed-actions.spec.ts +++ b/runner/e2e/authed-actions.spec.ts @@ -651,3 +651,175 @@ test("a background profile refresh on an expired session is silent", async ({ pa // account control with or without a profile. await expect(saveButton(page)).toBeVisible(); }); + +// ── DEV-2530 parity follow-ups: the revoked card, and the share dialog's links ── +// +// The guide sells deletion as irreversible *and visible*: the share link starts +// answering 410 (covered live in share-create-live.spec.ts — not repeated here), +// and the row itself stays on My demos, marked. What was uncovered is that +// marking: the API only ever revokes — rows persist forever — and the card is +// where a user learns their demo is really gone. + +/** One `GET /api/demos` row as the worker returns it (`SELECT *` around + * `workers/api/src/index.ts:1236`; the client contract is `DemoListItem` in + * MyDemos.tsx). Mirrors `demo()` in all-demos.spec.ts — a local copy, per the + * helpers.ts rule that specs do not edit the shared file mid-flight. */ +function demoRow(id: string, title: string, revoked: 0 | 1) { + return { + id, + title, + description: null, + framework: "react", + tier: 1, + ht_version: "18.0.0", + forked_from: null, + visibility: "unlisted", + revoked, + created_at: "2026-08-01T10:00:00.000Z", + updated_at: "2026-08-01T10:00:00.000Z", + created_by: EMAIL, + }; +} + +/** The listing. `?` in a Playwright glob matches any single character, so the + * literal query `?scope=` still matches — the same pattern all-demos.spec.ts + * relies on. Distinct from `stubSavedDemo`'s `**​/api/demos/**`, which needs a + * literal `/` after `demos` and so never answers this GET. */ +async function stubDemosList(page: Page, rows: ReturnType[]) { + await page.route("**/api/demos?scope=*", (route) => + route.fulfill({ json: { demos: rows, scope: "mine" } }), + ); +} + +// Card locators, same shape as all-demos.spec.ts: a card is the `article` whose +// text carries the title, the kebab is its `Actions for ` button. +const demoCard = (page: Page, title: string) => page.locator("article").filter({ hasText: title }); +const cardKebab = (page: Page, title: string) => + demoCard(page, title).getByRole("button", { name: /Actions for/ }); +const menuItem = (page: Page, name: string) => page.getByRole("menuitem", { name, exact: true }); + +// The revoked treatment (MyDemos.tsx ~453-475): the badge appears in the card's +// meta row, and the kebab is not rendered at all — every action needs something +// the revoke took away (`getDemoSource` answers null once `revoked` is set), so +// a menu of five dead ends is withheld rather than disabled. +// +// The contrast between the two cards is the oracle, deliberately: a badge +// asserted alone cannot fail when badges start rendering everywhere, and a +// missing kebab alone cannot fail when kebabs stop rendering at all. Each card +// is the other's control. +test("a revoked card keeps its badge and loses its kebab; a live card, the reverse", async ({ page }) => { + await stubShell(page); + await signIn(page); + await stubProfile(page); + await stubDemosList(page, [ + demoRow("e2elive01", "Still here", 0), + demoRow("e2egone01", "Already gone", 1), + ]); + + await page.goto("/my-demos"); + + // The revoked row is still a card — rows persist forever, and disappearing + // silently is exactly what the guide promises does not happen. + await expect(demoCard(page, "Already gone")).toBeVisible(); + + // The badge sits on the revoked card and only there. Lowercase and exact: + // pinning the rendered text is what makes this fail if the badge is reworded + // or moved onto every card. + await expect(demoCard(page, "Already gone").getByText("revoked", { exact: true })).toBeVisible(); + await expect(demoCard(page, "Still here").getByText("revoked", { exact: true })).toHaveCount(0); + + // No kebab on the revoked card — not a thinned menu, no menu. + await expect(cardKebab(page, "Already gone")).toHaveCount(0); + + // …while the live card keeps the full owner's menu. Asserting all five rows is + // what separates "revoked lost its actions" from "actions broke everywhere". + await cardKebab(page, "Still here").click(); + await expect(menuItem(page, "Open")).toHaveAttribute("href", "/edit/e2elive01"); + await expect(menuItem(page, "Copy link")).toBeVisible(); + await expect(menuItem(page, "Fork")).toBeVisible(); + await expect(menuItem(page, "Rename")).toBeVisible(); + await expect(menuItem(page, "Delete")).toBeVisible(); +}); + +// The dialog's contract, pinned by accessible name (ShareLinks.tsx). The labels +// are load-bearing: "Public client link:" is the naming examples#188 settled on +// for the URL that goes to clients, and the other two rows explain themselves — +// full-window is the demo without the editor, the embed URL works on +// handsontable.com only. `exact: true` throughout, so a rewording fails here +// instead of silently shipping a dialog that says something else. +test("the share dialog is three labelled links: client, full-window, docs embed", async ({ page }) => { + await stubShell(page); + await stubSavedDemo(page); + await signIn(page); + await stubProfile(page); + await page.goto(`/edit/${DEMO_ID}`); + + await expect(accountAvatar(page)).toBeVisible(); + await shareIcon(page).click(); + const dialog = shareDialog(page); + await expect(dialog).toBeVisible(); + + // Exactly three — a fourth field or a dropped one is a dialog redesign, and + // this file is where that should first fail. + await expect(dialog.getByRole("textbox")).toHaveCount(3); + + // Derived from the page rather than hardcoded, so the same assertions hold + // against a deployed E2E_BASE_URL run. + const origin = new URL(page.url()).origin; + const clientUrl = `${origin}/share/${DEMO_ID}`; + await expect(dialog.getByRole("textbox", { name: "Public client link:", exact: true })) + .toHaveValue(clientUrl); + // Full-window is the client link plus `?mode=full` — one URL, one suffix + // (ShareLinks.tsx builds it off `clientUrl`), and the value assertion is what keeps + // the two from drifting apart. + await expect( + dialog.getByRole("textbox", { name: "Full-window (the demo without the editor)", exact: true }), + ).toHaveValue(`${clientUrl}?mode=full`); + // The embed URL's host is `VITE_API_BASE` — an env concern, not this dialog's — + // so only the path is pinned. + await expect( + dialog.getByRole("textbox", { name: "Docs embed URL (handsontable.com only)", exact: true }), + ).toHaveValue(new RegExp(`/embed/${DEMO_ID}$`)); + + // Each field carries its own copy affordance, named for a screen reader. + await expect(dialog.getByRole("button", { name: "Copy public client link", exact: true })).toBeVisible(); + await expect(dialog.getByRole("button", { name: "Copy full-window", exact: true })).toBeVisible(); + await expect(dialog.getByRole("button", { name: "Copy docs embed url", exact: true })).toBeVisible(); +}); + +// One URL, two surfaces: the dialog *shows* the client link, the card's kebab +// *copies* it. They are separate implementations (App.tsx:2596 vs MyDemos.tsx's +// `copyLink`) with no shared constant, so nothing but a test keeps them equal — +// and a client pasted a `/d/:id` or `?mode=full` URL by one surface would get a +// different page than the other promised. +// +// The clipboard is read for real (`grantPermissions`), not intercepted: the +// app's `copyLink` swallows clipboard errors, so a recorder wrapped around +// `writeText` could pass on intent while the actual copy silently failed. +test("the card's Copy link copies the URL the dialog calls the public client link", async ({ page, context }) => { + await context.grantPermissions(["clipboard-read", "clipboard-write"]); + await stubShell(page); + await stubSavedDemo(page); + await signIn(page); + await stubProfile(page); + await stubDemosList(page, [demoRow(DEMO_ID, "Parity demo", 0)]); + + // First surface: what the dialog shows for this demo. + await page.goto(`/edit/${DEMO_ID}`); + await expect(accountAvatar(page)).toBeVisible(); + await shareIcon(page).click(); + const clientField = shareDialog(page).getByRole("textbox", { name: "Public client link:", exact: true }); + await expect(clientField).toHaveValue(new RegExp(`/share/${DEMO_ID}$`)); + const dialogUrl = await clientField.inputValue(); + + // Second surface: what the card's menu puts on the clipboard. + await page.goto("/my-demos"); + await cardKebab(page, "Parity demo").click(); + await menuItem(page, "Copy link").click(); + + // Polled, because `copyLink` is fire-and-forget off the menu click — the + // write can land a beat after the click handler returns. + await expect + .poll(() => page.evaluate(() => navigator.clipboard.readText())) + .toBe(dialogUrl); +}); diff --git a/runner/e2e/container-boot-ux.spec.ts b/runner/e2e/container-boot-ux.spec.ts new file mode 100644 index 000000000..9522a207a --- /dev/null +++ b/runner/e2e/container-boot-ux.spec.ts @@ -0,0 +1,228 @@ +import { test, expect, type Page } from "@playwright/test"; +import { stubShell } from "./helpers"; + +// The container boot surface (DEV-2530 item 6) — the guide's first troubleshooting +// entry. A Tier-2 boot is the one place in the product where a user stares at an +// empty pane for tens of seconds with no grid to reassure them, so the pane makes +// three promises (packages/editor-shell/src/PreviewPane.tsx, `BootLog`): it says +// what the wait is ("Starting the live dev server — …"), it keeps the live boot +// log within reach behind a Details disclosure, and it resolves — to the demo or +// to the error card — when the boot does. This spec is those three promises, one +// test each. +// +// Deterministic throughout: no container, no worker, no Docker. The whole session +// lifecycle is three HTTP shapes (packages/runtime/src/container.ts): +// +// POST /api/session -> { previewUrl, port } +// GET /api/session/:id/status?port=... -> { ready, log, failed? } (polled, 2.5s) +// DELETE /api/session/:id (teardown) +// +// and the log on screen is the *last* status response's `log` wholesale — +// `App.tsx` line ~2104 replaces `bootLog` on every progress emission, it never +// appends. So a stub holding `ready: false` holds the pane in `booting` with any +// log we choose, and flipping one field of the same stub resolves the boot either +// way. Modeled on preview-recovery.spec.ts's /api/session stub, extended with the +// status route the boot surface is actually driven by. + +/** The boot overlay's caption, verbatim from PreviewPane.tsx. Asserted as copy on + * purpose: the sentence IS the user-visible promise (the guide quotes it), so a + * reworded caption should fail this test and force the guide to move with it. */ +const BOOT_CAPTION = + "Starting the live dev server — first load installs dependencies and can take a minute…"; + +/** A plausible install-then-dev-server log. Deliberately already in `tailLines`' + * fixed point — no ANSI escapes, no `\r`, no blank lines, fewer than 12 lines — + * so the disclosure must show it byte-for-byte. The cleaning rules themselves + * (CSI stripping, last-`\r`-frame-wins) are PreviewPane's own concern, not this + * spec's promise. */ +const BOOT_LOG_LINES = [ + "Progress: resolved 231, reused 231, downloaded 0, added 231, done", + "dependencies:", + "+ handsontable 18.0.0", + "Done in 4.2s", + "> demo@0.0.0 dev /workspace", + "> vite --host --port 5173", +]; +const BOOT_LOG = BOOT_LOG_LINES.join("\n"); +const NEWEST_LINE = BOOT_LOG_LINES[BOOT_LOG_LINES.length - 1]!; + +/** A failed install, shaped like pnpm's real output. The ERR_ line matters: it is + * what `bootFailureDetail`'s announcing tier picks as the cause, and the prose + * hint BELOW it is the decoy that tier exists to skip (failure-log.ts, + * CAUSE_LINE's anchoring comment). */ +const FAILURE_CAUSE = + "ERR_PNPM_NO_MATCHING_VERSION No matching version found for handsontable@99.99.99"; +const FAILURE_LOG_LINES = [ + "Progress: resolved 12, reused 12, downloaded 0, added 12", + FAILURE_CAUSE, + "This error happened while installing the dependencies of demo@0.0.0", +]; +const FAILURE_LOG = FAILURE_LOG_LINES.join("\n"); + +/** Where the create stub claims the dev server lives. Never fetched in the hold + * and failure tests; the completion test routes it to a stub document so the + * runtime's point-the-iframe step has something real to load. `.invalid` is the + * RFC 2606 reserved TLD — if a request ever escapes the route, it cannot reach + * anything. */ +const PREVIEW_URL = "https://e2e-container-preview.invalid/"; + +type ContainerStatus = { ready: boolean; log: string; failed?: boolean }; + +/** + * Stub the Tier-2 session lifecycle (original: the /api/session 503 stub in + * preview-recovery.spec.ts). The create succeeds — the runtime reads only + * `{ previewUrl, port }` off the response and mints its session id client-side + * (container.ts, `mintSessionId`) — and the status route answers with whatever + * the returned controller currently holds, so a test can flip the boot's outcome + * mid-flight. The DELETE is answered too: `dispose()` fires it on page close, and + * swallowing it here keeps a developer machine's real worker out of the loop. + */ +async function stubContainerSession(page: Page, initial: ContainerStatus) { + let status = initial; + await page.route(/\/api\/session$/, (route) => { + if (route.request().method() !== "POST") return route.fallback(); + return route.fulfill({ json: { sessionId: "e2e-boot-ux", previewUrl: PREVIEW_URL, port: 5173 } }); + }); + await page.route(/\/api\/session\/[^/]+\/status/, (route) => route.fulfill({ json: status })); + await page.route(/\/api\/session\/[^/]+$/, (route) => + route.request().method() === "DELETE" ? route.fulfill({ json: { ok: true } }) : route.fallback(), + ); + return { + set(next: ContainerStatus) { + status = next; + }, + }; +} + +const preview = (page: Page) => page.locator('section[aria-label="Preview"]'); + +// `react-js` is a container starter (catalog.json: engine "container") — the same +// entry preview-recovery.spec.ts boots. It also flips `containerBoot` on the +// pane, which is what gates the whole surface under test: Tier 1 gets the bare +// spinner and none of this. +const CONTAINER_EXAMPLE = "/?example=react-js"; + +// Promise 1: while the container boots, the pane explains the wait and the boot +// log is one click away — and readable exactly as the container printed it. +// +// The stub never resolves, so nothing here is a race against a fast boot: every +// assertion runs against a pane that is *held* in `booting`, the state a user +// with a cold container sits in. `data-preview-status` is the documented +// machine-readable contract for that state (PreviewPane.tsx); the caption and the +// log are asserted as text because text is what the user was promised. +test("a booting container explains the wait and reveals the boot log behind Details", async ({ page }) => { + await stubShell(page); + await stubContainerSession(page, { ready: false, log: BOOT_LOG }); + + await page.goto(CONTAINER_EXAMPLE); + + await expect(preview(page)).toHaveAttribute("data-preview-status", "booting"); + await expect(page.getByText(BOOT_CAPTION)).toBeVisible(); + + // The always-visible single row is the NEWEST line (BootLog derives it from the + // tail), the one signal that distinguishes "installing" from "stuck". Asserted + // exact and BEFORE the disclosure opens: at that point the only element with + // this text is the live line — afterwards the tail <pre> matches too — and its + // visibility also proves the first poll's log replaced the runtime's own + // "Starting container…" placeholder, so the disclosure below reads our fixture, + // not the placeholder. + await expect(page.getByText(NEWEST_LINE, { exact: true })).toBeVisible(); + + // Details is a real disclosure with the accessible contract to match: + // aria-expanded, and no log <pre> in the pane until asked. + const details = page.getByRole("button", { name: "Details" }); + await expect(details).toHaveAttribute("aria-expanded", "false"); + await expect(preview(page).locator("pre")).toHaveCount(0); + + await details.click(); + await expect(details).toHaveAttribute("aria-expanded", "true"); + const tail = preview(page).locator("pre"); + await expect(tail).toBeVisible(); + // Strict equality, not toContainText: a contains-check would pass a tail that + // dropped, reordered, or duplicated lines — the exact defects a log pipeline + // invents (tailLines' own header documents one it used to). Equality is honest + // here only because the fixture avoids everything tailLines cleans; see + // BOOT_LOG_LINES. + expect(await tail.textContent(), "the disclosure shows the stubbed tail verbatim").toBe(BOOT_LOG); + + // And it closes again — a disclosure that only opens is a reveal, not a control. + await details.click(); + await expect(details).toHaveAttribute("aria-expanded", "false"); + await expect(preview(page).locator("pre")).toHaveCount(0); +}); + +// Promise 2 (resolution, unhappy leg): a boot script that exits nonzero turns the +// held boot surface into the error card — cause line first, full log kept, and a +// way out offered. +// +// The oracle chain is the real product path: `failed: true` on the status poll +// makes container.ts pick the cause via bootFailureDetail and emit +// ContainerBootFailure; App.tsx's describeRuntimeError composes "cause\n\ntail" +// into the card's <pre>, the ONLY place a user ever sees a boot log after a +// failure. The cause-leads assertion is the DEV-2533 regression pinned from the +// user's side: bury the ERR_ line under the tail again and this goes red. +test("a boot failure resolves the surface to the error card, cause first and log kept", async ({ page }) => { + await stubShell(page); + const session = await stubContainerSession(page, { ready: false, log: FAILURE_LOG }); + + await page.goto(CONTAINER_EXAMPLE); + // Prove we resolve *from* the held boot state, not that the app skipped it. + await expect(page.getByText(BOOT_CAPTION)).toBeVisible(); + + // Exactly what the status route reports when the boot script has exited: same + // log, `failed` now true. The next poll (2.5s cadence) delivers it. + session.set({ ready: false, log: FAILURE_LOG, failed: true }); + + await expect(preview(page)).toHaveAttribute("data-preview-status", "error"); + await expect(page.getByText("The preview could not start")).toBeVisible(); + await expect(page.getByText(BOOT_CAPTION)).toHaveCount(0); + + const body = preview(page).locator("pre"); + await expect(body).toContainText(FAILURE_CAUSE); + // The prose hint pnpm prints AFTER the code line stays visible as context — + // and must not have been picked over the ERR_ line as the headline. + await expect(body).toContainText("This error happened while installing"); + expect( + (await body.textContent()) ?? "", + "the picked cause line leads the card, not whatever the tail starts with", + ).toMatch(/^ERR_PNPM_NO_MATCHING_VERSION/); + + // A Tier-2 boot failure kills the container's dev server outright; the card's + // Restart is the only way back (preview-recovery.spec.ts proves the button + // remounts — here it only has to be offered). + await expect(page.getByRole("button", { name: "Restart preview" })).toBeVisible(); +}); + +// Promise 3 (resolution, happy leg): when the dev server comes up, the overlay — +// caption, live line, disclosure — gets out of the way and the pane is the dev +// server. +// +// `ready: true` alone is deliberately NOT the oracle for "resolved": the runtime +// points the iframe, waits for its `load` plus a 3.5s render grace, then re-probes +// the status route before claiming ready (container.ts, DEV-2547) — so the test +// must supply a document at previewUrl for the frame to load, and the iframe's +// `src` landing on that URL is the concrete proof the pane was handed to the +// stubbed dev server rather than the status attribute flipping over a blank. +test("a boot that completes drops the overlay and points the pane at the dev server", async ({ page }) => { + await stubShell(page); + // The "dev server": whatever the create response named as previewUrl, fulfilled + // locally so no request leaves the machine (and `.invalid` cannot resolve if + // one did). + await page.route(`${PREVIEW_URL}**`, (route) => + route.fulfill({ contentType: "text/html", body: "<!doctype html><title>demo

demo up

" }), + ); + const session = await stubContainerSession(page, { ready: false, log: BOOT_LOG }); + + await page.goto(CONTAINER_EXAMPLE); + await expect(page.getByText(BOOT_CAPTION)).toBeVisible(); + + session.set({ ready: true, log: BOOT_LOG }); + + // Poll cadence (2.5s) + frame load + render grace (3.5s) + confirm probe ≈ 6s + // on the happy path; 30s leaves CI slack without masking a hang at the 60s + // test budget. + await expect(preview(page)).toHaveAttribute("data-preview-status", "ready", { timeout: 30_000 }); + await expect(page.getByText(BOOT_CAPTION)).toHaveCount(0); + await expect(page.getByRole("button", { name: "Details" })).toHaveCount(0); + await expect(page.locator('iframe[title="Demo preview"]')).toHaveAttribute("src", PREVIEW_URL); +}); diff --git a/runner/e2e/docs-picker.spec.ts b/runner/e2e/docs-picker.spec.ts new file mode 100644 index 000000000..cd8b277f4 --- /dev/null +++ b/runner/e2e/docs-picker.spec.ts @@ -0,0 +1,284 @@ +import { test, expect, type Page, type Route } from "@playwright/test"; +import { activeEditor, stubShell } from "./helpers"; + +// The docs example picker's search box (DEV-2530 item 5). The guide's opening +// beat tells users to *search* the picker rather than drill the categories — +// and until this spec, nothing proved the search box at all. docs-examples. +// spec.ts covers the two-column tree (drill-down, collapse, keyboard walking); +// this file covers the flattened search view that replaces it while a query is +// typed. +// +// What makes search worth its own spec is cross-category reach: `searchLeaves` +// (apps/authoring/src/docs-catalog.ts) matches every term against each leaf's +// *full breadcrumb path*, across all categories and both sections +// (DOCUMENTATION and RECIPES) at once. A plausible regression — scoping the +// search to the active category, the way the un-searched view is scoped — +// would still pass every drill-down test and every "search narrows" smoke; +// only a fixture with the same phrase planted in several categories can catch +// it. Hence the fixture below, not a slice of the real ~1,450-example +// manifest. +// +// Everything is deterministic: the catalog is NOT bundled — App.tsx fetches +// `/docs-examples//manifest.json` at boot and hands the rows to the +// picker — so the manifest, the example artifacts, and the Sandpack hosts are +// all stubbed. Whether a picked example then *renders* is the live suites' +// job; the strongest oracle available without a bundler is the `?docs=` URL +// plus the editor showing the fetched artifact's source, and that is what the +// selection tests assert. + +/** One manifest row per category; every field the app reads (App.tsx renders + * the trigger from guideTitle/exampleTitle, the picker model from breadcrumb). + * Same shape as docs-examples.spec.ts's `manifestItem` — redeclared here + * because the fixture *content* is this spec's whole point: the phrase + * "context menu" is planted in three categories (two DOCUMENTATION, one + * RECIPES — recipes breadcrumbs run a level deeper, the exact shape + * `searchLeaves` flattens), plus one decoy that must never match. One + * framework variant each, so a pick resolves to exactly one docsPath and the + * URL oracle can be exact. */ +const FIXTURE = [ + { + breadcrumb: ["Columns", "Adding and removing columns"], + guide: "guides/columns/column-adding/column-adding.md", + guideTitle: "Adding and removing columns", + docsPath: "guides/columns/column-adding/react/example2.tsx", + exampleId: "example2", + exampleTitle: "Add and remove columns from the context menu", + docPermalink: "/column-adding", + }, + { + breadcrumb: ["Rows", "Adding and removing rows"], + guide: "guides/rows/row-adding/row-adding.md", + guideTitle: "Adding and removing rows", + docsPath: "guides/rows/row-adding/react/example3.tsx", + exampleId: "example3", + exampleTitle: "Add and remove rows from the context menu", + docPermalink: "/row-adding", + }, + { + breadcrumb: ["Recipes", "Context menu", "Conditional entries"], + guide: "recipes/context-menu/conditional-entries/conditional-entries.md", + guideTitle: "Conditional entries", + docsPath: "recipes/context-menu/conditional-entries/react/example1.tsx", + exampleId: "example1", + exampleTitle: "Standard example", + docPermalink: "/conditional-entries", + }, + // The decoy: proves search *filters*, not just flattens. Shares nothing with + // "context menu" anywhere in its path. + { + breadcrumb: ["Cell features", "Selection"], + guide: "guides/cell-features/selection/selection.md", + guideTitle: "Selection", + docsPath: "guides/cell-features/selection/react/example1.tsx", + exampleId: "example1", + exampleTitle: "Standard example", + docPermalink: "/selection", + }, +] as const; + +const ROWS_DOCS_PATH = FIXTURE[1].docsPath; + +/** Stub the docs catalog: the manifest at boot, and one artifact per fixture + * row for the selection tests. Trimmed from docs-examples.spec.ts's + * `installRouteFixtures` — that helper's knobs (missing buckets, SPA-fallback + * hosts, request logs) are load-error machinery this spec never exercises. + * Route order matters: the artifact glob also matches manifest.json, and + * Playwright resolves routes last-registered-first, so the manifest route + * goes second. */ +async function installDocsCatalog(page: Page) { + await stubShell(page); // versions stub → latest 18.0.0 → bucket "18.0" + await page.route("**/docs-examples/*/*.json", async (route: Route) => { + const url = new URL(route.request().url()); + const [, bucket, file] = url.pathname.match(/\/docs-examples\/([^/]+)\/(.+)\.json$/) ?? []; + const path = decodeURIComponent(file ?? "").replace(/__/g, "/"); + const row = FIXTURE.find((r) => r.docsPath === path); + if (!row) { + await route.fulfill({ status: 404, body: "not found" }); + return; + } + // Minimal Tier-1 CatalogEntry (same skeleton as docs-examples.spec.ts's + // `fixtureEntry`). The `fixture =` marker is what the editor oracle reads: + // it names the artifact that was fetched, so a selection that loaded the + // wrong example — or none — cannot fake the assertion. + await route.fulfill({ + json: { + framework: "react", + displayName: "React", + tier: 1, + engine: "sandpack", + sandpackTemplate: "react-ts", + sandpackEnvironment: "parcel", + container: null, + htWrappers: ["@handsontable/react-wrapper"], + entry: "/src/App.tsx", + htmlEntry: "/index.html", + devCommand: null, + buildCommand: "vite build", + outputDir: "dist", + outputGlob: null, + staticExport: false, + spaMode: false, + port: null, + installCommand: "pnpm install", + htCoreRange: "18.0.0", + fileCount: 3, + assets: [], + skipped: [], + docsPath: path, + breadcrumb: [...row.breadcrumb], + guide: row.guide, + guideTitle: row.guideTitle, + exampleId: row.exampleId, + lang: "tsx", + files: { + "/src/App.tsx": `export const fixture = "${bucket}:${path}";\n`, + "/index.html": `
`, + "/package.json": JSON.stringify({ + dependencies: { handsontable: "18.0.0", "@handsontable/react-wrapper": "18.0.0" }, + }, null, 2), + }, + }, + }); + }); + await page.route("**/docs-examples/*/manifest.json", async (route: Route) => { + const bucket = new URL(route.request().url()).pathname.split("/").at(-2) ?? ""; + await route.fulfill({ + json: { + bucket, + docsBranch: "e2e-fixture", + generatedFrom: "e2e fixture", + // Matches the stubbed latest, so opening an example takes the clean + // (unpinned) path — version pinning has its own spec. + hotVersion: "18.0.0", + count: FIXTURE.length, + examples: FIXTURE.map((row) => ({ + bucket, + docsPath: row.docsPath, + file: row.docsPath.replace(/\//g, "__") + ".json", + breadcrumb: [...row.breadcrumb], + guide: row.guide, + guideTitle: row.guideTitle, + exampleId: row.exampleId, + exampleTitle: row.exampleTitle, + docPermalink: row.docPermalink, + framework: "react", + displayName: "React", + })), + }, + }); + }); +} + +/** Open the picker from the React starter and wait for the open to settle. + * The focus check is not decoration: the cascader focuses its search input + * from a `setTimeout(…, 0)` (DocsCascader.tsx), so typing before that lands + * races the focus — the same trap docs-examples.spec.ts documents for its + * keyboard tests. Starting on a *starter* (not a docs example) also makes the + * selection tests honest — the `?docs=` URL the oracle looks for cannot be + * there already. */ +async function openPicker(page: Page) { + await page.goto("/?example=react"); + await page.getByRole("button", { name: /React/ }).first().click(); + const search = page.getByPlaceholder("Search examples…"); + await expect(search).toBeFocused(); + return search; +} + +/** The flattened results list that replaces the two-column body while a query + * is typed. Scoped by its accessible name so the assertions can never leak + * onto the category listbox. */ +function results(page: Page) { + return page.getByRole("listbox", { name: "Search results" }).getByRole("option"); +} + +// The promise: one query searches the WHOLE catalog. `searchLeaves` matches +// against each leaf's full breadcrumb path, so a phrase that lives in three +// categories — spanning both the DOCUMENTATION and RECIPES sections, whose +// breadcrumbs are even shaped differently — surfaces all three, breadcrumb +// visible on every row so same-named examples stay tellable apart. Counting +// per-category (and pinning the total) is the oracle because the credible +// regression leaves *some* results: a search scoped to the active category +// would still find the Columns row and pass any "results appear" check. +test("search surfaces matches from every category, across both sections", async ({ page }) => { + await installDocsCatalog(page); + const search = await openPicker(page); + + await search.fill("context menu"); + + // One hit per planted category… + await expect(results(page).filter({ hasText: "Adding and removing columns" })).toHaveCount(1); + await expect(results(page).filter({ hasText: "Adding and removing rows" })).toHaveCount(1); + await expect(results(page).filter({ hasText: "Conditional entries" })).toHaveCount(1); + // …and nothing else: the decoy ("Cell features ▸ Selection") must not ride + // along, or "search" has degraded to "flatten". + await expect(results(page)).toHaveCount(3); + + // Each row renders the full breadcrumb trail, not just the leaf title — the + // recipe row's deeper path proves the flattening kept every level. + await expect( + results(page).filter({ hasText: "Recipes ▸ Context menu ▸ Conditional entries ▸ Standard example" }), + ).toHaveCount(1); +}); + +// The promise: clicking a search result actually opens that example. The +// oracle is the `?docs=` URL (the app's routing contract for docs examples, +// asserted exactly — the fixture has one framework per example, so there is +// exactly one right answer) plus the editor showing the fetched artifact's +// marker. URL alone would pass a regression where navigation happens but the +// artifact never loads; the marker names bucket AND path, so loading the +// wrong example fails too. Rendering is the live suites' job (docs-examples +// E2E_LIVE tests). +test("clicking a search result loads that example", async ({ page }) => { + await installDocsCatalog(page); + const search = await openPicker(page); + + await search.fill("context menu"); + await results(page).filter({ hasText: "Adding and removing rows" }).click(); + + await expect(page).toHaveURL(/docs=guides%2Frows%2Frow-adding%2Freact%2Fexample3\.tsx/); + await expect(activeEditor(page)).toContainText(`18.0:${ROWS_DOCS_PATH}`); + // Choosing dismisses the popover — a picker that stays open over the loaded + // example blocks the editor it just filled. + await expect(page.getByRole("dialog", { name: "Choose an example" })).toHaveCount(0); +}); + +// The promise: the guide's search-first flow works without touching the mouse. +// ArrowDown from the search box must enter the RESULTS list while a query is +// live (`onSearchKeyDown` branches on `searching`), and Enter on a result must +// choose it (`onResultKeyDown`) — a code path none of docs-examples.spec.ts's +// keyboard tests reach, since they all walk the un-searched tree. The query +// matches exactly one leaf ("rows" rules out the other two "context menu" +// carriers), so first-result-Enter has one right outcome. +test("a search result is choosable by keyboard straight from the search box", async ({ page }) => { + await installDocsCatalog(page); + const search = await openPicker(page); + + await search.fill("rows context"); + await expect(results(page)).toHaveCount(1); + + await page.keyboard.press("ArrowDown"); + await expect(results(page).first()).toBeFocused(); + await page.keyboard.press("Enter"); + + await expect(page).toHaveURL(/docs=guides%2Frows%2Frow-adding%2Freact%2Fexample3\.tsx/); +}); + +// The promise: a query that matches nothing says so. The stale-results +// scenario is staged deliberately — real results first, then garbage — because +// the regression this guards (results not recomputed on query change, or the +// empty branch never rendering) shows the PREVIOUS hits under the new query. +// A test that only ever typed garbage would pass a picker frozen on its last +// good result set... as long as there never was one. +test("a garbage query shows the empty state, not stale results", async ({ page }) => { + await installDocsCatalog(page); + const search = await openPicker(page); + + // Populate the results view first, so staleness has something to be stale with. + await search.fill("context menu"); + await expect(results(page)).toHaveCount(3); + + await search.fill("xyzzy plugh"); + + await expect(page.getByText("No matching examples.")).toBeVisible(); + await expect(results(page)).toHaveCount(0); +}); diff --git a/runner/e2e/editor-download.spec.ts b/runner/e2e/editor-download.spec.ts index 98515df9f..9dea163e6 100644 --- a/runner/e2e/editor-download.spec.ts +++ b/runner/e2e/editor-download.spec.ts @@ -15,6 +15,10 @@ import { activeEditor, expectGridRendered, previewReady, stubShell } from "./hel // proves error → recovery round-trips and style-apply.spec.ts proves theme // modules land, but "type a thing, see the thing" — the whole point of the // editor — was only ever implied. Needs the live bundler, so E2E_LIVE. +// +// 3. (DEV-2530 item 4) The FILES-header download button (FileTree.tsx) hands +// over the *same* zip as the top bar — same entries, same bytes, same +// filename. Deterministic for the same reason as 1. const MARKER = "// e2e-download-marker"; @@ -55,6 +59,70 @@ test("Download zips the workspace including an unsaved edit", async ({ page }) = expect(strFromU8(entries["src/index.tsx"])).toContain(MARKER); }); +/** Click a download control and hand back the zip, unpacked. Factored from the + * inline pattern in the first test (waitForEvent → click → path → unzipSync) + * because the parity test below needs it twice in one run. */ +async function downloadZipVia(page: Page, button: ReturnType) { + const event = page.waitForEvent("download"); + await button.click(); + const download = await event; + const zipPath = await download.path(); + return { filename: download.suggestedFilename(), entries: unzipSync(readFileSync(zipPath!)) }; +} + +test("the FILES-header download hands over the same zip as the top bar", async ({ page }) => { + // The promise: the little download icon on the FILES header (FileTree.tsx) + // is the same exit as the big top-bar Download — one workspace, one zip, + // whichever control you reach for. Today both wire to the one `downloadZip` + // callback in App.tsx; this test is the tripwire for the day the sidebar + // button grows its own zipper (say, one that reads the saved snapshot + // instead of the live files) and the two exits quietly diverge. + // + // The oracle is entry names + per-entry decompressed bytes, NOT whole-zip + // byte equality: fflate stamps each entry's header with an mtime that + // defaults to "now" at zip time, so two zips of identical files built + // milliseconds apart can legitimately differ as raw bytes. Decompressed + // entry content carries no timestamp, so comparing it is exact and stable. + await stubShell(page); + await page.goto("/?example=react"); + await insertAtTop(page, MARKER); + + // Same anchored name as the first test — matches with or without the + // unsaved-work "•", never the sidebar button's longer name. + const topBar = await downloadZipVia(page, page.getByRole("button", { name: /^Download( •)?$/ })); + + // The FILES-header button is icon-only; its accessible name is its `title` + // (the icon itself is aria-hidden — icons/ui.tsx labels live on the button). + const filesHeader = await downloadZipVia( + page, + page.getByRole("button", { name: "Download this workspace (including your edits) as a .zip" }), + ); + + // Same suggested filename — a sidebar zip named differently would fail the + // "same hand-over" promise even with identical contents. + expect(filesHeader.filename).toBe(topBar.filename); + + // Identical entry *lists* first: a missing or extra file is a clearer + // failure than a byte mismatch on whatever entry the loop reached first. + const topPaths = Object.keys(topBar.entries).sort(); + expect(Object.keys(filesHeader.entries).sort()).toEqual(topPaths); + + // The marker must be in BOTH zips — without this, two zips that both lost + // the unsaved edit would still compare equal and the test would pass vacuously. + expect(strFromU8(topBar.entries["src/index.tsx"])).toContain(MARKER); + expect(strFromU8(filesHeader.entries["src/index.tsx"])).toContain(MARKER); + + // Per-entry byte equality across the whole workspace (superset of the marker + // file). Buffer.equals rather than toEqual keeps a failure to one named path + // instead of a screenful of diffed typed arrays. + for (const p of topPaths) { + expect( + Buffer.from(filesHeader.entries[p]).equals(Buffer.from(topBar.entries[p])), + `zip entry "${p}" differs between the FILES-header and top-bar downloads`, + ).toBe(true); + } +}); + test("an edit to the example reaches the rendered grid", async ({ page }) => { test.skip(process.env.E2E_LIVE !== "1", "set E2E_LIVE=1 to run live-render checks"); test.setTimeout(240_000); diff --git a/runner/pipeline/ht-version-resolve.test.mjs b/runner/pipeline/ht-version-resolve.test.mjs index 198e57a85..eb7525863 100644 --- a/runner/pipeline/ht-version-resolve.test.mjs +++ b/runner/pipeline/ht-version-resolve.test.mjs @@ -11,11 +11,15 @@ // PR demo against the wrong core, which is worse than the bug being fixed. // // Run: node --experimental-strip-types --test pipeline/ht-version-resolve.test.mjs +// — after `pnpm --filter @handsontable/demo-runtime build`: this file imports +// the runtime's *dist*, so a direct run against a stale build tests the wrong +// code and can stay green through a source mutation (`pnpm test` builds first). import test from "node:test"; import assert from "node:assert/strict"; import { handsontableDependencyRef, pinHandsontableFiles, + validateHandsontableVersion, } from "../packages/runtime/dist/version.js"; import { editorVersionRef, @@ -231,6 +235,63 @@ test("the `next` dist-tag resolves to the newest nightly by publish date, not th assert.equal(r.ref, "19.0.0-next.4"); }); +// ---- the bare-numeric boundary (MIN_BARE_NUMERIC_PKG_PR_NEW_REF) ------------ +// +// A bare integer is ambiguous: "18" is a major-only semver range, "13106" a +// pkg.pr.new build id. The validator draws the line at 1000 (DEV-2530: the +// guide states refs below 1000 read as majors, 1000 and up as PR builds), so +// 999/1000 is the sharpest pair that exists. Both sides are pinned, because +// nudging the threshold silently flips which of two wrong things happens: a +// typoed major becomes a doomed pkg.pr.new install, or a real PR id becomes a +// refused create. + +test("a bare 999 is refused as an out-of-range major, with the validator's own message", async (t) => { + // The boundary belongs to the validator, and its message is the contract: the + // API forwards it verbatim, so a paraphrase upstream would break what MCP + // callers and the guide both quote. + const refused = validateHandsontableVersion("999"); + assert.equal(refused.ok, false); + assert.equal(refused.message, "handsontable-version major must be at most 19"); + + // Through the API path the same input is a 400 before npm is ever consulted — + // a doomed ref must not cost a registry roundtrip, let alone a builder + // container. + const { env, calls } = fakeEnv(t); + const r = await resolveHandsontableVersion(env, { htVersion: "999", files: filesWith("^18.0.0") }); + assert.equal(r.ok, false); + assert.equal(r.status, 400); + assert.equal(r.message, refused.message, "the route surfaces the validator's message, not its own"); + assert.equal(calls.registry, 0); +}); + +test("a bare 1000 is accepted as a pkg.pr.new ref and pins the derived tarball URL", async (t) => { + // The first integer past the line must come out the other side whole: as a + // build id, not a coerced 1000.0.0. + assert.deepEqual(validateHandsontableVersion("1000"), { + ok: true, + value: { ref: "1000", pkgPrNew: true }, + }); + + // The dependency URL is asserted literally rather than built with + // pkgPrNewDependencyUrl — constructing the expectation from the helper under + // test would let a broken helper vouch for itself. + const { env, calls } = fakeEnv(t); + const r = await resolveHandsontableVersion(env, { htVersion: "1000", files: filesWith("^18.0.0") }); + assert.equal(r.ok, true); + assert.equal(r.ref, "1000"); + assert.equal(deps(r.files).handsontable, "https://pkg.pr.new/handsontable@1000"); + assert.equal(calls.registry, 0, "an explicit concrete ref never needs npm"); +}); + +test("the same threshold decides what a bare integer in package.json derives", () => { + // handsontableDependencyRef delegates the id-vs-major call to the validator, + // so the boundary must hold on the derivation path too: "999" names no build + // (npm reads it as any 999.x, and there is nothing to preserve), while "1000" + // is a ref the pin must keep. + assert.equal(handsontableDependencyRef(filesWith("999")), null); + assert.equal(handsontableDependencyRef(filesWith("1000")), "1000"); +}); + // ---- resolution: derive, don't default ------------------------------------- test("derives a pkg.pr.new ref from the submitted package.json rather than defaulting to latest", async (t) => {