# ТЗ #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) — задача не меняет продукт. - Скриншоты не меняются.