Addresses both Copilot review comments on #1978. 1. `stop()` removed the catalog dir immediately after a bare `child.kill()`, which only *delivers* SIGTERM. That is precisely the #1801 race `child-cleanup.mjs:11-17` documents, and it says both halves are needed: `stopChild()` closes the race on the normal path, `removeSafe()` makes the residual case harmless. I had used only the second. `smoke-web-app.mjs` already used `stopChild` for its MCP test-server child, so the helper was inconsistent with its own sibling. `stop()` is now async — it awaits `stopChild` and then removes — and all three callers await it (`smoke-web.mjs`'s `fail()` became async to match, so every call site had to `await fail(...)` or execution would run past it instead of exiting). 2. The isolation contract had no test. The smokes exit the process right after teardown, so a regression reintroducing the shared catalog — or dropping cleanup — would leave them all green. Split the two pure pieces out (`createTempCatalog`, `buildWebServerEnv`) and cover them in a sibling `prod-web-server.test.mjs`: a unique dir per run, no pre-created catalog file, the dir is removable, and an inherited `MCP_CATALOG_PATH` (or `MCP_AUTO_OPEN_ENABLED`) cannot beat the explicit one — the spread-then-assign ordering that guard depends on. `startProdWebServer` itself stays untested by design; it spawns a real launcher, which is the smokes' job. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall <cliff@futurescale.com>
210 lines
8.1 KiB
JavaScript
210 lines
8.1 KiB
JavaScript
/**
|
|
* Shared boot/readiness helper for the prod web smokes.
|
|
*
|
|
* `scripts/smoke-web.mjs` (serves-the-HTML check), `scripts/smoke-web-browser.mjs`
|
|
* (runs-the-bundle check, #1615), and `scripts/smoke-web-app.mjs` (MCP Apps
|
|
* end-to-end, #1859) all boot the *same* prod `mcp-inspector --web` server, so the
|
|
* spawn + readiness-poll boilerplate lives here once instead of being copy-pasted
|
|
* (and drifting) in each script. Catalog isolation (#1977) lives here for the same
|
|
* reason — it is a property every web smoke needs, not one script's concern.
|
|
*
|
|
* Repo-root paths are derived from import.meta.url, so a caller's cwd (e.g.
|
|
* `smoke:web:browser` does `cd clients/web` first so its `npx playwright
|
|
* install` finds the local bin) doesn't affect which launcher/build tree runs.
|
|
*/
|
|
|
|
import { spawn } from "node:child_process";
|
|
import { mkdtempSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { setTimeout as delay } from "node:timers/promises";
|
|
import { fileURLToPath } from "node:url";
|
|
import { dirname, join, resolve } from "node:path";
|
|
|
|
import { removeSafe, stopChild } from "./child-cleanup.mjs";
|
|
|
|
const libDir = dirname(fileURLToPath(import.meta.url));
|
|
const repoRoot = resolve(libDir, "..", "..");
|
|
const launcherEntry = resolve(repoRoot, "clients/launcher/build/index.js");
|
|
|
|
/**
|
|
* Mint a fresh throwaway catalog for one server run.
|
|
*
|
|
* `mkdtemp` (not a fixed name) is what makes concurrent runs safe: the three web
|
|
* smokes run back-to-back today, but a fixed path would have them share — and
|
|
* silently reintroduce the cross-run bleed this whole change removes.
|
|
*
|
|
* The catalog *file* is deliberately not created. The backend seeds an empty
|
|
* catalog on first use, which is exactly the first-run state we want every run
|
|
* to start from.
|
|
*
|
|
* @returns {{ dir: string, path: string }}
|
|
*/
|
|
export function createTempCatalog() {
|
|
const dir = mkdtempSync(join(tmpdir(), "smoke-web-catalog-"));
|
|
return { dir, path: join(dir, "catalog.json") };
|
|
}
|
|
|
|
/**
|
|
* Build the child env for the prod web server.
|
|
*
|
|
* Split out from the spawn so the isolation contract is unit-testable without
|
|
* starting a server (#1977). The ordering is load-bearing: `MCP_CATALOG_PATH`
|
|
* is assigned *after* the `process.env` spread, so an inherited value from the
|
|
* developer's shell is overridden rather than silently winning — which would
|
|
* put the smoke straight back on whatever catalog that variable names.
|
|
*
|
|
* @param {object} opts
|
|
* @param {string} opts.host
|
|
* @param {string} opts.port
|
|
* @param {string} opts.token
|
|
* @param {string} opts.catalogPath
|
|
* @param {NodeJS.ProcessEnv} [opts.baseEnv]
|
|
*/
|
|
export function buildWebServerEnv({
|
|
host,
|
|
port,
|
|
token,
|
|
catalogPath,
|
|
baseEnv = process.env,
|
|
}) {
|
|
return {
|
|
...baseEnv,
|
|
CLIENT_PORT: port,
|
|
HOST: host,
|
|
MCP_INSPECTOR_API_TOKEN: token,
|
|
MCP_CATALOG_PATH: catalogPath,
|
|
// Don't pop a browser in CI.
|
|
MCP_AUTO_OPEN_ENABLED: "false",
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Spawn `mcp-inspector --web` (prod, no `--dev`) against the built
|
|
* `clients/web/dist` and return handles for readiness + teardown.
|
|
*
|
|
* The server always runs against a **throwaway catalog**, never the developer's
|
|
* real `~/.mcp-inspector/mcp.json` (#1977). Without `MCP_CATALOG_PATH` the web
|
|
* backend falls back to that default writable catalog, which made these smokes
|
|
* both destructive and non-deterministic: `smoke:web:app`'s deep link persists a
|
|
* `deep-link` server row, so a second run finds it already on disk. The row is
|
|
* then racing hydration — the deep-link effect misses it in `servers`, POSTs
|
|
* `addServer` anyway, and the backend answers 409. The app swallows that 409 by
|
|
* design so the smoke still passes, but it reports a spurious non-fatal console
|
|
* error that is really just residue from the previous run. Isolating the catalog
|
|
* makes every run look like the first one, and mirrors `smoke:cli` / `smoke:tui`,
|
|
* which have always driven a temp `--catalog`.
|
|
*
|
|
* Only the catalog is redirected. Other per-user state under `~/.mcp-inspector`
|
|
* (OAuth tokens, the `storage/` dir) is still shared — isolating that means
|
|
* redirecting HOME wholesale, which these smokes deliberately do not do, since
|
|
* HOME also resolves the npx and Playwright caches they depend on.
|
|
*
|
|
* @param {object} opts
|
|
* @param {string} opts.host
|
|
* @param {string} opts.port
|
|
* @param {string} opts.token value injected as MCP_INSPECTOR_API_TOKEN
|
|
* @param {string} [opts.label] prefix for the temp dir + cleanup warnings
|
|
*/
|
|
export function startProdWebServer({ host, port, token, label = "smoke:web" }) {
|
|
const baseUrl = `http://${host}:${port}`;
|
|
|
|
const { dir: catalogDir, path: catalogPath } = createTempCatalog();
|
|
|
|
const child = spawn(process.execPath, [launcherEntry, "--web"], {
|
|
env: buildWebServerEnv({ host, port, token, catalogPath }),
|
|
stdio: ["ignore", "inherit", "inherit"],
|
|
});
|
|
|
|
let exited = false;
|
|
let exitCode = null;
|
|
let childError = null;
|
|
child.on("exit", (code) => {
|
|
exited = true;
|
|
exitCode = code;
|
|
});
|
|
// A child 'error' event covers a spawn failure (e.g. a missing launcher
|
|
// entry) but ALSO a failed kill / failed send where the process is still
|
|
// alive. Without a listener Node throws it uncaught with a raw stack instead
|
|
// of the smoke's `… FAILED —` line. Record it, but deliberately do NOT set
|
|
// `exited` — otherwise `stop()` (which guards on `exited`) would skip the
|
|
// SIGTERM and orphan a still-running launcher on the port.
|
|
child.on("error", (err) => {
|
|
childError = err;
|
|
});
|
|
|
|
// Boot has failed if the process exited or emitted an error.
|
|
const bootFailed = () => exited || childError !== null;
|
|
|
|
function bootFailure(phase) {
|
|
return new Error(
|
|
childError
|
|
? `launcher process error: ${childError.message}`
|
|
: `launcher exited (code ${exitCode}) ${phase} — see output above`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Poll `GET /` until the server answers with an ok status. Keeps polling on a
|
|
* non-ok status (a warming server may legitimately answer 503), but records
|
|
* the last one so a server that boots yet never returns ok (e.g. a broken
|
|
* `dist` answering 500) reports that status instead of a bare timeout. Rejects
|
|
* early if the launcher exits before serving. Resolves with the first ok
|
|
* Response.
|
|
*/
|
|
async function waitForReady({ attempts = 120, intervalMs = 500 } = {}) {
|
|
let lastStatus = null;
|
|
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
if (bootFailed()) throw bootFailure("before serving");
|
|
try {
|
|
const res = await fetch(`${baseUrl}/`);
|
|
if (res.ok) return res;
|
|
lastStatus = res.status;
|
|
} catch {
|
|
// not listening yet
|
|
}
|
|
await delay(intervalMs);
|
|
}
|
|
throw new Error(
|
|
`server did not start within ${(attempts * intervalMs) / 1000}s${
|
|
lastStatus !== null ? ` (last response: HTTP ${lastStatus})` : ""
|
|
}`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* A promise that rejects when the launcher process dies — race it against
|
|
* page-load work so a mid-flight server death is reported as the real cause
|
|
* instead of a downstream render timeout. Never resolves; if the server stays
|
|
* up it simply stays pending until the process exits.
|
|
*/
|
|
function whenChildExits() {
|
|
return new Promise((_resolve, reject) => {
|
|
const onDeath = () => reject(bootFailure("mid-run"));
|
|
if (bootFailed()) onDeath();
|
|
else {
|
|
child.once("exit", onDeath);
|
|
child.once("error", onDeath);
|
|
}
|
|
});
|
|
}
|
|
|
|
return {
|
|
baseUrl,
|
|
catalogPath,
|
|
waitForReady,
|
|
whenChildExits,
|
|
/**
|
|
* Terminate the server, then remove its catalog dir. **Await this** — the
|
|
* two halves are the documented pair in `child-cleanup.mjs`, and both are
|
|
* needed. `stopChild` closes the #1801 race on the normal path (a bare
|
|
* `kill()` only *delivers* the signal, so a synchronous remove can hit
|
|
* ENOTEMPTY when the server writes the catalog on its way out); `removeSafe`
|
|
* then makes the residual case harmless, warning instead of throwing so a
|
|
* leftover temp dir can never turn a passing smoke red.
|
|
*/
|
|
stop: async () => {
|
|
await stopChild(child, { label, what: "prod web server" });
|
|
removeSafe(catalogDir, { label });
|
|
},
|
|
};
|
|
}
|