From 788a928f0c195b86229d5c08d322af9af8279e34 Mon Sep 17 00:00:00 2001 From: Iddan Aaronsohn Date: Tue, 16 Dec 2025 16:38:06 +0200 Subject: [PATCH] Use jscodeshift --- README.md | 68 ++++-- src/runner.ts | 260 +++++----------------- src/transform.ts | 111 +++------ src/transforms/automock/2/to-suites-v3.ts | 14 +- src/types.ts | 2 - src/utils/ast-helpers.ts | 30 +-- src/utils/file-processor.ts | 147 ------------ test/integration/snapshot-tests.spec.ts | 20 +- test/parse-errors.spec.ts | 18 +- test/transforms/global-jest.spec.ts | 10 +- test/utils/transform-test-helper.ts | 91 ++++++++ 11 files changed, 275 insertions(+), 496 deletions(-) delete mode 100644 src/utils/file-processor.ts create mode 100644 test/utils/transform-test-helper.ts diff --git a/README.md b/README.md index d2ba7e0..ca975d4 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ npx @suites/codemod [options] ``` **Example:** + ```bash npx @suites/codemod automock/2/to-suites-v3 src/**/*.spec.ts ``` @@ -66,24 +67,25 @@ describe('UserService', () => { ### Arguments -| Argument | Description | Default | -|----------|-------------|---------| -| `codemod` | Codemod slug to run. See available transforms below. | - | -| `source` | Path to source files or directory to transform including glob patterns. | `.` | +| Argument | Description | Default | +| --------- | ----------------------------------------------------------------------- | ------- | +| `codemod` | Codemod slug to run. See available transforms below. | - | +| `source` | Path to source files or directory to transform including glob patterns. | `.` | ### Options -| Option | Description | Default | -|--------|-------------|---------| -| `-v, --version` | Output the current version | - | -| `-d, --dry` | Dry run (no changes are made to files) | `false` | -| `-f, --force` | Bypass Git safety checks and forcibly run codemods | `false` | -| `-p, --print` | Print transformed files to stdout, useful for development | `false` | -| `--verbose` | Show more information about the transform process | `false` | -| `--parser ` | Parser to use: `tsx`, `ts`, `babel` | `tsx` | -| `-h, --help` | Display help message | - | +| Option | Description | Default | +| ------------------- | --------------------------------------------------------- | ------- | +| `-v, --version` | Output the current version | - | +| `-d, --dry` | Dry run (no changes are made to files) | `false` | +| `-f, --force` | Bypass Git safety checks and forcibly run codemods | `false` | +| `-p, --print` | Print transformed files to stdout, useful for development | `false` | +| `--verbose` | Show more information about the transform process | `false` | +| `--parser ` | Parser to use: `tsx`, `ts`, `babel` | `tsx` | +| `-h, --help` | Display help message | - | **Examples:** + ```bash # Preview changes (dry run) npx @suites/codemod automock/2/to-suites-v3 src --dry @@ -105,6 +107,7 @@ npx @suites/codemod automock/2/to-suites-v3 src --parser babel Intelligently migrates Automock v2 test files to Suites v3 framework. **What it transforms:** + - Import statements: `@automock/jest` -> `@suites/unit` - TestBed API: `TestBed.create()` -> `TestBed.solitary().compile()` - Mock configuration: `.using()` -> `.impl()` or `.final()` @@ -124,6 +127,7 @@ The codemod automatically chooses between `.impl()` and `.final()`: **Validation:** Built-in validation ensures: + - No `@automock` imports remain - `TestBed.create()` is converted to `TestBed.solitary()` - `.compile()` is called with proper `await` @@ -139,15 +143,19 @@ Built-in validation ensures: ## Troubleshooting **"Working directory is not clean"** + - Commit your changes or use `--force` to bypass **"No files found"** + - Check your source path and ensure it contains `.ts` or `.tsx` files **Parser errors** + - Try the babel parser: `--parser babel` **Validation failed** + - Run with `--verbose` for detailed logs - Review validation errors in the output - Fix the issues reported by validators @@ -157,6 +165,7 @@ For more help, see [troubleshooting guide](https://github.com/suites-dev/codemod ## How It Works The codemod uses [jscodeshift](https://github.com/facebook/jscodeshift) to: + 1. Parse TypeScript/JavaScript into an Abstract Syntax Tree (AST) 2. Apply intelligent transformations (imports, TestBed API, mocks, types) 3. Validate the transformed code @@ -169,12 +178,14 @@ The codemod uses [jscodeshift](https://github.com/facebook/jscodeshift) to: This codemod follows the **Codemod Registry** pattern used by React, Next.js, and other major frameworks: **Transform Naming:** `//` + - `automock/2/to-suites-v3` - Current migration - `automock/3/to-suites-v4` - Future migrations - Supports multiple transforms per version - Extensible to other frameworks (e.g., `jest/28/to-v29`) **Directory Structure:** + ``` src/transforms/ automock/ # Framework namespace @@ -185,6 +196,7 @@ src/transforms/ ``` **Design Benefits:** + - No default transform - explicit selection prevents mistakes - Version-based organization supports migration chains - Framework namespacing allows multi-framework support @@ -204,7 +216,30 @@ Contributions welcome! To contribute: ### Adding New Transforms 1. Create transform directory: `src/transforms///.ts` -2. Export `applyTransform` function from your transform +2. **Export a default function** that follows jscodeshift's transform signature: + + ```typescript + import type { FileInfo, API, Options } from 'jscodeshift'; + import { transform } from '../../../transform'; + + export default transform; + ``` + + Or implement your own transform function: + + ```typescript + import type { FileInfo, API, Options } from 'jscodeshift'; + + export default function transform( + fileInfo: FileInfo, + api: API, + options: Options + ): string | null | undefined { + // Your transform logic here + return transformedCode; + } + ``` + 3. Register in `src/transforms/index.ts`: ```typescript { @@ -218,9 +253,12 @@ Contributions welcome! To contribute: 6. Update this README **Example:** + ```typescript // src/transforms/automock/3/to-suites-v4.ts -export { applyTransform } from '../../../transform'; +import { transform } from '../../../transform'; + +export default transform; ``` ### Project Structure diff --git a/src/runner.ts b/src/runner.ts index eb36fcb..bce3935 100644 --- a/src/runner.ts +++ b/src/runner.ts @@ -1,17 +1,11 @@ -import * as fs from 'fs/promises'; import * as path from 'path'; -import type { - CliOptions, - MigrationSummary, - TransformResult, - ValidationError, -} from './types'; +import { run } from 'jscodeshift/src/Runner'; +import type { CliOptions, MigrationSummary } from './types'; import type { Logger } from './utils/logger'; import type { TransformInfo } from './transforms'; -import { createFileProcessor, type FileInfo } from './utils/file-processor'; /** - * Run the transformation on the target path + * Run the transformation on the target path using jscodeshift's run function */ export async function runTransform( targetPath: string, @@ -19,54 +13,39 @@ export async function runTransform( options: CliOptions, logger: Logger ): Promise { - // Load the transform module dynamically - const transformModule = await import(transformInfo.path); - const applyTransform = transformModule.applyTransform; - - if (!applyTransform) { - throw new Error( - `Transform ${transformInfo.name} does not export applyTransform function` - ); - } - // Step 1: Discover files - logger.startSpinner('Discovering files..'); - - const fileProcessor = createFileProcessor({ - extensions: ['.ts', '.tsx'], - ignorePatterns: ['**/node_modules/**', '**/dist/**', '**/*.d.ts'], - }); - - const allFiles = await fileProcessor.discoverFiles(targetPath); - logger.succeedSpinner(`Found ${allFiles.length} files`); - - // Step 2: Transform files - logger.section('🔄 Transforming files...'); - const results: TransformResult[] = []; - let filesTransformed = 0; - let totalErrors = 0; - let totalWarnings = 0; - let sourceFilesCount = 0; - - for (const fileInfo of fileProcessor.filterSourceFiles(allFiles)) { - sourceFilesCount++; - const result = await transformFile( - fileInfo, - applyTransform, - options, - logger - ); - results.push(result); + // Get the path to the transform + // We need to use the compiled version in dist + // In development, __dirname points to dist/ after compilation + const transformPath = path.resolve(__dirname, transformInfo.path + '.js'); + + // Prepare jscodeshift options + // Pass our custom options through jscodeshift's options object + // When print is enabled, also enable dry mode to prevent file writes + const jscodeshiftOptions = { + dry: options.dry || options.print, + verbose: options.verbose ? 2 : 0, + extensions: 'ts,tsx', + ignorePattern: ['**/node_modules/**', '**/dist/**', '**/*.d.ts'], + parser: options.parser || 'ts', + runInBand: false, // Use parallel processing + babel: false, // Don't use babel parser + // Pass our custom options + allowCriticalErrors: options.allowCriticalErrors, + print: options.print, + }; - if (result.transformed) { - filesTransformed++; - } + // Step 1: Discover and transform files using jscodeshift + const jscodeshiftResults = await run( + transformPath, + [targetPath], + jscodeshiftOptions + ); - totalErrors += result.errors.length; - totalWarnings += result.warnings.length; - } + // Transform jscodeshift results to our format + const summary = transformJscodeshiftResults(jscodeshiftResults); // Check if any files were found - if (sourceFilesCount === 0) { + if (summary.filesProcessed === 0) { logger.warnSpinner('No source framework files found'); logger.info( 'No files contain source framework imports. Migration not needed.' @@ -74,177 +53,56 @@ export async function runTransform( return createEmptySummary(); } - logger.succeedSpinner( - `${sourceFilesCount} files contain source framework imports` - ); - logger.subsection( - `${allFiles.length - sourceFilesCount} files skipped (no source imports)` - ); logger.newline(); - // Step 3: Report summary - logger.newline(); + // Step 2: Report summary logger.section('📊 Migration Summary'); - if (filesTransformed > 0) { + if (summary.filesTransformed > 0) { logger.success( - `${filesTransformed} file${ - filesTransformed > 1 ? 's' : '' + `${summary.filesTransformed} file${ + summary.filesTransformed > 1 ? 's' : '' } transformed successfully` ); } - if (sourceFilesCount - filesTransformed > 0) { + if (summary.filesSkipped > 0) { logger.info( - ` ${sourceFilesCount - filesTransformed} file${ - sourceFilesCount - filesTransformed > 1 ? 's' : '' - } skipped (no changes needed)` + ` ${summary.filesSkipped} file${summary.filesSkipped > 1 ? 's' : ''} skipped (no changes needed)` ); } - if (totalWarnings > 0) { - logger.warn( - `${totalWarnings} warning${totalWarnings > 1 ? 's' : ''} found` + if (summary.errors > 0) { + logger.error( + `${summary.errors} error${summary.errors > 1 ? 's' : ''} found` ); } - if (totalErrors > 0) { - logger.error(`${totalErrors} error${totalErrors > 1 ? 's' : ''} found`); - } + return summary; +} - // Show detailed results if verbose - if (options.verbose) { - logger.newline(); - logger.subsection('Detailed Results:'); - results.forEach((result) => { - if (result.transformed) { - logger.success(` ${result.filePath}`); - result.changes.forEach((change) => logger.debug(` - ${change}`)); - } - if (result.warnings.length > 0) { - result.warnings.forEach((warning) => logger.warn(` ${warning}`)); - } - if (result.errors.length > 0) { - result.errors.forEach((error) => logger.error(` ${error}`)); - } - }); - } +/** + * Transform jscodeshift results into MigrationSummary format + */ +function transformJscodeshiftResults(jscodeshiftResults: { + ok?: number; + nochange?: number; + error?: number; + skip?: number; +}): MigrationSummary { + const filesTransformed = jscodeshiftResults.ok || 0; + const filesSkipped = jscodeshiftResults.nochange || 0; + const totalErrors = jscodeshiftResults.error || 0; + const filesProcessed = filesTransformed + filesSkipped + totalErrors; return { - filesProcessed: sourceFilesCount, + filesProcessed, filesTransformed, - filesSkipped: sourceFilesCount - filesTransformed, + filesSkipped, errors: totalErrors, - warnings: totalWarnings, - results, }; } -/** - * Transform a single file - */ -async function transformFile( - fileInfo: FileInfo, - applyTransform: ( - source: string, - options?: { skipValidation?: boolean; parser?: string } - ) => any, - options: CliOptions, - logger: Logger -): Promise { - const result: TransformResult = { - filePath: fileInfo.path, - transformed: false, - changes: [], - warnings: [], - errors: [], - }; - - try { - // Note: Preprocessing has been disabled because the parser fallback strategy - // now uses ts/tsx parsers first, which handle TypeScript syntax correctly. - // The preprocessing was breaking valid TypeScript generic syntax like: - // unitRef.get(ChargeService) - // into invalid syntax: - // unitRef.get((ChargeService) as ChargeService) - // - // If preprocessing is needed for specific edge cases, it should be done - // more carefully to avoid breaking valid TypeScript patterns. - - // Apply transformation - const transformOutput = applyTransform(fileInfo.source, { - parser: options.parser, - }); - - // Check if code actually changed - if (transformOutput.code === fileInfo.source) { - logger.debug( - ` ⊘ ${path.relative(process.cwd(), fileInfo.path)} (no changes)` - ); - return result; - } - - result.transformed = true; - - // Collect validation errors and warnings - transformOutput.validation.errors.forEach((err: ValidationError) => { - result.errors.push(`${err.rule}: ${err.message}`); - }); - - transformOutput.validation.warnings.forEach((warn: ValidationError) => { - result.warnings.push(`${warn.rule}: ${warn.message}`); - }); - - transformOutput.validation.criticalErrors.forEach( - (err: ValidationError) => { - result.errors.push(`[CRITICAL] ${err.rule}: ${err.message}`); - } - ); - - // Skip write if there are critical errors (unless explicitly allowed) - const hasCriticalErrors = - transformOutput.validation.criticalErrors.length > 0; - if (hasCriticalErrors && !options.allowCriticalErrors) { - logger.error( - ` ✗ ${path.relative( - process.cwd(), - fileInfo.path - )} (skipped due to critical errors)` - ); - result.changes.push('Skipped (critical validation errors)'); - return result; - } - - // Handle --print flag (output to stdout instead of writing) - if (options.print) { - logger.info(`\n${'='.repeat(60)}`); - logger.info(`File: ${fileInfo.path}`); - logger.info('='.repeat(60)); - console.log(transformOutput.code); - logger.info('='.repeat(60)); - result.changes.push('Printed to stdout'); - } else if (!options.dry) { - // Write transformed file - await fs.writeFile(fileInfo.path, transformOutput.code, 'utf-8'); - result.changes.push('File updated'); - logger.success(` ${path.relative(process.cwd(), fileInfo.path)}`); - } else { - // Dry run - just report what would change - result.changes.push('Would be updated (dry)'); - logger.info(` ~ ${path.relative(process.cwd(), fileInfo.path)} (dry)`); - } - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : 'Unknown error occurred'; - result.errors.push(errorMessage); - logger.error( - ` ${path.relative(process.cwd(), fileInfo.path)}: ${errorMessage}` - ); - } - - return result; -} - /** * Create an empty migration summary */ @@ -254,7 +112,5 @@ function createEmptySummary(): MigrationSummary { filesTransformed: 0, filesSkipped: 0, errors: 0, - warnings: 0, - results: [], }; } diff --git a/src/transform.ts b/src/transform.ts index ae0d1ed..40267bf 100644 --- a/src/transform.ts +++ b/src/transform.ts @@ -1,5 +1,4 @@ -import jscodeshift from 'jscodeshift'; -import type { AnalysisContext, TransformOutput } from './types'; +import type { AnalysisContext } from './types'; import { detectSuitesContext } from './analyzers/context-detector'; import { detectRetrievals } from './analyzers/retrieval-detector'; import { analyzeAllMockConfigurations } from './analyzers/stub-detector'; @@ -10,33 +9,16 @@ import { transformGlobalJest } from './transforms/global-jest-transformer'; import { cleanupObsoleteTypeCasts } from './transforms/cleanup-transformer'; import { validateTransformedCode } from './validators'; +import type { FileInfo, API } from 'jscodeshift'; + /** - * Main transformation orchestrator - * Applies all transformations in the correct order and validates the result + * Apply transformations using jscodeshift's standard transform signature + * This is the main transformation function that follows jscodeshift conventions */ -export function applyTransform( - source: string, - options?: { skipValidation?: boolean; parser?: string } -): TransformOutput { - // Input validation - if (typeof source !== 'string') { - throw new TypeError('Input must be a string'); - } - - if (source.length === 0) { - return { - code: source, - validation: { - success: true, - errors: [], - warnings: [], - criticalErrors: [], - }, - }; - } - - // Parse with fallback strategy - const { j, root } = parseSourceWithFallback(source, options?.parser); +export function transform(fileInfo: FileInfo, api: API): string { + const j = api.jscodeshift; + const root = j(fileInfo.source); + const source = fileInfo.source; // Phase 1: Analysis const context: AnalysisContext = { @@ -74,17 +56,31 @@ export function applyTransform( // Do this last to clean up any remaining type casts cleanupObsoleteTypeCasts(j, root); - // Phase 6: Post-transformation validation + // Phase 7: Post-transformation validation const transformedSource = root.toSource(); - const validation = options?.skipValidation - ? { success: true, errors: [], warnings: [], criticalErrors: [] } - : validateTransformedCode(j, j(transformedSource), transformedSource); + const validationResult = validateTransformedCode( + j, + j(transformedSource), + transformedSource + ); - return { - code: transformedSource, - validation, - }; + // Fail if critical errors are found + if (validationResult.criticalErrors.length > 0) { + const errorMessages = validationResult.criticalErrors + .map((error) => { + const location = error.line + ? ` (line ${error.line}${error.column ? `, column ${error.column}` : ''})` + : ''; + return ` - ${error.message}${location}`; + }) + .join('\n'); + throw new Error( + `Transformation failed with ${validationResult.criticalErrors.length} critical error(s):\n${errorMessages}` + ); + } + + return transformedSource; } /** @@ -100,48 +96,3 @@ function checkNeedsMockedImport(source: string): boolean { function checkNeedsUnitReferenceImport(source: string): boolean { return /:\s*UnitReference|/.test(source); } - -/** - * Parse source code with automatic fallback strategy - * Tries parsers in order: specified parser -> ts -> tsx -> babel - * This handles the 38 parse error cases from PARSE_ERRORS.md - * - * Note: We prefer TypeScript parsers (ts/tsx) over babel because: - * - Babel can parse TypeScript but may not handle all TS patterns correctly - * - For example, babel misses TestBed.create() calls (parses 5 instead of 6) - * - The ts/tsx parsers are more accurate for TypeScript code - */ -function parseSourceWithFallback( - source: string, - preferredParser?: string -): { j: jscodeshift.JSCodeshift; root: ReturnType } { - // Define parser priority - prefer TS parsers for better TypeScript support - const parserPriority = preferredParser - ? [preferredParser, 'ts', 'tsx', 'babel'] - : ['ts', 'tsx', 'babel']; - - // Remove duplicates while preserving order - const parsersToTry = [...new Set(parserPriority)]; - - let lastError: Error | null = null; - - for (const parser of parsersToTry) { - try { - const j = jscodeshift.withParser(parser); - const root = j(source); - - // Successfully parsed - return immediately - return { j, root }; - } catch (error) { - // Store error and continue to next parser - lastError = error instanceof Error ? error : new Error(String(error)); - continue; - } - } - - // All parsers failed - throw detailed error - throw new Error( - `Failed to parse source code with any available parser (tried: ${parsersToTry.join(', ')}). ` + - `Last error: ${lastError?.message || 'Unknown error'}` - ); -} diff --git a/src/transforms/automock/2/to-suites-v3.ts b/src/transforms/automock/2/to-suites-v3.ts index 59858ef..6c22075 100644 --- a/src/transforms/automock/2/to-suites-v3.ts +++ b/src/transforms/automock/2/to-suites-v3.ts @@ -1,13 +1,3 @@ -/** - * Automock to Suites Transform - * - * Migrates code from Automock testing framework to Suites. - * This includes: - * - Import transformations (@automock/* -> @suites/unit) - * - TestBed API changes (create -> solitary, async compile) - * - Mock configuration (.using -> .impl/.final) - * - Type transformations (jest.Mocked -> Mocked) - * - Cleanup of obsolete patterns - */ +import { transform } from '../../../transform'; -export { applyTransform } from '../../../transform'; +export default transform; diff --git a/src/types.ts b/src/types.ts index 5bb4b5d..1e4275a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -108,6 +108,4 @@ export interface MigrationSummary { filesTransformed: number; filesSkipped: number; errors: number; - warnings: number; - results: TransformResult[]; } diff --git a/src/utils/ast-helpers.ts b/src/utils/ast-helpers.ts index ca60fd9..b308978 100644 --- a/src/utils/ast-helpers.ts +++ b/src/utils/ast-helpers.ts @@ -1,12 +1,15 @@ -import type { JSCodeshift, Collection, ASTPath, Identifier, Node } from 'jscodeshift'; -import { isCallExpression, isMemberExpression, isIdentifier } from './type-guards'; - -/** - * Parse source code into AST - */ -export function parseSource(j: JSCodeshift, source: string): Collection { - return j(source); -} +import type { + JSCodeshift, + Collection, + ASTPath, + Identifier, + Node, +} from 'jscodeshift'; +import { + isCallExpression, + isMemberExpression, + isIdentifier, +} from './type-guards'; /** * Check if a node is an await expression @@ -21,7 +24,9 @@ export function isAwaitExpression(node: Node): boolean { export function isUnitRefVariable(node: Node): boolean { if (node.type === 'Identifier') { const name = (node as Identifier).name; - return name === 'unitRef' || name.includes('unitRef') || name.includes('ref'); + return ( + name === 'unitRef' || name.includes('unitRef') || name.includes('ref') + ); } return false; } @@ -136,10 +141,7 @@ export function isSinonStub(node: Node): boolean { /** * Get all call expressions in a node */ -export function getAllCallExpressions( - j: JSCodeshift, - node: Node -): Collection { +export function getAllCallExpressions(j: JSCodeshift, node: Node): Collection { return j(node as any).find(j.CallExpression); } diff --git a/src/utils/file-processor.ts b/src/utils/file-processor.ts deleted file mode 100644 index fafa8e0..0000000 --- a/src/utils/file-processor.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { readFileSync, writeFileSync, existsSync, statSync } from 'fs'; -import { resolve } from 'path'; -import { glob } from 'glob'; - -export interface FileProcessorOptions { - extensions: string[]; - ignorePatterns: string[]; - sourceImportPattern?: RegExp; -} - -export interface FileInfo { - path: string; - source: string; -} - -export class FileProcessor { - private options: FileProcessorOptions; - - constructor(options: Partial = {}) { - this.options = { - extensions: options.extensions || ['.ts', '.tsx'], - ignorePatterns: options.ignorePatterns || [ - '**/node_modules/**', - '**/dist/**', - '**/*.d.ts', - ], - }; - } - - /** - * Discover files to process based on the target path - */ - async discoverFiles(targetPath: string): Promise { - const absolutePath = resolve(targetPath); - - // Check if path exists - if (!existsSync(absolutePath)) { - throw new Error(`Path does not exist: ${targetPath}`); - } - - const stats = statSync(absolutePath); - - // If it's a single file, return it - if (stats.isFile()) { - return this.isValidFile(absolutePath) ? [absolutePath] : []; - } - - // If it's a directory, glob for files - if (stats.isDirectory()) { - return this.globFiles(absolutePath); - } - - return []; - } - - /** - * Glob for files in a directory - */ - private async globFiles(directory: string): Promise { - const patterns = this.options.extensions.map((ext) => `**/*${ext}`); - - const files: string[] = []; - - for (const pattern of patterns) { - const matches = await glob(pattern, { - cwd: directory, - absolute: true, - ignore: this.options.ignorePatterns, - nodir: true, - }); - files.push(...matches); - } - - // Remove duplicates - return [...new Set(files)]; - } - - /** - * Check if a file is valid (has a supported extension) - */ - private isValidFile(filePath: string): boolean { - return this.options.extensions.some((ext) => filePath.endsWith(ext)); - } - - /** - * Read a file's contents - */ - readFile(filePath: string): string { - try { - return readFileSync(filePath, 'utf-8'); - } catch (error) { - throw new Error( - `Failed to read file ${filePath}: ${(error as Error).message}` - ); - } - } - - /** - * Write content to a file - */ - writeFile(filePath: string, content: string): void { - try { - writeFileSync(filePath, content, 'utf-8'); - } catch (error) { - throw new Error( - `Failed to write file ${filePath}: ${(error as Error).message}` - ); - } - } - - /** - * Filter files that contain source framework imports - * Default pattern matches Automock imports for backward compatibility - * Yields file info objects with path and source content to avoid double reads - * Uses a generator for memory efficiency with large file sets - */ - *filterSourceFiles(files: string[]): Generator { - for (const filePath of files) { - const content = this.readFile(filePath); - if (this.hasSourceImport(content)) { - yield { path: filePath, source: content }; - } - } - } - - /** - * Check if file content contains source framework imports - * Default pattern matches Automock imports for backward compatibility - */ - private hasSourceImport(content: string): boolean { - const importPattern = - this.options.sourceImportPattern || - /@automock\/(jest|sinon|core)['"]|from\s+['"]@automock\/(jest|sinon|core)['"]/; - return importPattern.test(content); - } - - /** - * Get relative path from current working directory - */ - getRelativePath(filePath: string): string { - return filePath.replace(process.cwd() + '/', ''); - } -} - -export const createFileProcessor = ( - options?: Partial -): FileProcessor => new FileProcessor(options); diff --git a/test/integration/snapshot-tests.spec.ts b/test/integration/snapshot-tests.spec.ts index 525f0e0..6123131 100644 --- a/test/integration/snapshot-tests.spec.ts +++ b/test/integration/snapshot-tests.spec.ts @@ -6,27 +6,27 @@ */ import { loadFixturePair } from '../utils/fixture-loader'; -import { applyTransform } from '../../src/transforms/automock/2/to-suites-v3'; +import { testTransform } from '../utils/transform-test-helper'; describe('Snapshot Tests', () => { describe('Basic Examples from Specification', () => { it('should transform simple mock with .final()', () => { const fixtures = loadFixturePair('simple-final'); - const result = applyTransform(fixtures.input); + const result = testTransform(fixtures.input); expect(result.code).toMatchSnapshot(); expect(result.validation.success).toBe(true); }); it('should transform complex mock with .impl() and retrieval', () => { const fixtures = loadFixturePair('complex-impl'); - const result = applyTransform(fixtures.input); + const result = testTransform(fixtures.input); expect(result.code).toMatchSnapshot(); expect(result.validation.success).toBe(true); }); it('should transform token injection', () => { const fixtures = loadFixturePair('token-injection'); - const result = applyTransform(fixtures.input); + const result = testTransform(fixtures.input); expect(result.code).toMatchSnapshot(); expect(result.validation.success).toBe(true); }); @@ -35,7 +35,7 @@ describe('Snapshot Tests', () => { describe('Sinon Framework', () => { it('should transform Sinon-based tests', () => { const fixtures = loadFixturePair('sinon-example'); - const result = applyTransform(fixtures.input); + const result = testTransform(fixtures.input); expect(result.code).toMatchSnapshot(); expect(result.validation.success).toBe(true); }); @@ -44,7 +44,7 @@ describe('Snapshot Tests', () => { describe('Mixed .impl() and .final()', () => { it('should correctly apply .impl() to retrieved mocks and .final() to others', () => { const fixtures = loadFixturePair('mixed-impl-final'); - const result = applyTransform(fixtures.input); + const result = testTransform(fixtures.input); expect(result.code).toMatchSnapshot(); expect(result.validation.success).toBe(true); }); @@ -53,7 +53,7 @@ describe('Snapshot Tests', () => { describe('Type Cast Cleanup', () => { it('should remove obsolete type casts', () => { const fixtures = loadFixturePair('type-cast-cleanup'); - const result = applyTransform(fixtures.input); + const result = testTransform(fixtures.input); expect(result.code).toMatchSnapshot(); expect(result.validation.success).toBe(true); }); @@ -62,7 +62,7 @@ describe('Snapshot Tests', () => { describe('UnitReference Usage', () => { it('should handle UnitReference imports and usage', () => { const fixtures = loadFixturePair('unit-reference-usage'); - const result = applyTransform(fixtures.input); + const result = testTransform(fixtures.input); expect(result.code).toMatchSnapshot(); expect(result.validation.success).toBe(true); }); @@ -71,7 +71,7 @@ describe('Snapshot Tests', () => { describe('Multiple Test Hooks', () => { it('should transform TestBed in beforeAll, beforeEach, and test blocks', () => { const fixtures = loadFixturePair('multiple-hooks'); - const result = applyTransform(fixtures.input); + const result = testTransform(fixtures.input); expect(result.code).toMatchSnapshot(); expect(result.validation.success).toBe(true); }); @@ -80,7 +80,7 @@ describe('Snapshot Tests', () => { describe('Edge Cases', () => { it('should handle various edge cases correctly', () => { const fixtures = loadFixturePair('edge-cases'); - const result = applyTransform(fixtures.input); + const result = testTransform(fixtures.input); expect(result.code).toMatchSnapshot(); expect(result.validation.success).toBe(true); }); diff --git a/test/parse-errors.spec.ts b/test/parse-errors.spec.ts index c45a4df..f8eedc0 100644 --- a/test/parse-errors.spec.ts +++ b/test/parse-errors.spec.ts @@ -3,17 +3,17 @@ * * These tests validate that the codemod can handle problematic * TypeScript syntax patterns that caused parse errors across 38 files. - * The parser fallback mechanism tries: babel -> ts -> tsx parsers. + * jscodeshift handles parser selection automatically. */ import { loadFixturePair } from './utils/fixture-loader'; -import { applyTransform } from '../src/transforms/automock/2/to-suites-v3'; +import { testTransform } from './utils/transform-test-helper'; describe('Parse Error Handling', () => { describe('Category 1: Multi-Variable Declarations with Generics (18 files)', () => { it('should parse and transform files with multi-variable let declarations containing jest.Mocked generics', () => { const fixtures = loadFixturePair('parse-multi-var-generics'); - const result = applyTransform(fixtures.input); + const result = testTransform(fixtures.input); expect(result.code).toMatchSnapshot(); expect(result.validation.success).toBe(true); @@ -25,7 +25,7 @@ describe('Parse Error Handling', () => { describe('Category 2: TypeScript Type Assertions in Generics (3 files)', () => { it('should parse and transform files with type assertions in expect().resolves.toStrictEqual', () => { const fixtures = loadFixturePair('parse-type-assertions'); - const result = applyTransform(fixtures.input); + const result = testTransform(fixtures.input); expect(result.code).toMatchSnapshot(); expect(result.validation.success).toBe(true); @@ -37,7 +37,7 @@ describe('Parse Error Handling', () => { describe('Category 3: Function Call Syntax Issues (3 files)', () => { it('should parse and transform files with spread syntax in function parameters', () => { const fixtures = loadFixturePair('parse-spread-params'); - const result = applyTransform(fixtures.input); + const result = testTransform(fixtures.input); expect(result.code).toMatchSnapshot(); // Import is still transformed even without TestBed usage @@ -48,7 +48,7 @@ describe('Parse Error Handling', () => { describe('Real-world Integration: Multiple Parse Error Categories', () => { it('should parse and transform complex files combining multiple error patterns', () => { const fixtures = loadFixturePair('parse-complex-integration'); - const result = applyTransform(fixtures.input); + const result = testTransform(fixtures.input); expect(result.code).toMatchSnapshot(); expect(result.validation.success).toBe(true); @@ -57,7 +57,7 @@ describe('Parse Error Handling', () => { }); }); - describe('Parser Fallback Strategy', () => { + describe('Parser Selection', () => { it('should automatically select the correct parser without manual intervention', () => { // Multi-var declarations that babel might struggle with const problematicInput = ` @@ -75,7 +75,7 @@ describe('Test', () => { `; expect(() => { - const result = applyTransform(problematicInput); + const result = testTransform(problematicInput); expect(result.code).toBeDefined(); expect(result.code).toContain('@suites/unit'); }).not.toThrow(); @@ -95,7 +95,7 @@ describe('ComponentTest', () => { `; expect(() => { - const result = applyTransform(tsxInput); + const result = testTransform(tsxInput, 'tsx'); expect(result.code).toBeDefined(); }).not.toThrow(); }); diff --git a/test/transforms/global-jest.spec.ts b/test/transforms/global-jest.spec.ts index 47ebbde..abf9910 100644 --- a/test/transforms/global-jest.spec.ts +++ b/test/transforms/global-jest.spec.ts @@ -1,4 +1,4 @@ -import { applyTransform } from '../../src/transforms/automock/2/to-suites-v3'; +import { testTransform } from '../utils/transform-test-helper'; describe('Global Jest Handling', () => { describe('When jest is global (no import)', () => { @@ -23,7 +23,7 @@ describe('Global Jest Handling', () => { }); `; - const result = applyTransform(source); + const result = testTransform(source); const transformed = result.code; // Should transform jest.Mocked to Mocked @@ -60,7 +60,7 @@ describe('Global Jest Handling', () => { }); `; - const result = applyTransform(source); + const result = testTransform(source); const transformed = result.code; // Should transform to .impl() (because retrieved) @@ -87,7 +87,7 @@ describe('Global Jest Handling', () => { }); `; - const result = applyTransform(source); + const result = testTransform(source); const transformed = result.code; // Should still transform jest.Mocked even in partially migrated file @@ -109,7 +109,7 @@ describe('Global Jest Handling', () => { TestBed.create(MyService).compile(); `; - const result = applyTransform(source); + const result = testTransform(source); const transformed = result.code; // All jest.Mocked should be transformed diff --git a/test/utils/transform-test-helper.ts b/test/utils/transform-test-helper.ts new file mode 100644 index 0000000..4f88b18 --- /dev/null +++ b/test/utils/transform-test-helper.ts @@ -0,0 +1,91 @@ +/** + * Test helper for jscodeshift transforms + * + * Provides a utility function to test transforms with a mock jscodeshift API + */ + +import jscodeshift from 'jscodeshift'; +import type { FileInfo, API, Options, Stats } from 'jscodeshift'; +import transform from '../../src/transforms/automock/2/to-suites-v3'; +import { validateTransformedCode } from '../../src/validators'; + +export interface TransformTestResult { + code: string; + validation: { + success: boolean; + errors: any[]; + warnings: any[]; + criticalErrors: any[]; + }; +} + +/** + * Helper function to test the transform with a mock API + */ +export function testTransform( + source: string, + parser = 'tsx' +): TransformTestResult { + const j = jscodeshift.withParser(parser); + const stats: Stats = (() => {}) as Stats; + const api: API = { + j, + jscodeshift: j, + stats, + report: () => {}, + }; + + const fileInfo: FileInfo = { + path: 'test.ts', + source, + }; + + const options: Options = { + parser, + allowCriticalErrors: false, + print: false, + }; + + try { + const result = transform(fileInfo, api, options); + + if (result === null || result === undefined) { + // No changes - return original source with success validation + return { + code: source, + validation: { + success: true, + errors: [], + warnings: [], + criticalErrors: [], + }, + }; + } + + // Re-parse to validate + const transformedRoot = j(result); + const validation = validateTransformedCode(j, transformedRoot, result); + + return { + code: result, + validation: { + success: validation.success, + errors: validation.errors, + warnings: validation.warnings, + criticalErrors: validation.criticalErrors, + }, + }; + } catch (error) { + // Transform threw an error - return error validation + return { + code: source, + validation: { + success: false, + errors: [error instanceof Error ? error.message : 'Unknown error'], + warnings: [], + criticalErrors: [], + }, + }; + } +} +