From 2a380c1399cd400c6e70e770546f62e177ed7045 Mon Sep 17 00:00:00 2001 From: Dan Zyto Date: Fri, 14 Aug 2026 13:15:16 +0200 Subject: [PATCH] feat(runner): prove the editor's two promises, and version dispatch end to end (DEV-2203) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit editor-download.spec.ts: Download is the only way out of play/share with your edits, and no test ever opened a zip — now one does, client-side and deterministic (fflate both builds and reads it). The live half types into the real editor and watches the renamed header reach the rendered grid; error-recovery and theme specs only ever implied that path. version-pinning.spec.ts: the URL → pin → workspace path a DEV-2198 PR preview link will take. Deterministic: a semver deep link pins core and wrapper in lockstep; a bare pkg.pr.new id rewrites every Handsontable dependency to its tarball URL and posts the validated ref to the container session. Live: the newest -next build installs through the real bundler. pkg.pr.new refs are never hardcoded (builds expire; npm cannot vouch for them) — E2E_PKG_PR_NEW_REF boots one real container at a fresh ref when a DEV-2198 validation run wants it. --- runner/e2e/editor-download.spec.ts | 81 +++++++++++++++++++++++ runner/e2e/version-pinning.spec.ts | 102 +++++++++++++++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 runner/e2e/editor-download.spec.ts create mode 100644 runner/e2e/version-pinning.spec.ts diff --git a/runner/e2e/editor-download.spec.ts b/runner/e2e/editor-download.spec.ts new file mode 100644 index 00000000..98515df9 --- /dev/null +++ b/runner/e2e/editor-download.spec.ts @@ -0,0 +1,81 @@ +import { readFileSync } from "node:fs"; +import { test, expect, type Page } from "@playwright/test"; +import { strFromU8, unzipSync } from "fflate"; +import { activeEditor, expectGridRendered, previewReady, stubShell } from "./helpers"; + +// The two editor promises nothing was proving (DEV-2203): +// +// 1. Download hands over the workspace *as edited*. In play and share modes the +// zip is the only way out with your changes — the button even highlights to +// say so — yet no test ever opened one. The zip is built client-side with +// fflate (App.tsx downloadWorkspaceZip), so this is deterministic: no +// bundler, no API, runs in PR CI. +// +// 2. A plain content edit reaches the rendered grid. preview-recovery.spec.ts +// 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. + +const MARKER = "// e2e-download-marker"; + +/** Insert text at the top of the visible editor through CodeMirror's own + * dispatch — `.cm-content` is contenteditable but virtualised, so typing via + * the keyboard depends on scroll position while a dispatch does not. */ +async function insertAtTop(page: Page, text: string) { + await activeEditor(page).waitFor(); + await page.evaluate(`(() => { + const view = document.querySelector('[data-pane-active="true"] .cm-content').cmTile.view; + view.dispatch({ changes: { from: 0, insert: ${JSON.stringify(text + "\n")} } }); + })()`); +} + +test("Download zips the workspace including an unsaved edit", async ({ page }) => { + await stubShell(page); + await page.goto("/?example=react"); + await insertAtTop(page, MARKER); + + // The edit marks the workspace dirty, so the button gains its "•" nudge — + // waiting for it doubles as "the edit reached the files state". + const download1 = page.waitForEvent("download"); + await page.getByRole("button", { name: /^Download( •)?$/ }).click(); + const download = await download1; + + expect(download.suggestedFilename()).toBe("react-vite-ts.zip"); + + const zipPath = await download.path(); + const entries = unzipSync(readFileSync(zipPath!)); + + // Paths in the zip lose their leading slash — `/src/index.tsx` unzips to a + // real relative path, not a root-anchored one. + const paths = Object.keys(entries); + expect(paths).toContain("src/index.tsx"); + expect(paths).toContain("package.json"); + expect(paths.some((p) => p.startsWith("/"))).toBe(false); + + expect(strFromU8(entries["src/index.tsx"])).toContain(MARKER); +}); + +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); + + await page.goto("/?example=react"); + await previewReady(page, "sandpack"); + await expectGridRendered(page); + + // Rename the first column header. A header is asserted by its text, so the + // check cannot pass by accident the way a data cell's value could. + await activeEditor(page).waitFor(); + await page.evaluate(`(() => { + const view = document.querySelector('[data-pane-active="true"] .cm-content').cmTile.view; + const doc = view.state.doc.toString(); + const at = doc.indexOf("'Company name'"); + if (at < 0) throw new Error("fixture changed: 'Company name' not found in the react starter"); + view.dispatch({ changes: { from: at, to: at + "'Company name'".length, insert: "'E2E header'" } }); + })()`); + + const renamed = page.frameLocator('iframe[title="Demo preview"]').locator("th", { hasText: "E2E header" }); + await expect(async () => { + expect(await renamed.count()).toBeGreaterThan(0); + }).toPass({ timeout: 90_000 }); +}); diff --git a/runner/e2e/version-pinning.spec.ts b/runner/e2e/version-pinning.spec.ts new file mode 100644 index 00000000..3eee3baa --- /dev/null +++ b/runner/e2e/version-pinning.spec.ts @@ -0,0 +1,102 @@ +import { test, expect, type Page } from "@playwright/test"; +import { activeEditor, expectGridRendered, previewReady, stubShell, trackSessions } from "./helpers"; + +// Version dispatch, end to end (DEV-2203, groundwork for DEV-2198 PR previews). +// +// pipeline/version.test.mjs proves the rewrite rules on file maps and +// starter-matrix.spec.ts proves live per-major rendering, but nothing walked +// the URL → pin → workspace path a PR-preview link will actually take: paste +// `?v=`, and the *open workspace's* package.json now pins core and wrapper in +// lockstep. The three deterministic tests below run in PR CI; the live one +// proves the newest -next build still installs through the real bundler. +// +// pkg.pr.new refs are deliberately never hardcoded live: builds expire per +// commit, `/api/versions/exists` only vouches for npm, and Sandpack cannot +// install URL tarballs at all (containers only). The mechanism is pinned +// deterministically instead, and E2E_PKG_PR_NEW_REF lets a DEV-2198 +// validation run point one real container at a fresh build id on demand. + +/** Open a root file through its tree row and hand back the visible editor. + * package.json is short enough to sit fully inside CodeMirror's viewport, so + * reading `.cm-content` is safe here — do not copy this pattern for long + * files (the virtualisation trap that killed the first style-panel draft). */ +async function openRootFile(page: Page, path: string) { + await page.locator(`.hot-file-row:has(> button[title="${path}"])`).locator(`button[title="${path}"]`).click(); + return activeEditor(page); +} + +test("a semver deep link pins core and wrapper in lockstep", async ({ page }) => { + await stubShell(page); + await page.goto("/?example=react&v=17.1.0"); + + const editor = await openRootFile(page, "/package.json"); + // Pinning happens when the bucket artifact lands, so poll rather than sample. + await expect(editor).toContainText('"handsontable": "17.1.0"'); + await expect(editor).toContainText('"@handsontable/react-wrapper": "17.1.0"'); +}); + +test("a pkg.pr.new build id rewrites every Handsontable dependency to a tarball URL", async ({ page }) => { + await stubShell(page); + // A bare id ≥ 1000 reads as a pkg.pr.new build ref and resolves the `next` + // starter bucket (there is no semver to derive a bucket from). + await page.goto("/?example=react&v=7940"); + + const editor = await openRootFile(page, "/package.json"); + await expect(editor).toContainText('"handsontable": "https://pkg.pr.new/handsontable@7940"'); + await expect(editor).toContainText('"@handsontable/react-wrapper": "https://pkg.pr.new/@handsontable/react-wrapper@7940"'); +}); + +test("a pkg.pr.new ref reaches the container session payload", async ({ page }) => { + await stubShell(page); + + // react-js is engine: container, so opening it posts /api/session. Refusing + // the session keeps the test deterministic — the payload is the assertion. + const posted: string[] = []; + await page.route("**/api/session", async (route) => { + posted.push(route.request().postData() ?? ""); + await route.fulfill({ status: 503, json: { error: "e2e: refused on purpose" } }); + }); + + await page.goto("/?example=react-js&v=7940"); + + await expect(async () => { + expect(posted.length).toBeGreaterThan(0); + const { htVersion } = JSON.parse(posted[0]) as { htVersion?: string }; + expect(htVersion, "the validated ref, not the raw URL param, travels to the container").toBe("7940"); + }).toPass({ timeout: 30_000 }); +}); + +test("the newest -next build installs and renders", async ({ page, request, baseURL }) => { + test.skip(process.env.E2E_LIVE !== "1", "set E2E_LIVE=1 to run live-render checks"); + test.skip(!process.env.E2E_BASE_URL, "needs a deployed /api/versions — vite preview has no API proxy"); + test.setTimeout(240_000); + + const versions = (await (await request.get(`${baseURL}/api/versions`)).json()) as { next?: string }; + test.skip(!versions.next, "no -next build is published right now"); + + const htRequests: string[] = []; + page.on("request", (req) => { + if (req.url().includes(`handsontable`) && req.url().includes(versions.next!)) htRequests.push(req.url()); + }); + + await page.goto(`/?example=react&v=${encodeURIComponent(versions.next!)}`); + await previewReady(page, "sandpack"); + await expectGridRendered(page); + expect(htRequests.length, `the bundler asked for handsontable@${versions.next}`).toBeGreaterThan(0); +}); + +test("a fresh pkg.pr.new build boots a real container at that ref", async ({ page, request }) => { + const ref = process.env.E2E_PKG_PR_NEW_REF; + test.skip(!ref, "set E2E_PKG_PR_NEW_REF= to verify a pkg.pr.new build end to end (DEV-2198)"); + test.skip(!process.env.E2E_BASE_URL, "needs a deployed API origin"); + test.setTimeout(300_000); + + const tracked = trackSessions(page); + try { + await page.goto(`/?example=react-js&v=${encodeURIComponent(ref!)}`); + await previewReady(page, "container"); + await expectGridRendered(page); + } finally { + await tracked.cleanup(request); + } +});