Skip to content

Commit fac25a2

Browse files
leosvelperezFrozenPandaz
authored andcommitted
fix(testing): convert executor-based jest.config.ts and preserve type-only imports (#35286)
## Current Behavior `nx migrate --run-migrations` crashes on workspaces that use the `@nx/jest:jest` executor (via `targetDefaults`) instead of `@nx/jest/plugin`: ``` NX Failed to run replace-removed-matcher-aliases-v22-3 from @nx/jest. This workspace is NOT up to date! NX Jest: Failed to parse the TypeScript config file .../libs/.../jest.config.ts ReferenceError: __dirname is not defined in ES module scope ``` The `convert-jest-config-to-cjs` migration (update-22-2-0) is gated on `@nx/jest/plugin` being registered in `nx.json`, so executor-based workspaces skip it entirely. Their `jest.config.ts` files (often a mix of ESM syntax and CJS globals like `__dirname`) never get converted. The later `replace-removed-matcher-aliases-v22-3` migration then calls `jest-config.readConfig` on every `jest.config.ts`, which on Node 22+/24+ with native type-stripping reparses the file as ESM and crashes. Separately, the conversion logic also didn't handle `import type` declarations — it rewrote them to `const { X } = require('mod')`, which unnecessarily pulls the module at runtime and drops type references the IDE/tsc relied on. For types-only specifiers, it could even crash at runtime. ## Expected Behavior `convert-jest-config-to-cjs` runs for every `jest.config.ts` whose project is CommonJS (plugin registration is no longer required), so executor-based setups are covered. The `type: module` guard still skips ESM projects. Type-only imports are preserved: - `import type { Config } from 'jest'` — left untouched (Node's type-stripping erases it, so it doesn't force ESM parsing at runtime). - `import { type Foo, bar } from 'mod'` — split into `import type { Foo } from 'mod'` plus `const { bar } = require('mod')`. - Renames (`import { type Foo as JestFoo, run } from 'mod'`) preserved. ## Related Issue(s) Fixes #34593
1 parent d8cf210 commit fac25a2

2 files changed

Lines changed: 247 additions & 193 deletions

File tree

‎packages/jest/src/migrations/update-22-2-0/convert-jest-config-to-cjs.spec.ts‎

Lines changed: 198 additions & 142 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Tree, writeJson, readJson } from '@nx/devkit';
1+
import { Tree, writeJson } from '@nx/devkit';
22
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
33
import migration from './convert-jest-config-to-cjs';
44

