diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 0000000..c14896c --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,61 @@ +name: Test & Coverage + +on: + push: + branches: + - master + +permissions: + contents: read + id-token: write + checks: write + +jobs: + test: + name: Test & Upload Coverage + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20.x' + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 8.15.9 + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm cache + uses: actions/cache@v4 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run tests with coverage + run: pnpm test + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./coverage/lcov.info + flags: unittests + name: codecov-codemod + fail_ci_if_error: true + verbose: true diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..4c96662 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,111 @@ +name: Publish Package + +on: + workflow_dispatch: + inputs: + tag: + description: 'Distribution tag' + required: true + type: choice + options: + - latest + - next + - rc + - dev + - alpha + - beta + branch: + description: 'Target branch' + required: true + type: choice + options: + - master + - next + +permissions: + contents: write + id-token: write + +jobs: + publish: + name: Publish to NPM + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ inputs.branch }} + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20.x' + registry-url: 'https://registry.npmjs.org' + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 8.15.9 + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm cache + uses: actions/cache@v4 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm run build + + - name: Run tests + run: pnpm test + + - name: Run lint + run: pnpm run lint + + - name: Publish to NPM + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_CONFIG_PROVENANCE: true + run: | + echo "//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}" > .npmrc + npm publish --tag ${{ inputs.tag }} --provenance --access public + + - name: Create Summary + if: success() + run: | + echo "## ✅ Publication Successful" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Package published to npm with tag: **${{ inputs.tag }}**" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Installation" >> $GITHUB_STEP_SUMMARY + echo '```bash' >> $GITHUB_STEP_SUMMARY + if [ "${{ inputs.tag }}" = "latest" ]; then + echo "npm install @suites/codemod" >> $GITHUB_STEP_SUMMARY + else + echo "npm install @suites/codemod@${{ inputs.tag }}" >> $GITHUB_STEP_SUMMARY + fi + echo '```' >> $GITHUB_STEP_SUMMARY + + - name: Create Failure Summary + if: failure() + run: | + echo "## ❌ Publication Failed" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "The package could not be published. Check the logs above for details." >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Common Issues" >> $GITHUB_STEP_SUMMARY + echo "- Ensure NPM_TOKEN secret is configured correctly" >> $GITHUB_STEP_SUMMARY + echo "- Check if the version already exists on npm" >> $GITHUB_STEP_SUMMARY + echo "- Verify build and tests pass successfully" >> $GITHUB_STEP_SUMMARY diff --git a/.releaserc.json b/.releaserc.json index fa88536..9f6d1c4 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -65,7 +65,7 @@ "@semantic-release/git", { "assets": ["CHANGELOG.md", "package.json"], - "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" + "message": "chore(release): ${nextRelease.version}\n\n${nextRelease.notes}" } ], "@semantic-release/github" diff --git a/README.md b/README.md index 27343e4..101c466 100644 --- a/README.md +++ b/README.md @@ -10,19 +10,19 @@ Automated code transformations for Suites projects. Built on [jscodeshift](https ## Usage ```bash -npx @suites/codemod [options] +npx @suites/codemod [options] ``` **Example:** ```bash -npx @suites/codemod src/**/*.spec.ts +npx @suites/codemod automock/2/to-suites-v3 src/**/*.spec.ts ``` Run with `--dry-run` to preview changes without modifying files. ## Available Transforms -- **`automock-to-suites`** (default) - Migrate test files from Automock to Suites testing framework +- **`automock/2/to-suites-v3`** - Migrate test files from Automock v2 to Suites v3 testing framework ## Example @@ -79,10 +79,10 @@ describe('UserService', () => { **More examples:** ```bash # Preview changes -npx @suites/codemod src --dry-run +npx @suites/codemod automock/2/to-suites-v3 src --dry-run # Ignore certain files -npx @suites/codemod src --ignore "**/*.integration.ts" +npx @suites/codemod automock/2/to-suites-v3 src --ignore "**/*.integration.ts" # List all transforms npx @suites/codemod --list-transforms @@ -90,9 +90,9 @@ npx @suites/codemod --list-transforms ## Transform Details -### `automock-to-suites` +### `automock/2/to-suites-v3` -Intelligently migrates Automock test files to Suites framework. +Intelligently migrates Automock v2 test files to Suites v3 framework. **What it transforms:** - Import statements: `@automock/jest` -> `@suites/unit` @@ -154,6 +154,32 @@ The codemod uses [jscodeshift](https://github.com/facebook/jscodeshift) to: **TypeScript Support:** First-class support with fallback parser for complex syntax (generics, type guards, decorators, JSX/TSX). +## Architecture + +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 + 2/ # Source version + to-suites-v3.ts # Migration transform + 3/ # Future: next version + to-suites-v4.ts +``` + +**Design Benefits:** +- No default transform - explicit selection prevents mistakes +- Version-based organization supports migration chains +- Framework namespacing allows multi-framework support +- Clear source → target versioning + ## Contributing Contributions welcome! To contribute: @@ -167,11 +193,25 @@ Contributions welcome! To contribute: ### Adding New Transforms -1. Create transform file in `src/transforms/` -2. Register in `src/transforms/index.ts` -3. Add test fixtures in `fixtures/` -4. Add integration tests in `test/integration/` -5. Update this README +1. Create transform directory: `src/transforms///.ts` +2. Export `applyTransform` function from your transform +3. Register in `src/transforms/index.ts`: + ```typescript + { + name: 'framework/version/transform-name', + description: 'Description of what it does', + path: './transforms/framework/version/transform-name', + } + ``` +4. Add test fixtures in `fixtures/` +5. Add integration tests in `test/integration/` +6. Update this README + +**Example:** +```typescript +// src/transforms/automock/3/to-suites-v4.ts +export { applyTransform } from '../../../transform'; +``` ### Project Structure @@ -179,6 +219,10 @@ Contributions welcome! To contribute: src/ analyzers/ # Code analysis utilities transforms/ # Transform implementations + automock/ # Framework namespace + 2/ # Version-specific transforms + to-suites-v3.ts + index.ts # Transform registry validators/ # Post-transform validation utils/ # Shared utilities cli.ts # CLI interface @@ -191,6 +235,52 @@ test/ fixtures/ # Test fixtures (before/after) ``` +## Local Development + +### Running Locally + +From within the codemod repository: + +```bash +# Build first +pnpm build + +# Run on a target repository +node dist/cli.js automock/2/to-suites-v3 /path/to/repo --dry-run + +# Run on test fixtures +node dist/cli.js automock/2/to-suites-v3 fixtures/simple-final --dry-run + +# Verbose output for debugging +node dist/cli.js automock/2/to-suites-v3 /path/to/repo --dry-run --verbose +``` + +### Using npm link for Testing + +```bash +# In the codemod repo +npm link + +# Now use it anywhere like npx +codemod automock/2/to-suites-v3 /path/to/repo --dry-run + +# Unlink when done +npm unlink -g @suites/codemod +``` + +### Running Tests + +```bash +# All tests +pnpm test + +# Specific test file +pnpm test path/to/test.spec.ts + +# With coverage +pnpm test --coverage +``` + ## License MIT (c) [Omer Morad](https://github.com/omermorad) diff --git a/package.json b/package.json index 7b7f80a..9145d8e 100644 --- a/package.json +++ b/package.json @@ -77,8 +77,8 @@ "typescript" ], "publishConfig": { - "registry": "http://localhost:4873", - "access": "public" + "access": "public", + "provenance": true }, "packageManager": "pnpm@8.15.9" } diff --git a/src/cli.ts b/src/cli.ts index e6898e9..46cf0f5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -5,7 +5,6 @@ import { runTransform } from './runner'; import { checkGitStatus } from './utils/git-safety'; import { getTransform, - getDefaultTransform, AVAILABLE_TRANSFORMS, } from './transforms'; @@ -17,7 +16,7 @@ program .version('0.1.0') .argument( '[transform]', - 'Transform to apply (e.g., automock-to-suites). Defaults to automock-to-suites if not specified.' + 'Transform to apply (e.g., automock/2/to-suites-v3)' ) .argument('[path]', 'Path to transform (file or directory)', '.') .option('-d, --dry-run', 'Preview changes without writing files', false) @@ -47,32 +46,22 @@ program return; } - // Determine transform and path - let transformName: string; - let targetPath: string; - - if (transformArg && !transformArg.startsWith('-')) { - // First arg might be transform or path - const maybeTransform = getTransform(transformArg); - if (maybeTransform) { - // It's a transform name - transformName = transformArg; - targetPath = pathArg || '.'; - } else { - // First arg is path, use default transform - transformName = getDefaultTransform().name; - targetPath = transformArg; - logger.info(`No transform specified, using default: ${transformName}`); - } - } else { - // No transform specified, use default - transformName = getDefaultTransform().name; - targetPath = pathArg || '.'; - if (!pathArg) { - logger.info(`No transform specified, using default: ${transformName}`); - } + // Validate transform is provided + if (!transformArg) { + logger.error('Transform argument required.'); + logger.info('\nAvailable transforms:'); + AVAILABLE_TRANSFORMS.forEach((t) => { + console.log(` ${t.name}`); + console.log(` ${t.description}\n`); + }); + logger.info('Example usage:'); + logger.info(` npx @suites/codemod ${AVAILABLE_TRANSFORMS[0].name} ./src`); + process.exit(1); } + const transformName = transformArg; + const targetPath = pathArg || '.'; + const transformInfo = getTransform(transformName); if (!transformInfo) { logger.error(`Unknown transform: ${transformName}`); diff --git a/src/runner.ts b/src/runner.ts index 228c4c0..f8a6c31 100644 --- a/src/runner.ts +++ b/src/runner.ts @@ -37,18 +37,18 @@ export async function runTransform( const allFiles = await fileProcessor.discoverFiles(targetPath); logger.succeedSpinner(`Found ${allFiles.length} files`); - // Step 2: Filter for Automock files - logger.startSpinner('Analyzing Automock usage..'); - const automockFiles = fileProcessor.filterAutomockFiles(allFiles); + // Step 2: Filter for source framework files + logger.startSpinner('Analyzing source framework usage..'); + const sourceFiles = fileProcessor.filterSourceFiles(allFiles); - if (automockFiles.length === 0) { - logger.warnSpinner('No Automock files found'); - logger.info('No files contain Automock imports. Migration not needed.'); + if (sourceFiles.length === 0) { + logger.warnSpinner('No source framework files found'); + logger.info('No files contain source framework imports. Migration not needed.'); return createEmptySummary(); } - logger.succeedSpinner(`${automockFiles.length} files contain Automock imports`); - logger.subsection(`${allFiles.length - automockFiles.length} files skipped (no Automock code)`); + logger.succeedSpinner(`${sourceFiles.length} files contain source framework imports`); + logger.subsection(`${allFiles.length - sourceFiles.length} files skipped (no source imports)`); logger.newline(); @@ -59,7 +59,7 @@ export async function runTransform( let totalErrors = 0; let totalWarnings = 0; - for (const filePath of automockFiles) { + for (const filePath of sourceFiles) { const result = await transformFile(filePath, applyTransform, options, logger); results.push(result); @@ -79,8 +79,8 @@ export async function runTransform( logger.success(`${filesTransformed} file${filesTransformed > 1 ? 's' : ''} transformed successfully`); } - if (automockFiles.length - filesTransformed > 0) { - logger.info(` ${automockFiles.length - filesTransformed} file${automockFiles.length - filesTransformed > 1 ? 's' : ''} skipped (no changes needed)`); + if (sourceFiles.length - filesTransformed > 0) { + logger.info(` ${sourceFiles.length - filesTransformed} file${sourceFiles.length - filesTransformed > 1 ? 's' : ''} skipped (no changes needed)`); } if (totalWarnings > 0) { @@ -110,9 +110,9 @@ export async function runTransform( } return { - filesProcessed: automockFiles.length, + filesProcessed: sourceFiles.length, filesTransformed, - filesSkipped: automockFiles.length - filesTransformed, + filesSkipped: sourceFiles.length - filesTransformed, importsUpdated: 0, // TODO: Track this from transformers mocksConfigured: 0, // TODO: Track this from transformers errors: totalErrors, diff --git a/src/transforms/automock-to-suites.ts b/src/transforms/automock/2/to-suites-v3.ts similarity index 87% rename from src/transforms/automock-to-suites.ts rename to src/transforms/automock/2/to-suites-v3.ts index 48f923d..59858ef 100644 --- a/src/transforms/automock-to-suites.ts +++ b/src/transforms/automock/2/to-suites-v3.ts @@ -10,4 +10,4 @@ * - Cleanup of obsolete patterns */ -export { applyTransform } from '../transform'; +export { applyTransform } from '../../../transform'; diff --git a/src/transforms/index.ts b/src/transforms/index.ts index 68aa8f3..8fe971f 100644 --- a/src/transforms/index.ts +++ b/src/transforms/index.ts @@ -2,11 +2,12 @@ * Transform Registry * * Central registry for all available codemods. - * Each transform represents a specific migration (e.g., automock-to-suites, v3-to-v4). + * Follows Codemod Registry pattern: // + * Examples: automock/2/to-suites-v3, jest/28/to-v29 */ export interface TransformInfo { - /** Unique identifier for the transform (e.g., 'automock-to-suites') */ + /** Unique identifier for the transform (e.g., 'automock/2/to-suites-v3') */ name: string; /** Human-readable description of what the transform does */ description: string; @@ -20,36 +21,27 @@ export interface TransformInfo { */ export const AVAILABLE_TRANSFORMS: TransformInfo[] = [ { - name: 'automock-to-suites', - description: 'Migrate from Automock to Suites unit testing framework', - path: './transforms/automock-to-suites', + name: 'automock/2/to-suites-v3', + description: 'Migrate from Automock v2 to Suites v3 unit testing framework', + path: './transforms/automock/2/to-suites-v3', }, - // Future transforms will be added here, e.g.: + // Future transforms: // { - // name: 'v3-to-v4', - // description: 'Migrate from Suites v3 to v4', - // path: './transforms/v3-to-v4', + // name: 'automock/3/to-suites-v4', + // description: 'Migrate from Suites v3 to Suites v4', + // path: './transforms/automock/3/to-suites-v4', // }, ]; /** * Get transform info by name - * @param name Transform name (e.g., 'automock-to-suites') + * @param name Transform name (e.g., 'automock/2/to-suites-v3') * @returns Transform info or null if not found */ export function getTransform(name: string): TransformInfo | null { return AVAILABLE_TRANSFORMS.find((t) => t.name === name) || null; } -/** - * Get the default transform (first in the registry) - * Used for backward compatibility when no transform is specified. - * @returns Default transform info - */ -export function getDefaultTransform(): TransformInfo { - return AVAILABLE_TRANSFORMS[0]; -} - /** * Check if a transform name is valid * @param name Transform name to check diff --git a/src/utils/file-processor.ts b/src/utils/file-processor.ts index 95bb3ea..d29e8cf 100644 --- a/src/utils/file-processor.ts +++ b/src/utils/file-processor.ts @@ -5,6 +5,7 @@ import { glob } from 'glob'; export interface FileProcessorOptions { extensions: string[]; ignorePatterns: string[]; + sourceImportPattern?: RegExp; } export class FileProcessor { @@ -105,22 +106,24 @@ export class FileProcessor { } /** - * Filter files that contain Automock imports + * Filter files that contain source framework imports + * Default pattern matches Automock imports for backward compatibility */ - filterAutomockFiles(files: string[]): string[] { + filterSourceFiles(files: string[]): string[] { return files.filter((filePath) => { const content = this.readFile(filePath); - return this.hasAutomockImport(content); + return this.hasSourceImport(content); }); } /** - * Check if file content contains Automock imports + * Check if file content contains source framework imports + * Default pattern matches Automock imports for backward compatibility */ - private hasAutomockImport(content: string): boolean { - const automockImportPattern = + private hasSourceImport(content: string): boolean { + const importPattern = this.options.sourceImportPattern || /@automock\/(jest|sinon|core)['"]|from\s+['"]@automock\/(jest|sinon|core)['"]/; - return automockImportPattern.test(content); + return importPattern.test(content); } /** diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 44d6b1b..cab7266 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -62,7 +62,7 @@ export class Logger { * Log a success message */ success(message: string): void { - console.log(chalk.green('✓'), message); + console.log(chalk.green('✔'), message); } /** @@ -113,7 +113,7 @@ export class Logger { * Log a file transformation result */ fileTransformed(filePath: string, changes: string[]): void { - console.log(chalk.green('✓'), chalk.dim(filePath)); + console.log(chalk.green('✔'), chalk.dim(filePath)); changes.forEach((change) => { this.subsection(chalk.dim(`- ${change}`), 5); }); diff --git a/test/integration/snapshot-tests.spec.ts b/test/integration/snapshot-tests.spec.ts index 6493f09..525f0e0 100644 --- a/test/integration/snapshot-tests.spec.ts +++ b/test/integration/snapshot-tests.spec.ts @@ -6,7 +6,7 @@ */ import { loadFixturePair } from '../utils/fixture-loader'; -import { applyTransform } from '../../src/transforms/automock-to-suites'; +import { applyTransform } from '../../src/transforms/automock/2/to-suites-v3'; describe('Snapshot Tests', () => { describe('Basic Examples from Specification', () => { diff --git a/test/parse-errors.spec.ts b/test/parse-errors.spec.ts index 2289d4b..c45a4df 100644 --- a/test/parse-errors.spec.ts +++ b/test/parse-errors.spec.ts @@ -7,7 +7,7 @@ */ import { loadFixturePair } from './utils/fixture-loader'; -import { applyTransform } from '../src/transforms/automock-to-suites'; +import { applyTransform } from '../src/transforms/automock/2/to-suites-v3'; describe('Parse Error Handling', () => { describe('Category 1: Multi-Variable Declarations with Generics (18 files)', () => { diff --git a/test/transforms/global-jest.spec.ts b/test/transforms/global-jest.spec.ts index d606b76..47ebbde 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-to-suites'; +import { applyTransform } from '../../src/transforms/automock/2/to-suites-v3'; describe('Global Jest Handling', () => { describe('When jest is global (no import)', () => {