Skip to content

Commit 647ae26

Browse files
fix: improve text matching (#1222)
1 parent adfb493 commit 647ae26

13 files changed

+177
-75
lines changed

src/__tests__/react-native-api.test.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ test('React Native API assumption: nested <Text> renders single host element', (
4545
</Text>
4646
</Text>
4747
);
48-
expect(getHostSelf(view.getByText('Hello'))).toBe(view.getByTestId('test'));
48+
expect(getHostSelf(view.getByText(/Hello/))).toBe(view.getByTestId('test'));
4949
expect(getHostSelf(view.getByText('Before'))).toBe(
5050
view.getByTestId('before')
5151
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import * as React from 'react';
2+
import { Text } from 'react-native';
3+
import render from '../../render';
4+
import { getTextContent } from '../getTextContent';
5+
6+
test('getTextContent with simple content', () => {
7+
const view = render(<Text>Hello world</Text>);
8+
expect(getTextContent(view.container)).toBe('Hello world');
9+
});
10+
11+
test('getTextContent with null element', () => {
12+
expect(getTextContent(null)).toBe('');
13+
});
14+
15+
test('getTextContent with single nested content', () => {
16+
const view = render(
17+
<Text>
18+
<Text>Hello world</Text>
19+
</Text>
20+
);
21+
expect(getTextContent(view.container)).toBe('Hello world');
22+
});
23+
24+
test('getTextContent with multiple nested content', () => {
25+
const view = render(
26+
<Text>
27+
<Text>Hello</Text> <Text>world</Text>
28+
</Text>
29+
);
30+
expect(getTextContent(view.container)).toBe('Hello world');
31+
});
32+
33+
test('getTextContent with multiple number content', () => {
34+
const view = render(
35+
<Text>
36+
<Text>Hello</Text> <Text>world</Text> <Text>{100}</Text>
37+
</Text>
38+
);
39+
expect(getTextContent(view.container)).toBe('Hello world 100');
40+
});
41+
42+
test('getTextContent with multiple boolean content', () => {
43+
const view = render(
44+
<Text>
45+
<Text>Hello{false}</Text> <Text>{true}world</Text>
46+
</Text>
47+
);
48+
expect(getTextContent(view.container)).toBe('Hello world');
49+
});

src/helpers/findAll.ts

+39-2
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,22 @@ import { getConfig } from '../config';
33
import { isHiddenFromAccessibility } from './accessiblity';
44

55
interface FindAllOptions {
6+
/** Match elements hidden from accessibility */
67
includeHiddenElements?: boolean;
8+
79
/** RTL-compatible alias to `includeHiddenElements` */
810
hidden?: boolean;
11+
12+
/* Exclude any ancestors of deepest matched elements even if they match the predicate */
13+
matchDeepestOnly?: boolean;
914
}
1015

1116
export function findAll(
1217
root: ReactTestInstance,
13-
predicate: (node: ReactTestInstance) => boolean,
18+
predicate: (element: ReactTestInstance) => boolean,
1419
options?: FindAllOptions
1520
) {
16-
const results = root.findAll(predicate);
21+
const results = findAllInternal(root, predicate, options);
1722

1823
const includeHiddenElements =
1924
options?.includeHiddenElements ??
@@ -29,3 +34,35 @@ export function findAll(
2934
(element) => !isHiddenFromAccessibility(element, { cache })
3035
);
3136
}
37+
38+
// Extracted from React Test Renderer
39+
// src: https://github.com/facebook/react/blob/8e2bde6f2751aa6335f3cef488c05c3ea08e074a/packages/react-test-renderer/src/ReactTestRenderer.js#L402
40+
function findAllInternal(
41+
root: ReactTestInstance,
42+
predicate: (element: ReactTestInstance) => boolean,
43+
options?: FindAllOptions
44+
): Array<ReactTestInstance> {
45+
const results: ReactTestInstance[] = [];
46+
47+
// Match descendants first but do not add them to results yet.
48+
const matchingDescendants: ReactTestInstance[] = [];
49+
root.children.forEach((child) => {
50+
if (typeof child === 'string') {
51+
return;
52+
}
53+
matchingDescendants.push(...findAllInternal(child, predicate, options));
54+
});
55+
56+
if (
57+
// When matchDeepestOnly = true: add current element only if no descendants match
58+
(!options?.matchDeepestOnly || matchingDescendants.length === 0) &&
59+
predicate(root)
60+
) {
61+
results.push(root);
62+
}
63+
64+
// Add matching descendants after element to preserve original tree walk order.
65+
results.push(...matchingDescendants);
66+
67+
return results;
68+
}

src/helpers/getTextContent.ts

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { ReactTestInstance } from 'react-test-renderer';
2+
3+
export function getTextContent(
4+
element: ReactTestInstance | string | null
5+
): string {
6+
if (!element) {
7+
return '';
8+
}
9+
10+
if (typeof element === 'string') {
11+
return element;
12+
}
13+
14+
const result: string[] = [];
15+
element.children?.forEach((child) => {
16+
result.push(getTextContent(child));
17+
});
18+
19+
return result.join('');
20+
}
+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { Text } from 'react-native';
2+
import type { ReactTestInstance } from 'react-test-renderer';
3+
import { matches, TextMatch, TextMatchOptions } from '../../matches';
4+
import { filterNodeByType } from '../filterNodeByType';
5+
import { getTextContent } from '../getTextContent';
6+
7+
export function matchTextContent(
8+
node: ReactTestInstance,
9+
text: TextMatch,
10+
options: TextMatchOptions = {}
11+
) {
12+
if (!filterNodeByType(node, Text)) {
13+
return false;
14+
}
15+
16+
const textContent = getTextContent(node);
17+
const { exact, normalizer } = options;
18+
return matches(text, textContent, normalizer, exact);
19+
}

src/matches.ts

+5
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
export type NormalizerFn = (textToNormalize: string) => string;
2+
23
export type TextMatch = string | RegExp;
4+
export type TextMatchOptions = {
5+
exact?: boolean;
6+
normalizer?: NormalizerFn;
7+
};
38

49
export function matches(
510
matcher: TextMatch,

src/queries/__tests__/text.test.tsx

+28-8
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,29 @@ import {
99
} from 'react-native';
1010
import { render, getDefaultNormalizer, within } from '../..';
1111

12+
test('byText matches simple text', () => {
13+
const { getByText } = render(<Text testID="text">Hello World</Text>);
14+
expect(getByText('Hello World').props.testID).toBe('text');
15+
});
16+
17+
test('byText matches inner nested text', () => {
18+
const { getByText } = render(
19+
<Text testID="outer">
20+
<Text testID="inner">Hello World</Text>
21+
</Text>
22+
);
23+
expect(getByText('Hello World').props.testID).toBe('inner');
24+
});
25+
26+
test('byText matches accross multiple texts', () => {
27+
const { getByText } = render(
28+
<Text testID="outer">
29+
<Text testID="inner-1">Hello</Text> <Text testID="inner-2">World</Text>
30+
</Text>
31+
);
32+
expect(getByText('Hello World').props.testID).toBe('outer');
33+
});
34+
1235
type MyButtonProps = {
1336
children: React.ReactNode;
1437
onPress: () => void;
@@ -192,8 +215,8 @@ test('queryByText not found', () => {
192215
).toBeFalsy();
193216
});
194217

195-
test('queryByText does not match nested text across multiple <Text> in <Text>', () => {
196-
const { queryByText } = render(
218+
test('*ByText matches text across multiple nested Text', () => {
219+
const { getByText } = render(
197220
<Text nativeID="1">
198221
Hello{' '}
199222
<Text nativeID="2">
@@ -203,7 +226,7 @@ test('queryByText does not match nested text across multiple <Text> in <Text>',
203226
</Text>
204227
);
205228

206-
expect(queryByText('Hello World!')).toBe(null);
229+
expect(getByText('Hello World!')).toBeTruthy();
207230
});
208231

209232
test('queryByText with nested Text components return the closest Text', () => {
@@ -214,7 +237,6 @@ test('queryByText with nested Text components return the closest Text', () => {
214237
);
215238

216239
const { queryByText } = render(<NestedTexts />);
217-
218240
expect(queryByText('My text', { exact: false })?.props.nativeID).toBe('2');
219241
});
220242

@@ -241,8 +263,8 @@ test('queryByText nested deep <CustomText> in <Text>', () => {
241263
<Text>
242264
<CustomText>Hello</CustomText> <CustomText>World!</CustomText>
243265
</Text>
244-
).queryByText('Hello World!')
245-
).toBe(null);
266+
).getByText('Hello World!')
267+
).toBeTruthy();
246268
});
247269

248270
test('queryByText with nested Text components: not-exact text match returns the most deeply nested common component', () => {
@@ -365,7 +387,6 @@ describe('Supports normalization', () => {
365387
<View>
366388
<Text>{` Text and
367389
368-
369390
whitespace`}</Text>
370391
</View>
371392
);
@@ -376,7 +397,6 @@ describe('Supports normalization', () => {
376397
test('trim and collapseWhitespace is customizable by getDefaultNormalizer param', () => {
377398
const testTextWithWhitespace = ` Text and
378399
379-
380400
whitespace`;
381401
const { getByText } = render(
382402
<View>

src/queries/displayValue.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { ReactTestInstance } from 'react-test-renderer';
22
import { TextInput } from 'react-native';
33
import { filterNodeByType } from '../helpers/filterNodeByType';
44
import { findAll } from '../helpers/findAll';
5-
import { matches, TextMatch } from '../matches';
5+
import { matches, TextMatch, TextMatchOptions } from '../matches';
66
import { makeQueries } from './makeQueries';
77
import type {
88
FindAllByQuery,
@@ -12,7 +12,7 @@ import type {
1212
QueryAllByQuery,
1313
QueryByQuery,
1414
} from './makeQueries';
15-
import type { CommonQueryOptions, TextMatchOptions } from './options';
15+
import type { CommonQueryOptions } from './options';
1616

1717
type ByDisplayValueOptions = CommonQueryOptions & TextMatchOptions;
1818

src/queries/hintText.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { ReactTestInstance } from 'react-test-renderer';
22
import { findAll } from '../helpers/findAll';
3-
import { matches, TextMatch } from '../matches';
3+
import { matches, TextMatch, TextMatchOptions } from '../matches';
44
import { makeQueries } from './makeQueries';
55
import type {
66
FindAllByQuery,
@@ -10,7 +10,7 @@ import type {
1010
QueryAllByQuery,
1111
QueryByQuery,
1212
} from './makeQueries';
13-
import { CommonQueryOptions, TextMatchOptions } from './options';
13+
import { CommonQueryOptions } from './options';
1414

1515
type ByHintTextOptions = CommonQueryOptions & TextMatchOptions;
1616

src/queries/labelText.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { ReactTestInstance } from 'react-test-renderer';
22
import { findAll } from '../helpers/findAll';
3-
import { matches, TextMatch } from '../matches';
3+
import { matches, TextMatch, TextMatchOptions } from '../matches';
44
import { makeQueries } from './makeQueries';
55
import type {
66
FindAllByQuery,
@@ -10,7 +10,7 @@ import type {
1010
QueryAllByQuery,
1111
QueryByQuery,
1212
} from './makeQueries';
13-
import { CommonQueryOptions, TextMatchOptions } from './options';
13+
import { CommonQueryOptions } from './options';
1414

1515
type ByLabelTextOptions = CommonQueryOptions & TextMatchOptions;
1616

src/queries/placeholderText.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { ReactTestInstance } from 'react-test-renderer';
22
import { TextInput } from 'react-native';
33
import { findAll } from '../helpers/findAll';
44
import { filterNodeByType } from '../helpers/filterNodeByType';
5-
import { matches, TextMatch } from '../matches';
5+
import { matches, TextMatch, TextMatchOptions } from '../matches';
66
import { makeQueries } from './makeQueries';
77
import type {
88
FindAllByQuery,
@@ -12,7 +12,7 @@ import type {
1212
QueryAllByQuery,
1313
QueryByQuery,
1414
} from './makeQueries';
15-
import type { CommonQueryOptions, TextMatchOptions } from './options';
15+
import type { CommonQueryOptions } from './options';
1616

1717
type ByPlaceholderTextOptions = CommonQueryOptions & TextMatchOptions;
1818

src/queries/testId.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { ReactTestInstance } from 'react-test-renderer';
22
import { findAll } from '../helpers/findAll';
3-
import { matches, TextMatch } from '../matches';
3+
import { matches, TextMatch, TextMatchOptions } from '../matches';
44
import { makeQueries } from './makeQueries';
55
import type {
66
FindAllByQuery,
@@ -10,7 +10,7 @@ import type {
1010
QueryAllByQuery,
1111
QueryByQuery,
1212
} from './makeQueries';
13-
import type { CommonQueryOptions, TextMatchOptions } from './options';
13+
import type { CommonQueryOptions } from './options';
1414

1515
type ByTestIdOptions = CommonQueryOptions & TextMatchOptions;
1616

0 commit comments

Comments
 (0)