diff --git a/scripts/lib/ensure-test-servers.mjs b/scripts/lib/ensure-test-servers.mjs index 6effc7fc..a273c7e8 100644 --- a/scripts/lib/ensure-test-servers.mjs +++ b/scripts/lib/ensure-test-servers.mjs @@ -92,9 +92,10 @@ const built = new Set(); * * 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. + * 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`) @@ -113,16 +114,29 @@ export function ensureTestServers({ exists = existsSync, }) { const paths = requires.map((name) => testServerEntryPath(repoRoot, name)); - if (built.has(repoRoot)) return paths; + + // 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)); } - built.add(repoRoot); - return paths; } /** Test-only: forget which roots this process has built. */ diff --git a/scripts/lib/ensure-test-servers.test.mjs b/scripts/lib/ensure-test-servers.test.mjs index 5f853e3e..08fb87fd 100644 --- a/scripts/lib/ensure-test-servers.test.mjs +++ b/scripts/lib/ensure-test-servers.test.mjs @@ -94,6 +94,18 @@ describe("ensureTestServers", () => { 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 }); diff --git a/test-servers/tsconfig.json b/test-servers/tsconfig.json index 6f746dc3..9c5d964c 100644 --- a/test-servers/tsconfig.json +++ b/test-servers/tsconfig.json @@ -5,9 +5,14 @@ "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. + // 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,