diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index 74ff799ff1ca7..2dee5412a2121 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -29,7 +29,7 @@ import type { CSSComplexSelectorList } from '../../utils/isomorphic/cssParser'; import { generateSelector, type GenerateSelectorOptions } from './selectorGenerator'; import type * as channels from '@protocol/channels'; 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 { asLocator } from '../../utils/isomorphic/locatorGenerators'; import type { Language } from '../../utils/isomorphic/locatorGenerators'; @@ -1320,6 +1320,8 @@ export class InjectedScript { received = getElementAccessibleName(element, false /* includeHidden */); } else if (expression === 'to.have.accessible.description') { received = getElementAccessibleDescription(element, false /* includeHidden */); + } else if (expression === 'to.have.accessible.error.message') { + received = getElementAccessibleErrorMessage(element, false /* includeHidden */); } else if (expression === 'to.have.role') { received = getAriaRole(element) || ''; } else if (expression === 'to.have.title') { diff --git a/packages/playwright-core/src/server/injected/roleUtils.ts b/packages/playwright-core/src/server/injected/roleUtils.ts index cc9e6a70d08ef..5aa356be9e175 100644 --- a/packages/playwright-core/src/server/injected/roleUtils.ts +++ b/packages/playwright-core/src/server/injected/roleUtils.ts @@ -461,6 +461,38 @@ export function getElementAccessibleDescription(element: Element, includeHidden: return accessibleDescription; } +export function getElementAccessibleErrorMessage(element: Element, includeHidden: boolean): string { + const cache = includeHidden ? cacheAccessibleErrorMessageHidden : cacheAccessibleErrorMessage; + let accessibleErrorMessage = cache?.get(element); + + if (accessibleErrorMessage === undefined) { + accessibleErrorMessage = ''; + + const ariaInvalid = element.getAttribute('aria-invalid'); + if (ariaInvalid === 'true') { + const errorMessageId = element.getAttribute('aria-errormessage'); + if (errorMessageId) { + // Ensure the ID is valid (no whitespace) + if (!/\s+/.test(errorMessageId)) { + // Retrieve the element referenced by aria-errormessage. + const errorElement = element.ownerDocument.getElementById(errorMessageId); + if (errorElement) { + accessibleErrorMessage = asFlatString( + getTextAlternativeInternal(errorElement, { + includeHidden, + visitedElements: new Set(), + embeddedInDescribedBy: { element: errorElement, hidden: isElementHiddenForAria(errorElement) }, + }) + ); + } + } + } + } + cache?.set(element, accessibleErrorMessage); + } + return accessibleErrorMessage; +} + type AccessibleNameOptions = { visitedElements: Set, includeHidden?: boolean, @@ -972,6 +1004,8 @@ let cacheAccessibleName: Map | undefined; let cacheAccessibleNameHidden: Map | undefined; let cacheAccessibleDescription: Map | undefined; let cacheAccessibleDescriptionHidden: Map | undefined; +let cacheAccessibleErrorMessage: Map | undefined; +let cacheAccessibleErrorMessageHidden: Map | undefined; let cacheIsHidden: Map | undefined; let cachePseudoContentBefore: Map | undefined; let cachePseudoContentAfter: Map | undefined; diff --git a/packages/playwright/src/matchers/expect.ts b/packages/playwright/src/matchers/expect.ts index 0bd116e7a1f62..d4c3287d33ae4 100644 --- a/packages/playwright/src/matchers/expect.ts +++ b/packages/playwright/src/matchers/expect.ts @@ -35,6 +35,7 @@ import { toContainText, toHaveAccessibleDescription, toHaveAccessibleName, + toHaveAccessibleErrorMessage, toHaveAttribute, toHaveClass, toHaveCount, @@ -224,6 +225,7 @@ const customAsyncMatchers = { toContainText, toHaveAccessibleDescription, toHaveAccessibleName, + toHaveAccessibleErrorMessage, toHaveAttribute, toHaveClass, toHaveCount, diff --git a/packages/playwright/src/matchers/matchers.ts b/packages/playwright/src/matchers/matchers.ts index 8a8089e91e20f..c9f3a8f3deed7 100644 --- a/packages/playwright/src/matchers/matchers.ts +++ b/packages/playwright/src/matchers/matchers.ts @@ -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 }); + return await locator._expect('to.have.accessible.error.message', { expectedText: expectedText, isNot, timeout }); + }, expected, options); +} + export function toHaveAttribute( this: ExpectMatcherState, locator: LocatorEx,