diff --git a/packages/jest/src/migrations/update-21-3-0/replace-removed-matcher-aliases.spec.ts b/packages/jest/src/migrations/update-21-3-0/replace-removed-matcher-aliases.spec.ts index 76ee380f7c3..3285540a436 100644 --- a/packages/jest/src/migrations/update-21-3-0/replace-removed-matcher-aliases.spec.ts +++ b/packages/jest/src/migrations/update-21-3-0/replace-removed-matcher-aliases.spec.ts @@ -193,6 +193,135 @@ describe('test', () => { `); }); + it('should preserve complex code patterns that could be corrupted by AST reprinting', async () => { + // This test covers patterns that were corrupted by the old implementation which used + // tsquery.replace() that reprints the entire AST using TypeScript's Printer. + // The patterns include: destructuring, arrow functions, nested callbacks, and object literals. + writeFile( + tree, + 'apps/app1/jest.config.js', + `module.exports = {}; + ` + ); + const complexCode = `interface Config { + buildVariables: (p: { payGroupId: string; value: T }) => unknown; + initialValue: number; +} + +const mockAutoSave = jest.fn(); + +function useAutoSave(config: Config) { + return { autoSave: mockAutoSave, lastAttemptedValue: config.initialValue }; +} + +describe('useAutoSave', () => { + beforeEach(() => { + mockAutoSave.mockClear(); + }); + + it('should handle complex callback patterns', async () => { + const { result } = useAutoSave({ + buildVariables: ({ payGroupId, value }) => ({ payGroupId, value }), + initialValue: 0, + }); + + expect(mockAutoSave).not.toBeCalled(); + + mockAutoSave.mockImplementation(async (value: number) => { + return { autoSave: mockAutoSave, lastAttemptedValue: value }; + }); + + await result.autoSave(42); + expect(mockAutoSave).toBeCalledWith(42); + expect(mockAutoSave).toBeCalledTimes(1); + }); + + it('should work with nested arrow functions', () => { + const config = { + buildVariables: (p: { id: string }) => p, + initialValue: 0, + }; + + const { result } = useAutoSave(config); + + expect(mockAutoSave).not.toBeCalled(); + }); +}); +`; + + writeFile(tree, 'apps/app1/src/useAutoSave.spec.ts', complexCode); + + await migration(tree); + + const result = tree.read('apps/app1/src/useAutoSave.spec.ts', 'utf-8'); + + // Verify the output is valid TypeScript by checking key patterns weren't corrupted + // The old buggy implementation would produce patterns like: + // - "{buildVariables}: (p:" instead of "{ buildVariables: (p:" + // - Missing opening braces after arrow functions + // - Collapsed/merged code blocks + // expect(result).not.toContain('toBeCalled'); + expect(result).not.toContain('toBeCalledWith'); + expect(result).not.toContain('toBeCalledTimes'); + expect(result).toContain('{ payGroupId, value }'); + expect(result).toContain('{ result }'); + expect(result).not.toMatch(/\{payGroupId\}:/); + expect(result).not.toMatch(/\{result\}:/); + + // Verify interface syntax is preserved + expect(result).toContain('{ payGroupId: string; value: T }'); + expect(result).not.toMatch(/\{payGroupId\}: string/); + + // Snapshot test to verify the overall structure and formatting is preserved + expect(result).toMatchInlineSnapshot(` + "interface Config { + buildVariables: (p: { payGroupId: string; value: T }) => unknown; + initialValue: number; + } + + const mockAutoSave = jest.fn(); + + function useAutoSave(config: Config) { + return { autoSave: mockAutoSave, lastAttemptedValue: config.initialValue }; + } + + describe('useAutoSave', () => { + beforeEach(() => { + mockAutoSave.mockClear(); + }); + + it('should handle complex callback patterns', async () => { + const { result } = useAutoSave({ + buildVariables: ({ payGroupId, value }) => ({ payGroupId, value }), + initialValue: 0, + }); + + expect(mockAutoSave).not.toHaveBeenCalled(); + + mockAutoSave.mockImplementation(async (value: number) => { + return { autoSave: mockAutoSave, lastAttemptedValue: value }; + }); + + await result.autoSave(42); + expect(mockAutoSave).toHaveBeenCalledWith(42); + expect(mockAutoSave).toHaveBeenCalledTimes(1); + }); + + it('should work with nested arrow functions', () => { + const config = { + buildVariables: (p: { id: string }) => p, + initialValue: 0, + }; + + const { result } = useAutoSave(config); + + expect(mockAutoSave).not.toHaveBeenCalled(); + }); + }); + " + `); + }); + function writeFile(tree: Tree, path: string, content: string): void { tree.write(path, content); fs.createFileSync(path, content); diff --git a/packages/jest/src/migrations/update-21-3-0/replace-removed-matcher-aliases.ts b/packages/jest/src/migrations/update-21-3-0/replace-removed-matcher-aliases.ts index 85162225ef1..aabaf4ca369 100644 --- a/packages/jest/src/migrations/update-21-3-0/replace-removed-matcher-aliases.ts +++ b/packages/jest/src/migrations/update-21-3-0/replace-removed-matcher-aliases.ts @@ -1,5 +1,5 @@ import { formatFiles, globAsync, type Tree } from '@nx/devkit'; -import { replace } from '@phenomnomnominal/tsquery'; +import { ast, query } from '@phenomnomnominal/tsquery'; import { SearchSource } from 'jest'; import { readConfig } from 'jest-config'; import Runtime from 'jest-runtime'; @@ -24,20 +24,63 @@ const matcherAliasesMap = new Map([ export default async function (tree: Tree) { const testFilePaths = await getTestFilePaths(tree); for (const testFilePath of testFilePaths) { - let testFileContent = tree.read(testFilePath, 'utf-8'); - for (const [alias, matcher] of matcherAliasesMap) { - testFileContent = replace( - testFileContent, - `CallExpression PropertyAccessExpression:has(CallExpression Identifier[name=expect]) Identifier[name=${alias}]`, - (_node: Identifier) => matcher - ); + const testFileContent = tree.read(testFilePath, 'utf-8'); + const updatedContent = replaceMatcherAliases(testFileContent); + if (updatedContent !== testFileContent) { + tree.write(testFilePath, updatedContent); } - tree.write(testFilePath, testFileContent); } await formatFiles(tree); } +function replaceMatcherAliases(fileContent: string): string { + // Build a selector that matches any of the deprecated matcher aliases + const aliasNames = Array.from(matcherAliasesMap.keys()); + const aliasPattern = aliasNames.join('|'); + + // Quick check to avoid parsing files that don't contain any aliases + const hasAnyAlias = aliasNames.some((alias) => fileContent.includes(alias)); + if (!hasAnyAlias) { + return fileContent; + } + + const sourceFile = ast(fileContent); + const updates: Array<{ start: number; end: number; text: string }> = []; + + // Query for all deprecated matcher identifiers in expect() chains + // The selector matches: expect(...).toBeCalled(), expect(...).not.toBeCalled(), etc. + const selector = `CallExpression PropertyAccessExpression:has(CallExpression Identifier[name=expect]) Identifier[name=/^(${aliasPattern})$/]`; + const matchedNodes = query(sourceFile, selector); + + for (const node of matchedNodes) { + const alias = node.text; + const replacement = matcherAliasesMap.get(alias); + if (replacement) { + updates.push({ + start: node.getStart(sourceFile), + end: node.getEnd(), + text: replacement, + }); + } + } + + if (!updates.length) { + return fileContent; + } + + // Apply updates in reverse order to preserve positions + let updatedContent = fileContent; + for (const update of updates.sort((a, b) => b.start - a.start)) { + updatedContent = + updatedContent.slice(0, update.start) + + update.text + + updatedContent.slice(update.end); + } + + return updatedContent; +} + async function getTestFilePaths(tree: Tree): Promise { const jestConfigFiles = await globAsync(tree, [ '**/jest.config.{cjs,mjs,js,cts,mts,ts}',