diff --git a/package.json b/package.json index 11a3b6cc..3f85e89f 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "aria-query": "^4.2.2", "chalk": "^4.1.0", "dom-accessibility-api": "^0.5.4", + "leven": "^3.1.0", "lz-string": "^1.4.4", "pretty-format": "^26.6.2" }, diff --git a/src/__tests__/close-matches.js b/src/__tests__/close-matches.js new file mode 100644 index 00000000..0fc6ed22 --- /dev/null +++ b/src/__tests__/close-matches.js @@ -0,0 +1,49 @@ +import {getCloseMatchesByAttribute} from '../close-matches' +import {render} from './helpers/test-utils' + +describe('getCloseMatchesByAttribute', () => { + test('should return all closest matches', () => { + const {container} = render(` +
+
+
+
+
+ `) + expect( + getCloseMatchesByAttribute( + 'data-testid', + container, + 'The quick brown fox jumps over the lazy dog', + ), + ).toEqual([ + 'The quick black fox jumps over the lazy dog', + 'The quick brown fox flies over the lazy dog', + ]) + }) + + test('should ignore matches that are too distant', () => { + const {container} = render(` +
+
+
+
+
+ `) + expect( + getCloseMatchesByAttribute('data-testid', container, 'normal-div'), + ).toEqual([]) + }) + + test('should ignore duplicated matches', () => { + const {container} = render(` +
+
+
+
+ `) + expect( + getCloseMatchesByAttribute('data-testid', container, 'happy dog'), + ).toEqual(['lazy dog']) + }) +}) diff --git a/src/__tests__/element-queries.js b/src/__tests__/element-queries.js index 3e3ee6de..520a4eda 100644 --- a/src/__tests__/element-queries.js +++ b/src/__tests__/element-queries.js @@ -1273,3 +1273,80 @@ it(`should get element by it's label when there are elements with same text`, () `) expect(getByLabelText('test 1')).toBeInTheDocument() }) + +test('returns closest match when computeCloseMatches = true', () => { + const {getByTestId} = render(` +
+
+
+
+
`) + + expect(() => getByTestId('meercat', {computeCloseMatches: true})) + .toThrowErrorMatchingInlineSnapshot(` +"Unable to find an element by: [data-testid="meercat"]. Did you mean one of the following? +meerkat + +
+ + +
+ + +
+ + +
+ + +
+ + +
+
" +`) +}) + +test('returns default error message when computeCloseMatches = true but cant find any good suggestions', () => { + const {getByTestId} = render(` +
+
+
+
+
`) + + expect(() => getByTestId('white-shark', {computeCloseMatches: true})) + .toThrowErrorMatchingInlineSnapshot(` +"Unable to find an element by: [data-testid="white-shark"] + +
+ + +
+ + +
+ + +
+ + +
+ + +
+
" +`) +}) diff --git a/src/close-matches.js b/src/close-matches.js new file mode 100644 index 00000000..604d6c2c --- /dev/null +++ b/src/close-matches.js @@ -0,0 +1,50 @@ +import {makeNormalizer} from './matches' +import calculateLevenshteinDistance from 'leven' + +const MAX_LEVENSHTEIN_DISTANCE = 4 + +export const getCloseMatchesByAttribute = ( + attribute, + container, + searchText, + {collapseWhitespace, trim, normalizer} = {}, +) => { + const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer}) + const allElements = Array.from(container.querySelectorAll(`[${attribute}]`)) + const allNormalizedValues = new Set( + allElements.map(element => + matchNormalizer(element.getAttribute(attribute) || ''), + ), + ) + const iterator = allNormalizedValues.values() + const lowerCaseSearch = searchText.toLowerCase() + let lastClosestDistance = MAX_LEVENSHTEIN_DISTANCE + let closestValues = [] + + for (let normalizedText; (normalizedText = iterator.next().value); ) { + if ( + Math.abs(normalizedText.length - searchText.length) > lastClosestDistance + ) { + // the distance cannot be closer than what we have already found + // eslint-disable-next-line no-continue + continue + } + + const distance = calculateLevenshteinDistance( + normalizedText.toLowerCase(), + lowerCaseSearch, + ) + + if (distance > lastClosestDistance) { + // eslint-disable-next-line no-continue + continue + } + + if (distance < lastClosestDistance) { + lastClosestDistance = distance + closestValues = [] + } + closestValues.push(normalizedText) + } + return closestValues +} diff --git a/src/config.js b/src/config.js index 2472a3fe..188162ea 100644 --- a/src/config.js +++ b/src/config.js @@ -33,6 +33,7 @@ let config = { }, _disableExpensiveErrorDiagnostics: false, computedStyleSupportsPseudoElements: false, + computeCloseMatches: false, } export const DEFAULT_IGNORE_TAGS = 'script, style' diff --git a/src/queries/test-id.js b/src/queries/test-id.js index f2ef5a9d..5d967adb 100644 --- a/src/queries/test-id.js +++ b/src/queries/test-id.js @@ -1,3 +1,4 @@ +import {getCloseMatchesByAttribute} from '../close-matches' import {checkContainerType} from '../helpers' import {wrapAllByQueryWithSuggestion} from '../query-helpers' import {queryAllByAttribute, getConfig, buildQueries} from './all-utils' @@ -11,8 +12,24 @@ function queryAllByTestId(...args) { const getMultipleError = (c, id) => `Found multiple elements by: [${getTestIdAttribute()}="${id}"]` -const getMissingError = (c, id) => - `Unable to find an element by: [${getTestIdAttribute()}="${id}"]` +const getMissingError = ( + c, + id, + {computeCloseMatches = getConfig().computeCloseMatches, ...options} = {}, +) => { + const defaultMessage = `Unable to find an element by: [${getTestIdAttribute()}="${id}"]` + + const closeMatches = + !computeCloseMatches || typeof id !== 'string' + ? [] + : getCloseMatchesByAttribute(getTestIdAttribute(), c, id, options) + + return closeMatches.length === 0 + ? defaultMessage + : `${defaultMessage}. Did you mean one of the following?\n${closeMatches.join( + '\n', + )}` +} const queryAllByTestIdWithSuggestions = wrapAllByQueryWithSuggestion( queryAllByTestId,