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.
This commit is contained in:
Andrey Lushnikov 2018-05-10 13:26:08 -07:00 committed by GitHub
parent 58c672b131
commit 3b03ff65c7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 395 additions and 33 deletions

View File

@ -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: <?[ChildProcess]> 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"

View File

@ -22,11 +22,12 @@ const TaskQueue = require('./TaskQueue');
class Browser extends EventEmitter {
/**
* @param {!Puppeteer.Connection} connection
* @param {!Array<string>} 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<string, BrowserContext>} */
this._contexts = new Map();
for (const contextId of contextIds)
this._contexts.set(contextId, new BrowserContext(this, contextId));
/** @type {Map<string, Target>} */
this._targets = new Map();
this._connection.setClosedCallback(() => {
@ -51,29 +59,60 @@ class Browser extends EventEmitter {
return this._process;
}
/**
* @return {!Promise<!BrowserContext>}
*/
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<!BrowserContext>}
*/
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<string>} 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<!Puppeteer.Page>}
*/
async newPage() {
const {targetId} = await this._connection.send('Target.createTarget', {url: 'about:blank'});
return this._defaultContext.newPage();
}
/**
* @param {string} contextId
* @return {!Promise<!Puppeteer.Page>}
*/
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>} target
*/
targets() {
return this._browser.targets().filter(target => target.browserContext() === this);
}
/**
* @return {boolean}
*/
isIncognito() {
return !!this._id;
}
/**
* @return {!Promise<!Puppeteer.Page>}
*/
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
*/
*/

View File

@ -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));
}
}

View File

@ -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<!Puppeteer.CDPSession>} 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;

13
lib/externs.d.ts vendored
View File

@ -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 {}
}
}
}

134
test/browsercontext.spec.js Normal file
View File

@ -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();
});
});
};

View File

@ -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});