fix: six smokes never asked whether the card threw

Счётчик исключений внутри карточки живёт в demo/serve.mjs, и читала его одна
функция — finish(). Шесть смоков её не вызывали вовсе: у трёх своя развязка
(`if (!ok) process.exit(1)`), у двух throw из try/finally, у
smoke_entry_stale ни того ни другого. Необработанное исключение во время этих
шести проходило незамеченным всегда — в лог печаталось EXC, а прогон
оставался зелёным.

smoke_entry_stale был хуже остальных: он складывал неудачи в _failures через
check/checkAll, но их никто не печатал и код возврата не выставлял. То есть
смок не мог провалиться в принципе — ровно паттерн «печатали булевы значения
и всегда выходили нулём», который шапка serve.mjs описывает как исправленный
в 2026-07-27.

Добавлен reportPageErrors(): тот же вердикт, что у finish(), для смоков со
своей логикой выхода. Каждый из шести теперь вердикт запрашивает, а
smoke_entry_stale получил finish() и вместе с ним настоящий код возврата.

Вердикт обязан ОСТАНАВЛИВАТЬ, а не только помечать. Первый заход выставлял
process.exitCode, и отрицательный прогон напечатал «FAILED: 1 uncaught
exception(s)» и следом «OK deep-link: …»: код был верным, вывод
противоречивым, а читают вывод.

Доказано отрицательным прогоном, а не рассуждением: на ветке
experiment/407-negative smoke_deeplink получил намеренное исключение внутри
карточки, шард 2/3 упал с exit code 1, в логе FAIL и FAILED без строки
успеха. Ветка удалена.

Гейт против повторения — test/smoke-harness-contract.test.mjs: он падает,
если смок не запрашивает вердикт или запрашивает, не останавливаясь. На
origin/dev до починки он находил ровно шесть файлов, после — ноль.

