From 6ac66c3547fdd95fed97fc90b15ac0b4bf178a86 Mon Sep 17 00:00:00 2001 From: Joel Einbinder Date: Tue, 9 Oct 2018 14:16:53 -0700 Subject: [PATCH] feat: browser.waitForTarget (#3356) This adds `browser.waitForTarget` and `browserContext.waitForTarget`. It also fixes a flaky test that was incorrectly expecting targets to appear instantly. --- docs/api.md | 30 +++++++++++++++++++++++++++ lib/Browser.js | 41 +++++++++++++++++++++++++++++++++++++ lib/helper.js | 19 +++++++++++++++++ test/browsercontext.spec.js | 19 +++++++++++++++++ test/target.spec.js | 2 +- 5 files changed, 110 insertions(+), 1 deletion(-) diff --git a/docs/api.md b/docs/api.md index c060f6d9..19258f42 100644 --- a/docs/api.md +++ b/docs/api.md @@ -52,6 +52,7 @@ * [browser.targets()](#browsertargets) * [browser.userAgent()](#browseruseragent) * [browser.version()](#browserversion) + * [browser.waitForTarget(predicate[, options])](#browserwaitfortargetpredicate-options) * [browser.wsEndpoint()](#browserwsendpoint) - [class: BrowserContext](#class-browsercontext) * [event: 'targetchanged'](#event-targetchanged-1) @@ -65,6 +66,7 @@ * [browserContext.overridePermissions(origin, permissions)](#browsercontextoverridepermissionsorigin-permissions) * [browserContext.pages()](#browsercontextpages) * [browserContext.targets()](#browsercontexttargets) + * [browserContext.waitForTarget(predicate[, options])](#browsercontextwaitfortargetpredicate-options) - [class: Page](#class-page) * [event: 'close'](#event-close) * [event: 'console'](#event-console) @@ -699,6 +701,20 @@ the method will return an array with all the targets in all browser contexts. > **NOTE** the format of browser.version() might change with future releases of Chromium. +#### browser.waitForTarget(predicate[, options]) +- `predicate` <[function]\([Target]\):[boolean]> A function to be run for every target +- `options` <[Object]> + - `timeout` <[number]> Maximum wait time in milliseconds. Pass `0` to disable the timeout. Defaults to 30 seconds. +- returns: <[Promise]<[Target]>> Promise which resolves to the first target found that matches the `predicate` function. + +This searches for a target in all browser contexts. + +An example of finding a target for a page opened via `window.open`: +```js +await page.evaluate(() => window.open('https://www.example.com/')); +const newWindowTarget = await browser.waitForTarget(target => target.url() === 'https://www.example.com/'); +``` + #### browser.wsEndpoint() - returns: <[string]> Browser websocket url. @@ -823,6 +839,20 @@ An array of all pages inside the browser context. An array of all active targets inside the browser context. +#### browserContext.waitForTarget(predicate[, options]) +- `predicate` <[function]\([Target]\):[boolean]> A function to be run for every target +- `options` <[Object]> + - `timeout` <[number]> Maximum wait time in milliseconds. Pass `0` to disable the timeout. Defaults to 30 seconds. +- returns: <[Promise]<[Target]>> Promise which resolves to the first target found that matches the `predicate` function. + +This searches for a target in this specific browser context. + +An example of finding a target for a page opened via `window.open`: +```js +await page.evaluate(() => window.open('https://www.example.com/')); +const newWindowTarget = await browserContext.waitForTarget(target => target.url() === 'https://www.example.com/'); +``` + ### class: Page * extends: [`EventEmitter`](https://nodejs.org/api/events.html#events_class_eventemitter) diff --git a/lib/Browser.js b/lib/Browser.js index 0ccd7720..c8df1236 100644 --- a/lib/Browser.js +++ b/lib/Browser.js @@ -192,6 +192,39 @@ class Browser extends EventEmitter { return this.targets().find(target => target.type() === 'browser'); } + /** + * @param {function(!Target):boolean} predicate + * @param {{timeout?: number}=} options + */ + async waitForTarget(predicate, options = {}) { + const { + timeout = 30000 + } = options; + const existingTarget = this.targets().find(predicate); + if (existingTarget) + return existingTarget; + let resolve; + const targetPromise = new Promise(x => resolve = x); + this.on(Browser.Events.TargetCreated, check); + this.on(Browser.Events.TargetChanged, check); + try { + if (!timeout) + return await targetPromise; + return await helper.waitWithTimeout(targetPromise, 'target', timeout); + } finally { + this.removeListener(Browser.Events.TargetCreated, check); + this.removeListener(Browser.Events.TargetChanged, check); + } + + /** + * @param {!Target} target + */ + function check(target) { + if (predicate(target)) + resolve(target); + } + } + /** * @return {!Promise>} */ @@ -262,6 +295,14 @@ class BrowserContext extends EventEmitter { return this._browser.targets().filter(target => target.browserContext() === this); } + /** + * @param {function(!Target):boolean} predicate + * @param {{timeout?: number}=} options + */ + waitForTarget(predicate, options) { + return this._browser.waitForTarget(target => target.browserContext() === this && predicate(target), options); + } + /** * @return {!Promise>} */ diff --git a/lib/helper.js b/lib/helper.js index d5400818..169a8218 100644 --- a/lib/helper.js +++ b/lib/helper.js @@ -248,6 +248,25 @@ class Helper { } return promise; } + + /** + * @template T + * @param {!Promise} promise + * @param {string} taskName + * @param {number} timeout + * @return {!Promise} + */ + static async waitWithTimeout(promise, taskName, timeout) { + let reject; + const timeoutError = new TimeoutError(`waiting for ${taskName} failed: timeout ${timeout}ms exceeded`); + const timeoutPromise = new Promise((resolve, x) => reject = x); + const timeoutTimer = setTimeout(() => reject(timeoutError), timeout); + try { + return await Promise.race([promise, timeoutPromise]); + } finally { + clearTimeout(timeoutTimer); + } + } } /** diff --git a/test/browsercontext.spec.js b/test/browsercontext.spec.js index 51e1ca59..fb1ac727 100644 --- a/test/browsercontext.spec.js +++ b/test/browsercontext.spec.js @@ -16,6 +16,7 @@ const utils = require('./utils'); const puppeteer = utils.requireRoot('index'); +const {TimeoutError} = utils.requireRoot('Errors'); module.exports.addTests = function({testRunner, expect}) { const {describe, xdescribe, fdescribe} = testRunner; @@ -79,6 +80,24 @@ module.exports.addTests = function({testRunner, expect}) { ]); await context.close(); }); + it('should wait for a target', async function({browser, server}) { + const context = await browser.createIncognitoBrowserContext(); + let resolved = false; + const targetPromise = context.waitForTarget(target => target.url() === server.EMPTY_PAGE); + targetPromise.then(() => resolved = true); + const page = await context.newPage(); + expect(resolved).toBe(false); + await page.goto(server.EMPTY_PAGE); + const target = await targetPromise; + expect(await target.page()).toBe(page); + await context.close(); + }); + it('should timeout waiting for a non-existent target', async function({browser, server}) { + const context = await browser.createIncognitoBrowserContext(); + const error = await context.waitForTarget(target => target.url() === server.EMPTY_PAGE, {timeout: 1}).catch(e => e); + expect(error).toBeInstanceOf(TimeoutError); + await context.close(); + }); it('should isolate localStorage and cookies', async function({browser, server}) { // Create two incognito contexts. const context1 = await browser.createIncognitoBrowserContext(); diff --git a/test/target.spec.js b/test/target.spec.js index 1fcb0721..44e80ffe 100644 --- a/test/target.spec.js +++ b/test/target.spec.js @@ -121,7 +121,7 @@ module.exports.addTests = function({testRunner, expect}) { server.waitForRequest('/one-style.css') ]); // Connect to the opened page. - const target = context.targets().find(target => target.url().includes('one-style.html')); + const target = await context.waitForTarget(target => target.url().includes('one-style.html')); const newPage = await target.page(); // Issue a redirect. serverResponse.writeHead(302, { location: '/injectedstyle.css' });