#!/usr/bin/env bun /** * Migrates every packages///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 = {}; 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();