From 878ad7d91ae29cd31c7128bd0797a7c9d8b7c672 Mon Sep 17 00:00:00 2001 From: Isaiah Thomason <47364027+ITenthusiasm@users.noreply.github.com> Date: Mon, 17 Jul 2023 19:12:15 -0400 Subject: [PATCH 1/2] fix: Conform `toHaveErrorMessage` to Spec and Rename Included Changes: - According to the WAI-ARIA spec, passing an invalid `id` to `aria-errormessage` now fails assertion. This means that any empty spaces inside `aria-errormessage` will now cause test failures. - According to the WAI-ARIA spec, developers can now assert that an accessible error message is missing if `aria-invalid` is `false` (or if the `aria-errormessage` attribute is invalid). - Updated the error message and test cases surrounding the requirement for `aria-invalid`. They are now more detailed/accurate. - Renamed `toHaveErrorMessage` to `toHaveAccessibleErrorMessage` to be consistent with the other a11y-related methods (`toHaveAccessibleName` and `toHaveAccessibleDescription`). - Note: This deprecates the previous `toHaveErrorMessage` method. - Updated documentation. Similar to the `toHaveAccessibleDescription` method, this description is much more lean, as the reader can simply read the WAI ARIA spec for additional details/requirements. --- README.md | 62 ++++ .../to-have-accessible-errormessage.js | 277 ++++++++++++++++++ src/matchers.js | 2 + src/to-have-accessible-errormessage.js | 85 ++++++ src/to-have-errormessage.js | 3 +- 5 files changed, 428 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/to-have-accessible-errormessage.js create mode 100644 src/to-have-accessible-errormessage.js diff --git a/README.md b/README.md index 1778f8fe..7c6d3c65 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ clear to read and to maintain. - [`toContainElement`](#tocontainelement) - [`toContainHTML`](#tocontainhtml) - [`toHaveAccessibleDescription`](#tohaveaccessibledescription) + - [`toHaveAccessibleErrorMessage`](#tohaveaccessibleerrormessage) - [`toHaveAccessibleName`](#tohaveaccessiblename) - [`toHaveAttribute`](#tohaveattribute) - [`toHaveClass`](#tohaveclass) @@ -561,6 +562,63 @@ expect(getByTestId('logo')).toHaveAccessibleDescription(
+### `toHaveAccessibleErrorMessage` + +```typescript +toHaveAccessibleErrorMessage(expectedAccessibleErrorMessage?: string | RegExp) +``` + +This allows you to assert that an element has the expected +[accessible error message](https://w3c.github.io/aria/#aria-errormessage). + +You can pass the exact string of the expected accessible error message. +Alternatively, you can perform a partial match by passing a regular expression +or by using +[expect.stringContaining](https://jestjs.io/docs/en/expect.html#expectnotstringcontainingstring)/[expect.stringMatching](https://jestjs.io/docs/en/expect.html#expectstringmatchingstring-regexp). + +#### Examples + +```html + + + + + +``` + +```js +// Inputs with Valid Error Messages +expect(getByRole('textbox', {name: 'Has Error'})).toHaveAccessibleErrorMessage() +expect(getByRole('textbox', {name: 'Has Error'})).toHaveAccessibleErrorMessage( + 'This field is invalid', +) +expect(getByRole('textbox', {name: 'Has Error'})).toHaveAccessibleErrorMessage( + /invalid/i, +) +expect( + getByRole('textbox', {name: 'Has Error'}), +).not.toHaveAccessibleErrorMessage('This field is absolutely correct!') + +// Inputs without Valid Error Messages +expect( + getByRole('textbox', {name: 'No Error Attributes'}), +).not.toHaveAccessibleErrorMessage() + +expect( + getByRole('textbox', {name: 'Not Invalid'}), +).not.toHaveAccessibleErrorMessage() +``` + +
+ ### `toHaveAccessibleName` ```typescript @@ -1069,6 +1127,10 @@ expect(inputCheckboxIndeterminate).toBePartiallyChecked() ### `toHaveErrorMessage` +> This custom matcher is deprecated. Prefer +> [`toHaveAccessibleErrorMessage`](#tohaveaccessibleerrormessage) instead, which +> is more comprehensive in implementing the official spec. + ```typescript toHaveErrorMessage(text: string | RegExp) ``` diff --git a/src/__tests__/to-have-accessible-errormessage.js b/src/__tests__/to-have-accessible-errormessage.js new file mode 100644 index 00000000..1586020f --- /dev/null +++ b/src/__tests__/to-have-accessible-errormessage.js @@ -0,0 +1,277 @@ +import {render} from './helpers/test-utils' + +describe('.toHaveAccessibleErrorMessage', () => { + const input = 'input' + const errorId = 'error-id' + const error = 'This field is invalid' + const strings = {true: String(true), false: String(false)} + + describe('Positive Test Cases', () => { + it("Fails the test if an invalid `id` is provided for the target element's `aria-errormessage`", () => { + const secondId = 'id2' + const secondError = 'LISTEN TO ME!!!' + + const {queryByTestId} = render(` +
+ <${input} data-testid="${input}" aria-invalid="${strings.true}" aria-errormessage="${errorId} ${secondId}" /> + + +
+ `) + + const field = queryByTestId('input') + expect(() => expect(field).toHaveAccessibleErrorMessage()) + .toThrowErrorMatchingInlineSnapshot(` + expect(element).toHaveAccessibleErrorMessage(expected) + + Expected element's \`aria-errormessage\` attribute to be empty or a single, valid ID: + + Received: + aria-errormessage="error-id id2" + `) + + // Assume the remaining error messages are the EXACT same as above + expect(() => + expect(field).toHaveAccessibleErrorMessage(new RegExp(error[0])), + ).toThrow() + + expect(() => expect(field).toHaveAccessibleErrorMessage(error)).toThrow() + expect(() => + expect(field).toHaveAccessibleErrorMessage(secondError), + ).toThrow() + + expect(() => + expect(field).toHaveAccessibleErrorMessage(new RegExp(secondError[0])), + ).toThrow() + }) + + it('Fails the test if the target element is valid according to the WAI-ARIA spec', () => { + const noAriaInvalidAttribute = 'no-aria-invalid-attribute' + const validFieldState = 'false' + const invalidFieldStates = [ + 'true', + '', + 'grammar', + 'spelling', + 'asfdafbasdfasa', + ] + + function renderFieldWithState(state) { + return render(` +
+ <${input} data-testid="${input}" aria-invalid="${state}" aria-errormessage="${errorId}" /> + + + +
+ `) + } + + // Success Cases + invalidFieldStates.forEach(invalidState => { + const {queryByTestId} = renderFieldWithState(invalidState) + const field = queryByTestId('input') + + expect(field).toHaveAccessibleErrorMessage() + expect(field).toHaveAccessibleErrorMessage(error) + }) + + // Failure Case + const {queryByTestId} = renderFieldWithState(validFieldState) + const field = queryByTestId('input') + const fieldWithoutAttribute = queryByTestId(noAriaInvalidAttribute) + + expect(() => expect(fieldWithoutAttribute).toHaveAccessibleErrorMessage()) + .toThrowErrorMatchingInlineSnapshot(` + expect(element).toHaveAccessibleErrorMessage(expected) + + Expected element to be marked as invalid with attribute: + aria-invalid="true" + Received: + null + `) + + expect(() => expect(field).toHaveAccessibleErrorMessage()) + .toThrowErrorMatchingInlineSnapshot(` + expect(element).toHaveAccessibleErrorMessage(expected) + + Expected element to be marked as invalid with attribute: + aria-invalid="true" + Received: + aria-invalid="false + `) + + // Assume the remaining error messages are the EXACT same as above + expect(() => expect(field).toHaveAccessibleErrorMessage(error)).toThrow() + expect(() => + expect(field).toHaveAccessibleErrorMessage(new RegExp(error, 'i')), + ).toThrow() + }) + + it('Passes the test if the target element has ANY recognized, non-empty error message', () => { + const {queryByTestId} = render(` +
+ <${input} data-testid="${input}" aria-invalid="${strings.true}" aria-errormessage="${errorId}" /> + +
+ `) + + const field = queryByTestId(input) + expect(field).toHaveAccessibleErrorMessage() + }) + + it('Fails the test if NO recognized, non-empty error message was found for the target element', () => { + const empty = 'empty' + const emptyErrorId = 'empty-error' + const missing = 'missing' + + const {queryByTestId} = render(` +
+ + + + +
+ `) + + const fieldWithEmptyError = queryByTestId(empty) + const fieldMissingError = queryByTestId(missing) + + expect(() => expect(fieldWithEmptyError).toHaveAccessibleErrorMessage()) + .toThrowErrorMatchingInlineSnapshot(` + expect(element).toHaveAccessibleErrorMessage(expected) + + Expected element to have accessible error message: + + Received: + + `) + + expect(() => expect(fieldMissingError).toHaveAccessibleErrorMessage()) + .toThrowErrorMatchingInlineSnapshot(` + expect(element).toHaveAccessibleErrorMessage(expected) + + Expected element to have accessible error message: + + Received: + + `) + }) + + it('Passes the test if the target element has the error message that was SPECIFIED', () => { + const {queryByTestId} = render(` +
+ <${input} data-testid="${input}" aria-invalid="${strings.true}" aria-errormessage="${errorId}" /> + +
+ `) + + const field = queryByTestId(input) + const halfOfError = error.slice(0, Math.floor(error.length * 0.5)) + + expect(field).toHaveAccessibleErrorMessage(error) + expect(field).toHaveAccessibleErrorMessage(new RegExp(halfOfError), 'i') + expect(field).toHaveAccessibleErrorMessage( + expect.stringContaining(halfOfError), + ) + expect(field).toHaveAccessibleErrorMessage( + expect.stringMatching(new RegExp(halfOfError), 'i'), + ) + }) + + it('Fails the test if the target element DOES NOT have the error message that was SPECIFIED', () => { + const {queryByTestId} = render(` +
+ <${input} data-testid="${input}" aria-invalid="${strings.true}" aria-errormessage="${errorId}" /> + +
+ `) + + const field = queryByTestId(input) + const msg = 'asdflkje2984fguyvb bnafdsasfa;lj' + + expect(() => expect(field).toHaveAccessibleErrorMessage('')) + .toThrowErrorMatchingInlineSnapshot(` + expect(element).toHaveAccessibleErrorMessage(expected) + + Expected element to have accessible error message: + + Received: + This field is invalid + `) + + // Assume this error is SIMILAR to the error above + expect(() => expect(field).toHaveAccessibleErrorMessage(msg)).toThrow() + expect(() => + expect(field).toHaveAccessibleErrorMessage( + error.slice(0, Math.floor(error.length * 0.5)), + ), + ).toThrow() + + expect(() => + expect(field).toHaveAccessibleErrorMessage(new RegExp(msg), 'i'), + ).toThrowErrorMatchingInlineSnapshot(` + expect(element).toHaveAccessibleErrorMessage(expected) + + Expected element to have accessible error message: + /asdflkje2984fguyvb bnafdsasfa;lj/ + Received: + This field is invalid + `) + }) + + it('Normalizes the whitespace of the received error message', () => { + const {queryByTestId} = render(` +
+ <${input} data-testid="${input}" aria-invalid="${strings.true}" aria-errormessage="${errorId}" /> + +
+ `) + + const field = queryByTestId(input) + expect(field).toHaveAccessibleErrorMessage('Step 1 of 9000') + }) + }) + + // These tests for the `.not` use cases will help us cover our bases and complete test coverage + describe('Negated Test Cases', () => { + it("Passes the test if an invalid `id` is provided for the target element's `aria-errormessage`", () => { + const secondId = 'id2' + const secondError = 'LISTEN TO ME!!!' + + const {queryByTestId} = render(` +
+ <${input} data-testid="${input}" aria-invalid="${strings.true}" aria-errormessage="${errorId} ${secondId}" /> + + +
+ `) + + const field = queryByTestId('input') + expect(field).not.toHaveAccessibleErrorMessage() + expect(field).not.toHaveAccessibleErrorMessage(error) + expect(field).not.toHaveAccessibleErrorMessage(new RegExp(error[0])) + expect(field).not.toHaveAccessibleErrorMessage(secondError) + expect(field).not.toHaveAccessibleErrorMessage(new RegExp(secondError[0])) + }) + + it('Passes the test if the target element is valid according to the WAI-ARIA spec', () => { + const {queryByTestId} = render(` +
+ <${input} data-testid="${input}" aria-errormessage="${errorId}" /> + +
+ `) + + const field = queryByTestId(input) + expect(field).not.toHaveAccessibleErrorMessage() + expect(field).not.toHaveAccessibleErrorMessage(error) + expect(field).not.toHaveAccessibleErrorMessage(new RegExp(error[0])) + }) + }) +}) diff --git a/src/matchers.js b/src/matchers.js index c90945d5..04fe30bc 100644 --- a/src/matchers.js +++ b/src/matchers.js @@ -6,6 +6,7 @@ import {toContainElement} from './to-contain-element' import {toContainHTML} from './to-contain-html' import {toHaveTextContent} from './to-have-text-content' import {toHaveAccessibleDescription} from './to-have-accessible-description' +import {toHaveAccessibleErrorMessage} from './to-have-accessible-errormessage' import {toHaveAccessibleName} from './to-have-accessible-name' import {toHaveAttribute} from './to-have-attribute' import {toHaveClass} from './to-have-class' @@ -32,6 +33,7 @@ export { toContainHTML, toHaveTextContent, toHaveAccessibleDescription, + toHaveAccessibleErrorMessage, toHaveAccessibleName, toHaveAttribute, toHaveClass, diff --git a/src/to-have-accessible-errormessage.js b/src/to-have-accessible-errormessage.js new file mode 100644 index 00000000..d34d310f --- /dev/null +++ b/src/to-have-accessible-errormessage.js @@ -0,0 +1,85 @@ +import {checkHtmlElement, getMessage, normalize} from './utils' + +const ariaInvalidName = 'aria-invalid' +const validStates = ['false'] + +// See `aria-errormessage` spec at https://www.w3.org/TR/wai-aria-1.2/#aria-errormessage +export function toHaveAccessibleErrorMessage( + htmlElement, + expectedAccessibleErrorMessage, +) { + checkHtmlElement(htmlElement, toHaveAccessibleErrorMessage, this) + const to = this.isNot ? 'not to' : 'to' + const method = this.isNot + ? '.not.toHaveAccessibleErrorMessage' + : '.toHaveAccessibleErrorMessage' + + // Enforce Valid Id + const errormessageId = htmlElement.getAttribute('aria-errormessage') + const errormessageIdInvalid = !!errormessageId && /\s+/.test(errormessageId) + + if (errormessageIdInvalid) { + return { + pass: false, + message: () => { + return getMessage( + this, + this.utils.matcherHint(method, 'element'), + "Expected element's `aria-errormessage` attribute to be empty or a single, valid ID", + '', + 'Received', + `aria-errormessage="${errormessageId}"`, + ) + }, + } + } + + // See `aria-invalid` spec at https://www.w3.org/TR/wai-aria-1.2/#aria-invalid + const ariaInvalidVal = htmlElement.getAttribute(ariaInvalidName) + const fieldValid = + !htmlElement.hasAttribute(ariaInvalidName) || + validStates.includes(ariaInvalidVal) + + // Enforce Valid `aria-invalid` Attribute + if (fieldValid) { + return { + pass: false, + message: () => { + return getMessage( + this, + this.utils.matcherHint(method, 'element'), + 'Expected element to be marked as invalid with attribute', + `${ariaInvalidName}="${String(true)}"`, + 'Received', + htmlElement.hasAttribute('aria-invalid') + ? `${ariaInvalidName}="${htmlElement.getAttribute(ariaInvalidName)}` + : null, + ) + }, + } + } + + const error = normalize( + htmlElement.ownerDocument.getElementById(errormessageId)?.textContent ?? '', + ) + + return { + pass: + expectedAccessibleErrorMessage === undefined + ? Boolean(error) + : expectedAccessibleErrorMessage instanceof RegExp + ? expectedAccessibleErrorMessage.test(error) + : this.equals(error, expectedAccessibleErrorMessage), + + message: () => { + return getMessage( + this, + this.utils.matcherHint(method, 'element'), + `Expected element ${to} have accessible error message`, + expectedAccessibleErrorMessage ?? '', + 'Received', + error, + ) + }, + } +} diff --git a/src/to-have-errormessage.js b/src/to-have-errormessage.js index a253b390..5b12e4e2 100644 --- a/src/to-have-errormessage.js +++ b/src/to-have-errormessage.js @@ -1,7 +1,8 @@ -import {checkHtmlElement, getMessage, normalize} from './utils' +import {checkHtmlElement, getMessage, normalize, deprecate} from './utils' // See aria-errormessage spec https://www.w3.org/TR/wai-aria-1.2/#aria-errormessage export function toHaveErrorMessage(htmlElement, checkWith) { + deprecate('toHaveErrorMessage', 'Please use toHaveAccessibleErrorMessage.') checkHtmlElement(htmlElement, toHaveErrorMessage, this) if ( From fbe6feb7e3d3b66ef9d65c94bde6e008153aab04 Mon Sep 17 00:00:00 2001 From: Isaiah Thomason <47364027+ITenthusiasm@users.noreply.github.com> Date: Mon, 17 Jul 2023 19:41:13 -0400 Subject: [PATCH 2/2] refactor: Simplify Exports from `matchers.js` This makes the code easier to maintain as more exports are added. --- src/matchers.js | 80 ++++++++++++++++--------------------------------- 1 file changed, 25 insertions(+), 55 deletions(-) diff --git a/src/matchers.js b/src/matchers.js index 04fe30bc..f49b489a 100644 --- a/src/matchers.js +++ b/src/matchers.js @@ -1,55 +1,25 @@ -import {toBeInTheDOM} from './to-be-in-the-dom' -import {toBeInTheDocument} from './to-be-in-the-document' -import {toBeEmpty} from './to-be-empty' -import {toBeEmptyDOMElement} from './to-be-empty-dom-element' -import {toContainElement} from './to-contain-element' -import {toContainHTML} from './to-contain-html' -import {toHaveTextContent} from './to-have-text-content' -import {toHaveAccessibleDescription} from './to-have-accessible-description' -import {toHaveAccessibleErrorMessage} from './to-have-accessible-errormessage' -import {toHaveAccessibleName} from './to-have-accessible-name' -import {toHaveAttribute} from './to-have-attribute' -import {toHaveClass} from './to-have-class' -import {toHaveStyle} from './to-have-style' -import {toHaveFocus} from './to-have-focus' -import {toHaveFormValues} from './to-have-form-values' -import {toBeVisible} from './to-be-visible' -import {toBeDisabled, toBeEnabled} from './to-be-disabled' -import {toBeRequired} from './to-be-required' -import {toBeInvalid, toBeValid} from './to-be-invalid' -import {toHaveValue} from './to-have-value' -import {toHaveDisplayValue} from './to-have-display-value' -import {toBeChecked} from './to-be-checked' -import {toBePartiallyChecked} from './to-be-partially-checked' -import {toHaveDescription} from './to-have-description' -import {toHaveErrorMessage} from './to-have-errormessage' - -export { - toBeInTheDOM, - toBeInTheDocument, - toBeEmpty, - toBeEmptyDOMElement, - toContainElement, - toContainHTML, - toHaveTextContent, - toHaveAccessibleDescription, - toHaveAccessibleErrorMessage, - toHaveAccessibleName, - toHaveAttribute, - toHaveClass, - toHaveStyle, - toHaveFocus, - toHaveFormValues, - toBeVisible, - toBeDisabled, - toBeEnabled, - toBeRequired, - toBeInvalid, - toBeValid, - toHaveValue, - toHaveDisplayValue, - toBeChecked, - toBePartiallyChecked, - toHaveDescription, - toHaveErrorMessage, -} +export {toBeInTheDOM} from './to-be-in-the-dom' +export {toBeInTheDocument} from './to-be-in-the-document' +export {toBeEmpty} from './to-be-empty' +export {toBeEmptyDOMElement} from './to-be-empty-dom-element' +export {toContainElement} from './to-contain-element' +export {toContainHTML} from './to-contain-html' +export {toHaveTextContent} from './to-have-text-content' +export {toHaveAccessibleDescription} from './to-have-accessible-description' +export {toHaveAccessibleErrorMessage} from './to-have-accessible-errormessage' +export {toHaveAccessibleName} from './to-have-accessible-name' +export {toHaveAttribute} from './to-have-attribute' +export {toHaveClass} from './to-have-class' +export {toHaveStyle} from './to-have-style' +export {toHaveFocus} from './to-have-focus' +export {toHaveFormValues} from './to-have-form-values' +export {toBeVisible} from './to-be-visible' +export {toBeDisabled, toBeEnabled} from './to-be-disabled' +export {toBeRequired} from './to-be-required' +export {toBeInvalid, toBeValid} from './to-be-invalid' +export {toHaveValue} from './to-have-value' +export {toHaveDisplayValue} from './to-have-display-value' +export {toBeChecked} from './to-be-checked' +export {toBePartiallyChecked} from './to-be-partially-checked' +export {toHaveDescription} from './to-have-description' +export {toHaveErrorMessage} from './to-have-errormessage'