import AxeBuilder from "@axe-core/playwright"; import { expect, test as base, type APIRequestContext, type Locator, type Page, type TestInfo } from "@playwright/test"; import { translations } from "../src/i18n/translations"; const apiUrl = "http://localhost:5302/api"; const password = "E2ePassword123!"; async function login(api: APIRequestContext, email: string) { expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200); const me = await (await api.get(`${apiUrl}/auth/me`)).json(); const csrf = (await api.storageState()).cookies.find(cookie => cookie.name === "XSRF-TOKEN")!.value; return { "X-CSRF-TOKEN": csrf, "X-Session-Context": me.sessionContext as string }; } const test = base.extend<{ owner: { headers: Record; companyId: number; cvId: number } }>({ owner: async ({ playwright, page, context }, provide) => { const admin = await playwright.request.newContext(); try { const adminHeaders = await login(admin, "e2e@example.test"); const email = `p4-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`; expect((await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email, password, roles: [] } })).status()).toBe(200); const headers = await login(page.request, email); // Real disposable API, no provider calls. Only the explicit company-state case // below delays/replaces a GET to make loading/error/empty deterministic. await context.route("**/*", route => { const url = new URL(route.request().url()); return ["localhost", "127.0.0.1"].includes(url.hostname) || ["data:", "blob:", "about:"].includes(url.protocol) ? route.continue() : route.abort(); }); const company = await page.request.post(`${apiUrl}/companies`, { headers, data: { name: "Nordlys æøå AS", location: "Tromsø" } }); expect(company.status()).toBe(201); expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { profile: { version: "1", contact: { fullName: "Åse Eksempel", headline: "Engineer" }, jobs: [{ title: "Engineer", company: "Nordlys AS", bullets: ["Built reliable APIs"] }], skills: ["C#", "Norsk"], } } })).status()).toBe(200); const cv = await page.request.post(`${apiUrl}/cv/variants`, { headers, data: { name: "P4 loaded CV" } }); expect(cv.status()).toBe(200); await provide({ headers, companyId: (await company.json()).id, cvId: (await cv.json()).id }); } finally { await admin.dispose(); } }, }); async function setup(page: Page, mode: "light" | "dark", language: "en" | "nb", width: number) { await page.setViewportSize({ width, height: 900 }); await page.addInitScript(({ mode, language }) => { localStorage.setItem("jobtracker.themeMode", mode); localStorage.setItem("uiLanguage", language); }, { mode, language }); } async function capture(page: Page, info: TestInfo, name: string) { const path = process.env.P4_CAPTURE === "true" ? `../docs/redesign/phase-4/p4-01/visual/${name}.png` : info.outputPath(`${name}.png`); // A post-axe capture can precede Chromium repainting unchanged sidebar text. // Allow a complete paint cycle; do not alter the page or hide content for capture. await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); await page.screenshot({ path, fullPage: false }); await info.attach(name, { path, contentType: "image/png" }); } async function loadedContrast(page: Page, info: TestInfo, state: string) { // Scan the loaded state after finite entry/focus transitions finish. Sampling // MUI's Fade/Grow mid-frame measures composited, partially transparent text. // Keep normal motion enabled, retain every axe rule and record what we waited for. const transitions = await page.evaluate(async () => { await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); const active = document.getAnimations().filter(animation => animation.playState === "running" && Number.isFinite(animation.effect?.getComputedTiming().endTime)); const evidence = active.map(animation => { const target = (animation.effect as KeyframeEffect | null)?.target; return { type: animation.constructor.name, opacity: target instanceof Element ? getComputedStyle(target).opacity : null }; }); await Promise.all(active.map(animation => animation.finished.catch(() => undefined))); return evidence; }); await info.attach(`${state}-transitions`, { body: JSON.stringify(transitions), contentType: "application/json" }); const result = await new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]).analyze(); // This slice gates color contrast. Preserve the full scan (including naming and // preview-scroll findings) for P4-02/03; this is not a full accessibility pass. await info.attach(`${state}-axe`, { body: JSON.stringify({ violations: result.violations, incomplete: result.incomplete }, null, 2), contentType: "application/json" }); expect(result.violations.filter(v => v.id === "color-contrast"), state).toEqual([]); } async function keyboardFocus(page: Page, target: Locator) { await target.focus(); const option = await target.getAttribute("role") === "option"; await page.keyboard.press(option ? "ArrowDown" : "Tab"); await page.keyboard.press(option ? "ArrowUp" : "Shift+Tab"); await expect(target).toBeFocused(); const ring = await target.evaluate(node => { const control = node.closest(".MuiInputBase-root") ?? node; const notch = control.querySelector(".MuiOutlinedInput-notchedOutline"); const css = getComputedStyle(notch ?? control); const color = notch ? css.borderTopColor : css.outlineColor; const width = notch ? css.borderTopWidth : css.outlineWidth; const style = notch ? css.borderTopStyle : css.outlineStyle; const offset = notch ? "-3px" : css.outlineOffset; const rgba = (value: string) => value.match(/[\d.]+/g)!.map(Number); const blend = (front: number[], back: number[]) => front.slice(0, 3).map((v, i) => v * (front[3] ?? 1) + back[i] * (1 - (front[3] ?? 1))); const chain: Element[] = []; // Inset rings sit on the control; offset rings sit on its parent surface. for (let el = Number.parseFloat(offset) < 0 ? control : control.parentElement; el; el = el.parentElement) chain.unshift(el); const surface = chain.reduce((back, el) => blend(rgba(getComputedStyle(el).backgroundColor), back), [255, 255, 255]); const luminance = (rgb: number[]) => rgb.map(v => v / 255).map(v => v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4).reduce((sum, v, i) => sum + v * [0.2126, 0.7152, 0.0722][i], 0); const a = luminance(blend(rgba(color), surface)); const b = luminance(surface); return { width, style, color, offset, contrast: (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05) }; }); expect(ring.width).toBe("3px"); expect(ring.style).toBe("solid"); expect(ring.color).not.toBe("rgba(0, 0, 0, 0)"); expect(ring.contrast).toBeGreaterThanOrEqual(3); return ring; } for (const mode of ["light", "dark"] as const) { for (const language of ["en", "nb"] as const) { for (const width of [1440, 390]) { test(`[PRES-E08 PRES-J08] ${mode}/${language}/${width} loading → error → retry → empty → populated`, async ({ page, owner }, info) => { const t = translations[language]; await setup(page, mode, language, width); expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers: owner.headers, data: { language: language === "nb" ? "nb-NO" : "en" } })).ok()).toBe(true); let release!: () => void; const delayed = new Promise(resolve => { release = resolve; }); let phase: "error" | "empty" | "populated" = "error"; await page.route(`${apiUrl}/companies`, async route => { await delayed; if (phase === "populated") await route.continue(); else await route.fulfill({ status: phase === "error" ? 503 : 200, contentType: "application/json", body: phase === "error" ? JSON.stringify({ message: t.companiesLoadFailed }) : "[]" }); }); await page.goto("/companies"); await expect(page.locator("main").getByRole("status")).toHaveAttribute("aria-live", "polite"); await expect(page.locator("main").getByRole("progressbar")).toHaveAccessibleName(t.loading); release(); await expect(page.locator("main").getByRole("alert")).toContainText(t.companiesLoadFailed); await loadedContrast(page, info, "loaded-error"); const retry = page.getByRole("button", { name: t.retry, exact: true }); await keyboardFocus(page, retry); phase = "empty"; await retry.press("Enter"); await expect(page.getByText(t.companiesEmpty, { exact: true })).toBeVisible(); await expect(page.locator("main").getByRole("alert")).toHaveCount(0); phase = "populated"; await page.reload(); await expect(page.locator("main").getByText("Nordlys æøå AS", { exact: true })).toBeVisible(); await expect(page.locator("main").getByRole("status")).toHaveText(t.contentLoaded); await expect(page.locator("html")).toHaveAttribute("data-color-scheme", mode); await loadedContrast(page, info, "loaded-company"); // Both desktop table and phone cards retain named actions and a real dialog. const edit = page.getByRole("button", { name: `${t.jobTableEdit}: Nordlys æøå AS`, exact: true }); await keyboardFocus(page, edit); await capture(page, info, `companies-${mode}-${language}-${width}`); await edit.press("Enter"); const dialog = page.getByRole("dialog"); await expect(dialog).toHaveAccessibleName(t.companiesEdit); const field = dialog.getByRole("textbox").first(); await keyboardFocus(page, field); await loadedContrast(page, info, "loaded-company-dialog"); await page.keyboard.press("Escape"); await expect(edit).toBeFocused(); if (width === 390) { const trigger = page.getByRole("button", { name: t.openNavigation, exact: true }); await keyboardFocus(page, trigger); await trigger.press("Enter"); const destination = page.getByRole("button", { name: t.companies, exact: true }); await keyboardFocus(page, destination); await page.keyboard.press("Escape"); await expect(trigger).toBeFocused(); } expect((await page.request.get(`${apiUrl}/companies/${owner.companyId}`)).status()).toBe(200); }); } } test(`[PRES-E07 PRES-E08 PRES-J05 PRES-J07] ${mode} loaded status semantics, navigation and command focus`, async ({ page, owner }, info) => { await setup(page, mode, "en", 1440); for (const status of ["Saved", "Applied", "Waiting", "Interview", "Offer", "Rejected"]) { const created = await page.request.post(`${apiUrl}/jobapplications`, { headers: owner.headers, data: { companyId: owner.companyId, jobTitle: `P4 ${status}`, status } }); expect(created.ok()).toBe(true); } await page.goto("/jobs"); await expect(page.getByRole("table").getByText("P4 Offer", { exact: true })).toBeVisible(); await loadedContrast(page, info, "loaded-jobs"); const nav = page.getByRole("button", { name: "Companies", exact: true }); await keyboardFocus(page, nav); await capture(page, info, `jobs-nav-${mode}`); await page.keyboard.press("Control+k"); const dialog = page.getByRole("dialog"); await expect(dialog).toBeVisible(); await keyboardFocus(page, dialog.getByRole("textbox")); await page.keyboard.press("Tab"); const command = dialog.getByRole("button").first(); await expect(command).toBeFocused(); await expect.poll(() => command.evaluate(node => getComputedStyle(node).outlineWidth)).toBe("3px"); await page.keyboard.press("Escape"); await expect(dialog).not.toBeVisible(); }); test(`[PRES-E08 PRES-C06 PRES-C07 PRES-C08 PRES-C09 PRES-C11 PRES-S03] ${mode} loaded CV tabs, selects and settings`, async ({ page, owner }, info) => { await setup(page, mode, "en", 1440); await page.goto(`/career/builder/${owner.cvId}`); await expect(page.getByLabel("CV name", { exact: true })).toHaveValue("P4 loaded CV"); await expect(page.getByTitle("CV preview page 1")).toBeVisible(); const download = page.getByRole("button", { name: translations.en.cvEditorDownloadPdf, exact: true }); await download.hover(); await loadedContrast(page, info, "loaded-cv-template-button-hover"); await page.getByRole("tab", { name: "Content", exact: true }).click(); await expect(page.getByLabel("Headline override", { exact: true })).toBeVisible(); await keyboardFocus(page, page.getByLabel("Headline override", { exact: true })); await loadedContrast(page, info, "loaded-cv-content"); await capture(page, info, `cv-content-${mode}`); const customize = page.getByRole("tab", { name: "Customize", exact: true }); await page.getByRole("tab", { name: "Content", exact: true }).focus(); await page.keyboard.press("ArrowRight"); await expect(customize).toBeFocused(); await customize.press("Enter"); await keyboardFocus(page, customize); const select = page.getByRole("combobox", { name: "Page size", exact: true }); await keyboardFocus(page, select); await select.press("Enter"); const option = page.getByRole("option", { name: "A4", exact: true }); await expect(option).toBeFocused(); await keyboardFocus(page, option); await page.keyboard.press("Escape"); await expect(select).toBeFocused(); await loadedContrast(page, info, "loaded-cv-customize"); await page.getByRole("tab", { name: "AI Tools", exact: true }).click(); const aiReviews = page.getByRole("button", { name: translations.en.cvDocumentAiReview, exact: true }); await expect(aiReviews).toHaveCount(3); for (const review of await aiReviews.all()) await expect(review).toBeDisabled(); await loadedContrast(page, info, "loaded-cv-ai-locked"); await page.getByRole("tab", { name: "History", exact: true }).click(); await loadedContrast(page, info, "loaded-cv-history"); await page.goto("/settings"); await page.getByRole("tab", { name: "Notifications", exact: true }).click(); await expect(page.getByLabel("Email reminders for follow-ups", { exact: true })).toBeVisible(); const save = page.getByRole("button", { name: "Save notification settings", exact: true }); await save.click(); // Ordinary pointer activation doesn't leave a keyboard ring on a button. await expect(save).not.toHaveClass(/Mui-focusVisible/); expect(await save.evaluate(node => node.matches(":focus-visible"))).toBe(false); await keyboardFocus(page, save); await loadedContrast(page, info, "loaded-settings"); }); }