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:
Generated
+64
@@ -16,6 +16,7 @@
|
|||||||
"react-dom": "^19.2.4"
|
"react-dom": "^19.2.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.60.0",
|
||||||
"@resvg/resvg-js": "^2.6.2",
|
"@resvg/resvg-js": "^2.6.2",
|
||||||
"satori": "^0.26.0"
|
"satori": "^0.26.0"
|
||||||
},
|
},
|
||||||
@@ -1386,6 +1387,22 @@
|
|||||||
"integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==",
|
"integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@resvg/resvg-js": {
|
||||||
"version": "2.6.2",
|
"version": "2.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js/-/resvg-js-2.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/@resvg/resvg-js/-/resvg-js-2.6.2.tgz",
|
||||||
@@ -4587,6 +4604,53 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"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": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.8",
|
"version": "8.5.8",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||||
|
|||||||
+3
-1
@@ -12,7 +12,8 @@
|
|||||||
"build": "astro build",
|
"build": "astro build",
|
||||||
"preview": "astro preview",
|
"preview": "astro preview",
|
||||||
"astro": "astro",
|
"astro": "astro",
|
||||||
"og": "node scripts/generate-og-images.mjs"
|
"og": "node scripts/generate-og-images.mjs",
|
||||||
|
"test": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/react": "^5.0.2",
|
"@astrojs/react": "^5.0.2",
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
"react-dom": "^19.2.4"
|
"react-dom": "^19.2.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.60.0",
|
||||||
"@resvg/resvg-js": "^2.6.2",
|
"@resvg/resvg-js": "^2.6.2",
|
||||||
"satori": "^0.26.0"
|
"satori": "^0.26.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"] } },
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user