From c8f6adf9f32b6062a6eeda4475beaaa68589b5d5 Mon Sep 17 00:00:00 2001 From: Alex Rudenko Date: Tue, 4 Apr 2023 15:29:21 +0200 Subject: [PATCH] refactor: use browsers for launchers (#9937) --- docs/api/puppeteer.productlauncher.launch.md | 8 +- docs/api/puppeteer.productlauncher.md | 2 +- package-lock.json | 2 + packages/browsers/src/launcher.ts | 5 +- packages/puppeteer-core/package.json | 6 +- .../puppeteer-core/src/common/Connection.ts | 7 + .../puppeteer-core/src/node/BrowserRunner.ts | 392 ----------------- .../puppeteer-core/src/node/ChromeLauncher.ts | 234 +++------- .../src/node/FirefoxLauncher.ts | 407 +++--------------- .../src/node/ProductLauncher.ts | 282 +++++++++++- packages/puppeteer-core/src/node/node.ts | 1 - test/installation/src/describeInstallation.ts | 4 +- test/installation/src/puppeteer-core.spec.ts | 2 +- test/src/launcher.spec.ts | 11 +- 14 files changed, 437 insertions(+), 926 deletions(-) delete mode 100644 packages/puppeteer-core/src/node/BrowserRunner.ts diff --git a/docs/api/puppeteer.productlauncher.launch.md b/docs/api/puppeteer.productlauncher.launch.md index 1f0a9d93..4d1b7d2c 100644 --- a/docs/api/puppeteer.productlauncher.launch.md +++ b/docs/api/puppeteer.productlauncher.launch.md @@ -8,15 +8,15 @@ sidebar_label: ProductLauncher.launch ```typescript class ProductLauncher { - launch(object: PuppeteerNodeLaunchOptions): Promise; + launch(options?: PuppeteerNodeLaunchOptions): Promise; } ``` ## Parameters -| Parameter | Type | Description | -| --------- | ----------------------------------------------------------------------- | ----------- | -| object | [PuppeteerNodeLaunchOptions](./puppeteer.puppeteernodelaunchoptions.md) | | +| Parameter | Type | Description | +| --------- | ----------------------------------------------------------------------- | ------------ | +| options | [PuppeteerNodeLaunchOptions](./puppeteer.puppeteernodelaunchoptions.md) | _(Optional)_ | **Returns:** diff --git a/docs/api/puppeteer.productlauncher.md b/docs/api/puppeteer.productlauncher.md index 23dbf6f7..f4eed461 100644 --- a/docs/api/puppeteer.productlauncher.md +++ b/docs/api/puppeteer.productlauncher.md @@ -28,4 +28,4 @@ The constructor for this class is marked as internal. Third-party code should no | ------------------------------------------------------------------------ | --------- | ----------- | | [defaultArgs(object)](./puppeteer.productlauncher.defaultargs.md) | | | | [executablePath(channel)](./puppeteer.productlauncher.executablepath.md) | | | -| [launch(object)](./puppeteer.productlauncher.launch.md) | | | +| [launch(options)](./puppeteer.productlauncher.launch.md) | | | diff --git a/package-lock.json b/package-lock.json index 31a66459..a72565bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9482,6 +9482,7 @@ "version": "19.8.3", "license": "Apache-2.0", "dependencies": { + "@puppeteer/browsers": "0.3.2", "chromium-bidi": "0.4.6", "cross-fetch": "3.1.5", "debug": "4.3.4", @@ -14464,6 +14465,7 @@ "puppeteer-core": { "version": "file:packages/puppeteer-core", "requires": { + "@puppeteer/browsers": "0.3.2", "chromium-bidi": "0.4.6", "cross-fetch": "3.1.5", "debug": "4.3.4", diff --git a/packages/browsers/src/launcher.ts b/packages/browsers/src/launcher.ts index 888a95c4..69722ff2 100644 --- a/packages/browsers/src/launcher.ts +++ b/packages/browsers/src/launcher.ts @@ -270,10 +270,9 @@ class Process { async close(): Promise { await this.#runHooks(); - if (this.#exited) { - return this.#browserProcessExiting; + if (!this.#exited) { + this.kill(); } - this.kill(); return this.#browserProcessExiting; } diff --git a/packages/puppeteer-core/package.json b/packages/puppeteer-core/package.json index 1310aebc..4f519183 100644 --- a/packages/puppeteer-core/package.json +++ b/packages/puppeteer-core/package.json @@ -97,7 +97,8 @@ "clean": "if-file-deleted", "dependencies": [ "generate:package-json", - "generate:sources" + "generate:sources", + "../browsers:build" ], "files": [ "{compat,src,third_party}/**", @@ -140,7 +141,8 @@ "proxy-from-env": "1.1.0", "tar-fs": "2.1.1", "unbzip2-stream": "1.4.3", - "ws": "8.13.0" + "ws": "8.13.0", + "@puppeteer/browsers": "0.3.2" }, "peerDependencies": { "typescript": ">= 4.7.4" diff --git a/packages/puppeteer-core/src/common/Connection.ts b/packages/puppeteer-core/src/common/Connection.ts index f19bfb83..8b758483 100644 --- a/packages/puppeteer-core/src/common/Connection.ts +++ b/packages/puppeteer-core/src/common/Connection.ts @@ -275,6 +275,13 @@ export class Connection extends EventEmitter { }) as Promise; } + /** + * @internal + */ + async closeBrowser(): Promise { + await this.send('Browser.close'); + } + /** * @internal */ diff --git a/packages/puppeteer-core/src/node/BrowserRunner.ts b/packages/puppeteer-core/src/node/BrowserRunner.ts deleted file mode 100644 index cb8e2474..00000000 --- a/packages/puppeteer-core/src/node/BrowserRunner.ts +++ /dev/null @@ -1,392 +0,0 @@ -/** - * Copyright 2020 Google Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import childProcess from 'child_process'; -import fs from 'fs'; -import {rename, unlink} from 'fs/promises'; -import path from 'path'; -import readline from 'readline'; - -import type {Connection as BiDiConnection} from '../common/bidi/bidi.js'; -import {Connection} from '../common/Connection.js'; -import {debug} from '../common/Debug.js'; -import {TimeoutError} from '../common/Errors.js'; -import {NodeWebSocketTransport as WebSocketTransport} from '../common/NodeWebSocketTransport.js'; -import {Product} from '../common/Product.js'; -import { - addEventListener, - debugError, - PuppeteerEventListener, - removeEventListeners, -} from '../common/util.js'; -import {assert} from '../util/assert.js'; -import {isErrnoException, isErrorLike} from '../util/ErrorLike.js'; -import {rm, rmSync} from '../util/fs.js'; - -import {LaunchOptions} from './LaunchOptions.js'; -import {PipeTransport} from './PipeTransport.js'; - -const debugLauncher = debug('puppeteer:launcher'); - -const PROCESS_ERROR_EXPLANATION = `Puppeteer was unable to kill the process which ran the browser binary. -This means that, on future Puppeteer launches, Puppeteer might not be able to launch the browser. -Please check your open processes and ensure that the browser processes that Puppeteer launched have been killed. -If you think this is a bug, please report it on the Puppeteer issue tracker.`; - -/** - * @internal - */ -export class BrowserRunner { - #product: Product; - #executablePath: string; - #processArguments: string[]; - #userDataDir: string; - #isTempUserDataDir?: boolean; - #closed = true; - #listeners: PuppeteerEventListener[] = []; - #processClosing!: Promise; - - proc?: childProcess.ChildProcess; - connection?: Connection; - - constructor( - product: Product, - executablePath: string, - processArguments: string[], - userDataDir: string, - isTempUserDataDir?: boolean - ) { - this.#product = product; - this.#executablePath = executablePath; - this.#processArguments = processArguments; - this.#userDataDir = userDataDir; - this.#isTempUserDataDir = isTempUserDataDir; - } - - start(options: LaunchOptions): void { - const {handleSIGINT, handleSIGTERM, handleSIGHUP, dumpio, env, pipe} = - options; - let stdio: Array<'ignore' | 'pipe'>; - if (pipe) { - if (dumpio) { - stdio = ['ignore', 'pipe', 'pipe', 'pipe', 'pipe']; - } else { - stdio = ['ignore', 'ignore', 'ignore', 'pipe', 'pipe']; - } - } else { - if (dumpio) { - stdio = ['pipe', 'pipe', 'pipe']; - } else { - stdio = ['pipe', 'ignore', 'pipe']; - } - } - assert(!this.proc, 'This process has previously been started.'); - debugLauncher( - `Calling ${this.#executablePath} ${this.#processArguments.join(' ')}` - ); - this.proc = childProcess.spawn( - this.#executablePath, - this.#processArguments, - { - // On non-windows platforms, `detached: true` makes child process a - // leader of a new process group, making it possible to kill child - // process tree with `.kill(-pid)` command. @see - // https://nodejs.org/api/child_process.html#child_process_options_detached - detached: process.platform !== 'win32', - env, - stdio, - } - ); - if (dumpio) { - this.proc.stderr?.pipe(process.stderr); - this.proc.stdout?.pipe(process.stdout); - } - this.#closed = false; - this.#processClosing = new Promise((fulfill, reject) => { - this.proc!.once('exit', async () => { - this.#closed = true; - // Cleanup as processes exit. - if (this.#isTempUserDataDir) { - try { - await rm(this.#userDataDir); - fulfill(); - } catch (error) { - debugError(error); - reject(error); - } - } else { - if (this.#product === 'firefox') { - try { - // When an existing user profile has been used remove the user - // preferences file and restore possibly backuped preferences. - await unlink(path.join(this.#userDataDir, 'user.js')); - - const prefsBackupPath = path.join( - this.#userDataDir, - 'prefs.js.puppeteer' - ); - if (fs.existsSync(prefsBackupPath)) { - const prefsPath = path.join(this.#userDataDir, 'prefs.js'); - await unlink(prefsPath); - await rename(prefsBackupPath, prefsPath); - } - } catch (error) { - debugError(error); - reject(error); - } - } - - fulfill(); - } - }); - }); - this.#listeners = [addEventListener(process, 'exit', this.kill.bind(this))]; - if (handleSIGINT) { - this.#listeners.push( - addEventListener(process, 'SIGINT', () => { - this.kill(); - process.exit(130); - }) - ); - } - if (handleSIGTERM) { - this.#listeners.push( - addEventListener(process, 'SIGTERM', this.close.bind(this)) - ); - } - if (handleSIGHUP) { - this.#listeners.push( - addEventListener(process, 'SIGHUP', this.close.bind(this)) - ); - } - } - - close(): Promise { - if (this.#closed) { - return Promise.resolve(); - } - if (this.#isTempUserDataDir) { - this.kill(); - } else if (this.connection) { - // Attempt to close the browser gracefully - this.connection.send('Browser.close').catch(error => { - debugError(error); - this.kill(); - }); - } - // Cleanup this listener last, as that makes sure the full callback runs. If we - // perform this earlier, then the previous function calls would not happen. - removeEventListeners(this.#listeners); - return this.#processClosing; - } - - kill(): void { - // If the process failed to launch (for example if the browser executable path - // is invalid), then the process does not get a pid assigned. A call to - // `proc.kill` would error, as the `pid` to-be-killed can not be found. - if (this.proc && this.proc.pid && pidExists(this.proc.pid)) { - const proc = this.proc; - try { - if (process.platform === 'win32') { - childProcess.exec(`taskkill /pid ${this.proc.pid} /T /F`, error => { - if (error) { - // taskkill can fail to kill the process e.g. due to missing permissions. - // Let's kill the process via Node API. This delays killing of all child - // processes of `this.proc` until the main Node.js process dies. - proc.kill(); - } - }); - } else { - // on linux the process group can be killed with the group id prefixed with - // a minus sign. The process group id is the group leader's pid. - const processGroupId = -this.proc.pid; - - try { - process.kill(processGroupId, 'SIGKILL'); - } catch (error) { - // Killing the process group can fail due e.g. to missing permissions. - // Let's kill the process via Node API. This delays killing of all child - // processes of `this.proc` until the main Node.js process dies. - proc.kill('SIGKILL'); - } - } - } catch (error) { - throw new Error( - `${PROCESS_ERROR_EXPLANATION}\nError cause: ${ - isErrorLike(error) ? error.stack : error - }` - ); - } - } - - // Attempt to remove temporary profile directory to avoid littering. - try { - if (this.#isTempUserDataDir) { - rmSync(this.#userDataDir); - } - } catch (error) {} - - // Cleanup this listener last, as that makes sure the full callback runs. If we - // perform this earlier, then the previous function calls would not happen. - removeEventListeners(this.#listeners); - } - - /** - * @internal - */ - async setupWebDriverBiDiConnection(options: { - timeout: number; - slowMo: number; - preferredRevision: string; - protocolTimeout?: number; - }): Promise { - assert(this.proc, 'BrowserRunner not started.'); - - const {timeout, slowMo, preferredRevision, protocolTimeout} = options; - let browserWSEndpoint = await waitForWSEndpoint( - this.proc, - timeout, - preferredRevision, - /^WebDriver BiDi listening on (ws:\/\/.*)$/ - ); - browserWSEndpoint += '/session'; - const transport = await WebSocketTransport.create(browserWSEndpoint); - const BiDi = await import( - /* webpackIgnore: true */ '../common/bidi/bidi.js' - ); - return new BiDi.Connection(transport, slowMo, protocolTimeout); - } - - async setupConnection(options: { - usePipe?: boolean; - timeout: number; - slowMo: number; - preferredRevision: string; - protocolTimeout?: number; - }): Promise { - assert(this.proc, 'BrowserRunner not started.'); - - const {usePipe, timeout, slowMo, preferredRevision, protocolTimeout} = - options; - if (!usePipe) { - const browserWSEndpoint = await waitForWSEndpoint( - this.proc, - timeout, - preferredRevision - ); - const transport = await WebSocketTransport.create(browserWSEndpoint); - this.connection = new Connection( - browserWSEndpoint, - transport, - slowMo, - protocolTimeout - ); - } else { - // stdio was assigned during start(), and the 'pipe' option there adds the - // 4th and 5th items to stdio array - const {3: pipeWrite, 4: pipeRead} = this.proc.stdio; - const transport = new PipeTransport( - pipeWrite as NodeJS.WritableStream, - pipeRead as NodeJS.ReadableStream - ); - this.connection = new Connection('', transport, slowMo, protocolTimeout); - } - return this.connection; - } -} - -function waitForWSEndpoint( - browserProcess: childProcess.ChildProcess, - timeout: number, - preferredRevision: string, - regex = /^DevTools listening on (ws:\/\/.*)$/ -): Promise { - assert(browserProcess.stderr, '`browserProcess` does not have stderr.'); - const rl = readline.createInterface(browserProcess.stderr); - let stderr = ''; - - return new Promise((resolve, reject) => { - const listeners = [ - addEventListener(rl, 'line', onLine), - addEventListener(rl, 'close', () => { - return onClose(); - }), - addEventListener(browserProcess, 'exit', () => { - return onClose(); - }), - addEventListener(browserProcess, 'error', error => { - return onClose(error); - }), - ]; - const timeoutId = timeout ? setTimeout(onTimeout, timeout) : 0; - - function onClose(error?: Error): void { - cleanup(); - reject( - new Error( - [ - 'Failed to launch the browser process!' + - (error ? ' ' + error.message : ''), - stderr, - '', - 'TROUBLESHOOTING: https://pptr.dev/troubleshooting', - '', - ].join('\n') - ) - ); - } - - function onTimeout(): void { - cleanup(); - reject( - new TimeoutError( - `Timed out after ${timeout} ms while trying to connect to the browser! Only Chrome at revision r${preferredRevision} is guaranteed to work.` - ) - ); - } - - function onLine(line: string): void { - stderr += line + '\n'; - const match = line.match(regex); - if (!match) { - return; - } - cleanup(); - // The RegExp matches, so this will obviously exist. - resolve(match[1]!); - } - - function cleanup(): void { - if (timeoutId) { - clearTimeout(timeoutId); - } - removeEventListeners(listeners); - } - }); -} - -function pidExists(pid: number): boolean { - try { - return process.kill(pid, 0); - } catch (error) { - if (isErrnoException(error)) { - if (error.code && error.code === 'ESRCH') { - return false; - } - } - throw error; - } -} diff --git a/packages/puppeteer-core/src/node/ChromeLauncher.ts b/packages/puppeteer-core/src/node/ChromeLauncher.ts index 2d364506..b93b1baa 100644 --- a/packages/puppeteer-core/src/node/ChromeLauncher.ts +++ b/packages/puppeteer-core/src/node/ChromeLauncher.ts @@ -1,19 +1,38 @@ -import {accessSync} from 'fs'; +/** + * Copyright 2023 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + import {mkdtemp} from 'fs/promises'; -import os from 'os'; import path from 'path'; -import {Browser} from '../api/Browser.js'; -import {CDPBrowser} from '../common/Browser.js'; -import {assert} from '../util/assert.js'; +import { + computeSystemExecutablePath, + Browser as SupportedBrowsers, + ChromeReleaseChannel as BrowsersChromeReleaseChannel, +} from '@puppeteer/browsers'; + +import {debugError} from '../common/util.js'; +import {assert} from '../util/assert.js'; +import {rm} from '../util/fs.js'; -import {BrowserRunner} from './BrowserRunner.js'; import { BrowserLaunchArgumentOptions, ChromeReleaseChannel, PuppeteerNodeLaunchOptions, } from './LaunchOptions.js'; -import {ProductLauncher} from './ProductLauncher.js'; +import {ProductLauncher, ResolvedLaunchArgs} from './ProductLauncher.js'; import {PuppeteerNode} from './PuppeteerNode.js'; /** @@ -24,28 +43,19 @@ export class ChromeLauncher extends ProductLauncher { super(puppeteer, 'chrome'); } - override async launch( + /** + * @internal + */ + override async computeLaunchArguments( options: PuppeteerNodeLaunchOptions = {} - ): Promise { + ): Promise { const { ignoreDefaultArgs = false, args = [], - dumpio = false, + pipe = false, + debuggingPort, channel, executablePath, - pipe = false, - env = process.env, - handleSIGINT = true, - handleSIGTERM = true, - handleSIGHUP = true, - ignoreHTTPSErrors = false, - defaultViewport = {width: 800, height: 600}, - slowMo = 0, - timeout = 30000, - waitForInitialPage = true, - debuggingPort, - protocol, - protocolTimeout, } = options; const chromeArguments = []; @@ -104,82 +114,29 @@ export class ChromeLauncher extends ProductLauncher { chromeExecutable = this.executablePath(channel); } - const usePipe = chromeArguments.includes('--remote-debugging-pipe'); - const runner = new BrowserRunner( - this.product, - chromeExecutable, - chromeArguments, + return { + executablePath: chromeExecutable, + args: chromeArguments, + isTempUserDataDir, userDataDir, - isTempUserDataDir - ); - runner.start({ - handleSIGHUP, - handleSIGTERM, - handleSIGINT, - dumpio, - env, - pipe: usePipe, - }); + }; + } - let browser; - try { - const connection = await runner.setupConnection({ - usePipe, - timeout, - slowMo, - preferredRevision: this.puppeteer.browserRevision, - protocolTimeout, - }); - - if (protocol === 'webDriverBiDi') { - try { - const BiDi = await import( - /* webpackIgnore: true */ '../common/bidi/bidi.js' - ); - const bidiConnection = await BiDi.connectBidiOverCDP(connection); - browser = await BiDi.Browser.create({ - connection: bidiConnection, - closeCallback: runner.close.bind(runner), - process: runner.proc, - }); - } catch (error) { - runner.kill(); - throw error; - } - - return browser; - } - - browser = await CDPBrowser._create( - this.product, - connection, - [], - ignoreHTTPSErrors, - defaultViewport, - runner.proc, - runner.close.bind(runner), - options.targetFilter - ); - } catch (error) { - runner.kill(); - throw error; - } - - if (waitForInitialPage) { + /** + * @internal + */ + override async cleanUserDataDir( + path: string, + opts: {isTemp: boolean} + ): Promise { + if (opts.isTemp) { try { - await browser.waitForTarget( - t => { - return t.type() === 'page'; - }, - {timeout} - ); + await rm(path); } catch (error) { - await browser.close(); + debugError(error); throw error; } } - - return browser; } override defaultArgs(options: BrowserLaunchArgumentOptions = {}): string[] { @@ -248,86 +205,27 @@ export class ChromeLauncher extends ProductLauncher { override executablePath(channel?: ChromeReleaseChannel): string { if (channel) { - return this.#executablePathForChannel(channel); + return computeSystemExecutablePath({ + browser: SupportedBrowsers.CHROME, + channel: convertPuppeteerChannelToBrowsersChannel(channel), + }); } else { return this.resolveExecutablePath(); } } +} - /** - * @internal - */ - #executablePathForChannel(channel: ChromeReleaseChannel): string { - const platform = os.platform(); - - let chromePath: string | undefined; - switch (platform) { - case 'win32': - switch (channel) { - case 'chrome': - chromePath = `${process.env['PROGRAMFILES']}\\Google\\Chrome\\Application\\chrome.exe`; - break; - case 'chrome-beta': - chromePath = `${process.env['PROGRAMFILES']}\\Google\\Chrome Beta\\Application\\chrome.exe`; - break; - case 'chrome-canary': - chromePath = `${process.env['PROGRAMFILES']}\\Google\\Chrome SxS\\Application\\chrome.exe`; - break; - case 'chrome-dev': - chromePath = `${process.env['PROGRAMFILES']}\\Google\\Chrome Dev\\Application\\chrome.exe`; - break; - } - break; - case 'darwin': - switch (channel) { - case 'chrome': - chromePath = - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; - break; - case 'chrome-beta': - chromePath = - '/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta'; - break; - case 'chrome-canary': - chromePath = - '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary'; - break; - case 'chrome-dev': - chromePath = - '/Applications/Google Chrome Dev.app/Contents/MacOS/Google Chrome Dev'; - break; - } - break; - case 'linux': - switch (channel) { - case 'chrome': - chromePath = '/opt/google/chrome/chrome'; - break; - case 'chrome-beta': - chromePath = '/opt/google/chrome-beta/chrome'; - break; - case 'chrome-dev': - chromePath = '/opt/google/chrome-unstable/chrome'; - break; - } - break; - } - - if (!chromePath) { - throw new Error( - `Unable to detect browser executable path for '${channel}' on ${platform}.` - ); - } - - // Check if Chrome exists and is accessible. - try { - accessSync(chromePath); - } catch (error) { - throw new Error( - `Could not find Google Chrome executable for channel '${channel}' at '${chromePath}'.` - ); - } - - return chromePath; +function convertPuppeteerChannelToBrowsersChannel( + channel: ChromeReleaseChannel +): BrowsersChromeReleaseChannel { + switch (channel) { + case 'chrome': + return BrowsersChromeReleaseChannel.STABLE; + case 'chrome-dev': + return BrowsersChromeReleaseChannel.DEV; + case 'chrome-beta': + return BrowsersChromeReleaseChannel.BETA; + case 'chrome-canary': + return BrowsersChromeReleaseChannel.CANARY; } } diff --git a/packages/puppeteer-core/src/node/FirefoxLauncher.ts b/packages/puppeteer-core/src/node/FirefoxLauncher.ts index cd8624cd..835fb7ba 100644 --- a/packages/puppeteer-core/src/node/FirefoxLauncher.ts +++ b/packages/puppeteer-core/src/node/FirefoxLauncher.ts @@ -1,17 +1,35 @@ +/** + * Copyright 2023 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + import fs from 'fs'; +import {rename, unlink, mkdtemp} from 'fs/promises'; import os from 'os'; import path from 'path'; -import {Browser} from '../api/Browser.js'; -import {CDPBrowser} from '../common/Browser.js'; -import {assert} from '../util/assert.js'; +import {Browser as SupportedBrowsers, createProfile} from '@puppeteer/browsers'; + +import {debugError} from '../common/util.js'; +import {assert} from '../util/assert.js'; +import {rm} from '../util/fs.js'; -import {BrowserRunner} from './BrowserRunner.js'; import { BrowserLaunchArgumentOptions, PuppeteerNodeLaunchOptions, } from './LaunchOptions.js'; -import {ProductLauncher} from './ProductLauncher.js'; +import {ProductLauncher, ResolvedLaunchArgs} from './ProductLauncher.js'; import {PuppeteerNode} from './PuppeteerNode.js'; /** @@ -21,29 +39,19 @@ export class FirefoxLauncher extends ProductLauncher { constructor(puppeteer: PuppeteerNode) { super(puppeteer, 'firefox'); } - - override async launch( + /** + * @internal + */ + override async computeLaunchArguments( options: PuppeteerNodeLaunchOptions = {} - ): Promise { + ): Promise { const { ignoreDefaultArgs = false, args = [], - dumpio = false, executablePath, pipe = false, - env = process.env, - handleSIGINT = true, - handleSIGTERM = true, - handleSIGHUP = true, - ignoreHTTPSErrors = false, - defaultViewport = {width: 800, height: 600}, - slowMo = 0, - timeout = 30000, extraPrefsFirefox = {}, - waitForInitialPage = true, debuggingPort = null, - protocol = 'cdp', - protocolTimeout, } = options; const firefoxArguments = []; @@ -91,14 +99,17 @@ export class FirefoxLauncher extends ProductLauncher { // When using a custom Firefox profile it needs to be populated // with required preferences. isTempUserDataDir = false; - const prefs = this.defaultPreferences(extraPrefsFirefox); - this.writePreferences(prefs, userDataDir); } else { - userDataDir = await this._createProfile(extraPrefsFirefox); + userDataDir = await mkdtemp(this.getProfilePath()); firefoxArguments.push('--profile'); firefoxArguments.push(userDataDir); } + await createProfile(SupportedBrowsers.FIREFOX, { + path: userDataDir, + preferences: extraPrefsFirefox, + }); + let firefoxExecutable: string; if (this.puppeteer._isPuppeteerCore || executablePath) { assert( @@ -110,86 +121,44 @@ export class FirefoxLauncher extends ProductLauncher { firefoxExecutable = this.executablePath(); } - const runner = new BrowserRunner( - this.product, - firefoxExecutable, - firefoxArguments, + return { + isTempUserDataDir, userDataDir, - isTempUserDataDir - ); - runner.start({ - handleSIGHUP, - handleSIGTERM, - handleSIGINT, - dumpio, - env, - pipe, - }); + args: firefoxArguments, + executablePath: firefoxExecutable, + }; + } - if (protocol === 'webDriverBiDi') { - let browser: Browser; + /** + * @internal + */ + override async cleanUserDataDir( + userDataDir: string, + opts: {isTemp: boolean} + ): Promise { + if (opts.isTemp) { try { - const connection = await runner.setupWebDriverBiDiConnection({ - timeout, - slowMo, - preferredRevision: this.puppeteer.browserRevision, - protocolTimeout, - }); - const BiDi = await import( - /* webpackIgnore: true */ '../common/bidi/bidi.js' - ); - browser = await BiDi.Browser.create({ - connection, - closeCallback: runner.close.bind(runner), - process: runner.proc, - }); + await rm(userDataDir); } catch (error) { - runner.kill(); + debugError(error); throw error; } - - return browser; - } - - let browser; - try { - const connection = await runner.setupConnection({ - usePipe: pipe, - timeout, - slowMo, - preferredRevision: this.puppeteer.browserRevision, - protocolTimeout, - }); - browser = await CDPBrowser._create( - this.product, - connection, - [], - ignoreHTTPSErrors, - defaultViewport, - runner.proc, - runner.close.bind(runner), - options.targetFilter - ); - } catch (error) { - runner.kill(); - throw error; - } - - if (waitForInitialPage) { + } else { try { - await browser.waitForTarget( - t => { - return t.type() === 'page'; - }, - {timeout} - ); + // When an existing user profile has been used remove the user + // preferences file and restore possibly backuped preferences. + await unlink(path.join(userDataDir, 'user.js')); + + const prefsBackupPath = path.join(userDataDir, 'prefs.js.puppeteer'); + if (fs.existsSync(prefsBackupPath)) { + const prefsPath = path.join(userDataDir, 'prefs.js'); + await unlink(prefsPath); + await rename(prefsBackupPath, prefsPath); + } } catch (error) { - await browser.close(); - throw error; + debugError(error); } } - - return browser; } override executablePath(): string { @@ -245,256 +214,4 @@ export class FirefoxLauncher extends ProductLauncher { firefoxArguments.push(...args); return firefoxArguments; } - - defaultPreferences(extraPrefs: {[x: string]: unknown}): { - [x: string]: unknown; - } { - const server = 'dummy.test'; - - const defaultPrefs = { - // Make sure Shield doesn't hit the network. - 'app.normandy.api_url': '', - // Disable Firefox old build background check - 'app.update.checkInstallTime': false, - // Disable automatically upgrading Firefox - 'app.update.disabledForTesting': true, - - // Increase the APZ content response timeout to 1 minute - 'apz.content_response_timeout': 60000, - - // Prevent various error message on the console - // jest-puppeteer asserts that no error message is emitted by the console - 'browser.contentblocking.features.standard': - '-tp,tpPrivate,cookieBehavior0,-cm,-fp', - - // Enable the dump function: which sends messages to the system - // console - // https://bugzilla.mozilla.org/show_bug.cgi?id=1543115 - 'browser.dom.window.dump.enabled': true, - // Disable topstories - 'browser.newtabpage.activity-stream.feeds.system.topstories': false, - // Always display a blank page - 'browser.newtabpage.enabled': false, - // Background thumbnails in particular cause grief: and disabling - // thumbnails in general cannot hurt - 'browser.pagethumbnails.capturing_disabled': true, - - // Disable safebrowsing components. - 'browser.safebrowsing.blockedURIs.enabled': false, - 'browser.safebrowsing.downloads.enabled': false, - 'browser.safebrowsing.malware.enabled': false, - 'browser.safebrowsing.passwords.enabled': false, - 'browser.safebrowsing.phishing.enabled': false, - - // Disable updates to search engines. - 'browser.search.update': false, - // Do not restore the last open set of tabs if the browser has crashed - 'browser.sessionstore.resume_from_crash': false, - // Skip check for default browser on startup - 'browser.shell.checkDefaultBrowser': false, - - // Disable newtabpage - 'browser.startup.homepage': 'about:blank', - // Do not redirect user when a milstone upgrade of Firefox is detected - 'browser.startup.homepage_override.mstone': 'ignore', - // Start with a blank page about:blank - 'browser.startup.page': 0, - - // Do not allow background tabs to be zombified on Android: otherwise for - // tests that open additional tabs: the test harness tab itself might get - // unloaded - 'browser.tabs.disableBackgroundZombification': false, - // Do not warn when closing all other open tabs - 'browser.tabs.warnOnCloseOtherTabs': false, - // Do not warn when multiple tabs will be opened - 'browser.tabs.warnOnOpen': false, - - // Disable the UI tour. - 'browser.uitour.enabled': false, - // Turn off search suggestions in the location bar so as not to trigger - // network connections. - 'browser.urlbar.suggest.searches': false, - // Disable first run splash page on Windows 10 - 'browser.usedOnWindows10.introURL': '', - // Do not warn on quitting Firefox - 'browser.warnOnQuit': false, - - // Defensively disable data reporting systems - 'datareporting.healthreport.documentServerURI': `http://${server}/dummy/healthreport/`, - 'datareporting.healthreport.logging.consoleEnabled': false, - 'datareporting.healthreport.service.enabled': false, - 'datareporting.healthreport.service.firstRun': false, - 'datareporting.healthreport.uploadEnabled': false, - - // Do not show datareporting policy notifications which can interfere with tests - 'datareporting.policy.dataSubmissionEnabled': false, - 'datareporting.policy.dataSubmissionPolicyBypassNotification': true, - - // DevTools JSONViewer sometimes fails to load dependencies with its require.js. - // This doesn't affect Puppeteer but spams console (Bug 1424372) - 'devtools.jsonview.enabled': false, - - // Disable popup-blocker - 'dom.disable_open_during_load': false, - - // Enable the support for File object creation in the content process - // Required for |Page.setFileInputFiles| protocol method. - 'dom.file.createInChild': true, - - // Disable the ProcessHangMonitor - 'dom.ipc.reportProcessHangs': false, - - // Disable slow script dialogues - 'dom.max_chrome_script_run_time': 0, - 'dom.max_script_run_time': 0, - - // Only load extensions from the application and user profile - // AddonManager.SCOPE_PROFILE + AddonManager.SCOPE_APPLICATION - 'extensions.autoDisableScopes': 0, - 'extensions.enabledScopes': 5, - - // Disable metadata caching for installed add-ons by default - 'extensions.getAddons.cache.enabled': false, - - // Disable installing any distribution extensions or add-ons. - 'extensions.installDistroAddons': false, - - // Disabled screenshots extension - 'extensions.screenshots.disabled': true, - - // Turn off extension updates so they do not bother tests - 'extensions.update.enabled': false, - - // Turn off extension updates so they do not bother tests - 'extensions.update.notifyUser': false, - - // Make sure opening about:addons will not hit the network - 'extensions.webservice.discoverURL': `http://${server}/dummy/discoveryURL`, - - // Temporarily force disable BFCache in parent (https://bit.ly/bug-1732263) - 'fission.bfcacheInParent': false, - - // Force all web content to use a single content process - 'fission.webContentIsolationStrategy': 0, - - // Allow the application to have focus even it runs in the background - 'focusmanager.testmode': true, - // Disable useragent updates - 'general.useragent.updates.enabled': false, - // Always use network provider for geolocation tests so we bypass the - // macOS dialog raised by the corelocation provider - 'geo.provider.testing': true, - // Do not scan Wifi - 'geo.wifi.scan': false, - // No hang monitor - 'hangmonitor.timeout': 0, - // Show chrome errors and warnings in the error console - 'javascript.options.showInConsole': true, - - // Disable download and usage of OpenH264: and Widevine plugins - 'media.gmp-manager.updateEnabled': false, - // Prevent various error message on the console - // jest-puppeteer asserts that no error message is emitted by the console - 'network.cookie.cookieBehavior': 0, - - // Disable experimental feature that is only available in Nightly - 'network.cookie.sameSite.laxByDefault': false, - - // Do not prompt for temporary redirects - 'network.http.prompt-temp-redirect': false, - - // Disable speculative connections so they are not reported as leaking - // when they are hanging around - 'network.http.speculative-parallel-limit': 0, - - // Do not automatically switch between offline and online - 'network.manage-offline-status': false, - - // Make sure SNTP requests do not hit the network - 'network.sntp.pools': server, - - // Disable Flash. - 'plugin.state.flash': 0, - - 'privacy.trackingprotection.enabled': false, - - // Can be removed once Firefox 89 is no longer supported - // https://bugzilla.mozilla.org/show_bug.cgi?id=1710839 - 'remote.enabled': true, - - // Don't do network connections for mitm priming - 'security.certerrors.mitm.priming.enabled': false, - // Local documents have access to all other local documents, - // including directory listings - 'security.fileuri.strict_origin_policy': false, - // Do not wait for the notification button security delay - 'security.notification_enable_delay': 0, - - // Ensure blocklist updates do not hit the network - 'services.settings.server': `http://${server}/dummy/blocklist/`, - - // Do not automatically fill sign-in forms with known usernames and - // passwords - 'signon.autofillForms': false, - // Disable password capture, so that tests that include forms are not - // influenced by the presence of the persistent doorhanger notification - 'signon.rememberSignons': false, - - // Disable first-run welcome page - 'startup.homepage_welcome_url': 'about:blank', - - // Disable first-run welcome page - 'startup.homepage_welcome_url.additional': '', - - // Disable browser animations (tabs, fullscreen, sliding alerts) - 'toolkit.cosmeticAnimations.enabled': false, - - // Prevent starting into safe mode after application crashes - 'toolkit.startup.max_resumed_crashes': -1, - }; - - return Object.assign(defaultPrefs, extraPrefs); - } - - /** - * Populates the user.js file with custom preferences as needed to allow - * Firefox's CDP support to properly function. These preferences will be - * automatically copied over to prefs.js during startup of Firefox. To be - * able to restore the original values of preferences a backup of prefs.js - * will be created. - * - * @param prefs - List of preferences to add. - * @param profilePath - Firefox profile to write the preferences to. - */ - async writePreferences( - prefs: {[x: string]: unknown}, - profilePath: string - ): Promise { - const lines = Object.entries(prefs).map(([key, value]) => { - return `user_pref(${JSON.stringify(key)}, ${JSON.stringify(value)});`; - }); - - await fs.promises.writeFile( - path.join(profilePath, 'user.js'), - lines.join('\n') - ); - - // Create a backup of the preferences file if it already exitsts. - const prefsPath = path.join(profilePath, 'prefs.js'); - if (fs.existsSync(prefsPath)) { - const prefsBackupPath = path.join(profilePath, 'prefs.js.puppeteer'); - await fs.promises.copyFile(prefsPath, prefsBackupPath); - } - } - - async _createProfile(extraPrefs: {[x: string]: unknown}): Promise { - const temporaryProfilePath = await fs.promises.mkdtemp( - this.getProfilePath() - ); - - const prefs = this.defaultPreferences(extraPrefs); - await this.writePreferences(prefs, temporaryProfilePath); - - return temporaryProfilePath; - } } diff --git a/packages/puppeteer-core/src/node/ProductLauncher.ts b/packages/puppeteer-core/src/node/ProductLauncher.ts index 814b48eb..c8a13f86 100644 --- a/packages/puppeteer-core/src/node/ProductLauncher.ts +++ b/packages/puppeteer-core/src/node/ProductLauncher.ts @@ -17,16 +17,39 @@ import {existsSync} from 'fs'; import os, {tmpdir} from 'os'; import {join} from 'path'; -import {Browser} from '../api/Browser.js'; +import { + CDP_WEBSOCKET_ENDPOINT_REGEX, + launch, + TimeoutError as BrowsersTimeoutError, + WEBDRIVER_BIDI_WEBSOCKET_ENDPOINT_REGEX, +} from '@puppeteer/browsers'; + +import {Browser, BrowserCloseCallback} from '../api/Browser.js'; +import {CDPBrowser} from '../common/Browser.js'; +import {Connection} from '../common/Connection.js'; +import {TimeoutError} from '../common/Errors.js'; +import {NodeWebSocketTransport as WebSocketTransport} from '../common/NodeWebSocketTransport.js'; import {Product} from '../common/Product.js'; +import {debugError} from '../common/util.js'; import { BrowserLaunchArgumentOptions, ChromeReleaseChannel, PuppeteerNodeLaunchOptions, } from './LaunchOptions.js'; +import {PipeTransport} from './PipeTransport.js'; import {PuppeteerNode} from './PuppeteerNode.js'; +/** + * @internal + */ +export type ResolvedLaunchArgs = { + isTempUserDataDir: boolean; + userDataDir: string; + executablePath: string; + args: string[]; +}; + /** * Describes a launcher - a class that is able to create and launch a browser instance. * @@ -57,9 +80,113 @@ export class ProductLauncher { return this.#product; } - launch(object: PuppeteerNodeLaunchOptions): Promise; - launch(): Promise { - throw new Error('Not implemented'); + async launch(options: PuppeteerNodeLaunchOptions = {}): Promise { + const { + dumpio = false, + env = process.env, + handleSIGINT = true, + handleSIGTERM = true, + handleSIGHUP = true, + ignoreHTTPSErrors = false, + defaultViewport = {width: 800, height: 600}, + slowMo = 0, + timeout = 30000, + waitForInitialPage = true, + protocol, + protocolTimeout, + } = options; + + const launchArgs = await this.computeLaunchArguments(options); + + const usePipe = launchArgs.args.includes('--remote-debugging-pipe'); + + const onProcessExit = async () => { + await this.cleanUserDataDir(launchArgs.userDataDir, { + isTemp: launchArgs.isTempUserDataDir, + }); + }; + + const browserProcess = launch({ + executablePath: launchArgs.executablePath, + args: launchArgs.args, + handleSIGHUP, + handleSIGTERM, + handleSIGINT, + dumpio, + env, + pipe: usePipe, + onExit: onProcessExit, + }); + + let browser: Browser; + let connection: Connection; + let closing = false; + + const browserCloseCallback = async () => { + if (closing) { + return; + } + closing = true; + await this.closeBrowser(browserProcess, connection); + }; + + try { + if (this.#product === 'firefox' && protocol === 'webDriverBiDi') { + browser = await this.createBiDiBrowser( + browserProcess, + browserCloseCallback, + { + timeout, + protocolTimeout, + slowMo, + } + ); + } else { + if (usePipe) { + connection = await this.createCDPPipeConnection(browserProcess, { + timeout, + protocolTimeout, + slowMo, + }); + } else { + connection = await this.createCDPSocketConnection(browserProcess, { + timeout, + protocolTimeout, + slowMo, + }); + } + if (protocol === 'webDriverBiDi') { + browser = await this.createBiDiOverCDPBrowser( + browserProcess, + connection, + browserCloseCallback + ); + } else { + browser = await CDPBrowser._create( + this.product, + connection, + [], + ignoreHTTPSErrors, + defaultViewport, + browserProcess.nodeProcess, + browserCloseCallback, + options.targetFilter + ); + } + } + } catch (error) { + browserCloseCallback(); + if (error instanceof BrowsersTimeoutError) { + throw new TimeoutError(error.message); + } + throw error; + } + + if (waitForInitialPage && protocol !== 'webDriverBiDi') { + await this.waitForPageTarget(browser, timeout); + } + + return browser; } executablePath(channel?: ChromeReleaseChannel): string; @@ -81,6 +208,153 @@ export class ProductLauncher { return this.actualBrowserRevision; } + /** + * @internal + */ + protected async computeLaunchArguments( + options: PuppeteerNodeLaunchOptions + ): Promise; + protected async computeLaunchArguments(): Promise { + throw new Error('Not implemented'); + } + + /** + * @internal + */ + protected async cleanUserDataDir( + path: string, + opts: {isTemp: boolean} + ): Promise; + protected async cleanUserDataDir(): Promise { + throw new Error('Not implemented'); + } + + /** + * @internal + */ + protected async closeBrowser( + browserProcess: ReturnType, + connection?: Connection + ): Promise { + if (connection) { + // Attempt to close the browser gracefully + try { + await connection.closeBrowser(); + await browserProcess.hasClosed(); + } catch (error) { + debugError(error); + await browserProcess.close(); + } + } else { + await browserProcess.close(); + } + } + + /** + * @internal + */ + protected async waitForPageTarget( + browser: Browser, + timeout: number + ): Promise { + try { + await browser.waitForTarget( + t => { + return t.type() === 'page'; + }, + {timeout} + ); + } catch (error) { + await browser.close(); + throw error; + } + } + + /** + * @internal + */ + protected async createCDPSocketConnection( + browserProcess: ReturnType, + opts: {timeout: number; protocolTimeout: number | undefined; slowMo: number} + ): Promise { + const browserWSEndpoint = await browserProcess.waitForLineOutput( + CDP_WEBSOCKET_ENDPOINT_REGEX, + opts.timeout + ); + const transport = await WebSocketTransport.create(browserWSEndpoint); + return new Connection( + browserWSEndpoint, + transport, + opts.slowMo, + opts.protocolTimeout + ); + } + + /** + * @internal + */ + protected async createCDPPipeConnection( + browserProcess: ReturnType, + opts: {timeout: number; protocolTimeout: number | undefined; slowMo: number} + ): Promise { + // stdio was assigned during start(), and the 'pipe' option there adds the + // 4th and 5th items to stdio array + const {3: pipeWrite, 4: pipeRead} = browserProcess.nodeProcess.stdio; + const transport = new PipeTransport( + pipeWrite as NodeJS.WritableStream, + pipeRead as NodeJS.ReadableStream + ); + return new Connection('', transport, opts.slowMo, opts.protocolTimeout); + } + + /** + * @internal + */ + protected async createBiDiOverCDPBrowser( + browserProcess: ReturnType, + connection: Connection, + closeCallback: BrowserCloseCallback + ): Promise { + const BiDi = await import( + /* webpackIgnore: true */ '../common/bidi/bidi.js' + ); + const bidiConnection = await BiDi.connectBidiOverCDP(connection); + return await BiDi.Browser.create({ + connection: bidiConnection, + closeCallback, + process: browserProcess.nodeProcess, + }); + } + + /** + * @internal + */ + protected async createBiDiBrowser( + browserProcess: ReturnType, + closeCallback: BrowserCloseCallback, + opts: {timeout: number; protocolTimeout: number | undefined; slowMo: number} + ): Promise { + const browserWSEndpoint = + (await browserProcess.waitForLineOutput( + WEBDRIVER_BIDI_WEBSOCKET_ENDPOINT_REGEX, + opts.timeout + )) + '/session'; + const transport = await WebSocketTransport.create(browserWSEndpoint); + const BiDi = await import( + /* webpackIgnore: true */ '../common/bidi/bidi.js' + ); + const bidiConnection = new BiDi.Connection( + transport, + opts.slowMo, + opts.protocolTimeout + ); + return await BiDi.Browser.create({ + connection: bidiConnection, + closeCallback, + process: browserProcess.nodeProcess, + }); + } + /** * @internal */ diff --git a/packages/puppeteer-core/src/node/node.ts b/packages/puppeteer-core/src/node/node.ts index ba20a800..6de393a4 100644 --- a/packages/puppeteer-core/src/node/node.ts +++ b/packages/puppeteer-core/src/node/node.ts @@ -15,7 +15,6 @@ */ export * from './BrowserFetcher.js'; -export * from './BrowserRunner.js'; export * from './ChromeLauncher.js'; export * from './FirefoxLauncher.js'; export * from './LaunchOptions.js'; diff --git a/test/installation/src/describeInstallation.ts b/test/installation/src/describeInstallation.ts index 61defefb..aeeef440 100644 --- a/test/installation/src/describeInstallation.ts +++ b/test/installation/src/describeInstallation.ts @@ -104,8 +104,10 @@ export const describeInstallation = ( }); after(async () => { - if (process.env['KEEP_SANDBOX']) { + if (!process.env['KEEP_SANDBOX']) { await rm(sandbox, {recursive: true, force: true, maxRetries: 5}); + } else { + console.log('sandbox saved in ' + sandbox); } }); diff --git a/test/installation/src/puppeteer-core.spec.ts b/test/installation/src/puppeteer-core.spec.ts index 0cbf79fa..e72e6e7e 100644 --- a/test/installation/src/puppeteer-core.spec.ts +++ b/test/installation/src/puppeteer-core.spec.ts @@ -19,7 +19,7 @@ import {readAsset} from './util.js'; describeInstallation( '`puppeteer-core`', - {dependencies: ['puppeteer-core']}, + {dependencies: ['@puppeteer/browsers', 'puppeteer-core']}, ({itEvaluates}) => { itEvaluates('CommonJS', {commonjs: true}, async () => { return readAsset('puppeteer-core', 'requires.cjs'); diff --git a/test/src/launcher.spec.ts b/test/src/launcher.spec.ts index dfcc734b..9279ff07 100644 --- a/test/src/launcher.spec.ts +++ b/test/src/launcher.spec.ts @@ -462,10 +462,13 @@ describe('Launcher specs', function () { const options = Object.assign({}, defaultBrowserOptions); options.ignoreDefaultArgs = true; const browser = await puppeteer.launch(options); - const page = await browser.newPage(); - expect(await page.evaluate('11 * 11')).toBe(121); - await page.close(); - await browser.close(); + try { + const page = await browser.newPage(); + expect(await page.evaluate('11 * 11')).toBe(121); + await page.close(); + } finally { + await browser.close(); + } }); it('should filter out ignored default arguments in Chrome', async () => { const {defaultBrowserOptions, puppeteer} = getTestState();