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:
cliffhall 2026-08-25 00:57:48 -04:00
commit e59452ea8c

View 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;
}