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() }]); }); // A key press, not scrollIntoView (a script can scroll a box no reader can) and not the wheel // (Firefox's synthetic event never reaches 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`, 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"); await expect(page.locator(tail).last()).toBeInViewport({ ratio: 1 }); }); } } // Green only while #nav-toggle is position: fixed — a label tap focuses it, and focus scrolls into view. test("closing the mobile drawer leaves the reader where the scrim found them", async ({ page }) => { await page.setViewportSize({ width: 390, height: 200 }); await page.goto("/dashboard"); await page.locator(".hamburger").click(); await expect(page.locator("#nav-toggle")).toBeChecked(); // Scripted, because a key press with focus on the toggle does not scroll in every engine — and // what is under test is closing the drawer, not how the reader got down the page. await page.evaluate(() => window.scrollTo(0, 120)); const at = await page.evaluate(() => window.scrollY); expect(at, "the page must have somewhere to scroll behind the scrim").toBeGreaterThan(0); // The exposed strip beside the 264px panel: the scrim spans the viewport, so its centre is under // the drawer and a centre click lands on the panel instead. await page.locator(".scrim").click({ position: { x: 340, y: 100 } }); await expect(page.locator("#nav-toggle")).not.toBeChecked(); expect(await page.evaluate(() => window.scrollY), "closing the drawer must not move the page").toBe(at); }); 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