docs: specify the smoke exception guard fix (#404)

User-Visible: no
Issue: #404
This commit is contained in:
Codex
2026-08-31 20:01:18 +03:00
parent 2fae84af58
commit 884387d770
+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) — задача не меняет продукт.
- Скриншоты не меняются.