import { expect, test, type APIRequestContext } from '@playwright/test'; import AxeBuilder from '@axe-core/playwright'; import fs from 'node:fs/promises'; import path from 'node:path'; import { createHash } from 'node:crypto'; import { previewDocumentHtml } from '../src/cvPreview'; import { translations } from '../src/i18n/translations'; const apiUrl = 'http://localhost:5302/api'; const output = path.resolve(process.env.LETTER_FIDELITY_OUTPUT || '../.artifacts/cv-ux-01/letter-fidelity'); 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 }; } test('cover letter native preview/PDF fidelity records four independent physical documents', async ({ page, context, browser, playwright }, info) => { test.setTimeout(360_000); // Existing attempts remain reviewable, including a partially produced failed run. await fs.mkdir(output, { recursive: true }); await fs.writeFile(path.join(output, 'started.json'), JSON.stringify({ startedUtc: new Date().toISOString(), browserVersion: browser.version() }), { flag: 'wx' }); 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(() => { const query = new URLSearchParams(location.search); if (query.has('fidelityMode')) localStorage.setItem('jobtracker.themeMode', query.get('fidelityMode')!); if (query.has('fidelityLanguage')) localStorage.setItem('uiLanguage', query.get('fidelityLanguage')!); }); const admin = await playwright.request.newContext(); const adminHeaders = await login(admin, 'e2e@example.test'); const email = `letter-fidelity-${Date.now()}@example.test`; try { expect((await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email, password, roles: [] } })).status()).toBe(200); expect((await admin.post(`${apiUrl}/jobapplications/2147483647/ai/history/2147483647/validate-source`, { headers: adminHeaders })).status()).toBe(404); } finally { await admin.dispose(); } const headers = await login(page.request, email); const sourceBefore = await (await page.request.get(`${apiUrl}/career/profile`)).json(); const companyResponse = await page.request.post(`${apiUrl}/companies`, { headers, data: { name: 'Nordlys Synthetic AS', source: 'User' } }); expect(companyResponse.status()).toBe(201); const company = await companyResponse.json(); const fixtures = []; for (const [mode, language, pageSize] of [['light', 'en', 'a4'], ['dark', 'nb', 'a4'], ['light', 'nb', 'letter'], ['dark', 'en', 'letter']] as const) { const fixture = `${mode}-${language}-${pageSize}`; const directory = path.join(output, fixture); await fs.mkdir(directory); const t = translations[language]; const sentence = language === 'nb' ? 'Dokumenterte pålitelige arbeidsflyter og tilgjengelig navigasjon for norske arbeidssøkere. ' : 'Documented reliable workflows and accessible navigation using reviewed source evidence. '; const text = Array.from({ length: 24 }, (_, index) => `B${String(index + 1).padStart(2, '0')} ${sentence.repeat(4)}`) .concat(['- S01 **Reviewed achievement** — æøå.', '- S02 *Verified collaboration* — ÆØÅ.', 'R99 Final reviewed letter content — Åse Østby.']).join('\n\n'); const documentSettings = { schemaVersion: 1, language: language === 'nb' ? 'nb-NO' : 'en', senderName: 'R01 Åse Østby', senderTitle: 'P01 Platform engineer', senderEmail: 'ase@example.test', senderPhone: '+47 12345678', senderLocation: 'Oslo, Norge', recipient: 'R03 Hiring team\nNordlys Synthetic AS\nOslo, Norge', subject: language === 'nb' ? 'R02 Søknad om plattformutvikler' : 'R02 Application for platform engineer', date: '2026-10-25', themeId: 'minimal', accentColor: '#126b55', bodyFont: 'Arial, Helvetica, sans-serif', baseFontSizePt: 11, lineHeight: 1.4, pageMarginMm: 20, pageSize }; const created = await page.request.post(`${apiUrl}/jobapplications`, { headers, data: { companyId: company.id, jobTitle: fixture, status: 'Saved', description: 'Synthetic letter fidelity fixture.' } }); expect(created.status()).toBe(201); const job = await created.json(); const letterUrl = `${apiUrl}/jobapplications/${job.id}/cover-letter`; const original = await (await page.request.get(letterUrl)).json(); const save = await page.request.put(letterUrl, { headers, data: { text, documentSettings, source: 'manual', expectedVersion: original.revision } }); expect(save.status()).toBe(200); const acknowledgement = await save.json(); // MariaDB stores datetime(6); compare subsequent reads with durable history timestamps. const saved = await (await page.request.get(letterUrl)).json(); expect(saved.revision).toBe(acknowledgement.revision); expect(saved.text).toBe(text); const preview = await page.request.post(`${letterUrl}/preview`, { headers, data: { text: saved.text, documentSettings: saved.documentSettings } }); expect(preview.status()).toBe(200); const rendered = await preview.json(); expect(rendered.html).toContain(``); expect(rendered.html).toContain('class="page letter-paper"'); const pdf = await page.request.post(`${letterUrl}/export-pdf?expectedVersion=${saved.revision}`, { headers }); expect(pdf.status()).toBe(200); expect(pdf.headers()['content-type']).toContain('application/pdf'); const bytes = await pdf.body(); expect(bytes.subarray(0, 5).toString()).toBe('%PDF-'); await fs.writeFile(path.join(directory, 'export.pdf'), bytes); await fs.writeFile(path.join(directory, 'render.html'), rendered.html); expect(await (await page.request.get(letterUrl)).json()).toEqual(saved); expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers, data: { language: language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204); await page.setViewportSize({ width: 1440, height: 1000 }); await page.goto(`/jobs/${job.id}?section=application&sub=letter&fidelityMode=${mode}&fidelityLanguage=${language}#materials`); await expect(page.locator('html')).toHaveAttribute('data-color-scheme', mode); const frame = page.getByTitle(t.letterDocumentPreview, { exact: true }); const previewWorkspace = frame.locator('..').locator('..').locator('..'); await expect(frame).toBeVisible(); await expect.poll(() => frame.evaluate(element => (element as HTMLIFrameElement).contentDocument?.documentElement.dataset.cvPreviewReady)).toBe('true'); expect(await frame.evaluate(element => (element as HTMLIFrameElement).contentDocument?.documentElement.lang)).toBe(documentSettings.language); const measured = await frame.evaluate(async element => { const doc = (element as HTMLIFrameElement).contentDocument!; const win = (element as HTMLIFrameElement).contentWindow!; await doc.fonts.ready; const geometry = doc.documentElement.dataset; const width = Number(geometry.cvPreviewWidth); const height = Number(geometry.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(geometry.cvPreviewPages), pages: Number(geometry.cvPreviewPages), effectiveMargin: win.getComputedStyle(doc.documentElement).getPropertyValue('--cv-page-margin'), text: doc.body.innerText, bold: doc.querySelectorAll('strong').length, italic: doc.querySelectorAll('em').length, tokens }; }); expect(measured.pages).toBeGreaterThan(1); expect(measured.pages).toBeLessThan(15); expect(measured.tokens.map(token => token.token).sort()).toEqual(['R01', 'R02', 'R03', 'R99', 'P01', 'S01', 'S02', ...Array.from({ length: 24 }, (_, index) => `B${String(index + 1).padStart(2, '0')}`)].sort()); expect(measured.text).toContain('ase@example.test'); expect(measured.text).toContain('Nordlys Synthetic AS'); expect(measured.bold).toBeGreaterThan(0); expect(measured.italic).toBeGreaterThan(0); expect(measured.tokens.find(token => token.token === 'R99')!.page).toBe(measured.pages); for (let index = 0; index < measured.pages; index++) { if (index > 0) { await previewWorkspace.getByRole('button', { name: t.cvEditorNext, exact: true }).click(); await expect(previewWorkspace.getByText(t.letterPage.replace('{page}', String(index + 1)).replace('{count}', String(measured.pages)), { exact: true })).toBeVisible(); await expect.poll(() => frame.evaluate(element => (element as HTMLIFrameElement).contentDocument?.documentElement.dataset.cvPreviewReady)).toBe('true'); } const capture = await browser.newPage({ viewport: { width: Math.ceil(measured.width), height: Math.ceil(measured.height) } }); try { await capture.setContent(previewDocumentHtml(rendered.html, measured.height, index)); await expect.poll(() => capture.evaluate(() => document.documentElement.dataset.cvPreviewReady)).toBe('true'); await capture.screenshot({ path: path.join(directory, `preview-${index + 1}.png`) }); } finally { await capture.close(); } } const reachable = await frame.evaluate(element => { const doc = (element as HTMLIFrameElement).contentDocument!; const node = Array.from(doc.querySelectorAll('p')).find(item => item.textContent?.includes('R99'))!; const rect = node.getBoundingClientRect(); return rect.right > 0 && rect.left < Number(doc.documentElement.dataset.cvPreviewWidth) && rect.top >= 0 && rect.bottom <= Number(doc.documentElement.dataset.cvPreviewHeight); }); expect(reachable).toBe(true); const record = { fixture, previewPages: measured.pages, settings: saved.documentSettings, measured, savedRevision: saved.revision, pdfSha256: createHash('sha256').update(bytes).digest('hex'), htmlSha256: createHash('sha256').update(rendered.html).digest('hex'), browserVersion: browser.version(), language, mode }; await fs.writeFile(path.join(directory, 'measurements.json'), JSON.stringify(record, null, 2)); await page.screenshot({ path: path.join(directory, 'last-page-workspace.png') }); await info.attach(fixture, { body: JSON.stringify(record), contentType: 'application/json' }); fixtures.push(record); } expect(await (await page.request.get(`${apiUrl}/career/profile`)).json()).toEqual(sourceBefore); await fs.writeFile(path.join(output, 'measurements.json'), JSON.stringify({ browserVersion: browser.version(), fixtures }, null, 2), { flag: 'wx' }); }); test('cover letter workspace preserves keyboard drafts and localised controls across responsive widths', async ({ context, playwright }, info) => { test.setTimeout(300_000); 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(() => { const query = new URLSearchParams(location.search); if (query.has('responsiveMode')) localStorage.setItem('jobtracker.themeMode', query.get('responsiveMode')!); if (query.has('responsiveLanguage')) localStorage.setItem('uiLanguage', query.get('responsiveLanguage')!); }); const admin = await playwright.request.newContext(); const adminHeaders = await login(admin, 'e2e@example.test'); const email = `letter-responsive-${Date.now()}@example.test`; try { // Free ownership keeps the four AI actions visible but prevents any provider invocation. expect((await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email, password, roles: [] } })).status()).toBe(200); } finally { await admin.dispose(); } const headers = await login(context.request, email); const companyResponse = await context.request.post(`${apiUrl}/companies`, { headers, data: { name: 'Responsive Synthetic AS', source: 'User' } }); expect(companyResponse.status()).toBe(201); const company = await companyResponse.json(); const created = await context.request.post(`${apiUrl}/jobapplications`, { headers, data: { companyId: company.id, jobTitle: 'Responsive reviewed letter', status: 'Saved' } }); expect(created.status()).toBe(201); const job = await created.json(); expect((await context.request.post(`${apiUrl}/jobapplications/${job.id}/ai/history/2147483647/validate-source`, { headers })).status()).toBe(403); const letterUrl = `${apiUrl}/jobapplications/${job.id}/cover-letter`; const original = await (await context.request.get(letterUrl)).json(); const savedResponse = await context.request.put(letterUrl, { headers, data: { text: 'Original reviewed letter — æøå.', source: 'manual', expectedVersion: original.revision } }); expect(savedResponse.status()).toBe(200); const saved = await (await context.request.get(letterUrl)).json(); const sourceBefore = await (await context.request.get(`${apiUrl}/career/profile`)).json(); const scenarios = [ [320, 'en', 'light'], [390, 'nb', 'dark'], [768, 'en', 'dark'], [899, 'nb', 'light'], [900, 'en', 'light'], [1024, 'nb', 'dark'], [1440, 'nb', 'light'], ] as const; for (const [width, language, mode] of scenarios) { const t = translations[language]; expect((await context.request.put(`${apiUrl}/auth/preferences/language`, { headers, data: { language: language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204); const page = await context.newPage(); await page.setViewportSize({ width, height: 900 }); const forbiddenRequests: string[] = []; page.on('request', request => { const pathname = new URL(request.url()).pathname; if (request.method() === 'POST' && (pathname.endsWith('/ai/generate') || pathname.endsWith('/cover-letter/export-pdf')) || request.method() === 'PUT' && pathname === new URL(letterUrl).pathname) forbiddenRequests.push(`${request.method()} ${pathname}`); }); try { await page.goto(`/jobs/${job.id}?section=application&sub=letter&responsiveMode=${mode}&responsiveLanguage=${language}#materials`); await expect(page.locator('html')).toHaveAttribute('lang', language === 'nb' ? 'nb-NO' : 'en'); await expect(page.locator('html')).toHaveAttribute('data-color-scheme', mode); const workspace = page.getByRole('group', { name: t.workspaceCoverLetter, exact: true }); const body = workspace.getByRole('textbox', { name: t.jobDetailsCoverLetter, exact: true }); await expect(body).toHaveValue(saved.text); const documentLanguage = workspace.getByRole('combobox', { name: t.letterDocumentLanguage }); await expect(documentLanguage).toBeVisible(); await documentLanguage.click(); await page.getByRole('option', { name: language === 'nb' ? t.languageNorwegianBokmal : t.languageEnglish, exact: true }).click(); const draft = `${width} ${language === 'nb' ? 'Ulagret søknadsbrev med gjennomgåtte fakta' : 'Unsaved letter with reviewed facts'} — æøå.`; await body.focus(); await body.press('ControlOrMeta+A'); await body.pressSequentially(draft); await expect(body).toHaveValue(draft); const controls = [t.cvAiImprove, t.cvAiSuggestContent, t.cvAiGrammarCheck, t.cvAiShorter]; const bodyRect = await body.boundingBox(); expect(bodyRect).not.toBeNull(); for (const name of controls) { const control = workspace.getByRole('button', { name, exact: true }); await expect(control).toBeVisible(); await expect(control).toBeDisabled(); const rect = await control.boundingBox(); expect(rect).not.toBeNull(); expect(rect!.y - (bodyRect!.y + bodyRect!.height)).toBeGreaterThanOrEqual(-2); expect(rect!.y - (bodyRect!.y + bodyRect!.height)).toBeLessThan(240); expect(rect!.x).toBeGreaterThanOrEqual(0); expect(rect!.x + rect!.width).toBeLessThanOrEqual(width + 1); } const geometry = await page.evaluate(() => { const main = document.querySelector('main')!; return { root: document.documentElement.scrollWidth - innerWidth, app: main.scrollWidth - main.clientWidth }; }); expect(geometry.root).toBeLessThanOrEqual(1); expect(geometry.app).toBeLessThanOrEqual(1); const writePane = workspace.locator('[id$="-write-panel"]'); const previewPane = workspace.locator('[id$="-preview-panel"]'); const audit = async (state: string) => { await workspace.evaluate(node => node.setAttribute('data-cv-ux-axe', 'letter')); const result = await new AxeBuilder({ page }).include('[data-cv-ux-axe="letter"]').withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze(); const reportPath = info.outputPath(`letter-${width}-${state}-axe.json`); await fs.writeFile(reportPath, JSON.stringify({ violations: result.violations, incomplete: result.incomplete }, null, 2)); await info.attach(`letter-${width}-${state}-axe`, { path: reportPath, contentType: 'application/json' }); expect(result.violations).toEqual([]); }; if (width === 320 || width === 390 || width === 1440) await audit('write'); if (width < 1024) { const tabs = workspace.getByRole('tablist', { name: t.assetsLetterWorkspace, exact: true }); const write = tabs.getByRole('tab', { name: t.assetsLetterWrite, exact: true }); const preview = tabs.getByRole('tab', { name: t.assetsLetterPreview, exact: true }); await expect(previewPane).not.toBeVisible(); await write.focus(); await page.keyboard.press('ArrowRight'); await expect(preview).toBeFocused(); await page.keyboard.press('Enter'); await expect(preview).toHaveAttribute('aria-selected', 'true'); await expect(writePane).not.toBeVisible(); await expect(previewPane).toBeVisible(); if (width === 320 || width === 390) await audit('preview'); await expect(workspace.getByRole('textbox', { name: t.jobDetailsCoverLetter, exact: true })).toHaveCount(0); for (let step = 0; step < 8; step++) { await page.keyboard.press('Tab'); expect(await writePane.evaluate(node => node.contains(document.activeElement))).toBe(false); } await preview.focus(); await page.keyboard.press('ArrowLeft'); await expect(write).toBeFocused(); await page.keyboard.press('Enter'); await expect(write).toHaveAttribute('aria-selected', 'true'); await expect(previewPane).not.toBeVisible(); await expect(body).toHaveValue(draft); await page.keyboard.press('Tab'); expect(await previewPane.evaluate(node => node.contains(document.activeElement))).toBe(false); } else { await expect(workspace.getByRole('tablist', { name: t.assetsLetterWorkspace, exact: true })).toHaveCount(0); await expect(writePane).toBeVisible(); await expect(previewPane).toBeVisible(); } for (const name of [t.save, t.letterExportPdf]) { const control = workspace.getByRole('button', { name, exact: true }); await expect(control).toBeEnabled(); await control.scrollIntoViewIfNeeded(); await expect(control).toBeInViewport(); await control.focus(); await expect(control).toBeFocused(); const rect = await control.boundingBox(); expect(rect).not.toBeNull(); expect(rect!.x).toBeGreaterThanOrEqual(0); expect(rect!.x + rect!.width).toBeLessThanOrEqual(width + 1); expect(await control.evaluate(node => { const rect = node.getBoundingClientRect(); return node.contains(document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2)); })).toBe(true); } await expect(body).toHaveValue(draft); expect(forbiddenRequests).toEqual([]); expect(await (await context.request.get(letterUrl)).json()).toEqual(saved); await page.screenshot({ path: info.outputPath(`letter-responsive-${width}-${language}-${mode}.png`), fullPage: true, animations: 'disabled' }); await info.attach(`letter-responsive-${width}`, { body: JSON.stringify({ width, language, mode, geometry, draft, unchangedRevision: saved.revision, forbiddenRequests }), contentType: 'application/json' }); } finally { await page.close(); } } expect(await (await context.request.get(`${apiUrl}/career/profile`)).json()).toEqual(sourceBefore); });