mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 12:49:56 +00:00
Аудит v1.71.0-beta.1 (§3.2 M1/M2/M3/M6/M9) прогнал по мутанту на каждый контракт: пять снятий защиты не покраснили ни один тест. Восьмой подряд случай проверок, не умеющих падать, и первый — в бете, закрывавшей #421, задачу ровно об этом. Каждый свидетель ниже проверен отрицательным прогоном: мутант краснит ровно свой тест и не задевает остальные. 1. Полное декодирование растра. `test_supported_raster_headers_and_full_decode` спрашивал w/h/mime — их даёт header-парсер; обрезка `PNG_1X1[:33]` теряет IEND и отбивается там же. No-op блока Pillow оставлял 35 pass. Новый свидетель: PNG с верными сигнатурой, IHDR, IEND, длинами и CRC, но с текстом вместо zlib-потока в IDAT. Оба прежних теста переименованы — их имена обещали то, чего они не проверяли. Пропуск без Pillow остался (validate_asset глотает ImportError осознанно), но в окружении с Home Assistant отсутствие Pillow теперь красное само по себе — иначе свидетель молча скипался бы в каноне. 2. Канонизация SVG. `ValidatedAsset(canonical,…)` → `ValidatedAsset(data,…)` не краснило ничего: все тесты смотрели w/h/mime, ни один — байты. Свидетель сверяет байты целиком: пролог и комментарий не переживают канонизацию, пустой элемент сжимается. Вторая половина — `_check_size(canonical)`. Экранирование `>` в тексте раздувает документ вчетверо: загрузка 1.84 МиБ канонизуется в 7.35 МиБ и до сих пор проходила входной контроль. Свидетель — ровно такая. 3. Гард внешних URL. Все три «внешних» кейса корпуса ловились другими правилами (тег не из словаря, атрибут не из словаря, ветвь href), и `if False:` не краснило ничего. Свидетель: разрешённый тег, разрешённый атрибут, пять токенов — сработать может только сам гард, сообщение это подтверждает. 4. Проекция декора. Единственный кейс задавал flip_h и opacity: 2 → 1; ожидание неотличимо от «opacity игнорируется», а flip_v не проверялся вовсе. Свидетели: четыре комбинации отражений, opacity 0.4 и 0 против заглушки, форма asset_id с согласованным url (прежняя строка ловилась сравнением url, поэтому регулярку id можно было удалить незаметно). 5. Гард benchmark из #423. Доказательство было циклическим: тест вырезал из текста подстроку и спрашивал регулярку, находит ли она её, — а регулярка искала именно её. Динамический режим `--guard-probe` существовал с #423 и не вызывался ни одним прогоном. Теперь он в `demo/guard/verify-guard.mjs` (умеет аргументы и файл выше каталога), а тест из обнаружения требует у нового page-benchmark режим пробы и запись в верификаторе. Мутантов в реестре стало восемь новых: четыре бэкендных, три юнитных, один на пробах гарда. У #423 их было ноль — единственная задача с циклическим тестом и она же единственная без мутантов. Гейты: typecheck зелёный; npm test 1790 tests, 1789 pass, 0 fail; pytest без HA 310 passed, 2 skipped; npm run build зелёный, dist не изменился (продуктовый код не тронут); mutation-gate --check применяет все восемь якорей. Полный прогон новых мутантов — следующим шагом. Issue: #430 User-Visible: no
135 lines
8.9 KiB
JavaScript
135 lines
8.9 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
// #404. Гард «uncaught exception внутри карточки» существовал с 2026-07-27 и не
|
|
// срабатывал ни разу: счётчик читался синхронно, а Playwright доставляет
|
|
// pageerror асинхронно. Поведение доказывается запуском проб —
|
|
// demo/guard/verify-guard.mjs в job со браузером. Здесь закреплено то, что
|
|
// запуском не проверить: порядок операций в исходнике, место проб и наличие
|
|
// вызова там, где он должен быть.
|
|
|
|
const ROOT = fileURLToPath(new URL('../', import.meta.url));
|
|
const read = (rel) => readFileSync(new URL(rel, `file://${ROOT}`), 'utf8');
|
|
|
|
test('счётчик читается ПОСЛЕ доставки событий, а не до (#404)', () => {
|
|
const serve = read('demo/serve.mjs');
|
|
const flush = serve.indexOf('await roundTripLivePages();\n if (_pageErrors)');
|
|
assert.ok(flush > 0,
|
|
'round-trip обязан стоять непосредственно перед чтением счётчика: обратный'
|
|
+ ' порядок и был дефектом #404');
|
|
// Вторая половина правки: опрашивать нечего, если страницы не регистрируются.
|
|
assert.match(serve, /const _livePages = new Set\(\)/);
|
|
assert.match(serve, /export function watchPage\(page\)/);
|
|
assert.match(serve, /_livePages\.add\(page\)/);
|
|
assert.match(serve, /page\.on\('close', \(\) => _livePages\.delete\(page\)\)/,
|
|
'закрытая страница обязана уходить из реестра');
|
|
// Round-trip к закрытой странице бросает — и не имеет права ронять вердикт.
|
|
assert.match(serve, /try \{ await page\.evaluate\(\(\) => 0\); \} catch/);
|
|
});
|
|
|
|
test('оба читателя счётчика ждут доставки (#404, #407)', () => {
|
|
const serve = read('demo/serve.mjs');
|
|
assert.match(serve, /export async function reportPageErrors\(\)\s*\{\s*await roundTripLivePages\(\);/,
|
|
'вердикт для смоков со своей развязкой обязан ждать так же, как finish()');
|
|
});
|
|
|
|
test('пробы гарда лежат вне маски смоков и вне корпуса отпечатка (#404)', () => {
|
|
const guard = readdirSync(new URL('demo/guard/', `file://${ROOT}`));
|
|
assert.ok(guard.includes('verify-guard.mjs'));
|
|
const probes = guard.filter((name) => name.startsWith('guard_'));
|
|
assert.deepEqual(probes.sort(), [
|
|
'guard_closed_page.mjs',
|
|
'guard_report_page_errors.mjs',
|
|
'guard_tail_exception.mjs',
|
|
'guard_tail_rejection.mjs',
|
|
], 'четыре адресные пробы гарда обязаны оставаться обнаружимыми');
|
|
|
|
// Маска demo/smoke_*.mjs — то, что гоняют шарды CI. Проба, попавшая туда,
|
|
// покрасит шард по построению: она обязана падать.
|
|
for (const name of probes) {
|
|
assert.equal(name.startsWith('smoke_'), false, `${name} попала бы в шарды CI`);
|
|
}
|
|
|
|
// demo/fixtures/**/*.mjs входит в корпус sourceFingerprint: каждый файл там
|
|
// объявляет устаревшими бандл, скриншот-индекс и golden-индекс. Пробы гарда
|
|
// не касаются ни одного пикселя — платить за них пересъёмкой нечем.
|
|
for (const name of probes.concat('verify-guard.mjs')) {
|
|
assert.equal(existsSync(new URL(`demo/fixtures/${name}`, `file://${ROOT}`)), false,
|
|
`${name} в demo/fixtures/ протухал бы отпечаток исходников`);
|
|
}
|
|
});
|
|
|
|
test('пробы вызываются в job с браузером и служат guard мутантов (#404)', () => {
|
|
const workflow = read('.github/workflows/validate.yml');
|
|
const smoke = workflow.slice(workflow.indexOf('\n smoke:\n'), workflow.indexOf('\n smoke_done:\n'));
|
|
assert.match(smoke, /node demo\/guard\/verify-guard\.mjs/,
|
|
'проверка обязана идти там, где есть Chromium: в job «Фронтенд» npm test браузер не ставит,'
|
|
+ ' и тест на пробах молча скипался бы');
|
|
assert.match(smoke, /if: matrix\.shard == 1/, 'один раз, а не в каждом шарде');
|
|
|
|
const mutants = read('scripts/mutation-gate.mjs');
|
|
// Правка #404 состоит из двух половин, и мутант на одну оставил бы другую
|
|
// недоказанной. Список ведётся руками, и это осознанно: счётчик обязан
|
|
// совпадать с ним, поэтому новый мутант на этих пробах нельзя добавить, не
|
|
// назвав его здесь (в #430 так добавился четвёртый — гард page-benchmark).
|
|
const guarded = [
|
|
'smoke-guard-blind-to-tail',
|
|
'smoke-guard-forgets-to-register-pages',
|
|
'report-page-errors-skips-round-trip',
|
|
'benchmark-page-verdict-unwatched',
|
|
];
|
|
for (const id of guarded) {
|
|
assert.match(mutants, new RegExp(`id: '${id}'`), `мутант ${id} не зарегистрирован`);
|
|
}
|
|
assert.equal(
|
|
(mutants.match(/node demo\/guard\/verify-guard\.mjs/g) || []).length,
|
|
guarded.length,
|
|
'мутант на пробах гарда есть, а в списке выше его нет — список отстал от реестра',
|
|
);
|
|
});
|
|
|
|
test('страницы, созданные вне launch(), подписаны общим гардом (#404, Medium-1)', () => {
|
|
// Ревью ТЗ нашло разрыв: smoke_zoom_flash открывал вторую страницу со своей
|
|
// подпиской, печатавшей EXC2 мимо счётчика, а три страницы smoke_svg_sandbox
|
|
// не имели слушателя вовсе. Регистрация в launchInternal их не покрывает.
|
|
const zoom = read('demo/smoke_zoom_flash.mjs');
|
|
assert.match(zoom, /watchPage\(await ctx\.newPage\(\)\)/);
|
|
assert.equal(/p2\.on\('pageerror'/.test(zoom), false,
|
|
'своя подписка мимо счётчика убрана (EXC2 остался только в объяснении)');
|
|
|
|
const sandbox = read('demo/smoke_svg_sandbox.mjs');
|
|
assert.equal((sandbox.match(/watchPage\(await ctx\.newPage\(\)\)/g) || []).length, 3);
|
|
|
|
// Общий инвариант — не «никто не подписывается сам», а «ни одна страница не
|
|
// создаётся мимо гарда». Своя подписка поверх гарда законна и полезна:
|
|
// smoke_cold_view_toggle и smoke_cold_view_vacuum считают исключения ещё и
|
|
// отдельным утверждением `noPageErrors`, а их страница приходит из
|
|
// launchColdView, то есть уже зарегистрирована.
|
|
const demo = fileURLToPath(new URL('demo/', `file://${ROOT}`));
|
|
const smokes = readdirSync(demo)
|
|
.filter((name) => name.startsWith('smoke_') && name.endsWith('.mjs'));
|
|
const unwatched = [];
|
|
for (const name of smokes) {
|
|
const text = readFileSync(demo + name, 'utf8');
|
|
const total = (text.match(/newPage\(\)/g) || []).length;
|
|
const watched = (text.match(/watchPage\(await [\w.]+\.newPage\(\)\)/g) || []).length;
|
|
if (total > watched) unwatched.push(name);
|
|
}
|
|
assert.deepEqual(unwatched, ['smoke_entry_stale.mjs'],
|
|
'страница, созданная мимо watchPage, остаётся слепой зоной гарда.'
|
|
+ ' Единственное законное исключение — smoke_entry_stale: он намеренно'
|
|
+ ' поднимает свой браузер, ведёт свой счётчик исключений (он и есть'
|
|
+ ' предмет проверки #353) и выносит вердикт через finish()');
|
|
|
|
// Свои подписки допускаются только поверх общего гарда: страница обязана
|
|
// приходить из launch()/launchColdView(), иначе счётчик гарда её не видит.
|
|
for (const name of smokes) {
|
|
const text = readFileSync(demo + name, 'utf8');
|
|
if (!/\.on\('pageerror'/.test(text) || name === 'smoke_entry_stale.mjs') continue;
|
|
assert.match(text, /launch(ColdView)?[,}]|launch(ColdView)?\(/,
|
|
`${name}: своя подписка на pageerror поверх страницы, которую гард не знает`);
|
|
}
|
|
});
|