mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 19:58:50 +00:00
Прогон показал, что дописать binding и bindingMode было мало: у объявленного типа больше сорока обязательных полей, и рендер упал на следующем недостающем — теперь на name внутри _markerDraft. Гоняться за типом руками бессмысленно. Смок открывает диалог штатным _openMarkerDialog() и переопределяет три поля. Это заодно и доказательство, что дефекта поведения нет: продукт своим же путём собирает объект, на котором рендер не падает. Issue: #404 User-Visible: no
255 lines
18 KiB
Markdown
255 lines
18 KiB
Markdown
# ТЗ #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) — задача не меняет продукт.
|
||
- Скриншоты не меняются.
|