220 lines
7.2 KiB
TypeScript
220 lines
7.2 KiB
TypeScript
#!/usr/bin/env bun
|
||
/**
|
||
* Layer 2 audit: scan every package's tests for opportunities to use
|
||
* @suaveplan/testing helpers (FakeClock, Factory, FixtureManager, MemoryStore)
|
||
* that are currently being hand-rolled.
|
||
*
|
||
* Heuristics (each one is a strong signal, not a guarantee):
|
||
*
|
||
* FakeClock — test files using `new Date(`, `Date.now(`, or
|
||
* `setTimeout(` without already importing `FakeClock`.
|
||
* Factory — test files with ≥3 occurrences of an inline literal
|
||
* object that has the shape of test data (id/name/etc).
|
||
* FixtureManager — test files with paired `beforeEach` + `afterEach`
|
||
* blocks doing setup+cleanup.
|
||
* MemoryStore — test files defining `const store = new Map(` or
|
||
* `const cache = {}` and treating it like a kv store.
|
||
*
|
||
* Output: a per-package report listing potential opportunities, NOT
|
||
* automatic edits. The fix list goes to stdout; the user reviews and
|
||
* applies selectively.
|
||
*
|
||
* Usage:
|
||
* bun scripts/audit-test-helper-usage.ts # full report
|
||
* bun scripts/audit-test-helper-usage.ts --json # machine-readable
|
||
* bun scripts/audit-test-helper-usage.ts --summary # counts only
|
||
*/
|
||
|
||
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||
import { join } from "node:path";
|
||
|
||
const repoRoot = "/mnt/s/web/genesis";
|
||
const packagesRoot = join(repoRoot, "packages");
|
||
|
||
interface Finding {
|
||
pkg: string;
|
||
file: string;
|
||
helper: "FakeClock" | "Factory" | "FixtureManager" | "MemoryStore";
|
||
signal: string;
|
||
line: number;
|
||
}
|
||
|
||
const args = process.argv.slice(2);
|
||
const jsonMode = args.includes("--json");
|
||
const summaryMode = args.includes("--summary");
|
||
|
||
function* walkTestFiles(dir: string): Generator<string> {
|
||
if (!existsSync(dir) || !statSync(dir).isDirectory()) return;
|
||
for (const name of readdirSync(dir)) {
|
||
const p = join(dir, name);
|
||
const s = statSync(p);
|
||
if (s.isDirectory()) {
|
||
if (
|
||
name === "node_modules" ||
|
||
name === "dist" ||
|
||
name === ".turbo" ||
|
||
name === "coverage"
|
||
)
|
||
continue;
|
||
yield* walkTestFiles(p);
|
||
} else if (
|
||
s.isFile() &&
|
||
(name.endsWith(".test.ts") || name.endsWith(".test.tsx"))
|
||
) {
|
||
yield p;
|
||
}
|
||
}
|
||
}
|
||
|
||
function scan(file: string, pkg: string): Finding[] {
|
||
const out: Finding[] = [];
|
||
const content = readFileSync(file, "utf8");
|
||
const lines = content.split("\n");
|
||
const usesFakeClock = /import\s+\{[^}]*\bFakeClock\b/.test(content);
|
||
const usesFactory = /import\s+\{[^}]*\b(Factory|defineFactory)\b/.test(
|
||
content
|
||
);
|
||
const usesFixtureMgr = /import\s+\{[^}]*\bFixtureManager\b/.test(content);
|
||
const usesMemoryStore = /import\s+\{[^}]*\bMemoryStore\b/.test(content);
|
||
|
||
let beforeEach = 0;
|
||
let afterEach = 0;
|
||
let dateNow = 0;
|
||
let newDate = 0;
|
||
let setTimeoutCount = 0;
|
||
let _mapAsStore = 0;
|
||
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const line = lines[i] ?? "";
|
||
if (/\bnew\s+Date\s*\(/.test(line)) {
|
||
if (newDate === 0 && !usesFakeClock) {
|
||
out.push({
|
||
pkg,
|
||
file,
|
||
helper: "FakeClock",
|
||
signal: "uses `new Date(` directly",
|
||
line: i + 1,
|
||
});
|
||
}
|
||
newDate += 1;
|
||
}
|
||
if (/\bDate\.now\s*\(/.test(line) && !usesFakeClock) {
|
||
if (dateNow === 0)
|
||
out.push({
|
||
pkg,
|
||
file,
|
||
helper: "FakeClock",
|
||
signal: "uses `Date.now()` directly",
|
||
line: i + 1,
|
||
});
|
||
dateNow += 1;
|
||
}
|
||
if (/\bsetTimeout\s*\(/.test(line) && !usesFakeClock) {
|
||
if (setTimeoutCount === 0)
|
||
out.push({
|
||
pkg,
|
||
file,
|
||
helper: "FakeClock",
|
||
signal: "uses real `setTimeout` (consider `FakeClock.tick`)",
|
||
line: i + 1,
|
||
});
|
||
setTimeoutCount += 1;
|
||
}
|
||
if (/\bbeforeEach\s*\(/.test(line)) beforeEach += 1;
|
||
if (/\bafterEach\s*\(/.test(line)) afterEach += 1;
|
||
if (
|
||
/(const|let)\s+\w+\s*[:=]\s*new\s+Map<.*string/.test(line) &&
|
||
!usesMemoryStore
|
||
) {
|
||
out.push({
|
||
pkg,
|
||
file,
|
||
helper: "MemoryStore",
|
||
signal: "hand-rolled Map<string,…> (consider `MemoryStore`)",
|
||
line: i + 1,
|
||
});
|
||
_mapAsStore += 1;
|
||
}
|
||
}
|
||
|
||
if (beforeEach >= 1 && afterEach >= 1 && !usesFixtureMgr) {
|
||
out.push({
|
||
pkg,
|
||
file,
|
||
helper: "FixtureManager",
|
||
signal: `paired beforeEach/afterEach × ${Math.min(beforeEach, afterEach)} (consider \`FixtureManager\`)`,
|
||
line: 0,
|
||
});
|
||
}
|
||
|
||
const literalCount = (content.match(/\{\s*id:\s*["'][^"']+["']/g) ?? [])
|
||
.length;
|
||
if (literalCount >= 3 && !usesFactory) {
|
||
out.push({
|
||
pkg,
|
||
file,
|
||
helper: "Factory",
|
||
signal: `${literalCount} inline {id: "…"} test-data literals (consider \`defineFactory\`)`,
|
||
line: 0,
|
||
});
|
||
}
|
||
|
||
return out;
|
||
}
|
||
|
||
function main(): void {
|
||
const findings: Finding[] = [];
|
||
const packagesScanned: string[] = [];
|
||
for (const plugin of readdirSync(packagesRoot)) {
|
||
const pluginPath = join(packagesRoot, plugin);
|
||
if (!statSync(pluginPath).isDirectory()) continue;
|
||
for (const pkg of readdirSync(pluginPath)) {
|
||
const pkgPath = join(pluginPath, pkg);
|
||
if (!statSync(pkgPath).isDirectory()) continue;
|
||
if (!existsSync(join(pkgPath, "package.json"))) continue;
|
||
const id = `${plugin}/${pkg}`;
|
||
packagesScanned.push(id);
|
||
for (const file of walkTestFiles(pkgPath)) {
|
||
findings.push(...scan(file, id));
|
||
}
|
||
}
|
||
}
|
||
|
||
if (jsonMode) {
|
||
process.stdout.write(
|
||
`${JSON.stringify({ packagesScanned: packagesScanned.length, findings }, null, 2)}\n`
|
||
);
|
||
return;
|
||
}
|
||
|
||
const byHelper = new Map<string, number>();
|
||
for (const f of findings)
|
||
byHelper.set(f.helper, (byHelper.get(f.helper) ?? 0) + 1);
|
||
|
||
console.log(`Scanned ${packagesScanned.length} packages.`);
|
||
console.log(`Findings: ${findings.length}`);
|
||
console.log("");
|
||
console.log("By helper:");
|
||
for (const [h, n] of [...byHelper.entries()].sort((a, b) => b[1] - a[1])) {
|
||
console.log(` ${h.padEnd(16)} ${n}`);
|
||
}
|
||
|
||
if (summaryMode) return;
|
||
|
||
const byPkg = new Map<string, Finding[]>();
|
||
for (const f of findings) {
|
||
if (!byPkg.has(f.pkg)) byPkg.set(f.pkg, []);
|
||
byPkg.get(f.pkg)?.push(f);
|
||
}
|
||
const ranked = [...byPkg.entries()].sort(
|
||
(a, b) => b[1].length - a[1].length
|
||
);
|
||
console.log("");
|
||
console.log("Top 20 packages by finding count:");
|
||
for (const [pkg, fs] of ranked.slice(0, 20)) {
|
||
console.log(` ${pkg.padEnd(40)} ${fs.length}`);
|
||
}
|
||
}
|
||
|
||
main();
|