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