Skip to content

Commit 7f141b2

Browse files
authored
feat: expect(locator).toHaveAccessibleErrorMessage (#33904)
1 parent 3ec8ee7 commit 7f141b2

File tree

7 files changed

+281
-1
lines changed

7 files changed

+281
-1
lines changed

docs/src/api/class-locatorassertions.md

+50
Original file line numberDiff line numberDiff line change
@@ -1217,6 +1217,56 @@ Expected accessible description.
12171217
* since: v1.44
12181218

12191219

1220+
## async method: LocatorAssertions.toHaveAccessibleErrorMessage
1221+
* since: v1.50
1222+
* langs:
1223+
- alias-java: hasAccessibleErrorMessage
1224+
1225+
Ensures the [Locator] points to an element with a given [aria errormessage](https://w3c.github.io/aria/#aria-errormessage).
1226+
1227+
**Usage**
1228+
1229+
```js
1230+
const locator = page.getByTestId('username-input');
1231+
await expect(locator).toHaveAccessibleErrorMessage('Username is required.');
1232+
```
1233+
1234+
```java
1235+
Locator locator = page.getByTestId("username-input");
1236+
assertThat(locator).hasAccessibleErrorMessage("Username is required.");
1237+
```
1238+
1239+
```python async
1240+
locator = page.get_by_test_id("username-input")
1241+
await expect(locator).to_have_accessible_error_message("Username is required.")
1242+
```
1243+
1244+
```python sync
1245+
locator = page.get_by_test_id("username-input")
1246+
expect(locator).to_have_accessible_error_message("Username is required.")
1247+
```
1248+
1249+
```csharp
1250+
var locator = Page.GetByTestId("username-input");
1251+
await Expect(locator).ToHaveAccessibleErrorMessageAsync("Username is required.");
1252+
```
1253+
1254+
### param: LocatorAssertions.toHaveAccessibleErrorMessage.errorMessage
1255+
* since: v1.50
1256+
- `errorMessage` <[string]|[RegExp]>
1257+
1258+
Expected accessible error message.
1259+
1260+
### option: LocatorAssertions.toHaveAccessibleErrorMessage.timeout = %%-js-assertions-timeout-%%
1261+
* since: v1.50
1262+
1263+
### option: LocatorAssertions.toHaveAccessibleErrorMessage.timeout = %%-csharp-java-python-assertions-timeout-%%
1264+
* since: v1.50
1265+
1266+
### option: LocatorAssertions.toHaveAccessibleErrorMessage.ignoreCase = %%-assertions-ignore-case-%%
1267+
* since: v1.50
1268+
1269+
12201270
## async method: LocatorAssertions.toHaveAccessibleName
12211271
* since: v1.44
12221272
* langs:

packages/playwright-core/src/server/injected/injectedScript.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import type { CSSComplexSelectorList } from '../../utils/isomorphic/cssParser';
2929
import { generateSelector, type GenerateSelectorOptions } from './selectorGenerator';
3030
import type * as channels from '@protocol/channels';
3131
import { Highlight } from './highlight';
32-
import { getChecked, getAriaDisabled, getAriaRole, getElementAccessibleName, getElementAccessibleDescription, getReadonly } from './roleUtils';
32+
import { getChecked, getAriaDisabled, getAriaRole, getElementAccessibleName, getElementAccessibleDescription, getReadonly, getElementAccessibleErrorMessage } from './roleUtils';
3333
import { kLayoutSelectorNames, type LayoutSelectorName, layoutSelectorScore } from './layoutSelectorUtils';
3434
import { asLocator } from '../../utils/isomorphic/locatorGenerators';
3535
import type { Language } from '../../utils/isomorphic/locatorGenerators';
@@ -1321,6 +1321,8 @@ export class InjectedScript {
13211321
received = getElementAccessibleName(element, false /* includeHidden */);
13221322
} else if (expression === 'to.have.accessible.description') {
13231323
received = getElementAccessibleDescription(element, false /* includeHidden */);
1324+
} else if (expression === 'to.have.accessible.error.message') {
1325+
received = getElementAccessibleErrorMessage(element);
13241326
} else if (expression === 'to.have.role') {
13251327
received = getAriaRole(element) || '';
13261328
} else if (expression === 'to.have.title') {

packages/playwright-core/src/server/injected/roleUtils.ts

+56
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,59 @@ export function getElementAccessibleDescription(element: Element, includeHidden:
461461
return accessibleDescription;
462462
}
463463

464+
// https://www.w3.org/TR/wai-aria-1.2/#aria-invalid
465+
const kAriaInvalidRoles = ['application', 'checkbox', 'combobox', 'gridcell', 'listbox', 'radiogroup', 'slider', 'spinbutton', 'textbox', 'tree', 'columnheader', 'rowheader', 'searchbox', 'switch', 'treegrid'];
466+
467+
function getAriaInvalid(element: Element): 'false' | 'true' | 'grammar' | 'spelling' {
468+
const role = getAriaRole(element) || '';
469+
if (!role || !kAriaInvalidRoles.includes(role))
470+
return 'false';
471+
const ariaInvalid = element.getAttribute('aria-invalid');
472+
if (!ariaInvalid || ariaInvalid.trim() === '' || ariaInvalid.toLocaleLowerCase() === 'false')
473+
return 'false';
474+
if (ariaInvalid === 'true' || ariaInvalid === 'grammar' || ariaInvalid === 'spelling')
475+
return ariaInvalid;
476+
return 'true';
477+
}
478+
479+
function getValidityInvalid(element: Element) {
480+
if ('validity' in element){
481+
const validity = element.validity as ValidityState | undefined;
482+
return validity?.valid === false;
483+
}
484+
return false;
485+
}
486+
487+
export function getElementAccessibleErrorMessage(element: Element): string {
488+
// SPEC: https://w3c.github.io/aria/#aria-errormessage
489+
//
490+
// TODO: support https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/validationMessage
491+
const cache = cacheAccessibleErrorMessage;
492+
let accessibleErrorMessage = cacheAccessibleErrorMessage?.get(element);
493+
494+
if (accessibleErrorMessage === undefined) {
495+
accessibleErrorMessage = '';
496+
497+
const isAriaInvalid = getAriaInvalid(element) !== 'false';
498+
const isValidityInvalid = getValidityInvalid(element);
499+
if (isAriaInvalid || isValidityInvalid) {
500+
const errorMessageId = element.getAttribute('aria-errormessage');
501+
const errorMessages = getIdRefs(element, errorMessageId);
502+
// Ideally, this should be a separate "embeddedInErrorMessage", but it would follow the exact same rules.
503+
// Relevant vague spec: https://w3c.github.io/core-aam/#ariaErrorMessage.
504+
const parts = errorMessages.map(errorMessage => asFlatString(
505+
getTextAlternativeInternal(errorMessage, {
506+
visitedElements: new Set(),
507+
embeddedInDescribedBy: { element: errorMessage, hidden: isElementHiddenForAria(errorMessage) },
508+
})
509+
));
510+
accessibleErrorMessage = parts.join(' ').trim();
511+
}
512+
cache?.set(element, accessibleErrorMessage);
513+
}
514+
return accessibleErrorMessage;
515+
}
516+
464517
type AccessibleNameOptions = {
465518
visitedElements: Set<Element>,
466519
includeHidden?: boolean,
@@ -972,6 +1025,7 @@ let cacheAccessibleName: Map<Element, string> | undefined;
9721025
let cacheAccessibleNameHidden: Map<Element, string> | undefined;
9731026
let cacheAccessibleDescription: Map<Element, string> | undefined;
9741027
let cacheAccessibleDescriptionHidden: Map<Element, string> | undefined;
1028+
let cacheAccessibleErrorMessage: Map<Element, string> | undefined;
9751029
let cacheIsHidden: Map<Element, boolean> | undefined;
9761030
let cachePseudoContentBefore: Map<Element, string> | undefined;
9771031
let cachePseudoContentAfter: Map<Element, string> | undefined;
@@ -983,6 +1037,7 @@ export function beginAriaCaches() {
9831037
cacheAccessibleNameHidden ??= new Map();
9841038
cacheAccessibleDescription ??= new Map();
9851039
cacheAccessibleDescriptionHidden ??= new Map();
1040+
cacheAccessibleErrorMessage ??= new Map();
9861041
cacheIsHidden ??= new Map();
9871042
cachePseudoContentBefore ??= new Map();
9881043
cachePseudoContentAfter ??= new Map();
@@ -994,6 +1049,7 @@ export function endAriaCaches() {
9941049
cacheAccessibleNameHidden = undefined;
9951050
cacheAccessibleDescription = undefined;
9961051
cacheAccessibleDescriptionHidden = undefined;
1052+
cacheAccessibleErrorMessage = undefined;
9971053
cacheIsHidden = undefined;
9981054
cachePseudoContentBefore = undefined;
9991055
cachePseudoContentAfter = undefined;

packages/playwright/src/matchers/expect.ts

+2
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
toContainText,
3636
toHaveAccessibleDescription,
3737
toHaveAccessibleName,
38+
toHaveAccessibleErrorMessage,
3839
toHaveAttribute,
3940
toHaveClass,
4041
toHaveCount,
@@ -224,6 +225,7 @@ const customAsyncMatchers = {
224225
toContainText,
225226
toHaveAccessibleDescription,
226227
toHaveAccessibleName,
228+
toHaveAccessibleErrorMessage,
227229
toHaveAttribute,
228230
toHaveClass,
229231
toHaveCount,

packages/playwright/src/matchers/matchers.ts

+12
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,18 @@ export function toHaveAccessibleName(
205205
}
206206
}
207207

208+
export function toHaveAccessibleErrorMessage(
209+
this: ExpectMatcherState,
210+
locator: LocatorEx,
211+
expected: string | RegExp,
212+
options?: { timeout?: number; ignoreCase?: boolean },
213+
) {
214+
return toMatchText.call(this, 'toHaveAccessibleErrorMessage', locator, 'Locator', async (isNot, timeout) => {
215+
const expectedText = serializeExpectedTextValues([expected], { ignoreCase: options?.ignoreCase, normalizeWhiteSpace: true });
216+
return await locator._expect('to.have.accessible.error.message', { expectedText: expectedText, isNot, timeout });
217+
}, expected, options);
218+
}
219+
208220
export function toHaveAttribute(
209221
this: ExpectMatcherState,
210222
locator: LocatorEx,

packages/playwright/types/test.d.ts

+28
Original file line numberDiff line numberDiff line change
@@ -8112,6 +8112,34 @@ interface LocatorAssertions {
81128112
timeout?: number;
81138113
}): Promise<void>;
81148114

8115+
/**
8116+
* Ensures the [Locator](https://playwright.dev/docs/api/class-locator) points to an element with a given
8117+
* [aria errormessage](https://w3c.github.io/aria/#aria-errormessage).
8118+
*
8119+
* **Usage**
8120+
*
8121+
* ```js
8122+
* const locator = page.getByTestId('username-input');
8123+
* await expect(locator).toHaveAccessibleErrorMessage('Username is required.');
8124+
* ```
8125+
*
8126+
* @param errorMessage Expected accessible error message.
8127+
* @param options
8128+
*/
8129+
toHaveAccessibleErrorMessage(errorMessage: string|RegExp, options?: {
8130+
/**
8131+
* Whether to perform case-insensitive match.
8132+
* [`ignoreCase`](https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-have-accessible-error-message-option-ignore-case)
8133+
* option takes precedence over the corresponding regular expression flag if specified.
8134+
*/
8135+
ignoreCase?: boolean;
8136+
8137+
/**
8138+
* Time to retry the assertion for in milliseconds. Defaults to `timeout` in `TestConfig.expect`.
8139+
*/
8140+
timeout?: number;
8141+
}): Promise<void>;
8142+
81158143
/**
81168144
* Ensures the [Locator](https://playwright.dev/docs/api/class-locator) points to an element with a given
81178145
* [accessible name](https://w3c.github.io/accname/#dfn-accessible-name).

tests/page/expect-misc.spec.ts

+130
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,136 @@ test('toHaveAccessibleDescription', async ({ page }) => {
491491
await expect(page.locator('div')).toHaveAccessibleDescription('foo bar baz');
492492
});
493493

494+
test('toHaveAccessibleErrorMessage', async ({ page }) => {
495+
await page.setContent(`
496+
<form>
497+
<input role="textbox" aria-invalid="true" aria-errormessage="error-message" />
498+
<div id="error-message">Hello</div>
499+
<div id="irrelevant-error">This should not be considered.</div>
500+
</form>
501+
`);
502+
503+
const locator = page.locator('input[role="textbox"]');
504+
await expect(locator).toHaveAccessibleErrorMessage('Hello');
505+
await expect(locator).not.toHaveAccessibleErrorMessage('hello');
506+
await expect(locator).toHaveAccessibleErrorMessage('hello', { ignoreCase: true });
507+
await expect(locator).toHaveAccessibleErrorMessage(/ell\w/);
508+
await expect(locator).not.toHaveAccessibleErrorMessage(/hello/);
509+
await expect(locator).toHaveAccessibleErrorMessage(/hello/, { ignoreCase: true });
510+
await expect(locator).not.toHaveAccessibleErrorMessage('This should not be considered.');
511+
});
512+
513+
test('toHaveAccessibleErrorMessage should handle multiple aria-errormessage references', async ({ page }) => {
514+
await page.setContent(`
515+
<form>
516+
<input role="textbox" aria-invalid="true" aria-errormessage="error1 error2" />
517+
<div id="error1">First error message.</div>
518+
<div id="error2">Second error message.</div>
519+
<div id="irrelevant-error">This should not be considered.</div>
520+
</form>
521+
`);
522+
523+
const locator = page.locator('input[role="textbox"]');
524+
525+
await expect(locator).toHaveAccessibleErrorMessage('First error message. Second error message.');
526+
await expect(locator).toHaveAccessibleErrorMessage(/first error message./i);
527+
await expect(locator).toHaveAccessibleErrorMessage(/second error message./i);
528+
await expect(locator).not.toHaveAccessibleErrorMessage(/This should not be considered./i);
529+
});
530+
531+
test.describe('toHaveAccessibleErrorMessage should handle aria-invalid attribute', () => {
532+
const errorMessageText = 'Error message';
533+
534+
async function setupPage(page, ariaInvalidValue: string | null) {
535+
const ariaInvalidAttr = ariaInvalidValue === null ? '' : `aria-invalid="${ariaInvalidValue}"`;
536+
await page.setContent(`
537+
<form>
538+
<input id="node" role="textbox" ${ariaInvalidAttr} aria-errormessage="error-msg" />
539+
<div id="error-msg">${errorMessageText}</div>
540+
</form>
541+
`);
542+
return page.locator('#node');
543+
}
544+
545+
test.describe('evaluated in false', () => {
546+
test('no aria-invalid attribute', async ({ page }) => {
547+
const locator = await setupPage(page, null);
548+
await expect(locator).not.toHaveAccessibleErrorMessage(errorMessageText);
549+
});
550+
test('aria-invalid="false"', async ({ page }) => {
551+
const locator = await setupPage(page, 'false');
552+
await expect(locator).not.toHaveAccessibleErrorMessage(errorMessageText);
553+
});
554+
test('aria-invalid="" (empty string)', async ({ page }) => {
555+
const locator = await setupPage(page, '');
556+
await expect(locator).not.toHaveAccessibleErrorMessage(errorMessageText);
557+
});
558+
});
559+
test.describe('evaluated in true', () => {
560+
test('aria-invalid="true"', async ({ page }) => {
561+
const locator = await setupPage(page, 'true');
562+
await expect(locator).toHaveAccessibleErrorMessage(errorMessageText);
563+
});
564+
test('aria-invalid="foo" (unrecognized value)', async ({ page }) => {
565+
const locator = await setupPage(page, 'foo');
566+
await expect(locator).toHaveAccessibleErrorMessage(errorMessageText);
567+
});
568+
});
569+
});
570+
571+
test.describe('toHaveAccessibleErrorMessage should handle validity state with aria-invalid', () => {
572+
const errorMessageText = 'Error message';
573+
574+
test('should show error message when validity is false and aria-invalid is true', async ({ page }) => {
575+
await page.setContent(`
576+
<form>
577+
<input id="node" role="textbox" type="number" min="1" max="100" aria-invalid="true" aria-errormessage="error-msg" />
578+
<div id="error-msg">${errorMessageText}</div>
579+
</form>
580+
`);
581+
const locator = page.locator('#node');
582+
await locator.fill('101');
583+
await expect(locator).toHaveAccessibleErrorMessage(errorMessageText);
584+
});
585+
586+
test('should show error message when validity is true and aria-invalid is true', async ({ page }) => {
587+
await page.setContent(`
588+
<form>
589+
<input id="node" role="textbox" type="number" min="1" max="100" aria-invalid="true" aria-errormessage="error-msg" />
590+
<div id="error-msg">${errorMessageText}</div>
591+
</form>
592+
`);
593+
const locator = page.locator('#node');
594+
await locator.fill('99');
595+
await expect(locator).toHaveAccessibleErrorMessage(errorMessageText);
596+
});
597+
598+
test('should show error message when validity is false and aria-invalid is false', async ({ page }) => {
599+
await page.setContent(`
600+
<form>
601+
<input id="node" role="textbox" type="number" min="1" max="100" aria-invalid="false" aria-errormessage="error-msg" />
602+
<div id="error-msg">${errorMessageText}</div>
603+
</form>
604+
`);
605+
const locator = page.locator('#node');
606+
await locator.fill('101');
607+
await expect(locator).toHaveAccessibleErrorMessage(errorMessageText);
608+
});
609+
610+
test('should not show error message when validity is true and aria-invalid is false', async ({ page }) => {
611+
await page.setContent(`
612+
<form>
613+
<input id="node" role="textbox" type="number" min="1" max="100" aria-invalid="false" aria-errormessage="error-msg" />
614+
<div id="error-msg">${errorMessageText}</div>
615+
</form>
616+
`);
617+
const locator = page.locator('#node');
618+
await locator.fill('99');
619+
await expect(locator).not.toHaveAccessibleErrorMessage(errorMessageText);
620+
});
621+
});
622+
623+
494624
test('toHaveRole', async ({ page }) => {
495625
await page.setContent(`<div role="button">Button!</div>`);
496626
await expect(page.locator('div')).toHaveRole('button');

0 commit comments

Comments
 (0)