chore: split injected utils into proper files (#14093)

This commit is contained in:
Dmitry Gozman 2022-05-11 13:49:12 +01:00 committed by GitHub
parent 305afcdacf
commit b753ff8686
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 470 additions and 448 deletions

View file

@ -1,260 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export type ParsedAttributeOperator = '<truthy>'|'='|'*='|'|='|'^='|'$='|'~=';
export type ParsedComponentAttribute = {
name: string,
jsonPath: string[],
op: ParsedAttributeOperator,
value: any,
caseSensitive: boolean,
};
export type ParsedComponentSelector = {
name: string,
attributes: ParsedComponentAttribute[],
};
export function checkComponentAttribute(obj: any, attr: ParsedComponentAttribute) {
for (const token of attr.jsonPath) {
if (obj !== undefined && obj !== null)
obj = obj[token];
}
return matchesAttribute(obj, attr);
}
export function matchesAttribute(value: any, attr: ParsedComponentAttribute) {
const objValue = typeof value === 'string' && !attr.caseSensitive ? value.toUpperCase() : value;
const attrValue = typeof attr.value === 'string' && !attr.caseSensitive ? attr.value.toUpperCase() : attr.value;
if (attr.op === '<truthy>')
return !!objValue;
if (attr.op === '=') {
if (attrValue instanceof RegExp)
return typeof objValue === 'string' && !!objValue.match(attrValue);
return objValue === attrValue;
}
if (typeof objValue !== 'string' || typeof attrValue !== 'string')
return false;
if (attr.op === '*=')
return objValue.includes(attrValue);
if (attr.op === '^=')
return objValue.startsWith(attrValue);
if (attr.op === '$=')
return objValue.endsWith(attrValue);
if (attr.op === '|=')
return objValue === attrValue || objValue.startsWith(attrValue + '-');
if (attr.op === '~=')
return objValue.split(' ').includes(attrValue);
return false;
}
export function parseComponentSelector(selector: string, allowUnquotedStrings: boolean): ParsedComponentSelector {
let wp = 0;
let EOL = selector.length === 0;
const next = () => selector[wp] || '';
const eat1 = () => {
const result = next();
++wp;
EOL = wp >= selector.length;
return result;
};
const syntaxError = (stage: string|undefined) => {
if (EOL)
throw new Error(`Unexpected end of selector while parsing selector \`${selector}\``);
throw new Error(`Error while parsing selector \`${selector}\` - unexpected symbol "${next()}" at position ${wp}` + (stage ? ' during ' + stage : ''));
};
function skipSpaces() {
while (!EOL && /\s/.test(next()))
eat1();
}
function isCSSNameChar(char: string) {
// https://www.w3.org/TR/css-syntax-3/#ident-token-diagram
return (char >= '\u0080') // non-ascii
|| (char >= '\u0030' && char <= '\u0039') // digit
|| (char >= '\u0041' && char <= '\u005a') // uppercase letter
|| (char >= '\u0061' && char <= '\u007a') // lowercase letter
|| (char >= '\u0030' && char <= '\u0039') // digit
|| char === '\u005f' // "_"
|| char === '\u002d'; // "-"
}
function readIdentifier() {
let result = '';
skipSpaces();
while (!EOL && isCSSNameChar(next()))
result += eat1();
return result;
}
function readQuotedString(quote: string) {
let result = eat1();
if (result !== quote)
syntaxError('parsing quoted string');
while (!EOL && next() !== quote) {
if (next() === '\\')
eat1();
result += eat1();
}
if (next() !== quote)
syntaxError('parsing quoted string');
result += eat1();
return result;
}
function readRegularExpression() {
if (eat1() !== '/')
syntaxError('parsing regular expression');
let source = '';
let inClass = false;
// https://262.ecma-international.org/11.0/#sec-literals-regular-expression-literals
while (!EOL) {
if (next() === '\\') {
source += eat1();
if (EOL)
syntaxError('parsing regular expressiion');
} else if (inClass && next() === ']') {
inClass = false;
} else if (!inClass && next() === '[') {
inClass = true;
} else if (!inClass && next() === '/') {
break;
}
source += eat1();
}
if (eat1() !== '/')
syntaxError('parsing regular expression');
let flags = '';
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions
while (!EOL && next().match(/[dgimsuy]/))
flags += eat1();
try {
return new RegExp(source, flags);
} catch (e) {
throw new Error(`Error while parsing selector \`${selector}\`: ${e.message}`);
}
}
function readAttributeToken() {
let token = '';
skipSpaces();
if (next() === `'` || next() === `"`)
token = readQuotedString(next()).slice(1, -1);
else
token = readIdentifier();
if (!token)
syntaxError('parsing property path');
return token;
}
function readOperator(): ParsedAttributeOperator {
skipSpaces();
let op = '';
if (!EOL)
op += eat1();
if (!EOL && (op !== '='))
op += eat1();
if (!['=', '*=', '^=', '$=', '|=', '~='].includes(op))
syntaxError('parsing operator');
return (op as ParsedAttributeOperator);
}
function readAttribute(): ParsedComponentAttribute {
// skip leading [
eat1();
// read attribute name:
// foo.bar
// 'foo' . "ba zz"
const jsonPath = [];
jsonPath.push(readAttributeToken());
skipSpaces();
while (next() === '.') {
eat1();
jsonPath.push(readAttributeToken());
skipSpaces();
}
// check property is truthy: [enabled]
if (next() === ']') {
eat1();
return { name: jsonPath.join('.'), jsonPath, op: '<truthy>', value: null, caseSensitive: false };
}
const operator = readOperator();
let value = undefined;
let caseSensitive = true;
skipSpaces();
if (next() === '/') {
if (operator !== '=')
throw new Error(`Error while parsing selector \`${selector}\` - cannot use ${operator} in attribute with regular expression`);
value = readRegularExpression();
} else if (next() === `'` || next() === `"`) {
value = readQuotedString(next()).slice(1, -1);
skipSpaces();
if (next() === 'i' || next() === 'I') {
caseSensitive = false;
eat1();
} else if (next() === 's' || next() === 'S') {
caseSensitive = true;
eat1();
}
} else {
value = '';
while (!EOL && (isCSSNameChar(next()) || next() === '+' || next() === '.'))
value += eat1();
if (value === 'true') {
value = true;
} else if (value === 'false') {
value = false;
} else {
if (!allowUnquotedStrings) {
value = +value;
if (Number.isNaN(value))
syntaxError('parsing attribute value');
}
}
}
skipSpaces();
if (next() !== ']')
syntaxError('parsing attribute value');
eat1();
if (operator !== '=' && typeof value !== 'string')
throw new Error(`Error while parsing selector \`${selector}\` - cannot use ${operator} in attribute with non-string matching value - ${value}`);
return { name: jsonPath.join('.'), jsonPath, op: operator, value, caseSensitive };
}
const result: ParsedComponentSelector = {
name: '',
attributes: [],
};
result.name = readIdentifier();
skipSpaces();
while (next() === '[') {
result.attributes.push(readAttribute());
skipSpaces();
}
if (!EOL)
syntaxError(undefined);
if (!result.name && !result.attributes.length)
throw new Error(`Error while parsing selector \`${selector}\` - selector cannot be empty`);
return result;
}

View file

@ -0,0 +1,85 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export function isInsideScope(scope: Node, element: Element | undefined): boolean {
while (element) {
if (scope.contains(element))
return true;
element = enclosingShadowHost(element);
}
return false;
}
export function parentElementOrShadowHost(element: Element): Element | undefined {
if (element.parentElement)
return element.parentElement;
if (!element.parentNode)
return;
if (element.parentNode.nodeType === 11 /* Node.DOCUMENT_FRAGMENT_NODE */ && (element.parentNode as ShadowRoot).host)
return (element.parentNode as ShadowRoot).host;
}
export function enclosingShadowRootOrDocument(element: Element): Document | ShadowRoot | undefined {
let node: Node = element;
while (node.parentNode)
node = node.parentNode;
if (node.nodeType === 11 /* Node.DOCUMENT_FRAGMENT_NODE */ || node.nodeType === 9 /* Node.DOCUMENT_NODE */)
return node as Document | ShadowRoot;
}
function enclosingShadowHost(element: Element): Element | undefined {
while (element.parentElement)
element = element.parentElement;
return parentElementOrShadowHost(element);
}
export function closestCrossShadow(element: Element | undefined, css: string): Element | undefined {
while (element) {
const closest = element.closest(css);
if (closest)
return closest;
element = enclosingShadowHost(element);
}
}
export function isElementVisible(element: Element): boolean {
// Note: this logic should be similar to waitForDisplayedAtStablePosition() to avoid surprises.
if (!element.ownerDocument || !element.ownerDocument.defaultView)
return true;
const style = element.ownerDocument.defaultView.getComputedStyle(element);
if (!style || style.visibility === 'hidden')
return false;
if (style.display === 'contents') {
// display:contents is not rendered itself, but its child nodes are.
for (let child = element.firstChild; child; child = child.nextSibling) {
if (child.nodeType === 1 /* Node.ELEMENT_NODE */ && isElementVisible(child as Element))
return true;
if (child.nodeType === 3 /* Node.TEXT_NODE */ && isVisibleTextNode(child as Text))
return true;
}
return false;
}
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}
function isVisibleTextNode(node: Text) {
// https://stackoverflow.com/questions/1461059/is-there-an-equivalent-to-getboundingclientrect-for-text-nodes
const range = document.createRange();
range.selectNode(node);
const rect = range.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}

View file

@ -21,8 +21,9 @@ import { VueEngine } from './vueSelectorEngine';
import { RoleEngine } from './roleSelectorEngine';
import type { NestedSelectorBody, ParsedSelector, ParsedSelectorPart } from '../isomorphic/selectorParser';
import { allEngineNames, parseSelector, stringifySelector } from '../isomorphic/selectorParser';
import type { TextMatcher } from './selectorEvaluator';
import { SelectorEvaluatorImpl, isVisible, parentElementOrShadowHost, elementMatchesText, createRegexTextMatcher, createStrictTextMatcher, createLaxTextMatcher } from './selectorEvaluator';
import { type TextMatcher, elementMatchesText, createRegexTextMatcher, createStrictTextMatcher, createLaxTextMatcher } from './selectorUtils';
import { SelectorEvaluatorImpl } from './selectorEvaluator';
import { isElementVisible, parentElementOrShadowHost } from './domUtils';
import type { CSSComplexSelectorList } from '../isomorphic/cssParser';
import { generateSelector } from './selectorGenerator';
import type * as channels from '../../protocol/channels';
@ -253,7 +254,7 @@ export class InjectedScript {
// TODO: replace contains() with something shadow-dom-aware?
if (kind === 'lax' && lastDidNotMatchSelf && lastDidNotMatchSelf.contains(element))
return false;
const matches = elementMatchesText(this._evaluator, element, matcher);
const matches = elementMatchesText(this._evaluator._cacheText, element, matcher);
if (matches === 'none')
lastDidNotMatchSelf = element;
if (matches === 'self' || (matches === 'selfAndChildren' && kind === 'strict'))
@ -301,7 +302,7 @@ export class InjectedScript {
const queryAll = (root: SelectorRoot, body: string) => {
if (root.nodeType !== 1 /* Node.ELEMENT_NODE */)
return [];
return isVisible(root as Element) === Boolean(body) ? [root as Element] : [];
return isElementVisible(root as Element) === Boolean(body) ? [root as Element] : [];
};
return { queryAll };
}
@ -327,7 +328,7 @@ export class InjectedScript {
}
isVisible(element: Element): boolean {
return isVisible(element);
return isElementVisible(element);
}
pollRaf<T>(predicate: Predicate<T>): InjectedScriptPoll<T> {

View file

@ -15,8 +15,9 @@
*/
import type { SelectorEngine, SelectorRoot } from './selectorEngine';
import { isInsideScope } from './selectorEvaluator';
import { checkComponentAttribute, parseComponentSelector } from './componentUtils';
import { isInsideScope } from './domUtils';
import { matchesComponentAttribute } from './selectorUtils';
import { parseAttributeSelector } from '../isomorphic/selectorParser';
type ComponentNode = {
key?: any,
@ -176,7 +177,7 @@ function findReactRoots(root: Document | ShadowRoot, roots: ReactVNode[] = []):
export const ReactEngine: SelectorEngine = {
queryAll(scope: SelectorRoot, selector: string): Element[] {
const { name, attributes } = parseComponentSelector(selector, false);
const { name, attributes } = parseAttributeSelector(selector, false);
const reactRoots = findReactRoots(document);
const trees = reactRoots.map(reactRoot => buildComponentsTree(reactRoot));
@ -191,7 +192,7 @@ export const ReactEngine: SelectorEngine = {
if (treeNode.rootElements.some(domNode => !isInsideScope(scope, domNode)))
return false;
for (const attr of attributes) {
if (!checkComponentAttribute(props, attr))
if (!matchesComponentAttribute(props, attr))
return false;
}
return true;

View file

@ -15,9 +15,9 @@
*/
import type { SelectorEngine, SelectorRoot } from './selectorEngine';
import type { ParsedComponentAttribute, ParsedAttributeOperator } from './componentUtils';
import { matchesAttribute, parseComponentSelector } from './componentUtils';
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';
const kSupportedAttributes = ['selected', 'checked', 'pressed', 'expanded', 'level', 'disabled', 'name', 'include-hidden'];
kSupportedAttributes.sort();
@ -27,17 +27,17 @@ function validateSupportedRole(attr: string, roles: string[], role: string) {
throw new Error(`"${attr}" attribute is only supported for roles: ${roles.slice().sort().map(role => `"${role}"`).join(', ')}`);
}
function validateSupportedValues(attr: ParsedComponentAttribute, values: any[]) {
function validateSupportedValues(attr: AttributeSelectorPart, values: any[]) {
if (attr.op !== '<truthy>' && !values.includes(attr.value))
throw new Error(`"${attr.name}" must be one of ${values.map(v => JSON.stringify(v)).join(', ')}`);
}
function validateSupportedOp(attr: ParsedComponentAttribute, ops: ParsedAttributeOperator[]) {
function validateSupportedOp(attr: AttributeSelectorPart, ops: AttributeSelectorOperator[]) {
if (!ops.includes(attr.op))
throw new Error(`"${attr.name}" does not support "${attr.op}" matcher`);
}
function validateAttributes(attrs: ParsedComponentAttribute[], role: string) {
function validateAttributes(attrs: AttributeSelectorPart[], role: string) {
for (const attr of attrs) {
switch (attr.name) {
case 'checked': {
@ -109,7 +109,7 @@ function validateAttributes(attrs: ParsedComponentAttribute[], role: string) {
export const RoleEngine: SelectorEngine = {
queryAll(scope: SelectorRoot, selector: string): Element[] {
const parsed = parseComponentSelector(selector, true);
const parsed = parseAttributeSelector(selector, true);
const role = parsed.name.toLowerCase();
if (!role)
throw new Error(`Role must not be empty`);
@ -121,7 +121,7 @@ export const RoleEngine: SelectorEngine = {
if (getAriaRole(element) !== role)
return;
let includeHidden = false; // By default, hidden elements are excluded.
let nameAttr: ParsedComponentAttribute | undefined;
let nameAttr: AttributeSelectorPart | undefined;
for (const attr of parsed.attributes) {
if (attr.name === 'include-hidden') {
includeHidden = attr.op === '<truthy>' || !!attr.value;
@ -140,7 +140,7 @@ export const RoleEngine: SelectorEngine = {
case 'level': actual = getAriaLevel(element); break;
case 'disabled': actual = getAriaDisabled(element); break;
}
if (!matchesAttribute(actual, attr))
if (!matchesAttributePart(actual, attr))
return;
}
if (!includeHidden) {
@ -150,7 +150,7 @@ export const RoleEngine: SelectorEngine = {
}
if (nameAttr !== undefined) {
const accessibleName = getElementAccessibleName(element, includeHidden, hiddenCache);
if (!matchesAttribute(accessibleName, nameAttr))
if (!matchesAttributePart(accessibleName, nameAttr))
return;
}
result.push(element);

View file

@ -14,7 +14,7 @@
* limitations under the License.
*/
import { closestCrossShadow, enclosingShadowRootOrDocument, parentElementOrShadowHost } from './selectorEvaluator';
import { closestCrossShadow, enclosingShadowRootOrDocument, parentElementOrShadowHost } from './domUtils';
function hasExplicitAccessibleName(e: Element) {
return e.hasAttribute('aria-label') || e.hasAttribute('aria-labelledby');
@ -676,7 +676,7 @@ export function getAriaExpanded(element: Element): boolean {
}
export const kAriaLevelRoles = ['heading', 'listitem', 'row', 'treeitem'];
export function getAriaLevel(element: Element) {
export function getAriaLevel(element: Element): number {
// https://www.w3.org/TR/wai-aria-1.2/#aria-level
// https://www.w3.org/TR/html-aam-1.0/#html-attribute-state-and-property-mappings
const native = { 'H1': 1, 'H2': 2, 'H3': 3, 'H4': 4, 'H5': 5, 'H6': 6 }[element.tagName];

View file

@ -16,9 +16,11 @@
import type { CSSComplexSelector, CSSSimpleSelector, CSSComplexSelectorList, CSSFunctionArgument } from '../isomorphic/cssParser';
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';
export type QueryContext = {
type QueryContext = {
scope: Element | Document;
pierceShadow: boolean;
// Place for more options, e.g. normalizing whitespace.
@ -373,8 +375,6 @@ const hasEngine: SelectorEngine = {
return evaluator.query({ ...context, scope: element }, args).length > 0;
},
// TODO: we do not implement "relative selectors", as in "div:has(> span)" or "div:has(+ span)".
// TODO: we can implement efficient "query" by matching "args" and returning
// all parents/descendants, just have to be careful with the ":scope" matching.
};
@ -423,7 +423,7 @@ const visibleEngine: SelectorEngine = {
matches(element: Element, args: (string | number | Selector)[], context: QueryContext, evaluator: SelectorEvaluator): boolean {
if (args.length)
throw new Error(`"visible" engine expects no arguments`);
return isVisible(element);
return isElementVisible(element);
}
};
@ -432,7 +432,7 @@ const textEngine: SelectorEngine = {
if (args.length !== 1 || typeof args[0] !== 'string')
throw new Error(`"text" engine expects a single string`);
const matcher = createLaxTextMatcher(args[0]);
return elementMatchesText(evaluator as SelectorEvaluatorImpl, element, matcher) === 'self';
return elementMatchesText((evaluator as SelectorEvaluatorImpl)._cacheText, element, matcher) === 'self';
},
};
@ -441,7 +441,7 @@ const textIsEngine: SelectorEngine = {
if (args.length !== 1 || typeof args[0] !== 'string')
throw new Error(`"text-is" engine expects a single string`);
const matcher = createStrictTextMatcher(args[0]);
return elementMatchesText(evaluator as SelectorEvaluatorImpl, element, matcher) !== 'none';
return elementMatchesText((evaluator as SelectorEvaluatorImpl)._cacheText, element, matcher) !== 'none';
},
};
@ -450,7 +450,7 @@ const textMatchesEngine: SelectorEngine = {
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);
return elementMatchesText(evaluator as SelectorEvaluatorImpl, element, matcher) === 'self';
return elementMatchesText((evaluator as SelectorEvaluatorImpl)._cacheText, element, matcher) === 'self';
},
};
@ -461,87 +461,10 @@ const hasTextEngine: SelectorEngine = {
if (shouldSkipForTextMatching(element))
return false;
const matcher = createLaxTextMatcher(args[0]);
return matcher(elementText(evaluator as SelectorEvaluatorImpl, element));
return matcher(elementText((evaluator as SelectorEvaluatorImpl)._cacheText, element));
},
};
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 createRegexTextMatcher(source: string, flags?: string): TextMatcher {
const re = new RegExp(source, flags);
return (elementText: ElementText) => {
return re.test(elementText.full);
};
}
function shouldSkipForTextMatching(element: Element | ShadowRoot) {
return element.nodeName === 'SCRIPT' || element.nodeName === 'STYLE' || document.head && document.head.contains(element);
}
export type ElementText = { full: string, immediate: string[] };
export type TextMatcher = (text: ElementText) => boolean;
export function elementText(evaluator: SelectorEvaluatorImpl, root: Element | ShadowRoot): ElementText {
let value = evaluator._cacheText.get(root);
if (value === undefined) {
value = { full: '', immediate: [] };
if (!shouldSkipForTextMatching(root)) {
let currentImmediate = '';
if ((root instanceof HTMLInputElement) && (root.type === 'submit' || root.type === 'button')) {
value = { full: root.value, immediate: [root.value] };
} else {
for (let child = root.firstChild; child; child = child.nextSibling) {
if (child.nodeType === Node.TEXT_NODE) {
value.full += child.nodeValue || '';
currentImmediate += child.nodeValue || '';
} else {
if (currentImmediate)
value.immediate.push(currentImmediate);
currentImmediate = '';
if (child.nodeType === Node.ELEMENT_NODE)
value.full += elementText(evaluator, child as Element).full;
}
}
if (currentImmediate)
value.immediate.push(currentImmediate);
if ((root as Element).shadowRoot)
value.full += elementText(evaluator, (root as Element).shadowRoot!).full;
}
}
evaluator._cacheText.set(root, value);
}
return value;
}
export function elementMatchesText(evaluator: SelectorEvaluatorImpl, element: Element, matcher: TextMatcher): 'none' | 'self' | 'selfAndChildren' {
if (shouldSkipForTextMatching(element))
return 'none';
if (!matcher(elementText(evaluator, element)))
return 'none';
for (let child = element.firstChild; child; child = child.nextSibling) {
if (child.nodeType === Node.ELEMENT_NODE && matcher(elementText(evaluator, child as Element)))
return 'selfAndChildren';
}
if (element.shadowRoot && matcher(elementText(evaluator, element.shadowRoot)))
return 'selfAndChildren';
return 'self';
}
function createLayoutEngine(name: LayoutSelectorName): SelectorEngine {
return {
matches(element: Element, args: (string | number | Selector)[], context: QueryContext, evaluator: SelectorEvaluator): boolean {
@ -572,47 +495,6 @@ const nthMatchEngine: SelectorEngine = {
},
};
export function isInsideScope(scope: Node, element: Element | undefined): boolean {
while (element) {
if (scope.contains(element))
return true;
element = enclosingShadowHost(element);
}
return false;
}
export function parentElementOrShadowHost(element: Element): Element | undefined {
if (element.parentElement)
return element.parentElement;
if (!element.parentNode)
return;
if (element.parentNode.nodeType === 11 /* Node.DOCUMENT_FRAGMENT_NODE */ && (element.parentNode as ShadowRoot).host)
return (element.parentNode as ShadowRoot).host;
}
export function enclosingShadowRootOrDocument(element: Element): Document | ShadowRoot | undefined {
let node: Node = element;
while (node.parentNode)
node = node.parentNode;
if (node.nodeType === 11 /* Node.DOCUMENT_FRAGMENT_NODE */ || node.nodeType === 9 /* Node.DOCUMENT_NODE */)
return node as Document | ShadowRoot;
}
function enclosingShadowHost(element: Element): Element | undefined {
while (element.parentElement)
element = element.parentElement;
return parentElementOrShadowHost(element);
}
export function closestCrossShadow(element: Element | undefined, css: string): Element | undefined {
while (element) {
const closest = element.closest(css);
if (closest)
return closest;
element = enclosingShadowHost(element);
}
}
function parentElementOrShadowHostInContext(element: Element, context: QueryContext): Element | undefined {
if (element === context.scope)
return;
@ -627,35 +509,6 @@ function previousSiblingInContext(element: Element, context: QueryContext): Elem
return element.previousElementSibling || undefined;
}
export function isVisible(element: Element): boolean {
// Note: this logic should be similar to waitForDisplayedAtStablePosition() to avoid surprises.
if (!element.ownerDocument || !element.ownerDocument.defaultView)
return true;
const style = element.ownerDocument.defaultView.getComputedStyle(element);
if (!style || style.visibility === 'hidden')
return false;
if (style.display === 'contents') {
// display:contents is not rendered itself, but its child nodes are.
for (let child = element.firstChild; child; child = child.nextSibling) {
if (child.nodeType === 1 /* Node.ELEMENT_NODE */ && isVisible(child as Element))
return true;
if (child.nodeType === 3 /* Node.TEXT_NODE */ && isVisibleTextNode(child as Text))
return true;
}
return false;
}
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}
function isVisibleTextNode(node: Text) {
// https://stackoverflow.com/questions/1461059/is-there-an-equivalent-to-getboundingclientrect-for-text-nodes
const range = document.createRange();
range.selectNode(node);
const rect = range.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}
function sortInDOMOrder(elements: Element[]): Element[] {
type SortEntry = { children: Element[], taken: boolean };

View file

@ -15,7 +15,7 @@
*/
import { type InjectedScript } from './injectedScript';
import { elementText } from './selectorEvaluator';
import { elementText } from './selectorUtils';
type SelectorToken = {
engine: string;
@ -182,7 +182,7 @@ function buildCandidates(injectedScript: InjectedScript, element: Element): Sele
function buildTextCandidates(injectedScript: InjectedScript, element: Element, allowHasText: boolean): SelectorToken[] {
if (element.nodeName === 'SELECT')
return [];
const text = elementText(injectedScript._evaluator, element).full.trim().replace(/\s+/g, ' ').substring(0, 80);
const text = elementText(injectedScript._evaluator._cacheText, element).full.trim().replace(/\s+/g, ' ').substring(0, 80);
if (!text)
return [];
const candidates: SelectorToken[] = [];

View file

@ -0,0 +1,129 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type AttributeSelectorPart } from '../isomorphic/selectorParser';
export function matchesComponentAttribute(obj: any, attr: AttributeSelectorPart) {
for (const token of attr.jsonPath) {
if (obj !== undefined && obj !== null)
obj = obj[token];
}
return matchesAttributePart(obj, attr);
}
export function matchesAttributePart(value: any, attr: AttributeSelectorPart) {
const objValue = typeof value === 'string' && !attr.caseSensitive ? value.toUpperCase() : value;
const attrValue = typeof attr.value === 'string' && !attr.caseSensitive ? attr.value.toUpperCase() : attr.value;
if (attr.op === '<truthy>')
return !!objValue;
if (attr.op === '=') {
if (attrValue instanceof RegExp)
return typeof objValue === 'string' && !!objValue.match(attrValue);
return objValue === attrValue;
}
if (typeof objValue !== 'string' || typeof attrValue !== 'string')
return false;
if (attr.op === '*=')
return objValue.includes(attrValue);
if (attr.op === '^=')
return objValue.startsWith(attrValue);
if (attr.op === '$=')
return objValue.endsWith(attrValue);
if (attr.op === '|=')
return objValue === attrValue || objValue.startsWith(attrValue + '-');
if (attr.op === '~=')
return objValue.split(' ').includes(attrValue);
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 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 === 'STYLE' || document.head && document.head.contains(element);
}
export type ElementText = { full: string, immediate: string[] };
export type TextMatcher = (text: ElementText) => boolean;
export function elementText(cache: Map<Element | ShadowRoot, ElementText>, root: Element | ShadowRoot): ElementText {
let value = cache.get(root);
if (value === undefined) {
value = { full: '', immediate: [] };
if (!shouldSkipForTextMatching(root)) {
let currentImmediate = '';
if ((root instanceof HTMLInputElement) && (root.type === 'submit' || root.type === 'button')) {
value = { full: root.value, immediate: [root.value] };
} else {
for (let child = root.firstChild; child; child = child.nextSibling) {
if (child.nodeType === Node.TEXT_NODE) {
value.full += child.nodeValue || '';
currentImmediate += child.nodeValue || '';
} else {
if (currentImmediate)
value.immediate.push(currentImmediate);
currentImmediate = '';
if (child.nodeType === Node.ELEMENT_NODE)
value.full += elementText(cache, child as Element).full;
}
}
if (currentImmediate)
value.immediate.push(currentImmediate);
if ((root as Element).shadowRoot)
value.full += elementText(cache, (root as Element).shadowRoot!).full;
}
}
cache.set(root, value);
}
return value;
}
export function elementMatchesText(cache: Map<Element | ShadowRoot, ElementText>, element: Element, matcher: TextMatcher): 'none' | 'self' | 'selfAndChildren' {
if (shouldSkipForTextMatching(element))
return 'none';
if (!matcher(elementText(cache, element)))
return 'none';
for (let child = element.firstChild; child; child = child.nextSibling) {
if (child.nodeType === Node.ELEMENT_NODE && matcher(elementText(cache, child as Element)))
return 'selfAndChildren';
}
if (element.shadowRoot && matcher(elementText(cache, element.shadowRoot)))
return 'selfAndChildren';
return 'self';
}

View file

@ -15,8 +15,9 @@
*/
import type { SelectorEngine, SelectorRoot } from './selectorEngine';
import { isInsideScope } from './selectorEvaluator';
import { checkComponentAttribute, parseComponentSelector } from './componentUtils';
import { isInsideScope } from './domUtils';
import { matchesComponentAttribute } from './selectorUtils';
import { parseAttributeSelector } from '../isomorphic/selectorParser';
type ComponentNode = {
name: string,
@ -232,7 +233,7 @@ function findVueRoots(root: Document | ShadowRoot, roots: VueRoot[] = []): VueRo
export const VueEngine: SelectorEngine = {
queryAll(scope: SelectorRoot, selector: string): Element[] {
const { name, attributes } = parseComponentSelector(selector, false);
const { name, attributes } = parseAttributeSelector(selector, false);
const vueRoots = findVueRoots(document);
const trees = vueRoots.map(vueRoot => vueRoot.version === 3 ? buildComponentsTreeVue3(vueRoot.root) : buildComponentsTreeVue2(vueRoot.root));
const treeNodes = trees.map(tree => filterComponentsTree(tree, treeNode => {
@ -241,7 +242,7 @@ export const VueEngine: SelectorEngine = {
if (treeNode.rootElements.some(rootElement => !isInsideScope(scope, rootElement)))
return false;
for (const attr of attributes) {
if (!checkComponentAttribute(treeNode.props, attr))
if (!matchesComponentAttribute(treeNode.props, attr))
return false;
}
return true;

View file

@ -211,3 +211,215 @@ function parseSelectorString(selector: string): ParsedSelectorStrings {
append();
return result;
}
export type AttributeSelectorOperator = '<truthy>'|'='|'*='|'|='|'^='|'$='|'~=';
export type AttributeSelectorPart = {
name: string,
jsonPath: string[],
op: AttributeSelectorOperator,
value: any,
caseSensitive: boolean,
};
export type AttributeSelector = {
name: string,
attributes: AttributeSelectorPart[],
};
export function parseAttributeSelector(selector: string, allowUnquotedStrings: boolean): AttributeSelector {
let wp = 0;
let EOL = selector.length === 0;
const next = () => selector[wp] || '';
const eat1 = () => {
const result = next();
++wp;
EOL = wp >= selector.length;
return result;
};
const syntaxError = (stage: string|undefined) => {
if (EOL)
throw new Error(`Unexpected end of selector while parsing selector \`${selector}\``);
throw new Error(`Error while parsing selector \`${selector}\` - unexpected symbol "${next()}" at position ${wp}` + (stage ? ' during ' + stage : ''));
};
function skipSpaces() {
while (!EOL && /\s/.test(next()))
eat1();
}
function isCSSNameChar(char: string) {
// https://www.w3.org/TR/css-syntax-3/#ident-token-diagram
return (char >= '\u0080') // non-ascii
|| (char >= '\u0030' && char <= '\u0039') // digit
|| (char >= '\u0041' && char <= '\u005a') // uppercase letter
|| (char >= '\u0061' && char <= '\u007a') // lowercase letter
|| (char >= '\u0030' && char <= '\u0039') // digit
|| char === '\u005f' // "_"
|| char === '\u002d'; // "-"
}
function readIdentifier() {
let result = '';
skipSpaces();
while (!EOL && isCSSNameChar(next()))
result += eat1();
return result;
}
function readQuotedString(quote: string) {
let result = eat1();
if (result !== quote)
syntaxError('parsing quoted string');
while (!EOL && next() !== quote) {
if (next() === '\\')
eat1();
result += eat1();
}
if (next() !== quote)
syntaxError('parsing quoted string');
result += eat1();
return result;
}
function readRegularExpression() {
if (eat1() !== '/')
syntaxError('parsing regular expression');
let source = '';
let inClass = false;
// https://262.ecma-international.org/11.0/#sec-literals-regular-expression-literals
while (!EOL) {
if (next() === '\\') {
source += eat1();
if (EOL)
syntaxError('parsing regular expressiion');
} else if (inClass && next() === ']') {
inClass = false;
} else if (!inClass && next() === '[') {
inClass = true;
} else if (!inClass && next() === '/') {
break;
}
source += eat1();
}
if (eat1() !== '/')
syntaxError('parsing regular expression');
let flags = '';
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions
while (!EOL && next().match(/[dgimsuy]/))
flags += eat1();
try {
return new RegExp(source, flags);
} catch (e) {
throw new Error(`Error while parsing selector \`${selector}\`: ${e.message}`);
}
}
function readAttributeToken() {
let token = '';
skipSpaces();
if (next() === `'` || next() === `"`)
token = readQuotedString(next()).slice(1, -1);
else
token = readIdentifier();
if (!token)
syntaxError('parsing property path');
return token;
}
function readOperator(): AttributeSelectorOperator {
skipSpaces();
let op = '';
if (!EOL)
op += eat1();
if (!EOL && (op !== '='))
op += eat1();
if (!['=', '*=', '^=', '$=', '|=', '~='].includes(op))
syntaxError('parsing operator');
return (op as AttributeSelectorOperator);
}
function readAttribute(): AttributeSelectorPart {
// skip leading [
eat1();
// read attribute name:
// foo.bar
// 'foo' . "ba zz"
const jsonPath = [];
jsonPath.push(readAttributeToken());
skipSpaces();
while (next() === '.') {
eat1();
jsonPath.push(readAttributeToken());
skipSpaces();
}
// check property is truthy: [enabled]
if (next() === ']') {
eat1();
return { name: jsonPath.join('.'), jsonPath, op: '<truthy>', value: null, caseSensitive: false };
}
const operator = readOperator();
let value = undefined;
let caseSensitive = true;
skipSpaces();
if (next() === '/') {
if (operator !== '=')
throw new Error(`Error while parsing selector \`${selector}\` - cannot use ${operator} in attribute with regular expression`);
value = readRegularExpression();
} else if (next() === `'` || next() === `"`) {
value = readQuotedString(next()).slice(1, -1);
skipSpaces();
if (next() === 'i' || next() === 'I') {
caseSensitive = false;
eat1();
} else if (next() === 's' || next() === 'S') {
caseSensitive = true;
eat1();
}
} else {
value = '';
while (!EOL && (isCSSNameChar(next()) || next() === '+' || next() === '.'))
value += eat1();
if (value === 'true') {
value = true;
} else if (value === 'false') {
value = false;
} else {
if (!allowUnquotedStrings) {
value = +value;
if (Number.isNaN(value))
syntaxError('parsing attribute value');
}
}
}
skipSpaces();
if (next() !== ']')
syntaxError('parsing attribute value');
eat1();
if (operator !== '=' && typeof value !== 'string')
throw new Error(`Error while parsing selector \`${selector}\` - cannot use ${operator} in attribute with non-string matching value - ${value}`);
return { name: jsonPath.join('.'), jsonPath, op: operator, value, caseSensitive };
}
const result: AttributeSelector = {
name: '',
attributes: [],
};
result.name = readIdentifier();
skipSpaces();
while (next() === '[') {
result.attributes.push(readAttribute());
skipSpaces();
}
if (!EOL)
syntaxError(undefined);
if (!result.name && !result.attributes.length)
throw new Error(`Error while parsing selector \`${selector}\` - selector cannot be empty`);
return result;
}

View file

@ -15,11 +15,11 @@
*/
import { playwrightTest as it, expect } from '../config/browserTest';
import type { ParsedComponentSelector } from '../../packages/playwright-core/src/server/injected/componentUtils';
import { parseComponentSelector } from '../../packages/playwright-core/src/server/injected/componentUtils';
import type { AttributeSelector } from '../../packages/playwright-core/src/server/isomorphic/selectorParser';
import { parseAttributeSelector } from '../../packages/playwright-core/src/server/isomorphic/selectorParser';
const parse = (selector: string) => parseComponentSelector(selector, false);
const serialize = (parsed: ParsedComponentSelector) => {
const parse = (selector: string) => parseAttributeSelector(selector, false);
const serialize = (parsed: AttributeSelector) => {
return parsed.name + parsed.attributes.map(attr => {
const path = attr.jsonPath.map(token => /^[a-zA-Z0-9]+$/i.test(token) ? token : JSON.stringify(token)).join('.');
if (attr.op === '<truthy>')
@ -115,9 +115,9 @@ it('should parse identifiers', async () => {
});
it('should parse unqouted string', async () => {
expect(serialize(parseComponentSelector('[hey=foo]', true))).toBe('[hey = "foo"]');
expect(serialize(parseComponentSelector('[yay=and😀more]', true))).toBe('[yay = "and😀more"]');
expect(serialize(parseComponentSelector('[yay= trims ]', true))).toBe('[yay = "trims"]');
expect(serialize(parseAttributeSelector('[hey=foo]', true))).toBe('[hey = "foo"]');
expect(serialize(parseAttributeSelector('[yay=and😀more]', true))).toBe('[yay = "and😀more"]');
expect(serialize(parseAttributeSelector('[yay= trims ]', true))).toBe('[yay = "trims"]');
});
it('should throw on malformed selector', async () => {