diff --git a/.vscode/settings.json b/.vscode/settings.json index 09b7f95be..2be7fa331 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,11 @@ { - "cSpell.words": ["labelledby", "Pressable", "RNTL", "Uncapitalize", "valuenow", "valuetext"] + "cSpell.words": [ + "labelledby", + "Pressable", + "redent", + "RNTL", + "Uncapitalize", + "valuenow", + "valuetext" + ] } diff --git a/jest.config.js b/jest.config.js index d99aa4dc6..5018adce5 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,7 +1,7 @@ module.exports = { preset: './jest-preset', setupFilesAfterEnv: ['./jest-setup.ts'], - testPathIgnorePatterns: ['timer-utils', 'examples/', 'experiments-app/', 'experiments-rtl/'], + testPathIgnorePatterns: ['build/', 'examples/', 'experiments-app/', 'timer-utils'], testTimeout: 60000, transformIgnorePatterns: ['/node_modules/(?!(@react-native|react-native)/).*/'], snapshotSerializers: ['@relmify/jest-serializer-strip-ansi/always'], diff --git a/package.json b/package.json index b8adbaeaf..d2bff08dd 100644 --- a/package.json +++ b/package.json @@ -48,13 +48,13 @@ "dependencies": { "jest-matcher-utils": "^29.7.0", "pretty-format": "^29.7.0", + "react-reconciler": "0.31.0-rc-fb9a90fa48-20240614", "redent": "^3.0.0" }, "peerDependencies": { "jest": ">=28.0.0", - "react": ">=16.8.0", - "react-native": ">=0.59", - "react-test-renderer": ">=16.8.0" + "react": ">=18.0.0", + "react-native": ">=0.59" }, "peerDependenciesMeta": { "jest": { @@ -74,7 +74,7 @@ "@relmify/jest-serializer-strip-ansi": "^1.0.2", "@types/jest": "^29.5.12", "@types/react": "^18.3.3", - "@types/react-test-renderer": "^18.3.0", + "@types/react-reconciler": "^0", "babel-jest": "^29.7.0", "del-cli": "^6.0.0", "eslint": "^8.57.0", @@ -83,9 +83,8 @@ "flow-bin": "~0.170.0", "jest": "^29.7.0", "prettier": "^2.8.8", - "react": "18.3.1", - "react-native": "0.76.0-rc.6", - "react-test-renderer": "18.3.1", + "react": "19.0.0-rc-fb9a90fa48-20240614", + "react-native": "0.77.0-nightly-20241019-f3e37e29e", "release-it": "^17.6.0", "strip-ansi": "^6.0.1", "typescript": "^5.5.4" diff --git a/src/__tests__/__snapshots__/render-debug.test.tsx.snap b/src/__tests__/__snapshots__/render-debug.test.tsx.snap index 561b363ae..8e3c10779 100644 --- a/src/__tests__/__snapshots__/render-debug.test.tsx.snap +++ b/src/__tests__/__snapshots__/render-debug.test.tsx.snap @@ -367,106 +367,6 @@ exports[`debug: another custom message 1`] = ` " `; -exports[`debug: shallow 1`] = ` -" - - Is the banana fresh? - - - not fresh - - - - - - - Change freshness! - - - First Text - - - Second Text - - - 0 - -" -`; - -exports[`debug: shallow with message 1`] = ` -"my other custom message - - - - Is the banana fresh? - - - not fresh - - - - - - - Change freshness! - - - First Text - - - Second Text - - - 0 - -" -`; - exports[`debug: with message 1`] = ` "my custom message diff --git a/src/__tests__/act.test.tsx b/src/__tests__/act.test.tsx index 379eecc49..36a10f8b5 100644 --- a/src/__tests__/act.test.tsx +++ b/src/__tests__/act.test.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { Text } from 'react-native'; import { act, fireEvent, render, screen } from '../'; +import '../matchers/extend-expect'; type UseEffectProps = { callback(): void }; const UseEffect = ({ callback }: UseEffectProps) => { @@ -34,9 +35,9 @@ test('fireEvent should trigger useState', () => { render(); const counter = screen.getByText(/Total count/i); - expect(counter.props.children).toEqual('Total count: 0'); + expect(counter).toHaveTextContent('Total count: 0'); fireEvent.press(counter); - expect(counter.props.children).toEqual('Total count: 1'); + expect(counter).toHaveTextContent('Total count: 1'); }); test('should be able to not await act', () => { diff --git a/src/__tests__/auto-cleanup.test.tsx b/src/__tests__/auto-cleanup.test.tsx index cb11f5e62..157b40851 100644 --- a/src/__tests__/auto-cleanup.test.tsx +++ b/src/__tests__/auto-cleanup.test.tsx @@ -26,14 +26,14 @@ 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 unmounted before test ends', () => { const fn = jest.fn(); render(); expect(isMounted).toEqual(true); expect(fn).not.toHaveBeenCalled(); }); -test('component is automatically umounted after first test ends', () => { +test('component is automatically unmounted after first test ends', () => { expect(isMounted).toEqual(false); }); diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index aca62f304..835b9f791 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -1,5 +1,9 @@ import { getConfig, configure, resetToDefaults, configureInternal } from '../config'; +beforeEach(() => { + resetToDefaults(); +}); + test('getConfig() returns existing configuration', () => { expect(getConfig().asyncUtilTimeout).toEqual(1000); expect(getConfig().defaultIncludeHiddenElements).toEqual(false); diff --git a/src/__tests__/fire-event.test.tsx b/src/__tests__/fire-event.test.tsx index 2adf0ba78..240aaff89 100644 --- a/src/__tests__/fire-event.test.tsx +++ b/src/__tests__/fire-event.test.tsx @@ -30,38 +30,12 @@ const WithoutEventComponent = (_props: WithoutEventComponentProps) => ( ); -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('fireEvent', () => { test('should invoke specified event', () => { const onPressMock = jest.fn(); render(); - fireEvent(screen.getByText('Press me'), 'press'); + fireEvent.press(screen.getByText('Press me')); expect(onPressMock).toHaveBeenCalled(); }); @@ -71,7 +45,7 @@ describe('fireEvent', () => { const text = 'New press text'; render(); - fireEvent(screen.getByText(text), 'press'); + fireEvent.press(screen.getByText(text)); expect(onPressMock).toHaveBeenCalled(); }); @@ -84,26 +58,11 @@ describe('fireEvent', () => { fireEvent(screen.getByText('Without event'), 'press'); expect(onPressMock).not.toHaveBeenCalled(); }); - - test('should invoke event with custom name', () => { - const handlerMock = jest.fn(); - const EVENT_DATA = 'event data'; - - render( - - - , - ); - - fireEvent(screen.getByText('Custom event component'), 'customEvent', EVENT_DATA); - - expect(handlerMock).toHaveBeenCalledWith(EVENT_DATA); - }); }); test('fireEvent.press', () => { const onPressMock = jest.fn(); - const text = 'Fireevent press'; + const text = 'FireEvent press'; const eventData = { nativeEvent: { pageX: 20, @@ -114,7 +73,8 @@ test('fireEvent.press', () => { fireEvent.press(screen.getByText(text), eventData); - expect(onPressMock).toHaveBeenCalledWith(eventData); + expect(onPressMock).toHaveBeenCalledTimes(1); + expect(onPressMock.mock.calls[0][0].nativeEvent).toMatchObject(eventData.nativeEvent); }); test('fireEvent.scroll', () => { @@ -162,26 +122,6 @@ it('sets native state value for unmanaged text inputs', () => { expect(input).toHaveDisplayValue('abc'); }); -test('custom component with custom event name', () => { - const handlePress = jest.fn(); - - render(); - - fireEvent(screen.getByText('Custom component'), 'handlePress'); - - expect(handlePress).toHaveBeenCalled(); -}); - -test('event with multiple handler parameters', () => { - const handlePress = jest.fn(); - - render(); - - fireEvent(screen.getByText('Custom component'), 'handlePress', 'param1', 'param2'); - - expect(handlePress).toHaveBeenCalledWith('param1', 'param2'); -}); - test('should not fire on disabled TouchableOpacity', () => { const handlePress = jest.fn(); render( @@ -251,8 +191,7 @@ test('should fire inside View with pointerEvents="box-none"', () => { ); fireEvent.press(screen.getByText('Trigger')); - fireEvent(screen.getByText('Trigger'), 'onPress'); - expect(onPress).toHaveBeenCalledTimes(2); + expect(onPress).toHaveBeenCalledTimes(1); }); test('should fire inside View with pointerEvents="auto"', () => { @@ -266,8 +205,7 @@ test('should fire inside View with pointerEvents="auto"', () => { ); fireEvent.press(screen.getByText('Trigger')); - fireEvent(screen.getByText('Trigger'), 'onPress'); - expect(onPress).toHaveBeenCalledTimes(2); + expect(onPress).toHaveBeenCalledTimes(1); }); test('should not fire deeply inside View with pointerEvents="box-only"', () => { diff --git a/src/__tests__/host-component-names.test.tsx b/src/__tests__/host-component-names.test.tsx index 0e55f1a82..316312548 100644 --- a/src/__tests__/host-component-names.test.tsx +++ b/src/__tests__/host-component-names.test.tsx @@ -1,12 +1,12 @@ import * as React from 'react'; import { View } from 'react-native'; -import TestRenderer from 'react-test-renderer'; import { configureInternal, getConfig } from '../config'; import { getHostComponentNames, configureHostComponentNamesIfNeeded, } from '../helpers/host-component-names'; import { act, render } from '..'; +import * as rendererModule from '../renderer/renderer'; describe('getHostComponentNames', () => { test('returns host component names from internal config', () => { @@ -102,12 +102,14 @@ describe('configureHostComponentNamesIfNeeded', () => { }); test('throw an error when auto-detection fails', () => { - const mockCreate = jest.spyOn(TestRenderer, 'create') as jest.Mock; - const renderer = TestRenderer.create(); + const renderer = rendererModule.createRenderer(); + renderer.render(); - mockCreate.mockReturnValue({ - root: renderer.root, - }); + const mockCreateRenderer = jest + .spyOn(rendererModule, 'createRenderer') + .mockReturnValue(renderer); + // @ts-expect-error + jest.spyOn(renderer, 'render').mockReturnValue(renderer.root); expect(() => configureHostComponentNamesIfNeeded()).toThrowErrorMatchingInlineSnapshot(` "Trying to detect host component names triggered the following error: @@ -118,6 +120,6 @@ describe('configureHostComponentNamesIfNeeded', () => { Please check if you are using compatible versions of React Native and React Native Testing Library." `); - mockCreate.mockReset(); + mockCreateRenderer.mockReset(); }); }); diff --git a/src/__tests__/render-debug.test.tsx b/src/__tests__/render-debug.test.tsx index 9a57c8144..c8e37ba55 100644 --- a/src/__tests__/render-debug.test.tsx +++ b/src/__tests__/render-debug.test.tsx @@ -1,3 +1,4 @@ +/* eslint-disable jest/no-conditional-expect */ /* eslint-disable no-console */ import * as React from 'react'; import { Pressable, Text, TextInput, View } from 'react-native'; @@ -97,16 +98,12 @@ test('debug', () => { screen.debug(); screen.debug('my custom message'); - screen.debug.shallow(); - screen.debug.shallow('my other custom message'); screen.debug({ message: 'another custom message' }); const mockCalls = jest.mocked(console.log).mock.calls; expect(stripAnsi(mockCalls[0][0])).toMatchSnapshot(); expect(stripAnsi(mockCalls[1][0] + mockCalls[1][1])).toMatchSnapshot('with message'); - expect(stripAnsi(mockCalls[2][0])).toMatchSnapshot('shallow'); - expect(stripAnsi(mockCalls[3][0] + mockCalls[3][1])).toMatchSnapshot('shallow with message'); - expect(stripAnsi(mockCalls[4][0] + mockCalls[4][1])).toMatchSnapshot('another custom message'); + expect(stripAnsi(mockCalls[2][0] + mockCalls[2][1])).toMatchSnapshot('another custom message'); const mockWarnCalls = jest.mocked(console.warn).mock.calls; expect(mockWarnCalls[0]).toEqual([ diff --git a/src/__tests__/render-hook.test.tsx b/src/__tests__/render-hook.test.tsx index f924f7ec0..c3720ddae 100644 --- a/src/__tests__/render-hook.test.tsx +++ b/src/__tests__/render-hook.test.tsx @@ -1,5 +1,6 @@ +/* eslint-disable jest/no-conditional-expect */ import React, { ReactNode } from 'react'; -import TestRenderer from 'react-test-renderer'; +import * as rendererModule from '../renderer/renderer'; import { renderHook } from '../pure'; test('gives committed result', () => { @@ -93,12 +94,14 @@ test('props type is inferred correctly when initial props is explicitly undefine * we check the count of renders using React Test Renderers. */ test('does render only once', () => { - jest.spyOn(TestRenderer, 'create'); + const renderer = rendererModule.createRenderer(); + const renderSpy = jest.spyOn(renderer, 'render'); + jest.spyOn(rendererModule, 'createRenderer').mockReturnValue(renderer); renderHook(() => { const [state, setState] = React.useState(1); return [state, setState]; }); - expect(TestRenderer.create).toHaveBeenCalledTimes(1); + expect(renderSpy).toHaveBeenCalledTimes(1); }); diff --git a/src/__tests__/render-string-validation.test.tsx b/src/__tests__/render-string-validation.test.tsx index 2eba3ab27..35cb9bc0b 100644 --- a/src/__tests__/render-string-validation.test.tsx +++ b/src/__tests__/render-string-validation.test.tsx @@ -7,7 +7,7 @@ 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'; +const PROFILER_ERROR = 'The above error occurred in the component'; beforeEach(() => { // eslint-disable-next-line no-console @@ -24,19 +24,13 @@ afterEach(() => { }); test('should throw when rendering a string outside a text component', () => { - expect(() => - render(hello, { - unstable_validateStringsRenderedWithinText: true, - }), - ).toThrow( + expect(() => render(hello)).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, - }); + render(); expect(() => screen.rerender(hello)).toThrow( `${VALIDATION_ERROR}. Detected attempt to render "hello" string within a component.`, @@ -58,9 +52,7 @@ const InvalidTextAfterPress = () => { }; test('should throw an error when strings are rendered outside Text', () => { - render(, { - unstable_validateStringsRenderedWithinText: true, - }); + render(); expect(() => fireEvent.press(screen.getByText('Show text'))).toThrow( `${VALIDATION_ERROR}. Detected attempt to render "text rendered outside text component" string within a component.`, @@ -73,15 +65,10 @@ test('should not throw for texts nested in fragments', () => { <>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( @@ -89,7 +76,6 @@ test(`should throw when one of the children is a text and the parent is not a Te hello hello , - { unstable_validateStringsRenderedWithinText: true }, ), ).toThrow( `${VALIDATION_ERROR}. Detected attempt to render "hello" string within a component.`, @@ -102,7 +88,6 @@ test(`should throw when a string is rendered within a fragment rendered outside <>hello , - { unstable_validateStringsRenderedWithinText: true }, ), ).toThrow( `${VALIDATION_ERROR}. Detected attempt to render "hello" string within a component.`, @@ -110,9 +95,7 @@ test(`should throw when a string is rendered within a fragment rendered outside }); test('should throw if a number is rendered outside a text', () => { - expect(() => - render(0, { unstable_validateStringsRenderedWithinText: true }), - ).toThrow( + expect(() => render(0)).toThrow( `${VALIDATION_ERROR}. Detected attempt to render "0" string within a component.`, ); }); @@ -125,7 +108,6 @@ test('should throw with components returning string value not rendered in Text', , - { unstable_validateStringsRenderedWithinText: true }, ), ).toThrow( `${VALIDATION_ERROR}. Detected attempt to render "hello" string within a component.`, @@ -138,7 +120,6 @@ test('should not throw with components returning string value rendered in Text', , - { unstable_validateStringsRenderedWithinText: true }, ), ).not.toThrow(); }); @@ -149,7 +130,6 @@ test('should throw when rendering string in a View in a Text', () => { hello , - { unstable_validateStringsRenderedWithinText: true }, ), ).toThrow( `${VALIDATION_ERROR}. Detected attempt to render "hello" string within a component.`, @@ -175,7 +155,7 @@ const UseEffectComponent = () => { }; test('should render immediate setState in useEffect properly', async () => { - render(, { unstable_validateStringsRenderedWithinText: true }); + render(); expect(await screen.findByText('Text is visible')).toBeTruthy(); }); @@ -195,9 +175,7 @@ const InvalidUseEffectComponent = () => { }; test('should throw properly for immediate setState in useEffect', () => { - expect(() => - render(, { unstable_validateStringsRenderedWithinText: true }), - ).toThrow( + expect(() => render()).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 0c09b3904..0399ab7a2 100644 --- a/src/__tests__/render.test.tsx +++ b/src/__tests__/render.test.tsx @@ -1,8 +1,9 @@ /* eslint-disable no-console */ import * as React from 'react'; import { Pressable, Text, TextInput, View } from 'react-native'; -import { getConfig, resetToDefaults } from '../config'; -import { fireEvent, render, RenderAPI, screen } from '..'; +import { getConfig } from '../config'; +import { CONTAINER_TYPE } from '../renderer/contants'; +import { fireEvent, render, type RenderAPI, screen, resetToDefaults } from '..'; const PLACEHOLDER_FRESHNESS = 'Add custom freshness'; const PLACEHOLDER_CHEF = 'Who inspected freshness?'; @@ -74,42 +75,6 @@ class Banana extends React.Component { } } -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('update', () => { const fn = jest.fn(); render(); @@ -200,26 +165,16 @@ test('returns host root', () => { render(); expect(screen.root).toBeDefined(); - expect(screen.root.type).toBe('View'); - expect(screen.root.props.testID).toBe('inner'); + expect(screen.root?.type).toBe('View'); + expect(screen.root?.props.testID).toBe('inner'); }); -test('returns composite UNSAFE_root', () => { +test('returns container', () => { 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.container).toBeDefined(); + expect(screen.container.type).toBe(CONTAINER_TYPE); + expect(screen.container.props).toEqual({}); }); test('RenderAPI type', () => { diff --git a/src/__tests__/screen.test.tsx b/src/__tests__/screen.test.tsx index b22e92522..33299a358 100644 --- a/src/__tests__/screen.test.tsx +++ b/src/__tests__/screen.test.tsx @@ -53,8 +53,7 @@ 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.container).toThrow('`render` method has not been called'); expect(() => screen.debug()).toThrow('`render` method has not been called'); - expect(() => screen.debug.shallow()).toThrow('`render` method has not been called'); expect(() => screen.getByText('Mt. Everest')).toThrow('`render` method has not been called'); }); diff --git a/src/__tests__/wait-for.test.tsx b/src/__tests__/wait-for.test.tsx index 5eaa0190f..495904377 100644 --- a/src/__tests__/wait-for.test.tsx +++ b/src/__tests__/wait-for.test.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { Text, TouchableOpacity, View, Pressable } from 'react-native'; -import { fireEvent, render, waitFor, configure, screen } from '..'; +import { fireEvent, render, waitFor, configure, screen, act } from '..'; +import '../matchers/extend-expect'; class Banana extends React.Component { changeFresh = () => { @@ -45,7 +46,7 @@ test('waits for element until it stops throwing', async () => { const freshBananaText = await waitFor(() => screen.getByText('Fresh')); - expect(freshBananaText.props.children).toBe('Fresh'); + expect(freshBananaText).toHaveTextContent('Fresh'); }); test('waits for element until timeout is met', async () => { @@ -142,10 +143,12 @@ test.each([false, true])( fireEvent.press(screen.getByText('Change freshness!')); expect(screen.queryByText('Fresh')).toBeNull(); - jest.advanceTimersByTime(300); + act(() => { + jest.advanceTimersByTime(300); + }); const freshBananaText = await waitFor(() => screen.getByText('Fresh')); - expect(freshBananaText.props.children).toBe('Fresh'); + expect(freshBananaText).toHaveTextContent('Fresh'); }, ); diff --git a/src/act.ts b/src/act.ts index 5c44ca358..d107ba807 100644 --- a/src/act.ts +++ b/src/act.ts @@ -1,9 +1,33 @@ +import * as React from 'react'; // This file and the act() implementation is sourced from react-testing-library // https://github.com/testing-library/react-testing-library/blob/c80809a956b0b9f3289c4a6fa8b5e8cc72d6ef6d/src/act-compat.js -import { act as reactTestRendererAct } from 'react-test-renderer'; -import { checkReactVersionAtLeast } from './react-versions'; -type ReactAct = typeof reactTestRendererAct; +type ReactAct = typeof React.act; + +const reactAct = React.act; + +function getGlobalThis() { + /* istanbul ignore else */ + if (typeof globalThis !== 'undefined') { + return globalThis; + } + /* istanbul ignore next */ + // eslint-disable-next-line no-restricted-globals + if (typeof self !== 'undefined') { + // eslint-disable-next-line no-restricted-globals + return self; + } + /* istanbul ignore next */ + if (typeof window !== 'undefined') { + return window; + } + /* istanbul ignore next */ + if (typeof global !== 'undefined') { + return global; + } + /* istanbul ignore next */ + throw new Error('unable to locate global object'); +} // See https://github.com/reactwg/react-18/discussions/102 for more context on global.IS_REACT_ACT_ENVIRONMENT declare global { @@ -11,31 +35,24 @@ declare global { } function setIsReactActEnvironment(isReactActEnvironment: boolean | undefined) { - globalThis.IS_REACT_ACT_ENVIRONMENT = isReactActEnvironment; + getGlobalThis().IS_REACT_ACT_ENVIRONMENT = isReactActEnvironment; } function getIsReactActEnvironment() { - return globalThis.IS_REACT_ACT_ENVIRONMENT; + return getGlobalThis().IS_REACT_ACT_ENVIRONMENT; } function withGlobalActEnvironment(actImplementation: ReactAct) { return (callback: Parameters[0]) => { const previousActEnvironment = getIsReactActEnvironment(); setIsReactActEnvironment(true); - - // this code is riddled with eslint disabling comments because this doesn't use real promises but eslint thinks we do try { // The return value of `act` is always a thenable. let callbackNeedsToBeAwaited = false; const actResult = actImplementation(() => { const result = callback(); - if ( - result !== null && - typeof result === 'object' && - // @ts-expect-error this should be a promise or thenable - // eslint-disable-next-line promise/prefer-await-to-then - typeof result.then === 'function' - ) { + // @ts-expect-error result is not typed + if (result !== null && typeof result === 'object' && typeof result.then === 'function') { callbackNeedsToBeAwaited = true; } return result; @@ -44,8 +61,8 @@ function withGlobalActEnvironment(actImplementation: ReactAct) { if (callbackNeedsToBeAwaited) { const thenable = actResult; return { - then: (resolve: (value: never) => never, reject: (value: never) => never) => { - // eslint-disable-next-line + then: (resolve: (value: unknown) => void, reject: (error: unknown) => void) => { + // eslint-disable-next-line promise/catch-or-return, promise/prefer-await-to-then thenable.then( // eslint-disable-next-line promise/always-return (returnValue) => { @@ -72,9 +89,7 @@ function withGlobalActEnvironment(actImplementation: ReactAct) { }; } -const act: ReactAct = checkReactVersionAtLeast(18, 0) - ? (withGlobalActEnvironment(reactTestRendererAct) as ReactAct) - : reactTestRendererAct; +const act: ReactAct = withGlobalActEnvironment(reactAct) as ReactAct; export default act; export { setIsReactActEnvironment as setReactActEnvironment, getIsReactActEnvironment }; diff --git a/src/config.ts b/src/config.ts index c343a3e15..7066b81a6 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,4 +1,4 @@ -import { DebugOptions } from './helpers/debug-deep'; +import type { DebugOptions } from './helpers/debug-deep'; /** * Global configuration options for React Native Testing Library. diff --git a/src/fire-event.ts b/src/fire-event.ts index 98c7745a3..db7c08fbd 100644 --- a/src/fire-event.ts +++ b/src/fire-event.ts @@ -1,4 +1,3 @@ -import { ReactTestInstance } from 'react-test-renderer'; import { ViewProps, TextProps, @@ -7,17 +6,19 @@ import { ScrollViewProps, } from 'react-native'; import act from './act'; -import { isHostElement } from './helpers/component-tree'; +import { isValidElement } from './helpers/component-tree'; import { isHostScrollView, isHostTextInput } from './helpers/host-component-names'; import { isPointerEventEnabled } from './helpers/pointer-events'; import { isTextInputEditable } from './helpers/text-input'; import { Point, StringWithAutocomplete } from './types'; import { nativeState } from './native-state'; +import { HostElement } from './renderer/host-element'; +import { EventBuilder } from './user-event/event-builder'; type EventHandler = (...args: unknown[]) => unknown; -export function isTouchResponder(element: ReactTestInstance) { - if (!isHostElement(element)) { +export function isTouchResponder(element: HostElement) { + if (!isValidElement(element)) { return false; } @@ -30,7 +31,15 @@ export function isTouchResponder(element: ReactTestInstance) { * Note: `fireEvent` is accepting both `press` and `onPress` for event names, * so we need cover both forms. */ -const eventsAffectedByPointerEventsProp = new Set(['press', 'onPress']); +const eventsAffectedByPointerEventsProp = new Set([ + 'press', + 'onPress', + 'responderGrant', + 'responderRelease', + 'longPress', + 'pressIn', + 'pressOut', +]); /** * List of `TextInput` events not affected by `editable` prop. @@ -48,13 +57,13 @@ const textInputEventsIgnoringEditableProp = new Set([ ]); export function isEventEnabled( - element: ReactTestInstance, + element: HostElement, eventName: string, - nearestTouchResponder?: ReactTestInstance, + nearestTouchResponder?: HostElement, ) { if (isHostTextInput(nearestTouchResponder)) { return ( - isTextInputEditable(nearestTouchResponder) || + isTextInputEditable(nearestTouchResponder!) || textInputEventsIgnoringEditableProp.has(eventName) ); } @@ -73,14 +82,16 @@ 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); - if (handler && isEventEnabled(element, eventName, touchResponder)) return handler; + if (handler && isEventEnabled(element, eventName, touchResponder)) { + return handler; + } // eslint-disable-next-line @typescript-eslint/prefer-optional-chain if (element.parent === null || element.parent.parent === null) { @@ -90,7 +101,7 @@ function findEventHandler( return findEventHandler(element.parent, eventName, touchResponder); } -function getEventHandler(element: ReactTestInstance, eventName: string) { +function getEventHandler(element: HostElement, eventName: string) { const eventHandlerName = getEventHandlerName(eventName); if (typeof element.props[eventHandlerName] === 'function') { return element.props[eventHandlerName]; @@ -120,7 +131,7 @@ type EventName = StringWithAutocomplete< | EventNameExtractor >; -function fireEvent(element: ReactTestInstance, eventName: EventName, ...data: unknown[]) { +function fireEvent(element: HostElement, eventName: EventName, ...data: unknown[]) { setNativeStateIfNeeded(element, eventName, data[0]); const handler = findEventHandler(element, eventName); @@ -136,13 +147,41 @@ function fireEvent(element: ReactTestInstance, eventName: EventName, ...data: un return returnValue; } -fireEvent.press = (element: ReactTestInstance, ...data: unknown[]) => +fireEvent.press = (element: HostElement, ...data: unknown[]) => { + const nativeData = + data.length === 1 && + typeof data[0] === 'object' && + data[0] !== null && + 'nativeEvent' in data[0] && + typeof data[0].nativeEvent === 'object' + ? data[0].nativeEvent + : null; + + let responderGrantEvent = EventBuilder.Common.responderGrant(); + if (nativeData) { + responderGrantEvent.nativeEvent = { + ...responderGrantEvent.nativeEvent, + ...nativeData, + }; + } + fireEvent(element, 'responderGrant', responderGrantEvent); + fireEvent(element, 'press', ...data); -fireEvent.changeText = (element: ReactTestInstance, ...data: unknown[]) => + let responderReleaseEvent = EventBuilder.Common.responderRelease(); + if (nativeData) { + responderReleaseEvent.nativeEvent = { + ...responderReleaseEvent.nativeEvent, + ...nativeData, + }; + } + fireEvent(element, 'responderRelease', responderReleaseEvent); +}; + +fireEvent.changeText = (element: HostElement, ...data: unknown[]) => fireEvent(element, 'changeText', ...data); -fireEvent.scroll = (element: ReactTestInstance, ...data: unknown[]) => +fireEvent.scroll = (element: HostElement, ...data: unknown[]) => fireEvent(element, 'scroll', ...data); export default fireEvent; @@ -155,7 +194,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' && diff --git a/src/helpers/__tests__/component-tree.test.tsx b/src/helpers/__tests__/component-tree.test.tsx index 0746d58ff..f858a2b55 100644 --- a/src/helpers/__tests__/component-tree.test.tsx +++ b/src/helpers/__tests__/component-tree.test.tsx @@ -1,17 +1,7 @@ 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 { getHostSiblings, getRootElement } from '../component-tree'; function MultipleHostChildren() { return ( @@ -23,155 +13,6 @@ 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( @@ -194,31 +35,10 @@ 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', () => { +describe('getRootElement()', () => { + it('returns container for mounted view', () => { render( @@ -226,10 +46,6 @@ describe('getUnsafeRootElement()', () => { ); const view = screen.getByTestId('view'); - expect(getUnsafeRootElement(view)).toEqual(screen.UNSAFE_root); - }); - - it('returns null for null', () => { - expect(getUnsafeRootElement(null)).toEqual(null); + expect(getRootElement(view)).toEqual(screen.container); }); }); diff --git a/src/helpers/accessibility.ts b/src/helpers/accessibility.ts index 5eee9401a..4dfc40f6b 100644 --- a/src/helpers/accessibility.ts +++ b/src/helpers/accessibility.ts @@ -5,8 +5,8 @@ import { Role, StyleSheet, } from 'react-native'; -import { ReactTestInstance } from 'react-test-renderer'; -import { getHostSiblings, getUnsafeRootElement } from './component-tree'; +import { HostElement } from '../renderer/host-element'; +import { getHostSiblings, getRootElement } from './component-tree'; import { getHostComponentNames, isHostImage, @@ -16,9 +16,10 @@ import { } from './host-component-names'; import { getTextContent } from './text-content'; import { isTextInputEditable } from './text-input'; +import { findAllByProps } from './find-all'; type IsInaccessibleOptions = { - cache?: WeakMap; + cache?: WeakMap; }; export const accessibilityStateKeys: (keyof AccessibilityState)[] = [ @@ -32,14 +33,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); @@ -61,7 +62,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; @@ -98,7 +99,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; } @@ -133,7 +134,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); @@ -164,11 +165,11 @@ 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 explicitLabel = element.props['aria-label'] ?? element.props.accessibilityLabel; if (explicitLabel) { return explicitLabel; @@ -182,17 +183,17 @@ export function computeAriaLabel(element: ReactTestInstance): string | undefined return undefined; } -export function computeAriaLabelledBy(element: ReactTestInstance): string | undefined { +export function computeAriaLabelledBy(element: HostElement): string | undefined { return element.props['aria-labelledby'] ?? element.props.accessibilityLabelledBy; } // 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)) { @@ -208,7 +209,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) && !isTextInputEditable(element)) { return true; } @@ -218,16 +219,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 +245,7 @@ export function computeAriaValue(element: ReactTestInstance): AccessibilityValue }; } -export function computeAccessibleName(element: ReactTestInstance): string | undefined { +export function computeAccessibleName(element: HostElement): string | undefined { const label = computeAriaLabel(element); if (label) { return label; @@ -252,10 +253,10 @@ export function computeAccessibleName(element: ReactTestInstance): string | unde const labelElementId = computeAriaLabelledBy(element); if (labelElementId) { - const rootElement = getUnsafeRootElement(element); - const labelElement = rootElement?.findByProps({ nativeID: labelElementId }); - if (labelElement) { - return getTextContent(labelElement); + const rootElement = getRootElement(element); + const labelElement = findAllByProps(rootElement, { nativeID: labelElementId }); + if (labelElement.length > 0) { + return getTextContent(labelElement[0]); } } diff --git a/src/helpers/component-tree.ts b/src/helpers/component-tree.ts index 8387278b5..247484b04 100644 --- a/src/helpers/component-tree.ts +++ b/src/helpers/component-tree.ts @@ -1,101 +1,38 @@ -import { ReactTestInstance } from 'react-test-renderer'; - -/** - * ReactTestInstance referring to host element. - */ -export type HostTestInstance = ReactTestInstance & { type: string }; +import { CONTAINER_TYPE } from '../renderer/contants'; +import { HostElement } from '../renderer/host-element'; /** * 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 isValidElement(element?: HostElement | null): element is HostElement { + return typeof element?.type === 'string' && element.type !== CONTAINER_TYPE; } /** - * Returns first host ancestor for given element. + * Returns the unsafe root element of the tree (probably composite). + * * @param element The element start traversing from. + * @returns The root element of the tree (host or composite). */ -export function getHostParent(element: ReactTestInstance | null): HostTestInstance | null { - if (element == null) { - return null; - } - - let current = element.parent; - while (current) { - if (isHostElement(current)) { - return current; - } - +export function getRootElement(element: HostElement) { + let current: HostElement | null = element; + while (current?.parent) { current = current.parent; } - return null; -} - -/** - * Returns host children for given element. - * @param element The element start traversing from. - */ -export function getHostChildren(element: ReactTestInstance | null): HostTestInstance[] { - if (element == null) { - 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); + return current; } /** * 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)); -} - -/** - * Returns the unsafe root element of the tree (probably composite). - * - * @param element The element start traversing from. - * @returns The root element of the tree (host or composite). - */ -export function getUnsafeRootElement(element: ReactTestInstance | null) { - if (element == null) { - return null; - } - - let current = element; - while (current.parent) { - current = current.parent; - } - - return current; +export function getHostSiblings(element: HostElement | null): HostElement[] { + const hostParent = element?.parent ?? null; + return ( + hostParent?.children.filter( + (sibling): sibling is HostElement => typeof sibling === 'object' && sibling !== element, + ) ?? [] + ); } diff --git a/src/helpers/debug-deep.ts b/src/helpers/debug-deep.ts index 0450330e9..272458b4d 100644 --- a/src/helpers/debug-deep.ts +++ b/src/helpers/debug-deep.ts @@ -1,4 +1,4 @@ -import type { ReactTestRendererJSON } from 'react-test-renderer'; +import type { JsonNode } from '../renderer/render-to-json'; import format, { FormatOptions } from './format'; export type DebugOptions = { @@ -9,7 +9,7 @@ export type DebugOptions = { * Log pretty-printed deep test component instance */ export default function debugDeep( - instance: ReactTestRendererJSON | ReactTestRendererJSON[], + instance: JsonNode | JsonNode[], options?: DebugOptions | string, ) { const message = typeof options === 'string' ? options : options?.message; diff --git a/src/helpers/debug-shallow.ts b/src/helpers/debug-shallow.ts deleted file mode 100644 index 510a1f402..000000000 --- a/src/helpers/debug-shallow.ts +++ /dev/null @@ -1,22 +0,0 @@ -import * as React from 'react'; -import type { ReactTestInstance } from 'react-test-renderer'; -import { shallowInternal } from '../shallow'; -import format from './format'; - -/** - * Log pretty-printed shallow test component instance - */ -export default function debugShallow( - instance: ReactTestInstance | React.ReactElement, - message?: string, -) { - const { output } = shallowInternal(instance); - - if (message) { - // eslint-disable-next-line no-console - console.log(`${message}\n\n`, format(output)); - } else { - // eslint-disable-next-line no-console - console.log(format(output)); - } -} diff --git a/src/helpers/find-all.ts b/src/helpers/find-all.ts index ffd62f936..5fcb5c3f0 100644 --- a/src/helpers/find-all.ts +++ b/src/helpers/find-all.ts @@ -1,7 +1,7 @@ -import { ReactTestInstance } from 'react-test-renderer'; import { getConfig } from '../config'; +import { HostElement } from '../renderer/host-element'; import { isHiddenFromAccessibility } from './accessibility'; -import { HostTestInstance, isHostElement } from './component-tree'; +import { isValidElement } from './component-tree'; interface FindAllOptions { /** Match elements hidden from accessibility */ @@ -15,10 +15,10 @@ interface FindAllOptions { } export function findAll( - root: ReactTestInstance, - predicate: (element: ReactTestInstance) => boolean, + root: HostElement, + predicate: (element: HostElement) => boolean, options?: FindAllOptions, -): HostTestInstance[] { +): HostElement[] { const results = findAllInternal(root, predicate, options); const includeHiddenElements = @@ -28,35 +28,38 @@ 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, + node: HostElement, + predicate: (element: HostElement) => boolean, options?: FindAllOptions, -): HostTestInstance[] { - const results: HostTestInstance[] = []; + indent: string = '', +): HostElement[] { + const results: HostElement[] = []; + + //console.log(`${indent} 🟢 findAllInternal`, node.type, node.props); // Match descendants first but do not add them to results yet. - const matchingDescendants: HostTestInstance[] = []; - root.children.forEach((child) => { + const matchingDescendants: HostElement[] = []; + node.children.forEach((child) => { if (typeof child === 'string') { return; } - matchingDescendants.push(...findAllInternal(child, predicate, options)); + matchingDescendants.push(...findAllInternal(child, predicate, options, indent + ' ')); }); if ( // When matchDeepestOnly = true: add current element only if no descendants match (!options?.matchDeepestOnly || matchingDescendants.length === 0) && - isHostElement(root) && - predicate(root) + isValidElement(node) && + predicate(node) ) { - results.push(root); + results.push(node); } // Add matching descendants after element to preserve original tree walk order. @@ -64,3 +67,20 @@ function findAllInternal( return results; } + +export function findAllByProps( + root: HostElement, + props: { [propName: string]: any }, + options?: FindAllOptions, +): HostElement[] { + return findAll(root, (element) => matchProps(element, props), options); +} + +function matchProps(element: HostElement, props: { [propName: string]: any }): boolean { + for (const key in props) { + if (props[key] !== element.props[key]) { + return false; + } + } + return true; +} diff --git a/src/helpers/format.ts b/src/helpers/format.ts index 33dfb2e3f..89f3c0454 100644 --- a/src/helpers/format.ts +++ b/src/helpers/format.ts @@ -1,19 +1,16 @@ -import type { ReactTestRendererJSON } from 'react-test-renderer'; import prettyFormat, { NewPlugin, plugins } from 'pretty-format'; +import { JsonInstance, JsonNode } from '../renderer/render-to-json'; export type MapPropsFunction = ( props: Record, - node: ReactTestRendererJSON, + node: JsonInstance, ) => Record; export type FormatOptions = { mapProps?: MapPropsFunction; }; -const format = ( - input: ReactTestRendererJSON | ReactTestRendererJSON[], - options: FormatOptions = {}, -) => +const format = (input: JsonNode | JsonNode[], options: FormatOptions = {}) => prettyFormat(input, { plugins: [getCustomPlugin(options.mapProps), plugins.ReactElement], highlight: true, diff --git a/src/helpers/host-component-names.tsx b/src/helpers/host-component-names.tsx index b450c930b..81b131314 100644 --- a/src/helpers/host-component-names.tsx +++ b/src/helpers/host-component-names.tsx @@ -1,9 +1,10 @@ import * as React from 'react'; -import { ReactTestInstance } from 'react-test-renderer'; import { Image, Modal, ScrollView, Switch, Text, TextInput, View } from 'react-native'; import { configureInternal, getConfig, HostComponentNames } from '../config'; -import { renderWithAct } from '../render-act'; -import { HostTestInstance } from './component-tree'; +import { HostElement } from '../renderer/host-element'; +import { createRenderer } from '../renderer/renderer'; +import act from '../act'; +import { findAll } from './find-all'; const userConfigErrorMessage = `There seems to be an issue with your configuration that prevents React Native Testing Library from working correctly. Please check if you are using compatible versions of React Native and React Native Testing Library.`; @@ -30,24 +31,26 @@ export function configureHostComponentNamesIfNeeded() { function detectHostComponentNames(): HostComponentNames { try { - const renderer = renderWithAct( - - Hello - - - - - - , - ); - + const renderer = createRenderer(); + act(() => { + renderer.render( + + Hello + + + + + + , + ); + }); return { - text: getByTestId(renderer.root, 'text').type as string, - textInput: getByTestId(renderer.root, 'textInput').type as string, - image: getByTestId(renderer.root, 'image').type as string, - switch: getByTestId(renderer.root, 'switch').type as string, - scrollView: getByTestId(renderer.root, 'scrollView').type as string, - modal: getByTestId(renderer.root, 'modal').type as string, + text: getByTestId(renderer.container, 'text').type as string, + textInput: getByTestId(renderer.container, 'textInput').type as string, + image: getByTestId(renderer.container, 'image').type as string, + switch: getByTestId(renderer.container, 'switch').type as string, + scrollView: getByTestId(renderer.container, 'scrollView').type as string, + modal: getByTestId(renderer.container, 'modal').type as string, }; } catch (error) { const errorMessage = @@ -59,8 +62,9 @@ function detectHostComponentNames(): HostComponentNames { } } -function getByTestId(instance: ReactTestInstance, testID: string) { - const nodes = instance.findAll( +function getByTestId(element: HostElement, testID: string) { + const nodes = findAll( + element, (node) => typeof node.type === 'string' && node.props.testID === testID, ); @@ -75,7 +79,7 @@ function getByTestId(instance: ReactTestInstance, testID: string) { * Checks if the given element is a host Text element. * @param element The element to check. */ -export function isHostText(element?: ReactTestInstance | null): element is HostTestInstance { +export function isHostText(element?: HostElement | null) { return element?.type === getHostComponentNames().text; } @@ -83,7 +87,7 @@ export function isHostText(element?: ReactTestInstance | null): element is HostT * Checks if the given element is a host TextInput element. * @param element The element to check. */ -export function isHostTextInput(element?: ReactTestInstance | null): element is HostTestInstance { +export function isHostTextInput(element?: HostElement | null) { return element?.type === getHostComponentNames().textInput; } @@ -91,7 +95,7 @@ export function isHostTextInput(element?: ReactTestInstance | null): element is * Checks if the given element is a host Image element. * @param element The element to check. */ -export function isHostImage(element?: ReactTestInstance | null): element is HostTestInstance { +export function isHostImage(element?: HostElement | null) { return element?.type === getHostComponentNames().image; } @@ -99,7 +103,7 @@ export function isHostImage(element?: ReactTestInstance | null): element is Host * Checks if the given element is a host Switch element. * @param element The element to check. */ -export function isHostSwitch(element?: ReactTestInstance | null): element is HostTestInstance { +export function isHostSwitch(element?: HostElement | null) { return element?.type === getHostComponentNames().switch; } @@ -107,7 +111,7 @@ export function isHostSwitch(element?: ReactTestInstance | null): element is Hos * Checks if the given element is a host ScrollView element. * @param element The element to check. */ -export function isHostScrollView(element?: ReactTestInstance | null): element is HostTestInstance { +export function isHostScrollView(element?: HostElement | null) { return element?.type === getHostComponentNames().scrollView; } @@ -115,6 +119,6 @@ export function isHostScrollView(element?: ReactTestInstance | null): element is * Checks if the given element is a host Modal element. * @param element The element to check. */ -export function isHostModal(element?: ReactTestInstance | null): element is HostTestInstance { +export function isHostModal(element?: HostElement | null) { return element?.type === getHostComponentNames().modal; } diff --git a/src/helpers/matchers/match-accessibility-state.ts b/src/helpers/matchers/match-accessibility-state.ts index 099300db3..aa45a7a64 100644 --- a/src/helpers/matchers/match-accessibility-state.ts +++ b/src/helpers/matchers/match-accessibility-state.ts @@ -1,4 +1,4 @@ -import { ReactTestInstance } from 'react-test-renderer'; +import { HostElement } from '../../renderer/host-element'; import { computeAriaBusy, computeAriaChecked, @@ -19,10 +19,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 c9370166c..e80f5238d 100644 --- a/src/helpers/matchers/match-accessibility-value.ts +++ b/src/helpers/matchers/match-accessibility-value.ts @@ -1,6 +1,6 @@ -import { ReactTestInstance } from 'react-test-renderer'; import { computeAriaValue } from '../accessibility'; import { TextMatch } from '../../matches'; +import { HostElement } from '../../renderer/host-element'; import { matchStringProp } from './match-string-prop'; export interface AccessibilityValueMatcher { @@ -11,7 +11,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 1da29d867..320a39728 100644 --- a/src/helpers/matchers/match-label-text.ts +++ b/src/helpers/matchers/match-label-text.ts @@ -1,12 +1,12 @@ -import { ReactTestInstance } from 'react-test-renderer'; import { matches, TextMatch, TextMatchOptions } from '../../matches'; +import { HostElement } from '../../renderer/host-element'; import { computeAriaLabel, computeAriaLabelledBy } from '../accessibility'; import { findAll } from '../find-all'; import { matchTextContent } from './match-text-content'; export function matchLabelText( - root: ReactTestInstance, - element: ReactTestInstance, + root: HostElement, + element: HostElement, expectedText: TextMatch, options: TextMatchOptions = {}, ) { @@ -17,7 +17,7 @@ export function matchLabelText( } function matchAccessibilityLabel( - element: ReactTestInstance, + element: HostElement, expectedLabel: TextMatch, options: TextMatchOptions, ) { @@ -25,7 +25,7 @@ function matchAccessibilityLabel( } function matchAccessibilityLabelledBy( - root: ReactTestInstance, + root: HostElement, nativeId: string | undefined, text: TextMatch, options: TextMatchOptions, diff --git a/src/helpers/matchers/match-text-content.ts b/src/helpers/matchers/match-text-content.ts index 41b1d126c..7b70f2bf9 100644 --- a/src/helpers/matchers/match-text-content.ts +++ b/src/helpers/matchers/match-text-content.ts @@ -1,5 +1,5 @@ -import type { ReactTestInstance } from 'react-test-renderer'; import { matches, TextMatch, TextMatchOptions } from '../../matches'; +import { HostElement } from '../../renderer/host-element'; import { getTextContent } from '../text-content'; /** @@ -10,7 +10,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 921b607c8..47338665a 100644 --- a/src/helpers/pointer-events.ts +++ b/src/helpers/pointer-events.ts @@ -1,5 +1,4 @@ -import { ReactTestInstance } from 'react-test-renderer'; -import { getHostParent } from './component-tree'; +import { HostElement } from '../renderer/host-element'; /** * pointerEvents controls whether the View can be the target of touch events. @@ -8,7 +7,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 => { const parentCondition = isParent ? element?.props.pointerEvents === 'box-only' : element?.props.pointerEvents === 'box-none'; @@ -17,7 +16,7 @@ export const isPointerEventEnabled = (element: ReactTestInstance, isParent?: boo return false; } - const hostParent = getHostParent(element); + const hostParent = element.parent; if (!hostParent) return true; return isPointerEventEnabled(hostParent, true); diff --git a/src/helpers/string-validation.ts b/src/helpers/string-validation.ts deleted file mode 100644 index 6f7433ffb..000000000 --- a/src/helpers/string-validation.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { 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..22e5c65ea 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 { HostElement } from '../renderer/host-element'; -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 bf76389fe..c69d29a1a 100644 --- a/src/helpers/text-input.ts +++ b/src/helpers/text-input.ts @@ -1,8 +1,8 @@ -import { ReactTestInstance } from 'react-test-renderer'; import { nativeState } from '../native-state'; +import { HostElement } from '../renderer/host-element'; import { isHostTextInput } from './host-component-names'; -export function isTextInputEditable(element: ReactTestInstance) { +export function isTextInputEditable(element: HostElement) { if (!isHostTextInput(element)) { throw new Error(`Element is not a "TextInput", but it has type "${element.type}".`); } @@ -10,7 +10,7 @@ export function isTextInputEditable(element: ReactTestInstance) { return 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/wrap-async.ts b/src/helpers/wrap-async.ts index c22a1df5e..883673a70 100644 --- a/src/helpers/wrap-async.ts +++ b/src/helpers/wrap-async.ts @@ -6,6 +6,7 @@ import { checkReactVersionAtLeast } from '../react-versions'; /** * Run given async callback with temporarily disabled `act` environment and flushes microtasks queue. + * See: https://github.com/testing-library/react-testing-library/blob/3dcd8a9649e25054c0e650d95fca2317b7008576/src/pure.js#L37 * * @param callback Async callback to run * @returns Result of the callback diff --git a/src/index.ts b/src/index.ts index 5c867106f..7ec2e9c62 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,3 +31,4 @@ if (!process?.env?.RNTL_SKIP_AUTO_CLEANUP) { } export * from './pure'; +export { HostElement } from './renderer/host-element'; diff --git a/src/matchers/__tests__/to-be-checked.test.tsx b/src/matchers/__tests__/to-be-checked.test.tsx index 85dc39aa7..6cdc2979a 100644 --- a/src/matchers/__tests__/to-be-checked.test.tsx +++ b/src/matchers/__tests__/to-be-checked.test.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { type AccessibilityRole, Switch, View } from 'react-native'; -import render from '../../render'; -import { screen } from '../../screen'; +import { render, screen } from '../..'; import '../extend-expect'; function renderViewsWithRole(role: AccessibilityRole) { diff --git a/src/matchers/__tests__/to-be-partially-checked.test.tsx b/src/matchers/__tests__/to-be-partially-checked.test.tsx index 69df9b76c..751fd3c36 100644 --- a/src/matchers/__tests__/to-be-partially-checked.test.tsx +++ b/src/matchers/__tests__/to-be-partially-checked.test.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { type AccessibilityRole, View } from 'react-native'; -import render from '../../render'; -import { screen } from '../../screen'; +import { render, screen } from '../..'; import '../extend-expect'; function renderViewsWithRole(role: AccessibilityRole) { diff --git a/src/matchers/__tests__/to-have-accessible-name.test.tsx b/src/matchers/__tests__/to-have-accessible-name.test.tsx index 4bb8f92cc..01627c7e0 100644 --- a/src/matchers/__tests__/to-have-accessible-name.test.tsx +++ b/src/matchers/__tests__/to-have-accessible-name.test.tsx @@ -119,14 +119,14 @@ test('toHaveAccessibleName() handles a view without name when called without exp }); it('toHaveAccessibleName() rejects non-host element', () => { - const nonElement = 'This is not a ReactTestInstance'; + const nonElement = 'This is not a HostElement'; expect(() => expect(nonElement).toHaveAccessibleName()).toThrowErrorMatchingInlineSnapshot(` "expect(received).toHaveAccessibleName() received value must be a host element. Received has type: string - Received has value: "This is not a ReactTestInstance"" + Received has value: "This is not a HostElement"" `); expect(() => expect(nonElement).not.toHaveAccessibleName()).toThrowErrorMatchingInlineSnapshot(` @@ -134,6 +134,6 @@ it('toHaveAccessibleName() rejects non-host element', () => { received value must be a host element. Received has type: string - Received has value: "This is not a ReactTestInstance"" + Received has value: "This is not a HostElement"" `); }); diff --git a/src/matchers/__tests__/to-have-text-content.test.tsx b/src/matchers/__tests__/to-have-text-content.test.tsx index 082555a96..b06b951d7 100644 --- a/src/matchers/__tests__/to-have-text-content.test.tsx +++ b/src/matchers/__tests__/to-have-text-content.test.tsx @@ -6,7 +6,8 @@ import '../extend-expect'; test('toHaveTextContent() example test', () => { render( - Hello World + Hello + World , ); diff --git a/src/matchers/__tests__/utils.test.tsx b/src/matchers/__tests__/utils.test.tsx index 31cfdade9..1a73b78f1 100644 --- a/src/matchers/__tests__/utils.test.tsx +++ b/src/matchers/__tests__/utils.test.tsx @@ -25,7 +25,7 @@ test('checkHostElement allows rejects composite element', () => { expect(() => { // @ts-expect-error - checkHostElement(screen.UNSAFE_root, fakeMatcher, {}); + checkHostElement(screen.container, fakeMatcher, {}); }).toThrow(/value must be a host element./); }); diff --git a/src/matchers/to-be-busy.tsx b/src/matchers/to-be-busy.tsx index effc027c1..dce03a08d 100644 --- a/src/matchers/to-be-busy.tsx +++ b/src/matchers/to-be-busy.tsx @@ -1,9 +1,9 @@ -import { ReactTestInstance } from 'react-test-renderer'; import { matcherHint } from 'jest-matcher-utils'; import { computeAriaBusy } from '../helpers/accessibility'; +import { HostElement } from '../renderer/host-element'; import { checkHostElement, formatElement } from './utils'; -export function toBeBusy(this: jest.MatcherContext, element: ReactTestInstance) { +export function toBeBusy(this: jest.MatcherContext, element: HostElement) { checkHostElement(element, toBeBusy, this); return { diff --git a/src/matchers/to-be-checked.tsx b/src/matchers/to-be-checked.tsx index fb9be3927..491ef64af 100644 --- a/src/matchers/to-be-checked.tsx +++ b/src/matchers/to-be-checked.tsx @@ -1,4 +1,3 @@ -import type { ReactTestInstance } from 'react-test-renderer'; import { matcherHint } from 'jest-matcher-utils'; import { computeAriaChecked, @@ -8,9 +7,10 @@ import { } from '../helpers/accessibility'; import { ErrorWithStack } from '../helpers/errors'; import { isHostSwitch } from '../helpers/host-component-names'; +import { HostElement } from '../renderer/host-element'; import { checkHostElement, formatElement } from './utils'; -export function toBeChecked(this: jest.MatcherContext, element: ReactTestInstance) { +export function toBeChecked(this: jest.MatcherContext, element: HostElement) { checkHostElement(element, toBeChecked, this); if (!isHostSwitch(element) && !isSupportedAccessibilityElement(element)) { @@ -34,7 +34,7 @@ export function toBeChecked(this: jest.MatcherContext, element: ReactTestInstanc }; } -function isSupportedAccessibilityElement(element: ReactTestInstance) { +function isSupportedAccessibilityElement(element: HostElement) { if (!isAccessibilityElement(element)) { return false; } diff --git a/src/matchers/to-be-disabled.tsx b/src/matchers/to-be-disabled.tsx index 3c917e078..dc17a3d54 100644 --- a/src/matchers/to-be-disabled.tsx +++ b/src/matchers/to-be-disabled.tsx @@ -1,10 +1,9 @@ -import type { ReactTestInstance } from 'react-test-renderer'; import { matcherHint } from 'jest-matcher-utils'; import { computeAriaDisabled } from '../helpers/accessibility'; -import { getHostParent } from '../helpers/component-tree'; +import { HostElement } from '../renderer/host-element'; import { checkHostElement, formatElement } from './utils'; -export function toBeDisabled(this: jest.MatcherContext, element: ReactTestInstance) { +export function toBeDisabled(this: jest.MatcherContext, element: HostElement) { checkHostElement(element, toBeDisabled, this); const isDisabled = computeAriaDisabled(element) || isAncestorDisabled(element); @@ -23,7 +22,7 @@ export function toBeDisabled(this: jest.MatcherContext, element: ReactTestInstan }; } -export function toBeEnabled(this: jest.MatcherContext, element: ReactTestInstance) { +export function toBeEnabled(this: jest.MatcherContext, element: HostElement) { checkHostElement(element, toBeEnabled, this); const isEnabled = !computeAriaDisabled(element) && !isAncestorDisabled(element); @@ -42,8 +41,8 @@ export function toBeEnabled(this: jest.MatcherContext, element: ReactTestInstanc }; } -function isAncestorDisabled(element: ReactTestInstance): boolean { - const parent = getHostParent(element); +function isAncestorDisabled(element: HostElement): boolean { + const parent = element.parent; if (parent == null) { return false; } diff --git a/src/matchers/to-be-empty-element.tsx b/src/matchers/to-be-empty-element.tsx index 8a61bf269..98306f7c3 100644 --- a/src/matchers/to-be-empty-element.tsx +++ b/src/matchers/to-be-empty-element.tsx @@ -1,12 +1,11 @@ -import { ReactTestInstance } from 'react-test-renderer'; import { matcherHint, RECEIVED_COLOR } from 'jest-matcher-utils'; -import { getHostChildren } from '../helpers/component-tree'; +import { HostElement } from '../renderer/host-element'; import { checkHostElement, formatElementArray } from './utils'; -export function toBeEmptyElement(this: jest.MatcherContext, element: ReactTestInstance) { +export function toBeEmptyElement(this: jest.MatcherContext, element: HostElement) { checkHostElement(element, toBeEmptyElement, this); - const hostChildren = getHostChildren(element); + const hostChildren = element.children; return { pass: hostChildren.length === 0, diff --git a/src/matchers/to-be-expanded.tsx b/src/matchers/to-be-expanded.tsx index cc0744a79..8ba3fc2f0 100644 --- a/src/matchers/to-be-expanded.tsx +++ b/src/matchers/to-be-expanded.tsx @@ -1,9 +1,9 @@ -import { ReactTestInstance } from 'react-test-renderer'; import { matcherHint } from 'jest-matcher-utils'; import { computeAriaExpanded } from '../helpers/accessibility'; +import { HostElement } from '../renderer/host-element'; import { checkHostElement, formatElement } from './utils'; -export function toBeExpanded(this: jest.MatcherContext, element: ReactTestInstance) { +export function toBeExpanded(this: jest.MatcherContext, element: HostElement) { checkHostElement(element, toBeExpanded, this); return { @@ -20,7 +20,7 @@ export function toBeExpanded(this: jest.MatcherContext, element: ReactTestInstan }; } -export function toBeCollapsed(this: jest.MatcherContext, element: ReactTestInstance) { +export function toBeCollapsed(this: jest.MatcherContext, element: HostElement) { checkHostElement(element, toBeCollapsed, this); return { diff --git a/src/matchers/to-be-on-the-screen.tsx b/src/matchers/to-be-on-the-screen.tsx index c00958222..6015dc8bb 100644 --- a/src/matchers/to-be-on-the-screen.tsx +++ b/src/matchers/to-be-on-the-screen.tsx @@ -1,15 +1,15 @@ -import type { ReactTestInstance } from 'react-test-renderer'; import { matcherHint, RECEIVED_COLOR } from 'jest-matcher-utils'; -import { getUnsafeRootElement } from '../helpers/component-tree'; +import { getRootElement } from '../helpers/component-tree'; +import { HostElement } from '../renderer/host-element'; import { screen } from '../screen'; import { checkHostElement, formatElement } from './utils'; -export function toBeOnTheScreen(this: jest.MatcherContext, element: ReactTestInstance) { +export function toBeOnTheScreen(this: jest.MatcherContext, element: HostElement) { if (element !== null || !this.isNot) { checkHostElement(element, toBeOnTheScreen, this); } - const pass = element === null ? false : screen.UNSAFE_root === getUnsafeRootElement(element); + const pass = element === null ? false : screen.container === getRootElement(element); const errorFound = () => { return `expected element tree not to contain element, but found\n${formatElement(element)}`; diff --git a/src/matchers/to-be-partially-checked.tsx b/src/matchers/to-be-partially-checked.tsx index 975c48e93..63f97f500 100644 --- a/src/matchers/to-be-partially-checked.tsx +++ b/src/matchers/to-be-partially-checked.tsx @@ -1,10 +1,10 @@ -import type { ReactTestInstance } from 'react-test-renderer'; import { matcherHint } from 'jest-matcher-utils'; import { computeAriaChecked, getRole, isAccessibilityElement } from '../helpers/accessibility'; import { ErrorWithStack } from '../helpers/errors'; +import { HostElement } from '../renderer/host-element'; import { checkHostElement, formatElement } from './utils'; -export function toBePartiallyChecked(this: jest.MatcherContext, element: ReactTestInstance) { +export function toBePartiallyChecked(this: jest.MatcherContext, element: HostElement) { checkHostElement(element, toBePartiallyChecked, this); if (!hasValidAccessibilityRole(element)) { @@ -28,7 +28,7 @@ export function toBePartiallyChecked(this: jest.MatcherContext, element: ReactTe }; } -function hasValidAccessibilityRole(element: ReactTestInstance) { +function hasValidAccessibilityRole(element: HostElement) { const role = getRole(element); return isAccessibilityElement(element) && role === 'checkbox'; } diff --git a/src/matchers/to-be-selected.ts b/src/matchers/to-be-selected.ts index be03cc997..6f3087ad0 100644 --- a/src/matchers/to-be-selected.ts +++ b/src/matchers/to-be-selected.ts @@ -1,9 +1,9 @@ -import { ReactTestInstance } from 'react-test-renderer'; import { matcherHint } from 'jest-matcher-utils'; import { computeAriaSelected } from '../helpers/accessibility'; +import { HostElement } from '../renderer/host-element'; import { checkHostElement, formatElement } from './utils'; -export function toBeSelected(this: jest.MatcherContext, element: ReactTestInstance) { +export function toBeSelected(this: jest.MatcherContext, element: HostElement) { checkHostElement(element, toBeSelected, this); return { diff --git a/src/matchers/to-be-visible.tsx b/src/matchers/to-be-visible.tsx index e817f6701..370ef8a94 100644 --- a/src/matchers/to-be-visible.tsx +++ b/src/matchers/to-be-visible.tsx @@ -1,12 +1,11 @@ -import type { ReactTestInstance } from 'react-test-renderer'; import { matcherHint } from 'jest-matcher-utils'; import { StyleSheet } from 'react-native'; import { isHiddenFromAccessibility } from '../helpers/accessibility'; -import { getHostParent } from '../helpers/component-tree'; import { isHostModal } from '../helpers/host-component-names'; +import { HostElement } from '../renderer/host-element'; import { checkHostElement, formatElement } from './utils'; -export function toBeVisible(this: jest.MatcherContext, element: ReactTestInstance) { +export function toBeVisible(this: jest.MatcherContext, element: HostElement) { if (element !== null || !this.isNot) { checkHostElement(element, toBeVisible, this); } @@ -26,11 +25,11 @@ export function toBeVisible(this: jest.MatcherContext, element: ReactTestInstanc } function isElementVisible( - element: ReactTestInstance, - accessibilityCache?: WeakMap, + element: HostElement, + accessibilityCache?: WeakMap, ): boolean { // Use cache to speed up repeated searches by `isHiddenFromAccessibility`. - const cache = accessibilityCache ?? new WeakMap(); + const cache = accessibilityCache ?? new WeakMap(); if (isHiddenFromAccessibility(element, { cache })) { return false; } @@ -45,7 +44,7 @@ function isElementVisible( return false; } - const hostParent = getHostParent(element); + const hostParent = element.parent; if (hostParent === null) { return true; } @@ -53,7 +52,7 @@ function isElementVisible( return isElementVisible(hostParent, cache); } -function isHiddenForStyles(element: ReactTestInstance) { +function isHiddenForStyles(element: HostElement) { const flatStyle = StyleSheet.flatten(element.props.style); return flatStyle?.display === 'none' || flatStyle?.opacity === 0; } diff --git a/src/matchers/to-contain-element.tsx b/src/matchers/to-contain-element.tsx index 875c6c18a..ccebbf2b7 100644 --- a/src/matchers/to-contain-element.tsx +++ b/src/matchers/to-contain-element.tsx @@ -1,11 +1,12 @@ -import { ReactTestInstance } from 'react-test-renderer'; import { matcherHint, RECEIVED_COLOR } from 'jest-matcher-utils'; +import { HostElement } from '../renderer/host-element'; +import { findAll } from '../helpers/find-all'; import { checkHostElement, formatElement } from './utils'; export function toContainElement( this: jest.MatcherContext, - container: ReactTestInstance, - element: ReactTestInstance | null, + container: HostElement, + element: HostElement | null, ) { checkHostElement(container, toContainElement, this); @@ -13,9 +14,9 @@ export function toContainElement( checkHostElement(element, toContainElement, this); } - let matches: ReactTestInstance[] = []; + let matches: HostElement[] = []; if (element) { - matches = container.findAll((node) => node === element); + matches = findAll(container, (node) => node === element); } return { diff --git a/src/matchers/to-have-accessibility-value.tsx b/src/matchers/to-have-accessibility-value.tsx index 6241adc28..8260162f7 100644 --- a/src/matchers/to-have-accessibility-value.tsx +++ b/src/matchers/to-have-accessibility-value.tsx @@ -1,4 +1,3 @@ -import type { ReactTestInstance } from 'react-test-renderer'; import { matcherHint, stringify } from 'jest-matcher-utils'; import { computeAriaValue } from '../helpers/accessibility'; import { @@ -6,11 +5,12 @@ import { matchAccessibilityValue, } from '../helpers/matchers/match-accessibility-value'; import { removeUndefinedKeys } from '../helpers/object'; +import { HostElement } from '../renderer/host-element'; import { checkHostElement, formatMessage } from './utils'; export function toHaveAccessibilityValue( this: jest.MatcherContext, - element: ReactTestInstance, + element: HostElement, expectedValue: AccessibilityValueMatcher, ) { checkHostElement(element, toHaveAccessibilityValue, this); diff --git a/src/matchers/to-have-accessible-name.tsx b/src/matchers/to-have-accessible-name.tsx index fce6ac365..c777994c5 100644 --- a/src/matchers/to-have-accessible-name.tsx +++ b/src/matchers/to-have-accessible-name.tsx @@ -1,12 +1,12 @@ -import type { ReactTestInstance } from 'react-test-renderer'; import { matcherHint } from 'jest-matcher-utils'; import { computeAccessibleName } from '../helpers/accessibility'; +import { HostElement } from '../renderer/host-element'; import { TextMatch, TextMatchOptions, matches } from '../matches'; import { checkHostElement, formatMessage } from './utils'; export function toHaveAccessibleName( this: jest.MatcherContext, - element: ReactTestInstance, + element: HostElement, expectedName?: TextMatch, options?: TextMatchOptions, ) { diff --git a/src/matchers/to-have-display-value.tsx b/src/matchers/to-have-display-value.tsx index a47511e7e..7bfd97c58 100644 --- a/src/matchers/to-have-display-value.tsx +++ b/src/matchers/to-have-display-value.tsx @@ -1,14 +1,14 @@ -import type { ReactTestInstance } from 'react-test-renderer'; import { matcherHint } from 'jest-matcher-utils'; import { isHostTextInput } from '../helpers/host-component-names'; import { ErrorWithStack } from '../helpers/errors'; import { getTextInputValue } from '../helpers/text-input'; +import { HostElement } from '../renderer/host-element'; import { TextMatch, TextMatchOptions, matches } from '../matches'; import { checkHostElement, formatMessage } from './utils'; export function toHaveDisplayValue( this: jest.MatcherContext, - element: ReactTestInstance, + element: HostElement, expectedValue: TextMatch, options?: TextMatchOptions, ) { diff --git a/src/matchers/to-have-prop.ts b/src/matchers/to-have-prop.ts index d4d8b0821..f54e4ccd5 100644 --- a/src/matchers/to-have-prop.ts +++ b/src/matchers/to-have-prop.ts @@ -1,10 +1,10 @@ -import type { ReactTestInstance } from 'react-test-renderer'; import { matcherHint, stringify, printExpected } from 'jest-matcher-utils'; +import { HostElement } from '../renderer/host-element'; import { checkHostElement, formatMessage } from './utils'; export function toHaveProp( this: jest.MatcherContext, - element: ReactTestInstance, + element: HostElement, name: string, expectedValue: unknown, ) { diff --git a/src/matchers/to-have-style.tsx b/src/matchers/to-have-style.tsx index 9522c79d2..e8b372171 100644 --- a/src/matchers/to-have-style.tsx +++ b/src/matchers/to-have-style.tsx @@ -1,6 +1,6 @@ -import type { ReactTestInstance } from 'react-test-renderer'; import { ImageStyle, StyleProp, StyleSheet, TextStyle, ViewStyle } from 'react-native'; import { matcherHint, diff } from 'jest-matcher-utils'; +import { HostElement } from '../renderer/host-element'; import { checkHostElement, formatMessage } from './utils'; export type Style = ViewStyle | TextStyle | ImageStyle; @@ -9,7 +9,7 @@ type StyleLike = Record; export function toHaveStyle( this: jest.MatcherContext, - element: ReactTestInstance, + element: HostElement, style: StyleProp