From 3153d892f492d104da719f28373606203c1076ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Tue, 29 Sep 2026 21:06:05 -0400 Subject: [PATCH] T46 E2E: Playwright walks the built site, desktop and phone, live stories included --- frontend/.gitignore | 2 + frontend/e2e/live.spec.ts | 70 +++++++++++++++++++++++++++++ frontend/e2e/site.spec.ts | 85 +++++++++++++++++++++++++++++++++++ frontend/package.json | 4 +- frontend/playwright.config.ts | 15 +++++++ frontend/pnpm-lock.yaml | 28 ++++++++++++ 6 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 frontend/e2e/live.spec.ts create mode 100644 frontend/e2e/site.spec.ts create mode 100644 frontend/playwright.config.ts diff --git a/frontend/.gitignore b/frontend/.gitignore index b50d9c1..c50a3f5 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -2,3 +2,5 @@ node_modules dist public/gallery .wrangler +test-results +playwright-report diff --git a/frontend/e2e/live.spec.ts b/frontend/e2e/live.spec.ts new file mode 100644 index 0000000..dac0c88 --- /dev/null +++ b/frontend/e2e/live.spec.ts @@ -0,0 +1,70 @@ +import { expect, test, type Page } from "@playwright/test"; +import manifest from "../../examples/gallery/stories.json" with { type: "json" }; + +const STARTS = 45_000; + +async function live(page: Page, path: string) { + await page.goto(path); + const frame = page.frameLocator(".stage iframe"); + // The host hides its state once the gallery says it is ready. + await expect(page.locator(".stage .state")).toBeHidden({ timeout: STARTS }); + return frame; +} + +test("a story runs live and follows the site's mode", async ({ page }) => { + await page.emulateMedia({ colorScheme: "light" }); + const frame = await live(page, "/components/buttons/button/"); + await expect(frame.locator("canvas")).toBeVisible(); + await expect(frame.locator("html")).not.toHaveClass(/dark/); + await page.locator("[data-mode]").click(); + await expect(frame.locator("html")).toHaveClass(/dark/); +}); + +test("the live story leaves focus with the host until it is pressed", async ({ page, isMobile }) => { + test.skip(isMobile, "no hardware keyboard"); + await live(page, "/components/forms/input-textfield-clearableinput/"); + await page.waitForTimeout(1000); + expect(await page.evaluate(() => document.activeElement?.tagName)).not.toBe("IFRAME"); + await page.keyboard.press("/"); + await expect(page.getByRole("dialog", { name: "Search components" })).toBeVisible(); +}); + +test("a skip link passes the live example", async ({ page, isMobile }) => { + test.skip(isMobile, "no hardware keyboard"); + await page.goto("/components/buttons/button/"); + const skip = page.getByRole("link", { name: "Skip the live example" }); + await skip.focus(); + await expect(skip).toBeVisible(); + await page.keyboard.press("Enter"); + await expect(page.locator("#after-stage")).toBeFocused(); +}); + +test("typing reaches a live field after a press", async ({ page, isMobile }) => { + test.skip(isMobile, "no hardware keyboard"); + const frame = await live(page, "/components/forms/input-textfield-clearableinput/"); + const box = (await page.locator(".stage iframe").boundingBox())!; + // The first field sits under the section's note, as the gallery lays it. + await page.mouse.click(box.x + 120, box.y + 72); + const before = await frame.locator("canvas").screenshot(); + await page.keyboard.type("Ada"); + await expect(frame.locator("textarea")).toBeFocused(); + await expect.poll(async () => (await frame.locator("canvas").screenshot()).equals(before)).toBe(false); +}); + +test("full screen covers the page and Escape leaves it", async ({ page }) => { + await live(page, "/components/buttons/button/"); + await page.locator(".stage").hover(); + await page.getByRole("button", { name: "Full screen" }).click(); + await expect(page.locator(".stage")).toHaveClass(/full/); + await page.keyboard.press("Escape"); + await expect(page.locator(".stage")).not.toHaveClass(/full/); +}); + +// One story from every fifth chapter, each drawn in its frame. +for (const chapter of manifest.pages.filter((_, i) => i % 5 === 0)) { + test(`the first ${chapter.title} story draws`, async ({ page }) => { + const frame = await live(page, `/components/${chapter.slug}/`); + const shot = await frame.locator("canvas").screenshot(); + expect(shot.length).toBeGreaterThan(4000); + }); +} diff --git a/frontend/e2e/site.spec.ts b/frontend/e2e/site.spec.ts new file mode 100644 index 0000000..22848c9 --- /dev/null +++ b/frontend/e2e/site.spec.ts @@ -0,0 +1,85 @@ +import { expect, test, type Page } from "@playwright/test"; +import manifest from "../../examples/gallery/stories.json" with { type: "json" }; + +/** Fails the test on any console error or uncaught exception. */ +function watch(page: Page): string[] { + const errors: string[] = []; + page.on("console", (m) => m.type() === "error" && errors.push(m.text())); + page.on("pageerror", (e) => errors.push(e.message)); + return errors; +} + +test("the home page shows the pitch, the scene and the credits", async ({ page }) => { + const errors = watch(page); + await page.goto("/"); + await expect(page.getByRole("heading", { level: 1 })).toContainText("component library for GPUI"); + await expect(page.locator(".hero canvas")).toBeVisible(); + await expect(page.locator("#foot")).toContainText("Solar Icons by 480 Design"); + expect(errors).toEqual([]); +}); + +test("the mode toggles, and a reload keeps it", async ({ page }) => { + await page.emulateMedia({ colorScheme: "light" }); + await page.goto("/components/"); + const html = page.locator("html"); + await expect(html).not.toHaveClass(/dark/); + await page.getByRole("button", { name: "Dark mode" }).click(); + await expect(html).toHaveClass(/dark/); + await page.reload(); + await expect(html).toHaveClass(/dark/); +}); + +test("the index lists every chapter with its story count", async ({ page }) => { + await page.goto("/components/"); + const rows = page.locator(".grid a"); + await expect(rows).toHaveCount(manifest.pages.length); + const first = manifest.pages[0]; + await expect(rows.first()).toContainText(`${first.stories.length} stories`); +}); + +test("a chapter opens its first story, and paging walks on", async ({ page }) => { + const [chapter] = manifest.pages; + await page.goto(`/components/${chapter.slug}/`); + await expect(page.getByRole("heading", { level: 1 })).toHaveText(chapter.stories[0].title); + await page.locator(".pager a.next").click(); + await expect(page).toHaveURL(`/components/${chapter.slug}/${chapter.stories[1].slug}/`); + await expect(page.getByRole("heading", { level: 1 })).toHaveText(chapter.stories[1].title); +}); + +test("the sidebar filters by story and component name", async ({ page }) => { + await page.goto("/components/"); + await page.getByPlaceholder("Filter components").fill("Toast"); + await expect(page.locator(".side li a").first()).toContainText("Toast"); + await page.getByPlaceholder("Filter components").fill("zzzz"); + await expect(page.locator(".side .none")).toContainText("Nothing matches"); +}); + +test("search opens with the keyboard and goes where Enter points", async ({ page, isMobile }) => { + test.skip(isMobile, "no hardware keyboard"); + await page.goto("/components/"); + await page.keyboard.press("/"); + const dialog = page.getByRole("dialog", { name: "Search components" }); + await expect(dialog).toBeVisible(); + await page.keyboard.type("breadcrumb"); + await page.keyboard.press("Enter"); + await expect(dialog).toBeHidden(); + await expect(page).toHaveURL(/\/components\/navigation\/.*breadcrumb/); +}); + +test("the Browse menu opens a chapter's stories beside it", async ({ page, isMobile }) => { + test.skip(isMobile, "hover menu"); + await page.goto("/components/"); + await page.getByRole("button", { name: "Browse" }).click(); + await page.getByRole("menuitem", { name: "Charts" }).hover(); + const story = page.getByRole("menuitem", { name: /^RadarChart/ }); + await expect(story).toBeVisible(); + await story.click(); + await expect(page).toHaveURL(/\/components\/charts\/radarchart/); +}); + +test("an unknown chapter says so", async ({ page }) => { + const errors = watch(page); + await page.goto("/components/nowhere/"); + await expect(page.getByRole("heading", { level: 1 })).toHaveText("Not found"); + expect(errors.join()).toContain("No chapter"); +}); diff --git a/frontend/package.json b/frontend/package.json index 2432a99..06f45db 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,9 +7,11 @@ "dev": "vite", "build": "tsc --noEmit && vite build", "preview": "vite preview", - "deploy": "wrangler deploy" + "deploy": "wrangler deploy", + "e2e": "playwright test" }, "devDependencies": { + "@playwright/test": "1.63.0", "@types/three": "0.186.0", "typescript": "^7.0.2", "vite": "8.3.1", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..72cfb8e --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,15 @@ +import { defineConfig, devices } from "@playwright/test"; + +// Runs against the built site; the gallery must be built for this origin. +export default defineConfig({ + testDir: "e2e", + timeout: 60_000, + fullyParallel: true, + reporter: [["list"]], + use: { baseURL: "http://localhost:4173", trace: "retain-on-failure" }, + webServer: { command: "pnpm preview --port 4173 --strictPort", url: "http://localhost:4173", reuseExistingServer: true }, + projects: [ + { name: "desktop", use: { ...devices["Desktop Chrome"] } }, + { name: "phone", use: { ...devices["Pixel 7"] } }, + ], +}); diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 738cacd..0d9fce8 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -24,6 +24,9 @@ importers: specifier: 0.186.1 version: 0.186.1 devDependencies: + '@playwright/test': + specifier: 1.63.0 + version: 1.63.0 '@types/three': specifier: 0.186.0 version: 0.186.0 @@ -429,6 +432,11 @@ packages: '@oxc-project/types@0.151.0': resolution: {integrity: sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==} + '@playwright/test@1.63.0': + resolution: {integrity: sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==} + engines: {node: '>=20'} + hasBin: true + '@poppinss/colors@4.1.6': resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} @@ -829,6 +837,16 @@ packages: resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} + playwright-core@1.63.0: + resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.63.0: + resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==} + engines: {node: '>=20'} + hasBin: true + postcss@8.5.28: resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} engines: {node: ^10 || ^12 || >=14} @@ -1196,6 +1214,10 @@ snapshots: '@oxc-project/types@0.151.0': {} + '@playwright/test@1.63.0': + dependencies: + playwright: 1.63.0 + '@poppinss/colors@4.1.6': dependencies: kleur: 4.1.5 @@ -1458,6 +1480,12 @@ snapshots: picomatch@4.0.7: {} + playwright-core@1.63.0: {} + + playwright@1.63.0: + dependencies: + playwright-core: 1.63.0 + postcss@8.5.28: dependencies: nanoid: 3.3.19