mirror of
https://github.com/microsoft/playwright.git
synced 2025-06-26 21:40:17 +00:00
feat(cli): bring selector generator into playwright (#4795)
Also remove unused `SelectorEngine.create` function and add tests.
This commit is contained in:
parent
8d4c46ac19
commit
f709e2300c
@ -4639,12 +4639,6 @@ const { selectors, firefox } = require('playwright'); // Or 'chromium' or 'webk
|
||||
(async () => {
|
||||
// Must be a function that evaluates to a selector engine instance.
|
||||
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.
|
||||
query(root, selector) {
|
||||
return root.querySelector(selector);
|
||||
|
@ -4224,12 +4224,6 @@ const { selectors, firefox } = require('playwright'); // Or 'chromium' or 'webk
|
||||
(async () => {
|
||||
// Must be a function that evaluates to a selector engine instance.
|
||||
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.
|
||||
query(root, selector) {
|
||||
return root.querySelector(selector);
|
||||
|
@ -20,12 +20,6 @@ An example of registering selector engine that queries elements based on a tag n
|
||||
```js
|
||||
// Must be a function that evaluates to a selector engine instance.
|
||||
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.
|
||||
query(root, selector) {
|
||||
return root.querySelector(selector);
|
||||
|
@ -100,11 +100,6 @@ export type LaunchServerOptions = {
|
||||
} & FirefoxUserPrefs;
|
||||
|
||||
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.
|
||||
*/
|
||||
|
@ -25,23 +25,27 @@ export function installDebugController() {
|
||||
}
|
||||
|
||||
class DebugController implements ContextListener {
|
||||
private async ensureInstalledInFrame(frame: frames.Frame) {
|
||||
try {
|
||||
await frame.extendInjectedScript(debugScriptSource.source);
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
|
||||
async onContextCreated(context: BrowserContext): Promise<void> {
|
||||
if (!isDebugMode())
|
||||
return;
|
||||
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));
|
||||
});
|
||||
if (isDebugMode())
|
||||
installDebugControllerInContext(context);
|
||||
}
|
||||
|
||||
async onContextWillDestroy(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));
|
||||
}
|
||||
|
@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
import type InjectedScript from '../../server/injected/injectedScript';
|
||||
import { generateSelector } from './selectorGenerator';
|
||||
|
||||
export class ConsoleAPI {
|
||||
private _injectedScript: InjectedScript;
|
||||
@ -25,28 +26,35 @@ export class ConsoleAPI {
|
||||
$: (selector: string) => this._querySelector(selector),
|
||||
$$: (selector: string) => this._querySelectorAll(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')
|
||||
throw new Error(`Usage: playwright.query('Playwright >> selector').`);
|
||||
const parsed = this._injectedScript.parseSelector(selector);
|
||||
return this._injectedScript.querySelector(parsed, document);
|
||||
}
|
||||
|
||||
_querySelectorAll(selector: string): Element[] {
|
||||
private _querySelectorAll(selector: string): Element[] {
|
||||
if (typeof selector !== 'string')
|
||||
throw new Error(`Usage: playwright.$$('Playwright >> selector').`);
|
||||
const parsed = this._injectedScript.parseSelector(selector);
|
||||
return this._injectedScript.querySelectorAll(parsed, document);
|
||||
}
|
||||
|
||||
_inspect(selector: string) {
|
||||
private _inspect(selector: string) {
|
||||
if (typeof (window as any).inspect !== 'function')
|
||||
return;
|
||||
if (typeof selector !== 'string')
|
||||
throw new Error(`Usage: playwright.inspect('Playwright >> 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;
|
||||
}
|
||||
}
|
||||
|
363
src/debug/injected/selectorGenerator.ts
Normal file
363
src/debug/injected/selectorGenerator.ts
Normal 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();
|
||||
}
|
@ -18,14 +18,6 @@ import { SelectorEngine, SelectorRoot } from './selectorEngine';
|
||||
|
||||
export function createAttributeEngine(attribute: string, shadow: boolean): 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 {
|
||||
if (!shadow)
|
||||
return root.querySelector(`[${attribute}=${JSON.stringify(selector)}]`) || undefined;
|
||||
|
@ -18,68 +18,6 @@ import { SelectorEngine, SelectorRoot } from './selectorEngine';
|
||||
|
||||
export function createCSSEngine(shadow: boolean): 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 {
|
||||
// TODO: uncomment for performance.
|
||||
// const simple = root.querySelector(selector);
|
||||
|
@ -14,11 +14,9 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export type SelectorType = 'default' | 'notext';
|
||||
export type SelectorRoot = Element | ShadowRoot | Document;
|
||||
|
||||
export interface SelectorEngine {
|
||||
create(root: SelectorRoot, target: Element, type?: SelectorType): string | undefined;
|
||||
query(root: SelectorRoot, selector: string): Element | undefined;
|
||||
queryAll(root: SelectorRoot, selector: string): Element[];
|
||||
}
|
||||
|
@ -14,27 +14,10 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { SelectorEngine, SelectorType, SelectorRoot } from './selectorEngine';
|
||||
import { SelectorEngine, SelectorRoot } from './selectorEngine';
|
||||
|
||||
export function createTextSelector(shadow: boolean): 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 {
|
||||
return queryInternal(root, createMatcher(selector), shadow);
|
||||
},
|
||||
|
@ -14,139 +14,9 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { SelectorEngine, SelectorType, SelectorRoot } from './selectorEngine';
|
||||
|
||||
const maxTextLength = 80;
|
||||
const minMeaningfulSelectorLegth = 100;
|
||||
import { SelectorEngine, SelectorRoot } from './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 {
|
||||
const document = root instanceof Document ? root : root.ownerDocument;
|
||||
if (!document)
|
||||
@ -171,19 +41,3 @@ export const XPathEngine: SelectorEngine = {
|
||||
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('/');
|
||||
}
|
||||
|
@ -1,6 +1,4 @@
|
||||
({
|
||||
create(root, target) {
|
||||
},
|
||||
query(root, selector) {
|
||||
return root.querySelector('section');
|
||||
},
|
||||
|
@ -165,7 +165,6 @@ describe('connect', (suite, { mode }) => {
|
||||
|
||||
it('should respect selectors', async ({ playwright, browserType, remoteServer }) => {
|
||||
const mycss = () => ({
|
||||
create(root, target) {},
|
||||
query(root, selector) {
|
||||
return root.querySelector(selector);
|
||||
},
|
||||
|
@ -219,7 +219,6 @@ it('should respect selectors', async ({playwright, launchPersistent}) => {
|
||||
const {page} = await launchPersistent();
|
||||
|
||||
const defaultContextCSS = () => ({
|
||||
create(root, target) {},
|
||||
query(root, selector) {
|
||||
return root.querySelector(selector);
|
||||
},
|
||||
|
@ -71,7 +71,6 @@ it('textContent should work', async ({ page, server }) => {
|
||||
|
||||
it('textContent should be atomic', async ({ playwright, page }) => {
|
||||
const createDummySelector = () => ({
|
||||
create(root, target) { },
|
||||
query(root, selector) {
|
||||
const result = root.querySelector(selector);
|
||||
if (result)
|
||||
@ -94,7 +93,6 @@ it('textContent should be atomic', async ({ playwright, page }) => {
|
||||
|
||||
it('innerText should be atomic', async ({ playwright, page }) => {
|
||||
const createDummySelector = () => ({
|
||||
create(root, target) { },
|
||||
query(root: HTMLElement, selector: string) {
|
||||
const result = root.querySelector(selector);
|
||||
if (result)
|
||||
@ -117,7 +115,6 @@ it('innerText should be atomic', async ({ playwright, page }) => {
|
||||
|
||||
it('innerHTML should be atomic', async ({ playwright, page }) => {
|
||||
const createDummySelector = () => ({
|
||||
create(root, target) { },
|
||||
query(root, selector) {
|
||||
const result = root.querySelector(selector);
|
||||
if (result)
|
||||
@ -140,7 +137,6 @@ it('innerHTML should be atomic', async ({ playwright, page }) => {
|
||||
|
||||
it('getAttribute should be atomic', async ({ playwright, page }) => {
|
||||
const createDummySelector = () => ({
|
||||
create(root, target) { },
|
||||
query(root: HTMLElement, selector: string) {
|
||||
const result = root.querySelector(selector);
|
||||
if (result)
|
||||
|
@ -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}) => {
|
||||
const createDummySelector = () => ({
|
||||
create(root, target) {},
|
||||
query(root, selector) {
|
||||
const result = root.querySelector(selector);
|
||||
if (result)
|
||||
|
211
test/selector-generator.spec.ts
Normal file
211
test/selector-generator.spec.ts
Normal 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"');
|
||||
});
|
||||
});
|
@ -21,9 +21,6 @@ import path from 'path';
|
||||
|
||||
it('should work', async ({playwright, browser}) => {
|
||||
const createTagSelector = () => ({
|
||||
create(root, target) {
|
||||
return target.nodeName;
|
||||
},
|
||||
query(root, 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}) => {
|
||||
const createDummySelector = () => ({
|
||||
create(root, target) { },
|
||||
query(root, selector) {
|
||||
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');
|
||||
|
||||
const createDummySelector = () => ({
|
||||
create(root, target) {
|
||||
return target.nodeName;
|
||||
},
|
||||
query(root, selector) {
|
||||
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 }) => {
|
||||
const createValueEngine = () => ({
|
||||
create(root, target) {
|
||||
return undefined;
|
||||
},
|
||||
query(root, selector) {
|
||||
return root && root.value.includes(selector) ? root : undefined;
|
||||
},
|
||||
|
@ -749,18 +749,12 @@ playwright.chromium.launch().then(async browser => {
|
||||
|
||||
// Must be a function that evaluates to a selector engine instance.
|
||||
const createTagNameEngine = () => ({
|
||||
// Creates a selector that matches given target when queried at the root.
|
||||
// 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.
|
||||
// Returns the first element matching given selector in the root's subtree.
|
||||
query(root: Element, selector: string) {
|
||||
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) {
|
||||
return Array.from(root.querySelectorAll(selector));
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user