textmachine/frontend/scripts/shot.mjs
2026-08-15 17:39:09 +03:00

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();