Files
houseplan-card/docs/specs/404-smoke-exception-guard.md
T
Matysh 8ac8f0105a fix: let the product build the marker-dialog fixture
Прогон показал, что дописать binding и bindingMode было мало: у объявленного
типа больше сорока обязательных полей, и рендер упал на следующем
недостающем — теперь на name внутри _markerDraft. Гоняться за типом руками
бессмысленно.

Смок открывает диалог штатным _openMarkerDialog() и переопределяет три поля.
Это заодно и доказательство, что дефекта поведения нет: продукт своим же
путём собирает объект, на котором рендер не падает.

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

255 lines
18 KiB
Markdown
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.
# ТЗ #404 — Гард «uncaught exception внутри карточки» перестаёт быть слепым к хвосту смока
- Issue: https://github.com/Matysh/houseplan-card/issues/404
- Приоритет: P2, tests + infra; полный трек — прецеденты #398 и #399 (обе
инфраструктурные, обе прошли с файлом ТЗ). Класс файлов — только B
(`demo/**`, `test/**`), ни одного файла класса A
- Ревизия: 2 (2026-09-01) — по жёлтому вердикту SPEC-REVIEW-404-r1
## Ревизия 2: что изменилось после жёлтого ревью
**Medium-1 (в скоупе, возврат автору) закрыт расширением, а не оговоркой.**
Ревью нашло разрыв: контракт «страницы регистрируются там, где создаются, — в
`launchInternal`» не покрывает страницы, созданные смоком после `launch()`.
Выбор был между «расширить регистрацию» и «назвать разрыв второй границей».
Взят первый: документировать слепую зону в задаче, которая существует ради
устранения слепой зоны, — значит закрыть issue, оставив дефект.
Гард отдаёт наружу одну функцию, `watchPage(page)`: она подписывает на
`pageerror` и регистрирует страницу для round-trip'а. Двумя половинами это быть
не может — разъехавшись, они дадут страницу, чьи исключения считаются, но
доставки которых никто не ждёт.
**Разрыв оказался шире, чем в ревью.** Ревью назвало два файла
(`smoke_zoom_flash`, `smoke_svg_sandbox`); проверка по всему набору нашла ещё
два — `smoke_cold_view_toggle` и `smoke_cold_view_vacuum` вешают собственный
слушатель. Они, в отличие от первых двух, **не** слепая зона: страница приходит
из `launchColdView()`, то есть уже зарегистрирована, а свой счётчик они
превращают в отдельное утверждение `noPageErrors`. Такая подписка законна и
полезна. Поэтому инвариант сформулирован не как «никто не подписывается сам», а
как «ни одна страница не создаётся мимо гарда» — и закреплён тестом по всему
набору, а не по двум названным файлам.
**AC7 скорректирован**: дифф задачи содержит `demo/smoke_zoom_flash.mjs`,
`demo/smoke_svg_sandbox.mjs` (регистрация страниц) и пять смоков из #407
(вердикт стал асинхронным), помимо `smoke_danger_confirmation.mjs`. Сигнатура
`finish(browser, out)` не изменилась — это и было существом AC7.
## Отступления от ревизии 1, каждое по измеренной причине
**1. Пробы лежат в `demo/guard/`, а не в `demo/fixtures/`.** Каталог
`demo/fixtures/**/*.mjs` входит в корпус `sourceFingerprint`
(`scripts/source-fingerprint.mjs:66`), поэтому каждый новый `.mjs` там объявляет
устаревшими закоммиченный бандл, скриншот-индекс документации и golden-индекс.
Пробы гарда не касаются ни одного пикселя — платить за них пересъёмкой нечем.
Запрет закреплён тестом.
**3. AC5: фикстуру собирает продукт, а не смок.** Ревизия 1 предполагала
дописать `binding` и `bindingMode`. Прогон показал, что этого мало: у
объявленного типа больше сорока обязательных полей, и рендер падал на следующем
недостающем. Гоняться за типом руками бессмысленно — смок открывает диалог
штатным `_openMarkerDialog()` и переопределяет три поля. Заодно это и есть
доказательство, что дефекта поведения нет: продукт своим же путём собирает
объект, на котором рендер не падает.
**2. Поведение доказывается в job «Смоки в браузере», а не в `npm test`.**
Пробам нужен настоящий Chromium, а job «Фронтенд», где идёт `npm test`, браузеры
не ставит. Тест на пробах там молча скипался бы — то есть ровно тот тихий успех,
против которого вся задача. В `test/` остались проверки, которые запуском не
делаются: порядок операций в исходнике, место проб, наличие вызова в workflow и
регистрация мутантов.
## Сценарий
Смок гоняет карточку, внутри карточки происходит необработанное исключение, и
смок печатает `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) — задача не меняет продукт.
- Скриншоты не меняются.