diff --git a/package-lock.json b/package-lock.json index 4728f23d..660fe7bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1527,6 +1527,10 @@ "resolved": "recipes/import-assertions-to-attributes", "link": true }, + "node_modules/@nodejs/mock-module-exports": { + "resolved": "recipes/mock-module-exports", + "link": true + }, "node_modules/@nodejs/node-url-to-whatwg-url": { "resolved": "recipes/node-url-to-whatwg-url", "link": true @@ -4438,6 +4442,17 @@ "@codemod.com/jssg-types": "^1.5.0" } }, + "recipes/mock-module-exports": { + "name": "@nodejs/mock-module-exports", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@nodejs/codemod-utils": "0.0.0" + }, + "devDependencies": { + "@codemod.com/jssg-types": "^1.3.1" + } + }, "recipes/node-url-to-whatwg-url": { "name": "@nodejs/node-url-to-whatwg-url", "version": "1.0.1", diff --git a/recipes/mock-module-exports/README.md b/recipes/mock-module-exports/README.md new file mode 100644 index 00000000..633e9e5f --- /dev/null +++ b/recipes/mock-module-exports/README.md @@ -0,0 +1,19 @@ +# Mock Module Exports + +This migration trasforming use of deprecated `options.defaultExport` and `options.namedExports` on +`node:test.mock` + +## Example + +```diff +mock.module('…', { +- defaultExport: …, +- namedExports: { +- foo: … +- }, ++ exports: { ++ default: …, ++ foo: …, ++ }, +}); +``` diff --git a/recipes/mock-module-exports/codemod.yaml b/recipes/mock-module-exports/codemod.yaml new file mode 100644 index 00000000..5643f3b6 --- /dev/null +++ b/recipes/mock-module-exports/codemod.yaml @@ -0,0 +1,21 @@ +schema_version: "1.0" +name: "@nodejs/mock-module-exports" +version: 1.0.0 +description: "Handle mock.module exports deprecation" +author: Bruno Rodrigues +license: MIT +workflow: workflow.yaml +category: migration + +targets: + languages: + - javascript + - typescript + +keywords: + - transformation + - migration + +registry: + access: public + visibility: public diff --git a/recipes/mock-module-exports/package.json b/recipes/mock-module-exports/package.json new file mode 100644 index 00000000..8f0055e0 --- /dev/null +++ b/recipes/mock-module-exports/package.json @@ -0,0 +1,24 @@ +{ + "name": "@nodejs/mock-module-exports", + "version": "1.0.0", + "description": "Handle mock.module exports deprecation", + "type": "module", + "scripts": { + "test": "npx codemod jssg test -l typescript ./src/workflow.ts ./tests/" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/nodejs/userland-migrations.git", + "directory": "recipes/mock-module-exports", + "bugs": "https://github.com/nodejs/userland-migrations/issues" + }, + "author": "Bruno Rodrigues", + "license": "MIT", + "homepage": "https://github.com/nodejs/userland-migrations/blob/main/recipes/mock-module-exports/README.md", + "devDependencies": { + "@codemod.com/jssg-types": "^1.3.1" + }, + "dependencies": { + "@nodejs/codemod-utils": "*" + } +} diff --git a/recipes/mock-module-exports/src/workflow.ts b/recipes/mock-module-exports/src/workflow.ts new file mode 100644 index 00000000..76105183 --- /dev/null +++ b/recipes/mock-module-exports/src/workflow.ts @@ -0,0 +1,281 @@ +import type { SgRoot, Edit, SgNode, Kinds } from '@codemod.com/jssg-types/main'; +import type JS from '@codemod.com/jssg-types/langs/javascript'; +import { getModuleDependencies } from '@nodejs/codemod-utils/ast-grep/module-dependencies'; +import { resolveBindingPath } from '@nodejs/codemod-utils/ast-grep/resolve-binding-path'; +import { + detectIndentUnit, + getLineIndent, +} from '@nodejs/codemod-utils/ast-grep/indent'; +import { EOL } from 'node:os'; + +type QueueEvent = { + event: keyof typeof parsers; + handler: () => void; +}; + +const queue: QueueEvent[] = []; + +type Pair = { + before: SgNode | SgNode; + after: string; +}; + +type ExportedValue = { + node: SgNode>; + default: Pair; + named: Pair[] | undefined; +}; +const exportedValues: Map = new Map(); + +const parsers = { + parseOptions: (optionsNode: SgNode>) => { + switch (optionsNode.kind()) { + case 'object': + queue.push( + { + event: 'defaultExport', + handler: () => parsers.defaultExport(optionsNode), + }, + { + event: 'namedExports', + handler: () => parsers.namedExports(optionsNode), + }, + { + event: 'spreadElements', + handler: () => parsers.spreadElements(optionsNode), + }, + ); + break; + case 'identifier': + queue.push({ + event: 'resolveVariables', + handler: () => parsers.resolveVariables(optionsNode), + }); + break; + case 'call_expression': + queue.push({ + event: 'resolveVariables', + handler: () => + parsers.resolveVariables(optionsNode.field('function')), + }); + break; + } + }, + resolveVariables: (node: SgNode>) => { + const definition = node.definition(); + if (!definition) return; + + switch (definition.node.parent().kind()) { + case 'variable_declarator': { + const parent = definition.node.parent<'variable_declarator'>(); + queue.push({ + event: 'parseOptions', + handler: () => parsers.parseOptions(parent.field('value')), + }); + break; + } + case 'function_declaration': { + const fnDeclaration = definition.node.parent<'variable_declarator'>(); + + const returns = fnDeclaration + .findAll<'return_statement'>({ + rule: { + kind: 'return_statement', + }, + }) + .map((n) => n.child(1)); + + for (const ret of returns) { + if (ret) { + queue.push({ + event: 'parseOptions', + handler: () => parsers.parseOptions(ret), + }); + } + } + + break; + } + default: + throw new Error('unhandled scenario'); + } + }, + defaultExport: (node: SgNode>): Edit[] => { + const edits: Edit[] = []; + const defaultExport = node.find<'pair'>({ + rule: { + kind: 'pair', + has: { + field: 'key', + kind: 'property_identifier', + regex: 'defaultExport', + }, + }, + }); + if (defaultExport) { + const change = { + before: defaultExport, + after: `default: ${defaultExport?.field('value').text()}`, + }; + + if (!exportedValues.has(node.id())) { + exportedValues.set(node.id(), { + node, + default: change, + named: [], + }); + return; + } + + const n = exportedValues.get(node.id()); + n.default = change; + } + return edits; + }, + namedExports: (node: SgNode>) => { + const namedExport = node.find<'pair'>({ + rule: { + kind: 'pair', + has: { + field: 'key', + kind: 'property_identifier', + regex: 'namedExport', + }, + }, + }); + + if (namedExport) { + if (!exportedValues.has(node.id())) { + exportedValues.set(node.id(), { + node, + default: undefined, + named: [], + }); + } + + const pairs = exportedValues.get(node.id()).named; + + const fieldValueNode = namedExport.field('value'); + + if (fieldValueNode.is('identifier')) { + pairs.push({ + before: namedExport, + after: `...(${fieldValueNode.text()} || {})`, + }); + } + for (const namedPair of fieldValueNode.children()) { + if (namedPair.is('pair')) { + pairs.push({ + before: namedPair, + after: namedPair.text(), + }); + } + } + } + }, + spreadElements: (node: SgNode>): undefined => { + const spreadElements = node.findAll<'spread_element'>({ + rule: { + kind: 'spread_element', + }, + }); + + if (spreadElements) { + if (!exportedValues.has(node.id())) { + exportedValues.set(node.id(), { + node, + default: undefined, + named: [], + }); + } + + const pairs = exportedValues.get(node.id()).named; + + for (const spread of spreadElements) { + pairs.push({ + before: spread, + after: spread.text(), + }); + } + } + }, +} as const satisfies Record>) => void>; + +export default function transform(root: SgRoot): string | null { + const rootNode = root.root(); + const edits: Edit[] = []; + + const deps = getModuleDependencies(root, 'test'); + let moduleFnCalls: SgNode[] = []; + + if (!deps.length) return null; + + for (const dep of deps) { + const moduleFn = resolveBindingPath(dep, '$.mock.module'); + + const fnCallNodes = rootNode.findAll<'call_expression'>({ + rule: { + kind: 'call_expression', + has: { + any: [ + { + kind: 'member_expression', + pattern: moduleFn, + }, + ], + }, + }, + }); + + moduleFnCalls = moduleFnCalls.concat(fnCallNodes); + } + + for (const moduleFnCall of moduleFnCalls) { + const argumentsNode = moduleFnCall.field<'arguments'>('arguments'); + const args = argumentsNode.children().filter((node) => node.isNamed()); + + if (args.length < 2) continue; + const optionsArg = args[1]; + queue.push({ + event: 'parseOptions', + handler: () => parsers.parseOptions(optionsArg), + }); + } + + const indentUnit = detectIndentUnit(rootNode.text()); + + let i = 0; + while (queue.length > i) { + const event = queue.at(i); + event.handler(); + i++; + } + + for (const [_nodeId, change] of Array.from(exportedValues)) { + const indentLevel = getLineIndent( + rootNode.text(), + change.node.range().start.index, + ); + + const exportsLevel = `${indentLevel}${indentUnit}`; + const innerExports = `${exportsLevel}${indentUnit}`; + + let newValue = `{${EOL}` + `${exportsLevel}exports: {${EOL}`; + + if (change.default?.after) { + newValue += `${innerExports}${change.default.after},${EOL}`; + } + + if (change.named?.length) { + newValue += `${innerExports}${change.named.map((t) => t.after).join(`,${EOL}${innerExports}`)},${EOL}`; + } + + newValue += `${exportsLevel}},${EOL}` + `${indentLevel}}`; + + edits.push(change.node.replace(newValue)); + } + + if (!edits.length) return null; + + return rootNode.commitEdits(edits); +} diff --git a/recipes/mock-module-exports/tests/basic/expected.mjs b/recipes/mock-module-exports/tests/basic/expected.mjs new file mode 100644 index 00000000..4361f0d5 --- /dev/null +++ b/recipes/mock-module-exports/tests/basic/expected.mjs @@ -0,0 +1,53 @@ +import { mock } from 'node:test'; + +mock.module('example', { + exports: { + default: 'bar', + foo: 'foo', + baz: 'baz', + }, +}); + +const namedExports = { + foo: 'foo', + baz: 'baz', +} + +mock.module('example2', { + exports: { + default: 'bar', + ...(namedExports || {}), + }, +}); + +function getDefault() { + return 'bar' +} + +function getBar() { + return 'bar' +} + +mock.module('example3', { + exports: { + default: getDefault(), + bar: getBar(), + }, +}); + +mock.module('example-no-named', { + exports: { + default: getDefault(), + }, +}); + +mock.module('example-no-fault', { + exports: { + bar: 'bar', + }, +}); + +mock.module('example-empty', { + exports: { + }, +}); diff --git a/recipes/mock-module-exports/tests/basic/input.mjs b/recipes/mock-module-exports/tests/basic/input.mjs new file mode 100644 index 00000000..2f4c1d5f --- /dev/null +++ b/recipes/mock-module-exports/tests/basic/input.mjs @@ -0,0 +1,55 @@ +import { mock } from 'node:test'; + +mock.module('example', { + defaultExport: 'bar', + namedExports: { + foo: 'foo', + baz: 'baz', + }, +}); + +const namedExports = { + foo: 'foo', + baz: 'baz', +} + +mock.module('example2', { + defaultExport: 'bar', + namedExports: namedExports, +}); + +function getDefault() { + return 'bar' +} + +function getBar() { + return 'bar' +} + +mock.module('example3', { + exports: { + defaultExport: getDefault(), + namedExports: { + bar: getBar(), + } + }, +}); + +mock.module('example-no-named', { + exports: { + defaultExport: getDefault(), + }, +}); + +mock.module('example-no-fault', { + exports: { + namedExports: { + bar: 'bar', + } + }, +}); + +mock.module('example-empty', { + exports: { + }, +}); diff --git a/recipes/mock-module-exports/tests/destructuring-example/expected.mjs b/recipes/mock-module-exports/tests/destructuring-example/expected.mjs new file mode 100644 index 00000000..6ada2ee8 --- /dev/null +++ b/recipes/mock-module-exports/tests/destructuring-example/expected.mjs @@ -0,0 +1,16 @@ +import { mock } from 'node:test'; + +const foo = { + a: 'a', + b: 'b', + c: 'c' +} + +const mockValues = { + exports: { + default: 'bar', + ...foo, + }, +} + +mock.module('example', mockValues); diff --git a/recipes/mock-module-exports/tests/destructuring-example/input.mjs b/recipes/mock-module-exports/tests/destructuring-example/input.mjs new file mode 100644 index 00000000..63d1ccd1 --- /dev/null +++ b/recipes/mock-module-exports/tests/destructuring-example/input.mjs @@ -0,0 +1,16 @@ +import { mock } from 'node:test'; + +const foo = { + a: 'a', + b: 'b', + c: 'c' +} + +const mockValues = { + defaultExport: 'bar', + namedExports: { + ...foo + }, +} + +mock.module('example', mockValues); diff --git a/recipes/mock-module-exports/tests/esm-default-import/expected.mjs b/recipes/mock-module-exports/tests/esm-default-import/expected.mjs new file mode 100644 index 00000000..0fb52d3b --- /dev/null +++ b/recipes/mock-module-exports/tests/esm-default-import/expected.mjs @@ -0,0 +1,11 @@ +import test from 'node:test'; + +const bar = 'bar' +const foo = 'foo' + +test.mock.module('example', { + exports: { + default: bar, + foo: foo, + }, +}); diff --git a/recipes/mock-module-exports/tests/esm-default-import/input.mjs b/recipes/mock-module-exports/tests/esm-default-import/input.mjs new file mode 100644 index 00000000..827272e2 --- /dev/null +++ b/recipes/mock-module-exports/tests/esm-default-import/input.mjs @@ -0,0 +1,11 @@ +import test from 'node:test'; + +const bar = 'bar' +const foo = 'foo' + +test.mock.module('example', { + defaultExport: bar, + namedExports: { + foo: foo, + }, +}); diff --git a/recipes/mock-module-exports/tests/fn-return-example/expected.mjs b/recipes/mock-module-exports/tests/fn-return-example/expected.mjs new file mode 100644 index 00000000..abf124a1 --- /dev/null +++ b/recipes/mock-module-exports/tests/fn-return-example/expected.mjs @@ -0,0 +1,18 @@ +import { mock } from 'node:test'; + +const foo = { + a: 'a', + b: 'b', + c: 'c' +} + +function mockValues() { + return { + exports: { + default: 'bar', + ...foo, + }, + } +} + +mock.module('example', mockValues()); diff --git a/recipes/mock-module-exports/tests/fn-return-example/input.mjs b/recipes/mock-module-exports/tests/fn-return-example/input.mjs new file mode 100644 index 00000000..0ad9e863 --- /dev/null +++ b/recipes/mock-module-exports/tests/fn-return-example/input.mjs @@ -0,0 +1,18 @@ +import { mock } from 'node:test'; + +const foo = { + a: 'a', + b: 'b', + c: 'c' +} + +function mockValues() { + return { + defaultExport: 'bar', + namedExports: { + ...foo, + }, + } +} + +mock.module('example', mockValues()); diff --git a/recipes/mock-module-exports/tests/option-as-variable/expected.mjs b/recipes/mock-module-exports/tests/option-as-variable/expected.mjs new file mode 100644 index 00000000..572a2e7b --- /dev/null +++ b/recipes/mock-module-exports/tests/option-as-variable/expected.mjs @@ -0,0 +1,10 @@ +import { mock } from 'node:test'; + +const mockValues = { + exports: { + default: 'bar', + foo: 'foo', + }, +} + +mock.module('example', mockValues); diff --git a/recipes/mock-module-exports/tests/option-as-variable/input.mjs b/recipes/mock-module-exports/tests/option-as-variable/input.mjs new file mode 100644 index 00000000..bb2f95ad --- /dev/null +++ b/recipes/mock-module-exports/tests/option-as-variable/input.mjs @@ -0,0 +1,10 @@ +import { mock } from 'node:test'; + +const mockValues = { + defaultExport: 'bar', + namedExports: { + foo: 'foo', + }, +} + +mock.module('example', mockValues); diff --git a/recipes/mock-module-exports/tests/without-default-export/expected.mjs b/recipes/mock-module-exports/tests/without-default-export/expected.mjs new file mode 100644 index 00000000..59923414 --- /dev/null +++ b/recipes/mock-module-exports/tests/without-default-export/expected.mjs @@ -0,0 +1,7 @@ +import { mock } from 'node:test'; + +mock.module('example', { + exports: { + foo: 'foo', + }, +}); diff --git a/recipes/mock-module-exports/tests/without-default-export/input.mjs b/recipes/mock-module-exports/tests/without-default-export/input.mjs new file mode 100644 index 00000000..d82e8ced --- /dev/null +++ b/recipes/mock-module-exports/tests/without-default-export/input.mjs @@ -0,0 +1,7 @@ +import { mock } from 'node:test'; + +mock.module('example', { + namedExports: { + foo: 'foo', + }, +}); diff --git a/recipes/mock-module-exports/tests/without-named-export/expected.mjs b/recipes/mock-module-exports/tests/without-named-export/expected.mjs new file mode 100644 index 00000000..322cb8c8 --- /dev/null +++ b/recipes/mock-module-exports/tests/without-named-export/expected.mjs @@ -0,0 +1,7 @@ +import { mock } from 'node:test'; + +mock.module('example', { + exports: { + default: 'bar', + }, +}); diff --git a/recipes/mock-module-exports/tests/without-named-export/input.mjs b/recipes/mock-module-exports/tests/without-named-export/input.mjs new file mode 100644 index 00000000..10e46bdd --- /dev/null +++ b/recipes/mock-module-exports/tests/without-named-export/input.mjs @@ -0,0 +1,5 @@ +import { mock } from 'node:test'; + +mock.module('example', { + defaultExport: 'bar', +}); diff --git a/recipes/mock-module-exports/workflow.yaml b/recipes/mock-module-exports/workflow.yaml new file mode 100644 index 00000000..80809110 --- /dev/null +++ b/recipes/mock-module-exports/workflow.yaml @@ -0,0 +1,25 @@ +version: "1" + +nodes: + - id: apply-transforms + name: Apply AST Transformations + type: automatic + runtime: + type: direct + steps: + - name: Handle mock.module exports deprecation + js-ast-grep: + js_file: src/workflow.ts + base_path: . + include: + - "**/*.js" + - "**/*.jsx" + - "**/*.mjs" + - "**/*.cjs" + - "**/*.cts" + - "**/*.mts" + - "**/*.ts" + - "**/*.tsx" + exclude: + - "**/node_modules/**" + language: typescript