131 lines
7.8 KiB
JavaScript
131 lines
7.8 KiB
JavaScript
// Скриншот-цикл: собрать, поднять 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();
|