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

111 lines
11 KiB
JavaScript

// Actual exported React UI with synthetic, in-memory API fixtures; no live backend.
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 visualMatrix = process.env.A3_VISUAL_MATRIX === '1';
const widths = process.env.A3_PILOT ? [320] : visualMatrix ? [320, 360, 390, 430, 768, 1024, 1280, 1440, 1920] : [320, 390, 768, 1440];
const languages = process.env.A3_PILOT ? ['en'] : ['en', 'nb'];
const modes = process.env.A3_PILOT ? ['light'] : ['light', 'dark'];
const expectedJourneys = widths.length * languages.length * modes.length;
const out = path.resolve(import.meta.dirname, visualMatrix ? '../../docs/ui-modernisation/visual-correction/evidence/dashboard' : '../../docs/ui-modernisation/evidence/dashboard/browser');
fs.mkdirSync(out, { recursive: true });
const report = { screens: [], journeys: [], errors: [], unexpectedWrites: [], mockedWrites: [], blockedExternal: [] };
const browser = await chromium.launch();
try {
for (const width of widths) for (const language of languages) for (const mode of modes) {
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 });
let layout = { version: 2, revision: 0, widgets: [] };
const now = new Date(), due = new Date(now); due.setDate(due.getDate() + 2);
const deadline = `${due.getFullYear()}-${String(due.getMonth() + 1).padStart(2, '0')}-${String(due.getDate()).padStart(2, '0')}`;
const jobs = [
{ id: 31, version: 1, jobTitle: 'Community nurse', company: { id: 1, name: 'Fjord Care' }, status: 'Interview', workflowSignal: { actionKey: 'interview-prep', workspaceTab: 'interview-prep', needsAttention: true, needsInterviewPrep: true, reason: 'Review your recorded preparation' } },
{ id: 32, version: 1, jobTitle: 'Primary teacher', company: { id: 2, name: 'Linden School' }, status: 'Preparing', deadline, workflowSignal: { actionKey: 'package-work', workspaceTab: 'tailored-cv', needsAttention: true, hasPackageGap: true, reason: 'Review application documents' } },
{ id: 33, version: 1, jobTitle: 'Electrical engineer', company: { id: 3, name: 'Northline Energy' }, status: 'Applied', followUpAt: due.toISOString(), workflowSignal: { actionKey: 'follow-up', workspaceTab: 'follow-up', needsAttention: true, needsFollowUpAction: true, reason: 'Review a follow-up' } },
];
await page.route('**/*', route => {
const req = route.request(), url = new URL(req.url());
if (!['localhost', '127.0.0.1'].includes(url.hostname)) { report.blockedExternal.push(url.origin); return route.abort(); }
if (!url.pathname.startsWith('/api/')) return route.continue();
if (req.method() === 'PUT' && url.pathname === '/api/auth/preferences/dashboard') {
const value = req.postDataJSON(); assert.equal(value.version, 2); assert.equal(value.revision, layout.revision);
assert.equal(value.widgets.length, 10); layout = { ...value, revision: layout.revision + 1 };
report.mockedWrites.push({ width, language, mode, revision: layout.revision });
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(layout) });
}
if (req.method() !== 'GET') { report.unexpectedWrites.push(url.pathname); return route.abort(); }
const fixtures = {
'/api/auth/config': { requireAuth: true },
'/api/auth/me': { id: 'a3-dashboard-owner', sessionContext: 'a3-dashboard-session', firstName: 'Alex', displayName: 'Alex Sample', roles: [], uiLanguage: language === 'nb' ? 'nb-NO' : 'en' },
'/api/auth/preferences/dashboard': layout,
'/api/jobapplications/stats': { total: 12, active: 10, deleted: 2, byStatus: { Preparing: 3, Applied: 5, Interview: 2 }, appliedLast30Days: 4, averageDaysSinceApplied: 5 },
'/api/jobapplications/analytics-overview': { funnel: [{ label: 'Preparing', count: 3 }, { label: 'Applied', count: 5 }, { label: 'Interview', count: 2 }], responseRateBySource: [], topCompanies: [], timeInStage: [], salaryInsights: [], totalResponses: 2, totalActive: 10 },
'/api/jobapplications/reminders': jobs,
'/api/jobapplications/analytics': [{ month: `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`, applied: 4, responses: 1 }],
'/api/jobapplications/tags': [{ tag: 'Communication', count: 3 }],
'/api/jobapplications/tag-trends': { months: [], series: [] },
'/api/notifications/unread-count': { count: 0 },
'/api/career/profile': { revision: 1, cvText: '', profile: { version: '1', skills: ['Teaching', 'Care coordination'] } },
'/api/gmail/status': { connected: false }, '/api/microsoft-graph/status': { connected: false }, '/api/imap/status': { connected: false },
};
return route.fulfill({ status: Object.hasOwn(fixtures, url.pathname) ? 200 : 404, contentType: 'application/json', body: JSON.stringify(fixtures[url.pathname] ?? {}) });
});
await page.goto('http://localhost:4338/dashboard');
const heading = page.locator('main h1'); await heading.waitFor();
assert.match(await heading.innerText(), language === 'en' ? /^Good (morning|afternoon|evening), Alex$/ : /^God (morgen|ettermiddag|kveld), Alex$/);
const weekName = language === 'en' ? 'Your week' : 'Uken din';
const week = page.getByRole('region', { name: weekName, exact: true }); await week.waitFor();
await page.getByText('3 / 4', { exact: true }).waitFor();
assert.deepEqual(await page.locator('[data-dashboard-widget]').evaluateAll(nodes => nodes.slice(0, 4).map(node => node.getAttribute('data-dashboard-widget'))), ['attention', 'agenda', 'funnel', 'cards']);
assert.equal(await week.locator('time').first().getAttribute('datetime'), deadline);
assert.equal(await week.getByRole('link', { name: 'Community nurse' }).count(), 0);
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1), false);
if (width >= 1440) await page.waitForFunction(() => {
const a = document.querySelector('[data-dashboard-widget=agenda]').getBoundingClientRect(), b = document.querySelector('[data-dashboard-widget=funnel]').getBoundingClientRect();
return Math.abs(a.top - b.top) < 2 && a.left < b.left;
});
const scan = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa']).analyze();
report.screens.push({ width, language, mode, violations: scan.violations }); assert.deepEqual(scan.violations, []);
await page.screenshot({ path: path.join(out, `dashboard-${language}-${mode}-${width}.png`), fullPage: true });
await page.getByRole('button', { name: language === 'en' ? /^Skip for now:/ : /^Hopp over foreløpig:/ }).click();
await page.reload(); await page.getByRole('button', { name: language === 'en' ? /^Resume step:/ : /^Fortsett steg:/ }).waitFor();
await page.getByRole('button', { name: language === 'en' ? /^Resume step:/ : /^Fortsett steg:/ }).click();
await page.getByRole('button', { name: language === 'en' ? 'Dismiss setup checklist' : 'Skjul oppstartslisten', exact: true }).click();
await page.reload();
await page.getByRole('button', { name: language === 'en' ? 'Continue setup' : 'Fortsett oppsett', exact: true }).click();
await page.getByText('3 / 4', { exact: true }).waitFor();
await page.getByRole('button', { name: language === 'en' ? 'Show walkthrough' : 'Vis veiledning', exact: true }).click();
assert.equal(await page.evaluate(() => document.activeElement.tagName), 'H3');
await page.getByRole('button', { name: language === 'en' ? 'Next step' : 'Neste steg', exact: true }).click();
assert.equal(await page.evaluate(() => document.activeElement.tagName), 'H3');
const guideScan = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa']).analyze();
report.screens.push({ width, language, mode, state: 'walkthrough', violations: guideScan.violations }); assert.deepEqual(guideScan.violations, []);
await page.screenshot({ path: path.join(out, `walkthrough-${language}-${mode}-${width}.png`), fullPage: true });
await page.getByRole('button', { name: language === 'en' ? 'Close walkthrough' : 'Lukk veiledning', exact: true }).click();
assert.equal(await page.getByRole('button', { name: language === 'en' ? 'Show walkthrough' : 'Vis veiledning', exact: true }).evaluate(node => node === document.activeElement), true);
assert.equal(report.mockedWrites.filter(row => row.width === width && row.language === language && row.mode === mode).length, 0);
await page.getByRole('button', { name: language === 'en' ? 'Customize dashboard' : 'Tilpass dashboard', exact: true }).click();
const editor = page.getByRole('region', { name: language === 'en' ? 'Editing dashboard' : 'Tilpasser dashboard', exact: true });
await editor.locator('summary').click(); await editor.getByRole('checkbox', { name: weekName, exact: true }).uncheck();
await editor.getByRole('button', { name: language === 'en' ? 'Save layout' : 'Lagre oppsett', exact: true }).click();
await editor.waitFor({ state: 'hidden' }); assert.equal(layout.revision, 1); assert.equal(layout.widgets.find(widget => widget.id === 'agenda').visible, false);
await page.reload(); await page.getByRole('button', { name: language === 'en' ? 'Customize dashboard' : 'Tilpass dashboard', exact: true }).waitFor();
assert.equal(await week.count(), 0);
report.journeys.push({ width, language, mode, skipResumeDismissWalkthrough: 'PASS', noGuidanceWrites: 'PASS', explicitLayoutSaveReload: 'PASS' });
await context.close();
}
assert.deepEqual(report.errors, []); assert.deepEqual(report.unexpectedWrites, []);
assert.ok(report.blockedExternal.every(origin => origin === 'https://fonts.googleapis.com'));
assert.equal(report.mockedWrites.length, expectedJourneys);
assert.equal(report.journeys.length, expectedJourneys);
assert.equal(report.screens.length, expectedJourneys * 2);
console.log(`PASS:${report.journeys.length} dashboard journeys,${report.screens.length} scoped axe sweeps/captures; controlled API persistence only.`);
} finally {
fs.writeFileSync(path.join(out, process.env.A3_PILOT ? 'PILOT.json' : 'BROWSER.json'), JSON.stringify({ ...report, limits: ['synthetic API fixtures, not real backend end-to-end','no native device/screen reader/model/provider/security/release qualification'] }, null, 2));
await browser.close();
}