Skip to content

Commit 7c9de13

Browse files
juristrFrozenPandaz
authored andcommitted
fix(testing): gracefully handle broken jest configs in alias migration (#34901)
## Current Behavior The `replace-removed-matcher-aliases` migration crashes the entire migration runner when a project has a broken or misconfigured `jest.config.ts`. This was discovered while upgrading nx-labs from Nx 21 to 22 via `nx migrate --run-migrations`. The migration uses Jest's `readConfig()` and `Runtime.createContext()` to resolve jest configs, but has no error handling around these calls. If any project has issues like: - A missing file referenced in the config (e.g. `.lib.swcrc` that was renamed to `.swcrc`) - A missing transform module (e.g. `@swc/jest` not installed) - A missing preset file (e.g. `jest.preset.ts` instead of `jest.preset.js`) ...the entire migration fails with an opaque error: ``` Error: Command failed: /var/folders/.../node_modules/.bin/nx _migrate --run-migrations at checkExecSyncError (node:child_process:925:11) status: 1, stdout: null, stderr: null ``` The actual errors are swallowed by the nested `execSync` call, making it very hard to debug. ## Expected Behavior The migration should skip projects with broken jest configs and continue processing the rest of the workspace. ## Fix Wrapped the `readConfig` / `Runtime.createContext` / `SearchSource.getTestPaths` block in a try-catch that skips the failing project. This matches the defensive pattern already used in `packages/jest/src/plugins/plugin.ts` for similar jest config resolution. ## Test Plan - Added 3 tests covering: missing file reference, missing preset, and verifying valid projects are still processed when a sibling project has a broken config - All existing tests continue to pass
1 parent b54b839 commit 7c9de13

File tree

2 files changed

+145
-12
lines changed

2 files changed

+145
-12
lines changed

packages/jest/src/migrations/update-21-3-0/replace-removed-matcher-aliases.spec.ts

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Tree } from '@nx/devkit';
1+
import { logger, type Tree } from '@nx/devkit';
22
import { TempFs } from '@nx/devkit/internal-testing-utils';
33
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
44
import migration from './replace-removed-matcher-aliases';
@@ -322,6 +322,95 @@ describe('useAutoSave', () => {
322322
`);
323323
});
324324

325+
describe('gracefully handles broken jest configs', () => {
326+
let warnSpy: jest.SpyInstance;
327+
328+
beforeEach(() => {
329+
warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => {});
330+
});
331+
332+
afterEach(() => {
333+
warnSpy.mockRestore();
334+
});
335+
336+
it('should not fail and warn when a jest config references a missing file', async () => {
337+
// Simulates: jest.config.ts reads a .lib.swcrc that does not exist
338+
writeFile(
339+
tree,
340+
'packages/broken/jest.config.ts',
341+
`import { readFileSync } from 'fs';
342+
const swcConfig = JSON.parse(readFileSync(\`\${__dirname}/.lib.swcrc\`, 'utf-8'));
343+
module.exports = { transform: { '^.+\\.[tj]s$': ['@swc/jest', swcConfig] } };`
344+
);
345+
346+
await expect(migration(tree)).resolves.not.toThrow();
347+
expect(warnSpy).toHaveBeenCalledWith(
348+
expect.stringContaining('packages/broken/jest.config.ts')
349+
);
350+
});
351+
352+
it('should not fail and warn when a jest config references a missing preset', async () => {
353+
// Simulates: jest.config.ts references jest.preset.ts but only .js exists
354+
writeFile(
355+
tree,
356+
'packages/broken/jest.config.ts',
357+
`module.exports = { preset: '../../jest.preset.ts' };`
358+
);
359+
360+
await expect(migration(tree)).resolves.not.toThrow();
361+
expect(warnSpy).toHaveBeenCalledWith(
362+
expect.stringContaining('packages/broken/jest.config.ts')
363+
);
364+
});
365+
366+
it('should not fail and warn when test path resolution throws (e.g. broken regex)', async () => {
367+
const { SearchSource } = require('jest');
368+
const spy = jest
369+
.spyOn(SearchSource.prototype, 'getTestPaths')
370+
.mockRejectedValue(new Error('Invalid regular expression'));
371+
372+
writeFile(tree, 'packages/broken/jest.config.js', `module.exports = {};`);
373+
374+
await expect(migration(tree)).resolves.not.toThrow();
375+
expect(warnSpy).toHaveBeenCalledWith(
376+
expect.stringContaining('packages/broken/jest.config.js')
377+
);
378+
379+
spy.mockRestore();
380+
});
381+
382+
it('should still process valid projects when another has a broken config', async () => {
383+
// Broken project: references a file that does not exist
384+
writeFile(
385+
tree,
386+
'packages/broken/jest.config.ts',
387+
`import { readFileSync } from 'fs';
388+
const swcConfig = JSON.parse(readFileSync(\`\${__dirname}/.lib.swcrc\`, 'utf-8'));
389+
module.exports = { transform: { '^.+\\.[tj]s$': ['@swc/jest', swcConfig] } };`
390+
);
391+
392+
// Valid project with a test file using deprecated aliases
393+
writeFile(tree, 'packages/valid/jest.config.js', `module.exports = {};`);
394+
writeFile(
395+
tree,
396+
'packages/valid/src/example.spec.ts',
397+
`it('test', () => { expect(fn).toBeCalled(); });`
398+
);
399+
400+
await migration(tree);
401+
402+
// Broken project was warned about
403+
expect(warnSpy).toHaveBeenCalledWith(
404+
expect.stringContaining('packages/broken/jest.config.ts')
405+
);
406+
407+
// Valid project was still processed
408+
const content = tree.read('packages/valid/src/example.spec.ts', 'utf-8');
409+
expect(content).toContain('toHaveBeenCalled');
410+
expect(content).not.toContain('toBeCalled');
411+
});
412+
});
413+
325414
function writeFile(tree: Tree, path: string, content: string): void {
326415
tree.write(path, content);
327416
fs.createFileSync(path, content);

packages/jest/src/migrations/update-21-3-0/replace-removed-matcher-aliases.ts

Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { formatFiles, globAsync, type Tree } from '@nx/devkit';
1+
import { formatFiles, globAsync, logger, type Tree } from '@nx/devkit';
22
import { ast, query } from '@phenomnomnominal/tsquery';
33
import { SearchSource } from 'jest';
44
import { readConfig } from 'jest-config';
@@ -98,23 +98,67 @@ async function getTestFilePaths(tree: Tree): Promise<string[]> {
9898
continue;
9999
}
100100

101-
const config = await readConfig(
102-
{ _: [], $0: undefined },
103-
join(tree.root, jestConfigFile)
101+
const resolvedPaths = await resolveTestPaths(tree, jestConfigFile);
102+
if (resolvedPaths) {
103+
for (const testPath of resolvedPaths) {
104+
testFilePaths.add(testPath);
105+
}
106+
}
107+
}
108+
109+
return Array.from(testFilePaths);
110+
}
111+
112+
/**
113+
* Resolves test file paths for a single jest config by loading the config
114+
* through Jest's own resolution. Returns null if the config cannot be
115+
* resolved (e.g. missing files, uninstalled transform modules, invalid
116+
* presets), logging a warning so the user knows which project was skipped.
117+
*/
118+
async function resolveTestPaths(
119+
tree: Tree,
120+
jestConfigFile: string
121+
): Promise<string[] | null> {
122+
const fullConfigPath = join(tree.root, jestConfigFile);
123+
124+
let config: Awaited<ReturnType<typeof readConfig>>;
125+
try {
126+
config = await readConfig({ _: [], $0: undefined }, fullConfigPath);
127+
} catch (e) {
128+
const message = e instanceof Error ? e.message : String(e);
129+
logger.warn(
130+
`Could not read Jest config "${jestConfigFile}": ${message}. Skipping this project for matcher alias replacement.`
104131
);
105-
const jestContext = await Runtime.createContext(config.projectConfig, {
132+
return null;
133+
}
134+
135+
let jestContext: Awaited<ReturnType<(typeof Runtime)['createContext']>>;
136+
try {
137+
jestContext = await Runtime.createContext(config.projectConfig, {
106138
maxWorkers: 1,
107139
watchman: false,
108140
});
141+
} catch (e) {
142+
const message = e instanceof Error ? e.message : String(e);
143+
logger.warn(
144+
`Could not create Jest context for "${jestConfigFile}": ${message}. Skipping this project for matcher alias replacement.`
145+
);
146+
return null;
147+
}
148+
149+
let specs: Awaited<ReturnType<SearchSource['getTestPaths']>>;
150+
try {
109151
const source = new SearchSource(jestContext);
110-
const specs = await source.getTestPaths(
152+
specs = await source.getTestPaths(
111153
config.globalConfig,
112154
config.projectConfig
113155
);
114-
for (const testPath of specs.tests) {
115-
testFilePaths.add(posix.normalize(relative(tree.root, testPath.path)));
116-
}
156+
} catch (e) {
157+
const message = e instanceof Error ? e.message : String(e);
158+
logger.warn(
159+
`Could not resolve test paths for "${jestConfigFile}": ${message}. Skipping this project for matcher alias replacement.`
160+
);
161+
return null;
117162
}
118-
119-
return Array.from(testFilePaths);
163+
return specs.tests.map((t) => posix.normalize(relative(tree.root, t.path)));
120164
}

0 commit comments

Comments
 (0)