diff --git a/packages/jest/src/migrations/update-22-2-0/convert-jest-config-to-cjs.spec.ts b/packages/jest/src/migrations/update-22-2-0/convert-jest-config-to-cjs.spec.ts index 8b59e7fb25207..4e436fece7959 100644 --- a/packages/jest/src/migrations/update-22-2-0/convert-jest-config-to-cjs.spec.ts +++ b/packages/jest/src/migrations/update-22-2-0/convert-jest-config-to-cjs.spec.ts @@ -1,4 +1,4 @@ -import { Tree, writeJson, readJson } from '@nx/devkit'; +import { Tree, writeJson } from '@nx/devkit'; import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; import migration from './convert-jest-config-to-cjs'; @@ -7,10 +7,6 @@ describe('convert-jest-config-to-cjs', () => { beforeEach(() => { tree = createTreeWithEmptyWorkspace(); - // Register @nx/jest/plugin in nx.json - required for migration to run - const nxJson = readJson(tree, 'nx.json'); - nxJson.plugins = ['@nx/jest/plugin']; - writeJson(tree, 'nx.json', nxJson); }); describe('export default conversion', () => { @@ -183,6 +179,178 @@ export default { }); }); + describe('type-only imports', () => { + it('should leave `import type { X } from "mod"` untouched', async () => { + tree.write( + 'apps/app1/jest.config.ts', + `import type { Config } from 'jest'; + +const config: Config = { + displayName: 'app1', +}; + +export default config;` + ); + writeJson(tree, 'apps/app1/package.json', { type: 'commonjs' }); + + await migration(tree); + + const content = tree.read('apps/app1/jest.config.ts', 'utf-8'); + expect(content).toMatchInlineSnapshot(` + "import type { Config } from 'jest'; + + const config: Config = { + displayName: 'app1', + }; + + module.exports = config; + " + `); + }); + + it('should leave `import type Default from "mod"` untouched', async () => { + tree.write( + 'apps/app1/jest.config.ts', + `import type Config from 'jest'; + +const config: Config = { displayName: 'app1' }; + +export default config;` + ); + writeJson(tree, 'apps/app1/package.json', { type: 'commonjs' }); + + await migration(tree); + + const content = tree.read('apps/app1/jest.config.ts', 'utf-8'); + expect(content).toMatchInlineSnapshot(` + "import type Config from 'jest'; + + const config: Config = { displayName: 'app1' }; + + module.exports = config; + " + `); + }); + + it('should leave `import type * as T from "mod"` untouched', async () => { + tree.write( + 'apps/app1/jest.config.ts', + `import type * as Jest from 'jest'; + +const config: Jest.Config = { displayName: 'app1' }; + +export default config;` + ); + writeJson(tree, 'apps/app1/package.json', { type: 'commonjs' }); + + await migration(tree); + + const content = tree.read('apps/app1/jest.config.ts', 'utf-8'); + expect(content).toMatchInlineSnapshot(` + "import type * as Jest from 'jest'; + + const config: Jest.Config = { displayName: 'app1' }; + + module.exports = config; + " + `); + }); + + it('should split inline type specifiers into a type-only import and a require for value specifiers', async () => { + tree.write( + 'apps/app1/jest.config.ts', + `import { type Config, readConfig } from 'some-pkg'; + +const config: Config = readConfig(); +export default config;` + ); + writeJson(tree, 'apps/app1/package.json', { type: 'commonjs' }); + + await migration(tree); + + const content = tree.read('apps/app1/jest.config.ts', 'utf-8'); + expect(content).toMatchInlineSnapshot(` + "import type { Config } from 'some-pkg'; + const { readConfig } = require('some-pkg'); + + const config: Config = readConfig(); + module.exports = config; + " + `); + }); + + it('should drop an inline type specifier entirely when it is the only named import', async () => { + tree.write( + 'apps/app1/jest.config.ts', + `import { type Config } from 'jest'; + +const config: Config = { displayName: 'app1' }; +export default config;` + ); + writeJson(tree, 'apps/app1/package.json', { type: 'commonjs' }); + + await migration(tree); + + const content = tree.read('apps/app1/jest.config.ts', 'utf-8'); + expect(content).toMatchInlineSnapshot(` + "import type { Config } from 'jest'; + + const config: Config = { displayName: 'app1' }; + module.exports = config; + " + `); + }); + + it('should split a default + inline type specifiers into three parts', async () => { + tree.write( + 'apps/app1/jest.config.ts', + `import setup, { type Config, helper } from 'some-pkg'; + +setup(); +const config: Config = helper(); +export default config;` + ); + writeJson(tree, 'apps/app1/package.json', { type: 'commonjs' }); + + await migration(tree); + + const content = tree.read('apps/app1/jest.config.ts', 'utf-8'); + expect(content).toMatchInlineSnapshot(` + "import type { Config } from 'some-pkg'; + const setup = require('some-pkg').default ?? require('some-pkg'); + const { helper } = require('some-pkg'); + + setup(); + const config: Config = helper(); + module.exports = config; + " + `); + }); + + it('should preserve renamed inline type specifiers', async () => { + tree.write( + 'apps/app1/jest.config.ts', + `import { type Config as JestConfig, run } from 'some-pkg'; + +const config: JestConfig = run(); +export default config;` + ); + writeJson(tree, 'apps/app1/package.json', { type: 'commonjs' }); + + await migration(tree); + + const content = tree.read('apps/app1/jest.config.ts', 'utf-8'); + expect(content).toMatchInlineSnapshot(` + "import type { Config as JestConfig } from 'some-pkg'; + const { run } = require('some-pkg'); + + const config: JestConfig = run(); + module.exports = config; + " + `); + }); + }); + describe('ESM module type', () => { it('should NOT convert jest.config.ts when project package.json has type: module', async () => { const originalContent = `import { readFileSync } from 'fs'; @@ -451,40 +619,22 @@ export default { }); }); - describe('plugin registration guard', () => { - it('should NOT run migration when @nx/jest/plugin is not registered', async () => { - // Remove the plugin from nx.json - const nxJson = readJson(tree, 'nx.json'); - nxJson.plugins = []; - writeJson(tree, 'nx.json', nxJson); - - const originalContent = `export default { - displayName: 'app1', -};`; - tree.write('apps/app1/jest.config.ts', originalContent); - writeJson(tree, 'apps/app1/package.json', { type: 'commonjs' }); - - await migration(tree); - - // File should remain unchanged - const content = tree.read('apps/app1/jest.config.ts', 'utf-8'); - expect(content).toMatchInlineSnapshot(` - "export default { - displayName: 'app1', - };" - `); - }); - - it('should run migration when @nx/jest/plugin is registered as object', async () => { - // Register plugin as object format - const nxJson = readJson(tree, 'nx.json'); - nxJson.plugins = [{ plugin: '@nx/jest/plugin', options: {} }]; - writeJson(tree, 'nx.json', nxJson); - + describe('executor-based setups', () => { + it('should convert jest.config.ts even when @nx/jest/plugin is not registered', async () => { + // Simulate an executor-based workspace: no @nx/jest/plugin in nx.json. tree.write( 'apps/app1/jest.config.ts', - `export default { + `import { readFileSync } from 'fs'; + +const swcJestConfig = JSON.parse( + readFileSync(\`\${__dirname}/.swcrc\`, 'utf-8') +); + +export default { displayName: 'app1', + transform: { + '^.+\\.[tj]s$': ['@swc/jest', swcJestConfig], + }, };` ); writeJson(tree, 'apps/app1/package.json', { type: 'commonjs' }); @@ -493,98 +643,23 @@ export default { const content = tree.read('apps/app1/jest.config.ts', 'utf-8'); expect(content).toMatchInlineSnapshot(` - "module.exports = { - displayName: 'app1', - }; - " - `); - }); - - it('should NOT run migration when nx.json does not exist', async () => { - tree.delete('nx.json'); - - const originalContent = `export default { - displayName: 'app1', -};`; - tree.write('apps/app1/jest.config.ts', originalContent); - writeJson(tree, 'apps/app1/package.json', { type: 'commonjs' }); + "const { readFileSync } = require('fs'); - await migration(tree); + const swcJestConfig = JSON.parse(readFileSync(\`\${__dirname}/.swcrc\`, 'utf-8')); - // File should remain unchanged - const content = tree.read('apps/app1/jest.config.ts', 'utf-8'); - expect(content).toMatchInlineSnapshot(` - "export default { + module.exports = { displayName: 'app1', - };" - `); - }); - - it('should NOT convert files excluded from plugin via exclude pattern', async () => { - // Register plugin with exclude pattern - const nxJson = readJson(tree, 'nx.json'); - nxJson.plugins = [ - { - plugin: '@nx/jest/plugin', - exclude: ['apps/excluded/**/*'], - }, - ]; - writeJson(tree, 'nx.json', nxJson); - - const originalContent = `export default { - displayName: 'excluded-app', -};`; - tree.write('apps/excluded/jest.config.ts', originalContent); - writeJson(tree, 'apps/excluded/package.json', { type: 'commonjs' }); - - // Also create a non-excluded file to ensure it still gets converted - tree.write( - 'apps/included/jest.config.ts', - `export default { - displayName: 'included-app', -};` - ); - writeJson(tree, 'apps/included/package.json', { type: 'commonjs' }); - - await migration(tree); - - // Excluded file should remain unchanged - const excludedContent = tree.read( - 'apps/excluded/jest.config.ts', - 'utf-8' - ); - expect(excludedContent).toMatchInlineSnapshot(` - "export default { - displayName: 'excluded-app', - }; - " - `); - - // Included file should be converted - const includedContent = tree.read( - 'apps/included/jest.config.ts', - 'utf-8' - ); - expect(includedContent).toMatchInlineSnapshot(` - "module.exports = { - displayName: 'included-app', + transform: { + '^.+\\.[tj]s$': ['@swc/jest', swcJestConfig], + }, }; " `); }); - it('should only convert files matching include pattern', async () => { - // Register plugin with include pattern - const nxJson = readJson(tree, 'nx.json'); - nxJson.plugins = [ - { - plugin: '@nx/jest/plugin', - include: ['libs/**/*'], - }, - ]; - writeJson(tree, 'nx.json', nxJson); - - // Create file outside include pattern + it('should convert jest.config.ts when nx.json does not exist', async () => { + tree.delete('nx.json'); + tree.write( 'apps/app1/jest.config.ts', `export default { @@ -593,31 +668,12 @@ export default { ); writeJson(tree, 'apps/app1/package.json', { type: 'commonjs' }); - // Create file inside include pattern - tree.write( - 'libs/lib1/jest.config.ts', - `export default { - displayName: 'lib1', -};` - ); - writeJson(tree, 'libs/lib1/package.json', { type: 'commonjs' }); - await migration(tree); - // File outside include pattern should remain unchanged - const appContent = tree.read('apps/app1/jest.config.ts', 'utf-8'); - expect(appContent).toMatchInlineSnapshot(` - "export default { - displayName: 'app1', - }; - " - `); - - // File inside include pattern should be converted - const libContent = tree.read('libs/lib1/jest.config.ts', 'utf-8'); - expect(libContent).toMatchInlineSnapshot(` + const content = tree.read('apps/app1/jest.config.ts', 'utf-8'); + expect(content).toMatchInlineSnapshot(` "module.exports = { - displayName: 'lib1', + displayName: 'app1', }; " `); diff --git a/packages/jest/src/migrations/update-22-2-0/convert-jest-config-to-cjs.ts b/packages/jest/src/migrations/update-22-2-0/convert-jest-config-to-cjs.ts index 6433814b5fc85..3778021448219 100644 --- a/packages/jest/src/migrations/update-22-2-0/convert-jest-config-to-cjs.ts +++ b/packages/jest/src/migrations/update-22-2-0/convert-jest-config-to-cjs.ts @@ -3,11 +3,9 @@ import { globAsync, joinPathFragments, logger, - NxJsonConfiguration, readJson, Tree, } from '@nx/devkit'; -import { findPluginForConfigFile } from '@nx/devkit/src/utils/find-plugin-for-config-file'; import { dirname } from 'path'; /** @@ -16,8 +14,6 @@ import { dirname } from 'path'; * in newer versions (22+, 24+) can cause issues with ESM syntax in .ts files * when the project is configured for CommonJS. * - * This migration only runs if @nx/jest/plugin is registered in nx.json. - * * Conversions: * - `export default { ... }` -> `module.exports = { ... }` * - `import { x } from 'y'` -> `const { x } = require('y')` @@ -27,14 +23,10 @@ import { dirname } from 'path'; * - `import.meta` * - top-level `await` * - * Projects with `type: module` in package.json will be warned as they are - * incompatible with @nx/jest/plugin which forces CommonJS resolution. + * Projects with `type: module` in package.json are skipped and warned about, + * as they use ESM semantics and don't need the CJS conversion. */ export default async function convertJestConfigToCjs(tree: Tree) { - // If @nx/jest/plugin not used, then there will not be any problems with graph construction, which - // is what we're trying to address. - if (!isJestPluginRegistered(tree)) return; - const { ast: parseAst, query } = require('@phenomnomnominal/tsquery'); const jestConfigPaths = await globAsync(tree, ['**/jest.config.ts']); @@ -43,14 +35,6 @@ export default async function convertJestConfigToCjs(tree: Tree) { const modifiedFiles: string[] = []; for (const configPath of jestConfigPaths) { - // Skip config files that are excluded from the plugin via include/exclude patterns - const pluginRegistration = await findPluginForConfigFile( - tree, - '@nx/jest/plugin', - configPath - ); - if (!pluginRegistration) continue; - const projectRoot = dirname(configPath); const packageJsonPath = joinPathFragments(projectRoot, 'package.json'); const rootPackageJsonPath = 'package.json'; @@ -70,8 +54,8 @@ export default async function convertJestConfigToCjs(tree: Tree) { const effectiveType = projectPackageJson?.type ?? rootPackageJson?.type ?? 'commonjs'; // CJS is default if missing - // If type is "module", warn user - this is incompatible with @nx/jest/plugin - // Should not be possible, but it's possible that there's a way to get this working that we're unaware of + // If type is "module", skip conversion: the file already runs under ESM + // semantics and does not need the CJS rewrite. if (effectiveType === 'module') { projectsWithTypeModule.push(configPath); continue; @@ -109,9 +93,9 @@ export default async function convertJestConfigToCjs(tree: Tree) { return () => { if (projectsWithTypeModule.length > 0) { logger.warn( - `The following projects have "type": "module" in their package.json which is incompatible ` + - `with @nx/jest/plugin. Consider removing "type": "module" ` + - `or using a different Jest configuration approach:\n` + + `The following jest.config.ts files belong to projects with "type": "module" in their package.json ` + + `and were left as-is. If you use @nx/jest/plugin, it forces CommonJS resolution, so consider ` + + `removing "type": "module" or using a different Jest configuration approach:\n` + projectsWithTypeModule.map((p) => ` - ${p}`).join('\n') ); } @@ -189,7 +173,14 @@ function convertImportsToRequire( continue; } + // `import type ...` is erased by Node's type-stripping at runtime, so it + // can remain in the file untouched — this preserves editor/tsc type safety. + if (importClause.isTypeOnly) { + continue; + } + const parts: string[] = []; + const typeOnlySpecifiers: string[] = []; // Default import: import x from 'module' if (importClause.name) { @@ -199,20 +190,29 @@ function convertImportsToRequire( ); } - // Named imports: import { a, b } from 'module' if (importClause.namedBindings) { if (ts.isNamedImports(importClause.namedBindings)) { - const namedImports = importClause.namedBindings.elements - .map((element) => { - const name = element.name.getText(); - const propertyName = element.propertyName?.getText(); - if (propertyName) { - return `${propertyName}: ${name}`; - } - return name; - }) - .join(', '); - parts.push(`const { ${namedImports} } = require('${moduleSpecifier}')`); + const valueSpecifiers: string[] = []; + for (const element of importClause.namedBindings.elements) { + const name = element.name.getText(); + const propertyName = element.propertyName?.getText(); + if (element.isTypeOnly) { + // Inline `type` modifier: `import { type Foo, bar } from 'x'`. + // Preserve the type-only portion so type references still resolve. + typeOnlySpecifiers.push( + propertyName ? `${propertyName} as ${name}` : name + ); + } else { + valueSpecifiers.push( + propertyName ? `${propertyName}: ${name}` : name + ); + } + } + if (valueSpecifiers.length) { + parts.push( + `const { ${valueSpecifiers.join(', ')} } = require('${moduleSpecifier}')` + ); + } } else if (ts.isNamespaceImport(importClause.namedBindings)) { // Namespace import: import * as x from 'module' const namespaceName = importClause.namedBindings.name.getText(); @@ -220,8 +220,20 @@ function convertImportsToRequire( } } - const requireStatement = parts.join(';\n'); - content = replaceNode(content, importDecl, requireStatement); + const replacementParts: string[] = []; + if (typeOnlySpecifiers.length) { + replacementParts.push( + `import type { ${typeOnlySpecifiers.join(', ')} } from '${moduleSpecifier}'` + ); + } + replacementParts.push(...parts); + + if (replacementParts.length === 0) { + continue; + } + + const replacement = replacementParts.join(';\n'); + content = replaceNode(content, importDecl, replacement); } return content; @@ -258,17 +270,3 @@ function replaceNode(content: string, node: any, replacement: string): string { } return content.slice(0, start) + replacement + ';' + content.slice(endPos); } - -function isJestPluginRegistered(tree: Tree): boolean { - if (!tree.exists('nx.json')) { - return false; - } - - const nxJson = readJson(tree, 'nx.json'); - const plugins = nxJson.plugins ?? []; - - return plugins.some((plugin) => { - const pluginName = typeof plugin === 'string' ? plugin : plugin.plugin; - return pluginName === '@nx/jest/plugin' || pluginName === '@nx/jest'; - }); -}