scripts/check-web-tests.ts
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env bun
/**
* Typecheck: the web suite compiles under web's own strict config. Every
* diagnostic fails the check.
*
* `web/tsconfig.json` excludes `src/**\/*.test.ts(x)` and `src/**\/__tests__`,
* and its `include` never reaches `web/test/`, so `check:web` reads none of the
* suite, and `bun test` strips types. Web also resolves no Bun types there, so
* `bun:test` would not resolve and every `describe`/`expect` would be `any`. A
* prop, a context value, or an API type can then change shape while its tests
* keep passing against the old one.
*
* `web/tsconfig.test.json` extends web's config unchanged, so the suite meets
* the same strictness as the source, adds `types: ["bun"]`, and includes `src/`
* and `test/` whole. It runs web's own `tsc`, the version its lockfile pins.
*
* Typing a stub on the real signature keeps it honest: `mock<typeof
* ApiClient.fn>(…)` (with `import type * as ApiClient`) fails here when `fn`
* changes shape, where a mock typed from its inline body would not.
*
* ## Why it proves tsc actually ran
*
* A clean run and a run that analyzed nothing both print no diagnostics, so
* the pass condition is positive: `--listFiles` reports the program tsc built,
* and every `.ts`/`.tsx` under `web/src/` and `web/test/` must appear in it. A
* missing or unreadable project file, or an `include` that drifts off either
* tree, fails here rather than passing unchecked.
*/
import { join, relative } from "node:path";
import { fileURLToPath } from "node:url";
import { $, Glob } from "bun";
import { tscBinary, unlistedSources } from "./lib/tsc-paths.ts";
const ROOT = fileURLToPath(new URL("..", import.meta.url));
const WEB = join(ROOT, "web");
const PROJECT = "tsconfig.test.json";
/** The trees the project must cover, relative to `web/`. */
const TREES = ["src", "test"];
/** Any tsc diagnostic line. */
const DIAGNOSTIC = /error TS\d+:/;
/** Every `.ts`/`.tsx` under the trees the project must cover. */
async function listOnDisk(): Promise<string[]> {
const files: string[] = [];
for (const tree of TREES) {
for await (const rel of new Glob("**/*.{ts,tsx}").scan({ cwd: join(WEB, tree) })) {
files.push(join(WEB, tree, rel));
}
}
return files;
}
function fail(header: string, detail: string[]): never {
console.error(`✗ ${header}\n`);
for (const d of detail) console.error(` ${d}`);
process.exit(1);
}
async function main(): Promise<void> {
const tsc = tscBinary(WEB);
if (!(await Bun.file(tsc).exists())) {
fail("web/ has no TypeScript installed. Run `cd web && bun install` first.", []);
}
const result = await $`${tsc} -p ${PROJECT} --noEmit --pretty false --listFiles`
.cwd(WEB)
.nothrow()
.quiet();
const lines = `${result.stdout.toString()}${result.stderr.toString()}`.split("\n");
const diagnostics = lines.filter((l) => DIAGNOSTIC.test(l)).map((l) => `web/${l.trim()}`);
const onDisk = await listOnDisk();
if (onDisk.length === 0) {
fail(`Found no .ts/.tsx under web/${TREES.join(", web/")} — nothing checked.`, []);
}
const unanalyzed = unlistedSources(onDisk, lines).map((f) => relative(ROOT, f));
if (unanalyzed.length > 0) {
fail(
`web/${PROJECT} left ${unanalyzed.length} of ${onDisk.length} files unanalyzed — this gate did not check them.`,
[
...unanalyzed.slice(0, 5),
...(unanalyzed.length > 5 ? [`… and ${unanalyzed.length - 5} more`] : []),
...diagnostics.slice(0, 5),
],
);
}
if (diagnostics.length > 0) {
fail(`Found ${diagnostics.length} type error(s) in the web suite:`, [
...diagnostics,
"",
"A test typed against a shape the code no longer has still runs and passes,",
"asserting on a case the runtime cannot produce. Fix the test, not the type.",
]);
}
// A crash or a bad flag exits non-zero without a diagnostic line; say so
// rather than read the silence as clean.
if (result.exitCode !== 0) {
fail(
`tsc exited ${result.exitCode} with no diagnostics:`,
lines.filter((l) => l.trim() && !l.startsWith(ROOT)).slice(0, 10),
);
}
console.log(`✓ web suite typechecks clean (${onDisk.length} files under web/src and web/test)`);
}
main().catch((err: unknown) => {
console.error(err);
process.exit(1);
});