Copilot was right that the previous test proved only that `removeSafe` can
delete a directory — it never drove `stop()`, so deleting the cleanup call
would have left the unit suite AND all three smokes green. That is exactly the
regression these tests exist to catch, and they did not catch it.
Extract the teardown into an exported `teardownWebServer({ child, catalogDir,
label })`, which `stop()` now delegates to, and drive it against a stand-in
child process (a real `node -e setInterval` that stays alive until signalled)
rather than a mock. Three cases:
- it removes the catalog dir it was given (asserted on the dir itself, not a
spy, so the assertion cannot drift from the behavior);
- it awaits the child's exit before removing — the #1801 race in one
assertion, since losing the await is otherwise invisible;
- an already-dead child is not an error, which is the failure path a smoke
takes when it calls fail() after the launcher has crashed.
Mutation-checked: with the `removeSafe` call deleted, 2 of the 3 now fail
(previously: 0). AGENTS.md updated to match.
Signed-off-by is now on every commit (#1978 DCO); the requirement itself is
undocumented and tracked in #1979.
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>
230 lines
8.9 KiB
JavaScript
230 lines
8.9 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",
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Terminate a spawned web server, then remove its catalog dir.
|
|
*
|
|
* 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.
|
|
*
|
|
* Exported separately from `startProdWebServer` so the teardown *contract* is
|
|
* testable against a stand-in child, without booting a real launcher. Testing it
|
|
* matters more than it looks: deleting the `removeSafe` call would leave both the
|
|
* unit suite and all three smokes green (the smokes exit immediately after
|
|
* teardown), which is precisely the regression the tests exist to catch.
|
|
*
|
|
* @param {object} opts
|
|
* @param {import("node:child_process").ChildProcess} opts.child
|
|
* @param {string} opts.catalogDir
|
|
* @param {string} [opts.label]
|
|
*/
|
|
export async function teardownWebServer({
|
|
child,
|
|
catalogDir,
|
|
label = "smoke:web",
|
|
}) {
|
|
await stopChild(child, { label, what: "prod web server" });
|
|
removeSafe(catalogDir, { label });
|
|
}
|
|
|
|
/**
|
|
* 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 teardown warnings (the temp dir's own
|
|
* prefix is fixed, so every run's dir is greppable as `smoke-web-catalog-*`)
|
|
*/
|
|
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.** */
|
|
stop: () => teardownWebServer({ child, catalogDir, label }),
|
|
};
|
|
}
|