diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index 999bb965d7..93bd88aaf0 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 '../isomorphic/cssParser'; import { generateSelector } from './selectorGenerator'; import type * as channels from '@protocol/channels'; import { Highlight } from './highlight'; -import { getAriaCheckedStrict, getAriaDisabled, getAriaRole, getElementAccessibleName } from './roleUtils'; +import { getAriaCheckedStrict, getAriaDisabled, getAriaLabelledByElements, getAriaRole, getElementAccessibleName } from './roleUtils'; import { kLayoutSelectorNames, type LayoutSelectorName, layoutSelectorScore } from './layoutSelectorUtils'; import { asLocator } from '../isomorphic/locatorGenerators'; import type { Language } from '../isomorphic/locatorGenerators'; @@ -296,14 +296,13 @@ export class InjectedScript { return { queryAll: (root: SelectorRoot, selector: string): Element[] => { const { matcher } = createTextMatcher(selector, true); - const result: Element[] = []; - const labels = this._evaluator._queryCSS({ scope: root as Document | Element, pierceShadow: true }, 'label') as HTMLLabelElement[]; - for (const label of labels) { - const control = label.control; - if (control && matcher(elementText(this._evaluator._cacheText, label))) - result.push(control); - } - return result; + const allElements = this._evaluator._queryCSS({ scope: root as Document | Element, pierceShadow: true }, '*'); + return allElements.filter(element => { + let labels: Element[] | NodeListOf | null | undefined = getAriaLabelledByElements(element); + if (labels === null) + labels = (element as HTMLInputElement).labels; + return !!labels && [...labels].some(label => matcher(elementText(this._evaluator._cacheText, label))); + }); } }; } diff --git a/packages/playwright-core/src/server/injected/roleUtils.ts b/packages/playwright-core/src/server/injected/roleUtils.ts index 1952962a6b..2fbd272756 100644 --- a/packages/playwright-core/src/server/injected/roleUtils.ts +++ b/packages/playwright-core/src/server/injected/roleUtils.ts @@ -309,6 +309,13 @@ function getPseudoContent(pseudoStyle: CSSStyleDeclaration | undefined) { return ''; } +export function getAriaLabelledByElements(element: Element): Element[] | null { + const ref = element.getAttribute('aria-labelledby'); + if (ref === null) + return null; + return getIdRefs(element, ref); +} + export function getElementAccessibleName(element: Element, includeHidden: boolean, hiddenCache: Map): string { // https://w3c.github.io/accname/#computation-steps @@ -360,7 +367,7 @@ function getElementAccessibleNameInternal(element: Element, options: AccessibleN // step 2b. if (options.embeddedInLabelledBy === 'none') { - const refs = getIdRefs(element, element.getAttribute('aria-labelledby')); + const refs = getAriaLabelledByElements(element) || []; const accessibleName = refs.map(ref => getElementAccessibleNameInternal(ref, { ...options, embeddedInLabelledBy: 'self', diff --git a/tests/page/selectors-get-by.spec.ts b/tests/page/selectors-get-by.spec.ts index 0f310b2390..a6012888f8 100644 --- a/tests/page/selectors-get-by.spec.ts +++ b/tests/page/selectors-get-by.spec.ts @@ -80,6 +80,38 @@ it('getByLabel should work with nested elements', async ({ page }) => { expect(await page.getByLabel(/last name/).elementHandles()).toEqual([]); }); +it('getByLabel should work with multiply-labelled input', async ({ page }) => { + await page.setContent(``); + expect(await page.getByLabel('Name').evaluate(e => e.id)).toBe('target'); + expect(await page.getByLabel('First or Last').evaluate(e => e.id)).toBe('target'); +}); + +it('getByLabel should work with ancestor label and multiple controls', async ({ page }) => { + await page.setContent(``); + expect(await page.getByLabel('Name').evaluate(e => e.id)).toBe('target'); +}); + +it('getByLabel should work with ancestor label and for', async ({ page }) => { + await page.setContent(` + + + `); + expect(await page.getByLabel('Name').evaluate(e => e.id)).toBe('target'); +}); + +it('getByLabel should work with aria-labelledby', async ({ page }) => { + await page.setContent(``); + expect(await page.getByLabel('Name').evaluate(e => e.textContent)).toBe('Click me'); +}); + +it('getByLabel should prioritize aria-labelledby over native label', async ({ page }) => { + await page.setContent(` + + + `); + expect(await page.getByLabel('Name').evaluate(e => e.textContent)).toBe('Click me'); +}); + it('getByPlaceholder should work', async ({ page }) => { await page.setContent(`