Files
jobtrackingapp/job-tracker-ui/e2e/responsive-accessibility-audit.spec.ts

658 lines
58 KiB
TypeScript

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<string, string>; 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<void>) => {
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' });
});
});