Files
houseplan-card/test/smoke-harness-contract.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

74 lines
5.1 KiB
JavaScript
Raw 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.
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
// #407. Счётчик исключений внутри карточки живёт в demo/serve.mjs, и до этой
// задачи его читала одна функция — finish(). Шесть смоков её не вызывали вовсе:
// у каждого была своя развязка, и ни одна не спрашивала про исключения. То есть
// необработанное исключение во время этих шести проходило незамеченным всегда —
// в лог печаталось `EXC`, а прогон оставался зелёным.
//
// Проверка статическая и грубая: она не доказывает, что вердикт вынесен
// правильно, а доказывает, что его вообще запрашивают. Этого достаточно, потому
// что пропуск здесь молчаливый — а молчаливый пропуск и есть то, что стоит
// ловить механизмом.
const DEMO = fileURLToPath(new URL('../demo/', import.meta.url));
const smokes = () => readdirSync(DEMO)
.filter((name) => name.startsWith('smoke_') && name.endsWith('.mjs'));
const read = (name) => readFileSync(new URL(name, `file://${DEMO}`), 'utf8');
test('каждый смок спрашивает вердикт по исключениям в карточке (#407)', () => {
const silent = smokes().filter((name) => {
const text = read(name);
return !/\bfinish\s*\(/.test(text) && !/\breportPageErrors\s*\(/.test(text);
});
assert.deepEqual(silent, [],
'смок обязан либо вызвать finish(), либо спросить reportPageErrors():'
+ ' иначе исключение внутри карточки во время него не увидит никто,'
+ ' а `EXC` в логе прогон зелёным быть не мешает (#407)');
});
test('вердикт по исключениям запрашивается после прогона, а не до (#407)', () => {
// Вызов раньше самих действий бессмысленен: счётчик к тому моменту нулевой.
// Проверяется дешёвым признаком — вердикт не может быть первым обращением к
// странице в файле.
for (const name of smokes()) {
const text = read(name);
const verdict = Math.max(text.search(/\breportPageErrors\s*\(/), text.search(/\bawait finish\s*\(/));
if (verdict < 0) continue;
const firstAction = text.search(/\b(page|card)\.|\blaunch\s*\(/);
if (firstAction < 0) continue;
assert.ok(verdict > firstAction,
`${name}: вердикт по исключениям запрашивается прежде, чем что-то произошло`);
}
});
test('вердикт не только выставляет код, но и останавливает смок (#407)', () => {
// Первый заход этой задачи выставлял только process.exitCode, и отрицательный
// прогон напечатал «FAILED: 1 uncaught exception» и следом «OK deep-link: …».
// Код возврата был верным, а вывод — противоречивым; читают же вывод.
for (const name of smokes()) {
const text = read(name);
if (!/\breportPageErrors\s*\(/.test(text)) continue;
assert.match(text, /if \(await reportPageErrors\(\)\)\s*(process\.exit\(1\)|throw )/,
`${name}: вердикт по исключениям обязан останавливать смок, а не только`
+ ' помечать его — иначе после «FAILED» печатается строка успеха');
}
});
test('serve.mjs остаётся единственным владельцем счётчика (#407)', () => {
// Смок, который завёл бы свой счётчик и свою проверку, вернул бы ровно ту
// ситуацию, которую задача чинит: у smoke_entry_stale собственный pageErrors
// существует законно (он и есть предмет проверки), но вердикт всё равно
// выносит finish().
const serve = readFileSync(new URL('../demo/serve.mjs', import.meta.url), 'utf8');
assert.match(serve, /export async function reportPageErrors\(\)/,
'вердикт стал асинхронным в #404: он ждёт доставки событий страницей');
assert.equal((serve.match(/_pageErrors\+\+/g) || []).length, 1,
'счётчик инкрементируется в одном месте — внутри watchPage (#404)');
const entryStale = read('smoke_entry_stale.mjs');
assert.match(entryStale, /await finish\(/, 'smoke_entry_stale обязан выносить вердикт');
});