mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-07 23:19:14 +00:00
Count delivered page errors after awaited browser close. Exercise the public harness lifecycle with deterministic negative subprocess cases and retain the unchanged Chromium probes for the real transport. Move the premature-snapshot mutation witness to the deterministic Node suite. Issue: #776 User-Visible: no
205 lines
12 KiB
JavaScript
205 lines
12 KiB
JavaScript
// Shared launcher for demo captures: starts headless Chromium serving demo/srv/
|
||
// via request interception (no HTTP server needed). Usage: const {page,browser}=await launch();
|
||
import { chromium } from 'playwright';
|
||
import { assertFreshDemoBundleUnlessAllowed } from './bundle-freshness.mjs';
|
||
import { ensureHarnessEditorRuntime } from './editor-runtime-compat.mjs';
|
||
import { installHarnessIsoRuntimeHelper } from './iso-runtime-compat.mjs';
|
||
import { installHpTestOnPage } from './helpers/hp-test.mjs';
|
||
import { readFileSync, existsSync } from 'node:fs';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { dirname } from 'node:path';
|
||
const ROOT = dirname(fileURLToPath(import.meta.url)) + '/srv';
|
||
// Корень репозитория, а не каталог раздачи: фингерпринт считается по
|
||
// src/** и demo/fixtures, которых внутри demo/srv нет (#236).
|
||
const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
||
const CT = { '.html': 'text/html', '.js': 'text/javascript', '.svg': 'image/svg+xml' };
|
||
// ---- assertion harness (audit T1) --------------------------------------
|
||
// Until 2026-07-27 the smokes printed booleans and always exited 0: a broken
|
||
// build reported success. `check()` accumulates named failures, `finish()`
|
||
// prints them and sets the exit code.
|
||
const _failures = [];
|
||
/** Имена всех проверок прогона — для `HP_SMOKE_CHECKS=1` (#629). */
|
||
const _checkNames = new Set();
|
||
let _pageErrors = 0;
|
||
/**
|
||
* Открытые страницы — для round-trip'а перед чтением счётчика (#404).
|
||
*
|
||
* Ссылок на страницы у `finish(browser, out)` нет, а менять её сигнатуру
|
||
* нельзя: так её зовут 205 смоков. Поэтому страницы регистрируются там, где
|
||
* создаются.
|
||
*/
|
||
const _livePages = new Set();
|
||
|
||
/**
|
||
* События `pageerror` Playwright доставляет асинхронно по CDP, а гард читал
|
||
* счётчик синхронно (#404). Если исключение возникло после последнего обращения
|
||
* смока к странице, счётчик к моменту проверки ещё нулевой, а `browser.close()`
|
||
* уносит недоставленное событие. В логе это видно дословно: `EXC` печатается
|
||
* ПОСЛЕ результата и ДО `OK`.
|
||
*
|
||
* Круговой запрос даёт странице возможность доставить накопленные события,
|
||
* но не является барьером для всех browser task queues: например, хвостовое
|
||
* отклонение промиса может прийти уже во время browser.close() (#776).
|
||
* finish() поэтому читает счётчик только после завершения закрытия. Граница —
|
||
* все pageerror, доставленные до вердикта; произвольных будущих таймеров не ждём.
|
||
* reportPageErrors() браузер не закрывает и судит события после своего запроса.
|
||
*/
|
||
async function roundTripLivePages() {
|
||
for (const page of _livePages) {
|
||
try { await page.evaluate(() => 0); } catch { /* закрыта, упала или в навигации */ }
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Подписать страницу, созданную ВНЕ `launch()` (#404, Medium-1 ревью ТЗ).
|
||
*
|
||
* Таких мест два — `smoke_zoom_flash` открывает вторую страницу на своём
|
||
* контексте, `smoke_svg_sandbox` создаёт три. Их исключения не считал никто:
|
||
* первая печатала своё `EXC2` мимо счётчика, остальные три не имели слушателя
|
||
* вовсе. Регистрация в `launchInternal` их не покрывает по построению, поэтому
|
||
* гард отдаёт наружу ровно одну функцию — и её вызов виден в диффе смока.
|
||
*/
|
||
export function watchPage(page) {
|
||
page.on('pageerror', (e) => { _pageErrors++; console.log('EXC', e.stack || e.message); });
|
||
_livePages.add(page);
|
||
page.on('close', () => _livePages.delete(page));
|
||
return page;
|
||
}
|
||
|
||
/** Assert one named fact. `expected` defaults to true. */
|
||
export function check(name, actual, expected = true) {
|
||
_checkNames.add(String(name));
|
||
const ok = JSON.stringify(actual) === JSON.stringify(expected);
|
||
if (!ok) _failures.push(`${name}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
||
return ok;
|
||
}
|
||
|
||
/** Assert a whole result object: every key must equal true unless listed. */
|
||
export function checkAll(out, expected = {}) {
|
||
for (const [k, v] of Object.entries(out)) check(k, v, k in expected ? expected[k] : true);
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Тот же вердикт по исключениям в карточке, что у `finish()`, для смоков со
|
||
* своей логикой выхода (#407).
|
||
*
|
||
* Счётчик `_pageErrors` живёт здесь, а читал его только `finish()`. Шесть
|
||
* смоков `finish()` не вызывали вовсе — у каждого была своя ручная развязка
|
||
* (`if (!ok) process.exit(1)`) либо `throw` из try/finally, и ни одна не
|
||
* спрашивала про исключения. То есть необработанное исключение внутри карточки
|
||
* во время этих шести проходило незамеченным всегда: в лог печаталось `EXC`, а
|
||
* прогон оставался зелёным.
|
||
*
|
||
* Функция ничего не бросает намеренно. Смок с ручной развязкой продолжает
|
||
* решать сам, что печатать и когда выходить; здесь только выставляется код
|
||
* возврата, который дальше уже не отнять — `process.exitCode` переживёт любой
|
||
* последующий `console.log`.
|
||
*
|
||
* @returns true, если карточка бросала — чтобы вызывающий мог добавить своё
|
||
* сообщение, не считая исключения заново.
|
||
*/
|
||
export async function reportPageErrors() {
|
||
await roundTripLivePages();
|
||
if (!_pageErrors) return false;
|
||
console.error(`FAILED: ${_pageErrors} uncaught exception(s) inside the card`);
|
||
process.exitCode = 1;
|
||
return true;
|
||
}
|
||
|
||
/** Print the result, report failures, close the browser, set the exit code. */
|
||
export async function finish(browser, out) {
|
||
if (out !== undefined) console.log(JSON.stringify(out, null, 1));
|
||
// #404/#776: дать доставить события и дождаться закрытия, затем снять итог.
|
||
// Между round-trip и close тоже приходят pageerror: ранний снимок давал EXC + OK.
|
||
await roundTripLivePages();
|
||
await browser?.close?.();
|
||
if (_pageErrors) _failures.push(`${_pageErrors} uncaught exception(s) inside the card`);
|
||
// #629: доказательство «перевод без потери утверждений» — отсортированный
|
||
// список имён проверок; без переменной вывод прежний.
|
||
if (process.env.HP_SMOKE_CHECKS === '1') {
|
||
console.log('HP_SMOKE_CHECKS ' + JSON.stringify([..._checkNames].sort()));
|
||
}
|
||
if (_failures.length) {
|
||
console.error('\nFAILED (' + _failures.length + '):');
|
||
for (const f of _failures) console.error(' - ' + f);
|
||
process.exitCode = 1;
|
||
} else {
|
||
console.log('OK');
|
||
}
|
||
}
|
||
|
||
async function launchInternal(
|
||
preloadEditorRuntime,
|
||
viewport = { width: 820, height: 760 },
|
||
scale = 1,
|
||
browserArgs = [],
|
||
contextOptions = {},
|
||
serveRoot = ROOT,
|
||
repoRoot = REPO_ROOT,
|
||
entry = '/demo.html',
|
||
waitForCard = true,
|
||
) {
|
||
const browser = await chromium.launch({ args: ['--no-sandbox', ...browserArgs] });
|
||
const page = await (await browser.newContext({
|
||
viewport, deviceScaleFactor: scale, ...contextOptions,
|
||
})).newPage();
|
||
// audit T1: an exception inside the card used to be logged and ignored.
|
||
// Подписка и регистрация — одной функцией: разъехавшись, они дали бы
|
||
// страницу, чьи исключения считаются, но доставки которых никто не ждёт (#404).
|
||
watchPage(page);
|
||
await page.route('**/*', (r) => {
|
||
const u = new URL(r.request().url());
|
||
let p = decodeURIComponent(u.pathname);
|
||
if (p === '/') p = '/demo.html';
|
||
const f = serveRoot + p;
|
||
existsSync(f)
|
||
? r.fulfill({ status: 200, headers: { 'content-type': CT[p.slice(p.lastIndexOf('.'))] || 'application/octet-stream' }, body: readFileSync(f) })
|
||
: r.fulfill({ status: 404, body: 'nf' });
|
||
});
|
||
await page.goto(`http://demo.local${entry}`, { waitUntil: 'domcontentloaded' });
|
||
await installHarnessIsoRuntimeHelper(page);
|
||
if (!waitForCard) {
|
||
await installHpTestOnPage(page);
|
||
return { page, browser };
|
||
}
|
||
await page.waitForFunction(() => window.__card?._model?.length > 0, { timeout: 9000 });
|
||
// Свежесть бандла проверяется здесь, а не в каждом смоке (#236). Смок читает
|
||
// demo/srv/assets/houseplan-card.js; если туда не скопирован свежий dist,
|
||
// проверяется прежняя версия карточки — и результат выглядит осмысленным,
|
||
// потому что часть проверок краснеет, а часть зеленеет. На #234 это стоило
|
||
// круга разбора: три проверки упали, четвёртая ложно прошла, поскольку старый
|
||
// код одинаково врал в двух местах, которые сверялись друг с другом.
|
||
// golden и бенчмарки эту защиту имели с самого начала, смоки — нет.
|
||
await assertFreshDemoBundleUnlessAllowed(page, repoRoot);
|
||
// HP-1552: the first-open boot veil hides the plan (visibility:hidden) until
|
||
// the stage height settles — real pointer interaction cannot hit a hidden
|
||
// plan, so every smoke starts where the user does: with the plan revealed.
|
||
await page.waitForFunction(() => window.__card._booting === false, { timeout: 9000 });
|
||
// hass flows continuously in production; the stub sets it once — nudge a rebuild
|
||
await page.evaluate(() => { const c = window.__card; c.hass = { ...c.hass }; });
|
||
await page.waitForFunction(() => window.__card._devices.length > 0, { timeout: 9000 });
|
||
// Existing product smokes exercise editor internals directly. They preload
|
||
// the new #337 runtime without changing mode; the dedicated network smoke
|
||
// uses launchColdView() and proves the real cold-View boundary separately.
|
||
// Comparative performance runs also launch the previous stable through this
|
||
// file. A pre-#337 monolithic card has no preload method and is already
|
||
// ready; a current card still has to return truthy (#380).
|
||
if (preloadEditorRuntime) {
|
||
const ready = await page.evaluate(ensureHarnessEditorRuntime);
|
||
if (!ready) throw new Error('editor runtime did not preload for browser smoke');
|
||
}
|
||
// #629: тестовый фасад window.__hpTest — публичный вход смока в сценарий.
|
||
// Ставится последним: бандл к этому моменту исполнен, и `preinstalled`
|
||
// честно отвечает, не определил ли его кто-то раньше харнесса.
|
||
await installHpTestOnPage(page);
|
||
return { page, browser };
|
||
}
|
||
|
||
export const launch = (...args) => launchInternal(true, ...args);
|
||
export const launchColdView = (...args) => launchInternal(false, ...args);
|
||
/** A true cold panel document: fixture APIs exist, but neither frontend entry is loaded. */
|
||
export const launchPanelCold = (viewport = { width: 1280, height: 800 }) => launchInternal(
|
||
false, viewport, 1, [], {}, ROOT, REPO_ROOT, '/demo.html?panel-cold=1', false,
|
||
);
|