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 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> {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
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 { 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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue