From a1364f9c1e3b427cb71cdac6cf95ed76ba7b243f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Tue, 6 Jan 2026 14:04:25 +0100 Subject: [PATCH 01/27] wip --- codemods/v14-render-async/.gitignore | 33 +++ codemods/v14-render-async/README.md | 157 ++++++++++ codemods/v14-render-async/codemod.yaml | 20 ++ codemods/v14-render-async/package.json | 14 + codemods/v14-render-async/scripts/codemod.ts | 278 ++++++++++++++++++ .../tests/fixtures/already-async/expected.tsx | 6 + .../tests/fixtures/already-async/input.tsx | 6 + .../fixtures/already-awaited/expected.tsx | 5 + .../tests/fixtures/already-awaited/input.tsx | 5 + .../fixtures/basic-sync-test/expected.tsx | 6 + .../tests/fixtures/basic-sync-test/input.tsx | 6 + .../function-declaration/expected.tsx | 5 + .../fixtures/function-declaration/input.tsx | 5 + .../fixtures/helper-function/expected.tsx | 9 + .../tests/fixtures/helper-function/input.tsx | 9 + .../fixtures/it-instead-of-test/expected.tsx | 5 + .../fixtures/it-instead-of-test/input.tsx | 5 + .../fixtures/multiple-renders/expected.tsx | 6 + .../tests/fixtures/multiple-renders/input.tsx | 6 + .../fixtures/no-rntl-import/expected.tsx | 5 + .../tests/fixtures/no-rntl-import/input.tsx | 5 + .../fixtures/render-with-options/expected.tsx | 5 + .../fixtures/render-with-options/input.tsx | 5 + .../tests/fixtures/test-skip/expected.tsx | 5 + .../tests/fixtures/test-skip/input.tsx | 5 + codemods/v14-render-async/tsconfig.json | 17 ++ codemods/v14-render-async/workflow.yaml | 28 ++ codemods/v14-render-async/yarn.lock | 0 28 files changed, 661 insertions(+) create mode 100644 codemods/v14-render-async/.gitignore create mode 100644 codemods/v14-render-async/README.md create mode 100644 codemods/v14-render-async/codemod.yaml create mode 100644 codemods/v14-render-async/package.json create mode 100644 codemods/v14-render-async/scripts/codemod.ts create mode 100644 codemods/v14-render-async/tests/fixtures/already-async/expected.tsx create mode 100644 codemods/v14-render-async/tests/fixtures/already-async/input.tsx create mode 100644 codemods/v14-render-async/tests/fixtures/already-awaited/expected.tsx create mode 100644 codemods/v14-render-async/tests/fixtures/already-awaited/input.tsx create mode 100644 codemods/v14-render-async/tests/fixtures/basic-sync-test/expected.tsx create mode 100644 codemods/v14-render-async/tests/fixtures/basic-sync-test/input.tsx create mode 100644 codemods/v14-render-async/tests/fixtures/function-declaration/expected.tsx create mode 100644 codemods/v14-render-async/tests/fixtures/function-declaration/input.tsx create mode 100644 codemods/v14-render-async/tests/fixtures/helper-function/expected.tsx create mode 100644 codemods/v14-render-async/tests/fixtures/helper-function/input.tsx create mode 100644 codemods/v14-render-async/tests/fixtures/it-instead-of-test/expected.tsx create mode 100644 codemods/v14-render-async/tests/fixtures/it-instead-of-test/input.tsx create mode 100644 codemods/v14-render-async/tests/fixtures/multiple-renders/expected.tsx create mode 100644 codemods/v14-render-async/tests/fixtures/multiple-renders/input.tsx create mode 100644 codemods/v14-render-async/tests/fixtures/no-rntl-import/expected.tsx create mode 100644 codemods/v14-render-async/tests/fixtures/no-rntl-import/input.tsx create mode 100644 codemods/v14-render-async/tests/fixtures/render-with-options/expected.tsx create mode 100644 codemods/v14-render-async/tests/fixtures/render-with-options/input.tsx create mode 100644 codemods/v14-render-async/tests/fixtures/test-skip/expected.tsx create mode 100644 codemods/v14-render-async/tests/fixtures/test-skip/input.tsx create mode 100644 codemods/v14-render-async/tsconfig.json create mode 100644 codemods/v14-render-async/workflow.yaml create mode 100644 codemods/v14-render-async/yarn.lock diff --git a/codemods/v14-render-async/.gitignore b/codemods/v14-render-async/.gitignore new file mode 100644 index 000000000..78174f4c5 --- /dev/null +++ b/codemods/v14-render-async/.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-render-async/README.md b/codemods/v14-render-async/README.md new file mode 100644 index 000000000..1924f4aac --- /dev/null +++ b/codemods/v14-render-async/README.md @@ -0,0 +1,157 @@ +# RNTL v14: Make render() calls async + +This codemod migrates your test files from React Native Testing Library v13 to v14 by transforming synchronous `render()` calls to `await render()` calls and making test functions async when needed. + +## What it does + +- ✅ Transforms `render()` calls to `await render()` in test functions +- ✅ Makes test functions async if they're not already +- ✅ Handles `test()`, `it()`, `test.skip()`, and `it.skip()` patterns +- ✅ Preserves already-awaited render calls +- ✅ Skips render calls in helper functions (not inside test callbacks) +- ✅ Only transforms render calls imported from `@testing-library/react-native` + +## What it doesn't do + +- ❌ Does not transform render calls in helper functions (like `renderWithProviders`) +- ❌ Does not transform render calls from other libraries +- ❌ Does not handle namespace imports (e.g., `import * as RNTL from '@testing-library/react-native'`) + +## Usage + +### Running the codemod + +```bash +# Run on your test files +npx codemod@latest workflow run -w ./codemods/v14-render-async/workflow.yaml --target ./path/to/your/tests + +# Or if published to the registry +npx codemod@latest run @testing-library/react-native-v14-render-async --target ./path/to/your/tests +``` + +### Example transformations + +#### Basic sync test + +**Before:** +```typescript +import { render, screen } from '@testing-library/react-native'; + +test('renders component', () => { + render(); + expect(screen.getByText('Hello')).toBeOnTheScreen(); +}); +``` + +**After:** +```typescript +import { render, screen } from '@testing-library/react-native'; + +test('renders component', async () => { + await render(); + expect(screen.getByText('Hello')).toBeOnTheScreen(); +}); +``` + +#### Already async test + +**Before:** +```typescript +test('renders component', async () => { + render(); +}); +``` + +**After:** +```typescript +test('renders component', async () => { + await render(); +}); +``` + +#### Multiple render calls + +**Before:** +```typescript +test('renders multiple', () => { + render(); + render(); +}); +``` + +**After:** +```typescript +test('renders multiple', async () => { + await render(); + await render(); +}); +``` + +#### Render with options + +**Before:** +```typescript +test('renders with wrapper', () => { + render(, { wrapper: Wrapper }); +}); +``` + +**After:** +```typescript +test('renders with wrapper', async () => { + await render(, { wrapper: Wrapper }); +}); +``` + +#### Helper functions (not transformed) + +**Before:** +```typescript +function renderWithProviders(component: React.ReactElement) { + render(component); // This is NOT transformed +} + +test('uses helper', () => { + renderWithProviders(); +}); +``` + +**After:** +```typescript +function renderWithProviders(component: React.ReactElement) { + render(component); // Unchanged - helper functions are skipped +} + +test('uses helper', () => { + renderWithProviders(); +}); +``` + +## Testing + +Run the test suite: + +```bash +cd codemods/v14-render-async +yarn test +``` + +## Limitations + +1. **Helper functions**: Render calls inside helper functions (not directly in test callbacks) are not transformed. You'll need to manually update these functions to be async and await their render calls. + +2. **Namespace imports**: The codemod currently doesn't handle namespace imports like `import * as RNTL from '@testing-library/react-native'`. If you use this pattern, you'll need to manually update those calls. + +3. **Semantic analysis**: The codemod uses pattern matching rather than semantic analysis, so it may transform render calls that aren't from RNTL if they match the pattern. Always review the changes. + +## Migration Guide + +1. **Run the codemod** on your test files +2. **Review the changes** to ensure all transformations are correct +3. **Manually update helper functions** that contain render calls +4. **Update your RNTL version** to v14 +5. **Run your tests** to verify everything works + +## Contributing + +If you find issues or have suggestions for improvements, please open an issue or submit a pull request to the React Native Testing Library repository. diff --git a/codemods/v14-render-async/codemod.yaml b/codemods/v14-render-async/codemod.yaml new file mode 100644 index 000000000..20c559a22 --- /dev/null +++ b/codemods/v14-render-async/codemod.yaml @@ -0,0 +1,20 @@ +schema_version: "1.0" + +name: "@testing-library/react-native-v14-render-async" +version: "0.1.0" +description: "Codemod to migrate render() calls to await render() for RNTL v14" +author: "Callstack" +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-render-async/package.json b/codemods/v14-render-async/package.json new file mode 100644 index 000000000..48321c934 --- /dev/null +++ b/codemods/v14-render-async/package.json @@ -0,0 +1,14 @@ +{ + "name": "@testing-library/react-native-v14-render-async", + "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 typescript ./scripts/codemod.ts", + "check-types": "tsc --noEmit" + }, + "devDependencies": { + "@codemod.com/jssg-types": "^1.3.0", + "typescript": "^5.8.3" + } +} diff --git a/codemods/v14-render-async/scripts/codemod.ts b/codemods/v14-render-async/scripts/codemod.ts new file mode 100644 index 000000000..9a590273e --- /dev/null +++ b/codemods/v14-render-async/scripts/codemod.ts @@ -0,0 +1,278 @@ +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 transform: Transform = async (root) => { + const rootNode = root.root(); + + // Step 1: Check if render is imported from @testing-library/react-native + const renderImports = rootNode.findAll({ + rule: { + kind: "import_statement", + has: { + kind: "string", + regex: "@testing-library/react-native", + }, + }, + }); + + if (renderImports.length === 0) { + return null; // No RNTL imports, skip this file + } + + // Check if 'render' is actually imported + let hasRenderImport = false; + for (const importStmt of renderImports) { + const importClause = importStmt.find({ + rule: { kind: "import_clause" }, + }); + if (!importClause) continue; + + // Check for named imports: import { render, ... } from ... + const namedImports = importClause.find({ + rule: { kind: "named_imports" }, + }); + if (namedImports) { + const renderSpecifier = namedImports.find({ + rule: { + kind: "import_specifier", + has: { + kind: "identifier", + regex: "^render$", + }, + }, + }); + if (renderSpecifier) { + hasRenderImport = true; + break; + } + } + + // Check for default import: import render from ... + const defaultImport = importClause.find({ + rule: { + kind: "identifier", + regex: "^render$", + }, + }); + if (defaultImport) { + hasRenderImport = true; + break; + } + + // Check for namespace import: import * as RNTL from ... (we'll handle render calls via namespace) + const namespaceImport = importClause.find({ + rule: { kind: "namespace_import" }, + }); + if (namespaceImport) { + // For namespace imports, we'll check if render is called via the namespace + // This is handled in the render call matching below + hasRenderImport = true; + break; + } + } + + if (!hasRenderImport) { + return null; // render is not imported, skip + } + + // Step 2: Find all render() call expressions + // Match: render(...) where render is an identifier + const renderCalls = rootNode.findAll({ + rule: { + kind: "call_expression", + has: { + field: "function", + kind: "identifier", + regex: "^render$", + }, + }, + }); + + if (renderCalls.length === 0) { + return null; // No render calls found + } + + const edits: Edit[] = []; + const functionsToMakeAsync = new Map>(); // Use Map with node ID to ensure uniqueness + + // Step 3: Process each render call + for (const renderCall of renderCalls) { + // Skip if already awaited + const parent = renderCall.parent(); + if (parent && parent.is("await_expression")) { + continue; // Already awaited, skip + } + + // Skip if it's renderAsync (different function) + const functionNode = renderCall.field("function"); + if (functionNode && functionNode.text() === "renderAsync") { + continue; + } + + // Verify this render call refers to the imported render from RNTL + // We do this by checking if there's a render identifier that could refer to the import + // For simplicity, we'll transform all render() calls and let users verify + // In a more sophisticated version, we could use semantic analysis to verify the binding + + // Step 4: Find the containing function (test/it callback) + const containingFunction = findContainingTestFunction(renderCall); + if (!containingFunction) { + // Not inside a test function, skip (could be a helper function) + continue; + } + + // Step 5: Track functions that need to be made async + // Check if function is already async + // For arrow functions, async is a child node; for function declarations, it's before "function" + let isAsync = false; + if (containingFunction.is("arrow_function")) { + // Check if arrow function has async child node by checking children + const children = containingFunction.children(); + isAsync = children.some(child => child.text() === "async"); + } else { + // For function declarations/expressions, check text before + const funcStart = containingFunction.range().start.index; + const textBefore = rootNode.text().substring(Math.max(0, funcStart - 10), funcStart); + isAsync = textBefore.trim().endsWith("async"); + } + + // Only add if not already async and not already in the map + if (!isAsync && !functionsToMakeAsync.has(containingFunction.id())) { + functionsToMakeAsync.set(containingFunction.id(), containingFunction); + } + + // Step 6: Add await before render call + const callStart = renderCall.range().start.index; + edits.push({ + startPos: callStart, + endPos: callStart, + insertedText: "await ", + }); + } + + // Step 7: Add async keyword to functions that need it + for (const func of functionsToMakeAsync.values()) { + if (func.is("arrow_function")) { + // Arrow function: () => {} -> async () => {} + // Insert async before the parameters + const funcStart = func.range().start.index; + edits.push({ + startPos: funcStart, + endPos: funcStart, + insertedText: "async ", + }); + } else if ( + func.is("function_declaration") || + func.is("function_expression") + ) { + // Function declaration/expression: function name() {} -> async function name() {} + // The "function" keyword is the first child + const children = func.children(); + if (children.length > 0 && children[0].text() === "function") { + // Insert "async " before "function" + const funcKeywordStart = children[0].range().start.index; + edits.push({ + startPos: funcKeywordStart, + endPos: funcKeywordStart, + insertedText: "async ", + }); + } else { + // Fallback: insert before function start + const funcStart = func.range().start.index; + edits.push({ + startPos: funcStart, + endPos: funcStart, + insertedText: "async ", + }); + } + } + } + + if (edits.length === 0) { + return null; // No changes needed + } + + // Sort edits by position (reverse order to avoid offset issues) + edits.sort((a, b) => b.startPos - a.startPos); + + return rootNode.commitEdits(edits); +}; + +/** + * Find the containing test function (test/it callback) for a given node + */ +function findContainingTestFunction( + node: SgNode +): SgNode | null { + // Walk up the AST to find the containing function + let current: SgNode | null = node; + + while (current) { + // Check if current node is a function + if ( + current.is("arrow_function") || + current.is("function_declaration") || + current.is("function_expression") + ) { + // Check if this function is a test callback + // The function is typically the second argument of a test/it call + const parent = current.parent(); + if (parent) { + // Parent could be arguments node + 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(); + // Match test, it, describe + if (/^(test|it|describe)$/.test(funcText)) { + return current; + } + // Handle test.skip and it.skip (member expressions) + if (funcNode.is("member_expression")) { + const object = funcNode.field("object"); + const property = funcNode.field("property"); + if (object && property) { + const objText = object.text(); + const propText = property.text(); + if ((objText === "test" || objText === "it") && propText === "skip") { + return current; + } + } + } + } + } + } + // Parent could be call_expression directly (less common) + if (parent.is("call_expression")) { + const funcNode = parent.field("function"); + if (funcNode) { + const funcText = funcNode.text(); + if (/^(test|it|describe)$/.test(funcText)) { + return current; + } + if (funcNode.is("member_expression")) { + const object = funcNode.field("object"); + const property = funcNode.field("property"); + if (object && property) { + const objText = object.text(); + const propText = property.text(); + if ((objText === "test" || objText === "it") && propText === "skip") { + return current; + } + } + } + } + } + } + } + + current = current.parent(); + } + + return null; +} + +export default transform; diff --git a/codemods/v14-render-async/tests/fixtures/already-async/expected.tsx b/codemods/v14-render-async/tests/fixtures/already-async/expected.tsx new file mode 100644 index 000000000..c2caa6bef --- /dev/null +++ b/codemods/v14-render-async/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-render-async/tests/fixtures/already-async/input.tsx b/codemods/v14-render-async/tests/fixtures/already-async/input.tsx new file mode 100644 index 000000000..d1ac82bc2 --- /dev/null +++ b/codemods/v14-render-async/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-render-async/tests/fixtures/already-awaited/expected.tsx b/codemods/v14-render-async/tests/fixtures/already-awaited/expected.tsx new file mode 100644 index 000000000..7aae57ba0 --- /dev/null +++ b/codemods/v14-render-async/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-render-async/tests/fixtures/already-awaited/input.tsx b/codemods/v14-render-async/tests/fixtures/already-awaited/input.tsx new file mode 100644 index 000000000..7aae57ba0 --- /dev/null +++ b/codemods/v14-render-async/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-render-async/tests/fixtures/basic-sync-test/expected.tsx b/codemods/v14-render-async/tests/fixtures/basic-sync-test/expected.tsx new file mode 100644 index 000000000..c2caa6bef --- /dev/null +++ b/codemods/v14-render-async/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-render-async/tests/fixtures/basic-sync-test/input.tsx b/codemods/v14-render-async/tests/fixtures/basic-sync-test/input.tsx new file mode 100644 index 000000000..3e884dcfd --- /dev/null +++ b/codemods/v14-render-async/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-render-async/tests/fixtures/function-declaration/expected.tsx b/codemods/v14-render-async/tests/fixtures/function-declaration/expected.tsx new file mode 100644 index 000000000..7f4234bb6 --- /dev/null +++ b/codemods/v14-render-async/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-render-async/tests/fixtures/function-declaration/input.tsx b/codemods/v14-render-async/tests/fixtures/function-declaration/input.tsx new file mode 100644 index 000000000..ad71eea56 --- /dev/null +++ b/codemods/v14-render-async/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-render-async/tests/fixtures/helper-function/expected.tsx b/codemods/v14-render-async/tests/fixtures/helper-function/expected.tsx new file mode 100644 index 000000000..a50e1dd69 --- /dev/null +++ b/codemods/v14-render-async/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-render-async/tests/fixtures/helper-function/input.tsx b/codemods/v14-render-async/tests/fixtures/helper-function/input.tsx new file mode 100644 index 000000000..a50e1dd69 --- /dev/null +++ b/codemods/v14-render-async/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-render-async/tests/fixtures/it-instead-of-test/expected.tsx b/codemods/v14-render-async/tests/fixtures/it-instead-of-test/expected.tsx new file mode 100644 index 000000000..038609e80 --- /dev/null +++ b/codemods/v14-render-async/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-render-async/tests/fixtures/it-instead-of-test/input.tsx b/codemods/v14-render-async/tests/fixtures/it-instead-of-test/input.tsx new file mode 100644 index 000000000..4ada3736f --- /dev/null +++ b/codemods/v14-render-async/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-render-async/tests/fixtures/multiple-renders/expected.tsx b/codemods/v14-render-async/tests/fixtures/multiple-renders/expected.tsx new file mode 100644 index 000000000..22a8d029c --- /dev/null +++ b/codemods/v14-render-async/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-render-async/tests/fixtures/multiple-renders/input.tsx b/codemods/v14-render-async/tests/fixtures/multiple-renders/input.tsx new file mode 100644 index 000000000..68cf577fb --- /dev/null +++ b/codemods/v14-render-async/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-render-async/tests/fixtures/no-rntl-import/expected.tsx b/codemods/v14-render-async/tests/fixtures/no-rntl-import/expected.tsx new file mode 100644 index 000000000..4807101a0 --- /dev/null +++ b/codemods/v14-render-async/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-render-async/tests/fixtures/no-rntl-import/input.tsx b/codemods/v14-render-async/tests/fixtures/no-rntl-import/input.tsx new file mode 100644 index 000000000..4807101a0 --- /dev/null +++ b/codemods/v14-render-async/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-render-async/tests/fixtures/render-with-options/expected.tsx b/codemods/v14-render-async/tests/fixtures/render-with-options/expected.tsx new file mode 100644 index 000000000..54d3c48e9 --- /dev/null +++ b/codemods/v14-render-async/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-render-async/tests/fixtures/render-with-options/input.tsx b/codemods/v14-render-async/tests/fixtures/render-with-options/input.tsx new file mode 100644 index 000000000..8272061c6 --- /dev/null +++ b/codemods/v14-render-async/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-render-async/tests/fixtures/test-skip/expected.tsx b/codemods/v14-render-async/tests/fixtures/test-skip/expected.tsx new file mode 100644 index 000000000..d8234abc3 --- /dev/null +++ b/codemods/v14-render-async/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-render-async/tests/fixtures/test-skip/input.tsx b/codemods/v14-render-async/tests/fixtures/test-skip/input.tsx new file mode 100644 index 000000000..33d933fc2 --- /dev/null +++ b/codemods/v14-render-async/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-render-async/tsconfig.json b/codemods/v14-render-async/tsconfig.json new file mode 100644 index 000000000..469fc5acf --- /dev/null +++ b/codemods/v14-render-async/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-render-async/workflow.yaml b/codemods/v14-render-async/workflow.yaml new file mode 100644 index 000000000..463bac86c --- /dev/null +++ b/codemods/v14-render-async/workflow.yaml @@ -0,0 +1,28 @@ +# 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: + - "**/*.test.ts" + - "**/*.test.tsx" + - "**/*.spec.ts" + - "**/*.spec.tsx" + - "**/*.test.js" + - "**/*.test.jsx" + - "**/*.spec.js" + - "**/*.spec.jsx" + exclude: + - "**/node_modules/**" + - "**/build/**" + - "**/dist/**" + - "**/.next/**" + - "**/coverage/**" diff --git a/codemods/v14-render-async/yarn.lock b/codemods/v14-render-async/yarn.lock new file mode 100644 index 000000000..e69de29bb From 85c59b8d3dbf5c462d950321181eacb4ea286747 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Tue, 6 Jan 2026 14:24:16 +0100 Subject: [PATCH 02/27] renderHook, act async codemods --- codemods/v14-render-async/README.md | 126 +++++++++- codemods/v14-render-async/package.json | 2 +- codemods/v14-render-async/scripts/codemod.ts | 232 +++++++++--------- .../tests/fixtures/act-call/expected.tsx | 8 + .../tests/fixtures/act-call/input.tsx | 8 + .../fixtures/combined-functions/expected.tsx | 16 ++ .../fixtures/combined-functions/input.tsx | 16 ++ .../fixtures/renderhook-call/expected.tsx | 8 + .../tests/fixtures/renderhook-call/input.tsx | 8 + .../tests/fixtures/skip-variants/expected.tsx | 19 ++ .../tests/fixtures/skip-variants/input.tsx | 19 ++ codemods/v14-render-async/yarn.lock | 42 ++++ 12 files changed, 383 insertions(+), 121 deletions(-) create mode 100644 codemods/v14-render-async/tests/fixtures/act-call/expected.tsx create mode 100644 codemods/v14-render-async/tests/fixtures/act-call/input.tsx create mode 100644 codemods/v14-render-async/tests/fixtures/combined-functions/expected.tsx create mode 100644 codemods/v14-render-async/tests/fixtures/combined-functions/input.tsx create mode 100644 codemods/v14-render-async/tests/fixtures/renderhook-call/expected.tsx create mode 100644 codemods/v14-render-async/tests/fixtures/renderhook-call/input.tsx create mode 100644 codemods/v14-render-async/tests/fixtures/skip-variants/expected.tsx create mode 100644 codemods/v14-render-async/tests/fixtures/skip-variants/input.tsx diff --git a/codemods/v14-render-async/README.md b/codemods/v14-render-async/README.md index 1924f4aac..3da33b022 100644 --- a/codemods/v14-render-async/README.md +++ b/codemods/v14-render-async/README.md @@ -1,21 +1,25 @@ -# RNTL v14: Make render() calls async +# RNTL v14: Make render(), act(), and renderHook() calls async -This codemod migrates your test files from React Native Testing Library v13 to v14 by transforming synchronous `render()` calls to `await render()` calls and making test functions async when needed. +This codemod migrates your test files from React Native Testing Library v13 to v14 by transforming synchronous `render()`, `act()`, and `renderHook()` calls to `await render()`, `await act()`, and `await renderHook()` calls and making test functions async when needed. ## What it does - ✅ Transforms `render()` calls to `await render()` in test functions +- ✅ Transforms `act()` calls to `await act()` in test functions +- ✅ Transforms `renderHook()` calls to `await renderHook()` in test functions - ✅ Makes test functions async if they're not already - ✅ Handles `test()`, `it()`, `test.skip()`, and `it.skip()` patterns -- ✅ Preserves already-awaited render calls -- ✅ Skips render calls in helper functions (not inside test callbacks) -- ✅ Only transforms render calls imported from `@testing-library/react-native` +- ✅ Preserves already-awaited function calls +- ✅ Skips function calls in helper functions (not inside test callbacks) +- ✅ Only transforms calls imported from `@testing-library/react-native` +- ✅ Skips variants like `renderAsync`, `unsafe_act`, and `unsafe_renderHookSync` ## What it doesn't do -- ❌ Does not transform render calls in helper functions (like `renderWithProviders`) -- ❌ Does not transform render calls from other libraries +- ❌ Does not transform function calls in helper functions (like `renderWithProviders`) +- ❌ Does not transform function calls from other libraries - ❌ Does not handle namespace imports (e.g., `import * as RNTL from '@testing-library/react-native'`) +- ❌ Does not transform unsafe variants (`unsafe_act`, `unsafe_renderHookSync`) or `renderAsync` ## Usage @@ -103,6 +107,108 @@ test('renders with wrapper', async () => { }); ``` +#### Using act() + +**Before:** +```typescript +import { act } from '@testing-library/react-native'; + +test('updates state', () => { + act(() => { + // Some state update + }); +}); +``` + +**After:** +```typescript +import { act } from '@testing-library/react-native'; + +test('updates state', async () => { + await act(() => { + // Some state update + }); +}); +``` + +#### Using renderHook() + +**Before:** +```typescript +import { renderHook } from '@testing-library/react-native'; + +test('renders hook', () => { + const { result } = renderHook(() => ({ value: 42 })); + expect(result.current.value).toBe(42); +}); +``` + +**After:** +```typescript +import { renderHook } from '@testing-library/react-native'; + +test('renders hook', async () => { + const { result } = await renderHook(() => ({ value: 42 })); + expect(result.current.value).toBe(42); +}); +``` + +#### Combined usage + +**Before:** +```typescript +import { render, act, renderHook, screen } from '@testing-library/react-native'; + +test('uses all three', () => { + render(); + act(() => { + // State update + }); + const { result } = renderHook(() => ({ value: 42 })); +}); +``` + +**After:** +```typescript +import { render, act, renderHook, screen } from '@testing-library/react-native'; + +test('uses all three', async () => { + await render(); + await act(() => { + // State update + }); + const { result } = await renderHook(() => ({ value: 42 })); +}); +``` + +#### Skipping unsafe variants + +**Before:** +```typescript +import { act, renderHook, unsafe_act, unsafe_renderHookSync, renderAsync } from '@testing-library/react-native'; + +test('skips unsafe variants', () => { + act(() => {}); // Will be transformed + unsafe_act(() => {}); // Will NOT be transformed + renderHook(() => ({})); // Will be transformed + unsafe_renderHookSync(() => ({})); // Will NOT be transformed + renderAsync(); // Will NOT be transformed +}); +``` + +**After:** +```typescript +import { act, renderHook, unsafe_act, unsafe_renderHookSync, renderAsync } from '@testing-library/react-native'; + +test('skips unsafe variants', async () => { + await act(() => {}); // Transformed + unsafe_act(() => {}); // Unchanged + await renderHook(() => ({})); // Transformed + unsafe_renderHookSync(() => ({})); // Unchanged + renderAsync(); // Unchanged +}); +``` + #### Helper functions (not transformed) **Before:** @@ -138,17 +244,17 @@ yarn test ## Limitations -1. **Helper functions**: Render calls inside helper functions (not directly in test callbacks) are not transformed. You'll need to manually update these functions to be async and await their render calls. +1. **Helper functions**: Function calls (`render`, `act`, `renderHook`) inside helper functions (not directly in test callbacks) are not transformed. You'll need to manually update these functions to be async and await their calls. 2. **Namespace imports**: The codemod currently doesn't handle namespace imports like `import * as RNTL from '@testing-library/react-native'`. If you use this pattern, you'll need to manually update those calls. -3. **Semantic analysis**: The codemod uses pattern matching rather than semantic analysis, so it may transform render calls that aren't from RNTL if they match the pattern. Always review the changes. +3. **Semantic analysis**: The codemod uses pattern matching rather than semantic analysis, so it may transform function calls that aren't from RNTL if they match the pattern. Always review the changes. ## Migration Guide 1. **Run the codemod** on your test files 2. **Review the changes** to ensure all transformations are correct -3. **Manually update helper functions** that contain render calls +3. **Manually update helper functions** that contain `render`, `act`, or `renderHook` calls 4. **Update your RNTL version** to v14 5. **Run your tests** to verify everything works diff --git a/codemods/v14-render-async/package.json b/codemods/v14-render-async/package.json index 48321c934..6f5b02203 100644 --- a/codemods/v14-render-async/package.json +++ b/codemods/v14-render-async/package.json @@ -4,7 +4,7 @@ "description": "Codemod to migrate render() calls to await render() for RNTL v14", "type": "module", "scripts": { - "test": "yarn dlx codemod@latest jssg test -l typescript ./scripts/codemod.ts", + "test": "yarn dlx codemod@latest jssg test -l tsx ./scripts/codemod.ts", "check-types": "tsc --noEmit" }, "devDependencies": { diff --git a/codemods/v14-render-async/scripts/codemod.ts b/codemods/v14-render-async/scripts/codemod.ts index 9a590273e..a3da07db6 100644 --- a/codemods/v14-render-async/scripts/codemod.ts +++ b/codemods/v14-render-async/scripts/codemod.ts @@ -1,122 +1,130 @@ -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"; +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'; + +// Functions that should be transformed to async +const FUNCTIONS_TO_TRANSFORM = new Set(['render', 'renderHook', 'act']); + +// Variants that should be skipped (they're already async or have different behavior) +const SKIP_VARIANTS = new Set(['renderAsync', 'unsafe_renderHookSync', 'unsafe_act']); const transform: Transform = async (root) => { const rootNode = root.root(); - // Step 1: Check if render is imported from @testing-library/react-native - const renderImports = rootNode.findAll({ + // Step 1: Check if any of the target functions are imported from @testing-library/react-native + const rntlImports = rootNode.findAll({ rule: { - kind: "import_statement", + kind: 'import_statement', has: { - kind: "string", - regex: "@testing-library/react-native", + kind: 'string', + regex: '@testing-library/react-native', }, }, }); - if (renderImports.length === 0) { + if (rntlImports.length === 0) { return null; // No RNTL imports, skip this file } - // Check if 'render' is actually imported - let hasRenderImport = false; - for (const importStmt of renderImports) { + // Track which functions are imported using a Set + const importedFunctions = new Set(); + for (const importStmt of rntlImports) { const importClause = importStmt.find({ - rule: { kind: "import_clause" }, + rule: { kind: 'import_clause' }, }); if (!importClause) continue; - // Check for named imports: import { render, ... } from ... + // Check for named imports: import { render, act, renderHook, ... } from ... const namedImports = importClause.find({ - rule: { kind: "named_imports" }, + rule: { kind: 'named_imports' }, }); if (namedImports) { - const renderSpecifier = namedImports.find({ - rule: { - kind: "import_specifier", - has: { - kind: "identifier", - regex: "^render$", - }, - }, + const specifiers = namedImports.findAll({ + rule: { kind: 'import_specifier' }, }); - if (renderSpecifier) { - hasRenderImport = true; - break; + for (const specifier of specifiers) { + const identifier = specifier.find({ + rule: { kind: 'identifier' }, + }); + if (identifier) { + const funcName = identifier.text(); + if (FUNCTIONS_TO_TRANSFORM.has(funcName)) { + importedFunctions.add(funcName); + } + } } } // Check for default import: import render from ... const defaultImport = importClause.find({ - rule: { - kind: "identifier", - regex: "^render$", - }, + rule: { kind: 'identifier' }, }); if (defaultImport) { - hasRenderImport = true; - break; + const funcName = defaultImport.text(); + if (FUNCTIONS_TO_TRANSFORM.has(funcName)) { + importedFunctions.add(funcName); + } } - // Check for namespace import: import * as RNTL from ... (we'll handle render calls via namespace) + // Check for namespace import: import * as RNTL from ... const namespaceImport = importClause.find({ - rule: { kind: "namespace_import" }, + rule: { kind: 'namespace_import' }, }); if (namespaceImport) { - // For namespace imports, we'll check if render is called via the namespace - // This is handled in the render call matching below - hasRenderImport = true; + // For namespace imports, we'll check if functions are called via the namespace + // This is handled in the call matching below + // We assume all functions might be available via namespace + FUNCTIONS_TO_TRANSFORM.forEach((func) => importedFunctions.add(func)); break; } } - if (!hasRenderImport) { - return null; // render is not imported, skip + if (importedFunctions.size === 0) { + return null; // None of the target functions are imported, skip } - // Step 2: Find all render() call expressions - // Match: render(...) where render is an identifier - const renderCalls = rootNode.findAll({ - rule: { - kind: "call_expression", - has: { - field: "function", - kind: "identifier", - regex: "^render$", + // Step 2: Find all call expressions for imported functions + 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); + } - if (renderCalls.length === 0) { - return null; // No render calls found + if (functionCalls.length === 0) { + return null; // No function calls found } const edits: Edit[] = []; const functionsToMakeAsync = new Map>(); // Use Map with node ID to ensure uniqueness - // Step 3: Process each render call - for (const renderCall of renderCalls) { + // Step 3: Process each function call + for (const functionCall of functionCalls) { // Skip if already awaited - const parent = renderCall.parent(); - if (parent && parent.is("await_expression")) { + const parent = functionCall.parent(); + if (parent && parent.is('await_expression')) { continue; // Already awaited, skip } - // Skip if it's renderAsync (different function) - const functionNode = renderCall.field("function"); - if (functionNode && functionNode.text() === "renderAsync") { - continue; + // Skip variants that should not be transformed + const functionNode = functionCall.field('function'); + if (functionNode) { + const funcName = functionNode.text(); + if (SKIP_VARIANTS.has(funcName)) { + continue; + } } - // Verify this render call refers to the imported render from RNTL - // We do this by checking if there's a render identifier that could refer to the import - // For simplicity, we'll transform all render() calls and let users verify - // In a more sophisticated version, we could use semantic analysis to verify the binding - // Step 4: Find the containing function (test/it callback) - const containingFunction = findContainingTestFunction(renderCall); + const containingFunction = findContainingTestFunction(functionCall); if (!containingFunction) { // Not inside a test function, skip (could be a helper function) continue; @@ -126,56 +134,54 @@ const transform: Transform = async (root) => { // Check if function is already async // For arrow functions, async is a child node; for function declarations, it's before "function" let isAsync = false; - if (containingFunction.is("arrow_function")) { + if (containingFunction.is('arrow_function')) { // Check if arrow function has async child node by checking children const children = containingFunction.children(); - isAsync = children.some(child => child.text() === "async"); + isAsync = children.some((child) => child.text() === 'async'); } else { // For function declarations/expressions, check text before const funcStart = containingFunction.range().start.index; const textBefore = rootNode.text().substring(Math.max(0, funcStart - 10), funcStart); - isAsync = textBefore.trim().endsWith("async"); + isAsync = textBefore.trim().endsWith('async'); } - + // Only add if not already async and not already in the map if (!isAsync && !functionsToMakeAsync.has(containingFunction.id())) { functionsToMakeAsync.set(containingFunction.id(), containingFunction); } - // Step 6: Add await before render call - const callStart = renderCall.range().start.index; + // Step 6: Add await before function call + const callStart = functionCall.range().start.index; edits.push({ startPos: callStart, endPos: callStart, - insertedText: "await ", + insertedText: 'await ', }); } // Step 7: Add async keyword to functions that need it for (const func of functionsToMakeAsync.values()) { - if (func.is("arrow_function")) { + if (func.is('arrow_function')) { // Arrow function: () => {} -> async () => {} // Insert async before the parameters const funcStart = func.range().start.index; edits.push({ startPos: funcStart, endPos: funcStart, - insertedText: "async ", + insertedText: 'async ', }); - } else if ( - func.is("function_declaration") || - func.is("function_expression") - ) { + } else if (func.is('function_declaration') || func.is('function_expression')) { // Function declaration/expression: function name() {} -> async function name() {} // The "function" keyword is the first child const children = func.children(); - if (children.length > 0 && children[0].text() === "function") { + const firstChild = children.length > 0 ? children[0] : null; + if (firstChild && firstChild.text() === 'function') { // Insert "async " before "function" - const funcKeywordStart = children[0].range().start.index; + const funcKeywordStart = firstChild.range().start.index; edits.push({ startPos: funcKeywordStart, endPos: funcKeywordStart, - insertedText: "async ", + insertedText: 'async ', }); } else { // Fallback: insert before function start @@ -183,7 +189,7 @@ const transform: Transform = async (root) => { edits.push({ startPos: funcStart, endPos: funcStart, - insertedText: "async ", + insertedText: 'async ', }); } } @@ -202,28 +208,26 @@ const transform: Transform = async (root) => { /** * Find the containing test function (test/it callback) for a given node */ -function findContainingTestFunction( - node: SgNode -): SgNode | null { +function findContainingTestFunction(node: SgNode): SgNode | null { // Walk up the AST to find the containing function let current: SgNode | null = node; while (current) { // Check if current node is a function if ( - current.is("arrow_function") || - current.is("function_declaration") || - current.is("function_expression") + current.is('arrow_function') || + current.is('function_declaration') || + current.is('function_expression') ) { // Check if this function is a test callback // The function is typically the second argument of a test/it call const parent = current.parent(); if (parent) { // Parent could be arguments node - if (parent.is("arguments")) { + if (parent.is('arguments')) { const grandParent = parent.parent(); - if (grandParent && grandParent.is("call_expression")) { - const funcNode = grandParent.field("function"); + if (grandParent && grandParent.is('call_expression')) { + const funcNode = grandParent.field('function'); if (funcNode) { const funcText = funcNode.text(); // Match test, it, describe @@ -231,37 +235,45 @@ function findContainingTestFunction( return current; } // Handle test.skip and it.skip (member expressions) - if (funcNode.is("member_expression")) { - const object = funcNode.field("object"); - const property = funcNode.field("property"); - if (object && property) { - const objText = object.text(); - const propText = property.text(); - if ((objText === "test" || objText === "it") && propText === "skip") { - 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 ((objText === 'test' || objText === 'it') && propText === 'skip') { + return current; + } } + } catch { + // field() might not be available for this node type, skip } } } } } // Parent could be call_expression directly (less common) - if (parent.is("call_expression")) { - const funcNode = parent.field("function"); + if (parent.is('call_expression')) { + const funcNode = parent.field('function'); if (funcNode) { const funcText = funcNode.text(); if (/^(test|it|describe)$/.test(funcText)) { return current; } - if (funcNode.is("member_expression")) { - const object = funcNode.field("object"); - const property = funcNode.field("property"); - if (object && property) { - const objText = object.text(); - const propText = property.text(); - if ((objText === "test" || objText === "it") && propText === "skip") { - 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 ((objText === 'test' || objText === 'it') && propText === 'skip') { + return current; + } } + } catch { + // field() might not be available for this node type, skip } } } diff --git a/codemods/v14-render-async/tests/fixtures/act-call/expected.tsx b/codemods/v14-render-async/tests/fixtures/act-call/expected.tsx new file mode 100644 index 000000000..355501a8a --- /dev/null +++ b/codemods/v14-render-async/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-render-async/tests/fixtures/act-call/input.tsx b/codemods/v14-render-async/tests/fixtures/act-call/input.tsx new file mode 100644 index 000000000..275dff85b --- /dev/null +++ b/codemods/v14-render-async/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-render-async/tests/fixtures/combined-functions/expected.tsx b/codemods/v14-render-async/tests/fixtures/combined-functions/expected.tsx new file mode 100644 index 000000000..632ad4877 --- /dev/null +++ b/codemods/v14-render-async/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-render-async/tests/fixtures/combined-functions/input.tsx b/codemods/v14-render-async/tests/fixtures/combined-functions/input.tsx new file mode 100644 index 000000000..c11c032d3 --- /dev/null +++ b/codemods/v14-render-async/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-render-async/tests/fixtures/renderhook-call/expected.tsx b/codemods/v14-render-async/tests/fixtures/renderhook-call/expected.tsx new file mode 100644 index 000000000..4146596eb --- /dev/null +++ b/codemods/v14-render-async/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-render-async/tests/fixtures/renderhook-call/input.tsx b/codemods/v14-render-async/tests/fixtures/renderhook-call/input.tsx new file mode 100644 index 000000000..04b2c9f38 --- /dev/null +++ b/codemods/v14-render-async/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-render-async/tests/fixtures/skip-variants/expected.tsx b/codemods/v14-render-async/tests/fixtures/skip-variants/expected.tsx new file mode 100644 index 000000000..ff2e2e34d --- /dev/null +++ b/codemods/v14-render-async/tests/fixtures/skip-variants/expected.tsx @@ -0,0 +1,19 @@ +import { render, act, renderHook, unsafe_act, unsafe_renderHookSync, renderAsync } 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 })); + + renderAsync(); +}); diff --git a/codemods/v14-render-async/tests/fixtures/skip-variants/input.tsx b/codemods/v14-render-async/tests/fixtures/skip-variants/input.tsx new file mode 100644 index 000000000..dbade8d1f --- /dev/null +++ b/codemods/v14-render-async/tests/fixtures/skip-variants/input.tsx @@ -0,0 +1,19 @@ +import { render, act, renderHook, unsafe_act, unsafe_renderHookSync, renderAsync } from '@testing-library/react-native'; + +test('skips unsafe variants', () => { + render(); + + act(() => { + // Should be transformed + }); + + unsafe_act(() => { + // Should NOT be transformed + }); + + const { result } = renderHook(() => ({ value: 42 })); + + unsafe_renderHookSync(() => ({ value: 43 })); + + renderAsync(); +}); diff --git a/codemods/v14-render-async/yarn.lock b/codemods/v14-render-async/yarn.lock index e69de29bb..2dfe29b80 100644 --- a/codemods/v14-render-async/yarn.lock +++ b/codemods/v14-render-async/yarn.lock @@ -0,0 +1,42 @@ +# This file is generated by running "yarn install" inside your project. +# Manual changes might be lost - proceed with caution! + +__metadata: + version: 8 + cacheKey: 10c0 + +"@codemod.com/jssg-types@npm:^1.3.0": + version: 1.3.1 + resolution: "@codemod.com/jssg-types@npm:1.3.1" + checksum: 10c0/5936b2fbcfaaec53e2774c92b0e1e3bd6326b676391625a74aac495d6a4f8157c154132b80f5beae362a66cd9f0fb3c84746ca68157be6fa009b718f37d27462 + languageName: node + linkType: hard + +"@testing-library/react-native-v14-render-async@workspace:.": + version: 0.0.0-use.local + resolution: "@testing-library/react-native-v14-render-async@workspace:." + dependencies: + "@codemod.com/jssg-types": "npm:^1.3.0" + typescript: "npm:^5.8.3" + languageName: unknown + linkType: soft + +"typescript@npm:^5.8.3": + version: 5.9.3 + resolution: "typescript@npm:5.9.3" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/6bd7552ce39f97e711db5aa048f6f9995b53f1c52f7d8667c1abdc1700c68a76a308f579cd309ce6b53646deb4e9a1be7c813a93baaf0a28ccd536a30270e1c5 + languageName: node + linkType: hard + +"typescript@patch:typescript@npm%3A^5.8.3#optional!builtin": + version: 5.9.3 + resolution: "typescript@patch:typescript@npm%3A5.9.3#optional!builtin::version=5.9.3&hash=5786d5" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/ad09fdf7a756814dce65bc60c1657b40d44451346858eea230e10f2e95a289d9183b6e32e5c11e95acc0ccc214b4f36289dcad4bf1886b0adb84d711d336a430 + languageName: node + linkType: hard From 8544f49b9b98f4ab04f4afdf35da9933e077c1c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Tue, 6 Jan 2026 14:26:46 +0100 Subject: [PATCH 03/27] fire event --- codemods/v14-render-async/README.md | 77 +++++++++++++++++-- codemods/v14-render-async/scripts/codemod.ts | 40 +++++++++- .../fixtures/fireevent-call/expected.tsx | 8 ++ .../tests/fixtures/fireevent-call/input.tsx | 8 ++ .../fixtures/fireevent-methods/expected.tsx | 14 ++++ .../fixtures/fireevent-methods/input.tsx | 14 ++++ 6 files changed, 154 insertions(+), 7 deletions(-) create mode 100644 codemods/v14-render-async/tests/fixtures/fireevent-call/expected.tsx create mode 100644 codemods/v14-render-async/tests/fixtures/fireevent-call/input.tsx create mode 100644 codemods/v14-render-async/tests/fixtures/fireevent-methods/expected.tsx create mode 100644 codemods/v14-render-async/tests/fixtures/fireevent-methods/input.tsx diff --git a/codemods/v14-render-async/README.md b/codemods/v14-render-async/README.md index 3da33b022..0175fe2b6 100644 --- a/codemods/v14-render-async/README.md +++ b/codemods/v14-render-async/README.md @@ -1,12 +1,14 @@ -# RNTL v14: Make render(), act(), and renderHook() calls async +# 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 transforming synchronous `render()`, `act()`, and `renderHook()` calls to `await render()`, `await act()`, and `await renderHook()` calls and making test functions async when needed. +This codemod migrates your test files from React Native Testing Library v13 to v14 by transforming synchronous `render()`, `act()`, `renderHook()`, and `fireEvent()` calls to their async versions (`await render()`, `await act()`, `await renderHook()`, `await fireEvent()`, etc.) and making test functions async when needed. ## What it does - ✅ Transforms `render()` calls to `await render()` in test functions - ✅ Transforms `act()` calls to `await act()` in test functions - ✅ Transforms `renderHook()` calls to `await renderHook()` in test functions +- ✅ Transforms `fireEvent()` calls to `await fireEvent()` in test functions +- ✅ Transforms `fireEvent.press()`, `fireEvent.changeText()`, and `fireEvent.scroll()` calls to `await fireEvent.press()`, etc. - ✅ Makes test functions async if they're not already - ✅ Handles `test()`, `it()`, `test.skip()`, and `it.skip()` patterns - ✅ Preserves already-awaited function calls @@ -181,6 +183,66 @@ test('uses all three', async () => { }); ``` +#### Using fireEvent() + +**Before:** +```typescript +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(); +}); +``` + +**After:** +```typescript +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(); +}); +``` + +#### Using fireEvent methods + +**Before:** +```typescript +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 } } }); +}); +``` + +**After:** +```typescript +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 } } }); +}); +``` + #### Skipping unsafe variants **Before:** @@ -244,19 +306,22 @@ yarn test ## Limitations -1. **Helper functions**: Function calls (`render`, `act`, `renderHook`) inside helper functions (not directly in test callbacks) are not transformed. You'll need to manually update these functions to be async and await their calls. +1. **Helper functions**: Function calls (`render`, `act`, `renderHook`, `fireEvent`) inside helper functions (not directly in test callbacks) are not transformed. You'll need to manually update these functions to be async and await their calls. 2. **Namespace imports**: The codemod currently doesn't handle namespace imports like `import * as RNTL from '@testing-library/react-native'`. If you use this pattern, you'll need to manually update those calls. 3. **Semantic analysis**: The codemod uses pattern matching rather than semantic analysis, so it may transform function calls that aren't from RNTL if they match the pattern. Always review the changes. +4. **fireEvent methods**: Only `fireEvent.press`, `fireEvent.changeText`, and `fireEvent.scroll` are transformed. Other `fireEvent` methods are not automatically transformed. + ## Migration Guide 1. **Run the codemod** on your test files 2. **Review the changes** to ensure all transformations are correct -3. **Manually update helper functions** that contain `render`, `act`, or `renderHook` calls -4. **Update your RNTL version** to v14 -5. **Run your tests** to verify everything works +3. **Manually update helper functions** that contain `render`, `act`, `renderHook`, or `fireEvent` calls +4. **Manually update other fireEvent methods** if you use methods other than `press`, `changeText`, or `scroll` +5. **Update your RNTL version** to v14 +6. **Run your tests** to verify everything works ## Contributing diff --git a/codemods/v14-render-async/scripts/codemod.ts b/codemods/v14-render-async/scripts/codemod.ts index a3da07db6..1c4f0d0c1 100644 --- a/codemods/v14-render-async/scripts/codemod.ts +++ b/codemods/v14-render-async/scripts/codemod.ts @@ -3,7 +3,10 @@ import type TSX from 'codemod:ast-grep/langs/tsx'; import type { Edit, SgNode } from '@codemod.com/jssg-types/main'; // Functions that should be transformed to async -const FUNCTIONS_TO_TRANSFORM = new Set(['render', 'renderHook', 'act']); +const FUNCTIONS_TO_TRANSFORM = new Set(['render', 'renderHook', 'act', 'fireEvent']); + +// fireEvent methods that should be transformed to async +const FIRE_EVENT_METHODS = new Set(['press', 'changeText', 'scroll']); // Variants that should be skipped (they're already async or have different behavior) const SKIP_VARIANTS = new Set(['renderAsync', 'unsafe_renderHookSync', 'unsafe_act']); @@ -85,6 +88,8 @@ const transform: Transform = async (root) => { // Step 2: Find all call expressions for imported functions const functionCalls: SgNode[] = []; + + // Find standalone function calls (render, act, renderHook, fireEvent) for (const funcName of importedFunctions) { const calls = rootNode.findAll({ rule: { @@ -99,6 +104,39 @@ const transform: Transform = async (root) => { functionCalls.push(...calls); } + // Find fireEvent method calls (fireEvent.press, fireEvent.changeText, fireEvent.scroll) + if (importedFunctions.has('fireEvent')) { + 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(); + // Check if it's fireEvent.methodName where methodName is one of our target methods + if (objText === 'fireEvent' && FIRE_EVENT_METHODS.has(propText)) { + functionCalls.push(call); + } + } + } catch { + // field() might not be available for this node type, skip + } + } + } + } + if (functionCalls.length === 0) { return null; // No function calls found } diff --git a/codemods/v14-render-async/tests/fixtures/fireevent-call/expected.tsx b/codemods/v14-render-async/tests/fixtures/fireevent-call/expected.tsx new file mode 100644 index 000000000..5547a8fa9 --- /dev/null +++ b/codemods/v14-render-async/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-render-async/tests/fixtures/fireevent-call/input.tsx b/codemods/v14-render-async/tests/fixtures/fireevent-call/input.tsx new file mode 100644 index 000000000..601621ba7 --- /dev/null +++ b/codemods/v14-render-async/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-render-async/tests/fixtures/fireevent-methods/expected.tsx b/codemods/v14-render-async/tests/fixtures/fireevent-methods/expected.tsx new file mode 100644 index 000000000..24ec7e2fe --- /dev/null +++ b/codemods/v14-render-async/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-render-async/tests/fixtures/fireevent-methods/input.tsx b/codemods/v14-render-async/tests/fixtures/fireevent-methods/input.tsx new file mode 100644 index 000000000..337ad46db --- /dev/null +++ b/codemods/v14-render-async/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(); +}); From da28a69fb61676cb9b4fa3de896b4300fceee15c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Tue, 6 Jan 2026 14:28:42 +0100 Subject: [PATCH 04/27] beforeEach/All --- codemods/v14-render-async/README.md | 1 + codemods/v14-render-async/scripts/codemod.ts | 14 +++++++------- .../tests/fixtures/aftereach-hook/expected.tsx | 10 ++++++++++ .../tests/fixtures/aftereach-hook/input.tsx | 10 ++++++++++ .../tests/fixtures/beforeeach-hook/expected.tsx | 9 +++++++++ .../tests/fixtures/beforeeach-hook/input.tsx | 9 +++++++++ 6 files changed, 46 insertions(+), 7 deletions(-) create mode 100644 codemods/v14-render-async/tests/fixtures/aftereach-hook/expected.tsx create mode 100644 codemods/v14-render-async/tests/fixtures/aftereach-hook/input.tsx create mode 100644 codemods/v14-render-async/tests/fixtures/beforeeach-hook/expected.tsx create mode 100644 codemods/v14-render-async/tests/fixtures/beforeeach-hook/input.tsx diff --git a/codemods/v14-render-async/README.md b/codemods/v14-render-async/README.md index 0175fe2b6..1c56f5ce4 100644 --- a/codemods/v14-render-async/README.md +++ b/codemods/v14-render-async/README.md @@ -11,6 +11,7 @@ This codemod migrates your test files from React Native Testing Library v13 to v - ✅ Transforms `fireEvent.press()`, `fireEvent.changeText()`, and `fireEvent.scroll()` calls to `await fireEvent.press()`, etc. - ✅ Makes test functions async if they're not already - ✅ Handles `test()`, `it()`, `test.skip()`, and `it.skip()` patterns +- ✅ Handles `beforeEach()`, `afterEach()`, `beforeAll()`, and `afterAll()` hooks - ✅ Preserves already-awaited function calls - ✅ Skips function calls in helper functions (not inside test callbacks) - ✅ Only transforms calls imported from `@testing-library/react-native` diff --git a/codemods/v14-render-async/scripts/codemod.ts b/codemods/v14-render-async/scripts/codemod.ts index 1c4f0d0c1..2078f9931 100644 --- a/codemods/v14-render-async/scripts/codemod.ts +++ b/codemods/v14-render-async/scripts/codemod.ts @@ -161,10 +161,10 @@ const transform: Transform = async (root) => { } } - // Step 4: Find the containing function (test/it callback) + // Step 4: Find the containing function (test/it/hook callback) const containingFunction = findContainingTestFunction(functionCall); if (!containingFunction) { - // Not inside a test function, skip (could be a helper function) + // Not inside a test function or hook, skip (could be a helper function) continue; } @@ -244,7 +244,7 @@ const transform: Transform = async (root) => { }; /** - * Find the containing test function (test/it callback) for a given node + * Find the containing test function or hook callback (test/it/beforeEach/afterEach/etc.) for a given node */ function findContainingTestFunction(node: SgNode): SgNode | null { // Walk up the AST to find the containing function @@ -268,11 +268,11 @@ function findContainingTestFunction(node: SgNode): SgNode | null { const funcNode = grandParent.field('function'); if (funcNode) { const funcText = funcNode.text(); - // Match test, it, describe - if (/^(test|it|describe)$/.test(funcText)) { + // Match test, it, describe, beforeEach, afterEach, beforeAll, afterAll + if (/^(test|it|describe|beforeEach|afterEach|beforeAll|afterAll)$/.test(funcText)) { return current; } - // Handle test.skip and it.skip (member expressions) + // Handle test.skip, it.skip, etc. (member expressions) if (funcNode.is('member_expression')) { try { const object = funcNode.field('object'); @@ -296,7 +296,7 @@ function findContainingTestFunction(node: SgNode): SgNode | null { const funcNode = parent.field('function'); if (funcNode) { const funcText = funcNode.text(); - if (/^(test|it|describe)$/.test(funcText)) { + if (/^(test|it|describe|beforeEach|afterEach|beforeAll|afterAll)$/.test(funcText)) { return current; } if (funcNode.is('member_expression')) { diff --git a/codemods/v14-render-async/tests/fixtures/aftereach-hook/expected.tsx b/codemods/v14-render-async/tests/fixtures/aftereach-hook/expected.tsx new file mode 100644 index 000000000..d832cbc65 --- /dev/null +++ b/codemods/v14-render-async/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-render-async/tests/fixtures/aftereach-hook/input.tsx b/codemods/v14-render-async/tests/fixtures/aftereach-hook/input.tsx new file mode 100644 index 000000000..2e372b078 --- /dev/null +++ b/codemods/v14-render-async/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-render-async/tests/fixtures/beforeeach-hook/expected.tsx b/codemods/v14-render-async/tests/fixtures/beforeeach-hook/expected.tsx new file mode 100644 index 000000000..4bd6d0405 --- /dev/null +++ b/codemods/v14-render-async/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-render-async/tests/fixtures/beforeeach-hook/input.tsx b/codemods/v14-render-async/tests/fixtures/beforeeach-hook/input.tsx new file mode 100644 index 000000000..53b4e467f --- /dev/null +++ b/codemods/v14-render-async/tests/fixtures/beforeeach-hook/input.tsx @@ -0,0 +1,9 @@ +import { render } from '@testing-library/react-native'; + +beforeEach(() => { + render(); +}); + +test('test case', () => { + // Test code +}); From c5c6d9c4b6bbffa4d52bd4bf2f8b45dfcbbb4438 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Tue, 6 Jan 2026 14:36:26 +0100 Subject: [PATCH 05/27] custom render functions --- codemods/v14-render-async/README.md | 65 ++++- codemods/v14-render-async/scripts/codemod.ts | 272 +++++++++++++++++- .../custom-render-function/expected.tsx | 28 ++ .../fixtures/custom-render-function/input.tsx | 28 ++ 4 files changed, 380 insertions(+), 13 deletions(-) create mode 100644 codemods/v14-render-async/tests/fixtures/custom-render-function/expected.tsx create mode 100644 codemods/v14-render-async/tests/fixtures/custom-render-function/input.tsx diff --git a/codemods/v14-render-async/README.md b/codemods/v14-render-async/README.md index 1c56f5ce4..5ba020f43 100644 --- a/codemods/v14-render-async/README.md +++ b/codemods/v14-render-async/README.md @@ -19,7 +19,7 @@ This codemod migrates your test files from React Native Testing Library v13 to v ## What it doesn't do -- ❌ Does not transform function calls in helper functions (like `renderWithProviders`) +- ❌ Does not transform function calls in helper functions (like `renderWithProviders`) - **unless specified via `CUSTOM_RENDER_FUNCTIONS`** - ❌ Does not transform function calls from other libraries - ❌ Does not handle namespace imports (e.g., `import * as RNTL from '@testing-library/react-native'`) - ❌ Does not transform unsafe variants (`unsafe_act`, `unsafe_renderHookSync`) or `renderAsync` @@ -34,6 +34,9 @@ npx codemod@latest workflow run -w ./codemods/v14-render-async/workflow.yaml --t # Or if published to the registry npx codemod@latest run @testing-library/react-native-v14-render-async --target ./path/to/your/tests + +# With custom render functions (comma-separated list) +CUSTOM_RENDER_FUNCTIONS="renderWithProviders,renderWithTheme" npx codemod@latest workflow run -w ./codemods/v14-render-async/workflow.yaml --target ./path/to/your/tests ``` ### Example transformations @@ -272,12 +275,12 @@ test('skips unsafe variants', async () => { }); ``` -#### Helper functions (not transformed) +#### Helper functions (not transformed by default) **Before:** ```typescript function renderWithProviders(component: React.ReactElement) { - render(component); // This is NOT transformed + render(component); // This is NOT transformed by default } test('uses helper', () => { @@ -285,10 +288,10 @@ test('uses helper', () => { }); ``` -**After:** +**After (without CUSTOM_RENDER_FUNCTIONS):** ```typescript function renderWithProviders(component: React.ReactElement) { - render(component); // Unchanged - helper functions are skipped + render(component); // Unchanged - helper functions are skipped by default } test('uses helper', () => { @@ -296,6 +299,42 @@ test('uses helper', () => { }); ``` +#### Custom render functions (with CUSTOM_RENDER_FUNCTIONS) + +When you specify custom render function names via the `CUSTOM_RENDER_FUNCTIONS` environment variable, those functions will be transformed: + +**Before:** +```typescript +function renderWithProviders(component: React.ReactElement) { + render(component); +} + +const renderWithTheme = (component: React.ReactElement) => { + render(component); +}; + +test('uses custom render', () => { + renderWithProviders(); + renderWithTheme(); +}); +``` + +**After (with CUSTOM_RENDER_FUNCTIONS="renderWithProviders,renderWithTheme"):** +```typescript +async function renderWithProviders(component: React.ReactElement) { + await render(component); +} + +const renderWithTheme = async (component: React.ReactElement) => { + await render(component); +}; + +test('uses custom render', async () => { + await renderWithProviders(); + await renderWithTheme(); +}); +``` + ## Testing Run the test suite: @@ -307,7 +346,7 @@ yarn test ## Limitations -1. **Helper functions**: Function calls (`render`, `act`, `renderHook`, `fireEvent`) inside helper functions (not directly in test callbacks) are not transformed. You'll need to manually update these functions to be async and await their calls. +1. **Helper functions**: Function calls (`render`, `act`, `renderHook`, `fireEvent`) inside helper functions (not directly in test callbacks) are not transformed by default. You can specify custom render function names via the `CUSTOM_RENDER_FUNCTIONS` environment variable to have them automatically transformed. For other helper functions, you'll need to manually update them to be async and await their calls. 2. **Namespace imports**: The codemod currently doesn't handle namespace imports like `import * as RNTL from '@testing-library/react-native'`. If you use this pattern, you'll need to manually update those calls. @@ -318,11 +357,15 @@ yarn test ## Migration Guide 1. **Run the codemod** on your test files -2. **Review the changes** to ensure all transformations are correct -3. **Manually update helper functions** that contain `render`, `act`, `renderHook`, or `fireEvent` calls -4. **Manually update other fireEvent methods** if you use methods other than `press`, `changeText`, or `scroll` -5. **Update your RNTL version** to v14 -6. **Run your tests** to verify everything works +2. **If you have custom render functions** (like `renderWithProviders`, `renderWithTheme`, etc.), run the codemod with `CUSTOM_RENDER_FUNCTIONS` environment variable: + ```bash + CUSTOM_RENDER_FUNCTIONS="renderWithProviders,renderWithTheme" npx codemod@latest workflow run -w ./codemods/v14-render-async/workflow.yaml --target ./path/to/your/tests + ``` +3. **Review the changes** to ensure all transformations are correct +4. **Manually update helper functions** that contain `render`, `act`, `renderHook`, or `fireEvent` calls (if not specified in `CUSTOM_RENDER_FUNCTIONS`) +5. **Manually update other fireEvent methods** if you use methods other than `press`, `changeText`, or `scroll` +6. **Update your RNTL version** to v14 +7. **Run your tests** to verify everything works ## Contributing diff --git a/codemods/v14-render-async/scripts/codemod.ts b/codemods/v14-render-async/scripts/codemod.ts index 2078f9931..64663f35a 100644 --- a/codemods/v14-render-async/scripts/codemod.ts +++ b/codemods/v14-render-async/scripts/codemod.ts @@ -14,6 +14,18 @@ const SKIP_VARIANTS = new Set(['renderAsync', 'unsafe_renderHookSync', 'unsafe_a const transform: Transform = async (root) => { const rootNode = root.root(); + // Parse custom render functions from environment variable + // Format: CUSTOM_RENDER_FUNCTIONS="renderWithProviders,renderWithTheme,renderCustom" + const customRenderFunctionsParam = process.env.CUSTOM_RENDER_FUNCTIONS || ''; + const customRenderFunctionsSet = new Set(); + if (customRenderFunctionsParam) { + customRenderFunctionsParam + .split(',') + .map((name) => name.trim()) + .filter((name) => name.length > 0) + .forEach((name) => customRenderFunctionsSet.add(name)); + } + // Step 1: Check if any of the target functions are imported from @testing-library/react-native const rntlImports = rootNode.findAll({ rule: { @@ -137,12 +149,66 @@ const transform: Transform = async (root) => { } } - if (functionCalls.length === 0) { - return null; // No function calls found + if (functionCalls.length === 0 && customRenderFunctionsSet.size === 0) { + return null; // No function calls found and no custom render functions to process } const edits: Edit[] = []; const functionsToMakeAsync = new Map>(); // Use Map with node ID to ensure uniqueness + const customRenderFunctionsToMakeAsync = new Map>(); // Track custom render functions that need to be async + + // Step 2.5: Find and process custom render function definitions + if (customRenderFunctionsSet.size > 0) { + // Find function declarations + 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)) { + // Found a custom render function declaration + processCustomRenderFunction(funcDecl, importedFunctions, edits, customRenderFunctionsToMakeAsync, rootNode); + } + } + } + + // Find arrow functions and function expressions (const renderWithX = () => {} or const renderWithX = function() {}) + 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)) { + // Check if it's an arrow function or function expression + const init = declarator.find({ + rule: { + any: [ + { kind: 'arrow_function' }, + { kind: 'function_expression' }, + ], + }, + }); + if (init) { + // Found a custom render function (arrow or expression) + processCustomRenderFunction(init, importedFunctions, edits, customRenderFunctionsToMakeAsync, rootNode); + } + } + } + } + } + } // Step 3: Process each function call for (const functionCall of functionCalls) { @@ -197,6 +263,60 @@ const transform: Transform = async (root) => { }); } + // Step 3.5: Transform calls to custom render functions in tests + if (customRenderFunctionsSet.size > 0) { + 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')) { + // Skip member expressions (e.g., obj.renderWithX()) + continue; + } + + if (calledFunctionName && customRenderFunctionsSet.has(calledFunctionName)) { + // Check if this call is inside a test function + const containingFunction = findContainingTestFunction(callExpr); + if (containingFunction) { + // Skip if already awaited + const parent = callExpr.parent(); + if (parent && parent.is('await_expression')) { + continue; + } + + // Track that the test function needs to be async + let isAsync = false; + if (containingFunction.is('arrow_function')) { + const children = containingFunction.children(); + isAsync = children.some((child) => child.text() === 'async'); + } else { + const funcStart = containingFunction.range().start.index; + const textBefore = rootNode.text().substring(Math.max(0, funcStart - 10), funcStart); + isAsync = textBefore.trim().endsWith('async'); + } + + if (!isAsync && !functionsToMakeAsync.has(containingFunction.id())) { + functionsToMakeAsync.set(containingFunction.id(), containingFunction); + } + + // Add await before the call + const callStart = callExpr.range().start.index; + edits.push({ + startPos: callStart, + endPos: callStart, + insertedText: 'await ', + }); + } + } + } + } + // Step 7: Add async keyword to functions that need it for (const func of functionsToMakeAsync.values()) { if (func.is('arrow_function')) { @@ -233,6 +353,43 @@ const transform: Transform = async (root) => { } } + // Step 7.5: Add async keyword to custom render functions that need it + for (const func of customRenderFunctionsToMakeAsync.values()) { + 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 ', + }); + } + } + } + + if (edits.length === 0) { + return null; // No changes needed + } + + // Sort edits by position (reverse order to avoid offset issues) + edits.sort((a, b) => b.startPos - a.startPos); + if (edits.length === 0) { return null; // No changes needed } @@ -243,6 +400,113 @@ const transform: Transform = async (root) => { return rootNode.commitEdits(edits); }; +/** + * Process a custom render function: find RNTL calls inside it and transform them + */ +function processCustomRenderFunction( + funcNode: SgNode, + importedFunctions: Set, + edits: Edit[], + customRenderFunctionsToMakeAsync: Map>, + rootNode: SgNode +): void { + // Find RNTL function calls inside this custom render function + const rntlCalls: SgNode[] = []; + + // Find standalone function calls (render, act, renderHook, fireEvent) + for (const funcName of importedFunctions) { + const calls = funcNode.findAll({ + rule: { + kind: 'call_expression', + has: { + field: 'function', + kind: 'identifier', + regex: `^${funcName}$`, + }, + }, + }); + rntlCalls.push(...calls); + } + + // Find fireEvent method 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.has(propText)) { + rntlCalls.push(call); + } + } + } catch { + // Skip if field() is not available + } + } + } + } + + // Process each RNTL call found inside the custom render function + let needsAsync = false; + for (const rntlCall of rntlCalls) { + // Skip if already awaited + const parent = rntlCall.parent(); + if (parent && parent.is('await_expression')) { + continue; + } + + // Skip variants that should not be transformed + const functionNode = rntlCall.field('function'); + if (functionNode) { + const funcName = functionNode.text(); + if (SKIP_VARIANTS.has(funcName)) { + continue; + } + } + + // Add await before the call + const callStart = rntlCall.range().start.index; + edits.push({ + startPos: callStart, + endPos: callStart, + insertedText: 'await ', + }); + needsAsync = true; + } + + // Track that this custom render function needs to be async + if (needsAsync && !customRenderFunctionsToMakeAsync.has(funcNode.id())) { + // Check if function is already async + let isAsync = false; + if (funcNode.is('arrow_function')) { + const children = funcNode.children(); + isAsync = children.some((child) => child.text() === 'async'); + } else { + const funcStart = funcNode.range().start.index; + const textBefore = rootNode.text().substring(Math.max(0, funcStart - 10), funcStart); + isAsync = textBefore.trim().endsWith('async'); + } + + if (!isAsync) { + customRenderFunctionsToMakeAsync.set(funcNode.id(), funcNode); + } + } +} + /** * Find the containing test function or hook callback (test/it/beforeEach/afterEach/etc.) for a given node */ @@ -275,7 +539,9 @@ function findContainingTestFunction(node: SgNode): SgNode | null { // Handle test.skip, it.skip, etc. (member expressions) if (funcNode.is('member_expression')) { try { + // @ts-expect-error - field() types are complex, but this works at runtime const object = funcNode.field('object'); + // @ts-expect-error - field() types are complex, but this works at runtime const property = funcNode.field('property'); if (object && property) { const objText = object.text(); @@ -301,7 +567,9 @@ function findContainingTestFunction(node: SgNode): SgNode | null { } if (funcNode.is('member_expression')) { try { + // @ts-expect-error - field() types are complex, but this works at runtime const object = funcNode.field('object'); + // @ts-expect-error - field() types are complex, but this works at runtime const property = funcNode.field('property'); if (object && property) { const objText = object.text(); diff --git a/codemods/v14-render-async/tests/fixtures/custom-render-function/expected.tsx b/codemods/v14-render-async/tests/fixtures/custom-render-function/expected.tsx new file mode 100644 index 000000000..9f04cfef3 --- /dev/null +++ b/codemods/v14-render-async/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-render-async/tests/fixtures/custom-render-function/input.tsx b/codemods/v14-render-async/tests/fixtures/custom-render-function/input.tsx new file mode 100644 index 000000000..7baf0cbfe --- /dev/null +++ b/codemods/v14-render-async/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(); +}); From ff555ba1c7af95eb497bfbff313c5765030e7eaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Tue, 6 Jan 2026 14:43:15 +0100 Subject: [PATCH 06/27] update deps --- codemods/v14-update-dependencies/.gitignore | 4 + codemods/v14-update-dependencies/README.md | 154 ++++++++++++++++ codemods/v14-update-dependencies/codemod.yaml | 20 +++ codemods/v14-update-dependencies/package.json | 11 ++ .../scripts/codemod.js | 140 +++++++++++++++ .../scripts/process-file.sh | 11 ++ .../v14-update-dependencies/scripts/test.js | 167 ++++++++++++++++++ .../fixtures/already-alpha/expected.json | 8 + .../tests/fixtures/already-alpha/input.json | 11 ++ .../tests/fixtures/basic-update/expected.json | 8 + .../tests/fixtures/basic-update/input.json | 11 ++ .../fixtures/move-from-deps/expected.json | 8 + .../tests/fixtures/move-from-deps/input.json | 10 ++ .../tests/fixtures/no-rntl/expected.json | 8 + .../tests/fixtures/no-rntl/input.json | 8 + .../fixtures/rntl-in-devdeps/expected.json | 1 + .../tests/fixtures/rntl-in-devdeps/input.json | 1 + .../fixtures/with-peer-deps/expected.json | 8 + .../tests/fixtures/with-peer-deps/input.json | 13 ++ .../v14-update-dependencies/workflow.yaml | 14 ++ 20 files changed, 616 insertions(+) create mode 100644 codemods/v14-update-dependencies/.gitignore create mode 100644 codemods/v14-update-dependencies/README.md create mode 100644 codemods/v14-update-dependencies/codemod.yaml create mode 100644 codemods/v14-update-dependencies/package.json create mode 100755 codemods/v14-update-dependencies/scripts/codemod.js create mode 100755 codemods/v14-update-dependencies/scripts/process-file.sh create mode 100755 codemods/v14-update-dependencies/scripts/test.js create mode 100644 codemods/v14-update-dependencies/tests/fixtures/already-alpha/expected.json create mode 100644 codemods/v14-update-dependencies/tests/fixtures/already-alpha/input.json create mode 100644 codemods/v14-update-dependencies/tests/fixtures/basic-update/expected.json create mode 100644 codemods/v14-update-dependencies/tests/fixtures/basic-update/input.json create mode 100644 codemods/v14-update-dependencies/tests/fixtures/move-from-deps/expected.json create mode 100644 codemods/v14-update-dependencies/tests/fixtures/move-from-deps/input.json create mode 100644 codemods/v14-update-dependencies/tests/fixtures/no-rntl/expected.json create mode 100644 codemods/v14-update-dependencies/tests/fixtures/no-rntl/input.json create mode 100644 codemods/v14-update-dependencies/tests/fixtures/rntl-in-devdeps/expected.json create mode 100644 codemods/v14-update-dependencies/tests/fixtures/rntl-in-devdeps/input.json create mode 100644 codemods/v14-update-dependencies/tests/fixtures/with-peer-deps/expected.json create mode 100644 codemods/v14-update-dependencies/tests/fixtures/with-peer-deps/input.json create mode 100644 codemods/v14-update-dependencies/workflow.yaml diff --git a/codemods/v14-update-dependencies/.gitignore b/codemods/v14-update-dependencies/.gitignore new file mode 100644 index 000000000..7588598f8 --- /dev/null +++ b/codemods/v14-update-dependencies/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +*.log +.DS_Store +tests/fixtures/*/temp.json diff --git a/codemods/v14-update-dependencies/README.md b/codemods/v14-update-dependencies/README.md new file mode 100644 index 000000000..d75da16ed --- /dev/null +++ b/codemods/v14-update-dependencies/README.md @@ -0,0 +1,154 @@ +# RNTL v14: Update Dependencies + +This codemod updates your `package.json` file to prepare for React Native Testing Library v14 migration by: + +- ✅ Removing `@types/react-test-renderer` (no longer needed) +- ✅ Removing `react-test-renderer` (replaced by universal-test-renderer) +- ✅ Moving `@testing-library/react-native` from `dependencies` to `devDependencies` if present +- ✅ Adding `@testing-library/react-native@^14.0.0-alpha` to `devDependencies` if not present +- ✅ Updating `@testing-library/react-native` to `^14.0.0-alpha` (latest alpha version) +- ✅ Adding `universal-test-renderer@0.10.1` to `devDependencies` (always added) + +## What it does + +This codemod automatically updates your `package.json` dependencies to match the requirements for RNTL v14. It: + +1. **Removes deprecated packages**: `@types/react-test-renderer` and `react-test-renderer` are removed from all dependency types (dependencies, devDependencies, peerDependencies, optionalDependencies) + +2. **Moves RNTL to devDependencies**: If `@testing-library/react-native` is in `dependencies`, it's moved to `devDependencies` + +3. **Ensures RNTL is present**: If `@testing-library/react-native` is not present, it's added to `devDependencies` with version `^14.0.0-alpha` + +4. **Updates RNTL version**: Updates `@testing-library/react-native` to `^14.0.0-alpha` to get the latest alpha version + +5. **Adds universal-test-renderer**: Always adds `universal-test-renderer@0.10.1` to `devDependencies` + +## Usage + +### Running the codemod + +```bash +# Run on your project +npx codemod@latest workflow run -w ./codemods/v14-update-dependencies/workflow.yaml --target ./path/to/your/project + +# Or if published to the registry +npx codemod@latest run @testing-library/react-native-v14-update-dependencies --target ./path/to/your/project +``` + +### Example transformations + +#### 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", + "universal-test-renderer": "0.10.1" + } +} +``` + +#### Moving from dependencies to devDependencies: + +**Before:** +```json +{ + "dependencies": { + "@testing-library/react-native": "^13.0.0" + } +} +``` + +**After:** +```json +{ + "devDependencies": { + "@testing-library/react-native": "^14.0.0-alpha", + "universal-test-renderer": "0.10.1" + } +} +``` + +#### Adding if not present: + +**Before:** +```json +{ + "devDependencies": { + "some-other-package": "^1.0.0" + } +} +``` + +**After:** +```json +{ + "devDependencies": { + "some-other-package": "^1.0.0", + "@testing-library/react-native": "^14.0.0-alpha", + "universal-test-renderer": "0.10.1" + } +} +``` + +## Important Notes + +1. **After running the codemod**, you'll need to run your package manager to install the new dependencies: + ```bash + npm install + # or + yarn install + # or + pnpm install + ``` + +2. **Version resolution**: The codemod sets `@testing-library/react-native` to `^14.0.0-alpha`, which will resolve to the latest alpha version available. You can manually update this to a specific version if needed. + +3. **Always adds packages**: The codemod always ensures both `@testing-library/react-native` and `universal-test-renderer` are present in `devDependencies`, even if they weren't there before. + +## Testing + +Run the test suite: + +```bash +cd codemods/v14-update-dependencies +npm test +``` + +## Limitations + +1. **Package manager**: The codemod updates `package.json` but doesn't run the package manager install command. You need to run `npm install` / `yarn install` / `pnpm install` manually after the codemod completes. + +2. **Version pinning**: If you have a specific alpha version pinned, the codemod will update it to the range `^14.0.0-alpha`. You may want to review and adjust the version after running the codemod. + +3. **Workspace projects**: For monorepos with multiple `package.json` files, the codemod will process each one individually. + +## Migration Guide + +1. **Run this codemod** to update your dependencies +2. **Run your package manager** to install the new dependencies: + ```bash + npm install + ``` +3. **Run the render-async codemod** to update your test code: + ```bash + npx codemod@latest run @testing-library/react-native-v14-render-async --target ./path/to/your/tests + ``` +4. **Review and test** your changes +5. **Update your RNTL version** to a specific alpha version if needed + +## Contributing + +If you find issues or have suggestions for improvements, please open an issue or submit a pull request to the React Native Testing Library repository. diff --git a/codemods/v14-update-dependencies/codemod.yaml b/codemods/v14-update-dependencies/codemod.yaml new file mode 100644 index 000000000..098b4a232 --- /dev/null +++ b/codemods/v14-update-dependencies/codemod.yaml @@ -0,0 +1,20 @@ +schema_version: "1.0" + +name: "@testing-library/react-native-v14-update-dependencies" +version: "0.1.0" +description: "Codemod to update dependencies for RNTL v14 migration" +author: "Callstack" +license: "MIT" +workflow: "workflow.yaml" + + +targets: + languages: ["json"] + +keywords: ["transformation", "migration", "dependencies"] + +registry: + access: "public" + visibility: "public" + +capabilities: [] diff --git a/codemods/v14-update-dependencies/package.json b/codemods/v14-update-dependencies/package.json new file mode 100644 index 000000000..1549e86f2 --- /dev/null +++ b/codemods/v14-update-dependencies/package.json @@ -0,0 +1,11 @@ +{ + "name": "@testing-library/react-native-v14-update-dependencies", + "version": "0.1.0", + "description": "Codemod to update dependencies for RNTL v14 migration", + "type": "module", + "scripts": { + "test": "node scripts/test.js" + }, + "devDependencies": {}, + "dependencies": {} +} diff --git a/codemods/v14-update-dependencies/scripts/codemod.js b/codemods/v14-update-dependencies/scripts/codemod.js new file mode 100755 index 000000000..f9bdcb5b4 --- /dev/null +++ b/codemods/v14-update-dependencies/scripts/codemod.js @@ -0,0 +1,140 @@ +#!/usr/bin/env node + +/** + * Codemod to update package.json dependencies for RNTL v14 migration: + * - Removes @types/react-test-renderer + * - Removes react-test-renderer + * - Adds universal-test-renderer + * - Updates @testing-library/react-native to latest alpha version + */ + +import { readFileSync, writeFileSync } from 'fs'; + +// Version constants - adjust these to update versions +const RNTL_VERSION = '^14.0.0-alpha'; +const UNIVERSAL_TEST_RENDERER_VERSION = '0.10.1'; + +// Get the file path from command line arguments +const filePath = process.argv[2]; + +if (!filePath) { + console.error('Error: No file path provided'); + process.exit(1); +} + +try { + // Read package.json + const packageJsonContent = readFileSync(filePath, 'utf8'); + const packageJson = JSON.parse(packageJsonContent); + + let hasChanges = false; + + // Function to remove a package from dependencies, devDependencies, or peerDependencies + const removePackage = (pkgName, pkgJson) => { + let removed = false; + ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'].forEach((depType) => { + if (pkgJson[depType] && pkgJson[depType][pkgName]) { + delete pkgJson[depType][pkgName]; + removed = true; + hasChanges = true; + // Remove the entire depType object if it's empty + if (Object.keys(pkgJson[depType]).length === 0) { + delete pkgJson[depType]; + } + } + }); + return removed; + }; + + // Function to add or update a package in dependencies or devDependencies + const addOrUpdatePackage = (pkgName, version, pkgJson, isDevDep = false) => { + const depType = isDevDep ? 'devDependencies' : 'dependencies'; + if (!pkgJson[depType]) { + pkgJson[depType] = {}; + } + const currentVersion = pkgJson[depType][pkgName]; + if (currentVersion !== version) { + pkgJson[depType][pkgName] = version; + hasChanges = true; + return true; + } + return false; + }; + + // Remove @types/react-test-renderer + if (removePackage('@types/react-test-renderer', packageJson)) { + console.log(`Removed @types/react-test-renderer from ${filePath}`); + } + + // Remove react-test-renderer + if (removePackage('react-test-renderer', packageJson)) { + console.log(`Removed react-test-renderer from ${filePath}`); + } + + // Ensure devDependencies exists + if (!packageJson.devDependencies) { + packageJson.devDependencies = {}; + hasChanges = true; + } + + // Handle @testing-library/react-native + const rntlInDeps = packageJson.dependencies && packageJson.dependencies['@testing-library/react-native']; + const rntlInDevDeps = packageJson.devDependencies['@testing-library/react-native']; + const rntlVersion = rntlInDeps || rntlInDevDeps; + + // If RNTL is in dependencies, move it to devDependencies + if (rntlInDeps) { + const version = packageJson.dependencies['@testing-library/react-native']; + delete packageJson.dependencies['@testing-library/react-native']; + if (Object.keys(packageJson.dependencies).length === 0) { + delete packageJson.dependencies; + } + packageJson.devDependencies['@testing-library/react-native'] = version; + hasChanges = true; + console.log(`Moved @testing-library/react-native from dependencies to devDependencies in ${filePath}`); + } + + // Always ensure @testing-library/react-native is in devDependencies + if (!packageJson.devDependencies['@testing-library/react-native']) { + // Add if not present + packageJson.devDependencies['@testing-library/react-native'] = RNTL_VERSION; + hasChanges = true; + console.log(`Added @testing-library/react-native@${RNTL_VERSION} to devDependencies in ${filePath}`); + } else { + // Update existing version to alpha if needed + const currentVersion = packageJson.devDependencies['@testing-library/react-native']; + if (!currentVersion.includes('alpha') && !currentVersion.includes('beta') && !currentVersion.includes('rc')) { + packageJson.devDependencies['@testing-library/react-native'] = RNTL_VERSION; + hasChanges = true; + console.log(`Updated @testing-library/react-native to ${RNTL_VERSION} in ${filePath}`); + } else if (currentVersion.includes('alpha') && currentVersion !== RNTL_VERSION) { + // Normalize alpha versions to the range + packageJson.devDependencies['@testing-library/react-native'] = RNTL_VERSION; + hasChanges = true; + console.log(`Updated @testing-library/react-native to ${RNTL_VERSION} in ${filePath}`); + } + } + + // Always ensure universal-test-renderer is in devDependencies + if (!packageJson.devDependencies['universal-test-renderer']) { + packageJson.devDependencies['universal-test-renderer'] = UNIVERSAL_TEST_RENDERER_VERSION; + hasChanges = true; + console.log(`Added universal-test-renderer@${UNIVERSAL_TEST_RENDERER_VERSION} to devDependencies in ${filePath}`); + } else if (packageJson.devDependencies['universal-test-renderer'] !== UNIVERSAL_TEST_RENDERER_VERSION) { + packageJson.devDependencies['universal-test-renderer'] = UNIVERSAL_TEST_RENDERER_VERSION; + hasChanges = true; + console.log(`Updated universal-test-renderer to ${UNIVERSAL_TEST_RENDERER_VERSION} in ${filePath}`); + } + + // Write back the updated package.json if there were changes + if (hasChanges) { + const updatedContent = JSON.stringify(packageJson, null, 2) + '\n'; + writeFileSync(filePath, updatedContent, 'utf8'); + console.log(`Updated ${filePath}`); + } else { + console.log(`No changes needed for ${filePath}`); + } +} catch (error) { + console.error(`Error processing ${filePath}:`, error.message); + process.exit(1); +} diff --git a/codemods/v14-update-dependencies/scripts/process-file.sh b/codemods/v14-update-dependencies/scripts/process-file.sh new file mode 100755 index 000000000..0cd9ddbf9 --- /dev/null +++ b/codemods/v14-update-dependencies/scripts/process-file.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +# Process a single package.json file +FILE="$1" + +if [ ! -f "$FILE" ]; then + echo "Error: File $FILE does not exist" + exit 1 +fi + +node scripts/codemod.js "$FILE" diff --git a/codemods/v14-update-dependencies/scripts/test.js b/codemods/v14-update-dependencies/scripts/test.js new file mode 100755 index 000000000..37ce3a546 --- /dev/null +++ b/codemods/v14-update-dependencies/scripts/test.js @@ -0,0 +1,167 @@ +#!/usr/bin/env node + +/** + * Test script for the package.json update codemod + */ + +import { readFileSync, writeFileSync, existsSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const codemodScript = join(__dirname, 'codemod.js'); +const fixturesDir = join(__dirname, '..', 'tests', 'fixtures'); + +// Version constants - must match codemod.js +const RNTL_VERSION = '^14.0.0-alpha'; +const UNIVERSAL_TEST_RENDERER_VERSION = '0.10.1'; + +// Import the codemod logic +async function runCodemod(filePath) { + const { readFileSync, writeFileSync } = await import('fs'); + const packageJsonContent = readFileSync(filePath, 'utf8'); + const packageJson = JSON.parse(packageJsonContent); + + let hasChanges = false; + + const removePackage = (pkgName, pkgJson) => { + let removed = false; + ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'].forEach((depType) => { + if (pkgJson[depType] && pkgJson[depType][pkgName]) { + delete pkgJson[depType][pkgName]; + removed = true; + hasChanges = true; + if (Object.keys(pkgJson[depType]).length === 0) { + delete pkgJson[depType]; + } + } + }); + return removed; + }; + + const addOrUpdatePackage = (pkgName, version, pkgJson, isDevDep = false) => { + const depType = isDevDep ? 'devDependencies' : 'dependencies'; + if (!pkgJson[depType]) { + pkgJson[depType] = {}; + } + const currentVersion = pkgJson[depType][pkgName]; + if (currentVersion !== version) { + pkgJson[depType][pkgName] = version; + hasChanges = true; + return true; + } + return false; + }; + + removePackage('@types/react-test-renderer', packageJson); + removePackage('react-test-renderer', packageJson); + + // Ensure devDependencies exists + if (!packageJson.devDependencies) { + packageJson.devDependencies = {}; + hasChanges = true; + } + + // Handle @testing-library/react-native + const rntlInDeps = packageJson.dependencies && packageJson.dependencies['@testing-library/react-native']; + const rntlInDevDeps = packageJson.devDependencies['@testing-library/react-native']; + + // If RNTL is in dependencies, move it to devDependencies + if (rntlInDeps) { + const version = packageJson.dependencies['@testing-library/react-native']; + delete packageJson.dependencies['@testing-library/react-native']; + if (Object.keys(packageJson.dependencies).length === 0) { + delete packageJson.dependencies; + } + packageJson.devDependencies['@testing-library/react-native'] = version; + hasChanges = true; + } + + // Always ensure @testing-library/react-native is in devDependencies + if (!packageJson.devDependencies['@testing-library/react-native']) { + packageJson.devDependencies['@testing-library/react-native'] = RNTL_VERSION; + hasChanges = true; + } else { + const currentVersion = packageJson.devDependencies['@testing-library/react-native']; + if (!currentVersion.includes('alpha') && !currentVersion.includes('beta') && !currentVersion.includes('rc')) { + packageJson.devDependencies['@testing-library/react-native'] = RNTL_VERSION; + hasChanges = true; + } else if (currentVersion.includes('alpha') && currentVersion !== RNTL_VERSION) { + packageJson.devDependencies['@testing-library/react-native'] = RNTL_VERSION; + hasChanges = true; + } + } + + // Always ensure universal-test-renderer is in devDependencies + if (!packageJson.devDependencies['universal-test-renderer']) { + packageJson.devDependencies['universal-test-renderer'] = UNIVERSAL_TEST_RENDERER_VERSION; + hasChanges = true; + } else if (packageJson.devDependencies['universal-test-renderer'] !== UNIVERSAL_TEST_RENDERER_VERSION) { + packageJson.devDependencies['universal-test-renderer'] = UNIVERSAL_TEST_RENDERER_VERSION; + hasChanges = true; + } + + if (hasChanges) { + return JSON.stringify(packageJson, null, 2) + '\n'; + } + return packageJsonContent; +} + +// Test each fixture +import { readdirSync } from 'fs'; +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 + const tempPath = join(fixturesDir, testCase, 'temp.json'); + writeFileSync(tempPath, inputContent, 'utf8'); + + // Run codemod + const result = await runCodemod(tempPath); + + // Compare results + const expectedJson = JSON.parse(expectedContent); + const resultJson = JSON.parse(result); + + 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 + if (existsSync(tempPath)) { + const { unlinkSync } = await import('fs'); + unlinkSync(tempPath); + } + } 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-dependencies/tests/fixtures/already-alpha/expected.json b/codemods/v14-update-dependencies/tests/fixtures/already-alpha/expected.json new file mode 100644 index 000000000..09f2ccd3f --- /dev/null +++ b/codemods/v14-update-dependencies/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", + "universal-test-renderer": "0.10.1" + } +} diff --git a/codemods/v14-update-dependencies/tests/fixtures/already-alpha/input.json b/codemods/v14-update-dependencies/tests/fixtures/already-alpha/input.json new file mode 100644 index 000000000..7f2f64bc7 --- /dev/null +++ b/codemods/v14-update-dependencies/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-dependencies/tests/fixtures/basic-update/expected.json b/codemods/v14-update-dependencies/tests/fixtures/basic-update/expected.json new file mode 100644 index 000000000..09f2ccd3f --- /dev/null +++ b/codemods/v14-update-dependencies/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", + "universal-test-renderer": "0.10.1" + } +} diff --git a/codemods/v14-update-dependencies/tests/fixtures/basic-update/input.json b/codemods/v14-update-dependencies/tests/fixtures/basic-update/input.json new file mode 100644 index 000000000..135395224 --- /dev/null +++ b/codemods/v14-update-dependencies/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-dependencies/tests/fixtures/move-from-deps/expected.json b/codemods/v14-update-dependencies/tests/fixtures/move-from-deps/expected.json new file mode 100644 index 000000000..09f2ccd3f --- /dev/null +++ b/codemods/v14-update-dependencies/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", + "universal-test-renderer": "0.10.1" + } +} diff --git a/codemods/v14-update-dependencies/tests/fixtures/move-from-deps/input.json b/codemods/v14-update-dependencies/tests/fixtures/move-from-deps/input.json new file mode 100644 index 000000000..bc0e5685f --- /dev/null +++ b/codemods/v14-update-dependencies/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-dependencies/tests/fixtures/no-rntl/expected.json b/codemods/v14-update-dependencies/tests/fixtures/no-rntl/expected.json new file mode 100644 index 000000000..09f2ccd3f --- /dev/null +++ b/codemods/v14-update-dependencies/tests/fixtures/no-rntl/expected.json @@ -0,0 +1,8 @@ +{ + "name": "test-project", + "version": "1.0.0", + "devDependencies": { + "@testing-library/react-native": "^14.0.0-alpha", + "universal-test-renderer": "0.10.1" + } +} diff --git a/codemods/v14-update-dependencies/tests/fixtures/no-rntl/input.json b/codemods/v14-update-dependencies/tests/fixtures/no-rntl/input.json new file mode 100644 index 000000000..47de196a8 --- /dev/null +++ b/codemods/v14-update-dependencies/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-dependencies/tests/fixtures/rntl-in-devdeps/expected.json b/codemods/v14-update-dependencies/tests/fixtures/rntl-in-devdeps/expected.json new file mode 100644 index 000000000..7d7088b57 --- /dev/null +++ b/codemods/v14-update-dependencies/tests/fixtures/rntl-in-devdeps/expected.json @@ -0,0 +1 @@ +{"devDependencies":{"@testing-library/react-native":"^14.0.0-alpha","universal-test-renderer":"0.10.1"}} diff --git a/codemods/v14-update-dependencies/tests/fixtures/rntl-in-devdeps/input.json b/codemods/v14-update-dependencies/tests/fixtures/rntl-in-devdeps/input.json new file mode 100644 index 000000000..a74ab959d --- /dev/null +++ b/codemods/v14-update-dependencies/tests/fixtures/rntl-in-devdeps/input.json @@ -0,0 +1 @@ +{"devDependencies":{"@testing-library/react-native":"^13.0.0","react-test-renderer":"^18.0.0"}} diff --git a/codemods/v14-update-dependencies/tests/fixtures/with-peer-deps/expected.json b/codemods/v14-update-dependencies/tests/fixtures/with-peer-deps/expected.json new file mode 100644 index 000000000..09f2ccd3f --- /dev/null +++ b/codemods/v14-update-dependencies/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", + "universal-test-renderer": "0.10.1" + } +} diff --git a/codemods/v14-update-dependencies/tests/fixtures/with-peer-deps/input.json b/codemods/v14-update-dependencies/tests/fixtures/with-peer-deps/input.json new file mode 100644 index 000000000..860d699d4 --- /dev/null +++ b/codemods/v14-update-dependencies/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-dependencies/workflow.yaml b/codemods/v14-update-dependencies/workflow.yaml new file mode 100644 index 000000000..241d26cec --- /dev/null +++ b/codemods/v14-update-dependencies/workflow.yaml @@ -0,0 +1,14 @@ +# 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" + command: | + find . -name "package.json" -not -path "*/node_modules/*" -not -path "*/build/*" -not -path "*/dist/*" -not -path "*/.next/*" -not -path "*/coverage/*" -not -path "*/.yarn/*" | while read -r file; do + node "$(dirname "$0")/scripts/codemod.js" "$file" || true + done From 312669ee80650064d49cab0378a77576631576c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Tue, 6 Jan 2026 14:49:59 +0100 Subject: [PATCH 07/27] update deps wip --- .../scripts/codemod-json.js | 108 ++++++++++++++++++ .../v14-update-dependencies/workflow.yaml | 16 ++- 2 files changed, 120 insertions(+), 4 deletions(-) create mode 100644 codemods/v14-update-dependencies/scripts/codemod-json.js diff --git a/codemods/v14-update-dependencies/scripts/codemod-json.js b/codemods/v14-update-dependencies/scripts/codemod-json.js new file mode 100644 index 000000000..dd2412ad5 --- /dev/null +++ b/codemods/v14-update-dependencies/scripts/codemod-json.js @@ -0,0 +1,108 @@ +#!/usr/bin/env node + +/** + * JavaScript codemod for updating package.json files + * This version works with the codemod CLI workflow system + */ + +// Version constants - adjust these to update versions +const RNTL_VERSION = '^14.0.0-alpha'; +const UNIVERSAL_TEST_RENDERER_VERSION = '0.10.1'; + +// This will be called by the codemod platform for each file +async function transform(root) { + const filename = root.filename(); + + // Only process package.json files + if (!filename.endsWith('package.json')) { + return null; + } + + try { + // Read the file content + const content = root.root().text(); + const packageJson = JSON.parse(content); + + let hasChanges = false; + + // Function to remove a package from dependencies, devDependencies, or peerDependencies + const removePackage = (pkgName, pkgJson) => { + let removed = false; + ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'].forEach((depType) => { + if (pkgJson[depType] && pkgJson[depType][pkgName]) { + delete pkgJson[depType][pkgName]; + removed = true; + hasChanges = true; + // Remove the entire depType object if it's empty + if (Object.keys(pkgJson[depType]).length === 0) { + delete pkgJson[depType]; + } + } + }); + return removed; + }; + + // Remove @types/react-test-renderer + removePackage('@types/react-test-renderer', packageJson); + + // Remove react-test-renderer + removePackage('react-test-renderer', packageJson); + + // Ensure devDependencies exists + if (!packageJson.devDependencies) { + packageJson.devDependencies = {}; + hasChanges = true; + } + + // Handle @testing-library/react-native + const rntlInDeps = packageJson.dependencies && packageJson.dependencies['@testing-library/react-native']; + const rntlInDevDeps = packageJson.devDependencies['@testing-library/react-native']; + + // If RNTL is in dependencies, move it to devDependencies + if (rntlInDeps) { + const version = packageJson.dependencies['@testing-library/react-native']; + delete packageJson.dependencies['@testing-library/react-native']; + if (Object.keys(packageJson.dependencies).length === 0) { + delete packageJson.dependencies; + } + packageJson.devDependencies['@testing-library/react-native'] = version; + hasChanges = true; + } + + // Always ensure @testing-library/react-native is in devDependencies + if (!packageJson.devDependencies['@testing-library/react-native']) { + packageJson.devDependencies['@testing-library/react-native'] = RNTL_VERSION; + hasChanges = true; + } else { + const currentVersion = packageJson.devDependencies['@testing-library/react-native']; + if (!currentVersion.includes('alpha') && !currentVersion.includes('beta') && !currentVersion.includes('rc')) { + packageJson.devDependencies['@testing-library/react-native'] = RNTL_VERSION; + hasChanges = true; + } else if (currentVersion.includes('alpha') && currentVersion !== RNTL_VERSION) { + packageJson.devDependencies['@testing-library/react-native'] = RNTL_VERSION; + hasChanges = true; + } + } + + // Always ensure universal-test-renderer is in devDependencies + if (!packageJson.devDependencies['universal-test-renderer']) { + packageJson.devDependencies['universal-test-renderer'] = UNIVERSAL_TEST_RENDERER_VERSION; + hasChanges = true; + } else if (packageJson.devDependencies['universal-test-renderer'] !== UNIVERSAL_TEST_RENDERER_VERSION) { + packageJson.devDependencies['universal-test-renderer'] = UNIVERSAL_TEST_RENDERER_VERSION; + hasChanges = true; + } + + // Return the updated content if there were changes + if (hasChanges) { + return JSON.stringify(packageJson, null, 2) + '\n'; + } + + return null; // No changes + } catch (error) { + console.error(`Error processing ${filename}:`, error.message); + return null; + } +} + +export default transform; diff --git a/codemods/v14-update-dependencies/workflow.yaml b/codemods/v14-update-dependencies/workflow.yaml index 241d26cec..ec6c47352 100644 --- a/codemods/v14-update-dependencies/workflow.yaml +++ b/codemods/v14-update-dependencies/workflow.yaml @@ -8,7 +8,15 @@ nodes: type: automatic steps: - name: "Update dependencies in package.json" - command: | - find . -name "package.json" -not -path "*/node_modules/*" -not -path "*/build/*" -not -path "*/dist/*" -not -path "*/.next/*" -not -path "*/coverage/*" -not -path "*/.yarn/*" | while read -r file; do - node "$(dirname "$0")/scripts/codemod.js" "$file" || true - done + js-ast-grep: + js_file: scripts/codemod-json.js + language: json + include: + - "**/package.json" + exclude: + - "**/node_modules/**" + - "**/build/**" + - "**/dist/**" + - "**/.next/**" + - "**/coverage/**" + - "**/.yarn/**" From a4a09fd7896d08df25b3d866e83f2e01849ebfcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Tue, 6 Jan 2026 14:53:24 +0100 Subject: [PATCH 08/27] wip --- .../scripts/codemod-json.js | 27 +++- .../scripts/codemod.js | 140 ------------------ .../scripts/process-file.sh | 11 -- .../v14-update-dependencies/scripts/test.js | 111 +++----------- .../fixtures/no-rntl-or-utr/expected.json | 11 ++ .../tests/fixtures/no-rntl-or-utr/input.json | 11 ++ .../tests/fixtures/no-rntl/expected.json | 4 +- 7 files changed, 67 insertions(+), 248 deletions(-) delete mode 100755 codemods/v14-update-dependencies/scripts/codemod.js delete mode 100755 codemods/v14-update-dependencies/scripts/process-file.sh create mode 100644 codemods/v14-update-dependencies/tests/fixtures/no-rntl-or-utr/expected.json create mode 100644 codemods/v14-update-dependencies/tests/fixtures/no-rntl-or-utr/input.json diff --git a/codemods/v14-update-dependencies/scripts/codemod-json.js b/codemods/v14-update-dependencies/scripts/codemod-json.js index dd2412ad5..8eede4e45 100644 --- a/codemods/v14-update-dependencies/scripts/codemod-json.js +++ b/codemods/v14-update-dependencies/scripts/codemod-json.js @@ -1,8 +1,14 @@ #!/usr/bin/env node /** - * JavaScript codemod for updating package.json files - * This version works with the codemod CLI workflow system + * Codemod to update package.json dependencies for RNTL v14 migration: + * - Removes @types/react-test-renderer + * - Removes react-test-renderer + * - Moves @testing-library/react-native from dependencies to devDependencies if present + * - Adds/updates @testing-library/react-native to ^14.0.0-alpha in devDependencies + * - Adds/updates universal-test-renderer@0.10.1 to devDependencies + * + * Only processes package.json files that already contain RNTL or UTR. */ // Version constants - adjust these to update versions @@ -42,6 +48,23 @@ async function transform(root) { return removed; }; + // Check if RNTL or UTR already exists in the package.json + // Only proceed if at least one of them is present + const hasRNTL = + (packageJson.dependencies && packageJson.dependencies['@testing-library/react-native']) || + (packageJson.devDependencies && packageJson.devDependencies['@testing-library/react-native']) || + (packageJson.peerDependencies && packageJson.peerDependencies['@testing-library/react-native']); + + const hasUTR = + (packageJson.dependencies && packageJson.dependencies['universal-test-renderer']) || + (packageJson.devDependencies && packageJson.devDependencies['universal-test-renderer']) || + (packageJson.peerDependencies && packageJson.peerDependencies['universal-test-renderer']); + + // Skip this file if neither RNTL nor UTR is present + if (!hasRNTL && !hasUTR) { + return null; // No changes - skip this file + } + // Remove @types/react-test-renderer removePackage('@types/react-test-renderer', packageJson); diff --git a/codemods/v14-update-dependencies/scripts/codemod.js b/codemods/v14-update-dependencies/scripts/codemod.js deleted file mode 100755 index f9bdcb5b4..000000000 --- a/codemods/v14-update-dependencies/scripts/codemod.js +++ /dev/null @@ -1,140 +0,0 @@ -#!/usr/bin/env node - -/** - * Codemod to update package.json dependencies for RNTL v14 migration: - * - Removes @types/react-test-renderer - * - Removes react-test-renderer - * - Adds universal-test-renderer - * - Updates @testing-library/react-native to latest alpha version - */ - -import { readFileSync, writeFileSync } from 'fs'; - -// Version constants - adjust these to update versions -const RNTL_VERSION = '^14.0.0-alpha'; -const UNIVERSAL_TEST_RENDERER_VERSION = '0.10.1'; - -// Get the file path from command line arguments -const filePath = process.argv[2]; - -if (!filePath) { - console.error('Error: No file path provided'); - process.exit(1); -} - -try { - // Read package.json - const packageJsonContent = readFileSync(filePath, 'utf8'); - const packageJson = JSON.parse(packageJsonContent); - - let hasChanges = false; - - // Function to remove a package from dependencies, devDependencies, or peerDependencies - const removePackage = (pkgName, pkgJson) => { - let removed = false; - ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'].forEach((depType) => { - if (pkgJson[depType] && pkgJson[depType][pkgName]) { - delete pkgJson[depType][pkgName]; - removed = true; - hasChanges = true; - // Remove the entire depType object if it's empty - if (Object.keys(pkgJson[depType]).length === 0) { - delete pkgJson[depType]; - } - } - }); - return removed; - }; - - // Function to add or update a package in dependencies or devDependencies - const addOrUpdatePackage = (pkgName, version, pkgJson, isDevDep = false) => { - const depType = isDevDep ? 'devDependencies' : 'dependencies'; - if (!pkgJson[depType]) { - pkgJson[depType] = {}; - } - const currentVersion = pkgJson[depType][pkgName]; - if (currentVersion !== version) { - pkgJson[depType][pkgName] = version; - hasChanges = true; - return true; - } - return false; - }; - - // Remove @types/react-test-renderer - if (removePackage('@types/react-test-renderer', packageJson)) { - console.log(`Removed @types/react-test-renderer from ${filePath}`); - } - - // Remove react-test-renderer - if (removePackage('react-test-renderer', packageJson)) { - console.log(`Removed react-test-renderer from ${filePath}`); - } - - // Ensure devDependencies exists - if (!packageJson.devDependencies) { - packageJson.devDependencies = {}; - hasChanges = true; - } - - // Handle @testing-library/react-native - const rntlInDeps = packageJson.dependencies && packageJson.dependencies['@testing-library/react-native']; - const rntlInDevDeps = packageJson.devDependencies['@testing-library/react-native']; - const rntlVersion = rntlInDeps || rntlInDevDeps; - - // If RNTL is in dependencies, move it to devDependencies - if (rntlInDeps) { - const version = packageJson.dependencies['@testing-library/react-native']; - delete packageJson.dependencies['@testing-library/react-native']; - if (Object.keys(packageJson.dependencies).length === 0) { - delete packageJson.dependencies; - } - packageJson.devDependencies['@testing-library/react-native'] = version; - hasChanges = true; - console.log(`Moved @testing-library/react-native from dependencies to devDependencies in ${filePath}`); - } - - // Always ensure @testing-library/react-native is in devDependencies - if (!packageJson.devDependencies['@testing-library/react-native']) { - // Add if not present - packageJson.devDependencies['@testing-library/react-native'] = RNTL_VERSION; - hasChanges = true; - console.log(`Added @testing-library/react-native@${RNTL_VERSION} to devDependencies in ${filePath}`); - } else { - // Update existing version to alpha if needed - const currentVersion = packageJson.devDependencies['@testing-library/react-native']; - if (!currentVersion.includes('alpha') && !currentVersion.includes('beta') && !currentVersion.includes('rc')) { - packageJson.devDependencies['@testing-library/react-native'] = RNTL_VERSION; - hasChanges = true; - console.log(`Updated @testing-library/react-native to ${RNTL_VERSION} in ${filePath}`); - } else if (currentVersion.includes('alpha') && currentVersion !== RNTL_VERSION) { - // Normalize alpha versions to the range - packageJson.devDependencies['@testing-library/react-native'] = RNTL_VERSION; - hasChanges = true; - console.log(`Updated @testing-library/react-native to ${RNTL_VERSION} in ${filePath}`); - } - } - - // Always ensure universal-test-renderer is in devDependencies - if (!packageJson.devDependencies['universal-test-renderer']) { - packageJson.devDependencies['universal-test-renderer'] = UNIVERSAL_TEST_RENDERER_VERSION; - hasChanges = true; - console.log(`Added universal-test-renderer@${UNIVERSAL_TEST_RENDERER_VERSION} to devDependencies in ${filePath}`); - } else if (packageJson.devDependencies['universal-test-renderer'] !== UNIVERSAL_TEST_RENDERER_VERSION) { - packageJson.devDependencies['universal-test-renderer'] = UNIVERSAL_TEST_RENDERER_VERSION; - hasChanges = true; - console.log(`Updated universal-test-renderer to ${UNIVERSAL_TEST_RENDERER_VERSION} in ${filePath}`); - } - - // Write back the updated package.json if there were changes - if (hasChanges) { - const updatedContent = JSON.stringify(packageJson, null, 2) + '\n'; - writeFileSync(filePath, updatedContent, 'utf8'); - console.log(`Updated ${filePath}`); - } else { - console.log(`No changes needed for ${filePath}`); - } -} catch (error) { - console.error(`Error processing ${filePath}:`, error.message); - process.exit(1); -} diff --git a/codemods/v14-update-dependencies/scripts/process-file.sh b/codemods/v14-update-dependencies/scripts/process-file.sh deleted file mode 100755 index 0cd9ddbf9..000000000 --- a/codemods/v14-update-dependencies/scripts/process-file.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/bash - -# Process a single package.json file -FILE="$1" - -if [ ! -f "$FILE" ]; then - echo "Error: File $FILE does not exist" - exit 1 -fi - -node scripts/codemod.js "$FILE" diff --git a/codemods/v14-update-dependencies/scripts/test.js b/codemods/v14-update-dependencies/scripts/test.js index 37ce3a546..c797194a8 100755 --- a/codemods/v14-update-dependencies/scripts/test.js +++ b/codemods/v14-update-dependencies/scripts/test.js @@ -4,113 +4,35 @@ * Test script for the package.json update codemod */ -import { readFileSync, writeFileSync, existsSync } from 'fs'; +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 codemodScript = join(__dirname, 'codemod.js'); const fixturesDir = join(__dirname, '..', 'tests', 'fixtures'); -// Version constants - must match codemod.js -const RNTL_VERSION = '^14.0.0-alpha'; -const UNIVERSAL_TEST_RENDERER_VERSION = '0.10.1'; - // Import the codemod logic async function runCodemod(filePath) { - const { readFileSync, writeFileSync } = await import('fs'); + const { readFileSync } = await import('fs'); + const { default: transform } = await import('./codemod-json.js'); + + // Mock the codemod platform root object const packageJsonContent = readFileSync(filePath, 'utf8'); - const packageJson = JSON.parse(packageJsonContent); - - let hasChanges = false; - - const removePackage = (pkgName, pkgJson) => { - let removed = false; - ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'].forEach((depType) => { - if (pkgJson[depType] && pkgJson[depType][pkgName]) { - delete pkgJson[depType][pkgName]; - removed = true; - hasChanges = true; - if (Object.keys(pkgJson[depType]).length === 0) { - delete pkgJson[depType]; - } - } - }); - return removed; + const mockRoot = { + filename: () => filePath, + root: () => ({ + text: () => packageJsonContent + }) }; - - const addOrUpdatePackage = (pkgName, version, pkgJson, isDevDep = false) => { - const depType = isDevDep ? 'devDependencies' : 'dependencies'; - if (!pkgJson[depType]) { - pkgJson[depType] = {}; - } - const currentVersion = pkgJson[depType][pkgName]; - if (currentVersion !== version) { - pkgJson[depType][pkgName] = version; - hasChanges = true; - return true; - } - return false; - }; - - removePackage('@types/react-test-renderer', packageJson); - removePackage('react-test-renderer', packageJson); - - // Ensure devDependencies exists - if (!packageJson.devDependencies) { - packageJson.devDependencies = {}; - hasChanges = true; - } - - // Handle @testing-library/react-native - const rntlInDeps = packageJson.dependencies && packageJson.dependencies['@testing-library/react-native']; - const rntlInDevDeps = packageJson.devDependencies['@testing-library/react-native']; - - // If RNTL is in dependencies, move it to devDependencies - if (rntlInDeps) { - const version = packageJson.dependencies['@testing-library/react-native']; - delete packageJson.dependencies['@testing-library/react-native']; - if (Object.keys(packageJson.dependencies).length === 0) { - delete packageJson.dependencies; - } - packageJson.devDependencies['@testing-library/react-native'] = version; - hasChanges = true; - } - - // Always ensure @testing-library/react-native is in devDependencies - if (!packageJson.devDependencies['@testing-library/react-native']) { - packageJson.devDependencies['@testing-library/react-native'] = RNTL_VERSION; - hasChanges = true; - } else { - const currentVersion = packageJson.devDependencies['@testing-library/react-native']; - if (!currentVersion.includes('alpha') && !currentVersion.includes('beta') && !currentVersion.includes('rc')) { - packageJson.devDependencies['@testing-library/react-native'] = RNTL_VERSION; - hasChanges = true; - } else if (currentVersion.includes('alpha') && currentVersion !== RNTL_VERSION) { - packageJson.devDependencies['@testing-library/react-native'] = RNTL_VERSION; - hasChanges = true; - } - } - - // Always ensure universal-test-renderer is in devDependencies - if (!packageJson.devDependencies['universal-test-renderer']) { - packageJson.devDependencies['universal-test-renderer'] = UNIVERSAL_TEST_RENDERER_VERSION; - hasChanges = true; - } else if (packageJson.devDependencies['universal-test-renderer'] !== UNIVERSAL_TEST_RENDERER_VERSION) { - packageJson.devDependencies['universal-test-renderer'] = UNIVERSAL_TEST_RENDERER_VERSION; - hasChanges = true; - } - - if (hasChanges) { - return JSON.stringify(packageJson, null, 2) + '\n'; - } - return packageJsonContent; + + const result = await transform(mockRoot); + // Return result or original content if null (no changes) + return result || packageJsonContent; } // Test each fixture -import { readdirSync } from 'fs'; const testCases = readdirSync(fixturesDir); let passed = 0; @@ -136,9 +58,12 @@ for (const testCase of testCases) { // 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(result); + const resultJson = JSON.parse(resultContent); if (JSON.stringify(expectedJson, null, 2) === JSON.stringify(resultJson, null, 2)) { console.log(`✅ ${testCase}: PASSED`); diff --git a/codemods/v14-update-dependencies/tests/fixtures/no-rntl-or-utr/expected.json b/codemods/v14-update-dependencies/tests/fixtures/no-rntl-or-utr/expected.json new file mode 100644 index 000000000..8457b20f0 --- /dev/null +++ b/codemods/v14-update-dependencies/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-dependencies/tests/fixtures/no-rntl-or-utr/input.json b/codemods/v14-update-dependencies/tests/fixtures/no-rntl-or-utr/input.json new file mode 100644 index 000000000..8457b20f0 --- /dev/null +++ b/codemods/v14-update-dependencies/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-dependencies/tests/fixtures/no-rntl/expected.json b/codemods/v14-update-dependencies/tests/fixtures/no-rntl/expected.json index 09f2ccd3f..47de196a8 100644 --- a/codemods/v14-update-dependencies/tests/fixtures/no-rntl/expected.json +++ b/codemods/v14-update-dependencies/tests/fixtures/no-rntl/expected.json @@ -2,7 +2,7 @@ "name": "test-project", "version": "1.0.0", "devDependencies": { - "@testing-library/react-native": "^14.0.0-alpha", - "universal-test-renderer": "0.10.1" + "@types/react-test-renderer": "^18.0.0", + "react-test-renderer": "^18.0.0" } } From fe107e3a98d642515523726bf8be5721219ca2d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Tue, 6 Jan 2026 15:06:05 +0100 Subject: [PATCH 09/27] yarn config --- .yarnrc.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 330b8800bb2349f7dc7815a78fbcf6d72a497ddc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Tue, 6 Jan 2026 16:24:37 +0100 Subject: [PATCH 10/27] custom function fixed --- codemods/v14-render-async/README.md | 13 +- codemods/v14-render-async/scripts/codemod.ts | 118 ++++++++++++++++--- codemods/v14-render-async/workflow.yaml | 4 + 3 files changed, 115 insertions(+), 20 deletions(-) diff --git a/codemods/v14-render-async/README.md b/codemods/v14-render-async/README.md index 5ba020f43..994846f78 100644 --- a/codemods/v14-render-async/README.md +++ b/codemods/v14-render-async/README.md @@ -35,7 +35,10 @@ npx codemod@latest workflow run -w ./codemods/v14-render-async/workflow.yaml --t # Or if published to the registry npx codemod@latest run @testing-library/react-native-v14-render-async --target ./path/to/your/tests -# With custom render functions (comma-separated list) +# With custom render functions - Option 1: Workflow parameter (recommended) +npx codemod@latest workflow run -w ./codemods/v14-render-async/workflow.yaml --target ./path/to/your/tests --param customRenderFunctions="renderWithProviders,renderWithTheme" + +# With custom render functions - Option 2: Environment variable CUSTOM_RENDER_FUNCTIONS="renderWithProviders,renderWithTheme" npx codemod@latest workflow run -w ./codemods/v14-render-async/workflow.yaml --target ./path/to/your/tests ``` @@ -346,7 +349,7 @@ yarn test ## Limitations -1. **Helper functions**: Function calls (`render`, `act`, `renderHook`, `fireEvent`) inside helper functions (not directly in test callbacks) are not transformed by default. You can specify custom render function names via the `CUSTOM_RENDER_FUNCTIONS` environment variable to have them automatically transformed. For other helper functions, you'll need to manually update them to be async and await their calls. +1. **Helper functions**: Function calls (`render`, `act`, `renderHook`, `fireEvent`) inside helper functions (not directly in test callbacks) are not transformed by default. You can specify custom render function names via the `--param customRenderFunctions=...` flag or `CUSTOM_RENDER_FUNCTIONS` environment variable to have them automatically transformed. For other helper functions, you'll need to manually update them to be async and await their calls. 2. **Namespace imports**: The codemod currently doesn't handle namespace imports like `import * as RNTL from '@testing-library/react-native'`. If you use this pattern, you'll need to manually update those calls. @@ -357,7 +360,11 @@ yarn test ## Migration Guide 1. **Run the codemod** on your test files -2. **If you have custom render functions** (like `renderWithProviders`, `renderWithTheme`, etc.), run the codemod with `CUSTOM_RENDER_FUNCTIONS` environment variable: +2. **If you have custom render functions** (like `renderWithProviders`, `renderWithTheme`, etc.), run the codemod with the `--param` flag: + ```bash + npx codemod@latest workflow run -w ./codemods/v14-render-async/workflow.yaml --target ./path/to/your/tests --param customRenderFunctions="renderWithProviders,renderWithTheme" + ``` + Or use the environment variable: ```bash CUSTOM_RENDER_FUNCTIONS="renderWithProviders,renderWithTheme" npx codemod@latest workflow run -w ./codemods/v14-render-async/workflow.yaml --target ./path/to/your/tests ``` diff --git a/codemods/v14-render-async/scripts/codemod.ts b/codemods/v14-render-async/scripts/codemod.ts index 64663f35a..8cc100e51 100644 --- a/codemods/v14-render-async/scripts/codemod.ts +++ b/codemods/v14-render-async/scripts/codemod.ts @@ -11,12 +11,16 @@ const FIRE_EVENT_METHODS = new Set(['press', 'changeText', 'scroll']); // Variants that should be skipped (they're already async or have different behavior) const SKIP_VARIANTS = new Set(['renderAsync', 'unsafe_renderHookSync', 'unsafe_act']); -const transform: Transform = async (root) => { +const transform: Transform = async (root, options) => { const rootNode = root.root(); - // Parse custom render functions from environment variable - // Format: CUSTOM_RENDER_FUNCTIONS="renderWithProviders,renderWithTheme,renderCustom" - const customRenderFunctionsParam = process.env.CUSTOM_RENDER_FUNCTIONS || ''; + // Parse custom render functions from workflow parameters or environment variable + // Priority: 1. --param customRenderFunctions=... 2. CUSTOM_RENDER_FUNCTIONS env var + // Format: "renderWithProviders,renderWithTheme,renderCustom" + const customRenderFunctionsParam = options?.params?.customRenderFunctions + ? String(options.params.customRenderFunctions) + : ''; + const customRenderFunctionsSet = new Set(); if (customRenderFunctionsParam) { customRenderFunctionsParam @@ -37,8 +41,10 @@ const transform: Transform = async (root) => { }, }); - if (rntlImports.length === 0) { - return null; // No RNTL imports, skip this file + // If we have custom render functions to process, we should still process the file + // even if it doesn't have RNTL imports (it might call custom render functions) + if (rntlImports.length === 0 && customRenderFunctionsSet.size === 0) { + return null; // No RNTL imports and no custom render functions, skip this file } // Track which functions are imported using a Set @@ -94,13 +100,15 @@ const transform: Transform = async (root) => { } } - if (importedFunctions.size === 0) { - return null; // None of the target functions are imported, skip + // If we have custom render functions to process, continue even if no RNTL functions are imported + // (the file might only call custom render functions) + if (importedFunctions.size === 0 && customRenderFunctionsSet.size === 0) { + return null; // None of the target functions are imported and no custom render functions, skip } // Step 2: Find all call expressions for imported functions const functionCalls: SgNode[] = []; - + // Find standalone function calls (render, act, renderHook, fireEvent) for (const funcName of importedFunctions) { const calls = rootNode.findAll({ @@ -158,28 +166,61 @@ const transform: Transform = async (root) => { const customRenderFunctionsToMakeAsync = new Map>(); // Track custom render functions that need to be async // Step 2.5: Find and process custom render function definitions - if (customRenderFunctionsSet.size > 0) { + // Note: This only processes definitions. Calls to custom render functions are handled in Step 3.5 + // We need importedFunctions to be populated to find RNTL calls inside custom render functions + // If there are no RNTL imports but we have custom render functions, we still want to process + // calls to custom render functions in tests (Step 3.5), but we can't process their definitions + if (customRenderFunctionsSet.size > 0 && importedFunctions.size > 0) { + console.log( + `[RNTL Codemod] Processing ${customRenderFunctionsSet.size} custom render functions: ${Array.from(customRenderFunctionsSet).join(', ')}`, + ); + console.log( + `[RNTL Codemod] Imported RNTL functions: ${Array.from(importedFunctions).join(', ') || 'none'}`, + ); + console.log(`[RNTL Codemod] File: ${root.filename()}`); + // Find function declarations const functionDeclarations = rootNode.findAll({ rule: { kind: 'function_declaration' }, }); + console.log( + `[RNTL Codemod] Found ${functionDeclarations.length} function declaration(s) in file`, + ); + let foundCustomFunctions = 0; + const allFunctionNames: string[] = []; for (const funcDecl of functionDeclarations) { const nameNode = funcDecl.find({ rule: { kind: 'identifier' }, }); if (nameNode) { const funcName = nameNode.text(); + allFunctionNames.push(funcName); if (customRenderFunctionsSet.has(funcName)) { + foundCustomFunctions++; + console.log(`[RNTL Codemod] ✓ Found custom render function declaration: ${funcName}`); // Found a custom render function declaration - processCustomRenderFunction(funcDecl, importedFunctions, edits, customRenderFunctionsToMakeAsync, rootNode); + processCustomRenderFunction( + funcDecl, + importedFunctions, + edits, + customRenderFunctionsToMakeAsync, + rootNode, + ); } } } + if (allFunctionNames.length > 0 && foundCustomFunctions === 0) { + console.log(`[RNTL Codemod] Function names found in file: ${allFunctionNames.join(', ')}`); + } // Find arrow functions and function expressions (const renderWithX = () => {} or const renderWithX = function() {}) const variableDeclarations = rootNode.findAll({ rule: { kind: 'lexical_declaration' }, }); + console.log( + `[RNTL Codemod] Found ${variableDeclarations.length} variable declaration(s) in file`, + ); + const allVariableNames: string[] = []; for (const varDecl of variableDeclarations) { const declarators = varDecl.findAll({ rule: { kind: 'variable_declarator' }, @@ -190,24 +231,45 @@ const transform: Transform = async (root) => { }); if (nameNode) { const funcName = nameNode.text(); + allVariableNames.push(funcName); if (customRenderFunctionsSet.has(funcName)) { // Check if it's an arrow function or function expression const init = declarator.find({ rule: { - any: [ - { kind: 'arrow_function' }, - { kind: 'function_expression' }, - ], + any: [{ kind: 'arrow_function' }, { kind: 'function_expression' }], }, }); if (init) { + foundCustomFunctions++; + console.log( + `[RNTL Codemod] ✓ Found custom render function (arrow/expression): ${funcName}`, + ); // Found a custom render function (arrow or expression) - processCustomRenderFunction(init, importedFunctions, edits, customRenderFunctionsToMakeAsync, rootNode); + processCustomRenderFunction( + init, + importedFunctions, + edits, + customRenderFunctionsToMakeAsync, + rootNode, + ); + } else { + console.log( + `[RNTL Codemod] Variable ${funcName} found but is not a function (arrow/expression)`, + ); } } } } } + if (allVariableNames.length > 0 && foundCustomFunctions === 0) { + const matchingVars = allVariableNames.filter((name) => customRenderFunctionsSet.has(name)); + if (matchingVars.length > 0) { + console.log( + `[RNTL Codemod] Found matching variable names but they're not functions: ${matchingVars.join(', ')}`, + ); + } + } + console.log(`[RNTL Codemod] Total custom render functions found: ${foundCustomFunctions}`); } // Step 3: Process each function call @@ -268,6 +330,10 @@ const transform: Transform = async (root) => { const allCallExpressions = rootNode.findAll({ rule: { kind: 'call_expression' }, }); + console.log( + `[RNTL Codemod] Checking ${allCallExpressions.length} call expressions for custom render function calls`, + ); + let foundCustomCalls = 0; for (const callExpr of allCallExpressions) { const funcNode = callExpr.field('function'); if (!funcNode) continue; @@ -281,6 +347,8 @@ const transform: Transform = async (root) => { } if (calledFunctionName && customRenderFunctionsSet.has(calledFunctionName)) { + foundCustomCalls++; + console.log(`[RNTL Codemod] Found call to custom render function: ${calledFunctionName}`); // Check if this call is inside a test function const containingFunction = findContainingTestFunction(callExpr); if (containingFunction) { @@ -315,6 +383,9 @@ const transform: Transform = async (root) => { } } } + console.log( + `[RNTL Codemod] Found ${foundCustomCalls} call(s) to custom render functions in tests`, + ); } // Step 7: Add async keyword to functions that need it @@ -408,11 +479,15 @@ function processCustomRenderFunction( importedFunctions: Set, edits: Edit[], customRenderFunctionsToMakeAsync: Map>, - rootNode: SgNode + rootNode: SgNode, ): void { // Find RNTL function calls inside this custom render function const rntlCalls: SgNode[] = []; + console.log( + `[RNTL Codemod] Processing custom render function, importedFunctions: ${Array.from(importedFunctions).join(', ') || 'none'}`, + ); + // Find standalone function calls (render, act, renderHook, fireEvent) for (const funcName of importedFunctions) { const calls = funcNode.findAll({ @@ -425,6 +500,11 @@ function processCustomRenderFunction( }, }, }); + if (calls.length > 0) { + console.log( + `[RNTL Codemod] Found ${calls.length} call(s) to ${funcName} inside custom render function`, + ); + } rntlCalls.push(...calls); } @@ -460,6 +540,10 @@ function processCustomRenderFunction( } } + console.log( + `[RNTL Codemod] Found ${rntlCalls.length} total RNTL call(s) inside custom render function`, + ); + // Process each RNTL call found inside the custom render function let needsAsync = false; for (const rntlCall of rntlCalls) { diff --git a/codemods/v14-render-async/workflow.yaml b/codemods/v14-render-async/workflow.yaml index 463bac86c..3bd8e63db 100644 --- a/codemods/v14-render-async/workflow.yaml +++ b/codemods/v14-render-async/workflow.yaml @@ -20,6 +20,10 @@ nodes: - "**/*.test.jsx" - "**/*.spec.js" - "**/*.spec.jsx" + - "**/__tests__/**/*.ts" + - "**/__tests__/**/*.tsx" + - "**/__tests__/**/*.js" + - "**/__tests__/**/*.jsx" exclude: - "**/node_modules/**" - "**/build/**" From fbb0b2263180851a97acf69b486b8c70e41a044e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Tue, 6 Jan 2026 16:36:03 +0100 Subject: [PATCH 11/27] async rerender/unmount --- .../.gitignore | 0 .../README.md | 16 +- codemods/v14-async-functions/codemod.yaml | 19 ++ .../package.json | 2 +- .../scripts/codemod.ts | 234 +++++++++++++----- .../tests/fixtures/act-call/expected.tsx | 0 .../tests/fixtures/act-call/input.tsx | 0 .../fixtures/aftereach-hook/expected.tsx | 0 .../tests/fixtures/aftereach-hook/input.tsx | 0 .../tests/fixtures/already-async/expected.tsx | 0 .../tests/fixtures/already-async/input.tsx | 0 .../fixtures/already-awaited/expected.tsx | 0 .../tests/fixtures/already-awaited/input.tsx | 0 .../fixtures/basic-sync-test/expected.tsx | 0 .../tests/fixtures/basic-sync-test/input.tsx | 0 .../fixtures/beforeeach-hook/expected.tsx | 0 .../tests/fixtures/beforeeach-hook/input.tsx | 0 .../fixtures/combined-functions/expected.tsx | 0 .../fixtures/combined-functions/input.tsx | 0 .../custom-render-function/expected.tsx | 0 .../fixtures/custom-render-function/input.tsx | 0 .../fixtures/fireevent-call/expected.tsx | 0 .../tests/fixtures/fireevent-call/input.tsx | 0 .../fixtures/fireevent-methods/expected.tsx | 0 .../fixtures/fireevent-methods/input.tsx | 0 .../function-declaration/expected.tsx | 0 .../fixtures/function-declaration/input.tsx | 0 .../fixtures/helper-function/expected.tsx | 0 .../tests/fixtures/helper-function/input.tsx | 0 .../fixtures/it-instead-of-test/expected.tsx | 0 .../fixtures/it-instead-of-test/input.tsx | 0 .../fixtures/multiple-renders/expected.tsx | 0 .../tests/fixtures/multiple-renders/input.tsx | 0 .../fixtures/no-rntl-import/expected.tsx | 0 .../tests/fixtures/no-rntl-import/input.tsx | 0 .../fixtures/render-with-options/expected.tsx | 0 .../fixtures/render-with-options/input.tsx | 0 .../fixtures/renderer-rerender/expected.tsx | 7 + .../fixtures/renderer-rerender/input.tsx | 7 + .../fixtures/renderer-unmount/expected.tsx | 7 + .../tests/fixtures/renderer-unmount/input.tsx | 7 + .../fixtures/renderhook-call/expected.tsx | 0 .../tests/fixtures/renderhook-call/input.tsx | 0 .../fixtures/screen-rerender/expected.tsx | 7 + .../tests/fixtures/screen-rerender/input.tsx | 7 + .../fixtures/screen-unmount/expected.tsx | 7 + .../tests/fixtures/screen-unmount/input.tsx | 7 + .../tests/fixtures/skip-variants/expected.tsx | 0 .../tests/fixtures/skip-variants/input.tsx | 0 .../tests/fixtures/test-skip/expected.tsx | 0 .../tests/fixtures/test-skip/input.tsx | 0 .../tsconfig.json | 0 .../workflow.yaml | 0 codemods/v14-render-async/codemod.yaml | 20 -- codemods/v14-render-async/yarn.lock | 42 ---- .../.gitignore | 0 .../README.md | 8 +- .../codemod.yaml | 4 +- .../package.json | 2 +- .../scripts/codemod-json.js | 0 .../scripts/test.js | 0 .../fixtures/already-alpha/expected.json | 0 .../tests/fixtures/already-alpha/input.json | 0 .../tests/fixtures/basic-update/expected.json | 0 .../tests/fixtures/basic-update/input.json | 0 .../fixtures/move-from-deps/expected.json | 0 .../tests/fixtures/move-from-deps/input.json | 0 .../fixtures/no-rntl-or-utr/expected.json | 0 .../tests/fixtures/no-rntl-or-utr/input.json | 0 .../tests/fixtures/no-rntl/expected.json | 0 .../tests/fixtures/no-rntl/input.json | 0 .../fixtures/rntl-in-devdeps/expected.json | 0 .../tests/fixtures/rntl-in-devdeps/input.json | 0 .../fixtures/with-peer-deps/expected.json | 0 .../tests/fixtures/with-peer-deps/input.json | 0 .../workflow.yaml | 0 76 files changed, 269 insertions(+), 134 deletions(-) rename codemods/{v14-render-async => v14-async-functions}/.gitignore (100%) rename codemods/{v14-render-async => v14-async-functions}/README.md (91%) create mode 100644 codemods/v14-async-functions/codemod.yaml rename codemods/{v14-render-async => v14-async-functions}/package.json (85%) rename codemods/{v14-render-async => v14-async-functions}/scripts/codemod.ts (77%) rename codemods/{v14-render-async => v14-async-functions}/tests/fixtures/act-call/expected.tsx (100%) rename codemods/{v14-render-async => v14-async-functions}/tests/fixtures/act-call/input.tsx (100%) rename codemods/{v14-render-async => v14-async-functions}/tests/fixtures/aftereach-hook/expected.tsx (100%) rename codemods/{v14-render-async => v14-async-functions}/tests/fixtures/aftereach-hook/input.tsx (100%) rename codemods/{v14-render-async => v14-async-functions}/tests/fixtures/already-async/expected.tsx (100%) rename codemods/{v14-render-async => v14-async-functions}/tests/fixtures/already-async/input.tsx (100%) rename codemods/{v14-render-async => v14-async-functions}/tests/fixtures/already-awaited/expected.tsx (100%) rename codemods/{v14-render-async => v14-async-functions}/tests/fixtures/already-awaited/input.tsx (100%) rename codemods/{v14-render-async => v14-async-functions}/tests/fixtures/basic-sync-test/expected.tsx (100%) rename codemods/{v14-render-async => v14-async-functions}/tests/fixtures/basic-sync-test/input.tsx (100%) rename codemods/{v14-render-async => v14-async-functions}/tests/fixtures/beforeeach-hook/expected.tsx (100%) rename codemods/{v14-render-async => v14-async-functions}/tests/fixtures/beforeeach-hook/input.tsx (100%) rename codemods/{v14-render-async => v14-async-functions}/tests/fixtures/combined-functions/expected.tsx (100%) rename codemods/{v14-render-async => v14-async-functions}/tests/fixtures/combined-functions/input.tsx (100%) rename codemods/{v14-render-async => v14-async-functions}/tests/fixtures/custom-render-function/expected.tsx (100%) rename codemods/{v14-render-async => v14-async-functions}/tests/fixtures/custom-render-function/input.tsx (100%) rename codemods/{v14-render-async => v14-async-functions}/tests/fixtures/fireevent-call/expected.tsx (100%) rename codemods/{v14-render-async => v14-async-functions}/tests/fixtures/fireevent-call/input.tsx (100%) rename codemods/{v14-render-async => v14-async-functions}/tests/fixtures/fireevent-methods/expected.tsx (100%) rename codemods/{v14-render-async => v14-async-functions}/tests/fixtures/fireevent-methods/input.tsx (100%) rename codemods/{v14-render-async => v14-async-functions}/tests/fixtures/function-declaration/expected.tsx (100%) rename codemods/{v14-render-async => v14-async-functions}/tests/fixtures/function-declaration/input.tsx (100%) rename codemods/{v14-render-async => v14-async-functions}/tests/fixtures/helper-function/expected.tsx (100%) rename codemods/{v14-render-async => v14-async-functions}/tests/fixtures/helper-function/input.tsx (100%) rename codemods/{v14-render-async => v14-async-functions}/tests/fixtures/it-instead-of-test/expected.tsx (100%) rename codemods/{v14-render-async => v14-async-functions}/tests/fixtures/it-instead-of-test/input.tsx (100%) rename codemods/{v14-render-async => v14-async-functions}/tests/fixtures/multiple-renders/expected.tsx (100%) rename codemods/{v14-render-async => v14-async-functions}/tests/fixtures/multiple-renders/input.tsx (100%) rename codemods/{v14-render-async => v14-async-functions}/tests/fixtures/no-rntl-import/expected.tsx (100%) rename codemods/{v14-render-async => v14-async-functions}/tests/fixtures/no-rntl-import/input.tsx (100%) rename codemods/{v14-render-async => v14-async-functions}/tests/fixtures/render-with-options/expected.tsx (100%) rename codemods/{v14-render-async => v14-async-functions}/tests/fixtures/render-with-options/input.tsx (100%) create mode 100644 codemods/v14-async-functions/tests/fixtures/renderer-rerender/expected.tsx create mode 100644 codemods/v14-async-functions/tests/fixtures/renderer-rerender/input.tsx create mode 100644 codemods/v14-async-functions/tests/fixtures/renderer-unmount/expected.tsx create mode 100644 codemods/v14-async-functions/tests/fixtures/renderer-unmount/input.tsx rename codemods/{v14-render-async => v14-async-functions}/tests/fixtures/renderhook-call/expected.tsx (100%) rename codemods/{v14-render-async => v14-async-functions}/tests/fixtures/renderhook-call/input.tsx (100%) create mode 100644 codemods/v14-async-functions/tests/fixtures/screen-rerender/expected.tsx create mode 100644 codemods/v14-async-functions/tests/fixtures/screen-rerender/input.tsx create mode 100644 codemods/v14-async-functions/tests/fixtures/screen-unmount/expected.tsx create mode 100644 codemods/v14-async-functions/tests/fixtures/screen-unmount/input.tsx rename codemods/{v14-render-async => v14-async-functions}/tests/fixtures/skip-variants/expected.tsx (100%) rename codemods/{v14-render-async => v14-async-functions}/tests/fixtures/skip-variants/input.tsx (100%) rename codemods/{v14-render-async => v14-async-functions}/tests/fixtures/test-skip/expected.tsx (100%) rename codemods/{v14-render-async => v14-async-functions}/tests/fixtures/test-skip/input.tsx (100%) rename codemods/{v14-render-async => v14-async-functions}/tsconfig.json (100%) rename codemods/{v14-render-async => v14-async-functions}/workflow.yaml (100%) delete mode 100644 codemods/v14-render-async/codemod.yaml delete mode 100644 codemods/v14-render-async/yarn.lock rename codemods/{v14-update-dependencies => v14-update-deps}/.gitignore (100%) rename codemods/{v14-update-dependencies => v14-update-deps}/README.md (93%) rename codemods/{v14-update-dependencies => v14-update-deps}/codemod.yaml (79%) rename codemods/{v14-update-dependencies => v14-update-deps}/package.json (76%) rename codemods/{v14-update-dependencies => v14-update-deps}/scripts/codemod-json.js (100%) rename codemods/{v14-update-dependencies => v14-update-deps}/scripts/test.js (100%) rename codemods/{v14-update-dependencies => v14-update-deps}/tests/fixtures/already-alpha/expected.json (100%) rename codemods/{v14-update-dependencies => v14-update-deps}/tests/fixtures/already-alpha/input.json (100%) rename codemods/{v14-update-dependencies => v14-update-deps}/tests/fixtures/basic-update/expected.json (100%) rename codemods/{v14-update-dependencies => v14-update-deps}/tests/fixtures/basic-update/input.json (100%) rename codemods/{v14-update-dependencies => v14-update-deps}/tests/fixtures/move-from-deps/expected.json (100%) rename codemods/{v14-update-dependencies => v14-update-deps}/tests/fixtures/move-from-deps/input.json (100%) rename codemods/{v14-update-dependencies => v14-update-deps}/tests/fixtures/no-rntl-or-utr/expected.json (100%) rename codemods/{v14-update-dependencies => v14-update-deps}/tests/fixtures/no-rntl-or-utr/input.json (100%) rename codemods/{v14-update-dependencies => v14-update-deps}/tests/fixtures/no-rntl/expected.json (100%) rename codemods/{v14-update-dependencies => v14-update-deps}/tests/fixtures/no-rntl/input.json (100%) rename codemods/{v14-update-dependencies => v14-update-deps}/tests/fixtures/rntl-in-devdeps/expected.json (100%) rename codemods/{v14-update-dependencies => v14-update-deps}/tests/fixtures/rntl-in-devdeps/input.json (100%) rename codemods/{v14-update-dependencies => v14-update-deps}/tests/fixtures/with-peer-deps/expected.json (100%) rename codemods/{v14-update-dependencies => v14-update-deps}/tests/fixtures/with-peer-deps/input.json (100%) rename codemods/{v14-update-dependencies => v14-update-deps}/workflow.yaml (100%) diff --git a/codemods/v14-render-async/.gitignore b/codemods/v14-async-functions/.gitignore similarity index 100% rename from codemods/v14-render-async/.gitignore rename to codemods/v14-async-functions/.gitignore diff --git a/codemods/v14-render-async/README.md b/codemods/v14-async-functions/README.md similarity index 91% rename from codemods/v14-render-async/README.md rename to codemods/v14-async-functions/README.md index 994846f78..9d2ecb048 100644 --- a/codemods/v14-render-async/README.md +++ b/codemods/v14-async-functions/README.md @@ -9,6 +9,8 @@ This codemod migrates your test files from React Native Testing Library v13 to v - ✅ Transforms `renderHook()` calls to `await renderHook()` in test functions - ✅ Transforms `fireEvent()` calls to `await fireEvent()` in test functions - ✅ Transforms `fireEvent.press()`, `fireEvent.changeText()`, and `fireEvent.scroll()` calls to `await fireEvent.press()`, etc. +- ✅ Transforms `screen.rerender()` and `screen.unmount()` calls to `await screen.rerender()`, etc. +- ✅ Transforms `renderer.rerender()` and `renderer.unmount()` calls (where renderer is the return value from `render()`) to `await renderer.rerender()`, etc. - ✅ Makes test functions async if they're not already - ✅ Handles `test()`, `it()`, `test.skip()`, and `it.skip()` patterns - ✅ Handles `beforeEach()`, `afterEach()`, `beforeAll()`, and `afterAll()` hooks @@ -30,16 +32,16 @@ This codemod migrates your test files from React Native Testing Library v13 to v ```bash # Run on your test files -npx codemod@latest workflow run -w ./codemods/v14-render-async/workflow.yaml --target ./path/to/your/tests +npx codemod@latest workflow run -w ./codemods/v14-async-functions/workflow.yaml --target ./path/to/your/tests # Or if published to the registry -npx codemod@latest run @testing-library/react-native-v14-render-async --target ./path/to/your/tests +npx codemod@latest run @testing-library/react-native-v14-async-functions --target ./path/to/your/tests # With custom render functions - Option 1: Workflow parameter (recommended) -npx codemod@latest workflow run -w ./codemods/v14-render-async/workflow.yaml --target ./path/to/your/tests --param customRenderFunctions="renderWithProviders,renderWithTheme" +npx codemod@latest workflow run -w ./codemods/v14-async-functions/workflow.yaml --target ./path/to/your/tests --param customRenderFunctions="renderWithProviders,renderWithTheme" # With custom render functions - Option 2: Environment variable -CUSTOM_RENDER_FUNCTIONS="renderWithProviders,renderWithTheme" npx codemod@latest workflow run -w ./codemods/v14-render-async/workflow.yaml --target ./path/to/your/tests +CUSTOM_RENDER_FUNCTIONS="renderWithProviders,renderWithTheme" npx codemod@latest workflow run -w ./codemods/v14-async-functions/workflow.yaml --target ./path/to/your/tests ``` ### Example transformations @@ -343,7 +345,7 @@ test('uses custom render', async () => { Run the test suite: ```bash -cd codemods/v14-render-async +cd codemods/v14-async-functions yarn test ``` @@ -362,11 +364,11 @@ yarn test 1. **Run the codemod** on your test files 2. **If you have custom render functions** (like `renderWithProviders`, `renderWithTheme`, etc.), run the codemod with the `--param` flag: ```bash - npx codemod@latest workflow run -w ./codemods/v14-render-async/workflow.yaml --target ./path/to/your/tests --param customRenderFunctions="renderWithProviders,renderWithTheme" + npx codemod@latest workflow run -w ./codemods/v14-async-functions/workflow.yaml --target ./path/to/your/tests --param customRenderFunctions="renderWithProviders,renderWithTheme" ``` Or use the environment variable: ```bash - CUSTOM_RENDER_FUNCTIONS="renderWithProviders,renderWithTheme" npx codemod@latest workflow run -w ./codemods/v14-render-async/workflow.yaml --target ./path/to/your/tests + CUSTOM_RENDER_FUNCTIONS="renderWithProviders,renderWithTheme" npx codemod@latest workflow run -w ./codemods/v14-async-functions/workflow.yaml --target ./path/to/your/tests ``` 3. **Review the changes** to ensure all transformations are correct 4. **Manually update helper functions** that contain `render`, `act`, `renderHook`, or `fireEvent` calls (if not specified in `CUSTOM_RENDER_FUNCTIONS`) diff --git a/codemods/v14-async-functions/codemod.yaml b/codemods/v14-async-functions/codemod.yaml new file mode 100644 index 000000000..6c3135f34 --- /dev/null +++ b/codemods/v14-async-functions/codemod.yaml @@ -0,0 +1,19 @@ +schema_version: '1.0' + +name: '@testing-library/react-native-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-render-async/package.json b/codemods/v14-async-functions/package.json similarity index 85% rename from codemods/v14-render-async/package.json rename to codemods/v14-async-functions/package.json index 6f5b02203..0180522ba 100644 --- a/codemods/v14-render-async/package.json +++ b/codemods/v14-async-functions/package.json @@ -1,5 +1,5 @@ { - "name": "@testing-library/react-native-v14-render-async", + "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", diff --git a/codemods/v14-render-async/scripts/codemod.ts b/codemods/v14-async-functions/scripts/codemod.ts similarity index 77% rename from codemods/v14-render-async/scripts/codemod.ts rename to codemods/v14-async-functions/scripts/codemod.ts index 8cc100e51..5da961a6d 100644 --- a/codemods/v14-render-async/scripts/codemod.ts +++ b/codemods/v14-async-functions/scripts/codemod.ts @@ -8,6 +8,12 @@ const FUNCTIONS_TO_TRANSFORM = new Set(['render', 'renderHook', 'act', 'fireEven // fireEvent methods that should be transformed to async const FIRE_EVENT_METHODS = new Set(['press', 'changeText', 'scroll']); +// Screen methods that should be transformed to async +const SCREEN_METHODS = new Set(['rerender', 'unmount']); + +// Renderer methods that should be transformed to async (methods on render() return value) +const RENDERER_METHODS = new Set(['rerender', 'unmount']); + // Variants that should be skipped (they're already async or have different behavior) const SKIP_VARIANTS = new Set(['renderAsync', 'unsafe_renderHookSync', 'unsafe_act']); @@ -157,6 +163,177 @@ const transform: Transform = async (root, options) => { } } + // Find screen method calls (screen.rerender, screen.unmount) + // Check if screen is imported or available + 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(); + // Check if it's screen.methodName where methodName is rerender or unmount + if (objText === 'screen' && SCREEN_METHODS.has(propText)) { + functionCalls.push(call); + } + } + } catch { + // field() might not be available for this node type, skip + } + } + } + + // Find renderer method calls (renderer.rerender, renderer.unmount) + // Track variables that are assigned the result of render() calls + const rendererVariables = new Set(); + + // Find all render() calls and track what they're assigned to + if (importedFunctions.has('render')) { + const renderCalls = rootNode.findAll({ + rule: { + kind: 'call_expression', + has: { + field: 'function', + kind: 'identifier', + regex: '^render$', + }, + }, + }); + + for (const renderCall of renderCalls) { + // Check if this render() call is assigned to a variable + // Handle both: const renderer = render(...) and const renderer = await render(...) + let parent = renderCall.parent(); + const isAwaited = parent && parent.is('await_expression'); + + // If awaited, get the await expression's parent + if (isAwaited) { + parent = parent.parent(); + } + + if (parent && parent.is('variable_declarator')) { + // Handle: const renderer = render(...) or const { rerender, unmount } = render(...) + // Try to find object_pattern first (destructuring) + const objectPattern = parent.find({ + rule: { kind: 'object_pattern' }, + }); + if (objectPattern) { + // Destructuring: const { rerender, unmount } = ... + const shorthandProps = objectPattern.findAll({ + rule: { kind: 'shorthand_property_identifier_pattern' }, + }); + for (const prop of shorthandProps) { + // The shorthand_property_identifier_pattern IS the identifier + const propName = prop.text(); + if (RENDERER_METHODS.has(propName)) { + rendererVariables.add(propName); + } + } + } else { + // Simple variable assignment: const renderer = ... + const nameNode = parent.find({ + rule: { kind: 'identifier' }, + }); + if (nameNode) { + const varName = nameNode.text(); + rendererVariables.add(varName); + } + } + } else if (parent && parent.is('assignment_expression')) { + // Handle: renderer = render(...) or renderer = await render(...) + const left = parent.find({ + rule: { kind: 'identifier' }, + }); + if (left) { + const varName = left.text(); + rendererVariables.add(varName); + } else { + // Handle destructuring assignment: { rerender } = render(...) + 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) { + // The shorthand_property_identifier_pattern IS the identifier + const propName = prop.text(); + if (RENDERER_METHODS.has(propName)) { + rendererVariables.add(propName); + } + } + } + } + } + } + + // Now find calls to .rerender() or .unmount() on these variables + // Handle both: renderer.rerender() and rerender() (when destructured) + if (rendererVariables.size > 0) { + // Find member expression calls: renderer.rerender() + const rendererMethodCalls = rootNode.findAll({ + rule: { + kind: 'call_expression', + has: { + field: 'function', + kind: 'member_expression', + }, + }, + }); + + for (const call of rendererMethodCalls) { + 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(); + // Check if it's rendererVariable.methodName where methodName is rerender or unmount + if (rendererVariables.has(objText) && RENDERER_METHODS.has(propText)) { + functionCalls.push(call); + } + } + } catch { + // field() might not be available for this node type, skip + } + } + } + + // Find direct identifier calls: rerender() and unmount() (when destructured) + for (const varName of rendererVariables) { + if (RENDERER_METHODS.has(varName)) { + // This is a destructured method name (rerender or unmount) + const directCalls = rootNode.findAll({ + rule: { + kind: 'call_expression', + has: { + field: 'function', + kind: 'identifier', + regex: `^${varName}$`, + }, + }, + }); + functionCalls.push(...directCalls); + } + } + } + } + if (functionCalls.length === 0 && customRenderFunctionsSet.size === 0) { return null; // No function calls found and no custom render functions to process } @@ -171,33 +348,17 @@ const transform: Transform = async (root, options) => { // If there are no RNTL imports but we have custom render functions, we still want to process // calls to custom render functions in tests (Step 3.5), but we can't process their definitions if (customRenderFunctionsSet.size > 0 && importedFunctions.size > 0) { - console.log( - `[RNTL Codemod] Processing ${customRenderFunctionsSet.size} custom render functions: ${Array.from(customRenderFunctionsSet).join(', ')}`, - ); - console.log( - `[RNTL Codemod] Imported RNTL functions: ${Array.from(importedFunctions).join(', ') || 'none'}`, - ); - console.log(`[RNTL Codemod] File: ${root.filename()}`); - // Find function declarations const functionDeclarations = rootNode.findAll({ rule: { kind: 'function_declaration' }, }); - console.log( - `[RNTL Codemod] Found ${functionDeclarations.length} function declaration(s) in file`, - ); - let foundCustomFunctions = 0; - const allFunctionNames: string[] = []; for (const funcDecl of functionDeclarations) { const nameNode = funcDecl.find({ rule: { kind: 'identifier' }, }); if (nameNode) { const funcName = nameNode.text(); - allFunctionNames.push(funcName); if (customRenderFunctionsSet.has(funcName)) { - foundCustomFunctions++; - console.log(`[RNTL Codemod] ✓ Found custom render function declaration: ${funcName}`); // Found a custom render function declaration processCustomRenderFunction( funcDecl, @@ -209,18 +370,11 @@ const transform: Transform = async (root, options) => { } } } - if (allFunctionNames.length > 0 && foundCustomFunctions === 0) { - console.log(`[RNTL Codemod] Function names found in file: ${allFunctionNames.join(', ')}`); - } // Find arrow functions and function expressions (const renderWithX = () => {} or const renderWithX = function() {}) const variableDeclarations = rootNode.findAll({ rule: { kind: 'lexical_declaration' }, }); - console.log( - `[RNTL Codemod] Found ${variableDeclarations.length} variable declaration(s) in file`, - ); - const allVariableNames: string[] = []; for (const varDecl of variableDeclarations) { const declarators = varDecl.findAll({ rule: { kind: 'variable_declarator' }, @@ -231,7 +385,6 @@ const transform: Transform = async (root, options) => { }); if (nameNode) { const funcName = nameNode.text(); - allVariableNames.push(funcName); if (customRenderFunctionsSet.has(funcName)) { // Check if it's an arrow function or function expression const init = declarator.find({ @@ -240,10 +393,6 @@ const transform: Transform = async (root, options) => { }, }); if (init) { - foundCustomFunctions++; - console.log( - `[RNTL Codemod] ✓ Found custom render function (arrow/expression): ${funcName}`, - ); // Found a custom render function (arrow or expression) processCustomRenderFunction( init, @@ -252,24 +401,11 @@ const transform: Transform = async (root, options) => { customRenderFunctionsToMakeAsync, rootNode, ); - } else { - console.log( - `[RNTL Codemod] Variable ${funcName} found but is not a function (arrow/expression)`, - ); } } } } } - if (allVariableNames.length > 0 && foundCustomFunctions === 0) { - const matchingVars = allVariableNames.filter((name) => customRenderFunctionsSet.has(name)); - if (matchingVars.length > 0) { - console.log( - `[RNTL Codemod] Found matching variable names but they're not functions: ${matchingVars.join(', ')}`, - ); - } - } - console.log(`[RNTL Codemod] Total custom render functions found: ${foundCustomFunctions}`); } // Step 3: Process each function call @@ -330,9 +466,6 @@ const transform: Transform = async (root, options) => { const allCallExpressions = rootNode.findAll({ rule: { kind: 'call_expression' }, }); - console.log( - `[RNTL Codemod] Checking ${allCallExpressions.length} call expressions for custom render function calls`, - ); let foundCustomCalls = 0; for (const callExpr of allCallExpressions) { const funcNode = callExpr.field('function'); @@ -348,7 +481,6 @@ const transform: Transform = async (root, options) => { if (calledFunctionName && customRenderFunctionsSet.has(calledFunctionName)) { foundCustomCalls++; - console.log(`[RNTL Codemod] Found call to custom render function: ${calledFunctionName}`); // Check if this call is inside a test function const containingFunction = findContainingTestFunction(callExpr); if (containingFunction) { @@ -383,9 +515,6 @@ const transform: Transform = async (root, options) => { } } } - console.log( - `[RNTL Codemod] Found ${foundCustomCalls} call(s) to custom render functions in tests`, - ); } // Step 7: Add async keyword to functions that need it @@ -484,9 +613,6 @@ function processCustomRenderFunction( // Find RNTL function calls inside this custom render function const rntlCalls: SgNode[] = []; - console.log( - `[RNTL Codemod] Processing custom render function, importedFunctions: ${Array.from(importedFunctions).join(', ') || 'none'}`, - ); // Find standalone function calls (render, act, renderHook, fireEvent) for (const funcName of importedFunctions) { @@ -501,9 +627,6 @@ function processCustomRenderFunction( }, }); if (calls.length > 0) { - console.log( - `[RNTL Codemod] Found ${calls.length} call(s) to ${funcName} inside custom render function`, - ); } rntlCalls.push(...calls); } @@ -540,9 +663,6 @@ function processCustomRenderFunction( } } - console.log( - `[RNTL Codemod] Found ${rntlCalls.length} total RNTL call(s) inside custom render function`, - ); // Process each RNTL call found inside the custom render function let needsAsync = false; diff --git a/codemods/v14-render-async/tests/fixtures/act-call/expected.tsx b/codemods/v14-async-functions/tests/fixtures/act-call/expected.tsx similarity index 100% rename from codemods/v14-render-async/tests/fixtures/act-call/expected.tsx rename to codemods/v14-async-functions/tests/fixtures/act-call/expected.tsx diff --git a/codemods/v14-render-async/tests/fixtures/act-call/input.tsx b/codemods/v14-async-functions/tests/fixtures/act-call/input.tsx similarity index 100% rename from codemods/v14-render-async/tests/fixtures/act-call/input.tsx rename to codemods/v14-async-functions/tests/fixtures/act-call/input.tsx diff --git a/codemods/v14-render-async/tests/fixtures/aftereach-hook/expected.tsx b/codemods/v14-async-functions/tests/fixtures/aftereach-hook/expected.tsx similarity index 100% rename from codemods/v14-render-async/tests/fixtures/aftereach-hook/expected.tsx rename to codemods/v14-async-functions/tests/fixtures/aftereach-hook/expected.tsx diff --git a/codemods/v14-render-async/tests/fixtures/aftereach-hook/input.tsx b/codemods/v14-async-functions/tests/fixtures/aftereach-hook/input.tsx similarity index 100% rename from codemods/v14-render-async/tests/fixtures/aftereach-hook/input.tsx rename to codemods/v14-async-functions/tests/fixtures/aftereach-hook/input.tsx diff --git a/codemods/v14-render-async/tests/fixtures/already-async/expected.tsx b/codemods/v14-async-functions/tests/fixtures/already-async/expected.tsx similarity index 100% rename from codemods/v14-render-async/tests/fixtures/already-async/expected.tsx rename to codemods/v14-async-functions/tests/fixtures/already-async/expected.tsx diff --git a/codemods/v14-render-async/tests/fixtures/already-async/input.tsx b/codemods/v14-async-functions/tests/fixtures/already-async/input.tsx similarity index 100% rename from codemods/v14-render-async/tests/fixtures/already-async/input.tsx rename to codemods/v14-async-functions/tests/fixtures/already-async/input.tsx diff --git a/codemods/v14-render-async/tests/fixtures/already-awaited/expected.tsx b/codemods/v14-async-functions/tests/fixtures/already-awaited/expected.tsx similarity index 100% rename from codemods/v14-render-async/tests/fixtures/already-awaited/expected.tsx rename to codemods/v14-async-functions/tests/fixtures/already-awaited/expected.tsx diff --git a/codemods/v14-render-async/tests/fixtures/already-awaited/input.tsx b/codemods/v14-async-functions/tests/fixtures/already-awaited/input.tsx similarity index 100% rename from codemods/v14-render-async/tests/fixtures/already-awaited/input.tsx rename to codemods/v14-async-functions/tests/fixtures/already-awaited/input.tsx diff --git a/codemods/v14-render-async/tests/fixtures/basic-sync-test/expected.tsx b/codemods/v14-async-functions/tests/fixtures/basic-sync-test/expected.tsx similarity index 100% rename from codemods/v14-render-async/tests/fixtures/basic-sync-test/expected.tsx rename to codemods/v14-async-functions/tests/fixtures/basic-sync-test/expected.tsx diff --git a/codemods/v14-render-async/tests/fixtures/basic-sync-test/input.tsx b/codemods/v14-async-functions/tests/fixtures/basic-sync-test/input.tsx similarity index 100% rename from codemods/v14-render-async/tests/fixtures/basic-sync-test/input.tsx rename to codemods/v14-async-functions/tests/fixtures/basic-sync-test/input.tsx diff --git a/codemods/v14-render-async/tests/fixtures/beforeeach-hook/expected.tsx b/codemods/v14-async-functions/tests/fixtures/beforeeach-hook/expected.tsx similarity index 100% rename from codemods/v14-render-async/tests/fixtures/beforeeach-hook/expected.tsx rename to codemods/v14-async-functions/tests/fixtures/beforeeach-hook/expected.tsx diff --git a/codemods/v14-render-async/tests/fixtures/beforeeach-hook/input.tsx b/codemods/v14-async-functions/tests/fixtures/beforeeach-hook/input.tsx similarity index 100% rename from codemods/v14-render-async/tests/fixtures/beforeeach-hook/input.tsx rename to codemods/v14-async-functions/tests/fixtures/beforeeach-hook/input.tsx diff --git a/codemods/v14-render-async/tests/fixtures/combined-functions/expected.tsx b/codemods/v14-async-functions/tests/fixtures/combined-functions/expected.tsx similarity index 100% rename from codemods/v14-render-async/tests/fixtures/combined-functions/expected.tsx rename to codemods/v14-async-functions/tests/fixtures/combined-functions/expected.tsx diff --git a/codemods/v14-render-async/tests/fixtures/combined-functions/input.tsx b/codemods/v14-async-functions/tests/fixtures/combined-functions/input.tsx similarity index 100% rename from codemods/v14-render-async/tests/fixtures/combined-functions/input.tsx rename to codemods/v14-async-functions/tests/fixtures/combined-functions/input.tsx diff --git a/codemods/v14-render-async/tests/fixtures/custom-render-function/expected.tsx b/codemods/v14-async-functions/tests/fixtures/custom-render-function/expected.tsx similarity index 100% rename from codemods/v14-render-async/tests/fixtures/custom-render-function/expected.tsx rename to codemods/v14-async-functions/tests/fixtures/custom-render-function/expected.tsx diff --git a/codemods/v14-render-async/tests/fixtures/custom-render-function/input.tsx b/codemods/v14-async-functions/tests/fixtures/custom-render-function/input.tsx similarity index 100% rename from codemods/v14-render-async/tests/fixtures/custom-render-function/input.tsx rename to codemods/v14-async-functions/tests/fixtures/custom-render-function/input.tsx diff --git a/codemods/v14-render-async/tests/fixtures/fireevent-call/expected.tsx b/codemods/v14-async-functions/tests/fixtures/fireevent-call/expected.tsx similarity index 100% rename from codemods/v14-render-async/tests/fixtures/fireevent-call/expected.tsx rename to codemods/v14-async-functions/tests/fixtures/fireevent-call/expected.tsx diff --git a/codemods/v14-render-async/tests/fixtures/fireevent-call/input.tsx b/codemods/v14-async-functions/tests/fixtures/fireevent-call/input.tsx similarity index 100% rename from codemods/v14-render-async/tests/fixtures/fireevent-call/input.tsx rename to codemods/v14-async-functions/tests/fixtures/fireevent-call/input.tsx diff --git a/codemods/v14-render-async/tests/fixtures/fireevent-methods/expected.tsx b/codemods/v14-async-functions/tests/fixtures/fireevent-methods/expected.tsx similarity index 100% rename from codemods/v14-render-async/tests/fixtures/fireevent-methods/expected.tsx rename to codemods/v14-async-functions/tests/fixtures/fireevent-methods/expected.tsx diff --git a/codemods/v14-render-async/tests/fixtures/fireevent-methods/input.tsx b/codemods/v14-async-functions/tests/fixtures/fireevent-methods/input.tsx similarity index 100% rename from codemods/v14-render-async/tests/fixtures/fireevent-methods/input.tsx rename to codemods/v14-async-functions/tests/fixtures/fireevent-methods/input.tsx diff --git a/codemods/v14-render-async/tests/fixtures/function-declaration/expected.tsx b/codemods/v14-async-functions/tests/fixtures/function-declaration/expected.tsx similarity index 100% rename from codemods/v14-render-async/tests/fixtures/function-declaration/expected.tsx rename to codemods/v14-async-functions/tests/fixtures/function-declaration/expected.tsx diff --git a/codemods/v14-render-async/tests/fixtures/function-declaration/input.tsx b/codemods/v14-async-functions/tests/fixtures/function-declaration/input.tsx similarity index 100% rename from codemods/v14-render-async/tests/fixtures/function-declaration/input.tsx rename to codemods/v14-async-functions/tests/fixtures/function-declaration/input.tsx diff --git a/codemods/v14-render-async/tests/fixtures/helper-function/expected.tsx b/codemods/v14-async-functions/tests/fixtures/helper-function/expected.tsx similarity index 100% rename from codemods/v14-render-async/tests/fixtures/helper-function/expected.tsx rename to codemods/v14-async-functions/tests/fixtures/helper-function/expected.tsx diff --git a/codemods/v14-render-async/tests/fixtures/helper-function/input.tsx b/codemods/v14-async-functions/tests/fixtures/helper-function/input.tsx similarity index 100% rename from codemods/v14-render-async/tests/fixtures/helper-function/input.tsx rename to codemods/v14-async-functions/tests/fixtures/helper-function/input.tsx diff --git a/codemods/v14-render-async/tests/fixtures/it-instead-of-test/expected.tsx b/codemods/v14-async-functions/tests/fixtures/it-instead-of-test/expected.tsx similarity index 100% rename from codemods/v14-render-async/tests/fixtures/it-instead-of-test/expected.tsx rename to codemods/v14-async-functions/tests/fixtures/it-instead-of-test/expected.tsx diff --git a/codemods/v14-render-async/tests/fixtures/it-instead-of-test/input.tsx b/codemods/v14-async-functions/tests/fixtures/it-instead-of-test/input.tsx similarity index 100% rename from codemods/v14-render-async/tests/fixtures/it-instead-of-test/input.tsx rename to codemods/v14-async-functions/tests/fixtures/it-instead-of-test/input.tsx diff --git a/codemods/v14-render-async/tests/fixtures/multiple-renders/expected.tsx b/codemods/v14-async-functions/tests/fixtures/multiple-renders/expected.tsx similarity index 100% rename from codemods/v14-render-async/tests/fixtures/multiple-renders/expected.tsx rename to codemods/v14-async-functions/tests/fixtures/multiple-renders/expected.tsx diff --git a/codemods/v14-render-async/tests/fixtures/multiple-renders/input.tsx b/codemods/v14-async-functions/tests/fixtures/multiple-renders/input.tsx similarity index 100% rename from codemods/v14-render-async/tests/fixtures/multiple-renders/input.tsx rename to codemods/v14-async-functions/tests/fixtures/multiple-renders/input.tsx diff --git a/codemods/v14-render-async/tests/fixtures/no-rntl-import/expected.tsx b/codemods/v14-async-functions/tests/fixtures/no-rntl-import/expected.tsx similarity index 100% rename from codemods/v14-render-async/tests/fixtures/no-rntl-import/expected.tsx rename to codemods/v14-async-functions/tests/fixtures/no-rntl-import/expected.tsx diff --git a/codemods/v14-render-async/tests/fixtures/no-rntl-import/input.tsx b/codemods/v14-async-functions/tests/fixtures/no-rntl-import/input.tsx similarity index 100% rename from codemods/v14-render-async/tests/fixtures/no-rntl-import/input.tsx rename to codemods/v14-async-functions/tests/fixtures/no-rntl-import/input.tsx diff --git a/codemods/v14-render-async/tests/fixtures/render-with-options/expected.tsx b/codemods/v14-async-functions/tests/fixtures/render-with-options/expected.tsx similarity index 100% rename from codemods/v14-render-async/tests/fixtures/render-with-options/expected.tsx rename to codemods/v14-async-functions/tests/fixtures/render-with-options/expected.tsx diff --git a/codemods/v14-render-async/tests/fixtures/render-with-options/input.tsx b/codemods/v14-async-functions/tests/fixtures/render-with-options/input.tsx similarity index 100% rename from codemods/v14-render-async/tests/fixtures/render-with-options/input.tsx rename to codemods/v14-async-functions/tests/fixtures/render-with-options/input.tsx 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-render-async/tests/fixtures/renderhook-call/expected.tsx b/codemods/v14-async-functions/tests/fixtures/renderhook-call/expected.tsx similarity index 100% rename from codemods/v14-render-async/tests/fixtures/renderhook-call/expected.tsx rename to codemods/v14-async-functions/tests/fixtures/renderhook-call/expected.tsx diff --git a/codemods/v14-render-async/tests/fixtures/renderhook-call/input.tsx b/codemods/v14-async-functions/tests/fixtures/renderhook-call/input.tsx similarity index 100% rename from codemods/v14-render-async/tests/fixtures/renderhook-call/input.tsx rename to codemods/v14-async-functions/tests/fixtures/renderhook-call/input.tsx 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-render-async/tests/fixtures/skip-variants/expected.tsx b/codemods/v14-async-functions/tests/fixtures/skip-variants/expected.tsx similarity index 100% rename from codemods/v14-render-async/tests/fixtures/skip-variants/expected.tsx rename to codemods/v14-async-functions/tests/fixtures/skip-variants/expected.tsx diff --git a/codemods/v14-render-async/tests/fixtures/skip-variants/input.tsx b/codemods/v14-async-functions/tests/fixtures/skip-variants/input.tsx similarity index 100% rename from codemods/v14-render-async/tests/fixtures/skip-variants/input.tsx rename to codemods/v14-async-functions/tests/fixtures/skip-variants/input.tsx diff --git a/codemods/v14-render-async/tests/fixtures/test-skip/expected.tsx b/codemods/v14-async-functions/tests/fixtures/test-skip/expected.tsx similarity index 100% rename from codemods/v14-render-async/tests/fixtures/test-skip/expected.tsx rename to codemods/v14-async-functions/tests/fixtures/test-skip/expected.tsx diff --git a/codemods/v14-render-async/tests/fixtures/test-skip/input.tsx b/codemods/v14-async-functions/tests/fixtures/test-skip/input.tsx similarity index 100% rename from codemods/v14-render-async/tests/fixtures/test-skip/input.tsx rename to codemods/v14-async-functions/tests/fixtures/test-skip/input.tsx diff --git a/codemods/v14-render-async/tsconfig.json b/codemods/v14-async-functions/tsconfig.json similarity index 100% rename from codemods/v14-render-async/tsconfig.json rename to codemods/v14-async-functions/tsconfig.json diff --git a/codemods/v14-render-async/workflow.yaml b/codemods/v14-async-functions/workflow.yaml similarity index 100% rename from codemods/v14-render-async/workflow.yaml rename to codemods/v14-async-functions/workflow.yaml diff --git a/codemods/v14-render-async/codemod.yaml b/codemods/v14-render-async/codemod.yaml deleted file mode 100644 index 20c559a22..000000000 --- a/codemods/v14-render-async/codemod.yaml +++ /dev/null @@ -1,20 +0,0 @@ -schema_version: "1.0" - -name: "@testing-library/react-native-v14-render-async" -version: "0.1.0" -description: "Codemod to migrate render() calls to await render() for RNTL v14" -author: "Callstack" -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-render-async/yarn.lock b/codemods/v14-render-async/yarn.lock deleted file mode 100644 index 2dfe29b80..000000000 --- a/codemods/v14-render-async/yarn.lock +++ /dev/null @@ -1,42 +0,0 @@ -# This file is generated by running "yarn install" inside your project. -# Manual changes might be lost - proceed with caution! - -__metadata: - version: 8 - cacheKey: 10c0 - -"@codemod.com/jssg-types@npm:^1.3.0": - version: 1.3.1 - resolution: "@codemod.com/jssg-types@npm:1.3.1" - checksum: 10c0/5936b2fbcfaaec53e2774c92b0e1e3bd6326b676391625a74aac495d6a4f8157c154132b80f5beae362a66cd9f0fb3c84746ca68157be6fa009b718f37d27462 - languageName: node - linkType: hard - -"@testing-library/react-native-v14-render-async@workspace:.": - version: 0.0.0-use.local - resolution: "@testing-library/react-native-v14-render-async@workspace:." - dependencies: - "@codemod.com/jssg-types": "npm:^1.3.0" - typescript: "npm:^5.8.3" - languageName: unknown - linkType: soft - -"typescript@npm:^5.8.3": - version: 5.9.3 - resolution: "typescript@npm:5.9.3" - bin: - tsc: bin/tsc - tsserver: bin/tsserver - checksum: 10c0/6bd7552ce39f97e711db5aa048f6f9995b53f1c52f7d8667c1abdc1700c68a76a308f579cd309ce6b53646deb4e9a1be7c813a93baaf0a28ccd536a30270e1c5 - languageName: node - linkType: hard - -"typescript@patch:typescript@npm%3A^5.8.3#optional!builtin": - version: 5.9.3 - resolution: "typescript@patch:typescript@npm%3A5.9.3#optional!builtin::version=5.9.3&hash=5786d5" - bin: - tsc: bin/tsc - tsserver: bin/tsserver - checksum: 10c0/ad09fdf7a756814dce65bc60c1657b40d44451346858eea230e10f2e95a289d9183b6e32e5c11e95acc0ccc214b4f36289dcad4bf1886b0adb84d711d336a430 - languageName: node - linkType: hard diff --git a/codemods/v14-update-dependencies/.gitignore b/codemods/v14-update-deps/.gitignore similarity index 100% rename from codemods/v14-update-dependencies/.gitignore rename to codemods/v14-update-deps/.gitignore diff --git a/codemods/v14-update-dependencies/README.md b/codemods/v14-update-deps/README.md similarity index 93% rename from codemods/v14-update-dependencies/README.md rename to codemods/v14-update-deps/README.md index d75da16ed..76a989ce1 100644 --- a/codemods/v14-update-dependencies/README.md +++ b/codemods/v14-update-deps/README.md @@ -29,10 +29,10 @@ This codemod automatically updates your `package.json` dependencies to match the ```bash # Run on your project -npx codemod@latest workflow run -w ./codemods/v14-update-dependencies/workflow.yaml --target ./path/to/your/project +npx codemod@latest workflow run -w ./codemods/v14-update-deps/workflow.yaml --target ./path/to/your/project # Or if published to the registry -npx codemod@latest run @testing-library/react-native-v14-update-dependencies --target ./path/to/your/project +npx codemod@latest run @testing-library/react-native-v14-update-deps --target ./path/to/your/project ``` ### Example transformations @@ -123,7 +123,7 @@ npx codemod@latest run @testing-library/react-native-v14-update-dependencies --t Run the test suite: ```bash -cd codemods/v14-update-dependencies +cd codemods/v14-update-deps npm test ``` @@ -144,7 +144,7 @@ npm test ``` 3. **Run the render-async codemod** to update your test code: ```bash - npx codemod@latest run @testing-library/react-native-v14-render-async --target ./path/to/your/tests + npx codemod@latest run @testing-library/react-native-v14-async-functions --target ./path/to/your/tests ``` 4. **Review and test** your changes 5. **Update your RNTL version** to a specific alpha version if needed diff --git a/codemods/v14-update-dependencies/codemod.yaml b/codemods/v14-update-deps/codemod.yaml similarity index 79% rename from codemods/v14-update-dependencies/codemod.yaml rename to codemods/v14-update-deps/codemod.yaml index 098b4a232..933ad8888 100644 --- a/codemods/v14-update-dependencies/codemod.yaml +++ b/codemods/v14-update-deps/codemod.yaml @@ -1,9 +1,9 @@ schema_version: "1.0" -name: "@testing-library/react-native-v14-update-dependencies" +name: "@testing-library/react-native-v14-update-deps" version: "0.1.0" description: "Codemod to update dependencies for RNTL v14 migration" -author: "Callstack" +author: "Maciej Jastrzebski" license: "MIT" workflow: "workflow.yaml" diff --git a/codemods/v14-update-dependencies/package.json b/codemods/v14-update-deps/package.json similarity index 76% rename from codemods/v14-update-dependencies/package.json rename to codemods/v14-update-deps/package.json index 1549e86f2..80920de7e 100644 --- a/codemods/v14-update-dependencies/package.json +++ b/codemods/v14-update-deps/package.json @@ -1,5 +1,5 @@ { - "name": "@testing-library/react-native-v14-update-dependencies", + "name": "@testing-library/react-native-v14-update-deps", "version": "0.1.0", "description": "Codemod to update dependencies for RNTL v14 migration", "type": "module", diff --git a/codemods/v14-update-dependencies/scripts/codemod-json.js b/codemods/v14-update-deps/scripts/codemod-json.js similarity index 100% rename from codemods/v14-update-dependencies/scripts/codemod-json.js rename to codemods/v14-update-deps/scripts/codemod-json.js diff --git a/codemods/v14-update-dependencies/scripts/test.js b/codemods/v14-update-deps/scripts/test.js similarity index 100% rename from codemods/v14-update-dependencies/scripts/test.js rename to codemods/v14-update-deps/scripts/test.js diff --git a/codemods/v14-update-dependencies/tests/fixtures/already-alpha/expected.json b/codemods/v14-update-deps/tests/fixtures/already-alpha/expected.json similarity index 100% rename from codemods/v14-update-dependencies/tests/fixtures/already-alpha/expected.json rename to codemods/v14-update-deps/tests/fixtures/already-alpha/expected.json diff --git a/codemods/v14-update-dependencies/tests/fixtures/already-alpha/input.json b/codemods/v14-update-deps/tests/fixtures/already-alpha/input.json similarity index 100% rename from codemods/v14-update-dependencies/tests/fixtures/already-alpha/input.json rename to codemods/v14-update-deps/tests/fixtures/already-alpha/input.json diff --git a/codemods/v14-update-dependencies/tests/fixtures/basic-update/expected.json b/codemods/v14-update-deps/tests/fixtures/basic-update/expected.json similarity index 100% rename from codemods/v14-update-dependencies/tests/fixtures/basic-update/expected.json rename to codemods/v14-update-deps/tests/fixtures/basic-update/expected.json diff --git a/codemods/v14-update-dependencies/tests/fixtures/basic-update/input.json b/codemods/v14-update-deps/tests/fixtures/basic-update/input.json similarity index 100% rename from codemods/v14-update-dependencies/tests/fixtures/basic-update/input.json rename to codemods/v14-update-deps/tests/fixtures/basic-update/input.json diff --git a/codemods/v14-update-dependencies/tests/fixtures/move-from-deps/expected.json b/codemods/v14-update-deps/tests/fixtures/move-from-deps/expected.json similarity index 100% rename from codemods/v14-update-dependencies/tests/fixtures/move-from-deps/expected.json rename to codemods/v14-update-deps/tests/fixtures/move-from-deps/expected.json diff --git a/codemods/v14-update-dependencies/tests/fixtures/move-from-deps/input.json b/codemods/v14-update-deps/tests/fixtures/move-from-deps/input.json similarity index 100% rename from codemods/v14-update-dependencies/tests/fixtures/move-from-deps/input.json rename to codemods/v14-update-deps/tests/fixtures/move-from-deps/input.json diff --git a/codemods/v14-update-dependencies/tests/fixtures/no-rntl-or-utr/expected.json b/codemods/v14-update-deps/tests/fixtures/no-rntl-or-utr/expected.json similarity index 100% rename from codemods/v14-update-dependencies/tests/fixtures/no-rntl-or-utr/expected.json rename to codemods/v14-update-deps/tests/fixtures/no-rntl-or-utr/expected.json diff --git a/codemods/v14-update-dependencies/tests/fixtures/no-rntl-or-utr/input.json b/codemods/v14-update-deps/tests/fixtures/no-rntl-or-utr/input.json similarity index 100% rename from codemods/v14-update-dependencies/tests/fixtures/no-rntl-or-utr/input.json rename to codemods/v14-update-deps/tests/fixtures/no-rntl-or-utr/input.json diff --git a/codemods/v14-update-dependencies/tests/fixtures/no-rntl/expected.json b/codemods/v14-update-deps/tests/fixtures/no-rntl/expected.json similarity index 100% rename from codemods/v14-update-dependencies/tests/fixtures/no-rntl/expected.json rename to codemods/v14-update-deps/tests/fixtures/no-rntl/expected.json diff --git a/codemods/v14-update-dependencies/tests/fixtures/no-rntl/input.json b/codemods/v14-update-deps/tests/fixtures/no-rntl/input.json similarity index 100% rename from codemods/v14-update-dependencies/tests/fixtures/no-rntl/input.json rename to codemods/v14-update-deps/tests/fixtures/no-rntl/input.json diff --git a/codemods/v14-update-dependencies/tests/fixtures/rntl-in-devdeps/expected.json b/codemods/v14-update-deps/tests/fixtures/rntl-in-devdeps/expected.json similarity index 100% rename from codemods/v14-update-dependencies/tests/fixtures/rntl-in-devdeps/expected.json rename to codemods/v14-update-deps/tests/fixtures/rntl-in-devdeps/expected.json diff --git a/codemods/v14-update-dependencies/tests/fixtures/rntl-in-devdeps/input.json b/codemods/v14-update-deps/tests/fixtures/rntl-in-devdeps/input.json similarity index 100% rename from codemods/v14-update-dependencies/tests/fixtures/rntl-in-devdeps/input.json rename to codemods/v14-update-deps/tests/fixtures/rntl-in-devdeps/input.json diff --git a/codemods/v14-update-dependencies/tests/fixtures/with-peer-deps/expected.json b/codemods/v14-update-deps/tests/fixtures/with-peer-deps/expected.json similarity index 100% rename from codemods/v14-update-dependencies/tests/fixtures/with-peer-deps/expected.json rename to codemods/v14-update-deps/tests/fixtures/with-peer-deps/expected.json diff --git a/codemods/v14-update-dependencies/tests/fixtures/with-peer-deps/input.json b/codemods/v14-update-deps/tests/fixtures/with-peer-deps/input.json similarity index 100% rename from codemods/v14-update-dependencies/tests/fixtures/with-peer-deps/input.json rename to codemods/v14-update-deps/tests/fixtures/with-peer-deps/input.json diff --git a/codemods/v14-update-dependencies/workflow.yaml b/codemods/v14-update-deps/workflow.yaml similarity index 100% rename from codemods/v14-update-dependencies/workflow.yaml rename to codemods/v14-update-deps/workflow.yaml From c8f7403b5c364f857743c331bc4073690f10d417 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Tue, 6 Jan 2026 16:45:59 +0100 Subject: [PATCH 12/27] rename renderASync --- .../v14-async-functions/scripts/codemod.ts | 61 +++++++++++++++++-- .../async-variants-rename/expected.tsx | 7 +++ .../fixtures/async-variants-rename/input.tsx | 7 +++ 3 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 codemods/v14-async-functions/tests/fixtures/async-variants-rename/expected.tsx create mode 100644 codemods/v14-async-functions/tests/fixtures/async-variants-rename/input.tsx diff --git a/codemods/v14-async-functions/scripts/codemod.ts b/codemods/v14-async-functions/scripts/codemod.ts index 5da961a6d..16688b191 100644 --- a/codemods/v14-async-functions/scripts/codemod.ts +++ b/codemods/v14-async-functions/scripts/codemod.ts @@ -15,7 +15,14 @@ const SCREEN_METHODS = new Set(['rerender', 'unmount']); const RENDERER_METHODS = new Set(['rerender', 'unmount']); // Variants that should be skipped (they're already async or have different behavior) -const SKIP_VARIANTS = new Set(['renderAsync', 'unsafe_renderHookSync', 'unsafe_act']); +const SKIP_VARIANTS = new Set(['unsafe_renderHookSync', 'unsafe_act']); + +// Async variants that should be renamed to their sync names (they're already async) +const ASYNC_VARIANTS_TO_RENAME = new Map([ + ['renderAsync', 'render'], + ['renderHookAsync', 'renderHook'], + ['fireEventAsync', 'fireEvent'], +]); const transform: Transform = async (root, options) => { const rootNode = root.root(); @@ -55,6 +62,10 @@ const transform: Transform = async (root, options) => { // Track which functions are imported using a Set const importedFunctions = new Set(); + + // Initialize edits array for collecting transformations + const edits: Edit[] = []; + for (const importStmt of rntlImports) { const importClause = importStmt.find({ rule: { kind: 'import_clause' }, @@ -75,7 +86,19 @@ const transform: Transform = async (root, options) => { }); if (identifier) { const funcName = identifier.text(); - if (FUNCTIONS_TO_TRANSFORM.has(funcName)) { + // Check if this is an async variant that needs to be renamed + if (ASYNC_VARIANTS_TO_RENAME.has(funcName)) { + const newName = ASYNC_VARIANTS_TO_RENAME.get(funcName)!; + // Rename in import: renderAsync -> render + const identifierRange = identifier.range(); + edits.push({ + startPos: identifierRange.start.index, + endPos: identifierRange.end.index, + insertedText: newName, + }); + // Track the renamed function as imported + importedFunctions.add(newName); + } else if (FUNCTIONS_TO_TRANSFORM.has(funcName)) { importedFunctions.add(funcName); } } @@ -112,10 +135,38 @@ const transform: Transform = async (root, options) => { return null; // None of the target functions are imported and no custom render functions, skip } + // Step 1.5: Rename all usages of async variants (renderAsync -> render, etc.) + // Find all calls to async variants and rename them throughout the file + for (const [asyncName, syncName] of ASYNC_VARIANTS_TO_RENAME.entries()) { + // Find all identifier usages of the async variant + const asyncIdentifiers = rootNode.findAll({ + rule: { + kind: 'identifier', + regex: `^${asyncName}$`, + }, + }); + + for (const identifier of asyncIdentifiers) { + // Skip if it's already in an import (we handled that above) + const parent = identifier.parent(); + if (parent && parent.is('import_specifier')) { + continue; + } + // Rename the usage - these are already async so they don't need await + const identifierRange = identifier.range(); + edits.push({ + startPos: identifierRange.start.index, + endPos: identifierRange.end.index, + insertedText: syncName, + }); + } + } + // Step 2: Find all call expressions for imported functions const functionCalls: SgNode[] = []; // Find standalone function calls (render, act, renderHook, fireEvent) + // Note: renderAsync, renderHookAsync, fireEventAsync are already renamed above for (const funcName of importedFunctions) { const calls = rootNode.findAll({ rule: { @@ -335,10 +386,12 @@ const transform: Transform = async (root, options) => { } if (functionCalls.length === 0 && customRenderFunctionsSet.size === 0) { - return null; // No function calls found and no custom render functions to process + // If we have rename edits (from async variants), we should still return them + if (edits.length === 0) { + return null; // No function calls found and no custom render functions to process + } } - const edits: Edit[] = []; const functionsToMakeAsync = new Map>(); // Use Map with node ID to ensure uniqueness const customRenderFunctionsToMakeAsync = new Map>(); // Track custom render functions that need to be async 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')); +}); From d00633a2cfdb5b7cc50189b44b5e03159edbfc1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Tue, 6 Jan 2026 18:34:04 +0100 Subject: [PATCH 13/27] all files --- codemods/v14-async-functions/workflow.yaml | 32 ++++++++-------------- 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/codemods/v14-async-functions/workflow.yaml b/codemods/v14-async-functions/workflow.yaml index 3bd8e63db..62d083f52 100644 --- a/codemods/v14-async-functions/workflow.yaml +++ b/codemods/v14-async-functions/workflow.yaml @@ -1,32 +1,24 @@ # yaml-language-server: $schema=https://raw.githubusercontent.com/codemod/codemod/refs/heads/main/schemas/workflow.json -version: "1" +version: '1' nodes: - id: apply-transforms name: Apply AST Transformations type: automatic steps: - - name: "Transform render() calls to await render()" + - name: 'Transform render() calls to await render()' js-ast-grep: js_file: scripts/codemod.ts - language: "tsx" + language: 'tsx' include: - - "**/*.test.ts" - - "**/*.test.tsx" - - "**/*.spec.ts" - - "**/*.spec.tsx" - - "**/*.test.js" - - "**/*.test.jsx" - - "**/*.spec.js" - - "**/*.spec.jsx" - - "**/__tests__/**/*.ts" - - "**/__tests__/**/*.tsx" - - "**/__tests__/**/*.js" - - "**/__tests__/**/*.jsx" + - '**/*.ts' + - '**/*.tsx' + - '**/*.js' + - '**/*.jsx' exclude: - - "**/node_modules/**" - - "**/build/**" - - "**/dist/**" - - "**/.next/**" - - "**/coverage/**" + - '**/node_modules/**' + - '**/build/**' + - '**/dist/**' + - '**/.next/**' + - '**/coverage/**' From db0b893a6193d69f9f191bf1407451e8e197abfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Tue, 6 Jan 2026 18:39:52 +0100 Subject: [PATCH 14/27] dedup imports --- .../v14-async-functions/scripts/codemod.ts | 146 ++++++++++++++++-- .../fixtures/duplicate-imports/expected.tsx | 16 ++ .../fixtures/duplicate-imports/input.tsx | 16 ++ .../tests/fixtures/skip-variants/expected.tsx | 4 +- 4 files changed, 169 insertions(+), 13 deletions(-) create mode 100644 codemods/v14-async-functions/tests/fixtures/duplicate-imports/expected.tsx create mode 100644 codemods/v14-async-functions/tests/fixtures/duplicate-imports/input.tsx diff --git a/codemods/v14-async-functions/scripts/codemod.ts b/codemods/v14-async-functions/scripts/codemod.ts index 16688b191..69f70071b 100644 --- a/codemods/v14-async-functions/scripts/codemod.ts +++ b/codemods/v14-async-functions/scripts/codemod.ts @@ -63,6 +63,9 @@ const transform: Transform = async (root, options) => { // Track which functions are imported using a Set const importedFunctions = new Set(); + // Track which async variant specifiers need to be removed (because target name already exists) + const specifiersToRemove: Array<{ specifier: SgNode; importStmt: SgNode }> = []; + // Initialize edits array for collecting transformations const edits: Edit[] = []; @@ -80,6 +83,20 @@ const transform: Transform = async (root, options) => { const specifiers = namedImports.findAll({ rule: { kind: 'import_specifier' }, }); + + // First pass: collect all imported names to detect duplicates + 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); + } + } + + // Second pass: process each specifier for (const specifier of specifiers) { const identifier = specifier.find({ rule: { kind: 'identifier' }, @@ -89,15 +106,24 @@ const transform: Transform = async (root, options) => { // Check if this is an async variant that needs to be renamed if (ASYNC_VARIANTS_TO_RENAME.has(funcName)) { const newName = ASYNC_VARIANTS_TO_RENAME.get(funcName)!; - // Rename in import: renderAsync -> render - const identifierRange = identifier.range(); - edits.push({ - startPos: identifierRange.start.index, - endPos: identifierRange.end.index, - insertedText: newName, - }); - // Track the renamed function as imported - importedFunctions.add(newName); + // Check if the target name is already imported + if (importedNames.has(newName)) { + // Target name already exists - mark this specifier for removal + // The renaming logic below will rename all usages of the async variant to the sync name + specifiersToRemove.push({ specifier, importStmt }); + // Track the target name as imported (since it already exists) + importedFunctions.add(newName); + } else { + // Target name doesn't exist, rename the async variant in the import + const identifierRange = identifier.range(); + edits.push({ + startPos: identifierRange.start.index, + endPos: identifierRange.end.index, + insertedText: newName, + }); + // Track the renamed function as imported + importedFunctions.add(newName); + } } else if (FUNCTIONS_TO_TRANSFORM.has(funcName)) { importedFunctions.add(funcName); } @@ -129,6 +155,54 @@ const transform: Transform = async (root, options) => { } } + // Remove duplicate specifiers (async variants whose target name already exists) + // Sort by position in reverse order to avoid offset issues + 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; + + // Check for trailing comma and whitespace + const textAfter = fullText.substring(specifierEnd); + const trailingCommaMatch = textAfter.match(/^\s*,\s*/); + + if (trailingCommaMatch) { + // Remove specifier and trailing comma/whitespace + edits.push({ + startPos: specifierRange.start.index, + endPos: specifierEnd + trailingCommaMatch[0].length, + insertedText: '', + }); + } else { + // Check for leading comma and whitespace before this specifier + const textBefore = fullText.substring(0, specifierRange.start.index); + const leadingCommaMatch = textBefore.match(/,\s*$/); + + if (leadingCommaMatch) { + // Remove leading comma/whitespace and specifier + edits.push({ + startPos: specifierRange.start.index - leadingCommaMatch[0].length, + endPos: specifierEnd, + insertedText: '', + }); + } else { + // Edge case: single specifier or malformed import (shouldn't happen normally) + // Just remove the specifier itself + edits.push({ + startPos: specifierRange.start.index, + endPos: specifierEnd, + insertedText: '', + }); + } + } + } + } + // If we have custom render functions to process, continue even if no RNTL functions are imported // (the file might only call custom render functions) if (importedFunctions.size === 0 && customRenderFunctionsSet.size === 0) { @@ -160,6 +234,30 @@ const transform: Transform = async (root, options) => { insertedText: syncName, }); } + + // Also handle member expressions like fireEventAsync.press -> fireEvent.press + 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, + }); + } + } } // Step 2: Find all call expressions for imported functions @@ -182,7 +280,33 @@ const transform: Transform = async (root, options) => { } // Find fireEvent method calls (fireEvent.press, fireEvent.changeText, fireEvent.scroll) + // Also check for fireEventAsync variants that will be renamed + const fireEventNames = new Set(); if (importedFunctions.has('fireEvent')) { + fireEventNames.add('fireEvent'); + } + // Also check for async variants that will be renamed to fireEvent + for (const [asyncName, syncName] of ASYNC_VARIANTS_TO_RENAME.entries()) { + if (syncName === 'fireEvent') { + // Check if this async variant was imported (even if it will be removed) + 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', @@ -202,8 +326,8 @@ const transform: Transform = async (root, options) => { if (object && property) { const objText = object.text(); const propText = property.text(); - // Check if it's fireEvent.methodName where methodName is one of our target methods - if (objText === 'fireEvent' && FIRE_EVENT_METHODS.has(propText)) { + // Check if it's fireEvent.methodName or fireEventAsync.methodName where methodName is one of our target methods + if (fireEventNames.has(objText) && FIRE_EVENT_METHODS.has(propText)) { functionCalls.push(call); } } 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..67c896570 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/duplicate-imports/expected.tsx @@ -0,0 +1,16 @@ +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..a8d7d5410 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/duplicate-imports/input.tsx @@ -0,0 +1,16 @@ +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/skip-variants/expected.tsx b/codemods/v14-async-functions/tests/fixtures/skip-variants/expected.tsx index ff2e2e34d..a6f1c1a89 100644 --- a/codemods/v14-async-functions/tests/fixtures/skip-variants/expected.tsx +++ b/codemods/v14-async-functions/tests/fixtures/skip-variants/expected.tsx @@ -1,4 +1,4 @@ -import { render, act, renderHook, unsafe_act, unsafe_renderHookSync, renderAsync } from '@testing-library/react-native'; +import { render, act, renderHook, unsafe_act, unsafe_renderHookSync } from '@testing-library/react-native'; test('skips unsafe variants', async () => { await render(); @@ -15,5 +15,5 @@ test('skips unsafe variants', async () => { unsafe_renderHookSync(() => ({ value: 43 })); - renderAsync(); + await render(); }); From c342395c96f449c0912ede3d97e3263499d5c9c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Tue, 6 Jan 2026 21:08:50 +0100 Subject: [PATCH 15/27] aboid making describe blocks async --- codemods/v14-async-functions/README.md | 53 +++- .../v14-async-functions/scripts/codemod.ts | 238 +++++++++++++++++- .../both-renderhook-renderer/expected.tsx | 9 + .../both-renderhook-renderer/input.tsx | 9 + .../fixtures/describe-block/expected.tsx | 18 ++ .../tests/fixtures/describe-block/input.tsx | 18 ++ .../renderhook-destructured/expected.tsx | 8 + .../renderhook-destructured/input.tsx | 8 + .../fixtures/renderhook-rerender/expected.tsx | 7 + .../fixtures/renderhook-rerender/input.tsx | 7 + .../fixtures/renderhook-unmount/expected.tsx | 6 + .../fixtures/renderhook-unmount/input.tsx | 6 + .../fixtures/test-each-combined/expected.tsx | 18 ++ .../fixtures/test-each-combined/input.tsx | 18 ++ .../tests/fixtures/test-each/expected.tsx | 15 ++ .../tests/fixtures/test-each/input.tsx | 15 ++ .../tests/fixtures/test-only/expected.tsx | 9 + .../tests/fixtures/test-only/input.tsx | 9 + 18 files changed, 464 insertions(+), 7 deletions(-) create mode 100644 codemods/v14-async-functions/tests/fixtures/both-renderhook-renderer/expected.tsx create mode 100644 codemods/v14-async-functions/tests/fixtures/both-renderhook-renderer/input.tsx create mode 100644 codemods/v14-async-functions/tests/fixtures/describe-block/expected.tsx create mode 100644 codemods/v14-async-functions/tests/fixtures/describe-block/input.tsx create mode 100644 codemods/v14-async-functions/tests/fixtures/renderhook-destructured/expected.tsx create mode 100644 codemods/v14-async-functions/tests/fixtures/renderhook-destructured/input.tsx create mode 100644 codemods/v14-async-functions/tests/fixtures/renderhook-rerender/expected.tsx create mode 100644 codemods/v14-async-functions/tests/fixtures/renderhook-rerender/input.tsx create mode 100644 codemods/v14-async-functions/tests/fixtures/renderhook-unmount/expected.tsx create mode 100644 codemods/v14-async-functions/tests/fixtures/renderhook-unmount/input.tsx create mode 100644 codemods/v14-async-functions/tests/fixtures/test-each-combined/expected.tsx create mode 100644 codemods/v14-async-functions/tests/fixtures/test-each-combined/input.tsx create mode 100644 codemods/v14-async-functions/tests/fixtures/test-each/expected.tsx create mode 100644 codemods/v14-async-functions/tests/fixtures/test-each/input.tsx create mode 100644 codemods/v14-async-functions/tests/fixtures/test-only/expected.tsx create mode 100644 codemods/v14-async-functions/tests/fixtures/test-only/input.tsx diff --git a/codemods/v14-async-functions/README.md b/codemods/v14-async-functions/README.md index 9d2ecb048..469f05399 100644 --- a/codemods/v14-async-functions/README.md +++ b/codemods/v14-async-functions/README.md @@ -11,9 +11,11 @@ This codemod migrates your test files from React Native Testing Library v13 to v - ✅ Transforms `fireEvent.press()`, `fireEvent.changeText()`, and `fireEvent.scroll()` calls to `await fireEvent.press()`, etc. - ✅ Transforms `screen.rerender()` and `screen.unmount()` calls to `await screen.rerender()`, etc. - ✅ Transforms `renderer.rerender()` and `renderer.unmount()` calls (where renderer is the return value from `render()`) to `await renderer.rerender()`, etc. +- ✅ Transforms `hookResult.rerender()` and `hookResult.unmount()` calls (where hookResult is the return value from `renderHook()`) to `await hookResult.rerender()`, etc. - ✅ Makes test functions async if they're not already -- ✅ Handles `test()`, `it()`, `test.skip()`, and `it.skip()` patterns +- ✅ Handles `test()`, `it()`, `test.skip()`, `it.skip()`, `test.only()`, `it.only()`, `test.each()`, and `it.each()` patterns - ✅ Handles `beforeEach()`, `afterEach()`, `beforeAll()`, and `afterAll()` hooks +- ✅ Does NOT make `describe()` block callbacks async (they are just grouping mechanisms) - ✅ Preserves already-awaited function calls - ✅ Skips function calls in helper functions (not inside test callbacks) - ✅ Only transforms calls imported from `@testing-library/react-native` @@ -25,6 +27,7 @@ This codemod migrates your test files from React Native Testing Library v13 to v - ❌ Does not transform function calls from other libraries - ❌ Does not handle namespace imports (e.g., `import * as RNTL from '@testing-library/react-native'`) - ❌ Does not transform unsafe variants (`unsafe_act`, `unsafe_renderHookSync`) or `renderAsync` +- ❌ Does not make `describe()` block callbacks async (they are grouping mechanisms, not test functions) ## Usage @@ -340,6 +343,54 @@ test('uses custom render', async () => { }); ``` +#### Describe blocks (not made async) + +`describe()` blocks are grouping mechanisms and their callbacks are not made async, even if they contain `render` calls in helper functions. However, `test()` callbacks inside `describe` blocks are still made async. + +**Before:** +```typescript +import { render, screen } from '@testing-library/react-native'; + +describe('MyComponent', () => { + function setupComponent() { + render(); + } + + test('renders component', () => { + setupComponent(); + expect(screen.getByText('Hello')).toBeOnTheScreen(); + }); + + test('renders with direct render call', () => { + render(); + expect(screen.getByText('Hello')).toBeOnTheScreen(); + }); +}); +``` + +**After:** +```typescript +import { render, screen } from '@testing-library/react-native'; + +describe('MyComponent', () => { + 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(); + }); +}); +``` + +Note: The `describe` callback remains synchronous. The `test` callback that directly calls `render()` is made async, but the `test` callback that only calls a helper function (not in `CUSTOM_RENDER_FUNCTIONS`) remains synchronous. + ## Testing Run the test suite: diff --git a/codemods/v14-async-functions/scripts/codemod.ts b/codemods/v14-async-functions/scripts/codemod.ts index 69f70071b..7b7f191a8 100644 --- a/codemods/v14-async-functions/scripts/codemod.ts +++ b/codemods/v14-async-functions/scripts/codemod.ts @@ -509,6 +509,189 @@ const transform: Transform = async (root, options) => { } } + // Find renderHook result method calls (hookResult.rerender, hookResult.unmount) + // Track variables that are assigned the result of renderHook() calls + const renderHookVariables = new Set(); + // Track renamed method variables (e.g., rerenderHook from const { rerender: rerenderHook }) + const renderHookMethodVariables = new Set(); + + // Find all renderHook() calls and track what they're assigned to + if (importedFunctions.has('renderHook')) { + const renderHookCalls = rootNode.findAll({ + rule: { + kind: 'call_expression', + has: { + field: 'function', + kind: 'identifier', + regex: '^renderHook$', + }, + }, + }); + + for (const renderHookCall of renderHookCalls) { + // Check if this renderHook() call is assigned to a variable + // Handle both: const hookResult = renderHook(...) and const hookResult = await renderHook(...) + let parent = renderHookCall.parent(); + const isAwaited = parent && parent.is('await_expression'); + + // If awaited, get the await expression's parent + if (isAwaited) { + parent = parent.parent(); + } + + if (parent && parent.is('variable_declarator')) { + // Handle: const hookResult = renderHook(...) or const { rerender, unmount, result } = renderHook(...) + // Try to find object_pattern first (destructuring) + const objectPattern = parent.find({ + rule: { kind: 'object_pattern' }, + }); + if (objectPattern) { + // Destructuring: const { rerender, unmount, result } = ... or const { rerender: rerenderHook } = ... + const shorthandProps = objectPattern.findAll({ + rule: { kind: 'shorthand_property_identifier_pattern' }, + }); + for (const prop of shorthandProps) { + // The shorthand_property_identifier_pattern IS the identifier + const propName = prop.text(); + if (RENDERER_METHODS.has(propName)) { + renderHookVariables.add(propName); + } + } + // Also handle renamed properties: const { rerender: rerenderHook } = ... + const pairPatterns = objectPattern.findAll({ + rule: { kind: 'pair_pattern' }, + }); + for (const pair of pairPatterns) { + // Find the key (property name) and value (variable name) + 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 the key is rerender or unmount, track the value (renamed variable) + if (RENDERER_METHODS.has(keyName)) { + renderHookVariables.add(valueName); + renderHookMethodVariables.add(valueName); + } + } + } + } else { + // Simple variable assignment: const hookResult = ... + const nameNode = parent.find({ + rule: { kind: 'identifier' }, + }); + if (nameNode) { + const varName = nameNode.text(); + renderHookVariables.add(varName); + } + } + } else if (parent && parent.is('assignment_expression')) { + // Handle: hookResult = renderHook(...) or hookResult = await renderHook(...) + const left = parent.find({ + rule: { kind: 'identifier' }, + }); + if (left) { + const varName = left.text(); + renderHookVariables.add(varName); + } else { + // Handle destructuring assignment: { rerender } = renderHook(...) or { rerender: rerenderHook } = renderHook(...) + 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) { + // The shorthand_property_identifier_pattern IS the identifier + const propName = prop.text(); + if (RENDERER_METHODS.has(propName)) { + renderHookVariables.add(propName); + } + } + // Also handle renamed properties in assignment + 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 (RENDERER_METHODS.has(keyName)) { + renderHookVariables.add(valueName); + renderHookMethodVariables.add(valueName); + } + } + } + } + } + } + } + + // Now find calls to .rerender() or .unmount() on these variables + // Handle both: hookResult.rerender() and rerender() (when destructured) + if (renderHookVariables.size > 0) { + // Find member expression calls: hookResult.rerender() + const renderHookMethodCalls = rootNode.findAll({ + rule: { + kind: 'call_expression', + has: { + field: 'function', + kind: 'member_expression', + }, + }, + }); + + for (const call of renderHookMethodCalls) { + 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(); + // Check if it's renderHookVariable.methodName where methodName is rerender or unmount + if (renderHookVariables.has(objText) && RENDERER_METHODS.has(propText)) { + functionCalls.push(call); + } + } + } catch { + // field() might not be available for this node type, skip + } + } + } + + // Find direct identifier calls: rerender(), unmount(), or renamed variants like rerenderHook() + for (const varName of renderHookVariables) { + if (RENDERER_METHODS.has(varName) || renderHookMethodVariables.has(varName)) { + // This is a destructured method name (rerender, unmount, or renamed variant) + const directCalls = rootNode.findAll({ + rule: { + kind: 'call_expression', + has: { + field: 'function', + kind: 'identifier', + regex: `^${varName}$`, + }, + }, + }); + functionCalls.push(...directCalls); + } + } + } + } + if (functionCalls.length === 0 && customRenderFunctionsSet.size === 0) { // If we have rename edits (from async variants), we should still return them if (edits.length === 0) { @@ -913,11 +1096,11 @@ function findContainingTestFunction(node: SgNode): SgNode | null { const funcNode = grandParent.field('function'); if (funcNode) { const funcText = funcNode.text(); - // Match test, it, describe, beforeEach, afterEach, beforeAll, afterAll - if (/^(test|it|describe|beforeEach|afterEach|beforeAll|afterAll)$/.test(funcText)) { + // Match test, it, beforeEach, afterEach, beforeAll, afterAll + if (/^(test|it|beforeEach|afterEach|beforeAll|afterAll)$/.test(funcText)) { return current; } - // Handle test.skip, it.skip, etc. (member expressions) + // Handle test.skip, it.skip, test.only, it.only (member expressions) if (funcNode.is('member_expression')) { try { // @ts-expect-error - field() types are complex, but this works at runtime @@ -927,7 +1110,7 @@ function findContainingTestFunction(node: SgNode): SgNode | null { if (object && property) { const objText = object.text(); const propText = property.text(); - if ((objText === 'test' || objText === 'it') && propText === 'skip') { + if ((objText === 'test' || objText === 'it') && (propText === 'skip' || propText === 'only')) { return current; } } @@ -935,6 +1118,28 @@ function findContainingTestFunction(node: SgNode): SgNode | null { // field() might not be available for this node type, skip } } + // Handle test.each([...])('name', callback) and it.each([...])('name', callback) + // The function of the outer call is itself a call expression: test.each([...]) + if (funcNode.is('call_expression')) { + try { + const innerFuncNode = funcNode.field('function'); + if (innerFuncNode && innerFuncNode.is('member_expression')) { + // @ts-expect-error - field() types are complex, but this works at runtime + const object = innerFuncNode.field('object'); + // @ts-expect-error - field() types are complex, but this works at runtime + const property = innerFuncNode.field('property'); + if (object && property) { + const objText = object.text(); + const propText = property.text(); + if ((objText === 'test' || objText === 'it') && propText === 'each') { + return current; + } + } + } + } catch { + // field() might not be available for this node type, skip + } + } } } } @@ -943,7 +1148,7 @@ function findContainingTestFunction(node: SgNode): SgNode | null { const funcNode = parent.field('function'); if (funcNode) { const funcText = funcNode.text(); - if (/^(test|it|describe|beforeEach|afterEach|beforeAll|afterAll)$/.test(funcText)) { + if (/^(test|it|beforeEach|afterEach|beforeAll|afterAll)$/.test(funcText)) { return current; } if (funcNode.is('member_expression')) { @@ -955,7 +1160,7 @@ function findContainingTestFunction(node: SgNode): SgNode | null { if (object && property) { const objText = object.text(); const propText = property.text(); - if ((objText === 'test' || objText === 'it') && propText === 'skip') { + if ((objText === 'test' || objText === 'it') && (propText === 'skip' || propText === 'only')) { return current; } } @@ -963,6 +1168,27 @@ function findContainingTestFunction(node: SgNode): SgNode | null { // field() might not be available for this node type, skip } } + // Handle test.each([...])('name', callback) and it.each([...])('name', callback) + if (funcNode.is('call_expression')) { + try { + const innerFuncNode = funcNode.field('function'); + if (innerFuncNode && innerFuncNode.is('member_expression')) { + // @ts-expect-error - field() types are complex, but this works at runtime + const object = innerFuncNode.field('object'); + // @ts-expect-error - field() types are complex, but this works at runtime + const property = innerFuncNode.field('property'); + if (object && property) { + const objText = object.text(); + const propText = property.text(); + if ((objText === 'test' || objText === 'it') && propText === 'each') { + return current; + } + } + } + } catch { + // field() might not be available for this node type, skip + } + } } } } 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..08a93c4b6 --- /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..3c20f194e --- /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/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/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/test-each-combined/expected.tsx b/codemods/v14-async-functions/tests/fixtures/test-each-combined/expected.tsx new file mode 100644 index 000000000..272a89a97 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/test-each-combined/expected.tsx @@ -0,0 +1,18 @@ +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..9035bb9d4 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/test-each-combined/input.tsx @@ -0,0 +1,18 @@ +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..7caa8a6c9 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/test-each/expected.tsx @@ -0,0 +1,15 @@ +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..ed9d39618 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/test-each/input.tsx @@ -0,0 +1,15 @@ +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(); +}); From ff2d0ddd13a58dd3de3aebbc2ffd36658695192d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Tue, 6 Jan 2026 21:24:38 +0100 Subject: [PATCH 16/27] refactor --- .../v14-async-functions/scripts/codemod.ts | 923 ++++++++---------- .../v14-update-deps/scripts/codemod-json.js | 205 ++-- 2 files changed, 521 insertions(+), 607 deletions(-) diff --git a/codemods/v14-async-functions/scripts/codemod.ts b/codemods/v14-async-functions/scripts/codemod.ts index 7b7f191a8..d75fb2e76 100644 --- a/codemods/v14-async-functions/scripts/codemod.ts +++ b/codemods/v14-async-functions/scripts/codemod.ts @@ -2,34 +2,18 @@ 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'; -// Functions that should be transformed to async const FUNCTIONS_TO_TRANSFORM = new Set(['render', 'renderHook', 'act', 'fireEvent']); - -// fireEvent methods that should be transformed to async const FIRE_EVENT_METHODS = new Set(['press', 'changeText', 'scroll']); - -// Screen methods that should be transformed to async const SCREEN_METHODS = new Set(['rerender', 'unmount']); - -// Renderer methods that should be transformed to async (methods on render() return value) const RENDERER_METHODS = new Set(['rerender', 'unmount']); - -// Variants that should be skipped (they're already async or have different behavior) const SKIP_VARIANTS = new Set(['unsafe_renderHookSync', 'unsafe_act']); - -// Async variants that should be renamed to their sync names (they're already async) const ASYNC_VARIANTS_TO_RENAME = new Map([ ['renderAsync', 'render'], ['renderHookAsync', 'renderHook'], ['fireEventAsync', 'fireEvent'], ]); -const transform: Transform = async (root, options) => { - const rootNode = root.root(); - - // Parse custom render functions from workflow parameters or environment variable - // Priority: 1. --param customRenderFunctions=... 2. CUSTOM_RENDER_FUNCTIONS env var - // Format: "renderWithProviders,renderWithTheme,renderCustom" +function parseCustomRenderFunctionsFromOptions(options: any): Set { const customRenderFunctionsParam = options?.params?.customRenderFunctions ? String(options.params.customRenderFunctions) : ''; @@ -42,9 +26,11 @@ const transform: Transform = async (root, options) => { .filter((name) => name.length > 0) .forEach((name) => customRenderFunctionsSet.add(name)); } + return customRenderFunctionsSet; +} - // Step 1: Check if any of the target functions are imported from @testing-library/react-native - const rntlImports = rootNode.findAll({ +function findRNTLImportStatements(rootNode: SgNode): SgNode[] { + return rootNode.findAll({ rule: { kind: 'import_statement', has: { @@ -53,29 +39,21 @@ const transform: Transform = async (root, options) => { }, }, }); +} - // If we have custom render functions to process, we should still process the file - // even if it doesn't have RNTL imports (it might call custom render functions) - if (rntlImports.length === 0 && customRenderFunctionsSet.size === 0) { - return null; // No RNTL imports and no custom render functions, skip this file - } - - // Track which functions are imported using a Set +function extractImportedFunctionNames( + rntlImports: SgNode[], + specifiersToRemove: Array<{ specifier: SgNode; importStmt: SgNode }>, + edits: Edit[], +): Set { const importedFunctions = new Set(); - - // Track which async variant specifiers need to be removed (because target name already exists) - const specifiersToRemove: Array<{ specifier: SgNode; importStmt: SgNode }> = []; - - // Initialize edits array for collecting transformations - const edits: Edit[] = []; - + for (const importStmt of rntlImports) { const importClause = importStmt.find({ rule: { kind: 'import_clause' }, }); if (!importClause) continue; - // Check for named imports: import { render, act, renderHook, ... } from ... const namedImports = importClause.find({ rule: { kind: 'named_imports' }, }); @@ -83,8 +61,7 @@ const transform: Transform = async (root, options) => { const specifiers = namedImports.findAll({ rule: { kind: 'import_specifier' }, }); - - // First pass: collect all imported names to detect duplicates + const importedNames = new Set(); for (const specifier of specifiers) { const identifier = specifier.find({ @@ -95,33 +72,25 @@ const transform: Transform = async (root, options) => { importedNames.add(funcName); } } - - // Second pass: process each specifier + for (const specifier of specifiers) { const identifier = specifier.find({ rule: { kind: 'identifier' }, }); if (identifier) { const funcName = identifier.text(); - // Check if this is an async variant that needs to be renamed if (ASYNC_VARIANTS_TO_RENAME.has(funcName)) { const newName = ASYNC_VARIANTS_TO_RENAME.get(funcName)!; - // Check if the target name is already imported if (importedNames.has(newName)) { - // Target name already exists - mark this specifier for removal - // The renaming logic below will rename all usages of the async variant to the sync name specifiersToRemove.push({ specifier, importStmt }); - // Track the target name as imported (since it already exists) importedFunctions.add(newName); } else { - // Target name doesn't exist, rename the async variant in the import const identifierRange = identifier.range(); edits.push({ startPos: identifierRange.start.index, endPos: identifierRange.end.index, insertedText: newName, }); - // Track the renamed function as imported importedFunctions.add(newName); } } else if (FUNCTIONS_TO_TRANSFORM.has(funcName)) { @@ -131,7 +100,6 @@ const transform: Transform = async (root, options) => { } } - // Check for default import: import render from ... const defaultImport = importClause.find({ rule: { kind: 'identifier' }, }); @@ -142,57 +110,53 @@ const transform: Transform = async (root, options) => { } } - // Check for namespace import: import * as RNTL from ... const namespaceImport = importClause.find({ rule: { kind: 'namespace_import' }, }); if (namespaceImport) { - // For namespace imports, we'll check if functions are called via the namespace - // This is handled in the call matching below - // We assume all functions might be available via namespace FUNCTIONS_TO_TRANSFORM.forEach((func) => importedFunctions.add(func)); break; } } - // Remove duplicate specifiers (async variants whose target name already exists) - // Sort by position in reverse order to avoid offset issues + return importedFunctions; +} + +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; - - // Check for trailing comma and whitespace + const textAfter = fullText.substring(specifierEnd); const trailingCommaMatch = textAfter.match(/^\s*,\s*/); - + if (trailingCommaMatch) { - // Remove specifier and trailing comma/whitespace edits.push({ startPos: specifierRange.start.index, endPos: specifierEnd + trailingCommaMatch[0].length, insertedText: '', }); } else { - // Check for leading comma and whitespace before this specifier const textBefore = fullText.substring(0, specifierRange.start.index); const leadingCommaMatch = textBefore.match(/,\s*$/); - + if (leadingCommaMatch) { - // Remove leading comma/whitespace and specifier edits.push({ startPos: specifierRange.start.index - leadingCommaMatch[0].length, endPos: specifierEnd, insertedText: '', }); } else { - // Edge case: single specifier or malformed import (shouldn't happen normally) - // Just remove the specifier itself edits.push({ startPos: specifierRange.start.index, endPos: specifierEnd, @@ -202,31 +166,22 @@ const transform: Transform = async (root, options) => { } } } +} - // If we have custom render functions to process, continue even if no RNTL functions are imported - // (the file might only call custom render functions) - if (importedFunctions.size === 0 && customRenderFunctionsSet.size === 0) { - return null; // None of the target functions are imported and no custom render functions, skip - } - - // Step 1.5: Rename all usages of async variants (renderAsync -> render, etc.) - // Find all calls to async variants and rename them throughout the file +function renameAsyncVariantsInUsages(rootNode: SgNode, edits: Edit[]): void { for (const [asyncName, syncName] of ASYNC_VARIANTS_TO_RENAME.entries()) { - // Find all identifier usages of the async variant const asyncIdentifiers = rootNode.findAll({ rule: { kind: 'identifier', regex: `^${asyncName}$`, }, }); - + for (const identifier of asyncIdentifiers) { - // Skip if it's already in an import (we handled that above) const parent = identifier.parent(); if (parent && parent.is('import_specifier')) { continue; } - // Rename the usage - these are already async so they don't need await const identifierRange = identifier.range(); edits.push({ startPos: identifierRange.start.index, @@ -234,8 +189,7 @@ const transform: Transform = async (root, options) => { insertedText: syncName, }); } - - // Also handle member expressions like fireEventAsync.press -> fireEvent.press + const memberExpressions = rootNode.findAll({ rule: { kind: 'member_expression', @@ -246,7 +200,7 @@ const transform: Transform = async (root, options) => { }, }, }); - + for (const memberExpr of memberExpressions) { const object = memberExpr.field('object'); if (object && object.is('identifier')) { @@ -259,12 +213,11 @@ const transform: Transform = async (root, options) => { } } } +} - // Step 2: Find all call expressions for imported functions +function findDirectFunctionCalls(rootNode: SgNode, importedFunctions: Set): SgNode[] { const functionCalls: SgNode[] = []; - // Find standalone function calls (render, act, renderHook, fireEvent) - // Note: renderAsync, renderHookAsync, fireEventAsync are already renamed above for (const funcName of importedFunctions) { const calls = rootNode.findAll({ rule: { @@ -279,16 +232,23 @@ const transform: Transform = async (root, options) => { functionCalls.push(...calls); } - // Find fireEvent method calls (fireEvent.press, fireEvent.changeText, fireEvent.scroll) - // Also check for fireEventAsync variants that will be renamed + 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'); } - // Also check for async variants that will be renamed to fireEvent + for (const [asyncName, syncName] of ASYNC_VARIANTS_TO_RENAME.entries()) { if (syncName === 'fireEvent') { - // Check if this async variant was imported (even if it will be removed) const wasImported = rntlImports.some((importStmt) => { const importClause = importStmt.find({ rule: { kind: 'import_clause' } }); if (!importClause) return false; @@ -305,7 +265,7 @@ const transform: Transform = async (root, options) => { } } } - + if (fireEventNames.size > 0) { const fireEventMethodCalls = rootNode.findAll({ rule: { @@ -326,20 +286,22 @@ const transform: Transform = async (root, options) => { if (object && property) { const objText = object.text(); const propText = property.text(); - // Check if it's fireEvent.methodName or fireEventAsync.methodName where methodName is one of our target methods if (fireEventNames.has(objText) && FIRE_EVENT_METHODS.has(propText)) { functionCalls.push(call); } } } catch { - // field() might not be available for this node type, skip + // Skip if field() is not available } } } } - // Find screen method calls (screen.rerender, screen.unmount) - // Check if screen is imported or available + return functionCalls; +} + +function findScreenMethodCalls(rootNode: SgNode): SgNode[] { + const functionCalls: SgNode[] = []; const screenMethodCalls = rootNode.findAll({ rule: { kind: 'call_expression', @@ -359,22 +321,22 @@ const transform: Transform = async (root, options) => { if (object && property) { const objText = object.text(); const propText = property.text(); - // Check if it's screen.methodName where methodName is rerender or unmount if (objText === 'screen' && SCREEN_METHODS.has(propText)) { functionCalls.push(call); } } } catch { - // field() might not be available for this node type, skip + // Skip if field() is not available } } } - // Find renderer method calls (renderer.rerender, renderer.unmount) - // Track variables that are assigned the result of render() calls + return functionCalls; +} + +function trackVariablesAssignedFromRender(rootNode: SgNode, importedFunctions: Set): Set { const rendererVariables = new Set(); - - // Find all render() calls and track what they're assigned to + if (importedFunctions.has('render')) { const renderCalls = rootNode.findAll({ rule: { @@ -388,36 +350,28 @@ const transform: Transform = async (root, options) => { }); for (const renderCall of renderCalls) { - // Check if this render() call is assigned to a variable - // Handle both: const renderer = render(...) and const renderer = await render(...) let parent = renderCall.parent(); const isAwaited = parent && parent.is('await_expression'); - - // If awaited, get the await expression's parent + if (isAwaited) { parent = parent.parent(); } - + if (parent && parent.is('variable_declarator')) { - // Handle: const renderer = render(...) or const { rerender, unmount } = render(...) - // Try to find object_pattern first (destructuring) const objectPattern = parent.find({ rule: { kind: 'object_pattern' }, }); if (objectPattern) { - // Destructuring: const { rerender, unmount } = ... const shorthandProps = objectPattern.findAll({ rule: { kind: 'shorthand_property_identifier_pattern' }, }); for (const prop of shorthandProps) { - // The shorthand_property_identifier_pattern IS the identifier const propName = prop.text(); if (RENDERER_METHODS.has(propName)) { rendererVariables.add(propName); } } } else { - // Simple variable assignment: const renderer = ... const nameNode = parent.find({ rule: { kind: 'identifier' }, }); @@ -427,7 +381,6 @@ const transform: Transform = async (root, options) => { } } } else if (parent && parent.is('assignment_expression')) { - // Handle: renderer = render(...) or renderer = await render(...) const left = parent.find({ rule: { kind: 'identifier' }, }); @@ -435,7 +388,6 @@ const transform: Transform = async (root, options) => { const varName = left.text(); rendererVariables.add(varName); } else { - // Handle destructuring assignment: { rerender } = render(...) const objectPattern = parent.find({ rule: { kind: 'object_pattern' }, }); @@ -444,7 +396,6 @@ const transform: Transform = async (root, options) => { rule: { kind: 'shorthand_property_identifier_pattern' }, }); for (const prop of shorthandProps) { - // The shorthand_property_identifier_pattern IS the identifier const propName = prop.text(); if (RENDERER_METHODS.has(propName)) { rendererVariables.add(propName); @@ -454,68 +405,71 @@ const transform: Transform = async (root, options) => { } } } + } - // Now find calls to .rerender() or .unmount() on these variables - // Handle both: renderer.rerender() and rerender() (when destructured) - if (rendererVariables.size > 0) { - // Find member expression calls: renderer.rerender() - const rendererMethodCalls = rootNode.findAll({ - rule: { - kind: 'call_expression', - has: { - field: 'function', - kind: 'member_expression', - }, + return rendererVariables; +} + +function findRendererMethodCalls(rootNode: SgNode, rendererVariables: Set): SgNode[] { + const functionCalls: SgNode[] = []; + + if (rendererVariables.size > 0) { + const rendererMethodCalls = rootNode.findAll({ + rule: { + kind: 'call_expression', + has: { + field: 'function', + kind: 'member_expression', }, - }); + }, + }); - for (const call of rendererMethodCalls) { - 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(); - // Check if it's rendererVariable.methodName where methodName is rerender or unmount - if (rendererVariables.has(objText) && RENDERER_METHODS.has(propText)) { - functionCalls.push(call); - } + for (const call of rendererMethodCalls) { + 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 (rendererVariables.has(objText) && RENDERER_METHODS.has(propText)) { + functionCalls.push(call); } - } catch { - // field() might not be available for this node type, skip } + } catch { + // Skip if field() is not available } } + } - // Find direct identifier calls: rerender() and unmount() (when destructured) - for (const varName of rendererVariables) { - if (RENDERER_METHODS.has(varName)) { - // This is a destructured method name (rerender or unmount) - const directCalls = rootNode.findAll({ - rule: { - kind: 'call_expression', - has: { - field: 'function', - kind: 'identifier', - regex: `^${varName}$`, - }, + for (const varName of rendererVariables) { + if (RENDERER_METHODS.has(varName)) { + const directCalls = rootNode.findAll({ + rule: { + kind: 'call_expression', + has: { + field: 'function', + kind: 'identifier', + regex: `^${varName}$`, }, - }); - functionCalls.push(...directCalls); - } + }, + }); + functionCalls.push(...directCalls); } } } - // Find renderHook result method calls (hookResult.rerender, hookResult.unmount) - // Track variables that are assigned the result of renderHook() calls + return functionCalls; +} + +function trackVariablesAssignedFromRenderHook(rootNode: SgNode, importedFunctions: Set): { + renderHookVariables: Set; + renderHookMethodVariables: Set; +} { const renderHookVariables = new Set(); - // Track renamed method variables (e.g., rerenderHook from const { rerender: rerenderHook }) const renderHookMethodVariables = new Set(); - - // Find all renderHook() calls and track what they're assigned to + if (importedFunctions.has('renderHook')) { const renderHookCalls = rootNode.findAll({ rule: { @@ -529,40 +483,31 @@ const transform: Transform = async (root, options) => { }); for (const renderHookCall of renderHookCalls) { - // Check if this renderHook() call is assigned to a variable - // Handle both: const hookResult = renderHook(...) and const hookResult = await renderHook(...) let parent = renderHookCall.parent(); const isAwaited = parent && parent.is('await_expression'); - - // If awaited, get the await expression's parent + if (isAwaited) { parent = parent.parent(); } - + if (parent && parent.is('variable_declarator')) { - // Handle: const hookResult = renderHook(...) or const { rerender, unmount, result } = renderHook(...) - // Try to find object_pattern first (destructuring) const objectPattern = parent.find({ rule: { kind: 'object_pattern' }, }); if (objectPattern) { - // Destructuring: const { rerender, unmount, result } = ... or const { rerender: rerenderHook } = ... const shorthandProps = objectPattern.findAll({ rule: { kind: 'shorthand_property_identifier_pattern' }, }); for (const prop of shorthandProps) { - // The shorthand_property_identifier_pattern IS the identifier const propName = prop.text(); if (RENDERER_METHODS.has(propName)) { renderHookVariables.add(propName); } } - // Also handle renamed properties: const { rerender: rerenderHook } = ... const pairPatterns = objectPattern.findAll({ rule: { kind: 'pair_pattern' }, }); for (const pair of pairPatterns) { - // Find the key (property name) and value (variable name) const key = pair.find({ rule: { kind: 'property_identifier' }, }); @@ -572,7 +517,6 @@ const transform: Transform = async (root, options) => { if (key && value) { const keyName = key.text(); const valueName = value.text(); - // If the key is rerender or unmount, track the value (renamed variable) if (RENDERER_METHODS.has(keyName)) { renderHookVariables.add(valueName); renderHookMethodVariables.add(valueName); @@ -580,7 +524,6 @@ const transform: Transform = async (root, options) => { } } } else { - // Simple variable assignment: const hookResult = ... const nameNode = parent.find({ rule: { kind: 'identifier' }, }); @@ -590,7 +533,6 @@ const transform: Transform = async (root, options) => { } } } else if (parent && parent.is('assignment_expression')) { - // Handle: hookResult = renderHook(...) or hookResult = await renderHook(...) const left = parent.find({ rule: { kind: 'identifier' }, }); @@ -598,7 +540,6 @@ const transform: Transform = async (root, options) => { const varName = left.text(); renderHookVariables.add(varName); } else { - // Handle destructuring assignment: { rerender } = renderHook(...) or { rerender: rerenderHook } = renderHook(...) const objectPattern = parent.find({ rule: { kind: 'object_pattern' }, }); @@ -607,13 +548,11 @@ const transform: Transform = async (root, options) => { rule: { kind: 'shorthand_property_identifier_pattern' }, }); for (const prop of shorthandProps) { - // The shorthand_property_identifier_pattern IS the identifier const propName = prop.text(); if (RENDERER_METHODS.has(propName)) { renderHookVariables.add(propName); } } - // Also handle renamed properties in assignment const pairPatterns = objectPattern.findAll({ rule: { kind: 'pair_pattern' }, }); @@ -637,344 +576,147 @@ const transform: Transform = async (root, options) => { } } } + } - // Now find calls to .rerender() or .unmount() on these variables - // Handle both: hookResult.rerender() and rerender() (when destructured) - if (renderHookVariables.size > 0) { - // Find member expression calls: hookResult.rerender() - const renderHookMethodCalls = rootNode.findAll({ - rule: { - kind: 'call_expression', - has: { - field: 'function', - kind: 'member_expression', - }, + return { renderHookVariables, renderHookMethodVariables }; +} + +function findRenderHookMethodCalls( + rootNode: SgNode, + renderHookVariables: Set, + renderHookMethodVariables: Set, +): SgNode[] { + const functionCalls: SgNode[] = []; + + if (renderHookVariables.size > 0) { + const renderHookMethodCalls = rootNode.findAll({ + rule: { + kind: 'call_expression', + has: { + field: 'function', + kind: 'member_expression', }, - }); + }, + }); - for (const call of renderHookMethodCalls) { - 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(); - // Check if it's renderHookVariable.methodName where methodName is rerender or unmount - if (renderHookVariables.has(objText) && RENDERER_METHODS.has(propText)) { - functionCalls.push(call); - } + for (const call of renderHookMethodCalls) { + 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 (renderHookVariables.has(objText) && RENDERER_METHODS.has(propText)) { + functionCalls.push(call); } - } catch { - // field() might not be available for this node type, skip } + } catch { + // Skip if field() is not available } } + } - // Find direct identifier calls: rerender(), unmount(), or renamed variants like rerenderHook() - for (const varName of renderHookVariables) { - if (RENDERER_METHODS.has(varName) || renderHookMethodVariables.has(varName)) { - // This is a destructured method name (rerender, unmount, or renamed variant) - const directCalls = rootNode.findAll({ - rule: { - kind: 'call_expression', - has: { - field: 'function', - kind: 'identifier', - regex: `^${varName}$`, - }, + for (const varName of renderHookVariables) { + if (RENDERER_METHODS.has(varName) || renderHookMethodVariables.has(varName)) { + const directCalls = rootNode.findAll({ + rule: { + kind: 'call_expression', + has: { + field: 'function', + kind: 'identifier', + regex: `^${varName}$`, }, - }); - functionCalls.push(...directCalls); - } + }, + }); + functionCalls.push(...directCalls); } } } - if (functionCalls.length === 0 && customRenderFunctionsSet.size === 0) { - // If we have rename edits (from async variants), we should still return them - if (edits.length === 0) { - return null; // No function calls found and no custom render functions to process + return functionCalls; +} + +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 functionsToMakeAsync = new Map>(); // Use Map with node ID to ensure uniqueness - const customRenderFunctionsToMakeAsync = new Map>(); // Track custom render functions that need to be async - - // Step 2.5: Find and process custom render function definitions - // Note: This only processes definitions. Calls to custom render functions are handled in Step 3.5 - // We need importedFunctions to be populated to find RNTL calls inside custom render functions - // If there are no RNTL imports but we have custom render functions, we still want to process - // calls to custom render functions in tests (Step 3.5), but we can't process their definitions - if (customRenderFunctionsSet.size > 0 && importedFunctions.size > 0) { - // Find function declarations - const functionDeclarations = rootNode.findAll({ - rule: { kind: 'function_declaration' }, + const variableDeclarations = rootNode.findAll({ + rule: { kind: 'lexical_declaration' }, + }); + for (const varDecl of variableDeclarations) { + const declarators = varDecl.findAll({ + rule: { kind: 'variable_declarator' }, }); - for (const funcDecl of functionDeclarations) { - const nameNode = funcDecl.find({ + for (const declarator of declarators) { + const nameNode = declarator.find({ rule: { kind: 'identifier' }, }); if (nameNode) { const funcName = nameNode.text(); if (customRenderFunctionsSet.has(funcName)) { - // Found a custom render function declaration - processCustomRenderFunction( - funcDecl, - importedFunctions, - edits, - customRenderFunctionsToMakeAsync, - rootNode, - ); - } - } - } - - // Find arrow functions and function expressions (const renderWithX = () => {} or const renderWithX = function() {}) - 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)) { - // Check if it's an arrow function or function expression - const init = declarator.find({ - rule: { - any: [{ kind: 'arrow_function' }, { kind: 'function_expression' }], - }, - }); - if (init) { - // Found a custom render function (arrow or expression) - processCustomRenderFunction( - init, - importedFunctions, - edits, - customRenderFunctionsToMakeAsync, - rootNode, - ); - } + const init = declarator.find({ + rule: { + any: [{ kind: 'arrow_function' }, { kind: 'function_expression' }], + }, + }); + if (init) { + customRenderFunctions.push(init); } } } } } - // Step 3: Process each function call - for (const functionCall of functionCalls) { - // Skip if already awaited - const parent = functionCall.parent(); - if (parent && parent.is('await_expression')) { - continue; // Already awaited, skip - } - - // Skip variants that should not be transformed - const functionNode = functionCall.field('function'); - if (functionNode) { - const funcName = functionNode.text(); - if (SKIP_VARIANTS.has(funcName)) { - continue; - } - } - - // Step 4: Find the containing function (test/it/hook callback) - const containingFunction = findContainingTestFunction(functionCall); - if (!containingFunction) { - // Not inside a test function or hook, skip (could be a helper function) - continue; - } - - // Step 5: Track functions that need to be made async - // Check if function is already async - // For arrow functions, async is a child node; for function declarations, it's before "function" - let isAsync = false; - if (containingFunction.is('arrow_function')) { - // Check if arrow function has async child node by checking children - const children = containingFunction.children(); - isAsync = children.some((child) => child.text() === 'async'); - } else { - // For function declarations/expressions, check text before - const funcStart = containingFunction.range().start.index; - const textBefore = rootNode.text().substring(Math.max(0, funcStart - 10), funcStart); - isAsync = textBefore.trim().endsWith('async'); - } - - // Only add if not already async and not already in the map - if (!isAsync && !functionsToMakeAsync.has(containingFunction.id())) { - functionsToMakeAsync.set(containingFunction.id(), containingFunction); - } - - // Step 6: Add await before function call - const callStart = functionCall.range().start.index; - edits.push({ - startPos: callStart, - endPos: callStart, - insertedText: 'await ', - }); - } - - // Step 3.5: Transform calls to custom render functions in tests - if (customRenderFunctionsSet.size > 0) { - const allCallExpressions = rootNode.findAll({ - rule: { kind: 'call_expression' }, - }); - let foundCustomCalls = 0; - 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')) { - // Skip member expressions (e.g., obj.renderWithX()) - continue; - } - - if (calledFunctionName && customRenderFunctionsSet.has(calledFunctionName)) { - foundCustomCalls++; - // Check if this call is inside a test function - const containingFunction = findContainingTestFunction(callExpr); - if (containingFunction) { - // Skip if already awaited - const parent = callExpr.parent(); - if (parent && parent.is('await_expression')) { - continue; - } - - // Track that the test function needs to be async - let isAsync = false; - if (containingFunction.is('arrow_function')) { - const children = containingFunction.children(); - isAsync = children.some((child) => child.text() === 'async'); - } else { - const funcStart = containingFunction.range().start.index; - const textBefore = rootNode.text().substring(Math.max(0, funcStart - 10), funcStart); - isAsync = textBefore.trim().endsWith('async'); - } + return customRenderFunctions; +} - if (!isAsync && !functionsToMakeAsync.has(containingFunction.id())) { - functionsToMakeAsync.set(containingFunction.id(), containingFunction); - } +function findCustomRenderFunctionCalls(rootNode: SgNode, customRenderFunctionsSet: Set): SgNode[] { + const customRenderCalls: SgNode[] = []; + const allCallExpressions = rootNode.findAll({ + rule: { kind: 'call_expression' }, + }); - // Add await before the call - const callStart = callExpr.range().start.index; - edits.push({ - startPos: callStart, - endPos: callStart, - insertedText: 'await ', - }); - } - } - } - } + for (const callExpr of allCallExpressions) { + const funcNode = callExpr.field('function'); + if (!funcNode) continue; - // Step 7: Add async keyword to functions that need it - for (const func of functionsToMakeAsync.values()) { - if (func.is('arrow_function')) { - // Arrow function: () => {} -> async () => {} - // Insert async before the parameters - const funcStart = func.range().start.index; - edits.push({ - startPos: funcStart, - endPos: funcStart, - insertedText: 'async ', - }); - } else if (func.is('function_declaration') || func.is('function_expression')) { - // Function declaration/expression: function name() {} -> async function name() {} - // The "function" keyword is the first child - const children = func.children(); - const firstChild = children.length > 0 ? children[0] : null; - if (firstChild && firstChild.text() === 'function') { - // Insert "async " before "function" - const funcKeywordStart = firstChild.range().start.index; - edits.push({ - startPos: funcKeywordStart, - endPos: funcKeywordStart, - insertedText: 'async ', - }); - } else { - // Fallback: insert before function start - const funcStart = func.range().start.index; - edits.push({ - startPos: funcStart, - endPos: funcStart, - insertedText: 'async ', - }); - } + let calledFunctionName: string | null = null; + if (funcNode.is('identifier')) { + calledFunctionName = funcNode.text(); + } else if (funcNode.is('member_expression')) { + continue; } - } - // Step 7.5: Add async keyword to custom render functions that need it - for (const func of customRenderFunctionsToMakeAsync.values()) { - 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 ', - }); - } + if (calledFunctionName && customRenderFunctionsSet.has(calledFunctionName)) { + customRenderCalls.push(callExpr); } } - if (edits.length === 0) { - return null; // No changes needed - } - - // Sort edits by position (reverse order to avoid offset issues) - edits.sort((a, b) => b.startPos - a.startPos); - - if (edits.length === 0) { - return null; // No changes needed - } - - // Sort edits by position (reverse order to avoid offset issues) - edits.sort((a, b) => b.startPos - a.startPos); - - return rootNode.commitEdits(edits); -}; + return customRenderCalls; +} -/** - * Process a custom render function: find RNTL calls inside it and transform them - */ -function processCustomRenderFunction( - funcNode: SgNode, - importedFunctions: Set, - edits: Edit[], - customRenderFunctionsToMakeAsync: Map>, - rootNode: SgNode, -): void { - // Find RNTL function calls inside this custom render function +function findRNTLFunctionCallsInNode(funcNode: SgNode, importedFunctions: Set): SgNode[] { const rntlCalls: SgNode[] = []; - - // Find standalone function calls (render, act, renderHook, fireEvent) for (const funcName of importedFunctions) { const calls = funcNode.findAll({ rule: { @@ -986,12 +728,9 @@ function processCustomRenderFunction( }, }, }); - if (calls.length > 0) { - } rntlCalls.push(...calls); } - // Find fireEvent method calls if (importedFunctions.has('fireEvent')) { const fireEventMethodCalls = funcNode.findAll({ rule: { @@ -1023,17 +762,25 @@ function processCustomRenderFunction( } } + return rntlCalls; +} - // Process each RNTL call found inside the custom render function +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) { - // Skip if already awaited const parent = rntlCall.parent(); if (parent && parent.is('await_expression')) { continue; } - // Skip variants that should not be transformed const functionNode = rntlCall.field('function'); if (functionNode) { const funcName = functionNode.text(); @@ -1042,7 +789,6 @@ function processCustomRenderFunction( } } - // Add await before the call const callStart = rntlCall.range().start.index; edits.push({ startPos: callStart, @@ -1052,60 +798,100 @@ function processCustomRenderFunction( needsAsync = true; } - // Track that this custom render function needs to be async if (needsAsync && !customRenderFunctionsToMakeAsync.has(funcNode.id())) { - // Check if function is already async - let isAsync = false; - if (funcNode.is('arrow_function')) { - const children = funcNode.children(); - isAsync = children.some((child) => child.text() === 'async'); - } else { - const funcStart = funcNode.range().start.index; - const textBefore = rootNode.text().substring(Math.max(0, funcStart - 10), funcStart); - isAsync = textBefore.trim().endsWith('async'); - } - + const isAsync = isFunctionAlreadyAsync(funcNode, rootNode); if (!isAsync) { customRenderFunctionsToMakeAsync.set(funcNode.id(), funcNode); } } } -/** - * Find the containing test function or hook callback (test/it/beforeEach/afterEach/etc.) for a given node - */ +function isCallAlreadyAwaited(functionCall: SgNode): boolean { + const parent = functionCall.parent(); + return parent !== null && parent.is('await_expression'); +} + +function shouldSkipTransformation(functionCall: SgNode): boolean { + const functionNode = functionCall.field('function'); + if (functionNode) { + const funcName = functionNode.text(); + return SKIP_VARIANTS.has(funcName); + } + return false; +} + +function addAwaitBeforeCall(functionCall: SgNode, edits: Edit[]): void { + const callStart = functionCall.range().start.index; + edits.push({ + startPos: callStart, + endPos: callStart, + insertedText: 'await ', + }); +} + +function isFunctionAlreadyAsync(func: SgNode, rootNode: SgNode): boolean { + if (func.is('arrow_function')) { + const children = func.children(); + return children.some((child) => child.text() === 'async'); + } else { + const funcStart = func.range().start.index; + const textBefore = rootNode.text().substring(Math.max(0, funcStart - 10), funcStart); + return textBefore.trim().endsWith('async'); + } +} + +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 ', + }); + } + } +} + function findContainingTestFunction(node: SgNode): SgNode | null { - // Walk up the AST to find the containing function let current: SgNode | null = node; while (current) { - // Check if current node is a function if ( current.is('arrow_function') || current.is('function_declaration') || current.is('function_expression') ) { - // Check if this function is a test callback - // The function is typically the second argument of a test/it call const parent = current.parent(); if (parent) { - // Parent could be arguments node 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(); - // Match test, it, beforeEach, afterEach, beforeAll, afterAll if (/^(test|it|beforeEach|afterEach|beforeAll|afterAll)$/.test(funcText)) { return current; } - // Handle test.skip, it.skip, test.only, it.only (member expressions) if (funcNode.is('member_expression')) { try { - // @ts-expect-error - field() types are complex, but this works at runtime const object = funcNode.field('object'); - // @ts-expect-error - field() types are complex, but this works at runtime const property = funcNode.field('property'); if (object && property) { const objText = object.text(); @@ -1115,18 +901,14 @@ function findContainingTestFunction(node: SgNode): SgNode | null { } } } catch { - // field() might not be available for this node type, skip + // Skip if field() is not available } } - // Handle test.each([...])('name', callback) and it.each([...])('name', callback) - // The function of the outer call is itself a call expression: test.each([...]) if (funcNode.is('call_expression')) { try { const innerFuncNode = funcNode.field('function'); if (innerFuncNode && innerFuncNode.is('member_expression')) { - // @ts-expect-error - field() types are complex, but this works at runtime const object = innerFuncNode.field('object'); - // @ts-expect-error - field() types are complex, but this works at runtime const property = innerFuncNode.field('property'); if (object && property) { const objText = object.text(); @@ -1137,13 +919,12 @@ function findContainingTestFunction(node: SgNode): SgNode | null { } } } catch { - // field() might not be available for this node type, skip + // Skip if field() is not available } } } } } - // Parent could be call_expression directly (less common) if (parent.is('call_expression')) { const funcNode = parent.field('function'); if (funcNode) { @@ -1153,9 +934,7 @@ function findContainingTestFunction(node: SgNode): SgNode | null { } if (funcNode.is('member_expression')) { try { - // @ts-expect-error - field() types are complex, but this works at runtime const object = funcNode.field('object'); - // @ts-expect-error - field() types are complex, but this works at runtime const property = funcNode.field('property'); if (object && property) { const objText = object.text(); @@ -1165,17 +944,14 @@ function findContainingTestFunction(node: SgNode): SgNode | null { } } } catch { - // field() might not be available for this node type, skip + // Skip if field() is not available } } - // Handle test.each([...])('name', callback) and it.each([...])('name', callback) if (funcNode.is('call_expression')) { try { const innerFuncNode = funcNode.field('function'); if (innerFuncNode && innerFuncNode.is('member_expression')) { - // @ts-expect-error - field() types are complex, but this works at runtime const object = innerFuncNode.field('object'); - // @ts-expect-error - field() types are complex, but this works at runtime const property = innerFuncNode.field('property'); if (object && property) { const objText = object.text(); @@ -1186,7 +962,7 @@ function findContainingTestFunction(node: SgNode): SgNode | null { } } } catch { - // field() might not be available for this node type, skip + // Skip if field() is not available } } } @@ -1200,4 +976,117 @@ function findContainingTestFunction(node: SgNode): SgNode | null { return null; } +const transform: Transform = async (root, options) => { + 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 specifiersToRemove: Array<{ specifier: SgNode; importStmt: SgNode }> = []; + const importedFunctions = extractImportedFunctionNames(rntlImports, specifiersToRemove, edits); + removeDuplicateImportSpecifiers(specifiersToRemove, rootNode, edits); + + if (importedFunctions.size === 0 && customRenderFunctionsSet.size === 0) { + return null; + } + + renameAsyncVariantsInUsages(rootNode, edits); + + const functionCalls: SgNode[] = []; + functionCalls.push(...findDirectFunctionCalls(rootNode, importedFunctions)); + functionCalls.push(...findFireEventMethodCalls(rootNode, importedFunctions, rntlImports)); + functionCalls.push(...findScreenMethodCalls(rootNode)); + + const rendererVariables = trackVariablesAssignedFromRender(rootNode, importedFunctions); + functionCalls.push(...findRendererMethodCalls(rootNode, rendererVariables)); + + const { renderHookVariables, renderHookMethodVariables } = trackVariablesAssignedFromRenderHook( + rootNode, + importedFunctions, + ); + functionCalls.push(...findRenderHookMethodCalls(rootNode, renderHookVariables, renderHookMethodVariables)); + + if (functionCalls.length === 0 && customRenderFunctionsSet.size === 0) { + if (edits.length === 0) { + return null; + } + } + + const functionsToMakeAsync = new Map>(); + const customRenderFunctionsToMakeAsync = new Map>(); + + if (customRenderFunctionsSet.size > 0 && importedFunctions.size > 0) { + const customRenderFunctionDefinitions = findCustomRenderFunctionDefinitions(rootNode, customRenderFunctionsSet); + for (const funcDef of customRenderFunctionDefinitions) { + transformRNTLCallsInsideCustomRender( + funcDef, + importedFunctions, + edits, + customRenderFunctionsToMakeAsync, + rootNode, + ); + } + } + + for (const functionCall of functionCalls) { + if (isCallAlreadyAwaited(functionCall)) { + continue; + } + + if (shouldSkipTransformation(functionCall)) { + continue; + } + + const containingFunction = findContainingTestFunction(functionCall); + if (!containingFunction) { + continue; + } + + if (!isFunctionAlreadyAsync(containingFunction, rootNode) && !functionsToMakeAsync.has(containingFunction.id())) { + functionsToMakeAsync.set(containingFunction.id(), containingFunction); + } + + addAwaitBeforeCall(functionCall, edits); + } + + if (customRenderFunctionsSet.size > 0) { + const customRenderCalls = findCustomRenderFunctionCalls(rootNode, customRenderFunctionsSet); + for (const callExpr of customRenderCalls) { + const containingFunction = findContainingTestFunction(callExpr); + if (containingFunction) { + if (isCallAlreadyAwaited(callExpr)) { + continue; + } + + if (!isFunctionAlreadyAsync(containingFunction, rootNode) && !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); +}; + export default transform; diff --git a/codemods/v14-update-deps/scripts/codemod-json.js b/codemods/v14-update-deps/scripts/codemod-json.js index 8eede4e45..8d189f3a6 100644 --- a/codemods/v14-update-deps/scripts/codemod-json.js +++ b/codemods/v14-update-deps/scripts/codemod-json.js @@ -1,127 +1,152 @@ #!/usr/bin/env node -/** - * Codemod to update package.json dependencies for RNTL v14 migration: - * - Removes @types/react-test-renderer - * - Removes react-test-renderer - * - Moves @testing-library/react-native from dependencies to devDependencies if present - * - Adds/updates @testing-library/react-native to ^14.0.0-alpha in devDependencies - * - Adds/updates universal-test-renderer@0.10.1 to devDependencies - * - * Only processes package.json files that already contain RNTL or UTR. - */ - -// Version constants - adjust these to update versions const RNTL_VERSION = '^14.0.0-alpha'; const UNIVERSAL_TEST_RENDERER_VERSION = '0.10.1'; -// This will be called by the codemod platform for each file +function isPackageJsonFile(filename) { + return filename.endsWith('package.json'); +} + +function hasRNTLOrUTR(packageJson) { + 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, packageJson) { + let removed = false; + ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'].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, depType) { + if (packageJson[depType] && !Object.keys(packageJson[depType]).length) { + delete packageJson[depType]; + } +} + +function ensureDevDependenciesObjectExists(packageJson) { + if (!packageJson.devDependencies) { + packageJson.devDependencies = {}; + return true; + } + return false; +} + +function removeObsoletePackages(packageJson) { + const removedTypes = removePackageFromAllDependencyTypes('@types/react-test-renderer', packageJson); + const removedRenderer = removePackageFromAllDependencyTypes('react-test-renderer', packageJson); + return removedTypes || removedRenderer; +} + +function moveRNTLFromDependenciesToDevDependencies(packageJson) { + const rntlInDeps = packageJson.dependencies?.['@testing-library/react-native']; + if (rntlInDeps) { + const version = packageJson.dependencies['@testing-library/react-native']; + delete packageJson.dependencies['@testing-library/react-native']; + removeEmptyDependencyObject(packageJson, 'dependencies'); + packageJson.devDependencies['@testing-library/react-native'] = version; + return true; + } + return false; +} + +function isPreReleaseVersion(version) { + return version.includes('alpha') || version.includes('beta') || version.includes('rc'); +} + +function updateRNTLVersionInDevDependencies(packageJson) { + if (!packageJson.devDependencies?.['@testing-library/react-native']) { + packageJson.devDependencies['@testing-library/react-native'] = RNTL_VERSION; + return true; + } + + const currentVersion = packageJson.devDependencies['@testing-library/react-native']; + if (!isPreReleaseVersion(currentVersion)) { + packageJson.devDependencies['@testing-library/react-native'] = RNTL_VERSION; + return true; + } + + if (currentVersion.includes('alpha') && currentVersion !== RNTL_VERSION) { + packageJson.devDependencies['@testing-library/react-native'] = RNTL_VERSION; + return true; + } + + return false; +} + +function updateUTRVersionInDevDependencies(packageJson) { + if (!packageJson.devDependencies?.['universal-test-renderer']) { + packageJson.devDependencies['universal-test-renderer'] = UNIVERSAL_TEST_RENDERER_VERSION; + return true; + } + + if (packageJson.devDependencies['universal-test-renderer'] !== UNIVERSAL_TEST_RENDERER_VERSION) { + packageJson.devDependencies['universal-test-renderer'] = UNIVERSAL_TEST_RENDERER_VERSION; + return true; + } + + return false; +} + async function transform(root) { const filename = root.filename(); - - // Only process package.json files - if (!filename.endsWith('package.json')) { + + if (!isPackageJsonFile(filename)) { return null; } try { - // Read the file content const content = root.root().text(); const packageJson = JSON.parse(content); - let hasChanges = false; - - // Function to remove a package from dependencies, devDependencies, or peerDependencies - const removePackage = (pkgName, pkgJson) => { - let removed = false; - ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'].forEach((depType) => { - if (pkgJson[depType] && pkgJson[depType][pkgName]) { - delete pkgJson[depType][pkgName]; - removed = true; - hasChanges = true; - // Remove the entire depType object if it's empty - if (Object.keys(pkgJson[depType]).length === 0) { - delete pkgJson[depType]; - } - } - }); - return removed; - }; - - // Check if RNTL or UTR already exists in the package.json - // Only proceed if at least one of them is present - const hasRNTL = - (packageJson.dependencies && packageJson.dependencies['@testing-library/react-native']) || - (packageJson.devDependencies && packageJson.devDependencies['@testing-library/react-native']) || - (packageJson.peerDependencies && packageJson.peerDependencies['@testing-library/react-native']); - - const hasUTR = - (packageJson.dependencies && packageJson.dependencies['universal-test-renderer']) || - (packageJson.devDependencies && packageJson.devDependencies['universal-test-renderer']) || - (packageJson.peerDependencies && packageJson.peerDependencies['universal-test-renderer']); - - // Skip this file if neither RNTL nor UTR is present - if (!hasRNTL && !hasUTR) { - return null; // No changes - skip this file + if (!hasRNTLOrUTR(packageJson)) { + return null; } - // Remove @types/react-test-renderer - removePackage('@types/react-test-renderer', packageJson); - - // Remove react-test-renderer - removePackage('react-test-renderer', packageJson); + let hasChanges = false; - // Ensure devDependencies exists - if (!packageJson.devDependencies) { - packageJson.devDependencies = {}; + if (removeObsoletePackages(packageJson)) { hasChanges = true; } - // Handle @testing-library/react-native - const rntlInDeps = packageJson.dependencies && packageJson.dependencies['@testing-library/react-native']; - const rntlInDevDeps = packageJson.devDependencies['@testing-library/react-native']; - - // If RNTL is in dependencies, move it to devDependencies - if (rntlInDeps) { - const version = packageJson.dependencies['@testing-library/react-native']; - delete packageJson.dependencies['@testing-library/react-native']; - if (Object.keys(packageJson.dependencies).length === 0) { - delete packageJson.dependencies; - } - packageJson.devDependencies['@testing-library/react-native'] = version; + if (ensureDevDependenciesObjectExists(packageJson)) { hasChanges = true; } - // Always ensure @testing-library/react-native is in devDependencies - if (!packageJson.devDependencies['@testing-library/react-native']) { - packageJson.devDependencies['@testing-library/react-native'] = RNTL_VERSION; + if (moveRNTLFromDependenciesToDevDependencies(packageJson)) { hasChanges = true; - } else { - const currentVersion = packageJson.devDependencies['@testing-library/react-native']; - if (!currentVersion.includes('alpha') && !currentVersion.includes('beta') && !currentVersion.includes('rc')) { - packageJson.devDependencies['@testing-library/react-native'] = RNTL_VERSION; - hasChanges = true; - } else if (currentVersion.includes('alpha') && currentVersion !== RNTL_VERSION) { - packageJson.devDependencies['@testing-library/react-native'] = RNTL_VERSION; - hasChanges = true; - } } - // Always ensure universal-test-renderer is in devDependencies - if (!packageJson.devDependencies['universal-test-renderer']) { - packageJson.devDependencies['universal-test-renderer'] = UNIVERSAL_TEST_RENDERER_VERSION; + if (updateRNTLVersionInDevDependencies(packageJson)) { hasChanges = true; - } else if (packageJson.devDependencies['universal-test-renderer'] !== UNIVERSAL_TEST_RENDERER_VERSION) { - packageJson.devDependencies['universal-test-renderer'] = UNIVERSAL_TEST_RENDERER_VERSION; + } + + if (updateUTRVersionInDevDependencies(packageJson)) { hasChanges = true; } - // Return the updated content if there were changes if (hasChanges) { return JSON.stringify(packageJson, null, 2) + '\n'; } - return null; // No changes + return null; } catch (error) { console.error(`Error processing ${filename}:`, error.message); return null; From e66e554b45fe8475b1a138d0d690574a7b753949 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Tue, 6 Jan 2026 21:30:48 +0100 Subject: [PATCH 17/27] refacttor to ts --- .../v14-update-deps/scripts/codemod-json.js | 156 ------------------ codemods/v14-update-deps/scripts/codemod.ts | 141 ++++++++++++++++ codemods/v14-update-deps/scripts/test.js | 2 +- codemods/v14-update-deps/workflow.yaml | 2 +- 4 files changed, 143 insertions(+), 158 deletions(-) delete mode 100644 codemods/v14-update-deps/scripts/codemod-json.js create mode 100644 codemods/v14-update-deps/scripts/codemod.ts diff --git a/codemods/v14-update-deps/scripts/codemod-json.js b/codemods/v14-update-deps/scripts/codemod-json.js deleted file mode 100644 index 8d189f3a6..000000000 --- a/codemods/v14-update-deps/scripts/codemod-json.js +++ /dev/null @@ -1,156 +0,0 @@ -#!/usr/bin/env node - -const RNTL_VERSION = '^14.0.0-alpha'; -const UNIVERSAL_TEST_RENDERER_VERSION = '0.10.1'; - -function isPackageJsonFile(filename) { - return filename.endsWith('package.json'); -} - -function hasRNTLOrUTR(packageJson) { - 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, packageJson) { - let removed = false; - ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'].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, depType) { - if (packageJson[depType] && !Object.keys(packageJson[depType]).length) { - delete packageJson[depType]; - } -} - -function ensureDevDependenciesObjectExists(packageJson) { - if (!packageJson.devDependencies) { - packageJson.devDependencies = {}; - return true; - } - return false; -} - -function removeObsoletePackages(packageJson) { - const removedTypes = removePackageFromAllDependencyTypes('@types/react-test-renderer', packageJson); - const removedRenderer = removePackageFromAllDependencyTypes('react-test-renderer', packageJson); - return removedTypes || removedRenderer; -} - -function moveRNTLFromDependenciesToDevDependencies(packageJson) { - const rntlInDeps = packageJson.dependencies?.['@testing-library/react-native']; - if (rntlInDeps) { - const version = packageJson.dependencies['@testing-library/react-native']; - delete packageJson.dependencies['@testing-library/react-native']; - removeEmptyDependencyObject(packageJson, 'dependencies'); - packageJson.devDependencies['@testing-library/react-native'] = version; - return true; - } - return false; -} - -function isPreReleaseVersion(version) { - return version.includes('alpha') || version.includes('beta') || version.includes('rc'); -} - -function updateRNTLVersionInDevDependencies(packageJson) { - if (!packageJson.devDependencies?.['@testing-library/react-native']) { - packageJson.devDependencies['@testing-library/react-native'] = RNTL_VERSION; - return true; - } - - const currentVersion = packageJson.devDependencies['@testing-library/react-native']; - if (!isPreReleaseVersion(currentVersion)) { - packageJson.devDependencies['@testing-library/react-native'] = RNTL_VERSION; - return true; - } - - if (currentVersion.includes('alpha') && currentVersion !== RNTL_VERSION) { - packageJson.devDependencies['@testing-library/react-native'] = RNTL_VERSION; - return true; - } - - return false; -} - -function updateUTRVersionInDevDependencies(packageJson) { - if (!packageJson.devDependencies?.['universal-test-renderer']) { - packageJson.devDependencies['universal-test-renderer'] = UNIVERSAL_TEST_RENDERER_VERSION; - return true; - } - - if (packageJson.devDependencies['universal-test-renderer'] !== UNIVERSAL_TEST_RENDERER_VERSION) { - packageJson.devDependencies['universal-test-renderer'] = UNIVERSAL_TEST_RENDERER_VERSION; - return true; - } - - return false; -} - -async function transform(root) { - const filename = root.filename(); - - if (!isPackageJsonFile(filename)) { - return null; - } - - try { - const content = root.root().text(); - const packageJson = JSON.parse(content); - - if (!hasRNTLOrUTR(packageJson)) { - return null; - } - - let hasChanges = false; - - if (removeObsoletePackages(packageJson)) { - hasChanges = true; - } - - if (ensureDevDependenciesObjectExists(packageJson)) { - hasChanges = true; - } - - if (moveRNTLFromDependenciesToDevDependencies(packageJson)) { - hasChanges = true; - } - - if (updateRNTLVersionInDevDependencies(packageJson)) { - hasChanges = true; - } - - if (updateUTRVersionInDevDependencies(packageJson)) { - hasChanges = true; - } - - if (hasChanges) { - return JSON.stringify(packageJson, null, 2) + '\n'; - } - - return null; - } catch (error) { - console.error(`Error processing ${filename}:`, error.message); - return null; - } -} - -export default transform; diff --git a/codemods/v14-update-deps/scripts/codemod.ts b/codemods/v14-update-deps/scripts/codemod.ts new file mode 100644 index 000000000..93f9659b9 --- /dev/null +++ b/codemods/v14-update-deps/scripts/codemod.ts @@ -0,0 +1,141 @@ +#!/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) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error(`Error processing ${filename}:`, errorMessage); + return null; + } +} + +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 index c797194a8..6dc9fe2b6 100755 --- a/codemods/v14-update-deps/scripts/test.js +++ b/codemods/v14-update-deps/scripts/test.js @@ -16,7 +16,7 @@ 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-json.js'); + const { default: transform } = await import('./codemod.ts'); // Mock the codemod platform root object const packageJsonContent = readFileSync(filePath, 'utf8'); diff --git a/codemods/v14-update-deps/workflow.yaml b/codemods/v14-update-deps/workflow.yaml index ec6c47352..b6f2ded51 100644 --- a/codemods/v14-update-deps/workflow.yaml +++ b/codemods/v14-update-deps/workflow.yaml @@ -9,7 +9,7 @@ nodes: steps: - name: "Update dependencies in package.json" js-ast-grep: - js_file: scripts/codemod-json.js + js_file: scripts/codemod.ts language: json include: - "**/package.json" From ffcb091e32763889d92972791fcc7bda7af71564 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Tue, 6 Jan 2026 21:33:46 +0100 Subject: [PATCH 18/27] . --- .../v14-async-functions/scripts/codemod.ts | 231 +++++++++--------- 1 file changed, 116 insertions(+), 115 deletions(-) diff --git a/codemods/v14-async-functions/scripts/codemod.ts b/codemods/v14-async-functions/scripts/codemod.ts index d75fb2e76..c0badcc94 100644 --- a/codemods/v14-async-functions/scripts/codemod.ts +++ b/codemods/v14-async-functions/scripts/codemod.ts @@ -13,6 +13,122 @@ const ASYNC_VARIANTS_TO_RENAME = new Map([ ['fireEventAsync', 'fireEvent'], ]); +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 specifiersToRemove: Array<{ specifier: SgNode; importStmt: SgNode }> = []; + const importedFunctions = extractImportedFunctionNames(rntlImports, specifiersToRemove, edits); + removeDuplicateImportSpecifiers(specifiersToRemove, rootNode, edits); + + if (importedFunctions.size === 0 && customRenderFunctionsSet.size === 0) { + return null; + } + + renameAsyncVariantsInUsages(rootNode, edits); + + const functionCalls: SgNode[] = []; + functionCalls.push(...findDirectFunctionCalls(rootNode, importedFunctions)); + functionCalls.push(...findFireEventMethodCalls(rootNode, importedFunctions, rntlImports)); + functionCalls.push(...findScreenMethodCalls(rootNode)); + + const rendererVariables = trackVariablesAssignedFromRender(rootNode, importedFunctions); + functionCalls.push(...findRendererMethodCalls(rootNode, rendererVariables)); + + const { renderHookVariables, renderHookMethodVariables } = trackVariablesAssignedFromRenderHook( + rootNode, + importedFunctions, + ); + functionCalls.push(...findRenderHookMethodCalls(rootNode, renderHookVariables, renderHookMethodVariables)); + + if (functionCalls.length === 0 && customRenderFunctionsSet.size === 0) { + if (edits.length === 0) { + return null; + } + } + + const functionsToMakeAsync = new Map>(); + const customRenderFunctionsToMakeAsync = new Map>(); + + if (customRenderFunctionsSet.size > 0 && importedFunctions.size > 0) { + const customRenderFunctionDefinitions = findCustomRenderFunctionDefinitions(rootNode, customRenderFunctionsSet); + for (const funcDef of customRenderFunctionDefinitions) { + transformRNTLCallsInsideCustomRender( + funcDef, + importedFunctions, + edits, + customRenderFunctionsToMakeAsync, + rootNode, + ); + } + } + + for (const functionCall of functionCalls) { + if (isCallAlreadyAwaited(functionCall)) { + continue; + } + + if (shouldSkipTransformation(functionCall)) { + continue; + } + + const containingFunction = findContainingTestFunction(functionCall); + if (!containingFunction) { + continue; + } + + if (!isFunctionAlreadyAsync(containingFunction, rootNode) && !functionsToMakeAsync.has(containingFunction.id())) { + functionsToMakeAsync.set(containingFunction.id(), containingFunction); + } + + addAwaitBeforeCall(functionCall, edits); + } + + if (customRenderFunctionsSet.size > 0) { + const customRenderCalls = findCustomRenderFunctionCalls(rootNode, customRenderFunctionsSet); + for (const callExpr of customRenderCalls) { + const containingFunction = findContainingTestFunction(callExpr); + if (containingFunction) { + if (isCallAlreadyAwaited(callExpr)) { + continue; + } + + if (!isFunctionAlreadyAsync(containingFunction, rootNode) && !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); +} + function parseCustomRenderFunctionsFromOptions(options: any): Set { const customRenderFunctionsParam = options?.params?.customRenderFunctions ? String(options.params.customRenderFunctions) @@ -975,118 +1091,3 @@ function findContainingTestFunction(node: SgNode): SgNode | null { return null; } - -const transform: Transform = async (root, options) => { - 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 specifiersToRemove: Array<{ specifier: SgNode; importStmt: SgNode }> = []; - const importedFunctions = extractImportedFunctionNames(rntlImports, specifiersToRemove, edits); - removeDuplicateImportSpecifiers(specifiersToRemove, rootNode, edits); - - if (importedFunctions.size === 0 && customRenderFunctionsSet.size === 0) { - return null; - } - - renameAsyncVariantsInUsages(rootNode, edits); - - const functionCalls: SgNode[] = []; - functionCalls.push(...findDirectFunctionCalls(rootNode, importedFunctions)); - functionCalls.push(...findFireEventMethodCalls(rootNode, importedFunctions, rntlImports)); - functionCalls.push(...findScreenMethodCalls(rootNode)); - - const rendererVariables = trackVariablesAssignedFromRender(rootNode, importedFunctions); - functionCalls.push(...findRendererMethodCalls(rootNode, rendererVariables)); - - const { renderHookVariables, renderHookMethodVariables } = trackVariablesAssignedFromRenderHook( - rootNode, - importedFunctions, - ); - functionCalls.push(...findRenderHookMethodCalls(rootNode, renderHookVariables, renderHookMethodVariables)); - - if (functionCalls.length === 0 && customRenderFunctionsSet.size === 0) { - if (edits.length === 0) { - return null; - } - } - - const functionsToMakeAsync = new Map>(); - const customRenderFunctionsToMakeAsync = new Map>(); - - if (customRenderFunctionsSet.size > 0 && importedFunctions.size > 0) { - const customRenderFunctionDefinitions = findCustomRenderFunctionDefinitions(rootNode, customRenderFunctionsSet); - for (const funcDef of customRenderFunctionDefinitions) { - transformRNTLCallsInsideCustomRender( - funcDef, - importedFunctions, - edits, - customRenderFunctionsToMakeAsync, - rootNode, - ); - } - } - - for (const functionCall of functionCalls) { - if (isCallAlreadyAwaited(functionCall)) { - continue; - } - - if (shouldSkipTransformation(functionCall)) { - continue; - } - - const containingFunction = findContainingTestFunction(functionCall); - if (!containingFunction) { - continue; - } - - if (!isFunctionAlreadyAsync(containingFunction, rootNode) && !functionsToMakeAsync.has(containingFunction.id())) { - functionsToMakeAsync.set(containingFunction.id(), containingFunction); - } - - addAwaitBeforeCall(functionCall, edits); - } - - if (customRenderFunctionsSet.size > 0) { - const customRenderCalls = findCustomRenderFunctionCalls(rootNode, customRenderFunctionsSet); - for (const callExpr of customRenderCalls) { - const containingFunction = findContainingTestFunction(callExpr); - if (containingFunction) { - if (isCallAlreadyAwaited(callExpr)) { - continue; - } - - if (!isFunctionAlreadyAsync(containingFunction, rootNode) && !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); -}; - -export default transform; From ef474e908bb6a6de56be166c3f778ff4c4b94c16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Tue, 6 Jan 2026 21:43:31 +0100 Subject: [PATCH 19/27] tweaks --- .../v14-async-functions/scripts/codemod.ts | 134 ++++++++++++------ 1 file changed, 87 insertions(+), 47 deletions(-) diff --git a/codemods/v14-async-functions/scripts/codemod.ts b/codemods/v14-async-functions/scripts/codemod.ts index c0badcc94..39c438641 100644 --- a/codemods/v14-async-functions/scripts/codemod.ts +++ b/codemods/v14-async-functions/scripts/codemod.ts @@ -2,16 +2,20 @@ 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_TRANSFORM = new Set(['render', 'renderHook', 'act', 'fireEvent']); -const FIRE_EVENT_METHODS = new Set(['press', 'changeText', 'scroll']); -const SCREEN_METHODS = new Set(['rerender', 'unmount']); -const RENDERER_METHODS = new Set(['rerender', 'unmount']); +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 SKIP_VARIANTS = new Set(['unsafe_renderHookSync', 'unsafe_act']); -const ASYNC_VARIANTS_TO_RENAME = new Map([ +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], @@ -27,8 +31,10 @@ export default async function transform( return null; } - const specifiersToRemove: Array<{ specifier: SgNode; importStmt: SgNode }> = []; - const importedFunctions = extractImportedFunctionNames(rntlImports, specifiersToRemove, edits); + const { importedFunctions, specifiersToRemove } = extractImportedFunctionNames( + rntlImports, + edits, + ); removeDuplicateImportSpecifiers(specifiersToRemove, rootNode, edits); if (importedFunctions.size === 0 && customRenderFunctionsSet.size === 0) { @@ -49,7 +55,9 @@ export default async function transform( rootNode, importedFunctions, ); - functionCalls.push(...findRenderHookMethodCalls(rootNode, renderHookVariables, renderHookMethodVariables)); + functionCalls.push( + ...findRenderHookMethodCalls(rootNode, renderHookVariables, renderHookMethodVariables), + ); if (functionCalls.length === 0 && customRenderFunctionsSet.size === 0) { if (edits.length === 0) { @@ -61,7 +69,10 @@ export default async function transform( const customRenderFunctionsToMakeAsync = new Map>(); if (customRenderFunctionsSet.size > 0 && importedFunctions.size > 0) { - const customRenderFunctionDefinitions = findCustomRenderFunctionDefinitions(rootNode, customRenderFunctionsSet); + const customRenderFunctionDefinitions = findCustomRenderFunctionDefinitions( + rootNode, + customRenderFunctionsSet, + ); for (const funcDef of customRenderFunctionDefinitions) { transformRNTLCallsInsideCustomRender( funcDef, @@ -87,7 +98,10 @@ export default async function transform( continue; } - if (!isFunctionAlreadyAsync(containingFunction, rootNode) && !functionsToMakeAsync.has(containingFunction.id())) { + if ( + !isFunctionAlreadyAsync(containingFunction, rootNode) && + !functionsToMakeAsync.has(containingFunction.id()) + ) { functionsToMakeAsync.set(containingFunction.id(), containingFunction); } @@ -103,7 +117,10 @@ export default async function transform( continue; } - if (!isFunctionAlreadyAsync(containingFunction, rootNode) && !functionsToMakeAsync.has(containingFunction.id())) { + if ( + !isFunctionAlreadyAsync(containingFunction, rootNode) && + !functionsToMakeAsync.has(containingFunction.id()) + ) { functionsToMakeAsync.set(containingFunction.id(), containingFunction); } @@ -159,10 +176,13 @@ function findRNTLImportStatements(rootNode: SgNode): SgNode[] { function extractImportedFunctionNames( rntlImports: SgNode[], - specifiersToRemove: Array<{ specifier: SgNode; importStmt: SgNode }>, edits: Edit[], -): Set { +): { + 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({ @@ -195,8 +215,8 @@ function extractImportedFunctionNames( }); if (identifier) { const funcName = identifier.text(); - if (ASYNC_VARIANTS_TO_RENAME.has(funcName)) { - const newName = ASYNC_VARIANTS_TO_RENAME.get(funcName)!; + 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); @@ -209,7 +229,7 @@ function extractImportedFunctionNames( }); importedFunctions.add(newName); } - } else if (FUNCTIONS_TO_TRANSFORM.has(funcName)) { + } else if (FUNCTIONS_TO_MAKE_ASYNC.has(funcName)) { importedFunctions.add(funcName); } } @@ -221,7 +241,7 @@ function extractImportedFunctionNames( }); if (defaultImport) { const funcName = defaultImport.text(); - if (FUNCTIONS_TO_TRANSFORM.has(funcName)) { + if (FUNCTIONS_TO_MAKE_ASYNC.has(funcName)) { importedFunctions.add(funcName); } } @@ -230,12 +250,12 @@ function extractImportedFunctionNames( rule: { kind: 'namespace_import' }, }); if (namespaceImport) { - FUNCTIONS_TO_TRANSFORM.forEach((func) => importedFunctions.add(func)); + FUNCTIONS_TO_MAKE_ASYNC.forEach((func) => importedFunctions.add(func)); break; } } - return importedFunctions; + return { importedFunctions, specifiersToRemove }; } function removeDuplicateImportSpecifiers( @@ -243,7 +263,9 @@ function removeDuplicateImportSpecifiers( rootNode: SgNode, edits: Edit[], ): void { - specifiersToRemove.sort((a, b) => b.specifier.range().start.index - a.specifier.range().start.index); + specifiersToRemove.sort( + (a, b) => b.specifier.range().start.index - a.specifier.range().start.index, + ); for (const { specifier } of specifiersToRemove) { const specifierRange = specifier.range(); @@ -285,7 +307,7 @@ function removeDuplicateImportSpecifiers( } function renameAsyncVariantsInUsages(rootNode: SgNode, edits: Edit[]): void { - for (const [asyncName, syncName] of ASYNC_VARIANTS_TO_RENAME.entries()) { + for (const [asyncName, syncName] of ASYNC_FUNCTIONS_TO_RENAME.entries()) { const asyncIdentifiers = rootNode.findAll({ rule: { kind: 'identifier', @@ -331,7 +353,10 @@ function renameAsyncVariantsInUsages(rootNode: SgNode, edits: Edit[]): void } } -function findDirectFunctionCalls(rootNode: SgNode, importedFunctions: Set): SgNode[] { +function findDirectFunctionCalls( + rootNode: SgNode, + importedFunctions: Set, +): SgNode[] { const functionCalls: SgNode[] = []; for (const funcName of importedFunctions) { @@ -363,7 +388,7 @@ function findFireEventMethodCalls( fireEventNames.add('fireEvent'); } - for (const [asyncName, syncName] of ASYNC_VARIANTS_TO_RENAME.entries()) { + 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' } }); @@ -402,7 +427,7 @@ function findFireEventMethodCalls( if (object && property) { const objText = object.text(); const propText = property.text(); - if (fireEventNames.has(objText) && FIRE_EVENT_METHODS.has(propText)) { + if (fireEventNames.has(objText) && FIRE_EVENT_METHODS_TO_MAKE_ASYNC.has(propText)) { functionCalls.push(call); } } @@ -437,7 +462,7 @@ function findScreenMethodCalls(rootNode: SgNode): SgNode[] { if (object && property) { const objText = object.text(); const propText = property.text(); - if (objText === 'screen' && SCREEN_METHODS.has(propText)) { + if (objText === 'screen' && SCREEN_METHODS_TO_MAKE_ASYNC.has(propText)) { functionCalls.push(call); } } @@ -450,7 +475,10 @@ function findScreenMethodCalls(rootNode: SgNode): SgNode[] { return functionCalls; } -function trackVariablesAssignedFromRender(rootNode: SgNode, importedFunctions: Set): Set { +function trackVariablesAssignedFromRender( + rootNode: SgNode, + importedFunctions: Set, +): Set { const rendererVariables = new Set(); if (importedFunctions.has('render')) { @@ -483,7 +511,7 @@ function trackVariablesAssignedFromRender(rootNode: SgNode, importedFunctio }); for (const prop of shorthandProps) { const propName = prop.text(); - if (RENDERER_METHODS.has(propName)) { + if (RESULT_METHODS_TO_MAKE_ASYNC.has(propName)) { rendererVariables.add(propName); } } @@ -513,7 +541,7 @@ function trackVariablesAssignedFromRender(rootNode: SgNode, importedFunctio }); for (const prop of shorthandProps) { const propName = prop.text(); - if (RENDERER_METHODS.has(propName)) { + if (RESULT_METHODS_TO_MAKE_ASYNC.has(propName)) { rendererVariables.add(propName); } } @@ -526,7 +554,10 @@ function trackVariablesAssignedFromRender(rootNode: SgNode, importedFunctio return rendererVariables; } -function findRendererMethodCalls(rootNode: SgNode, rendererVariables: Set): SgNode[] { +function findRendererMethodCalls( + rootNode: SgNode, + rendererVariables: Set, +): SgNode[] { const functionCalls: SgNode[] = []; if (rendererVariables.size > 0) { @@ -549,7 +580,7 @@ function findRendererMethodCalls(rootNode: SgNode, rendererVariables: Set, rendererVariables: Set, rendererVariables: Set, importedFunctions: Set): { +function trackVariablesAssignedFromRenderHook( + rootNode: SgNode, + importedFunctions: Set, +): { renderHookVariables: Set; renderHookMethodVariables: Set; } { @@ -616,7 +650,7 @@ function trackVariablesAssignedFromRenderHook(rootNode: SgNode, importedFun }); for (const prop of shorthandProps) { const propName = prop.text(); - if (RENDERER_METHODS.has(propName)) { + if (RESULT_METHODS_TO_MAKE_ASYNC.has(propName)) { renderHookVariables.add(propName); } } @@ -633,7 +667,7 @@ function trackVariablesAssignedFromRenderHook(rootNode: SgNode, importedFun if (key && value) { const keyName = key.text(); const valueName = value.text(); - if (RENDERER_METHODS.has(keyName)) { + if (RESULT_METHODS_TO_MAKE_ASYNC.has(keyName)) { renderHookVariables.add(valueName); renderHookMethodVariables.add(valueName); } @@ -665,7 +699,7 @@ function trackVariablesAssignedFromRenderHook(rootNode: SgNode, importedFun }); for (const prop of shorthandProps) { const propName = prop.text(); - if (RENDERER_METHODS.has(propName)) { + if (RESULT_METHODS_TO_MAKE_ASYNC.has(propName)) { renderHookVariables.add(propName); } } @@ -682,7 +716,7 @@ function trackVariablesAssignedFromRenderHook(rootNode: SgNode, importedFun if (key && value) { const keyName = key.text(); const valueName = value.text(); - if (RENDERER_METHODS.has(keyName)) { + if (RESULT_METHODS_TO_MAKE_ASYNC.has(keyName)) { renderHookVariables.add(valueName); renderHookMethodVariables.add(valueName); } @@ -724,7 +758,7 @@ function findRenderHookMethodCalls( if (object && property) { const objText = object.text(); const propText = property.text(); - if (renderHookVariables.has(objText) && RENDERER_METHODS.has(propText)) { + if (renderHookVariables.has(objText) && RESULT_METHODS_TO_MAKE_ASYNC.has(propText)) { functionCalls.push(call); } } @@ -735,7 +769,7 @@ function findRenderHookMethodCalls( } for (const varName of renderHookVariables) { - if (RENDERER_METHODS.has(varName) || renderHookMethodVariables.has(varName)) { + if (RESULT_METHODS_TO_MAKE_ASYNC.has(varName) || renderHookMethodVariables.has(varName)) { const directCalls = rootNode.findAll({ rule: { kind: 'call_expression', @@ -805,7 +839,10 @@ function findCustomRenderFunctionDefinitions( return customRenderFunctions; } -function findCustomRenderFunctionCalls(rootNode: SgNode, customRenderFunctionsSet: Set): SgNode[] { +function findCustomRenderFunctionCalls( + rootNode: SgNode, + customRenderFunctionsSet: Set, +): SgNode[] { const customRenderCalls: SgNode[] = []; const allCallExpressions = rootNode.findAll({ rule: { kind: 'call_expression' }, @@ -830,7 +867,10 @@ function findCustomRenderFunctionCalls(rootNode: SgNode, customRenderFuncti return customRenderCalls; } -function findRNTLFunctionCallsInNode(funcNode: SgNode, importedFunctions: Set): SgNode[] { +function findRNTLFunctionCallsInNode( + funcNode: SgNode, + importedFunctions: Set, +): SgNode[] { const rntlCalls: SgNode[] = []; for (const funcName of importedFunctions) { @@ -867,7 +907,7 @@ function findRNTLFunctionCallsInNode(funcNode: SgNode, importedFunctions: S if (object && property) { const objText = object.text(); const propText = property.text(); - if (objText === 'fireEvent' && FIRE_EVENT_METHODS.has(propText)) { + if (objText === 'fireEvent' && FIRE_EVENT_METHODS_TO_MAKE_ASYNC.has(propText)) { rntlCalls.push(call); } } @@ -1002,7 +1042,7 @@ function findContainingTestFunction(node: SgNode): SgNode | null { const funcNode = grandParent.field('function'); if (funcNode) { const funcText = funcNode.text(); - if (/^(test|it|beforeEach|afterEach|beforeAll|afterAll)$/.test(funcText)) { + if (TEST_FUNCTION_NAMES.has(funcText)) { return current; } if (funcNode.is('member_expression')) { @@ -1012,7 +1052,7 @@ function findContainingTestFunction(node: SgNode): SgNode | null { if (object && property) { const objText = object.text(); const propText = property.text(); - if ((objText === 'test' || objText === 'it') && (propText === 'skip' || propText === 'only')) { + if (TEST_FUNCTION_PREFIXES.has(objText) && TEST_MODIFIERS.has(propText)) { return current; } } @@ -1029,7 +1069,7 @@ function findContainingTestFunction(node: SgNode): SgNode | null { if (object && property) { const objText = object.text(); const propText = property.text(); - if ((objText === 'test' || objText === 'it') && propText === 'each') { + if (TEST_FUNCTION_PREFIXES.has(objText) && propText === TEST_EACH_METHOD) { return current; } } @@ -1045,7 +1085,7 @@ function findContainingTestFunction(node: SgNode): SgNode | null { const funcNode = parent.field('function'); if (funcNode) { const funcText = funcNode.text(); - if (/^(test|it|beforeEach|afterEach|beforeAll|afterAll)$/.test(funcText)) { + if (TEST_FUNCTION_NAMES.has(funcText)) { return current; } if (funcNode.is('member_expression')) { @@ -1055,7 +1095,7 @@ function findContainingTestFunction(node: SgNode): SgNode | null { if (object && property) { const objText = object.text(); const propText = property.text(); - if ((objText === 'test' || objText === 'it') && (propText === 'skip' || propText === 'only')) { + if (TEST_FUNCTION_PREFIXES.has(objText) && TEST_MODIFIERS.has(propText)) { return current; } } @@ -1072,7 +1112,7 @@ function findContainingTestFunction(node: SgNode): SgNode | null { if (object && property) { const objText = object.text(); const propText = property.text(); - if ((objText === 'test' || objText === 'it') && propText === 'each') { + if (TEST_FUNCTION_PREFIXES.has(objText) && propText === TEST_EACH_METHOD) { return current; } } From 84b8c89bf107137abc2a31388a36924b91cd0a69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Tue, 6 Jan 2026 22:14:15 +0100 Subject: [PATCH 20/27] tweaks --- .prettierignore | 1 + codemods/v14-async-functions/README.md | 30 ++- .../v14-async-functions/scripts/codemod.ts | 223 +++++++++++++++++- .../both-renderhook-renderer/expected.tsx | 2 +- .../both-renderhook-renderer/input.tsx | 2 +- .../fixtures/combined-functions/expected.tsx | 6 +- .../fixtures/combined-functions/input.tsx | 6 +- .../custom-render-function/expected.tsx | 2 +- .../fixtures/custom-render-function/input.tsx | 2 +- .../fixtures/duplicate-imports/expected.tsx | 13 +- .../fixtures/duplicate-imports/input.tsx | 16 +- .../fixtures/fireevent-methods/expected.tsx | 4 +- .../fixtures/fireevent-methods/input.tsx | 4 +- .../function-declaration/expected.tsx | 2 +- .../fixtures/function-declaration/input.tsx | 2 +- .../tests/fixtures/skip-variants/expected.tsx | 18 +- .../tests/fixtures/skip-variants/input.tsx | 23 +- .../fixtures/test-each-combined/expected.tsx | 10 +- .../fixtures/test-each-combined/input.tsx | 10 +- .../tests/fixtures/test-each/expected.tsx | 5 +- .../tests/fixtures/test-each/input.tsx | 5 +- codemods/v14-update-deps/README.md | 7 + codemods/v14-update-deps/codemod.yaml | 23 +- codemods/v14-update-deps/package.json | 2 +- codemods/v14-update-deps/scripts/codemod.ts | 13 +- codemods/v14-update-deps/scripts/test.js | 29 +-- .../fixtures/already-alpha/expected.json | 2 +- .../tests/fixtures/basic-update/expected.json | 2 +- .../fixtures/move-from-deps/expected.json | 2 +- .../fixtures/rntl-in-devdeps/expected.json | 7 +- .../tests/fixtures/rntl-in-devdeps/input.json | 7 +- .../fixtures/with-peer-deps/expected.json | 2 +- codemods/v14-update-deps/workflow.yaml | 18 +- package.json | 1 + scripts/test-codemods.mjs | 38 +++ 35 files changed, 423 insertions(+), 116 deletions(-) create mode 100644 scripts/test-codemods.mjs 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/codemods/v14-async-functions/README.md b/codemods/v14-async-functions/README.md index 469f05399..3fe456023 100644 --- a/codemods/v14-async-functions/README.md +++ b/codemods/v14-async-functions/README.md @@ -52,6 +52,7 @@ CUSTOM_RENDER_FUNCTIONS="renderWithProviders,renderWithTheme" npx codemod@latest #### Basic sync test **Before:** + ```typescript import { render, screen } from '@testing-library/react-native'; @@ -62,6 +63,7 @@ test('renders component', () => { ``` **After:** + ```typescript import { render, screen } from '@testing-library/react-native'; @@ -74,6 +76,7 @@ test('renders component', async () => { #### Already async test **Before:** + ```typescript test('renders component', async () => { render(); @@ -81,6 +84,7 @@ test('renders component', async () => { ``` **After:** + ```typescript test('renders component', async () => { await render(); @@ -90,6 +94,7 @@ test('renders component', async () => { #### Multiple render calls **Before:** + ```typescript test('renders multiple', () => { render(); @@ -98,6 +103,7 @@ test('renders multiple', () => { ``` **After:** + ```typescript test('renders multiple', async () => { await render(); @@ -108,6 +114,7 @@ test('renders multiple', async () => { #### Render with options **Before:** + ```typescript test('renders with wrapper', () => { render(, { wrapper: Wrapper }); @@ -115,6 +122,7 @@ test('renders with wrapper', () => { ``` **After:** + ```typescript test('renders with wrapper', async () => { await render(, { wrapper: Wrapper }); @@ -124,6 +132,7 @@ test('renders with wrapper', async () => { #### Using act() **Before:** + ```typescript import { act } from '@testing-library/react-native'; @@ -135,6 +144,7 @@ test('updates state', () => { ``` **After:** + ```typescript import { act } from '@testing-library/react-native'; @@ -148,6 +158,7 @@ test('updates state', async () => { #### Using renderHook() **Before:** + ```typescript import { renderHook } from '@testing-library/react-native'; @@ -158,6 +169,7 @@ test('renders hook', () => { ``` **After:** + ```typescript import { renderHook } from '@testing-library/react-native'; @@ -170,6 +182,7 @@ test('renders hook', async () => { #### Combined usage **Before:** + ```typescript import { render, act, renderHook, screen } from '@testing-library/react-native'; @@ -183,6 +196,7 @@ test('uses all three', () => { ``` **After:** + ```typescript import { render, act, renderHook, screen } from '@testing-library/react-native'; @@ -198,6 +212,7 @@ test('uses all three', async () => { #### Using fireEvent() **Before:** + ```typescript import { fireEvent, render, screen } from '@testing-library/react-native'; @@ -210,6 +225,7 @@ test('uses fireEvent', () => { ``` **After:** + ```typescript import { fireEvent, render, screen } from '@testing-library/react-native'; @@ -224,6 +240,7 @@ test('uses fireEvent', async () => { #### Using fireEvent methods **Before:** + ```typescript import { fireEvent, render, screen } from '@testing-library/react-native'; @@ -232,7 +249,7 @@ test('uses fireEvent methods', () => { 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 } } }); @@ -240,6 +257,7 @@ test('uses fireEvent methods', () => { ``` **After:** + ```typescript import { fireEvent, render, screen } from '@testing-library/react-native'; @@ -248,7 +266,7 @@ test('uses fireEvent methods', async () => { 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 } } }); @@ -258,6 +276,7 @@ test('uses fireEvent methods', async () => { #### Skipping unsafe variants **Before:** + ```typescript import { act, renderHook, unsafe_act, unsafe_renderHookSync, renderAsync } from '@testing-library/react-native'; @@ -271,6 +290,7 @@ test('skips unsafe variants', () => { ``` **After:** + ```typescript import { act, renderHook, unsafe_act, unsafe_renderHookSync, renderAsync } from '@testing-library/react-native'; @@ -286,6 +306,7 @@ test('skips unsafe variants', async () => { #### Helper functions (not transformed by default) **Before:** + ```typescript function renderWithProviders(component: React.ReactElement) { render(component); // This is NOT transformed by default @@ -297,6 +318,7 @@ test('uses helper', () => { ``` **After (without CUSTOM_RENDER_FUNCTIONS):** + ```typescript function renderWithProviders(component: React.ReactElement) { render(component); // Unchanged - helper functions are skipped by default @@ -312,6 +334,7 @@ test('uses helper', () => { When you specify custom render function names via the `CUSTOM_RENDER_FUNCTIONS` environment variable, those functions will be transformed: **Before:** + ```typescript function renderWithProviders(component: React.ReactElement) { render(component); @@ -328,6 +351,7 @@ test('uses custom render', () => { ``` **After (with CUSTOM_RENDER_FUNCTIONS="renderWithProviders,renderWithTheme"):** + ```typescript async function renderWithProviders(component: React.ReactElement) { await render(component); @@ -348,6 +372,7 @@ test('uses custom render', async () => { `describe()` blocks are grouping mechanisms and their callbacks are not made async, even if they contain `render` calls in helper functions. However, `test()` callbacks inside `describe` blocks are still made async. **Before:** + ```typescript import { render, screen } from '@testing-library/react-native'; @@ -369,6 +394,7 @@ describe('MyComponent', () => { ``` **After:** + ```typescript import { render, screen } from '@testing-library/react-native'; diff --git a/codemods/v14-async-functions/scripts/codemod.ts b/codemods/v14-async-functions/scripts/codemod.ts index 39c438641..d60ca3a38 100644 --- a/codemods/v14-async-functions/scripts/codemod.ts +++ b/codemods/v14-async-functions/scripts/codemod.ts @@ -12,7 +12,14 @@ const ASYNC_FUNCTIONS_TO_RENAME = new Map([ ['renderHookAsync', 'renderHook'], ['fireEventAsync', 'fireEvent'], ]); -const TEST_FUNCTION_NAMES = new Set(['test', 'it', 'beforeEach', 'afterEach', 'beforeAll', 'afterAll']); +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'; @@ -37,7 +44,45 @@ export default async function transform( ); removeDuplicateImportSpecifiers(specifiersToRemove, rootNode, edits); - if (importedFunctions.size === 0 && customRenderFunctionsSet.size === 0) { + 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; } @@ -45,6 +90,24 @@ export default async function transform( 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)); @@ -59,7 +122,7 @@ export default async function transform( ...findRenderHookMethodCalls(rootNode, renderHookVariables, renderHookMethodVariables), ); - if (functionCalls.length === 0 && customRenderFunctionsSet.size === 0) { + if (functionCalls.length === 0 && finalCustomRenderFunctionsSet.size === 0) { if (edits.length === 0) { return null; } @@ -68,10 +131,10 @@ export default async function transform( const functionsToMakeAsync = new Map>(); const customRenderFunctionsToMakeAsync = new Map>(); - if (customRenderFunctionsSet.size > 0 && importedFunctions.size > 0) { + if (finalCustomRenderFunctionsSet.size > 0 && importedFunctions.size > 0) { const customRenderFunctionDefinitions = findCustomRenderFunctionDefinitions( rootNode, - customRenderFunctionsSet, + finalCustomRenderFunctionsSet, ); for (const funcDef of customRenderFunctionDefinitions) { transformRNTLCallsInsideCustomRender( @@ -108,8 +171,11 @@ export default async function transform( addAwaitBeforeCall(functionCall, edits); } - if (customRenderFunctionsSet.size > 0) { - const customRenderCalls = findCustomRenderFunctionCalls(rootNode, customRenderFunctionsSet); + if (finalCustomRenderFunctionsSet.size > 0) { + const customRenderCalls = findCustomRenderFunctionCalls( + rootNode, + finalCustomRenderFunctionsSet, + ); for (const callExpr of customRenderCalls) { const containingFunction = findContainingTestFunction(callExpr); if (containingFunction) { @@ -788,6 +854,149 @@ function findRenderHookMethodCalls( return functionCalls; } +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, 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 index 08a93c4b6..675e36472 100644 --- a/codemods/v14-async-functions/tests/fixtures/both-renderhook-renderer/expected.tsx +++ b/codemods/v14-async-functions/tests/fixtures/both-renderhook-renderer/expected.tsx @@ -3,7 +3,7 @@ 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 index 3c20f194e..f73f47017 100644 --- a/codemods/v14-async-functions/tests/fixtures/both-renderhook-renderer/input.tsx +++ b/codemods/v14-async-functions/tests/fixtures/both-renderhook-renderer/input.tsx @@ -3,7 +3,7 @@ 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 index 632ad4877..3d06284e4 100644 --- a/codemods/v14-async-functions/tests/fixtures/combined-functions/expected.tsx +++ b/codemods/v14-async-functions/tests/fixtures/combined-functions/expected.tsx @@ -2,15 +2,15 @@ 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 index c11c032d3..f0c9e8336 100644 --- a/codemods/v14-async-functions/tests/fixtures/combined-functions/input.tsx +++ b/codemods/v14-async-functions/tests/fixtures/combined-functions/input.tsx @@ -2,15 +2,15 @@ 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 index 9f04cfef3..65a8b5ba4 100644 --- a/codemods/v14-async-functions/tests/fixtures/custom-render-function/expected.tsx +++ b/codemods/v14-async-functions/tests/fixtures/custom-render-function/expected.tsx @@ -11,7 +11,7 @@ const renderWithTheme = async (component: React.ReactElement) => { }; // Function expression -const renderCustom = async function(component: React.ReactElement) { +const renderCustom = async function (component: React.ReactElement) { await render(component); }; 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 index 7baf0cbfe..f06786edc 100644 --- a/codemods/v14-async-functions/tests/fixtures/custom-render-function/input.tsx +++ b/codemods/v14-async-functions/tests/fixtures/custom-render-function/input.tsx @@ -11,7 +11,7 @@ const renderWithTheme = (component: React.ReactElement) => { }; // Function expression -const renderCustom = function(component: React.ReactElement) { +const renderCustom = function (component: React.ReactElement) { render(component); }; diff --git a/codemods/v14-async-functions/tests/fixtures/duplicate-imports/expected.tsx b/codemods/v14-async-functions/tests/fixtures/duplicate-imports/expected.tsx index 67c896570..6279bef29 100644 --- a/codemods/v14-async-functions/tests/fixtures/duplicate-imports/expected.tsx +++ b/codemods/v14-async-functions/tests/fixtures/duplicate-imports/expected.tsx @@ -1,15 +1,20 @@ -import { render, renderHook, fireEvent, waitFor } from '@testing-library/react-native'; +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 index a8d7d5410..1d2ac69e8 100644 --- a/codemods/v14-async-functions/tests/fixtures/duplicate-imports/input.tsx +++ b/codemods/v14-async-functions/tests/fixtures/duplicate-imports/input.tsx @@ -1,15 +1,23 @@ -import { render, renderAsync, renderHook, renderHookAsync, fireEvent, fireEventAsync, waitFor } from '@testing-library/react-native'; +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-methods/expected.tsx b/codemods/v14-async-functions/tests/fixtures/fireevent-methods/expected.tsx index 24ec7e2fe..fcac36ec7 100644 --- a/codemods/v14-async-functions/tests/fixtures/fireevent-methods/expected.tsx +++ b/codemods/v14-async-functions/tests/fixtures/fireevent-methods/expected.tsx @@ -5,10 +5,10 @@ test('uses fireEvent methods', async () => { 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 index 337ad46db..6f9307ad1 100644 --- a/codemods/v14-async-functions/tests/fixtures/fireevent-methods/input.tsx +++ b/codemods/v14-async-functions/tests/fixtures/fireevent-methods/input.tsx @@ -5,10 +5,10 @@ test('uses fireEvent methods', () => { 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 index 7f4234bb6..cbef18280 100644 --- a/codemods/v14-async-functions/tests/fixtures/function-declaration/expected.tsx +++ b/codemods/v14-async-functions/tests/fixtures/function-declaration/expected.tsx @@ -1,5 +1,5 @@ import { render } from '@testing-library/react-native'; -test('function declaration', async function() { +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 index ad71eea56..9e0fc2ac6 100644 --- a/codemods/v14-async-functions/tests/fixtures/function-declaration/input.tsx +++ b/codemods/v14-async-functions/tests/fixtures/function-declaration/input.tsx @@ -1,5 +1,5 @@ import { render } from '@testing-library/react-native'; -test('function declaration', function() { +test('function declaration', function () { render(); }); diff --git a/codemods/v14-async-functions/tests/fixtures/skip-variants/expected.tsx b/codemods/v14-async-functions/tests/fixtures/skip-variants/expected.tsx index a6f1c1a89..d5f15efa2 100644 --- a/codemods/v14-async-functions/tests/fixtures/skip-variants/expected.tsx +++ b/codemods/v14-async-functions/tests/fixtures/skip-variants/expected.tsx @@ -1,19 +1,25 @@ -import { render, act, renderHook, unsafe_act, unsafe_renderHookSync } from '@testing-library/react-native'; +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 index dbade8d1f..d44c68977 100644 --- a/codemods/v14-async-functions/tests/fixtures/skip-variants/input.tsx +++ b/codemods/v14-async-functions/tests/fixtures/skip-variants/input.tsx @@ -1,19 +1,26 @@ -import { render, act, renderHook, unsafe_act, unsafe_renderHookSync, renderAsync } from '@testing-library/react-native'; +import { + render, + act, + renderHook, + unsafe_act, + unsafe_renderHookSync, + renderAsync, +} from '@testing-library/react-native'; -test('skips unsafe variants', () => { +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 })); - - renderAsync(); + + 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 index 272a89a97..9f570c288 100644 --- a/codemods/v14-async-functions/tests/fixtures/test-each-combined/expected.tsx +++ b/codemods/v14-async-functions/tests/fixtures/test-each-combined/expected.tsx @@ -1,16 +1,10 @@ import { render, renderHook, act } from '@testing-library/react-native'; -test.each([ - { value: 1 }, - { value: 2 }, -])('renders component with value $value', async ({ value }) => { +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) => { +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 index 9035bb9d4..ae66107df 100644 --- a/codemods/v14-async-functions/tests/fixtures/test-each-combined/input.tsx +++ b/codemods/v14-async-functions/tests/fixtures/test-each-combined/input.tsx @@ -1,16 +1,10 @@ import { render, renderHook, act } from '@testing-library/react-native'; -test.each([ - { value: 1 }, - { value: 2 }, -])('renders component with value $value', ({ value }) => { +test.each([{ value: 1 }, { value: 2 }])('renders component with value $value', ({ value }) => { render(); }); -it.each([ - [true], - [false], -])('renders hook with flag %p', async (flag) => { +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 index 7caa8a6c9..1f57c8d4b 100644 --- a/codemods/v14-async-functions/tests/fixtures/test-each/expected.tsx +++ b/codemods/v14-async-functions/tests/fixtures/test-each/expected.tsx @@ -1,9 +1,6 @@ import { render } from '@testing-library/react-native'; -test.each([ - { name: 'Alice' }, - { name: 'Bob' }, -])('renders for $name', async ({ name }) => { +test.each([{ name: 'Alice' }, { name: 'Bob' }])('renders for $name', async ({ name }) => { 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 index ed9d39618..a69f07efc 100644 --- a/codemods/v14-async-functions/tests/fixtures/test-each/input.tsx +++ b/codemods/v14-async-functions/tests/fixtures/test-each/input.tsx @@ -1,9 +1,6 @@ import { render } from '@testing-library/react-native'; -test.each([ - { name: 'Alice' }, - { name: 'Bob' }, -])('renders for $name', ({ name }) => { +test.each([{ name: 'Alice' }, { name: 'Bob' }])('renders for $name', ({ name }) => { render(); }); diff --git a/codemods/v14-update-deps/README.md b/codemods/v14-update-deps/README.md index 76a989ce1..c062e0bd1 100644 --- a/codemods/v14-update-deps/README.md +++ b/codemods/v14-update-deps/README.md @@ -38,6 +38,7 @@ npx codemod@latest run @testing-library/react-native-v14-update-deps --target ./ ### Example transformations #### Before: + ```json { "dependencies": { @@ -51,6 +52,7 @@ npx codemod@latest run @testing-library/react-native-v14-update-deps --target ./ ``` #### After: + ```json { "devDependencies": { @@ -63,6 +65,7 @@ npx codemod@latest run @testing-library/react-native-v14-update-deps --target ./ #### Moving from dependencies to devDependencies: **Before:** + ```json { "dependencies": { @@ -72,6 +75,7 @@ npx codemod@latest run @testing-library/react-native-v14-update-deps --target ./ ``` **After:** + ```json { "devDependencies": { @@ -84,6 +88,7 @@ npx codemod@latest run @testing-library/react-native-v14-update-deps --target ./ #### Adding if not present: **Before:** + ```json { "devDependencies": { @@ -93,6 +98,7 @@ npx codemod@latest run @testing-library/react-native-v14-update-deps --target ./ ``` **After:** + ```json { "devDependencies": { @@ -106,6 +112,7 @@ npx codemod@latest run @testing-library/react-native-v14-update-deps --target ./ ## Important Notes 1. **After running the codemod**, you'll need to run your package manager to install the new dependencies: + ```bash npm install # or diff --git a/codemods/v14-update-deps/codemod.yaml b/codemods/v14-update-deps/codemod.yaml index 933ad8888..c58a05343 100644 --- a/codemods/v14-update-deps/codemod.yaml +++ b/codemods/v14-update-deps/codemod.yaml @@ -1,20 +1,19 @@ -schema_version: "1.0" - -name: "@testing-library/react-native-v14-update-deps" -version: "0.1.0" -description: "Codemod to update dependencies for RNTL v14 migration" -author: "Maciej Jastrzebski" -license: "MIT" -workflow: "workflow.yaml" +schema_version: '1.0' +name: '@testing-library/react-native-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"] + languages: ['json'] -keywords: ["transformation", "migration", "dependencies"] +keywords: ['transformation', 'migration', 'dependencies'] registry: - access: "public" - visibility: "public" + access: 'public' + visibility: 'public' capabilities: [] diff --git a/codemods/v14-update-deps/package.json b/codemods/v14-update-deps/package.json index 80920de7e..a187527d9 100644 --- a/codemods/v14-update-deps/package.json +++ b/codemods/v14-update-deps/package.json @@ -4,7 +4,7 @@ "description": "Codemod to update dependencies for RNTL v14 migration", "type": "module", "scripts": { - "test": "node scripts/test.js" + "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 index 93f9659b9..bb250ed91 100644 --- a/codemods/v14-update-deps/scripts/codemod.ts +++ b/codemods/v14-update-deps/scripts/codemod.ts @@ -14,7 +14,9 @@ interface PackageJson { [key: string]: unknown; } -export default async function transform(root: Parameters>[0]): Promise { +export default async function transform( + root: Parameters>[0], +): Promise { const filename = root.filename(); if (!isPackageJsonFile(filename)) { @@ -79,7 +81,9 @@ function hasRNTLOrUTR(packageJson: PackageJson): boolean { function removePackageFromAllDependencyTypes(pkgName: string, packageJson: PackageJson): boolean { let removed = false; - (['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'] as const).forEach((depType) => { + ( + ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'] as const + ).forEach((depType) => { if (packageJson[depType]?.[pkgName]) { delete packageJson[depType]![pkgName]; removed = true; @@ -107,7 +111,10 @@ function ensureDevDependenciesObjectExists(packageJson: PackageJson): boolean { } function removeObsoletePackages(packageJson: PackageJson): boolean { - const removedTypes = removePackageFromAllDependencyTypes('@types/react-test-renderer', packageJson); + const removedTypes = removePackageFromAllDependencyTypes( + '@types/react-test-renderer', + packageJson, + ); const removedRenderer = removePackageFromAllDependencyTypes('react-test-renderer', packageJson); return removedTypes || removedRenderer; } diff --git a/codemods/v14-update-deps/scripts/test.js b/codemods/v14-update-deps/scripts/test.js index 6dc9fe2b6..a9b7b297f 100755 --- a/codemods/v14-update-deps/scripts/test.js +++ b/codemods/v14-update-deps/scripts/test.js @@ -17,16 +17,16 @@ const fixturesDir = join(__dirname, '..', 'tests', 'fixtures'); 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 - }) + text: () => packageJsonContent, + }), }; - + const result = await transform(mockRoot); // Return result or original content if null (no changes) return result || packageJsonContent; @@ -50,21 +50,21 @@ for (const testCase of testCases) { try { const inputContent = readFileSync(inputPath, 'utf8'); const expectedContent = readFileSync(expectedPath, 'utf8'); - - // Create a temporary file to test - const tempPath = join(fixturesDir, testCase, 'temp.json'); + + // 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++; @@ -76,11 +76,12 @@ for (const testCase of testCases) { console.log(JSON.stringify(resultJson, null, 2)); failed++; } - + // Clean up temp file - if (existsSync(tempPath)) { + const tempFilePath = join(fixturesDir, testCase, 'package.json'); + if (existsSync(tempFilePath)) { const { unlinkSync } = await import('fs'); - unlinkSync(tempPath); + unlinkSync(tempFilePath); } } catch (error) { console.log(`❌ ${testCase}: ERROR - ${error.message}`); diff --git a/codemods/v14-update-deps/tests/fixtures/already-alpha/expected.json b/codemods/v14-update-deps/tests/fixtures/already-alpha/expected.json index 09f2ccd3f..0f4787f02 100644 --- a/codemods/v14-update-deps/tests/fixtures/already-alpha/expected.json +++ b/codemods/v14-update-deps/tests/fixtures/already-alpha/expected.json @@ -2,7 +2,7 @@ "name": "test-project", "version": "1.0.0", "devDependencies": { - "@testing-library/react-native": "^14.0.0-alpha", + "@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/expected.json b/codemods/v14-update-deps/tests/fixtures/basic-update/expected.json index 09f2ccd3f..0f4787f02 100644 --- a/codemods/v14-update-deps/tests/fixtures/basic-update/expected.json +++ b/codemods/v14-update-deps/tests/fixtures/basic-update/expected.json @@ -2,7 +2,7 @@ "name": "test-project", "version": "1.0.0", "devDependencies": { - "@testing-library/react-native": "^14.0.0-alpha", + "@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/expected.json b/codemods/v14-update-deps/tests/fixtures/move-from-deps/expected.json index 09f2ccd3f..0f4787f02 100644 --- a/codemods/v14-update-deps/tests/fixtures/move-from-deps/expected.json +++ b/codemods/v14-update-deps/tests/fixtures/move-from-deps/expected.json @@ -2,7 +2,7 @@ "name": "test-project", "version": "1.0.0", "devDependencies": { - "@testing-library/react-native": "^14.0.0-alpha", + "@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/expected.json b/codemods/v14-update-deps/tests/fixtures/rntl-in-devdeps/expected.json index 7d7088b57..1928868ae 100644 --- a/codemods/v14-update-deps/tests/fixtures/rntl-in-devdeps/expected.json +++ b/codemods/v14-update-deps/tests/fixtures/rntl-in-devdeps/expected.json @@ -1 +1,6 @@ -{"devDependencies":{"@testing-library/react-native":"^14.0.0-alpha","universal-test-renderer":"0.10.1"}} +{ + "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 index a74ab959d..1fa31c01b 100644 --- a/codemods/v14-update-deps/tests/fixtures/rntl-in-devdeps/input.json +++ b/codemods/v14-update-deps/tests/fixtures/rntl-in-devdeps/input.json @@ -1 +1,6 @@ -{"devDependencies":{"@testing-library/react-native":"^13.0.0","react-test-renderer":"^18.0.0"}} +{ + "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 index 09f2ccd3f..0f4787f02 100644 --- a/codemods/v14-update-deps/tests/fixtures/with-peer-deps/expected.json +++ b/codemods/v14-update-deps/tests/fixtures/with-peer-deps/expected.json @@ -2,7 +2,7 @@ "name": "test-project", "version": "1.0.0", "devDependencies": { - "@testing-library/react-native": "^14.0.0-alpha", + "@testing-library/react-native": "^14.0.0-alpha.5", "universal-test-renderer": "0.10.1" } } diff --git a/codemods/v14-update-deps/workflow.yaml b/codemods/v14-update-deps/workflow.yaml index b6f2ded51..b62748fd5 100644 --- a/codemods/v14-update-deps/workflow.yaml +++ b/codemods/v14-update-deps/workflow.yaml @@ -1,22 +1,22 @@ # yaml-language-server: $schema=https://raw.githubusercontent.com/codemod/codemod/refs/heads/main/schemas/workflow.json -version: "1" +version: '1' nodes: - id: update-package-json name: Update package.json dependencies type: automatic steps: - - name: "Update dependencies in package.json" + - name: 'Update dependencies in package.json' js-ast-grep: js_file: scripts/codemod.ts language: json include: - - "**/package.json" + - '**/package.json' exclude: - - "**/node_modules/**" - - "**/build/**" - - "**/dist/**" - - "**/.next/**" - - "**/coverage/**" - - "**/.yarn/**" + - '**/node_modules/**' + - '**/build/**' + - '**/dist/**' + - '**/.next/**' + - '**/coverage/**' + - '**/.yarn/**' 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'); From d46d0aa97eb863f9fe613bbfbf9c304ba5b97072 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Tue, 6 Jan 2026 22:20:00 +0100 Subject: [PATCH 21/27] code review --- .../v14-async-functions/scripts/codemod.ts | 22 ------------------- 1 file changed, 22 deletions(-) diff --git a/codemods/v14-async-functions/scripts/codemod.ts b/codemods/v14-async-functions/scripts/codemod.ts index d60ca3a38..596ffd3d3 100644 --- a/codemods/v14-async-functions/scripts/codemod.ts +++ b/codemods/v14-async-functions/scripts/codemod.ts @@ -6,7 +6,6 @@ const FUNCTIONS_TO_MAKE_ASYNC = new Set(['render', 'renderHook', 'act', 'fireEve 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 SKIP_VARIANTS = new Set(['unsafe_renderHookSync', 'unsafe_act']); const ASYNC_FUNCTIONS_TO_RENAME = new Map([ ['renderAsync', 'render'], ['renderHookAsync', 'renderHook'], @@ -152,10 +151,6 @@ export default async function transform( continue; } - if (shouldSkipTransformation(functionCall)) { - continue; - } - const containingFunction = findContainingTestFunction(functionCall); if (!containingFunction) { continue; @@ -1146,14 +1141,6 @@ function transformRNTLCallsInsideCustomRender( continue; } - const functionNode = rntlCall.field('function'); - if (functionNode) { - const funcName = functionNode.text(); - if (SKIP_VARIANTS.has(funcName)) { - continue; - } - } - const callStart = rntlCall.range().start.index; edits.push({ startPos: callStart, @@ -1176,15 +1163,6 @@ function isCallAlreadyAwaited(functionCall: SgNode): boolean { return parent !== null && parent.is('await_expression'); } -function shouldSkipTransformation(functionCall: SgNode): boolean { - const functionNode = functionCall.field('function'); - if (functionNode) { - const funcName = functionNode.text(); - return SKIP_VARIANTS.has(funcName); - } - return false; -} - function addAwaitBeforeCall(functionCall: SgNode, edits: Edit[]): void { const callStart = functionCall.range().start.index; edits.push({ From 9b1638fd48ddf2353288cb84542be4aad0687540 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Tue, 6 Jan 2026 22:26:00 +0100 Subject: [PATCH 22/27] code review --- .../v14-async-functions/scripts/codemod.ts | 134 +++++++++++++++--- codemods/v14-update-deps/README.md | 18 +-- codemods/v14-update-deps/scripts/codemod.ts | 8 +- codemods/v14-update-deps/scripts/test.js | 2 + codemods/v14-update-deps/tsconfig.json | 17 +++ 5 files changed, 149 insertions(+), 30 deletions(-) create mode 100644 codemods/v14-update-deps/tsconfig.json diff --git a/codemods/v14-async-functions/scripts/codemod.ts b/codemods/v14-async-functions/scripts/codemod.ts index 596ffd3d3..fc20c3f4d 100644 --- a/codemods/v14-async-functions/scripts/codemod.ts +++ b/codemods/v14-async-functions/scripts/codemod.ts @@ -157,7 +157,7 @@ export default async function transform( } if ( - !isFunctionAlreadyAsync(containingFunction, rootNode) && + !isFunctionAlreadyAsync(containingFunction) && !functionsToMakeAsync.has(containingFunction.id()) ) { functionsToMakeAsync.set(containingFunction.id(), containingFunction); @@ -179,7 +179,7 @@ export default async function transform( } if ( - !isFunctionAlreadyAsync(containingFunction, rootNode) && + !isFunctionAlreadyAsync(containingFunction) && !functionsToMakeAsync.has(containingFunction.id()) ) { functionsToMakeAsync.set(containingFunction.id(), containingFunction); @@ -207,7 +207,15 @@ export default async function transform( return rootNode.commitEdits(edits); } -function parseCustomRenderFunctionsFromOptions(options: any): Set { +interface CodemodOptions { + params?: { + customRenderFunctions?: string | number | boolean; + }; +} + +function parseCustomRenderFunctionsFromOptions( + options?: CodemodOptions, +): Set { const customRenderFunctionsParam = options?.params?.customRenderFunctions ? String(options.params.customRenderFunctions) : ''; @@ -319,6 +327,17 @@ function extractImportedFunctionNames( 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, @@ -493,7 +512,8 @@ function findFireEventMethodCalls( } } } catch { - // Skip if field() is not available + // 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. } } } @@ -528,7 +548,8 @@ function findScreenMethodCalls(rootNode: SgNode): SgNode[] { } } } catch { - // Skip if field() is not available + // 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. } } } @@ -536,6 +557,19 @@ function findScreenMethodCalls(rootNode: SgNode): SgNode[] { return functionCalls; } +/** + * Tracks variables assigned from render() calls to identify renderer result objects. + * This helps identify calls like `renderer.rerender()` or `renderer.unmount()` that need to be made async. + * + * Handles various assignment patterns: + * - Direct assignment: `const renderer = render(...)` + * - Destructured assignment: `const { rerender } = render(...)` + * - Assignment expressions: `renderer = render(...)` + * + * @param rootNode - The root AST node to search within + * @param importedFunctions - Set of imported function names (must include 'render') + * @returns Set of variable names that represent renderer results + */ function trackVariablesAssignedFromRender( rootNode: SgNode, importedFunctions: Set, @@ -646,7 +680,8 @@ function findRendererMethodCalls( } } } catch { - // Skip if field() is not available + // 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. } } } @@ -671,6 +706,21 @@ function findRendererMethodCalls( return functionCalls; } +/** + * Tracks variables assigned from renderHook() calls to identify hook result objects. + * Similar to trackVariablesAssignedFromRender but handles renderHook-specific patterns. + * + * Handles various assignment patterns: + * - Direct assignment: `const result = renderHook(...)` + * - Destructured assignment: `const { rerender, unmount } = renderHook(...)` + * - Renamed destructuring: `const { rerender: rerenderHook } = renderHook(...)` + * + * @param rootNode - The root AST node to search within + * @param importedFunctions - Set of imported function names (must include 'renderHook') + * @returns Object containing: + * - renderHookVariables: Set of all variable names representing hook results + * - renderHookMethodVariables: Set of renamed method variables (e.g., rerenderHook) + */ function trackVariablesAssignedFromRenderHook( rootNode: SgNode, importedFunctions: Set, @@ -824,7 +874,8 @@ function findRenderHookMethodCalls( } } } catch { - // Skip if field() is not available + // 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. } } } @@ -849,6 +900,20 @@ function findRenderHookMethodCalls( 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, @@ -1116,7 +1181,8 @@ function findRNTLFunctionCallsInNode( } } } catch { - // Skip if field() is not available + // 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. } } } @@ -1151,7 +1217,7 @@ function transformRNTLCallsInsideCustomRender( } if (needsAsync && !customRenderFunctionsToMakeAsync.has(funcNode.id())) { - const isAsync = isFunctionAlreadyAsync(funcNode, rootNode); + const isAsync = isFunctionAlreadyAsync(funcNode); if (!isAsync) { customRenderFunctionsToMakeAsync.set(funcNode.id(), funcNode); } @@ -1172,15 +1238,30 @@ function addAwaitBeforeCall(functionCall: SgNode, edits: Edit[]): void { }); } -function isFunctionAlreadyAsync(func: SgNode, rootNode: SgNode): boolean { +/** + * 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 { - const funcStart = func.range().start.index; - const textBefore = rootNode.text().substring(Math.max(0, funcStart - 10), funcStart); - return textBefore.trim().endsWith('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 { @@ -1212,6 +1293,19 @@ function addAsyncKeywordToFunction(func: SgNode, edits: Edit[]): void { } } +/** + * 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; @@ -1244,7 +1338,8 @@ function findContainingTestFunction(node: SgNode): SgNode | null { } } } catch { - // Skip if field() is not available + // 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')) { @@ -1262,7 +1357,8 @@ function findContainingTestFunction(node: SgNode): SgNode | null { } } } catch { - // Skip if field() is not available + // 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. } } } @@ -1287,7 +1383,8 @@ function findContainingTestFunction(node: SgNode): SgNode | null { } } } catch { - // Skip if field() is not available + // 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')) { @@ -1305,7 +1402,8 @@ function findContainingTestFunction(node: SgNode): SgNode | null { } } } catch { - // Skip if field() is not available + // 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. } } } diff --git a/codemods/v14-update-deps/README.md b/codemods/v14-update-deps/README.md index c062e0bd1..014173a2e 100644 --- a/codemods/v14-update-deps/README.md +++ b/codemods/v14-update-deps/README.md @@ -5,8 +5,8 @@ This codemod updates your `package.json` file to prepare for React Native Testin - ✅ Removing `@types/react-test-renderer` (no longer needed) - ✅ Removing `react-test-renderer` (replaced by universal-test-renderer) - ✅ Moving `@testing-library/react-native` from `dependencies` to `devDependencies` if present -- ✅ Adding `@testing-library/react-native@^14.0.0-alpha` to `devDependencies` if not present -- ✅ Updating `@testing-library/react-native` to `^14.0.0-alpha` (latest alpha version) +- ✅ Adding `@testing-library/react-native@^14.0.0-alpha.5` to `devDependencies` if not present +- ✅ Updating `@testing-library/react-native` to `^14.0.0-alpha.5` - ✅ Adding `universal-test-renderer@0.10.1` to `devDependencies` (always added) ## What it does @@ -17,9 +17,9 @@ This codemod automatically updates your `package.json` dependencies to match the 2. **Moves RNTL to devDependencies**: If `@testing-library/react-native` is in `dependencies`, it's moved to `devDependencies` -3. **Ensures RNTL is present**: If `@testing-library/react-native` is not present, it's added to `devDependencies` with version `^14.0.0-alpha` +3. **Ensures RNTL is present**: If `@testing-library/react-native` is not present, it's added to `devDependencies` with version `^14.0.0-alpha.5` -4. **Updates RNTL version**: Updates `@testing-library/react-native` to `^14.0.0-alpha` to get the latest alpha version +4. **Updates RNTL version**: Updates `@testing-library/react-native` to `^14.0.0-alpha.5` 5. **Adds universal-test-renderer**: Always adds `universal-test-renderer@0.10.1` to `devDependencies` @@ -56,7 +56,7 @@ npx codemod@latest run @testing-library/react-native-v14-update-deps --target ./ ```json { "devDependencies": { - "@testing-library/react-native": "^14.0.0-alpha", + "@testing-library/react-native": "^14.0.0-alpha.5", "universal-test-renderer": "0.10.1" } } @@ -79,7 +79,7 @@ npx codemod@latest run @testing-library/react-native-v14-update-deps --target ./ ```json { "devDependencies": { - "@testing-library/react-native": "^14.0.0-alpha", + "@testing-library/react-native": "^14.0.0-alpha.5", "universal-test-renderer": "0.10.1" } } @@ -103,7 +103,7 @@ npx codemod@latest run @testing-library/react-native-v14-update-deps --target ./ { "devDependencies": { "some-other-package": "^1.0.0", - "@testing-library/react-native": "^14.0.0-alpha", + "@testing-library/react-native": "^14.0.0-alpha.5", "universal-test-renderer": "0.10.1" } } @@ -121,7 +121,7 @@ npx codemod@latest run @testing-library/react-native-v14-update-deps --target ./ pnpm install ``` -2. **Version resolution**: The codemod sets `@testing-library/react-native` to `^14.0.0-alpha`, which will resolve to the latest alpha version available. You can manually update this to a specific version if needed. +2. **Version resolution**: The codemod sets `@testing-library/react-native` to `^14.0.0-alpha.5`. You can manually update this to a different version if needed. 3. **Always adds packages**: The codemod always ensures both `@testing-library/react-native` and `universal-test-renderer` are present in `devDependencies`, even if they weren't there before. @@ -138,7 +138,7 @@ npm test 1. **Package manager**: The codemod updates `package.json` but doesn't run the package manager install command. You need to run `npm install` / `yarn install` / `pnpm install` manually after the codemod completes. -2. **Version pinning**: If you have a specific alpha version pinned, the codemod will update it to the range `^14.0.0-alpha`. You may want to review and adjust the version after running the codemod. +2. **Version pinning**: If you have a specific alpha version pinned, the codemod will update it to `^14.0.0-alpha.5`. You may want to review and adjust the version after running the codemod. 3. **Workspace projects**: For monorepos with multiple `package.json` files, the codemod will process each one individually. diff --git a/codemods/v14-update-deps/scripts/codemod.ts b/codemods/v14-update-deps/scripts/codemod.ts index bb250ed91..e9c859333 100644 --- a/codemods/v14-update-deps/scripts/codemod.ts +++ b/codemods/v14-update-deps/scripts/codemod.ts @@ -55,9 +55,11 @@ export default async function transform( return null; } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - console.error(`Error processing ${filename}:`, errorMessage); - return null; + // 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)}`, + ); } } diff --git a/codemods/v14-update-deps/scripts/test.js b/codemods/v14-update-deps/scripts/test.js index a9b7b297f..373e056d4 100755 --- a/codemods/v14-update-deps/scripts/test.js +++ b/codemods/v14-update-deps/scripts/test.js @@ -2,8 +2,10 @@ /** * 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'; 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"] +} From 1b1a41bf316ec97592f7e88b58444b19ccbd6c84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Tue, 6 Jan 2026 22:29:48 +0100 Subject: [PATCH 23/27] tweaks --- .../v14-async-functions/scripts/codemod.ts | 253 +++++------------- 1 file changed, 61 insertions(+), 192 deletions(-) diff --git a/codemods/v14-async-functions/scripts/codemod.ts b/codemods/v14-async-functions/scripts/codemod.ts index fc20c3f4d..c94382db1 100644 --- a/codemods/v14-async-functions/scripts/codemod.ts +++ b/codemods/v14-async-functions/scripts/codemod.ts @@ -110,16 +110,11 @@ export default async function transform( functionCalls.push(...findFireEventMethodCalls(rootNode, importedFunctions, rntlImports)); functionCalls.push(...findScreenMethodCalls(rootNode)); - const rendererVariables = trackVariablesAssignedFromRender(rootNode, importedFunctions); - functionCalls.push(...findRendererMethodCalls(rootNode, rendererVariables)); - - const { renderHookVariables, renderHookMethodVariables } = trackVariablesAssignedFromRenderHook( + const { allVariables, renamedMethodVariables } = trackVariablesAssignedFromRenderAndRenderHook( rootNode, importedFunctions, ); - functionCalls.push( - ...findRenderHookMethodCalls(rootNode, renderHookVariables, renderHookMethodVariables), - ); + functionCalls.push(...findResultMethodCalls(rootNode, allVariables, renamedMethodVariables)); if (functionCalls.length === 0 && finalCustomRenderFunctionsSet.size === 0) { if (edits.length === 0) { @@ -558,193 +553,53 @@ function findScreenMethodCalls(rootNode: SgNode): SgNode[] { } /** - * Tracks variables assigned from render() calls to identify renderer result objects. - * This helps identify calls like `renderer.rerender()` or `renderer.unmount()` that need to be made async. - * - * Handles various assignment patterns: - * - Direct assignment: `const renderer = render(...)` - * - Destructured assignment: `const { rerender } = render(...)` - * - Assignment expressions: `renderer = render(...)` - * - * @param rootNode - The root AST node to search within - * @param importedFunctions - Set of imported function names (must include 'render') - * @returns Set of variable names that represent renderer results - */ -function trackVariablesAssignedFromRender( - rootNode: SgNode, - importedFunctions: Set, -): Set { - const rendererVariables = new Set(); - - if (importedFunctions.has('render')) { - const renderCalls = rootNode.findAll({ - rule: { - kind: 'call_expression', - has: { - field: 'function', - kind: 'identifier', - regex: '^render$', - }, - }, - }); - - for (const renderCall of renderCalls) { - let parent = renderCall.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)) { - rendererVariables.add(propName); - } - } - } else { - const nameNode = parent.find({ - rule: { kind: 'identifier' }, - }); - if (nameNode) { - const varName = nameNode.text(); - rendererVariables.add(varName); - } - } - } else if (parent && parent.is('assignment_expression')) { - const left = parent.find({ - rule: { kind: 'identifier' }, - }); - if (left) { - const varName = left.text(); - rendererVariables.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)) { - rendererVariables.add(propName); - } - } - } - } - } - } - } - - return rendererVariables; -} - -function findRendererMethodCalls( - rootNode: SgNode, - rendererVariables: Set, -): SgNode[] { - const functionCalls: SgNode[] = []; - - if (rendererVariables.size > 0) { - const rendererMethodCalls = rootNode.findAll({ - rule: { - kind: 'call_expression', - has: { - field: 'function', - kind: 'member_expression', - }, - }, - }); - - for (const call of rendererMethodCalls) { - 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 (rendererVariables.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. - } - } - } - - for (const varName of rendererVariables) { - if (RESULT_METHODS_TO_MAKE_ASYNC.has(varName)) { - const directCalls = rootNode.findAll({ - rule: { - kind: 'call_expression', - has: { - field: 'function', - kind: 'identifier', - regex: `^${varName}$`, - }, - }, - }); - functionCalls.push(...directCalls); - } - } - } - - return functionCalls; -} - -/** - * Tracks variables assigned from renderHook() calls to identify hook result objects. - * Similar to trackVariablesAssignedFromRender but handles renderHook-specific patterns. + * 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 result = renderHook(...)` - * - Destructured assignment: `const { rerender, unmount } = renderHook(...)` - * - Renamed destructuring: `const { rerender: rerenderHook } = renderHook(...)` + * - 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 (must include 'renderHook') + * @param importedFunctions - Set of imported function names (should include 'render' and/or 'renderHook') * @returns Object containing: - * - renderHookVariables: Set of all variable names representing hook results - * - renderHookMethodVariables: Set of renamed method variables (e.g., rerenderHook) + * - allVariables: Set of all variable names representing render/renderHook results + * - renamedMethodVariables: Set of renamed method variables (e.g., rerenderHook from renderHook) */ -function trackVariablesAssignedFromRenderHook( +function trackVariablesAssignedFromRenderAndRenderHook( rootNode: SgNode, importedFunctions: Set, ): { - renderHookVariables: Set; - renderHookMethodVariables: Set; + allVariables: Set; + renamedMethodVariables: Set; } { - const renderHookVariables = new Set(); - const renderHookMethodVariables = new Set(); + const allVariables = new Set(); + const renamedMethodVariables = new Set(); + + // Track variables from both render() and renderHook() calls + const functionsToTrack = ['render', 'renderHook'] as const; - if (importedFunctions.has('renderHook')) { - const renderHookCalls = rootNode.findAll({ + for (const funcName of functionsToTrack) { + if (!importedFunctions.has(funcName)) { + continue; + } + + const functionCalls = rootNode.findAll({ rule: { kind: 'call_expression', has: { field: 'function', kind: 'identifier', - regex: '^renderHook$', + regex: `^${funcName}$`, }, }, }); - for (const renderHookCall of renderHookCalls) { - let parent = renderHookCall.parent(); + for (const functionCall of functionCalls) { + let parent = functionCall.parent(); const isAwaited = parent && parent.is('await_expression'); if (isAwaited) { @@ -762,9 +617,10 @@ function trackVariablesAssignedFromRenderHook( for (const prop of shorthandProps) { const propName = prop.text(); if (RESULT_METHODS_TO_MAKE_ASYNC.has(propName)) { - renderHookVariables.add(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' }, }); @@ -779,8 +635,8 @@ function trackVariablesAssignedFromRenderHook( const keyName = key.text(); const valueName = value.text(); if (RESULT_METHODS_TO_MAKE_ASYNC.has(keyName)) { - renderHookVariables.add(valueName); - renderHookMethodVariables.add(valueName); + allVariables.add(valueName); + renamedMethodVariables.add(valueName); } } } @@ -790,7 +646,7 @@ function trackVariablesAssignedFromRenderHook( }); if (nameNode) { const varName = nameNode.text(); - renderHookVariables.add(varName); + allVariables.add(varName); } } } else if (parent && parent.is('assignment_expression')) { @@ -799,7 +655,7 @@ function trackVariablesAssignedFromRenderHook( }); if (left) { const varName = left.text(); - renderHookVariables.add(varName); + allVariables.add(varName); } else { const objectPattern = parent.find({ rule: { kind: 'object_pattern' }, @@ -811,9 +667,10 @@ function trackVariablesAssignedFromRenderHook( for (const prop of shorthandProps) { const propName = prop.text(); if (RESULT_METHODS_TO_MAKE_ASYNC.has(propName)) { - renderHookVariables.add(propName); + allVariables.add(propName); } } + // Handle renamed destructuring in assignment expressions const pairPatterns = objectPattern.findAll({ rule: { kind: 'pair_pattern' }, }); @@ -828,8 +685,8 @@ function trackVariablesAssignedFromRenderHook( const keyName = key.text(); const valueName = value.text(); if (RESULT_METHODS_TO_MAKE_ASYNC.has(keyName)) { - renderHookVariables.add(valueName); - renderHookMethodVariables.add(valueName); + allVariables.add(valueName); + renamedMethodVariables.add(valueName); } } } @@ -839,18 +696,27 @@ function trackVariablesAssignedFromRenderHook( } } - return { renderHookVariables, renderHookMethodVariables }; + return { allVariables, renamedMethodVariables }; } -function findRenderHookMethodCalls( +/** + * 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, - renderHookVariables: Set, - renderHookMethodVariables: Set, + allVariables: Set, + renamedMethodVariables: Set, ): SgNode[] { const functionCalls: SgNode[] = []; - if (renderHookVariables.size > 0) { - const renderHookMethodCalls = rootNode.findAll({ + if (allVariables.size > 0) { + const resultMethodCalls = rootNode.findAll({ rule: { kind: 'call_expression', has: { @@ -860,7 +726,7 @@ function findRenderHookMethodCalls( }, }); - for (const call of renderHookMethodCalls) { + for (const call of resultMethodCalls) { const funcNode = call.field('function'); if (funcNode && funcNode.is('member_expression')) { try { @@ -869,7 +735,7 @@ function findRenderHookMethodCalls( if (object && property) { const objText = object.text(); const propText = property.text(); - if (renderHookVariables.has(objText) && RESULT_METHODS_TO_MAKE_ASYNC.has(propText)) { + if (allVariables.has(objText) && RESULT_METHODS_TO_MAKE_ASYNC.has(propText)) { functionCalls.push(call); } } @@ -880,8 +746,9 @@ function findRenderHookMethodCalls( } } - for (const varName of renderHookVariables) { - if (RESULT_METHODS_TO_MAKE_ASYNC.has(varName) || renderHookMethodVariables.has(varName)) { + // 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', @@ -900,6 +767,8 @@ function findRenderHookMethodCalls( return functionCalls; } + + /** * Automatically detects custom render functions by analyzing the code structure. * A custom render function is identified as: From 9a5b79884c8acb6eae8b296c20aa2a3f5d38e5e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Tue, 6 Jan 2026 22:30:49 +0100 Subject: [PATCH 24/27] . --- jest.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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)/).*/', From 336a01aa9611f5e43853892a8c2968af787c8ab0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Tue, 6 Jan 2026 23:03:16 +0100 Subject: [PATCH 25/27] better README --- codemods/v14-async-functions/README.md | 442 +--------------------- codemods/v14-async-functions/codemod.yaml | 2 +- codemods/v14-update-deps/README.md | 145 ++----- codemods/v14-update-deps/codemod.yaml | 2 +- 4 files changed, 46 insertions(+), 545 deletions(-) diff --git a/codemods/v14-async-functions/README.md b/codemods/v14-async-functions/README.md index 3fe456023..f56eced77 100644 --- a/codemods/v14-async-functions/README.md +++ b/codemods/v14-async-functions/README.md @@ -1,61 +1,34 @@ # 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 transforming synchronous `render()`, `act()`, `renderHook()`, and `fireEvent()` calls to their async versions (`await render()`, `await act()`, `await renderHook()`, `await fireEvent()`, etc.) and making test functions async when needed. +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()` calls to `await render()` in test functions -- ✅ Transforms `act()` calls to `await act()` in test functions -- ✅ Transforms `renderHook()` calls to `await renderHook()` in test functions -- ✅ Transforms `fireEvent()` calls to `await fireEvent()` in test functions -- ✅ Transforms `fireEvent.press()`, `fireEvent.changeText()`, and `fireEvent.scroll()` calls to `await fireEvent.press()`, etc. -- ✅ Transforms `screen.rerender()` and `screen.unmount()` calls to `await screen.rerender()`, etc. -- ✅ Transforms `renderer.rerender()` and `renderer.unmount()` calls (where renderer is the return value from `render()`) to `await renderer.rerender()`, etc. -- ✅ Transforms `hookResult.rerender()` and `hookResult.unmount()` calls (where hookResult is the return value from `renderHook()`) to `await hookResult.rerender()`, etc. -- ✅ Makes test functions async if they're not already -- ✅ Handles `test()`, `it()`, `test.skip()`, `it.skip()`, `test.only()`, `it.only()`, `test.each()`, and `it.each()` patterns -- ✅ Handles `beforeEach()`, `afterEach()`, `beforeAll()`, and `afterAll()` hooks -- ✅ Does NOT make `describe()` block callbacks async (they are just grouping mechanisms) -- ✅ Preserves already-awaited function calls -- ✅ Skips function calls in helper functions (not inside test callbacks) -- ✅ Only transforms calls imported from `@testing-library/react-native` -- ✅ Skips variants like `renderAsync`, `unsafe_act`, and `unsafe_renderHookSync` - -## What it doesn't do - -- ❌ Does not transform function calls in helper functions (like `renderWithProviders`) - **unless specified via `CUSTOM_RENDER_FUNCTIONS`** -- ❌ Does not transform function calls from other libraries -- ❌ Does not handle namespace imports (e.g., `import * as RNTL from '@testing-library/react-native'`) -- ❌ Does not transform unsafe variants (`unsafe_act`, `unsafe_renderHookSync`) or `renderAsync` -- ❌ Does not make `describe()` block callbacks async (they are grouping mechanisms, not test functions) +- 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 -### Running the codemod - ```bash -# Run on your test files -npx codemod@latest workflow run -w ./codemods/v14-async-functions/workflow.yaml --target ./path/to/your/tests +# Run the codemod +npx codemod@latest run rntl-v14-async-functions --target ./path/to/your/tests +``` -# Or if published to the registry -npx codemod@latest run @testing-library/react-native-v14-async-functions --target ./path/to/your/tests +### Custom render functions -# With custom render functions - Option 1: Workflow parameter (recommended) -npx codemod@latest workflow run -w ./codemods/v14-async-functions/workflow.yaml --target ./path/to/your/tests --param customRenderFunctions="renderWithProviders,renderWithTheme" +If you have custom render helper functions (like `renderWithProviders`, `renderWithTheme`), specify them so they get transformed too: -# With custom render functions - Option 2: Environment variable -CUSTOM_RENDER_FUNCTIONS="renderWithProviders,renderWithTheme" npx codemod@latest workflow run -w ./codemods/v14-async-functions/workflow.yaml --target ./path/to/your/tests +```bash +npx codemod@latest run rntl-v14-async-functions --target ./path/to/your/tests --param customRenderFunctions="renderWithProviders,renderWithTheme" ``` -### Example transformations - -#### Basic sync test +## Example **Before:** ```typescript -import { render, screen } from '@testing-library/react-native'; - test('renders component', () => { render(); expect(screen.getByText('Hello')).toBeOnTheScreen(); @@ -65,394 +38,21 @@ test('renders component', () => { **After:** ```typescript -import { render, screen } from '@testing-library/react-native'; - test('renders component', async () => { await render(); expect(screen.getByText('Hello')).toBeOnTheScreen(); }); ``` -#### Already async test - -**Before:** - -```typescript -test('renders component', async () => { - render(); -}); -``` - -**After:** - -```typescript -test('renders component', async () => { - await render(); -}); -``` - -#### Multiple render calls - -**Before:** - -```typescript -test('renders multiple', () => { - render(); - render(); -}); -``` - -**After:** - -```typescript -test('renders multiple', async () => { - await render(); - await render(); -}); -``` - -#### Render with options - -**Before:** - -```typescript -test('renders with wrapper', () => { - render(, { wrapper: Wrapper }); -}); -``` - -**After:** - -```typescript -test('renders with wrapper', async () => { - await render(, { wrapper: Wrapper }); -}); -``` - -#### Using act() - -**Before:** - -```typescript -import { act } from '@testing-library/react-native'; - -test('updates state', () => { - act(() => { - // Some state update - }); -}); -``` - -**After:** - -```typescript -import { act } from '@testing-library/react-native'; - -test('updates state', async () => { - await act(() => { - // Some state update - }); -}); -``` - -#### Using renderHook() - -**Before:** - -```typescript -import { renderHook } from '@testing-library/react-native'; - -test('renders hook', () => { - const { result } = renderHook(() => ({ value: 42 })); - expect(result.current.value).toBe(42); -}); -``` - -**After:** - -```typescript -import { renderHook } from '@testing-library/react-native'; - -test('renders hook', async () => { - const { result } = await renderHook(() => ({ value: 42 })); - expect(result.current.value).toBe(42); -}); -``` - -#### Combined usage - -**Before:** - -```typescript -import { render, act, renderHook, screen } from '@testing-library/react-native'; - -test('uses all three', () => { - render(); - act(() => { - // State update - }); - const { result } = renderHook(() => ({ value: 42 })); -}); -``` - -**After:** - -```typescript -import { render, act, renderHook, screen } from '@testing-library/react-native'; - -test('uses all three', async () => { - await render(); - await act(() => { - // State update - }); - const { result } = await renderHook(() => ({ value: 42 })); -}); -``` - -#### Using fireEvent() - -**Before:** - -```typescript -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(); -}); -``` - -**After:** - -```typescript -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(); -}); -``` - -#### Using fireEvent methods - -**Before:** - -```typescript -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 } } }); -}); -``` - -**After:** - -```typescript -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 } } }); -}); -``` - -#### Skipping unsafe variants - -**Before:** - -```typescript -import { act, renderHook, unsafe_act, unsafe_renderHookSync, renderAsync } from '@testing-library/react-native'; - -test('skips unsafe variants', () => { - act(() => {}); // Will be transformed - unsafe_act(() => {}); // Will NOT be transformed - renderHook(() => ({})); // Will be transformed - unsafe_renderHookSync(() => ({})); // Will NOT be transformed - renderAsync(); // Will NOT be transformed -}); -``` - -**After:** - -```typescript -import { act, renderHook, unsafe_act, unsafe_renderHookSync, renderAsync } from '@testing-library/react-native'; - -test('skips unsafe variants', async () => { - await act(() => {}); // Transformed - unsafe_act(() => {}); // Unchanged - await renderHook(() => ({})); // Transformed - unsafe_renderHookSync(() => ({})); // Unchanged - renderAsync(); // Unchanged -}); -``` - -#### Helper functions (not transformed by default) - -**Before:** - -```typescript -function renderWithProviders(component: React.ReactElement) { - render(component); // This is NOT transformed by default -} - -test('uses helper', () => { - renderWithProviders(); -}); -``` - -**After (without CUSTOM_RENDER_FUNCTIONS):** - -```typescript -function renderWithProviders(component: React.ReactElement) { - render(component); // Unchanged - helper functions are skipped by default -} - -test('uses helper', () => { - renderWithProviders(); -}); -``` - -#### Custom render functions (with CUSTOM_RENDER_FUNCTIONS) - -When you specify custom render function names via the `CUSTOM_RENDER_FUNCTIONS` environment variable, those functions will be transformed: - -**Before:** - -```typescript -function renderWithProviders(component: React.ReactElement) { - render(component); -} - -const renderWithTheme = (component: React.ReactElement) => { - render(component); -}; - -test('uses custom render', () => { - renderWithProviders(); - renderWithTheme(); -}); -``` - -**After (with CUSTOM_RENDER_FUNCTIONS="renderWithProviders,renderWithTheme"):** - -```typescript -async function renderWithProviders(component: React.ReactElement) { - await render(component); -} - -const renderWithTheme = async (component: React.ReactElement) => { - await render(component); -}; - -test('uses custom render', async () => { - await renderWithProviders(); - await renderWithTheme(); -}); -``` - -#### Describe blocks (not made async) - -`describe()` blocks are grouping mechanisms and their callbacks are not made async, even if they contain `render` calls in helper functions. However, `test()` callbacks inside `describe` blocks are still made async. - -**Before:** - -```typescript -import { render, screen } from '@testing-library/react-native'; - -describe('MyComponent', () => { - function setupComponent() { - render(); - } - - test('renders component', () => { - setupComponent(); - expect(screen.getByText('Hello')).toBeOnTheScreen(); - }); - - test('renders with direct render call', () => { - render(); - expect(screen.getByText('Hello')).toBeOnTheScreen(); - }); -}); -``` - -**After:** - -```typescript -import { render, screen } from '@testing-library/react-native'; - -describe('MyComponent', () => { - 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(); - }); -}); -``` - -Note: The `describe` callback remains synchronous. The `test` callback that directly calls `render()` is made async, but the `test` callback that only calls a helper function (not in `CUSTOM_RENDER_FUNCTIONS`) remains synchronous. - -## Testing - -Run the test suite: - -```bash -cd codemods/v14-async-functions -yarn test -``` - ## Limitations -1. **Helper functions**: Function calls (`render`, `act`, `renderHook`, `fireEvent`) inside helper functions (not directly in test callbacks) are not transformed by default. You can specify custom render function names via the `--param customRenderFunctions=...` flag or `CUSTOM_RENDER_FUNCTIONS` environment variable to have them automatically transformed. For other helper functions, you'll need to manually update them to be async and await their calls. - -2. **Namespace imports**: The codemod currently doesn't handle namespace imports like `import * as RNTL from '@testing-library/react-native'`. If you use this pattern, you'll need to manually update those calls. - -3. **Semantic analysis**: The codemod uses pattern matching rather than semantic analysis, so it may transform function calls that aren't from RNTL if they match the pattern. Always review the changes. - -4. **fireEvent methods**: Only `fireEvent.press`, `fireEvent.changeText`, and `fireEvent.scroll` are transformed. Other `fireEvent` methods are not automatically transformed. - -## Migration Guide - -1. **Run the codemod** on your test files -2. **If you have custom render functions** (like `renderWithProviders`, `renderWithTheme`, etc.), run the codemod with the `--param` flag: - ```bash - npx codemod@latest workflow run -w ./codemods/v14-async-functions/workflow.yaml --target ./path/to/your/tests --param customRenderFunctions="renderWithProviders,renderWithTheme" - ``` - Or use the environment variable: - ```bash - CUSTOM_RENDER_FUNCTIONS="renderWithProviders,renderWithTheme" npx codemod@latest workflow run -w ./codemods/v14-async-functions/workflow.yaml --target ./path/to/your/tests - ``` -3. **Review the changes** to ensure all transformations are correct -4. **Manually update helper functions** that contain `render`, `act`, `renderHook`, or `fireEvent` calls (if not specified in `CUSTOM_RENDER_FUNCTIONS`) -5. **Manually update other fireEvent methods** if you use methods other than `press`, `changeText`, or `scroll` -6. **Update your RNTL version** to v14 -7. **Run your tests** to verify everything works +- Helper functions are not transformed by default (use `customRenderFunctions` param if needed) +- Namespace imports (`import * as RNTL`) are not handled -## Contributing +## Next steps -If you find issues or have suggestions for improvements, please open an issue or submit a pull request to the React Native Testing Library repository. +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 index 6c3135f34..9fbe1c9fd 100644 --- a/codemods/v14-async-functions/codemod.yaml +++ b/codemods/v14-async-functions/codemod.yaml @@ -1,6 +1,6 @@ schema_version: '1.0' -name: '@testing-library/react-native-v14-async-functions' +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' diff --git a/codemods/v14-update-deps/README.md b/codemods/v14-update-deps/README.md index 014173a2e..b17960317 100644 --- a/codemods/v14-update-deps/README.md +++ b/codemods/v14-update-deps/README.md @@ -1,44 +1,24 @@ # RNTL v14: Update Dependencies -This codemod updates your `package.json` file to prepare for React Native Testing Library v14 migration by: - -- ✅ Removing `@types/react-test-renderer` (no longer needed) -- ✅ Removing `react-test-renderer` (replaced by universal-test-renderer) -- ✅ Moving `@testing-library/react-native` from `dependencies` to `devDependencies` if present -- ✅ Adding `@testing-library/react-native@^14.0.0-alpha.5` to `devDependencies` if not present -- ✅ Updating `@testing-library/react-native` to `^14.0.0-alpha.5` -- ✅ Adding `universal-test-renderer@0.10.1` to `devDependencies` (always added) +This codemod automatically updates your `package.json` to prepare for React Native Testing Library v14 migration. ## What it does -This codemod automatically updates your `package.json` dependencies to match the requirements for RNTL v14. It: - -1. **Removes deprecated packages**: `@types/react-test-renderer` and `react-test-renderer` are removed from all dependency types (dependencies, devDependencies, peerDependencies, optionalDependencies) - -2. **Moves RNTL to devDependencies**: If `@testing-library/react-native` is in `dependencies`, it's moved to `devDependencies` - -3. **Ensures RNTL is present**: If `@testing-library/react-native` is not present, it's added to `devDependencies` with version `^14.0.0-alpha.5` - -4. **Updates RNTL version**: Updates `@testing-library/react-native` to `^14.0.0-alpha.5` - -5. **Adds universal-test-renderer**: Always adds `universal-test-renderer@0.10.1` to `devDependencies` +- 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 -### Running the codemod - ```bash -# Run on your project -npx codemod@latest workflow run -w ./codemods/v14-update-deps/workflow.yaml --target ./path/to/your/project - -# Or if published to the registry -npx codemod@latest run @testing-library/react-native-v14-update-deps --target ./path/to/your/project +# Run the codemod +npx codemod@latest run rntl-v14-update-deps --target ./path/to/your/project ``` -### Example transformations - -#### Before: +## Example +**Before:** ```json { "dependencies": { @@ -51,31 +31,7 @@ npx codemod@latest run @testing-library/react-native-v14-update-deps --target ./ } ``` -#### After: - -```json -{ - "devDependencies": { - "@testing-library/react-native": "^14.0.0-alpha.5", - "universal-test-renderer": "0.10.1" - } -} -``` - -#### Moving from dependencies to devDependencies: - -**Before:** - -```json -{ - "dependencies": { - "@testing-library/react-native": "^13.0.0" - } -} -``` - **After:** - ```json { "devDependencies": { @@ -85,77 +41,22 @@ npx codemod@latest run @testing-library/react-native-v14-update-deps --target ./ } ``` -#### Adding if not present: +## Important notes -**Before:** +- **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. -```json -{ - "devDependencies": { - "some-other-package": "^1.0.0" - } -} -``` - -**After:** - -```json -{ - "devDependencies": { - "some-other-package": "^1.0.0", - "@testing-library/react-native": "^14.0.0-alpha.5", - "universal-test-renderer": "0.10.1" - } -} -``` - -## Important Notes - -1. **After running the codemod**, you'll need to run your package manager to install the new dependencies: +## 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 - npm install - # or - yarn install - # or - pnpm install + npx codemod@latest run rntl-v14-async-functions --target ./path/to/your/tests ``` - -2. **Version resolution**: The codemod sets `@testing-library/react-native` to `^14.0.0-alpha.5`. You can manually update this to a different version if needed. - -3. **Always adds packages**: The codemod always ensures both `@testing-library/react-native` and `universal-test-renderer` are present in `devDependencies`, even if they weren't there before. - -## Testing - -Run the test suite: - -```bash -cd codemods/v14-update-deps -npm test -``` - -## Limitations - -1. **Package manager**: The codemod updates `package.json` but doesn't run the package manager install command. You need to run `npm install` / `yarn install` / `pnpm install` manually after the codemod completes. - -2. **Version pinning**: If you have a specific alpha version pinned, the codemod will update it to `^14.0.0-alpha.5`. You may want to review and adjust the version after running the codemod. - -3. **Workspace projects**: For monorepos with multiple `package.json` files, the codemod will process each one individually. - -## Migration Guide - -1. **Run this codemod** to update your dependencies -2. **Run your package manager** to install the new dependencies: - ```bash - npm install - ``` -3. **Run the render-async codemod** to update your test code: - ```bash - npx codemod@latest run @testing-library/react-native-v14-async-functions --target ./path/to/your/tests - ``` -4. **Review and test** your changes -5. **Update your RNTL version** to a specific alpha version if needed - -## Contributing - -If you find issues or have suggestions for improvements, please open an issue or submit a pull request to the React Native Testing Library repository. +4. Review and test your changes diff --git a/codemods/v14-update-deps/codemod.yaml b/codemods/v14-update-deps/codemod.yaml index c58a05343..62020da51 100644 --- a/codemods/v14-update-deps/codemod.yaml +++ b/codemods/v14-update-deps/codemod.yaml @@ -1,6 +1,6 @@ schema_version: '1.0' -name: '@testing-library/react-native-v14-update-deps' +name: 'rntl-v14-update-deps' version: '0.1.0' description: 'Codemod to update dependencies for RNTL v14 migration' author: 'Maciej Jastrzebski' From 99b14da0c82d2edc71499ca0219eaa07fe27a318 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Tue, 6 Jan 2026 23:05:17 +0100 Subject: [PATCH 26/27] . --- .github/workflows/ci.yml | 13 +++++++++++++ codemods/v14-async-functions/scripts/codemod.ts | 10 ++-------- codemods/v14-update-deps/README.md | 2 ++ 3 files changed, 17 insertions(+), 8 deletions(-) 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/codemods/v14-async-functions/scripts/codemod.ts b/codemods/v14-async-functions/scripts/codemod.ts index c94382db1..49fdb7891 100644 --- a/codemods/v14-async-functions/scripts/codemod.ts +++ b/codemods/v14-async-functions/scripts/codemod.ts @@ -208,9 +208,7 @@ interface CodemodOptions { }; } -function parseCustomRenderFunctionsFromOptions( - options?: CodemodOptions, -): Set { +function parseCustomRenderFunctionsFromOptions(options?: CodemodOptions): Set { const customRenderFunctionsParam = options?.params?.customRenderFunctions ? String(options.params.customRenderFunctions) : ''; @@ -767,8 +765,6 @@ function findResultMethodCalls( return functionCalls; } - - /** * Automatically detects custom render functions by analyzing the code structure. * A custom render function is identified as: @@ -1123,9 +1119,7 @@ function isFunctionAlreadyAsync(func: SgNode): boolean { 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'); + 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'; diff --git a/codemods/v14-update-deps/README.md b/codemods/v14-update-deps/README.md index b17960317..4600a0180 100644 --- a/codemods/v14-update-deps/README.md +++ b/codemods/v14-update-deps/README.md @@ -19,6 +19,7 @@ npx codemod@latest run rntl-v14-update-deps --target ./path/to/your/project ## Example **Before:** + ```json { "dependencies": { @@ -32,6 +33,7 @@ npx codemod@latest run rntl-v14-update-deps --target ./path/to/your/project ``` **After:** + ```json { "devDependencies": { From bb0a7edf12246a471e26f4d96635cef88818852b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Wed, 7 Jan 2026 10:37:58 +0100 Subject: [PATCH 27/27] update AGENTS.md --- AGENTS.md | 6 ++++++ 1 file changed, 6 insertions(+) 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.