Files
suaveplan/scripts/migrate-vitest-configs.ts
2026-05-27 23:47:53 +01:00

211 lines
7.4 KiB
TypeScript

#!/usr/bin/env bun
/**
* Migrates every packages/<plugin>/<pkg>/vitest.config.ts to use
* `createTestConfig` from `@suaveplan/testing/config`. Also:
*
* - Adds `@suaveplan/testing` as a workspace devDependency where missing.
* - Removes the 81 per-package `tests/setup/polyfills.ts` files.
* - Heuristically detects async-heavy packages (those whose existing
* config sets `testTimeout: 30000` or higher) and preserves the timeout.
* - Heuristically detects DOM packages (env: happy-dom) and preserves
* `environment` + sets `fileParallelism: false`.
*
* Run:
* bun scripts/migrate-vitest-configs.ts # apply
* bun scripts/migrate-vitest-configs.ts --dry # preview
*/
import {
existsSync,
readdirSync,
readFileSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path";
const repoRoot = "/mnt/s/web/genesis";
const packagesRoot = join(repoRoot, "packages");
const TESTING_PKG = "@suaveplan/testing";
const TESTING_OWN_PATH = join(packagesRoot, "dev", "testing");
const dry = process.argv.includes("--dry");
interface Detected {
timeout: number;
hookTimeout?: number;
environment: "node" | "happy-dom" | "jsdom";
fileParallelism: boolean;
additionalSetupFiles: string[];
additionalIncludes: string[];
additionalCoverageExcludes: string[];
}
function detect(content: string): Detected {
const out: Detected = {
timeout: 5000,
environment: "node",
fileParallelism: true,
additionalSetupFiles: [],
additionalIncludes: [],
additionalCoverageExcludes: [],
};
const tm = content.match(/testTimeout:\s*(\d+)/);
if (tm?.[1] !== undefined) out.timeout = Number(tm[1]);
const hm = content.match(/hookTimeout:\s*(\d+)/);
if (hm?.[1] !== undefined) out.hookTimeout = Number(hm[1]);
if (/environment:\s*"happy-dom"/.test(content))
out.environment = "happy-dom";
else if (/environment:\s*"jsdom"/.test(content)) out.environment = "jsdom";
if (/fileParallelism:\s*false/.test(content)) out.fileParallelism = false;
const setupMatch = content.match(/setupFiles:\s*\[([^\]]*)\]/s);
if (setupMatch?.[1]) {
const entries = setupMatch[1]
.split(",")
.map((s) => s.trim().replace(/["']/g, ""))
.filter(Boolean)
.filter(
(p) =>
!p.endsWith("polyfills.ts") && !p.endsWith("polyfills.js")
);
out.additionalSetupFiles = entries;
}
const incMatch = content.match(/include:\s*\[([^\]]*)\]/s);
if (incMatch?.[1]) {
const entries = incMatch[1]
.split(",")
.map((s) => s.trim().replace(/["']/g, ""))
.filter(Boolean)
.filter(
(p) => p !== "src/**/*.test.ts" && p !== "tests/**/*.test.ts"
);
out.additionalIncludes = entries;
}
return out;
}
function buildConfig(d: Detected): string {
const opts: string[] = [];
if (d.environment !== "node") opts.push(`environment: "${d.environment}"`);
if (d.timeout !== 5000) opts.push(`testTimeout: ${d.timeout}`);
if (d.hookTimeout !== undefined && d.hookTimeout !== d.timeout)
opts.push(`hookTimeout: ${d.hookTimeout}`);
if (d.fileParallelism === false) opts.push("fileParallelism: false");
if (d.additionalSetupFiles.length > 0) {
opts.push(
`additionalSetupFiles: [${d.additionalSetupFiles.map((s) => `"${s}"`).join(", ")}]`
);
}
if (d.additionalIncludes.length > 0) {
opts.push(
`additionalIncludes: [${d.additionalIncludes.map((s) => `"${s}"`).join(", ")}]`
);
}
const optsBlock = opts.length === 0 ? "" : `{\n\t${opts.join(",\n\t")},\n}`;
const call =
optsBlock === ""
? "createTestConfig()"
: `createTestConfig(${optsBlock})`;
return `import { createTestConfig } from "@suaveplan/testing/config";\n\nexport default ${call};\n`;
}
function ensureTestingDep(pkgJsonPath: string): boolean {
const raw = readFileSync(pkgJsonPath, "utf8");
const pj = JSON.parse(raw);
const dev = pj.devDependencies ?? (pj.devDependencies = {});
if (dev[TESTING_PKG] !== undefined) return false;
dev[TESTING_PKG] = "workspace:^";
const sorted: Record<string, string> = {};
for (const k of Object.keys(dev).sort()) sorted[k] = dev[k];
pj.devDependencies = sorted;
if (!dry) writeFileSync(pkgJsonPath, `${JSON.stringify(pj, null, 2)}\n`);
return true;
}
function migrateOne(pkgDir: string): {
configChanged: boolean;
depAdded: boolean;
polyfillRemoved: boolean;
} {
const result = {
configChanged: false,
depAdded: false,
polyfillRemoved: false,
};
const cfgPath = join(pkgDir, "vitest.config.ts");
const pkgJsonPath = join(pkgDir, "package.json");
if (!existsSync(cfgPath) || !existsSync(pkgJsonPath)) return result;
if (pkgDir === TESTING_OWN_PATH) return result; // do not migrate the testing package itself
const original = readFileSync(cfgPath, "utf8");
const detected = detect(original);
const replacement = buildConfig(detected);
if (original.trim() !== replacement.trim()) {
if (!dry) writeFileSync(cfgPath, replacement);
result.configChanged = true;
}
if (ensureTestingDep(pkgJsonPath)) result.depAdded = true;
const polyfillPath = join(pkgDir, "tests", "setup", "polyfills.ts");
if (existsSync(polyfillPath)) {
if (!dry) rmSync(polyfillPath, { force: true });
result.polyfillRemoved = true;
}
const setupDir = join(pkgDir, "tests", "setup");
if (existsSync(setupDir) && readdirSync(setupDir).length === 0 && !dry) {
try {
rmSync(setupDir, { recursive: true });
} catch {}
}
return result;
}
function main(): void {
const counts = {
configChanged: 0,
depAdded: 0,
polyfillRemoved: 0,
scanned: 0,
};
const skipped: 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;
const cfgPath = join(pkgPath, "vitest.config.ts");
if (!existsSync(cfgPath)) {
skipped.push(`${plugin}/${pkg} — no vitest.config.ts`);
continue;
}
counts.scanned += 1;
const r = migrateOne(pkgPath);
if (r.configChanged) counts.configChanged += 1;
if (r.depAdded) counts.depAdded += 1;
if (r.polyfillRemoved) counts.polyfillRemoved += 1;
}
}
console.log(`Mode: ${dry ? "dry-run" : "execute"}`);
console.log(`Scanned vitest configs: ${counts.scanned}`);
console.log(`Config rewrites: ${counts.configChanged}`);
console.log(`Deps added: ${counts.depAdded}`);
console.log(`Polyfill files removed: ${counts.polyfillRemoved}`);
if (skipped.length > 0) {
console.log(`\nSkipped (${skipped.length}):`);
for (const s of skipped.slice(0, 10)) console.log(` ${s}`);
if (skipped.length > 10)
console.log(` ...and ${skipped.length - 10} more`);
}
console.log(dry ? "\n(no changes written)" : "\n✓ migration applied");
}
main();