diff --git a/experimental/puppeteer-firefox/lib/firefox/Browser.js b/experimental/puppeteer-firefox/lib/firefox/Browser.js index aaae4523..58ff5ed0 100644 --- a/experimental/puppeteer-firefox/lib/firefox/Browser.js +++ b/experimental/puppeteer-firefox/lib/firefox/Browser.js @@ -1,4 +1,4 @@ -const {helper} = require('./helper'); +const {helper, assert} = require('./helper'); const {Page} = require('./Page'); const EventEmitter = require('events'); @@ -9,7 +9,21 @@ class Browser extends EventEmitter { * @param {?Puppeteer.ChildProcess} process * @param {function():void} closeCallback */ - constructor(connection, defaultViewport, process, closeCallback) { + static async create(connection, defaultViewport, process, closeCallback) { + const {browserContextIds} = await connection.send('Browser.getBrowserContexts'); + const browser = new Browser(connection, browserContextIds, defaultViewport, process, closeCallback); + await connection.send('Browser.enable'); + return browser; + } + + /** + * @param {!Puppeteer.Connection} connection + * @param {!Array} browserContextIds + * @param {?Puppeteer.Viewport} defaultViewport + * @param {?Puppeteer.ChildProcess} process + * @param {function():void} closeCallback + */ + constructor(connection, browserContextIds, defaultViewport, process, closeCallback) { super(); this._connection = connection; this._defaultViewport = defaultViewport; @@ -19,6 +33,12 @@ class Browser extends EventEmitter { /** @type {!Map} */ this._pageTargets = new Map(); + this._defaultContext = new BrowserContext(this._connection, this, null); + /** @type {!Map} */ + this._contexts = new Map(); + for (const browserContextId of browserContextIds) + this._contexts.set(browserContextId, new BrowserContext(this._connection, this, browserContextId)); + this._eventListeners = [ helper.addEventListener(this._connection, 'Browser.tabOpened', this._onTabOpened.bind(this)), helper.addEventListener(this._connection, 'Browser.tabClosed', this._onTabClosed.bind(this)), @@ -26,6 +46,32 @@ class Browser extends EventEmitter { ]; } + /** + * @return {!BrowserContext} + */ + async createIncognitoBrowserContext() { + const {browserContextId} = await this._connection.send('Browser.createBrowserContext'); + const context = new BrowserContext(this._connection, this, browserContextId); + this._contexts.set(browserContextId, context); + return context; + } + + /** + * @return {!Array} + */ + browserContexts() { + return [this._defaultContext, ...Array.from(this._contexts.values())]; + } + + defaultBrowserContext() { + return this._defaultContext; + } + + async _disposeContext(browserContextId) { + await this._connection.send('Browser.removeBrowserContext', {browserContextId}); + this._contexts.delete(browserContextId); + } + /** * @return {!Promise} */ @@ -83,8 +129,21 @@ class Browser extends EventEmitter { } } - async newPage() { - const {pageId} = await this._connection.send('Browser.newPage'); + /** + * @return {Promise} + */ + newPage() { + return this._createPageInContext(this._defaultContext._browserContextId); + } + + /** + * @param {?string} browserContextId + * @return {Promise} + */ + async _createPageInContext(browserContextId) { + const {pageId} = await this._connection.send('Browser.newPage', { + browserContextId: browserContextId || undefined + }); const target = this._pageTargets.get(pageId); return await target.page(); } @@ -98,22 +157,26 @@ class Browser extends EventEmitter { return Array.from(this._pageTargets.values()); } - _onTabOpened({pageId, url}) { - const target = new Target(this._connection, this, pageId, url); + _onTabOpened({pageId, url, browserContextId}) { + const context = browserContextId ? this._contexts.get(browserContextId) : this._defaultContext; + const target = new Target(this._connection, this, context, pageId, url); this._pageTargets.set(pageId, target); this.emit(Browser.Events.TargetCreated, target); + context.emit(BrowserContext.Events.TargetCreated, target); } _onTabClosed({pageId}) { const target = this._pageTargets.get(pageId); this._pageTargets.delete(pageId); this.emit(Browser.Events.TargetDestroyed, target); + target.browserContext().emit(BrowserContext.Events.TargetDestroyed, target); } _onTabNavigated({pageId, url}) { const target = this._pageTargets.get(pageId); target._url = url; this.emit(Browser.Events.TargetChanged, target); + target.browserContext().emit(BrowserContext.Events.TargetChanged, target); } async close() { @@ -134,11 +197,13 @@ class Target { * * @param {*} connection * @param {!Browser} browser + * @param {!BrowserContext} context * @param {string} pageId * @param {string} url */ - constructor(connection, browser, pageId, url) { + constructor(connection, browser, context, pageId, url) { this._browser = browser; + this._context = context; this._connection = connection; this._pageId = pageId; /** @type {?Promise} */ @@ -157,6 +222,13 @@ class Target { return this._url; } + /** + * @return {!BrowserContext} + */ + browserContext() { + return this._context; + } + async page() { if (!this._pagePromise) this._pagePromise = Page.create(this._connection, this, this._pageId, this._browser._defaultViewport); @@ -168,4 +240,76 @@ class Target { } } +class BrowserContext extends EventEmitter { + /** + * @param {!Puppeteer.Connection} connection + * @param {!Browser} browser + * @param {?string} browserContextId + */ + constructor(connection, browser, browserContextId) { + super(); + this._connection = connection; + this._browser = browser; + this._browserContextId = browserContextId; + } + + /** + * @return {Array} + */ + targets() { + return this._browser.targets().filter(target => target.browserContext() === this); + } + + /** + * @return {Promise>} + */ + async pages() { + const pages = await Promise.all( + this.targets() + .filter(target => target.type() === 'page') + .map(target => target.page()) + ); + return pages.filter(page => !!page); + } + + /** + * @param {function(Target):boolean} predicate + * @param {{timeout?: number}=} options + * @return {!Promise} + */ + waitForTarget(predicate, options) { + return this._browser.waitForTarget(target => target.browserContext() === this && predicate(target), options); + } + + /** + * @return {boolean} + */ + isIncognito() { + return !!this._browserContextId; + } + + newPage() { + return this._browser._createPageInContext(this._browserContextId); + } + + /** + * @return {!Browser} + */ + browser() { + return this._browser; + } + + async close() { + assert(this._browserContextId, 'Non-incognito contexts cannot be closed!'); + await this._browser._disposeContext(this._browserContextId); + } +} + +/** @enum {string} */ +BrowserContext.Events = { + TargetCreated: 'targetcreated', + TargetChanged: 'targetchanged', + TargetDestroyed: 'targetdestroyed' +} + module.exports = {Browser, Target}; diff --git a/experimental/puppeteer-firefox/lib/firefox/Launcher.js b/experimental/puppeteer-firefox/lib/firefox/Launcher.js index 2365e2f5..bff4c3ca 100644 --- a/experimental/puppeteer-firefox/lib/firefox/Launcher.js +++ b/experimental/puppeteer-firefox/lib/firefox/Launcher.js @@ -116,7 +116,7 @@ class Launcher { const port = await waitForWSEndpoint(firefoxProcess, 30000); const transport = await FirefoxTransport.create(parseInt(port, 10)); connection = new Connection(transport, slowMo); - const browser = new Browser(connection, defaultViewport, firefoxProcess, killFirefox); + const browser = await Browser.create(connection, defaultViewport, firefoxProcess, killFirefox); if (ignoreHTTPSErrors) await connection.send('Browser.setIgnoreHTTPSErrors', {enabled: true}); if (!browser.targets().length) diff --git a/experimental/puppeteer-firefox/package.json b/experimental/puppeteer-firefox/package.json index 199b83bb..9c7adb51 100644 --- a/experimental/puppeteer-firefox/package.json +++ b/experimental/puppeteer-firefox/package.json @@ -8,7 +8,7 @@ "node": ">=8.9.4" }, "puppeteer": { - "firefox_revision": "e5fdeac984d4f966caafcdbc9b14da7a7f73fbed" + "firefox_revision": "2a2b2cd2c5e5b7062e7ede93b235a5a062d4dc9a" }, "scripts": { "install": "node install.js", diff --git a/experimental/puppeteer-firefox/test/browsercontext.spec.js b/experimental/puppeteer-firefox/test/browsercontext.spec.js new file mode 100644 index 00000000..11cf3c0c --- /dev/null +++ b/experimental/puppeteer-firefox/test/browsercontext.spec.js @@ -0,0 +1,160 @@ +/** + * 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'); +const {TimeoutError} = require('../Errors'); + +module.exports.addTests = function({testRunner, expect, puppeteer, product}) { + const {describe, xdescribe, fdescribe} = testRunner; + const {it, fit, xit} = testRunner; + const {beforeAll, beforeEach, afterAll, afterEach} = testRunner; + + const FFOX = product === 'firefox'; + const CHROME = product === 'chromium'; + + 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(browser.defaultBrowserContext()).toBe(defaultContext); + 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(1); + + const context = await browser.createIncognitoBrowserContext(); + await context.newPage(); + expect((await browser.pages()).length).toBe(2); + expect((await context.pages()).length).toBe(1); + + await context.close(); + expect((await browser.pages()).length).toBe(1); + }); + 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); + const [popupTarget] = await Promise.all([ + utils.waitEvent(browser, 'targetcreated'), + page.evaluate(url => window.open(url), server.EMPTY_PAGE) + ]); + 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('DESTROYED: ' + target.url())); + const page = await context.newPage(); + await page.goto(server.EMPTY_PAGE); + await page.close(); + expect(events).toEqual([ + 'CREATED: about:blank', + `CHANGED: ${server.EMPTY_PAGE}`, + `DESTROYED: ${server.EMPTY_PAGE}` + ]); + 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(); + 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); + }); + (FFOX ? xit : 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/experimental/puppeteer-firefox/test/puppeteer.spec.js b/experimental/puppeteer-firefox/test/puppeteer.spec.js index e3d644df..0365ec1c 100644 --- a/experimental/puppeteer-firefox/test/puppeteer.spec.js +++ b/experimental/puppeteer-firefox/test/puppeteer.spec.js @@ -38,6 +38,7 @@ module.exports.addTests = ({testRunner, product, puppeteer}) => testRunner.descr }; if (product === 'firefox' && state.defaultBrowserOptions.executablePath) { await require('../misc/install-preferences')(state.defaultBrowserOptions.executablePath); + console.log('RUNNING CUSTOM FIREFOX: ' + state.defaultBrowserOptions.executablePath); } }); afterAll(state => { @@ -58,6 +59,7 @@ module.exports.addTests = ({testRunner, product, puppeteer}) => testRunner.descr }); require('./browser.spec.js').addTests({testRunner, expect, product, puppeteer}); + require('./browsercontext.spec.js').addTests({testRunner, expect, product, puppeteer}); describe('Page', () => { beforeEach(async state => {