Skip to content

Commit 2520735

Browse files
authored
fix: *ByRole queries to match their own text text content (#1139)
1 parent 190df67 commit 2520735

11 files changed

+269
-52
lines changed
+90
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import * as React from 'react';
2+
import { Text, Pressable, View } from 'react-native';
3+
import { render, within } from '../pure';
4+
5+
/**
6+
* Our queries interact differently with composite and host elements, and some specific cases require us
7+
* to crawl up the tree to a Text composite element to be able to traverse it down again. Going up the tree
8+
* is a dangerous behaviour because we could take the risk of then traversing a sibling node to the original one.
9+
* This test suite is designed to be able to test as many different combinations, as a safety net.
10+
* Specific cases should still be tested within the relevant file (for instance an edge case with `within` should have
11+
* an explicit test in the within test suite)
12+
*/
13+
describe('nested text handling', () => {
14+
test('within same node', () => {
15+
const view = render(<Text testID="subject">Hello</Text>);
16+
expect(within(view.getByTestId('subject')).getByText('Hello')).toBeTruthy();
17+
});
18+
19+
test('role with direct text children', () => {
20+
const view = render(<Text accessibilityRole="header">About</Text>);
21+
22+
expect(view.getByRole('header', { name: 'About' })).toBeTruthy();
23+
});
24+
25+
test('nested text with child with role', () => {
26+
const view = render(
27+
<Text>
28+
<Text testID="child" accessibilityRole="header">
29+
About
30+
</Text>
31+
</Text>
32+
);
33+
34+
expect(view.getByRole('header', { name: 'About' }).props.testID).toBe(
35+
'child'
36+
);
37+
});
38+
39+
test('pressable within View, with text child', () => {
40+
const view = render(
41+
<View>
42+
<Pressable testID="pressable" accessibilityRole="button">
43+
<Text>Save</Text>
44+
</Pressable>
45+
</View>
46+
);
47+
48+
expect(view.getByRole('button', { name: 'Save' }).props.testID).toBe(
49+
'pressable'
50+
);
51+
});
52+
53+
test('pressable within View, with text child within view', () => {
54+
const view = render(
55+
<View>
56+
<Pressable testID="pressable" accessibilityRole="button">
57+
<View>
58+
<Text>Save</Text>
59+
</View>
60+
</Pressable>
61+
</View>
62+
);
63+
64+
expect(view.getByRole('button', { name: 'Save' }).props.testID).toBe(
65+
'pressable'
66+
);
67+
});
68+
69+
test('Text within pressable', () => {
70+
const view = render(
71+
<Pressable testID="pressable" accessibilityRole="button">
72+
<Text testID="text">Save</Text>
73+
</Pressable>
74+
);
75+
76+
expect(view.getByText('Save').props.testID).toBe('text');
77+
});
78+
79+
test('Text within view within pressable', () => {
80+
const view = render(
81+
<Pressable testID="pressable" accessibilityRole="button">
82+
<View>
83+
<Text testID="text">Save</Text>
84+
</View>
85+
</Pressable>
86+
);
87+
88+
expect(view.getByText('Save').props.testID).toBe('text');
89+
});
90+
});

src/__tests__/within.test.tsx

+8
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,11 @@ test('within() exposes a11y queries', async () => {
9494
test('getQueriesForElement is alias to within', () => {
9595
expect(getQueriesForElement).toBe(within);
9696
});
97+
98+
test('within allows searching for text within a composite component', () => {
99+
const view = render(<Text testID="subject">Hello</Text>);
100+
// view.getByTestId('subject') returns a host component, contrary to text queries returning a composite component
101+
// we want to be sure that this doesn't interfere with the way text is searched
102+
const hostTextQueries = within(view.getByTestId('subject'));
103+
expect(hostTextQueries.getByText('Hello')).toBeTruthy();
104+
});

src/fireEvent.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { ReactTestInstance } from 'react-test-renderer';
2+
import { TextInput } from 'react-native';
23
import act from './act';
34
import { isHostElement } from './helpers/component-tree';
45
import { filterNodeByType } from './helpers/filterNodeByType';
@@ -10,7 +11,6 @@ const isTextInput = (element?: ReactTestInstance) => {
1011
return false;
1112
}
1213

13-
const { TextInput } = require('react-native');
1414
// We have to test if the element type is either the TextInput component
1515
// (which would if it is a composite component) or the string
1616
// TextInput (which would be true if it is a host component)

src/helpers/__tests__/component-tree.test.tsx

+37
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import {
77
getHostSelf,
88
getHostSelves,
99
getHostSiblings,
10+
getCompositeParentOfType,
11+
isHostElementForType,
1012
} from '../component-tree';
1113

1214
function ZeroHostChildren() {
@@ -289,3 +291,38 @@ describe('getHostSiblings()', () => {
289291
]);
290292
});
291293
});
294+
295+
test('getCompositeParentOfType', () => {
296+
const root = render(
297+
<View testID="view">
298+
<Text testID="text" />
299+
</View>
300+
);
301+
const hostView = root.getByTestId('view');
302+
const hostText = root.getByTestId('text');
303+
304+
const compositeView = getCompositeParentOfType(hostView, View);
305+
// We get the corresponding composite component (same testID), but not the host
306+
expect(compositeView?.type).toBe(View);
307+
expect(compositeView?.props.testID).toBe('view');
308+
const compositeText = getCompositeParentOfType(hostText, Text);
309+
expect(compositeText?.type).toBe(Text);
310+
expect(compositeText?.props.testID).toBe('text');
311+
312+
// Checks parent type
313+
expect(getCompositeParentOfType(hostText, View)).toBeNull();
314+
expect(getCompositeParentOfType(hostView, Text)).toBeNull();
315+
316+
// Ignores itself, stops if ancestor is host
317+
expect(getCompositeParentOfType(compositeText!, Text)).toBeNull();
318+
expect(getCompositeParentOfType(compositeView!, View)).toBeNull();
319+
});
320+
321+
test('isHostElementForType', () => {
322+
const view = render(<View testID="test" />);
323+
const hostComponent = view.getByTestId('test');
324+
const compositeComponent = getCompositeParentOfType(hostComponent, View);
325+
expect(isHostElementForType(hostComponent, View)).toBe(true);
326+
expect(isHostElementForType(hostComponent, Text)).toBe(false);
327+
expect(isHostElementForType(compositeComponent!, View)).toBe(false);
328+
});

