ci: add headless-browser boot smoke for the web client (#1615)#1768
Conversation
smoke:web only asserts GET / serves the SPA HTML with the injected token; it never runs the React app, so a Node built-in reaching the browser bundle (which crashes the app to a blank page at runtime, e.g. #1612) slips through. Add smoke:web:browser, which boots the same prod --web server and runs the bundle in headless Chromium (Playwright, already a clients/web devDependency), asserting the app renders its first frame (the "Add Servers" control) with no uncaught page errors — in particular no `Module "node:*" has been externalized`. This catches "Node code reached the browser bundle" as a class, not one import at a time. Wire it into the root smoke chain (and thus npm run ci / GitHub CI), and move the Playwright install/cache steps ahead of the smoke step so the new smoke reuses the cache. Update AGENTS.md and README. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
|
@claude review |
|
Claude finished @cliffhall's task in 3m 6s —— View job Review: headless-browser boot smoke (#1768)
Good addition — this closes a real gap that source-level import scans can't ( A few things I'd change before merge — the first two are the ones that matter. 1. Treating every
|
- Don't fail on benign console.error (CDN font loads, React warnings) — the #1612 class arrives as an uncaught pageerror. console errors are now diagnostics, hard-failing only when they carry the externalization signature. - Broaden the externalization regex to non-prefixed built-ins ("fs" as well as "node:fs") and label the signature on both the pageerror and console channels. - Rename the port override to SMOKE_WEB_BROWSER_PORT so it doesn't collide with smoke:web's SMOKE_WEB_PORT. - Add a settle window (networkidle + 500ms) so a late-evaluating lazy chunk that throws is caught, and race the render against launcher death so a mid-load crash reports the real cause instead of a render timeout. - Print an actionable hint when Playwright/chromium can't launch (missing --with-deps system libs). - Extract the shared spawn/readiness boilerplate into scripts/lib/prod-web-server.mjs so smoke-web and smoke-web-browser can't drift. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
|
Thanks — thorough review. Pushed fixes in bb80837. Point by point: 1. 2. Regex misses non-prefixed built-ins. Fixed. Broadened to 3. Smaller notes:
Full @claude review |
|
Claude finished @cliffhall's task in 2m 34s —— View job Re-review: headless-browser boot smoke (#1768)
All three main findings are addressed correctly, and the helper extraction is a genuine improvement over two copies. One small regression came in with the extraction (finding A below) — everything else is nits. Verified fixes
Nice unplanned improvement: the render-failure path ( I also checked the locator can't hit a Playwright strict-mode violation: A. The extraction made
|
The bare `import("playwright")` resolved relative to the script's own
directory (scripts/), not the cwd — so `cd clients/web` in the npm script
never made it resolvable. It only passed locally because an ancestor
node_modules happened to carry playwright; CI has none, so it failed with
"Cannot find package 'playwright'". Resolve it explicitly from
clients/web via createRequire, independent of cwd and ancestor dirs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
|
Note: CI on the previous push failed in |
- waitForReady now records the last non-ok status and includes it in the timeout message, so a server that boots but persistently answers 500 (broken dist / injection failure) reports "server did not start … (last response: HTTP 500)" instead of a bare timeout that points at the wrong thing. Keeps polling on non-ok (a warming server may answer 503). - Add a child 'error' handler so a spawn-level failure surfaces as the smoke's FAILED line instead of an uncaught raw stack; readiness and the child-exit race both report it. - Drop the unused isExited/exitCode/child exports from the helper API. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
|
Thanks — addressed in 45e828e. A. Nit — unused exports. Dropped Nit — no Nit — Double boot — kept as the deliberate two-script choice, as noted. Separately: the first CI run on this PR caught a real bug the static reviews couldn't — the bare Local |
|
Claude finished @cliffhall's task in 5m 47s —— View job Third-pass review: headless-browser boot smoke (#1768)
Finding A and all three nits are correctly addressed. One new thing surfaced while checking the externalization signature against the repo's actual Vite version — it doesn't break the smoke, but it does mean the docs promise a string the prod bundle can't produce. Verified fixes (45e828e)
CI on this head ( 1. The
|
Third-review findings.
1. The `Module "…" has been externalized` string can't fire against a Vite 8
*prod* bundle — verified end-to-end against the installed vite@8.0.0: a
node: import reaching the browser graph is a BUILD-time warning, and the
shipped stub is a silent `module.exports = {}`. The real #1612 manifestation
is the first *call* into that empty stub throwing a TypeError at module init
(confirmed: injecting a used `node:fs` import fails the smoke with
"readFileSync is not a function"; an unused one ships {} and is harmless).
So the signature machinery was dead against the bundle it runs on — dropped
it. The smoke now asserts exactly what it verifies: the prod bundle boots and
paints with no uncaught page error. Reworded the script header, the
AGENTS.md bullet, and the README row to match. Console errors stay
diagnostic-only.
2. The helper's child 'error' handler set `exited = true`, but Node emits
'error' for a failed kill/send too (child still alive) — so `stop()`
(guarded on `exited`) would skip the SIGTERM and orphan the launcher on the
port. Track the error separately (`childError`); `bootFailed()` = exited ||
childError, while `stop()` still SIGTERMs a live child.
Docs: fixed the stale AGENTS.md claim that `cd clients/web` makes
`import("playwright")` resolve (createRequire does, cwd-independently; the cd is
only for `npx playwright install`), and noted the shared
scripts/lib/prod-web-server.mjs helper on both smoke bullets.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
|
Excellent review — the Vite-8 point is the important one. Addressed in b97b407, and I verified it empirically against the installed vite@8.0.0 rather than trusting a version-specific dist read, since you flagged the exact split as version-dependent: 1. Signature can't fire against a Vite 8 prod bundle. Confirmed end-to-end. In this repo's vite@8.0.0, a
So the 2. Nit — stale AGENTS.md playwright sentence. Fixed. The bullet no longer claims Nit — Nit — Local @claude review |
|
Claude finished @cliffhall's task in 3m 6s —— View job Fourth-pass review: headless-browser boot smoke (#1768)
Both findings are correctly addressed, and dropping the signature machinery was the right call rather than papering over it — the script now claims exactly what it asserts. Your empirical result also supersedes my dist read in a way that strengthens the PR (see the note under 1). One residual gap in the failure signal is worth three lines; everything else is nits. Verified fixes (b97b407)
Also re-verified: Your Vite finding upgrades the smoke's importance. I had read 8.1.5's dist and concluded a 1.
|
page.on("pageerror") fires for uncaught SYNCHRONOUS exceptions only. The
async twin of the #1612 shape — the same empty-stub TypeError reached
through an await/.then(), or a failed dynamic import (this app lazy-loads
chunks) — lands on the console channel as `Uncaught (in promise) …` /
`Failed to fetch dynamically imported module`, which were diagnostic-only,
so the smoke would have passed. Hard-fail console errors matching
/^Uncaught\b|Failed to fetch dynamically imported module/; every other
console error stays a printed non-fatal note. The prefixes are unambiguous
(a font/CDN miss reads "Failed to load resource: net::ERR_…"; React
warnings never start with "Uncaught"), so this can't reintroduce the flake
the earlier round fixed. Verified: an unhandled rejection at module init
now fails the smoke where it previously wouldn't.
Also prod-scope the AGENTS.md note that the "externalized" string isn't a
runtime message (under `npm run dev` Vite's stub is a Proxy that
console.warns it), and document both the sync and async failure signals.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
|
Thanks — took finding 1; it's a real gap. Fixed in 4382322. 1. Empirically verified against vite@8.0.0:
Updated the script header and the AGENTS.md bullet to document both the sync ( Nit — "not a runtime message" was absolute. Fixed — now scoped: "in a prod build … not a runtime message (under Nits — Your point about this being the only gate for CASE 1 is right and worth recording: under vite@8.0.0 a Local |
|
Claude finished @cliffhall's task in 2m 51s —— View job Fifth-pass review: headless-browser boot smoke (#1768)
Finding 1 is correctly implemented, and I found empirical corroboration in CI that it carries no flake risk here (below). No blocking findings this round. Verified (4382322)
Header comment Empirical corroboration from CI (stronger than my last round's caveat)The last green run on the previous head ( …and no Also, your "interesting wrinkle" is expected rather than build-specific, which makes the belt-and-braces framing exactly right: Chromium reports unhandled rejections through Nits (take or leave)
Verdict: LGTM. Five rounds in, the script now asserts exactly what it verifies, on both channels the class can arrive through, with the anti-flake property confirmed by a real CI run. CI on this head ( Caveat on verification: this checkout has no |
…h review) After the async hard-failure signature, the assertion spans both the pageerror (sync) and console (unhandled rejection / failed dynamic import) channels. The OK console line and the README smoke row still said "page error"; match the two-channel wording already in the AGENTS.md bullet. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
|
Thanks — both nits handled in 9c576d7. Nit — success line + README said "page error". Fixed. The OK line now reads Nit — Remaining items left as-is ( Thanks for the five-round pass — the CI-corroborated anti-flake point and the async-channel gap were both real catches. Local @claude review |
|
Claude finished @cliffhall's task in 1m 53s —— View job Sixth-pass review: headless-browser boot smoke (#1768)
Both nits are correctly applied and the diff is in good shape. No blocking findings; one small message-accuracy nit that's new this round, plus one thing I couldn't verify from here. Verified (9c576d7)
Full-diff re-read (
Nit (take or leave)
Notes
Verdict: LGTM — ship it. Six rounds in, the smoke asserts exactly what it verifies on both channels the crash class can arrive through, the shared helper removes the drift risk, the docs match the behavior, and the CASE 2 gap that this smoke can't see by design is tracked in #1769. The Caveat on verification: this checkout has no |
The catch around requireFromWeb("playwright") advised `playwright install
--with-deps chromium`, but that installs browser binaries, not the npm
package — and the npm script already ran `playwright install` a step
earlier. A failure there means the package isn't resolvable from
clients/web, so the actionable fix is `npm install` at the repo root (the
postinstall cascade installs clients/web's devDependencies). The
chromium.launch() catch keeps its --with-deps advice (correct for the
missing-system-libraries case).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5
|
Thanks — fixed the last nit in 0c27159.
On your notes:
@claude review |
|
Claude finished @cliffhall's task in 1m 29s —— View job Seventh-pass review: headless-browser boot smoke (#1768)
The last nit is correctly fixed and I have no further findings. Full diff re-read; ship it. Verified (0c27159)
Both messages are now actionable for the failure they actually describe: a bare box missing devDependencies gets CI state (corrected/completed picture)Your claim checks out: Final diff pass — nothing further
Verdict: LGTM — approved to merge once the in-progress run on this head goes green (the functional commit Caveat on verification: this checkout has no |
Closes #1615
What
Adds
smoke:web:browser— a lightweight headless-Chromium boot smoke for the prod web client — and wires it intonpm run smoke(and thusnpm run ci/ GitHub CI).Why
smoke:webonly asserts thatGET /serves the SPA HTML with the injected auth token — it never runs the React app. That's how #1612 slipped through: the browser bundle transitively value-imported a Node-only module, pullingnode:*built-ins into the build and crashing the app to a blank page at runtime (Module "node:*" has been externalized for browser compatibility). Unit/integration tests run in node/happy-dom, never a real browser bundle, so none of them caught it either.The PR that fixed #1612 added a source-level scan of the known browser-reachable OAuth-storage modules — but that only closes the hole one import list at a time. A Node dep pulled in through some other module (or a dependency) would still ship a broken bundle.
How
scripts/smoke-web-browser.mjsboots the same prodmcp-inspector --webserver assmoke:web, then opens the served page in headless Chromium (Playwright, already aclients/webdevDependency for the Storybook tests) and asserts:pageerror+console.error), calling out theModule "node:*" has been externalizedsignature explicitly.This catches "Node code reached the browser bundle" as a class of regression, not one import at a time.
Notes:
cd clients/web && npx playwright install chromiumfirst soimport("playwright")resolves and chromium is present (a no-op when already installed). Repo-root paths inside the script come fromimport.meta.url, so the cwd change doesn't affect which build tree is exercised..github/workflows/main.yml, the Playwright cache-restore + install steps now run before the smokes sosmoke:web:browserreuses the cache instead of downloading its own copy; the Storybook step still reuses the same install.Verification
Module "node:fs" has been externalized …throw intomain.tsx, rebuilt: the smoke failed withNode built-in reached the browser bundle: …and exited 1. Reverted.