Skip to content

feat(breaking): add text match options a.k.a string precision API #554

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 31 commits into from
Nov 23, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
34ffda8
Add TextMatch options to getByAPI
RafikiTiki Sep 9, 2020
d95ff2f
Add TextMatch options to queryByAPI
RafikiTiki Sep 9, 2020
b616319
Add TextMatch options to findByAPI
RafikiTiki Sep 9, 2020
3857870
Add tests covering new TextMatch options
RafikiTiki Sep 9, 2020
7c4c074
Add documentation and examples for TextMatch options
RafikiTiki Sep 9, 2020
f9e7d8d
Implement text match normalization customization
RafikiTiki Sep 14, 2020
e3719bd
Implement tests for normalization options
RafikiTiki Sep 14, 2020
73c6aed
Add text normalization docs
RafikiTiki Sep 14, 2020
0884d86
Refactor typings
RafikiTiki Sep 14, 2020
c14446c
Add export for getDefaultNormalizer function
RafikiTiki Sep 14, 2020
677a78e
Remove undocumented config for normalizer function
RafikiTiki Sep 14, 2020
cb6d122
Update website/docs/Queries.md
thymikee Sep 15, 2020
b7f0b74
BREAKING: make findBy* queries arguments order compatible with RTL
RafikiTiki Sep 15, 2020
5f1d266
Fix tests for findBy queries after changing their API
RafikiTiki Sep 15, 2020
e5e977b
Fix matching nested Text components with queryOptions exact set to true
RafikiTiki Sep 15, 2020
4118a23
Remove recursion matching for *byText APIs
AugustinLF Sep 28, 2020
4087aff
Reuse getAllByText in getByText
AugustinLF Sep 28, 2020
6d8caf4
Add Textmatch options to *byTestId
AugustinLF Oct 7, 2020
5a8c62b
fix: types for getDefaultNormalizer
May 17, 2021
901d3e8
refactor: remove makeNormalizer and use getDefaultNormalizer() instead
May 17, 2021
e1ad2b4
refactor: change params position and description for matches
May 17, 2021
ff10a3a
Merge branch 'master' into AugustinLF/non-recursive-text-match-options
May 19, 2021
6eb071a
refactor: move & remove duplicated tests after merge
May 19, 2021
3bafdff
Merge remote-tracking branch 'origin/master' into non-recursive-text-…
AugustinLF Oct 30, 2021
d2f9f33
Make waitForOptions param change non breaking change
AugustinLF Oct 30, 2021
af62597
chore: cleanup and type fixups
thymikee Nov 23, 2021
5aa5e50
Merge remote-tracking branch 'origin/main' into pr/554
thymikee Nov 23, 2021
864155a
tests: update snapshots
thymikee Nov 23, 2021
ed34a69
chore: use default arg value for normalizer
thymikee Nov 23, 2021
223247b
Restore mock after use
AugustinLF Nov 23, 2021
bd17c86
fix: wrong snapshot update with outdated deps
thymikee Nov 23, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/__tests__/byDisplayValue.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,10 @@ test('findBy queries work asynchronously', async () => {
);

await expect(
findByDisplayValue('Display Value', options)
findByDisplayValue('Display Value', {}, options)
).rejects.toBeTruthy();
await expect(
findAllByDisplayValue('Display Value', options)
findAllByDisplayValue('Display Value', {}, options)
).rejects.toBeTruthy();

setTimeout(
Expand Down
4 changes: 2 additions & 2 deletions src/__tests__/byTestId.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,8 @@ test('getAllByTestId, queryAllByTestId', () => {
test('findByTestId and findAllByTestId work asynchronously', async () => {
const options = { timeout: 10 }; // Short timeout so that this test runs quickly
const { rerender, findByTestId, findAllByTestId } = render(<View />);
await expect(findByTestId('aTestId', options)).rejects.toBeTruthy();
await expect(findAllByTestId('aTestId', options)).rejects.toBeTruthy();
await expect(findByTestId('aTestId', {}, options)).rejects.toBeTruthy();
await expect(findAllByTestId('aTestId', {}, options)).rejects.toBeTruthy();

setTimeout(
() =>
Expand Down
231 changes: 210 additions & 21 deletions src/__tests__/byText.test.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
// @flow
import * as React from 'react';
import { View, Text, TouchableOpacity, Image } from 'react-native';
import { render } from '..';
import {
View,
Text,
TouchableOpacity,
Image,
Button,
TextInput,
} from 'react-native';
import { render, getDefaultNormalizer } from '..';

const MyButton = ({ children, onPress }) => (
<TouchableOpacity onPress={onPress}>
Expand Down Expand Up @@ -88,8 +95,8 @@ test('getAllByText, queryAllByText', () => {
test('findByText queries work asynchronously', async () => {
const options = { timeout: 10 }; // Short timeout so that this test runs quickly
const { rerender, findByText, findAllByText } = render(<View />);
await expect(findByText('Some Text', options)).rejects.toBeTruthy();
await expect(findAllByText('Some Text', options)).rejects.toBeTruthy();
await expect(findByText('Some Text', {}, options)).rejects.toBeTruthy();
await expect(findAllByText('Some Text', {}, options)).rejects.toBeTruthy();

setTimeout(
() =>
Expand All @@ -105,6 +112,39 @@ test('findByText queries work asynchronously', async () => {
await expect(findAllByText('Some Text')).resolves.toHaveLength(1);
}, 20000);

describe('findBy options deprecations', () => {
let warnSpy;
beforeEach(() => {
warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
});
afterEach(() => {
warnSpy.mockRestore();
});

test('findByText queries warn on deprecated use of WaitForOptions', async () => {
const options = { timeout: 10 };
// mock implementation to avoid warning in the test suite
const { rerender, findByText } = render(<View />);
await expect(findByText('Some Text', options)).rejects.toBeTruthy();

setTimeout(
() =>
rerender(
<View>
<Text>Some Text</Text>
</View>
),
20
);

await expect(findByText('Some Text')).resolves.toBeTruthy();

expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('Use of option "timeout"')
);
}, 20000);
});

test.skip('getByText works properly with custom text component', () => {
function BoldText({ children }) {
return <Text>{children}</Text>;
Expand Down Expand Up @@ -181,7 +221,7 @@ test('queryByText not found', () => {
).toBeFalsy();
});

test('queryByText nested text across multiple <Text> in <Text>', () => {
test('queryByText does not match nested text across multiple <Text> in <Text>', () => {
const { queryByText } = render(
<Text nativeID="1">
Hello{' '}
Expand All @@ -192,7 +232,7 @@ test('queryByText nested text across multiple <Text> in <Text>', () => {
</Text>
);

expect(queryByText('Hello World!')?.props.nativeID).toBe('1');
expect(queryByText('Hello World!')).toBe(null);
});

test('queryByText with nested Text components return the closest Text', () => {
Expand All @@ -204,7 +244,7 @@ test('queryByText with nested Text components return the closest Text', () => {

const { queryByText } = render(<NestedTexts />);

expect(queryByText('My text')?.props.nativeID).toBe('2');
expect(queryByText('My text', { exact: false })?.props.nativeID).toBe('2');
});

test('queryByText with nested Text components each with text return the lowest one', () => {
Expand All @@ -217,33 +257,182 @@ test('queryByText with nested Text components each with text return the lowest o

const { queryByText } = render(<NestedTexts />);

expect(queryByText('My text')?.props.nativeID).toBe('2');
expect(queryByText('My text', { exact: false })?.props.nativeID).toBe('2');
});

test('queryByText nested <CustomText> in <Text>', () => {
test('queryByText nested deep <CustomText> in <Text>', () => {
const CustomText = ({ children }) => {
return <Text>{children}</Text>;
};

expect(
render(
<Text>
Hello <CustomText>World!</CustomText>
<CustomText>Hello</CustomText> <CustomText>World!</CustomText>
</Text>
).queryByText('Hello World!')
).toBeTruthy();
).toBe(null);
});

test('queryByText nested deep <CustomText> in <Text>', () => {
const CustomText = ({ children }) => {
return <Text>{children}</Text>;
};
test('queryByText with nested Text components: not-exact text match returns the most deeply nested common component', () => {
const { queryByText: queryByTextFirstCase } = render(
<Text nativeID="1">
bob
<Text nativeID="2">My </Text>
<Text nativeID="3">text</Text>
</Text>
);

const { queryByText: queryByTextSecondCase } = render(
<Text nativeID="1">
bob
<Text nativeID="2">My text for test</Text>
</Text>
);

expect(queryByTextFirstCase('My text')).toBe(null);
expect(
render(
<Text>
<CustomText>Hello</CustomText> <CustomText>World!</CustomText>
</Text>
).queryByText('Hello World!')
).toBeTruthy();
queryByTextSecondCase('My text', { exact: false })?.props.nativeID
).toBe('2');
});

test('queryAllByText does not match several times the same text', () => {
const allMatched = render(
<Text nativeID="1">
Start
<Text nativeID="2">This is a long text</Text>
</Text>
).queryAllByText('long text', { exact: false });
expect(allMatched.length).toBe(1);
expect(allMatched[0].props.nativeID).toBe('2');
});

test('queryAllByText matches all the matching nodes', () => {
const allMatched = render(
<Text nativeID="1">
Start
<Text nativeID="2">This is a long text</Text>
<Text nativeID="3">This is another long text</Text>
</Text>
).queryAllByText('long text', { exact: false });
expect(allMatched.length).toBe(2);
expect(allMatched.map((node) => node.props.nativeID)).toEqual(['2', '3']);
});

describe('supports TextMatch options', () => {
test('getByText, getAllByText', () => {
const { getByText, getAllByText } = render(
<View>
<Text testID="text">Text and details</Text>
<Button
testID="button"
title="Button and a detail"
onPress={jest.fn()}
/>
</View>
);

expect(getByText('details', { exact: false })).toBeTruthy();
expect(getAllByText('detail', { exact: false })).toHaveLength(2);
});

test('getByPlaceholderText, getAllByPlaceholderText', () => {
const { getByPlaceholderText, getAllByPlaceholderText } = render(
<View>
<TextInput placeholder={'Placeholder with details'} />
<TextInput placeholder={'Placeholder with a DETAIL'} />
</View>
);

expect(getByPlaceholderText('details', { exact: false })).toBeTruthy();
expect(getAllByPlaceholderText('detail', { exact: false })).toHaveLength(2);
});

test('getByDisplayValue, getAllByDisplayValue', () => {
const { getByDisplayValue, getAllByDisplayValue } = render(
<View>
<TextInput value={'Value with details'} />
<TextInput value={'Value with a detail'} />
</View>
);

expect(getByDisplayValue('details', { exact: false })).toBeTruthy();
expect(getAllByDisplayValue('detail', { exact: false })).toHaveLength(2);
});

test('getByTestId, getAllByTestId', () => {
const { getByTestId, getAllByTestId } = render(
<View>
<View testID="test" />
<View testID="tests id" />
</View>
);
expect(getByTestId('id', { exact: false })).toBeTruthy();
expect(getAllByTestId('test', { exact: false })).toHaveLength(2);
});

test('with TextMatch option exact === false text search is NOT case sensitive', () => {
const { getByText, getAllByText } = render(
<View>
<Text testID="text">Text and details</Text>
<Button
testID="button"
title="Button and a DeTAil"
onPress={jest.fn()}
/>
</View>
);

expect(getByText('DeTaIlS', { exact: false })).toBeTruthy();
expect(getAllByText('detail', { exact: false })).toHaveLength(2);
});
});

describe('Supports normalization', () => {
test('trims and collapses whitespace by default', () => {
const { getByText } = render(
<View>
<Text>{` Text and


whitespace`}</Text>
</View>
);

expect(getByText('Text and whitespace')).toBeTruthy();
});

test('trim and collapseWhitespace is customizable by getDefaultNormalizer param', () => {
const testTextWithWhitespace = ` Text and


whitespace`;
const { getByText } = render(
<View>
<Text>{testTextWithWhitespace}</Text>
</View>
);

expect(
getByText(testTextWithWhitespace, {
normalizer: getDefaultNormalizer({
trim: false,
collapseWhitespace: false,
}),
})
).toBeTruthy();
});

test('normalizer function is customisable', () => {
const testText = 'A TO REMOVE text';
const normalizerFn = (textToNormalize) =>
textToNormalize.replace('TO REMOVE ', '');
const { getByText } = render(
<View>
<Text>{testText}</Text>
</View>
);

expect(getByText('A text', { normalizer: normalizerFn })).toBeTruthy();
});
});
20 changes: 15 additions & 5 deletions src/helpers/byDisplayValue.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,26 @@
// @flow
import { matches } from '../matches';
import { makeQueries } from './makeQueries';
import type { Queries } from './makeQueries';
import { filterNodeByType } from './filterNodeByType';
import { createLibraryNotSupportedError } from './errors';
import type { TextMatchOptions } from './byText';

const getTextInputNodeByDisplayValue = (node, value) => {
const getTextInputNodeByDisplayValue = (
node,
value,
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) &&
(typeof value === 'string' ? value === nodeValue : value.test(nodeValue))
matches(value, nodeValue, normalizer, exact)
);
} catch (error) {
throw createLibraryNotSupportedError(error);
Expand All @@ -22,10 +29,13 @@ const getTextInputNodeByDisplayValue = (node, value) => {

const queryAllByDisplayValue = (
instance: ReactTestInstance
): ((displayValue: string | RegExp) => Array<ReactTestInstance>) =>
function queryAllByDisplayValueFn(displayValue) {
): ((
displayValue: string | RegExp,
queryOptions?: TextMatchOptions
) => Array<ReactTestInstance>) =>
function queryAllByDisplayValueFn(displayValue, queryOptions) {
return instance.findAll((node) =>
getTextInputNodeByDisplayValue(node, displayValue)
getTextInputNodeByDisplayValue(node, displayValue, queryOptions)
);
};

Expand Down
Loading