diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f269214d..1a7e1fa2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,5 +64,9 @@ jobs: uses: actions/upload-artifact@v4 with: name: playwright-report - path: runner/playwright-report/ + # playwright-report/ holds the html report, test-results/ the traces. + path: | + runner/playwright-report/ + runner/test-results/ retention-days: 7 + if-no-files-found: warn diff --git a/.github/workflows/e2e-live.yml b/.github/workflows/e2e-live.yml index d2b5f55b..b2677605 100644 --- a/.github/workflows/e2e-live.yml +++ b/.github/workflows/e2e-live.yml @@ -123,5 +123,9 @@ jobs: uses: actions/upload-artifact@v4 with: name: playwright-report-live - path: runner/playwright-report/ + # playwright-report/ holds the html report, test-results/ the traces. + path: | + runner/playwright-report/ + runner/test-results/ retention-days: 7 + if-no-files-found: warn diff --git a/runner/e2e/helpers.ts b/runner/e2e/helpers.ts new file mode 100644 index 00000000..b8b4c5f7 --- /dev/null +++ b/runner/e2e/helpers.ts @@ -0,0 +1,135 @@ +import { expect, type APIRequestContext, type Page } from "@playwright/test"; + +// Shared helpers for the e2e suite (DEV-2203). +// +// Everything here existed first as copy-paste: `stubShell` and `signIn` were +// declared verbatim in nine specs, the session-cleanup pattern in two, the +// noise filter in one. New specs import from here; existing specs keep their +// local copies and migrate opportunistically — a mass rewrite would churn +// thousands of spec lines for zero behaviour change. +// +// The file is not matched by `*.spec.ts`, so Playwright never collects it. + +export const EMAIL = "dev@handsontable.com"; + +/** + * The deterministic-shell recipe: a stubbed version list, both Sandpack hosts + * aborted (no external bundler, no grid — the shell renders fine without one), + * and the login redirect neutered so a stray click cannot leave the app. + */ +export async function stubShell(page: Page) { + 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"] } }), + ); + await page.route("https://sandpack.codesandbox.io/**", (route) => route.abort()); + await page.route("https://sandpack-bundler.codesandbox.io/**", (route) => route.abort()); + await page.route("**/broker/login**", (route) => route.abort()); +} + +/** + * Sign-in faked at the token layer — the app reads `sessionStorage.hot_token` + * and asks the broker who that is. Faking here rather than via `VITE_DEV_USER` + * keeps the production auth path in play (see sidebar-crud.spec.ts for the full + * argument). Never add storage *clears* to an init script: it runs on + * `page.reload()` too and silently defeats persistence tests (AGENTS.md). + */ +export async function signIn(page: Page, email: string = EMAIL) { + await page.addInitScript(() => sessionStorage.setItem("hot_token", "e2e-token")); + await page.route("**/broker/userinfo", (route) => route.fulfill({ json: { email } })); +} + +/** + * The visible editor pane. Scoped since T12 (DEV-2169): every open tab keeps + * its own CodeMirror instance mounted, so a bare `.cm-content` trips strict + * mode as soon as a test opens a second file. + * + * For *reading file contents*, prefer `workspaceFiles()` — CodeMirror + * virtualises long documents, so `.cm-content` only holds the lines on screen. + */ +export function activeEditor(page: Page) { + return page.locator('[data-pane-active="true"] .cm-content'); +} + +/** + * The workspace files, via the `window.__HOT_FILES__` test contract + * (apps/authoring/src/App.tsx). Poll with `expect(...).toPass()` when the + * write you are waiting for rides the Style panel's 250 ms debounce. + */ +export function workspaceFiles(page: Page): Promise> { + return page.evaluate(() => { + const hook = (window as unknown as { __HOT_FILES__?: () => Record }).__HOT_FILES__; + if (!hook) throw new Error("window.__HOT_FILES__ is not installed — is the app older than DEV-2203?"); + return hook(); + }); +} + +/** + * The version and framework pickers are custom listboxes in the preview bar + * (T2, DEV-2156) — `selectOption` does not apply. Open the trigger, click the + * option. + */ +export async function pickFromMenu(page: Page, menu: "Handsontable version" | "Framework", option: string) { + await page.getByRole("button", { name: menu, exact: true }).click(); + await page.getByRole("option", { name: option, exact: true }).click(); +} + +/** + * Wait for the preview to declare itself ready. Readiness comes off + * `data-preview-status` on the preview section (PreviewPane.tsx — a documented + * test contract), never off visible text. For containers, "ready" only means + * the dev server responded; follow up with `expectGridRendered` before + * asserting anything about the demo itself. + */ +export async function previewReady(page: Page, engine: "sandpack" | "container" = "sandpack") { + await expect(page.locator('section[aria-label="Preview"]')).toHaveAttribute("data-preview-status", "ready", { + timeout: engine === "container" ? 240_000 : 120_000, + }); +} + +/** The rendered grid inside the preview frame — the real functional check. */ +export function gridCells(page: Page) { + return page.frameLocator('iframe[title="Demo preview"]').locator(".handsontable .htCore td"); +} + +export async function expectGridRendered(page: Page) { + await expect + .poll(async () => gridCells(page).count().catch(() => 0), { timeout: 60_000, intervals: [1_000] }) + .toBeGreaterThan(0); +} + +// Console/page noise that isn't a real break. Extend as live runs surface new +// false positives (same list as starter-matrix.spec.ts). +export const NOISE = [ + /non-commercial|evaluation license/i, + /Download the React DevTools/i, + /favicon\.ico/i, + /\[vite\] (connecting|connected)/i, + /ERR_BLOCKED_BY_CLIENT|third-party cookie/i, +]; +export const isKnownNoise = (message: string) => NOISE.some((re) => re.test(message)); + +/** + * Track Tier-2 sessions a test creates so they can be torn down even when the + * test fails: the container pool holds five global slots shared with real + * traffic, and a leaked session squats one for its whole idle window. + * + * const tracked = trackSessions(page); + * try { ... } finally { await tracked.cleanup(request); } + */ +export function trackSessions(page: Page) { + const sessions: { id: string; apiBase: string }[] = []; + page.on("response", async (res) => { + if (res.request().method() === "POST" && /\/api\/session$/.test(res.url()) && res.ok()) { + const body = (await res.json().catch(() => null)) as { sessionId?: string } | null; + if (body?.sessionId) sessions.push({ id: body.sessionId, apiBase: new URL(res.url()).origin }); + } + }); + return { + sessions, + async cleanup(request: APIRequestContext) { + for (const { id, apiBase } of sessions) { + await request.delete(`${apiBase}/api/session/${id}`).catch(() => {}); + } + }, + }; +} diff --git a/runner/package.json b/runner/package.json index b39717c8..62b57a8d 100644 --- a/runner/package.json +++ b/runner/package.json @@ -22,7 +22,8 @@ }, "devDependencies": { "@playwright/test": "^1.48.0", - "acorn": "^8.18.0", +"acorn": "^8.18.0", + "fflate": "^0.8.2", "typescript": "~5.6.0" } } diff --git a/runner/playwright.config.ts b/runner/playwright.config.ts index e2d056c8..819e858b 100644 --- a/runner/playwright.config.ts +++ b/runner/playwright.config.ts @@ -12,7 +12,10 @@ export default defineConfig({ expect: { timeout: 15_000 }, fullyParallel: true, retries: process.env.CI ? 2 : 0, - reporter: process.env.CI ? "github" : "list", + // In CI, "github" alone leaves `playwright-report/` empty — the workflows + // upload that directory on failure, and until DEV-2203 the artifact was a + // no-op. The html reporter fills it; traces land in `test-results/`. + reporter: process.env.CI ? [["list"], ["github"], ["html", { open: "never" }]] : "list", use: { baseURL, trace: "on-first-retry" }, projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], // Start a local preview only when not pointing at an external URL. diff --git a/runner/pnpm-lock.yaml b/runner/pnpm-lock.yaml index 948101b6..76b44544 100644 --- a/runner/pnpm-lock.yaml +++ b/runner/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: acorn: specifier: ^8.18.0 version: 8.18.0 + fflate: + specifier: ^0.8.2 + version: 0.8.3 typescript: specifier: ~5.6.0 version: 5.6.3