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) => {