inspector/scripts/lib/ensure-test-servers.mjs
cliffhall 8d193461e3 fix(scripts): pin the tsbuildinfo inside build/, assert on cache hits
Two findings from review round 1, both real.

The default incremental cache path derives from `rootDir`, so it landed at
`test-servers/tsconfig.tsbuildinfo` — beside the config, OUTSIDE `build/`.
That survives 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. Pinned to `./build/tsconfig.tsbuildinfo`, and
verified that removing the output dir now forces a full re-emit.

The build is memoized per repo root; the existence assertion was memoized
with it. A later caller naming an entry the first did not would be handed a
path tsc never emitted, then fail with exactly the opaque
ERR_MODULE_NOT_FOUND the helper exists to replace. The assertion now runs on
every call.

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>
2026-08-25 01:11:10 -04:00

160 lines
6.3 KiB
JavaScript

/**
* 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 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;
}