refactor(firefox): migrate onto Juggler flatten protocol (#4033)

Juggler now implements the same "flatten" protocol as CDP.
This patch:

* copies `Connection.js` from original Puppeteer (with a few renames, e.g. `CDPSesssion` -> `JugglerSession`).
* migrates code to support protocol-level sessions
This commit is contained in:
Andrey Lushnikov 2019-02-19 13:20:39 -08:00 committed by GitHub
parent 4a4793a5e1
commit 3b180923a6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 171 additions and 71 deletions

View File

@ -11,9 +11,9 @@ class Browser extends EventEmitter {
* @param {function():void} closeCallback * @param {function():void} closeCallback
*/ */
static async create(connection, defaultViewport, process, closeCallback) { static async create(connection, defaultViewport, process, closeCallback) {
const {browserContextIds} = await connection.send('Browser.getBrowserContexts'); const {browserContextIds} = await connection.send('Target.getBrowserContexts');
const browser = new Browser(connection, browserContextIds, defaultViewport, process, closeCallback); const browser = new Browser(connection, browserContextIds, defaultViewport, process, closeCallback);
await connection.send('Browser.enable'); await connection.send('Target.enable');
return browser; return browser;
} }
@ -43,9 +43,9 @@ class Browser extends EventEmitter {
this._connection.on(Events.Connection.Disconnected, () => this.emit(Events.Browser.Disconnected)); this._connection.on(Events.Connection.Disconnected, () => this.emit(Events.Browser.Disconnected));
this._eventListeners = [ this._eventListeners = [
helper.addEventListener(this._connection, 'Browser.targetCreated', this._onTargetCreated.bind(this)), helper.addEventListener(this._connection, 'Target.targetCreated', this._onTargetCreated.bind(this)),
helper.addEventListener(this._connection, 'Browser.targetDestroyed', this._onTargetDestroyed.bind(this)), helper.addEventListener(this._connection, 'Target.targetDestroyed', this._onTargetDestroyed.bind(this)),
helper.addEventListener(this._connection, 'Browser.targetInfoChanged', this._onTargetInfoChanged.bind(this)), helper.addEventListener(this._connection, 'Target.targetInfoChanged', this._onTargetInfoChanged.bind(this)),
]; ];
} }
@ -61,7 +61,7 @@ class Browser extends EventEmitter {
* @return {!BrowserContext} * @return {!BrowserContext}
*/ */
async createIncognitoBrowserContext() { async createIncognitoBrowserContext() {
const {browserContextId} = await this._connection.send('Browser.createBrowserContext'); const {browserContextId} = await this._connection.send('Target.createBrowserContext');
const context = new BrowserContext(this._connection, this, browserContextId); const context = new BrowserContext(this._connection, this, browserContextId);
this._contexts.set(browserContextId, context); this._contexts.set(browserContextId, context);
return context; return context;
@ -79,7 +79,7 @@ class Browser extends EventEmitter {
} }
async _disposeContext(browserContextId) { async _disposeContext(browserContextId) {
await this._connection.send('Browser.removeBrowserContext', {browserContextId}); await this._connection.send('Target.removeBrowserContext', {browserContextId});
this._contexts.delete(browserContextId); this._contexts.delete(browserContextId);
} }
@ -152,7 +152,7 @@ class Browser extends EventEmitter {
* @return {Promise<Page>} * @return {Promise<Page>}
*/ */
async _createPageInContext(browserContextId) { async _createPageInContext(browserContextId) {
const {targetId} = await this._connection.send('Browser.newPage', { const {targetId} = await this._connection.send('Target.newPage', {
browserContextId: browserContextId || undefined browserContextId: browserContextId || undefined
}); });
const target = this._targets.get(targetId); const target = this._targets.get(targetId);
@ -190,6 +190,7 @@ class Browser extends EventEmitter {
_onTargetDestroyed({targetId}) { _onTargetDestroyed({targetId}) {
const target = this._targets.get(targetId); const target = this._targets.get(targetId);
this._targets.delete(targetId); this._targets.delete(targetId);
target._closedCallback();
this.emit(Events.Browser.TargetDestroyed, target); this.emit(Events.Browser.TargetDestroyed, target);
target.browserContext().emit(Events.BrowserContext.TargetDestroyed, target); target.browserContext().emit(Events.BrowserContext.TargetDestroyed, target);
} }
@ -228,6 +229,7 @@ class Target {
this._pagePromise = null; this._pagePromise = null;
this._url = url; this._url = url;
this._openerId = openerId; this._openerId = openerId;
this._isClosedPromise = new Promise(fulfill => this._closedCallback = fulfill);
} }
/** /**
@ -256,8 +258,10 @@ class Target {
} }
async page() { async page() {
if (this._type === 'page' && !this._pagePromise) if (this._type === 'page' && !this._pagePromise) {
this._pagePromise = Page.create(this._connection, this, this._targetId, this._browser._defaultViewport); const session = await this._connection.createSession(this._targetId);
this._pagePromise = Page.create(session, this, this._browser._defaultViewport);
}
return this._pagePromise; return this._pagePromise;
} }

View File

@ -13,15 +13,14 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
const debugProtocol = require('debug')('hdfox:protocol'); const {assert} = require('./helper');
const EventEmitter = require('events');
const {Events} = require('./Events'); const {Events} = require('./Events');
const debugProtocol = require('debug')('puppeteer:protocol');
const EventEmitter = require('events');
/**
* @internal
*/
class Connection extends EventEmitter { class Connection extends EventEmitter {
/** /**
* @param {string} url
* @param {!Puppeteer.ConnectionTransport} transport * @param {!Puppeteer.ConnectionTransport} transport
* @param {number=} delay * @param {number=} delay
*/ */
@ -36,9 +35,30 @@ class Connection extends EventEmitter {
this._transport = transport; this._transport = transport;
this._transport.onmessage = this._onMessage.bind(this); this._transport.onmessage = this._onMessage.bind(this);
this._transport.onclose = this._onClose.bind(this); this._transport.onclose = this._onClose.bind(this);
/** @type {!Map<string, !JugglerSession>}*/
this._sessions = new Map();
this._closed = false; this._closed = false;
} }
/**
* @param {!JugglerSession} session
* @return {!Connection}
*/
static fromSession(session) {
return session._connection;
}
/**
* @param {string} sessionId
* @return {?JugglerSession}
*/
session(sessionId) {
return this._sessions.get(sessionId) || null;
}
/**
* @return {string}
*/
url() { url() {
return this._url; return this._url;
} }
@ -49,15 +69,24 @@ class Connection extends EventEmitter {
* @return {!Promise<?Object>} * @return {!Promise<?Object>}
*/ */
send(method, params = {}) { send(method, params = {}) {
const id = ++this._lastId; const id = this._rawSend({method, params});
const message = JSON.stringify({id, method, params});
debugProtocol('SEND ► ' + message);
this._transport.send(message);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this._callbacks.set(id, {resolve, reject, error: new Error(), method}); this._callbacks.set(id, {resolve, reject, error: new Error(), method});
}); });
} }
/**
* @param {*} message
* @return {number}
*/
_rawSend(message) {
const id = ++this._lastId;
message = JSON.stringify(Object.assign({}, message, {id}));
debugProtocol('SEND ► ' + message);
this._transport.send(message);
return id;
}
/** /**
* @param {string} message * @param {string} message
*/ */
@ -66,7 +95,22 @@ class Connection extends EventEmitter {
await new Promise(f => setTimeout(f, this._delay)); await new Promise(f => setTimeout(f, this._delay));
debugProtocol('◀ RECV ' + message); debugProtocol('◀ RECV ' + message);
const object = JSON.parse(message); const object = JSON.parse(message);
if (object.id) { if (object.method === 'Target.attachedToTarget') {
const sessionId = object.params.sessionId;
const session = new JugglerSession(this, object.params.targetInfo.type, sessionId);
this._sessions.set(sessionId, session);
} else if (object.method === 'Browser.detachedFromTarget') {
const session = this._sessions.get(object.params.sessionId);
if (session) {
session._onClosed();
this._sessions.delete(object.params.sessionId);
}
}
if (object.sessionId) {
const session = this._sessions.get(object.sessionId);
if (session)
session._onMessage(object);
} else if (object.id) {
const callback = this._callbacks.get(object.id); const callback = this._callbacks.get(object.id);
// Callbacks could be all rejected if someone has called `.dispose()`. // Callbacks could be all rejected if someone has called `.dispose()`.
if (callback) { if (callback) {
@ -90,6 +134,9 @@ class Connection extends EventEmitter {
for (const callback of this._callbacks.values()) for (const callback of this._callbacks.values())
callback.reject(rewriteError(callback.error, `Protocol error (${callback.method}): Target closed.`)); callback.reject(rewriteError(callback.error, `Protocol error (${callback.method}): Target closed.`));
this._callbacks.clear(); this._callbacks.clear();
for (const session of this._sessions.values())
session._onClosed();
this._sessions.clear();
this.emit(Events.Connection.Disconnected); this.emit(Events.Connection.Disconnected);
} }
@ -97,6 +144,76 @@ class Connection extends EventEmitter {
this._onClose(); this._onClose();
this._transport.close(); this._transport.close();
} }
/**
* @param {string} targetId
* @return {!Promise<!JugglerSession>}
*/
async createSession(targetId) {
const {sessionId} = await this.send('Target.attachToTarget', {targetId});
return this._sessions.get(sessionId);
}
}
class JugglerSession extends EventEmitter {
/**
* @param {!Connection} connection
* @param {string} targetType
* @param {string} sessionId
*/
constructor(connection, targetType, sessionId) {
super();
/** @type {!Map<number, {resolve: function, reject: function, error: !Error, method: string}>}*/
this._callbacks = new Map();
this._connection = connection;
this._targetType = targetType;
this._sessionId = sessionId;
}
/**
* @param {string} method
* @param {!Object=} params
* @return {!Promise<?Object>}
*/
send(method, params = {}) {
if (!this._connection)
return Promise.reject(new Error(`Protocol error (${method}): Session closed. Most likely the ${this._targetType} has been closed.`));
const id = this._connection._rawSend({sessionId: this._sessionId, method, params});
return new Promise((resolve, reject) => {
this._callbacks.set(id, {resolve, reject, error: new Error(), method});
});
}
/**
* @param {{id?: number, method: string, params: Object, error: {message: string, data: any}, result?: *}} object
*/
_onMessage(object) {
if (object.id && this._callbacks.has(object.id)) {
const callback = this._callbacks.get(object.id);
this._callbacks.delete(object.id);
if (object.error)
callback.reject(createProtocolError(callback.error, callback.method, object));
else
callback.resolve(object.result);
} else {
assert(!object.id);
this.emit(object.method, object.params);
}
}
async detach() {
if (!this._connection)
throw new Error(`Session already detached. Most likely the ${this._targetType} has been closed.`);
await this._connection.send('Target.detachFromTarget', {sessionId: this._sessionId});
}
_onClosed() {
for (const callback of this._callbacks.values())
callback.reject(rewriteError(callback.error, `Protocol error (${callback.method}): Target closed.`));
this._callbacks.clear();
this._connection = null;
this.emit(Events.JugglerSession.Disconnected);
}
} }
/** /**
@ -122,4 +239,4 @@ function rewriteError(error, message) {
return error; return error;
} }
module.exports = {Connection}; module.exports = {Connection, JugglerSession};

View File

@ -31,6 +31,10 @@ const Events = {
Disconnected: Symbol('Events.Connection.Disconnected'), Disconnected: Symbol('Events.Connection.Disconnected'),
}, },
JugglerSession: {
Disconnected: Symbol('Events.JugglerSession.Disconnected'),
},
FrameManager: { FrameManager: {
Load: Symbol('Events.FrameManager.Load'), Load: Symbol('Events.FrameManager.Load'),
DOMContentLoaded: Symbol('Events.FrameManager.DOMContentLoaded'), DOMContentLoaded: Symbol('Events.FrameManager.DOMContentLoaded'),

View File

@ -15,49 +15,20 @@ const {NavigationWatchdog, NextNavigationWatchdog} = require('./NavigationWatchd
const writeFileAsync = util.promisify(fs.writeFile); const writeFileAsync = util.promisify(fs.writeFile);
/**
* @internal
*/
class PageSession extends EventEmitter {
constructor(connection, targetId) {
super();
this._connection = connection;
this._targetId = targetId;
const wrapperSymbol = Symbol('listenerWrapper');
function wrapperListener(listener, params) {
if (params.targetId === targetId)
listener.call(null, params);
}
this.on('removeListener', (eventName, listener) => {
this._connection.removeListener(eventName, listener[wrapperSymbol]);
});
this.on('newListener', (eventName, listener) => {
if (!listener[wrapperSymbol])
listener[wrapperSymbol] = wrapperListener.bind(null, listener);
this._connection.on(eventName, listener[wrapperSymbol]);
});
}
async send(method, params = {}) {
params = Object.assign({}, params, {targetId: this._targetId});
return await this._connection.send(method, params);
}
}
class Page extends EventEmitter { class Page extends EventEmitter {
/** /**
* *
* @param {!Puppeteer.Connection} connection * @param {!Puppeteer.JugglerSession} connection
* @param {!Puppeteer.Target} target * @param {!Puppeteer.Target} target
* @param {string} targetId
* @param {?Puppeteer.Viewport} defaultViewport * @param {?Puppeteer.Viewport} defaultViewport
*/ */
static async create(connection, target, targetId, defaultViewport) { static async create(session, target, defaultViewport) {
const session = new PageSession(connection, targetId);
const page = new Page(session, target); const page = new Page(session, target);
await session.send('Page.enable'); await Promise.all([
session.send('Page.enable'),
session.send('Network.enable'),
]);
if (defaultViewport) if (defaultViewport)
await page.setViewport(defaultViewport); await page.setViewport(defaultViewport);
return page; return page;
@ -74,7 +45,7 @@ class Page extends EventEmitter {
this._target = target; this._target = target;
this._keyboard = new Keyboard(session); this._keyboard = new Keyboard(session);
this._mouse = new Mouse(session, this._keyboard); this._mouse = new Mouse(session, this._keyboard);
this._isClosed = false; this._closed = false;
this._networkManager = new NetworkManager(session); this._networkManager = new NetworkManager(session);
this._frameManager = new FrameManager(session, this, this._networkManager, this._timeoutSettings); this._frameManager = new FrameManager(session, this, this._networkManager, this._timeoutSettings);
this._networkManager.setFrameManager(this._frameManager); this._networkManager.setFrameManager(this._frameManager);
@ -82,7 +53,6 @@ class Page extends EventEmitter {
helper.addEventListener(this._session, 'Page.uncaughtError', this._onUncaughtError.bind(this)), helper.addEventListener(this._session, 'Page.uncaughtError', this._onUncaughtError.bind(this)),
helper.addEventListener(this._session, 'Page.console', this._onConsole.bind(this)), helper.addEventListener(this._session, 'Page.console', this._onConsole.bind(this)),
helper.addEventListener(this._session, 'Page.dialogOpened', this._onDialogOpened.bind(this)), helper.addEventListener(this._session, 'Page.dialogOpened', this._onDialogOpened.bind(this)),
helper.addEventListener(this._session, 'Browser.targetDestroyed', this._onClosed.bind(this)),
helper.addEventListener(this._frameManager, Events.FrameManager.Load, () => this.emit(Events.Page.Load)), helper.addEventListener(this._frameManager, Events.FrameManager.Load, () => this.emit(Events.Page.Load)),
helper.addEventListener(this._frameManager, Events.FrameManager.DOMContentLoaded, () => this.emit(Events.Page.DOMContentLoaded)), helper.addEventListener(this._frameManager, Events.FrameManager.DOMContentLoaded, () => this.emit(Events.Page.DOMContentLoaded)),
helper.addEventListener(this._frameManager, Events.FrameManager.FrameAttached, frame => this.emit(Events.Page.FrameAttached, frame)), helper.addEventListener(this._frameManager, Events.FrameManager.FrameAttached, frame => this.emit(Events.Page.FrameAttached, frame)),
@ -94,6 +64,13 @@ class Page extends EventEmitter {
helper.addEventListener(this._networkManager, Events.NetworkManager.RequestFailed, request => this.emit(Events.Page.RequestFailed, request)), helper.addEventListener(this._networkManager, Events.NetworkManager.RequestFailed, request => this.emit(Events.Page.RequestFailed, request)),
]; ];
this._viewport = null; this._viewport = null;
this._target._isClosedPromise.then(() => {
this._closed = true;
this._frameManager.dispose();
this._networkManager.dispose();
helper.removeEventListeners(this._eventListeners);
this.emit(Events.Page.Close);
});
} }
/** /**
@ -553,7 +530,9 @@ class Page extends EventEmitter {
const { const {
runBeforeUnload = false, runBeforeUnload = false,
} = options; } = options;
await this._session.send('Browser.closePage', { runBeforeUnload }); await this._session.send('Page.close', { runBeforeUnload });
if (!runBeforeUnload)
await this._target._isClosedPromise;
} }
async content() { async content() {
@ -568,11 +547,6 @@ class Page extends EventEmitter {
} }
_onClosed() { _onClosed() {
this._isClosed = true;
this._frameManager.dispose();
this._networkManager.dispose();
helper.removeEventListeners(this._eventListeners);
this.emit(Events.Page.Close);
} }
_onConsole({type, args, frameId, location}) { _onConsole({type, args, frameId, location}) {
@ -584,7 +558,7 @@ class Page extends EventEmitter {
* @return {boolean} * @return {boolean}
*/ */
isClosed() { isClosed() {
return this._isClosed; return this._closed;
} }
} }

View File

@ -9,7 +9,7 @@
"node": ">=8.9.4" "node": ">=8.9.4"
}, },
"puppeteer": { "puppeteer": {
"firefox_revision": "2ede4ae19f39ec7a1b73162a6004235908260dfe" "firefox_revision": "387ac6bbbe5357d174e9fb3aa9b6f935113c315d"
}, },
"scripts": { "scripts": {
"install": "node install.js", "install": "node install.js",

View File

@ -265,7 +265,7 @@ module.exports.addTests = function({testRunner, expect, Errors, CHROME}) {
process.removeListener('warning', warningHandler); process.removeListener('warning', warningHandler);
expect(warning).toBe(null); expect(warning).toBe(null);
}); });
it_fails_ffox('should not leak listeners during navigation of 11 pages', async({page, context, server}) => { it('should not leak listeners during navigation of 11 pages', async({page, context, server}) => {
let warning = null; let warning = null;
const warningHandler = w => warning = w; const warningHandler = w => warning = w;
process.on('warning', warningHandler); process.on('warning', warningHandler);

View File

@ -36,10 +36,11 @@ module.exports.addTests = function({testRunner, expect, headless, Errors, Device
describe('Page.close', function() { describe('Page.close', function() {
it('should reject all promises when page is closed', async({context}) => { it('should reject all promises when page is closed', async({context}) => {
const newPage = await context.newPage(); const newPage = await context.newPage();
const neverResolves = newPage.evaluate(() => new Promise(r => {}));
newPage.close();
let error = null; let error = null;
await neverResolves.catch(e => error = e); await Promise.all([
newPage.evaluate(() => new Promise(r => {})).catch(e => error = e),
newPage.close(),
]);
expect(error.message).toContain('Protocol error'); expect(error.message).toContain('Protocol error');
}); });
it('should not be visible in browser.pages', async({browser}) => { it('should not be visible in browser.pages', async({browser}) => {