From 7dc0c747b4495f65832adffe6cb049070d082035 Mon Sep 17 00:00:00 2001 From: Xavier Stouder Date: Sun, 21 Dec 2025 01:31:59 +0100 Subject: [PATCH 01/10] wip --- package-lock.json | 15 ++ recipes/mocha-to-node-test-runner/README.md | 56 +++++ .../mocha-to-node-test-runner/codemod.yaml | 23 ++ .../mocha-to-node-test-runner/package.json | 24 +++ .../src/remove-dependencies.ts | 8 + .../mocha-to-node-test-runner/src/workflow.ts | 200 ++++++++++++++++++ .../tests/expected/1_basic.js | 11 + .../tests/expected/2_async.js | 8 + .../tests/expected/3_hooks.js | 17 ++ .../tests/expected/4_done.js | 10 + .../tests/expected/5_skipped.js | 21 ++ .../tests/expected/6_dynamic.js | 10 + .../tests/input/1_basic.js | 10 + .../tests/input/2_async.js | 7 + .../tests/input/3_hooks.js | 16 ++ .../tests/input/4_done.js | 9 + .../tests/input/5_skipped.js | 20 ++ .../tests/input/6_dynamic.js | 9 + .../mocha-to-node-test-runner/workflow.yaml | 42 ++++ utils/src/is-esm.ts | 39 ++++ 20 files changed, 555 insertions(+) create mode 100644 recipes/mocha-to-node-test-runner/README.md create mode 100644 recipes/mocha-to-node-test-runner/codemod.yaml create mode 100644 recipes/mocha-to-node-test-runner/package.json create mode 100644 recipes/mocha-to-node-test-runner/src/remove-dependencies.ts create mode 100644 recipes/mocha-to-node-test-runner/src/workflow.ts create mode 100644 recipes/mocha-to-node-test-runner/tests/expected/1_basic.js create mode 100644 recipes/mocha-to-node-test-runner/tests/expected/2_async.js create mode 100644 recipes/mocha-to-node-test-runner/tests/expected/3_hooks.js create mode 100644 recipes/mocha-to-node-test-runner/tests/expected/4_done.js create mode 100644 recipes/mocha-to-node-test-runner/tests/expected/5_skipped.js create mode 100644 recipes/mocha-to-node-test-runner/tests/expected/6_dynamic.js create mode 100644 recipes/mocha-to-node-test-runner/tests/input/1_basic.js create mode 100644 recipes/mocha-to-node-test-runner/tests/input/2_async.js create mode 100644 recipes/mocha-to-node-test-runner/tests/input/3_hooks.js create mode 100644 recipes/mocha-to-node-test-runner/tests/input/4_done.js create mode 100644 recipes/mocha-to-node-test-runner/tests/input/5_skipped.js create mode 100644 recipes/mocha-to-node-test-runner/tests/input/6_dynamic.js create mode 100644 recipes/mocha-to-node-test-runner/workflow.yaml create mode 100644 utils/src/is-esm.ts 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..e0fc812d --- /dev/null +++ b/recipes/mocha-to-node-test-runner/README.md @@ -0,0 +1,56 @@ +# Chalk to util.styleText + +This recipe migrates from the external `chalk` package to Node.js built-in `util.styleText` API. It transforms chalk method calls to use the native Node.js styling functionality. + +## Examples + +```diff +- import chalk from 'chalk'; ++ import { styleText } from 'node:util'; +- console.log(chalk.red('Error message')); ++ console.log(styleText('red', 'Error message')); +- console.log(chalk.green('Success message')); ++ console.log(styleText('green', 'Success message')); +- console.log(chalk.blue('Info message')); ++ console.log(styleText('blue', 'Info message')); +``` + +```diff +- import chalk from 'chalk'; ++ import { styleText } from 'node:util'; +- console.log(chalk.red.bold('Important error')); ++ console.log(styleText(['red', 'bold'], 'Important error')); +- console.log(chalk.green.underline('Success with emphasis')); ++ console.log(styleText(['green', 'underline'], 'Success with emphasis')); +``` + +```diff +- const chalk = require('chalk'); ++ const { styleText } = require('node:util'); +- const red = chalk.red; ++ const red = (text) => styleText('red', text); +- const boldBlue = chalk.blue.bold; ++ const boldBlue = (text) => styleText(['blue', 'bold'], text); +- console.log(red('Error')); ++ console.log(red('Error')); +- console.log(boldBlue('Info')); ++ console.log(boldBlue('Info')); +``` + +## Usage + +Run this codemod with: + +```sh +npx codemod nodejs/chalk-to-util-styletext +``` + +## Compatibility + +- **Removes chalk dependency** from package.json automatically +- **Supports most chalk methods**: colors, background colors, and text modifiers +- **Unsupported methods**: `hex()`, `rgb()`, `ansi256()`, `bgAnsi256()`, `visible()` (warnings will be shown) + +## Limitations + +- **Complex conditional expressions** in some contexts may need manual review 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..7c9d10ce --- /dev/null +++ b/recipes/mocha-to-node-test-runner/codemod.yaml @@ -0,0 +1,23 @@ +schema_version: "1.0" +name: "@nodejs/mocha-to-node-test-runner" +version: 1.0.0 +capabilities: + - fs + - child_process +description: FIXME +author: Xavier Stouder +license: MIT +workflow: workflow.yaml +category: migration + +targets: + languages: + - javascript + - typescript + +keywords: + - FIXME + +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..619aad60 --- /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": "FIXME", + "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..0591fbcf --- /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 removeChalkDependencies(): string | null { + return removeDependencies(['chalk', '@types/chalk']); +} 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..29e67d3d --- /dev/null +++ b/recipes/mocha-to-node-test-runner/src/workflow.ts @@ -0,0 +1,200 @@ +import type { Edit, SgRoot } from '@codemod.com/jssg-types/main'; +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 JS from '@codemod.com/jssg-types/langs/javascript'; + +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, + ].flatMap((transform) => transform(root)); + + if (!edits.length) { + 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 (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 + ? `\nimport { ${imports} } from 'node:test';` + : `\nconst { ${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, + }, + ] as Edit[]; + } + } 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, + }, + ] as Edit[]; + } + } + return [ + { + startPos: 0, + endPos: 0, + insertedText, + }, + ] as Edit[]; +} + +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 })', + }, + ], + }, + }) + .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; + }); +} 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/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/workflow.yaml b/recipes/mocha-to-node-test-runner/workflow.yaml new file mode 100644 index 00000000..af2d47fb --- /dev/null +++ b/recipes/mocha-to-node-test-runner/workflow.yaml @@ -0,0 +1,42 @@ +# 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: FIXME + 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: FIXME + type: automatic + steps: + - name: FIXME + 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.ts b/utils/src/is-esm.ts new file mode 100644 index 00000000..466aff46 --- /dev/null +++ b/utils/src/is-esm.ts @@ -0,0 +1,39 @@ +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 rootNode = root.root(); + const usingRequire = rootNode.find({ + rule: { + kind: 'call_expression', + has: { + kind: 'identifier', + field: 'function', + regex: 'require', + }, + }, + }); + const usingImport = rootNode.find({ + rule: { + kind: 'import_statement', + }, + }); + const filename = root.filename(); + + const isCjsFile = filename.endsWith('.cjs') || filename.endsWith('.cts'); + const isMjsFile = filename.endsWith('.mjs') || filename.endsWith('.mts'); + + if (usingImport || isMjsFile) { + return true; + } + + if (usingRequire || isCjsFile) { + return false; + } + + const packageJsonPath = join(process.cwd(), 'package.json'); + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')); + return packageJson.type === 'module'; +} From 11d3fd6293bd9c0ec0502f9fe85cb5934eff82e6 Mon Sep 17 00:00:00 2001 From: Xavier Stouder Date: Mon, 22 Dec 2025 00:43:03 +0100 Subject: [PATCH 02/10] wip --- .../mocha-to-node-test-runner/src/workflow.ts | 58 +++++++++++++++++-- .../tests/expected/7_timeouts.js | 15 +++++ .../tests/input/7_timeouts.js | 14 +++++ 3 files changed, 81 insertions(+), 6 deletions(-) create mode 100644 recipes/mocha-to-node-test-runner/tests/expected/7_timeouts.js create mode 100644 recipes/mocha-to-node-test-runner/tests/input/7_timeouts.js diff --git a/recipes/mocha-to-node-test-runner/src/workflow.ts b/recipes/mocha-to-node-test-runner/src/workflow.ts index 29e67d3d..0d325ebe 100644 --- a/recipes/mocha-to-node-test-runner/src/workflow.ts +++ b/recipes/mocha-to-node-test-runner/src/workflow.ts @@ -1,4 +1,4 @@ -import type { Edit, SgRoot } from '@codemod.com/jssg-types/main'; +import type { Edit, Range, SgRoot } from '@codemod.com/jssg-types/main'; 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'; @@ -23,13 +23,17 @@ export default function transform(root: SgRoot): string | null { transformImport, transformDoneCallbacks, transformThisSkip, + transformThisTimeout, ].flatMap((transform) => transform(root)); - if (!edits.length) { return null; } - return rootNode.commitEdits(edits); + return rootNode + .commitEdits(edits) + .split('\n') + .map((line) => (line.trim() === '' ? line.trim() : line)) + .join('\n'); } function transformImport(root: SgRoot): Edit[] { @@ -95,7 +99,7 @@ function transformImport(root: SgRoot): Edit[] { endPos: lastImportStatement.range().end.index, insertedText, }, - ] as Edit[]; + ]; } } else { const requireStatements = rootNode.findAll({ @@ -110,7 +114,7 @@ function transformImport(root: SgRoot): Edit[] { endPos: lastRequireStatements.range().end.index, insertedText, }, - ] as Edit[]; + ]; } } return [ @@ -119,7 +123,7 @@ function transformImport(root: SgRoot): Edit[] { endPos: 0, insertedText, }, - ] as Edit[]; + ]; } function transformDoneCallbacks(root: SgRoot): Edit[] { @@ -145,6 +149,18 @@ function transformDoneCallbacks(root: SgRoot): Edit[] { { 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 })', + }, ], }, }) @@ -198,3 +214,33 @@ function transformThisSkip(root: SgRoot): Edit[] { 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(); + edits.push(thisTimeoutExpression.replace('')); + + 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/7_timeouts.js b/recipes/mocha-to-node-test-runner/tests/expected/7_timeouts.js new file mode 100644 index 00000000..15482e8a --- /dev/null +++ b/recipes/mocha-to-node-test-runner/tests/expected/7_timeouts.js @@ -0,0 +1,15 @@ +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/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 + }); +}); From 99e0ab47424662fb3bcbdd5da9e79bffe24c00a3 Mon Sep 17 00:00:00 2001 From: Xavier Stouder Date: Mon, 22 Dec 2025 22:33:46 +0100 Subject: [PATCH 03/10] wip --- recipes/mocha-to-node-test-runner/README.md | 169 ++++++++++++++---- .../src/remove-dependencies.ts | 8 - .../mocha-to-node-test-runner/workflow.yaml | 17 -- 3 files changed, 133 insertions(+), 61 deletions(-) delete mode 100644 recipes/mocha-to-node-test-runner/src/remove-dependencies.ts diff --git a/recipes/mocha-to-node-test-runner/README.md b/recipes/mocha-to-node-test-runner/README.md index e0fc812d..9b0db7fa 100644 --- a/recipes/mocha-to-node-test-runner/README.md +++ b/recipes/mocha-to-node-test-runner/README.md @@ -1,56 +1,153 @@ -# Chalk to util.styleText +# Mocha to Node.js Test Runner +This recipe migrate Mocha v8 tests to Node.js test runner (v22, v24+) -This recipe migrates from the external `chalk` package to Node.js built-in `util.styleText` API. It transforms chalk method calls to use the native Node.js styling functionality. +## 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 -- import chalk from 'chalk'; -+ import { styleText } from 'node:util'; -- console.log(chalk.red('Error message')); -+ console.log(styleText('red', 'Error message')); -- console.log(chalk.green('Success message')); -+ console.log(styleText('green', 'Success message')); -- console.log(chalk.blue('Info message')); -+ console.log(styleText('blue', 'Info message')); + 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 -- import chalk from 'chalk'; -+ import { styleText } from 'node:util'; -- console.log(chalk.red.bold('Important error')); -+ console.log(styleText(['red', 'bold'], 'Important error')); -- console.log(chalk.green.underline('Success with emphasis')); -+ console.log(styleText(['green', 'underline'], 'Success with emphasis')); + 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 chalk = require('chalk'); -+ const { styleText } = require('node:util'); -- const red = chalk.red; -+ const red = (text) => styleText('red', text); -- const boldBlue = chalk.blue.bold; -+ const boldBlue = (text) => styleText(['blue', 'bold'], text); -- console.log(red('Error')); -+ console.log(red('Error')); -- console.log(boldBlue('Info')); -+ console.log(boldBlue('Info')); +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); + }); +}) ``` -## Usage +### 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); + }); -Run this codemod with: +- it('should also be skipped 2', (done) => { +- this.skip(); ++ it('should also be skipped 2', (t, done) => { ++ t.skip(); + assert.strictEqual(1 + 1, 3); + }); -```sh -npx codemod nodejs/chalk-to-util-styletext +- it('should also be skipped 3', x => { +- this.skip(); ++ it('should also be skipped 3', (t, x) => { ++ t.skip(); + assert.strictEqual(1 + 1, 3); + }); + }) ``` -## Compatibility +### 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); + }); + }); + }); +``` -- **Removes chalk dependency** from package.json automatically -- **Supports most chalk methods**: colors, background colors, and text modifiers -- **Unsupported methods**: `hex()`, `rgb()`, `ansi256()`, `bgAnsi256()`, `visible()` (warnings will be shown) +### 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) => { -## Limitations +- 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. -- **Complex conditional expressions** in some contexts may need manual review +## References +- [Node Test Runner](https://nodejs.org/api/test.html) +- [Mocha](https://mochajs.org/) diff --git a/recipes/mocha-to-node-test-runner/src/remove-dependencies.ts b/recipes/mocha-to-node-test-runner/src/remove-dependencies.ts deleted file mode 100644 index 0591fbcf..00000000 --- a/recipes/mocha-to-node-test-runner/src/remove-dependencies.ts +++ /dev/null @@ -1,8 +0,0 @@ -import removeDependencies from '@nodejs/codemod-utils/remove-dependencies'; - -/** - * Remove chalk and @types/chalk dependencies from package.json - */ -export default function removeChalkDependencies(): string | null { - return removeDependencies(['chalk', '@types/chalk']); -} diff --git a/recipes/mocha-to-node-test-runner/workflow.yaml b/recipes/mocha-to-node-test-runner/workflow.yaml index af2d47fb..8611b0ad 100644 --- a/recipes/mocha-to-node-test-runner/workflow.yaml +++ b/recipes/mocha-to-node-test-runner/workflow.yaml @@ -23,20 +23,3 @@ nodes: exclude: - "**/node_modules/**" language: typescript - - - id: remove-dependencies - name: FIXME - type: automatic - steps: - - name: FIXME - js-ast-grep: - js_file: src/remove-dependencies.ts - base_path: . - include: - - "**/package.json" - exclude: - - "**/node_modules/**" - language: typescript - capabilities: - - child_process - - fs From ef337d582a8a1688369749ab76f4ef87fa6a06b8 Mon Sep 17 00:00:00 2001 From: Xavier Stouder Date: Mon, 22 Dec 2025 22:38:53 +0100 Subject: [PATCH 04/10] wip --- recipes/mocha-to-node-test-runner/codemod.yaml | 7 +++++-- recipes/mocha-to-node-test-runner/package.json | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/recipes/mocha-to-node-test-runner/codemod.yaml b/recipes/mocha-to-node-test-runner/codemod.yaml index 7c9d10ce..3c74371b 100644 --- a/recipes/mocha-to-node-test-runner/codemod.yaml +++ b/recipes/mocha-to-node-test-runner/codemod.yaml @@ -4,7 +4,7 @@ version: 1.0.0 capabilities: - fs - child_process -description: FIXME +description: Converts Mocha v8 tests to Node.js test runner (v22, v24+) author: Xavier Stouder license: MIT workflow: workflow.yaml @@ -16,7 +16,10 @@ targets: - typescript keywords: - - FIXME + - transformation + - migration + - mocha + - test registry: access: public diff --git a/recipes/mocha-to-node-test-runner/package.json b/recipes/mocha-to-node-test-runner/package.json index 619aad60..7aea88cb 100644 --- a/recipes/mocha-to-node-test-runner/package.json +++ b/recipes/mocha-to-node-test-runner/package.json @@ -1,7 +1,7 @@ { "name": "@nodejs/mocha-to-node-test-runner", "version": "1.0.0", - "description": "FIXME", + "description": "Converts Mocha v8 tests to Node.js test runner (v22, v24+)", "type": "module", "scripts": { "test": "npx codemod jssg test -l typescript ./src/workflow.ts ./" From 759291ae6aec3f85250394d905f8197a297f1330 Mon Sep 17 00:00:00 2001 From: Xavier Stouder Date: Mon, 22 Dec 2025 23:57:47 +0100 Subject: [PATCH 05/10] wip --- recipes/mocha-to-node-test-runner/codemod.yaml | 2 +- recipes/mocha-to-node-test-runner/package.json | 2 +- recipes/mocha-to-node-test-runner/workflow.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/recipes/mocha-to-node-test-runner/codemod.yaml b/recipes/mocha-to-node-test-runner/codemod.yaml index 3c74371b..f220c801 100644 --- a/recipes/mocha-to-node-test-runner/codemod.yaml +++ b/recipes/mocha-to-node-test-runner/codemod.yaml @@ -4,7 +4,7 @@ version: 1.0.0 capabilities: - fs - child_process -description: Converts Mocha v8 tests to Node.js test runner (v22, v24+) +description: Migrate Mocha v8 tests to Node.js test runner (v22, v24+) author: Xavier Stouder license: MIT workflow: workflow.yaml diff --git a/recipes/mocha-to-node-test-runner/package.json b/recipes/mocha-to-node-test-runner/package.json index 7aea88cb..fb78ed15 100644 --- a/recipes/mocha-to-node-test-runner/package.json +++ b/recipes/mocha-to-node-test-runner/package.json @@ -1,7 +1,7 @@ { "name": "@nodejs/mocha-to-node-test-runner", "version": "1.0.0", - "description": "Converts Mocha v8 tests to Node.js test runner (v22, v24+)", + "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 ./" diff --git a/recipes/mocha-to-node-test-runner/workflow.yaml b/recipes/mocha-to-node-test-runner/workflow.yaml index 8611b0ad..b66312cf 100644 --- a/recipes/mocha-to-node-test-runner/workflow.yaml +++ b/recipes/mocha-to-node-test-runner/workflow.yaml @@ -7,7 +7,7 @@ nodes: name: Apply AST Transformations type: automatic steps: - - name: FIXME + - name: Migrate Mocha v8 tests to Node.js test runner (v22, v24+) js-ast-grep: js_file: src/workflow.ts base_path: . From 7262ab1777be7153801549cf6308788de41f0a47 Mon Sep 17 00:00:00 2001 From: Xavier Stouder Date: Tue, 23 Dec 2025 19:12:50 +0100 Subject: [PATCH 06/10] Apply suggestions from code review Co-authored-by: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> --- recipes/mocha-to-node-test-runner/README.md | 19 +++++++++++++++++++ .../mocha-to-node-test-runner/src/workflow.ts | 19 ++++++------------- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/recipes/mocha-to-node-test-runner/README.md b/recipes/mocha-to-node-test-runner/README.md index 9b0db7fa..bc55e47b 100644 --- a/recipes/mocha-to-node-test-runner/README.md +++ b/recipes/mocha-to-node-test-runner/README.md @@ -1,7 +1,9 @@ # 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 @@ -13,7 +15,9 @@ This recipe migrate Mocha v8 tests to Node.js test runner (v22, v24+) ## Examples ### Example 1: Basic + ```diff +``` const assert = require('assert'); +const { describe, it } = require('node:test'); @@ -28,7 +32,9 @@ This recipe migrate Mocha v8 tests to Node.js test runner (v22, v24+) ``` ### Example 2: Async + ```diff +``` const assert = require('assert'); +const { describe, it } = require('node:test'); describe('Async Test', function() { @@ -41,7 +47,9 @@ This recipe migrate Mocha v8 tests to Node.js test runner (v22, v24+) ``` ### Example 3: Hooks + ```diff +``` const assert = require('assert'); const fs = require('fs'); +const { describe, before, after, it } = require('node:test'); @@ -62,7 +70,9 @@ This recipe migrate Mocha v8 tests to Node.js test runner (v22, v24+) ``` ### Example 4: Done + ```diff +``` const assert = require('assert'); +const { describe, it } = require('node:test'); describe('Callback Test', function() { @@ -77,7 +87,9 @@ describe('Callback Test', function() { ``` ### Example 5: Skipped + ```diff +``` const assert = require('assert'); +const { describe, it } = require('node:test'); describe('Skipped Test', () => { @@ -108,11 +120,14 @@ describe('Callback Test', function() { ``` ### 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); @@ -122,6 +137,7 @@ describe('Callback Test', function() { ``` ### Example 7: Timeouts + ```diff const assert = require('assert'); -describe('Timeout Test', function() { @@ -145,8 +161,11 @@ const assert = require('assert'); }); }); ``` + ## 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) diff --git a/recipes/mocha-to-node-test-runner/src/workflow.ts b/recipes/mocha-to-node-test-runner/src/workflow.ts index 0d325ebe..1075d62a 100644 --- a/recipes/mocha-to-node-test-runner/src/workflow.ts +++ b/recipes/mocha-to-node-test-runner/src/workflow.ts @@ -1,7 +1,7 @@ -import type { Edit, Range, SgRoot } from '@codemod.com/jssg-types/main'; 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, Range, SgRoot } from '@codemod.com/jssg-types/main'; import type JS from '@codemod.com/jssg-types/langs/javascript'; export default function transform(root: SgRoot): string | null { @@ -15,9 +15,7 @@ export default function transform(root: SgRoot): string | null { .some((pattern) => rootNode.findAll({ rule: { pattern } }).length > 0), ); - if (usedGlobalIdentifiers.length === 0) { - return null; - } + if (!usedGlobalIdentifiers.length) return null; const edits = [ transformImport, @@ -25,9 +23,7 @@ export default function transform(root: SgRoot): string | null { transformThisSkip, transformThisTimeout, ].flatMap((transform) => transform(root)); - if (!edits.length) { - return null; - } + if (!edits.length) return null; return rootNode .commitEdits(edits) @@ -68,18 +64,15 @@ function transformImport(root: SgRoot): Edit[] { ), ), ]; - if (usedMochaGlobals.length === 0) { - return []; - } + // if mocha isn't founded don't try to apply change + if (!usedMochaGlobals.length) return []; const esm = isESM(root); const existingNodeTestImports = esm ? getNodeImportStatements(rootNode.getRoot(), 'test') : getNodeRequireCalls(rootNode.getRoot(), 'test'); - if (existingNodeTestImports.length > 0) { - return []; - } + if (!existingNodeTestImports.length) return []; const imports = usedMochaGlobals.join(', '); From ebdc99673d8bac0c2e3eae6f200199a58f130a45 Mon Sep 17 00:00:00 2001 From: Xavier Stouder Date: Tue, 23 Dec 2025 20:10:34 +0100 Subject: [PATCH 07/10] wip --- .../mocha-to-node-test-runner/src/workflow.ts | 13 +- utils/src/is-esm.test.ts | 168 ++++++++++++++++++ utils/src/is-esm.ts | 36 ++-- 3 files changed, 196 insertions(+), 21 deletions(-) create mode 100644 utils/src/is-esm.test.ts diff --git a/recipes/mocha-to-node-test-runner/src/workflow.ts b/recipes/mocha-to-node-test-runner/src/workflow.ts index 1075d62a..511e90f9 100644 --- a/recipes/mocha-to-node-test-runner/src/workflow.ts +++ b/recipes/mocha-to-node-test-runner/src/workflow.ts @@ -1,7 +1,7 @@ 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, Range, SgRoot } from '@codemod.com/jssg-types/main'; +import type { Edit, SgRoot } from '@codemod.com/jssg-types/main'; import type JS from '@codemod.com/jssg-types/langs/javascript'; export default function transform(root: SgRoot): string | null { @@ -15,7 +15,7 @@ export default function transform(root: SgRoot): string | null { .some((pattern) => rootNode.findAll({ rule: { pattern } }).length > 0), ); - if (!usedGlobalIdentifiers.length) return null; + if (usedGlobalIdentifiers.length === 0) return null; const edits = [ transformImport, @@ -23,7 +23,7 @@ export default function transform(root: SgRoot): string | null { transformThisSkip, transformThisTimeout, ].flatMap((transform) => transform(root)); - if (!edits.length) return null; + if (edits.length === 0) return null; return rootNode .commitEdits(edits) @@ -64,15 +64,16 @@ function transformImport(root: SgRoot): Edit[] { ), ), ]; - // if mocha isn't founded don't try to apply change - if (!usedMochaGlobals.length) return []; + + // 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) return []; + if (existingNodeTestImports.length > 0) return []; const imports = usedMochaGlobals.join(', '); 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 index 466aff46..fd99db19 100644 --- a/utils/src/is-esm.ts +++ b/utils/src/is-esm.ts @@ -4,7 +4,27 @@ 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', @@ -15,21 +35,7 @@ export default function isESM(root: SgRoot): boolean { }, }, }); - const usingImport = rootNode.find({ - rule: { - kind: 'import_statement', - }, - }); - const filename = root.filename(); - - const isCjsFile = filename.endsWith('.cjs') || filename.endsWith('.cts'); - const isMjsFile = filename.endsWith('.mjs') || filename.endsWith('.mts'); - - if (usingImport || isMjsFile) { - return true; - } - - if (usingRequire || isCjsFile) { + if (usingRequire) { return false; } From be89c049e3c98435680656e5c01192fa48b08960 Mon Sep 17 00:00:00 2001 From: Xavier Stouder Date: Fri, 26 Dec 2025 20:27:31 +0100 Subject: [PATCH 08/10] fix --- .../mocha-to-node-test-runner/src/workflow.ts | 17 ++++++++++++++++- .../tests/expected/7_timeouts.js | 3 --- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/recipes/mocha-to-node-test-runner/src/workflow.ts b/recipes/mocha-to-node-test-runner/src/workflow.ts index 511e90f9..0b955851 100644 --- a/recipes/mocha-to-node-test-runner/src/workflow.ts +++ b/recipes/mocha-to-node-test-runner/src/workflow.ts @@ -218,7 +218,22 @@ function transformThisTimeout(root: SgRoot): Edit[] { return thisTimeoutCalls.flatMap((thisTimeoutCall) => { const edits = [] as Edit[]; const thisTimeoutExpression = thisTimeoutCall.parent(); - edits.push(thisTimeoutExpression.replace('')); + + 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] !== '\n') lineStart--; + let lineEnd = endIndex; + while (lineEnd < source.length && source[lineEnd] !== '\n') lineEnd++; + if (lineEnd < source.length) lineEnd++; + + edits.push({ + startPos: lineStart, + endPos: lineEnd, + insertedText: '', + }); const enclosingFunction = thisTimeoutCall .ancestors() 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 index 15482e8a..3e36ee55 100644 --- a/recipes/mocha-to-node-test-runner/tests/expected/7_timeouts.js +++ b/recipes/mocha-to-node-test-runner/tests/expected/7_timeouts.js @@ -2,14 +2,11 @@ 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 }); }); From ead6abc55e48fa88f65ae6c3f0626166411f4892 Mon Sep 17 00:00:00 2001 From: Xavier Stouder Date: Tue, 30 Dec 2025 19:10:45 +0100 Subject: [PATCH 09/10] fix --- recipes/mocha-to-node-test-runner/README.md | 1 - .../src/remove-dependencies.ts | 8 ++++++++ recipes/mocha-to-node-test-runner/workflow.yaml | 16 ++++++++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 recipes/mocha-to-node-test-runner/src/remove-dependencies.ts diff --git a/recipes/mocha-to-node-test-runner/README.md b/recipes/mocha-to-node-test-runner/README.md index bc55e47b..f6a86ec0 100644 --- a/recipes/mocha-to-node-test-runner/README.md +++ b/recipes/mocha-to-node-test-runner/README.md @@ -165,7 +165,6 @@ const assert = require('assert'); ## 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) 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/workflow.yaml b/recipes/mocha-to-node-test-runner/workflow.yaml index b66312cf..a4b26d57 100644 --- a/recipes/mocha-to-node-test-runner/workflow.yaml +++ b/recipes/mocha-to-node-test-runner/workflow.yaml @@ -23,3 +23,19 @@ nodes: 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 From cf520d26f51548e601b8d00d2c81489aa11bc2f4 Mon Sep 17 00:00:00 2001 From: Xavier Stouder Date: Tue, 30 Dec 2025 21:26:29 +0100 Subject: [PATCH 10/10] fix --- recipes/mocha-to-node-test-runner/src/workflow.ts | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/recipes/mocha-to-node-test-runner/src/workflow.ts b/recipes/mocha-to-node-test-runner/src/workflow.ts index 0b955851..f93003e4 100644 --- a/recipes/mocha-to-node-test-runner/src/workflow.ts +++ b/recipes/mocha-to-node-test-runner/src/workflow.ts @@ -3,6 +3,7 @@ import { getNodeImportStatements } from '@nodejs/codemod-utils/ast-grep/import-s 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(); @@ -25,11 +26,7 @@ export default function transform(root: SgRoot): string | null { ].flatMap((transform) => transform(root)); if (edits.length === 0) return null; - return rootNode - .commitEdits(edits) - .split('\n') - .map((line) => (line.trim() === '' ? line.trim() : line)) - .join('\n'); + return rootNode.commitEdits(edits); } function transformImport(root: SgRoot): Edit[] { @@ -78,8 +75,8 @@ function transformImport(root: SgRoot): Edit[] { const imports = usedMochaGlobals.join(', '); const insertedText = esm - ? `\nimport { ${imports} } from 'node:test';` - : `\nconst { ${imports} } = require('node:test');`; + ? `${EOL}import { ${imports} } from 'node:test';` + : `${EOL}const { ${imports} } = require('node:test');`; if (esm) { const importStatements = rootNode.findAll({ @@ -224,9 +221,9 @@ function transformThisTimeout(root: SgRoot): Edit[] { const endIndex = thisTimeoutExpression.range().end.index; let lineStart = startIndex; - while (lineStart > 0 && source[lineStart - 1] !== '\n') lineStart--; + while (lineStart > 0 && source[lineStart - 1] !== EOL) lineStart--; let lineEnd = endIndex; - while (lineEnd < source.length && source[lineEnd] !== '\n') lineEnd++; + while (lineEnd < source.length && source[lineEnd] !== EOL) lineEnd++; if (lineEnd < source.length) lineEnd++; edits.push({