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 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 19:40:41 +02:00
parent 61503fdac4
commit c5464aa0aa
4 changed files with 255 additions and 1 deletions
+64
View File
@@ -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",
+3 -1
View File
@@ -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"
}
+17
View File
@@ -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"] } },
],
});
+171
View File
@@ -0,0 +1,171 @@
import { test, expect, type Page } from "@playwright/test";
async function getCookie(page: Page, name: string): Promise<string | undefined> {
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();
});