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() }]); }); 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