From f709e2300c3fdd793162c7ffce2f40f727f77d3f Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Wed, 23 Dec 2020 12:44:47 -0800 Subject: [PATCH] feat(cli): bring selector generator into playwright (#4795) Also remove unused `SelectorEngine.create` function and add tests. --- docs-src/api-body.md | 6 - docs/api.md | 6 - docs/extensibility.md | 6 - src/client/types.ts | 5 - src/debug/debugController.ts | 34 +- src/debug/injected/consoleApi.ts | 14 +- src/debug/injected/selectorGenerator.ts | 363 ++++++++++++++++++ .../injected/attributeSelectorEngine.ts | 8 - src/server/injected/cssSelectorEngine.ts | 62 --- src/server/injected/selectorEngine.ts | 2 - src/server/injected/textSelectorEngine.ts | 19 +- src/server/injected/xpathSelectorEngine.ts | 148 +------ test/assets/sectionselectorengine.js | 2 - test/browsertype-connect.spec.ts | 1 - test/defaultbrowsercontext-2.spec.ts | 1 - test/elementhandle-convenience.spec.ts | 4 - test/page-dispatchevent.spec.ts | 1 - test/selector-generator.spec.ts | 211 ++++++++++ test/selectors-register.spec.ts | 10 - utils/generate_types/test/test.ts | 10 +- 20 files changed, 608 insertions(+), 305 deletions(-) create mode 100644 src/debug/injected/selectorGenerator.ts create mode 100644 test/selector-generator.spec.ts diff --git a/docs-src/api-body.md b/docs-src/api-body.md index 2e08da12e9..88dd6561e6 100644 --- a/docs-src/api-body.md +++ b/docs-src/api-body.md @@ -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); diff --git a/docs/api.md b/docs/api.md index 776a29bebc..7cb099978a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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); diff --git a/docs/extensibility.md b/docs/extensibility.md index c2a8e6255f..0a5ccef328 100644 --- a/docs/extensibility.md +++ b/docs/extensibility.md @@ -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); diff --git a/src/client/types.ts b/src/client/types.ts index 99594a0ef8..d393c3a278 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -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. */ diff --git a/src/debug/debugController.ts b/src/debug/debugController.ts index b1ecc367f4..dd3747be0e 100644 --- a/src/debug/debugController.ts +++ b/src/debug/debugController.ts @@ -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 { - 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 {} async onContextDidDestroy(context: BrowserContext): Promise {} } + +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)); +} diff --git a/src/debug/injected/consoleApi.ts b/src/debug/injected/consoleApi.ts index 6ef01d8c0d..851e5b5779 100644 --- a/src/debug/injected/consoleApi.ts +++ b/src/debug/injected/consoleApi.ts @@ -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; + } } diff --git a/src/debug/injected/selectorGenerator.ts b/src/debug/injected/selectorGenerator.ts new file mode 100644 index 0000000000..a1065a9b31 --- /dev/null +++ b/src/debug/injected/selectorGenerator.ts @@ -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(); + 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([ + [ '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(); +} diff --git a/src/server/injected/attributeSelectorEngine.ts b/src/server/injected/attributeSelectorEngine.ts index f1413a41a6..4965d81acf 100644 --- a/src/server/injected/attributeSelectorEngine.ts +++ b/src/server/injected/attributeSelectorEngine.ts @@ -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; diff --git a/src/server/injected/cssSelectorEngine.ts b/src/server/injected/cssSelectorEngine.ts index 2dd27eba98..4002ac62e8 100644 --- a/src/server/injected/cssSelectorEngine.ts +++ b/src/server/injected/cssSelectorEngine.ts @@ -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); diff --git a/src/server/injected/selectorEngine.ts b/src/server/injected/selectorEngine.ts index eadb8ea7a3..c430dd919d 100644 --- a/src/server/injected/selectorEngine.ts +++ b/src/server/injected/selectorEngine.ts @@ -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[]; } diff --git a/src/server/injected/textSelectorEngine.ts b/src/server/injected/textSelectorEngine.ts index aa2a3243a1..70cb80e86d 100644 --- a/src/server/injected/textSelectorEngine.ts +++ b/src/server/injected/textSelectorEngine.ts @@ -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); }, diff --git a/src/server/injected/xpathSelectorEngine.ts b/src/server/injected/xpathSelectorEngine.ts index edf73ea17b..41a7be837d 100644 --- a/src/server/injected/xpathSelectorEngine.ts +++ b/src/server/injected/xpathSelectorEngine.ts @@ -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(); - 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([ - [ '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('/'); -} diff --git a/test/assets/sectionselectorengine.js b/test/assets/sectionselectorengine.js index e4a836452c..e83b187a95 100644 --- a/test/assets/sectionselectorengine.js +++ b/test/assets/sectionselectorengine.js @@ -1,6 +1,4 @@ ({ - create(root, target) { - }, query(root, selector) { return root.querySelector('section'); }, diff --git a/test/browsertype-connect.spec.ts b/test/browsertype-connect.spec.ts index 20005a2f61..af4b38e5d4 100644 --- a/test/browsertype-connect.spec.ts +++ b/test/browsertype-connect.spec.ts @@ -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); }, diff --git a/test/defaultbrowsercontext-2.spec.ts b/test/defaultbrowsercontext-2.spec.ts index cd6f9ee6ec..453227aa9b 100644 --- a/test/defaultbrowsercontext-2.spec.ts +++ b/test/defaultbrowsercontext-2.spec.ts @@ -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); }, diff --git a/test/elementhandle-convenience.spec.ts b/test/elementhandle-convenience.spec.ts index 5b3e2aaeb0..160e8e3654 100644 --- a/test/elementhandle-convenience.spec.ts +++ b/test/elementhandle-convenience.spec.ts @@ -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) diff --git a/test/page-dispatchevent.spec.ts b/test/page-dispatchevent.spec.ts index 8786a22b84..f8a7fa49b1 100644 --- a/test/page-dispatchevent.spec.ts +++ b/test/page-dispatchevent.spec.ts @@ -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) diff --git a/test/selector-generator.spec.ts b/test/selector-generator.spec.ts new file mode 100644 index 0000000000..f3c2d18e56 --- /dev/null +++ b/test/selector-generator.spec.ts @@ -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 { + 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(`
Text
`); + expect(await generate(page, 'div')).toBe('text="Text"'); + }); + + it('should use ordinal for identical nodes', async ({ page }) => { + await page.setContent(`
Text
Text
Text
Text
`); + expect(await generate(page, 'div[mark="1"]')).toBe('//div[3][normalize-space(.)=\'Text\']'); + }); + + it('should prefer data-testid', async ({ page }) => { + await page.setContent(`
Text
Text
Text
Text
`); + 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(` +
+ Text +
+
+ Text +
`); + 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(` +
+ Text +
+
+ Text +
`); + expect(await generate(page, 'div[mark="1"]')).toBe('//div[2][normalize-space(.)=\'Text\']'); + }); + + it('should use readable id', async ({ page }) => { + await page.setContent(` +
+
+ `); + expect(await generate(page, 'div[mark="1"]')).toBe('div[id="first-item"]'); + }); + + it('should not use generated id', async ({ page }) => { + await page.setContent(` +
+
+ `); + expect(await generate(page, 'div[mark="1"]')).toBe('//div[2]'); + }); + + it('should separate selectors by >>', async ({ page }) => { + await page.setContent(` +
+
Text
+
+
+
Text
+
+ `); + expect(await generate(page, '#id > div')).toBe('div[id=\"id\"] >> text=\"Text\"'); + }); + + it('should trim long text', async ({ page }) => { + await page.setContent(` +
+
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
+
+
+
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
+
+ `); + 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(` + + + + + + + + + + + + `); + expect(await generate(page, 'c[mark="1"]')).toBe('//b[2]/c'); + }); + + it('should not use input[value]', async ({ page }) => { + await page.setContent(` + + + + `); + expect(await generate(page, 'input[mark="1"]')).toBe('//input[2]'); + }); + + describe('should prioritise input element attributes correctly', () => { + it('name', async ({ page }) => { + await page.setContent(``); + expect(await generate(page, 'input')).toBe('input[name="foobar"]'); + }); + it('placeholder', async ({ page }) => { + await page.setContent(``); + expect(await generate(page, 'input')).toBe('input[placeholder="foobar"]'); + }); + it('type', async ({ page }) => { + await page.setContent(``); + expect(await generate(page, 'input')).toBe('input[type="text"]'); + }); + }); + + it('should find text in shadow dom', async ({ page }) => { + await page.setContent(`
`); + 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(`
`); + 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(`
`); + 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(`
`); + 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 = '
Target
'; + f(); + }; + document.body.appendChild(iframe); + }); + }), + ]); + expect(await generate(frame, 'div')).toBe('text="Target"'); + }); +}); diff --git a/test/selectors-register.spec.ts b/test/selectors-register.spec.ts index 88640a8dc2..7a781be96b 100644 --- a/test/selectors-register.spec.ts +++ b/test/selectors-register.spec.ts @@ -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; }, diff --git a/utils/generate_types/test/test.ts b/utils/generate_types/test/test.ts index 98193e96bb..874545e96d 100644 --- a/utils/generate_types/test/test.ts +++ b/utils/generate_types/test/test.ts @@ -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)); }