import AxeBuilder from '@axe-core/playwright'; import { writeFile } from 'node:fs/promises'; import { expect, test as base, type APIRequestContext, type BrowserContext, type Page, type TestInfo } from '@playwright/test'; import { translations } from '../src/i18n/translations'; const apiUrl = 'http://localhost:5302/api'; const password = 'E2ePassword123!'; type Language = 'en' | 'nb'; type Theme = 'light' | 'dark'; type Area = 'authentication' | 'shell' | 'commands' | 'jobs' | 'board' | 'dashboard' | 'analytics' | 'workspace' | 'profile' | 'cv' | 'documents' | 'settings' | 'admin'; type Owner = { headers: Record; email: string; jobId: number; cvId: number }; type Sample = { area: Area; path: string; width: number; height?: number; language: Language; theme: Theme; state?: string }; async function login(api: APIRequestContext, email: string) { expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200); const me = await api.get(`${apiUrl}/auth/me`); expect(me.status()).toBe(200); return { 'X-CSRF-TOKEN': (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value, 'X-Session-Context': (await me.json()).sessionContext as string }; } async function localOnly(context: BrowserContext) { 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(); }); } const test = base.extend<{ owner: Owner }>({ owner: async ({ page, context, playwright }, provide) => { const admin = await playwright.request.newContext(); try { const adminHeaders = await login(admin, 'e2e@example.test'); const email = `responsive-audit-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`; expect((await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email, password, roles: ['Admin'] } })).status()).toBe(200); const headers = await login(page.request, email); await localOnly(context); await context.addInitScript(() => { if (!localStorage.getItem('uiLanguage')) localStorage.setItem('uiLanguage', 'en'); if (!localStorage.getItem('jobtracker.themeMode')) localStorage.setItem('jobtracker.themeMode', 'light'); }); const sentence = 'Documented accessible services and reliable user workflows. Dokumenterte tilgjengelige tjenester og pålitelige arbeidsflyter. '; const profile = { version: '1', contact: { fullName: 'Åse Revisjonseksempel', headline: 'Recorded platform developer', location: 'Oslo, Norge' }, summary: [sentence], jobs: Array.from({ length: 3 }, (_, index) => ({ id: `audit-role-${index}`, title: `Platform developer ${index + 1}`, company: 'Nordlys Audit AS', startDate: '2020-01', endDate: '2025-12', bullets: Array.from({ length: 4 }, () => sentence.repeat(3)) })), education: [{ id: 'audit-education', qualification: 'Computer science', institution: 'Fixture University', startDate: '2016-08', endDate: '2019-06' }], projects: [{ id: 'audit-project', name: 'Recorded accessibility project', bullets: [sentence] }], skills: ['C#', 'React', 'SQL'], languages: [{ language: 'Norsk', proficiency: 'Fluent' }] }; expect((await page.request.put(`${apiUrl}/career/profile`, { headers, data: { profile } })).status()).toBe(200); const company = await page.request.post(`${apiUrl}/companies`, { headers, data: { name: 'Nordlys samfunnskritiske plattformer og inkluderende digitale tjenester æøå' } }); expect(company.status()).toBe(201); const companyId = (await company.json()).id; let jobId = 0; for (const [index, status] of ['Saved', 'Applied', 'Interview', 'Offer', 'Rejected', 'Faglig vurdering'].entries()) { const created = await page.request.post(`${apiUrl}/jobapplications`, { headers, data: { companyId, jobTitle: `Audit platform engineer ${index + 1} — langsiktig utvikling og inkluderende tjenester`, status, description: 'Recorded C# and React service requirements.', dateApplied: status === 'Saved' ? null : '2026-09-15T12:00:00Z', location: 'Oslo, Norge', notes: 'Recorded personal notes, separate from application content.' } }); expect(created.status()).toBe(201); if (index === 0) jobId = (await created.json()).id; } const cv = await page.request.post(`${apiUrl}/cv/variants`, { headers, data: { name: 'Audit CV — dokumenterte ferdigheter og erfaring æøå', settings: { themeId: 'minimal', language: 'en', headline: 'CV-specific platform headline', sections: ['summary', 'experience', 'education', 'projects', 'skills', 'certifications', 'languages', 'awards', 'publications', 'organisations', 'interests', 'references'].map((key, index) => ({ key, hidden: index >= 5 })) } } }); expect(cv.status()).toBe(200); const cvId = (await cv.json()).id; expect((await page.request.post(`${apiUrl}/attachments`, { headers, multipart: { jobId: String(jobId), files: { name: 'Recorded application evidence — æøå.txt', mimeType: 'text/plain', buffer: Buffer.from('Synthetic recorded evidence — æøå') } } })).ok()).toBe(true); await provide({ headers, email, jobId, cvId }); } finally { await admin.dispose(); } }, }); for (const language of ['en', 'nb'] as const) { test(`[P6-02] ${language} company save failure stays in the dialog and retry persists the retained draft`, async ({ page, owner }, info) => { const t = translations[language]; await enter(page, owner, { area: 'jobs', path: '/jobs', width: language === 'en' ? 1440 : 390, language, theme: language === 'en' ? 'light' : 'dark' }); await page.goto('/companies'); const edit = page.getByRole('button', { name: new RegExp(`^${t.jobTableEdit}:`) }); await expect(edit).toHaveCount(1); await edit.press('Enter'); const dialog = page.getByRole('dialog'); const name = dialog.getByLabel(t.companiesName, { exact: true }); const changed = `Reviewed company draft ${language} æøå`; await name.fill(changed); const save = dialog.getByRole('button', { name: t.save, exact: true }); const fail = async (route: import('@playwright/test').Route) => route.fulfill({ status: 500, contentType: 'application/json', body: '{}' }); await page.route('**/api/companies/*', fail); await save.press('Enter'); await expect(dialog.getByRole('alert')).toHaveText(t.companiesUpdateFailed); await expect(name).toHaveValue(changed); expect((await (await page.request.get(`${apiUrl}/companies`)).json()).some((company: { name: string }) => company.name === changed)).toBe(false); await info.attach(`company-${language}-failed-save`, { body: await dialog.ariaSnapshot(), contentType: 'text/plain' }); await info.attach(`company-${language}-failed-save`, { body: await page.screenshot({ animations: 'disabled' }), contentType: 'image/png' }); await page.unroute('**/api/companies/*', fail); const persisted = page.waitForResponse(response => /\/api\/companies\/\d+$/.test(new URL(response.url()).pathname) && response.request().method() === 'PUT'); await save.press('Enter'); expect((await persisted).status()).toBe(200); await expect(dialog).toBeHidden(); await page.reload(); await expect(page.getByRole('button', { name: `${t.jobTableEdit}: ${changed}`, exact: true })).toBeVisible(); }); } async function enter(page: Page, owner: Owner, sample: Sample) { const t = translations[sample.language]; expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers: owner.headers, data: { language: sample.language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204); await page.setViewportSize({ width: sample.width, height: sample.height ?? 900 }); if (!page.url().startsWith('http://localhost:3300')) { await page.goto('/dashboard'); await expect(page.getByRole('main')).toBeVisible(); } await page.evaluate(({ language, theme }) => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', theme); }, sample); await page.emulateMedia({ reducedMotion: 'reduce' }); const endpoint = sample.area === 'cv' ? `/cv/variants/${owner.cvId}` : sample.area === 'documents' ? `/attachments/${owner.jobId}` : sample.area === 'workspace' ? `/jobapplications/${owner.jobId}/workspace` : sample.area === 'board' ? '/jobapplications/board' : sample.area === 'profile' ? '/career/profile' : sample.area === 'admin' ? '/users' : ['dashboard', 'analytics'].includes(sample.area) ? '/jobapplications/stats' : sample.area === 'settings' ? '/notification-settings' : '/jobapplications'; const loaded = page.waitForResponse(response => new URL(response.url()).pathname === `/api${endpoint}` && response.request().method() === 'GET'); await page.goto(sample.path); expect((await loaded).status(), `${sample.area} native read`).toBe(200); await expect(page.getByRole('main')).toBeVisible(); await expect(page.locator('html')).toHaveAttribute('data-color-scheme', sample.theme); if (sample.area === 'cv') { await expect(page.getByLabel(t.cvEditorName, { exact: true })).toHaveValue('Audit CV — dokumenterte ferdigheter og erfaring æøå'); await expect(page.getByRole('region', { name: t.cvEditorEditingRegion, exact: true })).toBeVisible(); await expect.poll(() => page.getByTitle(t.cvEditorPreviewTitle, { exact: true }).evaluate(element => (element as HTMLIFrameElement).contentDocument?.documentElement.dataset.cvPreviewReady)).toBe('true'); } else if (sample.area === 'documents') await expect(page.getByText('Recorded application evidence — æøå.txt', { exact: true })).toBeVisible(); else if (sample.area === 'workspace') await expect(page.getByRole('heading', { name: /^Audit platform engineer 1/ })).toBeVisible(); else if (sample.area === 'profile') await expect(page.getByLabel(t.profileCvContactFullName, { exact: true })).toHaveValue('Åse Revisjonseksempel'); else if (sample.area === 'board') await expect(page.locator('[data-kanban-card-id]')).toHaveCount(6); else if (sample.area === 'commands') { await page.getByRole('button', { name: new RegExp(t.quickSearch) }).press('Enter'); await expect(page.getByRole('dialog', { name: t.quickSearch }).getByRole('textbox')).toBeFocused(); } else await expect(page.getByRole('heading', { level: 1, name: sample.area === 'admin' ? t.users : sample.area === 'settings' ? t.settings : sample.area === 'dashboard' ? t.dashboard : sample.area === 'analytics' ? t.analytics : t.jobApplications, exact: true })).toBeVisible(); } for (const language of ['en', 'nb'] as const) { test(`[P6-02] ${language} failed account language preference reports local scope and retry persists`, async ({ page, owner }, info) => { const initial = language === 'nb' ? 'en' : 'nb'; await enter(page, owner, { area: 'settings', path: '/settings', width: language === 'nb' ? 360 : 1440, language: initial, theme: language === 'nb' ? 'dark' : 'light' }); const fail = async (route: import('@playwright/test').Route) => route.fulfill({ status: 500, contentType: 'application/json', body: '{}' }); await page.route('**/api/auth/preferences/language', fail); await page.getByRole('combobox', { name: translations[initial].settingsPreferredLanguage, exact: true }).click(); await page.getByRole('option', { name: translations[initial][language === 'nb' ? 'settingsNorwegian' : 'settingsEnglish'], exact: true }).click(); const alert = page.getByRole('alert').filter({ hasText: translations[language].settingsLanguageSaveFailed }); await expect(alert).toBeVisible(); await expect(page.locator('html')).toHaveAttribute('lang', language === 'nb' ? 'nb-NO' : 'en'); expect(await page.evaluate(() => localStorage.getItem('uiLanguage'))).toBe(language); await info.attach(`language-${language}-failed-account-save`, { body: await page.screenshot({ animations: 'disabled' }), contentType: 'image/png' }); await page.unroute('**/api/auth/preferences/language', fail); const saved = page.waitForResponse(response => new URL(response.url()).pathname === '/api/auth/preferences/language' && response.request().method() === 'PUT'); await alert.getByRole('button', { name: translations[language].retry, exact: true }).press('Enter'); expect((await saved).status()).toBe(204); await page.reload(); await expect(page.getByRole('combobox', { name: translations[language].settingsPreferredLanguage, exact: true })).toHaveText(translations[language][language === 'nb' ? 'settingsNorwegian' : 'settingsEnglish']); }); } for (const [language, theme, width] of [['en', 'light', 1920], ['nb', 'dark', 360], ['en', 'dark', 1280], ['nb', 'light', 390]] as const) { test(`[P6-03] ${language}/${theme} Settings panel relationships and reflow at ${width}px`, async ({ page, owner }, info) => { const sample: Sample = { area: 'settings', path: '/settings', width, language, theme }; const t = translations[language]; await enter(page, owner, sample); await expect(page.getByRole('tabpanel', { name: t.settingsTabGeneral, exact: true })).toBeVisible(); if (width >= 600) { const tab = page.getByRole('tab', { name: t.settingsTabNotifications, exact: true }); await tab.press('Enter'); await expect(tab).toHaveAttribute('aria-controls', 'settings-panel-2'); await expect(tab).toHaveAttribute('aria-selected', 'true'); } else { await page.getByRole('combobox', { name: t.settingsNavigation, exact: true }).click(); await page.getByRole('option', { name: t.settingsTabNotifications, exact: true }).click(); } await expect(page.getByRole('tabpanel', { name: t.settingsTabNotifications, exact: true })).toHaveAttribute('id', 'settings-panel-2'); const evidence = await collect(page, info, sample, 'named-notification-panel'); expect(evidence.geometry.pageHorizontalOverflow).toBe(0); }); } for (const [language, theme, width] of [['nb', 'dark', 320], ['en', 'light', 390]] as const) { test(`[P6-03] ${language}/${theme} Career selector floating label is not clipped at ${width}px`, async ({ page, owner }, info) => { const sample: Sample = { area: 'profile', path: '/career?section=profile', width, language, theme }; await enter(page, owner, sample); const navigation = page.getByRole('navigation', { name: translations[language].careerWorkspaceNavigation, exact: true }); const label = navigation.locator('#career-workspace-section-label'); const bounds = await label.evaluate(element => { const label = element.getBoundingClientRect(); const nav = element.closest('nav')!.getBoundingClientRect(); return { labelTop: label.top, labelBottom: label.bottom, navTop: nav.top, navBottom: nav.bottom }; }); await info.attach('career-selector-label-geometry', { body: JSON.stringify({ sample, bounds, proofKind: 'Native CSS label bounds versus clipping navigation container; no human/device acceptance' }), contentType: 'application/json' }); expect(bounds.labelTop).toBeGreaterThanOrEqual(bounds.navTop); expect(bounds.labelBottom).toBeLessThanOrEqual(bounds.navBottom); const evidence = await collect(page, info, sample, 'career-selector-label'); expect(evidence.geometry.pageHorizontalOverflow).toBe(0); await navigation.getByRole('combobox').press('Enter'); await page.getByRole('option', { name: translations[language].careerWorkspaceOverviewTab, exact: true }).press('Enter'); await expect(page).toHaveURL(/\/career\/?$/); await expect(navigation.getByRole('combobox')).toHaveText(translations[language].careerWorkspaceOverviewTab); }); } test('[P6-04] measured native route, long CV and complete large Board workflows', async ({ page, owner, context }, info) => { test.setTimeout(360_000); const profileResponse = await page.request.get(`${apiUrl}/career/profile`); expect(profileResponse.status()).toBe(200); const profile = (await profileResponse.json()).profile; const sentence = 'Documented accessible services and reliable user workflows. Dokumenterte tilgjengelige tjenester og pålitelige arbeidsflyter. '; profile.jobs = Array.from({ length: 8 }, (_, index) => ({ id: `performance-role-${index}`, title: `Measured platform developer ${index + 1}`, company: 'Nordlys Audit AS', startDate: '2020-01', endDate: '2025-12', bullets: Array.from({ length: 4 }, (_, bullet) => `Evidence ${index + 1}.${bullet + 1}: ${sentence.repeat(3)}`) })); expect((await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers, data: { profile } })).status()).toBe(200); await context.addInitScript(() => { const observations = { longTasks: [] as number[], layoutShift: 0 }; Object.assign(window, { phase6Performance: observations }); if (PerformanceObserver.supportedEntryTypes.includes('longtask')) new PerformanceObserver(list => observations.longTasks.push(...list.getEntries().map(entry => entry.duration))).observe({ type: 'longtask', buffered: true }); if (PerformanceObserver.supportedEntryTypes.includes('layout-shift')) new PerformanceObserver(list => { for (const entry of list.getEntries()) if (!(entry as PerformanceEntry & { hadRecentInput: boolean }).hadRecentInput) observations.layoutShift += (entry as PerformanceEntry & { value: number }).value; }).observe({ type: 'layout-shift', buffered: true }); }); const session = await context.newCDPSession(page); await session.send('Performance.enable'); const observations: object[] = []; const measure = async (name: string, path: string, ready: () => Promise) => { const requests: string[] = []; const track = (request: import('@playwright/test').Request) => { if (new URL(request.url()).pathname.startsWith('/api/')) requests.push(`${request.method()} ${new URL(request.url()).pathname}`); }; page.on('request', track); const destination = new URL(path, 'http://localhost:3300'); const endpoint = destination.pathname === '/dashboard' || destination.pathname === '/analytics' ? '/api/jobapplications/stats' : destination.pathname === '/jobs' ? destination.searchParams.get('view') === 'board' ? '/api/jobapplications/board' : '/api/jobapplications' : destination.pathname.startsWith('/career/builder/') ? `/api/cv/variants/${owner.cvId}` : `/api/jobapplications/${owner.jobId}/workspace`; const loaded = page.waitForResponse(response => new URL(response.url()).pathname === endpoint && response.request().method() === 'GET'); const start = Date.now(); await page.goto(path); expect((await loaded).status()).toBe(200); await ready(); await page.evaluate(async () => { await document.fonts.ready; await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); }); const readyMs = Date.now() - start; const browser = await page.evaluate(() => { const timing = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming; const telemetry = (window as Window & { phase6Performance?: { longTasks: number[]; layoutShift: number } }).phase6Performance; return { navigation: { responseEndMs: timing.responseEnd, domContentLoadedMs: timing.domContentLoadedEventEnd, loadMs: timing.loadEventEnd }, ...telemetry, resources: performance.getEntriesByType('resource').map(entry => ({ path: new URL(entry.name).pathname, durationMs: entry.duration, transferSize: (entry as PerformanceResourceTiming).transferSize })) }; }); const metrics = (await session.send('Performance.getMetrics')).metrics.filter(metric => ['JSHeapUsedSize', 'Nodes', 'Documents', 'LayoutCount', 'RecalcStyleCount', 'TaskDuration'].includes(metric.name)); page.off('request', track); observations.push({ name, path, readyMs, requests, browser, metrics }); }; const heading = (name: string) => async () => { await expect(page.getByRole('heading', { level: 1, name, exact: true })).toBeVisible(); }; const t = translations.en; await measure('initial Jobs load', '/jobs', heading(t.jobApplications)); await measure('Dashboard', '/dashboard', async () => { await expect(page.getByRole('heading', { name: t.dashboard, exact: true })).toBeVisible(); await expect(page.getByRole('link', { name: 'Tracked jobs: 6', exact: true })).toBeVisible(); }); await measure('Analytics 6 months', '/analytics?months=6', heading(t.analytics)); await measure('Analytics 24 months', '/analytics?months=24', heading(t.analytics)); await measure('Job details', `/jobs/${owner.jobId}`, async () => { await expect(page.getByRole('heading', { name: /^Audit platform engineer 1/ })).toBeVisible(); }); await measure('Long CV initialization', `/career/builder/${owner.cvId}`, async () => { await expect.poll(() => page.getByTitle(t.cvEditorPreviewTitle, { exact: true }).evaluate(element => (element as HTMLIFrameElement).contentDocument?.documentElement.dataset.cvPreviewReady)).toBe('true'); await expect(page.locator('[data-cv-page]').first()).toBeVisible(); const preview = page.getByRole('region', { name: t.cvEditorLivePreview, exact: true }); await expect(preview).toHaveAttribute('aria-busy', 'false'); await expect.poll(() => preview.locator('[data-cv-page] iframe').evaluateAll(nodes => nodes.length > 0 && nodes.every(node => (node as HTMLIFrameElement).contentDocument?.documentElement.dataset.cvPreviewReady === 'true'))).toBe(true); expect(await page.locator('[data-cv-page]').count()).toBeGreaterThan(1); }); observations.push({ name: 'Long CV visible pagination', pages: await page.locator('[data-cv-page]').count() }); const editStart = Date.now(); await page.getByLabel(t.cvEditorHeadlineOverride, { exact: true }).fill('Measured CV-only edit'); await expect.poll(async () => (await (await page.request.get(`${apiUrl}/cv/variants/${owner.cvId}`)).json()).settings.headline).toBe('Measured CV-only edit'); await expect(page.getByText(t.cvEditorSaved, { exact: true })).toBeVisible(); observations.push({ name: 'CV section editing including existing autosave debounce', persistedMs: Date.now() - editStart }); const company = await page.request.post(`${apiUrl}/companies`, { headers: owner.headers, data: { name: 'Phase6 capacity fixture' } }); expect(company.status()).toBe(201); const companyId = (await company.json()).id; let count = 6; for (const size of [200, 1000]) { while (count < size) { const response = await page.request.post(`${apiUrl}/jobapplications`, { headers: owner.headers, data: { companyId, jobTitle: `Phase6 capacity ${++count}`, status: 'Saved' } }); expect(response.status()).toBe(201); } const started = Date.now(); const response = await page.request.get(`${apiUrl}/jobapplications/board`); expect(response.status()).toBe(200); const jobs = await response.json(); expect(jobs).toHaveLength(size); observations.push({ name: `Board ${size} API`, apiMs: Date.now() - started, bytes: (await response.body()).length }); await measure(`Board ${size} render`, '/jobs?view=board', async () => { await expect(page.locator('[data-kanban-card-id]')).toHaveCount(size, { timeout: 60_000 }); }); expect(await page.locator('[data-kanban-card-id]').evaluateAll(nodes => nodes.map(node => Number(node.getAttribute('data-kanban-card-id'))).sort((a, b) => a - b))).toEqual(jobs.map((job: { id: number }) => job.id).sort((a: number, b: number) => a - b)); await measure(`Table ${size}`, '/jobs?view=table', heading(t.jobApplications)); await measure(`Filtered Board ${size}`, '/jobs?view=board&q=Phase6%20capacity', async () => { await expect(page.locator('[data-kanban-card-id]')).toHaveCount(size - 6); }); } const clientTimeOrigin = await page.evaluate(() => performance.timeOrigin); const clientRequests: string[] = []; const trackClient = (request: import('@playwright/test').Request) => { const url = new URL(request.url()); if (url.pathname.startsWith('/api/')) clientRequests.push(`${request.method()} ${url.pathname}${url.search}`); }; page.on('request', trackClient); const tableLoaded = page.waitForResponse(response => new URL(response.url()).pathname === '/api/jobapplications' && response.request().method() === 'GET'); let interactionStart = Date.now(); await page.getByRole('link', { name: t.jobsTableView, exact: true }).click(); const tableResponse = await tableLoaded; expect(tableResponse.status()).toBe(200); expect((await tableResponse.json()).total).toBe(994); await expect(page.locator('[data-job-row-id]').first()).toBeVisible(); observations.push({ name: 'Client Board to Table retaining the large query', readyMs: Date.now() - interactionStart }); interactionStart = Date.now(); await page.getByRole('link', { name: t.jobsBoardView, exact: true }).click(); await expect(page.locator('[data-kanban-card-id]')).toHaveCount(994); observations.push({ name: 'Client Table to Board retaining the large query', readyMs: Date.now() - interactionStart }); const searched = page.waitForResponse(response => { const url = new URL(response.url()); return url.pathname === '/api/jobapplications/board' && url.searchParams.get('q') === 'Phase6 capacity 1000'; }); interactionStart = Date.now(); await page.getByRole('textbox', { name: t.jobTableSearch, exact: true }).fill('Phase6 capacity 1000'); const searchResponse = await searched; expect(searchResponse.status()).toBe(200); expect(await searchResponse.json()).toHaveLength(1); await expect(page.locator('[data-kanban-card-id]')).toHaveCount(1); await expect(page.locator('[data-kanban-card-id]')).toContainText('Phase6 capacity 1000'); observations.push({ name: 'Large Jobs search including existing debounce', readyMs: Date.now() - interactionStart }); const dashboardLoaded = page.waitForResponse(response => new URL(response.url()).pathname === '/api/jobapplications/stats'); interactionStart = Date.now(); await page.getByRole('navigation', { name: t.navigation, exact: true }).getByRole('button', { name: t.dashboard, exact: true }).click(); expect((await dashboardLoaded).status()).toBe(200); await expect(page.getByRole('link', { name: 'Tracked jobs: 1,000', exact: true })).toBeVisible(); observations.push({ name: 'Client Jobs to Dashboard navigation', readyMs: Date.now() - interactionStart }); expect(await page.evaluate(() => performance.timeOrigin)).toBe(clientTimeOrigin); page.off('request', trackClient); observations.push({ name: 'Client interactions request queries; unchanged document timeOrigin', timeOrigin: clientTimeOrigin, requests: clientRequests }); await info.attach('phase6-performance', { body: JSON.stringify({ browserVersion: context.browser()?.version(), viewport: page.viewportSize(), environment: { platform: process.platform, application: process.env.PLAYWRIGHT_STATIC_EXPORT === 'true' ? 'production static export' : 'Next development server', prebuiltExport: process.env.PLAYWRIGHT_PREBUILT_EXPORT === 'true', serverReuseAllowed: process.env.PLAYWRIGHT_REUSE_SERVERS === 'true', database: process.env.PLAYWRIGHT_REUSE_SERVERS === 'true' ? 'reused server database not independently verified' : 'isolated synthetic SQLite from native test configuration', api: 'Release API from native test configuration' }, observations, limits: ['Single reproducible diagnostic, no production budget or device qualification', 'Hard-navigation measurements include boot/auth; no React render-count inference', 'CDP heap samples are observations, not a leak diagnosis'] }), contentType: 'application/json' }); }); async function collect(page: Page, info: TestInfo, sample: Sample, detail = '') { await page.evaluate(async () => { await document.fonts.ready; await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); }); const state = `${sample.area}-${sample.width}x${sample.height ?? 900}-${sample.language}-${sample.theme}${detail ? `-${detail}` : ''}`; const geometry = await page.evaluate(() => { const label = (element: Element) => element.getAttribute('aria-label') || element.getAttribute('title') || element.textContent?.trim().slice(0, 100) || (element as HTMLInputElement).labels?.[0]?.textContent?.trim() || ''; const controls = Array.from(document.querySelectorAll('main button,main a[href],main input:not([type=hidden]),main select,main textarea,main [role=button],main [role=tab],header button,[role=dialog] button,[role=dialog] input')) .filter(element => { const style = getComputedStyle(element); return element.getClientRects().length > 0 && style.visibility !== 'hidden'; }) .map(element => { const rect = element.getBoundingClientRect(); const center = { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }; const centerInViewport = center.x >= 0 && center.x < innerWidth && center.y >= 0 && center.y < innerHeight; const hit = centerInViewport ? document.elementFromPoint(center.x, center.y) : null; const clippedBy: { role: string | null; label: string; overflowX: string; overflowY: string; horizontal: boolean; vertical: boolean }[] = []; for (let parent = element.parentElement; parent; parent = parent.parentElement) { const style = getComputedStyle(parent); const bounds = parent.getBoundingClientRect(); const horizontal = ['auto', 'scroll', 'hidden', 'clip'].includes(style.overflowX) && (rect.left < bounds.left - 1 || rect.right > bounds.right + 1); const vertical = ['auto', 'scroll', 'hidden', 'clip'].includes(style.overflowY) && (rect.top < bounds.top - 1 || rect.bottom > bounds.bottom + 1); if (horizontal || vertical) clippedBy.push({ role: parent.getAttribute('role'), label: label(parent), overflowX: style.overflowX, overflowY: style.overflowY, horizontal, vertical }); } return { tag: element.tagName.toLowerCase(), role: element.getAttribute('role'), labelApproximation: label(element), labelSource: 'DOM approximation; actual names are in the separate ARIA snapshot', disabled: element.hasAttribute('disabled') || element.getAttribute('aria-disabled') === 'true', bounds: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, horizontalOutsideViewport: rect.left < -1 || rect.right > innerWidth + 1, centerObstructionChecked: centerInViewport, obstructedAtCenter: centerInViewport && !element.contains(hit) && !hit?.contains(element), obstruction: hit ? { tag: hit.tagName.toLowerCase(), role: hit.getAttribute('role'), labelApproximation: label(hit) } : null, clippedBy, targetBelow24CssPx: rect.width < 24 || rect.height < 24 }; }); const ariaReferences = Array.from(document.querySelectorAll('[role=tab],[role=tabpanel],[role=dialog],[aria-labelledby],[aria-describedby],[aria-controls]')).map(element => ({ role: element.getAttribute('role'), id: element.id, label: label(element), references: ['aria-labelledby', 'aria-describedby', 'aria-controls'].map(attribute => ({ attribute, value: element.getAttribute(attribute), missingIds: (element.getAttribute(attribute) ?? '').split(/\s+/).filter(id => id && !document.getElementById(id)) })) })); return { viewport: { width: innerWidth, height: innerHeight, devicePixelRatio }, pageHorizontalOverflow: Math.max(0, document.documentElement.scrollWidth - innerWidth), scroll: { x: scrollX, y: scrollY }, ariaReferences, focused: { tag: document.activeElement?.tagName, label: document.activeElement ? label(document.activeElement) : null }, coarsePointer: matchMedia('(pointer:coarse)').matches, reducedMotion: matchMedia('(prefers-reduced-motion:reduce)').matches, controls }; }); const snapshot = await page.locator('body').ariaSnapshot(); const axe = await new AxeBuilder({ page }).options({ rules: { 'target-size': { enabled: true } } }).analyze(); const incomplete = axe.incomplete.map(rule => ({ id: rule.id, impact: rule.impact, description: rule.description, help: rule.help, helpUrl: rule.helpUrl, nodes: rule.nodes.map(node => ({ target: node.target, html: node.html, failureSummary: node.failureSummary, checks: [...node.any, ...node.all, ...node.none].map(check => ({ id: check.id, impact: check.impact, message: check.message, data: check.data, relatedNodes: check.relatedNodes })) })) })); const screenshot = info.outputPath(`${state}.png`); await page.screenshot({ path: screenshot, animations: 'disabled' }); await info.attach(state, { path: screenshot, contentType: 'image/png' }); const evidence = { sample, state, url: page.url(), evidenceKind: 'automated collection; findings require triage, not product acceptance', geometry, ariaSnapshot: snapshot, potentialUnnamedActionLines: snapshot.split('\n').filter(line => /^\s*- (button|link|textbox|combobox|checkbox|switch|tab)(:| \[)/.test(line)), axe: { scope: 'All enabled axe rules, including best practices and WCAG 2.2 AA target-size', targetSizeExplicitlyEnabled: true, violations: axe.violations, incomplete, passes: axe.passes.map(rule => ({ id: rule.id, nodes: rule.nodes.length })), incompleteNodeOccurrences: incomplete.reduce((sum, rule) => sum + rule.nodes.length, 0) }, limitations: ['No human screen-reader verification', 'Center hit-testing and DOM clipping are triage signals, not proof an action is unusable', 'No physical touch or software keyboard', 'No actual browser zoom measurement'] }; await info.attach(`${state}-audit`, { body: JSON.stringify(evidence), contentType: 'application/json' }); return evidence; } test('[P5-15-02 COLLECTION] thirteen areas retain native rendered entry across the distributed responsive/accessibility matrix', async ({ page, browser, owner }, info) => { test.setTimeout(360_000); await info.attach('synthetic-owned-fixture-identifiers', { body: JSON.stringify({ jobId: owner.jobId, cvId: owner.cvId, actor: 'synthetic administrator with private owned Jobs/CV', recordCount: 6, accountCredentials: 'not included' }), contentType: 'application/json' }); const anonymous = await browser.newContext({ viewport: { width: 320, height: 844 } }); try { await localOnly(anonymous); await anonymous.addInitScript(() => { localStorage.setItem('uiLanguage', 'en'); localStorage.setItem('jobtracker.themeMode', 'light'); }); const publicPage = await anonymous.newPage(); await publicPage.goto('http://localhost:3300/login'); await expect(publicPage.getByLabel(translations.en.usernameOrEmail, { exact: true })).toBeVisible(); await collect(publicPage, info, { area: 'authentication', path: '/login', width: 320, height: 844, language: 'en', theme: 'light' }); } finally { await anonymous.close(); } const samples: Sample[] = [ ...[767, 768, 899, 900, 1023, 1024].map(width => ({ area: 'shell' as const, path: '/jobs', width, language: 'nb' as const, theme: 'dark' as const })), { area: 'commands', path: '/jobs', width: 390, language: 'nb', theme: 'dark' }, { area: 'jobs', path: '/jobs', width: 1440, language: 'en', theme: 'light' }, { area: 'jobs', path: '/jobs', width: 320, language: 'nb', theme: 'dark' }, { area: 'board', path: '/jobs?view=board', width: 1280, language: 'nb', theme: 'dark' }, { area: 'board', path: '/jobs?view=board', width: 390, language: 'en', theme: 'light' }, { area: 'dashboard', path: '/dashboard', width: 1440, language: 'en', theme: 'light' }, { area: 'dashboard', path: '/dashboard', width: 768, language: 'nb', theme: 'dark' }, { area: 'analytics', path: '/analytics?months=6', width: 1024, language: 'nb', theme: 'dark' }, { area: 'analytics', path: '/analytics?months=6', width: 390, language: 'en', theme: 'light' }, { area: 'workspace', path: `/jobs/${owner.jobId}?section=overview`, width: 768, language: 'nb', theme: 'dark' }, { area: 'workspace', path: `/jobs/${owner.jobId}?section=timeline`, width: 320, language: 'en', theme: 'light' }, { area: 'profile', path: '/career?section=profile', width: 1440, language: 'en', theme: 'light' }, { area: 'profile', path: '/career?section=profile', width: 390, language: 'nb', theme: 'dark' }, { area: 'cv', path: `/career/builder/${owner.cvId}`, width: 1440, language: 'en', theme: 'light' }, { area: 'cv', path: `/career/builder/${owner.cvId}`, width: 768, language: 'nb', theme: 'dark' }, { area: 'cv', path: `/career/builder/${owner.cvId}`, width: 390, language: 'en', theme: 'light' }, { area: 'documents', path: `/jobs/${owner.jobId}?section=documents`, width: 1024, language: 'en', theme: 'light' }, { area: 'documents', path: `/jobs/${owner.jobId}?section=documents`, width: 320, language: 'nb', theme: 'dark' }, { area: 'settings', path: '/settings', width: 1440, language: 'en', theme: 'light' }, { area: 'settings', path: '/settings', width: 320, language: 'nb', theme: 'dark' }, { area: 'admin', path: '/admin/users', width: 1280, language: 'en', theme: 'light' }, { area: 'admin', path: '/admin/users', width: 768, language: 'nb', theme: 'dark' }, { area: 'admin', path: '/admin/users', width: 390, language: 'en', theme: 'light' }, ]; const findings: Array<{ sample: Sample; overflow: number; violations: number; incompleteNodeOccurrences: number }> = []; for (const sample of samples) { await test.step(`${sample.area} ${sample.width} ${sample.language}/${sample.theme}`, async () => { await enter(page, owner, sample); if (sample.area === 'shell') { const navigation = page.getByRole('button', { name: translations[sample.language].openNavigation, exact: true }); if (sample.width < 1024) await expect(navigation).toHaveCount(1); else await expect(navigation).toHaveCount(0); } const collected = await collect(page, info, sample); findings.push({ sample, overflow: collected.geometry.pageHorizontalOverflow, violations: collected.axe.violations.length, incompleteNodeOccurrences: collected.axe.incompleteNodeOccurrences }); }); } expect(new Set(['authentication', ...samples.map(sample => sample.area)]).size).toBe(13); await info.attach('distributed-thirteen-area-matrix', { body: JSON.stringify({ collectionOnly: true, samples: findings, authenticationSamples: 1, findingsRequireAcceptanceTriage: true }), contentType: 'application/json' }); }); test('[P5-15-02] reduced-motion mobile drawer enters focus and Escape returns it', async ({ page, owner }, info) => { test.setTimeout(60_000); await page.addInitScript(() => { const original = HTMLElement.prototype.focus; const state = window as Window & { drawerFocusAttempts?: unknown[] }; state.drawerFocusAttempts = []; HTMLElement.prototype.focus = function (options) { if (this.getAttribute('role') === 'dialog') state.drawerFocusAttempts!.push({ visibility: getComputedStyle(this).visibility, parentVisibility: this.closest('.MuiModal-root') ? getComputedStyle(this.closest('.MuiModal-root')!).visibility : null }); original.call(this, options); }; }); const t = translations.en; const sample: Sample = { area: 'shell', path: '/jobs', width: 390, language: 'en', theme: 'light' }; await enter(page, owner, sample); const navigation = page.getByRole('button', { name: t.openNavigation, exact: true }); await navigation.focus(); await navigation.press('Enter'); const drawer = page.getByRole('dialog', { name: t.navigation, exact: true }); await expect.soft(drawer.getByRole('button', { name: t.dashboard, exact: true })).toBeFocused(); const focus = () => page.evaluate(() => ({ active: document.activeElement?.outerHTML.slice(0, 500), focusedInDialog: !!document.activeElement?.closest('[role=dialog]'), attempts: (window as Window & { drawerFocusAttempts?: unknown[] }).drawerFocusAttempts, dialogs: Array.from(document.querySelectorAll('[role=dialog]')).map(node => ({ html: node.outerHTML.slice(0, 400), visibility: getComputedStyle(node).visibility, modalVisibility: node.closest('.MuiModal-root') ? getComputedStyle(node.closest('.MuiModal-root')!).visibility : null, focusable: node.getAttribute('tabindex'), bounds: node.getBoundingClientRect().toJSON() })) })); const beforeScan = await focus(); await collect(page, info, sample, 'navigation-open'); const afterScan = await focus(); await page.keyboard.press('Escape'); await expect.soft(navigation).toBeFocused(); const afterEscape = await focus(); const stayedOpen = await drawer.isVisible(); if (stayedOpen) { await page.keyboard.press('Tab'); await page.keyboard.press('Escape'); } await info.attach('drawer-focus-recovery', { body: JSON.stringify({ beforeScan, afterScan, afterEscape, stayedOpen, afterTabAndEscape: await focus(), drawerStillVisible: await drawer.isVisible(), humanScreenReader: 'NOT VERIFIED' }), contentType: 'application/json' }); await expect.soft(drawer).not.toBeVisible(); }); test('[P5-15-02] keyboard command, dialog, section and document controls keep deliberate focus contracts', async ({ page, owner }, info) => { test.setTimeout(180_000); const t = translations.en; const sample: Sample = { area: 'shell', path: '/jobs', width: 390, language: 'en', theme: 'light' }; await enter(page, owner, sample); const command = page.getByRole('button', { name: new RegExp(t.quickSearch) }); await command.focus(); await command.press('Enter'); const dialog = page.getByRole('dialog', { name: t.quickSearch, exact: true }); const input = dialog.getByRole('textbox', { name: t.quickSearch, exact: true }); await expect(input).toBeFocused(); await input.fill('no-such-audit-record'); await expect(dialog.getByText(t.noMatchingCommands, { exact: true })).toBeVisible(); await collect(page, info, { ...sample, area: 'commands' }, 'empty-keyboard'); await page.keyboard.press('Escape'); await expect(command).toBeFocused(); await command.press('Enter'); await input.fill(''); await dialog.getByRole('button', { name: new RegExp(t.addNewJob) }).press('Enter'); await expect(dialog).not.toBeVisible(); await expect(page.getByRole('dialog')).toBeVisible(); await collect(page, info, { ...sample, area: 'jobs' }, 'add-dialog-keyboard'); await page.keyboard.press('Escape'); await expect(page.getByRole('dialog')).toHaveCount(0); await enter(page, owner, { ...sample, area: 'workspace', path: `/jobs/${owner.jobId}?section=overview`, width: 768 }); const overview = page.getByRole('tab', { name: t.workspaceOverview, exact: true }); await overview.focus(); await overview.press('ArrowRight'); const match = page.getByRole('tab', { name: t.workspaceMatch, exact: true }); await expect(match).toBeFocused(); await expect(overview).toHaveAttribute('aria-selected', 'true'); await match.press('Enter'); await expect(match).toHaveAttribute('aria-selected', 'true'); await collect(page, info, { ...sample, area: 'workspace', path: page.url(), width: 768 }, 'keyboard-section'); await enter(page, owner, { ...sample, area: 'cv', path: `/career/builder/${owner.cvId}`, width: 1440 }); const sectionSelect = page.getByRole('combobox', { name: new RegExp(`^${t.cvEditorGoToSection}`) }); await sectionSelect.focus(); await sectionSelect.press('Space'); await page.keyboard.press('Home'); for (let index = 0; index < 4; index++) await page.keyboard.press('ArrowDown'); await page.keyboard.press('Enter'); await expect(page.getByRole('button', { name: t.cvEditorCollapseSection.replace('{section}', t.cvSectionSkills), exact: true })).toBeFocused(); await collect(page, info, { ...sample, area: 'cv', path: page.url(), width: 1440 }, 'keyboard-section-selector'); const move = page.getByRole('button', { name: t.cvEditorMoveSectionDown.replace('{section}', t.cvSectionExperience), exact: true }); await move.focus(); await move.press('Enter'); await expect(move).toBeFocused(); await expect.poll(async () => (await (await page.request.get(`${apiUrl}/cv/variants/${owner.cvId}`)).json()).settings.sections.slice(0, 5).map((section: { key: string }) => section.key)).toEqual(['summary', 'education', 'experience', 'projects', 'skills']); const preview = page.getByRole('region', { name: t.cvEditorLivePreview, exact: true }); await page.getByRole('button', { name: t.cvEditorFocusPreview, exact: true }).press('Enter'); await expect(preview).toBeFocused(); await expect(preview).toHaveAttribute('aria-busy', 'false'); await page.keyboard.press('Control+End'); await expect.poll(() => preview.evaluate(node => Math.abs(node.scrollHeight - node.clientHeight - node.scrollTop))).toBeLessThan(3); await collect(page, info, { ...sample, area: 'cv', path: page.url(), width: 1440 }, 'keyboard-preview-end'); await page.getByRole('button', { name: t.cvEditorFocusEditor, exact: true }).press('Enter'); await expect(page.getByRole('region', { name: t.cvEditorEditingRegion, exact: true })).toBeFocused(); await info.attach('native-keyboard-contracts', { body: JSON.stringify({ commandEntryAndEscapeReturn: true, commandWorkflowDialog: true, workspaceArrowNavigation: true, cvKeyboardOrderPersisted: true, previewEntryScrollExit: true, evidence: 'preceding hard assertions completed', humanScreenReader: 'NOT VERIFIED' }), contentType: 'application/json' }); }); test('[P5-15-02 COLLECTION] text-spacing overrides and landscape reflow retain inspectable native controls, without claiming browser zoom', async ({ page, owner }, info) => { test.setTimeout(180_000); for (const [area, path] of [['dashboard', '/dashboard'], ['analytics', '/analytics?months=6'], ['jobs', '/jobs'], ['documents', `/jobs/${owner.jobId}?section=documents`], ['settings', '/settings'], ['cv', `/career/builder/${owner.cvId}`]] as const) { const sample: Sample = { area, path, width: 844, height: 390, language: 'nb', theme: 'dark', state: 'landscape-text-spacing' }; await enter(page, owner, sample); await page.addStyleTag({ content: 'body * { line-height: 1.5 !important; letter-spacing: .12em !important; word-spacing: .16em !important; } p { margin-block-end: 2em !important; }' }); const spacing = await page.locator('body').evaluate(node => ({ rootFont: getComputedStyle(node).fontSize, letterSpacing: getComputedStyle(node.querySelector('button') ?? node).letterSpacing })); await collect(page, info, sample, 'text-spacing'); await info.attach(`${area}-text-spacing-method`, { body: JSON.stringify({ spacing, method: 'WCAG text-spacing CSS overrides in parent UI document; document renderer iframe is untouched', viewport: '844x390 landscape CSS viewport', browserZoom: 'NOT VERIFIED', pinchZoom: 'NOT VERIFIED', softwareKeyboard: 'NOT VERIFIED' }), contentType: 'application/json' }); } }); test('[P5-15-02 C06 C07] CV source action reaches the existing Career Profile workflow', async ({ page, owner }, info) => { const t = translations.en; await enter(page, owner, { area: 'cv', path: `/career/builder/${owner.cvId}`, width: 1440, language: 'en', theme: 'light' }); const link = page.getByRole('link', { name: t.cvEditorEditMasterProfile, exact: true }); const href = await link.getAttribute('href'); await link.press('Enter'); await expect(page.getByRole('main')).toBeVisible(); await info.attach('cv-source-action-destination', { body: JSON.stringify({ href, destination: page.url(), expected: '/career or /career?section=profile; established canonical source workflow', observedText: await page.getByRole('main').innerText() }), contentType: 'application/json' }); await expect(page).toHaveURL(url => url.pathname === '/career'); await expect(page).toHaveURL(url => url.searchParams.get('section') === 'profile'); await expect(page.getByLabel(t.profileCvContactFullName, { exact: true })).toHaveValue('Åse Revisjonseksempel'); await page.goBack(); await expect(page.getByLabel(t.cvEditorName, { exact: true })).toHaveValue('Audit CV — dokumenterte ferdigheter og erfaring æøå'); }); test('[P5-15-04 S01] recorded-status drilldowns have usable targets and retain custom status queries', async ({ page, owner }, info) => { for (const sample of [ { area: 'analytics', path: '/analytics?months=6', width: 390, language: 'en', theme: 'light' }, { area: 'analytics', path: '/analytics?months=6', width: 1024, language: 'nb', theme: 'dark' }, ] as const) { await enter(page, owner, sample); const details = page.getByRole('region', { name: translations[sample.language].dashboardCurrentPipeline, exact: true }).locator('details'); await expect(details).toHaveAttribute('open', ''); const link = page.getByRole('link', { name: 'Faglig vurdering', exact: true }); await expect(link).toBeVisible(); const bounds = await link.boundingBox(); expect(bounds!.height, 'standalone status target height').toBeGreaterThanOrEqual(24); expect(bounds!.width, 'standalone status target width').toBeGreaterThanOrEqual(24); await link.focus(); await link.press('Enter'); await expect(page).toHaveURL(url => url.pathname === '/jobs' && url.searchParams.get('status') === 'Faglig vurdering'); await expect(page.getByText('Audit platform engineer 6 — langsiktig utvikling og inkluderende tjenester', { exact: true }).first()).toBeVisible(); await info.attach(`status-target-${sample.language}`, { body: JSON.stringify({ bounds, destination: page.url() }), contentType: 'application/json' }); } }); test('[P5-15-03 C08 C12 E03] account photo reaches preview and native PDF only when included', async ({ page, owner }, info) => { test.setTimeout(120_000); const photo = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64'); const upload = await page.request.post(`${apiUrl}/auth/avatar`, { headers: owner.headers, multipart: { file: { name: 'synthetic-photo.png', mimeType: 'image/png', buffer: photo } } }); expect(upload.status()).toBe(200); const dataUrl = (await upload.json()).avatarImageDataUrl; expect(dataUrl).toMatch(/^data:image\/png;base64,/); for (const themeId of ['modern', 'technical']) { for (const showPhoto of [true, false]) { const current = await (await page.request.get(`${apiUrl}/cv/variants/${owner.cvId}`)).json(); expect((await page.request.put(`${apiUrl}/cv/variants/${owner.cvId}`, { headers: owner.headers, data: { expectedVersion: current.version, settings: { ...current.settings, themeId, showPhoto } } })).status()).toBe(200); await enter(page, owner, { area: 'cv', path: `/career/builder/${owner.cvId}`, width: 1440, language: 'en', theme: 'dark' }); const frame = page.getByTitle('CV preview', { exact: true }); const images = await frame.evaluate(element => Array.from((element as HTMLIFrameElement).contentDocument!.images).map(image => ({ src: image.src, complete: image.complete, width: image.naturalWidth }))); expect(images.filter(image => image.src === dataUrl)).toHaveLength(showPhoto ? 1 : 0); if (showPhoto) expect(images.find(image => image.src === dataUrl)).toEqual({ src: dataUrl, complete: true, width: 1 }); const pdf = await page.request.post(`${apiUrl}/cv/variants/${owner.cvId}/export-pdf`, { headers: owner.headers }); expect(pdf.status()).toBe(200); await info.attach(`photo-${themeId}-${showPhoto ? 'included' : 'hidden'}.pdf`, { body: await pdf.body(), contentType: 'application/pdf' }); await info.attach(`photo-${themeId}-${showPhoto ? 'included' : 'hidden'}.json`, { body: JSON.stringify({ themeId, showPhoto, previewPages: await page.locator('[data-cv-page]').count(), decodedPhotoCount: images.filter(image => image.src === dataUrl).length }), contentType: 'application/json' }); } } }); test('[P5-15-02 W08 J10] keyboard upload and import controls open the native file chooser', async ({ page, owner }, info) => { test.setTimeout(90_000); const t = translations.en; const outcomes: Array<{ route: string; label: string; key: string; opened: boolean }> = []; for (const [area, path, labels] of [ ['documents', `/jobs/${owner.jobId}?section=documents`, [t.attachmentsUpload]], ['settings', '/settings', [t.importJson, t.convertCsvToImportJson]], ] as const) { await enter(page, owner, { area, path, width: 768, language: 'en', theme: 'light' }); for (const label of labels) { const control = page.getByRole('button', { name: label, exact: true }); await expect(control).toBeVisible(); for (const key of ['Enter', 'Space']) { await control.focus(); await expect(control).toBeFocused(); const [chooser] = await Promise.all([page.waitForEvent('filechooser', { timeout: 3000 }).catch(() => null), control.press(key)]); outcomes.push({ route: path, label, key, opened: !!chooser }); if (chooser) await chooser.setFiles([]); expect.soft(!!chooser, `${label} ${key} opens the native picker`).toBe(true); } const [pointerChooser] = await Promise.all([page.waitForEvent('filechooser', { timeout: 3000 }).catch(() => null), control.click()]); outcomes.push({ route: path, label, key: 'pointer comparison', opened: !!pointerChooser }); if (pointerChooser) await pointerChooser.setFiles([]); expect.soft(!!pointerChooser, `${label} pointer comparison opens the same picker`).toBe(true); } } await info.attach('native-keyboard-file-chooser', { body: JSON.stringify({ outcomes, fileDataWritten: false }), contentType: 'application/json' }); }); test('[P5-15-02 C06 C09 C12] mobile edit-to-preview retains physical document measurement', async ({ page, owner }, info) => { test.setTimeout(60_000); const t = translations.en; await enter(page, owner, { area: 'cv', path: `/career/builder/${owner.cvId}`, width: 390, height: 844, language: 'en', theme: 'light' }); const metrics = () => page.locator('iframe').evaluateAll(frames => frames.filter(frame => (frame as HTMLIFrameElement).contentDocument?.querySelector('.page')).map(frame => { const doc = (frame as HTMLIFrameElement).contentDocument!; const sheet = doc.querySelector('.page')!; return { title: frame.getAttribute('title'), hiddenMeasurement: frame.getAttribute('aria-hidden') === 'true', frameVisible: frame.getBoundingClientRect().width > 0, measuredWidth: Number(doc.documentElement.dataset.cvPreviewWidth), measuredPages: Number(doc.documentElement.dataset.cvPreviewPages), ready: doc.documentElement.dataset.cvPreviewReady, physicalPageWidth: sheet.getBoundingClientRect().width, flowWidth: doc.querySelector('[data-cv-preview-flow]')?.getBoundingClientRect().width }; })); const inEdit = await metrics(); await page.getByRole('button', { name: t.cvEditorPreview, exact: true }).press('Enter'); const preview = page.getByRole('region', { name: t.cvEditorLivePreview, exact: true }); await expect(preview).toBeVisible(); await expect(preview).toHaveAttribute('aria-busy', 'false'); await page.evaluate(async () => { await document.fonts.ready; await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); }); const inPreview = await metrics(); const screenshot = info.outputPath('mobile-preview-measurement-390-en-light.png'); await page.screenshot({ path: screenshot, animations: 'disabled' }); await info.attach('mobile-preview-measurement', { path: screenshot, contentType: 'image/png' }); await info.attach('mobile-preview-physical-measurement', { body: JSON.stringify({ inEdit, inPreview, pageCountText: await page.getByRole('status').allTextContents(), method: 'native DOM measurements before axe; A4 physical width 210mm =793.7008CSSpx at96dpi' }), contentType: 'application/json' }); const pdf = await page.request.post(`${apiUrl}/cv/variants/${owner.cvId}/export-pdf`, { headers: owner.headers }); expect(pdf.status()).toBe(200); const exportPath = info.outputPath('mobile-preview-export.pdf'); await writeFile(exportPath, await pdf.body()); await info.attach('mobile-preview-native-export', { path: exportPath, contentType: 'application/pdf' }); const measurement = inPreview.find(frame => frame.hiddenMeasurement)!; expect(measurement.measuredWidth, 'hidden mobile pane must not publish a zero-width document').toBeGreaterThan(790); expect(measurement.measuredWidth).toBeLessThan(798); for (const frame of inPreview.filter(frame => frame.frameVisible)) { expect(frame.flowWidth, `${frame.title} content flow matches physical paper width`).toBeGreaterThan(790); expect(frame.flowWidth).toBeLessThan(798); } }); test.describe('Chromium emulated touch; not physical-device acceptance', () => { test.use({ hasTouch: true }); test('[P5-15-02] emulated taps preserve CV modes and navigation across portrait/landscape viewport changes', async ({ page, owner }, info) => { test.setTimeout(120_000); const t = translations.nb; const sample: Sample = { area: 'cv', path: `/career/builder/${owner.cvId}`, width: 390, height: 844, language: 'nb', theme: 'dark' }; await enter(page, owner, sample); const original = await (await page.request.get(`${apiUrl}/cv/variants/${owner.cvId}`)).json(); await page.getByRole('button', { name: t.cvEditorPreview, exact: true }).tap(); const preview = page.getByRole('region', { name: t.cvEditorLivePreview, exact: true }); await expect(preview).toBeVisible(); await expect(preview).toHaveAttribute('aria-busy', 'false'); await expect(page.getByRole('button', { name: t.cvEditorPreview, exact: true })).toHaveAttribute('aria-pressed', 'true'); await collect(page, info, sample, 'emulated-touch-preview'); await page.setViewportSize({ width: 844, height: 390 }); await expect(preview).toBeVisible(); await collect(page, info, { ...sample, width: 844, height: 390 }, 'emulated-touch-landscape'); await page.getByRole('button', { name: t.cvEditorEdit, exact: true }).tap(); await expect(page.getByRole('region', { name: t.cvEditorEditingRegion, exact: true })).toBeVisible(); expect(await (await page.request.get(`${apiUrl}/cv/variants/${owner.cvId}`)).json()).toEqual(original); await page.setViewportSize({ width: 390, height: 844 }); const navigation = page.getByRole('button', { name: t.openNavigation, exact: true }); await navigation.tap(); await expect(page.getByRole('dialog', { name: t.navigation, exact: true })).toBeVisible(); await collect(page, info, { ...sample, area: 'shell' }, 'emulated-touch-navigation'); const drawer = page.getByRole('dialog', { name: t.navigation, exact: true }); await expect(drawer.getByRole('button', { name: t.dashboard, exact: true })).toBeFocused(); for (const name of [t.discoverJobs, t.companies, t.offerComparisonNav, t.trash]) await expect(drawer.getByRole('button', { name, exact: true })).toBeVisible(); const settled = await collect(page, info, { ...sample, area: 'shell' }, 'emulated-touch-navigation-settled'); expect(settled.axe.violations.filter(rule => rule.id === 'aria-command-name')).toEqual([]); await page.keyboard.press('Escape'); await expect.soft(navigation).toBeFocused(); await info.attach('emulated-touch-boundary', { body: JSON.stringify({ hasTouch: true, input: 'Playwright Chromium touch tap', portraitLandscape: 'CSS viewport changes, not physical orientation sensor', physicalTouch: 'NOT VERIFIED', nativeTouchDrag: 'NOT VERIFIED', softwareKeyboard: 'NOT VERIFIED', cvUnchanged: true }), contentType: 'application/json' }); }); });