feat(cv): redesign document editing with revision-safe letters and writing tools
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
import { expect, test, type APIRequestContext } 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 { translations } from '../src/i18n/translations';
|
||||
|
||||
const apiUrl = 'http://localhost:5302/api';
|
||||
const output = path.resolve(process.env.LETTER_FIDELITY_OUTPUT || '../.artifacts/cv-ux-01/letter-fidelity');
|
||||
const password = 'E2ePassword123!';
|
||||
|
||||
async function login(api: APIRequestContext, email: string) {
|
||||
expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200);
|
||||
const me = await (await api.get(`${apiUrl}/auth/me`)).json();
|
||||
return { 'X-CSRF-TOKEN': (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value,
|
||||
'X-Session-Context': me.sessionContext as string };
|
||||
}
|
||||
|
||||
test('cover letter native preview/PDF fidelity records four independent physical documents', async ({ page, context, browser, playwright }, info) => {
|
||||
test.setTimeout(360_000);
|
||||
// Existing attempts remain reviewable, including a partially produced failed run.
|
||||
await fs.mkdir(output, { recursive: true });
|
||||
await fs.writeFile(path.join(output, 'started.json'), JSON.stringify({ startedUtc: new Date().toISOString(), browserVersion: browser.version() }), { flag: 'wx' });
|
||||
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();
|
||||
});
|
||||
await context.addInitScript(() => {
|
||||
const query = new URLSearchParams(location.search);
|
||||
if (query.has('fidelityMode')) localStorage.setItem('jobtracker.themeMode', query.get('fidelityMode')!);
|
||||
if (query.has('fidelityLanguage')) localStorage.setItem('uiLanguage', query.get('fidelityLanguage')!);
|
||||
});
|
||||
const admin = await playwright.request.newContext();
|
||||
const adminHeaders = await login(admin, 'e2e@example.test');
|
||||
const email = `letter-fidelity-${Date.now()}@example.test`;
|
||||
try {
|
||||
expect((await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email, password, roles: [] } })).status()).toBe(200);
|
||||
} finally { await admin.dispose(); }
|
||||
const headers = await login(page.request, email);
|
||||
const sourceBefore = await (await page.request.get(`${apiUrl}/career/profile`)).json();
|
||||
const companyResponse = await page.request.post(`${apiUrl}/companies`, { headers, data: { name: 'Nordlys Synthetic AS', source: 'User' } });
|
||||
expect(companyResponse.status()).toBe(201);
|
||||
const company = await companyResponse.json();
|
||||
const fixtures = [];
|
||||
for (const [mode, language, pageSize] of [['light', 'en', 'a4'], ['dark', 'nb', 'a4'], ['light', 'nb', 'letter'], ['dark', 'en', 'letter']] as const) {
|
||||
const fixture = `${mode}-${language}-${pageSize}`;
|
||||
const directory = path.join(output, fixture);
|
||||
await fs.mkdir(directory);
|
||||
const t = translations[language];
|
||||
const sentence = language === 'nb' ? 'Dokumenterte pålitelige arbeidsflyter og tilgjengelig navigasjon for norske arbeidssøkere. '
|
||||
: 'Documented reliable workflows and accessible navigation using reviewed source evidence. ';
|
||||
const text = Array.from({ length: 24 }, (_, index) => `B${String(index + 1).padStart(2, '0')} ${sentence.repeat(4)}`)
|
||||
.concat(['- S01 **Reviewed achievement** — æøå.', '- S02 *Verified collaboration* — ÆØÅ.', 'R99 Final reviewed letter content — Åse Østby.']).join('\n\n');
|
||||
const documentSettings = { schemaVersion: 1, senderName: 'R01 Åse Østby', senderTitle: 'P01 Platform engineer',
|
||||
senderEmail: 'ase@example.test', senderPhone: '+47 12345678', senderLocation: 'Oslo, Norge',
|
||||
recipient: 'R03 Hiring team\nNordlys Synthetic AS\nOslo, Norge', subject: language === 'nb' ? 'R02 Søknad om plattformutvikler' : 'R02 Application for platform engineer',
|
||||
date: '2026-10-25', themeId: 'minimal', accentColor: '#126b55', bodyFont: 'Arial, Helvetica, sans-serif',
|
||||
baseFontSizePt: 11, lineHeight: 1.4, pageMarginMm: 20, pageSize };
|
||||
const created = await page.request.post(`${apiUrl}/jobapplications`, { headers, data: { companyId: company.id, jobTitle: fixture, status: 'Saved', description: 'Synthetic letter fidelity fixture.' } });
|
||||
expect(created.status()).toBe(201);
|
||||
const job = await created.json();
|
||||
const letterUrl = `${apiUrl}/jobapplications/${job.id}/cover-letter`;
|
||||
const original = await (await page.request.get(letterUrl)).json();
|
||||
const save = await page.request.put(letterUrl, { headers, data: { text, documentSettings, source: 'manual', expectedVersion: original.revision } });
|
||||
expect(save.status()).toBe(200);
|
||||
const acknowledgement = await save.json();
|
||||
// MariaDB stores datetime(6); compare subsequent reads with durable history timestamps.
|
||||
const saved = await (await page.request.get(letterUrl)).json();
|
||||
expect(saved.revision).toBe(acknowledgement.revision);
|
||||
expect(saved.text).toBe(text);
|
||||
const preview = await page.request.post(`${letterUrl}/preview`, { headers, data: { text: saved.text, documentSettings: saved.documentSettings } });
|
||||
expect(preview.status()).toBe(200);
|
||||
const rendered = await preview.json();
|
||||
expect(rendered.html).toContain('class="page letter-paper"');
|
||||
const pdf = await page.request.post(`${letterUrl}/export-pdf?expectedVersion=${saved.revision}`, { headers });
|
||||
expect(pdf.status()).toBe(200);
|
||||
expect(pdf.headers()['content-type']).toContain('application/pdf');
|
||||
const bytes = await pdf.body();
|
||||
expect(bytes.subarray(0, 5).toString()).toBe('%PDF-');
|
||||
await fs.writeFile(path.join(directory, 'export.pdf'), bytes);
|
||||
await fs.writeFile(path.join(directory, 'render.html'), rendered.html);
|
||||
expect(await (await page.request.get(letterUrl)).json()).toEqual(saved);
|
||||
|
||||
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers, data: { language: language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204);
|
||||
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||
await page.goto(`/jobs/${job.id}?section=application&sub=letter&fidelityMode=${mode}&fidelityLanguage=${language}#materials`);
|
||||
await expect(page.locator('html')).toHaveAttribute('data-color-scheme', mode);
|
||||
const frame = page.getByTitle(t.letterDocumentPreview, { exact: true });
|
||||
const previewWorkspace = frame.locator('..').locator('..').locator('..');
|
||||
await expect(frame).toBeVisible();
|
||||
await expect.poll(() => frame.evaluate(element => (element as HTMLIFrameElement).contentDocument?.documentElement.dataset.cvPreviewReady)).toBe('true');
|
||||
const measured = await frame.evaluate(async element => {
|
||||
const doc = (element as HTMLIFrameElement).contentDocument!;
|
||||
const win = (element as HTMLIFrameElement).contentWindow!;
|
||||
await doc.fonts.ready;
|
||||
const geometry = doc.documentElement.dataset;
|
||||
const width = Number(geometry.cvPreviewWidth); const height = Number(geometry.cvPreviewHeight);
|
||||
const tokens = [];
|
||||
const walker = doc.createTreeWalker(doc.body, 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 = doc.createRange(); range.setStart(node, match.index!); range.setEnd(node, match.index! + match[0].length);
|
||||
const rect = range.getClientRects()[0]; const style = win.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, fontFamily: style.fontFamily, color: style.color });
|
||||
}
|
||||
}
|
||||
return { width, height, contentHeight: height * Number(geometry.cvPreviewPages), pages: Number(geometry.cvPreviewPages),
|
||||
effectiveMargin: win.getComputedStyle(doc.documentElement).getPropertyValue('--cv-page-margin'), text: doc.body.innerText,
|
||||
bold: doc.querySelectorAll('strong').length, italic: doc.querySelectorAll('em').length, tokens };
|
||||
});
|
||||
expect(measured.pages).toBeGreaterThan(1);
|
||||
expect(measured.pages).toBeLessThan(15);
|
||||
expect(measured.tokens.map(token => token.token).sort()).toEqual(['R01', 'R02', 'R03', 'R99', 'P01', 'S01', 'S02', ...Array.from({ length: 24 }, (_, index) => `B${String(index + 1).padStart(2, '0')}`)].sort());
|
||||
expect(measured.text).toContain('ase@example.test');
|
||||
expect(measured.text).toContain('Nordlys Synthetic AS');
|
||||
expect(measured.bold).toBeGreaterThan(0); expect(measured.italic).toBeGreaterThan(0);
|
||||
expect(measured.tokens.find(token => token.token === 'R99')!.page).toBe(measured.pages);
|
||||
for (let index = 0; index < measured.pages; index++) {
|
||||
if (index > 0) {
|
||||
await previewWorkspace.getByRole('button', { name: t.cvEditorNext, exact: true }).click();
|
||||
await expect(previewWorkspace.getByText(t.letterPage.replace('{page}', String(index + 1)).replace('{count}', String(measured.pages)), { exact: true })).toBeVisible();
|
||||
await expect.poll(() => frame.evaluate(element => (element as HTMLIFrameElement).contentDocument?.documentElement.dataset.cvPreviewReady)).toBe('true');
|
||||
}
|
||||
const capture = await browser.newPage({ viewport: { width: Math.ceil(measured.width), height: Math.ceil(measured.height) } });
|
||||
try {
|
||||
await capture.setContent(previewDocumentHtml(rendered.html, measured.height, index));
|
||||
await expect.poll(() => capture.evaluate(() => document.documentElement.dataset.cvPreviewReady)).toBe('true');
|
||||
await capture.screenshot({ path: path.join(directory, `preview-${index + 1}.png`) });
|
||||
} finally { await capture.close(); }
|
||||
}
|
||||
const reachable = await frame.evaluate(element => {
|
||||
const doc = (element as HTMLIFrameElement).contentDocument!;
|
||||
const node = Array.from(doc.querySelectorAll('p')).find(item => item.textContent?.includes('R99'))!;
|
||||
const rect = node.getBoundingClientRect();
|
||||
return rect.right > 0 && rect.left < Number(doc.documentElement.dataset.cvPreviewWidth) && rect.top >= 0 && rect.bottom <= Number(doc.documentElement.dataset.cvPreviewHeight);
|
||||
});
|
||||
expect(reachable).toBe(true);
|
||||
const record = { fixture, previewPages: measured.pages, settings: saved.documentSettings, measured,
|
||||
savedRevision: saved.revision, pdfSha256: createHash('sha256').update(bytes).digest('hex'), htmlSha256: createHash('sha256').update(rendered.html).digest('hex'), browserVersion: browser.version(), language, mode };
|
||||
await fs.writeFile(path.join(directory, 'measurements.json'), JSON.stringify(record, null, 2));
|
||||
await page.screenshot({ path: path.join(directory, 'last-page-workspace.png') });
|
||||
await info.attach(fixture, { body: JSON.stringify(record), contentType: 'application/json' });
|
||||
fixtures.push(record);
|
||||
}
|
||||
expect(await (await page.request.get(`${apiUrl}/career/profile`)).json()).toEqual(sourceBefore);
|
||||
await fs.writeFile(path.join(output, 'measurements.json'), JSON.stringify({ browserVersion: browser.version(), fixtures }, null, 2), { flag: 'wx' });
|
||||
});
|
||||
@@ -81,6 +81,7 @@ for (const [mode, language] of [['light', 'en'], ['dark', 'nb'], ['light', 'nb']
|
||||
const move = page.getByRole('button', { name: label('cvEditorMoveSectionDown', t.cvSectionExperience), exact: true });
|
||||
await move.focus(); await page.keyboard.press('Enter');
|
||||
await expect(move).toBeFocused(); await saved();
|
||||
await expect(page.getByRole('status').filter({ hasText: t.cvEditorReordered.replace('{item}', t.cvSectionExperience).replace('{position}', '4').replace('{total}', '13') })).toBeVisible();
|
||||
expect((await read()).settings.sections.slice(0, 4).map((row: { key: string }) => row.key)).toEqual(['summary', 'projects', 'education', 'experience']);
|
||||
await capture(page, info, 'reordered-section-focus');
|
||||
|
||||
@@ -137,7 +138,7 @@ for (const [mode, language] of [['light', 'en'], ['dark', 'nb'], ['light', 'nb']
|
||||
await page.getByRole('menuitem', { name: t.cvEditorStopSharing, exact: true }).click();
|
||||
await expect(page.getByText(t.cvEditorShared, { exact: true })).toHaveCount(0);
|
||||
expect((await anonymous.get(`${apiUrl}/public-cv/${publicCv.publicSlug}`)).status()).toBe(404); await anonymous.dispose();
|
||||
const pdfResponse = page.waitForResponse(response => response.url() === `${cvUrl}/export-pdf` && response.request().method() === 'POST');
|
||||
const pdfResponse = page.waitForResponse(response => new URL(response.url()).pathname === new URL(`${cvUrl}/export-pdf`).pathname && new URL(response.url()).searchParams.has('expectedVersion') && response.request().method() === 'POST');
|
||||
const download = page.waitForEvent('download');
|
||||
await page.getByRole('button', { name: t.cvEditorDownloadPdf, exact: true }).click();
|
||||
expect((await pdfResponse).status()).toBe(200); expect((await download).suggestedFilename()).toMatch(/\.pdf$/i);
|
||||
@@ -145,6 +146,7 @@ for (const [mode, language] of [['light', 'en'], ['dark', 'nb'], ['light', 'nb']
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.getByRole('tab', { name: t.cvEditorHistory, exact: true }).click();
|
||||
const history = page.getByRole('tabpanel', { name: t.cvEditorHistory, exact: true });
|
||||
await expect(history.getByText(t.cvEditorHistoryScope, { exact: true })).toBeVisible();
|
||||
await expect(history.getByText(t.cvEditorVersion.replace('{version}', '1'), { exact: true })).toBeVisible();
|
||||
await capture(page, info, 'history'); await scan(page, info, 'history');
|
||||
await page.getByRole('tab', { name: t.cvEditorAiTools, exact: true }).click();
|
||||
|
||||
@@ -54,7 +54,7 @@ async function measure(page: Page, browser: Browser, info: TestInfo, cvUrl: stri
|
||||
});
|
||||
const variant = await (await page.request.get(cvUrl)).json();
|
||||
const directory = path.join(output, fixture); await fs.mkdir(directory, { recursive: true });
|
||||
const response = page.waitForResponse(r => r.url() === `${cvUrl}/export-pdf` && r.request().method() === 'POST');
|
||||
const response = page.waitForResponse(r => new URL(r.url()).pathname === new URL(`${cvUrl}/export-pdf`).pathname && new URL(r.url()).searchParams.has('expectedVersion') && r.request().method() === 'POST');
|
||||
const downloading = page.waitForEvent('download');
|
||||
await page.getByRole('button', { name: t.cvEditorDownloadPdf, exact: true }).click();
|
||||
const pdf = await response; expect(pdf.status()).toBe(200); expect((await downloading).suggestedFilename()).toMatch(/\.pdf$/i);
|
||||
@@ -163,9 +163,15 @@ for (const [mode, language, premium] of [['light', 'en', false], ['dark', 'nb',
|
||||
await capture(page, info, 'templates'); await scan(page, info, 'templates');
|
||||
const target = premium ? technical : page.getByRole('button', { name: t.cvTemplateCodeName, exact: true });
|
||||
await target.focus(); await page.keyboard.press('Enter');
|
||||
const dialog = page.getByRole('dialog', { name: t.cvEditorChangeTemplateTitle }); await expect(dialog).toBeVisible(); await capture(page, info, 'reset-confirmation');
|
||||
const candidate = page.getByRole('dialog', { name: new RegExp(t.cvEditorPreviewDesign) });
|
||||
await expect(candidate).toBeVisible();
|
||||
await expect(candidate.getByRole('button', { name: t.cvEditorApplyTemplate, exact: true })).toBeEnabled();
|
||||
expect((await read()).settings).toEqual(changed.settings);
|
||||
await capture(page, info, 'candidate-preview'); await scan(page, info, 'candidate-preview');
|
||||
await page.keyboard.press('Escape'); await expect(target).toBeFocused(); expect((await read()).settings.themeId).toBe('minimal');
|
||||
await page.keyboard.press('Space'); await dialog.getByRole('button', { name: t.cvEditorApplyTemplate, exact: true }).click(); await saved();
|
||||
await page.keyboard.press('Space'); await candidate.getByRole('button', { name: t.cvEditorApplyTemplate, exact: true }).click();
|
||||
const dialog = page.getByRole('dialog', { name: t.cvEditorChangeTemplateTitle }); await expect(dialog).toBeVisible(); await capture(page, info, 'reset-confirmation');
|
||||
await dialog.getByRole('button', { name: t.cvEditorApplyTemplate, exact: true }).click(); await saved();
|
||||
await expect.poll(async () => (await read()).settings.themeId).toBe(premium ? 'technical' : 'code');
|
||||
const selected = await read(); expect(selected.settings.themeId).toBe(premium ? 'technical' : 'code');
|
||||
for (const key of ['accentColor', 'bodyFont', 'baseFontSizePt', 'pageMarginMm', 'entryGapMm', 'sectionGapMm', 'lineHeight']) expect(selected.settings[key]).toBeNull();
|
||||
|
||||
Reference in New Issue
Block a user