test(mutation): сократить browser guards реестра (#659)

Issue: #659
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-09-27 17:08:06 +03:00
parent b9aa2b54b1
commit 718afca221
12 changed files with 810 additions and 56 deletions
+9
View File
@@ -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 нашёлся
+10
View File
@@ -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)
@@ -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`
+10
View File
@@ -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 есть только после сборки.
+30
View File
@@ -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)),
};
}
+58
View File
@@ -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 });
}
}
+17 -10
View File
@@ -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) {
+15 -43
View File
@@ -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]) {
+63
View File
@@ -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;
}
+167
View File
@@ -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,
)),
+152
View File
@@ -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',
"<ha-icon icon=${request.confirmIcon || (destructive",
);
contains('src/editors/form-kit.ts', 'onInput: () => 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`<path class="iso-wall-top" d=${face.d} data-component=${face.component}');
contains('src/hp-color-opacity.ts', 'return this.flatSwatch && !this.coverSwatch;');
});
test('#659 Glow fallbacks, blockers and feather remain explicit pure contracts', () => {
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 </g>`;',
'${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;');
});
+27 -3
View File
@@ -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);