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