UNPKG

13.8 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;
27const {TimeoutError} = require('./Errors');
28
29const mkdtempAsync = helper.promisify(fs.mkdtemp);
30const removeFolderAsync = helper.promisify(removeFolder);
31
32const CHROME_PROFILE_PATH = path.join(os.tmpdir(), 'puppeteer_dev_profile-');
33
34const DEFAULT_ARGS = [
35 '--disable-background-networking',
36 '--disable-background-timer-throttling',
37 '--disable-breakpad',
38 '--disable-client-side-phishing-detection',
39 '--disable-default-apps',
40 '--disable-dev-shm-usage',
41 '--disable-extensions',
42 // TODO: Support OOOPIF. @see https://github.com/GoogleChrome/puppeteer/issues/2548
43 '--disable-features=site-per-process',
44 '--disable-hang-monitor',
45 '--disable-popup-blocking',
46 '--disable-prompt-on-repost',
47 '--disable-sync',
48 '--disable-translate',
49 '--metrics-recording-only',
50 '--no-first-run',
51 '--safebrowsing-disable-auto-update',
52 '--enable-automation',
53 '--password-store=basic',
54 '--use-mock-keychain',
55];
56
57class Launcher {
58 /**
59 * @param {!(LaunchOptions & ChromeArgOptions & BrowserOptions)=} options
60 * @return {!Promise<!Browser>}
61 */
62 static /* async */ launch(options = {}) {return (fn => {
63 const gen = fn.call(this);
64 return new Promise((resolve, reject) => {
65 function step(key, arg) {
66 let info, value;
67 try {
68 info = gen[key](arg);
69 value = info.value;
70 } catch (error) {
71 reject(error);
72 return;
73 }
74 if (info.done) {
75 resolve(value);
76 } else {
77 return Promise.resolve(value).then(
78 value => {
79 step('next', value);
80 },
81 err => {
82 step('throw', err);
83 });
84 }
85 }
86 return step('next');
87 });
88})(function*(){
89 const {
90 ignoreDefaultArgs = false,
91 args = [],
92 dumpio = false,
93 executablePath = null,
94 pipe = false,
95 env = process.env,
96 handleSIGINT = true,
97 handleSIGTERM = true,
98 handleSIGHUP = true,
99 ignoreHTTPSErrors = false,
100 defaultViewport = {width: 800, height: 600},
101 slowMo = 0,
102 timeout = 30000
103 } = options;
104
105 const chromeArguments = [];
106 if (!ignoreDefaultArgs)
107 chromeArguments.push(...this.defaultArgs(options));
108 else if (Array.isArray(ignoreDefaultArgs))
109 chromeArguments.push(...this.defaultArgs(options).filter(arg => ignoreDefaultArgs.indexOf(arg) === -1));
110 else
111 chromeArguments.push(...args);
112
113 let temporaryUserDataDir = null;
114
115 if (!chromeArguments.some(argument => argument.startsWith('--remote-debugging-')))
116 chromeArguments.push(pipe ? '--remote-debugging-pipe' : '--remote-debugging-port=0');
117 if (!chromeArguments.some(arg => arg.startsWith('--user-data-dir'))) {
118 temporaryUserDataDir = (yield mkdtempAsync(CHROME_PROFILE_PATH));
119 chromeArguments.push(`--user-data-dir=${temporaryUserDataDir}`);
120 }
121
122 let chromeExecutable = executablePath;
123 if (!executablePath) {
124 const browserFetcher = new BrowserFetcher();
125 const revisionInfo = browserFetcher.revisionInfo(ChromiumRevision);
126 assert(revisionInfo.local, `Chromium revision is not downloaded. Run "npm install" or "yarn install"`);
127 chromeExecutable = revisionInfo.executablePath;
128 }
129
130 const usePipe = chromeArguments.includes('--remote-debugging-pipe');
131 const stdio = usePipe ? ['ignore', 'ignore', 'ignore', 'pipe', 'pipe'] : ['pipe', 'pipe', 'pipe'];
132 const chromeProcess = childProcess.spawn(
133 chromeExecutable,
134 chromeArguments,
135 {
136 // On non-windows platforms, `detached: false` makes child process a leader of a new
137 // process group, making it possible to kill child process tree with `.kill(-pid)` command.
138 // @see https://nodejs.org/api/child_process.html#child_process_options_detached
139 detached: process.platform !== 'win32',
140 env,
141 stdio
142 }
143 );
144
145 if (dumpio) {
146 chromeProcess.stderr.pipe(process.stderr);
147 chromeProcess.stdout.pipe(process.stdout);
148 }
149
150 let chromeClosed = false;
151 const waitForChromeToClose = new Promise((fulfill, reject) => {
152 chromeProcess.once('exit', () => {
153 chromeClosed = true;
154 // Cleanup as processes exit.
155 if (temporaryUserDataDir) {
156 removeFolderAsync(temporaryUserDataDir)
157 .then(() => fulfill())
158 .catch(err => console.error(err));
159 } else {
160 fulfill();
161 }
162 });
163 });
164
165 const listeners = [ helper.addEventListener(process, 'exit', killChrome) ];
166 if (handleSIGINT)
167 listeners.push(helper.addEventListener(process, 'SIGINT', () => { killChrome(); process.exit(130); }));
168 if (handleSIGTERM)
169 listeners.push(helper.addEventListener(process, 'SIGTERM', gracefullyCloseChrome));
170 if (handleSIGHUP)
171 listeners.push(helper.addEventListener(process, 'SIGHUP', gracefullyCloseChrome));
172 /** @type {?Connection} */
173 let connection = null;
174 try {
175 if (!usePipe) {
176 const browserWSEndpoint = (yield waitForWSEndpoint(chromeProcess, timeout));
177 connection = (yield Connection.createForWebSocket(browserWSEndpoint, slowMo));
178 } else {
179 connection = Connection.createForPipe(/** @type {!NodeJS.WritableStream} */(chromeProcess.stdio[3]), /** @type {!NodeJS.ReadableStream} */ (chromeProcess.stdio[4]), slowMo);
180 }
181 const browser = (yield Browser.create(connection, [], ignoreHTTPSErrors, defaultViewport, chromeProcess, gracefullyCloseChrome));
182 (yield ensureInitialPage(browser));
183 return browser;
184 } catch (e) {
185 killChrome();
186 throw e;
187 }
188
189 /**
190 * @param {!Browser} browser
191 */
192 /* async */ function ensureInitialPage(browser) {return (fn => {
193 const gen = fn.call(this);
194 return new Promise((resolve, reject) => {
195 function step(key, arg) {
196 let info, value;
197 try {
198 info = gen[key](arg);
199 value = info.value;
200 } catch (error) {
201 reject(error);
202 return;
203 }
204 if (info.done) {
205 resolve(value);
206 } else {
207 return Promise.resolve(value).then(
208 value => {
209 step('next', value);
210 },
211 err => {
212 step('throw', err);
213 });
214 }
215 }
216 return step('next');
217 });
218})(function*(){
219 // Wait for initial page target to be created.
220 if (browser.targets().find(target => target.type() === 'page'))
221 return;
222
223 let initialPageCallback;
224 const initialPagePromise = new Promise(resolve => initialPageCallback = resolve);
225 const listeners = [helper.addEventListener(browser, 'targetcreated', target => {
226 if (target.type() === 'page')
227 initialPageCallback();
228 })];
229
230 (yield initialPagePromise);
231 helper.removeEventListeners(listeners);
232 });}
233
234 /**
235 * @return {Promise}
236 */
237 function gracefullyCloseChrome() {
238 helper.removeEventListeners(listeners);
239 if (temporaryUserDataDir) {
240 killChrome();
241 } else if (connection) {
242 // Attempt to close chrome gracefully
243 connection.send('Browser.close').catch(error => {
244 debugError(error);
245 killChrome();
246 });
247 }
248 return waitForChromeToClose;
249 }
250
251 // This method has to be sync to be used as 'exit' event handler.
252 function killChrome() {
253 helper.removeEventListeners(listeners);
254 if (chromeProcess.pid && !chromeProcess.killed && !chromeClosed) {
255 // Force kill chrome.
256 try {
257 if (process.platform === 'win32')
258 childProcess.execSync(`taskkill /pid ${chromeProcess.pid} /T /F`);
259 else
260 process.kill(-chromeProcess.pid, 'SIGKILL');
261 } catch (e) {
262 // the process might have already stopped
263 }
264 }
265 // Attempt to remove temporary profile directory to avoid littering.
266 try {
267 removeFolder.sync(temporaryUserDataDir);
268 } catch (e) { }
269 }
270 });}
271
272 /**
273 * @param {!ChromeArgOptions=} options
274 * @return {!Array<string>}
275 */
276 static defaultArgs(options = {}) {
277 const {
278 devtools = false,
279 headless = !devtools,
280 args = [],
281 userDataDir = null
282 } = options;
283 const chromeArguments = [...DEFAULT_ARGS];
284 if (userDataDir)
285 chromeArguments.push(`--user-data-dir=${userDataDir}`);
286 if (devtools)
287 chromeArguments.push('--auto-open-devtools-for-tabs');
288 if (headless) {
289 chromeArguments.push(
290 '--headless',
291 '--hide-scrollbars',
292 '--mute-audio'
293 );
294 if (os.platform() === 'win32')
295 chromeArguments.push('--disable-gpu');
296 }
297 if (args.every(arg => arg.startsWith('-')))
298 chromeArguments.push('about:blank');
299 chromeArguments.push(...args);
300 return chromeArguments;
301 }
302
303 /**
304 * @return {string}
305 */
306 static executablePath() {
307 const browserFetcher = new BrowserFetcher();
308 const revisionInfo = browserFetcher.revisionInfo(ChromiumRevision);
309 return revisionInfo.executablePath;
310 }
311
312 /**
313 * @param {!(BrowserOptions & {browserWSEndpoint: string})=} options
314 * @return {!Promise<!Browser>}
315 */
316 static /* async */ connect(options) {return (fn => {
317 const gen = fn.call(this);
318 return new Promise((resolve, reject) => {
319 function step(key, arg) {
320 let info, value;
321 try {
322 info = gen[key](arg);
323 value = info.value;
324 } catch (error) {
325 reject(error);
326 return;
327 }
328 if (info.done) {
329 resolve(value);
330 } else {
331 return Promise.resolve(value).then(
332 value => {
333 step('next', value);
334 },
335 err => {
336 step('throw', err);
337 });
338 }
339 }
340 return step('next');
341 });
342})(function*(){
343 const {
344 browserWSEndpoint,
345 ignoreHTTPSErrors = false,
346 defaultViewport = {width: 800, height: 600},
347 slowMo = 0,
348 } = options;
349 const connection = (yield Connection.createForWebSocket(browserWSEndpoint, slowMo));
350 const {browserContextIds} = (yield connection.send('Target.getBrowserContexts'));
351 return Browser.create(connection, browserContextIds, ignoreHTTPSErrors, defaultViewport, null, () => connection.send('Browser.close').catch(debugError));
352 });}
353}
354
355/**
356 * @param {!Puppeteer.ChildProcess} chromeProcess
357 * @param {number} timeout
358 * @return {!Promise<string>}
359 */
360function waitForWSEndpoint(chromeProcess, timeout) {
361 return new Promise((resolve, reject) => {
362 const rl = readline.createInterface({ input: chromeProcess.stderr });
363 let stderr = '';
364 const listeners = [
365 helper.addEventListener(rl, 'line', onLine),
366 helper.addEventListener(rl, 'close', () => onClose()),
367 helper.addEventListener(chromeProcess, 'exit', () => onClose()),
368 helper.addEventListener(chromeProcess, 'error', error => onClose(error))
369 ];
370 const timeoutId = timeout ? setTimeout(onTimeout, timeout) : 0;
371
372 /**
373 * @param {!Error=} error
374 */
375 function onClose(error) {
376 cleanup();
377 reject(new Error([
378 'Failed to launch chrome!' + (error ? ' ' + error.message : ''),
379 stderr,
380 '',
381 'TROUBLESHOOTING: https://github.com/GoogleChrome/puppeteer/blob/master/docs/troubleshooting.md',
382 '',
383 ].join('\n')));
384 }
385
386 function onTimeout() {
387 cleanup();
388 reject(new TimeoutError(`Timed out after ${timeout} ms while trying to connect to Chrome! The only Chrome revision guaranteed to work is r${ChromiumRevision}`));
389 }
390
391 /**
392 * @param {string} line
393 */
394 function onLine(line) {
395 stderr += line + '\n';
396 const match = line.match(/^DevTools listening on (ws:\/\/.*)$/);
397 if (!match)
398 return;
399 cleanup();
400 resolve(match[1]);
401 }
402
403 function cleanup() {
404 if (timeoutId)
405 clearTimeout(timeoutId);
406 helper.removeEventListeners(listeners);
407 }
408 });
409}
410
411/**
412 * @typedef {Object} ChromeArgOptions
413 * @property {boolean=} headless
414 * @property {Array<string>=} args
415 * @property {string=} userDataDir
416 * @property {boolean=} devtools
417 */
418
419/**
420 * @typedef {Object} LaunchOptions
421 * @property {string=} executablePath
422 * @property {boolean=} ignoreDefaultArgs
423 * @property {boolean=} handleSIGINT
424 * @property {boolean=} handleSIGTERM
425 * @property {boolean=} handleSIGHUP
426 * @property {number=} timeout
427 * @property {boolean=} dumpio
428 * @property {!Object<string, string | undefined>=} env
429 * @property {boolean=} pipe
430 */
431
432/**
433 * @typedef {Object} BrowserOptions
434 * @property {boolean=} ignoreHTTPSErrors
435 * @property {(?Puppeteer.Viewport)=} defaultViewport
436 * @property {number=} slowMo
437 */
438
439
440module.exports = Launcher;