diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 608d9f134..eec74a57e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -196,6 +196,19 @@ jobs: env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + test-codemods: + runs-on: ubuntu-latest + name: Test + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup Node.js and deps + uses: ./.github/actions/setup-deps + + - name: Test + run: yarn test:codemods + test-rn-0-83-1: runs-on: ubuntu-latest name: Test RN 0.83.1 diff --git a/.prettierignore b/.prettierignore index 63022880d..f0073a419 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,2 +1,3 @@ node_modules/ .yarn +codemods/**/tests/fixtures/** diff --git a/.yarnrc.yml b/.yarnrc.yml index 96756dd3e..4a8eb27e3 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -6,9 +6,9 @@ npmPreapprovedPackages: - react - react-native - universal-test-renderer + - '@testing-library/react-native' - '@react-native/*' - '@types/react' - - '@types/universal-test-renderer' - hermes-compiler yarnPath: .yarn/releases/yarn-4.11.0.cjs diff --git a/AGENTS.md b/AGENTS.md index 858ba77dc..e5f63d967 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,12 @@ This document provides context for the any code assistant to understand the `@te - **Tech Stack:** TypeScript, React Native, Jest. - **Architecture:** The library simulates the React Native runtime on top of `universal-test-renderer`. +## Project Guidelines + +- Small API surface +- Expose all features of the underlying platform (react, react-reconciler) for Testing Libraries to use +- Render host elements only, yet provide escape hatches to fibers when needed + ## Building and Running The project uses `yarn` for dependency management and script execution. diff --git a/codemods/v14-async-functions/.gitignore b/codemods/v14-async-functions/.gitignore new file mode 100644 index 000000000..78174f4c5 --- /dev/null +++ b/codemods/v14-async-functions/.gitignore @@ -0,0 +1,33 @@ +# Dependencies +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Build artifacts +target/ +dist/ +build/ + +# Temporary files +*.tmp +*.temp +.cache/ + +# Environment files +.env +.env.local + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo + +# OS files +.DS_Store +Thumbs.db + +# Package bundles +*.tar.gz +*.tgz \ No newline at end of file diff --git a/codemods/v14-async-functions/README.md b/codemods/v14-async-functions/README.md new file mode 100644 index 000000000..f56eced77 --- /dev/null +++ b/codemods/v14-async-functions/README.md @@ -0,0 +1,58 @@ +# RNTL v14: Make render(), act(), renderHook(), and fireEvent() calls async + +This codemod migrates your test files from React Native Testing Library v13 to v14 by automatically transforming synchronous function calls to async versions and making test functions async when needed. + +## What it does + +- Transforms `render()`, `act()`, `renderHook()`, and `fireEvent()` calls to `await render()`, `await act()`, etc. +- Makes test functions (`test()`, `it()`, hooks) async when needed +- Handles `fireEvent.press()`, `fireEvent.changeText()`, `fireEvent.scroll()`, `screen.rerender()`, `screen.unmount()`, and renderer methods +- Only transforms calls imported from `@testing-library/react-native` + +## Usage + +```bash +# Run the codemod +npx codemod@latest run rntl-v14-async-functions --target ./path/to/your/tests +``` + +### Custom render functions + +If you have custom render helper functions (like `renderWithProviders`, `renderWithTheme`), specify them so they get transformed too: + +```bash +npx codemod@latest run rntl-v14-async-functions --target ./path/to/your/tests --param customRenderFunctions="renderWithProviders,renderWithTheme" +``` + +## Example + +**Before:** + +```typescript +test('renders component', () => { + render(); + expect(screen.getByText('Hello')).toBeOnTheScreen(); +}); +``` + +**After:** + +```typescript +test('renders component', async () => { + await render(); + expect(screen.getByText('Hello')).toBeOnTheScreen(); +}); +``` + +## Limitations + +- Helper functions are not transformed by default (use `customRenderFunctions` param if needed) +- Namespace imports (`import * as RNTL`) are not handled + +## Next steps + +1. Run the codemod on your test files +2. Review the changes +3. Manually update any remaining helper functions if needed +4. Update your RNTL version to v14 (`rntl-v14-update-deps` codemod) +5. Run your tests to verify everything works diff --git a/codemods/v14-async-functions/codemod.yaml b/codemods/v14-async-functions/codemod.yaml new file mode 100644 index 000000000..9fbe1c9fd --- /dev/null +++ b/codemods/v14-async-functions/codemod.yaml @@ -0,0 +1,19 @@ +schema_version: '1.0' + +name: 'rntl-v14-async-functions' +version: '0.1.0' +description: 'Codemod to migrate sync RNTL function and method calls to async for RNTL v14' +author: 'Maciej Jastrzebski' +license: 'MIT' +workflow: 'workflow.yaml' + +targets: + languages: ['typescript', 'tsx', 'javascript', 'jsx'] + +keywords: ['transformation', 'migration'] + +registry: + access: 'public' + visibility: 'public' + +capabilities: [] diff --git a/codemods/v14-async-functions/package.json b/codemods/v14-async-functions/package.json new file mode 100644 index 000000000..0180522ba --- /dev/null +++ b/codemods/v14-async-functions/package.json @@ -0,0 +1,14 @@ +{ + "name": "@testing-library/react-native-v14-async-functions", + "version": "0.1.0", + "description": "Codemod to migrate render() calls to await render() for RNTL v14", + "type": "module", + "scripts": { + "test": "yarn dlx codemod@latest jssg test -l tsx ./scripts/codemod.ts", + "check-types": "tsc --noEmit" + }, + "devDependencies": { + "@codemod.com/jssg-types": "^1.3.0", + "typescript": "^5.8.3" + } +} diff --git a/codemods/v14-async-functions/scripts/codemod.ts b/codemods/v14-async-functions/scripts/codemod.ts new file mode 100644 index 000000000..49fdb7891 --- /dev/null +++ b/codemods/v14-async-functions/scripts/codemod.ts @@ -0,0 +1,1281 @@ +import type { Transform } from 'codemod:ast-grep'; +import type TSX from 'codemod:ast-grep/langs/tsx'; +import type { Edit, SgNode } from '@codemod.com/jssg-types/main'; + +const FUNCTIONS_TO_MAKE_ASYNC = new Set(['render', 'renderHook', 'act', 'fireEvent']); +const FIRE_EVENT_METHODS_TO_MAKE_ASYNC = new Set(['press', 'changeText', 'scroll']); +const SCREEN_METHODS_TO_MAKE_ASYNC = new Set(['rerender', 'unmount']); +const RESULT_METHODS_TO_MAKE_ASYNC = new Set(['rerender', 'unmount']); +const ASYNC_FUNCTIONS_TO_RENAME = new Map([ + ['renderAsync', 'render'], + ['renderHookAsync', 'renderHook'], + ['fireEventAsync', 'fireEvent'], +]); +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], +): ReturnType> { + const rootNode = root.root(); + const edits: Edit[] = []; + + const customRenderFunctionsSet = parseCustomRenderFunctionsFromOptions(options); + const rntlImports = findRNTLImportStatements(rootNode); + + if (rntlImports.length === 0 && customRenderFunctionsSet.size === 0) { + return null; + } + + const { importedFunctions, specifiersToRemove } = extractImportedFunctionNames( + rntlImports, + edits, + ); + removeDuplicateImportSpecifiers(specifiersToRemove, rootNode, edits); + + let finalCustomRenderFunctionsSet = customRenderFunctionsSet; + if (finalCustomRenderFunctionsSet.size === 0 && importedFunctions.has('render')) { + const autoDetectedCustomRenders = findAutoDetectedCustomRenderFunctions( + rootNode, + importedFunctions, + ); + if (autoDetectedCustomRenders.size > 1) { + finalCustomRenderFunctionsSet = autoDetectedCustomRenders; + } + } + + const importedAsyncVariants = new Set(); + for (const importStmt of rntlImports) { + const importClause = importStmt.find({ + rule: { kind: 'import_clause' }, + }); + if (!importClause) continue; + const namedImports = importClause.find({ + rule: { kind: 'named_imports' }, + }); + if (namedImports) { + const specifiers = namedImports.findAll({ + rule: { kind: 'import_specifier' }, + }); + for (const specifier of specifiers) { + const identifier = specifier.find({ + rule: { kind: 'identifier' }, + }); + if (identifier) { + const funcName = identifier.text(); + if (ASYNC_FUNCTIONS_TO_RENAME.has(funcName)) { + importedAsyncVariants.add(funcName); + } + } + } + } + } + + if (importedFunctions.size === 0 && finalCustomRenderFunctionsSet.size === 0) { + return null; + } + + renameAsyncVariantsInUsages(rootNode, edits); + + const functionCalls: SgNode[] = []; + functionCalls.push(...findDirectFunctionCalls(rootNode, importedFunctions)); + + for (const asyncName of importedAsyncVariants) { + const syncName = ASYNC_FUNCTIONS_TO_RENAME.get(asyncName)!; + const asyncCalls = rootNode.findAll({ + rule: { + kind: 'call_expression', + has: { + field: 'function', + kind: 'identifier', + regex: `^${asyncName}$`, + }, + }, + }); + functionCalls.push(...asyncCalls); + if (!importedFunctions.has(syncName)) { + importedFunctions.add(syncName); + } + } + functionCalls.push(...findFireEventMethodCalls(rootNode, importedFunctions, rntlImports)); + functionCalls.push(...findScreenMethodCalls(rootNode)); + + const { allVariables, renamedMethodVariables } = trackVariablesAssignedFromRenderAndRenderHook( + rootNode, + importedFunctions, + ); + functionCalls.push(...findResultMethodCalls(rootNode, allVariables, renamedMethodVariables)); + + if (functionCalls.length === 0 && finalCustomRenderFunctionsSet.size === 0) { + if (edits.length === 0) { + return null; + } + } + + const functionsToMakeAsync = new Map>(); + const customRenderFunctionsToMakeAsync = new Map>(); + + if (finalCustomRenderFunctionsSet.size > 0 && importedFunctions.size > 0) { + const customRenderFunctionDefinitions = findCustomRenderFunctionDefinitions( + rootNode, + finalCustomRenderFunctionsSet, + ); + for (const funcDef of customRenderFunctionDefinitions) { + transformRNTLCallsInsideCustomRender( + funcDef, + importedFunctions, + edits, + customRenderFunctionsToMakeAsync, + rootNode, + ); + } + } + + 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); + } + + if (finalCustomRenderFunctionsSet.size > 0) { + const customRenderCalls = findCustomRenderFunctionCalls( + rootNode, + finalCustomRenderFunctionsSet, + ); + for (const callExpr of customRenderCalls) { + const containingFunction = findContainingTestFunction(callExpr); + if (containingFunction) { + if (isCallAlreadyAwaited(callExpr)) { + continue; + } + + if ( + !isFunctionAlreadyAsync(containingFunction) && + !functionsToMakeAsync.has(containingFunction.id()) + ) { + functionsToMakeAsync.set(containingFunction.id(), containingFunction); + } + + addAwaitBeforeCall(callExpr, edits); + } + } + } + + for (const func of functionsToMakeAsync.values()) { + addAsyncKeywordToFunction(func, edits); + } + + for (const func of customRenderFunctionsToMakeAsync.values()) { + addAsyncKeywordToFunction(func, edits); + } + + if (edits.length === 0) { + return null; + } + + edits.sort((a, b) => b.startPos - a.startPos); + + return rootNode.commitEdits(edits); +} + +interface CodemodOptions { + params?: { + customRenderFunctions?: string | number | boolean; + }; +} + +function parseCustomRenderFunctionsFromOptions(options?: CodemodOptions): Set { + const customRenderFunctionsParam = options?.params?.customRenderFunctions + ? String(options.params.customRenderFunctions) + : ''; + + const customRenderFunctionsSet = new Set(); + if (customRenderFunctionsParam) { + customRenderFunctionsParam + .split(',') + .map((name) => name.trim()) + .filter((name) => name.length > 0) + .forEach((name) => customRenderFunctionsSet.add(name)); + } + return customRenderFunctionsSet; +} + +function findRNTLImportStatements(rootNode: SgNode): SgNode[] { + return rootNode.findAll({ + rule: { + kind: 'import_statement', + has: { + kind: 'string', + regex: '@testing-library/react-native', + }, + }, + }); +} + +function extractImportedFunctionNames( + rntlImports: SgNode[], + edits: Edit[], +): { + importedFunctions: Set; + specifiersToRemove: Array<{ specifier: SgNode; importStmt: SgNode }>; +} { + const importedFunctions = new Set(); + const specifiersToRemove: Array<{ specifier: SgNode; importStmt: SgNode }> = []; + + for (const importStmt of rntlImports) { + const importClause = importStmt.find({ + rule: { kind: 'import_clause' }, + }); + if (!importClause) continue; + + const namedImports = importClause.find({ + rule: { kind: 'named_imports' }, + }); + if (namedImports) { + const specifiers = namedImports.findAll({ + rule: { kind: 'import_specifier' }, + }); + + const importedNames = new Set(); + for (const specifier of specifiers) { + const identifier = specifier.find({ + rule: { kind: 'identifier' }, + }); + if (identifier) { + const funcName = identifier.text(); + importedNames.add(funcName); + } + } + + for (const specifier of specifiers) { + const identifier = specifier.find({ + rule: { kind: 'identifier' }, + }); + if (identifier) { + const funcName = identifier.text(); + if (ASYNC_FUNCTIONS_TO_RENAME.has(funcName)) { + const newName = ASYNC_FUNCTIONS_TO_RENAME.get(funcName)!; + if (importedNames.has(newName)) { + specifiersToRemove.push({ specifier, importStmt }); + importedFunctions.add(newName); + } else { + const identifierRange = identifier.range(); + edits.push({ + startPos: identifierRange.start.index, + endPos: identifierRange.end.index, + insertedText: newName, + }); + importedFunctions.add(newName); + } + } else if (FUNCTIONS_TO_MAKE_ASYNC.has(funcName)) { + importedFunctions.add(funcName); + } + } + } + } + + const defaultImport = importClause.find({ + rule: { kind: 'identifier' }, + }); + if (defaultImport) { + const funcName = defaultImport.text(); + if (FUNCTIONS_TO_MAKE_ASYNC.has(funcName)) { + importedFunctions.add(funcName); + } + } + + const namespaceImport = importClause.find({ + rule: { kind: 'namespace_import' }, + }); + if (namespaceImport) { + FUNCTIONS_TO_MAKE_ASYNC.forEach((func) => importedFunctions.add(func)); + break; + } + } + + return { importedFunctions, specifiersToRemove }; +} + +/** + * Removes duplicate import specifiers from import statements. + * Handles complex comma placement scenarios when removing specifiers: + * - Trailing commas after the specifier + * - Leading commas before the specifier + * - Edge cases with single vs multiple specifiers + * + * @param specifiersToRemove - Array of specifiers to remove with their import statements + * @param rootNode - The root AST node (used to get full text for comma detection) + * @param edits - Array to collect edit operations + */ +function removeDuplicateImportSpecifiers( + specifiersToRemove: Array<{ specifier: SgNode; importStmt: SgNode }>, + rootNode: SgNode, + edits: Edit[], +): void { + specifiersToRemove.sort( + (a, b) => b.specifier.range().start.index - a.specifier.range().start.index, + ); + + for (const { specifier } of specifiersToRemove) { + const specifierRange = specifier.range(); + const parent = specifier.parent(); + + if (parent && parent.is('named_imports')) { + const fullText = rootNode.text(); + const specifierEnd = specifierRange.end.index; + + const textAfter = fullText.substring(specifierEnd); + const trailingCommaMatch = textAfter.match(/^\s*,\s*/); + + if (trailingCommaMatch) { + edits.push({ + startPos: specifierRange.start.index, + endPos: specifierEnd + trailingCommaMatch[0].length, + insertedText: '', + }); + } else { + const textBefore = fullText.substring(0, specifierRange.start.index); + const leadingCommaMatch = textBefore.match(/,\s*$/); + + if (leadingCommaMatch) { + edits.push({ + startPos: specifierRange.start.index - leadingCommaMatch[0].length, + endPos: specifierEnd, + insertedText: '', + }); + } else { + edits.push({ + startPos: specifierRange.start.index, + endPos: specifierEnd, + insertedText: '', + }); + } + } + } + } +} + +function renameAsyncVariantsInUsages(rootNode: SgNode, edits: Edit[]): void { + for (const [asyncName, syncName] of ASYNC_FUNCTIONS_TO_RENAME.entries()) { + const asyncIdentifiers = rootNode.findAll({ + rule: { + kind: 'identifier', + regex: `^${asyncName}$`, + }, + }); + + for (const identifier of asyncIdentifiers) { + const parent = identifier.parent(); + if (parent && parent.is('import_specifier')) { + continue; + } + const identifierRange = identifier.range(); + edits.push({ + startPos: identifierRange.start.index, + endPos: identifierRange.end.index, + insertedText: syncName, + }); + } + + const memberExpressions = rootNode.findAll({ + rule: { + kind: 'member_expression', + has: { + field: 'object', + kind: 'identifier', + regex: `^${asyncName}$`, + }, + }, + }); + + for (const memberExpr of memberExpressions) { + const object = memberExpr.field('object'); + if (object && object.is('identifier')) { + const objectRange = object.range(); + edits.push({ + startPos: objectRange.start.index, + endPos: objectRange.end.index, + insertedText: syncName, + }); + } + } + } +} + +function findDirectFunctionCalls( + rootNode: SgNode, + importedFunctions: Set, +): SgNode[] { + const functionCalls: SgNode[] = []; + + for (const funcName of importedFunctions) { + const calls = rootNode.findAll({ + rule: { + kind: 'call_expression', + has: { + field: 'function', + kind: 'identifier', + regex: `^${funcName}$`, + }, + }, + }); + functionCalls.push(...calls); + } + + return functionCalls; +} + +function findFireEventMethodCalls( + rootNode: SgNode, + importedFunctions: Set, + rntlImports: SgNode[], +): SgNode[] { + const functionCalls: SgNode[] = []; + const fireEventNames = new Set(); + + if (importedFunctions.has('fireEvent')) { + fireEventNames.add('fireEvent'); + } + + for (const [asyncName, syncName] of ASYNC_FUNCTIONS_TO_RENAME.entries()) { + if (syncName === 'fireEvent') { + const wasImported = rntlImports.some((importStmt) => { + const importClause = importStmt.find({ rule: { kind: 'import_clause' } }); + if (!importClause) return false; + const namedImports = importClause.find({ rule: { kind: 'named_imports' } }); + if (!namedImports) return false; + const specifiers = namedImports.findAll({ rule: { kind: 'import_specifier' } }); + return specifiers.some((spec) => { + const identifier = spec.find({ rule: { kind: 'identifier' } }); + return identifier && identifier.text() === asyncName; + }); + }); + if (wasImported) { + fireEventNames.add(asyncName); + } + } + } + + if (fireEventNames.size > 0) { + 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 (fireEventNames.has(objText) && 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 findScreenMethodCalls(rootNode: SgNode): SgNode[] { + const functionCalls: SgNode[] = []; + const screenMethodCalls = rootNode.findAll({ + rule: { + kind: 'call_expression', + has: { + field: 'function', + kind: 'member_expression', + }, + }, + }); + + for (const call of screenMethodCalls) { + 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 === 'screen' && SCREEN_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; +} + +/** + * Tracks variables assigned from render() and renderHook() calls to identify result objects. + * This helps identify calls like `renderer.rerender()`, `renderer.unmount()`, `result.rerender()`, etc. + * that need to be made async. + * + * Handles various assignment patterns: + * - Direct assignment: `const renderer = render(...)` or `const result = renderHook(...)` + * - Destructured assignment: `const { rerender } = render(...)` or `const { rerender } = renderHook(...)` + * - Renamed destructuring: `const { rerender: rerenderHook } = renderHook(...)` (renderHook only) + * - Assignment expressions: `renderer = render(...)` or `result = renderHook(...)` + * + * @param rootNode - The root AST node to search within + * @param importedFunctions - Set of imported function names (should include 'render' and/or 'renderHook') + * @returns Object containing: + * - allVariables: Set of all variable names representing render/renderHook results + * - renamedMethodVariables: Set of renamed method variables (e.g., rerenderHook from renderHook) + */ +function trackVariablesAssignedFromRenderAndRenderHook( + rootNode: SgNode, + importedFunctions: Set, +): { + allVariables: Set; + renamedMethodVariables: Set; +} { + const allVariables = new Set(); + const renamedMethodVariables = new Set(); + + // Track variables from both render() and renderHook() calls + const functionsToTrack = ['render', 'renderHook'] as const; + + for (const funcName of functionsToTrack) { + if (!importedFunctions.has(funcName)) { + continue; + } + + const functionCalls = rootNode.findAll({ + rule: { + kind: 'call_expression', + has: { + field: 'function', + kind: 'identifier', + regex: `^${funcName}$`, + }, + }, + }); + + for (const functionCall of functionCalls) { + let parent = functionCall.parent(); + const isAwaited = parent && parent.is('await_expression'); + + if (isAwaited) { + parent = parent.parent(); + } + + if (parent && parent.is('variable_declarator')) { + const objectPattern = parent.find({ + rule: { kind: 'object_pattern' }, + }); + if (objectPattern) { + const shorthandProps = objectPattern.findAll({ + rule: { kind: 'shorthand_property_identifier_pattern' }, + }); + for (const prop of shorthandProps) { + const propName = prop.text(); + if (RESULT_METHODS_TO_MAKE_ASYNC.has(propName)) { + allVariables.add(propName); + } + } + // Handle renamed destructuring (only for renderHook, but we check for both to be safe) + const pairPatterns = objectPattern.findAll({ + rule: { kind: 'pair_pattern' }, + }); + for (const pair of pairPatterns) { + const key = pair.find({ + rule: { kind: 'property_identifier' }, + }); + const value = pair.find({ + rule: { kind: 'identifier' }, + }); + if (key && value) { + const keyName = key.text(); + const valueName = value.text(); + if (RESULT_METHODS_TO_MAKE_ASYNC.has(keyName)) { + allVariables.add(valueName); + renamedMethodVariables.add(valueName); + } + } + } + } else { + const nameNode = parent.find({ + rule: { kind: 'identifier' }, + }); + if (nameNode) { + const varName = nameNode.text(); + allVariables.add(varName); + } + } + } else if (parent && parent.is('assignment_expression')) { + const left = parent.find({ + rule: { kind: 'identifier' }, + }); + if (left) { + const varName = left.text(); + allVariables.add(varName); + } else { + const objectPattern = parent.find({ + rule: { kind: 'object_pattern' }, + }); + if (objectPattern) { + const shorthandProps = objectPattern.findAll({ + rule: { kind: 'shorthand_property_identifier_pattern' }, + }); + for (const prop of shorthandProps) { + const propName = prop.text(); + if (RESULT_METHODS_TO_MAKE_ASYNC.has(propName)) { + allVariables.add(propName); + } + } + // Handle renamed destructuring in assignment expressions + const pairPatterns = objectPattern.findAll({ + rule: { kind: 'pair_pattern' }, + }); + for (const pair of pairPatterns) { + const key = pair.find({ + rule: { kind: 'property_identifier' }, + }); + const value = pair.find({ + rule: { kind: 'identifier' }, + }); + if (key && value) { + const keyName = key.text(); + const valueName = value.text(); + if (RESULT_METHODS_TO_MAKE_ASYNC.has(keyName)) { + allVariables.add(valueName); + renamedMethodVariables.add(valueName); + } + } + } + } + } + } + } + } + + return { allVariables, renamedMethodVariables }; +} + +/** + * Finds method calls on render/renderHook result variables (e.g., renderer.rerender(), result.unmount()). + * Also finds direct calls to renamed method variables (e.g., rerenderHook()). + * + * @param rootNode - The root AST node to search within + * @param allVariables - Set of all variable names from render/renderHook results + * @param renamedMethodVariables - Set of renamed method variables (e.g., rerenderHook) + * @returns Array of function call nodes that need to be made async + */ +function findResultMethodCalls( + rootNode: SgNode, + allVariables: Set, + renamedMethodVariables: Set, +): SgNode[] { + const functionCalls: SgNode[] = []; + + if (allVariables.size > 0) { + const resultMethodCalls = rootNode.findAll({ + rule: { + kind: 'call_expression', + has: { + field: 'function', + kind: 'member_expression', + }, + }, + }); + + for (const call of resultMethodCalls) { + 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 (allVariables.has(objText) && RESULT_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. + } + } + } + + // Find direct calls to method variables (e.g., rerender(), unmount(), rerenderHook()) + for (const varName of allVariables) { + if (RESULT_METHODS_TO_MAKE_ASYNC.has(varName) || renamedMethodVariables.has(varName)) { + const directCalls = rootNode.findAll({ + rule: { + kind: 'call_expression', + has: { + field: 'function', + kind: 'identifier', + regex: `^${varName}$`, + }, + }, + }); + functionCalls.push(...directCalls); + } + } + } + + return functionCalls; +} + +/** + * Automatically detects custom render functions by analyzing the code structure. + * A custom render function is identified as: + * - A function/const that starts with 'render' (e.g., renderWithProviders, renderWithTheme) + * - Called from within test functions + * - Contains calls to the base render() function from RNTL + * - Defined at the top level (not nested inside other functions) + * + * This helps identify custom render wrappers that should be transformed. + * + * @param rootNode - The root AST node to search within + * @param importedFunctions - Set of imported function names (must include 'render') + * @returns Set of custom render function names that were auto-detected + */ +function findAutoDetectedCustomRenderFunctions( + rootNode: SgNode, + importedFunctions: Set, +): Set { + const customRenderFunctions = new Set(); + + if (!importedFunctions.has('render')) { + return customRenderFunctions; + } + + const allCallExpressions = rootNode.findAll({ + rule: { kind: 'call_expression' }, + }); + + const functionsCalledFromTests = new Set(); + for (const callExpr of allCallExpressions) { + const funcNode = callExpr.field('function'); + if (!funcNode) continue; + + let calledFunctionName: string | null = null; + if (funcNode.is('identifier')) { + calledFunctionName = funcNode.text(); + } else if (funcNode.is('member_expression')) { + continue; + } + + if (calledFunctionName) { + const containingFunction = findContainingTestFunction(callExpr); + if (containingFunction) { + functionsCalledFromTests.add(calledFunctionName); + } + } + } + + const functionDeclarations = rootNode.findAll({ + rule: { kind: 'function_declaration' }, + }); + for (const funcDecl of functionDeclarations) { + const nameNode = funcDecl.find({ + rule: { kind: 'identifier' }, + }); + if (nameNode) { + const funcName = nameNode.text(); + if (funcName.startsWith('render') && functionsCalledFromTests.has(funcName)) { + let parent = funcDecl.parent(); + let isTopLevel = false; + while (parent) { + if (parent.is('program') || parent.is('module')) { + isTopLevel = true; + break; + } + if ( + parent.is('statement_block') || + parent.is('lexical_declaration') || + parent.is('variable_declaration') + ) { + const grandParent = parent.parent(); + if (grandParent && (grandParent.is('program') || grandParent.is('module'))) { + isTopLevel = true; + break; + } + } + parent = parent.parent(); + } + if (isTopLevel) { + const renderCalls = funcDecl.findAll({ + rule: { + kind: 'call_expression', + has: { + field: 'function', + kind: 'identifier', + regex: '^render$', + }, + }, + }); + if (renderCalls.length > 0) { + customRenderFunctions.add(funcName); + } + } + } + } + } + + const variableDeclarations = rootNode.findAll({ + rule: { kind: 'lexical_declaration' }, + }); + for (const varDecl of variableDeclarations) { + const declarators = varDecl.findAll({ + rule: { kind: 'variable_declarator' }, + }); + for (const declarator of declarators) { + const nameNode = declarator.find({ + rule: { kind: 'identifier' }, + }); + if (nameNode) { + const funcName = nameNode.text(); + if (funcName.startsWith('render') && functionsCalledFromTests.has(funcName)) { + let parent = varDecl.parent(); + let isTopLevel = false; + while (parent) { + if (parent.is('program') || parent.is('module')) { + isTopLevel = true; + break; + } + if (parent.is('statement_block')) { + const grandParent = parent.parent(); + if (grandParent && (grandParent.is('program') || grandParent.is('module'))) { + isTopLevel = true; + break; + } + } + parent = parent.parent(); + } + if (isTopLevel) { + const init = declarator.find({ + rule: { + any: [{ kind: 'arrow_function' }, { kind: 'function_expression' }], + }, + }); + if (init) { + const renderCalls = init.findAll({ + rule: { + kind: 'call_expression', + has: { + field: 'function', + kind: 'identifier', + regex: '^render$', + }, + }, + }); + if (renderCalls.length > 0) { + customRenderFunctions.add(funcName); + } + } + } + } + } + } + } + + return customRenderFunctions; +} + +function findCustomRenderFunctionDefinitions( + rootNode: SgNode, + customRenderFunctionsSet: Set, +): SgNode[] { + const customRenderFunctions: SgNode[] = []; + + const functionDeclarations = rootNode.findAll({ + rule: { kind: 'function_declaration' }, + }); + for (const funcDecl of functionDeclarations) { + const nameNode = funcDecl.find({ + rule: { kind: 'identifier' }, + }); + if (nameNode) { + const funcName = nameNode.text(); + if (customRenderFunctionsSet.has(funcName)) { + customRenderFunctions.push(funcDecl); + } + } + } + + const variableDeclarations = rootNode.findAll({ + rule: { kind: 'lexical_declaration' }, + }); + for (const varDecl of variableDeclarations) { + const declarators = varDecl.findAll({ + rule: { kind: 'variable_declarator' }, + }); + for (const declarator of declarators) { + const nameNode = declarator.find({ + rule: { kind: 'identifier' }, + }); + if (nameNode) { + const funcName = nameNode.text(); + if (customRenderFunctionsSet.has(funcName)) { + const init = declarator.find({ + rule: { + any: [{ kind: 'arrow_function' }, { kind: 'function_expression' }], + }, + }); + if (init) { + customRenderFunctions.push(init); + } + } + } + } + } + + return customRenderFunctions; +} + +function findCustomRenderFunctionCalls( + rootNode: SgNode, + customRenderFunctionsSet: Set, +): SgNode[] { + const customRenderCalls: SgNode[] = []; + const allCallExpressions = rootNode.findAll({ + rule: { kind: 'call_expression' }, + }); + + for (const callExpr of allCallExpressions) { + const funcNode = callExpr.field('function'); + if (!funcNode) continue; + + let calledFunctionName: string | null = null; + if (funcNode.is('identifier')) { + calledFunctionName = funcNode.text(); + } else if (funcNode.is('member_expression')) { + continue; + } + + if (calledFunctionName && customRenderFunctionsSet.has(calledFunctionName)) { + customRenderCalls.push(callExpr); + } + } + + return customRenderCalls; +} + +function findRNTLFunctionCallsInNode( + funcNode: SgNode, + importedFunctions: Set, +): SgNode[] { + const rntlCalls: SgNode[] = []; + + for (const funcName of importedFunctions) { + const calls = funcNode.findAll({ + rule: { + kind: 'call_expression', + has: { + field: 'function', + kind: 'identifier', + regex: `^${funcName}$`, + }, + }, + }); + rntlCalls.push(...calls); + } + + if (importedFunctions.has('fireEvent')) { + const fireEventMethodCalls = funcNode.findAll({ + rule: { + kind: 'call_expression', + has: { + field: 'function', + kind: 'member_expression', + }, + }, + }); + + for (const call of fireEventMethodCalls) { + const funcCallNode = call.field('function'); + if (funcCallNode && funcCallNode.is('member_expression')) { + try { + const object = funcCallNode.field('object'); + const property = funcCallNode.field('property'); + if (object && property) { + const objText = object.text(); + const propText = property.text(); + if (objText === 'fireEvent' && FIRE_EVENT_METHODS_TO_MAKE_ASYNC.has(propText)) { + rntlCalls.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 rntlCalls; +} + +function transformRNTLCallsInsideCustomRender( + funcNode: SgNode, + importedFunctions: Set, + edits: Edit[], + customRenderFunctionsToMakeAsync: Map>, + rootNode: SgNode, +): void { + const rntlCalls = findRNTLFunctionCallsInNode(funcNode, importedFunctions); + let needsAsync = false; + + for (const rntlCall of rntlCalls) { + const parent = rntlCall.parent(); + if (parent && parent.is('await_expression')) { + continue; + } + + const callStart = rntlCall.range().start.index; + edits.push({ + startPos: callStart, + endPos: callStart, + insertedText: 'await ', + }); + needsAsync = true; + } + + if (needsAsync && !customRenderFunctionsToMakeAsync.has(funcNode.id())) { + const isAsync = isFunctionAlreadyAsync(funcNode); + if (!isAsync) { + customRenderFunctionsToMakeAsync.set(funcNode.id(), funcNode); + } + } +} + +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/v14-async-functions/tests/fixtures/act-call/expected.tsx b/codemods/v14-async-functions/tests/fixtures/act-call/expected.tsx new file mode 100644 index 000000000..355501a8a --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/act-call/expected.tsx @@ -0,0 +1,8 @@ +import { act } from '@testing-library/react-native'; + +test('uses act', async () => { + await act(() => { + // Some state update + }); + expect(true).toBe(true); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/act-call/input.tsx b/codemods/v14-async-functions/tests/fixtures/act-call/input.tsx new file mode 100644 index 000000000..275dff85b --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/act-call/input.tsx @@ -0,0 +1,8 @@ +import { act } from '@testing-library/react-native'; + +test('uses act', () => { + act(() => { + // Some state update + }); + expect(true).toBe(true); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/aftereach-hook/expected.tsx b/codemods/v14-async-functions/tests/fixtures/aftereach-hook/expected.tsx new file mode 100644 index 000000000..d832cbc65 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/aftereach-hook/expected.tsx @@ -0,0 +1,10 @@ +import { render, cleanup } from '@testing-library/react-native'; + +afterEach(async () => { + cleanup(); + await render(); +}); + +test('test case', () => { + // Test code +}); diff --git a/codemods/v14-async-functions/tests/fixtures/aftereach-hook/input.tsx b/codemods/v14-async-functions/tests/fixtures/aftereach-hook/input.tsx new file mode 100644 index 000000000..2e372b078 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/aftereach-hook/input.tsx @@ -0,0 +1,10 @@ +import { render, cleanup } from '@testing-library/react-native'; + +afterEach(() => { + cleanup(); + render(); +}); + +test('test case', () => { + // Test code +}); diff --git a/codemods/v14-async-functions/tests/fixtures/already-async/expected.tsx b/codemods/v14-async-functions/tests/fixtures/already-async/expected.tsx new file mode 100644 index 000000000..c2caa6bef --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/already-async/expected.tsx @@ -0,0 +1,6 @@ +import { render, screen } from '@testing-library/react-native'; + +test('renders component', async () => { + await render(); + expect(screen.getByText('Hello')).toBeOnTheScreen(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/already-async/input.tsx b/codemods/v14-async-functions/tests/fixtures/already-async/input.tsx new file mode 100644 index 000000000..d1ac82bc2 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/already-async/input.tsx @@ -0,0 +1,6 @@ +import { render, screen } from '@testing-library/react-native'; + +test('renders component', async () => { + render(); + expect(screen.getByText('Hello')).toBeOnTheScreen(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/already-awaited/expected.tsx b/codemods/v14-async-functions/tests/fixtures/already-awaited/expected.tsx new file mode 100644 index 000000000..7aae57ba0 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/already-awaited/expected.tsx @@ -0,0 +1,5 @@ +import { render } from '@testing-library/react-native'; + +test('already awaited', async () => { + await render(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/already-awaited/input.tsx b/codemods/v14-async-functions/tests/fixtures/already-awaited/input.tsx new file mode 100644 index 000000000..7aae57ba0 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/already-awaited/input.tsx @@ -0,0 +1,5 @@ +import { render } from '@testing-library/react-native'; + +test('already awaited', async () => { + await render(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/async-variants-rename/expected.tsx b/codemods/v14-async-functions/tests/fixtures/async-variants-rename/expected.tsx new file mode 100644 index 000000000..3e3ef8656 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/async-variants-rename/expected.tsx @@ -0,0 +1,7 @@ +import { render, renderHook, fireEvent } from '@testing-library/react-native'; + +test('uses async variants', async () => { + const component = await render(); + const { result } = await renderHook(() => useMyHook()); + await fireEvent.press(component.getByText('Button')); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/async-variants-rename/input.tsx b/codemods/v14-async-functions/tests/fixtures/async-variants-rename/input.tsx new file mode 100644 index 000000000..42185672b --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/async-variants-rename/input.tsx @@ -0,0 +1,7 @@ +import { renderAsync, renderHookAsync, fireEventAsync } from '@testing-library/react-native'; + +test('uses async variants', async () => { + const component = await renderAsync(); + const { result } = await renderHookAsync(() => useMyHook()); + await fireEventAsync.press(component.getByText('Button')); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/basic-sync-test/expected.tsx b/codemods/v14-async-functions/tests/fixtures/basic-sync-test/expected.tsx new file mode 100644 index 000000000..c2caa6bef --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/basic-sync-test/expected.tsx @@ -0,0 +1,6 @@ +import { render, screen } from '@testing-library/react-native'; + +test('renders component', async () => { + await render(); + expect(screen.getByText('Hello')).toBeOnTheScreen(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/basic-sync-test/input.tsx b/codemods/v14-async-functions/tests/fixtures/basic-sync-test/input.tsx new file mode 100644 index 000000000..3e884dcfd --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/basic-sync-test/input.tsx @@ -0,0 +1,6 @@ +import { render, screen } from '@testing-library/react-native'; + +test('renders component', () => { + render(); + expect(screen.getByText('Hello')).toBeOnTheScreen(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/beforeeach-hook/expected.tsx b/codemods/v14-async-functions/tests/fixtures/beforeeach-hook/expected.tsx new file mode 100644 index 000000000..4bd6d0405 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/beforeeach-hook/expected.tsx @@ -0,0 +1,9 @@ +import { render } from '@testing-library/react-native'; + +beforeEach(async () => { + await render(); +}); + +test('test case', () => { + // Test code +}); diff --git a/codemods/v14-async-functions/tests/fixtures/beforeeach-hook/input.tsx b/codemods/v14-async-functions/tests/fixtures/beforeeach-hook/input.tsx new file mode 100644 index 000000000..53b4e467f --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/beforeeach-hook/input.tsx @@ -0,0 +1,9 @@ +import { render } from '@testing-library/react-native'; + +beforeEach(() => { + render(); +}); + +test('test case', () => { + // Test code +}); diff --git a/codemods/v14-async-functions/tests/fixtures/both-renderhook-renderer/expected.tsx b/codemods/v14-async-functions/tests/fixtures/both-renderhook-renderer/expected.tsx new file mode 100644 index 000000000..675e36472 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/both-renderhook-renderer/expected.tsx @@ -0,0 +1,9 @@ +import { render, renderHook } from '@testing-library/react-native'; + +test('uses both render and renderHook with rerender', async () => { + const renderer = await render(); + const { rerender: rerenderHook } = await renderHook(() => ({ value: 42 })); + + await renderer.rerender(); + await rerenderHook({ value: 43 }); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/both-renderhook-renderer/input.tsx b/codemods/v14-async-functions/tests/fixtures/both-renderhook-renderer/input.tsx new file mode 100644 index 000000000..f73f47017 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/both-renderhook-renderer/input.tsx @@ -0,0 +1,9 @@ +import { render, renderHook } from '@testing-library/react-native'; + +test('uses both render and renderHook with rerender', () => { + const renderer = render(); + const { rerender: rerenderHook } = renderHook(() => ({ value: 42 })); + + renderer.rerender(); + rerenderHook({ value: 43 }); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/combined-functions/expected.tsx b/codemods/v14-async-functions/tests/fixtures/combined-functions/expected.tsx new file mode 100644 index 000000000..3d06284e4 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/combined-functions/expected.tsx @@ -0,0 +1,16 @@ +import { render, act, renderHook, screen } from '@testing-library/react-native'; + +test('uses all three functions', async () => { + await render(); + + await act(() => { + // Some state update + }); + + const { result } = await renderHook(() => { + return { value: 42 }; + }); + + expect(screen.getByText('Hello')).toBeOnTheScreen(); + expect(result.current.value).toBe(42); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/combined-functions/input.tsx b/codemods/v14-async-functions/tests/fixtures/combined-functions/input.tsx new file mode 100644 index 000000000..f0c9e8336 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/combined-functions/input.tsx @@ -0,0 +1,16 @@ +import { render, act, renderHook, screen } from '@testing-library/react-native'; + +test('uses all three functions', () => { + render(); + + act(() => { + // Some state update + }); + + const { result } = renderHook(() => { + return { value: 42 }; + }); + + expect(screen.getByText('Hello')).toBeOnTheScreen(); + expect(result.current.value).toBe(42); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/custom-render-function/expected.tsx b/codemods/v14-async-functions/tests/fixtures/custom-render-function/expected.tsx new file mode 100644 index 000000000..65a8b5ba4 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/custom-render-function/expected.tsx @@ -0,0 +1,28 @@ +import { render } from '@testing-library/react-native'; + +// Function declaration +async function renderWithProviders(component: React.ReactElement) { + await render(component); +} + +// Arrow function +const renderWithTheme = async (component: React.ReactElement) => { + await render(component); +}; + +// Function expression +const renderCustom = async function (component: React.ReactElement) { + await render(component); +}; + +test('uses custom render function declaration', async () => { + await renderWithProviders(); +}); + +test('uses custom render arrow function', async () => { + await renderWithTheme(); +}); + +test('uses custom render function expression', async () => { + await renderCustom(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/custom-render-function/input.tsx b/codemods/v14-async-functions/tests/fixtures/custom-render-function/input.tsx new file mode 100644 index 000000000..f06786edc --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/custom-render-function/input.tsx @@ -0,0 +1,28 @@ +import { render } from '@testing-library/react-native'; + +// Function declaration +function renderWithProviders(component: React.ReactElement) { + render(component); +} + +// Arrow function +const renderWithTheme = (component: React.ReactElement) => { + render(component); +}; + +// Function expression +const renderCustom = function (component: React.ReactElement) { + render(component); +}; + +test('uses custom render function declaration', () => { + renderWithProviders(); +}); + +test('uses custom render arrow function', () => { + renderWithTheme(); +}); + +test('uses custom render function expression', () => { + renderCustom(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/describe-block/expected.tsx b/codemods/v14-async-functions/tests/fixtures/describe-block/expected.tsx new file mode 100644 index 000000000..bfd1cad6e --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/describe-block/expected.tsx @@ -0,0 +1,18 @@ +import { render, screen } from '@testing-library/react-native'; + +describe('MyComponent', () => { + // Helper function inside describe block + function setupComponent() { + render(); + } + + test('renders component', () => { + setupComponent(); + expect(screen.getByText('Hello')).toBeOnTheScreen(); + }); + + test('renders with direct render call', async () => { + await render(); + expect(screen.getByText('Hello')).toBeOnTheScreen(); + }); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/describe-block/input.tsx b/codemods/v14-async-functions/tests/fixtures/describe-block/input.tsx new file mode 100644 index 000000000..1927278cd --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/describe-block/input.tsx @@ -0,0 +1,18 @@ +import { render, screen } from '@testing-library/react-native'; + +describe('MyComponent', () => { + // Helper function inside describe block + function setupComponent() { + render(); + } + + test('renders component', () => { + setupComponent(); + expect(screen.getByText('Hello')).toBeOnTheScreen(); + }); + + test('renders with direct render call', () => { + render(); + expect(screen.getByText('Hello')).toBeOnTheScreen(); + }); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/duplicate-imports/expected.tsx b/codemods/v14-async-functions/tests/fixtures/duplicate-imports/expected.tsx new file mode 100644 index 000000000..6279bef29 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/duplicate-imports/expected.tsx @@ -0,0 +1,21 @@ +import { + render, + renderHook, + fireEvent, + waitFor, +} from '@testing-library/react-native'; + +test('uses both sync and async variants', async () => { + const component1 = await render(); + const component2 = await render(); + + const { result: result1 } = await renderHook(() => useMyHook()); + const { result: result2 } = await renderHook(() => useMyHook()); + + await fireEvent.press(component1.getByText('Button')); + await fireEvent.press(component2.getByText('Button')); + + await waitFor(() => { + expect(result1.current.value).toBe(42); + }); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/duplicate-imports/input.tsx b/codemods/v14-async-functions/tests/fixtures/duplicate-imports/input.tsx new file mode 100644 index 000000000..1d2ac69e8 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/duplicate-imports/input.tsx @@ -0,0 +1,24 @@ +import { + render, + renderAsync, + renderHook, + renderHookAsync, + fireEvent, + fireEventAsync, + waitFor, +} from '@testing-library/react-native'; + +test('uses both sync and async variants', async () => { + const component1 = await renderAsync(); + const component2 = await render(); + + const { result: result1 } = await renderHookAsync(() => useMyHook()); + const { result: result2 } = await renderHook(() => useMyHook()); + + await fireEventAsync.press(component1.getByText('Button')); + await fireEvent.press(component2.getByText('Button')); + + await waitFor(() => { + expect(result1.current.value).toBe(42); + }); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/fireevent-call/expected.tsx b/codemods/v14-async-functions/tests/fixtures/fireevent-call/expected.tsx new file mode 100644 index 000000000..5547a8fa9 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/fireevent-call/expected.tsx @@ -0,0 +1,8 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; + +test('uses fireEvent', async () => { + await render(); + const button = screen.getByRole('button'); + await fireEvent(button, 'press'); + expect(screen.getByText('Clicked')).toBeOnTheScreen(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/fireevent-call/input.tsx b/codemods/v14-async-functions/tests/fixtures/fireevent-call/input.tsx new file mode 100644 index 000000000..601621ba7 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/fireevent-call/input.tsx @@ -0,0 +1,8 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; + +test('uses fireEvent', () => { + render(); + const button = screen.getByRole('button'); + fireEvent(button, 'press'); + expect(screen.getByText('Clicked')).toBeOnTheScreen(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/fireevent-methods/expected.tsx b/codemods/v14-async-functions/tests/fixtures/fireevent-methods/expected.tsx new file mode 100644 index 000000000..fcac36ec7 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/fireevent-methods/expected.tsx @@ -0,0 +1,14 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; + +test('uses fireEvent methods', async () => { + await render(); + const input = screen.getByPlaceholderText('Enter text'); + const button = screen.getByRole('button'); + const scrollView = screen.getByTestId('scroll-view'); + + await fireEvent.press(button); + await fireEvent.changeText(input, 'Hello'); + await fireEvent.scroll(scrollView, { nativeEvent: { contentOffset: { y: 100 } } }); + + expect(screen.getByText('Hello')).toBeOnTheScreen(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/fireevent-methods/input.tsx b/codemods/v14-async-functions/tests/fixtures/fireevent-methods/input.tsx new file mode 100644 index 000000000..6f9307ad1 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/fireevent-methods/input.tsx @@ -0,0 +1,14 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; + +test('uses fireEvent methods', () => { + render(); + const input = screen.getByPlaceholderText('Enter text'); + const button = screen.getByRole('button'); + const scrollView = screen.getByTestId('scroll-view'); + + fireEvent.press(button); + fireEvent.changeText(input, 'Hello'); + fireEvent.scroll(scrollView, { nativeEvent: { contentOffset: { y: 100 } } }); + + expect(screen.getByText('Hello')).toBeOnTheScreen(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/function-declaration/expected.tsx b/codemods/v14-async-functions/tests/fixtures/function-declaration/expected.tsx new file mode 100644 index 000000000..cbef18280 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/function-declaration/expected.tsx @@ -0,0 +1,5 @@ +import { render } from '@testing-library/react-native'; + +test('function declaration', async function () { + await render(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/function-declaration/input.tsx b/codemods/v14-async-functions/tests/fixtures/function-declaration/input.tsx new file mode 100644 index 000000000..9e0fc2ac6 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/function-declaration/input.tsx @@ -0,0 +1,5 @@ +import { render } from '@testing-library/react-native'; + +test('function declaration', function () { + render(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/helper-function/expected.tsx b/codemods/v14-async-functions/tests/fixtures/helper-function/expected.tsx new file mode 100644 index 000000000..a50e1dd69 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/helper-function/expected.tsx @@ -0,0 +1,9 @@ +import { render } from '@testing-library/react-native'; + +function renderWithProviders(component: React.ReactElement) { + render(component); +} + +test('uses helper', () => { + renderWithProviders(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/helper-function/input.tsx b/codemods/v14-async-functions/tests/fixtures/helper-function/input.tsx new file mode 100644 index 000000000..a50e1dd69 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/helper-function/input.tsx @@ -0,0 +1,9 @@ +import { render } from '@testing-library/react-native'; + +function renderWithProviders(component: React.ReactElement) { + render(component); +} + +test('uses helper', () => { + renderWithProviders(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/it-instead-of-test/expected.tsx b/codemods/v14-async-functions/tests/fixtures/it-instead-of-test/expected.tsx new file mode 100644 index 000000000..038609e80 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/it-instead-of-test/expected.tsx @@ -0,0 +1,5 @@ +import { render } from '@testing-library/react-native'; + +it('should render', async () => { + await render(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/it-instead-of-test/input.tsx b/codemods/v14-async-functions/tests/fixtures/it-instead-of-test/input.tsx new file mode 100644 index 000000000..4ada3736f --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/it-instead-of-test/input.tsx @@ -0,0 +1,5 @@ +import { render } from '@testing-library/react-native'; + +it('should render', () => { + render(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/multiple-renders/expected.tsx b/codemods/v14-async-functions/tests/fixtures/multiple-renders/expected.tsx new file mode 100644 index 000000000..22a8d029c --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/multiple-renders/expected.tsx @@ -0,0 +1,6 @@ +import { render } from '@testing-library/react-native'; + +test('renders multiple', async () => { + await render(); + await render(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/multiple-renders/input.tsx b/codemods/v14-async-functions/tests/fixtures/multiple-renders/input.tsx new file mode 100644 index 000000000..68cf577fb --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/multiple-renders/input.tsx @@ -0,0 +1,6 @@ +import { render } from '@testing-library/react-native'; + +test('renders multiple', () => { + render(); + render(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/no-rntl-import/expected.tsx b/codemods/v14-async-functions/tests/fixtures/no-rntl-import/expected.tsx new file mode 100644 index 000000000..4807101a0 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/no-rntl-import/expected.tsx @@ -0,0 +1,5 @@ +import { render } from 'some-other-library'; + +test('should not transform', () => { + render(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/no-rntl-import/input.tsx b/codemods/v14-async-functions/tests/fixtures/no-rntl-import/input.tsx new file mode 100644 index 000000000..4807101a0 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/no-rntl-import/input.tsx @@ -0,0 +1,5 @@ +import { render } from 'some-other-library'; + +test('should not transform', () => { + render(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/render-with-options/expected.tsx b/codemods/v14-async-functions/tests/fixtures/render-with-options/expected.tsx new file mode 100644 index 000000000..54d3c48e9 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/render-with-options/expected.tsx @@ -0,0 +1,5 @@ +import { render } from '@testing-library/react-native'; + +test('renders with wrapper', async () => { + await render(, { wrapper: Wrapper }); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/render-with-options/input.tsx b/codemods/v14-async-functions/tests/fixtures/render-with-options/input.tsx new file mode 100644 index 000000000..8272061c6 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/render-with-options/input.tsx @@ -0,0 +1,5 @@ +import { render } from '@testing-library/react-native'; + +test('renders with wrapper', () => { + render(, { wrapper: Wrapper }); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/renderer-rerender/expected.tsx b/codemods/v14-async-functions/tests/fixtures/renderer-rerender/expected.tsx new file mode 100644 index 000000000..c99cadc4a --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/renderer-rerender/expected.tsx @@ -0,0 +1,7 @@ +import { render } from '@testing-library/react-native'; + +test('rerenders with renderer', async () => { + const renderer = await render(); + await renderer.rerender(); + expect(renderer.getByText('Updated')).toBeOnTheScreen(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/renderer-rerender/input.tsx b/codemods/v14-async-functions/tests/fixtures/renderer-rerender/input.tsx new file mode 100644 index 000000000..146134dd8 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/renderer-rerender/input.tsx @@ -0,0 +1,7 @@ +import { render } from '@testing-library/react-native'; + +test('rerenders with renderer', () => { + const renderer = render(); + renderer.rerender(); + expect(renderer.getByText('Updated')).toBeOnTheScreen(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/renderer-unmount/expected.tsx b/codemods/v14-async-functions/tests/fixtures/renderer-unmount/expected.tsx new file mode 100644 index 000000000..471c2a473 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/renderer-unmount/expected.tsx @@ -0,0 +1,7 @@ +import { render } from '@testing-library/react-native'; + +test('unmounts with renderer', async () => { + const { rerender, unmount } = await render(); + await rerender(); + await unmount(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/renderer-unmount/input.tsx b/codemods/v14-async-functions/tests/fixtures/renderer-unmount/input.tsx new file mode 100644 index 000000000..e9d8e3691 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/renderer-unmount/input.tsx @@ -0,0 +1,7 @@ +import { render } from '@testing-library/react-native'; + +test('unmounts with renderer', () => { + const { rerender, unmount } = render(); + rerender(); + unmount(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/renderhook-call/expected.tsx b/codemods/v14-async-functions/tests/fixtures/renderhook-call/expected.tsx new file mode 100644 index 000000000..4146596eb --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/renderhook-call/expected.tsx @@ -0,0 +1,8 @@ +import { renderHook } from '@testing-library/react-native'; + +test('uses renderHook', async () => { + const { result } = await renderHook(() => { + return { value: 42 }; + }); + expect(result.current.value).toBe(42); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/renderhook-call/input.tsx b/codemods/v14-async-functions/tests/fixtures/renderhook-call/input.tsx new file mode 100644 index 000000000..04b2c9f38 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/renderhook-call/input.tsx @@ -0,0 +1,8 @@ +import { renderHook } from '@testing-library/react-native'; + +test('uses renderHook', () => { + const { result } = renderHook(() => { + return { value: 42 }; + }); + expect(result.current.value).toBe(42); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/renderhook-destructured/expected.tsx b/codemods/v14-async-functions/tests/fixtures/renderhook-destructured/expected.tsx new file mode 100644 index 000000000..1b58a2268 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/renderhook-destructured/expected.tsx @@ -0,0 +1,8 @@ +import { renderHook } from '@testing-library/react-native'; + +test('uses destructured rerender and unmount from renderHook', async () => { + const { rerender, unmount, result } = await renderHook(() => ({ value: 42 })); + await rerender({ value: 43 }); + expect(result.current.value).toBe(43); + await unmount(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/renderhook-destructured/input.tsx b/codemods/v14-async-functions/tests/fixtures/renderhook-destructured/input.tsx new file mode 100644 index 000000000..f7f252c5b --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/renderhook-destructured/input.tsx @@ -0,0 +1,8 @@ +import { renderHook } from '@testing-library/react-native'; + +test('uses destructured rerender and unmount from renderHook', () => { + const { rerender, unmount, result } = renderHook(() => ({ value: 42 })); + rerender({ value: 43 }); + expect(result.current.value).toBe(43); + unmount(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/renderhook-rerender/expected.tsx b/codemods/v14-async-functions/tests/fixtures/renderhook-rerender/expected.tsx new file mode 100644 index 000000000..5c5fcbc04 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/renderhook-rerender/expected.tsx @@ -0,0 +1,7 @@ +import { renderHook } from '@testing-library/react-native'; + +test('rerenders with renderHook result', async () => { + const hookResult = await renderHook(() => ({ value: 42 })); + await hookResult.rerender({ value: 43 }); + expect(hookResult.result.current.value).toBe(43); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/renderhook-rerender/input.tsx b/codemods/v14-async-functions/tests/fixtures/renderhook-rerender/input.tsx new file mode 100644 index 000000000..dc383069c --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/renderhook-rerender/input.tsx @@ -0,0 +1,7 @@ +import { renderHook } from '@testing-library/react-native'; + +test('rerenders with renderHook result', () => { + const hookResult = renderHook(() => ({ value: 42 })); + hookResult.rerender({ value: 43 }); + expect(hookResult.result.current.value).toBe(43); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/renderhook-unmount/expected.tsx b/codemods/v14-async-functions/tests/fixtures/renderhook-unmount/expected.tsx new file mode 100644 index 000000000..2b74a6e6b --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/renderhook-unmount/expected.tsx @@ -0,0 +1,6 @@ +import { renderHook } from '@testing-library/react-native'; + +test('unmounts with renderHook result', async () => { + const hookResult = await renderHook(() => ({ value: 42 })); + await hookResult.unmount(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/renderhook-unmount/input.tsx b/codemods/v14-async-functions/tests/fixtures/renderhook-unmount/input.tsx new file mode 100644 index 000000000..3c7f8fd34 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/renderhook-unmount/input.tsx @@ -0,0 +1,6 @@ +import { renderHook } from '@testing-library/react-native'; + +test('unmounts with renderHook result', () => { + const hookResult = renderHook(() => ({ value: 42 })); + hookResult.unmount(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/screen-rerender/expected.tsx b/codemods/v14-async-functions/tests/fixtures/screen-rerender/expected.tsx new file mode 100644 index 000000000..223fce531 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/screen-rerender/expected.tsx @@ -0,0 +1,7 @@ +import { render, screen } from '@testing-library/react-native'; + +test('rerenders component', async () => { + await render(); + await screen.rerender(); + expect(screen.getByText('Updated')).toBeOnTheScreen(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/screen-rerender/input.tsx b/codemods/v14-async-functions/tests/fixtures/screen-rerender/input.tsx new file mode 100644 index 000000000..d481d9d11 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/screen-rerender/input.tsx @@ -0,0 +1,7 @@ +import { render, screen } from '@testing-library/react-native'; + +test('rerenders component', () => { + render(); + screen.rerender(); + expect(screen.getByText('Updated')).toBeOnTheScreen(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/screen-unmount/expected.tsx b/codemods/v14-async-functions/tests/fixtures/screen-unmount/expected.tsx new file mode 100644 index 000000000..1c27fd41a --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/screen-unmount/expected.tsx @@ -0,0 +1,7 @@ +import { render, screen } from '@testing-library/react-native'; + +test('unmounts component', async () => { + await render(); + await screen.unmount(); + expect(screen.queryByText('Hello')).not.toBeOnTheScreen(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/screen-unmount/input.tsx b/codemods/v14-async-functions/tests/fixtures/screen-unmount/input.tsx new file mode 100644 index 000000000..2114aa444 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/screen-unmount/input.tsx @@ -0,0 +1,7 @@ +import { render, screen } from '@testing-library/react-native'; + +test('unmounts component', () => { + render(); + screen.unmount(); + expect(screen.queryByText('Hello')).not.toBeOnTheScreen(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/skip-variants/expected.tsx b/codemods/v14-async-functions/tests/fixtures/skip-variants/expected.tsx new file mode 100644 index 000000000..d5f15efa2 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/skip-variants/expected.tsx @@ -0,0 +1,25 @@ +import { + render, + act, + renderHook, + unsafe_act, + unsafe_renderHookSync, + } from '@testing-library/react-native'; + +test('skips unsafe variants', async () => { + await render(); + + await act(() => { + // Should be transformed + }); + + unsafe_act(() => { + // Should NOT be transformed + }); + + const { result } = await renderHook(() => ({ value: 42 })); + + unsafe_renderHookSync(() => ({ value: 43 })); + + await render(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/skip-variants/input.tsx b/codemods/v14-async-functions/tests/fixtures/skip-variants/input.tsx new file mode 100644 index 000000000..d44c68977 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/skip-variants/input.tsx @@ -0,0 +1,26 @@ +import { + render, + act, + renderHook, + unsafe_act, + unsafe_renderHookSync, + renderAsync, +} from '@testing-library/react-native'; + +test('skips unsafe variants', async () => { + render(); + + act(() => { + // Should be transformed + }); + + unsafe_act(() => { + // Should NOT be transformed + }); + + const { result } = renderHook(() => ({ value: 42 })); + + unsafe_renderHookSync(() => ({ value: 43 })); + + await renderAsync(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/test-each-combined/expected.tsx b/codemods/v14-async-functions/tests/fixtures/test-each-combined/expected.tsx new file mode 100644 index 000000000..9f570c288 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/test-each-combined/expected.tsx @@ -0,0 +1,12 @@ +import { render, renderHook, act } from '@testing-library/react-native'; + +test.each([{ value: 1 }, { value: 2 }])('renders component with value $value', async ({ value }) => { + await render(); +}); + +it.each([[true], [false]])('renders hook with flag %p', async (flag) => { + const { result } = await renderHook(() => ({ flag })); + await act(() => { + // update + }); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/test-each-combined/input.tsx b/codemods/v14-async-functions/tests/fixtures/test-each-combined/input.tsx new file mode 100644 index 000000000..ae66107df --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/test-each-combined/input.tsx @@ -0,0 +1,12 @@ +import { render, renderHook, act } from '@testing-library/react-native'; + +test.each([{ value: 1 }, { value: 2 }])('renders component with value $value', ({ value }) => { + render(); +}); + +it.each([[true], [false]])('renders hook with flag %p', async (flag) => { + const { result } = renderHook(() => ({ flag })); + act(() => { + // update + }); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/test-each/expected.tsx b/codemods/v14-async-functions/tests/fixtures/test-each/expected.tsx new file mode 100644 index 000000000..1f57c8d4b --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/test-each/expected.tsx @@ -0,0 +1,12 @@ +import { render } from '@testing-library/react-native'; + +test.each([{ name: 'Alice' }, { name: 'Bob' }])('renders for $name', async ({ name }) => { + await render(); +}); + +it.each([ + [1, 2, 3], + [4, 5, 9], +])('adds %i and %i to get %i', async (a, b, expected) => { + await render(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/test-each/input.tsx b/codemods/v14-async-functions/tests/fixtures/test-each/input.tsx new file mode 100644 index 000000000..a69f07efc --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/test-each/input.tsx @@ -0,0 +1,12 @@ +import { render } from '@testing-library/react-native'; + +test.each([{ name: 'Alice' }, { name: 'Bob' }])('renders for $name', ({ name }) => { + render(); +}); + +it.each([ + [1, 2, 3], + [4, 5, 9], +])('adds %i and %i to get %i', (a, b, expected) => { + render(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/test-only/expected.tsx b/codemods/v14-async-functions/tests/fixtures/test-only/expected.tsx new file mode 100644 index 000000000..0ee9a2ac2 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/test-only/expected.tsx @@ -0,0 +1,9 @@ +import { render } from '@testing-library/react-native'; + +test.only('only this test', async () => { + await render(); +}); + +it.only('only this it', async () => { + await render(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/test-only/input.tsx b/codemods/v14-async-functions/tests/fixtures/test-only/input.tsx new file mode 100644 index 000000000..2a45ddf63 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/test-only/input.tsx @@ -0,0 +1,9 @@ +import { render } from '@testing-library/react-native'; + +test.only('only this test', () => { + render(); +}); + +it.only('only this it', () => { + render(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/test-skip/expected.tsx b/codemods/v14-async-functions/tests/fixtures/test-skip/expected.tsx new file mode 100644 index 000000000..d8234abc3 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/test-skip/expected.tsx @@ -0,0 +1,5 @@ +import { render } from '@testing-library/react-native'; + +test.skip('skipped test', async () => { + await render(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/test-skip/input.tsx b/codemods/v14-async-functions/tests/fixtures/test-skip/input.tsx new file mode 100644 index 000000000..33d933fc2 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/test-skip/input.tsx @@ -0,0 +1,5 @@ +import { render } from '@testing-library/react-native'; + +test.skip('skipped test', () => { + render(); +}); diff --git a/codemods/v14-async-functions/tsconfig.json b/codemods/v14-async-functions/tsconfig.json new file mode 100644 index 000000000..469fc5acf --- /dev/null +++ b/codemods/v14-async-functions/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["@codemod.com/jssg-types"], + "allowImportingTsExtensions": true, + "noEmit": true, + "verbatimModuleSyntax": true, + "erasableSyntaxOnly": true, + "strict": true, + "strictNullChecks": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true + }, + "exclude": ["tests"] +} diff --git a/codemods/v14-async-functions/workflow.yaml b/codemods/v14-async-functions/workflow.yaml new file mode 100644 index 000000000..62d083f52 --- /dev/null +++ b/codemods/v14-async-functions/workflow.yaml @@ -0,0 +1,24 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/codemod/codemod/refs/heads/main/schemas/workflow.json + +version: '1' + +nodes: + - id: apply-transforms + name: Apply AST Transformations + type: automatic + steps: + - name: 'Transform render() calls to await render()' + js-ast-grep: + js_file: scripts/codemod.ts + language: 'tsx' + include: + - '**/*.ts' + - '**/*.tsx' + - '**/*.js' + - '**/*.jsx' + exclude: + - '**/node_modules/**' + - '**/build/**' + - '**/dist/**' + - '**/.next/**' + - '**/coverage/**' diff --git a/codemods/v14-update-deps/.gitignore b/codemods/v14-update-deps/.gitignore new file mode 100644 index 000000000..7588598f8 --- /dev/null +++ b/codemods/v14-update-deps/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +*.log +.DS_Store +tests/fixtures/*/temp.json diff --git a/codemods/v14-update-deps/README.md b/codemods/v14-update-deps/README.md new file mode 100644 index 000000000..4600a0180 --- /dev/null +++ b/codemods/v14-update-deps/README.md @@ -0,0 +1,64 @@ +# RNTL v14: Update Dependencies + +This codemod automatically updates your `package.json` to prepare for React Native Testing Library v14 migration. + +## What it does + +- Removes `@types/react-test-renderer` and `react-test-renderer` (no longer needed) +- Moves `@testing-library/react-native` to `devDependencies` if it's in `dependencies` +- Updates `@testing-library/react-native` to `^14.0.0-alpha.5` +- Adds `universal-test-renderer@0.10.1` to `devDependencies` + +## Usage + +```bash +# Run the codemod +npx codemod@latest run rntl-v14-update-deps --target ./path/to/your/project +``` + +## Example + +**Before:** + +```json +{ + "dependencies": { + "@testing-library/react-native": "^13.0.0" + }, + "devDependencies": { + "@types/react-test-renderer": "^18.0.0", + "react-test-renderer": "^18.0.0" + } +} +``` + +**After:** + +```json +{ + "devDependencies": { + "@testing-library/react-native": "^14.0.0-alpha.5", + "universal-test-renderer": "0.10.1" + } +} +``` + +## Important notes + +- **After running the codemod**, you must run your package manager to install dependencies: + ```bash + npm install + # or yarn install / pnpm install + ``` +- The codemod sets the version to `^14.0.0-alpha.5`. You can manually update this if needed. +- For monorepos, the codemod processes each `package.json` file individually. + +## Next steps + +1. Run this codemod to update dependencies +2. Run `npm install` (or your package manager) to install the new dependencies +3. Run the async-functions codemod to update your test code: + ```bash + npx codemod@latest run rntl-v14-async-functions --target ./path/to/your/tests + ``` +4. Review and test your changes diff --git a/codemods/v14-update-deps/codemod.yaml b/codemods/v14-update-deps/codemod.yaml new file mode 100644 index 000000000..62020da51 --- /dev/null +++ b/codemods/v14-update-deps/codemod.yaml @@ -0,0 +1,19 @@ +schema_version: '1.0' + +name: 'rntl-v14-update-deps' +version: '0.1.0' +description: 'Codemod to update dependencies for RNTL v14 migration' +author: 'Maciej Jastrzebski' +license: 'MIT' +workflow: 'workflow.yaml' + +targets: + languages: ['json'] + +keywords: ['transformation', 'migration', 'dependencies'] + +registry: + access: 'public' + visibility: 'public' + +capabilities: [] diff --git a/codemods/v14-update-deps/package.json b/codemods/v14-update-deps/package.json new file mode 100644 index 000000000..a187527d9 --- /dev/null +++ b/codemods/v14-update-deps/package.json @@ -0,0 +1,11 @@ +{ + "name": "@testing-library/react-native-v14-update-deps", + "version": "0.1.0", + "description": "Codemod to update dependencies for RNTL v14 migration", + "type": "module", + "scripts": { + "test": "node --loader tsx/esm scripts/test.js" + }, + "devDependencies": {}, + "dependencies": {} +} diff --git a/codemods/v14-update-deps/scripts/codemod.ts b/codemods/v14-update-deps/scripts/codemod.ts new file mode 100644 index 000000000..e9c859333 --- /dev/null +++ b/codemods/v14-update-deps/scripts/codemod.ts @@ -0,0 +1,150 @@ +#!/usr/bin/env node + +import type { Transform } from 'codemod:ast-grep'; +import type JSONLang from 'codemod:ast-grep/langs/json'; + +const RNTL_VERSION = '^14.0.0-alpha.5'; +const UNIVERSAL_TEST_RENDERER_VERSION = '0.10.1'; + +interface PackageJson { + dependencies?: Record; + devDependencies?: Record; + peerDependencies?: Record; + optionalDependencies?: Record; + [key: string]: unknown; +} + +export default async function transform( + root: Parameters>[0], +): Promise { + const filename = root.filename(); + + if (!isPackageJsonFile(filename)) { + return null; + } + + try { + const content = root.root().text(); + const packageJson: PackageJson = JSON.parse(content); + + if (!hasRNTLOrUTR(packageJson)) { + return null; + } + + let hasChanges = false; + + if (removeObsoletePackages(packageJson)) { + hasChanges = true; + } + + if (ensureDevDependenciesObjectExists(packageJson)) { + hasChanges = true; + } + + if (ensureRNTLInDevDependencies(packageJson)) { + hasChanges = true; + } + + if (updateUTRVersionInDevDependencies(packageJson)) { + hasChanges = true; + } + + if (hasChanges) { + return JSON.stringify(packageJson, null, 2) + '\n'; + } + + return null; + } catch (error) { + // Re-throw error to let the codemod platform handle it + // This provides better error reporting than silently returning null + throw new Error( + `Error processing ${filename}: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +function isPackageJsonFile(filename: string): boolean { + return filename.endsWith('package.json'); +} + +function hasRNTLOrUTR(packageJson: PackageJson): boolean { + const hasRNTL = + packageJson.dependencies?.['@testing-library/react-native'] || + packageJson.devDependencies?.['@testing-library/react-native'] || + packageJson.peerDependencies?.['@testing-library/react-native']; + + const hasUTR = + packageJson.dependencies?.['universal-test-renderer'] || + packageJson.devDependencies?.['universal-test-renderer'] || + packageJson.peerDependencies?.['universal-test-renderer']; + + return hasRNTL || hasUTR; +} + +function removePackageFromAllDependencyTypes(pkgName: string, packageJson: PackageJson): boolean { + let removed = false; + ( + ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'] as const + ).forEach((depType) => { + if (packageJson[depType]?.[pkgName]) { + delete packageJson[depType]![pkgName]; + removed = true; + if (Object.keys(packageJson[depType]!).length === 0) { + delete packageJson[depType]; + } + } + }); + return removed; +} + +function removeEmptyDependencyObject(packageJson: PackageJson, depType: keyof PackageJson): void { + const deps = packageJson[depType]; + if (deps && typeof deps === 'object' && !Array.isArray(deps) && !Object.keys(deps).length) { + delete packageJson[depType]; + } +} + +function ensureDevDependenciesObjectExists(packageJson: PackageJson): boolean { + if (!packageJson.devDependencies) { + packageJson.devDependencies = {}; + return true; + } + return false; +} + +function removeObsoletePackages(packageJson: PackageJson): boolean { + const removedTypes = removePackageFromAllDependencyTypes( + '@types/react-test-renderer', + packageJson, + ); + const removedRenderer = removePackageFromAllDependencyTypes('react-test-renderer', packageJson); + return removedTypes || removedRenderer; +} + +function ensureRNTLInDevDependencies(packageJson: PackageJson): boolean { + let hasChanges = false; + const rntlInDeps = packageJson.dependencies?.['@testing-library/react-native']; + + if (rntlInDeps) { + delete packageJson.dependencies!['@testing-library/react-native']; + removeEmptyDependencyObject(packageJson, 'dependencies'); + hasChanges = true; + } + + const currentVersion = packageJson.devDependencies?.['@testing-library/react-native']; + if (currentVersion !== RNTL_VERSION) { + packageJson.devDependencies!['@testing-library/react-native'] = RNTL_VERSION; + hasChanges = true; + } + + return hasChanges; +} + +function updateUTRVersionInDevDependencies(packageJson: PackageJson): boolean { + const currentVersion = packageJson.devDependencies?.['universal-test-renderer']; + if (currentVersion !== UNIVERSAL_TEST_RENDERER_VERSION) { + packageJson.devDependencies!['universal-test-renderer'] = UNIVERSAL_TEST_RENDERER_VERSION; + return true; + } + return false; +} diff --git a/codemods/v14-update-deps/scripts/test.js b/codemods/v14-update-deps/scripts/test.js new file mode 100755 index 000000000..373e056d4 --- /dev/null +++ b/codemods/v14-update-deps/scripts/test.js @@ -0,0 +1,95 @@ +#!/usr/bin/env node + +/** + * Test script for the package.json update codemod + * Note: This test script uses console.log for test output, which is acceptable for test scripts + */ + +/* eslint-disable no-console */ +import { readFileSync, writeFileSync, existsSync, readdirSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const fixturesDir = join(__dirname, '..', 'tests', 'fixtures'); + +// Import the codemod logic +async function runCodemod(filePath) { + const { readFileSync } = await import('fs'); + const { default: transform } = await import('./codemod.ts'); + + // Mock the codemod platform root object + const packageJsonContent = readFileSync(filePath, 'utf8'); + const mockRoot = { + filename: () => filePath, + root: () => ({ + text: () => packageJsonContent, + }), + }; + + const result = await transform(mockRoot); + // Return result or original content if null (no changes) + return result || packageJsonContent; +} + +// Test each fixture +const testCases = readdirSync(fixturesDir); + +let passed = 0; +let failed = 0; + +for (const testCase of testCases) { + const inputPath = join(fixturesDir, testCase, 'input.json'); + const expectedPath = join(fixturesDir, testCase, 'expected.json'); + + if (!existsSync(inputPath) || !existsSync(expectedPath)) { + console.log(`⚠️ ${testCase}: Missing input or expected file`); + continue; + } + + try { + const inputContent = readFileSync(inputPath, 'utf8'); + const expectedContent = readFileSync(expectedPath, 'utf8'); + + // Create a temporary file to test (must be named package.json for codemod to process it) + const tempPath = join(fixturesDir, testCase, 'package.json'); + writeFileSync(tempPath, inputContent, 'utf8'); + + // Run codemod + const result = await runCodemod(tempPath); + + // Handle null result (no changes or skipped) + const resultContent = result || inputContent; + + // Compare results + const expectedJson = JSON.parse(expectedContent); + const resultJson = JSON.parse(resultContent); + + if (JSON.stringify(expectedJson, null, 2) === JSON.stringify(resultJson, null, 2)) { + console.log(`✅ ${testCase}: PASSED`); + passed++; + } else { + console.log(`❌ ${testCase}: FAILED`); + console.log('Expected:'); + console.log(JSON.stringify(expectedJson, null, 2)); + console.log('Got:'); + console.log(JSON.stringify(resultJson, null, 2)); + failed++; + } + + // Clean up temp file + const tempFilePath = join(fixturesDir, testCase, 'package.json'); + if (existsSync(tempFilePath)) { + const { unlinkSync } = await import('fs'); + unlinkSync(tempFilePath); + } + } catch (error) { + console.log(`❌ ${testCase}: ERROR - ${error.message}`); + failed++; + } +} + +console.log(`\nTest Results: ${passed} passed, ${failed} failed`); +process.exit(failed > 0 ? 1 : 0); diff --git a/codemods/v14-update-deps/tests/fixtures/already-alpha/expected.json b/codemods/v14-update-deps/tests/fixtures/already-alpha/expected.json new file mode 100644 index 000000000..0f4787f02 --- /dev/null +++ b/codemods/v14-update-deps/tests/fixtures/already-alpha/expected.json @@ -0,0 +1,8 @@ +{ + "name": "test-project", + "version": "1.0.0", + "devDependencies": { + "@testing-library/react-native": "^14.0.0-alpha.5", + "universal-test-renderer": "0.10.1" + } +} diff --git a/codemods/v14-update-deps/tests/fixtures/already-alpha/input.json b/codemods/v14-update-deps/tests/fixtures/already-alpha/input.json new file mode 100644 index 000000000..7f2f64bc7 --- /dev/null +++ b/codemods/v14-update-deps/tests/fixtures/already-alpha/input.json @@ -0,0 +1,11 @@ +{ + "name": "test-project", + "version": "1.0.0", + "dependencies": { + "@testing-library/react-native": "^14.0.0-alpha.1" + }, + "devDependencies": { + "@types/react-test-renderer": "^18.0.0", + "react-test-renderer": "^18.0.0" + } +} diff --git a/codemods/v14-update-deps/tests/fixtures/basic-update/expected.json b/codemods/v14-update-deps/tests/fixtures/basic-update/expected.json new file mode 100644 index 000000000..0f4787f02 --- /dev/null +++ b/codemods/v14-update-deps/tests/fixtures/basic-update/expected.json @@ -0,0 +1,8 @@ +{ + "name": "test-project", + "version": "1.0.0", + "devDependencies": { + "@testing-library/react-native": "^14.0.0-alpha.5", + "universal-test-renderer": "0.10.1" + } +} diff --git a/codemods/v14-update-deps/tests/fixtures/basic-update/input.json b/codemods/v14-update-deps/tests/fixtures/basic-update/input.json new file mode 100644 index 000000000..135395224 --- /dev/null +++ b/codemods/v14-update-deps/tests/fixtures/basic-update/input.json @@ -0,0 +1,11 @@ +{ + "name": "test-project", + "version": "1.0.0", + "dependencies": { + "@testing-library/react-native": "^13.0.0" + }, + "devDependencies": { + "@types/react-test-renderer": "^18.0.0", + "react-test-renderer": "^18.0.0" + } +} diff --git a/codemods/v14-update-deps/tests/fixtures/move-from-deps/expected.json b/codemods/v14-update-deps/tests/fixtures/move-from-deps/expected.json new file mode 100644 index 000000000..0f4787f02 --- /dev/null +++ b/codemods/v14-update-deps/tests/fixtures/move-from-deps/expected.json @@ -0,0 +1,8 @@ +{ + "name": "test-project", + "version": "1.0.0", + "devDependencies": { + "@testing-library/react-native": "^14.0.0-alpha.5", + "universal-test-renderer": "0.10.1" + } +} diff --git a/codemods/v14-update-deps/tests/fixtures/move-from-deps/input.json b/codemods/v14-update-deps/tests/fixtures/move-from-deps/input.json new file mode 100644 index 000000000..bc0e5685f --- /dev/null +++ b/codemods/v14-update-deps/tests/fixtures/move-from-deps/input.json @@ -0,0 +1,10 @@ +{ + "name": "test-project", + "version": "1.0.0", + "dependencies": { + "@testing-library/react-native": "^13.0.0" + }, + "devDependencies": { + "@types/react-test-renderer": "^18.0.0" + } +} diff --git a/codemods/v14-update-deps/tests/fixtures/no-rntl-or-utr/expected.json b/codemods/v14-update-deps/tests/fixtures/no-rntl-or-utr/expected.json new file mode 100644 index 000000000..8457b20f0 --- /dev/null +++ b/codemods/v14-update-deps/tests/fixtures/no-rntl-or-utr/expected.json @@ -0,0 +1,11 @@ +{ + "name": "some-other-package", + "version": "1.0.0", + "dependencies": { + "some-package": "^1.0.0" + }, + "devDependencies": { + "@types/react-test-renderer": "^18.0.0", + "react-test-renderer": "^18.0.0" + } +} diff --git a/codemods/v14-update-deps/tests/fixtures/no-rntl-or-utr/input.json b/codemods/v14-update-deps/tests/fixtures/no-rntl-or-utr/input.json new file mode 100644 index 000000000..8457b20f0 --- /dev/null +++ b/codemods/v14-update-deps/tests/fixtures/no-rntl-or-utr/input.json @@ -0,0 +1,11 @@ +{ + "name": "some-other-package", + "version": "1.0.0", + "dependencies": { + "some-package": "^1.0.0" + }, + "devDependencies": { + "@types/react-test-renderer": "^18.0.0", + "react-test-renderer": "^18.0.0" + } +} diff --git a/codemods/v14-update-deps/tests/fixtures/no-rntl/expected.json b/codemods/v14-update-deps/tests/fixtures/no-rntl/expected.json new file mode 100644 index 000000000..47de196a8 --- /dev/null +++ b/codemods/v14-update-deps/tests/fixtures/no-rntl/expected.json @@ -0,0 +1,8 @@ +{ + "name": "test-project", + "version": "1.0.0", + "devDependencies": { + "@types/react-test-renderer": "^18.0.0", + "react-test-renderer": "^18.0.0" + } +} diff --git a/codemods/v14-update-deps/tests/fixtures/no-rntl/input.json b/codemods/v14-update-deps/tests/fixtures/no-rntl/input.json new file mode 100644 index 000000000..47de196a8 --- /dev/null +++ b/codemods/v14-update-deps/tests/fixtures/no-rntl/input.json @@ -0,0 +1,8 @@ +{ + "name": "test-project", + "version": "1.0.0", + "devDependencies": { + "@types/react-test-renderer": "^18.0.0", + "react-test-renderer": "^18.0.0" + } +} diff --git a/codemods/v14-update-deps/tests/fixtures/rntl-in-devdeps/expected.json b/codemods/v14-update-deps/tests/fixtures/rntl-in-devdeps/expected.json new file mode 100644 index 000000000..1928868ae --- /dev/null +++ b/codemods/v14-update-deps/tests/fixtures/rntl-in-devdeps/expected.json @@ -0,0 +1,6 @@ +{ + "devDependencies": { + "@testing-library/react-native": "^14.0.0-alpha.5", + "universal-test-renderer": "0.10.1" + } +} diff --git a/codemods/v14-update-deps/tests/fixtures/rntl-in-devdeps/input.json b/codemods/v14-update-deps/tests/fixtures/rntl-in-devdeps/input.json new file mode 100644 index 000000000..1fa31c01b --- /dev/null +++ b/codemods/v14-update-deps/tests/fixtures/rntl-in-devdeps/input.json @@ -0,0 +1,6 @@ +{ + "devDependencies": { + "@testing-library/react-native": "^13.0.0", + "react-test-renderer": "^18.0.0" + } +} diff --git a/codemods/v14-update-deps/tests/fixtures/with-peer-deps/expected.json b/codemods/v14-update-deps/tests/fixtures/with-peer-deps/expected.json new file mode 100644 index 000000000..0f4787f02 --- /dev/null +++ b/codemods/v14-update-deps/tests/fixtures/with-peer-deps/expected.json @@ -0,0 +1,8 @@ +{ + "name": "test-project", + "version": "1.0.0", + "devDependencies": { + "@testing-library/react-native": "^14.0.0-alpha.5", + "universal-test-renderer": "0.10.1" + } +} diff --git a/codemods/v14-update-deps/tests/fixtures/with-peer-deps/input.json b/codemods/v14-update-deps/tests/fixtures/with-peer-deps/input.json new file mode 100644 index 000000000..860d699d4 --- /dev/null +++ b/codemods/v14-update-deps/tests/fixtures/with-peer-deps/input.json @@ -0,0 +1,13 @@ +{ + "name": "test-project", + "version": "1.0.0", + "dependencies": { + "@testing-library/react-native": "^13.0.0" + }, + "devDependencies": { + "@types/react-test-renderer": "^18.0.0" + }, + "peerDependencies": { + "react-test-renderer": "^18.0.0" + } +} diff --git a/codemods/v14-update-deps/tsconfig.json b/codemods/v14-update-deps/tsconfig.json new file mode 100644 index 000000000..469fc5acf --- /dev/null +++ b/codemods/v14-update-deps/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["@codemod.com/jssg-types"], + "allowImportingTsExtensions": true, + "noEmit": true, + "verbatimModuleSyntax": true, + "erasableSyntaxOnly": true, + "strict": true, + "strictNullChecks": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true + }, + "exclude": ["tests"] +} diff --git a/codemods/v14-update-deps/workflow.yaml b/codemods/v14-update-deps/workflow.yaml new file mode 100644 index 000000000..b62748fd5 --- /dev/null +++ b/codemods/v14-update-deps/workflow.yaml @@ -0,0 +1,22 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/codemod/codemod/refs/heads/main/schemas/workflow.json + +version: '1' + +nodes: + - id: update-package-json + name: Update package.json dependencies + type: automatic + steps: + - name: 'Update dependencies in package.json' + js-ast-grep: + js_file: scripts/codemod.ts + language: json + include: + - '**/package.json' + exclude: + - '**/node_modules/**' + - '**/build/**' + - '**/dist/**' + - '**/.next/**' + - '**/coverage/**' + - '**/.yarn/**' diff --git a/jest.config.js b/jest.config.js index 5fd8d8e81..74705440d 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,7 +1,7 @@ module.exports = { preset: 'react-native', setupFilesAfterEnv: ['./jest-setup.ts'], - testPathIgnorePatterns: ['build/', 'examples/', 'experiments-app/'], + testPathIgnorePatterns: ['build/', 'examples/', 'experiments-app/', 'codemods/'], testTimeout: 60000, transformIgnorePatterns: [ '/node_modules/(?!(@react-native|react-native|react-native-gesture-handler)/).*/', diff --git a/package.json b/package.json index 6eee8cf55..ffc127f55 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "test": "jest", "test:ci": "jest --maxWorkers=2", "test:ci:coverage": "jest --maxWorkers=2 --collectCoverage=true --coverage-provider=v8", + "test:codemods": "node scripts/test-codemods.mjs", "typecheck": "tsc", "lint": "eslint src --cache", "validate": "yarn prettier && yarn lint && yarn typecheck && yarn test", diff --git a/scripts/test-codemods.mjs b/scripts/test-codemods.mjs new file mode 100644 index 000000000..a4a38323a --- /dev/null +++ b/scripts/test-codemods.mjs @@ -0,0 +1,38 @@ +#!/usr/bin/env node + +import { execSync } from 'child_process'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const rootDir = join(__dirname, '..'); + +console.log('Running async-functions codemod tests...\n'); +try { + execSync( + `yarn dlx codemod@latest jssg test -l tsx ${join(rootDir, 'codemods/v14-async-functions/scripts/codemod.ts')} ${join(rootDir, 'codemods/v14-async-functions/tests/fixtures')}`, + { + cwd: rootDir, + stdio: 'inherit', + }, + ); + console.log('\n✅ Async-functions codemod tests passed\n'); +} catch (error) { + console.error('\n❌ Async-functions codemod tests failed'); + process.exit(1); +} + +console.log('Running update-deps codemod tests...\n'); +try { + execSync(`yarn dlx tsx ${join(rootDir, 'codemods/v14-update-deps/scripts/test.js')}`, { + cwd: rootDir, + stdio: 'inherit', + }); + console.log('\n✅ Update-deps codemod tests passed\n'); +} catch (error) { + console.error('\n❌ Update-deps codemod tests failed'); + process.exit(1); +} + +console.log('✅ All codemod tests passed');