Merge branch 'v2/main' into v2/feat/1005-schema-lint

This commit is contained in:
Cliff Hall 2026-08-25 09:05:51 -04:00 • committed by GitHub
commit c228a326af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 436 additions and 169 deletions

View file

@ -276,6 +276,24 @@ v2/main/
│ # caller's teardown reaches it on every throw path —
│ # the readiness timeout included, which is what used
│ # to orphan a live server holding its port — #2000;
│ # ensure-test-servers.mjs is the ONE place
│ # `test-servers/build` is produced for a script
│ # consumer, and it builds UNCONDITIONALLY (once per
│ # process per repo root): the five hand-rolled copies
│ # it replaced each returned early when the output was
│ # already on disk, so after the first run an edit to
│ # `test-servers/src` was never picked up and the smoke
│ # silently drove the previous fixture — reported not as
│ # staleness but as a product failure in whatever the
│ # smoke was checking. Unconditional emit is still not a
│ # clean, and that is the one hole it does NOT close: a
│ # DELETED src file leaves its stale `.js` behind, the
│ # existence checks pass against it, and anything still
│ # importing that module SILENTLY runs the old code — the
│ # same failure class. So `rm -rf test-servers/build`
│ # after deleting or renaming a source file; the
│ # `.tsbuildinfo` is pinned inside `build/` so that
│ # clean actually invalidates the cache — #2111;
│ # resolve-node-bin.mjs resolves a
│ # package's bin through its own package.json, so the
│ # verify/smoke scripts spawn it with `process.execPath`
@ -284,7 +302,9 @@ v2/main/
│ # the exception — its children are `npm` itself and the
│ # installed `.bin` shim, neither resolvable that way, so
│ # it keeps a Windows shell and quotes its generated
│ # paths via win-shell-args.mjs — #1939). Prettier-gated via
│ # paths via win-shell-args.mjs — #1939, though its
│ # test-server build now goes through the shared helper
│ # like everyone else's). Prettier-gated via
│ # `format:check:scripts`; its own pure parsers are
│ # unit-tested by `npm run test:scripts` (node --test).
├── specification/ # Build specification

View file

