mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Гард «uncaught exception внутри карточки» жил в demo/serve.mjs с 2026-07-27 и не срабатывал ни разу в самом частом случае. Счётчик читался синхронно, а Playwright доставляет pageerror асинхронно по CDP: если исключение возникло после последнего обращения смока к странице, счётчик к моменту проверки нулевой, а browser.close() уносит недоставленное событие. В логе это видно дословно — EXC печатается после результата и до OK. finish() теперь делает round-trip по открытым страницам перед чтением счётчика. Страницы регистрируются там, где создаются: ссылок на них у finish(browser, out) нет, а менять сигнатуру нельзя — так её зовут 205 смоков. Medium-1 жёлтого ревью ТЗ закрыт расширением, а не оговоркой. Страницы, созданные смоком после launch(), регистрация в launchInternal не покрывает: smoke_zoom_flash печатал своё EXC2 мимо счётчика, три страницы smoke_svg_sandbox не имели слушателя вовсе. Документировать слепую зону в задаче, которая существует ради устранения слепой зоны, значит закрыть issue, оставив дефект. Наружу отдана одна функция watchPage(page): подписка и регистрация неразделимы, иначе появится страница, чьи исключения считаются, а доставки не ждёт никто. Разрыв оказался шире, чем в ревью: проверка по всему набору нашла ещё два файла со своей подпиской — smoke_cold_view_toggle и smoke_cold_view_vacuum. Они не слепая зона, их страница приходит из launchColdView и уже зарегистрирована, а свой счётчик они превращают в отдельное утверждение. Поэтому инвариант сформулирован как «ни одна страница не создаётся мимо гарда» и закреплён по всему набору, а не по двум названным файлам. reportPageErrors() из #407 стал асинхронным: второй читатель счётчика обязан ждать доставку так же, как finish(). Пять смоков получили await. Фикстура smoke_danger_confirmation приведена к объявленному типу: без binding и bindingMode _bindingHasHaPage падал на undefined.split(':') — два исключения, которых гард не видел. Дефекта поведения нет, все 15 мест в src/, создающих диалог, binding пишут; врала фикстура. Два отступления от ТЗ, каждое по измеренной причине. Пробы лежат в demo/guard/, а не demo/fixtures/: последний входит в корпус sourceFingerprint, и каждый файл там объявил бы устаревшими бандл, скриншот-индекс и golden-индекс — пробы же не касаются ни одного пикселя. Поведение доказывается в job со браузером, а не в npm test: job «Фронтенд» браузеры не ставит, и тест молча скипался бы — тот самый тихий успех, против которого вся задача. Issue: #404 User-Visible: no
118 lines
8.1 KiB
JavaScript
118 lines
8.1 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.equal(probes.length, 3, 'три пробы: хвостовое исключение, отклонение, закрытая страница');
|
|
|
|
// Маска 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');
|
|
for (const id of ['smoke-guard-blind-to-tail', 'smoke-guard-forgets-to-register-pages']) {
|
|
assert.match(mutants, new RegExp(`id: '${id}'`), `мутант ${id} не зарегистрирован`);
|
|
}
|
|
// Правка состоит из двух половин, и мутант на одну оставил бы другую
|
|
// недоказанной.
|
|
assert.equal((mutants.match(/node demo\/guard\/verify-guard\.mjs/g) || []).length, 2);
|
|
});
|
|
|
|
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 поверх страницы, которую гард не знает`);
|
|
}
|
|
});
|