241 lines
20 KiB
TypeScript
241 lines
20 KiB
TypeScript
import AxeBuilder from '@axe-core/playwright';
|
|
import { expect, test, type APIRequestContext, type Locator, type Page, type TestInfo } from '@playwright/test';
|
|
import type { CvVariant } from '../src/cvBuilder';
|
|
import { translations } from '../src/i18n/translations';
|
|
|
|
const apiUrl = 'http://localhost:5302/api';
|
|
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 };
|
|
}
|
|
async function capture(scope: Locator, info: TestInfo, state: string) {
|
|
const path = info.outputPath(`${state}.png`);
|
|
const geometry = await scope.evaluate(node => {
|
|
const region = node.querySelector('[role="region"]')!;
|
|
const bounds = (element: Element) => { const rect = element.getBoundingClientRect(); return { top: rect.top, bottom: rect.bottom, width: rect.width, height: rect.height }; };
|
|
return { viewport: { width: innerWidth, height: innerHeight }, chrome: bounds(node), region: bounds(region), scrollTop: region.scrollTop, focused: region === document.activeElement };
|
|
});
|
|
await scope.page().screenshot({ path, animations: 'disabled' });
|
|
await info.attach(state, { path, contentType: 'image/png' });
|
|
await info.attach(`${state}-viewport`, { body: JSON.stringify(geometry), contentType: 'application/json' });
|
|
}
|
|
async function scan(page: Page, info: TestInfo, state: string, region: string) {
|
|
const result = await new AxeBuilder({ page }).include(`.MuiPaper-root:has(> [role="region"][aria-label=${JSON.stringify(region)}])`).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
|
|
await info.attach(`${state}-axe`, { body: JSON.stringify({ violations: result.violations, incomplete: result.incomplete }), contentType: 'application/json' });
|
|
expect(result.violations).toEqual([]);
|
|
}
|
|
|
|
for (const [mode, language] of [['light', 'en'], ['dark', 'nb']] as const) {
|
|
test(`[PRES-C06 C08 C09 C10 C12 E08 E09] ${mode}/${language} native preview chrome, auto-fit, long pages and recovery`, async ({ page, context, playwright }, info) => {
|
|
test.setTimeout(300_000);
|
|
const t = translations[language];
|
|
const admin = await playwright.request.newContext();
|
|
try {
|
|
const adminHeaders = await login(admin, 'e2e@example.test');
|
|
const email = `p511-${Date.now()}-${mode}-${language}@example.test`;
|
|
expect((await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email, password, roles: [] } })).status()).toBe(200);
|
|
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 });
|
|
await page.emulateMedia({ reducedMotion: 'reduce' });
|
|
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers, data: { language: language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204);
|
|
const sentence = language === 'nb'
|
|
? 'Utviklet pålitelige arbeidsflyter med tilgjengelig navigasjon, dokumentert kildegrunnlag og tydelige vurderinger for norske arbeidssøkere.'
|
|
: 'Developed reliable workflows with accessible navigation, documented source evidence and explicit review for people managing their careers.';
|
|
const shortProfile = { version: '1', contact: { fullName: 'Åse Eksempel', headline: 'Canonical engineer — æøå', location: 'Oslo, Norge' }, summary: ['Documented career evidence.'], skills: ['C#', 'React', 'Norsk'] };
|
|
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { profile: shortProfile } })).status()).toBe(200);
|
|
const create = async (name: string, themeId: string, customItems: string[] = []): Promise<CvVariant> => {
|
|
const response = await page.request.post(`${apiUrl}/cv/variants`, { headers, data: { name, settings: {
|
|
themeId, language, sections: [{ key: 'summary' }, { key: 'experience' }, { key: 'skills' }, ...(customItems.length ? [{ key: 'custom:preview-evidence' }] : [])],
|
|
customSections: customItems.length ? [{ key: 'preview-evidence', title: 'Documented CV contributions — æøå', contentType: 'bullets', items: customItems }] : [],
|
|
} } });
|
|
expect(response.status()).toBe(200); return response.json();
|
|
};
|
|
const onePage = await create('One-page Code fixture — æøå', 'code');
|
|
const multiple = await create('Multi-page preview fixture — æøå', 'minimal', Array.from({ length: 45 }, (_, index) => `Custom contribution ${index + 1}: ${sentence}`));
|
|
const read = async (id: number): Promise<CvVariant> => (await page.request.get(`${apiUrl}/cv/variants/${id}`)).json();
|
|
const region = page.getByRole('region', { name: t.cvEditorLivePreview, exact: true });
|
|
const chrome = region.locator('..');
|
|
const editing = page.getByRole('region', { name: t.cvEditorEditingRegion, exact: true });
|
|
const measurement = page.getByTitle(t.cvEditorPreviewTitle, { exact: true });
|
|
const pages = region.locator('[data-cv-page]');
|
|
const frames = pages.locator('iframe');
|
|
const firstFrame = page.getByTitle(t.cvEditorPreviewPage.replace('{page}', '1'), { exact: true });
|
|
const firstDocument = page.frameLocator(`iframe[title=${JSON.stringify(t.cvEditorPreviewPage.replace('{page}', '1'))}]`);
|
|
const saved = async () => { await expect(page.getByText(t.cvEditorSaved, { exact: true })).toBeVisible(); };
|
|
const measurements: Record<string, unknown>[] = [];
|
|
const settle = async (state: string, minimum = 1) => {
|
|
await expect(region).toHaveAttribute('aria-busy', 'false');
|
|
await expect.poll(() => measurement.evaluate(element => (element as HTMLIFrameElement).contentDocument?.documentElement.dataset.cvPreviewReady)).toBe('true');
|
|
const native = await measurement.evaluate(element => {
|
|
const data = (element as HTMLIFrameElement).contentDocument!.documentElement.dataset;
|
|
return { pages: Number(data.cvPreviewPages), width: Number(data.cvPreviewWidth), height: Number(data.cvPreviewHeight), usableHeight: Number(data.cvPreviewUsableHeight), layoutDurationMs: Number(data.cvPreviewDurationMs), overflow: data.cvPreviewOverflow === 'true' };
|
|
});
|
|
expect(native.pages).toBeGreaterThanOrEqual(minimum);
|
|
await expect(pages).toHaveCount(native.pages);
|
|
await expect.poll(() => frames.evaluateAll(elements => elements.every(element => (element as HTMLIFrameElement).contentDocument?.documentElement.dataset.cvPreviewReady === 'true'))).toBe(true);
|
|
await expect(chrome.getByText(t.cvEditorPageCount.replace('{count}', String(native.pages)), { exact: true })).toBeVisible();
|
|
expect(Number.isFinite(native.layoutDurationMs)).toBe(true); expect(native.layoutDurationMs).toBeGreaterThanOrEqual(0);
|
|
expect(native.width).toBeCloseTo(210 * 96 / 25.4, 0); expect(native.height).toBeCloseTo(297 * 96 / 25.4, 0);
|
|
const titles = await frames.evaluateAll(elements => elements.map(element => element.getAttribute('title')));
|
|
expect(titles).toEqual(Array.from({ length: native.pages }, (_, index) => t.cvEditorPreviewPage.replace('{page}', String(index + 1))));
|
|
measurements.push({ state, ...native }); return native;
|
|
};
|
|
const enterPreview = async () => {
|
|
const desktopEntry = page.getByRole('button', { name: t.cvEditorFocusPreview, exact: true });
|
|
if (await desktopEntry.isVisible()) {
|
|
await desktopEntry.focus(); await page.keyboard.press('Enter');
|
|
} else {
|
|
await page.getByRole('button', { name: t.cvEditorPreview, exact: true }).press('Enter');
|
|
await chrome.getByRole('button', { name: t.cvEditorFocusEditor, exact: true }).focus();
|
|
await page.keyboard.press('Tab');
|
|
}
|
|
await expect(region).toBeFocused(); await expect(region).toBeVisible();
|
|
};
|
|
const returnEditor = async (draft: string) => {
|
|
await expect(region).toBeFocused(); await page.keyboard.press('Shift+Tab');
|
|
await expect(chrome.getByRole('button', { name: t.cvEditorFocusEditor, exact: true })).toBeFocused();
|
|
await page.keyboard.press('Enter');
|
|
await expect(editing).toBeFocused(); await expect(editing).toHaveAttribute('tabindex', '-1');
|
|
await expect(page.getByLabel(t.cvEditorHeadlineOverride, { exact: true })).toHaveValue(draft);
|
|
};
|
|
const autoFit = async (state: string, widths: number[]) => {
|
|
const originalSources = await frames.evaluateAll(elements => elements.map(element => element.getAttribute('srcdoc')));
|
|
const originalFrames = await frames.elementHandles();
|
|
const originalMeasurement = await measurement.getAttribute('srcdoc');
|
|
for (const width of widths) {
|
|
await enterPreview(); await page.setViewportSize({ width, height: width < 900 ? 844 : 900 });
|
|
await expect(region).toBeFocused(); await expect(region).toBeVisible();
|
|
const available = await region.evaluate(node => node.clientWidth);
|
|
const expectedScale = Math.min(1, Math.max(0.2, (available - 16) / (210 * 96 / 25.4)));
|
|
await expect.poll(() => firstFrame.evaluate(element => new DOMMatrixReadOnly(getComputedStyle(element).transform).a)).toBeCloseTo(expectedScale, 3);
|
|
const scale = await firstFrame.evaluate(element => new DOMMatrixReadOnly(getComputedStyle(element).transform).a);
|
|
await expect(chrome.getByText(t.cvEditorPreviewScale.replace('{percent}', String(Math.round(scale * 100))), { exact: true })).toBeVisible();
|
|
expect(await frames.evaluateAll(elements => elements.map(element => element.getAttribute('srcdoc')))).toEqual(originalSources);
|
|
expect(await measurement.getAttribute('srcdoc')).toBe(originalMeasurement);
|
|
for (let index = 0; index < originalFrames.length; index++) expect(await frames.nth(index).evaluate((node, original) => node === original, originalFrames[index])).toBe(true);
|
|
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
|
|
await capture(chrome, info, `${state}-fit-${width}`);
|
|
}
|
|
for (const handle of originalFrames) await handle.dispose();
|
|
};
|
|
const bottomSnapshot = async () => {
|
|
await enterPreview(); await page.keyboard.press('Control+End');
|
|
await expect.poll(() => region.evaluate(node => Math.abs(node.scrollHeight - node.clientHeight - node.scrollTop))).toBeLessThan(3);
|
|
return { scroll: await region.evaluate(node => ({ top: node.scrollTop, height: node.scrollHeight })),
|
|
sources: await frames.evaluateAll(elements => elements.map(element => element.getAttribute('srcdoc'))), handles: await frames.elementHandles() };
|
|
};
|
|
const retainedBottom = async (snapshot: Awaited<ReturnType<typeof bottomSnapshot>>, state: string) => {
|
|
const current = await region.evaluate(node => ({ top: node.scrollTop, height: node.scrollHeight }));
|
|
await info.attach(`${state}-scroll-retention`, { body: JSON.stringify({ before: snapshot.scroll, after: current }), contentType: 'application/json' });
|
|
expect(Math.abs(current.top - snapshot.scroll.top), `${state}: retained scroll position`).toBeLessThanOrEqual(1);
|
|
expect(Math.abs(current.height - snapshot.scroll.height), `${state}: retained scroll height`).toBeLessThanOrEqual(1);
|
|
expect(await frames.evaluateAll(elements => elements.map(element => element.getAttribute('srcdoc')))).toEqual(snapshot.sources);
|
|
for (let index = 0; index < snapshot.handles.length; index++) expect(await frames.nth(index).evaluate((node, original) => node === original, snapshot.handles[index])).toBe(true);
|
|
};
|
|
|
|
await page.setViewportSize({ width: 1440, height: 900 });
|
|
await page.goto(`/career/builder/${onePage.id}`);
|
|
await expect(page.getByLabel(t.cvEditorName, { exact: true })).toHaveValue(onePage.name);
|
|
expect((await settle('one-page-code')).pages).toBe(1);
|
|
await capture(chrome, info, 'one-page-code-default');
|
|
await enterPreview(); await capture(chrome, info, 'one-page-code-focus');
|
|
await autoFit('one-page-code', [1440, 768, 320]);
|
|
await scan(page, info, 'one-page-phone-preview', t.cvEditorLivePreview);
|
|
await returnEditor(''); expect(await read(onePage.id)).toEqual(onePage);
|
|
|
|
await page.setViewportSize({ width: 1440, height: 900 });
|
|
await page.goto(`/career/builder/${multiple.id}`);
|
|
await expect(page.getByLabel(t.cvEditorName, { exact: true })).toHaveValue(multiple.name);
|
|
await settle('multiple-pages', 2); await enterPreview();
|
|
await expect(region).toBeFocused();
|
|
const pageDownSettled = region.evaluate(node => new Promise<void>(resolve => node.addEventListener('scrollend', () => resolve(), { once: true })));
|
|
await page.keyboard.press('PageDown');
|
|
await pageDownSettled;
|
|
await expect.poll(() => region.evaluate(node => node.scrollTop)).toBeGreaterThan(100);
|
|
await expect(region).toBeFocused(); await page.keyboard.press('Control+End');
|
|
await expect.poll(() => region.evaluate(node => Math.abs(node.scrollHeight - node.clientHeight - node.scrollTop))).toBeLessThan(3);
|
|
await capture(chrome, info, 'multiple-pages-end');
|
|
await page.keyboard.press('Control+Home'); await expect.poll(() => region.evaluate(node => node.scrollTop)).toBe(0);
|
|
await capture(chrome, info, 'multiple-pages-start');
|
|
await returnEditor(''); expect(await read(multiple.id)).toEqual(multiple);
|
|
|
|
const longProfile = { ...shortProfile, jobs: Array.from({ length: 3 }, (_, index) => ({ id: `long-job-${index + 1}`, title: `Source-grounded engineer ${index + 1} — æøå`, company: 'Nordlys Fixture AS', startDate: '2017-01', endDate: '2025-12',
|
|
bullets: Array.from({ length: 30 }, (_, bullet) => `Source contribution ${index * 30 + bullet + 1}: ${sentence} ${sentence}`) })) };
|
|
expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { profile: longProfile } })).status()).toBe(200);
|
|
const source = await (await page.request.get(`${apiUrl}/career/profile`)).json();
|
|
const veryLong = await create('Very long source and custom CV — æøå', 'minimal', Array.from({ length: 45 }, (_, index) => `CV-only long contribution ${index + 1}: ${sentence} ${sentence}`));
|
|
const unchanged = async () => {
|
|
expect(await (await page.request.get(`${apiUrl}/career/profile`)).json()).toEqual(source);
|
|
expect(await read(onePage.id)).toEqual(onePage); expect(await read(multiple.id)).toEqual(multiple);
|
|
};
|
|
await page.goto(`/career/builder/${veryLong.id}`);
|
|
await expect(page.getByLabel(t.cvEditorName, { exact: true })).toHaveValue(veryLong.name);
|
|
const longMeasurement = await settle('very-long-source-and-custom', 5);
|
|
await autoFit('very-long', [1440, 768, 320]);
|
|
await scan(page, info, 'very-long-phone-preview', t.cvEditorLivePreview);
|
|
await page.keyboard.press('Control+End');
|
|
await expect.poll(() => region.evaluate(node => Math.abs(node.scrollHeight - node.clientHeight - node.scrollTop))).toBeLessThan(3);
|
|
await capture(chrome, info, 'very-long-phone-last-page');
|
|
await page.keyboard.press('Control+Home'); await expect.poll(() => region.evaluate(node => node.scrollTop)).toBe(0);
|
|
await returnEditor(''); expect(await read(veryLong.id)).toEqual(veryLong); await unchanged();
|
|
|
|
await page.setViewportSize({ width: 1440, height: 900 });
|
|
const heldBottom = await bottomSnapshot();
|
|
await returnEditor('');
|
|
let release!: () => void;
|
|
const hold = new Promise<void>(resolve => { release = resolve; });
|
|
let pending = false;
|
|
const lastGood = await firstFrame.getAttribute('srcdoc');
|
|
await page.route(`${apiUrl}/cv/preview`, async route => { pending = true; await hold; await route.continue(); }, { times: 1 });
|
|
const realRender = page.waitForResponse(response => response.url() === `${apiUrl}/cv/preview` && response.request().method() === 'POST');
|
|
const headline = page.getByLabel(t.cvEditorHeadlineOverride, { exact: true });
|
|
try {
|
|
await headline.fill('Preview draft retained — æøå');
|
|
await expect.poll(() => pending).toBe(true);
|
|
await expect(region).toHaveAttribute('aria-busy', 'true');
|
|
await enterPreview(); await expect(chrome.getByText(t.cvEditorUpdatingPreview, { exact: true })).toBeVisible();
|
|
await retainedBottom(heldBottom, 'held-request');
|
|
expect(await firstFrame.getAttribute('srcdoc')).toBe(lastGood); await expect(pages).toHaveCount(longMeasurement.pages);
|
|
await capture(chrome, info, 'loading-retains-last-good-pages');
|
|
await returnEditor('Preview draft retained — æøå');
|
|
} finally { release(); await realRender; for (const handle of heldBottom.handles) await handle.dispose(); }
|
|
expect((await realRender).status()).toBe(200);
|
|
await settle('native-render-after-held-request', 5);
|
|
await expect(firstDocument.getByText('Preview draft retained — æøå', { exact: true })).toBeVisible();
|
|
await expect.poll(async () => (await read(veryLong.id)).settings.headline).toBe('Preview draft retained — æøå'); await saved();
|
|
|
|
const failedBottom = await bottomSnapshot();
|
|
await returnEditor('Preview draft retained — æøå');
|
|
const goodSources = await frames.evaluateAll(elements => elements.map(element => element.getAttribute('srcdoc')));
|
|
const goodCount = await pages.count();
|
|
await page.route(`${apiUrl}/cv/preview`, route => route.abort('failed'), { times: 1 });
|
|
await headline.fill('Recovered native preview — æøå');
|
|
await enterPreview();
|
|
await expect(chrome.getByText(t.cvEditorPreviewUnavailable, { exact: true })).toBeVisible();
|
|
await expect(region).toHaveAttribute('aria-busy', 'false'); await expect(region).toBeFocused();
|
|
await retainedBottom(failedBottom, 'failed-request');
|
|
for (const handle of failedBottom.handles) await handle.dispose();
|
|
await expect(pages).toHaveCount(goodCount);
|
|
expect(await frames.evaluateAll(elements => elements.map(element => element.getAttribute('srcdoc')))).toEqual(goodSources);
|
|
await capture(chrome, info, 'error-retains-last-good-pages');
|
|
await scan(page, info, 'error-preview', t.cvEditorLivePreview);
|
|
const retried = page.waitForResponse(response => response.url() === `${apiUrl}/cv/preview` && response.request().method() === 'POST');
|
|
await chrome.getByRole('button', { name: t.cvEditorRetryPreview, exact: true }).press('Enter');
|
|
expect((await retried).status()).toBe(200); await settle('native-retry-recovered', 5);
|
|
await expect(chrome.getByText(t.cvEditorPreviewUnavailable, { exact: true })).toHaveCount(0);
|
|
await expect(firstDocument.getByText('Recovered native preview — æøå', { exact: true })).toBeVisible();
|
|
await enterPreview(); await capture(chrome, info, 'recovered-preview-focus');
|
|
await returnEditor('Recovered native preview — æøå');
|
|
await expect.poll(async () => (await read(veryLong.id)).settings.headline).toBe('Recovered native preview — æøå'); await saved();
|
|
await unchanged();
|
|
await info.attach('native-preview-measurements', { body: JSON.stringify({ mode, language, sourceBullets: 90, customBullets: 45, measurements }), contentType: 'application/json' });
|
|
} finally { await admin.dispose(); }
|
|
});
|
|
}
|