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>
160 lines
6.3 KiB
JavaScript
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;
|
|
}
|