diff --git a/frontend/e2e/live.spec.ts b/frontend/e2e/live.spec.ts index dac0c88..fd22010 100644 --- a/frontend/e2e/live.spec.ts +++ b/frontend/e2e/live.spec.ts @@ -5,10 +5,10 @@ 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; + await expect(page.locator(".live iframe")).toHaveCount(1); + // The host drops its state once the gallery says it is ready. + await expect(page.locator(".live .live-state")).toHaveCount(0, { timeout: STARTS }); + return page.frameLocator(".live iframe"); } test("a story runs live and follows the site's mode", async ({ page }) => { @@ -16,33 +16,33 @@ test("a story runs live and follows the site's mode", async ({ page }) => { 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 page.getByRole("button", { name: "Dark 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("a starting story leaves focus with the host", 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(); + await page.goto("/"); + await page.getByRole("link", { name: "Browse" }).first().focus(); + await page.locator(".feature").first().scrollIntoViewIfNeeded(); + await expect(page.locator(".feature .live .live-state")).toHaveCount(0, { timeout: STARTS }); + await expect(page.getByRole("link", { name: "Browse" }).first()).toBeFocused(); }); -test("a skip link passes the live example", async ({ page, isMobile }) => { +test("the skip control 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" }); + const skip = page.getByRole("button", { name: "Skip the live example" }); await skip.focus(); await expect(skip).toBeVisible(); await page.keyboard.press("Enter"); - await expect(page.locator("#after-stage")).toBeFocused(); + await expect(page.locator(".live + span")).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())!; + const box = (await page.locator(".live 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(); @@ -53,11 +53,17 @@ test("typing reaches a live field after a press", async ({ page, isMobile }) => 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.locator(".live").hover(); await page.getByRole("button", { name: "Full screen" }).click(); - await expect(page.locator(".stage")).toHaveClass(/full/); + await expect(page.locator(".live")).toHaveClass(/full/); await page.keyboard.press("Escape"); - await expect(page.locator(".stage")).not.toHaveClass(/full/); + await expect(page.locator(".live")).not.toHaveClass(/full/); +}); + +test("home frames run only near the view", async ({ page }) => { + await page.goto("/"); + await page.locator(".close").scrollIntoViewIfNeeded(); + await expect(page.locator(".feature iframe")).toHaveCount(0); }); // One story from every fifth chapter, each drawn in its frame. diff --git a/frontend/e2e/site.spec.ts b/frontend/e2e/site.spec.ts index dc1b04f..b1fac2d 100644 --- a/frontend/e2e/site.spec.ts +++ b/frontend/e2e/site.spec.ts @@ -1,7 +1,7 @@ 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. */ +/** Collects console errors and uncaught exceptions. */ function watch(page: Page): string[] { const errors: string[] = []; page.on("console", (m) => m.type() === "error" && errors.push(m.text())); @@ -9,13 +9,14 @@ function watch(page: Page): string[] { return errors; } -test("the home page shows the pitch, the scene and the credits", async ({ page }) => { +test("the home page shows the hero, five features, the city 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("canvas.iso")).toBeAttached(); + await expect(page.getByRole("heading", { level: 1 })).toHaveText("Components for GPUI."); await expect(page.locator(".hero-iso")).toBeVisible(); - await expect(page.locator("#foot")).toContainText("Solar Icons by 480 Design"); + await expect(page.locator(".feature")).toHaveCount(5); + await expect(page.locator(".city-names li")).toHaveCount(manifest.pages.length); + await expect(page.locator(".foot")).toContainText("Solar Icons by 480 Design"); expect(errors).toEqual([]); }); @@ -30,52 +31,61 @@ test("the mode toggles, and a reload keeps it", async ({ page }) => { await expect(html).toHaveClass(/dark/); }); -test("the index lists every chapter with its story count", async ({ page }) => { +test("the index lists every chapter with its stories", async ({ page }) => { await page.goto("/components/"); - const rows = page.locator(".grid a"); + const rows = page.locator(".chapters li"); await expect(rows).toHaveCount(manifest.pages.length); - const first = manifest.pages[0]; - await expect(rows.first()).toContainText(`${first.stories.length} stories`); + const [first] = manifest.pages; + await expect(rows.first().locator(".titles a")).toHaveCount(first.stories.length); }); -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 }) => { +test("search filters by story and component name, and Enter opens the first match", 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"); + const find = page.getByRole("searchbox", { name: "Search components" }); + await find.fill("zzzz"); + await expect(page.locator(".none")).toContainText("Nothing matches"); + await find.fill("breadcrumb"); + await expect(page.locator(".chapters li").first()).toContainText(/Breadcrumb/); + await find.press("Enter"); + await expect(page).toHaveURL(/\/components\/[a-z_]+\/.*breadcrumb/); }); -test("search opens with the keyboard and goes where Enter points", async ({ page, isMobile }) => { +test("Slash focuses search from the page", 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/); + await expect(page.getByRole("searchbox", { name: "Search components" })).toBeFocused(); }); -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("a chapter opens its first story, and the pager and arrows walk on", async ({ page, isMobile }) => { + const [chapter] = manifest.pages; + await page.goto(`/components/${chapter.slug}/`); + await expect(page).toHaveURL(`/components/${chapter.slug}/${chapter.stories[0].slug}/`); + await expect(page.getByRole("heading", { level: 1 })).toHaveText(chapter.stories[0].title); + await page.locator(".pager a.next").click(); + await expect(page.getByRole("heading", { level: 1 })).toHaveText(chapter.stories[1].title); + if (isMobile) return; + await page.keyboard.press("ArrowLeft"); + await expect(page.getByRole("heading", { level: 1 })).toHaveText(chapter.stories[0].title); +}); + +test("the rail marks the current story", async ({ page, isMobile }) => { + test.skip(isMobile, "the rail hides on phones"); + const chapter = manifest.pages[manifest.pages.length - 1]; + const story = chapter.stories[chapter.stories.length - 1]; + await page.goto(`/components/${chapter.slug}/${story.slug}/`); + await expect(page.locator('.side li [aria-current="page"]')).toHaveText(story.title); + await expect(page.locator('.side li [aria-current="page"]')).toBeInViewport(); +}); + +test("a chapter's name in the city names it and opens it", async ({ page, isMobile }) => { + test.skip(isMobile, "hover"); + await page.goto("/"); + const chapter = manifest.pages[15]; + await page.locator(".city-names a", { hasText: chapter.title }).first().hover(); + await expect(page.locator(".city h2")).toHaveText(`${chapter.title}.`); + await page.locator(".city-names a", { hasText: chapter.title }).first().click(); + await expect(page).toHaveURL(`/components/${chapter.slug}/${chapter.stories[0].slug}/`); }); test("an unknown chapter says so", async ({ page }) => { @@ -90,14 +100,14 @@ test("a story the web cannot run shows its native capture, in the site's mode", await page.goto("/components/forms/pathinput/"); const shot = page.locator("img.shot"); await expect(shot).toHaveAttribute("srcset", /pathinput-light\.jpg/); - await expect(page.locator(".stage iframe")).toHaveCount(0); - await page.locator("[data-mode]").click(); + await expect(page.locator(".live iframe")).toHaveCount(0); + await page.getByRole("button", { name: "Dark mode" }).click(); await expect(shot).toHaveAttribute("srcset", /pathinput-dark\.jpg/); await expect.poll(() => shot.evaluate((img: HTMLImageElement) => img.naturalWidth)).toBeGreaterThan(0); }); test("a live story that opens a window shows that window natively", async ({ page }) => { await page.goto("/components/shell/aboutdialog/"); - await expect(page.locator(".stage iframe")).toHaveCount(1); - await expect(page.locator(".native img.shot")).toHaveAttribute("srcset", /shell-about-window/); + await expect(page.locator(".live iframe")).toHaveCount(1); + await expect(page.locator(".windows img.shot")).toHaveAttribute("srcset", /shell-about-window/); }); diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 72cfb8e..ebc77fe 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ webServer: { command: "pnpm preview --port 4173 --strictPort", url: "http://localhost:4173", reuseExistingServer: true }, projects: [ { name: "desktop", use: { ...devices["Desktop Chrome"] } }, + { name: "firefox", use: { ...devices["Desktop Firefox"] } }, { name: "phone", use: { ...devices["Pixel 7"] } }, ], });