src/helpers/component-tree.ts

+34
Original file line numberDiff line numberDiff line change
@@ -113,3 +113,37 @@ export function getHostSiblings(
113113
(sibling) => !hostSelves.includes(sibling)
114114
);
115115
}
116+
117+
export function getCompositeParentOfType(
118+
element: ReactTestInstance,
119+
type: React.ComponentType
120+
) {
121+
let current = element.parent;
122+
123+
while (!isHostElement(current)) {
124+
// We're at the root of the tree
125+
if (!current) {
126+
return null;
127+
}
128+
129+
if (current.type === type) {
130+
return current;
131+
}
132+
current = current.parent;
133+
}
134+
135+
return null;
136+
}
137+
138+
/**
139+
* Note: this function should be generally used for core React Native types like `View`, `Text`, `TextInput`, etc.
140+
*/
141+
export function isHostElementForType(
142+
element: ReactTestInstance,
143+
type: React.ComponentType
144+
) {
145+
// Not a host element
146+
if (!isHostElement(element)) return false;
147+
148+
return getCompositeParentOfType(element, type) !== null;
149+
}

src/queries/__tests__/role.test.tsx

+46
Original file line numberDiff line numberDiff line change
@@ -134,4 +134,50 @@ describe('supports name option', () => {
134134
'target-button'
135135
);
136136
});
137+
138+
test('returns an element when the direct child is text', () => {
139+
const { getByRole, getByTestId } = render(
140+
<Text accessibilityRole="header" testID="target-header">
141+
About
142+
</Text>
143+
);
144+
145+
// assert on the testId to be sure that the returned element is the one with the accessibilityRole
146+
expect(getByRole('header', { name: 'About' })).toBe(
147+
getByTestId('target-header')
148+
);
149+
expect(getByRole('header', { name: 'About' }).props.testID).toBe(
150+
'target-header'
151+
);
152+
});
153+
154+
test('returns an element with nested Text as children', () => {
155+
const { getByRole, getByTestId } = render(
156+
<Text accessibilityRole="header" testID="parent">
157+
<Text testID="child">About</Text>
158+
</Text>
159+
);
160+
161+
// assert on the testId to be sure that the returned element is the one with the accessibilityRole
162+
expect(getByRole('header', { name: 'About' })).toBe(getByTestId('parent'));
163+
expect(getByRole('header', { name: 'About' }).props.testID).toBe('parent');
164+
});
165+
166+
test('returns a header with an accessibilityLabel', () => {
167+
const { getByRole, getByTestId } = render(
168+
<Text
169+
accessibilityRole="header"
170+
testID="target-header"
171+
accessibilityLabel="About"
172+
/>
173+
);
174+
175+
// assert on the testId to be sure that the returned element is the one with the accessibilityRole
176+
expect(getByRole('header', { name: 'About' })).toBe(
177+
getByTestId('target-header')
178+
);
179+
expect(getByRole('header', { name: 'About' }).props.testID).toBe(
180+
'target-header'
181+
);
182+
});
137183
});

