feat(cv): qualify templates and reliable local import processing

This commit is contained in:
cesnimda
2026-10-28 14:36:12 +01:00
parent 73ea888375
commit 3f4db8ca81
69 changed files with 2597 additions and 180 deletions
@@ -0,0 +1,124 @@
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' });
});
@@ -0,0 +1,40 @@
import { emptyStructuredCv } from '../src/profileCv';
// Synthetic comparison corpus: each theme receives identical facts and marker membership.
export const templateFixtures = [
{ key: 'short-en', language: 'en', jobs: 1, bullets: 2, skills: 5 },
{ key: 'medium-nb', language: 'nb', jobs: 4, bullets: 3, skills: 12 },
{ key: 'long-en', language: 'en', jobs: 10, bullets: 4, skills: 25 },
{ key: 'dense-nb', language: 'nb', jobs: 12, bullets: 5, skills: 60 },
] as const;
export function templateProfile(sample: typeof templateFixtures[number]) {
const nb = sample.language === 'nb';
const profile = {
...emptyStructuredCv(),
contact: { ...emptyStructuredCv().contact,
fullName: 'S01 Åse Ødegård-Sørensen LangtEtternavn',
headline: nb ? 'S02 Senior plattformingeniør og fagansvarlig' : 'S02 Senior platform engineer and technical lead',
email: 'ase.odegard-sorensen.long-account-identifier@example.test', location: nb ? 'Tromsø, Norge' : 'Tromsø, Norway' },
summary: [nb ? 'S03 Bygger pålitelige tjenester med tydelig dokumentasjon, tilgjengelighet og målbare resultater.' : 'S03 Builds reliable services with clear documentation, accessibility and measurable results.'],
jobs: Array.from({ length: sample.jobs }, (_, index) => ({
id: `comparison-job-${index + 1}`,
title: `E${String(index + 1).padStart(2, '0')} ${nb ? 'Senior utvikler og teknisk rådgiver' : 'Senior software engineer and technical adviser'}`,
company: `${nb ? 'Eksempel Teknologi' : 'Example Technology'} ${index + 1}`,
startDate: `${2025 - index}-01`, endDate: index ? `${2026 - index}-01` : undefined, isCurrent: index === 0,
bullets: Array.from({ length: sample.bullets }, (_, detail) => {
const marker = `B${String(index * sample.bullets + detail + 1).padStart(2, '0')}`;
return nb ? `${marker} Forbedret stabile arbeidsflyter gjennom samarbeid, grundig testing og tilgjengelige grensesnitt for norske brukere. Dokumenterte ansvar, beslutninger og målbare resultater uten å miste historiske opplysninger.`
: `${marker} Improved reliable workflows through collaboration, thorough testing and accessible interfaces. Documented ownership, engineering decisions and measurable outcomes while preserving historical information.`;
}),
})),
skills: Array.from({ length: sample.skills }, (_, index) => `P${String(index + 1).padStart(2, '0')} ${['C# og .NET', 'React og TypeScript', 'Tilgjengelighet', 'Distribuerte systemer', 'PostgreSQL', 'Docker'][index % 6]}`),
// Short intentionally omits education; all fixtures omit optional phone/website/projects.
education: sample.key === 'short-en' ? [] : [{ id: 'comparison-education', qualification: 'R01 Master i informatikk', institution: 'Universitetet i Tromsø', startDate: '2014', endDate: '2019' }],
languages: [{ name: 'R02 Norsk bokmål', level: 'Morsmål' }, { name: 'R03 English', level: 'Fluent' }],
interests: ['R04 Friluftsliv og åpen kildekode'],
};
const markers = JSON.stringify(profile).match(/\b[SEBPR]\d{2}\b/g)!;
if (new Set(markers).size !== markers.length) throw new Error(`Duplicate fixture markers: ${sample.key}`);
return { profile, markers };
}
@@ -0,0 +1,59 @@
import { expect, test } from '@playwright/test';
import fs from 'node:fs/promises';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { templateFixtures, templateProfile } from './pre-p10-template-fixtures';
const api = 'http://localhost:5302/api';
const baseline = path.resolve('../.artifacts/pre-phase10-ux/templates-legacy');
const output = path.resolve(process.env.CV_TEMPLATE_OUTPUT || '../.artifacts/pre-phase10-ux/templates-revised-01');
const hash = (text: string) => createHash('sha256').update(text).digest('hex');
// The existing avatar endpoint now normalizes image pixels with Skia. Retain font data
// verbatim; exclude only embedded image payload bytes when comparing document markup.
const normalizeAvatar = (html: string) => html.replace(/data:image\/(?:png|jpeg|webp);base64,[A-Za-z0-9+/=]+/g, 'data:image/normalized-avatar');
test('[PRE-P10 B] existing design-one markup, CSS and source markers survive design-two additions', async ({ request }, info) => {
test.setTimeout(180_000);
expect((await request.post(`${api}/auth/login`, { data: { email: 'e2e@example.test', password: 'E2ePassword123!' } })).status()).toBe(200);
const me = await (await request.get(`${api}/auth/me`)).json();
const headers = { 'X-CSRF-TOKEN': (await request.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value, 'X-Session-Context': me.sessionContext as string };
const avatar = await fs.readFile(path.join(baseline, 'synthetic-nonhuman-color-sample.png'));
expect((await request.post(`${api}/auth/avatar`, { headers, multipart: { file: { name: 'synthetic-nonhuman.png', mimeType: 'image/png', buffer: avatar } } })).status()).toBe(200);
const themes = await (await request.get(`${api}/cv/themes`)).json();
const results = [];
await fs.mkdir(path.join(output, 'legacy-current'), { recursive: true });
for (const sample of templateFixtures) {
const { profile } = templateProfile(sample);
const source = await (await request.get(`${api}/career/profile`)).json();
expect((await request.put(`${api}/career/profile`, { headers, data: { expectedVersion: source.revision, profile } })).status()).toBe(200);
for (const theme of themes) {
const fixture = `${sample.key}-${theme.id}`;
const created = await request.post(`${api}/cv/variants`, { headers, data: { name: fixture, settings: { themeId: theme.id, language: sample.language, showPhoto: true, showIcons: true } } });
expect(created.status(), fixture).toBe(200);
const variant = await created.json();
const response = await request.get(`${api}/cv/variants/${variant.id}/preview`);
expect(response.status(), fixture).toBe(200);
const current = (await response.json()).html as string;
const original = await fs.readFile(path.join(baseline, fixture, 'render.html'), 'utf8');
const style = (html: string) => html.match(/<style>([\s\S]*?)<\/style>/)![1];
const body = (html: string) => html.match(/<body>([\s\S]*?)<\/body>/)![1];
const originalMarkers = body(original).match(/\b[SEBPR]\d{2}\b/g);
const currentMarkers = body(current).match(/\b[SEBPR]\d{2}\b/g);
const measured = { fixture, rawOriginalHash: hash(original), rawCurrentHash: hash(current),
normalizedOriginalHash: hash(normalizeAvatar(original)), normalizedCurrentHash: hash(normalizeAvatar(current)),
originalCssHash: hash(style(original)), currentCssHash: hash(style(current)),
originalBodyHash: hash(normalizeAvatar(body(original))), currentBodyHash: hash(normalizeAvatar(body(current))),
markersEqual: JSON.stringify(currentMarkers) === JSON.stringify(originalMarkers) };
results.push(measured);
await fs.writeFile(path.join(output, 'legacy-current', `${fixture}.html`), current);
await fs.writeFile(path.join(output, 'legacy-preservation.json'), JSON.stringify({
normalization: 'Only data:image/{png,jpeg,webp};base64 payload bytes excluded because the upload pipeline now reencodes the synthetic avatar with Skia. Font resources, CSS and all other HTML bytes remain exact.', fixtures: results }, null, 2));
expect.soft(measured.normalizedCurrentHash, `${fixture} normalized full HTML`).toBe(measured.normalizedOriginalHash);
expect.soft(measured.currentCssHash, `${fixture} exact CSS including embedded licensed fonts`).toBe(measured.originalCssHash);
expect.soft(measured.currentBodyHash, `${fixture} body excluding avatar bytes`).toBe(measured.originalBodyHash);
expect.soft(measured.markersEqual, `${fixture} marker order`).toBe(true);
}
}
expect(results).toHaveLength(36);
await info.attach('legacy-preservation', { path: path.join(output, 'legacy-preservation.json'), contentType: 'application/json' });
});
@@ -0,0 +1,115 @@
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' });
});
@@ -0,0 +1,230 @@
import { expect, test, type APIRequestContext, type Page } from '@playwright/test';
import { translations } from '../src/i18n/translations';
import { emptyStructuredCv } from '../src/profileCv';
const api = 'http://localhost:5302/api';
const password = 'E2ePassword123!';
const themeNames = {
modern: 'cvTemplateModernName', minimal: 'cvTemplateMinimalName', executive: 'cvTemplateExecutiveName',
technical: 'cvTemplateTechnicalName', 'ats-classic': 'cvTemplateAtsClassicName', nordic: 'cvTemplateNordicName',
elegant: 'cvTemplateElegantName', creative: 'cvTemplateCreativeName', code: 'cvTemplateCodeName',
} as const;
async function login(request: APIRequestContext, email: string) {
expect((await request.post(`${api}/auth/login`, { data: { email, password } })).status()).toBe(200);
const me = await (await request.get(`${api}/auth/me`)).json();
return { 'X-CSRF-TOKEN': (await request.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value,
'X-Session-Context': me.sessionContext as string };
}
async function owner(page: Page, admin: APIRequestContext, language: 'en' | 'nb') {
const adminHeaders = await login(admin, 'e2e@example.test');
const email = `template-selection-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`;
expect((await admin.post(`${api}/users`, { headers: adminHeaders, data: { email, password, roles: ['Premium'] } })).status()).toBe(200);
const headers = await login(page.request, email);
const initial = await (await page.request.get(`${api}/career/profile`)).json();
const profile = { ...emptyStructuredCv(), contact: { ...emptyStructuredCv().contact, fullName: 'Owner Canonical Marker', headline: 'Canonical headline marker', email: 'synthetic@example.test' },
summary: ['Canonical summary marker'], skills: ['TypeScript', 'Documented evidence'] };
expect((await page.request.put(`${api}/career/profile`, { headers, data: { expectedVersion: initial.revision, profile } })).status()).toBe(200);
expect((await page.request.put(`${api}/auth/preferences/language`, { headers, data: { language: language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204);
await page.addInitScript(language => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', 'light'); }, language);
await page.setViewportSize({ width: language === 'nb' ? 390 : 1440, height: 1000 });
const source = await (await page.request.get(`${api}/career/profile`)).json();
return { headers, source };
}
async function createCv(page: Page, headers: Record<string, string>, showPhoto = false) {
const response = await page.request.post(`${api}/cv/variants`, { headers, data: { name: 'Template selection CV', settings: {
themeId: 'modern', sectionsExplicit: true, includeProfileContact: true, sections: [{ key: 'summary' }], headline: 'Authored CV headline marker', showPhoto,
} } });
expect(response.status()).toBe(200);
return response.json();
}
test.beforeEach(async ({ context }) => {
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();
});
});
for (const language of ['en', 'nb'] as const) {
test(`[PRE-P10 B] ${language} actual nine-theme gallery and candidate preview remain read-only until Apply`, async ({ page, playwright }, info) => {
test.setTimeout(180_000);
const t = translations[language];
const admin = await playwright.request.newContext();
let fixture: Awaited<ReturnType<typeof owner>>;
try { fixture = await owner(page, admin, language); } finally { await admin.dispose(); }
const cv = await createCv(page, fixture.headers);
const cvUrl = `${api}/cv/variants/${cv.id}`;
const read = async () => (await page.request.get(cvUrl)).json();
const requests: { path: string; status: number }[] = [];
let writes = 0;
page.on('response', response => { if (/\/cv\/themes\/[^/]+\/preview$/.test(new URL(response.url()).pathname)) requests.push({ path: new URL(response.url()).pathname, status: response.status() }); });
page.on('request', request => { if (request.url() === cvUrl && request.method() === 'PUT') writes++; });
await page.goto(`/career/builder/${cv.id}`);
await page.getByRole('tab', { name: t.cvEditorTemplate, exact: true }).click();
await page.getByRole('button', { name: t.cvEditorAllTemplates, exact: true }).click();
for (const [id, key] of Object.entries(themeNames)) {
const card = page.getByRole('button', { name: t[key], exact: true });
await card.scrollIntoViewIfNeeded();
await expect(card).toHaveAttribute('aria-disabled', 'false');
const frame = card.frameLocator('iframe');
await expect(frame.locator('.name')).toHaveText('Alex Morgan');
await expect(frame.locator('body')).not.toContainText('Owner Canonical Marker');
await expect.poll(() => requests.some(request => request.path === `/api/cv/themes/${id}/preview` && request.status === 200)).toBe(true);
}
const candidate = page.getByRole('button', { name: t.cvTemplateTechnicalName, exact: true });
await candidate.click();
const dialog = page.getByRole('dialog');
await expect(dialog.frameLocator('iframe').locator('.name')).toHaveText('Alex Morgan');
await expect(dialog.getByRole('button', { name: t.cvEditorApplyTemplate, exact: true })).toBeEnabled();
expect(await read()).toEqual(cv);
expect(writes).toBe(0);
await dialog.getByRole('button', { name: t.cancel, exact: true }).click();
expect(await read()).toEqual(cv);
await candidate.click();
await expect(dialog.frameLocator('iframe').locator('.name')).toHaveText('Alex Morgan');
const applied = page.waitForResponse(response => response.url() === cvUrl && response.request().method() === 'PUT');
await dialog.getByRole('button', { name: t.cvEditorApplyTemplate, exact: true }).click();
expect((await applied).status()).toBe(200);
await expect.poll(async () => (await read()).settings.themeId).toBe('technical');
const saved = await read();
expect(saved.settings).toMatchObject({ designVersion: 2, themeId: 'technical', sectionsExplicit: true, includeProfileContact: true,
sections: [{ key: 'summary' }], headline: 'Authored CV headline marker' });
expect(saved.version).toBe(cv.version + 1);
if (language === 'nb') await page.getByRole('button', { name: t.cvEditorPreview, exact: true }).click();
const live = page.frameLocator(`iframe[title="${t.cvEditorPreviewPage.replace('{page}', '1')}"]`);
await expect(live.locator('.name')).toHaveText('Owner Canonical Marker');
await expect(live.locator('body')).toContainText('Authored CV headline marker');
await expect.poll(() => live.locator('body').evaluate(node => getComputedStyle(node, '::before').backgroundColor)).toBe('rgb(15, 76, 92)');
expect(await (await page.request.get(`${api}/career/profile`)).json()).toEqual(fixture.source);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
await info.attach('actual-template-selection', { body: JSON.stringify({ language, samples: requests, savedSettings: saved.settings, writes }), contentType: 'application/json' });
await page.screenshot({ path: info.outputPath('template-selection.png'), fullPage: true });
});
test(`[PRE-P10 B] ${language} actual letter template Apply previews current authored text and preserves saved source`, async ({ page, playwright }, info) => {
test.setTimeout(180_000);
const t = translations[language];
const admin = await playwright.request.newContext();
let fixture: Awaited<ReturnType<typeof owner>>;
try { fixture = await owner(page, admin, language); } finally { await admin.dispose(); }
const companyResponse = await page.request.post(`${api}/companies`, { headers: fixture.headers, data: { name: 'Letter Selection Synthetic Company', source: 'User' } });
expect(companyResponse.status()).toBe(201);
const company = await companyResponse.json();
const jobResponse = await page.request.post(`${api}/jobapplications`, { headers: fixture.headers, data: { companyId: company.id, jobTitle: 'Letter template fixture', status: 'Saved', description: 'Synthetic controlled fixture' } });
expect(jobResponse.status()).toBe(201);
const job = await jobResponse.json();
const letterUrl = `${api}/jobapplications/${job.id}/cover-letter`;
const original = await (await page.request.get(letterUrl)).json();
const documentSettings = { schemaVersion: 1, senderName: 'Letter sender marker', senderTitle: 'Reviewed title', senderEmail: 'letter@example.test', senderPhone: '+47 12345678', senderLocation: 'Oslo',
recipient: 'Preserved recipient marker', subject: 'Preserved subject marker', date: '2026-10-27', themeId: 'modern', accentColor: '#2563eb', bodyFont: 'Arial, Helvetica, sans-serif',
baseFontSizePt: 10, lineHeight: 1.42, pageMarginMm: 16, pageSize: 'letter', language: language === 'nb' ? 'nb-NO' : 'en' };
expect((await page.request.put(letterUrl, { headers: fixture.headers, data: { text: 'Saved letter body marker', source: 'manual', expectedVersion: original.revision, documentSettings } })).status()).toBe(200);
const saved = await (await page.request.get(letterUrl)).json();
let writes = 0;
const cvSamples: string[] = [];
page.on('request', request => {
if (request.method() === 'PUT' && request.url() === letterUrl) writes++;
if (/\/cv\/themes\/[^/]+\/preview$/.test(new URL(request.url()).pathname)) cvSamples.push(request.url());
});
await page.goto(`/jobs/${job.id}?section=application&sub=letter#materials`);
const body = page.getByRole('textbox', { name: t.jobDetailsCoverLetter, exact: true });
await expect(body).toHaveValue('Saved letter body marker');
const draft = 'Current authored letter marker — Åse Østby';
await body.fill(draft);
await page.getByRole('button', { name: t.letterChooseTemplate, exact: true }).click();
const dialog = page.getByRole('dialog');
await expect(dialog.getByRole('button', { name: t.letterTemplateApply, exact: true })).toBeEnabled();
await dialog.getByRole('combobox', { name: new RegExp(`^${t.letterTemplate}(?: |$)`) }).click();
await expect(page.getByRole('option')).toHaveCount(9);
await page.getByRole('option', { name: 'Nordic', exact: true }).click();
await dialog.getByRole('button', { name: t.cancel, exact: true }).click();
await expect(body).toHaveValue(draft);
expect(await (await page.request.get(letterUrl)).json()).toEqual(saved);
await page.getByRole('button', { name: t.letterChooseTemplate, exact: true }).click();
await expect(dialog.getByRole('button', { name: t.letterTemplateApply, exact: true })).toBeEnabled();
await dialog.getByRole('combobox', { name: new RegExp(`^${t.letterTemplate}(?: |$)`) }).click();
await page.getByRole('option', { name: 'Nordic', exact: true }).click();
const previewResponse = page.waitForResponse(response => response.url() === `${letterUrl}/preview` && response.request().method() === 'POST'
&& response.request().postDataJSON()?.documentSettings?.themeId === 'nordic');
await dialog.getByRole('button', { name: t.letterTemplateApply, exact: true }).click();
await expect(body).toHaveValue(draft);
if (language === 'nb') await page.getByRole('tab', { name: t.assetsLetterPreview, exact: true }).click();
const preview = await previewResponse;
expect(preview.status()).toBe(200);
const submitted = preview.request().postDataJSON();
expect(submitted.text).toBe(draft);
expect(submitted.documentSettings).toMatchObject({ designVersion: 2, themeId: 'nordic', pageSize: 'letter', language: documentSettings.language,
senderName: documentSettings.senderName, recipient: documentSettings.recipient, date: documentSettings.date, subject: documentSettings.subject });
const frame = page.frameLocator(`iframe[title="${t.letterDocumentPreview}"]`);
await expect(frame.locator('.letter-theme-nordic')).toBeVisible();
await expect(frame.locator('body')).toContainText(draft);
await expect(frame.locator('body')).toContainText(documentSettings.recipient);
expect(await (await page.request.get(letterUrl)).json()).toEqual(saved);
expect(await (await page.request.get(`${api}/career/profile`)).json()).toEqual(fixture.source);
expect(writes).toBe(0);
expect(cvSamples).toEqual([]);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
await info.attach('actual-letter-template-preview', { body: JSON.stringify({ language, submitted, unchangedSavedRevision: saved.revision, writes, cvSamples }), contentType: 'application/json' });
await page.screenshot({ path: info.outputPath('letter-template-selection.png'), fullPage: true });
});
test(`[PRE-P10 B] ${language} keyboard crop uses existing avatar storage and CV photo remains optional`, async ({ page, playwright }, info) => {
test.setTimeout(180_000);
const t = translations[language];
const admin = await playwright.request.newContext();
let fixture: Awaited<ReturnType<typeof owner>>;
try { fixture = await owner(page, admin, language); } finally { await admin.dispose(); }
const cv = await createCv(page, fixture.headers, true);
await page.goto(`/career/builder/${cv.id}`);
await page.getByRole('tab', { name: t.cvEditorCustomize, exact: true }).click();
await page.getByRole('link', { name: t.cvEditorManagePhoto, exact: true }).click();
await expect(page).toHaveURL(/\/career\?section=profile$/);
await expect(page.getByRole('button', { name: t.profileChangeImage, exact: true })).toBeEnabled();
const png = await page.evaluate(() => {
const canvas = document.createElement('canvas'); canvas.width = 512; canvas.height = 384;
const draw = canvas.getContext('2d')!;
draw.fillStyle = '#0f766e'; draw.fillRect(0, 0, 256, 384);
draw.fillStyle = '#f59e0b'; draw.fillRect(256, 0, 256, 384);
return canvas.toDataURL('image/png').split(',')[1];
});
await page.locator('input[type=file]').nth(1).setInputFiles({ name: 'synthetic-nonhuman-color-blocks.png', mimeType: 'image/png', buffer: Buffer.from(png, 'base64') });
const crop = page.getByRole('dialog');
await expect(crop.getByAltText(t.cropDialogPreviewAlt, { exact: true })).toBeVisible();
const zoom = crop.getByRole('slider', { name: t.cropDialogZoom, exact: true });
await zoom.focus(); await zoom.press('End');
await expect(zoom).toHaveAttribute('aria-valuenow', '3');
const horizontal = crop.getByRole('slider', { name: t.cropDialogHorizontal, exact: true });
const vertical = crop.getByRole('slider', { name: t.cropDialogVertical, exact: true });
for (const slider of [horizontal, vertical]) {
await expect(slider).toBeEnabled();
await slider.focus(); await slider.press('Home');
await expect.poll(async () => Number(await slider.getAttribute('aria-valuenow'))).toBeLessThan(0);
await slider.press('End');
await expect(slider).toHaveAttribute('aria-valuenow', '0');
}
const uploadResponse = page.waitForResponse(response => new URL(response.url()).pathname === '/api/auth/avatar' && response.request().method() === 'POST');
await crop.getByRole('button', { name: t.cropDialogSave, exact: true }).press('Enter');
expect((await uploadResponse).status()).toBe(200);
await expect(crop).not.toBeVisible();
const avatar = (await (await page.request.get(`${api}/auth/me`)).json()).avatarImageDataUrl;
expect(avatar).toBeTruthy();
await page.goto(`/career/builder/${cv.id}`);
if (language === 'nb') await page.getByRole('button', { name: t.cvEditorPreview, exact: true }).click();
const live = page.frameLocator(`iframe[title="${t.cvEditorPreviewPage.replace('{page}', '1')}"]`);
await expect(live.locator('.photo img')).toBeVisible();
if (language === 'nb') await page.getByRole('button', { name: t.cvEditorEdit, exact: true }).click();
await page.getByRole('tab', { name: t.cvEditorCustomize, exact: true }).click();
await page.getByRole('switch', { name: t.cvEditorShowPhoto, exact: true }).uncheck();
await expect.poll(async () => (await (await page.request.get(`${api}/cv/variants/${cv.id}`)).json()).settings.showPhoto).toBe(false);
if (language === 'nb') await page.getByRole('button', { name: t.cvEditorPreview, exact: true }).click();
await expect(live.locator('.photo img')).toHaveCount(0);
expect((await (await page.request.get(`${api}/auth/me`)).json()).avatarImageDataUrl).toBe(avatar);
expect(await (await page.request.get(`${api}/career/profile`)).json()).toEqual(fixture.source);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
await page.screenshot({ path: info.outputPath('optional-photo-after-keyboard-crop.png'), fullPage: true });
await info.attach('keyboard-crop', { body: JSON.stringify({ language, syntheticNonhumanFixture: true, keyboardZoom: 3, keyboardAxes: ['horizontal', 'vertical'], optionalCvPhoto: false, avatarRetained: true }), contentType: 'application/json' });
});
}