Files
jobtrackingapp/job-tracker-ui/e2e/pre-p10-template-library.spec.ts
T

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' });
});