feat(firefox): switch over to WebSocket and support multiclient (#4022)

- switch transport from TCP to WS (yay!)
- implemenet `puppeter.connect()`, `browser.disconnect()`, `'disconnected'`
event and `browser.wsEndpoint()`
This commit is contained in:
Andrey Lushnikov 2019-02-15 17:57:48 -08:00 committed by GitHub
parent e0d4a5d2ec
commit c35821a1a1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 81 additions and 66 deletions

View File

@ -40,6 +40,8 @@ class Browser extends EventEmitter {
for (const browserContextId of browserContextIds)
this._contexts.set(browserContextId, new BrowserContext(this._connection, this, browserContextId));
this._connection.on(Events.Connection.Disconnected, () => this.emit(Events.Browser.Disconnected));
this._eventListeners = [
helper.addEventListener(this._connection, 'Browser.tabOpened', this._onTabOpened.bind(this)),
helper.addEventListener(this._connection, 'Browser.tabClosed', this._onTabClosed.bind(this)),
@ -47,6 +49,14 @@ class Browser extends EventEmitter {
];
}
wsEndpoint() {
return this._connection.url();
}
disconnect() {
this._connection.dispose();
}
/**
* @return {!BrowserContext}
*/

View File

@ -25,8 +25,9 @@ class Connection extends EventEmitter {
* @param {!Puppeteer.ConnectionTransport} transport
* @param {number=} delay
*/
constructor(transport, delay = 0) {
constructor(url, transport, delay = 0) {
super();
this._url = url;
this._lastId = 0;
/** @type {!Map<number, {resolve: function, reject: function, error: !Error, method: string}>}*/
this._callbacks = new Map();
@ -38,6 +39,10 @@ class Connection extends EventEmitter {
this._closed = false;
}
url() {
return this._url;
}
/**
* @param {string} method
* @param {!Object=} params

View File

@ -16,6 +16,7 @@ const Events = {
RequestFailed: 'requestfailed',
},
Browser: {
Disconnected: 'disconnected',
TargetCreated: 'targetcreated',
TargetChanged: 'targetchanged',
TargetDestroyed: 'targetdestroyed',

View File

@ -23,9 +23,9 @@ const {BrowserFetcher} = require('./BrowserFetcher');
const readline = require('readline');
const fs = require('fs');
const util = require('util');
const {helper} = require('./helper');
const {helper, debugError} = require('./helper');
const {TimeoutError} = require('./Errors')
const FirefoxTransport = require('./FirefoxTransport');
const WebSocketTransport = require('./WebSocketTransport');
const mkdtempAsync = util.promisify(fs.mkdtemp);
const removeFolderAsync = util.promisify(removeFolder);
@ -121,14 +121,13 @@ class Launcher {
/** @type {?Connection} */
let connection = null;
try {
const port = await waitForWSEndpoint(firefoxProcess, 30000);
const transport = await FirefoxTransport.create(parseInt(port, 10));
connection = new Connection(transport, slowMo);
const url = await waitForWSEndpoint(firefoxProcess, 30000);
const transport = await WebSocketTransport.create(url);
connection = new Connection(url, transport, slowMo);
const browser = await Browser.create(connection, defaultViewport, firefoxProcess, killFirefox);
if (ignoreHTTPSErrors)
await connection.send('Browser.setIgnoreHTTPSErrors', {enabled: true});
if (!browser.targets().length)
await new Promise(x => browser.once('targetcreated', x));
await browser.waitForTarget(t => t.type() === 'page');
return browser;
} catch (e) {
killFirefox();
@ -156,6 +155,26 @@ class Launcher {
}
}
/**
* @param {Object} options
* @return {!Promise<!Browser>}
*/
async connect(options = {}) {
const {
browserWSEndpoint,
slowMo = 0,
defaultViewport = {width: 800, height: 600},
ignoreHTTPSErrors = false,
} = options;
let connection = null;
const transport = await WebSocketTransport.create(browserWSEndpoint);
connection = new Connection(browserWSEndpoint, transport, slowMo);
const browser = await Browser.create(connection, defaultViewport, null, () => connection.send('Browser.close').catch(debugError));
if (ignoreHTTPSErrors)
await connection.send('Browser.setIgnoreHTTPSErrors', {enabled: true});
return browser;
}
/**
* @return {string}
*/
@ -205,7 +224,7 @@ function waitForWSEndpoint(firefoxProcess, timeout) {
*/
function onLine(line) {
stderr += line + '\n';
const match = line.match(/^Juggler listening on (\d+)$/);
const match = line.match(/^Juggler listening on (ws:\/\/.*)$/);
if (!match)
return;
cleanup();

View File

@ -12,9 +12,9 @@ class NetworkManager extends EventEmitter {
this._frameManager = null;
this._eventListeners = [
helper.addEventListener(session, 'Page.requestWillBeSent', this._onRequestWillBeSent.bind(this)),
helper.addEventListener(session, 'Page.responseReceived', this._onResponseReceived.bind(this)),
helper.addEventListener(session, 'Page.requestFinished', this._onRequestFinished.bind(this)),
helper.addEventListener(session, 'Network.requestWillBeSent', this._onRequestWillBeSent.bind(this)),
helper.addEventListener(session, 'Network.responseReceived', this._onResponseReceived.bind(this)),
helper.addEventListener(session, 'Network.requestFinished', this._onRequestFinished.bind(this)),
];
}

View File

@ -15,6 +15,10 @@ class Puppeteer {
return this._launcher.launch(options);
}
async connect(options) {
return this._launcher.connect(options);
}
createBrowserFetcher(options) {
return new BrowserFetcher(this._projectRoot, options);
}

View File

@ -13,63 +13,39 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const {Socket} = require('net');
const WebSocket = require('ws');
/**
* @implements {!Puppeteer.ConnectionTransport}
* @internal
*/
class FirefoxTransport {
class WebSocketTransport {
/**
* @param {number} port
* @return {!Promise<!FirefoxTransport>}
* @param {string} url
* @return {!Promise<!WebSocketTransport>}
*/
static async create(port) {
const socket = new Socket();
try {
await new Promise((resolve, reject) => {
socket.once('connect', resolve);
socket.once('error', reject);
socket.connect({
port,
host: 'localhost'
static create(url) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(url, [], { perMessageDeflate: false });
ws.addEventListener('open', () => resolve(new WebSocketTransport(ws)));
ws.addEventListener('error', reject);
});
});
} catch (e) {
socket.destroy();
throw e;
}
return new FirefoxTransport(socket);
}
/**
* @param {!Socket} socket
* @param {!WebSocket} ws
*/
constructor(socket) {
this._socket = socket;
this._socket.once('close', had_error => {
constructor(ws) {
this._ws = ws;
this._dispatchQueue = new DispatchQueue(this);
this._ws.addEventListener('message', event => {
this._dispatchQueue.enqueue(event.data);
});
this._ws.addEventListener('close', event => {
if (this.onclose)
this.onclose.call(null);
});
this._dispatchQueue = new DispatchQueue(this);
let buffer = Buffer.from('');
socket.on('data', async data => {
buffer = Buffer.concat([buffer, data]);
while (true) {
const bufferString = buffer.toString();
const seperatorIndex = bufferString.indexOf(':');
if (seperatorIndex === -1)
return;
const length = parseInt(bufferString.substring(0, seperatorIndex), 10);
if (buffer.length < length + seperatorIndex)
return;
const message = buffer.slice(seperatorIndex + 1, seperatorIndex + 1 + length).toString();
buffer = buffer.slice(seperatorIndex + 1 + length);
this._dispatchQueue.enqueue(message);
}
});
// Silently ignore all errors - we don't know what to do with them.
this._socket.on('error', () => {});
this._ws.addEventListener('error', () => {});
this.onmessage = null;
this.onclose = null;
}
@ -78,11 +54,11 @@ class FirefoxTransport {
* @param {string} message
*/
send(message) {
this._socket.write(Buffer.byteLength(message) + ':' + message);
this._ws.send(message);
}
close() {
this._socket.destroy();
this._ws.close();
}
}
@ -123,4 +99,4 @@ class DispatchQueue {
}
}
module.exports = FirefoxTransport;
module.exports = WebSocketTransport;

View File

@ -9,7 +9,7 @@
"node": ">=8.9.4"
},
"puppeteer": {
"firefox_revision": "fd017c27c17d0b4fa8bdea3ad40b88ca2addaeda"
"firefox_revision": "c74102def6c16584c155a98741e8143ab5d615b9"
},
"scripts": {
"install": "node install.js",

View File

@ -31,7 +31,7 @@ module.exports.addTests = function({testRunner, expect, headless, puppeteer}) {
const process = await browser.process();
expect(process.pid).toBeGreaterThan(0);
});
it_fails_ffox('should not return child_process for remote browser', async function({browser}) {
it('should not return child_process for remote browser', async function({browser}) {
const browserWSEndpoint = browser.wsEndpoint();
const remoteBrowser = await puppeteer.connect({browserWSEndpoint});
expect(remoteBrowser.process()).toBe(null);

View File

@ -141,7 +141,7 @@ module.exports.addTests = function({testRunner, expect, puppeteer, Errors}) {
]);
expect(browser.browserContexts().length).toBe(1);
});
it_fails_ffox('should work across sessions', async function({browser, server}) {
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);

View File

@ -300,7 +300,7 @@ module.exports.addTests = function({testRunner, expect, defaultBrowserOptions, p
await browser.close();
});
});
describe_fails_ffox('Puppeteer.connect', function() {
describe('Puppeteer.connect', function() {
it('should be able to connect multiple times to the same browser', async({server}) => {
const originalBrowser = await puppeteer.launch(defaultBrowserOptions);
const browser = await puppeteer.connect({
@ -349,7 +349,7 @@ module.exports.addTests = function({testRunner, expect, defaultBrowserOptions, p
expect(await restoredPage.evaluate(() => 7 * 8)).toBe(56);
await browser.close();
});
it('should be able to connect using browserUrl, with and without trailing slash', async({server}) => {
it_fails_ffox('should be able to connect using browserUrl, with and without trailing slash', async({server}) => {
const originalBrowser = await puppeteer.launch(Object.assign({}, defaultBrowserOptions, {
args: ['--remote-debugging-port=21222']
}));
@ -366,7 +366,7 @@ module.exports.addTests = function({testRunner, expect, defaultBrowserOptions, p
browser2.disconnect();
originalBrowser.close();
});
it('should throw when using both browserWSEndpoint and browserURL', async({server}) => {
it_fails_ffox('should throw when using both browserWSEndpoint and browserURL', async({server}) => {
const originalBrowser = await puppeteer.launch(Object.assign({}, defaultBrowserOptions, {
args: ['--remote-debugging-port=21222']
}));
@ -378,7 +378,7 @@ module.exports.addTests = function({testRunner, expect, defaultBrowserOptions, p
originalBrowser.close();
});
it('should throw when trying to connect to non-existing browser', async({server}) => {
it_fails_ffox('should throw when trying to connect to non-existing browser', async({server}) => {
const originalBrowser = await puppeteer.launch(Object.assign({}, defaultBrowserOptions, {
args: ['--remote-debugging-port=21222']
}));
@ -414,7 +414,7 @@ module.exports.addTests = function({testRunner, expect, defaultBrowserOptions, p
});
});
describe_fails_ffox('Browser.Events.disconnected', function() {
describe('Browser.Events.disconnected', function() {
it('should be emitted when: browser gets closed, disconnected or underlying websocket gets closed', async() => {
const originalBrowser = await puppeteer.launch(defaultBrowserOptions);
const browserWSEndpoint = originalBrowser.wsEndpoint();