Five `scripts/*.mjs` consumers each carried their own `ensureTestServer()` that returned early when `test-servers/build` was already on disk. Presence is not freshness: once that directory exists — which it does on any machine that has run the smokes once — an edit to `test-servers/src` was never picked up again, and the smoke went on driving the previously-built fixture. The failure is silent and misattributed. It does not read as "your fixture is stale"; it reads as a product bug in whatever the smoke is checking. `scripts/lib/ensure-test-servers.mjs` is now the one place that build is produced for a script consumer, and it builds unconditionally — memoized once per process per repo root, so pack-and-verify's two asks stay one tsc pass while a fresh run always rebuilds. Callers name the entries they depend on and get one actionable failure message, replacing five that had drifted. pack-and-verify also stops shelling out to `npx tsc`, which is what made it the one script unrunnable on Windows (#1939). `test-servers/tsconfig.json` gains `incremental` so the up-to-date case is cheap: 3.0s cold, 1.6s incremental here. Unconditional emit is still not a clean — `--noCheck` leaves the stale .js of a deleted src file behind — which is unchanged from before and recorded in the helper's header and AGENTS.md. Closes #2111 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H Signed-off-by: cliffhall <cliff@futurescale.com>
477 lines
17 KiB
JavaScript
477 lines
17 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* End-to-end smoke test for the prod CLI launcher path (#1347).
|
||
*
|
||
* `npm run smoke:launcher` only checks `--cli --help`; it never connects to a
|
||
* server, so the launcher → CLI → core → stdio-transport path and the
|
||
* `--catalog` / `--config` resolution introduced in #1347 went unverified by
|
||
* the launcher smokes. This script drives the built `clients/launcher/build`
|
||
* artifact in `--cli` mode and asserts, end to end:
|
||
*
|
||
* 1. `--catalog <populated>` + `--server` runs `tools/list` and returns the
|
||
* bundled test server's tools (real connect over stdio).
|
||
* 2. The default writable catalog is seeded empty on first run (and reports
|
||
* "no servers"), rather than erroring "Config file not found".
|
||
* 3. A read-only `--config` that is missing errors and is NOT seeded.
|
||
* 4. `--catalog` + `--config` is rejected as mutually exclusive.
|
||
* 5. An unknown `--server` errors with the source-agnostic "not found.
|
||
* Available servers: …" message (#1482).
|
||
* 6. A multi-server catalog selects via `--server` and errors (asking for a
|
||
* selection) when it is omitted.
|
||
* 7. `--header` merges into the resolved server's settings without breaking
|
||
* the connect path (the file→settings lift from #1482).
|
||
* 8. Over an HTTP transport (in-process test server), a config-file `headers`
|
||
* object is lifted onto the wire, and a CLI `--header` overrides it — the
|
||
* headline lift→transport path of #1482, verified end to end.
|
||
* 9. A usage error through the launcher `--cli` path emits the JSON `{"error":…}`
|
||
* envelope and exits 1 (a bad `--callback-url`, whose message contains
|
||
* "OAuth", must NOT misclassify as auth_required — #1795 AK1/AJ1).
|
||
* 10. A non-1 exit code survives the launcher too: `--use-stored-auth` with no
|
||
* stored token exits 3 with envelope code `no_stored_token`, pinning that the
|
||
* whole EXIT_CODES map — not just exit 1 — reaches an automated caller
|
||
* through `mcp-inspector --cli`. (Steps 9–10 are the ONLY guard on that
|
||
* contract: `clients/{launcher,cli}/src/index.ts` are coverage-excluded.)
|
||
*
|
||
* Exits non-zero (failing CI / `npm run validate`) on any mismatch.
|
||
*
|
||
* Expects `clients/launcher/build` and `clients/cli/build` to be built first
|
||
* (the validate / CI ordering guarantees this). The bundled test servers
|
||
* (`test-servers/build`) are rebuilt on every run — see
|
||
* `scripts/lib/ensure-test-servers.mjs` for why presence isn't freshness.
|
||
*/
|
||
|
||
import { spawn, spawnSync } from "node:child_process";
|
||
import { mkdtempSync, rmSync, existsSync, writeFileSync } from "node:fs";
|
||
import { tmpdir } from "node:os";
|
||
import { join, resolve } from "node:path";
|
||
import { pathToFileURL } from "node:url";
|
||
import { ensureTestServers } from "./lib/ensure-test-servers.mjs";
|
||
|
||
const repoRoot = resolve(import.meta.dirname, "..");
|
||
const launcher = join(repoRoot, "clients", "launcher", "build", "index.js");
|
||
function fail(message) {
|
||
console.error(`smoke:cli FAILED — ${message}`);
|
||
process.exit(1);
|
||
}
|
||
|
||
// Neutralize env that would make an unrelated step fail: parseRunnerOAuthCallbackUrl
|
||
// runs on every --cli invocation, so an ambient MCP_OAUTH_CALLBACK_URL pointing at
|
||
// a non-loopback host would fail steps 1–8. Empty reads as unset in the parser
|
||
// (default 127.0.0.1:6276 applies); per-call extraEnv is spread after, so step 9
|
||
// can still pass --callback-url explicitly. Mirrors prod-web-server.mjs's HOST pin.
|
||
const SMOKE_BASE_ENV = { MCP_OAUTH_CALLBACK_URL: "" };
|
||
|
||
/** Run the launcher in --cli mode. Returns { status, stdout, stderr }. */
|
||
function runCli(args, extraEnv = {}) {
|
||
const r = spawnSync(process.execPath, [launcher, "--cli", ...args], {
|
||
cwd: repoRoot,
|
||
env: { ...process.env, ...SMOKE_BASE_ENV, ...extraEnv },
|
||
encoding: "utf-8",
|
||
});
|
||
return { status: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
|
||
}
|
||
|
||
/**
|
||
* Async variant of `runCli` using non-blocking `spawn`. Required for the HTTP
|
||
* case: the test server runs in THIS process's event loop, so a blocking
|
||
* `spawnSync` would deadlock (the CLI child's request could never be serviced).
|
||
* Returns { status, stdout, stderr }.
|
||
*/
|
||
function runCliAsync(args, extraEnv = {}) {
|
||
return new Promise((resolveRun) => {
|
||
const child = spawn(process.execPath, [launcher, "--cli", ...args], {
|
||
cwd: repoRoot,
|
||
env: { ...process.env, ...SMOKE_BASE_ENV, ...extraEnv },
|
||
});
|
||
let stdout = "";
|
||
let stderr = "";
|
||
child.stdout.setEncoding("utf-8");
|
||
child.stderr.setEncoding("utf-8");
|
||
child.stdout.on("data", (d) => (stdout += d));
|
||
child.stderr.on("data", (d) => (stderr += d));
|
||
child.on("close", (status) => resolveRun({ status, stdout, stderr }));
|
||
});
|
||
}
|
||
|
||
if (!existsSync(launcher)) {
|
||
fail(`launcher build not found at ${launcher} — run \`npm run build\` first`);
|
||
}
|
||
// Rebuilt on every run — presence is not freshness (#2111).
|
||
let testServer, httpTestServerModule, fixturesModule;
|
||
try {
|
||
[testServer, httpTestServerModule, fixturesModule] = ensureTestServers({
|
||
repoRoot,
|
||
label: "smoke:cli",
|
||
requires: ["stdio", "http", "fixtures"],
|
||
});
|
||
} catch (e) {
|
||
fail(e.message);
|
||
}
|
||
|
||
const work = mkdtempSync(join(tmpdir(), "smoke-cli-"));
|
||
try {
|
||
// 1) Populated --catalog → tools/list returns the test server's tools.
|
||
const catalogPath = join(work, "catalog.json");
|
||
writeFileSync(
|
||
catalogPath,
|
||
JSON.stringify({
|
||
mcpServers: {
|
||
test: { type: "stdio", command: process.execPath, args: [testServer] },
|
||
},
|
||
}),
|
||
);
|
||
const list = runCli([
|
||
"--catalog",
|
||
catalogPath,
|
||
"--server",
|
||
"test",
|
||
"--method",
|
||
"tools/list",
|
||
]);
|
||
if (list.status !== 0) {
|
||
fail(
|
||
`tools/list via --catalog exited ${list.status}\n${list.stderr || list.stdout}`,
|
||
);
|
||
}
|
||
let parsed;
|
||
try {
|
||
parsed = JSON.parse(list.stdout);
|
||
} catch {
|
||
fail(`tools/list did not return JSON on stdout:\n${list.stdout}`);
|
||
}
|
||
const toolNames = (parsed.tools ?? []).map((t) => t.name);
|
||
if (!toolNames.includes("echo")) {
|
||
fail(
|
||
`tools/list missing expected "echo" tool; got: ${toolNames.join(", ")}`,
|
||
);
|
||
}
|
||
|
||
// 2) Default writable catalog is seeded empty on first run (HOME redirected).
|
||
const fakeHome = join(work, "home");
|
||
const seed = runCli(["--method", "tools/list"], {
|
||
HOME: fakeHome,
|
||
USERPROFILE: fakeHome,
|
||
});
|
||
if (seed.status === 0) {
|
||
fail("default catalog with no servers should have exited non-zero");
|
||
}
|
||
if (!/No servers found/.test(seed.stderr)) {
|
||
fail(
|
||
`expected "No servers found" from seeded default catalog; got:\n${seed.stderr}`,
|
||
);
|
||
}
|
||
if (!existsSync(join(fakeHome, ".mcp-inspector", "mcp.json"))) {
|
||
fail("default catalog was not seeded at ~/.mcp-inspector/mcp.json");
|
||
}
|
||
|
||
// 3) Read-only --config that is missing errors and is NOT seeded.
|
||
const missingConfig = join(work, "missing.json");
|
||
const ro = runCli(["--config", missingConfig, "--method", "tools/list"]);
|
||
if (ro.status === 0) {
|
||
fail("missing --config should have exited non-zero");
|
||
}
|
||
if (!/Config file not found/.test(ro.stderr)) {
|
||
fail(
|
||
`expected "Config file not found" for missing --config; got:\n${ro.stderr}`,
|
||
);
|
||
}
|
||
if (existsSync(missingConfig)) {
|
||
fail("read-only --config must never be seeded, but the file was created");
|
||
}
|
||
|
||
// 4) --catalog + --config is rejected.
|
||
const conflict = runCli([
|
||
"--catalog",
|
||
catalogPath,
|
||
"--config",
|
||
catalogPath,
|
||
"--method",
|
||
"tools/list",
|
||
]);
|
||
if (conflict.status === 0) {
|
||
fail("--catalog with --config should have exited non-zero");
|
||
}
|
||
if (!/mutually exclusive/.test(conflict.stderr)) {
|
||
fail(
|
||
`expected "mutually exclusive" for --catalog + --config; got:\n${conflict.stderr}`,
|
||
);
|
||
}
|
||
|
||
// 5) Unknown --server errors with the source-agnostic message (#1482): the
|
||
// entry may come from a file or a single ad-hoc target, so it is "not
|
||
// found" rather than "not found in config file".
|
||
const unknownServer = runCli([
|
||
"--catalog",
|
||
catalogPath,
|
||
"--server",
|
||
"nope",
|
||
"--method",
|
||
"tools/list",
|
||
]);
|
||
if (unknownServer.status === 0) {
|
||
fail("--server with an unknown name should have exited non-zero");
|
||
}
|
||
if (
|
||
!/Server 'nope' not found\. Available servers: test/.test(
|
||
unknownServer.stderr,
|
||
)
|
||
) {
|
||
fail(
|
||
`expected "Server 'nope' not found. Available servers: test"; got:\n${unknownServer.stderr}`,
|
||
);
|
||
}
|
||
|
||
// 6) Multi-server catalog: --server selects one; omitting it errors asking for
|
||
// a selection (selectServerEntry over a multi-entry source).
|
||
const multiCatalogPath = join(work, "multi-catalog.json");
|
||
writeFileSync(
|
||
multiCatalogPath,
|
||
JSON.stringify({
|
||
mcpServers: {
|
||
one: { type: "stdio", command: process.execPath, args: [testServer] },
|
||
two: { type: "stdio", command: process.execPath, args: [testServer] },
|
||
},
|
||
}),
|
||
);
|
||
const ambiguous = runCli([
|
||
"--catalog",
|
||
multiCatalogPath,
|
||
"--method",
|
||
"tools/list",
|
||
]);
|
||
if (ambiguous.status === 0) {
|
||
fail("multi-server catalog without --server should have exited non-zero");
|
||
}
|
||
if (!/Multiple servers found/.test(ambiguous.stderr)) {
|
||
fail(
|
||
`expected "Multiple servers found" without --server; got:\n${ambiguous.stderr}`,
|
||
);
|
||
}
|
||
const selected = runCli([
|
||
"--catalog",
|
||
multiCatalogPath,
|
||
"--server",
|
||
"two",
|
||
"--method",
|
||
"tools/list",
|
||
]);
|
||
if (selected.status !== 0) {
|
||
fail(
|
||
`--server two against a multi-server catalog exited ${selected.status}\n${selected.stderr || selected.stdout}`,
|
||
);
|
||
}
|
||
let selectedParsed;
|
||
try {
|
||
selectedParsed = JSON.parse(selected.stdout);
|
||
} catch {
|
||
fail(`--server two did not return JSON on stdout:\n${selected.stdout}`);
|
||
}
|
||
if (!(selectedParsed.tools ?? []).some((t) => t.name === "echo")) {
|
||
fail(`--server two tools/list missing expected "echo" tool`);
|
||
}
|
||
|
||
// 7) --header is merged into the resolved server's settings and broadcast to
|
||
// every entry (#1482). Over stdio the header is inert, so the real check is
|
||
// that the merge path does not break the connect: tools/list still works.
|
||
const withHeader = runCli([
|
||
"--catalog",
|
||
catalogPath,
|
||
"--server",
|
||
"test",
|
||
"--header",
|
||
"X-Smoke: 1",
|
||
"--method",
|
||
"tools/list",
|
||
]);
|
||
if (withHeader.status !== 0) {
|
||
fail(
|
||
`tools/list with --header exited ${withHeader.status}\n${withHeader.stderr || withHeader.stdout}`,
|
||
);
|
||
}
|
||
let withHeaderParsed;
|
||
try {
|
||
withHeaderParsed = JSON.parse(withHeader.stdout);
|
||
} catch {
|
||
fail(
|
||
`tools/list with --header did not return JSON on stdout:\n${withHeader.stdout}`,
|
||
);
|
||
}
|
||
if (!(withHeaderParsed.tools ?? []).some((t) => t.name === "echo")) {
|
||
fail(`tools/list with --header missing expected "echo" tool`);
|
||
}
|
||
|
||
// 8) HTTP transport — the headline of #1482: a config-file `headers` object is
|
||
// lifted into InspectorServerSettings and actually sent ON THE WIRE, and a
|
||
// CLI `--header` overrides it. Unlike stdio (where headers are inert), this
|
||
// proves the full lift→transport path. The HTTP test server runs IN-PROCESS
|
||
// so we can read back the headers it recorded; the CLI connects to it as a
|
||
// child via `runCliAsync` (a blocking spawnSync would deadlock the server's
|
||
// event loop). `getRecordedRequests()` lowercases header names (Node http).
|
||
const { createTestServerHttp } = await import(
|
||
pathToFileURL(httpTestServerModule).href
|
||
);
|
||
const { createEchoTool, createTestServerInfo } = await import(
|
||
pathToFileURL(fixturesModule).href
|
||
);
|
||
const httpServer = createTestServerHttp({
|
||
serverInfo: createTestServerInfo(),
|
||
tools: [createEchoTool()],
|
||
serverType: "streamable-http",
|
||
});
|
||
const httpPort = await httpServer.start();
|
||
try {
|
||
const httpConfigPath = join(work, "http-config.json");
|
||
const writeHttpConfig = (headers) =>
|
||
writeFileSync(
|
||
httpConfigPath,
|
||
JSON.stringify({
|
||
mcpServers: {
|
||
http: {
|
||
type: "http",
|
||
url: `http://localhost:${httpPort}/mcp`,
|
||
headers,
|
||
},
|
||
},
|
||
}),
|
||
);
|
||
|
||
// 8a) File-level header is lifted onto the wire.
|
||
writeHttpConfig({ "X-Smoke-Header": "from-config" });
|
||
const httpList = await runCliAsync([
|
||
"--config",
|
||
httpConfigPath,
|
||
"--server",
|
||
"http",
|
||
"--method",
|
||
"tools/list",
|
||
]);
|
||
if (httpList.status !== 0) {
|
||
fail(
|
||
`HTTP tools/list exited ${httpList.status}\n${httpList.stderr || httpList.stdout}`,
|
||
);
|
||
}
|
||
const headerValues = () =>
|
||
httpServer
|
||
.getRecordedRequests()
|
||
.map((r) => r.headers?.["x-smoke-header"])
|
||
.filter(Boolean);
|
||
if (!headerValues().includes("from-config")) {
|
||
fail(
|
||
`expected config-file header "X-Smoke-Header: from-config" on the wire; ` +
|
||
`recorded values: ${JSON.stringify(headerValues())}`,
|
||
);
|
||
}
|
||
|
||
// 8b) CLI --header overrides the file header on the wire (mergeSettings).
|
||
httpServer.clearRecordings();
|
||
const httpOverride = await runCliAsync([
|
||
"--config",
|
||
httpConfigPath,
|
||
"--server",
|
||
"http",
|
||
"--header",
|
||
"X-Smoke-Header: from-cli",
|
||
"--method",
|
||
"tools/list",
|
||
]);
|
||
if (httpOverride.status !== 0) {
|
||
fail(
|
||
`HTTP tools/list with --header override exited ${httpOverride.status}\n${httpOverride.stderr || httpOverride.stdout}`,
|
||
);
|
||
}
|
||
const overrideValues = headerValues();
|
||
if (!overrideValues.includes("from-cli")) {
|
||
fail(
|
||
`expected --header override "X-Smoke-Header: from-cli" on the wire; ` +
|
||
`recorded values: ${JSON.stringify(overrideValues)}`,
|
||
);
|
||
}
|
||
if (overrideValues.includes("from-config")) {
|
||
fail(
|
||
`--header should override the file header, but "from-config" still reached the wire; ` +
|
||
`recorded values: ${JSON.stringify(overrideValues)}`,
|
||
);
|
||
}
|
||
} finally {
|
||
await httpServer.stop();
|
||
}
|
||
|
||
// 9) A usage error through the launcher --cli path still emits the JSON
|
||
// envelope and the right exit code (#1795 AK1): the launcher imports runCli
|
||
// as a module, so the CLI's own error sink must be routed through, or the
|
||
// documented EXIT_CODES/envelope contract is lost. A bad --callback-url is
|
||
// a usage error whose message contains "OAuth" (would otherwise misclassify
|
||
// as auth_required/exit 3).
|
||
const badCallback = runCli([
|
||
"--catalog",
|
||
catalogPath,
|
||
"--server",
|
||
"test",
|
||
"--method",
|
||
"tools/list",
|
||
"--callback-url",
|
||
"http://0.0.0.0:6276/oauth/callback",
|
||
]);
|
||
if (badCallback.status !== 1) {
|
||
fail(
|
||
`bad --callback-url should exit 1 (usage) through the launcher, got ${badCallback.status}\n${badCallback.stderr}`,
|
||
);
|
||
}
|
||
let envelope;
|
||
try {
|
||
envelope = JSON.parse(badCallback.stderr.trim());
|
||
} catch {
|
||
fail(
|
||
`bad --callback-url should emit a JSON {"error":…} envelope through the launcher; got:\n${badCallback.stderr}`,
|
||
);
|
||
}
|
||
if (envelope?.error?.code !== "error") {
|
||
fail(
|
||
`bad --callback-url envelope should be code "error", got ${JSON.stringify(envelope)}`,
|
||
);
|
||
}
|
||
|
||
// 10) A NON-1 exit code survives the launcher too (the actual AK1 claim — the
|
||
// whole EXIT_CODES map, not just the exit-1 the old catch-all produced).
|
||
// --use-stored-auth with no stored token throws AUTH_REQUIRED (3) with
|
||
// envelope code "no_stored_token" *before* any connect, so it's offline.
|
||
const noStoredAuth = runCli(
|
||
[
|
||
"--server-url",
|
||
"http://example.invalid/mcp",
|
||
"--method",
|
||
"tools/list",
|
||
"--use-stored-auth",
|
||
],
|
||
{ HOME: fakeHome, USERPROFILE: fakeHome },
|
||
);
|
||
if (noStoredAuth.status !== 3) {
|
||
fail(
|
||
`--use-stored-auth with no token should exit 3 (auth_required) through the launcher, got ${noStoredAuth.status}\n${noStoredAuth.stderr}`,
|
||
);
|
||
}
|
||
let authEnvelope;
|
||
try {
|
||
authEnvelope = JSON.parse(noStoredAuth.stderr.trim());
|
||
} catch {
|
||
fail(
|
||
`--use-stored-auth should emit a JSON envelope through the launcher; got:\n${noStoredAuth.stderr}`,
|
||
);
|
||
}
|
||
if (authEnvelope?.error?.code !== "no_stored_token") {
|
||
fail(
|
||
`--use-stored-auth envelope should be code "no_stored_token", got ${JSON.stringify(authEnvelope)}`,
|
||
);
|
||
}
|
||
|
||
console.log(
|
||
"smoke:cli OK — tools/list over stdio via --catalog; default-catalog seed; " +
|
||
"read-only --config error (no seed); --catalog/--config conflict; " +
|
||
"unknown --server error; multi-server --server selection; --header merge; " +
|
||
"HTTP config-header lift + --header override on the wire; " +
|
||
"launcher --cli usage error → JSON envelope + exit 1; " +
|
||
"launcher --cli auth error → JSON envelope + exit 3",
|
||
);
|
||
} finally {
|
||
rmSync(work, { recursive: true, force: true });
|
||
}
|