From c5464aa0aad9695f4e404088b603dcbf4a129d7f Mon Sep 17 00:00:00 2001 From: davegilligan Date: Thu, 4 Jun 2026 19:40:41 +0200 Subject: [PATCH] test: add Playwright smoke suite (cookie banner, lang switcher, FamilyAtlas, WritingIssue) 34 tests across desktop + mobile (Chromium/Pixel 5). Verifies all client-side React islands and interactive features that astro build/WebFetch cannot confirm. API-dependent WritingIssue shows graceful error against local preview; resolves on the live site where the PHP backend is available. Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 64 ++++++++++++++++ package.json | 4 +- playwright.config.ts | 17 +++++ tests/smoke.spec.ts | 171 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 255 insertions(+), 1 deletion(-) create mode 100644 playwright.config.ts create mode 100644 tests/smoke.spec.ts diff --git a/package-lock.json b/package-lock.json index 4456c31..f011890 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "react-dom": "^19.2.4" }, "devDependencies": { + "@playwright/test": "^1.60.0", "@resvg/resvg-js": "^2.6.2", "satori": "^0.26.0" }, @@ -1386,6 +1387,22 @@ "integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==", "license": "MIT" }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@resvg/resvg-js": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/@resvg/resvg-js/-/resvg-js-2.6.2.tgz", @@ -4587,6 +4604,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", diff --git a/package.json b/package.json index cd62fb9..63b888d 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "build": "astro build", "preview": "astro preview", "astro": "astro", - "og": "node scripts/generate-og-images.mjs" + "og": "node scripts/generate-og-images.mjs", + "test": "playwright test" }, "dependencies": { "@astrojs/react": "^5.0.2", @@ -23,6 +24,7 @@ "react-dom": "^19.2.4" }, "devDependencies": { + "@playwright/test": "^1.60.0", "@resvg/resvg-js": "^2.6.2", "satori": "^0.26.0" } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..b4dbac8 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,17 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests", + timeout: 15_000, + use: { baseURL: "http://localhost:4321" }, + webServer: { + command: "npm run preview", + url: "http://localhost:4321", + reuseExistingServer: true, + timeout: 30_000, + }, + projects: [ + { name: "desktop", use: { ...devices["Desktop Chrome"] } }, + { name: "mobile", use: { ...devices["Pixel 5"] } }, + ], +}); diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts new file mode 100644 index 0000000..d7820c6 --- /dev/null +++ b/tests/smoke.spec.ts @@ -0,0 +1,171 @@ +import { test, expect, type Page } from "@playwright/test"; + +async function getCookie(page: Page, name: string): Promise { + const cookies = await page.context().cookies(); + return cookies.find((c) => c.name === name)?.value; +} + +// ─── Cookie banner ──────────────────────────────────────────────────────────── + +test.describe("Cookie banner", () => { + test.beforeEach(async ({ context }) => { + await context.clearCookies(); + }); + + test("appears on first visit", async ({ page }) => { + await page.goto("/"); + await expect(page.locator("[data-cookie-banner]")).toBeVisible(); + }); + + test("accept all → banner hidden, cookie = all", async ({ page }) => { + await page.goto("/"); + await page.click('[data-cookie-accept="all"]'); + await expect(page.locator("[data-cookie-banner]")).toBeHidden(); + expect(await getCookie(page, "dg_cookie_preferences")).toBe("all"); + }); + + test("preference persists on reload", async ({ page }) => { + await page.goto("/"); + await page.click('[data-cookie-accept="all"]'); + await page.reload(); + await expect(page.locator("[data-cookie-banner]")).toBeHidden(); + }); + + test("essential only → cookie = essential", async ({ page }) => { + await page.goto("/"); + await page.click('[data-cookie-accept="essential"]'); + await expect(page.locator("[data-cookie-banner]")).toBeHidden(); + expect(await getCookie(page, "dg_cookie_preferences")).toBe("essential"); + }); +}); + +// ─── Language switcher ──────────────────────────────────────────────────────── + +test.describe("Language switcher", () => { + test.beforeEach(async ({ context }) => { + await context.clearCookies(); + }); + + test("defaults to EN", async ({ page }) => { + await page.goto("/"); + await expect(page.locator("html")).toHaveAttribute("data-ui-lang", "en"); + }); + + test("switches to NB and persists cookie", async ({ page }) => { + await page.goto("/"); + await page.click('[data-lang-button="nb"]'); + await expect(page.locator("html")).toHaveAttribute("data-ui-lang", "nb"); + expect(await getCookie(page, "dg_ui_lang")).toBe("nb"); + }); + + test("switches to FR", async ({ page }) => { + await page.goto("/"); + await page.click('[data-lang-button="fr"]'); + await expect(page.locator("html")).toHaveAttribute("data-ui-lang", "fr"); + }); +}); + +// ─── /ai-lab bug regression ─────────────────────────────────────────────────── + +test("no [object Object] on /ai-lab", async ({ page }) => { + await page.goto("/ai-lab"); + const content = await page.content(); + expect(content).not.toContain("[object Object]"); +}); + +// ─── WritingIssue ───────────────────────────────────────────────────────────── + +test("WritingIssue resolves to content or graceful error on /writing", async ({ page }) => { + await page.goto("/writing"); + + // Wait for the island to mount and finish its API call (succeeds or fails) + await page.waitForSelector(".writing-live, h2:text('The writing issue could not be loaded.')", { + timeout: 10_000, + }); + + // Must not still be in loading state + await expect(page.locator("h2:has-text('Loading the Boris Vian issue...')")).not.toBeVisible(); + + // Either resolved content or graceful error must be visible — not blank + const hasContent = await page.locator(".writing-live").isVisible(); + const hasError = await page.locator("h2:has-text('The writing issue could not be loaded.')").isVisible(); + expect(hasContent || hasError).toBeTruthy(); +}); + +// ─── FamilyAtlas ───────────────────────────────────────────────────────────── + +test.describe("FamilyAtlas", () => { + // Pre-set the cookie preference so the banner doesn't intercept clicks + test.beforeEach(async ({ context }) => { + await context.addCookies([ + { name: "dg_cookie_preferences", value: "essential", domain: "localhost", path: "/" }, + ]); + }); + + test("renders on /family-lab", async ({ page }) => { + await page.goto("/family-lab"); + await page.waitForSelector(".family-atlas", { timeout: 8_000 }); + await expect(page.locator(".family-atlas")).toBeVisible(); + }); + + test("filter tab switches without crash", async ({ page }) => { + await page.goto("/family-lab"); + await page.waitForSelector(".family-atlas", { timeout: 8_000 }); + + const tabs = page.locator('[role="tablist"] [role="tab"]'); + const count = await tabs.count(); + if (count < 2) return; // skip if fewer than 2 tabs + + await tabs.nth(1).click(); + await expect(page.locator(".family-atlas")).toBeVisible(); + }); + + test("lightbox opens on tile click and closes with Escape", async ({ page }) => { + await page.goto("/family-lab"); + await page.waitForSelector(".family-atlas", { timeout: 8_000 }); + + const tiles = page.locator(".family-atlas__tile"); + const tileCount = await tiles.count(); + if (tileCount === 0) return; // skip if no photos + + await tiles.first().click(); + await expect(page.locator('[role="dialog"]')).toBeVisible(); + + await page.keyboard.press("Escape"); + await expect(page.locator('[role="dialog"]')).not.toBeVisible(); + }); +}); + +// ─── Console errors ─────────────────────────────────────────────────────────── + +for (const path of ["/", "/ai-lab", "/writing", "/family-lab"]) { + test(`no unexpected console errors on ${path}`, async ({ page }) => { + const errors: string[] = []; + + page.on("console", (msg) => { + if (msg.type() === "error") errors.push(msg.text()); + }); + page.on("pageerror", (err) => errors.push(err.message)); + + await page.goto(path); + // Allow React islands to mount and any API calls to settle + await page.waitForTimeout(3_000); + + // Filter known benign errors: API 404s on /writing are expected locally + const unexpected = errors.filter( + (e) => + !e.includes("/api/") && + !e.includes("Failed to fetch") && + !e.includes("NetworkError") && + !e.includes("Failed to load resource"), + ); + expect(unexpected).toHaveLength(0); + }); +} + +// ─── Mobile layout ─────────────────────────────────────────────────────────── + +test("navigation is visible on mobile", async ({ page }) => { + await page.goto("/"); + await expect(page.locator("nav")).toBeVisible(); +});