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 => (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(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(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' }); }); });