diff --git a/src/__tests__/component-tree.tsx b/src/__tests__/component-tree.tsx new file mode 100644 index 0000000..9ed0b38 --- /dev/null +++ b/src/__tests__/component-tree.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { View } from 'react-native'; +import { render } from '@testing-library/react-native'; +import { getParentElement } from '../component-tree'; + +function MultipleHostChildren() { + return ( + <> + + + + + ); +} + +describe('getParentElement()', () => { + it('returns host parent for host component', () => { + const view = render( + + + + + + , + ); + + const hostParent = getParentElement(view.getByTestId('subject')); + expect(hostParent).toBe(view.getByTestId('parent')); + + const hostGrandparent = getParentElement(hostParent); + expect(hostGrandparent).toBe(view.getByTestId('grandparent')); + + expect(getParentElement(hostGrandparent)).toBe(null); + }); + + it('returns host parent for null', () => { + expect(getParentElement(null)).toBe(null); + }); + + it('returns host parent for composite component', () => { + const view = render( + + + + , + ); + + const compositeComponent = view.UNSAFE_getByType(MultipleHostChildren); + const hostParent = getParentElement(compositeComponent); + expect(hostParent).toBe(view.getByTestId('parent')); + }); +}); diff --git a/src/__tests__/to-be-visible.tsx b/src/__tests__/to-be-visible.tsx index 589a079..281c02a 100644 --- a/src/__tests__/to-be-visible.tsx +++ b/src/__tests__/to-be-visible.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Modal, View } from 'react-native'; +import { View, Pressable, Modal } from 'react-native'; import { render } from '@testing-library/react-native'; describe('.toBeVisible', () => { @@ -120,16 +120,32 @@ describe('.toBeVisible', () => { expect(getByTestId('test')).not.toBeVisible(); }); - it('handles non-React elements', () => { + test('handles null elements', () => { + expect(() => expect(null).toBeVisible()).toThrowErrorMatchingInlineSnapshot(` + "expect(received).toBeVisible() + + received value must be a React Element. + Received has value: null" + `); + }); + + test('handles non-React elements', () => { expect(() => expect({ name: 'Non-React element' }).not.toBeVisible()).toThrow(); expect(() => expect(true).not.toBeVisible()).toThrow(); }); - it('throws an error when expectation is not matched', () => { + test('throws an error when expectation is not matched', () => { const { getByTestId, update } = render(); expect(() => expect(getByTestId('test')).not.toBeVisible()).toThrowErrorMatchingSnapshot(); update(); expect(() => expect(getByTestId('test')).toBeVisible()).toThrowErrorMatchingSnapshot(); }); + + test('handles Pressable with function style prop', () => { + const { getByTestId } = render( + ({ backgroundColor: 'blue' })} />, + ); + expect(getByTestId('test')).toBeVisible(); + }); }); diff --git a/src/__tests__/to-have-style.tsx b/src/__tests__/to-have-style.tsx index 8dad4ba..9f621aa 100644 --- a/src/__tests__/to-have-style.tsx +++ b/src/__tests__/to-have-style.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { StyleSheet, View, Text } from 'react-native'; +import { StyleSheet, View, Text, Pressable } from 'react-native'; import { render } from '@testing-library/react-native'; describe('.toHaveStyle', () => { @@ -90,4 +90,11 @@ describe('.toHaveStyle', () => { expect(container).toHaveStyle({ transform: [{ scale: 1 }] }), ).toThrowErrorMatchingSnapshot(); }); + + test('handles Pressable with function style prop', () => { + const { getByTestId } = render( + ({ backgroundColor: 'blue' })} />, + ); + expect(getByTestId('test')).toHaveStyle({ backgroundColor: 'blue' }); + }); }); diff --git a/src/component-tree.ts b/src/component-tree.ts new file mode 100644 index 0000000..ce438c9 --- /dev/null +++ b/src/component-tree.ts @@ -0,0 +1,37 @@ +import type React from 'react'; +import type { ReactTestInstance } from 'react-test-renderer'; + +/** + * Checks if the given element is a host element. + * @param element The element to check. + */ +export function isHostElement(element?: ReactTestInstance | null): boolean { + return typeof element?.type === 'string'; +} + +/** + * Returns first host ancestor for given element or first ancestor of one of + * passed component types. + * + * @param element The element start traversing from. + * @param componentTypes Additional component types to match. + */ +export function getParentElement( + element: ReactTestInstance | null, + componentTypes: React.ElementType[] = [], +): ReactTestInstance | null { + if (element == null) { + return null; + } + + let current = element.parent; + while (current) { + if (isHostElement(current) || componentTypes.includes(current.type)) { + return current; + } + + current = current.parent; + } + + return null; +} diff --git a/src/to-be-visible.ts b/src/to-be-visible.ts index 323c1b3..66c1430 100644 --- a/src/to-be-visible.ts +++ b/src/to-be-visible.ts @@ -3,31 +3,40 @@ import { matcherHint } from 'jest-matcher-utils'; import type { ReactTestInstance } from 'react-test-renderer'; import { checkReactElement, printElement } from './utils'; +import { getParentElement } from './component-tree'; -function isStyleVisible(element: ReactTestInstance) { +function isVisibleForStyles(element: ReactTestInstance) { const style = element.props.style || {}; const { display, opacity } = StyleSheet.flatten(style); return display !== 'none' && opacity !== 0; } -function isAttributeVisible(element: ReactTestInstance) { - return element.type !== Modal || element.props.visible !== false; +function isVisibleForAccessibility(element: ReactTestInstance) { + return ( + !element.props.accessibilityElementsHidden && + element.props.importantForAccessibility !== 'no-hide-descendants' + ); } -function isVisibleForAccessibility(element: ReactTestInstance) { - const visibleForiOSVoiceOver = !element.props.accessibilityElementsHidden; - const visibleForAndroidTalkBack = - element.props.importantForAccessibility !== 'no-hide-descendants'; - return visibleForiOSVoiceOver && visibleForAndroidTalkBack; +function isModalVisible(element: ReactTestInstance) { + return element.type !== Modal || element.props.visible !== false; } function isElementVisible(element: ReactTestInstance): boolean { - return ( - isStyleVisible(element) && - isAttributeVisible(element) && - isVisibleForAccessibility(element) && - (!element.parent || isElementVisible(element.parent)) - ); + let current: ReactTestInstance | null = element; + while (current) { + if ( + !isVisibleForStyles(current) || + !isVisibleForAccessibility(current) || + !isModalVisible(current) + ) { + return false; + } + + current = getParentElement(current, [Modal]); + } + + return true; } export function toBeVisible(this: jest.MatcherContext, element: ReactTestInstance) {