ci: add headless-browser boot smoke for the web client (#1615) (#1768)
Some checks failed
Run install, format, lint, build, and test on every push / build (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish-github-container-registry (push) Has been cancelled
Some checks failed
Run install, format, lint, build, and test on every push / build (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish-github-container-registry (push) Has been cancelled
* ci: add headless-browser boot smoke for the web client (#1615) 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 * ci: address review on the headless-browser boot smoke - 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 * ci: fix playwright resolution in the browser smoke (CI failure) 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 * ci: address second review on the prod-web-server helper - 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 * ci: reframe browser smoke around uncaught pageerror; fix stop() edge 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 * ci: also hard-fail the async half of the crash class (fourth review) 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 * docs: reword smoke success line + README to two-channel wording (fifth 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 * ci: correct the playwright-package-resolution hint (sixth review) 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 --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
373f346e5f
commit
742e82515a
1 changed files with 375 additions and 66 deletions
|
|
@ -9,15 +9,19 @@
|
|||
* returns HTTP 200 with the injected auth-token global. It exits non-zero
|
||||
* (failing CI / `npm run validate`) on any failure, then shuts the server down.
|
||||
*
|
||||
* This checks the served HTML only — it does not execute the bundle. The
|
||||
* companion `smoke:web:browser` (scripts/smoke-web-browser.mjs, #1615) boots the
|
||||
* same server and actually runs the app in headless Chromium; both share the
|
||||
* spawn/readiness helper in ./lib/prod-web-server.mjs.
|
||||
*
|
||||
* Expects `clients/web/dist` and `clients/launcher/build` to be built first —
|
||||
* the validate / CI ordering guarantees this, so the readiness wait below is
|
||||
* sized for an already-built `dist`. (If `dist` happened to be missing, the
|
||||
* launcher's build-on-demand path would build it on startup, which a cold
|
||||
* `vite build` could push past that wait — not a scenario this script targets.)
|
||||
* the validate / CI ordering guarantees this, so the readiness wait is sized for
|
||||
* an already-built `dist`. (If `dist` happened to be missing, the launcher's
|
||||
* build-on-demand path would build it on startup, which a cold `vite build`
|
||||
* could push past that wait — not a scenario this script targets.)
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { startProdWebServer } from "./lib/prod-web-server.mjs";
|
||||
|
||||
const HOST = "127.0.0.1";
|
||||
const PORT = process.env.SMOKE_WEB_PORT ?? "6299";
|
||||
|
|
@ -25,59 +29,17 @@ const TOKEN = "smoke-web-token";
|
|||
// Mirrors INSPECTOR_API_TOKEN_GLOBAL in core/mcp/remote/constants.ts; kept as a
|
||||
// literal because this plain .mjs script can't import the TS source.
|
||||
const TOKEN_GLOBAL = "__INSPECTOR_API_TOKEN__";
|
||||
const BASE_URL = `http://${HOST}:${PORT}`;
|
||||
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
["clients/launcher/build/index.js", "--web"],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
CLIENT_PORT: PORT,
|
||||
HOST,
|
||||
MCP_INSPECTOR_API_TOKEN: TOKEN,
|
||||
// Don't pop a browser in CI.
|
||||
MCP_AUTO_OPEN_ENABLED: "false",
|
||||
},
|
||||
stdio: ["ignore", "inherit", "inherit"],
|
||||
},
|
||||
);
|
||||
|
||||
let exited = false;
|
||||
let exitCode = null;
|
||||
child.on("exit", (code) => {
|
||||
exited = true;
|
||||
exitCode = code;
|
||||
});
|
||||
|
||||
function shutdown() {
|
||||
if (!exited) child.kill("SIGTERM");
|
||||
}
|
||||
const server = startProdWebServer({ host: HOST, port: PORT, token: TOKEN });
|
||||
|
||||
function fail(message) {
|
||||
console.error(`smoke:web FAILED — ${message}`);
|
||||
shutdown();
|
||||
server.stop();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function fetchRoot() {
|
||||
for (let attempt = 0; attempt < 120; attempt++) {
|
||||
if (exited) {
|
||||
throw new Error(
|
||||
`launcher exited (code ${exitCode}) before serving — see output above`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
return await fetch(`${BASE_URL}/`);
|
||||
} catch {
|
||||
await delay(500);
|
||||
}
|
||||
}
|
||||
throw new Error("server did not start within 60s");
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetchRoot();
|
||||
const res = await server.waitForReady();
|
||||
if (res.status !== 200) {
|
||||
fail(`GET / returned HTTP ${res.status}, expected 200`);
|
||||
}
|
||||
|
|
@ -91,9 +53,9 @@ try {
|
|||
fail("served HTML is missing the injected auth-token value");
|
||||
}
|
||||
console.log(
|
||||
`smoke:web OK — GET / => 200 with injected ${TOKEN_GLOBAL} at ${BASE_URL}`,
|
||||
`smoke:web OK — GET / => 200 with injected ${TOKEN_GLOBAL} at ${server.baseUrl}`,
|
||||
);
|
||||
shutdown();
|
||||
server.stop();
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
fail(err instanceof Error ? err.message : String(err));
|
||||
|
|
|
|||
Loading…
Reference in a new issue