From cb684ebbc4de271c7f2187f4e52c0b3a83cc0122 Mon Sep 17 00:00:00 2001 From: Ram Dobson Date: Mon, 22 Jan 2018 18:16:20 -0500 Subject: [PATCH] feat(Page): introduce Page.waitForXPath (#1767) This patch: - introduces `page.waitForXPath` method - introduces `frame.waitForXPath` method - amends `page.waitFor` to treat strings that start with `//` as xpath queries. Fixes #1757. --- docs/api.md | 66 ++++++++++++++++++++++++++++++- lib/FrameManager.js | 96 +++++++++++++++++++++++++++++---------------- lib/Page.js | 9 +++++ test/test.js | 84 ++++++++++++++++++++++++++++++++++++++- 4 files changed, 219 insertions(+), 36 deletions(-) diff --git a/docs/api.md b/docs/api.md index e98eecc0..610cd891 100644 --- a/docs/api.md +++ b/docs/api.md @@ -99,6 +99,7 @@ * [page.waitForFunction(pageFunction[, options[, ...args]])](#pagewaitforfunctionpagefunction-options-args) * [page.waitForNavigation(options)](#pagewaitfornavigationoptions) * [page.waitForSelector(selector[, options])](#pagewaitforselectorselector-options) + * [page.waitForXPath(xpath[, options])](#pagewaitforxpathxpath-options) - [class: Keyboard](#class-keyboard) * [keyboard.down(key[, options])](#keyboarddownkey-options) * [keyboard.press(key[, options])](#keyboardpresskey-options) @@ -147,6 +148,7 @@ * [frame.waitFor(selectorOrFunctionOrTimeout[, options[, ...args]])](#framewaitforselectororfunctionortimeout-options-args) * [frame.waitForFunction(pageFunction[, options[, ...args]])](#framewaitforfunctionpagefunction-options-args) * [frame.waitForSelector(selector[, options])](#framewaitforselectorselector-options) + * [frame.waitForXPath(xpath[, options])](#framewaitforxpathxpath-options) - [class: ExecutionContext](#class-executioncontext) * [executionContext.evaluate(pageFunction, ...args)](#executioncontextevaluatepagefunction-args) * [executionContext.evaluateHandle(pageFunction, ...args)](#executioncontextevaluatehandlepagefunction-args) @@ -1209,7 +1211,8 @@ This is a shortcut for [page.mainFrame().url()](#frameurl) - returns: <[Promise]<[JSHandle]>> Promise which resolves to a JSHandle of the success value This method behaves differently with respect to the type of the first parameter: -- if `selectorOrFunctionOrTimeout` is a `string`, then the first argument is treated as a [selector] to wait for and the method is a shortcut for [page.waitForSelector](#pagewaitforselectorselector-options) +- if `selectorOrFunctionOrTimeout` is a `string`, then the first argument is treated as a [selector] or [xpath], depending on whether or not it starts with '//', and the method is a shortcut for + [page.waitForSelector](#pagewaitforselectorselector-options) or [page.waitForXPath](#pagewaitforxpathxpath-options) - if `selectorOrFunctionOrTimeout` is a `function`, then the first argument is treated as a predicate to wait for and the method is a shortcut for [page.waitForFunction()](#pagewaitforfunctionpagefunction-options-args). - if `selectorOrFunctionOrTimeout` is a `number`, then the first argument is treated as a timeout in milliseconds and the method returns a promise which resolves after the timeout - otherwise, an exception is thrown @@ -1279,6 +1282,35 @@ puppeteer.launch().then(async browser => { ``` Shortcut for [page.mainFrame().waitForSelector(selector[, options])](#framewaitforselectorselector-options). +#### page.waitForXPath(xpath[, options]) +- `xpath` <[string]> A [xpath] of an element to wait for, +- `options` <[Object]> Optional waiting parameters + - `visible` <[boolean]> wait for element to be present in DOM and to be visible, i.e. to not have `display: none` or `visibility: hidden` CSS properties. Defaults to `false`. + - `hidden` <[boolean]> wait for element to not be found in the DOM or to be hidden, i.e. have `display: none` or `visibility: hidden` CSS properties. Defaults to `false`. + - `timeout` <[number]> maximum time to wait for in milliseconds. Defaults to `30000` (30 seconds). +- returns: <[Promise]<[ElementHandle]>> Promise which resolves when element specified by xpath string is added to DOM. + +Wait for the `xpath` to appear in page. If at the moment of calling +the method the `xpath` already exists, the method will return +immediately. If the xpath doesn't appear after the `timeout` milliseconds of waiting, the function will throw. + +This method works across navigations: +```js +const puppeteer = require('puppeteer'); + +puppeteer.launch().then(async browser => { + const page = await browser.newPage(); + let currentURL; + page + .waitForXPath('//img') + .then(() => console.log('First URL with image: ' + currentURL)); + for (currentURL of ['https://example.com', 'https://google.com', 'https://bbc.com']) + await page.goto(currentURL); + await browser.close(); +}); +``` +Shortcut for [page.mainFrame().waitForXPath(xpath[, options])](#framewaitforxpathxpath-options). + ### class: Keyboard @@ -1677,7 +1709,8 @@ Returns frame's url. - returns: <[Promise]<[JSHandle]>> Promise which resolves to a JSHandle of the success value This method behaves differently with respect to the type of the first parameter: -- if `selectorOrFunctionOrTimeout` is a `string`, then the first argument is treated as a [selector] to wait for and the method is a shortcut for [frame.waitForSelector](#framewaitforselectorselector-options) +- if `selectorOrFunctionOrTimeout` is a `string`, then the first argument is treated as a [selector] or [xpath], depending on whether or not it starts with '//', and the method is a shortcut for + [frame.waitForSelector](#framewaitforselectorselector-options) or [frame.waitForXPath](#framewaitforsxpathxpath-options) - if `selectorOrFunctionOrTimeout` is a `function`, then the first argument is treated as a predicate to wait for and the method is a shortcut for [frame.waitForFunction()](#framewaitforfunctionpagefunction-options-args). - if `selectorOrFunctionOrTimeout` is a `number`, then the first argument is treated as a timeout in milliseconds and the method returns a promise which resolves after the timeout - otherwise, an exception is thrown @@ -1734,6 +1767,34 @@ puppeteer.launch().then(async browser => { }); ``` +#### frame.waitForXPath(xpath[, options]) +- `xpath` <[string]> A [xpath] of an element to wait for +- `options` <[Object]> Optional waiting parameters + - `visible` <[boolean]> wait for element to be present in DOM and to be visible, i.e. to not have `display: none` or `visibility: hidden` CSS properties. Defaults to `false`. + - `hidden` <[boolean]> wait for element to not be found in the DOM or to be hidden, i.e. have `display: none` or `visibility: hidden` CSS properties. Defaults to `false`. + - `timeout` <[number]> maximum time to wait for in milliseconds. Defaults to `30000` (30 seconds). +- returns: <[Promise]<[ElementHandle]>> Promise which resolves when element specified by xpath string is added to DOM. + +Wait for the `xpath` to appear in page. If at the moment of calling +the method the `xpath` already exists, the method will return +immediately. If the xpath doesn't appear after the `timeout` milliseconds of waiting, the function will throw. + +This method works across navigations: +```js +const puppeteer = require('puppeteer'); + +puppeteer.launch().then(async browser => { + const page = await browser.newPage(); + let currentURL; + page.mainFrame() + .waitForXPath('//img') + .then(() => console.log('First URL with image: ' + currentURL)); + for (currentURL of ['https://example.com', 'https://google.com', 'https://bbc.com']) + await page.goto(currentURL); + await browser.close(); +}); +``` + ### class: ExecutionContext The class represents a context for JavaScript execution. Examples of JavaScript contexts are: @@ -2340,3 +2401,4 @@ reported. [Touchscreen]: #class-touchscreen "Touchscreen" [Target]: #class-target "Target" [USKeyboardLayout]: ../lib/USKeyboardLayout.js "USKeyboardLayout" +[xpath]: https://developer.mozilla.org/en-US/docs/Web/XPath "xpath" diff --git a/lib/FrameManager.js b/lib/FrameManager.js index 4e756396..239fea32 100644 --- a/lib/FrameManager.js +++ b/lib/FrameManager.js @@ -566,8 +566,14 @@ class Frame { * @return {!Promise} */ waitFor(selectorOrFunctionOrTimeout, options = {}, ...args) { - if (helper.isString(selectorOrFunctionOrTimeout)) - return this.waitForSelector(/** @type {string} */(selectorOrFunctionOrTimeout), options); + const xPathPattern = '//'; + + if (helper.isString(selectorOrFunctionOrTimeout)) { + const string = /** @type {string} */ (selectorOrFunctionOrTimeout); + if (string.startsWith(xPathPattern)) + return this.waitForXPath(string, options); + return this.waitForSelector(string, options); + } if (helper.isNumber(selectorOrFunctionOrTimeout)) return new Promise(fulfill => setTimeout(fulfill, selectorOrFunctionOrTimeout)); if (typeof selectorOrFunctionOrTimeout === 'function') @@ -581,37 +587,16 @@ class Frame { * @return {!Promise} */ waitForSelector(selector, options = {}) { - const timeout = options.timeout || 30000; - const waitForVisible = !!options.visible; - const waitForHidden = !!options.hidden; - const polling = waitForVisible || waitForHidden ? 'raf' : 'mutation'; - return this.waitForFunction(predicate, {timeout, polling}, selector, waitForVisible, waitForHidden); + return this._waitForSelectorOrXPath(selector, false, options); + } - /** - * @param {string} selector - * @param {boolean} waitForVisible - * @param {boolean} waitForHidden - * @return {?Node|boolean} - */ - function predicate(selector, waitForVisible, waitForHidden) { - const node = document.querySelector(selector); - if (!node) - return waitForHidden; - if (!waitForVisible && !waitForHidden) - return node; - const style = window.getComputedStyle(node); - const isVisible = style && style.visibility !== 'hidden' && hasVisibleBoundingBox(); - const success = (waitForVisible === isVisible || waitForHidden === !isVisible); - return success ? node : null; - - /** - * @return {boolean} - */ - function hasVisibleBoundingBox() { - const rect = node.getBoundingClientRect(); - return !!(rect.top || rect.bottom || rect.width || rect.height); - } - } + /** + * @param {string} xpath + * @param {!Object=} options + * @return {!Promise} + */ + waitForXPath(xpath, options = {}) { + return this._waitForSelectorOrXPath(xpath, true, options); } /** @@ -632,6 +617,51 @@ class Frame { return this.evaluate(() => document.title); } + /** + * @param {string} selectorOrXPath + * @param {boolean} isXPath + * @param {!Object=} options + * @return {!Promise} + */ + _waitForSelectorOrXPath(selectorOrXPath, isXPath, options = {}) { + const timeout = options.timeout || 30000; + const waitForVisible = !!options.visible; + const waitForHidden = !!options.hidden; + const polling = waitForVisible || waitForHidden ? 'raf' : 'mutation'; + return this.waitForFunction(predicate, {timeout, polling}, selectorOrXPath, isXPath, waitForVisible, waitForHidden); + + /** + * @param {string} selectorOrXPath + * @param {boolean} isXPath + * @param {boolean} waitForVisible + * @param {boolean} waitForHidden + * @return {?Node|boolean} + */ + function predicate(selectorOrXPath, isXPath, waitForVisible, waitForHidden) { + 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 = /** @type {Element} */ (node.nodeType === Node.TEXT_NODE ? node.parentElement : node); + + const style = window.getComputedStyle(element); + const isVisible = style && style.visibility !== 'hidden' && hasVisibleBoundingBox(); + const success = (waitForVisible === isVisible || waitForHidden === !isVisible); + return success ? node : null; + + /** + * @return {boolean} + */ + function hasVisibleBoundingBox() { + const rect = element.getBoundingClientRect(); + return !!(rect.top || rect.bottom || rect.width || rect.height); + } + } + } + /** * @param {!Object} framePayload */ @@ -654,7 +684,7 @@ class Frame { _detach() { for (const waitTask of this._waitTasks) - waitTask.terminate(new Error('waitForSelector failed: frame got detached.')); + waitTask.terminate(new Error('waitForFunction failed: frame got detached.')); this._detached = true; if (this._parentFrame) this._parentFrame._childFrames.delete(this); diff --git a/lib/Page.js b/lib/Page.js index 3d639b90..4ed8edb0 100644 --- a/lib/Page.js +++ b/lib/Page.js @@ -881,6 +881,15 @@ class Page extends EventEmitter { return this.mainFrame().waitForSelector(selector, options); } + /** + * @param {string} xpath + * @param {!Object=} options + * @return {!Promise} + */ + waitForXPath(xpath, options = {}) { + return this.mainFrame().waitForXPath(xpath, options); + } + /** * @param {function()} pageFunction * @param {!Object=} options diff --git a/test/test.js b/test/test.js index 948cdba9..e77845c5 100644 --- a/test/test.js +++ b/test/test.js @@ -797,7 +797,7 @@ describe('Page', function() { await FrameUtils.detachFrame(page, 'frame1'); await waitPromise; expect(waitError).toBeTruthy(); - expect(waitError.message).toContain('waitForSelector failed: frame got detached.'); + expect(waitError.message).toContain('waitForFunction failed: frame got detached.'); }); it('should survive cross-process navigation', async({page, server}) => { let boxFound = false; @@ -884,6 +884,73 @@ describe('Page', function() { }); }); + describe('Frame.waitForXPath', function() { + const addElement = tag => document.body.appendChild(document.createElement(tag)); + + it('should support some fancy xpath', async({page, server}) => { + await page.setContent(`

red herring

hello world

`); + const waitForXPath = page.waitForXPath('//p[normalize-space(.)="hello world"]'); + expect(await page.evaluate(x => x.textContent, await waitForXPath)).toBe('hello world '); + }); + it('should run in specified frame', async({page, server}) => { + await FrameUtils.attachFrame(page, 'frame1', server.EMPTY_PAGE); + await FrameUtils.attachFrame(page, 'frame2', server.EMPTY_PAGE); + const frame1 = page.frames()[1]; + const frame2 = page.frames()[2]; + let added = false; + frame2.waitForXPath('//div').then(() => added = true); + expect(added).toBe(false); + await frame1.evaluate(addElement, 'div'); + expect(added).toBe(false); + await frame2.evaluate(addElement, 'div'); + expect(added).toBe(true); + }); + it('should throw if evaluation failed', async({page, server}) => { + await page.evaluateOnNewDocument(function() { + document.evaluate = null; + }); + await page.goto(server.EMPTY_PAGE); + let error = null; + await page.waitForXPath('*').catch(e => error = e); + expect(error.message).toContain('document.evaluate is not a function'); + }); + it('should throw when frame is detached', async({page, server}) => { + await FrameUtils.attachFrame(page, 'frame1', server.EMPTY_PAGE); + const frame = page.frames()[1]; + let waitError = null; + const waitPromise = frame.waitForXPath('//*[@class="box"]').catch(e => waitError = e); + await FrameUtils.detachFrame(page, 'frame1'); + await waitPromise; + expect(waitError).toBeTruthy(); + expect(waitError.message).toContain('waitForFunction failed: frame got detached.'); + }); + it('hidden should wait for display: none', async({page, server}) => { + let divHidden = false; + await page.setContent(`
`); + const waitForXPath = page.waitForXPath('//div', {hidden: true}).then(() => divHidden = true); + await page.waitForXPath('//div'); // do a round trip + expect(divHidden).toBe(false); + await page.evaluate(() => document.querySelector('div').style.setProperty('display', 'none')); + expect(await waitForXPath).toBe(true); + expect(divHidden).toBe(true); + }); + it('should return the element handle', async({page, server}) => { + const waitForXPath = page.waitForXPath('//*[@class="zombo"]'); + 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'); + expect(await page.evaluate(x => x.textContent, await waitForXPath)).toBe('some text'); + }); + }); + describe('Page.waitFor', function() { it('should wait for selector', async({page, server}) => { let found = false; @@ -894,6 +961,21 @@ describe('Page', function() { await waitFor; expect(found).toBe(true); }); + it('should wait for an xpath', async({page, server}) => { + let found = false; + const waitFor = page.waitFor('//div').then(() => found = true); + await page.goto(server.EMPTY_PAGE); + expect(found).toBe(false); + await page.goto(server.PREFIX + '/grid.html'); + await waitFor; + expect(found).toBe(true); + }); + it('should not allow you to select an element with single slash xpath', async({page, server}) => { + await page.setContent(`
some text
`); + let error = null; + await page.waitFor('/html/body/div').catch(e => error = e); + expect(error).toBeTruthy(); + }); it('should timeout', async({page, server}) => { const startTime = Date.now(); const timeout = 42;