Files
jobtrackingapp/job-tracker-ui/scripts/verify-a3-theme.mjs
T

140 lines
12 KiB
JavaScript

// Real built React application; controlled API responses only, never a live service.
import { chromium } 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 navigation = process.argv.includes('--navigation');
const out = path.resolve(import.meta.dirname, '../../docs/ui-modernisation/evidence', navigation ? 'navigation' : '.');
fs.mkdirSync(out, { recursive: true });
(async () => {
const browser = await chromium.launch();
const rows = [], errors = [], writes = [], mockedWrites = [], external = [], journeys = [];
try {
for (const width of [320, 390, 768, 1440]) for (const language of ['en', 'nb']) for (const mode of ['light', 'dark']) {
const context = await browser.newContext({ viewport: { width, height: 1000 }, reducedMotion: 'reduce' });
const page = await context.newPage();
page.on('pageerror', error => errors.push(error.message));
await page.addInitScript(({ language, mode }) => {
localStorage.setItem('uiLanguage', language);
localStorage.setItem('jobtracker.themeMode', mode);
}, { language, mode });
let companyName = 'Nordlys synthetic company';
await page.route('**/*', route => {
const request = route.request(), url = new URL(request.url());
if (!['localhost', '127.0.0.1'].includes(url.hostname)) { external.push(url.origin); return route.abort(); }
if (!url.pathname.startsWith('/api/')) return route.continue();
if (navigation && request.method() === 'PUT' && url.pathname === '/api/companies/7') {
const payload = request.postDataJSON(); assert.ok(typeof payload.name === 'string');
companyName = payload.name; mockedWrites.push({ method: 'PUT', path: url.pathname });
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ id: 7, ...payload }) });
}
if (request.method() !== 'GET') { writes.push({ method: request.method(), path: url.pathname }); return route.abort(); }
const fixtures = {
'/api/auth/config': { requireAuth: true, allowRegistration: true, googleEnabled: false, microsoftEnabled: false, turnstileEnabled: false },
'/api/auth/me': { id: 'a3-synthetic-owner', sessionContext: 'a3-synthetic-context', email: 'alex@example.test', roles: [], uiLanguage: language === 'nb' ? 'nb-NO' : 'en' },
'/api/companies': [{ id: 7, name: companyName, location: 'Tromsø', source: 'Manual' }],
'/api/jobapplications/reminders': [], '/api/notifications/unread-count': { count: 0 },
};
return route.fulfill({ status: Object.hasOwn(fixtures, url.pathname) ? 200 : 404, contentType: 'application/json', body: JSON.stringify(fixtures[url.pathname] ?? { message: 'Not part of this controlled theme check' }) });
});
for (const screen of ['login', 'companies']) {
await page.goto(`http://localhost:4338/${screen}`);
await page.locator('main h1').waitFor();
assert.equal(await page.locator('html').getAttribute('data-color-scheme'), mode);
assert.equal(await page.locator('html').getAttribute('lang'), language === 'nb' ? 'nb-NO' : 'en');
if (screen === 'companies') await page.getByText('Nordlys synthetic company', { exact: true }).waitFor();
const geometry = await page.evaluate(() => ({ overflow: document.documentElement.scrollWidth > innerWidth + 1, background: getComputedStyle(document.body).backgroundColor }));
assert.equal(geometry.overflow, false);
assert.equal(geometry.background, mode === 'light' ? 'rgb(255, 255, 255)' : 'rgb(40, 35, 52)');
const builder = new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa']);
const scan = await (navigation ? builder : builder.include('main')).analyze();
rows.push({ screen, width, language, mode, geometry, violations: scan.violations });
await page.screenshot({ path: path.join(out, `${screen}-${language}-${mode}-${width}.png`), fullPage: true });
assert.deepEqual(scan.violations, [], `${screen}/${mode}/${language}/${width} scoped accessibility`);
if (screen === 'login') {
await page.locator('button[type=submit]').click();
assert.ok(await page.locator('input[aria-invalid=true]').count() >= 1);
assert.equal(await page.locator('input').first().evaluate(node => node === document.activeElement), true);
assert.equal(await page.locator('input').first().evaluate(node => getComputedStyle(node.closest('.MuiOutlinedInput-root').querySelector('fieldset')).borderTopWidth), '3px');
} else {
const edit = page.locator('[data-company-edit="7"]'); await edit.focus();
await page.keyboard.press('Tab'); await page.keyboard.press('Shift+Tab');
assert.equal(await edit.evaluate(node => node === document.activeElement), true);
assert.equal(await edit.evaluate(node => getComputedStyle(node).outlineWidth), '3px');
await edit.press('Enter'); await page.getByRole('dialog').waitFor();
const dialog = await new AxeBuilder({ page }).include('[role=dialog]').withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa']).analyze();
rows.push({ screen: 'company-dialog', width, language, mode, violations: dialog.violations });
assert.deepEqual(dialog.violations, []);
await page.keyboard.press('Escape'); await page.getByRole('dialog').waitFor({ state: 'hidden' });
assert.equal(await edit.evaluate(node => node === document.activeElement), true);
if (navigation) {
const primary = page.getByRole('navigation', { name: language === 'en' ? 'Primary navigation' : 'Hovednavigasjon', exact: true });
const more = language === 'en' ? 'More' : 'Mer';
if (width < 768) {
assert.equal(await primary.getByRole('button').count(), 5);
if (language === 'en') for (const label of ['Dashboard', 'Applications']) {
const metrics = await primary.getByText(label, { exact: true }).evaluate(node => ({ height: node.getBoundingClientRect().height, width: node.getBoundingClientRect().width, font: getComputedStyle(node).font, lineHeight: Number.parseFloat(getComputedStyle(node).lineHeight), buttonWidth: node.parentElement.getBoundingClientRect().width }));
assert.ok(metrics.height <= metrics.lineHeight + 1, `${label} should fit one line: ${JSON.stringify(metrics)}`);
}
assert.equal(await primary.getByRole('button', { name: more, exact: true }).getAttribute('aria-current'), 'page');
await primary.getByRole('button', { name: more, exact: true }).focus(); await page.keyboard.press('Enter');
const drawer = page.getByRole('dialog'); await drawer.waitFor();
assert.equal(await drawer.getByRole('button', { name: language === 'en' ? 'Companies' : 'Selskaper', exact: true }).getAttribute('aria-current'), 'page');
const menuScan = await new AxeBuilder({ page }).include('[role=dialog]').withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa']).analyze();
rows.push({ screen: 'more-navigation', width, language, mode, violations: menuScan.violations }); assert.deepEqual(menuScan.violations, []);
await page.screenshot({ path: path.join(out, `more-${language}-${mode}-${width}.png`), fullPage: true });
await page.keyboard.press('Escape'); await drawer.waitFor({ state: 'hidden' });
assert.equal(await primary.getByRole('button', { name: more, exact: true }).evaluate(node => node === document.activeElement), true);
await primary.getByRole('button', { name: more, exact: true }).click(); await drawer.waitFor();
await drawer.getByRole('button', { name: language === 'en' ? 'Companies' : 'Selskaper', exact: true }).click();
await drawer.waitFor({ state: 'hidden' });
await page.waitForFunction(() => document.querySelector('main h1') === document.activeElement);
} else {
assert.equal(await primary.count(), 0);
await page.getByRole('button', { name: language === 'en' ? 'Collapse sidebar' : 'Skjul sidemenyen', exact: true }).click();
await page.reload(); await page.getByText(companyName, { exact: true }).waitFor();
assert.equal(await page.evaluate(() => localStorage.getItem('appShellDesktopSidebarCollapsed')), '1');
await page.screenshot({ path: path.join(out, `collapsed-${language}-${mode}-${width}.png`), fullPage: true });
}
await page.locator('[data-company-edit="7"]').click();
const form = page.getByRole('dialog'); await form.getByRole('textbox').first().fill('Reviewed synthetic company');
await form.getByRole('button', { name: language === 'en' ? 'Save' : 'Lagre', exact: true }).click();
await form.waitFor({ state: 'hidden' });
const feedback = page.locator('.MuiSnackbar-root [role=alert]'); await feedback.waitFor();
await page.getByText(companyName, { exact: true }).waitFor();
if (width < 768) {
const clearance = await feedback.evaluate(node => ({ bottom: node.getBoundingClientRect().bottom, navTop: document.querySelector('nav[aria-label="Primary navigation"],nav[aria-label="Hovednavigasjon"]').getBoundingClientRect().top }));
assert.ok(clearance.bottom <= clearance.navTop, 'feedback must stay above mobile navigation');
await page.screenshot({ path: path.join(out, `feedback-${language}-${mode}-${width}.png`), fullPage: true });
}
if (width >= 768) await page.getByRole('button', { name: language === 'en' ? 'Expand sidebar' : 'Utvid sidemenyen', exact: true }).click();
const core = width < 768 ? primary : page.getByRole('navigation', { name: language === 'en' ? 'Navigation' : 'Navigasjon', exact: true });
for (const [to, label] of [
['/dashboard', 'Dashboard'],
['/jobs', width < 768 ? (language === 'en' ? 'Applications' : 'Søknader') : (language === 'en' ? 'Job Applications' : 'Jobbsøknader')],
['/discover', width < 768 && language === 'en' ? 'Discover' : (language === 'en' ? 'Discover jobs' : 'Finn jobber')],
['/career', width < 768 ? (language === 'en' ? 'Career' : 'Karriere') : (language === 'en' ? 'Career profile' : 'Karriereprofil')],
]) {
await core.getByRole('button', { name: label, exact: true }).click();
await page.waitForURL(url => url.pathname === to);
await page.waitForFunction(() => document.querySelector('main h1') === document.activeElement);
assert.equal(await core.getByRole('button', { name: label, exact: true }).getAttribute('aria-current'), 'page');
}
journeys.push({ width, language, mode, secondaryNavigation: 'PASS', fourCoreRoutesAndHeadingFocus: 'PASS (API-unavailable states allowed)', focusAndPersistence: 'PASS', controlledCompanySave: 'PASS', mobileFeedbackClearance: width < 768 ? 'PASS' : 'not applicable' });
}
}
}
await context.close();
}
assert.deepEqual(errors, []); assert.deepEqual(writes, []);
// Existing font stylesheet requests are blocked, not fetched or needed by A3's system stack.
assert.ok(external.every(origin => origin === 'https://fonts.googleapis.com'), 'unexpected external request');
assert.equal(mockedWrites.length, navigation ? 16 : 0);
console.log(`PASS: 32 actual-application screen conditions, 16 company-dialog keyboard journeys; ${journeys.length} navigation/save checks; controlled API only.`);
} finally {
fs.writeFileSync(path.join(out, 'BROWSER.json'), JSON.stringify({ rows, errors, writes, mockedWrites, journeys, blockedExternalRequests: external, scope: 'actual static-export React application with controlled API; existing remote font requests aborted; no backend or provider qualification' }, null, 2));
await browser.close();
}
})().catch(error => { console.error(error); process.exitCode = 1; });