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; shortId: number; longId: number } }>({ owner: async ({ playwright, page, context }, provide) => { const admin = await playwright.request.newContext(); try { const adminHeaders = await login(admin, "e2e@example.test"); const email = `p403-${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", headline: "Canonical engineer" }, skills: ["Norsk", "C#"], } } })).status()).toBe(200); const ids: number[] = []; for (const long of [false, true]) { const created = await page.request.post(`${apiUrl}/cv/variants`, { headers, data: { name: long ? "Erfaring og kvalifikasjoner — æøå" : "One page CV" } }); expect(created.status()).toBe(200); const cv = await created.json(); const customSections = long ? [{ key: "keyboard-evidence", title: "Erfaring og kvalifikasjoner", contentType: "bullets", items: Array.from({ length: 45 }, (_, i) => `Erfaring ${i + 1}: Samarbeid om tilgjengelige løsninger og pålitelig dokumentasjon for norske arbeidsgivere.`) }] : []; expect((await page.request.put(`${apiUrl}/cv/variants/${cv.id}`, { headers, data: { name: cv.name, settings: { ...cv.settings, themeId: "minimal", customSections }, expectedVersion: cv.version } })).status()).toBe(200); ids.push(cv.id); } await provide({ headers, shortId: ids[0], longId: ids[1] }); } finally { await admin.dispose(); } }, }); async function capture(page: Page, info: TestInfo, state: string) { const path = process.env.P4_03_CAPTURE === "true" ? `../docs/redesign/phase-4/p4-03/visual/${state}.png` : info.outputPath(`${state}.png`); await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); await page.screenshot({ path }); await info.attach(state, { path, contentType: "image/png" }); } async function scan(page: Page, info: TestInfo, state: string) { const result = await new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]).analyze(); await info.attach(`${state}-axe`, { body: JSON.stringify({ violations: result.violations, incomplete: result.incomplete }, null, 2), contentType: "application/json" }); // P5-00 repairs the shared Tooltip pair; no contrast exception remains. expect(result.violations).toEqual([]); } async function enterPreview(page: Page, region: Locator, start = page.getByRole("tab").last()) { // Content is primary in P5-09. Reach the named pane-navigation action through // the real tab order, rather than traversing every collapsed section control. await start.focus(); const previewEntry = page.getByRole("button", { name: /^(Go to preview|Gå til forhåndsvisning)$/ }); if (await previewEntry.isVisible()) { await page.keyboard.press("Tab"); await expect(page.getByRole("combobox", { name: /^(Go to section|Gå til seksjon)/ })).toBeFocused(); await page.keyboard.press("Tab"); await expect(previewEntry).toBeFocused(); await page.keyboard.press("Enter"); } else { // Mobile retains its existing Edit/Preview button and native tab entry. for (let i = 0; i < 80 && !(await region.evaluate(node => node === document.activeElement)); i++) await page.keyboard.press("Tab"); } await expect(region).toBeFocused(); const ring = await region.evaluate(node => { const css = getComputedStyle(node); 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 ancestors: Element[] = []; for (let el = node.parentElement; el; el = el.parentElement) ancestors.unshift(el); const surface = ancestors.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(css.outlineColor), surface)); const b = luminance(surface); return { width: css.outlineWidth, style: css.outlineStyle, visible: node.matches(":focus-visible"), contrast: (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05) }; }); expect(ring).toMatchObject({ width: "3px", style: "solid", visible: true }); expect(ring.contrast).toBeGreaterThanOrEqual(3); } for (const [mode, language] of [["light", "en"], ["dark", "nb"], ["light", "nb"], ["dark", "en"]] as const) { test(`[PRES-C06 PRES-C07 PRES-C10 PRES-E08] ${mode}/${language} preview keyboard, pagination and responsive state`, async ({ page, owner }, info) => { test.setTimeout(120_000); const t = translations[language]; const suffix = `${mode}-${language}`; await page.setViewportSize({ width: 1440, height: 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(`/career/builder/${owner.shortId}`); const frame = page.frameLocator(`iframe[title="${t.cvEditorPreviewPage.replace("{page}", "1")}"]`); await expect(frame.getByText("Åse Eksempel", { exact: true })).toBeVisible(); await expect(page.locator("[data-cv-page]")).toHaveCount(1); const region = page.getByRole("region", { name: t.cvEditorLivePreview, exact: true }); await expect(region).toBeVisible(); await enterPreview(page, region); await capture(page, info, `one-page-${suffix}`); const profileBefore = (await (await page.request.get(`${apiUrl}/career/profile`)).json()).profile; const siblingBefore = await (await page.request.get(`${apiUrl}/cv/variants/${owner.shortId}`)).json(); await page.goto(`/career/builder/${owner.longId}`); await expect(frame.getByText("Åse Eksempel", { exact: true })).toBeVisible(); await expect.poll(() => page.locator("[data-cv-page]").count()).toBeGreaterThan(1); await page.getByRole("tab", { name: t.cvEditorContent, exact: true }).click(); const headline = page.getByRole("textbox", { name: t.cvEditorHeadlineOverride, exact: true }); await headline.fill("Reviewed CV-only æøå"); await expect(page.getByText(t.cvEditorSaved, { exact: true })).toBeVisible(); await expect.poll(async () => (await (await page.request.get(`${apiUrl}/cv/variants/${owner.longId}`)).json()).settings.headline).toBe("Reviewed CV-only æøå"); await expect(region).toHaveAttribute("aria-busy", "false"); await enterPreview(page, region); const top = await region.evaluate(node => node.scrollTop); const pageDownSettled = region.evaluate(node => new Promise(resolve => node.addEventListener("scrollend", () => resolve(), { once: true }))); await page.keyboard.press("PageDown"); await pageDownSettled; await expect.poll(() => region.evaluate(node => node.scrollTop)).toBeGreaterThan(top + 100); await page.keyboard.press("Control+End"); await expect.poll(() => region.evaluate(node => Math.abs(node.scrollHeight - node.clientHeight - node.scrollTop))).toBeLessThan(3); await capture(page, info, `multipage-end-${suffix}`); await page.keyboard.press("Control+Home"); await expect.poll(() => region.evaluate(node => node.scrollTop)).toBe(0); await page.keyboard.press("Shift+Tab"); expect(await region.evaluate(node => node.contains(document.activeElement))).toBe(false); await page.keyboard.press("Tab"); await expect(region).toBeFocused(); // Native iframe content remains reachable. Forward traversal must eventually leave the preview. const frames = await page.locator("[data-cv-page]").count(); const traversal = []; for (let i = 0; i < frames * 3 + 5 && await region.evaluate(node => node.contains(document.activeElement)); i++) { await page.keyboard.press("Tab"); traversal.push(await page.evaluate(() => { const active = document.activeElement; return { outer: active?.tagName, title: active?.getAttribute("title"), inner: active instanceof HTMLIFrameElement ? active.contentDocument?.activeElement?.outerHTML.slice(0, 200) : null }; })); } await info.attach("forward-tab-traversal", { body: JSON.stringify(traversal, null, 2), contentType: "application/json" }); expect(await region.evaluate(node => node.contains(document.activeElement))).toBe(false); for (const width of [390, 768, 899, 900, 1024, 1440]) { await page.setViewportSize({ width, height: width < 900 ? 844 : 900 }); if (width < 900) { const previewButton = page.getByRole("button", { name: t.cvEditorPreview, exact: true }); await previewButton.focus(); await previewButton.press("Enter"); await expect(region).toBeVisible(); await enterPreview(page, region, previewButton); await page.keyboard.press("PageDown"); await expect.poll(() => region.evaluate(node => node.scrollTop)).toBeGreaterThan(0); await capture(page, info, `preview-${width}-${suffix}`); await page.getByRole("button", { name: t.cvEditorEdit, exact: true }).press("Enter"); await expect(region).not.toBeVisible(); } else { await expect(region).toBeVisible(); await enterPreview(page, region); await scan(page, info, `transition-${width}`); } await expect(headline).toHaveValue("Reviewed CV-only æøå"); } // Resizing while the preview owns focus must not hide that focused pane. await enterPreview(page, region); await page.setViewportSize({ width: 390, height: 844 }); await expect(region).toBeVisible(); await expect(region).toBeFocused(); await page.setViewportSize({ width: 1440, height: 900 }); await headline.focus(); await page.setViewportSize({ width: 390, height: 844 }); await expect(headline).toBeVisible(); await expect(headline).toBeFocused(); await page.setViewportSize({ width: 1440, height: 900 }); let failPreview!: () => void; const heldPreview = new Promise(resolve => { failPreview = resolve; }); await page.route(`${apiUrl}/cv/preview`, async route => { await heldPreview; await route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ message: "Synthetic preview unavailable" }) }); }, { times: 1 }); await page.getByRole("button", { name: t.cvEditorUndo, exact: true }).click(); await expect(headline).toHaveValue(""); await expect(page.getByText(t.cvEditorUpdatingPreview, { exact: true })).toBeVisible(); await enterPreview(page, region); await page.getByText(t.cvEditorUpdatingPreview, { exact: true }).evaluate(node => node.scrollIntoView({ block: "center" })); await capture(page, info, `loading-${suffix}`); failPreview(); await expect(page.getByText(t.cvEditorPreviewUnavailable, { exact: true })).toBeVisible(); await expect(region).toBeFocused(); await expect(frame.getByText("Reviewed CV-only æøå", { exact: true })).toBeVisible(); await page.getByText(t.cvEditorPreviewUnavailable, { exact: true }).evaluate(node => node.scrollIntoView({ block: "center" })); await capture(page, info, `error-${suffix}`); await page.getByRole("button", { name: t.cvEditorRetryPreview, exact: true }).click(); await expect(frame.getByText("Canonical engineer", { exact: true })).toBeVisible(); await expect(page.getByText(t.cvEditorPreviewUnavailable, { exact: true })).not.toBeVisible(); await page.getByRole("button", { name: t.cvEditorRedo, exact: true }).click(); await expect(headline).toHaveValue("Reviewed CV-only æøå"); await expect(page.getByText(t.cvEditorSaved, { exact: true })).toBeVisible(); expect((await (await page.request.get(`${apiUrl}/cv/variants/${owner.longId}`)).json()).settings.headline).toBe("Reviewed CV-only æøå"); expect(await (await page.request.get(`${apiUrl}/cv/variants/${owner.shortId}`)).json()).toEqual(siblingBefore); expect((await (await page.request.get(`${apiUrl}/career/profile`)).json()).profile).toEqual(profileBefore); await scan(page, info, "loaded-final"); }); }