From abd75dcef475bdf7d775a3443c503f1e02e87265 Mon Sep 17 00:00:00 2001 From: Royston Shufflebotham Date: Sat, 8 Dec 2018 16:59:43 +0000 Subject: [PATCH 1/4] 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. --- README.md | 22 ++++++- src/__tests__/text-matchers.js | 112 +++++++++++++++++++++++++++++++++ src/matches.js | 19 ++++-- src/queries.js | 29 +++++---- src/query-helpers.js | 4 +- typings/matches.d.ts | 4 ++ typings/queries.d.ts | 4 +- 7 files changed, 170 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index fd22e28a..4a976df9 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,7 @@ getByLabelText( exact?: boolean = true, collapseWhitespace?: boolean = true, trim?: boolean = true, + normalizer?: NormalizerFn, }): HTMLElement ``` @@ -261,6 +262,7 @@ getByPlaceholderText( exact?: boolean = true, collapseWhitespace?: boolean = false, trim?: boolean = true, + normalizer?: NormalizerFn, }): HTMLElement ``` @@ -285,6 +287,7 @@ getBySelectText( exact?: boolean = true, collapseWhitespace?: boolean = true, trim?: boolean = true, + normalizer?: NormalizerFn, }): HTMLElement ``` @@ -315,7 +318,8 @@ getByText( exact?: boolean = true, collapseWhitespace?: boolean = true, trim?: boolean = true, - ignore?: string|boolean = 'script, style' + ignore?: string|boolean = 'script, style', + normalizer?: NormalizerFn, }): HTMLElement ``` @@ -347,6 +351,7 @@ getByAltText( exact?: boolean = true, collapseWhitespace?: boolean = false, trim?: boolean = true, + normalizer?: NormalizerFn, }): HTMLElement ``` @@ -372,6 +377,7 @@ getByTitle( exact?: boolean = true, collapseWhitespace?: boolean = false, trim?: boolean = true, + normalizer?: NormalizerFn, }): HTMLElement ``` @@ -399,6 +405,7 @@ getByValue( exact?: boolean = true, collapseWhitespace?: boolean = false, trim?: boolean = true, + normalizer?: NormalizerFn, }): HTMLElement ``` @@ -419,6 +426,7 @@ getByRole( exact?: boolean = true, collapseWhitespace?: boolean = false, trim?: boolean = true, + normalizer?: NormalizerFn, }): HTMLElement ``` @@ -440,7 +448,8 @@ getByTestId( exact?: boolean = true, collapseWhitespace?: boolean = false, trim?: boolean = true, - }): HTMLElement` + normalizer?: NormalizerFn, + }): HTMLElement ``` A shortcut to `` container.querySelector(`[data-testid="${yourId}"]`) `` (and it @@ -802,8 +811,15 @@ 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. +- `trim`: Defaults to `true`. Trims leading and trailing whitespace. - `collapseWhitespace`: Defaults to `true`. Collapses inner whitespace (newlines, tabs, repeated spaces) into a single space. +- `normalizer`: Defaults to `undefined`. Specifies a custom function which will be called to normalize the text (after applying any `trim` or + `collapseWhitespace` behaviour). An example use of this might be to remove Unicode control characters before applying matching behavior, e.g. + ```javascript + { + normalizer: str => str.replace(/[\u200E-\u200F]*/g, '') + } + ``` ### TextMatch Examples diff --git a/src/__tests__/text-matchers.js b/src/__tests__/text-matchers.js index 5cf7b359..7b6f8ce0 100644 --- a/src/__tests__/text-matchers.js +++ b/src/__tests__/text-matchers.js @@ -194,3 +194,115 @@ cases( }, }, ) + +// A good use case for a custom normalizer is stripping +// out UCC codes such as LRM 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: ` + + `, + queryFn: 'queryAllByLabelText', + }, + queryAllByPlaceholderText: { + dom: ``, + queryFn: 'queryAllByPlaceholderText', + }, + queryAllBySelectText: { + dom: ``, + queryFn: 'queryAllBySelectText', + }, + queryAllByText: { + dom: `
User ${LRM}name
`, + queryFn: 'queryAllByText', + }, + queryAllByAltText: { + dom: `User ${LRM}name`, + queryFn: 'queryAllByAltText', + }, + queryAllByTitle: { + dom: `
`, + queryFn: 'queryAllByTitle', + }, + queryAllByValue: { + dom: ``, + queryFn: 'queryAllByValue', + }, + queryAllByRole: { + dom: ``, + queryFn: 'queryAllByRole', + }, + }, +) + +test('normalizer works with both exact and non-exact matching', () => { + const {queryAllByText} = render(`
MiXeD ${LRM}CaSe
`) + + 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('normalizer runs after trim and collapseWhitespace options', () => { + // Our test text has leading and trailing spaces, and multiple + // spaces in the middle; we'll make use of that fact to test + // the execution order of trim/collapseWhitespace and the custom + // normalizer + const {queryAllByText} = render('
abc def
') + + // Double-check the normal trim + collapseWhitespace behavior + expect( + queryAllByText('abc def', {trim: true, collapseWhitespace: true}), + ).toHaveLength(1) + + // Test that again, but with a normalizer that replaces double + // spaces with 'X' characters. If that runs before trim/collapseWhitespace, + // it'll prevent successful matching + expect( + queryAllByText('abc def', { + trim: true, + collapseWhitespace: true, + normalizer: str => str.replace(/ {2}/g, 'X'), + }), + ).toHaveLength(1) + + // Test that, if we turn off trim + collapse, that the normalizer does + // then get to see the double whitespace, and we should now be able + // to match the Xs + expect( + queryAllByText('XabcXdefX', { + trim: false, + collapseWhitespace: false, + // With the whitespace left in, this will add Xs which will + // prevent matching + normalizer: str => str.replace(/ {2}/g, 'X'), + }), + ).toHaveLength(1) +}) diff --git a/src/matches.js b/src/matches.js index 1a79cb4f..2b0a9ad4 100644 --- a/src/matches.js +++ b/src/matches.js @@ -2,12 +2,16 @@ function fuzzyMatches( textToMatch, node, matcher, - {collapseWhitespace = true, trim = true} = {}, + {collapseWhitespace = true, trim = true, normalizer} = {}, ) { if (typeof textToMatch !== 'string') { return false } - const normalizedText = normalize(textToMatch, {trim, collapseWhitespace}) + const normalizedText = normalize(textToMatch, { + trim, + collapseWhitespace, + normalizer, + }) if (typeof matcher === 'string') { return normalizedText.toLowerCase().includes(matcher.toLowerCase()) } else if (typeof matcher === 'function') { @@ -21,12 +25,16 @@ function matches( textToMatch, node, matcher, - {collapseWhitespace = true, trim = true} = {}, + {collapseWhitespace = true, trim = true, normalizer} = {}, ) { if (typeof textToMatch !== 'string') { return false } - const normalizedText = normalize(textToMatch, {trim, collapseWhitespace}) + const normalizedText = normalize(textToMatch, { + trim, + collapseWhitespace, + normalizer, + }) if (typeof matcher === 'string') { return normalizedText === matcher } else if (typeof matcher === 'function') { @@ -36,12 +44,13 @@ function matches( } } -function normalize(text, {trim, collapseWhitespace}) { +function normalize(text, {trim, collapseWhitespace, normalizer}) { let normalizedText = text normalizedText = trim ? normalizedText.trim() : normalizedText normalizedText = collapseWhitespace ? normalizedText.replace(/\s+/g, ' ') : normalizedText + normalizedText = normalizer ? normalizer(normalizedText) : normalizedText return normalizedText } diff --git a/src/queries.js b/src/queries.js index c0d738a3..b359c547 100644 --- a/src/queries.js +++ b/src/queries.js @@ -15,10 +15,10 @@ import {getConfig} from './config' function queryAllLabelsByText( container, text, - {exact = true, trim = true, collapseWhitespace = true} = {}, + {exact = true, trim = true, collapseWhitespace = true, normalizer} = {}, ) { const matcher = exact ? matches : fuzzyMatches - const matchOpts = {collapseWhitespace, trim} + const matchOpts = {collapseWhitespace, trim, normalizer} return Array.from(container.querySelectorAll('label')).filter(label => matcher(label.textContent, label, text, matchOpts), ) @@ -27,9 +27,15 @@ function queryAllLabelsByText( function queryAllByLabelText( container, text, - {selector = '*', exact = true, collapseWhitespace = true, trim = true} = {}, + { + selector = '*', + exact = true, + collapseWhitespace = true, + trim = true, + normalizer, + } = {}, ) { - const matchOpts = {collapseWhitespace, trim} + const matchOpts = {collapseWhitespace, trim, normalizer} const labels = queryAllLabelsByText(container, text, {exact, ...matchOpts}) const labelledElements = labels .map(label => { @@ -97,10 +103,11 @@ function queryAllByText( collapseWhitespace = true, trim = true, ignore = 'script, style', + normalizer, } = {}, ) { const matcher = exact ? matches : fuzzyMatches - const matchOpts = {collapseWhitespace, trim} + const matchOpts = {collapseWhitespace, trim, normalizer} return Array.from(container.querySelectorAll(selector)) .filter(node => !ignore || !node.matches(ignore)) .filter(node => matcher(getNodeText(node), node, text, matchOpts)) @@ -113,10 +120,10 @@ function queryByText(...args) { function queryAllByTitle( container, text, - {exact = true, collapseWhitespace = true, trim = true} = {}, + {exact = true, collapseWhitespace = true, trim = true, normalizer} = {}, ) { const matcher = exact ? matches : fuzzyMatches - const matchOpts = {collapseWhitespace, trim} + const matchOpts = {collapseWhitespace, trim, normalizer} return Array.from(container.querySelectorAll('[title], svg > title')).filter( node => matcher(node.getAttribute('title'), node, text, matchOpts) || @@ -131,10 +138,10 @@ function queryByTitle(...args) { function queryAllBySelectText( container, text, - {exact = true, collapseWhitespace = true, trim = true} = {}, + {exact = true, collapseWhitespace = true, trim = true, normalizer} = {}, ) { const matcher = exact ? matches : fuzzyMatches - const matchOpts = {collapseWhitespace, trim} + const matchOpts = {collapseWhitespace, trim, normalizer} return Array.from(container.querySelectorAll('select')).filter(selectNode => { const selectedOptions = Array.from(selectNode.options).filter( option => option.selected, @@ -167,10 +174,10 @@ const queryAllByRole = queryAllByAttribute.bind(null, 'role') function queryAllByAltText( container, alt, - {exact = true, collapseWhitespace = true, trim = true} = {}, + {exact = true, collapseWhitespace = true, trim = true, normalizer} = {}, ) { const matcher = exact ? matches : fuzzyMatches - const matchOpts = {collapseWhitespace, trim} + const matchOpts = {collapseWhitespace, trim, normalizer} return Array.from(container.querySelectorAll('img,input,area')).filter(node => matcher(node.getAttribute('alt'), node, alt, matchOpts), ) diff --git a/src/query-helpers.js b/src/query-helpers.js index 1f701f38..0fcd6799 100644 --- a/src/query-helpers.js +++ b/src/query-helpers.js @@ -39,10 +39,10 @@ function queryAllByAttribute( attribute, container, text, - {exact = true, collapseWhitespace = true, trim = true} = {}, + {exact = true, collapseWhitespace = true, trim = true, normalizer} = {}, ) { const matcher = exact ? matches : fuzzyMatches - const matchOpts = {collapseWhitespace, trim} + const matchOpts = {collapseWhitespace, trim, normalizer} return Array.from(container.querySelectorAll(`[${attribute}]`)).filter(node => matcher(node.getAttribute(attribute), node, text, matchOpts), ) diff --git a/typings/matches.d.ts b/typings/matches.d.ts index af89af82..16898845 100644 --- a/typings/matches.d.ts +++ b/typings/matches.d.ts @@ -1,9 +1,13 @@ 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 trim?: boolean collapseWhitespace?: boolean + normalizer?: NormalizerFn } export type Match = ( 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, From ab3a203d0620654a6b031fde7d8fa538c71c1d93 Mon Sep 17 00:00:00 2001 From: Royston Shufflebotham Date: Sat, 8 Dec 2018 20:02:08 +0000 Subject: [PATCH 2/4] Expand acronyms out --- src/__tests__/text-matchers.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/__tests__/text-matchers.js b/src/__tests__/text-matchers.js index 7b6f8ce0..42dca4a1 100644 --- a/src/__tests__/text-matchers.js +++ b/src/__tests__/text-matchers.js @@ -196,7 +196,8 @@ cases( ) // A good use case for a custom normalizer is stripping -// out UCC codes such as LRM before matching +// out Unicode control characters such as LRM (left-right-mark) +// before matching const LRM = '\u200e' function removeUCC(str) { return str.replace(/[\u200e]/g, '') From 25c531daedcbeeeb677ceccadcd8b16201e7a840 Mon Sep 17 00:00:00 2001 From: Royston Shufflebotham Date: Mon, 10 Dec 2018 11:29:49 +0000 Subject: [PATCH 3/4] 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. --- README.md | 66 ++++++++++++++++------------ src/__tests__/matches.js | 21 +++++---- src/__tests__/text-matchers.js | 75 +++++++++++++++++++------------- src/index.js | 2 +- src/matches.js | 78 ++++++++++++++++++++-------------- src/queries.js | 53 +++++++++++------------ src/query-helpers.js | 8 ++-- typings/matches.d.ts | 14 +++++- 8 files changed, 186 insertions(+), 131 deletions(-) diff --git a/README.md b/README.md index 4a976df9..12b2a00b 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,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,6 @@ getByLabelText( options?: { selector?: string = '*', exact?: boolean = true, - collapseWhitespace?: boolean = true, - trim?: boolean = true, normalizer?: NormalizerFn, }): HTMLElement ``` @@ -260,8 +259,6 @@ getByPlaceholderText( text: TextMatch, options?: { exact?: boolean = true, - collapseWhitespace?: boolean = false, - trim?: boolean = true, normalizer?: NormalizerFn, }): HTMLElement ``` @@ -285,8 +282,6 @@ getBySelectText( text: TextMatch, options?: { exact?: boolean = true, - collapseWhitespace?: boolean = true, - trim?: boolean = true, normalizer?: NormalizerFn, }): HTMLElement ``` @@ -316,8 +311,6 @@ getByText( options?: { selector?: string = '*', exact?: boolean = true, - collapseWhitespace?: boolean = true, - trim?: boolean = true, ignore?: string|boolean = 'script, style', normalizer?: NormalizerFn, }): HTMLElement @@ -349,8 +342,6 @@ getByAltText( text: TextMatch, options?: { exact?: boolean = true, - collapseWhitespace?: boolean = false, - trim?: boolean = true, normalizer?: NormalizerFn, }): HTMLElement ``` @@ -375,8 +366,6 @@ getByTitle( title: TextMatch, options?: { exact?: boolean = true, - collapseWhitespace?: boolean = false, - trim?: boolean = true, normalizer?: NormalizerFn, }): HTMLElement ``` @@ -403,8 +392,6 @@ getByValue( value: TextMatch, options?: { exact?: boolean = true, - collapseWhitespace?: boolean = false, - trim?: boolean = true, normalizer?: NormalizerFn, }): HTMLElement ``` @@ -424,8 +411,6 @@ getByRole( text: TextMatch, options?: { exact?: boolean = true, - collapseWhitespace?: boolean = false, - trim?: boolean = true, normalizer?: NormalizerFn, }): HTMLElement ``` @@ -446,8 +431,6 @@ getByTestId( text: TextMatch, options?: { exact?: boolean = true, - collapseWhitespace?: boolean = false, - trim?: boolean = true, normalizer?: NormalizerFn, }): HTMLElement ``` @@ -811,15 +794,46 @@ 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`. Trims 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. -- `normalizer`: Defaults to `undefined`. Specifies a custom function which will be called to normalize the text (after applying any `trim` or - `collapseWhitespace` behaviour). An example use of this might be to remove Unicode control characters before applying matching behavior, e.g. - ```javascript - { - normalizer: str => str.replace(/[\u200E-\u200F]*/g, '') - } - ``` + +#### 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 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 42dca4a1..4ff6fcc4 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) }, { @@ -271,39 +278,47 @@ test('normalizer works with both exact and non-exact matching', () => { expect(queryAllByText('MiXeD CaSe', {exact: true})).toHaveLength(0) }) -test('normalizer runs after trim and collapseWhitespace options', () => { - // Our test text has leading and trailing spaces, and multiple - // spaces in the middle; we'll make use of that fact to test - // the execution order of trim/collapseWhitespace and the custom - // normalizer +test('top-level trim and collapseWhitespace options are not supported if normalizer is specified', () => { const {queryAllByText} = render('
abc def
') + const normalizer = str => str - // Double-check the normal trim + collapseWhitespace behavior - expect( - queryAllByText('abc def', {trim: true, collapseWhitespace: true}), - ).toHaveLength(1) + 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') - // Test that again, but with a normalizer that replaces double - // spaces with 'X' characters. If that runs before trim/collapseWhitespace, - // it'll prevent successful matching + // 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( - queryAllByText('abc def', { - trim: true, - collapseWhitespace: true, - normalizer: str => str.replace(/ {2}/g, 'X'), - }), - ).toHaveLength(1) + getDefaultNormalizer({collapseWhitespace: false})(' abc def '), + ).toEqual('abc def') - // Test that, if we turn off trim + collapse, that the normalizer does - // then get to see the double whitespace, and we should now be able - // to match the Xs + // Whilst it's rather pointless, we should be able to turn both off expect( - queryAllByText('XabcXdefX', { - trim: false, - collapseWhitespace: false, - // With the whitespace left in, this will add Xs which will - // prevent matching - normalizer: str => str.replace(/ {2}/g, 'X'), - }), - ).toHaveLength(1) + 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('
x y
') + 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 2b0a9ad4..3241e680 100644 --- a/src/matches.js +++ b/src/matches.js @@ -1,17 +1,9 @@ -function fuzzyMatches( - textToMatch, - node, - matcher, - {collapseWhitespace = true, trim = true, normalizer} = {}, -) { +function fuzzyMatches(textToMatch, node, matcher, normalizer) { if (typeof textToMatch !== 'string') { return false } - const normalizedText = normalize(textToMatch, { - trim, - collapseWhitespace, - normalizer, - }) + + const normalizedText = normalizer(textToMatch) if (typeof matcher === 'string') { return normalizedText.toLowerCase().includes(matcher.toLowerCase()) } else if (typeof matcher === 'function') { @@ -21,20 +13,12 @@ function fuzzyMatches( } } -function matches( - textToMatch, - node, - matcher, - {collapseWhitespace = true, trim = true, normalizer} = {}, -) { +function matches(textToMatch, node, matcher, normalizer) { if (typeof textToMatch !== 'string') { return false } - const normalizedText = normalize(textToMatch, { - trim, - collapseWhitespace, - normalizer, - }) + + const normalizedText = normalizer(textToMatch) if (typeof matcher === 'string') { return normalizedText === matcher } else if (typeof matcher === 'function') { @@ -44,14 +28,46 @@ function matches( } } -function normalize(text, {trim, collapseWhitespace, normalizer}) { - let normalizedText = text - normalizedText = trim ? normalizedText.trim() : normalizedText - normalizedText = collapseWhitespace - ? normalizedText.replace(/\s+/g, ' ') - : normalizedText - normalizedText = normalizer ? normalizer(normalizedText) : 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 b359c547..e8bbacc8 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,28 +15,25 @@ import {getConfig} from './config' function queryAllLabelsByText( container, text, - {exact = true, trim = true, collapseWhitespace = true, normalizer} = {}, + {exact = true, trim, collapseWhitespace, normalizer} = {}, ) { const matcher = exact ? matches : fuzzyMatches - const matchOpts = {collapseWhitespace, trim, normalizer} + 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, - normalizer, - } = {}, + {selector = '*', exact = true, collapseWhitespace, trim, normalizer} = {}, ) { - const matchOpts = {collapseWhitespace, trim, normalizer} - 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) { @@ -68,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( @@ -100,17 +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, normalizer} + 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) { @@ -120,14 +117,14 @@ function queryByText(...args) { function queryAllByTitle( container, text, - {exact = true, collapseWhitespace = true, trim = true, normalizer} = {}, + {exact = true, collapseWhitespace, trim, normalizer} = {}, ) { const matcher = exact ? matches : fuzzyMatches - const matchOpts = {collapseWhitespace, trim, normalizer} + 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), ) } @@ -138,16 +135,16 @@ function queryByTitle(...args) { function queryAllBySelectText( container, text, - {exact = true, collapseWhitespace = true, trim = true, normalizer} = {}, + {exact = true, collapseWhitespace, trim, normalizer} = {}, ) { const matcher = exact ? matches : fuzzyMatches - const matchOpts = {collapseWhitespace, trim, normalizer} + 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), ) }) } @@ -174,12 +171,12 @@ const queryAllByRole = queryAllByAttribute.bind(null, 'role') function queryAllByAltText( container, alt, - {exact = true, collapseWhitespace = true, trim = true, normalizer} = {}, + {exact = true, collapseWhitespace, trim, normalizer} = {}, ) { const matcher = exact ? matches : fuzzyMatches - const matchOpts = {collapseWhitespace, trim, normalizer} + 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), ) } diff --git a/src/query-helpers.js b/src/query-helpers.js index 0fcd6799..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, normalizer} = {}, + {exact = true, collapseWhitespace, trim, normalizer} = {}, ) { const matcher = exact ? matches : fuzzyMatches - const matchOpts = {collapseWhitespace, trim, normalizer} + 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 16898845..39e7b643 100644 --- a/typings/matches.d.ts +++ b/typings/matches.d.ts @@ -5,7 +5,9 @@ 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 } @@ -17,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 From c51406d52e02fb955d2630a76362a50f06f1ccf3 Mon Sep 17 00:00:00 2001 From: Royston Shufflebotham Date: Wed, 12 Dec 2018 11:24:07 +0000 Subject: [PATCH 4/4] Apply normalizer treatment to queryAllByDisplayValue --- src/__tests__/text-matchers.js | 4 ++++ src/queries.js | 8 ++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/__tests__/text-matchers.js b/src/__tests__/text-matchers.js index 4ff6fcc4..7cbd6924 100644 --- a/src/__tests__/text-matchers.js +++ b/src/__tests__/text-matchers.js @@ -256,6 +256,10 @@ cases( dom: ``, queryFn: 'queryAllByValue', }, + queryAllByDisplayValue: { + dom: ``, + queryFn: 'queryAllByDisplayValue', + }, queryAllByRole: { dom: ``, queryFn: 'queryAllByRole', diff --git a/src/queries.js b/src/queries.js index 2ee31ae2..e5393222 100644 --- a/src/queries.js +++ b/src/queries.js @@ -187,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') { @@ -198,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) } }, )