Skip to content

Commit 17755f7

Browse files
committed
feat: improved text matching
1 parent c9c0493 commit 17755f7

13 files changed

+175
-73
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
);
+49
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 '../textContent';
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

+38-2
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,20 @@ import { isHiddenFromAccessibility } from './accessiblity';
44

55
interface FindAllOptions {
66
includeHiddenElements?: boolean;
7+
78
/** RTL-compatible alias to `includeHiddenElements` */
89
hidden?: boolean;
10+
11+
/* Exclude any ancestors of deepest matched elements even if they match the predicate */
12+
deepestOnly?: boolean;
913
}
1014

1115
export function findAll(
1216
root: ReactTestInstance,
13-
predicate: (node: ReactTestInstance) => boolean,
17+
predicate: (element: ReactTestInstance) => boolean,
1418
options?: FindAllOptions
1519
) {
16-
const results = root.findAll(predicate);
20+
const results = findAllInternal(root, predicate, options);
1721

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

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

+27-6
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;
@@ -193,7 +216,7 @@ test('queryByText not found', () => {
193216
});
194217

195218
test('queryByText does not match nested text across multiple <Text> in <Text>', () => {
196-
const { queryByText } = render(
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', () => {
@@ -241,8 +264,8 @@ test('queryByText nested deep <CustomText> in <Text>', () => {
241264
<Text>
242265
<CustomText>Hello</CustomText> <CustomText>World!</CustomText>
243266
</Text>
244-
).queryByText('Hello World!')
245-
).toBe(null);
267+
).getByText('Hello World!')
268+
).toBeTruthy();
246269
});
247270

248271
test('queryByText with nested Text components: not-exact text match returns the most deeply nested common component', () => {
@@ -365,7 +388,6 @@ describe('Supports normalization', () => {
365388
<View>
366389
<Text>{` Text and
367390
368-
369391
whitespace`}</Text>
370392
</View>
371393
);
@@ -376,7 +398,6 @@ describe('Supports normalization', () => {
376398
test('trim and collapseWhitespace is customizable by getDefaultNormalizer param', () => {
377399
const testTextWithWhitespace = ` Text and
378400
379-
380401
whitespace`;
381402
const { getByText } = render(
382403
<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)