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');