fix(selector generator): use the same label definition as getByLabel (#23846)

This extracts `getElementLabels` helper function to be used both for
generating and querying.
This commit is contained in:
Dmitry Gozman 2023-06-22 08:34:08 -07:00 коммит произвёл GitHub
Родитель bdac3e28a6
Коммит 5821c547aa
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
4 изменённых файлов: 46 добавлений и 22 удалений

Просмотреть файл

@ -22,14 +22,14 @@ import { createRoleEngine } from './roleSelectorEngine';
import { parseAttributeSelector } from '../../utils/isomorphic/selectorParser';
import type { NestedSelectorBody, ParsedSelector, ParsedSelectorPart } from '../../utils/isomorphic/selectorParser';
import { visitAllSelectorParts, parseSelector, stringifySelector } from '../../utils/isomorphic/selectorParser';
import { type TextMatcher, elementMatchesText, elementText, type ElementText } from './selectorUtils';
import { type TextMatcher, elementMatchesText, elementText, type ElementText, getElementLabels } from './selectorUtils';
import { SelectorEvaluatorImpl, sortInDOMOrder } from './selectorEvaluator';
import { enclosingShadowRootOrDocument, isElementVisible, parentElementOrShadowHost } from './domUtils';
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, getAriaLabelledByElements, getAriaRole, getElementAccessibleName } from './roleUtils';
import { getChecked, getAriaDisabled, getAriaRole, getElementAccessibleName } from './roleUtils';
import { kLayoutSelectorNames, type LayoutSelectorName, layoutSelectorScore } from './layoutSelectorUtils';
import { asLocator } from '../../utils/isomorphic/locatorGenerators';
import type { Language } from '../../utils/isomorphic/locatorGenerators';
@ -325,15 +325,7 @@ export class InjectedScript {
const { matcher } = createTextMatcher(selector, true);
const allElements = this._evaluator._queryCSS({ scope: root as Document | Element, pierceShadow: true }, '*');
return allElements.filter(element => {
let labels: Element[] | NodeListOf<Element> | null | undefined = getAriaLabelledByElements(element);
if (labels === null) {
const ariaLabel = element.getAttribute('aria-label');
if (ariaLabel !== null && !!ariaLabel.trim())
return matcher({ full: ariaLabel, immediate: [ariaLabel] });
}
if (labels === null)
labels = (element as HTMLInputElement).labels;
return !!labels && [...labels].some(label => matcher(elementText(this._evaluator._cacheText, label)));
return getElementLabels(this._evaluator._cacheText, element).some(label => matcher(label));
});
}
};

Просмотреть файл

