Прогон показал, что дописать binding и bindingMode было мало: у объявленного типа больше сорока обязательных полей, и рендер упал на следующем недостающем — теперь на name внутри _markerDraft. Гоняться за типом руками бессмысленно. Смок открывает диалог штатным _openMarkerDialog() и переопределяет три поля. Это заодно и доказательство, что дефекта поведения нет: продукт своим же путём собирает объект, на котором рендер не падает. Issue: #404 User-Visible: no
18 KiB
ТЗ #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 синхронно:
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'ом
к каждой открытой странице до чтения счётчика:
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:
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/):
guard_tail_exception.mjs— исключение прямо передfinish()(AC1, AC2).guard_tail_rejection.mjs—Promise.rejectпрямо передfinish()(AC3).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) — задача не меняет продукт.- Скриншоты не меняются.