265 lines
19 KiB
TypeScript
265 lines
19 KiB
TypeScript
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' });
|
|
});
|
|
test('cover letter workspace preserves keyboard drafts and localised controls across responsive widths', async ({ context, playwright }, info) => {
|
|
test.setTimeout(300_000);
|
|
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('responsiveMode')) localStorage.setItem('jobtracker.themeMode', query.get('responsiveMode')!);
|
|
if (query.has('responsiveLanguage')) localStorage.setItem('uiLanguage', query.get('responsiveLanguage')!);
|
|
});
|
|
const admin = await playwright.request.newContext();
|
|
const adminHeaders = await login(admin, 'e2e@example.test');
|
|
const email = `letter-responsive-${Date.now()}@example.test`;
|
|
try {
|
|
// Free ownership keeps the four AI actions visible but prevents any provider invocation.
|
|
expect((await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email, password, roles: [] } })).status()).toBe(200);
|
|
} finally { await admin.dispose(); }
|
|
const headers = await login(context.request, email);
|
|
const companyResponse = await context.request.post(`${apiUrl}/companies`, { headers, data: { name: 'Responsive Synthetic AS', source: 'User' } });
|
|
expect(companyResponse.status()).toBe(201);
|
|
const company = await companyResponse.json();
|
|
const created = await context.request.post(`${apiUrl}/jobapplications`, { headers, data: { companyId: company.id, jobTitle: 'Responsive reviewed letter', status: 'Saved' } });
|
|
expect(created.status()).toBe(201);
|
|
const job = await created.json();
|
|
const letterUrl = `${apiUrl}/jobapplications/${job.id}/cover-letter`;
|
|
const original = await (await context.request.get(letterUrl)).json();
|
|
const savedResponse = await context.request.put(letterUrl, { headers, data: { text: 'Original reviewed letter — æøå.', source: 'manual', expectedVersion: original.revision } });
|
|
expect(savedResponse.status()).toBe(200);
|
|
const saved = await (await context.request.get(letterUrl)).json();
|
|
const sourceBefore = await (await context.request.get(`${apiUrl}/career/profile`)).json();
|
|
const scenarios = [
|
|
[320, 'en', 'light'], [390, 'nb', 'dark'], [768, 'en', 'dark'], [899, 'nb', 'light'],
|
|
[900, 'en', 'light'], [1024, 'nb', 'dark'], [1440, 'nb', 'light'],
|
|
] as const;
|
|
for (const [width, language, mode] of scenarios) {
|
|
const t = translations[language];
|
|
expect((await context.request.put(`${apiUrl}/auth/preferences/language`, { headers, data: { language: language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204);
|
|
const page = await context.newPage();
|
|
await page.setViewportSize({ width, height: 900 });
|
|
const forbiddenRequests: string[] = [];
|
|
page.on('request', request => {
|
|
const pathname = new URL(request.url()).pathname;
|
|
if (request.method() === 'POST' && (pathname.endsWith('/ai/generate') || pathname.endsWith('/cover-letter/export-pdf'))
|
|
|| request.method() === 'PUT' && pathname === new URL(letterUrl).pathname) forbiddenRequests.push(`${request.method()} ${pathname}`);
|
|
});
|
|
try {
|
|
await page.goto(`/jobs/${job.id}?section=application&sub=letter&responsiveMode=${mode}&responsiveLanguage=${language}#materials`);
|
|
await expect(page.locator('html')).toHaveAttribute('lang', language === 'nb' ? 'nb-NO' : 'en');
|
|
await expect(page.locator('html')).toHaveAttribute('data-color-scheme', mode);
|
|
const workspace = page.getByRole('group', { name: t.workspaceCoverLetter, exact: true });
|
|
const body = workspace.getByRole('textbox', { name: t.jobDetailsCoverLetter, exact: true });
|
|
await expect(body).toHaveValue(saved.text);
|
|
const draft = `${width} ${language === 'nb' ? 'Ulagret søknadsbrev med gjennomgåtte fakta' : 'Unsaved letter with reviewed facts'} — æøå.`;
|
|
await body.focus(); await body.press('ControlOrMeta+A'); await body.pressSequentially(draft);
|
|
await expect(body).toHaveValue(draft);
|
|
const controls = [t.cvAiImprove, t.cvAiSuggestContent, t.cvAiGrammarCheck, t.cvAiShorter];
|
|
const bodyRect = await body.boundingBox();
|
|
expect(bodyRect).not.toBeNull();
|
|
for (const name of controls) {
|
|
const control = workspace.getByRole('button', { name, exact: true });
|
|
await expect(control).toBeVisible(); await expect(control).toBeDisabled();
|
|
const rect = await control.boundingBox(); expect(rect).not.toBeNull();
|
|
expect(rect!.y - (bodyRect!.y + bodyRect!.height)).toBeGreaterThanOrEqual(-2);
|
|
expect(rect!.y - (bodyRect!.y + bodyRect!.height)).toBeLessThan(240);
|
|
expect(rect!.x).toBeGreaterThanOrEqual(0); expect(rect!.x + rect!.width).toBeLessThanOrEqual(width + 1);
|
|
}
|
|
const geometry = await page.evaluate(() => {
|
|
const main = document.querySelector('main')!;
|
|
return { root: document.documentElement.scrollWidth - innerWidth, app: main.scrollWidth - main.clientWidth };
|
|
});
|
|
expect(geometry.root).toBeLessThanOrEqual(1); expect(geometry.app).toBeLessThanOrEqual(1);
|
|
const writePane = workspace.locator('[id$="-write-panel"]');
|
|
const previewPane = workspace.locator('[id$="-preview-panel"]');
|
|
if (width < 1024) {
|
|
const tabs = workspace.getByRole('tablist', { name: t.assetsLetterWorkspace, exact: true });
|
|
const write = tabs.getByRole('tab', { name: t.assetsLetterWrite, exact: true });
|
|
const preview = tabs.getByRole('tab', { name: t.assetsLetterPreview, exact: true });
|
|
await expect(previewPane).not.toBeVisible();
|
|
await write.focus(); await page.keyboard.press('ArrowRight'); await expect(preview).toBeFocused(); await page.keyboard.press('Enter');
|
|
await expect(preview).toHaveAttribute('aria-selected', 'true');
|
|
await expect(writePane).not.toBeVisible(); await expect(previewPane).toBeVisible();
|
|
await expect(workspace.getByRole('textbox', { name: t.jobDetailsCoverLetter, exact: true })).toHaveCount(0);
|
|
for (let step = 0; step < 8; step++) {
|
|
await page.keyboard.press('Tab');
|
|
expect(await writePane.evaluate(node => node.contains(document.activeElement))).toBe(false);
|
|
}
|
|
await preview.focus(); await page.keyboard.press('ArrowLeft'); await expect(write).toBeFocused(); await page.keyboard.press('Enter');
|
|
await expect(write).toHaveAttribute('aria-selected', 'true');
|
|
await expect(previewPane).not.toBeVisible(); await expect(body).toHaveValue(draft);
|
|
await page.keyboard.press('Tab');
|
|
expect(await previewPane.evaluate(node => node.contains(document.activeElement))).toBe(false);
|
|
} else {
|
|
await expect(workspace.getByRole('tablist', { name: t.assetsLetterWorkspace, exact: true })).toHaveCount(0);
|
|
await expect(writePane).toBeVisible(); await expect(previewPane).toBeVisible();
|
|
}
|
|
for (const name of [t.save, t.letterExportPdf]) {
|
|
const control = workspace.getByRole('button', { name, exact: true });
|
|
await expect(control).toBeEnabled(); await control.scrollIntoViewIfNeeded(); await expect(control).toBeInViewport();
|
|
await control.focus(); await expect(control).toBeFocused();
|
|
const rect = await control.boundingBox(); expect(rect).not.toBeNull();
|
|
expect(rect!.x).toBeGreaterThanOrEqual(0); expect(rect!.x + rect!.width).toBeLessThanOrEqual(width + 1);
|
|
expect(await control.evaluate(node => {
|
|
const rect = node.getBoundingClientRect();
|
|
return node.contains(document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2));
|
|
})).toBe(true);
|
|
}
|
|
await expect(body).toHaveValue(draft);
|
|
expect(forbiddenRequests).toEqual([]);
|
|
expect(await (await context.request.get(letterUrl)).json()).toEqual(saved);
|
|
await page.screenshot({ path: info.outputPath(`letter-responsive-${width}-${language}-${mode}.png`), fullPage: true, animations: 'disabled' });
|
|
await info.attach(`letter-responsive-${width}`, { body: JSON.stringify({ width, language, mode, geometry, draft, unchangedRevision: saved.revision, forbiddenRequests }), contentType: 'application/json' });
|
|
} finally { await page.close(); }
|
|
}
|
|
expect(await (await context.request.get(`${apiUrl}/career/profile`)).json()).toEqual(sourceBefore);
|
|
});
|