// The screenshot cycle: build, bring up preview, take a PNG, put it into .shots/. // Looking at the shot is a required part of the cycle: without it the code is valid and the look is // accidental. // // node scripts/shot.mjs every route of KNOWN_ROUTES, 1440x900 // node scripts/shot.mjs /showcase /reader only the named ones // node scripts/shot.mjs --size 1280x764 the viewport of the reference — for a direct overlay // node scripts/shot.mjs --dpr 1 what the owner sees on his own monitor // // ⚠ --dpr was brought in in S3.5 not for convenience: at deviceScaleFactor 2 a half CSS pixel lands // in a whole device pixel, and the CLASS of defects "mush on the stroke of an icon and on small // text" (remark 17) is not visible in the frame at all. The default stays 2 — the reference was shot // at 2x and is comparable only with it; 1 shoots the same thing through the owner's eyes. // // KNOWN_ROUTES duplicates src/routes.tsx: two lists instead of a TS loader in Node is a deliberate // choice in favour of simplicity, and both are to be filled in (a divergence fails the test // src/routes.test.ts). // Since S3 a route picks the world of fixtures as well: waiting · error · empty · the long tail · // the loss of the live stream — each has its own frame and its own axe run. const KNOWN_ROUTES = [ '/showcase', '/scale', '/empty', '/loading', '/error', '/offline', '/partial', ]; // Routes whose FINAL picture is the waiting state: their fixture deliberately does not answer. const PENDING_ROUTES = ['/loading']; import { mkdir } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const shotsDir = resolve(root, '.shots'); // Chromium and the CJK font lie locally, with no sudo and no rights over the system // (FRONTEND_PLAN.md §4). const toolingRoot = resolve(root, '.tooling/root'); if (existsSync(toolingRoot)) { const libs = resolve(toolingRoot, 'usr/lib/x86_64-linux-gnu'); process.env.LD_LIBRARY_PATH = [libs, process.env.LD_LIBRARY_PATH].filter(Boolean).join(':'); process.env.XDG_DATA_HOME = resolve(toolingRoot, 'usr/share'); } const { chromium } = await import('playwright'); const { default: AxeBuilder } = await import('@axe-core/playwright'); const { build, preview } = await import('vite'); // Parsed one after another rather than by a filter on the index: the filter without --size threw // away the first positional route (sizeIndex = -1, and the condition index !== 0 swallowed it in // silence). const requested = []; let size = '1440x900'; let dpr = '2'; let awaiting = null; for (const arg of process.argv.slice(2)) { if (awaiting) { if (awaiting === '--size') size = arg; else dpr = arg; awaiting = null; } else if (arg === '--size' || arg === '--dpr') { awaiting = arg; } else if (arg.startsWith('/')) { requested.push(arg); } else { throw new Error( `Unclear argument «${arg}». A route /showcase, --size WxH or --dpr N is expected`, ); } } if (awaiting) throw new Error(`${awaiting} has no value`); const [width, height] = size.split('x').map(Number); if (!width || !height) throw new Error('--size WIDTHxHEIGHT is expected, for example --size 1440x900'); const deviceScaleFactor = Number(dpr); if (!deviceScaleFactor) throw new Error('--dpr NUMBER is expected, for example --dpr 1'); /** * Waits for the animations to play out. Bought by the acceptance run of 10.08: one run printed * "contrast: 4 nodes" on `/loading`, two repeats printed zero, because axe started in the middle of * the 220ms appearance of the waiting card and was measuring SEMI-TRANSPARENT text. Infinite * animations we have none; should any appear, the wait will honestly run into its timeout rather * than take a blinking frame for a settled one. * @param {import('playwright').Page} page */ const settle = (page) => page.waitForFunction( () => document.getAnimations().every((animation) => animation.playState !== 'running'), null, { timeout: 5000 }, ); const routes = requested.length > 0 ? requested : KNOWN_ROUTES; const unknown = routes.filter((route) => !KNOWN_ROUTES.includes(route)); if (unknown.length > 0) { // Otherwise the router's error page silently lands in the frame while the command returns success. throw new Error(`No such route: ${unknown.join(', ')}. Known ones: ${KNOWN_ROUTES.join(', ')}`); } await mkdir(shotsDir, { recursive: true }); await build({ logLevel: 'warn' }); const server = await preview({ preview: { open: false } }); const origin = server.resolvedUrls?.local[0]?.replace(/\/$/, ''); if (!origin) throw new Error('vite preview did not give back a local address'); const browser = await chromium.launch(); // deviceScaleFactor 2 by default — the reference was taken on macOS at 2x, otherwise the shots are // not comparable in their detail; --dpr 1 shoots the same thing the way the owner sees it. // The context is created explicitly: @axe-core/playwright refuses to work with a page out of // browser.newPage() ("Please use browser.newContext()"). const context = await browser.newContext({ viewport: { width, height }, deviceScaleFactor }); const page = await context.newPage(); for (const route of routes) { // `networkidle` no longer serves and cannot serve: a live run holds the SSE connection open by // construction, that is, the network never falls quiet. We wait for the signal of the screen // itself — "I have decided what to draw"; for the waiting route that decision is "I am waiting". const expected = PENDING_ROUTES.includes(route) ? 'loading' : 'ready'; await page.goto(`${origin}${route}`, { waitUntil: 'load' }); await page.waitForFunction((want) => document.documentElement.dataset.screen === want, expected, { timeout: 15_000, }); // Without this the fallback font lands in the frame: the metrics and the density will be off. await page.evaluate(() => document.fonts.ready); await settle(page); const file = resolve(shotsDir, `${route.replace(/^\//, '').replace(/\//g, '-') || 'index'}.png`); await page.screenshot({ path: file }); console.log(`${route} → ${file} (${width}x${height} @${deviceScaleFactor}x)`); // Accessibility is of the same class as colour and the names of the tokens: the owner will not // check it with his eyes, which means the machine checks it. Contrast is taken out into the report // rather than into a failure: the palette was taken off Fleet by measurement and accepted by the // owner, and changing it for the sake of a WCAG floor is a separate decision (BACKLOG Ф-11). const axe = await new AxeBuilder({ page }).analyze(); const contrast = axe.violations.filter((v) => v.id === 'color-contrast'); const blocking = axe.violations.filter((v) => v.id !== 'color-contrast'); for (const v of contrast) console.log(` contrast (accepted as is): ${v.nodes.length} node(s)`); if (blocking.length > 0) { for (const v of blocking) console.error(` ✖ ${v.id}: ${v.help} — ${v.nodes.length} node(s)`); throw new Error(`Accessibility: ${blocking.length} violation(s) on ${route}`); } } await browser.close(); await server.close();