chore: split injected utils into proper files (#14093)
This commit is contained in:
parent
305afcdacf
commit
b753ff8686
|
|
@ -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;
|
|
||||||
}
|
|
||||||
85
packages/playwright-core/src/server/injected/domUtils.ts
Normal file
85
packages/playwright-core/src/server/injected/domUtils.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -21,8 +21,9 @@ import { VueEngine } from './vueSelectorEngine';
|
||||||
import { RoleEngine } from './roleSelectorEngine';
|
import { RoleEngine } from './roleSelectorEngine';
|
||||||
import type { NestedSelectorBody, ParsedSelector, ParsedSelectorPart } from '../isomorphic/selectorParser';
|
import type { NestedSelectorBody, ParsedSelector, ParsedSelectorPart } from '../isomorphic/selectorParser';
|
||||||
import { allEngineNames, parseSelector, stringifySelector } from '../isomorphic/selectorParser';
|
import { allEngineNames, parseSelector, stringifySelector } from '../isomorphic/selectorParser';
|
||||||
import type { TextMatcher } from './selectorEvaluator';
|
import { type TextMatcher, elementMatchesText, createRegexTextMatcher, createStrictTextMatcher, createLaxTextMatcher } from './selectorUtils';
|
||||||
import { SelectorEvaluatorImpl, isVisible, parentElementOrShadowHost, elementMatchesText, createRegexTextMatcher, createStrictTextMatcher, createLaxTextMatcher } from './selectorEvaluator';
|
import { SelectorEvaluatorImpl } from './selectorEvaluator';
|
||||||
|
import { isElementVisible, parentElementOrShadowHost } from './domUtils';
|
||||||
import type { CSSComplexSelectorList } from '../isomorphic/cssParser';
|
import type { CSSComplexSelectorList } from '../isomorphic/cssParser';
|
||||||
import { generateSelector } from './selectorGenerator';
|
import { generateSelector } from './selectorGenerator';
|
||||||
import type * as channels from '../../protocol/channels';
|
import type * as channels from '../../protocol/channels';
|
||||||
|
|
@ -253,7 +254,7 @@ export class InjectedScript {
|
||||||
// TODO: replace contains() with something shadow-dom-aware?
|
// TODO: replace contains() with something shadow-dom-aware?
|
||||||
if (kind === 'lax' && lastDidNotMatchSelf && lastDidNotMatchSelf.contains(element))
|
if (kind === 'lax' && lastDidNotMatchSelf && lastDidNotMatchSelf.contains(element))
|
||||||
return false;
|
return false;
|
||||||
const matches = elementMatchesText(this._evaluator, element, matcher);
|
const matches = elementMatchesText(this._evaluator._cacheText, element, matcher);
|
||||||
if (matches === 'none')
|
if (matches === 'none')
|
||||||
lastDidNotMatchSelf = element;
|
lastDidNotMatchSelf = element;
|
||||||
if (matches === 'self' || (matches === 'selfAndChildren' && kind === 'strict'))
|
if (matches === 'self' || (matches === 'selfAndChildren' && kind === 'strict'))
|
||||||
|
|
@ -301,7 +302,7 @@ export class InjectedScript {
|
||||||
const queryAll = (root: SelectorRoot, body: string) => {
|
const queryAll = (root: SelectorRoot, body: string) => {
|
||||||
if (root.nodeType !== 1 /* Node.ELEMENT_NODE */)
|
if (root.nodeType !== 1 /* Node.ELEMENT_NODE */)
|
||||||
return [];
|
return [];
|
||||||
return isVisible(root as Element) === Boolean(body) ? [root as Element] : [];
|
return isElementVisible(root as Element) === Boolean(body) ? [root as Element] : [];
|
||||||
};
|
};
|
||||||
return { queryAll };
|
return { queryAll };
|
||||||
}
|
}
|
||||||
|
|
@ -327,7 +328,7 @@ export class InjectedScript {
|
||||||
}
|
}
|
||||||
|
|
||||||
isVisible(element: Element): boolean {
|
isVisible(element: Element): boolean {
|
||||||
return isVisible(element);
|
return isElementVisible(element);
|
||||||
}
|
}
|
||||||
|
|
||||||
pollRaf<T>(predicate: Predicate<T>): InjectedScriptPoll<T> {
|
pollRaf<T>(predicate: Predicate<T>): InjectedScriptPoll<T> {
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,9 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { SelectorEngine, SelectorRoot } from './selectorEngine';
|
import type { SelectorEngine, SelectorRoot } from './selectorEngine';
|
||||||
import { isInsideScope } from './selectorEvaluator';
|
import { isInsideScope } from './domUtils';
|
||||||
import { checkComponentAttribute, parseComponentSelector } from './componentUtils';
|
import { matchesComponentAttribute } from './selectorUtils';
|
||||||
|
import { parseAttributeSelector } from '../isomorphic/selectorParser';
|
||||||
|
|
||||||
type ComponentNode = {
|
type ComponentNode = {
|
||||||
key?: any,
|
key?: any,
|
||||||
|
|
@ -176,7 +177,7 @@ function findReactRoots(root: Document | ShadowRoot, roots: ReactVNode[] = []):
|
||||||
|
|
||||||
export const ReactEngine: SelectorEngine = {
|
export const ReactEngine: SelectorEngine = {
|
||||||
queryAll(scope: SelectorRoot, selector: string): Element[] {
|
queryAll(scope: SelectorRoot, selector: string): Element[] {
|
||||||
const { name, attributes } = parseComponentSelector(selector, false);
|
const { name, attributes } = parseAttributeSelector(selector, false);
|
||||||
|
|
||||||
const reactRoots = findReactRoots(document);
|
const reactRoots = findReactRoots(document);
|
||||||
const trees = reactRoots.map(reactRoot => buildComponentsTree(reactRoot));
|
const trees = reactRoots.map(reactRoot => buildComponentsTree(reactRoot));
|
||||||
|
|
@ -191,7 +192,7 @@ export const ReactEngine: SelectorEngine = {
|
||||||
if (treeNode.rootElements.some(domNode => !isInsideScope(scope, domNode)))
|
if (treeNode.rootElements.some(domNode => !isInsideScope(scope, domNode)))
|
||||||
return false;
|
return false;
|
||||||
for (const attr of attributes) {
|
for (const attr of attributes) {
|
||||||
if (!checkComponentAttribute(props, attr))
|
if (!matchesComponentAttribute(props, attr))
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,9 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { SelectorEngine, SelectorRoot } from './selectorEngine';
|
import type { SelectorEngine, SelectorRoot } from './selectorEngine';
|
||||||
import type { ParsedComponentAttribute, ParsedAttributeOperator } from './componentUtils';
|
import { matchesAttributePart } from './selectorUtils';
|
||||||
import { matchesAttribute, parseComponentSelector } from './componentUtils';
|
|
||||||
import { getAriaChecked, getAriaDisabled, getAriaExpanded, getAriaLevel, getAriaPressed, getAriaRole, getAriaSelected, getElementAccessibleName, isElementHiddenForAria, kAriaCheckedRoles, kAriaExpandedRoles, kAriaLevelRoles, kAriaPressedRoles, kAriaSelectedRoles } from './roleUtils';
|
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'];
|
const kSupportedAttributes = ['selected', 'checked', 'pressed', 'expanded', 'level', 'disabled', 'name', 'include-hidden'];
|
||||||
kSupportedAttributes.sort();
|
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(', ')}`);
|
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))
|
if (attr.op !== '<truthy>' && !values.includes(attr.value))
|
||||||
throw new Error(`"${attr.name}" must be one of ${values.map(v => JSON.stringify(v)).join(', ')}`);
|
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))
|
if (!ops.includes(attr.op))
|
||||||
throw new Error(`"${attr.name}" does not support "${attr.op}" matcher`);
|
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) {
|
for (const attr of attrs) {
|
||||||
switch (attr.name) {
|
switch (attr.name) {
|
||||||
case 'checked': {
|
case 'checked': {
|
||||||
|
|
@ -109,7 +109,7 @@ function validateAttributes(attrs: ParsedComponentAttribute[], role: string) {
|
||||||
|
|
||||||
export const RoleEngine: SelectorEngine = {
|
export const RoleEngine: SelectorEngine = {
|
||||||
queryAll(scope: SelectorRoot, selector: string): Element[] {
|
queryAll(scope: SelectorRoot, selector: string): Element[] {
|
||||||
const parsed = parseComponentSelector(selector, true);
|
const parsed = parseAttributeSelector(selector, true);
|
||||||
const role = parsed.name.toLowerCase();
|
const role = parsed.name.toLowerCase();
|
||||||
if (!role)
|
if (!role)
|
||||||
throw new Error(`Role must not be empty`);
|
throw new Error(`Role must not be empty`);
|
||||||
|
|
@ -121,7 +121,7 @@ export const RoleEngine: SelectorEngine = {
|
||||||
if (getAriaRole(element) !== role)
|
if (getAriaRole(element) !== role)
|
||||||
return;
|
return;
|
||||||
let includeHidden = false; // By default, hidden elements are excluded.
|
let includeHidden = false; // By default, hidden elements are excluded.
|
||||||
let nameAttr: ParsedComponentAttribute | undefined;
|
let nameAttr: AttributeSelectorPart | undefined;
|
||||||
for (const attr of parsed.attributes) {
|
for (const attr of parsed.attributes) {
|
||||||
if (attr.name === 'include-hidden') {
|
if (attr.name === 'include-hidden') {
|
||||||
includeHidden = attr.op === '<truthy>' || !!attr.value;
|
includeHidden = attr.op === '<truthy>' || !!attr.value;
|
||||||
|
|
@ -140,7 +140,7 @@ export const RoleEngine: SelectorEngine = {
|
||||||
case 'level': actual = getAriaLevel(element); break;
|
case 'level': actual = getAriaLevel(element); break;
|
||||||
case 'disabled': actual = getAriaDisabled(element); break;
|
case 'disabled': actual = getAriaDisabled(element); break;
|
||||||
}
|
}
|
||||||
if (!matchesAttribute(actual, attr))
|
if (!matchesAttributePart(actual, attr))
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!includeHidden) {
|
if (!includeHidden) {
|
||||||
|
|
@ -150,7 +150,7 @@ export const RoleEngine: SelectorEngine = {
|
||||||
}
|
}
|
||||||
if (nameAttr !== undefined) {
|
if (nameAttr !== undefined) {
|
||||||
const accessibleName = getElementAccessibleName(element, includeHidden, hiddenCache);
|
const accessibleName = getElementAccessibleName(element, includeHidden, hiddenCache);
|
||||||
if (!matchesAttribute(accessibleName, nameAttr))
|
if (!matchesAttributePart(accessibleName, nameAttr))
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
result.push(element);
|
result.push(element);
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { closestCrossShadow, enclosingShadowRootOrDocument, parentElementOrShadowHost } from './selectorEvaluator';
|
import { closestCrossShadow, enclosingShadowRootOrDocument, parentElementOrShadowHost } from './domUtils';
|
||||||
|
|
||||||
function hasExplicitAccessibleName(e: Element) {
|
function hasExplicitAccessibleName(e: Element) {
|
||||||
return e.hasAttribute('aria-label') || e.hasAttribute('aria-labelledby');
|
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 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/wai-aria-1.2/#aria-level
|
||||||
// https://www.w3.org/TR/html-aam-1.0/#html-attribute-state-and-property-mappings
|
// 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];
|
const native = { 'H1': 1, 'H2': 2, 'H3': 3, 'H4': 4, 'H5': 5, 'H6': 6 }[element.tagName];
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,11 @@
|
||||||
|
|
||||||
import type { CSSComplexSelector, CSSSimpleSelector, CSSComplexSelectorList, CSSFunctionArgument } from '../isomorphic/cssParser';
|
import type { CSSComplexSelector, CSSSimpleSelector, CSSComplexSelectorList, CSSFunctionArgument } from '../isomorphic/cssParser';
|
||||||
import { customCSSNames } from '../isomorphic/selectorParser';
|
import { customCSSNames } from '../isomorphic/selectorParser';
|
||||||
|
import { isElementVisible, parentElementOrShadowHost } from './domUtils';
|
||||||
import { type LayoutSelectorName, layoutSelectorScore } from './layoutSelectorUtils';
|
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;
|
scope: Element | Document;
|
||||||
pierceShadow: boolean;
|
pierceShadow: boolean;
|
||||||
// Place for more options, e.g. normalizing whitespace.
|
// Place for more options, e.g. normalizing whitespace.
|
||||||
|
|
@ -373,8 +375,6 @@ const hasEngine: SelectorEngine = {
|
||||||
return evaluator.query({ ...context, scope: element }, args).length > 0;
|
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
|
// TODO: we can implement efficient "query" by matching "args" and returning
|
||||||
// all parents/descendants, just have to be careful with the ":scope" matching.
|
// 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 {
|
matches(element: Element, args: (string | number | Selector)[], context: QueryContext, evaluator: SelectorEvaluator): boolean {
|
||||||
if (args.length)
|
if (args.length)
|
||||||
throw new Error(`"visible" engine expects no arguments`);
|
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')
|
if (args.length !== 1 || typeof args[0] !== 'string')
|
||||||
throw new Error(`"text" engine expects a single string`);
|
throw new Error(`"text" engine expects a single string`);
|
||||||
const matcher = createLaxTextMatcher(args[0]);
|
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')
|
if (args.length !== 1 || typeof args[0] !== 'string')
|
||||||
throw new Error(`"text-is" engine expects a single string`);
|
throw new Error(`"text-is" engine expects a single string`);
|
||||||
const matcher = createStrictTextMatcher(args[0]);
|
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'))
|
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`);
|
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 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))
|
if (shouldSkipForTextMatching(element))
|
||||||
return false;
|
return false;
|
||||||
const matcher = createLaxTextMatcher(args[0]);
|
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 {
|
function createLayoutEngine(name: LayoutSelectorName): SelectorEngine {
|
||||||
return {
|
return {
|
||||||
matches(element: Element, args: (string | number | Selector)[], context: QueryContext, evaluator: SelectorEvaluator): boolean {
|
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 {
|
function parentElementOrShadowHostInContext(element: Element, context: QueryContext): Element | undefined {
|
||||||
if (element === context.scope)
|
if (element === context.scope)
|
||||||
return;
|
return;
|
||||||
|
|
@ -627,35 +509,6 @@ function previousSiblingInContext(element: Element, context: QueryContext): Elem
|
||||||
return element.previousElementSibling || undefined;
|
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[] {
|
function sortInDOMOrder(elements: Element[]): Element[] {
|
||||||
type SortEntry = { children: Element[], taken: boolean };
|
type SortEntry = { children: Element[], taken: boolean };
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { type InjectedScript } from './injectedScript';
|
import { type InjectedScript } from './injectedScript';
|
||||||
import { elementText } from './selectorEvaluator';
|
import { elementText } from './selectorUtils';
|
||||||
|
|
||||||
type SelectorToken = {
|
type SelectorToken = {
|
||||||
engine: string;
|
engine: string;
|
||||||
|
|
@ -182,7 +182,7 @@ function buildCandidates(injectedScript: InjectedScript, element: Element): Sele
|
||||||
function buildTextCandidates(injectedScript: InjectedScript, element: Element, allowHasText: boolean): SelectorToken[] {
|
function buildTextCandidates(injectedScript: InjectedScript, element: Element, allowHasText: boolean): SelectorToken[] {
|
||||||
if (element.nodeName === 'SELECT')
|
if (element.nodeName === 'SELECT')
|
||||||
return [];
|
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)
|
if (!text)
|
||||||
return [];
|
return [];
|
||||||
const candidates: SelectorToken[] = [];
|
const candidates: SelectorToken[] = [];
|
||||||
|
|
|
||||||
129
packages/playwright-core/src/server/injected/selectorUtils.ts
Normal file
129
packages/playwright-core/src/server/injected/selectorUtils.ts
Normal 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';
|
||||||
|
}
|
||||||
|
|
@ -15,8 +15,9 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { SelectorEngine, SelectorRoot } from './selectorEngine';
|
import type { SelectorEngine, SelectorRoot } from './selectorEngine';
|
||||||
import { isInsideScope } from './selectorEvaluator';
|
import { isInsideScope } from './domUtils';
|
||||||
import { checkComponentAttribute, parseComponentSelector } from './componentUtils';
|
import { matchesComponentAttribute } from './selectorUtils';
|
||||||
|
import { parseAttributeSelector } from '../isomorphic/selectorParser';
|
||||||
|
|
||||||
type ComponentNode = {
|
type ComponentNode = {
|
||||||
name: string,
|
name: string,
|
||||||
|
|
@ -232,7 +233,7 @@ function findVueRoots(root: Document | ShadowRoot, roots: VueRoot[] = []): VueRo
|
||||||
|
|
||||||
export const VueEngine: SelectorEngine = {
|
export const VueEngine: SelectorEngine = {
|
||||||
queryAll(scope: SelectorRoot, selector: string): Element[] {
|
queryAll(scope: SelectorRoot, selector: string): Element[] {
|
||||||
const { name, attributes } = parseComponentSelector(selector, false);
|
const { name, attributes } = parseAttributeSelector(selector, false);
|
||||||
const vueRoots = findVueRoots(document);
|
const vueRoots = findVueRoots(document);
|
||||||
const trees = vueRoots.map(vueRoot => vueRoot.version === 3 ? buildComponentsTreeVue3(vueRoot.root) : buildComponentsTreeVue2(vueRoot.root));
|
const trees = vueRoots.map(vueRoot => vueRoot.version === 3 ? buildComponentsTreeVue3(vueRoot.root) : buildComponentsTreeVue2(vueRoot.root));
|
||||||
const treeNodes = trees.map(tree => filterComponentsTree(tree, treeNode => {
|
const treeNodes = trees.map(tree => filterComponentsTree(tree, treeNode => {
|
||||||
|
|
@ -241,7 +242,7 @@ export const VueEngine: SelectorEngine = {
|
||||||
if (treeNode.rootElements.some(rootElement => !isInsideScope(scope, rootElement)))
|
if (treeNode.rootElements.some(rootElement => !isInsideScope(scope, rootElement)))
|
||||||
return false;
|
return false;
|
||||||
for (const attr of attributes) {
|
for (const attr of attributes) {
|
||||||
if (!checkComponentAttribute(treeNode.props, attr))
|
if (!matchesComponentAttribute(treeNode.props, attr))
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
|
|
@ -211,3 +211,215 @@ function parseSelectorString(selector: string): ParsedSelectorStrings {
|
||||||
append();
|
append();
|
||||||
return result;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,11 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { playwrightTest as it, expect } from '../config/browserTest';
|
import { playwrightTest as it, expect } from '../config/browserTest';
|
||||||
import type { ParsedComponentSelector } from '../../packages/playwright-core/src/server/injected/componentUtils';
|
import type { AttributeSelector } from '../../packages/playwright-core/src/server/isomorphic/selectorParser';
|
||||||
import { parseComponentSelector } from '../../packages/playwright-core/src/server/injected/componentUtils';
|
import { parseAttributeSelector } from '../../packages/playwright-core/src/server/isomorphic/selectorParser';
|
||||||
|
|
||||||
const parse = (selector: string) => parseComponentSelector(selector, false);
|
const parse = (selector: string) => parseAttributeSelector(selector, false);
|
||||||
const serialize = (parsed: ParsedComponentSelector) => {
|
const serialize = (parsed: AttributeSelector) => {
|
||||||
return parsed.name + parsed.attributes.map(attr => {
|
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('.');
|
const path = attr.jsonPath.map(token => /^[a-zA-Z0-9]+$/i.test(token) ? token : JSON.stringify(token)).join('.');
|
||||||
if (attr.op === '<truthy>')
|
if (attr.op === '<truthy>')
|
||||||
|
|
@ -115,9 +115,9 @@ it('should parse identifiers', async () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should parse unqouted string', async () => {
|
it('should parse unqouted string', async () => {
|
||||||
expect(serialize(parseComponentSelector('[hey=foo]', true))).toBe('[hey = "foo"]');
|
expect(serialize(parseAttributeSelector('[hey=foo]', true))).toBe('[hey = "foo"]');
|
||||||
expect(serialize(parseComponentSelector('[yay=and😀more]', true))).toBe('[yay = "and😀more"]');
|
expect(serialize(parseAttributeSelector('[yay=and😀more]', true))).toBe('[yay = "and😀more"]');
|
||||||
expect(serialize(parseComponentSelector('[yay= trims ]', true))).toBe('[yay = "trims"]');
|
expect(serialize(parseAttributeSelector('[yay= trims ]', true))).toBe('[yay = "trims"]');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw on malformed selector', async () => {
|
it('should throw on malformed selector', async () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue