From 60942d0af5f3b806ae5b8b6f619a61ad5603d866 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Wed, 25 Mar 2020 14:08:46 -0700 Subject: [PATCH] chore(selectors): move selectors logic to selectors.ts (#1536) This encapsulates selectors logic in one place, in a preparation for more complex scenarios like main-world selectors or piercing frames. Note: we had `Page.fill should wait for visible visibilty` test, but we do not actually wait for visible in page.fill(). It happened to pass due to lucky evaluation order. References #1316. --- src/dom.ts | 74 ++----- src/firefox/ffPage.ts | 4 +- src/frames.ts | 56 ++--- src/injected/injected.ts | 191 ++++-------------- src/injected/selectorEvaluator.ts | 102 ++++++++++ ...js => selectorEvaluator.webpack.config.js} | 6 +- src/injected/utils.ts | 39 ---- src/selectors.ts | 176 ++++++++++++++-- src/server/playwright.ts | 3 +- src/types.ts | 5 + src/webkit/wkPage.ts | 4 +- test/page.spec.js | 13 -- utils/runWebpack.js | 2 +- 13 files changed, 347 insertions(+), 328 deletions(-) create mode 100644 src/injected/selectorEvaluator.ts rename src/injected/{injected.webpack.config.js => selectorEvaluator.webpack.config.js} (85%) delete mode 100644 src/injected/utils.ts diff --git a/src/dom.ts b/src/dom.ts index 4da91292e3..e2166f2dde 100644 --- a/src/dom.ts +++ b/src/dom.ts @@ -18,12 +18,11 @@ import * as frames from './frames'; import * as input from './input'; import * as js from './javascript'; import * as types from './types'; -import * as injectedSource from './generated/injectedSource'; import { assert, helper, debugError } from './helper'; import Injected from './injected/injected'; import { Page } from './page'; import * as platform from './platform'; -import { Selectors } from './selectors'; +import { selectors } from './selectors'; export type PointerActionOptions = { modifiers?: input.Modifier[]; @@ -36,9 +35,7 @@ export type MultiClickOptions = PointerActionOptions & input.MouseMultiClickOpti export class FrameExecutionContext extends js.ExecutionContext { readonly frame: frames.Frame; - private _injectedPromise?: Promise; - private _injectedGeneration = -1; constructor(delegate: js.ExecutionContextDelegate, frame: frames.Frame) { super(delegate); @@ -64,58 +61,13 @@ export class FrameExecutionContext extends js.ExecutionContext { } _injected(): Promise> { - const selectors = Selectors._instance(); - if (this._injectedPromise && selectors._generation !== this._injectedGeneration) { - this._injectedPromise.then(handle => handle.dispose()); - this._injectedPromise = undefined; - } if (!this._injectedPromise) { - const custom: string[] = []; - for (const [name, source] of selectors._engines) - custom.push(`{ name: '${name}', engine: (${source}) }`); - const source = ` - new (${injectedSource.source})([ - ${custom.join(',\n')} - ]) - `; - this._injectedPromise = this._doEvaluateInternal(false /* returnByValue */, false /* waitForNavigations */, source); - this._injectedGeneration = selectors._generation; + this._injectedPromise = selectors._prepareEvaluator(this).then(evaluator => { + return this.evaluateHandleInternal(evaluator => evaluator.injected, evaluator); + }); } return this._injectedPromise; } - - async _$(selector: string, scope?: ElementHandle): Promise | null> { - const handle = await this.evaluateHandleInternal( - ({ injected, selector, scope }) => injected.querySelector(selector, scope || document), - { injected: await this._injected(), selector, scope } - ); - if (!handle.asElement()) - handle.dispose(); - return handle.asElement() as ElementHandle; - } - - async _$array(selector: string, scope?: ElementHandle): Promise> { - const arrayHandle = await this.evaluateHandleInternal( - ({ injected, selector, scope }) => injected.querySelectorAll(selector, scope || document), - { injected: await this._injected(), selector, scope } - ); - return arrayHandle; - } - - async _$$(selector: string, scope?: ElementHandle): Promise[]> { - const arrayHandle = await this._$array(selector, scope); - const properties = await arrayHandle.getProperties(); - arrayHandle.dispose(); - const result: ElementHandle[] = []; - for (const property of properties.values()) { - const elementHandle = property.asElement() as ElementHandle; - if (elementHandle) - result.push(elementHandle); - else - property.dispose(); - } - return result; - } } export class ElementHandle extends js.JSHandle { @@ -369,28 +321,32 @@ export class ElementHandle extends js.JSHandle { } $(selector: string): Promise { - return this._context._$(selector, this); + // TODO: this should be ownerFrame() instead. + return selectors._query(this._context.frame, selector, this); } $$(selector: string): Promise[]> { - return this._context._$$(selector, this); + // TODO: this should be ownerFrame() instead. + return selectors._queryAll(this._context.frame, selector, this); } async $eval(selector: string, pageFunction: types.FuncOn, arg: Arg): Promise; async $eval(selector: string, pageFunction: types.FuncOn, arg?: any): Promise; async $eval(selector: string, pageFunction: types.FuncOn, arg: Arg): Promise { - const elementHandle = await this._context._$(selector, this); - if (!elementHandle) + // TODO: this should be ownerFrame() instead. + const handle = await selectors._query(this._context.frame, selector, this); + if (!handle) throw new Error(`Error: failed to find element matching selector "${selector}"`); - const result = await elementHandle.evaluate(pageFunction, arg); - elementHandle.dispose(); + const result = await handle.evaluate(pageFunction, arg); + handle.dispose(); return result; } async $$eval(selector: string, pageFunction: types.FuncOn, arg: Arg): Promise; async $$eval(selector: string, pageFunction: types.FuncOn, arg?: any): Promise; async $$eval(selector: string, pageFunction: types.FuncOn, arg: Arg): Promise { - const arrayHandle = await this._context._$array(selector, this); + // TODO: this should be ownerFrame() instead. + const arrayHandle = await selectors._queryArray(this._context.frame, selector, this); const result = await arrayHandle.evaluate(pageFunction, arg); arrayHandle.dispose(); return result; diff --git a/src/firefox/ffPage.ts b/src/firefox/ffPage.ts index 68dc1893ee..e9585f8268 100644 --- a/src/firefox/ffPage.ts +++ b/src/firefox/ffPage.ts @@ -31,6 +31,7 @@ import { FFExecutionContext } from './ffExecutionContext'; import { RawKeyboardImpl, RawMouseImpl } from './ffInput'; import { FFNetworkManager, headersArray } from './ffNetworkManager'; import { Protocol } from './protocol'; +import { selectors } from '../selectors'; const UTILITY_WORLD_NAME = '__playwright_utility_world__'; @@ -467,8 +468,7 @@ export class FFPage implements PageDelegate { const parent = frame.parentFrame(); if (!parent) throw new Error('Frame has been detached.'); - const context = await parent._utilityContext(); - const handles = await context._$$('iframe'); + const handles = await selectors._queryAll(parent, 'iframe', undefined, true /* allowUtilityContext */); const items = await Promise.all(handles.map(async handle => { const frame = await handle.contentFrame().catch(e => null); return { handle, frame }; diff --git a/src/frames.ts b/src/frames.ts index fb99cfb730..f21f7223f2 100644 --- a/src/frames.ts +++ b/src/frames.ts @@ -25,6 +25,7 @@ import { Events } from './events'; import { Page } from './page'; import { ConsoleMessage } from './console'; import * as platform from './platform'; +import { selectors } from './selectors'; type ContextType = 'main' | 'utility'; type ContextData = { @@ -427,15 +428,7 @@ export class Frame { } async $(selector: string): Promise | null> { - const utilityContext = await this._utilityContext(); - const mainContext = await this._mainContext(); - const handle = await utilityContext._$(selector); - if (handle && handle._context !== mainContext) { - const adopted = this._page._delegate.adoptElementHandle(handle, mainContext); - handle.dispose(); - return adopted; - } - return handle; + return selectors._query(this, selector); } async waitForSelector(selector: string, options?: types.WaitForElementOptions): Promise | null> { @@ -445,8 +438,8 @@ export class Frame { if (!['attached', 'detached', 'visible', 'hidden'].includes(waitFor)) throw new Error(`Unsupported waitFor option "${waitFor}"`); - const task = waitForSelectorTask(selector, waitFor, timeout); - const result = await this._scheduleRerunnableTask(task, 'utility', timeout, `selector "${selectorToString(selector, waitFor)}"`); + const { world, task } = selectors._waitForSelectorTask(selector, waitFor, timeout); + const result = await this._scheduleRerunnableTask(task, world, timeout, `selector "${selectorToString(selector, waitFor)}"`); if (!result.asElement()) { result.dispose(); return null; @@ -464,28 +457,25 @@ export class Frame { async $eval(selector: string, pageFunction: types.FuncOn, arg: Arg): Promise; async $eval(selector: string, pageFunction: types.FuncOn, arg?: any): Promise; async $eval(selector: string, pageFunction: types.FuncOn, arg: Arg): Promise { - const context = await this._mainContext(); - const elementHandle = await context._$(selector); - if (!elementHandle) + const handle = await this.$(selector); + if (!handle) throw new Error(`Error: failed to find element matching selector "${selector}"`); - const result = await elementHandle.evaluate(pageFunction, arg); - elementHandle.dispose(); + const result = await handle.evaluate(pageFunction, arg); + handle.dispose(); return result; } async $$eval(selector: string, pageFunction: types.FuncOn, arg: Arg): Promise; async $$eval(selector: string, pageFunction: types.FuncOn, arg?: any): Promise; async $$eval(selector: string, pageFunction: types.FuncOn, arg: Arg): Promise { - const context = await this._mainContext(); - const arrayHandle = await context._$array(selector); + const arrayHandle = await selectors._queryArray(this, selector); const result = await arrayHandle.evaluate(pageFunction, arg); arrayHandle.dispose(); return result; } async $$(selector: string): Promise[]> { - const context = await this._mainContext(); - return context._$$(selector); + return selectors._queryAll(this, selector); } async content(): Promise { @@ -746,8 +736,8 @@ export class Frame { private async _waitForSelectorInUtilityContext(selector: string, options?: types.WaitForElementOptions): Promise> { const { timeout = this._page._timeoutSettings.timeout(), waitFor = 'attached' } = (options || {}); - const task = waitForSelectorTask(selector, waitFor, timeout); - const result = await this._scheduleRerunnableTask(task, 'utility', timeout, `selector "${selectorToString(selector, waitFor)}"`); + const { world, task } = selectors._waitForSelectorTask(selector, waitFor, timeout); + const result = await this._scheduleRerunnableTask(task, world, timeout, `selector "${selectorToString(selector, waitFor)}"`); return result.asElement() as dom.ElementHandle; } @@ -765,9 +755,7 @@ export class Frame { const task = async (context: dom.FrameExecutionContext) => context.evaluateHandleInternal(({ injected, predicateBody, polling, timeout, arg }) => { const innerPredicate = new Function('arg', predicateBody); - return injected.poll(polling, undefined, timeout, (element: Element | undefined): any => { - return innerPredicate(arg); - }); + return injected.poll(polling, timeout, () => innerPredicate(arg)); }, { injected: await context._injected(), predicateBody, polling, timeout, arg }); return this._scheduleRerunnableTask(task, 'main', timeout) as any as types.SmartHandle; } @@ -832,24 +820,6 @@ export class Frame { type Task = (context: dom.FrameExecutionContext) => Promise; -function waitForSelectorTask(selector: string, waitFor: 'attached' | 'detached' | 'visible' | 'hidden', timeout: number): Task { - return async (context: dom.FrameExecutionContext) => context.evaluateHandleInternal(({ injected, selector, waitFor, timeout }) => { - const polling = (waitFor === 'attached' || waitFor === 'detached') ? 'mutation' : 'raf'; - return injected.poll(polling, selector, timeout, (element: Element | undefined): Element | boolean => { - switch (waitFor) { - case 'attached': - return element || false; - case 'detached': - return !element; - case 'visible': - return element && injected.isVisible(element) ? element : false; - case 'hidden': - return !element || !injected.isVisible(element); - } - }); - }, { injected: await context._injected(), selector, waitFor, timeout }); -} - class RerunnableTask { readonly promise: Promise; private _contextData: ContextData; diff --git a/src/injected/injected.ts b/src/injected/injected.ts index 62c4edf9d6..68377b6845 100644 --- a/src/injected/injected.ts +++ b/src/injected/injected.ts @@ -14,144 +14,11 @@ * limitations under the License. */ -import { SelectorEngine, SelectorRoot } from './selectorEngine'; -import { Utils } from './utils'; -import { CSSEngine } from './cssSelectorEngine'; -import { XPathEngine } from './xpathSelectorEngine'; -import { TextEngine } from './textSelectorEngine'; import * as types from '../types'; -function createAttributeEngine(attribute: string): SelectorEngine { - const engine: SelectorEngine = { - create(root: SelectorRoot, target: Element): string | undefined { - const value = target.getAttribute(attribute); - if (!value) - return; - if (root.querySelector(`[${attribute}=${value}]`) === target) - return value; - }, - - query(root: SelectorRoot, selector: string): Element | undefined { - return root.querySelector(`[${attribute}=${selector}]`) || undefined; - }, - - queryAll(root: SelectorRoot, selector: string): Element[] { - return Array.from(root.querySelectorAll(`[${attribute}=${selector}]`)); - } - }; - return engine; -} - -type ParsedSelector = { engine: SelectorEngine, selector: string }[]; -type Predicate = (element: Element | undefined) => any; +type Predicate = () => any; class Injected { - readonly utils: Utils; - readonly engines: Map; - - constructor(customEngines: { name: string, engine: SelectorEngine}[]) { - this.utils = new Utils(); - this.engines = new Map(); - // Note: keep predefined names in sync with Selectors class. - this.engines.set('css', CSSEngine); - this.engines.set('xpath', XPathEngine); - this.engines.set('text', TextEngine); - this.engines.set('id', createAttributeEngine('id')); - this.engines.set('data-testid', createAttributeEngine('data-testid')); - this.engines.set('data-test-id', createAttributeEngine('data-test-id')); - this.engines.set('data-test', createAttributeEngine('data-test')); - for (const {name, engine} of customEngines) - this.engines.set(name, engine); - } - - querySelector(selector: string, root: Node): Element | undefined { - const parsed = this._parseSelector(selector); - if (!(root as any)['querySelector']) - throw new Error('Node is not queryable.'); - return this._querySelectorRecursively(root as SelectorRoot, parsed, 0); - } - - private _querySelectorRecursively(root: SelectorRoot, parsed: ParsedSelector, index: number): Element | undefined { - const current = parsed[index]; - root = (root as Element).shadowRoot || root; - if (index === parsed.length - 1) - return current.engine.query(root, current.selector); - const all = current.engine.queryAll(root, current.selector); - for (const next of all) { - const result = this._querySelectorRecursively(next, parsed, index + 1); - if (result) - return result; - } - } - - querySelectorAll(selector: string, root: Node): Element[] { - const parsed = this._parseSelector(selector); - if (!(root as any)['querySelectorAll']) - throw new Error('Node is not queryable.'); - let set = new Set([ root as SelectorRoot ]); - for (const { engine, selector } of parsed) { - const newSet = new Set(); - for (const prev of set) { - for (const next of engine.queryAll((prev as Element).shadowRoot || prev, selector)) { - if (newSet.has(next)) - continue; - newSet.add(next); - } - } - set = newSet; - } - return Array.from(set) as Element[]; - } - - private _parseSelector(selector: string): ParsedSelector { - let index = 0; - let quote: string | undefined; - let start = 0; - const result: ParsedSelector = []; - const append = () => { - const part = selector.substring(start, index).trim(); - const eqIndex = part.indexOf('='); - let name: string; - let body: string; - if (eqIndex !== -1 && part.substring(0, eqIndex).trim().match(/^[a-zA-Z_0-9-]+$/)) { - name = part.substring(0, eqIndex).trim(); - body = part.substring(eqIndex + 1); - } else if (part.startsWith('"')) { - name = 'text'; - body = part; - } else if (/^\(*\/\//.test(part)) { - // If selector starts with '//' or '//' prefixed with multiple opening - // parenthesis, consider xpath. @see https://github.com/microsoft/playwright/issues/817 - name = 'xpath'; - body = part; - } else { - name = 'css'; - body = part; - } - const engine = this.engines.get(name.toLowerCase()); - if (!engine) - throw new Error(`Unknown engine ${name} while parsing selector ${selector}`); - result.push({ engine, selector: body }); - }; - while (index < selector.length) { - const c = selector[index]; - if (c === '\\' && index + 1 < selector.length) { - index += 2; - } else if (c === quote) { - quote = undefined; - index++; - } else if (!quote && c === '>' && selector[index + 1] === '>') { - append(); - index += 2; - start = index; - } else { - index++; - } - } - append(); - return result; - } - isVisible(element: Element): boolean { if (!element.ownerDocument || !element.ownerDocument.defaultView) return true; @@ -162,13 +29,12 @@ class Injected { return !!(rect.top || rect.bottom || rect.width || rect.height); } - private _pollMutation(selector: string | undefined, predicate: Predicate, timeout: number): Promise { + private _pollMutation(predicate: Predicate, timeout: number): Promise { let timedOut = false; if (timeout) setTimeout(() => timedOut = true, timeout); - const element = selector === undefined ? undefined : this.querySelector(selector, document); - const success = predicate(element); + const success = predicate(); if (success) return Promise.resolve(success); @@ -180,8 +46,7 @@ class Injected { fulfill(); return; } - const element = selector === undefined ? undefined : this.querySelector(selector, document); - const success = predicate(element); + const success = predicate(); if (success) { observer.disconnect(); fulfill(success); @@ -195,7 +60,7 @@ class Injected { return result; } - private _pollRaf(selector: string | undefined, predicate: Predicate, timeout: number): Promise { + private _pollRaf(predicate: Predicate, timeout: number): Promise { let timedOut = false; if (timeout) setTimeout(() => timedOut = true, timeout); @@ -208,8 +73,7 @@ class Injected { fulfill(); return; } - const element = selector === undefined ? undefined : this.querySelector(selector, document); - const success = predicate(element); + const success = predicate(); if (success) fulfill(success); else @@ -220,7 +84,7 @@ class Injected { return result; } - private _pollInterval(selector: string | undefined, pollInterval: number, predicate: Predicate, timeout: number): Promise { + private _pollInterval(pollInterval: number, predicate: Predicate, timeout: number): Promise { let timedOut = false; if (timeout) setTimeout(() => timedOut = true, timeout); @@ -232,8 +96,7 @@ class Injected { fulfill(); return; } - const element = selector === undefined ? undefined : this.querySelector(selector, document); - const success = predicate(element); + const success = predicate(); if (success) fulfill(success); else @@ -244,12 +107,12 @@ class Injected { return result; } - poll(polling: 'raf' | 'mutation' | number, selector: string | undefined, timeout: number, predicate: Predicate): Promise { + poll(polling: 'raf' | 'mutation' | number, timeout: number, predicate: Predicate): Promise { if (polling === 'raf') - return this._pollRaf(selector, predicate, timeout); + return this._pollRaf(predicate, timeout); if (polling === 'mutation') - return this._pollMutation(selector, predicate, timeout); - return this._pollInterval(selector, polling, predicate, timeout); + return this._pollMutation(predicate, timeout); + return this._pollInterval(polling, predicate, timeout); } getElementBorderWidth(node: Node): { left: number; top: number; } { @@ -375,7 +238,7 @@ class Injected { let lastRect: types.Rect | undefined; let counter = 0; - const result = await this.poll('raf', undefined, timeout, () => { + const result = await this.poll('raf', timeout, () => { // First raf happens in the same animation frame as evaluation, so it does not produce // any client rect difference compared to synchronous call. We skip the synchronous call // and only force layout during actual rafs as a small optimisation. @@ -395,15 +258,37 @@ class Injected { const element = node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement; if (!element) throw new Error('Element is not attached to the DOM'); - const result = await this.poll('raf', undefined, timeout, () => { - let hitElement = this.utils.deepElementFromPoint(document, point.x, point.y); + const result = await this.poll('raf', timeout, () => { + let hitElement = this._deepElementFromPoint(document, point.x, point.y); while (hitElement && hitElement !== element) - hitElement = this.utils.parentElementOrShadowHost(hitElement); + hitElement = this._parentElementOrShadowHost(hitElement); return hitElement === element; }); if (!result) throw new Error(`waiting for element to receive mouse events failed: timeout ${timeout}ms exceeded`); } + + private _parentElementOrShadowHost(element: Element): Element | undefined { + if (element.parentElement) + return element.parentElement; + if (!element.parentNode) + return; + if (element.parentNode.nodeType === Node.DOCUMENT_FRAGMENT_NODE && (element.parentNode as ShadowRoot).host) + return (element.parentNode as ShadowRoot).host; + } + + private _deepElementFromPoint(document: Document, x: number, y: number): Element | undefined { + let container: Document | ShadowRoot | null = document; + let element: Element | undefined; + while (container) { + const innerElement = container.elementFromPoint(x, y) as Element | undefined; + if (!innerElement || element === innerElement) + break; + element = innerElement; + container = element.shadowRoot; + } + return element; + } } export default Injected; diff --git a/src/injected/selectorEvaluator.ts b/src/injected/selectorEvaluator.ts new file mode 100644 index 0000000000..0c00feac6a --- /dev/null +++ b/src/injected/selectorEvaluator.ts @@ -0,0 +1,102 @@ +/** + * 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 { CSSEngine } from './cssSelectorEngine'; +import { XPathEngine } from './xpathSelectorEngine'; +import { TextEngine } from './textSelectorEngine'; +import { SelectorEngine, SelectorRoot } from './selectorEngine'; +import Injected from './injected'; +import * as types from '../types'; + +function createAttributeEngine(attribute: string): SelectorEngine { + const engine: SelectorEngine = { + create(root: SelectorRoot, target: Element): string | undefined { + const value = target.getAttribute(attribute); + if (!value) + return; + if (root.querySelector(`[${attribute}=${value}]`) === target) + return value; + }, + + query(root: SelectorRoot, selector: string): Element | undefined { + return root.querySelector(`[${attribute}=${selector}]`) || undefined; + }, + + queryAll(root: SelectorRoot, selector: string): Element[] { + return Array.from(root.querySelectorAll(`[${attribute}=${selector}]`)); + } + }; + return engine; +} + +class SelectorEvaluator { + readonly engines: Map; + readonly injected: Injected; + + constructor(customEngines: { name: string, engine: SelectorEngine}[]) { + this.injected = new Injected(); + this.engines = new Map(); + // Note: keep predefined names in sync with Selectors class. + this.engines.set('css', CSSEngine); + this.engines.set('xpath', XPathEngine); + this.engines.set('text', TextEngine); + this.engines.set('id', createAttributeEngine('id')); + this.engines.set('data-testid', createAttributeEngine('data-testid')); + this.engines.set('data-test-id', createAttributeEngine('data-test-id')); + this.engines.set('data-test', createAttributeEngine('data-test')); + for (const {name, engine} of customEngines) + this.engines.set(name, engine); + } + + querySelector(selector: types.ParsedSelector, root: Node): Element | undefined { + if (!(root as any)['querySelector']) + throw new Error('Node is not queryable.'); + return this._querySelectorRecursively(root as SelectorRoot, selector, 0); + } + + private _querySelectorRecursively(root: SelectorRoot, selector: types.ParsedSelector, index: number): Element | undefined { + const current = selector[index]; + root = (root as Element).shadowRoot || root; + if (index === selector.length - 1) + return this.engines.get(current.name)!.query(root, current.body); + const all = this.engines.get(current.name)!.queryAll(root, current.body); + for (const next of all) { + const result = this._querySelectorRecursively(next, selector, index + 1); + if (result) + return result; + } + } + + querySelectorAll(selector: types.ParsedSelector, root: Node): Element[] { + if (!(root as any)['querySelectorAll']) + throw new Error('Node is not queryable.'); + let set = new Set([ root as SelectorRoot ]); + for (const { name, body } of selector) { + const newSet = new Set(); + for (const prev of set) { + for (const next of this.engines.get(name)!.queryAll((prev as Element).shadowRoot || prev, body)) { + if (newSet.has(next)) + continue; + newSet.add(next); + } + } + set = newSet; + } + return Array.from(set) as Element[]; + } +} + +export default SelectorEvaluator; diff --git a/src/injected/injected.webpack.config.js b/src/injected/selectorEvaluator.webpack.config.js similarity index 85% rename from src/injected/injected.webpack.config.js rename to src/injected/selectorEvaluator.webpack.config.js index fb1f11cf88..1cd60a020c 100644 --- a/src/injected/injected.webpack.config.js +++ b/src/injected/selectorEvaluator.webpack.config.js @@ -18,7 +18,7 @@ const path = require('path'); const InlineSource = require('./webpack-inline-source-plugin.js'); module.exports = { - entry: path.join(__dirname, 'injected.ts'), + entry: path.join(__dirname, 'selectorEvaluator.ts'), devtool: 'source-map', module: { rules: [ @@ -36,10 +36,10 @@ module.exports = { extensions: [ '.tsx', '.ts', '.js' ] }, output: { - filename: 'injectedSource.js', + filename: 'selectorEvaluatorSource.js', path: path.resolve(__dirname, '../../lib/injected/packed') }, plugins: [ - new InlineSource(path.join(__dirname, '..', 'generated', 'injectedSource.ts')), + new InlineSource(path.join(__dirname, '..', 'generated', 'selectorEvaluatorSource.ts')), ] }; diff --git a/src/injected/utils.ts b/src/injected/utils.ts deleted file mode 100644 index 18182d64b0..0000000000 --- a/src/injected/utils.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Copyright (c) Microsoft Corporation. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export class Utils { - parentElementOrShadowHost(element: Element): Element | undefined { - if (element.parentElement) - return element.parentElement; - if (!element.parentNode) - return; - if (element.parentNode.nodeType === Node.DOCUMENT_FRAGMENT_NODE && (element.parentNode as ShadowRoot).host) - return (element.parentNode as ShadowRoot).host; - } - - deepElementFromPoint(document: Document, x: number, y: number): Element | undefined { - let container: Document | ShadowRoot | null = document; - let element: Element | undefined; - while (container) { - const innerElement = container.elementFromPoint(x, y) as Element | undefined; - if (!innerElement || element === innerElement) - break; - element = innerElement; - container = element.shadowRoot; - } - return element; - } -} diff --git a/src/selectors.ts b/src/selectors.ts index eccf8b500b..0002b0fdca 100644 --- a/src/selectors.ts +++ b/src/selectors.ts @@ -15,29 +15,35 @@ */ import * as dom from './dom'; +import * as frames from './frames'; +import * as selectorEvaluatorSource from './generated/selectorEvaluatorSource'; import { helper } from './helper'; +import SelectorEvaluator from './injected/selectorEvaluator'; +import * as js from './javascript'; +import * as types from './types'; -let selectors: Selectors; +const kEvaluatorSymbol = Symbol('evaluator'); +type EvaluatorData = { + promise: Promise>, + generation: number, +}; export class Selectors { + readonly _builtinEngines: Set; readonly _engines: Map; _generation = 0; - static _instance() { - if (!selectors) - selectors = new Selectors(); - return selectors; - } - constructor() { + // Note: keep in sync with Injected class. + this._builtinEngines = new Set(['css', 'xpath', 'text', 'id', 'data-testid', 'data-test-id', 'data-test']); this._engines = new Map(); } async register(name: string, script: string | Function | { path?: string, content?: string }): Promise { if (!name.match(/^[a-zA-Z_0-9-]+$/)) throw new Error('Selector engine name may only contain [a-zA-Z0-9_] characters'); - // Note: keep in sync with Injected class, and also keep 'zs' for future. - if (['css', 'xpath', 'text', 'id', 'zs', 'data-testid', 'data-test-id', 'data-test'].includes(name)) + // Note: we keep 'zs' for future use. + if (this._builtinEngines.has(name) || name === 'zs') throw new Error(`"${name}" is a predefined selector engine`); const source = await helper.evaluationScript(script, undefined, false); if (this._engines.has(name)) @@ -46,10 +52,156 @@ export class Selectors { ++this._generation; } + async _prepareEvaluator(context: dom.FrameExecutionContext): Promise> { + let data = (context as any)[kEvaluatorSymbol] as EvaluatorData | undefined; + if (data && data.generation !== this._generation) { + data.promise.then(handle => handle.dispose()); + data = undefined; + } + if (!data) { + const custom: string[] = []; + for (const [name, source] of this._engines) + custom.push(`{ name: '${name}', engine: (${source}) }`); + const source = ` + new (${selectorEvaluatorSource.source})([ + ${custom.join(',\n')} + ]) + `; + data = { + promise: context._doEvaluateInternal(false /* returnByValue */, false /* waitForNavigations */, source), + generation: this._generation + }; + (context as any)[kEvaluatorSymbol] = data; + } + return data.promise; + } + + async _query(frame: frames.Frame, selector: string, scope?: dom.ElementHandle): Promise | null> { + const parsed = this._parseSelector(selector); + const context = await frame._utilityContext(); + const handle = await context.evaluateHandleInternal( + ({ evaluator, parsed, scope }) => evaluator.querySelector(parsed, scope || document), + { evaluator: await this._prepareEvaluator(context), parsed, scope } + ); + const elementHandle = handle.asElement() as dom.ElementHandle | null; + if (!elementHandle) { + handle.dispose(); + return null; + } + const mainContext = await frame._mainContext(); + if (elementHandle._context === mainContext) + return elementHandle; + const adopted = frame._page._delegate.adoptElementHandle(elementHandle, mainContext); + elementHandle.dispose(); + return adopted; + } + + async _queryArray(frame: frames.Frame, selector: string, scope?: dom.ElementHandle): Promise> { + const parsed = this._parseSelector(selector); + const context = await frame._mainContext(); + const arrayHandle = await context.evaluateHandleInternal( + ({ evaluator, parsed, scope }) => evaluator.querySelectorAll(parsed, scope || document), + { evaluator: await this._prepareEvaluator(context), parsed, scope } + ); + return arrayHandle; + } + + async _queryAll(frame: frames.Frame, selector: string, scope?: dom.ElementHandle, allowUtilityContext?: boolean): Promise[]> { + const parsed = this._parseSelector(selector); + const context = !allowUtilityContext ? await frame._mainContext() : await frame._utilityContext(); + const arrayHandle = await context.evaluateHandleInternal( + ({ evaluator, parsed, scope }) => evaluator.querySelectorAll(parsed, scope || document), + { evaluator: await this._prepareEvaluator(context), parsed, scope } + ); + const properties = await arrayHandle.getProperties(); + arrayHandle.dispose(); + const result: dom.ElementHandle[] = []; + for (const property of properties.values()) { + const elementHandle = property.asElement() as dom.ElementHandle; + if (elementHandle) + result.push(elementHandle); + else + property.dispose(); + } + return result; + } + + _waitForSelectorTask(selector: string, waitFor: 'attached' | 'detached' | 'visible' | 'hidden', timeout: number): { world: 'main' | 'utility', task: (context: dom.FrameExecutionContext) => Promise } { + const parsed = this._parseSelector(selector); + const task = async (context: dom.FrameExecutionContext) => context.evaluateHandleInternal(({ evaluator, parsed, waitFor, timeout }) => { + const polling = (waitFor === 'attached' || waitFor === 'detached') ? 'mutation' : 'raf'; + return evaluator.injected.poll(polling, timeout, () => { + const element = evaluator.querySelector(parsed, document); + switch (waitFor) { + case 'attached': + return element || false; + case 'detached': + return !element; + case 'visible': + return element && evaluator.injected.isVisible(element) ? element : false; + case 'hidden': + return !element || !evaluator.injected.isVisible(element); + } + }); + }, { evaluator: await this._prepareEvaluator(context), parsed, waitFor, timeout }); + return { world: 'utility', task }; + } + async _createSelector(name: string, handle: dom.ElementHandle): Promise { const mainContext = await handle._page.mainFrame()._mainContext(); - return mainContext.evaluateInternal(({ injected, target, name }) => { - return injected.engines.get(name)!.create(document.documentElement, target); - }, { injected: await mainContext._injected(), target: handle, name }); + return mainContext.evaluateInternal(({ evaluator, target, name }) => { + return evaluator.engines.get(name)!.create(document.documentElement, target); + }, { evaluator: await this._prepareEvaluator(mainContext), target: handle, name }); + } + + private _parseSelector(selector: string): types.ParsedSelector { + let index = 0; + let quote: string | undefined; + let start = 0; + const result: types.ParsedSelector = []; + const append = () => { + const part = selector.substring(start, index).trim(); + const eqIndex = part.indexOf('='); + let name: string; + let body: string; + if (eqIndex !== -1 && part.substring(0, eqIndex).trim().match(/^[a-zA-Z_0-9-]+$/)) { + name = part.substring(0, eqIndex).trim(); + body = part.substring(eqIndex + 1); + } else if (part.startsWith('"')) { + name = 'text'; + body = part; + } else if (/^\(*\/\//.test(part)) { + // If selector starts with '//' or '//' prefixed with multiple opening + // parenthesis, consider xpath. @see https://github.com/microsoft/playwright/issues/817 + name = 'xpath'; + body = part; + } else { + name = 'css'; + body = part; + } + name = name.toLowerCase(); + if (!this._builtinEngines.has(name) && !this._engines.has(name)) + throw new Error(`Unknown engine ${name} while parsing selector ${selector}`); + result.push({ name, body }); + }; + while (index < selector.length) { + const c = selector[index]; + if (c === '\\' && index + 1 < selector.length) { + index += 2; + } else if (c === quote) { + quote = undefined; + index++; + } else if (!quote && c === '>' && selector[index + 1] === '>') { + append(); + index += 2; + start = index; + } else { + index++; + } + } + append(); + return result; } } + +export const selectors = new Selectors(); diff --git a/src/server/playwright.ts b/src/server/playwright.ts index 6141b97891..bfdd486313 100644 --- a/src/server/playwright.ts +++ b/src/server/playwright.ts @@ -22,6 +22,7 @@ import { DeviceDescriptors } from '../deviceDescriptors'; import { Chromium } from './chromium'; import { WebKit } from './webkit'; import { Firefox } from './firefox'; +import { selectors } from '../selectors'; for (const className in api) { if (typeof (api as any)[className] === 'function') @@ -33,7 +34,7 @@ type PlaywrightOptions = { }; export class Playwright { - readonly selectors = api.Selectors._instance(); + readonly selectors = selectors; readonly devices: types.Devices; readonly errors: { TimeoutError: typeof TimeoutError }; readonly chromium: (Chromium|undefined); diff --git a/src/types.ts b/src/types.ts index 72eb54e778..79a82dd411 100644 --- a/src/types.ts +++ b/src/types.ts @@ -144,3 +144,8 @@ export type JSCoverageOptions = { resetOnNavigation?: boolean, reportAnonymousScripts?: boolean, }; + +export type ParsedSelector = { + name: string, + body: string, +}[]; diff --git a/src/webkit/wkPage.ts b/src/webkit/wkPage.ts index fbe5924fbb..6fe3065083 100644 --- a/src/webkit/wkPage.ts +++ b/src/webkit/wkPage.ts @@ -34,6 +34,7 @@ import * as platform from '../platform'; import { getAccessibilityTree } from './wkAccessibility'; import { WKProvisionalPage } from './wkProvisionalPage'; import { WKBrowserContext } from './wkBrowser'; +import { selectors } from '../selectors'; const UTILITY_WORLD_NAME = '__playwright_utility_world__'; const BINDING_CALL_MESSAGE = '__playwright_binding_call__'; @@ -730,8 +731,7 @@ export class WKPage implements PageDelegate { const parent = frame.parentFrame(); if (!parent) throw new Error('Frame has been detached.'); - const context = await parent._utilityContext(); - const handles = await context._$$('iframe'); + const handles = await selectors._queryAll(parent, 'iframe', undefined, true /* allowUtilityContext */); const items = await Promise.all(handles.map(async handle => { const frame = await handle.contentFrame().catch(e => null); return { handle, frame }; diff --git a/test/page.spec.js b/test/page.spec.js index f8147f4d69..b5cad536eb 100644 --- a/test/page.spec.js +++ b/test/page.spec.js @@ -978,19 +978,6 @@ module.exports.describe = function({testRunner, expect, headless, playwright, FF await page.fill('textarea', 123).catch(e => error = e); expect(error.message).toContain('Value must be string.'); }); - it('should wait for visible visibilty', async({page, server}) => { - await page.goto(server.PREFIX + '/input/textarea.html'); - await page.fill('input', 'some value'); - expect(await page.evaluate(() => result)).toBe('some value'); - - await page.goto(server.PREFIX + '/input/textarea.html'); - await page.$eval('input', i => i.style.display = 'none'); - await Promise.all([ - page.fill('input', 'some value'), - page.$eval('input', i => i.style.display = 'block'), - ]); - expect(await page.evaluate(() => result)).toBe('some value'); - }); it('should throw on disabled and readonly elements', async({page, server}) => { await page.goto(server.PREFIX + '/input/textarea.html'); await page.$eval('input', i => i.disabled = true); diff --git a/utils/runWebpack.js b/utils/runWebpack.js index 8b61e5a87e..f50dbbdb35 100644 --- a/utils/runWebpack.js +++ b/utils/runWebpack.js @@ -19,7 +19,7 @@ const path = require('path'); const files = [ path.join('src', 'injected', 'zsSelectorEngine.webpack.config.js'), - path.join('src', 'injected', 'injected.webpack.config.js'), + path.join('src', 'injected', 'selectorEvaluator.webpack.config.js'), path.join('src', 'web.webpack.config.js'), ];