chore(scripts): build test-servers unconditionally from one shared helper
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>
This commit is contained in:
parent
2b129087a3
commit
e59452ea8c
10 changed files with 391 additions and 164 deletions
17
AGENTS.md
17
AGENTS.md
|
|
@ -256,6 +256,19 @@ 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: a DELETED src file leaves its stale `.js`
|
||||
│ # behind, so `rm -rf test-servers/build` after one —
|
||||
│ # #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`
|
||||
|
|
@ -264,7 +277,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
|
||||
|
|
|
|||
|
|
@ -398,7 +398,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. |
|
||||
|
|
|
|||
146
scripts/lib/ensure-test-servers.mjs
Normal file
146
scripts/lib/ensure-test-servers.mjs
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
/**
|
||||
* 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: `--noCheck` emit leaves the stale `.js`
|
||||
* of a *deleted* `src` file behind, and no path here removes it. That is
|
||||
* unchanged from before (the old early-return only skipped the build; it never
|
||||
* cleaned either), and it is a loud failure rather than a silent one — an
|
||||
* import of a removed module fails at the consumer. 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` if you have
|
||||
* deleted a source file.
|
||||
*
|
||||
* 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 is a no-op. A fresh process
|
||||
* always rebuilds, which is the whole point — that is what makes an edit to
|
||||
* `test-servers/src` reach the next smoke.
|
||||
*
|
||||
* `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));
|
||||
if (built.has(repoRoot)) return paths;
|
||||
|
||||
log("building test-servers...");
|
||||
const status = run(repoRoot);
|
||||
const missing = paths.filter((p) => !exists(p));
|
||||
if (status !== 0 || missing.length) {
|
||||
throw new Error(buildFailureMessage(missing));
|
||||
}
|
||||
built.add(repoRoot);
|
||||
return paths;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
147
scripts/lib/ensure-test-servers.test.mjs
Normal file
147
scripts/lib/ensure-test-servers.test.mjs
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
/**
|
||||
* 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("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/);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -56,6 +56,10 @@ 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,
|
||||
|
|
@ -68,12 +72,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 testServer = testServerEntryPath(repoRoot, "stdio");
|
||||
const composableServer = composableServerPath(repoRoot);
|
||||
|
||||
// Mirrors INSPECTOR_API_TOKEN_GLOBAL in core/mcp/remote/constants.ts; kept as a
|
||||
|
|
@ -146,22 +145,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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 { spawn } from "node:child_process";
|
||||
import { existsSync, 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();
|
||||
|
|
|
|||
|
|
@ -4,6 +4,10 @@
|
|||
"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. `.tsbuildinfo` lands under `build/`,
|
||||
// which is gitignored.
|
||||
"incremental": true,
|
||||
"rootDir": "./src",
|
||||
"declaration": false,
|
||||
"sourceMap": true,
|
||||
|
|
|
|||
Loading…
Reference in a new issue