From 6d20da568e8123dc657abeffce8714af861b89f1 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Thu, 25 Apr 2024 15:26:10 -0700 Subject: [PATCH] feat: expect(locator).toHaveRole(role) (#30555) References #13517. Fixes #18332. --- docs/src/api/class-frame.md | 3 +- docs/src/api/class-framelocator.md | 3 +- docs/src/api/class-locator.md | 3 +- docs/src/api/class-locatorassertions.md | 64 +++++++++++++++++++ docs/src/api/class-page.md | 3 +- docs/src/api/params.md | 3 +- .../src/test-assertions-csharp-java-python.md | 3 + docs/src/test-assertions-js.md | 3 + .../src/server/injected/injectedScript.ts | 2 + packages/playwright/src/matchers/expect.ts | 2 + packages/playwright/src/matchers/matchers.ts | 16 ++++- packages/playwright/types/test.d.ts | 24 +++++++ tests/page/expect-misc.spec.ts | 13 ++++ 13 files changed, 135 insertions(+), 7 deletions(-) diff --git a/docs/src/api/class-frame.md b/docs/src/api/class-frame.md index 811191aaba..d9d2b92d07 100644 --- a/docs/src/api/class-frame.md +++ b/docs/src/api/class-frame.md @@ -1030,7 +1030,8 @@ Attribute name to get the value for. %%-template-locator-get-by-role-%% -### param: Frame.getByRole.role = %%-locator-get-by-role-role-%% +### param: Frame.getByRole.role = %%-get-by-role-to-have-role-role-%% +* since: v1.27 ### option: Frame.getByRole.-inline- = %%-locator-get-by-role-option-list-v1.27-%% * since: v1.27 diff --git a/docs/src/api/class-framelocator.md b/docs/src/api/class-framelocator.md index 320ad36ba2..9851a35f96 100644 --- a/docs/src/api/class-framelocator.md +++ b/docs/src/api/class-framelocator.md @@ -133,7 +133,8 @@ in that iframe. %%-template-locator-get-by-role-%% -### param: FrameLocator.getByRole.role = %%-locator-get-by-role-role-%% +### param: FrameLocator.getByRole.role = %%-get-by-role-to-have-role-role-%% +* since: v1.27 ### option: FrameLocator.getByRole.-inline- = %%-locator-get-by-role-option-list-v1.27-%% * since: v1.27 diff --git a/docs/src/api/class-locator.md b/docs/src/api/class-locator.md index 9ccfa6c2b1..d12f368807 100644 --- a/docs/src/api/class-locator.md +++ b/docs/src/api/class-locator.md @@ -1173,7 +1173,8 @@ Attribute name to get the value for. %%-template-locator-get-by-role-%% -### param: Locator.getByRole.role = %%-locator-get-by-role-role-%% +### param: Locator.getByRole.role = %%-get-by-role-to-have-role-role-%% +* since: v1.27 ### option: Locator.getByRole.-inline- = %%-locator-get-by-role-option-list-v1.27-%% * since: v1.27 diff --git a/docs/src/api/class-locatorassertions.md b/docs/src/api/class-locatorassertions.md index 72bad1940a..823b928a40 100644 --- a/docs/src/api/class-locatorassertions.md +++ b/docs/src/api/class-locatorassertions.md @@ -373,6 +373,23 @@ Property value. ### option: LocatorAssertions.NotToHaveJSProperty.timeout = %%-csharp-java-python-assertions-timeout-%% * since: v1.18 + +## async method: LocatorAssertions.NotToHaveRole +* since: v1.44 +* langs: python + +The opposite of [`method: LocatorAssertions.toHaveRole`]. + +### param: LocatorAssertions.NotToHaveRole.name +* since: v1.44 +- `name` <[string]|[RegExp]> + +Expected accessible name. + +### option: LocatorAssertions.NotToHaveRole.timeout = %%-csharp-java-python-assertions-timeout-%% +* since: v1.44 + + ## async method: LocatorAssertions.NotToHaveText * since: v1.20 * langs: python @@ -1629,6 +1646,53 @@ Property value. ### option: LocatorAssertions.toHaveJSProperty.timeout = %%-csharp-java-python-assertions-timeout-%% * since: v1.18 + +## async method: LocatorAssertions.toHaveRole +* since: v1.44 +* langs: + - alias-java: hasRole + +Ensures the [Locator] points to an element with a given [ARIA role](https://www.w3.org/TR/wai-aria-1.2/#roles). + +Note that role is matched as a string, disregarding the ARIA role hierarchy. For example, asserting a superclass role `"checkbox"` on an element with a subclass role `"switch"` will fail. + +**Usage** + +```js +const locator = page.getByTestId('save-button'); +await expect(locator).toHaveRole('button'); +``` + +```java +Locator locator = page.getByTestId("save-button"); +assertThat(locator).hasRole(AriaRole.BUTTON); +``` + +```python async +locator = page.get_by_test_id("save-button") +await expect(locator).to_have_role("button") +``` + +```python sync +locator = page.get_by_test_id("save-button") +expect(locator).to_have_role("button") +``` + +```csharp +var locator = Page.GetByTestId("save-button"); +await Expect(locator).ToHaveRoleAsync(AriaRole.Button); +``` + +### param: LocatorAssertions.toHaveRole.role = %%-get-by-role-to-have-role-role-%% +* since: v1.44 + +### option: LocatorAssertions.toHaveRole.timeout = %%-js-assertions-timeout-%% +* since: v1.44 + +### option: LocatorAssertions.toHaveRole.timeout = %%-csharp-java-python-assertions-timeout-%% +* since: v1.44 + + ## async method: LocatorAssertions.toHaveScreenshot#1 * since: v1.23 * langs: js diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md index 85e5af5929..d4183b4c62 100644 --- a/docs/src/api/class-page.md +++ b/docs/src/api/class-page.md @@ -2340,7 +2340,8 @@ Attribute name to get the value for. %%-template-locator-get-by-role-%% -### param: Page.getByRole.role = %%-locator-get-by-role-role-%% +### param: Page.getByRole.role = %%-get-by-role-to-have-role-role-%% +* since: v1.27 ### option: Page.getByRole.-inline- = %%-locator-get-by-role-option-list-v1.27-%% * since: v1.27 diff --git a/docs/src/api/params.md b/docs/src/api/params.md index 9bc70c1232..2d70488867 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -1203,8 +1203,7 @@ Text to locate the element for. Whether to find an exact match: case-sensitive and whole-string. Default to false. Ignored when locating by a regular expression. Note that exact match still trims whitespace. -## locator-get-by-role-role -* since: v1.27 +## get-by-role-to-have-role-role - `role` <[AriaRole]<"alert"|"alertdialog"|"application"|"article"|"banner"|"blockquote"|"button"|"caption"|"cell"|"checkbox"|"code"|"columnheader"|"combobox"|"complementary"|"contentinfo"|"definition"|"deletion"|"dialog"|"directory"|"document"|"emphasis"|"feed"|"figure"|"form"|"generic"|"grid"|"gridcell"|"group"|"heading"|"img"|"insertion"|"link"|"list"|"listbox"|"listitem"|"log"|"main"|"marquee"|"math"|"meter"|"menu"|"menubar"|"menuitem"|"menuitemcheckbox"|"menuitemradio"|"navigation"|"none"|"note"|"option"|"paragraph"|"presentation"|"progressbar"|"radio"|"radiogroup"|"region"|"row"|"rowgroup"|"rowheader"|"scrollbar"|"search"|"searchbox"|"separator"|"slider"|"spinbutton"|"status"|"strong"|"subscript"|"superscript"|"switch"|"tab"|"table"|"tablist"|"tabpanel"|"term"|"textbox"|"time"|"timer"|"toolbar"|"tooltip"|"tree"|"treegrid"|"treeitem">> Required aria role. diff --git a/docs/src/test-assertions-csharp-java-python.md b/docs/src/test-assertions-csharp-java-python.md index bc695ee46c..2ab57e40b2 100644 --- a/docs/src/test-assertions-csharp-java-python.md +++ b/docs/src/test-assertions-csharp-java-python.md @@ -18,12 +18,15 @@ title: "Assertions" | [`method: LocatorAssertions.toBeInViewport`] | Element intersects viewport | | [`method: LocatorAssertions.toBeVisible`] | Element is visible | | [`method: LocatorAssertions.toContainText`] | Element contains text | +| [`method: LocatorAssertions.toHaveAccessibleDescription`] | Element has a matching [accessible description](https://w3c.github.io/accname/#dfn-accessible-description) | +| [`method: LocatorAssertions.toHaveAccessibleName`] | Element has a matching [accessible name](https://w3c.github.io/accname/#dfn-accessible-name) | | [`method: LocatorAssertions.toHaveAttribute`] | Element has a DOM attribute | | [`method: LocatorAssertions.toHaveClass`] | Element has a class property | | [`method: LocatorAssertions.toHaveCount`] | List has exact number of children | | [`method: LocatorAssertions.toHaveCSS`] | Element has CSS property | | [`method: LocatorAssertions.toHaveId`] | Element has an ID | | [`method: LocatorAssertions.toHaveJSProperty`] | Element has a JavaScript property | +| [`method: LocatorAssertions.toHaveRole`] | Element has a specific [ARIA role](https://www.w3.org/TR/wai-aria-1.2/#roles) | | [`method: LocatorAssertions.toHaveText`] | Element matches text | | [`method: LocatorAssertions.toHaveValue`] | Input has a value | | [`method: LocatorAssertions.toHaveValues`] | Select has options selected | diff --git a/docs/src/test-assertions-js.md b/docs/src/test-assertions-js.md index 2d7a0b0332..4745659cf9 100644 --- a/docs/src/test-assertions-js.md +++ b/docs/src/test-assertions-js.md @@ -40,12 +40,15 @@ Note that retrying assertions are async, so you must `await` them. | [await expect(locator).toBeInViewport()](./api/class-locatorassertions.md#locator-assertions-to-be-in-viewport) | Element intersects viewport | | [await expect(locator).toBeVisible()](./api/class-locatorassertions.md#locator-assertions-to-be-visible) | Element is visible | | [await expect(locator).toContainText()](./api/class-locatorassertions.md#locator-assertions-to-contain-text) | Element contains text | +| [await expect(locator).toHaveAccessibleDescription()](./api/class-locatorassertions.md#locator-assertions-to-have-accessible-description) | Element has a matching [accessible description](https://w3c.github.io/accname/#dfn-accessible-description) | +| [await expect(locator).toHaveAccessibleName()](./api/class-locatorassertions.md#locator-assertions-to-have-accessible-name) | Element has a matching [accessible name](https://w3c.github.io/accname/#dfn-accessible-name) | | [await expect(locator).toHaveAttribute()](./api/class-locatorassertions.md#locator-assertions-to-have-attribute) | Element has a DOM attribute | | [await expect(locator).toHaveClass()](./api/class-locatorassertions.md#locator-assertions-to-have-class) | Element has a class property | | [await expect(locator).toHaveCount()](./api/class-locatorassertions.md#locator-assertions-to-have-count) | List has exact number of children | | [await expect(locator).toHaveCSS()](./api/class-locatorassertions.md#locator-assertions-to-have-css) | Element has CSS property | | [await expect(locator).toHaveId()](./api/class-locatorassertions.md#locator-assertions-to-have-id) | Element has an ID | | [await expect(locator).toHaveJSProperty()](./api/class-locatorassertions.md#locator-assertions-to-have-js-property) | Element has a JavaScript property | +| [await expect(locator).toHaveRole()](./api/class-locatorassertions.md#locator-assertions-to-have-role) | Element has a specific [ARIA role](https://www.w3.org/TR/wai-aria-1.2/#roles) | | [await expect(locator).toHaveScreenshot()](./api/class-locatorassertions.md#locator-assertions-to-have-screenshot-1) | Element has a screenshot | | [await expect(locator).toHaveText()](./api/class-locatorassertions.md#locator-assertions-to-have-text) | Element matches text | | [await expect(locator).toHaveValue()](./api/class-locatorassertions.md#locator-assertions-to-have-value) | Input has a value | diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index 6f829a7924..c988143a8f 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -1227,6 +1227,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.role') { + received = getAriaRole(element) || ''; } else if (expression === 'to.have.title') { received = this.document.title; } else if (expression === 'to.have.url') { diff --git a/packages/playwright/src/matchers/expect.ts b/packages/playwright/src/matchers/expect.ts index e1ce5470f0..5d51a20b5e 100644 --- a/packages/playwright/src/matchers/expect.ts +++ b/packages/playwright/src/matchers/expect.ts @@ -40,6 +40,7 @@ import { toHaveCSS, toHaveId, toHaveJSProperty, + toHaveRole, toHaveText, toHaveTitle, toHaveURL, @@ -195,6 +196,7 @@ const customAsyncMatchers = { toHaveCSS, toHaveId, toHaveJSProperty, + toHaveRole, toHaveText, toHaveTitle, toHaveURL, diff --git a/packages/playwright/src/matchers/matchers.ts b/packages/playwright/src/matchers/matchers.ts index 5145674673..505fd24351 100644 --- a/packages/playwright/src/matchers/matchers.ts +++ b/packages/playwright/src/matchers/matchers.ts @@ -21,7 +21,7 @@ import { expectTypes, callLogText } from '../util'; import { toBeTruthy } from './toBeTruthy'; import { toEqual } from './toEqual'; import { toExpectedTextValues, toMatchText } from './toMatchText'; -import { constructURLBasedOnBaseURL, isRegExp, isTextualMimeType, pollAgainstDeadline } from 'playwright-core/lib/utils'; +import { constructURLBasedOnBaseURL, isRegExp, isString, isTextualMimeType, pollAgainstDeadline } from 'playwright-core/lib/utils'; import { currentTestInfo } from '../common/globals'; import { TestInfoImpl } from '../worker/testInfo'; import type { ExpectMatcherContext } from './expect'; @@ -290,6 +290,20 @@ export function toHaveJSProperty( }, expected, options); } +export function toHaveRole( + this: ExpectMatcherContext, + locator: LocatorEx, + expected: string, + options?: { timeout?: number, ignoreCase?: boolean }, +) { + if (!isString(expected)) + throw new Error(`"role" argument in toHaveRole must be a string`); + return toMatchText.call(this, 'toHaveRole', locator, 'Locator', async (isNot, timeout) => { + const expectedText = toExpectedTextValues([expected]); + return await locator._expect('to.have.role', { expectedText, isNot, timeout }); + }, expected, options); +} + export function toHaveText( this: ExpectMatcherContext, locator: LocatorEx, diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index 635356f9c7..2440fbe556 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -7141,6 +7141,30 @@ interface LocatorAssertions { timeout?: number; }): Promise; + /** + * Ensures the {@link Locator} points to an element with a given + * [ARIA role](https://www.w3.org/TR/wai-aria-1.2/#roles). + * + * Note that role is matched as a string, disregarding the ARIA role hierarchy. For example, asserting a superclass + * role `"checkbox"` on an element with a subclass role `"switch"` will fail. + * + * **Usage** + * + * ```js + * const locator = page.getByTestId('save-button'); + * await expect(locator).toHaveRole('button'); + * ``` + * + * @param role Required aria role. + * @param options + */ + toHaveRole(role: "alert"|"alertdialog"|"application"|"article"|"banner"|"blockquote"|"button"|"caption"|"cell"|"checkbox"|"code"|"columnheader"|"combobox"|"complementary"|"contentinfo"|"definition"|"deletion"|"dialog"|"directory"|"document"|"emphasis"|"feed"|"figure"|"form"|"generic"|"grid"|"gridcell"|"group"|"heading"|"img"|"insertion"|"link"|"list"|"listbox"|"listitem"|"log"|"main"|"marquee"|"math"|"meter"|"menu"|"menubar"|"menuitem"|"menuitemcheckbox"|"menuitemradio"|"navigation"|"none"|"note"|"option"|"paragraph"|"presentation"|"progressbar"|"radio"|"radiogroup"|"region"|"row"|"rowgroup"|"rowheader"|"scrollbar"|"search"|"searchbox"|"separator"|"slider"|"spinbutton"|"status"|"strong"|"subscript"|"superscript"|"switch"|"tab"|"table"|"tablist"|"tabpanel"|"term"|"textbox"|"time"|"timer"|"toolbar"|"tooltip"|"tree"|"treegrid"|"treeitem", options?: { + /** + * Time to retry the assertion for in milliseconds. Defaults to `timeout` in `TestConfig.expect`. + */ + timeout?: number; + }): Promise; + /** * This function will wait until two consecutive locator screenshots yield the same result, and then compare the last * screenshot with the expectation. diff --git a/tests/page/expect-misc.spec.ts b/tests/page/expect-misc.spec.ts index 66e09a51df..36aa641510 100644 --- a/tests/page/expect-misc.spec.ts +++ b/tests/page/expect-misc.spec.ts @@ -443,3 +443,16 @@ test('toHaveAccessibleDescription', async ({ page }) => { await expect(page.locator('div')).not.toHaveAccessibleDescription(/hello/); await expect(page.locator('div')).toHaveAccessibleDescription(/hello/, { ignoreCase: true }); }); + +test('toHaveRole', async ({ page }) => { + await page.setContent(`
Button!
`); + await expect(page.locator('div')).toHaveRole('button'); + await expect(page.locator('div')).not.toHaveRole('checkbox'); + try { + // @ts-expect-error + await expect(page.locator('div')).toHaveRole(/button|checkbox/); + expect(1, 'Must throw when given a regular expression').toBe(2); + } catch (error) { + expect(error.message).toBe(`"role" argument in toHaveRole must be a string`); + } +});