From 718afca221da8fd3eda3dad3bf29c27c24c5bd10 Mon Sep 17 00:00:00 2001 From: Sergey Matyunin Date: Sun, 27 Sep 2026 14:06:48 +0300 Subject: [PATCH] =?UTF-8?q?test(mutation):=20=D1=81=D0=BE=D0=BA=D1=80?= =?UTF-8?q?=D0=B0=D1=82=D0=B8=D1=82=D1=8C=20browser=20guards=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B5=D1=81=D1=82=D1=80=D0=B0=20(#659)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue: #659 User-Visible: no --- PROCESS.md | 9 + docs/testing-notes/README.md | 10 + docs/testing-notes/mutation-browser-guards.md | 252 ++++++++++++++++++ scripts/inventory.mjs | 10 + scripts/mutation-browser-policy.mjs | 30 +++ scripts/mutation-bundle-cache.mjs | 58 ++++ scripts/mutation-execution.mjs | 27 +- scripts/mutation-gate.mjs | 58 ++-- scripts/mutation-registry-check.mjs | 63 +++++ scripts/mutation-registry.mjs | 167 ++++++++++++ test/mutation-browser-offload.test.mjs | 152 +++++++++++ test/mutation-gate.test.mjs | 30 ++- 12 files changed, 810 insertions(+), 56 deletions(-) create mode 100644 docs/testing-notes/mutation-browser-guards.md create mode 100644 scripts/mutation-browser-policy.mjs create mode 100644 scripts/mutation-bundle-cache.mjs create mode 100644 scripts/mutation-registry-check.mjs create mode 100644 test/mutation-browser-offload.test.mjs diff --git a/PROCESS.md b/PROCESS.md index 1f074086..657c46d0 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -251,6 +251,15 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready → ревьюер не воспроизведёт отрицательный прогон второй раз. Для чистых юнитов достаточно прогона со снятой защитой, приведённого в документе. + Новый мутант с browser-smoke guard допустим только когда инвариант нельзя + доказать без браузера: `because` обязан назвать конкретную зависимость от + DOM/CSS paint, измеренной геометрии, trusted pointer/lifecycle или browser + wall-time, а id — попасть в размеченный реестр + `docs/testing-notes/mutation-browser-guards.md`. `mutation-gate --check` + показывает число browser guards против лимита 200, краснеет при росте выше + лимита и предупреждает о любом id без browser-обоснования; ревьюер проверяет + не только наличие строки, но и невозможность более дешёвого `node --test`. + Считаются **защитные AC без названного свидетеля**, а не мутанты на подсистему: у #421 мутанты были, и дыра всё равно проехала. «Сколько мутантов принесла задача» остаётся признаком — у #423 их ноль, и именно у #423 нашёлся diff --git a/docs/testing-notes/README.md b/docs/testing-notes/README.md index 809195d6..915ceba8 100644 --- a/docs/testing-notes/README.md +++ b/docs/testing-notes/README.md @@ -107,6 +107,16 @@ - [Воспроизводимость съёмки документации (#410, #422)](infrastructure.md#воспроизводимость-съёмки-документации-410-422) - [Пустое совпадение `--test-name-pattern` (#650)](infrastructure.md#пустое-совпадение---test-name-pattern-650) +## [Browser guards mutation registry (#659)](mutation-browser-guards.md) + +- [Reviewed per-mutant inventory](mutation-browser-guards.md#reviewed-per-mutant-inventory) +- [Performance threshold](mutation-browser-guards.md#performance-threshold) +- [Browser harness integrity](mutation-browser-guards.md#browser-harness-integrity) +- [Paint, cascade and layer composition](mutation-browser-guards.md#paint-cascade-and-layer-composition) +- [Pointer geometry and trusted interaction](mutation-browser-guards.md#pointer-geometry-and-trusted-interaction) +- [Responsive DOM layout](mutation-browser-guards.md#responsive-dom-layout) +- [Custom-element and HA browser lifecycle](mutation-browser-guards.md#custom-element-and-ha-browser-lifecycle) + ## [История прогонов и партий](history.md) - [Last self-run](history.md#last-self-run) diff --git a/docs/testing-notes/mutation-browser-guards.md b/docs/testing-notes/mutation-browser-guards.md new file mode 100644 index 00000000..87f3411d --- /dev/null +++ b/docs/testing-notes/mutation-browser-guards.md @@ -0,0 +1,252 @@ +# Browser guards mutation registry (#659) + +This is the reviewed classification of every mutation witness that still needs a browser. +The executable limit is `200`; `mutation-gate --check`, `npm run inventory` and the unit +contract all read this same inventory. A new browser guard must be added deliberately under +one reason below, and its mutant `because` must explain the concrete browser-only invariant. + +Converted witnesses are not listed here: their registry guard names an explicit `node --test` +suite plus `test/mutation-browser-offload.test.mjs`, and every converted mutant is run once +to prove that the Node witness actually kills it. + +| Category | Count | Why a browser is still required | +| --- | ---: | --- | +| Performance threshold | 4 | The witness measures real browser wall-time or frame work; a pure assertion cannot prove the budget. | +| Browser harness integrity | 4 | The mutation breaks page-error, round-trip or page-registration observation in the browser harness itself. | +| Paint, cascade and layer composition | 26 | The invariant depends on computed CSS, SVG paint, clipping, stacking or pixels produced by Chromium. | +| Pointer geometry and trusted interaction | 45 | The invariant depends on hit testing, pointer capture, touch/keyboard dispatch or live DOM geometry. | +| Responsive DOM layout | 36 | The invariant depends on measured element boxes, responsive breakpoints, native/HA dialog shells or focusable target size. | +| Custom-element and HA browser lifecycle | 85 | The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy loading or a complete HA-card state transition. | +| **Total** | **200 / 200** | Growth above the cap fails `mutation-gate --check`. | + +## Reviewed per-mutant inventory + +### Performance threshold + +The witness measures real browser wall-time or frame work; a pure assertion cannot prove the budget. + +- `junction-limit-p3-quadratic-again` +- `junction-limit-p4-bruteforce-again` +- `wall-draw-full-preflight-again` +- `wall-draw-wall-artifact-discarded` + +### Browser harness integrity + +The mutation breaks page-error, round-trip or page-registration observation in the browser harness itself. + +- `benchmark-page-verdict-unwatched` +- `report-page-errors-skips-round-trip` +- `smoke-guard-blind-to-tail` +- `smoke-guard-forgets-to-register-pages` + +### Paint, cascade and layer composition + +The invariant depends on computed CSS, SVG paint, clipping, stacking or pixels produced by Chromium. + +- `daycycle-outline-not-promoted` +- `daycycle-outline-promoted-on-inner-paper` +- `daycycle-static-outline-promoted-on-inner-paper` +- `decor-restored-below-room-fills` +- `device-keyboard-bypasses-click-path` +- `device-long-value-ellipsis-restored` +- `device-unavailable-hover-restored` +- `golden-filled-tunnel-removed` +- `golden-lamp-out-of-reach` +- `hatch-static-renderer-untouched` +- `hatch-stroke-not-scaled` +- `hatch-zoom-compensation-back` +- `iso-first-frame-reveals-flat-during-lazy-load` +- `iso-room-label-44-box-centres-name` +- `iso-sun-card-drops-occluders` +- `iso-sun-flat-wedges-remain` +- `iso-theme-dark-wall-rule-returns` +- `stage3-w4-device-target-loses-44px-floor` +- `stage3-w5-runtime-nudge-writes-storage` +- `stage3-w6-no-borders-keeps-raised-plates` +- `stage3-w7-sun-state-enters-structural-key` +- `sun-ray-origin-cache-ignored` +- `sun-ray-origin-save-forced-inner` +- `value-static-icon-keeps-live-vacuum` +- `value-static-icon-keeps-route-warning` +- `value-static-icon-keeps-vacuum-overlay` + +### Pointer geometry and trusted interaction + +The invariant depends on hit testing, pointer capture, touch/keyboard dispatch or live DOM geometry. + +- `align-guides-exclude-dead-source` +- `align-point-reads-frozen-snapshot` +- `decor-default-style-debounce-cut` +- `decor-default-style-seed-cut` +- `decor-keyboard-nudge-drops-focus-dialog-guards` +- `decor-keyboard-nudge-reruns-magnet` +- `dense-device-hit-browser-skips-painted-priority` +- `double-fit-bypasses-canonical-fit-all` +- `furniture-art-editor-adopt-skipped` +- `furniture-edge-handles-steal-the-corner` +- `furniture-exterior-surface-removed` +- `furniture-shift-listeners-not-attached` +- `furniture-wall-runtime-drops-drag-side` +- `furniture-wall-runtime-drops-raw-intent` +- `live-pinch-compositor-demoted-on-active-lit-commit` +- `opening-dimension-overlay-hidden` +- `opening-search-hides-none` +- `opening-search-select-not-wired` +- `placement-accepts-any-mouse-button` +- `reorder-skips-materialization` +- `resize-history-boundary-repair-removed` +- `resize-label-uses-old-room-gear-centre` +- `resize-labels-hide-narrow-area` +- `resize-pointer-capture-removed` +- `resize-preview-reject-silent` +- `room-fit-html-overlay-jumps-ahead` +- `room-fit-pan-release-reaccepted` +- `room-fit-persists-zoom` +- `safe-resize-commit-preflight-bypassed` +- `sections-editor-transition-uses-window-height` +- `sections-grid-drops-stage-flex-chain` +- `sections-grid-falls-back-to-viewport-height` +- `sections-resize-drops-stage-refit-observer` +- `space-card-decor-capability-change-not-adopted` +- `space-card-decor-capability-downgrade-does-not-clear-assets` +- `tab-drag-outlives-the-card` +- `tab-drag-survives-release-outside` +- `tab-drag-target-follows-captured-source` +- `tab-drop-indicator-always-before` +- `tab-drop-outside-commits-last-target` +- `tab-reorder-not-persisted` +- `touch-pinch-marker-hold-rearmed` +- `touch-pinch-zoom-persists-per-frame` +- `unavailable-toggle-stays-silent` +- `wall-draw-rejection-rollback-skipped` + +### Responsive DOM layout + +The invariant depends on measured element boxes, responsive breakpoints, native/HA dialog shells or focusable target size. + +- `dialog-ha-disconnected-reject-reopen-enabled` +- `dialog-ha-rejected-close-reopen-disabled` +- `dialog-native-reconnect-recovery-disabled` +- `dialog-native-surface-stretches-to-viewport` +- `dialog-native-update-recovery-disabled` +- `fit-house-hidden-walls-vote` +- `grid-scale-imperial-roundtrip-drift` +- `grid-scale-opening-hit-unscaled` +- `grid-scale-opening-symbol-unscaled` +- `grid-scale-plan-chrome-unscaled` +- `grid-scale-static-factor-missing` +- `header-menu-item-below-44` +- `header-menu-outside-tap-reaches-plan` +- `hidden-room-names-compact-svg-fallback` +- `hidden-room-names-full-svg-fallback` +- `hidden-room-names-iso-override` +- `hp-dialog-ignores-flex-content` +- `panel-readonly-empty-bypasses-write-capability` +- `phone-editor-close-hidden-with-mode-buttons` +- `phone-header-at-tablet-width` +- `phone-header-shows-title` +- `phone-header-wraps` +- `static-card-descendants-hit-testable` +- `summary-dialog-drops-flex-content` +- `summary-picker-hot-add-stays-filtered` +- `tab-editing-without-write-access` +- `toolbar-active-highlight-stops-at-tab` +- `toolbar-close-back-inside-tab` +- `toolbar-close-hidden-at-medium-width` +- `toolbar-close-slot-collapses-outside-editor` +- `toolbar-close-slot-idle-focusable` +- `toolbar-close-slot-leaves-mode-group` +- `toolbar-close-target-below-24` +- `toolbar-device-count-returns` +- `toolbar-mode-zoom-gap-regresses` +- `warm-dialog-drops-transferred-baseline` + +### Custom-element and HA browser lifecycle + +The invariant crosses Lit/custom-element lifecycle, browser storage/events, lazy loading or a complete HA-card state transition. + +- `accepted-marker-rolled-back-by-layout-failure` +- `area-relocation-clears-whole-history` +- `area-relocation-loses-position-on-refusal` +- `backdrop-busy-dismiss-races-decision` +- `backdrop-downscale-drops-alpha` +- `backdrop-phase2-falls-back-to-original` +- `backdrop-probe-always-safe` +- `barrier-cache-never-invalidated` +- `camera-anchor-from-presented` +- `camera-cancel-loses-zoom` +- `child-readd-clears-parent-tombstone` +- `cold-view-toggle-delegated-to-runtime` +- `cold-view-vacuum-mapid-delegated` +- `color-picker-confirm-click-through` +- `color-picker-invalid-confirm-latch-removed` +- `config-updated-event-ignored` +- `confirm-dialog-loses-alertdialog` +- `current-rejected-physical-write-keeps-optimistic-wall` +- `danger-confirm-back-into-the-branch` +- `danger-confirm-lost-space-request-guard-removed` +- `danger-confirm-lost-space-transition-cancel-removed` +- `danger-confirm-uses-last-rendered-language-gate` +- `danger-confirm-warm-language-guard-removed` +- `danger-confirm-warm-transition-cancel-removed` +- `daycycle-programmatic-camera-skips-safe-outline` +- `device-echo-keeps-local-noncanonical` +- `device-focus-tooltip-blur-cleanup-removed` +- `device-focus-tooltip-handler-removed` +- `device-focus-tooltip-room-hover-overwrites` +- `device-inbox-batch-rollback` +- `household-enter-stops-acting` +- `household-marker-drops-keyboard-reach` +- `device-markers-rendered-without-keys` +- `device-pointer-leave-clears-focus-fallback` +- `device-position-cancel-routed-to-commit` +- `discovery-reset-writes-a-copy` +- `editor-neutral-escape-does-not-exit` +- `empty-space-cleanup-disabled` +- `fixed-floor-transition-guard-bypassed` +- `french-locale-wrong-dictionary` +- `hp-dialog-escape-does-not-close` +- `junction-limit-baseline-cache-stale` +- `junction-limit-candidate-fail-open` +- `junction-limit-write-gate-removed` +- `locale-failure-toast-dropped` +- `marker-reject-keeps-optimistic-candidate` +- `marker-rollback-keeps-enqueue-time-revision` +- `namespace-loader-returns-english` +- `near-axis-authoring-snap-bypassed` +- `onboarding-loader-skips-namespace-ensure` +- `openings-rendered-without-keys` +- `plan-only-preview-label-hidden` +- `plan-room-area-icon-hidden` +- `plan-room-area-icon-navigates` +- `plan-upload-client-limit` +- `plan-upload-guard-original` +- `plan-upload-reduced-over-limit-staged` +- `post-write-tail-runs-on-refused-gate` +- `readonly-view-syncs-new-devices` +- `render-invalidation-renders-irrelevant-ha` +- `reopened-room-from-registry-space` +- `room-accept-leaves-coincident-partitions` +- `room-gear-second-touch-keeps-drag` +- `room-settings-click-does-not-open` +- `room-tooltip-off-skips-pointer-modality` +- `same-space-room-change-recenters` +- `space-create-hidden-display-override` +- `stairs-view-pan-opens-target-floor` +- `support-invalid-response-leaks-issued-token` +- `support-stale-preview-response-revives-consent` +- `support-timeout-claims-success` +- `vacuum-overlay-back-to-the-dock-space-filter` +- `volumetric-kiosk-ignores-setting` +- `view-current-space-aria-removed` +- `wall-face-apply-skips-overlap-guard` +- `wallthick-hit-narrowed` +- `writer-history-skips-finished-chain-normalization` +- `zigbee-topology-endpoint-cleanup-skipped` +- `zigbee-topology-endpoint-elevation-removed` +- `zigbee-topology-hovered-endpoint-elevation-removed` +- `zigbee-topology-overlay-double-live-projection` +- `zigbee-topology-overlay-layer-lowered` +- `zigbee-topology-unknown-casing-gaps-filled` +- `zigbee-topology-unknown-casing-removed` +- `zigbee-topology-unrelated-markers-raised` diff --git a/scripts/inventory.mjs b/scripts/inventory.mjs index 2738deb0..19f51ce5 100644 --- a/scripts/inventory.mjs +++ b/scripts/inventory.mjs @@ -8,6 +8,10 @@ import { readdirSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { METRIC_NAMES, collectMetrics, readBaseline } from './monolith-metrics.mjs'; +import { MUTANTS } from './mutation-registry.mjs'; +import { + BROWSER_GUARD_LIMIT, browserGuardPolicy, readDocumentedBrowserGuards, +} from './mutation-browser-policy.mjs'; import { isMainModule } from './spawn-portable.mjs'; const count = (root, dir, match, re) => @@ -39,6 +43,12 @@ if (isMainModule(import.meta.url)) { const w = Math.max(...rows.map(([n]) => n.length)); for (const [name, n] of rows) console.log(`${name.padEnd(w)} ${n}`); + const browser = browserGuardPolicy(MUTANTS, readDocumentedBrowserGuards(process.cwd())); + console.log(`\nmutation browser guards ${browser.count}/${BROWSER_GUARD_LIMIT}`); + if (browser.missingReasons.length || browser.staleReasons.length) { + console.log(`mutation browser inventory drift +${browser.missingReasons.length} / -${browser.staleReasons.length}`); + } + // #624: связность монолита — те же шесть чисел и тот же модуль, что у гейта // `npm run lint:unused` («одно число — один источник»); рядом — база, чтобы // движение было видно без git blame. bundleBytes есть только после сборки. diff --git a/scripts/mutation-browser-policy.mjs b/scripts/mutation-browser-policy.mjs new file mode 100644 index 00000000..b6699baa --- /dev/null +++ b/scripts/mutation-browser-policy.mjs @@ -0,0 +1,30 @@ +// Browser mutation witnesses are the expensive part of the registry. This +// module keeps the cap and the reviewed inventory independent from the CLI so +// inventory, --check and unit tests ask the same question (#659). +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +export const BROWSER_GUARD_LIMIT = 200; +export const BROWSER_GUARD_INVENTORY = 'docs/testing-notes/mutation-browser-guards.md'; + +export const isBrowserGuard = (guard) => guard.includes('demo/') || guard.includes('bundle:sync'); + +export function documentedBrowserGuards(markdown) { + return new Set([...markdown.matchAll(/^- `([^`]+)`$/gm)].map((match) => match[1])); +} + +export function readDocumentedBrowserGuards(root = process.cwd()) { + return documentedBrowserGuards(readFileSync(join(root, BROWSER_GUARD_INVENTORY), 'utf8')); +} + +export function browserGuardPolicy(mutants, documented) { + const browser = mutants.filter((mutant) => isBrowserGuard(mutant.guard)); + const current = new Set(browser.map((mutant) => mutant.id)); + return { + browser, + count: browser.length, + overLimit: browser.length > BROWSER_GUARD_LIMIT, + missingReasons: browser.filter((mutant) => !documented.has(mutant.id)), + staleReasons: [...documented].filter((id) => !current.has(id)), + }; +} diff --git a/scripts/mutation-bundle-cache.mjs b/scripts/mutation-bundle-cache.mjs new file mode 100644 index 00000000..1de4bb59 --- /dev/null +++ b/scripts/mutation-bundle-cache.mjs @@ -0,0 +1,58 @@ +// One clean frontend bundle can serve every browser mutant whose patch does +// not touch a bundle input. Keep this policy outside the execution lifecycle: +// the runner applies it, while tests can judge it without making worktrees. +import { cpSync, existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { fingerprintCorpus } from './source-fingerprint.mjs'; + +const repoRoot = fileURLToPath(new URL('..', import.meta.url)); +const BUNDLE_CORPUS = new Set(fingerprintCorpus(repoRoot)); +const BUNDLE_TREES = ['dist', 'demo/srv/assets']; + +export function guardNeedsBundle(guard) { + return guard.includes('demo/') || guard.includes('bundle:sync'); +} + +export function mutantPatchesNeedBundle(patches, corpus = BUNDLE_CORPUS) { + const bundled = corpus instanceof Set ? corpus : new Set(corpus); + return patches.some((patch) => bundled.has(patch.file.replaceAll('\\', '/'))); +} + +export function mutantBundleStrategy(mutant, { seedAvailable = false, corpus } = {}) { + if (!guardNeedsBundle(mutant.guard)) return 'none'; + if (seedAvailable && !mutantPatchesNeedBundle(mutant.patches, corpus)) return 'seed'; + return 'build'; +} + +export const planNeedsBundleSeed = (mutants) => mutants.some((mutant) => ( + guardNeedsBundle(mutant.guard) && !mutantPatchesNeedBundle(mutant.patches) +)); + +export const makeBundleSeed = () => mkdtempSync(join(tmpdir(), 'hp-mutant-bundle-')); + +export function dropBundleSeed(dir) { + if (dir) rmSync(dir, { recursive: true, force: true }); +} + +export function captureBundleSeed(root, seed) { + for (const name of BUNDLE_TREES) { + const from = join(root, name); + if (!existsSync(from)) throw new Error(`bundle seed source is missing: ${name}`); + const to = join(seed, name); + rmSync(to, { recursive: true, force: true }); + cpSync(from, to, { recursive: true }); + } +} + +export function restoreBundleSeed(seed, root) { + for (const name of BUNDLE_TREES) { + const from = join(seed, name); + if (!existsSync(from)) throw new Error(`shared bundle seed is missing: ${name}`); + const to = join(root, name); + rmSync(to, { recursive: true, force: true }); + cpSync(from, to, { recursive: true }); + } +} diff --git a/scripts/mutation-execution.mjs b/scripts/mutation-execution.mjs index 3c1ecdf1..913a72de 100644 --- a/scripts/mutation-execution.mjs +++ b/scripts/mutation-execution.mjs @@ -12,6 +12,14 @@ import { fileURLToPath } from 'node:url'; import { MUTATION_OUTCOME, MUTATION_PROOF, runGuardPhases, runMutationLifecycle, setupFailureOwner, } from './mutation-guard-outcome.mjs'; +import { + captureBundleSeed, guardNeedsBundle, mutantBundleStrategy, restoreBundleSeed, +} from './mutation-bundle-cache.mjs'; + +export { + dropBundleSeed, guardNeedsBundle, makeBundleSeed, mutantBundleStrategy, + mutantPatchesNeedBundle, planNeedsBundleSeed, +} from './mutation-bundle-cache.mjs'; const repoRoot = fileURLToPath(new URL('..', import.meta.url)); @@ -88,12 +96,6 @@ export function guardNeedsTestBuild(guard) { * самая дорогая часть прогона (255 мутантов × ~15-20 с), поэтому она * выполняется только там, где её результат кто-то откроет. */ -export function guardNeedsBundle(guard) { - // `bundle:sync` в гварде реестр больше не допускает (--check, #499), но - // распознавание остаётся: чужой или старый гвард со сборкой всё равно - // браузерный, и бандл ему нужен. - return guard.includes('demo/') || guard.includes('bundle:sync'); -} /** * Тёплый старт компиляции мутанта (#332): скопировать `test-build/` вместе с @@ -168,14 +170,16 @@ function printMutantOutcome(mutant, outcome) { if (outcome.detail) console.log(` ${outcome.detail}`); } -export function runMutant(mutant, { ref = 'HEAD' } = {}) { +export function runMutant(mutant, { ref = 'HEAD', bundleSeed = null } = {}) { let dir; try { dir = makeWorktree(ref); const outcome = runMutationLifecycle({ apply: () => applyPatches(dir, mutant.patches), prepare: () => { - if (guardNeedsBundle(mutant.guard)) buildBundle(dir); + const bundle = mutantBundleStrategy(mutant, { seedAvailable: Boolean(bundleSeed) }); + if (bundle === 'seed') restoreBundleSeed(bundleSeed, dir); + else if (bundle === 'build') buildBundle(dir); if (guardNeedsTestBuild(mutant.guard)) buildTestBuild(dir); }, guard: mutant.guard, @@ -199,12 +203,15 @@ export function runMutant(mutant, { ref = 'HEAD' } = {}) { // Чистый прогон каждого guard ровно один раз: тест, красный и без мутанта, // «ловит» поломку тривиально и не доказывает ничего. -export function runCleanGuards(mutants) { +export function runCleanGuards(mutants, { bundleSeed = null } = {}) { const guards = [...new Set(mutants.map((m) => m.guard))]; const dir = makeWorktree(); try { try { - if (guards.some(guardNeedsBundle)) buildBundle(dir); + if (guards.some(guardNeedsBundle)) { + buildBundle(dir); + if (bundleSeed) captureBundleSeed(dir, bundleSeed); + } // Один worktree на все чистые гварды — значит и компиляция одна. if (guards.some(guardNeedsTestBuild)) buildTestBuild(dir); } catch (error) { diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index 5927fdb3..34e9e76c 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -10,9 +10,11 @@ import { fileURLToPath } from 'node:url'; import { MUTANTS } from './mutation-registry.mjs'; import { - applyPatches, buildBundle, dropWorktree, guardNeedsBundle, guardNeedsTestBuild, - makeWorktree, runCleanGuards, runMutant, + applyPatches, buildBundle, dropBundleSeed, dropWorktree, guardNeedsBundle, guardNeedsTestBuild, + makeBundleSeed, makeWorktree, mutantBundleStrategy, mutantPatchesNeedBundle, + planNeedsBundleSeed, runCleanGuards, runMutant, } from './mutation-execution.mjs'; +import { checkMutationRegistry } from './mutation-registry-check.mjs'; import { ANCHOR_RADIUS_LINES, MUTATION_REGISTRY_FILES, anchorRegion, anchorSpan, baseRegistry, createGuardInputResolver, guardFiles, guardInputs, @@ -24,9 +26,7 @@ import { } from './mutation-evidence.mjs'; import { attributeSetupFailure } from './mutation-attribution.mjs'; import { guardEnvironment, planEnvironment, planEnvironmentLines } from './mutation-environment.mjs'; -import { - MUTATION_OUTCOME, isProofOutcome, staticTestSelectionProblems, -} from './mutation-guard-outcome.mjs'; +import { MUTATION_OUTCOME, isProofOutcome } from './mutation-guard-outcome.mjs'; const repoRoot = fileURLToPath(new URL('..', import.meta.url)); @@ -36,6 +36,7 @@ export { ANCHOR_RADIUS_LINES, LEDGER_SCHEMA, MUTANTS, MUTATION_REGISTRY_FILES, anchorRegion, anchorSpan, applyPatches, baseRegistry, createGuardInputResolver, guardFiles, guardInputs, guardNeedsBundle, guardNeedsTestBuild, + mutantBundleStrategy, mutantPatchesNeedBundle, packageJsonRelevance, parseDiffRanges, patchTouched, readLedger, recordCaught, registryDelta, selectChangedMutants, selectForDiff, shardMutants, splitByLedger, witnessFingerprint, wrapperInputs, @@ -152,43 +153,9 @@ export async function main(argv) { } if (argv.includes('--check')) { - let stale = 0; - let warned = 0; - const readTest = (file) => { - const path = join(repoRoot, file); - return existsSync(path) ? readFileSync(path, 'utf8') : null; - }; - for (const m of selected) { - try { - // #499: бандл мутанта собирает раннер (`buildBundle`, только rollup + - // sync). Гвард с собственным `npm run bundle:sync` собирал бы его второй - // раз — плюс `tsc --noEmit`, который на нестрогом мутанте падает сам и - // красит гвард ещё до теста: «мутант пойман» без единого запуска смока. - if (/bundle:sync|bundle-sync\.mjs|rollup -c/.test(m.guard)) { - throw new Error('гвард сам собирает бандл — сборку делает раннер (#499)'); - } - for (const patch of m.patches) { - const source = readFileSync(join(repoRoot, patch.file), 'utf8'); - const hits = source.split(patch.find).length - 1; - if (hits !== 1) throw new Error(`якорь найден ${hits} раз(а)`); - } - // #650: a name filter that matches nothing makes the witness vacuous. - const selection = staticTestSelectionProblems(m.guard, readTest); - const error = selection.find((problem) => problem.level === 'error'); - if (error) throw new Error(error.text); - for (const problem of selection) { - console.log(`WARN ${m.id}: ${problem.text}`); - warned++; - } - console.log(`ok ${m.id}`); - } catch (error) { - console.log(`FAIL ${m.id}: ${error.message}`); - stale++; - } - } - if (warned) console.log(`предупреждений о шаблонах имён: ${warned}`); + const result = checkMutationRegistry(selected, { allMutants: MUTANTS, root: repoRoot }); reportPlanMetrics(); - return stale ? 2 : 0; + return result; } if (argv.includes('--build-only')) { @@ -241,7 +208,9 @@ export async function main(argv) { return 0; } reportPlanMetrics(); - if (!runCleanGuards(toRun)) return 2; + const bundleSeed = planNeedsBundleSeed(toRun) ? makeBundleSeed() : null; + try { + if (!runCleanGuards(toRun, { bundleSeed })) return 2; let caught = 0; let unverifiable = false; // #568: свидетель, который не готовится к прогону, ломает гейт той задачи, @@ -252,7 +221,7 @@ export async function main(argv) { // прогона одного и того же мутанта, а не код с ожиданием. const preExisting = []; for (const entry of plan) { - const outcome = runMutant(entry.mutant); + const outcome = runMutant(entry.mutant, { bundleSeed }); if (!isProofOutcome(outcome)) { if (outcome.kind === MUTATION_OUTCOME.SETUP) { const verdict = await attributeSetupFailure(entry.mutant, outcome, rangeBase); @@ -285,6 +254,9 @@ export async function main(argv) { } if (unverifiable) return 2; return caught === toRun.length - preExisting.length ? 0 : 1; + } finally { + dropBundleSeed(bundleSeed); + } } if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { diff --git a/scripts/mutation-registry-check.mjs b/scripts/mutation-registry-check.mjs new file mode 100644 index 00000000..9108fb82 --- /dev/null +++ b/scripts/mutation-registry-check.mjs @@ -0,0 +1,63 @@ +// Cheap structural validation for mutation declarations. Kept out of the CLI +// so adding registry policy does not grow the execution coordinator (#558). +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { + BROWSER_GUARD_LIMIT, browserGuardPolicy, readDocumentedBrowserGuards, +} from './mutation-browser-policy.mjs'; +import { staticTestSelectionProblems } from './mutation-guard-outcome.mjs'; + +export function checkMutationRegistry(selected, { allMutants, root, log = console.log }) { + let stale = 0; + let warned = 0; + const readTest = (file) => { + const path = join(root, file); + return existsSync(path) ? readFileSync(path, 'utf8') : null; + }; + for (const mutant of selected) { + try { + if (/bundle:sync|bundle-sync\.mjs|rollup -c/.test(mutant.guard)) { + throw new Error('гвард сам собирает бандл — сборку делает раннер (#499)'); + } + for (const patch of mutant.patches) { + const source = readFileSync(join(root, patch.file), 'utf8'); + const hits = source.split(patch.find).length - 1; + if (hits !== 1) throw new Error(`якорь найден ${hits} раз(а)`); + } + const selection = staticTestSelectionProblems(mutant.guard, readTest); + const error = selection.find((problem) => problem.level === 'error'); + if (error) throw new Error(error.text); + for (const problem of selection) { + log(`WARN ${mutant.id}: ${problem.text}`); + warned++; + } + log(`ok ${mutant.id}`); + } catch (error) { + log(`FAIL ${mutant.id}: ${error.message}`); + stale++; + } + } + try { + const policy = browserGuardPolicy(allMutants, readDocumentedBrowserGuards(root)); + log(`browser guards: ${policy.count}/${BROWSER_GUARD_LIMIT}`); + if (policy.overLimit) { + log(`FAIL browser guards: лимит ${BROWSER_GUARD_LIMIT} превышен`); + stale++; + } + for (const mutant of policy.missingReasons) { + log(`WARN ${mutant.id}: browser guard не размечен — добавьте причину в ` + + '`docs/testing-notes/mutation-browser-guards.md` и конкретику в `because`'); + warned++; + } + for (const id of policy.staleReasons) { + log(`WARN ${id}: browser-разметка устарела — такого browser guard больше нет`); + warned++; + } + } catch (error) { + log(`FAIL browser guard policy: ${error.message}`); + stale++; + } + if (warned) log(`предупреждений mutation registry: ${warned}`); + return stale ? 2 : 0; +} diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index 4da3a949..c441fe59 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -13058,8 +13058,175 @@ const MUTANT_DEFINITIONS = [ const mutationCardSource = readFileSync(join(repoRoot, 'src/houseplan-card.ts'), 'utf8'); const mutationEditorSource = readFileSync(join(repoRoot, 'src/houseplan-editor-runtime.ts'), 'utf8'); + +// #659: these witnesses exercise pure state/geometry/CSS contracts and do not +// need Chromium. Keep the grouping explicit: every promoted mutant is proven +// by `mutation-gate`, not merely reclassified to avoid a bundle build. +const unitGuard = (...files) => `node --test ${files.join(' ')} test/mutation-browser-offload.test.mjs`; +const UNIT_GUARD_GROUPS = [ + [unitGuard('test/stairs.test.mjs'), [ + 'stairs-fixed-floor-still-navigates', + 'stairs-link-auto-creates-target-object', + 'stairs-broken-targets-become-active', + ]], + [unitGuard('test/device-hit-owner.test.mjs'), [ + 'device-hit-scroll-observer-disabled', + ]], + [unitGuard('test/summary-panel-runtime.test.mjs'), [ + 'summary-hide-unmounts-before-animation', + 'summary-first-paint-shows-unavailable', + 'summary-metrics-block-first-frame', + ]], + [unitGuard('test/form-shell-consumers.test.mjs'), [ + 'discard-confirm-action-icon-falls-back-to-lock', + ]], + [unitGuard('test/form-kit.test.mjs'), [ + 'range-line-clamps-every-keystroke', + ]], + [unitGuard('test/dialog-baseline.test.mjs', 'test/styles-split.test.mjs'), [ + 'ha-form-shell-width-falls-back-to-generic', + 'ha-form-shell-loses-canvas', + 'ha-form-shell-mobile-keeps-desktop-inset', + ]], + [unitGuard('test/summary-panel.test.mjs'), [ + 'summary-dialog-loses-wide-shell', + 'summary-mobile-ignores-local-off', + ]], + [unitGuard('test/styles-split.test.mjs'), [ + 'marker-shadow-animates-again', + ]], + [unitGuard('test/device-inbox.test.mjs'), [ + 'device-inbox-batch-single-write', + ]], + [unitGuard('test/houseplan-panel.test.mjs'), [ + 'panel-ignores-pre-upgrade-properties', + 'panel-host-height-from-parent', + ]], + [unitGuard('test/version-recovery-card.test.mjs'), [ + 'version-recovery-delays-config-capability-adoption', + ]], + [unitGuard('test/vacuum-calibration-write.test.mjs'), [ + 'vacuum-manual-fit-after-proposal-uses-the-dock', + ]], + [unitGuard('test/devices.test.mjs'), [ + 'room-climate-ignores-marker-placement', + ]], + [unitGuard('test/device-inbox.test.mjs'), [ + 'device-tombstone-blocks-child-picker', + ]], + [unitGuard('test/room-fit.test.mjs'), [ + 'room-fit-interactive-owner-leaks-through', + ]], + [unitGuard('test/iso-stage6.test.mjs', 'test/styles-split.test.mjs'), [ + 'iso-tile-ring-visible', + 'iso-tile-edge-zero', + 'iso-tile-badge-gap-dropped', + 'iso-tile-shadow-layer-above', + 'iso-frame-selected-beats-focus', + 'iso-shadow-in-forced-colors', + 'iso-sun-length-ignores-elevation', + 'iso-sun-streaks-on-dark-floor', + 'iso-wall-top-constant', + ]], + [unitGuard('test/backdrop-probe.test.mjs'), [ + 'backdrop-hard-demoted-to-warn', + ]], + [unitGuard('test/bundle-assets.test.mjs'), [ + 'css-minifier-eats-required-space', + ]], + [unitGuard('test/wall-thickness.test.mjs', 'test/wall-union-isolation.test.mjs'), [ + 'wall-component-failure-kills-primary', + ]], + [unitGuard('test/glow-scene.test.mjs'), [ + 'union-failure-silent', + 'glow-fail-dark-weakened', + 'opening-cut-degenerate', + 'column-shadow-removed', + 'feather-20px', + ]], + [unitGuard('test/visual-continuity.test.mjs'), [ + 'continuity-long-resume-noop', + ]], + [unitGuard('test/ha-binding-status.test.mjs'), [ + 'registryless-opening-requires-registry-row', + ]], + [unitGuard('test/header-menu.test.mjs'), [ + 'header-menu-item-keeps-menu-open', + 'header-menu-escape-ignored', + 'active-tab-not-revealed', + ]], + [unitGuard('test/iso-scene-render.test.mjs', 'test/iso-stage6.test.mjs'), [ + 'grid-scale-iso-height-unscaled', + 'stage3-w8-material-defs-created-per-face', + ]], + [unitGuard('test/support-feedback.test.mjs'), [ + 'support-edited-retry-reuses-old-idempotency-key', + ]], + [unitGuard('test/touch-gesture-click-guard.test.mjs'), [ + 'touch-pinch-contextmenu-guard-removed', + ]], + [unitGuard('test/live-editor.test.mjs'), [ + 'live-editor-devices-drops-align-guides', + 'live-editor-decor-drops-align-guides', + 'live-editor-plan-drops-align-guides', + 'live-editor-keeps-the-settled-guides-visible', + ]], + [unitGuard('test/live-viewport.test.mjs'), [ + 'live-pan-incoming-edge-clipped', + 'live-pan-rewrites-viewbox-every-frame', + ]], + [unitGuard('test/summary-runtime-loader.test.mjs'), [ + 'summary-runtime-attaches-after-first-render', + ]], + [unitGuard('test/config-reload-authority.test.mjs'), [ + 'config-reload-drops-asset-gap-ownership', + ]], + [unitGuard('test/summary-panel.test.mjs'), [ + 'summary-picker-renders-unbounded-results', + ]], + [unitGuard('test/form-shell-consumers.test.mjs', 'test/dialog-baseline.test.mjs', + 'test/space-dialog.test.mjs'), [ + 'same-binding-click-resets-source', + 'state-callout-hidden-under-help', + 'dialog-card-loses-its-heading', + 'form-kit-writes-to-a-neighbour-key', + 'space-fill-segment-writes-a-neighbour-key', + 'space-save-enabled-without-changes', + 'space-discard-without-asking', + 'space-layer-toggle-loses-inversion', + 'general-save-enabled-without-changes', + 'general-tile-opacity-writes-a-neighbour-key', + 'general-north-clear-writes-zero', + 'topology-embedded-draws-its-own-heading', + 'room-save-enabled-without-changes', + 'room-discard-without-asking', + 'room-fill-segment-starts-at-none', + 'room-temp-min-writes-max', + 'marker-save-enabled-without-changes', + 'marker-save-click-forgets-baseline-before-result', + 'marker-discard-without-asking', + 'marker-confirm-row-shown-for-do-nothing', + 'marker-never-keeps-glow-block-live', + 'marker-badge-position-forgets-touch', + ]], + [unitGuard('test/color-picker.test.mjs'), [ + 'M-615-tile', + 'M-615-plate', + ]], + [unitGuard('test/form-kit.test.mjs', 'test/styles-split.test.mjs'), [ + 'form-kit-segment-breaks-words', + ]], + [unitGuard('test/room-gear-drag.test.mjs'), [ + 'room-gear-drag-reopens-settings', + ]], +]; +const UNIT_GUARD_OVERRIDES = new Map(UNIT_GUARD_GROUPS.flatMap(([guard, ids]) => ( + ids.map((id) => [id, guard]) +))); + export const MUTANTS = MUTANT_DEFINITIONS.map((mutant) => ({ ...mutant, + guard: UNIT_GUARD_OVERRIDES.get(mutant.id) ?? mutant.guard, patches: mutant.patches.map((patch) => relocateEditorPatch( patch, mutationCardSource, mutationEditorSource, )), diff --git a/test/mutation-browser-offload.test.mjs b/test/mutation-browser-offload.test.mjs new file mode 100644 index 00000000..50221128 --- /dev/null +++ b/test/mutation-browser-offload.test.mjs @@ -0,0 +1,152 @@ +// #659: browser mutation witnesses are expensive. These contracts cover the +// state, generated SVG/CSS and handler wiring that does not need a browser. +// Behavioural unit suites still run beside this file; the source assertions +// protect the wiring that cannot be imported without instantiating Lit/HA. +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +const source = (file) => readFileSync(new URL(`../${file}`, import.meta.url), 'utf8'); +const contains = (file, ...parts) => { + const text = source(file); + for (const part of parts) assert.ok(text.includes(part), `${file}: missing ${JSON.stringify(part)}`); + return text; +}; + +test('#659 pure state writers keep one-way ownership and current request claims', () => { + const stairs = contains('src/stairs-editor.ts', + 'this.write(this.stairs.map((item) => item.id === next.id ? next : item));', + "this.owner._recordGeometry(this.owner._t('history.stair_edit'), before);", + ); + assert.doesNotMatch(stairs, /-mirror|_serverCfg\.spaces\.find/); + contains('src/version-recovery-card.ts', + '(response) => adoptCardConfigCapabilities(host, response),'); + contains('src/vacuum-calibration-write.ts', 'const space = proposal.space || device.space;'); + contains('src/device-inbox.ts', + 'if (isRemovedPlanEntity(h, eid, removed) && !removedBindings.has(value) && !childOfRemovedDevice) continue;'); + contains('src/config-reload-authority.ts', "profile: 'reload',\n isCurrent,\n afterAdopt:"); +}); + +test('#659 summary lifecycle keeps outgoing content mounted and first paint non-blocking', () => { + const runtime = contains('src/summary-panel-runtime-loaded.ts', + 'if (!this.presentation.mounted) return nothing;', + ); + assert.doesNotMatch(runtime, /!this\.presentation\.mounted \|\| !this\.local\.show/); +}); + +test('#659 common dialogs and keyboard markers keep their source-level wiring', () => { + contains('src/hp-confirm.ts', + " undefined,'); + contains('src/hp-dialog.ts', + '--ha-dialog-width-md: 560px;', + '--ha-dialog-min-height: var(--safe-height, 100dvh);', + '--ha-dialog-max-height: var(--safe-height, 100dvh);', + ); + contains('src/styles/dialogs.styles.ts', + 'background: var(--hpf-canvas, var(--secondary-background-color, color-mix(in srgb, var(--card-background-color, var(--hp-bg, #202126)) 90%, var(--primary-text-color, #000))));', + ); + contains('src/styles/devices.styles.ts', + 'transition: border-color .15s, opacity .2s;', + ); + contains('src/summary-panel-editor.ts', + ".title=${t('summary.settings')} wide", + '.checked=${dialog.draft.show_on_mobile} ?disabled=${!dialog.localShow || dialog.busy}', + ); + contains('src/device-inbox-batch.ts', + ' try {\n await deps.saveConfigNow(); // #618 B5: one write per batch\n refreshRows();', + ); +}); + +test('#659 2.5D CSS and generated material contracts stay unit-testable strings', () => { + contains('src/styles/iso-tiles.styles.ts', + '.stage.projection-iso.mode-view .dev .device-shell-frame {\n border-color: transparent;', + 'pointer-events: none;\n z-index: -1;', + '.dev.sel:not(:focus-visible) { --iso-frame: #F0A00C; }', + '.stage.projection-iso.mode-view .iso-tile-shadows { display: none; }', + '@media (forced-colors: active) {\n .stage.projection-iso.mode-view .iso-tile-shadows { display: none; }', + ); + contains('src/iso-tiles.ts', '${S} .dev .device-shell.with-values { gap:'); + contains('src/iso-sun.ts', + 'const streaks = beam.lightFloor ? [inset / length, 1 - inset / length] : [];'); + contains('src/iso-scene-render.ts', + 'const wallHeight = gridVisualUnits(ISO_WALL_HEIGHT, input.cellCm);\n' + + ' const floorEdgeHeight = gridVisualUnits(ISO_FLOOR_EDGE_HEIGHT, input.cellCm);\n' + + ' const raisedHeight = gridVisualUnits(ISO_RAISED_OVERLAY_HEIGHT, input.cellCm);\n' + + ' const cached = lruRead(input.cache, input.source.key);', + 'return svg` { + contains('src/glow-scene.ts', + ' console.warn(\n' + + ' `HOUSEPLAN GLOW GEOMETRY FALLBACK: #218, space ${spaceId}, room ${roomId}, phase ${phase}`,\n' + + ' );', + 'return pointInOpaquePlanBody(\n [source.x, source.y], scene.masonryGeometry, scene.opaqueBodies,', + 'opening.x - dx, opening.y - dy,\n opening.x + dx, opening.y + dy,', + 'const opaqueBodies = input.physicalBodies(partitionCuts, cacheKey);', + 'export const GLOW_EDGE_FEATHER_PX = 2;', + ); +}); + +test('#659 header menu state transitions are guarded without rendering a browser menu', () => { + contains('src/header-menu.ts', + 'const run = (item: HeaderMenuItem) => { this.close(false); item.run(); };', + "if (event.key !== 'Escape' || !this.open) return;", + 'if (!nav || !tab || nav.clientWidth === 0) return;', + ); +}); + +test('#659 live editor templates own guides in every editor and hide settled copies', () => { + contains('src/live-editor.ts', + '${host._renderAlignGuides()}\n `;', + '${host._renderDecorLayer(activeId)}\n ${host._renderAlignGuides()}', + '${host._renderAlignGuides()}\n ${host._tool === \'draw\' ? nothing : host._renderPlanSnapOverlay()}', + "makeTransparent(state, root, '.hp-editor-only-layer:not(.hp-plan-snap-layer)');\n if (host._mode === 'plan') {", + ); +}); + +test('#659 settings handlers preserve dirty, discard, inversion and field ownership', () => { + contains('src/editors/space-form.ts', + 'onChange: (v) => set(port, { ...d, fillMode: v }),', + 'const canSave = dirty && problems.length === 0 && !d.busy;', + 'if (!dirty || d.busy) { close(); return; }', + 'checked: !d.hideDecor, onChange: (v) => set(port, { ...d, hideDecor: !v }),', + ); + contains('src/editors/general-settings-dialog.ts', + "? callout({ kind: 'warning', role: 'status', text: t('gs.sun_missing') })", + "title: t('gs.card_fills'),", + 'const canSave = dirty && problems.length === 0 && !d.busy;', + 'onOpacity: (a) => this._setFillColor(key, { c: v.c, a }),', + "textLink(t('gs.north_clear'), () => set({ northDeg: null, northDegInput: '' }))", + ); + contains('src/hp-zigbee-topology-settings.ts', + 'if (this.embedded) return this._renderEmbedded();'); + contains('src/editors/room-settings-dialog.ts', + "if (kind === 'temp') host._roomTempSrc = v;\n else host._roomHumSrc = v;", + 'const canSave = problems.length === 0 && tempValid && (edit ? dirty : true);', + 'if (!edit || !dirty) { close(); return; }', + "onChange: (v) => setFill(v ? '' : startMode()),", + 'onInput: (raw) => { host._roomTempMin = raw; host.requestUpdate(); },', + ); +}); + +test('#659 marker handlers preserve callouts, baselines and explicit user choices', () => { + contains('src/editors/marker-dialog.ts', + 'if (value === d.binding) {', + "? callout({ kind: 'warning', role: 'status', text: t('marker.run_target_gone', { id: d.tapTarget }) })", + '&& (edit ? dirty : true);', + '@click=${() => { void this._saveMarker(); }}', + 'if (!edit || !dirty) { forgetMarkerBaseline(this.host); this._closeMarkerDialog(); return; }', + "${effectiveTapAction === 'run' || effectiveTapAction === 'toggle'", + "markerglowblock ${glowSourceDisabled ? 'hpf-disabled' : ''}", + 'valueBadgePosition: position,\n valueBadgeTouched: true,', + ); +}); + +test('#659 room gear drag keeps the click suppression window after a real move', () => { + contains('src/room-gear-drag.ts', + 'this.suppressClickUntil = performance.now() + 700;'); +}); diff --git a/test/mutation-gate.test.mjs b/test/mutation-gate.test.mjs index 3ab3d85d..7649e207 100644 --- a/test/mutation-gate.test.mjs +++ b/test/mutation-gate.test.mjs @@ -7,11 +7,15 @@ import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { - MUTANTS, applyPatches, guardNeedsBundle, guardNeedsTestBuild, selectChangedMutants, shardMutants, guardFiles, packageJsonRelevance, + MUTANTS, applyPatches, guardNeedsBundle, guardNeedsTestBuild, mutantBundleStrategy, + mutantPatchesNeedBundle, selectChangedMutants, shardMutants, guardFiles, packageJsonRelevance, anchorSpan, anchorRegion, parseDiffRanges, ANCHOR_RADIUS_LINES, witnessFingerprint, readLedger, recordCaught, splitByLedger, LEDGER_SCHEMA, } from '../scripts/mutation-gate.mjs'; import { guardPhases } from '../scripts/mutation-guard-outcome.mjs'; +import { + BROWSER_GUARD_LIMIT, browserGuardPolicy, documentedBrowserGuards, +} from '../scripts/mutation-browser-policy.mjs'; const repoRoot = fileURLToPath(new URL('..', import.meta.url)); @@ -202,6 +206,26 @@ test('#332: каждый гвард реестра классифицирует assert.ok(rest >= 150, `небраузерных гвардов подозрительно мало: ${rest}`); }); +test('#659: browser guard inventory is reviewed, capped and exact', () => { + const markdown = readFileSync(join(repoRoot, 'docs/testing-notes/mutation-browser-guards.md'), 'utf8'); + const policy = browserGuardPolicy(MUTANTS, documentedBrowserGuards(markdown)); + assert.equal(policy.count, BROWSER_GUARD_LIMIT); + assert.deepEqual(policy.missingReasons, []); + assert.deepEqual(policy.staleReasons, []); +}); + +test('#659: browser-only mutations reuse one clean bundle unless their patch is bundled', () => { + const corpus = new Set(['src/card.ts', 'scripts/source-fingerprint.mjs']); + const smoke = (file) => ({ guard: 'node demo/smoke_x.mjs', patches: [{ file }] }); + assert.equal(mutantPatchesNeedBundle(smoke('src/card.ts').patches, corpus), true); + assert.equal(mutantPatchesNeedBundle(smoke('scripts/check.mjs').patches, corpus), false); + assert.equal(mutantBundleStrategy(smoke('scripts/check.mjs'), { seedAvailable: true, corpus }), 'seed'); + assert.equal(mutantBundleStrategy(smoke('tests_backend/test_x.py'), { seedAvailable: true, corpus }), 'seed'); + assert.equal(mutantBundleStrategy(smoke('src/card.ts'), { seedAvailable: true, corpus }), 'build'); + assert.equal(mutantBundleStrategy({ guard: 'node --test test/x.test.mjs', patches: [] }, + { seedAvailable: true, corpus }), 'none'); +}); + // --- #332: дифф-режим и шарды --- test('#332: selectChangedMutants берёт мутанта при любом задетом patch.file', () => { @@ -717,7 +741,7 @@ test('#499: ни один гвард реестра не собирает бан const guard = 'npm run bundle:sync && node demo/smoke_furniture.mjs'; assert.ok(/bundle:sync|bundle-sync\.mjs|rollup -c/.test(guard)); assert.equal(guardNeedsBundle('node demo/smoke_furniture.mjs'), true, 'без префикса гвард остаётся браузерным'); - const source = readFileSync(new URL('../scripts/mutation-gate.mjs', import.meta.url), 'utf8'); + const source = readFileSync(new URL('../scripts/mutation-registry-check.mjs', import.meta.url), 'utf8'); assert.match(source, /гвард сам собирает бандл — сборку делает раннер \(#499\)/); }); @@ -1000,7 +1024,7 @@ test('#620 (реестр): каждый смок-гард получает бр test('#620: --plan-only печатает окружение плана', () => { const script = join(repoRoot, 'scripts/mutation-gate.mjs'); const plan = (id) => spawnSync(process.execPath, [script, `--id=${id}`, '--plan-only'], { encoding: 'utf8' }); - const smoke = plan('discard-confirm-action-icon-falls-back-to-lock'); + const smoke = plan('dense-device-hit-browser-skips-painted-priority'); assert.equal(smoke.status, 0, smoke.stderr); assert.match(smoke.stdout, /^plan=1$/m); assert.match(smoke.stdout, /^plan-browser=true$/m);