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,