chore: refactor code around text selectors (#19278)

This commit is contained in:
Dmitry Gozman 2022-12-05 14:08:54 -08:00 committed by GitHub
parent 84a0aaaaff
commit 48182a4eb2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 56 additions and 79 deletions

View file

@ -22,7 +22,7 @@ import { createRoleEngine } from './roleSelectorEngine';
import { parseAttributeSelector } from '../isomorphic/selectorParser';
import type { NestedSelectorBody, ParsedSelector, ParsedSelectorPart } from '../isomorphic/selectorParser';
import { allEngineNames, parseSelector, stringifySelector } from '../isomorphic/selectorParser';
import { type TextMatcher, elementMatchesText, createRegexTextMatcher, createStrictTextMatcher, createLaxTextMatcher, elementText, createStrictFullTextMatcher } from './selectorUtils';
import { type TextMatcher, elementMatchesText, elementText, type ElementText } from './selectorUtils';
import { SelectorEvaluatorImpl } from './selectorEvaluator';
import { enclosingShadowRootOrDocument, isElementVisible, parentElementOrShadowHost } from './domUtils';
import type { CSSComplexSelectorList } from '../isomorphic/cssParser';
@ -33,6 +33,7 @@ import { getAriaCheckedStrict, getAriaDisabled, getAriaRole, getElementAccessibl
import { kLayoutSelectorNames, type LayoutSelectorName, layoutSelectorScore } from './layoutSelectorUtils';
import { asLocator } from '../isomorphic/locatorGenerators';
import type { Language } from '../isomorphic/locatorGenerators';
import { normalizeWhiteSpace } from '../../utils/isomorphic/stringUtils';
type Predicate<T> = (progress: InjectedScriptProgress) => T | symbol;
@ -64,16 +65,12 @@ export type InjectedScriptPoll<T> = {
export type ElementStateWithoutStable = 'visible' | 'hidden' | 'enabled' | 'disabled' | 'editable' | 'checked' | 'unchecked';
export type ElementState = ElementStateWithoutStable | 'stable';
export interface SelectorEngineV2 {
queryAll(root: SelectorRoot, body: any): Element[];
}
export type HitTargetInterceptionResult = {
stop: () => 'done' | { hitTargetDescription: string };
};
export class InjectedScript {
private _engines: Map<string, SelectorEngineV2>;
private _engines: Map<string, SelectorEngine>;
_evaluator: SelectorEvaluatorImpl;
private _stableRafCount: number;
private _browserName: string;
@ -247,17 +244,16 @@ export class InjectedScript {
};
}
private _createCSSEngine(): SelectorEngineV2 {
const evaluator = this._evaluator;
private _createCSSEngine(): SelectorEngine {
return {
queryAll(root: SelectorRoot, body: any) {
return evaluator.query({ scope: root as Document | Element, pierceShadow: true }, body);
queryAll: (root: SelectorRoot, body: any) => {
return this._evaluator.query({ scope: root as Document | Element, pierceShadow: true }, body);
}
};
}
private _createTextEngine(shadow: boolean, internal: boolean): SelectorEngine {
const queryList = (root: SelectorRoot, selector: string): Element[] => {
const queryAll = (root: SelectorRoot, selector: string): Element[] => {
const { matcher, kind } = createTextMatcher(selector, internal);
const result: Element[] = [];
let lastDidNotMatchSelf: Element | null = null;
@ -280,22 +276,16 @@ export class InjectedScript {
appendElement(element);
return result;
};
return {
queryAll: (root: SelectorRoot, selector: string): Element[] => {
return queryList(root, selector);
}
};
return { queryAll };
}
private _createInternalHasTextEngine(): SelectorEngine {
const evaluator = this._evaluator;
return {
queryAll: (root: SelectorRoot, selector: string): Element[] => {
if (root.nodeType !== 1 /* Node.ELEMENT_NODE */)
return [];
const element = root as Element;
const text = elementText(evaluator._cacheText, element);
const text = elementText(this._evaluator._cacheText, element);
const { matcher } = createTextMatcher(selector, true);
return matcher(text) ? [element] : [];
}
@ -303,7 +293,6 @@ export class InjectedScript {
}
private _createInternalLabelEngine(): SelectorEngine {
const evaluator = this._evaluator;
return {
queryAll: (root: SelectorRoot, selector: string): Element[] => {
const { matcher } = createTextMatcher(selector, true);
@ -311,7 +300,7 @@ export class InjectedScript {
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(evaluator._cacheText, label)))
if (control && matcher(elementText(this._evaluator._cacheText, label)))
result.push(control);
}
return result;
@ -320,7 +309,7 @@ export class InjectedScript {
}
private _createNamedAttributeEngine(): SelectorEngine {
const queryList = (root: SelectorRoot, selector: string): Element[] => {
const queryAll = (root: SelectorRoot, selector: string): Element[] => {
const parsed = parseAttributeSelector(selector, true);
if (parsed.name || parsed.attributes.length !== 1)
throw new Error('Malformed attribute selector: ' + selector);
@ -336,15 +325,10 @@ export class InjectedScript {
const elements = this._evaluator._queryCSS({ scope: root as Document | Element, pierceShadow: true }, `[${name}]`);
return elements.filter(e => matcher(e.getAttribute(name)!));
};
return {
queryAll: (root: SelectorRoot, selector: string): Element[] => {
return queryList(root, selector);
}
};
return { queryAll };
}
private _createControlEngine(): SelectorEngineV2 {
private _createControlEngine(): SelectorEngine {
return {
queryAll(root: SelectorRoot, body: any) {
if (body === 'enter-frame')
@ -363,7 +347,7 @@ export class InjectedScript {
};
}
private _createHasEngine(): SelectorEngineV2 {
private _createHasEngine(): SelectorEngine {
const queryAll = (root: SelectorRoot, body: NestedSelectorBody) => {
if (root.nodeType !== 1 /* Node.ELEMENT_NODE */)
return [];
@ -373,7 +357,7 @@ export class InjectedScript {
return { queryAll };
}
private _createVisibleEngine(): SelectorEngineV2 {
private _createVisibleEngine(): SelectorEngine {
const queryAll = (root: SelectorRoot, body: string) => {
if (root.nodeType !== 1 /* Node.ELEMENT_NODE */)
return [];
@ -1369,8 +1353,8 @@ function cssUnquote(s: string): string {
function createTextMatcher(selector: string, internal: boolean): { matcher: TextMatcher, kind: 'regex' | 'strict' | 'lax' } {
if (selector[0] === '/' && selector.lastIndexOf('/') > 0) {
const lastSlash = selector.lastIndexOf('/');
const matcher: TextMatcher = createRegexTextMatcher(selector.substring(1, lastSlash), selector.substring(lastSlash + 1));
return { matcher, kind: 'regex' };
const re = new RegExp(selector.substring(1, lastSlash), selector.substring(lastSlash + 1));
return { matcher: (elementText: ElementText) => re.test(elementText.full), kind: 'regex' };
}
const unquote = internal ? JSON.parse.bind(JSON) : cssUnquote;
let strict = false;
@ -1387,9 +1371,20 @@ function createTextMatcher(selector: string, internal: boolean): { matcher: Text
selector = unquote(selector);
strict = true;
}
if (strict)
return { matcher: internal ? createStrictFullTextMatcher(selector) : createStrictTextMatcher(selector), kind: 'strict' };
return { matcher: createLaxTextMatcher(selector), kind: 'lax' };
selector = normalizeWhiteSpace(selector);
if (strict) {
if (internal)
return { kind: 'strict', matcher: (elementText: ElementText) => normalizeWhiteSpace(elementText.full) === selector };
const strictTextNodeMatcher = (elementText: ElementText) => {
if (!selector && !elementText.immediate.length)
return true;
return elementText.immediate.some(s => normalizeWhiteSpace(s) === selector);
};
return { matcher: strictTextNodeMatcher, kind: 'strict' };
}
selector = selector.toLowerCase();
return { kind: 'lax', matcher: (elementText: ElementText) => normalizeWhiteSpace(elementText.full).toLowerCase().includes(selector) };
}
class ExpectedTextMatcher {
@ -1430,7 +1425,7 @@ class ExpectedTextMatcher {
if (!s)
return s;
if (this._normalizeWhiteSpace)
s = s.trim().replace(/\u200b/g, '').replace(/\s+/g, ' ');
s = normalizeWhiteSpace(s);
if (this._ignoreCase)
s = s.toLocaleLowerCase();
return s;

View file

@ -18,6 +18,7 @@ import type { SelectorEngine, SelectorRoot } from './selectorEngine';
import { matchesAttributePart } from './selectorUtils';
import { getAriaChecked, getAriaDisabled, getAriaExpanded, getAriaLevel, getAriaPressed, getAriaRole, getAriaSelected, getElementAccessibleName, isElementHiddenForAria, kAriaCheckedRoles, kAriaExpandedRoles, kAriaLevelRoles, kAriaPressedRoles, kAriaSelectedRoles } from './roleUtils';
import { parseAttributeSelector, type AttributeSelectorPart, type AttributeSelectorOperator } from '../isomorphic/selectorParser';
import { normalizeWhiteSpace } from '../../utils/isomorphic/stringUtils';
const kSupportedAttributes = ['selected', 'checked', 'pressed', 'expanded', 'level', 'disabled', 'name', 'include-hidden'];
kSupportedAttributes.sort();
@ -155,9 +156,9 @@ export function createRoleEngine(internal: boolean): SelectorEngine {
}
if (nameAttr !== undefined) {
// Always normalize whitespace in the accessible name.
const accessibleName = getElementAccessibleName(element, includeHidden, hiddenCache).trim().replace(/\s+/g, ' ');
const accessibleName = normalizeWhiteSpace(getElementAccessibleName(element, includeHidden, hiddenCache));
if (typeof nameAttr.value === 'string')
nameAttr.value = nameAttr.value.trim().replace(/\s+/g, ' ');
nameAttr.value = normalizeWhiteSpace(nameAttr.value);
// internal:role assumes that [name="foo"i] also means substring.
if (internal && !nameAttr.caseSensitive && nameAttr.op === '=')
nameAttr.op = '*=';

View file

@ -17,5 +17,5 @@
export type SelectorRoot = Element | ShadowRoot | Document;
export interface SelectorEngine {
queryAll(root: SelectorRoot, selector: string): Element[];
queryAll(root: SelectorRoot, selector: string | any): Element[];
}

View file

@ -18,7 +18,8 @@ import type { CSSComplexSelector, CSSSimpleSelector, CSSComplexSelectorList, CSS
import { customCSSNames } from '../isomorphic/selectorParser';
import { isElementVisible, parentElementOrShadowHost } from './domUtils';
import { type LayoutSelectorName, layoutSelectorScore } from './layoutSelectorUtils';
import { createLaxTextMatcher, createRegexTextMatcher, createStrictTextMatcher, elementMatchesText, elementText, shouldSkipForTextMatching, type ElementText } from './selectorUtils';
import { elementMatchesText, elementText, shouldSkipForTextMatching, type ElementText } from './selectorUtils';
import { normalizeWhiteSpace } from '../../utils/isomorphic/stringUtils';
type QueryContext = {
scope: Element | Document;
@ -431,7 +432,8 @@ const textEngine: SelectorEngine = {
matches(element: Element, args: (string | number | Selector)[], context: QueryContext, evaluator: SelectorEvaluator): boolean {
if (args.length !== 1 || typeof args[0] !== 'string')
throw new Error(`"text" engine expects a single string`);
const matcher = createLaxTextMatcher(args[0]);
const text = normalizeWhiteSpace(args[0]).toLowerCase();
const matcher = (elementText: ElementText) => normalizeWhiteSpace(elementText.full).toLowerCase().includes(text);
return elementMatchesText((evaluator as SelectorEvaluatorImpl)._cacheText, element, matcher) === 'self';
},
};
@ -440,7 +442,12 @@ const textIsEngine: SelectorEngine = {
matches(element: Element, args: (string | number | Selector)[], context: QueryContext, evaluator: SelectorEvaluator): boolean {
if (args.length !== 1 || typeof args[0] !== 'string')
throw new Error(`"text-is" engine expects a single string`);
const matcher = createStrictTextMatcher(args[0]);
const text = normalizeWhiteSpace(args[0]);
const matcher = (elementText: ElementText) => {
if (!text && !elementText.immediate.length)
return true;
return elementText.immediate.some(s => normalizeWhiteSpace(s) === text);
};
return elementMatchesText((evaluator as SelectorEvaluatorImpl)._cacheText, element, matcher) !== 'none';
},
};
@ -449,7 +456,8 @@ const textMatchesEngine: SelectorEngine = {
matches(element: Element, args: (string | number | Selector)[], context: QueryContext, evaluator: SelectorEvaluator): boolean {
if (args.length === 0 || typeof args[0] !== 'string' || args.length > 2 || (args.length === 2 && typeof args[1] !== 'string'))
throw new Error(`"text-matches" engine expects a regexp body and optional regexp flags`);
const matcher = createRegexTextMatcher(args[0], args.length === 2 ? args[1] : undefined);
const re = new RegExp(args[0], args.length === 2 ? args[1] : undefined);
const matcher = (elementText: ElementText) => re.test(elementText.full);
return elementMatchesText((evaluator as SelectorEvaluatorImpl)._cacheText, element, matcher) === 'self';
},
};
@ -460,7 +468,8 @@ const hasTextEngine: SelectorEngine = {
throw new Error(`"has-text" engine expects a single string`);
if (shouldSkipForTextMatching(element))
return false;
const matcher = createLaxTextMatcher(args[0]);
const text = normalizeWhiteSpace(args[0]).toLowerCase();
const matcher = (elementText: ElementText) => normalizeWhiteSpace(elementText.full).toLowerCase().includes(text);
return matcher(elementText((evaluator as SelectorEvaluatorImpl)._cacheText, element));
},
};

View file

@ -14,7 +14,7 @@
* limitations under the License.
*/
import { cssEscape, escapeForAttributeSelector, escapeForTextSelector } from '../../utils/isomorphic/stringUtils';
import { cssEscape, escapeForAttributeSelector, escapeForTextSelector, normalizeWhiteSpace } from '../../utils/isomorphic/stringUtils';
import { type InjectedScript } from './injectedScript';
import { getAriaRole, getElementAccessibleName } from './roleUtils';
import { elementText } from './selectorUtils';
@ -216,7 +216,7 @@ function buildCandidates(injectedScript: InjectedScript, element: Element, testI
function buildTextCandidates(injectedScript: InjectedScript, element: Element, isTargetNode: boolean, accessibleNameCache: Map<Element, boolean>): SelectorToken[][] {
if (element.nodeName === 'SELECT')
return [];
const text = elementText(injectedScript._evaluator._cacheText, element).full.trim().replace(/\s+/g, ' ').substring(0, 80);
const text = normalizeWhiteSpace(elementText(injectedScript._evaluator._cacheText, element).full).substring(0, 80);
if (!text)
return [];
const candidates: SelectorToken[][] = [];

View file

@ -50,38 +50,6 @@ export function matchesAttributePart(value: any, attr: AttributeSelectorPart) {
return false;
}
export function createLaxTextMatcher(text: string): TextMatcher {
text = text.trim().replace(/\s+/g, ' ').toLowerCase();
return (elementText: ElementText) => {
const s = elementText.full.trim().replace(/\s+/g, ' ').toLowerCase();
return s.includes(text);
};
}
export function createStrictTextMatcher(text: string): TextMatcher {
text = text.trim().replace(/\s+/g, ' ');
return (elementText: ElementText) => {
if (!text && !elementText.immediate.length)
return true;
return elementText.immediate.some(s => s.trim().replace(/\s+/g, ' ') === text);
};
}
export function createStrictFullTextMatcher(text: string): TextMatcher {
text = text.trim().replace(/\s+/g, ' ');
return (elementText: ElementText) => {
return elementText.full.trim().replace(/\s+/g, ' ') === text;
};
}
export function createRegexTextMatcher(source: string, flags?: string): TextMatcher {
const re = new RegExp(source, flags);
return (elementText: ElementText) => {
return re.test(elementText.full);
};
}
export function shouldSkipForTextMatching(element: Element | ShadowRoot) {
return element.nodeName === 'SCRIPT' || element.nodeName === 'NOSCRIPT' || element.nodeName === 'STYLE' || document.head && document.head.contains(element);
}

View file

@ -63,6 +63,10 @@ function cssEscapeOne(s: string, i: number): string {
return '\\' + s.charAt(i);
}
export function normalizeWhiteSpace(text: string): string {
return text.replace(/\u200b/g, '').trim().replace(/\s+/g, ' ');
}
export function escapeForTextSelector(text: string | RegExp, exact: boolean): string {
if (typeof text !== 'string')
return String(text);