Files
houseplan-card/test/smoke-exception-guard.test.mjs
T
Matysh 2903374b72 fix: the exception guard reads its counter after delivery, not before
Гард «uncaught exception внутри карточки» жил в demo/serve.mjs с 2026-07-27 и
не срабатывал ни разу в самом частом случае. Счётчик читался синхронно, а
Playwright доставляет pageerror асинхронно по CDP: если исключение возникло
после последнего обращения смока к странице, счётчик к моменту проверки
нулевой, а browser.close() уносит недоставленное событие. В логе это видно
дословно — EXC печатается после результата и до OK.

finish() теперь делает round-trip по открытым страницам перед чтением
счётчика. Страницы регистрируются там, где создаются: ссылок на них у
finish(browser, out) нет, а менять сигнатуру нельзя — так её зовут 205
смоков.

Medium-1 жёлтого ревью ТЗ закрыт расширением, а не оговоркой. Страницы,
созданные смоком после launch(), регистрация в launchInternal не покрывает:
smoke_zoom_flash печатал своё EXC2 мимо счётчика, три страницы
smoke_svg_sandbox не имели слушателя вовсе. Документировать слепую зону в
задаче, которая существует ради устранения слепой зоны, значит закрыть issue,
оставив дефект. Наружу отдана одна функция watchPage(page): подписка и
регистрация неразделимы, иначе появится страница, чьи исключения считаются, а
доставки не ждёт никто.

Разрыв оказался шире, чем в ревью: проверка по всему набору нашла ещё два
файла со своей подпиской — smoke_cold_view_toggle и smoke_cold_view_vacuum.
Они не слепая зона, их страница приходит из launchColdView и уже
зарегистрирована, а свой счётчик они превращают в отдельное утверждение.
Поэтому инвариант сформулирован как «ни одна страница не создаётся мимо
гарда» и закреплён по всему набору, а не по двум названным файлам.

reportPageErrors() из #407 стал асинхронным: второй читатель счётчика обязан
ждать доставку так же, как finish(). Пять смоков получили await.

Фикстура smoke_danger_confirmation приведена к объявленному типу: без binding
и bindingMode _bindingHasHaPage падал на undefined.split(':') — два
исключения, которых гард не видел. Дефекта поведения нет, все 15 мест в src/,
создающих диалог, binding пишут; врала фикстура.

Два отступления от ТЗ, каждое по измеренной причине. Пробы лежат в
demo/guard/, а не demo/fixtures/: последний входит в корпус sourceFingerprint,
и каждый файл там объявил бы устаревшими бандл, скриншот-индекс и
golden-индекс — пробы же не касаются ни одного пикселя. Поведение
доказывается в job со браузером, а не в npm test: job «Фронтенд» браузеры не
ставит, и тест молча скипался бы — тот самый тихий успех, против которого вся
задача.

Issue: #404
User-Visible: no
2026-09-01 19:08:07 +03:00

