chore: refactor code around text selectors (#19278)
This commit is contained in:
parent
84a0aaaaff
commit
48182a4eb2
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 = '*=';
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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[][] = [];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue