diff --git a/src/__tests__/host-text-nesting.test.tsx b/src/__tests__/host-text-nesting.test.tsx new file mode 100644 index 000000000..2a2af1828 --- /dev/null +++ b/src/__tests__/host-text-nesting.test.tsx @@ -0,0 +1,90 @@ +import * as React from 'react'; +import { Text, Pressable, View } from 'react-native'; +import { render, within } from '../pure'; + +/** + * Our queries interact differently with composite and host elements, and some specific cases require us + * to crawl up the tree to a Text composite element to be able to traverse it down again. Going up the tree + * is a dangerous behaviour because we could take the risk of then traversing a sibling node to the original one. + * This test suite is designed to be able to test as many different combinations, as a safety net. + * Specific cases should still be tested within the relevant file (for instance an edge case with `within` should have + * an explicit test in the within test suite) + */ +describe('nested text handling', () => { + test('within same node', () => { + const view = render(Hello); + expect(within(view.getByTestId('subject')).getByText('Hello')).toBeTruthy(); + }); + + test('role with direct text children', () => { + const view = render(About); + + expect(view.getByRole('header', { name: 'About' })).toBeTruthy(); + }); + + test('nested text with child with role', () => { + const view = render( + + + About + + + ); + + expect(view.getByRole('header', { name: 'About' }).props.testID).toBe( + 'child' + ); + }); + + test('pressable within View, with text child', () => { + const view = render( + + + Save + + + ); + + expect(view.getByRole('button', { name: 'Save' }).props.testID).toBe( + 'pressable' + ); + }); + + test('pressable within View, with text child within view', () => { + const view = render( + + + + Save + + + + ); + + expect(view.getByRole('button', { name: 'Save' }).props.testID).toBe( + 'pressable' + ); + }); + + test('Text within pressable', () => { + const view = render( + + Save + + ); + + expect(view.getByText('Save').props.testID).toBe('text'); + }); + + test('Text within view within pressable', () => { + const view = render( + + + Save + + + ); + + expect(view.getByText('Save').props.testID).toBe('text'); + }); +}); diff --git a/src/__tests__/jest-native.test.tsx b/src/__tests__/jest-native.test.tsx index f52e74f7e..64601f47e 100644 --- a/src/__tests__/jest-native.test.tsx +++ b/src/__tests__/jest-native.test.tsx @@ -40,10 +40,10 @@ test('jest-native matchers work correctly', () => { expect(getByText('Disabled Button')).toBeDisabled(); expect(getByText('Enabled Button')).not.toBeDisabled(); - expect(getByA11yHint('Empty Text')).toBeEmpty(); - expect(getByA11yHint('Empty View')).toBeEmpty(); - expect(getByA11yHint('Not Empty Text')).not.toBeEmpty(); - expect(getByA11yHint('Not Empty View')).not.toBeEmpty(); + expect(getByA11yHint('Empty Text')).toBeEmptyElement(); + expect(getByA11yHint('Empty View')).toBeEmptyElement(); + expect(getByA11yHint('Not Empty Text')).not.toBeEmptyElement(); + expect(getByA11yHint('Not Empty View')).not.toBeEmptyElement(); expect(getByA11yHint('Container View')).toContainElement( // $FlowFixMe - TODO: fix @testing-library/jest-native flow typings diff --git a/src/__tests__/within.test.tsx b/src/__tests__/within.test.tsx index 4066245e6..7b22d8483 100644 --- a/src/__tests__/within.test.tsx +++ b/src/__tests__/within.test.tsx @@ -94,3 +94,11 @@ test('within() exposes a11y queries', async () => { test('getQueriesForElement is alias to within', () => { expect(getQueriesForElement).toBe(within); }); + +test('within allows searching for text within a composite component', () => { + const view = render(Hello); + // view.getByTestId('subject') returns a host component, contrary to text queries returning a composite component + // we want to be sure that this doesn't interfere with the way text is searched + const hostTextQueries = within(view.getByTestId('subject')); + expect(hostTextQueries.getByText('Hello')).toBeTruthy(); +}); diff --git a/src/fireEvent.ts b/src/fireEvent.ts index 3cbf18a8c..f5ec35f4f 100644 --- a/src/fireEvent.ts +++ b/src/fireEvent.ts @@ -1,4 +1,5 @@ import { ReactTestInstance } from 'react-test-renderer'; +import { TextInput } from 'react-native'; import act from './act'; import { isHostElement } from './helpers/component-tree'; import { filterNodeByType } from './helpers/filterNodeByType'; @@ -10,7 +11,6 @@ const isTextInput = (element?: ReactTestInstance) => { return false; } - const { TextInput } = require('react-native'); // We have to test if the element type is either the TextInput component // (which would if it is a composite component) or the string // TextInput (which would be true if it is a host component) diff --git a/src/helpers/__tests__/component-tree.test.tsx b/src/helpers/__tests__/component-tree.test.tsx index 2b1b5c619..9ee68633c 100644 --- a/src/helpers/__tests__/component-tree.test.tsx +++ b/src/helpers/__tests__/component-tree.test.tsx @@ -6,6 +6,8 @@ import { getHostParent, getHostSelves, getHostSiblings, + getCompositeParentOfType, + isHostElementForType, } from '../component-tree'; function MultipleHostChildren() { @@ -200,3 +202,38 @@ test('returns host siblings for composite component', () => { view.getByTestId('siblingAfter'), ]); }); + +test('getCompositeParentOfType', () => { + const root = render( + + + + ); + const hostView = root.getByTestId('view'); + const hostText = root.getByTestId('text'); + + const compositeView = getCompositeParentOfType(hostView, View); + // We get the corresponding composite component (same testID), but not the host + expect(compositeView?.type).toBe(View); + expect(compositeView?.props.testID).toBe('view'); + const compositeText = getCompositeParentOfType(hostText, Text); + expect(compositeText?.type).toBe(Text); + expect(compositeText?.props.testID).toBe('text'); + + // Checks parent type + expect(getCompositeParentOfType(hostText, View)).toBeNull(); + expect(getCompositeParentOfType(hostView, Text)).toBeNull(); + + // Ignores itself, stops if ancestor is host + expect(getCompositeParentOfType(compositeText!, Text)).toBeNull(); + expect(getCompositeParentOfType(compositeView!, View)).toBeNull(); +}); + +test('isHostElementForType', () => { + const view = render(); + const hostComponent = view.getByTestId('test'); + const compositeComponent = getCompositeParentOfType(hostComponent, View); + expect(isHostElementForType(hostComponent, View)).toBe(true); + expect(isHostElementForType(hostComponent, Text)).toBe(false); + expect(isHostElementForType(compositeComponent!, View)).toBe(false); +}); diff --git a/src/helpers/component-tree.ts b/src/helpers/component-tree.ts index 340472d72..911cad485 100644 --- a/src/helpers/component-tree.ts +++ b/src/helpers/component-tree.ts @@ -87,3 +87,37 @@ export function getHostSiblings( (sibling) => !hostSelves.includes(sibling) ); } + +export function getCompositeParentOfType( + element: ReactTestInstance, + type: React.ComponentType +) { + let current = element.parent; + + while (!isHostElement(current)) { + // We're at the root of the tree + if (!current) { + return null; + } + + if (current.type === type) { + return current; + } + current = current.parent; + } + + return null; +} + +/** + * Note: this function should be generally used for core React Native types like `View`, `Text`, `TextInput`, etc. + */ +export function isHostElementForType( + element: ReactTestInstance, + type: React.ComponentType +) { + // Not a host element + if (!isHostElement(element)) return false; + + return getCompositeParentOfType(element, type) !== null; +} diff --git a/src/queries/__tests__/role.test.tsx b/src/queries/__tests__/role.test.tsx index 8fb28fc0f..ea7b98de9 100644 --- a/src/queries/__tests__/role.test.tsx +++ b/src/queries/__tests__/role.test.tsx @@ -134,4 +134,50 @@ describe('supports name option', () => { 'target-button' ); }); + + test('returns an element when the direct child is text', () => { + const { getByRole, getByTestId } = render( + + About + + ); + + // assert on the testId to be sure that the returned element is the one with the accessibilityRole + expect(getByRole('header', { name: 'About' })).toBe( + getByTestId('target-header') + ); + expect(getByRole('header', { name: 'About' }).props.testID).toBe( + 'target-header' + ); + }); + + test('returns an element with nested Text as children', () => { + const { getByRole, getByTestId } = render( + + About + + ); + + // assert on the testId to be sure that the returned element is the one with the accessibilityRole + expect(getByRole('header', { name: 'About' })).toBe(getByTestId('parent')); + expect(getByRole('header', { name: 'About' }).props.testID).toBe('parent'); + }); + + test('returns a header with an accessibilityLabel', () => { + const { getByRole, getByTestId } = render( + + ); + + // assert on the testId to be sure that the returned element is the one with the accessibilityRole + expect(getByRole('header', { name: 'About' })).toBe( + getByTestId('target-header') + ); + expect(getByRole('header', { name: 'About' }).props.testID).toBe( + 'target-header' + ); + }); }); diff --git a/src/queries/__tests__/text.test.tsx b/src/queries/__tests__/text.test.tsx index 4ea1145b8..6f5c435b6 100644 --- a/src/queries/__tests__/text.test.tsx +++ b/src/queries/__tests__/text.test.tsx @@ -7,7 +7,7 @@ import { Button, TextInput, } from 'react-native'; -import { render, getDefaultNormalizer } from '../..'; +import { render, getDefaultNormalizer, within } from '../..'; type MyButtonProps = { children: React.ReactNode; @@ -454,3 +454,15 @@ test('getByText and queryByText work with tabs', () => { expect(getByText(textWithTabs)).toBeTruthy(); expect(queryByText(textWithTabs)).toBeTruthy(); }); + +test('getByText searches for text within itself', () => { + const { getByText } = render(Hello); + const textNode = within(getByText('Hello')); + expect(textNode.getByText('Hello')).toBeTruthy(); +}); + +test('getByText searches for text within self host element', () => { + const { getByTestId } = render(Hello); + const textNode = within(getByTestId('subject')); + expect(textNode.getByText('Hello')).toBeTruthy(); +}); diff --git a/src/queries/a11yState.ts b/src/queries/a11yState.ts index 19133fd2c..4d740367d 100644 --- a/src/queries/a11yState.ts +++ b/src/queries/a11yState.ts @@ -1,5 +1,5 @@ import type { ReactTestInstance } from 'react-test-renderer'; -import { AccessibilityState } from 'react-native'; +import type { AccessibilityState } from 'react-native'; import { matchObjectProp } from '../helpers/matchers/matchObjectProp'; import { makeQueries } from './makeQueries'; import type { diff --git a/src/queries/displayValue.ts b/src/queries/displayValue.ts index 46157784b..10f93600e 100644 --- a/src/queries/displayValue.ts +++ b/src/queries/displayValue.ts @@ -1,5 +1,5 @@ import type { ReactTestInstance } from 'react-test-renderer'; -import { createLibraryNotSupportedError } from '../helpers/errors'; +import { TextInput } from 'react-native'; import { filterNodeByType } from '../helpers/filterNodeByType'; import { matches, TextMatch } from '../matches'; import { makeQueries } from './makeQueries'; @@ -18,20 +18,13 @@ const getTextInputNodeByDisplayValue = ( value: TextMatch, options: TextMatchOptions = {} ) => { - try { - const { TextInput } = require('react-native'); - const { exact, normalizer } = options; - const nodeValue = - node.props.value !== undefined - ? node.props.value - : node.props.defaultValue; - return ( - filterNodeByType(node, TextInput) && - matches(value, nodeValue, normalizer, exact) - ); - } catch (error) { - throw createLibraryNotSupportedError(error); - } + const { exact, normalizer } = options; + const nodeValue = + node.props.value !== undefined ? node.props.value : node.props.defaultValue; + return ( + filterNodeByType(node, TextInput) && + matches(value, nodeValue, normalizer, exact) + ); }; const queryAllByDisplayValue = ( diff --git a/src/queries/placeholderText.ts b/src/queries/placeholderText.ts index 4cc3f84b8..dc4fa4d98 100644 --- a/src/queries/placeholderText.ts +++ b/src/queries/placeholderText.ts @@ -1,5 +1,5 @@ import type { ReactTestInstance } from 'react-test-renderer'; -import { createLibraryNotSupportedError } from '../helpers/errors'; +import { TextInput } from 'react-native'; import { filterNodeByType } from '../helpers/filterNodeByType'; import { matches, TextMatch } from '../matches'; import { makeQueries } from './makeQueries'; @@ -18,16 +18,11 @@ const getTextInputNodeByPlaceholderText = ( placeholder: TextMatch, options: TextMatchOptions = {} ) => { - try { - const { TextInput } = require('react-native'); - const { exact, normalizer } = options; - return ( - filterNodeByType(node, TextInput) && - matches(placeholder, node.props.placeholder, normalizer, exact) - ); - } catch (error) { - throw createLibraryNotSupportedError(error); - } + const { exact, normalizer } = options; + return ( + filterNodeByType(node, TextInput) && + matches(placeholder, node.props.placeholder, normalizer, exact) + ); }; const queryAllByPlaceholderText = ( diff --git a/src/queries/text.ts b/src/queries/text.ts index 28f586627..9633c9a61 100644 --- a/src/queries/text.ts +++ b/src/queries/text.ts @@ -1,7 +1,11 @@ import type { ReactTestInstance } from 'react-test-renderer'; +import { Text } from 'react-native'; import * as React from 'react'; -import { createLibraryNotSupportedError } from '../helpers/errors'; import { filterNodeByType } from '../helpers/filterNodeByType'; +import { + isHostElementForType, + getCompositeParentOfType, +} from '../helpers/component-tree'; import { matches, TextMatch } from '../matches'; import type { NormalizerFn } from '../matches'; import { makeQueries } from './makeQueries'; @@ -19,10 +23,7 @@ export type TextMatchOptions = { normalizer?: NormalizerFn; }; -const getChildrenAsText = ( - children: React.ReactChild[], - TextComponent: React.ComponentType -) => { +const getChildrenAsText = (children: React.ReactChild[]) => { const textContent: string[] = []; React.Children.forEach(children, (child) => { if (typeof child === 'string') { @@ -40,14 +41,12 @@ const getChildrenAsText = ( // has no text. In such situations, react-test-renderer will traverse down // this tree in a separate call and run this query again. As a result, the // query will match the deepest text node that matches requested text. - if (filterNodeByType(child, TextComponent)) { + if (filterNodeByType(child, Text)) { return; } if (filterNodeByType(child, React.Fragment)) { - textContent.push( - ...getChildrenAsText(child.props.children, TextComponent) - ); + textContent.push(...getChildrenAsText(child.props.children)); } } }); @@ -60,21 +59,16 @@ const getNodeByText = ( text: TextMatch, options: TextMatchOptions = {} ) => { - try { - const { Text } = require('react-native'); - const isTextComponent = filterNodeByType(node, Text); - if (isTextComponent) { - const textChildren = getChildrenAsText(node.props.children, Text); - if (textChildren) { - const textToTest = textChildren.join(''); - const { exact, normalizer } = options; - return matches(text, textToTest, normalizer, exact); - } + const isTextComponent = filterNodeByType(node, Text); + if (isTextComponent) { + const textChildren = getChildrenAsText(node.props.children); + if (textChildren) { + const textToTest = textChildren.join(''); + const { exact, normalizer } = options; + return matches(text, textToTest, normalizer, exact); } - return false; - } catch (error) { - throw createLibraryNotSupportedError(error); } + return false; }; const queryAllByText = ( @@ -84,7 +78,15 @@ const queryAllByText = ( options?: TextMatchOptions ) => Array) => function queryAllByTextFn(text, options) { - const results = instance.findAll((node) => + const baseInstance = isHostElementForType(instance, Text) + ? getCompositeParentOfType(instance, Text) + : instance; + + if (!baseInstance) { + return []; + } + + const results = baseInstance.findAll((node) => getNodeByText(node, text, options) );