From a7b2b04588390988df4ea18d5755dad84b02465b Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Fri, 11 Nov 2022 15:58:36 -0800 Subject: [PATCH] fix(getByRole): name and exact (#18719) Following the `getByText()` and other methods: - By default, matching is substring and case-insensitive. Before, it was only case-insensitive, but not substring. - With new option `exact: true`, matching is full string and case-sensitive. - Matching always normalizes whitespace. - Codegen generates `exact: false` by default. - `internal:role` treats `[name="foo"i]` as non-exact match. Various fixes: - Updated `getByRole` docs to match the reality. - Locator generator edge cases. --- docs/src/api/class-frame.md | 1 + docs/src/api/class-framelocator.md | 1 + docs/src/api/class-locator.md | 1 + docs/src/api/class-page.md | 1 + docs/src/api/params.md | 22 +-- .../src/server/injected/injectedScript.ts | 6 +- .../src/server/injected/roleSelectorEngine.ts | 17 ++- .../src/server/injected/selectorGenerator.ts | 4 +- .../server/isomorphic/locatorGenerators.ts | 126 ++++++++++++------ .../src/utils/isomorphic/locatorUtils.ts | 3 +- packages/playwright-core/types/types.d.ts | 88 +++++++----- tests/library/debug-controller.spec.ts | 4 +- tests/library/locator-generator.spec.ts | 37 ++++- tests/library/selector-generator.spec.ts | 6 +- tests/page/selectors-get-by.spec.ts | 33 +++++ tests/page/selectors-role.spec.ts | 25 ++-- 16 files changed, 264 insertions(+), 111 deletions(-) diff --git a/docs/src/api/class-frame.md b/docs/src/api/class-frame.md index 347d75584c..504f3aa4ac 100644 --- a/docs/src/api/class-frame.md +++ b/docs/src/api/class-frame.md @@ -949,6 +949,7 @@ Attribute name to get the value for. ### param: Frame.getByRole.role = %%-locator-get-by-role-role-%% ### option: Frame.getByRole.-inline- = %%-locator-get-by-role-option-list-v1.27-%% * since: v1.27 +### option: Frame.getByRole.exact = %%-locator-get-by-role-option-exact-%% ## method: Frame.getByTestId diff --git a/docs/src/api/class-framelocator.md b/docs/src/api/class-framelocator.md index 81332862ba..6b643b36f7 100644 --- a/docs/src/api/class-framelocator.md +++ b/docs/src/api/class-framelocator.md @@ -153,6 +153,7 @@ in that iframe. ### param: FrameLocator.getByRole.role = %%-locator-get-by-role-role-%% ### option: FrameLocator.getByRole.-inline- = %%-locator-get-by-role-option-list-v1.27-%% * since: v1.27 +### option: FrameLocator.getByRole.exact = %%-locator-get-by-role-option-exact-%% ## method: FrameLocator.getByTestId diff --git a/docs/src/api/class-locator.md b/docs/src/api/class-locator.md index a16277fbde..1a7b0ca416 100644 --- a/docs/src/api/class-locator.md +++ b/docs/src/api/class-locator.md @@ -695,6 +695,7 @@ Attribute name to get the value for. ### param: Locator.getByRole.role = %%-locator-get-by-role-role-%% ### option: Locator.getByRole.-inline- = %%-locator-get-by-role-option-list-v1.27-%% * since: v1.27 +### option: Locator.getByRole.exact = %%-locator-get-by-role-option-exact-%% ## method: Locator.getByTestId diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md index 7535cf7507..08daf543a4 100644 --- a/docs/src/api/class-page.md +++ b/docs/src/api/class-page.md @@ -2222,6 +2222,7 @@ Attribute name to get the value for. ### param: Page.getByRole.role = %%-locator-get-by-role-role-%% ### option: Page.getByRole.-inline- = %%-locator-get-by-role-option-list-v1.27-%% * since: v1.27 +### option: Page.getByRole.exact = %%-locator-get-by-role-option-exact-%% ## method: Page.getByTestId diff --git a/docs/src/api/params.md b/docs/src/api/params.md index 8fef217fed..cac56703c1 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -1109,7 +1109,7 @@ Required aria role. * since: v1.27 - `checked` <[boolean]> -An attribute that is usually set by `aria-checked` or native `` controls. Available values for checked are `true`, `false` and `"mixed"`. +An attribute that is usually set by `aria-checked` or native `` controls. Learn more about [`aria-checked`](https://www.w3.org/TR/wai-aria-1.2/#aria-checked). @@ -1117,7 +1117,7 @@ Learn more about [`aria-checked`](https://www.w3.org/TR/wai-aria-1.2/#aria-check * since: v1.27 - `disabled` <[boolean]> -A boolean attribute that is usually set by `aria-disabled` or `disabled`. +An attribute that is usually set by `aria-disabled` or `disabled`. :::note Unlike most other attributes, `disabled` is inherited through the DOM hierarchy. @@ -1128,15 +1128,15 @@ Learn more about [`aria-disabled`](https://www.w3.org/TR/wai-aria-1.2/#aria-disa * since: v1.27 - `expanded` <[boolean]> -A boolean attribute that is usually set by `aria-expanded`. +An attribute that is usually set by `aria-expanded`. - Learn more about [`aria-expanded`](https://www.w3.org/TR/wai-aria-1.2/#aria-expanded). +Learn more about [`aria-expanded`](https://www.w3.org/TR/wai-aria-1.2/#aria-expanded). ## locator-get-by-role-option-includeHidden * since: v1.27 - `includeHidden` <[boolean]> -A boolean attribute that controls whether hidden elements are matched. By default, only non-hidden elements, as [defined by ARIA](https://www.w3.org/TR/wai-aria-1.2/#tree_exclusion), are matched by role selector. +Option that controls whether hidden elements are matched. By default, only non-hidden elements, as [defined by ARIA](https://www.w3.org/TR/wai-aria-1.2/#tree_exclusion), are matched by role selector. Learn more about [`aria-hidden`](https://www.w3.org/TR/wai-aria-1.2/#aria-hidden). @@ -1152,15 +1152,21 @@ Learn more about [`aria-level`](https://www.w3.org/TR/wai-aria-1.2/#aria-level). * since: v1.27 - `name` <[string]|[RegExp]> -A string attribute that matches [accessible name](https://w3c.github.io/accname/#dfn-accessible-name). +Option to match the [accessible name](https://w3c.github.io/accname/#dfn-accessible-name). By default, matching is case-insensitive and searches for a substring, use [`option: exact`] to control this behavior. Learn more about [accessible name](https://w3c.github.io/accname/#dfn-accessible-name). +## locator-get-by-role-option-exact +* since: v1.28 +- `exact` <[boolean]> + +Whether [`option: name`] is matched exactly: case-sensitive and whole-string. Defaults to false. Ignored when [`option: name`] is a regular expression. Note that exact match still trims whitespace. + ## locator-get-by-role-option-pressed * since: v1.27 - `pressed` <[boolean]> -An attribute that is usually set by `aria-pressed`. Available values for pressed are `true`, `false` and `"mixed"`. +An attribute that is usually set by `aria-pressed`. Learn more about [`aria-pressed`](https://www.w3.org/TR/wai-aria-1.2/#aria-pressed). @@ -1168,7 +1174,7 @@ Learn more about [`aria-pressed`](https://www.w3.org/TR/wai-aria-1.2/#aria-press * since: v1.27 - `selected` -A boolean attribute that is usually set by `aria-selected`. +An attribute that is usually set by `aria-selected`. Learn more about [`aria-selected`](https://www.w3.org/TR/wai-aria-1.2/#aria-selected). diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index b2c05d7123..8e762d0226 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -18,7 +18,7 @@ import type { SelectorEngine, SelectorRoot } from './selectorEngine'; import { XPathEngine } from './xpathSelectorEngine'; import { ReactEngine } from './reactSelectorEngine'; import { VueEngine } from './vueSelectorEngine'; -import { RoleEngine } from './roleSelectorEngine'; +import { createRoleEngine } from './roleSelectorEngine'; import { parseAttributeSelector } from '../isomorphic/selectorParser'; import type { NestedSelectorBody, ParsedSelector, ParsedSelectorPart } from '../isomorphic/selectorParser'; import { allEngineNames, parseSelector, stringifySelector } from '../isomorphic/selectorParser'; @@ -95,7 +95,7 @@ export class InjectedScript { this._engines.set('xpath:light', XPathEngine); this._engines.set('_react', ReactEngine); this._engines.set('_vue', VueEngine); - this._engines.set('role', RoleEngine); + this._engines.set('role', createRoleEngine(false)); this._engines.set('text', this._createTextEngine(true, false)); this._engines.set('text:light', this._createTextEngine(false, false)); this._engines.set('id', this._createAttributeEngine('id', true)); @@ -116,7 +116,7 @@ export class InjectedScript { this._engines.set('internal:has-text', this._createInternalHasTextEngine()); this._engines.set('internal:attr', this._createNamedAttributeEngine()); this._engines.set('internal:testid', this._createNamedAttributeEngine()); - this._engines.set('internal:role', RoleEngine); + this._engines.set('internal:role', createRoleEngine(true)); for (const { name, engine } of customEngines) this._engines.set(name, engine); diff --git a/packages/playwright-core/src/server/injected/roleSelectorEngine.ts b/packages/playwright-core/src/server/injected/roleSelectorEngine.ts index 0e306ee1f1..3e4d1ac5e9 100644 --- a/packages/playwright-core/src/server/injected/roleSelectorEngine.ts +++ b/packages/playwright-core/src/server/injected/roleSelectorEngine.ts @@ -107,8 +107,8 @@ function validateAttributes(attrs: AttributeSelectorPart[], role: string) { } } -export const RoleEngine: SelectorEngine = { - queryAll(scope: SelectorRoot, selector: string): Element[] { +export function createRoleEngine(internal: boolean): SelectorEngine { + const queryAll = (scope: SelectorRoot, selector: string): Element[] => { const parsed = parseAttributeSelector(selector, true); const role = parsed.name.toLowerCase(); if (!role) @@ -149,7 +149,13 @@ export const RoleEngine: SelectorEngine = { return; } if (nameAttr !== undefined) { - const accessibleName = getElementAccessibleName(element, includeHidden, hiddenCache); + // Always normalize whitespace in the accessible name. + const accessibleName = getElementAccessibleName(element, includeHidden, hiddenCache).trim().replace(/\s+/g, ' '); + if (typeof nameAttr.value === 'string') + nameAttr.value = nameAttr.value.trim().replace(/\s+/g, ' '); + // internal:role assumes that [name="foo"i] also means substring. + if (internal && !nameAttr.caseSensitive && nameAttr.op === '=') + nameAttr.op = '*='; if (!matchesAttributePart(accessibleName, nameAttr)) return; } @@ -170,5 +176,6 @@ export const RoleEngine: SelectorEngine = { query(scope); return result; - } -}; + }; + return { queryAll }; +} diff --git a/packages/playwright-core/src/server/injected/selectorGenerator.ts b/packages/playwright-core/src/server/injected/selectorGenerator.ts index 93487a1934..61e985f003 100644 --- a/packages/playwright-core/src/server/injected/selectorGenerator.ts +++ b/packages/playwright-core/src/server/injected/selectorGenerator.ts @@ -182,7 +182,7 @@ function buildCandidates(injectedScript: InjectedScript, element: Element, testI if (ariaRole && !['none', 'presentation'].includes(ariaRole)) { const ariaName = getElementAccessibleName(element, false, accessibleNameCache); if (ariaName) - candidates.push({ engine: 'internal:role', selector: `${ariaRole}[name=${escapeForAttributeSelector(ariaName, true)}]`, score: kRoleWithNameScore }); + candidates.push({ engine: 'internal:role', selector: `${ariaRole}[name=${escapeForAttributeSelector(ariaName, false)}]`, score: kRoleWithNameScore }); else candidates.push({ engine: 'internal:role', selector: ariaRole, score: kRoleWithoutNameScore }); } @@ -227,7 +227,7 @@ function buildTextCandidates(injectedScript: InjectedScript, element: Element, i if (ariaRole && !['none', 'presentation'].includes(ariaRole)) { const ariaName = getElementAccessibleName(element, false, accessibleNameCache); if (ariaName) - candidate.push({ engine: 'internal:role', selector: `${ariaRole}[name=${escapeForAttributeSelector(ariaName, true)}]`, score: kRoleWithNameScore }); + candidate.push({ engine: 'internal:role', selector: `${ariaRole}[name=${escapeForAttributeSelector(ariaName, false)}]`, score: kRoleWithNameScore }); else candidate.push({ engine: 'internal:role', selector: ariaRole, score: kRoleWithoutNameScore }); } else { diff --git a/packages/playwright-core/src/server/isomorphic/locatorGenerators.ts b/packages/playwright-core/src/server/isomorphic/locatorGenerators.ts index 316520b2a9..9ce963f721 100644 --- a/packages/playwright-core/src/server/isomorphic/locatorGenerators.ts +++ b/packages/playwright-core/src/server/isomorphic/locatorGenerators.ts @@ -22,8 +22,9 @@ export type Language = 'javascript' | 'python' | 'java' | 'csharp'; export type LocatorType = 'default' | 'role' | 'text' | 'label' | 'placeholder' | 'alt' | 'title' | 'test-id' | 'nth' | 'first' | 'last' | 'has-text' | 'has' | 'frame'; export type LocatorBase = 'page' | 'locator' | 'frame-locator'; +type LocatorOptions = { attrs?: { name: string, value: string | boolean | number}[], exact?: boolean, name?: string | RegExp }; export interface LocatorFactory { - generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options?: { attrs?: Record, exact?: boolean }): string; + generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options?: LocatorOptions): string; } export function asLocator(lang: Language, selector: string, isFrameLocator: boolean = false): string { @@ -69,10 +70,18 @@ function innerAsLocator(factory: LocatorFactory, parsed: ParsedSelector, isFrame } if (part.name === 'internal:role') { const attrSelector = parseAttributeSelector(part.body as string, true); - const attrs: Record = {}; - for (const attr of attrSelector.attributes!) - attrs[attr.name === 'include-hidden' ? 'includeHidden' : attr.name] = attr.value; - tokens.push(factory.generateLocator(base, 'role', attrSelector.name, { attrs })); + const options: LocatorOptions = { attrs: [] }; + for (const attr of attrSelector.attributes) { + if (attr.name === 'name') { + options.exact = attr.caseSensitive; + options.name = attr.value; + } else { + if (attr.name === 'level' && typeof attr.value === 'string') + attr.value = +attr.value; + options.attrs!.push({ name: attr.name === 'include-hidden' ? 'includeHidden' : attr.name, value: attr.value }); + } + } + tokens.push(factory.generateLocator(base, 'role', attrSelector.name, options)); continue; } if (part.name === 'internal:testid') { @@ -134,7 +143,7 @@ function detectExact(text: string): { exact?: boolean, text: string | RegExp } { } export class JavaScriptLocatorFactory implements LocatorFactory { - generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options: { attrs?: Record, exact?: boolean } = {}): string { + generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options: LocatorOptions = {}): string { switch (kind) { case 'default': return `locator(${this.quote(body as string)})`; @@ -148,7 +157,14 @@ export class JavaScriptLocatorFactory implements LocatorFactory { return `last()`; case 'role': const attrs: string[] = []; - for (const [name, value] of Object.entries(options.attrs!)) + if (isRegExp(options.name)) { + attrs.push(`name: ${options.name}`); + } else if (typeof options.name === 'string') { + attrs.push(`name: ${this.quote(options.name)}`); + if (options.exact) + attrs.push(`exact: true`); + } + for (const { name, value } of options.attrs!) attrs.push(`${name}: ${typeof value === 'string' ? this.quote(value) : value}`); const attrString = attrs.length ? `, { ${attrs.join(', ')} }` : ''; return `getByRole(${this.quote(body as string)}${attrString})`; @@ -191,7 +207,7 @@ export class JavaScriptLocatorFactory implements LocatorFactory { } export class PythonLocatorFactory implements LocatorFactory { - generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options: { attrs?: Record, exact?: boolean } = {}): string { + generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options: LocatorOptions = {}): string { switch (kind) { case 'default': return `locator(${this.quote(body as string)})`; @@ -205,8 +221,19 @@ export class PythonLocatorFactory implements LocatorFactory { return `last`; case 'role': const attrs: string[] = []; - for (const [name, value] of Object.entries(options.attrs!)) - attrs.push(`${toSnakeCase(name)}=${typeof value === 'string' ? this.quote(value) : value}`); + if (isRegExp(options.name)) { + attrs.push(`name=${this.regexToString(options.name)}`); + } else if (typeof options.name === 'string') { + attrs.push(`name=${this.quote(options.name)}`); + if (options.exact) + attrs.push(`exact=True`); + } + for (const { name, value } of options.attrs!) { + let valueString = typeof value === 'string' ? this.quote(value) : value; + if (typeof value === 'boolean') + valueString = value ? 'True' : 'False'; + attrs.push(`${toSnakeCase(name)}=${valueString}`); + } const attrString = attrs.length ? `, ${attrs.join(', ')}` : ''; return `get_by_role(${this.quote(body as string)}${attrString})`; case 'has-text': @@ -230,21 +257,22 @@ export class PythonLocatorFactory implements LocatorFactory { } } + private regexToString(body: RegExp) { + const suffix = body.flags.includes('i') ? ', re.IGNORECASE' : ''; + return `re.compile(r"${body.source.replace(/\\\//, '/').replace(/"/g, '\\"')}"${suffix})`; + } + private toCallWithExact(method: string, body: string | RegExp, exact: boolean) { - if (isRegExp(body)) { - const suffix = body.flags.includes('i') ? ', re.IGNORECASE' : ''; - return `${method}(re.compile(r"${body.source.replace(/\\\//, '/').replace(/"/g, '\\"')}"${suffix}))`; - } + if (isRegExp(body)) + return `${method}(${this.regexToString(body)})`; if (exact) return `${method}(${this.quote(body)}, exact=True)`; return `${method}(${this.quote(body)})`; } private toHasText(body: string | RegExp) { - if (isRegExp(body)) { - const suffix = body.flags.includes('i') ? ', re.IGNORECASE' : ''; - return `re.compile(r"${body.source.replace(/\\\//, '/').replace(/"/g, '\\"')}"${suffix})`; - } + if (isRegExp(body)) + return this.regexToString(body); return `${this.quote(body)}`; } @@ -254,7 +282,7 @@ export class PythonLocatorFactory implements LocatorFactory { } export class JavaLocatorFactory implements LocatorFactory { - generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options: { attrs?: Record, exact?: boolean } = {}): string { + generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options: LocatorOptions = {}): string { let clazz: string; switch (base) { case 'page': clazz = 'Page'; break; @@ -274,7 +302,14 @@ export class JavaLocatorFactory implements LocatorFactory { return `last()`; case 'role': const attrs: string[] = []; - for (const [name, value] of Object.entries(options.attrs!)) + if (isRegExp(options.name)) { + attrs.push(`.setName(${this.regexToString(options.name)})`); + } else if (typeof options.name === 'string') { + attrs.push(`.setName(${this.quote(options.name)})`); + if (options.exact) + attrs.push(`.setExact(true)`); + } + for (const { name, value } of options.attrs!) attrs.push(`.set${toTitleCase(name)}(${typeof value === 'string' ? this.quote(value) : value})`); const attrString = attrs.length ? `, new ${clazz}.GetByRoleOptions()${attrs.join('')}` : ''; return `getByRole(AriaRole.${toSnakeCase(body as string).toUpperCase()}${attrString})`; @@ -299,21 +334,22 @@ export class JavaLocatorFactory implements LocatorFactory { } } + private regexToString(body: RegExp) { + const suffix = body.flags.includes('i') ? ', Pattern.CASE_INSENSITIVE' : ''; + return `Pattern.compile(${this.quote(body.source)}${suffix})`; + } + private toCallWithExact(clazz: string, method: string, body: string | RegExp, exact: boolean) { - if (isRegExp(body)) { - const suffix = body.flags.includes('i') ? ', Pattern.CASE_INSENSITIVE' : ''; - return `${method}(Pattern.compile(${this.quote(body.source)}${suffix}))`; - } + if (isRegExp(body)) + return `${method}(${this.regexToString(body)})`; if (exact) return `${method}(${this.quote(body)}, new ${clazz}.${toTitleCase(method)}Options().setExact(true))`; return `${method}(${this.quote(body)})`; } private toHasText(body: string | RegExp) { - if (isRegExp(body)) { - const suffix = body.flags.includes('i') ? ', Pattern.CASE_INSENSITIVE' : ''; - return `Pattern.compile(${this.quote(body.source)}${suffix})`; - } + if (isRegExp(body)) + return this.regexToString(body); return this.quote(body); } @@ -323,7 +359,7 @@ export class JavaLocatorFactory implements LocatorFactory { } export class CSharpLocatorFactory implements LocatorFactory { - generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options: { attrs?: Record, exact?: boolean } = {}): string { + generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options: LocatorOptions = {}): string { switch (kind) { case 'default': return `Locator(${this.quote(body as string)})`; @@ -337,14 +373,19 @@ export class CSharpLocatorFactory implements LocatorFactory { return `Last`; case 'role': const attrs: string[] = []; - for (const [name, value] of Object.entries(options.attrs!)) { - const optionKey = name === 'name' ? 'NameString' : toTitleCase(name); - attrs.push(`${optionKey} = ${typeof value === 'string' ? this.quote(value) : value}`); + if (isRegExp(options.name)) { + attrs.push(`NameRegex = ${this.regexToString(options.name)}`); + } else if (typeof options.name === 'string') { + attrs.push(`NameString = ${this.quote(options.name)}`); + if (options.exact) + attrs.push(`Exact = true`); } + for (const { name, value } of options.attrs!) + attrs.push(`${toTitleCase(name)} = ${typeof value === 'string' ? this.quote(value) : value}`); const attrString = attrs.length ? `, new() { ${attrs.join(', ')} }` : ''; return `GetByRole(AriaRole.${toTitleCase(body as string)}${attrString})`; case 'has-text': - return `Filter(new() { HasTextString = ${this.toHasText(body)} })`; + return `Filter(new() { ${this.toHasText(body)} })`; case 'has': return `Filter(new() { Has = ${body} })`; case 'test-id': @@ -364,22 +405,23 @@ export class CSharpLocatorFactory implements LocatorFactory { } } + private regexToString(body: RegExp): string { + const suffix = body.flags.includes('i') ? ', RegexOptions.IgnoreCase' : ''; + return `new Regex(${this.quote(body.source)}${suffix})`; + } + private toCallWithExact(method: string, body: string | RegExp, exact: boolean) { - if (isRegExp(body)) { - const suffix = body.flags.includes('i') ? ', RegexOptions.IgnoreCase' : ''; - return `${method}(new Regex(${this.quote(body.source)}${suffix}))`; - } + if (isRegExp(body)) + return `${method}(${this.regexToString(body)})`; if (exact) return `${method}(${this.quote(body)}, new() { Exact = true })`; return `${method}(${this.quote(body)})`; } private toHasText(body: string | RegExp) { - if (isRegExp(body)) { - const suffix = body.flags.includes('i') ? ', RegexOptions.IgnoreCase' : ''; - return `new Regex(${this.quote(body.source)}${suffix})`; - } - return this.quote(body); + if (isRegExp(body)) + return `HasTextRegex = ${this.regexToString(body)}`; + return `HasTextString = ${this.quote(body)}`; } private quote(text: string) { diff --git a/packages/playwright-core/src/utils/isomorphic/locatorUtils.ts b/packages/playwright-core/src/utils/isomorphic/locatorUtils.ts index a82c5c2a98..3896713d0d 100644 --- a/packages/playwright-core/src/utils/isomorphic/locatorUtils.ts +++ b/packages/playwright-core/src/utils/isomorphic/locatorUtils.ts @@ -19,6 +19,7 @@ import { escapeForAttributeSelector, escapeForTextSelector, isString } from './s export type ByRoleOptions = { checked?: boolean; disabled?: boolean; + exact?: boolean; expanded?: boolean; includeHidden?: boolean; level?: number; @@ -72,7 +73,7 @@ export function getByRoleSelector(role: string, options: ByRoleOptions = {}): st if (options.level !== undefined) props.push(['level', String(options.level)]); if (options.name !== undefined) - props.push(['name', isString(options.name) ? escapeForAttributeSelector(options.name, false) : String(options.name)]); + props.push(['name', isString(options.name) ? escapeForAttributeSelector(options.name, !!options.exact) : String(options.name)]); if (options.pressed !== undefined) props.push(['pressed', String(options.pressed)]); return `internal:role=${role}${props.map(([n, v]) => `[${n}=${v}]`).join('')}`; diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index d3365eb9ab..0bcf424a17 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -2517,15 +2517,14 @@ export interface Page { */ getByRole(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?: { /** - * An attribute that is usually set by `aria-checked` or native `` controls. Available values for - * checked are `true`, `false` and `"mixed"`. + * An attribute that is usually set by `aria-checked` or native `` controls. * * Learn more about [`aria-checked`](https://www.w3.org/TR/wai-aria-1.2/#aria-checked). */ checked?: boolean; /** - * A boolean attribute that is usually set by `aria-disabled` or `disabled`. + * An attribute that is usually set by `aria-disabled` or `disabled`. * * > NOTE: Unlike most other attributes, `disabled` is inherited through the DOM hierarchy. Learn more about * [`aria-disabled`](https://www.w3.org/TR/wai-aria-1.2/#aria-disabled). @@ -2533,14 +2532,20 @@ export interface Page { disabled?: boolean; /** - * A boolean attribute that is usually set by `aria-expanded`. + * Whether `name` is matched exactly: case-sensitive and whole-string. Defaults to false. Ignored when `name` is a regular + * expression. Note that exact match still trims whitespace. + */ + exact?: boolean; + + /** + * An attribute that is usually set by `aria-expanded`. * * Learn more about [`aria-expanded`](https://www.w3.org/TR/wai-aria-1.2/#aria-expanded). */ expanded?: boolean; /** - * A boolean attribute that controls whether hidden elements are matched. By default, only non-hidden elements, as + * Option that controls whether hidden elements are matched. By default, only non-hidden elements, as * [defined by ARIA](https://www.w3.org/TR/wai-aria-1.2/#tree_exclusion), are matched by role selector. * * Learn more about [`aria-hidden`](https://www.w3.org/TR/wai-aria-1.2/#aria-hidden). @@ -2556,21 +2561,22 @@ export interface Page { level?: number; /** - * A string attribute that matches [accessible name](https://w3c.github.io/accname/#dfn-accessible-name). + * Option to match the [accessible name](https://w3c.github.io/accname/#dfn-accessible-name). By default, matching is + * case-insensitive and searches for a substring, use `exact` to control this behavior. * * Learn more about [accessible name](https://w3c.github.io/accname/#dfn-accessible-name). */ name?: string|RegExp; /** - * An attribute that is usually set by `aria-pressed`. Available values for pressed are `true`, `false` and `"mixed"`. + * An attribute that is usually set by `aria-pressed`. * * Learn more about [`aria-pressed`](https://www.w3.org/TR/wai-aria-1.2/#aria-pressed). */ pressed?: boolean; /** - * A boolean attribute that is usually set by `aria-selected`. + * An attribute that is usually set by `aria-selected`. * * Learn more about [`aria-selected`](https://www.w3.org/TR/wai-aria-1.2/#aria-selected). */ @@ -5657,15 +5663,14 @@ export interface Frame { */ getByRole(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?: { /** - * An attribute that is usually set by `aria-checked` or native `` controls. Available values for - * checked are `true`, `false` and `"mixed"`. + * An attribute that is usually set by `aria-checked` or native `` controls. * * Learn more about [`aria-checked`](https://www.w3.org/TR/wai-aria-1.2/#aria-checked). */ checked?: boolean; /** - * A boolean attribute that is usually set by `aria-disabled` or `disabled`. + * An attribute that is usually set by `aria-disabled` or `disabled`. * * > NOTE: Unlike most other attributes, `disabled` is inherited through the DOM hierarchy. Learn more about * [`aria-disabled`](https://www.w3.org/TR/wai-aria-1.2/#aria-disabled). @@ -5673,14 +5678,20 @@ export interface Frame { disabled?: boolean; /** - * A boolean attribute that is usually set by `aria-expanded`. + * Whether `name` is matched exactly: case-sensitive and whole-string. Defaults to false. Ignored when `name` is a regular + * expression. Note that exact match still trims whitespace. + */ + exact?: boolean; + + /** + * An attribute that is usually set by `aria-expanded`. * * Learn more about [`aria-expanded`](https://www.w3.org/TR/wai-aria-1.2/#aria-expanded). */ expanded?: boolean; /** - * A boolean attribute that controls whether hidden elements are matched. By default, only non-hidden elements, as + * Option that controls whether hidden elements are matched. By default, only non-hidden elements, as * [defined by ARIA](https://www.w3.org/TR/wai-aria-1.2/#tree_exclusion), are matched by role selector. * * Learn more about [`aria-hidden`](https://www.w3.org/TR/wai-aria-1.2/#aria-hidden). @@ -5696,21 +5707,22 @@ export interface Frame { level?: number; /** - * A string attribute that matches [accessible name](https://w3c.github.io/accname/#dfn-accessible-name). + * Option to match the [accessible name](https://w3c.github.io/accname/#dfn-accessible-name). By default, matching is + * case-insensitive and searches for a substring, use `exact` to control this behavior. * * Learn more about [accessible name](https://w3c.github.io/accname/#dfn-accessible-name). */ name?: string|RegExp; /** - * An attribute that is usually set by `aria-pressed`. Available values for pressed are `true`, `false` and `"mixed"`. + * An attribute that is usually set by `aria-pressed`. * * Learn more about [`aria-pressed`](https://www.w3.org/TR/wai-aria-1.2/#aria-pressed). */ pressed?: boolean; /** - * A boolean attribute that is usually set by `aria-selected`. + * An attribute that is usually set by `aria-selected`. * * Learn more about [`aria-selected`](https://www.w3.org/TR/wai-aria-1.2/#aria-selected). */ @@ -10187,15 +10199,14 @@ export interface Locator { */ getByRole(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?: { /** - * An attribute that is usually set by `aria-checked` or native `` controls. Available values for - * checked are `true`, `false` and `"mixed"`. + * An attribute that is usually set by `aria-checked` or native `` controls. * * Learn more about [`aria-checked`](https://www.w3.org/TR/wai-aria-1.2/#aria-checked). */ checked?: boolean; /** - * A boolean attribute that is usually set by `aria-disabled` or `disabled`. + * An attribute that is usually set by `aria-disabled` or `disabled`. * * > NOTE: Unlike most other attributes, `disabled` is inherited through the DOM hierarchy. Learn more about * [`aria-disabled`](https://www.w3.org/TR/wai-aria-1.2/#aria-disabled). @@ -10203,14 +10214,20 @@ export interface Locator { disabled?: boolean; /** - * A boolean attribute that is usually set by `aria-expanded`. + * Whether `name` is matched exactly: case-sensitive and whole-string. Defaults to false. Ignored when `name` is a regular + * expression. Note that exact match still trims whitespace. + */ + exact?: boolean; + + /** + * An attribute that is usually set by `aria-expanded`. * * Learn more about [`aria-expanded`](https://www.w3.org/TR/wai-aria-1.2/#aria-expanded). */ expanded?: boolean; /** - * A boolean attribute that controls whether hidden elements are matched. By default, only non-hidden elements, as + * Option that controls whether hidden elements are matched. By default, only non-hidden elements, as * [defined by ARIA](https://www.w3.org/TR/wai-aria-1.2/#tree_exclusion), are matched by role selector. * * Learn more about [`aria-hidden`](https://www.w3.org/TR/wai-aria-1.2/#aria-hidden). @@ -10226,21 +10243,22 @@ export interface Locator { level?: number; /** - * A string attribute that matches [accessible name](https://w3c.github.io/accname/#dfn-accessible-name). + * Option to match the [accessible name](https://w3c.github.io/accname/#dfn-accessible-name). By default, matching is + * case-insensitive and searches for a substring, use `exact` to control this behavior. * * Learn more about [accessible name](https://w3c.github.io/accname/#dfn-accessible-name). */ name?: string|RegExp; /** - * An attribute that is usually set by `aria-pressed`. Available values for pressed are `true`, `false` and `"mixed"`. + * An attribute that is usually set by `aria-pressed`. * * Learn more about [`aria-pressed`](https://www.w3.org/TR/wai-aria-1.2/#aria-pressed). */ pressed?: boolean; /** - * A boolean attribute that is usually set by `aria-selected`. + * An attribute that is usually set by `aria-selected`. * * Learn more about [`aria-selected`](https://www.w3.org/TR/wai-aria-1.2/#aria-selected). */ @@ -15633,15 +15651,14 @@ export interface FrameLocator { */ getByRole(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?: { /** - * An attribute that is usually set by `aria-checked` or native `` controls. Available values for - * checked are `true`, `false` and `"mixed"`. + * An attribute that is usually set by `aria-checked` or native `` controls. * * Learn more about [`aria-checked`](https://www.w3.org/TR/wai-aria-1.2/#aria-checked). */ checked?: boolean; /** - * A boolean attribute that is usually set by `aria-disabled` or `disabled`. + * An attribute that is usually set by `aria-disabled` or `disabled`. * * > NOTE: Unlike most other attributes, `disabled` is inherited through the DOM hierarchy. Learn more about * [`aria-disabled`](https://www.w3.org/TR/wai-aria-1.2/#aria-disabled). @@ -15649,14 +15666,20 @@ export interface FrameLocator { disabled?: boolean; /** - * A boolean attribute that is usually set by `aria-expanded`. + * Whether `name` is matched exactly: case-sensitive and whole-string. Defaults to false. Ignored when `name` is a regular + * expression. Note that exact match still trims whitespace. + */ + exact?: boolean; + + /** + * An attribute that is usually set by `aria-expanded`. * * Learn more about [`aria-expanded`](https://www.w3.org/TR/wai-aria-1.2/#aria-expanded). */ expanded?: boolean; /** - * A boolean attribute that controls whether hidden elements are matched. By default, only non-hidden elements, as + * Option that controls whether hidden elements are matched. By default, only non-hidden elements, as * [defined by ARIA](https://www.w3.org/TR/wai-aria-1.2/#tree_exclusion), are matched by role selector. * * Learn more about [`aria-hidden`](https://www.w3.org/TR/wai-aria-1.2/#aria-hidden). @@ -15672,21 +15695,22 @@ export interface FrameLocator { level?: number; /** - * A string attribute that matches [accessible name](https://w3c.github.io/accname/#dfn-accessible-name). + * Option to match the [accessible name](https://w3c.github.io/accname/#dfn-accessible-name). By default, matching is + * case-insensitive and searches for a substring, use `exact` to control this behavior. * * Learn more about [accessible name](https://w3c.github.io/accname/#dfn-accessible-name). */ name?: string|RegExp; /** - * An attribute that is usually set by `aria-pressed`. Available values for pressed are `true`, `false` and `"mixed"`. + * An attribute that is usually set by `aria-pressed`. * * Learn more about [`aria-pressed`](https://www.w3.org/TR/wai-aria-1.2/#aria-pressed). */ pressed?: boolean; /** - * A boolean attribute that is usually set by `aria-selected`. + * An attribute that is usually set by `aria-selected`. * * Learn more about [`aria-selected`](https://www.w3.org/TR/wai-aria-1.2/#aria-selected). */ diff --git a/tests/library/debug-controller.spec.ts b/tests/library/debug-controller.spec.ts index dff037b37a..c905d3d918 100644 --- a/tests/library/debug-controller.spec.ts +++ b/tests/library/debug-controller.spec.ts @@ -71,10 +71,10 @@ test('should pick element', async ({ backend, connectedBrowser }) => { expect(events).toEqual([ { - selector: 'internal:role=button[name=\"Submit\"s]', + selector: 'internal:role=button[name=\"Submit\"i]', locator: 'getByRole(\'button\', { name: \'Submit\' })', }, { - selector: 'internal:role=button[name=\"Submit\"s]', + selector: 'internal:role=button[name=\"Submit\"i]', locator: 'getByRole(\'button\', { name: \'Submit\' })', }, ]); diff --git a/tests/library/locator-generator.spec.ts b/tests/library/locator-generator.spec.ts index 4365519c8e..2835b107ca 100644 --- a/tests/library/locator-generator.spec.ts +++ b/tests/library/locator-generator.spec.ts @@ -175,6 +175,39 @@ it('reverse engineer locators', async ({ page }) => { }); }); +it('reverse engineer getByRole', async ({ page }) => { + expect.soft(generate(page.getByRole('button'))).toEqual({ + javascript: `getByRole('button')`, + python: `get_by_role("button")`, + java: `getByRole(AriaRole.BUTTON)`, + csharp: `GetByRole(AriaRole.Button)`, + }); + expect.soft(generate(page.getByRole('button', { name: 'Hello' }))).toEqual({ + javascript: `getByRole('button', { name: 'Hello' })`, + python: `get_by_role("button", name="Hello")`, + java: `getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Hello"))`, + csharp: `GetByRole(AriaRole.Button, new() { NameString = "Hello" })`, + }); + expect.soft(generate(page.getByRole('button', { name: /Hello/ }))).toEqual({ + javascript: `getByRole('button', { name: /Hello/ })`, + python: `get_by_role("button", name=re.compile(r"Hello"))`, + java: `getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName(Pattern.compile("Hello")))`, + csharp: `GetByRole(AriaRole.Button, new() { NameRegex = new Regex("Hello") })`, + }); + expect.soft(generate(page.getByRole('button', { name: 'He"llo', exact: true }))).toEqual({ + javascript: `getByRole('button', { name: 'He"llo', exact: true })`, + python: `get_by_role("button", name="He\\"llo", exact=True)`, + java: `getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("He\\"llo").setExact(true))`, + csharp: `GetByRole(AriaRole.Button, new() { NameString = "He\\"llo", Exact = true })`, + }); + expect.soft(generate(page.getByRole('button', { checked: true, pressed: false, level: 3 }))).toEqual({ + javascript: `getByRole('button', { checked: true, level: 3, pressed: false })`, + python: `get_by_role("button", checked=True, level=3, pressed=False)`, + java: `getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setChecked(true).setLevel(3).setPressed(false))`, + csharp: `GetByRole(AriaRole.Button, new() { Checked = true, Level = 3, Pressed = false })`, + }); +}); + it('reverse engineer ignore-case locators', async ({ page }) => { expect.soft(generate(page.getByText('hello my\nwo"rld'))).toEqual({ csharp: 'GetByText("hello my\\nwo\\"rld")', @@ -244,14 +277,14 @@ it('reverse engineer hasText', async ({ page }) => { }); expect.soft(generate(page.getByText('Hello').filter({ hasText: /wo\/\srld\n/ }))).toEqual({ - csharp: `GetByText("Hello").Filter(new() { HasTextString = new Regex("wo\\\\/\\\\srld\\\\n") })`, + csharp: `GetByText("Hello").Filter(new() { HasTextRegex = new Regex("wo\\\\/\\\\srld\\\\n") })`, java: `getByText("Hello").filter(new Locator.LocatorOptions().setHasText(Pattern.compile("wo\\\\/\\\\srld\\\\n")))`, javascript: `getByText('Hello').filter({ hasText: /wo\\/\\srld\\n/ })`, python: `get_by_text("Hello").filter(has_text=re.compile(r"wo/\\srld\\n"))`, }); expect.soft(generate(page.getByText('Hello').filter({ hasText: /wor"ld/ }))).toEqual({ - csharp: `GetByText("Hello").Filter(new() { HasTextString = new Regex("wor\\"ld") })`, + csharp: `GetByText("Hello").Filter(new() { HasTextRegex = new Regex("wor\\"ld") })`, java: `getByText("Hello").filter(new Locator.LocatorOptions().setHasText(Pattern.compile("wor\\"ld")))`, javascript: `getByText('Hello').filter({ hasText: /wor"ld/ })`, python: `get_by_text("Hello").filter(has_text=re.compile(r"wor\\"ld"))`, diff --git a/tests/library/selector-generator.spec.ts b/tests/library/selector-generator.spec.ts index 2f7e6d5007..856ec64bbd 100644 --- a/tests/library/selector-generator.spec.ts +++ b/tests/library/selector-generator.spec.ts @@ -50,7 +50,7 @@ it.describe('selector generator', () => { it('should generate text for ', async ({ page }) => { await page.setContent(``); - expect(await generate(page, 'input')).toBe('internal:role=button[name=\"Click me\"s]'); + expect(await generate(page, 'input')).toBe('internal:role=button[name=\"Click me\"i]'); }); it('should trim text', async ({ page }) => { @@ -347,7 +347,7 @@ it.describe('selector generator', () => { await page.setContent(``); await page.$eval('button', button => button.setAttribute('aria-label', `!#'!?:`)); - expect(await generate(page, 'button')).toBe(`internal:role=button[name="!#'!?:"s]`); + expect(await generate(page, 'button')).toBe(`internal:role=button[name="!#'!?:"i]`); expect(await page.$(`role=button[name="!#'!?:"]`)).toBeTruthy(); await page.setContent(`
`); @@ -371,7 +371,7 @@ it.describe('selector generator', () => { it('should accept valid aria-label for candidate consideration', async ({ page }) => { await page.setContent(``); - expect(await generate(page, 'button')).toBe('internal:role=button[name=\"ariaLabel\"s]'); + expect(await generate(page, 'button')).toBe('internal:role=button[name=\"ariaLabel\"i]'); }); it('should ignore empty role for candidate consideration', async ({ page }) => { diff --git a/tests/page/selectors-get-by.spec.ts b/tests/page/selectors-get-by.spec.ts index a54cbe237b..7aae90c09c 100644 --- a/tests/page/selectors-get-by.spec.ts +++ b/tests/page/selectors-get-by.spec.ts @@ -144,3 +144,36 @@ world`); await expect(page.getByAltText('hello my\nworld')).toHaveAttribute('id', 'control'); await expect(page.getByTitle('hello my\nworld')).toHaveAttribute('id', 'control'); }); + +it('getByRole escaping', async ({ page }) => { + await page.setContent(` + issues 123 + he llo 56 + + `); + expect.soft(await page.getByRole('button').evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ + ``, + ]); + expect.soft(await page.getByRole('link').evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ + `issues 123`, + `he llo 56`, + ]); + + expect.soft(await page.getByRole('link', { name: 'issues 123' }).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ + `issues 123`, + ]); + expect.soft(await page.getByRole('link', { name: 'sues' }).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ + `issues 123`, + ]); + expect.soft(await page.getByRole('link', { name: ' he \n llo ' }).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ + `he llo 56`, + ]); + expect.soft(await page.getByRole('button', { name: 'issues' }).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ + ]); + + expect.soft(await page.getByRole('link', { name: 'sues', exact: true }).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ + ]); + expect.soft(await page.getByRole('link', { name: ' he \n llo 56 ', exact: true }).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ + `he llo 56`, + ]); +}); diff --git a/tests/page/selectors-role.spec.ts b/tests/page/selectors-role.spec.ts index b6a428593c..0ea07f17c5 100644 --- a/tests/page/selectors-role.spec.ts +++ b/tests/page/selectors-role.spec.ts @@ -317,17 +317,20 @@ test('should filter hidden, unless explicitly asked for', async ({ page }) => { test('should support name', async ({ page }) => { await page.setContent(` -
+
`); expect(await page.locator(`role=button[name="Hello"]`).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ - `
`, + `
`, + ]); + expect(await page.locator(`role=button[name=" \n Hello "]`).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ + `
`, ]); expect(await page.getByRole('button', { name: 'Hello' }).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ - `
`, + `
`, ]); expect(await page.locator(`role=button[name*="all"]`).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ @@ -335,38 +338,38 @@ test('should support name', async ({ page }) => { ]); expect(await page.locator(`role=button[name=/^H[ae]llo$/]`).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ - `
`, + `
`, `
`, ]); expect(await page.getByRole('button', { name: /^H[ae]llo$/ }).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ - `
`, + `
`, `
`, ]); expect(await page.locator(`role=button[name=/h.*o/i]`).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ - `
`, + `
`, `
`, ]); expect(await page.getByRole('button', { name: /h.*o/i }).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ - `
`, + `
`, `
`, ]); expect(await page.locator(`role=button[name="Hello"][include-hidden]`).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ - `
`, + `
`, ``, ]); expect(await page.getByRole('button', { name: 'Hello', includeHidden: true }).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ - `
`, + `
`, ``, ]); expect(await page.getByRole('button', { name: 'hello', includeHidden: true }).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ - `
`, + `
`, ``, ]); expect(await page.locator(`role=button[name=Hello]`).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ - `
`, + `
`, ]); expect(await page.locator(`role=button[name=123][include-hidden]`).evaluateAll(els => els.map(e => e.outerHTML))).toEqual([ ``,