diff --git a/.flowconfig b/.flowconfig deleted file mode 100644 index 642af9996..000000000 --- a/.flowconfig +++ /dev/null @@ -1,63 +0,0 @@ -[ignore] -; We fork some components by platform -.*/*[.]android.js -.*/node_modules/.*\.json$ - -; Ignore "BUCK" generated dirs -/\.buckd/ - -; Ignore polyfills -node_modules/react-native/Libraries/polyfills/.* - -; These should not be required directly -; require from fbjs/lib instead: require('fbjs/lib/warning') -node_modules/warning/.* - -; Flow doesn't support platforms -.*/Libraries/Utilities/LoadingView.js - -[untyped] -.*/node_modules/@react-native-community/cli/.*/.* - -[include] -.*/typings/ - -[libs] -node_modules/react-native/interface.js -node_modules/react-native/flow/ -flow-typed/ -.*/typings/index - -[options] -server.max_workers=4 -emoji=true - -module.file_ext=.js -module.file_ext=.json -module.file_ext=.ios.js - -munge_underscores=true - -module.name_mapper='^react-native/\(.*\)$' -> '/node_modules/react-native/\1' -module.name_mapper='^@?[./a-zA-Z0-9$_-]+\.\(bmp\|gif\|jpg\|jpeg\|png\|psd\|svg\|webp\|m4v\|mov\|mp4\|mpeg\|mpg\|webm\|aac\|aiff\|caf\|m4a\|mp3\|wav\|html\|pdf\)$' -> '/node_modules/react-native/Libraries/Image/RelativeImageStub' - -[lints] -sketchy-null-number=warn -sketchy-null-mixed=warn -sketchy-number=warn -untyped-type-import=warn -nonstrict-import=warn -deprecated-type=warn -unsafe-getters-setters=warn -unnecessary-invariant=warn -signature-verification-failure=warn -deprecated-utility=error - -[strict] -deprecated-type -nonstrict-import -sketchy-null -unclear-type -unsafe-getters-setters -untyped-import -untyped-type-import diff --git a/.gemini/settings.json b/.gemini/settings.json new file mode 100644 index 000000000..2facafdd1 --- /dev/null +++ b/.gemini/settings.json @@ -0,0 +1,5 @@ +{ + "context": { + "contextFileName": "AGENTS.md" + } +} diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 366ce7186..1c26ec655 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -34,5 +34,5 @@ labels: 'bug report' diff --git a/.github/actions/setup-deps-rn-nightly/action.yml b/.github/actions/setup-deps-rn-nightly/action.yml index 8ffbed30d..a0bdffe61 100644 --- a/.github/actions/setup-deps-rn-nightly/action.yml +++ b/.github/actions/setup-deps-rn-nightly/action.yml @@ -31,7 +31,5 @@ runs: yarn add -D \ react-native@nightly \ @react-native/babel-preset@nightly \ - react@19.2.3 \ - react-test-renderer@19.2.3 \ - @types/react@^19.2.7 \ + react@19.2.3 shell: bash diff --git a/.github/actions/setup-deps/action.yml b/.github/actions/setup-deps/action.yml index c12ff4d43..062508b0c 100644 --- a/.github/actions/setup-deps/action.yml +++ b/.github/actions/setup-deps/action.yml @@ -1,6 +1,14 @@ name: Setup deps description: Setup Node.js and install dependencies +inputs: + react-version: + description: React version to install (e.g., ^19.2.0) + required: false + react-native-version: + description: React Native version to install (e.g., 0.83.1) + required: false + runs: using: composite steps: @@ -25,3 +33,9 @@ runs: if: steps.yarn-cache.outputs.cache-hit != 'true' run: yarn install --immutable shell: bash + + - name: Switch to React and React Native versions + if: inputs.react-version != '' && inputs.react-native-version != '' + run: | + yarn add -D react@${{ inputs.react-version }} @types/react@${{ inputs.react-version }} react-native@${{ inputs.react-native-version }} @react-native/babel-preset@${{ inputs.react-native-version }} + shell: bash diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 293fc97a4..49fa86982 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,15 +43,137 @@ jobs: - name: Typecheck run: yarn typecheck - typecheck-react-18: + typecheck-rn-0-83-1: runs-on: ubuntu-latest - name: Typecheck React 18 + name: Typecheck RN 0.83.1 steps: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Node.js and deps - uses: ./.github/actions/setup-deps-react-18 + uses: ./.github/actions/setup-deps + with: + react-version: 19.2.0 + react-native-version: 0.83.1 + + - name: Typecheck + run: yarn typecheck + + typecheck-rn-0-82-1: + runs-on: ubuntu-latest + name: Typecheck RN 0.82.1 + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup Node.js and deps + uses: ./.github/actions/setup-deps + with: + react-version: 19.1.1 + react-native-version: 0.82.1 + + - name: Typecheck + run: yarn typecheck + + typecheck-rn-0-81-5: + runs-on: ubuntu-latest + name: Typecheck RN 0.81.5 + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup Node.js and deps + uses: ./.github/actions/setup-deps + with: + react-version: 19.1.0 + react-native-version: 0.81.5 + + - name: Typecheck + run: yarn typecheck + + typecheck-rn-0-80-2: + runs-on: ubuntu-latest + name: Typecheck RN 0.80.2 + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup Node.js and deps + uses: ./.github/actions/setup-deps + with: + react-version: 19.1.0 + react-native-version: 0.80.2 + + - name: Typecheck + run: yarn typecheck + + typecheck-rn-0-79-2: + runs-on: ubuntu-latest + name: Typecheck RN 0.79.2 + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup Node.js and deps + uses: ./.github/actions/setup-deps + with: + react-version: 19.0.0 + react-native-version: 0.79.2 + + - name: Typecheck + run: yarn typecheck + + typecheck-rn-0-78-3: + runs-on: ubuntu-latest + name: Typecheck RN 0.78.3 + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup Node.js and deps + uses: ./.github/actions/setup-deps + with: + react-version: 19.0.0 + react-native-version: 0.78.3 + + - name: Typecheck + run: yarn typecheck + + typecheck-rn-latest: + runs-on: ubuntu-latest + name: Typecheck RN Latest + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup Node.js and deps + uses: ./.github/actions/setup-deps-rn-latest + + - name: Typecheck + run: yarn typecheck + + typecheck-rn-next: + runs-on: ubuntu-latest + name: Typecheck RN Next + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup Node.js and deps + uses: ./.github/actions/setup-deps-rn-next + + - name: Typecheck + run: yarn typecheck + + typecheck-rn-nightly: + runs-on: ubuntu-latest + name: Typecheck RN Nightly + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup Node.js and deps + uses: ./.github/actions/setup-deps-rn-nightly - name: Typecheck run: yarn typecheck @@ -74,15 +196,150 @@ jobs: env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - test-react-18: + test-codemods: + runs-on: ubuntu-latest + name: Test Codemods + 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 + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup Node.js and deps + uses: ./.github/actions/setup-deps + with: + react-version: 19.2.0 + react-native-version: 0.83.1 + + - name: Test + run: yarn test:ci + + test-rn-0-82-1: + runs-on: ubuntu-latest + name: Test RN 0.82.1 + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup Node.js and deps + uses: ./.github/actions/setup-deps + with: + react-version: 19.1.1 + react-native-version: 0.82.1 + + - name: Test + run: yarn test:ci + + test-rn-0-81-5: + runs-on: ubuntu-latest + name: Test RN 0.81.5 + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup Node.js and deps + uses: ./.github/actions/setup-deps + with: + react-version: 19.1.0 + react-native-version: 0.81.5 + + - name: Test + run: yarn test:ci + + test-rn-0-80-2: + runs-on: ubuntu-latest + name: Test RN 0.80.2 + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup Node.js and deps + uses: ./.github/actions/setup-deps + with: + react-version: 19.1.0 + react-native-version: 0.80.2 + + - name: Test + run: yarn test:ci + + test-rn-0-79-2: + runs-on: ubuntu-latest + name: Test RN 0.79.2 + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup Node.js and deps + uses: ./.github/actions/setup-deps + with: + react-version: 19.0.0 + react-native-version: 0.79.2 + + - name: Test + run: yarn test:ci + + test-rn-0-78-3: + runs-on: ubuntu-latest + name: Test RN 0.78.3 + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup Node.js and deps + uses: ./.github/actions/setup-deps + with: + react-version: 19.0.0 + react-native-version: 0.78.3 + + - name: Test + run: yarn test:ci + + test-rn-latest: + runs-on: ubuntu-latest + name: Test RN Latest + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup Node.js and deps + uses: ./.github/actions/setup-deps-rn-latest + + - name: Test + run: yarn test:ci + + test-rn-next: + runs-on: ubuntu-latest + name: Test RN Next + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup Node.js and deps + uses: ./.github/actions/setup-deps-rn-next + + - name: Test + run: yarn test:ci + + test-rn-nightly: runs-on: ubuntu-latest - name: Test React 18 + name: Test RN Nightly steps: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Node.js and deps - uses: ./.github/actions/setup-deps-react-18 + uses: ./.github/actions/setup-deps-rn-nightly - name: Test run: yarn test:ci diff --git a/.prettierignore b/.prettierignore index 26e25e83b..f0073a419 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,4 +1,3 @@ node_modules/ .yarn - -flow-typed/ +codemods/**/tests/fixtures/** diff --git a/.yarnrc.yml b/.yarnrc.yml index b6bff7067..9bbe01613 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -5,10 +5,10 @@ npmMinimalAgeGate: '3d' npmPreapprovedPackages: - react - react-native - - react-test-renderer + - test-renderer + - '@testing-library/react-native' - '@react-native/*' - '@types/react' - - '@types/react-test-renderer' - hermes-compiler yarnPath: .yarn/releases/yarn-4.11.0.cjs diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..52cde746f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,54 @@ +# Code Assistant Context + +This document provides context for the any code assistant to understand the `@testing-library/react-native` project. + +## Project Overview + +`@testing-library/react-native` (RNTL) provides a set of utilities for testing React Native components. It is designed to facilitate writing tests that resemble how users interact with the application, avoiding implementation details. + +- **Core Principle:** "The more your tests resemble the way your software is used, the more confidence they can give you." +- **Tech Stack:** TypeScript, React Native, Jest. +- **Architecture:** The library simulates the React Native runtime on top of `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. + +- **Installation:** `yarn install` +- **Run Tests:** `yarn test` (Runs Jest) +- **Run Tests (CI):** `yarn test:ci` (Runs Jest with worker limits) +- **Lint Code:** `yarn lint` (Runs ESLint on `src`) +- **Type Check:** `yarn typecheck` (Runs TypeScript compiler) +- **Format Check:** `yarn prettier` +- **Validate All:** `yarn validate` (Runs Prettier, ESLint, Typecheck, and Tests in sequence) +- **Build Project:** `yarn build` (Cleans, builds JS with Babel, and builds TS types) + +## Development Conventions + +- **Code Style:** + - **Linting:** ESLint is configured with `@callstack/eslint-config` and `typescript-eslint`. It enforces strict rules, including `no-console` and consistent type imports. + - **Formatting:** Prettier is used for code formatting (single quotes, trailing commas). + - **Imports:** Sorted using `eslint-plugin-simple-import-sort`. + +- **Testing:** + - **Framework:** Jest with `react-native` preset. + - **Location:** Tests are located within `src`, typically co-located in `__tests__` directories. + - **Setup:** `jest-setup.ts` configures the test environment. `src/index.ts` automatically configures cleanup after each test unless skipped. + - **Coverage:** Collected from `src`, excluding tests. + +- **Commits & Releases:** + - **Commits:** Follow the **Conventional Commits** specification (e.g., `fix:`, `feat:`, `chore:`). This is enforced and used for changelog generation. + - **Releases:** Managed via `release-it`. + +- **File Structure:** + - `src/`: Source code. + - `src/pure.ts`: Core logic without side effects (no auto-cleanup). + - `src/index.ts`: Main entry point, re-exports `pure` and adds side effects (auto-cleanup). + - `examples/`: Example React Native applications using the library. + - `website/`: Documentation website. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 9b2bdfde3..000000000 --- a/CLAUDE.md +++ /dev/null @@ -1,137 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -This is the **React Native Testing Library (RNTL)** - a comprehensive testing solution for React Native applications that provides React Native runtime simulation on top of `react-test-renderer`. The library encourages better testing practices by focusing on testing behavior rather than implementation details. - -## Key Development Commands - -### Testing - -- `yarn test` - Run all tests -- `yarn test:ci` - Run tests with CI optimizations (maxWorkers=2) -- `yarn test:ci:coverage` - Run tests with coverage reporting - -### Building - -- `yarn build` - Full build process (clean, build JS, build types, copy flow types) -- `yarn build:js` - Build JavaScript using Babel -- `yarn build:ts` - Build TypeScript declarations -- `yarn clean` - Clean build directory - -### Code Quality - -- `yarn typecheck` - Run TypeScript compiler -- `yarn lint` - Run ESLint with caching -- `yarn validate` - Run lint + typecheck + test (pre-commit validation) - -### Testing Single Files - -To test a specific file: `yarn test path/to/test.test.tsx` - -## Architecture Overview - -### Core Components - -1. **`src/index.ts`** - Main entry point that sets up auto-cleanup and extends Jest matchers -2. **`src/pure.ts`** - Pure exports without auto-cleanup for advanced use cases -3. **`src/render.tsx`** - Core rendering functionality using `react-test-renderer` -4. **`src/screen.ts`** - Global screen object providing access to rendered components - -### Key Modules - -- **`src/queries/`** - Query functions for finding elements (byText, byRole, byTestId, etc.) -- **`src/matchers/`** - Jest matchers for assertions (toBeVisible, toHaveTextContent, etc.) -- **`src/user-event/`** - User interaction simulation (press, type, scroll, etc.) -- **`src/helpers/`** - Utility functions for component tree traversal and debugging -- **`src/fire-event.ts`** - Low-level event firing utilities - -### Query System - -The library provides three query variants for each selector: - -- `get*` - Throws if not found (for assertions) -- `query*` - Returns null if not found (for conditional logic) -- `find*` - Returns Promise, waits for element (for async operations) - -### User Events vs Fire Events - -- **User Events** (`src/user-event/`) - High-level, realistic user interactions -- **Fire Events** (`src/fire-event.ts`) - Low-level, direct event dispatching - -## Configuration - -### Jest Setup - -- Main Jest config: `jest.config.js` -- Setup file: `jest-setup.ts` -- Uses React Native preset with custom transform ignore patterns - -### TypeScript - -- Main config: `tsconfig.json` (development) -- Release config: `tsconfig.release.json` (for builds) -- Strict mode enabled with ES2022 target - -### ESLint - -- Config: `eslint.config.mjs` -- Uses Callstack config + TypeScript strict rules -- Custom rules for import sorting and test files - -## Testing Patterns - -### Component Testing - -```jsx -import { render, screen, userEvent } from '@testing-library/react-native'; - -test('component behavior', async () => { - const user = userEvent.setup(); - render(); - - await user.press(screen.getByRole('button')); - expect(screen.getByText('Expected text')).toBeOnTheScreen(); -}); -``` - -### Async Testing - -Use `findBy*` queries or `waitFor` for async operations: - -```jsx -const element = await screen.findByText('Async content'); -await waitFor(() => expect(mockFn).toHaveBeenCalled()); -``` - -## Development Workflow - -1. **Working with Examples**: Test changes in `examples/` directory -2. **Commit Messages**: Follow conventional commits (feat:, fix:, docs:, test:, chore:, refactor:, BREAKING:) -3. **Pre-commit**: Hooks verify linting, type checking, and tests pass -4. **Pull Requests**: Run `yarn validate` before submitting - -## Build Process - -The build creates: - -- `build/` - Compiled JavaScript and TypeScript declarations -- `matchers.js` - Jest matchers for separate import -- `pure.js` - Pure version without auto-cleanup -- `dont-cleanup-after-each.js` - Version without auto-cleanup - -## Package Structure - -- **Main exports**: Full library with auto-cleanup -- **Pure exports**: Library without auto-cleanup (`/pure`) -- **Matcher exports**: Jest matchers only (`/matchers`) -- **No cleanup**: Disable auto-cleanup (`/dont-cleanup-after-each`) - -## Testing Environment - -- Uses `react-test-renderer` for component rendering -- Fake timers recommended for user events -- String validation available for text rendering checks -- Supports both concurrent and legacy React rendering modes diff --git a/README.md b/README.md index 9a2920e24..26fd0dc15 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ You want to write maintainable tests for your React Native components. As a part ## This solution -The React Native Testing Library (RNTL) is a comprehensive solution for testing React Native components. It provides React Native runtime simulation on top of `react-test-renderer`, in a way that encourages better testing practices. Its primary guiding principle is: +The React Native Testing Library (RNTL) is a comprehensive solution for testing React Native components. It provides React Native runtime simulation on top of `test-renderer`, in a way that encourages better testing practices. Its primary guiding principle is: > The more your tests resemble the way your software is used, the more confidence they can give you. @@ -36,7 +36,7 @@ yarn add --dev @testing-library/react-native npm install --save-dev @testing-library/react-native ``` -This library has a `peerDependencies` listing for `react-test-renderer`. Make sure that your `react-test-renderer` version matches exactly the `react` version, avoid using `^` in version number. +This library has a `peerDependencies` listing for `test-renderer`. Make sure that your `test-renderer` version matches exactly the `react` version, avoid using `^` in version number. ### Additional Jest matchers diff --git a/babel.config.js b/babel.config.js index f2c48ffa8..87c5bf471 100644 --- a/babel.config.js +++ b/babel.config.js @@ -12,7 +12,6 @@ module.exports = { ], '@babel/preset-react', '@babel/preset-typescript', - '@babel/preset-flow', ], plugins: ['@babel/plugin-transform-strict-mode'], env: { diff --git a/codemods/v14-async-functions/.gitignore b/codemods/v14-async-functions/.gitignore new file mode 100644 index 000000000..78174f4c5 --- /dev/null +++ b/codemods/v14-async-functions/.gitignore @@ -0,0 +1,33 @@ +# Dependencies +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Build artifacts +target/ +dist/ +build/ + +# Temporary files +*.tmp +*.temp +.cache/ + +# Environment files +.env +.env.local + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo + +# OS files +.DS_Store +Thumbs.db + +# Package bundles +*.tar.gz +*.tgz \ No newline at end of file diff --git a/codemods/v14-async-functions/README.md b/codemods/v14-async-functions/README.md new file mode 100644 index 000000000..f56eced77 --- /dev/null +++ b/codemods/v14-async-functions/README.md @@ -0,0 +1,58 @@ +# RNTL v14: Make render(), act(), renderHook(), and fireEvent() calls async + +This codemod migrates your test files from React Native Testing Library v13 to v14 by automatically transforming synchronous function calls to async versions and making test functions async when needed. + +## What it does + +- Transforms `render()`, `act()`, `renderHook()`, and `fireEvent()` calls to `await render()`, `await act()`, etc. +- Makes test functions (`test()`, `it()`, hooks) async when needed +- Handles `fireEvent.press()`, `fireEvent.changeText()`, `fireEvent.scroll()`, `screen.rerender()`, `screen.unmount()`, and renderer methods +- Only transforms calls imported from `@testing-library/react-native` + +## Usage + +```bash +# Run the codemod +npx codemod@latest run rntl-v14-async-functions --target ./path/to/your/tests +``` + +### Custom render functions + +If you have custom render helper functions (like `renderWithProviders`, `renderWithTheme`), specify them so they get transformed too: + +```bash +npx codemod@latest run rntl-v14-async-functions --target ./path/to/your/tests --param customRenderFunctions="renderWithProviders,renderWithTheme" +``` + +## Example + +**Before:** + +```typescript +test('renders component', () => { + render(); + expect(screen.getByText('Hello')).toBeOnTheScreen(); +}); +``` + +**After:** + +```typescript +test('renders component', async () => { + await render(); + expect(screen.getByText('Hello')).toBeOnTheScreen(); +}); +``` + +## Limitations + +- Helper functions are not transformed by default (use `customRenderFunctions` param if needed) +- Namespace imports (`import * as RNTL`) are not handled + +## Next steps + +1. Run the codemod on your test files +2. Review the changes +3. Manually update any remaining helper functions if needed +4. Update your RNTL version to v14 (`rntl-v14-update-deps` codemod) +5. Run your tests to verify everything works diff --git a/codemods/v14-async-functions/codemod.yaml b/codemods/v14-async-functions/codemod.yaml new file mode 100644 index 000000000..9fbe1c9fd --- /dev/null +++ b/codemods/v14-async-functions/codemod.yaml @@ -0,0 +1,19 @@ +schema_version: '1.0' + +name: 'rntl-v14-async-functions' +version: '0.1.0' +description: 'Codemod to migrate sync RNTL function and method calls to async for RNTL v14' +author: 'Maciej Jastrzebski' +license: 'MIT' +workflow: 'workflow.yaml' + +targets: + languages: ['typescript', 'tsx', 'javascript', 'jsx'] + +keywords: ['transformation', 'migration'] + +registry: + access: 'public' + visibility: 'public' + +capabilities: [] diff --git a/codemods/v14-async-functions/package.json b/codemods/v14-async-functions/package.json new file mode 100644 index 000000000..0180522ba --- /dev/null +++ b/codemods/v14-async-functions/package.json @@ -0,0 +1,14 @@ +{ + "name": "@testing-library/react-native-v14-async-functions", + "version": "0.1.0", + "description": "Codemod to migrate render() calls to await render() for RNTL v14", + "type": "module", + "scripts": { + "test": "yarn dlx codemod@latest jssg test -l tsx ./scripts/codemod.ts", + "check-types": "tsc --noEmit" + }, + "devDependencies": { + "@codemod.com/jssg-types": "^1.3.0", + "typescript": "^5.8.3" + } +} diff --git a/codemods/v14-async-functions/scripts/codemod.ts b/codemods/v14-async-functions/scripts/codemod.ts new file mode 100644 index 000000000..49fdb7891 --- /dev/null +++ b/codemods/v14-async-functions/scripts/codemod.ts @@ -0,0 +1,1281 @@ +import type { Transform } from 'codemod:ast-grep'; +import type TSX from 'codemod:ast-grep/langs/tsx'; +import type { Edit, SgNode } from '@codemod.com/jssg-types/main'; + +const FUNCTIONS_TO_MAKE_ASYNC = new Set(['render', 'renderHook', 'act', 'fireEvent']); +const FIRE_EVENT_METHODS_TO_MAKE_ASYNC = new Set(['press', 'changeText', 'scroll']); +const SCREEN_METHODS_TO_MAKE_ASYNC = new Set(['rerender', 'unmount']); +const RESULT_METHODS_TO_MAKE_ASYNC = new Set(['rerender', 'unmount']); +const ASYNC_FUNCTIONS_TO_RENAME = new Map([ + ['renderAsync', 'render'], + ['renderHookAsync', 'renderHook'], + ['fireEventAsync', 'fireEvent'], +]); +const TEST_FUNCTION_NAMES = new Set([ + 'test', + 'it', + 'beforeEach', + 'afterEach', + 'beforeAll', + 'afterAll', +]); +const TEST_FUNCTION_PREFIXES = new Set(['test', 'it']); +const TEST_MODIFIERS = new Set(['skip', 'only']); +const TEST_EACH_METHOD = 'each'; + +export default async function transform( + root: Parameters>[0], + options?: Parameters>[1], +): ReturnType> { + const rootNode = root.root(); + const edits: Edit[] = []; + + const customRenderFunctionsSet = parseCustomRenderFunctionsFromOptions(options); + const rntlImports = findRNTLImportStatements(rootNode); + + if (rntlImports.length === 0 && customRenderFunctionsSet.size === 0) { + return null; + } + + const { importedFunctions, specifiersToRemove } = extractImportedFunctionNames( + rntlImports, + edits, + ); + removeDuplicateImportSpecifiers(specifiersToRemove, rootNode, edits); + + let finalCustomRenderFunctionsSet = customRenderFunctionsSet; + if (finalCustomRenderFunctionsSet.size === 0 && importedFunctions.has('render')) { + const autoDetectedCustomRenders = findAutoDetectedCustomRenderFunctions( + rootNode, + importedFunctions, + ); + if (autoDetectedCustomRenders.size > 1) { + finalCustomRenderFunctionsSet = autoDetectedCustomRenders; + } + } + + const importedAsyncVariants = new Set(); + for (const importStmt of rntlImports) { + const importClause = importStmt.find({ + rule: { kind: 'import_clause' }, + }); + if (!importClause) continue; + const namedImports = importClause.find({ + rule: { kind: 'named_imports' }, + }); + if (namedImports) { + const specifiers = namedImports.findAll({ + rule: { kind: 'import_specifier' }, + }); + for (const specifier of specifiers) { + const identifier = specifier.find({ + rule: { kind: 'identifier' }, + }); + if (identifier) { + const funcName = identifier.text(); + if (ASYNC_FUNCTIONS_TO_RENAME.has(funcName)) { + importedAsyncVariants.add(funcName); + } + } + } + } + } + + if (importedFunctions.size === 0 && finalCustomRenderFunctionsSet.size === 0) { + return null; + } + + renameAsyncVariantsInUsages(rootNode, edits); + + const functionCalls: SgNode[] = []; + functionCalls.push(...findDirectFunctionCalls(rootNode, importedFunctions)); + + for (const asyncName of importedAsyncVariants) { + const syncName = ASYNC_FUNCTIONS_TO_RENAME.get(asyncName)!; + const asyncCalls = rootNode.findAll({ + rule: { + kind: 'call_expression', + has: { + field: 'function', + kind: 'identifier', + regex: `^${asyncName}$`, + }, + }, + }); + functionCalls.push(...asyncCalls); + if (!importedFunctions.has(syncName)) { + importedFunctions.add(syncName); + } + } + functionCalls.push(...findFireEventMethodCalls(rootNode, importedFunctions, rntlImports)); + functionCalls.push(...findScreenMethodCalls(rootNode)); + + const { allVariables, renamedMethodVariables } = trackVariablesAssignedFromRenderAndRenderHook( + rootNode, + importedFunctions, + ); + functionCalls.push(...findResultMethodCalls(rootNode, allVariables, renamedMethodVariables)); + + if (functionCalls.length === 0 && finalCustomRenderFunctionsSet.size === 0) { + if (edits.length === 0) { + return null; + } + } + + const functionsToMakeAsync = new Map>(); + const customRenderFunctionsToMakeAsync = new Map>(); + + if (finalCustomRenderFunctionsSet.size > 0 && importedFunctions.size > 0) { + const customRenderFunctionDefinitions = findCustomRenderFunctionDefinitions( + rootNode, + finalCustomRenderFunctionsSet, + ); + for (const funcDef of customRenderFunctionDefinitions) { + transformRNTLCallsInsideCustomRender( + funcDef, + importedFunctions, + edits, + customRenderFunctionsToMakeAsync, + rootNode, + ); + } + } + + for (const functionCall of functionCalls) { + if (isCallAlreadyAwaited(functionCall)) { + continue; + } + + const containingFunction = findContainingTestFunction(functionCall); + if (!containingFunction) { + continue; + } + + if ( + !isFunctionAlreadyAsync(containingFunction) && + !functionsToMakeAsync.has(containingFunction.id()) + ) { + functionsToMakeAsync.set(containingFunction.id(), containingFunction); + } + + addAwaitBeforeCall(functionCall, edits); + } + + if (finalCustomRenderFunctionsSet.size > 0) { + const customRenderCalls = findCustomRenderFunctionCalls( + rootNode, + finalCustomRenderFunctionsSet, + ); + for (const callExpr of customRenderCalls) { + const containingFunction = findContainingTestFunction(callExpr); + if (containingFunction) { + if (isCallAlreadyAwaited(callExpr)) { + continue; + } + + if ( + !isFunctionAlreadyAsync(containingFunction) && + !functionsToMakeAsync.has(containingFunction.id()) + ) { + functionsToMakeAsync.set(containingFunction.id(), containingFunction); + } + + addAwaitBeforeCall(callExpr, edits); + } + } + } + + for (const func of functionsToMakeAsync.values()) { + addAsyncKeywordToFunction(func, edits); + } + + for (const func of customRenderFunctionsToMakeAsync.values()) { + addAsyncKeywordToFunction(func, edits); + } + + if (edits.length === 0) { + return null; + } + + edits.sort((a, b) => b.startPos - a.startPos); + + return rootNode.commitEdits(edits); +} + +interface CodemodOptions { + params?: { + customRenderFunctions?: string | number | boolean; + }; +} + +function parseCustomRenderFunctionsFromOptions(options?: CodemodOptions): Set { + const customRenderFunctionsParam = options?.params?.customRenderFunctions + ? String(options.params.customRenderFunctions) + : ''; + + const customRenderFunctionsSet = new Set(); + if (customRenderFunctionsParam) { + customRenderFunctionsParam + .split(',') + .map((name) => name.trim()) + .filter((name) => name.length > 0) + .forEach((name) => customRenderFunctionsSet.add(name)); + } + return customRenderFunctionsSet; +} + +function findRNTLImportStatements(rootNode: SgNode): SgNode[] { + return rootNode.findAll({ + rule: { + kind: 'import_statement', + has: { + kind: 'string', + regex: '@testing-library/react-native', + }, + }, + }); +} + +function extractImportedFunctionNames( + rntlImports: SgNode[], + edits: Edit[], +): { + importedFunctions: Set; + specifiersToRemove: Array<{ specifier: SgNode; importStmt: SgNode }>; +} { + const importedFunctions = new Set(); + const specifiersToRemove: Array<{ specifier: SgNode; importStmt: SgNode }> = []; + + for (const importStmt of rntlImports) { + const importClause = importStmt.find({ + rule: { kind: 'import_clause' }, + }); + if (!importClause) continue; + + const namedImports = importClause.find({ + rule: { kind: 'named_imports' }, + }); + if (namedImports) { + const specifiers = namedImports.findAll({ + rule: { kind: 'import_specifier' }, + }); + + const importedNames = new Set(); + for (const specifier of specifiers) { + const identifier = specifier.find({ + rule: { kind: 'identifier' }, + }); + if (identifier) { + const funcName = identifier.text(); + importedNames.add(funcName); + } + } + + for (const specifier of specifiers) { + const identifier = specifier.find({ + rule: { kind: 'identifier' }, + }); + if (identifier) { + const funcName = identifier.text(); + if (ASYNC_FUNCTIONS_TO_RENAME.has(funcName)) { + const newName = ASYNC_FUNCTIONS_TO_RENAME.get(funcName)!; + if (importedNames.has(newName)) { + specifiersToRemove.push({ specifier, importStmt }); + importedFunctions.add(newName); + } else { + const identifierRange = identifier.range(); + edits.push({ + startPos: identifierRange.start.index, + endPos: identifierRange.end.index, + insertedText: newName, + }); + importedFunctions.add(newName); + } + } else if (FUNCTIONS_TO_MAKE_ASYNC.has(funcName)) { + importedFunctions.add(funcName); + } + } + } + } + + const defaultImport = importClause.find({ + rule: { kind: 'identifier' }, + }); + if (defaultImport) { + const funcName = defaultImport.text(); + if (FUNCTIONS_TO_MAKE_ASYNC.has(funcName)) { + importedFunctions.add(funcName); + } + } + + const namespaceImport = importClause.find({ + rule: { kind: 'namespace_import' }, + }); + if (namespaceImport) { + FUNCTIONS_TO_MAKE_ASYNC.forEach((func) => importedFunctions.add(func)); + break; + } + } + + return { importedFunctions, specifiersToRemove }; +} + +/** + * Removes duplicate import specifiers from import statements. + * Handles complex comma placement scenarios when removing specifiers: + * - Trailing commas after the specifier + * - Leading commas before the specifier + * - Edge cases with single vs multiple specifiers + * + * @param specifiersToRemove - Array of specifiers to remove with their import statements + * @param rootNode - The root AST node (used to get full text for comma detection) + * @param edits - Array to collect edit operations + */ +function removeDuplicateImportSpecifiers( + specifiersToRemove: Array<{ specifier: SgNode; importStmt: SgNode }>, + rootNode: SgNode, + edits: Edit[], +): void { + specifiersToRemove.sort( + (a, b) => b.specifier.range().start.index - a.specifier.range().start.index, + ); + + for (const { specifier } of specifiersToRemove) { + const specifierRange = specifier.range(); + const parent = specifier.parent(); + + if (parent && parent.is('named_imports')) { + const fullText = rootNode.text(); + const specifierEnd = specifierRange.end.index; + + const textAfter = fullText.substring(specifierEnd); + const trailingCommaMatch = textAfter.match(/^\s*,\s*/); + + if (trailingCommaMatch) { + edits.push({ + startPos: specifierRange.start.index, + endPos: specifierEnd + trailingCommaMatch[0].length, + insertedText: '', + }); + } else { + const textBefore = fullText.substring(0, specifierRange.start.index); + const leadingCommaMatch = textBefore.match(/,\s*$/); + + if (leadingCommaMatch) { + edits.push({ + startPos: specifierRange.start.index - leadingCommaMatch[0].length, + endPos: specifierEnd, + insertedText: '', + }); + } else { + edits.push({ + startPos: specifierRange.start.index, + endPos: specifierEnd, + insertedText: '', + }); + } + } + } + } +} + +function renameAsyncVariantsInUsages(rootNode: SgNode, edits: Edit[]): void { + for (const [asyncName, syncName] of ASYNC_FUNCTIONS_TO_RENAME.entries()) { + const asyncIdentifiers = rootNode.findAll({ + rule: { + kind: 'identifier', + regex: `^${asyncName}$`, + }, + }); + + for (const identifier of asyncIdentifiers) { + const parent = identifier.parent(); + if (parent && parent.is('import_specifier')) { + continue; + } + const identifierRange = identifier.range(); + edits.push({ + startPos: identifierRange.start.index, + endPos: identifierRange.end.index, + insertedText: syncName, + }); + } + + const memberExpressions = rootNode.findAll({ + rule: { + kind: 'member_expression', + has: { + field: 'object', + kind: 'identifier', + regex: `^${asyncName}$`, + }, + }, + }); + + for (const memberExpr of memberExpressions) { + const object = memberExpr.field('object'); + if (object && object.is('identifier')) { + const objectRange = object.range(); + edits.push({ + startPos: objectRange.start.index, + endPos: objectRange.end.index, + insertedText: syncName, + }); + } + } + } +} + +function findDirectFunctionCalls( + rootNode: SgNode, + importedFunctions: Set, +): SgNode[] { + const functionCalls: SgNode[] = []; + + for (const funcName of importedFunctions) { + const calls = rootNode.findAll({ + rule: { + kind: 'call_expression', + has: { + field: 'function', + kind: 'identifier', + regex: `^${funcName}$`, + }, + }, + }); + functionCalls.push(...calls); + } + + return functionCalls; +} + +function findFireEventMethodCalls( + rootNode: SgNode, + importedFunctions: Set, + rntlImports: SgNode[], +): SgNode[] { + const functionCalls: SgNode[] = []; + const fireEventNames = new Set(); + + if (importedFunctions.has('fireEvent')) { + fireEventNames.add('fireEvent'); + } + + for (const [asyncName, syncName] of ASYNC_FUNCTIONS_TO_RENAME.entries()) { + if (syncName === 'fireEvent') { + const wasImported = rntlImports.some((importStmt) => { + const importClause = importStmt.find({ rule: { kind: 'import_clause' } }); + if (!importClause) return false; + const namedImports = importClause.find({ rule: { kind: 'named_imports' } }); + if (!namedImports) return false; + const specifiers = namedImports.findAll({ rule: { kind: 'import_specifier' } }); + return specifiers.some((spec) => { + const identifier = spec.find({ rule: { kind: 'identifier' } }); + return identifier && identifier.text() === asyncName; + }); + }); + if (wasImported) { + fireEventNames.add(asyncName); + } + } + } + + if (fireEventNames.size > 0) { + const fireEventMethodCalls = rootNode.findAll({ + rule: { + kind: 'call_expression', + has: { + field: 'function', + kind: 'member_expression', + }, + }, + }); + + for (const call of fireEventMethodCalls) { + const funcNode = call.field('function'); + if (funcNode && funcNode.is('member_expression')) { + try { + const object = funcNode.field('object'); + const property = funcNode.field('property'); + if (object && property) { + const objText = object.text(); + const propText = property.text(); + if (fireEventNames.has(objText) && FIRE_EVENT_METHODS_TO_MAKE_ASYNC.has(propText)) { + functionCalls.push(call); + } + } + } catch { + // Skip nodes where field() is not available or AST structure doesn't match expectations. + // This is expected for malformed or edge-case AST structures and should be silently ignored. + } + } + } + } + + return functionCalls; +} + +function findScreenMethodCalls(rootNode: SgNode): SgNode[] { + const functionCalls: SgNode[] = []; + const screenMethodCalls = rootNode.findAll({ + rule: { + kind: 'call_expression', + has: { + field: 'function', + kind: 'member_expression', + }, + }, + }); + + for (const call of screenMethodCalls) { + const funcNode = call.field('function'); + if (funcNode && funcNode.is('member_expression')) { + try { + const object = funcNode.field('object'); + const property = funcNode.field('property'); + if (object && property) { + const objText = object.text(); + const propText = property.text(); + if (objText === 'screen' && SCREEN_METHODS_TO_MAKE_ASYNC.has(propText)) { + functionCalls.push(call); + } + } + } catch { + // Skip nodes where field() is not available or AST structure doesn't match expectations. + // This is expected for malformed or edge-case AST structures and should be silently ignored. + } + } + } + + return functionCalls; +} + +/** + * Tracks variables assigned from render() and renderHook() calls to identify result objects. + * This helps identify calls like `renderer.rerender()`, `renderer.unmount()`, `result.rerender()`, etc. + * that need to be made async. + * + * Handles various assignment patterns: + * - Direct assignment: `const renderer = render(...)` or `const result = renderHook(...)` + * - Destructured assignment: `const { rerender } = render(...)` or `const { rerender } = renderHook(...)` + * - Renamed destructuring: `const { rerender: rerenderHook } = renderHook(...)` (renderHook only) + * - Assignment expressions: `renderer = render(...)` or `result = renderHook(...)` + * + * @param rootNode - The root AST node to search within + * @param importedFunctions - Set of imported function names (should include 'render' and/or 'renderHook') + * @returns Object containing: + * - allVariables: Set of all variable names representing render/renderHook results + * - renamedMethodVariables: Set of renamed method variables (e.g., rerenderHook from renderHook) + */ +function trackVariablesAssignedFromRenderAndRenderHook( + rootNode: SgNode, + importedFunctions: Set, +): { + allVariables: Set; + renamedMethodVariables: Set; +} { + const allVariables = new Set(); + const renamedMethodVariables = new Set(); + + // Track variables from both render() and renderHook() calls + const functionsToTrack = ['render', 'renderHook'] as const; + + for (const funcName of functionsToTrack) { + if (!importedFunctions.has(funcName)) { + continue; + } + + const functionCalls = rootNode.findAll({ + rule: { + kind: 'call_expression', + has: { + field: 'function', + kind: 'identifier', + regex: `^${funcName}$`, + }, + }, + }); + + for (const functionCall of functionCalls) { + let parent = functionCall.parent(); + const isAwaited = parent && parent.is('await_expression'); + + if (isAwaited) { + parent = parent.parent(); + } + + if (parent && parent.is('variable_declarator')) { + const objectPattern = parent.find({ + rule: { kind: 'object_pattern' }, + }); + if (objectPattern) { + const shorthandProps = objectPattern.findAll({ + rule: { kind: 'shorthand_property_identifier_pattern' }, + }); + for (const prop of shorthandProps) { + const propName = prop.text(); + if (RESULT_METHODS_TO_MAKE_ASYNC.has(propName)) { + allVariables.add(propName); + } + } + // Handle renamed destructuring (only for renderHook, but we check for both to be safe) + const pairPatterns = objectPattern.findAll({ + rule: { kind: 'pair_pattern' }, + }); + for (const pair of pairPatterns) { + const key = pair.find({ + rule: { kind: 'property_identifier' }, + }); + const value = pair.find({ + rule: { kind: 'identifier' }, + }); + if (key && value) { + const keyName = key.text(); + const valueName = value.text(); + if (RESULT_METHODS_TO_MAKE_ASYNC.has(keyName)) { + allVariables.add(valueName); + renamedMethodVariables.add(valueName); + } + } + } + } else { + const nameNode = parent.find({ + rule: { kind: 'identifier' }, + }); + if (nameNode) { + const varName = nameNode.text(); + allVariables.add(varName); + } + } + } else if (parent && parent.is('assignment_expression')) { + const left = parent.find({ + rule: { kind: 'identifier' }, + }); + if (left) { + const varName = left.text(); + allVariables.add(varName); + } else { + const objectPattern = parent.find({ + rule: { kind: 'object_pattern' }, + }); + if (objectPattern) { + const shorthandProps = objectPattern.findAll({ + rule: { kind: 'shorthand_property_identifier_pattern' }, + }); + for (const prop of shorthandProps) { + const propName = prop.text(); + if (RESULT_METHODS_TO_MAKE_ASYNC.has(propName)) { + allVariables.add(propName); + } + } + // Handle renamed destructuring in assignment expressions + const pairPatterns = objectPattern.findAll({ + rule: { kind: 'pair_pattern' }, + }); + for (const pair of pairPatterns) { + const key = pair.find({ + rule: { kind: 'property_identifier' }, + }); + const value = pair.find({ + rule: { kind: 'identifier' }, + }); + if (key && value) { + const keyName = key.text(); + const valueName = value.text(); + if (RESULT_METHODS_TO_MAKE_ASYNC.has(keyName)) { + allVariables.add(valueName); + renamedMethodVariables.add(valueName); + } + } + } + } + } + } + } + } + + return { allVariables, renamedMethodVariables }; +} + +/** + * Finds method calls on render/renderHook result variables (e.g., renderer.rerender(), result.unmount()). + * Also finds direct calls to renamed method variables (e.g., rerenderHook()). + * + * @param rootNode - The root AST node to search within + * @param allVariables - Set of all variable names from render/renderHook results + * @param renamedMethodVariables - Set of renamed method variables (e.g., rerenderHook) + * @returns Array of function call nodes that need to be made async + */ +function findResultMethodCalls( + rootNode: SgNode, + allVariables: Set, + renamedMethodVariables: Set, +): SgNode[] { + const functionCalls: SgNode[] = []; + + if (allVariables.size > 0) { + const resultMethodCalls = rootNode.findAll({ + rule: { + kind: 'call_expression', + has: { + field: 'function', + kind: 'member_expression', + }, + }, + }); + + for (const call of resultMethodCalls) { + const funcNode = call.field('function'); + if (funcNode && funcNode.is('member_expression')) { + try { + const object = funcNode.field('object'); + const property = funcNode.field('property'); + if (object && property) { + const objText = object.text(); + const propText = property.text(); + if (allVariables.has(objText) && RESULT_METHODS_TO_MAKE_ASYNC.has(propText)) { + functionCalls.push(call); + } + } + } catch { + // Skip nodes where field() is not available or AST structure doesn't match expectations. + // This is expected for malformed or edge-case AST structures and should be silently ignored. + } + } + } + + // Find direct calls to method variables (e.g., rerender(), unmount(), rerenderHook()) + for (const varName of allVariables) { + if (RESULT_METHODS_TO_MAKE_ASYNC.has(varName) || renamedMethodVariables.has(varName)) { + const directCalls = rootNode.findAll({ + rule: { + kind: 'call_expression', + has: { + field: 'function', + kind: 'identifier', + regex: `^${varName}$`, + }, + }, + }); + functionCalls.push(...directCalls); + } + } + } + + return functionCalls; +} + +/** + * Automatically detects custom render functions by analyzing the code structure. + * A custom render function is identified as: + * - A function/const that starts with 'render' (e.g., renderWithProviders, renderWithTheme) + * - Called from within test functions + * - Contains calls to the base render() function from RNTL + * - Defined at the top level (not nested inside other functions) + * + * This helps identify custom render wrappers that should be transformed. + * + * @param rootNode - The root AST node to search within + * @param importedFunctions - Set of imported function names (must include 'render') + * @returns Set of custom render function names that were auto-detected + */ +function findAutoDetectedCustomRenderFunctions( + rootNode: SgNode, + importedFunctions: Set, +): Set { + const customRenderFunctions = new Set(); + + if (!importedFunctions.has('render')) { + return customRenderFunctions; + } + + const allCallExpressions = rootNode.findAll({ + rule: { kind: 'call_expression' }, + }); + + const functionsCalledFromTests = new Set(); + for (const callExpr of allCallExpressions) { + const funcNode = callExpr.field('function'); + if (!funcNode) continue; + + let calledFunctionName: string | null = null; + if (funcNode.is('identifier')) { + calledFunctionName = funcNode.text(); + } else if (funcNode.is('member_expression')) { + continue; + } + + if (calledFunctionName) { + const containingFunction = findContainingTestFunction(callExpr); + if (containingFunction) { + functionsCalledFromTests.add(calledFunctionName); + } + } + } + + const functionDeclarations = rootNode.findAll({ + rule: { kind: 'function_declaration' }, + }); + for (const funcDecl of functionDeclarations) { + const nameNode = funcDecl.find({ + rule: { kind: 'identifier' }, + }); + if (nameNode) { + const funcName = nameNode.text(); + if (funcName.startsWith('render') && functionsCalledFromTests.has(funcName)) { + let parent = funcDecl.parent(); + let isTopLevel = false; + while (parent) { + if (parent.is('program') || parent.is('module')) { + isTopLevel = true; + break; + } + if ( + parent.is('statement_block') || + parent.is('lexical_declaration') || + parent.is('variable_declaration') + ) { + const grandParent = parent.parent(); + if (grandParent && (grandParent.is('program') || grandParent.is('module'))) { + isTopLevel = true; + break; + } + } + parent = parent.parent(); + } + if (isTopLevel) { + const renderCalls = funcDecl.findAll({ + rule: { + kind: 'call_expression', + has: { + field: 'function', + kind: 'identifier', + regex: '^render$', + }, + }, + }); + if (renderCalls.length > 0) { + customRenderFunctions.add(funcName); + } + } + } + } + } + + const variableDeclarations = rootNode.findAll({ + rule: { kind: 'lexical_declaration' }, + }); + for (const varDecl of variableDeclarations) { + const declarators = varDecl.findAll({ + rule: { kind: 'variable_declarator' }, + }); + for (const declarator of declarators) { + const nameNode = declarator.find({ + rule: { kind: 'identifier' }, + }); + if (nameNode) { + const funcName = nameNode.text(); + if (funcName.startsWith('render') && functionsCalledFromTests.has(funcName)) { + let parent = varDecl.parent(); + let isTopLevel = false; + while (parent) { + if (parent.is('program') || parent.is('module')) { + isTopLevel = true; + break; + } + if (parent.is('statement_block')) { + const grandParent = parent.parent(); + if (grandParent && (grandParent.is('program') || grandParent.is('module'))) { + isTopLevel = true; + break; + } + } + parent = parent.parent(); + } + if (isTopLevel) { + const init = declarator.find({ + rule: { + any: [{ kind: 'arrow_function' }, { kind: 'function_expression' }], + }, + }); + if (init) { + const renderCalls = init.findAll({ + rule: { + kind: 'call_expression', + has: { + field: 'function', + kind: 'identifier', + regex: '^render$', + }, + }, + }); + if (renderCalls.length > 0) { + customRenderFunctions.add(funcName); + } + } + } + } + } + } + } + + return customRenderFunctions; +} + +function findCustomRenderFunctionDefinitions( + rootNode: SgNode, + customRenderFunctionsSet: Set, +): SgNode[] { + const customRenderFunctions: SgNode[] = []; + + const functionDeclarations = rootNode.findAll({ + rule: { kind: 'function_declaration' }, + }); + for (const funcDecl of functionDeclarations) { + const nameNode = funcDecl.find({ + rule: { kind: 'identifier' }, + }); + if (nameNode) { + const funcName = nameNode.text(); + if (customRenderFunctionsSet.has(funcName)) { + customRenderFunctions.push(funcDecl); + } + } + } + + const variableDeclarations = rootNode.findAll({ + rule: { kind: 'lexical_declaration' }, + }); + for (const varDecl of variableDeclarations) { + const declarators = varDecl.findAll({ + rule: { kind: 'variable_declarator' }, + }); + for (const declarator of declarators) { + const nameNode = declarator.find({ + rule: { kind: 'identifier' }, + }); + if (nameNode) { + const funcName = nameNode.text(); + if (customRenderFunctionsSet.has(funcName)) { + const init = declarator.find({ + rule: { + any: [{ kind: 'arrow_function' }, { kind: 'function_expression' }], + }, + }); + if (init) { + customRenderFunctions.push(init); + } + } + } + } + } + + return customRenderFunctions; +} + +function findCustomRenderFunctionCalls( + rootNode: SgNode, + customRenderFunctionsSet: Set, +): SgNode[] { + const customRenderCalls: SgNode[] = []; + const allCallExpressions = rootNode.findAll({ + rule: { kind: 'call_expression' }, + }); + + for (const callExpr of allCallExpressions) { + const funcNode = callExpr.field('function'); + if (!funcNode) continue; + + let calledFunctionName: string | null = null; + if (funcNode.is('identifier')) { + calledFunctionName = funcNode.text(); + } else if (funcNode.is('member_expression')) { + continue; + } + + if (calledFunctionName && customRenderFunctionsSet.has(calledFunctionName)) { + customRenderCalls.push(callExpr); + } + } + + return customRenderCalls; +} + +function findRNTLFunctionCallsInNode( + funcNode: SgNode, + importedFunctions: Set, +): SgNode[] { + const rntlCalls: SgNode[] = []; + + for (const funcName of importedFunctions) { + const calls = funcNode.findAll({ + rule: { + kind: 'call_expression', + has: { + field: 'function', + kind: 'identifier', + regex: `^${funcName}$`, + }, + }, + }); + rntlCalls.push(...calls); + } + + if (importedFunctions.has('fireEvent')) { + const fireEventMethodCalls = funcNode.findAll({ + rule: { + kind: 'call_expression', + has: { + field: 'function', + kind: 'member_expression', + }, + }, + }); + + for (const call of fireEventMethodCalls) { + const funcCallNode = call.field('function'); + if (funcCallNode && funcCallNode.is('member_expression')) { + try { + const object = funcCallNode.field('object'); + const property = funcCallNode.field('property'); + if (object && property) { + const objText = object.text(); + const propText = property.text(); + if (objText === 'fireEvent' && FIRE_EVENT_METHODS_TO_MAKE_ASYNC.has(propText)) { + rntlCalls.push(call); + } + } + } catch { + // Skip nodes where field() is not available or AST structure doesn't match expectations. + // This is expected for malformed or edge-case AST structures and should be silently ignored. + } + } + } + } + + return rntlCalls; +} + +function transformRNTLCallsInsideCustomRender( + funcNode: SgNode, + importedFunctions: Set, + edits: Edit[], + customRenderFunctionsToMakeAsync: Map>, + rootNode: SgNode, +): void { + const rntlCalls = findRNTLFunctionCallsInNode(funcNode, importedFunctions); + let needsAsync = false; + + for (const rntlCall of rntlCalls) { + const parent = rntlCall.parent(); + if (parent && parent.is('await_expression')) { + continue; + } + + const callStart = rntlCall.range().start.index; + edits.push({ + startPos: callStart, + endPos: callStart, + insertedText: 'await ', + }); + needsAsync = true; + } + + if (needsAsync && !customRenderFunctionsToMakeAsync.has(funcNode.id())) { + const isAsync = isFunctionAlreadyAsync(funcNode); + if (!isAsync) { + customRenderFunctionsToMakeAsync.set(funcNode.id(), funcNode); + } + } +} + +function isCallAlreadyAwaited(functionCall: SgNode): boolean { + const parent = functionCall.parent(); + return parent !== null && parent.is('await_expression'); +} + +function addAwaitBeforeCall(functionCall: SgNode, edits: Edit[]): void { + const callStart = functionCall.range().start.index; + edits.push({ + startPos: callStart, + endPos: callStart, + insertedText: 'await ', + }); +} + +/** + * Checks if a function is already marked as async using AST-based detection. + * This is more reliable than string matching and handles edge cases better. + */ +function isFunctionAlreadyAsync(func: SgNode): boolean { + if (func.is('arrow_function')) { + // For arrow functions, check if 'async' is a direct child + const children = func.children(); + return children.some((child) => child.text() === 'async'); + } else if (func.is('function_declaration') || func.is('function_expression')) { + // For function declarations/expressions, check for async modifier + // The async keyword appears before the 'function' keyword + const children = func.children(); + const functionKeywordIndex = children.findIndex((child) => child.text() === 'function'); + if (functionKeywordIndex > 0) { + // Check if any child before 'function' is 'async' + return children.slice(0, functionKeywordIndex).some((child) => child.text() === 'async'); + } + // Also check if the first child is 'async' + return children.length > 0 && children[0].text() === 'async'; + } + return false; +} + +function addAsyncKeywordToFunction(func: SgNode, edits: Edit[]): void { + if (func.is('arrow_function')) { + const funcStart = func.range().start.index; + edits.push({ + startPos: funcStart, + endPos: funcStart, + insertedText: 'async ', + }); + } else if (func.is('function_declaration') || func.is('function_expression')) { + const children = func.children(); + const firstChild = children.length > 0 ? children[0] : null; + if (firstChild && firstChild.text() === 'function') { + const funcKeywordStart = firstChild.range().start.index; + edits.push({ + startPos: funcKeywordStart, + endPos: funcKeywordStart, + insertedText: 'async ', + }); + } else { + const funcStart = func.range().start.index; + edits.push({ + startPos: funcStart, + endPos: funcStart, + insertedText: 'async ', + }); + } + } +} + +/** + * Finds the containing test function (test, it, beforeEach, etc.) for a given node. + * Traverses up the AST tree to find the nearest test function that contains the node. + * + * Handles various test patterns: + * - Direct test functions: test(), it() + * - Test modifiers: test.skip(), it.only() + * - Test.each patterns: test.each(), it.each() + * - Hooks: beforeEach(), afterEach(), beforeAll(), afterAll() + * + * @param node - The AST node to find the containing test function for + * @returns The containing test function node, or null if not found + */ +function findContainingTestFunction(node: SgNode): SgNode | null { + let current: SgNode | null = node; + + while (current) { + if ( + current.is('arrow_function') || + current.is('function_declaration') || + current.is('function_expression') + ) { + const parent = current.parent(); + if (parent) { + if (parent.is('arguments')) { + const grandParent = parent.parent(); + if (grandParent && grandParent.is('call_expression')) { + const funcNode = grandParent.field('function'); + if (funcNode) { + const funcText = funcNode.text(); + if (TEST_FUNCTION_NAMES.has(funcText)) { + return current; + } + if (funcNode.is('member_expression')) { + try { + const object = funcNode.field('object'); + const property = funcNode.field('property'); + if (object && property) { + const objText = object.text(); + const propText = property.text(); + if (TEST_FUNCTION_PREFIXES.has(objText) && TEST_MODIFIERS.has(propText)) { + return current; + } + } + } catch { + // Skip nodes where field() is not available or AST structure doesn't match expectations. + // This is expected for malformed or edge-case AST structures and should be silently ignored. + } + } + if (funcNode.is('call_expression')) { + try { + const innerFuncNode = funcNode.field('function'); + if (innerFuncNode && innerFuncNode.is('member_expression')) { + const object = innerFuncNode.field('object'); + const property = innerFuncNode.field('property'); + if (object && property) { + const objText = object.text(); + const propText = property.text(); + if (TEST_FUNCTION_PREFIXES.has(objText) && propText === TEST_EACH_METHOD) { + return current; + } + } + } + } catch { + // Skip nodes where field() is not available or AST structure doesn't match expectations. + // This is expected for malformed or edge-case AST structures and should be silently ignored. + } + } + } + } + } + if (parent.is('call_expression')) { + const funcNode = parent.field('function'); + if (funcNode) { + const funcText = funcNode.text(); + if (TEST_FUNCTION_NAMES.has(funcText)) { + return current; + } + if (funcNode.is('member_expression')) { + try { + const object = funcNode.field('object'); + const property = funcNode.field('property'); + if (object && property) { + const objText = object.text(); + const propText = property.text(); + if (TEST_FUNCTION_PREFIXES.has(objText) && TEST_MODIFIERS.has(propText)) { + return current; + } + } + } catch { + // Skip nodes where field() is not available or AST structure doesn't match expectations. + // This is expected for malformed or edge-case AST structures and should be silently ignored. + } + } + if (funcNode.is('call_expression')) { + try { + const innerFuncNode = funcNode.field('function'); + if (innerFuncNode && innerFuncNode.is('member_expression')) { + const object = innerFuncNode.field('object'); + const property = innerFuncNode.field('property'); + if (object && property) { + const objText = object.text(); + const propText = property.text(); + if (TEST_FUNCTION_PREFIXES.has(objText) && propText === TEST_EACH_METHOD) { + return current; + } + } + } + } catch { + // Skip nodes where field() is not available or AST structure doesn't match expectations. + // This is expected for malformed or edge-case AST structures and should be silently ignored. + } + } + } + } + } + } + + current = current.parent(); + } + + return null; +} diff --git a/codemods/v14-async-functions/tests/fixtures/act-call/expected.tsx b/codemods/v14-async-functions/tests/fixtures/act-call/expected.tsx new file mode 100644 index 000000000..355501a8a --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/act-call/expected.tsx @@ -0,0 +1,8 @@ +import { act } from '@testing-library/react-native'; + +test('uses act', async () => { + await act(() => { + // Some state update + }); + expect(true).toBe(true); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/act-call/input.tsx b/codemods/v14-async-functions/tests/fixtures/act-call/input.tsx new file mode 100644 index 000000000..275dff85b --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/act-call/input.tsx @@ -0,0 +1,8 @@ +import { act } from '@testing-library/react-native'; + +test('uses act', () => { + act(() => { + // Some state update + }); + expect(true).toBe(true); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/aftereach-hook/expected.tsx b/codemods/v14-async-functions/tests/fixtures/aftereach-hook/expected.tsx new file mode 100644 index 000000000..d832cbc65 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/aftereach-hook/expected.tsx @@ -0,0 +1,10 @@ +import { render, cleanup } from '@testing-library/react-native'; + +afterEach(async () => { + cleanup(); + await render(); +}); + +test('test case', () => { + // Test code +}); diff --git a/codemods/v14-async-functions/tests/fixtures/aftereach-hook/input.tsx b/codemods/v14-async-functions/tests/fixtures/aftereach-hook/input.tsx new file mode 100644 index 000000000..2e372b078 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/aftereach-hook/input.tsx @@ -0,0 +1,10 @@ +import { render, cleanup } from '@testing-library/react-native'; + +afterEach(() => { + cleanup(); + render(); +}); + +test('test case', () => { + // Test code +}); diff --git a/codemods/v14-async-functions/tests/fixtures/already-async/expected.tsx b/codemods/v14-async-functions/tests/fixtures/already-async/expected.tsx new file mode 100644 index 000000000..c2caa6bef --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/already-async/expected.tsx @@ -0,0 +1,6 @@ +import { render, screen } from '@testing-library/react-native'; + +test('renders component', async () => { + await render(); + expect(screen.getByText('Hello')).toBeOnTheScreen(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/already-async/input.tsx b/codemods/v14-async-functions/tests/fixtures/already-async/input.tsx new file mode 100644 index 000000000..d1ac82bc2 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/already-async/input.tsx @@ -0,0 +1,6 @@ +import { render, screen } from '@testing-library/react-native'; + +test('renders component', async () => { + render(); + expect(screen.getByText('Hello')).toBeOnTheScreen(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/already-awaited/expected.tsx b/codemods/v14-async-functions/tests/fixtures/already-awaited/expected.tsx new file mode 100644 index 000000000..7aae57ba0 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/already-awaited/expected.tsx @@ -0,0 +1,5 @@ +import { render } from '@testing-library/react-native'; + +test('already awaited', async () => { + await render(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/already-awaited/input.tsx b/codemods/v14-async-functions/tests/fixtures/already-awaited/input.tsx new file mode 100644 index 000000000..7aae57ba0 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/already-awaited/input.tsx @@ -0,0 +1,5 @@ +import { render } from '@testing-library/react-native'; + +test('already awaited', async () => { + await render(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/async-variants-rename/expected.tsx b/codemods/v14-async-functions/tests/fixtures/async-variants-rename/expected.tsx new file mode 100644 index 000000000..3e3ef8656 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/async-variants-rename/expected.tsx @@ -0,0 +1,7 @@ +import { render, renderHook, fireEvent } from '@testing-library/react-native'; + +test('uses async variants', async () => { + const component = await render(); + const { result } = await renderHook(() => useMyHook()); + await fireEvent.press(component.getByText('Button')); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/async-variants-rename/input.tsx b/codemods/v14-async-functions/tests/fixtures/async-variants-rename/input.tsx new file mode 100644 index 000000000..42185672b --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/async-variants-rename/input.tsx @@ -0,0 +1,7 @@ +import { renderAsync, renderHookAsync, fireEventAsync } from '@testing-library/react-native'; + +test('uses async variants', async () => { + const component = await renderAsync(); + const { result } = await renderHookAsync(() => useMyHook()); + await fireEventAsync.press(component.getByText('Button')); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/basic-sync-test/expected.tsx b/codemods/v14-async-functions/tests/fixtures/basic-sync-test/expected.tsx new file mode 100644 index 000000000..c2caa6bef --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/basic-sync-test/expected.tsx @@ -0,0 +1,6 @@ +import { render, screen } from '@testing-library/react-native'; + +test('renders component', async () => { + await render(); + expect(screen.getByText('Hello')).toBeOnTheScreen(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/basic-sync-test/input.tsx b/codemods/v14-async-functions/tests/fixtures/basic-sync-test/input.tsx new file mode 100644 index 000000000..3e884dcfd --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/basic-sync-test/input.tsx @@ -0,0 +1,6 @@ +import { render, screen } from '@testing-library/react-native'; + +test('renders component', () => { + render(); + expect(screen.getByText('Hello')).toBeOnTheScreen(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/beforeeach-hook/expected.tsx b/codemods/v14-async-functions/tests/fixtures/beforeeach-hook/expected.tsx new file mode 100644 index 000000000..4bd6d0405 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/beforeeach-hook/expected.tsx @@ -0,0 +1,9 @@ +import { render } from '@testing-library/react-native'; + +beforeEach(async () => { + await render(); +}); + +test('test case', () => { + // Test code +}); diff --git a/codemods/v14-async-functions/tests/fixtures/beforeeach-hook/input.tsx b/codemods/v14-async-functions/tests/fixtures/beforeeach-hook/input.tsx new file mode 100644 index 000000000..53b4e467f --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/beforeeach-hook/input.tsx @@ -0,0 +1,9 @@ +import { render } from '@testing-library/react-native'; + +beforeEach(() => { + render(); +}); + +test('test case', () => { + // Test code +}); diff --git a/codemods/v14-async-functions/tests/fixtures/both-renderhook-renderer/expected.tsx b/codemods/v14-async-functions/tests/fixtures/both-renderhook-renderer/expected.tsx new file mode 100644 index 000000000..675e36472 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/both-renderhook-renderer/expected.tsx @@ -0,0 +1,9 @@ +import { render, renderHook } from '@testing-library/react-native'; + +test('uses both render and renderHook with rerender', async () => { + const renderer = await render(); + const { rerender: rerenderHook } = await renderHook(() => ({ value: 42 })); + + await renderer.rerender(); + await rerenderHook({ value: 43 }); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/both-renderhook-renderer/input.tsx b/codemods/v14-async-functions/tests/fixtures/both-renderhook-renderer/input.tsx new file mode 100644 index 000000000..f73f47017 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/both-renderhook-renderer/input.tsx @@ -0,0 +1,9 @@ +import { render, renderHook } from '@testing-library/react-native'; + +test('uses both render and renderHook with rerender', () => { + const renderer = render(); + const { rerender: rerenderHook } = renderHook(() => ({ value: 42 })); + + renderer.rerender(); + rerenderHook({ value: 43 }); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/combined-functions/expected.tsx b/codemods/v14-async-functions/tests/fixtures/combined-functions/expected.tsx new file mode 100644 index 000000000..3d06284e4 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/combined-functions/expected.tsx @@ -0,0 +1,16 @@ +import { render, act, renderHook, screen } from '@testing-library/react-native'; + +test('uses all three functions', async () => { + await render(); + + await act(() => { + // Some state update + }); + + const { result } = await renderHook(() => { + return { value: 42 }; + }); + + expect(screen.getByText('Hello')).toBeOnTheScreen(); + expect(result.current.value).toBe(42); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/combined-functions/input.tsx b/codemods/v14-async-functions/tests/fixtures/combined-functions/input.tsx new file mode 100644 index 000000000..f0c9e8336 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/combined-functions/input.tsx @@ -0,0 +1,16 @@ +import { render, act, renderHook, screen } from '@testing-library/react-native'; + +test('uses all three functions', () => { + render(); + + act(() => { + // Some state update + }); + + const { result } = renderHook(() => { + return { value: 42 }; + }); + + expect(screen.getByText('Hello')).toBeOnTheScreen(); + expect(result.current.value).toBe(42); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/custom-render-function/expected.tsx b/codemods/v14-async-functions/tests/fixtures/custom-render-function/expected.tsx new file mode 100644 index 000000000..65a8b5ba4 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/custom-render-function/expected.tsx @@ -0,0 +1,28 @@ +import { render } from '@testing-library/react-native'; + +// Function declaration +async function renderWithProviders(component: React.ReactElement) { + await render(component); +} + +// Arrow function +const renderWithTheme = async (component: React.ReactElement) => { + await render(component); +}; + +// Function expression +const renderCustom = async function (component: React.ReactElement) { + await render(component); +}; + +test('uses custom render function declaration', async () => { + await renderWithProviders(); +}); + +test('uses custom render arrow function', async () => { + await renderWithTheme(); +}); + +test('uses custom render function expression', async () => { + await renderCustom(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/custom-render-function/input.tsx b/codemods/v14-async-functions/tests/fixtures/custom-render-function/input.tsx new file mode 100644 index 000000000..f06786edc --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/custom-render-function/input.tsx @@ -0,0 +1,28 @@ +import { render } from '@testing-library/react-native'; + +// Function declaration +function renderWithProviders(component: React.ReactElement) { + render(component); +} + +// Arrow function +const renderWithTheme = (component: React.ReactElement) => { + render(component); +}; + +// Function expression +const renderCustom = function (component: React.ReactElement) { + render(component); +}; + +test('uses custom render function declaration', () => { + renderWithProviders(); +}); + +test('uses custom render arrow function', () => { + renderWithTheme(); +}); + +test('uses custom render function expression', () => { + renderCustom(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/describe-block/expected.tsx b/codemods/v14-async-functions/tests/fixtures/describe-block/expected.tsx new file mode 100644 index 000000000..bfd1cad6e --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/describe-block/expected.tsx @@ -0,0 +1,18 @@ +import { render, screen } from '@testing-library/react-native'; + +describe('MyComponent', () => { + // Helper function inside describe block + function setupComponent() { + render(); + } + + test('renders component', () => { + setupComponent(); + expect(screen.getByText('Hello')).toBeOnTheScreen(); + }); + + test('renders with direct render call', async () => { + await render(); + expect(screen.getByText('Hello')).toBeOnTheScreen(); + }); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/describe-block/input.tsx b/codemods/v14-async-functions/tests/fixtures/describe-block/input.tsx new file mode 100644 index 000000000..1927278cd --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/describe-block/input.tsx @@ -0,0 +1,18 @@ +import { render, screen } from '@testing-library/react-native'; + +describe('MyComponent', () => { + // Helper function inside describe block + function setupComponent() { + render(); + } + + test('renders component', () => { + setupComponent(); + expect(screen.getByText('Hello')).toBeOnTheScreen(); + }); + + test('renders with direct render call', () => { + render(); + expect(screen.getByText('Hello')).toBeOnTheScreen(); + }); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/duplicate-imports/expected.tsx b/codemods/v14-async-functions/tests/fixtures/duplicate-imports/expected.tsx new file mode 100644 index 000000000..6279bef29 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/duplicate-imports/expected.tsx @@ -0,0 +1,21 @@ +import { + render, + renderHook, + fireEvent, + waitFor, +} from '@testing-library/react-native'; + +test('uses both sync and async variants', async () => { + const component1 = await render(); + const component2 = await render(); + + const { result: result1 } = await renderHook(() => useMyHook()); + const { result: result2 } = await renderHook(() => useMyHook()); + + await fireEvent.press(component1.getByText('Button')); + await fireEvent.press(component2.getByText('Button')); + + await waitFor(() => { + expect(result1.current.value).toBe(42); + }); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/duplicate-imports/input.tsx b/codemods/v14-async-functions/tests/fixtures/duplicate-imports/input.tsx new file mode 100644 index 000000000..1d2ac69e8 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/duplicate-imports/input.tsx @@ -0,0 +1,24 @@ +import { + render, + renderAsync, + renderHook, + renderHookAsync, + fireEvent, + fireEventAsync, + waitFor, +} from '@testing-library/react-native'; + +test('uses both sync and async variants', async () => { + const component1 = await renderAsync(); + const component2 = await render(); + + const { result: result1 } = await renderHookAsync(() => useMyHook()); + const { result: result2 } = await renderHook(() => useMyHook()); + + await fireEventAsync.press(component1.getByText('Button')); + await fireEvent.press(component2.getByText('Button')); + + await waitFor(() => { + expect(result1.current.value).toBe(42); + }); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/fireevent-call/expected.tsx b/codemods/v14-async-functions/tests/fixtures/fireevent-call/expected.tsx new file mode 100644 index 000000000..5547a8fa9 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/fireevent-call/expected.tsx @@ -0,0 +1,8 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; + +test('uses fireEvent', async () => { + await render(); + const button = screen.getByRole('button'); + await fireEvent(button, 'press'); + expect(screen.getByText('Clicked')).toBeOnTheScreen(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/fireevent-call/input.tsx b/codemods/v14-async-functions/tests/fixtures/fireevent-call/input.tsx new file mode 100644 index 000000000..601621ba7 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/fireevent-call/input.tsx @@ -0,0 +1,8 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; + +test('uses fireEvent', () => { + render(); + const button = screen.getByRole('button'); + fireEvent(button, 'press'); + expect(screen.getByText('Clicked')).toBeOnTheScreen(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/fireevent-methods/expected.tsx b/codemods/v14-async-functions/tests/fixtures/fireevent-methods/expected.tsx new file mode 100644 index 000000000..fcac36ec7 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/fireevent-methods/expected.tsx @@ -0,0 +1,14 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; + +test('uses fireEvent methods', async () => { + await render(); + const input = screen.getByPlaceholderText('Enter text'); + const button = screen.getByRole('button'); + const scrollView = screen.getByTestId('scroll-view'); + + await fireEvent.press(button); + await fireEvent.changeText(input, 'Hello'); + await fireEvent.scroll(scrollView, { nativeEvent: { contentOffset: { y: 100 } } }); + + expect(screen.getByText('Hello')).toBeOnTheScreen(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/fireevent-methods/input.tsx b/codemods/v14-async-functions/tests/fixtures/fireevent-methods/input.tsx new file mode 100644 index 000000000..6f9307ad1 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/fireevent-methods/input.tsx @@ -0,0 +1,14 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; + +test('uses fireEvent methods', () => { + render(); + const input = screen.getByPlaceholderText('Enter text'); + const button = screen.getByRole('button'); + const scrollView = screen.getByTestId('scroll-view'); + + fireEvent.press(button); + fireEvent.changeText(input, 'Hello'); + fireEvent.scroll(scrollView, { nativeEvent: { contentOffset: { y: 100 } } }); + + expect(screen.getByText('Hello')).toBeOnTheScreen(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/function-declaration/expected.tsx b/codemods/v14-async-functions/tests/fixtures/function-declaration/expected.tsx new file mode 100644 index 000000000..cbef18280 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/function-declaration/expected.tsx @@ -0,0 +1,5 @@ +import { render } from '@testing-library/react-native'; + +test('function declaration', async function () { + await render(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/function-declaration/input.tsx b/codemods/v14-async-functions/tests/fixtures/function-declaration/input.tsx new file mode 100644 index 000000000..9e0fc2ac6 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/function-declaration/input.tsx @@ -0,0 +1,5 @@ +import { render } from '@testing-library/react-native'; + +test('function declaration', function () { + render(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/helper-function/expected.tsx b/codemods/v14-async-functions/tests/fixtures/helper-function/expected.tsx new file mode 100644 index 000000000..a50e1dd69 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/helper-function/expected.tsx @@ -0,0 +1,9 @@ +import { render } from '@testing-library/react-native'; + +function renderWithProviders(component: React.ReactElement) { + render(component); +} + +test('uses helper', () => { + renderWithProviders(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/helper-function/input.tsx b/codemods/v14-async-functions/tests/fixtures/helper-function/input.tsx new file mode 100644 index 000000000..a50e1dd69 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/helper-function/input.tsx @@ -0,0 +1,9 @@ +import { render } from '@testing-library/react-native'; + +function renderWithProviders(component: React.ReactElement) { + render(component); +} + +test('uses helper', () => { + renderWithProviders(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/it-instead-of-test/expected.tsx b/codemods/v14-async-functions/tests/fixtures/it-instead-of-test/expected.tsx new file mode 100644 index 000000000..038609e80 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/it-instead-of-test/expected.tsx @@ -0,0 +1,5 @@ +import { render } from '@testing-library/react-native'; + +it('should render', async () => { + await render(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/it-instead-of-test/input.tsx b/codemods/v14-async-functions/tests/fixtures/it-instead-of-test/input.tsx new file mode 100644 index 000000000..4ada3736f --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/it-instead-of-test/input.tsx @@ -0,0 +1,5 @@ +import { render } from '@testing-library/react-native'; + +it('should render', () => { + render(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/multiple-renders/expected.tsx b/codemods/v14-async-functions/tests/fixtures/multiple-renders/expected.tsx new file mode 100644 index 000000000..22a8d029c --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/multiple-renders/expected.tsx @@ -0,0 +1,6 @@ +import { render } from '@testing-library/react-native'; + +test('renders multiple', async () => { + await render(); + await render(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/multiple-renders/input.tsx b/codemods/v14-async-functions/tests/fixtures/multiple-renders/input.tsx new file mode 100644 index 000000000..68cf577fb --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/multiple-renders/input.tsx @@ -0,0 +1,6 @@ +import { render } from '@testing-library/react-native'; + +test('renders multiple', () => { + render(); + render(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/no-rntl-import/expected.tsx b/codemods/v14-async-functions/tests/fixtures/no-rntl-import/expected.tsx new file mode 100644 index 000000000..4807101a0 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/no-rntl-import/expected.tsx @@ -0,0 +1,5 @@ +import { render } from 'some-other-library'; + +test('should not transform', () => { + render(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/no-rntl-import/input.tsx b/codemods/v14-async-functions/tests/fixtures/no-rntl-import/input.tsx new file mode 100644 index 000000000..4807101a0 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/no-rntl-import/input.tsx @@ -0,0 +1,5 @@ +import { render } from 'some-other-library'; + +test('should not transform', () => { + render(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/render-with-options/expected.tsx b/codemods/v14-async-functions/tests/fixtures/render-with-options/expected.tsx new file mode 100644 index 000000000..54d3c48e9 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/render-with-options/expected.tsx @@ -0,0 +1,5 @@ +import { render } from '@testing-library/react-native'; + +test('renders with wrapper', async () => { + await render(, { wrapper: Wrapper }); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/render-with-options/input.tsx b/codemods/v14-async-functions/tests/fixtures/render-with-options/input.tsx new file mode 100644 index 000000000..8272061c6 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/render-with-options/input.tsx @@ -0,0 +1,5 @@ +import { render } from '@testing-library/react-native'; + +test('renders with wrapper', () => { + render(, { wrapper: Wrapper }); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/renderer-rerender/expected.tsx b/codemods/v14-async-functions/tests/fixtures/renderer-rerender/expected.tsx new file mode 100644 index 000000000..c99cadc4a --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/renderer-rerender/expected.tsx @@ -0,0 +1,7 @@ +import { render } from '@testing-library/react-native'; + +test('rerenders with renderer', async () => { + const renderer = await render(); + await renderer.rerender(); + expect(renderer.getByText('Updated')).toBeOnTheScreen(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/renderer-rerender/input.tsx b/codemods/v14-async-functions/tests/fixtures/renderer-rerender/input.tsx new file mode 100644 index 000000000..146134dd8 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/renderer-rerender/input.tsx @@ -0,0 +1,7 @@ +import { render } from '@testing-library/react-native'; + +test('rerenders with renderer', () => { + const renderer = render(); + renderer.rerender(); + expect(renderer.getByText('Updated')).toBeOnTheScreen(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/renderer-unmount/expected.tsx b/codemods/v14-async-functions/tests/fixtures/renderer-unmount/expected.tsx new file mode 100644 index 000000000..471c2a473 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/renderer-unmount/expected.tsx @@ -0,0 +1,7 @@ +import { render } from '@testing-library/react-native'; + +test('unmounts with renderer', async () => { + const { rerender, unmount } = await render(); + await rerender(); + await unmount(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/renderer-unmount/input.tsx b/codemods/v14-async-functions/tests/fixtures/renderer-unmount/input.tsx new file mode 100644 index 000000000..e9d8e3691 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/renderer-unmount/input.tsx @@ -0,0 +1,7 @@ +import { render } from '@testing-library/react-native'; + +test('unmounts with renderer', () => { + const { rerender, unmount } = render(); + rerender(); + unmount(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/renderhook-call/expected.tsx b/codemods/v14-async-functions/tests/fixtures/renderhook-call/expected.tsx new file mode 100644 index 000000000..4146596eb --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/renderhook-call/expected.tsx @@ -0,0 +1,8 @@ +import { renderHook } from '@testing-library/react-native'; + +test('uses renderHook', async () => { + const { result } = await renderHook(() => { + return { value: 42 }; + }); + expect(result.current.value).toBe(42); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/renderhook-call/input.tsx b/codemods/v14-async-functions/tests/fixtures/renderhook-call/input.tsx new file mode 100644 index 000000000..04b2c9f38 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/renderhook-call/input.tsx @@ -0,0 +1,8 @@ +import { renderHook } from '@testing-library/react-native'; + +test('uses renderHook', () => { + const { result } = renderHook(() => { + return { value: 42 }; + }); + expect(result.current.value).toBe(42); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/renderhook-destructured/expected.tsx b/codemods/v14-async-functions/tests/fixtures/renderhook-destructured/expected.tsx new file mode 100644 index 000000000..1b58a2268 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/renderhook-destructured/expected.tsx @@ -0,0 +1,8 @@ +import { renderHook } from '@testing-library/react-native'; + +test('uses destructured rerender and unmount from renderHook', async () => { + const { rerender, unmount, result } = await renderHook(() => ({ value: 42 })); + await rerender({ value: 43 }); + expect(result.current.value).toBe(43); + await unmount(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/renderhook-destructured/input.tsx b/codemods/v14-async-functions/tests/fixtures/renderhook-destructured/input.tsx new file mode 100644 index 000000000..f7f252c5b --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/renderhook-destructured/input.tsx @@ -0,0 +1,8 @@ +import { renderHook } from '@testing-library/react-native'; + +test('uses destructured rerender and unmount from renderHook', () => { + const { rerender, unmount, result } = renderHook(() => ({ value: 42 })); + rerender({ value: 43 }); + expect(result.current.value).toBe(43); + unmount(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/renderhook-rerender/expected.tsx b/codemods/v14-async-functions/tests/fixtures/renderhook-rerender/expected.tsx new file mode 100644 index 000000000..5c5fcbc04 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/renderhook-rerender/expected.tsx @@ -0,0 +1,7 @@ +import { renderHook } from '@testing-library/react-native'; + +test('rerenders with renderHook result', async () => { + const hookResult = await renderHook(() => ({ value: 42 })); + await hookResult.rerender({ value: 43 }); + expect(hookResult.result.current.value).toBe(43); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/renderhook-rerender/input.tsx b/codemods/v14-async-functions/tests/fixtures/renderhook-rerender/input.tsx new file mode 100644 index 000000000..dc383069c --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/renderhook-rerender/input.tsx @@ -0,0 +1,7 @@ +import { renderHook } from '@testing-library/react-native'; + +test('rerenders with renderHook result', () => { + const hookResult = renderHook(() => ({ value: 42 })); + hookResult.rerender({ value: 43 }); + expect(hookResult.result.current.value).toBe(43); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/renderhook-unmount/expected.tsx b/codemods/v14-async-functions/tests/fixtures/renderhook-unmount/expected.tsx new file mode 100644 index 000000000..2b74a6e6b --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/renderhook-unmount/expected.tsx @@ -0,0 +1,6 @@ +import { renderHook } from '@testing-library/react-native'; + +test('unmounts with renderHook result', async () => { + const hookResult = await renderHook(() => ({ value: 42 })); + await hookResult.unmount(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/renderhook-unmount/input.tsx b/codemods/v14-async-functions/tests/fixtures/renderhook-unmount/input.tsx new file mode 100644 index 000000000..3c7f8fd34 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/renderhook-unmount/input.tsx @@ -0,0 +1,6 @@ +import { renderHook } from '@testing-library/react-native'; + +test('unmounts with renderHook result', () => { + const hookResult = renderHook(() => ({ value: 42 })); + hookResult.unmount(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/screen-rerender/expected.tsx b/codemods/v14-async-functions/tests/fixtures/screen-rerender/expected.tsx new file mode 100644 index 000000000..223fce531 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/screen-rerender/expected.tsx @@ -0,0 +1,7 @@ +import { render, screen } from '@testing-library/react-native'; + +test('rerenders component', async () => { + await render(); + await screen.rerender(); + expect(screen.getByText('Updated')).toBeOnTheScreen(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/screen-rerender/input.tsx b/codemods/v14-async-functions/tests/fixtures/screen-rerender/input.tsx new file mode 100644 index 000000000..d481d9d11 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/screen-rerender/input.tsx @@ -0,0 +1,7 @@ +import { render, screen } from '@testing-library/react-native'; + +test('rerenders component', () => { + render(); + screen.rerender(); + expect(screen.getByText('Updated')).toBeOnTheScreen(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/screen-unmount/expected.tsx b/codemods/v14-async-functions/tests/fixtures/screen-unmount/expected.tsx new file mode 100644 index 000000000..1c27fd41a --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/screen-unmount/expected.tsx @@ -0,0 +1,7 @@ +import { render, screen } from '@testing-library/react-native'; + +test('unmounts component', async () => { + await render(); + await screen.unmount(); + expect(screen.queryByText('Hello')).not.toBeOnTheScreen(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/screen-unmount/input.tsx b/codemods/v14-async-functions/tests/fixtures/screen-unmount/input.tsx new file mode 100644 index 000000000..2114aa444 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/screen-unmount/input.tsx @@ -0,0 +1,7 @@ +import { render, screen } from '@testing-library/react-native'; + +test('unmounts component', () => { + render(); + screen.unmount(); + expect(screen.queryByText('Hello')).not.toBeOnTheScreen(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/skip-variants/expected.tsx b/codemods/v14-async-functions/tests/fixtures/skip-variants/expected.tsx new file mode 100644 index 000000000..d5f15efa2 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/skip-variants/expected.tsx @@ -0,0 +1,25 @@ +import { + render, + act, + renderHook, + unsafe_act, + unsafe_renderHookSync, + } from '@testing-library/react-native'; + +test('skips unsafe variants', async () => { + await render(); + + await act(() => { + // Should be transformed + }); + + unsafe_act(() => { + // Should NOT be transformed + }); + + const { result } = await renderHook(() => ({ value: 42 })); + + unsafe_renderHookSync(() => ({ value: 43 })); + + await render(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/skip-variants/input.tsx b/codemods/v14-async-functions/tests/fixtures/skip-variants/input.tsx new file mode 100644 index 000000000..d44c68977 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/skip-variants/input.tsx @@ -0,0 +1,26 @@ +import { + render, + act, + renderHook, + unsafe_act, + unsafe_renderHookSync, + renderAsync, +} from '@testing-library/react-native'; + +test('skips unsafe variants', async () => { + render(); + + act(() => { + // Should be transformed + }); + + unsafe_act(() => { + // Should NOT be transformed + }); + + const { result } = renderHook(() => ({ value: 42 })); + + unsafe_renderHookSync(() => ({ value: 43 })); + + await renderAsync(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/test-each-combined/expected.tsx b/codemods/v14-async-functions/tests/fixtures/test-each-combined/expected.tsx new file mode 100644 index 000000000..9f570c288 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/test-each-combined/expected.tsx @@ -0,0 +1,12 @@ +import { render, renderHook, act } from '@testing-library/react-native'; + +test.each([{ value: 1 }, { value: 2 }])('renders component with value $value', async ({ value }) => { + await render(); +}); + +it.each([[true], [false]])('renders hook with flag %p', async (flag) => { + const { result } = await renderHook(() => ({ flag })); + await act(() => { + // update + }); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/test-each-combined/input.tsx b/codemods/v14-async-functions/tests/fixtures/test-each-combined/input.tsx new file mode 100644 index 000000000..ae66107df --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/test-each-combined/input.tsx @@ -0,0 +1,12 @@ +import { render, renderHook, act } from '@testing-library/react-native'; + +test.each([{ value: 1 }, { value: 2 }])('renders component with value $value', ({ value }) => { + render(); +}); + +it.each([[true], [false]])('renders hook with flag %p', async (flag) => { + const { result } = renderHook(() => ({ flag })); + act(() => { + // update + }); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/test-each/expected.tsx b/codemods/v14-async-functions/tests/fixtures/test-each/expected.tsx new file mode 100644 index 000000000..1f57c8d4b --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/test-each/expected.tsx @@ -0,0 +1,12 @@ +import { render } from '@testing-library/react-native'; + +test.each([{ name: 'Alice' }, { name: 'Bob' }])('renders for $name', async ({ name }) => { + await render(); +}); + +it.each([ + [1, 2, 3], + [4, 5, 9], +])('adds %i and %i to get %i', async (a, b, expected) => { + await render(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/test-each/input.tsx b/codemods/v14-async-functions/tests/fixtures/test-each/input.tsx new file mode 100644 index 000000000..a69f07efc --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/test-each/input.tsx @@ -0,0 +1,12 @@ +import { render } from '@testing-library/react-native'; + +test.each([{ name: 'Alice' }, { name: 'Bob' }])('renders for $name', ({ name }) => { + render(); +}); + +it.each([ + [1, 2, 3], + [4, 5, 9], +])('adds %i and %i to get %i', (a, b, expected) => { + render(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/test-only/expected.tsx b/codemods/v14-async-functions/tests/fixtures/test-only/expected.tsx new file mode 100644 index 000000000..0ee9a2ac2 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/test-only/expected.tsx @@ -0,0 +1,9 @@ +import { render } from '@testing-library/react-native'; + +test.only('only this test', async () => { + await render(); +}); + +it.only('only this it', async () => { + await render(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/test-only/input.tsx b/codemods/v14-async-functions/tests/fixtures/test-only/input.tsx new file mode 100644 index 000000000..2a45ddf63 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/test-only/input.tsx @@ -0,0 +1,9 @@ +import { render } from '@testing-library/react-native'; + +test.only('only this test', () => { + render(); +}); + +it.only('only this it', () => { + render(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/test-skip/expected.tsx b/codemods/v14-async-functions/tests/fixtures/test-skip/expected.tsx new file mode 100644 index 000000000..d8234abc3 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/test-skip/expected.tsx @@ -0,0 +1,5 @@ +import { render } from '@testing-library/react-native'; + +test.skip('skipped test', async () => { + await render(); +}); diff --git a/codemods/v14-async-functions/tests/fixtures/test-skip/input.tsx b/codemods/v14-async-functions/tests/fixtures/test-skip/input.tsx new file mode 100644 index 000000000..33d933fc2 --- /dev/null +++ b/codemods/v14-async-functions/tests/fixtures/test-skip/input.tsx @@ -0,0 +1,5 @@ +import { render } from '@testing-library/react-native'; + +test.skip('skipped test', () => { + render(); +}); diff --git a/codemods/v14-async-functions/tsconfig.json b/codemods/v14-async-functions/tsconfig.json new file mode 100644 index 000000000..469fc5acf --- /dev/null +++ b/codemods/v14-async-functions/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["@codemod.com/jssg-types"], + "allowImportingTsExtensions": true, + "noEmit": true, + "verbatimModuleSyntax": true, + "erasableSyntaxOnly": true, + "strict": true, + "strictNullChecks": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true + }, + "exclude": ["tests"] +} diff --git a/codemods/v14-async-functions/workflow.yaml b/codemods/v14-async-functions/workflow.yaml new file mode 100644 index 000000000..62d083f52 --- /dev/null +++ b/codemods/v14-async-functions/workflow.yaml @@ -0,0 +1,24 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/codemod/codemod/refs/heads/main/schemas/workflow.json + +version: '1' + +nodes: + - id: apply-transforms + name: Apply AST Transformations + type: automatic + steps: + - name: 'Transform render() calls to await render()' + js-ast-grep: + js_file: scripts/codemod.ts + language: 'tsx' + include: + - '**/*.ts' + - '**/*.tsx' + - '**/*.js' + - '**/*.jsx' + exclude: + - '**/node_modules/**' + - '**/build/**' + - '**/dist/**' + - '**/.next/**' + - '**/coverage/**' diff --git a/codemods/v14-update-deps/.gitignore b/codemods/v14-update-deps/.gitignore new file mode 100644 index 000000000..7588598f8 --- /dev/null +++ b/codemods/v14-update-deps/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +*.log +.DS_Store +tests/fixtures/*/temp.json diff --git a/codemods/v14-update-deps/README.md b/codemods/v14-update-deps/README.md new file mode 100644 index 000000000..96fe4d821 --- /dev/null +++ b/codemods/v14-update-deps/README.md @@ -0,0 +1,64 @@ +# RNTL v14: Update Dependencies + +This codemod automatically updates your `package.json` to prepare for React Native Testing Library v14 migration. + +## What it does + +- Removes `@types/react-test-renderer` and `react-test-renderer` (no longer needed) +- Moves `@testing-library/react-native` to `devDependencies` if it's in `dependencies` +- Updates `@testing-library/react-native` to `^14.0.0-alpha.5` +- Adds `test-renderer@0.12.0` to `devDependencies` + +## Usage + +```bash +# Run the codemod +npx codemod@latest run rntl-v14-update-deps --target ./path/to/your/project +``` + +## Example + +**Before:** + +```json +{ + "dependencies": { + "@testing-library/react-native": "^13.0.0" + }, + "devDependencies": { + "@types/react-test-renderer": "^18.0.0", + "react-test-renderer": "^18.0.0" + } +} +``` + +**After:** + +```json +{ + "devDependencies": { + "@testing-library/react-native": "^14.0.0-alpha.5", + "test-renderer": "0.12.0" + } +} +``` + +## Important notes + +- **After running the codemod**, you must run your package manager to install dependencies: + ```bash + npm install + # or yarn install / pnpm install + ``` +- The codemod sets the version to `^14.0.0-alpha.5`. You can manually update this if needed. +- For monorepos, the codemod processes each `package.json` file individually. + +## Next steps + +1. Run this codemod to update dependencies +2. Run `npm install` (or your package manager) to install the new dependencies +3. Run the async-functions codemod to update your test code: + ```bash + npx codemod@latest run rntl-v14-async-functions --target ./path/to/your/tests + ``` +4. Review and test your changes diff --git a/codemods/v14-update-deps/codemod.yaml b/codemods/v14-update-deps/codemod.yaml new file mode 100644 index 000000000..62020da51 --- /dev/null +++ b/codemods/v14-update-deps/codemod.yaml @@ -0,0 +1,19 @@ +schema_version: '1.0' + +name: 'rntl-v14-update-deps' +version: '0.1.0' +description: 'Codemod to update dependencies for RNTL v14 migration' +author: 'Maciej Jastrzebski' +license: 'MIT' +workflow: 'workflow.yaml' + +targets: + languages: ['json'] + +keywords: ['transformation', 'migration', 'dependencies'] + +registry: + access: 'public' + visibility: 'public' + +capabilities: [] diff --git a/codemods/v14-update-deps/package.json b/codemods/v14-update-deps/package.json new file mode 100644 index 000000000..a187527d9 --- /dev/null +++ b/codemods/v14-update-deps/package.json @@ -0,0 +1,11 @@ +{ + "name": "@testing-library/react-native-v14-update-deps", + "version": "0.1.0", + "description": "Codemod to update dependencies for RNTL v14 migration", + "type": "module", + "scripts": { + "test": "node --loader tsx/esm scripts/test.js" + }, + "devDependencies": {}, + "dependencies": {} +} diff --git a/codemods/v14-update-deps/scripts/codemod.ts b/codemods/v14-update-deps/scripts/codemod.ts new file mode 100644 index 000000000..a03af46a4 --- /dev/null +++ b/codemods/v14-update-deps/scripts/codemod.ts @@ -0,0 +1,150 @@ +#!/usr/bin/env node + +import type { Transform } from 'codemod:ast-grep'; +import type JSONLang from 'codemod:ast-grep/langs/json'; + +const RNTL_VERSION = '^14.0.0-alpha.5'; +const TEST_RENDERER_VERSION = '0.12.0'; + +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 (!hasRntlOrTestRenderer(packageJson)) { + return null; + } + + let hasChanges = false; + + if (removeObsoletePackages(packageJson)) { + hasChanges = true; + } + + if (ensureDevDependenciesObjectExists(packageJson)) { + hasChanges = true; + } + + if (ensureRntlInDevDependencies(packageJson)) { + hasChanges = true; + } + + if (updateTestRendererVersionInDevDependencies(packageJson)) { + hasChanges = true; + } + + if (hasChanges) { + return JSON.stringify(packageJson, null, 2) + '\n'; + } + + return null; + } catch (error) { + // Re-throw error to let the codemod platform handle it + // This provides better error reporting than silently returning null + throw new Error( + `Error processing ${filename}: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +function isPackageJsonFile(filename: string): boolean { + return filename.endsWith('package.json'); +} + +function hasRntlOrTestRenderer(packageJson: PackageJson): boolean { + const hasRntl = + packageJson.dependencies?.['@testing-library/react-native'] || + packageJson.devDependencies?.['@testing-library/react-native'] || + packageJson.peerDependencies?.['@testing-library/react-native']; + + const hasTestRenderer = + packageJson.dependencies?.['test-renderer'] || + packageJson.devDependencies?.['test-renderer'] || + packageJson.peerDependencies?.['test-renderer']; + + return hasRntl || hasTestRenderer; +} + +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 updateTestRendererVersionInDevDependencies(packageJson: PackageJson): boolean { + const currentVersion = packageJson.devDependencies?.['test-renderer']; + if (currentVersion !== TEST_RENDERER_VERSION) { + packageJson.devDependencies!['test-renderer'] = TEST_RENDERER_VERSION; + return true; + } + return false; +} diff --git a/codemods/v14-update-deps/scripts/test.js b/codemods/v14-update-deps/scripts/test.js new file mode 100755 index 000000000..373e056d4 --- /dev/null +++ b/codemods/v14-update-deps/scripts/test.js @@ -0,0 +1,95 @@ +#!/usr/bin/env node + +/** + * Test script for the package.json update codemod + * Note: This test script uses console.log for test output, which is acceptable for test scripts + */ + +/* eslint-disable no-console */ +import { readFileSync, writeFileSync, existsSync, readdirSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const fixturesDir = join(__dirname, '..', 'tests', 'fixtures'); + +// Import the codemod logic +async function runCodemod(filePath) { + const { readFileSync } = await import('fs'); + const { default: transform } = await import('./codemod.ts'); + + // Mock the codemod platform root object + const packageJsonContent = readFileSync(filePath, 'utf8'); + const mockRoot = { + filename: () => filePath, + root: () => ({ + text: () => packageJsonContent, + }), + }; + + const result = await transform(mockRoot); + // Return result or original content if null (no changes) + return result || packageJsonContent; +} + +// Test each fixture +const testCases = readdirSync(fixturesDir); + +let passed = 0; +let failed = 0; + +for (const testCase of testCases) { + const inputPath = join(fixturesDir, testCase, 'input.json'); + const expectedPath = join(fixturesDir, testCase, 'expected.json'); + + if (!existsSync(inputPath) || !existsSync(expectedPath)) { + console.log(`⚠️ ${testCase}: Missing input or expected file`); + continue; + } + + try { + const inputContent = readFileSync(inputPath, 'utf8'); + const expectedContent = readFileSync(expectedPath, 'utf8'); + + // Create a temporary file to test (must be named package.json for codemod to process it) + const tempPath = join(fixturesDir, testCase, 'package.json'); + writeFileSync(tempPath, inputContent, 'utf8'); + + // Run codemod + const result = await runCodemod(tempPath); + + // Handle null result (no changes or skipped) + const resultContent = result || inputContent; + + // Compare results + const expectedJson = JSON.parse(expectedContent); + const resultJson = JSON.parse(resultContent); + + if (JSON.stringify(expectedJson, null, 2) === JSON.stringify(resultJson, null, 2)) { + console.log(`✅ ${testCase}: PASSED`); + passed++; + } else { + console.log(`❌ ${testCase}: FAILED`); + console.log('Expected:'); + console.log(JSON.stringify(expectedJson, null, 2)); + console.log('Got:'); + console.log(JSON.stringify(resultJson, null, 2)); + failed++; + } + + // Clean up temp file + const tempFilePath = join(fixturesDir, testCase, 'package.json'); + if (existsSync(tempFilePath)) { + const { unlinkSync } = await import('fs'); + unlinkSync(tempFilePath); + } + } catch (error) { + console.log(`❌ ${testCase}: ERROR - ${error.message}`); + failed++; + } +} + +console.log(`\nTest Results: ${passed} passed, ${failed} failed`); +process.exit(failed > 0 ? 1 : 0); diff --git a/codemods/v14-update-deps/tests/fixtures/already-alpha/expected.json b/codemods/v14-update-deps/tests/fixtures/already-alpha/expected.json new file mode 100644 index 000000000..3636b3a68 --- /dev/null +++ b/codemods/v14-update-deps/tests/fixtures/already-alpha/expected.json @@ -0,0 +1,8 @@ +{ + "name": "test-project", + "version": "1.0.0", + "devDependencies": { + "@testing-library/react-native": "^14.0.0-alpha.5", + "test-renderer": "0.12.0" + } +} diff --git a/codemods/v14-update-deps/tests/fixtures/already-alpha/input.json b/codemods/v14-update-deps/tests/fixtures/already-alpha/input.json new file mode 100644 index 000000000..7f2f64bc7 --- /dev/null +++ b/codemods/v14-update-deps/tests/fixtures/already-alpha/input.json @@ -0,0 +1,11 @@ +{ + "name": "test-project", + "version": "1.0.0", + "dependencies": { + "@testing-library/react-native": "^14.0.0-alpha.1" + }, + "devDependencies": { + "@types/react-test-renderer": "^18.0.0", + "react-test-renderer": "^18.0.0" + } +} diff --git a/codemods/v14-update-deps/tests/fixtures/basic-update/expected.json b/codemods/v14-update-deps/tests/fixtures/basic-update/expected.json new file mode 100644 index 000000000..3636b3a68 --- /dev/null +++ b/codemods/v14-update-deps/tests/fixtures/basic-update/expected.json @@ -0,0 +1,8 @@ +{ + "name": "test-project", + "version": "1.0.0", + "devDependencies": { + "@testing-library/react-native": "^14.0.0-alpha.5", + "test-renderer": "0.12.0" + } +} diff --git a/codemods/v14-update-deps/tests/fixtures/basic-update/input.json b/codemods/v14-update-deps/tests/fixtures/basic-update/input.json new file mode 100644 index 000000000..135395224 --- /dev/null +++ b/codemods/v14-update-deps/tests/fixtures/basic-update/input.json @@ -0,0 +1,11 @@ +{ + "name": "test-project", + "version": "1.0.0", + "dependencies": { + "@testing-library/react-native": "^13.0.0" + }, + "devDependencies": { + "@types/react-test-renderer": "^18.0.0", + "react-test-renderer": "^18.0.0" + } +} diff --git a/codemods/v14-update-deps/tests/fixtures/move-from-deps/expected.json b/codemods/v14-update-deps/tests/fixtures/move-from-deps/expected.json new file mode 100644 index 000000000..3636b3a68 --- /dev/null +++ b/codemods/v14-update-deps/tests/fixtures/move-from-deps/expected.json @@ -0,0 +1,8 @@ +{ + "name": "test-project", + "version": "1.0.0", + "devDependencies": { + "@testing-library/react-native": "^14.0.0-alpha.5", + "test-renderer": "0.12.0" + } +} diff --git a/codemods/v14-update-deps/tests/fixtures/move-from-deps/input.json b/codemods/v14-update-deps/tests/fixtures/move-from-deps/input.json new file mode 100644 index 000000000..bc0e5685f --- /dev/null +++ b/codemods/v14-update-deps/tests/fixtures/move-from-deps/input.json @@ -0,0 +1,10 @@ +{ + "name": "test-project", + "version": "1.0.0", + "dependencies": { + "@testing-library/react-native": "^13.0.0" + }, + "devDependencies": { + "@types/react-test-renderer": "^18.0.0" + } +} diff --git a/codemods/v14-update-deps/tests/fixtures/no-rntl-or-utr/expected.json b/codemods/v14-update-deps/tests/fixtures/no-rntl-or-utr/expected.json new file mode 100644 index 000000000..8457b20f0 --- /dev/null +++ b/codemods/v14-update-deps/tests/fixtures/no-rntl-or-utr/expected.json @@ -0,0 +1,11 @@ +{ + "name": "some-other-package", + "version": "1.0.0", + "dependencies": { + "some-package": "^1.0.0" + }, + "devDependencies": { + "@types/react-test-renderer": "^18.0.0", + "react-test-renderer": "^18.0.0" + } +} diff --git a/codemods/v14-update-deps/tests/fixtures/no-rntl-or-utr/input.json b/codemods/v14-update-deps/tests/fixtures/no-rntl-or-utr/input.json new file mode 100644 index 000000000..8457b20f0 --- /dev/null +++ b/codemods/v14-update-deps/tests/fixtures/no-rntl-or-utr/input.json @@ -0,0 +1,11 @@ +{ + "name": "some-other-package", + "version": "1.0.0", + "dependencies": { + "some-package": "^1.0.0" + }, + "devDependencies": { + "@types/react-test-renderer": "^18.0.0", + "react-test-renderer": "^18.0.0" + } +} diff --git a/codemods/v14-update-deps/tests/fixtures/no-rntl/expected.json b/codemods/v14-update-deps/tests/fixtures/no-rntl/expected.json new file mode 100644 index 000000000..47de196a8 --- /dev/null +++ b/codemods/v14-update-deps/tests/fixtures/no-rntl/expected.json @@ -0,0 +1,8 @@ +{ + "name": "test-project", + "version": "1.0.0", + "devDependencies": { + "@types/react-test-renderer": "^18.0.0", + "react-test-renderer": "^18.0.0" + } +} diff --git a/codemods/v14-update-deps/tests/fixtures/no-rntl/input.json b/codemods/v14-update-deps/tests/fixtures/no-rntl/input.json new file mode 100644 index 000000000..47de196a8 --- /dev/null +++ b/codemods/v14-update-deps/tests/fixtures/no-rntl/input.json @@ -0,0 +1,8 @@ +{ + "name": "test-project", + "version": "1.0.0", + "devDependencies": { + "@types/react-test-renderer": "^18.0.0", + "react-test-renderer": "^18.0.0" + } +} diff --git a/codemods/v14-update-deps/tests/fixtures/rntl-in-devdeps/expected.json b/codemods/v14-update-deps/tests/fixtures/rntl-in-devdeps/expected.json new file mode 100644 index 000000000..009774ae5 --- /dev/null +++ b/codemods/v14-update-deps/tests/fixtures/rntl-in-devdeps/expected.json @@ -0,0 +1,6 @@ +{ + "devDependencies": { + "@testing-library/react-native": "^14.0.0-alpha.5", + "test-renderer": "0.12.0" + } +} diff --git a/codemods/v14-update-deps/tests/fixtures/rntl-in-devdeps/input.json b/codemods/v14-update-deps/tests/fixtures/rntl-in-devdeps/input.json new file mode 100644 index 000000000..1fa31c01b --- /dev/null +++ b/codemods/v14-update-deps/tests/fixtures/rntl-in-devdeps/input.json @@ -0,0 +1,6 @@ +{ + "devDependencies": { + "@testing-library/react-native": "^13.0.0", + "react-test-renderer": "^18.0.0" + } +} diff --git a/codemods/v14-update-deps/tests/fixtures/with-peer-deps/expected.json b/codemods/v14-update-deps/tests/fixtures/with-peer-deps/expected.json new file mode 100644 index 000000000..3636b3a68 --- /dev/null +++ b/codemods/v14-update-deps/tests/fixtures/with-peer-deps/expected.json @@ -0,0 +1,8 @@ +{ + "name": "test-project", + "version": "1.0.0", + "devDependencies": { + "@testing-library/react-native": "^14.0.0-alpha.5", + "test-renderer": "0.12.0" + } +} diff --git a/codemods/v14-update-deps/tests/fixtures/with-peer-deps/input.json b/codemods/v14-update-deps/tests/fixtures/with-peer-deps/input.json new file mode 100644 index 000000000..860d699d4 --- /dev/null +++ b/codemods/v14-update-deps/tests/fixtures/with-peer-deps/input.json @@ -0,0 +1,13 @@ +{ + "name": "test-project", + "version": "1.0.0", + "dependencies": { + "@testing-library/react-native": "^13.0.0" + }, + "devDependencies": { + "@types/react-test-renderer": "^18.0.0" + }, + "peerDependencies": { + "react-test-renderer": "^18.0.0" + } +} diff --git a/codemods/v14-update-deps/tsconfig.json b/codemods/v14-update-deps/tsconfig.json new file mode 100644 index 000000000..469fc5acf --- /dev/null +++ b/codemods/v14-update-deps/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["@codemod.com/jssg-types"], + "allowImportingTsExtensions": true, + "noEmit": true, + "verbatimModuleSyntax": true, + "erasableSyntaxOnly": true, + "strict": true, + "strictNullChecks": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true + }, + "exclude": ["tests"] +} diff --git a/codemods/v14-update-deps/workflow.yaml b/codemods/v14-update-deps/workflow.yaml new file mode 100644 index 000000000..b62748fd5 --- /dev/null +++ b/codemods/v14-update-deps/workflow.yaml @@ -0,0 +1,22 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/codemod/codemod/refs/heads/main/schemas/workflow.json + +version: '1' + +nodes: + - id: update-package-json + name: Update package.json dependencies + type: automatic + steps: + - name: 'Update dependencies in package.json' + js-ast-grep: + js_file: scripts/codemod.ts + language: json + include: + - '**/package.json' + exclude: + - '**/node_modules/**' + - '**/build/**' + - '**/dist/**' + - '**/.next/**' + - '**/coverage/**' + - '**/.yarn/**' diff --git a/eslint.config.mjs b/eslint.config.mjs index c02a5a65b..b55bbc78a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -5,14 +5,7 @@ import jest from 'eslint-plugin-jest'; export default [ { - ignores: [ - 'flow-typed/', - 'build/', - 'experiments-rtl/', - 'website/', - 'eslint.config.mjs', - 'jest-setup.ts', - ], + ignores: ['build/', 'experiments-rtl/', 'website/', 'eslint.config.mjs', 'jest-setup.ts'], }, ...callstackConfig, ...tseslint.configs.strict, @@ -50,7 +43,6 @@ export default [ 'react-native-a11y/has-valid-accessibility-ignores-invert-colors': 'off', 'react-native-a11y/has-valid-accessibility-value': 'off', '@typescript-eslint/no-explicit-any': 'off', - 'jest/no-standalone-expect': ['error', { additionalTestBlockFunctions: ['testGateReact19'] }], }, }, ]; diff --git a/flow-typed/npm/jest_v26.x.x.js b/flow-typed/npm/jest_v26.x.x.js deleted file mode 100644 index bb0a086df..000000000 --- a/flow-typed/npm/jest_v26.x.x.js +++ /dev/null @@ -1,1218 +0,0 @@ -// flow-typed signature: 9a1f9054d272cf6383233b8bfb639f84 -// flow-typed version: 4efeddffd8/jest_v26.x.x/flow_>=v0.104.x - -type JestMockFn, TReturn> = { - (...args: TArguments): TReturn, - /** - * An object for introspecting mock calls - */ - mock: { - /** - * An array that represents all calls that have been made into this mock - * function. Each call is represented by an array of arguments that were - * passed during the call. - */ - calls: Array, - /** - * An array that contains all the object instances that have been - * instantiated from this mock function. - */ - instances: Array, - /** - * An array that contains all the object results that have been - * returned by this mock function call - */ - results: Array<{ - isThrow: boolean, - value: TReturn, - ... - }>, - ... - }, - /** - * Resets all information stored in the mockFn.mock.calls and - * mockFn.mock.instances arrays. Often this is useful when you want to clean - * up a mock's usage data between two assertions. - */ - mockClear(): void, - /** - * Resets all information stored in the mock. This is useful when you want to - * completely restore a mock back to its initial state. - */ - mockReset(): void, - /** - * Removes the mock and restores the initial implementation. This is useful - * when you want to mock functions in certain test cases and restore the - * original implementation in others. Beware that mockFn.mockRestore only - * works when mock was created with jest.spyOn. Thus you have to take care of - * restoration yourself when manually assigning jest.fn(). - */ - mockRestore(): void, - /** - * Accepts a function that should be used as the implementation of the mock. - * The mock itself will still record all calls that go into and instances - * that come from itself -- the only difference is that the implementation - * will also be executed when the mock is called. - */ - mockImplementation( - fn: (...args: TArguments) => TReturn - ): JestMockFn, - /** - * Accepts a function that will be used as an implementation of the mock for - * one call to the mocked function. Can be chained so that multiple function - * calls produce different results. - */ - mockImplementationOnce( - fn: (...args: TArguments) => TReturn - ): JestMockFn, - /** - * Accepts a string to use in test result output in place of "jest.fn()" to - * indicate which mock function is being referenced. - */ - mockName(name: string): JestMockFn, - /** - * Just a simple sugar function for returning `this` - */ - mockReturnThis(): void, - /** - * Accepts a value that will be returned whenever the mock function is called. - */ - mockReturnValue(value: TReturn): JestMockFn, - /** - * Sugar for only returning a value once inside your mock - */ - mockReturnValueOnce(value: TReturn): JestMockFn, - /** - * Sugar for jest.fn().mockImplementation(() => Promise.resolve(value)) - */ - mockResolvedValue(value: TReturn): JestMockFn>, - /** - * Sugar for jest.fn().mockImplementationOnce(() => Promise.resolve(value)) - */ - mockResolvedValueOnce( - value: TReturn - ): JestMockFn>, - /** - * Sugar for jest.fn().mockImplementation(() => Promise.reject(value)) - */ - mockRejectedValue(value: TReturn): JestMockFn>, - /** - * Sugar for jest.fn().mockImplementationOnce(() => Promise.reject(value)) - */ - mockRejectedValueOnce(value: TReturn): JestMockFn>, - ... -}; - -type JestAsymmetricEqualityType = { - /** - * A custom Jasmine equality tester - */ - asymmetricMatch(value: mixed): boolean, - ... -}; - -type JestCallsType = { - allArgs(): mixed, - all(): mixed, - any(): boolean, - count(): number, - first(): mixed, - mostRecent(): mixed, - reset(): void, - ... -}; - -type JestClockType = { - install(): void, - mockDate(date: Date): void, - tick(milliseconds?: number): void, - uninstall(): void, - ... -}; - -type JestMatcherResult = { - message?: string | (() => string), - pass: boolean, - ... -}; - -type JestMatcher = ( - received: any, - ...actual: Array -) => JestMatcherResult | Promise; - -type JestPromiseType = { - /** - * Use rejects to unwrap the reason of a rejected promise so any other - * matcher can be chained. If the promise is fulfilled the assertion fails. - */ - rejects: JestExpectType, - /** - * Use resolves to unwrap the value of a fulfilled promise so any other - * matcher can be chained. If the promise is rejected the assertion fails. - */ - resolves: JestExpectType, - ... -}; - -/** - * Jest allows functions and classes to be used as test names in test() and - * describe() - */ -type JestTestName = string | Function; - -/** - * Plugin: jest-styled-components - */ - -type JestStyledComponentsMatcherValue = - | string - | JestAsymmetricEqualityType - | RegExp - | typeof undefined; - -type JestStyledComponentsMatcherOptions = { - media?: string, - modifier?: string, - supports?: string, - ... -}; - -type JestStyledComponentsMatchersType = { - toHaveStyleRule( - property: string, - value: JestStyledComponentsMatcherValue, - options?: JestStyledComponentsMatcherOptions - ): void, - ... -}; - -/** - * Plugin: jest-enzyme - */ -type EnzymeMatchersType = { - // 5.x - toBeEmpty(): void, - toBePresent(): void, - // 6.x - toBeChecked(): void, - toBeDisabled(): void, - toBeEmptyRender(): void, - toContainMatchingElement(selector: string): void, - toContainMatchingElements(n: number, selector: string): void, - toContainExactlyOneMatchingElement(selector: string): void, - toContainReact(element: React$Element): void, - toExist(): void, - toHaveClassName(className: string): void, - toHaveHTML(html: string): void, - toHaveProp: ((propKey: string, propValue?: any) => void) & - ((props: { ... }) => void), - toHaveRef(refName: string): void, - toHaveState: ((stateKey: string, stateValue?: any) => void) & - ((state: { ... }) => void), - toHaveStyle: ((styleKey: string, styleValue?: any) => void) & - ((style: { ... }) => void), - toHaveTagName(tagName: string): void, - toHaveText(text: string): void, - toHaveValue(value: any): void, - toIncludeText(text: string): void, - toMatchElement( - element: React$Element, - options?: {| ignoreProps?: boolean, verbose?: boolean |} - ): void, - toMatchSelector(selector: string): void, - // 7.x - toHaveDisplayName(name: string): void, - ... -}; - -// DOM testing library extensions (jest-dom) -// https://github.com/testing-library/jest-dom -type DomTestingLibraryType = { - /** - * @deprecated - */ - toBeInTheDOM(container?: HTMLElement): void, - - // 4.x - toBeInTheDocument(): void, - toBeVisible(): void, - toBeEmpty(): void, - toBeDisabled(): void, - toBeEnabled(): void, - toBeInvalid(): void, - toBeRequired(): void, - toBeValid(): void, - toContainElement(element: HTMLElement | null): void, - toContainHTML(htmlText: string): void, - toHaveAttribute(attr: string, value?: any): void, - toHaveClass(...classNames: string[]): void, - toHaveFocus(): void, - toHaveFormValues(expectedValues: { [name: string]: any, ... }): void, - toHaveStyle(css: string | { [name: string]: any, ... }): void, - toHaveTextContent( - text: string | RegExp, - options?: {| normalizeWhitespace: boolean |} - ): void, - toHaveValue(value?: string | string[] | number): void, - - // 5.x - toHaveDisplayValue(value: string | string[]): void, - toBeChecked(): void, - ... -}; - -// Jest JQuery Matchers: https://github.com/unindented/custom-jquery-matchers -type JestJQueryMatchersType = { - toExist(): void, - toHaveLength(len: number): void, - toHaveId(id: string): void, - toHaveClass(className: string): void, - toHaveTag(tag: string): void, - toHaveAttr(key: string, val?: any): void, - toHaveProp(key: string, val?: any): void, - toHaveText(text: string | RegExp): void, - toHaveData(key: string, val?: any): void, - toHaveValue(val: any): void, - toHaveCss(css: { [key: string]: any, ... }): void, - toBeChecked(): void, - toBeDisabled(): void, - toBeEmpty(): void, - toBeHidden(): void, - toBeSelected(): void, - toBeVisible(): void, - toBeFocused(): void, - toBeInDom(): void, - toBeMatchedBy(sel: string): void, - toHaveDescendant(sel: string): void, - toHaveDescendantWithText(sel: string, text: string | RegExp): void, - ... -}; - -// Jest Extended Matchers: https://github.com/jest-community/jest-extended -type JestExtendedMatchersType = { - /** - * Note: Currently unimplemented - * Passing assertion - * - * @param {String} message - */ - // pass(message: string): void; - - /** - * Note: Currently unimplemented - * Failing assertion - * - * @param {String} message - */ - // fail(message: string): void; - - /** - * Use .toBeEmpty when checking if a String '', Array [] or Object {} is empty. - */ - toBeEmpty(): void, - /** - * Use .toBeOneOf when checking if a value is a member of a given Array. - * @param {Array.<*>} members - */ - toBeOneOf(members: any[]): void, - /** - * Use `.toBeNil` when checking a value is `null` or `undefined`. - */ - toBeNil(): void, - /** - * Use `.toSatisfy` when you want to use a custom matcher by supplying a predicate function that returns a `Boolean`. - * @param {Function} predicate - */ - toSatisfy(predicate: (n: any) => boolean): void, - /** - * Use `.toBeArray` when checking if a value is an `Array`. - */ - toBeArray(): void, - /** - * Use `.toBeArrayOfSize` when checking if a value is an `Array` of size x. - * @param {Number} x - */ - toBeArrayOfSize(x: number): void, - /** - * Use `.toIncludeAllMembers` when checking if an `Array` contains all of the same members of a given set. - * @param {Array.<*>} members - */ - toIncludeAllMembers(members: any[]): void, - /** - * Use `.toIncludeAnyMembers` when checking if an `Array` contains any of the members of a given set. - * @param {Array.<*>} members - */ - toIncludeAnyMembers(members: any[]): void, - /** - * Use `.toSatisfyAll` when you want to use a custom matcher by supplying a predicate function that returns a `Boolean` for all values in an array. - * @param {Function} predicate - */ - toSatisfyAll(predicate: (n: any) => boolean): void, - /** - * Use `.toBeBoolean` when checking if a value is a `Boolean`. - */ - toBeBoolean(): void, - /** - * Use `.toBeTrue` when checking a value is equal (===) to `true`. - */ - toBeTrue(): void, - /** - * Use `.toBeFalse` when checking a value is equal (===) to `false`. - */ - toBeFalse(): void, - /** - * Use .toBeDate when checking if a value is a Date. - */ - toBeDate(): void, - /** - * Use `.toBeFunction` when checking if a value is a `Function`. - */ - toBeFunction(): void, - /** - * Use `.toHaveBeenCalledBefore` when checking if a `Mock` was called before another `Mock`. - * - * Note: Required Jest version >22 - * Note: Your mock functions will have to be asynchronous to cause the timestamps inside of Jest to occur in a differentJS event loop, otherwise the mock timestamps will all be the same - * - * @param {Mock} mock - */ - toHaveBeenCalledBefore(mock: JestMockFn): void, - /** - * Use `.toBeNumber` when checking if a value is a `Number`. - */ - toBeNumber(): void, - /** - * Use `.toBeNaN` when checking a value is `NaN`. - */ - toBeNaN(): void, - /** - * Use `.toBeFinite` when checking if a value is a `Number`, not `NaN` or `Infinity`. - */ - toBeFinite(): void, - /** - * Use `.toBePositive` when checking if a value is a positive `Number`. - */ - toBePositive(): void, - /** - * Use `.toBeNegative` when checking if a value is a negative `Number`. - */ - toBeNegative(): void, - /** - * Use `.toBeEven` when checking if a value is an even `Number`. - */ - toBeEven(): void, - /** - * Use `.toBeOdd` when checking if a value is an odd `Number`. - */ - toBeOdd(): void, - /** - * Use `.toBeWithin` when checking if a number is in between the given bounds of: start (inclusive) and end (exclusive). - * - * @param {Number} start - * @param {Number} end - */ - toBeWithin(start: number, end: number): void, - /** - * Use `.toBeObject` when checking if a value is an `Object`. - */ - toBeObject(): void, - /** - * Use `.toContainKey` when checking if an object contains the provided key. - * - * @param {String} key - */ - toContainKey(key: string): void, - /** - * Use `.toContainKeys` when checking if an object has all of the provided keys. - * - * @param {Array.} keys - */ - toContainKeys(keys: string[]): void, - /** - * Use `.toContainAllKeys` when checking if an object only contains all of the provided keys. - * - * @param {Array.} keys - */ - toContainAllKeys(keys: string[]): void, - /** - * Use `.toContainAnyKeys` when checking if an object contains at least one of the provided keys. - * - * @param {Array.} keys - */ - toContainAnyKeys(keys: string[]): void, - /** - * Use `.toContainValue` when checking if an object contains the provided value. - * - * @param {*} value - */ - toContainValue(value: any): void, - /** - * Use `.toContainValues` when checking if an object contains all of the provided values. - * - * @param {Array.<*>} values - */ - toContainValues(values: any[]): void, - /** - * Use `.toContainAllValues` when checking if an object only contains all of the provided values. - * - * @param {Array.<*>} values - */ - toContainAllValues(values: any[]): void, - /** - * Use `.toContainAnyValues` when checking if an object contains at least one of the provided values. - * - * @param {Array.<*>} values - */ - toContainAnyValues(values: any[]): void, - /** - * Use `.toContainEntry` when checking if an object contains the provided entry. - * - * @param {Array.} entry - */ - toContainEntry(entry: [string, string]): void, - /** - * Use `.toContainEntries` when checking if an object contains all of the provided entries. - * - * @param {Array.>} entries - */ - toContainEntries(entries: [string, string][]): void, - /** - * Use `.toContainAllEntries` when checking if an object only contains all of the provided entries. - * - * @param {Array.>} entries - */ - toContainAllEntries(entries: [string, string][]): void, - /** - * Use `.toContainAnyEntries` when checking if an object contains at least one of the provided entries. - * - * @param {Array.>} entries - */ - toContainAnyEntries(entries: [string, string][]): void, - /** - * Use `.toBeExtensible` when checking if an object is extensible. - */ - toBeExtensible(): void, - /** - * Use `.toBeFrozen` when checking if an object is frozen. - */ - toBeFrozen(): void, - /** - * Use `.toBeSealed` when checking if an object is sealed. - */ - toBeSealed(): void, - /** - * Use `.toBeString` when checking if a value is a `String`. - */ - toBeString(): void, - /** - * Use `.toEqualCaseInsensitive` when checking if a string is equal (===) to another ignoring the casing of both strings. - * - * @param {String} string - */ - toEqualCaseInsensitive(string: string): void, - /** - * Use `.toStartWith` when checking if a `String` starts with a given `String` prefix. - * - * @param {String} prefix - */ - toStartWith(prefix: string): void, - /** - * Use `.toEndWith` when checking if a `String` ends with a given `String` suffix. - * - * @param {String} suffix - */ - toEndWith(suffix: string): void, - /** - * Use `.toInclude` when checking if a `String` includes the given `String` substring. - * - * @param {String} substring - */ - toInclude(substring: string): void, - /** - * Use `.toIncludeRepeated` when checking if a `String` includes the given `String` substring the correct number of times. - * - * @param {String} substring - * @param {Number} times - */ - toIncludeRepeated(substring: string, times: number): void, - /** - * Use `.toIncludeMultiple` when checking if a `String` includes all of the given substrings. - * - * @param {Array.} substring - */ - toIncludeMultiple(substring: string[]): void, - ... -}; - -// Diffing snapshot utility for Jest (snapshot-diff) -// https://github.com/jest-community/snapshot-diff -type SnapshotDiffType = { - /** - * Compare the difference between the actual in the `expect()` - * vs the object inside `valueB` with some extra options. - */ - toMatchDiffSnapshot( - valueB: any, - options?: {| - expand?: boolean, - colors?: boolean, - contextLines?: number, - stablePatchmarks?: boolean, - aAnnotation?: string, - bAnnotation?: string, - |}, - testName?: string - ): void, - ... -}; - -interface JestExpectType { - not: JestExpectType & - EnzymeMatchersType & - DomTestingLibraryType & - JestJQueryMatchersType & - JestStyledComponentsMatchersType & - JestExtendedMatchersType & - SnapshotDiffType; - /** - * If you have a mock function, you can use .lastCalledWith to test what - * arguments it was last called with. - */ - lastCalledWith(...args: Array): void; - /** - * toBe just checks that a value is what you expect. It uses === to check - * strict equality. - */ - toBe(value: any): void; - /** - * Use .toBeCalledWith to ensure that a mock function was called with - * specific arguments. - */ - toBeCalledWith(...args: Array): void; - /** - * Using exact equality with floating point numbers is a bad idea. Rounding - * means that intuitive things fail. - */ - toBeCloseTo(num: number, delta: any): void; - /** - * Use .toBeDefined to check that a variable is not undefined. - */ - toBeDefined(): void; - /** - * Use .toBeFalsy when you don't care what a value is, you just want to - * ensure a value is false in a boolean context. - */ - toBeFalsy(): void; - /** - * To compare floating point numbers, you can use toBeGreaterThan. - */ - toBeGreaterThan(number: number): void; - /** - * To compare floating point numbers, you can use toBeGreaterThanOrEqual. - */ - toBeGreaterThanOrEqual(number: number): void; - /** - * To compare floating point numbers, you can use toBeLessThan. - */ - toBeLessThan(number: number): void; - /** - * To compare floating point numbers, you can use toBeLessThanOrEqual. - */ - toBeLessThanOrEqual(number: number): void; - /** - * Use .toBeInstanceOf(Class) to check that an object is an instance of a - * class. - */ - toBeInstanceOf(cls: Class<*>): void; - /** - * .toBeNull() is the same as .toBe(null) but the error messages are a bit - * nicer. - */ - toBeNull(): void; - /** - * Use .toBeTruthy when you don't care what a value is, you just want to - * ensure a value is true in a boolean context. - */ - toBeTruthy(): void; - /** - * Use .toBeUndefined to check that a variable is undefined. - */ - toBeUndefined(): void; - /** - * Use .toContain when you want to check that an item is in a list. For - * testing the items in the list, this uses ===, a strict equality check. - */ - toContain(item: any): void; - /** - * Use .toContainEqual when you want to check that an item is in a list. For - * testing the items in the list, this matcher recursively checks the - * equality of all fields, rather than checking for object identity. - */ - toContainEqual(item: any): void; - /** - * Use .toEqual when you want to check that two objects have the same value. - * This matcher recursively checks the equality of all fields, rather than - * checking for object identity. - */ - toEqual(value: any): void; - /** - * Use .toHaveBeenCalled to ensure that a mock function got called. - */ - toHaveBeenCalled(): void; - toBeCalled(): void; - /** - * Use .toHaveBeenCalledTimes to ensure that a mock function got called exact - * number of times. - */ - toHaveBeenCalledTimes(number: number): void; - toBeCalledTimes(number: number): void; - /** - * - */ - toHaveBeenNthCalledWith(nthCall: number, ...args: Array): void; - nthCalledWith(nthCall: number, ...args: Array): void; - /** - * - */ - toHaveReturned(): void; - toReturn(): void; - /** - * - */ - toHaveReturnedTimes(number: number): void; - toReturnTimes(number: number): void; - /** - * - */ - toHaveReturnedWith(value: any): void; - toReturnWith(value: any): void; - /** - * - */ - toHaveLastReturnedWith(value: any): void; - lastReturnedWith(value: any): void; - /** - * - */ - toHaveNthReturnedWith(nthCall: number, value: any): void; - nthReturnedWith(nthCall: number, value: any): void; - /** - * Use .toHaveBeenCalledWith to ensure that a mock function was called with - * specific arguments. - */ - toHaveBeenCalledWith(...args: Array): void; - toBeCalledWith(...args: Array): void; - /** - * Use .toHaveBeenLastCalledWith to ensure that a mock function was last called - * with specific arguments. - */ - toHaveBeenLastCalledWith(...args: Array): void; - lastCalledWith(...args: Array): void; - /** - * Check that an object has a .length property and it is set to a certain - * numeric value. - */ - toHaveLength(number: number): void; - /** - * - */ - toHaveProperty(propPath: string | $ReadOnlyArray, value?: any): void; - /** - * Use .toMatch to check that a string matches a regular expression or string. - */ - toMatch(regexpOrString: RegExp | string): void; - /** - * Use .toMatchObject to check that a javascript object matches a subset of the properties of an object. - */ - toMatchObject(object: Object | Array): void; - /** - * Use .toStrictEqual to check that a javascript object matches a subset of the properties of an object. - */ - toStrictEqual(value: any): void; - /** - * This ensures that an Object matches the most recent snapshot. - */ - toMatchSnapshot(propertyMatchers?: any, name?: string): void; - /** - * This ensures that an Object matches the most recent snapshot. - */ - toMatchSnapshot(name: string): void; - - toMatchInlineSnapshot(snapshot?: string): void; - toMatchInlineSnapshot(propertyMatchers?: any, snapshot?: string): void; - /** - * Use .toThrow to test that a function throws when it is called. - * If you want to test that a specific error gets thrown, you can provide an - * argument to toThrow. The argument can be a string for the error message, - * a class for the error, or a regex that should match the error. - * - * Alias: .toThrowError - */ - toThrow(message?: string | Error | Class | RegExp): void; - toThrowError(message?: string | Error | Class | RegExp): void; - /** - * Use .toThrowErrorMatchingSnapshot to test that a function throws a error - * matching the most recent snapshot when it is called. - */ - toThrowErrorMatchingSnapshot(): void; - toThrowErrorMatchingInlineSnapshot(snapshot?: string): void; -} - -type JestObjectType = { - /** - * Disables automatic mocking in the module loader. - * - * After this method is called, all `require()`s will return the real - * versions of each module (rather than a mocked version). - */ - disableAutomock(): JestObjectType, - /** - * An un-hoisted version of disableAutomock - */ - autoMockOff(): JestObjectType, - /** - * Enables automatic mocking in the module loader. - */ - enableAutomock(): JestObjectType, - /** - * An un-hoisted version of enableAutomock - */ - autoMockOn(): JestObjectType, - /** - * Clears the mock.calls and mock.instances properties of all mocks. - * Equivalent to calling .mockClear() on every mocked function. - */ - clearAllMocks(): JestObjectType, - /** - * Resets the state of all mocks. Equivalent to calling .mockReset() on every - * mocked function. - */ - resetAllMocks(): JestObjectType, - /** - * Restores all mocks back to their original value. - */ - restoreAllMocks(): JestObjectType, - /** - * Removes any pending timers from the timer system. - */ - clearAllTimers(): void, - /** - * Returns the number of fake timers still left to run. - */ - getTimerCount(): number, - /** - * The same as `mock` but not moved to the top of the expectation by - * babel-jest. - */ - doMock(moduleName: string, moduleFactory?: any): JestObjectType, - /** - * The same as `unmock` but not moved to the top of the expectation by - * babel-jest. - */ - dontMock(moduleName: string): JestObjectType, - /** - * Returns a new, unused mock function. Optionally takes a mock - * implementation. - */ - fn, TReturn>( - implementation?: (...args: TArguments) => TReturn - ): JestMockFn, - /** - * Determines if the given function is a mocked function. - */ - isMockFunction(fn: Function): boolean, - /** - * Given the name of a module, use the automatic mocking system to generate a - * mocked version of the module for you. - */ - genMockFromModule(moduleName: string): any, - /** - * Mocks a module with an auto-mocked version when it is being required. - * - * The second argument can be used to specify an explicit module factory that - * is being run instead of using Jest's automocking feature. - * - * The third argument can be used to create virtual mocks -- mocks of modules - * that don't exist anywhere in the system. - */ - mock( - moduleName: string, - moduleFactory?: any, - options?: Object - ): JestObjectType, - /** - * Returns the actual module instead of a mock, bypassing all checks on - * whether the module should receive a mock implementation or not. - */ - requireActual(moduleName: string): any, - /** - * Returns a mock module instead of the actual module, bypassing all checks - * on whether the module should be required normally or not. - */ - requireMock(moduleName: string): any, - /** - * Resets the module registry - the cache of all required modules. This is - * useful to isolate modules where local state might conflict between tests. - */ - resetModules(): JestObjectType, - /** - * Creates a sandbox registry for the modules that are loaded inside the - * callback function. This is useful to isolate specific modules for every - * test so that local module state doesn't conflict between tests. - */ - isolateModules(fn: () => void): JestObjectType, - /** - * Exhausts the micro-task queue (usually interfaced in node via - * process.nextTick). - */ - runAllTicks(): void, - /** - * Exhausts the macro-task queue (i.e., all tasks queued by setTimeout(), - * setInterval(), and setImmediate()). - */ - runAllTimers(): void, - /** - * Exhausts all tasks queued by setImmediate(). - */ - runAllImmediates(): void, - /** - * Executes only the macro task queue (i.e. all tasks queued by setTimeout() - * or setInterval() and setImmediate()). - */ - advanceTimersByTime(msToRun: number): void, - /** - * Executes only the macro task queue (i.e. all tasks queued by setTimeout() - * or setInterval() and setImmediate()). - * - * Renamed to `advanceTimersByTime`. - */ - runTimersToTime(msToRun: number): void, - /** - * Executes only the macro-tasks that are currently pending (i.e., only the - * tasks that have been queued by setTimeout() or setInterval() up to this - * point) - */ - runOnlyPendingTimers(): void, - /** - * Explicitly supplies the mock object that the module system should return - * for the specified module. Note: It is recommended to use jest.mock() - * instead. - */ - setMock(moduleName: string, moduleExports: any): JestObjectType, - /** - * Indicates that the module system should never return a mocked version of - * the specified module from require() (e.g. that it should always return the - * real module). - */ - unmock(moduleName: string): JestObjectType, - /** - * Instructs Jest to use fake versions of the standard timer functions - * (setTimeout, setInterval, clearTimeout, clearInterval, nextTick, - * setImmediate and clearImmediate). - */ - useFakeTimers(mode?: 'modern' | 'legacy'): JestObjectType, - /** - * Instructs Jest to use the real versions of the standard timer functions. - */ - useRealTimers(): JestObjectType, - /** - * Creates a mock function similar to jest.fn but also tracks calls to - * object[methodName]. - */ - spyOn( - object: Object, - methodName: string, - accessType?: 'get' | 'set' - ): JestMockFn, - /** - * Set the default timeout interval for tests and before/after hooks in milliseconds. - * Note: The default timeout interval is 5 seconds if this method is not called. - */ - setTimeout(timeout: number): JestObjectType, - ... -}; - -type JestSpyType = { calls: JestCallsType, ... }; - -type JestDoneFn = {| - (error?: Error): void, - fail: (error: Error) => void, -|}; - -/** Runs this function after every test inside this context */ -declare function afterEach( - fn: (done: JestDoneFn) => ?Promise, - timeout?: number -): void; -/** Runs this function before every test inside this context */ -declare function beforeEach( - fn: (done: JestDoneFn) => ?Promise, - timeout?: number -): void; -/** Runs this function after all tests have finished inside this context */ -declare function afterAll( - fn: (done: JestDoneFn) => ?Promise, - timeout?: number -): void; -/** Runs this function before any tests have started inside this context */ -declare function beforeAll( - fn: (done: JestDoneFn) => ?Promise, - timeout?: number -): void; - -/** A context for grouping tests together */ -declare var describe: { - /** - * Creates a block that groups together several related tests in one "test suite" - */ - (name: JestTestName, fn: () => void): void, - /** - * Only run this describe block - */ - only(name: JestTestName, fn: () => void): void, - /** - * Skip running this describe block - */ - skip(name: JestTestName, fn: () => void): void, - /** - * each runs this test against array of argument arrays per each run - * - * @param {table} table of Test - */ - each( - ...table: Array | mixed> | [Array, string] - ): ( - name: JestTestName, - fn?: (...args: Array) => ?Promise, - timeout?: number - ) => void, - ... -}; - -/** An individual test unit */ -declare var it: { - /** - * An individual test unit - * - * @param {JestTestName} Name of Test - * @param {Function} Test - * @param {number} Timeout for the test, in milliseconds. - */ - ( - name: JestTestName, - fn?: (done: JestDoneFn) => ?Promise, - timeout?: number - ): void, - /** - * Only run this test - * - * @param {JestTestName} Name of Test - * @param {Function} Test - * @param {number} Timeout for the test, in milliseconds. - */ - only: {| - ( - name: JestTestName, - fn?: (done: JestDoneFn) => ?Promise, - timeout?: number - ): void, - each( - ...table: Array | mixed> | [Array, string] - ): ( - name: JestTestName, - fn?: (...args: Array) => ?Promise, - timeout?: number - ) => void, - |}, - /** - * Skip running this test - * - * @param {JestTestName} Name of Test - * @param {Function} Test - * @param {number} Timeout for the test, in milliseconds. - */ - skip( - name: JestTestName, - fn?: (done: JestDoneFn) => ?Promise, - timeout?: number - ): void, - /** - * Highlight planned tests in the summary output - * - * @param {String} Name of Test to do - */ - todo(name: string): void, - /** - * Run the test concurrently - * - * @param {JestTestName} Name of Test - * @param {Function} Test - * @param {number} Timeout for the test, in milliseconds. - */ - concurrent( - name: JestTestName, - fn?: (done: JestDoneFn) => ?Promise, - timeout?: number - ): void, - /** - * each runs this test against array of argument arrays per each run - * - * @param {table} table of Test - */ - each( - ...table: Array | mixed> | [Array, string] - ): ( - name: JestTestName, - fn?: (...args: Array) => ?Promise, - timeout?: number - ) => void, - ... -}; - -declare function fit( - name: JestTestName, - fn: (done: JestDoneFn) => ?Promise, - timeout?: number -): void; -/** An individual test unit */ -declare var test: typeof it; -/** A disabled group of tests */ -declare var xdescribe: typeof describe; -/** A focused group of tests */ -declare var fdescribe: typeof describe; -/** A disabled individual test */ -declare var xit: typeof it; -/** A disabled individual test */ -declare var xtest: typeof it; - -type JestPrettyFormatColors = { - comment: { - close: string, - open: string, - ... - }, - content: { - close: string, - open: string, - ... - }, - prop: { - close: string, - open: string, - ... - }, - tag: { - close: string, - open: string, - ... - }, - value: { - close: string, - open: string, - ... - }, - ... -}; - -type JestPrettyFormatIndent = (string) => string; -type JestPrettyFormatRefs = Array; -type JestPrettyFormatPrint = (any) => string; -type JestPrettyFormatStringOrNull = string | null; - -type JestPrettyFormatOptions = {| - callToJSON: boolean, - edgeSpacing: string, - escapeRegex: boolean, - highlight: boolean, - indent: number, - maxDepth: number, - min: boolean, - plugins: JestPrettyFormatPlugins, - printFunctionName: boolean, - spacing: string, - theme: {| - comment: string, - content: string, - prop: string, - tag: string, - value: string, - |}, -|}; - -type JestPrettyFormatPlugin = { - print: ( - val: any, - serialize: JestPrettyFormatPrint, - indent: JestPrettyFormatIndent, - opts: JestPrettyFormatOptions, - colors: JestPrettyFormatColors - ) => string, - test: (any) => boolean, - ... -}; - -type JestPrettyFormatPlugins = Array; - -/** The expect function is used every time you want to test a value */ -declare var expect: { - /** The object that you want to make assertions against */ - ( - value: any - ): JestExpectType & - JestPromiseType & - EnzymeMatchersType & - DomTestingLibraryType & - JestJQueryMatchersType & - JestStyledComponentsMatchersType & - JestExtendedMatchersType & - SnapshotDiffType, - /** Add additional Jasmine matchers to Jest's roster */ - extend(matchers: { [name: string]: JestMatcher, ... }): void, - /** Add a module that formats application-specific data structures. */ - addSnapshotSerializer(pluginModule: JestPrettyFormatPlugin): void, - assertions(expectedAssertions: number): void, - hasAssertions(): void, - any(value: mixed): JestAsymmetricEqualityType, - anything(): any, - arrayContaining(value: Array): Array, - objectContaining(value: Object): Object, - /** Matches any received string that contains the exact expected string. */ - stringContaining(value: string): string, - stringMatching(value: string | RegExp): string, - not: { - arrayContaining: (value: $ReadOnlyArray) => Array, - objectContaining: (value: { ... }) => Object, - stringContaining: (value: string) => string, - stringMatching: (value: string | RegExp) => string, - ... - }, - ... -}; - -// TODO handle return type -// http://jasmine.github.io/2.4/introduction.html#section-Spies -declare function spyOn(value: mixed, method: string): Object; - -/** Holds all functions related to manipulating test runner */ -declare var jest: JestObjectType; - -/** - * The global Jasmine object, this is generally not exposed as the public API, - * using features inside here could break in later versions of Jest. - */ -declare var jasmine: { - DEFAULT_TIMEOUT_INTERVAL: number, - any(value: mixed): JestAsymmetricEqualityType, - anything(): any, - arrayContaining(value: Array): Array, - clock(): JestClockType, - createSpy(name: string): JestSpyType, - createSpyObj( - baseName: string, - methodNames: Array - ): { [methodName: string]: JestSpyType, ... }, - objectContaining(value: Object): Object, - stringMatching(value: string): string, - ... -}; diff --git a/flow-typed/npm/react-test-renderer_v16.x.x.js b/flow-typed/npm/react-test-renderer_v16.x.x.js deleted file mode 100644 index 87a149a1d..000000000 --- a/flow-typed/npm/react-test-renderer_v16.x.x.js +++ /dev/null @@ -1,81 +0,0 @@ -// flow-typed signature: b6bb53397d83d2d821e258cc73818d1b -// flow-typed version: 9c71eca8ef/react-test-renderer_v16.x.x/flow_>=v0.47.x - -// Type definitions for react-test-renderer 16.x.x -// Ported from: https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/react-test-renderer - -type ReactComponentInstance = React$Component; - -type ReactTestRendererJSON = { - type: string, - props: { [propName: string]: any }, - children: null | ReactTestRendererJSON[], -}; - -type ReactTestRendererTree = ReactTestRendererJSON & { - nodeType: 'component' | 'host', - instance: ?ReactComponentInstance, - rendered: null | ReactTestRendererTree, -}; - -type ReactTestInstance = { - instance: ?ReactComponentInstance, - type: string, - props: { [propName: string]: any }, - parent: null | ReactTestInstance, - children: Array, - - find(predicate: (node: ReactTestInstance) => boolean): ReactTestInstance, - findByType(type: React$ElementType): ReactTestInstance, - findByProps(props: { [propName: string]: any }): ReactTestInstance, - - findAll( - predicate: (node: ReactTestInstance) => boolean, - options?: { deep: boolean } - ): ReactTestInstance[], - findAllByType( - type: React$ElementType, - options?: { deep: boolean } - ): ReactTestInstance[], - findAllByProps( - props: { [propName: string]: any }, - options?: { deep: boolean } - ): ReactTestInstance[], -}; - -type TestRendererOptions = { - createNodeMock(element: React$Element): any, -}; - -declare module 'react-test-renderer' { - declare export type ReactTestRenderer = { - toJSON(): null | ReactTestRendererJSON, - toTree(): null | ReactTestRendererTree, - unmount(nextElement?: React$Element): void, - update(nextElement: React$Element): void, - getInstance(): ?ReactComponentInstance, - root: ReactTestInstance, - }; - - declare type Thenable = { - then(resolve: () => mixed, reject?: () => mixed): mixed, - }; - - declare function create( - nextElement: React$Element, - options?: TestRendererOptions - ): ReactTestRenderer; - - declare function act(callback: () => void): Thenable; -} - -declare module 'react-test-renderer/shallow' { - declare export default class ShallowRenderer { - static createRenderer(): ShallowRenderer; - getMountedInstance(): ReactTestInstance; - getRenderOutput>(): E; - getRenderOutput(): React$Element; - render(element: React$Element, context?: any): void; - unmount(): void; - } -} diff --git a/jest-setup.ts b/jest-setup.ts index 2d6dd3c1d..462a0d4ba 100644 --- a/jest-setup.ts +++ b/jest-setup.ts @@ -2,7 +2,4 @@ import { resetToDefaults, configure } from './src/pure'; beforeEach(() => { resetToDefaults(); - if (process.env.CONCURRENT_MODE === '0') { - configure({ concurrentRoot: false }); - } }); diff --git a/jest.config.js b/jest.config.js index 5fd8d8e81..74705440d 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,7 +1,7 @@ module.exports = { preset: 'react-native', setupFilesAfterEnv: ['./jest-setup.ts'], - testPathIgnorePatterns: ['build/', 'examples/', 'experiments-app/'], + testPathIgnorePatterns: ['build/', 'examples/', 'experiments-app/', 'codemods/'], testTimeout: 60000, transformIgnorePatterns: [ '/node_modules/(?!(@react-native|react-native|react-native-gesture-handler)/).*/', diff --git a/package.json b/package.json index 796f9739a..d3f818dce 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@testing-library/react-native", - "version": "13.3.3", + "version": "14.0.0-alpha.5", "description": "Simple and complete React Native testing utilities that encourage good testing practices.", "main": "build/index.js", "types": "build/index.d.ts", @@ -27,15 +27,17 @@ "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", - "copy-flowtypes": "cp typings/index.flow.js build", "lint": "eslint src --cache", "validate": "yarn prettier && yarn lint && yarn typecheck && yarn test", + "validate:write": "yarn prettier:write && yarn lint --fix && yarn typecheck && yarn test -u", "build:js": "babel src --out-dir build --extensions \".js,.ts,.jsx,.tsx\" --source-maps --ignore \"**/__tests__/**\"", "build:ts": "tsc --build tsconfig.release.json", - "build": "yarn clean && yarn build:js && yarn build:ts && yarn copy-flowtypes", + "build": "yarn clean && yarn build:js && yarn build:ts", "release": "release-it", "release:rc": "release-it --preRelease=rc", + "release:alpha": "release-it --preRelease=alpha", "prettier": "prettier --check .", "prettier:write": "prettier --write ." }, @@ -45,8 +47,7 @@ "matchers.d.ts", "pure.js", "pure.d.ts", - "dont-cleanup-after-each.js", - "typings/index.flow.js" + "dont-cleanup-after-each.js" ], "dependencies": { "jest-matcher-utils": "^30.2.0", @@ -56,9 +57,9 @@ }, "peerDependencies": { "jest": ">=29.0.0", - "react": ">=18.2.0", - "react-native": ">=0.71", - "react-test-renderer": ">=18.2.0" + "react": ">=19.0.0", + "react-native": ">=0.78", + "test-renderer": "~0.10.1" }, "peerDependenciesMeta": { "jest": { @@ -70,30 +71,27 @@ "@babel/core": "^7.28.5", "@babel/plugin-transform-strict-mode": "^7.27.1", "@babel/preset-env": "^7.28.5", - "@babel/preset-flow": "^7.27.1", "@babel/preset-react": "^7.28.5", "@babel/preset-typescript": "^7.28.5", "@callstack/eslint-config": "^15.0.0", - "@react-native/babel-preset": "0.82.1", + "@react-native/babel-preset": "0.83.1", "@release-it/conventional-changelog": "^10.0.2", "@relmify/jest-serializer-strip-ansi": "^1.0.2", "@types/jest": "^30.0.0", "@types/node": "^24.10.1", "@types/react": "^19.2.6", - "@types/react-test-renderer": "^19.1.0", "babel-jest": "^30.2.0", "babel-plugin-module-resolver": "^5.0.2", "del-cli": "^7.0.0", "eslint": "^9.39.1", "eslint-plugin-simple-import-sort": "^12.1.1", - "flow-bin": "~0.170.0", "jest": "^30.2.0", "prettier": "^3.6.2", - "react": "19.1.1", - "react-native": "0.82.1", + "react": "19.2.0", + "react-native": "0.83.1", "react-native-gesture-handler": "^2.29.1", - "react-test-renderer": "19.1.1", "release-it": "^19.0.6", + "test-renderer": "0.12.0", "typescript": "^5.9.3", "typescript-eslint": "^8.47.0" }, @@ -102,6 +100,6 @@ }, "packageManager": "yarn@4.11.0", "engines": { - "node": ">=18" + "node": ">=20" } } 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'); diff --git a/src/__tests__/__snapshots__/render-debug.test.tsx.snap b/src/__tests__/__snapshots__/render-debug.test.tsx.snap index b3a962a38..a83ba390a 100644 --- a/src/__tests__/__snapshots__/render-debug.test.tsx.snap +++ b/src/__tests__/__snapshots__/render-debug.test.tsx.snap @@ -1,297 +1,311 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`debug 1`] = ` -" - - Is the banana fresh? - - - not fresh - - - - - - +"<> + + + Is the banana fresh? + + + not fresh + + + + + + + + Change freshness! + + + + First Text + + + Second Text + - Change freshness! + 0 - - First Text - - - Second Text - - - 0 - -" +" `; exports[`debug changing component: bananaFresh button message should now be "fresh" 1`] = ` -" - - Is the banana fresh? - - - fresh - - - - - - + + + Is the banana fresh? + + + fresh + + + + + + + accessible={true} + collapsable={false} + focusable={true} + onBlur={[Function onBlur]} + onClick={[Function onClick]} + onFocus={[Function onFocus]} + onResponderGrant={[Function onResponderGrant]} + onResponderMove={[Function onResponderMove]} + onResponderRelease={[Function onResponderRelease]} + onResponderTerminate={[Function onResponderTerminate]} + onResponderTerminationRequest={[Function onResponderTerminationRequest]} + onStartShouldSetResponder={[Function onStartShouldSetResponder]} + role="button" + > + + Change freshness! + + + + First Text + + + Second Text + - Change freshness! + 0 - - First Text - - - Second Text - - - 0 - -" +" `; exports[`debug should use debugOptions from config when no option is specified 1`] = ` -" - - hello - -" +"<> + + + hello + + +" `; exports[`debug should use given options over config debugOptions 1`] = ` -" + - - hello - -" + > + + hello + + +" `; exports[`debug with only children prop 1`] = ` -" - - Is the banana fresh? - - - not fresh - - - - - +"<> - Change freshness! + Is the banana fresh? + + + not fresh + + + + + + + + Change freshness! + + + + First Text + + + Second Text + + + 0 - - First Text - - - Second Text - - - 0 - -" +" `; exports[`debug with only prop whose value is bananaChef 1`] = ` -" - - Is the banana fresh? - - - not fresh - - - - - +"<> - Change freshness! + Is the banana fresh? + + + not fresh + + + + + + + + Change freshness! + + + + First Text + + + Second Text + + + 0 - - First Text - - - Second Text - - - 0 - -" +" `; exports[`debug: All Props 1`] = ` -" - - Is the banana fresh? - - - not fresh - - - - - - + + + Is the banana fresh? + + + not fresh + + + + + + + accessible={true} + collapsable={false} + focusable={true} + onBlur={[Function onBlur]} + onClick={[Function onClick]} + onFocus={[Function onFocus]} + onResponderGrant={[Function onResponderGrant]} + onResponderMove={[Function onResponderMove]} + onResponderRelease={[Function onResponderRelease]} + onResponderTerminate={[Function onResponderTerminate]} + onResponderTerminationRequest={[Function onResponderTerminationRequest]} + onStartShouldSetResponder={[Function onStartShouldSetResponder]} + role="button" + > + + Change freshness! + + + + First Text + + + Second Text + - Change freshness! + 0 - - First Text - - - Second Text - - - 0 - - + undefined" `; @@ -299,53 +313,55 @@ exports[`debug: Option message 1`] = ` "another custom message - - - Is the banana fresh? - - - not fresh - - - - - - +<> + - Change freshness! + Is the banana fresh? + + + not fresh + + + + + + + + Change freshness! + + + + First Text + + + Second Text + + + 0 - - First Text - - - Second Text - - - 0 - -" +" `; diff --git a/src/__tests__/__snapshots__/render.test.tsx.snap b/src/__tests__/__snapshots__/unsafe-render-sync.test.tsx.snap similarity index 100% rename from src/__tests__/__snapshots__/render.test.tsx.snap rename to src/__tests__/__snapshots__/unsafe-render-sync.test.tsx.snap diff --git a/src/__tests__/act.test.tsx b/src/__tests__/act.test.tsx index f3b373df4..08944e994 100644 --- a/src/__tests__/act.test.tsx +++ b/src/__tests__/act.test.tsx @@ -16,35 +16,30 @@ const Counter = () => { return setCount(count + 1)}>{text}; }; -test('render should trigger useEffect', () => { +test('render should trigger useEffect', async () => { const effectCallback = jest.fn(); - render(); + await render(); expect(effectCallback).toHaveBeenCalledTimes(1); }); -test('rerender should trigger useEffect', () => { +test('rerender should trigger useEffect', async () => { const effectCallback = jest.fn(); - render(); - screen.rerender(); + await render(); + await screen.rerender(); expect(effectCallback).toHaveBeenCalledTimes(2); }); -test('fireEvent should trigger useState', () => { - render(); +test('fireEvent should trigger useState', async () => { + await render(); const counter = screen.getByText(/Total count/i); expect(counter.props.children).toEqual('Total count: 0'); - fireEvent.press(counter); + await fireEvent.press(counter); expect(counter.props.children).toEqual('Total count: 1'); }); -test('should be able to not await act', () => { - const result = act(() => {}); - expect(result).toHaveProperty('then'); -}); - test('should be able to await act', async () => { const result = await act(async () => {}); expect(result).toBe(undefined); diff --git a/src/__tests__/auto-cleanup-skip.test.tsx b/src/__tests__/auto-cleanup-skip.test.tsx index d6d51c50f..1bf6c419a 100644 --- a/src/__tests__/auto-cleanup-skip.test.tsx +++ b/src/__tests__/auto-cleanup-skip.test.tsx @@ -29,9 +29,9 @@ class Test extends React.Component<{ onUnmount?(): void }> { // This just verifies that by importing RNTL in pure mode in an environment which supports // afterEach (like jest) we won't get automatic cleanup between tests. -test('component is mounted, but not umounted before test ends', () => { +test('component is mounted, but not umounted before test ends', async () => { const fn = jest.fn(); - render(); + await render(); expect(fn).not.toHaveBeenCalled(); }); diff --git a/src/__tests__/auto-cleanup.test.tsx b/src/__tests__/auto-cleanup.test.tsx index 75453c93c..a4a3a6202 100644 --- a/src/__tests__/auto-cleanup.test.tsx +++ b/src/__tests__/auto-cleanup.test.tsx @@ -27,9 +27,9 @@ afterEach(() => { // This just verifies that by importing RNTL in an environment which supports afterEach (like jest) // we'll get automatic cleanup between tests. -test('component is mounted, but not umounted before test ends', () => { +test('component is mounted, but not umounted before test ends', async () => { const fn = jest.fn(); - render(); + await render(); expect(isMounted).toEqual(true); expect(fn).not.toHaveBeenCalled(); }); @@ -38,14 +38,14 @@ test('component is automatically umounted after first test ends', () => { expect(isMounted).toEqual(false); }); -test('does not time out with legacy fake timers', () => { +test('does not time out with legacy fake timers', async () => { jest.useFakeTimers({ legacyFakeTimers: true }); - render(); + await render(); expect(isMounted).toEqual(true); }); -test('does not time out with fake timers', () => { +test('does not time out with fake timers', async () => { jest.useFakeTimers(); - render(); + await render(); expect(isMounted).toEqual(true); }); diff --git a/src/__tests__/cleanup.test.tsx b/src/__tests__/cleanup.test.tsx index e09f3ce9b..f6036d028 100644 --- a/src/__tests__/cleanup.test.tsx +++ b/src/__tests__/cleanup.test.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { View } from 'react-native'; -import { cleanupAsync, render, renderAsync } from '../pure'; +import { cleanup, render, unsafe_renderSync } from '../pure'; class Test extends React.Component<{ onUnmount: () => void }> { componentWillUnmount() { @@ -17,21 +17,21 @@ class Test extends React.Component<{ onUnmount: () => void }> { test('cleanup after render', async () => { const fn = jest.fn(); - render(); - render(); + await render(); + await render(); expect(fn).not.toHaveBeenCalled(); - await cleanupAsync(); + await cleanup(); expect(fn).toHaveBeenCalledTimes(2); }); -test('cleanup after renderAsync', async () => { +test('cleanup after unsafe_renderSync', async () => { const fn = jest.fn(); - await renderAsync(); - await renderAsync(); + unsafe_renderSync(); + unsafe_renderSync(); expect(fn).not.toHaveBeenCalled(); - await cleanupAsync(); + await cleanup(); expect(fn).toHaveBeenCalledTimes(2); }); diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index dc454bea9..fa18b9be8 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -16,7 +16,6 @@ test('configure() overrides existing config values', () => { asyncUtilTimeout: 5000, defaultDebugOptions: { message: 'debug message' }, defaultIncludeHiddenElements: false, - concurrentRoot: true, }); }); diff --git a/src/__tests__/event-handler.test.tsx b/src/__tests__/event-handler.test.tsx index 2b4eb577e..0b200d7ca 100644 --- a/src/__tests__/event-handler.test.tsx +++ b/src/__tests__/event-handler.test.tsx @@ -2,13 +2,13 @@ import * as React from 'react'; import { Text, View } from 'react-native'; import { render, screen } from '..'; -import { getEventHandler } from '../event-handler'; +import { getEventHandlerFromProps } from '../event-handler'; -test('getEventHandler strict mode', () => { +test('getEventHandler strict mode', async () => { const onPress = jest.fn(); const testOnlyOnPress = jest.fn(); - render( + await render( {/* @ts-expect-error Intentionally passing such props */} @@ -22,20 +22,20 @@ test('getEventHandler strict mode', () => { const testOnly = screen.getByTestId('testOnly'); const both = screen.getByTestId('both'); - expect(getEventHandler(regular, 'press')).toBe(onPress); - expect(getEventHandler(testOnly, 'press')).toBe(testOnlyOnPress); - expect(getEventHandler(both, 'press')).toBe(onPress); + expect(getEventHandlerFromProps(regular.props, 'press')).toBe(onPress); + expect(getEventHandlerFromProps(testOnly.props, 'press')).toBe(testOnlyOnPress); + expect(getEventHandlerFromProps(both.props, 'press')).toBe(onPress); - expect(getEventHandler(regular, 'onPress')).toBe(undefined); - expect(getEventHandler(testOnly, 'onPress')).toBe(undefined); - expect(getEventHandler(both, 'onPress')).toBe(undefined); + expect(getEventHandlerFromProps(regular.props, 'onPress')).toBe(undefined); + expect(getEventHandlerFromProps(testOnly.props, 'onPress')).toBe(undefined); + expect(getEventHandlerFromProps(both.props, 'onPress')).toBe(undefined); }); -test('getEventHandler loose mode', () => { +test('getEventHandler loose mode', async () => { const onPress = jest.fn(); const testOnlyOnPress = jest.fn(); - render( + await render( {/* @ts-expect-error Intentionally passing such props */} @@ -49,11 +49,13 @@ test('getEventHandler loose mode', () => { const testOnly = screen.getByTestId('testOnly'); const both = screen.getByTestId('both'); - expect(getEventHandler(regular, 'press', { loose: true })).toBe(onPress); - expect(getEventHandler(testOnly, 'press', { loose: true })).toBe(testOnlyOnPress); - expect(getEventHandler(both, 'press', { loose: true })).toBe(onPress); + expect(getEventHandlerFromProps(regular.props, 'press', { loose: true })).toBe(onPress); + expect(getEventHandlerFromProps(testOnly.props, 'press', { loose: true })).toBe(testOnlyOnPress); + expect(getEventHandlerFromProps(both.props, 'press', { loose: true })).toBe(onPress); - expect(getEventHandler(regular, 'onPress', { loose: true })).toBe(onPress); - expect(getEventHandler(testOnly, 'onPress', { loose: true })).toBe(testOnlyOnPress); - expect(getEventHandler(both, 'onPress', { loose: true })).toBe(onPress); + expect(getEventHandlerFromProps(regular.props, 'onPress', { loose: true })).toBe(onPress); + expect(getEventHandlerFromProps(testOnly.props, 'onPress', { loose: true })).toBe( + testOnlyOnPress, + ); + expect(getEventHandlerFromProps(both.props, 'onPress', { loose: true })).toBe(onPress); }); diff --git a/src/__tests__/fire-event-async.test.tsx b/src/__tests__/fire-event-async.test.tsx deleted file mode 100644 index 49e96be94..000000000 --- a/src/__tests__/fire-event-async.test.tsx +++ /dev/null @@ -1,659 +0,0 @@ -import * as React from 'react'; -import { - PanResponder, - Pressable, - ScrollView, - Text, - TextInput, - TouchableOpacity, - View, -} from 'react-native'; - -import { fireEventAsync, render, screen, waitFor } from '..'; - -type OnPressComponentProps = { - onPress: () => void; - text: string; -}; -const OnPressComponent = ({ onPress, text }: OnPressComponentProps) => ( - - - {text} - - -); - -type CustomEventComponentProps = { - onCustomEvent: () => void; -}; -const CustomEventComponent = ({ onCustomEvent }: CustomEventComponentProps) => ( - - Custom event component - -); - -type MyCustomButtonProps = { - handlePress: () => void; - text: string; -}; -const MyCustomButton = ({ handlePress, text }: MyCustomButtonProps) => ( - -); - -type CustomEventComponentWithCustomNameProps = { - handlePress: () => void; -}; -const CustomEventComponentWithCustomName = ({ - handlePress, -}: CustomEventComponentWithCustomNameProps) => ( - -); - -describe('fireEventAsync', () => { - test('should invoke specified event', async () => { - const onPressMock = jest.fn(); - render(); - - await fireEventAsync(screen.getByText('Press me'), 'press'); - - expect(onPressMock).toHaveBeenCalled(); - }); - - test('should invoke specified event on parent element', async () => { - const onPressMock = jest.fn(); - const text = 'New press text'; - render(); - - await fireEventAsync(screen.getByText(text), 'press'); - expect(onPressMock).toHaveBeenCalled(); - }); - - test('should invoke event with custom name', async () => { - const handlerMock = jest.fn(); - const EVENT_DATA = 'event data'; - - render( - - - , - ); - - await fireEventAsync(screen.getByText('Custom event component'), 'customEvent', EVENT_DATA); - - expect(handlerMock).toHaveBeenCalledWith(EVENT_DATA); - }); -}); - -test('fireEventAsync.press', async () => { - const onPressMock = jest.fn(); - const text = 'Fireevent press'; - const eventData = { - nativeEvent: { - pageX: 20, - pageY: 30, - }, - }; - render(); - - await fireEventAsync.press(screen.getByText(text), eventData); - - expect(onPressMock).toHaveBeenCalledWith(eventData); -}); - -test('fireEventAsync.scroll', async () => { - const onScrollMock = jest.fn(); - const eventData = { - nativeEvent: { - contentOffset: { - y: 200, - }, - }, - }; - - render( - - XD - , - ); - - await fireEventAsync.scroll(screen.getByText('XD'), eventData); - - expect(onScrollMock).toHaveBeenCalledWith(eventData); -}); - -test('fireEventAsync.changeText', async () => { - const onChangeTextMock = jest.fn(); - - render( - - - , - ); - - const input = screen.getByPlaceholderText('Customer placeholder'); - await fireEventAsync.changeText(input, 'content'); - expect(onChangeTextMock).toHaveBeenCalledWith('content'); -}); - -it('sets native state value for unmanaged text inputs', async () => { - render(); - - const input = screen.getByTestId('input'); - expect(input).toHaveDisplayValue(''); - - await fireEventAsync.changeText(input, 'abc'); - expect(input).toHaveDisplayValue('abc'); -}); - -test('custom component with custom event name', async () => { - const handlePress = jest.fn(); - - render(); - - await fireEventAsync(screen.getByText('Custom component'), 'handlePress'); - - expect(handlePress).toHaveBeenCalled(); -}); - -test('event with multiple handler parameters', async () => { - const handlePress = jest.fn(); - - render(); - - await fireEventAsync(screen.getByText('Custom component'), 'handlePress', 'param1', 'param2'); - - expect(handlePress).toHaveBeenCalledWith('param1', 'param2'); -}); - -test('should not fire on disabled TouchableOpacity', async () => { - const handlePress = jest.fn(); - render( - - - Trigger - - , - ); - - await fireEventAsync.press(screen.getByText('Trigger')); - expect(handlePress).not.toHaveBeenCalled(); -}); - -test('should not fire on disabled Pressable', async () => { - const handlePress = jest.fn(); - render( - - - Trigger - - , - ); - - await fireEventAsync.press(screen.getByText('Trigger')); - expect(handlePress).not.toHaveBeenCalled(); -}); - -test('should not fire inside View with pointerEvents="none"', async () => { - const onPress = jest.fn(); - render( - - - Trigger - - , - ); - - await fireEventAsync.press(screen.getByText('Trigger')); - await fireEventAsync(screen.getByText('Trigger'), 'onPress'); - expect(onPress).not.toHaveBeenCalled(); -}); - -test('should not fire inside View with pointerEvents="box-only"', async () => { - const onPress = jest.fn(); - render( - - - Trigger - - , - ); - - await fireEventAsync.press(screen.getByText('Trigger')); - await fireEventAsync(screen.getByText('Trigger'), 'onPress'); - expect(onPress).not.toHaveBeenCalled(); -}); - -test('should fire inside View with pointerEvents="box-none"', async () => { - const onPress = jest.fn(); - render( - - - Trigger - - , - ); - - await fireEventAsync.press(screen.getByText('Trigger')); - await fireEventAsync(screen.getByText('Trigger'), 'onPress'); - expect(onPress).toHaveBeenCalledTimes(2); -}); - -test('should fire inside View with pointerEvents="auto"', async () => { - const onPress = jest.fn(); - render( - - - Trigger - - , - ); - - await fireEventAsync.press(screen.getByText('Trigger')); - await fireEventAsync(screen.getByText('Trigger'), 'onPress'); - expect(onPress).toHaveBeenCalledTimes(2); -}); - -test('should not fire deeply inside View with pointerEvents="box-only"', async () => { - const onPress = jest.fn(); - render( - - - - Trigger - - - , - ); - - await fireEventAsync.press(screen.getByText('Trigger')); - await fireEventAsync(screen.getByText('Trigger'), 'onPress'); - expect(onPress).not.toHaveBeenCalled(); -}); - -test('should fire non-pointer events inside View with pointerEvents="box-none"', async () => { - const onTouchStart = jest.fn(); - render(); - - await fireEventAsync(screen.getByTestId('view'), 'touchStart'); - expect(onTouchStart).toHaveBeenCalled(); -}); - -test('should fire non-touch events inside View with pointerEvents="box-none"', async () => { - const onLayout = jest.fn(); - render(); - - await fireEventAsync(screen.getByTestId('view'), 'layout'); - expect(onLayout).toHaveBeenCalled(); -}); - -// This test if pointerEvents="box-only" on composite `Pressable` is blocking -// the 'press' event on host View rendered by pressable. -test('should fire on Pressable with pointerEvents="box-only', async () => { - const onPress = jest.fn(); - render(); - - await fireEventAsync.press(screen.getByTestId('pressable')); - expect(onPress).toHaveBeenCalled(); -}); - -test('should pass event up on disabled TouchableOpacity', async () => { - const handleInnerPress = jest.fn(); - const handleOuterPress = jest.fn(); - render( - - - Inner Trigger - - , - ); - - await fireEventAsync.press(screen.getByText('Inner Trigger')); - expect(handleInnerPress).not.toHaveBeenCalled(); - expect(handleOuterPress).toHaveBeenCalledTimes(1); -}); - -test('should pass event up on disabled Pressable', async () => { - const handleInnerPress = jest.fn(); - const handleOuterPress = jest.fn(); - render( - - - Inner Trigger - - , - ); - - await fireEventAsync.press(screen.getByText('Inner Trigger')); - expect(handleInnerPress).not.toHaveBeenCalled(); - expect(handleOuterPress).toHaveBeenCalledTimes(1); -}); - -type TestComponentProps = { - onPress: () => void; - disabled?: boolean; -}; -const TestComponent = ({ onPress }: TestComponentProps) => { - return ( - - Trigger Test - - ); -}; - -test('is not fooled by non-native disabled prop', async () => { - const handlePress = jest.fn(); - render(); - - await fireEventAsync.press(screen.getByText('Trigger Test')); - expect(handlePress).toHaveBeenCalledTimes(1); -}); - -type TestChildTouchableComponentProps = { - onPress: () => void; - someProp: boolean; -}; - -function TestChildTouchableComponent({ onPress, someProp }: TestChildTouchableComponentProps) { - return ( - - - Trigger - - - ); -} - -test('is not fooled by non-responder wrapping host elements', async () => { - const handlePress = jest.fn(); - - render( - - - , - ); - - await fireEventAsync.press(screen.getByText('Trigger')); - expect(handlePress).not.toHaveBeenCalled(); -}); - -type TestDraggableComponentProps = { onDrag: () => void }; - -function TestDraggableComponent({ onDrag }: TestDraggableComponentProps) { - const responderHandlers = PanResponder.create({ - onMoveShouldSetPanResponder: (_evt, _gestureState) => true, - onPanResponderMove: onDrag, - }).panHandlers; - - return ( - - Trigger - - ); -} - -test('has only onMove', async () => { - const handleDrag = jest.fn(); - - render(); - - await fireEventAsync(screen.getByText('Trigger'), 'responderMove', { - touchHistory: { mostRecentTimeStamp: '2', touchBank: [] }, - }); - expect(handleDrag).toHaveBeenCalled(); -}); - -// Those events ideally should be triggered through `fireEventAsync.scroll`, but they are handled at the -// native level, so we need to support manually triggering them -describe('native events', () => { - test('triggers onScrollBeginDrag', async () => { - const onScrollBeginDragSpy = jest.fn(); - render(); - - await fireEventAsync(screen.getByTestId('test-id'), 'onScrollBeginDrag'); - expect(onScrollBeginDragSpy).toHaveBeenCalled(); - }); - - test('triggers onScrollEndDrag', async () => { - const onScrollEndDragSpy = jest.fn(); - render(); - - await fireEventAsync(screen.getByTestId('test-id'), 'onScrollEndDrag'); - expect(onScrollEndDragSpy).toHaveBeenCalled(); - }); - - test('triggers onMomentumScrollBegin', async () => { - const onMomentumScrollBeginSpy = jest.fn(); - render(); - - await fireEventAsync(screen.getByTestId('test-id'), 'onMomentumScrollBegin'); - expect(onMomentumScrollBeginSpy).toHaveBeenCalled(); - }); - - test('triggers onMomentumScrollEnd', async () => { - const onMomentumScrollEndSpy = jest.fn(); - render(); - - await fireEventAsync(screen.getByTestId('test-id'), 'onMomentumScrollEnd'); - expect(onMomentumScrollEndSpy).toHaveBeenCalled(); - }); -}); - -describe('React.Suspense integration', () => { - let mockPromise: Promise; - let resolveMockPromise: (value: string) => void; - - beforeEach(() => { - mockPromise = new Promise((resolve) => { - resolveMockPromise = resolve; - }); - }); - - type AsyncComponentProps = { - onPress: () => void; - shouldSuspend: boolean; - }; - - function AsyncComponent({ onPress, shouldSuspend }: AsyncComponentProps) { - if (shouldSuspend) { - throw mockPromise; - } - - return ( - - Async Component Loaded - - ); - } - - function SuspenseWrapper({ children }: { children: React.ReactNode }) { - return Loading...}>{children}; - } - - test('should handle events after Suspense resolves', async () => { - const onPressMock = jest.fn(); - - render( - - - , - ); - - // Initially shows fallback - expect(screen.getByText('Loading...')).toBeTruthy(); - - // Resolve the promise - resolveMockPromise('loaded'); - await waitFor(() => { - screen.rerender( - - - , - ); - }); - - // Component should be loaded now - await waitFor(() => { - expect(screen.getByText('Async Component Loaded')).toBeTruthy(); - }); - - // fireEventAsync should work on the resolved component - await fireEventAsync.press(screen.getByText('Async Component Loaded')); - expect(onPressMock).toHaveBeenCalled(); - }); - - test('should handle events on Suspense fallback components', async () => { - const fallbackPressMock = jest.fn(); - - function InteractiveFallback() { - return ( - - Loading with button... - - ); - } - - render( - }> - - , - ); - - // Should be able to interact with fallback - expect(screen.getByText('Loading with button...')).toBeTruthy(); - - await fireEventAsync.press(screen.getByText('Loading with button...')); - expect(fallbackPressMock).toHaveBeenCalled(); - }); - - test('should work with nested Suspense boundaries', async () => { - const outerPressMock = jest.fn(); - const innerPressMock = jest.fn(); - - type NestedAsyncProps = { - onPress: () => void; - shouldSuspend: boolean; - level: string; - }; - - function NestedAsync({ onPress, shouldSuspend, level }: NestedAsyncProps) { - if (shouldSuspend) { - throw mockPromise; - } - - return ( - - {level} Component Loaded - - ); - } - - const { rerender } = render( - Outer Loading...}> - - Inner Loading...}> - - - , - ); - - // Outer component should be loaded, inner should show fallback - expect(screen.getByText('Outer Component Loaded')).toBeTruthy(); - expect(screen.getByText('Inner Loading...')).toBeTruthy(); - - // Should be able to interact with outer component - await fireEventAsync.press(screen.getByText('Outer Component Loaded')); - expect(outerPressMock).toHaveBeenCalled(); - - // Resolve inner component - resolveMockPromise('inner-loaded'); - await waitFor(() => { - rerender( - Outer Loading...}> - - Inner Loading...}> - - - , - ); - }); - - // Both components should be loaded now - await waitFor(() => { - expect(screen.getByText('Inner Component Loaded')).toBeTruthy(); - }); - - // Should be able to interact with inner component - await fireEventAsync.press(screen.getByText('Inner Component Loaded')); - expect(innerPressMock).toHaveBeenCalled(); - }); - - test('should work when events cause components to suspend', async () => { - const onPressMock = jest.fn(); - let shouldSuspend = false; - - function DataComponent() { - if (shouldSuspend) { - throw mockPromise; // This will cause suspense - } - return Data loaded; - } - - function ButtonComponent() { - return ( - { - onPressMock(); - shouldSuspend = true; // This will cause DataComponent to suspend on next render - }} - > - Load Data - - ); - } - - render( - - - Loading data...}> - - - , - ); - - // Initially data is loaded - expect(screen.getByText('Data loaded')).toBeTruthy(); - - // Click button - this triggers the state change that will cause suspension - await fireEventAsync.press(screen.getByText('Load Data')); - expect(onPressMock).toHaveBeenCalled(); - - // Rerender - now DataComponent should suspend - screen.rerender( - - - Loading data...}> - - - , - ); - - // Should show loading fallback - expect(screen.getByText('Loading data...')).toBeTruthy(); - }); -}); - -test('should handle unmounted elements gracefully in async mode', async () => { - const onPress = jest.fn(); - render( - - Test - , - ); - - const element = screen.getByText('Test'); - screen.unmount(); - - // Firing async event on unmounted element should not crash - await fireEventAsync.press(element); - expect(onPress).not.toHaveBeenCalled(); -}); diff --git a/src/__tests__/fire-event-textInput.test.tsx b/src/__tests__/fire-event-textInput.test.tsx index 7851809e1..7223b1b21 100644 --- a/src/__tests__/fire-event-textInput.test.tsx +++ b/src/__tests__/fire-event-textInput.test.tsx @@ -14,13 +14,13 @@ function DoubleWrappedTextInput(props: TextInputProps) { const layoutEvent = { nativeEvent: { layout: { width: 100, height: 100 } } }; -test('should fire only non-touch-related events on non-editable TextInput', () => { +test('should fire only non-touch-related events on non-editable TextInput', async () => { const onFocus = jest.fn(); const onChangeText = jest.fn(); const onSubmitEditing = jest.fn(); const onLayout = jest.fn(); - render( + await render( { +test('should fire only non-touch-related events on non-editable TextInput with nested Text', async () => { const onFocus = jest.fn(); const onChangeText = jest.fn(); const onSubmitEditing = jest.fn(); const onLayout = jest.fn(); - render( + await render( { +test('should fire only non-touch-related events on non-editable wrapped TextInput', async () => { const onFocus = jest.fn(); const onChangeText = jest.fn(); const onSubmitEditing = jest.fn(); const onLayout = jest.fn(); - render( + await render( { +test('should fire only non-touch-related events on non-editable double wrapped TextInput', async () => { const onFocus = jest.fn(); const onChangeText = jest.fn(); const onSubmitEditing = jest.fn(); const onLayout = jest.fn(); - render( + await render( void; @@ -50,41 +50,41 @@ const CustomEventComponentWithCustomName = ({ ); describe('fireEvent', () => { - test('should invoke specified event', () => { + test('should invoke specified event', async () => { const onPressMock = jest.fn(); - render(); + await render(); - fireEvent(screen.getByText('Press me'), 'press'); + await fireEvent(screen.getByText('Press me'), 'press'); expect(onPressMock).toHaveBeenCalled(); }); - test('should invoke specified event on parent element', () => { + test('should invoke specified event on parent element', async () => { const onPressMock = jest.fn(); const text = 'New press text'; - render(); + await render(); - fireEvent(screen.getByText(text), 'press'); + await fireEvent(screen.getByText(text), 'press'); expect(onPressMock).toHaveBeenCalled(); }); - test('should invoke event with custom name', () => { + test('should invoke event with custom name', async () => { const handlerMock = jest.fn(); const EVENT_DATA = 'event data'; - render( + await render( , ); - fireEvent(screen.getByText('Custom event component'), 'customEvent', EVENT_DATA); + await fireEvent(screen.getByText('Custom event component'), 'customEvent', EVENT_DATA); expect(handlerMock).toHaveBeenCalledWith(EVENT_DATA); }); }); -test('fireEvent.press', () => { +test('fireEvent.press', async () => { const onPressMock = jest.fn(); const text = 'Fireevent press'; const eventData = { @@ -93,14 +93,14 @@ test('fireEvent.press', () => { pageY: 30, }, }; - render(); + await render(); - fireEvent.press(screen.getByText(text), eventData); + await fireEvent.press(screen.getByText(text), eventData); expect(onPressMock).toHaveBeenCalledWith(eventData); }); -test('fireEvent.scroll', () => { +test('fireEvent.scroll', async () => { const onScrollMock = jest.fn(); const eventData = { nativeEvent: { @@ -110,64 +110,64 @@ test('fireEvent.scroll', () => { }, }; - render( + await render( XD , ); - fireEvent.scroll(screen.getByText('XD'), eventData); + await fireEvent.scroll(screen.getByText('XD'), eventData); expect(onScrollMock).toHaveBeenCalledWith(eventData); }); -test('fireEvent.changeText', () => { +test('fireEvent.changeText', async () => { const onChangeTextMock = jest.fn(); - render( + await render( , ); const input = screen.getByPlaceholderText('Customer placeholder'); - fireEvent.changeText(input, 'content'); + await fireEvent.changeText(input, 'content'); expect(onChangeTextMock).toHaveBeenCalledWith('content'); }); -it('sets native state value for unmanaged text inputs', () => { - render(); +it('sets native state value for unmanaged text inputs', async () => { + await render(); const input = screen.getByTestId('input'); expect(input).toHaveDisplayValue(''); - fireEvent.changeText(input, 'abc'); + await fireEvent.changeText(input, 'abc'); expect(input).toHaveDisplayValue('abc'); }); -test('custom component with custom event name', () => { +test('custom component with custom event name', async () => { const handlePress = jest.fn(); - render(); + await render(); - fireEvent(screen.getByText('Custom component'), 'handlePress'); + await fireEvent(screen.getByText('Custom component'), 'handlePress'); expect(handlePress).toHaveBeenCalled(); }); -test('event with multiple handler parameters', () => { +test('event with multiple handler parameters', async () => { const handlePress = jest.fn(); - render(); + await render(); - fireEvent(screen.getByText('Custom component'), 'handlePress', 'param1', 'param2'); + await fireEvent(screen.getByText('Custom component'), 'handlePress', 'param1', 'param2'); expect(handlePress).toHaveBeenCalledWith('param1', 'param2'); }); -test('should not fire on disabled TouchableOpacity', () => { +test('should not fire on disabled TouchableOpacity', async () => { const handlePress = jest.fn(); - render( + await render( Trigger @@ -175,13 +175,13 @@ test('should not fire on disabled TouchableOpacity', () => { , ); - fireEvent.press(screen.getByText('Trigger')); + await fireEvent.press(screen.getByText('Trigger')); expect(handlePress).not.toHaveBeenCalled(); }); -test('should not fire on disabled Pressable', () => { +test('should not fire on disabled Pressable', async () => { const handlePress = jest.fn(); - render( + await render( Trigger @@ -189,13 +189,13 @@ test('should not fire on disabled Pressable', () => { , ); - fireEvent.press(screen.getByText('Trigger')); + await fireEvent.press(screen.getByText('Trigger')); expect(handlePress).not.toHaveBeenCalled(); }); -test('should not fire inside View with pointerEvents="none" in props', () => { +test('should not fire inside View with pointerEvents="none"', async () => { const onPress = jest.fn(); - render( + await render( Trigger @@ -203,44 +203,14 @@ test('should not fire inside View with pointerEvents="none" in props', () => { , ); - fireEvent.press(screen.getByText('Trigger')); - fireEvent(screen.getByText('Trigger'), 'onPress'); + await fireEvent.press(screen.getByText('Trigger')); + await fireEvent(screen.getByText('Trigger'), 'onPress'); expect(onPress).not.toHaveBeenCalled(); }); -test('should not fire inside View with pointerEvents="none" in styles', () => { +test('should not fire inside View with pointerEvents="box-only"', async () => { const onPress = jest.fn(); - render( - - - Trigger - - , - ); - - fireEvent.press(screen.getByText('Trigger')); - fireEvent(screen.getByText('Trigger'), 'onPress'); - expect(onPress).not.toHaveBeenCalled(); -}); - -test('should not fire inside View with pointerEvents="none" in styles array', () => { - const onPress = jest.fn(); - render( - - - Trigger - - , - ); - - fireEvent.press(screen.getByText('Trigger')); - fireEvent(screen.getByText('Trigger'), 'onPress'); - expect(onPress).not.toHaveBeenCalled(); -}); - -test('should not fire inside View with pointerEvents="box-only" in props', () => { - const onPress = jest.fn(); - render( + await render( Trigger @@ -248,29 +218,14 @@ test('should not fire inside View with pointerEvents="box-only" in props', () => , ); - fireEvent.press(screen.getByText('Trigger')); - fireEvent(screen.getByText('Trigger'), 'onPress'); - expect(onPress).not.toHaveBeenCalled(); -}); - -test('should not fire inside View with pointerEvents="box-only" in styles', () => { - const onPress = jest.fn(); - render( - - - Trigger - - , - ); - - fireEvent.press(screen.getByText('Trigger')); - fireEvent(screen.getByText('Trigger'), 'onPress'); + await fireEvent.press(screen.getByText('Trigger')); + await fireEvent(screen.getByText('Trigger'), 'onPress'); expect(onPress).not.toHaveBeenCalled(); }); -test('should fire inside View with pointerEvents="box-none" in props', () => { +test('should fire inside View with pointerEvents="box-none"', async () => { const onPress = jest.fn(); - render( + await render( Trigger @@ -278,29 +233,14 @@ test('should fire inside View with pointerEvents="box-none" in props', () => { , ); - fireEvent.press(screen.getByText('Trigger')); - fireEvent(screen.getByText('Trigger'), 'onPress'); + await fireEvent.press(screen.getByText('Trigger')); + await fireEvent(screen.getByText('Trigger'), 'onPress'); expect(onPress).toHaveBeenCalledTimes(2); }); -test('should fire inside View with pointerEvents="box-none" in styles', () => { +test('should fire inside View with pointerEvents="auto"', async () => { const onPress = jest.fn(); - render( - - - Trigger - - , - ); - - fireEvent.press(screen.getByText('Trigger')); - fireEvent(screen.getByText('Trigger'), 'onPress'); - expect(onPress).toHaveBeenCalledTimes(2); -}); - -test('should fire inside View with pointerEvents="auto" in props', () => { - const onPress = jest.fn(); - render( + await render( Trigger @@ -308,29 +248,14 @@ test('should fire inside View with pointerEvents="auto" in props', () => { , ); - fireEvent.press(screen.getByText('Trigger')); - fireEvent(screen.getByText('Trigger'), 'onPress'); + await fireEvent.press(screen.getByText('Trigger')); + await fireEvent(screen.getByText('Trigger'), 'onPress'); expect(onPress).toHaveBeenCalledTimes(2); }); -test('should fire inside View with pointerEvents="auto" in styles', () => { +test('should not fire deeply inside View with pointerEvents="box-only"', async () => { const onPress = jest.fn(); - render( - - - Trigger - - , - ); - - fireEvent.press(screen.getByText('Trigger')); - fireEvent(screen.getByText('Trigger'), 'onPress'); - expect(onPress).toHaveBeenCalledTimes(2); -}); - -test('should not fire deeply inside View with pointerEvents="box-only" in props', () => { - const onPress = jest.fn(); - render( + await render( @@ -340,82 +265,41 @@ test('should not fire deeply inside View with pointerEvents="box-only" in props' , ); - fireEvent.press(screen.getByText('Trigger')); - fireEvent(screen.getByText('Trigger'), 'onPress'); - expect(onPress).not.toHaveBeenCalled(); -}); - -test('should not fire deeply inside View with pointerEvents="box-only" in styles', () => { - const onPress = jest.fn(); - render( - - - - Trigger - - - , - ); - - fireEvent.press(screen.getByText('Trigger')); - fireEvent(screen.getByText('Trigger'), 'onPress'); + await fireEvent.press(screen.getByText('Trigger')); + await fireEvent(screen.getByText('Trigger'), 'onPress'); expect(onPress).not.toHaveBeenCalled(); }); -test('should fire non-pointer events inside View with pointerEvents="box-none" in props', () => { - const onTouchStart = jest.fn(); - render(); - - fireEvent(screen.getByTestId('view'), 'touchStart'); - expect(onTouchStart).toHaveBeenCalled(); -}); - -test('should fire non-pointer events inside View with pointerEvents="box-none" in styles', () => { +test('should fire non-pointer events inside View with pointerEvents="box-none"', async () => { const onTouchStart = jest.fn(); - render(); + await render(); - fireEvent(screen.getByTestId('view'), 'touchStart'); + await fireEvent(screen.getByTestId('view'), 'touchStart'); expect(onTouchStart).toHaveBeenCalled(); }); -test('should fire non-touch events inside View with pointerEvents="box-none" in props', () => { +test('should fire non-touch events inside View with pointerEvents="box-none"', async () => { const onLayout = jest.fn(); - render(); + await render(); - fireEvent(screen.getByTestId('view'), 'layout'); - expect(onLayout).toHaveBeenCalled(); -}); - -test('should fire non-touch events inside View with pointerEvents="box-none" in styles', () => { - const onLayout = jest.fn(); - render(); - - fireEvent(screen.getByTestId('view'), 'layout'); + await fireEvent(screen.getByTestId('view'), 'layout'); expect(onLayout).toHaveBeenCalled(); }); // This test if pointerEvents="box-only" on composite `Pressable` is blocking // the 'press' event on host View rendered by pressable. -test('should fire on Pressable with pointerEvents="box-only" in props', () => { - const onPress = jest.fn(); - render(); - - fireEvent.press(screen.getByTestId('pressable')); - expect(onPress).toHaveBeenCalled(); -}); - -test('should fire on Pressable with pointerEvents="box-only" in styles', () => { +test('should fire on Pressable with pointerEvents="box-only', async () => { const onPress = jest.fn(); - render(); + await render(); - fireEvent.press(screen.getByTestId('pressable')); + await fireEvent.press(screen.getByTestId('pressable')); expect(onPress).toHaveBeenCalled(); }); -test('should pass event up on disabled TouchableOpacity', () => { +test('should pass event up on disabled TouchableOpacity', async () => { const handleInnerPress = jest.fn(); const handleOuterPress = jest.fn(); - render( + await render( Inner Trigger @@ -423,15 +307,15 @@ test('should pass event up on disabled TouchableOpacity', () => { , ); - fireEvent.press(screen.getByText('Inner Trigger')); + await fireEvent.press(screen.getByText('Inner Trigger')); expect(handleInnerPress).not.toHaveBeenCalled(); expect(handleOuterPress).toHaveBeenCalledTimes(1); }); -test('should pass event up on disabled Pressable', () => { +test('should pass event up on disabled Pressable', async () => { const handleInnerPress = jest.fn(); const handleOuterPress = jest.fn(); - render( + await render( Inner Trigger @@ -439,7 +323,7 @@ test('should pass event up on disabled Pressable', () => { , ); - fireEvent.press(screen.getByText('Inner Trigger')); + await fireEvent.press(screen.getByText('Inner Trigger')); expect(handleInnerPress).not.toHaveBeenCalled(); expect(handleOuterPress).toHaveBeenCalledTimes(1); }); @@ -456,11 +340,11 @@ const TestComponent = ({ onPress }: TestComponentProps) => { ); }; -test('is not fooled by non-native disabled prop', () => { +test('is not fooled by non-native disabled prop', async () => { const handlePress = jest.fn(); - render(); + await render(); - fireEvent.press(screen.getByText('Trigger Test')); + await fireEvent.press(screen.getByText('Trigger Test')); expect(handlePress).toHaveBeenCalledTimes(1); }); @@ -479,16 +363,16 @@ function TestChildTouchableComponent({ onPress, someProp }: TestChildTouchableCo ); } -test('is not fooled by non-responder wrapping host elements', () => { +test('is not fooled by non-responder wrapping host elements', async () => { const handlePress = jest.fn(); - render( + await render( , ); - fireEvent.press(screen.getByText('Trigger')); + await fireEvent.press(screen.getByText('Trigger')); expect(handlePress).not.toHaveBeenCalled(); }); @@ -507,12 +391,12 @@ function TestDraggableComponent({ onDrag }: TestDraggableComponentProps) { ); } -test('has only onMove', () => { +test('has only onMove', async () => { const handleDrag = jest.fn(); - render(); + await render(); - fireEvent(screen.getByText('Trigger'), 'responderMove', { + await fireEvent(screen.getByText('Trigger'), 'responderMove', { touchHistory: { mostRecentTimeStamp: '2', touchBank: [] }, }); expect(handleDrag).toHaveBeenCalled(); @@ -521,51 +405,255 @@ test('has only onMove', () => { // Those events ideally should be triggered through `fireEvent.scroll`, but they are handled at the // native level, so we need to support manually triggering them describe('native events', () => { - test('triggers onScrollBeginDrag', () => { + test('triggers onScrollBeginDrag', async () => { const onScrollBeginDragSpy = jest.fn(); - render(); + await render(); - fireEvent(screen.getByTestId('test-id'), 'onScrollBeginDrag'); + await fireEvent(screen.getByTestId('test-id'), 'onScrollBeginDrag'); expect(onScrollBeginDragSpy).toHaveBeenCalled(); }); - test('triggers onScrollEndDrag', () => { + test('triggers onScrollEndDrag', async () => { const onScrollEndDragSpy = jest.fn(); - render(); + await render(); - fireEvent(screen.getByTestId('test-id'), 'onScrollEndDrag'); + await fireEvent(screen.getByTestId('test-id'), 'onScrollEndDrag'); expect(onScrollEndDragSpy).toHaveBeenCalled(); }); - test('triggers onMomentumScrollBegin', () => { + test('triggers onMomentumScrollBegin', async () => { const onMomentumScrollBeginSpy = jest.fn(); - render(); + await render(); - fireEvent(screen.getByTestId('test-id'), 'onMomentumScrollBegin'); + await fireEvent(screen.getByTestId('test-id'), 'onMomentumScrollBegin'); expect(onMomentumScrollBeginSpy).toHaveBeenCalled(); }); - test('triggers onMomentumScrollEnd', () => { + test('triggers onMomentumScrollEnd', async () => { const onMomentumScrollEndSpy = jest.fn(); - render(); + await render(); - fireEvent(screen.getByTestId('test-id'), 'onMomentumScrollEnd'); + await fireEvent(screen.getByTestId('test-id'), 'onMomentumScrollEnd'); expect(onMomentumScrollEndSpy).toHaveBeenCalled(); }); }); -test('should handle unmounted elements gracefully', () => { +describe('React.Suspense integration', () => { + let mockPromise: Promise; + let resolveMockPromise: (value: string) => void; + + beforeEach(() => { + mockPromise = new Promise((resolve) => { + resolveMockPromise = resolve; + }); + }); + + type AsyncComponentProps = { + onPress: () => void; + shouldSuspend: boolean; + }; + + function AsyncComponent({ onPress, shouldSuspend }: AsyncComponentProps) { + if (shouldSuspend) { + throw mockPromise; + } + + return ( + + Async Component Loaded + + ); + } + + function SuspenseWrapper({ children }: { children: React.ReactNode }) { + return Loading...}>{children}; + } + + test('should handle events after Suspense resolves', async () => { + const onPressMock = jest.fn(); + + await render( + + + , + ); + + // Initially shows fallback + expect(screen.getByText('Loading...')).toBeTruthy(); + + // Resolve the promise + resolveMockPromise('loaded'); + await waitFor(async () => { + await screen.rerender( + + + , + ); + }); + + // Component should be loaded now + await waitFor(() => { + expect(screen.getByText('Async Component Loaded')).toBeTruthy(); + }); + + // fireEvent should work on the resolved component + await fireEvent.press(screen.getByText('Async Component Loaded')); + expect(onPressMock).toHaveBeenCalled(); + }); + + test('should handle events on Suspense fallback components', async () => { + const fallbackPressMock = jest.fn(); + + function InteractiveFallback() { + return ( + + Loading with button... + + ); + } + + await render( + }> + + , + ); + + // Should be able to interact with fallback + expect(screen.getByText('Loading with button...')).toBeTruthy(); + + await fireEvent.press(screen.getByText('Loading with button...')); + expect(fallbackPressMock).toHaveBeenCalled(); + }); + + test('should work with nested Suspense boundaries', async () => { + const outerPressMock = jest.fn(); + const innerPressMock = jest.fn(); + + type NestedAsyncProps = { + onPress: () => void; + shouldSuspend: boolean; + level: string; + }; + + function NestedAsync({ onPress, shouldSuspend, level }: NestedAsyncProps) { + if (shouldSuspend) { + throw mockPromise; + } + + return ( + + {level} Component Loaded + + ); + } + + const { rerender } = await render( + Outer Loading...}> + + Inner Loading...}> + + + , + ); + + // Outer component should be loaded, inner should show fallback + expect(screen.getByText('Outer Component Loaded')).toBeTruthy(); + expect(screen.getByText('Inner Loading...')).toBeTruthy(); + + // Should be able to interact with outer component + await fireEvent.press(screen.getByText('Outer Component Loaded')); + expect(outerPressMock).toHaveBeenCalled(); + + // Resolve inner component + resolveMockPromise('inner-loaded'); + await waitFor(async () => { + await rerender( + Outer Loading...}> + + Inner Loading...}> + + + , + ); + }); + + // Both components should be loaded now + await waitFor(() => { + expect(screen.getByText('Inner Component Loaded')).toBeTruthy(); + }); + + // Should be able to interact with inner component + await fireEvent.press(screen.getByText('Inner Component Loaded')); + expect(innerPressMock).toHaveBeenCalled(); + }); + + test('should work when events cause components to suspend', async () => { + const onPressMock = jest.fn(); + let shouldSuspend = false; + + function DataComponent() { + if (shouldSuspend) { + throw mockPromise; // This will cause suspense + } + return Data loaded; + } + + function ButtonComponent() { + return ( + { + onPressMock(); + shouldSuspend = true; // This will cause DataComponent to suspend on next render + }} + > + Load Data + + ); + } + + await render( + + + Loading data...}> + + + , + ); + + // Initially data is loaded + expect(screen.getByText('Data loaded')).toBeTruthy(); + + // Click button - this triggers the state change that will cause suspension + await fireEvent.press(screen.getByText('Load Data')); + expect(onPressMock).toHaveBeenCalled(); + + // Rerender - now DataComponent should suspend + await screen.rerender( + + + Loading data...}> + + + , + ); + + // Should show loading fallback + expect(screen.getByText('Loading data...')).toBeTruthy(); + }); +}); + +test('should handle unmounted elements gracefully', async () => { const onPress = jest.fn(); - render( + await render( Test , ); const element = screen.getByText('Test'); - screen.unmount(); + await screen.rerender(); - // Firing event on unmounted element should not crash - fireEvent.press(element); + // Firing async event on unmounted element should not crash + await fireEvent.press(element); expect(onPress).not.toHaveBeenCalled(); }); diff --git a/src/__tests__/host-component-names.test.tsx b/src/__tests__/host-component-names.test.tsx index 3e89dd84b..3fbf5fdf2 100644 --- a/src/__tests__/host-component-names.test.tsx +++ b/src/__tests__/host-component-names.test.tsx @@ -11,39 +11,39 @@ import { isHostTextInput, } from '../helpers/host-component-names'; -test('detects host Text component', () => { - render(Hello); +test('detects host Text component', async () => { + await render(Hello); expect(isHostText(screen.root)).toBe(true); }); // Some users might use the raw RCTText component directly for performance reasons. // See: https://blog.theodo.com/2023/10/native-views-rn-performance/ -test('detects raw RCTText component', () => { - render(React.createElement('RCTText', { testID: 'text' }, 'Hello')); +test('detects raw RCTText component', async () => { + await render(React.createElement('RCTText', { testID: 'text' }, 'Hello')); expect(isHostText(screen.root)).toBe(true); }); -test('detects host TextInput component', () => { - render(); +test('detects host TextInput component', async () => { + await render(); expect(isHostTextInput(screen.root)).toBe(true); }); -test('detects host Image component', () => { - render(); +test('detects host Image component', async () => { + await render(); expect(isHostImage(screen.root)).toBe(true); }); -test('detects host Switch component', () => { - render(); +test('detects host Switch component', async () => { + await render(); expect(isHostSwitch(screen.root)).toBe(true); }); -test('detects host ScrollView component', () => { - render(); +test('detects host ScrollView component', async () => { + await render(); expect(isHostScrollView(screen.root)).toBe(true); }); -test('detects host Modal component', () => { - render(); +test('detects host Modal component', async () => { + await render(); expect(isHostModal(screen.root)).toBe(true); }); diff --git a/src/__tests__/host-text-nesting.test.tsx b/src/__tests__/host-text-nesting.test.tsx index 305120a81..c15c480d4 100644 --- a/src/__tests__/host-text-nesting.test.tsx +++ b/src/__tests__/host-text-nesting.test.tsx @@ -1,30 +1,22 @@ import * as React from 'react'; import { Pressable, Text, View } from 'react-native'; -import { render, screen, within } from '../pure'; +import { render, screen } from '../pure'; -/** - * Our queries interact differently with composite and host elements, and some specific cases require us - * to crawl up the tree to a Text composite element to be able to traverse it down again. Going up the tree - * is a dangerous behaviour because we could take the risk of then traversing a sibling node to the original one. - * This test suite is designed to be able to test as many different combinations, as a safety net. - * Specific cases should still be tested within the relevant file (for instance an edge case with `within` should have - * an explicit test in the within test suite) - */ describe('nested text handling', () => { - test('within same node', () => { - render(Hello); - expect(within(screen.getByTestId('subject')).getByText('Hello')).toBeTruthy(); + test('basic', async () => { + await render(Hello); + expect(screen.getByText('Hello')).toBeTruthy(); }); - test('role with direct text children', () => { - render(About); + test('role with direct text children', async () => { + await render(About); expect(screen.getByRole('header', { name: 'About' })).toBeTruthy(); }); - test('nested text with child with role', () => { - render( + test('nested text with child with role', async () => { + await render( About @@ -35,8 +27,8 @@ describe('nested text handling', () => { expect(screen.getByRole('header', { name: 'About' }).props.testID).toBe('child'); }); - test('pressable within View, with text child', () => { - render( + test('pressable within View, with text child', async () => { + await render( Save @@ -47,8 +39,8 @@ describe('nested text handling', () => { expect(screen.getByRole('button', { name: 'Save' }).props.testID).toBe('pressable'); }); - test('pressable within View, with text child within view', () => { - render( + test('pressable within View, with text child within view', async () => { + await render( @@ -61,8 +53,8 @@ describe('nested text handling', () => { expect(screen.getByRole('button', { name: 'Save' }).props.testID).toBe('pressable'); }); - test('Text within pressable', () => { - render( + test('Text within pressable', async () => { + await render( Save , @@ -71,8 +63,8 @@ describe('nested text handling', () => { expect(screen.getByText('Save').props.testID).toBe('text'); }); - test('Text within view within pressable', () => { - render( + test('Text within view within pressable', async () => { + await render( Save diff --git a/src/__tests__/questionsBoard.test.tsx b/src/__tests__/questionsBoard.test.tsx index c8be45eed..1eb97dcbb 100644 --- a/src/__tests__/questionsBoard.test.tsx +++ b/src/__tests__/questionsBoard.test.tsx @@ -44,7 +44,7 @@ test('form submits two answers', async () => { const onSubmit = jest.fn(); const user = userEvent.setup(); - render(); + await render(); const answerInputs = screen.getAllByLabelText('answer input'); await user.type(answerInputs[0], 'a1'); diff --git a/src/__tests__/react-native-animated.test.tsx b/src/__tests__/react-native-animated.test.tsx index 8d617fedd..87f3f582b 100644 --- a/src/__tests__/react-native-animated.test.tsx +++ b/src/__tests__/react-native-animated.test.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import type { ViewStyle } from 'react-native'; -import { Animated } from 'react-native'; +import { Animated, Text } from 'react-native'; import { act, render, screen } from '..'; @@ -44,9 +44,9 @@ describe('AnimatedView', () => { }); it('should use native driver when useNativeDriver is true', async () => { - render( + await render( - Test + Test , ); expect(screen.root).toHaveStyle({ opacity: 0 }); @@ -57,9 +57,9 @@ describe('AnimatedView', () => { }); it('should not use native driver when useNativeDriver is false', async () => { - render( + await render( - Test + Test , ); expect(screen.root).toHaveStyle({ opacity: 0 }); diff --git a/src/__tests__/react-native-api.test.tsx b/src/__tests__/react-native-api.test.tsx index 1c0d84b11..76c2f0c03 100644 --- a/src/__tests__/react-native-api.test.tsx +++ b/src/__tests__/react-native-api.test.tsx @@ -3,29 +3,27 @@ import { FlatList, Image, Modal, ScrollView, Switch, Text, TextInput, View } fro import { render, screen } from '..'; import { mapJsonProps } from '../test-utils/json'; - -const isReact19 = React.version.startsWith('19.'); -const testGateReact19 = isReact19 ? test : test.skip; +import { getReactNativeVersion } from '../test-utils/version'; /** * Tests in this file are intended to give us an proactive warning that React Native behavior has * changed in a way that may impact our code like queries or event handling. */ -test('React Native API assumption: renders a single host element', () => { - render(); +test('React Native API assumption: renders a single host element', async () => { + await render(); - expect(screen.toJSON()).toMatchInlineSnapshot(` + expect(screen).toMatchInlineSnapshot(` `); }); -test('React Native API assumption: renders a single host element', () => { - render(Hello); +test('React Native API assumption: renders a single host element', async () => { + await render(Hello); - expect(screen.toJSON()).toMatchInlineSnapshot(` + expect(screen).toMatchInlineSnapshot(` @@ -34,8 +32,8 @@ test('React Native API assumption: renders a single host element', () => `); }); -test('React Native API assumption: nested renders a single host element', () => { - render( +test('React Native API assumption: nested renders a single host element', async () => { + await render( Before Hello @@ -45,7 +43,7 @@ test('React Native API assumption: nested renders a single host element', , ); - expect(screen.toJSON()).toMatchInlineSnapshot(` + expect(screen).toMatchInlineSnapshot(` @@ -68,8 +66,8 @@ test('React Native API assumption: nested renders a single host element', `); }); -test('React Native API assumption: renders a single host element', () => { - render( +test('React Native API assumption: renders a single host element', async () => { + await render( renders a single host element', ( />, ); - expect(screen.toJSON()).toMatchInlineSnapshot(` + expect(screen).toMatchInlineSnapshot(` renders a single host element', ( `); }); -test('React Native API assumption: with nested Text renders single host element', () => { - render( +test('React Native API assumption: with nested Text renders single host element', async () => { + await render( Hello , ); - expect(screen.toJSON()).toMatchInlineSnapshot(` + expect(screen).toMatchInlineSnapshot(` with nested Text renders single h `); }); -test('React Native API assumption: renders a single host element', () => { - render(); +test('React Native API assumption: renders a single host element', async () => { + await render(); expect( mapJsonProps(screen.toJSON(), { @@ -127,10 +125,12 @@ test('React Native API assumption: renders a single host element', () = `); }); -test('React Native API assumption: renders a single host element', () => { - render(Alt text); +test('React Native API assumption: renders a single host element', async () => { + await render( + Alt text, + ); - expect(screen.toJSON()).toMatchInlineSnapshot(` + expect(screen).toMatchInlineSnapshot(` Alt text renders a single host element', () => `); }); -test('React Native API assumption: renders a single host element', () => { - render( +test('React Native API assumption: renders a single host element', async () => { + await render( , ); - expect(screen.toJSON()).toMatchInlineSnapshot(` + expect(screen).toMatchInlineSnapshot(` @@ -163,12 +163,12 @@ test('React Native API assumption: renders a single host element', `); }); -test('React Native API assumption: renders a single host element', () => { - render( +test('React Native API assumption: renders a single host element', async () => { + await render( {item}} />, ); - expect(screen.toJSON()).toMatchInlineSnapshot(` + expect(screen).toMatchInlineSnapshot(` renders a single host `); }); -testGateReact19('React Native API assumption: renders a single host element', () => { - render( +test('React Native API assumption: renders a single host element', async () => { + await render( Modal Content , ); - expect(screen.toJSON()).toMatchInlineSnapshot(` + const rnVersion = getReactNativeVersion(); + if (rnVersion.major == 0 && rnVersion.minor <= 79) { + // eslint-disable-next-line jest/no-conditional-expect + expect(screen).toMatchInlineSnapshot(` + + + Modal Content + + + `); + } else { + // eslint-disable-next-line jest/no-conditional-expect + expect(screen).toMatchInlineSnapshot(` @@ -233,10 +249,11 @@ testGateReact19('React Native API assumption: renders a single host elem `); + } }); -test('React Native API assumption: aria-* props render directly on host View', () => { - render( +test('React Native API assumption: aria-* props render directly on host View', async () => { + await render( , ); - expect(screen.toJSON()).toMatchInlineSnapshot(` + expect(screen).toMatchInlineSnapshot(` { - render( +test('React Native API assumption: aria-* props render directly on host Text', async () => { + await render( , ); - expect(screen.toJSON()).toMatchInlineSnapshot(` + expect(screen).toMatchInlineSnapshot(` { - render( +test('React Native API assumption: aria-* props render directly on host TextInput', async () => { + await render( , ); - expect(screen.toJSON()).toMatchInlineSnapshot(` + expect(screen).toMatchInlineSnapshot(` { +test('fireEvent can invoke press events for RNGH Pressable', async () => { const onPress = jest.fn(); const onPressIn = jest.fn(); const onPressOut = jest.fn(); const onLongPress = jest.fn(); - render( + await render( { const pressable = screen.getByTestId('pressable'); - fireEvent.press(pressable); + await fireEvent.press(pressable); expect(onPress).toHaveBeenCalled(); - fireEvent(pressable, 'pressIn'); + await fireEvent(pressable, 'pressIn'); expect(onPressIn).toHaveBeenCalled(); - fireEvent(pressable, 'pressOut'); + await fireEvent(pressable, 'pressOut'); expect(onPressOut).toHaveBeenCalled(); - fireEvent(pressable, 'longPress'); + await fireEvent(pressable, 'longPress'); expect(onLongPress).toHaveBeenCalled(); }); @@ -43,7 +43,7 @@ test('userEvent can invoke press events for RNGH Pressable', async () => { const { events, logEvent } = createEventLogger(); const user = userEvent.setup(); - render( + await render( { const pressable = screen.getByTestId('pressable'); await user.press(pressable); - expect(getEventsNames(events)).toEqual(['pressIn', 'pressOut', 'press']); + + const eventSequence = getEventsNames(events).join(', '); + expect( + eventSequence === 'pressIn, pressOut, press' || eventSequence === 'pressIn, press, pressOut', + ).toBe(true); }); diff --git a/src/__tests__/render-async.test.tsx b/src/__tests__/render-async.test.tsx deleted file mode 100644 index ea3b7b130..000000000 --- a/src/__tests__/render-async.test.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import * as React from 'react'; -import { Text, View } from 'react-native'; - -import { renderAsync, screen } from '..'; - -class Banana extends React.Component { - state = { - fresh: false, - }; - - componentDidUpdate() { - if (this.props.onUpdate) { - this.props.onUpdate(); - } - } - - componentWillUnmount() { - if (this.props.onUnmount) { - this.props.onUnmount(); - } - } - - changeFresh = () => { - this.setState((state) => ({ - fresh: !state.fresh, - })); - }; - - render() { - return ( - - Is the banana fresh? - {this.state.fresh ? 'fresh' : 'not fresh'} - - ); - } -} - -test('renderAsync renders component asynchronously', async () => { - await renderAsync(); - expect(screen.getByTestId('test')).toBeOnTheScreen(); -}); - -test('renderAsync with wrapper option', async () => { - const WrapperComponent = ({ children }: { children: React.ReactNode }) => ( - {children} - ); - - await renderAsync(, { - wrapper: WrapperComponent, - }); - - expect(screen.getByTestId('wrapper')).toBeTruthy(); - expect(screen.getByTestId('inner')).toBeTruthy(); -}); - -test('renderAsync supports legacy rendering option', async () => { - await renderAsync(, { concurrentRoot: false }); - expect(screen.root).toBeOnTheScreen(); -}); - -test('rerender function throws error when used with renderAsync', async () => { - await renderAsync(); - - expect(() => screen.rerender()).toThrowErrorMatchingInlineSnapshot( - `""rerender(...)" is not supported when using "renderAsync" use "await rerenderAsync(...)" instead"`, - ); -}); - -test('rerenderAsync function updates component asynchronously', async () => { - const fn = jest.fn(); - await renderAsync(); - expect(fn).toHaveBeenCalledTimes(0); - - await screen.rerenderAsync(); - expect(fn).toHaveBeenCalledTimes(1); -}); - -test('unmount function throws error when used with renderAsync', async () => { - await renderAsync(); - - expect(() => screen.unmount()).toThrowErrorMatchingInlineSnapshot( - `""unmount()" is not supported when using "renderAsync" use "await unmountAsync()" instead"`, - ); -}); - -test('unmountAsync function unmounts component asynchronously', async () => { - const fn = jest.fn(); - await renderAsync(); - - await screen.unmountAsync(); - expect(fn).toHaveBeenCalled(); -}); - -test('container property displays deprecation message', async () => { - await renderAsync(); - - expect(() => (screen as any).container).toThrowErrorMatchingInlineSnapshot(` - "'container' property has been renamed to 'UNSAFE_root'. - - Consider using 'root' property which returns root host element." - `); -}); diff --git a/src/__tests__/render-debug.test.tsx b/src/__tests__/render-debug.test.tsx index 16418c19e..21b37deb3 100644 --- a/src/__tests__/render-debug.test.tsx +++ b/src/__tests__/render-debug.test.tsx @@ -90,8 +90,8 @@ class Banana extends React.Component { } } -test('debug', () => { - render(); +test('debug', async () => { + await render(); screen.debug(); screen.debug({ message: 'another custom message' }); @@ -103,9 +103,9 @@ test('debug', () => { expect(`${mockCalls[2][0]}\n${mockCalls[2][1]}`).toMatchSnapshot('All Props'); }); -test('debug changing component', () => { - render(); - fireEvent.press(screen.getByRole('button', { name: 'Change freshness!' })); +test('debug changing component', async () => { + await render(); + await fireEvent.press(screen.getByRole('button', { name: 'Change freshness!' })); screen.debug({ mapProps: null }); @@ -113,16 +113,16 @@ test('debug changing component', () => { expect(mockCalls[0][0]).toMatchSnapshot('bananaFresh button message should now be "fresh"'); }); -test('debug with only children prop', () => { - render(); +test('debug with only children prop', async () => { + await render(); screen.debug({ mapProps: () => ({}) }); const mockCalls = jest.mocked(logger.info).mock.calls; expect(mockCalls[0][0]).toMatchSnapshot(); }); -test('debug with only prop whose value is bananaChef', () => { - render(); +test('debug with only prop whose value is bananaChef', async () => { + await render(); screen.debug({ mapProps: (props) => { const filterProps: Record = {}; @@ -139,10 +139,10 @@ test('debug with only prop whose value is bananaChef', () => { expect(mockCalls[0][0]).toMatchSnapshot(); }); -test('debug should use debugOptions from config when no option is specified', () => { +test('debug should use debugOptions from config when no option is specified', async () => { configure({ defaultDebugOptions: { mapProps: () => ({}) } }); - render( + await render( hello , @@ -153,17 +153,17 @@ test('debug should use debugOptions from config when no option is specified', () expect(mockCalls[0][0]).toMatchSnapshot(); }); -test('filtering out props through mapProps option should not modify component', () => { - render(); +test('filtering out props through mapProps option should not modify component', async () => { + await render(); screen.debug({ mapProps: () => ({}) }); expect(screen.getByTestId('viewTestID')).toBeTruthy(); }); -test('debug should use given options over config debugOptions', () => { +test('debug should use given options over config debugOptions', async () => { configure({ defaultDebugOptions: { mapProps: () => ({}) } }); - render( + await render( hello , diff --git a/src/__tests__/render-hook-async.test.tsx b/src/__tests__/render-hook-async.test.tsx deleted file mode 100644 index 8cc398ee6..000000000 --- a/src/__tests__/render-hook-async.test.tsx +++ /dev/null @@ -1,274 +0,0 @@ -import type { ReactNode } from 'react'; -import * as React from 'react'; - -import { act, renderHookAsync } from '..'; -import { excludeConsoleMessage } from '../test-utils/console'; - -const testGateReact19 = React.version.startsWith('19.') ? test : test.skip; - -// eslint-disable-next-line no-console -const originalConsoleError = console.error; -afterEach(() => { - // eslint-disable-next-line no-console - console.error = originalConsoleError; -}); - -function useSuspendingHook(promise: Promise) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore: React 18 does not have `use` hook - return React.use(promise); -} - -test('renderHookAsync renders hook asynchronously', async () => { - const { result } = await renderHookAsync(() => { - const [state, setState] = React.useState(1); - - React.useEffect(() => { - setState(2); - }, []); - - return state; - }); - - expect(result.current).toEqual(2); -}); - -test('renderHookAsync with wrapper option', async () => { - const Context = React.createContext('default'); - - function useTestHook() { - return React.useContext(Context); - } - - function Wrapper({ children }: { children: ReactNode }) { - return {children}; - } - - const { result } = await renderHookAsync(useTestHook, { wrapper: Wrapper }); - expect(result.current).toEqual('provided'); -}); - -test('renderHookAsync supports legacy rendering option', async () => { - function useTestHook() { - return React.useState(42)[0]; - } - - const { result } = await renderHookAsync(useTestHook, { concurrentRoot: false }); - expect(result.current).toEqual(42); -}); - -test('rerenderAsync function updates hook asynchronously', async () => { - function useTestHook(props: { value: number }) { - const [state, setState] = React.useState(props.value); - - React.useEffect(() => { - setState(props.value * 2); - }, [props.value]); - - return state; - } - - const { result, rerenderAsync } = await renderHookAsync(useTestHook, { - initialProps: { value: 5 }, - }); - expect(result.current).toEqual(10); - - await rerenderAsync({ value: 10 }); - expect(result.current).toEqual(20); -}); - -test('unmount function unmounts hook asynchronously', async () => { - let cleanupCalled = false; - - function useTestHook() { - React.useEffect(() => { - return () => { - cleanupCalled = true; - }; - }, []); - - return 'test'; - } - - const { unmountAsync } = await renderHookAsync(useTestHook); - expect(cleanupCalled).toBe(false); - - await unmountAsync(); - expect(cleanupCalled).toBe(true); -}); - -test('handles hook with state updates during effects', async () => { - function useTestHook() { - const [count, setCount] = React.useState(0); - - React.useEffect(() => { - setCount((prev) => prev + 1); - }, []); - - return count; - } - - const { result } = await renderHookAsync(useTestHook); - expect(result.current).toBe(1); -}); - -test('handles multiple state updates in effects', async () => { - function useTestHook() { - const [first, setFirst] = React.useState(1); - const [second, setSecond] = React.useState(2); - - React.useEffect(() => { - setFirst(10); - setSecond(20); - }, []); - - return { first, second }; - } - - const { result } = await renderHookAsync(useTestHook); - expect(result.current).toEqual({ first: 10, second: 20 }); -}); - -testGateReact19('handles hook with suspense', async () => { - let resolvePromise: (value: string) => void; - const promise = new Promise((resolve) => { - resolvePromise = resolve; - }); - - const { result } = await renderHookAsync(useSuspendingHook, { - initialProps: promise, - wrapper: ({ children }) => {children}, - }); - - // Initially suspended, result should not be available - expect(result.current).toBeNull(); - - // eslint-disable-next-line require-await - await act(async () => resolvePromise('resolved')); - expect(result.current).toBe('resolved'); -}); - -class ErrorBoundary extends React.Component< - { children: React.ReactNode; fallback: string }, - { hasError: boolean } -> { - constructor(props: { children: React.ReactNode; fallback: string }) { - super(props); - this.state = { hasError: false }; - } - - static getDerivedStateFromError() { - return { hasError: true }; - } - - render() { - return this.state.hasError ? this.props.fallback : this.props.children; - } -} - -testGateReact19('handles hook suspense with error boundary', async () => { - const ERROR_MESSAGE = 'Hook Promise Rejected In Test'; - // eslint-disable-next-line no-console - console.error = excludeConsoleMessage(console.error, ERROR_MESSAGE); - - let rejectPromise: (error: Error) => void; - const promise = new Promise((_resolve, reject) => { - rejectPromise = reject; - }); - - const { result } = await renderHookAsync(useSuspendingHook, { - initialProps: promise, - wrapper: ({ children }) => ( - - {children} - - ), - }); - - // Initially suspended - expect(result.current).toBeNull(); - - // eslint-disable-next-line require-await - await act(async () => rejectPromise(new Error(ERROR_MESSAGE))); - - // After error, result should still be null (error boundary caught it) - expect(result.current).toBeNull(); -}); - -test('handles custom hooks with complex logic', async () => { - function useCounter(initialValue: number) { - const [count, setCount] = React.useState(initialValue); - - const increment = React.useCallback(() => { - setCount((prev) => prev + 1); - }, []); - - const decrement = React.useCallback(() => { - setCount((prev) => prev - 1); - }, []); - - const reset = React.useCallback(() => { - setCount(initialValue); - }, [initialValue]); - - return { count, increment, decrement, reset }; - } - - const { result } = await renderHookAsync(useCounter, { initialProps: 5 }); - expect(result.current.count).toBe(5); - - // eslint-disable-next-line require-await - await act(async () => { - result.current.increment(); - }); - expect(result.current.count).toBe(6); - - // eslint-disable-next-line require-await - await act(async () => { - result.current.reset(); - }); - expect(result.current.count).toBe(5); - - // eslint-disable-next-line require-await - await act(async () => { - result.current.decrement(); - }); - expect(result.current.count).toBe(4); -}); - -test('handles hook with cleanup and re-initialization', async () => { - let effectCount = 0; - let cleanupCount = 0; - - function useTestHook(props: { key: string }) { - const [value, setValue] = React.useState(props.key); - - React.useEffect(() => { - effectCount++; - setValue(`${props.key}-effect`); - - return () => { - cleanupCount++; - }; - }, [props.key]); - - return value; - } - - const { result, rerenderAsync, unmountAsync } = await renderHookAsync(useTestHook, { - initialProps: { key: 'initial' }, - }); - - expect(result.current).toBe('initial-effect'); - expect(effectCount).toBe(1); - expect(cleanupCount).toBe(0); - - await rerenderAsync({ key: 'updated' }); - expect(result.current).toBe('updated-effect'); - expect(effectCount).toBe(2); - expect(cleanupCount).toBe(1); - - await unmountAsync(); - expect(effectCount).toBe(2); - expect(cleanupCount).toBe(2); -}); diff --git a/src/__tests__/render-hook.test.tsx b/src/__tests__/render-hook.test.tsx index 9cdc6618b..ca1f3f828 100644 --- a/src/__tests__/render-hook.test.tsx +++ b/src/__tests__/render-hook.test.tsx @@ -1,103 +1,291 @@ import type { ReactNode } from 'react'; -import React from 'react'; -import TestRenderer from 'react-test-renderer'; +import * as React from 'react'; +import { Text } from 'react-native'; -import { renderHook } from '../pure'; +import { act, renderHook } from '..'; +import { excludeConsoleMessage } from '../test-utils/console'; -test('gives committed result', () => { - const { result } = renderHook(() => { +// eslint-disable-next-line no-console +const originalConsoleError = console.error; +afterEach(() => { + // eslint-disable-next-line no-console + console.error = originalConsoleError; +}); + +function useSuspendingHook(promise: Promise) { + return React.use(promise); +} + +test('renders hook and returns committed result', async () => { + const { result } = await renderHook(() => { const [state, setState] = React.useState(1); React.useEffect(() => { setState(2); }, []); - return [state, setState]; + return state; }); - expect(result.current).toEqual([2, expect.any(Function)]); + expect(result.current).toEqual(2); }); -test('allows rerendering', () => { - const { result, rerender } = renderHook( - (props: { branch: 'left' | 'right' }) => { - const [left, setLeft] = React.useState('left'); - const [right, setRight] = React.useState('right'); - - switch (props.branch) { - case 'left': - return [left, setLeft]; - case 'right': - return [right, setRight]; - default: - throw new Error('No Props passed. This is a bug in the implementation'); - } - }, - { initialProps: { branch: 'left' } }, - ); - - expect(result.current).toEqual(['left', expect.any(Function)]); - - rerender({ branch: 'right' }); - - expect(result.current).toEqual(['right', expect.any(Function)]); +test('handles hook with state updates during effects', async () => { + function useTestHook() { + const [count, setCount] = React.useState(0); + + React.useEffect(() => { + setCount((prev) => prev + 1); + }, []); + + return count; + } + + const { result } = await renderHook(useTestHook); + expect(result.current).toBe(1); }); -test('allows wrapper components', () => { +test('handles multiple state updates in effects', async () => { + function useTestHook() { + const [first, setFirst] = React.useState(1); + const [second, setSecond] = React.useState(2); + + React.useEffect(() => { + setFirst(10); + setSecond(20); + }, []); + + return { first, second }; + } + + const { result } = await renderHook(useTestHook); + expect(result.current).toEqual({ first: 10, second: 20 }); +}); + +test('works with wrapper option', async () => { const Context = React.createContext('default'); + + function useTestHook() { + return React.useContext(Context); + } + function Wrapper({ children }: { children: ReactNode }) { return {children}; } - const { result } = renderHook( - () => { - return React.useContext(Context); - }, - { - wrapper: Wrapper, - }, - ); + const { result } = await renderHook(useTestHook, { wrapper: Wrapper }); expect(result.current).toEqual('provided'); }); -function useMyHook(param: T) { - return { param }; -} +test('works with initialProps option', async () => { + function useTestHook(props: { value: number }) { + const [state, setState] = React.useState(props.value); -test('props type is inferred correctly when initial props is defined', () => { - const { result, rerender } = renderHook((num: number) => useMyHook(num), { - initialProps: 5, + React.useEffect(() => { + setState(props.value * 2); + }, [props.value]); + + return state; + } + + const { result } = await renderHook(useTestHook, { + initialProps: { value: 5 }, }); - expect(result.current.param).toBe(5); + expect(result.current).toEqual(10); +}); + +test('works without initialProps option', async () => { + function useTestHook() { + const [count, setCount] = React.useState(0); + return { count, setCount }; + } - rerender(6); - expect(result.current.param).toBe(6); + const { result } = await renderHook(useTestHook); + expect(result.current.count).toBe(0); }); -test('props type is inferred correctly when initial props is explicitly undefined', () => { - const { result, rerender } = renderHook((num: number | undefined) => useMyHook(num), { - initialProps: undefined, +test('rerender updates hook with new props', async () => { + function useTestHook(props: { value: number }) { + const [state, setState] = React.useState(props.value); + + React.useEffect(() => { + setState(props.value * 2); + }, [props.value]); + + return state; + } + + const { result, rerender } = await renderHook(useTestHook, { + initialProps: { value: 5 }, }); + expect(result.current).toEqual(10); - expect(result.current.param).toBeUndefined(); + await rerender({ value: 10 }); + expect(result.current).toEqual(20); +}); + +test('unmount triggers cleanup effects', async () => { + let cleanupCalled = false; + + function useTestHook() { + React.useEffect(() => { + return () => { + cleanupCalled = true; + }; + }, []); - rerender(6); - expect(result.current.param).toBe(6); + return 'test'; + } + + const { unmount } = await renderHook(useTestHook); + expect(cleanupCalled).toBe(false); + + await unmount(); + expect(cleanupCalled).toBe(true); }); -/** - * This test makes sure that calling renderHook does - * not try to detect host component names in any form. - * But since there are numerous methods that could trigger that - * we check the count of renders using React Test Renderers. - */ -test('does render only once', () => { - jest.spyOn(TestRenderer, 'create'); +test('handles hook with cleanup and re-initialization', async () => { + let effectCount = 0; + let cleanupCount = 0; - renderHook(() => { - const [state, setState] = React.useState(1); - return [state, setState]; + function useTestHook(props: { key: string }) { + const [value, setValue] = React.useState(props.key); + + React.useEffect(() => { + effectCount++; + setValue(`${props.key}-effect`); + + return () => { + cleanupCount++; + }; + }, [props.key]); + + return value; + } + + const { result, rerender, unmount } = await renderHook(useTestHook, { + initialProps: { key: 'initial' }, + }); + + expect(result.current).toBe('initial-effect'); + expect(effectCount).toBe(1); + expect(cleanupCount).toBe(0); + + await rerender({ key: 'updated' }); + expect(result.current).toBe('updated-effect'); + expect(effectCount).toBe(2); + expect(cleanupCount).toBe(1); + + await unmount(); + expect(effectCount).toBe(2); + expect(cleanupCount).toBe(2); +}); + +test('handles hook with suspense', async () => { + let resolvePromise: (value: string) => void; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + + const { result } = await renderHook(useSuspendingHook, { + initialProps: promise, + wrapper: ({ children }) => ( + Loading...}>{children} + ), }); - expect(TestRenderer.create).toHaveBeenCalledTimes(1); + // Initially suspended, result should not be available + expect(result.current).toBeNull(); + + // eslint-disable-next-line require-await + await act(async () => resolvePromise('resolved')); + expect(result.current).toBe('resolved'); +}); + +class ErrorBoundary extends React.Component< + { children: React.ReactNode; fallback: React.ReactNode }, + { hasError: boolean } +> { + constructor(props: { children: React.ReactNode; fallback: React.ReactNode }) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError() { + return { hasError: true }; + } + + render() { + return this.state.hasError ? this.props.fallback : this.props.children; + } +} + +test('handles hook suspense with error boundary', async () => { + const ERROR_MESSAGE = 'Hook Promise Rejected In Test'; + // eslint-disable-next-line no-console + console.error = excludeConsoleMessage(console.error, ERROR_MESSAGE); + + let rejectPromise: (error: Error) => void; + const promise = new Promise((_resolve, reject) => { + rejectPromise = reject; + }); + + const { result } = await renderHook(useSuspendingHook, { + initialProps: promise, + wrapper: ({ children }) => ( + Error Fallback}> + Loading...}>{children} + + ), + }); + + // Initially suspended + expect(result.current).toBeNull(); + + // eslint-disable-next-line require-await + await act(async () => rejectPromise(new Error(ERROR_MESSAGE))); + + // After error, result should still be null (error boundary caught it) + expect(result.current).toBeNull(); +}); + +test('handles custom hooks with complex logic', async () => { + function useCounter(initialValue: number) { + const [count, setCount] = React.useState(initialValue); + + const increment = React.useCallback(() => { + setCount((prev) => prev + 1); + }, []); + + const decrement = React.useCallback(() => { + setCount((prev) => prev - 1); + }, []); + + const reset = React.useCallback(() => { + setCount(initialValue); + }, [initialValue]); + + return { count, increment, decrement, reset }; + } + + const { result } = await renderHook(useCounter, { initialProps: 5 }); + expect(result.current.count).toBe(5); + + // eslint-disable-next-line require-await + await act(async () => { + result.current.increment(); + }); + expect(result.current.count).toBe(6); + + // eslint-disable-next-line require-await + await act(async () => { + result.current.reset(); + }); + expect(result.current.count).toBe(5); + + // eslint-disable-next-line require-await + await act(async () => { + result.current.decrement(); + }); + expect(result.current.count).toBe(4); }); diff --git a/src/__tests__/render-string-validation.test.tsx b/src/__tests__/render-string-validation.test.tsx deleted file mode 100644 index 9ac25a01f..000000000 --- a/src/__tests__/render-string-validation.test.tsx +++ /dev/null @@ -1,200 +0,0 @@ -import * as React from 'react'; -import { Pressable, Text, View } from 'react-native'; - -import { fireEvent, render, screen } from '..'; -import { excludeConsoleMessage } from '../test-utils/console'; - -// eslint-disable-next-line no-console -const originalConsoleError = console.error; - -const VALIDATION_ERROR = - 'Invariant Violation: Text strings must be rendered within a component'; -const PROFILER_ERROR = 'The above error occurred in the component'; - -beforeEach(() => { - // eslint-disable-next-line no-console - console.error = excludeConsoleMessage(console.error, PROFILER_ERROR); -}); -afterEach(() => { - // eslint-disable-next-line no-console - console.error = originalConsoleError; -}); - -test('should throw when rendering a string outside a text component', () => { - expect(() => - render(hello, { - unstable_validateStringsRenderedWithinText: true, - }), - ).toThrow( - `${VALIDATION_ERROR}. Detected attempt to render "hello" string within a component.`, - ); -}); - -test('should throw an error when rerendering with text outside of Text component', () => { - render(, { - unstable_validateStringsRenderedWithinText: true, - }); - - expect(() => screen.rerender(hello)).toThrow( - `${VALIDATION_ERROR}. Detected attempt to render "hello" string within a component.`, - ); -}); - -const InvalidTextAfterPress = () => { - const [showText, setShowText] = React.useState(false); - - if (!showText) { - return ( - setShowText(true)}> - Show text - - ); - } - - return text rendered outside text component; -}; - -test('should throw an error when strings are rendered outside Text', () => { - render(, { - unstable_validateStringsRenderedWithinText: true, - }); - - expect(() => fireEvent.press(screen.getByText('Show text'))).toThrow( - `${VALIDATION_ERROR}. Detected attempt to render "text rendered outside text component" string within a component.`, - ); -}); - -test('should not throw for texts nested in fragments', () => { - expect(() => - render( - - <>hello - , - { unstable_validateStringsRenderedWithinText: true }, - ), - ).not.toThrow(); -}); - -test('should not throw if option validateRenderedString is false', () => { - expect(() => render(hello)).not.toThrow(); -}); - -test(`should throw when one of the children is a text and the parent is not a Text component`, () => { - expect(() => - render( - - hello - hello - , - { unstable_validateStringsRenderedWithinText: true }, - ), - ).toThrow( - `${VALIDATION_ERROR}. Detected attempt to render "hello" string within a component.`, - ); -}); - -test(`should throw when a string is rendered within a fragment rendered outside a Text`, () => { - expect(() => - render( - - <>hello - , - { unstable_validateStringsRenderedWithinText: true }, - ), - ).toThrow( - `${VALIDATION_ERROR}. Detected attempt to render "hello" string within a component.`, - ); -}); - -test('should throw if a number is rendered outside a text', () => { - expect(() => - render(0, { unstable_validateStringsRenderedWithinText: true }), - ).toThrow( - `${VALIDATION_ERROR}. Detected attempt to render "0" string within a component.`, - ); -}); - -const Trans = ({ i18nKey }: { i18nKey: string }) => <>{i18nKey}; - -test('should throw with components returning string value not rendered in Text', () => { - expect(() => - render( - - - , - { unstable_validateStringsRenderedWithinText: true }, - ), - ).toThrow( - `${VALIDATION_ERROR}. Detected attempt to render "hello" string within a component.`, - ); -}); - -test('should not throw with components returning string value rendered in Text', () => { - expect(() => - render( - - - , - { unstable_validateStringsRenderedWithinText: true }, - ), - ).not.toThrow(); -}); - -test('should throw when rendering string in a View in a Text', () => { - expect(() => - render( - - hello - , - { unstable_validateStringsRenderedWithinText: true }, - ), - ).toThrow( - `${VALIDATION_ERROR}. Detected attempt to render "hello" string within a component.`, - ); -}); - -const UseEffectComponent = () => { - const [showText, setShowText] = React.useState(false); - - React.useEffect(() => { - setShowText(true); - }, []); - - if (!showText) { - return Text is hidden; - } - - return ( - - Text is visible - - ); -}; - -test('should render immediate setState in useEffect properly', async () => { - render(, { unstable_validateStringsRenderedWithinText: true }); - - expect(await screen.findByText('Text is visible')).toBeTruthy(); -}); - -const InvalidUseEffectComponent = () => { - const [showText, setShowText] = React.useState(false); - - React.useEffect(() => { - setShowText(true); - }, []); - - if (!showText) { - return Text is hidden; - } - - return Text is visible; -}; - -test('should throw properly for immediate setState in useEffect', () => { - expect(() => - render(, { unstable_validateStringsRenderedWithinText: true }), - ).toThrow( - `${VALIDATION_ERROR}. Detected attempt to render "Text is visible" string within a component.`, - ); -}); diff --git a/src/__tests__/render.test.tsx b/src/__tests__/render.test.tsx index 48151662b..cef099f9d 100644 --- a/src/__tests__/render.test.tsx +++ b/src/__tests__/render.test.tsx @@ -1,25 +1,7 @@ import * as React from 'react'; -import { Pressable, Text, TextInput, View } from 'react-native'; +import { Text, View } from 'react-native'; -import type { RenderAPI } from '..'; -import { fireEvent, render, screen } from '..'; - -const PLACEHOLDER_FRESHNESS = 'Add custom freshness'; -const PLACEHOLDER_CHEF = 'Who inspected freshness?'; -const INPUT_FRESHNESS = 'Custom Freshie'; -const INPUT_CHEF = 'I inspected freshie'; -const DEFAULT_INPUT_CHEF = 'What did you inspect?'; -const DEFAULT_INPUT_CUSTOMER = 'What banana?'; - -class MyButton extends React.Component { - render() { - return ( - - {this.props.children} - - ); - } -} +import { render, screen } from '..'; class Banana extends React.Component { state = { @@ -45,228 +27,58 @@ class Banana extends React.Component { }; render() { - const test = 0; return ( Is the banana fresh? {this.state.fresh ? 'fresh' : 'not fresh'} - - - - - - Change freshness! - - First Text - Second Text - {test} ); } } -test('UNSAFE_getAllByType, UNSAFE_queryAllByType', () => { - render(); - const [text, status, button] = screen.UNSAFE_getAllByType(Text); - const InExistent = () => null; - - expect(text.props.children).toBe('Is the banana fresh?'); - expect(status.props.children).toBe('not fresh'); - expect(button.props.children).toBe('Change freshness!'); - expect(() => screen.UNSAFE_getAllByType(InExistent)).toThrow('No instances found'); - - expect(screen.UNSAFE_queryAllByType(Text)[1]).toBe(status); - expect(screen.UNSAFE_queryAllByType(InExistent)).toHaveLength(0); -}); - -test('UNSAFE_getByProps, UNSAFE_queryByProps', () => { - render(); - const primaryType = screen.UNSAFE_getByProps({ type: 'primary' }); - - expect(primaryType.props.children).toBe('Change freshness!'); - expect(() => screen.UNSAFE_getByProps({ type: 'inexistent' })).toThrow('No instances found'); - - expect(screen.UNSAFE_queryByProps({ type: 'primary' })).toBe(primaryType); - expect(screen.UNSAFE_queryByProps({ type: 'inexistent' })).toBeNull(); -}); - -test('UNSAFE_getAllByProp, UNSAFE_queryAllByProps', () => { - render(); - const primaryTypes = screen.UNSAFE_getAllByProps({ type: 'primary' }); - - expect(primaryTypes).toHaveLength(1); - expect(() => screen.UNSAFE_getAllByProps({ type: 'inexistent' })).toThrow('No instances found'); - - expect(screen.UNSAFE_queryAllByProps({ type: 'primary' })).toEqual(primaryTypes); - expect(screen.UNSAFE_queryAllByProps({ type: 'inexistent' })).toHaveLength(0); -}); - -test('rerender', () => { - const fn = jest.fn(); - render(); - expect(fn).toHaveBeenCalledTimes(0); - - fireEvent.press(screen.getByText('Change freshness!')); - expect(fn).toHaveBeenCalledTimes(1); - - screen.rerender(); - expect(fn).toHaveBeenCalledTimes(2); -}); - -test('unmount', () => { - const fn = jest.fn(); - render(); - screen.unmount(); - expect(fn).toHaveBeenCalled(); -}); - -test('unmount should handle cleanup functions', () => { - const cleanup = jest.fn(); - const Component = () => { - React.useEffect(() => cleanup); - return null; - }; - - render(); - - screen.unmount(); - - expect(cleanup).toHaveBeenCalledTimes(1); -}); - -test('toJSON renders host output', () => { - render(press me); - expect(screen.toJSON()).toMatchSnapshot(); -}); - -test('renders options.wrapper around node', () => { - type WrapperComponentProps = { children: React.ReactNode }; - const WrapperComponent = ({ children }: WrapperComponentProps) => ( - {children} - ); - - render(, { - wrapper: WrapperComponent, - }); - - expect(screen.getByTestId('wrapper')).toBeTruthy(); - expect(screen.toJSON()).toMatchInlineSnapshot(` - - - - `); +test('render renders component asynchronously', async () => { + await render(); + expect(screen.getByTestId('test')).toBeOnTheScreen(); }); -test('renders options.wrapper around updated node', () => { - type WrapperComponentProps = { children: React.ReactNode }; - const WrapperComponent = ({ children }: WrapperComponentProps) => ( +test('render with wrapper option', async () => { + const WrapperComponent = ({ children }: { children: React.ReactNode }) => ( {children} ); - render(, { + await render(, { wrapper: WrapperComponent, }); - screen.rerender(); - expect(screen.getByTestId('wrapper')).toBeTruthy(); - expect(screen.toJSON()).toMatchInlineSnapshot(` - - - - `); -}); - -test('returns host root', () => { - render(); - - expect(screen.root).toBeDefined(); - expect(screen.root.type).toBe('View'); - expect(screen.root.props.testID).toBe('inner'); -}); - -test('returns composite UNSAFE_root', () => { - render(); - - expect(screen.UNSAFE_root).toBeDefined(); - expect(screen.UNSAFE_root.type).toBe(View); - expect(screen.UNSAFE_root.props.testID).toBe('inner'); -}); - -test('container displays deprecation', () => { - render(); - - expect(() => (screen as any).container).toThrowErrorMatchingInlineSnapshot(` - "'container' property has been renamed to 'UNSAFE_root'. - - Consider using 'root' property which returns root host element." - `); + expect(screen.getByTestId('inner')).toBeTruthy(); }); -test('RenderAPI type', () => { - render() as RenderAPI; - expect(true).toBeTruthy(); -}); - -test('returned output can be spread using rest operator', () => { - // Next line should not throw - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { rerender, ...rest } = render(); - expect(rest).toBeTruthy(); -}); - -test('supports legacy rendering', () => { - render(, { concurrentRoot: false }); - expect(screen.root).toBeOnTheScreen(); -}); - -test('supports concurrent rendering', () => { - render(, { concurrentRoot: true }); - expect(screen.root).toBeOnTheScreen(); -}); - -test('rerenderAsync updates the component asynchronously', async () => { +test('rerender function updates component asynchronously', async () => { const fn = jest.fn(); - const result = render(); - - await result.rerenderAsync(); + await render(); + expect(fn).toHaveBeenCalledTimes(0); + await screen.rerender(); expect(fn).toHaveBeenCalledTimes(1); }); -test('updateAsync is an alias for rerenderAsync', async () => { +test('unmount function unmounts component asynchronously', async () => { const fn = jest.fn(); - const result = render(); - - await result.updateAsync(); + await render(); - expect(fn).toHaveBeenCalledTimes(1); + await screen.unmount(); + expect(fn).toHaveBeenCalled(); }); -test('unmountAsync unmounts the component asynchronously', async () => { - const fn = jest.fn(); - const result = render(); - - await result.unmountAsync(); +test('render accepts RCTText component', async () => { + await render(React.createElement('RCTText', { testID: 'text' }, 'Hello')); + expect(screen.getByTestId('text')).toBeOnTheScreen(); + expect(screen.getByText('Hello')).toBeOnTheScreen(); +}); - expect(fn).toHaveBeenCalled(); +test('render throws when text string is rendered without Text component', async () => { + await expect(render(Hello)).rejects.toThrowErrorMatchingInlineSnapshot( + `"Invariant Violation: Text strings must be rendered within a component. Detected attempt to render "Hello" string within a component."`, + ); }); diff --git a/src/__tests__/screen.test.tsx b/src/__tests__/screen.test.tsx index de5d72c23..59b95bf4b 100644 --- a/src/__tests__/screen.test.tsx +++ b/src/__tests__/screen.test.tsx @@ -3,8 +3,8 @@ import { Text, View } from 'react-native'; import { render, screen } from '..'; -test('screen has the same queries as render result', () => { - const result = render(Mt. Everest); +test('screen has the same queries as render result', async () => { + const result = await render(Mt. Everest); expect(screen).toBe(result); expect(screen.getByText('Mt. Everest')).toBeTruthy(); @@ -13,10 +13,10 @@ test('screen has the same queries as render result', () => { expect(screen.queryAllByText('Mt. Everest')).toHaveLength(1); }); -test('screen holds last render result', () => { - render(Mt. Everest); - render(Mt. Blanc); - const finalResult = render(Śnieżka); +test('screen holds last render result', async () => { + await render(Mt. Everest); + await render(Mt. Blanc); + const finalResult = await render(Śnieżka); expect(screen).toBe(finalResult); expect(screen.getByText('Śnieżka')).toBeTruthy(); @@ -24,24 +24,24 @@ test('screen holds last render result', () => { expect(screen.queryByText('Mt. Blanc')).toBeFalsy(); }); -test('screen works with updating rerender', () => { - const result = render(Mt. Everest); +test('screen works with updating rerender', async () => { + const result = await render(Mt. Everest); expect(screen).toBe(result); - screen.rerender(Śnieżka); + await screen.rerender(Śnieżka); expect(screen).toBe(result); expect(screen.getByText('Śnieżka')).toBeTruthy(); }); -test('screen works with nested re-mounting rerender', () => { - const result = render( +test('screen works with nested re-mounting rerender', async () => { + const result = await render( Mt. Everest , ); expect(screen).toBe(result); - screen.rerender( + await screen.rerender( Śnieżka @@ -53,8 +53,8 @@ test('screen works with nested re-mounting rerender', () => { }); test('screen throws without render', () => { - expect(() => screen.root).toThrow('`render` method has not been called'); - expect(() => screen.UNSAFE_root).toThrow('`render` method has not been called'); - expect(() => screen.debug()).toThrow('`render` method has not been called'); - expect(() => screen.getByText('Mt. Everest')).toThrow('`render` method has not been called'); + expect(() => screen.container).toThrow('`render` function has not been called'); + expect(() => screen.root).toThrow('`render` function has not been called'); + expect(() => screen.debug()).toThrow('`render` function has not been called'); + expect(() => screen.getByText('Mt. Everest')).toThrow('`render` function has not been called'); }); diff --git a/src/__tests__/suspense-fake-timers.test.tsx b/src/__tests__/suspense-fake-timers.test.tsx index a3ac0c07f..495971337 100644 --- a/src/__tests__/suspense-fake-timers.test.tsx +++ b/src/__tests__/suspense-fake-timers.test.tsx @@ -1,13 +1,11 @@ import * as React from 'react'; import { Text, View } from 'react-native'; -import { act, renderAsync, screen } from '..'; +import { act, render, screen } from '..'; import { excludeConsoleMessage } from '../test-utils/console'; jest.useFakeTimers(); -const testGateReact19 = React.version.startsWith('19.') ? test : test.skip; - // eslint-disable-next-line no-console const originalConsoleError = console.error; afterEach(() => { @@ -22,13 +20,13 @@ function Suspending({ promise, testID }: { promise: Promise; testID: st return ; } -testGateReact19('resolves manually-controlled promise', async () => { +test('resolves manually-controlled promise', async () => { let resolvePromise: (value: unknown) => void; const promise = new Promise((resolve) => { resolvePromise = resolve; }); - await renderAsync( + await render( Loading...}> @@ -47,12 +45,12 @@ testGateReact19('resolves manually-controlled promise', async () => { expect(screen.queryByText('Loading...')).not.toBeOnTheScreen(); }); -testGateReact19('resolves timer-controlled promise', async () => { +test('resolves timer-controlled promise', async () => { const promise = new Promise((resolve) => { setTimeout(() => resolve(null), 100); }); - await renderAsync( + await render( Loading...}> @@ -88,7 +86,7 @@ class ErrorBoundary extends React.Component< } } -testGateReact19('handles promise rejection with error boundary', async () => { +test('handles promise rejection with error boundary', async () => { const ERROR_MESSAGE = 'Promise Rejected In Test'; // eslint-disable-next-line no-console console.error = excludeConsoleMessage(console.error, ERROR_MESSAGE); @@ -98,7 +96,7 @@ testGateReact19('handles promise rejection with error boundary', async () => { rejectPromise = reject; }); - await renderAsync( + await render( Error occurred}> Loading...}> @@ -117,7 +115,7 @@ testGateReact19('handles promise rejection with error boundary', async () => { expect(screen.queryByTestId('error-content')).not.toBeOnTheScreen(); }); -testGateReact19('handles multiple suspending components', async () => { +test('handles multiple suspending components', async () => { let resolvePromise1: (value: unknown) => void; let resolvePromise2: (value: unknown) => void; @@ -128,7 +126,7 @@ testGateReact19('handles multiple suspending components', async () => { resolvePromise2 = resolve; }); - await renderAsync( + await render( Loading...}> @@ -154,7 +152,7 @@ testGateReact19('handles multiple suspending components', async () => { expect(screen.queryByText('Loading...')).not.toBeOnTheScreen(); }); -testGateReact19('handles multiple suspense boundaries independently', async () => { +test('handles multiple suspense boundaries independently', async () => { let resolvePromise1: (value: unknown) => void; let resolvePromise2: (value: unknown) => void; @@ -165,7 +163,7 @@ testGateReact19('handles multiple suspense boundaries independently', async () = resolvePromise2 = resolve; }); - await renderAsync( + await render( First Loading...}> diff --git a/src/__tests__/suspense.test.tsx b/src/__tests__/suspense.test.tsx index ac794c7a6..eded69052 100644 --- a/src/__tests__/suspense.test.tsx +++ b/src/__tests__/suspense.test.tsx @@ -1,11 +1,9 @@ import * as React from 'react'; import { Text, View } from 'react-native'; -import { act, renderAsync, screen } from '..'; +import { act, render, screen } from '..'; import { excludeConsoleMessage } from '../test-utils/console'; -const testGateReact19 = React.version.startsWith('19.') ? test : test.skip; - // eslint-disable-next-line no-console const originalConsoleError = console.error; afterEach(() => { @@ -20,13 +18,13 @@ function Suspending({ promise, testID }: { promise: Promise; testID: st return ; } -testGateReact19('resolves manually-controlled promise', async () => { +test('resolves manually-controlled promise', async () => { let resolvePromise: (value: unknown) => void; const promise = new Promise((resolve) => { resolvePromise = resolve; }); - await renderAsync( + await render( Loading...}> @@ -45,12 +43,12 @@ testGateReact19('resolves manually-controlled promise', async () => { expect(screen.queryByText('Loading...')).not.toBeOnTheScreen(); }); -testGateReact19('resolves timer-controlled promise', async () => { +test('resolves timer-controlled promise', async () => { const promise = new Promise((resolve) => { setTimeout(() => resolve(null), 100); }); - await renderAsync( + await render( Loading...}> @@ -85,7 +83,7 @@ class ErrorBoundary extends React.Component< } } -testGateReact19('handles promise rejection with error boundary', async () => { +test('handles promise rejection with error boundary', async () => { const ERROR_MESSAGE = 'Promise Rejected In Test'; // eslint-disable-next-line no-console console.error = excludeConsoleMessage(console.error, ERROR_MESSAGE); @@ -95,7 +93,7 @@ testGateReact19('handles promise rejection with error boundary', async () => { rejectPromise = reject; }); - await renderAsync( + await render( Error occurred}> Loading...}> @@ -114,7 +112,7 @@ testGateReact19('handles promise rejection with error boundary', async () => { expect(screen.queryByTestId('error-content')).not.toBeOnTheScreen(); }); -testGateReact19('handles multiple suspending components', async () => { +test('handles multiple suspending components', async () => { let resolvePromise1: (value: unknown) => void; let resolvePromise2: (value: unknown) => void; @@ -125,7 +123,7 @@ testGateReact19('handles multiple suspending components', async () => { resolvePromise2 = resolve; }); - await renderAsync( + await render( Loading...}> @@ -151,7 +149,7 @@ testGateReact19('handles multiple suspending components', async () => { expect(screen.queryByText('Loading...')).not.toBeOnTheScreen(); }); -testGateReact19('handles multiple suspense boundaries independently', async () => { +test('handles multiple suspense boundaries independently', async () => { let resolvePromise1: (value: unknown) => void; let resolvePromise2: (value: unknown) => void; @@ -162,7 +160,7 @@ testGateReact19('handles multiple suspense boundaries independently', async () = resolvePromise2 = resolve; }); - await renderAsync( + await render( First Loading...}> diff --git a/src/__tests__/timers.test.ts b/src/__tests__/timers.test.ts index acb9ec4c5..2c3a40a75 100644 --- a/src/__tests__/timers.test.ts +++ b/src/__tests__/timers.test.ts @@ -1,4 +1,4 @@ -import waitFor from '../wait-for'; +import { waitFor } from '../wait-for'; describe.each([false, true])('fake timers tests (legacyFakeTimers = %s)', (legacyFakeTimers) => { beforeEach(() => { diff --git a/src/__tests__/unsafe-fire-event-sync.test.tsx b/src/__tests__/unsafe-fire-event-sync.test.tsx new file mode 100644 index 000000000..7a696ad2a --- /dev/null +++ b/src/__tests__/unsafe-fire-event-sync.test.tsx @@ -0,0 +1,581 @@ +import * as React from 'react'; +import { + PanResponder, + Pressable, + ScrollView, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; + +import { screen, unsafe_fireEventSync, unsafe_renderSync } from '..'; + +type OnPressComponentProps = { + onPress: () => void; + text: string; +}; +const OnPressComponent = ({ onPress, text }: OnPressComponentProps) => ( + + + {text} + + +); + +type CustomEventComponentProps = { + onCustomEvent: () => void; +}; +const CustomEventComponent = ({ onCustomEvent }: CustomEventComponentProps) => ( + + Custom event component + +); + +type MyCustomButtonProps = { + handlePress: () => void; + text: string; +}; + +const MyCustomButton = ({ handlePress, text }: MyCustomButtonProps) => ( + +); + +type CustomEventComponentWithCustomNameProps = { + handlePress: () => void; +}; + +const CustomEventComponentWithCustomName = ({ + handlePress, +}: CustomEventComponentWithCustomNameProps) => ( + +); + +describe('unsafe_fireEventSync', () => { + test('should invoke specified event', () => { + const onPressMock = jest.fn(); + unsafe_renderSync(); + + unsafe_fireEventSync(screen.getByText('Press me'), 'press'); + + expect(onPressMock).toHaveBeenCalled(); + }); + + test('should invoke specified event on parent element', () => { + const onPressMock = jest.fn(); + const text = 'New press text'; + unsafe_renderSync(); + + unsafe_fireEventSync(screen.getByText(text), 'press'); + expect(onPressMock).toHaveBeenCalled(); + }); + + test('should invoke event with custom name', () => { + const handlerMock = jest.fn(); + const EVENT_DATA = 'event data'; + + unsafe_renderSync( + + + , + ); + + unsafe_fireEventSync(screen.getByText('Custom event component'), 'customEvent', EVENT_DATA); + + expect(handlerMock).toHaveBeenCalledWith(EVENT_DATA); + }); +}); + +test('unsafe_fireEventSync.press', () => { + const onPressMock = jest.fn(); + const text = 'Fireevent press'; + const eventData = { + nativeEvent: { + pageX: 20, + pageY: 30, + }, + }; + unsafe_renderSync(); + + unsafe_fireEventSync.press(screen.getByText(text), eventData); + + expect(onPressMock).toHaveBeenCalledWith(eventData); +}); + +test('unsafe_fireEventSync.scroll', () => { + const onScrollMock = jest.fn(); + const eventData = { + nativeEvent: { + contentOffset: { + y: 200, + }, + }, + }; + + unsafe_renderSync( + + XD + , + ); + + unsafe_fireEventSync.scroll(screen.getByText('XD'), eventData); + + expect(onScrollMock).toHaveBeenCalledWith(eventData); +}); + +test('unsafe_fireEventSync.changeText', () => { + const onChangeTextMock = jest.fn(); + + unsafe_renderSync( + + + , + ); + + const input = screen.getByPlaceholderText('Customer placeholder'); + unsafe_fireEventSync.changeText(input, 'content'); + expect(onChangeTextMock).toHaveBeenCalledWith('content'); +}); + +it('sets native state value for unmanaged text inputs', () => { + unsafe_renderSync(); + + const input = screen.getByTestId('input'); + expect(input).toHaveDisplayValue(''); + + unsafe_fireEventSync.changeText(input, 'abc'); + expect(input).toHaveDisplayValue('abc'); +}); + +test('custom component with custom event name', () => { + const handlePress = jest.fn(); + + unsafe_renderSync(); + + unsafe_fireEventSync(screen.getByText('Custom component'), 'handlePress'); + + expect(handlePress).toHaveBeenCalled(); +}); + +test('event with multiple handler parameters', () => { + const handlePress = jest.fn(); + + unsafe_renderSync(); + + unsafe_fireEventSync(screen.getByText('Custom component'), 'handlePress', 'param1', 'param2'); + + expect(handlePress).toHaveBeenCalledWith('param1', 'param2'); +}); + +test('should not fire on disabled TouchableOpacity', () => { + const handlePress = jest.fn(); + unsafe_renderSync( + + + Trigger + + , + ); + + unsafe_fireEventSync.press(screen.getByText('Trigger')); + expect(handlePress).not.toHaveBeenCalled(); +}); + +test('should not fire on disabled Pressable', () => { + const handlePress = jest.fn(); + unsafe_renderSync( + + + Trigger + + , + ); + + unsafe_fireEventSync.press(screen.getByText('Trigger')); + expect(handlePress).not.toHaveBeenCalled(); +}); + +test('should not fire inside View with pointerEvents="none" in props', () => { + const onPress = jest.fn(); + unsafe_renderSync( + + + Trigger + + , + ); + + unsafe_fireEventSync.press(screen.getByText('Trigger')); + unsafe_fireEventSync(screen.getByText('Trigger'), 'onPress'); + expect(onPress).not.toHaveBeenCalled(); +}); + +test('should not fire inside View with pointerEvents="none" in styles', () => { + const onPress = jest.fn(); + unsafe_renderSync( + + + Trigger + + , + ); + + unsafe_fireEventSync.press(screen.getByText('Trigger')); + unsafe_fireEventSync(screen.getByText('Trigger'), 'onPress'); + expect(onPress).not.toHaveBeenCalled(); +}); + +test('should not fire inside View with pointerEvents="none" in styles array', () => { + const onPress = jest.fn(); + unsafe_renderSync( + + + Trigger + + , + ); + + unsafe_fireEventSync.press(screen.getByText('Trigger')); + unsafe_fireEventSync(screen.getByText('Trigger'), 'onPress'); + expect(onPress).not.toHaveBeenCalled(); +}); + +test('should not fire inside View with pointerEvents="box-only" in props', () => { + const onPress = jest.fn(); + unsafe_renderSync( + + + Trigger + + , + ); + + unsafe_fireEventSync.press(screen.getByText('Trigger')); + unsafe_fireEventSync(screen.getByText('Trigger'), 'onPress'); + expect(onPress).not.toHaveBeenCalled(); +}); + +test('should not fire inside View with pointerEvents="box-only" in styles', () => { + const onPress = jest.fn(); + unsafe_renderSync( + + + Trigger + + , + ); + + unsafe_fireEventSync.press(screen.getByText('Trigger')); + unsafe_fireEventSync(screen.getByText('Trigger'), 'onPress'); + expect(onPress).not.toHaveBeenCalled(); +}); + +test('should fire inside View with pointerEvents="box-none" in props', () => { + const onPress = jest.fn(); + unsafe_renderSync( + + + Trigger + + , + ); + + unsafe_fireEventSync.press(screen.getByText('Trigger')); + unsafe_fireEventSync(screen.getByText('Trigger'), 'onPress'); + expect(onPress).toHaveBeenCalledTimes(2); +}); + +test('should fire inside View with pointerEvents="box-none" in styles', () => { + const onPress = jest.fn(); + unsafe_renderSync( + + + Trigger + + , + ); + + unsafe_fireEventSync.press(screen.getByText('Trigger')); + unsafe_fireEventSync(screen.getByText('Trigger'), 'onPress'); + expect(onPress).toHaveBeenCalledTimes(2); +}); + +test('should fire inside View with pointerEvents="auto" in props', () => { + const onPress = jest.fn(); + unsafe_renderSync( + + + Trigger + + , + ); + + unsafe_fireEventSync.press(screen.getByText('Trigger')); + unsafe_fireEventSync(screen.getByText('Trigger'), 'onPress'); + expect(onPress).toHaveBeenCalledTimes(2); +}); + +test('should fire inside View with pointerEvents="auto" in styles', () => { + const onPress = jest.fn(); + unsafe_renderSync( + + + Trigger + + , + ); + + unsafe_fireEventSync.press(screen.getByText('Trigger')); + unsafe_fireEventSync(screen.getByText('Trigger'), 'onPress'); + expect(onPress).toHaveBeenCalledTimes(2); +}); + +test('should not fire deeply inside View with pointerEvents="box-only" in props', () => { + const onPress = jest.fn(); + unsafe_renderSync( + + + + Trigger + + + , + ); + + unsafe_fireEventSync.press(screen.getByText('Trigger')); + unsafe_fireEventSync(screen.getByText('Trigger'), 'onPress'); + expect(onPress).not.toHaveBeenCalled(); +}); + +test('should not fire deeply inside View with pointerEvents="box-only" in styles', () => { + const onPress = jest.fn(); + unsafe_renderSync( + + + + Trigger + + + , + ); + + unsafe_fireEventSync.press(screen.getByText('Trigger')); + unsafe_fireEventSync(screen.getByText('Trigger'), 'onPress'); + expect(onPress).not.toHaveBeenCalled(); +}); + +test('should fire non-pointer events inside View with pointerEvents="box-none" in props', () => { + const onTouchStart = jest.fn(); + unsafe_renderSync(); + + unsafe_fireEventSync(screen.getByTestId('view'), 'touchStart'); + expect(onTouchStart).toHaveBeenCalled(); +}); + +test('should fire non-pointer events inside View with pointerEvents="box-none" in styles', () => { + const onTouchStart = jest.fn(); + unsafe_renderSync( + , + ); + + unsafe_fireEventSync(screen.getByTestId('view'), 'touchStart'); + expect(onTouchStart).toHaveBeenCalled(); +}); + +test('should fire non-touch events inside View with pointerEvents="box-none" in props', () => { + const onLayout = jest.fn(); + unsafe_renderSync(); + + unsafe_fireEventSync(screen.getByTestId('view'), 'layout'); + expect(onLayout).toHaveBeenCalled(); +}); + +test('should fire non-touch events inside View with pointerEvents="box-none" in styles', () => { + const onLayout = jest.fn(); + unsafe_renderSync( + , + ); + + unsafe_fireEventSync(screen.getByTestId('view'), 'layout'); + expect(onLayout).toHaveBeenCalled(); +}); + +// This test if pointerEvents="box-only" on composite `Pressable` is blocking +// the 'press' event on host View rendered by pressable. +test('should fire on Pressable with pointerEvents="box-only" in props', () => { + const onPress = jest.fn(); + unsafe_renderSync(); + + unsafe_fireEventSync.press(screen.getByTestId('pressable')); + expect(onPress).toHaveBeenCalled(); +}); + +test('should fire on Pressable with pointerEvents="box-only" in styles', () => { + const onPress = jest.fn(); + unsafe_renderSync( + , + ); + + unsafe_fireEventSync.press(screen.getByTestId('pressable')); + expect(onPress).toHaveBeenCalled(); +}); + +test('should pass event up on disabled TouchableOpacity', () => { + const handleInnerPress = jest.fn(); + const handleOuterPress = jest.fn(); + unsafe_renderSync( + + + Inner Trigger + + , + ); + + unsafe_fireEventSync.press(screen.getByText('Inner Trigger')); + expect(handleInnerPress).not.toHaveBeenCalled(); + expect(handleOuterPress).toHaveBeenCalledTimes(1); +}); + +test('should pass event up on disabled Pressable', () => { + const handleInnerPress = jest.fn(); + const handleOuterPress = jest.fn(); + unsafe_renderSync( + + + Inner Trigger + + , + ); + + unsafe_fireEventSync.press(screen.getByText('Inner Trigger')); + expect(handleInnerPress).not.toHaveBeenCalled(); + expect(handleOuterPress).toHaveBeenCalledTimes(1); +}); + +type TestComponentProps = { + onPress: () => void; + disabled?: boolean; +}; +const TestComponent = ({ onPress }: TestComponentProps) => { + return ( + + Trigger Test + + ); +}; + +test('is not fooled by non-native disabled prop', () => { + const handlePress = jest.fn(); + unsafe_renderSync(); + + unsafe_fireEventSync.press(screen.getByText('Trigger Test')); + expect(handlePress).toHaveBeenCalledTimes(1); +}); + +type TestChildTouchableComponentProps = { + onPress: () => void; + someProp: boolean; +}; + +function TestChildTouchableComponent({ onPress, someProp }: TestChildTouchableComponentProps) { + return ( + + + Trigger + + + ); +} + +test('is not fooled by non-responder wrapping host elements', () => { + const handlePress = jest.fn(); + + unsafe_renderSync( + + + , + ); + + unsafe_fireEventSync.press(screen.getByText('Trigger')); + expect(handlePress).not.toHaveBeenCalled(); +}); + +type TestDraggableComponentProps = { onDrag: () => void }; + +function TestDraggableComponent({ onDrag }: TestDraggableComponentProps) { + const responderHandlers = PanResponder.create({ + onMoveShouldSetPanResponder: (_evt, _gestureState) => true, + onPanResponderMove: onDrag, + }).panHandlers; + + return ( + + Trigger + + ); +} + +test('has only onMove', () => { + const handleDrag = jest.fn(); + + unsafe_renderSync(); + + unsafe_fireEventSync(screen.getByText('Trigger'), 'responderMove', { + touchHistory: { mostRecentTimeStamp: '2', touchBank: [] }, + }); + expect(handleDrag).toHaveBeenCalled(); +}); + +// Those events ideally should be triggered through `unsafe_fireEventSync.scroll`, but they are handled at the +// native level, so we need to support manually triggering them +describe('native events', () => { + test('triggers onScrollBeginDrag', () => { + const onScrollBeginDragSpy = jest.fn(); + unsafe_renderSync(); + + unsafe_fireEventSync(screen.getByTestId('test-id'), 'onScrollBeginDrag'); + expect(onScrollBeginDragSpy).toHaveBeenCalled(); + }); + + test('triggers onScrollEndDrag', () => { + const onScrollEndDragSpy = jest.fn(); + unsafe_renderSync(); + + unsafe_fireEventSync(screen.getByTestId('test-id'), 'onScrollEndDrag'); + expect(onScrollEndDragSpy).toHaveBeenCalled(); + }); + + test('triggers onMomentumScrollBegin', () => { + const onMomentumScrollBeginSpy = jest.fn(); + unsafe_renderSync( + , + ); + + unsafe_fireEventSync(screen.getByTestId('test-id'), 'onMomentumScrollBegin'); + expect(onMomentumScrollBeginSpy).toHaveBeenCalled(); + }); + + test('triggers onMomentumScrollEnd', () => { + const onMomentumScrollEndSpy = jest.fn(); + unsafe_renderSync(); + + unsafe_fireEventSync(screen.getByTestId('test-id'), 'onMomentumScrollEnd'); + expect(onMomentumScrollEndSpy).toHaveBeenCalled(); + }); +}); + +test('should handle unmounted elements gracefully', () => { + const onPress = jest.fn(); + const { rerender } = unsafe_renderSync( + + Test + , + ); + + const element = screen.getByText('Test'); + rerender(); + + // Firing event on unmounted element should not crash + unsafe_fireEventSync.press(element); + expect(onPress).not.toHaveBeenCalled(); +}); diff --git a/src/__tests__/unsafe-render-hook-sync.test.tsx b/src/__tests__/unsafe-render-hook-sync.test.tsx new file mode 100644 index 000000000..fd95f491d --- /dev/null +++ b/src/__tests__/unsafe-render-hook-sync.test.tsx @@ -0,0 +1,135 @@ +import type { ReactNode } from 'react'; +import * as React from 'react'; + +import { unsafe_renderHookSync } from '../pure'; + +test('renders hook and returns committed result', () => { + const { result } = unsafe_renderHookSync(() => { + const [state, setState] = React.useState(1); + + React.useEffect(() => { + setState(2); + }, []); + + return [state, setState]; + }); + + expect(result.current).toEqual([2, expect.any(Function)]); +}); + +test('works with wrapper option', () => { + const Context = React.createContext('default'); + function Wrapper({ children }: { children: ReactNode }) { + return {children}; + } + const { result } = unsafe_renderHookSync( + () => { + return React.useContext(Context); + }, + { + wrapper: Wrapper, + }, + ); + + expect(result.current).toEqual('provided'); +}); + +test('works with initialProps option', () => { + const { result } = unsafe_renderHookSync( + (props: { branch: 'left' | 'right' }) => { + const [left, setLeft] = React.useState('left'); + const [right, setRight] = React.useState('right'); + + switch (props.branch) { + case 'left': + return [left, setLeft]; + case 'right': + return [right, setRight]; + default: + throw new Error('No Props passed. This is a bug in the implementation'); + } + }, + { initialProps: { branch: 'left' } }, + ); + + expect(result.current).toEqual(['left', expect.any(Function)]); +}); + +test('works without initialProps option', () => { + function useTestHook() { + const [count, setCount] = React.useState(0); + return { count, setCount }; + } + + const { result } = unsafe_renderHookSync(useTestHook); + expect(result.current.count).toBe(0); +}); + +test('rerender updates hook with new props', () => { + const { result, rerender } = unsafe_renderHookSync( + (props: { branch: 'left' | 'right' }) => { + const [left, setLeft] = React.useState('left'); + const [right, setRight] = React.useState('right'); + + switch (props.branch) { + case 'left': + return [left, setLeft]; + case 'right': + return [right, setRight]; + default: + throw new Error('No Props passed. This is a bug in the implementation'); + } + }, + { initialProps: { branch: 'left' } }, + ); + + expect(result.current).toEqual(['left', expect.any(Function)]); + + rerender({ branch: 'right' }); + expect(result.current).toEqual(['right', expect.any(Function)]); +}); + +test('unmount triggers cleanup effects', () => { + let cleanupCalled = false; + + function useTestHook() { + React.useEffect(() => { + return () => { + cleanupCalled = true; + }; + }, []); + + return 'test'; + } + + const { unmount } = unsafe_renderHookSync(useTestHook); + expect(cleanupCalled).toBe(false); + + unmount(); + expect(cleanupCalled).toBe(true); +}); + +function useMyHook(param: T) { + return { param }; +} + +test('props type is inferred correctly when initial props is defined', () => { + const { result, rerender } = unsafe_renderHookSync((num: number) => useMyHook(num), { + initialProps: 5, + }); + expect(result.current.param).toBe(5); + + rerender(6); + expect(result.current.param).toBe(6); +}); + +test('props type is inferred correctly when initial props is explicitly undefined', () => { + const { result, rerender } = unsafe_renderHookSync((num: number | undefined) => useMyHook(num), { + initialProps: undefined, + }); + + expect(result.current.param).toBeUndefined(); + + rerender(6); + expect(result.current.param).toBe(6); +}); diff --git a/src/__tests__/unsafe-render-sync.test.tsx b/src/__tests__/unsafe-render-sync.test.tsx new file mode 100644 index 000000000..38596dd94 --- /dev/null +++ b/src/__tests__/unsafe-render-sync.test.tsx @@ -0,0 +1,185 @@ +import * as React from 'react'; +import { Pressable, Text, TextInput, View } from 'react-native'; + +import { screen, unsafe_fireEventSync, unsafe_renderSync } from '..'; + +const PLACEHOLDER_FRESHNESS = 'Add custom freshness'; +const PLACEHOLDER_CHEF = 'Who inspected freshness?'; +const INPUT_FRESHNESS = 'Custom Freshie'; +const INPUT_CHEF = 'I inspected freshie'; +const DEFAULT_INPUT_CHEF = 'What did you inspect?'; +const DEFAULT_INPUT_CUSTOMER = 'What banana?'; + +class MyButton extends React.Component { + render() { + return ( + + {this.props.children} + + ); + } +} + +class Banana extends React.Component { + state = { + fresh: false, + }; + + componentDidUpdate() { + if (this.props.onUpdate) { + this.props.onUpdate(); + } + } + + componentWillUnmount() { + if (this.props.onUnmount) { + this.props.onUnmount(); + } + } + + changeFresh = () => { + this.setState((state) => ({ + fresh: !state.fresh, + })); + }; + + render() { + const test = 0; + return ( + + Is the banana fresh? + {this.state.fresh ? 'fresh' : 'not fresh'} + + + + + + Change freshness! + + First Text + Second Text + {test} + + ); + } +} + +test('supports basic rendering', () => { + unsafe_renderSync(); + expect(screen.root).toBeOnTheScreen(); +}); + +test('rerender', () => { + const fn = jest.fn(); + const { rerender } = unsafe_renderSync(); + expect(fn).toHaveBeenCalledTimes(0); + + unsafe_fireEventSync.press(screen.getByText('Change freshness!')); + expect(fn).toHaveBeenCalledTimes(1); + + rerender(); + expect(fn).toHaveBeenCalledTimes(2); +}); + +test('unmount', () => { + const fn = jest.fn(); + const { unmount } = unsafe_renderSync(); + unmount(); + expect(fn).toHaveBeenCalled(); +}); + +test('unmount should handle cleanup functions', () => { + const cleanup = jest.fn(); + const Component = () => { + React.useEffect(() => cleanup); + return null; + }; + + const { unmount } = unsafe_renderSync(); + + unmount(); + + expect(cleanup).toHaveBeenCalledTimes(1); +}); + +test('toJSON renders host output', () => { + unsafe_renderSync(press me); + expect(screen).toMatchSnapshot(); +}); + +test('renders options.wrapper around node', () => { + type WrapperComponentProps = { children: React.ReactNode }; + const WrapperComponent = ({ children }: WrapperComponentProps) => ( + {children} + ); + + unsafe_renderSync(, { + wrapper: WrapperComponent, + }); + + expect(screen.getByTestId('wrapper')).toBeTruthy(); + expect(screen).toMatchInlineSnapshot(` + + + + `); +}); + +test('renders options.wrapper around updated node', () => { + type WrapperComponentProps = { children: React.ReactNode }; + const WrapperComponent = ({ children }: WrapperComponentProps) => ( + {children} + ); + + unsafe_renderSync(, { + wrapper: WrapperComponent, + }); + + void screen.rerender(); + + expect(screen.getByTestId('wrapper')).toBeTruthy(); + expect(screen).toMatchInlineSnapshot(` + + + + `); +}); + +test('returns host root', () => { + unsafe_renderSync(); + + expect(screen.root).toBeDefined(); + expect(screen.root?.type).toBe('View'); + expect(screen.root?.props.testID).toBe('inner'); +}); + +test('RenderAPI type', () => { + // This test verifies that unsafe_renderSync returns a compatible type + // Note: unsafe_renderSync has different method signatures (sync vs async) + const result = unsafe_renderSync(); + expect(result).toBeTruthy(); +}); + +test('returned output can be spread using rest operator', () => { + // Next line should not throw + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { rerender, ...rest } = unsafe_renderSync(); + expect(rest).toBeTruthy(); +}); diff --git a/src/__tests__/wait-for-element-to-be-removed.test.tsx b/src/__tests__/wait-for-element-to-be-removed.test.tsx index 787b2bce0..b40ab679b 100644 --- a/src/__tests__/wait-for-element-to-be-removed.test.tsx +++ b/src/__tests__/wait-for-element-to-be-removed.test.tsx @@ -30,9 +30,9 @@ afterEach(() => { }); test('waits when using getBy query', async () => { - render(); + await render(); - fireEvent.press(screen.getByText('Remove Element')); + await fireEvent.press(screen.getByText('Remove Element')); const element = screen.getByText('Observed Element'); expect(element).toBeTruthy(); @@ -42,9 +42,9 @@ test('waits when using getBy query', async () => { }); test('waits when using getAllBy query', async () => { - render(); + await render(); - fireEvent.press(screen.getByText('Remove Element')); + await fireEvent.press(screen.getByText('Remove Element')); const elements = screen.getAllByText('Observed Element'); expect(elements).toBeTruthy(); @@ -54,9 +54,9 @@ test('waits when using getAllBy query', async () => { }); test('waits when using queryBy query', async () => { - render(); + await render(); - fireEvent.press(screen.getByText('Remove Element')); + await fireEvent.press(screen.getByText('Remove Element')); const element = screen.getByText('Observed Element'); expect(element).toBeTruthy(); @@ -66,9 +66,9 @@ test('waits when using queryBy query', async () => { }); test('waits when using queryAllBy query', async () => { - render(); + await render(); - fireEvent.press(screen.getByText('Remove Element')); + await fireEvent.press(screen.getByText('Remove Element')); const elements = screen.getAllByText('Observed Element'); expect(elements).toBeTruthy(); @@ -78,9 +78,9 @@ test('waits when using queryAllBy query', async () => { }); test('checks if elements exist at start', async () => { - render(); + await render(); - fireEvent.press(screen.getByText('Remove Element')); + await fireEvent.press(screen.getByText('Remove Element')); expect(screen.queryByText('Observed Element')).toBeNull(); await expect( @@ -91,9 +91,9 @@ test('checks if elements exist at start', async () => { }); test('waits until timeout', async () => { - render(); + await render(); - fireEvent.press(screen.getByText('Remove Element')); + await fireEvent.press(screen.getByText('Remove Element')); expect(screen.getByText('Observed Element')).toBeTruthy(); await expect( diff --git a/src/__tests__/wait-for.test.tsx b/src/__tests__/wait-for.test.tsx index 7568d2760..b9235b3e9 100644 --- a/src/__tests__/wait-for.test.tsx +++ b/src/__tests__/wait-for.test.tsx @@ -38,9 +38,9 @@ afterEach(() => { }); test('waits for element until it stops throwing', async () => { - render(); + await render(); - fireEvent.press(screen.getByText('Change freshness!')); + await fireEvent.press(screen.getByText('Change freshness!')); expect(screen.queryByText('Fresh')).toBeNull(); @@ -50,9 +50,9 @@ test('waits for element until it stops throwing', async () => { }); test('waits for element until timeout is met', async () => { - render(); + await render(); - fireEvent.press(screen.getByText('Change freshness!')); + await fireEvent.press(screen.getByText('Change freshness!')); await expect(waitFor(() => screen.getByText('Fresh'), { timeout: 100 })).rejects.toThrow(); @@ -63,9 +63,9 @@ test('waits for element until timeout is met', async () => { test('waitFor defaults to asyncWaitTimeout config option', async () => { configure({ asyncUtilTimeout: 100 }); - render(); + await render(); - fireEvent.press(screen.getByText('Change freshness!')); + await fireEvent.press(screen.getByText('Change freshness!')); await expect(waitFor(() => screen.getByText('Fresh'))).rejects.toThrow(); // Async action ends after 300ms and we only waited 100ms, so we need to wait @@ -75,9 +75,9 @@ test('waitFor defaults to asyncWaitTimeout config option', async () => { test('waitFor timeout option takes precendence over `asyncWaitTimeout` config option', async () => { configure({ asyncUtilTimeout: 2000 }); - render(); + await render(); - fireEvent.press(screen.getByText('Change freshness!')); + await fireEvent.press(screen.getByText('Change freshness!')); await expect(waitFor(() => screen.getByText('Fresh'), { timeout: 100 })).rejects.toThrow(); // Async action ends after 300ms and we only waited 100ms, so we need to wait @@ -125,9 +125,9 @@ const Comp = ({ onPress }: { onPress: () => void }) => { test('waits for async event with fireEvent', async () => { const spy = jest.fn(); - render(); + await render(); - fireEvent.press(screen.getByText('Trigger')); + await fireEvent.press(screen.getByText('Trigger')); await waitFor(() => { expect(spy).toHaveBeenCalled(); @@ -138,9 +138,9 @@ test.each([false, true])( 'waits for element until it stops throwing using fake timers (legacyFakeTimers = %s)', async (legacyFakeTimers) => { jest.useFakeTimers({ legacyFakeTimers }); - render(); + await render(); - fireEvent.press(screen.getByText('Change freshness!')); + await fireEvent.press(screen.getByText('Change freshness!')); expect(screen.queryByText('Fresh')).toBeNull(); jest.advanceTimersByTime(300); @@ -294,7 +294,7 @@ test.each([ } const onPress = jest.fn(); - render(); + await render(); // Required: this `waitFor` will succeed on first check, because the "root" view is there // since the initial mount. @@ -305,7 +305,7 @@ test.each([ await waitFor(() => screen.getByText('red')); // Check that the `onPress` callback is called with the already-updated value of `syncedColor`. - fireEvent.press(screen.getByText('Trigger')); + await fireEvent.press(screen.getByText('Trigger')); expect(onPress).toHaveBeenCalledWith('red'); }, ); diff --git a/src/__tests__/within.test.tsx b/src/__tests__/within.test.tsx index 105aa081d..bd6889d99 100644 --- a/src/__tests__/within.test.tsx +++ b/src/__tests__/within.test.tsx @@ -4,7 +4,7 @@ import { Text, TextInput, View } from 'react-native'; import { getQueriesForElement, render, within } from '..'; test('within() exposes basic queries', async () => { - const rootQueries = render( + const rootQueries = await render( Same Text @@ -41,7 +41,7 @@ test('within() exposes basic queries', async () => { }); test('within() exposes a11y queries', async () => { - const rootQueries = render( + const rootQueries = await render( (callback: () => T | Promise): Promise { + return unsafe_act(async () => await callback()); +} -export default act; export { getIsReactActEnvironment, setIsReactActEnvironment as setReactActEnvironment }; diff --git a/src/cleanup.ts b/src/cleanup.ts index f85524389..5b6f056c0 100644 --- a/src/cleanup.ts +++ b/src/cleanup.ts @@ -1,18 +1,10 @@ import { clearRenderResult } from './screen'; -type CleanUpFunction = () => void; -type CleanUpFunctionAsync = () => Promise; +type CleanUpFunction = () => Promise | void; -const cleanupQueue = new Set(); +const cleanupQueue = new Set(); -export default function cleanup() { - clearRenderResult(); - - cleanupQueue.forEach((fn) => fn()); - cleanupQueue.clear(); -} - -export async function cleanupAsync() { +export async function cleanup() { clearRenderResult(); for (const fn of cleanupQueue) { @@ -22,6 +14,6 @@ export async function cleanupAsync() { cleanupQueue.clear(); } -export function addToCleanupQueue(fn: CleanUpFunction | CleanUpFunctionAsync) { +export function addToCleanupQueue(fn: CleanUpFunction) { cleanupQueue.add(fn); } diff --git a/src/config.ts b/src/config.ts index e861d0eb1..121e33bc4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -13,12 +13,6 @@ export type Config = { /** Default options for `debug` helper. */ defaultDebugOptions?: Partial; - - /** - * Set to `false` to disable concurrent rendering. - * Otherwise `render` will default to concurrent rendering. - */ - concurrentRoot: boolean; }; export type ConfigAliasOptions = { @@ -29,7 +23,6 @@ export type ConfigAliasOptions = { const defaultConfig: Config = { asyncUtilTimeout: 1000, defaultIncludeHiddenElements: false, - concurrentRoot: true, }; let config = { ...defaultConfig }; diff --git a/src/event-handler.ts b/src/event-handler.ts index 8f275c6b4..f2c6ec83e 100644 --- a/src/event-handler.ts +++ b/src/event-handler.ts @@ -1,30 +1,30 @@ -import type { ReactTestInstance } from 'react-test-renderer'; +export type EventHandler = (...args: unknown[]) => unknown; export type EventHandlerOptions = { /** Include check for event handler named without adding `on*` prefix. */ loose?: boolean; }; -export function getEventHandler( - element: ReactTestInstance, +export function getEventHandlerFromProps( + props: Record, eventName: string, options?: EventHandlerOptions, -) { +): EventHandler | undefined { const handlerName = getEventHandlerName(eventName); - if (typeof element.props[handlerName] === 'function') { - return element.props[handlerName]; + if (typeof props[handlerName] === 'function') { + return props[handlerName] as EventHandler; } - if (options?.loose && typeof element.props[eventName] === 'function') { - return element.props[eventName]; + if (options?.loose && typeof props[eventName] === 'function') { + return props[eventName] as EventHandler; } - if (typeof element.props[`testOnly_${handlerName}`] === 'function') { - return element.props[`testOnly_${handlerName}`]; + if (typeof props[`testOnly_${handlerName}`] === 'function') { + return props[`testOnly_${handlerName}`] as EventHandler; } - if (options?.loose && typeof element.props[`testOnly_${eventName}`] === 'function') { - return element.props[`testOnly_${eventName}`]; + if (options?.loose && typeof props[`testOnly_${eventName}`] === 'function') { + return props[`testOnly_${eventName}`] as EventHandler; } return undefined; diff --git a/src/fire-event.ts b/src/fire-event.ts index 981e6e649..c069c7944 100644 --- a/src/fire-event.ts +++ b/src/fire-event.ts @@ -5,10 +5,11 @@ import type { TextProps, ViewProps, } from 'react-native'; -import type { ReactTestInstance } from 'react-test-renderer'; +import type { Fiber, HostElement } from 'test-renderer'; -import act from './act'; -import { getEventHandler } from './event-handler'; +import { act, unsafe_act } from './act'; +import type { EventHandler } from './event-handler'; +import { getEventHandlerFromProps } from './event-handler'; import { isElementMounted, isHostElement } from './helpers/component-tree'; import { isHostScrollView, isHostTextInput } from './helpers/host-component-names'; import { isPointerEventEnabled } from './helpers/pointer-events'; @@ -16,9 +17,7 @@ import { isEditableTextInput } from './helpers/text-input'; import { nativeState } from './native-state'; import type { Point, StringWithAutocomplete } from './types'; -type EventHandler = (...args: unknown[]) => unknown; - -export function isTouchResponder(element: ReactTestInstance) { +export function isTouchResponder(element: HostElement) { if (!isHostElement(element)) { return false; } @@ -50,9 +49,9 @@ const textInputEventsIgnoringEditableProp = new Set([ ]); export function isEventEnabled( - element: ReactTestInstance, + element: HostElement, eventName: string, - nearestTouchResponder?: ReactTestInstance, + nearestTouchResponder?: HostElement, ) { if (nearestTouchResponder != null && isHostTextInput(nearestTouchResponder)) { return ( @@ -75,13 +74,15 @@ export function isEventEnabled( } function findEventHandler( - element: ReactTestInstance, + element: HostElement, eventName: string, - nearestTouchResponder?: ReactTestInstance, + nearestTouchResponder?: HostElement, ): EventHandler | null { const touchResponder = isTouchResponder(element) ? element : nearestTouchResponder; - const handler = getEventHandler(element, eventName, { loose: true }); + const handler = + getEventHandlerFromProps(element.props, eventName, { loose: true }) ?? + findEventHandlerFromFiber(element.unstable_fiber, eventName); if (handler && isEventEnabled(element, eventName, touchResponder)) { return handler; } @@ -93,6 +94,25 @@ function findEventHandler( return findEventHandler(element.parent, eventName, touchResponder); } +function findEventHandlerFromFiber(fiber: Fiber | null, eventName: string): EventHandler | null { + // Container fibers have memoizedProps set to null + if (!fiber?.memoizedProps) { + return null; + } + + const handler = getEventHandlerFromProps(fiber.memoizedProps, eventName, { loose: true }); + if (handler) { + return handler; + } + + // No parent fiber or we reached another host element + if (fiber.return === null || typeof fiber.return.type === 'string') { + return null; + } + + return findEventHandlerFromFiber(fiber.return, eventName); +} + // String union type of keys of T that start with on, stripped of 'on' type EventNameExtractor = keyof { [K in keyof T as K extends `on${infer Rest}` ? Uncapitalize : never]: T[K]; @@ -106,7 +126,7 @@ type EventName = StringWithAutocomplete< | EventNameExtractor >; -function fireEvent(element: ReactTestInstance, eventName: EventName, ...data: unknown[]) { +async function fireEvent(element: HostElement, eventName: EventName, ...data: unknown[]) { if (!isElementMounted(element)) { return; } @@ -119,27 +139,24 @@ function fireEvent(element: ReactTestInstance, eventName: EventName, ...data: un } let returnValue; - void act(() => { + await act(() => { returnValue = handler(...data); }); return returnValue; } -fireEvent.press = (element: ReactTestInstance, ...data: unknown[]) => - fireEvent(element, 'press', ...data); +fireEvent.press = async (element: HostElement, ...data: unknown[]) => + await fireEvent(element, 'press', ...data); -fireEvent.changeText = (element: ReactTestInstance, ...data: unknown[]) => - fireEvent(element, 'changeText', ...data); +fireEvent.changeText = async (element: HostElement, ...data: unknown[]) => + await fireEvent(element, 'changeText', ...data); -fireEvent.scroll = (element: ReactTestInstance, ...data: unknown[]) => - fireEvent(element, 'scroll', ...data); +fireEvent.scroll = async (element: HostElement, ...data: unknown[]) => + await fireEvent(element, 'scroll', ...data); -async function fireEventAsync( - element: ReactTestInstance, - eventName: EventName, - ...data: unknown[] -) { +/** @deprecated - Use async `fireEvent` instead. */ +function unsafe_fireEventSync(element: HostElement, eventName: EventName, ...data: unknown[]) { if (!isElementMounted(element)) { return; } @@ -152,25 +169,26 @@ async function fireEventAsync( } let returnValue; - // eslint-disable-next-line require-await - await act(async () => { + void unsafe_act(() => { returnValue = handler(...data); }); return returnValue; } -fireEventAsync.press = async (element: ReactTestInstance, ...data: unknown[]) => - await fireEventAsync(element, 'press', ...data); +/** @deprecated - Use async `fireEvent.press` instead. */ +unsafe_fireEventSync.press = (element: HostElement, ...data: unknown[]) => + unsafe_fireEventSync(element, 'press', ...data); -fireEventAsync.changeText = async (element: ReactTestInstance, ...data: unknown[]) => - await fireEventAsync(element, 'changeText', ...data); +/** @deprecated - Use async `fireEvent.changeText` instead. */ +unsafe_fireEventSync.changeText = (element: HostElement, ...data: unknown[]) => + unsafe_fireEventSync(element, 'changeText', ...data); -fireEventAsync.scroll = async (element: ReactTestInstance, ...data: unknown[]) => - await fireEventAsync(element, 'scroll', ...data); +/** @deprecated - Use async `fireEvent.scroll` instead. */ +unsafe_fireEventSync.scroll = (element: HostElement, ...data: unknown[]) => + unsafe_fireEventSync(element, 'scroll', ...data); -export { fireEventAsync }; -export default fireEvent; +export { fireEvent, unsafe_fireEventSync }; const scrollEventNames = new Set([ 'scroll', @@ -180,7 +198,7 @@ const scrollEventNames = new Set([ 'momentumScrollEnd', ]); -function setNativeStateIfNeeded(element: ReactTestInstance, eventName: string, value: unknown) { +function setNativeStateIfNeeded(element: HostElement, eventName: string, value: unknown) { if (eventName === 'changeText' && typeof value === 'string' && isEditableTextInput(element)) { nativeState.valueForElement.set(element, value); } diff --git a/src/helpers/__tests__/accessiblity.test.tsx b/src/helpers/__tests__/accessiblity.test.tsx index dd277c596..64331884d 100644 --- a/src/helpers/__tests__/accessiblity.test.tsx +++ b/src/helpers/__tests__/accessiblity.test.tsx @@ -5,10 +5,10 @@ import { isHiddenFromAccessibility, isInaccessible, render, screen } from '../.. import { computeAriaDisabled, computeAriaLabel, isAccessibilityElement } from '../accessibility'; describe('isHiddenFromAccessibility', () => { - test('returns false for accessible elements', () => { + test('returns false for accessible elements', async () => { expect( isHiddenFromAccessibility( - render().getByTestId('subject', { + (await render()).getByTestId('subject', { includeHiddenElements: true, }), ), @@ -16,7 +16,7 @@ describe('isHiddenFromAccessibility', () => { expect( isHiddenFromAccessibility( - render(Hello).getByTestId('subject', { + (await render(Hello)).getByTestId('subject', { includeHiddenElements: true, }), ), @@ -24,7 +24,7 @@ describe('isHiddenFromAccessibility', () => { expect( isHiddenFromAccessibility( - render().getByTestId('subject', { + (await render()).getByTestId('subject', { includeHiddenElements: true, }), ), @@ -35,8 +35,8 @@ describe('isHiddenFromAccessibility', () => { expect(isHiddenFromAccessibility(null)).toBe(true); }); - test('detects elements with aria-hidden prop', () => { - render(); + test('detects elements with aria-hidden prop', async () => { + await render(); expect( isHiddenFromAccessibility( screen.getByTestId('subject', { @@ -46,8 +46,8 @@ describe('isHiddenFromAccessibility', () => { ).toBe(true); }); - test('detects nested elements with aria-hidden prop', () => { - render( + test('detects nested elements with aria-hidden prop', async () => { + await render( , @@ -61,8 +61,8 @@ describe('isHiddenFromAccessibility', () => { ).toBe(true); }); - test('detects elements with accessibilityElementsHidden prop', () => { - render(); + test('detects elements with accessibilityElementsHidden prop', async () => { + await render(); expect( isHiddenFromAccessibility( screen.getByTestId('subject', { @@ -72,8 +72,8 @@ describe('isHiddenFromAccessibility', () => { ).toBe(true); }); - test('detects nested elements with accessibilityElementsHidden prop', () => { - render( + test('detects nested elements with accessibilityElementsHidden prop', async () => { + await render( , @@ -87,8 +87,8 @@ describe('isHiddenFromAccessibility', () => { ).toBe(true); }); - test('detects deeply nested elements with accessibilityElementsHidden prop', () => { - render( + test('detects deeply nested elements with accessibilityElementsHidden prop', async () => { + await render( @@ -106,8 +106,8 @@ describe('isHiddenFromAccessibility', () => { ).toBe(true); }); - test('detects elements with importantForAccessibility="no-hide-descendants" prop', () => { - render(); + test('detects elements with importantForAccessibility="no-hide-descendants" prop', async () => { + await render(); expect( isHiddenFromAccessibility( screen.getByTestId('subject', { @@ -117,8 +117,8 @@ describe('isHiddenFromAccessibility', () => { ).toBe(true); }); - test('detects nested elements with importantForAccessibility="no-hide-descendants" prop', () => { - render( + test('detects nested elements with importantForAccessibility="no-hide-descendants" prop', async () => { + await render( , @@ -132,8 +132,8 @@ describe('isHiddenFromAccessibility', () => { ).toBe(true); }); - test('detects elements with display=none', () => { - render(); + test('detects elements with display=none', async () => { + await render(); expect( isHiddenFromAccessibility( screen.getByTestId('subject', { @@ -143,8 +143,8 @@ describe('isHiddenFromAccessibility', () => { ).toBe(true); }); - test('detects nested elements with display=none', () => { - render( + test('detects nested elements with display=none', async () => { + await render( , @@ -158,8 +158,8 @@ describe('isHiddenFromAccessibility', () => { ).toBe(true); }); - test('detects deeply nested elements with display=none', () => { - render( + test('detects deeply nested elements with display=none', async () => { + await render( @@ -177,8 +177,8 @@ describe('isHiddenFromAccessibility', () => { ).toBe(true); }); - test('detects elements with display=none with complex style', () => { - render( + test('detects elements with display=none with complex style', async () => { + await render( { ).toBe(true); }); - test('is not trigged by opacity = 0', () => { - render(); + test('is not trigged by opacity = 0', async () => { + await render(); expect( isHiddenFromAccessibility( screen.getByTestId('subject', { @@ -204,8 +204,8 @@ describe('isHiddenFromAccessibility', () => { ).toBe(false); }); - test('detects siblings of element with accessibilityViewIsModal prop', () => { - render( + test('detects siblings of element with accessibilityViewIsModal prop', async () => { + await render( @@ -220,8 +220,8 @@ describe('isHiddenFromAccessibility', () => { ).toBe(true); }); - test('detects deeply nested siblings of element with accessibilityViewIsModal prop', () => { - render( + test('detects deeply nested siblings of element with accessibilityViewIsModal prop', async () => { + await render( @@ -236,8 +236,8 @@ describe('isHiddenFromAccessibility', () => { ).toBe(true); }); - test('detects siblings of element with "aria-modal" prop', () => { - render( + test('detects siblings of element with "aria-modal" prop', async () => { + await render( @@ -248,13 +248,13 @@ describe('isHiddenFromAccessibility', () => { ).toBe(true); }); - test('is not triggered for element with accessibilityViewIsModal prop', () => { - render(); + test('is not triggered for element with accessibilityViewIsModal prop', async () => { + await render(); expect(isHiddenFromAccessibility(screen.getByTestId('subject'))).toBe(false); }); - test('is not triggered for child of element with accessibilityViewIsModal prop', () => { - render( + test('is not triggered for child of element with accessibilityViewIsModal prop', async () => { + await render( , @@ -262,8 +262,8 @@ describe('isHiddenFromAccessibility', () => { expect(isHiddenFromAccessibility(screen.getByTestId('subject'))).toBe(false); }); - test('is not triggered for descendent of element with accessibilityViewIsModal prop', () => { - render( + test('is not triggered for descendent of element with accessibilityViewIsModal prop', async () => { + await render( @@ -279,15 +279,15 @@ describe('isHiddenFromAccessibility', () => { expect(isInaccessible).toBe(isHiddenFromAccessibility); }); - test('is not triggered for element with "aria-modal" prop', () => { - render(); + test('is not triggered for element with "aria-modal" prop', async () => { + await render(); expect(isHiddenFromAccessibility(screen.getByTestId('subject'))).toBe(false); }); }); describe('isAccessibilityElement', () => { - test('matches View component properly', () => { - render( + test('matches View component properly', async () => { + await render( @@ -299,8 +299,8 @@ describe('isAccessibilityElement', () => { expect(isAccessibilityElement(screen.getByTestId('false'))).toBeFalsy(); }); - test('matches TextInput component properly', () => { - render( + test('matches TextInput component properly', async () => { + await render( @@ -312,8 +312,8 @@ describe('isAccessibilityElement', () => { expect(isAccessibilityElement(screen.getByTestId('false'))).toBeFalsy(); }); - test('matches Text component properly', () => { - render( + test('matches Text component properly', async () => { + await render( Default @@ -329,8 +329,8 @@ describe('isAccessibilityElement', () => { expect(isAccessibilityElement(screen.getByTestId('false'))).toBeFalsy(); }); - test('matches Switch component properly', () => { - render( + test('matches Switch component properly', async () => { + await render( @@ -342,8 +342,8 @@ describe('isAccessibilityElement', () => { expect(isAccessibilityElement(screen.getByTestId('false'))).toBeFalsy(); }); - test('matches Pressable component properly', () => { - render( + test('matches Pressable component properly', async () => { + await render( @@ -355,8 +355,8 @@ describe('isAccessibilityElement', () => { expect(isAccessibilityElement(screen.getByTestId('false'))).toBeFalsy(); }); - test('matches TouchableOpacity component properly', () => { - render( + test('matches TouchableOpacity component properly', async () => { + await render( @@ -374,8 +374,8 @@ describe('isAccessibilityElement', () => { }); describe('computeAriaLabel', () => { - test('supports basic usage', () => { - render( + test('supports basic usage', async () => { + await render( @@ -395,8 +395,8 @@ describe('computeAriaLabel', () => { expect(computeAriaLabel(screen.getByTestId('text-content'))).toBeUndefined(); }); - test('label priority', () => { - render( + test('label priority', async () => { + await render( @@ -410,8 +410,8 @@ describe('computeAriaLabel', () => { }); describe('computeAriaDisabled', () => { - test('supports basic usage', () => { - render( + test('supports basic usage', async () => { + await render( @@ -428,8 +428,8 @@ describe('computeAriaDisabled', () => { expect(computeAriaDisabled(screen.getByTestId('disabled-false-by-state'))).toBe(false); }); - test('supports TextInput', () => { - render( + test('supports TextInput', async () => { + await render( @@ -442,8 +442,8 @@ describe('computeAriaDisabled', () => { expect(computeAriaDisabled(screen.getByTestId('editable-false'))).toBe(true); }); - test('supports Button', () => { - render( + test('supports Button', async () => { + await render( Default Button @@ -462,8 +462,8 @@ describe('computeAriaDisabled', () => { expect(computeAriaDisabled(screen.getByTestId('disabled-false'))).toBe(false); }); - test('supports Text', () => { - render( + test('supports Text', async () => { + await render( Default Text Disabled Text diff --git a/src/helpers/__tests__/component-tree.test.tsx b/src/helpers/__tests__/component-tree.test.tsx index c8a33036b..18b8d2580 100644 --- a/src/helpers/__tests__/component-tree.test.tsx +++ b/src/helpers/__tests__/component-tree.test.tsx @@ -1,18 +1,8 @@ import React from 'react'; -import { Text, TextInput, View } from 'react-native'; +import { View } from 'react-native'; import { render, screen } from '../..'; -import { - getHostChildren, - getHostParent, - getHostSelves, - getHostSiblings, - getUnsafeRootElement, -} from '../component-tree'; - -function ZeroHostChildren() { - return <>; -} +import { getContainerElement, getHostSiblings } from '../component-tree'; function MultipleHostChildren() { return ( @@ -24,158 +14,9 @@ function MultipleHostChildren() { ); } -describe('getHostParent()', () => { - it('returns host parent for host component', () => { - render( - - - - - - , - ); - - const hostParent = getHostParent(screen.getByTestId('subject')); - expect(hostParent).toBe(screen.getByTestId('parent')); - - const hostGrandparent = getHostParent(hostParent); - expect(hostGrandparent).toBe(screen.getByTestId('grandparent')); - - expect(getHostParent(hostGrandparent)).toBe(null); - }); - - it('returns host parent for null', () => { - expect(getHostParent(null)).toBe(null); - }); - - it('returns host parent for composite component', () => { - render( - - - - , - ); - - const compositeComponent = screen.UNSAFE_getByType(MultipleHostChildren); - const hostParent = getHostParent(compositeComponent); - expect(hostParent).toBe(screen.getByTestId('parent')); - }); -}); - -describe('getHostChildren()', () => { - it('returns host children for host component', () => { - render( - - - - Hello - - , - ); - - const hostSubject = screen.getByTestId('subject'); - expect(getHostChildren(hostSubject)).toEqual([]); - - const hostSibling = screen.getByTestId('sibling'); - expect(getHostChildren(hostSibling)).toEqual([]); - - const hostParent = screen.getByTestId('parent'); - expect(getHostChildren(hostParent)).toEqual([hostSubject, hostSibling]); - - const hostGrandparent = screen.getByTestId('grandparent'); - expect(getHostChildren(hostGrandparent)).toEqual([hostParent]); - }); - - it('returns host children for composite component', () => { - render( - - - - - , - ); - - expect(getHostChildren(screen.getByTestId('parent'))).toEqual([ - screen.getByTestId('child1'), - screen.getByTestId('child2'), - screen.getByTestId('child3'), - screen.getByTestId('subject'), - screen.getByTestId('sibling'), - ]); - }); -}); - -describe('getHostSelves()', () => { - it('returns passed element for host components', () => { - render( - - - - - - , - ); - - const hostSubject = screen.getByTestId('subject'); - expect(getHostSelves(hostSubject)).toEqual([hostSubject]); - - const hostSibling = screen.getByTestId('sibling'); - expect(getHostSelves(hostSibling)).toEqual([hostSibling]); - - const hostParent = screen.getByTestId('parent'); - expect(getHostSelves(hostParent)).toEqual([hostParent]); - - const hostGrandparent = screen.getByTestId('grandparent'); - expect(getHostSelves(hostGrandparent)).toEqual([hostGrandparent]); - }); - - test('returns single host element for React Native composite components', () => { - render( - - Text - - , - ); - - const compositeText = screen.getByText('Text'); - const hostText = screen.getByTestId('text'); - expect(getHostSelves(compositeText)).toEqual([hostText]); - - const compositeTextInputByValue = screen.getByDisplayValue('TextInputValue'); - const compositeTextInputByPlaceholder = screen.getByPlaceholderText('TextInputPlaceholder'); - - const hostTextInput = screen.getByTestId('textInput'); - expect(getHostSelves(compositeTextInputByValue)).toEqual([hostTextInput]); - expect(getHostSelves(compositeTextInputByPlaceholder)).toEqual([hostTextInput]); - }); - - test('returns host children for custom composite components', () => { - render( - - - - - , - ); - - const zeroCompositeComponent = screen.UNSAFE_getByType(ZeroHostChildren); - expect(getHostSelves(zeroCompositeComponent)).toEqual([]); - - const multipleCompositeComponent = screen.UNSAFE_getByType(MultipleHostChildren); - const hostChild1 = screen.getByTestId('child1'); - const hostChild2 = screen.getByTestId('child2'); - const hostChild3 = screen.getByTestId('child3'); - expect(getHostSelves(multipleCompositeComponent)).toEqual([hostChild1, hostChild2, hostChild3]); - }); -}); - describe('getHostSiblings()', () => { - it('returns host siblings for host component', () => { - render( + it('returns host siblings for host component', async () => { + await render( @@ -195,38 +36,17 @@ describe('getHostSiblings()', () => { screen.getByTestId('child3'), ]); }); - - it('returns host siblings for composite component', () => { - render( - - - - - - - - , - ); - - const compositeComponent = screen.UNSAFE_getByType(MultipleHostChildren); - const hostSiblings = getHostSiblings(compositeComponent); - expect(hostSiblings).toEqual([ - screen.getByTestId('siblingBefore'), - screen.getByTestId('subject'), - screen.getByTestId('siblingAfter'), - ]); - }); }); -describe('getUnsafeRootElement()', () => { - it('returns UNSAFE_root for mounted view', () => { - render( +describe('getContainerElement()', () => { + it('returns container for mounted view', async () => { + await render( , ); const view = screen.getByTestId('view'); - expect(getUnsafeRootElement(view)).toEqual(screen.UNSAFE_root); + expect(getContainerElement(view)).toEqual(screen.container); }); }); diff --git a/src/helpers/__tests__/ensure-peer-deps.test.ts b/src/helpers/__tests__/ensure-peer-deps.test.ts deleted file mode 100644 index 354eab004..000000000 --- a/src/helpers/__tests__/ensure-peer-deps.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -/* eslint-disable @typescript-eslint/no-require-imports */ - -// Mock the require calls -jest.mock('react/package.json', () => ({ version: '19.0.0' })); -jest.mock('react-test-renderer/package.json', () => ({ version: '19.0.0' })); - -describe('ensurePeerDeps', () => { - const originalEnv = process.env; - - beforeEach(() => { - jest.resetModules(); - process.env = { ...originalEnv }; - delete process.env.RNTL_SKIP_DEPS_CHECK; - }); - - afterEach(() => { - process.env = originalEnv; - }); - - it('should not throw when versions match', () => { - expect(() => require('../ensure-peer-deps')).not.toThrow(); - }); - - it('should throw when react-test-renderer is missing', () => { - jest.mock('react-test-renderer/package.json', () => { - throw new Error('Module not found'); - }); - - expect(() => require('../ensure-peer-deps')).toThrow( - 'Missing dev dependency "react-test-renderer@19.0.0"', - ); - }); - - it('should throw when react-test-renderer version mismatches', () => { - jest.mock('react-test-renderer/package.json', () => ({ version: '18.2.0' })); - - expect(() => require('../ensure-peer-deps')).toThrow( - 'Incorrect version of "react-test-renderer" detected. Expected "19.0.0", but found "18.2.0"', - ); - }); - - it('should skip dependency check when RNTL_SKIP_DEPS_CHECK is set', () => { - process.env.RNTL_SKIP_DEPS_CHECK = '1'; - jest.mock('react-test-renderer/package.json', () => { - throw new Error('Module not found'); - }); - - expect(() => require('../ensure-peer-deps')).not.toThrow(); - }); -}); diff --git a/src/helpers/__tests__/format-element.test.tsx b/src/helpers/__tests__/format-element.test.tsx index b27bde7a6..ffe8eaddc 100644 --- a/src/helpers/__tests__/format-element.test.tsx +++ b/src/helpers/__tests__/format-element.test.tsx @@ -4,8 +4,8 @@ import { Text, View } from 'react-native'; import { render, screen } from '../..'; import { formatElement } from '../format-element'; -test('formatElement', () => { - render( +test('formatElement', async () => { + await render( Hello diff --git a/src/helpers/__tests__/include-hidden-elements.test.tsx b/src/helpers/__tests__/include-hidden-elements.test.tsx index cffa8d234..03c9ddd3a 100644 --- a/src/helpers/__tests__/include-hidden-elements.test.tsx +++ b/src/helpers/__tests__/include-hidden-elements.test.tsx @@ -3,36 +3,36 @@ import { View } from 'react-native'; import { configure, render, screen } from '../..'; -test('includeHiddenElements query option takes priority over hidden option and global config', () => { +test('includeHiddenElements query option takes priority over hidden option and global config', async () => { configure({ defaultHidden: true, defaultIncludeHiddenElements: true }); - render(); + await render(); expect(screen.queryByTestId('view', { includeHiddenElements: false, hidden: true })).toBeFalsy(); }); -test('hidden option takes priority over global config when includeHiddenElements is not defined', () => { +test('hidden option takes priority over global config when includeHiddenElements is not defined', async () => { configure({ defaultHidden: true, defaultIncludeHiddenElements: true }); - render(); + await render(); expect(screen.queryByTestId('view', { hidden: false })).toBeFalsy(); }); -test('global config defaultIncludeElements option takes priority over defaultHidden when set at the same time', () => { +test('global config defaultIncludeElements option takes priority over defaultHidden when set at the same time', async () => { configure({ defaultHidden: false, defaultIncludeHiddenElements: true }); - render(); + await render(); expect(screen.getByTestId('view')).toBeTruthy(); }); -test('defaultHidden takes priority when it was set last', () => { +test('defaultHidden takes priority when it was set last', async () => { // also simulates the case when defaultIncludeHiddenElements is true by default in the config configure({ defaultIncludeHiddenElements: true }); configure({ defaultHidden: false }); - render(); + await render(); expect(screen.queryByTestId('view')).toBeFalsy(); }); -test('defaultIncludeHiddenElements takes priority when it was set last', () => { +test('defaultIncludeHiddenElements takes priority when it was set last', async () => { // also simulates the case when defaultHidden is true by default in the config configure({ defaultHidden: true }); configure({ defaultIncludeHiddenElements: false }); - render(); + await render(); expect(screen.queryByTestId('view')).toBeFalsy(); }); diff --git a/src/helpers/__tests__/text-content.test.tsx b/src/helpers/__tests__/text-content.test.tsx index 42f4d3f1e..0c773f699 100644 --- a/src/helpers/__tests__/text-content.test.tsx +++ b/src/helpers/__tests__/text-content.test.tsx @@ -4,8 +4,8 @@ import { Text } from 'react-native'; import { render, screen } from '../..'; import { getTextContent } from '../text-content'; -test('getTextContent with simple content', () => { - render(Hello world); +test('getTextContent with simple content', async () => { + await render(Hello world); expect(getTextContent(screen.root)).toBe('Hello world'); }); @@ -13,8 +13,8 @@ test('getTextContent with null element', () => { expect(getTextContent(null)).toBe(''); }); -test('getTextContent with single nested content', () => { - render( +test('getTextContent with single nested content', async () => { + await render( Hello world , @@ -22,8 +22,8 @@ test('getTextContent with single nested content', () => { expect(getTextContent(screen.root)).toBe('Hello world'); }); -test('getTextContent with multiple nested content', () => { - render( +test('getTextContent with multiple nested content', async () => { + await render( Hello world , @@ -31,8 +31,8 @@ test('getTextContent with multiple nested content', () => { expect(getTextContent(screen.root)).toBe('Hello world'); }); -test('getTextContent with multiple number content', () => { - render( +test('getTextContent with multiple number content', async () => { + await render( Hello world {100} , @@ -40,8 +40,8 @@ test('getTextContent with multiple number content', () => { expect(getTextContent(screen.root)).toBe('Hello world 100'); }); -test('getTextContent with multiple boolean content', () => { - render( +test('getTextContent with multiple boolean content', async () => { + await render( Hello{false} {true}world , diff --git a/src/helpers/__tests__/text-input.test.tsx b/src/helpers/__tests__/text-input.test.tsx index 60534dbb8..b3a6f72cf 100644 --- a/src/helpers/__tests__/text-input.test.tsx +++ b/src/helpers/__tests__/text-input.test.tsx @@ -4,8 +4,8 @@ import { TextInput, View } from 'react-native'; import { render, screen } from '../..'; import { getTextInputValue, isEditableTextInput } from '../text-input'; -test('getTextInputValue basic test', () => { - render( +test('getTextInputValue basic test', async () => { + await render( @@ -22,8 +22,8 @@ test('getTextInputValue basic test', () => { ); }); -test('isEditableTextInput basic test', () => { - render( +test('isEditableTextInput basic test', async () => { + await render( diff --git a/src/helpers/accessibility.ts b/src/helpers/accessibility.ts index 95ab9166f..02f5d7ee9 100644 --- a/src/helpers/accessibility.ts +++ b/src/helpers/accessibility.ts @@ -1,15 +1,15 @@ import type { AccessibilityRole, AccessibilityState, AccessibilityValue, Role } from 'react-native'; import { StyleSheet } from 'react-native'; -import type { ReactTestInstance } from 'react-test-renderer'; +import type { HostElement } from 'test-renderer'; -import { getHostSiblings, getUnsafeRootElement, isHostElement } from './component-tree'; +import { getContainerElement, getHostSiblings, isHostElement } from './component-tree'; import { findAll } from './find-all'; import { isHostImage, isHostSwitch, isHostText, isHostTextInput } from './host-component-names'; import { getTextContent } from './text-content'; import { isEditableTextInput } from './text-input'; type IsInaccessibleOptions = { - cache?: WeakMap; + cache?: WeakMap; }; export const accessibilityStateKeys: (keyof AccessibilityState)[] = [ @@ -23,14 +23,14 @@ export const accessibilityStateKeys: (keyof AccessibilityState)[] = [ export const accessibilityValueKeys: (keyof AccessibilityValue)[] = ['min', 'max', 'now', 'text']; export function isHiddenFromAccessibility( - element: ReactTestInstance | null, + element: HostElement | null, { cache }: IsInaccessibleOptions = {}, ): boolean { if (element == null) { return true; } - let current: ReactTestInstance | null = element; + let current: HostElement | null = element; while (current) { let isCurrentSubtreeInaccessible = cache?.get(current); @@ -52,7 +52,7 @@ export function isHiddenFromAccessibility( /** RTL-compatibility alias for `isHiddenFromAccessibility` */ export const isInaccessible = isHiddenFromAccessibility; -function isSubtreeInaccessible(element: ReactTestInstance): boolean { +function isSubtreeInaccessible(element: HostElement): boolean { // Null props can happen for React.Fragments if (element.props == null) { return false; @@ -89,7 +89,7 @@ function isSubtreeInaccessible(element: ReactTestInstance): boolean { return false; } -export function isAccessibilityElement(element: ReactTestInstance | null): boolean { +export function isAccessibilityElement(element: HostElement | null): boolean { if (element == null) { return false; } @@ -119,7 +119,7 @@ export function isAccessibilityElement(element: ReactTestInstance | null): boole * @param element * @returns */ -export function getRole(element: ReactTestInstance): Role | AccessibilityRole { +export function getRole(element: HostElement): Role | AccessibilityRole { const explicitRole = element.props.role ?? element.props.accessibilityRole; if (explicitRole) { return normalizeRole(explicitRole); @@ -150,16 +150,16 @@ export function normalizeRole(role: string): Role | AccessibilityRole { return role as Role | AccessibilityRole; } -export function computeAriaModal(element: ReactTestInstance): boolean | undefined { +export function computeAriaModal(element: HostElement): boolean | undefined { return element.props['aria-modal'] ?? element.props.accessibilityViewIsModal; } -export function computeAriaLabel(element: ReactTestInstance): string | undefined { +export function computeAriaLabel(element: HostElement): string | undefined { const labelElementId = element.props['aria-labelledby'] ?? element.props.accessibilityLabelledBy; if (labelElementId) { - const rootElement = getUnsafeRootElement(element); + const container = getContainerElement(element); const labelElement = findAll( - rootElement, + container, (node) => isHostElement(node) && node.props.nativeID === labelElementId, { includeHiddenElements: true }, ); @@ -182,12 +182,12 @@ export function computeAriaLabel(element: ReactTestInstance): string | undefined } // See: https://github.com/callstack/react-native-testing-library/wiki/Accessibility:-State#busy-state -export function computeAriaBusy({ props }: ReactTestInstance): boolean { +export function computeAriaBusy({ props }: HostElement): boolean { return props['aria-busy'] ?? props.accessibilityState?.busy ?? false; } // See: https://github.com/callstack/react-native-testing-library/wiki/Accessibility:-State#checked-state -export function computeAriaChecked(element: ReactTestInstance): AccessibilityState['checked'] { +export function computeAriaChecked(element: HostElement): AccessibilityState['checked'] { const { props } = element; if (isHostSwitch(element)) { @@ -203,7 +203,7 @@ export function computeAriaChecked(element: ReactTestInstance): AccessibilitySta } // See: https://github.com/callstack/react-native-testing-library/wiki/Accessibility:-State#disabled-state -export function computeAriaDisabled(element: ReactTestInstance): boolean { +export function computeAriaDisabled(element: HostElement): boolean { if (isHostTextInput(element) && !isEditableTextInput(element)) { return true; } @@ -218,16 +218,16 @@ export function computeAriaDisabled(element: ReactTestInstance): boolean { } // See: https://github.com/callstack/react-native-testing-library/wiki/Accessibility:-State#expanded-state -export function computeAriaExpanded({ props }: ReactTestInstance): boolean | undefined { +export function computeAriaExpanded({ props }: HostElement): boolean | undefined { return props['aria-expanded'] ?? props.accessibilityState?.expanded; } // See: https://github.com/callstack/react-native-testing-library/wiki/Accessibility:-State#selected-state -export function computeAriaSelected({ props }: ReactTestInstance): boolean { +export function computeAriaSelected({ props }: HostElement): boolean { return props['aria-selected'] ?? props.accessibilityState?.selected ?? false; } -export function computeAriaValue(element: ReactTestInstance): AccessibilityValue { +export function computeAriaValue(element: HostElement): AccessibilityValue { const { accessibilityValue, 'aria-valuemax': ariaValueMax, @@ -244,7 +244,7 @@ export function computeAriaValue(element: ReactTestInstance): AccessibilityValue }; } -export function computeAccessibleName(element: ReactTestInstance): string | undefined { +export function computeAccessibleName(element: HostElement): string | undefined { return computeAriaLabel(element) ?? getTextContent(element); } diff --git a/src/helpers/component-tree.ts b/src/helpers/component-tree.ts index 9b2c99afd..30f6a4e73 100644 --- a/src/helpers/component-tree.ts +++ b/src/helpers/component-tree.ts @@ -1,98 +1,42 @@ -import type { ReactTestInstance } from 'react-test-renderer'; +import type { HostElement, HostNode } from 'test-renderer'; import { screen } from '../screen'; -/** - * ReactTestInstance referring to host element. - */ -export type HostTestInstance = ReactTestInstance & { type: string }; /** * Checks if the given element is a host element. * @param element The element to check. */ -export function isHostElement(element?: ReactTestInstance | null): element is HostTestInstance { - return typeof element?.type === 'string'; +export function isHostElement(element?: HostNode | null): element is HostElement { + return typeof element !== 'string' && typeof element?.type === 'string'; } -export function isElementMounted(element: ReactTestInstance) { - return getUnsafeRootElement(element) === screen.UNSAFE_root; +export function isElementMounted(element: HostElement) { + return getContainerElement(element) === screen.container; } /** - * Returns first host ancestor for given element. - * @param element The element start traversing from. - */ -export function getHostParent(element: ReactTestInstance | null): HostTestInstance | null { - if (element == null) { - return null; - } - - let current = element.parent; - while (current) { - if (isHostElement(current)) { - return current; - } - - current = current.parent; - } - - return null; -} - -/** - * Returns host children for given element. + * Returns host siblings for given element. * @param element The element start traversing from. */ -export function getHostChildren(element: ReactTestInstance | null): HostTestInstance[] { - if (element == null) { +export function getHostSiblings(element: HostElement): HostElement[] { + // Should not happen + const parent = element.parent; + if (!parent) { return []; } - const hostChildren: HostTestInstance[] = []; - - element.children.forEach((child) => { - if (typeof child !== 'object') { - return; - } - - if (isHostElement(child)) { - hostChildren.push(child); - } else { - hostChildren.push(...getHostChildren(child)); - } - }); - - return hostChildren; -} - -/** - * Return the array of host elements that represent the passed element. - * - * @param element The element start traversing from. - * @returns If the passed element is a host element, it will return an array containing only that element, - * if the passed element is a composite element, it will return an array containing its host children (zero, one or many). - */ -export function getHostSelves(element: ReactTestInstance | null): HostTestInstance[] { - return isHostElement(element) ? [element] : getHostChildren(element); -} - -/** - * Returns host siblings for given element. - * @param element The element start traversing from. - */ -export function getHostSiblings(element: ReactTestInstance | null): HostTestInstance[] { - const hostParent = getHostParent(element); - const hostSelves = getHostSelves(element); - return getHostChildren(hostParent).filter((sibling) => !hostSelves.includes(sibling)); + return parent.children.filter( + (sibling) => typeof sibling !== 'string' && sibling !== element, + ) as HostElement[]; } /** - * Returns the unsafe root element of the tree (probably composite). + * Returns the containerelement of the tree. * * @param element The element start traversing from. - * @returns The root element of the tree (host or composite). + * @returns The container element of the tree. */ -export function getUnsafeRootElement(element: ReactTestInstance) { +export function getContainerElement(element: HostElement) { let current = element; while (current.parent) { current = current.parent; diff --git a/src/helpers/debug.ts b/src/helpers/debug.ts index 4ec242f61..a3b02f760 100644 --- a/src/helpers/debug.ts +++ b/src/helpers/debug.ts @@ -1,4 +1,4 @@ -import type { ReactTestRendererJSON } from 'react-test-renderer'; +import type { JsonNode } from 'test-renderer'; import type { FormatElementOptions } from './format-element'; import { formatJson } from './format-element'; @@ -12,12 +12,12 @@ export type DebugOptions = { * Log pretty-printed deep test component instance */ export function debug( - instance: ReactTestRendererJSON | ReactTestRendererJSON[], + node: JsonNode | JsonNode[], { message, ...formatOptions }: DebugOptions = {}, ) { if (message) { - logger.info(`${message}\n\n`, formatJson(instance, formatOptions)); + logger.info(`${message}\n\n`, formatJson(node, formatOptions)); } else { - logger.info(formatJson(instance, formatOptions)); + logger.info(formatJson(node, formatOptions)); } } diff --git a/src/helpers/ensure-peer-deps.ts b/src/helpers/ensure-peer-deps.ts deleted file mode 100644 index b06507bfc..000000000 --- a/src/helpers/ensure-peer-deps.ts +++ /dev/null @@ -1,37 +0,0 @@ -function ensurePeerDeps() { - const reactVersion = getPackageVersion('react'); - ensurePackage('react-test-renderer', reactVersion); -} - -function ensurePackage(name: string, expectedVersion: string) { - const actualVersion = getPackageVersion(name); - if (!actualVersion) { - const error = new Error( - `Missing dev dependency "${name}@${expectedVersion}".\n\nFix it by running:\nnpm install -D ${name}@${expectedVersion}`, - ); - Error.captureStackTrace(error, ensurePeerDeps); - throw error; - } - - if (expectedVersion !== actualVersion) { - const error = new Error( - `Incorrect version of "${name}" detected. Expected "${expectedVersion}", but found "${actualVersion}".\n\nFix it by running:\nnpm install -D ${name}@${expectedVersion}`, - ); - Error.captureStackTrace(error, ensurePeerDeps); - throw error; - } -} - -function getPackageVersion(name: string) { - try { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const packageJson = require(`${name}/package.json`); - return packageJson.version; - } catch { - return null; - } -} - -if (!process.env.RNTL_SKIP_DEPS_CHECK) { - ensurePeerDeps(); -} diff --git a/src/helpers/find-all.ts b/src/helpers/find-all.ts index 4b476dfdb..00389e159 100644 --- a/src/helpers/find-all.ts +++ b/src/helpers/find-all.ts @@ -1,9 +1,7 @@ -import type { ReactTestInstance } from 'react-test-renderer'; +import type { HostElement } from 'test-renderer'; import { getConfig } from '../config'; import { isHiddenFromAccessibility } from './accessibility'; -import type { HostTestInstance } from './component-tree'; -import { isHostElement } from './component-tree'; interface FindAllOptions { /** Match elements hidden from accessibility */ @@ -17,11 +15,12 @@ interface FindAllOptions { } export function findAll( - root: ReactTestInstance, - predicate: (element: ReactTestInstance) => boolean, - options?: FindAllOptions, -): HostTestInstance[] { - const results = findAllInternal(root, predicate, options); + root: HostElement, + predicate: (element: HostElement) => boolean, + options: FindAllOptions = {}, +): HostElement[] { + const { matchDeepestOnly } = options; + const results = root.queryAll(predicate, { matchDeepestOnly }); const includeHiddenElements = options?.includeHiddenElements ?? options?.hidden ?? getConfig()?.defaultIncludeHiddenElements; @@ -30,39 +29,6 @@ export function findAll( return results; } - const cache = new WeakMap(); + const cache = new WeakMap(); return results.filter((element) => !isHiddenFromAccessibility(element, { cache })); } - -// Extracted from React Test Renderer -// src: https://github.com/facebook/react/blob/8e2bde6f2751aa6335f3cef488c05c3ea08e074a/packages/react-test-renderer/src/ReactTestRenderer.js#L402 -function findAllInternal( - root: ReactTestInstance, - predicate: (element: ReactTestInstance) => boolean, - options?: FindAllOptions, -): HostTestInstance[] { - const results: HostTestInstance[] = []; - - // Match descendants first but do not add them to results yet. - const matchingDescendants: HostTestInstance[] = []; - root.children.forEach((child) => { - if (typeof child === 'string') { - return; - } - matchingDescendants.push(...findAllInternal(child, predicate, options)); - }); - - if ( - // When matchDeepestOnly = true: add current element only if no descendants match - (!options?.matchDeepestOnly || matchingDescendants.length === 0) && - isHostElement(root) && - predicate(root) - ) { - results.push(root); - } - - // Add matching descendants after element to preserve original tree walk order. - results.push(...matchingDescendants); - - return results; -} diff --git a/src/helpers/format-element.ts b/src/helpers/format-element.ts index 295636db2..4dd33e4a3 100644 --- a/src/helpers/format-element.ts +++ b/src/helpers/format-element.ts @@ -1,6 +1,6 @@ -import type { ReactTestInstance, ReactTestRendererJSON } from 'react-test-renderer'; import type { NewPlugin } from 'pretty-format'; import prettyFormat, { plugins } from 'pretty-format'; +import type { HostElement, JsonNode } from 'test-renderer'; import type { MapPropsFunction } from './map-props'; import { defaultMapProps } from './map-props'; @@ -22,7 +22,7 @@ export type FormatElementOptions = { * @param element Element to format. */ export function formatElement( - element: ReactTestInstance | null, + element: HostElement | null, { compact, highlight = true, mapProps = defaultMapProps }: FormatElementOptions = {}, ) { if (element == null) { @@ -35,7 +35,7 @@ export function formatElement( return prettyFormat( { // This prop is needed persuade the prettyFormat that the element is - // a ReactTestRendererJSON instance, so it is formatted as JSX. + // a JsonNode instance, so it is formatted as JSX. $$typeof: Symbol.for('react.test.json'), type: `${element.type}`, props: mapProps ? mapProps(props) : props, @@ -52,7 +52,7 @@ export function formatElement( ); } -export function formatElementList(elements: ReactTestInstance[], options?: FormatElementOptions) { +export function formatElementList(elements: HostElement[], options?: FormatElementOptions) { if (elements.length === 0) { return '(no elements)'; } @@ -61,7 +61,7 @@ export function formatElementList(elements: ReactTestInstance[], options?: Forma } export function formatJson( - json: ReactTestRendererJSON | ReactTestRendererJSON[], + json: JsonNode | JsonNode[], { compact, highlight = true, mapProps = defaultMapProps }: FormatElementOptions = {}, ) { return prettyFormat(json, { diff --git a/src/helpers/host-component-names.ts b/src/helpers/host-component-names.ts index 45e019bc8..4a25eae7d 100644 --- a/src/helpers/host-component-names.ts +++ b/src/helpers/host-component-names.ts @@ -1,8 +1,6 @@ -import type { ReactTestInstance } from 'react-test-renderer'; +import type { HostElement } from 'test-renderer'; -import type { HostTestInstance } from './component-tree'; - -const HOST_TEXT_NAMES = ['Text', 'RCTText']; +export const HOST_TEXT_NAMES = ['Text', 'RCTText']; const HOST_TEXT_INPUT_NAMES = ['TextInput']; const HOST_IMAGE_NAMES = ['Image']; const HOST_SWITCH_NAMES = ['RCTSwitch']; @@ -13,7 +11,7 @@ const HOST_MODAL_NAMES = ['Modal']; * Checks if the given element is a host Text element. * @param element The element to check. */ -export function isHostText(element: ReactTestInstance): element is HostTestInstance { +export function isHostText(element: HostElement | null) { return typeof element?.type === 'string' && HOST_TEXT_NAMES.includes(element.type); } @@ -21,7 +19,7 @@ export function isHostText(element: ReactTestInstance): element is HostTestInsta * Checks if the given element is a host TextInput element. * @param element The element to check. */ -export function isHostTextInput(element: ReactTestInstance): element is HostTestInstance { +export function isHostTextInput(element: HostElement | null) { return typeof element?.type === 'string' && HOST_TEXT_INPUT_NAMES.includes(element.type); } @@ -29,7 +27,7 @@ export function isHostTextInput(element: ReactTestInstance): element is HostTest * Checks if the given element is a host Image element. * @param element The element to check. */ -export function isHostImage(element: ReactTestInstance): element is HostTestInstance { +export function isHostImage(element: HostElement | null) { return typeof element?.type === 'string' && HOST_IMAGE_NAMES.includes(element.type); } @@ -37,7 +35,7 @@ export function isHostImage(element: ReactTestInstance): element is HostTestInst * Checks if the given element is a host Switch element. * @param element The element to check. */ -export function isHostSwitch(element: ReactTestInstance): element is HostTestInstance { +export function isHostSwitch(element: HostElement | null) { return typeof element?.type === 'string' && HOST_SWITCH_NAMES.includes(element.type); } @@ -45,7 +43,7 @@ export function isHostSwitch(element: ReactTestInstance): element is HostTestIns * Checks if the given element is a host ScrollView element. * @param element The element to check. */ -export function isHostScrollView(element: ReactTestInstance): element is HostTestInstance { +export function isHostScrollView(element: HostElement | null) { return typeof element?.type === 'string' && HOST_SCROLL_VIEW_NAMES.includes(element.type); } @@ -53,6 +51,6 @@ export function isHostScrollView(element: ReactTestInstance): element is HostTes * Checks if the given element is a host Modal element. * @param element The element to check. */ -export function isHostModal(element: ReactTestInstance): element is HostTestInstance { +export function isHostModal(element: HostElement | null) { return typeof element?.type === 'string' && HOST_MODAL_NAMES.includes(element.type); } diff --git a/src/helpers/matchers/match-accessibility-state.ts b/src/helpers/matchers/match-accessibility-state.ts index 0aabf216b..896cdb1ea 100644 --- a/src/helpers/matchers/match-accessibility-state.ts +++ b/src/helpers/matchers/match-accessibility-state.ts @@ -1,4 +1,4 @@ -import type { ReactTestInstance } from 'react-test-renderer'; +import type { HostElement } from 'test-renderer'; import { computeAriaBusy, @@ -20,10 +20,7 @@ export interface AccessibilityStateMatcher { expanded?: boolean; } -export function matchAccessibilityState( - node: ReactTestInstance, - matcher: AccessibilityStateMatcher, -) { +export function matchAccessibilityState(node: HostElement, matcher: AccessibilityStateMatcher) { if (matcher.busy !== undefined && matcher.busy !== computeAriaBusy(node)) { return false; } diff --git a/src/helpers/matchers/match-accessibility-value.ts b/src/helpers/matchers/match-accessibility-value.ts index 6fe281d32..24141f205 100644 --- a/src/helpers/matchers/match-accessibility-value.ts +++ b/src/helpers/matchers/match-accessibility-value.ts @@ -1,4 +1,4 @@ -import type { ReactTestInstance } from 'react-test-renderer'; +import type { HostElement } from 'test-renderer'; import type { TextMatch } from '../../matches'; import { computeAriaValue } from '../accessibility'; @@ -12,7 +12,7 @@ export interface AccessibilityValueMatcher { } export function matchAccessibilityValue( - node: ReactTestInstance, + node: HostElement, matcher: AccessibilityValueMatcher, ): boolean { const value = computeAriaValue(node); diff --git a/src/helpers/matchers/match-label-text.ts b/src/helpers/matchers/match-label-text.ts index ce1fef4c0..22070d12a 100644 --- a/src/helpers/matchers/match-label-text.ts +++ b/src/helpers/matchers/match-label-text.ts @@ -1,11 +1,11 @@ -import type { ReactTestInstance } from 'react-test-renderer'; +import type { HostElement } from 'test-renderer'; import type { TextMatch, TextMatchOptions } from '../../matches'; import { matches } from '../../matches'; import { computeAriaLabel } from '../accessibility'; export function matchAccessibilityLabel( - element: ReactTestInstance, + element: HostElement, expectedLabel: TextMatch, options?: TextMatchOptions, ) { diff --git a/src/helpers/matchers/match-text-content.ts b/src/helpers/matchers/match-text-content.ts index dd5e7d90e..8cc9d759f 100644 --- a/src/helpers/matchers/match-text-content.ts +++ b/src/helpers/matchers/match-text-content.ts @@ -1,4 +1,4 @@ -import type { ReactTestInstance } from 'react-test-renderer'; +import type { HostElement } from 'test-renderer'; import type { TextMatch, TextMatchOptions } from '../../matches'; import { matches } from '../../matches'; @@ -12,7 +12,7 @@ import { getTextContent } from '../text-content'; * @returns - Whether the node's text content matches the given string or regex. */ export function matchTextContent( - node: ReactTestInstance, + node: HostElement, text: TextMatch, options: TextMatchOptions = {}, ) { diff --git a/src/helpers/pointer-events.ts b/src/helpers/pointer-events.ts index 5992669c7..e7114c15a 100644 --- a/src/helpers/pointer-events.ts +++ b/src/helpers/pointer-events.ts @@ -1,7 +1,5 @@ import { StyleSheet } from 'react-native'; -import type { ReactTestInstance } from 'react-test-renderer'; - -import { getHostParent } from './component-tree'; +import type { HostElement } from 'test-renderer'; /** * pointerEvents controls whether the View can be the target of touch events. @@ -10,7 +8,7 @@ import { getHostParent } from './component-tree'; * 'box-none': The View is never the target of touch events but its subviews can be * 'box-only': The view can be the target of touch events but its subviews cannot be * see the official react native doc https://reactnative.dev/docs/view#pointerevents */ -export const isPointerEventEnabled = (element: ReactTestInstance, isParent?: boolean): boolean => { +export const isPointerEventEnabled = (element: HostElement, isParent?: boolean): boolean => { // Check both props.pointerEvents and props.style.pointerEvents const pointerEvents = element?.props.pointerEvents ?? StyleSheet.flatten(element?.props.style)?.pointerEvents; @@ -21,8 +19,9 @@ export const isPointerEventEnabled = (element: ReactTestInstance, isParent?: boo return false; } - const hostParent = getHostParent(element); - if (!hostParent) return true; + if (!element.parent) { + return true; + } - return isPointerEventEnabled(hostParent, true); + return isPointerEventEnabled(element.parent, true); }; diff --git a/src/helpers/string-validation.ts b/src/helpers/string-validation.ts deleted file mode 100644 index 17864c8e1..000000000 --- a/src/helpers/string-validation.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { ReactTestRendererNode } from 'react-test-renderer'; - -export const validateStringsRenderedWithinText = ( - rendererJSON: ReactTestRendererNode | Array | null, -) => { - if (!rendererJSON) return; - - if (Array.isArray(rendererJSON)) { - rendererJSON.forEach(validateStringsRenderedWithinTextForNode); - return; - } - - return validateStringsRenderedWithinTextForNode(rendererJSON); -}; - -const validateStringsRenderedWithinTextForNode = (node: ReactTestRendererNode) => { - if (typeof node === 'string') { - return; - } - - if (node.type !== 'Text') { - node.children?.forEach((child) => { - if (typeof child === 'string') { - throw new Error( - `Invariant Violation: Text strings must be rendered within a component. Detected attempt to render "${child}" string within a <${node.type}> component.`, - ); - } - }); - } - - if (node.children) { - node.children.forEach(validateStringsRenderedWithinTextForNode); - } -}; diff --git a/src/helpers/text-content.ts b/src/helpers/text-content.ts index 126dca44f..aa6277935 100644 --- a/src/helpers/text-content.ts +++ b/src/helpers/text-content.ts @@ -1,6 +1,6 @@ -import type { ReactTestInstance } from 'react-test-renderer'; +import type { HostElement } from 'test-renderer'; -export function getTextContent(element: ReactTestInstance | string | null): string { +export function getTextContent(element: HostElement | string | null): string { if (!element) { return ''; } diff --git a/src/helpers/text-input.ts b/src/helpers/text-input.ts index 682043992..e33b78d89 100644 --- a/src/helpers/text-input.ts +++ b/src/helpers/text-input.ts @@ -1,13 +1,13 @@ -import type { ReactTestInstance } from 'react-test-renderer'; +import type { HostElement } from 'test-renderer'; import { nativeState } from '../native-state'; import { isHostTextInput } from './host-component-names'; -export function isEditableTextInput(element: ReactTestInstance) { +export function isEditableTextInput(element: HostElement) { return isHostTextInput(element) && element.props.editable !== false; } -export function getTextInputValue(element: ReactTestInstance) { +export function getTextInputValue(element: HostElement) { if (!isHostTextInput(element)) { throw new Error(`Element is not a "TextInput", but it has type "${element.type}".`); } diff --git a/src/helpers/timers.ts b/src/helpers/timers.ts index 5c8d7e143..1284f9305 100644 --- a/src/helpers/timers.ts +++ b/src/helpers/timers.ts @@ -88,6 +88,7 @@ const { clearTimeoutFn, setImmediateFn, setTimeoutFn } = runWithRealTimers( export { clearTimeoutFn as clearTimeout, + getJestFakeTimersType, jestFakeTimersAreEnabled, runWithRealTimers, setImmediateFn as setImmediate, diff --git a/src/index.ts b/src/index.ts index 1ab373257..01a8f8ee9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,8 @@ -import './helpers/ensure-peer-deps'; import './matchers/extend-expect'; import { getIsReactActEnvironment, setReactActEnvironment } from './act'; import { flushMicroTasks } from './flush-micro-tasks'; -import { cleanupAsync } from './pure'; +import { cleanup } from './pure'; if (!process?.env?.RNTL_SKIP_AUTO_CLEANUP) { // If we're running in a test runner that supports afterEach @@ -14,7 +13,7 @@ if (!process?.env?.RNTL_SKIP_AUTO_CLEANUP) { if (typeof afterEach === 'function') { afterEach(async () => { await flushMicroTasks(); - await cleanupAsync(); + await cleanup(); }); } diff --git a/src/matchers/__tests__/to-be-busy.test.tsx b/src/matchers/__tests__/to-be-busy.test.tsx index e6684c242..cdd00bc7d 100644 --- a/src/matchers/__tests__/to-be-busy.test.tsx +++ b/src/matchers/__tests__/to-be-busy.test.tsx @@ -3,8 +3,8 @@ import { View } from 'react-native'; import { render, screen } from '../..'; -test('toBeBusy() basic case', () => { - render( +test('toBeBusy() basic case', async () => { + await render( <> @@ -21,8 +21,8 @@ test('toBeBusy() basic case', () => { expect(screen.getByTestId('default')).not.toBeBusy(); }); -test('toBeBusy() error messages', () => { - render( +test('toBeBusy() error messages', async () => { + await render( <> diff --git a/src/matchers/__tests__/to-be-checked.test.tsx b/src/matchers/__tests__/to-be-checked.test.tsx index 2b0b3f22a..4020fa450 100644 --- a/src/matchers/__tests__/to-be-checked.test.tsx +++ b/src/matchers/__tests__/to-be-checked.test.tsx @@ -3,8 +3,8 @@ import { type AccessibilityRole, Switch, View } from 'react-native'; import { render, screen } from '../..'; -function renderViewsWithRole(role: AccessibilityRole) { - render( +async function renderViewsWithRole(role: AccessibilityRole) { + await render( <> { - render( +test('toBeCheck() with Switch', async () => { + await render( <> @@ -78,8 +78,8 @@ test('toBeCheck() with Switch', () => { `); }); -test('toBeCheck() with "checkbox" role', () => { - renderViewsWithRole('checkbox'); +test('toBeCheck() with "checkbox" role', async () => { + await renderViewsWithRole('checkbox'); const checked = screen.getByTestId('checkbox-checked'); const unchecked = screen.getByTestId('checkbox-unchecked'); @@ -148,8 +148,8 @@ test('toBeCheck() with "checkbox" role', () => { `); }); -test('toBeCheck() with "radio" role', () => { - renderViewsWithRole('radio'); +test('toBeCheck() with "radio" role', async () => { + await renderViewsWithRole('radio'); const checked = screen.getByTestId('radio-checked'); const unchecked = screen.getByTestId('radio-unchecked'); @@ -201,8 +201,8 @@ test('toBeCheck() with "radio" role', () => { `); }); -test('toBeCheck() with "switch" role', () => { - renderViewsWithRole('switch'); +test('toBeCheck() with "switch" role', async () => { + await renderViewsWithRole('switch'); const checked = screen.getByTestId('switch-checked'); const unchecked = screen.getByTestId('switch-unchecked'); @@ -254,8 +254,8 @@ test('toBeCheck() with "switch" role', () => { `); }); -test('throws error for invalid role', () => { - renderViewsWithRole('adjustable'); +test('throws error for invalid role', async () => { + await renderViewsWithRole('adjustable'); const checked = screen.getByTestId('adjustable-checked'); const unchecked = screen.getByTestId('adjustable-unchecked'); @@ -268,8 +268,8 @@ test('throws error for invalid role', () => { ); }); -test('throws error for non-accessibility element', () => { - render(); +test('throws error for non-accessibility element', async () => { + await render(); const view = screen.getByTestId('test'); expect(() => expect(view).toBeChecked()).toThrowErrorMatchingInlineSnapshot( diff --git a/src/matchers/__tests__/to-be-disabled.test.tsx b/src/matchers/__tests__/to-be-disabled.test.tsx index 66eca2673..da7916665 100644 --- a/src/matchers/__tests__/to-be-disabled.test.tsx +++ b/src/matchers/__tests__/to-be-disabled.test.tsx @@ -13,8 +13,8 @@ import { import { render, screen } from '../..'; -test('toBeDisabled()/toBeEnabled() supports basic case', () => { - render( +test('toBeDisabled()/toBeEnabled() supports basic case', async () => { + await render( @@ -87,8 +87,8 @@ test('toBeDisabled()/toBeEnabled() supports basic case', () => { `); }); -test('toBeDisabled()/toBeEnabled() supports Pressable with "disabled" prop', () => { - render( +test('toBeDisabled()/toBeEnabled() supports Pressable with "disabled" prop', async () => { + await render( Button , @@ -157,27 +157,31 @@ test.each([ ['TouchableHighlight', TouchableHighlight], ['TouchableWithoutFeedback', TouchableWithoutFeedback], ['TouchableNativeFeedback', TouchableNativeFeedback], -] as const)('toBeDisabled()/toBeEnabled() supports %s with "disabled" prop', (_, Component) => { - render( - // @ts-expect-error - JSX element type 'Component' does not have any construct or call signatures. - - Button - , - ); +] as const)( + 'toBeDisabled()/toBeEnabled() supports %s with "disabled" prop', + async (_, Component) => { + await render( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - JSX element type 'Component' does not have any construct or call signatures. + + Button + , + ); - const touchable = screen.getByTestId('subject'); - expect(touchable).toBeDisabled(); - expect(touchable).not.toBeEnabled(); + const touchable = screen.getByTestId('subject'); + expect(touchable).toBeDisabled(); + expect(touchable).not.toBeEnabled(); - const title = screen.getByText('Button'); - expect(title).toBeDisabled(); - expect(title).not.toBeEnabled(); + const title = screen.getByText('Button'); + expect(title).toBeDisabled(); + expect(title).not.toBeEnabled(); - expect(() => expect(touchable).toBeEnabled()).toThrow(); - expect(() => expect(touchable).not.toBeDisabled()).toThrow(); - expect(() => expect(title).toBeEnabled()).toThrow(); - expect(() => expect(title).not.toBeDisabled()).toThrow(); -}); + expect(() => expect(touchable).toBeEnabled()).toThrow(); + expect(() => expect(touchable).not.toBeDisabled()).toThrow(); + expect(() => expect(title).toBeEnabled()).toThrow(); + expect(() => expect(title).not.toBeDisabled()).toThrow(); + }, +); test.each([ ['View', View], @@ -189,9 +193,10 @@ test.each([ ['TouchableNativeFeedback', TouchableNativeFeedback], ] as const)( 'toBeDisabled()/toBeEnabled() supports %s with "aria-disabled" prop', - (_, Component) => { - render( - // @ts-expect-error - JSX element type 'Component' does not have any construct or call signatures. + async (_, Component) => { + await render( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - JSX element type 'Component' does not have any construct or call signatures. Hello , @@ -216,9 +221,10 @@ test.each([ ['TouchableNativeFeedback', TouchableNativeFeedback], ] as const)( 'toBeDisabled()/toBeEnabled() supports %s with "accessibilityState.disabled" prop', - (_, Component) => { - render( - // @ts-expect-error - JSX element type 'Component' does not have any construct or call signatures. + async (_, Component) => { + await render( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - JSX element type 'Component' does not have any construct or call signatures. Hello , @@ -232,8 +238,8 @@ test.each([ }, ); -test('toBeDisabled()/toBeEnabled() supports "editable" prop on TextInput', () => { - render( +test('toBeDisabled()/toBeEnabled() supports "editable" prop on TextInput', async () => { + await render( @@ -250,8 +256,8 @@ test('toBeDisabled()/toBeEnabled() supports "editable" prop on TextInput', () => expect(screen.getByTestId('disabled')).not.toBeEnabled(); }); -test('toBeDisabled()/toBeEnabled() supports "disabled" prop on Button', () => { - render( +test('toBeDisabled()/toBeEnabled() supports "disabled" prop on Button', async () => { + await render(