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)
);