import { createPrivateKey, sign } from "node:crypto"; import { readFileSync } from "node:fs"; import type { Page } from "@playwright/test"; import { allowConsole, expect, test } from "./console-guard.ts"; const BASE_URL = process.env.BASE_URL ?? "http://localhost:3000"; const SESSION_COOKIE = "plainpages_jwt"; // src/auth/login.ts — web verifies it against the committed dev JWKS // Per engine: the three projects run this suite in parallel and would otherwise write one file. const shot = (page: Page, name: string): Promise => page.screenshot({ fullPage: true, path: `artifacts/screenshots/${test.info().project.name}/${name}.png` }); // Sign a session JWT with the committed dev tokenizer key (bind-mounted at /repo/jwks.json), so the // gated dashboard renders for a "signed-in" user without standing up Ory — web verifies it // with the same key by `kid`, exactly as it verifies a real Kratos-tokenizer JWT. function devSession(permissions: string[] = []): string { const jwk = JSON.parse(readFileSync("/repo/jwks.json", "utf8")).keys[0]; const key = createPrivateKey({ format: "jwk", key: jwk }); const b64 = (o: unknown): string => Buffer.from(JSON.stringify(o)).toString("base64url"); const now = Math.floor(Date.now() / 1000); const input = `${b64({ alg: "ES256", kid: jwk.kid, typ: "JWT" })}.${b64({ email: "demo@plainpages.local", exp: now + 3600, iat: now, permissions, sub: "visual-demo" })}`; return `${input}.${sign("SHA256", Buffer.from(input), { dsaEncoding: "ieee-p1363", key }).toString("base64url")}`; } // The dashboard is gated: a page navigation needs a session. Plant one per test — a plain // member (no permissions) so the gated scheduling nav stays filtered out. test.beforeEach(async ({ context }) => { await context.addCookies([{ name: SESSION_COOKIE, url: BASE_URL, value: devSession() }]); }); // The shell must never clip a page: a body that does not scroll itself has to reach the reader // through the document. One page per body idiom, since the change removed a bounded rule from each // (.form-page never had one, .shell-auth did). A key press, not scrollIntoView — a script can scroll // an overflow-hidden box, a reader cannot; and not the wheel, which Firefox's synthetic event never // delivers to the document. for (const [name, path, tail] of [ ["the starter dashboard", "/dashboard", ".form-actions .btn"], ["the public landing", "/", ".landing-actions .btn"], ] as const) { for (const width of [1280, 390]) { test(`${name} scrolls to its end at ${width}px wide, and the chrome stays put`, async ({ page }) => { await page.setViewportSize({ width, height: 200 }); await page.goto(path); const overflows = await page.evaluate(() => document.documentElement.scrollHeight > window.innerHeight); expect(overflows, "the page must overflow, or it proves nothing").toBe(true); await page.keyboard.press("End"); // Whole, not merely touched: a control half under the fold is not reachable either. await expect(page.locator(tail).last()).toBeInViewport({ ratio: 1 }); // The sticky pair is the whole reason the document may scroll: on a narrow screen the // hamburger in the topbar is the only way back into the nav. await expect(page.locator(".topbar")).toBeInViewport(); if (width > 860) await expect(page.locator(".brand-name")).toBeInViewport(); // The open nav is a fixed overlay, so a reader cannot scroll the page out from under the // scrim. Focus can still move it, and stopping that needs script this page does not have. if (width <= 860) { await page.locator(".hamburger").click(); // the label is the control; the checkbox takes no pointer await expect(page.locator("#nav-toggle")).toBeChecked(); // The lock is one CSS rule, and whether a key press moves the page with the nav open turns // out to differ by engine — so pin the rule itself, then the behaviour it buys. expect(await page.evaluate(() => getComputedStyle(document.body).overflow)).toBe("hidden"); // Both directions: opening the nav may leave the page at either end, and a key press toward // the end it already sits at cannot move it whether the lock holds or not. const before = await page.evaluate(() => window.scrollY); for (const key of ["End", "Home"]) { await page.keyboard.press(key); expect(await page.evaluate(() => window.scrollY), key + " cannot scroll the page while the nav is open").toBe(before); } } }); } } test("captures the live pages for review", async ({ page }) => { await page.goto("/dashboard"); await expect(page.locator(".sidebar")).toBeVisible(); // the default /dashboard is the instructional starter, not a mock-data list. await expect(page.getByRole("heading", { name: "Starter dashboard" })).toBeVisible(); await shot(page, "live-01-dashboard"); await page.goto("/dashboard"); await page.locator("#theme-dark").check({ force: true }); // visually-hidden radio await shot(page, "live-03-dark"); await page.setViewportSize({ width: 390, height: 844 }); await page.goto("/dashboard"); await shot(page, "live-04-mobile"); await page.setViewportSize({ width: 1280, height: 800 }); }); test("every icon resolves to a defined (no broken graphics)", async ({ page }) => { await page.goto("/dashboard"); const missing = await page.evaluate(() => { const ids = new Set([...document.querySelectorAll("symbol[id]")].map((s) => s.id)); return [...document.querySelectorAll("use")] .map((u) => (u.getAttribute("href") ?? "").replace(/^#/, "")) .filter((id) => id && !ids.has(id)); }); expect(missing).toEqual([]); }); // The zero-JS URL-driven list — sortable headers, ?q search — is unit-tested per component and // exercised live by the full-flow E2E's admin Users list, so it has no Ory-free counterpart here. test("theme switch flips the palette with no JavaScript", async ({ page }) => { await page.goto("/dashboard"); const light = await page.evaluate(() => getComputedStyle(document.body).backgroundColor); await page.locator("#theme-dark").check({ force: true }); const dark = await page.evaluate(() => getComputedStyle(document.body).backgroundColor); expect(dark).not.toBe(light); }); // The menus are