Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions runner/e2e/editor-download.spec.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
102 changes: 102 additions & 0 deletions runner/e2e/version-pinning.spec.ts
Original file line number Diff line number Diff line change
@@ -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=<build id> 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);
}
});
Loading