// Actual exported React UI; all API responses and status writes are synthetic. import { chromium, expect } from '@playwright/test'; import AxeBuilder from '@axe-core/playwright'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; const visualMatrix = Boolean(process.env.A3_VISUAL_MATRIX); const flatWorkspace = Boolean(process.env.A3_FLAT_WORKSPACE); const matchingCountOnly = Boolean(process.env.A3_MATCHING_COUNT); assert.ok(!matchingCountOnly || !flatWorkspace, 'Count presentation does not repeat workspace qualification.'); assert.ok(!flatWorkspace || visualMatrix, 'Flat workspace qualification uses the visual matrix.'); const widths = visualMatrix || matchingCountOnly ? [320, 360, 390, 430, 768, 1024, 1280, 1440, 1920] : [320, 390, 768, 1440]; const configurations = process.env.A3_PILOT ? [[320, 'en', 'light']] : widths.flatMap(width => ['en', 'nb'].flatMap(language => ['light', 'dark'].map(mode => [width, language, mode]))); const out = path.resolve(import.meta.dirname, matchingCountOnly ? '../../docs/ui-modernisation/visual-correction/evidence/matching-count' : flatWorkspace ? '../../docs/ui-modernisation/visual-correction/evidence/applications-flat' : visualMatrix ? '../../docs/ui-modernisation/visual-correction/evidence/applications' : '../../docs/ui-modernisation/evidence/applications/browser'); fs.mkdirSync(out, { recursive: true }); const report = { screens: [], journeys: [], aliases: [], focusChecks: [], errors: [], unexpectedWrites: [], mockedWrites: [], unavailableReads: [], blockedExternal: [] }; const browser = await chromium.launch(); try { for (const [width, language, mode] of configurations) { console.log(`Checking ${width}/${language}/${mode}`); const context = await browser.newContext({ viewport: { width, height: 1000 }, reducedMotion: 'reduce' }); const page = await context.newPage(); page.on('pageerror', error => report.errors.push(error.message)); await page.addInitScript(({ language, mode }) => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', mode); }, { language, mode }); const jobs = ['Community nurse', 'Primary teacher', 'Electrical engineer'].map((jobTitle, index) => ({ id: 42 + index, version: 7, jobTitle, company: { id: 1, name: 'Fjord Care' }, companyId: 1, status: 'Saved', location: 'Oslo', countryCode: 'NO', source: 'nav', salary: null, dateApplied: null, savedAt: '2026-10-01T09:00:00Z', deadline: '2026-11-12', followUpAt: null, tags: 'Care coordination', description: 'Recorded source advertisement.', isDeleted: false, needsFollowUp: false, workflowSignal: null })); if (matchingCountOnly) jobs.splice(1); await page.route('**/*', route => { const req = route.request(), url = new URL(req.url()); if (url.origin !== 'http://localhost:4338' && !(url.origin === 'http://localhost:5202' && url.pathname.startsWith('/api/'))) { report.blockedExternal.push(url.origin); return route.abort(); } if (!url.pathname.startsWith('/api/')) return route.continue(); const answer = data => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(data) }); if (!matchingCountOnly && req.method() === 'PATCH' && url.pathname === '/api/jobapplications/42/status') { const { status } = req.postDataJSON(); assert.equal(status, 'Waiting'); jobs[0].status = status; report.mockedWrites.push({ width, language, mode, status }); return answer({}); } if (req.method() !== 'GET') { report.unexpectedWrites.push(url.pathname); return route.abort(); } const matching = jobs.filter(job => !url.searchParams.get('q') || job.jobTitle.toLowerCase().includes(url.searchParams.get('q').toLowerCase())); const fixtures = { '/api/auth/config': { requireAuth: true }, '/api/auth/me': { id: 'a3-applications-owner', sessionContext: 'a3-applications-session', firstName: 'Alex', displayName: 'Alex Sample', roles: [], uiLanguage: language === 'nb' ? 'nb-NO' : 'en' }, '/api/companies': [jobs[0].company], '/api/jobapplications': { items: matching, total: matching.length, page: 1, pageSize: 15 }, '/api/jobapplications/board': matching, '/api/jobapplications/reminders': [], '/api/notifications/unread-count': { count: 0 }, '/api/jobapplications/42/workspace': { ...jobs[0], revision: 7, company: jobs[0].company.name, stageGroup: 'Applied', stageOrder: 1, jobUrl: null, translatedDescription: null, descriptionLanguage: 'en', tags: ['Care coordination'], notes: 'Ask about shift patterns.', applicationAnswerDraft: '', recruiterMessageDraft: '', hasJobDescription: true, cv: { variantId: null, variantName: null, themeId: null, hasTailoredCvText: false, updatedAtUtc: null }, hasCoverLetter: false, documentCount: 0, hasPortfolio: false, aiInteractionCount: 0, lastAiAtUtc: null, recentActivity: [], nextStep: null, checklistProgress: { total: 0, completed: 0, dismissed: 0, percent: 0 } }, '/api/jobapplications/42/status-suggestion': null, }; if (Object.hasOwn(fixtures, url.pathname)) return answer(fixtures[url.pathname]); report.unavailableReads.push(url.pathname); return route.fulfill({ status: 404, contentType: 'application/json', body: '{}' }); }); const scan = async state => { assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1), false); const result = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa']).analyze(); report.screens.push({ width, language, mode, state, violations: result.violations }); assert.deepEqual(result.violations, []); await page.evaluate(() => window.scrollTo(0, 0)); await page.screenshot({ path: path.join(out, `${state}-${language}-${mode}-${width}.png`), fullPage: true }); }; await page.goto('http://localhost:4338/jobs'); const search = page.getByRole('textbox', { name: language === 'en' ? 'Search' : 'Søk', exact: true }); await search.waitFor(); await page.locator('[data-job-row-id="42"], [data-job-card-id="42"]').first().waitFor(); if (matchingCountOnly) { const label = language === 'en' ? '1 matching job' : '1 jobb samsvarer'; await expect(page.getByText(label, { exact: true })).toBeVisible(); await scan('table'); await page.getByRole('link', { name: language === 'en' ? 'Board' : 'Tavle', exact: true }).click(); await expect(page.locator('[data-kanban-card-id="42"]')).toBeVisible(); await expect(page.getByText(label, { exact: true })).toBeVisible(); await scan('board'); report.journeys.push({ width, language, mode, singularTableAndBoard: 'PASS', mutationsDetailAliases: 'NOT RUN' }); await context.close(); continue; } await scan('table'); if (flatWorkspace) { await page.keyboard.press('Tab'); const row = page.locator('[data-job-row-id="42"]').first(); await row.focus(); await expect(row).toBeFocused(); const focus = await row.evaluate(element => { const style = getComputedStyle(element), rect = element.getBoundingClientRect(); const extension = parseFloat(style.outlineWidth) + parseFloat(style.outlineOffset); const clippedBy = []; for (let parent = element.parentElement; parent; parent = parent.parentElement) { const bounds = parent.getBoundingClientRect(), overflow = getComputedStyle(parent).overflowX; if (['hidden', 'clip', 'auto', 'scroll'].includes(overflow) && (rect.left - extension < bounds.left || rect.right + extension > bounds.right)) clippedBy.push(parent.tagName); } return { outlineStyle: style.outlineStyle, outlineWidth: style.outlineWidth, clippedBy }; }); report.focusChecks.push({ width, language, mode, ...focus }); assert.notEqual(focus.outlineStyle, 'none'); if (width < 768) assert.deepEqual(focus.clippedBy, [], 'mobile row focus ring is clipped'); await scan('table-focused'); } if (visualMatrix) { await page.getByRole('button', { name: language === 'en' ? 'Add Job' : 'Legg til jobb', exact: true }).click(); await expect(page.getByRole('dialog')).toBeVisible(); await scan('add-job'); await page.keyboard.press('Escape'); await expect(page.getByRole('dialog')).toHaveCount(0); assert.equal(report.unexpectedWrites.length, 0); } await search.fill('nurse'); await page.waitForURL(/q=nurse/); await page.getByRole('link', { name: language === 'en' ? 'Board' : 'Tavle', exact: true }).click(); const card = page.locator('[data-kanban-card-id="42"]'); await card.waitFor(); await page.getByRole('button', { name: `${language === 'en' ? 'Quick status' : 'Hurtigstatus'}: Community nurse`, exact: true }).click(); await page.getByRole('menuitem', { name: language === 'en' ? 'Set Waiting' : 'Sett Venter', exact: true }).click(); await page.waitForFunction(() => document.querySelector('[data-kanban-card-id="42"]')?.innerText.includes(document.documentElement.lang === 'nb-NO' ? 'Venter' : 'Waiting')); await scan('board'); await card.locator('[role="button"]').press('Enter'); await page.waitForURL(/\/jobs\/42/); const contextName = language === 'en' ? 'Application context' : 'Søknadskontekst'; const aside = page.getByRole('complementary', { name: contextName }); await aside.waitFor(); await page.getByRole('button', { name: language === 'en' ? 'Jump to application context' : 'Gå til søknadskontekst', exact: true }).click(); assert.equal(await aside.getByRole('heading', { name: contextName }).evaluate(node => node === document.activeElement), true); await aside.getByRole('button', { name: language === 'en' ? 'Return to current task' : 'Tilbake til gjeldende oppgave', exact: true }).click(); assert.equal(await page.getByRole('tabpanel').evaluate(node => node === document.activeElement), true); const main = await page.getByRole('tabpanel').boundingBox(), side = await aside.boundingBox(); assert.ok(width > 1000 ? side.x > main.x + main.width - 2 && Math.abs(side.y - main.y) < 2 : side.y >= main.y + main.height - 2); await scan('detail'); if (flatWorkspace) { const pageTitleSize = await page.getByRole('heading', { level: 1 }).evaluate(element => parseFloat(getComputedStyle(element).fontSize)); const roleTitleSize = await page.getByRole('heading', { name: 'Community nurse', level: 2, exact: true }).evaluate(element => parseFloat(getComputedStyle(element).fontSize)); assert.ok(roleTitleSize > pageTitleSize, 'Recorded role title has stronger visual hierarchy than generic route label'); await page.getByRole('button', { name: language === 'en' ? 'Job details' : 'Stillingsdetaljer', exact: true }).click(); await expect(page.getByText('Recorded source advertisement.', { exact: true })).toBeVisible(); await scan('detail-expanded'); } await page.getByRole('button', { name: language === 'en' ? 'Back to applications' : 'Tilbake til søknader', exact: true }).click(); await page.waitForURL(/view=board/); assert.equal(new URL(page.url()).searchParams.get('q'), 'nurse'); await page.locator('[data-kanban-card-id="42"] [role="button"]').waitFor(); report.journeys.push({ width, language, mode, searchBoardPreciseStatusDetailContextReturn: 'PASS' }); if (visualMatrix && width === 320 && language === 'en' && mode === 'light') { for (const [legacy, destination, parameter] of [['/kanban?q=nurse#retained', '/jobs', ['view', 'board']], ['/applications/42?section=overview', '/jobs/42', ['section', 'overview']], ['/correspondence/review?q=recorded#retained', '/correspondence', ['view', 'review']]]) { await page.goto(`http://localhost:4338${legacy}`); await expect.poll(() => new URL(page.url()).pathname).toBe(destination); assert.equal(new URL(page.url()).searchParams.get(parameter[0]), parameter[1]); if (!legacy.startsWith('/applications/')) { assert.equal(new URL(page.url()).hash, '#retained'); assert.equal(new URL(page.url()).searchParams.get('q'), new URL(legacy, 'http://localhost:4338').searchParams.get('q')); } report.aliases.push({ legacy, destination, queryPreserved: 'PASS' }); } } await context.close(); } assert.equal(report.screens.length, configurations.length * (matchingCountOnly ? 2 : flatWorkspace ? 6 : visualMatrix ? 4 : 3)); assert.equal(report.journeys.length, configurations.length); assert.equal(report.mockedWrites.length, matchingCountOnly ? 0 : configurations.length); assert.equal(report.aliases.length, visualMatrix && !matchingCountOnly ? 3 : 0); assert.deepEqual(report.errors, []); assert.deepEqual(report.unexpectedWrites, []); assert.ok(report.blockedExternal.every(origin => origin === 'https://fonts.googleapis.com')); console.log(`PASS:${report.journeys.length} controlled table/board/detail journeys,${report.screens.length} axe scans/captures;${report.mockedWrites.length} mocked status writes.`); } finally { fs.writeFileSync(path.join(out, process.env.A3_PILOT ? 'PILOT.json' : 'BROWSER.json'), JSON.stringify({ ...report, limits: ['controlled fixtures, not real backend integration', 'secondary service reads may return honest unavailable states', 'no native device, provider, model, security or release qualification'] }, null, 2)); await browser.close(); }