import AxeBuilder from "@axe-core/playwright"; import { expect, test as base, type APIRequestContext, 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; companyId: number; jobId: 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 = `p402-${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(); }); expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { profile: { version: "1", contact: { fullName: "Åse Eksempel" }, skills: ["Norsk", "C#"] } } })).status()).toBe(200); const company = await page.request.post(`${apiUrl}/companies`, { headers, data: { name: "Nordlys æøå AS" } }); expect(company.status()).toBe(201); const companyId = (await company.json()).id; const job = await page.request.post(`${apiUrl}/jobapplications`, { headers, data: { companyId, jobTitle: "Engineer", status: "Saved" } }); expect(job.ok()).toBe(true); const jobId = (await job.json()).id; const cv = await page.request.post(`${apiUrl}/cv/variants`, { headers, data: { name: "Named controls CV", jobApplicationId: jobId } }); expect(cv.status()).toBe(200); const cvId = (await cv.json()).id; expect((await page.request.put(`${apiUrl}/jobapplications/${jobId}/cv`, { headers, data: { variantId: cvId } })).status()).toBe(200); expect((await page.request.post(`${apiUrl}/attachments`, { headers, multipart: { jobId: String(jobId), files: { name: "evidence.txt", mimeType: "text/plain", buffer: Buffer.from("Synthetic æøå\n") } } })).ok()).toBe(true); await provide({ headers, companyId, jobId, cvId }); } finally { await admin.dispose(); } }, }); async function capture(page: Page, info: TestInfo, name: string) { const path = process.env.P4_02_CAPTURE === "true" ? `../docs/redesign/phase-4/p4-02/visual/${name}.png` : info.outputPath(`${name}.png`); await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); await page.screenshot({ path }); await info.attach(name, { path, contentType: "image/png" }); } async function scan(page: Page, info: TestInfo, name: string, dialog = false) { let axe = new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]); if (dialog) axe = axe.include('[role="dialog"]'); const result = await axe.analyze(); await info.attach(`${name}-axe`, { body: JSON.stringify({ violations: result.violations, incomplete: result.incomplete }, null, 2), contentType: "application/json" }); // P5-00 repairs the command-list boundary; dialogs now gate every rule. // Loaded pages still gate naming and inherited contrast. // Preview scrolling and wider workspace structure remain separately recorded debt. expect(dialog ? result.violations : result.violations.filter(v => ["label", "select-name", "aria-input-field-name", "aria-dialog-name", "color-contrast"].includes(v.id))).toEqual([]); } for (const [mode, language, width] of [["light", "en", 1440], ["dark", "nb", 1440], ["light", "nb", 390], ["dark", "en", 390]] as const) { test(`[PRES-E07 PRES-E08 PRES-J08 PRES-C08 PRES-C09 PRES-W06 PRES-W08] ${mode}/${language}/${width} named controls, modal keyboard and retained draft`, async ({ page, owner }, info) => { test.setTimeout(120_000); const t = translations[language]; const suffix = `${mode}-${language}-${width}`; await page.setViewportSize({ width, height: width === 390 ? 844 : 900 }); await page.emulateMedia({ reducedMotion: "reduce" }); await page.addInitScript(({ mode, language }) => { localStorage.setItem("jobtracker.themeMode", mode); localStorage.setItem("uiLanguage", language); }, { mode, language }); expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers: owner.headers, data: { language: language === "nb" ? "nb-NO" : "en" } })).ok()).toBe(true); await page.goto("/companies"); const status = page.locator("main").getByRole("status"); await expect(status).toHaveText(t.contentLoaded); await expect(page.locator("html")).toHaveAttribute("data-color-scheme", mode); const edit = page.getByRole("button", { name: `${t.jobTableEdit}: Nordlys æøå AS`, exact: true }); await edit.focus(); await page.keyboard.press("Control+k"); const commands = page.getByRole("dialog", { name: t.quickSearch, exact: true }); await expect(commands.getByRole("textbox", { name: t.quickSearch })).toBeFocused(); await page.keyboard.press("Tab"); await expect(commands.getByRole("button").first()).toBeFocused(); await scan(page, info, "command", true); await capture(page, info, `command-${suffix}`); await page.keyboard.press("Escape"); await expect(edit).toBeFocused(); const statusNode = await status.elementHandle(); await statusNode!.evaluate(node => { const observer = new MutationObserver(records => { node.setAttribute("data-test-announcements", String(Number(node.getAttribute("data-test-announcements") || 0) + records.length)); }); observer.observe(node, { childList: true, characterData: true, subtree: true }); }); await edit.press("Enter"); const companyDialog = page.getByRole("dialog", { name: t.companiesEdit }); await companyDialog.getByRole("textbox", { name: t.companiesRecruiterName, exact: true }).fill("Retained æøå draft"); await page.route(`${apiUrl}/companies/${owner.companyId}`, route => route.request().method() === "PUT" ? route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ message: "Synthetic save failure" }) }) : route.continue()); await companyDialog.getByRole("button", { name: t.save, exact: true }).click(); // Existing P4-02-D02: this caller reports save errors in the shared toast, // outside the modal's accessible subtree. Preserve the draft proof while // recording the hidden-alert defect instead of claiming an AT error pass. const saveError = page.getByRole("alert", { includeHidden: true }).filter({ hasText: "Synthetic save failure" }); await expect(saveError).toContainText("Synthetic save failure"); await expect(saveError).toBeVisible(); expect(await saveError.evaluate(node => !!node.closest('[aria-hidden="true"]'))).toBe(true); await expect(companyDialog.getByRole("textbox", { name: t.companiesRecruiterName, exact: true })).toHaveValue("Retained æøå draft"); expect(await statusNode!.getAttribute("data-test-announcements")).toBeNull(); await capture(page, info, `retained-draft-${suffix}`); await page.keyboard.press("Escape"); await expect(edit).toBeFocused(); expect((await (await page.request.get(`${apiUrl}/companies/${owner.companyId}`)).json()).recruiterName).toBeFalsy(); const canonicalBefore = (await (await page.request.get(`${apiUrl}/career/profile`)).json()).profile; await page.goto(`/career/builder/${owner.cvId}`); await page.getByRole("tab", { name: t.cvEditorCustomize, exact: true }).click(); for (const name of [t.cvEditorHeadingFont, t.cvEditorBodyFont, t.cvEditorHeadingTreatment, t.cvEditorHeaderTreatment]) await expect(page.getByRole("combobox", { name, exact: true })).toBeVisible(); const heading = page.getByRole("combobox", { name: new RegExp(t.cvEditorHeadingFont) }); await heading.focus(); await heading.press("Enter"); await page.getByRole("option", { name: "Arial", exact: true }).focus(); await page.keyboard.press("Enter"); await expect(heading).toBeFocused(); await scan(page, info, "cv-controls"); await capture(page, info, `cv-select-${suffix}`); await page.getByRole("combobox", { name: t.cvEditorHeaderTreatment, exact: true }).scrollIntoViewIfNeeded(); await capture(page, info, `cv-treatments-${suffix}`); // The existing autosave may finish during the loaded scan; do not click a // now-disabled Save button or change autosave timing merely for this test. await expect(page.getByText(t.cvEditorSaved, { exact: true })).toBeVisible(); expect((await (await page.request.get(`${apiUrl}/cv/variants/${owner.cvId}`)).json()).settings.headingFont).toBe("Arial, Helvetica, sans-serif"); expect((await (await page.request.get(`${apiUrl}/career/profile`)).json()).profile).toEqual(canonicalBefore); await page.goto(`/jobs/${owner.jobId}?section=cover-letter`); for (const name of [t.coverAiAction, t.coverAiTone, t.coverAiDocumentLanguage]) await expect(page.getByRole("combobox", { name: new RegExp(name) })).toBeVisible(); await scan(page, info, "cover-controls"); await page.getByRole("combobox", { name: new RegExp(t.coverAiAction) }).scrollIntoViewIfNeeded(); await capture(page, info, `cover-controls-${suffix}`); await page.goto(`/jobs/${owner.jobId}?section=overview`); await page.getByRole("tab", { name: t.workspaceDocuments, exact: true }).click(); const purpose = page.getByRole("combobox", { name: new RegExp(t.attachmentsPurpose) }); await purpose.focus(); await purpose.press("Enter"); await page.getByRole("option", { name: t.attachmentsPurposeCertificate, exact: true }).focus(); await page.keyboard.press("Enter"); await expect(purpose).toBeFocused(); await expect(page.getByRole("switch", { name: `${t.attachmentsAiUse}: evidence.txt` })).toBeVisible(); const rename = page.getByRole("button", { name: `${t.attachmentsRename}: evidence.txt`, exact: true }); await rename.focus(); await rename.press("Enter"); const prompt = page.getByRole("dialog", { name: t.attachmentsRenameTitle }); await expect(prompt).toHaveAccessibleDescription(t.attachmentsRenamePrompt); const filename = prompt.getByRole("textbox", { name: t.attachmentsRenamePrompt }); await expect(filename).toBeFocused(); await expect(filename).toHaveAccessibleDescription("12/180"); await filename.fill("Reviewed æøå.txt"); await filename.press("Shift+Tab"); await expect(prompt.getByRole("button", { name: t.attachmentsRename, exact: true })).toBeFocused(); await page.keyboard.press("Tab"); await expect(filename).toBeFocused(); await scan(page, info, "prompt", true); await capture(page, info, `prompt-${suffix}`); await page.keyboard.press("Escape"); await expect(rename).toBeFocused(); await rename.press("Enter"); await expect(filename).toHaveValue("evidence.txt"); await filename.fill("Reviewed æøå.txt"); await prompt.getByRole("button", { name: t.attachmentsRename, exact: true }).press("Enter"); const remove = page.getByRole("button", { name: `${t.attachmentsDelete}: Reviewed æøå.txt`, exact: true }); await remove.focus(); await remove.press("Enter"); const confirm = page.getByRole("dialog", { name: t.attachmentsDeleteTitle }); const cancel = confirm.getByRole("button", { name: t.cancel, exact: true }); await expect(cancel).toBeFocused(); await expect(confirm).toHaveAccessibleDescription(t.attachmentsDeleteConfirm.replace("{name}", "Reviewed æøå.txt")); await cancel.press("Shift+Tab"); await expect(confirm.getByRole("button", { name: t.attachmentsDelete, exact: true })).toBeFocused(); await page.keyboard.press("Tab"); await expect(cancel).toBeFocused(); await scan(page, info, "confirm", true); await capture(page, info, `confirm-${suffix}`); await cancel.press("Enter"); await expect(remove).toBeFocused(); const attachments = await (await page.request.get(`${apiUrl}/attachments/${owner.jobId}`)).json(); expect(attachments).toHaveLength(1); expect(attachments[0]).toMatchObject({ fileName: "Reviewed æøå.txt", purpose: "certificate" }); }); }