152 lines
7.2 KiB
JavaScript
152 lines
7.2 KiB
JavaScript
// 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',
|
|
'/intake',
|
|
'/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();
|