feat: expect(locator).toHaveAccessibleErrorMessage (#33904)

This commit is contained in:
Pengoose 2024-12-27 18:54:16 +09:00 committed by GitHub
parent 3ec8ee7a9b
commit 7f141b2c42
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 281 additions and 1 deletions

View file

@ -1217,6 +1217,56 @@ Expected accessible description.
* since: v1.44 * since: v1.44
## async method: LocatorAssertions.toHaveAccessibleErrorMessage
* since: v1.50
* langs:
- alias-java: hasAccessibleErrorMessage
Ensures the [Locator] points to an element with a given [aria errormessage](https://w3c.github.io/aria/#aria-errormessage).
**Usage**
```js
const locator = page.getByTestId('username-input');
await expect(locator).toHaveAccessibleErrorMessage('Username is required.');
```
```java
Locator locator = page.getByTestId("username-input");
assertThat(locator).hasAccessibleErrorMessage("Username is required.");
```
```python async
locator = page.get_by_test_id("username-input")
await expect(locator).to_have_accessible_error_message("Username is required.")
```
```python sync
locator = page.get_by_test_id("username-input")
expect(locator).to_have_accessible_error_message("Username is required.")
```
```csharp
var locator = Page.GetByTestId("username-input");
await Expect(locator).ToHaveAccessibleErrorMessageAsync("Username is required.");
```
### param: LocatorAssertions.toHaveAccessibleErrorMessage.errorMessage
* since: v1.50
- `errorMessage` <[string]|[RegExp]>
Expected accessible error message.
### option: LocatorAssertions.toHaveAccessibleErrorMessage.timeout = %%-js-assertions-timeout-%%
* since: v1.50
### option: LocatorAssertions.toHaveAccessibleErrorMessage.timeout = %%-csharp-java-python-assertions-timeout-%%
* since: v1.50
### option: LocatorAssertions.toHaveAccessibleErrorMessage.ignoreCase = %%-assertions-ignore-case-%%
* since: v1.50
## async method: LocatorAssertions.toHaveAccessibleName ## async method: LocatorAssertions.toHaveAccessibleName
* since: v1.44 * since: v1.44
* langs: * langs:

View file

@ -29,7 +29,7 @@ import type { CSSComplexSelectorList } from '../../utils/isomorphic/cssParser';
import { generateSelector, type GenerateSelectorOptions } from './selectorGenerator'; import { generateSelector, type GenerateSelectorOptions } from './selectorGenerator';
import type * as channels from '@protocol/channels'; import type * as channels from '@protocol/channels';
import { Highlight } from './highlight'; import { Highlight } from './highlight';
import { getChecked, getAriaDisabled, getAriaRole, getElementAccessibleName, getElementAccessibleDescription, getReadonly } from './roleUtils'; import { getChecked, getAriaDisabled, getAriaRole, getElementAccessibleName, getElementAccessibleDescription, getReadonly, getElementAccessibleErrorMessage } from './roleUtils';
import { kLayoutSelectorNames, type LayoutSelectorName, layoutSelectorScore } from './layoutSelectorUtils'; import { kLayoutSelectorNames, type LayoutSelectorName, layoutSelectorScore } from './layoutSelectorUtils';
import { asLocator } from '../../utils/isomorphic/locatorGenerators'; import { asLocator } from '../../utils/isomorphic/locatorGenerators';
import type { Language } from '../../utils/isomorphic/locatorGenerators'; import type { Language } from '../../utils/isomorphic/locatorGenerators';
@ -1321,6 +1321,8 @@ export class InjectedScript {
received = getElementAccessibleName(element, false /* includeHidden */); received = getElementAccessibleName(element, false /* includeHidden */);
} else if (expression === 'to.have.accessible.description') { } else if (expression === 'to.have.accessible.description') {
received = getElementAccessibleDescription(element, false /* includeHidden */); received = getElementAccessibleDescription(element, false /* includeHidden */);
} else if (expression === 'to.have.accessible.error.message') {
received = getElementAccessibleErrorMessage(element);
} else if (expression === 'to.have.role') { } else if (expression === 'to.have.role') {
received = getAriaRole(element) || ''; received = getAriaRole(element) || '';
} else if (expression === 'to.have.title') { } else if (expression === 'to.have.title') {

View file

@ -461,6 +461,59 @@ export function getElementAccessibleDescription(element: Element, includeHidden:
return accessibleDescription; return accessibleDescription;
} }
// https://www.w3.org/TR/wai-aria-1.2/#aria-invalid
const kAriaInvalidRoles = ['application', 'checkbox', 'combobox', 'gridcell', 'listbox', 'radiogroup', 'slider', 'spinbutton', 'textbox', 'tree', 'columnheader', 'rowheader', 'searchbox', 'switch', 'treegrid'];
function getAriaInvalid(element: Element): 'false' | 'true' | 'grammar' | 'spelling' {
const role = getAriaRole(element) || '';
if (!role || !kAriaInvalidRoles.includes(role))
return 'false';
const ariaInvalid = element.getAttribute('aria-invalid');
if (!ariaInvalid || ariaInvalid.trim() === '' || ariaInvalid.toLocaleLowerCase() === 'false')
return 'false';
if (ariaInvalid === 'true' || ariaInvalid === 'grammar' || ariaInvalid === 'spelling')
return ariaInvalid;
return 'true';
}
function getValidityInvalid(element: Element) {
if ('validity' in element){
const validity = element.validity as ValidityState | undefined;
return validity?.valid === false;
}
return false;
}
export function getElementAccessibleErrorMessage(element: Element): string {
// SPEC: https://w3c.github.io/aria/#aria-errormessage
//
// TODO: support https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/validationMessage
const cache = cacheAccessibleErrorMessage;
let accessibleErrorMessage = cacheAccessibleErrorMessage?.get(element);
if (accessibleErrorMessage === undefined) {
accessibleErrorMessage = '';
const isAriaInvalid = getAriaInvalid(element) !== 'false';
const isValidityInvalid = getValidityInvalid(element);
if (isAriaInvalid || isValidityInvalid) {
const errorMessageId = element.getAttribute('aria-errormessage');
const errorMessages = getIdRefs(element, errorMessageId);
// Ideally, this should be a separate "embeddedInErrorMessage", but it would follow the exact same rules.
// Relevant vague spec: https://w3c.github.io/core-aam/#ariaErrorMessage.
const parts = errorMessages.map(errorMessage => asFlatString(
getTextAlternativeInternal(errorMessage, {
visitedElements: new Set(),
embeddedInDescribedBy: { element: errorMessage, hidden: isElementHiddenForAria(errorMessage) },
})
));
accessibleErrorMessage = parts.join(' ').trim();
}
cache?.set(element, accessibleErrorMessage);
}
return accessibleErrorMessage;
}
type AccessibleNameOptions = { type AccessibleNameOptions = {
visitedElements: Set<Element>, visitedElements: Set<Element>,
includeHidden?: boolean, includeHidden?: boolean,
@ -972,6 +1025,7 @@ let cacheAccessibleName: Map<Element, string> | undefined;
let cacheAccessibleNameHidden: Map<Element, string> | undefined; let cacheAccessibleNameHidden: Map<Element, string> | undefined;
let cacheAccessibleDescription: Map<Element, string> | undefined; let cacheAccessibleDescription: Map<Element, string> | undefined;
let cacheAccessibleDescriptionHidden: Map<Element, string> | undefined; let cacheAccessibleDescriptionHidden: Map<Element, string> | undefined;
let cacheAccessibleErrorMessage: Map<Element, string> | undefined;
let cacheIsHidden: Map<Element, boolean> | undefined; let cacheIsHidden: Map<Element, boolean> | undefined;
let cachePseudoContentBefore: Map<Element, string> | undefined; let cachePseudoContentBefore: Map<Element, string> | undefined;
let cachePseudoContentAfter: Map<Element, string> | undefined; let cachePseudoContentAfter: Map<Element, string> | undefined;
@ -983,6 +1037,7 @@ export function beginAriaCaches() {
cacheAccessibleNameHidden ??= new Map(); cacheAccessibleNameHidden ??= new Map();
cacheAccessibleDescription ??= new Map(); cacheAccessibleDescription ??= new Map();
cacheAccessibleDescriptionHidden ??= new Map(); cacheAccessibleDescriptionHidden ??= new Map();
cacheAccessibleErrorMessage ??= new Map();
cacheIsHidden ??= new Map(); cacheIsHidden ??= new Map();
cachePseudoContentBefore ??= new Map(); cachePseudoContentBefore ??= new Map();
cachePseudoContentAfter ??= new Map(); cachePseudoContentAfter ??= new Map();
@ -994,6 +1049,7 @@ export function endAriaCaches() {
cacheAccessibleNameHidden = undefined; cacheAccessibleNameHidden = undefined;
cacheAccessibleDescription = undefined; cacheAccessibleDescription = undefined;
cacheAccessibleDescriptionHidden = undefined; cacheAccessibleDescriptionHidden = undefined;
cacheAccessibleErrorMessage = undefined;
cacheIsHidden = undefined; cacheIsHidden = undefined;
cachePseudoContentBefore = undefined; cachePseudoContentBefore = undefined;
cachePseudoContentAfter = undefined; cachePseudoContentAfter = undefined;

View file

@ -35,6 +35,7 @@ import {
toContainText, toContainText,
toHaveAccessibleDescription, toHaveAccessibleDescription,
toHaveAccessibleName, toHaveAccessibleName,
toHaveAccessibleErrorMessage,
toHaveAttribute, toHaveAttribute,
toHaveClass, toHaveClass,
toHaveCount, toHaveCount,
@ -224,6 +225,7 @@ const customAsyncMatchers = {
toContainText, toContainText,
toHaveAccessibleDescription, toHaveAccessibleDescription,
toHaveAccessibleName, toHaveAccessibleName,
toHaveAccessibleErrorMessage,
toHaveAttribute, toHaveAttribute,
toHaveClass, toHaveClass,
toHaveCount, toHaveCount,

View file

@ -205,6 +205,18 @@ export function toHaveAccessibleName(
} }
} }
export function toHaveAccessibleErrorMessage(
this: ExpectMatcherState,
locator: LocatorEx,
expected: string | RegExp,
options?: { timeout?: number; ignoreCase?: boolean },
) {
return toMatchText.call(this, 'toHaveAccessibleErrorMessage', locator, 'Locator', async (isNot, timeout) => {
const expectedText = serializeExpectedTextValues([expected], { ignoreCase: options?.ignoreCase, normalizeWhiteSpace: true });
return await locator._expect('to.have.accessible.error.message', { expectedText: expectedText, isNot, timeout });
}, expected, options);
}
export function toHaveAttribute( export function toHaveAttribute(
this: ExpectMatcherState, this: ExpectMatcherState,
locator: LocatorEx, locator: LocatorEx,

View file

@ -8112,6 +8112,34 @@ interface LocatorAssertions {
timeout?: number; timeout?: number;
}): Promise<void>; }): Promise<void>;
/**
* Ensures the [Locator](https://playwright.dev/docs/api/class-locator) points to an element with a given
* [aria errormessage](https://w3c.github.io/aria/#aria-errormessage).
*
* **Usage**
*
* ```js
* const locator = page.getByTestId('username-input');
* await expect(locator).toHaveAccessibleErrorMessage('Username is required.');
* ```
*
* @param errorMessage Expected accessible error message.
* @param options
*/
toHaveAccessibleErrorMessage(errorMessage: string|RegExp, options?: {
/**
* Whether to perform case-insensitive match.
* [`ignoreCase`](https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-have-accessible-error-message-option-ignore-case)
* option takes precedence over the corresponding regular expression flag if specified.
*/
ignoreCase?: boolean;
/**
* Time to retry the assertion for in milliseconds. Defaults to `timeout` in `TestConfig.expect`.
*/
timeout?: number;
}): Promise<void>;
/** /**
* Ensures the [Locator](https://playwright.dev/docs/api/class-locator) points to an element with a given * Ensures the [Locator](https://playwright.dev/docs/api/class-locator) points to an element with a given
* [accessible name](https://w3c.github.io/accname/#dfn-accessible-name). * [accessible name](https://w3c.github.io/accname/#dfn-accessible-name).

View file

@ -491,6 +491,136 @@ test('toHaveAccessibleDescription', async ({ page }) => {
await expect(page.locator('div')).toHaveAccessibleDescription('foo bar baz'); await expect(page.locator('div')).toHaveAccessibleDescription('foo bar baz');
}); });
test('toHaveAccessibleErrorMessage', async ({ page }) => {
await page.setContent(`
<form>
<input role="textbox" aria-invalid="true" aria-errormessage="error-message" />
<div id="error-message">Hello</div>
<div id="irrelevant-error">This should not be considered.</div>
</form>
`);
const locator = page.locator('input[role="textbox"]');
await expect(locator).toHaveAccessibleErrorMessage('Hello');
await expect(locator).not.toHaveAccessibleErrorMessage('hello');
await expect(locator).toHaveAccessibleErrorMessage('hello', { ignoreCase: true });
await expect(locator).toHaveAccessibleErrorMessage(/ell\w/);
await expect(locator).not.toHaveAccessibleErrorMessage(/hello/);
await expect(locator).toHaveAccessibleErrorMessage(/hello/, { ignoreCase: true });
await expect(locator).not.toHaveAccessibleErrorMessage('This should not be considered.');
});
test('toHaveAccessibleErrorMessage should handle multiple aria-errormessage references', async ({ page }) => {
await page.setContent(`
<form>
<input role="textbox" aria-invalid="true" aria-errormessage="error1 error2" />
<div id="error1">First error message.</div>
<div id="error2">Second error message.</div>
<div id="irrelevant-error">This should not be considered.</div>
</form>
`);
const locator = page.locator('input[role="textbox"]');
await expect(locator).toHaveAccessibleErrorMessage('First error message. Second error message.');
await expect(locator).toHaveAccessibleErrorMessage(/first error message./i);
await expect(locator).toHaveAccessibleErrorMessage(/second error message./i);
await expect(locator).not.toHaveAccessibleErrorMessage(/This should not be considered./i);
});
test.describe('toHaveAccessibleErrorMessage should handle aria-invalid attribute', () => {
const errorMessageText = 'Error message';
async function setupPage(page, ariaInvalidValue: string | null) {
const ariaInvalidAttr = ariaInvalidValue === null ? '' : `aria-invalid="${ariaInvalidValue}"`;
await page.setContent(`
<form>
<input id="node" role="textbox" ${ariaInvalidAttr} aria-errormessage="error-msg" />
<div id="error-msg">${errorMessageText}</div>
</form>
`);
return page.locator('#node');
}
test.describe('evaluated in false', () => {
test('no aria-invalid attribute', async ({ page }) => {
const locator = await setupPage(page, null);
await expect(locator).not.toHaveAccessibleErrorMessage(errorMessageText);
});
test('aria-invalid="false"', async ({ page }) => {
const locator = await setupPage(page, 'false');
await expect(locator).not.toHaveAccessibleErrorMessage(errorMessageText);
});
test('aria-invalid="" (empty string)', async ({ page }) => {
const locator = await setupPage(page, '');
await expect(locator).not.toHaveAccessibleErrorMessage(errorMessageText);
});
});
test.describe('evaluated in true', () => {
test('aria-invalid="true"', async ({ page }) => {
const locator = await setupPage(page, 'true');
await expect(locator).toHaveAccessibleErrorMessage(errorMessageText);
});
test('aria-invalid="foo" (unrecognized value)', async ({ page }) => {
const locator = await setupPage(page, 'foo');
await expect(locator).toHaveAccessibleErrorMessage(errorMessageText);
});
});
});
test.describe('toHaveAccessibleErrorMessage should handle validity state with aria-invalid', () => {
const errorMessageText = 'Error message';
test('should show error message when validity is false and aria-invalid is true', async ({ page }) => {
await page.setContent(`
<form>
<input id="node" role="textbox" type="number" min="1" max="100" aria-invalid="true" aria-errormessage="error-msg" />
<div id="error-msg">${errorMessageText}</div>
</form>
`);
const locator = page.locator('#node');
await locator.fill('101');
await expect(locator).toHaveAccessibleErrorMessage(errorMessageText);
});
test('should show error message when validity is true and aria-invalid is true', async ({ page }) => {
await page.setContent(`
<form>
<input id="node" role="textbox" type="number" min="1" max="100" aria-invalid="true" aria-errormessage="error-msg" />
<div id="error-msg">${errorMessageText}</div>
</form>
`);
const locator = page.locator('#node');
await locator.fill('99');
await expect(locator).toHaveAccessibleErrorMessage(errorMessageText);
});
test('should show error message when validity is false and aria-invalid is false', async ({ page }) => {
await page.setContent(`
<form>
<input id="node" role="textbox" type="number" min="1" max="100" aria-invalid="false" aria-errormessage="error-msg" />
<div id="error-msg">${errorMessageText}</div>
</form>
`);
const locator = page.locator('#node');
await locator.fill('101');
await expect(locator).toHaveAccessibleErrorMessage(errorMessageText);
});
test('should not show error message when validity is true and aria-invalid is false', async ({ page }) => {
await page.setContent(`
<form>
<input id="node" role="textbox" type="number" min="1" max="100" aria-invalid="false" aria-errormessage="error-msg" />
<div id="error-msg">${errorMessageText}</div>
</form>
`);
const locator = page.locator('#node');
await locator.fill('99');
await expect(locator).not.toHaveAccessibleErrorMessage(errorMessageText);
});
});
test('toHaveRole', async ({ page }) => { test('toHaveRole', async ({ page }) => {
await page.setContent(`<div role="button">Button!</div>`); await page.setContent(`<div role="button">Button!</div>`);
await expect(page.locator('div')).toHaveRole('button'); await expect(page.locator('div')).toHaveRole('button');