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