@ -18,7 +18,7 @@ import { cssEscape, escapeForAttributeSelector, escapeForTextSelector, normalize
import { closestCrossShadow, isInsideScope, parentElementOrShadowHost } from './domUtils';
import type { InjectedScript } from './injectedScript';
import { getAriaRole, getElementAccessibleName, beginAriaCaches, endAriaCaches } from './roleUtils';
import { elementText } from './selectorUtils';
import { elementText, getElementLabels } from './selectorUtils';
type SelectorToken = {
engine: string;
@ -214,13 +214,14 @@ function buildNoTextCandidates(injectedScript: InjectedScript, element: Element,
candidates.push({ engine: 'internal:attr', selector: `[placeholder=${escapeForAttributeSelector(input.placeholder, false)}]`, score: kPlaceholderScore });
candidates.push({ engine: 'internal:attr', selector: `[placeholder=${escapeForAttributeSelector(input.placeholder, true)}]`, score: kPlaceholderScoreExact });
}
const label = input.labels?.[0];
if (label) {
const labelText = elementText(injectedScript._evaluator._cacheText, label).full.trim();
}
const labels = getElementLabels(injectedScript._evaluator._cacheText, element);
for (const label of labels) {
const labelText = label.full.trim();
candidates.push({ engine: 'internal:label', selector: escapeForTextSelector(labelText, false), score: kLabelScore });
candidates.push({ engine: 'internal:label', selector: escapeForTextSelector(labelText, true), score: kLabelScoreExact });
}
}
const ariaRole = getAriaRole(element);
if (ariaRole && !['none', 'presentation'].includes(ariaRole))

Просмотреть файл

@ -15,6 +15,7 @@
*/
import type { AttributeSelectorPart } from '../../utils/isomorphic/selectorParser';
import { getAriaLabelledByElements } from './roleUtils';
export function matchesComponentAttribute(obj: any, attr: AttributeSelectorPart) {
for (const token of attr.jsonPath) {
@ -103,3 +104,21 @@ export function elementMatchesText(cache: Map<Element | ShadowRoot, ElementText>
return 'selfAndChildren';
return 'self';
}
export function getElementLabels(textCache: Map<Element | ShadowRoot, ElementText>, element: Element): ElementText[] {
const labels = getAriaLabelledByElements(element);
if (labels)
return labels.map(label => elementText(textCache, label));
const ariaLabel = element.getAttribute('aria-label');
if (ariaLabel !== null && !!ariaLabel.trim())
return [{ full: ariaLabel, immediate: [ariaLabel] }];
// https://html.spec.whatwg.org/multipage/forms.html#category-label
const isNonHiddenInput = element.nodeName === 'INPUT' && (element as HTMLInputElement).type !== 'hidden';
if (['BUTTON', 'METER', 'OUTPUT', 'PROGRESS', 'SELECT', 'TEXTAREA'].includes(element.nodeName) || isNonHiddenInput) {
const labels = (element as HTMLInputElement).labels;
if (labels)
return [...labels].map(label => elementText(textCache, label));
}
return [];
}

Просмотреть файл

@ -355,7 +355,7 @@ it.describe('selector generator', () => {
expect(await generate(page, 'ng\\:switch')).toBe('ng\\:switch');
await page.setContent(`<button><span></span></button><button></button>`);
await page.$eval('button', button => button.setAttribute('aria-label', `!#'!?:`));
await page.$eval('span', span => span.textContent = `!#'!?:`);
expect(await generate(page, 'button')).toBe(`internal:role=button[name="!#'!?:"i]`);
expect(await page.$(`role=button[name="!#'!?:"]`)).toBeTruthy();
@ -380,7 +380,7 @@ it.describe('selector generator', () => {
it('should accept valid aria-label for candidate consideration', async ({ page }) => {
await page.setContent(`<button aria-label="ariaLabel" id="buttonId"></button>`);
expect(await generate(page, 'button')).toBe('internal:role=button[name=\"ariaLabel\"i]');
expect(await generate(page, 'button')).toBe('internal:label="ariaLabel"i');
});
it('should ignore empty role for candidate consideration', async ({ page }) => {
@ -404,8 +404,20 @@ it.describe('selector generator', () => {
});
it('should generate label selector', async ({ page }) => {
await page.setContent(`<label for=target>Country</label><input id=target>`);
expect(await generate(page, 'input')).toBe('internal:label="Country"i');
await page.setContent(`
<label for=target1>Target1</label><input id=target1>
<label for=target2>Target2</label><button id=target2>??</button>
<label for=target3>Target3</label><select id=target3><option>hey</option></select>
<label for=target4>Target4</label><progress id=target4 value=70 max=100>70%</progress>
<label for=target5>Target5</label><input id=target5 type=hidden>
<label for=target6>Target6</label><div id=target6>text</div>
`);
expect(await generate(page, '#target1')).toBe('internal:label="Target1"i');
expect(await generate(page, '#target2')).toBe('internal:label="Target2"i');
expect(await generate(page, '#target3')).toBe('internal:label="Target3"i');
expect(await generate(page, '#target4')).toBe('internal:label="Target4"i');
expect(await generate(page, '#target5')).toBe('#target5');
expect(await generate(page, '#target6')).toBe('internal:text="text"i');
await page.setContent(`<label for=target>Coun"try</label><input id=target>`);
expect(await generate(page, 'input')).toBe('internal:label="Coun\\\"try"i');