Files
houseplan-card/legacy/specs/404-smoke-exception-guard.md
T
Claudeandclaude[bot] df46fd1c3e docs(hygiene): ТЗ выпущенных задач без живых ссылок — в legacy/specs (#682)
Волна 5 эпика #674, перенос ТЗ (класс C). Из 240 файлов `docs/specs/` в
`legacy/specs/` уехали 219: на них не ссылается ни один живой файл (код,
тесты, скрипты, workflow, документы вне архива и ревью). Остались 21 ТЗ —
на которые ссылаются код, ADR, ISOMETRIC, SUN, RADAR, LIGHT (`docs/specs/067`),
DECOR-EDITOR, support-relay, и те, на которые ссылаются они сами; README
каталога объясняет, где искать остальное. Открытых issue с файлом ТЗ среди
перенесённых нет. Относительные ссылки перенесённых файлов переписаны
(`../X` → `../../docs/X`, соседние оставшиеся ТЗ → `../../docs/specs/…`) —
все 26 резолвятся. Попутно: битая ссылка в
`089-isometric-view-stage1.md:8` на удалённый `089-isometric-view.md` —
теперь команда `git show` по истории. Строка в `legacy/README.md`.

Issue: #682
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-27 22:10:46 +00:00

18 KiB
Raw Blame History

ТЗ #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/):

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