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:
@@ -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