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 */
16import * as os from 'os';
17import * as path from 'path';
18import * as fs from 'fs';
19
20import { BrowserFetcher } from './BrowserFetcher.js';
21import { Browser } from '../common/Browser.js';
22import { BrowserRunner } from './BrowserRunner.js';
23import { promisify } from 'util';
24
25const mkdtempAsync = promisify(fs.mkdtemp);
26const writeFileAsync = promisify(fs.writeFile);
27
28import {
29  BrowserLaunchArgumentOptions,
30  PuppeteerNodeLaunchOptions,
31} from './LaunchOptions.js';
32import { Product } from '../common/Product.js';
33
34/**
35 * Describes a launcher - a class that is able to create and launch a browser instance.
36 * @public
37 */
38export interface ProductLauncher {
39  launch(object: PuppeteerNodeLaunchOptions);
40  executablePath: () => string;
41  defaultArgs(object: BrowserLaunchArgumentOptions);
42  product: Product;
43}
44
45/**
46 * @internal
47 */
48class ChromeLauncher implements ProductLauncher {
49  _projectRoot: string;
50  _preferredRevision: string;
51  _isPuppeteerCore: boolean;
52
53  constructor(
54    projectRoot: string,
55    preferredRevision: string,
56    isPuppeteerCore: boolean
57  ) {
58    this._projectRoot = projectRoot;
59    this._preferredRevision = preferredRevision;
60    this._isPuppeteerCore = isPuppeteerCore;
61  }
62
63  async launch(options: PuppeteerNodeLaunchOptions = {}): Promise<Browser> {
64    const {
65      ignoreDefaultArgs = false,
66      args = [],
67      dumpio = false,
68      executablePath = null,
69      pipe = false,
70      env = process.env,
71      handleSIGINT = true,
72      handleSIGTERM = true,
73      handleSIGHUP = true,
74      ignoreHTTPSErrors = false,
75      defaultViewport = { width: 800, height: 600 },
76      slowMo = 0,
77      timeout = 30000,
78      waitForInitialPage = true,
79    } = options;
80
81    const profilePath = path.join(os.tmpdir(), 'puppeteer_dev_chrome_profile-');
82    const chromeArguments = [];
83    if (!ignoreDefaultArgs) chromeArguments.push(...this.defaultArgs(options));
84    else if (Array.isArray(ignoreDefaultArgs))
85      chromeArguments.push(
86        ...this.defaultArgs(options).filter(
87          (arg) => !ignoreDefaultArgs.includes(arg)
88        )
89      );
90    else chromeArguments.push(...args);
91
92    let temporaryUserDataDir = null;
93
94    if (
95      !chromeArguments.some((argument) =>
96        argument.startsWith('--remote-debugging-')
97      )
98    )
99      chromeArguments.push(
100        pipe ? '--remote-debugging-pipe' : '--remote-debugging-port=0'
101      );
102    if (!chromeArguments.some((arg) => arg.startsWith('--user-data-dir'))) {
103      temporaryUserDataDir = await mkdtempAsync(profilePath);
104      chromeArguments.push(`--user-data-dir=${temporaryUserDataDir}`);
105    }
106
107    let chromeExecutable = executablePath;
108    if (!executablePath) {
109      // Use Intel x86 builds on Apple M1 until native macOS arm64
110      // Chromium builds are available.
111      if (os.platform() !== 'darwin' && os.arch() === 'arm64') {
112        chromeExecutable = '/usr/bin/chromium-browser';
113      } else {
114        const { missingText, executablePath } = resolveExecutablePath(this);
115        if (missingText) throw new Error(missingText);
116        chromeExecutable = executablePath;
117      }
118    }
119
120    const usePipe = chromeArguments.includes('--remote-debugging-pipe');
121    const runner = new BrowserRunner(
122      this.product,
123      chromeExecutable,
124      chromeArguments,
125      temporaryUserDataDir
126    );
127    runner.start({
128      handleSIGHUP,
129      handleSIGTERM,
130      handleSIGINT,
131      dumpio,
132      env,
133      pipe: usePipe,
134    });
135
136    try {
137      const connection = await runner.setupConnection({
138        usePipe,
139        timeout,
140        slowMo,
141        preferredRevision: this._preferredRevision,
142      });
143      const browser = await Browser.create(
144        connection,
145        [],
146        ignoreHTTPSErrors,
147        defaultViewport,
148        runner.proc,
149        runner.close.bind(runner)
150      );
151      if (waitForInitialPage)
152        await browser.waitForTarget((t) => t.type() === 'page');
153      return browser;
154    } catch (error) {
155      runner.kill();
156      throw error;
157    }
158  }
159
160  defaultArgs(options: BrowserLaunchArgumentOptions = {}): string[] {
161    const chromeArguments = [
162      '--disable-background-networking',
163      '--enable-features=NetworkService,NetworkServiceInProcess',
164      '--disable-background-timer-throttling',
165      '--disable-backgrounding-occluded-windows',
166      '--disable-breakpad',
167      '--disable-client-side-phishing-detection',
168      '--disable-component-extensions-with-background-pages',
169      '--disable-default-apps',
170      '--disable-dev-shm-usage',
171      '--disable-extensions',
172      '--disable-features=Translate',
173      '--disable-hang-monitor',
174      '--disable-ipc-flooding-protection',
175      '--disable-popup-blocking',
176      '--disable-prompt-on-repost',
177      '--disable-renderer-backgrounding',
178      '--disable-sync',
179      '--force-color-profile=srgb',
180      '--metrics-recording-only',
181      '--no-first-run',
182      '--enable-automation',
183      '--password-store=basic',
184      '--use-mock-keychain',
185      // TODO(sadym): remove '--enable-blink-features=IdleDetection'
186      // once IdleDetection is turned on by default.
187      '--enable-blink-features=IdleDetection',
188    ];
189    const {
190      devtools = false,
191      headless = !devtools,
192      args = [],
193      userDataDir = null,
194    } = options;
195    if (userDataDir)
196      chromeArguments.push(`--user-data-dir=${path.resolve(userDataDir)}`);
197    if (devtools) chromeArguments.push('--auto-open-devtools-for-tabs');
198    if (headless) {
199      chromeArguments.push('--headless', '--hide-scrollbars', '--mute-audio');
200    }
201    if (args.every((arg) => arg.startsWith('-')))
202      chromeArguments.push('about:blank');
203    chromeArguments.push(...args);
204    return chromeArguments;
205  }
206
207  executablePath(): string {
208    return resolveExecutablePath(this).executablePath;
209  }
210
211  get product(): Product {
212    return 'chrome';
213  }
214}
215
216/**
217 * @internal
218 */
219class FirefoxLauncher implements ProductLauncher {
220  _projectRoot: string;
221  _preferredRevision: string;
222  _isPuppeteerCore: boolean;
223
224  constructor(
225    projectRoot: string,
226    preferredRevision: string,
227    isPuppeteerCore: boolean
228  ) {
229    this._projectRoot = projectRoot;
230    this._preferredRevision = preferredRevision;
231    this._isPuppeteerCore = isPuppeteerCore;
232  }
233
234  async launch(options: PuppeteerNodeLaunchOptions = {}): Promise<Browser> {
235    const {
236      ignoreDefaultArgs = false,
237      args = [],
238      dumpio = false,
239      executablePath = null,
240      pipe = false,
241      env = process.env,
242      handleSIGINT = true,
243      handleSIGTERM = true,
244      handleSIGHUP = true,
245      ignoreHTTPSErrors = false,
246      defaultViewport = { width: 800, height: 600 },
247      slowMo = 0,
248      timeout = 30000,
249      extraPrefsFirefox = {},
250      waitForInitialPage = true,
251    } = options;
252
253    const firefoxArguments = [];
254    if (!ignoreDefaultArgs) firefoxArguments.push(...this.defaultArgs(options));
255    else if (Array.isArray(ignoreDefaultArgs))
256      firefoxArguments.push(
257        ...this.defaultArgs(options).filter(
258          (arg) => !ignoreDefaultArgs.includes(arg)
259        )
260      );
261    else firefoxArguments.push(...args);
262
263    if (
264      !firefoxArguments.some((argument) =>
265        argument.startsWith('--remote-debugging-')
266      )
267    )
268      firefoxArguments.push('--remote-debugging-port=0');
269
270    let temporaryUserDataDir = null;
271
272    if (
273      !firefoxArguments.includes('-profile') &&
274      !firefoxArguments.includes('--profile')
275    ) {
276      temporaryUserDataDir = await this._createProfile(extraPrefsFirefox);
277      firefoxArguments.push('--profile');
278      firefoxArguments.push(temporaryUserDataDir);
279    }
280
281    await this._updateRevision();
282    let firefoxExecutable = executablePath;
283    if (!executablePath) {
284      const { missingText, executablePath } = resolveExecutablePath(this);
285      if (missingText) throw new Error(missingText);
286      firefoxExecutable = executablePath;
287    }
288
289    const runner = new BrowserRunner(
290      this.product,
291      firefoxExecutable,
292      firefoxArguments,
293      temporaryUserDataDir
294    );
295    runner.start({
296      handleSIGHUP,
297      handleSIGTERM,
298      handleSIGINT,
299      dumpio,
300      env,
301      pipe,
302    });
303
304    try {
305      const connection = await runner.setupConnection({
306        usePipe: pipe,
307        timeout,
308        slowMo,
309        preferredRevision: this._preferredRevision,
310      });
311      const browser = await Browser.create(
312        connection,
313        [],
314        ignoreHTTPSErrors,
315        defaultViewport,
316        runner.proc,
317        runner.close.bind(runner)
318      );
319      if (waitForInitialPage)
320        await browser.waitForTarget((t) => t.type() === 'page');
321      return browser;
322    } catch (error) {
323      runner.kill();
324      throw error;
325    }
326  }
327
328  executablePath(): string {
329    return resolveExecutablePath(this).executablePath;
330  }
331
332  async _updateRevision(): Promise<void> {
333    // replace 'latest' placeholder with actual downloaded revision
334    if (this._preferredRevision === 'latest') {
335      const browserFetcher = new BrowserFetcher(this._projectRoot, {
336        product: this.product,
337      });
338      const localRevisions = await browserFetcher.localRevisions();
339      if (localRevisions[0]) this._preferredRevision = localRevisions[0];
340    }
341  }
342
343  get product(): Product {
344    return 'firefox';
345  }
346
347  defaultArgs(options: BrowserLaunchArgumentOptions = {}): string[] {
348    const firefoxArguments = ['--no-remote', '--foreground'];
349    if (os.platform().startsWith('win')) {
350      firefoxArguments.push('--wait-for-browser');
351    }
352    const {
353      devtools = false,
354      headless = !devtools,
355      args = [],
356      userDataDir = null,
357    } = options;
358    if (userDataDir) {
359      firefoxArguments.push('--profile');
360      firefoxArguments.push(userDataDir);
361    }
362    if (headless) firefoxArguments.push('--headless');
363    if (devtools) firefoxArguments.push('--devtools');
364    if (args.every((arg) => arg.startsWith('-')))
365      firefoxArguments.push('about:blank');
366    firefoxArguments.push(...args);
367    return firefoxArguments;
368  }
369
370  async _createProfile(extraPrefs: { [x: string]: unknown }): Promise<string> {
371    const profilePath = await mkdtempAsync(
372      path.join(os.tmpdir(), 'puppeteer_dev_firefox_profile-')
373    );
374    const prefsJS = [];
375    const userJS = [];
376    const server = 'dummy.test';
377    const defaultPreferences = {
378      // Make sure Shield doesn't hit the network.
379      'app.normandy.api_url': '',
380      // Disable Firefox old build background check
381      'app.update.checkInstallTime': false,
382      // Disable automatically upgrading Firefox
383      'app.update.disabledForTesting': true,
384
385      // Increase the APZ content response timeout to 1 minute
386      'apz.content_response_timeout': 60000,
387
388      // Prevent various error message on the console
389      // jest-puppeteer asserts that no error message is emitted by the console
390      'browser.contentblocking.features.standard':
391        '-tp,tpPrivate,cookieBehavior0,-cm,-fp',
392
393      // Enable the dump function: which sends messages to the system
394      // console
395      // https://bugzilla.mozilla.org/show_bug.cgi?id=1543115
396      'browser.dom.window.dump.enabled': true,
397      // Disable topstories
398      'browser.newtabpage.activity-stream.feeds.system.topstories': false,
399      // Always display a blank page
400      'browser.newtabpage.enabled': false,
401      // Background thumbnails in particular cause grief: and disabling
402      // thumbnails in general cannot hurt
403      'browser.pagethumbnails.capturing_disabled': true,
404
405      // Disable safebrowsing components.
406      'browser.safebrowsing.blockedURIs.enabled': false,
407      'browser.safebrowsing.downloads.enabled': false,
408      'browser.safebrowsing.malware.enabled': false,
409      'browser.safebrowsing.passwords.enabled': false,
410      'browser.safebrowsing.phishing.enabled': false,
411
412      // Disable updates to search engines.
413      'browser.search.update': false,
414      // Do not restore the last open set of tabs if the browser has crashed
415      'browser.sessionstore.resume_from_crash': false,
416      // Skip check for default browser on startup
417      'browser.shell.checkDefaultBrowser': false,
418
419      // Disable newtabpage
420      'browser.startup.homepage': 'about:blank',
421      // Do not redirect user when a milstone upgrade of Firefox is detected
422      'browser.startup.homepage_override.mstone': 'ignore',
423      // Start with a blank page about:blank
424      'browser.startup.page': 0,
425
426      // Do not allow background tabs to be zombified on Android: otherwise for
427      // tests that open additional tabs: the test harness tab itself might get
428      // unloaded
429      'browser.tabs.disableBackgroundZombification': false,
430      // Do not warn when closing all other open tabs
431      'browser.tabs.warnOnCloseOtherTabs': false,
432      // Do not warn when multiple tabs will be opened
433      'browser.tabs.warnOnOpen': false,
434
435      // Disable the UI tour.
436      'browser.uitour.enabled': false,
437      // Turn off search suggestions in the location bar so as not to trigger
438      // network connections.
439      'browser.urlbar.suggest.searches': false,
440      // Disable first run splash page on Windows 10
441      'browser.usedOnWindows10.introURL': '',
442      // Do not warn on quitting Firefox
443      'browser.warnOnQuit': false,
444
445      // Defensively disable data reporting systems
446      'datareporting.healthreport.documentServerURI': `http://${server}/dummy/healthreport/`,
447      'datareporting.healthreport.logging.consoleEnabled': false,
448      'datareporting.healthreport.service.enabled': false,
449      'datareporting.healthreport.service.firstRun': false,
450      'datareporting.healthreport.uploadEnabled': false,
451
452      // Do not show datareporting policy notifications which can interfere with tests
453      'datareporting.policy.dataSubmissionEnabled': false,
454      'datareporting.policy.dataSubmissionPolicyBypassNotification': true,
455
456      // DevTools JSONViewer sometimes fails to load dependencies with its require.js.
457      // This doesn't affect Puppeteer but spams console (Bug 1424372)
458      'devtools.jsonview.enabled': false,
459
460      // Disable popup-blocker
461      'dom.disable_open_during_load': false,
462
463      // Enable the support for File object creation in the content process
464      // Required for |Page.setFileInputFiles| protocol method.
465      'dom.file.createInChild': true,
466
467      // Disable the ProcessHangMonitor
468      'dom.ipc.reportProcessHangs': false,
469
470      // Disable slow script dialogues
471      'dom.max_chrome_script_run_time': 0,
472      'dom.max_script_run_time': 0,
473
474      // Only load extensions from the application and user profile
475      // AddonManager.SCOPE_PROFILE + AddonManager.SCOPE_APPLICATION
476      'extensions.autoDisableScopes': 0,
477      'extensions.enabledScopes': 5,
478
479      // Disable metadata caching for installed add-ons by default
480      'extensions.getAddons.cache.enabled': false,
481
482      // Disable installing any distribution extensions or add-ons.
483      'extensions.installDistroAddons': false,
484
485      // Disabled screenshots extension
486      'extensions.screenshots.disabled': true,
487
488      // Turn off extension updates so they do not bother tests
489      'extensions.update.enabled': false,
490
491      // Turn off extension updates so they do not bother tests
492      'extensions.update.notifyUser': false,
493
494      // Make sure opening about:addons will not hit the network
495      'extensions.webservice.discoverURL': `http://${server}/dummy/discoveryURL`,
496
497      // Force disable Fission until the Remote Agent is compatible
498      'fission.autostart': false,
499
500      // Allow the application to have focus even it runs in the background
501      'focusmanager.testmode': true,
502      // Disable useragent updates
503      'general.useragent.updates.enabled': false,
504      // Always use network provider for geolocation tests so we bypass the
505      // macOS dialog raised by the corelocation provider
506      'geo.provider.testing': true,
507      // Do not scan Wifi
508      'geo.wifi.scan': false,
509      // No hang monitor
510      'hangmonitor.timeout': 0,
511      // Show chrome errors and warnings in the error console
512      'javascript.options.showInConsole': true,
513
514      // Disable download and usage of OpenH264: and Widevine plugins
515      'media.gmp-manager.updateEnabled': false,
516      // Prevent various error message on the console
517      // jest-puppeteer asserts that no error message is emitted by the console
518      'network.cookie.cookieBehavior': 0,
519
520      // Disable experimental feature that is only available in Nightly
521      'network.cookie.sameSite.laxByDefault': false,
522
523      // Do not prompt for temporary redirects
524      'network.http.prompt-temp-redirect': false,
525
526      // Disable speculative connections so they are not reported as leaking
527      // when they are hanging around
528      'network.http.speculative-parallel-limit': 0,
529
530      // Do not automatically switch between offline and online
531      'network.manage-offline-status': false,
532
533      // Make sure SNTP requests do not hit the network
534      'network.sntp.pools': server,
535
536      // Disable Flash.
537      'plugin.state.flash': 0,
538
539      'privacy.trackingprotection.enabled': false,
540
541      // Enable Remote Agent
542      // https://bugzilla.mozilla.org/show_bug.cgi?id=1544393
543      'remote.enabled': true,
544
545      // Don't do network connections for mitm priming
546      'security.certerrors.mitm.priming.enabled': false,
547      // Local documents have access to all other local documents,
548      // including directory listings
549      'security.fileuri.strict_origin_policy': false,
550      // Do not wait for the notification button security delay
551      'security.notification_enable_delay': 0,
552
553      // Ensure blocklist updates do not hit the network
554      'services.settings.server': `http://${server}/dummy/blocklist/`,
555
556      // Do not automatically fill sign-in forms with known usernames and
557      // passwords
558      'signon.autofillForms': false,
559      // Disable password capture, so that tests that include forms are not
560      // influenced by the presence of the persistent doorhanger notification
561      'signon.rememberSignons': false,
562
563      // Disable first-run welcome page
564      'startup.homepage_welcome_url': 'about:blank',
565
566      // Disable first-run welcome page
567      'startup.homepage_welcome_url.additional': '',
568
569      // Disable browser animations (tabs, fullscreen, sliding alerts)
570      'toolkit.cosmeticAnimations.enabled': false,
571
572      // Prevent starting into safe mode after application crashes
573      'toolkit.startup.max_resumed_crashes': -1,
574    };
575
576    Object.assign(defaultPreferences, extraPrefs);
577    for (const [key, value] of Object.entries(defaultPreferences))
578      userJS.push(
579        `user_pref(${JSON.stringify(key)}, ${JSON.stringify(value)});`
580      );
581    await writeFileAsync(path.join(profilePath, 'user.js'), userJS.join('\n'));
582    await writeFileAsync(
583      path.join(profilePath, 'prefs.js'),
584      prefsJS.join('\n')
585    );
586    return profilePath;
587  }
588}
589
590function resolveExecutablePath(launcher: ChromeLauncher | FirefoxLauncher): {
591  executablePath: string;
592  missingText?: string;
593} {
594  let downloadPath: string;
595  // puppeteer-core doesn't take into account PUPPETEER_* env variables.
596  if (!launcher._isPuppeteerCore) {
597    const executablePath =
598      process.env.PUPPETEER_EXECUTABLE_PATH ||
599      process.env.npm_config_puppeteer_executable_path ||
600      process.env.npm_package_config_puppeteer_executable_path;
601    if (executablePath) {
602      const missingText = !fs.existsSync(executablePath)
603        ? 'Tried to use PUPPETEER_EXECUTABLE_PATH env variable to launch browser but did not find any executable at: ' +
604          executablePath
605        : null;
606      return { executablePath, missingText };
607    }
608    downloadPath =
609      process.env.PUPPETEER_DOWNLOAD_PATH ||
610      process.env.npm_config_puppeteer_download_path ||
611      process.env.npm_package_config_puppeteer_download_path;
612  }
613  const browserFetcher = new BrowserFetcher(launcher._projectRoot, {
614    product: launcher.product,
615    path: downloadPath,
616  });
617
618  if (!launcher._isPuppeteerCore && launcher.product === 'chrome') {
619    const revision = process.env['PUPPETEER_CHROMIUM_REVISION'];
620    if (revision) {
621      const revisionInfo = browserFetcher.revisionInfo(revision);
622      const missingText = !revisionInfo.local
623        ? 'Tried to use PUPPETEER_CHROMIUM_REVISION env variable to launch browser but did not find executable at: ' +
624          revisionInfo.executablePath
625        : null;
626      return { executablePath: revisionInfo.executablePath, missingText };
627    }
628  }
629  const revisionInfo = browserFetcher.revisionInfo(launcher._preferredRevision);
630
631  const firefoxHelp = `Run \`PUPPETEER_PRODUCT=firefox npm install\` to download a supported Firefox browser binary.`;
632  const chromeHelp = `Run \`npm install\` to download the correct Chromium revision (${launcher._preferredRevision}).`;
633  const missingText = !revisionInfo.local
634    ? `Could not find expected browser (${launcher.product}) locally. ${
635        launcher.product === 'chrome' ? chromeHelp : firefoxHelp
636      }`
637    : null;
638  return { executablePath: revisionInfo.executablePath, missingText };
639}
640
641/**
642 * @internal
643 */
644export default function Launcher(
645  projectRoot: string,
646  preferredRevision: string,
647  isPuppeteerCore: boolean,
648  product?: string
649): ProductLauncher {
650  // puppeteer-core doesn't take into account PUPPETEER_* env variables.
651  if (!product && !isPuppeteerCore)
652    product =
653      process.env.PUPPETEER_PRODUCT ||
654      process.env.npm_config_puppeteer_product ||
655      process.env.npm_package_config_puppeteer_product;
656  switch (product) {
657    case 'firefox':
658      return new FirefoxLauncher(
659        projectRoot,
660        preferredRevision,
661        isPuppeteerCore
662      );
663    case 'chrome':
664    default:
665      if (typeof product !== 'undefined' && product !== 'chrome') {
666        /* The user gave us an incorrect product name
667         * we'll default to launching Chrome, but log to the console
668         * to let the user know (they've probably typoed).
669         */
670        console.warn(
671          `Warning: unknown product name ${product}. Falling back to chrome.`
672        );
673      }
674      return new ChromeLauncher(
675        projectRoot,
676        preferredRevision,
677        isPuppeteerCore
678      );
679  }
680}
681