From 6b3c2632e7843e9a786b660687e095e77c650093 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Tue, 3 Dec 2019 10:43:13 -0800 Subject: [PATCH] feat(selectors): support various selectors in waitFor methods (#122) --- src/dom.ts | 81 +++++++++++++++++++++++++++++++------------ src/frames.ts | 44 ++++++----------------- src/waitTask.ts | 46 ++++-------------------- test/waittask.spec.js | 36 +++++++++++-------- 4 files changed, 97 insertions(+), 110 deletions(-) diff --git a/src/dom.ts b/src/dom.ts index 506f556e16..e2e49efd2f 100644 --- a/src/dom.ts +++ b/src/dom.ts @@ -10,6 +10,7 @@ import * as cssSelectorEngineSource from './generated/cssSelectorEngineSource'; import * as xpathSelectorEngineSource from './generated/xpathSelectorEngineSource'; import { assert, helper } from './helper'; import Injected from './injected/injected'; +import { WaitTaskParams } from './waitTask'; export interface DOMWorldDelegate { keyboard: input.Keyboard; @@ -47,7 +48,7 @@ export class DOMWorld { return null; } - private _injected(): Promise { + injected(): Promise { if (!this._injectedPromise) { const engineSources = [cssSelectorEngineSource.source, xpathSelectorEngineSource.source]; const source = ` @@ -65,36 +66,21 @@ export class DOMWorld { return this.delegate.adoptElementHandle(handle, this); } - private _normalizeSelector(selector: string): string { - const eqIndex = selector.indexOf('='); - if (eqIndex !== -1 && selector.substring(0, eqIndex).trim().match(/^[a-zA-Z_0-9]+$/)) - return selector; - if (selector.startsWith('//')) - return 'xpath=' + selector; - return 'css=' + selector; - } - private async _resolveSelector(selector: Selector): Promise { if (helper.isString(selector)) - return { selector: this._normalizeSelector(selector) }; + return { selector: normalizeSelector(selector) }; if (selector.root && selector.root.executionContext() !== this.context) { const root = await this.adoptElementHandle(selector.root); - return { root, selector: this._normalizeSelector(selector.selector), disposeRoot: true }; + return { root, selector: normalizeSelector(selector.selector), disposeRoot: true }; } - return { root: selector.root, selector: this._normalizeSelector(selector.selector) }; - } - - private _selectorToString(selector: Selector): string { - if (typeof selector === 'string') - return selector; - return `:scope >> ${selector.selector}`; + return { root: selector.root, selector: normalizeSelector(selector.selector) }; } async $(selector: Selector): Promise { const resolved = await this._resolveSelector(selector); const handle = await this.context.evaluateHandle( (injected: Injected, selector: string, root: SelectorRoot | undefined) => injected.querySelector(selector, root || document), - await this._injected(), resolved.selector, resolved.root + await this.injected(), resolved.selector, resolved.root ); if (resolved.disposeRoot) await resolved.root.dispose(); @@ -107,7 +93,7 @@ export class DOMWorld { const resolved = await this._resolveSelector(selector); const arrayHandle = await this.context.evaluateHandle( (injected: Injected, selector: string, root: SelectorRoot | undefined) => injected.querySelectorAll(selector, root || document), - await this._injected(), resolved.selector, resolved.root + await this.injected(), resolved.selector, resolved.root ); if (resolved.disposeRoot) await resolved.root.dispose(); @@ -127,7 +113,7 @@ export class DOMWorld { $eval: types.$Eval = async (selector, pageFunction, ...args) => { const elementHandle = await this.$(selector); if (!elementHandle) - throw new Error(`Error: failed to find element matching selector "${this._selectorToString(selector)}"`); + throw new Error(`Error: failed to find element matching selector "${selectorToString(selector)}"`); const result = await elementHandle.evaluate(pageFunction, ...args as any); await elementHandle.dispose(); return result; @@ -137,7 +123,7 @@ export class DOMWorld { const resolved = await this._resolveSelector(selector); const arrayHandle = await this.context.evaluateHandle( (injected: Injected, selector: string, root: SelectorRoot | undefined) => injected.querySelectorAll(selector, root || document), - await this._injected(), resolved.selector, resolved.root + await this.injected(), resolved.selector, resolved.root ); const result = await arrayHandle.evaluate(pageFunction, ...args as any); await arrayHandle.dispose(); @@ -305,3 +291,52 @@ export class ElementHandle extends js.JSHandle { }); } } + +function normalizeSelector(selector: string): string { + const eqIndex = selector.indexOf('='); + if (eqIndex !== -1 && selector.substring(0, eqIndex).trim().match(/^[a-zA-Z_0-9]+$/)) + return selector; + if (selector.startsWith('//')) + return 'xpath=' + selector; + return 'css=' + selector; +} + +function selectorToString(selector: Selector): string { + if (typeof selector === 'string') + return selector; + return `:scope >> ${selector.selector}`; +} + +export type WaitForSelectorOptions = { visible?: boolean, hidden?: boolean, timeout?: number }; + +export function waitForSelectorTask(selector: string, options: WaitForSelectorOptions): WaitTaskParams { + const { visible: waitForVisible = false, hidden: waitForHidden = false, timeout } = options; + const polling = waitForVisible || waitForHidden ? 'raf' : 'mutation'; + const title = `selector "${selector}"${waitForHidden ? ' to be hidden' : ''}`; + const params: WaitTaskParams = { + predicateBody: predicate, + title, + polling, + timeout, + args: [normalizeSelector(selector), waitForVisible, waitForHidden], + passInjected: true + }; + return params; + + function predicate(injected: Injected, selector: string, waitForVisible: boolean, waitForHidden: boolean): (Node | boolean) | null { + const element = injected.querySelector(selector, document); + if (!element) + return waitForHidden; + if (!waitForVisible && !waitForHidden) + return element; + const style = window.getComputedStyle(element); + const isVisible = style && style.visibility !== 'hidden' && hasVisibleBoundingBox(); + const success = (waitForVisible === isVisible || waitForHidden === !isVisible); + return success ? element : null; + + function hasVisibleBoundingBox(): boolean { + const rect = element.getBoundingClientRect(); + return !!(rect.top || rect.bottom || rect.width || rect.height); + } + } +} diff --git a/src/frames.ts b/src/frames.ts index e2b87ef0aa..2a093e65b8 100644 --- a/src/frames.ts +++ b/src/frames.ts @@ -22,7 +22,7 @@ import * as dom from './dom'; import * as network from './network'; import { helper, assert } from './helper'; import { ClickOptions, MultiClickOptions, PointerActionOptions, SelectOption } from './input'; -import { waitForSelectorOrXPath, WaitTaskParams, WaitTask } from './waitTask'; +import { WaitTaskParams, WaitTask } from './waitTask'; import { TimeoutSettings } from './TimeoutSettings'; const readFileAsync = helper.promisify(fs.readFile); @@ -376,14 +376,8 @@ export class Frame { } waitFor(selectorOrFunctionOrTimeout: (string | number | Function), options: any = {}, ...args: any[]): Promise { - const xPathPattern = '//'; - - if (helper.isString(selectorOrFunctionOrTimeout)) { - const string = selectorOrFunctionOrTimeout as string; - if (string.startsWith(xPathPattern)) - return this.waitForXPath(string, options) as any; - return this.waitForSelector(string, options) as any; - } + if (helper.isString(selectorOrFunctionOrTimeout)) + return this.waitForSelector(selectorOrFunctionOrTimeout as string, options) as any; if (helper.isNumber(selectorOrFunctionOrTimeout)) return new Promise(fulfill => setTimeout(fulfill, selectorOrFunctionOrTimeout as number)); if (typeof selectorOrFunctionOrTimeout === 'function') @@ -391,12 +385,9 @@ export class Frame { return Promise.reject(new Error('Unsupported target type: ' + (typeof selectorOrFunctionOrTimeout))); } - async waitForSelector(selector: string, options: { - visible?: boolean; - hidden?: boolean; - timeout?: number; } | undefined): Promise { - const params = waitForSelectorOrXPath(selector, false /* isXPath */, { timeout: this._timeoutSettings.timeout(), ...options }); - const handle = await this._scheduleWaitTask(params, this._worlds.get('utility')); + async waitForSelector(selector: string, options: dom.WaitForSelectorOptions = {}): Promise { + const params = dom.waitForSelectorTask(selector, { timeout: this._timeoutSettings.timeout(), ...options }); + const handle = await this._scheduleWaitTask(params, 'utility'); if (!handle.asElement()) { await handle.dispose(); return null; @@ -409,22 +400,8 @@ export class Frame { return adopted; } - async waitForXPath(xpath: string, options: { - visible?: boolean; - hidden?: boolean; - timeout?: number; } | undefined): Promise { - const params = waitForSelectorOrXPath(xpath, true /* isXPath */, { timeout: this._timeoutSettings.timeout(), ...options }); - const handle = await this._scheduleWaitTask(params, this._worlds.get('utility')); - if (!handle.asElement()) { - await handle.dispose(); - return null; - } - const mainDOMWorld = await this._mainDOMWorld(); - if (handle.executionContext() === mainDOMWorld.context) - return handle.asElement(); - const adopted = await mainDOMWorld.adoptElementHandle(handle.asElement()); - await handle.dispose(); - return adopted; + async waitForXPath(xpath: string, options: dom.WaitForSelectorOptions = {}): Promise { + return this.waitForSelector('xpath=' + xpath, options); } waitForFunction( @@ -442,7 +419,7 @@ export class Frame { timeout, args }; - return this._scheduleWaitTask(params, this._worlds.get('main')); + return this._scheduleWaitTask(params, 'main'); } async title(): Promise { @@ -466,7 +443,8 @@ export class Frame { this._parentFrame = null; } - private _scheduleWaitTask(params: WaitTaskParams, world: World): Promise { + private _scheduleWaitTask(params: WaitTaskParams, worldType: WorldType): Promise { + const world = this._worlds.get(worldType); const task = new WaitTask(params, () => world.waitTasks.delete(task)); world.waitTasks.add(task); if (world.context) diff --git a/src/waitTask.ts b/src/waitTask.ts index f2494f7fb5..6b4a183857 100644 --- a/src/waitTask.ts +++ b/src/waitTask.ts @@ -4,6 +4,7 @@ import { assert, helper } from './helper'; import * as js from './javascript'; import { TimeoutError } from './Errors'; +import Injected from './injected/injected'; export type WaitTaskParams = { // TODO: ensure types. @@ -12,6 +13,7 @@ export type WaitTaskParams = { polling: string | number; timeout: number; args: any[]; + passInjected?: boolean; }; export class WaitTask { @@ -61,7 +63,8 @@ export class WaitTask { let success: js.JSHandle | null = null; let error = null; try { - success = await context.evaluateHandle(waitForPredicatePageFunction, this._params.predicateBody, this._params.polling, this._params.timeout, ...this._params.args); + assert(context._domWorld, 'Wait task requires a dom world'); + success = await context.evaluateHandle(waitForPredicatePageFunction, await context._domWorld.injected(), this._params.predicateBody, this._params.polling, this._params.timeout, !!this._params.passInjected, ...this._params.args); } catch (e) { error = e; } @@ -104,44 +107,9 @@ export class WaitTask { } } -export function waitForSelectorOrXPath( - selectorOrXPath: string, - isXPath: boolean, - options: { visible?: boolean, hidden?: boolean, timeout: number }): WaitTaskParams { - const { visible: waitForVisible = false, hidden: waitForHidden = false, timeout } = options; - const polling = waitForVisible || waitForHidden ? 'raf' : 'mutation'; - const title = `${isXPath ? 'XPath' : 'selector'} "${selectorOrXPath}"${waitForHidden ? ' to be hidden' : ''}`; - const params: WaitTaskParams = { - predicateBody: predicate, - title, - polling, - timeout, - args: [selectorOrXPath, isXPath, waitForVisible, waitForHidden] - }; - return params; - - function predicate(selectorOrXPath: string, isXPath: boolean, waitForVisible: boolean, waitForHidden: boolean): (Node | boolean) | null { - const node = isXPath - ? document.evaluate(selectorOrXPath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue - : document.querySelector(selectorOrXPath); - if (!node) - return waitForHidden; - if (!waitForVisible && !waitForHidden) - return node; - const element = (node.nodeType === Node.TEXT_NODE ? node.parentElement : node) as Element; - const style = window.getComputedStyle(element); - const isVisible = style && style.visibility !== 'hidden' && hasVisibleBoundingBox(); - const success = (waitForVisible === isVisible || waitForHidden === !isVisible); - return success ? node : null; - - function hasVisibleBoundingBox(): boolean { - const rect = element.getBoundingClientRect(); - return !!(rect.top || rect.bottom || rect.width || rect.height); - } - } -} - -async function waitForPredicatePageFunction(predicateBody: string, polling: string | number, timeout: number, ...args): Promise { +async function waitForPredicatePageFunction(injected: Injected, predicateBody: string, polling: string | number, timeout: number, passInjected: boolean, ...args): Promise { + if (passInjected) + args = [injected, ...args]; const predicate = new Function('...args', predicateBody); let timedOut = false; if (timeout) diff --git a/test/waittask.spec.js b/test/waittask.spec.js index 916e10ef85..0267071233 100644 --- a/test/waittask.spec.js +++ b/test/waittask.spec.js @@ -90,16 +90,15 @@ module.exports.addTests = function({testRunner, expect, product, playwright, FFO }); }); it('should poll on interval', async({page, server}) => { - let success = false; - const startTime = Date.now(); const polling = 100; - const watchdog = page.waitForFunction(() => window.__FOO === 'hit', {polling}) - .then(() => success = true); - await page.evaluate(() => window.__FOO = 'hit'); - expect(success).toBe(false); - await page.evaluate(() => document.body.appendChild(document.createElement('div'))); - await watchdog; - expect(Date.now() - startTime).not.toBeLessThan(polling / 2); + const timeDelta = await page.waitForFunction(() => { + if (!window.__startTime) { + window.__startTime = Date.now(); + return false; + } + return Date.now() - window.__startTime; + }, {polling}); + expect(timeDelta).not.toBeLessThan(polling); }); it('should poll on mutation', async({page, server}) => { let success = false; @@ -377,6 +376,18 @@ module.exports.addTests = function({testRunner, expect, product, playwright, FFO await page.waitForSelector('.zombo', {timeout: 10}).catch(e => error = e); expect(error.stack).toContain('waittask.spec.js'); }); + + it('should support >> selector syntax', async({page, server}) => { + await page.goto(server.EMPTY_PAGE); + const frame = page.mainFrame(); + const watchdog = frame.waitForSelector('css=div >> css=span'); + await frame.evaluate(addElement, 'br'); + await frame.evaluate(addElement, 'div'); + await frame.evaluate(() => document.querySelector('div').appendChild(document.createElement('span'))); + const eHandle = await watchdog; + const tagName = await eHandle.getProperty('tagName').then(e => e.jsonValue()); + expect(tagName).toBe('SPAN'); + }); }); describe('Frame.waitForXPath', function() { @@ -391,7 +402,7 @@ module.exports.addTests = function({testRunner, expect, product, playwright, FFO let error = null; await page.waitForXPath('//div', {timeout: 10}).catch(e => error = e); expect(error).toBeTruthy(); - expect(error.message).toContain('waiting for XPath "//div" failed: timeout'); + expect(error.message).toContain('waiting for selector "xpath=//div" failed: timeout'); expect(error).toBeInstanceOf(playwright.errors.TimeoutError); }); it('should run in specified frame', async({page, server}) => { @@ -430,11 +441,6 @@ module.exports.addTests = function({testRunner, expect, product, playwright, FFO await page.setContent(`
anything
`); expect(await page.evaluate(x => x.textContent, await waitForXPath)).toBe('anything'); }); - it('should allow you to select a text node', async({page, server}) => { - await page.setContent(`
some text
`); - const text = await page.waitForXPath('//div/text()'); - expect(await (await text.getProperty('nodeType')).jsonValue()).toBe(3 /* Node.TEXT_NODE */); - }); it('should allow you to select an element with single slash', async({page, server}) => { await page.setContent(`
some text
`); const waitForXPath = page.waitForXPath('/html/body/div');