From 371923b6ab75cfb7e3bf67733de42eb0fdbb7fb4 Mon Sep 17 00:00:00 2001 From: Doug Date: Sat, 21 Nov 2020 17:26:04 +0900 Subject: [PATCH 1/7] implement close-matches api --- src/__tests__/close-matches.js | 84 +++++++++++++++++++++++++++++ src/close-matches.js | 96 ++++++++++++++++++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 src/__tests__/close-matches.js create mode 100644 src/close-matches.js diff --git a/src/__tests__/close-matches.js b/src/__tests__/close-matches.js new file mode 100644 index 00000000..b524402c --- /dev/null +++ b/src/__tests__/close-matches.js @@ -0,0 +1,84 @@ +import { + calculateLevenshteinDistance, + getCloseMatchesByAttribute, +} from '../close-matches' +import {render} from './helpers/test-utils' + +describe('calculateLevenshteinDistance', () => { + test.each([ + ['', '', 0], + ['hello', 'hello', 0], + ['greeting', 'greeting', 0], + ['react testing library', 'react testing library', 0], + ['hello', 'hellow', 1], + ['greetimg', 'greeting', 1], + ['submit', 'sbmit', 1], + ['cance', 'cancel', 1], + ['doug', 'dog', 1], + ['dogs and cats', 'dogs and cat', 1], + ['uncool-div', '12cool-div', 2], + ['dogs and cats', 'dogs, cats', 4], + ['greeting', 'greetings traveler', 10], + ['react testing library', '', 21], + ['react testing library', 'y', 20], + ['react testing library', 'ty', 19], + ['react testing library', 'tary', 17], + ['react testing library', 'trary', 16], + ['react testing library', 'tlibrary', 13], + ['react testing library', 'react testing', 8], + ['library', 'testing', 7], + ['react library', 'react testing', 7], + [ + 'The more your tests resemble the way your software is used, the more confidence they can give you.', + 'The less your tests resemble the way your software is used, the less confidence they can give you.', + 8, + ], + ])('distance between "%s" and "%s" is %i', (text1, text2, expected) => { + expect(calculateLevenshteinDistance(text1, text2)).toBe(expected) + }) +}) + +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/close-matches.js b/src/close-matches.js new file mode 100644 index 00000000..719b94a3 --- /dev/null +++ b/src/close-matches.js @@ -0,0 +1,96 @@ +import {makeNormalizer} from './matches' + +const initializeDpTable = (rows, columns) => { + const dp = Array(rows + 1) + .fill() + + .map(() => Array(columns + 1).fill()) + + // fill rows + + for (let i = 0; i <= rows; i++) { + dp[i][0] = i + } + + // fill columns + + for (let i = 0; i <= columns; i++) { + dp[0][i] = i + } + + return dp +} + +export const calculateLevenshteinDistance = (text1, text2) => { + const dp = initializeDpTable(text1.length, text2.length) + + for (let row = 1; row < dp.length; row++) { + for (let column = 1; column < dp[row].length; column++) { + if (text1[row - 1] === text2[column - 1]) { + dp[row][column] = dp[row - 1][column - 1] + } else { + dp[row][column] = + Math.min( + dp[row - 1][column - 1], + + dp[row][column - 1], + + dp[row - 1][column], + ) + 1 + } + } + } + return dp[text1.length][text2.length] +} + +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 +} From 5dac03a0260262cfd0efd8af17cef6e52a5f3045 Mon Sep 17 00:00:00 2001 From: Doug Date: Sat, 21 Nov 2020 17:34:44 +0900 Subject: [PATCH 2/7] implement closest matches suggestions on testId query --- src/__tests__/element-queries.js | 39 ++++++++++++++++++++++++++++++++ src/queries/test-id.js | 25 ++++++++++++++++++-- 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/src/__tests__/element-queries.js b/src/__tests__/element-queries.js index 3e3ee6de..e3379e79 100644 --- a/src/__tests__/element-queries.js +++ b/src/__tests__/element-queries.js @@ -1273,3 +1273,42 @@ 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 + +
+ + +
+ + +
+ + +
+ + +
+ + +
+
" +`) +}) diff --git a/src/queries/test-id.js b/src/queries/test-id.js index f2ef5a9d..7b619aec 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,28 @@ 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 = false, ...options} = {}, +) => { + const defaultMessage = `Unable to find an element by: [${getTestIdAttribute()}="${id}"]` + if (!computeCloseMatches || typeof id !== 'string') { + return defaultMessage + } + + const closeMatches = 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, From 788dbc29fe10b0f6240ec08f79a94feb5c98fc7b Mon Sep 17 00:00:00 2001 From: Doug Date: Sat, 21 Nov 2020 17:38:59 +0900 Subject: [PATCH 3/7] remove empty lines --- src/close-matches.js | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/close-matches.js b/src/close-matches.js index 719b94a3..2dbed15f 100644 --- a/src/close-matches.js +++ b/src/close-matches.js @@ -3,21 +3,17 @@ import {makeNormalizer} from './matches' const initializeDpTable = (rows, columns) => { const dp = Array(rows + 1) .fill() - .map(() => Array(columns + 1).fill()) // fill rows - for (let i = 0; i <= rows; i++) { dp[i][0] = i } // fill columns - for (let i = 0; i <= columns; i++) { dp[0][i] = i } - return dp } @@ -32,9 +28,7 @@ export const calculateLevenshteinDistance = (text1, text2) => { dp[row][column] = Math.min( dp[row - 1][column - 1], - dp[row][column - 1], - dp[row - 1][column], ) + 1 } @@ -47,11 +41,8 @@ const MAX_LEVENSHTEIN_DISTANCE = 4 export const getCloseMatchesByAttribute = ( attribute, - container, - searchText, - {collapseWhitespace, trim, normalizer} = {}, ) => { const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer}) @@ -91,6 +82,5 @@ export const getCloseMatchesByAttribute = ( } closestValues.push(normalizedText) } - return closestValues } From e69fd6bd3af08ddbbbe2bc5d553b73faa7c5ac32 Mon Sep 17 00:00:00 2001 From: Doug Date: Sat, 21 Nov 2020 18:10:35 +0900 Subject: [PATCH 4/7] add extra test --- src/__tests__/close-matches.js | 2 ++ src/__tests__/element-queries.js | 38 ++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/__tests__/close-matches.js b/src/__tests__/close-matches.js index b524402c..49c77ded 100644 --- a/src/__tests__/close-matches.js +++ b/src/__tests__/close-matches.js @@ -64,6 +64,8 @@ describe('getCloseMatchesByAttribute', () => {
+
+
`) expect( getCloseMatchesByAttribute('data-testid', container, 'normal-div'), diff --git a/src/__tests__/element-queries.js b/src/__tests__/element-queries.js index e3379e79..520a4eda 100644 --- a/src/__tests__/element-queries.js +++ b/src/__tests__/element-queries.js @@ -1312,3 +1312,41 @@ 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"] + +
+ + +
+ + +
+ + +
+ + +
+ + +
+
" +`) +}) From b12cb295c1fe25b72b0e70062f3e5d9604351729 Mon Sep 17 00:00:00 2001 From: Doug Date: Sat, 21 Nov 2020 18:10:48 +0900 Subject: [PATCH 5/7] simplify conditional --- src/queries/test-id.js | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/queries/test-id.js b/src/queries/test-id.js index 7b619aec..5d439f9f 100644 --- a/src/queries/test-id.js +++ b/src/queries/test-id.js @@ -18,16 +18,12 @@ const getMissingError = ( {computeCloseMatches = false, ...options} = {}, ) => { const defaultMessage = `Unable to find an element by: [${getTestIdAttribute()}="${id}"]` - if (!computeCloseMatches || typeof id !== 'string') { - return defaultMessage - } - - const closeMatches = getCloseMatchesByAttribute( - getTestIdAttribute(), - c, - id, - options, - ) + + 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( From cd548473e3178d2ada3b6a6539ee07d3e9d9d430 Mon Sep 17 00:00:00 2001 From: Doug Date: Sat, 28 Nov 2020 16:44:49 +0900 Subject: [PATCH 6/7] add computeCloseMatches config option --- src/config.js | 1 + src/queries/test-id.js | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) 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 5d439f9f..5d967adb 100644 --- a/src/queries/test-id.js +++ b/src/queries/test-id.js @@ -15,7 +15,7 @@ const getMultipleError = (c, id) => const getMissingError = ( c, id, - {computeCloseMatches = false, ...options} = {}, + {computeCloseMatches = getConfig().computeCloseMatches, ...options} = {}, ) => { const defaultMessage = `Unable to find an element by: [${getTestIdAttribute()}="${id}"]` From 8ee261f295e2fab496d358f53b62a6afe6f46a1f Mon Sep 17 00:00:00 2001 From: dougbacelar Date: Sat, 5 Dec 2020 10:57:08 +0900 Subject: [PATCH 7/7] remove custom implementatin of levenshtein --- package.json | 1 + src/__tests__/close-matches.js | 39 +--------------------------------- src/close-matches.js | 38 +-------------------------------- 3 files changed, 3 insertions(+), 75 deletions(-) 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 index 49c77ded..0fc6ed22 100644 --- a/src/__tests__/close-matches.js +++ b/src/__tests__/close-matches.js @@ -1,43 +1,6 @@ -import { - calculateLevenshteinDistance, - getCloseMatchesByAttribute, -} from '../close-matches' +import {getCloseMatchesByAttribute} from '../close-matches' import {render} from './helpers/test-utils' -describe('calculateLevenshteinDistance', () => { - test.each([ - ['', '', 0], - ['hello', 'hello', 0], - ['greeting', 'greeting', 0], - ['react testing library', 'react testing library', 0], - ['hello', 'hellow', 1], - ['greetimg', 'greeting', 1], - ['submit', 'sbmit', 1], - ['cance', 'cancel', 1], - ['doug', 'dog', 1], - ['dogs and cats', 'dogs and cat', 1], - ['uncool-div', '12cool-div', 2], - ['dogs and cats', 'dogs, cats', 4], - ['greeting', 'greetings traveler', 10], - ['react testing library', '', 21], - ['react testing library', 'y', 20], - ['react testing library', 'ty', 19], - ['react testing library', 'tary', 17], - ['react testing library', 'trary', 16], - ['react testing library', 'tlibrary', 13], - ['react testing library', 'react testing', 8], - ['library', 'testing', 7], - ['react library', 'react testing', 7], - [ - 'The more your tests resemble the way your software is used, the more confidence they can give you.', - 'The less your tests resemble the way your software is used, the less confidence they can give you.', - 8, - ], - ])('distance between "%s" and "%s" is %i', (text1, text2, expected) => { - expect(calculateLevenshteinDistance(text1, text2)).toBe(expected) - }) -}) - describe('getCloseMatchesByAttribute', () => { test('should return all closest matches', () => { const {container} = render(` diff --git a/src/close-matches.js b/src/close-matches.js index 2dbed15f..604d6c2c 100644 --- a/src/close-matches.js +++ b/src/close-matches.js @@ -1,41 +1,5 @@ import {makeNormalizer} from './matches' - -const initializeDpTable = (rows, columns) => { - const dp = Array(rows + 1) - .fill() - .map(() => Array(columns + 1).fill()) - - // fill rows - for (let i = 0; i <= rows; i++) { - dp[i][0] = i - } - - // fill columns - for (let i = 0; i <= columns; i++) { - dp[0][i] = i - } - return dp -} - -export const calculateLevenshteinDistance = (text1, text2) => { - const dp = initializeDpTable(text1.length, text2.length) - - for (let row = 1; row < dp.length; row++) { - for (let column = 1; column < dp[row].length; column++) { - if (text1[row - 1] === text2[column - 1]) { - dp[row][column] = dp[row - 1][column - 1] - } else { - dp[row][column] = - Math.min( - dp[row - 1][column - 1], - dp[row][column - 1], - dp[row - 1][column], - ) + 1 - } - } - } - return dp[text1.length][text2.length] -} +import calculateLevenshteinDistance from 'leven' const MAX_LEVENSHTEIN_DISTANCE = 4