125 lines
9.8 KiB
TypeScript
125 lines
9.8 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';
|
|
|
|
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' });
|
|
});
|