diff --git a/src/commands/migrate.meta.ts b/src/commands/migrate.meta.ts index c21b769..ff43584 100644 --- a/src/commands/migrate.meta.ts +++ b/src/commands/migrate.meta.ts @@ -2,20 +2,25 @@ export const meta = { name: 'migrate', description: 'Migrate from a package to a more performant alternative.', args: { - 'dry-run': { + all: { type: 'boolean', default: false, - description: `Don't apply any fixes, only show what would change.` + description: 'Run all available migrations' }, - interactive: { + 'dry-run': { type: 'boolean', default: false, - description: 'Run in interactive mode.' + description: `Don't apply any fixes, only show what would change.` }, include: { type: 'string', default: '**/*.{ts,js}', description: 'Files to migrate' + }, + interactive: { + type: 'boolean', + default: false, + description: 'Run in interactive mode.' } } } as const; diff --git a/src/commands/migrate.ts b/src/commands/migrate.ts index fe3b4c6..36d218d 100644 --- a/src/commands/migrate.ts +++ b/src/commands/migrate.ts @@ -14,6 +14,7 @@ export async function run(ctx: CommandContext) { const dryRun = ctx.values['dry-run'] === true; const interactive = ctx.values.interactive === true; const include = ctx.values.include; + const all = ctx.values.all === true; const fileSystem = new LocalFileSystem(process.cwd()); const packageJson = await getPackageJson(fileSystem); @@ -37,6 +38,13 @@ export async function run(ctx: CommandContext) { .map((rep) => rep.from) ); + // If --all flag is used, add all available migrations + if (all) { + for (const target of fixableReplacementsTargets) { + targetModules.push(target); + } + } + if (interactive) { const additionalTargets = await prompts.autocompleteMultiselect({ message: 'Select packages to migrate', @@ -62,7 +70,7 @@ export async function run(ctx: CommandContext) { if (targetModules.length === 0) { prompts.cancel( - 'Error: Please specify a package to migrate. For example, `migrate chalk`' + 'Error: Please specify a package to migrate. For example, `migrate chalk` or use `--all` to migrate all available packages' ); return; } diff --git a/src/test/__snapshots__/migrate.test.ts.snap b/src/test/__snapshots__/migrate.test.ts.snap new file mode 100644 index 0000000..5df5eed --- /dev/null +++ b/src/test/__snapshots__/migrate.test.ts.snap @@ -0,0 +1,171 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`migrate command > should handle custom include pattern 1`] = ` +"e18e (cli ) + +┌ Migrating packages... +│ +│ Targets: chalk +│ +◇ {cwd}/lib/main.js... +│ +│ loading {cwd}/lib/main.js +│ loading {cwd}/lib/main.js +│ migrating chalk to picocolors +│ loading {cwd}/lib/main.js +│ migrating chalk to picocolors +│ writing {cwd}/lib/main.js +│ +◆ {cwd}/lib/main.js (1 migrated) +│ +└ Migration complete. + +" +`; + +exports[`migrate command > should handle custom include pattern 2`] = ` +"import * as pc from 'picocolors'; + +console.log(pc.cyan('I am _so_ cyan')); +" +`; + +exports[`migrate command > should handle interactive mode 1`] = ` +"e18e (cli ) + +┌ Migrating packages... +[?25l│ +◆ Select packages to migrate + +│ Search: _ +│ ◼ chalk +│ ↑/↓ to navigate • Space: select • Enter: confirm • Type: to search +└" +`; + +exports[`migrate command > should handle interactive mode 2`] = ` +"import chalk from 'chalk'; + +console.log(chalk.cyan('I am _so_ cyan')); +" +`; + +exports[`migrate command > should migrate specific package 1`] = ` +"e18e (cli ) + +┌ Migrating packages... +│ +│ Targets: chalk +│ +◇ {cwd}/lib/main.js... +│ +│ loading {cwd}/lib/main.js +│ loading {cwd}/lib/main.js +│ migrating chalk to picocolors +│ loading {cwd}/lib/main.js +│ migrating chalk to picocolors +│ writing {cwd}/lib/main.js +│ +◆ {cwd}/lib/main.js (1 migrated) +│ +└ Migration complete. + +" +`; + +exports[`migrate command > should migrate specific package 2`] = ` +"import * as pc from 'picocolors'; + +console.log(pc.cyan('I am _so_ cyan')); +" +`; + +exports[`migrate command > should migrate with --all flag 1`] = ` +"e18e (cli ) + +┌ Migrating packages... +│ +│ Targets: chalk +│ +◇ {cwd}/lib/main.js... +│ +│ loading {cwd}/lib/main.js +│ loading {cwd}/lib/main.js +│ migrating chalk to picocolors +│ loading {cwd}/lib/main.js +│ migrating chalk to picocolors +│ writing {cwd}/lib/main.js +│ +◆ {cwd}/lib/main.js (1 migrated) +│ +└ Migration complete. + +" +`; + +exports[`migrate command > should migrate with --all flag 2`] = ` +"import * as pc from 'picocolors'; + +console.log(pc.cyan('I am _so_ cyan')); +" +`; + +exports[`migrate command > should not modify files with --all flag in dry-run mode 1`] = ` +"e18e (cli ) + +┌ Migrating packages... +│ +│ Targets: chalk +│ +◇ {cwd}/lib/main.js... +│ +│ loading {cwd}/lib/main.js +│ loading {cwd}/lib/main.js +│ migrating chalk to picocolors +│ loading {cwd}/lib/main.js +│ migrating chalk to picocolors +│ writing {cwd}/lib/main.js +│ +◆ {cwd}/lib/main.js (1 migrated) +│ +└ Migration complete. + +" +`; + +exports[`migrate command > should not modify files with --all flag in dry-run mode 2`] = ` +"import chalk from 'chalk'; + +console.log(chalk.cyan('I am _so_ cyan')); +" +`; + +exports[`migrate command > should not modify files with specific package in dry-run mode 1`] = ` +"e18e (cli ) + +┌ Migrating packages... +│ +│ Targets: chalk +│ +◇ {cwd}/lib/main.js... +│ +│ loading {cwd}/lib/main.js +│ loading {cwd}/lib/main.js +│ migrating chalk to picocolors +│ loading {cwd}/lib/main.js +│ migrating chalk to picocolors +│ writing {cwd}/lib/main.js +│ +◆ {cwd}/lib/main.js (1 migrated) +│ +└ Migration complete. + +" +`; + +exports[`migrate command > should not modify files with specific package in dry-run mode 2`] = ` +"import chalk from 'chalk'; + +console.log(chalk.cyan('I am _so_ cyan')); +" +`; diff --git a/src/test/cli.test.ts b/src/test/cli.test.ts index a170d10..eebfbfc 100644 --- a/src/test/cli.test.ts +++ b/src/test/cli.test.ts @@ -1,17 +1,17 @@ import {describe, it, expect, beforeAll, afterAll} from 'vitest'; -import {spawn} from 'node:child_process'; import path from 'node:path'; import fs from 'node:fs/promises'; -import {createTempDir, cleanupTempDir, createTestPackage} from './utils.js'; +import { + createTempDir, + cleanupTempDir, + createTestPackage, + runCliProcess, + stripVersion +} from './utils.js'; import {pack as packAsTarball} from '@publint/pack'; let mockTarballPath: string; let tempDir: string; -const stripVersion = (str: string): string => - str.replace( - new RegExp(/\(cli v\d+\.\d+\.\d+(?:-\S+)?\)/, 'g'), - '(cli )' - ); beforeAll(async () => { // Create a temporary directory for the test package @@ -59,28 +59,6 @@ afterAll(async () => { await cleanupTempDir(tempDir); }); -function runCliProcess( - args: string[], - cwd?: string -): Promise<{stdout: string; stderr: string; code: number | null}> { - return new Promise((resolve) => { - const cliPath = path.resolve(__dirname, '../../lib/cli.js'); - const proc = spawn('node', [cliPath, ...args], { - env: process.env, - cwd: cwd || process.cwd() - }); - let stdout = ''; - let stderr = ''; - proc.stdout.on('data', (data) => (stdout += data.toString())); - proc.stderr.on('data', (data) => (stderr += data.toString())); - proc.on('error', (err) => { - stderr += String(err); - resolve({stdout, stderr, code: 1}); - }); - proc.on('close', (code) => resolve({stdout, stderr, code})); - }); -} - describe('CLI', () => { it('should run successfully with default options', async () => { const {stdout, stderr, code} = await runCliProcess( @@ -91,7 +69,7 @@ describe('CLI', () => { console.error('CLI Error:', stderr); } expect(code).toBe(0); - expect(stripVersion(stdout)).toMatchSnapshot(); + expect(await stripVersion(stdout, process.cwd())).toMatchSnapshot(); expect(stderr).toBe(''); }); @@ -101,7 +79,7 @@ describe('CLI', () => { tempDir ); expect(code).toBe(0); - expect(stripVersion(stdout)).toMatchSnapshot(); + expect(await stripVersion(stdout, process.cwd())).toMatchSnapshot(); expect(stderr).toMatchSnapshot(); }); }); diff --git a/src/test/migrate.test.ts b/src/test/migrate.test.ts new file mode 100644 index 0000000..e4b9f9f --- /dev/null +++ b/src/test/migrate.test.ts @@ -0,0 +1,125 @@ +import {describe, it, expect, beforeEach, afterEach} from 'vitest'; +import path from 'node:path'; +import fs from 'node:fs/promises'; +import { + createTempDir, + cleanupTempDir, + runCliProcess, + stripVersion +} from './utils.js'; + +describe('migrate command', () => { + let tempDir: string; + + beforeEach(async () => { + // Create a temporary directory for each test + tempDir = await createTempDir(); + + // Copy the basic-chalk fixture to the temp dir + const fixturePath = path.join(process.cwd(), 'test/fixtures/basic-chalk'); + await fs.cp(fixturePath, tempDir, {recursive: true}); + }); + + afterEach(async () => { + // Clean up the temporary directory after each test + await cleanupTempDir(tempDir); + }); + it('should migrate with --all flag', async () => { + const {stdout, stderr, code} = await runCliProcess( + ['migrate', '--all'], + tempDir + ); + + expect(code).toBe(0); + expect(await stripVersion(stdout, tempDir)).toMatchSnapshot(); + expect(stderr).toBe(''); + + // Check that the file was actually modified (chalk import should be replaced with picocolors) + const mainJsPath = path.join(tempDir, 'lib/main.js'); + const fileContent = await fs.readFile(mainJsPath, 'utf-8'); + expect(fileContent).toMatchSnapshot(); + }); + + it('should migrate specific package', async () => { + const {stdout, stderr, code} = await runCliProcess( + ['migrate', 'chalk'], + tempDir + ); + + expect(code).toBe(0); + expect(await stripVersion(stdout, tempDir)).toMatchSnapshot(); + expect(stderr).toBe(''); + + // Check that the file was actually modified (chalk import should be replaced with picocolors) + const mainJsPath = path.join(tempDir, 'lib/main.js'); + const fileContent = await fs.readFile(mainJsPath, 'utf-8'); + expect(fileContent).toMatchSnapshot(); + }); + + it('should handle interactive mode', async () => { + // Test interactive mode by providing input to the prompt + // Press Enter to accept the default selection + const {stdout, stderr, code} = await runCliProcess( + ['migrate', '--all', '--interactive'], + tempDir, + '\n' // Press Enter to accept default + ); + + expect(code).toBe(0); + expect(await stripVersion(stdout, tempDir)).toMatchSnapshot(); + expect(stderr).toBe(''); + + // Check that the file was actually modified + const mainJsPath = path.join(tempDir, 'lib/main.js'); + const fileContent = await fs.readFile(mainJsPath, 'utf-8'); + expect(fileContent).toMatchSnapshot(); + }); + + it('should handle custom include pattern', async () => { + const {stdout, stderr, code} = await runCliProcess( + ['migrate', 'chalk', '--include', '**/*.js'], + tempDir + ); + + expect(code).toBe(0); + expect(await stripVersion(stdout, tempDir)).toMatchSnapshot(); + expect(stderr).toBe(''); + + // Check that the file was actually modified + const mainJsPath = path.join(tempDir, 'lib/main.js'); + const fileContent = await fs.readFile(mainJsPath, 'utf-8'); + expect(fileContent).toMatchSnapshot(); + }); + + it('should not modify files with --all flag in dry-run mode', async () => { + const {stdout, stderr, code} = await runCliProcess( + ['migrate', '--all', '--dry-run'], + tempDir + ); + + expect(code).toBe(0); + expect(await stripVersion(stdout, tempDir)).toMatchSnapshot(); + expect(stderr).toBe(''); + + // Check that the file was NOT modified in dry-run mode + const mainJsPath = path.join(tempDir, 'lib/main.js'); + const fileContent = await fs.readFile(mainJsPath, 'utf-8'); + expect(fileContent).toMatchSnapshot(); + }); + + it('should not modify files with specific package in dry-run mode', async () => { + const {stdout, stderr, code} = await runCliProcess( + ['migrate', 'chalk', '--dry-run'], + tempDir + ); + + expect(code).toBe(0); + expect(await stripVersion(stdout, tempDir)).toMatchSnapshot(); + expect(stderr).toBe(''); + + // Check that the file was NOT modified in dry-run mode + const mainJsPath = path.join(tempDir, 'lib/main.js'); + const fileContent = await fs.readFile(mainJsPath, 'utf-8'); + expect(fileContent).toMatchSnapshot(); + }); +}); diff --git a/src/test/utils.ts b/src/test/utils.ts index 072de60..98e9831 100644 --- a/src/test/utils.ts +++ b/src/test/utils.ts @@ -1,6 +1,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import os from 'node:os'; +import {spawn} from 'node:child_process'; export interface TestPackage { name: string; @@ -72,3 +73,55 @@ export function createMockTarball(files: Array<{name: string; content: any}>) { rootDir: 'package' }; } + +export function runCliProcess( + args: string[], + cwd?: string, + input?: string +): Promise<{stdout: string; stderr: string; code: number | null}> { + return new Promise((resolve) => { + const cliPath = path.resolve(__dirname, '../../lib/cli.js'); + const proc = spawn('node', [cliPath, ...args], { + env: process.env, + cwd: cwd || process.cwd(), + stdio: input ? ['pipe', 'pipe', 'pipe'] : ['inherit', 'pipe', 'pipe'] + }); + let stdout = ''; + let stderr = ''; + if (proc.stdout) { + proc.stdout.on('data', (data) => (stdout += data.toString())); + } + if (proc.stderr) { + proc.stderr.on('data', (data) => (stderr += data.toString())); + } + proc.on('error', (err) => { + stderr += String(err); + resolve({stdout, stderr, code: 1}); + }); + proc.on('close', (code) => resolve({stdout, stderr, code})); + + // If input is provided, write it to stdin + if (input && proc.stdin) { + proc.stdin.write(input); + proc.stdin.end(); + } + }); +} + +const cachedRealPaths = new Map(); + +export const stripVersion = async ( + str: string, + cwd: string = process.cwd() +): Promise => { + const cwdRealPath = cachedRealPaths.get(cwd) ?? (await fs.realpath(cwd)); + cachedRealPaths.set(cwd, cwdRealPath); + + return str + .replace( + new RegExp(/\(cli v\d+\.\d+\.\d+(?:-\S+)?\)/, 'g'), + '(cli )' + ) + .replaceAll(cwdRealPath, '{cwd}') + .replaceAll(cwd, '{cwd}'); +}; diff --git a/test/fixtures/multi-deps/lib/main.js b/test/fixtures/multi-deps/lib/main.js new file mode 100644 index 0000000..5a3b2b7 --- /dev/null +++ b/test/fixtures/multi-deps/lib/main.js @@ -0,0 +1,7 @@ +import * as pc from 'picocolors'; +import objectAssign from 'object-assign'; +import isString from 'is-string'; + +console.log(pc.blue('Hello World')); +console.log(objectAssign({}, {a: 1})); +console.log(isString('test')); diff --git a/test/fixtures/multi-deps/package.json b/test/fixtures/multi-deps/package.json new file mode 100644 index 0000000..e654c87 --- /dev/null +++ b/test/fixtures/multi-deps/package.json @@ -0,0 +1,14 @@ +{ + "name": "multi-deps", + "type": "module", + "private": true, + "version": "0.0.1", + "main": "lib/main.js", + "dependencies": { + "chalk": "^4.0.0", + "object-assign": "^4.1.1" + }, + "devDependencies": { + "is-string": "^1.0.5" + } +}