import AxeBuilder from '@axe-core/playwright'; import { expect, test, type APIRequestContext, type Locator, type Page, type TestInfo } from '@playwright/test'; import type { CvVariant } from '../src/cvBuilder'; 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(); return { 'X-CSRF-TOKEN': (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value, 'X-Session-Context': me.sessionContext as string }; } async function capture(scope: Locator, info: TestInfo, state: string) { const path = info.outputPath(`${state}.png`); const geometry = await scope.evaluate(node => { const region = node.querySelector('[role="region"]')!; const bounds = (element: Element) => { const rect = element.getBoundingClientRect(); return { top: rect.top, bottom: rect.bottom, width: rect.width, height: rect.height }; }; return { viewport: { width: innerWidth, height: innerHeight }, chrome: bounds(node), region: bounds(region), scrollTop: region.scrollTop, focused: region === document.activeElement }; }); await scope.page().screenshot({ path, animations: 'disabled' }); await info.attach(state, { path, contentType: 'image/png' }); await info.attach(`${state}-viewport`, { body: JSON.stringify(geometry), contentType: 'application/json' }); } async function scan(page: Page, info: TestInfo, state: string, region: string) { const result = await new AxeBuilder({ page }).include(`.MuiPaper-root:has(> [role="region"][aria-label=${JSON.stringify(region)}])`).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze(); await info.attach(`${state}-axe`, { body: JSON.stringify({ violations: result.violations, incomplete: result.incomplete }), contentType: 'application/json' }); expect(result.violations).toEqual([]); } for (const [mode, language] of [['light', 'en'], ['dark', 'nb']] as const) { test(`[PRES-C06 C08 C09 C10 C12 E08 E09] ${mode}/${language} native preview chrome, auto-fit, long pages and recovery`, async ({ page, context, playwright }, info) => { test.setTimeout(300_000); const t = translations[language]; const admin = await playwright.request.newContext(); try { const adminHeaders = await login(admin, 'e2e@example.test'); const email = `p511-${Date.now()}-${mode}-${language}@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 context.addInitScript(({ mode, language }) => { localStorage.setItem('jobtracker.themeMode', mode); localStorage.setItem('uiLanguage', language); }, { mode, language }); await page.emulateMedia({ reducedMotion: 'reduce' }); expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers, data: { language: language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204); const sentence = language === 'nb' ? 'Utviklet pålitelige arbeidsflyter med tilgjengelig navigasjon, dokumentert kildegrunnlag og tydelige vurderinger for norske arbeidssøkere.' : 'Developed reliable workflows with accessible navigation, documented source evidence and explicit review for people managing their careers.'; const shortProfile = { version: '1', contact: { fullName: 'Åse Eksempel', headline: 'Canonical engineer — æøå', location: 'Oslo, Norge' }, summary: ['Documented career evidence.'], skills: ['C#', 'React', 'Norsk'] }; expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { profile: shortProfile } })).status()).toBe(200); const create = async (name: string, themeId: string, customItems: string[] = []): Promise => { const response = await page.request.post(`${apiUrl}/cv/variants`, { headers, data: { name, settings: { themeId, language, sections: [{ key: 'summary' }, { key: 'experience' }, { key: 'skills' }, ...(customItems.length ? [{ key: 'custom:preview-evidence' }] : [])], customSections: customItems.length ? [{ key: 'preview-evidence', title: 'Documented CV contributions — æøå', contentType: 'bullets', items: customItems }] : [], } } }); expect(response.status()).toBe(200); return response.json(); }; const onePage = await create('One-page Code fixture — æøå', 'code'); const multiple = await create('Multi-page preview fixture — æøå', 'minimal', Array.from({ length: 45 }, (_, index) => `Custom contribution ${index + 1}: ${sentence}`)); const read = async (id: number): Promise => (await page.request.get(`${apiUrl}/cv/variants/${id}`)).json(); const region = page.getByRole('region', { name: t.cvEditorLivePreview, exact: true }); const chrome = region.locator('..'); const editing = page.getByRole('region', { name: t.cvEditorEditingRegion, exact: true }); const measurement = page.getByTitle(t.cvEditorPreviewTitle, { exact: true }); const pages = region.locator('[data-cv-page]'); const frames = pages.locator('iframe'); const firstFrame = page.getByTitle(t.cvEditorPreviewPage.replace('{page}', '1'), { exact: true }); const firstDocument = page.frameLocator(`iframe[title=${JSON.stringify(t.cvEditorPreviewPage.replace('{page}', '1'))}]`); const saved = async () => { await expect(page.getByText(t.cvEditorSaved, { exact: true })).toBeVisible(); }; const measurements: Record[] = []; const settle = async (state: string, minimum = 1) => { await expect(region).toHaveAttribute('aria-busy', 'false'); await expect.poll(() => measurement.evaluate(element => (element as HTMLIFrameElement).contentDocument?.documentElement.dataset.cvPreviewReady)).toBe('true'); const native = await measurement.evaluate(element => { const data = (element as HTMLIFrameElement).contentDocument!.documentElement.dataset; return { pages: Number(data.cvPreviewPages), width: Number(data.cvPreviewWidth), height: Number(data.cvPreviewHeight), usableHeight: Number(data.cvPreviewUsableHeight), layoutDurationMs: Number(data.cvPreviewDurationMs), overflow: data.cvPreviewOverflow === 'true' }; }); expect(native.pages).toBeGreaterThanOrEqual(minimum); await expect(pages).toHaveCount(native.pages); await expect.poll(() => frames.evaluateAll(elements => elements.every(element => (element as HTMLIFrameElement).contentDocument?.documentElement.dataset.cvPreviewReady === 'true'))).toBe(true); await expect(chrome.getByText(t.cvEditorPageCount.replace('{count}', String(native.pages)), { exact: true })).toBeVisible(); expect(Number.isFinite(native.layoutDurationMs)).toBe(true); expect(native.layoutDurationMs).toBeGreaterThanOrEqual(0); expect(native.width).toBeCloseTo(210 * 96 / 25.4, 0); expect(native.height).toBeCloseTo(297 * 96 / 25.4, 0); const titles = await frames.evaluateAll(elements => elements.map(element => element.getAttribute('title'))); expect(titles).toEqual(Array.from({ length: native.pages }, (_, index) => t.cvEditorPreviewPage.replace('{page}', String(index + 1)))); measurements.push({ state, ...native }); return native; }; const enterPreview = async () => { const desktopEntry = page.getByRole('button', { name: t.cvEditorFocusPreview, exact: true }); if (await desktopEntry.isVisible()) { await desktopEntry.focus(); await page.keyboard.press('Enter'); } else { await page.getByRole('button', { name: t.cvEditorPreview, exact: true }).press('Enter'); await chrome.getByRole('button', { name: t.cvEditorFocusEditor, exact: true }).focus(); await page.keyboard.press('Tab'); } await expect(region).toBeFocused(); await expect(region).toBeVisible(); }; const returnEditor = async (draft: string) => { await expect(region).toBeFocused(); await page.keyboard.press('Shift+Tab'); await expect(chrome.getByRole('button', { name: t.cvEditorFocusEditor, exact: true })).toBeFocused(); await page.keyboard.press('Enter'); await expect(editing).toBeFocused(); await expect(editing).toHaveAttribute('tabindex', '-1'); await expect(page.getByLabel(t.cvEditorHeadlineOverride, { exact: true })).toHaveValue(draft); }; const autoFit = async (state: string, widths: number[]) => { const originalSources = await frames.evaluateAll(elements => elements.map(element => element.getAttribute('srcdoc'))); const originalFrames = await frames.elementHandles(); const originalMeasurement = await measurement.getAttribute('srcdoc'); for (const width of widths) { await enterPreview(); await page.setViewportSize({ width, height: width < 900 ? 844 : 900 }); await expect(region).toBeFocused(); await expect(region).toBeVisible(); const available = await region.evaluate(node => node.clientWidth); const expectedScale = Math.min(1, Math.max(0.2, (available - 16) / (210 * 96 / 25.4))); await expect.poll(() => firstFrame.evaluate(element => new DOMMatrixReadOnly(getComputedStyle(element).transform).a)).toBeCloseTo(expectedScale, 3); const scale = await firstFrame.evaluate(element => new DOMMatrixReadOnly(getComputedStyle(element).transform).a); await expect(chrome.getByText(t.cvEditorPreviewScale.replace('{percent}', String(Math.round(scale * 100))), { exact: true })).toBeVisible(); expect(await frames.evaluateAll(elements => elements.map(element => element.getAttribute('srcdoc')))).toEqual(originalSources); expect(await measurement.getAttribute('srcdoc')).toBe(originalMeasurement); for (let index = 0; index < originalFrames.length; index++) expect(await frames.nth(index).evaluate((node, original) => node === original, originalFrames[index])).toBe(true); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true); await capture(chrome, info, `${state}-fit-${width}`); } for (const handle of originalFrames) await handle.dispose(); }; const bottomSnapshot = async () => { await enterPreview(); await page.keyboard.press('Control+End'); await expect.poll(() => region.evaluate(node => Math.abs(node.scrollHeight - node.clientHeight - node.scrollTop))).toBeLessThan(3); return { scroll: await region.evaluate(node => ({ top: node.scrollTop, height: node.scrollHeight })), sources: await frames.evaluateAll(elements => elements.map(element => element.getAttribute('srcdoc'))), handles: await frames.elementHandles() }; }; const retainedBottom = async (snapshot: Awaited>, state: string) => { const current = await region.evaluate(node => ({ top: node.scrollTop, height: node.scrollHeight })); await info.attach(`${state}-scroll-retention`, { body: JSON.stringify({ before: snapshot.scroll, after: current }), contentType: 'application/json' }); expect(Math.abs(current.top - snapshot.scroll.top), `${state}: retained scroll position`).toBeLessThanOrEqual(1); expect(Math.abs(current.height - snapshot.scroll.height), `${state}: retained scroll height`).toBeLessThanOrEqual(1); expect(await frames.evaluateAll(elements => elements.map(element => element.getAttribute('srcdoc')))).toEqual(snapshot.sources); for (let index = 0; index < snapshot.handles.length; index++) expect(await frames.nth(index).evaluate((node, original) => node === original, snapshot.handles[index])).toBe(true); }; await page.setViewportSize({ width: 1440, height: 900 }); await page.goto(`/career/builder/${onePage.id}`); await expect(page.getByLabel(t.cvEditorName, { exact: true })).toHaveValue(onePage.name); expect((await settle('one-page-code')).pages).toBe(1); await capture(chrome, info, 'one-page-code-default'); await enterPreview(); await capture(chrome, info, 'one-page-code-focus'); await autoFit('one-page-code', [1440, 768, 320]); await scan(page, info, 'one-page-phone-preview', t.cvEditorLivePreview); await returnEditor(''); expect(await read(onePage.id)).toEqual(onePage); await page.setViewportSize({ width: 1440, height: 900 }); await page.goto(`/career/builder/${multiple.id}`); await expect(page.getByLabel(t.cvEditorName, { exact: true })).toHaveValue(multiple.name); await settle('multiple-pages', 2); await enterPreview(); await expect(region).toBeFocused(); 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(100); await expect(region).toBeFocused(); await page.keyboard.press('Control+End'); await expect.poll(() => region.evaluate(node => Math.abs(node.scrollHeight - node.clientHeight - node.scrollTop))).toBeLessThan(3); await capture(chrome, info, 'multiple-pages-end'); await page.keyboard.press('Control+Home'); await expect.poll(() => region.evaluate(node => node.scrollTop)).toBe(0); await capture(chrome, info, 'multiple-pages-start'); await returnEditor(''); expect(await read(multiple.id)).toEqual(multiple); const longProfile = { ...shortProfile, jobs: Array.from({ length: 3 }, (_, index) => ({ id: `long-job-${index + 1}`, title: `Source-grounded engineer ${index + 1} — æøå`, company: 'Nordlys Fixture AS', startDate: '2017-01', endDate: '2025-12', bullets: Array.from({ length: 30 }, (_, bullet) => `Source contribution ${index * 30 + bullet + 1}: ${sentence} ${sentence}`) })) }; expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { profile: longProfile } })).status()).toBe(200); const source = await (await page.request.get(`${apiUrl}/career/profile`)).json(); const veryLong = await create('Very long source and custom CV — æøå', 'minimal', Array.from({ length: 45 }, (_, index) => `CV-only long contribution ${index + 1}: ${sentence} ${sentence}`)); const unchanged = async () => { expect(await (await page.request.get(`${apiUrl}/career/profile`)).json()).toEqual(source); expect(await read(onePage.id)).toEqual(onePage); expect(await read(multiple.id)).toEqual(multiple); }; await page.goto(`/career/builder/${veryLong.id}`); await expect(page.getByLabel(t.cvEditorName, { exact: true })).toHaveValue(veryLong.name); const longMeasurement = await settle('very-long-source-and-custom', 5); await autoFit('very-long', [1440, 768, 320]); await scan(page, info, 'very-long-phone-preview', t.cvEditorLivePreview); await page.keyboard.press('Control+End'); await expect.poll(() => region.evaluate(node => Math.abs(node.scrollHeight - node.clientHeight - node.scrollTop))).toBeLessThan(3); await capture(chrome, info, 'very-long-phone-last-page'); await page.keyboard.press('Control+Home'); await expect.poll(() => region.evaluate(node => node.scrollTop)).toBe(0); await returnEditor(''); expect(await read(veryLong.id)).toEqual(veryLong); await unchanged(); await page.setViewportSize({ width: 1440, height: 900 }); const heldBottom = await bottomSnapshot(); await returnEditor(''); let release!: () => void; const hold = new Promise(resolve => { release = resolve; }); let pending = false; const lastGood = await firstFrame.getAttribute('srcdoc'); await page.route(`${apiUrl}/cv/preview`, async route => { pending = true; await hold; await route.continue(); }, { times: 1 }); const realRender = page.waitForResponse(response => response.url() === `${apiUrl}/cv/preview` && response.request().method() === 'POST'); const headline = page.getByLabel(t.cvEditorHeadlineOverride, { exact: true }); try { await headline.fill('Preview draft retained — æøå'); await expect.poll(() => pending).toBe(true); await expect(region).toHaveAttribute('aria-busy', 'true'); await enterPreview(); await expect(chrome.getByText(t.cvEditorUpdatingPreview, { exact: true })).toBeVisible(); await retainedBottom(heldBottom, 'held-request'); expect(await firstFrame.getAttribute('srcdoc')).toBe(lastGood); await expect(pages).toHaveCount(longMeasurement.pages); await capture(chrome, info, 'loading-retains-last-good-pages'); await returnEditor('Preview draft retained — æøå'); } finally { release(); await realRender; for (const handle of heldBottom.handles) await handle.dispose(); } expect((await realRender).status()).toBe(200); await settle('native-render-after-held-request', 5); await expect(firstDocument.getByText('Preview draft retained — æøå', { exact: true })).toBeVisible(); await expect.poll(async () => (await read(veryLong.id)).settings.headline).toBe('Preview draft retained — æøå'); await saved(); const failedBottom = await bottomSnapshot(); await returnEditor('Preview draft retained — æøå'); const goodSources = await frames.evaluateAll(elements => elements.map(element => element.getAttribute('srcdoc'))); const goodCount = await pages.count(); await page.route(`${apiUrl}/cv/preview`, route => route.abort('failed'), { times: 1 }); await headline.fill('Recovered native preview — æøå'); await enterPreview(); await expect(chrome.getByText(t.cvEditorPreviewUnavailable, { exact: true })).toBeVisible(); await expect(region).toHaveAttribute('aria-busy', 'false'); await expect(region).toBeFocused(); await retainedBottom(failedBottom, 'failed-request'); for (const handle of failedBottom.handles) await handle.dispose(); await expect(pages).toHaveCount(goodCount); expect(await frames.evaluateAll(elements => elements.map(element => element.getAttribute('srcdoc')))).toEqual(goodSources); await capture(chrome, info, 'error-retains-last-good-pages'); await scan(page, info, 'error-preview', t.cvEditorLivePreview); const retried = page.waitForResponse(response => response.url() === `${apiUrl}/cv/preview` && response.request().method() === 'POST'); await chrome.getByRole('button', { name: t.cvEditorRetryPreview, exact: true }).press('Enter'); expect((await retried).status()).toBe(200); await settle('native-retry-recovered', 5); await expect(chrome.getByText(t.cvEditorPreviewUnavailable, { exact: true })).toHaveCount(0); await expect(firstDocument.getByText('Recovered native preview — æøå', { exact: true })).toBeVisible(); await enterPreview(); await capture(chrome, info, 'recovered-preview-focus'); await returnEditor('Recovered native preview — æøå'); await expect.poll(async () => (await read(veryLong.id)).settings.headline).toBe('Recovered native preview — æøå'); await saved(); await unchanged(); await info.attach('native-preview-measurements', { body: JSON.stringify({ mode, language, sourceBullets: 90, customBullets: 45, measurements }), contentType: 'application/json' }); } finally { await admin.dispose(); } }); }