diff --git a/README.md b/README.md index 90e6171c..5cea8e5a 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,7 @@ when a real user uses it. - [Using other assertion libraries](#using-other-assertion-libraries) - [`TextMatch`](#textmatch) - [Precision](#precision) + - [Normalization](#normalization) - [TextMatch Examples](#textmatch-examples) - [`query` APIs](#query-apis) - [`queryAll` and `getAll` APIs](#queryall-and-getall-apis) @@ -207,8 +208,7 @@ getByLabelText( options?: { selector?: string = '*', exact?: boolean = true, - collapseWhitespace?: boolean = true, - trim?: boolean = true, + normalizer?: NormalizerFn, }): HTMLElement ``` @@ -259,8 +259,7 @@ getByPlaceholderText( text: TextMatch, options?: { exact?: boolean = true, - collapseWhitespace?: boolean = false, - trim?: boolean = true, + normalizer?: NormalizerFn, }): HTMLElement ``` @@ -284,9 +283,8 @@ getByText( options?: { selector?: string = '*', exact?: boolean = true, - collapseWhitespace?: boolean = true, - trim?: boolean = true, - ignore?: string|boolean = 'script, style' + ignore?: string|boolean = 'script, style', + normalizer?: NormalizerFn, }): HTMLElement ``` @@ -316,8 +314,7 @@ getByAltText( text: TextMatch, options?: { exact?: boolean = true, - collapseWhitespace?: boolean = false, - trim?: boolean = true, + normalizer?: NormalizerFn, }): HTMLElement ``` @@ -341,8 +338,7 @@ getByTitle( title: TextMatch, options?: { exact?: boolean = true, - collapseWhitespace?: boolean = false, - trim?: boolean = true, + normalizer?: NormalizerFn, }): HTMLElement ``` @@ -368,8 +364,7 @@ getByDisplayValue( value: TextMatch, options?: { exact?: boolean = true, - collapseWhitespace?: boolean = false, - trim?: boolean = true, + normalizer?: NormalizerFn, }): HTMLElement ``` @@ -416,8 +411,7 @@ getByRole( text: TextMatch, options?: { exact?: boolean = true, - collapseWhitespace?: boolean = false, - trim?: boolean = true, + normalizer?: NormalizerFn, }): HTMLElement ``` @@ -437,9 +431,8 @@ getByTestId( text: TextMatch, options?: { exact?: boolean = true, - collapseWhitespace?: boolean = false, - trim?: boolean = true, - }): HTMLElement` + normalizer?: NormalizerFn, + }): HTMLElement ``` A shortcut to `` container.querySelector(`[data-testid="${yourId}"]`) `` (and it @@ -801,9 +794,47 @@ affect the precision of string matching: - `exact` has no effect on `regex` or `function` arguments. - In most cases using a regex instead of a string gives you more control over fuzzy matching and should be preferred over `{ exact: false }`. -- `trim`: Defaults to `true`; trim leading and trailing whitespace. +- `normalizer`: An optional function which overrides normalization behavior. + See [`Normalization`](#normalization). + +### Normalization + +Before running any matching logic against text in the DOM, `dom-testing-library` +automatically normalizes that text. By default, normalization consists of +trimming whitespace from the start and end of text, and collapsing multiple +adjacent whitespace characters into a single space. + +If you want to prevent that normalization, or provide alternative +normalization (e.g. to remove Unicode control characters), you can provide a +`normalizer` function in the options object. This function will be given +a string and is expected to return a normalized version of that string. + +Note: Specifying a value for `normalizer` _replaces_ the built-in normalization, but +you can call `getDefaultNormalizer` to obtain a built-in normalizer, either +to adjust that normalization or to call it from your own normalizer. + +`getDefaultNormalizer` takes an options object which allows the selection of behaviour: + +- `trim`: Defaults to `true`. Trims leading and trailing whitespace - `collapseWhitespace`: Defaults to `true`. Collapses inner whitespace (newlines, tabs, repeated spaces) into a single space. +#### Normalization Examples + +To perform a match against text without trimming: + +```javascript +getByText(node, 'text', {normalizer: getDefaultNormalizer({trim: false})}) +``` + +To override normalization to remove some Unicode characters whilst keeping some (but not all) of the built-in normalization behavior: + +```javascript +getByText(node, 'text', { + normalizer: str => + getDefaultNormalizer({trim: false})(str).replace(/[\u200E-\u200F]*/g, ''), +}) +``` + ### TextMatch Examples ```javascript diff --git a/src/__tests__/matches.js b/src/__tests__/matches.js index 8296a54f..5e4d7243 100644 --- a/src/__tests__/matches.js +++ b/src/__tests__/matches.js @@ -1,25 +1,28 @@ -import {fuzzyMatches, matches} from '../' +import {fuzzyMatches, matches} from '../matches' // unit tests for text match utils const node = null +const normalizer = str => str test('matchers accept strings', () => { - expect(matches('ABC', node, 'ABC')).toBe(true) - expect(fuzzyMatches('ABC', node, 'ABC')).toBe(true) + expect(matches('ABC', node, 'ABC', normalizer)).toBe(true) + expect(fuzzyMatches('ABC', node, 'ABC', normalizer)).toBe(true) }) test('matchers accept regex', () => { - expect(matches('ABC', node, /ABC/)).toBe(true) - expect(fuzzyMatches('ABC', node, /ABC/)).toBe(true) + expect(matches('ABC', node, /ABC/, normalizer)).toBe(true) + expect(fuzzyMatches('ABC', node, /ABC/, normalizer)).toBe(true) }) test('matchers accept functions', () => { - expect(matches('ABC', node, text => text === 'ABC')).toBe(true) - expect(fuzzyMatches('ABC', node, text => text === 'ABC')).toBe(true) + expect(matches('ABC', node, text => text === 'ABC', normalizer)).toBe(true) + expect(fuzzyMatches('ABC', node, text => text === 'ABC', normalizer)).toBe( + true, + ) }) test('matchers return false if text to match is not a string', () => { - expect(matches(null, node, 'ABC')).toBe(false) - expect(fuzzyMatches(null, node, 'ABC')).toBe(false) + expect(matches(null, node, 'ABC', normalizer)).toBe(false) + expect(fuzzyMatches(null, node, 'ABC', normalizer)).toBe(false) }) diff --git a/src/__tests__/text-matchers.js b/src/__tests__/text-matchers.js index 5cf7b359..7cbd6924 100644 --- a/src/__tests__/text-matchers.js +++ b/src/__tests__/text-matchers.js @@ -1,5 +1,7 @@ import 'jest-dom/extend-expect' import cases from 'jest-in-case' + +import {getDefaultNormalizer} from '../' import {render} from './helpers/test-utils' cases( @@ -68,7 +70,12 @@ cases( const queries = render(dom) expect(queries[queryFn](query)).toHaveLength(1) expect( - queries[queryFn](query, {collapseWhitespace: false, trim: false}), + queries[queryFn](query, { + normalizer: getDefaultNormalizer({ + collapseWhitespace: false, + trim: false, + }), + }), ).toHaveLength(0) }, { @@ -194,3 +201,128 @@ cases( }, }, ) + +// A good use case for a custom normalizer is stripping +// out Unicode control characters such as LRM (left-right-mark) +// before matching +const LRM = '\u200e' +function removeUCC(str) { + return str.replace(/[\u200e]/g, '') +} + +cases( + '{ normalizer } option allows custom pre-match normalization', + ({dom, queryFn}) => { + const queries = render(dom) + + const query = queries[queryFn] + + // With the correct normalizer, we should match + expect(query(/user n.me/i, {normalizer: removeUCC})).toHaveLength(1) + expect(query('User name', {normalizer: removeUCC})).toHaveLength(1) + + // Without the normalizer, we shouldn't + expect(query(/user n.me/i)).toHaveLength(0) + expect(query('User name')).toHaveLength(0) + }, + { + queryAllByLabelText: { + dom: ` + <label for="username">User ${LRM}name</label> + <input id="username" />`, + queryFn: 'queryAllByLabelText', + }, + queryAllByPlaceholderText: { + dom: `<input placeholder="User ${LRM}name" />`, + queryFn: 'queryAllByPlaceholderText', + }, + queryAllBySelectText: { + dom: `<select><option>User ${LRM}name</option></select>`, + queryFn: 'queryAllBySelectText', + }, + queryAllByText: { + dom: `<div>User ${LRM}name</div>`, + queryFn: 'queryAllByText', + }, + queryAllByAltText: { + dom: `<img alt="User ${LRM}name" src="username.jpg" />`, + queryFn: 'queryAllByAltText', + }, + queryAllByTitle: { + dom: `<div title="User ${LRM}name" />`, + queryFn: 'queryAllByTitle', + }, + queryAllByValue: { + dom: `<input value="User ${LRM}name" />`, + queryFn: 'queryAllByValue', + }, + queryAllByDisplayValue: { + dom: `<input value="User ${LRM}name" />`, + queryFn: 'queryAllByDisplayValue', + }, + queryAllByRole: { + dom: `<input role="User ${LRM}name" />`, + queryFn: 'queryAllByRole', + }, + }, +) + +test('normalizer works with both exact and non-exact matching', () => { + const {queryAllByText} = render(`<div>MiXeD ${LRM}CaSe</div>`) + + expect( + queryAllByText('mixed case', {exact: false, normalizer: removeUCC}), + ).toHaveLength(1) + expect( + queryAllByText('mixed case', {exact: true, normalizer: removeUCC}), + ).toHaveLength(0) + expect( + queryAllByText('MiXeD CaSe', {exact: true, normalizer: removeUCC}), + ).toHaveLength(1) + expect(queryAllByText('MiXeD CaSe', {exact: true})).toHaveLength(0) +}) + +test('top-level trim and collapseWhitespace options are not supported if normalizer is specified', () => { + const {queryAllByText} = render('<div> abc def </div>') + const normalizer = str => str + + expect(() => queryAllByText('abc', {trim: false, normalizer})).toThrow() + expect(() => queryAllByText('abc', {trim: true, normalizer})).toThrow() + expect(() => + queryAllByText('abc', {collapseWhitespace: false, normalizer}), + ).toThrow() + expect(() => + queryAllByText('abc', {collapseWhitespace: true, normalizer}), + ).toThrow() +}) + +test('getDefaultNormalizer returns a normalizer that supports trim and collapseWhitespace', () => { + // Default is trim: true and collapseWhitespace: true + expect(getDefaultNormalizer()(' abc def ')).toEqual('abc def') + + // Turning off trimming should not turn off whitespace collapsing + expect(getDefaultNormalizer({trim: false})(' abc def ')).toEqual( + ' abc def ', + ) + + // Turning off whitespace collapsing should not turn off trimming + expect( + getDefaultNormalizer({collapseWhitespace: false})(' abc def '), + ).toEqual('abc def') + + // Whilst it's rather pointless, we should be able to turn both off + expect( + getDefaultNormalizer({trim: false, collapseWhitespace: false})( + ' abc def ', + ), + ).toEqual(' abc def ') +}) + +test('we support an older API with trim and collapseWhitespace instead of a normalizer', () => { + const {queryAllByText} = render('<div> x y </div>') + expect(queryAllByText('x y')).toHaveLength(1) + expect(queryAllByText('x y', {trim: false})).toHaveLength(0) + expect(queryAllByText(' x y ', {trim: false})).toHaveLength(1) + expect(queryAllByText('x y', {collapseWhitespace: false})).toHaveLength(0) + expect(queryAllByText('x y', {collapseWhitespace: false})).toHaveLength(1) +}) diff --git a/src/index.js b/src/index.js index 5b9dbefa..c69df8a5 100644 --- a/src/index.js +++ b/src/index.js @@ -6,7 +6,7 @@ export * from './queries' export * from './wait' export * from './wait-for-element' export * from './wait-for-dom-change' -export * from './matches' +export {getDefaultNormalizer} from './matches' export * from './get-node-text' export * from './events' export * from './get-queries-for-element' diff --git a/src/matches.js b/src/matches.js index 1a79cb4f..3241e680 100644 --- a/src/matches.js +++ b/src/matches.js @@ -1,13 +1,9 @@ -function fuzzyMatches( - textToMatch, - node, - matcher, - {collapseWhitespace = true, trim = true} = {}, -) { +function fuzzyMatches(textToMatch, node, matcher, normalizer) { if (typeof textToMatch !== 'string') { return false } - const normalizedText = normalize(textToMatch, {trim, collapseWhitespace}) + + const normalizedText = normalizer(textToMatch) if (typeof matcher === 'string') { return normalizedText.toLowerCase().includes(matcher.toLowerCase()) } else if (typeof matcher === 'function') { @@ -17,16 +13,12 @@ function fuzzyMatches( } } -function matches( - textToMatch, - node, - matcher, - {collapseWhitespace = true, trim = true} = {}, -) { +function matches(textToMatch, node, matcher, normalizer) { if (typeof textToMatch !== 'string') { return false } - const normalizedText = normalize(textToMatch, {trim, collapseWhitespace}) + + const normalizedText = normalizer(textToMatch) if (typeof matcher === 'string') { return normalizedText === matcher } else if (typeof matcher === 'function') { @@ -36,13 +28,46 @@ function matches( } } -function normalize(text, {trim, collapseWhitespace}) { - let normalizedText = text - normalizedText = trim ? normalizedText.trim() : normalizedText - normalizedText = collapseWhitespace - ? normalizedText.replace(/\s+/g, ' ') - : normalizedText - return normalizedText +function getDefaultNormalizer({trim = true, collapseWhitespace = true} = {}) { + return text => { + let normalizedText = text + normalizedText = trim ? normalizedText.trim() : normalizedText + normalizedText = collapseWhitespace + ? normalizedText.replace(/\s+/g, ' ') + : normalizedText + return normalizedText + } +} + +/** + * Constructs a normalizer to pass to functions in matches.js + * @param {boolean|undefined} trim The user-specified value for `trim`, without + * any defaulting having been applied + * @param {boolean|undefined} collapseWhitespace The user-specified value for + * `collapseWhitespace`, without any defaulting having been applied + * @param {Function|undefined} normalizer The user-specified normalizer + * @returns {Function} A normalizer + */ +function makeNormalizer({trim, collapseWhitespace, normalizer}) { + if (normalizer) { + // User has specified a custom normalizer + if ( + typeof trim !== 'undefined' || + typeof collapseWhitespace !== 'undefined' + ) { + // They've also specified a value for trim or collapseWhitespace + throw new Error( + 'trim and collapseWhitespace are not supported with a normalizer. ' + + 'If you want to use the default trim and collapseWhitespace logic in your normalizer, ' + + 'use "getDefaultNormalizer({trim, collapseWhitespace})" and compose that into your normalizer', + ) + } + + return normalizer + } else { + // No custom normalizer specified. Just use default. + return getDefaultNormalizer({trim, collapseWhitespace}) + } } -export {fuzzyMatches, matches} +export {fuzzyMatches, matches, getDefaultNormalizer, makeNormalizer} diff --git a/src/queries.js b/src/queries.js index d9bf7c7d..e5393222 100644 --- a/src/queries.js +++ b/src/queries.js @@ -1,4 +1,4 @@ -import {fuzzyMatches, matches} from './matches' +import {fuzzyMatches, matches, makeNormalizer} from './matches' import {getNodeText} from './get-node-text' import { getElementError, @@ -15,22 +15,25 @@ import {getConfig} from './config' function queryAllLabelsByText( container, text, - {exact = true, trim = true, collapseWhitespace = true} = {}, + {exact = true, trim, collapseWhitespace, normalizer} = {}, ) { const matcher = exact ? matches : fuzzyMatches - const matchOpts = {collapseWhitespace, trim} + const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer}) return Array.from(container.querySelectorAll('label')).filter(label => - matcher(label.textContent, label, text, matchOpts), + matcher(label.textContent, label, text, matchNormalizer), ) } function queryAllByLabelText( container, text, - {selector = '*', exact = true, collapseWhitespace = true, trim = true} = {}, + {selector = '*', exact = true, collapseWhitespace, trim, normalizer} = {}, ) { - const matchOpts = {collapseWhitespace, trim} - const labels = queryAllLabelsByText(container, text, {exact, ...matchOpts}) + const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer}) + const labels = queryAllLabelsByText(container, text, { + exact, + normalizer: matchNormalizer, + }) const labelledElements = labels .map(label => { if (label.control) { @@ -62,7 +65,7 @@ function queryAllByLabelText( const possibleAriaLabelElements = queryAllByText(container, text, { exact, - ...matchOpts, + normalizer: matchNormalizer, }).filter(el => el.tagName !== 'LABEL') // don't reprocess labels const ariaLabelledElements = possibleAriaLabelElements.reduce( @@ -94,16 +97,17 @@ function queryAllByText( { selector = '*', exact = true, - collapseWhitespace = true, - trim = true, + collapseWhitespace, + trim, ignore = 'script, style', + normalizer, } = {}, ) { const matcher = exact ? matches : fuzzyMatches - const matchOpts = {collapseWhitespace, trim} + const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer}) return Array.from(container.querySelectorAll(selector)) .filter(node => !ignore || !node.matches(ignore)) - .filter(node => matcher(getNodeText(node), node, text, matchOpts)) + .filter(node => matcher(getNodeText(node), node, text, matchNormalizer)) } function queryByText(...args) { @@ -113,14 +117,14 @@ function queryByText(...args) { function queryAllByTitle( container, text, - {exact = true, collapseWhitespace = true, trim = true} = {}, + {exact = true, collapseWhitespace, trim, normalizer} = {}, ) { const matcher = exact ? matches : fuzzyMatches - const matchOpts = {collapseWhitespace, trim} + const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer}) return Array.from(container.querySelectorAll('[title], svg > title')).filter( node => - matcher(node.getAttribute('title'), node, text, matchOpts) || - matcher(getNodeText(node), node, text, matchOpts), + matcher(node.getAttribute('title'), node, text, matchNormalizer) || + matcher(getNodeText(node), node, text, matchNormalizer), ) } @@ -131,16 +135,16 @@ function queryByTitle(...args) { function queryAllBySelectText( container, text, - {exact = true, collapseWhitespace = true, trim = true} = {}, + {exact = true, collapseWhitespace, trim, normalizer} = {}, ) { const matcher = exact ? matches : fuzzyMatches - const matchOpts = {collapseWhitespace, trim} + const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer}) return Array.from(container.querySelectorAll('select')).filter(selectNode => { const selectedOptions = Array.from(selectNode.options).filter( option => option.selected, ) return selectedOptions.some(optionNode => - matcher(getNodeText(optionNode), optionNode, text, matchOpts), + matcher(getNodeText(optionNode), optionNode, text, matchNormalizer), ) }) } @@ -167,12 +171,12 @@ const queryAllByRole = queryAllByAttribute.bind(null, 'role') function queryAllByAltText( container, alt, - {exact = true, collapseWhitespace = true, trim = true} = {}, + {exact = true, collapseWhitespace, trim, normalizer} = {}, ) { const matcher = exact ? matches : fuzzyMatches - const matchOpts = {collapseWhitespace, trim} + const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer}) return Array.from(container.querySelectorAll('img,input,area')).filter(node => - matcher(node.getAttribute('alt'), node, alt, matchOpts), + matcher(node.getAttribute('alt'), node, alt, matchNormalizer), ) } @@ -183,10 +187,10 @@ function queryByAltText(...args) { function queryAllByDisplayValue( container, value, - {exact = true, collapseWhitespace = true, trim = true} = {}, + {exact = true, collapseWhitespace, trim, normalizer} = {}, ) { const matcher = exact ? matches : fuzzyMatches - const matchOpts = {collapseWhitespace, trim} + const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer}) return Array.from(container.querySelectorAll(`input,textarea,select`)).filter( node => { if (node.tagName === 'SELECT') { @@ -194,10 +198,10 @@ function queryAllByDisplayValue( option => option.selected, ) return selectedOptions.some(optionNode => - matcher(getNodeText(optionNode), optionNode, value, matchOpts), + matcher(getNodeText(optionNode), optionNode, value, matchNormalizer), ) } else { - return matcher(node.value, node, value, matchOpts) + return matcher(node.value, node, value, matchNormalizer) } }, ) diff --git a/src/query-helpers.js b/src/query-helpers.js index 1f701f38..8f849f47 100644 --- a/src/query-helpers.js +++ b/src/query-helpers.js @@ -1,5 +1,5 @@ import {prettyDOM} from './pretty-dom' -import {fuzzyMatches, matches} from './matches' +import {fuzzyMatches, matches, makeNormalizer} from './matches' /* eslint-disable complexity */ function debugDOM(htmlElement) { @@ -39,12 +39,12 @@ function queryAllByAttribute( attribute, container, text, - {exact = true, collapseWhitespace = true, trim = true} = {}, + {exact = true, collapseWhitespace, trim, normalizer} = {}, ) { const matcher = exact ? matches : fuzzyMatches - const matchOpts = {collapseWhitespace, trim} + const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer}) return Array.from(container.querySelectorAll(`[${attribute}]`)).filter(node => - matcher(node.getAttribute(attribute), node, text, matchOpts), + matcher(node.getAttribute(attribute), node, text, matchNormalizer), ) } diff --git a/typings/matches.d.ts b/typings/matches.d.ts index af89af82..39e7b643 100644 --- a/typings/matches.d.ts +++ b/typings/matches.d.ts @@ -1,9 +1,15 @@ export type MatcherFunction = (content: string, element: HTMLElement) => boolean export type Matcher = string | RegExp | MatcherFunction + +export type NormalizerFn = (text: string) => string + export interface MatcherOptions { exact?: boolean + /** Use normalizer with getDefaultNormalizer instead */ trim?: boolean + /** Use normalizer with getDefaultNormalizer instead */ collapseWhitespace?: boolean + normalizer?: NormalizerFn } export type Match = ( @@ -13,5 +19,13 @@ export type Match = ( options?: MatcherOptions, ) => boolean -export const fuzzyMatches: Match -export const matches: Match +export interface DefaultNormalizerOptions { + trim?: boolean + collapseWhitespace?: boolean +} + +export declare function getDefaultNormalizer( + options?: DefaultNormalizerOptions, +): NormalizerFn + +// N.B. Don't expose fuzzyMatches + matches here: they're not public API diff --git a/typings/queries.d.ts b/typings/queries.d.ts index 027169d4..42b45da7 100644 --- a/typings/queries.d.ts +++ b/typings/queries.d.ts @@ -1,7 +1,5 @@ import {Matcher, MatcherOptions} from './matches' -import { - SelectorMatcherOptions, -} from './query-helpers' +import {SelectorMatcherOptions} from './query-helpers' export type QueryByBoundAttribute = ( container: HTMLElement,