From 17828095c09f37af1a9783729c56892382d52c9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augustin=20Le=20F=C3=A8vre?= Date: Tue, 27 Sep 2022 15:00:56 +0200 Subject: [PATCH 1/6] Allow searching for text in a host component --- src/__tests__/jest-native.test.tsx | 8 ++-- src/__tests__/within.test.tsx | 8 ++++ src/queries/__tests__/role.test.tsx | 39 ++++++++++++++++++ src/queries/__tests__/text.test.tsx | 8 +++- src/queries/text.ts | 63 +++++++++++++++++++++-------- 5 files changed, 104 insertions(+), 22 deletions(-) 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..6919822bd 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 composite component, contrary to most queries returning host 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/queries/__tests__/role.test.tsx b/src/queries/__tests__/role.test.tsx index 8fb28fc0f..f00500ec5 100644 --- a/src/queries/__tests__/role.test.tsx +++ b/src/queries/__tests__/role.test.tsx @@ -134,4 +134,43 @@ describe('supports name option', () => { 'target-button' ); }); + + test('returns an element when the direct child is text', () => { + const { getByRole } = render( + + About + + ); + + // assert on the testId to be sure that the returned element is the one with the accessibilityRole + expect(getByRole('header', { name: 'About' }).props.testID).toBe( + 'target-header' + ); + }); + + test('returns an element with nested Text as children', () => { + const { getByRole } = render( + + About + + ); + + // assert on the testId to be sure that the returned element is the one with the accessibilityRole + expect(getByRole('header', { name: 'About' }).props.testID).toBe('parent'); + }); + + test('returns a header with an accessibilityLabel', () => { + const { getByRole } = render( + + ); + + // assert on the testId to be sure that the returned element is the one with the accessibilityRole + 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..307f6e62f 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,9 @@ 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(); +}); diff --git a/src/queries/text.ts b/src/queries/text.ts index 28f586627..27ea893d1 100644 --- a/src/queries/text.ts +++ b/src/queries/text.ts @@ -2,6 +2,7 @@ import type { ReactTestInstance } from 'react-test-renderer'; import * as React from 'react'; import { createLibraryNotSupportedError } from '../helpers/errors'; import { filterNodeByType } from '../helpers/filterNodeByType'; +import { isHostElement } from '../helpers/component-tree'; import { matches, TextMatch } from '../matches'; import type { NormalizerFn } from '../matches'; import { makeQueries } from './makeQueries'; @@ -58,25 +59,43 @@ const getChildrenAsText = ( const getNodeByText = ( node: ReactTestInstance, text: TextMatch, + TextComponent: React.ComponentType, 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, TextComponent); + if (isTextComponent) { + const textChildren = getChildrenAsText(node.props.children, TextComponent); + 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; }; +function getCompositeParent( + element: ReactTestInstance, + compositeType: React.ComponentType +) { + if (!isHostElement(element)) return null; + + let current = element.parent; + while (!isHostElement(current)) { + // We're at the top of the tree + if (!current) { + return null; + } + + if (filterNodeByType(current, compositeType)) { + return current; + } + current = current.parent ?? null; + } + + return null; +} + const queryAllByText = ( instance: ReactTestInstance ): (( @@ -84,11 +103,21 @@ const queryAllByText = ( options?: TextMatchOptions ) => Array) => function queryAllByTextFn(text, options) { - const results = instance.findAll((node) => - getNodeByText(node, text, options) - ); + try { + const { Text } = require('react-native'); + const rootInstance = isHostElement(instance) + ? getCompositeParent(instance, Text) ?? instance + : instance; - return results; + if (!rootInstance) return []; + const results = rootInstance.findAll((node) => + getNodeByText(node, text, Text, options) + ); + + return results; + } catch (error) { + throw createLibraryNotSupportedError(error); + } }; const getMultipleError = (text: TextMatch) => From 1e696a10600f596ef9c9020d73881648a99e8053 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augustin=20Le=20F=C3=A8vre?= Date: Wed, 28 Sep 2022 14:05:02 +0200 Subject: [PATCH 2/6] Search from composite text parent --- src/__tests__/host-text-nesting.test.tsx | 185 ++++++++++++++++++ src/__tests__/within.test.tsx | 2 +- src/helpers/__tests__/component-tree.test.tsx | 15 ++ src/helpers/component-tree.ts | 21 ++ src/queries/text.ts | 28 +-- 5 files changed, 238 insertions(+), 13 deletions(-) create mode 100644 src/__tests__/host-text-nesting.test.tsx diff --git a/src/__tests__/host-text-nesting.test.tsx b/src/__tests__/host-text-nesting.test.tsx new file mode 100644 index 000000000..5c191b8b3 --- /dev/null +++ b/src/__tests__/host-text-nesting.test.tsx @@ -0,0 +1,185 @@ +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 text with label', () => { + const view = render( + + + + ); + + expect(view.getByRole('button', { name: 'Save' }).props.testID).toBe( + 'pressable' + ); + }); + + test('pressable within text, with text child', () => { + const view = render( + + + Save + + + ); + + expect(view.getByRole('button', { name: 'Save' }).props.testID).toBe( + 'pressable' + ); + }); + + test('pressable within text, with multiple text children', () => { + const view = render( + + + Save + render + + + ); + + expect(view.getByRole('button', { name: 'Save' }).props.testID).toBe( + 'pressable' + ); + }); + + 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('pressable within View within Text, 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'); + }); + + test('View with text child', () => { + const view = render( + + Save + + ); + + expect(view.getByTestId('view').props.testID).toBe('view'); + }); + + test('Text within view', () => { + const view = render( + + Save + + ); + + expect(view.getByTestId('text').props.testID).toBe('text'); + }); + + test('Text within view within text', () => { + const view = render( + + + Save + + + ); + + expect(view.getByTestId('text').props.testID).toBe('text'); + }); +}); diff --git a/src/__tests__/within.test.tsx b/src/__tests__/within.test.tsx index 6919822bd..7b22d8483 100644 --- a/src/__tests__/within.test.tsx +++ b/src/__tests__/within.test.tsx @@ -97,7 +97,7 @@ test('getQueriesForElement is alias to within', () => { test('within allows searching for text within a composite component', () => { const view = render(Hello); - // view.getByTestId('subject') returns a composite component, contrary to most queries returning host component + // 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/helpers/__tests__/component-tree.test.tsx b/src/helpers/__tests__/component-tree.test.tsx index 2b1b5c619..70923da8e 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, + isHostElement, } from '../component-tree'; function MultipleHostChildren() { @@ -200,3 +202,16 @@ test('returns host siblings for composite component', () => { view.getByTestId('siblingAfter'), ]); }); + +test('getCompositeParentOfType', () => { + const view = render(); + const hostComponent = view.getByTestId('test'); + + const compositeComponent = getCompositeParentOfType(hostComponent, View); + + // We get the corresponding composite component (same testID), but not the host + expect(hostComponent.props.testID).toBe(compositeComponent?.props.testID); + + expect(hostComponent).not.toBe(compositeComponent); + expect(isHostElement(compositeComponent)).toBe(false); +}); diff --git a/src/helpers/component-tree.ts b/src/helpers/component-tree.ts index 340472d72..029cb1a4d 100644 --- a/src/helpers/component-tree.ts +++ b/src/helpers/component-tree.ts @@ -87,3 +87,24 @@ 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 ?? null; + } + + return null; +} diff --git a/src/queries/text.ts b/src/queries/text.ts index 27ea893d1..8aabe7b92 100644 --- a/src/queries/text.ts +++ b/src/queries/text.ts @@ -2,7 +2,10 @@ import type { ReactTestInstance } from 'react-test-renderer'; import * as React from 'react'; import { createLibraryNotSupportedError } from '../helpers/errors'; import { filterNodeByType } from '../helpers/filterNodeByType'; -import { isHostElement } from '../helpers/component-tree'; +import { + isHostElement, + getCompositeParentOfType, +} from '../helpers/component-tree'; import { matches, TextMatch } from '../matches'; import type { NormalizerFn } from '../matches'; import { makeQueries } from './makeQueries'; @@ -74,11 +77,12 @@ const getNodeByText = ( return false; }; -function getCompositeParent( +function isHostTextElement( element: ReactTestInstance, - compositeType: React.ComponentType + Text: React.ComponentType ) { - if (!isHostElement(element)) return null; + // Not a host element + if (typeof element.type !== 'string') return false; let current = element.parent; while (!isHostElement(current)) { @@ -87,13 +91,13 @@ function getCompositeParent( return null; } - if (filterNodeByType(current, compositeType)) { - return current; + if (current.type === Text) { + return true; } - current = current.parent ?? null; + current = current.parent; } - return null; + return false; } const queryAllByText = ( @@ -105,12 +109,12 @@ const queryAllByText = ( function queryAllByTextFn(text, options) { try { const { Text } = require('react-native'); - const rootInstance = isHostElement(instance) - ? getCompositeParent(instance, Text) ?? instance + const baseInstance = isHostTextElement(instance, Text) + ? getCompositeParentOfType(instance, Text) : instance; - if (!rootInstance) return []; - const results = rootInstance.findAll((node) => + if (!baseInstance) return []; + const results = baseInstance.findAll((node) => getNodeByText(node, text, Text, options) ); From be5a44355ac0c4bf346fe020ac55ecb220da355e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augustin=20Le=20F=C3=A8vre?= Date: Thu, 29 Sep 2022 11:44:14 +0200 Subject: [PATCH 3/6] Refactore component-tree utils used in queryAllByText and make tests more readable --- src/__tests__/host-text-nesting.test.tsx | 75 ------------------- src/helpers/__tests__/component-tree.test.tsx | 5 +- src/helpers/component-tree.ts | 12 ++- src/queries/__tests__/role.test.tsx | 13 +++- src/queries/__tests__/text.test.tsx | 6 ++ src/queries/text.ts | 32 ++------ 6 files changed, 36 insertions(+), 107 deletions(-) diff --git a/src/__tests__/host-text-nesting.test.tsx b/src/__tests__/host-text-nesting.test.tsx index 5c191b8b3..a6ed7d1e0 100644 --- a/src/__tests__/host-text-nesting.test.tsx +++ b/src/__tests__/host-text-nesting.test.tsx @@ -36,51 +36,6 @@ describe('nested text handling', () => { ); }); - test('pressable within text with label', () => { - const view = render( - - - - ); - - expect(view.getByRole('button', { name: 'Save' }).props.testID).toBe( - 'pressable' - ); - }); - - test('pressable within text, with text child', () => { - const view = render( - - - Save - - - ); - - expect(view.getByRole('button', { name: 'Save' }).props.testID).toBe( - 'pressable' - ); - }); - - test('pressable within text, with multiple text children', () => { - const view = render( - - - Save - render - - - ); - - expect(view.getByRole('button', { name: 'Save' }).props.testID).toBe( - 'pressable' - ); - }); - test('pressable within View, with text child', () => { const view = render( @@ -111,24 +66,6 @@ describe('nested text handling', () => { ); }); - test('pressable within View within Text, 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( @@ -170,16 +107,4 @@ describe('nested text handling', () => { expect(view.getByTestId('text').props.testID).toBe('text'); }); - - test('Text within view within text', () => { - const view = render( - - - Save - - - ); - - expect(view.getByTestId('text').props.testID).toBe('text'); - }); }); diff --git a/src/helpers/__tests__/component-tree.test.tsx b/src/helpers/__tests__/component-tree.test.tsx index 70923da8e..0d8610af5 100644 --- a/src/helpers/__tests__/component-tree.test.tsx +++ b/src/helpers/__tests__/component-tree.test.tsx @@ -210,8 +210,9 @@ test('getCompositeParentOfType', () => { const compositeComponent = getCompositeParentOfType(hostComponent, View); // We get the corresponding composite component (same testID), but not the host - expect(hostComponent.props.testID).toBe(compositeComponent?.props.testID); + expect(compositeComponent?.type).toBe(View); + expect(compositeComponent?.props.testID).toBe(hostComponent.props.testID); - expect(hostComponent).not.toBe(compositeComponent); + expect(compositeComponent).not.toBe(hostComponent); expect(isHostElement(compositeComponent)).toBe(false); }); diff --git a/src/helpers/component-tree.ts b/src/helpers/component-tree.ts index 029cb1a4d..929957787 100644 --- a/src/helpers/component-tree.ts +++ b/src/helpers/component-tree.ts @@ -103,8 +103,18 @@ export function getCompositeParentOfType( if (current.type === type) { return current; } - current = current.parent ?? null; + current = current.parent; } return null; } + +export function isHostElementOfType( + 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 f00500ec5..44ac66990 100644 --- a/src/queries/__tests__/role.test.tsx +++ b/src/queries/__tests__/role.test.tsx @@ -136,31 +136,35 @@ describe('supports name option', () => { }); test('returns an element when the direct child is text', () => { - const { getByRole } = render( + 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 } = render( + 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 } = render( + 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 307f6e62f..6f5c435b6 100644 --- a/src/queries/__tests__/text.test.tsx +++ b/src/queries/__tests__/text.test.tsx @@ -460,3 +460,9 @@ test('getByText searches for text within itself', () => { 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/text.ts b/src/queries/text.ts index 8aabe7b92..74c78e10b 100644 --- a/src/queries/text.ts +++ b/src/queries/text.ts @@ -3,7 +3,7 @@ import * as React from 'react'; import { createLibraryNotSupportedError } from '../helpers/errors'; import { filterNodeByType } from '../helpers/filterNodeByType'; import { - isHostElement, + isHostElementOfType, getCompositeParentOfType, } from '../helpers/component-tree'; import { matches, TextMatch } from '../matches'; @@ -77,29 +77,6 @@ const getNodeByText = ( return false; }; -function isHostTextElement( - element: ReactTestInstance, - Text: React.ComponentType -) { - // Not a host element - if (typeof element.type !== 'string') return false; - - let current = element.parent; - while (!isHostElement(current)) { - // We're at the top of the tree - if (!current) { - return null; - } - - if (current.type === Text) { - return true; - } - current = current.parent; - } - - return false; -} - const queryAllByText = ( instance: ReactTestInstance ): (( @@ -109,11 +86,14 @@ const queryAllByText = ( function queryAllByTextFn(text, options) { try { const { Text } = require('react-native'); - const baseInstance = isHostTextElement(instance, Text) + const baseInstance = isHostElementOfType(instance, Text) ? getCompositeParentOfType(instance, Text) : instance; - if (!baseInstance) return []; + if (!baseInstance) { + return []; + } + const results = baseInstance.findAll((node) => getNodeByText(node, text, Text, options) ); From cbd97ff31e186e24bcf95be6b16b1cdff46904c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augustin=20Le=20F=C3=A8vre?= Date: Thu, 29 Sep 2022 13:26:59 +0200 Subject: [PATCH 4/6] Extract RN imports to react-native-api.ts --- src/fireEvent.ts | 3 +- src/helpers/__tests__/component-tree.test.tsx | 6 +--- src/helpers/component-tree.ts | 2 +- src/helpers/react-native-api.ts | 19 ++++++++++++ src/queries/a11yState.ts | 2 +- src/queries/displayValue.ts | 24 ++++++--------- src/queries/placeholderText.ts | 19 +++++------- src/queries/text.ts | 30 ++++++++----------- 8 files changed, 54 insertions(+), 51 deletions(-) create mode 100644 src/helpers/react-native-api.ts diff --git a/src/fireEvent.ts b/src/fireEvent.ts index 3cbf18a8c..035c19f36 100644 --- a/src/fireEvent.ts +++ b/src/fireEvent.ts @@ -1,6 +1,7 @@ import { ReactTestInstance } from 'react-test-renderer'; import act from './act'; import { isHostElement } from './helpers/component-tree'; +import { importTextInputFromReactNative } from './helpers/react-native-api'; import { filterNodeByType } from './helpers/filterNodeByType'; type EventHandler = (...args: any) => unknown; @@ -10,7 +11,7 @@ const isTextInput = (element?: ReactTestInstance) => { return false; } - const { TextInput } = require('react-native'); + const TextInput = importTextInputFromReactNative(); // 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 0d8610af5..a995fd5bc 100644 --- a/src/helpers/__tests__/component-tree.test.tsx +++ b/src/helpers/__tests__/component-tree.test.tsx @@ -7,7 +7,6 @@ import { getHostSelves, getHostSiblings, getCompositeParentOfType, - isHostElement, } from '../component-tree'; function MultipleHostChildren() { @@ -204,7 +203,7 @@ test('returns host siblings for composite component', () => { }); test('getCompositeParentOfType', () => { - const view = render(); + const view = render(); const hostComponent = view.getByTestId('test'); const compositeComponent = getCompositeParentOfType(hostComponent, View); @@ -212,7 +211,4 @@ test('getCompositeParentOfType', () => { // We get the corresponding composite component (same testID), but not the host expect(compositeComponent?.type).toBe(View); expect(compositeComponent?.props.testID).toBe(hostComponent.props.testID); - - expect(compositeComponent).not.toBe(hostComponent); - expect(isHostElement(compositeComponent)).toBe(false); }); diff --git a/src/helpers/component-tree.ts b/src/helpers/component-tree.ts index 929957787..5212c18dc 100644 --- a/src/helpers/component-tree.ts +++ b/src/helpers/component-tree.ts @@ -109,7 +109,7 @@ export function getCompositeParentOfType( return null; } -export function isHostElementOfType( +export function isHostElementForType( element: ReactTestInstance, type: React.ComponentType ) { diff --git a/src/helpers/react-native-api.ts b/src/helpers/react-native-api.ts new file mode 100644 index 000000000..e537c6c16 --- /dev/null +++ b/src/helpers/react-native-api.ts @@ -0,0 +1,19 @@ +import { createLibraryNotSupportedError } from './errors'; + +export function importTextFromReactNative() { + try { + const { Text } = require('react-native'); + return Text; + } catch (error) { + throw createLibraryNotSupportedError(error); + } +} + +export function importTextInputFromReactNative() { + try { + const { TextInput } = require('react-native'); + return TextInput; + } catch (error) { + throw createLibraryNotSupportedError(error); + } +} 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..c98f5e25a 100644 --- a/src/queries/displayValue.ts +++ b/src/queries/displayValue.ts @@ -1,6 +1,6 @@ import type { ReactTestInstance } from 'react-test-renderer'; -import { createLibraryNotSupportedError } from '../helpers/errors'; import { filterNodeByType } from '../helpers/filterNodeByType'; +import { importTextInputFromReactNative } from '../helpers/react-native-api'; import { matches, TextMatch } from '../matches'; import { makeQueries } from './makeQueries'; import type { @@ -18,20 +18,14 @@ 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 TextInput = importTextInputFromReactNative(); + 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..17c16ba01 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 { importTextInputFromReactNative } from '../helpers/react-native-api'; import { filterNodeByType } from '../helpers/filterNodeByType'; import { matches, TextMatch } from '../matches'; import { makeQueries } from './makeQueries'; @@ -18,16 +18,13 @@ 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 TextInput = importTextInputFromReactNative(); + + 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 74c78e10b..9fb31da04 100644 --- a/src/queries/text.ts +++ b/src/queries/text.ts @@ -1,9 +1,9 @@ import type { ReactTestInstance } from 'react-test-renderer'; import * as React from 'react'; -import { createLibraryNotSupportedError } from '../helpers/errors'; import { filterNodeByType } from '../helpers/filterNodeByType'; +import { importTextFromReactNative } from '../helpers/react-native-api'; import { - isHostElementOfType, + isHostElementForType, getCompositeParentOfType, } from '../helpers/component-tree'; import { matches, TextMatch } from '../matches'; @@ -84,24 +84,20 @@ const queryAllByText = ( options?: TextMatchOptions ) => Array) => function queryAllByTextFn(text, options) { - try { - const { Text } = require('react-native'); - const baseInstance = isHostElementOfType(instance, Text) - ? getCompositeParentOfType(instance, Text) - : instance; + const Text = importTextFromReactNative(); + const baseInstance = isHostElementForType(instance, Text) + ? getCompositeParentOfType(instance, Text) + : instance; - if (!baseInstance) { - return []; - } + if (!baseInstance) { + return []; + } - const results = baseInstance.findAll((node) => - getNodeByText(node, text, Text, options) - ); + const results = baseInstance.findAll((node) => + getNodeByText(node, text, Text, options) + ); - return results; - } catch (error) { - throw createLibraryNotSupportedError(error); - } + return results; }; const getMultipleError = (text: TextMatch) => From 6b2907caa759cb3b36f4e98c173a3ebec595cb5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augustin=20Le=20F=C3=A8vre?= Date: Sat, 1 Oct 2022 17:46:55 +0200 Subject: [PATCH 5/6] Import Text/TextInput directly from react-native --- src/fireEvent.ts | 3 +-- src/helpers/react-native-api.ts | 19 ------------------- src/queries/displayValue.ts | 3 +-- src/queries/placeholderText.ts | 4 +--- src/queries/text.ts | 21 +++++++-------------- 5 files changed, 10 insertions(+), 40 deletions(-) delete mode 100644 src/helpers/react-native-api.ts diff --git a/src/fireEvent.ts b/src/fireEvent.ts index 035c19f36..f5ec35f4f 100644 --- a/src/fireEvent.ts +++ b/src/fireEvent.ts @@ -1,7 +1,7 @@ import { ReactTestInstance } from 'react-test-renderer'; +import { TextInput } from 'react-native'; import act from './act'; import { isHostElement } from './helpers/component-tree'; -import { importTextInputFromReactNative } from './helpers/react-native-api'; import { filterNodeByType } from './helpers/filterNodeByType'; type EventHandler = (...args: any) => unknown; @@ -11,7 +11,6 @@ const isTextInput = (element?: ReactTestInstance) => { return false; } - const TextInput = importTextInputFromReactNative(); // 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/react-native-api.ts b/src/helpers/react-native-api.ts deleted file mode 100644 index e537c6c16..000000000 --- a/src/helpers/react-native-api.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { createLibraryNotSupportedError } from './errors'; - -export function importTextFromReactNative() { - try { - const { Text } = require('react-native'); - return Text; - } catch (error) { - throw createLibraryNotSupportedError(error); - } -} - -export function importTextInputFromReactNative() { - try { - const { TextInput } = require('react-native'); - return TextInput; - } catch (error) { - throw createLibraryNotSupportedError(error); - } -} diff --git a/src/queries/displayValue.ts b/src/queries/displayValue.ts index c98f5e25a..10f93600e 100644 --- a/src/queries/displayValue.ts +++ b/src/queries/displayValue.ts @@ -1,6 +1,6 @@ import type { ReactTestInstance } from 'react-test-renderer'; +import { TextInput } from 'react-native'; import { filterNodeByType } from '../helpers/filterNodeByType'; -import { importTextInputFromReactNative } from '../helpers/react-native-api'; import { matches, TextMatch } from '../matches'; import { makeQueries } from './makeQueries'; import type { @@ -18,7 +18,6 @@ const getTextInputNodeByDisplayValue = ( value: TextMatch, options: TextMatchOptions = {} ) => { - const TextInput = importTextInputFromReactNative(); const { exact, normalizer } = options; const nodeValue = node.props.value !== undefined ? node.props.value : node.props.defaultValue; diff --git a/src/queries/placeholderText.ts b/src/queries/placeholderText.ts index 17c16ba01..dc4fa4d98 100644 --- a/src/queries/placeholderText.ts +++ b/src/queries/placeholderText.ts @@ -1,5 +1,5 @@ import type { ReactTestInstance } from 'react-test-renderer'; -import { importTextInputFromReactNative } from '../helpers/react-native-api'; +import { TextInput } from 'react-native'; import { filterNodeByType } from '../helpers/filterNodeByType'; import { matches, TextMatch } from '../matches'; import { makeQueries } from './makeQueries'; @@ -18,8 +18,6 @@ const getTextInputNodeByPlaceholderText = ( placeholder: TextMatch, options: TextMatchOptions = {} ) => { - const TextInput = importTextInputFromReactNative(); - const { exact, normalizer } = options; return ( filterNodeByType(node, TextInput) && diff --git a/src/queries/text.ts b/src/queries/text.ts index 9fb31da04..9633c9a61 100644 --- a/src/queries/text.ts +++ b/src/queries/text.ts @@ -1,7 +1,7 @@ import type { ReactTestInstance } from 'react-test-renderer'; +import { Text } from 'react-native'; import * as React from 'react'; import { filterNodeByType } from '../helpers/filterNodeByType'; -import { importTextFromReactNative } from '../helpers/react-native-api'; import { isHostElementForType, getCompositeParentOfType, @@ -23,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') { @@ -44,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)); } } }); @@ -62,12 +57,11 @@ const getChildrenAsText = ( const getNodeByText = ( node: ReactTestInstance, text: TextMatch, - TextComponent: React.ComponentType, options: TextMatchOptions = {} ) => { - const isTextComponent = filterNodeByType(node, TextComponent); + const isTextComponent = filterNodeByType(node, Text); if (isTextComponent) { - const textChildren = getChildrenAsText(node.props.children, TextComponent); + const textChildren = getChildrenAsText(node.props.children); if (textChildren) { const textToTest = textChildren.join(''); const { exact, normalizer } = options; @@ -84,7 +78,6 @@ const queryAllByText = ( options?: TextMatchOptions ) => Array) => function queryAllByTextFn(text, options) { - const Text = importTextFromReactNative(); const baseInstance = isHostElementForType(instance, Text) ? getCompositeParentOfType(instance, Text) : instance; @@ -94,7 +87,7 @@ const queryAllByText = ( } const results = baseInstance.findAll((node) => - getNodeByText(node, text, Text, options) + getNodeByText(node, text, options) ); return results; From 1a2ba73280316d1fc65dfb07e7a1ff7d0cea7b90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augustin=20Le=20F=C3=A8vre?= Date: Mon, 3 Oct 2022 19:01:54 -0400 Subject: [PATCH 6/6] Improve tests --- src/__tests__/host-text-nesting.test.tsx | 20 ----------- src/helpers/__tests__/component-tree.test.tsx | 35 ++++++++++++++++--- src/helpers/component-tree.ts | 3 ++ src/queries/__tests__/role.test.tsx | 2 +- 4 files changed, 34 insertions(+), 26 deletions(-) diff --git a/src/__tests__/host-text-nesting.test.tsx b/src/__tests__/host-text-nesting.test.tsx index a6ed7d1e0..2a2af1828 100644 --- a/src/__tests__/host-text-nesting.test.tsx +++ b/src/__tests__/host-text-nesting.test.tsx @@ -87,24 +87,4 @@ describe('nested text handling', () => { expect(view.getByText('Save').props.testID).toBe('text'); }); - - test('View with text child', () => { - const view = render( - - Save - - ); - - expect(view.getByTestId('view').props.testID).toBe('view'); - }); - - test('Text within view', () => { - const view = render( - - Save - - ); - - expect(view.getByTestId('text').props.testID).toBe('text'); - }); }); diff --git a/src/helpers/__tests__/component-tree.test.tsx b/src/helpers/__tests__/component-tree.test.tsx index a995fd5bc..9ee68633c 100644 --- a/src/helpers/__tests__/component-tree.test.tsx +++ b/src/helpers/__tests__/component-tree.test.tsx @@ -7,6 +7,7 @@ import { getHostSelves, getHostSiblings, getCompositeParentOfType, + isHostElementForType, } from '../component-tree'; function MultipleHostChildren() { @@ -203,12 +204,36 @@ test('returns host siblings for composite component', () => { }); 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); - - // We get the corresponding composite component (same testID), but not the host - expect(compositeComponent?.type).toBe(View); - expect(compositeComponent?.props.testID).toBe(hostComponent.props.testID); + 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 5212c18dc..911cad485 100644 --- a/src/helpers/component-tree.ts +++ b/src/helpers/component-tree.ts @@ -109,6 +109,9 @@ export function getCompositeParentOfType( 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 diff --git a/src/queries/__tests__/role.test.tsx b/src/queries/__tests__/role.test.tsx index 44ac66990..ea7b98de9 100644 --- a/src/queries/__tests__/role.test.tsx +++ b/src/queries/__tests__/role.test.tsx @@ -169,7 +169,7 @@ describe('supports name option', () => { accessibilityRole="header" testID="target-header" accessibilityLabel="About" - > + /> ); // assert on the testId to be sure that the returned element is the one with the accessibilityRole