mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
После #600 единственный скроллер диалога находится в shadow-root .content. Съёмка продолжала менять scrollTop у старой .body, поэтому обычный кадр устройства и кадр Display preview получались побайтово одинаковыми. Тест фиксирует владельца скролла и запрещает возврат к неработающей .body. Issue: #600 User-Visible: no
360 lines
18 KiB
JavaScript
360 lines
18 KiB
JavaScript
#!/usr/bin/env node
|
||
import { createHash } from 'node:crypto';
|
||
import { spawnSync } from 'node:child_process';
|
||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||
import { dirname, resolve } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
// #337: a clean CI checkout has no ignored demo bundle. Materialize the whole
|
||
// manifest-owned tree before launching Chromium; copying only the stable entry
|
||
// leaves every content-hashed import at 404.
|
||
import '../../scripts/bundle-sync.mjs';
|
||
import { wholePixelClip } from './clip.mjs';
|
||
import { visualFingerprint } from '../../scripts/source-fingerprint.mjs';
|
||
import { assertFreshDemoBundle } from '../bundle-freshness.mjs';
|
||
import { goldenClip, prepareGoldenScenario } from '../golden/harness.mjs';
|
||
import { launch } from '../serve.mjs';
|
||
import { DOC_SCREENSHOT_VERSION, DOC_SCREENSHOTS } from './screenshots.mjs';
|
||
import { DETERMINISTIC_ARGS } from './browser-args.mjs';
|
||
|
||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||
const OUTPUT = resolve(ROOT, 'docs/images');
|
||
const SCRIPT = fileURLToPath(import.meta.url);
|
||
const sha256 = (value) => createHash('sha256').update(value).digest('hex');
|
||
|
||
/**
|
||
* Перепаковка кадра без потерь (#345).
|
||
*
|
||
* Замер на этом наборе: 2096 КБ превращаются в 1689 КБ, минус 19.4%, и все
|
||
* десять кадров остаются ПИКСЕЛЬНО идентичными — декодированные RGBA совпадают
|
||
* по sha256. Это выбор фильтров строки и уровня сжатия, а не квантование:
|
||
* визуального решения здесь нет вовсе.
|
||
*
|
||
* Почему внутри съёмки, а не отдельным проходом по закоммиченным файлам.
|
||
* Манифест хранит `imageSha256` каждого кадра, поэтому оптимизировать файлы в
|
||
* репозитории руками нельзя — `check-docs` покраснеет; а если жать после
|
||
* подсчёта хешей, следующая же съёмка вернёт неоптимизированные байты.
|
||
*
|
||
* Отсутствие инструмента не ошибка: локальная съёмка и без него полезна для
|
||
* глаз, а приёмка всё равно идёт только из артефакта CI, где `oxipng` стоит
|
||
* пином (`.github/workflows/docs-screenshots.yml`). Но молчать об этом нельзя —
|
||
* байты кадра зависят от того, был ли инструмент, поэтому его версия попадает
|
||
* в манифест рядом с версией браузера, по той же причине.
|
||
*/
|
||
const oxipngVersion = (() => {
|
||
const probe = spawnSync('oxipng', ['--version'], { encoding: 'utf8' });
|
||
if (probe.status !== 0) {
|
||
console.log('oxipng не найден: кадры пишутся как есть, без перепаковки');
|
||
return null;
|
||
}
|
||
return String(probe.stdout || '').trim().split('\n')[0];
|
||
})();
|
||
|
||
/** Пожать файл на месте и вернуть его новые байты. */
|
||
const shrinkPng = (path, before) => {
|
||
if (!oxipngVersion) return before;
|
||
const run = spawnSync('oxipng', ['-o', '4', '--strip', 'safe', '--quiet', path]);
|
||
if (run.status !== 0) {
|
||
throw new Error(`oxipng не смог обработать ${path}: код ${run.status}`
|
||
+ `${run.stderr ? ` · ${run.stderr}` : ''}`);
|
||
}
|
||
const after = readFileSync(path);
|
||
console.log(` ${(before.length / 1024).toFixed(0)} КБ -> ${(after.length / 1024).toFixed(0)} КБ`);
|
||
return after;
|
||
};
|
||
|
||
|
||
const roomCardClip = (page) => page.evaluate(() => {
|
||
const card = window.__goldenCard;
|
||
const roomCards = [...(card?.renderRoot?.querySelectorAll('.roomlabel') || [])];
|
||
const target = roomCards.find((item) => item.querySelector('.rlm')) || roomCards[0];
|
||
if (!target) throw new Error('documentation room card is missing');
|
||
const rect = target.getBoundingClientRect();
|
||
const marginX = 80;
|
||
const marginY = 70;
|
||
return {
|
||
x: Math.max(0, rect.left - marginX),
|
||
y: Math.max(0, rect.top - marginY),
|
||
width: Math.min(innerWidth, rect.right + marginX) - Math.max(0, rect.left - marginX),
|
||
height: Math.min(innerHeight, rect.bottom + marginY) - Math.max(0, rect.top - marginY),
|
||
};
|
||
});
|
||
|
||
/**
|
||
* Documentation-only presentation state. Keep these mutations out of the
|
||
* golden harness: changing that release fixture would invalidate every visual
|
||
* baseline even though the production component and golden matrix are intact.
|
||
*/
|
||
const applyDocumentationState = (page, scenario) => page.evaluate(async (current) => {
|
||
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||
const card = window.__goldenCard;
|
||
if (!card) throw new Error(`documentation card is missing: ${current.id}`);
|
||
const settleCamera = async () => {
|
||
const started = performance.now();
|
||
do { await frame(); }
|
||
while (card._cameraTransition?.active && performance.now() - started < 1200);
|
||
if (card._cameraTransition?.active)
|
||
throw new Error(`documentation camera did not settle: ${current.id}`);
|
||
// One quiet pair proves that the settled reactive frame reached layout.
|
||
await frame();
|
||
};
|
||
|
||
if (current.title) {
|
||
card.setConfig({ ...card._config, title: current.title });
|
||
}
|
||
|
||
if (current.roomMetrics) {
|
||
const space = card._serverCfg?.spaces?.find((item) => item.id === current.space);
|
||
if (!space) throw new Error(`documentation room metrics space is missing: ${current.space}`);
|
||
space.settings = {
|
||
...(space.settings || {}),
|
||
label_temp: true,
|
||
label_hum: true,
|
||
label_lqi: true,
|
||
label_light: true,
|
||
};
|
||
card._cfgEpoch += 1;
|
||
card._modelCache = null;
|
||
}
|
||
|
||
if (current.fixture === 'empty') {
|
||
card._serverCfg = { ...(card._serverCfg || {}), spaces: [] };
|
||
card._cfgEpoch += 1;
|
||
card._modelCache = null;
|
||
card._space = '';
|
||
card._onboardingShown = true;
|
||
card.hass = { ...card.hass, floors: {} };
|
||
card._openSpaceDialog('create');
|
||
}
|
||
|
||
if (current.dialog === 'device-info') {
|
||
const device = card._devices.find((item) => item.id === current.deviceId);
|
||
if (!device) throw new Error(`documentation device is missing: ${current.deviceId}`);
|
||
card._infoCard = device;
|
||
}
|
||
|
||
if (current.dialog === 'pdf') {
|
||
if (!(await card._pdfRuntimeLoader.ensure()))
|
||
throw new Error('documentation PDF runtime did not load');
|
||
card._pdfDialog = true;
|
||
}
|
||
|
||
card.requestUpdate();
|
||
await card.updateComplete;
|
||
await frame();
|
||
|
||
if (current.devicePresentationPreview) {
|
||
const dialog = card.renderRoot.querySelector('hp-dialog');
|
||
const scroller = dialog?.renderRoot?.querySelector('.content');
|
||
const preview = dialog?.querySelector('hp-device-preview');
|
||
await preview?.updateComplete;
|
||
if (!scroller || !preview)
|
||
throw new Error('documentation device presentation preview is missing');
|
||
const scrollerRect = scroller.getBoundingClientRect();
|
||
const previewRect = preview.getBoundingClientRect();
|
||
scroller.scrollTop += previewRect.top - scrollerRect.top - 180;
|
||
await frame();
|
||
const visibleScroller = scroller.getBoundingClientRect();
|
||
const visiblePreview = preview.getBoundingClientRect();
|
||
if (visiblePreview.top < visibleScroller.top - 1 || visiblePreview.bottom > visibleScroller.bottom + 1)
|
||
throw new Error('documentation viewport does not show the device presentation preview');
|
||
}
|
||
|
||
await settleCamera();
|
||
// Most dialogs render hp-dialog directly into the card, while the lazy PDF
|
||
// surface owns that shell inside hp-pdf-dialog's shadow root. The host is
|
||
// the stable public capture boundary; reaching through its shadow root here
|
||
// would couple documentation capture to the dialog implementation.
|
||
return { dialog: !!card.renderRoot.querySelector('hp-dialog, hp-pdf-dialog') };
|
||
}, scenario);
|
||
|
||
mkdirSync(OUTPUT, { recursive: true });
|
||
|
||
// Растеризация закрепляется теми же тремя флагами, что у golden
|
||
// (`demo/golden/run.mjs`), и по той же причине — только golden их имел с самого
|
||
// начала, а съёмка документации запускалась без единого (#410).
|
||
//
|
||
// Измерено на dev SHA 184e0098: два прогона канонического workflow на одном и
|
||
// том же коммите, одном Chromium 151.0.7922.34 и одном oxipng 10.2.0 дали три
|
||
// разошедшихся кадра из десяти (06-device-editor, 08-room-card,
|
||
// 09-device-info). Дельта — единицы уровней в RGB на сглаженных границах, alpha
|
||
// не менялась: подпись субпиксельного сглаживания, а не изменения продукта.
|
||
//
|
||
// Аргументы запуска и объяснение каждого — в `browser-args.mjs`: константы
|
||
// живут отдельно, чтобы тест мог их прочитать, не поднимая браузер (#424).
|
||
// `reducedMotion: 'reduce'` добавлен к `animations: 'disabled'` у самого
|
||
// скриншота: первое гасит анимации в CSS, второе — уже начатые переходы на
|
||
// момент съёмки.
|
||
/**
|
||
* Режим замера стабильности (#410): `node demo/docs/capture.mjs --stability=3`.
|
||
*
|
||
* Отвечает на вопрос, который иначе решается гаданием: плавает ли кадр ВНУТРИ
|
||
* одного состояния страницы или разница копится между подготовками сценария.
|
||
* Для каждого сценария делается N снимков подряд без единой правки состояния, и
|
||
* они сравниваются попиксельно прямо в странице — тем же приёмом, что у golden
|
||
* (`createImageBitmap` + canvas), чтобы не тащить декодер PNG в зависимости.
|
||
*
|
||
* Ничего не пишет на диск и манифест не трогает: это измерение, а не съёмка.
|
||
*/
|
||
const unstable = [];
|
||
const stabilityArg = process.argv.find((arg) => arg.startsWith('--stability'));
|
||
const STABILITY_SHOTS = stabilityArg
|
||
? Math.max(2, Number(stabilityArg.split('=')[1] || 3))
|
||
: 0;
|
||
|
||
const comparePairs = (target, shots) => target.evaluate(async (base64Shots) => {
|
||
const decode = async (base64) => {
|
||
const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
|
||
return createImageBitmap(new Blob([bytes], { type: 'image/png' }));
|
||
};
|
||
const read = (bitmap) => {
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = bitmap.width;
|
||
canvas.height = bitmap.height;
|
||
const context = canvas.getContext('2d', { willReadFrequently: true });
|
||
context.drawImage(bitmap, 0, 0);
|
||
return context.getImageData(0, 0, bitmap.width, bitmap.height).data;
|
||
};
|
||
const frames = await Promise.all(base64Shots.map(async (shot) => {
|
||
const bitmap = await decode(shot);
|
||
return { data: read(bitmap), width: bitmap.width, height: bitmap.height };
|
||
}));
|
||
const first = frames[0];
|
||
return frames.slice(1).map((frame, index) => {
|
||
if (frame.width !== first.width || frame.height !== first.height) {
|
||
return { pair: `1↔${index + 2}`, sizeMismatch: true };
|
||
}
|
||
let pixels = 0;
|
||
let maxDelta = 0;
|
||
let alphaTouched = 0;
|
||
const box = { x0: Infinity, y0: Infinity, x1: -1, y1: -1 };
|
||
for (let at = 0; at < first.data.length; at += 4) {
|
||
const dr = Math.abs(first.data[at] - frame.data[at]);
|
||
const dg = Math.abs(first.data[at + 1] - frame.data[at + 1]);
|
||
const db = Math.abs(first.data[at + 2] - frame.data[at + 2]);
|
||
const da = Math.abs(first.data[at + 3] - frame.data[at + 3]);
|
||
if (!dr && !dg && !db && !da) continue;
|
||
pixels += 1;
|
||
maxDelta = Math.max(maxDelta, dr, dg, db);
|
||
if (da) alphaTouched += 1;
|
||
const pixel = at / 4;
|
||
const x = pixel % first.width;
|
||
const y = Math.floor(pixel / first.width);
|
||
box.x0 = Math.min(box.x0, x); box.y0 = Math.min(box.y0, y);
|
||
box.x1 = Math.max(box.x1, x); box.y1 = Math.max(box.y1, y);
|
||
}
|
||
return {
|
||
pair: `1↔${index + 2}`,
|
||
size: [first.width, first.height],
|
||
pixels,
|
||
maxDelta,
|
||
alphaTouched,
|
||
box: pixels ? box : null,
|
||
};
|
||
});
|
||
}, shots.map((shot) => shot.toString('base64')));
|
||
|
||
const { page, browser } = await launch(
|
||
undefined, undefined, DETERMINISTIC_ARGS, { reducedMotion: 'reduce' },
|
||
);
|
||
const browserErrors = [];
|
||
page.on('pageerror', (error) => browserErrors.push(error.message));
|
||
|
||
try {
|
||
// Свежесть бандла проверяется строго, вместе с версией: картинки обязаны
|
||
// приехать из бандла, собранного из ЭТОГО дерева. А в манифест пишется
|
||
// версионно-нечувствительный отпечаток (#245) — номер версии на скриншотах
|
||
// не виден, и требовать из-за него пересъёмки нечестно.
|
||
await assertFreshDemoBundle(page, ROOT);
|
||
const fingerprint = visualFingerprint(ROOT);
|
||
const scenarios = {};
|
||
for (const scenario of DOC_SCREENSHOTS) {
|
||
await prepareGoldenScenario(page, scenario);
|
||
const runtime = await applyDocumentationState(page, scenario);
|
||
if (scenario.expectDialog && !runtime.dialog)
|
||
throw new Error(`documentation scenario did not open its dialog: ${scenario.id}`);
|
||
const rawClip = scenario.capture === 'room-card'
|
||
? await roomCardClip(page)
|
||
: await goldenClip(page, scenario.capture);
|
||
// Обрезка выравнивается по целым пикселям (#410); почему именно так —
|
||
// в `wholePixelClip`. Замер тогда показал, где искать: три снимка подряд в
|
||
// одном состоянии совпадают побайтово у всех десяти сценариев, а между
|
||
// прогонами плавают два. Значит дело не в рендере, а в том, что приходит на
|
||
// вход съёмке — и проверяется это между прогонами (#422).
|
||
const clip = wholePixelClip(rawClip);
|
||
if (STABILITY_SHOTS && rawClip) {
|
||
console.log(`${scenario.id} обрезка: сырая`
|
||
+ ` ${rawClip.x},${rawClip.y} ${rawClip.width}x${rawClip.height}`
|
||
+ ` → целая ${clip.x},${clip.y} ${clip.width}x${clip.height}`);
|
||
}
|
||
// Два кадра ожидания перед съёмкой — как в golden. `animations: 'disabled'`
|
||
// гасит анимации, но не гарантирует, что уже запланированный ре-рендер
|
||
// успел лечь в композитор до захвата.
|
||
await page.evaluate(() => new Promise((done) => {
|
||
requestAnimationFrame(() => requestAnimationFrame(done));
|
||
}));
|
||
const shotOptions = {
|
||
...(clip ? { clip } : {}), animations: 'disabled', caret: 'hide', scale: 'css',
|
||
};
|
||
if (STABILITY_SHOTS) {
|
||
const shots = [];
|
||
for (let attempt = 0; attempt < STABILITY_SHOTS; attempt += 1) {
|
||
await page.evaluate(() => new Promise((done) => {
|
||
requestAnimationFrame(() => requestAnimationFrame(done));
|
||
}));
|
||
shots.push(await page.screenshot(shotOptions));
|
||
}
|
||
for (const result of await comparePairs(page, shots)) {
|
||
console.log(`${scenario.id} ${result.pair}: пикселей ${result.pixels}`
|
||
+ `, максимум ${result.maxDelta}, alpha ${result.alphaTouched}`
|
||
+ (result.box ? `, bbox ${result.box.x0},${result.box.y0}`
|
||
+ `..${result.box.x1},${result.box.y1}` : '')
|
||
+ `, кадр ${result.size ? result.size.join('x') : '?'}`);
|
||
if (result.pixels || result.sizeMismatch) unstable.push(`${scenario.id} ${result.pair}`);
|
||
}
|
||
continue;
|
||
}
|
||
const image = await page.screenshot(shotOptions);
|
||
const imagePath = resolve(OUTPUT, scenario.file);
|
||
writeFileSync(imagePath, image);
|
||
// Хеш считается ПОСЛЕ перепаковки: манифест обязан описывать те байты,
|
||
// которые лежат на диске, иначе приёмка отвергнет свой же кандидат.
|
||
const stored = shrinkPng(imagePath, image);
|
||
scenarios[scenario.id] = {
|
||
file: scenario.file,
|
||
viewport: scenario.viewport,
|
||
theme: scenario.theme,
|
||
language: scenario.language,
|
||
sourceSha256: fingerprint,
|
||
imageSha256: sha256(stored),
|
||
};
|
||
console.log(`captured ${scenario.id} -> docs/images/${scenario.file}`);
|
||
}
|
||
if (browserErrors.length) throw new Error(`browser errors: ${browserErrors.join(' | ')}`);
|
||
if (STABILITY_SHOTS) {
|
||
console.log(`\nзамер стабильности: ${STABILITY_SHOTS} снимка на сценарий, манифест не тронут`);
|
||
if (unstable.length) {
|
||
// Режим — не только диагностика, но и гейт: если кадр снова начнёт зависеть
|
||
// от времени, это должно останавливать съёмку, а не печататься в лог.
|
||
throw new Error(`кадр плавает внутри одного состояния страницы: ${unstable.join(', ')}`);
|
||
}
|
||
console.log('кадр не плавает ни в одном сценарии');
|
||
} else {
|
||
const manifest = {
|
||
version: DOC_SCREENSHOT_VERSION,
|
||
fixture: 'synthetic-only',
|
||
// Кто снимал. Смена браузера переписывает все картинки без содержательных
|
||
// изменений (#246), поэтому окружение съёмки — часть доказательства.
|
||
chromium: browser.version(),
|
||
// Чем жали — тоже часть доказательства: без инструмента байты другие.
|
||
oxipng: oxipngVersion,
|
||
sourceFingerprint: fingerprint,
|
||
captureScriptSha256: sha256(readFileSync(SCRIPT)),
|
||
command: 'npm run build && node demo/docs/capture.mjs',
|
||
scenarios,
|
||
};
|
||
writeFileSync(resolve(OUTPUT, 'screenshots.json'), `${JSON.stringify(manifest, null, 2)}\n`);
|
||
}
|
||
} finally {
|
||
await browser.close();
|
||
}
|