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<{ headers: Record }>({ headers: async ({ playwright, page, context }, provide) => { const admin = await playwright.request.newContext(); try { const adminHeaders = await login(admin, "e2e@example.test"); const email = `p404-${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(); }); await provide(headers); } finally { await admin.dispose(); } }, }); async function capture(page: Page, info: TestInfo, name: string) { const path = process.env.P4_04_CAPTURE === "true" ? `../docs/redesign/phase-4/p4-04/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 }, null, 2), contentType: "application/json" }); expect(result.violations).toEqual([]); } async function reflow(page: Page, info: TestInfo, name: string) { // Test actual descendants, not body.scrollWidth: the inherited body overflow rule // must not disguise a child/action extending beyond the available reading area. const outside = await page.locator("main").evaluate(main => { const bounds = main.getBoundingClientRect(); return Array.from(main.querySelectorAll("h1,h2,h3,p,button,[role='combobox'],.MuiFormControlLabel-root,a")) .filter(node => node.getClientRects().length && getComputedStyle(node).visibility !== "hidden") .map(node => ({ text: node.textContent?.slice(0, 90), left: node.getBoundingClientRect().left, right: node.getBoundingClientRect().right })) .filter(rect => rect.left < bounds.left - 1 || rect.right > Math.min(bounds.right, innerWidth) + 1); }); await info.attach(`${name}-reflow`, { body: JSON.stringify(outside), contentType: "application/json" }); expect(outside).toEqual([]); } for (const mode of ["light", "dark"] as const) for (const language of ["en", "nb"] as const) { test(`[PRES-E07 E08 E09 S03 S04 S05 J05 J10 M06 W11] ${mode}/${language} Settings headings, reflow and retained actions`, async ({ page, headers }, info) => { test.setTimeout(180_000); const t = translations[language]; const suffix = `${mode}-${language}`; 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, data: { language: language === "nb" ? "nb-NO" : "en" } })).ok()).toBe(true); await page.setViewportSize({ width: 1440, height: 900 }); await page.goto(language === "nb" ? "/settings/?source=preservation" : "/settings"); const main = page.getByRole("main"); await expect(page.locator("html")).toHaveAttribute("data-color-scheme", mode); await expect(main.getByRole("heading", { level: 1 })).toHaveText(t.settings); await expect(main.getByRole("switch", { name: t.settingsAiEnabled, exact: true })).toBeVisible(); await expect(main.getByText(t.settingsUsageNoAi, { exact: true })).toBeVisible(); await expect(main.getByRole("switch", { name: t.settingsAiExternalAllowed, exact: true })).toBeDisabled(); for (const title of [t.settingsAppearance, t.settingsLanguageTitle, t.settingsJobs, t.settingsAiPrivacyTitle, t.settingsUsageTitle, t.settingsQuickCaptureTitle, t.connectedAccounts]) { await expect(main.getByRole("heading", { level: 2, name: title, exact: true })).toHaveCount(1); } await scan(page, info, `general-${suffix}`); for (const width of [1920, 1440, 1024, 768, 767, 390, 320]) { await page.setViewportSize({ width, height: 900 }); await reflow(page, info, `general-${width}-${suffix}`); if ([1440, 390, 320].includes(width)) { await page.evaluate(() => scrollTo(0, 0)); await capture(page, info, `general-${width}-${suffix}`); } } await scan(page, info, `phone-${suffix}`); // Synthetic long localized title, with no CSS injection or hidden controls. await main.getByRole("heading", { level: 1 }).evaluate((node, title) => { node.textContent = title; }, language === "nb" ? "Innstillinger for jobbpreferanser, oppfølginger og tilkoblede innbokser" : "Settings for job preferences, follow-up reminders and connected inboxes"); await reflow(page, info, `long-heading-${suffix}`); await capture(page, info, `long-heading-${suffix}`); const chooseSection = async (name: string) => { if (page.viewportSize()!.width < 768) { const selector = main.getByRole("combobox", { name: t.settingsNavigation, exact: true }); await selector.focus(); await selector.press("Enter"); await page.getByRole("option", { name, exact: true }).focus(); await page.keyboard.press("Enter"); await expect(selector).toBeFocused(); } else await main.getByRole("tab", { name, exact: true }).click(); }; // Initial rules failure retains heading/error; existing section re-entry reloads. await page.route(`${apiUrl}/rules`, route => route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ message: "Synthetic rules unavailable" }) })); await chooseSection(t.settingsTabFollowUps); await expect(main.getByRole("alert")).toContainText("Synthetic rules unavailable"); await expect(main.getByRole("heading", { level: 2, name: t.rulesTitle })).toBeVisible(); await capture(page, info, `rules-error-${suffix}`); await scan(page, info, `rules-error-${suffix}`); await page.unroute(`${apiUrl}/rules`); await chooseSection(t.settingsTabGeneral); await chooseSection(t.settingsTabFollowUps); const rule = main.getByRole("spinbutton", { name: t.rulesAppliedFollowUpDays, exact: true }); await rule.fill("5"); for (const width of [320, 390, 768, 1024, 1440]) { await page.setViewportSize({ width, height: 900 }); await expect(rule).toHaveValue("5"); await reflow(page, info, `rules-${width}-${suffix}`); } const response = page.waitForResponse(r => r.url() === `${apiUrl}/rules` && r.request().method() === "PUT"); await main.getByRole("button", { name: t.rulesSave, exact: true }).click(); expect((await response).status()).toBe(204); expect((await (await page.request.get(`${apiUrl}/rules`)).json()).appliedFollowUpDays).toBe(5); // Hold only the local notification read to exercise loading in the same layout. let release!: () => void; const held = new Promise(resolve => { release = resolve; }); await page.route(`${apiUrl}/notification-settings`, async route => { await held; await route.continue(); }); await page.reload(); await page.setViewportSize({ width: 320, height: 900 }); await chooseSection(t.settingsTabNotifications); await expect(main.locator(".MuiSkeleton-root")).toBeVisible(); await capture(page, info, `notifications-loading-${suffix}`); release(); const pref = main.getByRole("checkbox", { name: t.settingsNotificationsFollowUpReminders, exact: true }); await expect(pref).toBeChecked(); await page.unroute(`${apiUrl}/notification-settings`); await pref.uncheck(); let fail = true; await page.route(`${apiUrl}/notification-settings`, route => { if (route.request().method() === "PUT" && fail) { fail = false; return route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ message: "Synthetic notification save failure" }) }); } return route.continue(); }); const save = main.getByRole("button", { name: t.settingsNotificationsSave, exact: true }); await save.focus(); const savedNotification = page.waitForResponse(r => r.url() === `${apiUrl}/notification-settings` && r.request().method() === "PUT"); await save.press("Enter"); expect((await savedNotification).status()).toBe(503); await expect(main.getByRole("alert")).toContainText("Synthetic notification save failure"); await expect(pref).not.toBeChecked(); await reflow(page, info, `notifications-error-${suffix}`); await capture(page, info, `notifications-error-${suffix}`); await scan(page, info, `notifications-error-${suffix}`); const retriedNotification = page.waitForResponse(r => r.url() === `${apiUrl}/notification-settings` && r.request().method() === "PUT"); await save.press("Enter"); expect((await retriedNotification).status()).toBe(200); await expect(main.getByRole("alert")).toHaveCount(0); expect((await (await page.request.get(`${apiUrl}/notification-settings`)).json()).emailFollowUpRemindersEnabled).toBe(false); await page.reload(); await chooseSection(t.settingsTabNotifications); await expect(pref).not.toBeChecked(); await chooseSection(t.settingsTabBackup); await expect(main.getByText(t.accountDeleteDisabled, { exact: true })).toBeVisible(); await expect(main.getByRole("button", { name: t.accountDeleteButton, exact: true })).toHaveCount(0); await reflow(page, info, `backup-${suffix}`); await capture(page, info, `backup-${suffix}`); await scan(page, info, `backup-${suffix}`); const download = page.waitForEvent("download"); await main.getByRole("button", { name: t.accountExportDownload, exact: true }).click(); expect((await download).suggestedFilename()).toMatch(/\.zip$/); // The existing fixed toast can temporarily cover bottom-of-page actions. // Let it dismiss normally before certifying/capturing the focused action. await expect(page.getByRole("alert").filter({ hasText: t.accountExportDownloaded })).toHaveCount(0, { timeout: 15_000 }); await chooseSection(t.settingsTabGeneral); const manage = main.getByRole("button", { name: t.settingsManageConnectedAccounts, exact: true }); await manage.focus(); await page.keyboard.press("Shift+Tab"); await page.keyboard.press("Tab"); await expect(manage).toBeFocused(); expect(await manage.evaluate(node => getComputedStyle(node).outlineStyle)).toBe("solid"); await manage.scrollIntoViewIfNeeded(); await expect.poll(() => manage.evaluate(node => { const rect = node.getBoundingClientRect(); const hit = document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2); return node.contains(hit) ? "visible" : `${hit?.tagName}: ${hit?.getAttribute("class")} at ${rect.x},${rect.y},${rect.width},${rect.height}`; })).toBe("visible"); await capture(page, info, `connected-action-${suffix}`); await manage.press("Enter"); await expect(page).toHaveURL(/\/settings\/connected-accounts$/); expect((await page.request.get(`${apiUrl}/users`)).status()).toBe(403); }); }