inspector/scripts/lib/ensure-test-servers.mjs
cliffhall 3222537e73 docs(scripts): correct the deleted-source caveat — it is silent, not loud
Round 3's suppressed finding, and it corrects a claim of mine that was
simply wrong. The header said a deleted source file's stale `.js` produces
"a loud failure rather than a silent one — an import of a removed module
fails at the consumer". It does not: the stale file is still there, so the
post-build existence checks pass against it and anything still importing
that module keeps silently running the old code. That is the SAME failure
class this PR exists to close, described as though it were the opposite.

Rewritten in the helper header and AGENTS.md to say the unconditional build
does not cover deletion, and to make `rm -rf test-servers/build` after a
delete or rename an instruction rather than an aside — which is now a real
clean, since the tsbuildinfo was pinned inside build/ in 8d19346.

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:37:40 -04:00

163 lines
6.6 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, 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;
}