21 KiB
ТЗ #421 — Отрицательные доказательства для трёх защитных проверок
- Issue: https://github.com/Matysh/houseplan-card/issues/421
- Приоритет: P2, tech-debt/tests/infra
- Маршрут: standard; три независимые поверхности нарушают критерий
small - Связанные контракты: #43 (support preview), #404/#407 (browser error guard), #401/#409 (приёмка документационных скриншотов), #85 (mutation gate)
Сценарий
Разработчик меняет lifecycle support-preview, browser error guard или CLI приёмки скриншотов и запускает объявленную для этого контракта проверку. Сейчас она может остаться зелёной после удаления защищающего механизма, поэтому ревьюер получает ложное доказательство и регрессия может попасть в релиз.
После этой задачи тот же намеренный дефект воспроизводится адресным мутантом либо контролируемым red proof. Гейт обязан покраснеть до merge; исправная версия проходит тот же тест зелёной.
Что человек увидит до и после
До: видимого дефекта в текущей версии нет, но три зелёные проверки не гарантируют заявленное поведение. Будущая регрессия может незаметно вернуть пригодность заменённого support-токена, пропустить browser exception или стереть историю приёмки документационных кадров.
После: UI и штатные сценарии выглядят ровно так же. Пользователь получает косвенный результат: эти три класса регрессий блокируются проверками до выпуска, а не обнаруживаются после него.
Цель
Сделать три существующих утверждения доказательными: тест или гейт обязан краснеть, когда ломается ровно тот механизм, защитой которого он объявлен. Пользовательское поведение не меняется; закрепляются уже действующие контракты.
Проблема
1. Lifecycle support-preview
test_support_preview_replacement_and_discard_are_draft_local создаёт три
разных токена, а затем отправляет три идемпотентных discard. Успех discard
ничего не говорит о наличии токена в runtime-map. Поэтому тест остаётся зелёным,
если убрать replacement-loop из ws_support_preview().
Отдельно не доказано истечение SUPPORT_PREVIEW_TTL_S: ни один тест не
переводит monotonic clock за границу TTL и не пытается применить истёкший токен.
2. reportPageErrors()
Browser probes guard_tail_exception, guard_tail_rejection и
guard_closed_page завершаются через finish(). Существующие мутанты ломают
round-trip внутри finish() либо общий registry страниц, но не отдельный
round-trip в reportPageErrors(). Удаление этого await остаётся зелёным.
3. Acceptance trace документационных скриншотов
Чистые тесты docsAcceptancePlan() проверяют выбор replace/keep/witnesses, но
не ветку в docs-accept.mjs, которая при fingerprint-only refresh переносит
предыдущий manifest.acceptance. Возврат к пустому следу приёмки не замечается.
Скоуп
В скоупе:
- реальные submit-проверки заменённого и явно удалённого preview-токена;
- доказательство, что токен другого
draft_idне инвалидируется заменой; - детерминированный TTL-тест без ожидания реального времени;
- отдельная browser probe, использующая
reportPageErrors(), а неfinish(); - адресный мутант, удаляющий round-trip только из
reportPageErrors(); - исполняемая, экспортируемая логика построения нового manifest acceptance
внутри
docs-accept.mjsи её unit-тест; - адресный мутант, возвращающий потерю прежнего acceptance trace;
- запись отрицательных доказательств в mutation registry и документ ревью.
Не-скоуп
- изменение WebSocket API, кодов ошибок, TTL, лимитов или idempotency semantics;
- изменение идемпотентного ответа
preview/discardдля отсутствующего токена; - изменение UI Help & feedback или текста privacy notice;
- переработка общего browser smoke harness и всех существующих probes;
- изменение правил принятия скриншотов, witness floor или CLI-флагов;
- новый общий процессный документ: требование показать, что тест умеет падать,
уже закреплено в
AGENTS.mdиPROCESS.md; - полный аудит всех тестов проекта за пределами трёх названных швов.
UX
Новых экранов, сообщений, настроек и действий нет. Help & feedback сохраняет действующие ответы и срок жизни preview; browser smokes и docs acceptance CLI остаются инструментами разработки. Desktop, touch, kiosk, keyboard, focus и ARIA не затронуты.
Контракт
1. Replacement, discard и TTL preview-токена
Для одного HA user и одного draft_id новый preview заменяет предыдущий:
- submit со старым токеном отвечает
support_preview_expired; - submit с новым токеном остаётся доступным, пока токен не удалён, не истёк и не был успешно использован;
- preview другого
draft_idтого же user заменой не затрагивается и может быть успешно отправлен; - после успешного
preview/discardsubmit того же токена отвечаетsupport_preview_expired; - сам
discardостаётся идемпотентным и по-прежнему может ответить{ok: true}для уже отсутствующего токена.
TTL проверяется управляемым monotonic clock:
- до
expiresтокен пригоден; - при
now >= expiresследующий submit вызывает pruning и отвечаетsupport_preview_expired; - relay transport для отклонённого токена не вызывается;
- тест не использует
sleepи не зависит от скорости CI.
Тесты используют разные idempotency keys, чтобы результат lifecycle токена не маскировался relay/idempotency-кэшем.
2. Отдельный отрицательный путь reportPageErrors()
Новая browser probe:
- запускает настоящий Chromium через существующий harness;
- регистрирует страницу штатным
watchPage/launchпутём; - создаёт page error в хвосте текущего browser turn;
- завершает сценарий только через
await reportPageErrors(); - ожидает ненулевой exit code и сообщение
uncaught exception(s) inside the card.
Probe не вызывает finish(): иначе она снова доказывала бы чужую ветку.
verify-guard.mjs запускает probe рядом с существующими и отличает ожидаемый
красный вердикт от timeout/crash без диагностического сообщения.
Mutation registry содержит адресный патч, который удаляет
await roundTripLivePages() только из тела reportPageErrors(). Патч обязан
иметь уникальный anchor и краснеть на новой probe.
3. Сохранение acceptance trace
CLI-owned функция построения принятого manifest получает:
- candidate manifest;
- прежний
acceptanceлибо отсутствие значения; - решение
docsAcceptancePlan(); - флаг/причину сознательного пропуска witnesses.
Она возвращает новый manifest без мутации входов:
- если
decision.replaceнепуст, записывает новый trace с точнымиdeclared,witnesses,floorи, при необходимости,witnessesSkippedBecause; - если пиксели не заменялись, сохраняет все поля прежнего trace и добавляет
lastWriteWasFingerprintOnly: true; - если прежнего trace нет, fingerprint-only refresh создаёт только
lastWriteWasFingerprintOnly: true, не выдумывая witnesses; main()использует эту функцию как единственный путь формирования поляacceptanceперед записьюdocs/images/screenshots.json.
Unit-тест живёт рядом с тестами docs-accept.mjs, а не только в тестах чистого
планировщика. Mutation registry возвращает старый пустой fallback и обязан
получить красный результат именно от этого теста.
Затрагиваемые файлы
Ожидаемый набор:
tests_backend/test_ha_websocket.py— submit-проверки replacement/discard и отдельный TTL case;demo/guard/guard_report_page_errors.mjs— новая отрицательная browser probe;demo/guard/verify-guard.mjs— запуск и проверка новой probe;scripts/docs-accept.mjs— тестируемая CLI-owned сборка acceptance trace;test/docs-accept.test.mjs— точная матрица нового/сохранённого trace;scripts/mutation-gate.mjs— адресные мутанты трёх разрывов;test/mutation-gate.test.mjs— существующая применимость реестра; новые специальные тесты нужны только если текущий общий контракт их не покрывает;docs/TESTING.md— только если без короткого описания новых guard IDs их невозможно обнаружить из действующего runbook.
Production modules custom_components/houseplan/websocket_api.py и
demo/serve.mjs менять не требуется, если новые доказательства подтверждают
текущее поведение. Если тест выявит реальный дефект поведения, реализация
останавливается и найденный продуктовый scope оформляется отдельно.
Модель данных и миграция
Persisted config/layout, support-package schema, WebSocket payloads и runtime records preview не меняются; миграция и compatibility-поля не нужны.
Тестируемая функция docs-accept.mjs перестраивает тот же manifest, который CLI
пишет сейчас. Формат docs/images/screenshots.json не расширяется: при реальной
замене пикселей создаётся уже существующий acceptance trace, а при
fingerprint-only refresh переносятся его прежние поля и добавляется уже
действующий lastWriteWasFingerprintOnly. Старые manifests без acceptance
остаются допустимыми.
i18n, compatibility, touch и security
- новых строк и изменений словарей нет;
- persisted config, import/export и WebSocket schema не меняются;
- desktop/touch/kiosk/accessibility не меняются;
- новых входов, прав, URL и раскрытия данных нет;
- тесты support preview не должны писать raw package/message в failure output;
- временные mutation worktrees удаляются существующим cleanup-контрактом.
Производительность
Обычный npm test получает только дешёвые unit/registry проверки. HA integration
test выполняется в закреплённом backend job. Новая browser probe и адресные
мутанты входят в дорогой mutation/release gate и не запускаются на каждом
обычном frontend unit прогоне сверх уже существующего verify-guard там, где
он явно вызван.
Никаких runtime-издержек продукта нет. Реальные sleep запрещены; TTL управляет
mocked monotonic clock.
Критерии приёмки
- AC1. После replacement submit старого токена получает
support_preview_expired, а submit токена другогоdraft_idдостигает подменённого transport с точными preview bytes. Доказательство: HA backend test + отрицательный прогон без replacement-loop. - AC2. После
preview/discardsubmit удалённого токена получаетsupport_preview_expired; идемпотентный success самого discard не используется как доказательство удаления. Доказательство: HA backend test + отрицательный прогон без удаления записи. - AC3. Токен принимается до TTL и отклоняется при
now >= expires, transport после истечения не вызывается; тест не ждёт реальное время. Доказательство: HA backend test + отрицательный прогон без submit-pruning. - AC4. Новая browser probe завершается ожидаемым failure и сообщением через
reportPageErrors()без вызоваfinish(). Доказательство:node demo/guard/verify-guard.mjs. - AC5. Мутант, удаляющий round-trip только из
reportPageErrors(), оставляет probe без требуемого вердикта и mutation gate считает мутанта пойманным. Доказательство: адресный запускscripts/mutation-gate.mjs --id=.... - AC6. При замене пикселей новый acceptance trace точен; при
fingerprint-only refresh прежние
declared,witnesses,floorи произвольные совместимые поля сохраняются, выставляетсяlastWriteWasFingerprintOnly. Входы не мутируются. Доказательство: unit. - AC7. Мутант, возвращающий пустой fingerprint-only trace, краснеет на
тесте CLI-owned функции, а не на тесте
docsAcceptancePlan(). Доказательство: адресный запуск mutation gate. - AC8.
node scripts/mutation-gate.mjs --check, typecheck, полный frontend unit suite, build и затронутые backend tests зелёные. Доказательство: локальные гейты и Linux CI. - AC9. Runtime product behavior, публичные API, i18n, config schema и пиксели не изменены. Доказательство: diff/code review; smoke/golden/docs screenshots не требуются при отсутствии product-source visual delta.
План отрицательной проверки
До реализации или в review evidence фиксируются три независимых красных результата:
- удалён replacement-loop (и отдельно pruning/discard для AC2–AC3) — HA-тесты падают на ожидаемом коде/transport call;
- удалён round-trip только из
reportPageErrors()— новая browser probe не выдаёт требуемый красный вердикт,verify-guard/mutation gate падает; - fingerprint-only fallback заменён пустым trace — unit CLI-owned функции показывает потерю прежних полей.
Зелёный прогон без зафиксированного соответствующего red proof не закрывает AC.
Риски
- Backend test маскирует результат успешным relay mock. Каждый submit проверяет и WebSocket response, и число/байты transport calls.
- TTL test становится flaky. Время передаётся через monkeypatch
time.monotonic; ожидания и реальные таймеры запрещены. - Новая probe снова попадает в
finish(). Source/contract test фиксирует, что её развязка вызываетreportPageErrors()напрямую. - Мутант ложится в одноимённый round-trip
finish(). Anchor включает имя функции и уникальный соседний код;--checkтребует ровно одно совпадение. - Тест manifest helper расходится с CLI.
main()не дублирует ветку и вызывает экспортированную функцию непосредственно. - Release mutation gate дорожает. Добавляются только адресные guards; backend/browser выполнение остаётся в существующих shard/time budgets.
Откат
Откат одного implementation-коммита удаляет новые тесты/probe/mutants и возвращает inline-сборку acceptance trace. Данные, API и пользовательская конфигурация не мигрируются. Откат безопасен для runtime, но снова оставляет три заявленных контракта без отрицательного доказательства.
Release-артефакты
- changelog RU/EN не меняется: пользовательское поведение не изменено;
- golden, docs screenshots, performance profile и security artefact не нужны;
- документ код-ревью обязан перечислить red proof для AC1–AC7;
- issue остаётся открытым в
S8-mergedдо пакетного закрытия при выпуске беты.
Принятые предположения
- Текущая production-реализация трёх контрактов верна; задача чинит их доказательства. Красный тест, выявивший реальную ошибку поведения, расширением этой задачи автоматически не считается.
- Для backend mutation guard используется действующий
scripts/backend-test-guard.mjs, чтобы запускать один именованный HA-тест. - Сохранение произвольных совместимых полей прежнего acceptance trace важнее
жёсткого перечисления только
declared/witnesses/floor: fingerprint-only refresh не является новой приёмкой и не должен переписывать её историю. - Названия probe и mutant IDs технические и могут быть уточнены при реализации без изменения AC.