UNPKG

13.7 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 const stdio = usePipe ? ['ignore', 'ignore', 'ignore', 'pipe', 'pipe'] : ['pipe', 'pipe', 'pipe'];
118 const chromeProcess = childProcess.spawn(
119 chromeExecutable,
120 chromeArguments,
121 {
122 // On non-windows platforms, `detached: false` makes child process a leader of a new
123 // process group, making it possible to kill child process tree with `.kill(-pid)` command.
124 // @see https://nodejs.org/api/child_process.html#child_process_options_detached
125 detached: process.platform !== 'win32',
126 env,
127 stdio
128 }
129 );
130
131 if (dumpio) {
132 chromeProcess.stderr.pipe(process.stderr);
133 chromeProcess.stdout.pipe(process.stdout);
134 }
135
136 let chromeClosed = false;
137 const waitForChromeToClose = new Promise((fulfill, reject) => {
138 chromeProcess.once('exit', () => {
139 chromeClosed = true;
140 // Cleanup as processes exit.
141 if (temporaryUserDataDir) {
142 removeFolderAsync(temporaryUserDataDir)
143 .then(() => fulfill())
144 .catch(err => console.error(err));
145 } else {
146 fulfill();
147 }
148 });
149 });
150
151 const listeners = [ helper.addEventListener(process, 'exit', killChrome) ];
152 if (handleSIGINT)
153 listeners.push(helper.addEventListener(process, 'SIGINT', () => { killChrome(); process.exit(130); }));
154 if (handleSIGTERM)
155 listeners.push(helper.addEventListener(process, 'SIGTERM', gracefullyCloseChrome));
156 if (handleSIGHUP)
157 listeners.push(helper.addEventListener(process, 'SIGHUP', gracefullyCloseChrome));
158 /** @type {?Connection} */
159 let connection = null;
160 try {
161 if (!usePipe) {
162 const browserWSEndpoint = await waitForWSEndpoint(chromeProcess, timeout, this._preferredRevision);
163 const transport = await WebSocketTransport.create(browserWSEndpoint);
164 connection = new Connection(browserWSEndpoint, transport, slowMo);
165 } else {
166 const transport = new PipeTransport(/** @type {!NodeJS.WritableStream} */(chromeProcess.stdio[3]), /** @type {!NodeJS.ReadableStream} */ (chromeProcess.stdio[4]));
167 connection = new Connection('', transport, slowMo);
168 }
169 const browser = await Browser.create(connection, [], ignoreHTTPSErrors, defaultViewport, 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 * @param {!ChromeArgOptions=} options
236 * @return {!Array<string>}
237 */
238 defaultArgs(options = {}) {
239 const {
240 devtools = false,
241 headless = !devtools,
242 args = [],
243 userDataDir = null
244 } = options;
245 const chromeArguments = [...DEFAULT_ARGS];
246 if (userDataDir)
247 chromeArguments.push(`--user-data-dir=${userDataDir}`);
248 if (devtools)
249 chromeArguments.push('--auto-open-devtools-for-tabs');
250 if (headless) {
251 chromeArguments.push(
252 '--headless',
253 '--hide-scrollbars',
254 '--mute-audio'
255 );
256 if (os.platform() === 'win32')
257 chromeArguments.push('--disable-gpu');
258 }
259 if (args.every(arg => arg.startsWith('-')))
260 chromeArguments.push('about:blank');
261 chromeArguments.push(...args);
262 return chromeArguments;
263 }
264
265 /**
266 * @return {string}
267 */
268 executablePath() {
269 return this._resolveExecutablePath().executablePath;
270 }
271
272 /**
273 * @param {!(BrowserOptions & {browserWSEndpoint: string, transport?: !Puppeteer.ConnectionTransport})} options
274 * @return {!Promise<!Browser>}
275 */
276 async connect(options) {
277 const {
278 browserWSEndpoint,
279 ignoreHTTPSErrors = false,
280 defaultViewport = {width: 800, height: 600},
281 transport = await WebSocketTransport.create(browserWSEndpoint),
282 slowMo = 0,
283 } = options;
284 const connection = new Connection(browserWSEndpoint, transport, slowMo);
285 const {browserContextIds} = await connection.send('Target.getBrowserContexts');
286 return Browser.create(connection, browserContextIds, ignoreHTTPSErrors, defaultViewport, null, () => connection.send('Browser.close').catch(debugError));
287 }
288
289 /**
290 * @return {{executablePath: string, missingText: ?string}}
291 */
292 _resolveExecutablePath() {
293 const browserFetcher = new BrowserFetcher(this._projectRoot);
294 // puppeteer-core doesn't take into account PUPPETEER_* env variables.
295 if (!this._isPuppeteerCore) {
296 const executablePath = process.env['PUPPETEER_EXECUTABLE_PATH'];
297 if (executablePath) {
298 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;
299 return { executablePath, missingText };
300 }
301 const revision = process.env['PUPPETEER_CHROMIUM_REVISION'];
302 if (revision) {
303 const revisionInfo = browserFetcher.revisionInfo(revision);
304 const missingText = !revisionInfo.local ? 'Tried to use PUPPETEER_CHROMIUM_REVISION env variable to launch browser but did not find executable at: ' + revisionInfo.executablePath : null;
305 return {executablePath: revisionInfo.executablePath, missingText};
306 }
307 }
308 const revisionInfo = browserFetcher.revisionInfo(this._preferredRevision);
309 const missingText = !revisionInfo.local ? `Chromium revision is not downloaded. Run "npm install" or "yarn install"` : null;
310 return {executablePath: revisionInfo.executablePath, missingText};
311 }
312
313}
314
315/**
316 * @param {!Puppeteer.ChildProcess} chromeProcess
317 * @param {number} timeout
318 * @param {string} preferredRevision
319 * @return {!Promise<string>}
320 */
321function waitForWSEndpoint(chromeProcess, timeout, preferredRevision) {
322 return new Promise((resolve, reject) => {
323 const rl = readline.createInterface({ input: chromeProcess.stderr });
324 let stderr = '';
325 const listeners = [
326 helper.addEventListener(rl, 'line', onLine),
327 helper.addEventListener(rl, 'close', () => onClose()),
328 helper.addEventListener(chromeProcess, 'exit', () => onClose()),
329 helper.addEventListener(chromeProcess, 'error', error => onClose(error))
330 ];
331 const timeoutId = timeout ? setTimeout(onTimeout, timeout) : 0;
332
333 /**
334 * @param {!Error=} error
335 */
336 function onClose(error) {
337 cleanup();
338 reject(new Error([
339 'Failed to launch chrome!' + (error ? ' ' + error.message : ''),
340 stderr,
341 '',
342 'TROUBLESHOOTING: https://github.com/GoogleChrome/puppeteer/blob/master/docs/troubleshooting.md',
343 '',
344 ].join('\n')));
345 }
346
347 function onTimeout() {
348 cleanup();
349 reject(new TimeoutError(`Timed out after ${timeout} ms while trying to connect to Chrome! The only Chrome revision guaranteed to work is r${preferredRevision}`));
350 }
351
352 /**
353 * @param {string} line
354 */
355 function onLine(line) {
356 stderr += line + '\n';
357 const match = line.match(/^DevTools listening on (ws:\/\/.*)$/);
358 if (!match)
359 return;
360 cleanup();
361 resolve(match[1]);
362 }
363
364 function cleanup() {
365 if (timeoutId)
366 clearTimeout(timeoutId);
367 helper.removeEventListeners(listeners);
368 }
369 });
370}
371
372/**
373 * @typedef {Object} ChromeArgOptions
374 * @property {boolean=} headless
375 * @property {Array<string>=} args
376 * @property {string=} userDataDir
377 * @property {boolean=} devtools
378 */
379
380/**
381 * @typedef {Object} LaunchOptions
382 * @property {string=} executablePath
383 * @property {boolean=} ignoreDefaultArgs
384 * @property {boolean=} handleSIGINT
385 * @property {boolean=} handleSIGTERM
386 * @property {boolean=} handleSIGHUP
387 * @property {number=} timeout
388 * @property {boolean=} dumpio
389 * @property {!Object<string, string | undefined>=} env
390 * @property {boolean=} pipe
391 */
392
393/**
394 * @typedef {Object} BrowserOptions
395 * @property {boolean=} ignoreHTTPSErrors
396 * @property {(?Puppeteer.Viewport)=} defaultViewport
397 * @property {number=} slowMo
398 */
399
400
401module.exports = Launcher;