@ -456,7 +456,7 @@ Each client self-validates from its own folder; the root scripts chain them. The
| `npm run verify:build-gate` | Runs a real `vite build` with a Node built-in forced into the browser graph and asserts the build **fails** via the #1769 gate (which turns Vite's browser-externalization warning into a hard error). Guards against the warning phrasing drifting in a Vite bump and silently disabling the gate. Part of `npm run ci`. |
| `npm run verify:bundle-externals` | Guards the must-not-bundle invariant (#2067): for each tsup-bundled client it reads the **built** `build/` output and fails if any package that must stay external was inlined anyway. Candidates are the union of the client's own `external` array **and the root manifest's `dependencies`** — the latter because #2067 was a *missing* `external` entry, which a self-referential check would have passed. Detection is via esbuild's `// <path>` module banners, so it covers both shapes — a separate `<pkg>-HASH.js` chunk (what a dynamically `import()`ed CommonJS package produces) and a statically-imported package folded straight into `index.js`, which emits no chunk at all. A build with no banners fails as such rather than passing clean, so enabling `minify` cannot silently retire the check. `undici` was declared only in the root and `clients/cli` manifests, and tsup auto-externalizes only what the _nearest_ manifest declares, so the web and TUI bundles inlined 1.05MB of it — and CommonJS inlined into an ESM bundle throws `Dynamic require of "assert" is not supported` on first use, from a specifier no user-side install can satisfy. Reads the output rather than the config because those two disagreed for four releases. Part of `npm run ci`. |
| `npm run verify:format-coverage` | Parses the `format:check` globs out of every `package.json` (only those reachable from `validate`), enumerates all tracked source files, and **fails** listing any not covered by a glob — the durable guard for the "every first-party source file is format-gated" invariant (#1792). Runs first in `validate`. |
| `npm run test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs`, `scripts/lib/tsc-program.mjs` + the exported helpers of `verify-typecheck-coverage.mjs` and `verify-dep-lockstep.mjs`), one case per rule they encode, plus two suites over shared `scripts/lib` helpers that no smoke can check itself: `resolve-node-bin.test.mjs` — the cross-platform bin resolver (#1939), pinned against the real `bin`/`exports` shapes of the packages the scripts actually spawn — and `announced-child.test.mjs` — the spawn/readiness ownership helper (#2000), which drives real `node -e` children to prove a child that never announces is still published to the caller before the timeout throws, and so is reachable by teardown rather than orphaned. A third, `mcp-app-flow.test.mjs`, covers the shared MCP Apps flow (#2003) — the deep link's two CSRF gates and `appArgs` encoding, plus `driveAppFlow`'s failure branches against a stand-in page, all of which are dead code from the happy-path smokes' point of view and would otherwise surface only as opaque timeouts. Runs in `validate` — and `verify:typecheck-coverage` guards *this* gate in turn (reachable from `validate`, non-empty test set, every test file matched by the `test:scripts` glob), since `node --test` silently skips a file its glob misses and still exits 0. |
| `npm run test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs`, `scripts/lib/tsc-program.mjs` + the exported helpers of `verify-typecheck-coverage.mjs` and `verify-dep-lockstep.mjs`), one case per rule they encode, plus two suites over shared `scripts/lib` helpers that no smoke can check itself: `resolve-node-bin.test.mjs` — the cross-platform bin resolver (#1939), pinned against the real `bin`/`exports` shapes of the packages the scripts actually spawn — and `announced-child.test.mjs` — the spawn/readiness ownership helper (#2000), which drives real `node -e` children to prove a child that never announces is still published to the caller before the timeout throws, and so is reachable by teardown rather than orphaned. Two more do the same: `mcp-app-flow.test.mjs` covers the shared MCP Apps flow (#2003) — the deep link's two CSRF gates and `appArgs` encoding, plus `driveAppFlow`'s failure branches against a stand-in page, all of which are dead code from the happy-path smokes' point of view and would otherwise surface only as opaque timeouts; and `ensure-test-servers.test.mjs` pins the [#2111](https://github.com/modelcontextprotocol/inspector/issues/2111) invariant — that `test-servers/build` is rebuilt **even when it already exists** — which no smoke can assert about itself, since one driving a stale fixture reports a product failure rather than a staleness one. Runs in `validate` — and `verify:typecheck-coverage` guards *this* gate in turn (reachable from `validate`, non-empty test set, every test file matched by the `test:scripts` glob), since `node --test` silently skips a file its glob misses and still exits 0. |
| `npm run verify:typecheck-coverage` | The typecheck-coverage analog of the above (#1791): for each Node client (auto-discovered from disk — enrolled via its `typecheck` script's projects, or for a `tsc -b` client like `clients/web` via its `tsconfig.json` `references`) it runs those projects with `tsc --listFilesOnly`, unions them, and **fails** listing any tracked `.ts`/`.tsx`/`.mts`/`.cts` under the client that lands in no project (so a new top-level config/helper can't silently go untypechecked). It also requires, deny-by-default, the first-party TS no client owns (`test-servers/src`, the root `vitest.shared.mts`, all of `core/`, and any new top-level location) to land in some client project's tsc pass — so a `core` `*.tsx` web's projects don't reach is caught too. Also asserts the gate is wired (each client's typecheck pass — its `typecheck` script, or web's `tsc -b` — is reachable from its `validate`, and the root chain runs each client's `validate`). Runs in `validate`. |
| `npm run verify:dep-lockstep` | Guards the "one version per install-crossing dependency" invariant (#1896). v2 is not a workspace, so a client's test project compiles the shared first-party TypeScript — `core/`, `test-servers/src`, and the root-owned `vitest.shared.mts`, all of which resolve their dependencies from the **root** install — alongside the client's own sources, putting the same package in one `tsc` program twice. At the same version that's harmless; skewed, TypeScript must relate two structurally-distinct copies of every type, which for a recursive-generic surface is exponential (zod `4.3.6` vs `4.4.3` exhausted the 4GB tsc heap in `clients/web`). Derives its candidate set from **what actually enters each program** (#1965) — every client tsconfig project listed with `tsc --listFilesOnly` via the shared `scripts/lib/tsc-program.mjs`, each resolved `node_modules` file mapped to its owning install, keeping the packages that reach one program from two installs (a package whose declarations arrive only through another package's `.d.ts`, as `@modelcontextprotocol/sdk`'s do, is invisible to a scan of first-party imports). Prices each copy from the lockfile entry for the exact install path the program resolved, compares only the installs that met in one program, and **fails deny-by-default** on any disagreement not in the annotated `TOLERATED_SKEW` allowlist — empty today — with an allowlisted package tolerated only *within a major version*. Runs in `validate`.
| `npm run ci` | **Mandatory pre-push command.** `validate` → `coverage` → `verify:build-gate` → `verify:bundle-externals` → `smoke` → Storybook. A true superset of GitHub CI. |

View file

@ -0,0 +1,163 @@
/**
* The one place `test-servers/build` is produced for a script consumer (#2111).
*
* Five scripts used to carry their own `ensureTestServer()`, each returning
* early when the output was already on disk. Presence is not freshness: once
* `test-servers/build` 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 kept driving the previously-built fixture.
*
* That failure is silent *and* misattributed. It does not read as "your fixture
* is stale", it reads as a product bug: a `v2/main` merge that touched
* `preset-registry.ts` made `smoke:web:app` report the dedicated app-origin
* path as broken, and two unrelated hypotheses were measured out before the
* real cause surfaced. The **tests** have always built the same fixture
* unconditionally (`pretest`, `test:coverage`, `test:integration` all run
* `test-servers:build`), so the weaker policy sat on exactly the checks that
* spawn real servers and are hardest to debug when wrong.
*
* So: build every time, from here. `tsc -p test-servers --noCheck` costs ~1s
* cold and less when `incremental` finds its `.tsbuildinfo` up to date, against
* smokes that take tens of seconds and a `pack:verify` that does a real
* `npm install` from the registry.
*
* ⚠️ Unconditional emit is not a clean, and this is the one hole it does NOT
* close. `--noCheck` emit leaves the stale `.js` of a *deleted* `src` file
* behind, and no path here removes it — the post-build existence checks pass
* against that stale file, so if anything still imports the deleted module it
* silently keeps running the old code. That is the same failure class as the
* one this helper fixes, so do not read the unconditional build as covering it.
* (It is unchanged from before: the old early return only skipped the build, it
* never cleaned either.) Fixing it properly means `tsc -b` with `composite`,
* which forces `declaration` on and drops `--noCheck`; not worth it for this.
* **`rm -rf test-servers/build` after deleting or renaming a source file** —
* the `.tsbuildinfo` lives inside `build/` precisely so that clean is real.
*
* The root-installed tsc is run via this Node — `npx` is a `.cmd` shim on
* Windows that a shell-free spawnSync can't start (ENOENT — #1939). That is
* also one of the drifts consolidating fixed: `pack-and-verify.mjs` still
* shelled out to `npx tsc`, so it alone was unrunnable on Windows.
*/
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { resolveNodeBin } from "./resolve-node-bin.mjs";
/**
* The build entries a consumer can require, by short name. Every one of them is
* emitted by the same single `tsc -p test-servers` pass — the names exist so a
* caller can say which files its own run depends on, and get told precisely
* which is missing when the build silently produces nothing.
*/
export const TEST_SERVER_ENTRIES = {
stdio: "test-server-stdio.js",
http: "test-server-http.js",
composable: "server-composable.js",
fixtures: "test-server-fixtures.js",
};
/** Absolute path of a named `test-servers/build` entry. */
export function testServerEntryPath(repoRoot, name) {
const file = TEST_SERVER_ENTRIES[name];
if (!file) {
throw new Error(
`unknown test-server entry "${name}" (known: ${Object.keys(
TEST_SERVER_ENTRIES,
).join(", ")})`,
);
}
return join(repoRoot, "test-servers", "build", file);
}
/**
* The actionable message a failed build reports. Kept as its own function
* because it is the half a consumer sees when things go wrong, and every
* previous copy worded it differently — including naming the wrong client to
* run `npm run test-servers:build` from.
*/
export function buildFailureMessage(missing) {
const detail = missing.length
? ` (missing ${missing.join(", ")})`
: " (tsc exited non-zero)";
return (
`could not build the test servers${detail}. ` +
"Run `npm run test-servers:build` from clients/web."
);
}
/** Repo roots already built in this process — see `ensureTestServers`. */
const built = new Set();
/**
* Build `test-servers/build`, unconditionally, and assert the entries the
* caller named exist afterwards.
*
* Unconditional per *process*, not per call: `pack-and-verify.mjs` asks twice
* (once for the stdio entry, once for the composable one) and one `tsc` pass
* emits both, so a second call within the same run skips the build. A fresh
* process always rebuilds, which is the whole point — that is what makes an
* edit to `test-servers/src` reach the next smoke. The *existence* assertion is
* not cached, because a later call can name an entry the first one didn't.
*
* `log` receives the bare message; the default prefixes it with `label`, which
* is what a caller owning its own prefixed logger (pack-and-verify's `step`)
* passes instead. `run` and `exists` are injectable so the failure branches —
* the ones no smoke's happy path can reach — are testable without spawning
* tsc.
*
* @returns {string[]} absolute paths of the required entries, in order.
*/
export function ensureTestServers({
repoRoot,
label,
requires = [],
log = (message) => console.log(`${label} — ${message}`),
run = runTsc,
exists = existsSync,
}) {
const paths = requires.map((name) => testServerEntryPath(repoRoot, name));
// A cached call can name an entry the first one didn't, so the existence
// assertion runs on every call. Skipping it on the cache hit would return a
// path to a file tsc never emitted, and the consumer would then fail with
// exactly the opaque ERR_MODULE_NOT_FOUND this helper exists to replace.
if (built.has(repoRoot)) {
assertEmitted(paths, exists, 0);
return paths;
}
log("building test-servers...");
const status = run(repoRoot);
assertEmitted(paths, exists, status);
built.add(repoRoot);
return paths;
}
/** Throw the shared actionable message unless tsc succeeded and emitted each path. */
function assertEmitted(paths, exists, status) {
const missing = paths.filter((p) => !exists(p));
if (status !== 0 || missing.length) {
throw new Error(buildFailureMessage(missing));
}
}
/** Test-only: forget which roots this process has built. */
export function resetTestServerBuildCache() {
built.clear();
}
/** Run the root-installed tsc over `test-servers`; returns its exit status. */
function runTsc(repoRoot) {
const r = spawnSync(
process.execPath,
[
resolveNodeBin("typescript", "tsc", repoRoot),
"-p",
"test-servers",
"--noCheck",
],
{ cwd: repoRoot, stdio: "inherit" },
);
return r.status;
}

View file

@ -0,0 +1,159 @@
/**
* Unit tests for the shared test-server build helper (#2111).
*
* Every consumer's happy path builds successfully, so from a smoke's point of
* view each failure branch below is dead code — and the *invariant* the helper
* exists for (that it builds even when the output is already there) is one no
* consumer can assert about itself, because a smoke that drove a stale fixture
* would report a product failure rather than a staleness one. That is exactly
* the defect this replaced: an `existsSync` early return that made the check
* silently measure the previous build.
*
* `run` and `exists` are injectable precisely so this can be driven without
* spawning tsc.
*/
import assert from "node:assert/strict";
import { join } from "node:path";
import { beforeEach, describe, it } from "node:test";
import {
TEST_SERVER_ENTRIES,
buildFailureMessage,
ensureTestServers,
resetTestServerBuildCache,
testServerEntryPath,
} from "./ensure-test-servers.mjs";
const ROOT = "/repo";
/** A helper invocation with the spawn and the fs stubbed out. */
function callEnsure({
repoRoot = ROOT,
requires = ["stdio"],
status = 0,
present = () => true,
calls = [],
logs = [],
} = {}) {
return ensureTestServers({
repoRoot,
label: "test",
requires,
log: (m) => logs.push(m),
run: (root) => {
calls.push(root);
return status;
},
exists: present,
});
}
beforeEach(() => resetTestServerBuildCache());
describe("testServerEntryPath", () => {
it("resolves each known entry under test-servers/build", () => {
for (const [name, file] of Object.entries(TEST_SERVER_ENTRIES)) {
assert.equal(
testServerEntryPath(ROOT, name),
join(ROOT, "test-servers", "build", file),
);
}
});
it("throws on an unknown entry rather than resolving a bogus path", () => {
assert.throws(
() => testServerEntryPath(ROOT, "nope"),
/unknown test-server entry "nope"/,
);
});
});
describe("ensureTestServers", () => {
it("builds even when every required entry is already on disk", () => {
// The whole point of #2111: presence is not freshness.
const calls = [];
callEnsure({ present: () => true, calls });
assert.deepEqual(calls, [ROOT]);
});
it("returns the required entry paths, in the order requested", () => {
const paths = callEnsure({ requires: ["http", "stdio"] });
assert.deepEqual(paths, [
testServerEntryPath(ROOT, "http"),
testServerEntryPath(ROOT, "stdio"),
]);
});
it("builds once per process per repo root", () => {
// pack-and-verify asks twice; one tsc pass emits both entries.
const calls = [];
callEnsure({ requires: ["stdio"], calls });
const paths = callEnsure({ requires: ["composable"], calls });
assert.deepEqual(calls, [ROOT]);
// The second call still resolves its own entries.
assert.deepEqual(paths, [testServerEntryPath(ROOT, "composable")]);
});
it("still asserts existence on a cache hit that names a new entry", () => {
// The build is cached; the assertion is not. A second caller asking for an
// entry the first didn't must not be handed a path tsc never emitted.
const absent = testServerEntryPath(ROOT, "composable");
const present = (p) => p !== absent;
callEnsure({ requires: ["stdio"], present });
assert.throws(
() => callEnsure({ requires: ["composable"], present }),
(e) => e.message.includes(absent),
);
});
it("builds again for a different repo root", () => {
const calls = [];
callEnsure({ calls });
callEnsure({ repoRoot: "/other", calls });
assert.deepEqual(calls, [ROOT, "/other"]);
});
it("logs through the caller's logger, unprefixed", () => {
// pack-and-verify passes its own `step`, which adds "pack:verify — ".
const logs = [];
callEnsure({ logs });
assert.deepEqual(logs, ["building test-servers..."]);
});
it("throws naming the missing entry when tsc emits nothing", () => {
const missing = testServerEntryPath(ROOT, "composable");
assert.throws(
() =>
callEnsure({
requires: ["composable"],
present: (p) => p !== missing,
}),
(e) =>
e.message.includes(missing) &&
e.message.includes("npm run test-servers:build"),
);
});
it("throws on a non-zero tsc exit even when the entries exist", () => {
// A stale build left over from a previous run must not pass a failed tsc.
assert.throws(() => callEnsure({ status: 1 }), /tsc exited non-zero/);
});
it("does not mark a failed build as done", () => {
const calls = [];
assert.throws(() => callEnsure({ status: 1, calls }));
assert.throws(() => callEnsure({ status: 1, calls }));
assert.deepEqual(calls, [ROOT, ROOT]);
});
});
describe("buildFailureMessage", () => {
it("always points at the command that reproduces it", () => {
assert.match(buildFailureMessage([]), /npm run test-servers:build/);
});
it("lists every missing entry", () => {
const message = buildFailureMessage(["/a.js", "/b.js"]);
assert.match(message, /missing \/a\.js, \/b\.js/);
});
});

View file

@ -29,12 +29,13 @@
* smoke-web-browser.mjs; see its header.
*/
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { createRequire } from "node:module";
import { join } from "node:path";
import { startAnnouncedChild } from "./announced-child.mjs";
import { resolveNodeBin } from "./resolve-node-bin.mjs";
import {
ensureTestServers,
testServerEntryPath,
} from "./ensure-test-servers.mjs";
/** The App tool the `mcp-app-http.json` fixture serves. */
export const APP_TOOL = "mcp_app_demo";
@ -51,7 +52,7 @@ export const FATAL_CONSOLE =
/** Path to the composable test server build, relative to the repo root. */
export function composableServerPath(repoRoot) {
return join(repoRoot, "test-servers", "build", "server-composable.js");
return testServerEntryPath(repoRoot, "composable");
}
/** Path to a `test-servers/configs/<name>.json` fixture. */
@ -71,31 +72,15 @@ export function sandboxProxyPageFor(webBuildDir) {
}
/**
* Build the composable test server bundle if it isn't present yet.
*
* The root-installed tsc is run via this Node — `npx` is a `.cmd` shim on
* Windows that a shell-free spawnSync can't start (ENOENT — #1939).
* Build the composable test server bundle. Unconditional per process — see
* `ensure-test-servers.mjs` for why presence is not freshness (#2111).
*/
export function ensureComposableTestServer(repoRoot, label) {
const entry = composableServerPath(repoRoot);
if (existsSync(entry)) return entry;
console.log(`${label} — building test-servers (missing build output)...`);
const r = spawnSync(
process.execPath,
[
resolveNodeBin("typescript", "tsc", repoRoot),
"-p",
"test-servers",
"--noCheck",
],
{ cwd: repoRoot, stdio: "inherit" },
);
if (r.status !== 0 || !existsSync(entry)) {
throw new Error(
"could not build the test servers (test-servers/build/server-composable.js). " +
"Run `npm run test-servers:build` from clients/web.",
);
}
const [entry] = ensureTestServers({
repoRoot,
label,
requires: ["composable"],
});
return entry;
}

View file

@ -56,11 +56,14 @@ import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { hasExited, removeSafe, stopChild } from "./lib/child-cleanup.mjs";
import {
ensureTestServers,
testServerEntryPath,
} from "./lib/ensure-test-servers.mjs";
import {
APP_TOOL,
attachPageDiagnostics,
buildAppDeepLink,
composableServerPath,
driveAppFlow,
loadChromium,
startMcpAppServer,
@ -68,13 +71,7 @@ import {
import { winShellArgs } from "./lib/win-shell-args.mjs";
const repoRoot = resolve(import.meta.dirname, "..");
const testServer = join(
repoRoot,
"test-servers",
"build",
"test-server-stdio.js",
);
const composableServer = composableServerPath(repoRoot);
const testServer = testServerEntryPath(repoRoot, "stdio");
// 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.
@ -146,22 +143,23 @@ function runInherit(command, args, cwd = repoRoot) {
}
/**
* Build the bundled test servers if they aren't present yet — both the stdio
* entry (step 4b) and the composable one (step 5). One `tsc -p test-servers`
* emits both, so this builds once and checks each entry.
* Build the bundled test servers — both the stdio entry (step 4b) and the
* composable one (step 5). One `tsc -p test-servers` emits both, and the shared
* helper is unconditional-per-process, so the second call here is a no-op while
* a fresh run always picks up an edit to `test-servers/src` (#2111). It also
* runs tsc through `process.execPath` rather than the `npx` this used to shell
* out to, which is what made this one script unrunnable on Windows (#1939).
*/
function ensureTestServer() {
if (existsSync(testServer) && existsSync(composableServer)) return;
step("building test-servers (missing build output)...");
const status = runInherit("npx", ["tsc", "-p", "test-servers", "--noCheck"]);
if (
status !== 0 ||
!existsSync(testServer) ||
!existsSync(composableServer)
) {
fail(
"could not build the test servers (test-servers/build/{test-server-stdio,server-composable}.js)",
);
try {
ensureTestServers({
repoRoot,
label: "pack:verify",
log: step,
requires: ["stdio", "composable"],
});
} catch (e) {
fail(e.message);
}
}

View file

@ -35,8 +35,9 @@
* 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 stdio test server
* (`test-servers/build`) is built on demand here if missing.
* (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";
@ -44,56 +45,15 @@ 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 { resolveNodeBin } from "./lib/resolve-node-bin.mjs";
import { ensureTestServers } from "./lib/ensure-test-servers.mjs";
const repoRoot = resolve(import.meta.dirname, "..");
const launcher = join(repoRoot, "clients", "launcher", "build", "index.js");
const testServer = join(
repoRoot,
"test-servers",
"build",
"test-server-stdio.js",
);
const httpTestServerModule = join(
repoRoot,
"test-servers",
"build",
"test-server-http.js",
);
function fail(message) {
console.error(`smoke:cli FAILED — ${message}`);
process.exit(1);
}
/** Build the bundled test servers (stdio + http) if they aren't present yet. */
function ensureTestServer() {
if (existsSync(testServer) && existsSync(httpTestServerModule)) return;
console.log("smoke:cli — building test-servers (missing build output)...");
// The root-installed tsc, run via this Node — `npx` is a `.cmd` shim on
// Windows that a shell-free spawnSync can't start (ENOENT — #1939).
const r = spawnSync(
process.execPath,
[
resolveNodeBin("typescript", "tsc", repoRoot),
"-p",
"test-servers",
"--noCheck",
],
{ cwd: repoRoot, stdio: "inherit" },
);
if (
r.status !== 0 ||
!existsSync(testServer) ||
!existsSync(httpTestServerModule)
) {
fail(
"could not build the test servers (test-servers/build/test-server-stdio.js, " +
"test-server-http.js). Run `npm run test-servers:build` from clients/cli.",
);
}
}
// 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
@ -136,7 +96,17 @@ function runCliAsync(args, extraEnv = {}) {
if (!existsSync(launcher)) {
fail(`launcher build not found at ${launcher} — run \`npm run build\` first`);
}
ensureTestServer();
// 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 {
@ -341,9 +311,7 @@ try {
pathToFileURL(httpTestServerModule).href
);
const { createEchoTool, createTestServerInfo } = await import(
pathToFileURL(
join(repoRoot, "test-servers", "build", "test-server-fixtures.js"),
).href
pathToFileURL(fixturesModule).href
);
const httpServer = createTestServerHttp({
serverInfo: createTestServerInfo(),

View file

@ -16,24 +16,19 @@
*
* Expects `clients/launcher/build` and `clients/tui/build` to be built first
* (the validate / CI ordering guarantees this). The bundled stdio test server
* (`test-servers/build`) is built on demand here if missing.
* (`test-servers/build`) is 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 { spawn } from "node:child_process";
import { mkdtempSync, existsSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { removeSafe } from "./lib/child-cleanup.mjs";
import { resolveNodeBin } from "./lib/resolve-node-bin.mjs";
import { ensureTestServers } from "./lib/ensure-test-servers.mjs";
const repoRoot = resolve(import.meta.dirname, "..");
const launcher = join(repoRoot, "clients", "launcher", "build", "index.js");
const testServer = join(
repoRoot,
"test-servers",
"build",
"test-server-stdio.js",
);
const RENDER_MARKER = "MCP Servers";
const TIMEOUT_MS = Number(process.env.SMOKE_TUI_TIMEOUT_MS ?? 15000);
@ -42,29 +37,6 @@ function fail(message) {
process.exit(1);
}
function ensureTestServer() {
if (existsSync(testServer)) return;
console.log("smoke:tui — building test-servers (missing build output)...");
// The root-installed tsc, run via this Node — `npx` is a `.cmd` shim on
// Windows that a shell-free spawnSync can't start (ENOENT — #1939).
const r = spawnSync(
process.execPath,
[
resolveNodeBin("typescript", "tsc", repoRoot),
"-p",
"test-servers",
"--noCheck",
],
{ cwd: repoRoot, stdio: "inherit" },
);
if (r.status !== 0 || !existsSync(testServer)) {
fail(
"could not build the stdio test server (test-servers/build/test-server-stdio.js). " +
"Run `npm run test-servers:build` from clients/cli.",
);
}
}
// The Ink TUI requires a real TTY for raw-mode keyboard input. Headless CI has
// none, so the app throws "Raw mode is not supported" on mount and exits before
// it can render its first frame — making this boot/render check inherently a
@ -81,7 +53,17 @@ if (process.env.CI) {
if (!existsSync(launcher)) {
fail(`launcher build not found at ${launcher} — run \`npm run build\` first`);
}
ensureTestServer();
// Rebuilt on every run — presence is not freshness (#2111).
let testServer;
try {
[testServer] = ensureTestServers({
repoRoot,
label: "smoke:tui",
requires: ["stdio"],
});
} catch (e) {
fail(e.message);
}
const work = mkdtempSync(join(tmpdir(), "smoke-tui-"));
const catalogPath = join(work, "catalog.json");

View file

@ -42,8 +42,11 @@
* would only find later.
*
* Expects `clients/web/dist` and `clients/launcher/build` to be built first —
* the validate / CI ordering guarantees this. `test-servers/build` is built on
* demand if missing, as in smoke:cli.
* the validate / CI ordering guarantees this. `test-servers/build` is rebuilt on
* every run, as in smoke:cli — see `scripts/lib/ensure-test-servers.mjs` for why
* presence isn't freshness. This smoke is the one that found that out: a
* `v2/main` merge touching `preset-registry.ts` made it report the dedicated
* app-origin path as broken when it was really driving the pre-merge fixture.
*/
import { existsSync } from "node:fs";

View file

@ -27,29 +27,28 @@
* length in smoke-web-browser.mjs.
*
* Expects `clients/web/dist` and `clients/launcher/build` to be built first.
* `test-servers/build` is built on demand if missing, as in smoke:web:app.
* `test-servers/build` is rebuilt on every run, as in smoke:web:app — see
* `scripts/lib/ensure-test-servers.mjs` for why presence isn't freshness.
*/
import { spawn, spawnSync } from "node:child_process";
import { existsSync, mkdirSync } from "node:fs";
import { spawn } from "node:child_process";
import { mkdirSync } from "node:fs";
import { createRequire } from "node:module";
import { setTimeout as delay } from "node:timers/promises";
import { join, resolve } from "node:path";
import { startProdWebServer } from "./lib/prod-web-server.mjs";
import { stopChild } from "./lib/child-cleanup.mjs";
import { resolveNodeBin } from "./lib/resolve-node-bin.mjs";
import {
ensureTestServers,
testServerEntryPath,
} from "./lib/ensure-test-servers.mjs";
const repoRoot = resolve(import.meta.dirname, "..");
const requireFromWeb = createRequire(
resolve(repoRoot, "clients/web/package.json"),
);
const composableServer = join(
repoRoot,
"test-servers",
"build",
"server-composable.js",
);
const composableServer = testServerEntryPath(repoRoot, "composable");
const configPath = (name) =>
join(repoRoot, "test-servers", "configs", `${name}.json`);
@ -97,30 +96,6 @@ async function fail(message) {
process.exit(1);
}
/** Build the composable test server bundle if it isn't present yet. */
function ensureTestServer() {
if (existsSync(composableServer)) return;
console.log(
"smoke:web:elicit — building test-servers (missing build output)...",
);
const r = spawnSync(
process.execPath,
[
resolveNodeBin("typescript", "tsc", repoRoot),
"-p",
"test-servers",
"--noCheck",
],
{ cwd: repoRoot, stdio: "inherit" },
);
if (r.status !== 0 || !existsSync(composableServer)) {
throw new Error(
"could not build the test servers (test-servers/build/server-composable.js). " +
"Run `npm run test-servers:build` from clients/web.",
);
}
}
/**
* Spawn a composable test server and wait for the URL it announces.
*
@ -229,7 +204,12 @@ async function runTool(page) {
}
try {
ensureTestServer();
// Rebuilt on every run — presence is not freshness (#2111).
ensureTestServers({
repoRoot,
label: "smoke:web:elicit",
requires: ["composable"],
});
const appUrl = await startMcpServer("app-elicitation-http");
const nativeUrl = await startMcpServer("app-elicitation-native-http");
await web.waitForReady();

View file

@ -4,6 +4,15 @@
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./build",
// The script consumers rebuild this project on every run (#2111), so the
// up-to-date case has to be cheap.
"incremental": true,
// Pinned INSIDE the output dir. The default derives from `rootDir`, landing
// it at `test-servers/tsconfig.tsbuildinfo` — beside the config, outside
// `build/`. That would survive the `rm -rf test-servers/build` this project
// documents as the remedy for a deleted source file, leaving tsc convinced
// its outputs are up to date and emitting nothing.
"tsBuildInfoFile": "./build/tsconfig.tsbuildinfo",
"rootDir": "./src",
"declaration": false,
"sourceMap": true,