import { expect, test, type APIRequestContext, type Browser, type Page, type TestInfo } from '@playwright/test'; import AxeBuilder from '@axe-core/playwright'; import fs from 'node:fs/promises'; import path from 'node:path'; import { translations } from '../src/i18n/translations'; const apiUrl = 'http://localhost:5302/api'; const password = 'E2ePassword123!'; const output = path.resolve(process.env.CV_SETTINGS_FIDELITY_OUTPUT || '../.artifacts/p5-12/settings-fidelity'); 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(page: Page, info: TestInfo, name: string) { await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); await page.screenshot({ path: info.outputPath(`${name}.png`), animations: 'disabled' }); await info.attach(name, { path: info.outputPath(`${name}.png`), 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 choose(page: Page, label: string, option: string) { const control = page.getByRole('combobox', { name: label, exact: true }); await control.focus(); await page.keyboard.press('Space'); await page.getByRole('option', { name: option, exact: true }).focus(); await page.keyboard.press('Enter'); await expect(page.getByRole('listbox')).toHaveCount(0); await expect(control).toBeFocused(); } async function measure(page: Page, browser: Browser, info: TestInfo, cvUrl: string, language: 'en' | 'nb', fixture: string) { const t = translations[language]; const iframe = page.getByTitle(t.cvEditorPreviewTitle, { exact: true }); await expect(page.getByRole('region', { name: t.cvEditorLivePreview, exact: true })).toHaveAttribute('aria-busy', 'false'); await expect.poll(() => iframe.evaluate(element => (element as HTMLIFrameElement).contentDocument?.documentElement.dataset.cvPreviewReady)).toBe('true'); const measured = await iframe.evaluate(async element => { const frame = element as HTMLIFrameElement; const doc = frame.contentDocument!; const win = frame.contentWindow!; await doc.fonts.ready; await new Promise(resolve => win.requestAnimationFrame(() => win.requestAnimationFrame(() => resolve()))); const width = Number(doc.documentElement.dataset.cvPreviewWidth); const height = Number(doc.documentElement.dataset.cvPreviewHeight); const tokens = []; const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT); let node; while ((node = walker.nextNode())) { if (['SCRIPT', 'STYLE'].includes(node.parentElement?.tagName || '')) continue; for (const match of (node.textContent || '').matchAll(/\b[SEBPR]\d{2}\b/g)) { const range = doc.createRange(); range.setStart(node, match.index!); range.setEnd(node, match.index! + match[0].length); const rect = range.getClientRects()[0]; const style = win.getComputedStyle(node.parentElement!); const index = Math.floor(rect.x / width); tokens.push({ token: match[0], x: rect.x - index * width, y: rect.y + index * height, page: index + 1, fontSize: style.fontSize, fontFamily: style.fontFamily, color: style.color }); } } return { width, height, contentHeight: height * Number(doc.documentElement.dataset.cvPreviewPages), effectiveMargin: win.getComputedStyle(doc.documentElement).getPropertyValue('--cv-page-margin'), layoutDurationMs: Number(doc.documentElement.dataset.cvPreviewDurationMs), text: doc.body.innerText, tokens }; }); const variant = await (await page.request.get(cvUrl)).json(); const directory = path.join(output, fixture); await fs.mkdir(directory, { recursive: true }); const response = page.waitForResponse(r => r.url() === `${cvUrl}/export-pdf` && r.request().method() === 'POST'); const downloading = page.waitForEvent('download'); await page.getByRole('button', { name: t.cvEditorDownloadPdf, exact: true }).click(); const pdf = await response; expect(pdf.status()).toBe(200); expect((await downloading).suggestedFilename()).toMatch(/\.pdf$/i); await fs.writeFile(path.join(directory, 'export.pdf'), await pdf.body()); const rendered = await (await page.request.get(`${cvUrl}/preview`)).json(); await fs.writeFile(path.join(directory, 'render.html'), rendered.html); const pages = await page.locator('[data-cv-page]').count(); expect(measured.contentHeight / measured.height).toBe(pages); expect(measured.text).not.toContain('HIDDEN-MARKER'); for (let index = 1; index <= pages; index++) { const html = await page.getByTitle(t.cvEditorPreviewPage.replace('{page}', String(index)), { exact: true }).getAttribute('srcdoc'); const capturePage = await browser.newPage({ viewport: { width: Math.ceil(measured.width), height: Math.ceil(measured.height) } }); try { await capturePage.setContent(html!); await expect.poll(() => capturePage.evaluate(() => document.documentElement.dataset.cvPreviewReady)).toBe('true'); await capturePage.screenshot({ path: path.join(directory, `preview-${index}.png`) }); } finally { await capturePage.close(); } } await info.attach(fixture, { body: JSON.stringify({ fixture, previewPages: pages, settings: variant.settings, measured }), contentType: 'application/json' }); return { fixture, previewPages: pages, settings: variant.settings, measured }; } for (const [mode, language, premium] of [['light', 'en', false], ['dark', 'nb', true], ['light', 'nb', false], ['dark', 'en', true]] as const) { test(`[PRES-C06 C08 C09 C10 C12 E08 E09] ${mode}/${language} document controls preserve settings, source, history and native output`, async ({ page, context, playwright, browser }, info) => { test.setTimeout(240_000); const t = translations[language]; const admin = await playwright.request.newContext(); const adminHeaders = await login(admin, 'e2e@example.test'); const email = `p512-${Date.now()}-${mode}-${language}@example.test`; expect((await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email, password, roles: premium ? ['Premium'] : [] } })).status()).toBe(200); await admin.dispose(); 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 }); expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers, data: { language: language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204); const sentence = language === 'nb' ? 'Dokumenterte pålitelige arbeidsflyter og tilgjengelig navigasjon for norske arbeidssøkere. ' : 'Documented reliable workflows and accessible navigation for people managing their careers. '; const profile = { version: '1', contact: { fullName: 'Åse Fixture', headline: 'Verified career information', location: 'Oslo, Norge' }, summary: ['R01 Verified source content — æøå.'], jobs: Array.from({ length: 5 }, (_, index) => ({ id: `job-${index}`, title: `E${String(index + 1).padStart(2, '0')} Plattformutvikler og dokumenterte integrasjoner`, company: 'Nordlys Fixture AS', startDate: '2020-01', endDate: '2025-01', bullets: Array.from({ length: 3 }, (_, bullet) => `B${String(index * 3 + bullet + 1).padStart(2, '0')} ${sentence.repeat(5)}`) })), education: [{ id: 'edu-1', qualification: 'E08 Computer science', institution: 'Fixture University', startDate: '2016-08', endDate: '2020-06' }], skills: ['React', 'C#', 'Norsk'], interests: ['HIDDEN-MARKER Not included'] }; expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { profile } })).status()).toBe(200); const source = await (await page.request.get(`${apiUrl}/career/profile`)).json(); const created = await page.request.post(`${apiUrl}/cv/variants`, { headers, data: { name: 'Dokumenterte kvalifikasjoner — innstillinger for denne CV-en', settings: { themeId: 'minimal', pageMarginMm: 16, baseFontSizePt: 10, bodyFont: 'Arial, Helvetica, sans-serif', accentColor: '#126b55', language: language === 'nb' ? 'nb-NO' : 'en', sections: [{ key: 'summary' }, { key: 'experience' }, { key: 'education' }, { key: 'skills' }, { key: 'interests', hidden: true }], overrides: {}, customSections: [], } } }); expect(created.status()).toBe(200); const cv = await created.json(); const cvUrl = `${apiUrl}/cv/variants/${cv.id}`; const sibling = await (await page.request.post(`${apiUrl}/cv/variants`, { headers, data: { name: 'Untouched sibling', settings: cv.settings } })).json(); const read = async () => (await page.request.get(cvUrl)).json(); const saved = async () => { await expect(page.getByText(t.cvEditorSaved, { exact: true })).toBeVisible(); }; await page.setViewportSize({ width: 1440, height: 900 }); await page.goto(`/career/builder/${cv.id}`); await expect(page.getByLabel(t.cvEditorName, { exact: true })).toHaveValue(cv.name); const before = await measure(page, browser, info, cvUrl, language, `${mode}-${language}-before`); await page.getByRole('tab', { name: t.cvEditorCustomize, exact: true }).click(); const advanced = page.getByRole('button', { name: t.cvEditorAdvancedSettings, exact: true }); await expect(advanced).toHaveAttribute('aria-expanded', 'false'); await expect(page.getByRole('region', { name: t.cvEditorStyle, exact: true })).toBeVisible(); await expect(page.getByRole('combobox', { name: t.cvEditorHeadingFont, exact: true })).toHaveText(t.cvEditorTemplateDefault); await capture(page, info, 'default-controls'); await scan(page, info, 'default-controls'); await advanced.focus(); await page.keyboard.press('Enter'); await expect(advanced).toHaveAttribute('aria-expanded', 'true'); expect((await read()).version).toBe(cv.version); const margin = page.getByRole('spinbutton', { name: t.cvEditorPageMargins, exact: true }); await margin.fill('29'); await expect(margin).toHaveAttribute('aria-invalid', 'true'); expect((await read()).settings.pageMarginMm).toBe(16); await capture(page, info, 'invalid-margin'); await margin.fill('22'); await saved(); await expect.poll(async () => (await read()).settings.pageMarginMm).toBe(22); await choose(page, t.cvEditorPageSize, t.cvEditorUsLetter); await choose(page, t.cvEditorBodyFont, 'Georgia (serif)'); await page.getByRole('spinbutton', { name: t.cvEditorBodySize, exact: true }).fill('11'); await page.getByRole('spinbutton', { name: t.cvEditorSectionSpacing, exact: true }).fill('8'); await page.getByRole('spinbutton', { name: t.cvEditorEntrySpacing, exact: true }).fill('5'); await page.getByRole('spinbutton', { name: t.cvEditorLineHeight, exact: true }).fill('1.5'); const colour = page.getByLabel(t.cvEditorCustomAccent, { exact: true }); await colour.fill('#9f1239'); await colour.focus(); await page.keyboard.press('Tab'); await choose(page, t.cvEditorDateFormat, '2020'); await saved(); await expect.poll(async () => (await read()).settings).toMatchObject({ pageSize: 'letter', pageMarginMm: 22, bodyFont: "Georgia, 'Times New Roman', serif", baseFontSizePt: 11, sectionGapMm: 8, entryGapMm: 5, lineHeight: 1.5, accentColor: '#9f1239', dateFormat: 'year' }); const after = await measure(page, browser, info, cvUrl, language, `${mode}-${language}-after`); expect(before.measured.width * 25.4 / 96).toBeCloseTo(210, 2); expect(after.measured.width * 25.4 / 96).toBeCloseTo(215.9, 2); expect(after.measured.height * 25.4 / 96).toBeCloseTo(279.4, 2); expect(after.measured.effectiveMargin.trim()).toBe('22mm'); expect(after.previewPages).toBeGreaterThan(1); expect(after.measured.tokens.find(token => token.token === 'R01')!.fontFamily).toContain('Georgia'); expect(after.measured.tokens.find(token => token.token === 'R01')!.fontSize).not.toBe(before.measured.tokens.find(token => token.token === 'R01')!.fontSize); await fs.mkdir(output, { recursive: true }); await fs.writeFile(path.join(output, `${mode}-${language}-measurements.json`), JSON.stringify({ browserVersion: browser.version(), fixtures: [before, after] }, null, 2)); const changed = await read(); await page.reload(); await page.getByRole('tab', { name: t.cvEditorCustomize, exact: true }).click(); await expect(margin).toHaveValue('22'); // Reload starts the asynchronous preview layout again. Use its existing settled // state before pointer activation, so layout movement cannot select another field. await expect(page.getByRole('region', { name: t.cvEditorLivePreview, exact: true })).toHaveAttribute('aria-busy', 'false'); await expect.poll(() => page.getByTitle(t.cvEditorPreviewTitle, { exact: true }).evaluate(element => (element as HTMLIFrameElement).contentDocument?.documentElement.dataset.cvPreviewReady)).toBe('true'); await page.getByRole('button', { name: t.cvEditorUseTemplateSetting.replace('{setting}', t.cvEditorPageMargins), exact: true }).click(); await saved(); await expect.poll(async () => (await read()).settings.pageMarginMm).toBeNull(); await page.getByRole('button', { name: t.cvEditorUndo, exact: true }).click(); await saved(); await expect(margin).toHaveValue('22'); await expect.poll(async () => (await read()).settings.pageMarginMm).toBe(22); await advanced.click(); for (const width of [1440, 768, 320]) { await page.setViewportSize({ width, height: 900 }); await margin.focus(); await expect(margin).toBeFocused(); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true); await capture(page, info, `controls-${width}`); await advanced.scrollIntoViewIfNeeded(); await expect(advanced).toBeVisible(); await capture(page, info, `advanced-${width}`); } await scan(page, info, 'phone-advanced'); await page.setViewportSize({ width: 1440, height: 900 }); await page.getByRole('tab', { name: t.cvEditorTemplate, exact: true }).click(); await page.getByRole('button', { name: t.cvEditorAllTemplates, exact: true }).click(); const technical = page.getByRole('button', { name: 'Technical', exact: true }); await expect(technical).toHaveAttribute('aria-disabled', premium ? 'false' : 'true'); await expect(technical).toHaveAccessibleDescription(new RegExp(premium ? t.cvEditorProUnlocked : t.cvEditorPro)); if (!premium) { await technical.click({ force: true }); await technical.focus(); await page.keyboard.press('Enter'); expect((await read()).settings.themeId).toBe('minimal'); await expect(page.getByRole('dialog')).toHaveCount(0); } await capture(page, info, 'templates'); await scan(page, info, 'templates'); const target = premium ? technical : page.getByRole('button', { name: 'Code', exact: true }); await target.focus(); await page.keyboard.press('Enter'); const dialog = page.getByRole('dialog', { name: t.cvEditorChangeTemplateTitle }); await expect(dialog).toBeVisible(); await capture(page, info, 'reset-confirmation'); await page.keyboard.press('Escape'); await expect(target).toBeFocused(); expect((await read()).settings.themeId).toBe('minimal'); await page.keyboard.press('Space'); await dialog.getByRole('button', { name: t.cvEditorApplyTemplate, exact: true }).click(); await saved(); await expect.poll(async () => (await read()).settings.themeId).toBe(premium ? 'technical' : 'code'); const selected = await read(); expect(selected.settings.themeId).toBe(premium ? 'technical' : 'code'); for (const key of ['accentColor', 'bodyFont', 'baseFontSizePt', 'pageMarginMm', 'entryGapMm', 'sectionGapMm', 'lineHeight']) expect(selected.settings[key]).toBeNull(); expect(selected.settings.sections).toEqual(changed.settings.sections); expect(selected.settings.pageSize).toBe('letter'); await page.getByRole('tab', { name: t.cvEditorCustomize, exact: true }).click(); await advanced.click(); if (premium) { await expect(page.getByRole('spinbutton', { name: t.cvEditorSidebarWidth, exact: true })).toBeVisible(); await page.getByRole('spinbutton', { name: t.cvEditorSidebarWidth, exact: true }).fill('68'); await saved(); await expect.poll(async () => (await read()).settings.sidebarWidthMm).toBe(68); } await capture(page, info, 'selected-template-defaults'); expect(await (await page.request.get(`${apiUrl}/career/profile`)).json()).toEqual(source); expect(await (await page.request.get(`${apiUrl}/cv/variants/${sibling.id}`)).json()).toEqual(sibling); const beforeRestore = await read(); await page.getByRole('tab', { name: t.cvEditorHistory, exact: true }).click(); await page.getByRole('tabpanel', { name: t.cvEditorHistory, exact: true }).getByRole('button', { name: t.cvEditorRestoreTitle.replace('{version}', '1'), exact: true }).click(); await page.getByRole('dialog').getByRole('button', { name: t.cvEditorRestoreVersion, exact: true }).click(); await saved(); await expect.poll(async () => (await read()).version).toBe(beforeRestore.version + 1); expect((await read()).settings).toEqual(cv.settings); expect(await (await page.request.get(`${apiUrl}/career/profile`)).json()).toEqual(source); }); }