diff --git a/codemods/v13-async-functions/scripts/codemod.ts b/codemods/v13-async-functions/scripts/codemod.ts index 5641f2bc..723fc67b 100644 --- a/codemods/v13-async-functions/scripts/codemod.ts +++ b/codemods/v13-async-functions/scripts/codemod.ts @@ -8,6 +8,19 @@ const FUNCTIONS_TO_RENAME = new Map([ ['fireEvent', 'fireEventAsync'], ]); +const FIRE_EVENT_METHODS_TO_MAKE_ASYNC = new Set(['press', 'changeText', 'scroll']); +const TEST_FUNCTION_NAMES = new Set([ + 'test', + 'it', + 'beforeEach', + 'afterEach', + 'beforeAll', + 'afterAll', +]); +const TEST_FUNCTION_PREFIXES = new Set(['test', 'it']); +const TEST_MODIFIERS = new Set(['skip', 'only']); +const TEST_EACH_METHOD = 'each'; + export default async function transform( root: Parameters>[0], options?: Parameters>[1], @@ -27,13 +40,64 @@ export default async function transform( return null; } + // Collect function calls that will be renamed, BEFORE renaming + // We need to find calls to the OLD names (render, renderHook, fireEvent) + // that are in importedFunctions, as these will be renamed to async variants + const functionCalls: SgNode[] = []; + functionCalls.push(...findDirectFunctionCallsThatWillBeRenamed(rootNode, importedFunctions)); + functionCalls.push(...findFireEventMethodCallsThatWillBeRenamed(rootNode, importedFunctions)); + + // Now rename the functions renameFunctionsInUsages(rootNode, importedFunctions, edits); + // Add await to the calls we found + const functionsToMakeAsync = new Map>(); + + for (const functionCall of functionCalls) { + if (isCallAlreadyAwaited(functionCall)) { + continue; + } + + const containingFunction = findContainingTestFunction(functionCall); + if (!containingFunction) { + continue; + } + + if ( + !isFunctionAlreadyAsync(containingFunction) && + !functionsToMakeAsync.has(containingFunction.id()) + ) { + functionsToMakeAsync.set(containingFunction.id(), containingFunction); + } + + addAwaitBeforeCall(functionCall, edits); + } + + for (const func of functionsToMakeAsync.values()) { + addAsyncKeywordToFunction(func, edits); + } + if (edits.length === 0) { return null; } - edits.sort((a, b) => b.startPos - a.startPos); + // Sort edits: descending by startPos, but insertion edits (startPos == endPos) + // come before replacement edits at the same position + edits.sort((a, b) => { + if (a.startPos !== b.startPos) { + return b.startPos - a.startPos; + } + // If same startPos, insertion edits (startPos == endPos) come first + const aIsInsertion = a.startPos === a.endPos; + const bIsInsertion = b.startPos === b.endPos; + if (aIsInsertion && !bIsInsertion) { + return -1; // a comes before b + } + if (!aIsInsertion && bIsInsertion) { + return 1; // b comes before a + } + return 0; + }); return rootNode.commitEdits(edits); } @@ -174,3 +238,263 @@ function renameFunctionsInUsages( } } } + +function findDirectFunctionCallsThatWillBeRenamed( + rootNode: SgNode, + importedFunctions: Set, +): SgNode[] { + const functionCalls: SgNode[] = []; + + for (const funcName of importedFunctions) { + if (!FUNCTIONS_TO_RENAME.has(funcName)) { + continue; + } + const calls = rootNode.findAll({ + rule: { + kind: 'call_expression', + has: { + field: 'function', + kind: 'identifier', + regex: `^${funcName}$`, + }, + }, + }); + functionCalls.push(...calls); + } + + return functionCalls; +} + +function findFireEventMethodCallsThatWillBeRenamed( + rootNode: SgNode, + importedFunctions: Set, +): SgNode[] { + const functionCalls: SgNode[] = []; + + if (!importedFunctions.has('fireEvent')) { + return functionCalls; + } + + const fireEventMethodCalls = rootNode.findAll({ + rule: { + kind: 'call_expression', + has: { + field: 'function', + kind: 'member_expression', + }, + }, + }); + + for (const call of fireEventMethodCalls) { + const funcNode = call.field('function'); + if (funcNode && funcNode.is('member_expression')) { + try { + const object = funcNode.field('object'); + const property = funcNode.field('property'); + if (object && property) { + const objText = object.text(); + const propText = property.text(); + if (objText === 'fireEvent' && FIRE_EVENT_METHODS_TO_MAKE_ASYNC.has(propText)) { + functionCalls.push(call); + } + } + } catch { + // Skip nodes where field() is not available or AST structure doesn't match expectations. + // This is expected for malformed or edge-case AST structures and should be silently ignored. + } + } + } + + return functionCalls; +} + +function isCallAlreadyAwaited(functionCall: SgNode): boolean { + const parent = functionCall.parent(); + return parent !== null && parent.is('await_expression'); +} + +function addAwaitBeforeCall(functionCall: SgNode, edits: Edit[]): void { + const callStart = functionCall.range().start.index; + edits.push({ + startPos: callStart, + endPos: callStart, + insertedText: 'await ', + }); +} + +/** + * Checks if a function is already marked as async using AST-based detection. + * This is more reliable than string matching and handles edge cases better. + */ +function isFunctionAlreadyAsync(func: SgNode): boolean { + if (func.is('arrow_function')) { + // For arrow functions, check if 'async' is a direct child + const children = func.children(); + return children.some((child) => child.text() === 'async'); + } else if (func.is('function_declaration') || func.is('function_expression')) { + // For function declarations/expressions, check for async modifier + // The async keyword appears before the 'function' keyword + const children = func.children(); + const functionKeywordIndex = children.findIndex((child) => child.text() === 'function'); + if (functionKeywordIndex > 0) { + // Check if any child before 'function' is 'async' + return children.slice(0, functionKeywordIndex).some((child) => child.text() === 'async'); + } + // Also check if the first child is 'async' + return children.length > 0 && children[0].text() === 'async'; + } + return false; +} + +function addAsyncKeywordToFunction(func: SgNode, edits: Edit[]): void { + if (func.is('arrow_function')) { + const funcStart = func.range().start.index; + edits.push({ + startPos: funcStart, + endPos: funcStart, + insertedText: 'async ', + }); + } else if (func.is('function_declaration') || func.is('function_expression')) { + const children = func.children(); + const firstChild = children.length > 0 ? children[0] : null; + if (firstChild && firstChild.text() === 'function') { + const funcKeywordStart = firstChild.range().start.index; + edits.push({ + startPos: funcKeywordStart, + endPos: funcKeywordStart, + insertedText: 'async ', + }); + } else { + const funcStart = func.range().start.index; + edits.push({ + startPos: funcStart, + endPos: funcStart, + insertedText: 'async ', + }); + } + } +} + +/** + * Finds the containing test function (test, it, beforeEach, etc.) for a given node. + * Traverses up the AST tree to find the nearest test function that contains the node. + * + * Handles various test patterns: + * - Direct test functions: test(), it() + * - Test modifiers: test.skip(), it.only() + * - Test.each patterns: test.each(), it.each() + * - Hooks: beforeEach(), afterEach(), beforeAll(), afterAll() + * + * @param node - The AST node to find the containing test function for + * @returns The containing test function node, or null if not found + */ +function findContainingTestFunction(node: SgNode): SgNode | null { + let current: SgNode | null = node; + + while (current) { + if ( + current.is('arrow_function') || + current.is('function_declaration') || + current.is('function_expression') + ) { + const parent = current.parent(); + if (parent) { + if (parent.is('arguments')) { + const grandParent = parent.parent(); + if (grandParent && grandParent.is('call_expression')) { + const funcNode = grandParent.field('function'); + if (funcNode) { + const funcText = funcNode.text(); + if (TEST_FUNCTION_NAMES.has(funcText)) { + return current; + } + if (funcNode.is('member_expression')) { + try { + const object = funcNode.field('object'); + const property = funcNode.field('property'); + if (object && property) { + const objText = object.text(); + const propText = property.text(); + if (TEST_FUNCTION_PREFIXES.has(objText) && TEST_MODIFIERS.has(propText)) { + return current; + } + } + } catch { + // Skip nodes where field() is not available or AST structure doesn't match expectations. + // This is expected for malformed or edge-case AST structures and should be silently ignored. + } + } + if (funcNode.is('call_expression')) { + try { + const innerFuncNode = funcNode.field('function'); + if (innerFuncNode && innerFuncNode.is('member_expression')) { + const object = innerFuncNode.field('object'); + const property = innerFuncNode.field('property'); + if (object && property) { + const objText = object.text(); + const propText = property.text(); + if (TEST_FUNCTION_PREFIXES.has(objText) && propText === TEST_EACH_METHOD) { + return current; + } + } + } + } catch { + // Skip nodes where field() is not available or AST structure doesn't match expectations. + // This is expected for malformed or edge-case AST structures and should be silently ignored. + } + } + } + } + } + if (parent.is('call_expression')) { + const funcNode = parent.field('function'); + if (funcNode) { + const funcText = funcNode.text(); + if (TEST_FUNCTION_NAMES.has(funcText)) { + return current; + } + if (funcNode.is('member_expression')) { + try { + const object = funcNode.field('object'); + const property = funcNode.field('property'); + if (object && property) { + const objText = object.text(); + const propText = property.text(); + if (TEST_FUNCTION_PREFIXES.has(objText) && TEST_MODIFIERS.has(propText)) { + return current; + } + } + } catch { + // Skip nodes where field() is not available or AST structure doesn't match expectations. + // This is expected for malformed or edge-case AST structures and should be silently ignored. + } + } + if (funcNode.is('call_expression')) { + try { + const innerFuncNode = funcNode.field('function'); + if (innerFuncNode && innerFuncNode.is('member_expression')) { + const object = innerFuncNode.field('object'); + const property = innerFuncNode.field('property'); + if (object && property) { + const objText = object.text(); + const propText = property.text(); + if (TEST_FUNCTION_PREFIXES.has(objText) && propText === TEST_EACH_METHOD) { + return current; + } + } + } + } catch { + // Skip nodes where field() is not available or AST structure doesn't match expectations. + // This is expected for malformed or edge-case AST structures and should be silently ignored. + } + } + } + } + } + } + + current = current.parent(); + } + + return null; +} diff --git a/codemods/v13-async-functions/tests/fixtures/basic-rename/expected.tsx b/codemods/v13-async-functions/tests/fixtures/basic-rename/expected.tsx index 0853dd31..8c1053d5 100644 --- a/codemods/v13-async-functions/tests/fixtures/basic-rename/expected.tsx +++ b/codemods/v13-async-functions/tests/fixtures/basic-rename/expected.tsx @@ -1,7 +1,7 @@ import { renderAsync, renderHookAsync, fireEventAsync } from '@testing-library/react-native'; -test('renders component', () => { - const component = renderAsync(); - const { result } = renderHookAsync(() => useMyHook()); - fireEventAsync.press(component.getByText('Button')); +test('renders component', async () => { + const component = await renderAsync(); + const { result } = await renderHookAsync(() => useMyHook()); + await fireEventAsync.press(component.getByText('Button')); }); diff --git a/codemods/v13-async-functions/tests/fixtures/fireevent-methods/expected.tsx b/codemods/v13-async-functions/tests/fixtures/fireevent-methods/expected.tsx index f221e845..02808047 100644 --- a/codemods/v13-async-functions/tests/fixtures/fireevent-methods/expected.tsx +++ b/codemods/v13-async-functions/tests/fixtures/fireevent-methods/expected.tsx @@ -1,8 +1,8 @@ import { fireEventAsync } from '@testing-library/react-native'; -test('handles events', () => { +test('handles events', async () => { const input = getByTestId('input'); - fireEventAsync.changeText(input, 'Hello'); - fireEventAsync.press(input); - fireEventAsync.scroll(input); + await fireEventAsync.changeText(input, 'Hello'); + await fireEventAsync.press(input); + await fireEventAsync.scroll(input); });