diff --git a/package-lock.json b/package-lock.json index 1fd81912..687e1e2a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1524,6 +1524,10 @@ "resolved": "recipes/import-assertions-to-attributes", "link": true }, + "node_modules/@nodejs/mocha-to-node-test-runner": { + "resolved": "recipes/mocha-to-node-test-runner", + "link": true + }, "node_modules/@nodejs/node-url-to-whatwg-url": { "resolved": "recipes/node-url-to-whatwg-url", "link": true @@ -4411,6 +4415,17 @@ "@codemod.com/jssg-types": "^1.3.1" } }, + "recipes/mocha-to-node-test-runner": { + "name": "@nodejs/mocha-to-node-test-runner", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@nodejs/codemod-utils": "*" + }, + "devDependencies": { + "@codemod.com/jssg-types": "^1.3.1" + } + }, "recipes/node-url-to-whatwg-url": { "name": "@nodejs/node-url-to-whatwg-url", "version": "1.0.0", diff --git a/recipes/mocha-to-node-test-runner/README.md b/recipes/mocha-to-node-test-runner/README.md new file mode 100644 index 00000000..f6a86ec0 --- /dev/null +++ b/recipes/mocha-to-node-test-runner/README.md @@ -0,0 +1,171 @@ +# Mocha to Node.js Test Runner + +This recipe migrate Mocha v8 tests to Node.js test runner (v22, v24+) + +## Features + +- Automatically adds `node:test` imports/requires +- Converts global `describe`, `it`, and hooks to imported versions +- Transforms `done` callbacks to `(t, done)` signature +- Converts `this.skip()` to `t.skip()` +- Converts `this.timeout(N)` to `{ timeout: N }` options +- Preserves function styles (doesn't convert between `function()` and arrow functions) +- Supports both CommonJS and ESM + +## Examples + +### Example 1: Basic + +```diff +``` + const assert = require('assert'); ++const { describe, it } = require('node:test'); + + describe('Array', function() { + describe.skip('#indexOf()', function() { + it('should return -1 when the value is not present', function() { + const arr = [1, 2, 3]; + assert.strictEqual(arr.indexOf(4), -1); + }); + }); + }); +``` + +### Example 2: Async + +```diff +``` + const assert = require('assert'); ++const { describe, it } = require('node:test'); + describe('Async Test', function() { +- it('should complete after a delay', async function(done) { ++ it('should complete after a delay', async function(t, done) { + const result = await new Promise(resolve => setTimeout(() => resolve(42), 100)); + assert.strictEqual(result, 42); + }); + }); +``` + +### Example 3: Hooks + +```diff +``` + const assert = require('assert'); + const fs = require('fs'); ++const { describe, before, after, it } = require('node:test'); + describe('File System', () => { + before(function() { + fs.writeFileSync('test.txt', 'Hello, World!'); + }); + + after(() => { + fs.unlinkSync('test.txt'); + }); + + it('should read the file', () => { + const content = fs.readFileSync('test.txt', 'utf8'); + assert.strictEqual(content, 'Hello, World!'); + }); + }); + ``` + +### Example 4: Done + +```diff +``` +const assert = require('assert'); ++const { describe, it } = require('node:test'); +describe('Callback Test', function() { +- it('should call done when complete', function(done) { ++ it('should call done when complete', function(t, done) { + setTimeout(() => { + assert.strictEqual(1 + 1, 2); + done(); + }, 100); + }); +}) +``` + +### Example 5: Skipped + +```diff +``` + const assert = require('assert'); ++const { describe, it } = require('node:test'); + describe('Skipped Test', () => { + it.skip('should not run this test', () => { + assert.strictEqual(1 + 1, 3); + }); +- it('should also be skipped', () => { +- this.skip(); ++ it('should also be skipped', (t) => { ++ t.skip(); + assert.strictEqual(1 + 1, 3); + }); + +- it('should also be skipped 2', (done) => { +- this.skip(); ++ it('should also be skipped 2', (t, done) => { ++ t.skip(); + assert.strictEqual(1 + 1, 3); + }); + +- it('should also be skipped 3', x => { +- this.skip(); ++ it('should also be skipped 3', (t, x) => { ++ t.skip(); + assert.strictEqual(1 + 1, 3); + }); + }) +``` + +### Example 6: Dynamic + +```diff +``` + const assert = require('assert'); ++const { describe, it } = require('node:test'); + describe('Dynamic Tests', () => { + const tests = [1, 2, 3]; + + tests.forEach((test) => { + it(`should handle test ${test}`, () => { + assert.strictEqual(test % 2, 0); + }); + }); + }); +``` + +### Example 7: Timeouts + +```diff +const assert = require('assert'); +-describe('Timeout Test', function() { +- this.timeout(500); ++const { describe, it } = require('node:test'); ++describe('Timeout Test', { timeout: 500 }, function() { ++ ++ ++ it('should complete within 100ms', { timeout: 100 }, (t, done) => { + +- it('should complete within 100ms', (done) => { +- this.timeout(100); + setTimeout(done, 500); // This will fail + }); + +- it('should complete within 200ms', function(done) { +- this.timeout(200); ++ it('should complete within 200ms', { timeout: 200 }, function(t, done) { ++ + setTimeout(done, 100); // This will pass + }); +}); +``` + +## Caveats + +* `node:test` doesn't support the `retry` option that Mocha has, so any tests using that will need to be handled separately. + +## References +- [Node Test Runner](https://nodejs.org/api/test.html) +- [Mocha](https://mochajs.org/) diff --git a/recipes/mocha-to-node-test-runner/codemod.yaml b/recipes/mocha-to-node-test-runner/codemod.yaml new file mode 100644 index 00000000..f220c801 --- /dev/null +++ b/recipes/mocha-to-node-test-runner/codemod.yaml @@ -0,0 +1,26 @@ +schema_version: "1.0" +name: "@nodejs/mocha-to-node-test-runner" +version: 1.0.0 +capabilities: + - fs + - child_process +description: Migrate Mocha v8 tests to Node.js test runner (v22, v24+) +author: Xavier Stouder +license: MIT +workflow: workflow.yaml +category: migration + +targets: + languages: + - javascript + - typescript + +keywords: + - transformation + - migration + - mocha + - test + +registry: + access: public + visibility: public diff --git a/recipes/mocha-to-node-test-runner/package.json b/recipes/mocha-to-node-test-runner/package.json new file mode 100644 index 00000000..fb78ed15 --- /dev/null +++ b/recipes/mocha-to-node-test-runner/package.json @@ -0,0 +1,24 @@ +{ + "name": "@nodejs/mocha-to-node-test-runner", + "version": "1.0.0", + "description": "Migrate Mocha v8 tests to Node.js test runner (v22, v24+)", + "type": "module", + "scripts": { + "test": "npx codemod jssg test -l typescript ./src/workflow.ts ./" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/nodejs/userland-migrations.git", + "directory": "recipes/mocha-to-node-test-runner", + "bugs": "https://github.com/nodejs/userland-migrations/issues" + }, + "author": "Richie McColl", + "license": "MIT", + "homepage": "https://github.com/nodejs/userland-migrations/blob/main/recipes/mocha-to-node-test-runner/README.md", + "devDependencies": { + "@codemod.com/jssg-types": "^1.3.1" + }, + "dependencies": { + "@nodejs/codemod-utils": "*" + } +} diff --git a/recipes/mocha-to-node-test-runner/src/remove-dependencies.ts b/recipes/mocha-to-node-test-runner/src/remove-dependencies.ts new file mode 100644 index 00000000..4c1c3dc2 --- /dev/null +++ b/recipes/mocha-to-node-test-runner/src/remove-dependencies.ts @@ -0,0 +1,8 @@ +import removeDependencies from '@nodejs/codemod-utils/remove-dependencies'; + +/** + * Remove chalk and @types/chalk dependencies from package.json + */ +export default function removeMochaDependencies(): string | null { + return removeDependencies(['mocha', '@types/mocha']); +} diff --git a/recipes/mocha-to-node-test-runner/src/workflow.ts b/recipes/mocha-to-node-test-runner/src/workflow.ts new file mode 100644 index 00000000..f93003e4 --- /dev/null +++ b/recipes/mocha-to-node-test-runner/src/workflow.ts @@ -0,0 +1,252 @@ +import isESM from '@nodejs/codemod-utils/is-esm'; +import { getNodeImportStatements } from '@nodejs/codemod-utils/ast-grep/import-statement'; +import { getNodeRequireCalls } from '@nodejs/codemod-utils/ast-grep/require-call'; +import type { Edit, SgRoot } from '@codemod.com/jssg-types/main'; +import type JS from '@codemod.com/jssg-types/langs/javascript'; +import { EOL } from 'node:os'; + +export default function transform(root: SgRoot): string | null { + const rootNode = root.root(); + + const globalIdentifiers = ['describe']; + + const usedGlobalIdentifiers = globalIdentifiers.filter((globalIdentifier) => + ['', '.skip', '.only'] + .map((suffix) => `${globalIdentifier}${suffix}($$$)`) + .some((pattern) => rootNode.findAll({ rule: { pattern } }).length > 0), + ); + + if (usedGlobalIdentifiers.length === 0) return null; + + const edits = [ + transformImport, + transformDoneCallbacks, + transformThisSkip, + transformThisTimeout, + ].flatMap((transform) => transform(root)); + if (edits.length === 0) return null; + + return rootNode.commitEdits(edits); +} + +function transformImport(root: SgRoot): Edit[] { + const rootNode = root.root(); + const mochaGlobalsNodes = rootNode.findAll({ + constraints: { + MOCHA_GLOBAL_FN: { + any: [ + { pattern: 'describe' }, + { pattern: 'it' }, + { pattern: 'before' }, + { pattern: 'after' }, + { pattern: 'beforeEach' }, + { pattern: 'afterEach' }, + { pattern: 'describe.skip' }, + { pattern: 'describe.only' }, + { pattern: 'it.skip' }, + { pattern: 'it.only' }, + ], + }, + }, + rule: { + any: [{ pattern: '$MOCHA_GLOBAL_FN($$$)' }], + }, + }); + + const usedMochaGlobals = [ + ...new Set( + mochaGlobalsNodes.map( + (mochaGlobalsNode) => + mochaGlobalsNode.getMatch('MOCHA_GLOBAL_FN').text().split('.')[0], + ), + ), + ]; + + // if mocha isn't found, don't try to apply changes + if (usedMochaGlobals.length === 0) return []; + + const esm = isESM(root); + + const existingNodeTestImports = esm + ? getNodeImportStatements(rootNode.getRoot(), 'test') + : getNodeRequireCalls(rootNode.getRoot(), 'test'); + if (existingNodeTestImports.length > 0) return []; + + const imports = usedMochaGlobals.join(', '); + + const insertedText = esm + ? `${EOL}import { ${imports} } from 'node:test';` + : `${EOL}const { ${imports} } = require('node:test');`; + + if (esm) { + const importStatements = rootNode.findAll({ + rule: { kind: 'import_statement' }, + }); + const lastImportStatement = importStatements[importStatements.length - 1]; + if (lastImportStatement !== undefined) { + return [ + { + startPos: lastImportStatement.range().end.index, + endPos: lastImportStatement.range().end.index, + insertedText, + }, + ]; + } + } else { + const requireStatements = rootNode.findAll({ + rule: { pattern: 'const $_A = require($_B)' }, + }); + const lastRequireStatements = + requireStatements[requireStatements.length - 1]; + if (lastRequireStatements !== undefined) { + return [ + { + startPos: lastRequireStatements.range().end.index, + endPos: lastRequireStatements.range().end.index, + insertedText, + }, + ]; + } + } + return [ + { + startPos: 0, + endPos: 0, + insertedText, + }, + ]; +} + +function transformDoneCallbacks(root: SgRoot): Edit[] { + return root + .root() + .findAll({ + constraints: { + DONE: { + regex: '^done$', + }, + CALLEE: { + regex: '^(it|before|after|beforeEach|afterEach)$', + }, + CALLEE_NO_TITLE: { + regex: '^(before|after|beforeEach|afterEach)$', + }, + }, + rule: { + any: [ + { + pattern: '$CALLEE($TITLE, function($DONE) { $$$BODY })', + }, + { + pattern: '$CALLEE_NO_TITLE(function($DONE) { $$$BODY })', + }, + { + pattern: '$CALLEE($TITLE, ($DONE) => { $$$BODY })', + }, + { + pattern: '$CALLEE_NO_TITLE(($DONE) => { $$$BODY })', + }, + { + pattern: '$CALLEE($TITLE, $DONE => { $$$BODY })', + }, + { + pattern: '$CALLEE_NO_TITLE($DONE => { $$$BODY })', + }, + ], + }, + }) + .map((found) => found.getMatch('DONE').replace('t, done')); +} + +function transformThisSkip(root: SgRoot): Edit[] { + const rootNode = root.root(); + const thisSkipCalls = rootNode.findAll({ + rule: { pattern: 'this.skip($$$)' }, + }); + + return thisSkipCalls.flatMap((thisSkipCall) => { + const edits: Edit[] = []; + const memberExpr = thisSkipCall.find({ + rule: { kind: 'member_expression', has: { kind: 'this' } }, + }); + if (memberExpr !== null) { + const thisKeyword = memberExpr.field('object'); + if (thisKeyword !== null) { + edits.push(thisKeyword.replace('t')); + } + } + + const enclosingFunction = thisSkipCall + .ancestors() + .find((ancestor) => + ['function_expression', 'arrow_function'].includes(ancestor.kind()), + ); + if (enclosingFunction === undefined) { + return edits; + } + + const parameters = + enclosingFunction.field('parameters') ?? + enclosingFunction.field('parameter'); + if (parameters === null) { + return edits; + } + + if (parameters.kind() === 'identifier') { + edits.push(parameters.replace(`(t, ${parameters.text()})`)); + } else if (parameters.kind() === 'formal_parameters') { + edits.push({ + startPos: parameters.range().start.index + 1, + endPos: parameters.range().start.index + 1, + insertedText: `t${parameters.children().length > 2 ? ', ' : ''}`, + }); + } + + return edits; + }); +} + +function transformThisTimeout(root: SgRoot): Edit[] { + const rootNode = root.root(); + const thisTimeoutCalls = rootNode.findAll({ + rule: { pattern: 'this.timeout($TIME)' }, + }); + + return thisTimeoutCalls.flatMap((thisTimeoutCall) => { + const edits = [] as Edit[]; + const thisTimeoutExpression = thisTimeoutCall.parent(); + + const source = rootNode.text(); + const startIndex = thisTimeoutExpression.range().start.index; + const endIndex = thisTimeoutExpression.range().end.index; + + let lineStart = startIndex; + while (lineStart > 0 && source[lineStart - 1] !== EOL) lineStart--; + let lineEnd = endIndex; + while (lineEnd < source.length && source[lineEnd] !== EOL) lineEnd++; + if (lineEnd < source.length) lineEnd++; + + edits.push({ + startPos: lineStart, + endPos: lineEnd, + insertedText: '', + }); + + const enclosingFunction = thisTimeoutCall + .ancestors() + .find((ancestor) => + ['function_expression', 'arrow_function'].includes(ancestor.kind()), + ); + if (enclosingFunction === undefined) { + return edits; + } + + const time = thisTimeoutCall.getMatch('TIME').text(); + edits.push({ + startPos: enclosingFunction.range().start.index, + endPos: enclosingFunction.range().start.index, + insertedText: `{ timeout: ${time} }, `, + }); + return edits; + }); +} diff --git a/recipes/mocha-to-node-test-runner/tests/expected/1_basic.js b/recipes/mocha-to-node-test-runner/tests/expected/1_basic.js new file mode 100644 index 00000000..aeb412da --- /dev/null +++ b/recipes/mocha-to-node-test-runner/tests/expected/1_basic.js @@ -0,0 +1,11 @@ +const assert = require('assert'); +const { describe, it } = require('node:test'); + +describe('Array', function() { + describe.skip('#indexOf()', function() { + it('should return -1 when the value is not present', function() { + const arr = [1, 2, 3]; + assert.strictEqual(arr.indexOf(4), -1); + }); + }); +}); diff --git a/recipes/mocha-to-node-test-runner/tests/expected/2_async.js b/recipes/mocha-to-node-test-runner/tests/expected/2_async.js new file mode 100644 index 00000000..7e47dcf7 --- /dev/null +++ b/recipes/mocha-to-node-test-runner/tests/expected/2_async.js @@ -0,0 +1,8 @@ +const assert = require('assert'); +const { describe, it } = require('node:test'); +describe('Async Test', function() { + it('should complete after a delay', async function(t, done) { + const result = await new Promise(resolve => setTimeout(() => resolve(42), 100)); + assert.strictEqual(result, 42); + }); +}); diff --git a/recipes/mocha-to-node-test-runner/tests/expected/3_hooks.js b/recipes/mocha-to-node-test-runner/tests/expected/3_hooks.js new file mode 100644 index 00000000..05aaa621 --- /dev/null +++ b/recipes/mocha-to-node-test-runner/tests/expected/3_hooks.js @@ -0,0 +1,17 @@ +const assert = require('assert'); +const fs = require('fs'); +const { describe, before, after, it } = require('node:test'); +describe('File System', () => { + before(function() { + fs.writeFileSync('test.txt', 'Hello, World!'); + }); + + after(() => { + fs.unlinkSync('test.txt'); + }); + + it('should read the file', () => { + const content = fs.readFileSync('test.txt', 'utf8'); + assert.strictEqual(content, 'Hello, World!'); + }); +}); diff --git a/recipes/mocha-to-node-test-runner/tests/expected/4_done.js b/recipes/mocha-to-node-test-runner/tests/expected/4_done.js new file mode 100644 index 00000000..a32a2ceb --- /dev/null +++ b/recipes/mocha-to-node-test-runner/tests/expected/4_done.js @@ -0,0 +1,10 @@ +const assert = require('assert'); +const { describe, it } = require('node:test'); +describe('Callback Test', function() { + it('should call done when complete', function(t, done) { + setTimeout(() => { + assert.strictEqual(1 + 1, 2); + done(); + }, 100); + }); +}); diff --git a/recipes/mocha-to-node-test-runner/tests/expected/5_skipped.js b/recipes/mocha-to-node-test-runner/tests/expected/5_skipped.js new file mode 100644 index 00000000..f707b456 --- /dev/null +++ b/recipes/mocha-to-node-test-runner/tests/expected/5_skipped.js @@ -0,0 +1,21 @@ +const assert = require('assert'); +const { describe, it } = require('node:test'); +describe('Skipped Test', () => { + it.skip('should not run this test', () => { + assert.strictEqual(1 + 1, 3); + }); + it('should also be skipped', (t) => { + t.skip(); + assert.strictEqual(1 + 1, 3); + }); + + it('should also be skipped 2', (t, done) => { + t.skip(); + assert.strictEqual(1 + 1, 3); + }); + + it('should also be skipped 3', (t, x) => { + t.skip(); + assert.strictEqual(1 + 1, 3); + }); +}); diff --git a/recipes/mocha-to-node-test-runner/tests/expected/6_dynamic.js b/recipes/mocha-to-node-test-runner/tests/expected/6_dynamic.js new file mode 100644 index 00000000..6ffa7da4 --- /dev/null +++ b/recipes/mocha-to-node-test-runner/tests/expected/6_dynamic.js @@ -0,0 +1,10 @@ +const assert = require('assert'); +const { describe, it } = require('node:test'); +describe('Dynamic Tests', () => { + const tests = [1, 2, 3]; + tests.forEach((test) => { + it(`should handle test ${test}`, () => { + assert.strictEqual(test % 2, 0); + }); + }); +}); diff --git a/recipes/mocha-to-node-test-runner/tests/expected/7_timeouts.js b/recipes/mocha-to-node-test-runner/tests/expected/7_timeouts.js new file mode 100644 index 00000000..3e36ee55 --- /dev/null +++ b/recipes/mocha-to-node-test-runner/tests/expected/7_timeouts.js @@ -0,0 +1,12 @@ +const assert = require('assert'); +const { describe, it } = require('node:test'); +describe('Timeout Test', { timeout: 500 }, function() { + + it('should complete within 100ms', { timeout: 100 }, (t, done) => { + setTimeout(done, 500); // This will fail + }); + + it('should complete within 200ms', { timeout: 200 }, function(t, done) { + setTimeout(done, 100); // This will pass + }); +}); diff --git a/recipes/mocha-to-node-test-runner/tests/input/1_basic.js b/recipes/mocha-to-node-test-runner/tests/input/1_basic.js new file mode 100644 index 00000000..523e082c --- /dev/null +++ b/recipes/mocha-to-node-test-runner/tests/input/1_basic.js @@ -0,0 +1,10 @@ +const assert = require('assert'); + +describe('Array', function() { + describe.skip('#indexOf()', function() { + it('should return -1 when the value is not present', function() { + const arr = [1, 2, 3]; + assert.strictEqual(arr.indexOf(4), -1); + }); + }); +}); diff --git a/recipes/mocha-to-node-test-runner/tests/input/2_async.js b/recipes/mocha-to-node-test-runner/tests/input/2_async.js new file mode 100644 index 00000000..01431e6d --- /dev/null +++ b/recipes/mocha-to-node-test-runner/tests/input/2_async.js @@ -0,0 +1,7 @@ +const assert = require('assert'); +describe('Async Test', function() { + it('should complete after a delay', async function(done) { + const result = await new Promise(resolve => setTimeout(() => resolve(42), 100)); + assert.strictEqual(result, 42); + }); +}); diff --git a/recipes/mocha-to-node-test-runner/tests/input/3_hooks.js b/recipes/mocha-to-node-test-runner/tests/input/3_hooks.js new file mode 100644 index 00000000..eff75b91 --- /dev/null +++ b/recipes/mocha-to-node-test-runner/tests/input/3_hooks.js @@ -0,0 +1,16 @@ +const assert = require('assert'); +const fs = require('fs'); +describe('File System', () => { + before(function() { + fs.writeFileSync('test.txt', 'Hello, World!'); + }); + + after(() => { + fs.unlinkSync('test.txt'); + }); + + it('should read the file', () => { + const content = fs.readFileSync('test.txt', 'utf8'); + assert.strictEqual(content, 'Hello, World!'); + }); +}); diff --git a/recipes/mocha-to-node-test-runner/tests/input/4_done.js b/recipes/mocha-to-node-test-runner/tests/input/4_done.js new file mode 100644 index 00000000..0809c60f --- /dev/null +++ b/recipes/mocha-to-node-test-runner/tests/input/4_done.js @@ -0,0 +1,9 @@ +const assert = require('assert'); +describe('Callback Test', function() { + it('should call done when complete', function(done) { + setTimeout(() => { + assert.strictEqual(1 + 1, 2); + done(); + }, 100); + }); +}); diff --git a/recipes/mocha-to-node-test-runner/tests/input/5_skipped.js b/recipes/mocha-to-node-test-runner/tests/input/5_skipped.js new file mode 100644 index 00000000..49ea5f55 --- /dev/null +++ b/recipes/mocha-to-node-test-runner/tests/input/5_skipped.js @@ -0,0 +1,20 @@ +const assert = require('assert'); +describe('Skipped Test', () => { + it.skip('should not run this test', () => { + assert.strictEqual(1 + 1, 3); + }); + it('should also be skipped', () => { + this.skip(); + assert.strictEqual(1 + 1, 3); + }); + + it('should also be skipped 2', (done) => { + this.skip(); + assert.strictEqual(1 + 1, 3); + }); + + it('should also be skipped 3', x => { + this.skip(); + assert.strictEqual(1 + 1, 3); + }); +}); diff --git a/recipes/mocha-to-node-test-runner/tests/input/6_dynamic.js b/recipes/mocha-to-node-test-runner/tests/input/6_dynamic.js new file mode 100644 index 00000000..4e53dd90 --- /dev/null +++ b/recipes/mocha-to-node-test-runner/tests/input/6_dynamic.js @@ -0,0 +1,9 @@ +const assert = require('assert'); +describe('Dynamic Tests', () => { + const tests = [1, 2, 3]; + tests.forEach((test) => { + it(`should handle test ${test}`, () => { + assert.strictEqual(test % 2, 0); + }); + }); +}); diff --git a/recipes/mocha-to-node-test-runner/tests/input/7_timeouts.js b/recipes/mocha-to-node-test-runner/tests/input/7_timeouts.js new file mode 100644 index 00000000..e9b72135 --- /dev/null +++ b/recipes/mocha-to-node-test-runner/tests/input/7_timeouts.js @@ -0,0 +1,14 @@ +const assert = require('assert'); +describe('Timeout Test', function() { + this.timeout(500); + + it('should complete within 100ms', (done) => { + this.timeout(100); + setTimeout(done, 500); // This will fail + }); + + it('should complete within 200ms', function(done) { + this.timeout(200); + setTimeout(done, 100); // This will pass + }); +}); diff --git a/recipes/mocha-to-node-test-runner/workflow.yaml b/recipes/mocha-to-node-test-runner/workflow.yaml new file mode 100644 index 00000000..a4b26d57 --- /dev/null +++ b/recipes/mocha-to-node-test-runner/workflow.yaml @@ -0,0 +1,41 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/codemod-com/codemod/refs/heads/main/schemas/workflow.json + +version: "1" + +nodes: + - id: apply-transforms + name: Apply AST Transformations + type: automatic + steps: + - name: Migrate Mocha v8 tests to Node.js test runner (v22, v24+) + js-ast-grep: + js_file: src/workflow.ts + base_path: . + include: + - "**/*.cjs" + - "**/*.cts" + - "**/*.js" + - "**/*.jsx" + - "**/*.mjs" + - "**/*.mts" + - "**/*.ts" + - "**/*.tsx" + exclude: + - "**/node_modules/**" + language: typescript + - id: remove-dependencies + name: Remove Mocha dependency + type: automatic + steps: + - name: Detect package manager and remove chalk dependency + js-ast-grep: + js_file: src/remove-dependencies.ts + base_path: . + include: + - "**/package.json" + exclude: + - "**/node_modules/**" + language: typescript + capabilities: + - child_process + - fs diff --git a/utils/src/is-esm.test.ts b/utils/src/is-esm.test.ts new file mode 100644 index 00000000..300f712b --- /dev/null +++ b/utils/src/is-esm.test.ts @@ -0,0 +1,168 @@ +import { afterEach, beforeEach, describe, it } from 'node:test'; +import { + writeFileSync, + unlinkSync, + existsSync, + mkdtempSync, + rmSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import isESM from './is-esm.ts'; +import type { SgRoot } from '@codemod.com/jssg-types/main'; +import type JS from '@codemod.com/jssg-types/langs/javascript'; +import assert from 'node:assert/strict'; + +const createMockRoot = (filename, hasImport = false, hasRequire = false) => { + return { + filename: () => filename, + root: () => ({ + find: ({ rule }) => { + if (rule.kind === 'import_statement') { + return hasImport ? ['mock-import-node'] : null; + } + if (rule.kind === 'call_expression' && rule.has?.regex === 'require') { + return hasRequire ? ['mock-require-node'] : null; + } + return []; + }, + }), + // biome-ignore lint/suspicious/noExplicitAny: it's a mock + } as any as SgRoot; +}; + +describe('isESM', () => { + let originalCwd: string; + let tempDir: string; + + beforeEach(() => { + originalCwd = process.cwd(); + tempDir = mkdtempSync(join(tmpdir(), 'is-esm-test')); + process.chdir(tempDir); + }); + + afterEach(() => { + process.chdir(originalCwd); + if (existsSync(tempDir)) { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + describe('File extension detection', () => { + it('should return true for .mjs files regardless of content', async () => { + const mockRoot = createMockRoot('test.mjs', false, true); + const result = isESM(mockRoot); + assert.strictEqual(result, true); + }); + + it('should return true for .mts files regardless of content', async () => { + const mockRoot = createMockRoot('test.mts', false, true); + const result = isESM(mockRoot); + assert.strictEqual(result, true); + }); + + it('should return false for .cjs files regardless of content', async () => { + const mockRoot = createMockRoot('test.cjs', true, false); + const result = isESM(mockRoot); + assert.strictEqual(result, false); + }); + + it('should return false for .cts files regardless of content', async () => { + const mockRoot = createMockRoot('test.cts', true, false); + const result = isESM(mockRoot); + assert.strictEqual(result, false); + }); + }); + + describe('Import/require detection', () => { + it('should return true when file has import statements', async () => { + const mockRoot = createMockRoot('test.js', true, false); + const result = isESM(mockRoot); + assert.strictEqual(result, true); + }); + + it('should return false when file has require statements', async () => { + const mockRoot = createMockRoot('test.js', false, true); + const result = isESM(mockRoot); + assert.strictEqual(result, false); + }); + + it('should prioritize import over require if both exist (edge case)', async () => { + const mockRoot = createMockRoot('test.js', true, true); + const result = isESM(mockRoot); + assert.strictEqual(result, true); + }); + + it('should prioritize file extension over import/require detection', async () => { + // .mjs with require should still be true + const mockRoot1 = createMockRoot('test.mjs', false, true); + const result1 = isESM(mockRoot1); + assert.strictEqual(result1, true); + + // .cjs with import should still be false + const mockRoot2 = createMockRoot('test.cjs', true, false); + const result2 = isESM(mockRoot2); + assert.strictEqual(result2, false); + }); + }); + + describe('package.json type detection', () => { + it('should return true when package.json has type: "module"', async () => { + writeFileSync( + join(tempDir, 'package.json'), + JSON.stringify({ type: 'module' }), + ); + + const mockRoot = createMockRoot('test.js', false, false); + const result = isESM(mockRoot); + assert.strictEqual(result, true); + }); + + it('should return false when package.json has no type field', async () => { + writeFileSync( + join(tempDir, 'package.json'), + JSON.stringify({ name: 'test-package' }), + ); + + const mockRoot = createMockRoot('test.js', false, false); + const result = isESM(mockRoot); + assert.strictEqual(result, false); + }); + + it('should return false when package.json has type: "commonjs"', async () => { + writeFileSync( + join(tempDir, 'package.json'), + JSON.stringify({ type: 'commonjs' }), + ); + + const mockRoot = createMockRoot('test.js', false, false); + const result = isESM(mockRoot); + assert.strictEqual(result, false); + }); + + it('should return false when package.json has other type value', async () => { + writeFileSync( + join(tempDir, 'package.json'), + JSON.stringify({ type: 'custom' }), + ); + + const mockRoot = createMockRoot('test.js', false, false); + const result = isESM(mockRoot); + assert.strictEqual(result, false); + }); + + it('should throw error when package.json does not exist', async () => { + const packageJsonPath = join(tempDir, 'package.json'); + if (existsSync(packageJsonPath)) { + unlinkSync(packageJsonPath); + } + + const mockRoot = createMockRoot('test.js', false, false); + + assert.throws(() => isESM(mockRoot), { + name: 'Error', + message: /ENOENT|no such file or directory/, + }); + }); + }); +}); diff --git a/utils/src/is-esm.ts b/utils/src/is-esm.ts new file mode 100644 index 00000000..fd99db19 --- /dev/null +++ b/utils/src/is-esm.ts @@ -0,0 +1,45 @@ +import { join } from 'node:path'; +import { readFileSync } from 'node:fs'; +import type JS from '@codemod.com/jssg-types/langs/javascript'; +import type { SgRoot } from '@codemod.com/jssg-types/main'; + +export default function isESM(root: SgRoot): boolean { + const filename = root.filename(); + + const isCjsFile = filename.endsWith('.cjs') || filename.endsWith('.cts'); + const isMjsFile = filename.endsWith('.mjs') || filename.endsWith('.mts'); + if (isMjsFile) { + return true; + } + if (isCjsFile) { + return false; + } + + const rootNode = root.root(); + const usingImport = rootNode.find({ + rule: { + kind: 'import_statement', + }, + }); + if (usingImport) { + return true; + } + + const usingRequire = rootNode.find({ + rule: { + kind: 'call_expression', + has: { + kind: 'identifier', + field: 'function', + regex: 'require', + }, + }, + }); + if (usingRequire) { + return false; + } + + const packageJsonPath = join(process.cwd(), 'package.json'); + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')); + return packageJson.type === 'module'; +}