textmachine/frontend/scripts/shot.mjs

131 lines
7.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Скриншот-цикл: собрать, поднять preview, снять PNG, положить в .shots/.
// Смотреть на снимок — обязательная часть цикла: без неё код валиден, а вид случаен.
//
// node scripts/shot.mjs все маршруты из KNOWN_ROUTES, 1440x900
// node scripts/shot.mjs /showcase /reader только названные
// node scripts/shot.mjs --size 1280x764 вьюпорт референса — для прямого наложения
// node scripts/shot.mjs --dpr 1 то, что видит владелец на своём мониторе
//
// ⚠ --dpr заведён в S3.5 не для удобства: при deviceScaleFactor 2 полупиксель CSS ложится
// в целый пиксель устройства, и КЛАСС дефектов «мыло на штрихе иконки и на мелком тексте»
// (замечание 17) в кадре не виден вовсе. Дефолт остаётся 2 — референс снят при 2x и сравним
// только с ним; 1 снимает то же самое глазами владельца.
//
// KNOWN_ROUTES дублирует src/routes.tsx: два списка вместо загрузчика TS в Node — сознательный
// выбор в пользу простоты, пополнять оба (расхождение падает тестом src/routes.test.ts).
// С S3 маршрут выбирает ещё и мир фикстур: ожидание · ошибка · пусто · длинный хвост · потеря
// живого потока — у каждого свой кадр и свой прогон axe.
const KNOWN_ROUTES = [
'/showcase',
'/scale',
'/empty',
'/loading',
'/error',
'/offline',
'/partial',
];
// Маршруты, чья ФИНАЛЬНАЯ картинка — состояние ожидания: их фикстура намеренно не отвечает.
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 и шрифт CJK лежат локально, без sudo и без прав на систему (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');
// Разбираем последовательно, а не фильтром по индексу: фильтр без --size выбрасывал первый
// позиционный маршрут (sizeIndex = -1, и условие index !== 0 съедало его молча).
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(
`Непонятный аргумент «${arg}». Ожидается маршрут /showcase, --size ШхВ или --dpr N`,
);
}
}
if (awaiting) throw new Error(`У ${awaiting} нет значения`);
const [width, height] = size.split('x').map(Number);
if (!width || !height) throw new Error('Ожидается --size ШИРИНАxВЫСОТА, например --size 1440x900');
const deviceScaleFactor = Number(dpr);
if (!deviceScaleFactor) throw new Error('Ожидается --dpr ЧИСЛО, например --dpr 1');
const routes = requested.length > 0 ? requested : KNOWN_ROUTES;
const unknown = routes.filter((route) => !KNOWN_ROUTES.includes(route));
if (unknown.length > 0) {
// Иначе в кадр молча попадёт страница ошибки роутера, а команда вернёт успех.
throw new Error(
`Нет такого маршрута: ${unknown.join(', ')}. Известные: ${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 не отдал локальный адрес');
const browser = await chromium.launch();
// deviceScaleFactor 2 по умолчанию — референс снят на macOS при 2x, иначе снимки несравнимы
// по детализации; --dpr 1 снимает то же самое так, как это видит владелец.
// Контекст создаётся явно: @axe-core/playwright отказывается работать со страницей из
// 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` больше не годится и не может годиться: живой прогон держит SSE-соединение
// открытым по построению, то есть сеть не затихает никогда. Ждём сигнала самого экрана —
// «я решил, что рисовать»; для маршрута ожидания решение это и есть «жду».
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,
});
// Без этого в кадр попадает фолбэк-шрифт: метрики и плотность будут не те.
await page.evaluate(() => document.fonts.ready);
const file = resolve(shotsDir, `${route.replace(/^\//, '').replace(/\//g, '-') || 'index'}.png`);
await page.screenshot({ path: file });
console.log(`${route}${file} (${width}x${height} @${deviceScaleFactor}x)`);
// Доступность — из того же класса, что цвет и имена токенов: владелец её глазами не проверит,
// значит проверяет машина. Контраст вынесен в отчёт, а не в падение: палитра снята с Fleet
// замером и принята владельцем, менять её ради порога WCAG — отдельное решение (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(` контраст (принято как есть): ${v.nodes.length} узл(ов)`);
if (blocking.length > 0) {
for (const v of blocking) console.error(`${v.id}: ${v.help}${v.nodes.length} узл(ов)`);
throw new Error(`Доступность: ${blocking.length} нарушени(й) на ${route}`);
}
}
await browser.close();
await server.close();