Files
houseplan-card/demo/serve.mjs
T
Claudeandclaude[bot] fa319e37b3 test(harness): тестовый фасад window.__hpTest и гейт no-new-private-writes (#629)
- scripts/no-new-private-writes.mjs: смоки и demo/helpers/** не добавляют
  записей в приватное состояние карточки (присваивание, ++/--, delete по
  цепочке с сегментом _x; от this — нет) и вызовов _setMode/_openRoomEdit/
  _openMarkerDialog/_openSpaceDialog. Зачёт правки по полю, перенос блока —
  movedLinesByFile из no-new-any; исключение // private-ok: <причина>.
  --count — остаток на HEAD. Подключён в gate:small и в шаг frontend рядом с
  no-new-any, с той же базой.
- demo/helpers/hp-test.mjs: 10 операций через контрактные хуки и события
  фикстуры (setMode, setTool, switchSpace, openRoomEdit, openMarkerDialog,
  openSpaceDialog, setServerConfig, setLayout, input, close); ставится
  launch*() из demo/serve.mjs. В бандле фасада нет.
- demo/srv/demo.html: доставка houseplan_config_updated/_layout_updated,
  __pushServerConfig/__pushServerLayout; после доставки запись со старым
  expected_rev — conflict, как у настоящего сервера.
- HP_SMOKE_CHECKS=1 печатает имена проверок в finish().
- smoke_area_relocation, smoke_glow, smoke_grid_snap переведены на фасад без
  потери утверждений; новый smoke_test_facade доказывает каждую операцию.
- 7 мутантов, docs/TESTING.md (раздел + правило №6), PROCESS.md §2.7, AGENTS.md.

Issue: #629
User-Visible: no
2026-09-25 01:25:20 +00:00

206 lines
12 KiB
JavaScript
Raw Permalink 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.
// 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`.
*
* Круговой запрос к странице вытесняет ранее поставленные макрозадачи, поэтому
* всё, что страница успела произвести до этого момента, к нам уже дошло.
*
* Честная граница: исключение, возникшее ПОСЛЕ этого round-trip'а — например, в
* обработчике `beforeunload` при закрытии браузера, — не учитывается. Ловить его
* значит ждать неизвестно чего неизвестно сколько; контракт формулируется как
* «всё, что произошло до вызова вердикта».
*/
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). Обратный порядок и был дефектом.
await roundTripLivePages();
if (_pageErrors) _failures.push(`${_pageErrors} uncaught exception(s) inside the card`);
await browser?.close?.();
// #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,
);