import { expect, test } from '@playwright/test'; import fs from 'node:fs/promises'; import path from 'node:path'; import { createHash } from 'node:crypto'; import { previewDocumentHtml } from '../src/cvPreview'; const api = 'http://localhost:5302/api'; const output = path.resolve(process.env.LETTER_LIBRARY_OUTPUT || '../.artifacts/pre-phase10-ux/matching-letters-01'); const sha256 = (value: string | Buffer) => createHash('sha256').update(value).digest('hex'); test('[PRE-P10 B] nine matching letter designs retain short EN and long NB physical documents', async ({ page, context, browser }, info) => { test.setTimeout(600_000); expect(process.env.PLAYWRIGHT_REUSE_SERVERS).not.toBe('true'); 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.post(`${api}/auth/login`, { data: { email: 'e2e@example.test', password: 'E2ePassword123!' } })).status()).toBe(200); const me = await (await page.request.get(`${api}/auth/me`)).json(); const headers = { 'X-CSRF-TOKEN': (await page.request.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value, 'X-Session-Context': me.sessionContext as string }; const sourceBefore = await (await page.request.get(`${api}/career/profile`)).json(); const themes = await (await page.request.get(`${api}/cv/themes`)).json(); expect(themes).toHaveLength(9); const companyResponse = await page.request.post(`${api}/companies`, { headers, data: { name: 'Fictional Fjord Systems AS', source: 'User' } }); expect(companyResponse.status()).toBe(201); const company = await companyResponse.json(); await fs.mkdir(output, { recursive: true }); const fixtures = []; for (const language of ['en', 'nb'] as const) { const nb = language === 'nb'; const paragraphs = Array.from({ length: nb ? 20 : 3 }, (_, index) => { const sentence = nb ? 'Jeg dokumenterte pålitelige arbeidsflyter, tilgjengelige grensesnitt og tydelig ansvar i et oppdiktet prosjekt. ' : 'I documented reliable workflows, accessible interfaces and clear ownership in a fictional project. '; return `B${String(index + 1).padStart(2, '0')} ${sentence.repeat(nb ? 4 : 1)}`.trim(); }); paragraphs.push(nb ? 'R99 Med vennlig hilsen, Åse Ødegård-Sørensen. Dette er kun syntetiske eksempler.' : 'R99 Kind regards, Åse Ødegård-Sørensen. These are synthetic examples only.'); const text = paragraphs.join('\n\n'); for (const theme of themes) { const fixture = `${nb ? 'long-nb' : 'short-en'}-${theme.id}`; const directory = path.join(output, fixture); await fs.mkdir(directory, { recursive: true }); const documentSettings = { schemaVersion: 1, designVersion: 2, language: nb ? 'nb-NO' : 'en', themeId: theme.id, senderName: 'R01 Åse Ødegård-Sørensen', senderTitle: nb ? 'P01 Plattformingeniør' : 'P01 Platform engineer', senderEmail: 'fictional.ase@example.test', senderLocation: 'Tromsø, Norge', recipient: 'R03 Fictional hiring team\nFictional Fjord Systems AS\nTromsø, Norge', subject: nb ? 'R02 Søknad om plattformingeniør' : 'R02 Application for platform engineer', date: '2026-10-27', pageSize: 'a4' }; const created = await page.request.post(`${api}/jobapplications`, { headers, data: { companyId: company.id, jobTitle: fixture, status: 'Saved', description: 'Synthetic matching-letter physical proof.' } }); expect(created.status(), fixture).toBe(201); const job = await created.json(); const letterUrl = `${api}/jobapplications/${job.id}/cover-letter`; const original = await (await page.request.get(letterUrl)).json(); const savedResponse = await page.request.put(letterUrl, { headers, data: { text, documentSettings, source: 'manual', expectedVersion: original.revision } }); expect(savedResponse.status(), fixture).toBe(200); const saved = await (await page.request.get(letterUrl)).json(); expect(saved.text).toBe(text); expect(saved.documentSettings).toMatchObject({ designVersion: 2, themeId: theme.id, accentColor: theme.accent, bodyFont: theme.bodyFont, headingFont: theme.headingFont }); const previewResponse = await page.request.post(`${letterUrl}/preview`, { headers, data: { text: saved.text, documentSettings: saved.documentSettings } }); expect(previewResponse.status(), fixture).toBe(200); const rendered = await previewResponse.json(); expect(rendered.html).toContain(`letter-theme-${theme.id}`); expect(rendered.html).toContain('data:font/ttf;base64,'); expect(rendered.html).toContain(theme.accent); await fs.writeFile(path.join(directory, 'render.html'), rendered.html); const expectedMarkers = [documentSettings.senderName, documentSettings.senderTitle, documentSettings.recipient, documentSettings.subject, text].join('\n').match(/\b[SEBPR]\d{2}\b/g)!; await page.setViewportSize({ width: 794, height: 1123 }); await page.setContent(previewDocumentHtml(rendered.html, 297 * 96 / 25.4)); await expect.poll(() => page.evaluate(() => document.documentElement.dataset.cvPreviewReady), { message: fixture }).toBe('true'); const measured = await page.evaluate(() => { const data = document.documentElement.dataset; const width = Number(data.cvPreviewWidth), height = Number(data.cvPreviewHeight); const tokens = []; const walker = document.createTreeWalker(document.querySelector('.page')!, 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 = document.createRange(); range.setStart(node, match.index!); range.setEnd(node, match.index! + match[0].length); const rect = range.getClientRects()[0], style = 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, color: style.color }); } } const header = getComputedStyle(document.querySelector('header')!); const heading = getComputedStyle(document.querySelector('h1')!); const contact = getComputedStyle(document.querySelector('.contact')!); return { width, height, contentHeight: Number(data.cvPreviewPages) * height, pages: Number(data.cvPreviewPages), effectiveMargin: getComputedStyle(document.documentElement).getPropertyValue('--cv-page-margin'), layoutDurationMs: Number(data.cvPreviewDurationMs), tokens, text: document.querySelector('.page')!.textContent, bodyFont: getComputedStyle(document.body).fontFamily, headingFont: heading.fontFamily, headingColor: heading.color, headingSize: heading.fontSize, headerBackground: header.backgroundColor, headerAlign: header.textAlign, headerBorder: header.borderBottomWidth, headerGridColumn: header.gridColumn, headerBefore: getComputedStyle(document.querySelector('header')!, '::before').content, pageDisplay: getComputedStyle(document.querySelector('.page')!).display, contactFont: contact.fontFamily }; }); expect(measured.tokens.map(token => token.token).sort(), `${fixture} complete preview markers`).toEqual([...expectedMarkers].sort()); for (const paragraph of paragraphs) expect(measured.text, `${fixture} full body paragraph`).toContain(paragraph); expect(measured.text).toContain(documentSettings.recipient); if (theme.id === 'modern') expect(measured.headerBackground).not.toBe('rgba(0, 0, 0, 0)'); if (theme.id === 'minimal') expect(measured.headerBorder).toBe('0px'); if (theme.id === 'executive') expect(measured.headerAlign).toBe('center'); if (theme.id === 'elegant') expect(measured.headerBefore).toContain(nb ? 'Søknadsbrev' : 'Cover letter'); if (theme.id === 'code') expect(measured.contactFont).toContain('Roboto Mono'); if (['technical', 'nordic', 'creative'].includes(theme.id)) expect(measured.pageDisplay).toBe('grid'); const exported = await page.request.post(`${letterUrl}/export-pdf?expectedVersion=${saved.revision}`, { headers }); expect(exported.status(), fixture).toBe(200); const bytes = await exported.body(); expect(bytes.subarray(0, 5).toString()).toBe('%PDF-'); await fs.writeFile(path.join(directory, 'export.pdf'), bytes); for (let index = 0; index < measured.pages; index++) { await page.setContent(previewDocumentHtml(rendered.html, measured.height, index)); await expect.poll(() => page.evaluate(() => document.documentElement.dataset.cvPreviewReady)).toBe('true'); await page.screenshot({ path: path.join(directory, `preview-${index + 1}.png`) }); } expect(await (await page.request.get(letterUrl)).json()).toEqual(saved); fixtures.push({ fixture, settings: saved.documentSettings, expectedMarkers, expectedParagraphs: paragraphs, expectedRecipient: documentSettings.recipient, expectedSender: documentSettings.senderName, expectedSubject: documentSettings.subject, previewPages: measured.pages, measured, savedRevision: saved.revision, htmlSha256: sha256(rendered.html), pdfSha256: sha256(bytes) }); await fs.writeFile(path.join(output, 'measurements.json'), JSON.stringify({ browserVersion: browser.version(), fixtures }, null, 2)); } } expect(fixtures).toHaveLength(18); expect(await (await page.request.get(`${api}/career/profile`)).json()).toEqual(sourceBefore); await info.attach('matching-letter-library-measurements', { path: path.join(output, 'measurements.json'), contentType: 'application/json' }); });