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'; import { templateFixtures, templateProfile } from './pre-p10-template-fixtures'; const api = 'http://localhost:5302/api'; const output = path.resolve(process.env.CV_TEMPLATE_OUTPUT || '../.artifacts/pre-phase10-ux/templates-legacy'); const designVersion = process.env.PRE_P10_DESIGN_VERSION ? Number(process.env.PRE_P10_DESIGN_VERSION) : undefined; const hash = (value: string | Buffer) => createHash('sha256').update(value).digest('hex'); test('[PRE-P10 B] equivalent synthetic histories retain preview and native PDF evidence across all nine themes', 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 themes = await (await page.request.get(`${api}/cv/themes`)).json(); expect(themes.map((theme: { id: string }) => theme.id).sort()).toEqual(['ats-classic', 'code', 'creative', 'elegant', 'executive', 'minimal', 'modern', 'nordic', 'technical']); await fs.mkdir(output, { recursive: true }); await page.setContent(''); const image = await page.evaluate(() => { const canvas = document.querySelector('canvas')!; const drawing = canvas.getContext('2d')!; drawing.fillStyle = '#cbd5e1'; drawing.fillRect(0, 0, 512, 512); drawing.fillStyle = '#0f766e'; drawing.fillRect(32, 32, 448, 224); drawing.fillStyle = '#f59e0b'; drawing.fillRect(128, 288, 256, 160); return canvas.toDataURL('image/png').split(',')[1]; }); const avatar = Buffer.from(image, 'base64'); await fs.writeFile(path.join(output, 'synthetic-nonhuman-color-sample.png'), avatar); expect((await page.request.post(`${api}/auth/avatar`, { headers, multipart: { file: { name: 'synthetic-nonhuman.png', mimeType: 'image/png', buffer: avatar } } })).status()).toBe(200); const results: unknown[] = []; for (const sample of templateFixtures) { const { profile, markers } = templateProfile(sample); const current = await (await page.request.get(`${api}/career/profile`)).json(); expect((await page.request.put(`${api}/career/profile`, { headers, data: { expectedVersion: current.revision, profile } })).status()).toBe(200); const source = await (await page.request.get(`${api}/career/profile`)).json(); for (const theme of themes) { const fixture = `${sample.key}-${theme.id}`; const directory = path.join(output, fixture); await fs.mkdir(directory, { recursive: true }); const started = performance.now(); const created = await page.request.post(`${api}/cv/variants`, { headers, data: { name: fixture, settings: { themeId: theme.id, language: sample.language, showPhoto: true, showIcons: true, ...(designVersion === undefined ? {} : { designVersion }), } } }); expect(created.status(), fixture).toBe(200); const variant = await created.json(); const renderedResponse = await page.request.get(`${api}/cv/variants/${variant.id}/preview`); expect(renderedResponse.status(), fixture).toBe(200); const rendered = await renderedResponse.json(); await fs.writeFile(path.join(directory, 'render.html'), rendered.html); 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: { token: string; x: number; y: number; page: number; fontSize: string; color: string }[] = []; const nameLines: { text: string; page: number; x: number; y: number; width: number; height: number }[] = []; 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 }); } if (node.parentElement?.closest('.name')) { for (const match of (node.textContent || '').matchAll(/\S+/g)) { const range = document.createRange(); range.setStart(node, match.index!); range.setEnd(node, match.index! + match[0].length); for (const rect of range.getClientRects()) nameLines.push({ text: match[0], page: Math.floor(rect.x / width) + 1, x: rect.x % width, y: rect.y, width: rect.width, height: rect.height }); } } } const sidebar = document.querySelector('.sidebar'); const sidebarStyle = sidebar ? getComputedStyle(sidebar) : null; return { width, contentHeight: Number(data.cvPreviewPages) * height, usableHeight: Number(data.cvPreviewUsableHeight), layoutDurationMs: Number(data.cvPreviewDurationMs), effectiveMargin: getComputedStyle(document.documentElement).getPropertyValue('--cv-page-margin'), tokens, nameLines, nameLineCount: new Set(nameLines.map(rect => `${rect.page}:${rect.y.toFixed(1)}`)).size, splitNameWords: nameLines.filter((rect, index) => nameLines.findIndex(item => item.text === rect.text) !== index).map(rect => rect.text), sidebar: sidebarStyle ? { background: sidebarStyle.backgroundColor, color: sidebarStyle.color, padding: sidebarStyle.padding, width: sidebar?.getBoundingClientRect().width } : null, photo: !!document.querySelector('.photo img'), previewPages: Number(data.cvPreviewPages), overflow: data.cvPreviewOverflow, userAgent: navigator.userAgent }; }); expect(measured.tokens.map(token => token.token).sort(), `${fixture} preview marker completeness`).toEqual([...markers].sort()); expect(measured.previewPages, fixture).toBeGreaterThan(0); if (designVersion === 2) expect(measured.splitNameWords.filter(word => !word.includes('-')), `${fixture} unhyphenated name words`).toEqual([]); const exported = await page.request.post(`${api}/cv/variants/${variant.id}/export-pdf?expectedVersion=${variant.version}`, { 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.previewPages; index++) { await page.setContent(previewDocumentHtml(rendered.html, 297 * 96 / 25.4, 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(`${api}/cv/variants/${variant.id}`)).json()).version).toBe(variant.version); expect(await (await page.request.get(`${api}/career/profile`)).json()).toEqual(source); results.push({ fixture, settings: variant.settings, expectedMarkers: markers, profileSha256: hash(JSON.stringify(profile)), htmlSha256: hash(rendered.html), pdfSha256: hash(bytes), durationMs: performance.now() - started, previewPages: measured.previewPages, measured }); await fs.writeFile(path.join(output, 'measurements.json'), JSON.stringify({ browserVersion: browser.version(), designVersion: designVersion ?? 'legacy-absent', syntheticPhoto: 'Colored rectangles only; no human portrait', fixtures: results }, null, 2)); } } expect(results).toHaveLength(36); await info.attach('template-library-measurements', { path: path.join(output, 'measurements.json'), contentType: 'application/json' }); });