From 3b03ff65c7b369debfcaa67a8673fba105a2d20d Mon Sep 17 00:00:00 2001 From: Andrey Lushnikov Date: Thu, 10 May 2018 13:26:08 -0700 Subject: [PATCH] feat(BrowserContext): introduce Browser Contexts. (#2523) This patch introduces Browser Contexts and methods to manage them: - `browser.createIncognitoBrowserContext()` - to create new incognito context - `browser.browserContext()` - to get all existing contexts - `browserContext.dispose()` - to dispose incognito context. Fixes #85. --- docs/api.md | 126 ++++++++++++++++++++++++++++++++- lib/Browser.js | 128 ++++++++++++++++++++++++++++++---- lib/Launcher.js | 7 +- lib/Target.js | 19 +++-- lib/externs.d.ts | 13 +--- test/browsercontext.spec.js | 134 ++++++++++++++++++++++++++++++++++++ test/test.js | 1 + 7 files changed, 395 insertions(+), 33 deletions(-) create mode 100644 test/browsercontext.spec.js diff --git a/docs/api.md b/docs/api.md index edec5df994d..38a97e805ef 100644 --- a/docs/api.md +++ b/docs/api.md @@ -31,7 +31,9 @@ * [event: 'targetchanged'](#event-targetchanged) * [event: 'targetcreated'](#event-targetcreated) * [event: 'targetdestroyed'](#event-targetdestroyed) + * [browser.browserContexts()](#browserbrowsercontexts) * [browser.close()](#browserclose) + * [browser.createIncognitoBrowserContext()](#browsercreateincognitobrowsercontext) * [browser.disconnect()](#browserdisconnect) * [browser.newPage()](#browsernewpage) * [browser.pages()](#browserpages) @@ -40,6 +42,15 @@ * [browser.userAgent()](#browseruseragent) * [browser.version()](#browserversion) * [browser.wsEndpoint()](#browserwsendpoint) +- [class: BrowserContext](#class-browsercontext) + * [event: 'targetchanged'](#event-targetchanged-1) + * [event: 'targetcreated'](#event-targetcreated-1) + * [event: 'targetdestroyed'](#event-targetdestroyed-1) + * [browserContext.browser()](#browsercontextbrowser) + * [browserContext.close()](#browsercontextclose) + * [browserContext.isIncognito()](#browsercontextisincognito) + * [browserContext.newPage()](#browsercontextnewpage) + * [browserContext.targets()](#browsercontexttargets) - [class: Page](#class-page) * [event: 'close'](#event-close) * [event: 'console'](#event-console) @@ -240,6 +251,7 @@ * [securityDetails.validTo()](#securitydetailsvalidto) - [class: Target](#class-target) * [target.browser()](#targetbrowser) + * [target.browserContext()](#targetbrowsercontext) * [target.createCDPSession()](#targetcreatecdpsession) * [target.page()](#targetpage) * [target.type()](#targettype) @@ -446,27 +458,57 @@ Emitted when Puppeteer gets disconnected from the Chromium instance. This might Emitted when the url of a target changes. +> **NOTE** This includes target changes in incognito browser contexts. + + #### event: 'targetcreated' - <[Target]> Emitted when a target is created, for example when a new page is opened by [`window.open`](https://developer.mozilla.org/en-US/docs/Web/API/Window/open) or [`browser.newPage`](#browsernewpage). +> **NOTE** This includes target creations in incognito browser contexts. + #### event: 'targetdestroyed' - <[Target]> Emitted when a target is destroyed, for example when a page is closed. +> **NOTE** This includes target destructions in incognito browser contexts. + +#### browser.browserContexts() +- returns: <[Array]<[BrowserContext]>> + +Returns an array of all open browser contexts. In a newly created browser, this will return +a single instance of [BrowserContext]. + #### browser.close() - returns: <[Promise]> Closes Chromium and all of its pages (if any were opened). The [Browser] object itself is considered to be disposed and cannot be used anymore. +#### browser.createIncognitoBrowserContext() +- returns: <[Promise]<[BrowserContext]>> + +Creates a new incognito browser context. This won't share cookies/cache with other browser contexts. + +```js +const browser = await puppeteer.launch(); +// Create a new incognito browser context. +const context = await browser.createIncognitoBrowserContext(); +// Create a new page in a pristine context. +const page = await context.newPage(); +// Do stuff +await page.goto('https://example.com'); +``` + #### browser.disconnect() Disconnects Puppeteer from the browser, but leaves the Chromium process running. After calling `disconnect`, the [Browser] object is considered disposed and cannot be used anymore. #### browser.newPage() -- returns: <[Promise]<[Page]>> Promise which resolves to a new [Page] object. +- returns: <[Promise]<[Page]>> + +Promise which resolves to a new [Page] object. The [Page] is created in a default browser context. #### browser.pages() - returns: <[Promise]<[Array]<[Page]>>> Promise which resolves to an array of all open pages. @@ -475,7 +517,10 @@ Disconnects Puppeteer from the browser, but leaves the Chromium process running. - returns: Spawned browser process. Returns `null` if the browser instance was created with [`puppeteer.connect`](#puppeteerconnectoptions) method. #### browser.targets() -- returns: <[Array]<[Target]>> An array of all active targets. +- returns: <[Array]<[Target]>> + +An array of all active targets inside the Browser. In case of multiple browser contexts, +the method will return an array with all the targets in all browser contexts. #### browser.userAgent() - returns: <[Promise]<[string]>> Promise which resolves to the browser's original user agent. @@ -495,6 +540,76 @@ Browser websocket endpoint which can be used as an argument to You can find the `webSocketDebuggerUrl` from `http://${host}:${port}/json/version`. Learn more about the [devtools protocol](https://chromedevtools.github.io/devtools-protocol) and the [browser endpoint](https://chromedevtools.github.io/devtools-protocol/#how-do-i-access-the-browser-target). +### class: BrowserContext + +* extends: [`EventEmitter`](https://nodejs.org/api/events.html#events_class_eventemitter) + +BrowserContexts provide a way to operate multiple independent browser sessions. When a browser is launched, it has +a single BrowserContext used by default. The method `browser.newPage()` creates a page in the default browser context. + +If a page opens another page, e.g. with a `window.open` call, the popup will belong to the parent page's browser +context. + +Puppeteer allows creation of "incognito" browser contexts with `browser.createIncognitoBrowserContext()` method. +"Incognito" browser contexts don't write any browsing data to disk. + +```js +// Create a new incognito browser context +const context = await browser.createIncognitoBrowserContext(); +// Create a new page inside context. +const page = await context.newPage(); +// ... do stuff with page ... +await page.goto('https://example.com'); +// Dispose context once it's no longer needed. +await context.close(); +``` + +#### event: 'targetchanged' +- <[Target]> + +Emitted when the url of a target inside the browser context changes. + +#### event: 'targetcreated' +- <[Target]> + +Emitted when a new target is created inside the browser context, for example when a new page is opened by [`window.open`](https://developer.mozilla.org/en-US/docs/Web/API/Window/open) or [`browserContext.newPage`](#browsercontextnewpage). + +#### event: 'targetdestroyed' +- <[Target]> + +Emitted when a target inside the browser context is destroyed, for example when a page is closed. + +#### browserContext.browser() +- returns: <[Browser]> + +The browser this browser context belongs to. + +#### browserContext.close() +- returns: <[Promise]> + +Closes the browser context. All the targets that belong to the browser context +will be closed. + +> **NOTE** only incognito browser contexts can be closed. + +#### browserContext.isIncognito() +- returns: <[boolean]> + +Returns whether BrowserContext is incognito. +The default browser context is the only non-incognito browser context. + +> **NOTE** the default browser context cannot be closed. + +#### browserContext.newPage() +- returns: <[Promise]<[Page]>> + +Creates a new page in the browser context. + +#### browserContext.targets() +- returns: <[Array]<[Target]>> + +An array of all active targets inside the browser context. + ### class: Page * extends: [`EventEmitter`](https://nodejs.org/api/events.html#events_class_eventemitter) @@ -2607,6 +2722,12 @@ Contains the URL of the response. Get the browser the target belongs to. +#### target.browserContext() + +- returns: <[BrowserContext]> + +The browser context the target belongs to. + #### target.createCDPSession() - returns: <[Promise]<[CDPSession]>> @@ -2734,6 +2855,7 @@ reported. [stream.Readable]: https://nodejs.org/api/stream.html#stream_class_stream_readable "stream.Readable" [CDPSession]: #class-cdpsession "CDPSession" [BrowserFetcher]: #class-browserfetcher "BrowserFetcher" +[BrowserContext]: #class-browsercontext "BrowserContext" [Error]: https://nodejs.org/api/errors.html#errors_class_error "Error" [Frame]: #class-frame "Frame" [ConsoleMessage]: #class-consolemessage "ConsoleMessage" diff --git a/lib/Browser.js b/lib/Browser.js index 77a67fdcba7..a217c48f698 100644 --- a/lib/Browser.js +++ b/lib/Browser.js @@ -22,11 +22,12 @@ const TaskQueue = require('./TaskQueue'); class Browser extends EventEmitter { /** * @param {!Puppeteer.Connection} connection + * @param {!Array} contextIds * @param {!BrowserOptions=} options * @param {?Puppeteer.ChildProcess} process * @param {(function():Promise)=} closeCallback */ - constructor(connection, options = {}, process, closeCallback) { + constructor(connection, contextIds, options = {}, process, closeCallback) { super(); this._ignoreHTTPSErrors = !!options.ignoreHTTPSErrors; this._appMode = !!options.appMode; @@ -34,6 +35,13 @@ class Browser extends EventEmitter { this._screenshotTaskQueue = new TaskQueue(); this._connection = connection; this._closeCallback = closeCallback || new Function(); + + this._defaultContext = new BrowserContext(this, null); + /** @type {Map} */ + this._contexts = new Map(); + for (const contextId of contextIds) + this._contexts.set(contextId, new BrowserContext(this, contextId)); + /** @type {Map} */ this._targets = new Map(); this._connection.setClosedCallback(() => { @@ -51,29 +59,60 @@ class Browser extends EventEmitter { return this._process; } + /** + * @return {!Promise} + */ + async createIncognitoBrowserContext() { + const {browserContextId} = await this._connection.send('Target.createBrowserContext'); + const context = new BrowserContext(this, browserContextId); + this._contexts.set(browserContextId, context); + return context; + } + + /** + * @return {!Array} + */ + browserContexts() { + return [this._defaultContext, ...Array.from(this._contexts.values())]; + } + + /** + * @param {?string} contextId + */ + async _disposeContext(contextId) { + await this._connection.send('Target.disposeBrowserContext', {browserContextId: contextId || undefined}); + this._contexts.delete(contextId); + } + /** * @param {!Puppeteer.Connection} connection + * @param {!Array} contextIds * @param {!BrowserOptions=} options * @param {?Puppeteer.ChildProcess} process * @param {function()=} closeCallback */ - static async create(connection, options, process, closeCallback) { - const browser = new Browser(connection, options, process, closeCallback); + static async create(connection, contextIds, options, process, closeCallback) { + const browser = new Browser(connection, contextIds, options, process, closeCallback); await connection.send('Target.setDiscoverTargets', {discover: true}); return browser; } /** - * @param {{targetInfo: !Puppeteer.TargetInfo}} event + * @param {!Protocol.Target.targetCreatedPayload} event */ async _targetCreated(event) { const targetInfo = event.targetInfo; - const target = new Target(targetInfo, this, () => this._connection.createSession(targetInfo.targetId), this._ignoreHTTPSErrors, !this._appMode, this._screenshotTaskQueue); + const {browserContextId} = targetInfo; + const context = (browserContextId && this._contexts.has(browserContextId)) ? this._contexts.get(browserContextId) : this._defaultContext; + + const target = new Target(targetInfo, context, () => this._connection.createSession(targetInfo.targetId), this._ignoreHTTPSErrors, !this._appMode, this._screenshotTaskQueue); console.assert(!this._targets.has(event.targetInfo.targetId), 'Target should not exist before targetCreated'); this._targets.set(event.targetInfo.targetId, target); - if (await target._initializedPromise) + if (await target._initializedPromise) { this.emit(Browser.Events.TargetCreated, target); + context.emit(BrowserContext.Events.TargetCreated, target); + } } /** @@ -84,12 +123,14 @@ class Browser extends EventEmitter { target._initializedCallback(false); this._targets.delete(event.targetId); target._closedCallback(); - if (await target._initializedPromise) + if (await target._initializedPromise) { this.emit(Browser.Events.TargetDestroyed, target); + target.browserContext().emit(BrowserContext.Events.TargetDestroyed, target); + } } /** - * @param {{targetInfo: !Puppeteer.TargetInfo}} event + * @param {!Protocol.Target.targetInfoChangedPayload} event */ _targetInfoChanged(event) { const target = this._targets.get(event.targetInfo.targetId); @@ -97,8 +138,10 @@ class Browser extends EventEmitter { const previousURL = target.url(); const wasInitialized = target._isInitialized; target._targetInfoChanged(event.targetInfo); - if (wasInitialized && previousURL !== target.url()) + if (wasInitialized && previousURL !== target.url()) { this.emit(Browser.Events.TargetChanged, target); + target.browserContext().emit(BrowserContext.Events.TargetChanged, target); + } } /** @@ -112,7 +155,15 @@ class Browser extends EventEmitter { * @return {!Promise} */ async newPage() { - const {targetId} = await this._connection.send('Target.createTarget', {url: 'about:blank'}); + return this._defaultContext.newPage(); + } + + /** + * @param {string} contextId + * @return {!Promise} + */ + async _createPageInContext(contextId) { + const {targetId} = await this._connection.send('Target.createTarget', {url: 'about:blank', browserContextId: contextId || undefined}); const target = await this._targets.get(targetId); console.assert(await target._initializedPromise, 'Failed to create target for page'); const page = await target.page(); @@ -175,12 +226,65 @@ Browser.Events = { Disconnected: 'disconnected' }; +class BrowserContext extends EventEmitter { + /** + * @param {!Browser} browser + * @param {?string} contextId + */ + constructor(browser, contextId) { + super(); + this._browser = browser; + this._id = contextId; + } + + /** + * @return {!Array} target + */ + targets() { + return this._browser.targets().filter(target => target.browserContext() === this); + } + + /** + * @return {boolean} + */ + isIncognito() { + return !!this._id; + } + + /** + * @return {!Promise} + */ + newPage() { + return this._browser._createPageInContext(this._id); + } + + /** + * @return {!Browser} + */ + browser() { + return this._browser; + } + + async close() { + console.assert(this._id, 'Non-incognito profiles cannot be closed!'); + await this._browser._disposeContext(this._id); + } +} + +/** @enum {string} */ +BrowserContext.Events = { + TargetCreated: 'targetcreated', + TargetDestroyed: 'targetdestroyed', + TargetChanged: 'targetchanged', +}; + +helper.tracePublicAPI(BrowserContext); helper.tracePublicAPI(Browser); -module.exports = Browser; +module.exports = {Browser, BrowserContext}; /** * @typedef {Object} BrowserOptions * @property {boolean=} appMode * @property {boolean=} ignoreHTTPSErrors - */ \ No newline at end of file + */ diff --git a/lib/Launcher.js b/lib/Launcher.js index a1be60295a0..a8cc8ef1513 100644 --- a/lib/Launcher.js +++ b/lib/Launcher.js @@ -19,7 +19,7 @@ const removeFolder = require('rimraf'); const childProcess = require('child_process'); const BrowserFetcher = require('./BrowserFetcher'); const {Connection} = require('./Connection'); -const Browser = require('./Browser'); +const {Browser} = require('./Browser'); const readline = require('readline'); const fs = require('fs'); const {helper, debugError} = require('./helper'); @@ -159,7 +159,7 @@ class Launcher { } else { connection = Connection.createForPipe(/** @type {!NodeJS.WritableStream} */(chromeProcess.stdio[3]), /** @type {!NodeJS.ReadableStream} */ (chromeProcess.stdio[4]), connectionDelay); } - return Browser.create(connection, options, chromeProcess, gracefullyCloseChrome); + return Browser.create(connection, [], options, chromeProcess, gracefullyCloseChrome); } catch (e) { killChrome(); throw e; @@ -226,7 +226,8 @@ class Launcher { static async connect(options = {}) { const connectionDelay = options.slowMo || 0; const connection = await Connection.createForWebSocket(options.browserWSEndpoint, connectionDelay); - return Browser.create(connection, options, null, () => connection.send('Browser.close').catch(debugError)); + const {browserContextIds} = await connection.send('Target.getBrowserContexts'); + return Browser.create(connection, browserContextIds, options, null, () => connection.send('Browser.close').catch(debugError)); } } diff --git a/lib/Target.js b/lib/Target.js index 4de01b4b350..01b6cc16071 100644 --- a/lib/Target.js +++ b/lib/Target.js @@ -3,16 +3,16 @@ const {helper} = require('./helper'); class Target { /** - * @param {!Puppeteer.TargetInfo} targetInfo - * @param {!Puppeteer.Browser} browser + * @param {!Protocol.Target.TargetInfo} targetInfo + * @param {!Puppeteer.BrowserContext} browserContext * @param {!function():!Promise} sessionFactory * @param {boolean} ignoreHTTPSErrors * @param {boolean} setDefaultViewport * @param {!Puppeteer.TaskQueue} screenshotTaskQueue */ - constructor(targetInfo, browser, sessionFactory, ignoreHTTPSErrors, setDefaultViewport, screenshotTaskQueue) { + constructor(targetInfo, browserContext, sessionFactory, ignoreHTTPSErrors, setDefaultViewport, screenshotTaskQueue) { this._targetInfo = targetInfo; - this._browser = browser; + this._browserContext = browserContext; this._targetId = targetInfo.targetId; this._sessionFactory = sessionFactory; this._ignoreHTTPSErrors = ignoreHTTPSErrors; @@ -66,11 +66,18 @@ class Target { * @return {!Puppeteer.Browser} */ browser() { - return this._browser; + return this._browserContext.browser(); } /** - * @param {!Puppeteer.TargetInfo} targetInfo + * @return {!Puppeteer.BrowserContext} + */ + browserContext() { + return this._browserContext; + } + + /** + * @param {!Protocol.Target.TargetInfo} targetInfo */ _targetInfoChanged(targetInfo) { this._targetInfo = targetInfo; diff --git a/lib/externs.d.ts b/lib/externs.d.ts index a54c47f4715..328c0d27fa7 100644 --- a/lib/externs.d.ts +++ b/lib/externs.d.ts @@ -1,5 +1,5 @@ import { Connection as RealConnection, CDPSession as RealCDPSession } from './Connection.js'; -import * as RealBrowser from './Browser.js'; +import { Browser as RealBrowser, BrowserContext as RealBrowserContext} from './Browser.js'; import * as RealTarget from './Target.js'; import * as RealPage from './Page.js'; import * as RealTaskQueue from './TaskQueue.js'; @@ -21,6 +21,7 @@ declare global { export class Touchscreen extends RealTouchscreen {} export class TaskQueue extends RealTaskQueue {} export class Browser extends RealBrowser {} + export class BrowserContext extends RealBrowserContext {} export class Target extends RealTarget {} export class Frame extends RealFrame {} export class FrameManager extends RealFrameManager {} @@ -37,15 +38,7 @@ declare global { close(); } - export interface TargetInfo { - type: string; - targetId: string; - title: string; - url: string; - attached: boolean; - } - export interface ChildProcess extends child_process.ChildProcess {} } -} \ No newline at end of file +} diff --git a/test/browsercontext.spec.js b/test/browsercontext.spec.js new file mode 100644 index 00000000000..1e059e55e0a --- /dev/null +++ b/test/browsercontext.spec.js @@ -0,0 +1,134 @@ +/** + * Copyright 2018 Google Inc. All rights reserved. + * + * 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. + */ + +const utils = require('./utils'); + +module.exports.addTests = function({testRunner, expect, puppeteer}) { + const {describe, xdescribe, fdescribe} = testRunner; + const {it, fit, xit} = testRunner; + const {beforeAll, beforeEach, afterAll, afterEach} = testRunner; + + describe('BrowserContext', function() { + it('should have default context', async function({browser, server}) { + expect(browser.browserContexts().length).toBe(1); + const defaultContext = browser.browserContexts()[0]; + expect(defaultContext.isIncognito()).toBe(false); + let error = null; + await defaultContext.close().catch(e => error = e); + expect(error.message).toContain('cannot be closed'); + }); + it('should create new incognito context', async function({browser, server}) { + expect(browser.browserContexts().length).toBe(1); + const context = await browser.createIncognitoBrowserContext(); + expect(context.isIncognito()).toBe(true); + expect(browser.browserContexts().length).toBe(2); + expect(browser.browserContexts().indexOf(context) !== -1).toBe(true); + await context.close(); + expect(browser.browserContexts().length).toBe(1); + }); + it('should close all belonging targets once closing context', async function({browser, server}) { + expect((await browser.pages()).length).toBe(2); + + const context = await browser.createIncognitoBrowserContext(); + await context.newPage(); + expect((await browser.pages()).length).toBe(3); + + await context.close(); + expect((await browser.pages()).length).toBe(2); + }); + it('window.open should use parent tab context', async function({browser, server}) { + const context = await browser.createIncognitoBrowserContext(); + const page = await context.newPage(); + await page.goto(server.EMPTY_PAGE); + page.evaluate(url => window.open(url), server.EMPTY_PAGE); + const popupTarget = await utils.waitEvent(browser, 'targetcreated'); + expect(popupTarget.browserContext()).toBe(context); + await context.close(); + }); + it('should fire target events', async function({browser, server}) { + const context = await browser.createIncognitoBrowserContext(); + const events = []; + context.on('targetcreated', target => events.push('CREATED: ' + target.url())); + context.on('targetchanged', target => events.push('CHANGED: ' + target.url())); + context.on('targetdestroyed', target => events.push('DESTRYOED: ' + target.url())); + const page = await context.newPage(); + await page.goto(server.EMPTY_PAGE); + await page.close(); + expect(events).toEqual([ + 'CREATED: about:blank', + 'CHANGED: http://localhost:8907/empty.html', + 'DESTRYOED: http://localhost:8907/empty.html' + ]); + await context.close(); + }); + it('should isolate localStorage and cookies', async function({browser, server}) { + // Create two incognito contexts. + const context1 = await browser.createIncognitoBrowserContext(); + const context2 = await browser.createIncognitoBrowserContext(); + expect(context1.targets().length).toBe(0); + expect(context2.targets().length).toBe(0); + + // Create a page in first incognito context. + const page1 = await context1.newPage(); + await page1.goto(server.EMPTY_PAGE); + await page1.evaluate(() => { + localStorage.setItem('name', 'page1'); + document.cookie = 'name=page1'; + }); + + expect(context1.targets().length).toBe(1); + expect(context2.targets().length).toBe(0); + + // Create a page in second incognito context. + const page2 = await context2.newPage(); + await page2.goto(server.EMPTY_PAGE); + await page2.evaluate(() => { + localStorage.setItem('name', 'page2'); + document.cookie = 'name=page2'; + }); + + expect(context1.targets().length).toBe(1); + expect(context1.targets()[0]).toBe(page1.target()); + expect(context2.targets().length).toBe(1); + expect(context2.targets()[0]).toBe(page2.target()); + + // Make sure pages don't share localstorage or cookies. + expect(await page1.evaluate(() => localStorage.getItem('name'))).toBe('page1'); + expect(await page1.evaluate(() => document.cookie)).toBe('name=page1'); + expect(await page2.evaluate(() => localStorage.getItem('name'))).toBe('page2'); + expect(await page2.evaluate(() => document.cookie)).toBe('name=page2'); + + // Cleanup contexts. + await Promise.all([ + context1.close(), + context2.close() + ]); + expect(browser.browserContexts().length).toBe(1); + }); + it('should work across sessions', async function({browser, server}) { + expect(browser.browserContexts().length).toBe(1); + const context = await browser.createIncognitoBrowserContext(); + expect(browser.browserContexts().length).toBe(2); + const remoteBrowser = await puppeteer.connect({ + browserWSEndpoint: browser.wsEndpoint() + }); + const contexts = remoteBrowser.browserContexts(); + expect(contexts.length).toBe(2); + await remoteBrowser.disconnect(); + await context.close(); + }); + }); +}; diff --git a/test/test.js b/test/test.js index 7369b6834a7..cf42a892d0a 100644 --- a/test/test.js +++ b/test/test.js @@ -122,6 +122,7 @@ describe('Page', function() { // Page-level tests that are given a browser and a page. require('./CDPSession.spec.js').addTests({testRunner, expect}); require('./browser.spec.js').addTests({testRunner, expect, puppeteer, headless}); + require('./browsercontext.spec.js').addTests({testRunner, expect, puppeteer}); require('./cookies.spec.js').addTests({testRunner, expect}); require('./coverage.spec.js').addTests({testRunner, expect}); require('./elementhandle.spec.js').addTests({testRunner, expect});