feat(cli): bring selector generator into playwright (#4795)

Also remove unused `SelectorEngine.create` function and add tests.
This commit is contained in:
Dmitry Gozman 2020-12-23 12:44:47 -08:00 committed by GitHub
parent 8d4c46ac19
commit f709e2300c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 608 additions and 305 deletions

View File

@ -4639,12 +4639,6 @@ const { selectors, firefox } = require('playwright'); // Or 'chromium' or 'webk
(async () => { (async () => {
// Must be a function that evaluates to a selector engine instance. // Must be a function that evaluates to a selector engine instance.
const createTagNameEngine = () => ({ const createTagNameEngine = () => ({
// Creates a selector that matches given target when queried at the root.
// Can return undefined if unable to create one.
create(root, target) {
return root.querySelector(target.tagName) === target ? target.tagName : undefined;
},
// Returns the first element matching given selector in the root's subtree. // Returns the first element matching given selector in the root's subtree.
query(root, selector) { query(root, selector) {
return root.querySelector(selector); return root.querySelector(selector);

View File

@ -4224,12 +4224,6 @@ const { selectors, firefox } = require('playwright'); // Or 'chromium' or 'webk
(async () => { (async () => {
// Must be a function that evaluates to a selector engine instance. // Must be a function that evaluates to a selector engine instance.
const createTagNameEngine = () => ({ const createTagNameEngine = () => ({
// Creates a selector that matches given target when queried at the root.
// Can return undefined if unable to create one.
create(root, target) {
return root.querySelector(target.tagName) === target ? target.tagName : undefined;
},
// Returns the first element matching given selector in the root's subtree. // Returns the first element matching given selector in the root's subtree.
query(root, selector) { query(root, selector) {
return root.querySelector(selector); return root.querySelector(selector);

View File

@ -20,12 +20,6 @@ An example of registering selector engine that queries elements based on a tag n
```js ```js
// Must be a function that evaluates to a selector engine instance. // Must be a function that evaluates to a selector engine instance.
const createTagNameEngine = () => ({ const createTagNameEngine = () => ({
// Creates a selector that matches given target when queried at the root.
// Can return undefined if unable to create one.
create(root, target) {
return root.querySelector(target.tagName) === target ? target.tagName : undefined;
},
// Returns the first element matching given selector in the root's subtree. // Returns the first element matching given selector in the root's subtree.
query(root, selector) { query(root, selector) {
return root.querySelector(selector); return root.querySelector(selector);

View File

@ -100,11 +100,6 @@ export type LaunchServerOptions = {
} & FirefoxUserPrefs; } & FirefoxUserPrefs;
export type SelectorEngine = { export type SelectorEngine = {
/**
* Creates a selector that matches given target when queried at the root.
* Can return undefined if unable to create one.
*/
create(root: HTMLElement, target: HTMLElement): string | undefined;
/** /**
* Returns the first element matching given selector in the root's subtree. * Returns the first element matching given selector in the root's subtree.
*/ */

View File

@ -25,23 +25,27 @@ export function installDebugController() {
} }
class DebugController implements ContextListener { class DebugController implements ContextListener {
private async ensureInstalledInFrame(frame: frames.Frame) {
try {
await frame.extendInjectedScript(debugScriptSource.source);
} catch (e) {
}
}
async onContextCreated(context: BrowserContext): Promise<void> { async onContextCreated(context: BrowserContext): Promise<void> {
if (!isDebugMode()) if (isDebugMode())
return; installDebugControllerInContext(context);
context.on(BrowserContext.Events.Page, (page: Page) => {
for (const frame of page.frames())
this.ensureInstalledInFrame(frame);
page.on(Page.Events.FrameNavigated, frame => this.ensureInstalledInFrame(frame));
});
} }
async onContextWillDestroy(context: BrowserContext): Promise<void> {} async onContextWillDestroy(context: BrowserContext): Promise<void> {}
async onContextDidDestroy(context: BrowserContext): Promise<void> {} async onContextDidDestroy(context: BrowserContext): Promise<void> {}
} }
async function ensureInstalledInFrame(frame: frames.Frame) {
try {
await frame.extendInjectedScript(debugScriptSource.source);
} catch (e) {
}
}
async function installInPage(page: Page) {
page.on(Page.Events.FrameNavigated, ensureInstalledInFrame);
await Promise.all(page.frames().map(ensureInstalledInFrame));
}
export async function installDebugControllerInContext(context: BrowserContext) {
context.on(BrowserContext.Events.Page, installInPage);
await Promise.all(context.pages().map(installInPage));
}

View File

@ -15,6 +15,7 @@
*/ */
import type InjectedScript from '../../server/injected/injectedScript'; import type InjectedScript from '../../server/injected/injectedScript';
import { generateSelector } from './selectorGenerator';
export class ConsoleAPI { export class ConsoleAPI {
private _injectedScript: InjectedScript; private _injectedScript: InjectedScript;
@ -25,28 +26,35 @@ export class ConsoleAPI {
$: (selector: string) => this._querySelector(selector), $: (selector: string) => this._querySelector(selector),
$$: (selector: string) => this._querySelectorAll(selector), $$: (selector: string) => this._querySelectorAll(selector),
inspect: (selector: string) => this._inspect(selector), inspect: (selector: string) => this._inspect(selector),
selector: (element: Element) => this._selector(element),
}; };
} }
_querySelector(selector: string): (Element | undefined) { private _querySelector(selector: string): (Element | undefined) {
if (typeof selector !== 'string') if (typeof selector !== 'string')
throw new Error(`Usage: playwright.query('Playwright >> selector').`); throw new Error(`Usage: playwright.query('Playwright >> selector').`);
const parsed = this._injectedScript.parseSelector(selector); const parsed = this._injectedScript.parseSelector(selector);
return this._injectedScript.querySelector(parsed, document); return this._injectedScript.querySelector(parsed, document);
} }
_querySelectorAll(selector: string): Element[] { private _querySelectorAll(selector: string): Element[] {
if (typeof selector !== 'string') if (typeof selector !== 'string')
throw new Error(`Usage: playwright.$$('Playwright >> selector').`); throw new Error(`Usage: playwright.$$('Playwright >> selector').`);
const parsed = this._injectedScript.parseSelector(selector); const parsed = this._injectedScript.parseSelector(selector);
return this._injectedScript.querySelectorAll(parsed, document); return this._injectedScript.querySelectorAll(parsed, document);
} }
_inspect(selector: string) { private _inspect(selector: string) {
if (typeof (window as any).inspect !== 'function') if (typeof (window as any).inspect !== 'function')
return; return;
if (typeof selector !== 'string') if (typeof selector !== 'string')
throw new Error(`Usage: playwright.inspect('Playwright >> selector').`); throw new Error(`Usage: playwright.inspect('Playwright >> selector').`);
(window as any).inspect(this._querySelector(selector)); (window as any).inspect(this._querySelector(selector));
} }
private _selector(element: Element) {
if (!(element instanceof Element))
throw new Error(`Usage: playwright.selector(element).`);
return generateSelector(this._injectedScript, element).selector;
}
} }

View File

@ -0,0 +1,363 @@
/**
* 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 InjectedScript from '../../server/injected/injectedScript';
export function generateSelector(injectedScript: InjectedScript, targetElement: Element): { selector: string, elements: Element[] } {
const path: SelectorToken[] = [];
let numberOfMatchingElements = Number.MAX_SAFE_INTEGER;
for (let element: Element | null = targetElement; element && element !== document.documentElement; element = parentElementOrShadowHost(element)) {
const selector = buildSelectorCandidate(element);
if (!selector)
continue;
const fullSelector = joinSelector([selector, ...path]);
const parsedSelector = injectedScript.parseSelector(fullSelector);
const selectorTargets = injectedScript.querySelectorAll(parsedSelector, targetElement.ownerDocument);
if (!selectorTargets.length)
break;
if (selectorTargets[0] === targetElement)
return { selector: fullSelector, elements: selectorTargets };
if (selectorTargets.length && numberOfMatchingElements > selectorTargets.length) {
numberOfMatchingElements = selectorTargets.length;
path.unshift(selector);
}
}
if (document.documentElement === targetElement) {
return {
selector: '/html',
elements: [document.documentElement]
};
}
const selector =
createXPath(document.documentElement, targetElement) ||
cssSelectorForElement(injectedScript, targetElement);
const parsedSelector = injectedScript.parseSelector(selector);
return {
selector,
elements: injectedScript.querySelectorAll(parsedSelector, targetElement.ownerDocument)
};
}
function buildSelectorCandidate(element: Element): SelectorToken | null {
const nodeName = element.nodeName.toLowerCase();
for (const attribute of ['data-testid', 'data-test-id', 'data-test']) {
if (element.hasAttribute(attribute))
return { engine: 'css', selector: `${nodeName}[${attribute}=${quoteString(element.getAttribute(attribute)!)}]` };
}
for (const attribute of ['aria-label', 'role']) {
if (element.hasAttribute(attribute))
return { engine: 'css', selector: `${element.nodeName.toLocaleLowerCase()}[${attribute}=${quoteString(element.getAttribute(attribute)!)}]` };
}
if (['INPUT', 'TEXTAREA'].includes(element.nodeName)) {
const nodeNameLowercase = element.nodeName.toLowerCase();
if (element.getAttribute('name'))
return { engine: 'css', selector: `${nodeNameLowercase}[name=${quoteString(element.getAttribute('name')!)}]` };
if (element.getAttribute('placeholder'))
return { engine: 'css', selector: `${nodeNameLowercase}[placeholder=${quoteString(element.getAttribute('placeholder')!)}]` };
if (element.getAttribute('type'))
return { engine: 'css', selector: `${nodeNameLowercase}[type=${quoteString(element.getAttribute('type')!)}]` };
} else if (element.nodeName === 'IMG') {
if (element.getAttribute('alt'))
return { engine: 'css', selector: `img[alt=${quoteString(element.getAttribute('alt')!)}]` };
}
const textSelector = textSelectorForElement(element);
if (textSelector)
return { engine: 'text', selector: textSelector };
// De-prioritize id, but still use it as a last resort.
const idAttr = element.getAttribute('id');
if (idAttr && !isGuidLike(idAttr))
return { engine: 'css', selector: `${nodeName}[id=${quoteString(idAttr!)}]` };
return null;
}
function parentElementOrShadowHost(element: Element): Element | null {
if (element.parentElement)
return element.parentElement;
if (!element.parentNode)
return null;
if (element.parentNode.nodeType === Node.DOCUMENT_FRAGMENT_NODE && (element.parentNode as ShadowRoot).host)
return (element.parentNode as ShadowRoot).host;
return null;
}
function cssSelectorForElement(injectedScript: InjectedScript, targetElement: Element): string {
const root: Node = targetElement.ownerDocument;
const tokens: string[] = [];
function uniqueCSSSelector(prefix?: string): string | undefined {
const path = tokens.slice();
if (prefix)
path.unshift(prefix);
const selector = path.join(' ');
const parsedSelector = injectedScript.parseSelector(selector);
const node = injectedScript.querySelector(parsedSelector, targetElement.ownerDocument);
return node === targetElement ? selector : undefined;
}
for (let element: Element | null = targetElement; element && element !== root; element = parentElementOrShadowHost(element)) {
const nodeName = element.nodeName.toLowerCase();
// Element ID is the strongest signal, use it.
let bestTokenForLevel: string = '';
if (element.id) {
const token = /^[a-zA-Z][a-zA-Z0-9\-\_]+$/.test(element.id) ? '#' + element.id : `[id="${element.id}"]`;
const selector = uniqueCSSSelector(token);
if (selector)
return selector;
bestTokenForLevel = token;
}
const parent = element.parentNode as (Element | ShadowRoot);
// Combine class names until unique.
const classes = Array.from(element.classList);
for (let i = 0; i < classes.length; ++i) {
const token = '.' + classes.slice(0, i + 1).join('.');
const selector = uniqueCSSSelector(token);
if (selector)
return selector;
// Even if not unique, does this subset of classes uniquely identify node as a child?
if (!bestTokenForLevel && parent) {
const sameClassSiblings = parent.querySelectorAll(token);
if (sameClassSiblings.length === 1)
bestTokenForLevel = token;
}
}
// Ordinal is the weakest signal.
if (parent) {
const siblings = Array.from(parent.children);
const sameTagSiblings = siblings.filter(sibling => (sibling).nodeName.toLowerCase() === nodeName);
const token = sameTagSiblings.indexOf(element) === 0 ? nodeName : `${nodeName}:nth-child(${1 + siblings.indexOf(element)})`;
const selector = uniqueCSSSelector(token);
if (selector)
return selector;
if (!bestTokenForLevel)
bestTokenForLevel = token;
} else if (!bestTokenForLevel) {
bestTokenForLevel = nodeName;
}
tokens.unshift(bestTokenForLevel);
}
return uniqueCSSSelector()!;
}
function textSelectorForElement(node: Node): string | null {
const maxLength = 30;
let needsRegex = false;
let trimmedText: string | null = null;
for (const child of node.childNodes) {
if (child.nodeType !== Node.TEXT_NODE)
continue;
if (child.textContent && child.textContent.trim()) {
if (trimmedText)
return null;
trimmedText = child.textContent.trim().substr(0, maxLength);
needsRegex = child.textContent !== trimmedText;
} else {
needsRegex = true;
}
}
if (!trimmedText)
return null;
return needsRegex ? `/.*${escapeForRegex(trimmedText)}.*/` : `"${trimmedText}"`;
}
function escapeForRegex(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function quoteString(text: string): string {
return `"${text.replaceAll(/"/g, '\\"')}"`;
}
type SelectorToken = {
engine: string;
selector: string;
};
function joinSelector(path: SelectorToken[]): string {
const tokens = [];
let lastEngine = '';
for (const { engine, selector } of path) {
if (tokens.length && (lastEngine !== 'css' || engine !== 'css'))
tokens.push('>>');
lastEngine = engine;
if (engine === 'css')
tokens.push(selector);
else
tokens.push(`${engine}=${selector}`);
}
return tokens.join(' ');
}
function isGuidLike(id: string): boolean {
let lastCharacterType: 'lower' | 'upper' | 'digit' | 'other' | undefined;
let transitionCount = 0;
for (let i = 0; i < id.length; ++i) {
const c = id[i];
let characterType: 'lower' | 'upper' | 'digit' | 'other';
if (c === '-' || c === '_')
continue;
if (c >= 'a' && c <= 'z')
characterType = 'lower';
else if (c >= 'A' && c <= 'Z')
characterType = 'upper';
else if (c >= '0' && c <= '9')
characterType = 'digit';
else
characterType = 'other';
if (characterType === 'lower' && lastCharacterType === 'upper') {
lastCharacterType = characterType;
continue;
}
if (lastCharacterType && lastCharacterType !== characterType)
++transitionCount;
lastCharacterType = characterType;
}
return transitionCount >= id.length / 4;
}
function createXPath(root: Node, targetElement: Element): string | undefined {
const maxTextLength = 80;
const minMeaningfulSelectorLegth = 100;
const maybeDocument = root instanceof Document ? root : root.ownerDocument;
if (!maybeDocument)
return;
const document = maybeDocument;
const xpathCache = new Map<string, Element[]>();
const tokens: string[] = [];
function evaluateXPath(expression: string): Element[] {
let nodes: Element[] | undefined = xpathCache.get(expression);
if (!nodes) {
nodes = [];
try {
const result = document.evaluate(expression, root, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE);
for (let node = result.iterateNext(); node; node = result.iterateNext()) {
if (node.nodeType === Node.ELEMENT_NODE)
nodes.push(node as Element);
}
} catch (e) {
}
xpathCache.set(expression, nodes);
}
return nodes;
}
function uniqueXPathSelector(prefix?: string): string | undefined {
const path = tokens.slice();
if (prefix)
path.unshift(prefix);
let selector = '//' + path.join('/');
while (selector.includes('///'))
selector = selector.replace('///', '//');
if (selector.endsWith('/'))
selector = selector.substring(0, selector.length - 1);
const nodes: Element[] = evaluateXPath(selector);
if (nodes[0] === targetElement)
return selector;
// If we are looking at a small set of elements with long selector, fall back to ordinal.
if (nodes.length < 5 && selector.length > minMeaningfulSelectorLegth) {
const index = nodes.indexOf(targetElement);
if (index !== -1)
return `(${selector})[${index + 1}]`;
}
return undefined;
}
function escapeAndCap(text: string) {
text = text.substring(0, maxTextLength);
// XPath 1.0 does not support quote escaping.
// 1. If there are no single quotes - use them.
if (text.indexOf(`'`) === -1)
return `'${text}'`;
// 2. If there are no double quotes - use them to enclose text.
if (text.indexOf(`"`) === -1)
return `"${text}"`;
// 3. Otherwise, use popular |concat| trick.
const Q = `'`;
return `concat(${text.split(Q).map(token => Q + token + Q).join(`, "'", `)})`;
}
const defaultAttributes = new Set([ 'title', 'aria-label', 'disabled', 'role' ]);
const importantAttributes = new Map<string, string[]>([
[ 'form', [ 'action' ] ],
[ 'img', [ 'alt' ] ],
[ 'input', [ 'placeholder', 'type', 'name' ] ],
[ 'textarea', [ 'placeholder', 'type', 'name' ] ],
]);
let usedTextConditions = false;
for (let element: Element | null = targetElement; element && element !== root; element = element.parentElement) {
const nodeName = element.nodeName.toLowerCase();
const tag = nodeName === 'svg' ? '*' : nodeName;
const tagConditions = [];
if (nodeName === 'svg')
tagConditions.push('local-name()="svg"');
const attrConditions: string[] = [];
const importantAttrs = [ ...defaultAttributes, ...(importantAttributes.get(tag) || []) ];
for (const attr of importantAttrs) {
const value = element.getAttribute(attr);
if (value && value.length < maxTextLength)
attrConditions.push(`normalize-space(@${attr})=${escapeAndCap(value)}`);
else if (value)
attrConditions.push(`starts-with(normalize-space(@${attr}), ${escapeAndCap(value)})`);
}
const text = document.evaluate('normalize-space(.)', element).stringValue;
const textConditions = [];
if (tag !== 'select' && text.length && !usedTextConditions) {
if (text.length < maxTextLength)
textConditions.push(`normalize-space(.)=${escapeAndCap(text)}`);
else
textConditions.push(`starts-with(normalize-space(.), ${escapeAndCap(text)})`);
usedTextConditions = true;
}
// Always retain the last tag.
const conditions = [ ...tagConditions, ...textConditions, ...attrConditions ];
const token = conditions.length ? `${tag}[${conditions.join(' and ')}]` : (tokens.length ? '' : tag);
const selector = uniqueXPathSelector(token);
if (selector)
return selector;
const parent = element.parentElement;
let ordinal = -1;
if (parent) {
const siblings = Array.from(parent.children);
const sameTagSiblings = siblings.filter(sibling => (sibling).nodeName.toLowerCase() === nodeName);
if (sameTagSiblings.length > 1)
ordinal = sameTagSiblings.indexOf(element);
}
// Do not include text into this token, only tag / attributes.
// Topmost node will get all the text.
const conditionsString = conditions.length ? `[${conditions.join(' and ')}]` : '';
const ordinalString = ordinal >= 0 ? `[${ordinal + 1}]` : '';
tokens.unshift(`${tag}${ordinalString}${conditionsString}`);
}
return uniqueXPathSelector();
}

View File

@ -18,14 +18,6 @@ import { SelectorEngine, SelectorRoot } from './selectorEngine';
export function createAttributeEngine(attribute: string, shadow: boolean): SelectorEngine { export function createAttributeEngine(attribute: string, shadow: boolean): SelectorEngine {
const engine: SelectorEngine = { const engine: SelectorEngine = {
create(root: SelectorRoot, target: Element): string | undefined {
const value = target.getAttribute(attribute);
if (!value)
return;
if (engine.query(root, value) === target)
return value;
},
query(root: SelectorRoot, selector: string): Element | undefined { query(root: SelectorRoot, selector: string): Element | undefined {
if (!shadow) if (!shadow)
return root.querySelector(`[${attribute}=${JSON.stringify(selector)}]`) || undefined; return root.querySelector(`[${attribute}=${JSON.stringify(selector)}]`) || undefined;

View File

@ -18,68 +18,6 @@ import { SelectorEngine, SelectorRoot } from './selectorEngine';
export function createCSSEngine(shadow: boolean): SelectorEngine { export function createCSSEngine(shadow: boolean): SelectorEngine {
const engine: SelectorEngine = { const engine: SelectorEngine = {
create(root: SelectorRoot, targetElement: Element): string | undefined {
if (shadow)
return;
const tokens: string[] = [];
function uniqueCSSSelector(prefix?: string): string | undefined {
const path = tokens.slice();
if (prefix)
path.unshift(prefix);
const selector = path.join(' > ');
const nodes = Array.from(root.querySelectorAll(selector));
return nodes[0] === targetElement ? selector : undefined;
}
for (let element: Element | null = targetElement; element && element !== root; element = element.parentElement) {
const nodeName = element.nodeName.toLowerCase();
// Element ID is the strongest signal, use it.
let bestTokenForLevel: string = '';
if (element.id) {
const token = /^[a-zA-Z][a-zA-Z0-9\-\_]+$/.test(element.id) ? '#' + element.id : `[id="${element.id}"]`;
const selector = uniqueCSSSelector(token);
if (selector)
return selector;
bestTokenForLevel = token;
}
const parent = element.parentElement;
// Combine class names until unique.
const classes = Array.from(element.classList);
for (let i = 0; i < classes.length; ++i) {
const token = '.' + classes.slice(0, i + 1).join('.');
const selector = uniqueCSSSelector(token);
if (selector)
return selector;
// Even if not unique, does this subset of classes uniquely identify node as a child?
if (!bestTokenForLevel && parent) {
const sameClassSiblings = parent.querySelectorAll(token);
if (sameClassSiblings.length === 1)
bestTokenForLevel = token;
}
}
// Ordinal is the weakest signal.
if (parent) {
const siblings = Array.from(parent.children);
const sameTagSiblings = siblings.filter(sibling => (sibling).nodeName.toLowerCase() === nodeName);
const token = sameTagSiblings.length === 1 ? nodeName : `${nodeName}:nth-child(${1 + siblings.indexOf(element)})`;
const selector = uniqueCSSSelector(token);
if (selector)
return selector;
if (!bestTokenForLevel)
bestTokenForLevel = token;
} else if (!bestTokenForLevel) {
bestTokenForLevel = nodeName;
}
tokens.unshift(bestTokenForLevel);
}
return uniqueCSSSelector();
},
query(root: SelectorRoot, selector: string): Element | undefined { query(root: SelectorRoot, selector: string): Element | undefined {
// TODO: uncomment for performance. // TODO: uncomment for performance.
// const simple = root.querySelector(selector); // const simple = root.querySelector(selector);

View File

@ -14,11 +14,9 @@
* limitations under the License. * limitations under the License.
*/ */
export type SelectorType = 'default' | 'notext';
export type SelectorRoot = Element | ShadowRoot | Document; export type SelectorRoot = Element | ShadowRoot | Document;
export interface SelectorEngine { export interface SelectorEngine {
create(root: SelectorRoot, target: Element, type?: SelectorType): string | undefined;
query(root: SelectorRoot, selector: string): Element | undefined; query(root: SelectorRoot, selector: string): Element | undefined;
queryAll(root: SelectorRoot, selector: string): Element[]; queryAll(root: SelectorRoot, selector: string): Element[];
} }

View File

@ -14,27 +14,10 @@
* limitations under the License. * limitations under the License.
*/ */
import { SelectorEngine, SelectorType, SelectorRoot } from './selectorEngine'; import { SelectorEngine, SelectorRoot } from './selectorEngine';
export function createTextSelector(shadow: boolean): SelectorEngine { export function createTextSelector(shadow: boolean): SelectorEngine {
const engine: SelectorEngine = { const engine: SelectorEngine = {
create(root: SelectorRoot, targetElement: Element, type: SelectorType): string | undefined {
const document = root instanceof Document ? root : root.ownerDocument;
if (!document)
return;
for (let child = targetElement.firstChild; child; child = child.nextSibling) {
if (child.nodeType === 3 /* Node.TEXT_NODE */) {
const text = child.nodeValue;
if (!text)
continue;
if (text.match(/^\s*[a-zA-Z0-9]+\s*$/) && engine.query(root, text.trim()) === targetElement)
return text.trim();
if (queryInternal(root, createMatcher(JSON.stringify(text)), shadow) === targetElement)
return JSON.stringify(text);
}
}
},
query(root: SelectorRoot, selector: string): Element | undefined { query(root: SelectorRoot, selector: string): Element | undefined {
return queryInternal(root, createMatcher(selector), shadow); return queryInternal(root, createMatcher(selector), shadow);
}, },

View File

@ -14,139 +14,9 @@
* limitations under the License. * limitations under the License.
*/ */
import { SelectorEngine, SelectorType, SelectorRoot } from './selectorEngine'; import { SelectorEngine, SelectorRoot } from './selectorEngine';
const maxTextLength = 80;
const minMeaningfulSelectorLegth = 100;
export const XPathEngine: SelectorEngine = { export const XPathEngine: SelectorEngine = {
create(root: SelectorRoot, targetElement: Element, type: SelectorType): string | undefined {
const maybeDocument = root instanceof Document ? root : root.ownerDocument;
if (!maybeDocument)
return;
const document = maybeDocument;
const xpathCache = new Map<string, Element[]>();
if (type === 'notext')
return createNoText(root, targetElement);
const tokens: string[] = [];
function evaluateXPath(expression: string): Element[] {
let nodes: Element[] | undefined = xpathCache.get(expression);
if (!nodes) {
nodes = [];
try {
const result = document.evaluate(expression, root, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE);
for (let node = result.iterateNext(); node; node = result.iterateNext()) {
if (node.nodeType === Node.ELEMENT_NODE)
nodes.push(node as Element);
}
} catch (e) {
}
xpathCache.set(expression, nodes);
}
return nodes;
}
function uniqueXPathSelector(prefix?: string): string | undefined {
const path = tokens.slice();
if (prefix)
path.unshift(prefix);
let selector = '//' + path.join('/');
while (selector.includes('///'))
selector = selector.replace('///', '//');
if (selector.endsWith('/'))
selector = selector.substring(0, selector.length - 1);
const nodes: Element[] = evaluateXPath(selector);
if (nodes[nodes.length - 1] === targetElement)
return selector;
// If we are looking at a small set of elements with long selector, fall back to ordinal.
if (nodes.length < 5 && selector.length > minMeaningfulSelectorLegth) {
const index = nodes.indexOf(targetElement);
if (index !== -1)
return `(${selector})[${index + 1}]`;
}
return undefined;
}
function escapeAndCap(text: string) {
text = text.substring(0, maxTextLength);
// XPath 1.0 does not support quote escaping.
// 1. If there are no single quotes - use them.
if (text.indexOf(`'`) === -1)
return `'${text}'`;
// 2. If there are no double quotes - use them to enclose text.
if (text.indexOf(`"`) === -1)
return `"${text}"`;
// 3. Otherwise, use popular |concat| trick.
const Q = `'`;
return `concat(${text.split(Q).map(token => Q + token + Q).join(`, "'", `)})`;
}
const defaultAttributes = new Set([ 'title', 'aria-label', 'disabled', 'role' ]);
const importantAttributes = new Map<string, string[]>([
[ 'form', [ 'action' ] ],
[ 'img', [ 'alt' ] ],
[ 'input', [ 'placeholder', 'type', 'name', 'value' ] ],
]);
let usedTextConditions = false;
for (let element: Element | null = targetElement; element && element !== root; element = element.parentElement) {
const nodeName = element.nodeName.toLowerCase();
const tag = nodeName === 'svg' ? '*' : nodeName;
const tagConditions = [];
if (nodeName === 'svg')
tagConditions.push('local-name()="svg"');
const attrConditions: string[] = [];
const importantAttrs = [ ...defaultAttributes, ...(importantAttributes.get(tag) || []) ];
for (const attr of importantAttrs) {
const value = element.getAttribute(attr);
if (value && value.length < maxTextLength)
attrConditions.push(`normalize-space(@${attr})=${escapeAndCap(value)}`);
else if (value)
attrConditions.push(`starts-with(normalize-space(@${attr}), ${escapeAndCap(value)})`);
}
const text = document.evaluate('normalize-space(.)', element).stringValue;
const textConditions = [];
if (tag !== 'select' && text.length && !usedTextConditions) {
if (text.length < maxTextLength)
textConditions.push(`normalize-space(.)=${escapeAndCap(text)}`);
else
textConditions.push(`starts-with(normalize-space(.), ${escapeAndCap(text)})`);
usedTextConditions = true;
}
// Always retain the last tag.
const conditions = [ ...tagConditions, ...textConditions, ...attrConditions ];
const token = conditions.length ? `${tag}[${conditions.join(' and ')}]` : (tokens.length ? '' : tag);
const selector = uniqueXPathSelector(token);
if (selector)
return selector;
// Ordinal is the weakest signal.
const parent = element.parentElement;
let tagWithOrdinal = tag;
if (parent) {
const siblings = Array.from(parent.children);
const sameTagSiblings = siblings.filter(sibling => (sibling).nodeName.toLowerCase() === nodeName);
if (sameTagSiblings.length > 1)
tagWithOrdinal += `[${1 + siblings.indexOf(element)}]`;
}
// Do not include text into this token, only tag / attributes.
// Topmost node will get all the text.
const nonTextConditions = [ ...tagConditions, ...attrConditions ];
const levelToken = nonTextConditions.length ? `${tagWithOrdinal}[${nonTextConditions.join(' and ')}]` : tokens.length ? '' : tagWithOrdinal;
tokens.unshift(levelToken);
}
return uniqueXPathSelector();
},
query(root: SelectorRoot, selector: string): Element | undefined { query(root: SelectorRoot, selector: string): Element | undefined {
const document = root instanceof Document ? root : root.ownerDocument; const document = root instanceof Document ? root : root.ownerDocument;
if (!document) if (!document)
@ -171,19 +41,3 @@ export const XPathEngine: SelectorEngine = {
return result; return result;
} }
}; };
function createNoText(root: SelectorRoot, targetElement: Element): string {
const steps = [];
for (let element: Element | null = targetElement; element && element !== root; element = element.parentElement) {
if (element.getAttribute('id')) {
steps.unshift(`//*[@id="${element.getAttribute('id')}"]`);
return steps.join('/');
}
const siblings = element.parentElement ? Array.from(element.parentElement.children) : [];
const similarElements: Element[] = siblings.filter(sibling => element!.nodeName === sibling.nodeName);
const index = similarElements.length === 1 ? 0 : similarElements.indexOf(element) + 1;
steps.unshift(index ? `${element.nodeName}[${index}]` : element.nodeName);
}
return '/' + steps.join('/');
}

View File

@ -1,6 +1,4 @@
({ ({
create(root, target) {
},
query(root, selector) { query(root, selector) {
return root.querySelector('section'); return root.querySelector('section');
}, },

View File

@ -165,7 +165,6 @@ describe('connect', (suite, { mode }) => {
it('should respect selectors', async ({ playwright, browserType, remoteServer }) => { it('should respect selectors', async ({ playwright, browserType, remoteServer }) => {
const mycss = () => ({ const mycss = () => ({
create(root, target) {},
query(root, selector) { query(root, selector) {
return root.querySelector(selector); return root.querySelector(selector);
}, },

View File

@ -219,7 +219,6 @@ it('should respect selectors', async ({playwright, launchPersistent}) => {
const {page} = await launchPersistent(); const {page} = await launchPersistent();
const defaultContextCSS = () => ({ const defaultContextCSS = () => ({
create(root, target) {},
query(root, selector) { query(root, selector) {
return root.querySelector(selector); return root.querySelector(selector);
}, },

View File

@ -71,7 +71,6 @@ it('textContent should work', async ({ page, server }) => {
it('textContent should be atomic', async ({ playwright, page }) => { it('textContent should be atomic', async ({ playwright, page }) => {
const createDummySelector = () => ({ const createDummySelector = () => ({
create(root, target) { },
query(root, selector) { query(root, selector) {
const result = root.querySelector(selector); const result = root.querySelector(selector);
if (result) if (result)
@ -94,7 +93,6 @@ it('textContent should be atomic', async ({ playwright, page }) => {
it('innerText should be atomic', async ({ playwright, page }) => { it('innerText should be atomic', async ({ playwright, page }) => {
const createDummySelector = () => ({ const createDummySelector = () => ({
create(root, target) { },
query(root: HTMLElement, selector: string) { query(root: HTMLElement, selector: string) {
const result = root.querySelector(selector); const result = root.querySelector(selector);
if (result) if (result)
@ -117,7 +115,6 @@ it('innerText should be atomic', async ({ playwright, page }) => {
it('innerHTML should be atomic', async ({ playwright, page }) => { it('innerHTML should be atomic', async ({ playwright, page }) => {
const createDummySelector = () => ({ const createDummySelector = () => ({
create(root, target) { },
query(root, selector) { query(root, selector) {
const result = root.querySelector(selector); const result = root.querySelector(selector);
if (result) if (result)
@ -140,7 +137,6 @@ it('innerHTML should be atomic', async ({ playwright, page }) => {
it('getAttribute should be atomic', async ({ playwright, page }) => { it('getAttribute should be atomic', async ({ playwright, page }) => {
const createDummySelector = () => ({ const createDummySelector = () => ({
create(root, target) { },
query(root: HTMLElement, selector: string) { query(root: HTMLElement, selector: string) {
const result = root.querySelector(selector); const result = root.querySelector(selector);
if (result) if (result)

View File

@ -105,7 +105,6 @@ it('should dispatch click when node is added in shadow dom', async ({page, serve
it('should be atomic', async ({playwright, page}) => { it('should be atomic', async ({playwright, page}) => {
const createDummySelector = () => ({ const createDummySelector = () => ({
create(root, target) {},
query(root, selector) { query(root, selector) {
const result = root.querySelector(selector); const result = root.querySelector(selector);
if (result) if (result)

View File

@ -0,0 +1,211 @@
/**
* 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 { folio } from './fixtures';
import * as path from 'path';
import type { Page, Frame } from '..';
const { installDebugControllerInContext } = require(path.join(__dirname, '..', 'lib', 'debug', 'debugController'));
const fixtures = folio.extend();
fixtures.context.override(async ({ context, toImpl }, run) => {
await installDebugControllerInContext(toImpl(context));
await run(context);
});
const { describe, it, expect } = fixtures.build();
async function generate(pageOrFrame: Page | Frame, target: string): Promise<string> {
return pageOrFrame.$eval(target, e => (window as any).playwright.selector(e));
}
describe('selector generator', (suite, { mode }) => {
suite.skip(mode !== 'default');
}, () => {
it('should generate for text', async ({ page }) => {
await page.setContent(`<div>Text</div>`);
expect(await generate(page, 'div')).toBe('text="Text"');
});
it('should use ordinal for identical nodes', async ({ page }) => {
await page.setContent(`<div>Text</div><div>Text</div><div mark=1>Text</div><div>Text</div>`);
expect(await generate(page, 'div[mark="1"]')).toBe('//div[3][normalize-space(.)=\'Text\']');
});
it('should prefer data-testid', async ({ page }) => {
await page.setContent(`<div>Text</div><div>Text</div><div data-testid=a>Text</div><div>Text</div>`);
expect(await generate(page, 'div[data-testid="a"]')).toBe('div[data-testid="a"]');
});
it('should handle first non-unique data-testid', async ({ page }) => {
await page.setContent(`
<div data-testid=a mark=1>
Text
</div>
<div data-testid=a>
Text
</div>`);
expect(await generate(page, 'div[mark="1"]')).toBe('div[data-testid="a"]');
});
it('should handle second non-unique data-testid', async ({ page }) => {
await page.setContent(`
<div data-testid=a>
Text
</div>
<div data-testid=a mark=1>
Text
</div>`);
expect(await generate(page, 'div[mark="1"]')).toBe('//div[2][normalize-space(.)=\'Text\']');
});
it('should use readable id', async ({ page }) => {
await page.setContent(`
<div></div>
<div id=first-item mark=1></div>
`);
expect(await generate(page, 'div[mark="1"]')).toBe('div[id="first-item"]');
});
it('should not use generated id', async ({ page }) => {
await page.setContent(`
<div></div>
<div id=aAbBcCdDeE mark=1></div>
`);
expect(await generate(page, 'div[mark="1"]')).toBe('//div[2]');
});
it('should separate selectors by >>', async ({ page }) => {
await page.setContent(`
<div>
<div>Text</div>
</div>
<div id="id">
<div>Text</div>
</div>
`);
expect(await generate(page, '#id > div')).toBe('div[id=\"id\"] >> text=\"Text\"');
});
it('should trim long text', async ({ page }) => {
await page.setContent(`
<div>
<div>Text that goes on and on and on and on and on and on and on and on and on and on and on and on and on and on and on</div>
</div>
<div id="id">
<div>Text that goes on and on and on and on and on and on and on and on and on and on and on and on and on and on and on</div>
</div>
`);
expect(await generate(page, '#id > div')).toBe('div[id=\"id\"] >> text=/.*Text that goes on and on and o.*/');
});
it('should use nested ordinals', async ({ page }) => {
await page.setContent(`
<a><b></b></a>
<a>
<b>
<c>
</c>
</b>
<b>
<c mark=1></c>
</b>
</a>
<a><b></b></a>
`);
expect(await generate(page, 'c[mark="1"]')).toBe('//b[2]/c');
});
it('should not use input[value]', async ({ page }) => {
await page.setContent(`
<input value="one">
<input value="two" mark="1">
<input value="three">
`);
expect(await generate(page, 'input[mark="1"]')).toBe('//input[2]');
});
describe('should prioritise input element attributes correctly', () => {
it('name', async ({ page }) => {
await page.setContent(`<input name="foobar" type="text"/>`);
expect(await generate(page, 'input')).toBe('input[name="foobar"]');
});
it('placeholder', async ({ page }) => {
await page.setContent(`<input placeholder="foobar" type="text"/>`);
expect(await generate(page, 'input')).toBe('input[placeholder="foobar"]');
});
it('type', async ({ page }) => {
await page.setContent(`<input type="text"/>`);
expect(await generate(page, 'input')).toBe('input[type="text"]');
});
});
it('should find text in shadow dom', async ({ page }) => {
await page.setContent(`<div></div>`);
await page.$eval('div', div => {
const shadowRoot = div.attachShadow({ mode: 'open' });
const span = document.createElement('span');
span.textContent = 'Target';
shadowRoot.appendChild(span);
});
expect(await generate(page, 'span')).toBe('text="Target"');
});
it('should fallback to css in shadow dom', async ({ page }) => {
await page.setContent(`<div></div>`);
await page.$eval('div', div => {
const shadowRoot = div.attachShadow({ mode: 'open' });
const input = document.createElement('input');
shadowRoot.appendChild(input);
});
expect(await generate(page, 'input')).toBe('input');
});
it('should fallback to css in deep shadow dom', async ({ page }) => {
await page.setContent(`<div></div><div></div><div><input></div>`);
await page.$eval('div', div1 => {
const shadowRoot1 = div1.attachShadow({ mode: 'open' });
const input1 = document.createElement('input');
shadowRoot1.appendChild(input1);
const divExtra3 = document.createElement('div');
shadowRoot1.append(divExtra3);
const div2 = document.createElement('div');
shadowRoot1.append(div2);
const shadowRoot2 = div2.attachShadow({ mode: 'open' });
const input2 = document.createElement('input');
input2.setAttribute('value', 'foo');
shadowRoot2.appendChild(input2);
});
expect(await generate(page, 'input[value=foo]')).toBe('div div:nth-child(3) input');
});
it('should work in dynamic iframes without navigation', async ({ page }) => {
await page.setContent(`<div></div>`);
const [frame] = await Promise.all([
page.waitForEvent('frameattached'),
page.evaluate(() => {
return new Promise(f => {
const iframe = document.createElement('iframe');
iframe.onload = () => {
iframe.contentDocument.body.innerHTML = '<div>Target</div>';
f();
};
document.body.appendChild(iframe);
});
}),
]);
expect(await generate(frame, 'div')).toBe('text="Target"');
});
});

View File

@ -21,9 +21,6 @@ import path from 'path';
it('should work', async ({playwright, browser}) => { it('should work', async ({playwright, browser}) => {
const createTagSelector = () => ({ const createTagSelector = () => ({
create(root, target) {
return target.nodeName;
},
query(root, selector) { query(root, selector) {
return root.querySelector(selector); return root.querySelector(selector);
}, },
@ -64,7 +61,6 @@ it('should work with path', async ({playwright, page}) => {
it('should work in main and isolated world', async ({playwright, page}) => { it('should work in main and isolated world', async ({playwright, page}) => {
const createDummySelector = () => ({ const createDummySelector = () => ({
create(root, target) { },
query(root, selector) { query(root, selector) {
return window['__answer']; return window['__answer'];
}, },
@ -99,9 +95,6 @@ it('should handle errors', async ({playwright, page}) => {
expect(error.message).toContain('Unknown engine "neverregister" while parsing selector neverregister=ignored'); expect(error.message).toContain('Unknown engine "neverregister" while parsing selector neverregister=ignored');
const createDummySelector = () => ({ const createDummySelector = () => ({
create(root, target) {
return target.nodeName;
},
query(root, selector) { query(root, selector) {
return root.querySelector('dummy'); return root.querySelector('dummy');
}, },
@ -126,9 +119,6 @@ it('should handle errors', async ({playwright, page}) => {
it('should not rely on engines working from the root', async ({ playwright, page }) => { it('should not rely on engines working from the root', async ({ playwright, page }) => {
const createValueEngine = () => ({ const createValueEngine = () => ({
create(root, target) {
return undefined;
},
query(root, selector) { query(root, selector) {
return root && root.value.includes(selector) ? root : undefined; return root && root.value.includes(selector) ? root : undefined;
}, },

View File

@ -749,18 +749,12 @@ playwright.chromium.launch().then(async browser => {
// Must be a function that evaluates to a selector engine instance. // Must be a function that evaluates to a selector engine instance.
const createTagNameEngine = () => ({ const createTagNameEngine = () => ({
// Creates a selector that matches given target when queried at the root. // Returns the first element matching given selector in the root's subtree.
// Can return undefined if unable to create one.
create(root: Element, target: Element) {
return root.querySelector(target.tagName) === target ? target.tagName : undefined;
},
// Returns the first element matching given selector in the root's subtree.
query(root: Element, selector: string) { query(root: Element, selector: string) {
return root.querySelector(selector); return root.querySelector(selector);
}, },
// Returns all elements matching given selector in the root's subtree. // Returns all elements matching given selector in the root's subtree.
queryAll(root: Element, selector: string) { queryAll(root: Element, selector: string) {
return Array.from(root.querySelectorAll(selector)); return Array.from(root.querySelectorAll(selector));
} }