platform-tooling/scripts/migrate-to-lixtest.ts
Quinn Ftw b034683777 chore(deps): 🔧 Update dependencies in package.json files
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-02-05 22:10:21 -08:00

757 lines
22 KiB
TypeScript

#!/usr/bin/env tsx
/**
* Migrate test scripts to lixtest
*
* Scans all package.json files in codebase/features/ and migrates their
* test scripts to use the unified lixtest CLI.
*
* Detection logic mirrors @lilith/lix-test/src/detect.ts but also handles
* .cjs/.mjs config extensions and dual-framework packages.
*
* Usage:
* bun tooling/scripts/migrate-to-lixtest.ts --dry-run
* bun tooling/scripts/migrate-to-lixtest.ts --package=marketplace/frontend-public
* bun tooling/scripts/migrate-to-lixtest.ts --batch=vitest
* bun tooling/scripts/migrate-to-lixtest.ts --batch=jest
* bun tooling/scripts/migrate-to-lixtest.ts --batch=playwright
* bun tooling/scripts/migrate-to-lixtest.ts # apply all
* bun tooling/scripts/migrate-to-lixtest.ts --json # output JSON report
*/
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { basename, dirname, join, relative, resolve } from 'node:path';
import { globSync } from 'glob';
import chalk from 'chalk';
import { PATHS } from '../configs/paths';
// =============================================================================
// Types
// =============================================================================
type TestFramework = 'vitest' | 'jest' | 'playwright';
interface DetectedFrameworks {
unit: TestFramework | null;
e2e: TestFramework | null;
reasons: string[];
}
interface PackageJson {
name?: string;
scripts?: Record<string, string>;
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
private?: boolean;
}
interface ScriptChange {
key: string;
oldValue: string;
newValue: string | null; // null means remove
}
interface MigrationPlan {
packageName: string;
packagePath: string;
relativePath: string;
frameworks: DetectedFrameworks;
alreadyMigrated: boolean;
changes: ScriptChange[];
preserved: string[];
}
interface MigrationOptions {
dryRun: boolean;
packageFilter: string | null;
batchFilter: TestFramework | null;
json: boolean;
verbose: boolean;
}
interface MigrationSummary {
total: number;
alreadyMigrated: number;
willMigrate: number;
skipped: number;
plans: MigrationPlan[];
}
// =============================================================================
// CLI Parsing
// =============================================================================
function parseArgs(): MigrationOptions {
const args = process.argv.slice(2);
const options: MigrationOptions = {
dryRun: false,
packageFilter: null,
batchFilter: null,
json: false,
verbose: false,
};
for (const arg of args) {
if (arg === '--dry-run') {
options.dryRun = true;
} else if (arg.startsWith('--package=')) {
options.packageFilter = arg.slice('--package='.length);
} else if (arg.startsWith('--batch=')) {
const batch = arg.slice('--batch='.length);
if (!['vitest', 'jest', 'playwright'].includes(batch)) {
console.error(chalk.red(`Invalid batch filter: ${batch}`));
console.error(chalk.dim('Valid options: vitest, jest, playwright'));
process.exit(1);
}
options.batchFilter = batch as TestFramework;
} else if (arg === '--json') {
options.json = true;
} else if (arg === '--verbose' || arg === '-v') {
options.verbose = true;
} else if (arg === '--help' || arg === '-h') {
printHelp();
process.exit(0);
} else {
console.error(chalk.red(`Unknown argument: ${arg}`));
printHelp();
process.exit(1);
}
}
return options;
}
function printHelp(): void {
console.log('');
console.log(chalk.bold('Usage:') + ' bun tooling/scripts/migrate-to-lixtest.ts [options]');
console.log('');
console.log(chalk.bold('Options:'));
console.log(' --dry-run Show changes without applying them');
console.log(' --package=<path> Migrate single package (relative to codebase/features/)');
console.log(' --batch=<framework> Migrate only packages using a specific framework');
console.log(' Valid: vitest, jest, playwright');
console.log(' --json Output migration plan as JSON');
console.log(' --verbose, -v Show detailed detection info');
console.log(' --help, -h Show this help');
console.log('');
console.log(chalk.bold('Examples:'));
console.log(' bun tooling/scripts/migrate-to-lixtest.ts --dry-run');
console.log(' bun tooling/scripts/migrate-to-lixtest.ts --package=email/backend-api');
console.log(' bun tooling/scripts/migrate-to-lixtest.ts --batch=vitest --dry-run');
console.log(' bun tooling/scripts/migrate-to-lixtest.ts --batch=jest');
console.log('');
}
// =============================================================================
// Framework Detection
// =============================================================================
const VITEST_CONFIG_FILES = ['vitest.config.ts', 'vitest.config.js', 'vitest.config.mts'];
const JEST_CONFIG_FILES = ['jest.config.js', 'jest.config.ts', 'jest.config.cjs', 'jest.config.mjs'];
const PLAYWRIGHT_CONFIG_FILES = ['playwright.config.ts', 'playwright.config.js'];
/**
* Detect test frameworks present in a package directory.
*
* Unlike lixtest's detect (which returns a single winner), this returns ALL
* detected frameworks categorized as unit vs e2e. This is essential for
* migration because many packages use vitest for unit + playwright for e2e.
*/
function detectFrameworks(packageDir: string): DetectedFrameworks {
const result: DetectedFrameworks = {
unit: null,
e2e: null,
reasons: [],
};
// Detect Playwright (E2E)
for (const configFile of PLAYWRIGHT_CONFIG_FILES) {
if (existsSync(join(packageDir, configFile))) {
result.e2e = 'playwright';
result.reasons.push(`${configFile} found at root`);
break;
}
}
// Check for e2e/ subdirectory with playwright config
if (!result.e2e) {
for (const configFile of PLAYWRIGHT_CONFIG_FILES) {
if (existsSync(join(packageDir, 'e2e', configFile))) {
result.e2e = 'playwright';
result.reasons.push(`e2e/${configFile} found`);
break;
}
}
}
// Check for e2e/tests directory (common playwright convention)
if (!result.e2e && existsSync(join(packageDir, 'e2e', 'tests'))) {
result.e2e = 'playwright';
result.reasons.push('e2e/tests directory found');
}
// Detect Vitest (Unit)
for (const configFile of VITEST_CONFIG_FILES) {
if (existsSync(join(packageDir, configFile))) {
result.unit = 'vitest';
result.reasons.push(`${configFile} found`);
break;
}
}
// Detect Jest (Unit) - only if vitest not already detected
if (!result.unit) {
for (const configFile of JEST_CONFIG_FILES) {
if (existsSync(join(packageDir, configFile))) {
result.unit = 'jest';
result.reasons.push(`${configFile} found`);
break;
}
}
}
// Fallback: check package.json dependencies
if (!result.unit && !result.e2e) {
try {
const pkgPath = join(packageDir, 'package.json');
if (existsSync(pkgPath)) {
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) as PackageJson;
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
if (allDeps['@playwright/test']) {
result.e2e = 'playwright';
result.reasons.push('@playwright/test in dependencies');
}
if (allDeps.vitest) {
result.unit = 'vitest';
result.reasons.push('vitest in dependencies');
}
if (!result.unit && allDeps.jest) {
result.unit = 'jest';
result.reasons.push('jest in dependencies');
}
}
} catch {
// Ignore package.json parse errors
}
}
// Special case: jest used for both unit AND e2e (NestJS pattern)
// Detected by jest-e2e.json or similar in test/ dir
if (result.unit === 'jest' && !result.e2e) {
const hasJestE2e =
existsSync(join(packageDir, 'test', 'jest-e2e.json')) ||
existsSync(join(packageDir, 'jest.config.e2e.js'));
if (hasJestE2e) {
result.e2e = 'jest';
result.reasons.push('jest e2e config found (test/jest-e2e.json or jest.config.e2e.js)');
}
}
return result;
}
// =============================================================================
// Script Mapping
// =============================================================================
/**
* Scripts that are custom/complex and should be preserved as-is.
* These typically involve docker, shell scripts, or multi-step commands.
*/
function isCustomScript(value: string): boolean {
return (
value.includes('docker') ||
value.includes('sh -c') ||
value.includes('bash ') ||
value.includes('&&') ||
value.includes('||') ||
value.includes('$') ||
value.includes('E2E_') ||
value.includes('pnpm run') ||
value.includes('bun run') ||
value.includes('setup-') ||
value.includes('teardown-') ||
value.includes('node --inspect')
);
}
/**
* Check if a test script is already using lixtest
*/
function isAlreadyLixtest(value: string): boolean {
return value.startsWith('lixtest') || value.includes('lixtest');
}
/**
* Generate the standard lixtest script set based on detected frameworks.
*
* The reference implementation (marketplace/frontend-public) defines:
* test → lixtest
* test:unit → lixtest --unit
* test:e2e → lixtest --e2e
* test:watch → lixtest --watch
* test:ui → lixtest --ui
* test:coverage → lixtest --coverage
* test:e2e:headed → lixtest --e2e --headed
* test:e2e:debug → lixtest --e2e --debug
*/
function generateLixtestScripts(frameworks: DetectedFrameworks): Record<string, string> {
const scripts: Record<string, string> = {};
const hasUnit = frameworks.unit !== null;
const hasE2e = frameworks.e2e !== null;
// Base test command
scripts['test'] = 'lixtest';
if (hasUnit) {
scripts['test:unit'] = 'lixtest --unit';
scripts['test:watch'] = 'lixtest --watch';
scripts['test:coverage'] = 'lixtest --coverage';
}
if (hasE2e) {
scripts['test:e2e'] = 'lixtest --e2e';
scripts['test:e2e:headed'] = 'lixtest --e2e --headed';
scripts['test:e2e:debug'] = 'lixtest --e2e --debug';
}
// UI is useful for both vitest and playwright
if (hasUnit || hasE2e) {
scripts['test:ui'] = 'lixtest --ui';
}
return scripts;
}
// =============================================================================
// Migration Planning
// =============================================================================
/**
* Find all package.json files in codebase/features/
*/
function findFeaturePackages(): string[] {
const pattern = join(PATHS.features, '**/package.json');
return globSync(pattern, {
absolute: true,
ignore: ['**/node_modules/**', '**/e2e/package.json', '**/dist/**'],
}).sort();
}
/**
* Create a migration plan for a single package
*/
function planMigration(packageJsonPath: string): MigrationPlan | null {
try {
const content = readFileSync(packageJsonPath, 'utf-8');
const pkg = JSON.parse(content) as PackageJson;
const packageDir = dirname(packageJsonPath);
const relativePath = relative(PATHS.features, packageDir);
const packageName = pkg.name || basename(packageDir);
const scripts = pkg.scripts || {};
// Detect frameworks
const frameworks = detectFrameworks(packageDir);
// Skip if no test framework detected
if (!frameworks.unit && !frameworks.e2e) {
return null;
}
// Check if already migrated
const testScript = scripts['test'];
const alreadyMigrated = testScript ? isAlreadyLixtest(testScript) : false;
if (alreadyMigrated) {
return {
packageName,
packagePath: packageDir,
relativePath,
frameworks,
alreadyMigrated: true,
changes: [],
preserved: [],
};
}
// Generate target scripts
const targetScripts = generateLixtestScripts(frameworks);
// Compute changes
const changes: ScriptChange[] = [];
const preserved: string[] = [];
const processedKeys = new Set<string>();
// Map existing test scripts to lixtest equivalents
for (const [key, value] of Object.entries(scripts)) {
if (!key.startsWith('test')) continue;
processedKeys.add(key);
// Preserve custom/complex scripts
if (isCustomScript(value)) {
preserved.push(`${key}: ${value}`);
continue;
}
// Check if we have a lixtest replacement
if (targetScripts[key]) {
if (value !== targetScripts[key]) {
changes.push({
key,
oldValue: value,
newValue: targetScripts[key],
});
}
} else {
// Existing test script with no direct lixtest equivalent.
// Map common patterns:
const mapped = mapLegacyScript(key, value, frameworks);
if (mapped) {
changes.push({
key,
oldValue: value,
newValue: mapped,
});
} else {
// Unknown script - preserve it
preserved.push(`${key}: ${value}`);
}
}
}
// Add new scripts that don't exist yet
for (const [key, value] of Object.entries(targetScripts)) {
if (!processedKeys.has(key) && !scripts[key]) {
changes.push({
key,
oldValue: '',
newValue: value,
});
}
}
return {
packageName,
packagePath: packageDir,
relativePath,
frameworks,
alreadyMigrated: false,
changes,
preserved,
};
} catch {
return null;
}
}
/**
* Map legacy test scripts to lixtest equivalents.
* Returns null if no mapping exists (script should be preserved).
*/
function mapLegacyScript(
key: string,
value: string,
frameworks: DetectedFrameworks,
): string | null {
// Common vitest patterns
if (value.startsWith('vitest run') || value === 'vitest run') {
if (key === 'test' || key === 'test:unit') return 'lixtest --unit';
if (key === 'test:all') return 'lixtest';
}
if (value === 'vitest' || value === 'vitest --watch') {
return 'lixtest --watch';
}
if (value.includes('vitest run --coverage') || value.includes('vitest --coverage')) {
return 'lixtest --coverage';
}
if (value === 'vitest run --passWithNoTests') {
return 'lixtest --unit';
}
// Common jest patterns
if (value === 'jest' || value === 'jest --runInBand') {
if (key === 'test') return 'lixtest';
if (key === 'test:unit') return 'lixtest --unit';
}
if (value === 'jest --watch') {
return 'lixtest --watch';
}
if (value === 'jest --coverage' || value.includes('jest --coverage')) {
return 'lixtest --coverage';
}
if (value.includes('jest --config') && (value.includes('e2e') || value.includes('E2E'))) {
return 'lixtest --e2e';
}
// Common playwright patterns
if (value === 'playwright test') {
return 'lixtest --e2e';
}
if (value === 'playwright test --ui') {
return 'lixtest --ui';
}
if (value === 'playwright test --headed') {
return 'lixtest --e2e --headed';
}
if (value === 'playwright test --debug') {
return 'lixtest --e2e --debug';
}
// test:cov → test:coverage alias
if (key === 'test:cov') {
return 'lixtest --coverage';
}
return null;
}
// =============================================================================
// Migration Execution
// =============================================================================
/**
* Apply a migration plan to a package.json file
*/
function applyMigration(plan: MigrationPlan): boolean {
const packageJsonPath = join(plan.packagePath, 'package.json');
try {
const content = readFileSync(packageJsonPath, 'utf-8');
const pkg = JSON.parse(content) as PackageJson;
if (!pkg.scripts) {
pkg.scripts = {};
}
for (const change of plan.changes) {
if (change.newValue === null) {
delete pkg.scripts[change.key];
} else {
pkg.scripts[change.key] = change.newValue;
}
}
// Write back with consistent formatting (2-space indent, trailing newline)
const output = JSON.stringify(pkg, null, 2) + '\n';
writeFileSync(packageJsonPath, output, 'utf-8');
return true;
} catch (error) {
console.error(
chalk.red(` Failed to apply migration to ${plan.relativePath}:`),
error instanceof Error ? error.message : String(error),
);
return false;
}
}
// =============================================================================
// Output
// =============================================================================
function printPlan(plan: MigrationPlan, verbose: boolean): void {
const nameDisplay = chalk.bold(plan.packageName);
const pathDisplay = chalk.dim(plan.relativePath);
if (plan.alreadyMigrated) {
console.log(` ${chalk.green('~')} ${nameDisplay} ${pathDisplay}`);
if (verbose) {
console.log(chalk.dim(' Already using lixtest'));
}
return;
}
if (plan.changes.length === 0) {
console.log(` ${chalk.gray('-')} ${nameDisplay} ${pathDisplay}`);
if (verbose) {
console.log(chalk.dim(' No changes needed'));
}
return;
}
console.log(` ${chalk.yellow('*')} ${nameDisplay} ${pathDisplay}`);
// Show detected frameworks
const fwParts: string[] = [];
if (plan.frameworks.unit) fwParts.push(`unit: ${plan.frameworks.unit}`);
if (plan.frameworks.e2e) fwParts.push(`e2e: ${plan.frameworks.e2e}`);
console.log(chalk.dim(` Detected: ${fwParts.join(', ')}`));
if (verbose) {
for (const reason of plan.frameworks.reasons) {
console.log(chalk.dim(` ${reason}`));
}
}
// Show changes
for (const change of plan.changes) {
if (change.oldValue) {
console.log(` ${chalk.red(`- "${change.key}": "${change.oldValue}"`)}`);
}
if (change.newValue) {
console.log(` ${chalk.green(`+ "${change.key}": "${change.newValue}"`)}`);
}
}
// Show preserved scripts
if (plan.preserved.length > 0 && verbose) {
console.log(chalk.dim(' Preserved (custom):'));
for (const p of plan.preserved) {
console.log(chalk.dim(` ${p}`));
}
}
}
function printSummary(summary: MigrationSummary, applied: boolean): void {
console.log('');
console.log(chalk.bold('Summary'));
console.log(chalk.dim('─'.repeat(50)));
console.log(` Total packages with tests: ${summary.total}`);
console.log(` Already using lixtest: ${chalk.green(String(summary.alreadyMigrated))}`);
console.log(` ${applied ? 'Migrated' : 'Will migrate'}: ${chalk.yellow(String(summary.willMigrate))}`);
if (summary.skipped > 0) {
console.log(` Skipped (no changes): ${chalk.gray(String(summary.skipped))}`);
}
console.log('');
}
function printJsonReport(summary: MigrationSummary): void {
const report = {
total: summary.total,
alreadyMigrated: summary.alreadyMigrated,
willMigrate: summary.willMigrate,
skipped: summary.skipped,
packages: summary.plans.map((plan) => ({
name: plan.packageName,
path: plan.relativePath,
frameworks: {
unit: plan.frameworks.unit,
e2e: plan.frameworks.e2e,
},
alreadyMigrated: plan.alreadyMigrated,
changes: plan.changes.map((c) => ({
script: c.key,
from: c.oldValue || null,
to: c.newValue,
})),
preserved: plan.preserved,
})),
};
console.log(JSON.stringify(report, null, 2));
}
// =============================================================================
// Main
// =============================================================================
async function main(): Promise<void> {
const options = parseArgs();
// Find packages
let packagePaths = findFeaturePackages();
// Apply filters
if (options.packageFilter) {
const filterPath = resolve(PATHS.features, options.packageFilter);
packagePaths = packagePaths.filter((p) => dirname(p) === filterPath);
if (packagePaths.length === 0) {
console.error(chalk.red(`No package found at: codebase/features/${options.packageFilter}`));
process.exit(1);
}
}
// Build migration plans
const allPlans: MigrationPlan[] = [];
for (const pkgPath of packagePaths) {
const plan = planMigration(pkgPath);
if (plan) {
allPlans.push(plan);
}
}
// Apply batch filter
let plans = allPlans;
if (options.batchFilter) {
plans = allPlans.filter((plan) => {
const fw = plan.frameworks;
return fw.unit === options.batchFilter || fw.e2e === options.batchFilter;
});
}
// Build summary
const summary: MigrationSummary = {
total: plans.length,
alreadyMigrated: plans.filter((p) => p.alreadyMigrated).length,
willMigrate: plans.filter((p) => !p.alreadyMigrated && p.changes.length > 0).length,
skipped: plans.filter((p) => !p.alreadyMigrated && p.changes.length === 0).length,
plans,
};
// JSON output
if (options.json) {
printJsonReport(summary);
return;
}
// Header
console.log('');
console.log(chalk.bold.cyan('━━━ lixtest Migration ━━━'));
if (options.dryRun) {
console.log(chalk.yellow.bold(' [DRY RUN] No changes will be applied'));
}
if (options.batchFilter) {
console.log(chalk.dim(` Filtered to: ${options.batchFilter} packages`));
}
console.log('');
// Print plans
for (const plan of plans) {
printPlan(plan, options.verbose);
}
// Apply changes (unless dry run)
if (!options.dryRun) {
const toMigrate = plans.filter((p) => !p.alreadyMigrated && p.changes.length > 0);
if (toMigrate.length > 0) {
console.log('');
console.log(chalk.bold('Applying migrations...'));
let successCount = 0;
let failCount = 0;
for (const plan of toMigrate) {
const success = applyMigration(plan);
if (success) {
successCount++;
console.log(` ${chalk.green('+')} ${plan.packageName}`);
} else {
failCount++;
}
}
console.log('');
console.log(` Applied: ${chalk.green(String(successCount))}`);
if (failCount > 0) {
console.log(` Failed: ${chalk.red(String(failCount))}`);
}
}
}
printSummary(summary, !options.dryRun);
if (options.dryRun && summary.willMigrate > 0) {
console.log(chalk.dim(' Run without --dry-run to apply changes.'));
console.log('');
}
}
main().catch((error) => {
console.error(chalk.red('Migration failed:'), error);
process.exit(1);
});