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(c => c.name === "XSRF-TOKEN")!.value; return { "X-CSRF-TOKEN": csrf, "X-Session-Context": me.sessionContext as string }; } const test = base.extend<{ owner: { headers: Record; ids: number[] } }>({ owner: async ({ playwright, page, context }, provide) => { const admin = await playwright.request.newContext(); try { const adminHeaders = await login(admin, "e2e@example.test"); const email = `p405-${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); 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); const companyId = (await company.json()).id; const ids: number[] = []; for (let index = 0; index < 18; index++) { const result = await page.request.post(`${apiUrl}/jobapplications`, { headers, data: { companyId, jobTitle: `Density ${String(index).padStart(2, "0")}${index === 0 ? " — Seniorrådgiver for plattformutvikling og digitale fellestjenester" : " Engineer"}`, status: index === 0 ? "Avventer faglig vurdering" : index % 3 === 0 ? "Interview" : "Saved", dateApplied: "2026-08-01", location: "Tromsø / hjemmekontor", jobUrl: "https://example.test/listing", } }); expect(result.status()).toBe(201); ids.push((await result.json()).id); } await provide({ headers, ids }); } finally { await admin.dispose(); } }, }); async function capture(page: Page, info: TestInfo, name: string) { const path = process.env.P4_05_CAPTURE === "true" ? `../docs/redesign/phase-4/p4-05/visual/${name}.png` : info.outputPath(`${name}.png`); await page.screenshot({ path, fullPage: false }); await info.attach(name, { path, contentType: "image/png" }); } async function scan(page: Page, info: TestInfo, name: string) { const result = await new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]).analyze(); await info.attach(`${name}-axe`, { body: JSON.stringify({ violations: result.violations, incomplete: result.incomplete }), contentType: "application/json" }); expect(result.violations).toEqual([]); } async function keyboardFocus(page: Page, target: Locator) { await target.focus(); await page.keyboard.press("Tab"); await page.keyboard.press("Shift+Tab"); await expect(target).toBeFocused(); // The native input can be focused before MUI commits the wrapper's // Mui-focusVisible class. Require the same ring within the normal UI timeout. const readRing = () => target.evaluate(node => { const control = node.closest(".MuiInputBase-root, .MuiCheckbox-root") ?? node; const notch = control.querySelector(".MuiOutlinedInput-notchedOutline"); const css = getComputedStyle(notch ?? control); return { width: notch ? css.borderTopWidth : css.outlineWidth, style: notch ? css.borderTopStyle : css.outlineStyle }; }); await expect.poll(readRing).toEqual({ width: "3px", style: "solid" }); } for (const mode of ["light", "dark"] as const) for (const language of ["en", "nb"] as const) { test(`[PRES-J05 J06 W01 W02 S03 E08 E09] ${mode}/${language} table and cards preserve behavior at both densities`, async ({ page, owner }, info) => { test.setTimeout(180_000); const t = translations[language]; const suffix = `${mode}-${language}`; await page.emulateMedia({ reducedMotion: "reduce" }); expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers: owner.headers, data: { language: language === "nb" ? "nb-NO" : "en" } })).status()).toBe(204); await page.addInitScript(({ mode, language }) => { localStorage.setItem("jobtracker.themeMode", mode); localStorage.setItem("uiLanguage", language); localStorage.setItem("jobColumns", JSON.stringify({ status: true, dateApplied: true, daysSince: true, jobUrl: true })); localStorage.setItem("jobPageSize", "15"); }, { mode, language }); await page.setViewportSize({ width: 1440, height: 960 }); await page.goto("/jobs?q=Density&sortBy=jobTitle&sortDir=asc"); const main = page.getByRole("main"); const density = main.getByRole("combobox", { name: t.listDensity, exact: true }); const first = main.locator(`[data-job-row-id='${owner.ids[0]}']`); const ordinary = main.locator(`[data-job-row-id='${owner.ids[1]}']`); const region = main.getByRole("region", { name: t.jobTableScrollRegion }); await expect(first).toBeVisible(); await expect(page.locator("html")).toHaveAttribute("data-color-scheme", mode); await expect(density).toHaveText(t.densityComfortable); await expect(main.getByRole("columnheader", { name: t.jobTableRole, exact: true })).toHaveAttribute("aria-sort", "ascending"); await capture(page, info, `jobs-top-desktop-${suffix}`); const rowText = await first.innerText(); const headers = await main.getByRole("columnheader").allTextContents(); const select = first.getByRole("checkbox"); await select.check(); const query = new URL(page.url()).search; const rowHeights: number[] = []; let listingReads = 0; page.on("request", request => { if (new URL(request.url()).pathname === "/api/jobapplications" && request.method() === "GET") listingReads++; }); const chooseDensity = async (value: string) => { await density.focus(); await density.press("Enter"); await page.getByRole("option", { name: value, exact: true }).focus(); await page.keyboard.press("Enter"); await expect(density).toBeFocused(); }; for (const value of ["comfortable", "compact"] as const) { if (value === "compact") await chooseDensity(t.densityCompact); await expect(select).toBeChecked(); expect(await first.innerText()).toBe(rowText); expect(await main.getByRole("columnheader").allTextContents()).toEqual(headers); expect(new URL(page.url()).search).toBe(query); expect(listingReads).toBe(0); rowHeights.push((await ordinary.boundingBox())!.height); await keyboardFocus(page, select); await keyboardFocus(page, region); await page.keyboard.press("ArrowRight"); await expect.poll(() => region.evaluate(node => node.scrollLeft)).toBeGreaterThan(0); await region.evaluate(node => { node.scrollLeft = 0; }); await density.scrollIntoViewIfNeeded(); await capture(page, info, `desktop-${value}-${suffix}`); await scan(page, info, `desktop-${value}-${suffix}`); } expect(rowHeights[0] - rowHeights[1]).toBeCloseTo(8, 0); await info.attach(`row-heights-${suffix}`, { body: JSON.stringify({ comfortable: rowHeights[0], compact: rowHeights[1] }), contentType: "application/json" }); // Responsive cards retain selected IDs; targets do not shrink with density. for (const value of ["comfortable", "compact"] as const) { await chooseDensity(value === "compact" ? t.densityCompact : t.densityComfortable); for (const width of [1024, 768, 390, 320]) { await page.setViewportSize({ width, height: 960 }); await expect(first.getByRole("checkbox")).toBeChecked(); await expect(first.getByText("Avventer faglig vurdering", { exact: true })).toBeVisible(); if (width >= 768) { await expect(main.getByRole("table")).toBeVisible(); continue; } await expect(main.getByRole("table")).toHaveCount(0); if (width === 390 && value === "comfortable") { await page.evaluate(() => window.scrollTo(0, 0)); await capture(page, info, `jobs-top-phone-${suffix}`); await main.getByRole("button", { name: t.jobTableStatus, exact: true }).click(); await expect(page.getByRole("menuitem", { name: t.statusOffer, exact: true })).toBeVisible(); await page.keyboard.press("Escape"); } // Geometric containment alone misses a title squeezed into one-letter lines. expect((await first.getByText(/^Density 00/).boundingBox())!.width).toBeGreaterThanOrEqual(150); const metrics = await first.evaluate(card => Array.from(card.querySelectorAll("button,a,.MuiCheckbox-root")).map(node => { const rect = node.getBoundingClientRect(); return { text: node.textContent, width: rect.width, height: rect.height, left: rect.left, right: rect.right }; })); expect(metrics.every(rect => rect.height >= 44 && rect.width >= 44 && rect.left >= 0 && rect.right <= width)).toBe(true); const overflow = await first.evaluate(card => Array.from(card.querySelectorAll("p,span,button,a")).filter(node => node.getClientRects().length).map(node => node.getBoundingClientRect()).some(rect => rect.left < 0 || rect.right > innerWidth + 1)); expect(overflow).toBe(false); await first.scrollIntoViewIfNeeded(); await capture(page, info, `phone-${width}-${value}-${suffix}`); if (width === 390) await scan(page, info, `phone-${value}-${suffix}`); } await page.setViewportSize({ width: 1440, height: 960 }); } // Native row status request, listing-link isolation and workspace navigation. await select.uncheck(); // Match by the translated action prefix; the long title remains in its accessible name. const statusAction = first.getByRole("button", { name: new RegExp(`^${t.jobTableQuickStatus}:`) }); await keyboardFocus(page, statusAction); await page.keyboard.press("Escape"); // Dismiss the existing tooltip, preserving focus. const targets = await first.locator(".MuiIconButton-root,.MuiCheckbox-root").evaluateAll(nodes => nodes.map(node => ({ width: node.getBoundingClientRect().width, height: node.getBoundingClientRect().height }))); expect(targets.every(target => target.width >= 32 && target.height >= 32)).toBe(true); await capture(page, info, `row-action-focus-${suffix}`); await statusAction.press("Enter"); const changed = page.waitForResponse(r => r.url() === `${apiUrl}/jobapplications/${owner.ids[0]}/status` && r.request().method() === "PATCH"); await page.getByRole("menuitem", { name: t.jobTableSetStatus.replace("{status}", t.statusInterview), exact: true }).click(); expect((await changed).status()).toBe(204); await expect(first.getByText(t.statusInterview, { exact: true })).toBeVisible(); expect(new URL(page.url()).pathname).toBe("/jobs"); const listing = first.getByRole("link", { name: t.jobTableLink, exact: true }); const popup = page.waitForEvent("popup"); await listing.click(); await (await popup).close(); expect(new URL(page.url()).pathname).toBe("/jobs"); await first.focus(); await first.press("Enter"); await expect(page).toHaveURL(new RegExp(`/jobs/${owner.ids[0]}$`)); await expect(page.getByRole("heading", { name: /^Density 00/ })).toBeVisible(); await page.goBack(); // Browser Back restores the originating row and query after the workspace unmounts. expect(new URL(page.url()).search).toBe(query); await expect(density).toHaveText(t.densityComfortable); await expect(first).toBeFocused(); // The explicit workspace Back action retains the same focus contract. await first.focus(); await first.press("Enter"); await expect(page.getByRole("heading", { name: /^Density 00/ })).toBeVisible(); await page.getByRole("button", { name: t.workspaceBack, exact: true }).click(); await expect(page).toHaveURL(url => url.pathname === "/jobs" && url.search === query); await expect(first).toBeFocused(); await ordinary.focus(); await ordinary.press("Enter"); await expect(page).toHaveURL(new RegExp(`/jobs/${owner.ids[1]}$`)); await page.goBack(); await expect(ordinary).toBeFocused(); await main.getByRole("button", { name: t.tableNextPage }).click(); await expect(main.locator("[data-job-row-id]")).toHaveCount(3); await chooseDensity(t.densityCompact); expect(new URL(page.url()).searchParams.get("page")).toBe("2"); await main.getByRole("combobox", { name: t.settingsRowsPerPage }).click(); await page.getByRole("option", { name: "20", exact: true }).click(); await expect(main.locator("[data-job-row-id]")).toHaveCount(18); await expect(density).toHaveText(t.densityCompact); expect(await page.evaluate(() => localStorage.getItem("jobPageSize"))).toBe("20"); await main.getByRole("button", { name: t.jobTableColumns, exact: true }).click(); await page.getByRole("menuitem", { name: t.settingsColumnJobUrl, exact: true }).click(); await page.keyboard.press("Escape"); await expect(first.getByRole("link", { name: t.jobTableLink, exact: true })).toHaveCount(0); expect(JSON.parse((await page.evaluate(() => localStorage.getItem("jobColumns")))!).jobUrl).toBe(false); // Real confirm/soft-delete and the same renderer's existing Trash restore path. await first.getByRole("button", { name: new RegExp(`^${t.jobTableSoftDelete}:`) }).click(); const deleted = page.waitForResponse(r => r.url() === `${apiUrl}/jobapplications/${owner.ids[0]}` && r.request().method() === "DELETE"); await page.getByRole("dialog").getByRole("button", { name: t.jobTableMove, exact: true }).click(); expect((await deleted).status()).toBe(204); await expect(first).toHaveCount(0); await page.goto("/trash"); await expect(first).toBeVisible(); await chooseDensity(t.densityCompact); await first.getByRole("checkbox").check(); const restored = page.waitForResponse(r => r.url() === `${apiUrl}/jobapplications/${owner.ids[0]}/restore` && r.request().method() === "POST"); await main.getByRole("button", { name: t.jobTableRestoreSelected, exact: true }).click(); expect((await restored).status()).toBe(204); await expect(first).toHaveCount(0); await expect(density).toHaveText(t.densityCompact); await scan(page, info, `empty-trash-${suffix}`); // Only this GET is held/failed; loaded data, actions and all mutations above // use the real owner API. Retry keeps the existing error contract. let release!: () => void; const held = new Promise(resolve => { release = resolve; }); await page.route(`${apiUrl}/jobapplications?*`, async route => { await held; await route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ message: "Synthetic list unavailable" }) }); }); await page.goto("/jobs?q=Density"); await expect(main.getByText(t.loading, { exact: true })).toBeVisible(); await chooseDensity(t.densityCompact); release(); await expect(main.getByRole("alert")).toBeVisible(); await capture(page, info, `list-error-${suffix}`); await scan(page, info, `list-error-${suffix}`); await page.unroute(`${apiUrl}/jobapplications?*`); await main.getByRole("button", { name: t.retry, exact: true }).click(); await expect(first).toBeVisible(); await expect(main.getByRole("alert")).toHaveCount(0); await expect(density).toHaveText(t.densityCompact); }); }