Files
jobtrackingapp/job-tracker-ui/scripts/verify-a3-secondary.mjs
T
cesnimda 69621788ee style(ui): flatten activity and offer comparison sections
61 selected tests and build/lint pass. Wider rendered qualification remains in progress; this local checkpoint does not establish visual acceptance.
2026-11-04 00:41:27 +01:00

70 lines
9.1 KiB
JavaScript

// Secondary routes in the actual export; every API result is a synthetic fixture.
import { chromium, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
import ts from 'typescript';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
const compiled = ts.transpileModule(fs.readFileSync(new URL('../src/i18n/translations.ts', import.meta.url), 'utf8'), { compilerOptions: { module: ts.ModuleKind.ESNext } }).outputText;
const { translations } = await import(`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`);
const visualMatrix = Boolean(process.env.A3_VISUAL_MATRIX);
const out = path.resolve(import.meta.dirname, visualMatrix ? '../../docs/ui-modernisation/visual-correction/evidence/secondary-after' : '../../docs/ui-modernisation/evidence/secondary/browser');
fs.mkdirSync(out, { recursive: true });
const report = { screens: [], journeys: [], errors: [], writes: [], unavailableReads: [], blockedExternal: [] };
const browser = await chromium.launch();
try {
const configurations = process.env.A3_PILOT ? [[320, 'en', 'light']] : (visualMatrix ? [320, 360, 390, 430, 768, 1024, 1280, 1440, 1920] : [320, 390, 768, 1440]).flatMap(width => ['en', 'nb'].flatMap(language => ['light', 'dark'].map(mode => [width, language, mode])));
for (const [width, language, mode] of configurations) {
console.log(`Checking ${width}/${language}/${mode}`);
const t = key => { assert.ok(translations[language][key], key); return translations[language][key]; };
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 company = { id: 7, name: 'Recorded school', location: 'Oslo', website: null, contactName: 'Recorded contact', contactEmail: 'contact@example.test', contactPhone: null, notes: '', jobsCount: 1 };
const offers = [{ id: 42, jobTitle: 'Recorded teacher role', company, status: 'Offer', dateApplied: '2026-10-01', deadline: '2026-12-01', location: 'Oslo', salaryMin: 610000, salaryMax: 710000, salaryCurrency: 'NOK', salaryPeriod: 'year', notes: 'Recorded teaching role', followUpAt: null }, { id: 43, jobTitle: 'Recorded nursing role', company: { id: 8, name: 'Recorded clinic' }, status: 'Offer', dateApplied: '2026-10-02', location: 'Bergen', salaryMin: null, salaryMax: null, notes: '', deadline: null, followUpAt: null }];
let authReads = 0;
await page.route('**/*', route => {
const req = route.request(), url = new URL(req.url()), p = url.pathname;
if (url.origin !== 'http://localhost:4338' && !(url.origin === 'http://localhost:5202' && p.startsWith('/api/'))) { report.blockedExternal.push(url.origin); return route.abort(); }
if (!p.startsWith('/api/')) return route.continue();
if (req.method() !== 'GET') { report.writes.push(p); return route.abort(); }
const answer = data => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(data) });
if (p === '/api/auth/me') authReads++;
if (p === '/api/jobapplications') return answer({ items: new URL(page.url()).pathname === '/offers' ? offers : [], total: new URL(page.url()).pathname === '/offers' ? 2 : 0, page: 1, pageSize: 25 });
const fixtures = {
'/api/auth/config': { requireAuth: true }, '/api/auth/me': { id: 'secondary-owner', sessionContext: 'secondary-session', provider: 'local', firstName: 'Alex', displayName: 'Alex Sample', roles: [], plan: 'pro', entitlements: { ai: false, proThemes: true }, uiLanguage: language === 'nb' ? 'nb-NO' : 'en' },
'/api/companies': [company], '/api/jobapplications/reminders': [], '/api/notifications/unread-count': { count: 0 },
'/api/operations': [{ id: '11111111-1111-4111-8111-111111111111', taskType: 'cv.process', status: 'running', createdAtUtc: '2026-11-03T10:00:00Z', progressStage: 'Recorded processing stage', progressPercent: 40, canCancel: false, canRetry: false }], '/api/notifications': [],
'/api/jobapplications/stats': { total: 5, active: 4, byStatus: { Applied: 2, Offer: 2, Saved: 1 } },
'/api/jobapplications/analytics-overview': { funnel: [{ label: 'Applied', count: 2 }], responseRateBySource: [{ label: 'NAV', total: 4, responses: 1, rate: 25 }], topCompanies: [{ companyId: 7, company: company.name, count: 4, responses: 1, responseRate: 25 }], timeInStage: [{ stage: 'Applied', medianDays: 3, count: 2 }], salaryInsights: [{ currency: 'NOK', period: 'year', count: 2, minimum: 610000, maximum: 710000, averageMidpoint: 660000 }] },
'/api/jobapplications/analytics': [{ month: '2026-10', applied: 2, responses: 1 }], '/api/jobapplications/tags': [{ tag: 'Teaching', count: 2 }], '/api/jobapplications/tag-trends': { months: ['2026-10'], series: [{ tag: 'Teaching', counts: [2] }] },
'/api/public-cv/synthetic': { name: 'Alex Sample', html: '<!doctype html><html lang="en"><head><title>Controlled public CV</title></head><body><main><h1>Alex Sample</h1><p>Synthetic teacher profile</p></main></body></html>' },
};
if (Object.hasOwn(fixtures, p)) return answer(fixtures[p]);
report.unavailableReads.push(p); return route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ detail: 'Controlled unavailable read' }) });
});
const scan = async state => {
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1), false, `overflow:${state}`);
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, [], `${state} accessibility`);
await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' })); await expect.poll(() => page.evaluate(() => window.scrollY)).toBe(0);
await page.screenshot({ path: path.join(out, `${state}-${language}-${mode}-${width}.png`), fullPage: width >= 768 });
};
await page.goto('http://localhost:4338/companies'); await expect(page.getByText(company.name, { exact: true })).toBeVisible(); await scan('companies');
await page.goto('http://localhost:4338/offers'); await expect(page.getByRole('article', { name: offers[0].jobTitle, exact: true })).toBeVisible(); await expect(page.getByRole('article', { name: offers[1].jobTitle, exact: true })).toContainText(t('offerComparisonNotRecorded')); await scan('offers');
await page.goto('http://localhost:4338/operations'); await expect(page.locator('[data-operation-id]')).toContainText('Recorded processing stage'); await expect(page.getByRole('button', { name: t('cancel'), exact: true })).toHaveCount(0); await scan('operations');
await page.goto('http://localhost:4338/analytics?months=6'); await expect(page.getByText('Teaching', { exact: true }).first()).toBeVisible(); await page.getByRole('button', { name: t('dashboardMonthsShort').replace('{count}', '24'), exact: true }).click(); await expect.poll(() => new URL(page.url()).searchParams.get('months')).toBe('24'); await scan('analytics');
await page.goto('http://localhost:4338/trash'); await expect(page.locator('main h1')).toBeVisible(); await expect(page.getByRole('dialog')).toHaveCount(0); await scan('trash-empty');
const previousAuthReads = authReads;
await page.goto('http://localhost:4338/cv/synthetic'); await expect(page.getByTitle(t('publicCvTitle'), { exact: true })).toBeVisible();
const pdf = new URL(await page.getByRole('link', { name: t('publicCvDownloadPdf'), exact: true }).getAttribute('href'), page.url());
assert.equal(pdf.origin, 'http://localhost:5202'); assert.equal(pdf.pathname, '/api/public-cv/synthetic/pdf'); assert.equal(authReads, previousAuthReads); await scan('public-cv');
report.journeys.push({ width, language, mode, sixDirectRoutes: 'PASS', populatedAndUnknownOffers: 'PASS', operationActionPermissions: 'PASS', analyticsPeriodQuery: 'PASS', publicRouteWithoutIdentityRead: 'PASS' }); await context.close();
}
assert.equal(report.screens.length, configurations.length * 6); assert.equal(report.journeys.length, configurations.length); assert.deepEqual(report.errors, []); assert.deepEqual(report.writes, []); assert.ok(report.blockedExternal.every(origin => origin === 'https://fonts.googleapis.com'));
console.log(`PASS:${report.journeys.length} controlled route journeys,${report.screens.length} axe scans/captures; zero mutations.`);
} finally {
fs.writeFileSync(path.join(out, process.env.A3_PILOT ? 'PILOT.json' : 'BROWSER.json'), JSON.stringify({ ...report, limits: ['All data/render responses synthetic. No actual job providers, operation workers, public CV access-control or physical PDF qualification.', 'Trash is empty; real restore/delete excluded. Public CV route makes no identity read in this fixture, not deployed anonymous-access acceptance.', 'No models, real credentials, native/human, integrated security, production or release qualification.'] }, null, 2));
await browser.close();
}