Files
houseplan-card/test/smoke-harness-contract.test.mjs
Claudeandclaude[bot] 9edef041a9 fix: openings and markers keep their identity across a space switch (#525)
Lit reuses list nodes by position. The opening list and the device markers had
no keys, so on a space switch the leaf that held a slot kept its DOM node and
only changed values — and `.op-leaf` (transform) and `.op-arc`
(stroke-dashoffset) carry a 0.6 s transition, so the browser animated a door
that never moved: the new floor's leaf drove in from the previous floor's
opening angle. Measured on two spaces with a door in the same place and
opposite contact states: the node is reused, transform goes
`rotate(-90deg) → rotate(0deg)`, dash offset `0 → 125.66`, both transitions
`running`. The marker shell adds two more with its `box-shadow`.

Both lists are now rendered through `repeat(…, (item) => item.id, …)`, the
same lesson `glow-scene.ts` already learned for the Glow spots. The trap is
written where it starts — above the two transitions in `plan.styles.ts` —
because that is the file someone edits when adding the next animated property.
`houseplan-card.ts` is at its line ceiling, and the note would have cost the
budget a dozen lines for nothing: the swap itself is line-for-line.

The witness walks the shadow tree per element. `document.getAnimations()` is
empty here EVEN ON THE BROKEN CODE — the card lives in a shadow root and the
document-level call does not reach into it, and the issue proposed exactly
that call. The smoke also builds its own fixture: the demo home has no
openings at all, so two doors in two spaces are prepared in the smoke, and it
asserts the other half of the contract as well — a real contact change inside
one space still animates the leaf.

On `origin/dev` the smoke fails on five facts, naming the offenders:
`op-arc:stroke-dashoffset`, `op-leaf:transform`, `device-shell-frame:box-shadow`
twice. Mutants `openings-rendered-without-keys` and
`device-markers-rendered-without-keys` put each `map` back.

Perf, 7 samples against `19e421b3`: spaceSwitchMs 524.8 (limit 769.35, base
512.9), switchCycleMs 1293.9 (1696.28, 1256.5), firstStableRenderMs 2533.8
(3000, 2529.2), modelReadyMs 732.7 (944.97, 726.9), longTask.maxSingleMs 663
(910, 660) — `benchmark:compare` green in full.

The initial View graph grows 241 B gzip: `repeat` enters it for the first
time. The #438 ceiling is recentred 300 400 → 300 700 with the usual dated
note; measured 300 059 B keeps 641 B above and 1 359 B below the band. The
301 066 B budget is untouched, but only 366 B now separate the ceiling from
it — the #367 headroom debt has stopped being theoretical.

Issue: #525
User-Visible: yes
2026-09-11 01:42:46 +00:00

168 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
// #407. Счётчик исключений внутри карточки живёт в demo/serve.mjs, и до этой
// задачи его читала одна функция — finish(). Шесть смоков её не вызывали вовсе:
// у каждого была своя развязка, и ни одна не спрашивала про исключения. То есть
// необработанное исключение во время этих шести проходило незамеченным всегда —
// в лог печаталось `EXC`, а прогон оставался зелёным.
//
// Проверка статическая и грубая: она не доказывает, что вердикт вынесен
// правильно, а доказывает, что его вообще запрашивают. Этого достаточно, потому
// что пропуск здесь молчаливый — а молчаливый пропуск и есть то, что стоит
// ловить механизмом.
const DEMO = fileURLToPath(new URL('../demo/', import.meta.url));
const smokes = () => readdirSync(DEMO)
.filter((name) => name.startsWith('smoke_') && name.endsWith('.mjs'));
const read = (name) => readFileSync(new URL(name, `file://${DEMO}`), 'utf8');
const pageBenchmarkIsGuarded = (text) => (
/watchPage\(await [\s\S]*?\.newPage\(\)\)/.test(text)
&& /if \(await reportPageErrors\(\)\)\s*\{?[\s\S]*?process\.exit\(1\)/.test(text)
);
test('каждый смок спрашивает вердикт по исключениям в карточке (#407)', () => {
const silent = smokes().filter((name) => {
const text = read(name);
return !/\bfinish\s*\(/.test(text) && !/\breportPageErrors\s*\(/.test(text);
});
assert.deepEqual(silent, [],
'смок обязан либо вызвать finish(), либо спросить reportPageErrors():'
+ ' иначе исключение внутри карточки во время него не увидит никто,'
+ ' а `EXC` в логе прогон зелёным быть не мешает (#407)');
});
test('вердикт по исключениям запрашивается после прогона, а не до (#407)', () => {
// Вызов раньше самих действий бессмысленен: счётчик к тому моменту нулевой.
// Проверяется дешёвым признаком — вердикт не может быть первым обращением к
// странице в файле.
for (const name of smokes()) {
const text = read(name);
const verdict = Math.max(text.search(/\breportPageErrors\s*\(/), text.search(/\bawait finish\s*\(/));
if (verdict < 0) continue;
const firstAction = text.search(/\b(page|card)\.|\blaunch\s*\(/);
if (firstAction < 0) continue;
assert.ok(verdict > firstAction,
`${name}: вердикт по исключениям запрашивается прежде, чем что-то произошло`);
}
});
test('вердикт не только выставляет код, но и останавливает смок (#407)', () => {
// Первый заход этой задачи выставлял только process.exitCode, и отрицательный
// прогон напечатал «FAILED: 1 uncaught exception» и следом «OK deep-link: …».
// Код возврата был верным, а вывод — противоречивым; читают же вывод.
for (const name of smokes()) {
const text = read(name);
if (!/\breportPageErrors\s*\(/.test(text)) continue;
assert.match(text, /if \(await reportPageErrors\(\)\)\s*(process\.exit\(1\)|throw )/,
`${name}: вердикт по исключениям обязан останавливать смок, а не только`
+ ' помечать его — иначе после «FAILED» печатается строка успеха');
}
});
test('serve.mjs остаётся единственным владельцем счётчика (#407)', () => {
// Смок, который завёл бы свой счётчик и свою проверку, вернул бы ровно ту
// ситуацию, которую задача чинит: у smoke_entry_stale собственный pageErrors
// существует законно (он и есть предмет проверки), но вердикт всё равно
// выносит finish().
const serve = readFileSync(new URL('../demo/serve.mjs', import.meta.url), 'utf8');
assert.match(serve, /export async function reportPageErrors\(\)/,
'вердикт стал асинхронным в #404: он ждёт доставки событий страницей');
assert.equal((serve.match(/_pageErrors\+\+/g) || []).length, 1,
'счётчик инкрементируется в одном месте — внутри watchPage (#404)');
const entryStale = read('smoke_entry_stale.mjs');
assert.match(entryStale, /await finish\(/, 'smoke_entry_stale обязан выносить вердикт');
});
test('#421 dedicated guard probe reaches reportPageErrors without finish', () => {
const probe = readFileSync(
new URL('../demo/guard/guard_report_page_errors.mjs', import.meta.url), 'utf8',
);
assert.match(probe, /await reportPageErrors\(\)/,
'отрицательная проба обязана пройти через отдельный verdict path');
assert.doesNotMatch(probe, /\bawait\s+finish\s*\(/,
'finish() замаскирует сломанный round-trip внутри reportPageErrors()');
});
test('#423 каждый page-benchmark обязан иметь вердикт и динамическую пробу (#430)', () => {
// Что здесь проверяется, а что — нет.
//
// Этот тест — ОБНАРУЖЕНИЕ: он находит benchmark, открывающий страницу
// Playwright, и требует от него формы гарда и наличия отрицательной пробы.
// Поведение гарда в рантайме он не доказывает и не может: браузера в job
// «Фронтенд» нет. Доказывает запуск — `demo/guard/verify-guard.mjs`.
//
// До #430 здесь стояли две «отрицательные проверки»: результат
// `pageBenchmarkIsGuarded(source.replace('watchPage(', '('))` обязан быть
// false. Но сама функция буквально ищет подстроку `watchPage(` — вырезав её,
// мы спрашивали регулярку, находит ли она то, что мы только что удалили.
// Доказано было, что регулярка не пуста. Ровно тот вид проверки, против
// которого заведён мутационный гейт, и он же — единственная находка аудита
// v1.71.0-beta.1, где тест был циклическим, а не просто слабым.
const benchmarks = readdirSync(DEMO)
.filter((name) => name.startsWith('benchmark_') && name.endsWith('.mjs'))
.filter((name) => /\.newPage\(/.test(read(name)));
assert.deepEqual(benchmarks, ['benchmark_backdrop_decode.mjs']);
const verifier = readFileSync(
new URL('../demo/guard/verify-guard.mjs', import.meta.url), 'utf8',
);
for (const name of benchmarks) {
const source = read(name);
assert.equal(pageBenchmarkIsGuarded(source), true, `${name}: missing pageerror guard`);
assert.match(source, /process\.argv\.includes\('--guard-probe'\)/,
`${name}: нужен режим отрицательной пробы — без него гард не проверить запуском`);
assert.match(source, /setTimeout\(\(\) => \{ throw new Error\(/,
`${name}: проба обязана бросать исключение ВНУТРИ страницы, в хвосте замера`);
assert.ok(
new RegExp(`file: '\\.\\./${name}',\\s*\n\\s*args: \\['--guard-probe'\\]`).test(verifier),
`${name}: нет записи в demo/guard/verify-guard.mjs — проба существует и не вызывается,`
+ ' а это ровно то состояние, в котором #423 прожил до #430',
);
}
});
// #521. Свидетель направляющих выравнивания был зелёным на сломанном коде,
// потому что фабриковал состояние жеста: `c._deviceDrag = {…, moved: true}` и
// `c._decorDraft = {…}` присваивались целиком. Присвоение с `oldValue == null`
// живой путь не включает, поэтому проверялась осевая отрисовка — то есть путь,
// которого при настоящем жесте не бывает. Проверка статическая и узкая: она
// стоит на одном файле и на двух именах, зато закрывает ровно тот способ
// обмануть её, который однажды уже сработал.
test('свидетель направляющих ведёт жест, а не подставляет его состояние (#521)', () => {
const source = read('smoke_align_guides.mjs');
for (const state of ['_deviceDrag', '_decorDraft']) {
assert.doesNotMatch(source, new RegExp(`\\b${state}\\s*=[^=]`),
`smoke_align_guides.mjs: ${state} присваивается напрямую — жест обязан`
+ ' создавать его сам, иначе смок снова проверит осевой путь (#521)');
}
assert.match(source, /dispatchEvent\(pe\('pointerdown'/,
'жест начинается настоящим pointerdown по элементу');
assert.match(source, /dispatchEvent\(pe\('pointermove'/,
'жест продолжается настоящими pointermove');
// Тишина перед жестом и нулевой счёт осевых кадров — часть свидетеля, а не
// украшение: без них сломанный код зеленеет на кадрах оседания `_hdrH`.
assert.match(source, /const quiet = async \(\) =>/);
assert.match(source, /ZeroSettledRenders/);
});
// #525. Карточка живёт в shadow root, и `document.getAnimations()` в неё не
// заходит: на сломанном коде документный вызов возвращал пустой список, пока
// створка двери честно доигрывала чужой переход. Свидетель обязан считать
// поэлементно, обходя вложенные теневые деревья, — иначе он зелен всегда.
test('свидетель переходов при переключении меряет поэлементно (#525)', () => {
const source = read('smoke_space_switch_transitions.mjs');
// Ловушка названа в шапке смока словами — упоминание в комментарии это не
// вызов; проверяется код, а не текст о нём.
const code = source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
assert.doesNotMatch(code, /document\.getAnimations\(\)/,
'smoke_space_switch_transitions.mjs: документный getAnimations() пуст даже на'
+ ' сломанном коде — считать надо по узлам внутри renderRoot (#525)');
assert.match(code, /element\.getAnimations\(\)/);
assert.match(code, /element\.shadowRoot\) walk\(element\.shadowRoot\)/,
'обход обязан заходить во вложенные shadow root');
assert.match(code, /_pickSpace/, 'переключение делается штатным путём');
});