Compare commits

...
Author SHA1 Message Date
claude[bot] 39f811a870 docs: review document for #404
Issue: #404
User-Visible: no
2026-09-01 13:33:17 +00:00
Codex 884387d770 docs: specify the smoke exception guard fix (#404)
User-Visible: no
Issue: #404
2026-08-31 20:01:18 +03:00
2 changed files with 444 additions and 0 deletions
+243
View File
@@ -0,0 +1,243 @@
# SPEC-REVIEW-404-r1
- Issue: https://github.com/Matysh/houseplan-card/issues/404
- ТЗ: `docs/specs/404-smoke-exception-guard.md`, ветка `issue/404-smoke-exception-guard`
- SHA материала ревью: `884387d770665d7ca8f6dd11e7885e3ea6f4d6b7`
- Трек: полный (метка `small` отсутствует), лимит циклов — 4
- Заход: r1 · вердикт: **жёлтый**
## Скоуп
Класс файлов задачи — только B (`demo/serve.mjs`, `demo/smoke_danger_confirmation.mjs`,
новые фикстуры `demo/fixtures/guard_*.mjs`, новый тест `test/smoke-exception-guard.test.mjs`,
запись в `scripts/mutation-gate.mjs`). Ни одного файла класса A — по §1 PROCESS.md задача
формально могла бы идти вне флоу, автор выбрал полный трек с файлом ТЗ по прецедентам
#398/#399; это решение задокументировано в комментарии, придирок к самому выбору трека нет.
Продуктового поведения задача не меняет (`User-Visible: no`), поэтому продуктовая рамка
`docs/SCOPE.md` к ней не применяется буквально — это инфраструктурная починка гейта, а не
фича. Читал `docs/SCOPE.md`, `AGENTS.md`, `PROCESS.md` полностью, тело issue #404 и оба
комментария (S2-анализ владельца, объявление ТЗ).
## Как проверялось
Ревью ТЗ на полном треке — это чтение и сверка утверждений документа с фактическим
состоянием дерева на SHA материала (без прогона тяжёлых гейтов — на этапе ТЗ они не нужны,
кода ещё нет). Конкретно:
1. Прочитан весь `docs/specs/404-smoke-exception-guard.md` (201 строка).
2. Прочитан `demo/serve.mjs` целиком — сверены номера строк, сигнатура `finish()`, механизм
`_pageErrors`/`page.on('pageerror')`, отсутствие текущей регистрации страниц.
3. Прочитан `scripts/mutation-gate.mjs` — подтверждена применимость AC2 (реестр уже патчит
`demo/serve.mjs` в существующем мутанте `smoke-launcher-skips-freshness`, прецедент есть).
4. Проверена фикстура `demo/smoke_danger_confirmation.mjs:167` и объявленный тип
`_markerDialog` в `src/houseplan-card.ts:2222-2235` — подтверждён дефект фикстуры (нет
`binding`/`bindingMode`), как описано в ТЗ.
5. Пересчитаны все числовые утверждения ТЗ командами на дереве:
- `ls demo/smoke_*.mjs | wc -l` → 211 (совпадает);
- `grep -l "finish(" demo/smoke_*.mjs | wc -l` → 205 (совпадает с «205 смоков зовут её
как `finish(browser, out)`»);
- `grep -rl "spawnSync\|execFileSync" test/*.mjs | wc -l` → 8 (совпадает с «8 тестов в
`test/` уже запускают процессы»).
6. Живым прогоном Playwright/Chromium (тот же движок, что использует проект) проверено
недокументированное в ТЗ утверждение AC3 «Проверено, что `page.on('pageerror')` в
Chromium [ловит `Promise.reject`]» — команда и результат см. в разделе Low ниже.
7. Проверена полнота охвата механизма «регистрация страниц»: прогреп всех
`demo/smoke_*.mjs` на `newContext|newPage` вне `launch()/launchInternal`, разобраны все
три найденных файла построчно (`smoke_entry_stale.mjs`, `smoke_svg_sandbox.mjs`,
`smoke_zoom_flash.mjs`) — см. находку Medium-1.
8. Проверено, какие смоки вообще не читают гард: `grep -L "finish(" demo/smoke_*.mjs` → 6
файлов, разобраны все шесть — см. находку Medium-2.
Гейты `typecheck`/`test`/`build` не гонялись: продуктового и тестового кода задача ещё не
содержит (только `docs/specs/**`), гонять их не на чем и не за чем — это подтверждается
самим диффом (`git show --stat HEAD` → один файл, `docs/specs/404-smoke-exception-guard.md`).
## Находки
### Medium-1 (в скоупе задачи) — «регистрация страниц» покрывает не все страницы, которые ТЗ обязано покрыть
**Файл**: `docs/specs/404-smoke-exception-guard.md`, разделы «Контракт» (строки 75-77),
«Честная граница» (79-83), АС6 (141-143).
**Суть**. ТЗ формулирует контракт регистрации так: «Страницы регистрируются там, где
создаются, — в `launchInternal`». Это неполно: как минимум два смока, которые уже пользуются
общим гардом (`launch()`/`finish()` из `serve.mjs`), создают дополнительные страницы **вне**
`launchInternal`, и эти страницы гарантированно останутся слепой зоной гарда и после починки
— ровно тот же класс дефекта, который описывает issue («любое исключение внутри карточки во
время смока остаётся незамеченным»), просто с другим механизмом, чем асинхронная гонка.
- `demo/smoke_zoom_flash.mjs:90-91,108`: фаза 1 получает `page` из `launch()` и закрывает её
(`await page.close()`); фаза 2 открывает `p2 = await ctx.newPage()` на **новом** контексте
(`ctx = await browser.newContext(...)`, строка 26) и вешает свой отдельный слушатель:
`p2.on('pageerror', (e) => console.log('EXC2', e.message));` — он только печатает `EXC2` в
лог и не трогает `_failures`/`_pageErrors` из `serve.mjs`. В конце вызывается
`await finish(browser)` (строка 108) — по контракту ТЗ он опросит `_livePages`, но `p2` там
никогда не окажется, потому что она создана не в `launchInternal`.
- `demo/smoke_svg_sandbox.mjs:38,48,58`: три страницы `before`/`after`/`card` создаются
напрямую через `ctx.newPage()` на `ctx = await browser.newContext()` (строка 22), и ни для
одной из них вообще не вешается `page.on('pageerror')` — не «слепа к хвосту», а слепа
полностью, с рождения. Файл при этом зовёт `finish(browser)` последней строкой.
**Сценарий отказа**. Задача реализована по ТЗ буквально, AC6 (211 смоков, зелены все, кроме
`smoke_infinite_canvas`) проходит зелёным прогоном — и при этом внутри `smoke_zoom_flash.mjs`
на странице `p2` (фаза сэмплирования кадров зума) или внутри любой из трёх страниц
`smoke_svg_sandbox.mjs` продолжает падать необработанное исключение в карточке — смок как
печатал `OK`, так и будет печатать, потому что `p2`/`before`/`after`/`card` никогда не были
частью того, что читает `finish()`. Ровно симптом issue, просто АС не может его поймать.
**Почему это находка ТЗ, а не наблюдение к реализации**: раздел «Честная граница, которую
задача не закрывает» называет ровно один исключённый случай — окно после round-trip'а
(`beforeunload` при закрытии браузера). Это честно, но неполно: второй, куда более широкий
разрыв (страницы/контексты, порождённые смоком напрямую, минуя `launchInternal`) в этом
разделе не назван вовсе, а формулировка контракта («страницы регистрируются там, где
создаются») читается как утверждение полноты. Это и есть «догадка, выданная за решение»:
автор, судя по тексту, не проверял `demo/smoke_*.mjs` на использование `newContext`/`newPage`
в обход `launchInternal`, иначе разрыв был бы назван явно, как это сделано для `beforeunload`.
Отдельно — ТЗ само создаёт внутреннее противоречие: заявляет «регистрация страниц» в скоупе
(«В скоупе: `demo/serve.mjs` (регистрация страниц и round-trip в `finish`)»), но тут же
исключает из скоупа «остальные 204 смока» (не-скоуп) — а именно там и рождаются
незарегистрированные страницы `p2`/`before`/`after`/`card`. Одновременно «регистрация всех
страниц» и «204 смока не трогаем» невыполнимы вместе для этих двух файлов.
**Что нужно от автора**: явно разрешить противоречие в тексте ТЗ — либо (а) распространить
регистрацию на страницы/контексты, порождаемые смоком после возврата из `launchInternal` (это
неизбежно тронет `smoke_zoom_flash.mjs` и `smoke_svg_sandbox.mjs`, тогда AC7 нужно
скорректировать: «204 смока, кроме уже перечисленных N, не тронуты»), либо (б) вписать этот
разрыв вторым пунктом в «Честную границу» рядом с `beforeunload`, назвав оба файла по имени,
и убрать из АС6 всё, что читается как утверждение полного покрытия. Технический выбор между
(а) и (б) — за автором, вопрос владельцу не нужен.
### Medium-2 (вне скоупа задачи → отдельный issue) — шесть смоков вообще не читают гард
**Файлы**: `demo/smoke_deeplink.mjs`, `demo/smoke_entry_stale.mjs`,
`demo/smoke_glow_blending.mjs`, `demo/smoke_icon_center.mjs`,
`demo/smoke_long_press_gesture.mjs`, `demo/smoke_space_card.mjs`.
**Суть**. `grep -L "finish(" demo/smoke_*.mjs` даёт ровно эти 6 файлов из 211 — они никогда
не вызывают `finish()` из `serve.mjs`, а значит, независимо от починки round-trip'а, счётчик
`_pageErrors` для них никогда не читается и не может уронить смок.
- Пять из шести (`smoke_deeplink`, `smoke_glow_blending`, `smoke_icon_center`,
`smoke_long_press_gesture`, `smoke_space_card`) импортируют `launch` из `serve.mjs` — значит
`page.on('pageerror')` вешается и `_pageErrors` инкрементируется, — но у каждого своя
ручная логика выхода (`if (!ok) { …; process.exit(1); }`), которая `_pageErrors` не
проверяет вовсе. Исключение внутри карточки в этих смоках инкрементирует счётчик, который
никто никогда не прочитает.
- `smoke_entry_stale.mjs` — отдельный случай, хуже: он вообще не пользуется
`launch()`/`launchInternal`, заводит `browser`/`page` сам, ведёт свой собственный
`pageErrors` (строка 23, локальная переменная, не связанная с `serve.mjs`), и в конце
(последние строки файла) делает `if (Object.values(out).every(Boolean)) console.log('OK');`
без единого `process.exitCode =` или `process.exit(`. То есть даже провал его собственных
`check()`/`checkAll()` не красит выход процесса — это тот самый паттерн «до 27.07.2026»,
который комментарий `demo/serve.mjs:15-18` называет прямо: «смоки печатали булевы значения
и всегда выходили нулём».
**Почему вне скоупа #404**: починка требует править сами эти 6 файлов
(`demo/smoke_*.mjs`), а ТЗ #404 прямо и обоснованно исключает такую правку в AC7 («дифф
задачи не содержит `demo/smoke_*.mjs`, кроме `smoke_danger_confirmation.mjs`») — расширять
это в текущей задаче значило бы нарушить её же собственный контракт. Причина дефекта тоже
другая: не асинхронная гонка доставки `pageerror`, а отсутствие вызова проверяющей функции.
**Действие**: заведён отдельный issue [#407](https://github.com/Matysh/houseplan-card/issues/407)
со ссылкой на #404, метки `bug`, `tests`, `S1-new` — ревью не патчит и не решает scope за
автора, только даёт отдельный трек находке, которая по формальным критериям (§202) не может
чиниться в текущей задаче.
### Low (снято решением ревьюера, без действия автора)
**АС3, `docs/specs/404-smoke-exception-guard.md:132-134`**: утверждение «Проверено, что
`page.on('pageerror')` в Chromium его получает» подано как установленный факт, но ни в теле
ТЗ, ни в комментариях issue не приведена команда/результат этой проверки — в отличие от АС1/
АС2, где транскрипт пробы (`EXC .../exit=`) приведён дословно. Это ровно тот шаблон «догадка,
выданная за решение», который должен становиться замечанием, если утверждение не подтверждено.
Проверил сам, живым прогоном на том же движке (Playwright + Chromium, `--no-sandbox`, как в
`demo/serve.mjs`):
```
node -e "
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ args: ['--no-sandbox'] });
const page = await browser.newPage();
let errors = 0;
page.on('pageerror', (e) => { errors++; console.log('PAGEERROR:', e.message); });
await page.goto('data:text/html,<script>Promise.reject(new Error(\"boom-rejection\"))</script>');
await page.waitForTimeout(500);
console.log('errors after rejection:', errors);
await browser.close();
})();
"
```
```
PAGEERROR: boom-rejection
errors after rejection: 1
```
Утверждение подтвердилось независимо. Снимаю находку без возврата автору — но фиксирую
здесь, чтобы АС3 в реализации имела ссылку на реальную проверку, а не только на веру в текст
ТЗ, ведь при код-ревью «verified без команды доказательством не является» уже будет применяться
в полную силу.
## Что проверено и корректно
- Обязательные разделы §7.1 присутствуют все: сценарий, что человек увидит до/после,
проблема и контракт, скоуп/не-скоуп, UX/данные/i18n («не применимо», обоснованно — код не
продуктовый), АС1…АС8 с доказательством, план автотестов, риски, откат, release-артефакты.
- Технические цифры и ссылки на код в ТЗ пересчитаны и подтверждены: номера строк
`demo/serve.mjs` (34-45 в момент разбора владельца → 35-46 сейчас, сдвиг на одну строку не
меняет сути и не вводит в заблуждение), `205 из 211` смоков зовут `finish(browser, out)`,
`8` тестов-прецедентов в `test/` уже гоняют процессы, объявленный тип `_markerDialog`
(`src/houseplan-card.ts:2222-2235`) действительно требует `binding`/`bindingMode`,
реальная строка фикстуры `demo/smoke_danger_confirmation.mjs:167` действительно их не
задаёт.
- АС1/АС2 (тайминг-гонка `pageerror` vs `finish()`) подтверждены исполнением в S2-анализе
владельца (пробный `launch()` → исключение → `finish()` даёт `exit=0`; с одним
`page.evaluate(() => 0)` между ними — `exit=1`) — это ровно то доказательство «тест умеет
падать», которого ждёт процесс, просто снятое до входа в S3, а не заново мной: я прочитал
транскрипт и он внутренне непротиворечив с описанным механизмом (`Runtime.exceptionThrown`
доставляется по CDP асинхронно, round-trip вытесняет очередь).
AC2 отдельно требует зарегистрированного в `scripts/mutation-gate.mjs` мутанта, а не ручной
правки — прецедент такого мутанта на `demo/serve.mjs` уже есть в реестре
(`smoke-launcher-skips-freshness`, патчит ту же функцию `launchInternal`), то есть
требование выполнимо буквально как написано.
- АС4 (порог 5% на замере трёх смоков), АС5 (правка только фикстуры, без ослабления
утверждений), АС7 (дифф не трогает 204 смока), АС8 (закрытая страница не ломает `finish`) —
однозначны и проверяемы, способ доказательства назван для каждого.
- Продуктовая рамка `docs/SCOPE.md` к задаче неприменима буквально (задача не меняет продукт),
и в тексте это явно проговорено («Видимого поведения продукта задача не меняет»), а не
просто пропущено — ТЗ не выдаёт молчание за решение.
- Открытых продуктовых вопросов к владельцу нет и не должно быть: всё, что решает ТЗ, —
техническое (где регистрировать страницы, как считать round-trip, формат фикстур), ни один
вопрос не про то, что видит или делает человек — задача инфраструктурная.
- Откат простой и правдоподобный: удаление двух фрагментов кода делает фикстуры/тест
красными — это явный сигнал отката, а не молчаливая деградация.
## Чего не проверял
- Не гонял `typecheck`/`test`/`build` — в диффе на этом SHA нет ничего, кроме
`docs/specs/404-smoke-exception-guard.md`, гонять гейты не на чем.
- Не проверял оставшиеся ~200 файлов `demo/smoke_*.mjs` на предмет собственных `newContext`/
`newPage` построчно за пределами найденных через `grep -rln "newContext\|newPage"` трёх —
сам grep по всему набору отработал и дал исчерпывающий список (3 файла), дальше не искал.
- Не проверял, вызывает ли `_bindingHasHaPage`/типизация диалога маркера какие-то другие
дефекты — ТЗ прямо и справедливо относит это к «дефекта поведения нет», и это совпадает с
прочитанным кодом (15 точек создания диалога действительно передают `binding`, беглым
`grep`), глубже не копал, так как продуктовый код вне скоупа этой задачи и этого ревью.
- Не запускал полный набор `demo/smoke_*.mjs` (211 файлов) — на этапе ТЗ кода для прогона нет,
а S2-анализ владельца этот прогон уже провёл и результат (211 файлов, красный ровно один —
`smoke_danger_confirmation`) внутренне согласуется с находками Medium-1/Medium-2: обе они
про смоки, которые физически не могут покраснеть от `_pageErrors`, так что прогон владельца
их и не показал бы — это не противоречие, а подтверждение находок.
## Итог
High: 0. Medium: 2 (1 в скоупе — исправляется правкой ТЗ; 1 вне скоупа — новый issue #407).
Low: 1 (снят). Полностью выполненных АС недостаточно для зелёного вердикта: находка Medium-1
бьёт именно по доказательной силе АС6 и по честности раздела «Честная граница», это находка
про сам текст ТЗ, а не про гипотетическую реализацию.
**Вердикт: жёлтый.** Возврат автору на правку ТЗ (Medium-1), фикс проходит r2 в рамках той же
задачи. Medium-2 закрыт отдельным issue [#407](https://github.com/Matysh/houseplan-card/issues/407).
+201
View File
@@ -0,0 +1,201 @@
# ТЗ #404 — Гард «uncaught exception внутри карточки» перестаёт быть слепым к хвосту смока
- Issue: https://github.com/Matysh/houseplan-card/issues/404
- Приоритет: P2, tests + infra; полный трек — прецеденты #398 и #399 (обе
инфраструктурные, обе прошли с файлом ТЗ). Класс файлов — только B
(`demo/**`, `test/**`), ни одного файла класса A
- Ревизия: 1 (2026-08-31)
## Сценарий
Смок гоняет карточку, внутри карточки происходит необработанное исключение, и
смок печатает `OK`. CI зелёный. Никто ничего не узнаёт — ни сегодня, ни через
месяц, когда это исключение станет жалобой пользователя.
Гард ровно для этого и писался (комментарий `demo/serve.mjs:15-18`: «Until
2026-07-27 the smokes printed booleans and always exited 0»). Он существует,
он выглядит работающим, и в самом частом случае он молчит.
## Что человек увидит до и после
Видимого поведения продукта задача не меняет — меняется то, что видит
разработчик и CI.
**До**: исключение, случившееся после последнего обращения смока к странице, не
роняет смок и **даже не печатается**: `browser.close()` уносит недоставленное
событие. Смок сообщает `OK`, exit 0.
**После**: такое исключение считается и роняет смок, как и было задумано.
## Проблема и контракт
`finish()` (`demo/serve.mjs:34-45`) читает `_pageErrors` синхронно:
```js
export async function finish(browser, out) {
if (out !== undefined) console.log(JSON.stringify(out, null, 1));
if (_pageErrors) _failures.push(`${_pageErrors} uncaught exception(s) inside the card`);
await browser?.close?.();
…
}
```
События `pageerror` Playwright доставляет асинхронно по CDP. Если исключение
возникло после последнего обращения смока к странице, счётчик к моменту проверки
ещё нулевой.
**Воспроизведено исполнением** (проба: `launch()` → исключение в странице →
сразу `finish()`):
```
{ "probe": "exception thrown, finish() called immediately" }
EXC Error: boom-async ← доставлено во время browser.close()
OK ← гард уже прочитал 0
exit=0
```
Порядок вывода — сам диагноз: `EXC` печатается после результата и до `OK`.
**Граница дефекта установлена, и она уже:** гард не сломан всегда. Та же проба с
одним `await page.evaluate(() => 0)` между исключением и `finish()` даёт
`FAILED (1): 1 uncaught exception(s)` и `exit=1`. Слепа только зона «после
последнего обращения к странице» — то есть финальные ре-рендеры, закрытие
диалогов и всё, что карточка делает, пока смок печатает результат.
**Контракт**: `finish()` обязана прочитать счётчик после того, как страница
доставила всё, что успела произвести к моменту вызова. Достигается round-trip'ом
к каждой открытой странице до чтения счётчика:
```js
for (const page of _livePages) { try { await page.evaluate(() => 0); } catch { /* закрыта */ } }
```
Круговой запрос к странице вытесняет ранее поставленные макрозадачи и
гарантирует, что события, порождённые до него, уже дошли до Node.
**Ссылок на страницы у `finish()` сейчас нет**, а менять её сигнатуру нельзя:
`finish(browser, out)` зовут 205 смоков. Страницы регистрируются там, где
создаются, — в `launchInternal`, рядом с подпиской на `pageerror`.
**Честная граница, которую задача не закрывает**: исключение, возникшее *после*
round-trip'а (например, в обработчике `beforeunload` при закрытии браузера),
по-прежнему не будет учтено. Ловить его — значит ждать неизвестно чего
неизвестно сколько. Контракт формулируется как «всё, что произошло до момента
вызова `finish()`», и это записывается в комментарии у кода.
### Что вскрывает починенный гард
Прогон **всех 211** `demo/smoke_*.mjs` с починенным гардом: краснеет ровно один —
`smoke_danger_confirmation` (2 исключения, других провалов нет). Оба одинаковы:
```
TypeError: Cannot read properties of undefined (reading 'split')
at _bindingHasHaPage (houseplan-card.ts:4814)
at _renderMarkerDialog (houseplan-editor-runtime.ts:12652)
```
Причина — фикстура смока, а не карточка. `demo/smoke_danger_confirmation.mjs:167`:
```js
card._markerDialog = { devId: 'danger-marker', name: 'Danger marker', busy: false };
```
Объявленный тип (`houseplan-card.ts:2222-2235`) требует `binding: string` и
`bindingMode`; JS-смок это не проверяет. Все 15 мест в `src/`, создающих диалог,
`binding` пишут — дефекта поведения нет. Фикстура чинится в этой же задаче:
иначе смок покраснеет по чужой причине.
## Скоуп / не-скоуп
**В скоупе**: `demo/serve.mjs` (регистрация страниц и round-trip в `finish`),
фикстура `demo/smoke_danger_confirmation.mjs:167`, новые фикстуры-пробы и тест,
доказывающий, что гард умеет падать.
**Не в скоупе**: сигнатура `finish` и остальные 204 смока; `_bindingHasHaPage`
и типизация диалога маркера (дефекта нет); перевод смоков на TypeScript;
оброненный промис в удалении черновика (#405) — он ловится тем же гардом, но
чинится своей задачей.
## UX, модель данных, i18n
Не применимо: продуктового кода задача не касается.
## Критерии приёмки
- **AC1**. Исключение, брошенное в странице непосредственно перед `finish()`
без промежуточных обращений к странице, роняет смок: `exit=1`, в выводе
строка `uncaught exception(s) inside the card`. Доказательство: фикстура-проба
плюс тест, запускающий её процессом.
- **AC2**. **Отрицательный прогон обязателен**: та же фикстура на коде без
round-trip'а даёт `exit=0`. Доказательство: мутант, зарегистрированный в
`scripts/mutation-gate.mjs` и прогнанный штатным раннером (`--id=…`), а не
ручной правкой файла.
- **AC3**. Необработанное отклонение промиса (`Promise.reject`) считается так
же, как исключение. Проверено, что `page.on('pageerror')` в Chromium его
получает; AC закрепляет это фикстурой, чтобы связь с #405 не потерялась.
- **AC4**. Смок без исключений остаётся зелёным, и round-trip не делает набор
заметно медленнее: замер трёх смоков до и после, разница в пределах шума
(порог — 5% суммарного времени трёх прогонов).
- **AC5**. `smoke_danger_confirmation` зелёный: фикстура `:167` приведена в
соответствие объявленному типу (`binding`, `bindingMode`), исключений 0.
Утверждения смока при этом не ослабляются — правится только фикстура.
- **AC6**. Весь набор `demo/smoke_*.mjs` зелёный. Доказательство: полный прогон
(211 файлов); допускается один известный внешний отказ —
`smoke_infinite_canvas` требует поднятого бэкенда и краснеет и без правки.
- **AC7**. Сигнатура `finish(browser, out)` не изменилась, 204 смока не
тронуты. Доказательство: дифф задачи не содержит `demo/smoke_*.mjs`, кроме
`smoke_danger_confirmation.mjs` и новых фикстур.
- **AC8**. Закрытая или упавшая страница не ломает `finish()`: round-trip к ней
проглатывается, остальные страницы опрашиваются. Доказательство: фикстура,
закрывающая страницу до `finish()`.
## План автотестов
**Фикстуры** (новые файлы, не входят в набор `smoke_*` — иначе CI будет гонять
заведомо красный смок; имя без префикса `smoke_`, каталог `demo/fixtures/`):
1. `guard_tail_exception.mjs` — исключение прямо перед `finish()` (AC1, AC2).
2. `guard_tail_rejection.mjs` — `Promise.reject` прямо перед `finish()` (AC3).
3. `guard_closed_page.mjs` — страница закрыта до `finish()` (AC8).
**Тест** (`test/smoke-exception-guard.test.mjs`, прецедент — 8 тестов в `test/`
уже запускают процессы через `spawnSync`):
- каждая фикстура запускается процессом, проверяется код возврата и текст;
- отдельная проверка, что фикстуры лежат вне маски `smoke_*` (иначе они
попадут в CI-шарды и покрасят их).
**Мутанты** (`scripts/mutation-gate.mjs`):
- `smoke-guard-blind-to-tail`: убрать цикл round-trip'а из `finish` → тест AC1
краснеет;
- `smoke-guard-forgets-to-register-pages`: убрать `_livePages.push(page)` из
`launchInternal` → тот же тест краснеет (цикл есть, опрашивать нечего).
Второй мутант нужен потому, что правка состоит из двух половин, и мутант,
проверяющий только одну, оставит вторую недоказанной.
## Риски
- **Round-trip замедляет набор.** Один `evaluate` — единицы миллисекунд на
смок; при 211 смоках это доли секунды. Смягчение: AC4 меряет, а не
предполагает.
- **Фикстуры попадут в CI как обычные смоки и покрасят его.** Смягчение: имя вне
маски `smoke_*` плюс явная проверка в тесте.
- **Гард начнёт краснеть на чужих смоках после будущих правок.** Это и есть
цель, но первый такой случай выглядит как «сломали CI». Смягчение: сообщение
гарда уже называет причину дословно, а прогон 211 смоков в этой задаче
фиксирует базовую линию — сегодня краснеет ровно один и по известной причине.
- **`page.evaluate` на странице, ушедшей в навигацию, бросит.** Смягчение:
try/catch вокруг каждого round-trip'а, AC8.
## Откат
Обе правки локальны: цикл в `finish()` и регистрация страниц в
`launchInternal`. Возврат — удаление двух фрагментов; фикстуры и тест при этом
станут красными, что и покажет откат явно.
## Release-артефакты
- `docs/CHANGELOG.md` / `docs/CHANGELOG.ru.md`: **не требуется** (User-Visible:
no) — задача не меняет продукт.
- Скриншоты не меняются.