diff --git a/package-lock.json b/package-lock.json index cb1f7259..3de4a7d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -582,6 +582,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/mock-module-exports": { "resolved": "recipes/mock-module-exports", "link": true @@ -1170,6 +1174,17 @@ "@codemod.com/jssg-types": "^1.6.2" } }, + "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/mock-module-exports": { "name": "@nodejs/mock-module-exports", "version": "1.0.0", diff --git a/recipes/fs-access-mode-constants/package.json b/recipes/fs-access-mode-constants/package.json index 6fce4aef..dc4fe090 100644 --- a/recipes/fs-access-mode-constants/package.json +++ b/recipes/fs-access-mode-constants/package.json @@ -4,7 +4,7 @@ "description": "Handle DEP0176 via transforming imports of `fs.F_OK`, `fs.R_OK`, `fs.W_OK`, `fs.X_OK` from the root `fs` module to `fs.constants`.", "type": "module", "scripts": { - "test": "npx codemod jssg test -l typescript ./src/workflow.ts ./tests" + "test": "npx codemod jssg test --allow-fs -l typescript ./src/workflow.ts ./tests" }, "repository": { "type": "git", 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..e378b78e --- /dev/null +++ b/recipes/mocha-to-node-test-runner/README.md @@ -0,0 +1,165 @@ +# Mocha to Node.js Test Runner + +This migrates Mocha 8.x tests to Node.js (22.x, 24.x) test runner + +## 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..0a3cadb6 --- /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 8.x tests to Node.js (22.x, 24.x) test runner +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..fec50331 --- /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 8.x tests to Node.js (22.x, 24.x) test runner", + "type": "module", + "scripts": { + "test": "npx codemod jssg test --strictness cst -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..be2895c0 --- /dev/null +++ b/recipes/mocha-to-node-test-runner/src/remove-dependencies.ts @@ -0,0 +1,13 @@ +import type { Transform } from '@codemod.com/jssg-types/main'; +import type Json from '@codemod.com/jssg-types/langs/json'; +import removeDependencies from '@nodejs/codemod-utils/remove-dependencies'; + +const transform: Transform = async (root) => { + return removeDependencies(['mocha', '@types/mocha'], { + packageJsonPath: root.filename(), + runInstall: false, + persistFileWrite: false, + }); +}; + +export default transform; 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..c74f71f8 --- /dev/null +++ b/recipes/mocha-to-node-test-runner/src/workflow.ts @@ -0,0 +1,267 @@ +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, Kinds, SgNode, SgRoot } from '@codemod.com/jssg-types/main'; +import type JS from '@codemod.com/jssg-types/langs/javascript'; + +const GLOBAL_IDENTIFIERS = ['describe']; +const USED_GLOBAL_IDENTIFIERS = ['', '.skip', '.only']; + +export default function transform(root: SgRoot): string | null { + const rootNode = root.root(); + const EOL = rootNode.text().includes('\r\n') ? '\r\n' : '\n'; + + const usedGlobalIdentifiers = GLOBAL_IDENTIFIERS.filter((globalIdentifier) => + USED_GLOBAL_IDENTIFIERS.map( + (suffix) => `${globalIdentifier}${suffix}($$$)`, + ).some((pattern) => rootNode.findAll({ rule: { pattern } }).length > 0), + ); + + if (!usedGlobalIdentifiers.length) return null; + + const edits = [ + transformImport, + transformDoneCallbacks, + transformThisSkip, + transformThisTimeout, + ].flatMap((transform) => transform(rootNode, EOL)); + + if (!edits.length) return null; + + return rootNode.commitEdits(edits); +} + +function transformImport(rootNode: SgNode, EOL: string): Edit[] { + 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: { + 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.size === 0) return []; + + const esm = isESM(rootNode); + + 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(rootNode: SgNode, EOL: string): Edit[] { + return rootNode + .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(rootNode: SgNode): Edit[] { + return rootNode + .findAll({ rule: { pattern: 'this.skip($$$)' } }) + .flatMap((call) => { + const edits: Edit[] = []; + const memberExpr = call.find({ + rule: { kind: 'member_expression', has: { kind: 'this' } }, + }); + const thisKeyword = memberExpr?.field('object'); + if (thisKeyword) edits.push(thisKeyword.replace('t')); + + const fn = findEnclosingFunction(call); + if (!fn) return edits; + + const params = getParameters(fn); + if (!params) return edits; + + edits.push(...addTParameter(params)); + + return edits; + }); +} + +function transformThisTimeout(rootNode: SgNode, EOL: string): Edit[] { + const source = rootNode.text(); + + return rootNode + .findAll({ rule: { pattern: 'this.timeout($TIME)' } }) + .flatMap((call) => { + const edits: Edit[] = []; + const expr = call.parent(); + const exprRange = expr.range(); + + const start = exprRange.start.index; + const end = exprRange.end.index; + const lineStart = findLineStart(source, start); + const lineEnd = findLineEnd(source, end); + + edits.push({ + startPos: lineStart, + endPos: lineEnd, + insertedText: '', + }); + + const fn = findEnclosingFunction(call); + if (!fn) return edits; + + const time = call.getMatch('TIME').text(); + const fnRange = fn.range(); + + edits.push({ + startPos: fnRange.start.index, + endPos: fnRange.start.index, + insertedText: `{ timeout: ${time} }, `, + }); + + return edits; + }); +} + +function findLineStart(source: string, index: number) { + const lineBreakIndex = source.lastIndexOf('\n', index - 1); + return lineBreakIndex === -1 ? 0 : lineBreakIndex + 1; +} + +function findLineEnd(source: string, index: number) { + let lineEnd = index; + while (lineEnd < source.length && source[lineEnd] !== '\n' && source[lineEnd] !== '\r') { + lineEnd++; + } + + if (source[lineEnd] === '\r' && source[lineEnd + 1] === '\n') { + return lineEnd + 2; + } + + if (source[lineEnd] === '\n' || source[lineEnd] === '\r') { + return lineEnd + 1; + } + + return lineEnd; +} + +function findEnclosingFunction(node: SgNode>) { + return node + .ancestors() + .find((a: SgNode>) => + ['function_expression', 'arrow_function'].includes(a.kind()), + ); +} + +function getParameters(fn: SgNode>) { + return fn.field('parameters') ?? fn.field('parameter'); +} + +function addTParameter(parameters: SgNode>): Edit[] { + const edits: Edit[] = []; + + 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; +} diff --git a/recipes/mocha-to-node-test-runner/tests/async/expected.js b/recipes/mocha-to-node-test-runner/tests/async/expected.js new file mode 100644 index 00000000..5fba1bf3 --- /dev/null +++ b/recipes/mocha-to-node-test-runner/tests/async/expected.js @@ -0,0 +1,17 @@ +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); + }); +}); + +describe('Async Test bis', async () => { + it('should complete after a delay', async (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/async/input.js b/recipes/mocha-to-node-test-runner/tests/async/input.js new file mode 100644 index 00000000..7246de56 --- /dev/null +++ b/recipes/mocha-to-node-test-runner/tests/async/input.js @@ -0,0 +1,15 @@ +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); + }); +}); + +describe('Async Test bis', async () => { + it('should complete after a delay', async (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/basic/expected.js b/recipes/mocha-to-node-test-runner/tests/basic/expected.js new file mode 100644 index 00000000..d80b97bf --- /dev/null +++ b/recipes/mocha-to-node-test-runner/tests/basic/expected.js @@ -0,0 +1,18 @@ +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); + }); + }); +}); + +describe('Set', () => { + it('should return true when the value is present', () => { + const set = new Set([1, 2, 3]); + assert.strictEqual(set.has(2), true); + }); +}); diff --git a/recipes/mocha-to-node-test-runner/tests/basic/input.js b/recipes/mocha-to-node-test-runner/tests/basic/input.js new file mode 100644 index 00000000..5dd8e1d7 --- /dev/null +++ b/recipes/mocha-to-node-test-runner/tests/basic/input.js @@ -0,0 +1,18 @@ +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); + }); + }); +}); + +describe('Set', () => { + it('should return true when the value is present', () => { + const set = new Set([1, 2, 3]); + assert.strictEqual(set.has(2), true); + }); +}); + diff --git a/recipes/mocha-to-node-test-runner/tests/done/expected.js b/recipes/mocha-to-node-test-runner/tests/done/expected.js new file mode 100644 index 00000000..bc2af48b --- /dev/null +++ b/recipes/mocha-to-node-test-runner/tests/done/expected.js @@ -0,0 +1,11 @@ +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/done/input.js b/recipes/mocha-to-node-test-runner/tests/done/input.js new file mode 100644 index 00000000..70dfed95 --- /dev/null +++ b/recipes/mocha-to-node-test-runner/tests/done/input.js @@ -0,0 +1,10 @@ +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/dynamic/expected.js b/recipes/mocha-to-node-test-runner/tests/dynamic/expected.js new file mode 100644 index 00000000..547d01cd --- /dev/null +++ b/recipes/mocha-to-node-test-runner/tests/dynamic/expected.js @@ -0,0 +1,17 @@ +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); + }); + }); + + for (const test of tests) { + it(`should handle test ${test}`, () => { + assert.strictEqual(test % 2, 0); + }); + } +}); diff --git a/recipes/mocha-to-node-test-runner/tests/dynamic/input.js b/recipes/mocha-to-node-test-runner/tests/dynamic/input.js new file mode 100644 index 00000000..e667cdbb --- /dev/null +++ b/recipes/mocha-to-node-test-runner/tests/dynamic/input.js @@ -0,0 +1,16 @@ +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); + }); + }); + + for(const test of tests) { + it(`should handle test ${test}`, () => { + assert.strictEqual(test % 2, 0); + }); + } +}); diff --git a/recipes/mocha-to-node-test-runner/tests/esm-imports/expected.js b/recipes/mocha-to-node-test-runner/tests/esm-imports/expected.js new file mode 100644 index 00000000..64157dd5 --- /dev/null +++ b/recipes/mocha-to-node-test-runner/tests/esm-imports/expected.js @@ -0,0 +1,11 @@ +import assert from 'assert'; +import { describe, it } from '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/esm-imports/input.js b/recipes/mocha-to-node-test-runner/tests/esm-imports/input.js new file mode 100644 index 00000000..6bbd9d15 --- /dev/null +++ b/recipes/mocha-to-node-test-runner/tests/esm-imports/input.js @@ -0,0 +1,10 @@ +import assert from '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/hooks/expected.js b/recipes/mocha-to-node-test-runner/tests/hooks/expected.js new file mode 100644 index 00000000..1501c320 --- /dev/null +++ b/recipes/mocha-to-node-test-runner/tests/hooks/expected.js @@ -0,0 +1,18 @@ +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/hooks/input.js b/recipes/mocha-to-node-test-runner/tests/hooks/input.js new file mode 100644 index 00000000..25e5a702 --- /dev/null +++ b/recipes/mocha-to-node-test-runner/tests/hooks/input.js @@ -0,0 +1,17 @@ +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/skipped/expected.js b/recipes/mocha-to-node-test-runner/tests/skipped/expected.js new file mode 100644 index 00000000..a1e1af24 --- /dev/null +++ b/recipes/mocha-to-node-test-runner/tests/skipped/expected.js @@ -0,0 +1,22 @@ +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/skipped/input.js b/recipes/mocha-to-node-test-runner/tests/skipped/input.js new file mode 100644 index 00000000..60cf2719 --- /dev/null +++ b/recipes/mocha-to-node-test-runner/tests/skipped/input.js @@ -0,0 +1,21 @@ +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/timeouts/expected.js b/recipes/mocha-to-node-test-runner/tests/timeouts/expected.js new file mode 100644 index 00000000..e725c2f7 --- /dev/null +++ b/recipes/mocha-to-node-test-runner/tests/timeouts/expected.js @@ -0,0 +1,13 @@ +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/timeouts/input.js b/recipes/mocha-to-node-test-runner/tests/timeouts/input.js new file mode 100644 index 00000000..326bdb84 --- /dev/null +++ b/recipes/mocha-to-node-test-runner/tests/timeouts/input.js @@ -0,0 +1,15 @@ +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..3f1469e1 --- /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 8.x tests to Node.js (22.x, 24.x) test runner + 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 mocha 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..fbf58b84 --- /dev/null +++ b/utils/src/is-esm.test.ts @@ -0,0 +1,178 @@ +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 { SgNode, Rule } 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: string, hasImport = false, hasRequire = false) => { + return { + getRoot: () => ({ + filename: () => filename, + }), + find: ({ rule }: { rule: 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 []; + }, + root: () => ({ + find: ({ rule }: { rule: 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 SgNode; +}; + +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 default to CommonJS 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); + + const result = isESM(mockRoot); + + assert.strictEqual(result, false); + }); + }); +}); diff --git a/utils/src/is-esm.ts b/utils/src/is-esm.ts new file mode 100644 index 00000000..23ab46d5 --- /dev/null +++ b/utils/src/is-esm.ts @@ -0,0 +1,55 @@ +import { join } from 'node:path'; +import { readFileSync, accessSync, constants } from 'node:fs'; +import type JS from '@codemod.com/jssg-types/langs/javascript'; +import type { SgNode } from '@codemod.com/jssg-types/main'; + +/** + * This api didn't exist on JSSG (behind the scenesn it use llrt) + * + * @param path name of the file to check for existence + */ +const existsSync = (path: string) => { + try { + accessSync(path, constants.F_OK); + return true; + } catch { + return false; + } +} + +export default function isESM(rootNode: SgNode): boolean { + const filename = rootNode.getRoot().filename(); + + if (filename.endsWith('.mjs') || filename.endsWith('.mts')) return true; + + if (filename.endsWith('.cjs') || filename.endsWith('.cts')) return false; + + + 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'); + if (!existsSync(packageJsonPath)) return false; + try { + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')); + return packageJson.type === 'module'; + } catch { + return false; + } +}