fix: fail smoke:web:app on async crashes, not just sync page errors

Copilot's second pass (suppressed comments) caught a real gap: the smoke
listened only for `pageerror`, which is the *synchronous* half of the
uncaught-crash class. Its async twin — an unhandled rejection, or a failed
dynamic import (this app lazy-loads chunks) — is not a `pageerror`; Chromium
reports it on the console channel. So an async crash during the app-open path
could leave the smoke green.

smoke-web-browser.mjs already solved this; this now mirrors it exactly — same
FATAL_CONSOLE pattern, same split between hard failures and diagnostics, so
benign noise (a font-CDN miss, a React warning) still can't flake CI. Fatal
console errors are now included both in the catch-block diagnostics and in the
final assertion, which is where they were missing.

Confirmed working on a passing run: a benign 409 subresource error is reported
as a non-fatal diagnostic rather than failing the smoke.

Full `npm run ci` green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XAR2Nr9kXbrywFNUVoTe9F
This commit is contained in:
cliffhall 2026-08-06 09:40:50 -04:00
commit 556aaca298

View file

@ -89,6 +89,12 @@ const HOST = "127.0.0.1";
const PORT = process.env.SMOKE_WEB_APP_PORT ?? "6297";
const TOKEN = "smoke-web-app-token";
const APP_TOOL = "mcp_app_demo";
// Console messages that are the async half of the uncaught-crash class (an
// unhandled rejection or a failed dynamic import). Hard failures; every other
// console error is a diagnostic, so benign font-CDN / React-warning noise can't
// flake CI. Kept identical to smoke-web-browser.mjs, which documents the
// reasoning at length.
const FATAL_CONSOLE = /^Uncaught\b|Failed to fetch dynamically imported module/;
// The URL the test server announces on startup. NOT derived from the config's
// port: createTestServerHttp resolves its port with findAvailablePort(), which
// walks UPWARD from the configured value when it's taken — so the config port is
@ -219,10 +225,19 @@ try {
browser = await loadChromium();
const page = await browser.newPage();
// Uncaught *synchronous* page errors. Their *async* twin — an unhandled
// rejection or a failed dynamic import — is not a `pageerror`; Chromium
// reports it on the console channel instead, so both are captured and both
// are hard failures. Same split as smoke:web:browser; see FATAL_CONSOLE there.
const pageErrors = [];
const consoleErrors = [];
page.on("pageerror", (err) =>
pageErrors.push(err instanceof Error ? err.message : String(err)),
);
page.on("console", (msg) => {
if (msg.type() === "error") consoleErrors.push(msg.text());
});
const fatalConsole = () => consoleErrors.filter((m) => FATAL_CONSOLE.test(m));
// Deep link: connect, switch to the Apps tab, pre-select the app tool, and
// fire "Open App". autoConnect/autoOpen must equal the session token (CSRF
@ -285,15 +300,33 @@ try {
try {
await Promise.race([server.whenChildExits(), drive()]);
} catch (err) {
const diagnostics = [
...pageErrors,
...fatalConsole().map((m) => `console: ${m}`),
];
await fail(
`${err instanceof Error ? err.message : String(err)}${
pageErrors.length ? ` — page errors: ${pageErrors.join("; ")}` : ""
diagnostics.length
? ` — page diagnostics: ${diagnostics.join("; ")}`
: ""
}`,
);
}
if (pageErrors.length > 0) {
await fail(`app logged uncaught error(s): ${pageErrors.join("; ")}`);
// Hard failures: any uncaught sync page error, plus the console errors that
// are the async half of the same class.
const fatal = [...pageErrors, ...fatalConsole()];
if (fatal.length > 0) {
await fail(`app logged uncaught error(s): ${fatal.join("; ")}`);
}
// Non-fatal console errors: surface them so a real problem isn't invisible,
// without failing on benign subresource/warning noise.
const benignConsole = consoleErrors.filter((m) => !FATAL_CONSOLE.test(m));
if (benignConsole.length > 0) {
console.log(
`smoke:web:app note — ${benignConsole.length} non-fatal console error(s): ${benignConsole.join("; ")}`,
);
}
console.log(