Files
jobtrackingapp/job-tracker-ui/e2e/cv-responsive-workflow.spec.ts

256 lines
23 KiB
TypeScript

import AxeBuilder from '@axe-core/playwright';
import { expect, test, type APIRequestContext, type BrowserContext, type Locator, type Page, type TestInfo } from '@playwright/test';
import fs from 'node:fs/promises';
import type { CvOutline, CvVariant } from '../src/cvBuilder';
import { translations } from '../src/i18n/translations';
const apiUrl = 'http://localhost:5302/api';
const password = 'E2ePassword123!';
type Language = 'en' | 'nb';
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 seed(page: Page, context: BrowserContext, admin: APIRequestContext, mode: 'light' | 'dark', language: Language) {
const email = `p514-${Date.now()}-${mode}-${language}@example.test`;
try {
const headers = await login(admin, 'e2e@example.test');
expect((await admin.post(`${apiUrl}/users`, { headers, data: { email, password, roles: [] } })).status()).toBe(200);
} finally { 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 });
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' ? 'Dokumenterte pålitelige arbeidsflyter med tilgjengelig navigasjon og tydelig kildegrunnlag. ' : 'Documented reliable workflows with accessible navigation and explicit source evidence. ';
const profile = {
version: '1', contact: { fullName: 'Åse Eksempel', headline: 'Canonical source — æøå', location: 'Oslo, Norge' }, summary: ['Verified source evidence.'],
jobs: Array.from({ length: 4 }, (_, index) => ({ id: `role-${index + 1}`, title: `Role ${index + 1} — documented integrations`, company: 'Nordlys Fixture AS', startDate: '2017-01', endDate: '2025-12', bullets: Array.from({ length: 5 }, (_, bullet) => `Evidence ${index + 1}.${bullet + 1}: ${sentence.repeat(4)}`) })),
education: [{ id: 'education-1', qualification: 'Computer science', institution: 'Fixture University', startDate: '2013-08', endDate: '2016-06' }],
projects: [{ id: 'project-1', name: 'Evidence workspace', bullets: [sentence] }, { id: 'project-2', name: 'Accessible integration', bullets: [sentence] }], skills: ['C#', 'React', 'Norsk'],
};
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 outline: CvOutline = await (await page.request.get(`${apiUrl}/cv/outline`)).json();
const sections = ['summary', 'experience', 'education', 'projects', 'skills', 'certifications', 'languages', 'awards', 'publications', 'organisations', 'interests', 'references'].map(key => ({ key, hidden: !outline.sections.some(section => section.key === key) }));
const created = await page.request.post(`${apiUrl}/cv/variants`, { headers, data: { name: 'Responsive owned CV — æøå', settings: { themeId: 'minimal', language, sections } } });
expect(created.status()).toBe(200); const cv: CvVariant = await created.json();
const siblingResponse = await page.request.post(`${apiUrl}/cv/variants`, { headers, data: { name: 'Untouched sibling CV', settings: cv.settings } });
expect(siblingResponse.status()).toBe(200); const sibling: CvVariant = await siblingResponse.json();
const cvUrl = `${apiUrl}/cv/variants/${cv.id}`;
const read = async (): Promise<CvVariant> => (await page.request.get(cvUrl)).json();
const unchanged = async () => {
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);
};
await page.setViewportSize({ width: 1440, height: 900 }); await page.goto(`/career/builder/${cv.id}`);
await expect(page.getByLabel(translations[language].cvEditorName, { exact: true })).toHaveValue(cv.name);
return { headers, cv, cvUrl, read, unchanged, outline };
}
async function reachable(control: Locator, minimumTarget = 0) {
// Pane entry reveals the owned frame after the browser's native focus scroll.
await control.evaluate(() => new Promise<void>(resolve => requestAnimationFrame(() => resolve())));
const geometry = await control.evaluate(element => {
const rect = element.getBoundingClientRect(); const shell = document.querySelector('.MuiAppBar-root')!.getBoundingClientRect();
const center = { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
const hit = document.elementFromPoint(center.x, center.y);
const pane = element.closest('.MuiPaper-root')?.getBoundingClientRect();
return { top: rect.top, bottom: rect.bottom, width: rect.width, height: rect.height, center, shellBottom: shell.bottom, viewport: { width: innerWidth, height: innerHeight }, pane: pane ? { top: pane.top, bottom: pane.bottom, height: pane.height } : null, hit: !!hit && element.contains(hit), hitNode: hit ? { tag: hit.tagName, role: hit.getAttribute('role'), label: hit.getAttribute('aria-label'), class: hit.className } : null };
});
expect(geometry.top, `control above the fixed shell boundary: ${JSON.stringify(geometry)}`).toBeGreaterThanOrEqual(geometry.shellBottom - 1);
expect(geometry.bottom, 'control inside the viewport').toBeLessThanOrEqual(geometry.viewport.height + 1);
expect(geometry.center.x).toBeGreaterThan(0); expect(geometry.center.x).toBeLessThan(geometry.viewport.width);
expect(geometry.hit, `actual center point reaches this control: ${JSON.stringify(geometry)}`).toBe(true);
expect(geometry.width).toBeGreaterThanOrEqual(minimumTarget); expect(geometry.height).toBeGreaterThanOrEqual(minimumTarget);
return geometry;
}
async function capture(page: Page, info: TestInfo, state: string, language: Language) {
const t = translations[language];
const geometry = await page.evaluate(({ editingName, previewName }) => {
const rect = (element: Element | null) => { if (!element) return null; const box = element.getBoundingClientRect(); return { x: box.x, y: box.y, width: box.width, height: box.height, top: box.top, bottom: box.bottom }; };
const region = (name: string) => Array.from(document.querySelectorAll('[role="region"]')).find(node => node.getAttribute('aria-label') === name)!;
const editing = region(editingName); const preview = region(previewName);
return { viewport: { width: innerWidth, height: innerHeight }, scroll: { x: scrollX, y: scrollY }, shell: rect(document.querySelector('.MuiAppBar-root')), editing: rect(editing), preview: rect(preview), previewChrome: rect(preview.parentElement), previewScroll: { top: preview.scrollTop, height: preview.scrollHeight, clientHeight: preview.clientHeight }, focused: document.activeElement?.getAttribute('aria-label'), horizontalOverflow: document.documentElement.scrollWidth - innerWidth };
}, { editingName: t.cvEditorEditingRegion, previewName: t.cvEditorLivePreview });
const screenshot = info.outputPath(`${state}.png`);
await page.screenshot({ path: screenshot, animations: 'disabled' });
await info.attach(state, { path: screenshot, contentType: 'image/png' });
await info.attach(`${state}-geometry`, { body: JSON.stringify(geometry), contentType: 'application/json' });
expect(geometry.horizontalOverflow).toBeLessThanOrEqual(1);
return geometry;
}
async function scan(page: Page, info: TestInfo, state: string) {
const result = await new AxeBuilder({ page }).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'], ['light', 'nb'], ['dark', 'en']] as const) {
test(`[PRES-C05 C06 C07 C10 E08 E09] ${mode}/${language} responsive panes retain draft, source, pointer order and revision recovery`, async ({ page, context, playwright }, info) => {
test.setTimeout(300_000);
const t = translations[language];
const { cv, cvUrl, headers, outline, read, unchanged } = await seed(page, context, await playwright.request.newContext(), mode, language);
const editing = page.getByRole('region', { name: t.cvEditorEditingRegion, exact: true });
const preview = page.getByRole('region', { name: t.cvEditorLivePreview, exact: true });
const returnEditor = page.getByRole('button', { name: t.cvEditorFocusEditor, exact: true });
const headline = page.getByLabel(t.cvEditorHeadlineOverride, { exact: true });
const saved = async () => { await expect(page.getByText(t.cvEditorSaved, { exact: true })).toBeVisible(); };
const enterPreview = async (width: number) => {
const entry = page.getByRole('button', { name: width < 900 ? t.cvEditorPreview : t.cvEditorFocusPreview, exact: true });
await entry.press('Enter');
if (width < 900) { await returnEditor.focus(); await page.keyboard.press('Tab'); }
await expect(preview).toBeFocused(); await expect(preview).toBeVisible();
await expect(preview).toHaveAttribute('aria-busy', 'false');
const frames = preview.locator('[data-cv-page] iframe');
await expect.poll(() => frames.evaluateAll(nodes => nodes.every(node => (node as HTMLIFrameElement).contentDocument?.documentElement.dataset.cvPreviewReady === 'true'))).toBe(true);
const available = await preview.evaluate(node => node.clientWidth);
const documentWidth = await frames.first().evaluate(node => (node as HTMLIFrameElement).offsetWidth);
const expectedScale = Math.min(1, Math.max(0.2, (available - 16) / documentWidth));
await expect.poll(() => frames.first().evaluate(node => new DOMMatrixReadOnly(getComputedStyle(node).transform).a)).toBeCloseTo(expectedScale, 3);
};
await expect.poll(() => page.getByTitle(t.cvEditorPreviewTitle, { exact: true }).evaluate(element => (element as HTMLIFrameElement).contentDocument?.documentElement.dataset.cvPreviewReady)).toBe('true');
expect(await preview.locator('[data-cv-page]').count()).toBeGreaterThan(1); await saved();
let release!: () => void; let pending = false;
const hold = new Promise<void>(resolve => { release = resolve; });
await page.route(cvUrl, async route => { if (route.request().method() === 'PUT') { pending = true; await hold; } await route.continue(); });
const draft = 'Held local CV draft — æøå';
try {
await headline.fill(draft); await expect.poll(() => pending).toBe(true);
await expect(page.getByText(t.cvEditorSaving, { exact: true })).toBeVisible();
for (const viewport of [{ width: 320, height: 844 }, { width: 390, height: 844 }, { width: 768, height: 1024 }, { width: 899, height: 900 }, { width: 900, height: 900 }, { width: 1024, height: 768 }, { width: 1440, height: 900 }, { width: 844, height: 390 }]) {
await headline.focus(); await page.setViewportSize(viewport); await expect(headline).toBeFocused(); await expect(headline).toHaveValue(draft);
await enterPreview(viewport.width);
const paneScroll = await page.evaluate(() => scrollY);
if (viewport.width === 320 || viewport.width === 1440 || viewport.height < 500) {
await page.keyboard.press('Control+Home');
await expect.poll(() => preview.evaluate(node => node.scrollTop)).toBe(0);
await capture(page, info, `pending-preview-start-${viewport.width}x${viewport.height}`, language);
}
await page.keyboard.press('Control+End');
await expect.poll(() => preview.evaluate(node => Math.abs(node.scrollHeight - node.clientHeight - node.scrollTop))).toBeLessThan(3);
await reachable(returnEditor, viewport.width < 600 ? 44 : 0);
const geometry = await capture(page, info, `pending-preview-${viewport.width}x${viewport.height}`, language);
expect(geometry.scroll.y, 'document-boundary keys stay in the focused preview pane').toBe(paneScroll);
const visibleCanvasHeight = Math.min(geometry.preview!.bottom, viewport.height) - Math.max(geometry.preview!.top, geometry.shell!.bottom);
expect(visibleCanvasHeight, 'visible usable preview canvas in portrait/landscape').toBeGreaterThanOrEqual(viewport.height < 500 ? 120 : 160);
expect(geometry.preview!.top).toBeGreaterThanOrEqual(geometry.shell!.bottom - 1);
if (viewport.width === 320) await scan(page, info, 'pending-phone-preview');
await page.keyboard.press('Shift+Tab'); await expect(returnEditor).toBeFocused(); await page.keyboard.press('Enter');
await expect(editing).toBeFocused(); await expect(headline).toHaveValue(draft);
await reachable(page.getByRole('tab', { name: t.cvEditorContent, exact: true }), viewport.width < 600 ? 44 : 0);
await reachable(editing.getByRole('button', { name: t.cvEditorUndo, exact: true }), viewport.width < 600 ? 44 : 0);
if (viewport.width === 320) { await capture(page, info, 'pending-phone-editor', language); await scan(page, info, 'pending-phone-editor'); }
expect((await read()).version).toBe(cv.version);
}
} finally { release(); }
await saved(); await page.unroute(cvUrl); await expect.poll(async () => (await read()).settings.headline).toBe(draft); await unchanged();
// Real nested HTML drag handlers must distinguish entry and section owners.
await page.setViewportSize({ width: 1440, height: 900 });
const sectionName = (key: 'cvEditorExpandSection' | 'cvEditorCollapseSection', section: string) => t[key].replace('{section}', section);
const experience = page.getByRole('group', { name: t.cvSectionExperience, exact: true });
const projects = page.getByRole('group', { name: t.cvSectionProjects, exact: true });
await page.getByRole('button', { name: sectionName('cvEditorExpandSection', t.cvSectionExperience), exact: true }).click();
await page.getByRole('button', { name: sectionName('cvEditorExpandSection', t.cvSectionProjects), exact: true }).click();
const entries = outline.sections.find(section => section.key === 'experience')!.entries;
const first = entries[0]; const second = entries[1];
const entry = (title: string) => experience.getByRole('group', { name: title, exact: true });
await expect(entry(first.title!).getByText(t.cvEditorUsingSource, { exact: true }).first()).toBeVisible();
const beforeOrder = await read();
const title = (name: string) => entry(name).getByText(name, { exact: true }).first();
const drag = async (source: Locator, target: Locator) => {
// Start the native gesture before revealing a distant target in the scroll pane.
await source.hover({ position: { x: 6, y: 8 } });
const start = (await source.boundingBox())!;
await page.mouse.down(); await page.mouse.move(start.x + 30, start.y + 8, { steps: 5 });
await target.scrollIntoViewIfNeeded();
const end = (await target.boundingBox())!;
await page.mouse.move(end.x + 18, end.y + 8, { steps: 5 });
await page.mouse.move(end.x + 24, end.y + 8); await page.mouse.up();
};
await drag(title(second.title!), projects.getByText('Evidence workspace', { exact: true }).first());
expect((await read()).settings.sections).toEqual(beforeOrder.settings.sections);
const foreignTitle = projects.getByLabel(t.cvEditorEntryTitle, { exact: true }).first();
await drag(title(second.title!), foreignTitle);
await expect(foreignTitle).toHaveValue('Evidence workspace');
expect(await read()).toEqual(beforeOrder);
await drag(title(second.title!), title(first.title!));
const expectedEntries = [second.key!, first.key!, ...entries.slice(2).map(row => row.key!)];
await expect.poll(async () => (await read()).settings.sections.find(section => section.key === 'experience')?.itemOrder).toEqual(expectedEntries); await saved();
const afterEntry = await read();
expect(afterEntry.settings.sections.map(section => section.key)).toEqual(beforeOrder.settings.sections.map(section => section.key));
expect(afterEntry.settings.sections.filter(section => section.key !== 'experience')).toEqual(beforeOrder.settings.sections.filter(section => section.key !== 'experience'));
await page.getByRole('button', { name: sectionName('cvEditorCollapseSection', t.cvSectionExperience), exact: true }).click();
await page.getByRole('button', { name: sectionName('cvEditorCollapseSection', t.cvSectionProjects), exact: true }).click();
await projects.dragTo(experience, { sourcePosition: { x: 8, y: 24 }, targetPosition: { x: 8, y: 24 } });
await expect.poll(async () => (await read()).settings.sections.map(section => section.key)).toEqual(['summary', 'projects', 'experience', 'education', 'skills', ...beforeOrder.settings.sections.slice(5).map(section => section.key)]); await saved();
expect((await read()).settings.sections.find(section => section.key === 'experience')?.itemOrder).toEqual(expectedEntries); await unchanged();
const more = page.getByRole('button', { name: t.cvEditorMoreActions, exact: true });
await more.click(); await page.keyboard.press('Escape'); await expect(more).toBeFocused();
await page.setViewportSize({ width: 390, height: 844 });
const retained = await read();
const remote = await page.request.put(cvUrl, { headers, data: { expectedVersion: retained.version, settings: { ...retained.settings, headline: 'Newer reviewed server version — æøå' } } });
expect(remote.status()).toBe(200); const latest: CvVariant = await remote.json();
const conflictResponse = page.waitForResponse(response => response.url() === cvUrl && response.request().method() === 'PUT');
await headline.fill('Recoverable phone draft — æøå');
expect((await conflictResponse).status()).toBe(409);
await expect(page.getByText(t.cvEditorConflict, { exact: true })).toBeVisible(); await expect(headline).toHaveValue('Recoverable phone draft — æøå');
expect(await read()).toEqual(latest);
const downloading = page.waitForEvent('download'); await page.getByRole('button', { name: t.cvEditorConflictDownload, exact: true }).click();
const downloaded = JSON.parse(await fs.readFile((await (await downloading).path())!, 'utf8'));
expect(downloaded.settings.headline).toBe('Recoverable phone draft — æøå'); expect(downloaded.expectedVersion).toBe(retained.version);
await page.getByRole('button', { name: t.cvEditorConflictReload, exact: true }).click();
await page.getByRole('dialog').getByRole('button', { name: t.cancel, exact: true }).click();
await expect(page.getByRole('dialog', { name: t.cvEditorConflictReload, exact: true, includeHidden: true })).toHaveCount(0);
await expect(headline).toHaveValue('Recoverable phone draft — æøå'); expect(await read()).toEqual(latest);
await capture(page, info, 'phone-conflict-retained', language);
await page.getByRole('button', { name: t.cvEditorConflictReload, exact: true }).click();
await page.getByRole('dialog').getByRole('button', { name: t.cvEditorConflictReload, exact: true }).click();
await expect(headline).toHaveValue('Newer reviewed server version — æøå'); expect(await read()).toEqual(latest); await unchanged();
await expect(page.getByRole('dialog', { name: t.cvEditorConflictReload, exact: true, includeHidden: true })).toHaveCount(0);
await scan(page, info, 'phone-reloaded');
});
}
test.describe('Chromium emulated coarse pointer; not physical touch hardware', () => {
test.use({ hasTouch: true });
test('[PRES-C05 C06 C07 E08] emulated touch taps pane actions and the accessible entry-order alternative', async ({ page, context, playwright }, info) => {
test.setTimeout(180_000);
const t = translations.nb;
const { outline, read, unchanged } = await seed(page, context, await playwright.request.newContext(), 'dark', 'nb');
expect(await page.evaluate(() => matchMedia('(pointer: coarse)').matches)).toBe(true);
const tap = async (control: Locator) => { await control.scrollIntoViewIfNeeded(); const box = await reachable(control, 44); await page.touchscreen.tap(box.center.x, box.center.y); };
for (const viewport of [{ width: 320, height: 844 }, { width: 390, height: 844 }, { width: 844, height: 390 }]) {
await page.setViewportSize(viewport);
await tap(page.getByRole('button', { name: t.cvEditorPreview, exact: true }));
const preview = page.getByRole('region', { name: t.cvEditorLivePreview, exact: true });
await expect(preview).toBeVisible();
await preview.evaluate(node => { node.scrollTop = node.scrollHeight; });
await tap(page.getByRole('button', { name: t.cvEditorFocusEditor, exact: true }));
await expect(page.getByRole('region', { name: t.cvEditorEditingRegion, exact: true })).toBeFocused();
const more = page.getByRole('button', { name: t.cvEditorMoreActions, exact: true });
await tap(more); await expect(page.getByRole('menu')).toBeVisible(); await page.keyboard.press('Escape'); await expect(more).toBeFocused();
await expect(page.getByRole('menu')).toBeHidden();
await expect(page.locator('#cv-document-actions')).toHaveCount(0);
await capture(page, info, `emulated-touch-${viewport.width}x${viewport.height}`, 'nb');
}
await page.setViewportSize({ width: 390, height: 844 });
await tap(page.getByRole('button', { name: t.cvEditorExpandSection.replace('{section}', t.cvSectionExperience), exact: true }));
const entries = outline.sections.find(section => section.key === 'experience')!.entries;
await tap(page.getByRole('button', { name: t.cvEditorMoveNamedEntryDown.replace('{entry}', entries[0].title!), exact: true }));
await expect.poll(async () => (await read()).settings.sections.find(section => section.key === 'experience')?.itemOrder).toEqual([entries[1].key!, entries[0].key!, ...entries.slice(2).map(row => row.key!)]);
await unchanged(); await scan(page, info, 'emulated-touch-editor');
await info.attach('touch-qualification', { body: JSON.stringify({ input: 'Chromium hasTouch and touchscreen.tap', physicalDevice: false, nativeSoftwareKeyboard: false, mobileScreenReader: false, gestures: 'taps only; entry reorder uses the existing named alternative' }), contentType: 'application/json' });
});
});