375 lines
34 KiB
TypeScript
375 lines
34 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(); }
|
|
},
|
|
});
|
|
|
|
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();
|
|
}
|
|
|
|
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);
|
|
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]') }));
|
|
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.getByLabel(t.profileCvContactFullName, { exact: true })).toHaveValue('Åse Revisjonseksempel');
|
|
});
|
|
|
|
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');
|
|
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' });
|
|
});
|
|
});
|