summaryrefslogtreecommitdiffstats
path: root/remote/test/puppeteer/packages/puppeteer-core/src/node
diff options
context:
space:
mode:
Diffstat (limited to 'remote/test/puppeteer/packages/puppeteer-core/src/node')
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/node/ChromeLauncher.test.ts59
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/node/ChromeLauncher.ts344
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/node/FirefoxLauncher.test.ts47
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/node/FirefoxLauncher.ts242
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/node/LaunchOptions.ts140
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/node/NodeWebSocketTransport.ts64
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/node/PipeTransport.ts86
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/node/ProductLauncher.ts451
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/node/PuppeteerNode.ts356
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/node/ScreenRecorder.ts255
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/node/node.ts13
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/node/util/fs.ts27
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);
+}