@@ -7,10 +7,6 @@ describe('convert-jest-config-to-cjs', () => {
77

88
beforeEach(() => {
99
tree = createTreeWithEmptyWorkspace();
10-
// Register @nx/jest/plugin in nx.json - required for migration to run
11-
const nxJson = readJson(tree, 'nx.json');
12-
nxJson.plugins = ['@nx/jest/plugin'];
13-
writeJson(tree, 'nx.json', nxJson);
1410
});
1511

1612
describe('export default conversion', () => {
@@ -183,6 +179,178 @@ export default {
183179
});
184180
});
185181

182+
describe('type-only imports', () => {
183+
it('should leave `import type { X } from "mod"` untouched', async () => {
184+
tree.write(
185+
'apps/app1/jest.config.ts',
186+
`import type { Config } from 'jest';
187+
188+
const config: Config = {
189+
displayName: 'app1',
190+
};
191+
192+
export default config;`
193+
);
194+
writeJson(tree, 'apps/app1/package.json', { type: 'commonjs' });
195+
196+
await migration(tree);
197+
198+
const content = tree.read('apps/app1/jest.config.ts', 'utf-8');
199+
expect(content).toMatchInlineSnapshot(`
200+
"import type { Config } from 'jest';
201+
202+
const config: Config = {
203+
displayName: 'app1',
204+
};
205+
206+
module.exports = config;
207+
"
208+
`);
209+
});
210+
211+
it('should leave `import type Default from "mod"` untouched', async () => {
212+
tree.write(
213+
'apps/app1/jest.config.ts',
214+
`import type Config from 'jest';
215+
216+
const config: Config = { displayName: 'app1' };
217+
218+
export default config;`
219+
);
220+
writeJson(tree, 'apps/app1/package.json', { type: 'commonjs' });
221+
222+
await migration(tree);
223+
224+
const content = tree.read('apps/app1/jest.config.ts', 'utf-8');
225+
expect(content).toMatchInlineSnapshot(`
226+
"import type Config from 'jest';
227+
228+
const config: Config = { displayName: 'app1' };
229+
230+
module.exports = config;
231+
"
232+
`);
233+
});
234+
235+
it('should leave `import type * as T from "mod"` untouched', async () => {
236+
tree.write(
237+
'apps/app1/jest.config.ts',
238+
`import type * as Jest from 'jest';
239+
240+
const config: Jest.Config = { displayName: 'app1' };
241+
242+
export default config;`
243+
);
244+
writeJson(tree, 'apps/app1/package.json', { type: 'commonjs' });
245+
246+
await migration(tree);
247+
248+
const content = tree.read('apps/app1/jest.config.ts', 'utf-8');
249+
expect(content).toMatchInlineSnapshot(`
250+
"import type * as Jest from 'jest';
251+
252+
const config: Jest.Config = { displayName: 'app1' };
253+
254+
module.exports = config;
255+
"
256+
`);
257+
});
258+
259+
it('should split inline type specifiers into a type-only import and a require for value specifiers', async () => {
260+
tree.write(
261+
'apps/app1/jest.config.ts',
262+
`import { type Config, readConfig } from 'some-pkg';
263+
264+
const config: Config = readConfig();
265+
export default config;`
266+
);
267+
writeJson(tree, 'apps/app1/package.json', { type: 'commonjs' });
268+
269+
await migration(tree);
270+
271+
const content = tree.read('apps/app1/jest.config.ts', 'utf-8');
272+
expect(content).toMatchInlineSnapshot(`
273+
"import type { Config } from 'some-pkg';
274+
const { readConfig } = require('some-pkg');
275+
276+
const config: Config = readConfig();
277+
module.exports = config;
278+
"
279+
`);
280+
});
281+
282+
it('should drop an inline type specifier entirely when it is the only named import', async () => {
283+
tree.write(
284+
'apps/app1/jest.config.ts',
285+
`import { type Config } from 'jest';
286+
287+
const config: Config = { displayName: 'app1' };
288+
export default config;`
289+
);
290+
writeJson(tree, 'apps/app1/package.json', { type: 'commonjs' });
291+
292+
await migration(tree);
293+
294+
const content = tree.read('apps/app1/jest.config.ts', 'utf-8');
295+
expect(content).toMatchInlineSnapshot(`
296+
"import type { Config } from 'jest';
297+
298+
const config: Config = { displayName: 'app1' };
299+
module.exports = config;
300+
"
301+
`);
302+
});
303+
304+
it('should split a default + inline type specifiers into three parts', async () => {
305+
tree.write(
306+
'apps/app1/jest.config.ts',
307+
`import setup, { type Config, helper } from 'some-pkg';
308+
309+
setup();
310+
const config: Config = helper();
311+
export default config;`
312+
);
313+
writeJson(tree, 'apps/app1/package.json', { type: 'commonjs' });
314+
315+
await migration(tree);
316+
317+
const content = tree.read('apps/app1/jest.config.ts', 'utf-8');
318+
expect(content).toMatchInlineSnapshot(`
319+
"import type { Config } from 'some-pkg';
320+
const setup = require('some-pkg').default ?? require('some-pkg');
321+
const { helper } = require('some-pkg');
322+
323+
setup();
324+
const config: Config = helper();
325+
module.exports = config;
326+
"
327+
`);
328+
});
329+
330+
it('should preserve renamed inline type specifiers', async () => {
331+
tree.write(
332+
'apps/app1/jest.config.ts',
333+
`import { type Config as JestConfig, run } from 'some-pkg';
334+
335+
const config: JestConfig = run();
336+
export default config;`
337+
);
338+
writeJson(tree, 'apps/app1/package.json', { type: 'commonjs' });
339+
340+
await migration(tree);
341+
342+
const content = tree.read('apps/app1/jest.config.ts', 'utf-8');
343+
expect(content).toMatchInlineSnapshot(`
344+
"import type { Config as JestConfig } from 'some-pkg';
345+
const { run } = require('some-pkg');
346+
347+
const config: JestConfig = run();
348+
module.exports = config;
349+
"
350+
`);
351+
});
352+
});
353+
186354
describe('ESM module type', () => {
187355
it('should NOT convert jest.config.ts when project package.json has type: module', async () => {
188356
const originalContent = `import { readFileSync } from 'fs';
@@ -451,40 +619,22 @@ export default {
451619
});
452620
});
453621

454-
describe('plugin registration guard', () => {
455-
it('should NOT run migration when @nx/jest/plugin is not registered', async () => {
456-
// Remove the plugin from nx.json
457-
const nxJson = readJson(tree, 'nx.json');
458-
nxJson.plugins = [];
459-
writeJson(tree, 'nx.json', nxJson);
460-
461-
const originalContent = `export default {
462-
displayName: 'app1',
463-
};`;
464-
tree.write('apps/app1/jest.config.ts', originalContent);
465-
writeJson(tree, 'apps/app1/package.json', { type: 'commonjs' });
466-
467-
await migration(tree);
468-
469-
// File should remain unchanged
470-
const content = tree.read('apps/app1/jest.config.ts', 'utf-8');
471-
expect(content).toMatchInlineSnapshot(`
472-
"export default {
473-
displayName: 'app1',
474-
};"
475-
`);
476-
});
477-
478-
it('should run migration when @nx/jest/plugin is registered as object', async () => {
479-
// Register plugin as object format
480-
const nxJson = readJson(tree, 'nx.json');
481-
nxJson.plugins = [{ plugin: '@nx/jest/plugin', options: {} }];
482-
writeJson(tree, 'nx.json', nxJson);
483-
622+
describe('executor-based setups', () => {
623+
it('should convert jest.config.ts even when @nx/jest/plugin is not registered', async () => {
624+
// Simulate an executor-based workspace: no @nx/jest/plugin in nx.json.
484625
tree.write(
485626
'apps/app1/jest.config.ts',
486-
`export default {
627+
`import { readFileSync } from 'fs';
628+
629+
const swcJestConfig = JSON.parse(
630+
readFileSync(\`\${__dirname}/.swcrc\`, 'utf-8')
631+
);
632+
633+
export default {
487634
displayName: 'app1',
635+
transform: {
636+
'^.+\\.[tj]s$': ['@swc/jest', swcJestConfig],
637+
},
488638
};`
489639
);
490640
writeJson(tree, 'apps/app1/package.json', { type: 'commonjs' });
@@ -493,98 +643,23 @@ export default {
493643

494644
const content = tree.read('apps/app1/jest.config.ts', 'utf-8');
495645
expect(content).toMatchInlineSnapshot(`
496-
"module.exports = {
497-
displayName: 'app1',
498-
};
499-
"
500-
`);
501-
});
502-
503-
it('should NOT run migration when nx.json does not exist', async () => {
504-
tree.delete('nx.json');
505-
506-
const originalContent = `export default {
507-
displayName: 'app1',
508-
};`;
509-
tree.write('apps/app1/jest.config.ts', originalContent);
510-
writeJson(tree, 'apps/app1/package.json', { type: 'commonjs' });
646+
"const { readFileSync } = require('fs');
511647
512-
await migration(tree);
648+
const swcJestConfig = JSON.parse(readFileSync(\`\${__dirname}/.swcrc\`, 'utf-8'));
513649
514-
// File should remain unchanged
515-
const content = tree.read('apps/app1/jest.config.ts', 'utf-8');
516-
expect(content).toMatchInlineSnapshot(`
517-
"export default {
650+
module.exports = {
518651
displayName: 'app1',
519-
};"
520-
`);
521-
});
522-
523-
it('should NOT convert files excluded from plugin via exclude pattern', async () => {
524-
// Register plugin with exclude pattern
525-
const nxJson = readJson(tree, 'nx.json');
526-
nxJson.plugins = [
527-
{
528-
plugin: '@nx/jest/plugin',
529-
exclude: ['apps/excluded/**/*'],
530-
},
531-
];
532-
writeJson(tree, 'nx.json', nxJson);
533-
534-
const originalContent = `export default {
535-
displayName: 'excluded-app',
536-
};`;
537-
tree.write('apps/excluded/jest.config.ts', originalContent);
538-
writeJson(tree, 'apps/excluded/package.json', { type: 'commonjs' });
539-
540-
// Also create a non-excluded file to ensure it still gets converted
541-
tree.write(
542-
'apps/included/jest.config.ts',
543-
`export default {
544-
displayName: 'included-app',
545-
};`
546-
);
547-
writeJson(tree, 'apps/included/package.json', { type: 'commonjs' });
548-
549-
await migration(tree);
550-
551-
// Excluded file should remain unchanged
552-
const excludedContent = tree.read(
553-
'apps/excluded/jest.config.ts',
554-
'utf-8'
555-
);
556-
expect(excludedContent).toMatchInlineSnapshot(`
557-
"export default {
558-
displayName: 'excluded-app',
559-
};
560-
"
561-
`);
562-
563-
// Included file should be converted
564-
const includedContent = tree.read(
565-
'apps/included/jest.config.ts',
566-
'utf-8'
567-
);
568-
expect(includedContent).toMatchInlineSnapshot(`
569-
"module.exports = {
570-
displayName: 'included-app',
652+
transform: {
653+
'^.+\\.[tj]s$': ['@swc/jest', swcJestConfig],
654+
},
571655
};
572656
"
573657
`);
574658
});
575659

576-
it('should only convert files matching include pattern', async () => {
577-
// Register plugin with include pattern
578-
const nxJson = readJson(tree, 'nx.json');
579-
nxJson.plugins = [
580-
{
581-
plugin: '@nx/jest/plugin',
582-
include: ['libs/**/*'],
583-
},
584-
];
585-
writeJson(tree, 'nx.json', nxJson);
586-
587-
// Create file outside include pattern
660+
it('should convert jest.config.ts when nx.json does not exist', async () => {
661+
tree.delete('nx.json');
662+
588663
tree.write(
589664
'apps/app1/jest.config.ts',
590665
`export default {
@@ -593,31 +668,12 @@ export default {
593668
);
594669
writeJson(tree, 'apps/app1/package.json', { type: 'commonjs' });
595670

596-
// Create file inside include pattern
597-
tree.write(
598-
'libs/lib1/jest.config.ts',
599-
`export default {
600-
displayName: 'lib1',
601-
};`
602-
);
603-
writeJson(tree, 'libs/lib1/package.json', { type: 'commonjs' });
604-
605671
await migration(tree);
606672

607-
// File outside include pattern should remain unchanged
608-
const appContent = tree.read('apps/app1/jest.config.ts', 'utf-8');
609-
expect(appContent).toMatchInlineSnapshot(`
610-
"export default {
611-
displayName: 'app1',
612-
};
613-
"
614-
`);
615-
616-
// File inside include pattern should be converted
617-
const libContent = tree.read('libs/lib1/jest.config.ts', 'utf-8');
618-
expect(libContent).toMatchInlineSnapshot(`
673+
const content = tree.read('apps/app1/jest.config.ts', 'utf-8');
674+
expect(content).toMatchInlineSnapshot(`
619675
"module.exports = {
620-
displayName: 'lib1',
676+
displayName: 'app1',
621677
};
622678
"
623679
`);

0 commit comments

Comments
 (0)