diff options
Diffstat (limited to 'remote/test/puppeteer/packages/puppeteer-core/src/node')
12 files changed, 2084 insertions, 0 deletions
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/node/ChromeLauncher.test.ts b/remote/test/puppeteer/packages/puppeteer-core/src/node/ChromeLauncher.test.ts new file mode 100644 index 0000000000..9abd3697f7 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/node/ChromeLauncher.test.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import {describe, it} from 'node:test'; + +import expect from 'expect'; + +import {getFeatures, removeMatchingFlags} from './ChromeLauncher.js'; + +describe('getFeatures', () => { + it('returns an empty array when no options are provided', () => { + const result = getFeatures('--foo'); + expect(result).toEqual([]); + }); + + it('returns an empty array when no options match the flag', () => { + const result = getFeatures('--foo', ['--bar', '--baz']); + expect(result).toEqual([]); + }); + + it('returns an array of values when options match the flag', () => { + const result = getFeatures('--foo', ['--foo=bar', '--foo=baz']); + expect(result).toEqual(['bar', 'baz']); + }); + + it('does not handle whitespace', () => { + const result = getFeatures('--foo', ['--foo bar', '--foo baz ']); + expect(result).toEqual([]); + }); + + it('handles equals sign around the flag and value', () => { + const result = getFeatures('--foo', ['--foo=bar', '--foo=baz ']); + expect(result).toEqual(['bar', 'baz']); + }); +}); + +describe('removeMatchingFlags', () => { + it('empty', () => { + const a: string[] = []; + expect(removeMatchingFlags(a, '--foo')).toEqual([]); + }); + + it('with one match', () => { + const a: string[] = ['--foo=1', '--bar=baz']; + expect(removeMatchingFlags(a, '--foo')).toEqual(['--bar=baz']); + }); + + it('with multiple matches', () => { + const a: string[] = ['--foo=1', '--foo=2', '--bar=baz']; + expect(removeMatchingFlags(a, '--foo')).toEqual(['--bar=baz']); + }); + + it('with no matches', () => { + const a: string[] = ['--foo=1', '--bar=baz']; + expect(removeMatchingFlags(a, '--baz')).toEqual(['--foo=1', '--bar=baz']); + }); +}); diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/node/ChromeLauncher.ts b/remote/test/puppeteer/packages/puppeteer-core/src/node/ChromeLauncher.ts new file mode 100644 index 0000000000..51d5a19983 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/node/ChromeLauncher.ts @@ -0,0 +1,344 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {mkdtemp} from 'fs/promises'; +import os from 'os'; +import path from 'path'; + +import { + computeSystemExecutablePath, + Browser as SupportedBrowsers, + ChromeReleaseChannel as BrowsersChromeReleaseChannel, +} from '@puppeteer/browsers'; + +import type {Browser} from '../api/Browser.js'; +import {debugError} from '../common/util.js'; +import {assert} from '../util/assert.js'; + +import type { + BrowserLaunchArgumentOptions, + ChromeReleaseChannel, + PuppeteerNodeLaunchOptions, +} from './LaunchOptions.js'; +import {ProductLauncher, type ResolvedLaunchArgs} from './ProductLauncher.js'; +import type {PuppeteerNode} from './PuppeteerNode.js'; +import {rm} from './util/fs.js'; + +/** + * @internal + */ +export class ChromeLauncher extends ProductLauncher { + constructor(puppeteer: PuppeteerNode) { + super(puppeteer, 'chrome'); + } + + override launch(options: PuppeteerNodeLaunchOptions = {}): Promise<Browser> { + const headless = options.headless ?? true; + if ( + headless === true && + this.puppeteer.configuration.logLevel === 'warn' && + !Boolean(process.env['PUPPETEER_DISABLE_HEADLESS_WARNING']) + ) { + console.warn( + [ + '\x1B[1m\x1B[43m\x1B[30m', + 'Puppeteer old Headless deprecation warning:\x1B[0m\x1B[33m', + ' In the near future `headless: true` will default to the new Headless mode', + ' for Chrome instead of the old Headless implementation. For more', + ' information, please see https://developer.chrome.com/articles/new-headless/.', + ' Consider opting in early by passing `headless: "new"` to `puppeteer.launch()`', + ' If you encounter any bugs, please report them to https://github.com/puppeteer/puppeteer/issues/new/choose.\x1B[0m\n', + ].join('\n ') + ); + } + + if ( + this.puppeteer.configuration.logLevel === 'warn' && + process.platform === 'darwin' && + process.arch === 'x64' + ) { + const cpus = os.cpus(); + if (cpus[0]?.model.includes('Apple')) { + console.warn( + [ + '\x1B[1m\x1B[43m\x1B[30m', + 'Degraded performance warning:\x1B[0m\x1B[33m', + 'Launching Chrome on Mac Silicon (arm64) from an x64 Node installation results in', + 'Rosetta translating the Chrome binary, even if Chrome is already arm64. This would', + 'result in huge performance issues. To resolve this, you must run Puppeteer with', + 'a version of Node built for arm64.', + ].join('\n ') + ); + } + } + + return super.launch(options); + } + + /** + * @internal + */ + override async computeLaunchArguments( + options: PuppeteerNodeLaunchOptions = {} + ): Promise<ResolvedLaunchArgs> { + const { + ignoreDefaultArgs = false, + args = [], + pipe = false, + debuggingPort, + channel, + executablePath, + } = options; + + const chromeArguments = []; + if (!ignoreDefaultArgs) { + chromeArguments.push(...this.defaultArgs(options)); + } else if (Array.isArray(ignoreDefaultArgs)) { + chromeArguments.push( + ...this.defaultArgs(options).filter(arg => { + return !ignoreDefaultArgs.includes(arg); + }) + ); + } else { + chromeArguments.push(...args); + } + + if ( + !chromeArguments.some(argument => { + return argument.startsWith('--remote-debugging-'); + }) + ) { + if (pipe) { + assert( + !debuggingPort, + 'Browser should be launched with either pipe or debugging port - not both.' + ); + chromeArguments.push('--remote-debugging-pipe'); + } else { + chromeArguments.push(`--remote-debugging-port=${debuggingPort || 0}`); + } + } + + let isTempUserDataDir = false; + + // Check for the user data dir argument, which will always be set even + // with a custom directory specified via the userDataDir option. + let userDataDirIndex = chromeArguments.findIndex(arg => { + return arg.startsWith('--user-data-dir'); + }); + if (userDataDirIndex < 0) { + isTempUserDataDir = true; + chromeArguments.push( + `--user-data-dir=${await mkdtemp(this.getProfilePath())}` + ); + userDataDirIndex = chromeArguments.length - 1; + } + + const userDataDir = chromeArguments[userDataDirIndex]!.split('=', 2)[1]; + assert(typeof userDataDir === 'string', '`--user-data-dir` is malformed'); + + let chromeExecutable = executablePath; + if (!chromeExecutable) { + assert( + channel || !this.puppeteer._isPuppeteerCore, + `An \`executablePath\` or \`channel\` must be specified for \`puppeteer-core\`` + ); + chromeExecutable = this.executablePath(channel, options.headless ?? true); + } + + return { + executablePath: chromeExecutable, + args: chromeArguments, + isTempUserDataDir, + userDataDir, + }; + } + + /** + * @internal + */ + override async cleanUserDataDir( + path: string, + opts: {isTemp: boolean} + ): Promise<void> { + if (opts.isTemp) { + try { + await rm(path); + } catch (error) { + debugError(error); + throw error; + } + } + } + + override defaultArgs(options: BrowserLaunchArgumentOptions = {}): string[] { + // See https://github.com/GoogleChrome/chrome-launcher/blob/main/docs/chrome-flags-for-tools.md + + const userDisabledFeatures = getFeatures( + '--disable-features', + options.args + ); + if (options.args && userDisabledFeatures.length > 0) { + removeMatchingFlags(options.args, '--disable-features'); + } + + // Merge default disabled features with user-provided ones, if any. + const disabledFeatures = [ + 'Translate', + // AcceptCHFrame disabled because of crbug.com/1348106. + 'AcceptCHFrame', + 'MediaRouter', + 'OptimizationHints', + // https://crbug.com/1492053 + 'ProcessPerSiteUpToMainFrameThreshold', + ...userDisabledFeatures, + ]; + + const userEnabledFeatures = getFeatures('--enable-features', options.args); + if (options.args && userEnabledFeatures.length > 0) { + removeMatchingFlags(options.args, '--enable-features'); + } + + // Merge default enabled features with user-provided ones, if any. + const enabledFeatures = [ + 'NetworkServiceInProcess2', + ...userEnabledFeatures, + ]; + + const chromeArguments = [ + '--allow-pre-commit-input', + '--disable-background-networking', + '--disable-background-timer-throttling', + '--disable-backgrounding-occluded-windows', + '--disable-breakpad', + '--disable-client-side-phishing-detection', + '--disable-component-extensions-with-background-pages', + '--disable-component-update', + '--disable-default-apps', + '--disable-dev-shm-usage', + '--disable-extensions', + '--disable-field-trial-config', // https://source.chromium.org/chromium/chromium/src/+/main:testing/variations/README.md + '--disable-hang-monitor', + '--disable-infobars', + '--disable-ipc-flooding-protection', + '--disable-popup-blocking', + '--disable-prompt-on-repost', + '--disable-renderer-backgrounding', + '--disable-search-engine-choice-screen', + '--disable-sync', + '--enable-automation', + '--export-tagged-pdf', + '--force-color-profile=srgb', + '--metrics-recording-only', + '--no-first-run', + '--password-store=basic', + '--use-mock-keychain', + `--disable-features=${disabledFeatures.join(',')}`, + `--enable-features=${enabledFeatures.join(',')}`, + ]; + const { + devtools = false, + headless = !devtools, + args = [], + userDataDir, + } = options; + if (userDataDir) { + chromeArguments.push(`--user-data-dir=${path.resolve(userDataDir)}`); + } + if (devtools) { + chromeArguments.push('--auto-open-devtools-for-tabs'); + } + if (headless) { + chromeArguments.push( + headless === 'new' ? '--headless=new' : '--headless', + '--hide-scrollbars', + '--mute-audio' + ); + } + if ( + args.every(arg => { + return arg.startsWith('-'); + }) + ) { + chromeArguments.push('about:blank'); + } + chromeArguments.push(...args); + return chromeArguments; + } + + override executablePath( + channel?: ChromeReleaseChannel, + headless?: boolean | 'new' + ): string { + if (channel) { + return computeSystemExecutablePath({ + browser: SupportedBrowsers.CHROME, + channel: convertPuppeteerChannelToBrowsersChannel(channel), + }); + } else { + return this.resolveExecutablePath(headless); + } + } +} + +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; + } +} + +/** + * Extracts all features from the given command-line flag + * (e.g. `--enable-features`, `--enable-features=`). + * + * Example input: + * ["--enable-features=NetworkService,NetworkServiceInProcess", "--enable-features=Foo"] + * + * Example output: + * ["NetworkService", "NetworkServiceInProcess", "Foo"] + * + * @internal + */ +export function getFeatures(flag: string, options: string[] = []): string[] { + return options + .filter(s => { + return s.startsWith(flag.endsWith('=') ? flag : `${flag}=`); + }) + .map(s => { + return s.split(new RegExp(`${flag}=\\s*`))[1]?.trim(); + }) + .filter(s => { + return s; + }) as string[]; +} + +/** + * Removes all elements in-place from the given string array + * that match the given command-line flag. + * + * @internal + */ +export function removeMatchingFlags(array: string[], flag: string): string[] { + const regex = new RegExp(`^${flag}=.*`); + let i = 0; + while (i < array.length) { + if (regex.test(array[i]!)) { + array.splice(i, 1); + } else { + i++; + } + } + return array; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/node/FirefoxLauncher.test.ts b/remote/test/puppeteer/packages/puppeteer-core/src/node/FirefoxLauncher.test.ts new file mode 100644 index 0000000000..b0b1f81249 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/node/FirefoxLauncher.test.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {describe, it} from 'node:test'; + +import expect from 'expect'; + +import {FirefoxLauncher} from './FirefoxLauncher.js'; + +describe('FirefoxLauncher', function () { + describe('getPreferences', function () { + it('should return preferences for CDP', async () => { + const prefs: Record<string, unknown> = FirefoxLauncher.getPreferences( + { + test: 1, + }, + undefined + ); + expect(prefs['test']).toBe(1); + expect(prefs['fission.bfcacheInParent']).toBe(false); + expect(prefs['fission.webContentIsolationStrategy']).toBe(0); + expect(prefs).toEqual( + FirefoxLauncher.getPreferences( + { + test: 1, + }, + 'cdp' + ) + ); + }); + + it('should return preferences for WebDriver BiDi', async () => { + const prefs: Record<string, unknown> = FirefoxLauncher.getPreferences( + { + test: 1, + }, + 'webDriverBiDi' + ); + expect(prefs['test']).toBe(1); + expect(prefs['fission.bfcacheInParent']).toBe(undefined); + expect(prefs['fission.webContentIsolationStrategy']).toBe(0); + }); + }); +}); diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/node/FirefoxLauncher.ts b/remote/test/puppeteer/packages/puppeteer-core/src/node/FirefoxLauncher.ts new file mode 100644 index 0000000000..eb4f375fc7 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/node/FirefoxLauncher.ts @@ -0,0 +1,242 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'fs'; +import {rename, unlink, mkdtemp} from 'fs/promises'; +import os from 'os'; +import path from 'path'; + +import { + Browser as SupportedBrowsers, + createProfile, + Cache, + detectBrowserPlatform, + Browser, +} from '@puppeteer/browsers'; + +import {debugError} from '../common/util.js'; +import {assert} from '../util/assert.js'; + +import type { + BrowserLaunchArgumentOptions, + PuppeteerNodeLaunchOptions, +} from './LaunchOptions.js'; +import {ProductLauncher, type ResolvedLaunchArgs} from './ProductLauncher.js'; +import type {PuppeteerNode} from './PuppeteerNode.js'; +import {rm} from './util/fs.js'; + +/** + * @internal + */ +export class FirefoxLauncher extends ProductLauncher { + constructor(puppeteer: PuppeteerNode) { + super(puppeteer, 'firefox'); + } + + static getPreferences( + extraPrefsFirefox?: Record<string, unknown>, + protocol?: 'cdp' | 'webDriverBiDi' + ): Record<string, unknown> { + return { + ...extraPrefsFirefox, + ...(protocol === 'webDriverBiDi' + ? {} + : { + // Do not close the window when the last tab gets closed + 'browser.tabs.closeWindowWithLastTab': false, + // Temporarily force disable BFCache in parent (https://bit.ly/bug-1732263) + 'fission.bfcacheInParent': false, + }), + // Force all web content to use a single content process. TODO: remove + // this once Firefox supports mouse event dispatch from the main frame + // context. Once this happens, webContentIsolationStrategy should only + // be set for CDP. See + // https://bugzilla.mozilla.org/show_bug.cgi?id=1773393 + 'fission.webContentIsolationStrategy': 0, + }; + } + + /** + * @internal + */ + override async computeLaunchArguments( + options: PuppeteerNodeLaunchOptions = {} + ): Promise<ResolvedLaunchArgs> { + const { + ignoreDefaultArgs = false, + args = [], + executablePath, + pipe = false, + extraPrefsFirefox = {}, + debuggingPort = null, + } = options; + + const firefoxArguments = []; + if (!ignoreDefaultArgs) { + firefoxArguments.push(...this.defaultArgs(options)); + } else if (Array.isArray(ignoreDefaultArgs)) { + firefoxArguments.push( + ...this.defaultArgs(options).filter(arg => { + return !ignoreDefaultArgs.includes(arg); + }) + ); + } else { + firefoxArguments.push(...args); + } + + if ( + !firefoxArguments.some(argument => { + return argument.startsWith('--remote-debugging-'); + }) + ) { + if (pipe) { + assert( + debuggingPort === null, + 'Browser should be launched with either pipe or debugging port - not both.' + ); + } + firefoxArguments.push(`--remote-debugging-port=${debuggingPort || 0}`); + } + + let userDataDir: string | undefined; + let isTempUserDataDir = true; + + // Check for the profile argument, which will always be set even + // with a custom directory specified via the userDataDir option. + const profileArgIndex = firefoxArguments.findIndex(arg => { + return ['-profile', '--profile'].includes(arg); + }); + + if (profileArgIndex !== -1) { + userDataDir = firefoxArguments[profileArgIndex + 1]; + if (!userDataDir || !fs.existsSync(userDataDir)) { + throw new Error(`Firefox profile not found at '${userDataDir}'`); + } + + // When using a custom Firefox profile it needs to be populated + // with required preferences. + isTempUserDataDir = false; + } else { + userDataDir = await mkdtemp(this.getProfilePath()); + firefoxArguments.push('--profile'); + firefoxArguments.push(userDataDir); + } + + await createProfile(SupportedBrowsers.FIREFOX, { + path: userDataDir, + preferences: FirefoxLauncher.getPreferences( + extraPrefsFirefox, + options.protocol + ), + }); + + let firefoxExecutable: string; + if (this.puppeteer._isPuppeteerCore || executablePath) { + assert( + executablePath, + `An \`executablePath\` must be specified for \`puppeteer-core\`` + ); + firefoxExecutable = executablePath; + } else { + firefoxExecutable = this.executablePath(); + } + + return { + isTempUserDataDir, + userDataDir, + args: firefoxArguments, + executablePath: firefoxExecutable, + }; + } + + /** + * @internal + */ + override async cleanUserDataDir( + userDataDir: string, + opts: {isTemp: boolean} + ): Promise<void> { + if (opts.isTemp) { + try { + await rm(userDataDir); + } catch (error) { + debugError(error); + throw error; + } + } else { + try { + // 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) { + debugError(error); + } + } + } + + override executablePath(): string { + // replace 'latest' placeholder with actual downloaded revision + if (this.puppeteer.browserRevision === 'latest') { + const cache = new Cache(this.puppeteer.defaultDownloadPath!); + const installedFirefox = cache.getInstalledBrowsers().find(browser => { + return ( + browser.platform === detectBrowserPlatform() && + browser.browser === Browser.FIREFOX + ); + }); + if (installedFirefox) { + this.actualBrowserRevision = installedFirefox.buildId; + } + } + return this.resolveExecutablePath(); + } + + override defaultArgs(options: BrowserLaunchArgumentOptions = {}): string[] { + const { + devtools = false, + headless = !devtools, + args = [], + userDataDir = null, + } = options; + + const firefoxArguments = ['--no-remote']; + + switch (os.platform()) { + case 'darwin': + firefoxArguments.push('--foreground'); + break; + case 'win32': + firefoxArguments.push('--wait-for-browser'); + break; + } + if (userDataDir) { + firefoxArguments.push('--profile'); + firefoxArguments.push(userDataDir); + } + if (headless) { + firefoxArguments.push('--headless'); + } + if (devtools) { + firefoxArguments.push('--devtools'); + } + if ( + args.every(arg => { + return arg.startsWith('-'); + }) + ) { + firefoxArguments.push('about:blank'); + } + firefoxArguments.push(...args); + return firefoxArguments; + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/node/LaunchOptions.ts b/remote/test/puppeteer/packages/puppeteer-core/src/node/LaunchOptions.ts new file mode 100644 index 0000000000..28e0b595df --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/node/LaunchOptions.ts @@ -0,0 +1,140 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {BrowserConnectOptions} from '../common/ConnectOptions.js'; +import type {Product} from '../common/Product.js'; + +/** + * Launcher options that only apply to Chrome. + * + * @public + */ +export interface BrowserLaunchArgumentOptions { + /** + * Whether to run the browser in headless mode. + * + * @remarks + * In the future `headless: true` will be equivalent to `headless: 'new'`. + * You can read more about the change {@link https://developer.chrome.com/articles/new-headless/ | here}. + * Consider opting in early by setting the value to `"new"`. + * + * @defaultValue `true` + */ + headless?: boolean | 'new'; + /** + * Path to a user data directory. + * {@link https://chromium.googlesource.com/chromium/src/+/refs/heads/main/docs/user_data_dir.md | see the Chromium docs} + * for more info. + */ + userDataDir?: string; + /** + * Whether to auto-open a DevTools panel for each tab. If this is set to + * `true`, then `headless` will be forced to `false`. + * @defaultValue `false` + */ + devtools?: boolean; + /** + * Specify the debugging port number to use + */ + debuggingPort?: number; + /** + * Additional command line arguments to pass to the browser instance. + */ + args?: string[]; +} +/** + * @public + */ +export type ChromeReleaseChannel = + | 'chrome' + | 'chrome-beta' + | 'chrome-canary' + | 'chrome-dev'; + +/** + * Generic launch options that can be passed when launching any browser. + * @public + */ +export interface LaunchOptions { + /** + * Chrome Release Channel + */ + channel?: ChromeReleaseChannel; + /** + * Path to a browser executable to use instead of the bundled Chromium. Note + * that Puppeteer is only guaranteed to work with the bundled Chromium, so use + * this setting at your own risk. + */ + executablePath?: string; + /** + * If `true`, do not use `puppeteer.defaultArgs()` when creating a browser. If + * an array is provided, these args will be filtered out. Use this with care - + * you probably want the default arguments Puppeteer uses. + * @defaultValue `false` + */ + ignoreDefaultArgs?: boolean | string[]; + /** + * Close the browser process on `Ctrl+C`. + * @defaultValue `true` + */ + handleSIGINT?: boolean; + /** + * Close the browser process on `SIGTERM`. + * @defaultValue `true` + */ + handleSIGTERM?: boolean; + /** + * Close the browser process on `SIGHUP`. + * @defaultValue `true` + */ + handleSIGHUP?: boolean; + /** + * Maximum time in milliseconds to wait for the browser to start. + * Pass `0` to disable the timeout. + * @defaultValue `30_000` (30 seconds). + */ + timeout?: number; + /** + * If true, pipes the browser process stdout and stderr to `process.stdout` + * and `process.stderr`. + * @defaultValue `false` + */ + dumpio?: boolean; + /** + * Specify environment variables that will be visible to the browser. + * @defaultValue The contents of `process.env`. + */ + env?: Record<string, string | undefined>; + /** + * Connect to a browser over a pipe instead of a WebSocket. + * @defaultValue `false` + */ + pipe?: boolean; + /** + * Which browser to launch. + * @defaultValue `chrome` + */ + product?: Product; + /** + * {@link https://searchfox.org/mozilla-release/source/modules/libpref/init/all.js | Additional preferences } that can be passed when launching with Firefox. + */ + extraPrefsFirefox?: Record<string, unknown>; + /** + * Whether to wait for the initial page to be ready. + * Useful when a user explicitly disables that (e.g. `--no-startup-window` for Chrome). + * @defaultValue `true` + */ + waitForInitialPage?: boolean; +} + +/** + * Utility type exposed to enable users to define options that can be passed to + * `puppeteer.launch` without having to list the set of all types. + * @public + */ +export type PuppeteerNodeLaunchOptions = BrowserLaunchArgumentOptions & + LaunchOptions & + BrowserConnectOptions; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/node/NodeWebSocketTransport.ts b/remote/test/puppeteer/packages/puppeteer-core/src/node/NodeWebSocketTransport.ts new file mode 100644 index 0000000000..f4ac592e4f --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/node/NodeWebSocketTransport.ts @@ -0,0 +1,64 @@ +/** + * @license + * Copyright 2018 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import NodeWebSocket from 'ws'; + +import type {ConnectionTransport} from '../common/ConnectionTransport.js'; +import {packageVersion} from '../generated/version.js'; + +/** + * @internal + */ +export class NodeWebSocketTransport implements ConnectionTransport { + static create( + url: string, + headers?: Record<string, string> + ): Promise<NodeWebSocketTransport> { + return new Promise((resolve, reject) => { + const ws = new NodeWebSocket(url, [], { + followRedirects: true, + perMessageDeflate: false, + maxPayload: 256 * 1024 * 1024, // 256Mb + headers: { + 'User-Agent': `Puppeteer ${packageVersion}`, + ...headers, + }, + }); + + ws.addEventListener('open', () => { + return resolve(new NodeWebSocketTransport(ws)); + }); + ws.addEventListener('error', reject); + }); + } + + #ws: NodeWebSocket; + onmessage?: (message: NodeWebSocket.Data) => void; + onclose?: () => void; + + constructor(ws: NodeWebSocket) { + this.#ws = ws; + this.#ws.addEventListener('message', event => { + if (this.onmessage) { + this.onmessage.call(null, event.data); + } + }); + this.#ws.addEventListener('close', () => { + if (this.onclose) { + this.onclose.call(null); + } + }); + // Silently ignore all errors - we don't know what to do with them. + this.#ws.addEventListener('error', () => {}); + } + + send(message: string): void { + this.#ws.send(message); + } + + close(): void { + this.#ws.close(); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/node/PipeTransport.ts b/remote/test/puppeteer/packages/puppeteer-core/src/node/PipeTransport.ts new file mode 100644 index 0000000000..616f164d82 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/node/PipeTransport.ts @@ -0,0 +1,86 @@ +/** + * @license + * Copyright 2018 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import type {ConnectionTransport} from '../common/ConnectionTransport.js'; +import {EventSubscription} from '../common/EventEmitter.js'; +import {debugError} from '../common/util.js'; +import {assert} from '../util/assert.js'; +import {DisposableStack} from '../util/disposable.js'; + +/** + * @internal + */ +export class PipeTransport implements ConnectionTransport { + #pipeWrite: NodeJS.WritableStream; + #subscriptions = new DisposableStack(); + + #isClosed = false; + #pendingMessage = ''; + + onclose?: () => void; + onmessage?: (value: string) => void; + + constructor( + pipeWrite: NodeJS.WritableStream, + pipeRead: NodeJS.ReadableStream + ) { + this.#pipeWrite = pipeWrite; + this.#subscriptions.use( + new EventSubscription(pipeRead, 'data', (buffer: Buffer) => { + return this.#dispatch(buffer); + }) + ); + this.#subscriptions.use( + new EventSubscription(pipeRead, 'close', () => { + if (this.onclose) { + this.onclose.call(null); + } + }) + ); + this.#subscriptions.use( + new EventSubscription(pipeRead, 'error', debugError) + ); + this.#subscriptions.use( + new EventSubscription(pipeWrite, 'error', debugError) + ); + } + + send(message: string): void { + assert(!this.#isClosed, '`PipeTransport` is closed.'); + + this.#pipeWrite.write(message); + this.#pipeWrite.write('\0'); + } + + #dispatch(buffer: Buffer): void { + assert(!this.#isClosed, '`PipeTransport` is closed.'); + + let end = buffer.indexOf('\0'); + if (end === -1) { + this.#pendingMessage += buffer.toString(); + return; + } + const message = this.#pendingMessage + buffer.toString(undefined, 0, end); + if (this.onmessage) { + this.onmessage.call(null, message); + } + + let start = end + 1; + end = buffer.indexOf('\0', start); + while (end !== -1) { + if (this.onmessage) { + this.onmessage.call(null, buffer.toString(undefined, start, end)); + } + start = end + 1; + end = buffer.indexOf('\0', start); + } + this.#pendingMessage = buffer.toString(undefined, start); + } + + close(): void { + this.#isClosed = true; + this.#subscriptions.dispose(); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/node/ProductLauncher.ts b/remote/test/puppeteer/packages/puppeteer-core/src/node/ProductLauncher.ts new file mode 100644 index 0000000000..ab3432cd3a --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/node/ProductLauncher.ts @@ -0,0 +1,451 @@ +/** + * @license + * Copyright 2017 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import {existsSync} from 'fs'; +import {tmpdir} from 'os'; +import {join} from 'path'; + +import { + Browser as InstalledBrowser, + CDP_WEBSOCKET_ENDPOINT_REGEX, + launch, + TimeoutError as BrowsersTimeoutError, + WEBDRIVER_BIDI_WEBSOCKET_ENDPOINT_REGEX, + computeExecutablePath, +} from '@puppeteer/browsers'; + +import { + firstValueFrom, + from, + map, + race, + timer, +} from '../../third_party/rxjs/rxjs.js'; +import type {Browser, BrowserCloseCallback} from '../api/Browser.js'; +import {CdpBrowser} from '../cdp/Browser.js'; +import {Connection} from '../cdp/Connection.js'; +import {TimeoutError} from '../common/Errors.js'; +import type {Product} from '../common/Product.js'; +import {debugError, DEFAULT_VIEWPORT} from '../common/util.js'; +import type {Viewport} from '../common/Viewport.js'; + +import type { + BrowserLaunchArgumentOptions, + ChromeReleaseChannel, + PuppeteerNodeLaunchOptions, +} from './LaunchOptions.js'; +import {NodeWebSocketTransport as WebSocketTransport} from './NodeWebSocketTransport.js'; +import {PipeTransport} from './PipeTransport.js'; +import type {PuppeteerNode} from './PuppeteerNode.js'; + +/** + * @internal + */ +export interface ResolvedLaunchArgs { + isTempUserDataDir: boolean; + userDataDir: string; + executablePath: string; + args: string[]; +} + +/** + * Describes a launcher - a class that is able to create and launch a browser instance. + * + * @public + */ +export abstract class ProductLauncher { + #product: Product; + + /** + * @internal + */ + puppeteer: PuppeteerNode; + + /** + * @internal + */ + protected actualBrowserRevision?: string; + + /** + * @internal + */ + constructor(puppeteer: PuppeteerNode, product: Product) { + this.puppeteer = puppeteer; + this.#product = product; + } + + get product(): Product { + return this.#product; + } + + async launch(options: PuppeteerNodeLaunchOptions = {}): Promise<Browser> { + const { + dumpio = false, + env = process.env, + handleSIGINT = true, + handleSIGTERM = true, + handleSIGHUP = true, + ignoreHTTPSErrors = false, + defaultViewport = DEFAULT_VIEWPORT, + slowMo = 0, + timeout = 30000, + waitForInitialPage = true, + protocolTimeout, + protocol, + } = 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 cdpConnection: Connection; + let closing = false; + + const browserCloseCallback: BrowserCloseCallback = async () => { + if (closing) { + return; + } + closing = true; + await this.closeBrowser(browserProcess, cdpConnection); + }; + + try { + if (this.#product === 'firefox' && protocol === 'webDriverBiDi') { + browser = await this.createBiDiBrowser( + browserProcess, + browserCloseCallback, + { + timeout, + protocolTimeout, + slowMo, + defaultViewport, + ignoreHTTPSErrors, + } + ); + } else { + if (usePipe) { + cdpConnection = await this.createCdpPipeConnection(browserProcess, { + timeout, + protocolTimeout, + slowMo, + }); + } else { + cdpConnection = await this.createCdpSocketConnection(browserProcess, { + timeout, + protocolTimeout, + slowMo, + }); + } + if (protocol === 'webDriverBiDi') { + browser = await this.createBiDiOverCdpBrowser( + browserProcess, + cdpConnection, + browserCloseCallback, + { + timeout, + protocolTimeout, + slowMo, + defaultViewport, + ignoreHTTPSErrors, + } + ); + } else { + browser = await CdpBrowser._create( + this.product, + cdpConnection, + [], + ignoreHTTPSErrors, + defaultViewport, + browserProcess.nodeProcess, + browserCloseCallback, + options.targetFilter + ); + } + } + } catch (error) { + void browserCloseCallback(); + if (error instanceof BrowsersTimeoutError) { + throw new TimeoutError(error.message); + } + throw error; + } + + if (waitForInitialPage && protocol !== 'webDriverBiDi') { + await this.waitForPageTarget(browser, timeout); + } + + return browser; + } + + abstract executablePath(channel?: ChromeReleaseChannel): string; + + abstract defaultArgs(object: BrowserLaunchArgumentOptions): string[]; + + /** + * Set only for Firefox, after the launcher resolves the `latest` revision to + * the actual revision. + * @internal + */ + getActualBrowserRevision(): string | undefined { + return this.actualBrowserRevision; + } + + /** + * @internal + */ + protected abstract computeLaunchArguments( + options: PuppeteerNodeLaunchOptions + ): Promise<ResolvedLaunchArgs>; + + /** + * @internal + */ + protected abstract cleanUserDataDir( + path: string, + opts: {isTemp: boolean} + ): Promise<void>; + + /** + * @internal + */ + protected async closeBrowser( + browserProcess: ReturnType<typeof launch>, + cdpConnection?: Connection + ): Promise<void> { + if (cdpConnection) { + // Attempt to close the browser gracefully + try { + await cdpConnection.closeBrowser(); + await browserProcess.hasClosed(); + } catch (error) { + debugError(error); + await browserProcess.close(); + } + } else { + // Wait for a possible graceful shutdown. + await firstValueFrom( + race( + from(browserProcess.hasClosed()), + timer(5000).pipe( + map(() => { + return from(browserProcess.close()); + }) + ) + ) + ); + } + } + + /** + * @internal + */ + protected async waitForPageTarget( + browser: Browser, + timeout: number + ): Promise<void> { + try { + await browser.waitForTarget( + t => { + return t.type() === 'page'; + }, + {timeout} + ); + } catch (error) { + await browser.close(); + throw error; + } + } + + /** + * @internal + */ + protected async createCdpSocketConnection( + browserProcess: ReturnType<typeof launch>, + opts: {timeout: number; protocolTimeout: number | undefined; slowMo: number} + ): Promise<Connection> { + 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<typeof launch>, + opts: {timeout: number; protocolTimeout: number | undefined; slowMo: number} + ): Promise<Connection> { + // 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<typeof launch>, + connection: Connection, + closeCallback: BrowserCloseCallback, + opts: { + timeout: number; + protocolTimeout: number | undefined; + slowMo: number; + defaultViewport: Viewport | null; + ignoreHTTPSErrors?: boolean; + } + ): Promise<Browser> { + // TODO: use other options too. + const BiDi = await import(/* webpackIgnore: true */ '../bidi/bidi.js'); + const bidiConnection = await BiDi.connectBidiOverCdp(connection, { + acceptInsecureCerts: opts.ignoreHTTPSErrors ?? false, + }); + return await BiDi.BidiBrowser.create({ + connection: bidiConnection, + closeCallback, + process: browserProcess.nodeProcess, + defaultViewport: opts.defaultViewport, + ignoreHTTPSErrors: opts.ignoreHTTPSErrors, + }); + } + + /** + * @internal + */ + protected async createBiDiBrowser( + browserProcess: ReturnType<typeof launch>, + closeCallback: BrowserCloseCallback, + opts: { + timeout: number; + protocolTimeout: number | undefined; + slowMo: number; + defaultViewport: Viewport | null; + ignoreHTTPSErrors?: boolean; + } + ): Promise<Browser> { + 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 */ '../bidi/bidi.js'); + const bidiConnection = new BiDi.BidiConnection( + browserWSEndpoint, + transport, + opts.slowMo, + opts.protocolTimeout + ); + // TODO: use other options too. + return await BiDi.BidiBrowser.create({ + connection: bidiConnection, + closeCallback, + process: browserProcess.nodeProcess, + defaultViewport: opts.defaultViewport, + ignoreHTTPSErrors: opts.ignoreHTTPSErrors, + }); + } + + /** + * @internal + */ + protected getProfilePath(): string { + return join( + this.puppeteer.configuration.temporaryDirectory ?? tmpdir(), + `puppeteer_dev_${this.product}_profile-` + ); + } + + /** + * @internal + */ + protected resolveExecutablePath(headless?: boolean | 'new'): string { + let executablePath = this.puppeteer.configuration.executablePath; + if (executablePath) { + if (!existsSync(executablePath)) { + throw new Error( + `Tried to find the browser at the configured path (${executablePath}), but no executable was found.` + ); + } + return executablePath; + } + + function productToBrowser(product?: Product, headless?: boolean | 'new') { + switch (product) { + case 'chrome': + if (headless === true) { + return InstalledBrowser.CHROMEHEADLESSSHELL; + } + return InstalledBrowser.CHROME; + case 'firefox': + return InstalledBrowser.FIREFOX; + } + return InstalledBrowser.CHROME; + } + + executablePath = computeExecutablePath({ + cacheDir: this.puppeteer.defaultDownloadPath!, + browser: productToBrowser(this.product, headless), + buildId: this.puppeteer.browserRevision, + }); + + if (!existsSync(executablePath)) { + if (this.puppeteer.configuration.browserRevision) { + throw new Error( + `Tried to find the browser at the configured path (${executablePath}) for revision ${this.puppeteer.browserRevision}, but no executable was found.` + ); + } + switch (this.product) { + case 'chrome': + throw new Error( + `Could not find Chrome (ver. ${this.puppeteer.browserRevision}). This can occur if either\n` + + ' 1. you did not perform an installation before running the script (e.g. `npx puppeteer browsers install chrome`) or\n' + + ` 2. your cache path is incorrectly configured (which is: ${this.puppeteer.configuration.cacheDirectory}).\n` + + 'For (2), check out our guide on configuring puppeteer at https://pptr.dev/guides/configuration.' + ); + case 'firefox': + throw new Error( + `Could not find Firefox (rev. ${this.puppeteer.browserRevision}). This can occur if either\n` + + ' 1. you did not perform an installation for Firefox before running the script (e.g. `npx puppeteer browsers install firefox`) or\n' + + ` 2. your cache path is incorrectly configured (which is: ${this.puppeteer.configuration.cacheDirectory}).\n` + + 'For (2), check out our guide on configuring puppeteer at https://pptr.dev/guides/configuration.' + ); + } + } + return executablePath; + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/node/PuppeteerNode.ts b/remote/test/puppeteer/packages/puppeteer-core/src/node/PuppeteerNode.ts new file mode 100644 index 0000000000..e50e09acdb --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/node/PuppeteerNode.ts @@ -0,0 +1,356 @@ +/** + * @license + * Copyright 2020 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + Browser as SupportedBrowser, + resolveBuildId, + detectBrowserPlatform, + getInstalledBrowsers, + uninstall, +} from '@puppeteer/browsers'; + +import type {Browser} from '../api/Browser.js'; +import type {Configuration} from '../common/Configuration.js'; +import type { + ConnectOptions, + BrowserConnectOptions, +} from '../common/ConnectOptions.js'; +import type {Product} from '../common/Product.js'; +import {type CommonPuppeteerSettings, Puppeteer} from '../common/Puppeteer.js'; +import {PUPPETEER_REVISIONS} from '../revisions.js'; + +import {ChromeLauncher} from './ChromeLauncher.js'; +import {FirefoxLauncher} from './FirefoxLauncher.js'; +import type { + BrowserLaunchArgumentOptions, + ChromeReleaseChannel, + LaunchOptions, +} from './LaunchOptions.js'; +import type {ProductLauncher} from './ProductLauncher.js'; + +/** + * @public + */ +export interface PuppeteerLaunchOptions + extends LaunchOptions, + BrowserLaunchArgumentOptions, + BrowserConnectOptions { + product?: Product; + extraPrefsFirefox?: Record<string, unknown>; +} + +/** + * Extends the main {@link Puppeteer} class with Node specific behaviour for + * fetching and downloading browsers. + * + * If you're using Puppeteer in a Node environment, this is the class you'll get + * when you run `require('puppeteer')` (or the equivalent ES `import`). + * + * @remarks + * The most common method to use is {@link PuppeteerNode.launch | launch}, which + * is used to launch and connect to a new browser instance. + * + * See {@link Puppeteer | the main Puppeteer class} for methods common to all + * environments, such as {@link Puppeteer.connect}. + * + * @example + * The following is a typical example of using Puppeteer to drive automation: + * + * ```ts + * import puppeteer from 'puppeteer'; + * + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * await page.goto('https://www.google.com'); + * // other actions... + * await browser.close(); + * })(); + * ``` + * + * Once you have created a `page` you have access to a large API to interact + * with the page, navigate, or find certain elements in that page. + * The {@link Page | `page` documentation} lists all the available methods. + * + * @public + */ +export class PuppeteerNode extends Puppeteer { + #_launcher?: ProductLauncher; + #lastLaunchedProduct?: Product; + + /** + * @internal + */ + defaultBrowserRevision: string; + + /** + * @internal + */ + configuration: Configuration = {}; + + /** + * @internal + */ + constructor( + settings: { + configuration?: Configuration; + } & CommonPuppeteerSettings + ) { + const {configuration, ...commonSettings} = settings; + super(commonSettings); + if (configuration) { + this.configuration = configuration; + } + switch (this.configuration.defaultProduct) { + case 'firefox': + this.defaultBrowserRevision = PUPPETEER_REVISIONS.firefox; + break; + default: + this.configuration.defaultProduct = 'chrome'; + this.defaultBrowserRevision = PUPPETEER_REVISIONS.chrome; + break; + } + + this.connect = this.connect.bind(this); + this.launch = this.launch.bind(this); + this.executablePath = this.executablePath.bind(this); + this.defaultArgs = this.defaultArgs.bind(this); + this.trimCache = this.trimCache.bind(this); + } + + /** + * This method attaches Puppeteer to an existing browser instance. + * + * @param options - Set of configurable options to set on the browser. + * @returns Promise which resolves to browser instance. + */ + override connect(options: ConnectOptions): Promise<Browser> { + return super.connect(options); + } + + /** + * Launches a browser instance with given arguments and options when + * specified. + * + * When using with `puppeteer-core`, + * {@link LaunchOptions | options.executablePath} or + * {@link LaunchOptions | options.channel} must be provided. + * + * @example + * You can use {@link LaunchOptions | options.ignoreDefaultArgs} + * to filter out `--mute-audio` from default arguments: + * + * ```ts + * const browser = await puppeteer.launch({ + * ignoreDefaultArgs: ['--mute-audio'], + * }); + * ``` + * + * @remarks + * Puppeteer can also be used to control the Chrome browser, but it works best + * with the version of Chrome for Testing downloaded by default. + * There is no guarantee it will work with any other version. If Google Chrome + * (rather than Chrome for Testing) is preferred, a + * {@link https://www.google.com/chrome/browser/canary.html | Chrome Canary} + * or + * {@link https://www.chromium.org/getting-involved/dev-channel | Dev Channel} + * build is suggested. See + * {@link https://www.howtogeek.com/202825/what%E2%80%99s-the-difference-between-chromium-and-chrome/ | this article} + * for a description of the differences between Chromium and Chrome. + * {@link https://chromium.googlesource.com/chromium/src/+/lkgr/docs/chromium_browser_vs_google_chrome.md | This article} + * describes some differences for Linux users. See + * {@link https://developer.chrome.com/blog/chrome-for-testing/ | this doc} for the description + * of Chrome for Testing. + * + * @param options - Options to configure launching behavior. + */ + launch(options: PuppeteerLaunchOptions = {}): Promise<Browser> { + const {product = this.defaultProduct} = options; + this.#lastLaunchedProduct = product; + return this.#launcher.launch(options); + } + + /** + * @internal + */ + get #launcher(): ProductLauncher { + if ( + this.#_launcher && + this.#_launcher.product === this.lastLaunchedProduct + ) { + return this.#_launcher; + } + switch (this.lastLaunchedProduct) { + case 'chrome': + this.defaultBrowserRevision = PUPPETEER_REVISIONS.chrome; + this.#_launcher = new ChromeLauncher(this); + break; + case 'firefox': + this.defaultBrowserRevision = PUPPETEER_REVISIONS.firefox; + this.#_launcher = new FirefoxLauncher(this); + break; + default: + throw new Error(`Unknown product: ${this.#lastLaunchedProduct}`); + } + return this.#_launcher; + } + + /** + * The default executable path. + */ + executablePath(channel?: ChromeReleaseChannel): string { + return this.#launcher.executablePath(channel); + } + + /** + * @internal + */ + get browserRevision(): string { + return ( + this.#_launcher?.getActualBrowserRevision() ?? + this.configuration.browserRevision ?? + this.defaultBrowserRevision! + ); + } + + /** + * The default download path for puppeteer. For puppeteer-core, this + * code should never be called as it is never defined. + * + * @internal + */ + get defaultDownloadPath(): string | undefined { + return this.configuration.downloadPath ?? this.configuration.cacheDirectory; + } + + /** + * The name of the browser that was last launched. + */ + get lastLaunchedProduct(): Product { + return this.#lastLaunchedProduct ?? this.defaultProduct; + } + + /** + * The name of the browser that will be launched by default. For + * `puppeteer`, this is influenced by your configuration. Otherwise, it's + * `chrome`. + */ + get defaultProduct(): Product { + return this.configuration.defaultProduct ?? 'chrome'; + } + + /** + * @deprecated Do not use as this field as it does not take into account + * multiple browsers of different types. Use + * {@link PuppeteerNode.defaultProduct | defaultProduct} or + * {@link PuppeteerNode.lastLaunchedProduct | lastLaunchedProduct}. + * + * @returns The name of the browser that is under automation. + */ + get product(): string { + return this.#launcher.product; + } + + /** + * @param options - Set of configurable options to set on the browser. + * + * @returns The default flags that Chromium will be launched with. + */ + defaultArgs(options: BrowserLaunchArgumentOptions = {}): string[] { + return this.#launcher.defaultArgs(options); + } + + /** + * Removes all non-current Firefox and Chrome binaries in the cache directory + * identified by the provided Puppeteer configuration. The current browser + * version is determined by resolving PUPPETEER_REVISIONS from Puppeteer + * unless `configuration.browserRevision` is provided. + * + * @remarks + * + * Note that the method does not check if any other Puppeteer versions + * installed on the host that use the same cache directory require the + * non-current binaries. + * + * @public + */ + async trimCache(): Promise<void> { + const platform = detectBrowserPlatform(); + if (!platform) { + throw new Error('The current platform is not supported.'); + } + + const cacheDir = + this.configuration.downloadPath ?? this.configuration.cacheDirectory!; + const installedBrowsers = await getInstalledBrowsers({ + cacheDir, + }); + + const product = this.configuration.defaultProduct!; + + const puppeteerBrowsers: Array<{ + product: Product; + browser: SupportedBrowser; + currentBuildId: string; + }> = [ + { + product: 'chrome', + browser: SupportedBrowser.CHROME, + currentBuildId: '', + }, + { + product: 'firefox', + browser: SupportedBrowser.FIREFOX, + currentBuildId: '', + }, + ]; + + // Resolve current buildIds. + for (const item of puppeteerBrowsers) { + item.currentBuildId = await resolveBuildId( + item.browser, + platform, + (product === item.product + ? this.configuration.browserRevision + : null) || PUPPETEER_REVISIONS[item.product] + ); + } + + const currentBrowserBuilds = new Set( + puppeteerBrowsers.map(browser => { + return `${browser.browser}_${browser.currentBuildId}`; + }) + ); + + const currentBrowsers = new Set( + puppeteerBrowsers.map(browser => { + return browser.browser; + }) + ); + + for (const installedBrowser of installedBrowsers) { + // Don't uninstall browsers that are not managed by Puppeteer yet. + if (!currentBrowsers.has(installedBrowser.browser)) { + continue; + } + // Keep the browser build used by the current Puppeteer installation. + if ( + currentBrowserBuilds.has( + `${installedBrowser.browser}_${installedBrowser.buildId}` + ) + ) { + continue; + } + + await uninstall({ + browser: installedBrowser.browser, + platform, + cacheDir, + buildId: installedBrowser.buildId, + }); + } + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/node/ScreenRecorder.ts b/remote/test/puppeteer/packages/puppeteer-core/src/node/ScreenRecorder.ts new file mode 100644 index 0000000000..effb2d63ba --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/node/ScreenRecorder.ts @@ -0,0 +1,255 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {ChildProcessWithoutNullStreams} from 'child_process'; +import {spawn, spawnSync} from 'child_process'; +import {PassThrough} from 'stream'; + +import debug from 'debug'; + +import type {OperatorFunction} from '../../third_party/rxjs/rxjs.js'; +import { + bufferCount, + concatMap, + filter, + from, + fromEvent, + lastValueFrom, + map, + takeUntil, + tap, +} from '../../third_party/rxjs/rxjs.js'; +import {CDPSessionEvent} from '../api/CDPSession.js'; +import type {BoundingBox} from '../api/ElementHandle.js'; +import type {Page} from '../api/Page.js'; +import {debugError, fromEmitterEvent} from '../common/util.js'; +import {guarded} from '../util/decorators.js'; +import {asyncDisposeSymbol} from '../util/disposable.js'; + +const CRF_VALUE = 30; +const DEFAULT_FPS = 30; + +const debugFfmpeg = debug('puppeteer:ffmpeg'); + +/** + * @internal + */ +export interface ScreenRecorderOptions { + speed?: number; + crop?: BoundingBox; + format?: 'gif' | 'webm'; + scale?: number; + path?: string; +} + +/** + * @public + */ +export class ScreenRecorder extends PassThrough { + #page: Page; + + #process: ChildProcessWithoutNullStreams; + + #controller = new AbortController(); + #lastFrame: Promise<readonly [Buffer, number]>; + + /** + * @internal + */ + constructor( + page: Page, + width: number, + height: number, + {speed, scale, crop, format, path}: ScreenRecorderOptions = {} + ) { + super({allowHalfOpen: false}); + + path ??= 'ffmpeg'; + + // Tests if `ffmpeg` exists. + const {error} = spawnSync(path); + if (error) { + throw error; + } + + this.#process = spawn( + path, + // See https://trac.ffmpeg.org/wiki/Encode/VP9 for more information on flags. + [ + ['-loglevel', 'error'], + // Reduces general buffering. + ['-avioflags', 'direct'], + // Reduces initial buffering while analyzing input fps and other stats. + [ + '-fpsprobesize', + '0', + '-probesize', + '32', + '-analyzeduration', + '0', + '-fflags', + 'nobuffer', + ], + // Forces input to be read from standard input, and forces png input + // image format. + ['-f', 'image2pipe', '-c:v', 'png', '-i', 'pipe:0'], + // Overwrite output and no audio. + ['-y', '-an'], + // This drastically reduces stalling when cpu is overbooked. By default + // VP9 tries to use all available threads? + ['-threads', '1'], + // Specifies the frame rate we are giving ffmpeg. + ['-framerate', `${DEFAULT_FPS}`], + // Specifies the encoding and format we are using. + this.#getFormatArgs(format ?? 'webm'), + // Disable bitrate. + ['-b:v', '0'], + // Filters to ensure the images are piped correctly. + [ + '-vf', + `${ + speed ? `setpts=${1 / speed}*PTS,` : '' + }crop='min(${width},iw):min(${height},ih):0:0',pad=${width}:${height}:0:0${ + crop ? `,crop=${crop.width}:${crop.height}:${crop.x}:${crop.y}` : '' + }${scale ? `,scale=iw*${scale}:-1` : ''}`, + ], + 'pipe:1', + ].flat(), + {stdio: ['pipe', 'pipe', 'pipe']} + ); + this.#process.stdout.pipe(this); + this.#process.stderr.on('data', (data: Buffer) => { + debugFfmpeg(data.toString('utf8')); + }); + + this.#page = page; + + const {client} = this.#page.mainFrame(); + client.once(CDPSessionEvent.Disconnected, () => { + void this.stop().catch(debugError); + }); + + this.#lastFrame = lastValueFrom( + fromEmitterEvent(client, 'Page.screencastFrame').pipe( + tap(event => { + void client.send('Page.screencastFrameAck', { + sessionId: event.sessionId, + }); + }), + filter(event => { + return event.metadata.timestamp !== undefined; + }), + map(event => { + return { + buffer: Buffer.from(event.data, 'base64'), + timestamp: event.metadata.timestamp!, + }; + }), + bufferCount(2, 1) as OperatorFunction< + {buffer: Buffer; timestamp: number}, + [ + {buffer: Buffer; timestamp: number}, + {buffer: Buffer; timestamp: number}, + ] + >, + concatMap(([{timestamp: previousTimestamp, buffer}, {timestamp}]) => { + return from( + Array<Buffer>( + Math.round( + DEFAULT_FPS * Math.max(timestamp - previousTimestamp, 0) + ) + ).fill(buffer) + ); + }), + map(buffer => { + void this.#writeFrame(buffer); + return [buffer, performance.now()] as const; + }), + takeUntil(fromEvent(this.#controller.signal, 'abort')) + ), + {defaultValue: [Buffer.from([]), performance.now()] as const} + ); + } + + #getFormatArgs(format: 'webm' | 'gif') { + switch (format) { + case 'webm': + return [ + // Sets the codec to use. + ['-c:v', 'vp9'], + // Sets the format + ['-f', 'webm'], + // Sets the quality. Lower the better. + ['-crf', `${CRF_VALUE}`], + // Sets the quality and how efficient the compression will be. + ['-deadline', 'realtime', '-cpu-used', '8'], + ].flat(); + case 'gif': + return [ + // Sets the frame rate and uses a custom palette generated from the + // input. + [ + '-vf', + 'fps=5,split[s0][s1];[s0]palettegen=stats_mode=diff[p];[s1][p]paletteuse', + ], + // Sets the format + ['-f', 'gif'], + ].flat(); + } + } + + @guarded() + async #writeFrame(buffer: Buffer) { + const error = await new Promise<Error | null | undefined>(resolve => { + this.#process.stdin.write(buffer, resolve); + }); + if (error) { + console.log(`ffmpeg failed to write: ${error.message}.`); + } + } + + /** + * Stops the recorder. + * + * @public + */ + @guarded() + async stop(): Promise<void> { + if (this.#controller.signal.aborted) { + return; + } + // Stopping the screencast will flush the frames. + await this.#page._stopScreencast().catch(debugError); + + this.#controller.abort(); + + // Repeat the last frame for the remaining frames. + const [buffer, timestamp] = await this.#lastFrame; + await Promise.all( + Array<Buffer>( + Math.max( + 1, + Math.round((DEFAULT_FPS * (performance.now() - timestamp)) / 1000) + ) + ) + .fill(buffer) + .map(this.#writeFrame.bind(this)) + ); + + // Close stdin to notify FFmpeg we are done. + this.#process.stdin.end(); + await new Promise(resolve => { + this.#process.once('close', resolve); + }); + } + + /** + * @internal + */ + async [asyncDisposeSymbol](): Promise<void> { + await this.stop(); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/node/node.ts b/remote/test/puppeteer/packages/puppeteer-core/src/node/node.ts new file mode 100644 index 0000000000..373449ec0f --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/node/node.ts @@ -0,0 +1,13 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './ChromeLauncher.js'; +export * from './FirefoxLauncher.js'; +export * from './LaunchOptions.js'; +export * from './PipeTransport.js'; +export * from './ProductLauncher.js'; +export * from './PuppeteerNode.js'; +export * from './ScreenRecorder.js'; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/node/util/fs.ts b/remote/test/puppeteer/packages/puppeteer-core/src/node/util/fs.ts new file mode 100644 index 0000000000..d18c76d6dc --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/node/util/fs.ts @@ -0,0 +1,27 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'fs'; + +const rmOptions = { + force: true, + recursive: true, + maxRetries: 5, +}; + +/** + * @internal + */ +export async function rm(path: string): Promise<void> { + await fs.promises.rm(path, rmOptions); +} + +/** + * @internal + */ +export function rmSync(path: string): void { + fs.rmSync(path, rmOptions); +} |