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