Issue: #407
User-Visible: no
This commit is contained in:
Matysh
2026-09-01 17:19:41 +03:00
parent 2fae84af58
commit 601e68e52d
8 changed files with 127 additions and 8 deletions
+26
View File
@@ -31,6 +31,32 @@ export function checkAll(out, expected = {}) {
return out;
}
/**
* Тот же вердикт по исключениям в карточке, что у `finish()`, для смоков со
* своей логикой выхода (#407).
*
* Счётчик `_pageErrors` живёт здесь, а читал его только `finish()`. Шесть
* смоков `finish()` не вызывали вовсе — у каждого была своя ручная развязка
* (`if (!ok) process.exit(1)`) либо `throw` из try/finally, и ни одна не
* спрашивала про исключения. То есть необработанное исключение внутри карточки
* во время этих шести проходило незамеченным всегда: в лог печаталось `EXC`, а
* прогон оставался зелёным.
*
* Функция ничего не бросает намеренно. Смок с ручной развязкой продолжает
* решать сам, что печатать и когда выходить; здесь только выставляется код
* возврата, который дальше уже не отнять — `process.exitCode` переживёт любой
* последующий `console.log`.
*
* @returns true, если карточка бросала — чтобы вызывающий мог добавить своё
* сообщение, не считая исключения заново.
*/
export function reportPageErrors() {
if (!_pageErrors) return false;
console.error(`FAILED: ${_pageErrors} uncaught exception(s) inside the card`);
process.exitCode = 1;
return true;
}
/** Print the result, report failures, close the browser, set the exit code. */
export async function finish(browser, out) {
if (out !== undefined) console.log(JSON.stringify(out, null, 1));
+4 -1
View File
@@ -1,5 +1,5 @@
// Smoke: the full houseplan-card honours the #space=<id> deep-link (hashchange).
import { launch } from './serve.mjs';
import { launch, reportPageErrors } from './serve.mjs';
const { page, browser } = await launch({ width: 820, height: 760 }, 1);
const res = await page.evaluate(async () => {
const card = window.__card;
@@ -22,5 +22,8 @@ const res = await page.evaluate(async () => {
await browser.close();
const ok = res.after === res.target && res.afterBad === res.target;
console.log(JSON.stringify(res));
// #407: своя развязка про исключения в карточке не спрашивает. Вердикт обязан
// именно остановить: иначе строка успеха печатается после «FAILED».
if (reportPageErrors()) process.exit(1);
if (!ok) { console.error('FAIL deeplink smoke'); process.exit(1); }
console.log('OK deep-link: full card switches to #space=<id>, ignores invalid ids');
+7 -3
View File
@@ -5,7 +5,7 @@
// and `await import(entry)` + `createElement(...).setConfig(...)` stays valid.
import { readFileSync } from 'node:fs';
import { chromium } from 'playwright';
import { check, checkAll } from './serve.mjs';
import { check, checkAll, finish } from './serve.mjs';
const entry = readFileSync('dist/houseplan-card.js');
const page404 = `<!doctype html><meta charset="utf-8"><body><script type="module">
@@ -52,5 +52,9 @@ const out = {
&& fr.text.includes('recharger la page'),
};
checkAll(out);
check('smoke_entry_stale', Object.values(out).every(Boolean));
if (Object.values(out).every(Boolean)) console.log('OK');
// #407: до этого смок не мог провалиться в принципе. `check`/`checkAll`
// складывали неудачи в `_failures`, но их никто не печатал и код возврата не
// выставлял — ровно тот паттерн «печатали булевы значения и всегда выходили
// нулём», который описан в шапке serve.mjs как исправленный в 2026-07-27.
// Браузеры закрыты внутри run(), поэтому finish() получает undefined.
await finish(undefined, out);
+5 -1
View File
@@ -1,6 +1,6 @@
/** Browser pixel smoke for the isolated additive group (#19). */
import { readFileSync } from 'node:fs';
import { launch } from './serve.mjs';
import { launch, reportPageErrors } from './serve.mjs';
const fixture = JSON.parse(readFileSync(
new URL('../test/fixtures/glow/additive-pools.json', import.meta.url), 'utf8',
@@ -218,6 +218,10 @@ try {
throw new Error(`custom data fill changed: ${out.customFill}/${out.customOpacity}`);
if (out.customBase || out.customBaseTunnel || !out.customLayerOrder)
throw new Error(`data fill was tinted/reordered by Glow base: full=${out.customBase}, tunnel=${out.customBaseTunnel}, order=${out.customLayerOrder}`);
// #407: проверки выше бросают на своей регрессии, но про исключения внутри
// карточки не спрашивает ни одна. Бросаем и здесь — тогда `finally` закроет
// браузер, а строка успеха не напечатается после «FAILED».
if (reportPageErrors()) throw new Error('uncaught exception inside the card — see EXC above');
console.log(JSON.stringify({ ok: true, blend: out.blend, pools: out.pools, staticParity: true, staticPools: out.staticPools }));
} finally {
await browser.close();
+4 -1
View File
@@ -1,5 +1,5 @@
// Asserts device icon badges sit exactly on their anchor point (no content-box/border drift).
import { launch } from './serve.mjs';
import { launch, reportPageErrors } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 1000 }, 2);
const bad = await page.evaluate(() => {
const c = window.__card;
@@ -24,5 +24,8 @@ const bad = await page.evaluate(() => {
return bad;
});
await browser.close();
// #407: своя развязка про исключения в карточке не спрашивает. Вердикт обязан
// именно остановить: иначе строка успеха печатается после «FAILED».
if (reportPageErrors()) process.exit(1);
if (bad.length) { console.error('FAIL icon-center: badges off anchor', JSON.stringify(bad.slice(0, 5))); process.exit(1); }
console.log('OK icon-center: all device badges centred on their anchor');
+5 -1
View File
@@ -1,5 +1,5 @@
/** Regression smoke for #59: a modal must terminate the held view gesture. */
import { launch } from './serve.mjs';
import { launch, reportPageErrors } from './serve.mjs';
const { page, browser } = await launch({ width: 1000, height: 820 }, 1);
try {
@@ -63,6 +63,10 @@ try {
});
if (!Object.values(result).every(Boolean))
throw new Error(`long-press gesture regression: ${JSON.stringify(result)}`);
// #407: проверки выше бросают на своей регрессии, но про исключения внутри
// карточки не спрашивает ни одна. Бросаем и здесь — тогда `finally` закроет
// браузер, а строка успеха не напечатается после «FAILED».
if (reportPageErrors()) throw new Error('uncaught exception inside the card — see EXC above');
console.log(JSON.stringify({ ok: true, ...result }));
} finally {
await browser.close();
+4 -1
View File
@@ -1,5 +1,5 @@
// Smoke: houseplan-space-card renders a live, non-interactive schematic + deep-link button.
import { launch } from './serve.mjs';
import { launch, reportPageErrors } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
const res = await page.evaluate(async () => {
await customElements.whenDefined('houseplan-space-card');
@@ -243,5 +243,8 @@ const ok =
typeof res.deepLink === 'string' && res.deepLink.includes('#space=') &&
res.errorShown;
console.log(JSON.stringify(res));
// #407: своя развязка про исключения в карточке не спрашивает. Вердикт обязан
// именно остановить: иначе строка успеха печатается после «FAILED».
if (reportPageErrors()) process.exit(1);
if (!ok) { console.error('FAIL space-card smoke'); process.exit(1); }
console.log('OK space-card: live shared marker face, pointer-events:none, deep-link button, error card');
+72
View File
@@ -0,0 +1,72 @@
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');
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 \(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 function reportPageErrors\(\)/);
assert.equal((serve.match(/_pageErrors\+\+/g) || []).length, 1,
'счётчик инкрементируется в одном месте');
const entryStale = read('smoke_entry_stale.mjs');
assert.match(entryStale, /await finish\(/, 'smoke_entry_stale обязан выносить вердикт');
});