116 lines
8.9 KiB
TypeScript
116 lines
8.9 KiB
TypeScript
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('<canvas width="512" height="512"></canvas>');
|
|
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' });
|
|
});
|