Files
jobtrackingapp/job-tracker-ui/e2e/cv-settings-presentation.spec.ts
T

181 lines
18 KiB
TypeScript

import { expect, test, type APIRequestContext, type Browser, type Page, type TestInfo } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
import fs from 'node:fs/promises';
import path from 'node:path';
import { translations } from '../src/i18n/translations';
const apiUrl = 'http://localhost:5302/api';
const password = 'E2ePassword123!';
const output = path.resolve(process.env.CV_SETTINGS_FIDELITY_OUTPUT || '../.artifacts/p5-12/settings-fidelity');
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 };
}
async function capture(page: Page, info: TestInfo, name: string) {
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
await page.screenshot({ path: info.outputPath(`${name}.png`), animations: 'disabled' });
await info.attach(name, { path: info.outputPath(`${name}.png`), contentType: 'image/png' });
}
async function scan(page: Page, info: TestInfo, name: string) {
const result = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
await info.attach(`${name}-axe`, { body: JSON.stringify({ violations: result.violations, incomplete: result.incomplete }), contentType: 'application/json' });
expect(result.violations).toEqual([]);
}
async function choose(page: Page, label: string, option: string) {
const control = page.getByRole('combobox', { name: label, exact: true });
await control.focus(); await page.keyboard.press('Space');
await page.getByRole('option', { name: option, exact: true }).focus();
await page.keyboard.press('Enter');
await expect(page.getByRole('listbox')).toHaveCount(0);
await expect(control).toBeFocused();
}
async function measure(page: Page, browser: Browser, info: TestInfo, cvUrl: string, language: 'en' | 'nb', fixture: string) {
const t = translations[language];
const iframe = page.getByTitle(t.cvEditorPreviewTitle, { exact: true });
await expect(page.getByRole('region', { name: t.cvEditorLivePreview, exact: true })).toHaveAttribute('aria-busy', 'false');
await expect.poll(() => iframe.evaluate(element => (element as HTMLIFrameElement).contentDocument?.documentElement.dataset.cvPreviewReady)).toBe('true');
const measured = await iframe.evaluate(async element => {
const frame = element as HTMLIFrameElement; const doc = frame.contentDocument!; const win = frame.contentWindow!;
await doc.fonts.ready;
await new Promise<void>(resolve => win.requestAnimationFrame(() => win.requestAnimationFrame(() => resolve())));
const width = Number(doc.documentElement.dataset.cvPreviewWidth); const height = Number(doc.documentElement.dataset.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(doc.documentElement.dataset.cvPreviewPages), effectiveMargin: win.getComputedStyle(doc.documentElement).getPropertyValue('--cv-page-margin'), layoutDurationMs: Number(doc.documentElement.dataset.cvPreviewDurationMs), text: doc.body.innerText, tokens };
});
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 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);
await fs.writeFile(path.join(directory, 'export.pdf'), await pdf.body());
const rendered = await (await page.request.get(`${cvUrl}/preview`)).json();
await fs.writeFile(path.join(directory, 'render.html'), rendered.html);
const pages = await page.locator('[data-cv-page]').count();
expect(measured.contentHeight / measured.height).toBe(pages);
expect(measured.text).not.toContain('HIDDEN-MARKER');
for (let index = 1; index <= pages; index++) {
const html = await page.getByTitle(t.cvEditorPreviewPage.replace('{page}', String(index)), { exact: true }).getAttribute('srcdoc');
const capturePage = await browser.newPage({ viewport: { width: Math.ceil(measured.width), height: Math.ceil(measured.height) } });
try {
await capturePage.setContent(html!);
await expect.poll(() => capturePage.evaluate(() => document.documentElement.dataset.cvPreviewReady)).toBe('true');
await capturePage.screenshot({ path: path.join(directory, `preview-${index}.png`) });
} finally { await capturePage.close(); }
}
await info.attach(fixture, { body: JSON.stringify({ fixture, previewPages: pages, settings: variant.settings, measured }), contentType: 'application/json' });
return { fixture, previewPages: pages, settings: variant.settings, measured };
}
for (const [mode, language, premium] of [['light', 'en', false], ['dark', 'nb', true], ['light', 'nb', false], ['dark', 'en', true]] as const) {
test(`[PRES-C06 C08 C09 C10 C12 E08 E09] ${mode}/${language} document controls preserve settings, source, history and native output`, async ({ page, context, playwright, browser }, info) => {
test.setTimeout(240_000);
const t = translations[language];
const admin = await playwright.request.newContext(); const adminHeaders = await login(admin, 'e2e@example.test');
const email = `p512-${Date.now()}-${mode}-${language}@example.test`;
expect((await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email, password, roles: premium ? ['Premium'] : [] } })).status()).toBe(200);
await admin.dispose(); const headers = await login(page.request, email);
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(({ mode, language }) => { localStorage.setItem('jobtracker.themeMode', mode); localStorage.setItem('uiLanguage', language); }, { mode, language });
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers, data: { language: language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204);
const sentence = language === 'nb' ? 'Dokumenterte pålitelige arbeidsflyter og tilgjengelig navigasjon for norske arbeidssøkere. ' : 'Documented reliable workflows and accessible navigation for people managing their careers. ';
const profile = { version: '1', contact: { fullName: 'Åse Fixture', headline: 'Verified career information', location: 'Oslo, Norge' }, summary: ['R01 Verified source content — æøå.'],
jobs: Array.from({ length: 5 }, (_, index) => ({ id: `job-${index}`, title: `E${String(index + 1).padStart(2, '0')} Plattformutvikler og dokumenterte integrasjoner`, company: 'Nordlys Fixture AS', startDate: '2020-01', endDate: '2025-01', bullets: Array.from({ length: 3 }, (_, bullet) => `B${String(index * 3 + bullet + 1).padStart(2, '0')} ${sentence.repeat(5)}`) })),
education: [{ id: 'edu-1', qualification: 'E08 Computer science', institution: 'Fixture University', startDate: '2016-08', endDate: '2020-06' }], skills: ['React', 'C#', 'Norsk'], interests: ['HIDDEN-MARKER Not included'] };
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { profile } })).status()).toBe(200);
const source = await (await page.request.get(`${apiUrl}/career/profile`)).json();
const created = await page.request.post(`${apiUrl}/cv/variants`, { headers, data: { name: 'Dokumenterte kvalifikasjoner — innstillinger for denne CV-en', settings: {
themeId: 'minimal', pageMarginMm: 16, baseFontSizePt: 10, bodyFont: 'Arial, Helvetica, sans-serif', accentColor: '#126b55', language: language === 'nb' ? 'nb-NO' : 'en',
sections: [{ key: 'summary' }, { key: 'experience' }, { key: 'education' }, { key: 'skills' }, { key: 'interests', hidden: true }], overrides: {}, customSections: [],
} } }); expect(created.status()).toBe(200); const cv = await created.json(); const cvUrl = `${apiUrl}/cv/variants/${cv.id}`;
const sibling = await (await page.request.post(`${apiUrl}/cv/variants`, { headers, data: { name: 'Untouched sibling', settings: cv.settings } })).json();
const read = async () => (await page.request.get(cvUrl)).json();
const saved = async () => { await expect(page.getByText(t.cvEditorSaved, { exact: true })).toBeVisible(); };
await page.setViewportSize({ width: 1440, height: 900 }); await page.goto(`/career/builder/${cv.id}`);
await expect(page.getByLabel(t.cvEditorName, { exact: true })).toHaveValue(cv.name);
const before = await measure(page, browser, info, cvUrl, language, `${mode}-${language}-before`);
await page.getByRole('tab', { name: t.cvEditorCustomize, exact: true }).click();
const advanced = page.getByRole('button', { name: t.cvEditorAdvancedSettings, exact: true });
await expect(advanced).toHaveAttribute('aria-expanded', 'false');
await expect(page.getByRole('region', { name: t.cvEditorStyle, exact: true })).toBeVisible();
await expect(page.getByRole('combobox', { name: t.cvEditorHeadingFont, exact: true })).toHaveText(t.cvEditorTemplateDefault);
await capture(page, info, 'default-controls'); await scan(page, info, 'default-controls');
await advanced.focus(); await page.keyboard.press('Enter'); await expect(advanced).toHaveAttribute('aria-expanded', 'true');
expect((await read()).version).toBe(cv.version);
const margin = page.getByRole('spinbutton', { name: t.cvEditorPageMargins, exact: true });
await margin.fill('29'); await expect(margin).toHaveAttribute('aria-invalid', 'true'); expect((await read()).settings.pageMarginMm).toBe(16);
await capture(page, info, 'invalid-margin'); await margin.fill('22'); await saved(); await expect.poll(async () => (await read()).settings.pageMarginMm).toBe(22);
await choose(page, t.cvEditorPageSize, t.cvEditorUsLetter);
await choose(page, t.cvEditorBodyFont, 'Georgia (serif)');
await page.getByRole('spinbutton', { name: t.cvEditorBodySize, exact: true }).fill('11');
await page.getByRole('spinbutton', { name: t.cvEditorSectionSpacing, exact: true }).fill('8');
await page.getByRole('spinbutton', { name: t.cvEditorEntrySpacing, exact: true }).fill('5');
await page.getByRole('spinbutton', { name: t.cvEditorLineHeight, exact: true }).fill('1.5');
const colour = page.getByLabel(t.cvEditorCustomAccent, { exact: true }); await colour.fill('#9f1239'); await colour.focus(); await page.keyboard.press('Tab');
await choose(page, t.cvEditorDateFormat, '2020'); await saved();
await expect.poll(async () => (await read()).settings).toMatchObject({ pageSize: 'letter', pageMarginMm: 22, bodyFont: "Georgia, 'Times New Roman', serif", baseFontSizePt: 11, sectionGapMm: 8, entryGapMm: 5, lineHeight: 1.5, accentColor: '#9f1239', dateFormat: 'year' });
const after = await measure(page, browser, info, cvUrl, language, `${mode}-${language}-after`);
expect(before.measured.width * 25.4 / 96).toBeCloseTo(210, 2); expect(after.measured.width * 25.4 / 96).toBeCloseTo(215.9, 2);
expect(after.measured.height * 25.4 / 96).toBeCloseTo(279.4, 2); expect(after.measured.effectiveMargin.trim()).toBe('22mm');
expect(after.previewPages).toBeGreaterThan(1); expect(after.measured.tokens.find(token => token.token === 'R01')!.fontFamily).toContain('Georgia');
expect(after.measured.tokens.find(token => token.token === 'R01')!.fontSize).not.toBe(before.measured.tokens.find(token => token.token === 'R01')!.fontSize);
await fs.mkdir(output, { recursive: true });
await fs.writeFile(path.join(output, `${mode}-${language}-measurements.json`), JSON.stringify({ browserVersion: browser.version(), fixtures: [before, after] }, null, 2));
const changed = await read(); await page.reload(); await page.getByRole('tab', { name: t.cvEditorCustomize, exact: true }).click();
await expect(margin).toHaveValue('22');
// Reload starts the asynchronous preview layout again. Use its existing settled
// state before pointer activation, so layout movement cannot select another field.
await expect(page.getByRole('region', { name: t.cvEditorLivePreview, exact: true })).toHaveAttribute('aria-busy', 'false');
await expect.poll(() => page.getByTitle(t.cvEditorPreviewTitle, { exact: true }).evaluate(element =>
(element as HTMLIFrameElement).contentDocument?.documentElement.dataset.cvPreviewReady)).toBe('true');
await page.getByRole('button', { name: t.cvEditorUseTemplateSetting.replace('{setting}', t.cvEditorPageMargins), exact: true }).click(); await saved();
await expect.poll(async () => (await read()).settings.pageMarginMm).toBeNull(); await page.getByRole('button', { name: t.cvEditorUndo, exact: true }).click(); await saved(); await expect(margin).toHaveValue('22');
await expect.poll(async () => (await read()).settings.pageMarginMm).toBe(22);
await advanced.click();
for (const width of [1440, 768, 320]) {
await page.setViewportSize({ width, height: 900 }); await margin.focus(); await expect(margin).toBeFocused();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
await capture(page, info, `controls-${width}`);
await advanced.scrollIntoViewIfNeeded(); await expect(advanced).toBeVisible(); await capture(page, info, `advanced-${width}`);
}
await scan(page, info, 'phone-advanced');
await page.setViewportSize({ width: 1440, height: 900 }); await page.getByRole('tab', { name: t.cvEditorTemplate, exact: true }).click();
await page.getByRole('button', { name: t.cvEditorAllTemplates, exact: true }).click();
const technical = page.getByRole('button', { name: 'Technical', exact: true }); await expect(technical).toHaveAttribute('aria-disabled', premium ? 'false' : 'true');
await expect(technical).toHaveAccessibleDescription(new RegExp(premium ? t.cvEditorProUnlocked : t.cvEditorPro));
if (!premium) { await technical.click({ force: true }); await technical.focus(); await page.keyboard.press('Enter'); expect((await read()).settings.themeId).toBe('minimal'); await expect(page.getByRole('dialog')).toHaveCount(0); }
await capture(page, info, 'templates'); await scan(page, info, 'templates');
const target = premium ? technical : page.getByRole('button', { name: 'Code', 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');
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 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();
expect(selected.settings.sections).toEqual(changed.settings.sections); expect(selected.settings.pageSize).toBe('letter');
await page.getByRole('tab', { name: t.cvEditorCustomize, exact: true }).click(); await advanced.click();
if (premium) { await expect(page.getByRole('spinbutton', { name: t.cvEditorSidebarWidth, exact: true })).toBeVisible(); await page.getByRole('spinbutton', { name: t.cvEditorSidebarWidth, exact: true }).fill('68'); await saved(); await expect.poll(async () => (await read()).settings.sidebarWidthMm).toBe(68); }
await capture(page, info, 'selected-template-defaults');
expect(await (await page.request.get(`${apiUrl}/career/profile`)).json()).toEqual(source);
expect(await (await page.request.get(`${apiUrl}/cv/variants/${sibling.id}`)).json()).toEqual(sibling);
const beforeRestore = await read();
await page.getByRole('tab', { name: t.cvEditorHistory, exact: true }).click();
await page.getByRole('tabpanel', { name: t.cvEditorHistory, exact: true }).getByRole('button', { name: t.cvEditorRestoreTitle.replace('{version}', '1'), exact: true }).click();
await page.getByRole('dialog').getByRole('button', { name: t.cvEditorRestoreVersion, exact: true }).click(); await saved();
await expect.poll(async () => (await read()).version).toBe(beforeRestore.version + 1);
expect((await read()).settings).toEqual(cv.settings);
expect(await (await page.request.get(`${apiUrl}/career/profile`)).json()).toEqual(source);
});
}