textmachine/frontend/scripts/shot.mjs

98 lines
5.5 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 вьюпорт референса — для прямого наложения
//
// KNOWN_ROUTES дублирует src/routes.tsx: два списка вместо загрузчика TS в Node — сознательный
// выбор в пользу простоты, пополнять оба.
const KNOWN_ROUTES = ['/showcase'];
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 awaitingSize = false;
for (const arg of process.argv.slice(2)) {
if (awaitingSize) {
size = arg;
awaitingSize = false;
} else if (arg === '--size') {
awaitingSize = true;
} else if (arg.startsWith('/')) {
requested.push(arg);
} else {
throw new Error(`Непонятный аргумент «${arg}». Ожидается маршрут /showcase или --size ШхВ`);
}
}
if (awaitingSize) throw new Error('У --size нет значения');
const [width, height] = size.split('x').map(Number);
if (!width || !height) throw new Error('Ожидается --size ШИРИНАxВЫСОТА, например --size 1440x900');
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, иначе снимки несравнимы по детализации.
// Контекст создаётся явно: @axe-core/playwright отказывается работать со страницей из
// browser.newPage() («Please use browser.newContext()»).
const context = await browser.newContext({ viewport: { width, height }, deviceScaleFactor: 2 });
const page = await context.newPage();
for (const route of routes) {
await page.goto(`${origin}${route}`, { waitUntil: 'networkidle' });
// Без этого в кадр попадает фолбэк-шрифт: метрики и плотность будут не те.
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} @2x)`);
// Доступность — из того же класса, что цвет и имена токенов: владелец её глазами не проверит,
// значит проверяет машина. Контраст вынесен в отчёт, а не в падение: палитра снята с 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();