src/queries/__tests__/text.test.tsx

+13-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
Button,
88
TextInput,
99
} from 'react-native';
10-
import { render, getDefaultNormalizer } from '../..';
10+
import { render, getDefaultNormalizer, within } from '../..';
1111

1212
type MyButtonProps = {
1313
children: React.ReactNode;
@@ -454,3 +454,15 @@ test('getByText and queryByText work with tabs', () => {
454454
expect(getByText(textWithTabs)).toBeTruthy();
455455
expect(queryByText(textWithTabs)).toBeTruthy();
456456
});
457+
458+
test('getByText searches for text within itself', () => {
459+
const { getByText } = render(<Text testID="subject">Hello</Text>);
460+
const textNode = within(getByText('Hello'));
461+
expect(textNode.getByText('Hello')).toBeTruthy();
462+
});
463+
464+
test('getByText searches for text within self host element', () => {
465+
const { getByTestId } = render(<Text testID="subject">Hello</Text>);
466+
const textNode = within(getByTestId('subject'));
467+
expect(textNode.getByText('Hello')).toBeTruthy();
468+
});

src/queries/a11yState.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { ReactTestInstance } from 'react-test-renderer';
2-
import { AccessibilityState } from 'react-native';
2+
import type { AccessibilityState } from 'react-native';
33
import { matchObjectProp } from '../helpers/matchers/matchObjectProp';
44
import { makeQueries } from './makeQueries';
55
import type {

src/queries/displayValue.ts

+8-15
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { ReactTestInstance } from 'react-test-renderer';
2-
import { createLibraryNotSupportedError } from '../helpers/errors';
2+
import { TextInput } from 'react-native';
33
import { filterNodeByType } from '../helpers/filterNodeByType';
44
import { matches, TextMatch } from '../matches';
55
import { makeQueries } from './makeQueries';
@@ -18,20 +18,13 @@ const getTextInputNodeByDisplayValue = (
1818
value: TextMatch,
1919
options: TextMatchOptions = {}
2020
) => {
21-
try {
22-
const { TextInput } = require('react-native');
23-
const { exact, normalizer } = options;
24-
const nodeValue =
25-
node.props.value !== undefined
26-
? node.props.value
27-
: node.props.defaultValue;
28-
return (
29-
filterNodeByType(node, TextInput) &&
30-
matches(value, nodeValue, normalizer, exact)
31-
);
32-
} catch (error) {
33-
throw createLibraryNotSupportedError(error);
34-
}
21+
const { exact, normalizer } = options;
22+
const nodeValue =
23+
node.props.value !== undefined ? node.props.value : node.props.defaultValue;
24+
return (
25+
filterNodeByType(node, TextInput) &&
26+
matches(value, nodeValue, normalizer, exact)
27+
);
3528
};
3629

3730
const queryAllByDisplayValue = (

src/queries/placeholderText.ts

+6-11
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { ReactTestInstance } from 'react-test-renderer';
2-
import { createLibraryNotSupportedError } from '../helpers/errors';
2+
import { TextInput } from 'react-native';
33
import { filterNodeByType } from '../helpers/filterNodeByType';
44
import { matches, TextMatch } from '../matches';
55
import { makeQueries } from './makeQueries';
@@ -18,16 +18,11 @@ const getTextInputNodeByPlaceholderText = (
1818
placeholder: TextMatch,
1919
options: TextMatchOptions = {}
2020
) => {
21-
try {
22-
const { TextInput } = require('react-native');
23-
const { exact, normalizer } = options;
24-
return (
25-
filterNodeByType(node, TextInput) &&
26-
matches(placeholder, node.props.placeholder, normalizer, exact)
27-
);
28-
} catch (error) {
29-
throw createLibraryNotSupportedError(error);
30-
}
21+
const { exact, normalizer } = options;
22+
return (
23+
filterNodeByType(node, TextInput) &&
24+
matches(placeholder, node.props.placeholder, normalizer, exact)
25+
);
3126
};
3227

3328
const queryAllByPlaceholderText = (

0 commit comments

Comments
 (0)