UNPKG

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