diff --git a/lib/Connection.js b/lib/Connection.js index b3439bed..e657926c 100644 --- a/lib/Connection.js +++ b/lib/Connection.js @@ -19,6 +19,7 @@ const debugSession = require('debug')('puppeteer:session'); const EventEmitter = require('events'); const WebSocket = require('ws'); +const Pipe = require('./Pipe'); class Connection extends EventEmitter { /** @@ -26,7 +27,7 @@ class Connection extends EventEmitter { * @param {number=} delay * @return {!Promise} */ - static async create(url, delay = 0) { + static async createForWebSocket(url, delay = 0) { return new Promise((resolve, reject) => { const ws = new WebSocket(url, { perMessageDeflate: false }); ws.on('open', () => resolve(new Connection(url, ws, delay))); @@ -34,12 +35,22 @@ class Connection extends EventEmitter { }); } + /** + * @param {!NodeJS.WritableStream} pipeWrite + * @param {!NodeJS.ReadableStream} pipeRead + * @param {number=} delay + * @return {!Connection} + */ + static createForPipe(pipeWrite, pipeRead, delay = 0) { + return new Connection('', new Pipe(pipeWrite, pipeRead), delay); + } + /** * @param {string} url - * @param {!WebSocket} ws + * @param {!Puppeteer.ConnectionTransport} transport * @param {number=} delay */ - constructor(url, ws, delay = 0) { + constructor(url, transport, delay = 0) { super(); this._url = url; this._lastId = 0; @@ -47,9 +58,9 @@ class Connection extends EventEmitter { this._callbacks = new Map(); this._delay = delay; - this._ws = ws; - this._ws.on('message', this._onMessage.bind(this)); - this._ws.on('close', this._onClose.bind(this)); + this._transport = transport; + this._transport.on('message', this._onMessage.bind(this)); + this._transport.on('close', this._onClose.bind(this)); /** @type {!Map}*/ this._sessions = new Map(); } @@ -70,7 +81,7 @@ class Connection extends EventEmitter { const id = ++this._lastId; const message = JSON.stringify({id, method, params}); debugProtocol('SEND ► ' + message); - this._ws.send(message); + this._transport.send(message); return new Promise((resolve, reject) => { this._callbacks.set(id, {resolve, reject, error: new Error(), method}); }); @@ -120,7 +131,7 @@ class Connection extends EventEmitter { this._closeCallback(); this._closeCallback = null; } - this._ws.removeAllListeners(); + this._transport.removeAllListeners(); for (const callback of this._callbacks.values()) callback.reject(rewriteError(callback.error, `Protocol error (${callback.method}): Target closed.`)); this._callbacks.clear(); @@ -131,7 +142,7 @@ class Connection extends EventEmitter { dispose() { this._onClose(); - this._ws.close(); + this._transport.close(); } /** diff --git a/lib/Launcher.js b/lib/Launcher.js index 583f1b1a..7c7e60b0 100644 --- a/lib/Launcher.js +++ b/lib/Launcher.js @@ -43,7 +43,6 @@ const DEFAULT_ARGS = [ '--disable-translate', '--metrics-recording-only', '--no-first-run', - '--remote-debugging-port=0', '--safebrowsing-disable-auto-update', ]; @@ -51,6 +50,7 @@ const AUTOMATION_ARGS = [ '--enable-automation', '--password-store=basic', '--use-mock-keychain', + '--remote-debugging-port=0', ]; class Launcher { @@ -60,15 +60,17 @@ class Launcher { */ static async launch(options) { options = Object.assign({}, options || {}); + console.assert(!options.ignoreDefaultArgs || !options.appMode, '`appMode` flag cannot be used together with `ignoreDefaultArgs`'); let temporaryUserDataDir = null; const chromeArguments = []; if (!options.ignoreDefaultArgs) chromeArguments.push(...DEFAULT_ARGS); - - if (options.appMode) + if (options.appMode) { options.headless = false; - else if (!options.ignoreDefaultArgs) + chromeArguments.push('--remote-debugging-pipe'); + } else if (!options.ignoreDefaultArgs) { chromeArguments.push(...AUTOMATION_ARGS); + } if (!options.args || !options.args.some(arg => arg.startsWith('--user-data-dir'))) { if (!options.userDataDir) @@ -98,18 +100,19 @@ class Launcher { if (Array.isArray(options.args)) chromeArguments.push(...options.args); + const stdio = options.dumpio ? ['inherit', 'inherit', 'inherit'] : ['pipe', 'pipe', 'pipe']; + if (options.appMode) + stdio.push('pipe', 'pipe'); + const chromeProcess = childProcess.spawn( chromeExecutable, chromeArguments, { detached: true, - env: options.env || process.env + env: options.env || process.env, + stdio } ); - if (options.dumpio) { - chromeProcess.stdout.pipe(process.stdout); - chromeProcess.stderr.pipe(process.stderr); - } let chromeClosed = false; const waitForChromeToClose = new Promise((fulfill, reject) => { @@ -136,10 +139,14 @@ class Launcher { /** @type {?Connection} */ let connection = null; try { - const timeout = helper.isNumber(options.timeout) ? options.timeout : 30000; const connectionDelay = options.slowMo || 0; - const browserWSEndpoint = await waitForWSEndpoint(chromeProcess, timeout); - connection = await Connection.create(browserWSEndpoint, connectionDelay); + if (!options.appMode) { + const timeout = helper.isNumber(options.timeout) ? options.timeout : 30000; + const browserWSEndpoint = await waitForWSEndpoint(chromeProcess, timeout); + connection = await Connection.createForWebSocket(browserWSEndpoint, connectionDelay); + } else { + connection = Connection.createForPipe(/** @type {!NodeJS.WritableStream} */(chromeProcess.stdio[3]), /** @type {!NodeJS.ReadableStream} */ (chromeProcess.stdio[4]), connectionDelay); + } return Browser.create(connection, options, chromeProcess, killChrome); } catch (e) { forceKillChrome(); @@ -198,7 +205,7 @@ class Launcher { */ static async connect(options = {}) { const connectionDelay = options.slowMo || 0; - const connection = await Connection.create(options.browserWSEndpoint, connectionDelay); + const connection = await Connection.createForWebSocket(options.browserWSEndpoint, connectionDelay); return Browser.create(connection, options, null, () => connection.send('Browser.close')); } } diff --git a/lib/Pipe.js b/lib/Pipe.js new file mode 100644 index 00000000..c08dea11 --- /dev/null +++ b/lib/Pipe.js @@ -0,0 +1,69 @@ +/** + * 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 {helper} = require('./helper'); +const EventEmitter = require('events'); + +class Pipe extends EventEmitter { + /** + * @param {!NodeJS.WritableStream} pipeWrite + * @param {!NodeJS.ReadableStream} pipeRead + */ + constructor(pipeWrite, pipeRead) { + super(); + this._pipeWrite = pipeWrite; + this._pendingMessage = ''; + this._eventListeners = [ + helper.addEventListener(pipeRead, 'data', buffer => this._dispatch(buffer)) + ]; + } + + /** + * @param {string} message + */ + send(message) { + this._pipeWrite.write(message); + this._pipeWrite.write('\n'); + } + + /** + * @param {!Buffer} buffer + */ + _dispatch(buffer) { + let end = buffer.indexOf('\n'); + if (end === -1) { + this._pendingMessage += buffer.toString(); + return; + } + const message = this._pendingMessage + buffer.toString(undefined, 0, end); + this.emit('message', message); + + let start = end + 1; + end = buffer.indexOf('\n', start); + while (end !== -1) { + this.emit('message', buffer.toString(undefined, start, end)); + start = end + 1; + end = buffer.indexOf('\n', start); + } + this._pendingMessage = buffer.toString(undefined, start); + } + + close() { + this._pipeWrite = null; + helper.removeEventListeners(this._eventListeners); + } +} + +module.exports = Pipe; diff --git a/lib/externs.d.ts b/lib/externs.d.ts index baa6eaac..0d2f36de 100644 --- a/lib/externs.d.ts +++ b/lib/externs.d.ts @@ -25,5 +25,9 @@ export class JSHandle extends RealJSHandle {} export class ExecutionContext extends RealExecutionContext {} export class Page extends RealPage {} +export interface ConnectionTransport extends NodeJS.EventEmitter { + send(string); + close(); +} export interface ChildProcess extends child_process.ChildProcess {} diff --git a/test/test.js b/test/test.js index bb3b51ed..3739c65d 100644 --- a/test/test.js +++ b/test/test.js @@ -141,6 +141,16 @@ describe('Puppeteer', function() { rm(downloadsFolder); }); }); + describe('AppMode', function() { + it('should work', async() => { + const options = Object.assign({appMode: true}, defaultBrowserOptions); + const browser = await puppeteer.launch(options); + const page = await browser.newPage(); + expect(await page.evaluate('11 * 11')).toBe(121); + await page.close(); + await browser.close(); + }); + }); describe('Puppeteer.launch', function() { it('should support ignoreHTTPSErrors option', async({httpsServer}) => { const options = Object.assign({ignoreHTTPSErrors: true}, defaultBrowserOptions); diff --git a/utils/doclint/check_public_api/index.js b/utils/doclint/check_public_api/index.js index 251223f1..b3a97b0a 100644 --- a/utils/doclint/check_public_api/index.js +++ b/utils/doclint/check_public_api/index.js @@ -30,6 +30,7 @@ const EXCLUDE_CLASSES = new Set([ 'Multimap', 'NavigatorWatcher', 'NetworkManager', + 'Pipe', 'TaskQueue', 'WaitTask', ]);