Skip to content

Commit a03f056

Browse files
RoystonSKent C. Dodds
authored and
Kent C. Dodds
committed
feat(custom normalizer): allow custom control of normalization (#172)
* feat(custom normalizer): allow custom control of normalization Adds an optional {normalizer} option to query functions; this is a transformation function run over candidate match text after it has had `trim` or `collapseWhitespace` run on it, but before any matching text/function/regexp is tested against it. The use case is for tidying up DOM text (which may contain, for instance, invisible Unicode control characters) before running matching logic, keeping the matching logic and normalization logic separate. * Expand acronyms out * Add `getDefaultNormalizer()` and move existing options This commit moves the implementation of `trim` + `collapseWhitespace`, making them just another normalizer. It also exposes a `getDefaultNormalizer()` function which provides the default normalization and allows for the configuration of it. Removed `matches` and `fuzzyMatches` from being publicly exposed. Updated tests, added new documentation for normalizer and getDefaultNormalizer, and removed documentation for the previous top-level `trim` and `collapseWhitespace` options. * Apply normalizer treatment to queryAllByDisplayValue
1 parent dec7856 commit a03f056

File tree

9 files changed

+294
-87
lines changed

9 files changed

+294
-87
lines changed

Diff for: README.md

+50-19
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ when a real user uses it.
9797
- [Using other assertion libraries](#using-other-assertion-libraries)
9898
- [`TextMatch`](#textmatch)
9999
- [Precision](#precision)
100+
- [Normalization](#normalization)
100101
- [TextMatch Examples](#textmatch-examples)
101102
- [`query` APIs](#query-apis)
102103
- [`queryAll` and `getAll` APIs](#queryall-and-getall-apis)
@@ -207,8 +208,7 @@ getByLabelText(
207208
options?: {
208209
selector?: string = '*',
209210
exact?: boolean = true,
210-
collapseWhitespace?: boolean = true,
211-
trim?: boolean = true,
211+
normalizer?: NormalizerFn,
212212
}): HTMLElement
213213
```
214214

@@ -259,8 +259,7 @@ getByPlaceholderText(
259259
text: TextMatch,
260260
options?: {
261261
exact?: boolean = true,
262-
collapseWhitespace?: boolean = false,
263-
trim?: boolean = true,
262+
normalizer?: NormalizerFn,
264263
}): HTMLElement
265264
```
266265

@@ -284,9 +283,8 @@ getByText(
284283
options?: {
285284
selector?: string = '*',
286285
exact?: boolean = true,
287-
collapseWhitespace?: boolean = true,
288-
trim?: boolean = true,
289-
ignore?: string|boolean = 'script, style'
286+
ignore?: string|boolean = 'script, style',
287+
normalizer?: NormalizerFn,
290288
}): HTMLElement
291289
```
292290

@@ -316,8 +314,7 @@ getByAltText(
316314
text: TextMatch,
317315
options?: {
318316
exact?: boolean = true,
319-
collapseWhitespace?: boolean = false,
320-
trim?: boolean = true,
317+
normalizer?: NormalizerFn,
321318
}): HTMLElement
322319
```
323320

@@ -341,8 +338,7 @@ getByTitle(
341338
title: TextMatch,
342339
options?: {
343340
exact?: boolean = true,
344-
collapseWhitespace?: boolean = false,
345-
trim?: boolean = true,
341+
normalizer?: NormalizerFn,
346342
}): HTMLElement
347343
```
348344

@@ -368,8 +364,7 @@ getByDisplayValue(
368364
value: TextMatch,
369365
options?: {
370366
exact?: boolean = true,
371-
collapseWhitespace?: boolean = false,
372-
trim?: boolean = true,
367+
normalizer?: NormalizerFn,
373368
}): HTMLElement
374369
```
375370

@@ -416,8 +411,7 @@ getByRole(
416411
text: TextMatch,
417412
options?: {
418413
exact?: boolean = true,
419-
collapseWhitespace?: boolean = false,
420-
trim?: boolean = true,
414+
normalizer?: NormalizerFn,
421415
}): HTMLElement
422416
```
423417

@@ -437,9 +431,8 @@ getByTestId(
437431
text: TextMatch,
438432
options?: {
439433
exact?: boolean = true,
440-
collapseWhitespace?: boolean = false,
441-
trim?: boolean = true,
442-
}): HTMLElement`
434+
normalizer?: NormalizerFn,
435+
}): HTMLElement
443436
```
444437

445438
A shortcut to `` container.querySelector(`[data-testid="${yourId}"]`) `` (and it
@@ -801,9 +794,47 @@ affect the precision of string matching:
801794
- `exact` has no effect on `regex` or `function` arguments.
802795
- In most cases using a regex instead of a string gives you more control over
803796
fuzzy matching and should be preferred over `{ exact: false }`.
804-
- `trim`: Defaults to `true`; trim leading and trailing whitespace.
797+
- `normalizer`: An optional function which overrides normalization behavior.
798+
See [`Normalization`](#normalization).
799+
800+
### Normalization
801+
802+
Before running any matching logic against text in the DOM, `dom-testing-library`
803+
automatically normalizes that text. By default, normalization consists of
804+
trimming whitespace from the start and end of text, and collapsing multiple
805+
adjacent whitespace characters into a single space.
806+
807+
If you want to prevent that normalization, or provide alternative
808+
normalization (e.g. to remove Unicode control characters), you can provide a
809+
`normalizer` function in the options object. This function will be given
810+
a string and is expected to return a normalized version of that string.
811+
812+
Note: Specifying a value for `normalizer` _replaces_ the built-in normalization, but
813+
you can call `getDefaultNormalizer` to obtain a built-in normalizer, either
814+
to adjust that normalization or to call it from your own normalizer.
815+
816+
`getDefaultNormalizer` takes an options object which allows the selection of behaviour:
817+
818+
- `trim`: Defaults to `true`. Trims leading and trailing whitespace
805819
- `collapseWhitespace`: Defaults to `true`. Collapses inner whitespace (newlines, tabs, repeated spaces) into a single space.
806820

821+
#### Normalization Examples
822+
823+
To perform a match against text without trimming:
824+
825+
```javascript
826+
getByText(node, 'text', {normalizer: getDefaultNormalizer({trim: false})})
827+
```
828+
829+
To override normalization to remove some Unicode characters whilst keeping some (but not all) of the built-in normalization behavior:
830+
831+
```javascript
832+
getByText(node, 'text', {
833+
normalizer: str =>
834+
getDefaultNormalizer({trim: false})(str).replace(/[\u200E-\u200F]*/g, ''),
835+
})
836+
```
837+
807838
### TextMatch Examples
808839

809840
```javascript

Diff for: src/__tests__/matches.js

+12-9
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,28 @@
1-
import {fuzzyMatches, matches} from '../'
1+
import {fuzzyMatches, matches} from '../matches'
22

33
// unit tests for text match utils
44

55
const node = null
6+
const normalizer = str => str
67

78
test('matchers accept strings', () => {
8-
expect(matches('ABC', node, 'ABC')).toBe(true)
9-
expect(fuzzyMatches('ABC', node, 'ABC')).toBe(true)
9+
expect(matches('ABC', node, 'ABC', normalizer)).toBe(true)
10+
expect(fuzzyMatches('ABC', node, 'ABC', normalizer)).toBe(true)
1011
})
1112

1213
test('matchers accept regex', () => {
13-
expect(matches('ABC', node, /ABC/)).toBe(true)
14-
expect(fuzzyMatches('ABC', node, /ABC/)).toBe(true)
14+
expect(matches('ABC', node, /ABC/, normalizer)).toBe(true)
15+
expect(fuzzyMatches('ABC', node, /ABC/, normalizer)).toBe(true)
1516
})
1617

1718
test('matchers accept functions', () => {
18-
expect(matches('ABC', node, text => text === 'ABC')).toBe(true)
19-
expect(fuzzyMatches('ABC', node, text => text === 'ABC')).toBe(true)
19+
expect(matches('ABC', node, text => text === 'ABC', normalizer)).toBe(true)
20+
expect(fuzzyMatches('ABC', node, text => text === 'ABC', normalizer)).toBe(
21+
true,
22+
)
2023
})
2124

2225
test('matchers return false if text to match is not a string', () => {
23-
expect(matches(null, node, 'ABC')).toBe(false)
24-
expect(fuzzyMatches(null, node, 'ABC')).toBe(false)
26+
expect(matches(null, node, 'ABC', normalizer)).toBe(false)
27+
expect(fuzzyMatches(null, node, 'ABC', normalizer)).toBe(false)
2528
})

Diff for: src/__tests__/text-matchers.js

+133-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import 'jest-dom/extend-expect'
22
import cases from 'jest-in-case'
3+
4+
import {getDefaultNormalizer} from '../'
35
import {render} from './helpers/test-utils'
46

57
cases(
@@ -68,7 +70,12 @@ cases(
6870
const queries = render(dom)
6971
expect(queries[queryFn](query)).toHaveLength(1)
7072
expect(
71-
queries[queryFn](query, {collapseWhitespace: false, trim: false}),
73+
queries[queryFn](query, {
74+
normalizer: getDefaultNormalizer({
75+
collapseWhitespace: false,
76+
trim: false,
77+
}),
78+
}),
7279
).toHaveLength(0)
7380
},
7481
{
@@ -194,3 +201,128 @@ cases(
194201
},
195202
},
196203
)
204+
205+
// A good use case for a custom normalizer is stripping
206+
// out Unicode control characters such as LRM (left-right-mark)
207+
// before matching
208+
const LRM = '\u200e'
209+
function removeUCC(str) {
210+
return str.replace(/[\u200e]/g, '')
211+
}
212+
213+
cases(
214+
'{ normalizer } option allows custom pre-match normalization',
215+
({dom, queryFn}) => {
216+
const queries = render(dom)
217+
218+
const query = queries[queryFn]
219+
220+
// With the correct normalizer, we should match
221+
expect(query(/user n.me/i, {normalizer: removeUCC})).toHaveLength(1)
222+
expect(query('User name', {normalizer: removeUCC})).toHaveLength(1)
223+
224+
// Without the normalizer, we shouldn't
225+
expect(query(/user n.me/i)).toHaveLength(0)
226+
expect(query('User name')).toHaveLength(0)
227+
},
228+
{
229+
queryAllByLabelText: {
230+
dom: `
231+
<label for="username">User ${LRM}name</label>
232+
<input id="username" />`,
233+
queryFn: 'queryAllByLabelText',
234+
},
235+
queryAllByPlaceholderText: {
236+
dom: `<input placeholder="User ${LRM}name" />`,
237+
queryFn: 'queryAllByPlaceholderText',
238+
},
239+
queryAllBySelectText: {
240+
dom: `<select><option>User ${LRM}name</option></select>`,
241+
queryFn: 'queryAllBySelectText',
242+
},
243+
queryAllByText: {
244+
dom: `<div>User ${LRM}name</div>`,
245+
queryFn: 'queryAllByText',
246+
},
247+
queryAllByAltText: {
248+
dom: `<img alt="User ${LRM}name" src="username.jpg" />`,
249+
queryFn: 'queryAllByAltText',
250+
},
251+
queryAllByTitle: {
252+
dom: `<div title="User ${LRM}name" />`,
253+
queryFn: 'queryAllByTitle',
254+
},
255+
queryAllByValue: {
256+
dom: `<input value="User ${LRM}name" />`,
257+
queryFn: 'queryAllByValue',
258+
},
259+
queryAllByDisplayValue: {
260+
dom: `<input value="User ${LRM}name" />`,
261+
queryFn: 'queryAllByDisplayValue',
262+
},
263+
queryAllByRole: {
264+
dom: `<input role="User ${LRM}name" />`,
265+
queryFn: 'queryAllByRole',
266+
},
267+
},
268+
)
269+
270+
test('normalizer works with both exact and non-exact matching', () => {
271+
const {queryAllByText} = render(`<div>MiXeD ${LRM}CaSe</div>`)
272+
273+
expect(
274+
queryAllByText('mixed case', {exact: false, normalizer: removeUCC}),
275+
).toHaveLength(1)
276+
expect(
277+
queryAllByText('mixed case', {exact: true, normalizer: removeUCC}),
278+
).toHaveLength(0)
279+
expect(
280+
queryAllByText('MiXeD CaSe', {exact: true, normalizer: removeUCC}),
281+
).toHaveLength(1)
282+
expect(queryAllByText('MiXeD CaSe', {exact: true})).toHaveLength(0)
283+
})
284+
285+
test('top-level trim and collapseWhitespace options are not supported if normalizer is specified', () => {
286+
const {queryAllByText} = render('<div> abc def </div>')
287+
const normalizer = str => str
288+
289+
expect(() => queryAllByText('abc', {trim: false, normalizer})).toThrow()
290+
expect(() => queryAllByText('abc', {trim: true, normalizer})).toThrow()
291+
expect(() =>
292+
queryAllByText('abc', {collapseWhitespace: false, normalizer}),
293+
).toThrow()
294+
expect(() =>
295+
queryAllByText('abc', {collapseWhitespace: true, normalizer}),
296+
).toThrow()
297+
})
298+
299+
test('getDefaultNormalizer returns a normalizer that supports trim and collapseWhitespace', () => {
300+
// Default is trim: true and collapseWhitespace: true
301+
expect(getDefaultNormalizer()(' abc def ')).toEqual('abc def')
302+
303+
// Turning off trimming should not turn off whitespace collapsing
304+
expect(getDefaultNormalizer({trim: false})(' abc def ')).toEqual(
305+
' abc def ',
306+
)
307+
308+
// Turning off whitespace collapsing should not turn off trimming
309+
expect(
310+
getDefaultNormalizer({collapseWhitespace: false})(' abc def '),
311+
).toEqual('abc def')
312+
313+
// Whilst it's rather pointless, we should be able to turn both off
314+
expect(
315+
getDefaultNormalizer({trim: false, collapseWhitespace: false})(
316+
' abc def ',
317+
),
318+
).toEqual(' abc def ')
319+
})
320+
321+
test('we support an older API with trim and collapseWhitespace instead of a normalizer', () => {
322+
const {queryAllByText} = render('<div> x y </div>')
323+
expect(queryAllByText('x y')).toHaveLength(1)
324+
expect(queryAllByText('x y', {trim: false})).toHaveLength(0)
325+
expect(queryAllByText(' x y ', {trim: false})).toHaveLength(1)
326+
expect(queryAllByText('x y', {collapseWhitespace: false})).toHaveLength(0)
327+
expect(queryAllByText('x y', {collapseWhitespace: false})).toHaveLength(1)
328+
})

Diff for: src/index.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ export * from './queries'
66
export * from './wait'
77
export * from './wait-for-element'
88
export * from './wait-for-dom-change'
9-
export * from './matches'
9+
export {getDefaultNormalizer} from './matches'
1010
export * from './get-node-text'
1111
export * from './events'
1212
export * from './get-queries-for-element'

0 commit comments

Comments
 (0)