118 lines
8.1 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
// #404. Гард «uncaught exception внутри карточки» существовал с 2026-07-27 и не
// срабатывал ни разу: счётчик читался синхронно, а Playwright доставляет
// pageerror асинхронно. Поведение доказывается запуском проб —
// demo/guard/verify-guard.mjs в job со браузером. Здесь закреплено то, что
// запуском не проверить: порядок операций в исходнике, место проб и наличие
// вызова там, где он должен быть.
const ROOT = fileURLToPath(new URL('../', import.meta.url));
const read = (rel) => readFileSync(new URL(rel, `file://${ROOT}`), 'utf8');
test('счётчик читается ПОСЛЕ доставки событий, а не до (#404)', () => {
const serve = read('demo/serve.mjs');
const flush = serve.indexOf('await roundTripLivePages();\n if (_pageErrors)');
assert.ok(flush > 0,
'round-trip обязан стоять непосредственно перед чтением счётчика: обратный'
+ ' порядок и был дефектом #404');
// Вторая половина правки: опрашивать нечего, если страницы не регистрируются.
assert.match(serve, /const _livePages = new Set\(\)/);
assert.match(serve, /export function watchPage\(page\)/);
assert.match(serve, /_livePages\.add\(page\)/);
assert.match(serve, /page\.on\('close', \(\) => _livePages\.delete\(page\)\)/,
'закрытая страница обязана уходить из реестра');
// Round-trip к закрытой странице бросает — и не имеет права ронять вердикт.
assert.match(serve, /try \{ await page\.evaluate\(\(\) => 0\); \} catch/);
});
test('оба читателя счётчика ждут доставки (#404, #407)', () => {
const serve = read('demo/serve.mjs');
assert.match(serve, /export async function reportPageErrors\(\)\s*\{\s*await roundTripLivePages\(\);/,
'вердикт для смоков со своей развязкой обязан ждать так же, как finish()');
});
test('пробы гарда лежат вне маски смоков и вне корпуса отпечатка (#404)', () => {
const guard = readdirSync(new URL('demo/guard/', `file://${ROOT}`));
assert.ok(guard.includes('verify-guard.mjs'));
const probes = guard.filter((name) => name.startsWith('guard_'));
assert.equal(probes.length, 3, 'три пробы: хвостовое исключение, отклонение, закрытая страница');
// Маска demo/smoke_*.mjs — то, что гоняют шарды CI. Проба, попавшая туда,
// покрасит шард по построению: она обязана падать.
for (const name of probes) {
assert.equal(name.startsWith('smoke_'), false, `${name} попала бы в шарды CI`);
}
// demo/fixtures/**/*.mjs входит в корпус sourceFingerprint: каждый файл там
// объявляет устаревшими бандл, скриншот-индекс и golden-индекс. Пробы гарда
// не касаются ни одного пикселя — платить за них пересъёмкой нечем.
for (const name of probes.concat('verify-guard.mjs')) {
assert.equal(existsSync(new URL(`demo/fixtures/${name}`, `file://${ROOT}`)), false,
`${name} в demo/fixtures/ протухал бы отпечаток исходников`);
}
});
test('пробы вызываются в job с браузером и служат guard мутантов (#404)', () => {
const workflow = read('.github/workflows/validate.yml');
const smoke = workflow.slice(workflow.indexOf('\n smoke:\n'), workflow.indexOf('\n smoke_done:\n'));
assert.match(smoke, /node demo\/guard\/verify-guard\.mjs/,
'проверка обязана идти там, где есть Chromium: в job «Фронтенд» npm test браузер не ставит,'
+ ' и тест на пробах молча скипался бы');
assert.match(smoke, /if: matrix\.shard == 1/, 'один раз, а не в каждом шарде');
const mutants = read('scripts/mutation-gate.mjs');
for (const id of ['smoke-guard-blind-to-tail', 'smoke-guard-forgets-to-register-pages']) {
assert.match(mutants, new RegExp(`id: '${id}'`), `мутант ${id} не зарегистрирован`);
}
// Правка состоит из двух половин, и мутант на одну оставил бы другую
// недоказанной.
assert.equal((mutants.match(/node demo\/guard\/verify-guard\.mjs/g) || []).length, 2);
});
test('страницы, созданные вне launch(), подписаны общим гардом (#404, Medium-1)', () => {
// Ревью ТЗ нашло разрыв: smoke_zoom_flash открывал вторую страницу со своей
// подпиской, печатавшей EXC2 мимо счётчика, а три страницы smoke_svg_sandbox
// не имели слушателя вовсе. Регистрация в launchInternal их не покрывает.
const zoom = read('demo/smoke_zoom_flash.mjs');
assert.match(zoom, /watchPage\(await ctx\.newPage\(\)\)/);
assert.equal(/p2\.on\('pageerror'/.test(zoom), false,
'своя подписка мимо счётчика убрана (EXC2 остался только в объяснении)');
const sandbox = read('demo/smoke_svg_sandbox.mjs');
assert.equal((sandbox.match(/watchPage\(await ctx\.newPage\(\)\)/g) || []).length, 3);
// Общий инвариант — не «никто не подписывается сам», а «ни одна страница не
// создаётся мимо гарда». Своя подписка поверх гарда законна и полезна:
// smoke_cold_view_toggle и smoke_cold_view_vacuum считают исключения ещё и
// отдельным утверждением `noPageErrors`, а их страница приходит из
// launchColdView, то есть уже зарегистрирована.
const demo = fileURLToPath(new URL('demo/', `file://${ROOT}`));
const smokes = readdirSync(demo)
.filter((name) => name.startsWith('smoke_') && name.endsWith('.mjs'));
const unwatched = [];
for (const name of smokes) {
const text = readFileSync(demo + name, 'utf8');
const total = (text.match(/newPage\(\)/g) || []).length;
const watched = (text.match(/watchPage\(await [\w.]+\.newPage\(\)\)/g) || []).length;
if (total > watched) unwatched.push(name);
}
assert.deepEqual(unwatched, ['smoke_entry_stale.mjs'],
'страница, созданная мимо watchPage, остаётся слепой зоной гарда.'
+ ' Единственное законное исключение — smoke_entry_stale: он намеренно'
+ ' поднимает свой браузер, ведёт свой счётчик исключений (он и есть'
+ ' предмет проверки #353) и выносит вердикт через finish()');
// Свои подписки допускаются только поверх общего гарда: страница обязана
// приходить из launch()/launchColdView(), иначе счётчик гарда её не видит.
for (const name of smokes) {
const text = readFileSync(demo + name, 'utf8');
if (!/\.on\('pageerror'/.test(text) || name === 'smoke_entry_stale.mjs') continue;
assert.match(text, /launch(ColdView)?[,}]|launch(ColdView)?\(/,
`${name}: своя подписка на pageerror поверх страницы, которую гард не знает`);
}
});