UNPKG

11.9 kBJavaScriptView Raw
1/**
2 * Copyright 2017 Google Inc. All rights reserved.
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16const os = require('os');
17const path = require('path');
18const removeFolder = require('rimraf');
19const childProcess = require('child_process');
20const BrowserFetcher = require('./BrowserFetcher');
21const {Connection} = require('./Connection');
22const {Browser} = require('./Browser');
23const readline = require('readline');
24const fs = require('fs');
25const {helper, assert, debugError} = require('./helper');
26const ChromiumRevision = require(path.join(helper.projectRoot(), 'package.json')).puppeteer.chromium_revision;
27
28const mkdtempAsync = helper.promisify(fs.mkdtemp);
29const removeFolderAsync = helper.promisify(removeFolder);
30
31const CHROME_PROFILE_PATH = path.join(os.tmpdir(), 'puppeteer_dev_profile-');
32
33const DEFAULT_ARGS = [
34 '--disable-background-networking',
35 '--disable-background-timer-throttling',
36 '--disable-breakpad',
37 '--disable-client-side-phishing-detection',
38 '--disable-default-apps',
39 '--disable-dev-shm-usage',
40 '--disable-extensions',
41 // TODO: Support OOOPIF. @see https://github.com/GoogleChrome/puppeteer/issues/2548
42 '--disable-features=site-per-process',
43 '--disable-hang-monitor',
44 '--disable-popup-blocking',
45 '--disable-prompt-on-repost',
46 '--disable-sync',
47 '--disable-translate',
48 '--metrics-recording-only',
49 '--no-first-run',
50 '--safebrowsing-disable-auto-update',
51];
52
53const AUTOMATION_ARGS = [
54 '--enable-automation',
55 '--password-store=basic',
56 '--use-mock-keychain',
57];
58
59class Launcher {
60 /**
61 * @param {!LaunchOptions=} options
62 * @return {!Promise<!Browser>}
63 */
64 static async launch(options) {
65 options = Object.assign({}, options || {});
66 assert(!options.ignoreDefaultArgs || !options.appMode, '`appMode` flag cannot be used together with `ignoreDefaultArgs`');
67 let temporaryUserDataDir = null;
68 const chromeArguments = [];
69 if (!options.ignoreDefaultArgs)
70 chromeArguments.push(...DEFAULT_ARGS);
71 if (options.appMode) {
72 options.headless = false;
73 options.pipe = true;
74 } else if (!options.ignoreDefaultArgs) {
75 chromeArguments.push(...AUTOMATION_ARGS);
76 }
77
78 if (!options.ignoreDefaultArgs || !chromeArguments.some(argument => argument.startsWith('--remote-debugging-')))
79 chromeArguments.push(options.pipe ? '--remote-debugging-pipe' : '--remote-debugging-port=0');
80
81 if (!options.args || !options.args.some(arg => arg.startsWith('--user-data-dir'))) {
82 if (!options.userDataDir)
83 temporaryUserDataDir = await mkdtempAsync(CHROME_PROFILE_PATH);
84
85 chromeArguments.push(`--user-data-dir=${options.userDataDir || temporaryUserDataDir}`);
86 }
87 if (options.devtools === true) {
88 chromeArguments.push('--auto-open-devtools-for-tabs');
89 options.headless = false;
90 }
91 if (typeof options.headless !== 'boolean' || options.headless) {
92 chromeArguments.push(
93 '--headless',
94 '--disable-gpu',
95 '--hide-scrollbars',
96 '--mute-audio'
97 );
98 }
99 if (Array.isArray(options.args) && options.args.every(arg => arg.startsWith('-')))
100 chromeArguments.push('about:blank');
101
102 let chromeExecutable = options.executablePath;
103 if (typeof chromeExecutable !== 'string') {
104 const browserFetcher = new BrowserFetcher();
105 const revisionInfo = browserFetcher.revisionInfo(ChromiumRevision);
106 assert(revisionInfo.local, `Chromium revision is not downloaded. Run "npm install" or "yarn install"`);
107 chromeExecutable = revisionInfo.executablePath;
108 }
109 if (Array.isArray(options.args))
110 chromeArguments.push(...options.args);
111
112 const usePipe = chromeArguments.includes('--remote-debugging-pipe');
113 const stdio = ['pipe', 'pipe', 'pipe'];
114 if (usePipe)
115 stdio.push('pipe', 'pipe');
116 const chromeProcess = childProcess.spawn(
117 chromeExecutable,
118 chromeArguments,
119 {
120 // On non-windows platforms, `detached: false` makes child process a leader of a new
121 // process group, making it possible to kill child process tree with `.kill(-pid)` command.
122 // @see https://nodejs.org/api/child_process.html#child_process_options_detached
123 detached: process.platform !== 'win32',
124 env: options.env || process.env,
125 stdio
126 }
127 );
128
129 if (options.dumpio) {
130 chromeProcess.stderr.pipe(process.stderr);
131 chromeProcess.stdout.pipe(process.stdout);
132 }
133
134 let chromeClosed = false;
135 const waitForChromeToClose = new Promise((fulfill, reject) => {
136 chromeProcess.once('exit', () => {
137 chromeClosed = true;
138 // Cleanup as processes exit.
139 if (temporaryUserDataDir) {
140 removeFolderAsync(temporaryUserDataDir)
141 .then(() => fulfill())
142 .catch(err => console.error(err));
143 } else {
144 fulfill();
145 }
146 });
147 });
148
149 const listeners = [ helper.addEventListener(process, 'exit', killChrome) ];
150 if (options.handleSIGINT !== false)
151 listeners.push(helper.addEventListener(process, 'SIGINT', () => { killChrome(); process.exit(130); }));
152 if (options.handleSIGTERM !== false)
153 listeners.push(helper.addEventListener(process, 'SIGTERM', gracefullyCloseChrome));
154 if (options.handleSIGHUP !== false)
155 listeners.push(helper.addEventListener(process, 'SIGHUP', gracefullyCloseChrome));
156 /** @type {?Connection} */
157 let connection = null;
158 try {
159 const connectionDelay = options.slowMo || 0;
160 if (!usePipe) {
161 const timeout = helper.isNumber(options.timeout) ? options.timeout : 30000;
162 const browserWSEndpoint = await waitForWSEndpoint(chromeProcess, timeout);
163 connection = await Connection.createForWebSocket(browserWSEndpoint, connectionDelay);
164 } else {
165 connection = Connection.createForPipe(/** @type {!NodeJS.WritableStream} */(chromeProcess.stdio[3]), /** @type {!NodeJS.ReadableStream} */ (chromeProcess.stdio[4]), connectionDelay);
166 }
167 const ignoreHTTPSErrors = !!options.ignoreHTTPSErrors;
168 const setDefaultViewport = !options.appMode;
169 const browser = await Browser.create(connection, [], ignoreHTTPSErrors, setDefaultViewport, chromeProcess, gracefullyCloseChrome);
170 await ensureInitialPage(browser);
171 return browser;
172 } catch (e) {
173 killChrome();
174 throw e;
175 }
176
177 /**
178 * @param {!Browser} browser
179 */
180 async function ensureInitialPage(browser) {
181 // Wait for initial page target to be created.
182 if (browser.targets().find(target => target.type() === 'page'))
183 return;
184
185 let initialPageCallback;
186 const initialPagePromise = new Promise(resolve => initialPageCallback = resolve);
187 const listeners = [helper.addEventListener(browser, 'targetcreated', target => {
188 if (target.type() === 'page')
189 initialPageCallback();
190 })];
191
192 await initialPagePromise;
193 helper.removeEventListeners(listeners);
194 }
195
196 /**
197 * @return {Promise}
198 */
199 function gracefullyCloseChrome() {
200 helper.removeEventListeners(listeners);
201 if (temporaryUserDataDir) {
202 killChrome();
203 } else if (connection) {
204 // Attempt to close chrome gracefully
205 connection.send('Browser.close').catch(error => {
206 debugError(error);
207 killChrome();
208 });
209 }
210 return waitForChromeToClose;
211 }
212
213 // This method has to be sync to be used as 'exit' event handler.
214 function killChrome() {
215 helper.removeEventListeners(listeners);
216 if (chromeProcess.pid && !chromeProcess.killed && !chromeClosed) {
217 // Force kill chrome.
218 try {
219 if (process.platform === 'win32')
220 childProcess.execSync(`taskkill /pid ${chromeProcess.pid} /T /F`);
221 else
222 process.kill(-chromeProcess.pid, 'SIGKILL');
223 } catch (e) {
224 // the process might have already stopped
225 }
226 }
227 // Attempt to remove temporary profile directory to avoid littering.
228 try {
229 removeFolder.sync(temporaryUserDataDir);
230 } catch (e) { }
231 }
232 }
233
234 /**
235 * @return {!Array<string>}
236 */
237 static defaultArgs() {
238 return DEFAULT_ARGS.concat(AUTOMATION_ARGS);
239 }
240
241 /**
242 * @return {string}
243 */
244 static executablePath() {
245 const browserFetcher = new BrowserFetcher();
246 const revisionInfo = browserFetcher.revisionInfo(ChromiumRevision);
247 return revisionInfo.executablePath;
248 }
249
250 /**
251 * @param {!Object=} options
252 * @return {!Promise<!Browser>}
253 */
254 static async connect(options = {}) {
255 const connectionDelay = options.slowMo || 0;
256 const connection = await Connection.createForWebSocket(options.browserWSEndpoint, connectionDelay);
257 const {browserContextIds} = await connection.send('Target.getBrowserContexts');
258 const ignoreHTTPSErrors = !!options.ignoreHTTPSErrors;
259 return Browser.create(connection, browserContextIds, ignoreHTTPSErrors, true /* setDefaultViewport */, null, () => connection.send('Browser.close').catch(debugError));
260 }
261}
262
263/**
264 * @param {!Puppeteer.ChildProcess} chromeProcess
265 * @param {number} timeout
266 * @return {!Promise<string>}
267 */
268function waitForWSEndpoint(chromeProcess, timeout) {
269 return new Promise((resolve, reject) => {
270 const rl = readline.createInterface({ input: chromeProcess.stderr });
271 let stderr = '';
272 const listeners = [
273 helper.addEventListener(rl, 'line', onLine),
274 helper.addEventListener(rl, 'close', () => onClose()),
275 helper.addEventListener(chromeProcess, 'exit', () => onClose()),
276 helper.addEventListener(chromeProcess, 'error', error => onClose(error))
277 ];
278 const timeoutId = timeout ? setTimeout(onTimeout, timeout) : 0;
279
280 /**
281 * @param {!Error=} error
282 */
283 function onClose(error) {
284 cleanup();
285 reject(new Error([
286 'Failed to launch chrome!' + (error ? ' ' + error.message : ''),
287 stderr,
288 '',
289 'TROUBLESHOOTING: https://github.com/GoogleChrome/puppeteer/blob/master/docs/troubleshooting.md',
290 '',
291 ].join('\n')));
292 }
293
294 function onTimeout() {
295 cleanup();
296 reject(new Error(`Timed out after ${timeout} ms while trying to connect to Chrome! The only Chrome revision guaranteed to work is r${ChromiumRevision}`));
297 }
298
299 /**
300 * @param {string} line
301 */
302 function onLine(line) {
303 stderr += line + '\n';
304 const match = line.match(/^DevTools listening on (ws:\/\/.*)$/);
305 if (!match)
306 return;
307 cleanup();
308 resolve(match[1]);
309 }
310
311 function cleanup() {
312 if (timeoutId)
313 clearTimeout(timeoutId);
314 helper.removeEventListeners(listeners);
315 }
316 });
317}
318
319/**
320 * @typedef {Object} LaunchOptions
321 * @property {boolean=} ignoreHTTPSErrors
322 * @property {boolean=} headless
323 * @property {string=} executablePath
324 * @property {number=} slowMo
325 * @property {!Array<string>=} args
326 * @property {boolean=} ignoreDefaultArgs
327 * @property {boolean=} handleSIGINT
328 * @property {boolean=} handleSIGTERM
329 * @property {boolean=} handleSIGHUP
330 * @property {number=} timeout
331 * @property {boolean=} dumpio
332 * @property {string=} userDataDir
333 * @property {!Object<string, string | undefined>=} env
334 * @property {boolean=} devtools
335 * @property {boolean=} pipe
336 * @property {boolean=} appMode
337 */
338
339
340module.exports = Launcher;