summaryrefslogtreecommitdiffstats
path: root/remote/test/puppeteer/packages/puppeteer-core/src
diff options
context:
space:
mode:
Diffstat (limited to 'remote/test/puppeteer/packages/puppeteer-core/src')
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/api/Browser.ts454
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/api/BrowserContext.ts224
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/api/CDPSession.ts121
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/api/Dialog.ts110
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/api/ElementHandle.ts1580
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/api/ElementHandleSymbol.ts10
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/api/Environment.ts16
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/api/Frame.ts1218
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/api/HTTPRequest.ts521
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/api/HTTPResponse.ts129
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/api/Input.ts517
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/api/JSHandle.ts212
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/api/Page.ts3090
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/api/Realm.ts104
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/api/Target.ts95
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/api/WebWorker.ts134
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/api/api.ts22
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/api/locators/locators.ts1088
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/BidiOverCdp.ts209
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/Browser.ts317
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/BrowserConnector.ts123
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/BrowserContext.ts145
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/BrowsingContext.ts187
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/Connection.test.ts50
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/Connection.ts256
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/Deserializer.ts96
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/Dialog.ts45
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/ElementHandle.ts87
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/EmulationManager.ts35
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/ExposedFunction.ts295
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/Frame.ts313
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/HTTPRequest.ts163
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/HTTPResponse.ts107
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/Input.ts732
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/JSHandle.ts101
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/NetworkManager.ts155
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/Page.ts913
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/Realm.ts228
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/Sandbox.ts123
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/Serializer.ts164
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/Target.ts151
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/bidi.ts22
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Browser.ts225
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/BrowsingContext.ts475
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Connection.ts139
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Navigation.ts144
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Realm.ts351
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Request.ts148
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Session.ts180
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/UserContext.ts178
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/UserPrompt.ts137
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/core.ts15
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/lifecycle.ts119
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/bidi/util.ts81
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/cdp/Accessibility.ts579
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/cdp/AriaQueryHandler.ts120
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/cdp/Binding.ts118
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/cdp/Browser.ts523
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/cdp/BrowserConnector.ts66
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/cdp/CDPSession.ts167
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/cdp/ChromeTargetManager.ts417
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/cdp/Connection.ts273
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/cdp/Coverage.ts513
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/cdp/DeviceRequestPrompt.test.ts471
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/cdp/DeviceRequestPrompt.ts280
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/cdp/Dialog.ts37
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/cdp/ElementHandle.ts172
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/cdp/EmulationManager.ts554
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/cdp/ExecutionContext.ts392
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/cdp/FirefoxTargetManager.ts210
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/cdp/Frame.ts351
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/cdp/FrameManager.ts551
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/cdp/FrameManagerEvents.ts39
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/cdp/FrameTree.ts98
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/cdp/HTTPRequest.ts449
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/cdp/HTTPResponse.ts173
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/cdp/Input.ts604
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/cdp/IsolatedWorld.ts273
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/cdp/IsolatedWorlds.ts20
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/cdp/JSHandle.ts109
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/cdp/LifecycleWatcher.ts298
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/cdp/NetworkEventManager.ts217
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/cdp/NetworkManager.test.ts1531
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/cdp/NetworkManager.ts710
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/cdp/Page.ts1249
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/cdp/PredefinedNetworkConditions.ts49
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/cdp/Target.ts305
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/cdp/TargetManager.ts65
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/cdp/Tracing.ts140
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/cdp/WebWorker.ts83
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/cdp/cdp.ts42
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/cdp/utils.ts232
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/common/BrowserConnector.ts114
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/common/BrowserWebSocketTransport.ts50
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/common/CallbackRegistry.ts177
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/common/Configuration.ts120
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/common/ConnectOptions.ts77
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/common/ConnectionTransport.ts15
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/common/ConsoleMessage.ts113
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/common/CustomQueryHandler.ts207
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/common/Debug.ts128
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/common/Device.ts1552
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/common/Errors.ts124
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/common/EventEmitter.test.ts185
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/common/EventEmitter.ts253
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/common/FileChooser.ts92
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/common/GetQueryHandler.ts49
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/common/HandleIterator.ts76
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/common/LazyArg.ts37
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/common/NetworkManagerEvents.ts38
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/common/PDFOptions.ts217
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/common/PQueryHandler.ts31
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/common/PierceQueryHandler.ts29
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/common/Product.ts11
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/common/Puppeteer.ts123
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/common/QueryHandler.ts205
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/common/ScriptInjector.ts52
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/common/SecurityDetails.ts78
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/common/TaskQueue.ts29
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/common/TextQueryHandler.ts20
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/common/TimeoutSettings.ts45
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/common/USKeyboardLayout.ts671
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/common/Viewport.ts50
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/common/WaitTask.ts275
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/common/XPathQueryHandler.ts35
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/common/common.ts40
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/common/fetch.ts14
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/common/types.ts225
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/common/util.ts447
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/environment.ts10
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/injected/ARIAQuerySelector.ts31
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/injected/CustomQuerySelector.ts59
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/injected/PQuerySelector.ts298
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/injected/PSelectorParser.ts105
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/injected/PierceQuerySelector.ts65
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/injected/Poller.ts168
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/injected/TextContent.ts146
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/injected/TextQuerySelector.ts46
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/injected/XPathQuerySelector.ts39
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/injected/injected.ts51
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/injected/util.ts67
-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
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/puppeteer-core.ts49
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/revisions.ts14
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/templates/injected.ts.tmpl8
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/templates/version.ts.tmpl4
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/tsconfig.cjs.json9
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/tsconfig.esm.json7
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/util/AsyncIterableUtil.ts46
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/util/Deferred.test.ts68
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/util/Deferred.ts122
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/util/ErrorLike.ts66
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/util/Function.test.ts36
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/util/Function.ts91
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/util/Mutex.ts41
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/util/assert.ts21
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/util/decorators.test.ts79
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/util/decorators.ts140
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/util/disposable.ts275
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/util/util.ts11
171 files changed, 39594 insertions, 0 deletions
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/Browser.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/Browser.ts
new file mode 100644
index 0000000000..e3b465c80e
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/Browser.ts
@@ -0,0 +1,454 @@
+/**
+ * @license
+ * Copyright 2017 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {ChildProcess} from 'child_process';
+
+import type {Protocol} from 'devtools-protocol';
+
+import {
+ filterAsync,
+ firstValueFrom,
+ from,
+ merge,
+ raceWith,
+} from '../../third_party/rxjs/rxjs.js';
+import type {ProtocolType} from '../common/ConnectOptions.js';
+import {EventEmitter, type EventType} from '../common/EventEmitter.js';
+import {debugError, fromEmitterEvent, timeout} from '../common/util.js';
+import {asyncDisposeSymbol, disposeSymbol} from '../util/disposable.js';
+
+import type {BrowserContext} from './BrowserContext.js';
+import type {Page} from './Page.js';
+import type {Target} from './Target.js';
+/**
+ * @public
+ */
+export interface BrowserContextOptions {
+ /**
+ * Proxy server with optional port to use for all requests.
+ * Username and password can be set in `Page.authenticate`.
+ */
+ proxyServer?: string;
+ /**
+ * Bypass the proxy for the given list of hosts.
+ */
+ proxyBypassList?: string[];
+}
+
+/**
+ * @internal
+ */
+export type BrowserCloseCallback = () => Promise<void> | void;
+
+/**
+ * @public
+ */
+export type TargetFilterCallback = (target: Target) => boolean;
+
+/**
+ * @internal
+ */
+export type IsPageTargetCallback = (target: Target) => boolean;
+
+/**
+ * @internal
+ */
+export const WEB_PERMISSION_TO_PROTOCOL_PERMISSION = new Map<
+ Permission,
+ Protocol.Browser.PermissionType
+>([
+ ['geolocation', 'geolocation'],
+ ['midi', 'midi'],
+ ['notifications', 'notifications'],
+ // TODO: push isn't a valid type?
+ // ['push', 'push'],
+ ['camera', 'videoCapture'],
+ ['microphone', 'audioCapture'],
+ ['background-sync', 'backgroundSync'],
+ ['ambient-light-sensor', 'sensors'],
+ ['accelerometer', 'sensors'],
+ ['gyroscope', 'sensors'],
+ ['magnetometer', 'sensors'],
+ ['accessibility-events', 'accessibilityEvents'],
+ ['clipboard-read', 'clipboardReadWrite'],
+ ['clipboard-write', 'clipboardReadWrite'],
+ ['clipboard-sanitized-write', 'clipboardSanitizedWrite'],
+ ['payment-handler', 'paymentHandler'],
+ ['persistent-storage', 'durableStorage'],
+ ['idle-detection', 'idleDetection'],
+ // chrome-specific permissions we have.
+ ['midi-sysex', 'midiSysex'],
+]);
+
+/**
+ * @public
+ */
+export type Permission =
+ | 'geolocation'
+ | 'midi'
+ | 'notifications'
+ | 'camera'
+ | 'microphone'
+ | 'background-sync'
+ | 'ambient-light-sensor'
+ | 'accelerometer'
+ | 'gyroscope'
+ | 'magnetometer'
+ | 'accessibility-events'
+ | 'clipboard-read'
+ | 'clipboard-write'
+ | 'clipboard-sanitized-write'
+ | 'payment-handler'
+ | 'persistent-storage'
+ | 'idle-detection'
+ | 'midi-sysex';
+
+/**
+ * @public
+ */
+export interface WaitForTargetOptions {
+ /**
+ * Maximum wait time in milliseconds. Pass `0` to disable the timeout.
+ *
+ * @defaultValue `30_000`
+ */
+ timeout?: number;
+}
+
+/**
+ * All the events a {@link Browser | browser instance} may emit.
+ *
+ * @public
+ */
+export const enum BrowserEvent {
+ /**
+ * Emitted when Puppeteer gets disconnected from the browser instance. This
+ * might happen because either:
+ *
+ * - The browser closes/crashes or
+ * - {@link Browser.disconnect} was called.
+ */
+ Disconnected = 'disconnected',
+ /**
+ * Emitted when the URL of a target changes. Contains a {@link Target}
+ * instance.
+ *
+ * @remarks Note that this includes target changes in incognito browser
+ * contexts.
+ */
+ TargetChanged = 'targetchanged',
+ /**
+ * Emitted when a target is created, for example when a new page is opened by
+ * {@link https://developer.mozilla.org/en-US/docs/Web/API/Window/open | window.open}
+ * or by {@link Browser.newPage | browser.newPage}
+ *
+ * Contains a {@link Target} instance.
+ *
+ * @remarks Note that this includes target creations in incognito browser
+ * contexts.
+ */
+ TargetCreated = 'targetcreated',
+ /**
+ * Emitted when a target is destroyed, for example when a page is closed.
+ * Contains a {@link Target} instance.
+ *
+ * @remarks Note that this includes target destructions in incognito browser
+ * contexts.
+ */
+ TargetDestroyed = 'targetdestroyed',
+ /**
+ * @internal
+ */
+ TargetDiscovered = 'targetdiscovered',
+}
+
+export {
+ /**
+ * @deprecated Use {@link BrowserEvent}.
+ */
+ BrowserEvent as BrowserEmittedEvents,
+};
+
+/**
+ * @public
+ */
+export interface BrowserEvents extends Record<EventType, unknown> {
+ [BrowserEvent.Disconnected]: undefined;
+ [BrowserEvent.TargetCreated]: Target;
+ [BrowserEvent.TargetDestroyed]: Target;
+ [BrowserEvent.TargetChanged]: Target;
+ /**
+ * @internal
+ */
+ [BrowserEvent.TargetDiscovered]: Protocol.Target.TargetInfo;
+}
+
+/**
+ * @public
+ * @experimental
+ */
+export interface DebugInfo {
+ pendingProtocolErrors: Error[];
+}
+
+/**
+ * {@link Browser} represents a browser instance that is either:
+ *
+ * - connected to via {@link Puppeteer.connect} or
+ * - launched by {@link PuppeteerNode.launch}.
+ *
+ * {@link Browser} {@link EventEmitter | emits} various events which are
+ * documented in the {@link BrowserEvent} enum.
+ *
+ * @example Using a {@link Browser} to create a {@link Page}:
+ *
+ * ```ts
+ * import puppeteer from 'puppeteer';
+ *
+ * const browser = await puppeteer.launch();
+ * const page = await browser.newPage();
+ * await page.goto('https://example.com');
+ * await browser.close();
+ * ```
+ *
+ * @example Disconnecting from and reconnecting to a {@link Browser}:
+ *
+ * ```ts
+ * import puppeteer from 'puppeteer';
+ *
+ * const browser = await puppeteer.launch();
+ * // Store the endpoint to be able to reconnect to the browser.
+ * const browserWSEndpoint = browser.wsEndpoint();
+ * // Disconnect puppeteer from the browser.
+ * await browser.disconnect();
+ *
+ * // Use the endpoint to reestablish a connection
+ * const browser2 = await puppeteer.connect({browserWSEndpoint});
+ * // Close the browser.
+ * await browser2.close();
+ * ```
+ *
+ * @public
+ */
+export abstract class Browser extends EventEmitter<BrowserEvents> {
+ /**
+ * @internal
+ */
+ constructor() {
+ super();
+ }
+
+ /**
+ * Gets the associated
+ * {@link https://nodejs.org/api/child_process.html#class-childprocess | ChildProcess}.
+ *
+ * @returns `null` if this instance was connected to via
+ * {@link Puppeteer.connect}.
+ */
+ abstract process(): ChildProcess | null;
+
+ /**
+ * Creates a new incognito {@link BrowserContext | browser context}.
+ *
+ * This won't share cookies/cache with other {@link BrowserContext | browser contexts}.
+ *
+ * @example
+ *
+ * ```ts
+ * import puppeteer from 'puppeteer';
+ *
+ * const browser = await puppeteer.launch();
+ * // Create a new incognito browser context.
+ * const context = await browser.createIncognitoBrowserContext();
+ * // Create a new page in a pristine context.
+ * const page = await context.newPage();
+ * // Do stuff
+ * await page.goto('https://example.com');
+ * ```
+ */
+ abstract createIncognitoBrowserContext(
+ options?: BrowserContextOptions
+ ): Promise<BrowserContext>;
+
+ /**
+ * Gets a list of open {@link BrowserContext | browser contexts}.
+ *
+ * In a newly-created {@link Browser | browser}, this will return a single
+ * instance of {@link BrowserContext}.
+ */
+ abstract browserContexts(): BrowserContext[];
+
+ /**
+ * Gets the default {@link BrowserContext | browser context}.
+ *
+ * @remarks The default {@link BrowserContext | browser context} cannot be
+ * closed.
+ */
+ abstract defaultBrowserContext(): BrowserContext;
+
+ /**
+ * Gets the WebSocket URL to connect to this {@link Browser | browser}.
+ *
+ * This is usually used with {@link Puppeteer.connect}.
+ *
+ * You can find the debugger URL (`webSocketDebuggerUrl`) from
+ * `http://HOST:PORT/json/version`.
+ *
+ * See {@link
+ * https://chromedevtools.github.io/devtools-protocol/#how-do-i-access-the-browser-target
+ * | browser endpoint} for more information.
+ *
+ * @remarks The format is always `ws://HOST:PORT/devtools/browser/<id>`.
+ */
+ abstract wsEndpoint(): string;
+
+ /**
+ * Creates a new {@link Page | page} in the
+ * {@link Browser.defaultBrowserContext | default browser context}.
+ */
+ abstract newPage(): Promise<Page>;
+
+ /**
+ * Gets all active {@link Target | targets}.
+ *
+ * In case of multiple {@link BrowserContext | browser contexts}, this returns
+ * all {@link Target | targets} in all
+ * {@link BrowserContext | browser contexts}.
+ */
+ abstract targets(): Target[];
+
+ /**
+ * Gets the {@link Target | target} associated with the
+ * {@link Browser.defaultBrowserContext | default browser context}).
+ */
+ abstract target(): Target;
+
+ /**
+ * Waits until a {@link Target | target} matching the given `predicate`
+ * appears and returns it.
+ *
+ * This will look all open {@link BrowserContext | browser contexts}.
+ *
+ * @example Finding a target for a page opened via `window.open`:
+ *
+ * ```ts
+ * await page.evaluate(() => window.open('https://www.example.com/'));
+ * const newWindowTarget = await browser.waitForTarget(
+ * target => target.url() === 'https://www.example.com/'
+ * );
+ * ```
+ */
+ async waitForTarget(
+ predicate: (x: Target) => boolean | Promise<boolean>,
+ options: WaitForTargetOptions = {}
+ ): Promise<Target> {
+ const {timeout: ms = 30000} = options;
+ return await firstValueFrom(
+ merge(
+ fromEmitterEvent(this, BrowserEvent.TargetCreated),
+ fromEmitterEvent(this, BrowserEvent.TargetChanged),
+ from(this.targets())
+ ).pipe(filterAsync(predicate), raceWith(timeout(ms)))
+ );
+ }
+
+ /**
+ * Gets a list of all open {@link Page | pages} inside this {@link Browser}.
+ *
+ * If there ar multiple {@link BrowserContext | browser contexts}, this
+ * returns all {@link Page | pages} in all
+ * {@link BrowserContext | browser contexts}.
+ *
+ * @remarks Non-visible {@link Page | pages}, such as `"background_page"`,
+ * will not be listed here. You can find them using {@link Target.page}.
+ */
+ async pages(): Promise<Page[]> {
+ const contextPages = await Promise.all(
+ this.browserContexts().map(context => {
+ return context.pages();
+ })
+ );
+ // Flatten array.
+ return contextPages.reduce((acc, x) => {
+ return acc.concat(x);
+ }, []);
+ }
+
+ /**
+ * Gets a string representing this {@link Browser | browser's} name and
+ * version.
+ *
+ * For headless browser, this is similar to `"HeadlessChrome/61.0.3153.0"`. For
+ * non-headless or new-headless, this is similar to `"Chrome/61.0.3153.0"`. For
+ * Firefox, it is similar to `"Firefox/116.0a1"`.
+ *
+ * The format of {@link Browser.version} might change with future releases of
+ * browsers.
+ */
+ abstract version(): Promise<string>;
+
+ /**
+ * Gets this {@link Browser | browser's} original user agent.
+ *
+ * {@link Page | Pages} can override the user agent with
+ * {@link Page.setUserAgent}.
+ *
+ */
+ abstract userAgent(): Promise<string>;
+
+ /**
+ * Closes this {@link Browser | browser} and all associated
+ * {@link Page | pages}.
+ */
+ abstract close(): Promise<void>;
+
+ /**
+ * Disconnects Puppeteer from this {@link Browser | browser}, but leaves the
+ * process running.
+ */
+ abstract disconnect(): Promise<void>;
+
+ /**
+ * Whether Puppeteer is connected to this {@link Browser | browser}.
+ *
+ * @deprecated Use {@link Browser | Browser.connected}.
+ */
+ isConnected(): boolean {
+ return this.connected;
+ }
+
+ /**
+ * Whether Puppeteer is connected to this {@link Browser | browser}.
+ */
+ abstract get connected(): boolean;
+
+ /** @internal */
+ [disposeSymbol](): void {
+ return void this.close().catch(debugError);
+ }
+
+ /** @internal */
+ [asyncDisposeSymbol](): Promise<void> {
+ return this.close();
+ }
+
+ /**
+ * @internal
+ */
+ abstract get protocol(): ProtocolType;
+
+ /**
+ * Get debug information from Puppeteer.
+ *
+ * @remarks
+ *
+ * Currently, includes pending protocol calls. In the future, we might add more info.
+ *
+ * @public
+ * @experimental
+ */
+ abstract get debugInfo(): DebugInfo;
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/BrowserContext.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/BrowserContext.ts
new file mode 100644
index 0000000000..79335eb9ed
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/BrowserContext.ts
@@ -0,0 +1,224 @@
+/**
+ * @license
+ * Copyright 2017 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {EventEmitter, type EventType} from '../common/EventEmitter.js';
+import {debugError} from '../common/util.js';
+import {asyncDisposeSymbol, disposeSymbol} from '../util/disposable.js';
+
+import type {Browser, Permission, WaitForTargetOptions} from './Browser.js';
+import type {Page} from './Page.js';
+import type {Target} from './Target.js';
+
+/**
+ * @public
+ */
+export const enum BrowserContextEvent {
+ /**
+ * Emitted when the url of a target inside the browser context changes.
+ * Contains a {@link Target} instance.
+ */
+ TargetChanged = 'targetchanged',
+
+ /**
+ * Emitted when a target is created within the browser context, for example
+ * when a new page is opened by
+ * {@link https://developer.mozilla.org/en-US/docs/Web/API/Window/open | window.open}
+ * or by {@link BrowserContext.newPage | browserContext.newPage}
+ *
+ * Contains a {@link Target} instance.
+ */
+ TargetCreated = 'targetcreated',
+ /**
+ * Emitted when a target is destroyed within the browser context, for example
+ * when a page is closed. Contains a {@link Target} instance.
+ */
+ TargetDestroyed = 'targetdestroyed',
+}
+
+export {
+ /**
+ * @deprecated Use {@link BrowserContextEvent}
+ */
+ BrowserContextEvent as BrowserContextEmittedEvents,
+};
+
+/**
+ * @public
+ */
+export interface BrowserContextEvents extends Record<EventType, unknown> {
+ [BrowserContextEvent.TargetChanged]: Target;
+ [BrowserContextEvent.TargetCreated]: Target;
+ [BrowserContextEvent.TargetDestroyed]: Target;
+}
+
+/**
+ * {@link BrowserContext} represents individual sessions within a
+ * {@link Browser | browser}.
+ *
+ * When a {@link Browser | browser} is launched, it has a single
+ * {@link BrowserContext | browser context} by default. Others can be created
+ * using {@link Browser.createIncognitoBrowserContext}.
+ *
+ * {@link BrowserContext} {@link EventEmitter | emits} various events which are
+ * documented in the {@link BrowserContextEvent} enum.
+ *
+ * If a {@link Page | page} opens another {@link Page | page}, e.g. using
+ * `window.open`, the popup will belong to the parent {@link Page.browserContext
+ * | page's browser context}.
+ *
+ * @example Creating an incognito {@link BrowserContext | browser context}:
+ *
+ * ```ts
+ * // Create a new incognito browser context
+ * const context = await browser.createIncognitoBrowserContext();
+ * // Create a new page inside context.
+ * const page = await context.newPage();
+ * // ... do stuff with page ...
+ * await page.goto('https://example.com');
+ * // Dispose context once it's no longer needed.
+ * await context.close();
+ * ```
+ *
+ * @public
+ */
+
+export abstract class BrowserContext extends EventEmitter<BrowserContextEvents> {
+ /**
+ * @internal
+ */
+ constructor() {
+ super();
+ }
+
+ /**
+ * Gets all active {@link Target | targets} inside this
+ * {@link BrowserContext | browser context}.
+ */
+ abstract targets(): Target[];
+
+ /**
+ * Waits until a {@link Target | target} matching the given `predicate`
+ * appears and returns it.
+ *
+ * This will look all open {@link BrowserContext | browser contexts}.
+ *
+ * @example Finding a target for a page opened via `window.open`:
+ *
+ * ```ts
+ * await page.evaluate(() => window.open('https://www.example.com/'));
+ * const newWindowTarget = await browserContext.waitForTarget(
+ * target => target.url() === 'https://www.example.com/'
+ * );
+ * ```
+ */
+ abstract waitForTarget(
+ predicate: (x: Target) => boolean | Promise<boolean>,
+ options?: WaitForTargetOptions
+ ): Promise<Target>;
+
+ /**
+ * Gets a list of all open {@link Page | pages} inside this
+ * {@link BrowserContext | browser context}.
+ *
+ * @remarks Non-visible {@link Page | pages}, such as `"background_page"`,
+ * will not be listed here. You can find them using {@link Target.page}.
+ */
+ abstract pages(): Promise<Page[]>;
+
+ /**
+ * Whether this {@link BrowserContext | browser context} is incognito.
+ *
+ * The {@link Browser.defaultBrowserContext | default browser context} is the
+ * only non-incognito browser context.
+ */
+ abstract isIncognito(): boolean;
+
+ /**
+ * Grants this {@link BrowserContext | browser context} the given
+ * `permissions` within the given `origin`.
+ *
+ * @example Overriding permissions in the
+ * {@link Browser.defaultBrowserContext | default browser context}:
+ *
+ * ```ts
+ * const context = browser.defaultBrowserContext();
+ * await context.overridePermissions('https://html5demos.com', [
+ * 'geolocation',
+ * ]);
+ * ```
+ *
+ * @param origin - The origin to grant permissions to, e.g.
+ * "https://example.com".
+ * @param permissions - An array of permissions to grant. All permissions that
+ * are not listed here will be automatically denied.
+ */
+ abstract overridePermissions(
+ origin: string,
+ permissions: Permission[]
+ ): Promise<void>;
+
+ /**
+ * Clears all permission overrides for this
+ * {@link BrowserContext | browser context}.
+ *
+ * @example Clearing overridden permissions in the
+ * {@link Browser.defaultBrowserContext | default browser context}:
+ *
+ * ```ts
+ * const context = browser.defaultBrowserContext();
+ * context.overridePermissions('https://example.com', ['clipboard-read']);
+ * // do stuff ..
+ * context.clearPermissionOverrides();
+ * ```
+ */
+ abstract clearPermissionOverrides(): Promise<void>;
+
+ /**
+ * Creates a new {@link Page | page} in this
+ * {@link BrowserContext | browser context}.
+ */
+ abstract newPage(): Promise<Page>;
+
+ /**
+ * Gets the {@link Browser | browser} associated with this
+ * {@link BrowserContext | browser context}.
+ */
+ abstract browser(): Browser;
+
+ /**
+ * Closes this {@link BrowserContext | browser context} and all associated
+ * {@link Page | pages}.
+ *
+ * @remarks The
+ * {@link Browser.defaultBrowserContext | default browser context} cannot be
+ * closed.
+ */
+ abstract close(): Promise<void>;
+
+ /**
+ * Whether this {@link BrowserContext | browser context} is closed.
+ */
+ get closed(): boolean {
+ return !this.browser().browserContexts().includes(this);
+ }
+
+ /**
+ * Identifier for this {@link BrowserContext | browser context}.
+ */
+ get id(): string | undefined {
+ return undefined;
+ }
+
+ /** @internal */
+ [disposeSymbol](): void {
+ return void this.close().catch(debugError);
+ }
+
+ /** @internal */
+ [asyncDisposeSymbol](): Promise<void> {
+ return this.close();
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/CDPSession.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/CDPSession.ts
new file mode 100644
index 0000000000..8bdf96f954
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/CDPSession.ts
@@ -0,0 +1,121 @@
+import type {ProtocolMapping} from 'devtools-protocol/types/protocol-mapping.js';
+
+import type {Connection} from '../cdp/Connection.js';
+import {EventEmitter, type EventType} from '../common/EventEmitter.js';
+
+/**
+ * @public
+ */
+export type CDPEvents = {
+ [Property in keyof ProtocolMapping.Events]: ProtocolMapping.Events[Property][0];
+};
+
+/**
+ * Events that the CDPSession class emits.
+ *
+ * @public
+ */
+// eslint-disable-next-line @typescript-eslint/no-namespace
+export namespace CDPSessionEvent {
+ /** @internal */
+ export const Disconnected = Symbol('CDPSession.Disconnected');
+ /** @internal */
+ export const Swapped = Symbol('CDPSession.Swapped');
+ /**
+ * Emitted when the session is ready to be configured during the auto-attach
+ * process. Right after the event is handled, the session will be resumed.
+ *
+ * @internal
+ */
+ export const Ready = Symbol('CDPSession.Ready');
+ export const SessionAttached = 'sessionattached' as const;
+ export const SessionDetached = 'sessiondetached' as const;
+}
+
+/**
+ * @public
+ */
+export interface CDPSessionEvents
+ extends CDPEvents,
+ Record<EventType, unknown> {
+ /** @internal */
+ [CDPSessionEvent.Disconnected]: undefined;
+ /** @internal */
+ [CDPSessionEvent.Swapped]: CDPSession;
+ /** @internal */
+ [CDPSessionEvent.Ready]: CDPSession;
+ [CDPSessionEvent.SessionAttached]: CDPSession;
+ [CDPSessionEvent.SessionDetached]: CDPSession;
+}
+
+/**
+ * @public
+ */
+export interface CommandOptions {
+ timeout: number;
+}
+
+/**
+ * The `CDPSession` instances are used to talk raw Chrome Devtools Protocol.
+ *
+ * @remarks
+ *
+ * Protocol methods can be called with {@link CDPSession.send} method and protocol
+ * events can be subscribed to with `CDPSession.on` method.
+ *
+ * Useful links: {@link https://chromedevtools.github.io/devtools-protocol/ | DevTools Protocol Viewer}
+ * and {@link https://github.com/aslushnikov/getting-started-with-cdp/blob/HEAD/README.md | Getting Started with DevTools Protocol}.
+ *
+ * @example
+ *
+ * ```ts
+ * const client = await page.target().createCDPSession();
+ * await client.send('Animation.enable');
+ * client.on('Animation.animationCreated', () =>
+ * console.log('Animation created!')
+ * );
+ * const response = await client.send('Animation.getPlaybackRate');
+ * console.log('playback rate is ' + response.playbackRate);
+ * await client.send('Animation.setPlaybackRate', {
+ * playbackRate: response.playbackRate / 2,
+ * });
+ * ```
+ *
+ * @public
+ */
+export abstract class CDPSession extends EventEmitter<CDPSessionEvents> {
+ /**
+ * @internal
+ */
+ constructor() {
+ super();
+ }
+
+ abstract connection(): Connection | undefined;
+
+ /**
+ * Parent session in terms of CDP's auto-attach mechanism.
+ *
+ * @internal
+ */
+ parentSession(): CDPSession | undefined {
+ return undefined;
+ }
+
+ abstract send<T extends keyof ProtocolMapping.Commands>(
+ method: T,
+ params?: ProtocolMapping.Commands[T]['paramsType'][0],
+ options?: CommandOptions
+ ): Promise<ProtocolMapping.Commands[T]['returnType']>;
+
+ /**
+ * Detaches the cdpSession from the target. Once detached, the cdpSession object
+ * won't emit any events and can't be used to send messages.
+ */
+ abstract detach(): Promise<void>;
+
+ /**
+ * Returns the session's id.
+ */
+ abstract id(): string;
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/Dialog.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/Dialog.ts
new file mode 100644
index 0000000000..352337f30f
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/Dialog.ts
@@ -0,0 +1,110 @@
+/**
+ * @license
+ * Copyright 2017 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {Protocol} from 'devtools-protocol';
+
+import {assert} from '../util/assert.js';
+
+/**
+ * Dialog instances are dispatched by the {@link Page} via the `dialog` event.
+ *
+ * @remarks
+ *
+ * @example
+ *
+ * ```ts
+ * import puppeteer from 'puppeteer';
+ *
+ * (async () => {
+ * const browser = await puppeteer.launch();
+ * const page = await browser.newPage();
+ * page.on('dialog', async dialog => {
+ * console.log(dialog.message());
+ * await dialog.dismiss();
+ * await browser.close();
+ * });
+ * page.evaluate(() => alert('1'));
+ * })();
+ * ```
+ *
+ * @public
+ */
+export abstract class Dialog {
+ #type: Protocol.Page.DialogType;
+ #message: string;
+ #defaultValue: string;
+ #handled = false;
+
+ /**
+ * @internal
+ */
+ constructor(
+ type: Protocol.Page.DialogType,
+ message: string,
+ defaultValue = ''
+ ) {
+ this.#type = type;
+ this.#message = message;
+ this.#defaultValue = defaultValue;
+ }
+
+ /**
+ * The type of the dialog.
+ */
+ type(): Protocol.Page.DialogType {
+ return this.#type;
+ }
+
+ /**
+ * The message displayed in the dialog.
+ */
+ message(): string {
+ return this.#message;
+ }
+
+ /**
+ * The default value of the prompt, or an empty string if the dialog
+ * is not a `prompt`.
+ */
+ defaultValue(): string {
+ return this.#defaultValue;
+ }
+
+ /**
+ * @internal
+ */
+ protected abstract handle(options: {
+ accept: boolean;
+ text?: string;
+ }): Promise<void>;
+
+ /**
+ * A promise that resolves when the dialog has been accepted.
+ *
+ * @param promptText - optional text that will be entered in the dialog
+ * prompt. Has no effect if the dialog's type is not `prompt`.
+ *
+ */
+ async accept(promptText?: string): Promise<void> {
+ assert(!this.#handled, 'Cannot accept dialog which is already handled!');
+ this.#handled = true;
+ await this.handle({
+ accept: true,
+ text: promptText,
+ });
+ }
+
+ /**
+ * A promise which will resolve once the dialog has been dismissed
+ */
+ async dismiss(): Promise<void> {
+ assert(!this.#handled, 'Cannot dismiss dialog which is already handled!');
+ this.#handled = true;
+ await this.handle({
+ accept: false,
+ });
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/ElementHandle.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/ElementHandle.ts
new file mode 100644
index 0000000000..43fec58e37
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/ElementHandle.ts
@@ -0,0 +1,1580 @@
+/**
+ * @license
+ * Copyright 2023 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {Protocol} from 'devtools-protocol';
+
+import type {Frame} from '../api/Frame.js';
+import {getQueryHandlerAndSelector} from '../common/GetQueryHandler.js';
+import {LazyArg} from '../common/LazyArg.js';
+import type {
+ ElementFor,
+ EvaluateFuncWith,
+ HandleFor,
+ HandleOr,
+ NodeFor,
+} from '../common/types.js';
+import type {KeyInput} from '../common/USKeyboardLayout.js';
+import {
+ debugError,
+ isString,
+ withSourcePuppeteerURLIfNone,
+} from '../common/util.js';
+import {assert} from '../util/assert.js';
+import {AsyncIterableUtil} from '../util/AsyncIterableUtil.js';
+import {throwIfDisposed} from '../util/decorators.js';
+import {AsyncDisposableStack} from '../util/disposable.js';
+
+import {_isElementHandle} from './ElementHandleSymbol.js';
+import type {
+ KeyboardTypeOptions,
+ KeyPressOptions,
+ MouseClickOptions,
+} from './Input.js';
+import {JSHandle} from './JSHandle.js';
+import type {ScreenshotOptions, WaitForSelectorOptions} from './Page.js';
+
+/**
+ * @public
+ */
+export type Quad = [Point, Point, Point, Point];
+
+/**
+ * @public
+ */
+export interface BoxModel {
+ content: Quad;
+ padding: Quad;
+ border: Quad;
+ margin: Quad;
+ width: number;
+ height: number;
+}
+
+/**
+ * @public
+ */
+export interface BoundingBox extends Point {
+ /**
+ * the width of the element in pixels.
+ */
+ width: number;
+ /**
+ * the height of the element in pixels.
+ */
+ height: number;
+}
+
+/**
+ * @public
+ */
+export interface Offset {
+ /**
+ * x-offset for the clickable point relative to the top-left corner of the border box.
+ */
+ x: number;
+ /**
+ * y-offset for the clickable point relative to the top-left corner of the border box.
+ */
+ y: number;
+}
+
+/**
+ * @public
+ */
+export interface ClickOptions extends MouseClickOptions {
+ /**
+ * Offset for the clickable point relative to the top-left corner of the border box.
+ */
+ offset?: Offset;
+}
+
+/**
+ * @public
+ */
+export interface Point {
+ x: number;
+ y: number;
+}
+
+/**
+ * @public
+ */
+export interface ElementScreenshotOptions extends ScreenshotOptions {
+ /**
+ * @defaultValue `true`
+ */
+ scrollIntoView?: boolean;
+}
+
+/**
+ * ElementHandle represents an in-page DOM element.
+ *
+ * @remarks
+ * ElementHandles can be created with the {@link Page.$} method.
+ *
+ * ```ts
+ * import puppeteer from 'puppeteer';
+ *
+ * (async () => {
+ * const browser = await puppeteer.launch();
+ * const page = await browser.newPage();
+ * await page.goto('https://example.com');
+ * const hrefElement = await page.$('a');
+ * await hrefElement.click();
+ * // ...
+ * })();
+ * ```
+ *
+ * ElementHandle prevents the DOM element from being garbage-collected unless the
+ * handle is {@link JSHandle.dispose | disposed}. ElementHandles are auto-disposed
+ * when their origin frame gets navigated.
+ *
+ * ElementHandle instances can be used as arguments in {@link Page.$eval} and
+ * {@link Page.evaluate} methods.
+ *
+ * If you're using TypeScript, ElementHandle takes a generic argument that
+ * denotes the type of element the handle is holding within. For example, if you
+ * have a handle to a `<select>` element, you can type it as
+ * `ElementHandle<HTMLSelectElement>` and you get some nicer type checks.
+ *
+ * @public
+ */
+export abstract class ElementHandle<
+ ElementType extends Node = Element,
+> extends JSHandle<ElementType> {
+ /**
+ * @internal
+ */
+ declare [_isElementHandle]: boolean;
+
+ /**
+ * A given method will have it's `this` replaced with an isolated version of
+ * `this` when decorated with this decorator.
+ *
+ * All changes of isolated `this` are reflected on the actual `this`.
+ *
+ * @internal
+ */
+ static bindIsolatedHandle<This extends ElementHandle<Node>>(
+ target: (this: This, ...args: any[]) => Promise<any>,
+ _: unknown
+ ): typeof target {
+ return async function (...args) {
+ // If the handle is already isolated, then we don't need to adopt it
+ // again.
+ if (this.realm === this.frame.isolatedRealm()) {
+ return await target.call(this, ...args);
+ }
+ using adoptedThis = await this.frame.isolatedRealm().adoptHandle(this);
+ const result = await target.call(adoptedThis, ...args);
+ // If the function returns `adoptedThis`, then we return `this`.
+ if (result === adoptedThis) {
+ return this;
+ }
+ // If the function returns a handle, transfer it into the current realm.
+ if (result instanceof JSHandle) {
+ return await this.realm.transferHandle(result);
+ }
+ // If the function returns an array of handlers, transfer them into the
+ // current realm.
+ if (Array.isArray(result)) {
+ await Promise.all(
+ result.map(async (item, index, result) => {
+ if (item instanceof JSHandle) {
+ result[index] = await this.realm.transferHandle(item);
+ }
+ })
+ );
+ }
+ if (result instanceof Map) {
+ await Promise.all(
+ [...result.entries()].map(async ([key, value]) => {
+ if (value instanceof JSHandle) {
+ result.set(key, await this.realm.transferHandle(value));
+ }
+ })
+ );
+ }
+ return result;
+ };
+ }
+
+ /**
+ * @internal
+ */
+ protected readonly handle;
+
+ /**
+ * @internal
+ */
+ constructor(handle: JSHandle<ElementType>) {
+ super();
+ this.handle = handle;
+ this[_isElementHandle] = true;
+ }
+
+ /**
+ * @internal
+ */
+ override get id(): string | undefined {
+ return this.handle.id;
+ }
+
+ /**
+ * @internal
+ */
+ override get disposed(): boolean {
+ return this.handle.disposed;
+ }
+
+ /**
+ * @internal
+ */
+ @throwIfDisposed()
+ @ElementHandle.bindIsolatedHandle
+ override async getProperty<K extends keyof ElementType>(
+ propertyName: HandleOr<K>
+ ): Promise<HandleFor<ElementType[K]>> {
+ return await this.handle.getProperty(propertyName);
+ }
+
+ /**
+ * @internal
+ */
+ @throwIfDisposed()
+ @ElementHandle.bindIsolatedHandle
+ override async getProperties(): Promise<Map<string, JSHandle>> {
+ return await this.handle.getProperties();
+ }
+
+ /**
+ * @internal
+ */
+ override async evaluate<
+ Params extends unknown[],
+ Func extends EvaluateFuncWith<ElementType, Params> = EvaluateFuncWith<
+ ElementType,
+ Params
+ >,
+ >(
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<Awaited<ReturnType<Func>>> {
+ pageFunction = withSourcePuppeteerURLIfNone(
+ this.evaluate.name,
+ pageFunction
+ );
+ return await this.handle.evaluate(pageFunction, ...args);
+ }
+
+ /**
+ * @internal
+ */
+ override async evaluateHandle<
+ Params extends unknown[],
+ Func extends EvaluateFuncWith<ElementType, Params> = EvaluateFuncWith<
+ ElementType,
+ Params
+ >,
+ >(
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<HandleFor<Awaited<ReturnType<Func>>>> {
+ pageFunction = withSourcePuppeteerURLIfNone(
+ this.evaluateHandle.name,
+ pageFunction
+ );
+ return await this.handle.evaluateHandle(pageFunction, ...args);
+ }
+
+ /**
+ * @internal
+ */
+ @throwIfDisposed()
+ @ElementHandle.bindIsolatedHandle
+ override async jsonValue(): Promise<ElementType> {
+ return await this.handle.jsonValue();
+ }
+
+ /**
+ * @internal
+ */
+ override toString(): string {
+ return this.handle.toString();
+ }
+
+ /**
+ * @internal
+ */
+ override remoteObject(): Protocol.Runtime.RemoteObject {
+ return this.handle.remoteObject();
+ }
+
+ /**
+ * @internal
+ */
+ override dispose(): Promise<void> {
+ return this.handle.dispose();
+ }
+
+ /**
+ * @internal
+ */
+ override asElement(): ElementHandle<ElementType> {
+ return this;
+ }
+
+ /**
+ * Frame corresponding to the current handle.
+ */
+ abstract get frame(): Frame;
+
+ /**
+ * Queries the current element for an element matching the given selector.
+ *
+ * @param selector - The selector to query for.
+ * @returns A {@link ElementHandle | element handle} to the first element
+ * matching the given selector. Otherwise, `null`.
+ */
+ @throwIfDisposed()
+ @ElementHandle.bindIsolatedHandle
+ async $<Selector extends string>(
+ selector: Selector
+ ): Promise<ElementHandle<NodeFor<Selector>> | null> {
+ const {updatedSelector, QueryHandler} =
+ getQueryHandlerAndSelector(selector);
+ return (await QueryHandler.queryOne(
+ this,
+ updatedSelector
+ )) as ElementHandle<NodeFor<Selector>> | null;
+ }
+
+ /**
+ * Queries the current element for all elements matching the given selector.
+ *
+ * @param selector - The selector to query for.
+ * @returns An array of {@link ElementHandle | element handles} that point to
+ * elements matching the given selector.
+ */
+ @throwIfDisposed()
+ @ElementHandle.bindIsolatedHandle
+ async $$<Selector extends string>(
+ selector: Selector
+ ): Promise<Array<ElementHandle<NodeFor<Selector>>>> {
+ const {updatedSelector, QueryHandler} =
+ getQueryHandlerAndSelector(selector);
+ return await (AsyncIterableUtil.collect(
+ QueryHandler.queryAll(this, updatedSelector)
+ ) as Promise<Array<ElementHandle<NodeFor<Selector>>>>);
+ }
+
+ /**
+ * Runs the given function on the first element matching the given selector in
+ * the current element.
+ *
+ * If the given function returns a promise, then this method will wait till
+ * the promise resolves.
+ *
+ * @example
+ *
+ * ```ts
+ * const tweetHandle = await page.$('.tweet');
+ * expect(await tweetHandle.$eval('.like', node => node.innerText)).toBe(
+ * '100'
+ * );
+ * expect(await tweetHandle.$eval('.retweets', node => node.innerText)).toBe(
+ * '10'
+ * );
+ * ```
+ *
+ * @param selector - The selector to query for.
+ * @param pageFunction - The function to be evaluated in this element's page's
+ * context. The first element matching the selector will be passed in as the
+ * first argument.
+ * @param args - Additional arguments to pass to `pageFunction`.
+ * @returns A promise to the result of the function.
+ */
+ async $eval<
+ Selector extends string,
+ Params extends unknown[],
+ Func extends EvaluateFuncWith<NodeFor<Selector>, Params> = EvaluateFuncWith<
+ NodeFor<Selector>,
+ Params
+ >,
+ >(
+ selector: Selector,
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<Awaited<ReturnType<Func>>> {
+ pageFunction = withSourcePuppeteerURLIfNone(this.$eval.name, pageFunction);
+ using elementHandle = await this.$(selector);
+ if (!elementHandle) {
+ throw new Error(
+ `Error: failed to find element matching selector "${selector}"`
+ );
+ }
+ return await elementHandle.evaluate(pageFunction, ...args);
+ }
+
+ /**
+ * Runs the given function on an array of elements matching the given selector
+ * in the current element.
+ *
+ * If the given function returns a promise, then this method will wait till
+ * the promise resolves.
+ *
+ * @example
+ * HTML:
+ *
+ * ```html
+ * <div class="feed">
+ * <div class="tweet">Hello!</div>
+ * <div class="tweet">Hi!</div>
+ * </div>
+ * ```
+ *
+ * JavaScript:
+ *
+ * ```ts
+ * const feedHandle = await page.$('.feed');
+ * expect(
+ * await feedHandle.$$eval('.tweet', nodes => nodes.map(n => n.innerText))
+ * ).toEqual(['Hello!', 'Hi!']);
+ * ```
+ *
+ * @param selector - The selector to query for.
+ * @param pageFunction - The function to be evaluated in the element's page's
+ * context. An array of elements matching the given selector will be passed to
+ * the function as its first argument.
+ * @param args - Additional arguments to pass to `pageFunction`.
+ * @returns A promise to the result of the function.
+ */
+ async $$eval<
+ Selector extends string,
+ Params extends unknown[],
+ Func extends EvaluateFuncWith<
+ Array<NodeFor<Selector>>,
+ Params
+ > = EvaluateFuncWith<Array<NodeFor<Selector>>, Params>,
+ >(
+ selector: Selector,
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<Awaited<ReturnType<Func>>> {
+ pageFunction = withSourcePuppeteerURLIfNone(this.$$eval.name, pageFunction);
+ const results = await this.$$(selector);
+ using elements = await this.evaluateHandle(
+ (_, ...elements) => {
+ return elements;
+ },
+ ...results
+ );
+ const [result] = await Promise.all([
+ elements.evaluate(pageFunction, ...args),
+ ...results.map(results => {
+ return results.dispose();
+ }),
+ ]);
+ return result;
+ }
+
+ /**
+ * @deprecated Use {@link ElementHandle.$$} with the `xpath` prefix.
+ *
+ * Example: `await elementHandle.$$('xpath/' + xpathExpression)`
+ *
+ * The method evaluates the XPath expression relative to the elementHandle.
+ * If `xpath` starts with `//` instead of `.//`, the dot will be appended
+ * automatically.
+ *
+ * If there are no such elements, the method will resolve to an empty array.
+ * @param expression - Expression to {@link https://developer.mozilla.org/en-US/docs/Web/API/Document/evaluate | evaluate}
+ */
+ @throwIfDisposed()
+ @ElementHandle.bindIsolatedHandle
+ async $x(expression: string): Promise<Array<ElementHandle<Node>>> {
+ if (expression.startsWith('//')) {
+ expression = `.${expression}`;
+ }
+ return await this.$$(`xpath/${expression}`);
+ }
+
+ /**
+ * Wait for an element matching the given selector to appear in the current
+ * element.
+ *
+ * Unlike {@link Frame.waitForSelector}, this method does not work across
+ * navigations or if the element is detached from DOM.
+ *
+ * @example
+ *
+ * ```ts
+ * import puppeteer from 'puppeteer';
+ *
+ * (async () => {
+ * const browser = await puppeteer.launch();
+ * const page = await browser.newPage();
+ * let currentURL;
+ * page
+ * .mainFrame()
+ * .waitForSelector('img')
+ * .then(() => console.log('First URL with image: ' + currentURL));
+ *
+ * for (currentURL of [
+ * 'https://example.com',
+ * 'https://google.com',
+ * 'https://bbc.com',
+ * ]) {
+ * await page.goto(currentURL);
+ * }
+ * await browser.close();
+ * })();
+ * ```
+ *
+ * @param selector - The selector to query and wait for.
+ * @param options - Options for customizing waiting behavior.
+ * @returns An element matching the given selector.
+ * @throws Throws if an element matching the given selector doesn't appear.
+ */
+ @throwIfDisposed()
+ @ElementHandle.bindIsolatedHandle
+ async waitForSelector<Selector extends string>(
+ selector: Selector,
+ options: WaitForSelectorOptions = {}
+ ): Promise<ElementHandle<NodeFor<Selector>> | null> {
+ const {updatedSelector, QueryHandler} =
+ getQueryHandlerAndSelector(selector);
+ return (await QueryHandler.waitFor(
+ this,
+ updatedSelector,
+ options
+ )) as ElementHandle<NodeFor<Selector>> | null;
+ }
+
+ async #checkVisibility(visibility: boolean): Promise<boolean> {
+ return await this.evaluate(
+ async (element, PuppeteerUtil, visibility) => {
+ return Boolean(PuppeteerUtil.checkVisibility(element, visibility));
+ },
+ LazyArg.create(context => {
+ return context.puppeteerUtil;
+ }),
+ visibility
+ );
+ }
+
+ /**
+ * Checks if an element is visible using the same mechanism as
+ * {@link ElementHandle.waitForSelector}.
+ */
+ @throwIfDisposed()
+ @ElementHandle.bindIsolatedHandle
+ async isVisible(): Promise<boolean> {
+ return await this.#checkVisibility(true);
+ }
+
+ /**
+ * Checks if an element is hidden using the same mechanism as
+ * {@link ElementHandle.waitForSelector}.
+ */
+ @throwIfDisposed()
+ @ElementHandle.bindIsolatedHandle
+ async isHidden(): Promise<boolean> {
+ return await this.#checkVisibility(false);
+ }
+
+ /**
+ * @deprecated Use {@link ElementHandle.waitForSelector} with the `xpath`
+ * prefix.
+ *
+ * Example: `await elementHandle.waitForSelector('xpath/' + xpathExpression)`
+ *
+ * The method evaluates the XPath expression relative to the elementHandle.
+ *
+ * Wait for the `xpath` within the element. If at the moment of calling the
+ * method the `xpath` already exists, the method will return immediately. If
+ * the `xpath` doesn't appear after the `timeout` milliseconds of waiting, the
+ * function will throw.
+ *
+ * If `xpath` starts with `//` instead of `.//`, the dot will be appended
+ * automatically.
+ *
+ * @example
+ * This method works across navigation.
+ *
+ * ```ts
+ * import puppeteer from 'puppeteer';
+ * (async () => {
+ * const browser = await puppeteer.launch();
+ * const page = await browser.newPage();
+ * let currentURL;
+ * page
+ * .waitForXPath('//img')
+ * .then(() => console.log('First URL with image: ' + currentURL));
+ * for (currentURL of [
+ * 'https://example.com',
+ * 'https://google.com',
+ * 'https://bbc.com',
+ * ]) {
+ * await page.goto(currentURL);
+ * }
+ * await browser.close();
+ * })();
+ * ```
+ *
+ * @param xpath - A
+ * {@link https://developer.mozilla.org/en-US/docs/Web/XPath | xpath} of an
+ * element to wait for
+ * @param options - Optional waiting parameters
+ * @returns Promise which resolves when element specified by xpath string is
+ * added to DOM. Resolves to `null` if waiting for `hidden: true` and xpath is
+ * not found in DOM, otherwise resolves to `ElementHandle`.
+ * @remarks
+ * The optional Argument `options` have properties:
+ *
+ * - `visible`: A boolean to wait for element to be present in DOM and to be
+ * visible, i.e. to not have `display: none` or `visibility: hidden` CSS
+ * properties. Defaults to `false`.
+ *
+ * - `hidden`: A boolean wait for element to not be found in the DOM or to be
+ * hidden, i.e. have `display: none` or `visibility: hidden` CSS properties.
+ * Defaults to `false`.
+ *
+ * - `timeout`: A number which is maximum time to wait for in milliseconds.
+ * Defaults to `30000` (30 seconds). Pass `0` to disable timeout. The
+ * default value can be changed by using the {@link Page.setDefaultTimeout}
+ * method.
+ */
+ @throwIfDisposed()
+ @ElementHandle.bindIsolatedHandle
+ async waitForXPath(
+ xpath: string,
+ options: {
+ visible?: boolean;
+ hidden?: boolean;
+ timeout?: number;
+ } = {}
+ ): Promise<ElementHandle<Node> | null> {
+ if (xpath.startsWith('//')) {
+ xpath = `.${xpath}`;
+ }
+ return await this.waitForSelector(`xpath/${xpath}`, options);
+ }
+
+ /**
+ * Converts the current handle to the given element type.
+ *
+ * @example
+ *
+ * ```ts
+ * const element: ElementHandle<Element> = await page.$(
+ * '.class-name-of-anchor'
+ * );
+ * // DO NOT DISPOSE `element`, this will be always be the same handle.
+ * const anchor: ElementHandle<HTMLAnchorElement> =
+ * await element.toElement('a');
+ * ```
+ *
+ * @param tagName - The tag name of the desired element type.
+ * @throws An error if the handle does not match. **The handle will not be
+ * automatically disposed.**
+ */
+ @throwIfDisposed()
+ @ElementHandle.bindIsolatedHandle
+ async toElement<
+ K extends keyof HTMLElementTagNameMap | keyof SVGElementTagNameMap,
+ >(tagName: K): Promise<HandleFor<ElementFor<K>>> {
+ const isMatchingTagName = await this.evaluate((node, tagName) => {
+ return node.nodeName === tagName.toUpperCase();
+ }, tagName);
+ if (!isMatchingTagName) {
+ throw new Error(`Element is not a(n) \`${tagName}\` element`);
+ }
+ return this as unknown as HandleFor<ElementFor<K>>;
+ }
+
+ /**
+ * Resolves the frame associated with the element, if any. Always exists for
+ * HTMLIFrameElements.
+ */
+ abstract contentFrame(this: ElementHandle<HTMLIFrameElement>): Promise<Frame>;
+ abstract contentFrame(): Promise<Frame | null>;
+
+ /**
+ * Returns the middle point within an element unless a specific offset is provided.
+ */
+ @throwIfDisposed()
+ @ElementHandle.bindIsolatedHandle
+ async clickablePoint(offset?: Offset): Promise<Point> {
+ const box = await this.#clickableBox();
+ if (!box) {
+ throw new Error('Node is either not clickable or not an Element');
+ }
+ if (offset !== undefined) {
+ return {
+ x: box.x + offset.x,
+ y: box.y + offset.y,
+ };
+ }
+ return {
+ x: box.x + box.width / 2,
+ y: box.y + box.height / 2,
+ };
+ }
+
+ /**
+ * This method scrolls element into view if needed, and then
+ * uses {@link Page} to hover over the center of the element.
+ * If the element is detached from DOM, the method throws an error.
+ */
+ @throwIfDisposed()
+ @ElementHandle.bindIsolatedHandle
+ async hover(this: ElementHandle<Element>): Promise<void> {
+ await this.scrollIntoViewIfNeeded();
+ const {x, y} = await this.clickablePoint();
+ await this.frame.page().mouse.move(x, y);
+ }
+
+ /**
+ * This method scrolls element into view if needed, and then
+ * uses {@link Page | Page.mouse} to click in the center of the element.
+ * If the element is detached from DOM, the method throws an error.
+ */
+ @throwIfDisposed()
+ @ElementHandle.bindIsolatedHandle
+ async click(
+ this: ElementHandle<Element>,
+ options: Readonly<ClickOptions> = {}
+ ): Promise<void> {
+ await this.scrollIntoViewIfNeeded();
+ const {x, y} = await this.clickablePoint(options.offset);
+ await this.frame.page().mouse.click(x, y, options);
+ }
+
+ /**
+ * Drags an element over the given element or point.
+ *
+ * @returns DEPRECATED. When drag interception is enabled, the drag payload is
+ * returned.
+ */
+ @throwIfDisposed()
+ @ElementHandle.bindIsolatedHandle
+ async drag(
+ this: ElementHandle<Element>,
+ target: Point | ElementHandle<Element>
+ ): Promise<Protocol.Input.DragData | void> {
+ await this.scrollIntoViewIfNeeded();
+ const page = this.frame.page();
+ if (page.isDragInterceptionEnabled()) {
+ const source = await this.clickablePoint();
+ if (target instanceof ElementHandle) {
+ target = await target.clickablePoint();
+ }
+ return await page.mouse.drag(source, target);
+ }
+ try {
+ if (!page._isDragging) {
+ page._isDragging = true;
+ await this.hover();
+ await page.mouse.down();
+ }
+ if (target instanceof ElementHandle) {
+ await target.hover();
+ } else {
+ await page.mouse.move(target.x, target.y);
+ }
+ } catch (error) {
+ page._isDragging = false;
+ throw error;
+ }
+ }
+
+ /**
+ * @deprecated Do not use. `dragenter` will automatically be performed during dragging.
+ */
+ @throwIfDisposed()
+ @ElementHandle.bindIsolatedHandle
+ async dragEnter(
+ this: ElementHandle<Element>,
+ data: Protocol.Input.DragData = {items: [], dragOperationsMask: 1}
+ ): Promise<void> {
+ const page = this.frame.page();
+ await this.scrollIntoViewIfNeeded();
+ const target = await this.clickablePoint();
+ await page.mouse.dragEnter(target, data);
+ }
+
+ /**
+ * @deprecated Do not use. `dragover` will automatically be performed during dragging.
+ */
+ @throwIfDisposed()
+ @ElementHandle.bindIsolatedHandle
+ async dragOver(
+ this: ElementHandle<Element>,
+ data: Protocol.Input.DragData = {items: [], dragOperationsMask: 1}
+ ): Promise<void> {
+ const page = this.frame.page();
+ await this.scrollIntoViewIfNeeded();
+ const target = await this.clickablePoint();
+ await page.mouse.dragOver(target, data);
+ }
+
+ /**
+ * Drops the given element onto the current one.
+ */
+ async drop(
+ this: ElementHandle<Element>,
+ element: ElementHandle<Element>
+ ): Promise<void>;
+
+ /**
+ * @deprecated No longer supported.
+ */
+ async drop(
+ this: ElementHandle<Element>,
+ data?: Protocol.Input.DragData
+ ): Promise<void>;
+
+ /**
+ * @internal
+ */
+ @throwIfDisposed()
+ @ElementHandle.bindIsolatedHandle
+ async drop(
+ this: ElementHandle<Element>,
+ dataOrElement: ElementHandle<Element> | Protocol.Input.DragData = {
+ items: [],
+ dragOperationsMask: 1,
+ }
+ ): Promise<void> {
+ const page = this.frame.page();
+ if ('items' in dataOrElement) {
+ await this.scrollIntoViewIfNeeded();
+ const destination = await this.clickablePoint();
+ await page.mouse.drop(destination, dataOrElement);
+ } else {
+ // Note if the rest errors, we still want dragging off because the errors
+ // is most likely something implying the mouse is no longer dragging.
+ await dataOrElement.drag(this);
+ page._isDragging = false;
+ await page.mouse.up();
+ }
+ }
+
+ /**
+ * @deprecated Use `ElementHandle.drop` instead.
+ */
+ @throwIfDisposed()
+ @ElementHandle.bindIsolatedHandle
+ async dragAndDrop(
+ this: ElementHandle<Element>,
+ target: ElementHandle<Node>,
+ options?: {delay: number}
+ ): Promise<void> {
+ const page = this.frame.page();
+ assert(
+ page.isDragInterceptionEnabled(),
+ 'Drag Interception is not enabled!'
+ );
+ await this.scrollIntoViewIfNeeded();
+ const startPoint = await this.clickablePoint();
+ const targetPoint = await target.clickablePoint();
+ await page.mouse.dragAndDrop(startPoint, targetPoint, options);
+ }
+
+ /**
+ * Triggers a `change` and `input` event once all the provided options have been
+ * selected. If there's no `<select>` element matching `selector`, the method
+ * throws an error.
+ *
+ * @example
+ *
+ * ```ts
+ * handle.select('blue'); // single selection
+ * handle.select('red', 'green', 'blue'); // multiple selections
+ * ```
+ *
+ * @param values - Values of options to select. If the `<select>` has the
+ * `multiple` attribute, all values are considered, otherwise only the first
+ * one is taken into account.
+ */
+ @throwIfDisposed()
+ @ElementHandle.bindIsolatedHandle
+ async select(...values: string[]): Promise<string[]> {
+ for (const value of values) {
+ assert(
+ isString(value),
+ 'Values must be strings. Found value "' +
+ value +
+ '" of type "' +
+ typeof value +
+ '"'
+ );
+ }
+
+ return await this.evaluate((element, vals): string[] => {
+ const values = new Set(vals);
+ if (!(element instanceof HTMLSelectElement)) {
+ throw new Error('Element is not a <select> element.');
+ }
+
+ const selectedValues = new Set<string>();
+ if (!element.multiple) {
+ for (const option of element.options) {
+ option.selected = false;
+ }
+ for (const option of element.options) {
+ if (values.has(option.value)) {
+ option.selected = true;
+ selectedValues.add(option.value);
+ break;
+ }
+ }
+ } else {
+ for (const option of element.options) {
+ option.selected = values.has(option.value);
+ if (option.selected) {
+ selectedValues.add(option.value);
+ }
+ }
+ }
+ element.dispatchEvent(new Event('input', {bubbles: true}));
+ element.dispatchEvent(new Event('change', {bubbles: true}));
+ return [...selectedValues.values()];
+ }, values);
+ }
+
+ /**
+ * Sets the value of an
+ * {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input | input element}
+ * to the given file paths.
+ *
+ * @remarks This will not validate whether the file paths exists. Also, if a
+ * path is relative, then it is resolved against the
+ * {@link https://nodejs.org/api/process.html#process_process_cwd | current working directory}.
+ * For locals script connecting to remote chrome environments, paths must be
+ * absolute.
+ */
+ abstract uploadFile(
+ this: ElementHandle<HTMLInputElement>,
+ ...paths: string[]
+ ): Promise<void>;
+
+ /**
+ * This method scrolls element into view if needed, and then uses
+ * {@link Touchscreen.tap} to tap in the center of the element.
+ * If the element is detached from DOM, the method throws an error.
+ */
+ @throwIfDisposed()
+ @ElementHandle.bindIsolatedHandle
+ async tap(this: ElementHandle<Element>): Promise<void> {
+ await this.scrollIntoViewIfNeeded();
+ const {x, y} = await this.clickablePoint();
+ await this.frame.page().touchscreen.tap(x, y);
+ }
+
+ @throwIfDisposed()
+ @ElementHandle.bindIsolatedHandle
+ async touchStart(this: ElementHandle<Element>): Promise<void> {
+ await this.scrollIntoViewIfNeeded();
+ const {x, y} = await this.clickablePoint();
+ await this.frame.page().touchscreen.touchStart(x, y);
+ }
+
+ @throwIfDisposed()
+ @ElementHandle.bindIsolatedHandle
+ async touchMove(this: ElementHandle<Element>): Promise<void> {
+ await this.scrollIntoViewIfNeeded();
+ const {x, y} = await this.clickablePoint();
+ await this.frame.page().touchscreen.touchMove(x, y);
+ }
+
+ @throwIfDisposed()
+ @ElementHandle.bindIsolatedHandle
+ async touchEnd(this: ElementHandle<Element>): Promise<void> {
+ await this.scrollIntoViewIfNeeded();
+ await this.frame.page().touchscreen.touchEnd();
+ }
+
+ /**
+ * Calls {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus | focus} on the element.
+ */
+ @throwIfDisposed()
+ @ElementHandle.bindIsolatedHandle
+ async focus(): Promise<void> {
+ await this.evaluate(element => {
+ if (!(element instanceof HTMLElement)) {
+ throw new Error('Cannot focus non-HTMLElement');
+ }
+ return element.focus();
+ });
+ }
+
+ /**
+ * Focuses the element, and then sends a `keydown`, `keypress`/`input`, and
+ * `keyup` event for each character in the text.
+ *
+ * To press a special key, like `Control` or `ArrowDown`,
+ * use {@link ElementHandle.press}.
+ *
+ * @example
+ *
+ * ```ts
+ * await elementHandle.type('Hello'); // Types instantly
+ * await elementHandle.type('World', {delay: 100}); // Types slower, like a user
+ * ```
+ *
+ * @example
+ * An example of typing into a text field and then submitting the form:
+ *
+ * ```ts
+ * const elementHandle = await page.$('input');
+ * await elementHandle.type('some text');
+ * await elementHandle.press('Enter');
+ * ```
+ *
+ * @param options - Delay in milliseconds. Defaults to 0.
+ */
+ @throwIfDisposed()
+ @ElementHandle.bindIsolatedHandle
+ async type(
+ text: string,
+ options?: Readonly<KeyboardTypeOptions>
+ ): Promise<void> {
+ await this.focus();
+ await this.frame.page().keyboard.type(text, options);
+ }
+
+ /**
+ * Focuses the element, and then uses {@link Keyboard.down} and {@link Keyboard.up}.
+ *
+ * @remarks
+ * If `key` is a single character and no modifier keys besides `Shift`
+ * are being held down, a `keypress`/`input` event will also be generated.
+ * The `text` option can be specified to force an input event to be generated.
+ *
+ * **NOTE** Modifier keys DO affect `elementHandle.press`. Holding down `Shift`
+ * will type the text in upper case.
+ *
+ * @param key - Name of key to press, such as `ArrowLeft`.
+ * See {@link KeyInput} for a list of all key names.
+ */
+ @throwIfDisposed()
+ @ElementHandle.bindIsolatedHandle
+ async press(
+ key: KeyInput,
+ options?: Readonly<KeyPressOptions>
+ ): Promise<void> {
+ await this.focus();
+ await this.frame.page().keyboard.press(key, options);
+ }
+
+ async #clickableBox(): Promise<BoundingBox | null> {
+ const boxes = await this.evaluate(element => {
+ if (!(element instanceof Element)) {
+ return null;
+ }
+ return [...element.getClientRects()].map(rect => {
+ return {x: rect.x, y: rect.y, width: rect.width, height: rect.height};
+ });
+ });
+ if (!boxes?.length) {
+ return null;
+ }
+ await this.#intersectBoundingBoxesWithFrame(boxes);
+ let frame = this.frame;
+ let parentFrame: Frame | null | undefined;
+ while ((parentFrame = frame?.parentFrame())) {
+ using handle = await frame.frameElement();
+ if (!handle) {
+ throw new Error('Unsupported frame type');
+ }
+ const parentBox = await handle.evaluate(element => {
+ // Element is not visible.
+ if (element.getClientRects().length === 0) {
+ return null;
+ }
+ const rect = element.getBoundingClientRect();
+ const style = window.getComputedStyle(element);
+ return {
+ left:
+ rect.left +
+ parseInt(style.paddingLeft, 10) +
+ parseInt(style.borderLeftWidth, 10),
+ top:
+ rect.top +
+ parseInt(style.paddingTop, 10) +
+ parseInt(style.borderTopWidth, 10),
+ };
+ });
+ if (!parentBox) {
+ return null;
+ }
+ for (const box of boxes) {
+ box.x += parentBox.left;
+ box.y += parentBox.top;
+ }
+ await handle.#intersectBoundingBoxesWithFrame(boxes);
+ frame = parentFrame;
+ }
+ const box = boxes.find(box => {
+ return box.width >= 1 && box.height >= 1;
+ });
+ if (!box) {
+ return null;
+ }
+ return {
+ x: box.x,
+ y: box.y,
+ height: box.height,
+ width: box.width,
+ };
+ }
+
+ async #intersectBoundingBoxesWithFrame(boxes: BoundingBox[]) {
+ const {documentWidth, documentHeight} = await this.frame
+ .isolatedRealm()
+ .evaluate(() => {
+ return {
+ documentWidth: document.documentElement.clientWidth,
+ documentHeight: document.documentElement.clientHeight,
+ };
+ });
+ for (const box of boxes) {
+ intersectBoundingBox(box, documentWidth, documentHeight);
+ }
+ }
+
+ /**
+ * This method returns the bounding box of the element (relative to the main frame),
+ * or `null` if the element is {@link https://drafts.csswg.org/css-display-4/#box-generation | not part of the layout}
+ * (example: `display: none`).
+ */
+ @throwIfDisposed()
+ @ElementHandle.bindIsolatedHandle
+ async boundingBox(): Promise<BoundingBox | null> {
+ const box = await this.evaluate(element => {
+ if (!(element instanceof Element)) {
+ return null;
+ }
+ // Element is not visible.
+ if (element.getClientRects().length === 0) {
+ return null;
+ }
+ const rect = element.getBoundingClientRect();
+ return {x: rect.x, y: rect.y, width: rect.width, height: rect.height};
+ });
+ if (!box) {
+ return null;
+ }
+ const offset = await this.#getTopLeftCornerOfFrame();
+ if (!offset) {
+ return null;
+ }
+ return {
+ x: box.x + offset.x,
+ y: box.y + offset.y,
+ height: box.height,
+ width: box.width,
+ };
+ }
+
+ /**
+ * This method returns boxes of the element,
+ * or `null` if the element is {@link https://drafts.csswg.org/css-display-4/#box-generation | not part of the layout}
+ * (example: `display: none`).
+ *
+ * @remarks
+ *
+ * Boxes are represented as an array of points;
+ * Each Point is an object `{x, y}`. Box points are sorted clock-wise.
+ */
+ @throwIfDisposed()
+ @ElementHandle.bindIsolatedHandle
+ async boxModel(): Promise<BoxModel | null> {
+ const model = await this.evaluate(element => {
+ if (!(element instanceof Element)) {
+ return null;
+ }
+ // Element is not visible.
+ if (element.getClientRects().length === 0) {
+ return null;
+ }
+ const rect = element.getBoundingClientRect();
+ const style = window.getComputedStyle(element);
+ const offsets = {
+ padding: {
+ left: parseInt(style.paddingLeft, 10),
+ top: parseInt(style.paddingTop, 10),
+ right: parseInt(style.paddingRight, 10),
+ bottom: parseInt(style.paddingBottom, 10),
+ },
+ margin: {
+ left: -parseInt(style.marginLeft, 10),
+ top: -parseInt(style.marginTop, 10),
+ right: -parseInt(style.marginRight, 10),
+ bottom: -parseInt(style.marginBottom, 10),
+ },
+ border: {
+ left: parseInt(style.borderLeft, 10),
+ top: parseInt(style.borderTop, 10),
+ right: parseInt(style.borderRight, 10),
+ bottom: parseInt(style.borderBottom, 10),
+ },
+ };
+ const border: Quad = [
+ {x: rect.left, y: rect.top},
+ {x: rect.left + rect.width, y: rect.top},
+ {x: rect.left + rect.width, y: rect.top + rect.bottom},
+ {x: rect.left, y: rect.top + rect.bottom},
+ ];
+ const padding = transformQuadWithOffsets(border, offsets.border);
+ const content = transformQuadWithOffsets(padding, offsets.padding);
+ const margin = transformQuadWithOffsets(border, offsets.margin);
+ return {
+ content,
+ padding,
+ border,
+ margin,
+ width: rect.width,
+ height: rect.height,
+ };
+
+ function transformQuadWithOffsets(
+ quad: Quad,
+ offsets: {top: number; left: number; right: number; bottom: number}
+ ): Quad {
+ return [
+ {
+ x: quad[0].x + offsets.left,
+ y: quad[0].y + offsets.top,
+ },
+ {
+ x: quad[1].x - offsets.right,
+ y: quad[1].y + offsets.top,
+ },
+ {
+ x: quad[2].x - offsets.right,
+ y: quad[2].y - offsets.bottom,
+ },
+ {
+ x: quad[3].x + offsets.left,
+ y: quad[3].y - offsets.bottom,
+ },
+ ];
+ }
+ });
+ if (!model) {
+ return null;
+ }
+ const offset = await this.#getTopLeftCornerOfFrame();
+ if (!offset) {
+ return null;
+ }
+ for (const attribute of [
+ 'content',
+ 'padding',
+ 'border',
+ 'margin',
+ ] as const) {
+ for (const point of model[attribute]) {
+ point.x += offset.x;
+ point.y += offset.y;
+ }
+ }
+ return model;
+ }
+
+ async #getTopLeftCornerOfFrame() {
+ const point = {x: 0, y: 0};
+ let frame = this.frame;
+ let parentFrame: Frame | null | undefined;
+ while ((parentFrame = frame?.parentFrame())) {
+ using handle = await frame.frameElement();
+ if (!handle) {
+ throw new Error('Unsupported frame type');
+ }
+ const parentBox = await handle.evaluate(element => {
+ // Element is not visible.
+ if (element.getClientRects().length === 0) {
+ return null;
+ }
+ const rect = element.getBoundingClientRect();
+ const style = window.getComputedStyle(element);
+ return {
+ left:
+ rect.left +
+ parseInt(style.paddingLeft, 10) +
+ parseInt(style.borderLeftWidth, 10),
+ top:
+ rect.top +
+ parseInt(style.paddingTop, 10) +
+ parseInt(style.borderTopWidth, 10),
+ };
+ });
+ if (!parentBox) {
+ return null;
+ }
+ point.x += parentBox.left;
+ point.y += parentBox.top;
+ frame = parentFrame;
+ }
+ return point;
+ }
+
+ /**
+ * This method scrolls element into view if needed, and then uses
+ * {@link Page.(screenshot:2) } to take a screenshot of the element.
+ * If the element is detached from DOM, the method throws an error.
+ */
+ async screenshot(
+ options: Readonly<ScreenshotOptions> & {encoding: 'base64'}
+ ): Promise<string>;
+ async screenshot(options?: Readonly<ScreenshotOptions>): Promise<Buffer>;
+ @throwIfDisposed()
+ @ElementHandle.bindIsolatedHandle
+ async screenshot(
+ this: ElementHandle<Element>,
+ options: Readonly<ElementScreenshotOptions> = {}
+ ): Promise<string | Buffer> {
+ const {scrollIntoView = true} = options;
+
+ let clip = await this.#nonEmptyVisibleBoundingBox();
+
+ const page = this.frame.page();
+
+ // If the element is larger than the viewport, `captureBeyondViewport` will
+ // _not_ affect element rendering, so we need to adjust the viewport to
+ // properly render the element.
+ const viewport = page.viewport() ?? {
+ width: clip.width,
+ height: clip.height,
+ };
+ await using stack = new AsyncDisposableStack();
+ if (clip.width > viewport.width || clip.height > viewport.height) {
+ await this.frame.page().setViewport({
+ ...viewport,
+ width: Math.max(viewport.width, Math.ceil(clip.width)),
+ height: Math.max(viewport.height, Math.ceil(clip.height)),
+ });
+
+ stack.defer(async () => {
+ try {
+ await this.frame.page().setViewport(viewport);
+ } catch (error) {
+ debugError(error);
+ }
+ });
+ }
+
+ // Only scroll the element into view if the user wants it.
+ if (scrollIntoView) {
+ await this.scrollIntoViewIfNeeded();
+
+ // We measure again just in case.
+ clip = await this.#nonEmptyVisibleBoundingBox();
+ }
+
+ const [pageLeft, pageTop] = await this.evaluate(() => {
+ if (!window.visualViewport) {
+ throw new Error('window.visualViewport is not supported.');
+ }
+ return [
+ window.visualViewport.pageLeft,
+ window.visualViewport.pageTop,
+ ] as const;
+ });
+ clip.x += pageLeft;
+ clip.y += pageTop;
+
+ return await page.screenshot({...options, clip});
+ }
+
+ async #nonEmptyVisibleBoundingBox() {
+ const box = await this.boundingBox();
+ assert(box, 'Node is either not visible or not an HTMLElement');
+ assert(box.width !== 0, 'Node has 0 width.');
+ assert(box.height !== 0, 'Node has 0 height.');
+ return box;
+ }
+
+ /**
+ * @internal
+ */
+ protected async assertConnectedElement(): Promise<void> {
+ const error = await this.evaluate(async element => {
+ if (!element.isConnected) {
+ return 'Node is detached from document';
+ }
+ if (element.nodeType !== Node.ELEMENT_NODE) {
+ return 'Node is not of type HTMLElement';
+ }
+ return;
+ });
+
+ if (error) {
+ throw new Error(error);
+ }
+ }
+
+ /**
+ * @internal
+ */
+ protected async scrollIntoViewIfNeeded(
+ this: ElementHandle<Element>
+ ): Promise<void> {
+ if (
+ await this.isIntersectingViewport({
+ threshold: 1,
+ })
+ ) {
+ return;
+ }
+ await this.scrollIntoView();
+ }
+
+ /**
+ * Resolves to true if the element is visible in the current viewport. If an
+ * element is an SVG, we check if the svg owner element is in the viewport
+ * instead. See https://crbug.com/963246.
+ *
+ * @param options - Threshold for the intersection between 0 (no intersection) and 1
+ * (full intersection). Defaults to 1.
+ */
+ @throwIfDisposed()
+ @ElementHandle.bindIsolatedHandle
+ async isIntersectingViewport(
+ this: ElementHandle<Element>,
+ options: {
+ threshold?: number;
+ } = {}
+ ): Promise<boolean> {
+ await this.assertConnectedElement();
+ // eslint-disable-next-line rulesdir/use-using -- Returns `this`.
+ const handle = await this.#asSVGElementHandle();
+ using target = handle && (await handle.#getOwnerSVGElement());
+ return await ((target ?? this) as ElementHandle<Element>).evaluate(
+ async (element, threshold) => {
+ const visibleRatio = await new Promise<number>(resolve => {
+ const observer = new IntersectionObserver(entries => {
+ resolve(entries[0]!.intersectionRatio);
+ observer.disconnect();
+ });
+ observer.observe(element);
+ });
+ return threshold === 1 ? visibleRatio === 1 : visibleRatio > threshold;
+ },
+ options.threshold ?? 0
+ );
+ }
+
+ /**
+ * Scrolls the element into view using either the automation protocol client
+ * or by calling element.scrollIntoView.
+ */
+ @throwIfDisposed()
+ @ElementHandle.bindIsolatedHandle
+ async scrollIntoView(this: ElementHandle<Element>): Promise<void> {
+ await this.assertConnectedElement();
+ await this.evaluate(async (element): Promise<void> => {
+ element.scrollIntoView({
+ block: 'center',
+ inline: 'center',
+ behavior: 'instant',
+ });
+ });
+ }
+
+ /**
+ * Returns true if an element is an SVGElement (included svg, path, rect
+ * etc.).
+ */
+ async #asSVGElementHandle(
+ this: ElementHandle<Element>
+ ): Promise<ElementHandle<SVGElement> | null> {
+ if (
+ await this.evaluate(element => {
+ return element instanceof SVGElement;
+ })
+ ) {
+ return this as ElementHandle<SVGElement>;
+ } else {
+ return null;
+ }
+ }
+
+ async #getOwnerSVGElement(
+ this: ElementHandle<SVGElement>
+ ): Promise<ElementHandle<SVGSVGElement>> {
+ // SVGSVGElement.ownerSVGElement === null.
+ return await this.evaluateHandle(element => {
+ if (element instanceof SVGSVGElement) {
+ return element;
+ }
+ return element.ownerSVGElement!;
+ });
+ }
+
+ /**
+ * If the element is a form input, you can use {@link ElementHandle.autofill}
+ * to test if the form is compatible with the browser's autofill
+ * implementation. Throws an error if the form cannot be autofilled.
+ *
+ * @remarks
+ *
+ * Currently, Puppeteer supports auto-filling credit card information only and
+ * in Chrome in the new headless and headful modes only.
+ *
+ * ```ts
+ * // Select an input on the credit card form.
+ * const name = await page.waitForSelector('form #name');
+ * // Trigger autofill with the desired data.
+ * await name.autofill({
+ * creditCard: {
+ * number: '4444444444444444',
+ * name: 'John Smith',
+ * expiryMonth: '01',
+ * expiryYear: '2030',
+ * cvc: '123',
+ * },
+ * });
+ * ```
+ */
+ abstract autofill(data: AutofillData): Promise<void>;
+}
+
+/**
+ * @public
+ */
+export interface AutofillData {
+ creditCard: {
+ // See https://chromedevtools.github.io/devtools-protocol/tot/Autofill/#type-CreditCard.
+ number: string;
+ name: string;
+ expiryMonth: string;
+ expiryYear: string;
+ cvc: string;
+ };
+}
+
+function intersectBoundingBox(
+ box: BoundingBox,
+ width: number,
+ height: number
+): void {
+ box.width = Math.max(
+ box.x >= 0
+ ? Math.min(width - box.x, box.width)
+ : Math.min(width, box.width + box.x),
+ 0
+ );
+ box.height = Math.max(
+ box.y >= 0
+ ? Math.min(height - box.y, box.height)
+ : Math.min(height, box.height + box.y),
+ 0
+ );
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/ElementHandleSymbol.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/ElementHandleSymbol.ts
new file mode 100644
index 0000000000..6e5087b773
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/ElementHandleSymbol.ts
@@ -0,0 +1,10 @@
+/**
+ * @license
+ * Copyright 2023 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * @internal
+ */
+export const _isElementHandle = Symbol('_isElementHandle');
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/Environment.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/Environment.ts
new file mode 100644
index 0000000000..c5a8d73d00
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/Environment.ts
@@ -0,0 +1,16 @@
+/**
+ * @license
+ * Copyright 2023 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {CDPSession} from './CDPSession.js';
+import type {Realm} from './Realm.js';
+
+/**
+ * @internal
+ */
+export interface Environment {
+ get client(): CDPSession;
+ mainRealm(): Realm;
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/Frame.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/Frame.ts
new file mode 100644
index 0000000000..757ec872c6
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/Frame.ts
@@ -0,0 +1,1218 @@
+/**
+ * @license
+ * Copyright 2023 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type Protocol from 'devtools-protocol';
+
+import type {ClickOptions, ElementHandle} from '../api/ElementHandle.js';
+import type {HTTPResponse} from '../api/HTTPResponse.js';
+import type {
+ Page,
+ WaitForSelectorOptions,
+ WaitTimeoutOptions,
+} from '../api/Page.js';
+import type {DeviceRequestPrompt} from '../cdp/DeviceRequestPrompt.js';
+import type {IsolatedWorldChart} from '../cdp/IsolatedWorld.js';
+import type {PuppeteerLifeCycleEvent} from '../cdp/LifecycleWatcher.js';
+import {EventEmitter, type EventType} from '../common/EventEmitter.js';
+import {getQueryHandlerAndSelector} from '../common/GetQueryHandler.js';
+import {transposeIterableHandle} from '../common/HandleIterator.js';
+import {LazyArg} from '../common/LazyArg.js';
+import type {
+ Awaitable,
+ EvaluateFunc,
+ EvaluateFuncWith,
+ HandleFor,
+ NodeFor,
+} from '../common/types.js';
+import {
+ importFSPromises,
+ withSourcePuppeteerURLIfNone,
+} from '../common/util.js';
+import {assert} from '../util/assert.js';
+import {throwIfDisposed} from '../util/decorators.js';
+
+import type {CDPSession} from './CDPSession.js';
+import type {KeyboardTypeOptions} from './Input.js';
+import {
+ FunctionLocator,
+ type Locator,
+ NodeLocator,
+} from './locators/locators.js';
+import type {Realm} from './Realm.js';
+
+/**
+ * @public
+ */
+export interface WaitForOptions {
+ /**
+ * Maximum wait time in milliseconds. Pass 0 to disable the timeout.
+ *
+ * The default value can be changed by using the
+ * {@link Page.setDefaultTimeout} or {@link Page.setDefaultNavigationTimeout}
+ * methods.
+ *
+ * @defaultValue `30000`
+ */
+ timeout?: number;
+ /**
+ * When to consider waiting succeeds. Given an array of event strings, waiting
+ * is considered to be successful after all events have been fired.
+ *
+ * @defaultValue `'load'`
+ */
+ waitUntil?: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[];
+}
+
+/**
+ * @public
+ */
+export interface GoToOptions extends WaitForOptions {
+ /**
+ * If provided, it will take preference over the referer header value set by
+ * {@link Page.setExtraHTTPHeaders | page.setExtraHTTPHeaders()}.
+ */
+ referer?: string;
+ /**
+ * If provided, it will take preference over the referer-policy header value
+ * set by {@link Page.setExtraHTTPHeaders | page.setExtraHTTPHeaders()}.
+ */
+ referrerPolicy?: string;
+}
+
+/**
+ * @public
+ */
+export interface FrameWaitForFunctionOptions {
+ /**
+ * An interval at which the `pageFunction` is executed, defaults to `raf`. If
+ * `polling` is a number, then it is treated as an interval in milliseconds at
+ * which the function would be executed. If `polling` is a string, then it can
+ * be one of the following values:
+ *
+ * - `raf` - to constantly execute `pageFunction` in `requestAnimationFrame`
+ * callback. This is the tightest polling mode which is suitable to observe
+ * styling changes.
+ *
+ * - `mutation` - to execute `pageFunction` on every DOM mutation.
+ */
+ polling?: 'raf' | 'mutation' | number;
+ /**
+ * Maximum time to wait in milliseconds. Defaults to `30000` (30 seconds).
+ * Pass `0` to disable the timeout. Puppeteer's default timeout can be changed
+ * using {@link Page.setDefaultTimeout}.
+ */
+ timeout?: number;
+ /**
+ * A signal object that allows you to cancel a waitForFunction call.
+ */
+ signal?: AbortSignal;
+}
+
+/**
+ * @public
+ */
+export interface FrameAddScriptTagOptions {
+ /**
+ * URL of the script to be added.
+ */
+ url?: string;
+ /**
+ * Path to a JavaScript file to be injected into the frame.
+ *
+ * @remarks
+ * If `path` is a relative path, it is resolved relative to the current
+ * working directory (`process.cwd()` in Node.js).
+ */
+ path?: string;
+ /**
+ * JavaScript to be injected into the frame.
+ */
+ content?: string;
+ /**
+ * Sets the `type` of the script. Use `module` in order to load an ES2015 module.
+ */
+ type?: string;
+ /**
+ * Sets the `id` of the script.
+ */
+ id?: string;
+}
+
+/**
+ * @public
+ */
+export interface FrameAddStyleTagOptions {
+ /**
+ * the URL of the CSS file to be added.
+ */
+ url?: string;
+ /**
+ * The path to a CSS file to be injected into the frame.
+ * @remarks
+ * If `path` is a relative path, it is resolved relative to the current
+ * working directory (`process.cwd()` in Node.js).
+ */
+ path?: string;
+ /**
+ * Raw CSS content to be injected into the frame.
+ */
+ content?: string;
+}
+
+/**
+ * @public
+ */
+export interface FrameEvents extends Record<EventType, unknown> {
+ /** @internal */
+ [FrameEvent.FrameNavigated]: Protocol.Page.NavigationType;
+ /** @internal */
+ [FrameEvent.FrameSwapped]: undefined;
+ /** @internal */
+ [FrameEvent.LifecycleEvent]: undefined;
+ /** @internal */
+ [FrameEvent.FrameNavigatedWithinDocument]: undefined;
+ /** @internal */
+ [FrameEvent.FrameDetached]: Frame;
+ /** @internal */
+ [FrameEvent.FrameSwappedByActivation]: undefined;
+}
+
+/**
+ * We use symbols to prevent external parties listening to these events.
+ * They are internal to Puppeteer.
+ *
+ * @internal
+ */
+// eslint-disable-next-line @typescript-eslint/no-namespace
+export namespace FrameEvent {
+ export const FrameNavigated = Symbol('Frame.FrameNavigated');
+ export const FrameSwapped = Symbol('Frame.FrameSwapped');
+ export const LifecycleEvent = Symbol('Frame.LifecycleEvent');
+ export const FrameNavigatedWithinDocument = Symbol(
+ 'Frame.FrameNavigatedWithinDocument'
+ );
+ export const FrameDetached = Symbol('Frame.FrameDetached');
+ export const FrameSwappedByActivation = Symbol(
+ 'Frame.FrameSwappedByActivation'
+ );
+}
+
+/**
+ * @internal
+ */
+export const throwIfDetached = throwIfDisposed<Frame>(frame => {
+ return `Attempted to use detached Frame '${frame._id}'.`;
+});
+
+/**
+ * Represents a DOM frame.
+ *
+ * To understand frames, you can think of frames as `<iframe>` elements. Just
+ * like iframes, frames can be nested, and when JavaScript is executed in a
+ * frame, the JavaScript does not effect frames inside the ambient frame the
+ * JavaScript executes in.
+ *
+ * @example
+ * At any point in time, {@link Page | pages} expose their current frame
+ * tree via the {@link Page.mainFrame} and {@link Frame.childFrames} methods.
+ *
+ * @example
+ * An example of dumping frame tree:
+ *
+ * ```ts
+ * import puppeteer from 'puppeteer';
+ *
+ * (async () => {
+ * const browser = await puppeteer.launch();
+ * const page = await browser.newPage();
+ * await page.goto('https://www.google.com/chrome/browser/canary.html');
+ * dumpFrameTree(page.mainFrame(), '');
+ * await browser.close();
+ *
+ * function dumpFrameTree(frame, indent) {
+ * console.log(indent + frame.url());
+ * for (const child of frame.childFrames()) {
+ * dumpFrameTree(child, indent + ' ');
+ * }
+ * }
+ * })();
+ * ```
+ *
+ * @example
+ * An example of getting text from an iframe element:
+ *
+ * ```ts
+ * const frame = page.frames().find(frame => frame.name() === 'myframe');
+ * const text = await frame.$eval('.selector', element => element.textContent);
+ * console.log(text);
+ * ```
+ *
+ * @remarks
+ * Frame lifecycles are controlled by three events that are all dispatched on
+ * the parent {@link Frame.page | page}:
+ *
+ * - {@link PageEvent.FrameAttached}
+ * - {@link PageEvent.FrameNavigated}
+ * - {@link PageEvent.FrameDetached}
+ *
+ * @public
+ */
+export abstract class Frame extends EventEmitter<FrameEvents> {
+ /**
+ * @internal
+ */
+ _id!: string;
+ /**
+ * @internal
+ */
+ _parentId?: string;
+
+ /**
+ * @internal
+ */
+ worlds!: IsolatedWorldChart;
+
+ /**
+ * @internal
+ */
+ _name?: string;
+
+ /**
+ * @internal
+ */
+ _hasStartedLoading = false;
+
+ /**
+ * @internal
+ */
+ constructor() {
+ super();
+ }
+
+ /**
+ * The page associated with the frame.
+ */
+ abstract page(): Page;
+
+ /**
+ * Is `true` if the frame is an out-of-process (OOP) frame. Otherwise,
+ * `false`.
+ */
+ abstract isOOPFrame(): boolean;
+
+ /**
+ * Navigates the frame to the given `url`.
+ *
+ * @remarks
+ * Navigation to `about:blank` or navigation to the same URL with a different
+ * hash will succeed and return `null`.
+ *
+ * :::warning
+ *
+ * Headless mode doesn't support navigation to a PDF document. See the {@link
+ * https://bugs.chromium.org/p/chromium/issues/detail?id=761295 | upstream
+ * issue}.
+ *
+ * :::
+ *
+ * @param url - URL to navigate the frame to. The URL should include scheme,
+ * e.g. `https://`
+ * @param options - Options to configure waiting behavior.
+ * @returns A promise which resolves to the main resource response. In case of
+ * multiple redirects, the navigation will resolve with the response of the
+ * last redirect.
+ * @throws If:
+ *
+ * - there's an SSL error (e.g. in case of self-signed certificates).
+ * - target URL is invalid.
+ * - the timeout is exceeded during navigation.
+ * - the remote server does not respond or is unreachable.
+ * - the main resource failed to load.
+ *
+ * This method will not throw an error when any valid HTTP status code is
+ * returned by the remote server, including 404 "Not Found" and 500 "Internal
+ * Server Error". The status code for such responses can be retrieved by
+ * calling {@link HTTPResponse.status}.
+ */
+ abstract goto(
+ url: string,
+ options?: {
+ referer?: string;
+ referrerPolicy?: string;
+ timeout?: number;
+ waitUntil?: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[];
+ }
+ ): Promise<HTTPResponse | null>;
+
+ /**
+ * Waits for the frame to navigate. It is useful for when you run code which
+ * will indirectly cause the frame to navigate.
+ *
+ * Usage of the
+ * {@link https://developer.mozilla.org/en-US/docs/Web/API/History_API | History API}
+ * to change the URL is considered a navigation.
+ *
+ * @example
+ *
+ * ```ts
+ * const [response] = await Promise.all([
+ * // The navigation promise resolves after navigation has finished
+ * frame.waitForNavigation(),
+ * // Clicking the link will indirectly cause a navigation
+ * frame.click('a.my-link'),
+ * ]);
+ * ```
+ *
+ * @param options - Options to configure waiting behavior.
+ * @returns A promise which resolves to the main resource response.
+ */
+ abstract waitForNavigation(
+ options?: WaitForOptions
+ ): Promise<HTTPResponse | null>;
+
+ /**
+ * @internal
+ */
+ abstract get client(): CDPSession;
+
+ /**
+ * @internal
+ */
+ abstract mainRealm(): Realm;
+
+ /**
+ * @internal
+ */
+ abstract isolatedRealm(): Realm;
+
+ #_document: Promise<ElementHandle<Document>> | undefined;
+
+ /**
+ * @internal
+ */
+ #document(): Promise<ElementHandle<Document>> {
+ if (!this.#_document) {
+ this.#_document = this.isolatedRealm()
+ .evaluateHandle(() => {
+ return document;
+ })
+ .then(handle => {
+ return this.mainRealm().transferHandle(handle);
+ });
+ }
+ return this.#_document;
+ }
+
+ /**
+ * Used to clear the document handle that has been destroyed.
+ *
+ * @internal
+ */
+ clearDocumentHandle(): void {
+ this.#_document = undefined;
+ }
+
+ /**
+ * @internal
+ */
+ @throwIfDetached
+ async frameElement(): Promise<HandleFor<HTMLIFrameElement> | null> {
+ const parentFrame = this.parentFrame();
+ if (!parentFrame) {
+ return null;
+ }
+ using list = await parentFrame.isolatedRealm().evaluateHandle(() => {
+ return document.querySelectorAll('iframe');
+ });
+ for await (using iframe of transposeIterableHandle(list)) {
+ const frame = await iframe.contentFrame();
+ if (frame._id === this._id) {
+ return iframe.move();
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Behaves identically to {@link Page.evaluateHandle} except it's run within
+ * the context of this frame.
+ *
+ * @see {@link Page.evaluateHandle} for details.
+ */
+ @throwIfDetached
+ async evaluateHandle<
+ Params extends unknown[],
+ Func extends EvaluateFunc<Params> = EvaluateFunc<Params>,
+ >(
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<HandleFor<Awaited<ReturnType<Func>>>> {
+ pageFunction = withSourcePuppeteerURLIfNone(
+ this.evaluateHandle.name,
+ pageFunction
+ );
+ return await this.mainRealm().evaluateHandle(pageFunction, ...args);
+ }
+
+ /**
+ * Behaves identically to {@link Page.evaluate} except it's run within the
+ * the context of this frame.
+ *
+ * @see {@link Page.evaluate} for details.
+ */
+ @throwIfDetached
+ async evaluate<
+ Params extends unknown[],
+ Func extends EvaluateFunc<Params> = EvaluateFunc<Params>,
+ >(
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<Awaited<ReturnType<Func>>> {
+ pageFunction = withSourcePuppeteerURLIfNone(
+ this.evaluate.name,
+ pageFunction
+ );
+ return await this.mainRealm().evaluate(pageFunction, ...args);
+ }
+
+ /**
+ * Creates a locator for the provided selector. See {@link Locator} for
+ * details and supported actions.
+ *
+ * @remarks
+ * Locators API is experimental and we will not follow semver for breaking
+ * change in the Locators API.
+ */
+ locator<Selector extends string>(
+ selector: Selector
+ ): Locator<NodeFor<Selector>>;
+
+ /**
+ * Creates a locator for the provided function. See {@link Locator} for
+ * details and supported actions.
+ *
+ * @remarks
+ * Locators API is experimental and we will not follow semver for breaking
+ * change in the Locators API.
+ */
+ locator<Ret>(func: () => Awaitable<Ret>): Locator<Ret>;
+
+ /**
+ * @internal
+ */
+ @throwIfDetached
+ locator<Selector extends string, Ret>(
+ selectorOrFunc: Selector | (() => Awaitable<Ret>)
+ ): Locator<NodeFor<Selector>> | Locator<Ret> {
+ if (typeof selectorOrFunc === 'string') {
+ return NodeLocator.create(this, selectorOrFunc);
+ } else {
+ return FunctionLocator.create(this, selectorOrFunc);
+ }
+ }
+ /**
+ * Queries the frame for an element matching the given selector.
+ *
+ * @param selector - The selector to query for.
+ * @returns A {@link ElementHandle | element handle} to the first element
+ * matching the given selector. Otherwise, `null`.
+ */
+ @throwIfDetached
+ async $<Selector extends string>(
+ selector: Selector
+ ): Promise<ElementHandle<NodeFor<Selector>> | null> {
+ // eslint-disable-next-line rulesdir/use-using -- This is cached.
+ const document = await this.#document();
+ return await document.$(selector);
+ }
+
+ /**
+ * Queries the frame for all elements matching the given selector.
+ *
+ * @param selector - The selector to query for.
+ * @returns An array of {@link ElementHandle | element handles} that point to
+ * elements matching the given selector.
+ */
+ @throwIfDetached
+ async $$<Selector extends string>(
+ selector: Selector
+ ): Promise<Array<ElementHandle<NodeFor<Selector>>>> {
+ // eslint-disable-next-line rulesdir/use-using -- This is cached.
+ const document = await this.#document();
+ return await document.$$(selector);
+ }
+
+ /**
+ * Runs the given function on the first element matching the given selector in
+ * the frame.
+ *
+ * If the given function returns a promise, then this method will wait till
+ * the promise resolves.
+ *
+ * @example
+ *
+ * ```ts
+ * const searchValue = await frame.$eval('#search', el => el.value);
+ * ```
+ *
+ * @param selector - The selector to query for.
+ * @param pageFunction - The function to be evaluated in the frame's context.
+ * The first element matching the selector will be passed to the function as
+ * its first argument.
+ * @param args - Additional arguments to pass to `pageFunction`.
+ * @returns A promise to the result of the function.
+ */
+ @throwIfDetached
+ async $eval<
+ Selector extends string,
+ Params extends unknown[],
+ Func extends EvaluateFuncWith<NodeFor<Selector>, Params> = EvaluateFuncWith<
+ NodeFor<Selector>,
+ Params
+ >,
+ >(
+ selector: Selector,
+ pageFunction: string | Func,
+ ...args: Params
+ ): Promise<Awaited<ReturnType<Func>>> {
+ pageFunction = withSourcePuppeteerURLIfNone(this.$eval.name, pageFunction);
+ // eslint-disable-next-line rulesdir/use-using -- This is cached.
+ const document = await this.#document();
+ return await document.$eval(selector, pageFunction, ...args);
+ }
+
+ /**
+ * Runs the given function on an array of elements matching the given selector
+ * in the frame.
+ *
+ * If the given function returns a promise, then this method will wait till
+ * the promise resolves.
+ *
+ * @example
+ *
+ * ```ts
+ * const divsCounts = await frame.$$eval('div', divs => divs.length);
+ * ```
+ *
+ * @param selector - The selector to query for.
+ * @param pageFunction - The function to be evaluated in the frame's context.
+ * An array of elements matching the given selector will be passed to the
+ * function as its first argument.
+ * @param args - Additional arguments to pass to `pageFunction`.
+ * @returns A promise to the result of the function.
+ */
+ @throwIfDetached
+ async $$eval<
+ Selector extends string,
+ Params extends unknown[],
+ Func extends EvaluateFuncWith<
+ Array<NodeFor<Selector>>,
+ Params
+ > = EvaluateFuncWith<Array<NodeFor<Selector>>, Params>,
+ >(
+ selector: Selector,
+ pageFunction: string | Func,
+ ...args: Params
+ ): Promise<Awaited<ReturnType<Func>>> {
+ pageFunction = withSourcePuppeteerURLIfNone(this.$$eval.name, pageFunction);
+ // eslint-disable-next-line rulesdir/use-using -- This is cached.
+ const document = await this.#document();
+ return await document.$$eval(selector, pageFunction, ...args);
+ }
+
+ /**
+ * @deprecated Use {@link Frame.$$} with the `xpath` prefix.
+ *
+ * Example: `await frame.$$('xpath/' + xpathExpression)`
+ *
+ * This method evaluates the given XPath expression and returns the results.
+ * If `xpath` starts with `//` instead of `.//`, the dot will be appended
+ * automatically.
+ * @param expression - the XPath expression to evaluate.
+ */
+ @throwIfDetached
+ async $x(expression: string): Promise<Array<ElementHandle<Node>>> {
+ // eslint-disable-next-line rulesdir/use-using -- This is cached.
+ const document = await this.#document();
+ return await document.$x(expression);
+ }
+
+ /**
+ * Waits for an element matching the given selector to appear in the frame.
+ *
+ * This method works across navigations.
+ *
+ * @example
+ *
+ * ```ts
+ * import puppeteer from 'puppeteer';
+ *
+ * (async () => {
+ * const browser = await puppeteer.launch();
+ * const page = await browser.newPage();
+ * let currentURL;
+ * page
+ * .mainFrame()
+ * .waitForSelector('img')
+ * .then(() => console.log('First URL with image: ' + currentURL));
+ *
+ * for (currentURL of [
+ * 'https://example.com',
+ * 'https://google.com',
+ * 'https://bbc.com',
+ * ]) {
+ * await page.goto(currentURL);
+ * }
+ * await browser.close();
+ * })();
+ * ```
+ *
+ * @param selector - The selector to query and wait for.
+ * @param options - Options for customizing waiting behavior.
+ * @returns An element matching the given selector.
+ * @throws Throws if an element matching the given selector doesn't appear.
+ */
+ @throwIfDetached
+ async waitForSelector<Selector extends string>(
+ selector: Selector,
+ options: WaitForSelectorOptions = {}
+ ): Promise<ElementHandle<NodeFor<Selector>> | null> {
+ const {updatedSelector, QueryHandler} =
+ getQueryHandlerAndSelector(selector);
+ return (await QueryHandler.waitFor(
+ this,
+ updatedSelector,
+ options
+ )) as ElementHandle<NodeFor<Selector>> | null;
+ }
+
+ /**
+ * @deprecated Use {@link Frame.waitForSelector} with the `xpath` prefix.
+ *
+ * Example: `await frame.waitForSelector('xpath/' + xpathExpression)`
+ *
+ * The method evaluates the XPath expression relative to the Frame.
+ * If `xpath` starts with `//` instead of `.//`, the dot will be appended
+ * automatically.
+ *
+ * Wait for the `xpath` to appear in page. If at the moment of calling the
+ * method the `xpath` already exists, the method will return immediately. If
+ * the xpath doesn't appear after the `timeout` milliseconds of waiting, the
+ * function will throw.
+ *
+ * For a code example, see the example for {@link Frame.waitForSelector}. That
+ * function behaves identically other than taking a CSS selector rather than
+ * an XPath.
+ *
+ * @param xpath - the XPath expression to wait for.
+ * @param options - options to configure the visibility of the element and how
+ * long to wait before timing out.
+ */
+ @throwIfDetached
+ async waitForXPath(
+ xpath: string,
+ options: WaitForSelectorOptions = {}
+ ): Promise<ElementHandle<Node> | null> {
+ if (xpath.startsWith('//')) {
+ xpath = `.${xpath}`;
+ }
+ return await this.waitForSelector(`xpath/${xpath}`, options);
+ }
+
+ /**
+ * @example
+ * The `waitForFunction` can be used to observe viewport size change:
+ *
+ * ```ts
+ * import puppeteer from 'puppeteer';
+ *
+ * (async () => {
+ * . const browser = await puppeteer.launch();
+ * . const page = await browser.newPage();
+ * . const watchDog = page.mainFrame().waitForFunction('window.innerWidth < 100');
+ * . page.setViewport({width: 50, height: 50});
+ * . await watchDog;
+ * . await browser.close();
+ * })();
+ * ```
+ *
+ * To pass arguments from Node.js to the predicate of `page.waitForFunction` function:
+ *
+ * ```ts
+ * const selector = '.foo';
+ * await frame.waitForFunction(
+ * selector => !!document.querySelector(selector),
+ * {}, // empty options object
+ * selector
+ * );
+ * ```
+ *
+ * @param pageFunction - the function to evaluate in the frame context.
+ * @param options - options to configure the polling method and timeout.
+ * @param args - arguments to pass to the `pageFunction`.
+ * @returns the promise which resolve when the `pageFunction` returns a truthy value.
+ */
+ @throwIfDetached
+ async waitForFunction<
+ Params extends unknown[],
+ Func extends EvaluateFunc<Params> = EvaluateFunc<Params>,
+ >(
+ pageFunction: Func | string,
+ options: FrameWaitForFunctionOptions = {},
+ ...args: Params
+ ): Promise<HandleFor<Awaited<ReturnType<Func>>>> {
+ return await (this.mainRealm().waitForFunction(
+ pageFunction,
+ options,
+ ...args
+ ) as Promise<HandleFor<Awaited<ReturnType<Func>>>>);
+ }
+ /**
+ * The full HTML contents of the frame, including the DOCTYPE.
+ */
+ @throwIfDetached
+ async content(): Promise<string> {
+ return await this.evaluate(() => {
+ let content = '';
+ for (const node of document.childNodes) {
+ switch (node) {
+ case document.documentElement:
+ content += document.documentElement.outerHTML;
+ break;
+ default:
+ content += new XMLSerializer().serializeToString(node);
+ break;
+ }
+ }
+
+ return content;
+ });
+ }
+
+ /**
+ * Set the content of the frame.
+ *
+ * @param html - HTML markup to assign to the page.
+ * @param options - Options to configure how long before timing out and at
+ * what point to consider the content setting successful.
+ */
+ abstract setContent(
+ html: string,
+ options?: {
+ timeout?: number;
+ waitUntil?: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[];
+ }
+ ): Promise<void>;
+
+ /**
+ * @internal
+ */
+ async setFrameContent(content: string): Promise<void> {
+ return await this.evaluate(html => {
+ document.open();
+ document.write(html);
+ document.close();
+ }, content);
+ }
+
+ /**
+ * The frame's `name` attribute as specified in the tag.
+ *
+ * @remarks
+ * If the name is empty, it returns the `id` attribute instead.
+ *
+ * @remarks
+ * This value is calculated once when the frame is created, and will not
+ * update if the attribute is changed later.
+ */
+ name(): string {
+ return this._name || '';
+ }
+
+ /**
+ * The frame's URL.
+ */
+ abstract url(): string;
+
+ /**
+ * The parent frame, if any. Detached and main frames return `null`.
+ */
+ abstract parentFrame(): Frame | null;
+
+ /**
+ * An array of child frames.
+ */
+ abstract childFrames(): Frame[];
+
+ /**
+ * @returns `true` if the frame has detached. `false` otherwise.
+ */
+ abstract get detached(): boolean;
+
+ /**
+ * Is`true` if the frame has been detached. Otherwise, `false`.
+ *
+ * @deprecated Use the `detached` getter.
+ */
+ isDetached(): boolean {
+ return this.detached;
+ }
+
+ /**
+ * @internal
+ */
+ get disposed(): boolean {
+ return this.detached;
+ }
+
+ /**
+ * Adds a `<script>` tag into the page with the desired url or content.
+ *
+ * @param options - Options for the script.
+ * @returns An {@link ElementHandle | element handle} to the injected
+ * `<script>` element.
+ */
+ @throwIfDetached
+ async addScriptTag(
+ options: FrameAddScriptTagOptions
+ ): Promise<ElementHandle<HTMLScriptElement>> {
+ let {content = '', type} = options;
+ const {path} = options;
+ if (+!!options.url + +!!path + +!!content !== 1) {
+ throw new Error(
+ 'Exactly one of `url`, `path`, or `content` must be specified.'
+ );
+ }
+
+ if (path) {
+ const fs = await importFSPromises();
+ content = await fs.readFile(path, 'utf8');
+ content += `//# sourceURL=${path.replace(/\n/g, '')}`;
+ }
+
+ type = type ?? 'text/javascript';
+
+ return await this.mainRealm().transferHandle(
+ await this.isolatedRealm().evaluateHandle(
+ async ({Deferred}, {url, id, type, content}) => {
+ const deferred = Deferred.create<void>();
+ const script = document.createElement('script');
+ script.type = type;
+ script.text = content;
+ if (url) {
+ script.src = url;
+ script.addEventListener(
+ 'load',
+ () => {
+ return deferred.resolve();
+ },
+ {once: true}
+ );
+ script.addEventListener(
+ 'error',
+ event => {
+ deferred.reject(
+ new Error(event.message ?? 'Could not load script')
+ );
+ },
+ {once: true}
+ );
+ } else {
+ deferred.resolve();
+ }
+ if (id) {
+ script.id = id;
+ }
+ document.head.appendChild(script);
+ await deferred.valueOrThrow();
+ return script;
+ },
+ LazyArg.create(context => {
+ return context.puppeteerUtil;
+ }),
+ {...options, type, content}
+ )
+ );
+ }
+
+ /**
+ * Adds a `HTMLStyleElement` into the frame with the desired URL
+ *
+ * @returns An {@link ElementHandle | element handle} to the loaded `<style>`
+ * element.
+ */
+ async addStyleTag(
+ options: Omit<FrameAddStyleTagOptions, 'url'>
+ ): Promise<ElementHandle<HTMLStyleElement>>;
+
+ /**
+ * Adds a `HTMLLinkElement` into the frame with the desired URL
+ *
+ * @returns An {@link ElementHandle | element handle} to the loaded `<link>`
+ * element.
+ */
+ async addStyleTag(
+ options: FrameAddStyleTagOptions
+ ): Promise<ElementHandle<HTMLLinkElement>>;
+
+ /**
+ * @internal
+ */
+ @throwIfDetached
+ async addStyleTag(
+ options: FrameAddStyleTagOptions
+ ): Promise<ElementHandle<HTMLStyleElement | HTMLLinkElement>> {
+ let {content = ''} = options;
+ const {path} = options;
+ if (+!!options.url + +!!path + +!!content !== 1) {
+ throw new Error(
+ 'Exactly one of `url`, `path`, or `content` must be specified.'
+ );
+ }
+
+ if (path) {
+ const fs = await importFSPromises();
+
+ content = await fs.readFile(path, 'utf8');
+ content += '/*# sourceURL=' + path.replace(/\n/g, '') + '*/';
+ options.content = content;
+ }
+
+ return await this.mainRealm().transferHandle(
+ await this.isolatedRealm().evaluateHandle(
+ async ({Deferred}, {url, content}) => {
+ const deferred = Deferred.create<void>();
+ let element: HTMLStyleElement | HTMLLinkElement;
+ if (!url) {
+ element = document.createElement('style');
+ element.appendChild(document.createTextNode(content!));
+ } else {
+ const link = document.createElement('link');
+ link.rel = 'stylesheet';
+ link.href = url;
+ element = link;
+ }
+ element.addEventListener(
+ 'load',
+ () => {
+ deferred.resolve();
+ },
+ {once: true}
+ );
+ element.addEventListener(
+ 'error',
+ event => {
+ deferred.reject(
+ new Error(
+ (event as ErrorEvent).message ?? 'Could not load style'
+ )
+ );
+ },
+ {once: true}
+ );
+ document.head.appendChild(element);
+ await deferred.valueOrThrow();
+ return element;
+ },
+ LazyArg.create(context => {
+ return context.puppeteerUtil;
+ }),
+ options
+ )
+ );
+ }
+
+ /**
+ * Clicks the first element found that matches `selector`.
+ *
+ * @remarks
+ * If `click()` triggers a navigation event and there's a separate
+ * `page.waitForNavigation()` promise to be resolved, you may end up with a
+ * race condition that yields unexpected results. The correct pattern for
+ * click and wait for navigation is the following:
+ *
+ * ```ts
+ * const [response] = await Promise.all([
+ * page.waitForNavigation(waitOptions),
+ * frame.click(selector, clickOptions),
+ * ]);
+ * ```
+ *
+ * @param selector - The selector to query for.
+ */
+ @throwIfDetached
+ async click(
+ selector: string,
+ options: Readonly<ClickOptions> = {}
+ ): Promise<void> {
+ using handle = await this.$(selector);
+ assert(handle, `No element found for selector: ${selector}`);
+ await handle.click(options);
+ await handle.dispose();
+ }
+
+ /**
+ * Focuses the first element that matches the `selector`.
+ *
+ * @param selector - The selector to query for.
+ * @throws Throws if there's no element matching `selector`.
+ */
+ @throwIfDetached
+ async focus(selector: string): Promise<void> {
+ using handle = await this.$(selector);
+ assert(handle, `No element found for selector: ${selector}`);
+ await handle.focus();
+ }
+
+ /**
+ * Hovers the pointer over the center of the first element that matches the
+ * `selector`.
+ *
+ * @param selector - The selector to query for.
+ * @throws Throws if there's no element matching `selector`.
+ */
+ @throwIfDetached
+ async hover(selector: string): Promise<void> {
+ using handle = await this.$(selector);
+ assert(handle, `No element found for selector: ${selector}`);
+ await handle.hover();
+ }
+
+ /**
+ * Selects a set of value on the first `<select>` element that matches the
+ * `selector`.
+ *
+ * @example
+ *
+ * ```ts
+ * frame.select('select#colors', 'blue'); // single selection
+ * frame.select('select#colors', 'red', 'green', 'blue'); // multiple selections
+ * ```
+ *
+ * @param selector - The selector to query for.
+ * @param values - The array of values to select. If the `<select>` has the
+ * `multiple` attribute, all values are considered, otherwise only the first
+ * one is taken into account.
+ * @returns the list of values that were successfully selected.
+ * @throws Throws if there's no `<select>` matching `selector`.
+ */
+ @throwIfDetached
+ async select(selector: string, ...values: string[]): Promise<string[]> {
+ using handle = await this.$(selector);
+ assert(handle, `No element found for selector: ${selector}`);
+ return await handle.select(...values);
+ }
+
+ /**
+ * Taps the first element that matches the `selector`.
+ *
+ * @param selector - The selector to query for.
+ * @throws Throws if there's no element matching `selector`.
+ */
+ @throwIfDetached
+ async tap(selector: string): Promise<void> {
+ using handle = await this.$(selector);
+ assert(handle, `No element found for selector: ${selector}`);
+ await handle.tap();
+ }
+
+ /**
+ * Sends a `keydown`, `keypress`/`input`, and `keyup` event for each character
+ * in the text.
+ *
+ * @remarks
+ * To press a special key, like `Control` or `ArrowDown`, use
+ * {@link Keyboard.press}.
+ *
+ * @example
+ *
+ * ```ts
+ * await frame.type('#mytextarea', 'Hello'); // Types instantly
+ * await frame.type('#mytextarea', 'World', {delay: 100}); // Types slower, like a user
+ * ```
+ *
+ * @param selector - the selector for the element to type into. If there are
+ * multiple the first will be used.
+ * @param text - text to type into the element
+ * @param options - takes one option, `delay`, which sets the time to wait
+ * between key presses in milliseconds. Defaults to `0`.
+ */
+ @throwIfDetached
+ async type(
+ selector: string,
+ text: string,
+ options?: Readonly<KeyboardTypeOptions>
+ ): Promise<void> {
+ using handle = await this.$(selector);
+ assert(handle, `No element found for selector: ${selector}`);
+ await handle.type(text, options);
+ }
+
+ /**
+ * @deprecated Replace with `new Promise(r => setTimeout(r, milliseconds));`.
+ *
+ * Causes your script to wait for the given number of milliseconds.
+ *
+ * @remarks
+ * It's generally recommended to not wait for a number of seconds, but instead
+ * use {@link Frame.waitForSelector}, {@link Frame.waitForXPath} or
+ * {@link Frame.waitForFunction} to wait for exactly the conditions you want.
+ *
+ * @example
+ *
+ * Wait for 1 second:
+ *
+ * ```ts
+ * await frame.waitForTimeout(1000);
+ * ```
+ *
+ * @param milliseconds - the number of milliseconds to wait.
+ */
+ async waitForTimeout(milliseconds: number): Promise<void> {
+ return await new Promise(resolve => {
+ setTimeout(resolve, milliseconds);
+ });
+ }
+
+ /**
+ * The frame's title.
+ */
+ @throwIfDetached
+ async title(): Promise<string> {
+ return await this.isolatedRealm().evaluate(() => {
+ return document.title;
+ });
+ }
+
+ /**
+ * This method is typically coupled with an action that triggers a device
+ * request from an api such as WebBluetooth.
+ *
+ * :::caution
+ *
+ * This must be called before the device request is made. It will not return a
+ * currently active device prompt.
+ *
+ * :::
+ *
+ * @example
+ *
+ * ```ts
+ * const [devicePrompt] = Promise.all([
+ * frame.waitForDevicePrompt(),
+ * frame.click('#connect-bluetooth'),
+ * ]);
+ * await devicePrompt.select(
+ * await devicePrompt.waitForDevice(({name}) => name.includes('My Device'))
+ * );
+ * ```
+ *
+ * @internal
+ */
+ abstract waitForDevicePrompt(
+ options?: WaitTimeoutOptions
+ ): Promise<DeviceRequestPrompt>;
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/HTTPRequest.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/HTTPRequest.ts
new file mode 100644
index 0000000000..3c952371ee
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/HTTPRequest.ts
@@ -0,0 +1,521 @@
+/**
+ * @license
+ * Copyright 2020 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import type {Protocol} from 'devtools-protocol';
+
+import type {CDPSession} from './CDPSession.js';
+import type {Frame} from './Frame.js';
+import type {HTTPResponse} from './HTTPResponse.js';
+
+/**
+ * @public
+ */
+export interface ContinueRequestOverrides {
+ /**
+ * If set, the request URL will change. This is not a redirect.
+ */
+ url?: string;
+ method?: string;
+ postData?: string;
+ headers?: Record<string, string>;
+}
+
+/**
+ * @public
+ */
+export interface InterceptResolutionState {
+ action: InterceptResolutionAction;
+ priority?: number;
+}
+
+/**
+ * Required response data to fulfill a request with.
+ *
+ * @public
+ */
+export interface ResponseForRequest {
+ status: number;
+ /**
+ * Optional response headers. All values are converted to strings.
+ */
+ headers: Record<string, unknown>;
+ contentType: string;
+ body: string | Buffer;
+}
+
+/**
+ * Resource types for HTTPRequests as perceived by the rendering engine.
+ *
+ * @public
+ */
+export type ResourceType = Lowercase<Protocol.Network.ResourceType>;
+
+/**
+ * The default cooperative request interception resolution priority
+ *
+ * @public
+ */
+export const DEFAULT_INTERCEPT_RESOLUTION_PRIORITY = 0;
+
+/**
+ * Represents an HTTP request sent by a page.
+ * @remarks
+ *
+ * Whenever the page sends a request, such as for a network resource, the
+ * following events are emitted by Puppeteer's `page`:
+ *
+ * - `request`: emitted when the request is issued by the page.
+ * - `requestfinished` - emitted when the response body is downloaded and the
+ * request is complete.
+ *
+ * If request fails at some point, then instead of `requestfinished` event the
+ * `requestfailed` event is emitted.
+ *
+ * All of these events provide an instance of `HTTPRequest` representing the
+ * request that occurred:
+ *
+ * ```
+ * page.on('request', request => ...)
+ * ```
+ *
+ * NOTE: HTTP Error responses, such as 404 or 503, are still successful
+ * responses from HTTP standpoint, so request will complete with
+ * `requestfinished` event.
+ *
+ * If request gets a 'redirect' response, the request is successfully finished
+ * with the `requestfinished` event, and a new request is issued to a
+ * redirected url.
+ *
+ * @public
+ */
+export abstract class HTTPRequest {
+ /**
+ * @internal
+ */
+ _requestId = '';
+ /**
+ * @internal
+ */
+ _interceptionId: string | undefined;
+ /**
+ * @internal
+ */
+ _failureText: string | null = null;
+ /**
+ * @internal
+ */
+ _response: HTTPResponse | null = null;
+ /**
+ * @internal
+ */
+ _fromMemoryCache = false;
+ /**
+ * @internal
+ */
+ _redirectChain: HTTPRequest[] = [];
+
+ /**
+ * Warning! Using this client can break Puppeteer. Use with caution.
+ *
+ * @experimental
+ */
+ abstract get client(): CDPSession;
+
+ /**
+ * @internal
+ */
+ constructor() {}
+
+ /**
+ * The URL of the request
+ */
+ abstract url(): string;
+
+ /**
+ * The `ContinueRequestOverrides` that will be used
+ * if the interception is allowed to continue (ie, `abort()` and
+ * `respond()` aren't called).
+ */
+ abstract continueRequestOverrides(): ContinueRequestOverrides;
+
+ /**
+ * The `ResponseForRequest` that gets used if the
+ * interception is allowed to respond (ie, `abort()` is not called).
+ */
+ abstract responseForRequest(): Partial<ResponseForRequest> | null;
+
+ /**
+ * The most recent reason for aborting the request
+ */
+ abstract abortErrorReason(): Protocol.Network.ErrorReason | null;
+
+ /**
+ * An InterceptResolutionState object describing the current resolution
+ * action and priority.
+ *
+ * InterceptResolutionState contains:
+ * action: InterceptResolutionAction
+ * priority?: number
+ *
+ * InterceptResolutionAction is one of: `abort`, `respond`, `continue`,
+ * `disabled`, `none`, or `already-handled`.
+ */
+ abstract interceptResolutionState(): InterceptResolutionState;
+
+ /**
+ * Is `true` if the intercept resolution has already been handled,
+ * `false` otherwise.
+ */
+ abstract isInterceptResolutionHandled(): boolean;
+
+ /**
+ * Adds an async request handler to the processing queue.
+ * Deferred handlers are not guaranteed to execute in any particular order,
+ * but they are guaranteed to resolve before the request interception
+ * is finalized.
+ */
+ abstract enqueueInterceptAction(
+ pendingHandler: () => void | PromiseLike<unknown>
+ ): void;
+
+ /**
+ * Awaits pending interception handlers and then decides how to fulfill
+ * the request interception.
+ */
+ abstract finalizeInterceptions(): Promise<void>;
+
+ /**
+ * Contains the request's resource type as it was perceived by the rendering
+ * engine.
+ */
+ abstract resourceType(): ResourceType;
+
+ /**
+ * The method used (`GET`, `POST`, etc.)
+ */
+ abstract method(): string;
+
+ /**
+ * The request's post body, if any.
+ */
+ abstract postData(): string | undefined;
+
+ /**
+ * True when the request has POST data. Note that {@link HTTPRequest.postData}
+ * might still be undefined when this flag is true when the data is too long
+ * or not readily available in the decoded form. In that case, use
+ * {@link HTTPRequest.fetchPostData}.
+ */
+ abstract hasPostData(): boolean;
+
+ /**
+ * Fetches the POST data for the request from the browser.
+ */
+ abstract fetchPostData(): Promise<string | undefined>;
+
+ /**
+ * An object with HTTP headers associated with the request. All
+ * header names are lower-case.
+ */
+ abstract headers(): Record<string, string>;
+
+ /**
+ * A matching `HTTPResponse` object, or null if the response has not
+ * been received yet.
+ */
+ abstract response(): HTTPResponse | null;
+
+ /**
+ * The frame that initiated the request, or null if navigating to
+ * error pages.
+ */
+ abstract frame(): Frame | null;
+
+ /**
+ * True if the request is the driver of the current frame's navigation.
+ */
+ abstract isNavigationRequest(): boolean;
+
+ /**
+ * The initiator of the request.
+ */
+ abstract initiator(): Protocol.Network.Initiator | undefined;
+
+ /**
+ * A `redirectChain` is a chain of requests initiated to fetch a resource.
+ * @remarks
+ *
+ * `redirectChain` is shared between all the requests of the same chain.
+ *
+ * For example, if the website `http://example.com` has a single redirect to
+ * `https://example.com`, then the chain will contain one request:
+ *
+ * ```ts
+ * const response = await page.goto('http://example.com');
+ * const chain = response.request().redirectChain();
+ * console.log(chain.length); // 1
+ * console.log(chain[0].url()); // 'http://example.com'
+ * ```
+ *
+ * If the website `https://google.com` has no redirects, then the chain will be empty:
+ *
+ * ```ts
+ * const response = await page.goto('https://google.com');
+ * const chain = response.request().redirectChain();
+ * console.log(chain.length); // 0
+ * ```
+ *
+ * @returns the chain of requests - if a server responds with at least a
+ * single redirect, this chain will contain all requests that were redirected.
+ */
+ abstract redirectChain(): HTTPRequest[];
+
+ /**
+ * Access information about the request's failure.
+ *
+ * @remarks
+ *
+ * @example
+ *
+ * Example of logging all failed requests:
+ *
+ * ```ts
+ * page.on('requestfailed', request => {
+ * console.log(request.url() + ' ' + request.failure().errorText);
+ * });
+ * ```
+ *
+ * @returns `null` unless the request failed. If the request fails this can
+ * return an object with `errorText` containing a human-readable error
+ * message, e.g. `net::ERR_FAILED`. It is not guaranteed that there will be
+ * failure text if the request fails.
+ */
+ abstract failure(): {errorText: string} | null;
+
+ /**
+ * Continues request with optional request overrides.
+ *
+ * @example
+ *
+ * ```ts
+ * await page.setRequestInterception(true);
+ * page.on('request', request => {
+ * // Override headers
+ * const headers = Object.assign({}, request.headers(), {
+ * foo: 'bar', // set "foo" header
+ * origin: undefined, // remove "origin" header
+ * });
+ * request.continue({headers});
+ * });
+ * ```
+ *
+ * @param overrides - optional overrides to apply to the request.
+ * @param priority - If provided, intercept is resolved using cooperative
+ * handling rules. Otherwise, intercept is resolved immediately.
+ *
+ * @remarks
+ *
+ * To use this, request interception should be enabled with
+ * {@link Page.setRequestInterception}.
+ *
+ * Exception is immediately thrown if the request interception is not enabled.
+ */
+ abstract continue(
+ overrides?: ContinueRequestOverrides,
+ priority?: number
+ ): Promise<void>;
+
+ /**
+ * Fulfills a request with the given response.
+ *
+ * @example
+ * An example of fulfilling all requests with 404 responses:
+ *
+ * ```ts
+ * await page.setRequestInterception(true);
+ * page.on('request', request => {
+ * request.respond({
+ * status: 404,
+ * contentType: 'text/plain',
+ * body: 'Not Found!',
+ * });
+ * });
+ * ```
+ *
+ * NOTE: Mocking responses for dataURL requests is not supported.
+ * Calling `request.respond` for a dataURL request is a noop.
+ *
+ * @param response - the response to fulfill the request with.
+ * @param priority - If provided, intercept is resolved using
+ * cooperative handling rules. Otherwise, intercept is resolved
+ * immediately.
+ *
+ * @remarks
+ *
+ * To use this, request
+ * interception should be enabled with {@link Page.setRequestInterception}.
+ *
+ * Exception is immediately thrown if the request interception is not enabled.
+ */
+ abstract respond(
+ response: Partial<ResponseForRequest>,
+ priority?: number
+ ): Promise<void>;
+
+ /**
+ * Aborts a request.
+ *
+ * @param errorCode - optional error code to provide.
+ * @param priority - If provided, intercept is resolved using
+ * cooperative handling rules. Otherwise, intercept is resolved
+ * immediately.
+ *
+ * @remarks
+ *
+ * To use this, request interception should be enabled with
+ * {@link Page.setRequestInterception}. If it is not enabled, this method will
+ * throw an exception immediately.
+ */
+ abstract abort(errorCode?: ErrorCode, priority?: number): Promise<void>;
+}
+
+/**
+ * @public
+ */
+export enum InterceptResolutionAction {
+ Abort = 'abort',
+ Respond = 'respond',
+ Continue = 'continue',
+ Disabled = 'disabled',
+ None = 'none',
+ AlreadyHandled = 'already-handled',
+}
+
+/**
+ * @public
+ *
+ * @deprecated please use {@link InterceptResolutionAction} instead.
+ */
+export type InterceptResolutionStrategy = InterceptResolutionAction;
+
+/**
+ * @public
+ */
+export type ErrorCode =
+ | 'aborted'
+ | 'accessdenied'
+ | 'addressunreachable'
+ | 'blockedbyclient'
+ | 'blockedbyresponse'
+ | 'connectionaborted'
+ | 'connectionclosed'
+ | 'connectionfailed'
+ | 'connectionrefused'
+ | 'connectionreset'
+ | 'internetdisconnected'
+ | 'namenotresolved'
+ | 'timedout'
+ | 'failed';
+
+/**
+ * @public
+ */
+export type ActionResult = 'continue' | 'abort' | 'respond';
+
+/**
+ * @internal
+ */
+export function headersArray(
+ headers: Record<string, string | string[]>
+): Array<{name: string; value: string}> {
+ const result = [];
+ for (const name in headers) {
+ const value = headers[name];
+
+ if (!Object.is(value, undefined)) {
+ const values = Array.isArray(value) ? value : [value];
+
+ result.push(
+ ...values.map(value => {
+ return {name, value: value + ''};
+ })
+ );
+ }
+ }
+ return result;
+}
+
+/**
+ * @internal
+ *
+ * @remarks
+ * List taken from {@link https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml}
+ * with extra 306 and 418 codes.
+ */
+export const STATUS_TEXTS: Record<string, string> = {
+ '100': 'Continue',
+ '101': 'Switching Protocols',
+ '102': 'Processing',
+ '103': 'Early Hints',
+ '200': 'OK',
+ '201': 'Created',
+ '202': 'Accepted',
+ '203': 'Non-Authoritative Information',
+ '204': 'No Content',
+ '205': 'Reset Content',
+ '206': 'Partial Content',
+ '207': 'Multi-Status',
+ '208': 'Already Reported',
+ '226': 'IM Used',
+ '300': 'Multiple Choices',
+ '301': 'Moved Permanently',
+ '302': 'Found',
+ '303': 'See Other',
+ '304': 'Not Modified',
+ '305': 'Use Proxy',
+ '306': 'Switch Proxy',
+ '307': 'Temporary Redirect',
+ '308': 'Permanent Redirect',
+ '400': 'Bad Request',
+ '401': 'Unauthorized',
+ '402': 'Payment Required',
+ '403': 'Forbidden',
+ '404': 'Not Found',
+ '405': 'Method Not Allowed',
+ '406': 'Not Acceptable',
+ '407': 'Proxy Authentication Required',
+ '408': 'Request Timeout',
+ '409': 'Conflict',
+ '410': 'Gone',
+ '411': 'Length Required',
+ '412': 'Precondition Failed',
+ '413': 'Payload Too Large',
+ '414': 'URI Too Long',
+ '415': 'Unsupported Media Type',
+ '416': 'Range Not Satisfiable',
+ '417': 'Expectation Failed',
+ '418': "I'm a teapot",
+ '421': 'Misdirected Request',
+ '422': 'Unprocessable Entity',
+ '423': 'Locked',
+ '424': 'Failed Dependency',
+ '425': 'Too Early',
+ '426': 'Upgrade Required',
+ '428': 'Precondition Required',
+ '429': 'Too Many Requests',
+ '431': 'Request Header Fields Too Large',
+ '451': 'Unavailable For Legal Reasons',
+ '500': 'Internal Server Error',
+ '501': 'Not Implemented',
+ '502': 'Bad Gateway',
+ '503': 'Service Unavailable',
+ '504': 'Gateway Timeout',
+ '505': 'HTTP Version Not Supported',
+ '506': 'Variant Also Negotiates',
+ '507': 'Insufficient Storage',
+ '508': 'Loop Detected',
+ '510': 'Not Extended',
+ '511': 'Network Authentication Required',
+} as const;
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/HTTPResponse.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/HTTPResponse.ts
new file mode 100644
index 0000000000..906479eb43
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/HTTPResponse.ts
@@ -0,0 +1,129 @@
+/**
+ * @license
+ * Copyright 2023 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type Protocol from 'devtools-protocol';
+
+import type {SecurityDetails} from '../common/SecurityDetails.js';
+
+import type {Frame} from './Frame.js';
+import type {HTTPRequest} from './HTTPRequest.js';
+
+/**
+ * @public
+ */
+export interface RemoteAddress {
+ ip?: string;
+ port?: number;
+}
+
+/**
+ * The HTTPResponse class represents responses which are received by the
+ * {@link Page} class.
+ *
+ * @public
+ */
+export abstract class HTTPResponse {
+ /**
+ * @internal
+ */
+ constructor() {}
+
+ /**
+ * The IP address and port number used to connect to the remote
+ * server.
+ */
+ abstract remoteAddress(): RemoteAddress;
+
+ /**
+ * The URL of the response.
+ */
+ abstract url(): string;
+
+ /**
+ * True if the response was successful (status in the range 200-299).
+ */
+ ok(): boolean {
+ // TODO: document === 0 case?
+ const status = this.status();
+ return status === 0 || (status >= 200 && status <= 299);
+ }
+
+ /**
+ * The status code of the response (e.g., 200 for a success).
+ */
+ abstract status(): number;
+
+ /**
+ * The status text of the response (e.g. usually an "OK" for a
+ * success).
+ */
+ abstract statusText(): string;
+
+ /**
+ * An object with HTTP headers associated with the response. All
+ * header names are lower-case.
+ */
+ abstract headers(): Record<string, string>;
+
+ /**
+ * {@link SecurityDetails} if the response was received over the
+ * secure connection, or `null` otherwise.
+ */
+ abstract securityDetails(): SecurityDetails | null;
+
+ /**
+ * Timing information related to the response.
+ */
+ abstract timing(): Protocol.Network.ResourceTiming | null;
+
+ /**
+ * Promise which resolves to a buffer with response body.
+ */
+ abstract buffer(): Promise<Buffer>;
+
+ /**
+ * Promise which resolves to a text representation of response body.
+ */
+ async text(): Promise<string> {
+ const content = await this.buffer();
+ return content.toString('utf8');
+ }
+
+ /**
+ * Promise which resolves to a JSON representation of response body.
+ *
+ * @remarks
+ *
+ * This method will throw if the response body is not parsable via
+ * `JSON.parse`.
+ */
+ async json(): Promise<any> {
+ const content = await this.text();
+ return JSON.parse(content);
+ }
+
+ /**
+ * A matching {@link HTTPRequest} object.
+ */
+ abstract request(): HTTPRequest;
+
+ /**
+ * True if the response was served from either the browser's disk
+ * cache or memory cache.
+ */
+ abstract fromCache(): boolean;
+
+ /**
+ * True if the response was served by a service worker.
+ */
+ abstract fromServiceWorker(): boolean;
+
+ /**
+ * A {@link Frame} that initiated this response, or `null` if
+ * navigating to error pages.
+ */
+ abstract frame(): Frame | null;
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/Input.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/Input.ts
new file mode 100644
index 0000000000..6b41ca8fe1
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/Input.ts
@@ -0,0 +1,517 @@
+/**
+ * @license
+ * Copyright 2017 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {Protocol} from 'devtools-protocol';
+
+import type {KeyInput} from '../common/USKeyboardLayout.js';
+
+import type {Point} from './ElementHandle.js';
+
+/**
+ * @public
+ */
+export interface KeyDownOptions {
+ /**
+ * @deprecated Do not use. This is automatically handled.
+ */
+ text?: string;
+ /**
+ * @deprecated Do not use. This is automatically handled.
+ */
+ commands?: string[];
+}
+
+/**
+ * @public
+ */
+export interface KeyboardTypeOptions {
+ delay?: number;
+}
+
+/**
+ * @public
+ */
+export type KeyPressOptions = KeyDownOptions & KeyboardTypeOptions;
+
+/**
+ * Keyboard provides an api for managing a virtual keyboard.
+ * The high level api is {@link Keyboard."type"},
+ * which takes raw characters and generates proper keydown, keypress/input,
+ * and keyup events on your page.
+ *
+ * @remarks
+ * For finer control, you can use {@link Keyboard.down},
+ * {@link Keyboard.up}, and {@link Keyboard.sendCharacter}
+ * to manually fire events as if they were generated from a real keyboard.
+ *
+ * On macOS, keyboard shortcuts like `⌘ A` -\> Select All do not work.
+ * See {@link https://github.com/puppeteer/puppeteer/issues/1313 | #1313}.
+ *
+ * @example
+ * An example of holding down `Shift` in order to select and delete some text:
+ *
+ * ```ts
+ * await page.keyboard.type('Hello World!');
+ * await page.keyboard.press('ArrowLeft');
+ *
+ * await page.keyboard.down('Shift');
+ * for (let i = 0; i < ' World'.length; i++)
+ * await page.keyboard.press('ArrowLeft');
+ * await page.keyboard.up('Shift');
+ *
+ * await page.keyboard.press('Backspace');
+ * // Result text will end up saying 'Hello!'
+ * ```
+ *
+ * @example
+ * An example of pressing `A`
+ *
+ * ```ts
+ * await page.keyboard.down('Shift');
+ * await page.keyboard.press('KeyA');
+ * await page.keyboard.up('Shift');
+ * ```
+ *
+ * @public
+ */
+export abstract class Keyboard {
+ /**
+ * @internal
+ */
+ constructor() {}
+
+ /**
+ * Dispatches a `keydown` event.
+ *
+ * @remarks
+ * If `key` is a single character and no modifier keys besides `Shift`
+ * are being held down, a `keypress`/`input` event will also generated.
+ * The `text` option can be specified to force an input event to be generated.
+ * If `key` is a modifier key, `Shift`, `Meta`, `Control`, or `Alt`,
+ * subsequent key presses will be sent with that modifier active.
+ * To release the modifier key, use {@link Keyboard.up}.
+ *
+ * After the key is pressed once, subsequent calls to
+ * {@link Keyboard.down} will have
+ * {@link https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/repeat | repeat}
+ * set to true. To release the key, use {@link Keyboard.up}.
+ *
+ * Modifier keys DO influence {@link Keyboard.down}.
+ * Holding down `Shift` will type the text in upper case.
+ *
+ * @param key - Name of key to press, such as `ArrowLeft`.
+ * See {@link KeyInput} for a list of all key names.
+ *
+ * @param options - An object of options. Accepts text which, if specified,
+ * generates an input event with this text. Accepts commands which, if specified,
+ * is the commands of keyboard shortcuts,
+ * see {@link https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/editing/commands/editor_command_names.h | Chromium Source Code} for valid command names.
+ */
+ abstract down(
+ key: KeyInput,
+ options?: Readonly<KeyDownOptions>
+ ): Promise<void>;
+
+ /**
+ * Dispatches a `keyup` event.
+ *
+ * @param key - Name of key to release, such as `ArrowLeft`.
+ * See {@link KeyInput | KeyInput}
+ * for a list of all key names.
+ */
+ abstract up(key: KeyInput): Promise<void>;
+
+ /**
+ * Dispatches a `keypress` and `input` event.
+ * This does not send a `keydown` or `keyup` event.
+ *
+ * @remarks
+ * Modifier keys DO NOT effect {@link Keyboard.sendCharacter | Keyboard.sendCharacter}.
+ * Holding down `Shift` will not type the text in upper case.
+ *
+ * @example
+ *
+ * ```ts
+ * page.keyboard.sendCharacter('å—¨');
+ * ```
+ *
+ * @param char - Character to send into the page.
+ */
+ abstract sendCharacter(char: string): Promise<void>;
+
+ /**
+ * Sends a `keydown`, `keypress`/`input`,
+ * and `keyup` event for each character in the text.
+ *
+ * @remarks
+ * To press a special key, like `Control` or `ArrowDown`,
+ * use {@link Keyboard.press}.
+ *
+ * Modifier keys DO NOT effect `keyboard.type`.
+ * Holding down `Shift` will not type the text in upper case.
+ *
+ * @example
+ *
+ * ```ts
+ * await page.keyboard.type('Hello'); // Types instantly
+ * await page.keyboard.type('World', {delay: 100}); // Types slower, like a user
+ * ```
+ *
+ * @param text - A text to type into a focused element.
+ * @param options - An object of options. Accepts delay which,
+ * if specified, is the time to wait between `keydown` and `keyup` in milliseconds.
+ * Defaults to 0.
+ */
+ abstract type(
+ text: string,
+ options?: Readonly<KeyboardTypeOptions>
+ ): Promise<void>;
+
+ /**
+ * Shortcut for {@link Keyboard.down}
+ * and {@link Keyboard.up}.
+ *
+ * @remarks
+ * If `key` is a single character and no modifier keys besides `Shift`
+ * are being held down, a `keypress`/`input` event will also generated.
+ * The `text` option can be specified to force an input event to be generated.
+ *
+ * Modifier keys DO effect {@link Keyboard.press}.
+ * Holding down `Shift` will type the text in upper case.
+ *
+ * @param key - Name of key to press, such as `ArrowLeft`.
+ * See {@link KeyInput} for a list of all key names.
+ *
+ * @param options - An object of options. Accepts text which, if specified,
+ * generates an input event with this text. Accepts delay which,
+ * if specified, is the time to wait between `keydown` and `keyup` in milliseconds.
+ * Defaults to 0. Accepts commands which, if specified,
+ * is the commands of keyboard shortcuts,
+ * see {@link https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/editing/commands/editor_command_names.h | Chromium Source Code} for valid command names.
+ */
+ abstract press(
+ key: KeyInput,
+ options?: Readonly<KeyPressOptions>
+ ): Promise<void>;
+}
+
+/**
+ * @public
+ */
+export interface MouseOptions {
+ /**
+ * Determines which button will be pressed.
+ *
+ * @defaultValue `'left'`
+ */
+ button?: MouseButton;
+ /**
+ * Determines the click count for the mouse event. This does not perform
+ * multiple clicks.
+ *
+ * @deprecated Use {@link MouseClickOptions.count}.
+ * @defaultValue `1`
+ */
+ clickCount?: number;
+}
+
+/**
+ * @public
+ */
+export interface MouseClickOptions extends MouseOptions {
+ /**
+ * Time (in ms) to delay the mouse release after the mouse press.
+ */
+ delay?: number;
+ /**
+ * Number of clicks to perform.
+ *
+ * @defaultValue `1`
+ */
+ count?: number;
+}
+
+/**
+ * @public
+ */
+export interface MouseWheelOptions {
+ deltaX?: number;
+ deltaY?: number;
+}
+
+/**
+ * @public
+ */
+export interface MouseMoveOptions {
+ /**
+ * Determines the number of movements to make from the current mouse position
+ * to the new one.
+ *
+ * @defaultValue `1`
+ */
+ steps?: number;
+}
+
+/**
+ * Enum of valid mouse buttons.
+ *
+ * @public
+ */
+export const MouseButton = Object.freeze({
+ Left: 'left',
+ Right: 'right',
+ Middle: 'middle',
+ Back: 'back',
+ Forward: 'forward',
+}) satisfies Record<string, Protocol.Input.MouseButton>;
+
+/**
+ * @public
+ */
+export type MouseButton = (typeof MouseButton)[keyof typeof MouseButton];
+
+/**
+ * The Mouse class operates in main-frame CSS pixels
+ * relative to the top-left corner of the viewport.
+ * @remarks
+ * Every `page` object has its own Mouse, accessible with [`page.mouse`](#pagemouse).
+ *
+ * @example
+ *
+ * ```ts
+ * // Using ‘page.mouse’ to trace a 100x100 square.
+ * await page.mouse.move(0, 0);
+ * await page.mouse.down();
+ * await page.mouse.move(0, 100);
+ * await page.mouse.move(100, 100);
+ * await page.mouse.move(100, 0);
+ * await page.mouse.move(0, 0);
+ * await page.mouse.up();
+ * ```
+ *
+ * **Note**: The mouse events trigger synthetic `MouseEvent`s.
+ * This means that it does not fully replicate the functionality of what a normal user
+ * would be able to do with their mouse.
+ *
+ * For example, dragging and selecting text is not possible using `page.mouse`.
+ * Instead, you can use the {@link https://developer.mozilla.org/en-US/docs/Web/API/DocumentOrShadowRoot/getSelection | `DocumentOrShadowRoot.getSelection()`} functionality implemented in the platform.
+ *
+ * @example
+ * For example, if you want to select all content between nodes:
+ *
+ * ```ts
+ * await page.evaluate(
+ * (from, to) => {
+ * const selection = from.getRootNode().getSelection();
+ * const range = document.createRange();
+ * range.setStartBefore(from);
+ * range.setEndAfter(to);
+ * selection.removeAllRanges();
+ * selection.addRange(range);
+ * },
+ * fromJSHandle,
+ * toJSHandle
+ * );
+ * ```
+ *
+ * If you then would want to copy-paste your selection, you can use the clipboard api:
+ *
+ * ```ts
+ * // The clipboard api does not allow you to copy, unless the tab is focused.
+ * await page.bringToFront();
+ * await page.evaluate(() => {
+ * // Copy the selected content to the clipboard
+ * document.execCommand('copy');
+ * // Obtain the content of the clipboard as a string
+ * return navigator.clipboard.readText();
+ * });
+ * ```
+ *
+ * **Note**: If you want access to the clipboard API,
+ * you have to give it permission to do so:
+ *
+ * ```ts
+ * await browser
+ * .defaultBrowserContext()
+ * .overridePermissions('<your origin>', [
+ * 'clipboard-read',
+ * 'clipboard-write',
+ * ]);
+ * ```
+ *
+ * @public
+ */
+export abstract class Mouse {
+ /**
+ * @internal
+ */
+ constructor() {}
+
+ /**
+ * Resets the mouse to the default state: No buttons pressed; position at
+ * (0,0).
+ */
+ abstract reset(): Promise<void>;
+
+ /**
+ * Moves the mouse to the given coordinate.
+ *
+ * @param x - Horizontal position of the mouse.
+ * @param y - Vertical position of the mouse.
+ * @param options - Options to configure behavior.
+ */
+ abstract move(
+ x: number,
+ y: number,
+ options?: Readonly<MouseMoveOptions>
+ ): Promise<void>;
+
+ /**
+ * Presses the mouse.
+ *
+ * @param options - Options to configure behavior.
+ */
+ abstract down(options?: Readonly<MouseOptions>): Promise<void>;
+
+ /**
+ * Releases the mouse.
+ *
+ * @param options - Options to configure behavior.
+ */
+ abstract up(options?: Readonly<MouseOptions>): Promise<void>;
+
+ /**
+ * Shortcut for `mouse.move`, `mouse.down` and `mouse.up`.
+ *
+ * @param x - Horizontal position of the mouse.
+ * @param y - Vertical position of the mouse.
+ * @param options - Options to configure behavior.
+ */
+ abstract click(
+ x: number,
+ y: number,
+ options?: Readonly<MouseClickOptions>
+ ): Promise<void>;
+
+ /**
+ * Dispatches a `mousewheel` event.
+ * @param options - Optional: `MouseWheelOptions`.
+ *
+ * @example
+ * An example of zooming into an element:
+ *
+ * ```ts
+ * await page.goto(
+ * 'https://mdn.mozillademos.org/en-US/docs/Web/API/Element/wheel_event$samples/Scaling_an_element_via_the_wheel?revision=1587366'
+ * );
+ *
+ * const elem = await page.$('div');
+ * const boundingBox = await elem.boundingBox();
+ * await page.mouse.move(
+ * boundingBox.x + boundingBox.width / 2,
+ * boundingBox.y + boundingBox.height / 2
+ * );
+ *
+ * await page.mouse.wheel({deltaY: -100});
+ * ```
+ */
+ abstract wheel(options?: Readonly<MouseWheelOptions>): Promise<void>;
+
+ /**
+ * Dispatches a `drag` event.
+ * @param start - starting point for drag
+ * @param target - point to drag to
+ */
+ abstract drag(start: Point, target: Point): Promise<Protocol.Input.DragData>;
+
+ /**
+ * Dispatches a `dragenter` event.
+ * @param target - point for emitting `dragenter` event
+ * @param data - drag data containing items and operations mask
+ */
+ abstract dragEnter(
+ target: Point,
+ data: Protocol.Input.DragData
+ ): Promise<void>;
+
+ /**
+ * Dispatches a `dragover` event.
+ * @param target - point for emitting `dragover` event
+ * @param data - drag data containing items and operations mask
+ */
+ abstract dragOver(
+ target: Point,
+ data: Protocol.Input.DragData
+ ): Promise<void>;
+
+ /**
+ * Performs a dragenter, dragover, and drop in sequence.
+ * @param target - point to drop on
+ * @param data - drag data containing items and operations mask
+ */
+ abstract drop(target: Point, data: Protocol.Input.DragData): Promise<void>;
+
+ /**
+ * Performs a drag, dragenter, dragover, and drop in sequence.
+ * @param start - point to drag from
+ * @param target - point to drop on
+ * @param options - An object of options. Accepts delay which,
+ * if specified, is the time to wait between `dragover` and `drop` in milliseconds.
+ * Defaults to 0.
+ */
+ abstract dragAndDrop(
+ start: Point,
+ target: Point,
+ options?: {delay?: number}
+ ): Promise<void>;
+}
+
+/**
+ * The Touchscreen class exposes touchscreen events.
+ * @public
+ */
+export abstract class Touchscreen {
+ /**
+ * @internal
+ */
+ constructor() {}
+
+ /**
+ * Dispatches a `touchstart` and `touchend` event.
+ * @param x - Horizontal position of the tap.
+ * @param y - Vertical position of the tap.
+ */
+ async tap(x: number, y: number): Promise<void> {
+ await this.touchStart(x, y);
+ await this.touchEnd();
+ }
+
+ /**
+ * Dispatches a `touchstart` event.
+ * @param x - Horizontal position of the tap.
+ * @param y - Vertical position of the tap.
+ */
+ abstract touchStart(x: number, y: number): Promise<void>;
+
+ /**
+ * Dispatches a `touchMove` event.
+ * @param x - Horizontal position of the move.
+ * @param y - Vertical position of the move.
+ *
+ * @remarks
+ *
+ * Not every `touchMove` call results in a `touchmove` event being emitted,
+ * depending on the browser's optimizations. For example, Chrome
+ * {@link https://developer.chrome.com/blog/a-more-compatible-smoother-touch/#chromes-new-model-the-throttled-async-touchmove-model | throttles}
+ * touch move events.
+ */
+ abstract touchMove(x: number, y: number): Promise<void>;
+
+ /**
+ * Dispatches a `touchend` event.
+ */
+ abstract touchEnd(): Promise<void>;
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/JSHandle.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/JSHandle.ts
new file mode 100644
index 0000000000..52ca7fe8f8
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/JSHandle.ts
@@ -0,0 +1,212 @@
+/**
+ * @license
+ * Copyright 2023 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type Protocol from 'devtools-protocol';
+
+import type {EvaluateFuncWith, HandleFor, HandleOr} from '../common/types.js';
+import {debugError, withSourcePuppeteerURLIfNone} from '../common/util.js';
+import {moveable, throwIfDisposed} from '../util/decorators.js';
+import {disposeSymbol, asyncDisposeSymbol} from '../util/disposable.js';
+
+import type {ElementHandle} from './ElementHandle.js';
+import type {Realm} from './Realm.js';
+
+/**
+ * Represents a reference to a JavaScript object. Instances can be created using
+ * {@link Page.evaluateHandle}.
+ *
+ * Handles prevent the referenced JavaScript object from being garbage-collected
+ * unless the handle is purposely {@link JSHandle.dispose | disposed}. JSHandles
+ * are auto-disposed when their associated frame is navigated away or the parent
+ * context gets destroyed.
+ *
+ * Handles can be used as arguments for any evaluation function such as
+ * {@link Page.$eval}, {@link Page.evaluate}, and {@link Page.evaluateHandle}.
+ * They are resolved to their referenced object.
+ *
+ * @example
+ *
+ * ```ts
+ * const windowHandle = await page.evaluateHandle(() => window);
+ * ```
+ *
+ * @public
+ */
+@moveable
+export abstract class JSHandle<T = unknown> {
+ declare move: () => this;
+
+ /**
+ * Used for nominally typing {@link JSHandle}.
+ */
+ declare _?: T;
+
+ /**
+ * @internal
+ */
+ constructor() {}
+
+ /**
+ * @internal
+ */
+ abstract get realm(): Realm;
+
+ /**
+ * @internal
+ */
+ abstract get disposed(): boolean;
+
+ /**
+ * Evaluates the given function with the current handle as its first argument.
+ */
+ async evaluate<
+ Params extends unknown[],
+ Func extends EvaluateFuncWith<T, Params> = EvaluateFuncWith<T, Params>,
+ >(
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<Awaited<ReturnType<Func>>> {
+ pageFunction = withSourcePuppeteerURLIfNone(
+ this.evaluate.name,
+ pageFunction
+ );
+ return await this.realm.evaluate(pageFunction, this, ...args);
+ }
+
+ /**
+ * Evaluates the given function with the current handle as its first argument.
+ *
+ */
+ async evaluateHandle<
+ Params extends unknown[],
+ Func extends EvaluateFuncWith<T, Params> = EvaluateFuncWith<T, Params>,
+ >(
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<HandleFor<Awaited<ReturnType<Func>>>> {
+ pageFunction = withSourcePuppeteerURLIfNone(
+ this.evaluateHandle.name,
+ pageFunction
+ );
+ return await this.realm.evaluateHandle(pageFunction, this, ...args);
+ }
+
+ /**
+ * Fetches a single property from the referenced object.
+ */
+ getProperty<K extends keyof T>(
+ propertyName: HandleOr<K>
+ ): Promise<HandleFor<T[K]>>;
+ getProperty(propertyName: string): Promise<JSHandle<unknown>>;
+
+ /**
+ * @internal
+ */
+ @throwIfDisposed()
+ async getProperty<K extends keyof T>(
+ propertyName: HandleOr<K>
+ ): Promise<HandleFor<T[K]>> {
+ return await this.evaluateHandle((object, propertyName) => {
+ return object[propertyName as K];
+ }, propertyName);
+ }
+
+ /**
+ * Gets a map of handles representing the properties of the current handle.
+ *
+ * @example
+ *
+ * ```ts
+ * const listHandle = await page.evaluateHandle(() => document.body.children);
+ * const properties = await listHandle.getProperties();
+ * const children = [];
+ * for (const property of properties.values()) {
+ * const element = property.asElement();
+ * if (element) {
+ * children.push(element);
+ * }
+ * }
+ * children; // holds elementHandles to all children of document.body
+ * ```
+ */
+ @throwIfDisposed()
+ async getProperties(): Promise<Map<string, JSHandle>> {
+ const propertyNames = await this.evaluate(object => {
+ const enumerableProperties = [];
+ const descriptors = Object.getOwnPropertyDescriptors(object);
+ for (const propertyName in descriptors) {
+ if (descriptors[propertyName]?.enumerable) {
+ enumerableProperties.push(propertyName);
+ }
+ }
+ return enumerableProperties;
+ });
+ const map = new Map<string, JSHandle>();
+ const results = await Promise.all(
+ propertyNames.map(key => {
+ return this.getProperty(key);
+ })
+ );
+ for (const [key, value] of Object.entries(propertyNames)) {
+ using handle = results[key as any];
+ if (handle) {
+ map.set(value, handle.move());
+ }
+ }
+ return map;
+ }
+
+ /**
+ * A vanilla object representing the serializable portions of the
+ * referenced object.
+ * @throws Throws if the object cannot be serialized due to circularity.
+ *
+ * @remarks
+ * If the object has a `toJSON` function, it **will not** be called.
+ */
+ abstract jsonValue(): Promise<T>;
+
+ /**
+ * Either `null` or the handle itself if the handle is an
+ * instance of {@link ElementHandle}.
+ */
+ abstract asElement(): ElementHandle<Node> | null;
+
+ /**
+ * Releases the object referenced by the handle for garbage collection.
+ */
+ abstract dispose(): Promise<void>;
+
+ /**
+ * Returns a string representation of the JSHandle.
+ *
+ * @remarks
+ * Useful during debugging.
+ */
+ abstract toString(): string;
+
+ /**
+ * @internal
+ */
+ abstract get id(): string | undefined;
+
+ /**
+ * Provides access to the
+ * {@link https://chromedevtools.github.io/devtools-protocol/tot/Runtime/#type-RemoteObject | Protocol.Runtime.RemoteObject}
+ * backing this handle.
+ */
+ abstract remoteObject(): Protocol.Runtime.RemoteObject;
+
+ /** @internal */
+ [disposeSymbol](): void {
+ return void this.dispose().catch(debugError);
+ }
+
+ /** @internal */
+ [asyncDisposeSymbol](): Promise<void> {
+ return this.dispose();
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/Page.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/Page.ts
new file mode 100644
index 0000000000..deb04628fd
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/Page.ts
@@ -0,0 +1,3090 @@
+/**
+ * @license
+ * Copyright 2017 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {Readable} from 'stream';
+
+import type {Protocol} from 'devtools-protocol';
+
+import {
+ concat,
+ EMPTY,
+ filter,
+ filterAsync,
+ first,
+ firstValueFrom,
+ from,
+ map,
+ merge,
+ mergeMap,
+ of,
+ race,
+ raceWith,
+ startWith,
+ switchMap,
+ takeUntil,
+ timer,
+ type Observable,
+} from '../../third_party/rxjs/rxjs.js';
+import type {HTTPRequest} from '../api/HTTPRequest.js';
+import type {HTTPResponse} from '../api/HTTPResponse.js';
+import type {Accessibility} from '../cdp/Accessibility.js';
+import type {Coverage} from '../cdp/Coverage.js';
+import type {DeviceRequestPrompt} from '../cdp/DeviceRequestPrompt.js';
+import type {Credentials, NetworkConditions} from '../cdp/NetworkManager.js';
+import type {Tracing} from '../cdp/Tracing.js';
+import type {ConsoleMessage} from '../common/ConsoleMessage.js';
+import type {Device} from '../common/Device.js';
+import {TargetCloseError} from '../common/Errors.js';
+import {
+ EventEmitter,
+ type EventsWithWildcard,
+ type EventType,
+ type Handler,
+} from '../common/EventEmitter.js';
+import type {FileChooser} from '../common/FileChooser.js';
+import type {PDFOptions} from '../common/PDFOptions.js';
+import {TimeoutSettings} from '../common/TimeoutSettings.js';
+import type {
+ Awaitable,
+ AwaitablePredicate,
+ EvaluateFunc,
+ EvaluateFuncWith,
+ HandleFor,
+ NodeFor,
+} from '../common/types.js';
+import {
+ debugError,
+ fromEmitterEvent,
+ importFSPromises,
+ isString,
+ NETWORK_IDLE_TIME,
+ timeout,
+ withSourcePuppeteerURLIfNone,
+} from '../common/util.js';
+import type {Viewport} from '../common/Viewport.js';
+import type {ScreenRecorder} from '../node/ScreenRecorder.js';
+import {guarded} from '../util/decorators.js';
+import {
+ AsyncDisposableStack,
+ asyncDisposeSymbol,
+ DisposableStack,
+ disposeSymbol,
+} from '../util/disposable.js';
+
+import type {Browser} from './Browser.js';
+import type {BrowserContext} from './BrowserContext.js';
+import type {CDPSession} from './CDPSession.js';
+import type {Dialog} from './Dialog.js';
+import type {
+ BoundingBox,
+ ClickOptions,
+ ElementHandle,
+} from './ElementHandle.js';
+import type {
+ Frame,
+ FrameAddScriptTagOptions,
+ FrameAddStyleTagOptions,
+ FrameWaitForFunctionOptions,
+ GoToOptions,
+ WaitForOptions,
+} from './Frame.js';
+import type {
+ Keyboard,
+ KeyboardTypeOptions,
+ Mouse,
+ Touchscreen,
+} from './Input.js';
+import type {JSHandle} from './JSHandle.js';
+import {
+ FunctionLocator,
+ Locator,
+ NodeLocator,
+ type AwaitedLocator,
+} from './locators/locators.js';
+import type {Target} from './Target.js';
+import type {WebWorker} from './WebWorker.js';
+
+/**
+ * @public
+ */
+export interface Metrics {
+ Timestamp?: number;
+ Documents?: number;
+ Frames?: number;
+ JSEventListeners?: number;
+ Nodes?: number;
+ LayoutCount?: number;
+ RecalcStyleCount?: number;
+ LayoutDuration?: number;
+ RecalcStyleDuration?: number;
+ ScriptDuration?: number;
+ TaskDuration?: number;
+ JSHeapUsedSize?: number;
+ JSHeapTotalSize?: number;
+}
+
+/**
+ * @public
+ */
+export interface WaitForNetworkIdleOptions extends WaitTimeoutOptions {
+ /**
+ * Time (in milliseconds) the network should be idle.
+ *
+ * @defaultValue `500`
+ */
+ idleTime?: number;
+ /**
+ * Maximum number concurrent of network connections to be considered inactive.
+ *
+ * @defaultValue `0`
+ */
+ concurrency?: number;
+}
+
+/**
+ * @public
+ */
+export interface WaitTimeoutOptions {
+ /**
+ * Maximum wait time in milliseconds. Pass 0 to disable the timeout.
+ *
+ * The default value can be changed by using the
+ * {@link Page.setDefaultTimeout} method.
+ *
+ * @defaultValue `30000`
+ */
+ timeout?: number;
+}
+
+/**
+ * @public
+ */
+export interface WaitForSelectorOptions {
+ /**
+ * Wait for the selected element to be present in DOM and to be visible, i.e.
+ * to not have `display: none` or `visibility: hidden` CSS properties.
+ *
+ * @defaultValue `false`
+ */
+ visible?: boolean;
+ /**
+ * Wait for the selected element to not be found in the DOM or to be hidden,
+ * i.e. have `display: none` or `visibility: hidden` CSS properties.
+ *
+ * @defaultValue `false`
+ */
+ hidden?: boolean;
+ /**
+ * Maximum time to wait in milliseconds. Pass `0` to disable timeout.
+ *
+ * The default value can be changed by using {@link Page.setDefaultTimeout}
+ *
+ * @defaultValue `30_000` (30 seconds)
+ */
+ timeout?: number;
+ /**
+ * A signal object that allows you to cancel a waitForSelector call.
+ */
+ signal?: AbortSignal;
+}
+
+/**
+ * @public
+ */
+export interface GeolocationOptions {
+ /**
+ * Latitude between `-90` and `90`.
+ */
+ longitude: number;
+ /**
+ * Longitude between `-180` and `180`.
+ */
+ latitude: number;
+ /**
+ * Optional non-negative accuracy value.
+ */
+ accuracy?: number;
+}
+
+/**
+ * @public
+ */
+export interface MediaFeature {
+ name: string;
+ value: string;
+}
+
+/**
+ * @public
+ */
+export interface ScreenshotClip extends BoundingBox {
+ /**
+ * @defaultValue `1`
+ */
+ scale?: number;
+}
+
+/**
+ * @public
+ */
+export interface ScreenshotOptions {
+ /**
+ * @defaultValue `false`
+ */
+ optimizeForSpeed?: boolean;
+ /**
+ * @defaultValue `'png'`
+ */
+ type?: 'png' | 'jpeg' | 'webp';
+ /**
+ * Quality of the image, between 0-100. Not applicable to `png` images.
+ */
+ quality?: number;
+ /**
+ * Capture the screenshot from the surface, rather than the view.
+ *
+ * @defaultValue `true`
+ */
+ fromSurface?: boolean;
+ /**
+ * When `true`, takes a screenshot of the full page.
+ *
+ * @defaultValue `false`
+ */
+ fullPage?: boolean;
+ /**
+ * Hides default white background and allows capturing screenshots with transparency.
+ *
+ * @defaultValue `false`
+ */
+ omitBackground?: boolean;
+ /**
+ * The file path to save the image to. The screenshot type will be inferred
+ * from file extension. If path is a relative path, then it is resolved
+ * relative to current working directory. If no path is provided, the image
+ * won't be saved to the disk.
+ */
+ path?: string;
+ /**
+ * Specifies the region of the page to clip.
+ */
+ clip?: ScreenshotClip;
+ /**
+ * Encoding of the image.
+ *
+ * @defaultValue `'binary'`
+ */
+ encoding?: 'base64' | 'binary';
+ /**
+ * Capture the screenshot beyond the viewport.
+ *
+ * @defaultValue `false` if there is no `clip`. `true` otherwise.
+ */
+ captureBeyondViewport?: boolean;
+}
+
+/**
+ * @public
+ * @experimental
+ */
+export interface ScreencastOptions {
+ /**
+ * File path to save the screencast to.
+ */
+ path?: `${string}.webm`;
+ /**
+ * Specifies the region of the viewport to crop.
+ */
+ crop?: BoundingBox;
+ /**
+ * Scales the output video.
+ *
+ * For example, `0.5` will shrink the width and height of the output video by
+ * half. `2` will double the width and height of the output video.
+ *
+ * @defaultValue `1`
+ */
+ scale?: number;
+ /**
+ * Specifies the speed to record at.
+ *
+ * For example, `0.5` will slowdown the output video by 50%. `2` will double the
+ * speed of the output video.
+ *
+ * @defaultValue `1`
+ */
+ speed?: number;
+ /**
+ * Path to the [ffmpeg](https://ffmpeg.org/).
+ *
+ * Required if `ffmpeg` is not in your PATH.
+ */
+ ffmpegPath?: string;
+}
+
+/**
+ * All the events that a page instance may emit.
+ *
+ * @public
+ */
+export const enum PageEvent {
+ /**
+ * Emitted when the page closes.
+ */
+ Close = 'close',
+ /**
+ * Emitted when JavaScript within the page calls one of console API methods,
+ * e.g. `console.log` or `console.dir`. Also emitted if the page throws an
+ * error or a warning.
+ *
+ * @remarks
+ * A `console` event provides a {@link ConsoleMessage} representing the
+ * console message that was logged.
+ *
+ * @example
+ * An example of handling `console` event:
+ *
+ * ```ts
+ * page.on('console', msg => {
+ * for (let i = 0; i < msg.args().length; ++i)
+ * console.log(`${i}: ${msg.args()[i]}`);
+ * });
+ * page.evaluate(() => console.log('hello', 5, {foo: 'bar'}));
+ * ```
+ */
+ Console = 'console',
+ /**
+ * Emitted when a JavaScript dialog appears, such as `alert`, `prompt`,
+ * `confirm` or `beforeunload`. Puppeteer can respond to the dialog via
+ * {@link Dialog.accept} or {@link Dialog.dismiss}.
+ */
+ Dialog = 'dialog',
+ /**
+ * Emitted when the JavaScript
+ * {@link https://developer.mozilla.org/en-US/docs/Web/Events/DOMContentLoaded | DOMContentLoaded }
+ * event is dispatched.
+ */
+ DOMContentLoaded = 'domcontentloaded',
+ /**
+ * Emitted when the page crashes. Will contain an `Error`.
+ */
+ Error = 'error',
+ /** Emitted when a frame is attached. Will contain a {@link Frame}. */
+ FrameAttached = 'frameattached',
+ /** Emitted when a frame is detached. Will contain a {@link Frame}. */
+ FrameDetached = 'framedetached',
+ /**
+ * Emitted when a frame is navigated to a new URL. Will contain a
+ * {@link Frame}.
+ */
+ FrameNavigated = 'framenavigated',
+ /**
+ * Emitted when the JavaScript
+ * {@link https://developer.mozilla.org/en-US/docs/Web/Events/load | load}
+ * event is dispatched.
+ */
+ Load = 'load',
+ /**
+ * Emitted when the JavaScript code makes a call to `console.timeStamp`. For
+ * the list of metrics see {@link Page.metrics | page.metrics}.
+ *
+ * @remarks
+ * Contains an object with two properties:
+ *
+ * - `title`: the title passed to `console.timeStamp`
+ * - `metrics`: object containing metrics as key/value pairs. The values will
+ * be `number`s.
+ */
+ Metrics = 'metrics',
+ /**
+ * Emitted when an uncaught exception happens within the page. Contains an
+ * `Error`.
+ */
+ PageError = 'pageerror',
+ /**
+ * Emitted when the page opens a new tab or window.
+ *
+ * Contains a {@link Page} corresponding to the popup window.
+ *
+ * @example
+ *
+ * ```ts
+ * const [popup] = await Promise.all([
+ * new Promise(resolve => page.once('popup', resolve)),
+ * page.click('a[target=_blank]'),
+ * ]);
+ * ```
+ *
+ * ```ts
+ * const [popup] = await Promise.all([
+ * new Promise(resolve => page.once('popup', resolve)),
+ * page.evaluate(() => window.open('https://example.com')),
+ * ]);
+ * ```
+ */
+ Popup = 'popup',
+ /**
+ * Emitted when a page issues a request and contains a {@link HTTPRequest}.
+ *
+ * @remarks
+ * The object is readonly. See {@link Page.setRequestInterception} for
+ * intercepting and mutating requests.
+ */
+ Request = 'request',
+ /**
+ * Emitted when a request ended up loading from cache. Contains a
+ * {@link HTTPRequest}.
+ *
+ * @remarks
+ * For certain requests, might contain undefined.
+ * {@link https://crbug.com/750469}
+ */
+ RequestServedFromCache = 'requestservedfromcache',
+ /**
+ * Emitted when a request fails, for example by timing out.
+ *
+ * Contains a {@link HTTPRequest}.
+ *
+ * @remarks
+ * HTTP Error responses, such as 404 or 503, are still successful responses
+ * from HTTP standpoint, so request will complete with `requestfinished` event
+ * and not with `requestfailed`.
+ */
+ RequestFailed = 'requestfailed',
+ /**
+ * Emitted when a request finishes successfully. Contains a
+ * {@link HTTPRequest}.
+ */
+ RequestFinished = 'requestfinished',
+ /**
+ * Emitted when a response is received. Contains a {@link HTTPResponse}.
+ */
+ Response = 'response',
+ /**
+ * Emitted when a dedicated
+ * {@link https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API | WebWorker}
+ * is spawned by the page.
+ */
+ WorkerCreated = 'workercreated',
+ /**
+ * Emitted when a dedicated
+ * {@link https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API | WebWorker}
+ * is destroyed by the page.
+ */
+ WorkerDestroyed = 'workerdestroyed',
+}
+
+export {
+ /**
+ * All the events that a page instance may emit.
+ *
+ * @deprecated Use {@link PageEvent}.
+ */
+ PageEvent as PageEmittedEvents,
+};
+
+/**
+ * Denotes the objects received by callback functions for page events.
+ *
+ * See {@link PageEvent} for more detail on the events and when they are
+ * emitted.
+ *
+ * @public
+ */
+export interface PageEvents extends Record<EventType, unknown> {
+ [PageEvent.Close]: undefined;
+ [PageEvent.Console]: ConsoleMessage;
+ [PageEvent.Dialog]: Dialog;
+ [PageEvent.DOMContentLoaded]: undefined;
+ [PageEvent.Error]: Error;
+ [PageEvent.FrameAttached]: Frame;
+ [PageEvent.FrameDetached]: Frame;
+ [PageEvent.FrameNavigated]: Frame;
+ [PageEvent.Load]: undefined;
+ [PageEvent.Metrics]: {title: string; metrics: Metrics};
+ [PageEvent.PageError]: Error;
+ [PageEvent.Popup]: Page | null;
+ [PageEvent.Request]: HTTPRequest;
+ [PageEvent.Response]: HTTPResponse;
+ [PageEvent.RequestFailed]: HTTPRequest;
+ [PageEvent.RequestFinished]: HTTPRequest;
+ [PageEvent.RequestServedFromCache]: HTTPRequest;
+ [PageEvent.WorkerCreated]: WebWorker;
+ [PageEvent.WorkerDestroyed]: WebWorker;
+}
+
+export type {
+ /**
+ * @deprecated Use {@link PageEvents}.
+ */
+ PageEvents as PageEventObject,
+};
+
+/**
+ * @public
+ */
+export interface NewDocumentScriptEvaluation {
+ identifier: string;
+}
+
+/**
+ * @internal
+ */
+export function setDefaultScreenshotOptions(options: ScreenshotOptions): void {
+ options.optimizeForSpeed ??= false;
+ options.type ??= 'png';
+ options.fromSurface ??= true;
+ options.fullPage ??= false;
+ options.omitBackground ??= false;
+ options.encoding ??= 'binary';
+ options.captureBeyondViewport ??= true;
+}
+
+/**
+ * Page provides methods to interact with a single tab or
+ * {@link https://developer.chrome.com/extensions/background_pages | extension background page}
+ * in the browser.
+ *
+ * :::note
+ *
+ * One Browser instance might have multiple Page instances.
+ *
+ * :::
+ *
+ * @example
+ * This example creates a page, navigates it to a URL, and then saves a screenshot:
+ *
+ * ```ts
+ * import puppeteer from 'puppeteer';
+ *
+ * (async () => {
+ * const browser = await puppeteer.launch();
+ * const page = await browser.newPage();
+ * await page.goto('https://example.com');
+ * await page.screenshot({path: 'screenshot.png'});
+ * await browser.close();
+ * })();
+ * ```
+ *
+ * The Page class extends from Puppeteer's {@link EventEmitter} class and will
+ * emit various events which are documented in the {@link PageEvent} enum.
+ *
+ * @example
+ * This example logs a message for a single page `load` event:
+ *
+ * ```ts
+ * page.once('load', () => console.log('Page loaded!'));
+ * ```
+ *
+ * To unsubscribe from events use the {@link EventEmitter.off} method:
+ *
+ * ```ts
+ * function logRequest(interceptedRequest) {
+ * console.log('A request was made:', interceptedRequest.url());
+ * }
+ * page.on('request', logRequest);
+ * // Sometime later...
+ * page.off('request', logRequest);
+ * ```
+ *
+ * @public
+ */
+export abstract class Page extends EventEmitter<PageEvents> {
+ /**
+ * @internal
+ */
+ _isDragging = false;
+ /**
+ * @internal
+ */
+ _timeoutSettings = new TimeoutSettings();
+
+ #requestHandlers = new WeakMap<Handler<HTTPRequest>, Handler<HTTPRequest>>();
+
+ #requestsInFlight = 0;
+ #inflight$: Observable<number>;
+
+ /**
+ * @internal
+ */
+ constructor() {
+ super();
+
+ this.#inflight$ = fromEmitterEvent(this, PageEvent.Request).pipe(
+ takeUntil(fromEmitterEvent(this, PageEvent.Close)),
+ mergeMap(request => {
+ return concat(
+ of(1),
+ race(
+ fromEmitterEvent(this, PageEvent.Response).pipe(
+ filter(response => {
+ return response.request()._requestId === request._requestId;
+ })
+ ),
+ fromEmitterEvent(this, PageEvent.RequestFailed).pipe(
+ filter(failure => {
+ return failure._requestId === request._requestId;
+ })
+ ),
+ fromEmitterEvent(this, PageEvent.RequestFinished).pipe(
+ filter(success => {
+ return success._requestId === request._requestId;
+ })
+ )
+ ).pipe(
+ map(() => {
+ return -1;
+ })
+ )
+ );
+ })
+ );
+
+ this.#inflight$.subscribe(count => {
+ this.#requestsInFlight += count;
+ });
+ }
+
+ /**
+ * `true` if the service worker are being bypassed, `false` otherwise.
+ */
+ abstract isServiceWorkerBypassed(): boolean;
+
+ /**
+ * `true` if drag events are being intercepted, `false` otherwise.
+ *
+ * @deprecated We no longer support intercepting drag payloads. Use the new
+ * drag APIs found on {@link ElementHandle} to drag (or just use the
+ * {@link Page | Page.mouse}).
+ */
+ abstract isDragInterceptionEnabled(): boolean;
+
+ /**
+ * `true` if the page has JavaScript enabled, `false` otherwise.
+ */
+ abstract isJavaScriptEnabled(): boolean;
+
+ /**
+ * Listen to page events.
+ *
+ * @remarks
+ * This method exists to define event typings and handle proper wireup of
+ * cooperative request interception. Actual event listening and dispatching is
+ * delegated to {@link EventEmitter}.
+ *
+ * @internal
+ */
+ override on<K extends keyof EventsWithWildcard<PageEvents>>(
+ type: K,
+ handler: (event: EventsWithWildcard<PageEvents>[K]) => void
+ ): this {
+ if (type !== PageEvent.Request) {
+ return super.on(type, handler);
+ }
+ let wrapper = this.#requestHandlers.get(
+ handler as (event: PageEvents[PageEvent.Request]) => void
+ );
+ if (wrapper === undefined) {
+ wrapper = (event: HTTPRequest) => {
+ event.enqueueInterceptAction(() => {
+ return handler(event as EventsWithWildcard<PageEvents>[K]);
+ });
+ };
+ this.#requestHandlers.set(
+ handler as (event: PageEvents[PageEvent.Request]) => void,
+ wrapper
+ );
+ }
+ return super.on(
+ type,
+ wrapper as (event: EventsWithWildcard<PageEvents>[K]) => void
+ );
+ }
+
+ /**
+ * @internal
+ */
+ override off<K extends keyof EventsWithWildcard<PageEvents>>(
+ type: K,
+ handler: (event: EventsWithWildcard<PageEvents>[K]) => void
+ ): this {
+ if (type === PageEvent.Request) {
+ handler =
+ (this.#requestHandlers.get(
+ handler as (
+ event: EventsWithWildcard<PageEvents>[PageEvent.Request]
+ ) => void
+ ) as (event: EventsWithWildcard<PageEvents>[K]) => void) || handler;
+ }
+ return super.off(type, handler);
+ }
+
+ /**
+ * This method is typically coupled with an action that triggers file
+ * choosing.
+ *
+ * :::caution
+ *
+ * This must be called before the file chooser is launched. It will not return
+ * a currently active file chooser.
+ *
+ * :::
+ *
+ * @remarks
+ * In the "headful" browser, this method results in the native file picker
+ * dialog `not showing up` for the user.
+ *
+ * @example
+ * The following example clicks a button that issues a file chooser
+ * and then responds with `/tmp/myfile.pdf` as if a user has selected this file.
+ *
+ * ```ts
+ * const [fileChooser] = await Promise.all([
+ * page.waitForFileChooser(),
+ * page.click('#upload-file-button'),
+ * // some button that triggers file selection
+ * ]);
+ * await fileChooser.accept(['/tmp/myfile.pdf']);
+ * ```
+ */
+ abstract waitForFileChooser(
+ options?: WaitTimeoutOptions
+ ): Promise<FileChooser>;
+
+ /**
+ * Sets the page's geolocation.
+ *
+ * @remarks
+ * Consider using {@link BrowserContext.overridePermissions} to grant
+ * permissions for the page to read its geolocation.
+ *
+ * @example
+ *
+ * ```ts
+ * await page.setGeolocation({latitude: 59.95, longitude: 30.31667});
+ * ```
+ */
+ abstract setGeolocation(options: GeolocationOptions): Promise<void>;
+
+ /**
+ * A target this page was created from.
+ */
+ abstract target(): Target;
+
+ /**
+ * Get the browser the page belongs to.
+ */
+ abstract browser(): Browser;
+
+ /**
+ * Get the browser context that the page belongs to.
+ */
+ abstract browserContext(): BrowserContext;
+
+ /**
+ * The page's main frame.
+ *
+ * @remarks
+ * Page is guaranteed to have a main frame which persists during navigations.
+ */
+ abstract mainFrame(): Frame;
+
+ /**
+ * Creates a Chrome Devtools Protocol session attached to the page.
+ */
+ abstract createCDPSession(): Promise<CDPSession>;
+
+ /**
+ * {@inheritDoc Keyboard}
+ */
+ abstract get keyboard(): Keyboard;
+
+ /**
+ * {@inheritDoc Touchscreen}
+ */
+ abstract get touchscreen(): Touchscreen;
+
+ /**
+ * {@inheritDoc Coverage}
+ */
+ abstract get coverage(): Coverage;
+
+ /**
+ * {@inheritDoc Tracing}
+ */
+ abstract get tracing(): Tracing;
+
+ /**
+ * {@inheritDoc Accessibility}
+ */
+ abstract get accessibility(): Accessibility;
+
+ /**
+ * An array of all frames attached to the page.
+ */
+ abstract frames(): Frame[];
+
+ /**
+ * All of the dedicated {@link
+ * https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API |
+ * WebWorkers} associated with the page.
+ *
+ * @remarks
+ * This does not contain ServiceWorkers
+ */
+ abstract workers(): WebWorker[];
+
+ /**
+ * Activating request interception enables {@link HTTPRequest.abort},
+ * {@link HTTPRequest.continue} and {@link HTTPRequest.respond} methods. This
+ * provides the capability to modify network requests that are made by a page.
+ *
+ * Once request interception is enabled, every request will stall unless it's
+ * continued, responded or aborted; or completed using the browser cache.
+ *
+ * See the
+ * {@link https://pptr.dev/next/guides/request-interception|Request interception guide}
+ * for more details.
+ *
+ * @example
+ * An example of a naïve request interceptor that aborts all image requests:
+ *
+ * ```ts
+ * import puppeteer from 'puppeteer';
+ * (async () => {
+ * const browser = await puppeteer.launch();
+ * const page = await browser.newPage();
+ * await page.setRequestInterception(true);
+ * page.on('request', interceptedRequest => {
+ * if (
+ * interceptedRequest.url().endsWith('.png') ||
+ * interceptedRequest.url().endsWith('.jpg')
+ * )
+ * interceptedRequest.abort();
+ * else interceptedRequest.continue();
+ * });
+ * await page.goto('https://example.com');
+ * await browser.close();
+ * })();
+ * ```
+ *
+ * @param value - Whether to enable request interception.
+ */
+ abstract setRequestInterception(value: boolean): Promise<void>;
+
+ /**
+ * Toggles ignoring of service worker for each request.
+ *
+ * @param bypass - Whether to bypass service worker and load from network.
+ */
+ abstract setBypassServiceWorker(bypass: boolean): Promise<void>;
+
+ /**
+ * @param enabled - Whether to enable drag interception.
+ *
+ * @deprecated We no longer support intercepting drag payloads. Use the new
+ * drag APIs found on {@link ElementHandle} to drag (or just use the
+ * {@link Page | Page.mouse}).
+ */
+ abstract setDragInterception(enabled: boolean): Promise<void>;
+
+ /**
+ * Sets the network connection to offline.
+ *
+ * It does not change the parameters used in {@link Page.emulateNetworkConditions}
+ *
+ * @param enabled - When `true`, enables offline mode for the page.
+ */
+ abstract setOfflineMode(enabled: boolean): Promise<void>;
+
+ /**
+ * This does not affect WebSockets and WebRTC PeerConnections (see
+ * https://crbug.com/563644). To set the page offline, you can use
+ * {@link Page.setOfflineMode}.
+ *
+ * A list of predefined network conditions can be used by importing
+ * {@link PredefinedNetworkConditions}.
+ *
+ * @example
+ *
+ * ```ts
+ * import {PredefinedNetworkConditions} from 'puppeteer';
+ * const slow3G = PredefinedNetworkConditions['Slow 3G'];
+ *
+ * (async () => {
+ * const browser = await puppeteer.launch();
+ * const page = await browser.newPage();
+ * await page.emulateNetworkConditions(slow3G);
+ * await page.goto('https://www.google.com');
+ * // other actions...
+ * await browser.close();
+ * })();
+ * ```
+ *
+ * @param networkConditions - Passing `null` disables network condition
+ * emulation.
+ */
+ abstract emulateNetworkConditions(
+ networkConditions: NetworkConditions | null
+ ): Promise<void>;
+
+ /**
+ * This setting will change the default maximum navigation time for the
+ * following methods and related shortcuts:
+ *
+ * - {@link Page.goBack | page.goBack(options)}
+ *
+ * - {@link Page.goForward | page.goForward(options)}
+ *
+ * - {@link Page.goto | page.goto(url,options)}
+ *
+ * - {@link Page.reload | page.reload(options)}
+ *
+ * - {@link Page.setContent | page.setContent(html,options)}
+ *
+ * - {@link Page.waitForNavigation | page.waitForNavigation(options)}
+ * @param timeout - Maximum navigation time in milliseconds.
+ */
+ abstract setDefaultNavigationTimeout(timeout: number): void;
+
+ /**
+ * @param timeout - Maximum time in milliseconds.
+ */
+ abstract setDefaultTimeout(timeout: number): void;
+
+ /**
+ * Maximum time in milliseconds.
+ */
+ abstract getDefaultTimeout(): number;
+
+ /**
+ * Creates a locator for the provided selector. See {@link Locator} for
+ * details and supported actions.
+ *
+ * @remarks
+ * Locators API is experimental and we will not follow semver for breaking
+ * change in the Locators API.
+ */
+ locator<Selector extends string>(
+ selector: Selector
+ ): Locator<NodeFor<Selector>>;
+
+ /**
+ * Creates a locator for the provided function. See {@link Locator} for
+ * details and supported actions.
+ *
+ * @remarks
+ * Locators API is experimental and we will not follow semver for breaking
+ * change in the Locators API.
+ */
+ locator<Ret>(func: () => Awaitable<Ret>): Locator<Ret>;
+ locator<Selector extends string, Ret>(
+ selectorOrFunc: Selector | (() => Awaitable<Ret>)
+ ): Locator<NodeFor<Selector>> | Locator<Ret> {
+ if (typeof selectorOrFunc === 'string') {
+ return NodeLocator.create(this, selectorOrFunc);
+ } else {
+ return FunctionLocator.create(this, selectorOrFunc);
+ }
+ }
+
+ /**
+ * A shortcut for {@link Locator.race} that does not require static imports.
+ *
+ * @internal
+ */
+ locatorRace<Locators extends readonly unknown[] | []>(
+ locators: Locators
+ ): Locator<AwaitedLocator<Locators[number]>> {
+ return Locator.race(locators);
+ }
+
+ /**
+ * Runs `document.querySelector` within the page. If no element matches the
+ * selector, the return value resolves to `null`.
+ *
+ * @param selector - A `selector` to query page for
+ * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors | selector}
+ * to query page for.
+ */
+ async $<Selector extends string>(
+ selector: Selector
+ ): Promise<ElementHandle<NodeFor<Selector>> | null> {
+ return await this.mainFrame().$(selector);
+ }
+
+ /**
+ * The method runs `document.querySelectorAll` within the page. If no elements
+ * match the selector, the return value resolves to `[]`.
+ *
+ * @param selector - A `selector` to query page for
+ *
+ * @remarks
+ *
+ * Shortcut for {@link Frame.$$ | Page.mainFrame().$$(selector) }.
+ */
+ async $$<Selector extends string>(
+ selector: Selector
+ ): Promise<Array<ElementHandle<NodeFor<Selector>>>> {
+ return await this.mainFrame().$$(selector);
+ }
+
+ /**
+ * @remarks
+ *
+ * The only difference between {@link Page.evaluate | page.evaluate} and
+ * `page.evaluateHandle` is that `evaluateHandle` will return the value
+ * wrapped in an in-page object.
+ *
+ * If the function passed to `page.evaluateHandle` returns a Promise, the
+ * function will wait for the promise to resolve and return its value.
+ *
+ * You can pass a string instead of a function (although functions are
+ * recommended as they are easier to debug and use with TypeScript):
+ *
+ * @example
+ *
+ * ```ts
+ * const aHandle = await page.evaluateHandle('document');
+ * ```
+ *
+ * @example
+ * {@link JSHandle} instances can be passed as arguments to the `pageFunction`:
+ *
+ * ```ts
+ * const aHandle = await page.evaluateHandle(() => document.body);
+ * const resultHandle = await page.evaluateHandle(
+ * body => body.innerHTML,
+ * aHandle
+ * );
+ * console.log(await resultHandle.jsonValue());
+ * await resultHandle.dispose();
+ * ```
+ *
+ * Most of the time this function returns a {@link JSHandle},
+ * but if `pageFunction` returns a reference to an element,
+ * you instead get an {@link ElementHandle} back:
+ *
+ * @example
+ *
+ * ```ts
+ * const button = await page.evaluateHandle(() =>
+ * document.querySelector('button')
+ * );
+ * // can call `click` because `button` is an `ElementHandle`
+ * await button.click();
+ * ```
+ *
+ * The TypeScript definitions assume that `evaluateHandle` returns
+ * a `JSHandle`, but if you know it's going to return an
+ * `ElementHandle`, pass it as the generic argument:
+ *
+ * ```ts
+ * const button = await page.evaluateHandle<ElementHandle>(...);
+ * ```
+ *
+ * @param pageFunction - a function that is run within the page
+ * @param args - arguments to be passed to the pageFunction
+ */
+ async evaluateHandle<
+ Params extends unknown[],
+ Func extends EvaluateFunc<Params> = EvaluateFunc<Params>,
+ >(
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<HandleFor<Awaited<ReturnType<Func>>>> {
+ pageFunction = withSourcePuppeteerURLIfNone(
+ this.evaluateHandle.name,
+ pageFunction
+ );
+ return await this.mainFrame().evaluateHandle(pageFunction, ...args);
+ }
+
+ /**
+ * This method iterates the JavaScript heap and finds all objects with the
+ * given prototype.
+ *
+ * @example
+ *
+ * ```ts
+ * // Create a Map object
+ * await page.evaluate(() => (window.map = new Map()));
+ * // Get a handle to the Map object prototype
+ * const mapPrototype = await page.evaluateHandle(() => Map.prototype);
+ * // Query all map instances into an array
+ * const mapInstances = await page.queryObjects(mapPrototype);
+ * // Count amount of map objects in heap
+ * const count = await page.evaluate(maps => maps.length, mapInstances);
+ * await mapInstances.dispose();
+ * await mapPrototype.dispose();
+ * ```
+ *
+ * @param prototypeHandle - a handle to the object prototype.
+ * @returns Promise which resolves to a handle to an array of objects with
+ * this prototype.
+ */
+ abstract queryObjects<Prototype>(
+ prototypeHandle: JSHandle<Prototype>
+ ): Promise<JSHandle<Prototype[]>>;
+
+ /**
+ * This method runs `document.querySelector` within the page and passes the
+ * result as the first argument to the `pageFunction`.
+ *
+ * @remarks
+ *
+ * If no element is found matching `selector`, the method will throw an error.
+ *
+ * If `pageFunction` returns a promise `$eval` will wait for the promise to
+ * resolve and then return its value.
+ *
+ * @example
+ *
+ * ```ts
+ * const searchValue = await page.$eval('#search', el => el.value);
+ * const preloadHref = await page.$eval('link[rel=preload]', el => el.href);
+ * const html = await page.$eval('.main-container', el => el.outerHTML);
+ * ```
+ *
+ * If you are using TypeScript, you may have to provide an explicit type to the
+ * first argument of the `pageFunction`.
+ * By default it is typed as `Element`, but you may need to provide a more
+ * specific sub-type:
+ *
+ * @example
+ *
+ * ```ts
+ * // if you don't provide HTMLInputElement here, TS will error
+ * // as `value` is not on `Element`
+ * const searchValue = await page.$eval(
+ * '#search',
+ * (el: HTMLInputElement) => el.value
+ * );
+ * ```
+ *
+ * The compiler should be able to infer the return type
+ * from the `pageFunction` you provide. If it is unable to, you can use the generic
+ * type to tell the compiler what return type you expect from `$eval`:
+ *
+ * @example
+ *
+ * ```ts
+ * // The compiler can infer the return type in this case, but if it can't
+ * // or if you want to be more explicit, provide it as the generic type.
+ * const searchValue = await page.$eval<string>(
+ * '#search',
+ * (el: HTMLInputElement) => el.value
+ * );
+ * ```
+ *
+ * @param selector - the
+ * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors | selector}
+ * to query for
+ * @param pageFunction - the function to be evaluated in the page context.
+ * Will be passed the result of `document.querySelector(selector)` as its
+ * first argument.
+ * @param args - any additional arguments to pass through to `pageFunction`.
+ *
+ * @returns The result of calling `pageFunction`. If it returns an element it
+ * is wrapped in an {@link ElementHandle}, else the raw value itself is
+ * returned.
+ */
+ async $eval<
+ Selector extends string,
+ Params extends unknown[],
+ Func extends EvaluateFuncWith<NodeFor<Selector>, Params> = EvaluateFuncWith<
+ NodeFor<Selector>,
+ Params
+ >,
+ >(
+ selector: Selector,
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<Awaited<ReturnType<Func>>> {
+ pageFunction = withSourcePuppeteerURLIfNone(this.$eval.name, pageFunction);
+ return await this.mainFrame().$eval(selector, pageFunction, ...args);
+ }
+
+ /**
+ * This method runs `Array.from(document.querySelectorAll(selector))` within
+ * the page and passes the result as the first argument to the `pageFunction`.
+ *
+ * @remarks
+ * If `pageFunction` returns a promise `$$eval` will wait for the promise to
+ * resolve and then return its value.
+ *
+ * @example
+ *
+ * ```ts
+ * // get the amount of divs on the page
+ * const divCount = await page.$$eval('div', divs => divs.length);
+ *
+ * // get the text content of all the `.options` elements:
+ * const options = await page.$$eval('div > span.options', options => {
+ * return options.map(option => option.textContent);
+ * });
+ * ```
+ *
+ * If you are using TypeScript, you may have to provide an explicit type to the
+ * first argument of the `pageFunction`.
+ * By default it is typed as `Element[]`, but you may need to provide a more
+ * specific sub-type:
+ *
+ * @example
+ *
+ * ```ts
+ * // if you don't provide HTMLInputElement here, TS will error
+ * // as `value` is not on `Element`
+ * await page.$$eval('input', (elements: HTMLInputElement[]) => {
+ * return elements.map(e => e.value);
+ * });
+ * ```
+ *
+ * The compiler should be able to infer the return type
+ * from the `pageFunction` you provide. If it is unable to, you can use the generic
+ * type to tell the compiler what return type you expect from `$$eval`:
+ *
+ * @example
+ *
+ * ```ts
+ * // The compiler can infer the return type in this case, but if it can't
+ * // or if you want to be more explicit, provide it as the generic type.
+ * const allInputValues = await page.$$eval<string[]>(
+ * 'input',
+ * (elements: HTMLInputElement[]) => elements.map(e => e.textContent)
+ * );
+ * ```
+ *
+ * @param selector - the
+ * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors | selector}
+ * to query for
+ * @param pageFunction - the function to be evaluated in the page context.
+ * Will be passed the result of
+ * `Array.from(document.querySelectorAll(selector))` as its first argument.
+ * @param args - any additional arguments to pass through to `pageFunction`.
+ *
+ * @returns The result of calling `pageFunction`. If it returns an element it
+ * is wrapped in an {@link ElementHandle}, else the raw value itself is
+ * returned.
+ */
+ async $$eval<
+ Selector extends string,
+ Params extends unknown[],
+ Func extends EvaluateFuncWith<
+ Array<NodeFor<Selector>>,
+ Params
+ > = EvaluateFuncWith<Array<NodeFor<Selector>>, Params>,
+ >(
+ selector: Selector,
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<Awaited<ReturnType<Func>>> {
+ pageFunction = withSourcePuppeteerURLIfNone(this.$$eval.name, pageFunction);
+ return await this.mainFrame().$$eval(selector, pageFunction, ...args);
+ }
+
+ /**
+ * The method evaluates the XPath expression relative to the page document as
+ * its context node. If there are no such elements, the method resolves to an
+ * empty array.
+ *
+ * @remarks
+ * Shortcut for {@link Frame.$x | Page.mainFrame().$x(expression) }.
+ *
+ * @param expression - Expression to evaluate
+ */
+ async $x(expression: string): Promise<Array<ElementHandle<Node>>> {
+ return await this.mainFrame().$x(expression);
+ }
+
+ /**
+ * If no URLs are specified, this method returns cookies for the current page
+ * URL. If URLs are specified, only cookies for those URLs are returned.
+ */
+ abstract cookies(...urls: string[]): Promise<Protocol.Network.Cookie[]>;
+
+ abstract deleteCookie(
+ ...cookies: Protocol.Network.DeleteCookiesRequest[]
+ ): Promise<void>;
+
+ /**
+ * @example
+ *
+ * ```ts
+ * await page.setCookie(cookieObject1, cookieObject2);
+ * ```
+ */
+ abstract setCookie(...cookies: Protocol.Network.CookieParam[]): Promise<void>;
+
+ /**
+ * Adds a `<script>` tag into the page with the desired URL or content.
+ *
+ * @remarks
+ * Shortcut for
+ * {@link Frame.addScriptTag | page.mainFrame().addScriptTag(options)}.
+ *
+ * @param options - Options for the script.
+ * @returns An {@link ElementHandle | element handle} to the injected
+ * `<script>` element.
+ */
+ async addScriptTag(
+ options: FrameAddScriptTagOptions
+ ): Promise<ElementHandle<HTMLScriptElement>> {
+ return await this.mainFrame().addScriptTag(options);
+ }
+
+ /**
+ * Adds a `<link rel="stylesheet">` tag into the page with the desired URL or
+ * a `<style type="text/css">` tag with the content.
+ *
+ * Shortcut for
+ * {@link Frame.(addStyleTag:2) | page.mainFrame().addStyleTag(options)}.
+ *
+ * @returns An {@link ElementHandle | element handle} to the injected `<link>`
+ * or `<style>` element.
+ */
+ async addStyleTag(
+ options: Omit<FrameAddStyleTagOptions, 'url'>
+ ): Promise<ElementHandle<HTMLStyleElement>>;
+ async addStyleTag(
+ options: FrameAddStyleTagOptions
+ ): Promise<ElementHandle<HTMLLinkElement>>;
+ async addStyleTag(
+ options: FrameAddStyleTagOptions
+ ): Promise<ElementHandle<HTMLStyleElement | HTMLLinkElement>> {
+ return await this.mainFrame().addStyleTag(options);
+ }
+
+ /**
+ * The method adds a function called `name` on the page's `window` object.
+ * When called, the function executes `puppeteerFunction` in node.js and
+ * returns a `Promise` which resolves to the return value of
+ * `puppeteerFunction`.
+ *
+ * If the puppeteerFunction returns a `Promise`, it will be awaited.
+ *
+ * :::note
+ *
+ * Functions installed via `page.exposeFunction` survive navigations.
+ *
+ * :::note
+ *
+ * @example
+ * An example of adding an `md5` function into the page:
+ *
+ * ```ts
+ * import puppeteer from 'puppeteer';
+ * import crypto from 'crypto';
+ *
+ * (async () => {
+ * const browser = await puppeteer.launch();
+ * const page = await browser.newPage();
+ * page.on('console', msg => console.log(msg.text()));
+ * await page.exposeFunction('md5', text =>
+ * crypto.createHash('md5').update(text).digest('hex')
+ * );
+ * await page.evaluate(async () => {
+ * // use window.md5 to compute hashes
+ * const myString = 'PUPPETEER';
+ * const myHash = await window.md5(myString);
+ * console.log(`md5 of ${myString} is ${myHash}`);
+ * });
+ * await browser.close();
+ * })();
+ * ```
+ *
+ * @example
+ * An example of adding a `window.readfile` function into the page:
+ *
+ * ```ts
+ * import puppeteer from 'puppeteer';
+ * import fs from 'fs';
+ *
+ * (async () => {
+ * const browser = await puppeteer.launch();
+ * const page = await browser.newPage();
+ * page.on('console', msg => console.log(msg.text()));
+ * await page.exposeFunction('readfile', async filePath => {
+ * return new Promise((resolve, reject) => {
+ * fs.readFile(filePath, 'utf8', (err, text) => {
+ * if (err) reject(err);
+ * else resolve(text);
+ * });
+ * });
+ * });
+ * await page.evaluate(async () => {
+ * // use window.readfile to read contents of a file
+ * const content = await window.readfile('/etc/hosts');
+ * console.log(content);
+ * });
+ * await browser.close();
+ * })();
+ * ```
+ *
+ * @param name - Name of the function on the window object
+ * @param pptrFunction - Callback function which will be called in Puppeteer's
+ * context.
+ */
+ abstract exposeFunction(
+ name: string,
+ pptrFunction: Function | {default: Function}
+ ): Promise<void>;
+
+ /**
+ * The method removes a previously added function via ${@link Page.exposeFunction}
+ * called `name` from the page's `window` object.
+ */
+ abstract removeExposedFunction(name: string): Promise<void>;
+
+ /**
+ * Provide credentials for `HTTP authentication`.
+ *
+ * @remarks
+ * To disable authentication, pass `null`.
+ */
+ abstract authenticate(credentials: Credentials): Promise<void>;
+
+ /**
+ * The extra HTTP headers will be sent with every request the page initiates.
+ *
+ * :::tip
+ *
+ * All HTTP header names are lowercased. (HTTP headers are
+ * case-insensitive, so this shouldn’t impact your server code.)
+ *
+ * :::
+ *
+ * :::note
+ *
+ * page.setExtraHTTPHeaders does not guarantee the order of headers in
+ * the outgoing requests.
+ *
+ * :::
+ *
+ * @param headers - An object containing additional HTTP headers to be sent
+ * with every request. All header values must be strings.
+ */
+ abstract setExtraHTTPHeaders(headers: Record<string, string>): Promise<void>;
+
+ /**
+ * @param userAgent - Specific user agent to use in this page
+ * @param userAgentData - Specific user agent client hint data to use in this
+ * page
+ * @returns Promise which resolves when the user agent is set.
+ */
+ abstract setUserAgent(
+ userAgent: string,
+ userAgentMetadata?: Protocol.Emulation.UserAgentMetadata
+ ): Promise<void>;
+
+ /**
+ * Object containing metrics as key/value pairs.
+ *
+ * @returns
+ *
+ * - `Timestamp` : The timestamp when the metrics sample was taken.
+ *
+ * - `Documents` : Number of documents in the page.
+ *
+ * - `Frames` : Number of frames in the page.
+ *
+ * - `JSEventListeners` : Number of events in the page.
+ *
+ * - `Nodes` : Number of DOM nodes in the page.
+ *
+ * - `LayoutCount` : Total number of full or partial page layout.
+ *
+ * - `RecalcStyleCount` : Total number of page style recalculations.
+ *
+ * - `LayoutDuration` : Combined durations of all page layouts.
+ *
+ * - `RecalcStyleDuration` : Combined duration of all page style
+ * recalculations.
+ *
+ * - `ScriptDuration` : Combined duration of JavaScript execution.
+ *
+ * - `TaskDuration` : Combined duration of all tasks performed by the browser.
+ *
+ * - `JSHeapUsedSize` : Used JavaScript heap size.
+ *
+ * - `JSHeapTotalSize` : Total JavaScript heap size.
+ *
+ * @remarks
+ * All timestamps are in monotonic time: monotonically increasing time
+ * in seconds since an arbitrary point in the past.
+ */
+ abstract metrics(): Promise<Metrics>;
+
+ /**
+ * The page's URL.
+ *
+ * @remarks
+ *
+ * Shortcut for {@link Frame.url | page.mainFrame().url()}.
+ */
+ url(): string {
+ return this.mainFrame().url();
+ }
+
+ /**
+ * The full HTML contents of the page, including the DOCTYPE.
+ */
+ async content(): Promise<string> {
+ return await this.mainFrame().content();
+ }
+
+ /**
+ * Set the content of the page.
+ *
+ * @param html - HTML markup to assign to the page.
+ * @param options - Parameters that has some properties.
+ *
+ * @remarks
+ *
+ * The parameter `options` might have the following options.
+ *
+ * - `timeout` : Maximum time in milliseconds for resources to load, defaults
+ * to 30 seconds, pass `0` to disable timeout. The default value can be
+ * changed by using the {@link Page.setDefaultNavigationTimeout} or
+ * {@link Page.setDefaultTimeout} methods.
+ *
+ * - `waitUntil`: When to consider setting markup succeeded, defaults to
+ * `load`. Given an array of event strings, setting content is considered
+ * to be successful after all events have been fired. Events can be
+ * either:<br/>
+ * - `load` : consider setting content to be finished when the `load` event
+ * is fired.<br/>
+ * - `domcontentloaded` : consider setting content to be finished when the
+ * `DOMContentLoaded` event is fired.<br/>
+ * - `networkidle0` : consider setting content to be finished when there are
+ * no more than 0 network connections for at least `500` ms.<br/>
+ * - `networkidle2` : consider setting content to be finished when there are
+ * no more than 2 network connections for at least `500` ms.
+ */
+ async setContent(html: string, options?: WaitForOptions): Promise<void> {
+ await this.mainFrame().setContent(html, options);
+ }
+
+ /**
+ * Navigates the page to the given `url`.
+ *
+ * @remarks
+ *
+ * Navigation to `about:blank` or navigation to the same URL with a different
+ * hash will succeed and return `null`.
+ *
+ * :::warning
+ *
+ * Headless mode doesn't support navigation to a PDF document. See the {@link
+ * https://bugs.chromium.org/p/chromium/issues/detail?id=761295 | upstream
+ * issue}.
+ *
+ * :::
+ *
+ * Shortcut for {@link Frame.goto | page.mainFrame().goto(url, options)}.
+ *
+ * @param url - URL to navigate page to. The URL should include scheme, e.g.
+ * `https://`
+ * @param options - Options to configure waiting behavior.
+ * @returns A promise which resolves to the main resource response. In case of
+ * multiple redirects, the navigation will resolve with the response of the
+ * last redirect.
+ * @throws If:
+ *
+ * - there's an SSL error (e.g. in case of self-signed certificates).
+ * - target URL is invalid.
+ * - the timeout is exceeded during navigation.
+ * - the remote server does not respond or is unreachable.
+ * - the main resource failed to load.
+ *
+ * This method will not throw an error when any valid HTTP status code is
+ * returned by the remote server, including 404 "Not Found" and 500 "Internal
+ * Server Error". The status code for such responses can be retrieved by
+ * calling {@link HTTPResponse.status}.
+ */
+ async goto(url: string, options?: GoToOptions): Promise<HTTPResponse | null> {
+ return await this.mainFrame().goto(url, options);
+ }
+
+ /**
+ * Reloads the page.
+ *
+ * @param options - Options to configure waiting behavior.
+ * @returns A promise which resolves to the main resource response. In case of
+ * multiple redirects, the navigation will resolve with the response of the
+ * last redirect.
+ */
+ abstract reload(options?: WaitForOptions): Promise<HTTPResponse | null>;
+
+ /**
+ * Waits for the page to navigate to a new URL or to reload. It is useful when
+ * you run code that will indirectly cause the page to navigate.
+ *
+ * @example
+ *
+ * ```ts
+ * const [response] = await Promise.all([
+ * page.waitForNavigation(), // The promise resolves after navigation has finished
+ * page.click('a.my-link'), // Clicking the link will indirectly cause a navigation
+ * ]);
+ * ```
+ *
+ * @remarks
+ *
+ * Usage of the
+ * {@link https://developer.mozilla.org/en-US/docs/Web/API/History_API | History API}
+ * to change the URL is considered a navigation.
+ *
+ * @param options - Navigation parameters which might have the following
+ * properties:
+ * @returns A `Promise` which resolves to the main resource response.
+ *
+ * - In case of multiple redirects, the navigation will resolve with the
+ * response of the last redirect.
+ * - In case of navigation to a different anchor or navigation due to History
+ * API usage, the navigation will resolve with `null`.
+ */
+ async waitForNavigation(
+ options: WaitForOptions = {}
+ ): Promise<HTTPResponse | null> {
+ return await this.mainFrame().waitForNavigation(options);
+ }
+
+ /**
+ * @param urlOrPredicate - A URL or predicate to wait for
+ * @param options - Optional waiting parameters
+ * @returns Promise which resolves to the matched request
+ * @example
+ *
+ * ```ts
+ * const firstRequest = await page.waitForRequest(
+ * 'https://example.com/resource'
+ * );
+ * const finalRequest = await page.waitForRequest(
+ * request => request.url() === 'https://example.com'
+ * );
+ * return finalRequest.response()?.ok();
+ * ```
+ *
+ * @remarks
+ * Optional Waiting Parameters have:
+ *
+ * - `timeout`: Maximum wait time in milliseconds, defaults to `30` seconds, pass
+ * `0` to disable the timeout. The default value can be changed by using the
+ * {@link Page.setDefaultTimeout} method.
+ */
+ waitForRequest(
+ urlOrPredicate: string | AwaitablePredicate<HTTPRequest>,
+ options: WaitTimeoutOptions = {}
+ ): Promise<HTTPRequest> {
+ const {timeout: ms = this._timeoutSettings.timeout()} = options;
+ if (typeof urlOrPredicate === 'string') {
+ const url = urlOrPredicate;
+ urlOrPredicate = (request: HTTPRequest) => {
+ return request.url() === url;
+ };
+ }
+ const observable$ = fromEmitterEvent(this, PageEvent.Request).pipe(
+ filterAsync(urlOrPredicate),
+ raceWith(
+ timeout(ms),
+ fromEmitterEvent(this, PageEvent.Close).pipe(
+ map(() => {
+ throw new TargetCloseError('Page closed!');
+ })
+ )
+ )
+ );
+ return firstValueFrom(observable$);
+ }
+
+ /**
+ * @param urlOrPredicate - A URL or predicate to wait for.
+ * @param options - Optional waiting parameters
+ * @returns Promise which resolves to the matched response.
+ * @example
+ *
+ * ```ts
+ * const firstResponse = await page.waitForResponse(
+ * 'https://example.com/resource'
+ * );
+ * const finalResponse = await page.waitForResponse(
+ * response =>
+ * response.url() === 'https://example.com' && response.status() === 200
+ * );
+ * const finalResponse = await page.waitForResponse(async response => {
+ * return (await response.text()).includes('<html>');
+ * });
+ * return finalResponse.ok();
+ * ```
+ *
+ * @remarks
+ * Optional Parameter have:
+ *
+ * - `timeout`: Maximum wait time in milliseconds, defaults to `30` seconds,
+ * pass `0` to disable the timeout. The default value can be changed by using
+ * the {@link Page.setDefaultTimeout} method.
+ */
+ waitForResponse(
+ urlOrPredicate: string | AwaitablePredicate<HTTPResponse>,
+ options: WaitTimeoutOptions = {}
+ ): Promise<HTTPResponse> {
+ const {timeout: ms = this._timeoutSettings.timeout()} = options;
+ if (typeof urlOrPredicate === 'string') {
+ const url = urlOrPredicate;
+ urlOrPredicate = (response: HTTPResponse) => {
+ return response.url() === url;
+ };
+ }
+ const observable$ = fromEmitterEvent(this, PageEvent.Response).pipe(
+ filterAsync(urlOrPredicate),
+ raceWith(
+ timeout(ms),
+ fromEmitterEvent(this, PageEvent.Close).pipe(
+ map(() => {
+ throw new TargetCloseError('Page closed!');
+ })
+ )
+ )
+ );
+ return firstValueFrom(observable$);
+ }
+
+ /**
+ * Waits for the network to be idle.
+ *
+ * @param options - Options to configure waiting behavior.
+ * @returns A promise which resolves once the network is idle.
+ */
+ waitForNetworkIdle(options: WaitForNetworkIdleOptions = {}): Promise<void> {
+ return firstValueFrom(this.waitForNetworkIdle$(options));
+ }
+
+ /**
+ * @internal
+ */
+ waitForNetworkIdle$(
+ options: WaitForNetworkIdleOptions = {}
+ ): Observable<void> {
+ const {
+ timeout: ms = this._timeoutSettings.timeout(),
+ idleTime = NETWORK_IDLE_TIME,
+ concurrency = 0,
+ } = options;
+
+ return this.#inflight$.pipe(
+ startWith(this.#requestsInFlight),
+ switchMap(() => {
+ if (this.#requestsInFlight > concurrency) {
+ return EMPTY;
+ } else {
+ return timer(idleTime);
+ }
+ }),
+ map(() => {}),
+ raceWith(
+ timeout(ms),
+ fromEmitterEvent(this, PageEvent.Close).pipe(
+ map(() => {
+ throw new TargetCloseError('Page closed!');
+ })
+ )
+ )
+ );
+ }
+
+ /**
+ * Waits for a frame matching the given conditions to appear.
+ *
+ * @example
+ *
+ * ```ts
+ * const frame = await page.waitForFrame(async frame => {
+ * return frame.name() === 'Test';
+ * });
+ * ```
+ */
+ async waitForFrame(
+ urlOrPredicate: string | ((frame: Frame) => Awaitable<boolean>),
+ options: WaitTimeoutOptions = {}
+ ): Promise<Frame> {
+ const {timeout: ms = this.getDefaultTimeout()} = options;
+
+ if (isString(urlOrPredicate)) {
+ urlOrPredicate = (frame: Frame) => {
+ return urlOrPredicate === frame.url();
+ };
+ }
+
+ return await firstValueFrom(
+ merge(
+ fromEmitterEvent(this, PageEvent.FrameAttached),
+ fromEmitterEvent(this, PageEvent.FrameNavigated),
+ from(this.frames())
+ ).pipe(
+ filterAsync(urlOrPredicate),
+ first(),
+ raceWith(
+ timeout(ms),
+ fromEmitterEvent(this, PageEvent.Close).pipe(
+ map(() => {
+ throw new TargetCloseError('Page closed.');
+ })
+ )
+ )
+ )
+ );
+ }
+
+ /**
+ * This method navigate to the previous page in history.
+ * @param options - Navigation parameters
+ * @returns Promise which resolves to the main resource response. In case of
+ * multiple redirects, the navigation will resolve with the response of the
+ * last redirect. If can not go back, resolves to `null`.
+ * @remarks
+ * The argument `options` might have the following properties:
+ *
+ * - `timeout` : Maximum navigation time in milliseconds, defaults to 30
+ * seconds, pass 0 to disable timeout. The default value can be changed by
+ * using the {@link Page.setDefaultNavigationTimeout} or
+ * {@link Page.setDefaultTimeout} methods.
+ *
+ * - `waitUntil` : When to consider navigation succeeded, defaults to `load`.
+ * Given an array of event strings, navigation is considered to be
+ * successful after all events have been fired. Events can be either:<br/>
+ * - `load` : consider navigation to be finished when the load event is
+ * fired.<br/>
+ * - `domcontentloaded` : consider navigation to be finished when the
+ * DOMContentLoaded event is fired.<br/>
+ * - `networkidle0` : consider navigation to be finished when there are no
+ * more than 0 network connections for at least `500` ms.<br/>
+ * - `networkidle2` : consider navigation to be finished when there are no
+ * more than 2 network connections for at least `500` ms.
+ */
+ abstract goBack(options?: WaitForOptions): Promise<HTTPResponse | null>;
+
+ /**
+ * This method navigate to the next page in history.
+ * @param options - Navigation Parameter
+ * @returns Promise which resolves to the main resource response. In case of
+ * multiple redirects, the navigation will resolve with the response of the
+ * last redirect. If can not go forward, resolves to `null`.
+ * @remarks
+ * The argument `options` might have the following properties:
+ *
+ * - `timeout` : Maximum navigation time in milliseconds, defaults to 30
+ * seconds, pass 0 to disable timeout. The default value can be changed by
+ * using the {@link Page.setDefaultNavigationTimeout} or
+ * {@link Page.setDefaultTimeout} methods.
+ *
+ * - `waitUntil`: When to consider navigation succeeded, defaults to `load`.
+ * Given an array of event strings, navigation is considered to be
+ * successful after all events have been fired. Events can be either:<br/>
+ * - `load` : consider navigation to be finished when the load event is
+ * fired.<br/>
+ * - `domcontentloaded` : consider navigation to be finished when the
+ * DOMContentLoaded event is fired.<br/>
+ * - `networkidle0` : consider navigation to be finished when there are no
+ * more than 0 network connections for at least `500` ms.<br/>
+ * - `networkidle2` : consider navigation to be finished when there are no
+ * more than 2 network connections for at least `500` ms.
+ */
+ abstract goForward(options?: WaitForOptions): Promise<HTTPResponse | null>;
+
+ /**
+ * Brings page to front (activates tab).
+ */
+ abstract bringToFront(): Promise<void>;
+
+ /**
+ * Emulates a given device's metrics and user agent.
+ *
+ * To aid emulation, Puppeteer provides a list of known devices that can be
+ * via {@link KnownDevices}.
+ *
+ * @remarks
+ * This method is a shortcut for calling two methods:
+ * {@link Page.setUserAgent} and {@link Page.setViewport}.
+ *
+ * This method will resize the page. A lot of websites don't expect phones to
+ * change size, so you should emulate before navigating to the page.
+ *
+ * @example
+ *
+ * ```ts
+ * import {KnownDevices} from 'puppeteer';
+ * const iPhone = KnownDevices['iPhone 6'];
+ *
+ * (async () => {
+ * const browser = await puppeteer.launch();
+ * const page = await browser.newPage();
+ * await page.emulate(iPhone);
+ * await page.goto('https://www.google.com');
+ * // other actions...
+ * await browser.close();
+ * })();
+ * ```
+ */
+ async emulate(device: Device): Promise<void> {
+ await Promise.all([
+ this.setUserAgent(device.userAgent),
+ this.setViewport(device.viewport),
+ ]);
+ }
+
+ /**
+ * @param enabled - Whether or not to enable JavaScript on the page.
+ * @remarks
+ * NOTE: changing this value won't affect scripts that have already been run.
+ * It will take full effect on the next navigation.
+ */
+ abstract setJavaScriptEnabled(enabled: boolean): Promise<void>;
+
+ /**
+ * Toggles bypassing page's Content-Security-Policy.
+ * @param enabled - sets bypassing of page's Content-Security-Policy.
+ * @remarks
+ * NOTE: CSP bypassing happens at the moment of CSP initialization rather than
+ * evaluation. Usually, this means that `page.setBypassCSP` should be called
+ * before navigating to the domain.
+ */
+ abstract setBypassCSP(enabled: boolean): Promise<void>;
+
+ /**
+ * @param type - Changes the CSS media type of the page. The only allowed
+ * values are `screen`, `print` and `null`. Passing `null` disables CSS media
+ * emulation.
+ * @example
+ *
+ * ```ts
+ * await page.evaluate(() => matchMedia('screen').matches);
+ * // → true
+ * await page.evaluate(() => matchMedia('print').matches);
+ * // → false
+ *
+ * await page.emulateMediaType('print');
+ * await page.evaluate(() => matchMedia('screen').matches);
+ * // → false
+ * await page.evaluate(() => matchMedia('print').matches);
+ * // → true
+ *
+ * await page.emulateMediaType(null);
+ * await page.evaluate(() => matchMedia('screen').matches);
+ * // → true
+ * await page.evaluate(() => matchMedia('print').matches);
+ * // → false
+ * ```
+ */
+ abstract emulateMediaType(type?: string): Promise<void>;
+
+ /**
+ * Enables CPU throttling to emulate slow CPUs.
+ * @param factor - slowdown factor (1 is no throttle, 2 is 2x slowdown, etc).
+ */
+ abstract emulateCPUThrottling(factor: number | null): Promise<void>;
+
+ /**
+ * @param features - `<?Array<Object>>` Given an array of media feature
+ * objects, emulates CSS media features on the page. Each media feature object
+ * must have the following properties:
+ * @example
+ *
+ * ```ts
+ * await page.emulateMediaFeatures([
+ * {name: 'prefers-color-scheme', value: 'dark'},
+ * ]);
+ * await page.evaluate(
+ * () => matchMedia('(prefers-color-scheme: dark)').matches
+ * );
+ * // → true
+ * await page.evaluate(
+ * () => matchMedia('(prefers-color-scheme: light)').matches
+ * );
+ * // → false
+ *
+ * await page.emulateMediaFeatures([
+ * {name: 'prefers-reduced-motion', value: 'reduce'},
+ * ]);
+ * await page.evaluate(
+ * () => matchMedia('(prefers-reduced-motion: reduce)').matches
+ * );
+ * // → true
+ * await page.evaluate(
+ * () => matchMedia('(prefers-reduced-motion: no-preference)').matches
+ * );
+ * // → false
+ *
+ * await page.emulateMediaFeatures([
+ * {name: 'prefers-color-scheme', value: 'dark'},
+ * {name: 'prefers-reduced-motion', value: 'reduce'},
+ * ]);
+ * await page.evaluate(
+ * () => matchMedia('(prefers-color-scheme: dark)').matches
+ * );
+ * // → true
+ * await page.evaluate(
+ * () => matchMedia('(prefers-color-scheme: light)').matches
+ * );
+ * // → false
+ * await page.evaluate(
+ * () => matchMedia('(prefers-reduced-motion: reduce)').matches
+ * );
+ * // → true
+ * await page.evaluate(
+ * () => matchMedia('(prefers-reduced-motion: no-preference)').matches
+ * );
+ * // → false
+ *
+ * await page.emulateMediaFeatures([{name: 'color-gamut', value: 'p3'}]);
+ * await page.evaluate(() => matchMedia('(color-gamut: srgb)').matches);
+ * // → true
+ * await page.evaluate(() => matchMedia('(color-gamut: p3)').matches);
+ * // → true
+ * await page.evaluate(() => matchMedia('(color-gamut: rec2020)').matches);
+ * // → false
+ * ```
+ */
+ abstract emulateMediaFeatures(features?: MediaFeature[]): Promise<void>;
+
+ /**
+ * @param timezoneId - Changes the timezone of the page. See
+ * {@link https://source.chromium.org/chromium/chromium/deps/icu.git/+/faee8bc70570192d82d2978a71e2a615788597d1:source/data/misc/metaZones.txt | ICU’s metaZones.txt}
+ * for a list of supported timezone IDs. Passing
+ * `null` disables timezone emulation.
+ */
+ abstract emulateTimezone(timezoneId?: string): Promise<void>;
+
+ /**
+ * Emulates the idle state.
+ * If no arguments set, clears idle state emulation.
+ *
+ * @example
+ *
+ * ```ts
+ * // set idle emulation
+ * await page.emulateIdleState({isUserActive: true, isScreenUnlocked: false});
+ *
+ * // do some checks here
+ * ...
+ *
+ * // clear idle emulation
+ * await page.emulateIdleState();
+ * ```
+ *
+ * @param overrides - Mock idle state. If not set, clears idle overrides
+ */
+ abstract emulateIdleState(overrides?: {
+ isUserActive: boolean;
+ isScreenUnlocked: boolean;
+ }): Promise<void>;
+
+ /**
+ * Simulates the given vision deficiency on the page.
+ *
+ * @example
+ *
+ * ```ts
+ * import puppeteer from 'puppeteer';
+ *
+ * (async () => {
+ * const browser = await puppeteer.launch();
+ * const page = await browser.newPage();
+ * await page.goto('https://v8.dev/blog/10-years');
+ *
+ * await page.emulateVisionDeficiency('achromatopsia');
+ * await page.screenshot({path: 'achromatopsia.png'});
+ *
+ * await page.emulateVisionDeficiency('deuteranopia');
+ * await page.screenshot({path: 'deuteranopia.png'});
+ *
+ * await page.emulateVisionDeficiency('blurredVision');
+ * await page.screenshot({path: 'blurred-vision.png'});
+ *
+ * await browser.close();
+ * })();
+ * ```
+ *
+ * @param type - the type of deficiency to simulate, or `'none'` to reset.
+ */
+ abstract emulateVisionDeficiency(
+ type?: Protocol.Emulation.SetEmulatedVisionDeficiencyRequest['type']
+ ): Promise<void>;
+
+ /**
+ * `page.setViewport` will resize the page. A lot of websites don't expect
+ * phones to change size, so you should set the viewport before navigating to
+ * the page.
+ *
+ * In the case of multiple pages in a single browser, each page can have its
+ * own viewport size.
+ * @example
+ *
+ * ```ts
+ * const page = await browser.newPage();
+ * await page.setViewport({
+ * width: 640,
+ * height: 480,
+ * deviceScaleFactor: 1,
+ * });
+ * await page.goto('https://example.com');
+ * ```
+ *
+ * @param viewport -
+ * @remarks
+ * NOTE: in certain cases, setting viewport will reload the page in order to
+ * set the isMobile or hasTouch properties.
+ */
+ abstract setViewport(viewport: Viewport): Promise<void>;
+
+ /**
+ * Returns the current page viewport settings without checking the actual page
+ * viewport.
+ *
+ * This is either the viewport set with the previous {@link Page.setViewport}
+ * call or the default viewport set via
+ * {@link BrowserConnectOptions | BrowserConnectOptions.defaultViewport}.
+ */
+ abstract viewport(): Viewport | null;
+
+ /**
+ * Evaluates a function in the page's context and returns the result.
+ *
+ * If the function passed to `page.evaluate` returns a Promise, the
+ * function will wait for the promise to resolve and return its value.
+ *
+ * @example
+ *
+ * ```ts
+ * const result = await frame.evaluate(() => {
+ * return Promise.resolve(8 * 7);
+ * });
+ * console.log(result); // prints "56"
+ * ```
+ *
+ * You can pass a string instead of a function (although functions are
+ * recommended as they are easier to debug and use with TypeScript):
+ *
+ * @example
+ *
+ * ```ts
+ * const aHandle = await page.evaluate('1 + 2');
+ * ```
+ *
+ * To get the best TypeScript experience, you should pass in as the
+ * generic the type of `pageFunction`:
+ *
+ * ```ts
+ * const aHandle = await page.evaluate(() => 2);
+ * ```
+ *
+ * @example
+ *
+ * {@link ElementHandle} instances (including {@link JSHandle}s) can be passed
+ * as arguments to the `pageFunction`:
+ *
+ * ```ts
+ * const bodyHandle = await page.$('body');
+ * const html = await page.evaluate(body => body.innerHTML, bodyHandle);
+ * await bodyHandle.dispose();
+ * ```
+ *
+ * @param pageFunction - a function that is run within the page
+ * @param args - arguments to be passed to the pageFunction
+ *
+ * @returns the return value of `pageFunction`.
+ */
+ async evaluate<
+ Params extends unknown[],
+ Func extends EvaluateFunc<Params> = EvaluateFunc<Params>,
+ >(
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<Awaited<ReturnType<Func>>> {
+ pageFunction = withSourcePuppeteerURLIfNone(
+ this.evaluate.name,
+ pageFunction
+ );
+ return await this.mainFrame().evaluate(pageFunction, ...args);
+ }
+
+ /**
+ * Adds a function which would be invoked in one of the following scenarios:
+ *
+ * - whenever the page is navigated
+ *
+ * - whenever the child frame is attached or navigated. In this case, the
+ * function is invoked in the context of the newly attached frame.
+ *
+ * The function is invoked after the document was created but before any of
+ * its scripts were run. This is useful to amend the JavaScript environment,
+ * e.g. to seed `Math.random`.
+ * @param pageFunction - Function to be evaluated in browser context
+ * @param args - Arguments to pass to `pageFunction`
+ * @example
+ * An example of overriding the navigator.languages property before the page loads:
+ *
+ * ```ts
+ * // preload.js
+ *
+ * // overwrite the `languages` property to use a custom getter
+ * Object.defineProperty(navigator, 'languages', {
+ * get: function () {
+ * return ['en-US', 'en', 'bn'];
+ * },
+ * });
+ *
+ * // In your puppeteer script, assuming the preload.js file is
+ * // in same folder of our script.
+ * const preloadFile = fs.readFileSync('./preload.js', 'utf8');
+ * await page.evaluateOnNewDocument(preloadFile);
+ * ```
+ */
+ abstract evaluateOnNewDocument<
+ Params extends unknown[],
+ Func extends (...args: Params) => unknown = (...args: Params) => unknown,
+ >(
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<NewDocumentScriptEvaluation>;
+
+ /**
+ * Removes script that injected into page by Page.evaluateOnNewDocument.
+ *
+ * @param identifier - script identifier
+ */
+ abstract removeScriptToEvaluateOnNewDocument(
+ identifier: string
+ ): Promise<void>;
+
+ /**
+ * Toggles ignoring cache for each request based on the enabled state. By
+ * default, caching is enabled.
+ * @param enabled - sets the `enabled` state of cache
+ * @defaultValue `true`
+ */
+ abstract setCacheEnabled(enabled?: boolean): Promise<void>;
+
+ /**
+ * @internal
+ */
+ async _maybeWriteBufferToFile(
+ path: string | undefined,
+ buffer: Buffer
+ ): Promise<void> {
+ if (!path) {
+ return;
+ }
+
+ const fs = await importFSPromises();
+
+ await fs.writeFile(path, buffer);
+ }
+
+ /**
+ * Captures a screencast of this {@link Page | page}.
+ *
+ * @example
+ * Recording a {@link Page | page}:
+ *
+ * ```
+ * import puppeteer from 'puppeteer';
+ *
+ * // Launch a browser
+ * const browser = await puppeteer.launch();
+ *
+ * // Create a new page
+ * const page = await browser.newPage();
+ *
+ * // Go to your site.
+ * await page.goto("https://www.example.com");
+ *
+ * // Start recording.
+ * const recorder = await page.screencast({path: 'recording.webm'});
+ *
+ * // Do something.
+ *
+ * // Stop recording.
+ * await recorder.stop();
+ *
+ * browser.close();
+ * ```
+ *
+ * @param options - Configures screencast behavior.
+ *
+ * @experimental
+ *
+ * @remarks
+ *
+ * All recordings will be {@link https://www.webmproject.org/ | WebM} format using
+ * the {@link https://www.webmproject.org/vp9/ | VP9} video codec. The FPS is 30.
+ *
+ * You must have {@link https://ffmpeg.org/ | ffmpeg} installed on your system.
+ */
+ async screencast(
+ options: Readonly<ScreencastOptions> = {}
+ ): Promise<ScreenRecorder> {
+ const [{ScreenRecorder}, [width, height, devicePixelRatio]] =
+ await Promise.all([
+ import('../node/ScreenRecorder.js'),
+ this.#getNativePixelDimensions(),
+ ]);
+
+ let crop: BoundingBox | undefined;
+ if (options.crop) {
+ const {
+ x,
+ y,
+ width: cropWidth,
+ height: cropHeight,
+ } = roundRectangle(normalizeRectangle(options.crop));
+ if (x < 0 || y < 0) {
+ throw new Error(
+ `\`crop.x\` and \`crop.y\` must be greater than or equal to 0.`
+ );
+ }
+ if (cropWidth <= 0 || cropHeight <= 0) {
+ throw new Error(
+ `\`crop.height\` and \`crop.width\` must be greater than or equal to 0.`
+ );
+ }
+
+ const viewportWidth = width / devicePixelRatio;
+ const viewportHeight = height / devicePixelRatio;
+ if (x + cropWidth > viewportWidth) {
+ throw new Error(
+ `\`crop.width\` cannot be larger than the viewport width (${viewportWidth}).`
+ );
+ }
+ if (y + cropHeight > viewportHeight) {
+ throw new Error(
+ `\`crop.height\` cannot be larger than the viewport height (${viewportHeight}).`
+ );
+ }
+
+ crop = {
+ x: x * devicePixelRatio,
+ y: y * devicePixelRatio,
+ width: cropWidth * devicePixelRatio,
+ height: cropHeight * devicePixelRatio,
+ };
+ }
+ if (options.speed !== undefined && options.speed <= 0) {
+ throw new Error(`\`speed\` must be greater than 0.`);
+ }
+ if (options.scale !== undefined && options.scale <= 0) {
+ throw new Error(`\`scale\` must be greater than 0.`);
+ }
+
+ const recorder = new ScreenRecorder(this, width, height, {
+ ...options,
+ path: options.ffmpegPath,
+ crop,
+ });
+ try {
+ await this._startScreencast();
+ } catch (error) {
+ void recorder.stop();
+ throw error;
+ }
+ if (options.path) {
+ const {createWriteStream} = await import('fs');
+ const stream = createWriteStream(options.path, 'binary');
+ recorder.pipe(stream);
+ }
+ return recorder;
+ }
+
+ #screencastSessionCount = 0;
+ #startScreencastPromise: Promise<void> | undefined;
+
+ /**
+ * @internal
+ */
+ async _startScreencast(): Promise<void> {
+ ++this.#screencastSessionCount;
+ if (!this.#startScreencastPromise) {
+ this.#startScreencastPromise = this.mainFrame()
+ .client.send('Page.startScreencast', {format: 'png'})
+ .then(() => {
+ // Wait for the first frame.
+ return new Promise(resolve => {
+ return this.mainFrame().client.once('Page.screencastFrame', () => {
+ return resolve();
+ });
+ });
+ });
+ }
+ await this.#startScreencastPromise;
+ }
+
+ /**
+ * @internal
+ */
+ async _stopScreencast(): Promise<void> {
+ --this.#screencastSessionCount;
+ if (!this.#startScreencastPromise) {
+ return;
+ }
+ this.#startScreencastPromise = undefined;
+ if (this.#screencastSessionCount === 0) {
+ await this.mainFrame().client.send('Page.stopScreencast');
+ }
+ }
+
+ /**
+ * Gets the native, non-emulated dimensions of the viewport.
+ */
+ async #getNativePixelDimensions(): Promise<
+ readonly [width: number, height: number, devicePixelRatio: number]
+ > {
+ const viewport = this.viewport();
+ using stack = new DisposableStack();
+ if (viewport && viewport.deviceScaleFactor !== 0) {
+ await this.setViewport({...viewport, deviceScaleFactor: 0});
+ stack.defer(() => {
+ void this.setViewport(viewport).catch(debugError);
+ });
+ }
+ return await this.mainFrame()
+ .isolatedRealm()
+ .evaluate(() => {
+ return [
+ window.visualViewport!.width * window.devicePixelRatio,
+ window.visualViewport!.height * window.devicePixelRatio,
+ window.devicePixelRatio,
+ ] as const;
+ });
+ }
+
+ /**
+ * Captures a screenshot of this {@link Page | page}.
+ *
+ * @param options - Configures screenshot behavior.
+ */
+ async screenshot(
+ options: Readonly<ScreenshotOptions> & {encoding: 'base64'}
+ ): Promise<string>;
+ async screenshot(options?: Readonly<ScreenshotOptions>): Promise<Buffer>;
+ @guarded(function () {
+ return this.browser();
+ })
+ async screenshot(
+ userOptions: Readonly<ScreenshotOptions> = {}
+ ): Promise<Buffer | string> {
+ await this.bringToFront();
+
+ // TODO: use structuredClone after Node 16 support is dropped.
+ const options = {
+ ...userOptions,
+ clip: userOptions.clip
+ ? {
+ ...userOptions.clip,
+ }
+ : undefined,
+ };
+ if (options.type === undefined && options.path !== undefined) {
+ const filePath = options.path;
+ // Note we cannot use Node.js here due to browser compatability.
+ const extension = filePath
+ .slice(filePath.lastIndexOf('.') + 1)
+ .toLowerCase();
+ switch (extension) {
+ case 'png':
+ options.type = 'png';
+ break;
+ case 'jpeg':
+ case 'jpg':
+ options.type = 'jpeg';
+ break;
+ case 'webp':
+ options.type = 'webp';
+ break;
+ }
+ }
+ if (options.quality !== undefined) {
+ if (options.quality < 0 && options.quality > 100) {
+ throw new Error(
+ `Expected 'quality' (${options.quality}) to be between 0 and 100, inclusive.`
+ );
+ }
+ if (
+ options.type === undefined ||
+ !['jpeg', 'webp'].includes(options.type)
+ ) {
+ throw new Error(
+ `${options.type ?? 'png'} screenshots do not support 'quality'.`
+ );
+ }
+ }
+ if (options.clip) {
+ if (options.clip.width <= 0) {
+ throw new Error("'width' in 'clip' must be positive.");
+ }
+ if (options.clip.height <= 0) {
+ throw new Error("'height' in 'clip' must be positive.");
+ }
+ }
+
+ setDefaultScreenshotOptions(options);
+
+ await using stack = new AsyncDisposableStack();
+ if (options.clip) {
+ if (options.fullPage) {
+ throw new Error("'clip' and 'fullPage' are mutually exclusive");
+ }
+
+ options.clip = roundRectangle(normalizeRectangle(options.clip));
+ } else {
+ if (options.fullPage) {
+ // If `captureBeyondViewport` is `false`, then we set the viewport to
+ // capture the full page. Note this may be affected by on-page CSS and
+ // JavaScript.
+ if (!options.captureBeyondViewport) {
+ const scrollDimensions = await this.mainFrame()
+ .isolatedRealm()
+ .evaluate(() => {
+ const element = document.documentElement;
+ return {
+ width: element.scrollWidth,
+ height: element.scrollHeight,
+ };
+ });
+ const viewport = this.viewport();
+ await this.setViewport({
+ ...viewport,
+ ...scrollDimensions,
+ });
+ stack.defer(async () => {
+ if (viewport) {
+ await this.setViewport(viewport).catch(debugError);
+ } else {
+ await this.setViewport({
+ width: 0,
+ height: 0,
+ }).catch(debugError);
+ }
+ });
+ }
+ } else {
+ options.captureBeyondViewport = false;
+ }
+ }
+
+ const data = await this._screenshot(options);
+ if (options.encoding === 'base64') {
+ return data;
+ }
+ const buffer = Buffer.from(data, 'base64');
+ await this._maybeWriteBufferToFile(options.path, buffer);
+ return buffer;
+ }
+
+ /**
+ * @internal
+ */
+ abstract _screenshot(options: Readonly<ScreenshotOptions>): Promise<string>;
+
+ /**
+ * Generates a PDF of the page with the `print` CSS media type.
+ *
+ * @param options - options for generating the PDF.
+ *
+ * @remarks
+ *
+ * To generate a PDF with the `screen` media type, call
+ * {@link Page.emulateMediaType | `page.emulateMediaType('screen')`} before
+ * calling `page.pdf()`.
+ *
+ * By default, `page.pdf()` generates a pdf with modified colors for printing.
+ * Use the
+ * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/-webkit-print-color-adjust | `-webkit-print-color-adjust`}
+ * property to force rendering of exact colors.
+ */
+ abstract createPDFStream(options?: PDFOptions): Promise<Readable>;
+
+ /**
+ * {@inheritDoc Page.createPDFStream}
+ */
+ abstract pdf(options?: PDFOptions): Promise<Buffer>;
+
+ /**
+ * The page's title
+ *
+ * @remarks
+ *
+ * Shortcut for {@link Frame.title | page.mainFrame().title()}.
+ */
+ async title(): Promise<string> {
+ return await this.mainFrame().title();
+ }
+
+ abstract close(options?: {runBeforeUnload?: boolean}): Promise<void>;
+
+ /**
+ * Indicates that the page has been closed.
+ * @returns
+ */
+ abstract isClosed(): boolean;
+
+ /**
+ * {@inheritDoc Mouse}
+ */
+ abstract get mouse(): Mouse;
+
+ /**
+ * This method fetches an element with `selector`, scrolls it into view if
+ * needed, and then uses {@link Page | Page.mouse} to click in the center of the
+ * element. If there's no element matching `selector`, the method throws an
+ * error.
+ *
+ * @remarks
+ *
+ * Bear in mind that if `click()` triggers a navigation event and
+ * there's a separate `page.waitForNavigation()` promise to be resolved, you
+ * may end up with a race condition that yields unexpected results. The
+ * correct pattern for click and wait for navigation is the following:
+ *
+ * ```ts
+ * const [response] = await Promise.all([
+ * page.waitForNavigation(waitOptions),
+ * page.click(selector, clickOptions),
+ * ]);
+ * ```
+ *
+ * Shortcut for {@link Frame.click | page.mainFrame().click(selector[, options]) }.
+ * @param selector - A `selector` to search for element to click. If there are
+ * multiple elements satisfying the `selector`, the first will be clicked
+ * @param options - `Object`
+ * @returns Promise which resolves when the element matching `selector` is
+ * successfully clicked. The Promise will be rejected if there is no element
+ * matching `selector`.
+ */
+ click(selector: string, options?: Readonly<ClickOptions>): Promise<void> {
+ return this.mainFrame().click(selector, options);
+ }
+
+ /**
+ * This method fetches an element with `selector` and focuses it. If there's no
+ * element matching `selector`, the method throws an error.
+ * @param selector - A
+ * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors | selector }
+ * of an element to focus. If there are multiple elements satisfying the
+ * selector, the first will be focused.
+ * @returns Promise which resolves when the element matching selector is
+ * successfully focused. The promise will be rejected if there is no element
+ * matching selector.
+ *
+ * @remarks
+ *
+ * Shortcut for {@link Frame.focus | page.mainFrame().focus(selector)}.
+ */
+ focus(selector: string): Promise<void> {
+ return this.mainFrame().focus(selector);
+ }
+
+ /**
+ * This method fetches an element with `selector`, scrolls it into view if
+ * needed, and then uses {@link Page | Page.mouse}
+ * to hover over the center of the element.
+ * If there's no element matching `selector`, the method throws an error.
+ * @param selector - A
+ * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors | selector}
+ * to search for element to hover. If there are multiple elements satisfying
+ * the selector, the first will be hovered.
+ * @returns Promise which resolves when the element matching `selector` is
+ * successfully hovered. Promise gets rejected if there's no element matching
+ * `selector`.
+ *
+ * @remarks
+ *
+ * Shortcut for {@link Page.hover | page.mainFrame().hover(selector)}.
+ */
+ hover(selector: string): Promise<void> {
+ return this.mainFrame().hover(selector);
+ }
+
+ /**
+ * Triggers a `change` and `input` event once all the provided options have been
+ * selected. If there's no `<select>` element matching `selector`, the method
+ * throws an error.
+ *
+ * @example
+ *
+ * ```ts
+ * page.select('select#colors', 'blue'); // single selection
+ * page.select('select#colors', 'red', 'green', 'blue'); // multiple selections
+ * ```
+ *
+ * @param selector - A
+ * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors | Selector}
+ * to query the page for
+ * @param values - Values of options to select. If the `<select>` has the
+ * `multiple` attribute, all values are considered, otherwise only the first one
+ * is taken into account.
+ * @returns
+ *
+ * @remarks
+ *
+ * Shortcut for {@link Frame.select | page.mainFrame().select()}
+ */
+ select(selector: string, ...values: string[]): Promise<string[]> {
+ return this.mainFrame().select(selector, ...values);
+ }
+
+ /**
+ * This method fetches an element with `selector`, scrolls it into view if
+ * needed, and then uses {@link Page | Page.touchscreen}
+ * to tap in the center of the element.
+ * If there's no element matching `selector`, the method throws an error.
+ * @param selector - A
+ * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors | Selector}
+ * to search for element to tap. If there are multiple elements satisfying the
+ * selector, the first will be tapped.
+ *
+ * @remarks
+ *
+ * Shortcut for {@link Frame.tap | page.mainFrame().tap(selector)}.
+ */
+ tap(selector: string): Promise<void> {
+ return this.mainFrame().tap(selector);
+ }
+
+ /**
+ * Sends a `keydown`, `keypress/input`, and `keyup` event for each character
+ * in the text.
+ *
+ * To press a special key, like `Control` or `ArrowDown`, use {@link Keyboard.press}.
+ * @example
+ *
+ * ```ts
+ * await page.type('#mytextarea', 'Hello');
+ * // Types instantly
+ * await page.type('#mytextarea', 'World', {delay: 100});
+ * // Types slower, like a user
+ * ```
+ *
+ * @param selector - A
+ * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors | selector}
+ * of an element to type into. If there are multiple elements satisfying the
+ * selector, the first will be used.
+ * @param text - A text to type into a focused element.
+ * @param options - have property `delay` which is the Time to wait between
+ * key presses in milliseconds. Defaults to `0`.
+ * @returns
+ */
+ type(
+ selector: string,
+ text: string,
+ options?: Readonly<KeyboardTypeOptions>
+ ): Promise<void> {
+ return this.mainFrame().type(selector, text, options);
+ }
+
+ /**
+ * @deprecated Replace with `new Promise(r => setTimeout(r, milliseconds));`.
+ *
+ * Causes your script to wait for the given number of milliseconds.
+ *
+ * @remarks
+ *
+ * It's generally recommended to not wait for a number of seconds, but instead
+ * use {@link Frame.waitForSelector}, {@link Frame.waitForXPath} or
+ * {@link Frame.waitForFunction} to wait for exactly the conditions you want.
+ *
+ * @example
+ *
+ * Wait for 1 second:
+ *
+ * ```ts
+ * await page.waitForTimeout(1000);
+ * ```
+ *
+ * @param milliseconds - the number of milliseconds to wait.
+ */
+ waitForTimeout(milliseconds: number): Promise<void> {
+ return this.mainFrame().waitForTimeout(milliseconds);
+ }
+
+ /**
+ * Wait for the `selector` to appear in page. If at the moment of calling the
+ * method the `selector` already exists, the method will return immediately. If
+ * the `selector` doesn't appear after the `timeout` milliseconds of waiting, the
+ * function will throw.
+ *
+ * @example
+ * This method works across navigations:
+ *
+ * ```ts
+ * import puppeteer from 'puppeteer';
+ * (async () => {
+ * const browser = await puppeteer.launch();
+ * const page = await browser.newPage();
+ * let currentURL;
+ * page
+ * .waitForSelector('img')
+ * .then(() => console.log('First URL with image: ' + currentURL));
+ * for (currentURL of [
+ * 'https://example.com',
+ * 'https://google.com',
+ * 'https://bbc.com',
+ * ]) {
+ * await page.goto(currentURL);
+ * }
+ * await browser.close();
+ * })();
+ * ```
+ *
+ * @param selector - A
+ * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors | selector}
+ * of an element to wait for
+ * @param options - Optional waiting parameters
+ * @returns Promise which resolves when element specified by selector string
+ * is added to DOM. Resolves to `null` if waiting for hidden: `true` and
+ * selector is not found in DOM.
+ *
+ * @remarks
+ * The optional Parameter in Arguments `options` are:
+ *
+ * - `visible`: A boolean wait for element to be present in DOM and to be
+ * visible, i.e. to not have `display: none` or `visibility: hidden` CSS
+ * properties. Defaults to `false`.
+ *
+ * - `hidden`: Wait for element to not be found in the DOM or to be hidden,
+ * i.e. have `display: none` or `visibility: hidden` CSS properties. Defaults to
+ * `false`.
+ *
+ * - `timeout`: maximum time to wait for in milliseconds. Defaults to `30000`
+ * (30 seconds). Pass `0` to disable timeout. The default value can be changed
+ * by using the {@link Page.setDefaultTimeout} method.
+ */
+ async waitForSelector<Selector extends string>(
+ selector: Selector,
+ options: WaitForSelectorOptions = {}
+ ): Promise<ElementHandle<NodeFor<Selector>> | null> {
+ return await this.mainFrame().waitForSelector(selector, options);
+ }
+
+ /**
+ * Wait for the `xpath` to appear in page. If at the moment of calling the
+ * method the `xpath` already exists, the method will return immediately. If
+ * the `xpath` doesn't appear after the `timeout` milliseconds of waiting, the
+ * function will throw.
+ *
+ * @example
+ * This method works across navigation
+ *
+ * ```ts
+ * import puppeteer from 'puppeteer';
+ * (async () => {
+ * const browser = await puppeteer.launch();
+ * const page = await browser.newPage();
+ * let currentURL;
+ * page
+ * .waitForXPath('//img')
+ * .then(() => console.log('First URL with image: ' + currentURL));
+ * for (currentURL of [
+ * 'https://example.com',
+ * 'https://google.com',
+ * 'https://bbc.com',
+ * ]) {
+ * await page.goto(currentURL);
+ * }
+ * await browser.close();
+ * })();
+ * ```
+ *
+ * @param xpath - A
+ * {@link https://developer.mozilla.org/en-US/docs/Web/XPath | xpath} of an
+ * element to wait for
+ * @param options - Optional waiting parameters
+ * @returns Promise which resolves when element specified by xpath string is
+ * added to DOM. Resolves to `null` if waiting for `hidden: true` and xpath is
+ * not found in DOM, otherwise resolves to `ElementHandle`.
+ * @remarks
+ * The optional Argument `options` have properties:
+ *
+ * - `visible`: A boolean to wait for element to be present in DOM and to be
+ * visible, i.e. to not have `display: none` or `visibility: hidden` CSS
+ * properties. Defaults to `false`.
+ *
+ * - `hidden`: A boolean wait for element to not be found in the DOM or to be
+ * hidden, i.e. have `display: none` or `visibility: hidden` CSS properties.
+ * Defaults to `false`.
+ *
+ * - `timeout`: A number which is maximum time to wait for in milliseconds.
+ * Defaults to `30000` (30 seconds). Pass `0` to disable timeout. The default
+ * value can be changed by using the {@link Page.setDefaultTimeout} method.
+ */
+ waitForXPath(
+ xpath: string,
+ options?: WaitForSelectorOptions
+ ): Promise<ElementHandle<Node> | null> {
+ return this.mainFrame().waitForXPath(xpath, options);
+ }
+
+ /**
+ * Waits for the provided function, `pageFunction`, to return a truthy value when
+ * evaluated in the page's context.
+ *
+ * @example
+ * {@link Page.waitForFunction} can be used to observe a viewport size change:
+ *
+ * ```ts
+ * import puppeteer from 'puppeteer';
+ * (async () => {
+ * const browser = await puppeteer.launch();
+ * const page = await browser.newPage();
+ * const watchDog = page.waitForFunction('window.innerWidth < 100');
+ * await page.setViewport({width: 50, height: 50});
+ * await watchDog;
+ * await browser.close();
+ * })();
+ * ```
+ *
+ * @example
+ * Arguments can be passed from Node.js to `pageFunction`:
+ *
+ * ```ts
+ * const selector = '.foo';
+ * await page.waitForFunction(
+ * selector => !!document.querySelector(selector),
+ * {},
+ * selector
+ * );
+ * ```
+ *
+ * @example
+ * The provided `pageFunction` can be asynchronous:
+ *
+ * ```ts
+ * const username = 'github-username';
+ * await page.waitForFunction(
+ * async username => {
+ * const githubResponse = await fetch(
+ * `https://api.github.com/users/${username}`
+ * );
+ * const githubUser = await githubResponse.json();
+ * // show the avatar
+ * const img = document.createElement('img');
+ * img.src = githubUser.avatar_url;
+ * // wait 3 seconds
+ * await new Promise((resolve, reject) => setTimeout(resolve, 3000));
+ * img.remove();
+ * },
+ * {},
+ * username
+ * );
+ * ```
+ *
+ * @param pageFunction - Function to be evaluated in browser context until it returns a
+ * truthy value.
+ * @param options - Options for configuring waiting behavior.
+ */
+ waitForFunction<
+ Params extends unknown[],
+ Func extends EvaluateFunc<Params> = EvaluateFunc<Params>,
+ >(
+ pageFunction: Func | string,
+ options?: FrameWaitForFunctionOptions,
+ ...args: Params
+ ): Promise<HandleFor<Awaited<ReturnType<Func>>>> {
+ return this.mainFrame().waitForFunction(pageFunction, options, ...args);
+ }
+
+ /**
+ * This method is typically coupled with an action that triggers a device
+ * request from an api such as WebBluetooth.
+ *
+ * :::caution
+ *
+ * This must be called before the device request is made. It will not return a
+ * currently active device prompt.
+ *
+ * :::
+ *
+ * @example
+ *
+ * ```ts
+ * const [devicePrompt] = Promise.all([
+ * page.waitForDevicePrompt(),
+ * page.click('#connect-bluetooth'),
+ * ]);
+ * await devicePrompt.select(
+ * await devicePrompt.waitForDevice(({name}) => name.includes('My Device'))
+ * );
+ * ```
+ */
+ abstract waitForDevicePrompt(
+ options?: WaitTimeoutOptions
+ ): Promise<DeviceRequestPrompt>;
+
+ /** @internal */
+ [disposeSymbol](): void {
+ return void this.close().catch(debugError);
+ }
+
+ /** @internal */
+ [asyncDisposeSymbol](): Promise<void> {
+ return this.close();
+ }
+}
+
+/**
+ * @internal
+ */
+export const supportedMetrics = new Set<string>([
+ 'Timestamp',
+ 'Documents',
+ 'Frames',
+ 'JSEventListeners',
+ 'Nodes',
+ 'LayoutCount',
+ 'RecalcStyleCount',
+ 'LayoutDuration',
+ 'RecalcStyleDuration',
+ 'ScriptDuration',
+ 'TaskDuration',
+ 'JSHeapUsedSize',
+ 'JSHeapTotalSize',
+]);
+
+/** @see https://w3c.github.io/webdriver-bidi/#normalize-rect */
+function normalizeRectangle<BoundingBoxType extends BoundingBox>(
+ clip: Readonly<BoundingBoxType>
+): BoundingBoxType {
+ return {
+ ...clip,
+ ...(clip.width < 0
+ ? {
+ x: clip.x + clip.width,
+ width: -clip.width,
+ }
+ : {
+ x: clip.x,
+ width: clip.width,
+ }),
+ ...(clip.height < 0
+ ? {
+ y: clip.y + clip.height,
+ height: -clip.height,
+ }
+ : {
+ y: clip.y,
+ height: clip.height,
+ }),
+ };
+}
+
+function roundRectangle<BoundingBoxType extends BoundingBox>(
+ clip: Readonly<BoundingBoxType>
+): BoundingBoxType {
+ const x = Math.round(clip.x);
+ const y = Math.round(clip.y);
+ const width = Math.round(clip.width + clip.x - x);
+ const height = Math.round(clip.height + clip.y - y);
+ return {...clip, x, y, width, height};
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/Realm.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/Realm.ts
new file mode 100644
index 0000000000..eee1f2c1dd
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/Realm.ts
@@ -0,0 +1,104 @@
+/**
+ * @license
+ * Copyright 2023 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {TimeoutSettings} from '../common/TimeoutSettings.js';
+import type {
+ EvaluateFunc,
+ HandleFor,
+ InnerLazyParams,
+} from '../common/types.js';
+import {TaskManager, WaitTask} from '../common/WaitTask.js';
+import {disposeSymbol} from '../util/disposable.js';
+
+import type {ElementHandle} from './ElementHandle.js';
+import type {Environment} from './Environment.js';
+import type {JSHandle} from './JSHandle.js';
+
+/**
+ * @internal
+ */
+export abstract class Realm implements Disposable {
+ protected readonly timeoutSettings: TimeoutSettings;
+ readonly taskManager = new TaskManager();
+
+ constructor(timeoutSettings: TimeoutSettings) {
+ this.timeoutSettings = timeoutSettings;
+ }
+
+ abstract get environment(): Environment;
+
+ abstract adoptHandle<T extends JSHandle<Node>>(handle: T): Promise<T>;
+ abstract transferHandle<T extends JSHandle<Node>>(handle: T): Promise<T>;
+ abstract evaluateHandle<
+ Params extends unknown[],
+ Func extends EvaluateFunc<Params> = EvaluateFunc<Params>,
+ >(
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<HandleFor<Awaited<ReturnType<Func>>>>;
+ abstract evaluate<
+ Params extends unknown[],
+ Func extends EvaluateFunc<Params> = EvaluateFunc<Params>,
+ >(
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<Awaited<ReturnType<Func>>>;
+
+ async waitForFunction<
+ Params extends unknown[],
+ Func extends EvaluateFunc<InnerLazyParams<Params>> = EvaluateFunc<
+ InnerLazyParams<Params>
+ >,
+ >(
+ pageFunction: Func | string,
+ options: {
+ polling?: 'raf' | 'mutation' | number;
+ timeout?: number;
+ root?: ElementHandle<Node>;
+ signal?: AbortSignal;
+ } = {},
+ ...args: Params
+ ): Promise<HandleFor<Awaited<ReturnType<Func>>>> {
+ const {
+ polling = 'raf',
+ timeout = this.timeoutSettings.timeout(),
+ root,
+ signal,
+ } = options;
+ if (typeof polling === 'number' && polling < 0) {
+ throw new Error('Cannot poll with non-positive interval');
+ }
+ const waitTask = new WaitTask(
+ this,
+ {
+ polling,
+ root,
+ timeout,
+ signal,
+ },
+ pageFunction as unknown as
+ | ((...args: unknown[]) => Promise<Awaited<ReturnType<Func>>>)
+ | string,
+ ...args
+ );
+ return await waitTask.result;
+ }
+
+ abstract adoptBackendNode(backendNodeId?: number): Promise<JSHandle<Node>>;
+
+ get disposed(): boolean {
+ return this.#disposed;
+ }
+
+ #disposed = false;
+ /** @internal */
+ [disposeSymbol](): void {
+ this.#disposed = true;
+ this.taskManager.terminateAll(
+ new Error('waitForFunction failed: frame got detached.')
+ );
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/Target.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/Target.ts
new file mode 100644
index 0000000000..f91b91df12
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/Target.ts
@@ -0,0 +1,95 @@
+/**
+ * @license
+ * Copyright 2023 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {Browser} from './Browser.js';
+import type {BrowserContext} from './BrowserContext.js';
+import type {CDPSession} from './CDPSession.js';
+import type {Page} from './Page.js';
+import type {WebWorker} from './WebWorker.js';
+
+/**
+ * @public
+ */
+export enum TargetType {
+ PAGE = 'page',
+ BACKGROUND_PAGE = 'background_page',
+ SERVICE_WORKER = 'service_worker',
+ SHARED_WORKER = 'shared_worker',
+ BROWSER = 'browser',
+ WEBVIEW = 'webview',
+ OTHER = 'other',
+ /**
+ * @internal
+ */
+ TAB = 'tab',
+}
+
+/**
+ * Target represents a
+ * {@link https://chromedevtools.github.io/devtools-protocol/tot/Target/ | CDP target}.
+ * In CDP a target is something that can be debugged such a frame, a page or a
+ * worker.
+ * @public
+ */
+export abstract class Target {
+ /**
+ * @internal
+ */
+ protected constructor() {}
+
+ /**
+ * If the target is not of type `"service_worker"` or `"shared_worker"`, returns `null`.
+ */
+ async worker(): Promise<WebWorker | null> {
+ return null;
+ }
+
+ /**
+ * If the target is not of type `"page"`, `"webview"` or `"background_page"`,
+ * returns `null`.
+ */
+ async page(): Promise<Page | null> {
+ return null;
+ }
+
+ /**
+ * Forcefully creates a page for a target of any type. It is useful if you
+ * want to handle a CDP target of type `other` as a page. If you deal with a
+ * regular page target, use {@link Target.page}.
+ */
+ abstract asPage(): Promise<Page>;
+
+ abstract url(): string;
+
+ /**
+ * Creates a Chrome Devtools Protocol session attached to the target.
+ */
+ abstract createCDPSession(): Promise<CDPSession>;
+
+ /**
+ * Identifies what kind of target this is.
+ *
+ * @remarks
+ *
+ * See {@link https://developer.chrome.com/extensions/background_pages | docs} for more info about background pages.
+ */
+ abstract type(): TargetType;
+
+ /**
+ * Get the browser the target belongs to.
+ */
+ abstract browser(): Browser;
+
+ /**
+ * Get the browser context the target belongs to.
+ */
+ abstract browserContext(): BrowserContext;
+
+ /**
+ * Get the target that opened this target. Top-level targets return `null`.
+ */
+ abstract opener(): Target | undefined;
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/WebWorker.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/WebWorker.ts
new file mode 100644
index 0000000000..4de287f146
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/WebWorker.ts
@@ -0,0 +1,134 @@
+/**
+ * @license
+ * Copyright 2018 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {EventEmitter, type EventType} from '../common/EventEmitter.js';
+import {TimeoutSettings} from '../common/TimeoutSettings.js';
+import type {EvaluateFunc, HandleFor} from '../common/types.js';
+import {withSourcePuppeteerURLIfNone} from '../common/util.js';
+
+import type {CDPSession} from './CDPSession.js';
+import type {Realm} from './Realm.js';
+
+/**
+ * This class represents a
+ * {@link https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API | WebWorker}.
+ *
+ * @remarks
+ * The events `workercreated` and `workerdestroyed` are emitted on the page
+ * object to signal the worker lifecycle.
+ *
+ * @example
+ *
+ * ```ts
+ * page.on('workercreated', worker =>
+ * console.log('Worker created: ' + worker.url())
+ * );
+ * page.on('workerdestroyed', worker =>
+ * console.log('Worker destroyed: ' + worker.url())
+ * );
+ *
+ * console.log('Current workers:');
+ * for (const worker of page.workers()) {
+ * console.log(' ' + worker.url());
+ * }
+ * ```
+ *
+ * @public
+ */
+export abstract class WebWorker extends EventEmitter<
+ Record<EventType, unknown>
+> {
+ /**
+ * @internal
+ */
+ readonly timeoutSettings = new TimeoutSettings();
+
+ readonly #url: string;
+
+ /**
+ * @internal
+ */
+ constructor(url: string) {
+ super();
+
+ this.#url = url;
+ }
+
+ /**
+ * @internal
+ */
+ abstract mainRealm(): Realm;
+
+ /**
+ * The URL of this web worker.
+ */
+ url(): string {
+ return this.#url;
+ }
+
+ /**
+ * The CDP session client the WebWorker belongs to.
+ */
+ abstract get client(): CDPSession;
+
+ /**
+ * Evaluates a given function in the {@link WebWorker | worker}.
+ *
+ * @remarks If the given function returns a promise,
+ * {@link WebWorker.evaluate | evaluate} will wait for the promise to resolve.
+ *
+ * As a rule of thumb, if the return value of the given function is more
+ * complicated than a JSON object (e.g. most classes), then
+ * {@link WebWorker.evaluate | evaluate} will _likely_ return some truncated
+ * value (or `{}`). This is because we are not returning the actual return
+ * value, but a deserialized version as a result of transferring the return
+ * value through a protocol to Puppeteer.
+ *
+ * In general, you should use
+ * {@link WebWorker.evaluateHandle | evaluateHandle} if
+ * {@link WebWorker.evaluate | evaluate} cannot serialize the return value
+ * properly or you need a mutable {@link JSHandle | handle} to the return
+ * object.
+ *
+ * @param func - Function to be evaluated.
+ * @param args - Arguments to pass into `func`.
+ * @returns The result of `func`.
+ */
+ async evaluate<
+ Params extends unknown[],
+ Func extends EvaluateFunc<Params> = EvaluateFunc<Params>,
+ >(func: Func | string, ...args: Params): Promise<Awaited<ReturnType<Func>>> {
+ func = withSourcePuppeteerURLIfNone(this.evaluate.name, func);
+ return await this.mainRealm().evaluate(func, ...args);
+ }
+
+ /**
+ * Evaluates a given function in the {@link WebWorker | worker}.
+ *
+ * @remarks If the given function returns a promise,
+ * {@link WebWorker.evaluate | evaluate} will wait for the promise to resolve.
+ *
+ * In general, you should use
+ * {@link WebWorker.evaluateHandle | evaluateHandle} if
+ * {@link WebWorker.evaluate | evaluate} cannot serialize the return value
+ * properly or you need a mutable {@link JSHandle | handle} to the return
+ * object.
+ *
+ * @param func - Function to be evaluated.
+ * @param args - Arguments to pass into `func`.
+ * @returns A {@link JSHandle | handle} to the return value of `func`.
+ */
+ async evaluateHandle<
+ Params extends unknown[],
+ Func extends EvaluateFunc<Params> = EvaluateFunc<Params>,
+ >(
+ func: Func | string,
+ ...args: Params
+ ): Promise<HandleFor<Awaited<ReturnType<Func>>>> {
+ func = withSourcePuppeteerURLIfNone(this.evaluateHandle.name, func);
+ return await this.mainRealm().evaluateHandle(func, ...args);
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/api.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/api.ts
new file mode 100644
index 0000000000..d2bf832a6d
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/api.ts
@@ -0,0 +1,22 @@
+/**
+ * @license
+ * Copyright 2022 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+export * from './Browser.js';
+export * from './BrowserContext.js';
+export * from './CDPSession.js';
+export * from './Dialog.js';
+export * from './ElementHandle.js';
+export * from './Environment.js';
+export * from './Frame.js';
+export * from './HTTPRequest.js';
+export * from './HTTPResponse.js';
+export * from './Input.js';
+export * from './JSHandle.js';
+export * from './Page.js';
+export * from './Realm.js';
+export * from './Target.js';
+export * from './WebWorker.js';
+export * from './locators/locators.js';
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/api/locators/locators.ts b/remote/test/puppeteer/packages/puppeteer-core/src/api/locators/locators.ts
new file mode 100644
index 0000000000..7bec11e38e
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/api/locators/locators.ts
@@ -0,0 +1,1088 @@
+/**
+ * @license
+ * Copyright 2023 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import type {
+ Observable,
+ OperatorFunction,
+} from '../../../third_party/rxjs/rxjs.js';
+import {
+ EMPTY,
+ catchError,
+ defaultIfEmpty,
+ defer,
+ filter,
+ first,
+ firstValueFrom,
+ from,
+ fromEvent,
+ identity,
+ ignoreElements,
+ map,
+ merge,
+ mergeMap,
+ noop,
+ pipe,
+ race,
+ raceWith,
+ retry,
+ tap,
+ throwIfEmpty,
+} from '../../../third_party/rxjs/rxjs.js';
+import type {EventType} from '../../common/EventEmitter.js';
+import {EventEmitter} from '../../common/EventEmitter.js';
+import type {Awaitable, HandleFor, NodeFor} from '../../common/types.js';
+import {debugError, timeout} from '../../common/util.js';
+import type {
+ BoundingBox,
+ ClickOptions,
+ ElementHandle,
+} from '../ElementHandle.js';
+import type {Frame} from '../Frame.js';
+import type {Page} from '../Page.js';
+
+/**
+ * @public
+ */
+export type VisibilityOption = 'hidden' | 'visible' | null;
+/**
+ * @public
+ */
+export interface LocatorOptions {
+ /**
+ * Whether to wait for the element to be `visible` or `hidden`. `null` to
+ * disable visibility checks.
+ */
+ visibility: VisibilityOption;
+ /**
+ * Total timeout for the entire locator operation.
+ *
+ * Pass `0` to disable timeout.
+ *
+ * @defaultValue `Page.getDefaultTimeout()`
+ */
+ timeout: number;
+ /**
+ * Whether to scroll the element into viewport if not in the viewprot already.
+ * @defaultValue `true`
+ */
+ ensureElementIsInTheViewport: boolean;
+ /**
+ * Whether to wait for input elements to become enabled before the action.
+ * Applicable to `click` and `fill` actions.
+ * @defaultValue `true`
+ */
+ waitForEnabled: boolean;
+ /**
+ * Whether to wait for the element's bounding box to be same between two
+ * animation frames.
+ * @defaultValue `true`
+ */
+ waitForStableBoundingBox: boolean;
+}
+/**
+ * @public
+ */
+export interface ActionOptions {
+ signal?: AbortSignal;
+}
+/**
+ * @public
+ */
+export type LocatorClickOptions = ClickOptions & ActionOptions;
+/**
+ * @public
+ */
+export interface LocatorScrollOptions extends ActionOptions {
+ scrollTop?: number;
+ scrollLeft?: number;
+}
+/**
+ * All the events that a locator instance may emit.
+ *
+ * @public
+ */
+export enum LocatorEvent {
+ /**
+ * Emitted every time before the locator performs an action on the located element(s).
+ */
+ Action = 'action',
+}
+export {
+ /**
+ * @deprecated Use {@link LocatorEvent}.
+ */
+ LocatorEvent as LocatorEmittedEvents,
+};
+/**
+ * @public
+ */
+export interface LocatorEvents extends Record<EventType, unknown> {
+ [LocatorEvent.Action]: undefined;
+}
+export type {
+ /**
+ * @deprecated Use {@link LocatorEvents}.
+ */
+ LocatorEvents as LocatorEventObject,
+};
+/**
+ * Locators describe a strategy of locating objects and performing an action on
+ * them. If the action fails because the object is not ready for the action, the
+ * whole operation is retried. Various preconditions for a successful action are
+ * checked automatically.
+ *
+ * @public
+ */
+export abstract class Locator<T> extends EventEmitter<LocatorEvents> {
+ /**
+ * Creates a race between multiple locators but ensures that only a single one
+ * acts.
+ *
+ * @public
+ */
+ static race<Locators extends readonly unknown[] | []>(
+ locators: Locators
+ ): Locator<AwaitedLocator<Locators[number]>> {
+ return RaceLocator.create(locators);
+ }
+
+ /**
+ * Used for nominally typing {@link Locator}.
+ */
+ declare _?: T;
+
+ /**
+ * @internal
+ */
+ protected visibility: VisibilityOption = null;
+ /**
+ * @internal
+ */
+ protected _timeout = 30000;
+ #ensureElementIsInTheViewport = true;
+ #waitForEnabled = true;
+ #waitForStableBoundingBox = true;
+
+ /**
+ * @internal
+ */
+ protected operators = {
+ conditions: (
+ conditions: Array<Action<T, never>>,
+ signal?: AbortSignal
+ ): OperatorFunction<HandleFor<T>, HandleFor<T>> => {
+ return mergeMap((handle: HandleFor<T>) => {
+ return merge(
+ ...conditions.map(condition => {
+ return condition(handle, signal);
+ })
+ ).pipe(defaultIfEmpty(handle));
+ });
+ },
+ retryAndRaceWithSignalAndTimer: <T>(
+ signal?: AbortSignal
+ ): OperatorFunction<T, T> => {
+ const candidates = [];
+ if (signal) {
+ candidates.push(
+ fromEvent(signal, 'abort').pipe(
+ map(() => {
+ throw signal.reason;
+ })
+ )
+ );
+ }
+ candidates.push(timeout(this._timeout));
+ return pipe(
+ retry({delay: RETRY_DELAY}),
+ raceWith<T, never[]>(...candidates)
+ );
+ },
+ };
+
+ // Determines when the locator will timeout for actions.
+ get timeout(): number {
+ return this._timeout;
+ }
+
+ setTimeout(timeout: number): Locator<T> {
+ const locator = this._clone();
+ locator._timeout = timeout;
+ return locator;
+ }
+
+ setVisibility<NodeType extends Node>(
+ this: Locator<NodeType>,
+ visibility: VisibilityOption
+ ): Locator<NodeType> {
+ const locator = this._clone();
+ locator.visibility = visibility;
+ return locator;
+ }
+
+ setWaitForEnabled<NodeType extends Node>(
+ this: Locator<NodeType>,
+ value: boolean
+ ): Locator<NodeType> {
+ const locator = this._clone();
+ locator.#waitForEnabled = value;
+ return locator;
+ }
+
+ setEnsureElementIsInTheViewport<ElementType extends Element>(
+ this: Locator<ElementType>,
+ value: boolean
+ ): Locator<ElementType> {
+ const locator = this._clone();
+ locator.#ensureElementIsInTheViewport = value;
+ return locator;
+ }
+
+ setWaitForStableBoundingBox<ElementType extends Element>(
+ this: Locator<ElementType>,
+ value: boolean
+ ): Locator<ElementType> {
+ const locator = this._clone();
+ locator.#waitForStableBoundingBox = value;
+ return locator;
+ }
+
+ /**
+ * @internal
+ */
+ copyOptions<T>(locator: Locator<T>): this {
+ this._timeout = locator._timeout;
+ this.visibility = locator.visibility;
+ this.#waitForEnabled = locator.#waitForEnabled;
+ this.#ensureElementIsInTheViewport = locator.#ensureElementIsInTheViewport;
+ this.#waitForStableBoundingBox = locator.#waitForStableBoundingBox;
+ return this;
+ }
+
+ /**
+ * If the element has a "disabled" property, wait for the element to be
+ * enabled.
+ */
+ #waitForEnabledIfNeeded = <ElementType extends Node>(
+ handle: HandleFor<ElementType>,
+ signal?: AbortSignal
+ ): Observable<never> => {
+ if (!this.#waitForEnabled) {
+ return EMPTY;
+ }
+ return from(
+ handle.frame.waitForFunction(
+ element => {
+ if (!(element instanceof HTMLElement)) {
+ return true;
+ }
+ const isNativeFormControl = [
+ 'BUTTON',
+ 'INPUT',
+ 'SELECT',
+ 'TEXTAREA',
+ 'OPTION',
+ 'OPTGROUP',
+ ].includes(element.nodeName);
+ return !isNativeFormControl || !element.hasAttribute('disabled');
+ },
+ {
+ timeout: this._timeout,
+ signal,
+ },
+ handle
+ )
+ ).pipe(ignoreElements());
+ };
+
+ /**
+ * Compares the bounding box of the element for two consecutive animation
+ * frames and waits till they are the same.
+ */
+ #waitForStableBoundingBoxIfNeeded = <ElementType extends Element>(
+ handle: HandleFor<ElementType>
+ ): Observable<never> => {
+ if (!this.#waitForStableBoundingBox) {
+ return EMPTY;
+ }
+ return defer(() => {
+ // Note we don't use waitForFunction because that relies on RAF.
+ return from(
+ handle.evaluate(element => {
+ return new Promise<[BoundingBox, BoundingBox]>(resolve => {
+ window.requestAnimationFrame(() => {
+ const rect1 = element.getBoundingClientRect();
+ window.requestAnimationFrame(() => {
+ const rect2 = element.getBoundingClientRect();
+ resolve([
+ {
+ x: rect1.x,
+ y: rect1.y,
+ width: rect1.width,
+ height: rect1.height,
+ },
+ {
+ x: rect2.x,
+ y: rect2.y,
+ width: rect2.width,
+ height: rect2.height,
+ },
+ ]);
+ });
+ });
+ });
+ })
+ );
+ }).pipe(
+ first(([rect1, rect2]) => {
+ return (
+ rect1.x === rect2.x &&
+ rect1.y === rect2.y &&
+ rect1.width === rect2.width &&
+ rect1.height === rect2.height
+ );
+ }),
+ retry({delay: RETRY_DELAY}),
+ ignoreElements()
+ );
+ };
+
+ /**
+ * Checks if the element is in the viewport and auto-scrolls it if it is not.
+ */
+ #ensureElementIsInTheViewportIfNeeded = <ElementType extends Element>(
+ handle: HandleFor<ElementType>
+ ): Observable<never> => {
+ if (!this.#ensureElementIsInTheViewport) {
+ return EMPTY;
+ }
+ return from(handle.isIntersectingViewport({threshold: 0})).pipe(
+ filter(isIntersectingViewport => {
+ return !isIntersectingViewport;
+ }),
+ mergeMap(() => {
+ return from(handle.scrollIntoView());
+ }),
+ mergeMap(() => {
+ return defer(() => {
+ return from(handle.isIntersectingViewport({threshold: 0}));
+ }).pipe(first(identity), retry({delay: RETRY_DELAY}), ignoreElements());
+ })
+ );
+ };
+
+ #click<ElementType extends Element>(
+ this: Locator<ElementType>,
+ options?: Readonly<LocatorClickOptions>
+ ): Observable<void> {
+ const signal = options?.signal;
+ return this._wait(options).pipe(
+ this.operators.conditions(
+ [
+ this.#ensureElementIsInTheViewportIfNeeded,
+ this.#waitForStableBoundingBoxIfNeeded,
+ this.#waitForEnabledIfNeeded,
+ ],
+ signal
+ ),
+ tap(() => {
+ return this.emit(LocatorEvent.Action, undefined);
+ }),
+ mergeMap(handle => {
+ return from(handle.click(options)).pipe(
+ catchError(err => {
+ void handle.dispose().catch(debugError);
+ throw err;
+ })
+ );
+ }),
+ this.operators.retryAndRaceWithSignalAndTimer(signal)
+ );
+ }
+
+ #fill<ElementType extends Element>(
+ this: Locator<ElementType>,
+ value: string,
+ options?: Readonly<ActionOptions>
+ ): Observable<void> {
+ const signal = options?.signal;
+ return this._wait(options).pipe(
+ this.operators.conditions(
+ [
+ this.#ensureElementIsInTheViewportIfNeeded,
+ this.#waitForStableBoundingBoxIfNeeded,
+ this.#waitForEnabledIfNeeded,
+ ],
+ signal
+ ),
+ tap(() => {
+ return this.emit(LocatorEvent.Action, undefined);
+ }),
+ mergeMap(handle => {
+ return from(
+ (handle as unknown as ElementHandle<HTMLElement>).evaluate(el => {
+ if (el instanceof HTMLSelectElement) {
+ return 'select';
+ }
+ if (el instanceof HTMLTextAreaElement) {
+ return 'typeable-input';
+ }
+ if (el instanceof HTMLInputElement) {
+ if (
+ new Set([
+ 'textarea',
+ 'text',
+ 'url',
+ 'tel',
+ 'search',
+ 'password',
+ 'number',
+ 'email',
+ ]).has(el.type)
+ ) {
+ return 'typeable-input';
+ } else {
+ return 'other-input';
+ }
+ }
+
+ if (el.isContentEditable) {
+ return 'contenteditable';
+ }
+
+ return 'unknown';
+ })
+ )
+ .pipe(
+ mergeMap(inputType => {
+ switch (inputType) {
+ case 'select':
+ return from(handle.select(value).then(noop));
+ case 'contenteditable':
+ case 'typeable-input':
+ return from(
+ (
+ handle as unknown as ElementHandle<HTMLInputElement>
+ ).evaluate((input, newValue) => {
+ const currentValue = input.isContentEditable
+ ? input.innerText
+ : input.value;
+
+ // Clear the input if the current value does not match the filled
+ // out value.
+ if (
+ newValue.length <= currentValue.length ||
+ !newValue.startsWith(input.value)
+ ) {
+ if (input.isContentEditable) {
+ input.innerText = '';
+ } else {
+ input.value = '';
+ }
+ return newValue;
+ }
+ const originalValue = input.isContentEditable
+ ? input.innerText
+ : input.value;
+
+ // If the value is partially filled out, only type the rest. Move
+ // cursor to the end of the common prefix.
+ if (input.isContentEditable) {
+ input.innerText = '';
+ input.innerText = originalValue;
+ } else {
+ input.value = '';
+ input.value = originalValue;
+ }
+ return newValue.substring(originalValue.length);
+ }, value)
+ ).pipe(
+ mergeMap(textToType => {
+ return from(handle.type(textToType));
+ })
+ );
+ case 'other-input':
+ return from(handle.focus()).pipe(
+ mergeMap(() => {
+ return from(
+ handle.evaluate((input, value) => {
+ (input as HTMLInputElement).value = value;
+ input.dispatchEvent(
+ new Event('input', {bubbles: true})
+ );
+ input.dispatchEvent(
+ new Event('change', {bubbles: true})
+ );
+ }, value)
+ );
+ })
+ );
+ case 'unknown':
+ throw new Error(`Element cannot be filled out.`);
+ }
+ })
+ )
+ .pipe(
+ catchError(err => {
+ void handle.dispose().catch(debugError);
+ throw err;
+ })
+ );
+ }),
+ this.operators.retryAndRaceWithSignalAndTimer(signal)
+ );
+ }
+
+ #hover<ElementType extends Element>(
+ this: Locator<ElementType>,
+ options?: Readonly<ActionOptions>
+ ): Observable<void> {
+ const signal = options?.signal;
+ return this._wait(options).pipe(
+ this.operators.conditions(
+ [
+ this.#ensureElementIsInTheViewportIfNeeded,
+ this.#waitForStableBoundingBoxIfNeeded,
+ ],
+ signal
+ ),
+ tap(() => {
+ return this.emit(LocatorEvent.Action, undefined);
+ }),
+ mergeMap(handle => {
+ return from(handle.hover()).pipe(
+ catchError(err => {
+ void handle.dispose().catch(debugError);
+ throw err;
+ })
+ );
+ }),
+ this.operators.retryAndRaceWithSignalAndTimer(signal)
+ );
+ }
+
+ #scroll<ElementType extends Element>(
+ this: Locator<ElementType>,
+ options?: Readonly<LocatorScrollOptions>
+ ): Observable<void> {
+ const signal = options?.signal;
+ return this._wait(options).pipe(
+ this.operators.conditions(
+ [
+ this.#ensureElementIsInTheViewportIfNeeded,
+ this.#waitForStableBoundingBoxIfNeeded,
+ ],
+ signal
+ ),
+ tap(() => {
+ return this.emit(LocatorEvent.Action, undefined);
+ }),
+ mergeMap(handle => {
+ return from(
+ handle.evaluate(
+ (el, scrollTop, scrollLeft) => {
+ if (scrollTop !== undefined) {
+ el.scrollTop = scrollTop;
+ }
+ if (scrollLeft !== undefined) {
+ el.scrollLeft = scrollLeft;
+ }
+ },
+ options?.scrollTop,
+ options?.scrollLeft
+ )
+ ).pipe(
+ catchError(err => {
+ void handle.dispose().catch(debugError);
+ throw err;
+ })
+ );
+ }),
+ this.operators.retryAndRaceWithSignalAndTimer(signal)
+ );
+ }
+
+ /**
+ * @internal
+ */
+ abstract _clone(): Locator<T>;
+
+ /**
+ * @internal
+ */
+ abstract _wait(options?: Readonly<ActionOptions>): Observable<HandleFor<T>>;
+
+ /**
+ * Clones the locator.
+ */
+ clone(): Locator<T> {
+ return this._clone();
+ }
+
+ /**
+ * Waits for the locator to get a handle from the page.
+ *
+ * @public
+ */
+ async waitHandle(options?: Readonly<ActionOptions>): Promise<HandleFor<T>> {
+ return await firstValueFrom(
+ this._wait(options).pipe(
+ this.operators.retryAndRaceWithSignalAndTimer(options?.signal)
+ )
+ );
+ }
+
+ /**
+ * Waits for the locator to get the serialized value from the page.
+ *
+ * Note this requires the value to be JSON-serializable.
+ *
+ * @public
+ */
+ async wait(options?: Readonly<ActionOptions>): Promise<T> {
+ using handle = await this.waitHandle(options);
+ return await handle.jsonValue();
+ }
+
+ /**
+ * Maps the locator using the provided mapper.
+ *
+ * @public
+ */
+ map<To>(mapper: Mapper<T, To>): Locator<To> {
+ return new MappedLocator(this._clone(), handle => {
+ // SAFETY: TypeScript cannot deduce the type.
+ return (handle as any).evaluateHandle(mapper);
+ });
+ }
+
+ /**
+ * Creates an expectation that is evaluated against located values.
+ *
+ * If the expectations do not match, then the locator will retry.
+ *
+ * @public
+ */
+ filter<S extends T>(predicate: Predicate<T, S>): Locator<S> {
+ return new FilteredLocator(this._clone(), async (handle, signal) => {
+ await (handle as ElementHandle<Node>).frame.waitForFunction(
+ predicate,
+ {signal, timeout: this._timeout},
+ handle
+ );
+ return true;
+ });
+ }
+
+ /**
+ * Creates an expectation that is evaluated against located handles.
+ *
+ * If the expectations do not match, then the locator will retry.
+ *
+ * @internal
+ */
+ filterHandle<S extends T>(
+ predicate: Predicate<HandleFor<T>, HandleFor<S>>
+ ): Locator<S> {
+ return new FilteredLocator(this._clone(), predicate);
+ }
+
+ /**
+ * Maps the locator using the provided mapper.
+ *
+ * @internal
+ */
+ mapHandle<To>(mapper: HandleMapper<T, To>): Locator<To> {
+ return new MappedLocator(this._clone(), mapper);
+ }
+
+ click<ElementType extends Element>(
+ this: Locator<ElementType>,
+ options?: Readonly<LocatorClickOptions>
+ ): Promise<void> {
+ return firstValueFrom(this.#click(options));
+ }
+
+ /**
+ * Fills out the input identified by the locator using the provided value. The
+ * type of the input is determined at runtime and the appropriate fill-out
+ * method is chosen based on the type. contenteditable, selector, inputs are
+ * supported.
+ */
+ fill<ElementType extends Element>(
+ this: Locator<ElementType>,
+ value: string,
+ options?: Readonly<ActionOptions>
+ ): Promise<void> {
+ return firstValueFrom(this.#fill(value, options));
+ }
+
+ hover<ElementType extends Element>(
+ this: Locator<ElementType>,
+ options?: Readonly<ActionOptions>
+ ): Promise<void> {
+ return firstValueFrom(this.#hover(options));
+ }
+
+ scroll<ElementType extends Element>(
+ this: Locator<ElementType>,
+ options?: Readonly<LocatorScrollOptions>
+ ): Promise<void> {
+ return firstValueFrom(this.#scroll(options));
+ }
+}
+
+/**
+ * @internal
+ */
+export class FunctionLocator<T> extends Locator<T> {
+ static create<Ret>(
+ pageOrFrame: Page | Frame,
+ func: () => Awaitable<Ret>
+ ): Locator<Ret> {
+ return new FunctionLocator<Ret>(pageOrFrame, func).setTimeout(
+ 'getDefaultTimeout' in pageOrFrame
+ ? pageOrFrame.getDefaultTimeout()
+ : pageOrFrame.page().getDefaultTimeout()
+ );
+ }
+
+ #pageOrFrame: Page | Frame;
+ #func: () => Awaitable<T>;
+
+ private constructor(pageOrFrame: Page | Frame, func: () => Awaitable<T>) {
+ super();
+
+ this.#pageOrFrame = pageOrFrame;
+ this.#func = func;
+ }
+
+ override _clone(): FunctionLocator<T> {
+ return new FunctionLocator(this.#pageOrFrame, this.#func);
+ }
+
+ _wait(options?: Readonly<ActionOptions>): Observable<HandleFor<T>> {
+ const signal = options?.signal;
+ return defer(() => {
+ return from(
+ this.#pageOrFrame.waitForFunction(this.#func, {
+ timeout: this.timeout,
+ signal,
+ })
+ );
+ }).pipe(throwIfEmpty());
+ }
+}
+
+/**
+ * @public
+ */
+export type Predicate<From, To extends From = From> =
+ | ((value: From) => value is To)
+ | ((value: From) => Awaitable<boolean>);
+/**
+ * @internal
+ */
+export type HandlePredicate<From, To extends From = From> =
+ | ((value: HandleFor<From>, signal?: AbortSignal) => value is HandleFor<To>)
+ | ((value: HandleFor<From>, signal?: AbortSignal) => Awaitable<boolean>);
+
+/**
+ * @internal
+ */
+export abstract class DelegatedLocator<T, U> extends Locator<U> {
+ #delegate: Locator<T>;
+
+ constructor(delegate: Locator<T>) {
+ super();
+
+ this.#delegate = delegate;
+ this.copyOptions(this.#delegate);
+ }
+
+ protected get delegate(): Locator<T> {
+ return this.#delegate;
+ }
+
+ override setTimeout(timeout: number): DelegatedLocator<T, U> {
+ const locator = super.setTimeout(timeout) as DelegatedLocator<T, U>;
+ locator.#delegate = this.#delegate.setTimeout(timeout);
+ return locator;
+ }
+
+ override setVisibility<ValueType extends Node, NodeType extends Node>(
+ this: DelegatedLocator<ValueType, NodeType>,
+ visibility: VisibilityOption
+ ): DelegatedLocator<ValueType, NodeType> {
+ const locator = super.setVisibility<NodeType>(
+ visibility
+ ) as DelegatedLocator<ValueType, NodeType>;
+ locator.#delegate = locator.#delegate.setVisibility<ValueType>(visibility);
+ return locator;
+ }
+
+ override setWaitForEnabled<ValueType extends Node, NodeType extends Node>(
+ this: DelegatedLocator<ValueType, NodeType>,
+ value: boolean
+ ): DelegatedLocator<ValueType, NodeType> {
+ const locator = super.setWaitForEnabled<NodeType>(
+ value
+ ) as DelegatedLocator<ValueType, NodeType>;
+ locator.#delegate = this.#delegate.setWaitForEnabled(value);
+ return locator;
+ }
+
+ override setEnsureElementIsInTheViewport<
+ ValueType extends Element,
+ ElementType extends Element,
+ >(
+ this: DelegatedLocator<ValueType, ElementType>,
+ value: boolean
+ ): DelegatedLocator<ValueType, ElementType> {
+ const locator = super.setEnsureElementIsInTheViewport<ElementType>(
+ value
+ ) as DelegatedLocator<ValueType, ElementType>;
+ locator.#delegate = this.#delegate.setEnsureElementIsInTheViewport(value);
+ return locator;
+ }
+
+ override setWaitForStableBoundingBox<
+ ValueType extends Element,
+ ElementType extends Element,
+ >(
+ this: DelegatedLocator<ValueType, ElementType>,
+ value: boolean
+ ): DelegatedLocator<ValueType, ElementType> {
+ const locator = super.setWaitForStableBoundingBox<ElementType>(
+ value
+ ) as DelegatedLocator<ValueType, ElementType>;
+ locator.#delegate = this.#delegate.setWaitForStableBoundingBox(value);
+ return locator;
+ }
+
+ abstract override _clone(): DelegatedLocator<T, U>;
+ abstract override _wait(): Observable<HandleFor<U>>;
+}
+
+/**
+ * @internal
+ */
+export class FilteredLocator<From, To extends From> extends DelegatedLocator<
+ From,
+ To
+> {
+ #predicate: HandlePredicate<From, To>;
+
+ constructor(base: Locator<From>, predicate: HandlePredicate<From, To>) {
+ super(base);
+ this.#predicate = predicate;
+ }
+
+ override _clone(): FilteredLocator<From, To> {
+ return new FilteredLocator(
+ this.delegate.clone(),
+ this.#predicate
+ ).copyOptions(this);
+ }
+
+ override _wait(options?: Readonly<ActionOptions>): Observable<HandleFor<To>> {
+ return this.delegate._wait(options).pipe(
+ mergeMap(handle => {
+ return from(
+ Promise.resolve(this.#predicate(handle, options?.signal))
+ ).pipe(
+ filter(value => {
+ return value;
+ }),
+ map(() => {
+ // SAFETY: It passed the predicate, so this is correct.
+ return handle as HandleFor<To>;
+ })
+ );
+ }),
+ throwIfEmpty()
+ );
+ }
+}
+
+/**
+ * @public
+ */
+export type Mapper<From, To> = (value: From) => Awaitable<To>;
+/**
+ * @internal
+ */
+export type HandleMapper<From, To> = (
+ value: HandleFor<From>,
+ signal?: AbortSignal
+) => Awaitable<HandleFor<To>>;
+/**
+ * @internal
+ */
+export class MappedLocator<From, To> extends DelegatedLocator<From, To> {
+ #mapper: HandleMapper<From, To>;
+
+ constructor(base: Locator<From>, mapper: HandleMapper<From, To>) {
+ super(base);
+ this.#mapper = mapper;
+ }
+
+ override _clone(): MappedLocator<From, To> {
+ return new MappedLocator(this.delegate.clone(), this.#mapper).copyOptions(
+ this
+ );
+ }
+
+ override _wait(options?: Readonly<ActionOptions>): Observable<HandleFor<To>> {
+ return this.delegate._wait(options).pipe(
+ mergeMap(handle => {
+ return from(Promise.resolve(this.#mapper(handle, options?.signal)));
+ })
+ );
+ }
+}
+
+/**
+ * @internal
+ */
+export type Action<T, U> = (
+ element: HandleFor<T>,
+ signal?: AbortSignal
+) => Observable<U>;
+/**
+ * @internal
+ */
+export class NodeLocator<T extends Node> extends Locator<T> {
+ static create<Selector extends string>(
+ pageOrFrame: Page | Frame,
+ selector: Selector
+ ): Locator<NodeFor<Selector>> {
+ return new NodeLocator<NodeFor<Selector>>(pageOrFrame, selector).setTimeout(
+ 'getDefaultTimeout' in pageOrFrame
+ ? pageOrFrame.getDefaultTimeout()
+ : pageOrFrame.page().getDefaultTimeout()
+ );
+ }
+
+ #pageOrFrame: Page | Frame;
+ #selector: string;
+
+ private constructor(pageOrFrame: Page | Frame, selector: string) {
+ super();
+
+ this.#pageOrFrame = pageOrFrame;
+ this.#selector = selector;
+ }
+
+ /**
+ * Waits for the element to become visible or hidden. visibility === 'visible'
+ * means that the element has a computed style, the visibility property other
+ * than 'hidden' or 'collapse' and non-empty bounding box. visibility ===
+ * 'hidden' means the opposite of that.
+ */
+ #waitForVisibilityIfNeeded = (handle: HandleFor<T>): Observable<never> => {
+ if (!this.visibility) {
+ return EMPTY;
+ }
+
+ return (() => {
+ switch (this.visibility) {
+ case 'hidden':
+ return defer(() => {
+ return from(handle.isHidden());
+ });
+ case 'visible':
+ return defer(() => {
+ return from(handle.isVisible());
+ });
+ }
+ })().pipe(first(identity), retry({delay: RETRY_DELAY}), ignoreElements());
+ };
+
+ override _clone(): NodeLocator<T> {
+ return new NodeLocator<T>(this.#pageOrFrame, this.#selector).copyOptions(
+ this
+ );
+ }
+
+ override _wait(options?: Readonly<ActionOptions>): Observable<HandleFor<T>> {
+ const signal = options?.signal;
+ return defer(() => {
+ return from(
+ this.#pageOrFrame.waitForSelector(this.#selector, {
+ visible: false,
+ timeout: this._timeout,
+ signal,
+ }) as Promise<HandleFor<T> | null>
+ );
+ }).pipe(
+ filter((value): value is NonNullable<typeof value> => {
+ return value !== null;
+ }),
+ throwIfEmpty(),
+ this.operators.conditions([this.#waitForVisibilityIfNeeded], signal)
+ );
+ }
+}
+
+/**
+ * @public
+ */
+export type AwaitedLocator<T> = T extends Locator<infer S> ? S : never;
+function checkLocatorArray<T extends readonly unknown[] | []>(
+ locators: T
+): ReadonlyArray<Locator<AwaitedLocator<T[number]>>> {
+ for (const locator of locators) {
+ if (!(locator instanceof Locator)) {
+ throw new Error('Unknown locator for race candidate');
+ }
+ }
+ return locators as ReadonlyArray<Locator<AwaitedLocator<T[number]>>>;
+}
+/**
+ * @internal
+ */
+export class RaceLocator<T> extends Locator<T> {
+ static create<T extends readonly unknown[]>(
+ locators: T
+ ): Locator<AwaitedLocator<T[number]>> {
+ const array = checkLocatorArray(locators);
+ return new RaceLocator(array);
+ }
+
+ #locators: ReadonlyArray<Locator<T>>;
+
+ constructor(locators: ReadonlyArray<Locator<T>>) {
+ super();
+ this.#locators = locators;
+ }
+
+ override _clone(): RaceLocator<T> {
+ return new RaceLocator<T>(
+ this.#locators.map(locator => {
+ return locator.clone();
+ })
+ ).copyOptions(this);
+ }
+
+ override _wait(options?: Readonly<ActionOptions>): Observable<HandleFor<T>> {
+ return race(
+ ...this.#locators.map(locator => {
+ return locator._wait(options);
+ })
+ );
+ }
+}
+
+/**
+ * For observables coming from promises, a delay is needed, otherwise RxJS will
+ * never yield in a permanent failure for a promise.
+ *
+ * We also don't want RxJS to do promise operations to often, so we bump the
+ * delay up to 100ms.
+ *
+ * @internal
+ */
+export const RETRY_DELAY = 100;
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/BidiOverCdp.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/BidiOverCdp.ts
new file mode 100644
index 0000000000..ace35a52b0
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/BidiOverCdp.ts
@@ -0,0 +1,209 @@
+/**
+ * @license
+ * Copyright 2023 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import * as BidiMapper from 'chromium-bidi/lib/cjs/bidiMapper/BidiMapper.js';
+import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
+import type {ProtocolMapping} from 'devtools-protocol/types/protocol-mapping.js';
+
+import type {CDPEvents, CDPSession} from '../api/CDPSession.js';
+import type {Connection as CdpConnection} from '../cdp/Connection.js';
+import {debug} from '../common/Debug.js';
+import {TargetCloseError} from '../common/Errors.js';
+import type {Handler} from '../common/EventEmitter.js';
+
+import {BidiConnection} from './Connection.js';
+
+const bidiServerLogger = (prefix: string, ...args: unknown[]): void => {
+ debug(`bidi:${prefix}`)(args);
+};
+
+/**
+ * @internal
+ */
+export async function connectBidiOverCdp(
+ cdp: CdpConnection,
+ // TODO: replace with `BidiMapper.MapperOptions`, once it's exported in
+ // https://github.com/puppeteer/puppeteer/pull/11415.
+ options: {acceptInsecureCerts: boolean}
+): Promise<BidiConnection> {
+ const transportBiDi = new NoOpTransport();
+ const cdpConnectionAdapter = new CdpConnectionAdapter(cdp);
+ const pptrTransport = {
+ send(message: string): void {
+ // Forwards a BiDi command sent by Puppeteer to the input of the BidiServer.
+ transportBiDi.emitMessage(JSON.parse(message));
+ },
+ close(): void {
+ bidiServer.close();
+ cdpConnectionAdapter.close();
+ cdp.dispose();
+ },
+ onmessage(_message: string): void {
+ // The method is overridden by the Connection.
+ },
+ };
+ transportBiDi.on('bidiResponse', (message: object) => {
+ // Forwards a BiDi event sent by BidiServer to Puppeteer.
+ pptrTransport.onmessage(JSON.stringify(message));
+ });
+ const pptrBiDiConnection = new BidiConnection(cdp.url(), pptrTransport);
+ const bidiServer = await BidiMapper.BidiServer.createAndStart(
+ transportBiDi,
+ cdpConnectionAdapter,
+ // TODO: most likely need a little bit of refactoring
+ cdpConnectionAdapter.browserClient(),
+ '',
+ options,
+ undefined,
+ bidiServerLogger
+ );
+ return pptrBiDiConnection;
+}
+
+/**
+ * Manages CDPSessions for BidiServer.
+ * @internal
+ */
+class CdpConnectionAdapter {
+ #cdp: CdpConnection;
+ #adapters = new Map<CDPSession, CDPClientAdapter<CDPSession>>();
+ #browserCdpConnection: CDPClientAdapter<CdpConnection>;
+
+ constructor(cdp: CdpConnection) {
+ this.#cdp = cdp;
+ this.#browserCdpConnection = new CDPClientAdapter(cdp);
+ }
+
+ browserClient(): CDPClientAdapter<CdpConnection> {
+ return this.#browserCdpConnection;
+ }
+
+ getCdpClient(id: string) {
+ const session = this.#cdp.session(id);
+ if (!session) {
+ throw new Error(`Unknown CDP session with id ${id}`);
+ }
+ if (!this.#adapters.has(session)) {
+ const adapter = new CDPClientAdapter(
+ session,
+ id,
+ this.#browserCdpConnection
+ );
+ this.#adapters.set(session, adapter);
+ return adapter;
+ }
+ return this.#adapters.get(session)!;
+ }
+
+ close() {
+ this.#browserCdpConnection.close();
+ for (const adapter of this.#adapters.values()) {
+ adapter.close();
+ }
+ }
+}
+
+/**
+ * Wrapper on top of CDPSession/CDPConnection to satisfy CDP interface that
+ * BidiServer needs.
+ *
+ * @internal
+ */
+class CDPClientAdapter<T extends CDPSession | CdpConnection>
+ extends BidiMapper.EventEmitter<CDPEvents>
+ implements BidiMapper.CdpClient
+{
+ #closed = false;
+ #client: T;
+ sessionId: string | undefined = undefined;
+ #browserClient?: BidiMapper.CdpClient;
+
+ constructor(
+ client: T,
+ sessionId?: string,
+ browserClient?: BidiMapper.CdpClient
+ ) {
+ super();
+ this.#client = client;
+ this.sessionId = sessionId;
+ this.#browserClient = browserClient;
+ this.#client.on('*', this.#forwardMessage as Handler<any>);
+ }
+
+ browserClient(): BidiMapper.CdpClient {
+ return this.#browserClient!;
+ }
+
+ #forwardMessage = <T extends keyof CDPEvents>(
+ method: T,
+ event: CDPEvents[T]
+ ) => {
+ this.emit(method, event);
+ };
+
+ async sendCommand<T extends keyof ProtocolMapping.Commands>(
+ method: T,
+ ...params: ProtocolMapping.Commands[T]['paramsType']
+ ): Promise<ProtocolMapping.Commands[T]['returnType']> {
+ if (this.#closed) {
+ return;
+ }
+ try {
+ return await this.#client.send(method, ...params);
+ } catch (err) {
+ if (this.#closed) {
+ return;
+ }
+ throw err;
+ }
+ }
+
+ close() {
+ this.#client.off('*', this.#forwardMessage as Handler<any>);
+ this.#closed = true;
+ }
+
+ isCloseError(error: unknown): boolean {
+ return error instanceof TargetCloseError;
+ }
+}
+
+/**
+ * This transport is given to the BiDi server instance and allows Puppeteer
+ * to send and receive commands to the BiDiServer.
+ * @internal
+ */
+class NoOpTransport
+ extends BidiMapper.EventEmitter<{
+ bidiResponse: Bidi.ChromiumBidi.Message;
+ }>
+ implements BidiMapper.BidiTransport
+{
+ #onMessage: (message: Bidi.ChromiumBidi.Command) => Promise<void> | void =
+ async (_m: Bidi.ChromiumBidi.Command): Promise<void> => {
+ return;
+ };
+
+ emitMessage(message: Bidi.ChromiumBidi.Command) {
+ void this.#onMessage(message);
+ }
+
+ setOnMessage(
+ onMessage: (message: Bidi.ChromiumBidi.Command) => Promise<void> | void
+ ): void {
+ this.#onMessage = onMessage;
+ }
+
+ async sendMessage(message: Bidi.ChromiumBidi.Message): Promise<void> {
+ this.emit('bidiResponse', message);
+ }
+
+ close() {
+ this.#onMessage = async (_m: Bidi.ChromiumBidi.Command): Promise<void> => {
+ return;
+ };
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Browser.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Browser.ts
new file mode 100644
index 0000000000..42979790c9
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Browser.ts
@@ -0,0 +1,317 @@
+/**
+ * @license
+ * Copyright 2022 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {ChildProcess} from 'child_process';
+
+import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
+
+import {
+ Browser,
+ BrowserEvent,
+ type BrowserCloseCallback,
+ type BrowserContextOptions,
+ type DebugInfo,
+} from '../api/Browser.js';
+import {BrowserContextEvent} from '../api/BrowserContext.js';
+import type {Page} from '../api/Page.js';
+import type {Target} from '../api/Target.js';
+import {UnsupportedOperation} from '../common/Errors.js';
+import type {Handler} from '../common/EventEmitter.js';
+import {debugError} from '../common/util.js';
+import type {Viewport} from '../common/Viewport.js';
+
+import {BidiBrowserContext} from './BrowserContext.js';
+import {BrowsingContext, BrowsingContextEvent} from './BrowsingContext.js';
+import type {BidiConnection} from './Connection.js';
+import type {Browser as BrowserCore} from './core/Browser.js';
+import {Session} from './core/Session.js';
+import type {UserContext} from './core/UserContext.js';
+import {
+ BiDiBrowserTarget,
+ BiDiBrowsingContextTarget,
+ BiDiPageTarget,
+ type BidiTarget,
+} from './Target.js';
+
+/**
+ * @internal
+ */
+export interface BidiBrowserOptions {
+ process?: ChildProcess;
+ closeCallback?: BrowserCloseCallback;
+ connection: BidiConnection;
+ defaultViewport: Viewport | null;
+ ignoreHTTPSErrors?: boolean;
+}
+
+/**
+ * @internal
+ */
+export class BidiBrowser extends Browser {
+ readonly protocol = 'webDriverBiDi';
+
+ // TODO: Update generator to include fully module
+ static readonly subscribeModules: string[] = [
+ 'browsingContext',
+ 'network',
+ 'log',
+ 'script',
+ ];
+ static readonly subscribeCdpEvents: Bidi.Cdp.EventNames[] = [
+ // Coverage
+ 'cdp.Debugger.scriptParsed',
+ 'cdp.CSS.styleSheetAdded',
+ 'cdp.Runtime.executionContextsCleared',
+ // Tracing
+ 'cdp.Tracing.tracingComplete',
+ // TODO: subscribe to all CDP events in the future.
+ 'cdp.Network.requestWillBeSent',
+ 'cdp.Debugger.scriptParsed',
+ 'cdp.Page.screencastFrame',
+ ];
+
+ static async create(opts: BidiBrowserOptions): Promise<BidiBrowser> {
+ const session = await Session.from(opts.connection, {
+ alwaysMatch: {
+ acceptInsecureCerts: opts.ignoreHTTPSErrors,
+ webSocketUrl: true,
+ },
+ });
+
+ await session.subscribe(
+ session.capabilities.browserName.toLocaleLowerCase().includes('firefox')
+ ? BidiBrowser.subscribeModules
+ : [...BidiBrowser.subscribeModules, ...BidiBrowser.subscribeCdpEvents]
+ );
+
+ const browser = new BidiBrowser(session.browser, opts);
+ browser.#initialize();
+ await browser.#getTree();
+ return browser;
+ }
+
+ #process?: ChildProcess;
+ #closeCallback?: BrowserCloseCallback;
+ #browserCore: BrowserCore;
+ #defaultViewport: Viewport | null;
+ #targets = new Map<string, BidiTarget>();
+ #browserContexts = new WeakMap<UserContext, BidiBrowserContext>();
+ #browserTarget: BiDiBrowserTarget;
+
+ #connectionEventHandlers = new Map<
+ Bidi.BrowsingContextEvent['method'],
+ Handler<any>
+ >([
+ ['browsingContext.contextCreated', this.#onContextCreated.bind(this)],
+ ['browsingContext.contextDestroyed', this.#onContextDestroyed.bind(this)],
+ ['browsingContext.domContentLoaded', this.#onContextDomLoaded.bind(this)],
+ ['browsingContext.fragmentNavigated', this.#onContextNavigation.bind(this)],
+ ['browsingContext.navigationStarted', this.#onContextNavigation.bind(this)],
+ ]);
+
+ private constructor(browserCore: BrowserCore, opts: BidiBrowserOptions) {
+ super();
+ this.#process = opts.process;
+ this.#closeCallback = opts.closeCallback;
+ this.#browserCore = browserCore;
+ this.#defaultViewport = opts.defaultViewport;
+ this.#browserTarget = new BiDiBrowserTarget(this);
+ this.#createBrowserContext(this.#browserCore.defaultUserContext);
+ }
+
+ #initialize() {
+ this.#browserCore.once('disconnected', () => {
+ this.emit(BrowserEvent.Disconnected, undefined);
+ });
+ this.#process?.once('close', () => {
+ this.#browserCore.dispose('Browser process exited.', true);
+ this.connection.dispose();
+ });
+
+ for (const [eventName, handler] of this.#connectionEventHandlers) {
+ this.connection.on(eventName, handler);
+ }
+ }
+
+ get #browserName() {
+ return this.#browserCore.session.capabilities.browserName;
+ }
+ get #browserVersion() {
+ return this.#browserCore.session.capabilities.browserVersion;
+ }
+
+ override userAgent(): never {
+ throw new UnsupportedOperation();
+ }
+
+ #createBrowserContext(userContext: UserContext) {
+ const browserContext = new BidiBrowserContext(this, userContext, {
+ defaultViewport: this.#defaultViewport,
+ });
+ this.#browserContexts.set(userContext, browserContext);
+ return browserContext;
+ }
+
+ #onContextDomLoaded(event: Bidi.BrowsingContext.Info) {
+ const target = this.#targets.get(event.context);
+ if (target) {
+ this.emit(BrowserEvent.TargetChanged, target);
+ }
+ }
+
+ #onContextNavigation(event: Bidi.BrowsingContext.NavigationInfo) {
+ const target = this.#targets.get(event.context);
+ if (target) {
+ this.emit(BrowserEvent.TargetChanged, target);
+ target.browserContext().emit(BrowserContextEvent.TargetChanged, target);
+ }
+ }
+
+ #onContextCreated(event: Bidi.BrowsingContext.ContextCreated['params']) {
+ const context = new BrowsingContext(
+ this.connection,
+ event,
+ this.#browserName
+ );
+ this.connection.registerBrowsingContexts(context);
+ // TODO: once more browsing context types are supported, this should be
+ // updated to support those. Currently, all top-level contexts are treated
+ // as pages.
+ const browserContext = this.browserContexts().at(-1);
+ if (!browserContext) {
+ throw new Error('Missing browser contexts');
+ }
+ const target = !context.parent
+ ? new BiDiPageTarget(browserContext, context)
+ : new BiDiBrowsingContextTarget(browserContext, context);
+ this.#targets.set(event.context, target);
+
+ this.emit(BrowserEvent.TargetCreated, target);
+ target.browserContext().emit(BrowserContextEvent.TargetCreated, target);
+
+ if (context.parent) {
+ const topLevel = this.connection.getTopLevelContext(context.parent);
+ topLevel.emit(BrowsingContextEvent.Created, context);
+ }
+ }
+
+ async #getTree(): Promise<void> {
+ const {result} = await this.connection.send('browsingContext.getTree', {});
+ for (const context of result.contexts) {
+ this.#onContextCreated(context);
+ }
+ }
+
+ async #onContextDestroyed(
+ event: Bidi.BrowsingContext.ContextDestroyed['params']
+ ) {
+ const context = this.connection.getBrowsingContext(event.context);
+ const topLevelContext = this.connection.getTopLevelContext(event.context);
+ topLevelContext.emit(BrowsingContextEvent.Destroyed, context);
+ const target = this.#targets.get(event.context);
+ const page = await target?.page();
+ await page?.close().catch(debugError);
+ this.#targets.delete(event.context);
+ if (target) {
+ this.emit(BrowserEvent.TargetDestroyed, target);
+ target.browserContext().emit(BrowserContextEvent.TargetDestroyed, target);
+ }
+ }
+
+ get connection(): BidiConnection {
+ // SAFETY: We only have one implementation.
+ return this.#browserCore.session.connection as BidiConnection;
+ }
+
+ override wsEndpoint(): string {
+ return this.connection.url;
+ }
+
+ override async close(): Promise<void> {
+ for (const [eventName, handler] of this.#connectionEventHandlers) {
+ this.connection.off(eventName, handler);
+ }
+ if (this.connection.closed) {
+ return;
+ }
+
+ try {
+ await this.#browserCore.close();
+ await this.#closeCallback?.call(null);
+ } catch (error) {
+ // Fail silently.
+ debugError(error);
+ } finally {
+ this.connection.dispose();
+ }
+ }
+
+ override get connected(): boolean {
+ return !this.#browserCore.disposed;
+ }
+
+ override process(): ChildProcess | null {
+ return this.#process ?? null;
+ }
+
+ override async createIncognitoBrowserContext(
+ _options?: BrowserContextOptions
+ ): Promise<BidiBrowserContext> {
+ const userContext = await this.#browserCore.createUserContext();
+ return this.#createBrowserContext(userContext);
+ }
+
+ override async version(): Promise<string> {
+ return `${this.#browserName}/${this.#browserVersion}`;
+ }
+
+ override browserContexts(): BidiBrowserContext[] {
+ return [...this.#browserCore.userContexts].map(context => {
+ return this.#browserContexts.get(context)!;
+ });
+ }
+
+ override defaultBrowserContext(): BidiBrowserContext {
+ return this.#browserContexts.get(this.#browserCore.defaultUserContext)!;
+ }
+
+ override newPage(): Promise<Page> {
+ return this.defaultBrowserContext().newPage();
+ }
+
+ override targets(): Target[] {
+ return [this.#browserTarget, ...Array.from(this.#targets.values())];
+ }
+
+ _getTargetById(id: string): BidiTarget {
+ const target = this.#targets.get(id);
+ if (!target) {
+ throw new Error('Target not found');
+ }
+ return target;
+ }
+
+ override target(): Target {
+ return this.#browserTarget;
+ }
+
+ override async disconnect(): Promise<void> {
+ try {
+ await this.#browserCore.session.end();
+ } catch (error) {
+ // Fail silently.
+ debugError(error);
+ } finally {
+ this.connection.dispose();
+ }
+ }
+
+ override get debugInfo(): DebugInfo {
+ return {
+ pendingProtocolErrors: this.connection.getPendingProtocolErrors(),
+ };
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/BrowserConnector.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/BrowserConnector.ts
new file mode 100644
index 0000000000..f616e90561
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/BrowserConnector.ts
@@ -0,0 +1,123 @@
+/**
+ * @license
+ * Copyright 2023 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {BrowserCloseCallback} from '../api/Browser.js';
+import {Connection} from '../cdp/Connection.js';
+import type {ConnectionTransport} from '../common/ConnectionTransport.js';
+import type {
+ BrowserConnectOptions,
+ ConnectOptions,
+} from '../common/ConnectOptions.js';
+import {ProtocolError, UnsupportedOperation} from '../common/Errors.js';
+import {debugError, DEFAULT_VIEWPORT} from '../common/util.js';
+
+import type {BidiBrowser} from './Browser.js';
+import type {BidiConnection} from './Connection.js';
+
+/**
+ * Users should never call this directly; it's called when calling `puppeteer.connect`
+ * with `protocol: 'webDriverBiDi'`. This method attaches Puppeteer to an existing browser
+ * instance. First it tries to connect to the browser using pure BiDi. If the protocol is
+ * not supported, connects to the browser using BiDi over CDP.
+ *
+ * @internal
+ */
+export async function _connectToBiDiBrowser(
+ connectionTransport: ConnectionTransport,
+ url: string,
+ options: BrowserConnectOptions & ConnectOptions
+): Promise<BidiBrowser> {
+ const {ignoreHTTPSErrors = false, defaultViewport = DEFAULT_VIEWPORT} =
+ options;
+
+ const {bidiConnection, closeCallback} = await getBiDiConnection(
+ connectionTransport,
+ url,
+ options
+ );
+ const BiDi = await import(/* webpackIgnore: true */ './bidi.js');
+ const bidiBrowser = await BiDi.BidiBrowser.create({
+ connection: bidiConnection,
+ closeCallback,
+ process: undefined,
+ defaultViewport: defaultViewport,
+ ignoreHTTPSErrors: ignoreHTTPSErrors,
+ });
+ return bidiBrowser;
+}
+
+/**
+ * Returns a BiDiConnection established to the endpoint specified by the options and a
+ * callback closing the browser. Callback depends on whether the connection is pure BiDi
+ * or BiDi over CDP.
+ * The method tries to connect to the browser using pure BiDi protocol, and falls back
+ * to BiDi over CDP.
+ */
+async function getBiDiConnection(
+ connectionTransport: ConnectionTransport,
+ url: string,
+ options: BrowserConnectOptions
+): Promise<{
+ bidiConnection: BidiConnection;
+ closeCallback: BrowserCloseCallback;
+}> {
+ const BiDi = await import(/* webpackIgnore: true */ './bidi.js');
+ const {ignoreHTTPSErrors = false, slowMo = 0, protocolTimeout} = options;
+
+ // Try pure BiDi first.
+ const pureBidiConnection = new BiDi.BidiConnection(
+ url,
+ connectionTransport,
+ slowMo,
+ protocolTimeout
+ );
+ try {
+ const result = await pureBidiConnection.send('session.status', {});
+ if ('type' in result && result.type === 'success') {
+ // The `browserWSEndpoint` points to an endpoint supporting pure WebDriver BiDi.
+ return {
+ bidiConnection: pureBidiConnection,
+ closeCallback: async () => {
+ await pureBidiConnection.send('browser.close', {}).catch(debugError);
+ },
+ };
+ }
+ } catch (e) {
+ if (!(e instanceof ProtocolError)) {
+ // Unexpected exception not related to BiDi / CDP. Rethrow.
+ throw e;
+ }
+ }
+ // Unbind the connection to avoid memory leaks.
+ pureBidiConnection.unbind();
+
+ // Fall back to CDP over BiDi reusing the WS connection.
+ const cdpConnection = new Connection(
+ url,
+ connectionTransport,
+ slowMo,
+ protocolTimeout
+ );
+
+ const version = await cdpConnection.send('Browser.getVersion');
+ if (version.product.toLowerCase().includes('firefox')) {
+ throw new UnsupportedOperation(
+ 'Firefox is not supported in BiDi over CDP mode.'
+ );
+ }
+
+ // TODO: use other options too.
+ const bidiOverCdpConnection = await BiDi.connectBidiOverCdp(cdpConnection, {
+ acceptInsecureCerts: ignoreHTTPSErrors,
+ });
+ return {
+ bidiConnection: bidiOverCdpConnection,
+ closeCallback: async () => {
+ // In case of BiDi over CDP, we need to close browser via CDP.
+ await cdpConnection.send('Browser.close').catch(debugError);
+ },
+ };
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/BrowserContext.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/BrowserContext.ts
new file mode 100644
index 0000000000..feb5e9951d
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/BrowserContext.ts
@@ -0,0 +1,145 @@
+/**
+ * @license
+ * Copyright 2022 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
+
+import type {WaitForTargetOptions} from '../api/Browser.js';
+import {BrowserContext} from '../api/BrowserContext.js';
+import type {Page} from '../api/Page.js';
+import type {Target} from '../api/Target.js';
+import {UnsupportedOperation} from '../common/Errors.js';
+import {debugError} from '../common/util.js';
+import type {Viewport} from '../common/Viewport.js';
+
+import type {BidiBrowser} from './Browser.js';
+import type {BidiConnection} from './Connection.js';
+import {UserContext} from './core/UserContext.js';
+import type {BidiPage} from './Page.js';
+
+/**
+ * @internal
+ */
+export interface BidiBrowserContextOptions {
+ defaultViewport: Viewport | null;
+}
+
+/**
+ * @internal
+ */
+export class BidiBrowserContext extends BrowserContext {
+ #browser: BidiBrowser;
+ #connection: BidiConnection;
+ #defaultViewport: Viewport | null;
+ #userContext: UserContext;
+
+ constructor(
+ browser: BidiBrowser,
+ userContext: UserContext,
+ options: BidiBrowserContextOptions
+ ) {
+ super();
+ this.#browser = browser;
+ this.#userContext = userContext;
+ this.#connection = this.#browser.connection;
+ this.#defaultViewport = options.defaultViewport;
+ }
+
+ override targets(): Target[] {
+ return this.#browser.targets().filter(target => {
+ return target.browserContext() === this;
+ });
+ }
+
+ override waitForTarget(
+ predicate: (x: Target) => boolean | Promise<boolean>,
+ options: WaitForTargetOptions = {}
+ ): Promise<Target> {
+ return this.#browser.waitForTarget(target => {
+ return target.browserContext() === this && predicate(target);
+ }, options);
+ }
+
+ get connection(): BidiConnection {
+ return this.#connection;
+ }
+
+ override async newPage(): Promise<Page> {
+ const {result} = await this.#connection.send('browsingContext.create', {
+ type: Bidi.BrowsingContext.CreateType.Tab,
+ });
+ const target = this.#browser._getTargetById(result.context);
+
+ // TODO: once BiDi has some concept matching BrowserContext, the newly
+ // created contexts should get automatically assigned to the right
+ // BrowserContext. For now, we assume that only explicitly created pages go
+ // to the current BrowserContext. Otherwise, the contexts get assigned to
+ // the default BrowserContext by the Browser.
+ target._setBrowserContext(this);
+
+ const page = await target.page();
+ if (!page) {
+ throw new Error('Page is not found');
+ }
+ if (this.#defaultViewport) {
+ try {
+ await page.setViewport(this.#defaultViewport);
+ } catch {
+ // No support for setViewport in Firefox.
+ }
+ }
+
+ return page;
+ }
+
+ override async close(): Promise<void> {
+ if (!this.isIncognito()) {
+ throw new Error('Default context cannot be closed!');
+ }
+
+ // TODO: Remove once we have adopted the new browsing contexts.
+ for (const target of this.targets()) {
+ const page = await target?.page();
+ try {
+ await page?.close();
+ } catch (error) {
+ debugError(error);
+ }
+ }
+
+ try {
+ await this.#userContext.remove();
+ } catch (error) {
+ debugError(error);
+ }
+ }
+
+ override browser(): BidiBrowser {
+ return this.#browser;
+ }
+
+ override async pages(): Promise<BidiPage[]> {
+ const results = await Promise.all(
+ [...this.targets()].map(t => {
+ return t.page();
+ })
+ );
+ return results.filter((p): p is BidiPage => {
+ return p !== null;
+ });
+ }
+
+ override isIncognito(): boolean {
+ return this.#userContext.id !== UserContext.DEFAULT;
+ }
+
+ override overridePermissions(): never {
+ throw new UnsupportedOperation();
+ }
+
+ override clearPermissionOverrides(): never {
+ throw new UnsupportedOperation();
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/BrowsingContext.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/BrowsingContext.ts
new file mode 100644
index 0000000000..0804628c06
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/BrowsingContext.ts
@@ -0,0 +1,187 @@
+import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
+import type ProtocolMapping from 'devtools-protocol/types/protocol-mapping.js';
+
+import {CDPSession} from '../api/CDPSession.js';
+import type {Connection as CdpConnection} from '../cdp/Connection.js';
+import {TargetCloseError, UnsupportedOperation} from '../common/Errors.js';
+import type {EventType} from '../common/EventEmitter.js';
+import {debugError} from '../common/util.js';
+import {Deferred} from '../util/Deferred.js';
+
+import type {BidiConnection} from './Connection.js';
+import {BidiRealm} from './Realm.js';
+
+/**
+ * @internal
+ */
+export const cdpSessions = new Map<string, CdpSessionWrapper>();
+
+/**
+ * @internal
+ */
+export class CdpSessionWrapper extends CDPSession {
+ #context: BrowsingContext;
+ #sessionId = Deferred.create<string>();
+ #detached = false;
+
+ constructor(context: BrowsingContext, sessionId?: string) {
+ super();
+ this.#context = context;
+ if (!this.#context.supportsCdp()) {
+ return;
+ }
+ if (sessionId) {
+ this.#sessionId.resolve(sessionId);
+ cdpSessions.set(sessionId, this);
+ } else {
+ context.connection
+ .send('cdp.getSession', {
+ context: context.id,
+ })
+ .then(session => {
+ this.#sessionId.resolve(session.result.session!);
+ cdpSessions.set(session.result.session!, this);
+ })
+ .catch(err => {
+ this.#sessionId.reject(err);
+ });
+ }
+ }
+
+ override connection(): CdpConnection | undefined {
+ return undefined;
+ }
+
+ override async send<T extends keyof ProtocolMapping.Commands>(
+ method: T,
+ ...paramArgs: ProtocolMapping.Commands[T]['paramsType']
+ ): Promise<ProtocolMapping.Commands[T]['returnType']> {
+ if (!this.#context.supportsCdp()) {
+ throw new UnsupportedOperation(
+ 'CDP support is required for this feature. The current browser does not support CDP.'
+ );
+ }
+ if (this.#detached) {
+ throw new TargetCloseError(
+ `Protocol error (${method}): Session closed. Most likely the page has been closed.`
+ );
+ }
+ const session = await this.#sessionId.valueOrThrow();
+ const {result} = await this.#context.connection.send('cdp.sendCommand', {
+ method: method,
+ params: paramArgs[0],
+ session,
+ });
+ return result.result;
+ }
+
+ override async detach(): Promise<void> {
+ cdpSessions.delete(this.id());
+ if (!this.#detached && this.#context.supportsCdp()) {
+ await this.#context.cdpSession.send('Target.detachFromTarget', {
+ sessionId: this.id(),
+ });
+ }
+ this.#detached = true;
+ }
+
+ override id(): string {
+ const val = this.#sessionId.value();
+ return val instanceof Error || val === undefined ? '' : val;
+ }
+}
+
+/**
+ * Internal events that the BrowsingContext class emits.
+ *
+ * @internal
+ */
+// eslint-disable-next-line @typescript-eslint/no-namespace
+export namespace BrowsingContextEvent {
+ /**
+ * Emitted on the top-level context, when a descendant context is created.
+ */
+ export const Created = Symbol('BrowsingContext.created');
+ /**
+ * Emitted on the top-level context, when a descendant context or the
+ * top-level context itself is destroyed.
+ */
+ export const Destroyed = Symbol('BrowsingContext.destroyed');
+}
+
+/**
+ * @internal
+ */
+export interface BrowsingContextEvents extends Record<EventType, unknown> {
+ [BrowsingContextEvent.Created]: BrowsingContext;
+ [BrowsingContextEvent.Destroyed]: BrowsingContext;
+}
+
+/**
+ * @internal
+ */
+export class BrowsingContext extends BidiRealm {
+ #id: string;
+ #url: string;
+ #cdpSession: CDPSession;
+ #parent?: string | null;
+ #browserName = '';
+
+ constructor(
+ connection: BidiConnection,
+ info: Bidi.BrowsingContext.Info,
+ browserName: string
+ ) {
+ super(connection);
+ this.#id = info.context;
+ this.#url = info.url;
+ this.#parent = info.parent;
+ this.#browserName = browserName;
+ this.#cdpSession = new CdpSessionWrapper(this, undefined);
+
+ this.on('browsingContext.domContentLoaded', this.#updateUrl.bind(this));
+ this.on('browsingContext.fragmentNavigated', this.#updateUrl.bind(this));
+ this.on('browsingContext.load', this.#updateUrl.bind(this));
+ }
+
+ supportsCdp(): boolean {
+ return !this.#browserName.toLowerCase().includes('firefox');
+ }
+
+ #updateUrl(info: Bidi.BrowsingContext.NavigationInfo) {
+ this.#url = info.url;
+ }
+
+ createRealmForSandbox(): BidiRealm {
+ return new BidiRealm(this.connection);
+ }
+
+ get url(): string {
+ return this.#url;
+ }
+
+ get id(): string {
+ return this.#id;
+ }
+
+ get parent(): string | undefined | null {
+ return this.#parent;
+ }
+
+ get cdpSession(): CDPSession {
+ return this.#cdpSession;
+ }
+
+ async sendCdpCommand<T extends keyof ProtocolMapping.Commands>(
+ method: T,
+ ...paramArgs: ProtocolMapping.Commands[T]['paramsType']
+ ): Promise<ProtocolMapping.Commands[T]['returnType']> {
+ return await this.#cdpSession.send(method, ...paramArgs);
+ }
+
+ dispose(): void {
+ this.removeAllListeners();
+ this.connection.unregisterBrowsingContexts(this.#id);
+ void this.#cdpSession.detach().catch(debugError);
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Connection.test.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Connection.test.ts
new file mode 100644
index 0000000000..9f37e38661
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Connection.test.ts
@@ -0,0 +1,50 @@
+/**
+ * @license
+ * Copyright 2022 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {describe, it} from 'node:test';
+
+import expect from 'expect';
+
+import type {ConnectionTransport} from '../common/ConnectionTransport.js';
+
+import {BidiConnection} from './Connection.js';
+
+describe('WebDriver BiDi Connection', () => {
+ class TestConnectionTransport implements ConnectionTransport {
+ sent: string[] = [];
+ closed = false;
+
+ send(message: string) {
+ this.sent.push(message);
+ }
+
+ close(): void {
+ this.closed = true;
+ }
+ }
+
+ it('should work', async () => {
+ const transport = new TestConnectionTransport();
+ const connection = new BidiConnection('ws://127.0.0.1', transport);
+ const responsePromise = connection.send('session.new', {
+ capabilities: {},
+ });
+ expect(transport.sent).toEqual([
+ `{"id":1,"method":"session.new","params":{"capabilities":{}}}`,
+ ]);
+ const id = JSON.parse(transport.sent[0]!).id;
+ const rawResponse = {
+ id,
+ type: 'success',
+ result: {ready: false, message: 'already connected'},
+ };
+ (transport as ConnectionTransport).onmessage?.(JSON.stringify(rawResponse));
+ const response = await responsePromise;
+ expect(response).toEqual(rawResponse);
+ connection.dispose();
+ expect(transport.closed).toBeTruthy();
+ });
+});
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Connection.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Connection.ts
new file mode 100644
index 0000000000..bce952ba39
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Connection.ts
@@ -0,0 +1,256 @@
+/**
+ * @license
+ * Copyright 2017 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
+
+import {CallbackRegistry} from '../common/CallbackRegistry.js';
+import type {ConnectionTransport} from '../common/ConnectionTransport.js';
+import {debug} from '../common/Debug.js';
+import type {EventsWithWildcard} from '../common/EventEmitter.js';
+import {EventEmitter} from '../common/EventEmitter.js';
+import {debugError} from '../common/util.js';
+import {assert} from '../util/assert.js';
+
+import {cdpSessions, type BrowsingContext} from './BrowsingContext.js';
+import type {
+ BidiEvents,
+ Commands as BidiCommands,
+ Connection,
+} from './core/Connection.js';
+
+const debugProtocolSend = debug('puppeteer:webDriverBiDi:SEND â–º');
+const debugProtocolReceive = debug('puppeteer:webDriverBiDi:RECV â—€');
+
+/**
+ * @internal
+ */
+export interface Commands extends BidiCommands {
+ 'cdp.sendCommand': {
+ params: Bidi.Cdp.SendCommandParameters;
+ returnType: Bidi.Cdp.SendCommandResult;
+ };
+ 'cdp.getSession': {
+ params: Bidi.Cdp.GetSessionParameters;
+ returnType: Bidi.Cdp.GetSessionResult;
+ };
+}
+
+/**
+ * @internal
+ */
+export class BidiConnection
+ extends EventEmitter<BidiEvents>
+ implements Connection
+{
+ #url: string;
+ #transport: ConnectionTransport;
+ #delay: number;
+ #timeout? = 0;
+ #closed = false;
+ #callbacks = new CallbackRegistry();
+ #browsingContexts = new Map<string, BrowsingContext>();
+ #emitters: Array<EventEmitter<any>> = [];
+
+ constructor(
+ url: string,
+ transport: ConnectionTransport,
+ delay = 0,
+ timeout?: number
+ ) {
+ super();
+ this.#url = url;
+ this.#delay = delay;
+ this.#timeout = timeout ?? 180_000;
+
+ this.#transport = transport;
+ this.#transport.onmessage = this.onMessage.bind(this);
+ this.#transport.onclose = this.unbind.bind(this);
+ }
+
+ get closed(): boolean {
+ return this.#closed;
+ }
+
+ get url(): string {
+ return this.#url;
+ }
+
+ pipeTo<Events extends BidiEvents>(emitter: EventEmitter<Events>): void {
+ this.#emitters.push(emitter);
+ }
+
+ override emit<Key extends keyof EventsWithWildcard<BidiEvents>>(
+ type: Key,
+ event: EventsWithWildcard<BidiEvents>[Key]
+ ): boolean {
+ for (const emitter of this.#emitters) {
+ emitter.emit(type, event);
+ }
+ return super.emit(type, event);
+ }
+
+ send<T extends keyof Commands>(
+ method: T,
+ params: Commands[T]['params']
+ ): Promise<{result: Commands[T]['returnType']}> {
+ assert(!this.#closed, 'Protocol error: Connection closed.');
+
+ return this.#callbacks.create(method, this.#timeout, id => {
+ const stringifiedMessage = JSON.stringify({
+ id,
+ method,
+ params,
+ } as Bidi.Command);
+ debugProtocolSend(stringifiedMessage);
+ this.#transport.send(stringifiedMessage);
+ }) as Promise<{result: Commands[T]['returnType']}>;
+ }
+
+ /**
+ * @internal
+ */
+ protected async onMessage(message: string): Promise<void> {
+ if (this.#delay) {
+ await new Promise(f => {
+ return setTimeout(f, this.#delay);
+ });
+ }
+ debugProtocolReceive(message);
+ const object: Bidi.ChromiumBidi.Message = JSON.parse(message);
+ if ('type' in object) {
+ switch (object.type) {
+ case 'success':
+ this.#callbacks.resolve(object.id, object);
+ return;
+ case 'error':
+ if (object.id === null) {
+ break;
+ }
+ this.#callbacks.reject(
+ object.id,
+ createProtocolError(object),
+ object.message
+ );
+ return;
+ case 'event':
+ if (isCdpEvent(object)) {
+ cdpSessions
+ .get(object.params.session)
+ ?.emit(object.params.event, object.params.params);
+ return;
+ }
+ this.#maybeEmitOnContext(object);
+ // SAFETY: We know the method and parameter still match here.
+ this.emit(
+ object.method,
+ object.params as BidiEvents[keyof BidiEvents]
+ );
+ return;
+ }
+ }
+ // Even if the response in not in BiDi protocol format but `id` is provided, reject
+ // the callback. This can happen if the endpoint supports CDP instead of BiDi.
+ if ('id' in object) {
+ this.#callbacks.reject(
+ (object as {id: number}).id,
+ `Protocol Error. Message is not in BiDi protocol format: '${message}'`,
+ object.message
+ );
+ }
+ debugError(object);
+ }
+
+ #maybeEmitOnContext(event: Bidi.ChromiumBidi.Event) {
+ let context: BrowsingContext | undefined;
+ // Context specific events
+ if ('context' in event.params && event.params.context !== null) {
+ context = this.#browsingContexts.get(event.params.context);
+ // `log.entryAdded` specific context
+ } else if (
+ 'source' in event.params &&
+ event.params.source.context !== undefined
+ ) {
+ context = this.#browsingContexts.get(event.params.source.context);
+ }
+ context?.emit(event.method, event.params);
+ }
+
+ registerBrowsingContexts(context: BrowsingContext): void {
+ this.#browsingContexts.set(context.id, context);
+ }
+
+ getBrowsingContext(contextId: string): BrowsingContext {
+ const currentContext = this.#browsingContexts.get(contextId);
+ if (!currentContext) {
+ throw new Error(`BrowsingContext ${contextId} does not exist.`);
+ }
+ return currentContext;
+ }
+
+ getTopLevelContext(contextId: string): BrowsingContext {
+ let currentContext = this.#browsingContexts.get(contextId);
+ if (!currentContext) {
+ throw new Error(`BrowsingContext ${contextId} does not exist.`);
+ }
+ while (currentContext.parent) {
+ contextId = currentContext.parent;
+ currentContext = this.#browsingContexts.get(contextId);
+ if (!currentContext) {
+ throw new Error(`BrowsingContext ${contextId} does not exist.`);
+ }
+ }
+ return currentContext;
+ }
+
+ unregisterBrowsingContexts(id: string): void {
+ this.#browsingContexts.delete(id);
+ }
+
+ /**
+ * Unbinds the connection, but keeps the transport open. Useful when the transport will
+ * be reused by other connection e.g. with different protocol.
+ * @internal
+ */
+ unbind(): void {
+ if (this.#closed) {
+ return;
+ }
+ this.#closed = true;
+ // Both may still be invoked and produce errors
+ this.#transport.onmessage = () => {};
+ this.#transport.onclose = () => {};
+
+ this.#browsingContexts.clear();
+ this.#callbacks.clear();
+ }
+
+ /**
+ * Unbinds the connection and closes the transport.
+ */
+ dispose(): void {
+ this.unbind();
+ this.#transport.close();
+ }
+
+ getPendingProtocolErrors(): Error[] {
+ return this.#callbacks.getPendingProtocolErrors();
+ }
+}
+
+/**
+ * @internal
+ */
+function createProtocolError(object: Bidi.ErrorResponse): string {
+ let message = `${object.error} ${object.message}`;
+ if (object.stacktrace) {
+ message += ` ${object.stacktrace}`;
+ }
+ return message;
+}
+
+function isCdpEvent(event: Bidi.ChromiumBidi.Event): event is Bidi.Cdp.Event {
+ return event.method.startsWith('cdp.');
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Deserializer.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Deserializer.ts
new file mode 100644
index 0000000000..14b87d403b
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Deserializer.ts
@@ -0,0 +1,96 @@
+/**
+ * @license
+ * Copyright 2023 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
+
+import {debugError} from '../common/util.js';
+
+/**
+ * @internal
+ */
+export class BidiDeserializer {
+ static deserializeNumber(value: Bidi.Script.SpecialNumber | number): number {
+ switch (value) {
+ case '-0':
+ return -0;
+ case 'NaN':
+ return NaN;
+ case 'Infinity':
+ return Infinity;
+ case '-Infinity':
+ return -Infinity;
+ default:
+ return value;
+ }
+ }
+
+ static deserializeLocalValue(result: Bidi.Script.RemoteValue): unknown {
+ switch (result.type) {
+ case 'array':
+ return result.value?.map(value => {
+ return BidiDeserializer.deserializeLocalValue(value);
+ });
+ case 'set':
+ return result.value?.reduce((acc: Set<unknown>, value) => {
+ return acc.add(BidiDeserializer.deserializeLocalValue(value));
+ }, new Set());
+ case 'object':
+ return result.value?.reduce((acc: Record<any, unknown>, tuple) => {
+ const {key, value} = BidiDeserializer.deserializeTuple(tuple);
+ acc[key as any] = value;
+ return acc;
+ }, {});
+ case 'map':
+ return result.value?.reduce((acc: Map<unknown, unknown>, tuple) => {
+ const {key, value} = BidiDeserializer.deserializeTuple(tuple);
+ return acc.set(key, value);
+ }, new Map());
+ case 'promise':
+ return {};
+ case 'regexp':
+ return new RegExp(result.value.pattern, result.value.flags);
+ case 'date':
+ return new Date(result.value);
+ case 'undefined':
+ return undefined;
+ case 'null':
+ return null;
+ case 'number':
+ return BidiDeserializer.deserializeNumber(result.value);
+ case 'bigint':
+ return BigInt(result.value);
+ case 'boolean':
+ return Boolean(result.value);
+ case 'string':
+ return result.value;
+ }
+
+ debugError(`Deserialization of type ${result.type} not supported.`);
+ return undefined;
+ }
+
+ static deserializeTuple([serializedKey, serializedValue]: [
+ Bidi.Script.RemoteValue | string,
+ Bidi.Script.RemoteValue,
+ ]): {key: unknown; value: unknown} {
+ const key =
+ typeof serializedKey === 'string'
+ ? serializedKey
+ : BidiDeserializer.deserializeLocalValue(serializedKey);
+ const value = BidiDeserializer.deserializeLocalValue(serializedValue);
+
+ return {key, value};
+ }
+
+ static deserialize(result: Bidi.Script.RemoteValue): any {
+ if (!result) {
+ debugError('Service did not produce a result.');
+ return undefined;
+ }
+
+ return BidiDeserializer.deserializeLocalValue(result);
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Dialog.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Dialog.ts
new file mode 100644
index 0000000000..ce22223461
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Dialog.ts
@@ -0,0 +1,45 @@
+/**
+ * @license
+ * Copyright 2017 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
+
+import {Dialog} from '../api/Dialog.js';
+
+import type {BrowsingContext} from './BrowsingContext.js';
+
+/**
+ * @internal
+ */
+export class BidiDialog extends Dialog {
+ #context: BrowsingContext;
+
+ /**
+ * @internal
+ */
+ constructor(
+ context: BrowsingContext,
+ type: Bidi.BrowsingContext.UserPromptOpenedParameters['type'],
+ message: string,
+ defaultValue?: string
+ ) {
+ super(type, message, defaultValue);
+ this.#context = context;
+ }
+
+ /**
+ * @internal
+ */
+ override async handle(options: {
+ accept: boolean;
+ text?: string;
+ }): Promise<void> {
+ await this.#context.connection.send('browsingContext.handleUserPrompt', {
+ context: this.#context.id,
+ accept: options.accept,
+ userText: options.text,
+ });
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/ElementHandle.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/ElementHandle.ts
new file mode 100644
index 0000000000..fd886e8c26
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/ElementHandle.ts
@@ -0,0 +1,87 @@
+/**
+ * @license
+ * Copyright 2023 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
+
+import {type AutofillData, ElementHandle} from '../api/ElementHandle.js';
+import {UnsupportedOperation} from '../common/Errors.js';
+import {throwIfDisposed} from '../util/decorators.js';
+
+import type {BidiFrame} from './Frame.js';
+import {BidiJSHandle} from './JSHandle.js';
+import type {BidiRealm} from './Realm.js';
+import type {Sandbox} from './Sandbox.js';
+
+/**
+ * @internal
+ */
+export class BidiElementHandle<
+ ElementType extends Node = Element,
+> extends ElementHandle<ElementType> {
+ declare handle: BidiJSHandle<ElementType>;
+
+ constructor(sandbox: Sandbox, remoteValue: Bidi.Script.RemoteValue) {
+ super(new BidiJSHandle(sandbox, remoteValue));
+ }
+
+ override get realm(): Sandbox {
+ return this.handle.realm;
+ }
+
+ override get frame(): BidiFrame {
+ return this.realm.environment;
+ }
+
+ context(): BidiRealm {
+ return this.handle.context();
+ }
+
+ get isPrimitiveValue(): boolean {
+ return this.handle.isPrimitiveValue;
+ }
+
+ remoteValue(): Bidi.Script.RemoteValue {
+ return this.handle.remoteValue();
+ }
+
+ @throwIfDisposed()
+ override async autofill(data: AutofillData): Promise<void> {
+ const client = this.frame.client;
+ const nodeInfo = await client.send('DOM.describeNode', {
+ objectId: this.handle.id,
+ });
+ const fieldId = nodeInfo.node.backendNodeId;
+ const frameId = this.frame._id;
+ await client.send('Autofill.trigger', {
+ fieldId,
+ frameId,
+ card: data.creditCard,
+ });
+ }
+
+ override async contentFrame(
+ this: BidiElementHandle<HTMLIFrameElement>
+ ): Promise<BidiFrame>;
+ @throwIfDisposed()
+ @ElementHandle.bindIsolatedHandle
+ override async contentFrame(): Promise<BidiFrame | null> {
+ using handle = (await this.evaluateHandle(element => {
+ if (element instanceof HTMLIFrameElement) {
+ return element.contentWindow;
+ }
+ return;
+ })) as BidiJSHandle;
+ const value = handle.remoteValue();
+ if (value.type === 'window') {
+ return this.frame.page().frame(value.value.context);
+ }
+ return null;
+ }
+
+ override uploadFile(this: ElementHandle<HTMLInputElement>): never {
+ throw new UnsupportedOperation();
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/EmulationManager.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/EmulationManager.ts
new file mode 100644
index 0000000000..de95695785
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/EmulationManager.ts
@@ -0,0 +1,35 @@
+/**
+ * @license
+ * Copyright 2023 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import type {Viewport} from '../common/Viewport.js';
+
+import type {BrowsingContext} from './BrowsingContext.js';
+
+/**
+ * @internal
+ */
+export class EmulationManager {
+ #browsingContext: BrowsingContext;
+
+ constructor(browsingContext: BrowsingContext) {
+ this.#browsingContext = browsingContext;
+ }
+
+ async emulateViewport(viewport: Viewport): Promise<void> {
+ await this.#browsingContext.connection.send('browsingContext.setViewport', {
+ context: this.#browsingContext.id,
+ viewport:
+ viewport.width && viewport.height
+ ? {
+ width: viewport.width,
+ height: viewport.height,
+ }
+ : null,
+ devicePixelRatio: viewport.deviceScaleFactor
+ ? viewport.deviceScaleFactor
+ : null,
+ });
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/ExposedFunction.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/ExposedFunction.ts
new file mode 100644
index 0000000000..62c6b5e37e
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/ExposedFunction.ts
@@ -0,0 +1,295 @@
+/**
+ * @license
+ * Copyright 2023 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
+
+import type {Awaitable, FlattenHandle} from '../common/types.js';
+import {debugError} from '../common/util.js';
+import {assert} from '../util/assert.js';
+import {Deferred} from '../util/Deferred.js';
+import {interpolateFunction, stringifyFunction} from '../util/Function.js';
+
+import type {BidiConnection} from './Connection.js';
+import {BidiDeserializer} from './Deserializer.js';
+import type {BidiFrame} from './Frame.js';
+import {BidiSerializer} from './Serializer.js';
+
+type SendArgsChannel<Args> = (value: [id: number, args: Args]) => void;
+type SendResolveChannel<Ret> = (
+ value: [id: number, resolve: (ret: FlattenHandle<Awaited<Ret>>) => void]
+) => void;
+type SendRejectChannel = (
+ value: [id: number, reject: (error: unknown) => void]
+) => void;
+
+interface RemotePromiseCallbacks {
+ resolve: Deferred<Bidi.Script.RemoteValue>;
+ reject: Deferred<Bidi.Script.RemoteValue>;
+}
+
+/**
+ * @internal
+ */
+export class ExposeableFunction<Args extends unknown[], Ret> {
+ readonly #frame;
+
+ readonly name;
+ readonly #apply;
+
+ readonly #channels;
+ readonly #callerInfos = new Map<
+ string,
+ Map<number, RemotePromiseCallbacks>
+ >();
+
+ #preloadScriptId?: Bidi.Script.PreloadScript;
+
+ constructor(
+ frame: BidiFrame,
+ name: string,
+ apply: (...args: Args) => Awaitable<Ret>
+ ) {
+ this.#frame = frame;
+ this.name = name;
+ this.#apply = apply;
+
+ this.#channels = {
+ args: `__puppeteer__${this.#frame._id}_page_exposeFunction_${this.name}_args`,
+ resolve: `__puppeteer__${this.#frame._id}_page_exposeFunction_${this.name}_resolve`,
+ reject: `__puppeteer__${this.#frame._id}_page_exposeFunction_${this.name}_reject`,
+ };
+ }
+
+ async expose(): Promise<void> {
+ const connection = this.#connection;
+ const channelArguments = this.#channelArguments;
+
+ // TODO(jrandolf): Implement cleanup with removePreloadScript.
+ connection.on(
+ Bidi.ChromiumBidi.Script.EventNames.Message,
+ this.#handleArgumentsMessage
+ );
+ connection.on(
+ Bidi.ChromiumBidi.Script.EventNames.Message,
+ this.#handleResolveMessage
+ );
+ connection.on(
+ Bidi.ChromiumBidi.Script.EventNames.Message,
+ this.#handleRejectMessage
+ );
+
+ const functionDeclaration = stringifyFunction(
+ interpolateFunction(
+ (
+ sendArgs: SendArgsChannel<Args>,
+ sendResolve: SendResolveChannel<Ret>,
+ sendReject: SendRejectChannel
+ ) => {
+ let id = 0;
+ Object.assign(globalThis, {
+ [PLACEHOLDER('name') as string]: function (...args: Args) {
+ return new Promise<FlattenHandle<Awaited<Ret>>>(
+ (resolve, reject) => {
+ sendArgs([id, args]);
+ sendResolve([id, resolve]);
+ sendReject([id, reject]);
+ ++id;
+ }
+ );
+ },
+ });
+ },
+ {name: JSON.stringify(this.name)}
+ )
+ );
+
+ const {result} = await connection.send('script.addPreloadScript', {
+ functionDeclaration,
+ arguments: channelArguments,
+ contexts: [this.#frame.page().mainFrame()._id],
+ });
+ this.#preloadScriptId = result.script;
+
+ await Promise.all(
+ this.#frame
+ .page()
+ .frames()
+ .map(async frame => {
+ return await connection.send('script.callFunction', {
+ functionDeclaration,
+ arguments: channelArguments,
+ awaitPromise: false,
+ target: frame.mainRealm().realm.target,
+ });
+ })
+ );
+ }
+
+ #handleArgumentsMessage = async (params: Bidi.Script.MessageParameters) => {
+ if (params.channel !== this.#channels.args) {
+ return;
+ }
+ const connection = this.#connection;
+ const {callbacks, remoteValue} = this.#getCallbacksAndRemoteValue(params);
+ const args = remoteValue.value?.[1];
+ assert(args);
+ try {
+ const result = await this.#apply(...BidiDeserializer.deserialize(args));
+ await connection.send('script.callFunction', {
+ functionDeclaration: stringifyFunction(([_, resolve]: any, result) => {
+ resolve(result);
+ }),
+ arguments: [
+ (await callbacks.resolve.valueOrThrow()) as Bidi.Script.LocalValue,
+ BidiSerializer.serializeRemoteValue(result),
+ ],
+ awaitPromise: false,
+ target: {
+ realm: params.source.realm,
+ },
+ });
+ } catch (error) {
+ try {
+ if (error instanceof Error) {
+ await connection.send('script.callFunction', {
+ functionDeclaration: stringifyFunction(
+ (
+ [_, reject]: [unknown, (error: Error) => void],
+ name: string,
+ message: string,
+ stack?: string
+ ) => {
+ const error = new Error(message);
+ error.name = name;
+ if (stack) {
+ error.stack = stack;
+ }
+ reject(error);
+ }
+ ),
+ arguments: [
+ (await callbacks.reject.valueOrThrow()) as Bidi.Script.LocalValue,
+ BidiSerializer.serializeRemoteValue(error.name),
+ BidiSerializer.serializeRemoteValue(error.message),
+ BidiSerializer.serializeRemoteValue(error.stack),
+ ],
+ awaitPromise: false,
+ target: {
+ realm: params.source.realm,
+ },
+ });
+ } else {
+ await connection.send('script.callFunction', {
+ functionDeclaration: stringifyFunction(
+ (
+ [_, reject]: [unknown, (error: unknown) => void],
+ error: unknown
+ ) => {
+ reject(error);
+ }
+ ),
+ arguments: [
+ (await callbacks.reject.valueOrThrow()) as Bidi.Script.LocalValue,
+ BidiSerializer.serializeRemoteValue(error),
+ ],
+ awaitPromise: false,
+ target: {
+ realm: params.source.realm,
+ },
+ });
+ }
+ } catch (error) {
+ debugError(error);
+ }
+ }
+ };
+
+ get #connection(): BidiConnection {
+ return this.#frame.context().connection;
+ }
+
+ get #channelArguments() {
+ return [
+ {
+ type: 'channel' as const,
+ value: {
+ channel: this.#channels.args,
+ ownership: Bidi.Script.ResultOwnership.Root,
+ },
+ },
+ {
+ type: 'channel' as const,
+ value: {
+ channel: this.#channels.resolve,
+ ownership: Bidi.Script.ResultOwnership.Root,
+ },
+ },
+ {
+ type: 'channel' as const,
+ value: {
+ channel: this.#channels.reject,
+ ownership: Bidi.Script.ResultOwnership.Root,
+ },
+ },
+ ];
+ }
+
+ #handleResolveMessage = (params: Bidi.Script.MessageParameters) => {
+ if (params.channel !== this.#channels.resolve) {
+ return;
+ }
+ const {callbacks, remoteValue} = this.#getCallbacksAndRemoteValue(params);
+ callbacks.resolve.resolve(remoteValue);
+ };
+
+ #handleRejectMessage = (params: Bidi.Script.MessageParameters) => {
+ if (params.channel !== this.#channels.reject) {
+ return;
+ }
+ const {callbacks, remoteValue} = this.#getCallbacksAndRemoteValue(params);
+ callbacks.reject.resolve(remoteValue);
+ };
+
+ #getCallbacksAndRemoteValue(params: Bidi.Script.MessageParameters) {
+ const {data, source} = params;
+ assert(data.type === 'array');
+ assert(data.value);
+
+ const callerIdRemote = data.value[0];
+ assert(callerIdRemote);
+ assert(callerIdRemote.type === 'number');
+ assert(typeof callerIdRemote.value === 'number');
+
+ let bindingMap = this.#callerInfos.get(source.realm);
+ if (!bindingMap) {
+ bindingMap = new Map();
+ this.#callerInfos.set(source.realm, bindingMap);
+ }
+
+ const callerId = callerIdRemote.value;
+ let callbacks = bindingMap.get(callerId);
+ if (!callbacks) {
+ callbacks = {
+ resolve: new Deferred(),
+ reject: new Deferred(),
+ };
+ bindingMap.set(callerId, callbacks);
+ }
+ return {callbacks, remoteValue: data};
+ }
+
+ [Symbol.dispose](): void {
+ void this[Symbol.asyncDispose]().catch(debugError);
+ }
+
+ async [Symbol.asyncDispose](): Promise<void> {
+ if (this.#preloadScriptId) {
+ await this.#connection.send('script.removePreloadScript', {
+ script: this.#preloadScriptId,
+ });
+ }
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Frame.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Frame.ts
new file mode 100644
index 0000000000..1638c2cbdf
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Frame.ts
@@ -0,0 +1,313 @@
+/**
+ * @license
+ * Copyright 2023 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
+
+import {
+ first,
+ firstValueFrom,
+ forkJoin,
+ from,
+ map,
+ merge,
+ raceWith,
+ zip,
+} from '../../third_party/rxjs/rxjs.js';
+import type {CDPSession} from '../api/CDPSession.js';
+import type {ElementHandle} from '../api/ElementHandle.js';
+import {
+ Frame,
+ throwIfDetached,
+ type GoToOptions,
+ type WaitForOptions,
+} from '../api/Frame.js';
+import type {WaitForSelectorOptions} from '../api/Page.js';
+import {UnsupportedOperation} from '../common/Errors.js';
+import type {TimeoutSettings} from '../common/TimeoutSettings.js';
+import type {Awaitable, NodeFor} from '../common/types.js';
+import {
+ fromEmitterEvent,
+ NETWORK_IDLE_TIME,
+ timeout,
+ UTILITY_WORLD_NAME,
+} from '../common/util.js';
+import {Deferred} from '../util/Deferred.js';
+import {disposeSymbol} from '../util/disposable.js';
+
+import type {BrowsingContext} from './BrowsingContext.js';
+import {ExposeableFunction} from './ExposedFunction.js';
+import type {BidiHTTPResponse} from './HTTPResponse.js';
+import {
+ getBiDiLifecycleEvent,
+ getBiDiReadinessState,
+ rewriteNavigationError,
+} from './lifecycle.js';
+import type {BidiPage} from './Page.js';
+import {
+ MAIN_SANDBOX,
+ PUPPETEER_SANDBOX,
+ Sandbox,
+ type SandboxChart,
+} from './Sandbox.js';
+
+/**
+ * Puppeteer's Frame class could be viewed as a BiDi BrowsingContext implementation
+ * @internal
+ */
+export class BidiFrame extends Frame {
+ #page: BidiPage;
+ #context: BrowsingContext;
+ #timeoutSettings: TimeoutSettings;
+ #abortDeferred = Deferred.create<never>();
+ #disposed = false;
+ sandboxes: SandboxChart;
+ override _id: string;
+
+ constructor(
+ page: BidiPage,
+ context: BrowsingContext,
+ timeoutSettings: TimeoutSettings,
+ parentId?: string | null
+ ) {
+ super();
+ this.#page = page;
+ this.#context = context;
+ this.#timeoutSettings = timeoutSettings;
+ this._id = this.#context.id;
+ this._parentId = parentId ?? undefined;
+
+ this.sandboxes = {
+ [MAIN_SANDBOX]: new Sandbox(undefined, this, context, timeoutSettings),
+ [PUPPETEER_SANDBOX]: new Sandbox(
+ UTILITY_WORLD_NAME,
+ this,
+ context.createRealmForSandbox(),
+ timeoutSettings
+ ),
+ };
+ }
+
+ override get client(): CDPSession {
+ return this.context().cdpSession;
+ }
+
+ override mainRealm(): Sandbox {
+ return this.sandboxes[MAIN_SANDBOX];
+ }
+
+ override isolatedRealm(): Sandbox {
+ return this.sandboxes[PUPPETEER_SANDBOX];
+ }
+
+ override page(): BidiPage {
+ return this.#page;
+ }
+
+ override isOOPFrame(): never {
+ throw new UnsupportedOperation();
+ }
+
+ override url(): string {
+ return this.#context.url;
+ }
+
+ override parentFrame(): BidiFrame | null {
+ return this.#page.frame(this._parentId ?? '');
+ }
+
+ override childFrames(): BidiFrame[] {
+ return this.#page.childFrames(this.#context.id);
+ }
+
+ @throwIfDetached
+ override async goto(
+ url: string,
+ options: GoToOptions = {}
+ ): Promise<BidiHTTPResponse | null> {
+ const {
+ waitUntil = 'load',
+ timeout: ms = this.#timeoutSettings.navigationTimeout(),
+ } = options;
+
+ const [readiness, networkIdle] = getBiDiReadinessState(waitUntil);
+
+ const result$ = zip(
+ from(
+ this.#context.connection.send('browsingContext.navigate', {
+ context: this.#context.id,
+ url,
+ wait: readiness,
+ })
+ ),
+ ...(networkIdle !== null
+ ? [
+ this.#page.waitForNetworkIdle$({
+ timeout: ms,
+ concurrency: networkIdle === 'networkidle2' ? 2 : 0,
+ idleTime: NETWORK_IDLE_TIME,
+ }),
+ ]
+ : [])
+ ).pipe(
+ map(([{result}]) => {
+ return result;
+ }),
+ raceWith(timeout(ms), from(this.#abortDeferred.valueOrThrow())),
+ rewriteNavigationError(url, ms)
+ );
+
+ const result = await firstValueFrom(result$);
+ return this.#page.getNavigationResponse(result.navigation);
+ }
+
+ @throwIfDetached
+ override async setContent(
+ html: string,
+ options: WaitForOptions = {}
+ ): Promise<void> {
+ const {
+ waitUntil = 'load',
+ timeout: ms = this.#timeoutSettings.navigationTimeout(),
+ } = options;
+
+ const [waitEvent, networkIdle] = getBiDiLifecycleEvent(waitUntil);
+
+ const result$ = zip(
+ forkJoin([
+ fromEmitterEvent(this.#context, waitEvent).pipe(first()),
+ from(this.setFrameContent(html)),
+ ]).pipe(
+ map(() => {
+ return null;
+ })
+ ),
+ ...(networkIdle !== null
+ ? [
+ this.#page.waitForNetworkIdle$({
+ timeout: ms,
+ concurrency: networkIdle === 'networkidle2' ? 2 : 0,
+ idleTime: NETWORK_IDLE_TIME,
+ }),
+ ]
+ : [])
+ ).pipe(
+ raceWith(timeout(ms), from(this.#abortDeferred.valueOrThrow())),
+ rewriteNavigationError('setContent', ms)
+ );
+
+ await firstValueFrom(result$);
+ }
+
+ context(): BrowsingContext {
+ return this.#context;
+ }
+
+ @throwIfDetached
+ override async waitForNavigation(
+ options: WaitForOptions = {}
+ ): Promise<BidiHTTPResponse | null> {
+ const {
+ waitUntil = 'load',
+ timeout: ms = this.#timeoutSettings.navigationTimeout(),
+ } = options;
+
+ const [waitUntilEvent, networkIdle] = getBiDiLifecycleEvent(waitUntil);
+
+ const navigation$ = merge(
+ forkJoin([
+ fromEmitterEvent(
+ this.#context,
+ Bidi.ChromiumBidi.BrowsingContext.EventNames.NavigationStarted
+ ).pipe(first()),
+ fromEmitterEvent(this.#context, waitUntilEvent).pipe(first()),
+ ]),
+ fromEmitterEvent(
+ this.#context,
+ Bidi.ChromiumBidi.BrowsingContext.EventNames.FragmentNavigated
+ )
+ ).pipe(
+ map(result => {
+ if (Array.isArray(result)) {
+ return {result: result[1]};
+ }
+ return {result};
+ })
+ );
+
+ const result$ = zip(
+ navigation$,
+ ...(networkIdle !== null
+ ? [
+ this.#page.waitForNetworkIdle$({
+ timeout: ms,
+ concurrency: networkIdle === 'networkidle2' ? 2 : 0,
+ idleTime: NETWORK_IDLE_TIME,
+ }),
+ ]
+ : [])
+ ).pipe(
+ map(([{result}]) => {
+ return result;
+ }),
+ raceWith(timeout(ms), from(this.#abortDeferred.valueOrThrow()))
+ );
+
+ const result = await firstValueFrom(result$);
+ return this.#page.getNavigationResponse(result.navigation);
+ }
+
+ override waitForDevicePrompt(): never {
+ throw new UnsupportedOperation();
+ }
+
+ override get detached(): boolean {
+ return this.#disposed;
+ }
+
+ [disposeSymbol](): void {
+ if (this.#disposed) {
+ return;
+ }
+ this.#disposed = true;
+ this.#abortDeferred.reject(new Error('Frame detached'));
+ this.#context.dispose();
+ this.sandboxes[MAIN_SANDBOX][disposeSymbol]();
+ this.sandboxes[PUPPETEER_SANDBOX][disposeSymbol]();
+ }
+
+ #exposedFunctions = new Map<string, ExposeableFunction<never[], unknown>>();
+ async exposeFunction<Args extends unknown[], Ret>(
+ name: string,
+ apply: (...args: Args) => Awaitable<Ret>
+ ): Promise<void> {
+ if (this.#exposedFunctions.has(name)) {
+ throw new Error(
+ `Failed to add page binding with name ${name}: globalThis['${name}'] already exists!`
+ );
+ }
+ const exposeable = new ExposeableFunction(this, name, apply);
+ this.#exposedFunctions.set(name, exposeable);
+ try {
+ await exposeable.expose();
+ } catch (error) {
+ this.#exposedFunctions.delete(name);
+ throw error;
+ }
+ }
+
+ override waitForSelector<Selector extends string>(
+ selector: Selector,
+ options?: WaitForSelectorOptions
+ ): Promise<ElementHandle<NodeFor<Selector>> | null> {
+ if (selector.startsWith('aria')) {
+ throw new UnsupportedOperation(
+ 'ARIA selector is not supported for BiDi!'
+ );
+ }
+
+ return super.waitForSelector(selector, options);
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/HTTPRequest.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/HTTPRequest.ts
new file mode 100644
index 0000000000..57cb801b8c
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/HTTPRequest.ts
@@ -0,0 +1,163 @@
+/**
+ * @license
+ * Copyright 2020 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
+
+import type {Frame} from '../api/Frame.js';
+import type {
+ ContinueRequestOverrides,
+ ResponseForRequest,
+} from '../api/HTTPRequest.js';
+import {HTTPRequest, type ResourceType} from '../api/HTTPRequest.js';
+import {UnsupportedOperation} from '../common/Errors.js';
+
+import type {BidiHTTPResponse} from './HTTPResponse.js';
+
+/**
+ * @internal
+ */
+export class BidiHTTPRequest extends HTTPRequest {
+ override _response: BidiHTTPResponse | null = null;
+ override _redirectChain: BidiHTTPRequest[];
+ _navigationId: string | null;
+
+ #url: string;
+ #resourceType: ResourceType;
+
+ #method: string;
+ #postData?: string;
+ #headers: Record<string, string> = {};
+ #initiator: Bidi.Network.Initiator;
+ #frame: Frame | null;
+
+ constructor(
+ event: Bidi.Network.BeforeRequestSentParameters,
+ frame: Frame | null,
+ redirectChain: BidiHTTPRequest[] = []
+ ) {
+ super();
+
+ this.#url = event.request.url;
+ this.#resourceType = event.initiator.type.toLowerCase() as ResourceType;
+ this.#method = event.request.method;
+ this.#postData = undefined;
+ this.#initiator = event.initiator;
+ this.#frame = frame;
+
+ this._requestId = event.request.request;
+ this._redirectChain = redirectChain;
+ this._navigationId = event.navigation;
+
+ for (const header of event.request.headers) {
+ // TODO: How to handle Binary Headers
+ // https://w3c.github.io/webdriver-bidi/#type-network-Header
+ if (header.value.type === 'string') {
+ this.#headers[header.name.toLowerCase()] = header.value.value;
+ }
+ }
+ }
+
+ override get client(): never {
+ throw new UnsupportedOperation();
+ }
+
+ override url(): string {
+ return this.#url;
+ }
+
+ override resourceType(): ResourceType {
+ return this.#resourceType;
+ }
+
+ override method(): string {
+ return this.#method;
+ }
+
+ override postData(): string | undefined {
+ return this.#postData;
+ }
+
+ override hasPostData(): boolean {
+ return this.#postData !== undefined;
+ }
+
+ override async fetchPostData(): Promise<string | undefined> {
+ return this.#postData;
+ }
+
+ override headers(): Record<string, string> {
+ return this.#headers;
+ }
+
+ override response(): BidiHTTPResponse | null {
+ return this._response;
+ }
+
+ override isNavigationRequest(): boolean {
+ return Boolean(this._navigationId);
+ }
+
+ override initiator(): Bidi.Network.Initiator {
+ return this.#initiator;
+ }
+
+ override redirectChain(): BidiHTTPRequest[] {
+ return this._redirectChain.slice();
+ }
+
+ override enqueueInterceptAction(
+ pendingHandler: () => void | PromiseLike<unknown>
+ ): void {
+ // Execute the handler when interception is not supported
+ void pendingHandler();
+ }
+
+ override frame(): Frame | null {
+ return this.#frame;
+ }
+
+ override continueRequestOverrides(): never {
+ throw new UnsupportedOperation();
+ }
+
+ override continue(_overrides: ContinueRequestOverrides = {}): never {
+ throw new UnsupportedOperation();
+ }
+
+ override responseForRequest(): never {
+ throw new UnsupportedOperation();
+ }
+
+ override abortErrorReason(): never {
+ throw new UnsupportedOperation();
+ }
+
+ override interceptResolutionState(): never {
+ throw new UnsupportedOperation();
+ }
+
+ override isInterceptResolutionHandled(): never {
+ throw new UnsupportedOperation();
+ }
+
+ override finalizeInterceptions(): never {
+ throw new UnsupportedOperation();
+ }
+
+ override abort(): never {
+ throw new UnsupportedOperation();
+ }
+
+ override respond(
+ _response: Partial<ResponseForRequest>,
+ _priority?: number
+ ): never {
+ throw new UnsupportedOperation();
+ }
+
+ override failure(): never {
+ throw new UnsupportedOperation();
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/HTTPResponse.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/HTTPResponse.ts
new file mode 100644
index 0000000000..ce28820a65
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/HTTPResponse.ts
@@ -0,0 +1,107 @@
+/**
+ * @license
+ * Copyright 2020 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
+import type Protocol from 'devtools-protocol';
+
+import type {Frame} from '../api/Frame.js';
+import {
+ HTTPResponse as HTTPResponse,
+ type RemoteAddress,
+} from '../api/HTTPResponse.js';
+import {UnsupportedOperation} from '../common/Errors.js';
+
+import type {BidiHTTPRequest} from './HTTPRequest.js';
+
+/**
+ * @internal
+ */
+export class BidiHTTPResponse extends HTTPResponse {
+ #request: BidiHTTPRequest;
+ #remoteAddress: RemoteAddress;
+ #status: number;
+ #statusText: string;
+ #url: string;
+ #fromCache: boolean;
+ #headers: Record<string, string> = {};
+ #timings: Record<string, string> | null;
+
+ constructor(
+ request: BidiHTTPRequest,
+ {response}: Bidi.Network.ResponseCompletedParameters
+ ) {
+ super();
+ this.#request = request;
+
+ this.#remoteAddress = {
+ ip: '',
+ port: -1,
+ };
+
+ this.#url = response.url;
+ this.#fromCache = response.fromCache;
+ this.#status = response.status;
+ this.#statusText = response.statusText;
+ // TODO: File and issue with BiDi spec
+ this.#timings = null;
+
+ // TODO: Removed once the Firefox implementation is compliant with https://w3c.github.io/webdriver-bidi/#get-the-response-data.
+ for (const header of response.headers || []) {
+ // TODO: How to handle Binary Headers
+ // https://w3c.github.io/webdriver-bidi/#type-network-Header
+ if (header.value.type === 'string') {
+ this.#headers[header.name.toLowerCase()] = header.value.value;
+ }
+ }
+ }
+
+ override remoteAddress(): RemoteAddress {
+ return this.#remoteAddress;
+ }
+
+ override url(): string {
+ return this.#url;
+ }
+
+ override status(): number {
+ return this.#status;
+ }
+
+ override statusText(): string {
+ return this.#statusText;
+ }
+
+ override headers(): Record<string, string> {
+ return this.#headers;
+ }
+
+ override request(): BidiHTTPRequest {
+ return this.#request;
+ }
+
+ override fromCache(): boolean {
+ return this.#fromCache;
+ }
+
+ override timing(): Protocol.Network.ResourceTiming | null {
+ return this.#timings as any;
+ }
+
+ override frame(): Frame | null {
+ return this.#request.frame();
+ }
+
+ override fromServiceWorker(): boolean {
+ return false;
+ }
+
+ override securityDetails(): never {
+ throw new UnsupportedOperation();
+ }
+
+ override buffer(): never {
+ throw new UnsupportedOperation();
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Input.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Input.ts
new file mode 100644
index 0000000000..5406556d64
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Input.ts
@@ -0,0 +1,732 @@
+/**
+ * @license
+ * Copyright 2017 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
+
+import type {Point} from '../api/ElementHandle.js';
+import {
+ Keyboard,
+ Mouse,
+ MouseButton,
+ Touchscreen,
+ type KeyDownOptions,
+ type KeyPressOptions,
+ type KeyboardTypeOptions,
+ type MouseClickOptions,
+ type MouseMoveOptions,
+ type MouseOptions,
+ type MouseWheelOptions,
+} from '../api/Input.js';
+import {UnsupportedOperation} from '../common/Errors.js';
+import type {KeyInput} from '../common/USKeyboardLayout.js';
+
+import type {BrowsingContext} from './BrowsingContext.js';
+import type {BidiPage} from './Page.js';
+
+const enum InputId {
+ Mouse = '__puppeteer_mouse',
+ Keyboard = '__puppeteer_keyboard',
+ Wheel = '__puppeteer_wheel',
+ Finger = '__puppeteer_finger',
+}
+
+enum SourceActionsType {
+ None = 'none',
+ Key = 'key',
+ Pointer = 'pointer',
+ Wheel = 'wheel',
+}
+
+enum ActionType {
+ Pause = 'pause',
+ KeyDown = 'keyDown',
+ KeyUp = 'keyUp',
+ PointerUp = 'pointerUp',
+ PointerDown = 'pointerDown',
+ PointerMove = 'pointerMove',
+ Scroll = 'scroll',
+}
+
+const getBidiKeyValue = (key: KeyInput) => {
+ switch (key) {
+ case '\r':
+ case '\n':
+ key = 'Enter';
+ break;
+ }
+ // Measures the number of code points rather than UTF-16 code units.
+ if ([...key].length === 1) {
+ return key;
+ }
+ switch (key) {
+ case 'Cancel':
+ return '\uE001';
+ case 'Help':
+ return '\uE002';
+ case 'Backspace':
+ return '\uE003';
+ case 'Tab':
+ return '\uE004';
+ case 'Clear':
+ return '\uE005';
+ case 'Enter':
+ return '\uE007';
+ case 'Shift':
+ case 'ShiftLeft':
+ return '\uE008';
+ case 'Control':
+ case 'ControlLeft':
+ return '\uE009';
+ case 'Alt':
+ case 'AltLeft':
+ return '\uE00A';
+ case 'Pause':
+ return '\uE00B';
+ case 'Escape':
+ return '\uE00C';
+ case 'PageUp':
+ return '\uE00E';
+ case 'PageDown':
+ return '\uE00F';
+ case 'End':
+ return '\uE010';
+ case 'Home':
+ return '\uE011';
+ case 'ArrowLeft':
+ return '\uE012';
+ case 'ArrowUp':
+ return '\uE013';
+ case 'ArrowRight':
+ return '\uE014';
+ case 'ArrowDown':
+ return '\uE015';
+ case 'Insert':
+ return '\uE016';
+ case 'Delete':
+ return '\uE017';
+ case 'NumpadEqual':
+ return '\uE019';
+ case 'Numpad0':
+ return '\uE01A';
+ case 'Numpad1':
+ return '\uE01B';
+ case 'Numpad2':
+ return '\uE01C';
+ case 'Numpad3':
+ return '\uE01D';
+ case 'Numpad4':
+ return '\uE01E';
+ case 'Numpad5':
+ return '\uE01F';
+ case 'Numpad6':
+ return '\uE020';
+ case 'Numpad7':
+ return '\uE021';
+ case 'Numpad8':
+ return '\uE022';
+ case 'Numpad9':
+ return '\uE023';
+ case 'NumpadMultiply':
+ return '\uE024';
+ case 'NumpadAdd':
+ return '\uE025';
+ case 'NumpadSubtract':
+ return '\uE027';
+ case 'NumpadDecimal':
+ return '\uE028';
+ case 'NumpadDivide':
+ return '\uE029';
+ case 'F1':
+ return '\uE031';
+ case 'F2':
+ return '\uE032';
+ case 'F3':
+ return '\uE033';
+ case 'F4':
+ return '\uE034';
+ case 'F5':
+ return '\uE035';
+ case 'F6':
+ return '\uE036';
+ case 'F7':
+ return '\uE037';
+ case 'F8':
+ return '\uE038';
+ case 'F9':
+ return '\uE039';
+ case 'F10':
+ return '\uE03A';
+ case 'F11':
+ return '\uE03B';
+ case 'F12':
+ return '\uE03C';
+ case 'Meta':
+ case 'MetaLeft':
+ return '\uE03D';
+ case 'ShiftRight':
+ return '\uE050';
+ case 'ControlRight':
+ return '\uE051';
+ case 'AltRight':
+ return '\uE052';
+ case 'MetaRight':
+ return '\uE053';
+ case 'Digit0':
+ return '0';
+ case 'Digit1':
+ return '1';
+ case 'Digit2':
+ return '2';
+ case 'Digit3':
+ return '3';
+ case 'Digit4':
+ return '4';
+ case 'Digit5':
+ return '5';
+ case 'Digit6':
+ return '6';
+ case 'Digit7':
+ return '7';
+ case 'Digit8':
+ return '8';
+ case 'Digit9':
+ return '9';
+ case 'KeyA':
+ return 'a';
+ case 'KeyB':
+ return 'b';
+ case 'KeyC':
+ return 'c';
+ case 'KeyD':
+ return 'd';
+ case 'KeyE':
+ return 'e';
+ case 'KeyF':
+ return 'f';
+ case 'KeyG':
+ return 'g';
+ case 'KeyH':
+ return 'h';
+ case 'KeyI':
+ return 'i';
+ case 'KeyJ':
+ return 'j';
+ case 'KeyK':
+ return 'k';
+ case 'KeyL':
+ return 'l';
+ case 'KeyM':
+ return 'm';
+ case 'KeyN':
+ return 'n';
+ case 'KeyO':
+ return 'o';
+ case 'KeyP':
+ return 'p';
+ case 'KeyQ':
+ return 'q';
+ case 'KeyR':
+ return 'r';
+ case 'KeyS':
+ return 's';
+ case 'KeyT':
+ return 't';
+ case 'KeyU':
+ return 'u';
+ case 'KeyV':
+ return 'v';
+ case 'KeyW':
+ return 'w';
+ case 'KeyX':
+ return 'x';
+ case 'KeyY':
+ return 'y';
+ case 'KeyZ':
+ return 'z';
+ case 'Semicolon':
+ return ';';
+ case 'Equal':
+ return '=';
+ case 'Comma':
+ return ',';
+ case 'Minus':
+ return '-';
+ case 'Period':
+ return '.';
+ case 'Slash':
+ return '/';
+ case 'Backquote':
+ return '`';
+ case 'BracketLeft':
+ return '[';
+ case 'Backslash':
+ return '\\';
+ case 'BracketRight':
+ return ']';
+ case 'Quote':
+ return '"';
+ default:
+ throw new Error(`Unknown key: "${key}"`);
+ }
+};
+
+/**
+ * @internal
+ */
+export class BidiKeyboard extends Keyboard {
+ #page: BidiPage;
+
+ constructor(page: BidiPage) {
+ super();
+ this.#page = page;
+ }
+
+ override async down(
+ key: KeyInput,
+ _options?: Readonly<KeyDownOptions>
+ ): Promise<void> {
+ await this.#page.connection.send('input.performActions', {
+ context: this.#page.mainFrame()._id,
+ actions: [
+ {
+ type: SourceActionsType.Key,
+ id: InputId.Keyboard,
+ actions: [
+ {
+ type: ActionType.KeyDown,
+ value: getBidiKeyValue(key),
+ },
+ ],
+ },
+ ],
+ });
+ }
+
+ override async up(key: KeyInput): Promise<void> {
+ await this.#page.connection.send('input.performActions', {
+ context: this.#page.mainFrame()._id,
+ actions: [
+ {
+ type: SourceActionsType.Key,
+ id: InputId.Keyboard,
+ actions: [
+ {
+ type: ActionType.KeyUp,
+ value: getBidiKeyValue(key),
+ },
+ ],
+ },
+ ],
+ });
+ }
+
+ override async press(
+ key: KeyInput,
+ options: Readonly<KeyPressOptions> = {}
+ ): Promise<void> {
+ const {delay = 0} = options;
+ const actions: Bidi.Input.KeySourceAction[] = [
+ {
+ type: ActionType.KeyDown,
+ value: getBidiKeyValue(key),
+ },
+ ];
+ if (delay > 0) {
+ actions.push({
+ type: ActionType.Pause,
+ duration: delay,
+ });
+ }
+ actions.push({
+ type: ActionType.KeyUp,
+ value: getBidiKeyValue(key),
+ });
+ await this.#page.connection.send('input.performActions', {
+ context: this.#page.mainFrame()._id,
+ actions: [
+ {
+ type: SourceActionsType.Key,
+ id: InputId.Keyboard,
+ actions,
+ },
+ ],
+ });
+ }
+
+ override async type(
+ text: string,
+ options: Readonly<KeyboardTypeOptions> = {}
+ ): Promise<void> {
+ const {delay = 0} = options;
+ // This spread separates the characters into code points rather than UTF-16
+ // code units.
+ const values = ([...text] as KeyInput[]).map(getBidiKeyValue);
+ const actions: Bidi.Input.KeySourceAction[] = [];
+ if (delay <= 0) {
+ for (const value of values) {
+ actions.push(
+ {
+ type: ActionType.KeyDown,
+ value,
+ },
+ {
+ type: ActionType.KeyUp,
+ value,
+ }
+ );
+ }
+ } else {
+ for (const value of values) {
+ actions.push(
+ {
+ type: ActionType.KeyDown,
+ value,
+ },
+ {
+ type: ActionType.Pause,
+ duration: delay,
+ },
+ {
+ type: ActionType.KeyUp,
+ value,
+ }
+ );
+ }
+ }
+ await this.#page.connection.send('input.performActions', {
+ context: this.#page.mainFrame()._id,
+ actions: [
+ {
+ type: SourceActionsType.Key,
+ id: InputId.Keyboard,
+ actions,
+ },
+ ],
+ });
+ }
+
+ override async sendCharacter(char: string): Promise<void> {
+ // Measures the number of code points rather than UTF-16 code units.
+ if ([...char].length > 1) {
+ throw new Error('Cannot send more than 1 character.');
+ }
+ const frame = await this.#page.focusedFrame();
+ await frame.isolatedRealm().evaluate(async char => {
+ document.execCommand('insertText', false, char);
+ }, char);
+ }
+}
+
+/**
+ * @internal
+ */
+export interface BidiMouseClickOptions extends MouseClickOptions {
+ origin?: Bidi.Input.Origin;
+}
+
+/**
+ * @internal
+ */
+export interface BidiMouseMoveOptions extends MouseMoveOptions {
+ origin?: Bidi.Input.Origin;
+}
+
+/**
+ * @internal
+ */
+export interface BidiTouchMoveOptions {
+ origin?: Bidi.Input.Origin;
+}
+
+const getBidiButton = (button: MouseButton) => {
+ switch (button) {
+ case MouseButton.Left:
+ return 0;
+ case MouseButton.Middle:
+ return 1;
+ case MouseButton.Right:
+ return 2;
+ case MouseButton.Back:
+ return 3;
+ case MouseButton.Forward:
+ return 4;
+ }
+};
+
+/**
+ * @internal
+ */
+export class BidiMouse extends Mouse {
+ #context: BrowsingContext;
+ #lastMovePoint: Point = {x: 0, y: 0};
+
+ constructor(context: BrowsingContext) {
+ super();
+ this.#context = context;
+ }
+
+ override async reset(): Promise<void> {
+ this.#lastMovePoint = {x: 0, y: 0};
+ await this.#context.connection.send('input.releaseActions', {
+ context: this.#context.id,
+ });
+ }
+
+ override async move(
+ x: number,
+ y: number,
+ options: Readonly<BidiMouseMoveOptions> = {}
+ ): Promise<void> {
+ const from = this.#lastMovePoint;
+ const to = {
+ x: Math.round(x),
+ y: Math.round(y),
+ };
+ const actions: Bidi.Input.PointerSourceAction[] = [];
+ const steps = options.steps ?? 0;
+ for (let i = 0; i < steps; ++i) {
+ actions.push({
+ type: ActionType.PointerMove,
+ x: from.x + (to.x - from.x) * (i / steps),
+ y: from.y + (to.y - from.y) * (i / steps),
+ origin: options.origin,
+ });
+ }
+ actions.push({
+ type: ActionType.PointerMove,
+ ...to,
+ origin: options.origin,
+ });
+ // https://w3c.github.io/webdriver-bidi/#command-input-performActions:~:text=input.PointerMoveAction%20%3D%20%7B%0A%20%20type%3A%20%22pointerMove%22%2C%0A%20%20x%3A%20js%2Dint%2C
+ this.#lastMovePoint = to;
+ await this.#context.connection.send('input.performActions', {
+ context: this.#context.id,
+ actions: [
+ {
+ type: SourceActionsType.Pointer,
+ id: InputId.Mouse,
+ actions,
+ },
+ ],
+ });
+ }
+
+ override async down(options: Readonly<MouseOptions> = {}): Promise<void> {
+ await this.#context.connection.send('input.performActions', {
+ context: this.#context.id,
+ actions: [
+ {
+ type: SourceActionsType.Pointer,
+ id: InputId.Mouse,
+ actions: [
+ {
+ type: ActionType.PointerDown,
+ button: getBidiButton(options.button ?? MouseButton.Left),
+ },
+ ],
+ },
+ ],
+ });
+ }
+
+ override async up(options: Readonly<MouseOptions> = {}): Promise<void> {
+ await this.#context.connection.send('input.performActions', {
+ context: this.#context.id,
+ actions: [
+ {
+ type: SourceActionsType.Pointer,
+ id: InputId.Mouse,
+ actions: [
+ {
+ type: ActionType.PointerUp,
+ button: getBidiButton(options.button ?? MouseButton.Left),
+ },
+ ],
+ },
+ ],
+ });
+ }
+
+ override async click(
+ x: number,
+ y: number,
+ options: Readonly<BidiMouseClickOptions> = {}
+ ): Promise<void> {
+ const actions: Bidi.Input.PointerSourceAction[] = [
+ {
+ type: ActionType.PointerMove,
+ x: Math.round(x),
+ y: Math.round(y),
+ origin: options.origin,
+ },
+ ];
+ const pointerDownAction = {
+ type: ActionType.PointerDown,
+ button: getBidiButton(options.button ?? MouseButton.Left),
+ } as const;
+ const pointerUpAction = {
+ type: ActionType.PointerUp,
+ button: pointerDownAction.button,
+ } as const;
+ for (let i = 1; i < (options.count ?? 1); ++i) {
+ actions.push(pointerDownAction, pointerUpAction);
+ }
+ actions.push(pointerDownAction);
+ if (options.delay) {
+ actions.push({
+ type: ActionType.Pause,
+ duration: options.delay,
+ });
+ }
+ actions.push(pointerUpAction);
+ await this.#context.connection.send('input.performActions', {
+ context: this.#context.id,
+ actions: [
+ {
+ type: SourceActionsType.Pointer,
+ id: InputId.Mouse,
+ actions,
+ },
+ ],
+ });
+ }
+
+ override async wheel(
+ options: Readonly<MouseWheelOptions> = {}
+ ): Promise<void> {
+ await this.#context.connection.send('input.performActions', {
+ context: this.#context.id,
+ actions: [
+ {
+ type: SourceActionsType.Wheel,
+ id: InputId.Wheel,
+ actions: [
+ {
+ type: ActionType.Scroll,
+ ...(this.#lastMovePoint ?? {
+ x: 0,
+ y: 0,
+ }),
+ deltaX: options.deltaX ?? 0,
+ deltaY: options.deltaY ?? 0,
+ },
+ ],
+ },
+ ],
+ });
+ }
+
+ override drag(): never {
+ throw new UnsupportedOperation();
+ }
+
+ override dragOver(): never {
+ throw new UnsupportedOperation();
+ }
+
+ override dragEnter(): never {
+ throw new UnsupportedOperation();
+ }
+
+ override drop(): never {
+ throw new UnsupportedOperation();
+ }
+
+ override dragAndDrop(): never {
+ throw new UnsupportedOperation();
+ }
+}
+
+/**
+ * @internal
+ */
+export class BidiTouchscreen extends Touchscreen {
+ #context: BrowsingContext;
+
+ constructor(context: BrowsingContext) {
+ super();
+ this.#context = context;
+ }
+
+ override async touchStart(
+ x: number,
+ y: number,
+ options: BidiTouchMoveOptions = {}
+ ): Promise<void> {
+ await this.#context.connection.send('input.performActions', {
+ context: this.#context.id,
+ actions: [
+ {
+ type: SourceActionsType.Pointer,
+ id: InputId.Finger,
+ parameters: {
+ pointerType: Bidi.Input.PointerType.Touch,
+ },
+ actions: [
+ {
+ type: ActionType.PointerMove,
+ x: Math.round(x),
+ y: Math.round(y),
+ origin: options.origin,
+ },
+ {
+ type: ActionType.PointerDown,
+ button: 0,
+ },
+ ],
+ },
+ ],
+ });
+ }
+
+ override async touchMove(
+ x: number,
+ y: number,
+ options: BidiTouchMoveOptions = {}
+ ): Promise<void> {
+ await this.#context.connection.send('input.performActions', {
+ context: this.#context.id,
+ actions: [
+ {
+ type: SourceActionsType.Pointer,
+ id: InputId.Finger,
+ parameters: {
+ pointerType: Bidi.Input.PointerType.Touch,
+ },
+ actions: [
+ {
+ type: ActionType.PointerMove,
+ x: Math.round(x),
+ y: Math.round(y),
+ origin: options.origin,
+ },
+ ],
+ },
+ ],
+ });
+ }
+
+ override async touchEnd(): Promise<void> {
+ await this.#context.connection.send('input.performActions', {
+ context: this.#context.id,
+ actions: [
+ {
+ type: SourceActionsType.Pointer,
+ id: InputId.Finger,
+ parameters: {
+ pointerType: Bidi.Input.PointerType.Touch,
+ },
+ actions: [
+ {
+ type: ActionType.PointerUp,
+ button: 0,
+ },
+ ],
+ },
+ ],
+ });
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/JSHandle.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/JSHandle.ts
new file mode 100644
index 0000000000..7104601553
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/JSHandle.ts
@@ -0,0 +1,101 @@
+/**
+ * @license
+ * Copyright 2023 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
+
+import type {ElementHandle} from '../api/ElementHandle.js';
+import {JSHandle} from '../api/JSHandle.js';
+import {UnsupportedOperation} from '../common/Errors.js';
+
+import {BidiDeserializer} from './Deserializer.js';
+import type {BidiRealm} from './Realm.js';
+import type {Sandbox} from './Sandbox.js';
+import {releaseReference} from './util.js';
+
+/**
+ * @internal
+ */
+export class BidiJSHandle<T = unknown> extends JSHandle<T> {
+ #disposed = false;
+ readonly #sandbox: Sandbox;
+ readonly #remoteValue: Bidi.Script.RemoteValue;
+
+ constructor(sandbox: Sandbox, remoteValue: Bidi.Script.RemoteValue) {
+ super();
+ this.#sandbox = sandbox;
+ this.#remoteValue = remoteValue;
+ }
+
+ context(): BidiRealm {
+ return this.realm.environment.context();
+ }
+
+ override get realm(): Sandbox {
+ return this.#sandbox;
+ }
+
+ override get disposed(): boolean {
+ return this.#disposed;
+ }
+
+ override async jsonValue(): Promise<T> {
+ return await this.evaluate(value => {
+ return value;
+ });
+ }
+
+ override asElement(): ElementHandle<Node> | null {
+ return null;
+ }
+
+ override async dispose(): Promise<void> {
+ if (this.#disposed) {
+ return;
+ }
+ this.#disposed = true;
+ if ('handle' in this.#remoteValue) {
+ await releaseReference(
+ this.context(),
+ this.#remoteValue as Bidi.Script.RemoteReference
+ );
+ }
+ }
+
+ get isPrimitiveValue(): boolean {
+ switch (this.#remoteValue.type) {
+ case 'string':
+ case 'number':
+ case 'bigint':
+ case 'boolean':
+ case 'undefined':
+ case 'null':
+ return true;
+
+ default:
+ return false;
+ }
+ }
+
+ override toString(): string {
+ if (this.isPrimitiveValue) {
+ return 'JSHandle:' + BidiDeserializer.deserialize(this.#remoteValue);
+ }
+
+ return 'JSHandle@' + this.#remoteValue.type;
+ }
+
+ override get id(): string | undefined {
+ return 'handle' in this.#remoteValue ? this.#remoteValue.handle : undefined;
+ }
+
+ remoteValue(): Bidi.Script.RemoteValue {
+ return this.#remoteValue;
+ }
+
+ override remoteObject(): never {
+ throw new UnsupportedOperation('Not available in WebDriver BiDi');
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/NetworkManager.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/NetworkManager.ts
new file mode 100644
index 0000000000..2caaf0ad50
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/NetworkManager.ts
@@ -0,0 +1,155 @@
+/**
+ * @license
+ * Copyright 2023 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
+
+import {EventEmitter, EventSubscription} from '../common/EventEmitter.js';
+import {
+ NetworkManagerEvent,
+ type NetworkManagerEvents,
+} from '../common/NetworkManagerEvents.js';
+import {DisposableStack} from '../util/disposable.js';
+
+import type {BidiConnection} from './Connection.js';
+import type {BidiFrame} from './Frame.js';
+import {BidiHTTPRequest} from './HTTPRequest.js';
+import {BidiHTTPResponse} from './HTTPResponse.js';
+import type {BidiPage} from './Page.js';
+
+/**
+ * @internal
+ */
+export class BidiNetworkManager extends EventEmitter<NetworkManagerEvents> {
+ #connection: BidiConnection;
+ #page: BidiPage;
+ #subscriptions = new DisposableStack();
+
+ #requestMap = new Map<string, BidiHTTPRequest>();
+ #navigationMap = new Map<string, BidiHTTPResponse>();
+
+ constructor(connection: BidiConnection, page: BidiPage) {
+ super();
+ this.#connection = connection;
+ this.#page = page;
+
+ // TODO: Subscribe to the Frame individually
+ this.#subscriptions.use(
+ new EventSubscription(
+ this.#connection,
+ 'network.beforeRequestSent',
+ this.#onBeforeRequestSent.bind(this)
+ )
+ );
+ this.#subscriptions.use(
+ new EventSubscription(
+ this.#connection,
+ 'network.responseStarted',
+ this.#onResponseStarted.bind(this)
+ )
+ );
+ this.#subscriptions.use(
+ new EventSubscription(
+ this.#connection,
+ 'network.responseCompleted',
+ this.#onResponseCompleted.bind(this)
+ )
+ );
+ this.#subscriptions.use(
+ new EventSubscription(
+ this.#connection,
+ 'network.fetchError',
+ this.#onFetchError.bind(this)
+ )
+ );
+ }
+
+ #onBeforeRequestSent(event: Bidi.Network.BeforeRequestSentParameters): void {
+ const frame = this.#page.frame(event.context ?? '');
+ if (!frame) {
+ return;
+ }
+ const request = this.#requestMap.get(event.request.request);
+ let upsertRequest: BidiHTTPRequest;
+ if (request) {
+ request._redirectChain.push(request);
+ upsertRequest = new BidiHTTPRequest(event, frame, request._redirectChain);
+ } else {
+ upsertRequest = new BidiHTTPRequest(event, frame, []);
+ }
+ this.#requestMap.set(event.request.request, upsertRequest);
+ this.emit(NetworkManagerEvent.Request, upsertRequest);
+ }
+
+ #onResponseStarted(_event: Bidi.Network.ResponseStartedParameters) {}
+
+ #onResponseCompleted(event: Bidi.Network.ResponseCompletedParameters): void {
+ const request = this.#requestMap.get(event.request.request);
+ if (!request) {
+ return;
+ }
+ const response = new BidiHTTPResponse(request, event);
+ request._response = response;
+ if (event.navigation) {
+ this.#navigationMap.set(event.navigation, response);
+ }
+ if (response.fromCache()) {
+ this.emit(NetworkManagerEvent.RequestServedFromCache, request);
+ }
+ this.emit(NetworkManagerEvent.Response, response);
+ this.emit(NetworkManagerEvent.RequestFinished, request);
+ }
+
+ #onFetchError(event: Bidi.Network.FetchErrorParameters) {
+ const request = this.#requestMap.get(event.request.request);
+ if (!request) {
+ return;
+ }
+ request._failureText = event.errorText;
+ this.emit(NetworkManagerEvent.RequestFailed, request);
+ this.#requestMap.delete(event.request.request);
+ }
+
+ getNavigationResponse(navigationId?: string | null): BidiHTTPResponse | null {
+ if (!navigationId) {
+ return null;
+ }
+ const response = this.#navigationMap.get(navigationId);
+
+ return response ?? null;
+ }
+
+ inFlightRequestsCount(): number {
+ let inFlightRequestCounter = 0;
+ for (const request of this.#requestMap.values()) {
+ if (!request.response() || request._failureText) {
+ inFlightRequestCounter++;
+ }
+ }
+
+ return inFlightRequestCounter;
+ }
+
+ clearMapAfterFrameDispose(frame: BidiFrame): void {
+ for (const [id, request] of this.#requestMap.entries()) {
+ if (request.frame() === frame) {
+ this.#requestMap.delete(id);
+ }
+ }
+
+ for (const [id, response] of this.#navigationMap.entries()) {
+ if (response.frame() === frame) {
+ this.#navigationMap.delete(id);
+ }
+ }
+ }
+
+ dispose(): void {
+ this.removeAllListeners();
+ this.#requestMap.clear();
+ this.#navigationMap.clear();
+ this.#subscriptions.dispose();
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Page.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Page.ts
new file mode 100644
index 0000000000..053d23b63a
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Page.ts
@@ -0,0 +1,913 @@
+/**
+ * @license
+ * Copyright 2022 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {Readable} from 'stream';
+
+import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
+import type Protocol from 'devtools-protocol';
+
+import {
+ firstValueFrom,
+ from,
+ map,
+ raceWith,
+ zip,
+} from '../../third_party/rxjs/rxjs.js';
+import type {CDPSession} from '../api/CDPSession.js';
+import type {BoundingBox} from '../api/ElementHandle.js';
+import type {WaitForOptions} from '../api/Frame.js';
+import type {HTTPResponse} from '../api/HTTPResponse.js';
+import {
+ Page,
+ PageEvent,
+ type GeolocationOptions,
+ type MediaFeature,
+ type NewDocumentScriptEvaluation,
+ type ScreenshotOptions,
+} from '../api/Page.js';
+import {Accessibility} from '../cdp/Accessibility.js';
+import {Coverage} from '../cdp/Coverage.js';
+import {EmulationManager as CdpEmulationManager} from '../cdp/EmulationManager.js';
+import {FrameTree} from '../cdp/FrameTree.js';
+import {Tracing} from '../cdp/Tracing.js';
+import {
+ ConsoleMessage,
+ type ConsoleMessageLocation,
+} from '../common/ConsoleMessage.js';
+import {TargetCloseError, UnsupportedOperation} from '../common/Errors.js';
+import type {Handler} from '../common/EventEmitter.js';
+import {NetworkManagerEvent} from '../common/NetworkManagerEvents.js';
+import type {PDFOptions} from '../common/PDFOptions.js';
+import type {Awaitable} from '../common/types.js';
+import {
+ debugError,
+ evaluationString,
+ NETWORK_IDLE_TIME,
+ parsePDFOptions,
+ timeout,
+ validateDialogType,
+} from '../common/util.js';
+import type {Viewport} from '../common/Viewport.js';
+import {assert} from '../util/assert.js';
+import {Deferred} from '../util/Deferred.js';
+import {disposeSymbol} from '../util/disposable.js';
+import {isErrorLike} from '../util/ErrorLike.js';
+
+import type {BidiBrowser} from './Browser.js';
+import type {BidiBrowserContext} from './BrowserContext.js';
+import {
+ BrowsingContextEvent,
+ CdpSessionWrapper,
+ type BrowsingContext,
+} from './BrowsingContext.js';
+import type {BidiConnection} from './Connection.js';
+import {BidiDeserializer} from './Deserializer.js';
+import {BidiDialog} from './Dialog.js';
+import {BidiElementHandle} from './ElementHandle.js';
+import {EmulationManager} from './EmulationManager.js';
+import {BidiFrame} from './Frame.js';
+import type {BidiHTTPRequest} from './HTTPRequest.js';
+import type {BidiHTTPResponse} from './HTTPResponse.js';
+import {BidiKeyboard, BidiMouse, BidiTouchscreen} from './Input.js';
+import type {BidiJSHandle} from './JSHandle.js';
+import {getBiDiReadinessState, rewriteNavigationError} from './lifecycle.js';
+import {BidiNetworkManager} from './NetworkManager.js';
+import {createBidiHandle} from './Realm.js';
+import type {BiDiPageTarget} from './Target.js';
+
+/**
+ * @internal
+ */
+export class BidiPage extends Page {
+ #accessibility: Accessibility;
+ #connection: BidiConnection;
+ #frameTree = new FrameTree<BidiFrame>();
+ #networkManager: BidiNetworkManager;
+ #viewport: Viewport | null = null;
+ #closedDeferred = Deferred.create<never, TargetCloseError>();
+ #subscribedEvents = new Map<Bidi.Event['method'], Handler<any>>([
+ ['log.entryAdded', this.#onLogEntryAdded.bind(this)],
+ ['browsingContext.load', this.#onFrameLoaded.bind(this)],
+ [
+ 'browsingContext.fragmentNavigated',
+ this.#onFrameFragmentNavigated.bind(this),
+ ],
+ [
+ 'browsingContext.domContentLoaded',
+ this.#onFrameDOMContentLoaded.bind(this),
+ ],
+ ['browsingContext.userPromptOpened', this.#onDialog.bind(this)],
+ ]);
+ readonly #networkManagerEvents = [
+ [
+ NetworkManagerEvent.Request,
+ (request: BidiHTTPRequest) => {
+ this.emit(PageEvent.Request, request);
+ },
+ ],
+ [
+ NetworkManagerEvent.RequestServedFromCache,
+ (request: BidiHTTPRequest) => {
+ this.emit(PageEvent.RequestServedFromCache, request);
+ },
+ ],
+ [
+ NetworkManagerEvent.RequestFailed,
+ (request: BidiHTTPRequest) => {
+ this.emit(PageEvent.RequestFailed, request);
+ },
+ ],
+ [
+ NetworkManagerEvent.RequestFinished,
+ (request: BidiHTTPRequest) => {
+ this.emit(PageEvent.RequestFinished, request);
+ },
+ ],
+ [
+ NetworkManagerEvent.Response,
+ (response: BidiHTTPResponse) => {
+ this.emit(PageEvent.Response, response);
+ },
+ ],
+ ] as const;
+
+ readonly #browsingContextEvents = new Map<symbol, Handler<any>>([
+ [BrowsingContextEvent.Created, this.#onContextCreated.bind(this)],
+ [BrowsingContextEvent.Destroyed, this.#onContextDestroyed.bind(this)],
+ ]);
+ #tracing: Tracing;
+ #coverage: Coverage;
+ #cdpEmulationManager: CdpEmulationManager;
+ #emulationManager: EmulationManager;
+ #mouse: BidiMouse;
+ #touchscreen: BidiTouchscreen;
+ #keyboard: BidiKeyboard;
+ #browsingContext: BrowsingContext;
+ #browserContext: BidiBrowserContext;
+ #target: BiDiPageTarget;
+
+ _client(): CDPSession {
+ return this.mainFrame().context().cdpSession;
+ }
+
+ constructor(
+ browsingContext: BrowsingContext,
+ browserContext: BidiBrowserContext,
+ target: BiDiPageTarget
+ ) {
+ super();
+ this.#browsingContext = browsingContext;
+ this.#browserContext = browserContext;
+ this.#target = target;
+ this.#connection = browsingContext.connection;
+
+ for (const [event, subscriber] of this.#browsingContextEvents) {
+ this.#browsingContext.on(event, subscriber);
+ }
+
+ this.#networkManager = new BidiNetworkManager(this.#connection, this);
+
+ for (const [event, subscriber] of this.#subscribedEvents) {
+ this.#connection.on(event, subscriber);
+ }
+
+ for (const [event, subscriber] of this.#networkManagerEvents) {
+ // TODO: remove any
+ this.#networkManager.on(event, subscriber as any);
+ }
+
+ const frame = new BidiFrame(
+ this,
+ this.#browsingContext,
+ this._timeoutSettings,
+ this.#browsingContext.parent
+ );
+ this.#frameTree.addFrame(frame);
+ this.emit(PageEvent.FrameAttached, frame);
+
+ // TODO: https://github.com/w3c/webdriver-bidi/issues/443
+ this.#accessibility = new Accessibility(
+ this.mainFrame().context().cdpSession
+ );
+ this.#tracing = new Tracing(this.mainFrame().context().cdpSession);
+ this.#coverage = new Coverage(this.mainFrame().context().cdpSession);
+ this.#cdpEmulationManager = new CdpEmulationManager(
+ this.mainFrame().context().cdpSession
+ );
+ this.#emulationManager = new EmulationManager(browsingContext);
+ this.#mouse = new BidiMouse(this.mainFrame().context());
+ this.#touchscreen = new BidiTouchscreen(this.mainFrame().context());
+ this.#keyboard = new BidiKeyboard(this);
+ }
+
+ /**
+ * @internal
+ */
+ get connection(): BidiConnection {
+ return this.#connection;
+ }
+
+ override async setUserAgent(
+ userAgent: string,
+ userAgentMetadata?: Protocol.Emulation.UserAgentMetadata | undefined
+ ): Promise<void> {
+ // TODO: handle CDP-specific cases such as mprach.
+ await this._client().send('Network.setUserAgentOverride', {
+ userAgent: userAgent,
+ userAgentMetadata: userAgentMetadata,
+ });
+ }
+
+ override async setBypassCSP(enabled: boolean): Promise<void> {
+ // TODO: handle CDP-specific cases such as mprach.
+ await this._client().send('Page.setBypassCSP', {enabled});
+ }
+
+ override async queryObjects<Prototype>(
+ prototypeHandle: BidiJSHandle<Prototype>
+ ): Promise<BidiJSHandle<Prototype[]>> {
+ assert(!prototypeHandle.disposed, 'Prototype JSHandle is disposed!');
+ assert(
+ prototypeHandle.id,
+ 'Prototype JSHandle must not be referencing primitive value'
+ );
+ const response = await this.mainFrame().client.send(
+ 'Runtime.queryObjects',
+ {
+ prototypeObjectId: prototypeHandle.id,
+ }
+ );
+ return createBidiHandle(this.mainFrame().mainRealm(), {
+ type: 'array',
+ handle: response.objects.objectId,
+ }) as BidiJSHandle<Prototype[]>;
+ }
+
+ _setBrowserContext(browserContext: BidiBrowserContext): void {
+ this.#browserContext = browserContext;
+ }
+
+ override get accessibility(): Accessibility {
+ return this.#accessibility;
+ }
+
+ override get tracing(): Tracing {
+ return this.#tracing;
+ }
+
+ override get coverage(): Coverage {
+ return this.#coverage;
+ }
+
+ override get mouse(): BidiMouse {
+ return this.#mouse;
+ }
+
+ override get touchscreen(): BidiTouchscreen {
+ return this.#touchscreen;
+ }
+
+ override get keyboard(): BidiKeyboard {
+ return this.#keyboard;
+ }
+
+ override browser(): BidiBrowser {
+ return this.browserContext().browser();
+ }
+
+ override browserContext(): BidiBrowserContext {
+ return this.#browserContext;
+ }
+
+ override mainFrame(): BidiFrame {
+ const mainFrame = this.#frameTree.getMainFrame();
+ assert(mainFrame, 'Requesting main frame too early!');
+ return mainFrame;
+ }
+
+ /**
+ * @internal
+ */
+ async focusedFrame(): Promise<BidiFrame> {
+ using frame = await this.mainFrame()
+ .isolatedRealm()
+ .evaluateHandle(() => {
+ let frame: HTMLIFrameElement | undefined;
+ let win: Window | null = window;
+ while (win?.document.activeElement instanceof HTMLIFrameElement) {
+ frame = win.document.activeElement;
+ win = frame.contentWindow;
+ }
+ return frame;
+ });
+ if (!(frame instanceof BidiElementHandle)) {
+ return this.mainFrame();
+ }
+ return await frame.contentFrame();
+ }
+
+ override frames(): BidiFrame[] {
+ return Array.from(this.#frameTree.frames());
+ }
+
+ frame(frameId?: string): BidiFrame | null {
+ return this.#frameTree.getById(frameId ?? '') || null;
+ }
+
+ childFrames(frameId: string): BidiFrame[] {
+ return this.#frameTree.childFrames(frameId);
+ }
+
+ #onFrameLoaded(info: Bidi.BrowsingContext.NavigationInfo): void {
+ const frame = this.frame(info.context);
+ if (frame && this.mainFrame() === frame) {
+ this.emit(PageEvent.Load, undefined);
+ }
+ }
+
+ #onFrameFragmentNavigated(info: Bidi.BrowsingContext.NavigationInfo): void {
+ const frame = this.frame(info.context);
+ if (frame) {
+ this.emit(PageEvent.FrameNavigated, frame);
+ }
+ }
+
+ #onFrameDOMContentLoaded(info: Bidi.BrowsingContext.NavigationInfo): void {
+ const frame = this.frame(info.context);
+ if (frame) {
+ frame._hasStartedLoading = true;
+ if (this.mainFrame() === frame) {
+ this.emit(PageEvent.DOMContentLoaded, undefined);
+ }
+ this.emit(PageEvent.FrameNavigated, frame);
+ }
+ }
+
+ #onContextCreated(context: BrowsingContext): void {
+ if (
+ !this.frame(context.id) &&
+ (this.frame(context.parent ?? '') || !this.#frameTree.getMainFrame())
+ ) {
+ const frame = new BidiFrame(
+ this,
+ context,
+ this._timeoutSettings,
+ context.parent
+ );
+ this.#frameTree.addFrame(frame);
+ if (frame !== this.mainFrame()) {
+ this.emit(PageEvent.FrameAttached, frame);
+ }
+ }
+ }
+
+ #onContextDestroyed(context: BrowsingContext): void {
+ const frame = this.frame(context.id);
+
+ if (frame) {
+ if (frame === this.mainFrame()) {
+ this.emit(PageEvent.Close, undefined);
+ }
+ this.#removeFramesRecursively(frame);
+ }
+ }
+
+ #removeFramesRecursively(frame: BidiFrame): void {
+ for (const child of frame.childFrames()) {
+ this.#removeFramesRecursively(child);
+ }
+ frame[disposeSymbol]();
+ this.#networkManager.clearMapAfterFrameDispose(frame);
+ this.#frameTree.removeFrame(frame);
+ this.emit(PageEvent.FrameDetached, frame);
+ }
+
+ #onLogEntryAdded(event: Bidi.Log.Entry): void {
+ const frame = this.frame(event.source.context);
+ if (!frame) {
+ return;
+ }
+ if (isConsoleLogEntry(event)) {
+ const args = event.args.map(arg => {
+ return createBidiHandle(frame.mainRealm(), arg);
+ });
+
+ const text = args
+ .reduce((value, arg) => {
+ const parsedValue = arg.isPrimitiveValue
+ ? BidiDeserializer.deserialize(arg.remoteValue())
+ : arg.toString();
+ return `${value} ${parsedValue}`;
+ }, '')
+ .slice(1);
+
+ this.emit(
+ PageEvent.Console,
+ new ConsoleMessage(
+ event.method as any,
+ text,
+ args,
+ getStackTraceLocations(event.stackTrace)
+ )
+ );
+ } else if (isJavaScriptLogEntry(event)) {
+ const error = new Error(event.text ?? '');
+
+ const messageHeight = error.message.split('\n').length;
+ const messageLines = error.stack!.split('\n').splice(0, messageHeight);
+
+ const stackLines = [];
+ if (event.stackTrace) {
+ for (const frame of event.stackTrace.callFrames) {
+ // Note we need to add `1` because the values are 0-indexed.
+ stackLines.push(
+ ` at ${frame.functionName || '<anonymous>'} (${frame.url}:${
+ frame.lineNumber + 1
+ }:${frame.columnNumber + 1})`
+ );
+ if (stackLines.length >= Error.stackTraceLimit) {
+ break;
+ }
+ }
+ }
+
+ error.stack = [...messageLines, ...stackLines].join('\n');
+ this.emit(PageEvent.PageError, error);
+ } else {
+ debugError(
+ `Unhandled LogEntry with type "${event.type}", text "${event.text}" and level "${event.level}"`
+ );
+ }
+ }
+
+ #onDialog(event: Bidi.BrowsingContext.UserPromptOpenedParameters): void {
+ const frame = this.frame(event.context);
+ if (!frame) {
+ return;
+ }
+ const type = validateDialogType(event.type);
+
+ const dialog = new BidiDialog(
+ frame.context(),
+ type,
+ event.message,
+ event.defaultValue
+ );
+ this.emit(PageEvent.Dialog, dialog);
+ }
+
+ getNavigationResponse(id?: string | null): BidiHTTPResponse | null {
+ return this.#networkManager.getNavigationResponse(id);
+ }
+
+ override isClosed(): boolean {
+ return this.#closedDeferred.finished();
+ }
+
+ override async close(options?: {runBeforeUnload?: boolean}): Promise<void> {
+ if (this.#closedDeferred.finished()) {
+ return;
+ }
+
+ this.#closedDeferred.reject(new TargetCloseError('Page closed!'));
+ this.#networkManager.dispose();
+
+ await this.#connection.send('browsingContext.close', {
+ context: this.mainFrame()._id,
+ promptUnload: options?.runBeforeUnload ?? false,
+ });
+
+ this.emit(PageEvent.Close, undefined);
+ this.removeAllListeners();
+ }
+
+ override async reload(
+ options: WaitForOptions = {}
+ ): Promise<BidiHTTPResponse | null> {
+ const {
+ waitUntil = 'load',
+ timeout: ms = this._timeoutSettings.navigationTimeout(),
+ } = options;
+
+ const [readiness, networkIdle] = getBiDiReadinessState(waitUntil);
+
+ const result$ = zip(
+ from(
+ this.#connection.send('browsingContext.reload', {
+ context: this.mainFrame()._id,
+ wait: readiness,
+ })
+ ),
+ ...(networkIdle !== null
+ ? [
+ this.waitForNetworkIdle$({
+ timeout: ms,
+ concurrency: networkIdle === 'networkidle2' ? 2 : 0,
+ idleTime: NETWORK_IDLE_TIME,
+ }),
+ ]
+ : [])
+ ).pipe(
+ map(([{result}]) => {
+ return result;
+ }),
+ raceWith(timeout(ms), from(this.#closedDeferred.valueOrThrow())),
+ rewriteNavigationError(this.url(), ms)
+ );
+
+ const result = await firstValueFrom(result$);
+ return this.getNavigationResponse(result.navigation);
+ }
+
+ override setDefaultNavigationTimeout(timeout: number): void {
+ this._timeoutSettings.setDefaultNavigationTimeout(timeout);
+ }
+
+ override setDefaultTimeout(timeout: number): void {
+ this._timeoutSettings.setDefaultTimeout(timeout);
+ }
+
+ override getDefaultTimeout(): number {
+ return this._timeoutSettings.timeout();
+ }
+
+ override isJavaScriptEnabled(): boolean {
+ return this.#cdpEmulationManager.javascriptEnabled;
+ }
+
+ override async setGeolocation(options: GeolocationOptions): Promise<void> {
+ return await this.#cdpEmulationManager.setGeolocation(options);
+ }
+
+ override async setJavaScriptEnabled(enabled: boolean): Promise<void> {
+ return await this.#cdpEmulationManager.setJavaScriptEnabled(enabled);
+ }
+
+ override async emulateMediaType(type?: string): Promise<void> {
+ return await this.#cdpEmulationManager.emulateMediaType(type);
+ }
+
+ override async emulateCPUThrottling(factor: number | null): Promise<void> {
+ return await this.#cdpEmulationManager.emulateCPUThrottling(factor);
+ }
+
+ override async emulateMediaFeatures(
+ features?: MediaFeature[]
+ ): Promise<void> {
+ return await this.#cdpEmulationManager.emulateMediaFeatures(features);
+ }
+
+ override async emulateTimezone(timezoneId?: string): Promise<void> {
+ return await this.#cdpEmulationManager.emulateTimezone(timezoneId);
+ }
+
+ override async emulateIdleState(overrides?: {
+ isUserActive: boolean;
+ isScreenUnlocked: boolean;
+ }): Promise<void> {
+ return await this.#cdpEmulationManager.emulateIdleState(overrides);
+ }
+
+ override async emulateVisionDeficiency(
+ type?: Protocol.Emulation.SetEmulatedVisionDeficiencyRequest['type']
+ ): Promise<void> {
+ return await this.#cdpEmulationManager.emulateVisionDeficiency(type);
+ }
+
+ override async setViewport(viewport: Viewport): Promise<void> {
+ if (!this.#browsingContext.supportsCdp()) {
+ await this.#emulationManager.emulateViewport(viewport);
+ this.#viewport = viewport;
+ return;
+ }
+ const needsReload =
+ await this.#cdpEmulationManager.emulateViewport(viewport);
+ this.#viewport = viewport;
+ if (needsReload) {
+ await this.reload();
+ }
+ }
+
+ override viewport(): Viewport | null {
+ return this.#viewport;
+ }
+
+ override async pdf(options: PDFOptions = {}): Promise<Buffer> {
+ const {timeout: ms = this._timeoutSettings.timeout(), path = undefined} =
+ options;
+ const {
+ printBackground: background,
+ margin,
+ landscape,
+ width,
+ height,
+ pageRanges: ranges,
+ scale,
+ preferCSSPageSize,
+ } = parsePDFOptions(options, 'cm');
+ const pageRanges = ranges ? ranges.split(', ') : [];
+ const {result} = await firstValueFrom(
+ from(
+ this.#connection.send('browsingContext.print', {
+ context: this.mainFrame()._id,
+ background,
+ margin,
+ orientation: landscape ? 'landscape' : 'portrait',
+ page: {
+ width,
+ height,
+ },
+ pageRanges,
+ scale,
+ shrinkToFit: !preferCSSPageSize,
+ })
+ ).pipe(raceWith(timeout(ms)))
+ );
+
+ const buffer = Buffer.from(result.data, 'base64');
+
+ await this._maybeWriteBufferToFile(path, buffer);
+
+ return buffer;
+ }
+
+ override async createPDFStream(
+ options?: PDFOptions | undefined
+ ): Promise<Readable> {
+ const buffer = await this.pdf(options);
+ try {
+ const {Readable} = await import('stream');
+ return Readable.from(buffer);
+ } catch (error) {
+ if (error instanceof TypeError) {
+ throw new Error(
+ 'Can only pass a file path in a Node-like environment.'
+ );
+ }
+ throw error;
+ }
+ }
+
+ override async _screenshot(
+ options: Readonly<ScreenshotOptions>
+ ): Promise<string> {
+ const {clip, type, captureBeyondViewport, quality} = options;
+ if (options.omitBackground !== undefined && options.omitBackground) {
+ throw new UnsupportedOperation(`BiDi does not support 'omitBackground'.`);
+ }
+ if (options.optimizeForSpeed !== undefined && options.optimizeForSpeed) {
+ throw new UnsupportedOperation(
+ `BiDi does not support 'optimizeForSpeed'.`
+ );
+ }
+ if (options.fromSurface !== undefined && !options.fromSurface) {
+ throw new UnsupportedOperation(`BiDi does not support 'fromSurface'.`);
+ }
+ if (clip !== undefined && clip.scale !== undefined && clip.scale !== 1) {
+ throw new UnsupportedOperation(
+ `BiDi does not support 'scale' in 'clip'.`
+ );
+ }
+
+ let box: BoundingBox | undefined;
+ if (clip) {
+ if (captureBeyondViewport) {
+ box = clip;
+ } else {
+ // The clip is always with respect to the document coordinates, so we
+ // need to convert this to viewport coordinates when we aren't capturing
+ // beyond the viewport.
+ const [pageLeft, pageTop] = await this.evaluate(() => {
+ if (!window.visualViewport) {
+ throw new Error('window.visualViewport is not supported.');
+ }
+ return [
+ window.visualViewport.pageLeft,
+ window.visualViewport.pageTop,
+ ] as const;
+ });
+ box = {
+ ...clip,
+ x: clip.x - pageLeft,
+ y: clip.y - pageTop,
+ };
+ }
+ }
+
+ const {
+ result: {data},
+ } = await this.#connection.send('browsingContext.captureScreenshot', {
+ context: this.mainFrame()._id,
+ origin: captureBeyondViewport ? 'document' : 'viewport',
+ format: {
+ type: `image/${type}`,
+ ...(quality !== undefined ? {quality: quality / 100} : {}),
+ },
+ ...(box ? {clip: {type: 'box', ...box}} : {}),
+ });
+ return data;
+ }
+
+ override async createCDPSession(): Promise<CDPSession> {
+ const {sessionId} = await this.mainFrame()
+ .context()
+ .cdpSession.send('Target.attachToTarget', {
+ targetId: this.mainFrame()._id,
+ flatten: true,
+ });
+ return new CdpSessionWrapper(this.mainFrame().context(), sessionId);
+ }
+
+ override async bringToFront(): Promise<void> {
+ await this.#connection.send('browsingContext.activate', {
+ context: this.mainFrame()._id,
+ });
+ }
+
+ override async evaluateOnNewDocument<
+ Params extends unknown[],
+ Func extends (...args: Params) => unknown = (...args: Params) => unknown,
+ >(
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<NewDocumentScriptEvaluation> {
+ const expression = evaluationExpression(pageFunction, ...args);
+ const {result} = await this.#connection.send('script.addPreloadScript', {
+ functionDeclaration: expression,
+ contexts: [this.mainFrame()._id],
+ });
+
+ return {identifier: result.script};
+ }
+
+ override async removeScriptToEvaluateOnNewDocument(
+ id: string
+ ): Promise<void> {
+ await this.#connection.send('script.removePreloadScript', {
+ script: id,
+ });
+ }
+
+ override async exposeFunction<Args extends unknown[], Ret>(
+ name: string,
+ pptrFunction:
+ | ((...args: Args) => Awaitable<Ret>)
+ | {default: (...args: Args) => Awaitable<Ret>}
+ ): Promise<void> {
+ return await this.mainFrame().exposeFunction(
+ name,
+ 'default' in pptrFunction ? pptrFunction.default : pptrFunction
+ );
+ }
+
+ override isDragInterceptionEnabled(): boolean {
+ return false;
+ }
+
+ override async setCacheEnabled(enabled?: boolean): Promise<void> {
+ // TODO: handle CDP-specific cases such as mprach.
+ await this._client().send('Network.setCacheDisabled', {
+ cacheDisabled: !enabled,
+ });
+ }
+
+ override isServiceWorkerBypassed(): never {
+ throw new UnsupportedOperation();
+ }
+
+ override target(): BiDiPageTarget {
+ return this.#target;
+ }
+
+ override waitForFileChooser(): never {
+ throw new UnsupportedOperation();
+ }
+
+ override workers(): never {
+ throw new UnsupportedOperation();
+ }
+
+ override setRequestInterception(): never {
+ throw new UnsupportedOperation();
+ }
+
+ override setDragInterception(): never {
+ throw new UnsupportedOperation();
+ }
+
+ override setBypassServiceWorker(): never {
+ throw new UnsupportedOperation();
+ }
+
+ override setOfflineMode(): never {
+ throw new UnsupportedOperation();
+ }
+
+ override emulateNetworkConditions(): never {
+ throw new UnsupportedOperation();
+ }
+
+ override cookies(): never {
+ throw new UnsupportedOperation();
+ }
+
+ override setCookie(): never {
+ throw new UnsupportedOperation();
+ }
+
+ override deleteCookie(): never {
+ throw new UnsupportedOperation();
+ }
+
+ override removeExposedFunction(): never {
+ // TODO: Quick win?
+ throw new UnsupportedOperation();
+ }
+
+ override authenticate(): never {
+ throw new UnsupportedOperation();
+ }
+
+ override setExtraHTTPHeaders(): never {
+ throw new UnsupportedOperation();
+ }
+
+ override metrics(): never {
+ throw new UnsupportedOperation();
+ }
+
+ override async goBack(
+ options: WaitForOptions = {}
+ ): Promise<HTTPResponse | null> {
+ return await this.#go(-1, options);
+ }
+
+ override async goForward(
+ options: WaitForOptions = {}
+ ): Promise<HTTPResponse | null> {
+ return await this.#go(+1, options);
+ }
+
+ async #go(
+ delta: number,
+ options: WaitForOptions
+ ): Promise<HTTPResponse | null> {
+ try {
+ const result = await Promise.all([
+ this.waitForNavigation(options),
+ this.#connection.send('browsingContext.traverseHistory', {
+ delta,
+ context: this.mainFrame()._id,
+ }),
+ ]);
+ return result[0];
+ } catch (err) {
+ // TODO: waitForNavigation should be cancelled if an error happens.
+ if (isErrorLike(err)) {
+ if (err.message.includes('no such history entry')) {
+ return null;
+ }
+ }
+ throw err;
+ }
+ }
+
+ override waitForDevicePrompt(): never {
+ throw new UnsupportedOperation();
+ }
+}
+
+function isConsoleLogEntry(
+ event: Bidi.Log.Entry
+): event is Bidi.Log.ConsoleLogEntry {
+ return event.type === 'console';
+}
+
+function isJavaScriptLogEntry(
+ event: Bidi.Log.Entry
+): event is Bidi.Log.JavascriptLogEntry {
+ return event.type === 'javascript';
+}
+
+function getStackTraceLocations(
+ stackTrace?: Bidi.Script.StackTrace
+): ConsoleMessageLocation[] {
+ const stackTraceLocations: ConsoleMessageLocation[] = [];
+ if (stackTrace) {
+ for (const callFrame of stackTrace.callFrames) {
+ stackTraceLocations.push({
+ url: callFrame.url,
+ lineNumber: callFrame.lineNumber,
+ columnNumber: callFrame.columnNumber,
+ });
+ }
+ }
+ return stackTraceLocations;
+}
+
+function evaluationExpression(fun: Function | string, ...args: unknown[]) {
+ return `() => {${evaluationString(fun, ...args)}}`;
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Realm.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Realm.ts
new file mode 100644
index 0000000000..84f13bc703
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Realm.ts
@@ -0,0 +1,228 @@
+import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
+
+import {EventEmitter, type EventType} from '../common/EventEmitter.js';
+import {scriptInjector} from '../common/ScriptInjector.js';
+import type {EvaluateFunc, HandleFor} from '../common/types.js';
+import {
+ PuppeteerURL,
+ SOURCE_URL_REGEX,
+ getSourcePuppeteerURLIfAvailable,
+ getSourceUrlComment,
+ isString,
+} from '../common/util.js';
+import type PuppeteerUtil from '../injected/injected.js';
+import {disposeSymbol} from '../util/disposable.js';
+import {stringifyFunction} from '../util/Function.js';
+
+import type {BidiConnection} from './Connection.js';
+import {BidiDeserializer} from './Deserializer.js';
+import {BidiElementHandle} from './ElementHandle.js';
+import {BidiJSHandle} from './JSHandle.js';
+import type {Sandbox} from './Sandbox.js';
+import {BidiSerializer} from './Serializer.js';
+import {createEvaluationError} from './util.js';
+
+/**
+ * @internal
+ */
+export class BidiRealm extends EventEmitter<Record<EventType, any>> {
+ readonly connection: BidiConnection;
+
+ #id!: string;
+ #sandbox!: Sandbox;
+
+ constructor(connection: BidiConnection) {
+ super();
+ this.connection = connection;
+ }
+
+ get target(): Bidi.Script.Target {
+ return {
+ context: this.#sandbox.environment._id,
+ sandbox: this.#sandbox.name,
+ };
+ }
+
+ handleRealmDestroyed = async (
+ params: Bidi.Script.RealmDestroyed['params']
+ ): Promise<void> => {
+ if (params.realm === this.#id) {
+ // Note: The Realm is destroyed, so in theory the handle should be as
+ // well.
+ this.internalPuppeteerUtil = undefined;
+ this.#sandbox.environment.clearDocumentHandle();
+ }
+ };
+
+ handleRealmCreated = (params: Bidi.Script.RealmCreated['params']): void => {
+ if (
+ params.type === 'window' &&
+ params.context === this.#sandbox.environment._id &&
+ params.sandbox === this.#sandbox.name
+ ) {
+ this.#id = params.realm;
+ void this.#sandbox.taskManager.rerunAll();
+ }
+ };
+
+ setSandbox(sandbox: Sandbox): void {
+ this.#sandbox = sandbox;
+ this.connection.on(
+ Bidi.ChromiumBidi.Script.EventNames.RealmCreated,
+ this.handleRealmCreated
+ );
+ this.connection.on(
+ Bidi.ChromiumBidi.Script.EventNames.RealmDestroyed,
+ this.handleRealmDestroyed
+ );
+ }
+
+ protected internalPuppeteerUtil?: Promise<BidiJSHandle<PuppeteerUtil>>;
+ get puppeteerUtil(): Promise<BidiJSHandle<PuppeteerUtil>> {
+ const promise = Promise.resolve() as Promise<unknown>;
+ scriptInjector.inject(script => {
+ if (this.internalPuppeteerUtil) {
+ void this.internalPuppeteerUtil.then(handle => {
+ void handle.dispose();
+ });
+ }
+ this.internalPuppeteerUtil = promise.then(() => {
+ return this.evaluateHandle(script) as Promise<
+ BidiJSHandle<PuppeteerUtil>
+ >;
+ });
+ }, !this.internalPuppeteerUtil);
+ return this.internalPuppeteerUtil as Promise<BidiJSHandle<PuppeteerUtil>>;
+ }
+
+ async evaluateHandle<
+ Params extends unknown[],
+ Func extends EvaluateFunc<Params> = EvaluateFunc<Params>,
+ >(
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<HandleFor<Awaited<ReturnType<Func>>>> {
+ return await this.#evaluate(false, pageFunction, ...args);
+ }
+
+ async evaluate<
+ Params extends unknown[],
+ Func extends EvaluateFunc<Params> = EvaluateFunc<Params>,
+ >(
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<Awaited<ReturnType<Func>>> {
+ return await this.#evaluate(true, pageFunction, ...args);
+ }
+
+ async #evaluate<
+ Params extends unknown[],
+ Func extends EvaluateFunc<Params> = EvaluateFunc<Params>,
+ >(
+ returnByValue: true,
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<Awaited<ReturnType<Func>>>;
+ async #evaluate<
+ Params extends unknown[],
+ Func extends EvaluateFunc<Params> = EvaluateFunc<Params>,
+ >(
+ returnByValue: false,
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<HandleFor<Awaited<ReturnType<Func>>>>;
+ async #evaluate<
+ Params extends unknown[],
+ Func extends EvaluateFunc<Params> = EvaluateFunc<Params>,
+ >(
+ returnByValue: boolean,
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<HandleFor<Awaited<ReturnType<Func>>> | Awaited<ReturnType<Func>>> {
+ const sourceUrlComment = getSourceUrlComment(
+ getSourcePuppeteerURLIfAvailable(pageFunction)?.toString() ??
+ PuppeteerURL.INTERNAL_URL
+ );
+
+ const sandbox = this.#sandbox;
+
+ let responsePromise;
+ const resultOwnership = returnByValue
+ ? Bidi.Script.ResultOwnership.None
+ : Bidi.Script.ResultOwnership.Root;
+ const serializationOptions: Bidi.Script.SerializationOptions = returnByValue
+ ? {}
+ : {
+ maxObjectDepth: 0,
+ maxDomDepth: 0,
+ };
+ if (isString(pageFunction)) {
+ const expression = SOURCE_URL_REGEX.test(pageFunction)
+ ? pageFunction
+ : `${pageFunction}\n${sourceUrlComment}\n`;
+
+ responsePromise = this.connection.send('script.evaluate', {
+ expression,
+ target: this.target,
+ resultOwnership,
+ awaitPromise: true,
+ userActivation: true,
+ serializationOptions,
+ });
+ } else {
+ let functionDeclaration = stringifyFunction(pageFunction);
+ functionDeclaration = SOURCE_URL_REGEX.test(functionDeclaration)
+ ? functionDeclaration
+ : `${functionDeclaration}\n${sourceUrlComment}\n`;
+ responsePromise = this.connection.send('script.callFunction', {
+ functionDeclaration,
+ arguments: args.length
+ ? await Promise.all(
+ args.map(arg => {
+ return BidiSerializer.serialize(sandbox, arg);
+ })
+ )
+ : [],
+ target: this.target,
+ resultOwnership,
+ awaitPromise: true,
+ userActivation: true,
+ serializationOptions,
+ });
+ }
+
+ const {result} = await responsePromise;
+
+ if ('type' in result && result.type === 'exception') {
+ throw createEvaluationError(result.exceptionDetails);
+ }
+
+ return returnByValue
+ ? BidiDeserializer.deserialize(result.result)
+ : createBidiHandle(sandbox, result.result);
+ }
+
+ [disposeSymbol](): void {
+ this.connection.off(
+ Bidi.ChromiumBidi.Script.EventNames.RealmCreated,
+ this.handleRealmCreated
+ );
+ this.connection.off(
+ Bidi.ChromiumBidi.Script.EventNames.RealmDestroyed,
+ this.handleRealmDestroyed
+ );
+ }
+}
+
+/**
+ * @internal
+ */
+export function createBidiHandle(
+ sandbox: Sandbox,
+ result: Bidi.Script.RemoteValue
+): BidiJSHandle<unknown> | BidiElementHandle<Node> {
+ if (result.type === 'node' || result.type === 'window') {
+ return new BidiElementHandle(sandbox, result);
+ }
+ return new BidiJSHandle(sandbox, result);
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Sandbox.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Sandbox.ts
new file mode 100644
index 0000000000..4411b3dbcd
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Sandbox.ts
@@ -0,0 +1,123 @@
+/**
+ * @license
+ * Copyright 2023 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {JSHandle} from '../api/JSHandle.js';
+import {Realm} from '../api/Realm.js';
+import type {TimeoutSettings} from '../common/TimeoutSettings.js';
+import type {EvaluateFunc, HandleFor} from '../common/types.js';
+import {withSourcePuppeteerURLIfNone} from '../common/util.js';
+
+import type {BrowsingContext} from './BrowsingContext.js';
+import {BidiElementHandle} from './ElementHandle.js';
+import type {BidiFrame} from './Frame.js';
+import type {BidiRealm as BidiRealm} from './Realm.js';
+/**
+ * A unique key for {@link SandboxChart} to denote the default world.
+ * Realms are automatically created in the default sandbox.
+ *
+ * @internal
+ */
+export const MAIN_SANDBOX = Symbol('mainSandbox');
+/**
+ * A unique key for {@link SandboxChart} to denote the puppeteer sandbox.
+ * This world contains all puppeteer-internal bindings/code.
+ *
+ * @internal
+ */
+export const PUPPETEER_SANDBOX = Symbol('puppeteerSandbox');
+
+/**
+ * @internal
+ */
+export interface SandboxChart {
+ [key: string]: Sandbox;
+ [MAIN_SANDBOX]: Sandbox;
+ [PUPPETEER_SANDBOX]: Sandbox;
+}
+
+/**
+ * @internal
+ */
+export class Sandbox extends Realm {
+ readonly name: string | undefined;
+ readonly realm: BidiRealm;
+ #frame: BidiFrame;
+
+ constructor(
+ name: string | undefined,
+ frame: BidiFrame,
+ // TODO: We should split the Realm and BrowsingContext
+ realm: BidiRealm | BrowsingContext,
+ timeoutSettings: TimeoutSettings
+ ) {
+ super(timeoutSettings);
+ this.name = name;
+ this.realm = realm;
+ this.#frame = frame;
+ this.realm.setSandbox(this);
+ }
+
+ override get environment(): BidiFrame {
+ return this.#frame;
+ }
+
+ async evaluateHandle<
+ Params extends unknown[],
+ Func extends EvaluateFunc<Params> = EvaluateFunc<Params>,
+ >(
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<HandleFor<Awaited<ReturnType<Func>>>> {
+ pageFunction = withSourcePuppeteerURLIfNone(
+ this.evaluateHandle.name,
+ pageFunction
+ );
+ return await this.realm.evaluateHandle(pageFunction, ...args);
+ }
+
+ async evaluate<
+ Params extends unknown[],
+ Func extends EvaluateFunc<Params> = EvaluateFunc<Params>,
+ >(
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<Awaited<ReturnType<Func>>> {
+ pageFunction = withSourcePuppeteerURLIfNone(
+ this.evaluate.name,
+ pageFunction
+ );
+ return await this.realm.evaluate(pageFunction, ...args);
+ }
+
+ async adoptHandle<T extends JSHandle<Node>>(handle: T): Promise<T> {
+ return (await this.evaluateHandle(node => {
+ return node;
+ }, handle)) as unknown as T;
+ }
+
+ async transferHandle<T extends JSHandle<Node>>(handle: T): Promise<T> {
+ if (handle.realm === this) {
+ return handle;
+ }
+ const transferredHandle = await this.evaluateHandle(node => {
+ return node;
+ }, handle);
+ await handle.dispose();
+ return transferredHandle as unknown as T;
+ }
+
+ override async adoptBackendNode(
+ backendNodeId?: number
+ ): Promise<JSHandle<Node>> {
+ const {object} = await this.environment.client.send('DOM.resolveNode', {
+ backendNodeId: backendNodeId,
+ });
+ return new BidiElementHandle(this, {
+ handle: object.objectId,
+ type: 'node',
+ });
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Serializer.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Serializer.ts
new file mode 100644
index 0000000000..c147ec9281
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Serializer.ts
@@ -0,0 +1,164 @@
+/**
+ * @license
+ * Copyright 2023 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
+
+import {LazyArg} from '../common/LazyArg.js';
+import {isDate, isPlainObject, isRegExp} from '../common/util.js';
+
+import {BidiElementHandle} from './ElementHandle.js';
+import {BidiJSHandle} from './JSHandle.js';
+import type {Sandbox} from './Sandbox.js';
+
+/**
+ * @internal
+ */
+class UnserializableError extends Error {}
+
+/**
+ * @internal
+ */
+export class BidiSerializer {
+ static serializeNumber(arg: number): Bidi.Script.LocalValue {
+ let value: Bidi.Script.SpecialNumber | number;
+ if (Object.is(arg, -0)) {
+ value = '-0';
+ } else if (Object.is(arg, Infinity)) {
+ value = 'Infinity';
+ } else if (Object.is(arg, -Infinity)) {
+ value = '-Infinity';
+ } else if (Object.is(arg, NaN)) {
+ value = 'NaN';
+ } else {
+ value = arg;
+ }
+ return {
+ type: 'number',
+ value,
+ };
+ }
+
+ static serializeObject(arg: object | null): Bidi.Script.LocalValue {
+ if (arg === null) {
+ return {
+ type: 'null',
+ };
+ } else if (Array.isArray(arg)) {
+ const parsedArray = arg.map(subArg => {
+ return BidiSerializer.serializeRemoteValue(subArg);
+ });
+
+ return {
+ type: 'array',
+ value: parsedArray,
+ };
+ } else if (isPlainObject(arg)) {
+ try {
+ JSON.stringify(arg);
+ } catch (error) {
+ if (
+ error instanceof TypeError &&
+ error.message.startsWith('Converting circular structure to JSON')
+ ) {
+ error.message += ' Recursive objects are not allowed.';
+ }
+ throw error;
+ }
+
+ const parsedObject: Bidi.Script.MappingLocalValue = [];
+ for (const key in arg) {
+ parsedObject.push([
+ BidiSerializer.serializeRemoteValue(key),
+ BidiSerializer.serializeRemoteValue(arg[key]),
+ ]);
+ }
+
+ return {
+ type: 'object',
+ value: parsedObject,
+ };
+ } else if (isRegExp(arg)) {
+ return {
+ type: 'regexp',
+ value: {
+ pattern: arg.source,
+ flags: arg.flags,
+ },
+ };
+ } else if (isDate(arg)) {
+ return {
+ type: 'date',
+ value: arg.toISOString(),
+ };
+ }
+
+ throw new UnserializableError(
+ 'Custom object sterilization not possible. Use plain objects instead.'
+ );
+ }
+
+ static serializeRemoteValue(arg: unknown): Bidi.Script.LocalValue {
+ switch (typeof arg) {
+ case 'symbol':
+ case 'function':
+ throw new UnserializableError(`Unable to serializable ${typeof arg}`);
+ case 'object':
+ return BidiSerializer.serializeObject(arg);
+
+ case 'undefined':
+ return {
+ type: 'undefined',
+ };
+ case 'number':
+ return BidiSerializer.serializeNumber(arg);
+ case 'bigint':
+ return {
+ type: 'bigint',
+ value: arg.toString(),
+ };
+ case 'string':
+ return {
+ type: 'string',
+ value: arg,
+ };
+ case 'boolean':
+ return {
+ type: 'boolean',
+ value: arg,
+ };
+ }
+ }
+
+ static async serialize(
+ sandbox: Sandbox,
+ arg: unknown
+ ): Promise<Bidi.Script.LocalValue> {
+ if (arg instanceof LazyArg) {
+ arg = await arg.get(sandbox.realm);
+ }
+ // eslint-disable-next-line rulesdir/use-using -- We want this to continue living.
+ const objectHandle =
+ arg && (arg instanceof BidiJSHandle || arg instanceof BidiElementHandle)
+ ? arg
+ : null;
+ if (objectHandle) {
+ if (
+ objectHandle.realm.environment.context() !==
+ sandbox.environment.context()
+ ) {
+ throw new Error(
+ 'JSHandles can be evaluated only in the context they were created!'
+ );
+ }
+ if (objectHandle.disposed) {
+ throw new Error('JSHandle is disposed!');
+ }
+ return objectHandle.remoteValue() as Bidi.Script.RemoteReference;
+ }
+
+ return BidiSerializer.serializeRemoteValue(arg);
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Target.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Target.ts
new file mode 100644
index 0000000000..fb01c34638
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/Target.ts
@@ -0,0 +1,151 @@
+/**
+ * @license
+ * Copyright 2023 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {CDPSession} from '../api/CDPSession.js';
+import type {Page} from '../api/Page.js';
+import {Target, TargetType} from '../api/Target.js';
+import {UnsupportedOperation} from '../common/Errors.js';
+
+import type {BidiBrowser} from './Browser.js';
+import type {BidiBrowserContext} from './BrowserContext.js';
+import {type BrowsingContext, CdpSessionWrapper} from './BrowsingContext.js';
+import {BidiPage} from './Page.js';
+
+/**
+ * @internal
+ */
+export abstract class BidiTarget extends Target {
+ protected _browserContext: BidiBrowserContext;
+
+ constructor(browserContext: BidiBrowserContext) {
+ super();
+ this._browserContext = browserContext;
+ }
+
+ _setBrowserContext(browserContext: BidiBrowserContext): void {
+ this._browserContext = browserContext;
+ }
+
+ override asPage(): Promise<Page> {
+ throw new UnsupportedOperation();
+ }
+
+ override browser(): BidiBrowser {
+ return this._browserContext.browser();
+ }
+
+ override browserContext(): BidiBrowserContext {
+ return this._browserContext;
+ }
+
+ override opener(): never {
+ throw new UnsupportedOperation();
+ }
+
+ override createCDPSession(): Promise<CDPSession> {
+ throw new UnsupportedOperation();
+ }
+}
+
+/**
+ * @internal
+ */
+export class BiDiBrowserTarget extends Target {
+ #browser: BidiBrowser;
+
+ constructor(browser: BidiBrowser) {
+ super();
+ this.#browser = browser;
+ }
+
+ override url(): string {
+ return '';
+ }
+
+ override type(): TargetType {
+ return TargetType.BROWSER;
+ }
+
+ override asPage(): Promise<Page> {
+ throw new UnsupportedOperation();
+ }
+
+ override browser(): BidiBrowser {
+ return this.#browser;
+ }
+
+ override browserContext(): BidiBrowserContext {
+ return this.#browser.defaultBrowserContext();
+ }
+
+ override opener(): never {
+ throw new UnsupportedOperation();
+ }
+
+ override createCDPSession(): Promise<CDPSession> {
+ throw new UnsupportedOperation();
+ }
+}
+
+/**
+ * @internal
+ */
+export class BiDiBrowsingContextTarget extends BidiTarget {
+ protected _browsingContext: BrowsingContext;
+
+ constructor(
+ browserContext: BidiBrowserContext,
+ browsingContext: BrowsingContext
+ ) {
+ super(browserContext);
+
+ this._browsingContext = browsingContext;
+ }
+
+ override url(): string {
+ return this._browsingContext.url;
+ }
+
+ override async createCDPSession(): Promise<CDPSession> {
+ const {sessionId} = await this._browsingContext.cdpSession.send(
+ 'Target.attachToTarget',
+ {
+ targetId: this._browsingContext.id,
+ flatten: true,
+ }
+ );
+ return new CdpSessionWrapper(this._browsingContext, sessionId);
+ }
+
+ override type(): TargetType {
+ return TargetType.PAGE;
+ }
+}
+
+/**
+ * @internal
+ */
+export class BiDiPageTarget extends BiDiBrowsingContextTarget {
+ #page: BidiPage;
+
+ constructor(
+ browserContext: BidiBrowserContext,
+ browsingContext: BrowsingContext
+ ) {
+ super(browserContext, browsingContext);
+
+ this.#page = new BidiPage(browsingContext, browserContext, this);
+ }
+
+ override async page(): Promise<BidiPage> {
+ return this.#page;
+ }
+
+ override _setBrowserContext(browserContext: BidiBrowserContext): void {
+ super._setBrowserContext(browserContext);
+ this.#page._setBrowserContext(browserContext);
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/bidi.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/bidi.ts
new file mode 100644
index 0000000000..373d6d999c
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/bidi.ts
@@ -0,0 +1,22 @@
+/**
+ * @license
+ * Copyright 2022 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+export * from './BidiOverCdp.js';
+export * from './Browser.js';
+export * from './BrowserContext.js';
+export * from './BrowsingContext.js';
+export * from './Connection.js';
+export * from './ElementHandle.js';
+export * from './Frame.js';
+export * from './HTTPRequest.js';
+export * from './HTTPResponse.js';
+export * from './Input.js';
+export * from './JSHandle.js';
+export * from './NetworkManager.js';
+export * from './Page.js';
+export * from './Realm.js';
+export * from './Sandbox.js';
+export * from './Target.js';
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Browser.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Browser.ts
new file mode 100644
index 0000000000..7c4a8ed01c
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Browser.ts
@@ -0,0 +1,225 @@
+/**
+ * @license
+ * Copyright 2024 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
+
+import {EventEmitter} from '../../common/EventEmitter.js';
+import {inertIfDisposed, throwIfDisposed} from '../../util/decorators.js';
+import {DisposableStack, disposeSymbol} from '../../util/disposable.js';
+
+import type {BrowsingContext} from './BrowsingContext.js';
+import type {SharedWorkerRealm} from './Realm.js';
+import type {Session} from './Session.js';
+import {UserContext} from './UserContext.js';
+
+/**
+ * @internal
+ */
+export type AddPreloadScriptOptions = Omit<
+ Bidi.Script.AddPreloadScriptParameters,
+ 'functionDeclaration' | 'contexts'
+> & {
+ contexts?: [BrowsingContext, ...BrowsingContext[]];
+};
+
+/**
+ * @internal
+ */
+export class Browser extends EventEmitter<{
+ /** Emitted before the browser closes. */
+ closed: {
+ /** The reason for closing the browser. */
+ reason: string;
+ };
+ /** Emitted after the browser disconnects. */
+ disconnected: {
+ /** The reason for disconnecting the browser. */
+ reason: string;
+ };
+ /** Emitted when a shared worker is created. */
+ sharedworker: {
+ /** The realm of the shared worker. */
+ realm: SharedWorkerRealm;
+ };
+}> {
+ static async from(session: Session): Promise<Browser> {
+ const browser = new Browser(session);
+ await browser.#initialize();
+ return browser;
+ }
+
+ // keep-sorted start
+ #closed = false;
+ #reason: string | undefined;
+ readonly #disposables = new DisposableStack();
+ readonly #userContexts = new Map<string, UserContext>();
+ readonly session: Session;
+ // keep-sorted end
+
+ private constructor(session: Session) {
+ super();
+ // keep-sorted start
+ this.session = session;
+ // keep-sorted end
+
+ this.#userContexts.set(
+ UserContext.DEFAULT,
+ UserContext.create(this, UserContext.DEFAULT)
+ );
+ }
+
+ async #initialize() {
+ const sessionEmitter = this.#disposables.use(
+ new EventEmitter(this.session)
+ );
+ sessionEmitter.once('ended', ({reason}) => {
+ this.dispose(reason);
+ });
+
+ sessionEmitter.on('script.realmCreated', info => {
+ if (info.type === 'shared-worker') {
+ // TODO: Create a SharedWorkerRealm.
+ }
+ });
+
+ await this.#syncBrowsingContexts();
+ }
+
+ async #syncBrowsingContexts() {
+ // In case contexts are created or destroyed during `getTree`, we use this
+ // set to detect them.
+ const contextIds = new Set<string>();
+ let contexts: Bidi.BrowsingContext.Info[];
+
+ {
+ using sessionEmitter = new EventEmitter(this.session);
+ sessionEmitter.on('browsingContext.contextCreated', info => {
+ contextIds.add(info.context);
+ });
+ sessionEmitter.on('browsingContext.contextDestroyed', info => {
+ contextIds.delete(info.context);
+ });
+ const {result} = await this.session.send('browsingContext.getTree', {});
+ contexts = result.contexts;
+ }
+
+ // Simulating events so contexts are created naturally.
+ for (const info of contexts) {
+ if (contextIds.has(info.context)) {
+ this.session.emit('browsingContext.contextCreated', info);
+ }
+ if (info.children) {
+ contexts.push(...info.children);
+ }
+ }
+ }
+
+ // keep-sorted start block=yes
+ get closed(): boolean {
+ return this.#closed;
+ }
+ get defaultUserContext(): UserContext {
+ // SAFETY: A UserContext is always created for the default context.
+ return this.#userContexts.get(UserContext.DEFAULT)!;
+ }
+ get disconnected(): boolean {
+ return this.#reason !== undefined;
+ }
+ get disposed(): boolean {
+ return this.disconnected;
+ }
+ get userContexts(): Iterable<UserContext> {
+ return this.#userContexts.values();
+ }
+ // keep-sorted end
+
+ @inertIfDisposed
+ dispose(reason?: string, closed = false): void {
+ this.#closed = closed;
+ this.#reason = reason;
+ this[disposeSymbol]();
+ }
+
+ @throwIfDisposed<Browser>(browser => {
+ // SAFETY: By definition of `disposed`, `#reason` is defined.
+ return browser.#reason!;
+ })
+ async close(): Promise<void> {
+ try {
+ await this.session.send('browser.close', {});
+ } finally {
+ this.dispose('Browser already closed.', true);
+ }
+ }
+
+ @throwIfDisposed<Browser>(browser => {
+ // SAFETY: By definition of `disposed`, `#reason` is defined.
+ return browser.#reason!;
+ })
+ async addPreloadScript(
+ functionDeclaration: string,
+ options: AddPreloadScriptOptions = {}
+ ): Promise<string> {
+ const {
+ result: {script},
+ } = await this.session.send('script.addPreloadScript', {
+ functionDeclaration,
+ ...options,
+ contexts: options.contexts?.map(context => {
+ return context.id;
+ }) as [string, ...string[]],
+ });
+ return script;
+ }
+
+ @throwIfDisposed<Browser>(browser => {
+ // SAFETY: By definition of `disposed`, `#reason` is defined.
+ return browser.#reason!;
+ })
+ async removePreloadScript(script: string): Promise<void> {
+ await this.session.send('script.removePreloadScript', {
+ script,
+ });
+ }
+
+ static userContextId = 0;
+ @throwIfDisposed<Browser>(browser => {
+ // SAFETY: By definition of `disposed`, `#reason` is defined.
+ return browser.#reason!;
+ })
+ async createUserContext(): Promise<UserContext> {
+ // TODO: implement incognito context https://github.com/w3c/webdriver-bidi/issues/289.
+ // TODO: Call `createUserContext` once available.
+ // Generating a monotonically increasing context id.
+ const context = `${++Browser.userContextId}`;
+
+ const userContext = UserContext.create(this, context);
+ this.#userContexts.set(userContext.id, userContext);
+
+ const userContextEmitter = this.#disposables.use(
+ new EventEmitter(userContext)
+ );
+ userContextEmitter.once('closed', () => {
+ userContextEmitter.removeAllListeners();
+
+ this.#userContexts.delete(context);
+ });
+
+ return userContext;
+ }
+
+ [disposeSymbol](): void {
+ this.#reason ??=
+ 'Browser was disconnected, probably because the session ended.';
+ if (this.closed) {
+ this.emit('closed', {reason: this.#reason});
+ }
+ this.emit('disconnected', {reason: this.#reason});
+
+ this.#disposables.dispose();
+ super[disposeSymbol]();
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/BrowsingContext.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/BrowsingContext.ts
new file mode 100644
index 0000000000..9bec2a506c
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/BrowsingContext.ts
@@ -0,0 +1,475 @@
+/**
+ * @license
+ * Copyright 2024 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
+
+import {EventEmitter} from '../../common/EventEmitter.js';
+import {inertIfDisposed, throwIfDisposed} from '../../util/decorators.js';
+import {DisposableStack, disposeSymbol} from '../../util/disposable.js';
+
+import type {AddPreloadScriptOptions} from './Browser.js';
+import {Navigation} from './Navigation.js';
+import {WindowRealm} from './Realm.js';
+import {Request} from './Request.js';
+import type {UserContext} from './UserContext.js';
+import {UserPrompt} from './UserPrompt.js';
+
+/**
+ * @internal
+ */
+export type CaptureScreenshotOptions = Omit<
+ Bidi.BrowsingContext.CaptureScreenshotParameters,
+ 'context'
+>;
+
+/**
+ * @internal
+ */
+export type ReloadOptions = Omit<
+ Bidi.BrowsingContext.ReloadParameters,
+ 'context'
+>;
+
+/**
+ * @internal
+ */
+export type PrintOptions = Omit<
+ Bidi.BrowsingContext.PrintParameters,
+ 'context'
+>;
+
+/**
+ * @internal
+ */
+export type HandleUserPromptOptions = Omit<
+ Bidi.BrowsingContext.HandleUserPromptParameters,
+ 'context'
+>;
+
+/**
+ * @internal
+ */
+export type SetViewportOptions = Omit<
+ Bidi.BrowsingContext.SetViewportParameters,
+ 'context'
+>;
+
+/**
+ * @internal
+ */
+export class BrowsingContext extends EventEmitter<{
+ /** Emitted when this context is closed. */
+ closed: {
+ /** The reason the browsing context was closed */
+ reason: string;
+ };
+ /** Emitted when a child browsing context is created. */
+ browsingcontext: {
+ /** The newly created child browsing context. */
+ browsingContext: BrowsingContext;
+ };
+ /** Emitted whenever a navigation occurs. */
+ navigation: {
+ /** The navigation that occurred. */
+ navigation: Navigation;
+ };
+ /** Emitted whenever a request is made. */
+ request: {
+ /** The request that was made. */
+ request: Request;
+ };
+ /** Emitted whenever a log entry is added. */
+ log: {
+ /** Entry added to the log. */
+ entry: Bidi.Log.Entry;
+ };
+ /** Emitted whenever a prompt is opened. */
+ userprompt: {
+ /** The prompt that was opened. */
+ userPrompt: UserPrompt;
+ };
+ /** Emitted whenever the frame emits `DOMContentLoaded` */
+ DOMContentLoaded: void;
+ /** Emitted whenever the frame emits `load` */
+ load: void;
+}> {
+ static from(
+ userContext: UserContext,
+ parent: BrowsingContext | undefined,
+ id: string,
+ url: string
+ ): BrowsingContext {
+ const browsingContext = new BrowsingContext(userContext, parent, id, url);
+ browsingContext.#initialize();
+ return browsingContext;
+ }
+
+ // keep-sorted start
+ #navigation: Navigation | undefined;
+ #reason?: string;
+ #url: string;
+ readonly #children = new Map<string, BrowsingContext>();
+ readonly #disposables = new DisposableStack();
+ readonly #realms = new Map<string, WindowRealm>();
+ readonly #requests = new Map<string, Request>();
+ readonly defaultRealm: WindowRealm;
+ readonly id: string;
+ readonly parent: BrowsingContext | undefined;
+ readonly userContext: UserContext;
+ // keep-sorted end
+
+ private constructor(
+ context: UserContext,
+ parent: BrowsingContext | undefined,
+ id: string,
+ url: string
+ ) {
+ super();
+ // keep-sorted start
+ this.#url = url;
+ this.id = id;
+ this.parent = parent;
+ this.userContext = context;
+ // keep-sorted end
+
+ this.defaultRealm = WindowRealm.from(this);
+ }
+
+ #initialize() {
+ const userContextEmitter = this.#disposables.use(
+ new EventEmitter(this.userContext)
+ );
+ userContextEmitter.once('closed', ({reason}) => {
+ this.dispose(`Browsing context already closed: ${reason}`);
+ });
+
+ const sessionEmitter = this.#disposables.use(
+ new EventEmitter(this.#session)
+ );
+ sessionEmitter.on('browsingContext.contextCreated', info => {
+ if (info.parent !== this.id) {
+ return;
+ }
+
+ const browsingContext = BrowsingContext.from(
+ this.userContext,
+ this,
+ info.context,
+ info.url
+ );
+ this.#children.set(info.context, browsingContext);
+
+ const browsingContextEmitter = this.#disposables.use(
+ new EventEmitter(browsingContext)
+ );
+ browsingContextEmitter.once('closed', () => {
+ browsingContextEmitter.removeAllListeners();
+
+ this.#children.delete(browsingContext.id);
+ });
+
+ this.emit('browsingcontext', {browsingContext});
+ });
+ sessionEmitter.on('browsingContext.contextDestroyed', info => {
+ if (info.context !== this.id) {
+ return;
+ }
+ this.dispose('Browsing context already closed.');
+ });
+
+ sessionEmitter.on('browsingContext.domContentLoaded', info => {
+ if (info.context !== this.id) {
+ return;
+ }
+ this.#url = info.url;
+ this.emit('DOMContentLoaded', undefined);
+ });
+
+ sessionEmitter.on('browsingContext.load', info => {
+ if (info.context !== this.id) {
+ return;
+ }
+ this.#url = info.url;
+ this.emit('load', undefined);
+ });
+
+ sessionEmitter.on('browsingContext.navigationStarted', info => {
+ if (info.context !== this.id) {
+ return;
+ }
+ this.#url = info.url;
+
+ this.#requests.clear();
+
+ // Note the navigation ID is null for this event.
+ this.#navigation = Navigation.from(this);
+
+ const navigationEmitter = this.#disposables.use(
+ new EventEmitter(this.#navigation)
+ );
+ for (const eventName of ['fragment', 'failed', 'aborted'] as const) {
+ navigationEmitter.once(eventName, ({url}) => {
+ navigationEmitter[disposeSymbol]();
+
+ this.#url = url;
+ });
+ }
+
+ this.emit('navigation', {navigation: this.#navigation});
+ });
+ sessionEmitter.on('network.beforeRequestSent', event => {
+ if (event.context !== this.id) {
+ return;
+ }
+ if (this.#requests.has(event.request.request)) {
+ return;
+ }
+
+ const request = Request.from(this, event);
+ this.#requests.set(request.id, request);
+ this.emit('request', {request});
+ });
+
+ sessionEmitter.on('log.entryAdded', entry => {
+ if (entry.source.context !== this.id) {
+ return;
+ }
+
+ this.emit('log', {entry});
+ });
+
+ sessionEmitter.on('browsingContext.userPromptOpened', info => {
+ if (info.context !== this.id) {
+ return;
+ }
+
+ const userPrompt = UserPrompt.from(this, info);
+ this.emit('userprompt', {userPrompt});
+ });
+ }
+
+ // keep-sorted start block=yes
+ get #session() {
+ return this.userContext.browser.session;
+ }
+ get children(): Iterable<BrowsingContext> {
+ return this.#children.values();
+ }
+ get closed(): boolean {
+ return this.#reason !== undefined;
+ }
+ get disposed(): boolean {
+ return this.closed;
+ }
+ get realms(): Iterable<WindowRealm> {
+ return this.#realms.values();
+ }
+ get top(): BrowsingContext {
+ let context = this as BrowsingContext;
+ for (let {parent} = context; parent; {parent} = context) {
+ context = parent;
+ }
+ return context;
+ }
+ get url(): string {
+ return this.#url;
+ }
+ // keep-sorted end
+
+ @inertIfDisposed
+ private dispose(reason?: string): void {
+ this.#reason = reason;
+ this[disposeSymbol]();
+ }
+
+ @throwIfDisposed<BrowsingContext>(context => {
+ // SAFETY: Disposal implies this exists.
+ return context.#reason!;
+ })
+ async activate(): Promise<void> {
+ await this.#session.send('browsingContext.activate', {
+ context: this.id,
+ });
+ }
+
+ @throwIfDisposed<BrowsingContext>(context => {
+ // SAFETY: Disposal implies this exists.
+ return context.#reason!;
+ })
+ async captureScreenshot(
+ options: CaptureScreenshotOptions = {}
+ ): Promise<string> {
+ const {
+ result: {data},
+ } = await this.#session.send('browsingContext.captureScreenshot', {
+ context: this.id,
+ ...options,
+ });
+ return data;
+ }
+
+ @throwIfDisposed<BrowsingContext>(context => {
+ // SAFETY: Disposal implies this exists.
+ return context.#reason!;
+ })
+ async close(promptUnload?: boolean): Promise<void> {
+ await Promise.all(
+ [...this.#children.values()].map(async child => {
+ await child.close(promptUnload);
+ })
+ );
+ await this.#session.send('browsingContext.close', {
+ context: this.id,
+ promptUnload,
+ });
+ }
+
+ @throwIfDisposed<BrowsingContext>(context => {
+ // SAFETY: Disposal implies this exists.
+ return context.#reason!;
+ })
+ async traverseHistory(delta: number): Promise<void> {
+ await this.#session.send('browsingContext.traverseHistory', {
+ context: this.id,
+ delta,
+ });
+ }
+
+ @throwIfDisposed<BrowsingContext>(context => {
+ // SAFETY: Disposal implies this exists.
+ return context.#reason!;
+ })
+ async navigate(
+ url: string,
+ wait?: Bidi.BrowsingContext.ReadinessState
+ ): Promise<Navigation> {
+ await this.#session.send('browsingContext.navigate', {
+ context: this.id,
+ url,
+ wait,
+ });
+ return await new Promise(resolve => {
+ this.once('navigation', ({navigation}) => {
+ resolve(navigation);
+ });
+ });
+ }
+
+ @throwIfDisposed<BrowsingContext>(context => {
+ // SAFETY: Disposal implies this exists.
+ return context.#reason!;
+ })
+ async reload(options: ReloadOptions = {}): Promise<Navigation> {
+ await this.#session.send('browsingContext.reload', {
+ context: this.id,
+ ...options,
+ });
+ return await new Promise(resolve => {
+ this.once('navigation', ({navigation}) => {
+ resolve(navigation);
+ });
+ });
+ }
+
+ @throwIfDisposed<BrowsingContext>(context => {
+ // SAFETY: Disposal implies this exists.
+ return context.#reason!;
+ })
+ async print(options: PrintOptions = {}): Promise<string> {
+ const {
+ result: {data},
+ } = await this.#session.send('browsingContext.print', {
+ context: this.id,
+ ...options,
+ });
+ return data;
+ }
+
+ @throwIfDisposed<BrowsingContext>(context => {
+ // SAFETY: Disposal implies this exists.
+ return context.#reason!;
+ })
+ async handleUserPrompt(options: HandleUserPromptOptions = {}): Promise<void> {
+ await this.#session.send('browsingContext.handleUserPrompt', {
+ context: this.id,
+ ...options,
+ });
+ }
+
+ @throwIfDisposed<BrowsingContext>(context => {
+ // SAFETY: Disposal implies this exists.
+ return context.#reason!;
+ })
+ async setViewport(options: SetViewportOptions = {}): Promise<void> {
+ await this.#session.send('browsingContext.setViewport', {
+ context: this.id,
+ ...options,
+ });
+ }
+
+ @throwIfDisposed<BrowsingContext>(context => {
+ // SAFETY: Disposal implies this exists.
+ return context.#reason!;
+ })
+ async performActions(actions: Bidi.Input.SourceActions[]): Promise<void> {
+ await this.#session.send('input.performActions', {
+ context: this.id,
+ actions,
+ });
+ }
+
+ @throwIfDisposed<BrowsingContext>(context => {
+ // SAFETY: Disposal implies this exists.
+ return context.#reason!;
+ })
+ async releaseActions(): Promise<void> {
+ await this.#session.send('input.releaseActions', {
+ context: this.id,
+ });
+ }
+
+ @throwIfDisposed<BrowsingContext>(context => {
+ // SAFETY: Disposal implies this exists.
+ return context.#reason!;
+ })
+ createWindowRealm(sandbox: string): WindowRealm {
+ return WindowRealm.from(this, sandbox);
+ }
+
+ @throwIfDisposed<BrowsingContext>(context => {
+ // SAFETY: Disposal implies this exists.
+ return context.#reason!;
+ })
+ async addPreloadScript(
+ functionDeclaration: string,
+ options: AddPreloadScriptOptions = {}
+ ): Promise<string> {
+ return await this.userContext.browser.addPreloadScript(
+ functionDeclaration,
+ {
+ ...options,
+ contexts: [this, ...(options.contexts ?? [])],
+ }
+ );
+ }
+
+ @throwIfDisposed<BrowsingContext>(context => {
+ // SAFETY: Disposal implies this exists.
+ return context.#reason!;
+ })
+ async removePreloadScript(script: string): Promise<void> {
+ await this.userContext.browser.removePreloadScript(script);
+ }
+
+ [disposeSymbol](): void {
+ this.#reason ??=
+ 'Browsing context already closed, probably because the user context closed.';
+ this.emit('closed', {reason: this.#reason});
+
+ this.#disposables.dispose();
+ super[disposeSymbol]();
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Connection.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Connection.ts
new file mode 100644
index 0000000000..b9de14372b
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Connection.ts
@@ -0,0 +1,139 @@
+/**
+ * @license
+ * Copyright 2024 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
+
+import type {EventEmitter} from '../../common/EventEmitter.js';
+
+/**
+ * @internal
+ */
+export interface Commands {
+ 'script.evaluate': {
+ params: Bidi.Script.EvaluateParameters;
+ returnType: Bidi.Script.EvaluateResult;
+ };
+ 'script.callFunction': {
+ params: Bidi.Script.CallFunctionParameters;
+ returnType: Bidi.Script.EvaluateResult;
+ };
+ 'script.disown': {
+ params: Bidi.Script.DisownParameters;
+ returnType: Bidi.EmptyResult;
+ };
+ 'script.addPreloadScript': {
+ params: Bidi.Script.AddPreloadScriptParameters;
+ returnType: Bidi.Script.AddPreloadScriptResult;
+ };
+ 'script.removePreloadScript': {
+ params: Bidi.Script.RemovePreloadScriptParameters;
+ returnType: Bidi.EmptyResult;
+ };
+
+ 'browser.close': {
+ params: Bidi.EmptyParams;
+ returnType: Bidi.EmptyResult;
+ };
+
+ 'browsingContext.activate': {
+ params: Bidi.BrowsingContext.ActivateParameters;
+ returnType: Bidi.EmptyResult;
+ };
+ 'browsingContext.create': {
+ params: Bidi.BrowsingContext.CreateParameters;
+ returnType: Bidi.BrowsingContext.CreateResult;
+ };
+ 'browsingContext.close': {
+ params: Bidi.BrowsingContext.CloseParameters;
+ returnType: Bidi.EmptyResult;
+ };
+ 'browsingContext.getTree': {
+ params: Bidi.BrowsingContext.GetTreeParameters;
+ returnType: Bidi.BrowsingContext.GetTreeResult;
+ };
+ 'browsingContext.navigate': {
+ params: Bidi.BrowsingContext.NavigateParameters;
+ returnType: Bidi.BrowsingContext.NavigateResult;
+ };
+ 'browsingContext.reload': {
+ params: Bidi.BrowsingContext.ReloadParameters;
+ returnType: Bidi.BrowsingContext.NavigateResult;
+ };
+ 'browsingContext.print': {
+ params: Bidi.BrowsingContext.PrintParameters;
+ returnType: Bidi.BrowsingContext.PrintResult;
+ };
+ 'browsingContext.captureScreenshot': {
+ params: Bidi.BrowsingContext.CaptureScreenshotParameters;
+ returnType: Bidi.BrowsingContext.CaptureScreenshotResult;
+ };
+ 'browsingContext.handleUserPrompt': {
+ params: Bidi.BrowsingContext.HandleUserPromptParameters;
+ returnType: Bidi.EmptyResult;
+ };
+ 'browsingContext.setViewport': {
+ params: Bidi.BrowsingContext.SetViewportParameters;
+ returnType: Bidi.EmptyResult;
+ };
+ 'browsingContext.traverseHistory': {
+ params: Bidi.BrowsingContext.TraverseHistoryParameters;
+ returnType: Bidi.EmptyResult;
+ };
+
+ 'input.performActions': {
+ params: Bidi.Input.PerformActionsParameters;
+ returnType: Bidi.EmptyResult;
+ };
+ 'input.releaseActions': {
+ params: Bidi.Input.ReleaseActionsParameters;
+ returnType: Bidi.EmptyResult;
+ };
+
+ 'session.end': {
+ params: Bidi.EmptyParams;
+ returnType: Bidi.EmptyResult;
+ };
+ 'session.new': {
+ params: Bidi.Session.NewParameters;
+ returnType: Bidi.Session.NewResult;
+ };
+ 'session.status': {
+ params: object;
+ returnType: Bidi.Session.StatusResult;
+ };
+ 'session.subscribe': {
+ params: Bidi.Session.SubscriptionRequest;
+ returnType: Bidi.EmptyResult;
+ };
+ 'session.unsubscribe': {
+ params: Bidi.Session.SubscriptionRequest;
+ returnType: Bidi.EmptyResult;
+ };
+}
+
+/**
+ * @internal
+ */
+export type BidiEvents = {
+ [K in Bidi.ChromiumBidi.Event['method']]: Extract<
+ Bidi.ChromiumBidi.Event,
+ {method: K}
+ >['params'];
+};
+
+/**
+ * @internal
+ */
+export interface Connection<Events extends BidiEvents = BidiEvents>
+ extends EventEmitter<Events> {
+ send<T extends keyof Commands>(
+ method: T,
+ params: Commands[T]['params']
+ ): Promise<{result: Commands[T]['returnType']}>;
+
+ // This will pipe events into the provided emitter.
+ pipeTo<Events extends BidiEvents>(emitter: EventEmitter<Events>): void;
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Navigation.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Navigation.ts
new file mode 100644
index 0000000000..a7efbfeb2c
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Navigation.ts
@@ -0,0 +1,144 @@
+/**
+ * @license
+ * Copyright 2024 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {EventEmitter} from '../../common/EventEmitter.js';
+import {inertIfDisposed} from '../../util/decorators.js';
+import {Deferred} from '../../util/Deferred.js';
+import {DisposableStack, disposeSymbol} from '../../util/disposable.js';
+
+import type {BrowsingContext} from './BrowsingContext.js';
+import type {Request} from './Request.js';
+
+/**
+ * @internal
+ */
+export interface NavigationInfo {
+ url: string;
+ timestamp: Date;
+}
+
+/**
+ * @internal
+ */
+export class Navigation extends EventEmitter<{
+ /** Emitted when navigation has a request associated with it. */
+ request: Request;
+ /** Emitted when fragment navigation occurred. */
+ fragment: NavigationInfo;
+ /** Emitted when navigation failed. */
+ failed: NavigationInfo;
+ /** Emitted when navigation was aborted. */
+ aborted: NavigationInfo;
+}> {
+ static from(context: BrowsingContext): Navigation {
+ const navigation = new Navigation(context);
+ navigation.#initialize();
+ return navigation;
+ }
+
+ // keep-sorted start
+ #request: Request | undefined;
+ readonly #browsingContext: BrowsingContext;
+ readonly #disposables = new DisposableStack();
+ readonly #id = new Deferred<string>();
+ // keep-sorted end
+
+ private constructor(context: BrowsingContext) {
+ super();
+ // keep-sorted start
+ this.#browsingContext = context;
+ // keep-sorted end
+ }
+
+ #initialize() {
+ const browsingContextEmitter = this.#disposables.use(
+ new EventEmitter(this.#browsingContext)
+ );
+ browsingContextEmitter.once('closed', () => {
+ this.emit('failed', {
+ url: this.#browsingContext.url,
+ timestamp: new Date(),
+ });
+ this.dispose();
+ });
+
+ this.#browsingContext.on('request', ({request}) => {
+ if (request.navigation === this.#id.value()) {
+ this.#request = request;
+ this.emit('request', request);
+ }
+ });
+
+ const sessionEmitter = this.#disposables.use(
+ new EventEmitter(this.#session)
+ );
+ // To get the navigation ID if any.
+ for (const eventName of [
+ 'browsingContext.domContentLoaded',
+ 'browsingContext.load',
+ ] as const) {
+ sessionEmitter.on(eventName, info => {
+ if (info.context !== this.#browsingContext.id) {
+ return;
+ }
+ if (!info.navigation) {
+ return;
+ }
+ if (!this.#id.resolved()) {
+ this.#id.resolve(info.navigation);
+ }
+ });
+ }
+
+ for (const [eventName, event] of [
+ ['browsingContext.fragmentNavigated', 'fragment'],
+ ['browsingContext.navigationFailed', 'failed'],
+ ['browsingContext.navigationAborted', 'aborted'],
+ ] as const) {
+ sessionEmitter.on(eventName, info => {
+ if (info.context !== this.#browsingContext.id) {
+ return;
+ }
+ if (!info.navigation) {
+ return;
+ }
+ if (!this.#id.resolved()) {
+ this.#id.resolve(info.navigation);
+ }
+ if (this.#id.value() !== info.navigation) {
+ return;
+ }
+ this.emit(event, {
+ url: info.url,
+ timestamp: new Date(info.timestamp),
+ });
+ this.dispose();
+ });
+ }
+ }
+
+ // keep-sorted start block=yes
+ get #session() {
+ return this.#browsingContext.userContext.browser.session;
+ }
+ get disposed(): boolean {
+ return this.#disposables.disposed;
+ }
+ get request(): Request | undefined {
+ return this.#request;
+ }
+ // keep-sorted end
+
+ @inertIfDisposed
+ private dispose(): void {
+ this[disposeSymbol]();
+ }
+
+ [disposeSymbol](): void {
+ this.#disposables.dispose();
+ super[disposeSymbol]();
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Realm.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Realm.ts
new file mode 100644
index 0000000000..d9bbbede50
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Realm.ts
@@ -0,0 +1,351 @@
+/**
+ * @license
+ * Copyright 2024 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
+
+import {EventEmitter} from '../../common/EventEmitter.js';
+import {inertIfDisposed, throwIfDisposed} from '../../util/decorators.js';
+import {DisposableStack, disposeSymbol} from '../../util/disposable.js';
+
+import type {BrowsingContext} from './BrowsingContext.js';
+import type {Session} from './Session.js';
+
+/**
+ * @internal
+ */
+export type CallFunctionOptions = Omit<
+ Bidi.Script.CallFunctionParameters,
+ 'functionDeclaration' | 'awaitPromise' | 'target'
+>;
+
+/**
+ * @internal
+ */
+export type EvaluateOptions = Omit<
+ Bidi.Script.EvaluateParameters,
+ 'expression' | 'awaitPromise' | 'target'
+>;
+
+/**
+ * @internal
+ */
+export abstract class Realm extends EventEmitter<{
+ /** Emitted when the realm is destroyed. */
+ destroyed: {reason: string};
+ /** Emitted when a dedicated worker is created in the realm. */
+ worker: DedicatedWorkerRealm;
+ /** Emitted when a shared worker is created in the realm. */
+ sharedworker: SharedWorkerRealm;
+}> {
+ // keep-sorted start
+ #reason?: string;
+ protected readonly disposables = new DisposableStack();
+ readonly id: string;
+ readonly origin: string;
+ // keep-sorted end
+
+ protected constructor(id: string, origin: string) {
+ super();
+ // keep-sorted start
+ this.id = id;
+ this.origin = origin;
+ // keep-sorted end
+ }
+
+ protected initialize(): void {
+ const sessionEmitter = this.disposables.use(new EventEmitter(this.session));
+ sessionEmitter.on('script.realmDestroyed', info => {
+ if (info.realm !== this.id) {
+ return;
+ }
+ this.dispose('Realm already destroyed.');
+ });
+ }
+
+ // keep-sorted start block=yes
+ get disposed(): boolean {
+ return this.#reason !== undefined;
+ }
+ protected abstract get session(): Session;
+ protected get target(): Bidi.Script.Target {
+ return {realm: this.id};
+ }
+ // keep-sorted end
+
+ @inertIfDisposed
+ protected dispose(reason?: string): void {
+ this.#reason = reason;
+ this[disposeSymbol]();
+ }
+
+ @throwIfDisposed<Realm>(realm => {
+ // SAFETY: Disposal implies this exists.
+ return realm.#reason!;
+ })
+ async disown(handles: string[]): Promise<void> {
+ await this.session.send('script.disown', {
+ target: this.target,
+ handles,
+ });
+ }
+
+ @throwIfDisposed<Realm>(realm => {
+ // SAFETY: Disposal implies this exists.
+ return realm.#reason!;
+ })
+ async callFunction(
+ functionDeclaration: string,
+ awaitPromise: boolean,
+ options: CallFunctionOptions = {}
+ ): Promise<Bidi.Script.EvaluateResult> {
+ const {result} = await this.session.send('script.callFunction', {
+ functionDeclaration,
+ awaitPromise,
+ target: this.target,
+ ...options,
+ });
+ return result;
+ }
+
+ @throwIfDisposed<Realm>(realm => {
+ // SAFETY: Disposal implies this exists.
+ return realm.#reason!;
+ })
+ async evaluate(
+ expression: string,
+ awaitPromise: boolean,
+ options: EvaluateOptions = {}
+ ): Promise<Bidi.Script.EvaluateResult> {
+ const {result} = await this.session.send('script.evaluate', {
+ expression,
+ awaitPromise,
+ target: this.target,
+ ...options,
+ });
+ return result;
+ }
+
+ [disposeSymbol](): void {
+ this.#reason ??=
+ 'Realm already destroyed, probably because all associated browsing contexts closed.';
+ this.emit('destroyed', {reason: this.#reason});
+
+ this.disposables.dispose();
+ super[disposeSymbol]();
+ }
+}
+
+/**
+ * @internal
+ */
+export class WindowRealm extends Realm {
+ static from(context: BrowsingContext, sandbox?: string): WindowRealm {
+ const realm = new WindowRealm(context, sandbox);
+ realm.initialize();
+ return realm;
+ }
+
+ // keep-sorted start
+ readonly browsingContext: BrowsingContext;
+ readonly sandbox?: string;
+ // keep-sorted end
+
+ readonly #workers: {
+ dedicated: Map<string, DedicatedWorkerRealm>;
+ shared: Map<string, SharedWorkerRealm>;
+ } = {
+ dedicated: new Map(),
+ shared: new Map(),
+ };
+
+ private constructor(context: BrowsingContext, sandbox?: string) {
+ super('', '');
+ // keep-sorted start
+ this.browsingContext = context;
+ this.sandbox = sandbox;
+ // keep-sorted end
+ }
+
+ override initialize(): void {
+ super.initialize();
+
+ const sessionEmitter = this.disposables.use(new EventEmitter(this.session));
+ sessionEmitter.on('script.realmCreated', info => {
+ if (info.type !== 'window') {
+ return;
+ }
+ (this as any).id = info.realm;
+ (this as any).origin = info.origin;
+ });
+ sessionEmitter.on('script.realmCreated', info => {
+ if (info.type !== 'dedicated-worker') {
+ return;
+ }
+ if (!info.owners.includes(this.id)) {
+ return;
+ }
+
+ const realm = DedicatedWorkerRealm.from(this, info.realm, info.origin);
+ this.#workers.dedicated.set(realm.id, realm);
+
+ const realmEmitter = this.disposables.use(new EventEmitter(realm));
+ realmEmitter.once('destroyed', () => {
+ realmEmitter.removeAllListeners();
+ this.#workers.dedicated.delete(realm.id);
+ });
+
+ this.emit('worker', realm);
+ });
+
+ this.browsingContext.userContext.browser.on('sharedworker', ({realm}) => {
+ if (!realm.owners.has(this)) {
+ return;
+ }
+
+ this.#workers.shared.set(realm.id, realm);
+
+ const realmEmitter = this.disposables.use(new EventEmitter(realm));
+ realmEmitter.once('destroyed', () => {
+ realmEmitter.removeAllListeners();
+ this.#workers.shared.delete(realm.id);
+ });
+
+ this.emit('sharedworker', realm);
+ });
+ }
+
+ override get session(): Session {
+ return this.browsingContext.userContext.browser.session;
+ }
+
+ override get target(): Bidi.Script.Target {
+ return {context: this.browsingContext.id, sandbox: this.sandbox};
+ }
+}
+
+/**
+ * @internal
+ */
+export type DedicatedWorkerOwnerRealm =
+ | DedicatedWorkerRealm
+ | SharedWorkerRealm
+ | WindowRealm;
+
+/**
+ * @internal
+ */
+export class DedicatedWorkerRealm extends Realm {
+ static from(
+ owner: DedicatedWorkerOwnerRealm,
+ id: string,
+ origin: string
+ ): DedicatedWorkerRealm {
+ const realm = new DedicatedWorkerRealm(owner, id, origin);
+ realm.initialize();
+ return realm;
+ }
+
+ // keep-sorted start
+ readonly #workers = new Map<string, DedicatedWorkerRealm>();
+ readonly owners: Set<DedicatedWorkerOwnerRealm>;
+ // keep-sorted end
+
+ private constructor(
+ owner: DedicatedWorkerOwnerRealm,
+ id: string,
+ origin: string
+ ) {
+ super(id, origin);
+ this.owners = new Set([owner]);
+ }
+
+ override initialize(): void {
+ super.initialize();
+
+ const sessionEmitter = this.disposables.use(new EventEmitter(this.session));
+ sessionEmitter.on('script.realmCreated', info => {
+ if (info.type !== 'dedicated-worker') {
+ return;
+ }
+ if (!info.owners.includes(this.id)) {
+ return;
+ }
+
+ const realm = DedicatedWorkerRealm.from(this, info.realm, info.origin);
+ this.#workers.set(realm.id, realm);
+
+ const realmEmitter = this.disposables.use(new EventEmitter(realm));
+ realmEmitter.once('destroyed', () => {
+ this.#workers.delete(realm.id);
+ });
+
+ this.emit('worker', realm);
+ });
+ }
+
+ override get session(): Session {
+ // SAFETY: At least one owner will exist.
+ return this.owners.values().next().value.session;
+ }
+}
+
+/**
+ * @internal
+ */
+export class SharedWorkerRealm extends Realm {
+ static from(
+ owners: [WindowRealm, ...WindowRealm[]],
+ id: string,
+ origin: string
+ ): SharedWorkerRealm {
+ const realm = new SharedWorkerRealm(owners, id, origin);
+ realm.initialize();
+ return realm;
+ }
+
+ // keep-sorted start
+ readonly #workers = new Map<string, DedicatedWorkerRealm>();
+ readonly owners: Set<WindowRealm>;
+ // keep-sorted end
+
+ private constructor(
+ owners: [WindowRealm, ...WindowRealm[]],
+ id: string,
+ origin: string
+ ) {
+ super(id, origin);
+ this.owners = new Set(owners);
+ }
+
+ override initialize(): void {
+ super.initialize();
+
+ const sessionEmitter = this.disposables.use(new EventEmitter(this.session));
+ sessionEmitter.on('script.realmCreated', info => {
+ if (info.type !== 'dedicated-worker') {
+ return;
+ }
+ if (!info.owners.includes(this.id)) {
+ return;
+ }
+
+ const realm = DedicatedWorkerRealm.from(this, info.realm, info.origin);
+ this.#workers.set(realm.id, realm);
+
+ const realmEmitter = this.disposables.use(new EventEmitter(realm));
+ realmEmitter.once('destroyed', () => {
+ this.#workers.delete(realm.id);
+ });
+
+ this.emit('worker', realm);
+ });
+ }
+
+ override get session(): Session {
+ // SAFETY: At least one owner will exist.
+ return this.owners.values().next().value.session;
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Request.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Request.ts
new file mode 100644
index 0000000000..2a445f7d87
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Request.ts
@@ -0,0 +1,148 @@
+/**
+ * @license
+ * Copyright 2024 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
+
+import {EventEmitter} from '../../common/EventEmitter.js';
+import {inertIfDisposed} from '../../util/decorators.js';
+import {DisposableStack, disposeSymbol} from '../../util/disposable.js';
+
+import type {BrowsingContext} from './BrowsingContext.js';
+
+/**
+ * @internal
+ */
+export class Request extends EventEmitter<{
+ /** Emitted when the request is redirected. */
+ redirect: Request;
+ /** Emitted when the request succeeds. */
+ success: Bidi.Network.ResponseData;
+ /** Emitted when the request fails. */
+ error: string;
+}> {
+ static from(
+ browsingContext: BrowsingContext,
+ event: Bidi.Network.BeforeRequestSentParameters
+ ): Request {
+ const request = new Request(browsingContext, event);
+ request.#initialize();
+ return request;
+ }
+
+ // keep-sorted start
+ #error?: string;
+ #redirect?: Request;
+ #response?: Bidi.Network.ResponseData;
+ readonly #browsingContext: BrowsingContext;
+ readonly #disposables = new DisposableStack();
+ readonly #event: Bidi.Network.BeforeRequestSentParameters;
+ // keep-sorted end
+
+ private constructor(
+ browsingContext: BrowsingContext,
+ event: Bidi.Network.BeforeRequestSentParameters
+ ) {
+ super();
+ // keep-sorted start
+ this.#browsingContext = browsingContext;
+ this.#event = event;
+ // keep-sorted end
+ }
+
+ #initialize() {
+ const browsingContextEmitter = this.#disposables.use(
+ new EventEmitter(this.#browsingContext)
+ );
+ browsingContextEmitter.once('closed', ({reason}) => {
+ this.#error = reason;
+ this.emit('error', this.#error);
+ this.dispose();
+ });
+
+ const sessionEmitter = this.#disposables.use(
+ new EventEmitter(this.#session)
+ );
+ sessionEmitter.on('network.beforeRequestSent', event => {
+ if (event.context !== this.#browsingContext.id) {
+ return;
+ }
+ if (event.request.request !== this.id) {
+ return;
+ }
+ this.#redirect = Request.from(this.#browsingContext, event);
+ this.emit('redirect', this.#redirect);
+ this.dispose();
+ });
+ sessionEmitter.on('network.fetchError', event => {
+ if (event.context !== this.#browsingContext.id) {
+ return;
+ }
+ if (event.request.request !== this.id) {
+ return;
+ }
+ this.#error = event.errorText;
+ this.emit('error', this.#error);
+ this.dispose();
+ });
+ sessionEmitter.on('network.responseCompleted', event => {
+ if (event.context !== this.#browsingContext.id) {
+ return;
+ }
+ if (event.request.request !== this.id) {
+ return;
+ }
+ this.#response = event.response;
+ this.emit('success', this.#response);
+ this.dispose();
+ });
+ }
+
+ // keep-sorted start block=yes
+ get #session() {
+ return this.#browsingContext.userContext.browser.session;
+ }
+ get disposed(): boolean {
+ return this.#disposables.disposed;
+ }
+ get error(): string | undefined {
+ return this.#error;
+ }
+ get headers(): Bidi.Network.Header[] {
+ return this.#event.request.headers;
+ }
+ get id(): string {
+ return this.#event.request.request;
+ }
+ get initiator(): Bidi.Network.Initiator {
+ return this.#event.initiator;
+ }
+ get method(): string {
+ return this.#event.request.method;
+ }
+ get navigation(): string | undefined {
+ return this.#event.navigation ?? undefined;
+ }
+ get redirect(): Request | undefined {
+ return this.redirect;
+ }
+ get response(): Bidi.Network.ResponseData | undefined {
+ return this.#response;
+ }
+ get url(): string {
+ return this.#event.request.url;
+ }
+ // keep-sorted end
+
+ @inertIfDisposed
+ private dispose(): void {
+ this[disposeSymbol]();
+ }
+
+ [disposeSymbol](): void {
+ this.#disposables.dispose();
+ super[disposeSymbol]();
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Session.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Session.ts
new file mode 100644
index 0000000000..b6e28061f1
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/Session.ts
@@ -0,0 +1,180 @@
+/**
+ * @license
+ * Copyright 2024 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
+
+import {EventEmitter} from '../../common/EventEmitter.js';
+import {debugError} from '../../common/util.js';
+import {inertIfDisposed, throwIfDisposed} from '../../util/decorators.js';
+import {DisposableStack, disposeSymbol} from '../../util/disposable.js';
+
+import {Browser} from './Browser.js';
+import type {BidiEvents, Commands, Connection} from './Connection.js';
+
+// TODO: Once Chrome supports session.status properly, uncomment this block.
+// const MAX_RETRIES = 5;
+
+/**
+ * @internal
+ */
+export class Session
+ extends EventEmitter<BidiEvents & {ended: {reason: string}}>
+ implements Connection<BidiEvents & {ended: {reason: string}}>
+{
+ static async from(
+ connection: Connection,
+ capabilities: Bidi.Session.CapabilitiesRequest
+ ): Promise<Session> {
+ // Wait until the session is ready.
+ //
+ // TODO: Once Chrome supports session.status properly, uncomment this block
+ // and remove `getBiDiConnection` in BrowserConnector.
+
+ // let status = {message: '', ready: false};
+ // for (let i = 0; i < MAX_RETRIES; ++i) {
+ // status = (await connection.send('session.status', {})).result;
+ // if (status.ready) {
+ // break;
+ // }
+ // // Backoff a little bit each time.
+ // await new Promise(resolve => {
+ // return setTimeout(resolve, (1 << i) * 100);
+ // });
+ // }
+ // if (!status.ready) {
+ // throw new Error(status.message);
+ // }
+
+ let result;
+ try {
+ result = (
+ await connection.send('session.new', {
+ capabilities,
+ })
+ ).result;
+ } catch (err) {
+ // Chrome does not support session.new.
+ debugError(err);
+ result = {
+ sessionId: '',
+ capabilities: {
+ acceptInsecureCerts: false,
+ browserName: '',
+ browserVersion: '',
+ platformName: '',
+ setWindowRect: false,
+ webSocketUrl: '',
+ },
+ };
+ }
+
+ const session = new Session(connection, result);
+ await session.#initialize();
+ return session;
+ }
+
+ // keep-sorted start
+ #reason: string | undefined;
+ readonly #disposables = new DisposableStack();
+ readonly #info: Bidi.Session.NewResult;
+ readonly browser!: Browser;
+ readonly connection: Connection;
+ // keep-sorted end
+
+ private constructor(connection: Connection, info: Bidi.Session.NewResult) {
+ super();
+ // keep-sorted start
+ this.#info = info;
+ this.connection = connection;
+ // keep-sorted end
+ }
+
+ async #initialize(): Promise<void> {
+ this.connection.pipeTo(this);
+
+ // SAFETY: We use `any` to allow assignment of the readonly property.
+ (this as any).browser = await Browser.from(this);
+
+ const browserEmitter = this.#disposables.use(this.browser);
+ browserEmitter.once('closed', ({reason}) => {
+ this.dispose(reason);
+ });
+ }
+
+ // keep-sorted start block=yes
+ get capabilities(): Bidi.Session.NewResult['capabilities'] {
+ return this.#info.capabilities;
+ }
+ get disposed(): boolean {
+ return this.ended;
+ }
+ get ended(): boolean {
+ return this.#reason !== undefined;
+ }
+ get id(): string {
+ return this.#info.sessionId;
+ }
+ // keep-sorted end
+
+ @inertIfDisposed
+ private dispose(reason?: string): void {
+ this.#reason = reason;
+ this[disposeSymbol]();
+ }
+
+ pipeTo<Events extends BidiEvents>(emitter: EventEmitter<Events>): void {
+ this.connection.pipeTo(emitter);
+ }
+
+ /**
+ * Currently, there is a 1:1 relationship between the session and the
+ * session. In the future, we might support multiple sessions and in that
+ * case we always needs to make sure that the session for the right session
+ * object is used, so we implement this method here, although it's not defined
+ * in the spec.
+ */
+ @throwIfDisposed<Session>(session => {
+ // SAFETY: By definition of `disposed`, `#reason` is defined.
+ return session.#reason!;
+ })
+ async send<T extends keyof Commands>(
+ method: T,
+ params: Commands[T]['params']
+ ): Promise<{result: Commands[T]['returnType']}> {
+ return await this.connection.send(method, params);
+ }
+
+ @throwIfDisposed<Session>(session => {
+ // SAFETY: By definition of `disposed`, `#reason` is defined.
+ return session.#reason!;
+ })
+ async subscribe(events: string[]): Promise<void> {
+ await this.send('session.subscribe', {
+ events,
+ });
+ }
+
+ @throwIfDisposed<Session>(session => {
+ // SAFETY: By definition of `disposed`, `#reason` is defined.
+ return session.#reason!;
+ })
+ async end(): Promise<void> {
+ try {
+ await this.send('session.end', {});
+ } finally {
+ this.dispose(`Session already ended.`);
+ }
+ }
+
+ [disposeSymbol](): void {
+ this.#reason ??=
+ 'Session already destroyed, probably because the connection broke.';
+ this.emit('ended', {reason: this.#reason});
+
+ this.#disposables.dispose();
+ super[disposeSymbol]();
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/UserContext.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/UserContext.ts
new file mode 100644
index 0000000000..01ee5c7649
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/UserContext.ts
@@ -0,0 +1,178 @@
+/**
+ * @license
+ * Copyright 2024 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
+
+import {EventEmitter} from '../../common/EventEmitter.js';
+import {assert} from '../../util/assert.js';
+import {inertIfDisposed, throwIfDisposed} from '../../util/decorators.js';
+import {DisposableStack, disposeSymbol} from '../../util/disposable.js';
+
+import type {Browser} from './Browser.js';
+import {BrowsingContext} from './BrowsingContext.js';
+
+/**
+ * @internal
+ */
+export type CreateBrowsingContextOptions = Omit<
+ Bidi.BrowsingContext.CreateParameters,
+ 'type' | 'referenceContext'
+> & {
+ referenceContext?: BrowsingContext;
+};
+
+/**
+ * @internal
+ */
+export class UserContext extends EventEmitter<{
+ /**
+ * Emitted when a new browsing context is created.
+ */
+ browsingcontext: {
+ /** The new browsing context. */
+ browsingContext: BrowsingContext;
+ };
+ /**
+ * Emitted when the user context is closed.
+ */
+ closed: {
+ /** The reason the user context was closed. */
+ reason: string;
+ };
+}> {
+ static DEFAULT = 'default';
+
+ static create(browser: Browser, id: string): UserContext {
+ const context = new UserContext(browser, id);
+ context.#initialize();
+ return context;
+ }
+
+ // keep-sorted start
+ #reason?: string;
+ // Note these are only top-level contexts.
+ readonly #browsingContexts = new Map<string, BrowsingContext>();
+ readonly #disposables = new DisposableStack();
+ readonly #id: string;
+ readonly browser: Browser;
+ // keep-sorted end
+
+ private constructor(browser: Browser, id: string) {
+ super();
+ // keep-sorted start
+ this.#id = id;
+ this.browser = browser;
+ // keep-sorted end
+ }
+
+ #initialize() {
+ const browserEmitter = this.#disposables.use(
+ new EventEmitter(this.browser)
+ );
+ browserEmitter.once('closed', ({reason}) => {
+ this.dispose(`User context already closed: ${reason}`);
+ });
+
+ const sessionEmitter = this.#disposables.use(
+ new EventEmitter(this.#session)
+ );
+ sessionEmitter.on('browsingContext.contextCreated', info => {
+ if (info.parent) {
+ return;
+ }
+
+ const browsingContext = BrowsingContext.from(
+ this,
+ undefined,
+ info.context,
+ info.url
+ );
+ this.#browsingContexts.set(browsingContext.id, browsingContext);
+
+ const browsingContextEmitter = this.#disposables.use(
+ new EventEmitter(browsingContext)
+ );
+ browsingContextEmitter.on('closed', () => {
+ browsingContextEmitter.removeAllListeners();
+
+ this.#browsingContexts.delete(browsingContext.id);
+ });
+
+ this.emit('browsingcontext', {browsingContext});
+ });
+ }
+
+ // keep-sorted start block=yes
+ get #session() {
+ return this.browser.session;
+ }
+ get browsingContexts(): Iterable<BrowsingContext> {
+ return this.#browsingContexts.values();
+ }
+ get closed(): boolean {
+ return this.#reason !== undefined;
+ }
+ get disposed(): boolean {
+ return this.closed;
+ }
+ get id(): string {
+ return this.#id;
+ }
+ // keep-sorted end
+
+ @inertIfDisposed
+ private dispose(reason?: string): void {
+ this.#reason = reason;
+ this[disposeSymbol]();
+ }
+
+ @throwIfDisposed<UserContext>(context => {
+ // SAFETY: Disposal implies this exists.
+ return context.#reason!;
+ })
+ async createBrowsingContext(
+ type: Bidi.BrowsingContext.CreateType,
+ options: CreateBrowsingContextOptions = {}
+ ): Promise<BrowsingContext> {
+ const {
+ result: {context: contextId},
+ } = await this.#session.send('browsingContext.create', {
+ type,
+ ...options,
+ referenceContext: options.referenceContext?.id,
+ });
+
+ const browsingContext = this.#browsingContexts.get(contextId);
+ assert(
+ browsingContext,
+ 'The WebDriver BiDi implementation is failing to create a browsing context correctly.'
+ );
+
+ // We use an array to avoid the promise from being awaited.
+ return browsingContext;
+ }
+
+ @throwIfDisposed<UserContext>(context => {
+ // SAFETY: Disposal implies this exists.
+ return context.#reason!;
+ })
+ async remove(): Promise<void> {
+ try {
+ // TODO: Call `removeUserContext` once available.
+ } finally {
+ this.dispose('User context already closed.');
+ }
+ }
+
+ [disposeSymbol](): void {
+ this.#reason ??=
+ 'User context already closed, probably because the browser disconnected/closed.';
+ this.emit('closed', {reason: this.#reason});
+
+ this.#disposables.dispose();
+ super[disposeSymbol]();
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/UserPrompt.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/UserPrompt.ts
new file mode 100644
index 0000000000..073233bed0
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/UserPrompt.ts
@@ -0,0 +1,137 @@
+/**
+ * @license
+ * Copyright 2024 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
+
+import {EventEmitter} from '../../common/EventEmitter.js';
+import {inertIfDisposed, throwIfDisposed} from '../../util/decorators.js';
+import {DisposableStack, disposeSymbol} from '../../util/disposable.js';
+
+import type {BrowsingContext} from './BrowsingContext.js';
+
+/**
+ * @internal
+ */
+export type HandleOptions = Omit<
+ Bidi.BrowsingContext.HandleUserPromptParameters,
+ 'context'
+>;
+
+/**
+ * @internal
+ */
+export type UserPromptResult = Omit<
+ Bidi.BrowsingContext.UserPromptClosedParameters,
+ 'context'
+>;
+
+/**
+ * @internal
+ */
+export class UserPrompt extends EventEmitter<{
+ /** Emitted when the user prompt is handled. */
+ handled: UserPromptResult;
+ /** Emitted when the user prompt is closed. */
+ closed: {
+ /** The reason the user prompt was closed. */
+ reason: string;
+ };
+}> {
+ static from(
+ browsingContext: BrowsingContext,
+ info: Bidi.BrowsingContext.UserPromptOpenedParameters
+ ): UserPrompt {
+ const userPrompt = new UserPrompt(browsingContext, info);
+ userPrompt.#initialize();
+ return userPrompt;
+ }
+
+ // keep-sorted start
+ #reason?: string;
+ #result?: UserPromptResult;
+ readonly #disposables = new DisposableStack();
+ readonly browsingContext: BrowsingContext;
+ readonly info: Bidi.BrowsingContext.UserPromptOpenedParameters;
+ // keep-sorted end
+
+ private constructor(
+ context: BrowsingContext,
+ info: Bidi.BrowsingContext.UserPromptOpenedParameters
+ ) {
+ super();
+ // keep-sorted start
+ this.browsingContext = context;
+ this.info = info;
+ // keep-sorted end
+ }
+
+ #initialize() {
+ const browserContextEmitter = this.#disposables.use(
+ new EventEmitter(this.browsingContext)
+ );
+ browserContextEmitter.once('closed', ({reason}) => {
+ this.dispose(`User prompt already closed: ${reason}`);
+ });
+
+ const sessionEmitter = this.#disposables.use(
+ new EventEmitter(this.#session)
+ );
+ sessionEmitter.on('browsingContext.userPromptClosed', parameters => {
+ if (parameters.context !== this.browsingContext.id) {
+ return;
+ }
+ this.#result = parameters;
+ this.emit('handled', parameters);
+ this.dispose('User prompt already handled.');
+ });
+ }
+
+ // keep-sorted start block=yes
+ get #session() {
+ return this.browsingContext.userContext.browser.session;
+ }
+ get closed(): boolean {
+ return this.#reason !== undefined;
+ }
+ get disposed(): boolean {
+ return this.closed;
+ }
+ get handled(): boolean {
+ return this.#result !== undefined;
+ }
+ get result(): UserPromptResult | undefined {
+ return this.#result;
+ }
+ // keep-sorted end
+
+ @inertIfDisposed
+ private dispose(reason?: string): void {
+ this.#reason = reason;
+ this[disposeSymbol]();
+ }
+
+ @throwIfDisposed<UserPrompt>(prompt => {
+ // SAFETY: Disposal implies this exists.
+ return prompt.#reason!;
+ })
+ async handle(options: HandleOptions = {}): Promise<UserPromptResult> {
+ await this.#session.send('browsingContext.handleUserPrompt', {
+ ...options,
+ context: this.info.context,
+ });
+ // SAFETY: `handled` is triggered before the above promise resolved.
+ return this.#result!;
+ }
+
+ [disposeSymbol](): void {
+ this.#reason ??=
+ 'User prompt already closed, probably because the associated browsing context was destroyed.';
+ this.emit('closed', {reason: this.#reason});
+
+ this.#disposables.dispose();
+ super[disposeSymbol]();
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/core.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/core.ts
new file mode 100644
index 0000000000..203281614b
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/core/core.ts
@@ -0,0 +1,15 @@
+/**
+ * @license
+ * Copyright 2024 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+export * from './Browser.js';
+export * from './BrowsingContext.js';
+export * from './Connection.js';
+export * from './Navigation.js';
+export * from './Realm.js';
+export * from './Request.js';
+export * from './Session.js';
+export * from './UserContext.js';
+export * from './UserPrompt.js';
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/lifecycle.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/lifecycle.ts
new file mode 100644
index 0000000000..73b86cba9c
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/lifecycle.ts
@@ -0,0 +1,119 @@
+/**
+ * @license
+ * Copyright 2023 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
+
+import type {
+ ObservableInput,
+ ObservedValueOf,
+ OperatorFunction,
+} from '../../third_party/rxjs/rxjs.js';
+import {catchError} from '../../third_party/rxjs/rxjs.js';
+import type {PuppeteerLifeCycleEvent} from '../cdp/LifecycleWatcher.js';
+import {ProtocolError, TimeoutError} from '../common/Errors.js';
+
+/**
+ * @internal
+ */
+export type BiDiNetworkIdle = Extract<
+ PuppeteerLifeCycleEvent,
+ 'networkidle0' | 'networkidle2'
+> | null;
+
+/**
+ * @internal
+ */
+export function getBiDiLifeCycles(
+ event: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[]
+): [
+ Extract<PuppeteerLifeCycleEvent, 'load' | 'domcontentloaded'>,
+ BiDiNetworkIdle,
+] {
+ if (Array.isArray(event)) {
+ const pageLifeCycle = event.some(lifeCycle => {
+ return lifeCycle !== 'domcontentloaded';
+ })
+ ? 'load'
+ : 'domcontentloaded';
+
+ const networkLifeCycle = event.reduce((acc, lifeCycle) => {
+ if (lifeCycle === 'networkidle0') {
+ return lifeCycle;
+ } else if (acc !== 'networkidle0' && lifeCycle === 'networkidle2') {
+ return lifeCycle;
+ }
+ return acc;
+ }, null as BiDiNetworkIdle);
+
+ return [pageLifeCycle, networkLifeCycle];
+ }
+
+ if (event === 'networkidle0' || event === 'networkidle2') {
+ return ['load', event];
+ }
+
+ return [event, null];
+}
+
+/**
+ * @internal
+ */
+export const lifeCycleToReadinessState = new Map<
+ PuppeteerLifeCycleEvent,
+ Bidi.BrowsingContext.ReadinessState
+>([
+ ['load', Bidi.BrowsingContext.ReadinessState.Complete],
+ ['domcontentloaded', Bidi.BrowsingContext.ReadinessState.Interactive],
+]);
+
+export function getBiDiReadinessState(
+ event: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[]
+): [Bidi.BrowsingContext.ReadinessState, BiDiNetworkIdle] {
+ const lifeCycles = getBiDiLifeCycles(event);
+ const readiness = lifeCycleToReadinessState.get(lifeCycles[0])!;
+ return [readiness, lifeCycles[1]];
+}
+
+/**
+ * @internal
+ */
+export const lifeCycleToSubscribedEvent = new Map<
+ PuppeteerLifeCycleEvent,
+ 'browsingContext.load' | 'browsingContext.domContentLoaded'
+>([
+ ['load', 'browsingContext.load'],
+ ['domcontentloaded', 'browsingContext.domContentLoaded'],
+]);
+
+/**
+ * @internal
+ */
+export function getBiDiLifecycleEvent(
+ event: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[]
+): [
+ 'browsingContext.load' | 'browsingContext.domContentLoaded',
+ BiDiNetworkIdle,
+] {
+ const lifeCycles = getBiDiLifeCycles(event);
+ const bidiEvent = lifeCycleToSubscribedEvent.get(lifeCycles[0])!;
+ return [bidiEvent, lifeCycles[1]];
+}
+
+/**
+ * @internal
+ */
+export function rewriteNavigationError<T, R extends ObservableInput<T>>(
+ message: string,
+ ms: number
+): OperatorFunction<T, T | ObservedValueOf<R>> {
+ return catchError<T, R>(error => {
+ if (error instanceof ProtocolError) {
+ error.message += ` at ${message}`;
+ } else if (error instanceof TimeoutError) {
+ error.message = `Navigation timeout of ${ms} ms exceeded`;
+ }
+ throw error;
+ });
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/bidi/util.ts b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/util.ts
new file mode 100644
index 0000000000..41e88e26c2
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/bidi/util.ts
@@ -0,0 +1,81 @@
+/**
+ * @license
+ * Copyright 2023 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
+
+import {PuppeteerURL, debugError} from '../common/util.js';
+
+import {BidiDeserializer} from './Deserializer.js';
+import type {BidiRealm} from './Realm.js';
+
+/**
+ * @internal
+ */
+export async function releaseReference(
+ client: BidiRealm,
+ remoteReference: Bidi.Script.RemoteReference
+): Promise<void> {
+ if (!remoteReference.handle) {
+ return;
+ }
+ await client.connection
+ .send('script.disown', {
+ target: client.target,
+ handles: [remoteReference.handle],
+ })
+ .catch(error => {
+ // Exceptions might happen in case of a page been navigated or closed.
+ // Swallow these since they are harmless and we don't leak anything in this case.
+ debugError(error);
+ });
+}
+
+/**
+ * @internal
+ */
+export function createEvaluationError(
+ details: Bidi.Script.ExceptionDetails
+): unknown {
+ if (details.exception.type !== 'error') {
+ return BidiDeserializer.deserialize(details.exception);
+ }
+ const [name = '', ...parts] = details.text.split(': ');
+ const message = parts.join(': ');
+ const error = new Error(message);
+ error.name = name;
+
+ // The first line is this function which we ignore.
+ const stackLines = [];
+ if (details.stackTrace && stackLines.length < Error.stackTraceLimit) {
+ for (const frame of details.stackTrace.callFrames.reverse()) {
+ if (
+ PuppeteerURL.isPuppeteerURL(frame.url) &&
+ frame.url !== PuppeteerURL.INTERNAL_URL
+ ) {
+ const url = PuppeteerURL.parse(frame.url);
+ stackLines.unshift(
+ ` at ${frame.functionName || url.functionName} (${
+ url.functionName
+ } at ${url.siteString}, <anonymous>:${frame.lineNumber}:${
+ frame.columnNumber
+ })`
+ );
+ } else {
+ stackLines.push(
+ ` at ${frame.functionName || '<anonymous>'} (${frame.url}:${
+ frame.lineNumber
+ }:${frame.columnNumber})`
+ );
+ }
+ if (stackLines.length >= Error.stackTraceLimit) {
+ break;
+ }
+ }
+ }
+
+ error.stack = [details.text, ...stackLines].join('\n');
+ return error;
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Accessibility.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Accessibility.ts
new file mode 100644
index 0000000000..d0279e3dda
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Accessibility.ts
@@ -0,0 +1,579 @@
+/**
+ * @license
+ * Copyright 2018 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {Protocol} from 'devtools-protocol';
+
+import type {CDPSession} from '../api/CDPSession.js';
+import type {ElementHandle} from '../api/ElementHandle.js';
+
+/**
+ * Represents a Node and the properties of it that are relevant to Accessibility.
+ * @public
+ */
+export interface SerializedAXNode {
+ /**
+ * The {@link https://www.w3.org/TR/wai-aria/#usage_intro | role} of the node.
+ */
+ role: string;
+ /**
+ * A human readable name for the node.
+ */
+ name?: string;
+ /**
+ * The current value of the node.
+ */
+ value?: string | number;
+ /**
+ * An additional human readable description of the node.
+ */
+ description?: string;
+ /**
+ * Any keyboard shortcuts associated with this node.
+ */
+ keyshortcuts?: string;
+ /**
+ * A human readable alternative to the role.
+ */
+ roledescription?: string;
+ /**
+ * A description of the current value.
+ */
+ valuetext?: string;
+ disabled?: boolean;
+ expanded?: boolean;
+ focused?: boolean;
+ modal?: boolean;
+ multiline?: boolean;
+ /**
+ * Whether more than one child can be selected.
+ */
+ multiselectable?: boolean;
+ readonly?: boolean;
+ required?: boolean;
+ selected?: boolean;
+ /**
+ * Whether the checkbox is checked, or in a
+ * {@link https://www.w3.org/TR/wai-aria-practices/examples/checkbox/checkbox-2/checkbox-2.html | mixed state}.
+ */
+ checked?: boolean | 'mixed';
+ /**
+ * Whether the node is checked or in a mixed state.
+ */
+ pressed?: boolean | 'mixed';
+ /**
+ * The level of a heading.
+ */
+ level?: number;
+ valuemin?: number;
+ valuemax?: number;
+ autocomplete?: string;
+ haspopup?: string;
+ /**
+ * Whether and in what way this node's value is invalid.
+ */
+ invalid?: string;
+ orientation?: string;
+ /**
+ * Children of this node, if there are any.
+ */
+ children?: SerializedAXNode[];
+}
+
+/**
+ * @public
+ */
+export interface SnapshotOptions {
+ /**
+ * Prune uninteresting nodes from the tree.
+ * @defaultValue `true`
+ */
+ interestingOnly?: boolean;
+ /**
+ * Root node to get the accessibility tree for
+ * @defaultValue The root node of the entire page.
+ */
+ root?: ElementHandle<Node>;
+}
+
+/**
+ * The Accessibility class provides methods for inspecting the browser's
+ * accessibility tree. The accessibility tree is used by assistive technology
+ * such as {@link https://en.wikipedia.org/wiki/Screen_reader | screen readers} or
+ * {@link https://en.wikipedia.org/wiki/Switch_access | switches}.
+ *
+ * @remarks
+ *
+ * Accessibility is a very platform-specific thing. On different platforms,
+ * there are different screen readers that might have wildly different output.
+ *
+ * Blink - Chrome's rendering engine - has a concept of "accessibility tree",
+ * which is then translated into different platform-specific APIs. Accessibility
+ * namespace gives users access to the Blink Accessibility Tree.
+ *
+ * Most of the accessibility tree gets filtered out when converting from Blink
+ * AX Tree to Platform-specific AX-Tree or by assistive technologies themselves.
+ * By default, Puppeteer tries to approximate this filtering, exposing only
+ * the "interesting" nodes of the tree.
+ *
+ * @public
+ */
+export class Accessibility {
+ #client: CDPSession;
+
+ /**
+ * @internal
+ */
+ constructor(client: CDPSession) {
+ this.#client = client;
+ }
+
+ /**
+ * @internal
+ */
+ updateClient(client: CDPSession): void {
+ this.#client = client;
+ }
+
+ /**
+ * Captures the current state of the accessibility tree.
+ * The returned object represents the root accessible node of the page.
+ *
+ * @remarks
+ *
+ * **NOTE** The Chrome accessibility tree contains nodes that go unused on
+ * most platforms and by most screen readers. Puppeteer will discard them as
+ * well for an easier to process tree, unless `interestingOnly` is set to
+ * `false`.
+ *
+ * @example
+ * An example of dumping the entire accessibility tree:
+ *
+ * ```ts
+ * const snapshot = await page.accessibility.snapshot();
+ * console.log(snapshot);
+ * ```
+ *
+ * @example
+ * An example of logging the focused node's name:
+ *
+ * ```ts
+ * const snapshot = await page.accessibility.snapshot();
+ * const node = findFocusedNode(snapshot);
+ * console.log(node && node.name);
+ *
+ * function findFocusedNode(node) {
+ * if (node.focused) return node;
+ * for (const child of node.children || []) {
+ * const foundNode = findFocusedNode(child);
+ * return foundNode;
+ * }
+ * return null;
+ * }
+ * ```
+ *
+ * @returns An AXNode object representing the snapshot.
+ */
+ public async snapshot(
+ options: SnapshotOptions = {}
+ ): Promise<SerializedAXNode | null> {
+ const {interestingOnly = true, root = null} = options;
+ const {nodes} = await this.#client.send('Accessibility.getFullAXTree');
+ let backendNodeId: number | undefined;
+ if (root) {
+ const {node} = await this.#client.send('DOM.describeNode', {
+ objectId: root.id,
+ });
+ backendNodeId = node.backendNodeId;
+ }
+ const defaultRoot = AXNode.createTree(nodes);
+ let needle: AXNode | null = defaultRoot;
+ if (backendNodeId) {
+ needle = defaultRoot.find(node => {
+ return node.payload.backendDOMNodeId === backendNodeId;
+ });
+ if (!needle) {
+ return null;
+ }
+ }
+ if (!interestingOnly) {
+ return this.serializeTree(needle)[0] ?? null;
+ }
+
+ const interestingNodes = new Set<AXNode>();
+ this.collectInterestingNodes(interestingNodes, defaultRoot, false);
+ if (!interestingNodes.has(needle)) {
+ return null;
+ }
+ return this.serializeTree(needle, interestingNodes)[0] ?? null;
+ }
+
+ private serializeTree(
+ node: AXNode,
+ interestingNodes?: Set<AXNode>
+ ): SerializedAXNode[] {
+ const children: SerializedAXNode[] = [];
+ for (const child of node.children) {
+ children.push(...this.serializeTree(child, interestingNodes));
+ }
+
+ if (interestingNodes && !interestingNodes.has(node)) {
+ return children;
+ }
+
+ const serializedNode = node.serialize();
+ if (children.length) {
+ serializedNode.children = children;
+ }
+ return [serializedNode];
+ }
+
+ private collectInterestingNodes(
+ collection: Set<AXNode>,
+ node: AXNode,
+ insideControl: boolean
+ ): void {
+ if (node.isInteresting(insideControl)) {
+ collection.add(node);
+ }
+ if (node.isLeafNode()) {
+ return;
+ }
+ insideControl = insideControl || node.isControl();
+ for (const child of node.children) {
+ this.collectInterestingNodes(collection, child, insideControl);
+ }
+ }
+}
+
+class AXNode {
+ public payload: Protocol.Accessibility.AXNode;
+ public children: AXNode[] = [];
+
+ #richlyEditable = false;
+ #editable = false;
+ #focusable = false;
+ #hidden = false;
+ #name: string;
+ #role: string;
+ #ignored: boolean;
+ #cachedHasFocusableChild?: boolean;
+
+ constructor(payload: Protocol.Accessibility.AXNode) {
+ this.payload = payload;
+ this.#name = this.payload.name ? this.payload.name.value : '';
+ this.#role = this.payload.role ? this.payload.role.value : 'Unknown';
+ this.#ignored = this.payload.ignored;
+
+ for (const property of this.payload.properties || []) {
+ if (property.name === 'editable') {
+ this.#richlyEditable = property.value.value === 'richtext';
+ this.#editable = true;
+ }
+ if (property.name === 'focusable') {
+ this.#focusable = property.value.value;
+ }
+ if (property.name === 'hidden') {
+ this.#hidden = property.value.value;
+ }
+ }
+ }
+
+ #isPlainTextField(): boolean {
+ if (this.#richlyEditable) {
+ return false;
+ }
+ if (this.#editable) {
+ return true;
+ }
+ return this.#role === 'textbox' || this.#role === 'searchbox';
+ }
+
+ #isTextOnlyObject(): boolean {
+ const role = this.#role;
+ return (
+ role === 'LineBreak' ||
+ role === 'text' ||
+ role === 'InlineTextBox' ||
+ role === 'StaticText'
+ );
+ }
+
+ #hasFocusableChild(): boolean {
+ if (this.#cachedHasFocusableChild === undefined) {
+ this.#cachedHasFocusableChild = false;
+ for (const child of this.children) {
+ if (child.#focusable || child.#hasFocusableChild()) {
+ this.#cachedHasFocusableChild = true;
+ break;
+ }
+ }
+ }
+ return this.#cachedHasFocusableChild;
+ }
+
+ public find(predicate: (x: AXNode) => boolean): AXNode | null {
+ if (predicate(this)) {
+ return this;
+ }
+ for (const child of this.children) {
+ const result = child.find(predicate);
+ if (result) {
+ return result;
+ }
+ }
+ return null;
+ }
+
+ public isLeafNode(): boolean {
+ if (!this.children.length) {
+ return true;
+ }
+
+ // These types of objects may have children that we use as internal
+ // implementation details, but we want to expose them as leaves to platform
+ // accessibility APIs because screen readers might be confused if they find
+ // any children.
+ if (this.#isPlainTextField() || this.#isTextOnlyObject()) {
+ return true;
+ }
+
+ // Roles whose children are only presentational according to the ARIA and
+ // HTML5 Specs should be hidden from screen readers.
+ // (Note that whilst ARIA buttons can have only presentational children, HTML5
+ // buttons are allowed to have content.)
+ switch (this.#role) {
+ case 'doc-cover':
+ case 'graphics-symbol':
+ case 'img':
+ case 'image':
+ case 'Meter':
+ case 'scrollbar':
+ case 'slider':
+ case 'separator':
+ case 'progressbar':
+ return true;
+ default:
+ break;
+ }
+
+ // Here and below: Android heuristics
+ if (this.#hasFocusableChild()) {
+ return false;
+ }
+ if (this.#focusable && this.#name) {
+ return true;
+ }
+ if (this.#role === 'heading' && this.#name) {
+ return true;
+ }
+ return false;
+ }
+
+ public isControl(): boolean {
+ switch (this.#role) {
+ case 'button':
+ case 'checkbox':
+ case 'ColorWell':
+ case 'combobox':
+ case 'DisclosureTriangle':
+ case 'listbox':
+ case 'menu':
+ case 'menubar':
+ case 'menuitem':
+ case 'menuitemcheckbox':
+ case 'menuitemradio':
+ case 'radio':
+ case 'scrollbar':
+ case 'searchbox':
+ case 'slider':
+ case 'spinbutton':
+ case 'switch':
+ case 'tab':
+ case 'textbox':
+ case 'tree':
+ case 'treeitem':
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ public isInteresting(insideControl: boolean): boolean {
+ const role = this.#role;
+ if (role === 'Ignored' || this.#hidden || this.#ignored) {
+ return false;
+ }
+
+ if (this.#focusable || this.#richlyEditable) {
+ return true;
+ }
+
+ // If it's not focusable but has a control role, then it's interesting.
+ if (this.isControl()) {
+ return true;
+ }
+
+ // A non focusable child of a control is not interesting
+ if (insideControl) {
+ return false;
+ }
+
+ return this.isLeafNode() && !!this.#name;
+ }
+
+ public serialize(): SerializedAXNode {
+ const properties = new Map<string, number | string | boolean>();
+ for (const property of this.payload.properties || []) {
+ properties.set(property.name.toLowerCase(), property.value.value);
+ }
+ if (this.payload.name) {
+ properties.set('name', this.payload.name.value);
+ }
+ if (this.payload.value) {
+ properties.set('value', this.payload.value.value);
+ }
+ if (this.payload.description) {
+ properties.set('description', this.payload.description.value);
+ }
+
+ const node: SerializedAXNode = {
+ role: this.#role,
+ };
+
+ type UserStringProperty =
+ | 'name'
+ | 'value'
+ | 'description'
+ | 'keyshortcuts'
+ | 'roledescription'
+ | 'valuetext';
+
+ const userStringProperties: UserStringProperty[] = [
+ 'name',
+ 'value',
+ 'description',
+ 'keyshortcuts',
+ 'roledescription',
+ 'valuetext',
+ ];
+ const getUserStringPropertyValue = (key: UserStringProperty): string => {
+ return properties.get(key) as string;
+ };
+
+ for (const userStringProperty of userStringProperties) {
+ if (!properties.has(userStringProperty)) {
+ continue;
+ }
+
+ node[userStringProperty] = getUserStringPropertyValue(userStringProperty);
+ }
+
+ type BooleanProperty =
+ | 'disabled'
+ | 'expanded'
+ | 'focused'
+ | 'modal'
+ | 'multiline'
+ | 'multiselectable'
+ | 'readonly'
+ | 'required'
+ | 'selected';
+ const booleanProperties: BooleanProperty[] = [
+ 'disabled',
+ 'expanded',
+ 'focused',
+ 'modal',
+ 'multiline',
+ 'multiselectable',
+ 'readonly',
+ 'required',
+ 'selected',
+ ];
+ const getBooleanPropertyValue = (key: BooleanProperty): boolean => {
+ return properties.get(key) as boolean;
+ };
+
+ for (const booleanProperty of booleanProperties) {
+ // RootWebArea's treat focus differently than other nodes. They report whether
+ // their frame has focus, not whether focus is specifically on the root
+ // node.
+ if (booleanProperty === 'focused' && this.#role === 'RootWebArea') {
+ continue;
+ }
+ const value = getBooleanPropertyValue(booleanProperty);
+ if (!value) {
+ continue;
+ }
+ node[booleanProperty] = getBooleanPropertyValue(booleanProperty);
+ }
+
+ type TristateProperty = 'checked' | 'pressed';
+ const tristateProperties: TristateProperty[] = ['checked', 'pressed'];
+ for (const tristateProperty of tristateProperties) {
+ if (!properties.has(tristateProperty)) {
+ continue;
+ }
+ const value = properties.get(tristateProperty);
+ node[tristateProperty] =
+ value === 'mixed' ? 'mixed' : value === 'true' ? true : false;
+ }
+
+ type NumbericalProperty = 'level' | 'valuemax' | 'valuemin';
+ const numericalProperties: NumbericalProperty[] = [
+ 'level',
+ 'valuemax',
+ 'valuemin',
+ ];
+ const getNumericalPropertyValue = (key: NumbericalProperty): number => {
+ return properties.get(key) as number;
+ };
+ for (const numericalProperty of numericalProperties) {
+ if (!properties.has(numericalProperty)) {
+ continue;
+ }
+ node[numericalProperty] = getNumericalPropertyValue(numericalProperty);
+ }
+
+ type TokenProperty =
+ | 'autocomplete'
+ | 'haspopup'
+ | 'invalid'
+ | 'orientation';
+ const tokenProperties: TokenProperty[] = [
+ 'autocomplete',
+ 'haspopup',
+ 'invalid',
+ 'orientation',
+ ];
+ const getTokenPropertyValue = (key: TokenProperty): string => {
+ return properties.get(key) as string;
+ };
+ for (const tokenProperty of tokenProperties) {
+ const value = getTokenPropertyValue(tokenProperty);
+ if (!value || value === 'false') {
+ continue;
+ }
+ node[tokenProperty] = getTokenPropertyValue(tokenProperty);
+ }
+ return node;
+ }
+
+ public static createTree(payloads: Protocol.Accessibility.AXNode[]): AXNode {
+ const nodeById = new Map<string, AXNode>();
+ for (const payload of payloads) {
+ nodeById.set(payload.nodeId, new AXNode(payload));
+ }
+ for (const node of nodeById.values()) {
+ for (const childId of node.payload.childIds || []) {
+ const child = nodeById.get(childId);
+ if (child) {
+ node.children.push(child);
+ }
+ }
+ }
+ return nodeById.values().next().value;
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/AriaQueryHandler.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/AriaQueryHandler.ts
new file mode 100644
index 0000000000..2286723758
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/AriaQueryHandler.ts
@@ -0,0 +1,120 @@
+/**
+ * @license
+ * Copyright 2020 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {Protocol} from 'devtools-protocol';
+
+import type {CDPSession} from '../api/CDPSession.js';
+import type {ElementHandle} from '../api/ElementHandle.js';
+import {QueryHandler, type QuerySelector} from '../common/QueryHandler.js';
+import type {AwaitableIterable} from '../common/types.js';
+import {assert} from '../util/assert.js';
+import {AsyncIterableUtil} from '../util/AsyncIterableUtil.js';
+
+const NON_ELEMENT_NODE_ROLES = new Set(['StaticText', 'InlineTextBox']);
+
+const queryAXTree = async (
+ client: CDPSession,
+ element: ElementHandle<Node>,
+ accessibleName?: string,
+ role?: string
+): Promise<Protocol.Accessibility.AXNode[]> => {
+ const {nodes} = await client.send('Accessibility.queryAXTree', {
+ objectId: element.id,
+ accessibleName,
+ role,
+ });
+ return nodes.filter((node: Protocol.Accessibility.AXNode) => {
+ return !node.role || !NON_ELEMENT_NODE_ROLES.has(node.role.value);
+ });
+};
+
+interface ARIASelector {
+ name?: string;
+ role?: string;
+}
+
+const isKnownAttribute = (
+ attribute: string
+): attribute is keyof ARIASelector => {
+ return ['name', 'role'].includes(attribute);
+};
+
+const normalizeValue = (value: string): string => {
+ return value.replace(/ +/g, ' ').trim();
+};
+
+/**
+ * The selectors consist of an accessible name to query for and optionally
+ * further aria attributes on the form `[<attribute>=<value>]`.
+ * Currently, we only support the `name` and `role` attribute.
+ * The following examples showcase how the syntax works wrt. querying:
+ *
+ * - 'title[role="heading"]' queries for elements with name 'title' and role 'heading'.
+ * - '[role="image"]' queries for elements with role 'image' and any name.
+ * - 'label' queries for elements with name 'label' and any role.
+ * - '[name=""][role="button"]' queries for elements with no name and role 'button'.
+ */
+const ATTRIBUTE_REGEXP =
+ /\[\s*(?<attribute>\w+)\s*=\s*(?<quote>"|')(?<value>\\.|.*?(?=\k<quote>))\k<quote>\s*\]/g;
+const parseARIASelector = (selector: string): ARIASelector => {
+ const queryOptions: ARIASelector = {};
+ const defaultName = selector.replace(
+ ATTRIBUTE_REGEXP,
+ (_, attribute, __, value) => {
+ attribute = attribute.trim();
+ assert(
+ isKnownAttribute(attribute),
+ `Unknown aria attribute "${attribute}" in selector`
+ );
+ queryOptions[attribute] = normalizeValue(value);
+ return '';
+ }
+ );
+ if (defaultName && !queryOptions.name) {
+ queryOptions.name = normalizeValue(defaultName);
+ }
+ return queryOptions;
+};
+
+/**
+ * @internal
+ */
+export class ARIAQueryHandler extends QueryHandler {
+ static override querySelector: QuerySelector = async (
+ node,
+ selector,
+ {ariaQuerySelector}
+ ) => {
+ return await ariaQuerySelector(node, selector);
+ };
+
+ static override async *queryAll(
+ element: ElementHandle<Node>,
+ selector: string
+ ): AwaitableIterable<ElementHandle<Node>> {
+ const {name, role} = parseARIASelector(selector);
+ const results = await queryAXTree(
+ element.realm.environment.client,
+ element,
+ name,
+ role
+ );
+ yield* AsyncIterableUtil.map(results, node => {
+ return element.realm.adoptBackendNode(node.backendDOMNodeId) as Promise<
+ ElementHandle<Node>
+ >;
+ });
+ }
+
+ static override queryOne = async (
+ element: ElementHandle<Node>,
+ selector: string
+ ): Promise<ElementHandle<Node> | null> => {
+ return (
+ (await AsyncIterableUtil.first(this.queryAll(element, selector))) ?? null
+ );
+ };
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Binding.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Binding.ts
new file mode 100644
index 0000000000..7a6a6f8582
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Binding.ts
@@ -0,0 +1,118 @@
+import {JSHandle} from '../api/JSHandle.js';
+import {debugError} from '../common/util.js';
+import {DisposableStack} from '../util/disposable.js';
+import {isErrorLike} from '../util/ErrorLike.js';
+
+import type {ExecutionContext} from './ExecutionContext.js';
+
+/**
+ * @internal
+ */
+export class Binding {
+ #name: string;
+ #fn: (...args: unknown[]) => unknown;
+ constructor(name: string, fn: (...args: unknown[]) => unknown) {
+ this.#name = name;
+ this.#fn = fn;
+ }
+
+ get name(): string {
+ return this.#name;
+ }
+
+ /**
+ * @param context - Context to run the binding in; the context should have
+ * the binding added to it beforehand.
+ * @param id - ID of the call. This should come from the CDP
+ * `onBindingCalled` response.
+ * @param args - Plain arguments from CDP.
+ */
+ async run(
+ context: ExecutionContext,
+ id: number,
+ args: unknown[],
+ isTrivial: boolean
+ ): Promise<void> {
+ const stack = new DisposableStack();
+ try {
+ if (!isTrivial) {
+ // Getting non-trivial arguments.
+ using handles = await context.evaluateHandle(
+ (name, seq) => {
+ // @ts-expect-error Code is evaluated in a different context.
+ return globalThis[name].args.get(seq);
+ },
+ this.#name,
+ id
+ );
+ const properties = await handles.getProperties();
+ for (const [index, handle] of properties) {
+ // This is not straight-forward since some arguments can stringify, but
+ // aren't plain objects so add subtypes when the use-case arises.
+ if (index in args) {
+ switch (handle.remoteObject().subtype) {
+ case 'node':
+ args[+index] = handle;
+ break;
+ default:
+ stack.use(handle);
+ }
+ } else {
+ stack.use(handle);
+ }
+ }
+ }
+
+ await context.evaluate(
+ (name, seq, result) => {
+ // @ts-expect-error Code is evaluated in a different context.
+ const callbacks = globalThis[name].callbacks;
+ callbacks.get(seq).resolve(result);
+ callbacks.delete(seq);
+ },
+ this.#name,
+ id,
+ await this.#fn(...args)
+ );
+
+ for (const arg of args) {
+ if (arg instanceof JSHandle) {
+ stack.use(arg);
+ }
+ }
+ } catch (error) {
+ if (isErrorLike(error)) {
+ await context
+ .evaluate(
+ (name, seq, message, stack) => {
+ const error = new Error(message);
+ error.stack = stack;
+ // @ts-expect-error Code is evaluated in a different context.
+ const callbacks = globalThis[name].callbacks;
+ callbacks.get(seq).reject(error);
+ callbacks.delete(seq);
+ },
+ this.#name,
+ id,
+ error.message,
+ error.stack
+ )
+ .catch(debugError);
+ } else {
+ await context
+ .evaluate(
+ (name, seq, error) => {
+ // @ts-expect-error Code is evaluated in a different context.
+ const callbacks = globalThis[name].callbacks;
+ callbacks.get(seq).reject(error);
+ callbacks.delete(seq);
+ },
+ this.#name,
+ id,
+ error
+ )
+ .catch(debugError);
+ }
+ }
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Browser.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Browser.ts
new file mode 100644
index 0000000000..7698acd164
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Browser.ts
@@ -0,0 +1,523 @@
+/**
+ * @license
+ * Copyright 2017 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {ChildProcess} from 'child_process';
+
+import type {Protocol} from 'devtools-protocol';
+
+import type {DebugInfo} from '../api/Browser.js';
+import {
+ Browser as BrowserBase,
+ BrowserEvent,
+ WEB_PERMISSION_TO_PROTOCOL_PERMISSION,
+ type BrowserCloseCallback,
+ type BrowserContextOptions,
+ type IsPageTargetCallback,
+ type Permission,
+ type TargetFilterCallback,
+ type WaitForTargetOptions,
+} from '../api/Browser.js';
+import {BrowserContext, BrowserContextEvent} from '../api/BrowserContext.js';
+import {CDPSessionEvent, type CDPSession} from '../api/CDPSession.js';
+import type {Page} from '../api/Page.js';
+import type {Target} from '../api/Target.js';
+import type {Viewport} from '../common/Viewport.js';
+import {assert} from '../util/assert.js';
+
+import {ChromeTargetManager} from './ChromeTargetManager.js';
+import type {Connection} from './Connection.js';
+import {FirefoxTargetManager} from './FirefoxTargetManager.js';
+import {
+ DevToolsTarget,
+ InitializationStatus,
+ OtherTarget,
+ PageTarget,
+ WorkerTarget,
+ type CdpTarget,
+} from './Target.js';
+import {TargetManagerEvent, type TargetManager} from './TargetManager.js';
+
+/**
+ * @internal
+ */
+export class CdpBrowser extends BrowserBase {
+ readonly protocol = 'cdp';
+
+ static async _create(
+ product: 'firefox' | 'chrome' | undefined,
+ connection: Connection,
+ contextIds: string[],
+ ignoreHTTPSErrors: boolean,
+ defaultViewport?: Viewport | null,
+ process?: ChildProcess,
+ closeCallback?: BrowserCloseCallback,
+ targetFilterCallback?: TargetFilterCallback,
+ isPageTargetCallback?: IsPageTargetCallback,
+ waitForInitiallyDiscoveredTargets = true
+ ): Promise<CdpBrowser> {
+ const browser = new CdpBrowser(
+ product,
+ connection,
+ contextIds,
+ ignoreHTTPSErrors,
+ defaultViewport,
+ process,
+ closeCallback,
+ targetFilterCallback,
+ isPageTargetCallback,
+ waitForInitiallyDiscoveredTargets
+ );
+ await browser._attach();
+ return browser;
+ }
+ #ignoreHTTPSErrors: boolean;
+ #defaultViewport?: Viewport | null;
+ #process?: ChildProcess;
+ #connection: Connection;
+ #closeCallback: BrowserCloseCallback;
+ #targetFilterCallback: TargetFilterCallback;
+ #isPageTargetCallback!: IsPageTargetCallback;
+ #defaultContext: CdpBrowserContext;
+ #contexts = new Map<string, CdpBrowserContext>();
+ #targetManager: TargetManager;
+
+ constructor(
+ product: 'chrome' | 'firefox' | undefined,
+ connection: Connection,
+ contextIds: string[],
+ ignoreHTTPSErrors: boolean,
+ defaultViewport?: Viewport | null,
+ process?: ChildProcess,
+ closeCallback?: BrowserCloseCallback,
+ targetFilterCallback?: TargetFilterCallback,
+ isPageTargetCallback?: IsPageTargetCallback,
+ waitForInitiallyDiscoveredTargets = true
+ ) {
+ super();
+ product = product || 'chrome';
+ this.#ignoreHTTPSErrors = ignoreHTTPSErrors;
+ this.#defaultViewport = defaultViewport;
+ this.#process = process;
+ this.#connection = connection;
+ this.#closeCallback = closeCallback || function (): void {};
+ this.#targetFilterCallback =
+ targetFilterCallback ||
+ ((): boolean => {
+ return true;
+ });
+ this.#setIsPageTargetCallback(isPageTargetCallback);
+ if (product === 'firefox') {
+ this.#targetManager = new FirefoxTargetManager(
+ connection,
+ this.#createTarget,
+ this.#targetFilterCallback
+ );
+ } else {
+ this.#targetManager = new ChromeTargetManager(
+ connection,
+ this.#createTarget,
+ this.#targetFilterCallback,
+ waitForInitiallyDiscoveredTargets
+ );
+ }
+ this.#defaultContext = new CdpBrowserContext(this.#connection, this);
+ for (const contextId of contextIds) {
+ this.#contexts.set(
+ contextId,
+ new CdpBrowserContext(this.#connection, this, contextId)
+ );
+ }
+ }
+
+ #emitDisconnected = () => {
+ this.emit(BrowserEvent.Disconnected, undefined);
+ };
+
+ async _attach(): Promise<void> {
+ this.#connection.on(CDPSessionEvent.Disconnected, this.#emitDisconnected);
+ this.#targetManager.on(
+ TargetManagerEvent.TargetAvailable,
+ this.#onAttachedToTarget
+ );
+ this.#targetManager.on(
+ TargetManagerEvent.TargetGone,
+ this.#onDetachedFromTarget
+ );
+ this.#targetManager.on(
+ TargetManagerEvent.TargetChanged,
+ this.#onTargetChanged
+ );
+ this.#targetManager.on(
+ TargetManagerEvent.TargetDiscovered,
+ this.#onTargetDiscovered
+ );
+ await this.#targetManager.initialize();
+ }
+
+ _detach(): void {
+ this.#connection.off(CDPSessionEvent.Disconnected, this.#emitDisconnected);
+ this.#targetManager.off(
+ TargetManagerEvent.TargetAvailable,
+ this.#onAttachedToTarget
+ );
+ this.#targetManager.off(
+ TargetManagerEvent.TargetGone,
+ this.#onDetachedFromTarget
+ );
+ this.#targetManager.off(
+ TargetManagerEvent.TargetChanged,
+ this.#onTargetChanged
+ );
+ this.#targetManager.off(
+ TargetManagerEvent.TargetDiscovered,
+ this.#onTargetDiscovered
+ );
+ }
+
+ override process(): ChildProcess | null {
+ return this.#process ?? null;
+ }
+
+ _targetManager(): TargetManager {
+ return this.#targetManager;
+ }
+
+ #setIsPageTargetCallback(isPageTargetCallback?: IsPageTargetCallback): void {
+ this.#isPageTargetCallback =
+ isPageTargetCallback ||
+ ((target: Target): boolean => {
+ return (
+ target.type() === 'page' ||
+ target.type() === 'background_page' ||
+ target.type() === 'webview'
+ );
+ });
+ }
+
+ _getIsPageTargetCallback(): IsPageTargetCallback | undefined {
+ return this.#isPageTargetCallback;
+ }
+
+ override async createIncognitoBrowserContext(
+ options: BrowserContextOptions = {}
+ ): Promise<CdpBrowserContext> {
+ const {proxyServer, proxyBypassList} = options;
+
+ const {browserContextId} = await this.#connection.send(
+ 'Target.createBrowserContext',
+ {
+ proxyServer,
+ proxyBypassList: proxyBypassList && proxyBypassList.join(','),
+ }
+ );
+ const context = new CdpBrowserContext(
+ this.#connection,
+ this,
+ browserContextId
+ );
+ this.#contexts.set(browserContextId, context);
+ return context;
+ }
+
+ override browserContexts(): CdpBrowserContext[] {
+ return [this.#defaultContext, ...Array.from(this.#contexts.values())];
+ }
+
+ override defaultBrowserContext(): CdpBrowserContext {
+ return this.#defaultContext;
+ }
+
+ async _disposeContext(contextId?: string): Promise<void> {
+ if (!contextId) {
+ return;
+ }
+ await this.#connection.send('Target.disposeBrowserContext', {
+ browserContextId: contextId,
+ });
+ this.#contexts.delete(contextId);
+ }
+
+ #createTarget = (
+ targetInfo: Protocol.Target.TargetInfo,
+ session?: CDPSession
+ ) => {
+ const {browserContextId} = targetInfo;
+ const context =
+ browserContextId && this.#contexts.has(browserContextId)
+ ? this.#contexts.get(browserContextId)
+ : this.#defaultContext;
+
+ if (!context) {
+ throw new Error('Missing browser context');
+ }
+
+ const createSession = (isAutoAttachEmulated: boolean) => {
+ return this.#connection._createSession(targetInfo, isAutoAttachEmulated);
+ };
+ const otherTarget = new OtherTarget(
+ targetInfo,
+ session,
+ context,
+ this.#targetManager,
+ createSession
+ );
+ if (targetInfo.url?.startsWith('devtools://')) {
+ return new DevToolsTarget(
+ targetInfo,
+ session,
+ context,
+ this.#targetManager,
+ createSession,
+ this.#ignoreHTTPSErrors,
+ this.#defaultViewport ?? null
+ );
+ }
+ if (this.#isPageTargetCallback(otherTarget)) {
+ return new PageTarget(
+ targetInfo,
+ session,
+ context,
+ this.#targetManager,
+ createSession,
+ this.#ignoreHTTPSErrors,
+ this.#defaultViewport ?? null
+ );
+ }
+ if (
+ targetInfo.type === 'service_worker' ||
+ targetInfo.type === 'shared_worker'
+ ) {
+ return new WorkerTarget(
+ targetInfo,
+ session,
+ context,
+ this.#targetManager,
+ createSession
+ );
+ }
+ return otherTarget;
+ };
+
+ #onAttachedToTarget = async (target: CdpTarget) => {
+ if (
+ target._isTargetExposed() &&
+ (await target._initializedDeferred.valueOrThrow()) ===
+ InitializationStatus.SUCCESS
+ ) {
+ this.emit(BrowserEvent.TargetCreated, target);
+ target.browserContext().emit(BrowserContextEvent.TargetCreated, target);
+ }
+ };
+
+ #onDetachedFromTarget = async (target: CdpTarget): Promise<void> => {
+ target._initializedDeferred.resolve(InitializationStatus.ABORTED);
+ target._isClosedDeferred.resolve();
+ if (
+ target._isTargetExposed() &&
+ (await target._initializedDeferred.valueOrThrow()) ===
+ InitializationStatus.SUCCESS
+ ) {
+ this.emit(BrowserEvent.TargetDestroyed, target);
+ target.browserContext().emit(BrowserContextEvent.TargetDestroyed, target);
+ }
+ };
+
+ #onTargetChanged = ({target}: {target: CdpTarget}): void => {
+ this.emit(BrowserEvent.TargetChanged, target);
+ target.browserContext().emit(BrowserContextEvent.TargetChanged, target);
+ };
+
+ #onTargetDiscovered = (targetInfo: Protocol.Target.TargetInfo): void => {
+ this.emit(BrowserEvent.TargetDiscovered, targetInfo);
+ };
+
+ override wsEndpoint(): string {
+ return this.#connection.url();
+ }
+
+ override async newPage(): Promise<Page> {
+ return await this.#defaultContext.newPage();
+ }
+
+ async _createPageInContext(contextId?: string): Promise<Page> {
+ const {targetId} = await this.#connection.send('Target.createTarget', {
+ url: 'about:blank',
+ browserContextId: contextId || undefined,
+ });
+ const target = (await this.waitForTarget(t => {
+ return (t as CdpTarget)._targetId === targetId;
+ })) as CdpTarget;
+ if (!target) {
+ throw new Error(`Missing target for page (id = ${targetId})`);
+ }
+ const initialized =
+ (await target._initializedDeferred.valueOrThrow()) ===
+ InitializationStatus.SUCCESS;
+ if (!initialized) {
+ throw new Error(`Failed to create target for page (id = ${targetId})`);
+ }
+ const page = await target.page();
+ if (!page) {
+ throw new Error(
+ `Failed to create a page for context (id = ${contextId})`
+ );
+ }
+ return page;
+ }
+
+ override targets(): CdpTarget[] {
+ return Array.from(
+ this.#targetManager.getAvailableTargets().values()
+ ).filter(target => {
+ return (
+ target._isTargetExposed() &&
+ target._initializedDeferred.value() === InitializationStatus.SUCCESS
+ );
+ });
+ }
+
+ override target(): CdpTarget {
+ const browserTarget = this.targets().find(target => {
+ return target.type() === 'browser';
+ });
+ if (!browserTarget) {
+ throw new Error('Browser target is not found');
+ }
+ return browserTarget;
+ }
+
+ override async version(): Promise<string> {
+ const version = await this.#getVersion();
+ return version.product;
+ }
+
+ override async userAgent(): Promise<string> {
+ const version = await this.#getVersion();
+ return version.userAgent;
+ }
+
+ override async close(): Promise<void> {
+ await this.#closeCallback.call(null);
+ await this.disconnect();
+ }
+
+ override disconnect(): Promise<void> {
+ this.#targetManager.dispose();
+ this.#connection.dispose();
+ this._detach();
+ return Promise.resolve();
+ }
+
+ override get connected(): boolean {
+ return !this.#connection._closed;
+ }
+
+ #getVersion(): Promise<Protocol.Browser.GetVersionResponse> {
+ return this.#connection.send('Browser.getVersion');
+ }
+
+ override get debugInfo(): DebugInfo {
+ return {
+ pendingProtocolErrors: this.#connection.getPendingProtocolErrors(),
+ };
+ }
+}
+
+/**
+ * @internal
+ */
+export class CdpBrowserContext extends BrowserContext {
+ #connection: Connection;
+ #browser: CdpBrowser;
+ #id?: string;
+
+ constructor(connection: Connection, browser: CdpBrowser, contextId?: string) {
+ super();
+ this.#connection = connection;
+ this.#browser = browser;
+ this.#id = contextId;
+ }
+
+ override get id(): string | undefined {
+ return this.#id;
+ }
+
+ override targets(): CdpTarget[] {
+ return this.#browser.targets().filter(target => {
+ return target.browserContext() === this;
+ });
+ }
+
+ override waitForTarget(
+ predicate: (x: Target) => boolean | Promise<boolean>,
+ options: WaitForTargetOptions = {}
+ ): Promise<Target> {
+ return this.#browser.waitForTarget(target => {
+ return target.browserContext() === this && predicate(target);
+ }, options);
+ }
+
+ override async pages(): Promise<Page[]> {
+ const pages = await Promise.all(
+ this.targets()
+ .filter(target => {
+ return (
+ target.type() === 'page' ||
+ (target.type() === 'other' &&
+ this.#browser._getIsPageTargetCallback()?.(target))
+ );
+ })
+ .map(target => {
+ return target.page();
+ })
+ );
+ return pages.filter((page): page is Page => {
+ return !!page;
+ });
+ }
+
+ override isIncognito(): boolean {
+ return !!this.#id;
+ }
+
+ override async overridePermissions(
+ origin: string,
+ permissions: Permission[]
+ ): Promise<void> {
+ const protocolPermissions = permissions.map(permission => {
+ const protocolPermission =
+ WEB_PERMISSION_TO_PROTOCOL_PERMISSION.get(permission);
+ if (!protocolPermission) {
+ throw new Error('Unknown permission: ' + permission);
+ }
+ return protocolPermission;
+ });
+ await this.#connection.send('Browser.grantPermissions', {
+ origin,
+ browserContextId: this.#id || undefined,
+ permissions: protocolPermissions,
+ });
+ }
+
+ override async clearPermissionOverrides(): Promise<void> {
+ await this.#connection.send('Browser.resetPermissions', {
+ browserContextId: this.#id || undefined,
+ });
+ }
+
+ override newPage(): Promise<Page> {
+ return this.#browser._createPageInContext(this.#id);
+ }
+
+ override browser(): CdpBrowser {
+ return this.#browser;
+ }
+
+ override async close(): Promise<void> {
+ assert(this.#id, 'Non-incognito profiles cannot be closed!');
+ await this.#browser._disposeContext(this.#id);
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/BrowserConnector.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/BrowserConnector.ts
new file mode 100644
index 0000000000..ef4aebe747
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/BrowserConnector.ts
@@ -0,0 +1,66 @@
+/**
+ * @license
+ * Copyright 2020 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {ConnectionTransport} from '../common/ConnectionTransport.js';
+import type {
+ BrowserConnectOptions,
+ ConnectOptions,
+} from '../common/ConnectOptions.js';
+import {debugError, DEFAULT_VIEWPORT} from '../common/util.js';
+
+import {CdpBrowser} from './Browser.js';
+import {Connection} from './Connection.js';
+
+/**
+ * Users should never call this directly; it's called when calling
+ * `puppeteer.connect` with `protocol: 'cdp'`.
+ *
+ * @internal
+ */
+export async function _connectToCdpBrowser(
+ connectionTransport: ConnectionTransport,
+ url: string,
+ options: BrowserConnectOptions & ConnectOptions
+): Promise<CdpBrowser> {
+ const {
+ ignoreHTTPSErrors = false,
+ defaultViewport = DEFAULT_VIEWPORT,
+ targetFilter,
+ _isPageTarget: isPageTarget,
+ slowMo = 0,
+ protocolTimeout,
+ } = options;
+
+ const connection = new Connection(
+ url,
+ connectionTransport,
+ slowMo,
+ protocolTimeout
+ );
+
+ const version = await connection.send('Browser.getVersion');
+ const product = version.product.toLowerCase().includes('firefox')
+ ? 'firefox'
+ : 'chrome';
+
+ const {browserContextIds} = await connection.send(
+ 'Target.getBrowserContexts'
+ );
+ const browser = await CdpBrowser._create(
+ product || 'chrome',
+ connection,
+ browserContextIds,
+ ignoreHTTPSErrors,
+ defaultViewport,
+ undefined,
+ () => {
+ return connection.send('Browser.close').catch(debugError);
+ },
+ targetFilter,
+ isPageTarget
+ );
+ return browser;
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/CDPSession.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/CDPSession.ts
new file mode 100644
index 0000000000..fe5faa5647
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/CDPSession.ts
@@ -0,0 +1,167 @@
+/**
+ * @license
+ * Copyright 2017 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {ProtocolMapping} from 'devtools-protocol/types/protocol-mapping.js';
+
+import {
+ type CDPEvents,
+ CDPSession,
+ CDPSessionEvent,
+ type CommandOptions,
+} from '../api/CDPSession.js';
+import {CallbackRegistry} from '../common/CallbackRegistry.js';
+import {TargetCloseError} from '../common/Errors.js';
+import {assert} from '../util/assert.js';
+import {createProtocolErrorMessage} from '../util/ErrorLike.js';
+
+import type {Connection} from './Connection.js';
+import type {CdpTarget} from './Target.js';
+
+/**
+ * @internal
+ */
+
+export class CdpCDPSession extends CDPSession {
+ #sessionId: string;
+ #targetType: string;
+ #callbacks = new CallbackRegistry();
+ #connection?: Connection;
+ #parentSessionId?: string;
+ #target?: CdpTarget;
+
+ /**
+ * @internal
+ */
+ constructor(
+ connection: Connection,
+ targetType: string,
+ sessionId: string,
+ parentSessionId: string | undefined
+ ) {
+ super();
+ this.#connection = connection;
+ this.#targetType = targetType;
+ this.#sessionId = sessionId;
+ this.#parentSessionId = parentSessionId;
+ }
+
+ /**
+ * Sets the {@link CdpTarget} associated with the session instance.
+ *
+ * @internal
+ */
+ _setTarget(target: CdpTarget): void {
+ this.#target = target;
+ }
+
+ /**
+ * Gets the {@link CdpTarget} associated with the session instance.
+ *
+ * @internal
+ */
+ _target(): CdpTarget {
+ assert(this.#target, 'Target must exist');
+ return this.#target;
+ }
+
+ override connection(): Connection | undefined {
+ return this.#connection;
+ }
+
+ override parentSession(): CDPSession | undefined {
+ if (!this.#parentSessionId) {
+ // To make it work in Firefox that does not have parent (tab) sessions.
+ return this;
+ }
+ const parent = this.#connection?.session(this.#parentSessionId);
+ return parent ?? undefined;
+ }
+
+ override send<T extends keyof ProtocolMapping.Commands>(
+ method: T,
+ params?: ProtocolMapping.Commands[T]['paramsType'][0],
+ options?: CommandOptions
+ ): Promise<ProtocolMapping.Commands[T]['returnType']> {
+ if (!this.#connection) {
+ return Promise.reject(
+ new TargetCloseError(
+ `Protocol error (${method}): Session closed. Most likely the ${this.#targetType} has been closed.`
+ )
+ );
+ }
+ return this.#connection._rawSend(
+ this.#callbacks,
+ method,
+ params,
+ this.#sessionId,
+ options
+ );
+ }
+
+ /**
+ * @internal
+ */
+ _onMessage(object: {
+ id?: number;
+ method: keyof CDPEvents;
+ params: CDPEvents[keyof CDPEvents];
+ error: {message: string; data: any; code: number};
+ result?: any;
+ }): void {
+ if (object.id) {
+ if (object.error) {
+ this.#callbacks.reject(
+ object.id,
+ createProtocolErrorMessage(object),
+ object.error.message
+ );
+ } else {
+ this.#callbacks.resolve(object.id, object.result);
+ }
+ } else {
+ assert(!object.id);
+ this.emit(object.method, object.params);
+ }
+ }
+
+ /**
+ * Detaches the cdpSession from the target. Once detached, the cdpSession object
+ * won't emit any events and can't be used to send messages.
+ */
+ override async detach(): Promise<void> {
+ if (!this.#connection) {
+ throw new Error(
+ `Session already detached. Most likely the ${this.#targetType} has been closed.`
+ );
+ }
+ await this.#connection.send('Target.detachFromTarget', {
+ sessionId: this.#sessionId,
+ });
+ }
+
+ /**
+ * @internal
+ */
+ _onClosed(): void {
+ this.#callbacks.clear();
+ this.#connection = undefined;
+ this.emit(CDPSessionEvent.Disconnected, undefined);
+ }
+
+ /**
+ * Returns the session's id.
+ */
+ override id(): string {
+ return this.#sessionId;
+ }
+
+ /**
+ * @internal
+ */
+ getPendingProtocolErrors(): Error[] {
+ return this.#callbacks.getPendingProtocolErrors();
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/ChromeTargetManager.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/ChromeTargetManager.ts
new file mode 100644
index 0000000000..e87d71fff9
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/ChromeTargetManager.ts
@@ -0,0 +1,417 @@
+/**
+ * @license
+ * Copyright 2022 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {Protocol} from 'devtools-protocol';
+
+import type {TargetFilterCallback} from '../api/Browser.js';
+import {CDPSession, CDPSessionEvent} from '../api/CDPSession.js';
+import {EventEmitter} from '../common/EventEmitter.js';
+import {debugError} from '../common/util.js';
+import {assert} from '../util/assert.js';
+import {Deferred} from '../util/Deferred.js';
+
+import type {CdpCDPSession} from './CDPSession.js';
+import type {Connection} from './Connection.js';
+import {CdpTarget, InitializationStatus} from './Target.js';
+import {
+ type TargetFactory,
+ type TargetManager,
+ TargetManagerEvent,
+ type TargetManagerEvents,
+} from './TargetManager.js';
+
+function isPageTargetBecomingPrimary(
+ target: CdpTarget,
+ newTargetInfo: Protocol.Target.TargetInfo
+): boolean {
+ return Boolean(target._subtype()) && !newTargetInfo.subtype;
+}
+
+/**
+ * ChromeTargetManager uses the CDP's auto-attach mechanism to intercept
+ * new targets and allow the rest of Puppeteer to configure listeners while
+ * the target is paused.
+ *
+ * @internal
+ */
+export class ChromeTargetManager
+ extends EventEmitter<TargetManagerEvents>
+ implements TargetManager
+{
+ #connection: Connection;
+ /**
+ * Keeps track of the following events: 'Target.targetCreated',
+ * 'Target.targetDestroyed', 'Target.targetInfoChanged'.
+ *
+ * A target becomes discovered when 'Target.targetCreated' is received.
+ * A target is removed from this map once 'Target.targetDestroyed' is
+ * received.
+ *
+ * `targetFilterCallback` has no effect on this map.
+ */
+ #discoveredTargetsByTargetId = new Map<string, Protocol.Target.TargetInfo>();
+ /**
+ * A target is added to this map once ChromeTargetManager has created
+ * a Target and attached at least once to it.
+ */
+ #attachedTargetsByTargetId = new Map<string, CdpTarget>();
+ /**
+ * Tracks which sessions attach to which target.
+ */
+ #attachedTargetsBySessionId = new Map<string, CdpTarget>();
+ /**
+ * If a target was filtered out by `targetFilterCallback`, we still receive
+ * events about it from CDP, but we don't forward them to the rest of Puppeteer.
+ */
+ #ignoredTargets = new Set<string>();
+ #targetFilterCallback: TargetFilterCallback | undefined;
+ #targetFactory: TargetFactory;
+
+ #attachedToTargetListenersBySession = new WeakMap<
+ CDPSession | Connection,
+ (event: Protocol.Target.AttachedToTargetEvent) => void
+ >();
+ #detachedFromTargetListenersBySession = new WeakMap<
+ CDPSession | Connection,
+ (event: Protocol.Target.DetachedFromTargetEvent) => void
+ >();
+
+ #initializeDeferred = Deferred.create<void>();
+ #targetsIdsForInit = new Set<string>();
+ #waitForInitiallyDiscoveredTargets = true;
+
+ #discoveryFilter: Protocol.Target.FilterEntry[] = [{}];
+
+ constructor(
+ connection: Connection,
+ targetFactory: TargetFactory,
+ targetFilterCallback?: TargetFilterCallback,
+ waitForInitiallyDiscoveredTargets = true
+ ) {
+ super();
+ this.#connection = connection;
+ this.#targetFilterCallback = targetFilterCallback;
+ this.#targetFactory = targetFactory;
+ this.#waitForInitiallyDiscoveredTargets = waitForInitiallyDiscoveredTargets;
+
+ this.#connection.on('Target.targetCreated', this.#onTargetCreated);
+ this.#connection.on('Target.targetDestroyed', this.#onTargetDestroyed);
+ this.#connection.on('Target.targetInfoChanged', this.#onTargetInfoChanged);
+ this.#connection.on(
+ CDPSessionEvent.SessionDetached,
+ this.#onSessionDetached
+ );
+ this.#setupAttachmentListeners(this.#connection);
+ }
+
+ #storeExistingTargetsForInit = () => {
+ if (!this.#waitForInitiallyDiscoveredTargets) {
+ return;
+ }
+ for (const [
+ targetId,
+ targetInfo,
+ ] of this.#discoveredTargetsByTargetId.entries()) {
+ const targetForFilter = new CdpTarget(
+ targetInfo,
+ undefined,
+ undefined,
+ this,
+ undefined
+ );
+ if (
+ (!this.#targetFilterCallback ||
+ this.#targetFilterCallback(targetForFilter)) &&
+ targetInfo.type !== 'browser'
+ ) {
+ this.#targetsIdsForInit.add(targetId);
+ }
+ }
+ };
+
+ async initialize(): Promise<void> {
+ await this.#connection.send('Target.setDiscoverTargets', {
+ discover: true,
+ filter: this.#discoveryFilter,
+ });
+
+ this.#storeExistingTargetsForInit();
+
+ await this.#connection.send('Target.setAutoAttach', {
+ waitForDebuggerOnStart: true,
+ flatten: true,
+ autoAttach: true,
+ filter: [
+ {
+ type: 'page',
+ exclude: true,
+ },
+ ...this.#discoveryFilter,
+ ],
+ });
+ this.#finishInitializationIfReady();
+ await this.#initializeDeferred.valueOrThrow();
+ }
+
+ dispose(): void {
+ this.#connection.off('Target.targetCreated', this.#onTargetCreated);
+ this.#connection.off('Target.targetDestroyed', this.#onTargetDestroyed);
+ this.#connection.off('Target.targetInfoChanged', this.#onTargetInfoChanged);
+ this.#connection.off(
+ CDPSessionEvent.SessionDetached,
+ this.#onSessionDetached
+ );
+
+ this.#removeAttachmentListeners(this.#connection);
+ }
+
+ getAvailableTargets(): ReadonlyMap<string, CdpTarget> {
+ return this.#attachedTargetsByTargetId;
+ }
+
+ #setupAttachmentListeners(session: CDPSession | Connection): void {
+ const listener = (event: Protocol.Target.AttachedToTargetEvent) => {
+ void this.#onAttachedToTarget(session, event);
+ };
+ assert(!this.#attachedToTargetListenersBySession.has(session));
+ this.#attachedToTargetListenersBySession.set(session, listener);
+ session.on('Target.attachedToTarget', listener);
+
+ const detachedListener = (
+ event: Protocol.Target.DetachedFromTargetEvent
+ ) => {
+ return this.#onDetachedFromTarget(session, event);
+ };
+ assert(!this.#detachedFromTargetListenersBySession.has(session));
+ this.#detachedFromTargetListenersBySession.set(session, detachedListener);
+ session.on('Target.detachedFromTarget', detachedListener);
+ }
+
+ #removeAttachmentListeners(session: CDPSession | Connection): void {
+ const listener = this.#attachedToTargetListenersBySession.get(session);
+ if (listener) {
+ session.off('Target.attachedToTarget', listener);
+ this.#attachedToTargetListenersBySession.delete(session);
+ }
+
+ if (this.#detachedFromTargetListenersBySession.has(session)) {
+ session.off(
+ 'Target.detachedFromTarget',
+ this.#detachedFromTargetListenersBySession.get(session)!
+ );
+ this.#detachedFromTargetListenersBySession.delete(session);
+ }
+ }
+
+ #onSessionDetached = (session: CDPSession) => {
+ this.#removeAttachmentListeners(session);
+ };
+
+ #onTargetCreated = async (event: Protocol.Target.TargetCreatedEvent) => {
+ this.#discoveredTargetsByTargetId.set(
+ event.targetInfo.targetId,
+ event.targetInfo
+ );
+
+ this.emit(TargetManagerEvent.TargetDiscovered, event.targetInfo);
+
+ // The connection is already attached to the browser target implicitly,
+ // therefore, no new CDPSession is created and we have special handling
+ // here.
+ if (event.targetInfo.type === 'browser' && event.targetInfo.attached) {
+ if (this.#attachedTargetsByTargetId.has(event.targetInfo.targetId)) {
+ return;
+ }
+ const target = this.#targetFactory(event.targetInfo, undefined);
+ target._initialize();
+ this.#attachedTargetsByTargetId.set(event.targetInfo.targetId, target);
+ }
+ };
+
+ #onTargetDestroyed = (event: Protocol.Target.TargetDestroyedEvent) => {
+ const targetInfo = this.#discoveredTargetsByTargetId.get(event.targetId);
+ this.#discoveredTargetsByTargetId.delete(event.targetId);
+ this.#finishInitializationIfReady(event.targetId);
+ if (
+ targetInfo?.type === 'service_worker' &&
+ this.#attachedTargetsByTargetId.has(event.targetId)
+ ) {
+ // Special case for service workers: report TargetGone event when
+ // the worker is destroyed.
+ const target = this.#attachedTargetsByTargetId.get(event.targetId);
+ if (target) {
+ this.emit(TargetManagerEvent.TargetGone, target);
+ this.#attachedTargetsByTargetId.delete(event.targetId);
+ }
+ }
+ };
+
+ #onTargetInfoChanged = (event: Protocol.Target.TargetInfoChangedEvent) => {
+ this.#discoveredTargetsByTargetId.set(
+ event.targetInfo.targetId,
+ event.targetInfo
+ );
+
+ if (
+ this.#ignoredTargets.has(event.targetInfo.targetId) ||
+ !this.#attachedTargetsByTargetId.has(event.targetInfo.targetId) ||
+ !event.targetInfo.attached
+ ) {
+ return;
+ }
+
+ const target = this.#attachedTargetsByTargetId.get(
+ event.targetInfo.targetId
+ );
+ if (!target) {
+ return;
+ }
+ const previousURL = target.url();
+ const wasInitialized =
+ target._initializedDeferred.value() === InitializationStatus.SUCCESS;
+
+ if (isPageTargetBecomingPrimary(target, event.targetInfo)) {
+ const session = target?._session();
+ assert(
+ session,
+ 'Target that is being activated is missing a CDPSession.'
+ );
+ session.parentSession()?.emit(CDPSessionEvent.Swapped, session);
+ }
+
+ target._targetInfoChanged(event.targetInfo);
+
+ if (wasInitialized && previousURL !== target.url()) {
+ this.emit(TargetManagerEvent.TargetChanged, {
+ target,
+ wasInitialized,
+ previousURL,
+ });
+ }
+ };
+
+ #onAttachedToTarget = async (
+ parentSession: Connection | CDPSession,
+ event: Protocol.Target.AttachedToTargetEvent
+ ) => {
+ const targetInfo = event.targetInfo;
+ const session = this.#connection.session(event.sessionId);
+ if (!session) {
+ throw new Error(`Session ${event.sessionId} was not created.`);
+ }
+
+ const silentDetach = async () => {
+ await session.send('Runtime.runIfWaitingForDebugger').catch(debugError);
+ // We don't use `session.detach()` because that dispatches all commands on
+ // the connection instead of the parent session.
+ await parentSession
+ .send('Target.detachFromTarget', {
+ sessionId: session.id(),
+ })
+ .catch(debugError);
+ };
+
+ if (!this.#connection.isAutoAttached(targetInfo.targetId)) {
+ return;
+ }
+
+ // Special case for service workers: being attached to service workers will
+ // prevent them from ever being destroyed. Therefore, we silently detach
+ // from service workers unless the connection was manually created via
+ // `page.worker()`. To determine this, we use
+ // `this.#connection.isAutoAttached(targetInfo.targetId)`. In the future, we
+ // should determine if a target is auto-attached or not with the help of
+ // CDP.
+ if (targetInfo.type === 'service_worker') {
+ this.#finishInitializationIfReady(targetInfo.targetId);
+ await silentDetach();
+ if (this.#attachedTargetsByTargetId.has(targetInfo.targetId)) {
+ return;
+ }
+ const target = this.#targetFactory(targetInfo);
+ target._initialize();
+ this.#attachedTargetsByTargetId.set(targetInfo.targetId, target);
+ this.emit(TargetManagerEvent.TargetAvailable, target);
+ return;
+ }
+
+ const isExistingTarget = this.#attachedTargetsByTargetId.has(
+ targetInfo.targetId
+ );
+
+ const target = isExistingTarget
+ ? this.#attachedTargetsByTargetId.get(targetInfo.targetId)!
+ : this.#targetFactory(
+ targetInfo,
+ session,
+ parentSession instanceof CDPSession ? parentSession : undefined
+ );
+
+ if (this.#targetFilterCallback && !this.#targetFilterCallback(target)) {
+ this.#ignoredTargets.add(targetInfo.targetId);
+ this.#finishInitializationIfReady(targetInfo.targetId);
+ await silentDetach();
+ return;
+ }
+
+ this.#setupAttachmentListeners(session);
+
+ if (isExistingTarget) {
+ (session as CdpCDPSession)._setTarget(target);
+ this.#attachedTargetsBySessionId.set(
+ session.id(),
+ this.#attachedTargetsByTargetId.get(targetInfo.targetId)!
+ );
+ } else {
+ target._initialize();
+ this.#attachedTargetsByTargetId.set(targetInfo.targetId, target);
+ this.#attachedTargetsBySessionId.set(session.id(), target);
+ }
+
+ parentSession.emit(CDPSessionEvent.Ready, session);
+
+ this.#targetsIdsForInit.delete(target._targetId);
+ if (!isExistingTarget) {
+ this.emit(TargetManagerEvent.TargetAvailable, target);
+ }
+ this.#finishInitializationIfReady();
+
+ // TODO: the browser might be shutting down here. What do we do with the
+ // error?
+ await Promise.all([
+ session.send('Target.setAutoAttach', {
+ waitForDebuggerOnStart: true,
+ flatten: true,
+ autoAttach: true,
+ filter: this.#discoveryFilter,
+ }),
+ session.send('Runtime.runIfWaitingForDebugger'),
+ ]).catch(debugError);
+ };
+
+ #finishInitializationIfReady(targetId?: string): void {
+ targetId !== undefined && this.#targetsIdsForInit.delete(targetId);
+ if (this.#targetsIdsForInit.size === 0) {
+ this.#initializeDeferred.resolve();
+ }
+ }
+
+ #onDetachedFromTarget = (
+ _parentSession: Connection | CDPSession,
+ event: Protocol.Target.DetachedFromTargetEvent
+ ) => {
+ const target = this.#attachedTargetsBySessionId.get(event.sessionId);
+
+ this.#attachedTargetsBySessionId.delete(event.sessionId);
+
+ if (!target) {
+ return;
+ }
+
+ this.#attachedTargetsByTargetId.delete(target._targetId);
+ this.emit(TargetManagerEvent.TargetGone, target);
+ };
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Connection.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Connection.ts
new file mode 100644
index 0000000000..3c565341b3
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Connection.ts
@@ -0,0 +1,273 @@
+/**
+ * @license
+ * Copyright 2017 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {Protocol} from 'devtools-protocol';
+import type {ProtocolMapping} from 'devtools-protocol/types/protocol-mapping.js';
+
+import type {CommandOptions} from '../api/CDPSession.js';
+import {
+ CDPSessionEvent,
+ type CDPSession,
+ type CDPSessionEvents,
+} from '../api/CDPSession.js';
+import {CallbackRegistry} from '../common/CallbackRegistry.js';
+import type {ConnectionTransport} from '../common/ConnectionTransport.js';
+import {debug} from '../common/Debug.js';
+import {TargetCloseError} from '../common/Errors.js';
+import {EventEmitter} from '../common/EventEmitter.js';
+import {createProtocolErrorMessage} from '../util/ErrorLike.js';
+
+import {CdpCDPSession} from './CDPSession.js';
+
+const debugProtocolSend = debug('puppeteer:protocol:SEND â–º');
+const debugProtocolReceive = debug('puppeteer:protocol:RECV â—€');
+
+/**
+ * @public
+ */
+export type {ConnectionTransport, ProtocolMapping};
+
+/**
+ * @public
+ */
+export class Connection extends EventEmitter<CDPSessionEvents> {
+ #url: string;
+ #transport: ConnectionTransport;
+ #delay: number;
+ #timeout: number;
+ #sessions = new Map<string, CdpCDPSession>();
+ #closed = false;
+ #manuallyAttached = new Set<string>();
+ #callbacks = new CallbackRegistry();
+
+ constructor(
+ url: string,
+ transport: ConnectionTransport,
+ delay = 0,
+ timeout?: number
+ ) {
+ super();
+ this.#url = url;
+ this.#delay = delay;
+ this.#timeout = timeout ?? 180_000;
+
+ this.#transport = transport;
+ this.#transport.onmessage = this.onMessage.bind(this);
+ this.#transport.onclose = this.#onClose.bind(this);
+ }
+
+ static fromSession(session: CDPSession): Connection | undefined {
+ return session.connection();
+ }
+
+ get timeout(): number {
+ return this.#timeout;
+ }
+
+ /**
+ * @internal
+ */
+ get _closed(): boolean {
+ return this.#closed;
+ }
+
+ /**
+ * @internal
+ */
+ get _sessions(): Map<string, CDPSession> {
+ return this.#sessions;
+ }
+
+ /**
+ * @param sessionId - The session id
+ * @returns The current CDP session if it exists
+ */
+ session(sessionId: string): CDPSession | null {
+ return this.#sessions.get(sessionId) || null;
+ }
+
+ url(): string {
+ return this.#url;
+ }
+
+ send<T extends keyof ProtocolMapping.Commands>(
+ method: T,
+ params?: ProtocolMapping.Commands[T]['paramsType'][0],
+ options?: CommandOptions
+ ): Promise<ProtocolMapping.Commands[T]['returnType']> {
+ // There is only ever 1 param arg passed, but the Protocol defines it as an
+ // array of 0 or 1 items See this comment:
+ // https://github.com/ChromeDevTools/devtools-protocol/pull/113#issuecomment-412603285
+ // which explains why the protocol defines the params this way for better
+ // type-inference.
+ // So now we check if there are any params or not and deal with them accordingly.
+ return this._rawSend(this.#callbacks, method, params, undefined, options);
+ }
+
+ /**
+ * @internal
+ */
+ _rawSend<T extends keyof ProtocolMapping.Commands>(
+ callbacks: CallbackRegistry,
+ method: T,
+ params: ProtocolMapping.Commands[T]['paramsType'][0],
+ sessionId?: string,
+ options?: CommandOptions
+ ): Promise<ProtocolMapping.Commands[T]['returnType']> {
+ return callbacks.create(method, options?.timeout ?? this.#timeout, id => {
+ const stringifiedMessage = JSON.stringify({
+ method,
+ params,
+ id,
+ sessionId,
+ });
+ debugProtocolSend(stringifiedMessage);
+ this.#transport.send(stringifiedMessage);
+ }) as Promise<ProtocolMapping.Commands[T]['returnType']>;
+ }
+
+ /**
+ * @internal
+ */
+ async closeBrowser(): Promise<void> {
+ await this.send('Browser.close');
+ }
+
+ /**
+ * @internal
+ */
+ protected async onMessage(message: string): Promise<void> {
+ if (this.#delay) {
+ await new Promise(r => {
+ return setTimeout(r, this.#delay);
+ });
+ }
+ debugProtocolReceive(message);
+ const object = JSON.parse(message);
+ if (object.method === 'Target.attachedToTarget') {
+ const sessionId = object.params.sessionId;
+ const session = new CdpCDPSession(
+ this,
+ object.params.targetInfo.type,
+ sessionId,
+ object.sessionId
+ );
+ this.#sessions.set(sessionId, session);
+ this.emit(CDPSessionEvent.SessionAttached, session);
+ const parentSession = this.#sessions.get(object.sessionId);
+ if (parentSession) {
+ parentSession.emit(CDPSessionEvent.SessionAttached, session);
+ }
+ } else if (object.method === 'Target.detachedFromTarget') {
+ const session = this.#sessions.get(object.params.sessionId);
+ if (session) {
+ session._onClosed();
+ this.#sessions.delete(object.params.sessionId);
+ this.emit(CDPSessionEvent.SessionDetached, session);
+ const parentSession = this.#sessions.get(object.sessionId);
+ if (parentSession) {
+ parentSession.emit(CDPSessionEvent.SessionDetached, session);
+ }
+ }
+ }
+ if (object.sessionId) {
+ const session = this.#sessions.get(object.sessionId);
+ if (session) {
+ session._onMessage(object);
+ }
+ } else if (object.id) {
+ if (object.error) {
+ this.#callbacks.reject(
+ object.id,
+ createProtocolErrorMessage(object),
+ object.error.message
+ );
+ } else {
+ this.#callbacks.resolve(object.id, object.result);
+ }
+ } else {
+ this.emit(object.method, object.params);
+ }
+ }
+
+ #onClose(): void {
+ if (this.#closed) {
+ return;
+ }
+ this.#closed = true;
+ this.#transport.onmessage = undefined;
+ this.#transport.onclose = undefined;
+ this.#callbacks.clear();
+ for (const session of this.#sessions.values()) {
+ session._onClosed();
+ }
+ this.#sessions.clear();
+ this.emit(CDPSessionEvent.Disconnected, undefined);
+ }
+
+ dispose(): void {
+ this.#onClose();
+ this.#transport.close();
+ }
+
+ /**
+ * @internal
+ */
+ isAutoAttached(targetId: string): boolean {
+ return !this.#manuallyAttached.has(targetId);
+ }
+
+ /**
+ * @internal
+ */
+ async _createSession(
+ targetInfo: Protocol.Target.TargetInfo,
+ isAutoAttachEmulated = true
+ ): Promise<CDPSession> {
+ if (!isAutoAttachEmulated) {
+ this.#manuallyAttached.add(targetInfo.targetId);
+ }
+ const {sessionId} = await this.send('Target.attachToTarget', {
+ targetId: targetInfo.targetId,
+ flatten: true,
+ });
+ this.#manuallyAttached.delete(targetInfo.targetId);
+ const session = this.#sessions.get(sessionId);
+ if (!session) {
+ throw new Error('CDPSession creation failed.');
+ }
+ return session;
+ }
+
+ /**
+ * @param targetInfo - The target info
+ * @returns The CDP session that is created
+ */
+ async createSession(
+ targetInfo: Protocol.Target.TargetInfo
+ ): Promise<CDPSession> {
+ return await this._createSession(targetInfo, false);
+ }
+
+ /**
+ * @internal
+ */
+ getPendingProtocolErrors(): Error[] {
+ const result: Error[] = [];
+ result.push(...this.#callbacks.getPendingProtocolErrors());
+ for (const session of this.#sessions.values()) {
+ result.push(...session.getPendingProtocolErrors());
+ }
+ return result;
+ }
+}
+
+/**
+ * @internal
+ */
+export function isTargetClosedError(error: Error): boolean {
+ return error instanceof TargetCloseError;
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Coverage.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Coverage.ts
new file mode 100644
index 0000000000..db995fb45b
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Coverage.ts
@@ -0,0 +1,513 @@
+/**
+ * @license
+ * Copyright 2017 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {Protocol} from 'devtools-protocol';
+
+import type {CDPSession} from '../api/CDPSession.js';
+import {EventSubscription} from '../common/EventEmitter.js';
+import {debugError, PuppeteerURL} from '../common/util.js';
+import {assert} from '../util/assert.js';
+import {DisposableStack} from '../util/disposable.js';
+
+/**
+ * The CoverageEntry class represents one entry of the coverage report.
+ * @public
+ */
+export interface CoverageEntry {
+ /**
+ * The URL of the style sheet or script.
+ */
+ url: string;
+ /**
+ * The content of the style sheet or script.
+ */
+ text: string;
+ /**
+ * The covered range as start and end positions.
+ */
+ ranges: Array<{start: number; end: number}>;
+}
+
+/**
+ * The CoverageEntry class for JavaScript
+ * @public
+ */
+export interface JSCoverageEntry extends CoverageEntry {
+ /**
+ * Raw V8 script coverage entry.
+ */
+ rawScriptCoverage?: Protocol.Profiler.ScriptCoverage;
+}
+
+/**
+ * Set of configurable options for JS coverage.
+ * @public
+ */
+export interface JSCoverageOptions {
+ /**
+ * Whether to reset coverage on every navigation.
+ */
+ resetOnNavigation?: boolean;
+ /**
+ * Whether anonymous scripts generated by the page should be reported.
+ */
+ reportAnonymousScripts?: boolean;
+ /**
+ * Whether the result includes raw V8 script coverage entries.
+ */
+ includeRawScriptCoverage?: boolean;
+ /**
+ * Whether to collect coverage information at the block level.
+ * If true, coverage will be collected at the block level (this is the default).
+ * If false, coverage will be collected at the function level.
+ */
+ useBlockCoverage?: boolean;
+}
+
+/**
+ * Set of configurable options for CSS coverage.
+ * @public
+ */
+export interface CSSCoverageOptions {
+ /**
+ * Whether to reset coverage on every navigation.
+ */
+ resetOnNavigation?: boolean;
+}
+
+/**
+ * The Coverage class provides methods to gather information about parts of
+ * JavaScript and CSS that were used by the page.
+ *
+ * @remarks
+ * To output coverage in a form consumable by {@link https://github.com/istanbuljs | Istanbul},
+ * see {@link https://github.com/istanbuljs/puppeteer-to-istanbul | puppeteer-to-istanbul}.
+ *
+ * @example
+ * An example of using JavaScript and CSS coverage to get percentage of initially
+ * executed code:
+ *
+ * ```ts
+ * // Enable both JavaScript and CSS coverage
+ * await Promise.all([
+ * page.coverage.startJSCoverage(),
+ * page.coverage.startCSSCoverage(),
+ * ]);
+ * // Navigate to page
+ * await page.goto('https://example.com');
+ * // Disable both JavaScript and CSS coverage
+ * const [jsCoverage, cssCoverage] = await Promise.all([
+ * page.coverage.stopJSCoverage(),
+ * page.coverage.stopCSSCoverage(),
+ * ]);
+ * let totalBytes = 0;
+ * let usedBytes = 0;
+ * const coverage = [...jsCoverage, ...cssCoverage];
+ * for (const entry of coverage) {
+ * totalBytes += entry.text.length;
+ * for (const range of entry.ranges) usedBytes += range.end - range.start - 1;
+ * }
+ * console.log(`Bytes used: ${(usedBytes / totalBytes) * 100}%`);
+ * ```
+ *
+ * @public
+ */
+export class Coverage {
+ #jsCoverage: JSCoverage;
+ #cssCoverage: CSSCoverage;
+
+ constructor(client: CDPSession) {
+ this.#jsCoverage = new JSCoverage(client);
+ this.#cssCoverage = new CSSCoverage(client);
+ }
+
+ /**
+ * @internal
+ */
+ updateClient(client: CDPSession): void {
+ this.#jsCoverage.updateClient(client);
+ this.#cssCoverage.updateClient(client);
+ }
+
+ /**
+ * @param options - Set of configurable options for coverage defaults to
+ * `resetOnNavigation : true, reportAnonymousScripts : false,`
+ * `includeRawScriptCoverage : false, useBlockCoverage : true`
+ * @returns Promise that resolves when coverage is started.
+ *
+ * @remarks
+ * Anonymous scripts are ones that don't have an associated url. These are
+ * scripts that are dynamically created on the page using `eval` or
+ * `new Function`. If `reportAnonymousScripts` is set to `true`, anonymous
+ * scripts URL will start with `debugger://VM` (unless a magic //# sourceURL
+ * comment is present, in which case that will the be URL).
+ */
+ async startJSCoverage(options: JSCoverageOptions = {}): Promise<void> {
+ return await this.#jsCoverage.start(options);
+ }
+
+ /**
+ * Promise that resolves to the array of coverage reports for
+ * all scripts.
+ *
+ * @remarks
+ * JavaScript Coverage doesn't include anonymous scripts by default.
+ * However, scripts with sourceURLs are reported.
+ */
+ async stopJSCoverage(): Promise<JSCoverageEntry[]> {
+ return await this.#jsCoverage.stop();
+ }
+
+ /**
+ * @param options - Set of configurable options for coverage, defaults to
+ * `resetOnNavigation : true`
+ * @returns Promise that resolves when coverage is started.
+ */
+ async startCSSCoverage(options: CSSCoverageOptions = {}): Promise<void> {
+ return await this.#cssCoverage.start(options);
+ }
+
+ /**
+ * Promise that resolves to the array of coverage reports
+ * for all stylesheets.
+ *
+ * @remarks
+ * CSS Coverage doesn't include dynamically injected style tags
+ * without sourceURLs.
+ */
+ async stopCSSCoverage(): Promise<CoverageEntry[]> {
+ return await this.#cssCoverage.stop();
+ }
+}
+
+/**
+ * @public
+ */
+export class JSCoverage {
+ #client: CDPSession;
+ #enabled = false;
+ #scriptURLs = new Map<string, string>();
+ #scriptSources = new Map<string, string>();
+ #subscriptions?: DisposableStack;
+ #resetOnNavigation = false;
+ #reportAnonymousScripts = false;
+ #includeRawScriptCoverage = false;
+
+ constructor(client: CDPSession) {
+ this.#client = client;
+ }
+
+ /**
+ * @internal
+ */
+ updateClient(client: CDPSession): void {
+ this.#client = client;
+ }
+
+ async start(
+ options: {
+ resetOnNavigation?: boolean;
+ reportAnonymousScripts?: boolean;
+ includeRawScriptCoverage?: boolean;
+ useBlockCoverage?: boolean;
+ } = {}
+ ): Promise<void> {
+ assert(!this.#enabled, 'JSCoverage is already enabled');
+ const {
+ resetOnNavigation = true,
+ reportAnonymousScripts = false,
+ includeRawScriptCoverage = false,
+ useBlockCoverage = true,
+ } = options;
+ this.#resetOnNavigation = resetOnNavigation;
+ this.#reportAnonymousScripts = reportAnonymousScripts;
+ this.#includeRawScriptCoverage = includeRawScriptCoverage;
+ this.#enabled = true;
+ this.#scriptURLs.clear();
+ this.#scriptSources.clear();
+ this.#subscriptions = new DisposableStack();
+ this.#subscriptions.use(
+ new EventSubscription(
+ this.#client,
+ 'Debugger.scriptParsed',
+ this.#onScriptParsed.bind(this)
+ )
+ );
+ this.#subscriptions.use(
+ new EventSubscription(
+ this.#client,
+ 'Runtime.executionContextsCleared',
+ this.#onExecutionContextsCleared.bind(this)
+ )
+ );
+ await Promise.all([
+ this.#client.send('Profiler.enable'),
+ this.#client.send('Profiler.startPreciseCoverage', {
+ callCount: this.#includeRawScriptCoverage,
+ detailed: useBlockCoverage,
+ }),
+ this.#client.send('Debugger.enable'),
+ this.#client.send('Debugger.setSkipAllPauses', {skip: true}),
+ ]);
+ }
+
+ #onExecutionContextsCleared(): void {
+ if (!this.#resetOnNavigation) {
+ return;
+ }
+ this.#scriptURLs.clear();
+ this.#scriptSources.clear();
+ }
+
+ async #onScriptParsed(
+ event: Protocol.Debugger.ScriptParsedEvent
+ ): Promise<void> {
+ // Ignore puppeteer-injected scripts
+ if (PuppeteerURL.isPuppeteerURL(event.url)) {
+ return;
+ }
+ // Ignore other anonymous scripts unless the reportAnonymousScripts option is true.
+ if (!event.url && !this.#reportAnonymousScripts) {
+ return;
+ }
+ try {
+ const response = await this.#client.send('Debugger.getScriptSource', {
+ scriptId: event.scriptId,
+ });
+ this.#scriptURLs.set(event.scriptId, event.url);
+ this.#scriptSources.set(event.scriptId, response.scriptSource);
+ } catch (error) {
+ // This might happen if the page has already navigated away.
+ debugError(error);
+ }
+ }
+
+ async stop(): Promise<JSCoverageEntry[]> {
+ assert(this.#enabled, 'JSCoverage is not enabled');
+ this.#enabled = false;
+
+ const result = await Promise.all([
+ this.#client.send('Profiler.takePreciseCoverage'),
+ this.#client.send('Profiler.stopPreciseCoverage'),
+ this.#client.send('Profiler.disable'),
+ this.#client.send('Debugger.disable'),
+ ]);
+
+ this.#subscriptions?.dispose();
+
+ const coverage = [];
+ const profileResponse = result[0];
+
+ for (const entry of profileResponse.result) {
+ let url = this.#scriptURLs.get(entry.scriptId);
+ if (!url && this.#reportAnonymousScripts) {
+ url = 'debugger://VM' + entry.scriptId;
+ }
+ const text = this.#scriptSources.get(entry.scriptId);
+ if (text === undefined || url === undefined) {
+ continue;
+ }
+ const flattenRanges = [];
+ for (const func of entry.functions) {
+ flattenRanges.push(...func.ranges);
+ }
+ const ranges = convertToDisjointRanges(flattenRanges);
+ if (!this.#includeRawScriptCoverage) {
+ coverage.push({url, ranges, text});
+ } else {
+ coverage.push({url, ranges, text, rawScriptCoverage: entry});
+ }
+ }
+ return coverage;
+ }
+}
+
+/**
+ * @public
+ */
+export class CSSCoverage {
+ #client: CDPSession;
+ #enabled = false;
+ #stylesheetURLs = new Map<string, string>();
+ #stylesheetSources = new Map<string, string>();
+ #eventListeners?: DisposableStack;
+ #resetOnNavigation = false;
+
+ constructor(client: CDPSession) {
+ this.#client = client;
+ }
+
+ /**
+ * @internal
+ */
+ updateClient(client: CDPSession): void {
+ this.#client = client;
+ }
+
+ async start(options: {resetOnNavigation?: boolean} = {}): Promise<void> {
+ assert(!this.#enabled, 'CSSCoverage is already enabled');
+ const {resetOnNavigation = true} = options;
+ this.#resetOnNavigation = resetOnNavigation;
+ this.#enabled = true;
+ this.#stylesheetURLs.clear();
+ this.#stylesheetSources.clear();
+ this.#eventListeners = new DisposableStack();
+ this.#eventListeners.use(
+ new EventSubscription(
+ this.#client,
+ 'CSS.styleSheetAdded',
+ this.#onStyleSheet.bind(this)
+ )
+ );
+ this.#eventListeners.use(
+ new EventSubscription(
+ this.#client,
+ 'Runtime.executionContextsCleared',
+ this.#onExecutionContextsCleared.bind(this)
+ )
+ );
+ await Promise.all([
+ this.#client.send('DOM.enable'),
+ this.#client.send('CSS.enable'),
+ this.#client.send('CSS.startRuleUsageTracking'),
+ ]);
+ }
+
+ #onExecutionContextsCleared(): void {
+ if (!this.#resetOnNavigation) {
+ return;
+ }
+ this.#stylesheetURLs.clear();
+ this.#stylesheetSources.clear();
+ }
+
+ async #onStyleSheet(event: Protocol.CSS.StyleSheetAddedEvent): Promise<void> {
+ const header = event.header;
+ // Ignore anonymous scripts
+ if (!header.sourceURL) {
+ return;
+ }
+ try {
+ const response = await this.#client.send('CSS.getStyleSheetText', {
+ styleSheetId: header.styleSheetId,
+ });
+ this.#stylesheetURLs.set(header.styleSheetId, header.sourceURL);
+ this.#stylesheetSources.set(header.styleSheetId, response.text);
+ } catch (error) {
+ // This might happen if the page has already navigated away.
+ debugError(error);
+ }
+ }
+
+ async stop(): Promise<CoverageEntry[]> {
+ assert(this.#enabled, 'CSSCoverage is not enabled');
+ this.#enabled = false;
+ const ruleTrackingResponse = await this.#client.send(
+ 'CSS.stopRuleUsageTracking'
+ );
+ await Promise.all([
+ this.#client.send('CSS.disable'),
+ this.#client.send('DOM.disable'),
+ ]);
+ this.#eventListeners?.dispose();
+
+ // aggregate by styleSheetId
+ const styleSheetIdToCoverage = new Map();
+ for (const entry of ruleTrackingResponse.ruleUsage) {
+ let ranges = styleSheetIdToCoverage.get(entry.styleSheetId);
+ if (!ranges) {
+ ranges = [];
+ styleSheetIdToCoverage.set(entry.styleSheetId, ranges);
+ }
+ ranges.push({
+ startOffset: entry.startOffset,
+ endOffset: entry.endOffset,
+ count: entry.used ? 1 : 0,
+ });
+ }
+
+ const coverage: CoverageEntry[] = [];
+ for (const styleSheetId of this.#stylesheetURLs.keys()) {
+ const url = this.#stylesheetURLs.get(styleSheetId);
+ assert(
+ typeof url !== 'undefined',
+ `Stylesheet URL is undefined (styleSheetId=${styleSheetId})`
+ );
+ const text = this.#stylesheetSources.get(styleSheetId);
+ assert(
+ typeof text !== 'undefined',
+ `Stylesheet text is undefined (styleSheetId=${styleSheetId})`
+ );
+ const ranges = convertToDisjointRanges(
+ styleSheetIdToCoverage.get(styleSheetId) || []
+ );
+ coverage.push({url, ranges, text});
+ }
+
+ return coverage;
+ }
+}
+
+function convertToDisjointRanges(
+ nestedRanges: Array<{startOffset: number; endOffset: number; count: number}>
+): Array<{start: number; end: number}> {
+ const points = [];
+ for (const range of nestedRanges) {
+ points.push({offset: range.startOffset, type: 0, range});
+ points.push({offset: range.endOffset, type: 1, range});
+ }
+ // Sort points to form a valid parenthesis sequence.
+ points.sort((a, b) => {
+ // Sort with increasing offsets.
+ if (a.offset !== b.offset) {
+ return a.offset - b.offset;
+ }
+ // All "end" points should go before "start" points.
+ if (a.type !== b.type) {
+ return b.type - a.type;
+ }
+ const aLength = a.range.endOffset - a.range.startOffset;
+ const bLength = b.range.endOffset - b.range.startOffset;
+ // For two "start" points, the one with longer range goes first.
+ if (a.type === 0) {
+ return bLength - aLength;
+ }
+ // For two "end" points, the one with shorter range goes first.
+ return aLength - bLength;
+ });
+
+ const hitCountStack = [];
+ const results: Array<{
+ start: number;
+ end: number;
+ }> = [];
+ let lastOffset = 0;
+ // Run scanning line to intersect all ranges.
+ for (const point of points) {
+ if (
+ hitCountStack.length &&
+ lastOffset < point.offset &&
+ hitCountStack[hitCountStack.length - 1]! > 0
+ ) {
+ const lastResult = results[results.length - 1];
+ if (lastResult && lastResult.end === lastOffset) {
+ lastResult.end = point.offset;
+ } else {
+ results.push({start: lastOffset, end: point.offset});
+ }
+ }
+ lastOffset = point.offset;
+ if (point.type === 0) {
+ hitCountStack.push(point.range.count);
+ } else {
+ hitCountStack.pop();
+ }
+ }
+ // Filter out empty ranges.
+ return results.filter(range => {
+ return range.end - range.start > 0;
+ });
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/DeviceRequestPrompt.test.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/DeviceRequestPrompt.test.ts
new file mode 100644
index 0000000000..7d75e97eaf
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/DeviceRequestPrompt.test.ts
@@ -0,0 +1,471 @@
+/**
+ * @license
+ * Copyright 2022 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {describe, it} from 'node:test';
+
+import expect from 'expect';
+
+import type {CDPSessionEvents} from '../api/CDPSession.js';
+import {TimeoutError} from '../common/Errors.js';
+import {EventEmitter} from '../common/EventEmitter.js';
+import {TimeoutSettings} from '../common/TimeoutSettings.js';
+
+import {
+ DeviceRequestPrompt,
+ DeviceRequestPromptDevice,
+ DeviceRequestPromptManager,
+} from './DeviceRequestPrompt.js';
+
+class MockCDPSession extends EventEmitter<CDPSessionEvents> {
+ async send(): Promise<any> {}
+ connection() {
+ return undefined;
+ }
+ async detach() {}
+ id() {
+ return '1';
+ }
+ parentSession() {
+ return undefined;
+ }
+}
+
+describe('DeviceRequestPrompt', function () {
+ describe('waitForDevicePrompt', function () {
+ it('should return prompt', async () => {
+ const client = new MockCDPSession();
+ const timeoutSettings = new TimeoutSettings();
+ const manager = new DeviceRequestPromptManager(client, timeoutSettings);
+
+ const [prompt] = await Promise.all([
+ manager.waitForDevicePrompt(),
+ (() => {
+ client.emit('DeviceAccess.deviceRequestPrompted', {
+ id: '00000000000000000000000000000000',
+ devices: [],
+ });
+ })(),
+ ]);
+ expect(prompt).toBeTruthy();
+ });
+
+ it('should respect timeout', async () => {
+ const client = new MockCDPSession();
+ const timeoutSettings = new TimeoutSettings();
+ const manager = new DeviceRequestPromptManager(client, timeoutSettings);
+
+ await expect(
+ manager.waitForDevicePrompt({timeout: 1})
+ ).rejects.toBeInstanceOf(TimeoutError);
+ });
+
+ it('should respect default timeout when there is no custom timeout', async () => {
+ const client = new MockCDPSession();
+ const timeoutSettings = new TimeoutSettings();
+ const manager = new DeviceRequestPromptManager(client, timeoutSettings);
+
+ timeoutSettings.setDefaultTimeout(1);
+ await expect(manager.waitForDevicePrompt()).rejects.toBeInstanceOf(
+ TimeoutError
+ );
+ });
+
+ it('should prioritize exact timeout over default timeout', async () => {
+ const client = new MockCDPSession();
+ const timeoutSettings = new TimeoutSettings();
+ const manager = new DeviceRequestPromptManager(client, timeoutSettings);
+
+ timeoutSettings.setDefaultTimeout(0);
+ await expect(
+ manager.waitForDevicePrompt({timeout: 1})
+ ).rejects.toBeInstanceOf(TimeoutError);
+ });
+
+ it('should work with no timeout', async () => {
+ const client = new MockCDPSession();
+ const timeoutSettings = new TimeoutSettings();
+ const manager = new DeviceRequestPromptManager(client, timeoutSettings);
+
+ const [prompt] = await Promise.all([
+ manager.waitForDevicePrompt({timeout: 0}),
+ (async () => {
+ await new Promise(resolve => {
+ setTimeout(resolve, 50);
+ });
+ client.emit('DeviceAccess.deviceRequestPrompted', {
+ id: '00000000000000000000000000000000',
+ devices: [],
+ });
+ })(),
+ ]);
+ expect(prompt).toBeTruthy();
+ });
+
+ it('should return the same prompt when there are many watchdogs simultaneously', async () => {
+ const client = new MockCDPSession();
+ const timeoutSettings = new TimeoutSettings();
+ const manager = new DeviceRequestPromptManager(client, timeoutSettings);
+
+ const [prompt1, prompt2] = await Promise.all([
+ manager.waitForDevicePrompt(),
+ manager.waitForDevicePrompt(),
+ (() => {
+ client.emit('DeviceAccess.deviceRequestPrompted', {
+ id: '00000000000000000000000000000000',
+ devices: [],
+ });
+ })(),
+ ]);
+ expect(prompt1 === prompt2).toBeTruthy();
+ });
+
+ it('should listen and shortcut when there are no watchdogs', async () => {
+ const client = new MockCDPSession();
+ const timeoutSettings = new TimeoutSettings();
+ const manager = new DeviceRequestPromptManager(client, timeoutSettings);
+
+ client.emit('DeviceAccess.deviceRequestPrompted', {
+ id: '00000000000000000000000000000000',
+ devices: [],
+ });
+
+ expect(manager).toBeTruthy();
+ });
+ });
+
+ describe('DeviceRequestPrompt.devices', function () {
+ it('lists devices as they arrive', function () {
+ const client = new MockCDPSession();
+ const timeoutSettings = new TimeoutSettings();
+ const prompt = new DeviceRequestPrompt(client, timeoutSettings, {
+ id: '00000000000000000000000000000000',
+ devices: [],
+ });
+
+ expect(prompt.devices).toHaveLength(0);
+ client.emit('DeviceAccess.deviceRequestPrompted', {
+ id: '00000000000000000000000000000000',
+ devices: [{id: '00000000', name: 'Device 0'}],
+ });
+ expect(prompt.devices).toHaveLength(1);
+ client.emit('DeviceAccess.deviceRequestPrompted', {
+ id: '00000000000000000000000000000000',
+ devices: [
+ {id: '00000000', name: 'Device 0'},
+ {id: '11111111', name: 'Device 1'},
+ ],
+ });
+ expect(prompt.devices).toHaveLength(2);
+ expect(prompt.devices[0]).toBeInstanceOf(DeviceRequestPromptDevice);
+ expect(prompt.devices[1]).toBeInstanceOf(DeviceRequestPromptDevice);
+ });
+
+ it('does not list devices from events of another prompt', function () {
+ const client = new MockCDPSession();
+ const timeoutSettings = new TimeoutSettings();
+ const prompt = new DeviceRequestPrompt(client, timeoutSettings, {
+ id: '00000000000000000000000000000000',
+ devices: [],
+ });
+
+ expect(prompt.devices).toHaveLength(0);
+ client.emit('DeviceAccess.deviceRequestPrompted', {
+ id: '88888888888888888888888888888888',
+ devices: [
+ {id: '00000000', name: 'Device 0'},
+ {id: '11111111', name: 'Device 1'},
+ ],
+ });
+ expect(prompt.devices).toHaveLength(0);
+ });
+ });
+
+ describe('DeviceRequestPrompt.waitForDevice', function () {
+ it('should return first matching device', async () => {
+ const client = new MockCDPSession();
+ const timeoutSettings = new TimeoutSettings();
+ const prompt = new DeviceRequestPrompt(client, timeoutSettings, {
+ id: '00000000000000000000000000000000',
+ devices: [],
+ });
+
+ const [device] = await Promise.all([
+ prompt.waitForDevice(({name}) => {
+ return name.includes('1');
+ }),
+ (() => {
+ client.emit('DeviceAccess.deviceRequestPrompted', {
+ id: '00000000000000000000000000000000',
+ devices: [{id: '00000000', name: 'Device 0'}],
+ });
+ client.emit('DeviceAccess.deviceRequestPrompted', {
+ id: '00000000000000000000000000000000',
+ devices: [
+ {id: '00000000', name: 'Device 0'},
+ {id: '11111111', name: 'Device 1'},
+ ],
+ });
+ })(),
+ ]);
+ expect(device).toBeInstanceOf(DeviceRequestPromptDevice);
+ });
+
+ it('should return first matching device from already known devices', async () => {
+ const client = new MockCDPSession();
+ const timeoutSettings = new TimeoutSettings();
+ const prompt = new DeviceRequestPrompt(client, timeoutSettings, {
+ id: '00000000000000000000000000000000',
+ devices: [
+ {id: '00000000', name: 'Device 0'},
+ {id: '11111111', name: 'Device 1'},
+ ],
+ });
+
+ const device = await prompt.waitForDevice(({name}) => {
+ return name.includes('1');
+ });
+ expect(device).toBeInstanceOf(DeviceRequestPromptDevice);
+ });
+
+ it('should return device in the devices list', async () => {
+ const client = new MockCDPSession();
+ const timeoutSettings = new TimeoutSettings();
+ const prompt = new DeviceRequestPrompt(client, timeoutSettings, {
+ id: '00000000000000000000000000000000',
+ devices: [],
+ });
+
+ const [device] = await Promise.all([
+ prompt.waitForDevice(({name}) => {
+ return name.includes('1');
+ }),
+ (() => {
+ client.emit('DeviceAccess.deviceRequestPrompted', {
+ id: '00000000000000000000000000000000',
+ devices: [
+ {id: '00000000', name: 'Device 0'},
+ {id: '11111111', name: 'Device 1'},
+ ],
+ });
+ })(),
+ ]);
+ expect(prompt.devices).toContain(device);
+ });
+
+ it('should respect timeout', async () => {
+ const client = new MockCDPSession();
+ const timeoutSettings = new TimeoutSettings();
+ const prompt = new DeviceRequestPrompt(client, timeoutSettings, {
+ id: '00000000000000000000000000000000',
+ devices: [],
+ });
+
+ await expect(
+ prompt.waitForDevice(
+ ({name}) => {
+ return name.includes('Device');
+ },
+ {timeout: 1}
+ )
+ ).rejects.toBeInstanceOf(TimeoutError);
+ });
+
+ it('should respect default timeout when there is no custom timeout', async () => {
+ const client = new MockCDPSession();
+ const timeoutSettings = new TimeoutSettings();
+ const prompt = new DeviceRequestPrompt(client, timeoutSettings, {
+ id: '00000000000000000000000000000000',
+ devices: [],
+ });
+
+ timeoutSettings.setDefaultTimeout(1);
+ await expect(
+ prompt.waitForDevice(
+ ({name}) => {
+ return name.includes('Device');
+ },
+ {timeout: 1}
+ )
+ ).rejects.toBeInstanceOf(TimeoutError);
+ });
+
+ it('should prioritize exact timeout over default timeout', async () => {
+ const client = new MockCDPSession();
+ const timeoutSettings = new TimeoutSettings();
+ const prompt = new DeviceRequestPrompt(client, timeoutSettings, {
+ id: '00000000000000000000000000000000',
+ devices: [],
+ });
+
+ timeoutSettings.setDefaultTimeout(0);
+ await expect(
+ prompt.waitForDevice(
+ ({name}) => {
+ return name.includes('Device');
+ },
+ {timeout: 1}
+ )
+ ).rejects.toBeInstanceOf(TimeoutError);
+ });
+
+ it('should work with no timeout', async () => {
+ const client = new MockCDPSession();
+ const timeoutSettings = new TimeoutSettings();
+ const prompt = new DeviceRequestPrompt(client, timeoutSettings, {
+ id: '00000000000000000000000000000000',
+ devices: [],
+ });
+
+ const [device] = await Promise.all([
+ prompt.waitForDevice(
+ ({name}) => {
+ return name.includes('1');
+ },
+ {timeout: 0}
+ ),
+ (() => {
+ client.emit('DeviceAccess.deviceRequestPrompted', {
+ id: '00000000000000000000000000000000',
+ devices: [{id: '00000000', name: 'Device 0'}],
+ });
+ client.emit('DeviceAccess.deviceRequestPrompted', {
+ id: '00000000000000000000000000000000',
+ devices: [
+ {id: '00000000', name: 'Device 0'},
+ {id: '11111111', name: 'Device 1'},
+ ],
+ });
+ })(),
+ ]);
+ expect(device).toBeInstanceOf(DeviceRequestPromptDevice);
+ });
+
+ it('should return same device from multiple watchdogs', async () => {
+ const client = new MockCDPSession();
+ const timeoutSettings = new TimeoutSettings();
+ const prompt = new DeviceRequestPrompt(client, timeoutSettings, {
+ id: '00000000000000000000000000000000',
+ devices: [],
+ });
+
+ const [device1, device2] = await Promise.all([
+ prompt.waitForDevice(({name}) => {
+ return name.includes('1');
+ }),
+ prompt.waitForDevice(({name}) => {
+ return name.includes('1');
+ }),
+ (() => {
+ client.emit('DeviceAccess.deviceRequestPrompted', {
+ id: '00000000000000000000000000000000',
+ devices: [{id: '00000000', name: 'Device 0'}],
+ });
+ client.emit('DeviceAccess.deviceRequestPrompted', {
+ id: '00000000000000000000000000000000',
+ devices: [
+ {id: '00000000', name: 'Device 0'},
+ {id: '11111111', name: 'Device 1'},
+ ],
+ });
+ })(),
+ ]);
+ expect(device1 === device2).toBeTruthy();
+ });
+ });
+
+ describe('DeviceRequestPrompt.select', function () {
+ it('should succeed with listed device', async () => {
+ const client = new MockCDPSession();
+ const timeoutSettings = new TimeoutSettings();
+ const prompt = new DeviceRequestPrompt(client, timeoutSettings, {
+ id: '00000000000000000000000000000000',
+ devices: [],
+ });
+
+ const [device] = await Promise.all([
+ prompt.waitForDevice(({name}) => {
+ return name.includes('1');
+ }),
+ (() => {
+ client.emit('DeviceAccess.deviceRequestPrompted', {
+ id: '00000000000000000000000000000000',
+ devices: [
+ {id: '00000000', name: 'Device 0'},
+ {id: '11111111', name: 'Device 1'},
+ ],
+ });
+ })(),
+ ]);
+ await prompt.select(device);
+ });
+
+ it('should error for device not listed in devices', async () => {
+ const client = new MockCDPSession();
+ const timeoutSettings = new TimeoutSettings();
+ const prompt = new DeviceRequestPrompt(client, timeoutSettings, {
+ id: '00000000000000000000000000000000',
+ devices: [],
+ });
+
+ await expect(
+ prompt.select(new DeviceRequestPromptDevice('11111111', 'Device 1'))
+ ).rejects.toThrowError('Cannot select unknown device!');
+ });
+
+ it('should fail when selecting prompt twice', async () => {
+ const client = new MockCDPSession();
+ const timeoutSettings = new TimeoutSettings();
+ const prompt = new DeviceRequestPrompt(client, timeoutSettings, {
+ id: '00000000000000000000000000000000',
+ devices: [],
+ });
+
+ const [device] = await Promise.all([
+ prompt.waitForDevice(({name}) => {
+ return name.includes('1');
+ }),
+ (() => {
+ client.emit('DeviceAccess.deviceRequestPrompted', {
+ id: '00000000000000000000000000000000',
+ devices: [
+ {id: '00000000', name: 'Device 0'},
+ {id: '11111111', name: 'Device 1'},
+ ],
+ });
+ })(),
+ ]);
+ await prompt.select(device);
+ await expect(prompt.select(device)).rejects.toThrowError(
+ 'Cannot select DeviceRequestPrompt which is already handled!'
+ );
+ });
+ });
+
+ describe('DeviceRequestPrompt.cancel', function () {
+ it('should succeed on first call', async () => {
+ const client = new MockCDPSession();
+ const timeoutSettings = new TimeoutSettings();
+ const prompt = new DeviceRequestPrompt(client, timeoutSettings, {
+ id: '00000000000000000000000000000000',
+ devices: [],
+ });
+ await prompt.cancel();
+ });
+
+ it('should fail when canceling prompt twice', async () => {
+ const client = new MockCDPSession();
+ const timeoutSettings = new TimeoutSettings();
+ const prompt = new DeviceRequestPrompt(client, timeoutSettings, {
+ id: '00000000000000000000000000000000',
+ devices: [],
+ });
+ await prompt.cancel();
+ await expect(prompt.cancel()).rejects.toThrowError(
+ 'Cannot cancel DeviceRequestPrompt which is already handled!'
+ );
+ });
+ });
+});
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/DeviceRequestPrompt.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/DeviceRequestPrompt.ts
new file mode 100644
index 0000000000..f5bd73bf72
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/DeviceRequestPrompt.ts
@@ -0,0 +1,280 @@
+/**
+ * @license
+ * Copyright 2022 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type Protocol from 'devtools-protocol';
+
+import type {CDPSession} from '../api/CDPSession.js';
+import type {WaitTimeoutOptions} from '../api/Page.js';
+import type {TimeoutSettings} from '../common/TimeoutSettings.js';
+import {assert} from '../util/assert.js';
+import {Deferred} from '../util/Deferred.js';
+
+/**
+ * Device in a request prompt.
+ *
+ * @public
+ */
+export class DeviceRequestPromptDevice {
+ /**
+ * Device id during a prompt.
+ */
+ id: string;
+
+ /**
+ * Device name as it appears in a prompt.
+ */
+ name: string;
+
+ /**
+ * @internal
+ */
+ constructor(id: string, name: string) {
+ this.id = id;
+ this.name = name;
+ }
+}
+
+/**
+ * Device request prompts let you respond to the page requesting for a device
+ * through an API like WebBluetooth.
+ *
+ * @remarks
+ * `DeviceRequestPrompt` instances are returned via the
+ * {@link Page.waitForDevicePrompt} method.
+ *
+ * @example
+ *
+ * ```ts
+ * const [deviceRequest] = Promise.all([
+ * page.waitForDevicePrompt(),
+ * page.click('#connect-bluetooth'),
+ * ]);
+ * await devicePrompt.select(
+ * await devicePrompt.waitForDevice(({name}) => name.includes('My Device'))
+ * );
+ * ```
+ *
+ * @public
+ */
+export class DeviceRequestPrompt {
+ #client: CDPSession | null;
+ #timeoutSettings: TimeoutSettings;
+ #id: string;
+ #handled = false;
+ #updateDevicesHandle = this.#updateDevices.bind(this);
+ #waitForDevicePromises = new Set<{
+ filter: (device: DeviceRequestPromptDevice) => boolean;
+ promise: Deferred<DeviceRequestPromptDevice>;
+ }>();
+
+ /**
+ * Current list of selectable devices.
+ */
+ devices: DeviceRequestPromptDevice[] = [];
+
+ /**
+ * @internal
+ */
+ constructor(
+ client: CDPSession,
+ timeoutSettings: TimeoutSettings,
+ firstEvent: Protocol.DeviceAccess.DeviceRequestPromptedEvent
+ ) {
+ this.#client = client;
+ this.#timeoutSettings = timeoutSettings;
+ this.#id = firstEvent.id;
+
+ this.#client.on(
+ 'DeviceAccess.deviceRequestPrompted',
+ this.#updateDevicesHandle
+ );
+ this.#client.on('Target.detachedFromTarget', () => {
+ this.#client = null;
+ });
+
+ this.#updateDevices(firstEvent);
+ }
+
+ #updateDevices(event: Protocol.DeviceAccess.DeviceRequestPromptedEvent) {
+ if (event.id !== this.#id) {
+ return;
+ }
+
+ for (const rawDevice of event.devices) {
+ if (
+ this.devices.some(device => {
+ return device.id === rawDevice.id;
+ })
+ ) {
+ continue;
+ }
+
+ const newDevice = new DeviceRequestPromptDevice(
+ rawDevice.id,
+ rawDevice.name
+ );
+ this.devices.push(newDevice);
+
+ for (const waitForDevicePromise of this.#waitForDevicePromises) {
+ if (waitForDevicePromise.filter(newDevice)) {
+ waitForDevicePromise.promise.resolve(newDevice);
+ }
+ }
+ }
+ }
+
+ /**
+ * Resolve to the first device in the prompt matching a filter.
+ */
+ async waitForDevice(
+ filter: (device: DeviceRequestPromptDevice) => boolean,
+ options: WaitTimeoutOptions = {}
+ ): Promise<DeviceRequestPromptDevice> {
+ for (const device of this.devices) {
+ if (filter(device)) {
+ return device;
+ }
+ }
+
+ const {timeout = this.#timeoutSettings.timeout()} = options;
+ const deferred = Deferred.create<DeviceRequestPromptDevice>({
+ message: `Waiting for \`DeviceRequestPromptDevice\` failed: ${timeout}ms exceeded`,
+ timeout,
+ });
+ const handle = {filter, promise: deferred};
+ this.#waitForDevicePromises.add(handle);
+ try {
+ return await deferred.valueOrThrow();
+ } finally {
+ this.#waitForDevicePromises.delete(handle);
+ }
+ }
+
+ /**
+ * Select a device in the prompt's list.
+ */
+ async select(device: DeviceRequestPromptDevice): Promise<void> {
+ assert(
+ this.#client !== null,
+ 'Cannot select device through detached session!'
+ );
+ assert(this.devices.includes(device), 'Cannot select unknown device!');
+ assert(
+ !this.#handled,
+ 'Cannot select DeviceRequestPrompt which is already handled!'
+ );
+ this.#client.off(
+ 'DeviceAccess.deviceRequestPrompted',
+ this.#updateDevicesHandle
+ );
+ this.#handled = true;
+ return await this.#client.send('DeviceAccess.selectPrompt', {
+ id: this.#id,
+ deviceId: device.id,
+ });
+ }
+
+ /**
+ * Cancel the prompt.
+ */
+ async cancel(): Promise<void> {
+ assert(
+ this.#client !== null,
+ 'Cannot cancel prompt through detached session!'
+ );
+ assert(
+ !this.#handled,
+ 'Cannot cancel DeviceRequestPrompt which is already handled!'
+ );
+ this.#client.off(
+ 'DeviceAccess.deviceRequestPrompted',
+ this.#updateDevicesHandle
+ );
+ this.#handled = true;
+ return await this.#client.send('DeviceAccess.cancelPrompt', {id: this.#id});
+ }
+}
+
+/**
+ * @internal
+ */
+export class DeviceRequestPromptManager {
+ #client: CDPSession | null;
+ #timeoutSettings: TimeoutSettings;
+ #deviceRequestPrompDeferreds = new Set<Deferred<DeviceRequestPrompt>>();
+
+ /**
+ * @internal
+ */
+ constructor(client: CDPSession, timeoutSettings: TimeoutSettings) {
+ this.#client = client;
+ this.#timeoutSettings = timeoutSettings;
+
+ this.#client.on('DeviceAccess.deviceRequestPrompted', event => {
+ this.#onDeviceRequestPrompted(event);
+ });
+ this.#client.on('Target.detachedFromTarget', () => {
+ this.#client = null;
+ });
+ }
+
+ /**
+ * Wait for device prompt created by an action like calling WebBluetooth's
+ * requestDevice.
+ */
+ async waitForDevicePrompt(
+ options: WaitTimeoutOptions = {}
+ ): Promise<DeviceRequestPrompt> {
+ assert(
+ this.#client !== null,
+ 'Cannot wait for device prompt through detached session!'
+ );
+ const needsEnable = this.#deviceRequestPrompDeferreds.size === 0;
+ let enablePromise: Promise<void> | undefined;
+ if (needsEnable) {
+ enablePromise = this.#client.send('DeviceAccess.enable');
+ }
+
+ const {timeout = this.#timeoutSettings.timeout()} = options;
+ const deferred = Deferred.create<DeviceRequestPrompt>({
+ message: `Waiting for \`DeviceRequestPrompt\` failed: ${timeout}ms exceeded`,
+ timeout,
+ });
+ this.#deviceRequestPrompDeferreds.add(deferred);
+
+ try {
+ const [result] = await Promise.all([
+ deferred.valueOrThrow(),
+ enablePromise,
+ ]);
+ return result;
+ } finally {
+ this.#deviceRequestPrompDeferreds.delete(deferred);
+ }
+ }
+
+ /**
+ * @internal
+ */
+ #onDeviceRequestPrompted(
+ event: Protocol.DeviceAccess.DeviceRequestPromptedEvent
+ ) {
+ if (!this.#deviceRequestPrompDeferreds.size) {
+ return;
+ }
+
+ assert(this.#client !== null);
+ const devicePrompt = new DeviceRequestPrompt(
+ this.#client,
+ this.#timeoutSettings,
+ event
+ );
+ for (const promise of this.#deviceRequestPrompDeferreds) {
+ promise.resolve(devicePrompt);
+ }
+ this.#deviceRequestPrompDeferreds.clear();
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Dialog.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Dialog.ts
new file mode 100644
index 0000000000..fe8fffbcad
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Dialog.ts
@@ -0,0 +1,37 @@
+/**
+ * @license
+ * Copyright 2017 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {Protocol} from 'devtools-protocol';
+
+import type {CDPSession} from '../api/CDPSession.js';
+import {Dialog} from '../api/Dialog.js';
+
+/**
+ * @internal
+ */
+export class CdpDialog extends Dialog {
+ #client: CDPSession;
+
+ constructor(
+ client: CDPSession,
+ type: Protocol.Page.DialogType,
+ message: string,
+ defaultValue = ''
+ ) {
+ super(type, message, defaultValue);
+ this.#client = client;
+ }
+
+ override async handle(options: {
+ accept: boolean;
+ text?: string;
+ }): Promise<void> {
+ await this.#client.send('Page.handleJavaScriptDialog', {
+ accept: options.accept,
+ promptText: options.text,
+ });
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/ElementHandle.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/ElementHandle.ts
new file mode 100644
index 0000000000..a47d546a87
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/ElementHandle.ts
@@ -0,0 +1,172 @@
+/**
+ * @license
+ * Copyright 2019 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type Path from 'path';
+
+import type {Protocol} from 'devtools-protocol';
+
+import type {CDPSession} from '../api/CDPSession.js';
+import {ElementHandle, type AutofillData} from '../api/ElementHandle.js';
+import {debugError} from '../common/util.js';
+import {assert} from '../util/assert.js';
+import {throwIfDisposed} from '../util/decorators.js';
+
+import type {CdpFrame} from './Frame.js';
+import type {FrameManager} from './FrameManager.js';
+import type {IsolatedWorld} from './IsolatedWorld.js';
+import {CdpJSHandle} from './JSHandle.js';
+
+/**
+ * The CdpElementHandle extends ElementHandle now to keep compatibility
+ * with `instanceof` because of that we need to have methods for
+ * CdpJSHandle to in this implementation as well.
+ *
+ * @internal
+ */
+export class CdpElementHandle<
+ ElementType extends Node = Element,
+> extends ElementHandle<ElementType> {
+ protected declare readonly handle: CdpJSHandle<ElementType>;
+
+ constructor(
+ world: IsolatedWorld,
+ remoteObject: Protocol.Runtime.RemoteObject
+ ) {
+ super(new CdpJSHandle(world, remoteObject));
+ }
+
+ override get realm(): IsolatedWorld {
+ return this.handle.realm;
+ }
+
+ get client(): CDPSession {
+ return this.handle.client;
+ }
+
+ override remoteObject(): Protocol.Runtime.RemoteObject {
+ return this.handle.remoteObject();
+ }
+
+ get #frameManager(): FrameManager {
+ return this.frame._frameManager;
+ }
+
+ override get frame(): CdpFrame {
+ return this.realm.environment as CdpFrame;
+ }
+
+ override async contentFrame(
+ this: ElementHandle<HTMLIFrameElement>
+ ): Promise<CdpFrame>;
+
+ @throwIfDisposed()
+ override async contentFrame(): Promise<CdpFrame | null> {
+ const nodeInfo = await this.client.send('DOM.describeNode', {
+ objectId: this.id,
+ });
+ if (typeof nodeInfo.node.frameId !== 'string') {
+ return null;
+ }
+ return this.#frameManager.frame(nodeInfo.node.frameId);
+ }
+
+ @throwIfDisposed()
+ @ElementHandle.bindIsolatedHandle
+ override async scrollIntoView(
+ this: CdpElementHandle<Element>
+ ): Promise<void> {
+ await this.assertConnectedElement();
+ try {
+ await this.client.send('DOM.scrollIntoViewIfNeeded', {
+ objectId: this.id,
+ });
+ } catch (error) {
+ debugError(error);
+ // Fallback to Element.scrollIntoView if DOM.scrollIntoViewIfNeeded is not supported
+ await super.scrollIntoView();
+ }
+ }
+
+ @throwIfDisposed()
+ @ElementHandle.bindIsolatedHandle
+ override async uploadFile(
+ this: CdpElementHandle<HTMLInputElement>,
+ ...filePaths: string[]
+ ): Promise<void> {
+ const isMultiple = await this.evaluate(element => {
+ return element.multiple;
+ });
+ assert(
+ filePaths.length <= 1 || isMultiple,
+ 'Multiple file uploads only work with <input type=file multiple>'
+ );
+
+ // Locate all files and confirm that they exist.
+ let path: typeof Path;
+ try {
+ path = await import('path');
+ } catch (error) {
+ if (error instanceof TypeError) {
+ throw new Error(
+ `JSHandle#uploadFile can only be used in Node-like environments.`
+ );
+ }
+ throw error;
+ }
+ const files = filePaths.map(filePath => {
+ if (path.win32.isAbsolute(filePath) || path.posix.isAbsolute(filePath)) {
+ return filePath;
+ } else {
+ return path.resolve(filePath);
+ }
+ });
+
+ /**
+ * The zero-length array is a special case, it seems that
+ * DOM.setFileInputFiles does not actually update the files in that case, so
+ * the solution is to eval the element value to a new FileList directly.
+ */
+ if (files.length === 0) {
+ // XXX: These events should converted to trusted events. Perhaps do this
+ // in `DOM.setFileInputFiles`?
+ await this.evaluate(element => {
+ element.files = new DataTransfer().files;
+
+ // Dispatch events for this case because it should behave akin to a user action.
+ element.dispatchEvent(
+ new Event('input', {bubbles: true, composed: true})
+ );
+ element.dispatchEvent(new Event('change', {bubbles: true}));
+ });
+ return;
+ }
+
+ const {
+ node: {backendNodeId},
+ } = await this.client.send('DOM.describeNode', {
+ objectId: this.id,
+ });
+ await this.client.send('DOM.setFileInputFiles', {
+ objectId: this.id,
+ files,
+ backendNodeId,
+ });
+ }
+
+ @throwIfDisposed()
+ override async autofill(data: AutofillData): Promise<void> {
+ const nodeInfo = await this.client.send('DOM.describeNode', {
+ objectId: this.handle.id,
+ });
+ const fieldId = nodeInfo.node.backendNodeId;
+ const frameId = this.frame._id;
+ await this.client.send('Autofill.trigger', {
+ fieldId,
+ frameId,
+ card: data.creditCard,
+ });
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/EmulationManager.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/EmulationManager.ts
new file mode 100644
index 0000000000..8598967fe7
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/EmulationManager.ts
@@ -0,0 +1,554 @@
+/**
+ * @license
+ * Copyright 2017 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import type {Protocol} from 'devtools-protocol';
+
+import {type CDPSession, CDPSessionEvent} from '../api/CDPSession.js';
+import type {GeolocationOptions, MediaFeature} from '../api/Page.js';
+import {debugError} from '../common/util.js';
+import type {Viewport} from '../common/Viewport.js';
+import {assert} from '../util/assert.js';
+import {invokeAtMostOnceForArguments} from '../util/decorators.js';
+import {isErrorLike} from '../util/ErrorLike.js';
+
+interface ViewportState {
+ viewport?: Viewport;
+ active: boolean;
+}
+
+interface IdleOverridesState {
+ overrides?: {
+ isUserActive: boolean;
+ isScreenUnlocked: boolean;
+ };
+ active: boolean;
+}
+
+interface TimezoneState {
+ timezoneId?: string;
+ active: boolean;
+}
+
+interface VisionDeficiencyState {
+ visionDeficiency?: Protocol.Emulation.SetEmulatedVisionDeficiencyRequest['type'];
+ active: boolean;
+}
+
+interface CpuThrottlingState {
+ factor?: number;
+ active: boolean;
+}
+
+interface MediaFeaturesState {
+ mediaFeatures?: MediaFeature[];
+ active: boolean;
+}
+
+interface MediaTypeState {
+ type?: string;
+ active: boolean;
+}
+
+interface GeoLocationState {
+ geoLocation?: GeolocationOptions;
+ active: boolean;
+}
+
+interface DefaultBackgroundColorState {
+ color?: Protocol.DOM.RGBA;
+ active: boolean;
+}
+
+interface JavascriptEnabledState {
+ javaScriptEnabled: boolean;
+ active: boolean;
+}
+
+/**
+ * @internal
+ */
+export interface ClientProvider {
+ clients(): CDPSession[];
+ registerState(state: EmulatedState<any>): void;
+}
+
+/**
+ * @internal
+ */
+export class EmulatedState<T extends {active: boolean}> {
+ #state: T;
+ #clientProvider: ClientProvider;
+ #updater: (client: CDPSession, state: T) => Promise<void>;
+
+ constructor(
+ initialState: T,
+ clientProvider: ClientProvider,
+ updater: (client: CDPSession, state: T) => Promise<void>
+ ) {
+ this.#state = initialState;
+ this.#clientProvider = clientProvider;
+ this.#updater = updater;
+ this.#clientProvider.registerState(this);
+ }
+
+ async setState(state: T): Promise<void> {
+ this.#state = state;
+ await this.sync();
+ }
+
+ get state(): T {
+ return this.#state;
+ }
+
+ async sync(): Promise<void> {
+ await Promise.all(
+ this.#clientProvider.clients().map(client => {
+ return this.#updater(client, this.#state);
+ })
+ );
+ }
+}
+
+/**
+ * @internal
+ */
+export class EmulationManager {
+ #client: CDPSession;
+
+ #emulatingMobile = false;
+ #hasTouch = false;
+
+ #states: Array<EmulatedState<any>> = [];
+
+ #viewportState = new EmulatedState<ViewportState>(
+ {
+ active: false,
+ },
+ this,
+ this.#applyViewport
+ );
+ #idleOverridesState = new EmulatedState<IdleOverridesState>(
+ {
+ active: false,
+ },
+ this,
+ this.#emulateIdleState
+ );
+ #timezoneState = new EmulatedState<TimezoneState>(
+ {
+ active: false,
+ },
+ this,
+ this.#emulateTimezone
+ );
+ #visionDeficiencyState = new EmulatedState<VisionDeficiencyState>(
+ {
+ active: false,
+ },
+ this,
+ this.#emulateVisionDeficiency
+ );
+ #cpuThrottlingState = new EmulatedState<CpuThrottlingState>(
+ {
+ active: false,
+ },
+ this,
+ this.#emulateCpuThrottling
+ );
+ #mediaFeaturesState = new EmulatedState<MediaFeaturesState>(
+ {
+ active: false,
+ },
+ this,
+ this.#emulateMediaFeatures
+ );
+ #mediaTypeState = new EmulatedState<MediaTypeState>(
+ {
+ active: false,
+ },
+ this,
+ this.#emulateMediaType
+ );
+ #geoLocationState = new EmulatedState<GeoLocationState>(
+ {
+ active: false,
+ },
+ this,
+ this.#setGeolocation
+ );
+ #defaultBackgroundColorState = new EmulatedState<DefaultBackgroundColorState>(
+ {
+ active: false,
+ },
+ this,
+ this.#setDefaultBackgroundColor
+ );
+ #javascriptEnabledState = new EmulatedState<JavascriptEnabledState>(
+ {
+ javaScriptEnabled: true,
+ active: false,
+ },
+ this,
+ this.#setJavaScriptEnabled
+ );
+
+ #secondaryClients = new Set<CDPSession>();
+
+ constructor(client: CDPSession) {
+ this.#client = client;
+ }
+
+ updateClient(client: CDPSession): void {
+ this.#client = client;
+ this.#secondaryClients.delete(client);
+ }
+
+ registerState(state: EmulatedState<any>): void {
+ this.#states.push(state);
+ }
+
+ clients(): CDPSession[] {
+ return [this.#client, ...Array.from(this.#secondaryClients)];
+ }
+
+ async registerSpeculativeSession(client: CDPSession): Promise<void> {
+ this.#secondaryClients.add(client);
+ client.once(CDPSessionEvent.Disconnected, () => {
+ this.#secondaryClients.delete(client);
+ });
+ // We don't await here because we want to register all state changes before
+ // the target is unpaused.
+ void Promise.all(
+ this.#states.map(s => {
+ return s.sync().catch(debugError);
+ })
+ );
+ }
+
+ get javascriptEnabled(): boolean {
+ return this.#javascriptEnabledState.state.javaScriptEnabled;
+ }
+
+ async emulateViewport(viewport: Viewport): Promise<boolean> {
+ await this.#viewportState.setState({
+ viewport,
+ active: true,
+ });
+
+ const mobile = viewport.isMobile || false;
+ const hasTouch = viewport.hasTouch || false;
+ const reloadNeeded =
+ this.#emulatingMobile !== mobile || this.#hasTouch !== hasTouch;
+ this.#emulatingMobile = mobile;
+ this.#hasTouch = hasTouch;
+
+ return reloadNeeded;
+ }
+
+ @invokeAtMostOnceForArguments
+ async #applyViewport(
+ client: CDPSession,
+ viewportState: ViewportState
+ ): Promise<void> {
+ if (!viewportState.viewport) {
+ return;
+ }
+ const {viewport} = viewportState;
+ const mobile = viewport.isMobile || false;
+ const width = viewport.width;
+ const height = viewport.height;
+ const deviceScaleFactor = viewport.deviceScaleFactor ?? 1;
+ const screenOrientation: Protocol.Emulation.ScreenOrientation =
+ viewport.isLandscape
+ ? {angle: 90, type: 'landscapePrimary'}
+ : {angle: 0, type: 'portraitPrimary'};
+ const hasTouch = viewport.hasTouch || false;
+
+ await Promise.all([
+ client.send('Emulation.setDeviceMetricsOverride', {
+ mobile,
+ width,
+ height,
+ deviceScaleFactor,
+ screenOrientation,
+ }),
+ client.send('Emulation.setTouchEmulationEnabled', {
+ enabled: hasTouch,
+ }),
+ ]);
+ }
+
+ async emulateIdleState(overrides?: {
+ isUserActive: boolean;
+ isScreenUnlocked: boolean;
+ }): Promise<void> {
+ await this.#idleOverridesState.setState({
+ active: true,
+ overrides,
+ });
+ }
+
+ @invokeAtMostOnceForArguments
+ async #emulateIdleState(
+ client: CDPSession,
+ idleStateState: IdleOverridesState
+ ): Promise<void> {
+ if (!idleStateState.active) {
+ return;
+ }
+ if (idleStateState.overrides) {
+ await client.send('Emulation.setIdleOverride', {
+ isUserActive: idleStateState.overrides.isUserActive,
+ isScreenUnlocked: idleStateState.overrides.isScreenUnlocked,
+ });
+ } else {
+ await client.send('Emulation.clearIdleOverride');
+ }
+ }
+
+ @invokeAtMostOnceForArguments
+ async #emulateTimezone(
+ client: CDPSession,
+ timezoneState: TimezoneState
+ ): Promise<void> {
+ if (!timezoneState.active) {
+ return;
+ }
+ try {
+ await client.send('Emulation.setTimezoneOverride', {
+ timezoneId: timezoneState.timezoneId || '',
+ });
+ } catch (error) {
+ if (isErrorLike(error) && error.message.includes('Invalid timezone')) {
+ throw new Error(`Invalid timezone ID: ${timezoneState.timezoneId}`);
+ }
+ throw error;
+ }
+ }
+
+ async emulateTimezone(timezoneId?: string): Promise<void> {
+ await this.#timezoneState.setState({
+ timezoneId,
+ active: true,
+ });
+ }
+
+ @invokeAtMostOnceForArguments
+ async #emulateVisionDeficiency(
+ client: CDPSession,
+ visionDeficiency: VisionDeficiencyState
+ ): Promise<void> {
+ if (!visionDeficiency.active) {
+ return;
+ }
+ await client.send('Emulation.setEmulatedVisionDeficiency', {
+ type: visionDeficiency.visionDeficiency || 'none',
+ });
+ }
+
+ async emulateVisionDeficiency(
+ type?: Protocol.Emulation.SetEmulatedVisionDeficiencyRequest['type']
+ ): Promise<void> {
+ const visionDeficiencies = new Set<
+ Protocol.Emulation.SetEmulatedVisionDeficiencyRequest['type']
+ >([
+ 'none',
+ 'achromatopsia',
+ 'blurredVision',
+ 'deuteranopia',
+ 'protanopia',
+ 'tritanopia',
+ ]);
+ assert(
+ !type || visionDeficiencies.has(type),
+ `Unsupported vision deficiency: ${type}`
+ );
+ await this.#visionDeficiencyState.setState({
+ active: true,
+ visionDeficiency: type,
+ });
+ }
+
+ @invokeAtMostOnceForArguments
+ async #emulateCpuThrottling(
+ client: CDPSession,
+ state: CpuThrottlingState
+ ): Promise<void> {
+ if (!state.active) {
+ return;
+ }
+ await client.send('Emulation.setCPUThrottlingRate', {
+ rate: state.factor ?? 1,
+ });
+ }
+
+ async emulateCPUThrottling(factor: number | null): Promise<void> {
+ assert(
+ factor === null || factor >= 1,
+ 'Throttling rate should be greater or equal to 1'
+ );
+ await this.#cpuThrottlingState.setState({
+ active: true,
+ factor: factor ?? undefined,
+ });
+ }
+
+ @invokeAtMostOnceForArguments
+ async #emulateMediaFeatures(
+ client: CDPSession,
+ state: MediaFeaturesState
+ ): Promise<void> {
+ if (!state.active) {
+ return;
+ }
+ await client.send('Emulation.setEmulatedMedia', {
+ features: state.mediaFeatures,
+ });
+ }
+
+ async emulateMediaFeatures(features?: MediaFeature[]): Promise<void> {
+ if (Array.isArray(features)) {
+ for (const mediaFeature of features) {
+ const name = mediaFeature.name;
+ assert(
+ /^(?:prefers-(?:color-scheme|reduced-motion)|color-gamut)$/.test(
+ name
+ ),
+ 'Unsupported media feature: ' + name
+ );
+ }
+ }
+ await this.#mediaFeaturesState.setState({
+ active: true,
+ mediaFeatures: features,
+ });
+ }
+
+ @invokeAtMostOnceForArguments
+ async #emulateMediaType(
+ client: CDPSession,
+ state: MediaTypeState
+ ): Promise<void> {
+ if (!state.active) {
+ return;
+ }
+ await client.send('Emulation.setEmulatedMedia', {
+ media: state.type || '',
+ });
+ }
+
+ async emulateMediaType(type?: string): Promise<void> {
+ assert(
+ type === 'screen' ||
+ type === 'print' ||
+ (type ?? undefined) === undefined,
+ 'Unsupported media type: ' + type
+ );
+ await this.#mediaTypeState.setState({
+ type,
+ active: true,
+ });
+ }
+
+ @invokeAtMostOnceForArguments
+ async #setGeolocation(
+ client: CDPSession,
+ state: GeoLocationState
+ ): Promise<void> {
+ if (!state.active) {
+ return;
+ }
+ await client.send(
+ 'Emulation.setGeolocationOverride',
+ state.geoLocation
+ ? {
+ longitude: state.geoLocation.longitude,
+ latitude: state.geoLocation.latitude,
+ accuracy: state.geoLocation.accuracy,
+ }
+ : undefined
+ );
+ }
+
+ async setGeolocation(options: GeolocationOptions): Promise<void> {
+ const {longitude, latitude, accuracy = 0} = options;
+ if (longitude < -180 || longitude > 180) {
+ throw new Error(
+ `Invalid longitude "${longitude}": precondition -180 <= LONGITUDE <= 180 failed.`
+ );
+ }
+ if (latitude < -90 || latitude > 90) {
+ throw new Error(
+ `Invalid latitude "${latitude}": precondition -90 <= LATITUDE <= 90 failed.`
+ );
+ }
+ if (accuracy < 0) {
+ throw new Error(
+ `Invalid accuracy "${accuracy}": precondition 0 <= ACCURACY failed.`
+ );
+ }
+ await this.#geoLocationState.setState({
+ active: true,
+ geoLocation: {
+ longitude,
+ latitude,
+ accuracy,
+ },
+ });
+ }
+
+ @invokeAtMostOnceForArguments
+ async #setDefaultBackgroundColor(
+ client: CDPSession,
+ state: DefaultBackgroundColorState
+ ): Promise<void> {
+ if (!state.active) {
+ return;
+ }
+ await client.send('Emulation.setDefaultBackgroundColorOverride', {
+ color: state.color,
+ });
+ }
+
+ /**
+ * Resets default white background
+ */
+ async resetDefaultBackgroundColor(): Promise<void> {
+ await this.#defaultBackgroundColorState.setState({
+ active: true,
+ color: undefined,
+ });
+ }
+
+ /**
+ * Hides default white background
+ */
+ async setTransparentBackgroundColor(): Promise<void> {
+ await this.#defaultBackgroundColorState.setState({
+ active: true,
+ color: {r: 0, g: 0, b: 0, a: 0},
+ });
+ }
+
+ @invokeAtMostOnceForArguments
+ async #setJavaScriptEnabled(
+ client: CDPSession,
+ state: JavascriptEnabledState
+ ): Promise<void> {
+ if (!state.active) {
+ return;
+ }
+ await client.send('Emulation.setScriptExecutionDisabled', {
+ value: !state.javaScriptEnabled,
+ });
+ }
+
+ async setJavaScriptEnabled(enabled: boolean): Promise<void> {
+ await this.#javascriptEnabledState.setState({
+ active: true,
+ javaScriptEnabled: enabled,
+ });
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/ExecutionContext.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/ExecutionContext.ts
new file mode 100644
index 0000000000..6efdf8ac76
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/ExecutionContext.ts
@@ -0,0 +1,392 @@
+/**
+ * @license
+ * Copyright 2017 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {Protocol} from 'devtools-protocol';
+
+import type {CDPSession} from '../api/CDPSession.js';
+import type {ElementHandle} from '../api/ElementHandle.js';
+import type {JSHandle} from '../api/JSHandle.js';
+import {LazyArg} from '../common/LazyArg.js';
+import {scriptInjector} from '../common/ScriptInjector.js';
+import type {EvaluateFunc, HandleFor} from '../common/types.js';
+import {
+ PuppeteerURL,
+ SOURCE_URL_REGEX,
+ getSourcePuppeteerURLIfAvailable,
+ getSourceUrlComment,
+ isString,
+} from '../common/util.js';
+import type PuppeteerUtil from '../injected/injected.js';
+import {AsyncIterableUtil} from '../util/AsyncIterableUtil.js';
+import {stringifyFunction} from '../util/Function.js';
+
+import {ARIAQueryHandler} from './AriaQueryHandler.js';
+import {Binding} from './Binding.js';
+import {CdpElementHandle} from './ElementHandle.js';
+import type {IsolatedWorld} from './IsolatedWorld.js';
+import {CdpJSHandle} from './JSHandle.js';
+import {createEvaluationError, valueFromRemoteObject} from './utils.js';
+
+/**
+ * @internal
+ */
+export class ExecutionContext {
+ _client: CDPSession;
+ _world: IsolatedWorld;
+ _contextId: number;
+ _contextName?: string;
+
+ constructor(
+ client: CDPSession,
+ contextPayload: Protocol.Runtime.ExecutionContextDescription,
+ world: IsolatedWorld
+ ) {
+ this._client = client;
+ this._world = world;
+ this._contextId = contextPayload.id;
+ if (contextPayload.name) {
+ this._contextName = contextPayload.name;
+ }
+ }
+
+ #bindingsInstalled = false;
+ #puppeteerUtil?: Promise<JSHandle<PuppeteerUtil>>;
+ get puppeteerUtil(): Promise<JSHandle<PuppeteerUtil>> {
+ let promise = Promise.resolve() as Promise<unknown>;
+ if (!this.#bindingsInstalled) {
+ promise = Promise.all([
+ this.#installGlobalBinding(
+ new Binding(
+ '__ariaQuerySelector',
+ ARIAQueryHandler.queryOne as (...args: unknown[]) => unknown
+ )
+ ),
+ this.#installGlobalBinding(
+ new Binding('__ariaQuerySelectorAll', (async (
+ element: ElementHandle<Node>,
+ selector: string
+ ): Promise<JSHandle<Node[]>> => {
+ const results = ARIAQueryHandler.queryAll(element, selector);
+ return await element.realm.evaluateHandle(
+ (...elements) => {
+ return elements;
+ },
+ ...(await AsyncIterableUtil.collect(results))
+ );
+ }) as (...args: unknown[]) => unknown)
+ ),
+ ]);
+ this.#bindingsInstalled = true;
+ }
+ scriptInjector.inject(script => {
+ if (this.#puppeteerUtil) {
+ void this.#puppeteerUtil.then(handle => {
+ void handle.dispose();
+ });
+ }
+ this.#puppeteerUtil = promise.then(() => {
+ return this.evaluateHandle(script) as Promise<JSHandle<PuppeteerUtil>>;
+ });
+ }, !this.#puppeteerUtil);
+ return this.#puppeteerUtil as Promise<JSHandle<PuppeteerUtil>>;
+ }
+
+ async #installGlobalBinding(binding: Binding) {
+ try {
+ if (this._world) {
+ this._world._bindings.set(binding.name, binding);
+ await this._world._addBindingToContext(this, binding.name);
+ }
+ } catch {
+ // If the binding cannot be added, then either the browser doesn't support
+ // bindings (e.g. Firefox) or the context is broken. Either breakage is
+ // okay, so we ignore the error.
+ }
+ }
+
+ /**
+ * Evaluates the given function.
+ *
+ * @example
+ *
+ * ```ts
+ * const executionContext = await page.mainFrame().executionContext();
+ * const result = await executionContext.evaluate(() => Promise.resolve(8 * 7))* ;
+ * console.log(result); // prints "56"
+ * ```
+ *
+ * @example
+ * A string can also be passed in instead of a function:
+ *
+ * ```ts
+ * console.log(await executionContext.evaluate('1 + 2')); // prints "3"
+ * ```
+ *
+ * @example
+ * Handles can also be passed as `args`. They resolve to their referenced object:
+ *
+ * ```ts
+ * const oneHandle = await executionContext.evaluateHandle(() => 1);
+ * const twoHandle = await executionContext.evaluateHandle(() => 2);
+ * const result = await executionContext.evaluate(
+ * (a, b) => a + b,
+ * oneHandle,
+ * twoHandle
+ * );
+ * await oneHandle.dispose();
+ * await twoHandle.dispose();
+ * console.log(result); // prints '3'.
+ * ```
+ *
+ * @param pageFunction - The function to evaluate.
+ * @param args - Additional arguments to pass into the function.
+ * @returns The result of evaluating the function. If the result is an object,
+ * a vanilla object containing the serializable properties of the result is
+ * returned.
+ */
+ async evaluate<
+ Params extends unknown[],
+ Func extends EvaluateFunc<Params> = EvaluateFunc<Params>,
+ >(
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<Awaited<ReturnType<Func>>> {
+ return await this.#evaluate(true, pageFunction, ...args);
+ }
+
+ /**
+ * Evaluates the given function.
+ *
+ * Unlike {@link ExecutionContext.evaluate | evaluate}, this method returns a
+ * handle to the result of the function.
+ *
+ * This method may be better suited if the object cannot be serialized (e.g.
+ * `Map`) and requires further manipulation.
+ *
+ * @example
+ *
+ * ```ts
+ * const context = await page.mainFrame().executionContext();
+ * const handle: JSHandle<typeof globalThis> = await context.evaluateHandle(
+ * () => Promise.resolve(self)
+ * );
+ * ```
+ *
+ * @example
+ * A string can also be passed in instead of a function.
+ *
+ * ```ts
+ * const handle: JSHandle<number> = await context.evaluateHandle('1 + 2');
+ * ```
+ *
+ * @example
+ * Handles can also be passed as `args`. They resolve to their referenced object:
+ *
+ * ```ts
+ * const bodyHandle: ElementHandle<HTMLBodyElement> =
+ * await context.evaluateHandle(() => {
+ * return document.body;
+ * });
+ * const stringHandle: JSHandle<string> = await context.evaluateHandle(
+ * body => body.innerHTML,
+ * body
+ * );
+ * console.log(await stringHandle.jsonValue()); // prints body's innerHTML
+ * // Always dispose your garbage! :)
+ * await bodyHandle.dispose();
+ * await stringHandle.dispose();
+ * ```
+ *
+ * @param pageFunction - The function to evaluate.
+ * @param args - Additional arguments to pass into the function.
+ * @returns A {@link JSHandle | handle} to the result of evaluating the
+ * function. If the result is a `Node`, then this will return an
+ * {@link ElementHandle | element handle}.
+ */
+ async evaluateHandle<
+ Params extends unknown[],
+ Func extends EvaluateFunc<Params> = EvaluateFunc<Params>,
+ >(
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<HandleFor<Awaited<ReturnType<Func>>>> {
+ return await this.#evaluate(false, pageFunction, ...args);
+ }
+
+ async #evaluate<
+ Params extends unknown[],
+ Func extends EvaluateFunc<Params> = EvaluateFunc<Params>,
+ >(
+ returnByValue: true,
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<Awaited<ReturnType<Func>>>;
+ async #evaluate<
+ Params extends unknown[],
+ Func extends EvaluateFunc<Params> = EvaluateFunc<Params>,
+ >(
+ returnByValue: false,
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<HandleFor<Awaited<ReturnType<Func>>>>;
+ async #evaluate<
+ Params extends unknown[],
+ Func extends EvaluateFunc<Params> = EvaluateFunc<Params>,
+ >(
+ returnByValue: boolean,
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<HandleFor<Awaited<ReturnType<Func>>> | Awaited<ReturnType<Func>>> {
+ const sourceUrlComment = getSourceUrlComment(
+ getSourcePuppeteerURLIfAvailable(pageFunction)?.toString() ??
+ PuppeteerURL.INTERNAL_URL
+ );
+
+ if (isString(pageFunction)) {
+ const contextId = this._contextId;
+ const expression = pageFunction;
+ const expressionWithSourceUrl = SOURCE_URL_REGEX.test(expression)
+ ? expression
+ : `${expression}\n${sourceUrlComment}\n`;
+
+ const {exceptionDetails, result: remoteObject} = await this._client
+ .send('Runtime.evaluate', {
+ expression: expressionWithSourceUrl,
+ contextId,
+ returnByValue,
+ awaitPromise: true,
+ userGesture: true,
+ })
+ .catch(rewriteError);
+
+ if (exceptionDetails) {
+ throw createEvaluationError(exceptionDetails);
+ }
+
+ return returnByValue
+ ? valueFromRemoteObject(remoteObject)
+ : createCdpHandle(this._world, remoteObject);
+ }
+
+ const functionDeclaration = stringifyFunction(pageFunction);
+ const functionDeclarationWithSourceUrl = SOURCE_URL_REGEX.test(
+ functionDeclaration
+ )
+ ? functionDeclaration
+ : `${functionDeclaration}\n${sourceUrlComment}\n`;
+ let callFunctionOnPromise;
+ try {
+ callFunctionOnPromise = this._client.send('Runtime.callFunctionOn', {
+ functionDeclaration: functionDeclarationWithSourceUrl,
+ executionContextId: this._contextId,
+ arguments: args.length
+ ? await Promise.all(args.map(convertArgument.bind(this)))
+ : [],
+ returnByValue,
+ awaitPromise: true,
+ userGesture: true,
+ });
+ } catch (error) {
+ if (
+ error instanceof TypeError &&
+ error.message.startsWith('Converting circular structure to JSON')
+ ) {
+ error.message += ' Recursive objects are not allowed.';
+ }
+ throw error;
+ }
+ const {exceptionDetails, result: remoteObject} =
+ await callFunctionOnPromise.catch(rewriteError);
+ if (exceptionDetails) {
+ throw createEvaluationError(exceptionDetails);
+ }
+ return returnByValue
+ ? valueFromRemoteObject(remoteObject)
+ : createCdpHandle(this._world, remoteObject);
+
+ async function convertArgument(
+ this: ExecutionContext,
+ arg: unknown
+ ): Promise<Protocol.Runtime.CallArgument> {
+ if (arg instanceof LazyArg) {
+ arg = await arg.get(this);
+ }
+ if (typeof arg === 'bigint') {
+ // eslint-disable-line valid-typeof
+ return {unserializableValue: `${arg.toString()}n`};
+ }
+ if (Object.is(arg, -0)) {
+ return {unserializableValue: '-0'};
+ }
+ if (Object.is(arg, Infinity)) {
+ return {unserializableValue: 'Infinity'};
+ }
+ if (Object.is(arg, -Infinity)) {
+ return {unserializableValue: '-Infinity'};
+ }
+ if (Object.is(arg, NaN)) {
+ return {unserializableValue: 'NaN'};
+ }
+ const objectHandle =
+ arg && (arg instanceof CdpJSHandle || arg instanceof CdpElementHandle)
+ ? arg
+ : null;
+ if (objectHandle) {
+ if (objectHandle.realm !== this._world) {
+ throw new Error(
+ 'JSHandles can be evaluated only in the context they were created!'
+ );
+ }
+ if (objectHandle.disposed) {
+ throw new Error('JSHandle is disposed!');
+ }
+ if (objectHandle.remoteObject().unserializableValue) {
+ return {
+ unserializableValue:
+ objectHandle.remoteObject().unserializableValue,
+ };
+ }
+ if (!objectHandle.remoteObject().objectId) {
+ return {value: objectHandle.remoteObject().value};
+ }
+ return {objectId: objectHandle.remoteObject().objectId};
+ }
+ return {value: arg};
+ }
+ }
+}
+
+const rewriteError = (error: Error): Protocol.Runtime.EvaluateResponse => {
+ if (error.message.includes('Object reference chain is too long')) {
+ return {result: {type: 'undefined'}};
+ }
+ if (error.message.includes("Object couldn't be returned by value")) {
+ return {result: {type: 'undefined'}};
+ }
+
+ if (
+ error.message.endsWith('Cannot find context with specified id') ||
+ error.message.endsWith('Inspected target navigated or closed')
+ ) {
+ throw new Error(
+ 'Execution context was destroyed, most likely because of a navigation.'
+ );
+ }
+ throw error;
+};
+
+/**
+ * @internal
+ */
+export function createCdpHandle(
+ realm: IsolatedWorld,
+ remoteObject: Protocol.Runtime.RemoteObject
+): JSHandle | ElementHandle<Node> {
+ if (remoteObject.subtype === 'node') {
+ return new CdpElementHandle(realm, remoteObject);
+ }
+ return new CdpJSHandle(realm, remoteObject);
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/FirefoxTargetManager.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/FirefoxTargetManager.ts
new file mode 100644
index 0000000000..0ef09a0093
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/FirefoxTargetManager.ts
@@ -0,0 +1,210 @@
+/**
+ * @license
+ * Copyright 2022 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {Protocol} from 'devtools-protocol';
+
+import type {TargetFilterCallback} from '../api/Browser.js';
+import {type CDPSession, CDPSessionEvent} from '../api/CDPSession.js';
+import {EventEmitter} from '../common/EventEmitter.js';
+import {assert} from '../util/assert.js';
+import {Deferred} from '../util/Deferred.js';
+
+import type {CdpCDPSession} from './CDPSession.js';
+import type {Connection} from './Connection.js';
+import type {CdpTarget} from './Target.js';
+import {
+ type TargetFactory,
+ TargetManagerEvent,
+ type TargetManager,
+ type TargetManagerEvents,
+} from './TargetManager.js';
+
+/**
+ * FirefoxTargetManager implements target management using
+ * `Target.setDiscoverTargets` without using auto-attach. It, therefore, creates
+ * targets that lazily establish their CDP sessions.
+ *
+ * Although the approach is potentially flaky, there is no other way for Firefox
+ * because Firefox's CDP implementation does not support auto-attach.
+ *
+ * Firefox does not support targetInfoChanged and detachedFromTarget events:
+ *
+ * - https://bugzilla.mozilla.org/show_bug.cgi?id=1610855
+ * - https://bugzilla.mozilla.org/show_bug.cgi?id=1636979
+ * @internal
+ */
+export class FirefoxTargetManager
+ extends EventEmitter<TargetManagerEvents>
+ implements TargetManager
+{
+ #connection: Connection;
+ /**
+ * Keeps track of the following events: 'Target.targetCreated',
+ * 'Target.targetDestroyed'.
+ *
+ * A target becomes discovered when 'Target.targetCreated' is received.
+ * A target is removed from this map once 'Target.targetDestroyed' is
+ * received.
+ *
+ * `targetFilterCallback` has no effect on this map.
+ */
+ #discoveredTargetsByTargetId = new Map<string, Protocol.Target.TargetInfo>();
+ /**
+ * Keeps track of targets that were created via 'Target.targetCreated'
+ * and which one are not filtered out by `targetFilterCallback`.
+ *
+ * The target is removed from here once it's been destroyed.
+ */
+ #availableTargetsByTargetId = new Map<string, CdpTarget>();
+ /**
+ * Tracks which sessions attach to which target.
+ */
+ #availableTargetsBySessionId = new Map<string, CdpTarget>();
+ #targetFilterCallback: TargetFilterCallback | undefined;
+ #targetFactory: TargetFactory;
+
+ #attachedToTargetListenersBySession = new WeakMap<
+ CDPSession | Connection,
+ (event: Protocol.Target.AttachedToTargetEvent) => Promise<void>
+ >();
+
+ #initializeDeferred = Deferred.create<void>();
+ #targetsIdsForInit = new Set<string>();
+
+ constructor(
+ connection: Connection,
+ targetFactory: TargetFactory,
+ targetFilterCallback?: TargetFilterCallback
+ ) {
+ super();
+ this.#connection = connection;
+ this.#targetFilterCallback = targetFilterCallback;
+ this.#targetFactory = targetFactory;
+
+ this.#connection.on('Target.targetCreated', this.#onTargetCreated);
+ this.#connection.on('Target.targetDestroyed', this.#onTargetDestroyed);
+ this.#connection.on(
+ CDPSessionEvent.SessionDetached,
+ this.#onSessionDetached
+ );
+ this.setupAttachmentListeners(this.#connection);
+ }
+
+ setupAttachmentListeners(session: CDPSession | Connection): void {
+ const listener = (event: Protocol.Target.AttachedToTargetEvent) => {
+ return this.#onAttachedToTarget(session, event);
+ };
+ assert(!this.#attachedToTargetListenersBySession.has(session));
+ this.#attachedToTargetListenersBySession.set(session, listener);
+ session.on('Target.attachedToTarget', listener);
+ }
+
+ #onSessionDetached = (session: CDPSession) => {
+ this.removeSessionListeners(session);
+ this.#availableTargetsBySessionId.delete(session.id());
+ };
+
+ removeSessionListeners(session: CDPSession): void {
+ if (this.#attachedToTargetListenersBySession.has(session)) {
+ session.off(
+ 'Target.attachedToTarget',
+ this.#attachedToTargetListenersBySession.get(session)!
+ );
+ this.#attachedToTargetListenersBySession.delete(session);
+ }
+ }
+
+ getAvailableTargets(): ReadonlyMap<string, CdpTarget> {
+ return this.#availableTargetsByTargetId;
+ }
+
+ dispose(): void {
+ this.#connection.off('Target.targetCreated', this.#onTargetCreated);
+ this.#connection.off('Target.targetDestroyed', this.#onTargetDestroyed);
+ }
+
+ async initialize(): Promise<void> {
+ await this.#connection.send('Target.setDiscoverTargets', {
+ discover: true,
+ filter: [{}],
+ });
+ this.#targetsIdsForInit = new Set(this.#discoveredTargetsByTargetId.keys());
+ await this.#initializeDeferred.valueOrThrow();
+ }
+
+ #onTargetCreated = async (
+ event: Protocol.Target.TargetCreatedEvent
+ ): Promise<void> => {
+ if (this.#discoveredTargetsByTargetId.has(event.targetInfo.targetId)) {
+ return;
+ }
+
+ this.#discoveredTargetsByTargetId.set(
+ event.targetInfo.targetId,
+ event.targetInfo
+ );
+
+ if (event.targetInfo.type === 'browser' && event.targetInfo.attached) {
+ const target = this.#targetFactory(event.targetInfo, undefined);
+ target._initialize();
+ this.#availableTargetsByTargetId.set(event.targetInfo.targetId, target);
+ this.#finishInitializationIfReady(target._targetId);
+ return;
+ }
+
+ const target = this.#targetFactory(event.targetInfo, undefined);
+ if (this.#targetFilterCallback && !this.#targetFilterCallback(target)) {
+ this.#finishInitializationIfReady(event.targetInfo.targetId);
+ return;
+ }
+ target._initialize();
+ this.#availableTargetsByTargetId.set(event.targetInfo.targetId, target);
+ this.emit(TargetManagerEvent.TargetAvailable, target);
+ this.#finishInitializationIfReady(target._targetId);
+ };
+
+ #onTargetDestroyed = (event: Protocol.Target.TargetDestroyedEvent): void => {
+ this.#discoveredTargetsByTargetId.delete(event.targetId);
+ this.#finishInitializationIfReady(event.targetId);
+ const target = this.#availableTargetsByTargetId.get(event.targetId);
+ if (target) {
+ this.emit(TargetManagerEvent.TargetGone, target);
+ this.#availableTargetsByTargetId.delete(event.targetId);
+ }
+ };
+
+ #onAttachedToTarget = async (
+ parentSession: Connection | CDPSession,
+ event: Protocol.Target.AttachedToTargetEvent
+ ) => {
+ const targetInfo = event.targetInfo;
+ const session = this.#connection.session(event.sessionId);
+ if (!session) {
+ throw new Error(`Session ${event.sessionId} was not created.`);
+ }
+
+ const target = this.#availableTargetsByTargetId.get(targetInfo.targetId);
+
+ assert(target, `Target ${targetInfo.targetId} is missing`);
+
+ (session as CdpCDPSession)._setTarget(target);
+ this.setupAttachmentListeners(session);
+
+ this.#availableTargetsBySessionId.set(
+ session.id(),
+ this.#availableTargetsByTargetId.get(targetInfo.targetId)!
+ );
+
+ parentSession.emit(CDPSessionEvent.Ready, session);
+ };
+
+ #finishInitializationIfReady(targetId: string): void {
+ this.#targetsIdsForInit.delete(targetId);
+ if (this.#targetsIdsForInit.size === 0) {
+ this.#initializeDeferred.resolve();
+ }
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Frame.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Frame.ts
new file mode 100644
index 0000000000..844120d7ff
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Frame.ts
@@ -0,0 +1,351 @@
+/**
+ * @license
+ * Copyright 2017 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {Protocol} from 'devtools-protocol';
+
+import type {CDPSession} from '../api/CDPSession.js';
+import {Frame, FrameEvent, throwIfDetached} from '../api/Frame.js';
+import type {HTTPResponse} from '../api/HTTPResponse.js';
+import type {WaitTimeoutOptions} from '../api/Page.js';
+import {UnsupportedOperation} from '../common/Errors.js';
+import {Deferred} from '../util/Deferred.js';
+import {disposeSymbol} from '../util/disposable.js';
+import {isErrorLike} from '../util/ErrorLike.js';
+
+import type {
+ DeviceRequestPrompt,
+ DeviceRequestPromptManager,
+} from './DeviceRequestPrompt.js';
+import type {FrameManager} from './FrameManager.js';
+import {IsolatedWorld} from './IsolatedWorld.js';
+import {MAIN_WORLD, PUPPETEER_WORLD} from './IsolatedWorlds.js';
+import {
+ LifecycleWatcher,
+ type PuppeteerLifeCycleEvent,
+} from './LifecycleWatcher.js';
+import type {CdpPage} from './Page.js';
+
+/**
+ * @internal
+ */
+export class CdpFrame extends Frame {
+ #url = '';
+ #detached = false;
+ #client!: CDPSession;
+
+ _frameManager: FrameManager;
+ override _id: string;
+ _loaderId = '';
+ _lifecycleEvents = new Set<string>();
+ override _parentId?: string;
+
+ constructor(
+ frameManager: FrameManager,
+ frameId: string,
+ parentFrameId: string | undefined,
+ client: CDPSession
+ ) {
+ super();
+ this._frameManager = frameManager;
+ this.#url = '';
+ this._id = frameId;
+ this._parentId = parentFrameId;
+ this.#detached = false;
+
+ this._loaderId = '';
+
+ this.updateClient(client);
+
+ this.on(FrameEvent.FrameSwappedByActivation, () => {
+ // Emulate loading process for swapped frames.
+ this._onLoadingStarted();
+ this._onLoadingStopped();
+ });
+ }
+
+ /**
+ * This is used internally in DevTools.
+ *
+ * @internal
+ */
+ _client(): CDPSession {
+ return this.#client;
+ }
+
+ /**
+ * Updates the frame ID with the new ID. This happens when the main frame is
+ * replaced by a different frame.
+ */
+ updateId(id: string): void {
+ this._id = id;
+ }
+
+ updateClient(client: CDPSession, keepWorlds = false): void {
+ this.#client = client;
+ if (!keepWorlds) {
+ // Clear the current contexts on previous world instances.
+ if (this.worlds) {
+ this.worlds[MAIN_WORLD].clearContext();
+ this.worlds[PUPPETEER_WORLD].clearContext();
+ }
+ this.worlds = {
+ [MAIN_WORLD]: new IsolatedWorld(
+ this,
+ this._frameManager.timeoutSettings
+ ),
+ [PUPPETEER_WORLD]: new IsolatedWorld(
+ this,
+ this._frameManager.timeoutSettings
+ ),
+ };
+ } else {
+ this.worlds[MAIN_WORLD].frameUpdated();
+ this.worlds[PUPPETEER_WORLD].frameUpdated();
+ }
+ }
+
+ override page(): CdpPage {
+ return this._frameManager.page();
+ }
+
+ override isOOPFrame(): boolean {
+ return this.#client !== this._frameManager.client;
+ }
+
+ @throwIfDetached
+ override async goto(
+ url: string,
+ options: {
+ referer?: string;
+ referrerPolicy?: string;
+ timeout?: number;
+ waitUntil?: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[];
+ } = {}
+ ): Promise<HTTPResponse | null> {
+ const {
+ referer = this._frameManager.networkManager.extraHTTPHeaders()['referer'],
+ referrerPolicy = this._frameManager.networkManager.extraHTTPHeaders()[
+ 'referer-policy'
+ ],
+ waitUntil = ['load'],
+ timeout = this._frameManager.timeoutSettings.navigationTimeout(),
+ } = options;
+
+ let ensureNewDocumentNavigation = false;
+ const watcher = new LifecycleWatcher(
+ this._frameManager.networkManager,
+ this,
+ waitUntil,
+ timeout
+ );
+ let error = await Deferred.race([
+ navigate(
+ this.#client,
+ url,
+ referer,
+ referrerPolicy as Protocol.Page.ReferrerPolicy,
+ this._id
+ ),
+ watcher.terminationPromise(),
+ ]);
+ if (!error) {
+ error = await Deferred.race([
+ watcher.terminationPromise(),
+ ensureNewDocumentNavigation
+ ? watcher.newDocumentNavigationPromise()
+ : watcher.sameDocumentNavigationPromise(),
+ ]);
+ }
+
+ try {
+ if (error) {
+ throw error;
+ }
+ return await watcher.navigationResponse();
+ } finally {
+ watcher.dispose();
+ }
+
+ async function navigate(
+ client: CDPSession,
+ url: string,
+ referrer: string | undefined,
+ referrerPolicy: Protocol.Page.ReferrerPolicy | undefined,
+ frameId: string
+ ): Promise<Error | null> {
+ try {
+ const response = await client.send('Page.navigate', {
+ url,
+ referrer,
+ frameId,
+ referrerPolicy,
+ });
+ ensureNewDocumentNavigation = !!response.loaderId;
+ if (response.errorText === 'net::ERR_HTTP_RESPONSE_CODE_FAILURE') {
+ return null;
+ }
+ return response.errorText
+ ? new Error(`${response.errorText} at ${url}`)
+ : null;
+ } catch (error) {
+ if (isErrorLike(error)) {
+ return error;
+ }
+ throw error;
+ }
+ }
+ }
+
+ @throwIfDetached
+ override async waitForNavigation(
+ options: {
+ timeout?: number;
+ waitUntil?: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[];
+ } = {}
+ ): Promise<HTTPResponse | null> {
+ const {
+ waitUntil = ['load'],
+ timeout = this._frameManager.timeoutSettings.navigationTimeout(),
+ } = options;
+ const watcher = new LifecycleWatcher(
+ this._frameManager.networkManager,
+ this,
+ waitUntil,
+ timeout
+ );
+ const error = await Deferred.race([
+ watcher.terminationPromise(),
+ watcher.sameDocumentNavigationPromise(),
+ watcher.newDocumentNavigationPromise(),
+ ]);
+ try {
+ if (error) {
+ throw error;
+ }
+ return await watcher.navigationResponse();
+ } finally {
+ watcher.dispose();
+ }
+ }
+
+ override get client(): CDPSession {
+ return this.#client;
+ }
+
+ override mainRealm(): IsolatedWorld {
+ return this.worlds[MAIN_WORLD];
+ }
+
+ override isolatedRealm(): IsolatedWorld {
+ return this.worlds[PUPPETEER_WORLD];
+ }
+
+ @throwIfDetached
+ override async setContent(
+ html: string,
+ options: {
+ timeout?: number;
+ waitUntil?: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[];
+ } = {}
+ ): Promise<void> {
+ const {
+ waitUntil = ['load'],
+ timeout = this._frameManager.timeoutSettings.navigationTimeout(),
+ } = options;
+
+ // We rely upon the fact that document.open() will reset frame lifecycle with "init"
+ // lifecycle event. @see https://crrev.com/608658
+ await this.setFrameContent(html);
+
+ const watcher = new LifecycleWatcher(
+ this._frameManager.networkManager,
+ this,
+ waitUntil,
+ timeout
+ );
+ const error = await Deferred.race<void | Error | undefined>([
+ watcher.terminationPromise(),
+ watcher.lifecyclePromise(),
+ ]);
+ watcher.dispose();
+ if (error) {
+ throw error;
+ }
+ }
+
+ override url(): string {
+ return this.#url;
+ }
+
+ override parentFrame(): CdpFrame | null {
+ return this._frameManager._frameTree.parentFrame(this._id) || null;
+ }
+
+ override childFrames(): CdpFrame[] {
+ return this._frameManager._frameTree.childFrames(this._id);
+ }
+
+ #deviceRequestPromptManager(): DeviceRequestPromptManager {
+ const rootFrame = this.page().mainFrame();
+ if (this.isOOPFrame() || rootFrame === null) {
+ return this._frameManager._deviceRequestPromptManager(this.#client);
+ } else {
+ return rootFrame._frameManager._deviceRequestPromptManager(this.#client);
+ }
+ }
+
+ @throwIfDetached
+ override async waitForDevicePrompt(
+ options: WaitTimeoutOptions = {}
+ ): Promise<DeviceRequestPrompt> {
+ return await this.#deviceRequestPromptManager().waitForDevicePrompt(
+ options
+ );
+ }
+
+ _navigated(framePayload: Protocol.Page.Frame): void {
+ this._name = framePayload.name;
+ this.#url = `${framePayload.url}${framePayload.urlFragment || ''}`;
+ }
+
+ _navigatedWithinDocument(url: string): void {
+ this.#url = url;
+ }
+
+ _onLifecycleEvent(loaderId: string, name: string): void {
+ if (name === 'init') {
+ this._loaderId = loaderId;
+ this._lifecycleEvents.clear();
+ }
+ this._lifecycleEvents.add(name);
+ }
+
+ _onLoadingStopped(): void {
+ this._lifecycleEvents.add('DOMContentLoaded');
+ this._lifecycleEvents.add('load');
+ }
+
+ _onLoadingStarted(): void {
+ this._hasStartedLoading = true;
+ }
+
+ override get detached(): boolean {
+ return this.#detached;
+ }
+
+ [disposeSymbol](): void {
+ if (this.#detached) {
+ return;
+ }
+ this.#detached = true;
+ this.worlds[MAIN_WORLD][disposeSymbol]();
+ this.worlds[PUPPETEER_WORLD][disposeSymbol]();
+ }
+
+ exposeFunction(): never {
+ throw new UnsupportedOperation();
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/FrameManager.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/FrameManager.ts
new file mode 100644
index 0000000000..48ed9ac2f5
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/FrameManager.ts
@@ -0,0 +1,551 @@
+/**
+ * @license
+ * Copyright 2017 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {Protocol} from 'devtools-protocol';
+
+import {type CDPSession, CDPSessionEvent} from '../api/CDPSession.js';
+import {FrameEvent} from '../api/Frame.js';
+import {EventEmitter} from '../common/EventEmitter.js';
+import type {TimeoutSettings} from '../common/TimeoutSettings.js';
+import {debugError, PuppeteerURL, UTILITY_WORLD_NAME} from '../common/util.js';
+import {assert} from '../util/assert.js';
+import {Deferred} from '../util/Deferred.js';
+import {disposeSymbol} from '../util/disposable.js';
+import {isErrorLike} from '../util/ErrorLike.js';
+
+import {CdpCDPSession} from './CDPSession.js';
+import {isTargetClosedError} from './Connection.js';
+import {DeviceRequestPromptManager} from './DeviceRequestPrompt.js';
+import {ExecutionContext} from './ExecutionContext.js';
+import {CdpFrame} from './Frame.js';
+import type {FrameManagerEvents} from './FrameManagerEvents.js';
+import {FrameManagerEvent} from './FrameManagerEvents.js';
+import {FrameTree} from './FrameTree.js';
+import type {IsolatedWorld} from './IsolatedWorld.js';
+import {MAIN_WORLD, PUPPETEER_WORLD} from './IsolatedWorlds.js';
+import {NetworkManager} from './NetworkManager.js';
+import type {CdpPage} from './Page.js';
+import type {CdpTarget} from './Target.js';
+
+const TIME_FOR_WAITING_FOR_SWAP = 100; // ms.
+
+/**
+ * A frame manager manages the frames for a given {@link Page | page}.
+ *
+ * @internal
+ */
+export class FrameManager extends EventEmitter<FrameManagerEvents> {
+ #page: CdpPage;
+ #networkManager: NetworkManager;
+ #timeoutSettings: TimeoutSettings;
+ #contextIdToContext = new Map<string, ExecutionContext>();
+ #isolatedWorlds = new Set<string>();
+ #client: CDPSession;
+
+ _frameTree = new FrameTree<CdpFrame>();
+
+ /**
+ * Set of frame IDs stored to indicate if a frame has received a
+ * frameNavigated event so that frame tree responses could be ignored as the
+ * frameNavigated event usually contains the latest information.
+ */
+ #frameNavigatedReceived = new Set<string>();
+
+ #deviceRequestPromptManagerMap = new WeakMap<
+ CDPSession,
+ DeviceRequestPromptManager
+ >();
+
+ #frameTreeHandled?: Deferred<void>;
+
+ get timeoutSettings(): TimeoutSettings {
+ return this.#timeoutSettings;
+ }
+
+ get networkManager(): NetworkManager {
+ return this.#networkManager;
+ }
+
+ get client(): CDPSession {
+ return this.#client;
+ }
+
+ constructor(
+ client: CDPSession,
+ page: CdpPage,
+ ignoreHTTPSErrors: boolean,
+ timeoutSettings: TimeoutSettings
+ ) {
+ super();
+ this.#client = client;
+ this.#page = page;
+ this.#networkManager = new NetworkManager(ignoreHTTPSErrors, this);
+ this.#timeoutSettings = timeoutSettings;
+ this.setupEventListeners(this.#client);
+ client.once(CDPSessionEvent.Disconnected, () => {
+ this.#onClientDisconnect().catch(debugError);
+ });
+ }
+
+ /**
+ * Called when the frame's client is disconnected. We don't know if the
+ * disconnect means that the frame is removed or if it will be replaced by a
+ * new frame. Therefore, we wait for a swap event.
+ */
+ async #onClientDisconnect() {
+ const mainFrame = this._frameTree.getMainFrame();
+ if (!mainFrame) {
+ return;
+ }
+ for (const child of mainFrame.childFrames()) {
+ this.#removeFramesRecursively(child);
+ }
+ const swapped = Deferred.create<void>({
+ timeout: TIME_FOR_WAITING_FOR_SWAP,
+ message: 'Frame was not swapped',
+ });
+ mainFrame.once(FrameEvent.FrameSwappedByActivation, () => {
+ swapped.resolve();
+ });
+ try {
+ await swapped.valueOrThrow();
+ } catch (err) {
+ this.#removeFramesRecursively(mainFrame);
+ }
+ }
+
+ /**
+ * When the main frame is replaced by another main frame,
+ * we maintain the main frame object identity while updating
+ * its frame tree and ID.
+ */
+ async swapFrameTree(client: CDPSession): Promise<void> {
+ this.#onExecutionContextsCleared(this.#client);
+
+ this.#client = client;
+ assert(
+ this.#client instanceof CdpCDPSession,
+ 'CDPSession is not an instance of CDPSessionImpl.'
+ );
+ const frame = this._frameTree.getMainFrame();
+ if (frame) {
+ this.#frameNavigatedReceived.add(this.#client._target()._targetId);
+ this._frameTree.removeFrame(frame);
+ frame.updateId(this.#client._target()._targetId);
+ frame.mainRealm().clearContext();
+ frame.isolatedRealm().clearContext();
+ this._frameTree.addFrame(frame);
+ frame.updateClient(client, true);
+ }
+ this.setupEventListeners(client);
+ client.once(CDPSessionEvent.Disconnected, () => {
+ this.#onClientDisconnect().catch(debugError);
+ });
+ await this.initialize(client);
+ await this.#networkManager.addClient(client);
+ if (frame) {
+ frame.emit(FrameEvent.FrameSwappedByActivation, undefined);
+ }
+ }
+
+ async registerSpeculativeSession(client: CdpCDPSession): Promise<void> {
+ await this.#networkManager.addClient(client);
+ }
+
+ private setupEventListeners(session: CDPSession) {
+ session.on('Page.frameAttached', async event => {
+ await this.#frameTreeHandled?.valueOrThrow();
+ this.#onFrameAttached(session, event.frameId, event.parentFrameId);
+ });
+ session.on('Page.frameNavigated', async event => {
+ this.#frameNavigatedReceived.add(event.frame.id);
+ await this.#frameTreeHandled?.valueOrThrow();
+ void this.#onFrameNavigated(event.frame, event.type);
+ });
+ session.on('Page.navigatedWithinDocument', async event => {
+ await this.#frameTreeHandled?.valueOrThrow();
+ this.#onFrameNavigatedWithinDocument(event.frameId, event.url);
+ });
+ session.on(
+ 'Page.frameDetached',
+ async (event: Protocol.Page.FrameDetachedEvent) => {
+ await this.#frameTreeHandled?.valueOrThrow();
+ this.#onFrameDetached(
+ event.frameId,
+ event.reason as Protocol.Page.FrameDetachedEventReason
+ );
+ }
+ );
+ session.on('Page.frameStartedLoading', async event => {
+ await this.#frameTreeHandled?.valueOrThrow();
+ this.#onFrameStartedLoading(event.frameId);
+ });
+ session.on('Page.frameStoppedLoading', async event => {
+ await this.#frameTreeHandled?.valueOrThrow();
+ this.#onFrameStoppedLoading(event.frameId);
+ });
+ session.on('Runtime.executionContextCreated', async event => {
+ await this.#frameTreeHandled?.valueOrThrow();
+ this.#onExecutionContextCreated(event.context, session);
+ });
+ session.on('Runtime.executionContextDestroyed', async event => {
+ await this.#frameTreeHandled?.valueOrThrow();
+ this.#onExecutionContextDestroyed(event.executionContextId, session);
+ });
+ session.on('Runtime.executionContextsCleared', async () => {
+ await this.#frameTreeHandled?.valueOrThrow();
+ this.#onExecutionContextsCleared(session);
+ });
+ session.on('Page.lifecycleEvent', async event => {
+ await this.#frameTreeHandled?.valueOrThrow();
+ this.#onLifecycleEvent(event);
+ });
+ }
+
+ async initialize(client: CDPSession): Promise<void> {
+ try {
+ this.#frameTreeHandled?.resolve();
+ this.#frameTreeHandled = Deferred.create();
+ // We need to schedule all these commands while the target is paused,
+ // therefore, it needs to happen synchroniously. At the same time we
+ // should not start processing execution context and frame events before
+ // we received the initial information about the frame tree.
+ await Promise.all([
+ this.#networkManager.addClient(client),
+ client.send('Page.enable'),
+ client.send('Page.getFrameTree').then(({frameTree}) => {
+ this.#handleFrameTree(client, frameTree);
+ this.#frameTreeHandled?.resolve();
+ }),
+ client.send('Page.setLifecycleEventsEnabled', {enabled: true}),
+ client.send('Runtime.enable').then(() => {
+ return this.#createIsolatedWorld(client, UTILITY_WORLD_NAME);
+ }),
+ ]);
+ } catch (error) {
+ this.#frameTreeHandled?.resolve();
+ // The target might have been closed before the initialization finished.
+ if (isErrorLike(error) && isTargetClosedError(error)) {
+ return;
+ }
+
+ throw error;
+ }
+ }
+
+ executionContextById(
+ contextId: number,
+ session: CDPSession = this.#client
+ ): ExecutionContext {
+ const context = this.getExecutionContextById(contextId, session);
+ assert(context, 'INTERNAL ERROR: missing context with id = ' + contextId);
+ return context;
+ }
+
+ getExecutionContextById(
+ contextId: number,
+ session: CDPSession = this.#client
+ ): ExecutionContext | undefined {
+ return this.#contextIdToContext.get(`${session.id()}:${contextId}`);
+ }
+
+ page(): CdpPage {
+ return this.#page;
+ }
+
+ mainFrame(): CdpFrame {
+ const mainFrame = this._frameTree.getMainFrame();
+ assert(mainFrame, 'Requesting main frame too early!');
+ return mainFrame;
+ }
+
+ frames(): CdpFrame[] {
+ return Array.from(this._frameTree.frames());
+ }
+
+ frame(frameId: string): CdpFrame | null {
+ return this._frameTree.getById(frameId) || null;
+ }
+
+ onAttachedToTarget(target: CdpTarget): void {
+ if (target._getTargetInfo().type !== 'iframe') {
+ return;
+ }
+
+ const frame = this.frame(target._getTargetInfo().targetId);
+ if (frame) {
+ frame.updateClient(target._session()!);
+ }
+ this.setupEventListeners(target._session()!);
+ void this.initialize(target._session()!);
+ }
+
+ _deviceRequestPromptManager(client: CDPSession): DeviceRequestPromptManager {
+ let manager = this.#deviceRequestPromptManagerMap.get(client);
+ if (manager === undefined) {
+ manager = new DeviceRequestPromptManager(client, this.#timeoutSettings);
+ this.#deviceRequestPromptManagerMap.set(client, manager);
+ }
+ return manager;
+ }
+
+ #onLifecycleEvent(event: Protocol.Page.LifecycleEventEvent): void {
+ const frame = this.frame(event.frameId);
+ if (!frame) {
+ return;
+ }
+ frame._onLifecycleEvent(event.loaderId, event.name);
+ this.emit(FrameManagerEvent.LifecycleEvent, frame);
+ frame.emit(FrameEvent.LifecycleEvent, undefined);
+ }
+
+ #onFrameStartedLoading(frameId: string): void {
+ const frame = this.frame(frameId);
+ if (!frame) {
+ return;
+ }
+ frame._onLoadingStarted();
+ }
+
+ #onFrameStoppedLoading(frameId: string): void {
+ const frame = this.frame(frameId);
+ if (!frame) {
+ return;
+ }
+ frame._onLoadingStopped();
+ this.emit(FrameManagerEvent.LifecycleEvent, frame);
+ frame.emit(FrameEvent.LifecycleEvent, undefined);
+ }
+
+ #handleFrameTree(
+ session: CDPSession,
+ frameTree: Protocol.Page.FrameTree
+ ): void {
+ if (frameTree.frame.parentId) {
+ this.#onFrameAttached(
+ session,
+ frameTree.frame.id,
+ frameTree.frame.parentId
+ );
+ }
+ if (!this.#frameNavigatedReceived.has(frameTree.frame.id)) {
+ void this.#onFrameNavigated(frameTree.frame, 'Navigation');
+ } else {
+ this.#frameNavigatedReceived.delete(frameTree.frame.id);
+ }
+
+ if (!frameTree.childFrames) {
+ return;
+ }
+
+ for (const child of frameTree.childFrames) {
+ this.#handleFrameTree(session, child);
+ }
+ }
+
+ #onFrameAttached(
+ session: CDPSession,
+ frameId: string,
+ parentFrameId: string
+ ): void {
+ let frame = this.frame(frameId);
+ if (frame) {
+ if (session && frame.isOOPFrame()) {
+ // If an OOP iframes becomes a normal iframe again
+ // it is first attached to the parent page before
+ // the target is removed.
+ frame.updateClient(session);
+ }
+ return;
+ }
+
+ frame = new CdpFrame(this, frameId, parentFrameId, session);
+ this._frameTree.addFrame(frame);
+ this.emit(FrameManagerEvent.FrameAttached, frame);
+ }
+
+ async #onFrameNavigated(
+ framePayload: Protocol.Page.Frame,
+ navigationType: Protocol.Page.NavigationType
+ ): Promise<void> {
+ const frameId = framePayload.id;
+ const isMainFrame = !framePayload.parentId;
+
+ let frame = this._frameTree.getById(frameId);
+
+ // Detach all child frames first.
+ if (frame) {
+ for (const child of frame.childFrames()) {
+ this.#removeFramesRecursively(child);
+ }
+ }
+
+ // Update or create main frame.
+ if (isMainFrame) {
+ if (frame) {
+ // Update frame id to retain frame identity on cross-process navigation.
+ this._frameTree.removeFrame(frame);
+ frame._id = frameId;
+ } else {
+ // Initial main frame navigation.
+ frame = new CdpFrame(this, frameId, undefined, this.#client);
+ }
+ this._frameTree.addFrame(frame);
+ }
+
+ frame = await this._frameTree.waitForFrame(frameId);
+ frame._navigated(framePayload);
+ this.emit(FrameManagerEvent.FrameNavigated, frame);
+ frame.emit(FrameEvent.FrameNavigated, navigationType);
+ }
+
+ async #createIsolatedWorld(session: CDPSession, name: string): Promise<void> {
+ const key = `${session.id()}:${name}`;
+
+ if (this.#isolatedWorlds.has(key)) {
+ return;
+ }
+
+ await session.send('Page.addScriptToEvaluateOnNewDocument', {
+ source: `//# sourceURL=${PuppeteerURL.INTERNAL_URL}`,
+ worldName: name,
+ });
+
+ await Promise.all(
+ this.frames()
+ .filter(frame => {
+ return frame.client === session;
+ })
+ .map(frame => {
+ // Frames might be removed before we send this, so we don't want to
+ // throw an error.
+ return session
+ .send('Page.createIsolatedWorld', {
+ frameId: frame._id,
+ worldName: name,
+ grantUniveralAccess: true,
+ })
+ .catch(debugError);
+ })
+ );
+
+ this.#isolatedWorlds.add(key);
+ }
+
+ #onFrameNavigatedWithinDocument(frameId: string, url: string): void {
+ const frame = this.frame(frameId);
+ if (!frame) {
+ return;
+ }
+ frame._navigatedWithinDocument(url);
+ this.emit(FrameManagerEvent.FrameNavigatedWithinDocument, frame);
+ frame.emit(FrameEvent.FrameNavigatedWithinDocument, undefined);
+ this.emit(FrameManagerEvent.FrameNavigated, frame);
+ frame.emit(FrameEvent.FrameNavigated, 'Navigation');
+ }
+
+ #onFrameDetached(
+ frameId: string,
+ reason: Protocol.Page.FrameDetachedEventReason
+ ): void {
+ const frame = this.frame(frameId);
+ if (!frame) {
+ return;
+ }
+ switch (reason) {
+ case 'remove':
+ // Only remove the frame if the reason for the detached event is
+ // an actual removement of the frame.
+ // For frames that become OOP iframes, the reason would be 'swap'.
+ this.#removeFramesRecursively(frame);
+ break;
+ case 'swap':
+ this.emit(FrameManagerEvent.FrameSwapped, frame);
+ frame.emit(FrameEvent.FrameSwapped, undefined);
+ break;
+ }
+ }
+
+ #onExecutionContextCreated(
+ contextPayload: Protocol.Runtime.ExecutionContextDescription,
+ session: CDPSession
+ ): void {
+ const auxData = contextPayload.auxData as {frameId?: string} | undefined;
+ const frameId = auxData && auxData.frameId;
+ const frame = typeof frameId === 'string' ? this.frame(frameId) : undefined;
+ let world: IsolatedWorld | undefined;
+ if (frame) {
+ // Only care about execution contexts created for the current session.
+ if (frame.client !== session) {
+ return;
+ }
+ if (contextPayload.auxData && contextPayload.auxData['isDefault']) {
+ world = frame.worlds[MAIN_WORLD];
+ } else if (
+ contextPayload.name === UTILITY_WORLD_NAME &&
+ !frame.worlds[PUPPETEER_WORLD].hasContext()
+ ) {
+ // In case of multiple sessions to the same target, there's a race between
+ // connections so we might end up creating multiple isolated worlds.
+ // We can use either.
+ world = frame.worlds[PUPPETEER_WORLD];
+ }
+ }
+ // If there is no world, the context is not meant to be handled by us.
+ if (!world) {
+ return;
+ }
+ const context = new ExecutionContext(
+ frame?.client || this.#client,
+ contextPayload,
+ world
+ );
+ if (world) {
+ world.setContext(context);
+ }
+ const key = `${session.id()}:${contextPayload.id}`;
+ this.#contextIdToContext.set(key, context);
+ }
+
+ #onExecutionContextDestroyed(
+ executionContextId: number,
+ session: CDPSession
+ ): void {
+ const key = `${session.id()}:${executionContextId}`;
+ const context = this.#contextIdToContext.get(key);
+ if (!context) {
+ return;
+ }
+ this.#contextIdToContext.delete(key);
+ if (context._world) {
+ context._world.clearContext();
+ }
+ }
+
+ #onExecutionContextsCleared(session: CDPSession): void {
+ for (const [key, context] of this.#contextIdToContext.entries()) {
+ // Make sure to only clear execution contexts that belong
+ // to the current session.
+ if (context._client !== session) {
+ continue;
+ }
+ if (context._world) {
+ context._world.clearContext();
+ }
+ this.#contextIdToContext.delete(key);
+ }
+ }
+
+ #removeFramesRecursively(frame: CdpFrame): void {
+ for (const child of frame.childFrames()) {
+ this.#removeFramesRecursively(child);
+ }
+ frame[disposeSymbol]();
+ this._frameTree.removeFrame(frame);
+ this.emit(FrameManagerEvent.FrameDetached, frame);
+ frame.emit(FrameEvent.FrameDetached, frame);
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/FrameManagerEvents.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/FrameManagerEvents.ts
new file mode 100644
index 0000000000..645dd86d71
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/FrameManagerEvents.ts
@@ -0,0 +1,39 @@
+/**
+ * @license
+ * Copyright 2023 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {EventType} from '../common/EventEmitter.js';
+
+import type {CdpFrame} from './Frame.js';
+
+/**
+ * We use symbols to prevent external parties listening to these events.
+ * They are internal to Puppeteer.
+ *
+ * @internal
+ */
+// eslint-disable-next-line @typescript-eslint/no-namespace
+export namespace FrameManagerEvent {
+ export const FrameAttached = Symbol('FrameManager.FrameAttached');
+ export const FrameNavigated = Symbol('FrameManager.FrameNavigated');
+ export const FrameDetached = Symbol('FrameManager.FrameDetached');
+ export const FrameSwapped = Symbol('FrameManager.FrameSwapped');
+ export const LifecycleEvent = Symbol('FrameManager.LifecycleEvent');
+ export const FrameNavigatedWithinDocument = Symbol(
+ 'FrameManager.FrameNavigatedWithinDocument'
+ );
+}
+
+/**
+ * @internal
+ */
+export interface FrameManagerEvents extends Record<EventType, unknown> {
+ [FrameManagerEvent.FrameAttached]: CdpFrame;
+ [FrameManagerEvent.FrameNavigated]: CdpFrame;
+ [FrameManagerEvent.FrameDetached]: CdpFrame;
+ [FrameManagerEvent.FrameSwapped]: CdpFrame;
+ [FrameManagerEvent.LifecycleEvent]: CdpFrame;
+ [FrameManagerEvent.FrameNavigatedWithinDocument]: CdpFrame;
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/FrameTree.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/FrameTree.ts
new file mode 100644
index 0000000000..7ee1b86b5f
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/FrameTree.ts
@@ -0,0 +1,98 @@
+/**
+ * @license
+ * Copyright 2022 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {Frame} from '../api/Frame.js';
+import {Deferred} from '../util/Deferred.js';
+
+/**
+ * Keeps track of the page frame tree and it's is managed by
+ * {@link FrameManager}. FrameTree uses frame IDs to reference frame and it
+ * means that referenced frames might not be in the tree anymore. Thus, the tree
+ * structure is eventually consistent.
+ * @internal
+ */
+export class FrameTree<FrameType extends Frame> {
+ #frames = new Map<string, FrameType>();
+ // frameID -> parentFrameID
+ #parentIds = new Map<string, string>();
+ // frameID -> childFrameIDs
+ #childIds = new Map<string, Set<string>>();
+ #mainFrame?: FrameType;
+ #waitRequests = new Map<string, Set<Deferred<FrameType>>>();
+
+ getMainFrame(): FrameType | undefined {
+ return this.#mainFrame;
+ }
+
+ getById(frameId: string): FrameType | undefined {
+ return this.#frames.get(frameId);
+ }
+
+ /**
+ * Returns a promise that is resolved once the frame with
+ * the given ID is added to the tree.
+ */
+ waitForFrame(frameId: string): Promise<FrameType> {
+ const frame = this.getById(frameId);
+ if (frame) {
+ return Promise.resolve(frame);
+ }
+ const deferred = Deferred.create<FrameType>();
+ const callbacks =
+ this.#waitRequests.get(frameId) || new Set<Deferred<FrameType>>();
+ callbacks.add(deferred);
+ return deferred.valueOrThrow();
+ }
+
+ frames(): FrameType[] {
+ return Array.from(this.#frames.values());
+ }
+
+ addFrame(frame: FrameType): void {
+ this.#frames.set(frame._id, frame);
+ if (frame._parentId) {
+ this.#parentIds.set(frame._id, frame._parentId);
+ if (!this.#childIds.has(frame._parentId)) {
+ this.#childIds.set(frame._parentId, new Set());
+ }
+ this.#childIds.get(frame._parentId)!.add(frame._id);
+ } else if (!this.#mainFrame) {
+ this.#mainFrame = frame;
+ }
+ this.#waitRequests.get(frame._id)?.forEach(request => {
+ return request.resolve(frame);
+ });
+ }
+
+ removeFrame(frame: FrameType): void {
+ this.#frames.delete(frame._id);
+ this.#parentIds.delete(frame._id);
+ if (frame._parentId) {
+ this.#childIds.get(frame._parentId)?.delete(frame._id);
+ } else {
+ this.#mainFrame = undefined;
+ }
+ }
+
+ childFrames(frameId: string): FrameType[] {
+ const childIds = this.#childIds.get(frameId);
+ if (!childIds) {
+ return [];
+ }
+ return Array.from(childIds)
+ .map(id => {
+ return this.getById(id);
+ })
+ .filter((frame): frame is FrameType => {
+ return frame !== undefined;
+ });
+ }
+
+ parentFrame(frameId: string): FrameType | undefined {
+ const parentId = this.#parentIds.get(frameId);
+ return parentId ? this.getById(parentId) : undefined;
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/HTTPRequest.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/HTTPRequest.ts
new file mode 100644
index 0000000000..029e77470b
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/HTTPRequest.ts
@@ -0,0 +1,449 @@
+/**
+ * @license
+ * Copyright 2020 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import type {Protocol} from 'devtools-protocol';
+
+import type {CDPSession} from '../api/CDPSession.js';
+import type {Frame} from '../api/Frame.js';
+import {
+ type ContinueRequestOverrides,
+ type ErrorCode,
+ headersArray,
+ HTTPRequest,
+ InterceptResolutionAction,
+ type InterceptResolutionState,
+ type ResourceType,
+ type ResponseForRequest,
+ STATUS_TEXTS,
+} from '../api/HTTPRequest.js';
+import type {ProtocolError} from '../common/Errors.js';
+import {debugError, isString} from '../common/util.js';
+import {assert} from '../util/assert.js';
+
+import type {CdpHTTPResponse} from './HTTPResponse.js';
+
+/**
+ * @internal
+ */
+export class CdpHTTPRequest extends HTTPRequest {
+ declare _redirectChain: CdpHTTPRequest[];
+ declare _response: CdpHTTPResponse | null;
+
+ #client: CDPSession;
+ #isNavigationRequest: boolean;
+ #allowInterception: boolean;
+ #interceptionHandled = false;
+ #url: string;
+ #resourceType: ResourceType;
+
+ #method: string;
+ #hasPostData = false;
+ #postData?: string;
+ #headers: Record<string, string> = {};
+ #frame: Frame | null;
+ #continueRequestOverrides: ContinueRequestOverrides;
+ #responseForRequest: Partial<ResponseForRequest> | null = null;
+ #abortErrorReason: Protocol.Network.ErrorReason | null = null;
+ #interceptResolutionState: InterceptResolutionState = {
+ action: InterceptResolutionAction.None,
+ };
+ #interceptHandlers: Array<() => void | PromiseLike<any>>;
+ #initiator?: Protocol.Network.Initiator;
+
+ override get client(): CDPSession {
+ return this.#client;
+ }
+
+ constructor(
+ client: CDPSession,
+ frame: Frame | null,
+ interceptionId: string | undefined,
+ allowInterception: boolean,
+ data: {
+ /**
+ * Request identifier.
+ */
+ requestId: Protocol.Network.RequestId;
+ /**
+ * Loader identifier. Empty string if the request is fetched from worker.
+ */
+ loaderId?: Protocol.Network.LoaderId;
+ /**
+ * URL of the document this request is loaded for.
+ */
+ documentURL?: string;
+ /**
+ * Request data.
+ */
+ request: Protocol.Network.Request;
+ /**
+ * Request initiator.
+ */
+ initiator?: Protocol.Network.Initiator;
+ /**
+ * Type of this resource.
+ */
+ type?: Protocol.Network.ResourceType;
+ },
+ redirectChain: CdpHTTPRequest[]
+ ) {
+ super();
+ this.#client = client;
+ this._requestId = data.requestId;
+ this.#isNavigationRequest =
+ data.requestId === data.loaderId && data.type === 'Document';
+ this._interceptionId = interceptionId;
+ this.#allowInterception = allowInterception;
+ this.#url = data.request.url;
+ this.#resourceType = (data.type || 'other').toLowerCase() as ResourceType;
+ this.#method = data.request.method;
+ this.#postData = data.request.postData;
+ this.#hasPostData = data.request.hasPostData ?? false;
+ this.#frame = frame;
+ this._redirectChain = redirectChain;
+ this.#continueRequestOverrides = {};
+ this.#interceptHandlers = [];
+ this.#initiator = data.initiator;
+
+ for (const [key, value] of Object.entries(data.request.headers)) {
+ this.#headers[key.toLowerCase()] = value;
+ }
+ }
+
+ override url(): string {
+ return this.#url;
+ }
+
+ override continueRequestOverrides(): ContinueRequestOverrides {
+ assert(this.#allowInterception, 'Request Interception is not enabled!');
+ return this.#continueRequestOverrides;
+ }
+
+ override responseForRequest(): Partial<ResponseForRequest> | null {
+ assert(this.#allowInterception, 'Request Interception is not enabled!');
+ return this.#responseForRequest;
+ }
+
+ override abortErrorReason(): Protocol.Network.ErrorReason | null {
+ assert(this.#allowInterception, 'Request Interception is not enabled!');
+ return this.#abortErrorReason;
+ }
+
+ override interceptResolutionState(): InterceptResolutionState {
+ if (!this.#allowInterception) {
+ return {action: InterceptResolutionAction.Disabled};
+ }
+ if (this.#interceptionHandled) {
+ return {action: InterceptResolutionAction.AlreadyHandled};
+ }
+ return {...this.#interceptResolutionState};
+ }
+
+ override isInterceptResolutionHandled(): boolean {
+ return this.#interceptionHandled;
+ }
+
+ enqueueInterceptAction(
+ pendingHandler: () => void | PromiseLike<unknown>
+ ): void {
+ this.#interceptHandlers.push(pendingHandler);
+ }
+
+ override async finalizeInterceptions(): Promise<void> {
+ await this.#interceptHandlers.reduce((promiseChain, interceptAction) => {
+ return promiseChain.then(interceptAction);
+ }, Promise.resolve());
+ const {action} = this.interceptResolutionState();
+ switch (action) {
+ case 'abort':
+ return await this.#abort(this.#abortErrorReason);
+ case 'respond':
+ if (this.#responseForRequest === null) {
+ throw new Error('Response is missing for the interception');
+ }
+ return await this.#respond(this.#responseForRequest);
+ case 'continue':
+ return await this.#continue(this.#continueRequestOverrides);
+ }
+ }
+
+ override resourceType(): ResourceType {
+ return this.#resourceType;
+ }
+
+ override method(): string {
+ return this.#method;
+ }
+
+ override postData(): string | undefined {
+ return this.#postData;
+ }
+
+ override hasPostData(): boolean {
+ return this.#hasPostData;
+ }
+
+ override async fetchPostData(): Promise<string | undefined> {
+ try {
+ const result = await this.#client.send('Network.getRequestPostData', {
+ requestId: this._requestId,
+ });
+ return result.postData;
+ } catch (err) {
+ debugError(err);
+ return;
+ }
+ }
+
+ override headers(): Record<string, string> {
+ return this.#headers;
+ }
+
+ override response(): CdpHTTPResponse | null {
+ return this._response;
+ }
+
+ override frame(): Frame | null {
+ return this.#frame;
+ }
+
+ override isNavigationRequest(): boolean {
+ return this.#isNavigationRequest;
+ }
+
+ override initiator(): Protocol.Network.Initiator | undefined {
+ return this.#initiator;
+ }
+
+ override redirectChain(): CdpHTTPRequest[] {
+ return this._redirectChain.slice();
+ }
+
+ override failure(): {errorText: string} | null {
+ if (!this._failureText) {
+ return null;
+ }
+ return {
+ errorText: this._failureText,
+ };
+ }
+
+ override async continue(
+ overrides: ContinueRequestOverrides = {},
+ priority?: number
+ ): Promise<void> {
+ // Request interception is not supported for data: urls.
+ if (this.#url.startsWith('data:')) {
+ return;
+ }
+ assert(this.#allowInterception, 'Request Interception is not enabled!');
+ assert(!this.#interceptionHandled, 'Request is already handled!');
+ if (priority === undefined) {
+ return await this.#continue(overrides);
+ }
+ this.#continueRequestOverrides = overrides;
+ if (
+ this.#interceptResolutionState.priority === undefined ||
+ priority > this.#interceptResolutionState.priority
+ ) {
+ this.#interceptResolutionState = {
+ action: InterceptResolutionAction.Continue,
+ priority,
+ };
+ return;
+ }
+ if (priority === this.#interceptResolutionState.priority) {
+ if (
+ this.#interceptResolutionState.action === 'abort' ||
+ this.#interceptResolutionState.action === 'respond'
+ ) {
+ return;
+ }
+ this.#interceptResolutionState.action =
+ InterceptResolutionAction.Continue;
+ }
+ return;
+ }
+
+ async #continue(overrides: ContinueRequestOverrides = {}): Promise<void> {
+ const {url, method, postData, headers} = overrides;
+ this.#interceptionHandled = true;
+
+ const postDataBinaryBase64 = postData
+ ? Buffer.from(postData).toString('base64')
+ : undefined;
+
+ if (this._interceptionId === undefined) {
+ throw new Error(
+ 'HTTPRequest is missing _interceptionId needed for Fetch.continueRequest'
+ );
+ }
+ await this.#client
+ .send('Fetch.continueRequest', {
+ requestId: this._interceptionId,
+ url,
+ method,
+ postData: postDataBinaryBase64,
+ headers: headers ? headersArray(headers) : undefined,
+ })
+ .catch(error => {
+ this.#interceptionHandled = false;
+ return handleError(error);
+ });
+ }
+
+ override async respond(
+ response: Partial<ResponseForRequest>,
+ priority?: number
+ ): Promise<void> {
+ // Mocking responses for dataURL requests is not currently supported.
+ if (this.#url.startsWith('data:')) {
+ return;
+ }
+ assert(this.#allowInterception, 'Request Interception is not enabled!');
+ assert(!this.#interceptionHandled, 'Request is already handled!');
+ if (priority === undefined) {
+ return await this.#respond(response);
+ }
+ this.#responseForRequest = response;
+ if (
+ this.#interceptResolutionState.priority === undefined ||
+ priority > this.#interceptResolutionState.priority
+ ) {
+ this.#interceptResolutionState = {
+ action: InterceptResolutionAction.Respond,
+ priority,
+ };
+ return;
+ }
+ if (priority === this.#interceptResolutionState.priority) {
+ if (this.#interceptResolutionState.action === 'abort') {
+ return;
+ }
+ this.#interceptResolutionState.action = InterceptResolutionAction.Respond;
+ }
+ }
+
+ async #respond(response: Partial<ResponseForRequest>): Promise<void> {
+ this.#interceptionHandled = true;
+
+ const responseBody: Buffer | null =
+ response.body && isString(response.body)
+ ? Buffer.from(response.body)
+ : (response.body as Buffer) || null;
+
+ const responseHeaders: Record<string, string | string[]> = {};
+ if (response.headers) {
+ for (const header of Object.keys(response.headers)) {
+ const value = response.headers[header];
+
+ responseHeaders[header.toLowerCase()] = Array.isArray(value)
+ ? value.map(item => {
+ return String(item);
+ })
+ : String(value);
+ }
+ }
+ if (response.contentType) {
+ responseHeaders['content-type'] = response.contentType;
+ }
+ if (responseBody && !('content-length' in responseHeaders)) {
+ responseHeaders['content-length'] = String(
+ Buffer.byteLength(responseBody)
+ );
+ }
+
+ const status = response.status || 200;
+ if (this._interceptionId === undefined) {
+ throw new Error(
+ 'HTTPRequest is missing _interceptionId needed for Fetch.fulfillRequest'
+ );
+ }
+ await this.#client
+ .send('Fetch.fulfillRequest', {
+ requestId: this._interceptionId,
+ responseCode: status,
+ responsePhrase: STATUS_TEXTS[status],
+ responseHeaders: headersArray(responseHeaders),
+ body: responseBody ? responseBody.toString('base64') : undefined,
+ })
+ .catch(error => {
+ this.#interceptionHandled = false;
+ return handleError(error);
+ });
+ }
+
+ override async abort(
+ errorCode: ErrorCode = 'failed',
+ priority?: number
+ ): Promise<void> {
+ // Request interception is not supported for data: urls.
+ if (this.#url.startsWith('data:')) {
+ return;
+ }
+ const errorReason = errorReasons[errorCode];
+ assert(errorReason, 'Unknown error code: ' + errorCode);
+ assert(this.#allowInterception, 'Request Interception is not enabled!');
+ assert(!this.#interceptionHandled, 'Request is already handled!');
+ if (priority === undefined) {
+ return await this.#abort(errorReason);
+ }
+ this.#abortErrorReason = errorReason;
+ if (
+ this.#interceptResolutionState.priority === undefined ||
+ priority >= this.#interceptResolutionState.priority
+ ) {
+ this.#interceptResolutionState = {
+ action: InterceptResolutionAction.Abort,
+ priority,
+ };
+ return;
+ }
+ }
+
+ async #abort(
+ errorReason: Protocol.Network.ErrorReason | null
+ ): Promise<void> {
+ this.#interceptionHandled = true;
+ if (this._interceptionId === undefined) {
+ throw new Error(
+ 'HTTPRequest is missing _interceptionId needed for Fetch.failRequest'
+ );
+ }
+ await this.#client
+ .send('Fetch.failRequest', {
+ requestId: this._interceptionId,
+ errorReason: errorReason || 'Failed',
+ })
+ .catch(handleError);
+ }
+}
+
+const errorReasons: Record<ErrorCode, Protocol.Network.ErrorReason> = {
+ aborted: 'Aborted',
+ accessdenied: 'AccessDenied',
+ addressunreachable: 'AddressUnreachable',
+ blockedbyclient: 'BlockedByClient',
+ blockedbyresponse: 'BlockedByResponse',
+ connectionaborted: 'ConnectionAborted',
+ connectionclosed: 'ConnectionClosed',
+ connectionfailed: 'ConnectionFailed',
+ connectionrefused: 'ConnectionRefused',
+ connectionreset: 'ConnectionReset',
+ internetdisconnected: 'InternetDisconnected',
+ namenotresolved: 'NameNotResolved',
+ timedout: 'TimedOut',
+ failed: 'Failed',
+} as const;
+
+async function handleError(error: ProtocolError) {
+ if (['Invalid header'].includes(error.originalMessage)) {
+ throw error;
+ }
+ // In certain cases, protocol will return error if the request was
+ // already canceled or the page was closed. We should tolerate these
+ // errors.
+ debugError(error);
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/HTTPResponse.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/HTTPResponse.ts
new file mode 100644
index 0000000000..2b2264ffd4
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/HTTPResponse.ts
@@ -0,0 +1,173 @@
+/**
+ * @license
+ * Copyright 2020 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import type {Protocol} from 'devtools-protocol';
+
+import type {CDPSession} from '../api/CDPSession.js';
+import type {Frame} from '../api/Frame.js';
+import {HTTPResponse, type RemoteAddress} from '../api/HTTPResponse.js';
+import {ProtocolError} from '../common/Errors.js';
+import {SecurityDetails} from '../common/SecurityDetails.js';
+import {Deferred} from '../util/Deferred.js';
+
+import type {CdpHTTPRequest} from './HTTPRequest.js';
+
+/**
+ * @internal
+ */
+export class CdpHTTPResponse extends HTTPResponse {
+ #client: CDPSession;
+ #request: CdpHTTPRequest;
+ #contentPromise: Promise<Buffer> | null = null;
+ #bodyLoadedDeferred = Deferred.create<void, Error>();
+ #remoteAddress: RemoteAddress;
+ #status: number;
+ #statusText: string;
+ #url: string;
+ #fromDiskCache: boolean;
+ #fromServiceWorker: boolean;
+ #headers: Record<string, string> = {};
+ #securityDetails: SecurityDetails | null;
+ #timing: Protocol.Network.ResourceTiming | null;
+
+ constructor(
+ client: CDPSession,
+ request: CdpHTTPRequest,
+ responsePayload: Protocol.Network.Response,
+ extraInfo: Protocol.Network.ResponseReceivedExtraInfoEvent | null
+ ) {
+ super();
+ this.#client = client;
+ this.#request = request;
+
+ this.#remoteAddress = {
+ ip: responsePayload.remoteIPAddress,
+ port: responsePayload.remotePort,
+ };
+ this.#statusText =
+ this.#parseStatusTextFromExtraInfo(extraInfo) ||
+ responsePayload.statusText;
+ this.#url = request.url();
+ this.#fromDiskCache = !!responsePayload.fromDiskCache;
+ this.#fromServiceWorker = !!responsePayload.fromServiceWorker;
+
+ this.#status = extraInfo ? extraInfo.statusCode : responsePayload.status;
+ const headers = extraInfo ? extraInfo.headers : responsePayload.headers;
+ for (const [key, value] of Object.entries(headers)) {
+ this.#headers[key.toLowerCase()] = value;
+ }
+
+ this.#securityDetails = responsePayload.securityDetails
+ ? new SecurityDetails(responsePayload.securityDetails)
+ : null;
+ this.#timing = responsePayload.timing || null;
+ }
+
+ #parseStatusTextFromExtraInfo(
+ extraInfo: Protocol.Network.ResponseReceivedExtraInfoEvent | null
+ ): string | undefined {
+ if (!extraInfo || !extraInfo.headersText) {
+ return;
+ }
+ const firstLine = extraInfo.headersText.split('\r', 1)[0];
+ if (!firstLine) {
+ return;
+ }
+ const match = firstLine.match(/[^ ]* [^ ]* (.*)/);
+ if (!match) {
+ return;
+ }
+ const statusText = match[1];
+ if (!statusText) {
+ return;
+ }
+ return statusText;
+ }
+
+ _resolveBody(err?: Error): void {
+ if (err) {
+ return this.#bodyLoadedDeferred.reject(err);
+ }
+ return this.#bodyLoadedDeferred.resolve();
+ }
+
+ override remoteAddress(): RemoteAddress {
+ return this.#remoteAddress;
+ }
+
+ override url(): string {
+ return this.#url;
+ }
+
+ override status(): number {
+ return this.#status;
+ }
+
+ override statusText(): string {
+ return this.#statusText;
+ }
+
+ override headers(): Record<string, string> {
+ return this.#headers;
+ }
+
+ override securityDetails(): SecurityDetails | null {
+ return this.#securityDetails;
+ }
+
+ override timing(): Protocol.Network.ResourceTiming | null {
+ return this.#timing;
+ }
+
+ override buffer(): Promise<Buffer> {
+ if (!this.#contentPromise) {
+ this.#contentPromise = this.#bodyLoadedDeferred
+ .valueOrThrow()
+ .then(async () => {
+ try {
+ const response = await this.#client.send(
+ 'Network.getResponseBody',
+ {
+ requestId: this.#request._requestId,
+ }
+ );
+ return Buffer.from(
+ response.body,
+ response.base64Encoded ? 'base64' : 'utf8'
+ );
+ } catch (error) {
+ if (
+ error instanceof ProtocolError &&
+ error.originalMessage ===
+ 'No resource with given identifier found'
+ ) {
+ throw new ProtocolError(
+ 'Could not load body for this request. This might happen if the request is a preflight request.'
+ );
+ }
+
+ throw error;
+ }
+ });
+ }
+ return this.#contentPromise;
+ }
+
+ override request(): CdpHTTPRequest {
+ return this.#request;
+ }
+
+ override fromCache(): boolean {
+ return this.#fromDiskCache || this.#request._fromMemoryCache;
+ }
+
+ override fromServiceWorker(): boolean {
+ return this.#fromServiceWorker;
+ }
+
+ override frame(): Frame | null {
+ return this.#request.frame();
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Input.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Input.ts
new file mode 100644
index 0000000000..9bfafddcf3
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Input.ts
@@ -0,0 +1,604 @@
+/**
+ * @license
+ * Copyright 2017 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {Protocol} from 'devtools-protocol';
+
+import type {CDPSession} from '../api/CDPSession.js';
+import type {Point} from '../api/ElementHandle.js';
+import {
+ Keyboard,
+ type KeyDownOptions,
+ type KeyPressOptions,
+ Mouse,
+ MouseButton,
+ type MouseClickOptions,
+ type MouseMoveOptions,
+ type MouseOptions,
+ type MouseWheelOptions,
+ Touchscreen,
+ type KeyboardTypeOptions,
+} from '../api/Input.js';
+import {
+ _keyDefinitions,
+ type KeyDefinition,
+ type KeyInput,
+} from '../common/USKeyboardLayout.js';
+import {assert} from '../util/assert.js';
+
+type KeyDescription = Required<
+ Pick<KeyDefinition, 'keyCode' | 'key' | 'text' | 'code' | 'location'>
+>;
+
+/**
+ * @internal
+ */
+export class CdpKeyboard extends Keyboard {
+ #client: CDPSession;
+ #pressedKeys = new Set<string>();
+
+ _modifiers = 0;
+
+ constructor(client: CDPSession) {
+ super();
+ this.#client = client;
+ }
+
+ updateClient(client: CDPSession): void {
+ this.#client = client;
+ }
+
+ override async down(
+ key: KeyInput,
+ options: Readonly<KeyDownOptions> = {
+ text: undefined,
+ commands: [],
+ }
+ ): Promise<void> {
+ const description = this.#keyDescriptionForString(key);
+
+ const autoRepeat = this.#pressedKeys.has(description.code);
+ this.#pressedKeys.add(description.code);
+ this._modifiers |= this.#modifierBit(description.key);
+
+ const text = options.text === undefined ? description.text : options.text;
+ await this.#client.send('Input.dispatchKeyEvent', {
+ type: text ? 'keyDown' : 'rawKeyDown',
+ modifiers: this._modifiers,
+ windowsVirtualKeyCode: description.keyCode,
+ code: description.code,
+ key: description.key,
+ text: text,
+ unmodifiedText: text,
+ autoRepeat,
+ location: description.location,
+ isKeypad: description.location === 3,
+ commands: options.commands,
+ });
+ }
+
+ #modifierBit(key: string): number {
+ if (key === 'Alt') {
+ return 1;
+ }
+ if (key === 'Control') {
+ return 2;
+ }
+ if (key === 'Meta') {
+ return 4;
+ }
+ if (key === 'Shift') {
+ return 8;
+ }
+ return 0;
+ }
+
+ #keyDescriptionForString(keyString: KeyInput): KeyDescription {
+ const shift = this._modifiers & 8;
+ const description = {
+ key: '',
+ keyCode: 0,
+ code: '',
+ text: '',
+ location: 0,
+ };
+
+ const definition = _keyDefinitions[keyString];
+ assert(definition, `Unknown key: "${keyString}"`);
+
+ if (definition.key) {
+ description.key = definition.key;
+ }
+ if (shift && definition.shiftKey) {
+ description.key = definition.shiftKey;
+ }
+
+ if (definition.keyCode) {
+ description.keyCode = definition.keyCode;
+ }
+ if (shift && definition.shiftKeyCode) {
+ description.keyCode = definition.shiftKeyCode;
+ }
+
+ if (definition.code) {
+ description.code = definition.code;
+ }
+
+ if (definition.location) {
+ description.location = definition.location;
+ }
+
+ if (description.key.length === 1) {
+ description.text = description.key;
+ }
+
+ if (definition.text) {
+ description.text = definition.text;
+ }
+ if (shift && definition.shiftText) {
+ description.text = definition.shiftText;
+ }
+
+ // if any modifiers besides shift are pressed, no text should be sent
+ if (this._modifiers & ~8) {
+ description.text = '';
+ }
+
+ return description;
+ }
+
+ override async up(key: KeyInput): Promise<void> {
+ const description = this.#keyDescriptionForString(key);
+
+ this._modifiers &= ~this.#modifierBit(description.key);
+ this.#pressedKeys.delete(description.code);
+ await this.#client.send('Input.dispatchKeyEvent', {
+ type: 'keyUp',
+ modifiers: this._modifiers,
+ key: description.key,
+ windowsVirtualKeyCode: description.keyCode,
+ code: description.code,
+ location: description.location,
+ });
+ }
+
+ override async sendCharacter(char: string): Promise<void> {
+ await this.#client.send('Input.insertText', {text: char});
+ }
+
+ private charIsKey(char: string): char is KeyInput {
+ return !!_keyDefinitions[char as KeyInput];
+ }
+
+ override async type(
+ text: string,
+ options: Readonly<KeyboardTypeOptions> = {}
+ ): Promise<void> {
+ const delay = options.delay || undefined;
+ for (const char of text) {
+ if (this.charIsKey(char)) {
+ await this.press(char, {delay});
+ } else {
+ if (delay) {
+ await new Promise(f => {
+ return setTimeout(f, delay);
+ });
+ }
+ await this.sendCharacter(char);
+ }
+ }
+ }
+
+ override async press(
+ key: KeyInput,
+ options: Readonly<KeyPressOptions> = {}
+ ): Promise<void> {
+ const {delay = null} = options;
+ await this.down(key, options);
+ if (delay) {
+ await new Promise(f => {
+ return setTimeout(f, options.delay);
+ });
+ }
+ await this.up(key);
+ }
+}
+
+/**
+ * This must follow {@link Protocol.Input.DispatchMouseEventRequest.buttons}.
+ */
+const enum MouseButtonFlag {
+ None = 0,
+ Left = 1,
+ Right = 1 << 1,
+ Middle = 1 << 2,
+ Back = 1 << 3,
+ Forward = 1 << 4,
+}
+
+const getFlag = (button: MouseButton): MouseButtonFlag => {
+ switch (button) {
+ case MouseButton.Left:
+ return MouseButtonFlag.Left;
+ case MouseButton.Right:
+ return MouseButtonFlag.Right;
+ case MouseButton.Middle:
+ return MouseButtonFlag.Middle;
+ case MouseButton.Back:
+ return MouseButtonFlag.Back;
+ case MouseButton.Forward:
+ return MouseButtonFlag.Forward;
+ }
+};
+
+/**
+ * This should match
+ * https://source.chromium.org/chromium/chromium/src/+/refs/heads/main:content/browser/renderer_host/input/web_input_event_builders_mac.mm;drc=a61b95c63b0b75c1cfe872d9c8cdf927c226046e;bpv=1;bpt=1;l=221.
+ */
+const getButtonFromPressedButtons = (
+ buttons: number
+): Protocol.Input.MouseButton => {
+ if (buttons & MouseButtonFlag.Left) {
+ return MouseButton.Left;
+ } else if (buttons & MouseButtonFlag.Right) {
+ return MouseButton.Right;
+ } else if (buttons & MouseButtonFlag.Middle) {
+ return MouseButton.Middle;
+ } else if (buttons & MouseButtonFlag.Back) {
+ return MouseButton.Back;
+ } else if (buttons & MouseButtonFlag.Forward) {
+ return MouseButton.Forward;
+ }
+ return 'none';
+};
+
+interface MouseState {
+ /**
+ * The current position of the mouse.
+ */
+ position: Point;
+ /**
+ * The buttons that are currently being pressed.
+ */
+ buttons: number;
+}
+
+/**
+ * @internal
+ */
+export class CdpMouse extends Mouse {
+ #client: CDPSession;
+ #keyboard: CdpKeyboard;
+
+ constructor(client: CDPSession, keyboard: CdpKeyboard) {
+ super();
+ this.#client = client;
+ this.#keyboard = keyboard;
+ }
+
+ updateClient(client: CDPSession): void {
+ this.#client = client;
+ }
+
+ #_state: Readonly<MouseState> = {
+ position: {x: 0, y: 0},
+ buttons: MouseButtonFlag.None,
+ };
+ get #state(): MouseState {
+ return Object.assign({...this.#_state}, ...this.#transactions);
+ }
+
+ // Transactions can run in parallel, so we store each of thme in this array.
+ #transactions: Array<Partial<MouseState>> = [];
+ #createTransaction(): {
+ update: (updates: Partial<MouseState>) => void;
+ commit: () => void;
+ rollback: () => void;
+ } {
+ const transaction: Partial<MouseState> = {};
+ this.#transactions.push(transaction);
+ const popTransaction = () => {
+ this.#transactions.splice(this.#transactions.indexOf(transaction), 1);
+ };
+ return {
+ update: (updates: Partial<MouseState>) => {
+ Object.assign(transaction, updates);
+ },
+ commit: () => {
+ this.#_state = {...this.#_state, ...transaction};
+ popTransaction();
+ },
+ rollback: popTransaction,
+ };
+ }
+
+ /**
+ * This is a shortcut for a typical update, commit/rollback lifecycle based on
+ * the error of the action.
+ */
+ async #withTransaction(
+ action: (update: (updates: Partial<MouseState>) => void) => Promise<unknown>
+ ): Promise<void> {
+ const {update, commit, rollback} = this.#createTransaction();
+ try {
+ await action(update);
+ commit();
+ } catch (error) {
+ rollback();
+ throw error;
+ }
+ }
+
+ override async reset(): Promise<void> {
+ const actions = [];
+ for (const [flag, button] of [
+ [MouseButtonFlag.Left, MouseButton.Left],
+ [MouseButtonFlag.Middle, MouseButton.Middle],
+ [MouseButtonFlag.Right, MouseButton.Right],
+ [MouseButtonFlag.Forward, MouseButton.Forward],
+ [MouseButtonFlag.Back, MouseButton.Back],
+ ] as const) {
+ if (this.#state.buttons & flag) {
+ actions.push(this.up({button: button}));
+ }
+ }
+ if (this.#state.position.x !== 0 || this.#state.position.y !== 0) {
+ actions.push(this.move(0, 0));
+ }
+ await Promise.all(actions);
+ }
+
+ override async move(
+ x: number,
+ y: number,
+ options: Readonly<MouseMoveOptions> = {}
+ ): Promise<void> {
+ const {steps = 1} = options;
+ const from = this.#state.position;
+ const to = {x, y};
+ for (let i = 1; i <= steps; i++) {
+ await this.#withTransaction(updateState => {
+ updateState({
+ position: {
+ x: from.x + (to.x - from.x) * (i / steps),
+ y: from.y + (to.y - from.y) * (i / steps),
+ },
+ });
+ const {buttons, position} = this.#state;
+ return this.#client.send('Input.dispatchMouseEvent', {
+ type: 'mouseMoved',
+ modifiers: this.#keyboard._modifiers,
+ buttons,
+ button: getButtonFromPressedButtons(buttons),
+ ...position,
+ });
+ });
+ }
+ }
+
+ override async down(options: Readonly<MouseOptions> = {}): Promise<void> {
+ const {button = MouseButton.Left, clickCount = 1} = options;
+ const flag = getFlag(button);
+ if (!flag) {
+ throw new Error(`Unsupported mouse button: ${button}`);
+ }
+ if (this.#state.buttons & flag) {
+ throw new Error(`'${button}' is already pressed.`);
+ }
+ await this.#withTransaction(updateState => {
+ updateState({
+ buttons: this.#state.buttons | flag,
+ });
+ const {buttons, position} = this.#state;
+ return this.#client.send('Input.dispatchMouseEvent', {
+ type: 'mousePressed',
+ modifiers: this.#keyboard._modifiers,
+ clickCount,
+ buttons,
+ button,
+ ...position,
+ });
+ });
+ }
+
+ override async up(options: Readonly<MouseOptions> = {}): Promise<void> {
+ const {button = MouseButton.Left, clickCount = 1} = options;
+ const flag = getFlag(button);
+ if (!flag) {
+ throw new Error(`Unsupported mouse button: ${button}`);
+ }
+ if (!(this.#state.buttons & flag)) {
+ throw new Error(`'${button}' is not pressed.`);
+ }
+ await this.#withTransaction(updateState => {
+ updateState({
+ buttons: this.#state.buttons & ~flag,
+ });
+ const {buttons, position} = this.#state;
+ return this.#client.send('Input.dispatchMouseEvent', {
+ type: 'mouseReleased',
+ modifiers: this.#keyboard._modifiers,
+ clickCount,
+ buttons,
+ button,
+ ...position,
+ });
+ });
+ }
+
+ override async click(
+ x: number,
+ y: number,
+ options: Readonly<MouseClickOptions> = {}
+ ): Promise<void> {
+ const {delay, count = 1, clickCount = count} = options;
+ if (count < 1) {
+ throw new Error('Click must occur a positive number of times.');
+ }
+ const actions: Array<Promise<void>> = [this.move(x, y)];
+ if (clickCount === count) {
+ for (let i = 1; i < count; ++i) {
+ actions.push(
+ this.down({...options, clickCount: i}),
+ this.up({...options, clickCount: i})
+ );
+ }
+ }
+ actions.push(this.down({...options, clickCount}));
+ if (typeof delay === 'number') {
+ await Promise.all(actions);
+ actions.length = 0;
+ await new Promise(resolve => {
+ setTimeout(resolve, delay);
+ });
+ }
+ actions.push(this.up({...options, clickCount}));
+ await Promise.all(actions);
+ }
+
+ override async wheel(
+ options: Readonly<MouseWheelOptions> = {}
+ ): Promise<void> {
+ const {deltaX = 0, deltaY = 0} = options;
+ const {position, buttons} = this.#state;
+ await this.#client.send('Input.dispatchMouseEvent', {
+ type: 'mouseWheel',
+ pointerType: 'mouse',
+ modifiers: this.#keyboard._modifiers,
+ deltaY,
+ deltaX,
+ buttons,
+ ...position,
+ });
+ }
+
+ override async drag(
+ start: Point,
+ target: Point
+ ): Promise<Protocol.Input.DragData> {
+ const promise = new Promise<Protocol.Input.DragData>(resolve => {
+ this.#client.once('Input.dragIntercepted', event => {
+ return resolve(event.data);
+ });
+ });
+ await this.move(start.x, start.y);
+ await this.down();
+ await this.move(target.x, target.y);
+ return await promise;
+ }
+
+ override async dragEnter(
+ target: Point,
+ data: Protocol.Input.DragData
+ ): Promise<void> {
+ await this.#client.send('Input.dispatchDragEvent', {
+ type: 'dragEnter',
+ x: target.x,
+ y: target.y,
+ modifiers: this.#keyboard._modifiers,
+ data,
+ });
+ }
+
+ override async dragOver(
+ target: Point,
+ data: Protocol.Input.DragData
+ ): Promise<void> {
+ await this.#client.send('Input.dispatchDragEvent', {
+ type: 'dragOver',
+ x: target.x,
+ y: target.y,
+ modifiers: this.#keyboard._modifiers,
+ data,
+ });
+ }
+
+ override async drop(
+ target: Point,
+ data: Protocol.Input.DragData
+ ): Promise<void> {
+ await this.#client.send('Input.dispatchDragEvent', {
+ type: 'drop',
+ x: target.x,
+ y: target.y,
+ modifiers: this.#keyboard._modifiers,
+ data,
+ });
+ }
+
+ override async dragAndDrop(
+ start: Point,
+ target: Point,
+ options: {delay?: number} = {}
+ ): Promise<void> {
+ const {delay = null} = options;
+ const data = await this.drag(start, target);
+ await this.dragEnter(target, data);
+ await this.dragOver(target, data);
+ if (delay) {
+ await new Promise(resolve => {
+ return setTimeout(resolve, delay);
+ });
+ }
+ await this.drop(target, data);
+ await this.up();
+ }
+}
+
+/**
+ * @internal
+ */
+export class CdpTouchscreen extends Touchscreen {
+ #client: CDPSession;
+ #keyboard: CdpKeyboard;
+
+ constructor(client: CDPSession, keyboard: CdpKeyboard) {
+ super();
+ this.#client = client;
+ this.#keyboard = keyboard;
+ }
+
+ updateClient(client: CDPSession): void {
+ this.#client = client;
+ }
+
+ override async touchStart(x: number, y: number): Promise<void> {
+ await this.#client.send('Input.dispatchTouchEvent', {
+ type: 'touchStart',
+ touchPoints: [
+ {
+ x: Math.round(x),
+ y: Math.round(y),
+ radiusX: 0.5,
+ radiusY: 0.5,
+ },
+ ],
+ modifiers: this.#keyboard._modifiers,
+ });
+ }
+
+ override async touchMove(x: number, y: number): Promise<void> {
+ await this.#client.send('Input.dispatchTouchEvent', {
+ type: 'touchMove',
+ touchPoints: [
+ {
+ x: Math.round(x),
+ y: Math.round(y),
+ radiusX: 0.5,
+ radiusY: 0.5,
+ },
+ ],
+ modifiers: this.#keyboard._modifiers,
+ });
+ }
+
+ override async touchEnd(): Promise<void> {
+ await this.#client.send('Input.dispatchTouchEvent', {
+ type: 'touchEnd',
+ touchPoints: [],
+ modifiers: this.#keyboard._modifiers,
+ });
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/IsolatedWorld.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/IsolatedWorld.ts
new file mode 100644
index 0000000000..5846ef3652
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/IsolatedWorld.ts
@@ -0,0 +1,273 @@
+/**
+ * @license
+ * Copyright 2019 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {Protocol} from 'devtools-protocol';
+
+import type {CDPSession} from '../api/CDPSession.js';
+import type {JSHandle} from '../api/JSHandle.js';
+import {Realm} from '../api/Realm.js';
+import type {TimeoutSettings} from '../common/TimeoutSettings.js';
+import type {BindingPayload, EvaluateFunc, HandleFor} from '../common/types.js';
+import {debugError, withSourcePuppeteerURLIfNone} from '../common/util.js';
+import {Deferred} from '../util/Deferred.js';
+import {disposeSymbol} from '../util/disposable.js';
+import {Mutex} from '../util/Mutex.js';
+
+import type {Binding} from './Binding.js';
+import {ExecutionContext, createCdpHandle} from './ExecutionContext.js';
+import type {CdpFrame} from './Frame.js';
+import type {MAIN_WORLD, PUPPETEER_WORLD} from './IsolatedWorlds.js';
+import {addPageBinding} from './utils.js';
+import type {CdpWebWorker} from './WebWorker.js';
+
+/**
+ * @internal
+ */
+export interface PageBinding {
+ name: string;
+ pptrFunction: Function;
+}
+
+/**
+ * @internal
+ */
+export interface IsolatedWorldChart {
+ [key: string]: IsolatedWorld;
+ [MAIN_WORLD]: IsolatedWorld;
+ [PUPPETEER_WORLD]: IsolatedWorld;
+}
+
+/**
+ * @internal
+ */
+export class IsolatedWorld extends Realm {
+ #context = Deferred.create<ExecutionContext>();
+
+ // Set of bindings that have been registered in the current context.
+ #contextBindings = new Set<string>();
+
+ // Contains mapping from functions that should be bound to Puppeteer functions.
+ #bindings = new Map<string, Binding>();
+
+ get _bindings(): Map<string, Binding> {
+ return this.#bindings;
+ }
+
+ readonly #frameOrWorker: CdpFrame | CdpWebWorker;
+
+ constructor(
+ frameOrWorker: CdpFrame | CdpWebWorker,
+ timeoutSettings: TimeoutSettings
+ ) {
+ super(timeoutSettings);
+ this.#frameOrWorker = frameOrWorker;
+ this.frameUpdated();
+ }
+
+ get environment(): CdpFrame | CdpWebWorker {
+ return this.#frameOrWorker;
+ }
+
+ frameUpdated(): void {
+ this.client.on('Runtime.bindingCalled', this.#onBindingCalled);
+ }
+
+ get client(): CDPSession {
+ return this.#frameOrWorker.client;
+ }
+
+ clearContext(): void {
+ // The message has to match the CDP message expected by the WaitTask class.
+ this.#context?.reject(new Error('Execution context was destroyed'));
+ this.#context = Deferred.create();
+ if ('clearDocumentHandle' in this.#frameOrWorker) {
+ this.#frameOrWorker.clearDocumentHandle();
+ }
+ }
+
+ setContext(context: ExecutionContext): void {
+ this.#contextBindings.clear();
+ this.#context.resolve(context);
+ void this.taskManager.rerunAll();
+ }
+
+ hasContext(): boolean {
+ return this.#context.resolved();
+ }
+
+ #executionContext(): Promise<ExecutionContext> {
+ if (this.disposed) {
+ throw new Error(
+ `Execution context is not available in detached frame "${this.environment.url()}" (are you trying to evaluate?)`
+ );
+ }
+ if (this.#context === null) {
+ throw new Error(`Execution content promise is missing`);
+ }
+ return this.#context.valueOrThrow();
+ }
+
+ async evaluateHandle<
+ Params extends unknown[],
+ Func extends EvaluateFunc<Params> = EvaluateFunc<Params>,
+ >(
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<HandleFor<Awaited<ReturnType<Func>>>> {
+ pageFunction = withSourcePuppeteerURLIfNone(
+ this.evaluateHandle.name,
+ pageFunction
+ );
+ const context = await this.#executionContext();
+ return await context.evaluateHandle(pageFunction, ...args);
+ }
+
+ async evaluate<
+ Params extends unknown[],
+ Func extends EvaluateFunc<Params> = EvaluateFunc<Params>,
+ >(
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<Awaited<ReturnType<Func>>> {
+ pageFunction = withSourcePuppeteerURLIfNone(
+ this.evaluate.name,
+ pageFunction
+ );
+ let context = this.#context.value();
+ if (!context || !(context instanceof ExecutionContext)) {
+ context = await this.#executionContext();
+ }
+ return await context.evaluate(pageFunction, ...args);
+ }
+
+ // If multiple waitFor are set up asynchronously, we need to wait for the
+ // first one to set up the binding in the page before running the others.
+ #mutex = new Mutex();
+ async _addBindingToContext(
+ context: ExecutionContext,
+ name: string
+ ): Promise<void> {
+ if (this.#contextBindings.has(name)) {
+ return;
+ }
+
+ using _ = await this.#mutex.acquire();
+ try {
+ await context._client.send(
+ 'Runtime.addBinding',
+ context._contextName
+ ? {
+ name,
+ executionContextName: context._contextName,
+ }
+ : {
+ name,
+ executionContextId: context._contextId,
+ }
+ );
+
+ await context.evaluate(addPageBinding, 'internal', name);
+
+ this.#contextBindings.add(name);
+ } catch (error) {
+ // We could have tried to evaluate in a context which was already
+ // destroyed. This happens, for example, if the page is navigated while
+ // we are trying to add the binding
+ if (error instanceof Error) {
+ // Destroyed context.
+ if (error.message.includes('Execution context was destroyed')) {
+ return;
+ }
+ // Missing context.
+ if (error.message.includes('Cannot find context with specified id')) {
+ return;
+ }
+ }
+
+ debugError(error);
+ }
+ }
+
+ #onBindingCalled = async (
+ event: Protocol.Runtime.BindingCalledEvent
+ ): Promise<void> => {
+ let payload: BindingPayload;
+ try {
+ payload = JSON.parse(event.payload);
+ } catch {
+ // The binding was either called by something in the page or it was
+ // called before our wrapper was initialized.
+ return;
+ }
+ const {type, name, seq, args, isTrivial} = payload;
+ if (type !== 'internal') {
+ return;
+ }
+ if (!this.#contextBindings.has(name)) {
+ return;
+ }
+
+ try {
+ const context = await this.#context.valueOrThrow();
+ if (event.executionContextId !== context._contextId) {
+ return;
+ }
+
+ const binding = this._bindings.get(name);
+ await binding?.run(context, seq, args, isTrivial);
+ } catch (err) {
+ debugError(err);
+ }
+ };
+
+ override async adoptBackendNode(
+ backendNodeId?: Protocol.DOM.BackendNodeId
+ ): Promise<JSHandle<Node>> {
+ const executionContext = await this.#executionContext();
+ const {object} = await this.client.send('DOM.resolveNode', {
+ backendNodeId: backendNodeId,
+ executionContextId: executionContext._contextId,
+ });
+ return createCdpHandle(this, object) as JSHandle<Node>;
+ }
+
+ async adoptHandle<T extends JSHandle<Node>>(handle: T): Promise<T> {
+ if (handle.realm === this) {
+ // If the context has already adopted this handle, clone it so downstream
+ // disposal doesn't become an issue.
+ return (await handle.evaluateHandle(value => {
+ return value;
+ })) as unknown as T;
+ }
+ const nodeInfo = await this.client.send('DOM.describeNode', {
+ objectId: handle.id,
+ });
+ return (await this.adoptBackendNode(nodeInfo.node.backendNodeId)) as T;
+ }
+
+ async transferHandle<T extends JSHandle<Node>>(handle: T): Promise<T> {
+ if (handle.realm === this) {
+ return handle;
+ }
+ // Implies it's a primitive value, probably.
+ if (handle.remoteObject().objectId === undefined) {
+ return handle;
+ }
+ const info = await this.client.send('DOM.describeNode', {
+ objectId: handle.remoteObject().objectId,
+ });
+ const newHandle = (await this.adoptBackendNode(
+ info.node.backendNodeId
+ )) as T;
+ await handle.dispose();
+ return newHandle;
+ }
+
+ [disposeSymbol](): void {
+ super[disposeSymbol]();
+ this.client.off('Runtime.bindingCalled', this.#onBindingCalled);
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/IsolatedWorlds.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/IsolatedWorlds.ts
new file mode 100644
index 0000000000..ddb6c2381d
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/IsolatedWorlds.ts
@@ -0,0 +1,20 @@
+/**
+ * @license
+ * Copyright 2022 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * A unique key for {@link IsolatedWorldChart} to denote the default world.
+ * Execution contexts are automatically created in the default world.
+ *
+ * @internal
+ */
+export const MAIN_WORLD = Symbol('mainWorld');
+/**
+ * A unique key for {@link IsolatedWorldChart} to denote the puppeteer world.
+ * This world contains all puppeteer-internal bindings/code.
+ *
+ * @internal
+ */
+export const PUPPETEER_WORLD = Symbol('puppeteerWorld');
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/JSHandle.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/JSHandle.ts
new file mode 100644
index 0000000000..bba5f96b5d
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/JSHandle.ts
@@ -0,0 +1,109 @@
+/**
+ * @license
+ * Copyright 2019 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {Protocol} from 'devtools-protocol';
+
+import type {CDPSession} from '../api/CDPSession.js';
+import {JSHandle} from '../api/JSHandle.js';
+import {debugError} from '../common/util.js';
+
+import type {CdpElementHandle} from './ElementHandle.js';
+import type {IsolatedWorld} from './IsolatedWorld.js';
+import {valueFromRemoteObject} from './utils.js';
+
+/**
+ * @internal
+ */
+export class CdpJSHandle<T = unknown> extends JSHandle<T> {
+ #disposed = false;
+ readonly #remoteObject: Protocol.Runtime.RemoteObject;
+ readonly #world: IsolatedWorld;
+
+ constructor(
+ world: IsolatedWorld,
+ remoteObject: Protocol.Runtime.RemoteObject
+ ) {
+ super();
+ this.#world = world;
+ this.#remoteObject = remoteObject;
+ }
+
+ override get disposed(): boolean {
+ return this.#disposed;
+ }
+
+ override get realm(): IsolatedWorld {
+ return this.#world;
+ }
+
+ get client(): CDPSession {
+ return this.realm.environment.client;
+ }
+
+ override async jsonValue(): Promise<T> {
+ if (!this.#remoteObject.objectId) {
+ return valueFromRemoteObject(this.#remoteObject);
+ }
+ const value = await this.evaluate(object => {
+ return object;
+ });
+ if (value === undefined) {
+ throw new Error('Could not serialize referenced object');
+ }
+ return value;
+ }
+
+ /**
+ * Either `null` or the handle itself if the handle is an
+ * instance of {@link ElementHandle}.
+ */
+ override asElement(): CdpElementHandle<Node> | null {
+ return null;
+ }
+
+ override async dispose(): Promise<void> {
+ if (this.#disposed) {
+ return;
+ }
+ this.#disposed = true;
+ await releaseObject(this.client, this.#remoteObject);
+ }
+
+ override toString(): string {
+ if (!this.#remoteObject.objectId) {
+ return 'JSHandle:' + valueFromRemoteObject(this.#remoteObject);
+ }
+ const type = this.#remoteObject.subtype || this.#remoteObject.type;
+ return 'JSHandle@' + type;
+ }
+
+ override get id(): string | undefined {
+ return this.#remoteObject.objectId;
+ }
+
+ override remoteObject(): Protocol.Runtime.RemoteObject {
+ return this.#remoteObject;
+ }
+}
+
+/**
+ * @internal
+ */
+export async function releaseObject(
+ client: CDPSession,
+ remoteObject: Protocol.Runtime.RemoteObject
+): Promise<void> {
+ if (!remoteObject.objectId) {
+ return;
+ }
+ await client
+ .send('Runtime.releaseObject', {objectId: remoteObject.objectId})
+ .catch(error => {
+ // Exceptions might happen in case of a page been navigated or closed.
+ // Swallow these since they are harmless and we don't leak anything in this case.
+ debugError(error);
+ });
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/LifecycleWatcher.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/LifecycleWatcher.ts
new file mode 100644
index 0000000000..a4f5aaa468
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/LifecycleWatcher.ts
@@ -0,0 +1,298 @@
+/**
+ * @license
+ * Copyright 2019 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type Protocol from 'devtools-protocol';
+
+import {type Frame, FrameEvent} from '../api/Frame.js';
+import type {HTTPRequest} from '../api/HTTPRequest.js';
+import type {HTTPResponse} from '../api/HTTPResponse.js';
+import type {TimeoutError} from '../common/Errors.js';
+import {EventSubscription} from '../common/EventEmitter.js';
+import {NetworkManagerEvent} from '../common/NetworkManagerEvents.js';
+import {assert} from '../util/assert.js';
+import {Deferred} from '../util/Deferred.js';
+import {DisposableStack} from '../util/disposable.js';
+
+import type {CdpFrame} from './Frame.js';
+import {FrameManagerEvent} from './FrameManagerEvents.js';
+import type {NetworkManager} from './NetworkManager.js';
+
+/**
+ * @public
+ */
+export type PuppeteerLifeCycleEvent =
+ /**
+ * Waits for the 'load' event.
+ */
+ | 'load'
+ /**
+ * Waits for the 'DOMContentLoaded' event.
+ */
+ | 'domcontentloaded'
+ /**
+ * Waits till there are no more than 0 network connections for at least `500`
+ * ms.
+ */
+ | 'networkidle0'
+ /**
+ * Waits till there are no more than 2 network connections for at least `500`
+ * ms.
+ */
+ | 'networkidle2';
+
+/**
+ * @public
+ */
+export type ProtocolLifeCycleEvent =
+ | 'load'
+ | 'DOMContentLoaded'
+ | 'networkIdle'
+ | 'networkAlmostIdle';
+
+const puppeteerToProtocolLifecycle = new Map<
+ PuppeteerLifeCycleEvent,
+ ProtocolLifeCycleEvent
+>([
+ ['load', 'load'],
+ ['domcontentloaded', 'DOMContentLoaded'],
+ ['networkidle0', 'networkIdle'],
+ ['networkidle2', 'networkAlmostIdle'],
+]);
+
+/**
+ * @internal
+ */
+export class LifecycleWatcher {
+ #expectedLifecycle: ProtocolLifeCycleEvent[];
+ #frame: CdpFrame;
+ #timeout: number;
+ #navigationRequest: HTTPRequest | null = null;
+ #subscriptions = new DisposableStack();
+ #initialLoaderId: string;
+
+ #terminationDeferred: Deferred<Error>;
+ #sameDocumentNavigationDeferred = Deferred.create<undefined>();
+ #lifecycleDeferred = Deferred.create<void>();
+ #newDocumentNavigationDeferred = Deferred.create<undefined>();
+
+ #hasSameDocumentNavigation?: boolean;
+ #swapped?: boolean;
+
+ #navigationResponseReceived?: Deferred<void>;
+
+ constructor(
+ networkManager: NetworkManager,
+ frame: CdpFrame,
+ waitUntil: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[],
+ timeout: number
+ ) {
+ if (Array.isArray(waitUntil)) {
+ waitUntil = waitUntil.slice();
+ } else if (typeof waitUntil === 'string') {
+ waitUntil = [waitUntil];
+ }
+ this.#initialLoaderId = frame._loaderId;
+ this.#expectedLifecycle = waitUntil.map(value => {
+ const protocolEvent = puppeteerToProtocolLifecycle.get(value);
+ assert(protocolEvent, 'Unknown value for options.waitUntil: ' + value);
+ return protocolEvent as ProtocolLifeCycleEvent;
+ });
+
+ this.#frame = frame;
+ this.#timeout = timeout;
+ this.#subscriptions.use(
+ // Revert if TODO #1 is done
+ new EventSubscription(
+ frame._frameManager,
+ FrameManagerEvent.LifecycleEvent,
+ this.#checkLifecycleComplete.bind(this)
+ )
+ );
+ this.#subscriptions.use(
+ new EventSubscription(
+ frame,
+ FrameEvent.FrameNavigatedWithinDocument,
+ this.#navigatedWithinDocument.bind(this)
+ )
+ );
+ this.#subscriptions.use(
+ new EventSubscription(
+ frame,
+ FrameEvent.FrameNavigated,
+ this.#navigated.bind(this)
+ )
+ );
+ this.#subscriptions.use(
+ new EventSubscription(
+ frame,
+ FrameEvent.FrameSwapped,
+ this.#frameSwapped.bind(this)
+ )
+ );
+ this.#subscriptions.use(
+ new EventSubscription(
+ frame,
+ FrameEvent.FrameSwappedByActivation,
+ this.#frameSwapped.bind(this)
+ )
+ );
+ this.#subscriptions.use(
+ new EventSubscription(
+ frame,
+ FrameEvent.FrameDetached,
+ this.#onFrameDetached.bind(this)
+ )
+ );
+ this.#subscriptions.use(
+ new EventSubscription(
+ networkManager,
+ NetworkManagerEvent.Request,
+ this.#onRequest.bind(this)
+ )
+ );
+ this.#subscriptions.use(
+ new EventSubscription(
+ networkManager,
+ NetworkManagerEvent.Response,
+ this.#onResponse.bind(this)
+ )
+ );
+ this.#subscriptions.use(
+ new EventSubscription(
+ networkManager,
+ NetworkManagerEvent.RequestFailed,
+ this.#onRequestFailed.bind(this)
+ )
+ );
+ this.#terminationDeferred = Deferred.create<Error>({
+ timeout: this.#timeout,
+ message: `Navigation timeout of ${this.#timeout} ms exceeded`,
+ });
+
+ this.#checkLifecycleComplete();
+ }
+
+ #onRequest(request: HTTPRequest): void {
+ if (request.frame() !== this.#frame || !request.isNavigationRequest()) {
+ return;
+ }
+ this.#navigationRequest = request;
+ // Resolve previous navigation response in case there are multiple
+ // navigation requests reported by the backend. This generally should not
+ // happen by it looks like it's possible.
+ this.#navigationResponseReceived?.resolve();
+ this.#navigationResponseReceived = Deferred.create();
+ if (request.response() !== null) {
+ this.#navigationResponseReceived?.resolve();
+ }
+ }
+
+ #onRequestFailed(request: HTTPRequest): void {
+ if (this.#navigationRequest?._requestId !== request._requestId) {
+ return;
+ }
+ this.#navigationResponseReceived?.resolve();
+ }
+
+ #onResponse(response: HTTPResponse): void {
+ if (this.#navigationRequest?._requestId !== response.request()._requestId) {
+ return;
+ }
+ this.#navigationResponseReceived?.resolve();
+ }
+
+ #onFrameDetached(frame: Frame): void {
+ if (this.#frame === frame) {
+ this.#terminationDeferred.resolve(
+ new Error('Navigating frame was detached')
+ );
+ return;
+ }
+ this.#checkLifecycleComplete();
+ }
+
+ async navigationResponse(): Promise<HTTPResponse | null> {
+ // Continue with a possibly null response.
+ await this.#navigationResponseReceived?.valueOrThrow();
+ return this.#navigationRequest ? this.#navigationRequest.response() : null;
+ }
+
+ sameDocumentNavigationPromise(): Promise<Error | undefined> {
+ return this.#sameDocumentNavigationDeferred.valueOrThrow();
+ }
+
+ newDocumentNavigationPromise(): Promise<Error | undefined> {
+ return this.#newDocumentNavigationDeferred.valueOrThrow();
+ }
+
+ lifecyclePromise(): Promise<void> {
+ return this.#lifecycleDeferred.valueOrThrow();
+ }
+
+ terminationPromise(): Promise<Error | TimeoutError | undefined> {
+ return this.#terminationDeferred.valueOrThrow();
+ }
+
+ #navigatedWithinDocument(): void {
+ this.#hasSameDocumentNavigation = true;
+ this.#checkLifecycleComplete();
+ }
+
+ #navigated(navigationType: Protocol.Page.NavigationType): void {
+ if (navigationType === 'BackForwardCacheRestore') {
+ return this.#frameSwapped();
+ }
+ this.#checkLifecycleComplete();
+ }
+
+ #frameSwapped(): void {
+ this.#swapped = true;
+ this.#checkLifecycleComplete();
+ }
+
+ #checkLifecycleComplete(): void {
+ // We expect navigation to commit.
+ if (!checkLifecycle(this.#frame, this.#expectedLifecycle)) {
+ return;
+ }
+ this.#lifecycleDeferred.resolve();
+ if (this.#hasSameDocumentNavigation) {
+ this.#sameDocumentNavigationDeferred.resolve(undefined);
+ }
+ if (this.#swapped || this.#frame._loaderId !== this.#initialLoaderId) {
+ this.#newDocumentNavigationDeferred.resolve(undefined);
+ }
+
+ function checkLifecycle(
+ frame: CdpFrame,
+ expectedLifecycle: ProtocolLifeCycleEvent[]
+ ): boolean {
+ for (const event of expectedLifecycle) {
+ if (!frame._lifecycleEvents.has(event)) {
+ return false;
+ }
+ }
+ // TODO(#1): Its possible we don't need this check
+ // CDP provided the correct order for Loading Events
+ // And NetworkIdle is a global state
+ // Consider removing
+ for (const child of frame.childFrames()) {
+ if (
+ child._hasStartedLoading &&
+ !checkLifecycle(child, expectedLifecycle)
+ ) {
+ return false;
+ }
+ }
+ return true;
+ }
+ }
+
+ dispose(): void {
+ this.#subscriptions.dispose();
+ this.#terminationDeferred.resolve(new Error('LifecycleWatcher disposed'));
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/NetworkEventManager.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/NetworkEventManager.ts
new file mode 100644
index 0000000000..2aadd21d25
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/NetworkEventManager.ts
@@ -0,0 +1,217 @@
+/**
+ * @license
+ * Copyright 2022 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {Protocol} from 'devtools-protocol';
+
+import type {CdpHTTPRequest} from './HTTPRequest.js';
+
+/**
+ * @internal
+ */
+export interface QueuedEventGroup {
+ responseReceivedEvent: Protocol.Network.ResponseReceivedEvent;
+ loadingFinishedEvent?: Protocol.Network.LoadingFinishedEvent;
+ loadingFailedEvent?: Protocol.Network.LoadingFailedEvent;
+}
+
+/**
+ * @internal
+ */
+export type FetchRequestId = string;
+
+/**
+ * @internal
+ */
+export interface RedirectInfo {
+ event: Protocol.Network.RequestWillBeSentEvent;
+ fetchRequestId?: FetchRequestId;
+}
+type RedirectInfoList = RedirectInfo[];
+
+/**
+ * @internal
+ */
+export type NetworkRequestId = string;
+
+/**
+ * Helper class to track network events by request ID
+ *
+ * @internal
+ */
+export class NetworkEventManager {
+ /**
+ * There are four possible orders of events:
+ * A. `_onRequestWillBeSent`
+ * B. `_onRequestWillBeSent`, `_onRequestPaused`
+ * C. `_onRequestPaused`, `_onRequestWillBeSent`
+ * D. `_onRequestPaused`, `_onRequestWillBeSent`, `_onRequestPaused`,
+ * `_onRequestWillBeSent`, `_onRequestPaused`, `_onRequestPaused`
+ * (see crbug.com/1196004)
+ *
+ * For `_onRequest` we need the event from `_onRequestWillBeSent` and
+ * optionally the `interceptionId` from `_onRequestPaused`.
+ *
+ * If request interception is disabled, call `_onRequest` once per call to
+ * `_onRequestWillBeSent`.
+ * If request interception is enabled, call `_onRequest` once per call to
+ * `_onRequestPaused` (once per `interceptionId`).
+ *
+ * Events are stored to allow for subsequent events to call `_onRequest`.
+ *
+ * Note that (chains of) redirect requests have the same `requestId` (!) as
+ * the original request. We have to anticipate series of events like these:
+ * A. `_onRequestWillBeSent`,
+ * `_onRequestWillBeSent`, ...
+ * B. `_onRequestWillBeSent`, `_onRequestPaused`,
+ * `_onRequestWillBeSent`, `_onRequestPaused`, ...
+ * C. `_onRequestWillBeSent`, `_onRequestPaused`,
+ * `_onRequestPaused`, `_onRequestWillBeSent`, ...
+ * D. `_onRequestPaused`, `_onRequestWillBeSent`,
+ * `_onRequestPaused`, `_onRequestWillBeSent`, `_onRequestPaused`,
+ * `_onRequestWillBeSent`, `_onRequestPaused`, `_onRequestPaused`, ...
+ * (see crbug.com/1196004)
+ */
+ #requestWillBeSentMap = new Map<
+ NetworkRequestId,
+ Protocol.Network.RequestWillBeSentEvent
+ >();
+ #requestPausedMap = new Map<
+ NetworkRequestId,
+ Protocol.Fetch.RequestPausedEvent
+ >();
+ #httpRequestsMap = new Map<NetworkRequestId, CdpHTTPRequest>();
+
+ /*
+ * The below maps are used to reconcile Network.responseReceivedExtraInfo
+ * events with their corresponding request. Each response and redirect
+ * response gets an ExtraInfo event, and we don't know which will come first.
+ * This means that we have to store a Response or an ExtraInfo for each
+ * response, and emit the event when we get both of them. In addition, to
+ * handle redirects, we have to make them Arrays to represent the chain of
+ * events.
+ */
+ #responseReceivedExtraInfoMap = new Map<
+ NetworkRequestId,
+ Protocol.Network.ResponseReceivedExtraInfoEvent[]
+ >();
+ #queuedRedirectInfoMap = new Map<NetworkRequestId, RedirectInfoList>();
+ #queuedEventGroupMap = new Map<NetworkRequestId, QueuedEventGroup>();
+
+ forget(networkRequestId: NetworkRequestId): void {
+ this.#requestWillBeSentMap.delete(networkRequestId);
+ this.#requestPausedMap.delete(networkRequestId);
+ this.#queuedEventGroupMap.delete(networkRequestId);
+ this.#queuedRedirectInfoMap.delete(networkRequestId);
+ this.#responseReceivedExtraInfoMap.delete(networkRequestId);
+ }
+
+ responseExtraInfo(
+ networkRequestId: NetworkRequestId
+ ): Protocol.Network.ResponseReceivedExtraInfoEvent[] {
+ if (!this.#responseReceivedExtraInfoMap.has(networkRequestId)) {
+ this.#responseReceivedExtraInfoMap.set(networkRequestId, []);
+ }
+ return this.#responseReceivedExtraInfoMap.get(
+ networkRequestId
+ ) as Protocol.Network.ResponseReceivedExtraInfoEvent[];
+ }
+
+ private queuedRedirectInfo(fetchRequestId: FetchRequestId): RedirectInfoList {
+ if (!this.#queuedRedirectInfoMap.has(fetchRequestId)) {
+ this.#queuedRedirectInfoMap.set(fetchRequestId, []);
+ }
+ return this.#queuedRedirectInfoMap.get(fetchRequestId) as RedirectInfoList;
+ }
+
+ queueRedirectInfo(
+ fetchRequestId: FetchRequestId,
+ redirectInfo: RedirectInfo
+ ): void {
+ this.queuedRedirectInfo(fetchRequestId).push(redirectInfo);
+ }
+
+ takeQueuedRedirectInfo(
+ fetchRequestId: FetchRequestId
+ ): RedirectInfo | undefined {
+ return this.queuedRedirectInfo(fetchRequestId).shift();
+ }
+
+ inFlightRequestsCount(): number {
+ let inFlightRequestCounter = 0;
+ for (const request of this.#httpRequestsMap.values()) {
+ if (!request.response()) {
+ inFlightRequestCounter++;
+ }
+ }
+ return inFlightRequestCounter;
+ }
+
+ storeRequestWillBeSent(
+ networkRequestId: NetworkRequestId,
+ event: Protocol.Network.RequestWillBeSentEvent
+ ): void {
+ this.#requestWillBeSentMap.set(networkRequestId, event);
+ }
+
+ getRequestWillBeSent(
+ networkRequestId: NetworkRequestId
+ ): Protocol.Network.RequestWillBeSentEvent | undefined {
+ return this.#requestWillBeSentMap.get(networkRequestId);
+ }
+
+ forgetRequestWillBeSent(networkRequestId: NetworkRequestId): void {
+ this.#requestWillBeSentMap.delete(networkRequestId);
+ }
+
+ getRequestPaused(
+ networkRequestId: NetworkRequestId
+ ): Protocol.Fetch.RequestPausedEvent | undefined {
+ return this.#requestPausedMap.get(networkRequestId);
+ }
+
+ forgetRequestPaused(networkRequestId: NetworkRequestId): void {
+ this.#requestPausedMap.delete(networkRequestId);
+ }
+
+ storeRequestPaused(
+ networkRequestId: NetworkRequestId,
+ event: Protocol.Fetch.RequestPausedEvent
+ ): void {
+ this.#requestPausedMap.set(networkRequestId, event);
+ }
+
+ getRequest(networkRequestId: NetworkRequestId): CdpHTTPRequest | undefined {
+ return this.#httpRequestsMap.get(networkRequestId);
+ }
+
+ storeRequest(
+ networkRequestId: NetworkRequestId,
+ request: CdpHTTPRequest
+ ): void {
+ this.#httpRequestsMap.set(networkRequestId, request);
+ }
+
+ forgetRequest(networkRequestId: NetworkRequestId): void {
+ this.#httpRequestsMap.delete(networkRequestId);
+ }
+
+ getQueuedEventGroup(
+ networkRequestId: NetworkRequestId
+ ): QueuedEventGroup | undefined {
+ return this.#queuedEventGroupMap.get(networkRequestId);
+ }
+
+ queueEventGroup(
+ networkRequestId: NetworkRequestId,
+ event: QueuedEventGroup
+ ): void {
+ this.#queuedEventGroupMap.set(networkRequestId, event);
+ }
+
+ forgetQueuedEventGroup(networkRequestId: NetworkRequestId): void {
+ this.#queuedEventGroupMap.delete(networkRequestId);
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/NetworkManager.test.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/NetworkManager.test.ts
new file mode 100644
index 0000000000..c3e9a8f609
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/NetworkManager.test.ts
@@ -0,0 +1,1531 @@
+/**
+ * @license
+ * Copyright 2020 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {describe, it} from 'node:test';
+
+import expect from 'expect';
+
+import type {CDPSessionEvents} from '../api/CDPSession.js';
+import type {HTTPRequest} from '../api/HTTPRequest.js';
+import type {HTTPResponse} from '../api/HTTPResponse.js';
+import {EventEmitter} from '../common/EventEmitter.js';
+import {NetworkManagerEvent} from '../common/NetworkManagerEvents.js';
+
+import type {CdpFrame} from './Frame.js';
+import {NetworkManager} from './NetworkManager.js';
+
+// TODO: develop a helper to generate fake network events for attributes that
+// are not relevant for the network manager to make tests shorter.
+
+class MockCDPSession extends EventEmitter<CDPSessionEvents> {
+ async send(): Promise<any> {}
+ connection() {
+ return undefined;
+ }
+ async detach() {}
+ id() {
+ return '1';
+ }
+ parentSession() {
+ return undefined;
+ }
+}
+
+describe('NetworkManager', () => {
+ it('should process extra info on multiple redirects', async () => {
+ const mockCDPSession = new MockCDPSession();
+ const manager = new NetworkManager(true, {
+ frame(): CdpFrame | null {
+ return null;
+ },
+ });
+ await manager.addClient(mockCDPSession);
+ mockCDPSession.emit('Network.requestWillBeSent', {
+ requestId: '7760711DEFCFA23132D98ABA6B4E175C',
+ loaderId: '7760711DEFCFA23132D98ABA6B4E175C',
+ documentURL: 'http://localhost:8907/redirect/1.html',
+ request: {
+ url: 'http://localhost:8907/redirect/1.html',
+ method: 'GET',
+ headers: {
+ 'Upgrade-Insecure-Requests': '1',
+ 'User-Agent':
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/97.0.4691.0 Safari/537.36',
+ },
+ mixedContentType: 'none',
+ initialPriority: 'VeryHigh',
+ referrerPolicy: 'strict-origin-when-cross-origin',
+ isSameSite: true,
+ },
+ timestamp: 2111.55635,
+ wallTime: 1637315638.473634,
+ initiator: {type: 'other'},
+ redirectHasExtraInfo: false,
+ type: 'Document',
+ frameId: '099A5216AF03AAFEC988F214B024DF08',
+ hasUserGesture: false,
+ });
+
+ mockCDPSession.emit('Network.requestWillBeSentExtraInfo', {
+ requestId: '7760711DEFCFA23132D98ABA6B4E175C',
+ associatedCookies: [],
+ headers: {
+ Host: 'localhost:8907',
+ Connection: 'keep-alive',
+ 'Upgrade-Insecure-Requests': '1',
+ 'User-Agent':
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/97.0.4691.0 Safari/537.36',
+ Accept:
+ 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
+ 'Sec-Fetch-Site': 'none',
+ 'Sec-Fetch-Mode': 'navigate',
+ 'Sec-Fetch-User': '?1',
+ 'Sec-Fetch-Dest': 'document',
+ 'Accept-Encoding': 'gzip, deflate, br',
+ },
+ connectTiming: {requestTime: 2111.557593},
+ });
+ mockCDPSession.emit('Network.responseReceivedExtraInfo', {
+ requestId: '7760711DEFCFA23132D98ABA6B4E175C',
+ blockedCookies: [],
+ headers: {
+ location: '/redirect/2.html',
+ Date: 'Fri, 19 Nov 2021 09:53:58 GMT',
+ Connection: 'keep-alive',
+ 'Keep-Alive': 'timeout=5',
+ 'Transfer-Encoding': 'chunked',
+ },
+ resourceIPAddressSpace: 'Local',
+ statusCode: 302,
+ headersText:
+ 'HTTP/1.1 302 Found\r\nlocation: /redirect/2.html\r\nDate: Fri, 19 Nov 2021 09:53:58 GMT\r\nConnection: keep-alive\r\nKeep-Alive: timeout=5\r\nTransfer-Encoding: chunked\r\n\r\n',
+ });
+ mockCDPSession.emit('Network.requestWillBeSent', {
+ requestId: '7760711DEFCFA23132D98ABA6B4E175C',
+ loaderId: '7760711DEFCFA23132D98ABA6B4E175C',
+ documentURL: 'http://localhost:8907/redirect/2.html',
+ request: {
+ url: 'http://localhost:8907/redirect/2.html',
+ method: 'GET',
+ headers: {
+ 'Upgrade-Insecure-Requests': '1',
+ 'User-Agent':
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/97.0.4691.0 Safari/537.36',
+ },
+ mixedContentType: 'none',
+ initialPriority: 'VeryHigh',
+ referrerPolicy: 'strict-origin-when-cross-origin',
+ isSameSite: true,
+ },
+ timestamp: 2111.559124,
+ wallTime: 1637315638.47642,
+ initiator: {type: 'other'},
+ redirectHasExtraInfo: true,
+ redirectResponse: {
+ url: 'http://localhost:8907/redirect/1.html',
+ status: 302,
+ statusText: 'Found',
+ headers: {
+ location: '/redirect/2.html',
+ Date: 'Fri, 19 Nov 2021 09:53:58 GMT',
+ Connection: 'keep-alive',
+ 'Keep-Alive': 'timeout=5',
+ 'Transfer-Encoding': 'chunked',
+ },
+ mimeType: '',
+ connectionReused: false,
+ connectionId: 322,
+ remoteIPAddress: '[::1]',
+ remotePort: 8907,
+ fromDiskCache: false,
+ fromServiceWorker: false,
+ fromPrefetchCache: false,
+ encodedDataLength: 162,
+ timing: {
+ receiveHeadersStart: 0,
+ requestTime: 2111.557593,
+ proxyStart: -1,
+ proxyEnd: -1,
+ dnsStart: 0.241,
+ dnsEnd: 0.251,
+ connectStart: 0.251,
+ connectEnd: 0.47,
+ sslStart: -1,
+ sslEnd: -1,
+ workerStart: -1,
+ workerReady: -1,
+ workerFetchStart: -1,
+ workerRespondWithSettled: -1,
+ sendStart: 0.537,
+ sendEnd: 0.611,
+ pushStart: 0,
+ pushEnd: 0,
+ receiveHeadersEnd: 0.939,
+ },
+ responseTime: 1.637315638475744e12,
+ protocol: 'http/1.1',
+ securityState: 'secure',
+ },
+ type: 'Document',
+ frameId: '099A5216AF03AAFEC988F214B024DF08',
+ hasUserGesture: false,
+ });
+ mockCDPSession.emit('Network.requestWillBeSentExtraInfo', {
+ requestId: '7760711DEFCFA23132D98ABA6B4E175C',
+ associatedCookies: [],
+ headers: {
+ Host: 'localhost:8907',
+ Connection: 'keep-alive',
+ 'Upgrade-Insecure-Requests': '1',
+ 'User-Agent':
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/97.0.4691.0 Safari/537.36',
+ Accept:
+ 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
+ 'Sec-Fetch-Site': 'none',
+ 'Sec-Fetch-Mode': 'navigate',
+ 'Sec-Fetch-User': '?1',
+ 'Sec-Fetch-Dest': 'document',
+ 'Accept-Encoding': 'gzip, deflate, br',
+ },
+ connectTiming: {requestTime: 2111.559346},
+ });
+ mockCDPSession.emit('Network.requestWillBeSent', {
+ requestId: '7760711DEFCFA23132D98ABA6B4E175C',
+ loaderId: '7760711DEFCFA23132D98ABA6B4E175C',
+ documentURL: 'http://localhost:8907/redirect/3.html',
+ request: {
+ url: 'http://localhost:8907/redirect/3.html',
+ method: 'GET',
+ headers: {
+ 'Upgrade-Insecure-Requests': '1',
+ 'User-Agent':
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/97.0.4691.0 Safari/537.36',
+ },
+ mixedContentType: 'none',
+ initialPriority: 'VeryHigh',
+ referrerPolicy: 'strict-origin-when-cross-origin',
+ isSameSite: true,
+ },
+ timestamp: 2111.560249,
+ wallTime: 1637315638.477543,
+ initiator: {type: 'other'},
+ redirectHasExtraInfo: true,
+ redirectResponse: {
+ url: 'http://localhost:8907/redirect/2.html',
+ status: 302,
+ statusText: 'Found',
+ headers: {
+ location: '/redirect/3.html',
+ Date: 'Fri, 19 Nov 2021 09:53:58 GMT',
+ Connection: 'keep-alive',
+ 'Keep-Alive': 'timeout=5',
+ 'Transfer-Encoding': 'chunked',
+ },
+ mimeType: '',
+ connectionReused: true,
+ connectionId: 322,
+ remoteIPAddress: '[::1]',
+ remotePort: 8907,
+ fromDiskCache: false,
+ fromServiceWorker: false,
+ fromPrefetchCache: false,
+ encodedDataLength: 162,
+ timing: {
+ receiveHeadersStart: 0,
+ requestTime: 2111.559346,
+ proxyStart: -1,
+ proxyEnd: -1,
+ dnsStart: -1,
+ dnsEnd: -1,
+ connectStart: -1,
+ connectEnd: -1,
+ sslStart: -1,
+ sslEnd: -1,
+ workerStart: -1,
+ workerReady: -1,
+ workerFetchStart: -1,
+ workerRespondWithSettled: -1,
+ sendStart: 0.15,
+ sendEnd: 0.196,
+ pushStart: 0,
+ pushEnd: 0,
+ receiveHeadersEnd: 0.507,
+ },
+ responseTime: 1.637315638477063e12,
+ protocol: 'http/1.1',
+ securityState: 'secure',
+ },
+ type: 'Document',
+ frameId: '099A5216AF03AAFEC988F214B024DF08',
+ hasUserGesture: false,
+ });
+ mockCDPSession.emit('Network.responseReceivedExtraInfo', {
+ requestId: '7760711DEFCFA23132D98ABA6B4E175C',
+ blockedCookies: [],
+ headers: {
+ location: '/redirect/3.html',
+ Date: 'Fri, 19 Nov 2021 09:53:58 GMT',
+ Connection: 'keep-alive',
+ 'Keep-Alive': 'timeout=5',
+ 'Transfer-Encoding': 'chunked',
+ },
+ resourceIPAddressSpace: 'Local',
+ statusCode: 302,
+ headersText:
+ 'HTTP/1.1 302 Found\r\nlocation: /redirect/3.html\r\nDate: Fri, 19 Nov 2021 09:53:58 GMT\r\nConnection: keep-alive\r\nKeep-Alive: timeout=5\r\nTransfer-Encoding: chunked\r\n\r\n',
+ });
+ mockCDPSession.emit('Network.requestWillBeSentExtraInfo', {
+ requestId: '7760711DEFCFA23132D98ABA6B4E175C',
+ associatedCookies: [],
+ headers: {
+ Host: 'localhost:8907',
+ Connection: 'keep-alive',
+ 'Upgrade-Insecure-Requests': '1',
+ 'User-Agent':
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/97.0.4691.0 Safari/537.36',
+ Accept:
+ 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
+ 'Sec-Fetch-Site': 'none',
+ 'Sec-Fetch-Mode': 'navigate',
+ 'Sec-Fetch-User': '?1',
+ 'Sec-Fetch-Dest': 'document',
+ 'Accept-Encoding': 'gzip, deflate, br',
+ },
+ connectTiming: {requestTime: 2111.560482},
+ });
+ mockCDPSession.emit('Network.requestWillBeSent', {
+ requestId: '7760711DEFCFA23132D98ABA6B4E175C',
+ loaderId: '7760711DEFCFA23132D98ABA6B4E175C',
+ documentURL: 'http://localhost:8907/empty.html',
+ request: {
+ url: 'http://localhost:8907/empty.html',
+ method: 'GET',
+ headers: {
+ 'Upgrade-Insecure-Requests': '1',
+ 'User-Agent':
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/97.0.4691.0 Safari/537.36',
+ },
+ mixedContentType: 'none',
+ initialPriority: 'VeryHigh',
+ referrerPolicy: 'strict-origin-when-cross-origin',
+ isSameSite: true,
+ },
+ timestamp: 2111.561542,
+ wallTime: 1637315638.478837,
+ initiator: {type: 'other'},
+ redirectHasExtraInfo: true,
+ redirectResponse: {
+ url: 'http://localhost:8907/redirect/3.html',
+ status: 302,
+ statusText: 'Found',
+ headers: {
+ location: 'http://localhost:8907/empty.html',
+ Date: 'Fri, 19 Nov 2021 09:53:58 GMT',
+ Connection: 'keep-alive',
+ 'Keep-Alive': 'timeout=5',
+ 'Transfer-Encoding': 'chunked',
+ },
+ mimeType: '',
+ connectionReused: true,
+ connectionId: 322,
+ remoteIPAddress: '[::1]',
+ remotePort: 8907,
+ fromDiskCache: false,
+ fromServiceWorker: false,
+ fromPrefetchCache: false,
+ encodedDataLength: 178,
+ timing: {
+ receiveHeadersStart: 0,
+ requestTime: 2111.560482,
+ proxyStart: -1,
+ proxyEnd: -1,
+ dnsStart: -1,
+ dnsEnd: -1,
+ connectStart: -1,
+ connectEnd: -1,
+ sslStart: -1,
+ sslEnd: -1,
+ workerStart: -1,
+ workerReady: -1,
+ workerFetchStart: -1,
+ workerRespondWithSettled: -1,
+ sendStart: 0.149,
+ sendEnd: 0.198,
+ pushStart: 0,
+ pushEnd: 0,
+ receiveHeadersEnd: 0.478,
+ },
+ responseTime: 1.637315638478184e12,
+ protocol: 'http/1.1',
+ securityState: 'secure',
+ },
+ type: 'Document',
+ frameId: '099A5216AF03AAFEC988F214B024DF08',
+ hasUserGesture: false,
+ });
+ mockCDPSession.emit('Network.responseReceivedExtraInfo', {
+ requestId: '7760711DEFCFA23132D98ABA6B4E175C',
+ blockedCookies: [],
+ headers: {
+ location: 'http://localhost:8907/empty.html',
+ Date: 'Fri, 19 Nov 2021 09:53:58 GMT',
+ Connection: 'keep-alive',
+ 'Keep-Alive': 'timeout=5',
+ 'Transfer-Encoding': 'chunked',
+ },
+ resourceIPAddressSpace: 'Local',
+ statusCode: 302,
+ headersText:
+ 'HTTP/1.1 302 Found\r\nlocation: http://localhost:8907/empty.html\r\nDate: Fri, 19 Nov 2021 09:53:58 GMT\r\nConnection: keep-alive\r\nKeep-Alive: timeout=5\r\nTransfer-Encoding: chunked\r\n\r\n',
+ });
+ mockCDPSession.emit('Network.requestWillBeSentExtraInfo', {
+ requestId: '7760711DEFCFA23132D98ABA6B4E175C',
+ associatedCookies: [],
+ headers: {
+ Host: 'localhost:8907',
+ Connection: 'keep-alive',
+ 'Upgrade-Insecure-Requests': '1',
+ 'User-Agent':
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/97.0.4691.0 Safari/537.36',
+ Accept:
+ 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
+ 'Sec-Fetch-Site': 'none',
+ 'Sec-Fetch-Mode': 'navigate',
+ 'Sec-Fetch-User': '?1',
+ 'Sec-Fetch-Dest': 'document',
+ 'Accept-Encoding': 'gzip, deflate, br',
+ },
+ connectTiming: {requestTime: 2111.561759},
+ });
+ mockCDPSession.emit('Network.responseReceivedExtraInfo', {
+ requestId: '7760711DEFCFA23132D98ABA6B4E175C',
+ blockedCookies: [],
+ headers: {
+ 'Cache-Control': 'no-cache, no-store',
+ 'Content-Type': 'text/html; charset=utf-8',
+ Date: 'Fri, 19 Nov 2021 09:53:58 GMT',
+ Connection: 'keep-alive',
+ 'Keep-Alive': 'timeout=5',
+ 'Content-Length': '0',
+ },
+ resourceIPAddressSpace: 'Local',
+ statusCode: 200,
+ headersText:
+ 'HTTP/1.1 200 OK\r\nCache-Control: no-cache, no-store\r\nContent-Type: text/html; charset=utf-8\r\nDate: Fri, 19 Nov 2021 09:53:58 GMT\r\nConnection: keep-alive\r\nKeep-Alive: timeout=5\r\nContent-Length: 0\r\n\r\n',
+ });
+ mockCDPSession.emit('Network.responseReceived', {
+ requestId: '7760711DEFCFA23132D98ABA6B4E175C',
+ loaderId: '7760711DEFCFA23132D98ABA6B4E175C',
+ timestamp: 2111.563565,
+ type: 'Document',
+ response: {
+ url: 'http://localhost:8907/empty.html',
+ status: 200,
+ statusText: 'OK',
+ headers: {
+ 'Cache-Control': 'no-cache, no-store',
+ 'Content-Type': 'text/html; charset=utf-8',
+ Date: 'Fri, 19 Nov 2021 09:53:58 GMT',
+ Connection: 'keep-alive',
+ 'Keep-Alive': 'timeout=5',
+ 'Content-Length': '0',
+ },
+ mimeType: 'text/html',
+ connectionReused: true,
+ connectionId: 322,
+ remoteIPAddress: '[::1]',
+ remotePort: 8907,
+ fromDiskCache: false,
+ fromServiceWorker: false,
+ fromPrefetchCache: false,
+ encodedDataLength: 197,
+ timing: {
+ receiveHeadersStart: 0,
+ requestTime: 2111.561759,
+ proxyStart: -1,
+ proxyEnd: -1,
+ dnsStart: -1,
+ dnsEnd: -1,
+ connectStart: -1,
+ connectEnd: -1,
+ sslStart: -1,
+ sslEnd: -1,
+ workerStart: -1,
+ workerReady: -1,
+ workerFetchStart: -1,
+ workerRespondWithSettled: -1,
+ sendStart: 0.148,
+ sendEnd: 0.19,
+ pushStart: 0,
+ pushEnd: 0,
+ receiveHeadersEnd: 0.925,
+ },
+ responseTime: 1.637315638479928e12,
+ protocol: 'http/1.1',
+ securityState: 'secure',
+ },
+ hasExtraInfo: true,
+ frameId: '099A5216AF03AAFEC988F214B024DF08',
+ });
+ });
+ it(`should handle "double pause" (crbug.com/1196004) Fetch.requestPaused events for the same Network.requestWillBeSent event`, async () => {
+ const mockCDPSession = new MockCDPSession();
+ const manager = new NetworkManager(true, {
+ frame(): CdpFrame | null {
+ return null;
+ },
+ });
+ await manager.addClient(mockCDPSession);
+ await manager.setRequestInterception(true);
+
+ const requests: HTTPRequest[] = [];
+ manager.on(NetworkManagerEvent.Request, async (request: HTTPRequest) => {
+ requests.push(request);
+ await request.continue();
+ });
+
+ /**
+ * This sequence was taken from an actual CDP session produced by the following
+ * test script:
+ *
+ * ```ts
+ * const browser = await puppeteer.launch({headless: false});
+ * const page = await browser.newPage();
+ * await page.setCacheEnabled(false);
+ *
+ * await page.setRequestInterception(true);
+ * page.on('request', interceptedRequest => {
+ * interceptedRequest.continue();
+ * });
+ *
+ * await page.goto('https://www.google.com');
+ * await browser.close();
+ * ```
+ */
+ mockCDPSession.emit('Network.requestWillBeSent', {
+ requestId: '11ACE9783588040D644B905E8B55285B',
+ loaderId: '11ACE9783588040D644B905E8B55285B',
+ documentURL: 'https://www.google.com/',
+ request: {
+ url: 'https://www.google.com/',
+ method: 'GET',
+ headers: {},
+ mixedContentType: 'none',
+ initialPriority: 'VeryHigh',
+ referrerPolicy: 'strict-origin-when-cross-origin',
+ isSameSite: true,
+ },
+ timestamp: 224604.980827,
+ wallTime: 1637955746.786191,
+ initiator: {type: 'other'},
+ redirectHasExtraInfo: false,
+ type: 'Document',
+ frameId: '84AC261A351B86932B775B76D1DD79F8',
+ hasUserGesture: false,
+ });
+ mockCDPSession.emit('Fetch.requestPaused', {
+ requestId: 'interception-job-1.0',
+ request: {
+ url: 'https://www.google.com/',
+ method: 'GET',
+ headers: {},
+ initialPriority: 'VeryHigh',
+ referrerPolicy: 'strict-origin-when-cross-origin',
+ },
+ frameId: '84AC261A351B86932B775B76D1DD79F8',
+ resourceType: 'Document',
+ networkId: '11ACE9783588040D644B905E8B55285B',
+ });
+ mockCDPSession.emit('Fetch.requestPaused', {
+ requestId: 'interception-job-2.0',
+ request: {
+ url: 'https://www.google.com/',
+ method: 'GET',
+ headers: {},
+ initialPriority: 'VeryHigh',
+ referrerPolicy: 'strict-origin-when-cross-origin',
+ },
+ frameId: '84AC261A351B86932B775B76D1DD79F8',
+ resourceType: 'Document',
+ networkId: '11ACE9783588040D644B905E8B55285B',
+ });
+
+ expect(requests).toHaveLength(2);
+ });
+ it(`should handle Network.responseReceivedExtraInfo event after Network.responseReceived event (github.com/puppeteer/puppeteer/issues/8234)`, async () => {
+ const mockCDPSession = new MockCDPSession();
+ const manager = new NetworkManager(true, {
+ frame(): CdpFrame | null {
+ return null;
+ },
+ });
+ await manager.addClient(mockCDPSession);
+
+ const requests: HTTPRequest[] = [];
+ manager.on(NetworkManagerEvent.RequestFinished, (request: HTTPRequest) => {
+ requests.push(request);
+ });
+
+ mockCDPSession.emit('Network.requestWillBeSent', {
+ requestId: '1360.2',
+ loaderId: '9E86B0282CC98B77FB0ABD49156DDFDD',
+ documentURL: 'http://this.is.the.start.page.com/',
+ request: {
+ url: 'http://this.is.a.test.com:1080/test.js',
+ method: 'GET',
+ headers: {
+ 'Accept-Language': 'en-US,en;q=0.9',
+ Referer: 'http://this.is.the.start.page.com/',
+ 'User-Agent':
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.0 Safari/537.36',
+ },
+ mixedContentType: 'none',
+ initialPriority: 'High',
+ referrerPolicy: 'strict-origin-when-cross-origin',
+ isSameSite: false,
+ },
+ timestamp: 10959.020087,
+ wallTime: 1649712607.861365,
+ initiator: {
+ type: 'parser',
+ url: 'http://this.is.the.start.page.com/',
+ lineNumber: 9,
+ columnNumber: 80,
+ },
+ redirectHasExtraInfo: false,
+ type: 'Script',
+ frameId: '60E6C35E7E519F28E646056820095498',
+ hasUserGesture: false,
+ });
+ mockCDPSession.emit('Network.responseReceived', {
+ requestId: '1360.2',
+ loaderId: '9E86B0282CC98B77FB0ABD49156DDFDD',
+ timestamp: 10959.042529,
+ type: 'Script',
+ response: {
+ url: 'http://this.is.a.test.com:1080',
+ status: 200,
+ statusText: 'OK',
+ headers: {
+ connection: 'keep-alive',
+ 'content-length': '85862',
+ },
+ mimeType: 'text/plain',
+ connectionReused: false,
+ connectionId: 119,
+ remoteIPAddress: '127.0.0.1',
+ remotePort: 1080,
+ fromDiskCache: false,
+ fromServiceWorker: false,
+ fromPrefetchCache: false,
+ encodedDataLength: 66,
+ timing: {
+ receiveHeadersStart: 0,
+ requestTime: 10959.023904,
+ proxyStart: -1,
+ proxyEnd: -1,
+ dnsStart: 0.328,
+ dnsEnd: 2.183,
+ connectStart: 2.183,
+ connectEnd: 2.798,
+ sslStart: -1,
+ sslEnd: -1,
+ workerStart: -1,
+ workerReady: -1,
+ workerFetchStart: -1,
+ workerRespondWithSettled: -1,
+ sendStart: 2.982,
+ sendEnd: 3.757,
+ pushStart: 0,
+ pushEnd: 0,
+ receiveHeadersEnd: 16.373,
+ },
+ responseTime: 1649712607880.971,
+ protocol: 'http/1.1',
+ securityState: 'insecure',
+ },
+ hasExtraInfo: true,
+ frameId: '60E6C35E7E519F28E646056820095498',
+ });
+ mockCDPSession.emit('Network.responseReceivedExtraInfo', {
+ requestId: '1360.2',
+ blockedCookies: [],
+ headers: {
+ connection: 'keep-alive',
+ 'content-length': '85862',
+ },
+ resourceIPAddressSpace: 'Private',
+ statusCode: 200,
+ headersText:
+ 'HTTP/1.1 200 OK\r\nconnection: keep-alive\r\ncontent-length: 85862\r\n\r\n',
+ });
+ mockCDPSession.emit('Network.loadingFinished', {
+ requestId: '1360.2',
+ timestamp: 10959.060708,
+ encodedDataLength: 85928,
+ });
+
+ expect(requests).toHaveLength(1);
+ });
+
+ it(`should resolve the response once the late responseReceivedExtraInfo event arrives`, async () => {
+ const mockCDPSession = new MockCDPSession();
+ const manager = new NetworkManager(true, {
+ frame(): CdpFrame | null {
+ return null;
+ },
+ });
+ await manager.addClient(mockCDPSession);
+
+ const finishedRequests: HTTPRequest[] = [];
+ const pendingRequests: HTTPRequest[] = [];
+ manager.on(NetworkManagerEvent.RequestFinished, (request: HTTPRequest) => {
+ finishedRequests.push(request);
+ });
+
+ manager.on(NetworkManagerEvent.Request, (request: HTTPRequest) => {
+ pendingRequests.push(request);
+ });
+
+ mockCDPSession.emit('Network.requestWillBeSent', {
+ requestId: 'LOADERID',
+ loaderId: 'LOADERID',
+ documentURL: 'http://10.1.0.39:42915/empty.html',
+ request: {
+ url: 'http://10.1.0.39:42915/empty.html',
+ method: 'GET',
+ headers: {
+ 'Upgrade-Insecure-Requests': '1',
+ 'User-Agent':
+ 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36',
+ },
+ mixedContentType: 'none',
+ initialPriority: 'VeryHigh',
+ referrerPolicy: 'strict-origin-when-cross-origin',
+ isSameSite: true,
+ },
+ timestamp: 671.229856,
+ wallTime: 1660121157.913774,
+ initiator: {type: 'other'},
+ redirectHasExtraInfo: false,
+ type: 'Document',
+ frameId: 'FRAMEID',
+ hasUserGesture: false,
+ });
+
+ mockCDPSession.emit('Network.responseReceived', {
+ requestId: 'LOADERID',
+ loaderId: 'LOADERID',
+ timestamp: 671.236025,
+ type: 'Document',
+ response: {
+ url: 'http://10.1.0.39:42915/empty.html',
+ status: 200,
+ statusText: 'OK',
+ headers: {
+ 'Cache-Control': 'no-cache, no-store',
+ Connection: 'keep-alive',
+ 'Content-Length': '0',
+ 'Content-Type': 'text/html; charset=utf-8',
+ Date: 'Wed, 10 Aug 2022 08:45:57 GMT',
+ 'Keep-Alive': 'timeout=5',
+ },
+ mimeType: 'text/html',
+ connectionReused: true,
+ connectionId: 18,
+ remoteIPAddress: '10.1.0.39',
+ remotePort: 42915,
+ fromDiskCache: false,
+ fromServiceWorker: false,
+ fromPrefetchCache: false,
+ encodedDataLength: 197,
+ timing: {
+ receiveHeadersStart: 0,
+ requestTime: 671.232585,
+ proxyStart: -1,
+ proxyEnd: -1,
+ dnsStart: -1,
+ dnsEnd: -1,
+ connectStart: -1,
+ connectEnd: -1,
+ sslStart: -1,
+ sslEnd: -1,
+ workerStart: -1,
+ workerReady: -1,
+ workerFetchStart: -1,
+ workerRespondWithSettled: -1,
+ sendStart: 0.308,
+ sendEnd: 0.364,
+ pushStart: 0,
+ pushEnd: 0,
+ receiveHeadersEnd: 1.554,
+ },
+ responseTime: 1.660121157917951e12,
+ protocol: 'http/1.1',
+ securityState: 'insecure',
+ },
+ hasExtraInfo: true,
+ frameId: 'FRAMEID',
+ });
+
+ mockCDPSession.emit('Network.requestWillBeSentExtraInfo', {
+ requestId: 'LOADERID',
+ associatedCookies: [],
+ headers: {
+ Accept:
+ 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
+ 'Accept-Encoding': 'gzip, deflate',
+ 'Accept-Language': 'en-US,en;q=0.9',
+ Connection: 'keep-alive',
+ Host: '10.1.0.39:42915',
+ 'Upgrade-Insecure-Requests': '1',
+ 'User-Agent':
+ 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36',
+ },
+ connectTiming: {requestTime: 671.232585},
+ });
+
+ mockCDPSession.emit('Network.loadingFinished', {
+ requestId: 'LOADERID',
+ timestamp: 671.234448,
+ encodedDataLength: 197,
+ });
+
+ expect(pendingRequests).toHaveLength(1);
+ expect(finishedRequests).toHaveLength(0);
+ expect(pendingRequests[0]!.response()).toEqual(null);
+
+ // The extra info might arrive late.
+ mockCDPSession.emit('Network.responseReceivedExtraInfo', {
+ requestId: 'LOADERID',
+ blockedCookies: [],
+ headers: {
+ 'Cache-Control': 'no-cache, no-store',
+ Connection: 'keep-alive',
+ 'Content-Length': '0',
+ 'Content-Type': 'text/html; charset=utf-8',
+ Date: 'Wed, 10 Aug 2022 09:04:39 GMT',
+ 'Keep-Alive': 'timeout=5',
+ },
+ resourceIPAddressSpace: 'Private',
+ statusCode: 200,
+ headersText:
+ 'HTTP/1.1 200 OK\\r\\nCache-Control: no-cache, no-store\\r\\nContent-Type: text/html; charset=utf-8\\r\\nDate: Wed, 10 Aug 2022 09:04:39 GMT\\r\\nConnection: keep-alive\\r\\nKeep-Alive: timeout=5\\r\\nContent-Length: 0\\r\\n\\r\\n',
+ });
+
+ expect(pendingRequests).toHaveLength(1);
+ expect(finishedRequests).toHaveLength(1);
+ expect(pendingRequests[0]!.response()).not.toEqual(null);
+ });
+
+ it(`should send responses for iframe that don't receive loadingFinished event`, async () => {
+ const mockCDPSession = new MockCDPSession();
+ const manager = new NetworkManager(true, {
+ frame(): CdpFrame | null {
+ return null;
+ },
+ });
+ await manager.addClient(mockCDPSession);
+
+ const responses: HTTPResponse[] = [];
+ const requests: HTTPRequest[] = [];
+ manager.on(NetworkManagerEvent.Response, (response: HTTPResponse) => {
+ responses.push(response);
+ });
+
+ manager.on(NetworkManagerEvent.Request, (request: HTTPRequest) => {
+ requests.push(request);
+ });
+
+ mockCDPSession.emit('Network.requestWillBeSent', {
+ requestId: '94051D839ACF29E53A3D1273FB20B4C4',
+ loaderId: '94051D839ACF29E53A3D1273FB20B4C4',
+ documentURL: 'http://127.0.0.1:54590/empty.html',
+ request: {
+ url: 'http://127.0.0.1:54590/empty.html',
+ method: 'GET',
+ headers: {
+ Referer: 'http://localhost:54590/',
+ 'Upgrade-Insecure-Requests': '1',
+ 'User-Agent':
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/105.0.5173.0 Safari/537.36',
+ },
+ mixedContentType: 'none',
+ initialPriority: 'VeryHigh',
+ referrerPolicy: 'strict-origin-when-cross-origin',
+ isSameSite: false,
+ },
+ timestamp: 504903.99901,
+ wallTime: 1660125092.026021,
+ initiator: {
+ type: 'script',
+ stack: {
+ callFrames: [
+ {
+ functionName: 'navigateFrame',
+ scriptId: '8',
+ url: 'pptr://__puppeteer_evaluation_script__',
+ lineNumber: 2,
+ columnNumber: 18,
+ },
+ ],
+ },
+ },
+ redirectHasExtraInfo: false,
+ type: 'Document',
+ frameId: '07D18B8630A8161C72B6079B74123D60',
+ hasUserGesture: true,
+ });
+
+ mockCDPSession.emit('Network.requestWillBeSentExtraInfo', {
+ requestId: '94051D839ACF29E53A3D1273FB20B4C4',
+ associatedCookies: [],
+ headers: {
+ Accept:
+ 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
+ 'Accept-Encoding': 'gzip, deflate, br',
+ Connection: 'keep-alive',
+ Host: '127.0.0.1:54590',
+ Referer: 'http://localhost:54590/',
+ 'Sec-Fetch-Dest': 'iframe',
+ 'Sec-Fetch-Mode': 'navigate',
+ 'Sec-Fetch-Site': 'cross-site',
+ 'Sec-Fetch-User': '?1',
+ 'Upgrade-Insecure-Requests': '1',
+ 'User-Agent':
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/105.0.5173.0 Safari/537.36',
+ },
+ connectTiming: {requestTime: 504904.000422},
+ clientSecurityState: {
+ initiatorIsSecureContext: true,
+ initiatorIPAddressSpace: 'Local',
+ privateNetworkRequestPolicy: 'Allow',
+ },
+ });
+
+ mockCDPSession.emit('Network.responseReceivedExtraInfo', {
+ requestId: '94051D839ACF29E53A3D1273FB20B4C4',
+ blockedCookies: [],
+ headers: {
+ 'Cache-Control': 'no-cache, no-store',
+ Connection: 'keep-alive',
+ 'Content-Length': '0',
+ 'Content-Type': 'text/html; charset=utf-8',
+ Date: 'Wed, 10 Aug 2022 09:51:32 GMT',
+ 'Keep-Alive': 'timeout=5',
+ },
+ resourceIPAddressSpace: 'Local',
+ statusCode: 200,
+ headersText:
+ 'HTTP/1.1 200 OK\r\nCache-Control: no-cache, no-store\r\nContent-Type: text/html; charset=utf-8\r\nDate: Wed, 10 Aug 2022 09:51:32 GMT\r\nConnection: keep-alive\r\nKeep-Alive: timeout=5\r\nContent-Length: 0\r\n\r\n',
+ });
+
+ mockCDPSession.emit('Network.responseReceived', {
+ requestId: '94051D839ACF29E53A3D1273FB20B4C4',
+ loaderId: '94051D839ACF29E53A3D1273FB20B4C4',
+ timestamp: 504904.00338,
+ type: 'Document',
+ response: {
+ url: 'http://127.0.0.1:54590/empty.html',
+ status: 200,
+ statusText: 'OK',
+ headers: {
+ 'Cache-Control': 'no-cache, no-store',
+ Connection: 'keep-alive',
+ 'Content-Length': '0',
+ 'Content-Type': 'text/html; charset=utf-8',
+ Date: 'Wed, 10 Aug 2022 09:51:32 GMT',
+ 'Keep-Alive': 'timeout=5',
+ },
+ mimeType: 'text/html',
+ connectionReused: true,
+ connectionId: 13,
+ remoteIPAddress: '127.0.0.1',
+ remotePort: 54590,
+ fromDiskCache: false,
+ fromServiceWorker: false,
+ fromPrefetchCache: false,
+ encodedDataLength: 197,
+ timing: {
+ receiveHeadersStart: 0,
+ requestTime: 504904.000422,
+ proxyStart: -1,
+ proxyEnd: -1,
+ dnsStart: -1,
+ dnsEnd: -1,
+ connectStart: -1,
+ connectEnd: -1,
+ sslStart: -1,
+ sslEnd: -1,
+ workerStart: -1,
+ workerReady: -1,
+ workerFetchStart: -1,
+ workerRespondWithSettled: -1,
+ sendStart: 0.338,
+ sendEnd: 0.413,
+ pushStart: 0,
+ pushEnd: 0,
+ receiveHeadersEnd: 1.877,
+ },
+ responseTime: 1.660125092029241e12,
+ protocol: 'http/1.1',
+ securityState: 'secure',
+ },
+ hasExtraInfo: true,
+ frameId: '07D18B8630A8161C72B6079B74123D60',
+ });
+
+ expect(requests).toHaveLength(1);
+ expect(responses).toHaveLength(1);
+ expect(requests[0]!.response()).not.toEqual(null);
+ });
+
+ it(`should send responses for iframe that don't receive loadingFinished event`, async () => {
+ const mockCDPSession = new MockCDPSession();
+ const manager = new NetworkManager(true, {
+ frame(): CdpFrame | null {
+ return null;
+ },
+ });
+ await manager.addClient(mockCDPSession);
+
+ const responses: HTTPResponse[] = [];
+ const requests: HTTPRequest[] = [];
+ manager.on(NetworkManagerEvent.Response, (response: HTTPResponse) => {
+ responses.push(response);
+ });
+
+ manager.on(NetworkManagerEvent.Request, (request: HTTPRequest) => {
+ requests.push(request);
+ });
+
+ mockCDPSession.emit('Network.requestWillBeSent', {
+ requestId: 'E18BEB94B486CA8771F9AFA2030FEA37',
+ loaderId: 'E18BEB94B486CA8771F9AFA2030FEA37',
+ documentURL: 'http://localhost:56295/empty.html',
+ request: {
+ url: 'http://localhost:56295/empty.html',
+ method: 'GET',
+ headers: {
+ 'Upgrade-Insecure-Requests': '1',
+ 'User-Agent':
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/105.0.5173.0 Safari/537.36',
+ },
+ mixedContentType: 'none',
+ initialPriority: 'VeryHigh',
+ referrerPolicy: 'strict-origin-when-cross-origin',
+ isSameSite: true,
+ },
+ timestamp: 510294.105656,
+ wallTime: 1660130482.230591,
+ initiator: {type: 'other'},
+ redirectHasExtraInfo: false,
+ type: 'Document',
+ frameId: 'F9C89A517341F1EFFE63310141630189',
+ hasUserGesture: false,
+ });
+ mockCDPSession.emit('Network.responseReceived', {
+ requestId: 'E18BEB94B486CA8771F9AFA2030FEA37',
+ loaderId: 'E18BEB94B486CA8771F9AFA2030FEA37',
+ timestamp: 510294.119816,
+ type: 'Document',
+ response: {
+ url: 'http://localhost:56295/empty.html',
+ status: 200,
+ statusText: 'OK',
+ headers: {
+ 'Cache-Control': 'no-cache, no-store',
+ Connection: 'keep-alive',
+ 'Content-Length': '0',
+ 'Content-Type': 'text/html; charset=utf-8',
+ Date: 'Wed, 10 Aug 2022 11:21:22 GMT',
+ 'Keep-Alive': 'timeout=5',
+ },
+ mimeType: 'text/html',
+ connectionReused: true,
+ connectionId: 13,
+ remoteIPAddress: '[::1]',
+ remotePort: 56295,
+ fromDiskCache: false,
+ fromServiceWorker: false,
+ fromPrefetchCache: false,
+ encodedDataLength: 197,
+ timing: {
+ receiveHeadersStart: 0,
+ requestTime: 510294.106734,
+ proxyStart: -1,
+ proxyEnd: -1,
+ dnsStart: -1,
+ dnsEnd: -1,
+ connectStart: -1,
+ connectEnd: -1,
+ sslStart: -1,
+ sslEnd: -1,
+ workerStart: -1,
+ workerReady: -1,
+ workerFetchStart: -1,
+ workerRespondWithSettled: -1,
+ sendStart: 2.195,
+ sendEnd: 2.29,
+ pushStart: 0,
+ pushEnd: 0,
+ receiveHeadersEnd: 6.493,
+ },
+ responseTime: 1.660130482238109e12,
+ protocol: 'http/1.1',
+ securityState: 'secure',
+ },
+ hasExtraInfo: true,
+ frameId: 'F9C89A517341F1EFFE63310141630189',
+ });
+ mockCDPSession.emit('Network.requestWillBeSentExtraInfo', {
+ requestId: 'E18BEB94B486CA8771F9AFA2030FEA37',
+ associatedCookies: [],
+ headers: {
+ Accept:
+ 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
+ 'Accept-Encoding': 'gzip, deflate, br',
+ Connection: 'keep-alive',
+ Host: 'localhost:56295',
+ 'Sec-Fetch-Dest': 'document',
+ 'Sec-Fetch-Mode': 'navigate',
+ 'Sec-Fetch-Site': 'none',
+ 'Sec-Fetch-User': '?1',
+ 'Upgrade-Insecure-Requests': '1',
+ 'User-Agent':
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/105.0.5173.0 Safari/537.36',
+ },
+ connectTiming: {requestTime: 510294.106734},
+ });
+ mockCDPSession.emit('Network.loadingFinished', {
+ requestId: 'E18BEB94B486CA8771F9AFA2030FEA37',
+ timestamp: 510294.113383,
+ encodedDataLength: 197,
+ });
+ mockCDPSession.emit('Network.responseReceivedExtraInfo', {
+ requestId: 'E18BEB94B486CA8771F9AFA2030FEA37',
+ blockedCookies: [],
+ headers: {
+ 'Cache-Control': 'no-cache, no-store',
+ Connection: 'keep-alive',
+ 'Content-Length': '0',
+ 'Content-Type': 'text/html; charset=utf-8',
+ Date: 'Wed, 10 Aug 2022 11:21:22 GMT',
+ 'Keep-Alive': 'timeout=5',
+ },
+ resourceIPAddressSpace: 'Local',
+ statusCode: 200,
+ headersText:
+ 'HTTP/1.1 200 OK\r\nCache-Control: no-cache, no-store\r\nContent-Type: text/html; charset=utf-8\r\nDate: Wed, 10 Aug 2022 11:21:22 GMT\r\nConnection: keep-alive\r\nKeep-Alive: timeout=5\r\nContent-Length: 0\r\n\r\n',
+ });
+
+ expect(requests).toHaveLength(1);
+ expect(responses).toHaveLength(1);
+ expect(requests[0]!.response()).not.toEqual(null);
+ });
+
+ it(`should handle cached redirects`, async () => {
+ const mockCDPSession = new MockCDPSession();
+ const manager = new NetworkManager(true, {
+ frame(): CdpFrame | null {
+ return null;
+ },
+ });
+ await manager.addClient(mockCDPSession);
+
+ const responses: HTTPResponse[] = [];
+ const requests: HTTPRequest[] = [];
+ manager.on(NetworkManagerEvent.Response, (response: HTTPResponse) => {
+ responses.push(response);
+ });
+
+ manager.on(NetworkManagerEvent.Request, (request: HTTPRequest) => {
+ requests.push(request);
+ });
+
+ mockCDPSession.emit('Network.requestWillBeSent', {
+ requestId: '6D76C8ACAECE880C722FA515AD380015',
+ loaderId: '6D76C8ACAECE880C722FA515AD380015',
+ documentURL: 'http://localhost:3000/',
+ request: {
+ url: 'http://localhost:3000/',
+ method: 'GET',
+ headers: {
+ 'Upgrade-Insecure-Requests': '1',
+ 'User-Agent':
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36',
+ },
+ mixedContentType: 'none',
+ initialPriority: 'VeryHigh',
+ referrerPolicy: 'strict-origin-when-cross-origin',
+ isSameSite: true,
+ },
+ timestamp: 31949.95878,
+ wallTime: 1680698353.570949,
+ initiator: {type: 'other'},
+ redirectHasExtraInfo: false,
+ type: 'Document',
+ frameId: '4A6E05B1781795F1B586C1F8F8B2CBE4',
+ hasUserGesture: false,
+ });
+ mockCDPSession.emit('Network.requestWillBeSentExtraInfo', {
+ requestId: '6D76C8ACAECE880C722FA515AD380015',
+ associatedCookies: [],
+ headers: {
+ Accept:
+ 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
+ 'Accept-Encoding': 'gzip, deflate, br',
+ 'Accept-Language': 'en-GB,en-US;q=0.9,en;q=0.8',
+ Connection: 'keep-alive',
+ Host: 'localhost:3000',
+ 'Sec-Fetch-Dest': 'document',
+ 'Sec-Fetch-Mode': 'navigate',
+ 'Sec-Fetch-Site': 'none',
+ 'Sec-Fetch-User': '?1',
+ 'Upgrade-Insecure-Requests': '1',
+ 'User-Agent':
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36',
+ 'sec-ch-ua-mobile': '?0',
+ },
+ connectTiming: {requestTime: 31949.959838},
+ siteHasCookieInOtherPartition: false,
+ });
+ mockCDPSession.emit('Network.responseReceivedExtraInfo', {
+ requestId: '6D76C8ACAECE880C722FA515AD380015',
+ blockedCookies: [],
+ headers: {
+ 'Cache-Control': 'max-age=5',
+ Connection: 'keep-alive',
+ 'Content-Type': 'text/html; charset=utf-8',
+ Date: 'Wed, 05 Apr 2023 12:39:13 GMT',
+ 'Keep-Alive': 'timeout=5',
+ 'Transfer-Encoding': 'chunked',
+ },
+ resourceIPAddressSpace: 'Local',
+ statusCode: 200,
+ headersText:
+ 'HTTP/1.1 200 OK\\r\\nContent-Type: text/html; charset=utf-8\\r\\nCache-Control: max-age=5\\r\\nDate: Wed, 05 Apr 2023 12:39:13 GMT\\r\\nConnection: keep-alive\\r\\nKeep-Alive: timeout=5\\r\\nTransfer-Encoding: chunked\\r\\n\\r\\n',
+ cookiePartitionKey: 'http://localhost',
+ cookiePartitionKeyOpaque: false,
+ });
+
+ mockCDPSession.emit('Network.responseReceived', {
+ requestId: '6D76C8ACAECE880C722FA515AD380015',
+ loaderId: '6D76C8ACAECE880C722FA515AD380015',
+ timestamp: 31949.965149,
+ type: 'Document',
+ response: {
+ url: 'http://localhost:3000/',
+ status: 200,
+ statusText: 'OK',
+ headers: {
+ 'Cache-Control': 'max-age=5',
+ Connection: 'keep-alive',
+ 'Content-Type': 'text/html; charset=utf-8',
+ Date: 'Wed, 05 Apr 2023 12:39:13 GMT',
+ 'Keep-Alive': 'timeout=5',
+ 'Transfer-Encoding': 'chunked',
+ },
+ mimeType: 'text/html',
+ connectionReused: true,
+ connectionId: 34,
+ remoteIPAddress: '127.0.0.1',
+ remotePort: 3000,
+ fromDiskCache: false,
+ fromServiceWorker: false,
+ fromPrefetchCache: false,
+ encodedDataLength: 197,
+ timing: {
+ receiveHeadersStart: 0,
+ requestTime: 31949.959838,
+ proxyStart: -1,
+ proxyEnd: -1,
+ dnsStart: -1,
+ dnsEnd: -1,
+ connectStart: -1,
+ connectEnd: -1,
+ sslStart: -1,
+ sslEnd: -1,
+ workerStart: -1,
+ workerReady: -1,
+ workerFetchStart: -1,
+ workerRespondWithSettled: -1,
+ sendStart: 0.613,
+ sendEnd: 0.665,
+ pushStart: 0,
+ pushEnd: 0,
+ receiveHeadersEnd: 3.619,
+ },
+ responseTime: 1.680698353573552e12,
+ protocol: 'http/1.1',
+ alternateProtocolUsage: 'unspecifiedReason',
+ securityState: 'secure',
+ },
+ hasExtraInfo: true,
+ frameId: '4A6E05B1781795F1B586C1F8F8B2CBE4',
+ });
+ mockCDPSession.emit('Network.loadingFinished', {
+ requestId: '6D76C8ACAECE880C722FA515AD380015',
+ timestamp: 31949.963861,
+ encodedDataLength: 847,
+ });
+
+ mockCDPSession.emit('Network.requestWillBeSent', {
+ requestId: '4C2CC44FB6A6CAC5BE2780BCC9313105',
+ loaderId: '4C2CC44FB6A6CAC5BE2780BCC9313105',
+ documentURL: 'http://localhost:3000/redirect',
+ request: {
+ url: 'http://localhost:3000/redirect',
+ method: 'GET',
+ headers: {
+ Referer: 'http://localhost:3000/',
+ 'Upgrade-Insecure-Requests': '1',
+ 'User-Agent':
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36',
+ 'sec-ch-ua-mobile': '?0',
+ },
+ mixedContentType: 'none',
+ initialPriority: 'VeryHigh',
+ referrerPolicy: 'strict-origin-when-cross-origin',
+ isSameSite: true,
+ },
+ timestamp: 31949.982895,
+ wallTime: 1680698353.595079,
+ initiator: {
+ type: 'script',
+ stack: {
+ callFrames: [
+ {
+ functionName: '',
+ scriptId: '5',
+ url: 'http://localhost:3000/',
+ lineNumber: 8,
+ columnNumber: 32,
+ },
+ ],
+ },
+ },
+ redirectHasExtraInfo: false,
+ type: 'Document',
+ frameId: '4A6E05B1781795F1B586C1F8F8B2CBE4',
+ hasUserGesture: false,
+ });
+
+ mockCDPSession.emit('Network.requestWillBeSentExtraInfo', {
+ requestId: '4C2CC44FB6A6CAC5BE2780BCC9313105',
+ associatedCookies: [],
+ headers: {
+ Accept:
+ 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
+ 'Accept-Encoding': 'gzip, deflate, br',
+ 'Accept-Language': 'en-GB,en-US;q=0.9,en;q=0.8',
+ Connection: 'keep-alive',
+ Host: 'localhost:3000',
+ Referer: 'http://localhost:3000/',
+ 'Sec-Fetch-Dest': 'document',
+ 'Sec-Fetch-Mode': 'navigate',
+ 'Sec-Fetch-Site': 'same-origin',
+ 'Upgrade-Insecure-Requests': '1',
+ 'User-Agent':
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36',
+ 'sec-ch-ua-mobile': '?0',
+ },
+ connectTiming: {requestTime: 31949.983605},
+ siteHasCookieInOtherPartition: false,
+ });
+ mockCDPSession.emit('Network.responseReceivedExtraInfo', {
+ requestId: '4C2CC44FB6A6CAC5BE2780BCC9313105',
+ blockedCookies: [],
+ headers: {
+ Connection: 'keep-alive',
+ Date: 'Wed, 05 Apr 2023 12:39:13 GMT',
+ 'Keep-Alive': 'timeout=5',
+ Location: 'http://localhost:3000/#from-redirect',
+ 'Transfer-Encoding': 'chunked',
+ },
+ resourceIPAddressSpace: 'Local',
+ statusCode: 302,
+ headersText:
+ 'HTTP/1.1 302 Found\\r\\nLocation: http://localhost:3000/#from-redirect\\r\\nDate: Wed, 05 Apr 2023 12:39:13 GMT\\r\\nConnection: keep-alive\\r\\nKeep-Alive: timeout=5\\r\\nTransfer-Encoding: chunked\\r\\n\\r\\n',
+ cookiePartitionKey: 'http://localhost',
+ cookiePartitionKeyOpaque: false,
+ });
+ mockCDPSession.emit('Network.requestWillBeSent', {
+ requestId: '4C2CC44FB6A6CAC5BE2780BCC9313105',
+ loaderId: '4C2CC44FB6A6CAC5BE2780BCC9313105',
+ documentURL: 'http://localhost:3000/',
+ request: {
+ url: 'http://localhost:3000/',
+ urlFragment: '#from-redirect',
+ method: 'GET',
+ headers: {
+ Referer: 'http://localhost:3000/',
+ 'Upgrade-Insecure-Requests': '1',
+ 'User-Agent':
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36',
+ 'sec-ch-ua-mobile': '?0',
+ },
+ mixedContentType: 'none',
+ initialPriority: 'VeryHigh',
+ referrerPolicy: 'strict-origin-when-cross-origin',
+ isSameSite: true,
+ },
+ timestamp: 31949.988506,
+ wallTime: 1680698353.60069,
+ initiator: {
+ type: 'script',
+ stack: {
+ callFrames: [
+ {
+ functionName: '',
+ scriptId: '5',
+ url: 'http://localhost:3000/',
+ lineNumber: 8,
+ columnNumber: 32,
+ },
+ ],
+ },
+ },
+ redirectHasExtraInfo: true,
+ redirectResponse: {
+ url: 'http://localhost:3000/redirect',
+ status: 302,
+ statusText: 'Found',
+ headers: {
+ Connection: 'keep-alive',
+ Date: 'Wed, 05 Apr 2023 12:39:13 GMT',
+ 'Keep-Alive': 'timeout=5',
+ Location: 'http://localhost:3000/#from-redirect',
+ 'Transfer-Encoding': 'chunked',
+ },
+ mimeType: '',
+ connectionReused: true,
+ connectionId: 34,
+ remoteIPAddress: '127.0.0.1',
+ remotePort: 3000,
+ fromDiskCache: false,
+ fromServiceWorker: false,
+ fromPrefetchCache: false,
+ encodedDataLength: 182,
+ timing: {
+ receiveHeadersStart: 0,
+ requestTime: 31949.983605,
+ proxyStart: -1,
+ proxyEnd: -1,
+ dnsStart: -1,
+ dnsEnd: -1,
+ connectStart: -1,
+ connectEnd: -1,
+ sslStart: -1,
+ sslEnd: -1,
+ workerStart: -1,
+ workerReady: -1,
+ workerFetchStart: -1,
+ workerRespondWithSettled: -1,
+ sendStart: 0.364,
+ sendEnd: 0.401,
+ pushStart: 0,
+ pushEnd: 0,
+ receiveHeadersEnd: 4.085,
+ },
+ responseTime: 1.680698353596548e12,
+ protocol: 'http/1.1',
+ alternateProtocolUsage: 'unspecifiedReason',
+ securityState: 'secure',
+ },
+ type: 'Document',
+ frameId: '4A6E05B1781795F1B586C1F8F8B2CBE4',
+ hasUserGesture: false,
+ });
+ mockCDPSession.emit('Network.requestWillBeSentExtraInfo', {
+ requestId: '4C2CC44FB6A6CAC5BE2780BCC9313105',
+ associatedCookies: [],
+ headers: {},
+ connectTiming: {requestTime: 31949.988855},
+ siteHasCookieInOtherPartition: false,
+ });
+
+ mockCDPSession.emit('Network.responseReceived', {
+ requestId: '4C2CC44FB6A6CAC5BE2780BCC9313105',
+ loaderId: '4C2CC44FB6A6CAC5BE2780BCC9313105',
+ timestamp: 31949.991319,
+ type: 'Document',
+ response: {
+ url: 'http://localhost:3000/',
+ status: 200,
+ statusText: 'OK',
+ headers: {
+ 'Cache-Control': 'max-age=5',
+ 'Content-Type': 'text/html; charset=utf-8',
+ Date: 'Wed, 05 Apr 2023 12:39:13 GMT',
+ },
+ mimeType: 'text/html',
+ connectionReused: false,
+ connectionId: 0,
+ remoteIPAddress: '127.0.0.1',
+ remotePort: 3000,
+ fromDiskCache: true,
+ fromServiceWorker: false,
+ fromPrefetchCache: false,
+ encodedDataLength: 0,
+ timing: {
+ receiveHeadersStart: 0,
+ requestTime: 31949.988855,
+ proxyStart: -1,
+ proxyEnd: -1,
+ dnsStart: -1,
+ dnsEnd: -1,
+ connectStart: -1,
+ connectEnd: -1,
+ sslStart: -1,
+ sslEnd: -1,
+ workerStart: -1,
+ workerReady: -1,
+ workerFetchStart: -1,
+ workerRespondWithSettled: -1,
+ sendStart: 0.069,
+ sendEnd: 0.069,
+ pushStart: 0,
+ pushEnd: 0,
+ receiveHeadersEnd: 0.321,
+ },
+ responseTime: 1.680698353573552e12,
+ protocol: 'http/1.1',
+ alternateProtocolUsage: 'unspecifiedReason',
+ securityState: 'secure',
+ },
+ hasExtraInfo: true,
+ frameId: '4A6E05B1781795F1B586C1F8F8B2CBE4',
+ });
+ mockCDPSession.emit('Network.responseReceivedExtraInfo', {
+ requestId: '4C2CC44FB6A6CAC5BE2780BCC9313105',
+ blockedCookies: [],
+ headers: {
+ Connection: 'keep-alive',
+ Date: 'Wed, 05 Apr 2023 12:39:13 GMT',
+ 'Keep-Alive': 'timeout=5',
+ Location: 'http://localhost:3000/#from-redirect',
+ 'Transfer-Encoding': 'chunked',
+ },
+ resourceIPAddressSpace: 'Local',
+ statusCode: 302,
+ headersText:
+ 'HTTP/1.1 302 Found\\r\\nLocation: http://localhost:3000/#from-redirect\\r\\nDate: Wed, 05 Apr 2023 12:39:13 GMT\\r\\nConnection: keep-alive\\r\\nKeep-Alive: timeout=5\\r\\nTransfer-Encoding: chunked\\r\\n\\r\\n',
+ cookiePartitionKey: 'http://localhost',
+ cookiePartitionKeyOpaque: false,
+ });
+ mockCDPSession.emit('Network.loadingFinished', {
+ requestId: '4C2CC44FB6A6CAC5BE2780BCC9313105',
+ timestamp: 31949.989412,
+ encodedDataLength: 0,
+ });
+ expect(
+ responses.map(r => {
+ return r.status();
+ })
+ ).toEqual([200, 302, 200]);
+ });
+});
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/NetworkManager.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/NetworkManager.ts
new file mode 100644
index 0000000000..8b24b9a748
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/NetworkManager.ts
@@ -0,0 +1,710 @@
+/**
+ * @license
+ * Copyright 2017 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {Protocol} from 'devtools-protocol';
+
+import {CDPSessionEvent, type CDPSession} from '../api/CDPSession.js';
+import type {Frame} from '../api/Frame.js';
+import {EventEmitter, EventSubscription} from '../common/EventEmitter.js';
+import {
+ NetworkManagerEvent,
+ type NetworkManagerEvents,
+} from '../common/NetworkManagerEvents.js';
+import {debugError, isString} from '../common/util.js';
+import {assert} from '../util/assert.js';
+import {DisposableStack} from '../util/disposable.js';
+
+import {CdpHTTPRequest} from './HTTPRequest.js';
+import {CdpHTTPResponse} from './HTTPResponse.js';
+import {
+ NetworkEventManager,
+ type FetchRequestId,
+} from './NetworkEventManager.js';
+
+/**
+ * @public
+ */
+export interface Credentials {
+ username: string;
+ password: string;
+}
+
+/**
+ * @public
+ */
+export interface NetworkConditions {
+ // Download speed (bytes/s)
+ download: number;
+ // Upload speed (bytes/s)
+ upload: number;
+ // Latency (ms)
+ latency: number;
+}
+
+/**
+ * @public
+ */
+export interface InternalNetworkConditions extends NetworkConditions {
+ offline: boolean;
+}
+
+/**
+ * @internal
+ */
+export interface FrameProvider {
+ frame(id: string): Frame | null;
+}
+
+/**
+ * @internal
+ */
+export class NetworkManager extends EventEmitter<NetworkManagerEvents> {
+ #ignoreHTTPSErrors: boolean;
+ #frameManager: FrameProvider;
+ #networkEventManager = new NetworkEventManager();
+ #extraHTTPHeaders?: Record<string, string>;
+ #credentials?: Credentials;
+ #attemptedAuthentications = new Set<string>();
+ #userRequestInterceptionEnabled = false;
+ #protocolRequestInterceptionEnabled = false;
+ #userCacheDisabled?: boolean;
+ #emulatedNetworkConditions?: InternalNetworkConditions;
+ #userAgent?: string;
+ #userAgentMetadata?: Protocol.Emulation.UserAgentMetadata;
+
+ readonly #handlers = [
+ ['Fetch.requestPaused', this.#onRequestPaused],
+ ['Fetch.authRequired', this.#onAuthRequired],
+ ['Network.requestWillBeSent', this.#onRequestWillBeSent],
+ ['Network.requestServedFromCache', this.#onRequestServedFromCache],
+ ['Network.responseReceived', this.#onResponseReceived],
+ ['Network.loadingFinished', this.#onLoadingFinished],
+ ['Network.loadingFailed', this.#onLoadingFailed],
+ ['Network.responseReceivedExtraInfo', this.#onResponseReceivedExtraInfo],
+ [CDPSessionEvent.Disconnected, this.#removeClient],
+ ] as const;
+
+ #clients = new Map<CDPSession, DisposableStack>();
+
+ constructor(ignoreHTTPSErrors: boolean, frameManager: FrameProvider) {
+ super();
+ this.#ignoreHTTPSErrors = ignoreHTTPSErrors;
+ this.#frameManager = frameManager;
+ }
+
+ async addClient(client: CDPSession): Promise<void> {
+ if (this.#clients.has(client)) {
+ return;
+ }
+ const subscriptions = new DisposableStack();
+ this.#clients.set(client, subscriptions);
+ for (const [event, handler] of this.#handlers) {
+ subscriptions.use(
+ // TODO: Remove any here.
+ new EventSubscription(client, event, (arg: any) => {
+ return handler.bind(this)(client, arg);
+ })
+ );
+ }
+ await Promise.all([
+ this.#ignoreHTTPSErrors
+ ? client.send('Security.setIgnoreCertificateErrors', {
+ ignore: true,
+ })
+ : null,
+ client.send('Network.enable'),
+ this.#applyExtraHTTPHeaders(client),
+ this.#applyNetworkConditions(client),
+ this.#applyProtocolCacheDisabled(client),
+ this.#applyProtocolRequestInterception(client),
+ this.#applyUserAgent(client),
+ ]);
+ }
+
+ async #removeClient(client: CDPSession) {
+ this.#clients.get(client)?.dispose();
+ this.#clients.delete(client);
+ }
+
+ async authenticate(credentials?: Credentials): Promise<void> {
+ this.#credentials = credentials;
+ const enabled = this.#userRequestInterceptionEnabled || !!this.#credentials;
+ if (enabled === this.#protocolRequestInterceptionEnabled) {
+ return;
+ }
+ this.#protocolRequestInterceptionEnabled = enabled;
+ await this.#applyToAllClients(
+ this.#applyProtocolRequestInterception.bind(this)
+ );
+ }
+
+ async setExtraHTTPHeaders(
+ extraHTTPHeaders: Record<string, string>
+ ): Promise<void> {
+ this.#extraHTTPHeaders = {};
+ for (const key of Object.keys(extraHTTPHeaders)) {
+ const value = extraHTTPHeaders[key];
+ assert(
+ isString(value),
+ `Expected value of header "${key}" to be String, but "${typeof value}" is found.`
+ );
+ this.#extraHTTPHeaders[key.toLowerCase()] = value;
+ }
+
+ await this.#applyToAllClients(this.#applyExtraHTTPHeaders.bind(this));
+ }
+
+ async #applyExtraHTTPHeaders(client: CDPSession) {
+ if (this.#extraHTTPHeaders === undefined) {
+ return;
+ }
+ await client.send('Network.setExtraHTTPHeaders', {
+ headers: this.#extraHTTPHeaders,
+ });
+ }
+
+ extraHTTPHeaders(): Record<string, string> {
+ return Object.assign({}, this.#extraHTTPHeaders);
+ }
+
+ inFlightRequestsCount(): number {
+ return this.#networkEventManager.inFlightRequestsCount();
+ }
+
+ async setOfflineMode(value: boolean): Promise<void> {
+ if (!this.#emulatedNetworkConditions) {
+ this.#emulatedNetworkConditions = {
+ offline: false,
+ upload: -1,
+ download: -1,
+ latency: 0,
+ };
+ }
+ this.#emulatedNetworkConditions.offline = value;
+ await this.#applyToAllClients(this.#applyNetworkConditions.bind(this));
+ }
+
+ async emulateNetworkConditions(
+ networkConditions: NetworkConditions | null
+ ): Promise<void> {
+ if (!this.#emulatedNetworkConditions) {
+ this.#emulatedNetworkConditions = {
+ offline: false,
+ upload: -1,
+ download: -1,
+ latency: 0,
+ };
+ }
+ this.#emulatedNetworkConditions.upload = networkConditions
+ ? networkConditions.upload
+ : -1;
+ this.#emulatedNetworkConditions.download = networkConditions
+ ? networkConditions.download
+ : -1;
+ this.#emulatedNetworkConditions.latency = networkConditions
+ ? networkConditions.latency
+ : 0;
+
+ await this.#applyToAllClients(this.#applyNetworkConditions.bind(this));
+ }
+
+ async #applyToAllClients(fn: (client: CDPSession) => Promise<unknown>) {
+ await Promise.all(
+ Array.from(this.#clients.keys()).map(client => {
+ return fn(client);
+ })
+ );
+ }
+
+ async #applyNetworkConditions(client: CDPSession): Promise<void> {
+ if (this.#emulatedNetworkConditions === undefined) {
+ return;
+ }
+ await client.send('Network.emulateNetworkConditions', {
+ offline: this.#emulatedNetworkConditions.offline,
+ latency: this.#emulatedNetworkConditions.latency,
+ uploadThroughput: this.#emulatedNetworkConditions.upload,
+ downloadThroughput: this.#emulatedNetworkConditions.download,
+ });
+ }
+
+ async setUserAgent(
+ userAgent: string,
+ userAgentMetadata?: Protocol.Emulation.UserAgentMetadata
+ ): Promise<void> {
+ this.#userAgent = userAgent;
+ this.#userAgentMetadata = userAgentMetadata;
+ await this.#applyToAllClients(this.#applyUserAgent.bind(this));
+ }
+
+ async #applyUserAgent(client: CDPSession) {
+ if (this.#userAgent === undefined) {
+ return;
+ }
+ await client.send('Network.setUserAgentOverride', {
+ userAgent: this.#userAgent,
+ userAgentMetadata: this.#userAgentMetadata,
+ });
+ }
+
+ async setCacheEnabled(enabled: boolean): Promise<void> {
+ this.#userCacheDisabled = !enabled;
+ await this.#applyToAllClients(this.#applyProtocolCacheDisabled.bind(this));
+ }
+
+ async setRequestInterception(value: boolean): Promise<void> {
+ this.#userRequestInterceptionEnabled = value;
+ const enabled = this.#userRequestInterceptionEnabled || !!this.#credentials;
+ if (enabled === this.#protocolRequestInterceptionEnabled) {
+ return;
+ }
+ this.#protocolRequestInterceptionEnabled = enabled;
+ await this.#applyToAllClients(
+ this.#applyProtocolRequestInterception.bind(this)
+ );
+ }
+
+ async #applyProtocolRequestInterception(client: CDPSession): Promise<void> {
+ if (this.#userCacheDisabled === undefined) {
+ this.#userCacheDisabled = false;
+ }
+ if (this.#protocolRequestInterceptionEnabled) {
+ await Promise.all([
+ this.#applyProtocolCacheDisabled(client),
+ client.send('Fetch.enable', {
+ handleAuthRequests: true,
+ patterns: [{urlPattern: '*'}],
+ }),
+ ]);
+ } else {
+ await Promise.all([
+ this.#applyProtocolCacheDisabled(client),
+ client.send('Fetch.disable'),
+ ]);
+ }
+ }
+
+ async #applyProtocolCacheDisabled(client: CDPSession): Promise<void> {
+ if (this.#userCacheDisabled === undefined) {
+ return;
+ }
+ await client.send('Network.setCacheDisabled', {
+ cacheDisabled: this.#userCacheDisabled,
+ });
+ }
+
+ #onRequestWillBeSent(
+ client: CDPSession,
+ event: Protocol.Network.RequestWillBeSentEvent
+ ): void {
+ // Request interception doesn't happen for data URLs with Network Service.
+ if (
+ this.#userRequestInterceptionEnabled &&
+ !event.request.url.startsWith('data:')
+ ) {
+ const {requestId: networkRequestId} = event;
+
+ this.#networkEventManager.storeRequestWillBeSent(networkRequestId, event);
+
+ /**
+ * CDP may have sent a Fetch.requestPaused event already. Check for it.
+ */
+ const requestPausedEvent =
+ this.#networkEventManager.getRequestPaused(networkRequestId);
+ if (requestPausedEvent) {
+ const {requestId: fetchRequestId} = requestPausedEvent;
+ this.#patchRequestEventHeaders(event, requestPausedEvent);
+ this.#onRequest(client, event, fetchRequestId);
+ this.#networkEventManager.forgetRequestPaused(networkRequestId);
+ }
+
+ return;
+ }
+ this.#onRequest(client, event, undefined);
+ }
+
+ #onAuthRequired(
+ client: CDPSession,
+ event: Protocol.Fetch.AuthRequiredEvent
+ ): void {
+ let response: Protocol.Fetch.AuthChallengeResponse['response'] = 'Default';
+ if (this.#attemptedAuthentications.has(event.requestId)) {
+ response = 'CancelAuth';
+ } else if (this.#credentials) {
+ response = 'ProvideCredentials';
+ this.#attemptedAuthentications.add(event.requestId);
+ }
+ const {username, password} = this.#credentials || {
+ username: undefined,
+ password: undefined,
+ };
+ client
+ .send('Fetch.continueWithAuth', {
+ requestId: event.requestId,
+ authChallengeResponse: {response, username, password},
+ })
+ .catch(debugError);
+ }
+
+ /**
+ * CDP may send a Fetch.requestPaused without or before a
+ * Network.requestWillBeSent
+ *
+ * CDP may send multiple Fetch.requestPaused
+ * for the same Network.requestWillBeSent.
+ */
+ #onRequestPaused(
+ client: CDPSession,
+ event: Protocol.Fetch.RequestPausedEvent
+ ): void {
+ if (
+ !this.#userRequestInterceptionEnabled &&
+ this.#protocolRequestInterceptionEnabled
+ ) {
+ client
+ .send('Fetch.continueRequest', {
+ requestId: event.requestId,
+ })
+ .catch(debugError);
+ }
+
+ const {networkId: networkRequestId, requestId: fetchRequestId} = event;
+
+ if (!networkRequestId) {
+ this.#onRequestWithoutNetworkInstrumentation(client, event);
+ return;
+ }
+
+ const requestWillBeSentEvent = (() => {
+ const requestWillBeSentEvent =
+ this.#networkEventManager.getRequestWillBeSent(networkRequestId);
+
+ // redirect requests have the same `requestId`,
+ if (
+ requestWillBeSentEvent &&
+ (requestWillBeSentEvent.request.url !== event.request.url ||
+ requestWillBeSentEvent.request.method !== event.request.method)
+ ) {
+ this.#networkEventManager.forgetRequestWillBeSent(networkRequestId);
+ return;
+ }
+ return requestWillBeSentEvent;
+ })();
+
+ if (requestWillBeSentEvent) {
+ this.#patchRequestEventHeaders(requestWillBeSentEvent, event);
+ this.#onRequest(client, requestWillBeSentEvent, fetchRequestId);
+ } else {
+ this.#networkEventManager.storeRequestPaused(networkRequestId, event);
+ }
+ }
+
+ #patchRequestEventHeaders(
+ requestWillBeSentEvent: Protocol.Network.RequestWillBeSentEvent,
+ requestPausedEvent: Protocol.Fetch.RequestPausedEvent
+ ): void {
+ requestWillBeSentEvent.request.headers = {
+ ...requestWillBeSentEvent.request.headers,
+ // includes extra headers, like: Accept, Origin
+ ...requestPausedEvent.request.headers,
+ };
+ }
+
+ #onRequestWithoutNetworkInstrumentation(
+ client: CDPSession,
+ event: Protocol.Fetch.RequestPausedEvent
+ ): void {
+ // If an event has no networkId it should not have any network events. We
+ // still want to dispatch it for the interception by the user.
+ const frame = event.frameId
+ ? this.#frameManager.frame(event.frameId)
+ : null;
+
+ const request = new CdpHTTPRequest(
+ client,
+ frame,
+ event.requestId,
+ this.#userRequestInterceptionEnabled,
+ event,
+ []
+ );
+ this.emit(NetworkManagerEvent.Request, request);
+ void request.finalizeInterceptions();
+ }
+
+ #onRequest(
+ client: CDPSession,
+ event: Protocol.Network.RequestWillBeSentEvent,
+ fetchRequestId?: FetchRequestId
+ ): void {
+ let redirectChain: CdpHTTPRequest[] = [];
+ if (event.redirectResponse) {
+ // We want to emit a response and requestfinished for the
+ // redirectResponse, but we can't do so unless we have a
+ // responseExtraInfo ready to pair it up with. If we don't have any
+ // responseExtraInfos saved in our queue, they we have to wait until
+ // the next one to emit response and requestfinished, *and* we should
+ // also wait to emit this Request too because it should come after the
+ // response/requestfinished.
+ let redirectResponseExtraInfo = null;
+ if (event.redirectHasExtraInfo) {
+ redirectResponseExtraInfo = this.#networkEventManager
+ .responseExtraInfo(event.requestId)
+ .shift();
+ if (!redirectResponseExtraInfo) {
+ this.#networkEventManager.queueRedirectInfo(event.requestId, {
+ event,
+ fetchRequestId,
+ });
+ return;
+ }
+ }
+
+ const request = this.#networkEventManager.getRequest(event.requestId);
+ // If we connect late to the target, we could have missed the
+ // requestWillBeSent event.
+ if (request) {
+ this.#handleRequestRedirect(
+ client,
+ request,
+ event.redirectResponse,
+ redirectResponseExtraInfo
+ );
+ redirectChain = request._redirectChain;
+ }
+ }
+ const frame = event.frameId
+ ? this.#frameManager.frame(event.frameId)
+ : null;
+
+ const request = new CdpHTTPRequest(
+ client,
+ frame,
+ fetchRequestId,
+ this.#userRequestInterceptionEnabled,
+ event,
+ redirectChain
+ );
+ this.#networkEventManager.storeRequest(event.requestId, request);
+ this.emit(NetworkManagerEvent.Request, request);
+ void request.finalizeInterceptions();
+ }
+
+ #onRequestServedFromCache(
+ _client: CDPSession,
+ event: Protocol.Network.RequestServedFromCacheEvent
+ ): void {
+ const request = this.#networkEventManager.getRequest(event.requestId);
+ if (request) {
+ request._fromMemoryCache = true;
+ }
+ this.emit(NetworkManagerEvent.RequestServedFromCache, request);
+ }
+
+ #handleRequestRedirect(
+ client: CDPSession,
+ request: CdpHTTPRequest,
+ responsePayload: Protocol.Network.Response,
+ extraInfo: Protocol.Network.ResponseReceivedExtraInfoEvent | null
+ ): void {
+ const response = new CdpHTTPResponse(
+ client,
+ request,
+ responsePayload,
+ extraInfo
+ );
+ request._response = response;
+ request._redirectChain.push(request);
+ response._resolveBody(
+ new Error('Response body is unavailable for redirect responses')
+ );
+ this.#forgetRequest(request, false);
+ this.emit(NetworkManagerEvent.Response, response);
+ this.emit(NetworkManagerEvent.RequestFinished, request);
+ }
+
+ #emitResponseEvent(
+ client: CDPSession,
+ responseReceived: Protocol.Network.ResponseReceivedEvent,
+ extraInfo: Protocol.Network.ResponseReceivedExtraInfoEvent | null
+ ): void {
+ const request = this.#networkEventManager.getRequest(
+ responseReceived.requestId
+ );
+ // FileUpload sends a response without a matching request.
+ if (!request) {
+ return;
+ }
+
+ const extraInfos = this.#networkEventManager.responseExtraInfo(
+ responseReceived.requestId
+ );
+ if (extraInfos.length) {
+ debugError(
+ new Error(
+ 'Unexpected extraInfo events for request ' +
+ responseReceived.requestId
+ )
+ );
+ }
+
+ // Chromium sends wrong extraInfo events for responses served from cache.
+ // See https://github.com/puppeteer/puppeteer/issues/9965 and
+ // https://crbug.com/1340398.
+ if (responseReceived.response.fromDiskCache) {
+ extraInfo = null;
+ }
+
+ const response = new CdpHTTPResponse(
+ client,
+ request,
+ responseReceived.response,
+ extraInfo
+ );
+ request._response = response;
+ this.emit(NetworkManagerEvent.Response, response);
+ }
+
+ #onResponseReceived(
+ client: CDPSession,
+ event: Protocol.Network.ResponseReceivedEvent
+ ): void {
+ const request = this.#networkEventManager.getRequest(event.requestId);
+ let extraInfo = null;
+ if (request && !request._fromMemoryCache && event.hasExtraInfo) {
+ extraInfo = this.#networkEventManager
+ .responseExtraInfo(event.requestId)
+ .shift();
+ if (!extraInfo) {
+ // Wait until we get the corresponding ExtraInfo event.
+ this.#networkEventManager.queueEventGroup(event.requestId, {
+ responseReceivedEvent: event,
+ });
+ return;
+ }
+ }
+ this.#emitResponseEvent(client, event, extraInfo);
+ }
+
+ #onResponseReceivedExtraInfo(
+ client: CDPSession,
+ event: Protocol.Network.ResponseReceivedExtraInfoEvent
+ ): void {
+ // We may have skipped a redirect response/request pair due to waiting for
+ // this ExtraInfo event. If so, continue that work now that we have the
+ // request.
+ const redirectInfo = this.#networkEventManager.takeQueuedRedirectInfo(
+ event.requestId
+ );
+ if (redirectInfo) {
+ this.#networkEventManager.responseExtraInfo(event.requestId).push(event);
+ this.#onRequest(client, redirectInfo.event, redirectInfo.fetchRequestId);
+ return;
+ }
+
+ // We may have skipped response and loading events because we didn't have
+ // this ExtraInfo event yet. If so, emit those events now.
+ const queuedEvents = this.#networkEventManager.getQueuedEventGroup(
+ event.requestId
+ );
+ if (queuedEvents) {
+ this.#networkEventManager.forgetQueuedEventGroup(event.requestId);
+ this.#emitResponseEvent(
+ client,
+ queuedEvents.responseReceivedEvent,
+ event
+ );
+ if (queuedEvents.loadingFinishedEvent) {
+ this.#emitLoadingFinished(queuedEvents.loadingFinishedEvent);
+ }
+ if (queuedEvents.loadingFailedEvent) {
+ this.#emitLoadingFailed(queuedEvents.loadingFailedEvent);
+ }
+ return;
+ }
+
+ // Wait until we get another event that can use this ExtraInfo event.
+ this.#networkEventManager.responseExtraInfo(event.requestId).push(event);
+ }
+
+ #forgetRequest(request: CdpHTTPRequest, events: boolean): void {
+ const requestId = request._requestId;
+ const interceptionId = request._interceptionId;
+
+ this.#networkEventManager.forgetRequest(requestId);
+ interceptionId !== undefined &&
+ this.#attemptedAuthentications.delete(interceptionId);
+
+ if (events) {
+ this.#networkEventManager.forget(requestId);
+ }
+ }
+
+ #onLoadingFinished(
+ _client: CDPSession,
+ event: Protocol.Network.LoadingFinishedEvent
+ ): void {
+ // If the response event for this request is still waiting on a
+ // corresponding ExtraInfo event, then wait to emit this event too.
+ const queuedEvents = this.#networkEventManager.getQueuedEventGroup(
+ event.requestId
+ );
+ if (queuedEvents) {
+ queuedEvents.loadingFinishedEvent = event;
+ } else {
+ this.#emitLoadingFinished(event);
+ }
+ }
+
+ #emitLoadingFinished(event: Protocol.Network.LoadingFinishedEvent): void {
+ const request = this.#networkEventManager.getRequest(event.requestId);
+ // For certain requestIds we never receive requestWillBeSent event.
+ // @see https://crbug.com/750469
+ if (!request) {
+ return;
+ }
+
+ // Under certain conditions we never get the Network.responseReceived
+ // event from protocol. @see https://crbug.com/883475
+ if (request.response()) {
+ request.response()?._resolveBody();
+ }
+ this.#forgetRequest(request, true);
+ this.emit(NetworkManagerEvent.RequestFinished, request);
+ }
+
+ #onLoadingFailed(
+ _client: CDPSession,
+ event: Protocol.Network.LoadingFailedEvent
+ ): void {
+ // If the response event for this request is still waiting on a
+ // corresponding ExtraInfo event, then wait to emit this event too.
+ const queuedEvents = this.#networkEventManager.getQueuedEventGroup(
+ event.requestId
+ );
+ if (queuedEvents) {
+ queuedEvents.loadingFailedEvent = event;
+ } else {
+ this.#emitLoadingFailed(event);
+ }
+ }
+
+ #emitLoadingFailed(event: Protocol.Network.LoadingFailedEvent): void {
+ const request = this.#networkEventManager.getRequest(event.requestId);
+ // For certain requestIds we never receive requestWillBeSent event.
+ // @see https://crbug.com/750469
+ if (!request) {
+ return;
+ }
+ request._failureText = event.errorText;
+ const response = request.response();
+ if (response) {
+ response._resolveBody();
+ }
+ this.#forgetRequest(request, true);
+ this.emit(NetworkManagerEvent.RequestFailed, request);
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Page.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Page.ts
new file mode 100644
index 0000000000..491637f0ea
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Page.ts
@@ -0,0 +1,1249 @@
+/**
+ * @license
+ * Copyright 2017 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {Readable} from 'stream';
+
+import type {Protocol} from 'devtools-protocol';
+
+import {firstValueFrom, from, raceWith} from '../../third_party/rxjs/rxjs.js';
+import type {Browser} from '../api/Browser.js';
+import type {BrowserContext} from '../api/BrowserContext.js';
+import {CDPSessionEvent, type CDPSession} from '../api/CDPSession.js';
+import type {ElementHandle} from '../api/ElementHandle.js';
+import type {Frame, WaitForOptions} from '../api/Frame.js';
+import type {HTTPRequest} from '../api/HTTPRequest.js';
+import type {HTTPResponse} from '../api/HTTPResponse.js';
+import type {JSHandle} from '../api/JSHandle.js';
+import {
+ Page,
+ PageEvent,
+ type GeolocationOptions,
+ type MediaFeature,
+ type Metrics,
+ type NewDocumentScriptEvaluation,
+ type ScreenshotClip,
+ type ScreenshotOptions,
+ type WaitTimeoutOptions,
+} from '../api/Page.js';
+import {
+ ConsoleMessage,
+ type ConsoleMessageType,
+} from '../common/ConsoleMessage.js';
+import {TargetCloseError} from '../common/Errors.js';
+import {FileChooser} from '../common/FileChooser.js';
+import {NetworkManagerEvent} from '../common/NetworkManagerEvents.js';
+import type {PDFOptions} from '../common/PDFOptions.js';
+import type {BindingPayload, HandleFor} from '../common/types.js';
+import {
+ debugError,
+ evaluationString,
+ getReadableAsBuffer,
+ getReadableFromProtocolStream,
+ parsePDFOptions,
+ timeout,
+ validateDialogType,
+} from '../common/util.js';
+import type {Viewport} from '../common/Viewport.js';
+import {assert} from '../util/assert.js';
+import {Deferred} from '../util/Deferred.js';
+import {AsyncDisposableStack} from '../util/disposable.js';
+import {isErrorLike} from '../util/ErrorLike.js';
+
+import {Accessibility} from './Accessibility.js';
+import {Binding} from './Binding.js';
+import {CdpCDPSession} from './CDPSession.js';
+import {isTargetClosedError} from './Connection.js';
+import {Coverage} from './Coverage.js';
+import type {DeviceRequestPrompt} from './DeviceRequestPrompt.js';
+import {CdpDialog} from './Dialog.js';
+import {EmulationManager} from './EmulationManager.js';
+import {createCdpHandle} from './ExecutionContext.js';
+import {FirefoxTargetManager} from './FirefoxTargetManager.js';
+import type {CdpFrame} from './Frame.js';
+import {FrameManager} from './FrameManager.js';
+import {FrameManagerEvent} from './FrameManagerEvents.js';
+import {CdpKeyboard, CdpMouse, CdpTouchscreen} from './Input.js';
+import {MAIN_WORLD} from './IsolatedWorlds.js';
+import {releaseObject} from './JSHandle.js';
+import type {Credentials, NetworkConditions} from './NetworkManager.js';
+import type {CdpTarget} from './Target.js';
+import type {TargetManager} from './TargetManager.js';
+import {TargetManagerEvent} from './TargetManager.js';
+import {Tracing} from './Tracing.js';
+import {
+ createClientError,
+ pageBindingInitString,
+ valueFromRemoteObject,
+} from './utils.js';
+import {CdpWebWorker} from './WebWorker.js';
+
+/**
+ * @internal
+ */
+export class CdpPage extends Page {
+ static async _create(
+ client: CDPSession,
+ target: CdpTarget,
+ ignoreHTTPSErrors: boolean,
+ defaultViewport: Viewport | null
+ ): Promise<CdpPage> {
+ const page = new CdpPage(client, target, ignoreHTTPSErrors);
+ await page.#initialize();
+ if (defaultViewport) {
+ try {
+ await page.setViewport(defaultViewport);
+ } catch (err) {
+ if (isErrorLike(err) && isTargetClosedError(err)) {
+ debugError(err);
+ } else {
+ throw err;
+ }
+ }
+ }
+ return page;
+ }
+
+ #closed = false;
+ readonly #targetManager: TargetManager;
+
+ #primaryTargetClient: CDPSession;
+ #primaryTarget: CdpTarget;
+ #tabTargetClient: CDPSession;
+ #tabTarget: CdpTarget;
+ #keyboard: CdpKeyboard;
+ #mouse: CdpMouse;
+ #touchscreen: CdpTouchscreen;
+ #accessibility: Accessibility;
+ #frameManager: FrameManager;
+ #emulationManager: EmulationManager;
+ #tracing: Tracing;
+ #bindings = new Map<string, Binding>();
+ #exposedFunctions = new Map<string, string>();
+ #coverage: Coverage;
+ #viewport: Viewport | null;
+ #workers = new Map<string, CdpWebWorker>();
+ #fileChooserDeferreds = new Set<Deferred<FileChooser>>();
+ #sessionCloseDeferred = Deferred.create<never, TargetCloseError>();
+ #serviceWorkerBypassed = false;
+ #userDragInterceptionEnabled = false;
+
+ readonly #frameManagerHandlers = [
+ [
+ FrameManagerEvent.FrameAttached,
+ (frame: CdpFrame) => {
+ this.emit(PageEvent.FrameAttached, frame);
+ },
+ ],
+ [
+ FrameManagerEvent.FrameDetached,
+ (frame: CdpFrame) => {
+ this.emit(PageEvent.FrameDetached, frame);
+ },
+ ],
+ [
+ FrameManagerEvent.FrameNavigated,
+ (frame: CdpFrame) => {
+ this.emit(PageEvent.FrameNavigated, frame);
+ },
+ ],
+ ] as const;
+
+ readonly #networkManagerHandlers = [
+ [
+ NetworkManagerEvent.Request,
+ (request: HTTPRequest) => {
+ this.emit(PageEvent.Request, request);
+ },
+ ],
+ [
+ NetworkManagerEvent.RequestServedFromCache,
+ (request: HTTPRequest) => {
+ this.emit(PageEvent.RequestServedFromCache, request);
+ },
+ ],
+ [
+ NetworkManagerEvent.Response,
+ (response: HTTPResponse) => {
+ this.emit(PageEvent.Response, response);
+ },
+ ],
+ [
+ NetworkManagerEvent.RequestFailed,
+ (request: HTTPRequest) => {
+ this.emit(PageEvent.RequestFailed, request);
+ },
+ ],
+ [
+ NetworkManagerEvent.RequestFinished,
+ (request: HTTPRequest) => {
+ this.emit(PageEvent.RequestFinished, request);
+ },
+ ],
+ ] as const;
+
+ readonly #sessionHandlers = [
+ [
+ CDPSessionEvent.Disconnected,
+ () => {
+ this.#sessionCloseDeferred.reject(
+ new TargetCloseError('Target closed')
+ );
+ },
+ ],
+ [
+ 'Page.domContentEventFired',
+ () => {
+ return this.emit(PageEvent.DOMContentLoaded, undefined);
+ },
+ ],
+ [
+ 'Page.loadEventFired',
+ () => {
+ return this.emit(PageEvent.Load, undefined);
+ },
+ ],
+ ['Runtime.consoleAPICalled', this.#onConsoleAPI.bind(this)],
+ ['Runtime.bindingCalled', this.#onBindingCalled.bind(this)],
+ ['Page.javascriptDialogOpening', this.#onDialog.bind(this)],
+ ['Runtime.exceptionThrown', this.#handleException.bind(this)],
+ ['Inspector.targetCrashed', this.#onTargetCrashed.bind(this)],
+ ['Performance.metrics', this.#emitMetrics.bind(this)],
+ ['Log.entryAdded', this.#onLogEntryAdded.bind(this)],
+ ['Page.fileChooserOpened', this.#onFileChooser.bind(this)],
+ ] as const;
+
+ constructor(
+ client: CDPSession,
+ target: CdpTarget,
+ ignoreHTTPSErrors: boolean
+ ) {
+ super();
+ this.#primaryTargetClient = client;
+ this.#tabTargetClient = client.parentSession()!;
+ assert(this.#tabTargetClient, 'Tab target session is not defined.');
+ this.#tabTarget = (this.#tabTargetClient as CdpCDPSession)._target();
+ assert(this.#tabTarget, 'Tab target is not defined.');
+ this.#primaryTarget = target;
+ this.#targetManager = target._targetManager();
+ this.#keyboard = new CdpKeyboard(client);
+ this.#mouse = new CdpMouse(client, this.#keyboard);
+ this.#touchscreen = new CdpTouchscreen(client, this.#keyboard);
+ this.#accessibility = new Accessibility(client);
+ this.#frameManager = new FrameManager(
+ client,
+ this,
+ ignoreHTTPSErrors,
+ this._timeoutSettings
+ );
+ this.#emulationManager = new EmulationManager(client);
+ this.#tracing = new Tracing(client);
+ this.#coverage = new Coverage(client);
+ this.#viewport = null;
+
+ for (const [eventName, handler] of this.#frameManagerHandlers) {
+ this.#frameManager.on(eventName, handler);
+ }
+
+ for (const [eventName, handler] of this.#networkManagerHandlers) {
+ // TODO: Remove any.
+ this.#frameManager.networkManager.on(eventName, handler as any);
+ }
+
+ this.#tabTargetClient.on(
+ CDPSessionEvent.Swapped,
+ this.#onActivation.bind(this)
+ );
+
+ this.#tabTargetClient.on(
+ CDPSessionEvent.Ready,
+ this.#onSecondaryTarget.bind(this)
+ );
+
+ this.#targetManager.on(
+ TargetManagerEvent.TargetGone,
+ this.#onDetachedFromTarget
+ );
+
+ this.#tabTarget._isClosedDeferred
+ .valueOrThrow()
+ .then(() => {
+ this.#targetManager.off(
+ TargetManagerEvent.TargetGone,
+ this.#onDetachedFromTarget
+ );
+
+ this.emit(PageEvent.Close, undefined);
+ this.#closed = true;
+ })
+ .catch(debugError);
+
+ this.#setupPrimaryTargetListeners();
+ }
+
+ async #onActivation(newSession: CDPSession): Promise<void> {
+ this.#primaryTargetClient = newSession;
+ assert(
+ this.#primaryTargetClient instanceof CdpCDPSession,
+ 'CDPSession is not instance of CDPSessionImpl'
+ );
+ this.#primaryTarget = this.#primaryTargetClient._target();
+ assert(this.#primaryTarget, 'Missing target on swap');
+ this.#keyboard.updateClient(newSession);
+ this.#mouse.updateClient(newSession);
+ this.#touchscreen.updateClient(newSession);
+ this.#accessibility.updateClient(newSession);
+ this.#emulationManager.updateClient(newSession);
+ this.#tracing.updateClient(newSession);
+ this.#coverage.updateClient(newSession);
+ await this.#frameManager.swapFrameTree(newSession);
+ this.#setupPrimaryTargetListeners();
+ }
+
+ async #onSecondaryTarget(session: CDPSession): Promise<void> {
+ assert(session instanceof CdpCDPSession);
+ if (session._target()._subtype() !== 'prerender') {
+ return;
+ }
+ this.#frameManager.registerSpeculativeSession(session).catch(debugError);
+ this.#emulationManager
+ .registerSpeculativeSession(session)
+ .catch(debugError);
+ }
+
+ /**
+ * Sets up listeners for the primary target. The primary target can change
+ * during a navigation to a prerended page.
+ */
+ #setupPrimaryTargetListeners() {
+ this.#primaryTargetClient.on(
+ CDPSessionEvent.Ready,
+ this.#onAttachedToTarget
+ );
+
+ for (const [eventName, handler] of this.#sessionHandlers) {
+ // TODO: Remove any.
+ this.#primaryTargetClient.on(eventName, handler as any);
+ }
+ }
+
+ #onDetachedFromTarget = (target: CdpTarget) => {
+ const sessionId = target._session()?.id();
+ const worker = this.#workers.get(sessionId!);
+ if (!worker) {
+ return;
+ }
+ this.#workers.delete(sessionId!);
+ this.emit(PageEvent.WorkerDestroyed, worker);
+ };
+
+ #onAttachedToTarget = (session: CDPSession) => {
+ assert(session instanceof CdpCDPSession);
+ this.#frameManager.onAttachedToTarget(session._target());
+ if (session._target()._getTargetInfo().type === 'worker') {
+ const worker = new CdpWebWorker(
+ session,
+ session._target().url(),
+ this.#addConsoleMessage.bind(this),
+ this.#handleException.bind(this)
+ );
+ this.#workers.set(session.id(), worker);
+ this.emit(PageEvent.WorkerCreated, worker);
+ }
+ session.on(CDPSessionEvent.Ready, this.#onAttachedToTarget);
+ };
+
+ async #initialize(): Promise<void> {
+ try {
+ await Promise.all([
+ this.#frameManager.initialize(this.#primaryTargetClient),
+ this.#primaryTargetClient.send('Performance.enable'),
+ this.#primaryTargetClient.send('Log.enable'),
+ ]);
+ } catch (err) {
+ if (isErrorLike(err) && isTargetClosedError(err)) {
+ debugError(err);
+ } else {
+ throw err;
+ }
+ }
+ }
+
+ async #onFileChooser(
+ event: Protocol.Page.FileChooserOpenedEvent
+ ): Promise<void> {
+ if (!this.#fileChooserDeferreds.size) {
+ return;
+ }
+
+ const frame = this.#frameManager.frame(event.frameId);
+ assert(frame, 'This should never happen.');
+
+ // This is guaranteed to be an HTMLInputElement handle by the event.
+ using handle = (await frame.worlds[MAIN_WORLD].adoptBackendNode(
+ event.backendNodeId
+ )) as ElementHandle<HTMLInputElement>;
+
+ const fileChooser = new FileChooser(handle.move(), event);
+ for (const promise of this.#fileChooserDeferreds) {
+ promise.resolve(fileChooser);
+ }
+ this.#fileChooserDeferreds.clear();
+ }
+
+ _client(): CDPSession {
+ return this.#primaryTargetClient;
+ }
+
+ override isServiceWorkerBypassed(): boolean {
+ return this.#serviceWorkerBypassed;
+ }
+
+ override isDragInterceptionEnabled(): boolean {
+ return this.#userDragInterceptionEnabled;
+ }
+
+ override isJavaScriptEnabled(): boolean {
+ return this.#emulationManager.javascriptEnabled;
+ }
+
+ override async waitForFileChooser(
+ options: WaitTimeoutOptions = {}
+ ): Promise<FileChooser> {
+ const needsEnable = this.#fileChooserDeferreds.size === 0;
+ const {timeout = this._timeoutSettings.timeout()} = options;
+ const deferred = Deferred.create<FileChooser>({
+ message: `Waiting for \`FileChooser\` failed: ${timeout}ms exceeded`,
+ timeout,
+ });
+ this.#fileChooserDeferreds.add(deferred);
+ let enablePromise: Promise<void> | undefined;
+ if (needsEnable) {
+ enablePromise = this.#primaryTargetClient.send(
+ 'Page.setInterceptFileChooserDialog',
+ {
+ enabled: true,
+ }
+ );
+ }
+ try {
+ const [result] = await Promise.all([
+ deferred.valueOrThrow(),
+ enablePromise,
+ ]);
+ return result;
+ } catch (error) {
+ this.#fileChooserDeferreds.delete(deferred);
+ throw error;
+ }
+ }
+
+ override async setGeolocation(options: GeolocationOptions): Promise<void> {
+ return await this.#emulationManager.setGeolocation(options);
+ }
+
+ override target(): CdpTarget {
+ return this.#primaryTarget;
+ }
+
+ override browser(): Browser {
+ return this.#primaryTarget.browser();
+ }
+
+ override browserContext(): BrowserContext {
+ return this.#primaryTarget.browserContext();
+ }
+
+ #onTargetCrashed(): void {
+ this.emit(PageEvent.Error, new Error('Page crashed!'));
+ }
+
+ #onLogEntryAdded(event: Protocol.Log.EntryAddedEvent): void {
+ const {level, text, args, source, url, lineNumber} = event.entry;
+ if (args) {
+ args.map(arg => {
+ void releaseObject(this.#primaryTargetClient, arg);
+ });
+ }
+ if (source !== 'worker') {
+ this.emit(
+ PageEvent.Console,
+ new ConsoleMessage(level, text, [], [{url, lineNumber}])
+ );
+ }
+ }
+
+ override mainFrame(): CdpFrame {
+ return this.#frameManager.mainFrame();
+ }
+
+ override get keyboard(): CdpKeyboard {
+ return this.#keyboard;
+ }
+
+ override get touchscreen(): CdpTouchscreen {
+ return this.#touchscreen;
+ }
+
+ override get coverage(): Coverage {
+ return this.#coverage;
+ }
+
+ override get tracing(): Tracing {
+ return this.#tracing;
+ }
+
+ override get accessibility(): Accessibility {
+ return this.#accessibility;
+ }
+
+ override frames(): Frame[] {
+ return this.#frameManager.frames();
+ }
+
+ override workers(): CdpWebWorker[] {
+ return Array.from(this.#workers.values());
+ }
+
+ override async setRequestInterception(value: boolean): Promise<void> {
+ return await this.#frameManager.networkManager.setRequestInterception(
+ value
+ );
+ }
+
+ override async setBypassServiceWorker(bypass: boolean): Promise<void> {
+ this.#serviceWorkerBypassed = bypass;
+ return await this.#primaryTargetClient.send(
+ 'Network.setBypassServiceWorker',
+ {bypass}
+ );
+ }
+
+ override async setDragInterception(enabled: boolean): Promise<void> {
+ this.#userDragInterceptionEnabled = enabled;
+ return await this.#primaryTargetClient.send('Input.setInterceptDrags', {
+ enabled,
+ });
+ }
+
+ override async setOfflineMode(enabled: boolean): Promise<void> {
+ return await this.#frameManager.networkManager.setOfflineMode(enabled);
+ }
+
+ override async emulateNetworkConditions(
+ networkConditions: NetworkConditions | null
+ ): Promise<void> {
+ return await this.#frameManager.networkManager.emulateNetworkConditions(
+ networkConditions
+ );
+ }
+
+ override setDefaultNavigationTimeout(timeout: number): void {
+ this._timeoutSettings.setDefaultNavigationTimeout(timeout);
+ }
+
+ override setDefaultTimeout(timeout: number): void {
+ this._timeoutSettings.setDefaultTimeout(timeout);
+ }
+
+ override getDefaultTimeout(): number {
+ return this._timeoutSettings.timeout();
+ }
+
+ override async queryObjects<Prototype>(
+ prototypeHandle: JSHandle<Prototype>
+ ): Promise<JSHandle<Prototype[]>> {
+ assert(!prototypeHandle.disposed, 'Prototype JSHandle is disposed!');
+ assert(
+ prototypeHandle.id,
+ 'Prototype JSHandle must not be referencing primitive value'
+ );
+ const response = await this.mainFrame().client.send(
+ 'Runtime.queryObjects',
+ {
+ prototypeObjectId: prototypeHandle.id,
+ }
+ );
+ return createCdpHandle(
+ this.mainFrame().mainRealm(),
+ response.objects
+ ) as HandleFor<Prototype[]>;
+ }
+
+ override async cookies(
+ ...urls: string[]
+ ): Promise<Protocol.Network.Cookie[]> {
+ const originalCookies = (
+ await this.#primaryTargetClient.send('Network.getCookies', {
+ urls: urls.length ? urls : [this.url()],
+ })
+ ).cookies;
+
+ const unsupportedCookieAttributes = ['priority'];
+ const filterUnsupportedAttributes = (
+ cookie: Protocol.Network.Cookie
+ ): Protocol.Network.Cookie => {
+ for (const attr of unsupportedCookieAttributes) {
+ delete (cookie as unknown as Record<string, unknown>)[attr];
+ }
+ return cookie;
+ };
+ return originalCookies.map(filterUnsupportedAttributes);
+ }
+
+ override async deleteCookie(
+ ...cookies: Protocol.Network.DeleteCookiesRequest[]
+ ): Promise<void> {
+ const pageURL = this.url();
+ for (const cookie of cookies) {
+ const item = Object.assign({}, cookie);
+ if (!cookie.url && pageURL.startsWith('http')) {
+ item.url = pageURL;
+ }
+ await this.#primaryTargetClient.send('Network.deleteCookies', item);
+ }
+ }
+
+ override async setCookie(
+ ...cookies: Protocol.Network.CookieParam[]
+ ): Promise<void> {
+ const pageURL = this.url();
+ const startsWithHTTP = pageURL.startsWith('http');
+ const items = cookies.map(cookie => {
+ const item = Object.assign({}, cookie);
+ if (!item.url && startsWithHTTP) {
+ item.url = pageURL;
+ }
+ assert(
+ item.url !== 'about:blank',
+ `Blank page can not have cookie "${item.name}"`
+ );
+ assert(
+ !String.prototype.startsWith.call(item.url || '', 'data:'),
+ `Data URL page can not have cookie "${item.name}"`
+ );
+ return item;
+ });
+ await this.deleteCookie(...items);
+ if (items.length) {
+ await this.#primaryTargetClient.send('Network.setCookies', {
+ cookies: items,
+ });
+ }
+ }
+
+ override async exposeFunction(
+ name: string,
+ pptrFunction: Function | {default: Function}
+ ): Promise<void> {
+ if (this.#bindings.has(name)) {
+ throw new Error(
+ `Failed to add page binding with name ${name}: window['${name}'] already exists!`
+ );
+ }
+
+ let binding: Binding;
+ switch (typeof pptrFunction) {
+ case 'function':
+ binding = new Binding(
+ name,
+ pptrFunction as (...args: unknown[]) => unknown
+ );
+ break;
+ default:
+ binding = new Binding(
+ name,
+ pptrFunction.default as (...args: unknown[]) => unknown
+ );
+ break;
+ }
+
+ this.#bindings.set(name, binding);
+
+ const expression = pageBindingInitString('exposedFun', name);
+ await this.#primaryTargetClient.send('Runtime.addBinding', {name});
+ // TODO: investigate this as it appears to only apply to the main frame and
+ // local subframes instead of the entire frame tree (including future
+ // frame).
+ const {identifier} = await this.#primaryTargetClient.send(
+ 'Page.addScriptToEvaluateOnNewDocument',
+ {
+ source: expression,
+ }
+ );
+
+ this.#exposedFunctions.set(name, identifier);
+
+ await Promise.all(
+ this.frames().map(frame => {
+ // If a frame has not started loading, it might never start. Rely on
+ // addScriptToEvaluateOnNewDocument in that case.
+ if (frame !== this.mainFrame() && !frame._hasStartedLoading) {
+ return;
+ }
+ return frame.evaluate(expression).catch(debugError);
+ })
+ );
+ }
+
+ override async removeExposedFunction(name: string): Promise<void> {
+ const exposedFun = this.#exposedFunctions.get(name);
+ if (!exposedFun) {
+ throw new Error(
+ `Failed to remove page binding with name ${name}: window['${name}'] does not exists!`
+ );
+ }
+
+ await this.#primaryTargetClient.send('Runtime.removeBinding', {name});
+ await this.removeScriptToEvaluateOnNewDocument(exposedFun);
+
+ await Promise.all(
+ this.frames().map(frame => {
+ // If a frame has not started loading, it might never start. Rely on
+ // addScriptToEvaluateOnNewDocument in that case.
+ if (frame !== this.mainFrame() && !frame._hasStartedLoading) {
+ return;
+ }
+ return frame
+ .evaluate(name => {
+ // Removes the dangling Puppeteer binding wrapper.
+ // @ts-expect-error: In a different context.
+ globalThis[name] = undefined;
+ }, name)
+ .catch(debugError);
+ })
+ );
+
+ this.#exposedFunctions.delete(name);
+ this.#bindings.delete(name);
+ }
+
+ override async authenticate(credentials: Credentials): Promise<void> {
+ return await this.#frameManager.networkManager.authenticate(credentials);
+ }
+
+ override async setExtraHTTPHeaders(
+ headers: Record<string, string>
+ ): Promise<void> {
+ return await this.#frameManager.networkManager.setExtraHTTPHeaders(headers);
+ }
+
+ override async setUserAgent(
+ userAgent: string,
+ userAgentMetadata?: Protocol.Emulation.UserAgentMetadata
+ ): Promise<void> {
+ return await this.#frameManager.networkManager.setUserAgent(
+ userAgent,
+ userAgentMetadata
+ );
+ }
+
+ override async metrics(): Promise<Metrics> {
+ const response = await this.#primaryTargetClient.send(
+ 'Performance.getMetrics'
+ );
+ return this.#buildMetricsObject(response.metrics);
+ }
+
+ #emitMetrics(event: Protocol.Performance.MetricsEvent): void {
+ this.emit(PageEvent.Metrics, {
+ title: event.title,
+ metrics: this.#buildMetricsObject(event.metrics),
+ });
+ }
+
+ #buildMetricsObject(metrics?: Protocol.Performance.Metric[]): Metrics {
+ const result: Record<
+ Protocol.Performance.Metric['name'],
+ Protocol.Performance.Metric['value']
+ > = {};
+ for (const metric of metrics || []) {
+ if (supportedMetrics.has(metric.name)) {
+ result[metric.name] = metric.value;
+ }
+ }
+ return result;
+ }
+
+ #handleException(exception: Protocol.Runtime.ExceptionThrownEvent): void {
+ this.emit(
+ PageEvent.PageError,
+ createClientError(exception.exceptionDetails)
+ );
+ }
+
+ async #onConsoleAPI(
+ event: Protocol.Runtime.ConsoleAPICalledEvent
+ ): Promise<void> {
+ if (event.executionContextId === 0) {
+ // DevTools protocol stores the last 1000 console messages. These
+ // messages are always reported even for removed execution contexts. In
+ // this case, they are marked with executionContextId = 0 and are
+ // reported upon enabling Runtime agent.
+ //
+ // Ignore these messages since:
+ // - there's no execution context we can use to operate with message
+ // arguments
+ // - these messages are reported before Puppeteer clients can subscribe
+ // to the 'console'
+ // page event.
+ //
+ // @see https://github.com/puppeteer/puppeteer/issues/3865
+ return;
+ }
+ const context = this.#frameManager.getExecutionContextById(
+ event.executionContextId,
+ this.#primaryTargetClient
+ );
+ if (!context) {
+ debugError(
+ new Error(
+ `ExecutionContext not found for a console message: ${JSON.stringify(
+ event
+ )}`
+ )
+ );
+ return;
+ }
+ const values = event.args.map(arg => {
+ return createCdpHandle(context._world, arg);
+ });
+ this.#addConsoleMessage(event.type, values, event.stackTrace);
+ }
+
+ async #onBindingCalled(
+ event: Protocol.Runtime.BindingCalledEvent
+ ): Promise<void> {
+ let payload: BindingPayload;
+ try {
+ payload = JSON.parse(event.payload);
+ } catch {
+ // The binding was either called by something in the page or it was
+ // called before our wrapper was initialized.
+ return;
+ }
+ const {type, name, seq, args, isTrivial} = payload;
+ if (type !== 'exposedFun') {
+ return;
+ }
+
+ const context = this.#frameManager.executionContextById(
+ event.executionContextId,
+ this.#primaryTargetClient
+ );
+ if (!context) {
+ return;
+ }
+
+ const binding = this.#bindings.get(name);
+ await binding?.run(context, seq, args, isTrivial);
+ }
+
+ #addConsoleMessage(
+ eventType: ConsoleMessageType,
+ args: JSHandle[],
+ stackTrace?: Protocol.Runtime.StackTrace
+ ): void {
+ if (!this.listenerCount(PageEvent.Console)) {
+ args.forEach(arg => {
+ return arg.dispose();
+ });
+ return;
+ }
+ const textTokens = [];
+ // eslint-disable-next-line max-len -- The comment is long.
+ // eslint-disable-next-line rulesdir/use-using -- These are not owned by this function.
+ for (const arg of args) {
+ const remoteObject = arg.remoteObject();
+ if (remoteObject.objectId) {
+ textTokens.push(arg.toString());
+ } else {
+ textTokens.push(valueFromRemoteObject(remoteObject));
+ }
+ }
+ const stackTraceLocations = [];
+ if (stackTrace) {
+ for (const callFrame of stackTrace.callFrames) {
+ stackTraceLocations.push({
+ url: callFrame.url,
+ lineNumber: callFrame.lineNumber,
+ columnNumber: callFrame.columnNumber,
+ });
+ }
+ }
+ const message = new ConsoleMessage(
+ eventType,
+ textTokens.join(' '),
+ args,
+ stackTraceLocations
+ );
+ this.emit(PageEvent.Console, message);
+ }
+
+ #onDialog(event: Protocol.Page.JavascriptDialogOpeningEvent): void {
+ const type = validateDialogType(event.type);
+ const dialog = new CdpDialog(
+ this.#primaryTargetClient,
+ type,
+ event.message,
+ event.defaultPrompt
+ );
+ this.emit(PageEvent.Dialog, dialog);
+ }
+
+ override async reload(
+ options?: WaitForOptions
+ ): Promise<HTTPResponse | null> {
+ const [result] = await Promise.all([
+ this.waitForNavigation(options),
+ this.#primaryTargetClient.send('Page.reload'),
+ ]);
+
+ return result;
+ }
+
+ override async createCDPSession(): Promise<CDPSession> {
+ return await this.target().createCDPSession();
+ }
+
+ override async goBack(
+ options: WaitForOptions = {}
+ ): Promise<HTTPResponse | null> {
+ return await this.#go(-1, options);
+ }
+
+ override async goForward(
+ options: WaitForOptions = {}
+ ): Promise<HTTPResponse | null> {
+ return await this.#go(+1, options);
+ }
+
+ async #go(
+ delta: number,
+ options: WaitForOptions
+ ): Promise<HTTPResponse | null> {
+ const history = await this.#primaryTargetClient.send(
+ 'Page.getNavigationHistory'
+ );
+ const entry = history.entries[history.currentIndex + delta];
+ if (!entry) {
+ return null;
+ }
+ const result = await Promise.all([
+ this.waitForNavigation(options),
+ this.#primaryTargetClient.send('Page.navigateToHistoryEntry', {
+ entryId: entry.id,
+ }),
+ ]);
+ return result[0];
+ }
+
+ override async bringToFront(): Promise<void> {
+ await this.#primaryTargetClient.send('Page.bringToFront');
+ }
+
+ override async setJavaScriptEnabled(enabled: boolean): Promise<void> {
+ return await this.#emulationManager.setJavaScriptEnabled(enabled);
+ }
+
+ override async setBypassCSP(enabled: boolean): Promise<void> {
+ await this.#primaryTargetClient.send('Page.setBypassCSP', {enabled});
+ }
+
+ override async emulateMediaType(type?: string): Promise<void> {
+ return await this.#emulationManager.emulateMediaType(type);
+ }
+
+ override async emulateCPUThrottling(factor: number | null): Promise<void> {
+ return await this.#emulationManager.emulateCPUThrottling(factor);
+ }
+
+ override async emulateMediaFeatures(
+ features?: MediaFeature[]
+ ): Promise<void> {
+ return await this.#emulationManager.emulateMediaFeatures(features);
+ }
+
+ override async emulateTimezone(timezoneId?: string): Promise<void> {
+ return await this.#emulationManager.emulateTimezone(timezoneId);
+ }
+
+ override async emulateIdleState(overrides?: {
+ isUserActive: boolean;
+ isScreenUnlocked: boolean;
+ }): Promise<void> {
+ return await this.#emulationManager.emulateIdleState(overrides);
+ }
+
+ override async emulateVisionDeficiency(
+ type?: Protocol.Emulation.SetEmulatedVisionDeficiencyRequest['type']
+ ): Promise<void> {
+ return await this.#emulationManager.emulateVisionDeficiency(type);
+ }
+
+ override async setViewport(viewport: Viewport): Promise<void> {
+ const needsReload = await this.#emulationManager.emulateViewport(viewport);
+ this.#viewport = viewport;
+ if (needsReload) {
+ await this.reload();
+ }
+ }
+
+ override viewport(): Viewport | null {
+ return this.#viewport;
+ }
+
+ override async evaluateOnNewDocument<
+ Params extends unknown[],
+ Func extends (...args: Params) => unknown = (...args: Params) => unknown,
+ >(
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<NewDocumentScriptEvaluation> {
+ const source = evaluationString(pageFunction, ...args);
+ const {identifier} = await this.#primaryTargetClient.send(
+ 'Page.addScriptToEvaluateOnNewDocument',
+ {
+ source,
+ }
+ );
+
+ return {identifier};
+ }
+
+ override async removeScriptToEvaluateOnNewDocument(
+ identifier: string
+ ): Promise<void> {
+ await this.#primaryTargetClient.send(
+ 'Page.removeScriptToEvaluateOnNewDocument',
+ {
+ identifier,
+ }
+ );
+ }
+
+ override async setCacheEnabled(enabled = true): Promise<void> {
+ await this.#frameManager.networkManager.setCacheEnabled(enabled);
+ }
+
+ override async _screenshot(
+ options: Readonly<ScreenshotOptions>
+ ): Promise<string> {
+ const {
+ fromSurface,
+ omitBackground,
+ optimizeForSpeed,
+ quality,
+ clip: userClip,
+ type,
+ captureBeyondViewport,
+ } = options;
+
+ const isFirefox =
+ this.target()._targetManager() instanceof FirefoxTargetManager;
+
+ await using stack = new AsyncDisposableStack();
+ // Firefox omits background by default; it's not configurable.
+ if (!isFirefox && omitBackground && (type === 'png' || type === 'webp')) {
+ await this.#emulationManager.setTransparentBackgroundColor();
+ stack.defer(async () => {
+ await this.#emulationManager
+ .resetDefaultBackgroundColor()
+ .catch(debugError);
+ });
+ }
+
+ let clip = userClip;
+ if (clip && !captureBeyondViewport) {
+ const viewport = await this.mainFrame()
+ .isolatedRealm()
+ .evaluate(() => {
+ const {
+ height,
+ pageLeft: x,
+ pageTop: y,
+ width,
+ } = window.visualViewport!;
+ return {x, y, height, width};
+ });
+ clip = getIntersectionRect(clip, viewport);
+ }
+
+ // We need to do these spreads because Firefox doesn't allow unknown options.
+ const {data} = await this.#primaryTargetClient.send(
+ 'Page.captureScreenshot',
+ {
+ format: type,
+ ...(optimizeForSpeed ? {optimizeForSpeed} : {}),
+ ...(quality !== undefined ? {quality: Math.round(quality)} : {}),
+ ...(clip ? {clip: {...clip, scale: clip.scale ?? 1}} : {}),
+ ...(!fromSurface ? {fromSurface} : {}),
+ captureBeyondViewport,
+ }
+ );
+ return data;
+ }
+
+ override async createPDFStream(options: PDFOptions = {}): Promise<Readable> {
+ const {timeout: ms = this._timeoutSettings.timeout()} = options;
+ const {
+ landscape,
+ displayHeaderFooter,
+ headerTemplate,
+ footerTemplate,
+ printBackground,
+ scale,
+ width: paperWidth,
+ height: paperHeight,
+ margin,
+ pageRanges,
+ preferCSSPageSize,
+ omitBackground,
+ tagged: generateTaggedPDF,
+ } = parsePDFOptions(options);
+
+ if (omitBackground) {
+ await this.#emulationManager.setTransparentBackgroundColor();
+ }
+
+ const printCommandPromise = this.#primaryTargetClient.send(
+ 'Page.printToPDF',
+ {
+ transferMode: 'ReturnAsStream',
+ landscape,
+ displayHeaderFooter,
+ headerTemplate,
+ footerTemplate,
+ printBackground,
+ scale,
+ paperWidth,
+ paperHeight,
+ marginTop: margin.top,
+ marginBottom: margin.bottom,
+ marginLeft: margin.left,
+ marginRight: margin.right,
+ pageRanges,
+ preferCSSPageSize,
+ generateTaggedPDF,
+ }
+ );
+
+ const result = await firstValueFrom(
+ from(printCommandPromise).pipe(raceWith(timeout(ms)))
+ );
+
+ if (omitBackground) {
+ await this.#emulationManager.resetDefaultBackgroundColor();
+ }
+
+ assert(result.stream, '`stream` is missing from `Page.printToPDF');
+ return await getReadableFromProtocolStream(
+ this.#primaryTargetClient,
+ result.stream
+ );
+ }
+
+ override async pdf(options: PDFOptions = {}): Promise<Buffer> {
+ const {path = undefined} = options;
+ const readable = await this.createPDFStream(options);
+ const buffer = await getReadableAsBuffer(readable, path);
+ assert(buffer, 'Could not create buffer');
+ return buffer;
+ }
+
+ override async close(
+ options: {runBeforeUnload?: boolean} = {runBeforeUnload: undefined}
+ ): Promise<void> {
+ const connection = this.#primaryTargetClient.connection();
+ assert(
+ connection,
+ 'Protocol error: Connection closed. Most likely the page has been closed.'
+ );
+ const runBeforeUnload = !!options.runBeforeUnload;
+ if (runBeforeUnload) {
+ await this.#primaryTargetClient.send('Page.close');
+ } else {
+ await connection.send('Target.closeTarget', {
+ targetId: this.#primaryTarget._targetId,
+ });
+ await this.#tabTarget._isClosedDeferred.valueOrThrow();
+ }
+ }
+
+ override isClosed(): boolean {
+ return this.#closed;
+ }
+
+ override get mouse(): CdpMouse {
+ return this.#mouse;
+ }
+
+ /**
+ * This method is typically coupled with an action that triggers a device
+ * request from an api such as WebBluetooth.
+ *
+ * :::caution
+ *
+ * This must be called before the device request is made. It will not return a
+ * currently active device prompt.
+ *
+ * :::
+ *
+ * @example
+ *
+ * ```ts
+ * const [devicePrompt] = Promise.all([
+ * page.waitForDevicePrompt(),
+ * page.click('#connect-bluetooth'),
+ * ]);
+ * await devicePrompt.select(
+ * await devicePrompt.waitForDevice(({name}) => name.includes('My Device'))
+ * );
+ * ```
+ */
+ override async waitForDevicePrompt(
+ options: WaitTimeoutOptions = {}
+ ): Promise<DeviceRequestPrompt> {
+ return await this.mainFrame().waitForDevicePrompt(options);
+ }
+}
+
+const supportedMetrics = new Set<string>([
+ 'Timestamp',
+ 'Documents',
+ 'Frames',
+ 'JSEventListeners',
+ 'Nodes',
+ 'LayoutCount',
+ 'RecalcStyleCount',
+ 'LayoutDuration',
+ 'RecalcStyleDuration',
+ 'ScriptDuration',
+ 'TaskDuration',
+ 'JSHeapUsedSize',
+ 'JSHeapTotalSize',
+]);
+
+/** @see https://w3c.github.io/webdriver-bidi/#rectangle-intersection */
+function getIntersectionRect(
+ clip: Readonly<ScreenshotClip>,
+ viewport: Readonly<Protocol.DOM.Rect>
+): ScreenshotClip {
+ // Note these will already be normalized.
+ const x = Math.max(clip.x, viewport.x);
+ const y = Math.max(clip.y, viewport.y);
+ return {
+ x,
+ y,
+ width: Math.max(
+ Math.min(clip.x + clip.width, viewport.x + viewport.width) - x,
+ 0
+ ),
+ height: Math.max(
+ Math.min(clip.y + clip.height, viewport.y + viewport.height) - y,
+ 0
+ ),
+ };
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/PredefinedNetworkConditions.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/PredefinedNetworkConditions.ts
new file mode 100644
index 0000000000..df035ae52b
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/PredefinedNetworkConditions.ts
@@ -0,0 +1,49 @@
+/**
+ * @license
+ * Copyright 2021 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {NetworkConditions} from './NetworkManager.js';
+
+/**
+ * A list of network conditions to be used with
+ * {@link Page.emulateNetworkConditions}.
+ *
+ * @example
+ *
+ * ```ts
+ * import {PredefinedNetworkConditions} from 'puppeteer';
+ * const slow3G = PredefinedNetworkConditions['Slow 3G'];
+ *
+ * (async () => {
+ * const browser = await puppeteer.launch();
+ * const page = await browser.newPage();
+ * await page.emulateNetworkConditions(slow3G);
+ * await page.goto('https://www.google.com');
+ * // other actions...
+ * await browser.close();
+ * })();
+ * ```
+ *
+ * @public
+ */
+export const PredefinedNetworkConditions = Object.freeze({
+ 'Slow 3G': {
+ download: ((500 * 1000) / 8) * 0.8,
+ upload: ((500 * 1000) / 8) * 0.8,
+ latency: 400 * 5,
+ } as NetworkConditions,
+ 'Fast 3G': {
+ download: ((1.6 * 1000 * 1000) / 8) * 0.9,
+ upload: ((750 * 1000) / 8) * 0.9,
+ latency: 150 * 3.75,
+ } as NetworkConditions,
+});
+
+/**
+ * @deprecated Import {@link PredefinedNetworkConditions}.
+ *
+ * @public
+ */
+export const networkConditions = PredefinedNetworkConditions;
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Target.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Target.ts
new file mode 100644
index 0000000000..b3e9ea83ec
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Target.ts
@@ -0,0 +1,305 @@
+/**
+ * @license
+ * Copyright 2019 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {Protocol} from 'devtools-protocol';
+
+import type {Browser} from '../api/Browser.js';
+import type {BrowserContext} from '../api/BrowserContext.js';
+import type {CDPSession} from '../api/CDPSession.js';
+import {PageEvent, type Page} from '../api/Page.js';
+import {Target, TargetType} from '../api/Target.js';
+import {debugError} from '../common/util.js';
+import type {Viewport} from '../common/Viewport.js';
+import {Deferred} from '../util/Deferred.js';
+
+import {CdpCDPSession} from './CDPSession.js';
+import {CdpPage} from './Page.js';
+import type {TargetManager} from './TargetManager.js';
+import {CdpWebWorker} from './WebWorker.js';
+
+/**
+ * @internal
+ */
+export enum InitializationStatus {
+ SUCCESS = 'success',
+ ABORTED = 'aborted',
+}
+
+/**
+ * @internal
+ */
+export class CdpTarget extends Target {
+ #browserContext?: BrowserContext;
+ #session?: CDPSession;
+ #targetInfo: Protocol.Target.TargetInfo;
+ #targetManager?: TargetManager;
+ #sessionFactory:
+ | ((isAutoAttachEmulated: boolean) => Promise<CDPSession>)
+ | undefined;
+
+ _initializedDeferred = Deferred.create<InitializationStatus>();
+ _isClosedDeferred = Deferred.create<void>();
+ _targetId: string;
+
+ /**
+ * To initialize the target for use, call initialize.
+ *
+ * @internal
+ */
+ constructor(
+ targetInfo: Protocol.Target.TargetInfo,
+ session: CDPSession | undefined,
+ browserContext: BrowserContext | undefined,
+ targetManager: TargetManager | undefined,
+ sessionFactory:
+ | ((isAutoAttachEmulated: boolean) => Promise<CDPSession>)
+ | undefined
+ ) {
+ super();
+ this.#session = session;
+ this.#targetManager = targetManager;
+ this.#targetInfo = targetInfo;
+ this.#browserContext = browserContext;
+ this._targetId = targetInfo.targetId;
+ this.#sessionFactory = sessionFactory;
+ if (this.#session && this.#session instanceof CdpCDPSession) {
+ this.#session._setTarget(this);
+ }
+ }
+
+ override async asPage(): Promise<Page> {
+ const session = this._session();
+ if (!session) {
+ return await this.createCDPSession().then(client => {
+ return CdpPage._create(client, this, false, null);
+ });
+ }
+ return await CdpPage._create(session, this, false, null);
+ }
+
+ _subtype(): string | undefined {
+ return this.#targetInfo.subtype;
+ }
+
+ _session(): CDPSession | undefined {
+ return this.#session;
+ }
+
+ protected _sessionFactory(): (
+ isAutoAttachEmulated: boolean
+ ) => Promise<CDPSession> {
+ if (!this.#sessionFactory) {
+ throw new Error('sessionFactory is not initialized');
+ }
+ return this.#sessionFactory;
+ }
+
+ override createCDPSession(): Promise<CDPSession> {
+ if (!this.#sessionFactory) {
+ throw new Error('sessionFactory is not initialized');
+ }
+ return this.#sessionFactory(false).then(session => {
+ (session as CdpCDPSession)._setTarget(this);
+ return session;
+ });
+ }
+
+ override url(): string {
+ return this.#targetInfo.url;
+ }
+
+ override type(): TargetType {
+ const type = this.#targetInfo.type;
+ switch (type) {
+ case 'page':
+ return TargetType.PAGE;
+ case 'background_page':
+ return TargetType.BACKGROUND_PAGE;
+ case 'service_worker':
+ return TargetType.SERVICE_WORKER;
+ case 'shared_worker':
+ return TargetType.SHARED_WORKER;
+ case 'browser':
+ return TargetType.BROWSER;
+ case 'webview':
+ return TargetType.WEBVIEW;
+ case 'tab':
+ return TargetType.TAB;
+ default:
+ return TargetType.OTHER;
+ }
+ }
+
+ _targetManager(): TargetManager {
+ if (!this.#targetManager) {
+ throw new Error('targetManager is not initialized');
+ }
+ return this.#targetManager;
+ }
+
+ _getTargetInfo(): Protocol.Target.TargetInfo {
+ return this.#targetInfo;
+ }
+
+ override browser(): Browser {
+ if (!this.#browserContext) {
+ throw new Error('browserContext is not initialized');
+ }
+ return this.#browserContext.browser();
+ }
+
+ override browserContext(): BrowserContext {
+ if (!this.#browserContext) {
+ throw new Error('browserContext is not initialized');
+ }
+ return this.#browserContext;
+ }
+
+ override opener(): Target | undefined {
+ const {openerId} = this.#targetInfo;
+ if (!openerId) {
+ return;
+ }
+ return this.browser()
+ .targets()
+ .find(target => {
+ return (target as CdpTarget)._targetId === openerId;
+ });
+ }
+
+ _targetInfoChanged(targetInfo: Protocol.Target.TargetInfo): void {
+ this.#targetInfo = targetInfo;
+ this._checkIfInitialized();
+ }
+
+ _initialize(): void {
+ this._initializedDeferred.resolve(InitializationStatus.SUCCESS);
+ }
+
+ _isTargetExposed(): boolean {
+ return this.type() !== TargetType.TAB && !this._subtype();
+ }
+
+ protected _checkIfInitialized(): void {
+ if (!this._initializedDeferred.resolved()) {
+ this._initializedDeferred.resolve(InitializationStatus.SUCCESS);
+ }
+ }
+}
+
+/**
+ * @internal
+ */
+export class PageTarget extends CdpTarget {
+ #defaultViewport?: Viewport;
+ protected pagePromise?: Promise<Page>;
+ #ignoreHTTPSErrors: boolean;
+
+ constructor(
+ targetInfo: Protocol.Target.TargetInfo,
+ session: CDPSession | undefined,
+ browserContext: BrowserContext,
+ targetManager: TargetManager,
+ sessionFactory: (isAutoAttachEmulated: boolean) => Promise<CDPSession>,
+ ignoreHTTPSErrors: boolean,
+ defaultViewport: Viewport | null
+ ) {
+ super(targetInfo, session, browserContext, targetManager, sessionFactory);
+ this.#ignoreHTTPSErrors = ignoreHTTPSErrors;
+ this.#defaultViewport = defaultViewport ?? undefined;
+ }
+
+ override _initialize(): void {
+ this._initializedDeferred
+ .valueOrThrow()
+ .then(async result => {
+ if (result === InitializationStatus.ABORTED) {
+ return;
+ }
+ const opener = this.opener();
+ if (!(opener instanceof PageTarget)) {
+ return;
+ }
+ if (!opener || !opener.pagePromise || this.type() !== 'page') {
+ return true;
+ }
+ const openerPage = await opener.pagePromise;
+ if (!openerPage.listenerCount(PageEvent.Popup)) {
+ return true;
+ }
+ const popupPage = await this.page();
+ openerPage.emit(PageEvent.Popup, popupPage);
+ return true;
+ })
+ .catch(debugError);
+ this._checkIfInitialized();
+ }
+
+ override async page(): Promise<Page | null> {
+ if (!this.pagePromise) {
+ const session = this._session();
+ this.pagePromise = (
+ session
+ ? Promise.resolve(session)
+ : this._sessionFactory()(/* isAutoAttachEmulated=*/ false)
+ ).then(client => {
+ return CdpPage._create(
+ client,
+ this,
+ this.#ignoreHTTPSErrors,
+ this.#defaultViewport ?? null
+ );
+ });
+ }
+ return (await this.pagePromise) ?? null;
+ }
+
+ override _checkIfInitialized(): void {
+ if (this._initializedDeferred.resolved()) {
+ return;
+ }
+ if (this._getTargetInfo().url !== '') {
+ this._initializedDeferred.resolve(InitializationStatus.SUCCESS);
+ }
+ }
+}
+
+/**
+ * @internal
+ */
+export class DevToolsTarget extends PageTarget {}
+
+/**
+ * @internal
+ */
+export class WorkerTarget extends CdpTarget {
+ #workerPromise?: Promise<CdpWebWorker>;
+
+ override async worker(): Promise<CdpWebWorker | null> {
+ if (!this.#workerPromise) {
+ const session = this._session();
+ // TODO(einbinder): Make workers send their console logs.
+ this.#workerPromise = (
+ session
+ ? Promise.resolve(session)
+ : this._sessionFactory()(/* isAutoAttachEmulated=*/ false)
+ ).then(client => {
+ return new CdpWebWorker(
+ client,
+ this._getTargetInfo().url,
+ () => {} /* consoleAPICalled */,
+ () => {} /* exceptionThrown */
+ );
+ });
+ }
+ return await this.#workerPromise;
+ }
+}
+
+/**
+ * @internal
+ */
+export class OtherTarget extends CdpTarget {}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/TargetManager.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/TargetManager.ts
new file mode 100644
index 0000000000..248f63539d
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/TargetManager.ts
@@ -0,0 +1,65 @@
+/**
+ * @license
+ * Copyright 2022 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {Protocol} from 'devtools-protocol';
+
+import type {CDPSession} from '../api/CDPSession.js';
+import type {EventEmitter, EventType} from '../common/EventEmitter.js';
+
+import type {CdpTarget} from './Target.js';
+
+/**
+ * @internal
+ */
+export type TargetFactory = (
+ targetInfo: Protocol.Target.TargetInfo,
+ session?: CDPSession,
+ parentSession?: CDPSession
+) => CdpTarget;
+
+/**
+ * @internal
+ */
+export const enum TargetManagerEvent {
+ TargetDiscovered = 'targetDiscovered',
+ TargetAvailable = 'targetAvailable',
+ TargetGone = 'targetGone',
+ /**
+ * Emitted after a target has been initialized and whenever its URL changes.
+ */
+ TargetChanged = 'targetChanged',
+}
+
+/**
+ * @internal
+ */
+export interface TargetManagerEvents extends Record<EventType, unknown> {
+ [TargetManagerEvent.TargetAvailable]: CdpTarget;
+ [TargetManagerEvent.TargetDiscovered]: Protocol.Target.TargetInfo;
+ [TargetManagerEvent.TargetGone]: CdpTarget;
+ [TargetManagerEvent.TargetChanged]: {
+ target: CdpTarget;
+ wasInitialized: true;
+ previousURL: string;
+ };
+}
+
+/**
+ * TargetManager encapsulates all interactions with CDP targets and is
+ * responsible for coordinating the configuration of targets with the rest of
+ * Puppeteer. Code outside of this class should not subscribe `Target.*` events
+ * and only use the TargetManager events.
+ *
+ * There are two implementations: one for Chrome that uses CDP's auto-attach
+ * mechanism and one for Firefox because Firefox does not support auto-attach.
+ *
+ * @internal
+ */
+export interface TargetManager extends EventEmitter<TargetManagerEvents> {
+ getAvailableTargets(): ReadonlyMap<string, CdpTarget>;
+ initialize(): Promise<void>;
+ dispose(): void;
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Tracing.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Tracing.ts
new file mode 100644
index 0000000000..22eae9a5d4
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/Tracing.ts
@@ -0,0 +1,140 @@
+/**
+ * @license
+ * Copyright 2017 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import type {CDPSession} from '../api/CDPSession.js';
+import {
+ getReadableAsBuffer,
+ getReadableFromProtocolStream,
+} from '../common/util.js';
+import {assert} from '../util/assert.js';
+import {Deferred} from '../util/Deferred.js';
+import {isErrorLike} from '../util/ErrorLike.js';
+
+/**
+ * @public
+ */
+export interface TracingOptions {
+ path?: string;
+ screenshots?: boolean;
+ categories?: string[];
+}
+
+/**
+ * The Tracing class exposes the tracing audit interface.
+ * @remarks
+ * You can use `tracing.start` and `tracing.stop` to create a trace file
+ * which can be opened in Chrome DevTools or {@link https://chromedevtools.github.io/timeline-viewer/ | timeline viewer}.
+ *
+ * @example
+ *
+ * ```ts
+ * await page.tracing.start({path: 'trace.json'});
+ * await page.goto('https://www.google.com');
+ * await page.tracing.stop();
+ * ```
+ *
+ * @public
+ */
+export class Tracing {
+ #client: CDPSession;
+ #recording = false;
+ #path?: string;
+
+ /**
+ * @internal
+ */
+ constructor(client: CDPSession) {
+ this.#client = client;
+ }
+
+ /**
+ * @internal
+ */
+ updateClient(client: CDPSession): void {
+ this.#client = client;
+ }
+
+ /**
+ * Starts a trace for the current page.
+ * @remarks
+ * Only one trace can be active at a time per browser.
+ *
+ * @param options - Optional `TracingOptions`.
+ */
+ async start(options: TracingOptions = {}): Promise<void> {
+ assert(
+ !this.#recording,
+ 'Cannot start recording trace while already recording trace.'
+ );
+
+ const defaultCategories = [
+ '-*',
+ 'devtools.timeline',
+ 'v8.execute',
+ 'disabled-by-default-devtools.timeline',
+ 'disabled-by-default-devtools.timeline.frame',
+ 'toplevel',
+ 'blink.console',
+ 'blink.user_timing',
+ 'latencyInfo',
+ 'disabled-by-default-devtools.timeline.stack',
+ 'disabled-by-default-v8.cpu_profiler',
+ ];
+ const {path, screenshots = false, categories = defaultCategories} = options;
+
+ if (screenshots) {
+ categories.push('disabled-by-default-devtools.screenshot');
+ }
+
+ const excludedCategories = categories
+ .filter(cat => {
+ return cat.startsWith('-');
+ })
+ .map(cat => {
+ return cat.slice(1);
+ });
+ const includedCategories = categories.filter(cat => {
+ return !cat.startsWith('-');
+ });
+
+ this.#path = path;
+ this.#recording = true;
+ await this.#client.send('Tracing.start', {
+ transferMode: 'ReturnAsStream',
+ traceConfig: {
+ excludedCategories,
+ includedCategories,
+ },
+ });
+ }
+
+ /**
+ * Stops a trace started with the `start` method.
+ * @returns Promise which resolves to buffer with trace data.
+ */
+ async stop(): Promise<Buffer | undefined> {
+ const contentDeferred = Deferred.create<Buffer | undefined>();
+ this.#client.once('Tracing.tracingComplete', async event => {
+ try {
+ assert(event.stream, 'Missing "stream"');
+ const readable = await getReadableFromProtocolStream(
+ this.#client,
+ event.stream
+ );
+ const buffer = await getReadableAsBuffer(readable, this.#path);
+ contentDeferred.resolve(buffer ?? undefined);
+ } catch (error) {
+ if (isErrorLike(error)) {
+ contentDeferred.reject(error);
+ } else {
+ contentDeferred.reject(new Error(`Unknown error: ${error}`));
+ }
+ }
+ });
+ await this.#client.send('Tracing.end');
+ this.#recording = false;
+ return await contentDeferred.valueOrThrow();
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/WebWorker.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/WebWorker.ts
new file mode 100644
index 0000000000..552e8a6cf5
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/WebWorker.ts
@@ -0,0 +1,83 @@
+/**
+ * @license
+ * Copyright 2018 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import type {Protocol} from 'devtools-protocol';
+
+import type {CDPSession} from '../api/CDPSession.js';
+import type {Realm} from '../api/Realm.js';
+import {WebWorker} from '../api/WebWorker.js';
+import type {ConsoleMessageType} from '../common/ConsoleMessage.js';
+import {TimeoutSettings} from '../common/TimeoutSettings.js';
+import {debugError} from '../common/util.js';
+
+import {ExecutionContext} from './ExecutionContext.js';
+import {IsolatedWorld} from './IsolatedWorld.js';
+import {CdpJSHandle} from './JSHandle.js';
+
+/**
+ * @internal
+ */
+export type ConsoleAPICalledCallback = (
+ eventType: ConsoleMessageType,
+ handles: CdpJSHandle[],
+ trace?: Protocol.Runtime.StackTrace
+) => void;
+
+/**
+ * @internal
+ */
+export type ExceptionThrownCallback = (
+ event: Protocol.Runtime.ExceptionThrownEvent
+) => void;
+
+/**
+ * @internal
+ */
+export class CdpWebWorker extends WebWorker {
+ #world: IsolatedWorld;
+ #client: CDPSession;
+
+ constructor(
+ client: CDPSession,
+ url: string,
+ consoleAPICalled: ConsoleAPICalledCallback,
+ exceptionThrown: ExceptionThrownCallback
+ ) {
+ super(url);
+ this.#client = client;
+ this.#world = new IsolatedWorld(this, new TimeoutSettings());
+
+ this.#client.once('Runtime.executionContextCreated', async event => {
+ this.#world.setContext(
+ new ExecutionContext(client, event.context, this.#world)
+ );
+ });
+ this.#client.on('Runtime.consoleAPICalled', async event => {
+ try {
+ return consoleAPICalled(
+ event.type,
+ event.args.map((object: Protocol.Runtime.RemoteObject) => {
+ return new CdpJSHandle(this.#world, object);
+ }),
+ event.stackTrace
+ );
+ } catch (err) {
+ debugError(err);
+ }
+ });
+ this.#client.on('Runtime.exceptionThrown', exceptionThrown);
+
+ // This might fail if the target is closed before we receive all execution contexts.
+ this.#client.send('Runtime.enable').catch(debugError);
+ }
+
+ mainRealm(): Realm {
+ return this.#world;
+ }
+
+ get client(): CDPSession {
+ return this.#client;
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/cdp.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/cdp.ts
new file mode 100644
index 0000000000..1533d63f35
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/cdp.ts
@@ -0,0 +1,42 @@
+/**
+ * @license
+ * Copyright 2023 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+export * from './Accessibility.js';
+export * from './AriaQueryHandler.js';
+export * from './Binding.js';
+export * from './Browser.js';
+export * from './BrowserConnector.js';
+export * from './cdp.js';
+export * from './CDPSession.js';
+export * from './ChromeTargetManager.js';
+export * from './Connection.js';
+export * from './Coverage.js';
+export * from './DeviceRequestPrompt.js';
+export * from './Dialog.js';
+export * from './ElementHandle.js';
+export * from './EmulationManager.js';
+export * from './ExecutionContext.js';
+export * from './FirefoxTargetManager.js';
+export * from './Frame.js';
+export * from './FrameManager.js';
+export * from './FrameManagerEvents.js';
+export * from './FrameTree.js';
+export * from './HTTPRequest.js';
+export * from './HTTPResponse.js';
+export * from './Input.js';
+export * from './IsolatedWorld.js';
+export * from './IsolatedWorlds.js';
+export * from './JSHandle.js';
+export * from './LifecycleWatcher.js';
+export * from './NetworkEventManager.js';
+export * from './NetworkManager.js';
+export * from './Page.js';
+export * from './PredefinedNetworkConditions.js';
+export * from './Target.js';
+export * from './TargetManager.js';
+export * from './Tracing.js';
+export * from './utils.js';
+export * from './WebWorker.js';
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/cdp/utils.ts b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/utils.ts
new file mode 100644
index 0000000000..989a3cd6a3
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/cdp/utils.ts
@@ -0,0 +1,232 @@
+/**
+ * @license
+ * Copyright 2017 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {Protocol} from 'devtools-protocol';
+
+import {PuppeteerURL, evaluationString} from '../common/util.js';
+import {assert} from '../util/assert.js';
+
+/**
+ * @internal
+ */
+export function createEvaluationError(
+ details: Protocol.Runtime.ExceptionDetails
+): unknown {
+ let name: string;
+ let message: string;
+ if (!details.exception) {
+ name = 'Error';
+ message = details.text;
+ } else if (
+ (details.exception.type !== 'object' ||
+ details.exception.subtype !== 'error') &&
+ !details.exception.objectId
+ ) {
+ return valueFromRemoteObject(details.exception);
+ } else {
+ const detail = getErrorDetails(details);
+ name = detail.name;
+ message = detail.message;
+ }
+ const messageHeight = message.split('\n').length;
+ const error = new Error(message);
+ error.name = name;
+ const stackLines = error.stack!.split('\n');
+ const messageLines = stackLines.splice(0, messageHeight);
+
+ // The first line is this function which we ignore.
+ stackLines.shift();
+ if (details.stackTrace && stackLines.length < Error.stackTraceLimit) {
+ for (const frame of details.stackTrace.callFrames.reverse()) {
+ if (
+ PuppeteerURL.isPuppeteerURL(frame.url) &&
+ frame.url !== PuppeteerURL.INTERNAL_URL
+ ) {
+ const url = PuppeteerURL.parse(frame.url);
+ stackLines.unshift(
+ ` at ${frame.functionName || url.functionName} (${
+ url.functionName
+ } at ${url.siteString}, <anonymous>:${frame.lineNumber}:${
+ frame.columnNumber
+ })`
+ );
+ } else {
+ stackLines.push(
+ ` at ${frame.functionName || '<anonymous>'} (${frame.url}:${
+ frame.lineNumber
+ }:${frame.columnNumber})`
+ );
+ }
+ if (stackLines.length >= Error.stackTraceLimit) {
+ break;
+ }
+ }
+ }
+
+ error.stack = [...messageLines, ...stackLines].join('\n');
+ return error;
+}
+
+const getErrorDetails = (details: Protocol.Runtime.ExceptionDetails) => {
+ let name = '';
+ let message: string;
+ const lines = details.exception?.description?.split('\n at ') ?? [];
+ const size = Math.min(
+ details.stackTrace?.callFrames.length ?? 0,
+ lines.length - 1
+ );
+ lines.splice(-size, size);
+ if (details.exception?.className) {
+ name = details.exception.className;
+ }
+ message = lines.join('\n');
+ if (name && message.startsWith(`${name}: `)) {
+ message = message.slice(name.length + 2);
+ }
+ return {message, name};
+};
+
+/**
+ * @internal
+ */
+export function createClientError(
+ details: Protocol.Runtime.ExceptionDetails
+): Error {
+ let name: string;
+ let message: string;
+ if (!details.exception) {
+ name = 'Error';
+ message = details.text;
+ } else if (
+ (details.exception.type !== 'object' ||
+ details.exception.subtype !== 'error') &&
+ !details.exception.objectId
+ ) {
+ return valueFromRemoteObject(details.exception);
+ } else {
+ const detail = getErrorDetails(details);
+ name = detail.name;
+ message = detail.message;
+ }
+ const error = new Error(message);
+ error.name = name;
+
+ const messageHeight = error.message.split('\n').length;
+ const messageLines = error.stack!.split('\n').splice(0, messageHeight);
+
+ const stackLines = [];
+ if (details.stackTrace) {
+ for (const frame of details.stackTrace.callFrames) {
+ // Note we need to add `1` because the values are 0-indexed.
+ stackLines.push(
+ ` at ${frame.functionName || '<anonymous>'} (${frame.url}:${
+ frame.lineNumber + 1
+ }:${frame.columnNumber + 1})`
+ );
+ if (stackLines.length >= Error.stackTraceLimit) {
+ break;
+ }
+ }
+ }
+
+ error.stack = [...messageLines, ...stackLines].join('\n');
+ return error;
+}
+
+/**
+ * @internal
+ */
+export function valueFromRemoteObject(
+ remoteObject: Protocol.Runtime.RemoteObject
+): any {
+ assert(!remoteObject.objectId, 'Cannot extract value when objectId is given');
+ if (remoteObject.unserializableValue) {
+ if (remoteObject.type === 'bigint') {
+ return BigInt(remoteObject.unserializableValue.replace('n', ''));
+ }
+ switch (remoteObject.unserializableValue) {
+ case '-0':
+ return -0;
+ case 'NaN':
+ return NaN;
+ case 'Infinity':
+ return Infinity;
+ case '-Infinity':
+ return -Infinity;
+ default:
+ throw new Error(
+ 'Unsupported unserializable value: ' +
+ remoteObject.unserializableValue
+ );
+ }
+ }
+ return remoteObject.value;
+}
+
+/**
+ * @internal
+ */
+export function addPageBinding(type: string, name: string): void {
+ // This is the CDP binding.
+ // @ts-expect-error: In a different context.
+ const callCdp = globalThis[name];
+
+ // Depending on the frame loading state either Runtime.evaluate or
+ // Page.addScriptToEvaluateOnNewDocument might succeed. Let's check that we
+ // don't re-wrap Puppeteer's binding.
+ if (callCdp[Symbol.toStringTag] === 'PuppeteerBinding') {
+ return;
+ }
+
+ // We replace the CDP binding with a Puppeteer binding.
+ Object.assign(globalThis, {
+ [name](...args: unknown[]): Promise<unknown> {
+ // This is the Puppeteer binding.
+ // @ts-expect-error: In a different context.
+ const callPuppeteer = globalThis[name];
+ callPuppeteer.args ??= new Map();
+ callPuppeteer.callbacks ??= new Map();
+
+ const seq = (callPuppeteer.lastSeq ?? 0) + 1;
+ callPuppeteer.lastSeq = seq;
+ callPuppeteer.args.set(seq, args);
+
+ callCdp(
+ JSON.stringify({
+ type,
+ name,
+ seq,
+ args,
+ isTrivial: !args.some(value => {
+ return value instanceof Node;
+ }),
+ })
+ );
+
+ return new Promise((resolve, reject) => {
+ callPuppeteer.callbacks.set(seq, {
+ resolve(value: unknown) {
+ callPuppeteer.args.delete(seq);
+ resolve(value);
+ },
+ reject(value?: unknown) {
+ callPuppeteer.args.delete(seq);
+ reject(value);
+ },
+ });
+ });
+ },
+ });
+ // @ts-expect-error: In a different context.
+ globalThis[name][Symbol.toStringTag] = 'PuppeteerBinding';
+}
+
+/**
+ * @internal
+ */
+export function pageBindingInitString(type: string, name: string): string {
+ return evaluationString(addPageBinding, type, name);
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/BrowserConnector.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/BrowserConnector.ts
new file mode 100644
index 0000000000..217e53bedd
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/BrowserConnector.ts
@@ -0,0 +1,114 @@
+/**
+ * @license
+ * Copyright 2023 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {Browser} from '../api/Browser.js';
+import {_connectToBiDiBrowser} from '../bidi/BrowserConnector.js';
+import {_connectToCdpBrowser} from '../cdp/BrowserConnector.js';
+import {isNode} from '../environment.js';
+import {assert} from '../util/assert.js';
+import {isErrorLike} from '../util/ErrorLike.js';
+
+import type {ConnectionTransport} from './ConnectionTransport.js';
+import type {ConnectOptions} from './ConnectOptions.js';
+import type {BrowserConnectOptions} from './ConnectOptions.js';
+import {getFetch} from './fetch.js';
+
+const getWebSocketTransportClass = async () => {
+ return isNode
+ ? (await import('../node/NodeWebSocketTransport.js')).NodeWebSocketTransport
+ : (await import('../common/BrowserWebSocketTransport.js'))
+ .BrowserWebSocketTransport;
+};
+
+/**
+ * Users should never call this directly; it's called when calling
+ * `puppeteer.connect`. This method attaches Puppeteer to an existing browser instance.
+ *
+ * @internal
+ */
+export async function _connectToBrowser(
+ options: ConnectOptions
+): Promise<Browser> {
+ const {connectionTransport, endpointUrl} =
+ await getConnectionTransport(options);
+
+ if (options.protocol === 'webDriverBiDi') {
+ const bidiBrowser = await _connectToBiDiBrowser(
+ connectionTransport,
+ endpointUrl,
+ options
+ );
+ return bidiBrowser;
+ } else {
+ const cdpBrowser = await _connectToCdpBrowser(
+ connectionTransport,
+ endpointUrl,
+ options
+ );
+ return cdpBrowser;
+ }
+}
+
+/**
+ * Establishes a websocket connection by given options and returns both transport and
+ * endpoint url the transport is connected to.
+ */
+async function getConnectionTransport(
+ options: BrowserConnectOptions & ConnectOptions
+): Promise<{connectionTransport: ConnectionTransport; endpointUrl: string}> {
+ const {browserWSEndpoint, browserURL, transport, headers = {}} = options;
+
+ assert(
+ Number(!!browserWSEndpoint) + Number(!!browserURL) + Number(!!transport) ===
+ 1,
+ 'Exactly one of browserWSEndpoint, browserURL or transport must be passed to puppeteer.connect'
+ );
+
+ if (transport) {
+ return {connectionTransport: transport, endpointUrl: ''};
+ } else if (browserWSEndpoint) {
+ const WebSocketClass = await getWebSocketTransportClass();
+ const connectionTransport: ConnectionTransport =
+ await WebSocketClass.create(browserWSEndpoint, headers);
+ return {
+ connectionTransport: connectionTransport,
+ endpointUrl: browserWSEndpoint,
+ };
+ } else if (browserURL) {
+ const connectionURL = await getWSEndpoint(browserURL);
+ const WebSocketClass = await getWebSocketTransportClass();
+ const connectionTransport: ConnectionTransport =
+ await WebSocketClass.create(connectionURL);
+ return {
+ connectionTransport: connectionTransport,
+ endpointUrl: connectionURL,
+ };
+ }
+ throw new Error('Invalid connection options');
+}
+
+async function getWSEndpoint(browserURL: string): Promise<string> {
+ const endpointURL = new URL('/json/version', browserURL);
+
+ const fetch = await getFetch();
+ try {
+ const result = await fetch(endpointURL.toString(), {
+ method: 'GET',
+ });
+ if (!result.ok) {
+ throw new Error(`HTTP ${result.statusText}`);
+ }
+ const data = await result.json();
+ return data.webSocketDebuggerUrl;
+ } catch (error) {
+ if (isErrorLike(error)) {
+ error.message =
+ `Failed to fetch browser webSocket URL from ${endpointURL}: ` +
+ error.message;
+ }
+ throw error;
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/BrowserWebSocketTransport.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/BrowserWebSocketTransport.ts
new file mode 100644
index 0000000000..cc0f81cb06
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/BrowserWebSocketTransport.ts
@@ -0,0 +1,50 @@
+/**
+ * @license
+ * Copyright 2020 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import type {ConnectionTransport} from './ConnectionTransport.js';
+
+/**
+ * @internal
+ */
+export class BrowserWebSocketTransport implements ConnectionTransport {
+ static create(url: string): Promise<BrowserWebSocketTransport> {
+ return new Promise((resolve, reject) => {
+ const ws = new WebSocket(url);
+
+ ws.addEventListener('open', () => {
+ return resolve(new BrowserWebSocketTransport(ws));
+ });
+ ws.addEventListener('error', reject);
+ });
+ }
+
+ #ws: WebSocket;
+ onmessage?: (message: string) => void;
+ onclose?: () => void;
+
+ constructor(ws: WebSocket) {
+ 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/common/CallbackRegistry.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/CallbackRegistry.ts
new file mode 100644
index 0000000000..ea9f3d5abb
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/CallbackRegistry.ts
@@ -0,0 +1,177 @@
+/**
+ * @license
+ * Copyright 2023 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {Deferred} from '../util/Deferred.js';
+import {rewriteError} from '../util/ErrorLike.js';
+
+import {ProtocolError, TargetCloseError} from './Errors.js';
+import {debugError} from './util.js';
+
+/**
+ * Manages callbacks and their IDs for the protocol request/response communication.
+ *
+ * @internal
+ */
+export class CallbackRegistry {
+ #callbacks = new Map<number, Callback>();
+ #idGenerator = createIncrementalIdGenerator();
+
+ create(
+ label: string,
+ timeout: number | undefined,
+ request: (id: number) => void
+ ): Promise<unknown> {
+ const callback = new Callback(this.#idGenerator(), label, timeout);
+ this.#callbacks.set(callback.id, callback);
+ try {
+ request(callback.id);
+ } catch (error) {
+ // We still throw sync errors synchronously and clean up the scheduled
+ // callback.
+ callback.promise
+ .valueOrThrow()
+ .catch(debugError)
+ .finally(() => {
+ this.#callbacks.delete(callback.id);
+ });
+ callback.reject(error as Error);
+ throw error;
+ }
+ // Must only have sync code up until here.
+ return callback.promise.valueOrThrow().finally(() => {
+ this.#callbacks.delete(callback.id);
+ });
+ }
+
+ reject(id: number, message: string, originalMessage?: string): void {
+ const callback = this.#callbacks.get(id);
+ if (!callback) {
+ return;
+ }
+ this._reject(callback, message, originalMessage);
+ }
+
+ _reject(
+ callback: Callback,
+ errorMessage: string | ProtocolError,
+ originalMessage?: string
+ ): void {
+ let error: ProtocolError;
+ let message: string;
+ if (errorMessage instanceof ProtocolError) {
+ error = errorMessage;
+ error.cause = callback.error;
+ message = errorMessage.message;
+ } else {
+ error = callback.error;
+ message = errorMessage;
+ }
+
+ callback.reject(
+ rewriteError(
+ error,
+ `Protocol error (${callback.label}): ${message}`,
+ originalMessage
+ )
+ );
+ }
+
+ resolve(id: number, value: unknown): void {
+ const callback = this.#callbacks.get(id);
+ if (!callback) {
+ return;
+ }
+ callback.resolve(value);
+ }
+
+ clear(): void {
+ for (const callback of this.#callbacks.values()) {
+ // TODO: probably we can accept error messages as params.
+ this._reject(callback, new TargetCloseError('Target closed'));
+ }
+ this.#callbacks.clear();
+ }
+
+ /**
+ * @internal
+ */
+ getPendingProtocolErrors(): Error[] {
+ const result: Error[] = [];
+ for (const callback of this.#callbacks.values()) {
+ result.push(
+ new Error(`${callback.label} timed out. Trace: ${callback.error.stack}`)
+ );
+ }
+ return result;
+ }
+}
+/**
+ * @internal
+ */
+
+export class Callback {
+ #id: number;
+ #error = new ProtocolError();
+ #deferred = Deferred.create<unknown>();
+ #timer?: ReturnType<typeof setTimeout>;
+ #label: string;
+
+ constructor(id: number, label: string, timeout?: number) {
+ this.#id = id;
+ this.#label = label;
+ if (timeout) {
+ this.#timer = setTimeout(() => {
+ this.#deferred.reject(
+ rewriteError(
+ this.#error,
+ `${label} timed out. Increase the 'protocolTimeout' setting in launch/connect calls for a higher timeout if needed.`
+ )
+ );
+ }, timeout);
+ }
+ }
+
+ resolve(value: unknown): void {
+ clearTimeout(this.#timer);
+ this.#deferred.resolve(value);
+ }
+
+ reject(error: Error): void {
+ clearTimeout(this.#timer);
+ this.#deferred.reject(error);
+ }
+
+ get id(): number {
+ return this.#id;
+ }
+
+ get promise(): Deferred<unknown> {
+ return this.#deferred;
+ }
+
+ get error(): ProtocolError {
+ return this.#error;
+ }
+
+ get label(): string {
+ return this.#label;
+ }
+}
+
+/**
+ * @internal
+ */
+export function createIncrementalIdGenerator(): GetIdFn {
+ let id = 0;
+ return (): number => {
+ return ++id;
+ };
+}
+
+/**
+ * @internal
+ */
+export type GetIdFn = () => number;
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/Configuration.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/Configuration.ts
new file mode 100644
index 0000000000..c64d109a7c
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/Configuration.ts
@@ -0,0 +1,120 @@
+/**
+ * @license
+ * Copyright 2022 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {Product} from './Product.js';
+
+/**
+ * Defines experiment options for Puppeteer.
+ *
+ * See individual properties for more information.
+ *
+ * @public
+ */
+export type ExperimentsConfiguration = Record<string, never>;
+
+/**
+ * Defines options to configure Puppeteer's behavior during installation and
+ * runtime.
+ *
+ * See individual properties for more information.
+ *
+ * @public
+ */
+export interface Configuration {
+ /**
+ * Specifies a certain version of the browser you'd like Puppeteer to use.
+ *
+ * Can be overridden by `PUPPETEER_BROWSER_REVISION`.
+ *
+ * See {@link PuppeteerNode.launch | puppeteer.launch} on how executable path
+ * is inferred.
+ *
+ * @defaultValue A compatible-revision of the browser.
+ */
+ browserRevision?: string;
+ /**
+ * Defines the directory to be used by Puppeteer for caching.
+ *
+ * Can be overridden by `PUPPETEER_CACHE_DIR`.
+ *
+ * @defaultValue `path.join(os.homedir(), '.cache', 'puppeteer')`
+ */
+ cacheDirectory?: string;
+ /**
+ * Specifies the URL prefix that is used to download the browser.
+ *
+ * Can be overridden by `PUPPETEER_DOWNLOAD_BASE_URL`.
+ *
+ * @remarks
+ * This must include the protocol and may even need a path prefix.
+ *
+ * @defaultValue Either https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing or
+ * https://archive.mozilla.org/pub/firefox/nightly/latest-mozilla-central,
+ * depending on the product.
+ */
+ downloadBaseUrl?: string;
+ /**
+ * Specifies the path for the downloads folder.
+ *
+ * Can be overridden by `PUPPETEER_DOWNLOAD_PATH`.
+ *
+ * @defaultValue `<cacheDirectory>`
+ */
+ downloadPath?: string;
+ /**
+ * Specifies an executable path to be used in
+ * {@link PuppeteerNode.launch | puppeteer.launch}.
+ *
+ * Can be overridden by `PUPPETEER_EXECUTABLE_PATH`.
+ *
+ * @defaultValue **Auto-computed.**
+ */
+ executablePath?: string;
+ /**
+ * Specifies which browser you'd like Puppeteer to use.
+ *
+ * Can be overridden by `PUPPETEER_PRODUCT`.
+ *
+ * @defaultValue `chrome`
+ */
+ defaultProduct?: Product;
+ /**
+ * Defines the directory to be used by Puppeteer for creating temporary files.
+ *
+ * Can be overridden by `PUPPETEER_TMP_DIR`.
+ *
+ * @defaultValue `os.tmpdir()`
+ */
+ temporaryDirectory?: string;
+ /**
+ * Tells Puppeteer to not download during installation.
+ *
+ * Can be overridden by `PUPPETEER_SKIP_DOWNLOAD`.
+ */
+ skipDownload?: boolean;
+ /**
+ * Tells Puppeteer to not Chrome download during installation.
+ *
+ * Can be overridden by `PUPPETEER_SKIP_CHROME_DOWNLOAD`.
+ */
+ skipChromeDownload?: boolean;
+ /**
+ * Tells Puppeteer to not chrome-headless-shell download during installation.
+ *
+ * Can be overridden by `PUPPETEER_SKIP_CHROME_HEADLESSS_HELL_DOWNLOAD`.
+ */
+ skipChromeHeadlessShellDownload?: boolean;
+ /**
+ * Tells Puppeteer to log at the given level.
+ *
+ * @defaultValue `warn`
+ */
+ logLevel?: 'silent' | 'error' | 'warn';
+ /**
+ * Defines experimental options for Puppeteer.
+ */
+ experiments?: ExperimentsConfiguration;
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/ConnectOptions.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/ConnectOptions.ts
new file mode 100644
index 0000000000..ce46585162
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/ConnectOptions.ts
@@ -0,0 +1,77 @@
+/**
+ * @license
+ * Copyright 2020 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {
+ IsPageTargetCallback,
+ TargetFilterCallback,
+} from '../api/Browser.js';
+
+import type {ConnectionTransport} from './ConnectionTransport.js';
+import type {Viewport} from './Viewport.js';
+
+/**
+ * @public
+ */
+export type ProtocolType = 'cdp' | 'webDriverBiDi';
+
+/**
+ * Generic browser options that can be passed when launching any browser or when
+ * connecting to an existing browser instance.
+ * @public
+ */
+export interface BrowserConnectOptions {
+ /**
+ * Whether to ignore HTTPS errors during navigation.
+ * @defaultValue `false`
+ */
+ ignoreHTTPSErrors?: boolean;
+ /**
+ * Sets the viewport for each page.
+ *
+ * @defaultValue '\{width: 800, height: 600\}'
+ */
+ defaultViewport?: Viewport | null;
+ /**
+ * Slows down Puppeteer operations by the specified amount of milliseconds to
+ * aid debugging.
+ */
+ slowMo?: number;
+ /**
+ * Callback to decide if Puppeteer should connect to a given target or not.
+ */
+ targetFilter?: TargetFilterCallback;
+ /**
+ * @internal
+ */
+ _isPageTarget?: IsPageTargetCallback;
+
+ /**
+ * @defaultValue 'cdp'
+ * @public
+ */
+ protocol?: ProtocolType;
+ /**
+ * Timeout setting for individual protocol (CDP) calls.
+ *
+ * @defaultValue `180_000`
+ */
+ protocolTimeout?: number;
+}
+
+/**
+ * @public
+ */
+export interface ConnectOptions extends BrowserConnectOptions {
+ browserWSEndpoint?: string;
+ browserURL?: string;
+ transport?: ConnectionTransport;
+ /**
+ * Headers to use for the web socket connection.
+ * @remarks
+ * Only works in the Node.js environment.
+ */
+ headers?: Record<string, string>;
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/ConnectionTransport.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/ConnectionTransport.ts
new file mode 100644
index 0000000000..ff36a2557a
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/ConnectionTransport.ts
@@ -0,0 +1,15 @@
+/**
+ * @license
+ * Copyright 2020 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * @public
+ */
+export interface ConnectionTransport {
+ send(message: string): void;
+ close(): void;
+ onmessage?: (message: string) => void;
+ onclose?: () => void;
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/ConsoleMessage.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/ConsoleMessage.ts
new file mode 100644
index 0000000000..85d2db9f75
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/ConsoleMessage.ts
@@ -0,0 +1,113 @@
+/**
+ * @license
+ * Copyright 2020 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {JSHandle} from '../api/JSHandle.js';
+
+/**
+ * @public
+ */
+export interface ConsoleMessageLocation {
+ /**
+ * URL of the resource if known or `undefined` otherwise.
+ */
+ url?: string;
+
+ /**
+ * 0-based line number in the resource if known or `undefined` otherwise.
+ */
+ lineNumber?: number;
+
+ /**
+ * 0-based column number in the resource if known or `undefined` otherwise.
+ */
+ columnNumber?: number;
+}
+
+/**
+ * The supported types for console messages.
+ * @public
+ */
+export type ConsoleMessageType =
+ | 'log'
+ | 'debug'
+ | 'info'
+ | 'error'
+ | 'warning'
+ | 'dir'
+ | 'dirxml'
+ | 'table'
+ | 'trace'
+ | 'clear'
+ | 'startGroup'
+ | 'startGroupCollapsed'
+ | 'endGroup'
+ | 'assert'
+ | 'profile'
+ | 'profileEnd'
+ | 'count'
+ | 'timeEnd'
+ | 'verbose';
+
+/**
+ * ConsoleMessage objects are dispatched by page via the 'console' event.
+ * @public
+ */
+export class ConsoleMessage {
+ #type: ConsoleMessageType;
+ #text: string;
+ #args: JSHandle[];
+ #stackTraceLocations: ConsoleMessageLocation[];
+
+ /**
+ * @public
+ */
+ constructor(
+ type: ConsoleMessageType,
+ text: string,
+ args: JSHandle[],
+ stackTraceLocations: ConsoleMessageLocation[]
+ ) {
+ this.#type = type;
+ this.#text = text;
+ this.#args = args;
+ this.#stackTraceLocations = stackTraceLocations;
+ }
+
+ /**
+ * The type of the console message.
+ */
+ type(): ConsoleMessageType {
+ return this.#type;
+ }
+
+ /**
+ * The text of the console message.
+ */
+ text(): string {
+ return this.#text;
+ }
+
+ /**
+ * An array of arguments passed to the console.
+ */
+ args(): JSHandle[] {
+ return this.#args;
+ }
+
+ /**
+ * The location of the console message.
+ */
+ location(): ConsoleMessageLocation {
+ return this.#stackTraceLocations[0] ?? {};
+ }
+
+ /**
+ * The array of locations on the stack of the console message.
+ */
+ stackTrace(): ConsoleMessageLocation[] {
+ return this.#stackTraceLocations;
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/CustomQueryHandler.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/CustomQueryHandler.ts
new file mode 100644
index 0000000000..33e5f889c1
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/CustomQueryHandler.ts
@@ -0,0 +1,207 @@
+/**
+ * @license
+ * Copyright 2023 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type PuppeteerUtil from '../injected/injected.js';
+import {assert} from '../util/assert.js';
+import {interpolateFunction, stringifyFunction} from '../util/Function.js';
+
+import {
+ QueryHandler,
+ type QuerySelector,
+ type QuerySelectorAll,
+} from './QueryHandler.js';
+import {scriptInjector} from './ScriptInjector.js';
+
+/**
+ * @public
+ */
+export interface CustomQueryHandler {
+ /**
+ * Searches for a {@link https://developer.mozilla.org/en-US/docs/Web/API/Node | Node} matching the given `selector` from {@link https://developer.mozilla.org/en-US/docs/Web/API/Node | node}.
+ */
+ queryOne?: (node: Node, selector: string) => Node | null;
+ /**
+ * Searches for some {@link https://developer.mozilla.org/en-US/docs/Web/API/Node | Nodes} matching the given `selector` from {@link https://developer.mozilla.org/en-US/docs/Web/API/Node | node}.
+ */
+ queryAll?: (node: Node, selector: string) => Iterable<Node>;
+}
+
+/**
+ * The registry of {@link CustomQueryHandler | custom query handlers}.
+ *
+ * @example
+ *
+ * ```ts
+ * Puppeteer.customQueryHandlers.register('lit', { … });
+ * const aHandle = await page.$('lit/…');
+ * ```
+ *
+ * @internal
+ */
+export class CustomQueryHandlerRegistry {
+ #handlers = new Map<
+ string,
+ [registerScript: string, Handler: typeof QueryHandler]
+ >();
+
+ get(name: string): typeof QueryHandler | undefined {
+ const handler = this.#handlers.get(name);
+ return handler ? handler[1] : undefined;
+ }
+
+ /**
+ * Registers a {@link CustomQueryHandler | custom query handler}.
+ *
+ * @remarks
+ * After registration, the handler can be used everywhere where a selector is
+ * expected by prepending the selection string with `<name>/`. The name is
+ * only allowed to consist of lower- and upper case latin letters.
+ *
+ * @example
+ *
+ * ```ts
+ * Puppeteer.customQueryHandlers.register('lit', { … });
+ * const aHandle = await page.$('lit/…');
+ * ```
+ *
+ * @param name - Name to register under.
+ * @param queryHandler - {@link CustomQueryHandler | Custom query handler} to
+ * register.
+ */
+ register(name: string, handler: CustomQueryHandler): void {
+ assert(
+ !this.#handlers.has(name),
+ `Cannot register over existing handler: ${name}`
+ );
+ assert(
+ /^[a-zA-Z]+$/.test(name),
+ `Custom query handler names may only contain [a-zA-Z]`
+ );
+ assert(
+ handler.queryAll || handler.queryOne,
+ `At least one query method must be implemented.`
+ );
+
+ const Handler = class extends QueryHandler {
+ static override querySelectorAll: QuerySelectorAll = interpolateFunction(
+ (node, selector, PuppeteerUtil) => {
+ return PuppeteerUtil.customQuerySelectors
+ .get(PLACEHOLDER('name'))!
+ .querySelectorAll(node, selector);
+ },
+ {name: JSON.stringify(name)}
+ );
+ static override querySelector: QuerySelector = interpolateFunction(
+ (node, selector, PuppeteerUtil) => {
+ return PuppeteerUtil.customQuerySelectors
+ .get(PLACEHOLDER('name'))!
+ .querySelector(node, selector);
+ },
+ {name: JSON.stringify(name)}
+ );
+ };
+ const registerScript = interpolateFunction(
+ (PuppeteerUtil: PuppeteerUtil) => {
+ PuppeteerUtil.customQuerySelectors.register(PLACEHOLDER('name'), {
+ queryAll: PLACEHOLDER('queryAll'),
+ queryOne: PLACEHOLDER('queryOne'),
+ });
+ },
+ {
+ name: JSON.stringify(name),
+ queryAll: handler.queryAll
+ ? stringifyFunction(handler.queryAll)
+ : String(undefined),
+ queryOne: handler.queryOne
+ ? stringifyFunction(handler.queryOne)
+ : String(undefined),
+ }
+ ).toString();
+
+ this.#handlers.set(name, [registerScript, Handler]);
+ scriptInjector.append(registerScript);
+ }
+
+ /**
+ * Unregisters the {@link CustomQueryHandler | custom query handler} for the
+ * given name.
+ *
+ * @throws `Error` if there is no handler under the given name.
+ */
+ unregister(name: string): void {
+ const handler = this.#handlers.get(name);
+ if (!handler) {
+ throw new Error(`Cannot unregister unknown handler: ${name}`);
+ }
+ scriptInjector.pop(handler[0]);
+ this.#handlers.delete(name);
+ }
+
+ /**
+ * Gets the names of all {@link CustomQueryHandler | custom query handlers}.
+ */
+ names(): string[] {
+ return [...this.#handlers.keys()];
+ }
+
+ /**
+ * Unregisters all custom query handlers.
+ */
+ clear(): void {
+ for (const [registerScript] of this.#handlers) {
+ scriptInjector.pop(registerScript);
+ }
+ this.#handlers.clear();
+ }
+}
+
+/**
+ * @internal
+ */
+export const customQueryHandlers = new CustomQueryHandlerRegistry();
+
+/**
+ * @deprecated Import {@link Puppeteer} and use the static method
+ * {@link Puppeteer.registerCustomQueryHandler}
+ *
+ * @public
+ */
+export function registerCustomQueryHandler(
+ name: string,
+ handler: CustomQueryHandler
+): void {
+ customQueryHandlers.register(name, handler);
+}
+
+/**
+ * @deprecated Import {@link Puppeteer} and use the static method
+ * {@link Puppeteer.unregisterCustomQueryHandler}
+ *
+ * @public
+ */
+export function unregisterCustomQueryHandler(name: string): void {
+ customQueryHandlers.unregister(name);
+}
+
+/**
+ * @deprecated Import {@link Puppeteer} and use the static method
+ * {@link Puppeteer.customQueryHandlerNames}
+ *
+ * @public
+ */
+export function customQueryHandlerNames(): string[] {
+ return customQueryHandlers.names();
+}
+
+/**
+ * @deprecated Import {@link Puppeteer} and use the static method
+ * {@link Puppeteer.clearCustomQueryHandlers}
+ *
+ * @public
+ */
+export function clearCustomQueryHandlers(): void {
+ customQueryHandlers.clear();
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/Debug.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/Debug.ts
new file mode 100644
index 0000000000..06ac9f58f9
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/Debug.ts
@@ -0,0 +1,128 @@
+/**
+ * @license
+ * Copyright 2020 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type Debug from 'debug';
+
+import {isNode} from '../environment.js';
+
+declare global {
+ // eslint-disable-next-line no-var
+ var __PUPPETEER_DEBUG: string;
+}
+
+/**
+ * @internal
+ */
+let debugModule: typeof Debug | null = null;
+/**
+ * @internal
+ */
+export async function importDebug(): Promise<typeof Debug> {
+ if (!debugModule) {
+ debugModule = (await import('debug')).default;
+ }
+ return debugModule;
+}
+
+/**
+ * A debug function that can be used in any environment.
+ *
+ * @remarks
+ * If used in Node, it falls back to the
+ * {@link https://www.npmjs.com/package/debug | debug module}. In the browser it
+ * uses `console.log`.
+ *
+ * In Node, use the `DEBUG` environment variable to control logging:
+ *
+ * ```
+ * DEBUG=* // logs all channels
+ * DEBUG=foo // logs the `foo` channel
+ * DEBUG=foo* // logs any channels starting with `foo`
+ * ```
+ *
+ * In the browser, set `window.__PUPPETEER_DEBUG` to a string:
+ *
+ * ```
+ * window.__PUPPETEER_DEBUG='*'; // logs all channels
+ * window.__PUPPETEER_DEBUG='foo'; // logs the `foo` channel
+ * window.__PUPPETEER_DEBUG='foo*'; // logs any channels starting with `foo`
+ * ```
+ *
+ * @example
+ *
+ * ```
+ * const log = debug('Page');
+ *
+ * log('new page created')
+ * // logs "Page: new page created"
+ * ```
+ *
+ * @param prefix - this will be prefixed to each log.
+ * @returns a function that can be called to log to that debug channel.
+ *
+ * @internal
+ */
+export const debug = (prefix: string): ((...args: unknown[]) => void) => {
+ if (isNode) {
+ return async (...logArgs: unknown[]) => {
+ if (captureLogs) {
+ capturedLogs.push(prefix + logArgs);
+ }
+ (await importDebug())(prefix)(logArgs);
+ };
+ }
+
+ return (...logArgs: unknown[]): void => {
+ const debugLevel = (globalThis as any).__PUPPETEER_DEBUG;
+ if (!debugLevel) {
+ return;
+ }
+
+ const everythingShouldBeLogged = debugLevel === '*';
+
+ const prefixMatchesDebugLevel =
+ everythingShouldBeLogged ||
+ /**
+ * If the debug level is `foo*`, that means we match any prefix that
+ * starts with `foo`. If the level is `foo`, we match only the prefix
+ * `foo`.
+ */
+ (debugLevel.endsWith('*')
+ ? prefix.startsWith(debugLevel)
+ : prefix === debugLevel);
+
+ if (!prefixMatchesDebugLevel) {
+ return;
+ }
+
+ // eslint-disable-next-line no-console
+ console.log(`${prefix}:`, ...logArgs);
+ };
+};
+
+/**
+ * @internal
+ */
+let capturedLogs: string[] = [];
+/**
+ * @internal
+ */
+let captureLogs = false;
+
+/**
+ * @internal
+ */
+export function setLogCapture(value: boolean): void {
+ capturedLogs = [];
+ captureLogs = value;
+}
+
+/**
+ * @internal
+ */
+export function getCapturedLogs(): string[] {
+ return capturedLogs;
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/Device.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/Device.ts
new file mode 100644
index 0000000000..dbf5c13c95
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/Device.ts
@@ -0,0 +1,1552 @@
+/**
+ * @license
+ * Copyright 2017 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {Viewport} from './Viewport.js';
+
+/**
+ * @public
+ */
+export interface Device {
+ userAgent: string;
+ viewport: Viewport;
+}
+
+const knownDevices = [
+ {
+ name: 'Blackberry PlayBook',
+ userAgent:
+ 'Mozilla/5.0 (PlayBook; U; RIM Tablet OS 2.1.0; en-US) AppleWebKit/536.2+ (KHTML like Gecko) Version/7.2.1.0 Safari/536.2+',
+ viewport: {
+ width: 600,
+ height: 1024,
+ deviceScaleFactor: 1,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'Blackberry PlayBook landscape',
+ userAgent:
+ 'Mozilla/5.0 (PlayBook; U; RIM Tablet OS 2.1.0; en-US) AppleWebKit/536.2+ (KHTML like Gecko) Version/7.2.1.0 Safari/536.2+',
+ viewport: {
+ width: 1024,
+ height: 600,
+ deviceScaleFactor: 1,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'BlackBerry Z30',
+ userAgent:
+ 'Mozilla/5.0 (BB10; Touch) AppleWebKit/537.10+ (KHTML, like Gecko) Version/10.0.9.2372 Mobile Safari/537.10+',
+ viewport: {
+ width: 360,
+ height: 640,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'BlackBerry Z30 landscape',
+ userAgent:
+ 'Mozilla/5.0 (BB10; Touch) AppleWebKit/537.10+ (KHTML, like Gecko) Version/10.0.9.2372 Mobile Safari/537.10+',
+ viewport: {
+ width: 640,
+ height: 360,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'Galaxy Note 3',
+ userAgent:
+ 'Mozilla/5.0 (Linux; U; Android 4.3; en-us; SM-N900T Build/JSS15J) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30',
+ viewport: {
+ width: 360,
+ height: 640,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'Galaxy Note 3 landscape',
+ userAgent:
+ 'Mozilla/5.0 (Linux; U; Android 4.3; en-us; SM-N900T Build/JSS15J) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30',
+ viewport: {
+ width: 640,
+ height: 360,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'Galaxy Note II',
+ userAgent:
+ 'Mozilla/5.0 (Linux; U; Android 4.1; en-us; GT-N7100 Build/JRO03C) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30',
+ viewport: {
+ width: 360,
+ height: 640,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'Galaxy Note II landscape',
+ userAgent:
+ 'Mozilla/5.0 (Linux; U; Android 4.1; en-us; GT-N7100 Build/JRO03C) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30',
+ viewport: {
+ width: 640,
+ height: 360,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'Galaxy S III',
+ userAgent:
+ 'Mozilla/5.0 (Linux; U; Android 4.0; en-us; GT-I9300 Build/IMM76D) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30',
+ viewport: {
+ width: 360,
+ height: 640,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'Galaxy S III landscape',
+ userAgent:
+ 'Mozilla/5.0 (Linux; U; Android 4.0; en-us; GT-I9300 Build/IMM76D) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30',
+ viewport: {
+ width: 640,
+ height: 360,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'Galaxy S5',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36',
+ viewport: {
+ width: 360,
+ height: 640,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'Galaxy S5 landscape',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36',
+ viewport: {
+ width: 640,
+ height: 360,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'Galaxy S8',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.84 Mobile Safari/537.36',
+ viewport: {
+ width: 360,
+ height: 740,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'Galaxy S8 landscape',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.84 Mobile Safari/537.36',
+ viewport: {
+ width: 740,
+ height: 360,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'Galaxy S9+',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.111 Mobile Safari/537.36',
+ viewport: {
+ width: 320,
+ height: 658,
+ deviceScaleFactor: 4.5,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'Galaxy S9+ landscape',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.111 Mobile Safari/537.36',
+ viewport: {
+ width: 658,
+ height: 320,
+ deviceScaleFactor: 4.5,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'Galaxy Tab S4',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.80 Safari/537.36',
+ viewport: {
+ width: 712,
+ height: 1138,
+ deviceScaleFactor: 2.25,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'Galaxy Tab S4 landscape',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.80 Safari/537.36',
+ viewport: {
+ width: 1138,
+ height: 712,
+ deviceScaleFactor: 2.25,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPad',
+ userAgent:
+ 'Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1',
+ viewport: {
+ width: 768,
+ height: 1024,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPad landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1',
+ viewport: {
+ width: 1024,
+ height: 768,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPad (gen 6)',
+ userAgent:
+ 'Mozilla/5.0 (iPad; CPU OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 768,
+ height: 1024,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPad (gen 6) landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPad; CPU OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 1024,
+ height: 768,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPad (gen 7)',
+ userAgent:
+ 'Mozilla/5.0 (iPad; CPU OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 810,
+ height: 1080,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPad (gen 7) landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPad; CPU OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 1080,
+ height: 810,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPad Mini',
+ userAgent:
+ 'Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1',
+ viewport: {
+ width: 768,
+ height: 1024,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPad Mini landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1',
+ viewport: {
+ width: 1024,
+ height: 768,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPad Pro',
+ userAgent:
+ 'Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1',
+ viewport: {
+ width: 1024,
+ height: 1366,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPad Pro landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1',
+ viewport: {
+ width: 1366,
+ height: 1024,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPad Pro 11',
+ userAgent:
+ 'Mozilla/5.0 (iPad; CPU OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 834,
+ height: 1194,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPad Pro 11 landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPad; CPU OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 1194,
+ height: 834,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPhone 4',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 7_1_2 like Mac OS X) AppleWebKit/537.51.2 (KHTML, like Gecko) Version/7.0 Mobile/11D257 Safari/9537.53',
+ viewport: {
+ width: 320,
+ height: 480,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPhone 4 landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 7_1_2 like Mac OS X) AppleWebKit/537.51.2 (KHTML, like Gecko) Version/7.0 Mobile/11D257 Safari/9537.53',
+ viewport: {
+ width: 480,
+ height: 320,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPhone 5',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1',
+ viewport: {
+ width: 320,
+ height: 568,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPhone 5 landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1',
+ viewport: {
+ width: 568,
+ height: 320,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPhone 6',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1',
+ viewport: {
+ width: 375,
+ height: 667,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPhone 6 landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1',
+ viewport: {
+ width: 667,
+ height: 375,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPhone 6 Plus',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1',
+ viewport: {
+ width: 414,
+ height: 736,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPhone 6 Plus landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1',
+ viewport: {
+ width: 736,
+ height: 414,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPhone 7',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1',
+ viewport: {
+ width: 375,
+ height: 667,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPhone 7 landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1',
+ viewport: {
+ width: 667,
+ height: 375,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPhone 7 Plus',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1',
+ viewport: {
+ width: 414,
+ height: 736,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPhone 7 Plus landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1',
+ viewport: {
+ width: 736,
+ height: 414,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPhone 8',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1',
+ viewport: {
+ width: 375,
+ height: 667,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPhone 8 landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1',
+ viewport: {
+ width: 667,
+ height: 375,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPhone 8 Plus',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1',
+ viewport: {
+ width: 414,
+ height: 736,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPhone 8 Plus landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1',
+ viewport: {
+ width: 736,
+ height: 414,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPhone SE',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1',
+ viewport: {
+ width: 320,
+ height: 568,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPhone SE landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1',
+ viewport: {
+ width: 568,
+ height: 320,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPhone X',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1',
+ viewport: {
+ width: 375,
+ height: 812,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPhone X landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1',
+ viewport: {
+ width: 812,
+ height: 375,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPhone XR',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 12_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.0 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 414,
+ height: 896,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPhone XR landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 12_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.0 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 896,
+ height: 414,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPhone 11',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 414,
+ height: 828,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPhone 11 landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 828,
+ height: 414,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPhone 11 Pro',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 375,
+ height: 812,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPhone 11 Pro landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 812,
+ height: 375,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPhone 11 Pro Max',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 414,
+ height: 896,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPhone 11 Pro Max landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 896,
+ height: 414,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPhone 12',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 390,
+ height: 844,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPhone 12 landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 844,
+ height: 390,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPhone 12 Pro',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 390,
+ height: 844,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPhone 12 Pro landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 844,
+ height: 390,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPhone 12 Pro Max',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 428,
+ height: 926,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPhone 12 Pro Max landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 926,
+ height: 428,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPhone 12 Mini',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 375,
+ height: 812,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPhone 12 Mini landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 812,
+ height: 375,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPhone 13',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 390,
+ height: 844,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPhone 13 landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 844,
+ height: 390,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPhone 13 Pro',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 390,
+ height: 844,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPhone 13 Pro landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 844,
+ height: 390,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPhone 13 Pro Max',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 428,
+ height: 926,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPhone 13 Pro Max landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 926,
+ height: 428,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPhone 13 Mini',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 375,
+ height: 812,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPhone 13 Mini landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 812,
+ height: 375,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'JioPhone 2',
+ userAgent:
+ 'Mozilla/5.0 (Mobile; LYF/F300B/LYF-F300B-001-01-15-130718-i;Android; rv:48.0) Gecko/48.0 Firefox/48.0 KAIOS/2.5',
+ viewport: {
+ width: 240,
+ height: 320,
+ deviceScaleFactor: 1,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'JioPhone 2 landscape',
+ userAgent:
+ 'Mozilla/5.0 (Mobile; LYF/F300B/LYF-F300B-001-01-15-130718-i;Android; rv:48.0) Gecko/48.0 Firefox/48.0 KAIOS/2.5',
+ viewport: {
+ width: 320,
+ height: 240,
+ deviceScaleFactor: 1,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'Kindle Fire HDX',
+ userAgent:
+ 'Mozilla/5.0 (Linux; U; en-us; KFAPWI Build/JDQ39) AppleWebKit/535.19 (KHTML, like Gecko) Silk/3.13 Safari/535.19 Silk-Accelerated=true',
+ viewport: {
+ width: 800,
+ height: 1280,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'Kindle Fire HDX landscape',
+ userAgent:
+ 'Mozilla/5.0 (Linux; U; en-us; KFAPWI Build/JDQ39) AppleWebKit/535.19 (KHTML, like Gecko) Silk/3.13 Safari/535.19 Silk-Accelerated=true',
+ viewport: {
+ width: 1280,
+ height: 800,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'LG Optimus L70',
+ userAgent:
+ 'Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/75.0.3765.0 Mobile Safari/537.36',
+ viewport: {
+ width: 384,
+ height: 640,
+ deviceScaleFactor: 1.25,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'LG Optimus L70 landscape',
+ userAgent:
+ 'Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/75.0.3765.0 Mobile Safari/537.36',
+ viewport: {
+ width: 640,
+ height: 384,
+ deviceScaleFactor: 1.25,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'Microsoft Lumia 550',
+ userAgent:
+ 'Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2486.0 Mobile Safari/537.36 Edge/14.14263',
+ viewport: {
+ width: 640,
+ height: 360,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'Microsoft Lumia 950',
+ userAgent:
+ 'Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2486.0 Mobile Safari/537.36 Edge/14.14263',
+ viewport: {
+ width: 360,
+ height: 640,
+ deviceScaleFactor: 4,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'Microsoft Lumia 950 landscape',
+ userAgent:
+ 'Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2486.0 Mobile Safari/537.36 Edge/14.14263',
+ viewport: {
+ width: 640,
+ height: 360,
+ deviceScaleFactor: 4,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'Nexus 10',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Safari/537.36',
+ viewport: {
+ width: 800,
+ height: 1280,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'Nexus 10 landscape',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Safari/537.36',
+ viewport: {
+ width: 1280,
+ height: 800,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'Nexus 4',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36',
+ viewport: {
+ width: 384,
+ height: 640,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'Nexus 4 landscape',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36',
+ viewport: {
+ width: 640,
+ height: 384,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'Nexus 5',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36',
+ viewport: {
+ width: 360,
+ height: 640,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'Nexus 5 landscape',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36',
+ viewport: {
+ width: 640,
+ height: 360,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'Nexus 5X',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36',
+ viewport: {
+ width: 412,
+ height: 732,
+ deviceScaleFactor: 2.625,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'Nexus 5X landscape',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36',
+ viewport: {
+ width: 732,
+ height: 412,
+ deviceScaleFactor: 2.625,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'Nexus 6',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36',
+ viewport: {
+ width: 412,
+ height: 732,
+ deviceScaleFactor: 3.5,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'Nexus 6 landscape',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36',
+ viewport: {
+ width: 732,
+ height: 412,
+ deviceScaleFactor: 3.5,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'Nexus 6P',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36',
+ viewport: {
+ width: 412,
+ height: 732,
+ deviceScaleFactor: 3.5,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'Nexus 6P landscape',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36',
+ viewport: {
+ width: 732,
+ height: 412,
+ deviceScaleFactor: 3.5,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'Nexus 7',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Safari/537.36',
+ viewport: {
+ width: 600,
+ height: 960,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'Nexus 7 landscape',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Safari/537.36',
+ viewport: {
+ width: 960,
+ height: 600,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'Nokia Lumia 520',
+ userAgent:
+ 'Mozilla/5.0 (compatible; MSIE 10.0; Windows Phone 8.0; Trident/6.0; IEMobile/10.0; ARM; Touch; NOKIA; Lumia 520)',
+ viewport: {
+ width: 320,
+ height: 533,
+ deviceScaleFactor: 1.5,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'Nokia Lumia 520 landscape',
+ userAgent:
+ 'Mozilla/5.0 (compatible; MSIE 10.0; Windows Phone 8.0; Trident/6.0; IEMobile/10.0; ARM; Touch; NOKIA; Lumia 520)',
+ viewport: {
+ width: 533,
+ height: 320,
+ deviceScaleFactor: 1.5,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'Nokia N9',
+ userAgent:
+ 'Mozilla/5.0 (MeeGo; NokiaN9) AppleWebKit/534.13 (KHTML, like Gecko) NokiaBrowser/8.5.0 Mobile Safari/534.13',
+ viewport: {
+ width: 480,
+ height: 854,
+ deviceScaleFactor: 1,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'Nokia N9 landscape',
+ userAgent:
+ 'Mozilla/5.0 (MeeGo; NokiaN9) AppleWebKit/534.13 (KHTML, like Gecko) NokiaBrowser/8.5.0 Mobile Safari/534.13',
+ viewport: {
+ width: 854,
+ height: 480,
+ deviceScaleFactor: 1,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'Pixel 2',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36',
+ viewport: {
+ width: 411,
+ height: 731,
+ deviceScaleFactor: 2.625,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'Pixel 2 landscape',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36',
+ viewport: {
+ width: 731,
+ height: 411,
+ deviceScaleFactor: 2.625,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'Pixel 2 XL',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36',
+ viewport: {
+ width: 411,
+ height: 823,
+ deviceScaleFactor: 3.5,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'Pixel 2 XL landscape',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36',
+ viewport: {
+ width: 823,
+ height: 411,
+ deviceScaleFactor: 3.5,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'Pixel 3',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.158 Mobile Safari/537.36',
+ viewport: {
+ width: 393,
+ height: 786,
+ deviceScaleFactor: 2.75,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'Pixel 3 landscape',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.158 Mobile Safari/537.36',
+ viewport: {
+ width: 786,
+ height: 393,
+ deviceScaleFactor: 2.75,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'Pixel 4',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.138 Mobile Safari/537.36',
+ viewport: {
+ width: 353,
+ height: 745,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'Pixel 4 landscape',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.138 Mobile Safari/537.36',
+ viewport: {
+ width: 745,
+ height: 353,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'Pixel 4a (5G)',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4812.0 Mobile Safari/537.36',
+ viewport: {
+ width: 353,
+ height: 745,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'Pixel 4a (5G) landscape',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4812.0 Mobile Safari/537.36',
+ viewport: {
+ width: 745,
+ height: 353,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'Pixel 5',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4812.0 Mobile Safari/537.36',
+ viewport: {
+ width: 393,
+ height: 851,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'Pixel 5 landscape',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4812.0 Mobile Safari/537.36',
+ viewport: {
+ width: 851,
+ height: 393,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'Moto G4',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4812.0 Mobile Safari/537.36',
+ viewport: {
+ width: 360,
+ height: 640,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'Moto G4 landscape',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4812.0 Mobile Safari/537.36',
+ viewport: {
+ width: 640,
+ height: 360,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+] as const;
+
+const knownDevicesByName = {} as Record<
+ (typeof knownDevices)[number]['name'],
+ Device
+>;
+
+for (const device of knownDevices) {
+ knownDevicesByName[device.name] = device;
+}
+
+/**
+ * A list of devices to be used with {@link Page.emulate}.
+ *
+ * @example
+ *
+ * ```ts
+ * import {KnownDevices} from 'puppeteer';
+ * const iPhone = KnownDevices['iPhone 6'];
+ *
+ * (async () => {
+ * const browser = await puppeteer.launch();
+ * const page = await browser.newPage();
+ * await page.emulate(iPhone);
+ * await page.goto('https://www.google.com');
+ * // other actions...
+ * await browser.close();
+ * })();
+ * ```
+ *
+ * @public
+ */
+export const KnownDevices = Object.freeze(knownDevicesByName);
+
+/**
+ * @deprecated Import {@link KnownDevices}
+ *
+ * @public
+ */
+export const devices = KnownDevices;
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/Errors.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/Errors.ts
new file mode 100644
index 0000000000..8225d64f07
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/Errors.ts
@@ -0,0 +1,124 @@
+/**
+ * @license
+ * Copyright 2018 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * @deprecated Do not use.
+ *
+ * @public
+ */
+export class CustomError extends Error {
+ /**
+ * @internal
+ */
+ constructor(message?: string) {
+ super(message);
+ this.name = this.constructor.name;
+ }
+
+ /**
+ * @internal
+ */
+ get [Symbol.toStringTag](): string {
+ return this.constructor.name;
+ }
+}
+
+/**
+ * TimeoutError is emitted whenever certain operations are terminated due to
+ * timeout.
+ *
+ * @remarks
+ * Example operations are {@link Page.waitForSelector | page.waitForSelector} or
+ * {@link PuppeteerNode.launch | puppeteer.launch}.
+ *
+ * @public
+ */
+export class TimeoutError extends CustomError {}
+
+/**
+ * ProtocolError is emitted whenever there is an error from the protocol.
+ *
+ * @public
+ */
+export class ProtocolError extends CustomError {
+ #code?: number;
+ #originalMessage = '';
+
+ set code(code: number | undefined) {
+ this.#code = code;
+ }
+ /**
+ * @readonly
+ * @public
+ */
+ get code(): number | undefined {
+ return this.#code;
+ }
+
+ set originalMessage(originalMessage: string) {
+ this.#originalMessage = originalMessage;
+ }
+ /**
+ * @readonly
+ * @public
+ */
+ get originalMessage(): string {
+ return this.#originalMessage;
+ }
+}
+
+/**
+ * Puppeteer will throw this error if a method is not
+ * supported by the currently used protocol
+ *
+ * @public
+ */
+export class UnsupportedOperation extends CustomError {}
+
+/**
+ * @internal
+ */
+export class TargetCloseError extends ProtocolError {}
+
+/**
+ * @deprecated Do not use.
+ *
+ * @public
+ */
+export interface PuppeteerErrors {
+ TimeoutError: typeof TimeoutError;
+ ProtocolError: typeof ProtocolError;
+}
+
+/**
+ * @deprecated Import error classes directly.
+ *
+ * Puppeteer methods might throw errors if they are unable to fulfill a request.
+ * For example, `page.waitForSelector(selector[, options])` might fail if the
+ * selector doesn't match any nodes during the given timeframe.
+ *
+ * For certain types of errors Puppeteer uses specific error classes. These
+ * classes are available via `puppeteer.errors`.
+ *
+ * @example
+ * An example of handling a timeout error:
+ *
+ * ```ts
+ * try {
+ * await page.waitForSelector('.foo');
+ * } catch (e) {
+ * if (e instanceof TimeoutError) {
+ * // Do something if this is a timeout.
+ * }
+ * }
+ * ```
+ *
+ * @public
+ */
+export const errors: PuppeteerErrors = Object.freeze({
+ TimeoutError,
+ ProtocolError,
+});
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/EventEmitter.test.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/EventEmitter.test.ts
new file mode 100644
index 0000000000..cf05ef6700
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/EventEmitter.test.ts
@@ -0,0 +1,185 @@
+/**
+ * @license
+ * Copyright 2023 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {describe, it, beforeEach} from 'node:test';
+
+import expect from 'expect';
+import sinon from 'sinon';
+
+import {EventEmitter} from './EventEmitter.js';
+
+describe('EventEmitter', () => {
+ let emitter: EventEmitter<Record<string, unknown>>;
+
+ beforeEach(() => {
+ emitter = new EventEmitter();
+ });
+
+ describe('on', () => {
+ const onTests = (methodName: 'on' | 'addListener'): void => {
+ it(`${methodName}: adds an event listener that is fired when the event is emitted`, () => {
+ const listener = sinon.spy();
+ emitter[methodName]('foo', listener);
+ emitter.emit('foo', undefined);
+ expect(listener.callCount).toEqual(1);
+ });
+
+ it(`${methodName} sends the event data to the handler`, () => {
+ const listener = sinon.spy();
+ const data = {};
+ emitter[methodName]('foo', listener);
+ emitter.emit('foo', data);
+ expect(listener.callCount).toEqual(1);
+ expect(listener.firstCall.args[0]).toBe(data);
+ });
+
+ it(`${methodName}: supports chaining`, () => {
+ const listener = sinon.spy();
+ const returnValue = emitter[methodName]('foo', listener);
+ expect(returnValue).toBe(emitter);
+ });
+ };
+ onTests('on');
+ // we support addListener for legacy reasons
+ onTests('addListener');
+ });
+
+ describe('off', () => {
+ const offTests = (methodName: 'off' | 'removeListener'): void => {
+ it(`${methodName}: removes the listener so it is no longer called`, () => {
+ const listener = sinon.spy();
+ emitter.on('foo', listener);
+ emitter.emit('foo', undefined);
+ expect(listener.callCount).toEqual(1);
+ emitter.off('foo', listener);
+ emitter.emit('foo', undefined);
+ expect(listener.callCount).toEqual(1);
+ });
+
+ it(`${methodName}: supports chaining`, () => {
+ const listener = sinon.spy();
+ emitter.on('foo', listener);
+ const returnValue = emitter.off('foo', listener);
+ expect(returnValue).toBe(emitter);
+ });
+ };
+ offTests('off');
+ // we support removeListener for legacy reasons
+ offTests('removeListener');
+ });
+
+ describe('once', () => {
+ it('only calls the listener once and then removes it', () => {
+ const listener = sinon.spy();
+ emitter.once('foo', listener);
+ emitter.emit('foo', undefined);
+ expect(listener.callCount).toEqual(1);
+ emitter.emit('foo', undefined);
+ expect(listener.callCount).toEqual(1);
+ });
+
+ it('supports chaining', () => {
+ const listener = sinon.spy();
+ const returnValue = emitter.once('foo', listener);
+ expect(returnValue).toBe(emitter);
+ });
+ });
+
+ describe('emit', () => {
+ it('calls all the listeners for an event', () => {
+ const listener1 = sinon.spy();
+ const listener2 = sinon.spy();
+ const listener3 = sinon.spy();
+ emitter.on('foo', listener1).on('foo', listener2).on('bar', listener3);
+
+ emitter.emit('foo', undefined);
+
+ expect(listener1.callCount).toEqual(1);
+ expect(listener2.callCount).toEqual(1);
+ expect(listener3.callCount).toEqual(0);
+ });
+
+ it('passes data through to the listener', () => {
+ const listener = sinon.spy();
+ emitter.on('foo', listener);
+ const data = {};
+
+ emitter.emit('foo', data);
+ expect(listener.callCount).toEqual(1);
+ expect(listener.firstCall.args[0]).toBe(data);
+ });
+
+ it('returns true if the event has listeners', () => {
+ const listener = sinon.spy();
+ emitter.on('foo', listener);
+ expect(emitter.emit('foo', undefined)).toBe(true);
+ });
+
+ it('returns false if the event has listeners', () => {
+ const listener = sinon.spy();
+ emitter.on('foo', listener);
+ expect(emitter.emit('notFoo', undefined)).toBe(false);
+ });
+ });
+
+ describe('listenerCount', () => {
+ it('returns the number of listeners for the given event', () => {
+ emitter.on('foo', () => {});
+ emitter.on('foo', () => {});
+ emitter.on('bar', () => {});
+ expect(emitter.listenerCount('foo')).toEqual(2);
+ expect(emitter.listenerCount('bar')).toEqual(1);
+ expect(emitter.listenerCount('noListeners')).toEqual(0);
+ });
+ });
+
+ describe('removeAllListeners', () => {
+ it('removes every listener from all events by default', () => {
+ emitter.on('foo', () => {}).on('bar', () => {});
+
+ emitter.removeAllListeners();
+ expect(emitter.emit('foo', undefined)).toBe(false);
+ expect(emitter.emit('bar', undefined)).toBe(false);
+ });
+
+ it('returns the emitter for chaining', () => {
+ expect(emitter.removeAllListeners()).toBe(emitter);
+ });
+
+ it('can filter to remove only listeners for a given event name', () => {
+ emitter
+ .on('foo', () => {})
+ .on('bar', () => {})
+ .on('bar', () => {});
+
+ emitter.removeAllListeners('bar');
+ expect(emitter.emit('foo', undefined)).toBe(true);
+ expect(emitter.emit('bar', undefined)).toBe(false);
+ });
+ });
+
+ describe('dispose', () => {
+ it('should dispose higher order emitters properly', () => {
+ let values = '';
+ emitter.on('foo', () => {
+ values += '1';
+ });
+ const higherOrderEmitter = new EventEmitter(emitter);
+
+ higherOrderEmitter.on('foo', () => {
+ values += '2';
+ });
+ higherOrderEmitter.emit('foo', undefined);
+
+ expect(values).toMatch('12');
+
+ higherOrderEmitter.off('foo');
+ higherOrderEmitter.emit('foo', undefined);
+
+ expect(values).toMatch('121');
+ });
+ });
+});
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/EventEmitter.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/EventEmitter.ts
new file mode 100644
index 0000000000..4a8bcb801f
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/EventEmitter.ts
@@ -0,0 +1,253 @@
+/**
+ * @license
+ * Copyright 2022 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import mitt, {type Emitter} from '../../third_party/mitt/mitt.js';
+import {disposeSymbol} from '../util/disposable.js';
+
+/**
+ * @public
+ */
+export type EventType = string | symbol;
+
+/**
+ * @public
+ */
+export type Handler<T = unknown> = (event: T) => void;
+
+/**
+ * @public
+ */
+export interface CommonEventEmitter<Events extends Record<EventType, unknown>> {
+ on<Key extends keyof Events>(type: Key, handler: Handler<Events[Key]>): this;
+ off<Key extends keyof Events>(
+ type: Key,
+ handler?: Handler<Events[Key]>
+ ): this;
+ emit<Key extends keyof Events>(type: Key, event: Events[Key]): boolean;
+ /* To maintain parity with the built in NodeJS event emitter which uses removeListener
+ * rather than `off`.
+ * If you're implementing new code you should use `off`.
+ */
+ addListener<Key extends keyof Events>(
+ type: Key,
+ handler: Handler<Events[Key]>
+ ): this;
+ removeListener<Key extends keyof Events>(
+ type: Key,
+ handler: Handler<Events[Key]>
+ ): this;
+ once<Key extends keyof Events>(
+ type: Key,
+ handler: Handler<Events[Key]>
+ ): this;
+ listenerCount(event: keyof Events): number;
+
+ removeAllListeners(event?: keyof Events): this;
+}
+
+/**
+ * @public
+ */
+export type EventsWithWildcard<Events extends Record<EventType, unknown>> =
+ Events & {
+ '*': Events[keyof Events];
+ };
+
+/**
+ * The EventEmitter class that many Puppeteer classes extend.
+ *
+ * @remarks
+ *
+ * This allows you to listen to events that Puppeteer classes fire and act
+ * accordingly. Therefore you'll mostly use {@link EventEmitter.on | on} and
+ * {@link EventEmitter.off | off} to bind
+ * and unbind to event listeners.
+ *
+ * @public
+ */
+export class EventEmitter<Events extends Record<EventType, unknown>>
+ implements CommonEventEmitter<EventsWithWildcard<Events>>
+{
+ #emitter: Emitter<EventsWithWildcard<Events>> | EventEmitter<Events>;
+ #handlers = new Map<keyof Events | '*', Array<Handler<any>>>();
+
+ /**
+ * If you pass an emitter, the returned emitter will wrap the passed emitter.
+ *
+ * @internal
+ */
+ constructor(
+ emitter: Emitter<EventsWithWildcard<Events>> | EventEmitter<Events> = mitt(
+ new Map()
+ )
+ ) {
+ this.#emitter = emitter;
+ }
+
+ /**
+ * Bind an event listener to fire when an event occurs.
+ * @param type - the event type you'd like to listen to. Can be a string or symbol.
+ * @param handler - the function to be called when the event occurs.
+ * @returns `this` to enable you to chain method calls.
+ */
+ on<Key extends keyof EventsWithWildcard<Events>>(
+ type: Key,
+ handler: Handler<EventsWithWildcard<Events>[Key]>
+ ): this {
+ const handlers = this.#handlers.get(type);
+ if (handlers === undefined) {
+ this.#handlers.set(type, [handler]);
+ } else {
+ handlers.push(handler);
+ }
+
+ this.#emitter.on(type, handler);
+ return this;
+ }
+
+ /**
+ * Remove an event listener from firing.
+ * @param type - the event type you'd like to stop listening to.
+ * @param handler - the function that should be removed.
+ * @returns `this` to enable you to chain method calls.
+ */
+ off<Key extends keyof EventsWithWildcard<Events>>(
+ type: Key,
+ handler?: Handler<EventsWithWildcard<Events>[Key]>
+ ): this {
+ const handlers = this.#handlers.get(type) ?? [];
+ if (handler === undefined) {
+ for (const handler of handlers) {
+ this.#emitter.off(type, handler);
+ }
+ this.#handlers.delete(type);
+ return this;
+ }
+ const index = handlers.lastIndexOf(handler);
+ if (index > -1) {
+ this.#emitter.off(type, ...handlers.splice(index, 1));
+ }
+ return this;
+ }
+
+ /**
+ * Emit an event and call any associated listeners.
+ *
+ * @param type - the event you'd like to emit
+ * @param eventData - any data you'd like to emit with the event
+ * @returns `true` if there are any listeners, `false` if there are not.
+ */
+ emit<Key extends keyof EventsWithWildcard<Events>>(
+ type: Key,
+ event: EventsWithWildcard<Events>[Key]
+ ): boolean {
+ this.#emitter.emit(type, event);
+ return this.listenerCount(type) > 0;
+ }
+
+ /**
+ * Remove an event listener.
+ *
+ * @deprecated please use {@link EventEmitter.off} instead.
+ */
+ removeListener<Key extends keyof EventsWithWildcard<Events>>(
+ type: Key,
+ handler: Handler<EventsWithWildcard<Events>[Key]>
+ ): this {
+ return this.off(type, handler);
+ }
+
+ /**
+ * Add an event listener.
+ *
+ * @deprecated please use {@link EventEmitter.on} instead.
+ */
+ addListener<Key extends keyof EventsWithWildcard<Events>>(
+ type: Key,
+ handler: Handler<EventsWithWildcard<Events>[Key]>
+ ): this {
+ return this.on(type, handler);
+ }
+
+ /**
+ * Like `on` but the listener will only be fired once and then it will be removed.
+ * @param type - the event you'd like to listen to
+ * @param handler - the handler function to run when the event occurs
+ * @returns `this` to enable you to chain method calls.
+ */
+ once<Key extends keyof EventsWithWildcard<Events>>(
+ type: Key,
+ handler: Handler<EventsWithWildcard<Events>[Key]>
+ ): this {
+ const onceHandler: Handler<EventsWithWildcard<Events>[Key]> = eventData => {
+ handler(eventData);
+ this.off(type, onceHandler);
+ };
+
+ return this.on(type, onceHandler);
+ }
+
+ /**
+ * Gets the number of listeners for a given event.
+ *
+ * @param type - the event to get the listener count for
+ * @returns the number of listeners bound to the given event
+ */
+ listenerCount(type: keyof EventsWithWildcard<Events>): number {
+ return this.#handlers.get(type)?.length || 0;
+ }
+
+ /**
+ * Removes all listeners. If given an event argument, it will remove only
+ * listeners for that event.
+ *
+ * @param type - the event to remove listeners for.
+ * @returns `this` to enable you to chain method calls.
+ */
+ removeAllListeners(type?: keyof EventsWithWildcard<Events>): this {
+ if (type !== undefined) {
+ return this.off(type);
+ }
+ this[disposeSymbol]();
+ return this;
+ }
+
+ /**
+ * @internal
+ */
+ [disposeSymbol](): void {
+ for (const [type, handlers] of this.#handlers) {
+ for (const handler of handlers) {
+ this.#emitter.off(type, handler);
+ }
+ }
+ this.#handlers.clear();
+ }
+}
+
+/**
+ * @internal
+ */
+export class EventSubscription<
+ Target extends CommonEventEmitter<Record<Type, Event>>,
+ Type extends EventType = EventType,
+ Event = unknown,
+> {
+ #target: Target;
+ #type: Type;
+ #handler: Handler<Event>;
+
+ constructor(target: Target, type: Type, handler: Handler<Event>) {
+ this.#target = target;
+ this.#type = type;
+ this.#handler = handler;
+ this.#target.on(this.#type, this.#handler);
+ }
+
+ [disposeSymbol](): void {
+ this.#target.off(this.#type, this.#handler);
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/FileChooser.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/FileChooser.ts
new file mode 100644
index 0000000000..2e4fd14fa7
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/FileChooser.ts
@@ -0,0 +1,92 @@
+/**
+ * @license
+ * Copyright 2020 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {Protocol} from 'devtools-protocol';
+
+import type {ElementHandle} from '../api/ElementHandle.js';
+import {assert} from '../util/assert.js';
+
+/**
+ * File choosers let you react to the page requesting for a file.
+ *
+ * @remarks
+ * `FileChooser` instances are returned via the {@link Page.waitForFileChooser} method.
+ *
+ * In browsers, only one file chooser can be opened at a time.
+ * All file choosers must be accepted or canceled. Not doing so will prevent
+ * subsequent file choosers from appearing.
+ *
+ * @example
+ *
+ * ```ts
+ * const [fileChooser] = await Promise.all([
+ * page.waitForFileChooser(),
+ * page.click('#upload-file-button'), // some button that triggers file selection
+ * ]);
+ * await fileChooser.accept(['/tmp/myfile.pdf']);
+ * ```
+ *
+ * @public
+ */
+export class FileChooser {
+ #element: ElementHandle<HTMLInputElement>;
+ #multiple: boolean;
+ #handled = false;
+
+ /**
+ * @internal
+ */
+ constructor(
+ element: ElementHandle<HTMLInputElement>,
+ event: Protocol.Page.FileChooserOpenedEvent
+ ) {
+ this.#element = element;
+ this.#multiple = event.mode !== 'selectSingle';
+ }
+
+ /**
+ * Whether file chooser allow for
+ * {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#attr-multiple | multiple}
+ * file selection.
+ */
+ isMultiple(): boolean {
+ return this.#multiple;
+ }
+
+ /**
+ * Accept the file chooser request with the given file paths.
+ *
+ * @remarks This will not validate whether the file paths exists. Also, if a
+ * path is relative, then it is resolved against the
+ * {@link https://nodejs.org/api/process.html#process_process_cwd | current working directory}.
+ * For locals script connecting to remote chrome environments, paths must be
+ * absolute.
+ */
+ async accept(paths: string[]): Promise<void> {
+ assert(
+ !this.#handled,
+ 'Cannot accept FileChooser which is already handled!'
+ );
+ this.#handled = true;
+ await this.#element.uploadFile(...paths);
+ }
+
+ /**
+ * Closes the file chooser without selecting any files.
+ */
+ async cancel(): Promise<void> {
+ assert(
+ !this.#handled,
+ 'Cannot cancel FileChooser which is already handled!'
+ );
+ this.#handled = true;
+ // XXX: These events should converted to trusted events. Perhaps do this
+ // in `DOM.setFileInputFiles`?
+ await this.#element.evaluate(element => {
+ element.dispatchEvent(new Event('cancel', {bubbles: true}));
+ });
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/GetQueryHandler.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/GetQueryHandler.ts
new file mode 100644
index 0000000000..1d8bb01414
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/GetQueryHandler.ts
@@ -0,0 +1,49 @@
+/**
+ * @license
+ * Copyright 2023 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {ARIAQueryHandler} from '../cdp/AriaQueryHandler.js';
+
+import {customQueryHandlers} from './CustomQueryHandler.js';
+import {PierceQueryHandler} from './PierceQueryHandler.js';
+import {PQueryHandler} from './PQueryHandler.js';
+import type {QueryHandler} from './QueryHandler.js';
+import {TextQueryHandler} from './TextQueryHandler.js';
+import {XPathQueryHandler} from './XPathQueryHandler.js';
+
+const BUILTIN_QUERY_HANDLERS = {
+ aria: ARIAQueryHandler,
+ pierce: PierceQueryHandler,
+ xpath: XPathQueryHandler,
+ text: TextQueryHandler,
+} as const;
+
+const QUERY_SEPARATORS = ['=', '/'];
+
+/**
+ * @internal
+ */
+export function getQueryHandlerAndSelector(selector: string): {
+ updatedSelector: string;
+ QueryHandler: typeof QueryHandler;
+} {
+ for (const handlerMap of [
+ customQueryHandlers.names().map(name => {
+ return [name, customQueryHandlers.get(name)!] as const;
+ }),
+ Object.entries(BUILTIN_QUERY_HANDLERS),
+ ]) {
+ for (const [name, QueryHandler] of handlerMap) {
+ for (const separator of QUERY_SEPARATORS) {
+ const prefix = `${name}${separator}`;
+ if (selector.startsWith(prefix)) {
+ selector = selector.slice(prefix.length);
+ return {updatedSelector: selector, QueryHandler};
+ }
+ }
+ }
+ }
+ return {updatedSelector: selector, QueryHandler: PQueryHandler};
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/HandleIterator.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/HandleIterator.ts
new file mode 100644
index 0000000000..c88003ed71
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/HandleIterator.ts
@@ -0,0 +1,76 @@
+/**
+ * @license
+ * Copyright 2023 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {JSHandle} from '../api/JSHandle.js';
+import {DisposableStack, disposeSymbol} from '../util/disposable.js';
+
+import type {AwaitableIterable, HandleFor} from './types.js';
+
+const DEFAULT_BATCH_SIZE = 20;
+
+/**
+ * This will transpose an iterator JSHandle into a fast, Puppeteer-side iterator
+ * of JSHandles.
+ *
+ * @param size - The number of elements to transpose. This should be something
+ * reasonable.
+ */
+async function* fastTransposeIteratorHandle<T>(
+ iterator: JSHandle<AwaitableIterator<T>>,
+ size: number
+) {
+ using array = await iterator.evaluateHandle(async (iterator, size) => {
+ const results = [];
+ while (results.length < size) {
+ const result = await iterator.next();
+ if (result.done) {
+ break;
+ }
+ results.push(result.value);
+ }
+ return results;
+ }, size);
+ const properties = (await array.getProperties()) as Map<string, HandleFor<T>>;
+ const handles = properties.values();
+ using stack = new DisposableStack();
+ stack.defer(() => {
+ for (using handle of handles) {
+ handle[disposeSymbol]();
+ }
+ });
+ yield* handles;
+ return properties.size === 0;
+}
+
+/**
+ * This will transpose an iterator JSHandle in batches based on the default size
+ * of {@link fastTransposeIteratorHandle}.
+ */
+
+async function* transposeIteratorHandle<T>(
+ iterator: JSHandle<AwaitableIterator<T>>
+) {
+ let size = DEFAULT_BATCH_SIZE;
+ while (!(yield* fastTransposeIteratorHandle(iterator, size))) {
+ size <<= 1;
+ }
+}
+
+type AwaitableIterator<T> = Iterator<T> | AsyncIterator<T>;
+
+/**
+ * @internal
+ */
+export async function* transposeIterableHandle<T>(
+ handle: JSHandle<AwaitableIterable<T>>
+): AsyncIterableIterator<HandleFor<T>> {
+ using generatorHandle = await handle.evaluateHandle(iterable => {
+ return (async function* () {
+ yield* iterable;
+ })();
+ });
+ yield* transposeIteratorHandle(generatorHandle);
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/LazyArg.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/LazyArg.ts
new file mode 100644
index 0000000000..ed30281dd8
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/LazyArg.ts
@@ -0,0 +1,37 @@
+/**
+ * @license
+ * Copyright 2022 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {JSHandle} from '../api/JSHandle.js';
+import type PuppeteerUtil from '../injected/injected.js';
+
+/**
+ * @internal
+ */
+export interface PuppeteerUtilWrapper {
+ puppeteerUtil: Promise<JSHandle<PuppeteerUtil>>;
+}
+
+/**
+ * @internal
+ */
+export class LazyArg<T, Context = PuppeteerUtilWrapper> {
+ static create = <T>(
+ get: (context: PuppeteerUtilWrapper) => Promise<T> | T
+ ): T => {
+ // We don't want to introduce LazyArg to the type system, otherwise we would
+ // have to make it public.
+ return new LazyArg(get) as unknown as T;
+ };
+
+ #get: (context: Context) => Promise<T> | T;
+ private constructor(get: (context: Context) => Promise<T> | T) {
+ this.#get = get;
+ }
+
+ async get(context: Context): Promise<T> {
+ return await this.#get(context);
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/NetworkManagerEvents.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/NetworkManagerEvents.ts
new file mode 100644
index 0000000000..eae26252d1
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/NetworkManagerEvents.ts
@@ -0,0 +1,38 @@
+/**
+ * @license
+ * Copyright 2022 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {HTTPRequest} from '../api/HTTPRequest.js';
+import type {HTTPResponse} from '../api/HTTPResponse.js';
+
+import type {EventType} from './EventEmitter.js';
+
+/**
+ * We use symbols to prevent any external parties listening to these events.
+ * They are internal to Puppeteer.
+ *
+ * @internal
+ */
+// eslint-disable-next-line @typescript-eslint/no-namespace
+export namespace NetworkManagerEvent {
+ export const Request = Symbol('NetworkManager.Request');
+ export const RequestServedFromCache = Symbol(
+ 'NetworkManager.RequestServedFromCache'
+ );
+ export const Response = Symbol('NetworkManager.Response');
+ export const RequestFailed = Symbol('NetworkManager.RequestFailed');
+ export const RequestFinished = Symbol('NetworkManager.RequestFinished');
+}
+
+/**
+ * @internal
+ */
+export interface NetworkManagerEvents extends Record<EventType, unknown> {
+ [NetworkManagerEvent.Request]: HTTPRequest;
+ [NetworkManagerEvent.RequestServedFromCache]: HTTPRequest | undefined;
+ [NetworkManagerEvent.Response]: HTTPResponse;
+ [NetworkManagerEvent.RequestFailed]: HTTPRequest;
+ [NetworkManagerEvent.RequestFinished]: HTTPRequest;
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/PDFOptions.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/PDFOptions.ts
new file mode 100644
index 0000000000..7cae9191a9
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/PDFOptions.ts
@@ -0,0 +1,217 @@
+/**
+ * @license
+ * Copyright 2020 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * @public
+ */
+export interface PDFMargin {
+ top?: string | number;
+ bottom?: string | number;
+ left?: string | number;
+ right?: string | number;
+}
+
+/**
+ * @public
+ */
+export type LowerCasePaperFormat =
+ | 'letter'
+ | 'legal'
+ | 'tabloid'
+ | 'ledger'
+ | 'a0'
+ | 'a1'
+ | 'a2'
+ | 'a3'
+ | 'a4'
+ | 'a5'
+ | 'a6';
+
+/**
+ * All the valid paper format types when printing a PDF.
+ *
+ * @remarks
+ *
+ * The sizes of each format are as follows:
+ *
+ * - `Letter`: 8.5in x 11in
+ *
+ * - `Legal`: 8.5in x 14in
+ *
+ * - `Tabloid`: 11in x 17in
+ *
+ * - `Ledger`: 17in x 11in
+ *
+ * - `A0`: 33.1in x 46.8in
+ *
+ * - `A1`: 23.4in x 33.1in
+ *
+ * - `A2`: 16.54in x 23.4in
+ *
+ * - `A3`: 11.7in x 16.54in
+ *
+ * - `A4`: 8.27in x 11.7in
+ *
+ * - `A5`: 5.83in x 8.27in
+ *
+ * - `A6`: 4.13in x 5.83in
+ *
+ * @public
+ */
+export type PaperFormat =
+ | Uppercase<LowerCasePaperFormat>
+ | Capitalize<LowerCasePaperFormat>
+ | LowerCasePaperFormat;
+
+/**
+ * Valid options to configure PDF generation via {@link Page.pdf}.
+ * @public
+ */
+export interface PDFOptions {
+ /**
+ * Scales the rendering of the web page. Amount must be between `0.1` and `2`.
+ * @defaultValue `1`
+ */
+ scale?: number;
+ /**
+ * Whether to show the header and footer.
+ * @defaultValue `false`
+ */
+ displayHeaderFooter?: boolean;
+ /**
+ * HTML template for the print header. Should be valid HTML with the following
+ * classes used to inject values into them:
+ *
+ * - `date` formatted print date
+ *
+ * - `title` document title
+ *
+ * - `url` document location
+ *
+ * - `pageNumber` current page number
+ *
+ * - `totalPages` total pages in the document
+ */
+ headerTemplate?: string;
+ /**
+ * HTML template for the print footer. Has the same constraints and support
+ * for special classes as {@link PDFOptions | PDFOptions.headerTemplate}.
+ */
+ footerTemplate?: string;
+ /**
+ * Set to `true` to print background graphics.
+ * @defaultValue `false`
+ */
+ printBackground?: boolean;
+ /**
+ * Whether to print in landscape orientation.
+ * @defaultValue `false`
+ */
+ landscape?: boolean;
+ /**
+ * Paper ranges to print, e.g. `1-5, 8, 11-13`.
+ * @defaultValue The empty string, which means all pages are printed.
+ */
+ pageRanges?: string;
+ /**
+ * @remarks
+ * If set, this takes priority over the `width` and `height` options.
+ * @defaultValue `letter`.
+ */
+ format?: PaperFormat;
+ /**
+ * Sets the width of paper. You can pass in a number or a string with a unit.
+ */
+ width?: string | number;
+ /**
+ * Sets the height of paper. You can pass in a number or a string with a unit.
+ */
+ height?: string | number;
+ /**
+ * Give any CSS `@page` size declared in the page priority over what is
+ * declared in the `width` or `height` or `format` option.
+ * @defaultValue `false`, which will scale the content to fit the paper size.
+ */
+ preferCSSPageSize?: boolean;
+ /**
+ * Set the PDF margins.
+ * @defaultValue `undefined` no margins are set.
+ */
+ margin?: PDFMargin;
+ /**
+ * The path to save the file to.
+ *
+ * @remarks
+ *
+ * If the path is relative, it's resolved relative to the current working directory.
+ *
+ * @defaultValue `undefined`, which means the PDF will not be written to disk.
+ */
+ path?: string;
+ /**
+ * Hides default white background and allows generating pdfs with transparency.
+ * @defaultValue `false`
+ */
+ omitBackground?: boolean;
+ /**
+ * Generate tagged (accessible) PDF.
+ * @defaultValue `false`
+ * @experimental
+ */
+ tagged?: boolean;
+ /**
+ * Timeout in milliseconds. Pass `0` to disable timeout.
+ * @defaultValue `30_000`
+ */
+ timeout?: number;
+}
+
+/**
+ * @internal
+ */
+export interface PaperFormatDimensions {
+ width: number;
+ height: number;
+}
+
+/**
+ * @internal
+ */
+export interface ParsedPDFOptionsInterface {
+ width: number;
+ height: number;
+ margin: {
+ top: number;
+ bottom: number;
+ left: number;
+ right: number;
+ };
+}
+
+/**
+ * @internal
+ */
+export type ParsedPDFOptions = Required<
+ Omit<PDFOptions, 'path' | 'format' | 'timeout'> & ParsedPDFOptionsInterface
+>;
+
+/**
+ * @internal
+ */
+export const paperFormats: Record<LowerCasePaperFormat, PaperFormatDimensions> =
+ {
+ letter: {width: 8.5, height: 11},
+ legal: {width: 8.5, height: 14},
+ tabloid: {width: 11, height: 17},
+ ledger: {width: 17, height: 11},
+ a0: {width: 33.1, height: 46.8},
+ a1: {width: 23.4, height: 33.1},
+ a2: {width: 16.54, height: 23.4},
+ a3: {width: 11.7, height: 16.54},
+ a4: {width: 8.27, height: 11.7},
+ a5: {width: 5.83, height: 8.27},
+ a6: {width: 4.13, height: 5.83},
+ } as const;
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/PQueryHandler.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/PQueryHandler.ts
new file mode 100644
index 0000000000..db9b832d77
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/PQueryHandler.ts
@@ -0,0 +1,31 @@
+/**
+ * @license
+ * Copyright 2023 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {
+ QueryHandler,
+ type QuerySelector,
+ type QuerySelectorAll,
+} from './QueryHandler.js';
+
+/**
+ * @internal
+ */
+export class PQueryHandler extends QueryHandler {
+ static override querySelectorAll: QuerySelectorAll = (
+ element,
+ selector,
+ {pQuerySelectorAll}
+ ) => {
+ return pQuerySelectorAll(element, selector);
+ };
+ static override querySelector: QuerySelector = (
+ element,
+ selector,
+ {pQuerySelector}
+ ) => {
+ return pQuerySelector(element, selector);
+ };
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/PierceQueryHandler.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/PierceQueryHandler.ts
new file mode 100644
index 0000000000..36ddbe7f3e
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/PierceQueryHandler.ts
@@ -0,0 +1,29 @@
+/**
+ * @license
+ * Copyright 2023 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type PuppeteerUtil from '../injected/injected.js';
+
+import {QueryHandler} from './QueryHandler.js';
+
+/**
+ * @internal
+ */
+export class PierceQueryHandler extends QueryHandler {
+ static override querySelector = (
+ element: Node,
+ selector: string,
+ {pierceQuerySelector}: PuppeteerUtil
+ ): Node | null => {
+ return pierceQuerySelector(element, selector);
+ };
+ static override querySelectorAll = (
+ element: Node,
+ selector: string,
+ {pierceQuerySelectorAll}: PuppeteerUtil
+ ): Iterable<Node> => {
+ return pierceQuerySelectorAll(element, selector);
+ };
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/Product.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/Product.ts
new file mode 100644
index 0000000000..dcd75aceb6
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/Product.ts
@@ -0,0 +1,11 @@
+/**
+ * @license
+ * Copyright 2020 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * Supported products.
+ * @public
+ */
+export type Product = 'chrome' | 'firefox';
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/Puppeteer.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/Puppeteer.ts
new file mode 100644
index 0000000000..844a3622bd
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/Puppeteer.ts
@@ -0,0 +1,123 @@
+/**
+ * @license
+ * Copyright 2017 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {Browser} from '../api/Browser.js';
+
+import {_connectToBrowser} from './BrowserConnector.js';
+import type {ConnectOptions} from './ConnectOptions.js';
+import {
+ type CustomQueryHandler,
+ customQueryHandlers,
+} from './CustomQueryHandler.js';
+
+/**
+ * Settings that are common to the Puppeteer class, regardless of environment.
+ *
+ * @internal
+ */
+export interface CommonPuppeteerSettings {
+ isPuppeteerCore: boolean;
+}
+
+/**
+ * The main Puppeteer class.
+ *
+ * IMPORTANT: if you are using Puppeteer in a Node environment, you will get an
+ * instance of {@link PuppeteerNode} when you import or require `puppeteer`.
+ * That class extends `Puppeteer`, so has all the methods documented below as
+ * well as all that are defined on {@link PuppeteerNode}.
+ *
+ * @public
+ */
+export class Puppeteer {
+ /**
+ * Operations for {@link CustomQueryHandler | custom query handlers}. See
+ * {@link CustomQueryHandlerRegistry}.
+ *
+ * @internal
+ */
+ static customQueryHandlers = customQueryHandlers;
+
+ /**
+ * Registers a {@link CustomQueryHandler | custom query handler}.
+ *
+ * @remarks
+ * After registration, the handler can be used everywhere where a selector is
+ * expected by prepending the selection string with `<name>/`. The name is only
+ * allowed to consist of lower- and upper case latin letters.
+ *
+ * @example
+ *
+ * ```
+ * puppeteer.registerCustomQueryHandler('text', { … });
+ * const aHandle = await page.$('text/…');
+ * ```
+ *
+ * @param name - The name that the custom query handler will be registered
+ * under.
+ * @param queryHandler - The {@link CustomQueryHandler | custom query handler}
+ * to register.
+ *
+ * @public
+ */
+ static registerCustomQueryHandler(
+ name: string,
+ queryHandler: CustomQueryHandler
+ ): void {
+ return this.customQueryHandlers.register(name, queryHandler);
+ }
+
+ /**
+ * Unregisters a custom query handler for a given name.
+ */
+ static unregisterCustomQueryHandler(name: string): void {
+ return this.customQueryHandlers.unregister(name);
+ }
+
+ /**
+ * Gets the names of all custom query handlers.
+ */
+ static customQueryHandlerNames(): string[] {
+ return this.customQueryHandlers.names();
+ }
+
+ /**
+ * Unregisters all custom query handlers.
+ */
+ static clearCustomQueryHandlers(): void {
+ return this.customQueryHandlers.clear();
+ }
+
+ /**
+ * @internal
+ */
+ _isPuppeteerCore: boolean;
+ /**
+ * @internal
+ */
+ protected _changedProduct = false;
+
+ /**
+ * @internal
+ */
+ constructor(settings: CommonPuppeteerSettings) {
+ this._isPuppeteerCore = settings.isPuppeteerCore;
+
+ this.connect = this.connect.bind(this);
+ }
+
+ /**
+ * This method attaches Puppeteer to an existing browser instance.
+ *
+ * @remarks
+ *
+ * @param options - Set of configurable options to set on the browser.
+ * @returns Promise which resolves to browser instance.
+ */
+ connect(options: ConnectOptions): Promise<Browser> {
+ return _connectToBrowser(options);
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/QueryHandler.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/QueryHandler.ts
new file mode 100644
index 0000000000..1655c7dba2
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/QueryHandler.ts
@@ -0,0 +1,205 @@
+/**
+ * @license
+ * Copyright 2023 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {ElementHandle} from '../api/ElementHandle.js';
+import {_isElementHandle} from '../api/ElementHandleSymbol.js';
+import type {Frame} from '../api/Frame.js';
+import type {WaitForSelectorOptions} from '../api/Page.js';
+import type PuppeteerUtil from '../injected/injected.js';
+import {isErrorLike} from '../util/ErrorLike.js';
+import {interpolateFunction, stringifyFunction} from '../util/Function.js';
+
+import {transposeIterableHandle} from './HandleIterator.js';
+import {LazyArg} from './LazyArg.js';
+import type {Awaitable, AwaitableIterable} from './types.js';
+
+/**
+ * @internal
+ */
+export type QuerySelectorAll = (
+ node: Node,
+ selector: string,
+ PuppeteerUtil: PuppeteerUtil
+) => AwaitableIterable<Node>;
+
+/**
+ * @internal
+ */
+export type QuerySelector = (
+ node: Node,
+ selector: string,
+ PuppeteerUtil: PuppeteerUtil
+) => Awaitable<Node | null>;
+
+/**
+ * @internal
+ */
+export class QueryHandler {
+ // Either one of these may be implemented, but at least one must be.
+ static querySelectorAll?: QuerySelectorAll;
+ static querySelector?: QuerySelector;
+
+ static get _querySelector(): QuerySelector {
+ if (this.querySelector) {
+ return this.querySelector;
+ }
+ if (!this.querySelectorAll) {
+ throw new Error('Cannot create default `querySelector`.');
+ }
+
+ return (this.querySelector = interpolateFunction(
+ async (node, selector, PuppeteerUtil) => {
+ const querySelectorAll: QuerySelectorAll =
+ PLACEHOLDER('querySelectorAll');
+ const results = querySelectorAll(node, selector, PuppeteerUtil);
+ for await (const result of results) {
+ return result;
+ }
+ return null;
+ },
+ {
+ querySelectorAll: stringifyFunction(this.querySelectorAll),
+ }
+ ));
+ }
+
+ static get _querySelectorAll(): QuerySelectorAll {
+ if (this.querySelectorAll) {
+ return this.querySelectorAll;
+ }
+ if (!this.querySelector) {
+ throw new Error('Cannot create default `querySelectorAll`.');
+ }
+
+ return (this.querySelectorAll = interpolateFunction(
+ async function* (node, selector, PuppeteerUtil) {
+ const querySelector: QuerySelector = PLACEHOLDER('querySelector');
+ const result = await querySelector(node, selector, PuppeteerUtil);
+ if (result) {
+ yield result;
+ }
+ },
+ {
+ querySelector: stringifyFunction(this.querySelector),
+ }
+ ));
+ }
+
+ /**
+ * Queries for multiple nodes given a selector and {@link ElementHandle}.
+ *
+ * Akin to {@link https://developer.mozilla.org/en-US/docs/Web/API/Document/querySelectorAll | Document.querySelectorAll()}.
+ */
+ static async *queryAll(
+ element: ElementHandle<Node>,
+ selector: string
+ ): AwaitableIterable<ElementHandle<Node>> {
+ using handle = await element.evaluateHandle(
+ this._querySelectorAll,
+ selector,
+ LazyArg.create(context => {
+ return context.puppeteerUtil;
+ })
+ );
+ yield* transposeIterableHandle(handle);
+ }
+
+ /**
+ * Queries for a single node given a selector and {@link ElementHandle}.
+ *
+ * Akin to {@link https://developer.mozilla.org/en-US/docs/Web/API/Document/querySelector}.
+ */
+ static async queryOne(
+ element: ElementHandle<Node>,
+ selector: string
+ ): Promise<ElementHandle<Node> | null> {
+ using result = await element.evaluateHandle(
+ this._querySelector,
+ selector,
+ LazyArg.create(context => {
+ return context.puppeteerUtil;
+ })
+ );
+ if (!(_isElementHandle in result)) {
+ return null;
+ }
+ return result.move();
+ }
+
+ /**
+ * Waits until a single node appears for a given selector and
+ * {@link ElementHandle}.
+ *
+ * This will always query the handle in the Puppeteer world and migrate the
+ * result to the main world.
+ */
+ static async waitFor(
+ elementOrFrame: ElementHandle<Node> | Frame,
+ selector: string,
+ options: WaitForSelectorOptions
+ ): Promise<ElementHandle<Node> | null> {
+ let frame!: Frame;
+ using element = await (async () => {
+ if (!(_isElementHandle in elementOrFrame)) {
+ frame = elementOrFrame;
+ return;
+ }
+ frame = elementOrFrame.frame;
+ return await frame.isolatedRealm().adoptHandle(elementOrFrame);
+ })();
+
+ const {visible = false, hidden = false, timeout, signal} = options;
+
+ try {
+ signal?.throwIfAborted();
+
+ using handle = await frame.isolatedRealm().waitForFunction(
+ async (PuppeteerUtil, query, selector, root, visible) => {
+ const querySelector = PuppeteerUtil.createFunction(
+ query
+ ) as QuerySelector;
+ const node = await querySelector(
+ root ?? document,
+ selector,
+ PuppeteerUtil
+ );
+ return PuppeteerUtil.checkVisibility(node, visible);
+ },
+ {
+ polling: visible || hidden ? 'raf' : 'mutation',
+ root: element,
+ timeout,
+ signal,
+ },
+ LazyArg.create(context => {
+ return context.puppeteerUtil;
+ }),
+ stringifyFunction(this._querySelector),
+ selector,
+ element,
+ visible ? true : hidden ? false : undefined
+ );
+
+ if (signal?.aborted) {
+ throw signal.reason;
+ }
+
+ if (!(_isElementHandle in handle)) {
+ return null;
+ }
+ return await frame.mainRealm().transferHandle(handle);
+ } catch (error) {
+ if (!isErrorLike(error)) {
+ throw error;
+ }
+ if (error.name === 'AbortError') {
+ throw error;
+ }
+ error.message = `Waiting for selector \`${selector}\` failed: ${error.message}`;
+ throw error;
+ }
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/ScriptInjector.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/ScriptInjector.ts
new file mode 100644
index 0000000000..0264c9175f
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/ScriptInjector.ts
@@ -0,0 +1,52 @@
+import {source as injectedSource} from '../generated/injected.js';
+
+/**
+ * @internal
+ */
+export class ScriptInjector {
+ #updated = false;
+ #amendments = new Set<string>();
+
+ // Appends a statement of the form `(PuppeteerUtil) => {...}`.
+ append(statement: string): void {
+ this.#update(() => {
+ this.#amendments.add(statement);
+ });
+ }
+
+ pop(statement: string): void {
+ this.#update(() => {
+ this.#amendments.delete(statement);
+ });
+ }
+
+ inject(inject: (script: string) => void, force = false): void {
+ if (this.#updated || force) {
+ inject(this.#get());
+ }
+ this.#updated = false;
+ }
+
+ #update(callback: () => void): void {
+ callback();
+ this.#updated = true;
+ }
+
+ #get(): string {
+ return `(() => {
+ const module = {};
+ ${injectedSource}
+ ${[...this.#amendments]
+ .map(statement => {
+ return `(${statement})(module.exports.default);`;
+ })
+ .join('')}
+ return module.exports.default;
+ })()`;
+ }
+}
+
+/**
+ * @internal
+ */
+export const scriptInjector = new ScriptInjector();
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/SecurityDetails.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/SecurityDetails.ts
new file mode 100644
index 0000000000..188eeea9ad
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/SecurityDetails.ts
@@ -0,0 +1,78 @@
+/**
+ * @license
+ * Copyright 2020 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {Protocol} from 'devtools-protocol';
+
+/**
+ * The SecurityDetails class represents the security details of a
+ * response that was received over a secure connection.
+ *
+ * @public
+ */
+export class SecurityDetails {
+ #subjectName: string;
+ #issuer: string;
+ #validFrom: number;
+ #validTo: number;
+ #protocol: string;
+ #sanList: string[];
+
+ /**
+ * @internal
+ */
+ constructor(securityPayload: Protocol.Network.SecurityDetails) {
+ this.#subjectName = securityPayload.subjectName;
+ this.#issuer = securityPayload.issuer;
+ this.#validFrom = securityPayload.validFrom;
+ this.#validTo = securityPayload.validTo;
+ this.#protocol = securityPayload.protocol;
+ this.#sanList = securityPayload.sanList;
+ }
+
+ /**
+ * The name of the issuer of the certificate.
+ */
+ issuer(): string {
+ return this.#issuer;
+ }
+
+ /**
+ * {@link https://en.wikipedia.org/wiki/Unix_time | Unix timestamp}
+ * marking the start of the certificate's validity.
+ */
+ validFrom(): number {
+ return this.#validFrom;
+ }
+
+ /**
+ * {@link https://en.wikipedia.org/wiki/Unix_time | Unix timestamp}
+ * marking the end of the certificate's validity.
+ */
+ validTo(): number {
+ return this.#validTo;
+ }
+
+ /**
+ * The security protocol being used, e.g. "TLS 1.2".
+ */
+ protocol(): string {
+ return this.#protocol;
+ }
+
+ /**
+ * The name of the subject to which the certificate was issued.
+ */
+ subjectName(): string {
+ return this.#subjectName;
+ }
+
+ /**
+ * The list of {@link https://en.wikipedia.org/wiki/Subject_Alternative_Name | subject alternative names (SANs)} of the certificate.
+ */
+ subjectAlternativeNames(): string[] {
+ return this.#sanList;
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/TaskQueue.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/TaskQueue.ts
new file mode 100644
index 0000000000..3ad1409c1b
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/TaskQueue.ts
@@ -0,0 +1,29 @@
+/**
+ * @license
+ * Copyright 2020 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * @internal
+ */
+export class TaskQueue {
+ #chain: Promise<void>;
+
+ constructor() {
+ this.#chain = Promise.resolve();
+ }
+
+ postTask<T>(task: () => Promise<T>): Promise<T> {
+ const result = this.#chain.then(task);
+ this.#chain = result.then(
+ () => {
+ return undefined;
+ },
+ () => {
+ return undefined;
+ }
+ );
+ return result;
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/TextQueryHandler.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/TextQueryHandler.ts
new file mode 100644
index 0000000000..450ed06957
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/TextQueryHandler.ts
@@ -0,0 +1,20 @@
+/**
+ * @license
+ * Copyright 2023 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {QueryHandler, type QuerySelectorAll} from './QueryHandler.js';
+
+/**
+ * @internal
+ */
+export class TextQueryHandler extends QueryHandler {
+ static override querySelectorAll: QuerySelectorAll = (
+ element,
+ selector,
+ {textQuerySelectorAll}
+ ) => {
+ return textQuerySelectorAll(element, selector);
+ };
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/TimeoutSettings.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/TimeoutSettings.ts
new file mode 100644
index 0000000000..7789d89b75
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/TimeoutSettings.ts
@@ -0,0 +1,45 @@
+/**
+ * @license
+ * Copyright 2019 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+const DEFAULT_TIMEOUT = 30000;
+
+/**
+ * @internal
+ */
+export class TimeoutSettings {
+ #defaultTimeout: number | null;
+ #defaultNavigationTimeout: number | null;
+
+ constructor() {
+ this.#defaultTimeout = null;
+ this.#defaultNavigationTimeout = null;
+ }
+
+ setDefaultTimeout(timeout: number): void {
+ this.#defaultTimeout = timeout;
+ }
+
+ setDefaultNavigationTimeout(timeout: number): void {
+ this.#defaultNavigationTimeout = timeout;
+ }
+
+ navigationTimeout(): number {
+ if (this.#defaultNavigationTimeout !== null) {
+ return this.#defaultNavigationTimeout;
+ }
+ if (this.#defaultTimeout !== null) {
+ return this.#defaultTimeout;
+ }
+ return DEFAULT_TIMEOUT;
+ }
+
+ timeout(): number {
+ if (this.#defaultTimeout !== null) {
+ return this.#defaultTimeout;
+ }
+ return DEFAULT_TIMEOUT;
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/USKeyboardLayout.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/USKeyboardLayout.ts
new file mode 100644
index 0000000000..0a6d2f2e18
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/USKeyboardLayout.ts
@@ -0,0 +1,671 @@
+/**
+ * @license
+ * Copyright 2017 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * @internal
+ */
+export interface KeyDefinition {
+ keyCode?: number;
+ shiftKeyCode?: number;
+ key?: string;
+ shiftKey?: string;
+ code?: string;
+ text?: string;
+ shiftText?: string;
+ location?: number;
+}
+
+/**
+ * All the valid keys that can be passed to functions that take user input, such
+ * as {@link Keyboard.press | keyboard.press }
+ *
+ * @public
+ */
+export type KeyInput =
+ | '0'
+ | '1'
+ | '2'
+ | '3'
+ | '4'
+ | '5'
+ | '6'
+ | '7'
+ | '8'
+ | '9'
+ | 'Power'
+ | 'Eject'
+ | 'Abort'
+ | 'Help'
+ | 'Backspace'
+ | 'Tab'
+ | 'Numpad5'
+ | 'NumpadEnter'
+ | 'Enter'
+ | '\r'
+ | '\n'
+ | 'ShiftLeft'
+ | 'ShiftRight'
+ | 'ControlLeft'
+ | 'ControlRight'
+ | 'AltLeft'
+ | 'AltRight'
+ | 'Pause'
+ | 'CapsLock'
+ | 'Escape'
+ | 'Convert'
+ | 'NonConvert'
+ | 'Space'
+ | 'Numpad9'
+ | 'PageUp'
+ | 'Numpad3'
+ | 'PageDown'
+ | 'End'
+ | 'Numpad1'
+ | 'Home'
+ | 'Numpad7'
+ | 'ArrowLeft'
+ | 'Numpad4'
+ | 'Numpad8'
+ | 'ArrowUp'
+ | 'ArrowRight'
+ | 'Numpad6'
+ | 'Numpad2'
+ | 'ArrowDown'
+ | 'Select'
+ | 'Open'
+ | 'PrintScreen'
+ | 'Insert'
+ | 'Numpad0'
+ | 'Delete'
+ | 'NumpadDecimal'
+ | 'Digit0'
+ | 'Digit1'
+ | 'Digit2'
+ | 'Digit3'
+ | 'Digit4'
+ | 'Digit5'
+ | 'Digit6'
+ | 'Digit7'
+ | 'Digit8'
+ | 'Digit9'
+ | 'KeyA'
+ | 'KeyB'
+ | 'KeyC'
+ | 'KeyD'
+ | 'KeyE'
+ | 'KeyF'
+ | 'KeyG'
+ | 'KeyH'
+ | 'KeyI'
+ | 'KeyJ'
+ | 'KeyK'
+ | 'KeyL'
+ | 'KeyM'
+ | 'KeyN'
+ | 'KeyO'
+ | 'KeyP'
+ | 'KeyQ'
+ | 'KeyR'
+ | 'KeyS'
+ | 'KeyT'
+ | 'KeyU'
+ | 'KeyV'
+ | 'KeyW'
+ | 'KeyX'
+ | 'KeyY'
+ | 'KeyZ'
+ | 'MetaLeft'
+ | 'MetaRight'
+ | 'ContextMenu'
+ | 'NumpadMultiply'
+ | 'NumpadAdd'
+ | 'NumpadSubtract'
+ | 'NumpadDivide'
+ | 'F1'
+ | 'F2'
+ | 'F3'
+ | 'F4'
+ | 'F5'
+ | 'F6'
+ | 'F7'
+ | 'F8'
+ | 'F9'
+ | 'F10'
+ | 'F11'
+ | 'F12'
+ | 'F13'
+ | 'F14'
+ | 'F15'
+ | 'F16'
+ | 'F17'
+ | 'F18'
+ | 'F19'
+ | 'F20'
+ | 'F21'
+ | 'F22'
+ | 'F23'
+ | 'F24'
+ | 'NumLock'
+ | 'ScrollLock'
+ | 'AudioVolumeMute'
+ | 'AudioVolumeDown'
+ | 'AudioVolumeUp'
+ | 'MediaTrackNext'
+ | 'MediaTrackPrevious'
+ | 'MediaStop'
+ | 'MediaPlayPause'
+ | 'Semicolon'
+ | 'Equal'
+ | 'NumpadEqual'
+ | 'Comma'
+ | 'Minus'
+ | 'Period'
+ | 'Slash'
+ | 'Backquote'
+ | 'BracketLeft'
+ | 'Backslash'
+ | 'BracketRight'
+ | 'Quote'
+ | 'AltGraph'
+ | 'Props'
+ | 'Cancel'
+ | 'Clear'
+ | 'Shift'
+ | 'Control'
+ | 'Alt'
+ | 'Accept'
+ | 'ModeChange'
+ | ' '
+ | 'Print'
+ | 'Execute'
+ | '\u0000'
+ | 'a'
+ | 'b'
+ | 'c'
+ | 'd'
+ | 'e'
+ | 'f'
+ | 'g'
+ | 'h'
+ | 'i'
+ | 'j'
+ | 'k'
+ | 'l'
+ | 'm'
+ | 'n'
+ | 'o'
+ | 'p'
+ | 'q'
+ | 'r'
+ | 's'
+ | 't'
+ | 'u'
+ | 'v'
+ | 'w'
+ | 'x'
+ | 'y'
+ | 'z'
+ | 'Meta'
+ | '*'
+ | '+'
+ | '-'
+ | '/'
+ | ';'
+ | '='
+ | ','
+ | '.'
+ | '`'
+ | '['
+ | '\\'
+ | ']'
+ | "'"
+ | 'Attn'
+ | 'CrSel'
+ | 'ExSel'
+ | 'EraseEof'
+ | 'Play'
+ | 'ZoomOut'
+ | ')'
+ | '!'
+ | '@'
+ | '#'
+ | '$'
+ | '%'
+ | '^'
+ | '&'
+ | '('
+ | 'A'
+ | 'B'
+ | 'C'
+ | 'D'
+ | 'E'
+ | 'F'
+ | 'G'
+ | 'H'
+ | 'I'
+ | 'J'
+ | 'K'
+ | 'L'
+ | 'M'
+ | 'N'
+ | 'O'
+ | 'P'
+ | 'Q'
+ | 'R'
+ | 'S'
+ | 'T'
+ | 'U'
+ | 'V'
+ | 'W'
+ | 'X'
+ | 'Y'
+ | 'Z'
+ | ':'
+ | '<'
+ | '_'
+ | '>'
+ | '?'
+ | '~'
+ | '{'
+ | '|'
+ | '}'
+ | '"'
+ | 'SoftLeft'
+ | 'SoftRight'
+ | 'Camera'
+ | 'Call'
+ | 'EndCall'
+ | 'VolumeDown'
+ | 'VolumeUp';
+
+/**
+ * @internal
+ */
+export const _keyDefinitions: Readonly<Record<KeyInput, KeyDefinition>> = {
+ '0': {keyCode: 48, key: '0', code: 'Digit0'},
+ '1': {keyCode: 49, key: '1', code: 'Digit1'},
+ '2': {keyCode: 50, key: '2', code: 'Digit2'},
+ '3': {keyCode: 51, key: '3', code: 'Digit3'},
+ '4': {keyCode: 52, key: '4', code: 'Digit4'},
+ '5': {keyCode: 53, key: '5', code: 'Digit5'},
+ '6': {keyCode: 54, key: '6', code: 'Digit6'},
+ '7': {keyCode: 55, key: '7', code: 'Digit7'},
+ '8': {keyCode: 56, key: '8', code: 'Digit8'},
+ '9': {keyCode: 57, key: '9', code: 'Digit9'},
+ Power: {key: 'Power', code: 'Power'},
+ Eject: {key: 'Eject', code: 'Eject'},
+ Abort: {keyCode: 3, code: 'Abort', key: 'Cancel'},
+ Help: {keyCode: 6, code: 'Help', key: 'Help'},
+ Backspace: {keyCode: 8, code: 'Backspace', key: 'Backspace'},
+ Tab: {keyCode: 9, code: 'Tab', key: 'Tab'},
+ Numpad5: {
+ keyCode: 12,
+ shiftKeyCode: 101,
+ key: 'Clear',
+ code: 'Numpad5',
+ shiftKey: '5',
+ location: 3,
+ },
+ NumpadEnter: {
+ keyCode: 13,
+ code: 'NumpadEnter',
+ key: 'Enter',
+ text: '\r',
+ location: 3,
+ },
+ Enter: {keyCode: 13, code: 'Enter', key: 'Enter', text: '\r'},
+ '\r': {keyCode: 13, code: 'Enter', key: 'Enter', text: '\r'},
+ '\n': {keyCode: 13, code: 'Enter', key: 'Enter', text: '\r'},
+ ShiftLeft: {keyCode: 16, code: 'ShiftLeft', key: 'Shift', location: 1},
+ ShiftRight: {keyCode: 16, code: 'ShiftRight', key: 'Shift', location: 2},
+ ControlLeft: {
+ keyCode: 17,
+ code: 'ControlLeft',
+ key: 'Control',
+ location: 1,
+ },
+ ControlRight: {
+ keyCode: 17,
+ code: 'ControlRight',
+ key: 'Control',
+ location: 2,
+ },
+ AltLeft: {keyCode: 18, code: 'AltLeft', key: 'Alt', location: 1},
+ AltRight: {keyCode: 18, code: 'AltRight', key: 'Alt', location: 2},
+ Pause: {keyCode: 19, code: 'Pause', key: 'Pause'},
+ CapsLock: {keyCode: 20, code: 'CapsLock', key: 'CapsLock'},
+ Escape: {keyCode: 27, code: 'Escape', key: 'Escape'},
+ Convert: {keyCode: 28, code: 'Convert', key: 'Convert'},
+ NonConvert: {keyCode: 29, code: 'NonConvert', key: 'NonConvert'},
+ Space: {keyCode: 32, code: 'Space', key: ' '},
+ Numpad9: {
+ keyCode: 33,
+ shiftKeyCode: 105,
+ key: 'PageUp',
+ code: 'Numpad9',
+ shiftKey: '9',
+ location: 3,
+ },
+ PageUp: {keyCode: 33, code: 'PageUp', key: 'PageUp'},
+ Numpad3: {
+ keyCode: 34,
+ shiftKeyCode: 99,
+ key: 'PageDown',
+ code: 'Numpad3',
+ shiftKey: '3',
+ location: 3,
+ },
+ PageDown: {keyCode: 34, code: 'PageDown', key: 'PageDown'},
+ End: {keyCode: 35, code: 'End', key: 'End'},
+ Numpad1: {
+ keyCode: 35,
+ shiftKeyCode: 97,
+ key: 'End',
+ code: 'Numpad1',
+ shiftKey: '1',
+ location: 3,
+ },
+ Home: {keyCode: 36, code: 'Home', key: 'Home'},
+ Numpad7: {
+ keyCode: 36,
+ shiftKeyCode: 103,
+ key: 'Home',
+ code: 'Numpad7',
+ shiftKey: '7',
+ location: 3,
+ },
+ ArrowLeft: {keyCode: 37, code: 'ArrowLeft', key: 'ArrowLeft'},
+ Numpad4: {
+ keyCode: 37,
+ shiftKeyCode: 100,
+ key: 'ArrowLeft',
+ code: 'Numpad4',
+ shiftKey: '4',
+ location: 3,
+ },
+ Numpad8: {
+ keyCode: 38,
+ shiftKeyCode: 104,
+ key: 'ArrowUp',
+ code: 'Numpad8',
+ shiftKey: '8',
+ location: 3,
+ },
+ ArrowUp: {keyCode: 38, code: 'ArrowUp', key: 'ArrowUp'},
+ ArrowRight: {keyCode: 39, code: 'ArrowRight', key: 'ArrowRight'},
+ Numpad6: {
+ keyCode: 39,
+ shiftKeyCode: 102,
+ key: 'ArrowRight',
+ code: 'Numpad6',
+ shiftKey: '6',
+ location: 3,
+ },
+ Numpad2: {
+ keyCode: 40,
+ shiftKeyCode: 98,
+ key: 'ArrowDown',
+ code: 'Numpad2',
+ shiftKey: '2',
+ location: 3,
+ },
+ ArrowDown: {keyCode: 40, code: 'ArrowDown', key: 'ArrowDown'},
+ Select: {keyCode: 41, code: 'Select', key: 'Select'},
+ Open: {keyCode: 43, code: 'Open', key: 'Execute'},
+ PrintScreen: {keyCode: 44, code: 'PrintScreen', key: 'PrintScreen'},
+ Insert: {keyCode: 45, code: 'Insert', key: 'Insert'},
+ Numpad0: {
+ keyCode: 45,
+ shiftKeyCode: 96,
+ key: 'Insert',
+ code: 'Numpad0',
+ shiftKey: '0',
+ location: 3,
+ },
+ Delete: {keyCode: 46, code: 'Delete', key: 'Delete'},
+ NumpadDecimal: {
+ keyCode: 46,
+ shiftKeyCode: 110,
+ code: 'NumpadDecimal',
+ key: '\u0000',
+ shiftKey: '.',
+ location: 3,
+ },
+ Digit0: {keyCode: 48, code: 'Digit0', shiftKey: ')', key: '0'},
+ Digit1: {keyCode: 49, code: 'Digit1', shiftKey: '!', key: '1'},
+ Digit2: {keyCode: 50, code: 'Digit2', shiftKey: '@', key: '2'},
+ Digit3: {keyCode: 51, code: 'Digit3', shiftKey: '#', key: '3'},
+ Digit4: {keyCode: 52, code: 'Digit4', shiftKey: '$', key: '4'},
+ Digit5: {keyCode: 53, code: 'Digit5', shiftKey: '%', key: '5'},
+ Digit6: {keyCode: 54, code: 'Digit6', shiftKey: '^', key: '6'},
+ Digit7: {keyCode: 55, code: 'Digit7', shiftKey: '&', key: '7'},
+ Digit8: {keyCode: 56, code: 'Digit8', shiftKey: '*', key: '8'},
+ Digit9: {keyCode: 57, code: 'Digit9', shiftKey: '(', key: '9'},
+ KeyA: {keyCode: 65, code: 'KeyA', shiftKey: 'A', key: 'a'},
+ KeyB: {keyCode: 66, code: 'KeyB', shiftKey: 'B', key: 'b'},
+ KeyC: {keyCode: 67, code: 'KeyC', shiftKey: 'C', key: 'c'},
+ KeyD: {keyCode: 68, code: 'KeyD', shiftKey: 'D', key: 'd'},
+ KeyE: {keyCode: 69, code: 'KeyE', shiftKey: 'E', key: 'e'},
+ KeyF: {keyCode: 70, code: 'KeyF', shiftKey: 'F', key: 'f'},
+ KeyG: {keyCode: 71, code: 'KeyG', shiftKey: 'G', key: 'g'},
+ KeyH: {keyCode: 72, code: 'KeyH', shiftKey: 'H', key: 'h'},
+ KeyI: {keyCode: 73, code: 'KeyI', shiftKey: 'I', key: 'i'},
+ KeyJ: {keyCode: 74, code: 'KeyJ', shiftKey: 'J', key: 'j'},
+ KeyK: {keyCode: 75, code: 'KeyK', shiftKey: 'K', key: 'k'},
+ KeyL: {keyCode: 76, code: 'KeyL', shiftKey: 'L', key: 'l'},
+ KeyM: {keyCode: 77, code: 'KeyM', shiftKey: 'M', key: 'm'},
+ KeyN: {keyCode: 78, code: 'KeyN', shiftKey: 'N', key: 'n'},
+ KeyO: {keyCode: 79, code: 'KeyO', shiftKey: 'O', key: 'o'},
+ KeyP: {keyCode: 80, code: 'KeyP', shiftKey: 'P', key: 'p'},
+ KeyQ: {keyCode: 81, code: 'KeyQ', shiftKey: 'Q', key: 'q'},
+ KeyR: {keyCode: 82, code: 'KeyR', shiftKey: 'R', key: 'r'},
+ KeyS: {keyCode: 83, code: 'KeyS', shiftKey: 'S', key: 's'},
+ KeyT: {keyCode: 84, code: 'KeyT', shiftKey: 'T', key: 't'},
+ KeyU: {keyCode: 85, code: 'KeyU', shiftKey: 'U', key: 'u'},
+ KeyV: {keyCode: 86, code: 'KeyV', shiftKey: 'V', key: 'v'},
+ KeyW: {keyCode: 87, code: 'KeyW', shiftKey: 'W', key: 'w'},
+ KeyX: {keyCode: 88, code: 'KeyX', shiftKey: 'X', key: 'x'},
+ KeyY: {keyCode: 89, code: 'KeyY', shiftKey: 'Y', key: 'y'},
+ KeyZ: {keyCode: 90, code: 'KeyZ', shiftKey: 'Z', key: 'z'},
+ MetaLeft: {keyCode: 91, code: 'MetaLeft', key: 'Meta', location: 1},
+ MetaRight: {keyCode: 92, code: 'MetaRight', key: 'Meta', location: 2},
+ ContextMenu: {keyCode: 93, code: 'ContextMenu', key: 'ContextMenu'},
+ NumpadMultiply: {
+ keyCode: 106,
+ code: 'NumpadMultiply',
+ key: '*',
+ location: 3,
+ },
+ NumpadAdd: {keyCode: 107, code: 'NumpadAdd', key: '+', location: 3},
+ NumpadSubtract: {
+ keyCode: 109,
+ code: 'NumpadSubtract',
+ key: '-',
+ location: 3,
+ },
+ NumpadDivide: {keyCode: 111, code: 'NumpadDivide', key: '/', location: 3},
+ F1: {keyCode: 112, code: 'F1', key: 'F1'},
+ F2: {keyCode: 113, code: 'F2', key: 'F2'},
+ F3: {keyCode: 114, code: 'F3', key: 'F3'},
+ F4: {keyCode: 115, code: 'F4', key: 'F4'},
+ F5: {keyCode: 116, code: 'F5', key: 'F5'},
+ F6: {keyCode: 117, code: 'F6', key: 'F6'},
+ F7: {keyCode: 118, code: 'F7', key: 'F7'},
+ F8: {keyCode: 119, code: 'F8', key: 'F8'},
+ F9: {keyCode: 120, code: 'F9', key: 'F9'},
+ F10: {keyCode: 121, code: 'F10', key: 'F10'},
+ F11: {keyCode: 122, code: 'F11', key: 'F11'},
+ F12: {keyCode: 123, code: 'F12', key: 'F12'},
+ F13: {keyCode: 124, code: 'F13', key: 'F13'},
+ F14: {keyCode: 125, code: 'F14', key: 'F14'},
+ F15: {keyCode: 126, code: 'F15', key: 'F15'},
+ F16: {keyCode: 127, code: 'F16', key: 'F16'},
+ F17: {keyCode: 128, code: 'F17', key: 'F17'},
+ F18: {keyCode: 129, code: 'F18', key: 'F18'},
+ F19: {keyCode: 130, code: 'F19', key: 'F19'},
+ F20: {keyCode: 131, code: 'F20', key: 'F20'},
+ F21: {keyCode: 132, code: 'F21', key: 'F21'},
+ F22: {keyCode: 133, code: 'F22', key: 'F22'},
+ F23: {keyCode: 134, code: 'F23', key: 'F23'},
+ F24: {keyCode: 135, code: 'F24', key: 'F24'},
+ NumLock: {keyCode: 144, code: 'NumLock', key: 'NumLock'},
+ ScrollLock: {keyCode: 145, code: 'ScrollLock', key: 'ScrollLock'},
+ AudioVolumeMute: {
+ keyCode: 173,
+ code: 'AudioVolumeMute',
+ key: 'AudioVolumeMute',
+ },
+ AudioVolumeDown: {
+ keyCode: 174,
+ code: 'AudioVolumeDown',
+ key: 'AudioVolumeDown',
+ },
+ AudioVolumeUp: {keyCode: 175, code: 'AudioVolumeUp', key: 'AudioVolumeUp'},
+ MediaTrackNext: {
+ keyCode: 176,
+ code: 'MediaTrackNext',
+ key: 'MediaTrackNext',
+ },
+ MediaTrackPrevious: {
+ keyCode: 177,
+ code: 'MediaTrackPrevious',
+ key: 'MediaTrackPrevious',
+ },
+ MediaStop: {keyCode: 178, code: 'MediaStop', key: 'MediaStop'},
+ MediaPlayPause: {
+ keyCode: 179,
+ code: 'MediaPlayPause',
+ key: 'MediaPlayPause',
+ },
+ Semicolon: {keyCode: 186, code: 'Semicolon', shiftKey: ':', key: ';'},
+ Equal: {keyCode: 187, code: 'Equal', shiftKey: '+', key: '='},
+ NumpadEqual: {keyCode: 187, code: 'NumpadEqual', key: '=', location: 3},
+ Comma: {keyCode: 188, code: 'Comma', shiftKey: '<', key: ','},
+ Minus: {keyCode: 189, code: 'Minus', shiftKey: '_', key: '-'},
+ Period: {keyCode: 190, code: 'Period', shiftKey: '>', key: '.'},
+ Slash: {keyCode: 191, code: 'Slash', shiftKey: '?', key: '/'},
+ Backquote: {keyCode: 192, code: 'Backquote', shiftKey: '~', key: '`'},
+ BracketLeft: {keyCode: 219, code: 'BracketLeft', shiftKey: '{', key: '['},
+ Backslash: {keyCode: 220, code: 'Backslash', shiftKey: '|', key: '\\'},
+ BracketRight: {keyCode: 221, code: 'BracketRight', shiftKey: '}', key: ']'},
+ Quote: {keyCode: 222, code: 'Quote', shiftKey: '"', key: "'"},
+ AltGraph: {keyCode: 225, code: 'AltGraph', key: 'AltGraph'},
+ Props: {keyCode: 247, code: 'Props', key: 'CrSel'},
+ Cancel: {keyCode: 3, key: 'Cancel', code: 'Abort'},
+ Clear: {keyCode: 12, key: 'Clear', code: 'Numpad5', location: 3},
+ Shift: {keyCode: 16, key: 'Shift', code: 'ShiftLeft', location: 1},
+ Control: {keyCode: 17, key: 'Control', code: 'ControlLeft', location: 1},
+ Alt: {keyCode: 18, key: 'Alt', code: 'AltLeft', location: 1},
+ Accept: {keyCode: 30, key: 'Accept'},
+ ModeChange: {keyCode: 31, key: 'ModeChange'},
+ ' ': {keyCode: 32, key: ' ', code: 'Space'},
+ Print: {keyCode: 42, key: 'Print'},
+ Execute: {keyCode: 43, key: 'Execute', code: 'Open'},
+ '\u0000': {keyCode: 46, key: '\u0000', code: 'NumpadDecimal', location: 3},
+ a: {keyCode: 65, key: 'a', code: 'KeyA'},
+ b: {keyCode: 66, key: 'b', code: 'KeyB'},
+ c: {keyCode: 67, key: 'c', code: 'KeyC'},
+ d: {keyCode: 68, key: 'd', code: 'KeyD'},
+ e: {keyCode: 69, key: 'e', code: 'KeyE'},
+ f: {keyCode: 70, key: 'f', code: 'KeyF'},
+ g: {keyCode: 71, key: 'g', code: 'KeyG'},
+ h: {keyCode: 72, key: 'h', code: 'KeyH'},
+ i: {keyCode: 73, key: 'i', code: 'KeyI'},
+ j: {keyCode: 74, key: 'j', code: 'KeyJ'},
+ k: {keyCode: 75, key: 'k', code: 'KeyK'},
+ l: {keyCode: 76, key: 'l', code: 'KeyL'},
+ m: {keyCode: 77, key: 'm', code: 'KeyM'},
+ n: {keyCode: 78, key: 'n', code: 'KeyN'},
+ o: {keyCode: 79, key: 'o', code: 'KeyO'},
+ p: {keyCode: 80, key: 'p', code: 'KeyP'},
+ q: {keyCode: 81, key: 'q', code: 'KeyQ'},
+ r: {keyCode: 82, key: 'r', code: 'KeyR'},
+ s: {keyCode: 83, key: 's', code: 'KeyS'},
+ t: {keyCode: 84, key: 't', code: 'KeyT'},
+ u: {keyCode: 85, key: 'u', code: 'KeyU'},
+ v: {keyCode: 86, key: 'v', code: 'KeyV'},
+ w: {keyCode: 87, key: 'w', code: 'KeyW'},
+ x: {keyCode: 88, key: 'x', code: 'KeyX'},
+ y: {keyCode: 89, key: 'y', code: 'KeyY'},
+ z: {keyCode: 90, key: 'z', code: 'KeyZ'},
+ Meta: {keyCode: 91, key: 'Meta', code: 'MetaLeft', location: 1},
+ '*': {keyCode: 106, key: '*', code: 'NumpadMultiply', location: 3},
+ '+': {keyCode: 107, key: '+', code: 'NumpadAdd', location: 3},
+ '-': {keyCode: 109, key: '-', code: 'NumpadSubtract', location: 3},
+ '/': {keyCode: 111, key: '/', code: 'NumpadDivide', location: 3},
+ ';': {keyCode: 186, key: ';', code: 'Semicolon'},
+ '=': {keyCode: 187, key: '=', code: 'Equal'},
+ ',': {keyCode: 188, key: ',', code: 'Comma'},
+ '.': {keyCode: 190, key: '.', code: 'Period'},
+ '`': {keyCode: 192, key: '`', code: 'Backquote'},
+ '[': {keyCode: 219, key: '[', code: 'BracketLeft'},
+ '\\': {keyCode: 220, key: '\\', code: 'Backslash'},
+ ']': {keyCode: 221, key: ']', code: 'BracketRight'},
+ "'": {keyCode: 222, key: "'", code: 'Quote'},
+ Attn: {keyCode: 246, key: 'Attn'},
+ CrSel: {keyCode: 247, key: 'CrSel', code: 'Props'},
+ ExSel: {keyCode: 248, key: 'ExSel'},
+ EraseEof: {keyCode: 249, key: 'EraseEof'},
+ Play: {keyCode: 250, key: 'Play'},
+ ZoomOut: {keyCode: 251, key: 'ZoomOut'},
+ ')': {keyCode: 48, key: ')', code: 'Digit0'},
+ '!': {keyCode: 49, key: '!', code: 'Digit1'},
+ '@': {keyCode: 50, key: '@', code: 'Digit2'},
+ '#': {keyCode: 51, key: '#', code: 'Digit3'},
+ $: {keyCode: 52, key: '$', code: 'Digit4'},
+ '%': {keyCode: 53, key: '%', code: 'Digit5'},
+ '^': {keyCode: 54, key: '^', code: 'Digit6'},
+ '&': {keyCode: 55, key: '&', code: 'Digit7'},
+ '(': {keyCode: 57, key: '(', code: 'Digit9'},
+ A: {keyCode: 65, key: 'A', code: 'KeyA'},
+ B: {keyCode: 66, key: 'B', code: 'KeyB'},
+ C: {keyCode: 67, key: 'C', code: 'KeyC'},
+ D: {keyCode: 68, key: 'D', code: 'KeyD'},
+ E: {keyCode: 69, key: 'E', code: 'KeyE'},
+ F: {keyCode: 70, key: 'F', code: 'KeyF'},
+ G: {keyCode: 71, key: 'G', code: 'KeyG'},
+ H: {keyCode: 72, key: 'H', code: 'KeyH'},
+ I: {keyCode: 73, key: 'I', code: 'KeyI'},
+ J: {keyCode: 74, key: 'J', code: 'KeyJ'},
+ K: {keyCode: 75, key: 'K', code: 'KeyK'},
+ L: {keyCode: 76, key: 'L', code: 'KeyL'},
+ M: {keyCode: 77, key: 'M', code: 'KeyM'},
+ N: {keyCode: 78, key: 'N', code: 'KeyN'},
+ O: {keyCode: 79, key: 'O', code: 'KeyO'},
+ P: {keyCode: 80, key: 'P', code: 'KeyP'},
+ Q: {keyCode: 81, key: 'Q', code: 'KeyQ'},
+ R: {keyCode: 82, key: 'R', code: 'KeyR'},
+ S: {keyCode: 83, key: 'S', code: 'KeyS'},
+ T: {keyCode: 84, key: 'T', code: 'KeyT'},
+ U: {keyCode: 85, key: 'U', code: 'KeyU'},
+ V: {keyCode: 86, key: 'V', code: 'KeyV'},
+ W: {keyCode: 87, key: 'W', code: 'KeyW'},
+ X: {keyCode: 88, key: 'X', code: 'KeyX'},
+ Y: {keyCode: 89, key: 'Y', code: 'KeyY'},
+ Z: {keyCode: 90, key: 'Z', code: 'KeyZ'},
+ ':': {keyCode: 186, key: ':', code: 'Semicolon'},
+ '<': {keyCode: 188, key: '<', code: 'Comma'},
+ _: {keyCode: 189, key: '_', code: 'Minus'},
+ '>': {keyCode: 190, key: '>', code: 'Period'},
+ '?': {keyCode: 191, key: '?', code: 'Slash'},
+ '~': {keyCode: 192, key: '~', code: 'Backquote'},
+ '{': {keyCode: 219, key: '{', code: 'BracketLeft'},
+ '|': {keyCode: 220, key: '|', code: 'Backslash'},
+ '}': {keyCode: 221, key: '}', code: 'BracketRight'},
+ '"': {keyCode: 222, key: '"', code: 'Quote'},
+ SoftLeft: {key: 'SoftLeft', code: 'SoftLeft', location: 4},
+ SoftRight: {key: 'SoftRight', code: 'SoftRight', location: 4},
+ Camera: {keyCode: 44, key: 'Camera', code: 'Camera', location: 4},
+ Call: {key: 'Call', code: 'Call', location: 4},
+ EndCall: {keyCode: 95, key: 'EndCall', code: 'EndCall', location: 4},
+ VolumeDown: {
+ keyCode: 182,
+ key: 'VolumeDown',
+ code: 'VolumeDown',
+ location: 4,
+ },
+ VolumeUp: {keyCode: 183, key: 'VolumeUp', code: 'VolumeUp', location: 4},
+};
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/Viewport.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/Viewport.ts
new file mode 100644
index 0000000000..46a937a88f
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/Viewport.ts
@@ -0,0 +1,50 @@
+/**
+ * @license
+ * Copyright 2020 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * @public
+ */
+export interface Viewport {
+ /**
+ * The page width in CSS pixels.
+ *
+ * @remarks
+ * Setting this value to `0` will reset this value to the system default.
+ */
+ width: number;
+ /**
+ * The page height in CSS pixels.
+ *
+ * @remarks
+ * Setting this value to `0` will reset this value to the system default.
+ */
+ height: number;
+ /**
+ * Specify device scale factor.
+ * See {@link https://developer.mozilla.org/en-US/docs/Web/API/Window/devicePixelRatio | devicePixelRatio} for more info.
+ *
+ * @remarks
+ * Setting this value to `0` will reset this value to the system default.
+ *
+ * @defaultValue `1`
+ */
+ deviceScaleFactor?: number;
+ /**
+ * Whether the `meta viewport` tag is taken into account.
+ * @defaultValue `false`
+ */
+ isMobile?: boolean;
+ /**
+ * Specifies if the viewport is in landscape mode.
+ * @defaultValue `false`
+ */
+ isLandscape?: boolean;
+ /**
+ * Specify if the viewport supports touch events.
+ * @defaultValue `false`
+ */
+ hasTouch?: boolean;
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/WaitTask.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/WaitTask.ts
new file mode 100644
index 0000000000..d0c1e2a038
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/WaitTask.ts
@@ -0,0 +1,275 @@
+/**
+ * @license
+ * Copyright 2022 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {ElementHandle} from '../api/ElementHandle.js';
+import type {JSHandle} from '../api/JSHandle.js';
+import type {Realm} from '../api/Realm.js';
+import type {Poller} from '../injected/Poller.js';
+import {Deferred} from '../util/Deferred.js';
+import {isErrorLike} from '../util/ErrorLike.js';
+import {stringifyFunction} from '../util/Function.js';
+
+import {TimeoutError} from './Errors.js';
+import {LazyArg} from './LazyArg.js';
+import type {HandleFor} from './types.js';
+
+/**
+ * @internal
+ */
+export interface WaitTaskOptions {
+ polling: 'raf' | 'mutation' | number;
+ root?: ElementHandle<Node>;
+ timeout: number;
+ signal?: AbortSignal;
+}
+
+/**
+ * @internal
+ */
+export class WaitTask<T = unknown> {
+ #world: Realm;
+ #polling: 'raf' | 'mutation' | number;
+ #root?: ElementHandle<Node>;
+
+ #fn: string;
+ #args: unknown[];
+
+ #timeout?: NodeJS.Timeout;
+ #timeoutError?: TimeoutError;
+
+ #result = Deferred.create<HandleFor<T>>();
+
+ #poller?: JSHandle<Poller<T>>;
+ #signal?: AbortSignal;
+ #reruns: AbortController[] = [];
+
+ constructor(
+ world: Realm,
+ options: WaitTaskOptions,
+ fn: ((...args: unknown[]) => Promise<T>) | string,
+ ...args: unknown[]
+ ) {
+ this.#world = world;
+ this.#polling = options.polling;
+ this.#root = options.root;
+ this.#signal = options.signal;
+ this.#signal?.addEventListener(
+ 'abort',
+ () => {
+ void this.terminate(this.#signal?.reason);
+ },
+ {
+ once: true,
+ }
+ );
+
+ switch (typeof fn) {
+ case 'string':
+ this.#fn = `() => {return (${fn});}`;
+ break;
+ default:
+ this.#fn = stringifyFunction(fn);
+ break;
+ }
+ this.#args = args;
+
+ this.#world.taskManager.add(this);
+
+ if (options.timeout) {
+ this.#timeoutError = new TimeoutError(
+ `Waiting failed: ${options.timeout}ms exceeded`
+ );
+ this.#timeout = setTimeout(() => {
+ void this.terminate(this.#timeoutError);
+ }, options.timeout);
+ }
+
+ void this.rerun();
+ }
+
+ get result(): Promise<HandleFor<T>> {
+ return this.#result.valueOrThrow();
+ }
+
+ async rerun(): Promise<void> {
+ for (const prev of this.#reruns) {
+ prev.abort();
+ }
+ this.#reruns.length = 0;
+ const controller = new AbortController();
+ this.#reruns.push(controller);
+ try {
+ switch (this.#polling) {
+ case 'raf':
+ this.#poller = await this.#world.evaluateHandle(
+ ({RAFPoller, createFunction}, fn, ...args) => {
+ const fun = createFunction(fn);
+ return new RAFPoller(() => {
+ return fun(...args) as Promise<T>;
+ });
+ },
+ LazyArg.create(context => {
+ return context.puppeteerUtil;
+ }),
+ this.#fn,
+ ...this.#args
+ );
+ break;
+ case 'mutation':
+ this.#poller = await this.#world.evaluateHandle(
+ ({MutationPoller, createFunction}, root, fn, ...args) => {
+ const fun = createFunction(fn);
+ return new MutationPoller(() => {
+ return fun(...args) as Promise<T>;
+ }, root || document);
+ },
+ LazyArg.create(context => {
+ return context.puppeteerUtil;
+ }),
+ this.#root,
+ this.#fn,
+ ...this.#args
+ );
+ break;
+ default:
+ this.#poller = await this.#world.evaluateHandle(
+ ({IntervalPoller, createFunction}, ms, fn, ...args) => {
+ const fun = createFunction(fn);
+ return new IntervalPoller(() => {
+ return fun(...args) as Promise<T>;
+ }, ms);
+ },
+ LazyArg.create(context => {
+ return context.puppeteerUtil;
+ }),
+ this.#polling,
+ this.#fn,
+ ...this.#args
+ );
+ break;
+ }
+
+ await this.#poller.evaluate(poller => {
+ void poller.start();
+ });
+
+ const result = await this.#poller.evaluateHandle(poller => {
+ return poller.result();
+ });
+ this.#result.resolve(result);
+
+ await this.terminate();
+ } catch (error) {
+ if (controller.signal.aborted) {
+ return;
+ }
+ const badError = this.getBadError(error);
+ if (badError) {
+ await this.terminate(badError);
+ }
+ }
+ }
+
+ async terminate(error?: Error): Promise<void> {
+ this.#world.taskManager.delete(this);
+
+ clearTimeout(this.#timeout);
+
+ if (error && !this.#result.finished()) {
+ this.#result.reject(error);
+ }
+
+ if (this.#poller) {
+ try {
+ await this.#poller.evaluateHandle(async poller => {
+ await poller.stop();
+ });
+ if (this.#poller) {
+ await this.#poller.dispose();
+ this.#poller = undefined;
+ }
+ } catch {
+ // Ignore errors since they most likely come from low-level cleanup.
+ }
+ }
+ }
+
+ /**
+ * Not all errors lead to termination. They usually imply we need to rerun the task.
+ */
+ getBadError(error: unknown): Error | undefined {
+ if (isErrorLike(error)) {
+ // When frame is detached the task should have been terminated by the IsolatedWorld.
+ // This can fail if we were adding this task while the frame was detached,
+ // so we terminate here instead.
+ if (
+ error.message.includes(
+ 'Execution context is not available in detached frame'
+ )
+ ) {
+ return new Error('Waiting failed: Frame detached');
+ }
+
+ // When the page is navigated, the promise is rejected.
+ // We will try again in the new execution context.
+ if (error.message.includes('Execution context was destroyed')) {
+ return;
+ }
+
+ // We could have tried to evaluate in a context which was already
+ // destroyed.
+ if (error.message.includes('Cannot find context with specified id')) {
+ return;
+ }
+
+ // Errors coming from WebDriver BiDi. TODO: Adjust messages after
+ // https://github.com/w3c/webdriver-bidi/issues/540 is resolved.
+ if (
+ error.message.includes(
+ "AbortError: Actor 'MessageHandlerFrame' destroyed"
+ )
+ ) {
+ return;
+ }
+
+ return error;
+ }
+
+ return new Error('WaitTask failed with an error', {
+ cause: error,
+ });
+ }
+}
+
+/**
+ * @internal
+ */
+export class TaskManager {
+ #tasks: Set<WaitTask> = new Set<WaitTask>();
+
+ add(task: WaitTask<any>): void {
+ this.#tasks.add(task);
+ }
+
+ delete(task: WaitTask<any>): void {
+ this.#tasks.delete(task);
+ }
+
+ terminateAll(error?: Error): void {
+ for (const task of this.#tasks) {
+ void task.terminate(error);
+ }
+ this.#tasks.clear();
+ }
+
+ async rerunAll(): Promise<void> {
+ await Promise.all(
+ [...this.#tasks].map(task => {
+ return task.rerun();
+ })
+ );
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/XPathQueryHandler.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/XPathQueryHandler.ts
new file mode 100644
index 0000000000..b6e3a67bad
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/XPathQueryHandler.ts
@@ -0,0 +1,35 @@
+/**
+ * @license
+ * Copyright 2023 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {
+ QueryHandler,
+ type QuerySelectorAll,
+ type QuerySelector,
+} from './QueryHandler.js';
+
+/**
+ * @internal
+ */
+export class XPathQueryHandler extends QueryHandler {
+ static override querySelectorAll: QuerySelectorAll = (
+ element,
+ selector,
+ {xpathQuerySelectorAll}
+ ) => {
+ return xpathQuerySelectorAll(element, selector);
+ };
+
+ static override querySelector: QuerySelector = (
+ element: Node,
+ selector: string,
+ {xpathQuerySelectorAll}
+ ) => {
+ for (const result of xpathQuerySelectorAll(element, selector, 1)) {
+ return result;
+ }
+ return null;
+ };
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/common.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/common.ts
new file mode 100644
index 0000000000..6ef8925605
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/common.ts
@@ -0,0 +1,40 @@
+/**
+ * @license
+ * Copyright 2022 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+export * from './BrowserWebSocketTransport.js';
+export * from './CallbackRegistry.js';
+export * from './Configuration.js';
+export * from './ConnectionTransport.js';
+export * from './ConnectOptions.js';
+export * from './ConsoleMessage.js';
+export * from './CustomQueryHandler.js';
+export * from './Debug.js';
+export * from './Device.js';
+export * from './Errors.js';
+export * from './EventEmitter.js';
+export * from './fetch.js';
+export * from './FileChooser.js';
+export * from './GetQueryHandler.js';
+export * from './HandleIterator.js';
+export * from './LazyArg.js';
+export * from './NetworkManagerEvents.js';
+export * from './PDFOptions.js';
+export * from './PierceQueryHandler.js';
+export * from './PQueryHandler.js';
+export * from './Product.js';
+export * from './Puppeteer.js';
+export * from './QueryHandler.js';
+export * from './ScriptInjector.js';
+export * from './SecurityDetails.js';
+export * from './TaskQueue.js';
+export * from './TextQueryHandler.js';
+export * from './TimeoutSettings.js';
+export * from './types.js';
+export * from './USKeyboardLayout.js';
+export * from './util.js';
+export * from './Viewport.js';
+export * from './WaitTask.js';
+export * from './XPathQueryHandler.js';
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/fetch.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/fetch.ts
new file mode 100644
index 0000000000..6c7a2b451c
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/fetch.ts
@@ -0,0 +1,14 @@
+/**
+ * @license
+ * Copyright 2020 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * Gets the global version if we're in the browser, else loads the node-fetch module.
+ *
+ * @internal
+ */
+export const getFetch = async (): Promise<typeof fetch> => {
+ return (globalThis as any).fetch || (await import('cross-fetch')).fetch;
+};
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/types.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/types.ts
new file mode 100644
index 0000000000..3f2cf5d4f3
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/types.ts
@@ -0,0 +1,225 @@
+/**
+ * @license
+ * Copyright 2020 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {ElementHandle} from '../api/ElementHandle.js';
+import type {JSHandle} from '../api/JSHandle.js';
+
+import type {LazyArg} from './LazyArg.js';
+
+/**
+ * @public
+ */
+export type AwaitablePredicate<T> = (value: T) => Awaitable<boolean>;
+
+/**
+ * @public
+ */
+export interface Moveable {
+ /**
+ * Moves the resource when 'using'.
+ */
+ move(): this;
+}
+
+/**
+ * @internal
+ */
+export interface Disposed {
+ get disposed(): boolean;
+}
+
+/**
+ * @internal
+ */
+export interface BindingPayload {
+ type: string;
+ name: string;
+ seq: number;
+ args: unknown[];
+ /**
+ * Determines whether the arguments of the payload are trivial.
+ */
+ isTrivial: boolean;
+}
+
+/**
+ * @internal
+ */
+export type AwaitableIterator<T> = Iterator<T> | AsyncIterator<T>;
+
+/**
+ * @public
+ */
+export type AwaitableIterable<T> = Iterable<T> | AsyncIterable<T>;
+
+/**
+ * @public
+ */
+export type Awaitable<T> = T | PromiseLike<T>;
+
+/**
+ * @public
+ */
+export type HandleFor<T> = T extends Node ? ElementHandle<T> : JSHandle<T>;
+
+/**
+ * @public
+ */
+export type HandleOr<T> = HandleFor<T> | JSHandle<T> | T;
+
+/**
+ * @public
+ */
+export type FlattenHandle<T> = T extends HandleOr<infer U> ? U : never;
+
+/**
+ * @internal
+ */
+export type FlattenLazyArg<T> = T extends LazyArg<infer U> ? U : T;
+
+/**
+ * @internal
+ */
+export type InnerLazyParams<T extends unknown[]> = {
+ [K in keyof T]: FlattenLazyArg<T[K]>;
+};
+
+/**
+ * @public
+ */
+export type InnerParams<T extends unknown[]> = {
+ [K in keyof T]: FlattenHandle<T[K]>;
+};
+
+/**
+ * @public
+ */
+export type ElementFor<
+ TagName extends keyof HTMLElementTagNameMap | keyof SVGElementTagNameMap,
+> = TagName extends keyof HTMLElementTagNameMap
+ ? HTMLElementTagNameMap[TagName]
+ : TagName extends keyof SVGElementTagNameMap
+ ? SVGElementTagNameMap[TagName]
+ : never;
+
+/**
+ * @public
+ */
+export type EvaluateFunc<T extends unknown[]> = (
+ ...params: InnerParams<T>
+) => Awaitable<unknown>;
+
+/**
+ * @public
+ */
+export type EvaluateFuncWith<V, T extends unknown[]> = (
+ ...params: [V, ...InnerParams<T>]
+) => Awaitable<unknown>;
+
+/**
+ * @public
+ */
+export type NodeFor<ComplexSelector extends string> =
+ TypeSelectorOfComplexSelector<ComplexSelector> extends infer TypeSelector
+ ? TypeSelector extends
+ | keyof HTMLElementTagNameMap
+ | keyof SVGElementTagNameMap
+ ? ElementFor<TypeSelector>
+ : Element
+ : never;
+
+type TypeSelectorOfComplexSelector<ComplexSelector extends string> =
+ CompoundSelectorsOfComplexSelector<ComplexSelector> extends infer CompoundSelectors
+ ? CompoundSelectors extends NonEmptyReadonlyArray<string>
+ ? Last<CompoundSelectors> extends infer LastCompoundSelector
+ ? LastCompoundSelector extends string
+ ? TypeSelectorOfCompoundSelector<LastCompoundSelector>
+ : never
+ : never
+ : unknown
+ : never;
+
+type TypeSelectorOfCompoundSelector<CompoundSelector extends string> =
+ SplitWithDelemiters<
+ CompoundSelector,
+ BeginSubclassSelectorTokens
+ > extends infer CompoundSelectorTokens
+ ? CompoundSelectorTokens extends [infer TypeSelector, ...any[]]
+ ? TypeSelector extends ''
+ ? unknown
+ : TypeSelector
+ : never
+ : never;
+
+type Last<Arr extends NonEmptyReadonlyArray<unknown>> = Arr extends [
+ infer Head,
+ ...infer Tail,
+]
+ ? Tail extends NonEmptyReadonlyArray<unknown>
+ ? Last<Tail>
+ : Head
+ : never;
+
+type NonEmptyReadonlyArray<T> = [T, ...(readonly T[])];
+
+type CompoundSelectorsOfComplexSelector<ComplexSelector extends string> =
+ SplitWithDelemiters<
+ ComplexSelector,
+ CombinatorTokens
+ > extends infer IntermediateTokens
+ ? IntermediateTokens extends readonly string[]
+ ? Drop<IntermediateTokens, ''>
+ : never
+ : never;
+
+type SplitWithDelemiters<
+ Input extends string,
+ Delemiters extends readonly string[],
+> = Delemiters extends [infer FirstDelemiter, ...infer RestDelemiters]
+ ? FirstDelemiter extends string
+ ? RestDelemiters extends readonly string[]
+ ? FlatmapSplitWithDelemiters<Split<Input, FirstDelemiter>, RestDelemiters>
+ : never
+ : never
+ : [Input];
+
+type BeginSubclassSelectorTokens = ['.', '#', '[', ':'];
+
+type CombinatorTokens = [' ', '>', '+', '~', '|', '|'];
+
+type Drop<
+ Arr extends readonly unknown[],
+ Remove,
+ Acc extends unknown[] = [],
+> = Arr extends [infer Head, ...infer Tail]
+ ? Head extends Remove
+ ? Drop<Tail, Remove>
+ : Drop<Tail, Remove, [...Acc, Head]>
+ : Acc;
+
+type FlatmapSplitWithDelemiters<
+ Inputs extends readonly string[],
+ Delemiters extends readonly string[],
+ Acc extends string[] = [],
+> = Inputs extends [infer FirstInput, ...infer RestInputs]
+ ? FirstInput extends string
+ ? RestInputs extends readonly string[]
+ ? FlatmapSplitWithDelemiters<
+ RestInputs,
+ Delemiters,
+ [...Acc, ...SplitWithDelemiters<FirstInput, Delemiters>]
+ >
+ : Acc
+ : Acc
+ : Acc;
+
+type Split<
+ Input extends string,
+ Delimiter extends string,
+ Acc extends string[] = [],
+> = Input extends `${infer Prefix}${Delimiter}${infer Suffix}`
+ ? Split<Suffix, Delimiter, [...Acc, Prefix]>
+ : [...Acc, Input];
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/common/util.ts b/remote/test/puppeteer/packages/puppeteer-core/src/common/util.ts
new file mode 100644
index 0000000000..2c8f76f664
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/common/util.ts
@@ -0,0 +1,447 @@
+/**
+ * @license
+ * Copyright 2017 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type FS from 'fs/promises';
+import type {Readable} from 'stream';
+
+import {map, NEVER, Observable, timer} from '../../third_party/rxjs/rxjs.js';
+import type {CDPSession} from '../api/CDPSession.js';
+import {isNode} from '../environment.js';
+import {assert} from '../util/assert.js';
+import {isErrorLike} from '../util/ErrorLike.js';
+
+import {debug} from './Debug.js';
+import {TimeoutError} from './Errors.js';
+import type {EventEmitter, EventType} from './EventEmitter.js';
+import type {
+ LowerCasePaperFormat,
+ ParsedPDFOptions,
+ PDFOptions,
+} from './PDFOptions.js';
+import {paperFormats} from './PDFOptions.js';
+
+/**
+ * @internal
+ */
+export const debugError = debug('puppeteer:error');
+
+/**
+ * @internal
+ */
+export const DEFAULT_VIEWPORT = Object.freeze({width: 800, height: 600});
+
+/**
+ * @internal
+ */
+const SOURCE_URL = Symbol('Source URL for Puppeteer evaluation scripts');
+
+/**
+ * @internal
+ */
+export class PuppeteerURL {
+ static INTERNAL_URL = 'pptr:internal';
+
+ static fromCallSite(
+ functionName: string,
+ site: NodeJS.CallSite
+ ): PuppeteerURL {
+ const url = new PuppeteerURL();
+ url.#functionName = functionName;
+ url.#siteString = site.toString();
+ return url;
+ }
+
+ static parse = (url: string): PuppeteerURL => {
+ url = url.slice('pptr:'.length);
+ const [functionName = '', siteString = ''] = url.split(';');
+ const puppeteerUrl = new PuppeteerURL();
+ puppeteerUrl.#functionName = functionName;
+ puppeteerUrl.#siteString = decodeURIComponent(siteString);
+ return puppeteerUrl;
+ };
+
+ static isPuppeteerURL = (url: string): boolean => {
+ return url.startsWith('pptr:');
+ };
+
+ #functionName!: string;
+ #siteString!: string;
+
+ get functionName(): string {
+ return this.#functionName;
+ }
+
+ get siteString(): string {
+ return this.#siteString;
+ }
+
+ toString(): string {
+ return `pptr:${[
+ this.#functionName,
+ encodeURIComponent(this.#siteString),
+ ].join(';')}`;
+ }
+}
+
+/**
+ * @internal
+ */
+export const withSourcePuppeteerURLIfNone = <T extends NonNullable<unknown>>(
+ functionName: string,
+ object: T
+): T => {
+ if (Object.prototype.hasOwnProperty.call(object, SOURCE_URL)) {
+ return object;
+ }
+ const original = Error.prepareStackTrace;
+ Error.prepareStackTrace = (_, stack) => {
+ // First element is the function.
+ // Second element is the caller of this function.
+ // Third element is the caller of the caller of this function
+ // which is precisely what we want.
+ return stack[2];
+ };
+ const site = new Error().stack as unknown as NodeJS.CallSite;
+ Error.prepareStackTrace = original;
+ return Object.assign(object, {
+ [SOURCE_URL]: PuppeteerURL.fromCallSite(functionName, site),
+ });
+};
+
+/**
+ * @internal
+ */
+export const getSourcePuppeteerURLIfAvailable = <
+ T extends NonNullable<unknown>,
+>(
+ object: T
+): PuppeteerURL | undefined => {
+ if (Object.prototype.hasOwnProperty.call(object, SOURCE_URL)) {
+ return object[SOURCE_URL as keyof T] as PuppeteerURL;
+ }
+ return undefined;
+};
+
+/**
+ * @internal
+ */
+export const isString = (obj: unknown): obj is string => {
+ return typeof obj === 'string' || obj instanceof String;
+};
+
+/**
+ * @internal
+ */
+export const isNumber = (obj: unknown): obj is number => {
+ return typeof obj === 'number' || obj instanceof Number;
+};
+
+/**
+ * @internal
+ */
+export const isPlainObject = (obj: unknown): obj is Record<any, unknown> => {
+ return typeof obj === 'object' && obj?.constructor === Object;
+};
+
+/**
+ * @internal
+ */
+export const isRegExp = (obj: unknown): obj is RegExp => {
+ return typeof obj === 'object' && obj?.constructor === RegExp;
+};
+
+/**
+ * @internal
+ */
+export const isDate = (obj: unknown): obj is Date => {
+ return typeof obj === 'object' && obj?.constructor === Date;
+};
+
+/**
+ * @internal
+ */
+export function evaluationString(
+ fun: Function | string,
+ ...args: unknown[]
+): string {
+ if (isString(fun)) {
+ assert(args.length === 0, 'Cannot evaluate a string with arguments');
+ return fun;
+ }
+
+ function serializeArgument(arg: unknown): string {
+ if (Object.is(arg, undefined)) {
+ return 'undefined';
+ }
+ return JSON.stringify(arg);
+ }
+
+ return `(${fun})(${args.map(serializeArgument).join(',')})`;
+}
+
+/**
+ * @internal
+ */
+let fs: typeof FS | null = null;
+/**
+ * @internal
+ */
+export async function importFSPromises(): Promise<typeof FS> {
+ if (!fs) {
+ try {
+ fs = await import('fs/promises');
+ } catch (error) {
+ if (error instanceof TypeError) {
+ throw new Error(
+ 'Cannot write to a path outside of a Node-like environment.'
+ );
+ }
+ throw error;
+ }
+ }
+ return fs;
+}
+
+/**
+ * @internal
+ */
+export async function getReadableAsBuffer(
+ readable: Readable,
+ path?: string
+): Promise<Buffer | null> {
+ const buffers = [];
+ if (path) {
+ const fs = await importFSPromises();
+ const fileHandle = await fs.open(path, 'w+');
+ try {
+ for await (const chunk of readable) {
+ buffers.push(chunk);
+ await fileHandle.writeFile(chunk);
+ }
+ } finally {
+ await fileHandle.close();
+ }
+ } else {
+ for await (const chunk of readable) {
+ buffers.push(chunk);
+ }
+ }
+ try {
+ return Buffer.concat(buffers);
+ } catch (error) {
+ return null;
+ }
+}
+
+/**
+ * @internal
+ */
+export async function getReadableFromProtocolStream(
+ client: CDPSession,
+ handle: string
+): Promise<Readable> {
+ // TODO: Once Node 18 becomes the lowest supported version, we can migrate to
+ // ReadableStream.
+ if (!isNode) {
+ throw new Error('Cannot create a stream outside of Node.js environment.');
+ }
+
+ const {Readable} = await import('stream');
+
+ let eof = false;
+ return new Readable({
+ async read(size: number) {
+ if (eof) {
+ return;
+ }
+
+ try {
+ const response = await client.send('IO.read', {handle, size});
+ this.push(response.data, response.base64Encoded ? 'base64' : undefined);
+ if (response.eof) {
+ eof = true;
+ await client.send('IO.close', {handle});
+ this.push(null);
+ }
+ } catch (error) {
+ if (isErrorLike(error)) {
+ this.destroy(error);
+ return;
+ }
+ throw error;
+ }
+ },
+ });
+}
+
+/**
+ * @internal
+ */
+export function validateDialogType(
+ type: string
+): 'alert' | 'confirm' | 'prompt' | 'beforeunload' {
+ let dialogType = null;
+ const validDialogTypes = new Set([
+ 'alert',
+ 'confirm',
+ 'prompt',
+ 'beforeunload',
+ ]);
+
+ if (validDialogTypes.has(type)) {
+ dialogType = type;
+ }
+ assert(dialogType, `Unknown javascript dialog type: ${type}`);
+ return dialogType as 'alert' | 'confirm' | 'prompt' | 'beforeunload';
+}
+
+/**
+ * @internal
+ */
+export function timeout(ms: number): Observable<never> {
+ return ms === 0
+ ? NEVER
+ : timer(ms).pipe(
+ map(() => {
+ throw new TimeoutError(`Timed out after waiting ${ms}ms`);
+ })
+ );
+}
+
+/**
+ * @internal
+ */
+export const UTILITY_WORLD_NAME = '__puppeteer_utility_world__';
+
+/**
+ * @internal
+ */
+export const SOURCE_URL_REGEX = /^[\040\t]*\/\/[@#] sourceURL=\s*(\S*?)\s*$/m;
+/**
+ * @internal
+ */
+export function getSourceUrlComment(url: string): string {
+ return `//# sourceURL=${url}`;
+}
+
+/**
+ * @internal
+ */
+export const NETWORK_IDLE_TIME = 500;
+
+/**
+ * @internal
+ */
+export function parsePDFOptions(
+ options: PDFOptions = {},
+ lengthUnit: 'in' | 'cm' = 'in'
+): ParsedPDFOptions {
+ const defaults: Omit<ParsedPDFOptions, 'width' | 'height' | 'margin'> = {
+ scale: 1,
+ displayHeaderFooter: false,
+ headerTemplate: '',
+ footerTemplate: '',
+ printBackground: false,
+ landscape: false,
+ pageRanges: '',
+ preferCSSPageSize: false,
+ omitBackground: false,
+ tagged: false,
+ };
+
+ let width = 8.5;
+ let height = 11;
+ if (options.format) {
+ const format =
+ paperFormats[options.format.toLowerCase() as LowerCasePaperFormat];
+ assert(format, 'Unknown paper format: ' + options.format);
+ width = format.width;
+ height = format.height;
+ } else {
+ width = convertPrintParameterToInches(options.width, lengthUnit) ?? width;
+ height =
+ convertPrintParameterToInches(options.height, lengthUnit) ?? height;
+ }
+
+ const margin = {
+ top: convertPrintParameterToInches(options.margin?.top, lengthUnit) || 0,
+ left: convertPrintParameterToInches(options.margin?.left, lengthUnit) || 0,
+ bottom:
+ convertPrintParameterToInches(options.margin?.bottom, lengthUnit) || 0,
+ right:
+ convertPrintParameterToInches(options.margin?.right, lengthUnit) || 0,
+ };
+
+ return {
+ ...defaults,
+ ...options,
+ width,
+ height,
+ margin,
+ };
+}
+
+/**
+ * @internal
+ */
+export const unitToPixels = {
+ px: 1,
+ in: 96,
+ cm: 37.8,
+ mm: 3.78,
+};
+
+function convertPrintParameterToInches(
+ parameter?: string | number,
+ lengthUnit: 'in' | 'cm' = 'in'
+): number | undefined {
+ if (typeof parameter === 'undefined') {
+ return undefined;
+ }
+ let pixels;
+ if (isNumber(parameter)) {
+ // Treat numbers as pixel values to be aligned with phantom's paperSize.
+ pixels = parameter;
+ } else if (isString(parameter)) {
+ const text = parameter;
+ let unit = text.substring(text.length - 2).toLowerCase();
+ let valueText = '';
+ if (unit in unitToPixels) {
+ valueText = text.substring(0, text.length - 2);
+ } else {
+ // In case of unknown unit try to parse the whole parameter as number of pixels.
+ // This is consistent with phantom's paperSize behavior.
+ unit = 'px';
+ valueText = text;
+ }
+ const value = Number(valueText);
+ assert(!isNaN(value), 'Failed to parse parameter value: ' + text);
+ pixels = value * unitToPixels[unit as keyof typeof unitToPixels];
+ } else {
+ throw new Error(
+ 'page.pdf() Cannot handle parameter type: ' + typeof parameter
+ );
+ }
+ return pixels / unitToPixels[lengthUnit];
+}
+
+/**
+ * @internal
+ */
+export function fromEmitterEvent<
+ Events extends Record<EventType, unknown>,
+ Event extends keyof Events,
+>(emitter: EventEmitter<Events>, eventName: Event): Observable<Events[Event]> {
+ return new Observable(subscriber => {
+ const listener = (event: Events[Event]) => {
+ subscriber.next(event);
+ };
+ emitter.on(eventName, listener);
+ return () => {
+ emitter.off(eventName, listener);
+ };
+ });
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/environment.ts b/remote/test/puppeteer/packages/puppeteer-core/src/environment.ts
new file mode 100644
index 0000000000..bf7227243d
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/environment.ts
@@ -0,0 +1,10 @@
+/**
+ * @license
+ * Copyright 2020 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * @internal
+ */
+export const isNode = !!(typeof process !== 'undefined' && process.version);
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/injected/ARIAQuerySelector.ts b/remote/test/puppeteer/packages/puppeteer-core/src/injected/ARIAQuerySelector.ts
new file mode 100644
index 0000000000..972b6a6c64
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/injected/ARIAQuerySelector.ts
@@ -0,0 +1,31 @@
+/**
+ * @license
+ * Copyright 2022 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+declare global {
+ interface Window {
+ /**
+ * @internal
+ */
+ __ariaQuerySelector(root: Node, selector: string): Promise<Node | null>;
+ /**
+ * @internal
+ */
+ __ariaQuerySelectorAll(root: Node, selector: string): Promise<Node[]>;
+ }
+}
+
+export const ariaQuerySelector = (
+ root: Node,
+ selector: string
+): Promise<Node | null> => {
+ return window.__ariaQuerySelector(root, selector);
+};
+export const ariaQuerySelectorAll = async function* (
+ root: Node,
+ selector: string
+): AsyncIterable<Node> {
+ yield* await window.__ariaQuerySelectorAll(root, selector);
+};
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/injected/CustomQuerySelector.ts b/remote/test/puppeteer/packages/puppeteer-core/src/injected/CustomQuerySelector.ts
new file mode 100644
index 0000000000..ccd041deea
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/injected/CustomQuerySelector.ts
@@ -0,0 +1,59 @@
+/**
+ * @license
+ * Copyright 2023 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {CustomQueryHandler} from '../common/CustomQueryHandler.js';
+import type {Awaitable, AwaitableIterable} from '../common/types.js';
+
+export interface CustomQuerySelector {
+ querySelector(root: Node, selector: string): Awaitable<Node | null>;
+ querySelectorAll(root: Node, selector: string): AwaitableIterable<Node>;
+}
+
+/**
+ * This class mimics the injected {@link CustomQuerySelectorRegistry}.
+ */
+class CustomQuerySelectorRegistry {
+ #selectors = new Map<string, CustomQuerySelector>();
+
+ register(name: string, handler: CustomQueryHandler): void {
+ if (!handler.queryOne && handler.queryAll) {
+ const querySelectorAll = handler.queryAll;
+ handler.queryOne = (node, selector) => {
+ for (const result of querySelectorAll(node, selector)) {
+ return result;
+ }
+ return null;
+ };
+ } else if (handler.queryOne && !handler.queryAll) {
+ const querySelector = handler.queryOne;
+ handler.queryAll = (node, selector) => {
+ const result = querySelector(node, selector);
+ return result ? [result] : [];
+ };
+ } else if (!handler.queryOne || !handler.queryAll) {
+ throw new Error('At least one query method must be defined.');
+ }
+
+ this.#selectors.set(name, {
+ querySelector: handler.queryOne,
+ querySelectorAll: handler.queryAll!,
+ });
+ }
+
+ unregister(name: string): void {
+ this.#selectors.delete(name);
+ }
+
+ get(name: string): CustomQuerySelector | undefined {
+ return this.#selectors.get(name);
+ }
+
+ clear() {
+ this.#selectors.clear();
+ }
+}
+
+export const customQuerySelectors = new CustomQuerySelectorRegistry();
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/injected/PQuerySelector.ts b/remote/test/puppeteer/packages/puppeteer-core/src/injected/PQuerySelector.ts
new file mode 100644
index 0000000000..11499c072f
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/injected/PQuerySelector.ts
@@ -0,0 +1,298 @@
+/**
+ * @license
+ * Copyright 2023 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {AwaitableIterable} from '../common/types.js';
+import {AsyncIterableUtil} from '../util/AsyncIterableUtil.js';
+
+import {ariaQuerySelectorAll} from './ARIAQuerySelector.js';
+import {customQuerySelectors} from './CustomQuerySelector.js';
+import {
+ type ComplexPSelector,
+ type ComplexPSelectorList,
+ type CompoundPSelector,
+ type CSSSelector,
+ parsePSelectors,
+ PCombinator,
+ type PPseudoSelector,
+} from './PSelectorParser.js';
+import {textQuerySelectorAll} from './TextQuerySelector.js';
+import {pierce, pierceAll} from './util.js';
+import {xpathQuerySelectorAll} from './XPathQuerySelector.js';
+
+const IDENT_TOKEN_START = /[-\w\P{ASCII}*]/;
+
+interface QueryableNode extends Node {
+ querySelectorAll: typeof Document.prototype.querySelectorAll;
+}
+
+const isQueryableNode = (node: Node): node is QueryableNode => {
+ return 'querySelectorAll' in node;
+};
+
+class SelectorError extends Error {
+ constructor(selector: string, message: string) {
+ super(`${selector} is not a valid selector: ${message}`);
+ }
+}
+
+class PQueryEngine {
+ #input: string;
+
+ #complexSelector: ComplexPSelector;
+ #compoundSelector: CompoundPSelector = [];
+ #selector: CSSSelector | PPseudoSelector | undefined = undefined;
+
+ elements: AwaitableIterable<Node>;
+
+ constructor(element: Node, input: string, complexSelector: ComplexPSelector) {
+ this.elements = [element];
+ this.#input = input;
+ this.#complexSelector = complexSelector;
+ this.#next();
+ }
+
+ async run(): Promise<void> {
+ if (typeof this.#selector === 'string') {
+ switch (this.#selector.trimStart()) {
+ case ':scope':
+ // `:scope` has some special behavior depending on the node. It always
+ // represents the current node within a compound selector, but by
+ // itself, it depends on the node. For example, Document is
+ // represented by `<html>`, but any HTMLElement is not represented by
+ // itself (i.e. `null`). This can be troublesome if our combinators
+ // are used right after so we treat this selector specially.
+ this.#next();
+ break;
+ }
+ }
+
+ for (; this.#selector !== undefined; this.#next()) {
+ const selector = this.#selector;
+ const input = this.#input;
+ if (typeof selector === 'string') {
+ // The regular expression tests if the selector is a type/universal
+ // selector. Any other case means we want to apply the selector onto
+ // the element itself (e.g. `element.class`, `element>div`,
+ // `element:hover`, etc.).
+ if (selector[0] && IDENT_TOKEN_START.test(selector[0])) {
+ this.elements = AsyncIterableUtil.flatMap(
+ this.elements,
+ async function* (element) {
+ if (isQueryableNode(element)) {
+ yield* element.querySelectorAll(selector);
+ }
+ }
+ );
+ } else {
+ this.elements = AsyncIterableUtil.flatMap(
+ this.elements,
+ async function* (element) {
+ if (!element.parentElement) {
+ if (!isQueryableNode(element)) {
+ return;
+ }
+ yield* element.querySelectorAll(selector);
+ return;
+ }
+
+ let index = 0;
+ for (const child of element.parentElement.children) {
+ ++index;
+ if (child === element) {
+ break;
+ }
+ }
+ yield* element.parentElement.querySelectorAll(
+ `:scope>:nth-child(${index})${selector}`
+ );
+ }
+ );
+ }
+ } else {
+ this.elements = AsyncIterableUtil.flatMap(
+ this.elements,
+ async function* (element) {
+ switch (selector.name) {
+ case 'text':
+ yield* textQuerySelectorAll(element, selector.value);
+ break;
+ case 'xpath':
+ yield* xpathQuerySelectorAll(element, selector.value);
+ break;
+ case 'aria':
+ yield* ariaQuerySelectorAll(element, selector.value);
+ break;
+ default:
+ const querySelector = customQuerySelectors.get(selector.name);
+ if (!querySelector) {
+ throw new SelectorError(
+ input,
+ `Unknown selector type: ${selector.name}`
+ );
+ }
+ yield* querySelector.querySelectorAll(element, selector.value);
+ }
+ }
+ );
+ }
+ }
+ }
+
+ #next() {
+ if (this.#compoundSelector.length !== 0) {
+ this.#selector = this.#compoundSelector.shift();
+ return;
+ }
+ if (this.#complexSelector.length === 0) {
+ this.#selector = undefined;
+ return;
+ }
+ const selector = this.#complexSelector.shift();
+ switch (selector) {
+ case PCombinator.Child: {
+ this.elements = AsyncIterableUtil.flatMap(this.elements, pierce);
+ this.#next();
+ break;
+ }
+ case PCombinator.Descendent: {
+ this.elements = AsyncIterableUtil.flatMap(this.elements, pierceAll);
+ this.#next();
+ break;
+ }
+ default:
+ this.#compoundSelector = selector as CompoundPSelector;
+ this.#next();
+ break;
+ }
+ }
+}
+
+class DepthCalculator {
+ #cache = new WeakMap<Node, number[]>();
+
+ calculate(node: Node | null, depth: number[] = []): number[] {
+ if (node === null) {
+ return depth;
+ }
+ if (node instanceof ShadowRoot) {
+ node = node.host;
+ }
+
+ const cachedDepth = this.#cache.get(node);
+ if (cachedDepth) {
+ return [...cachedDepth, ...depth];
+ }
+
+ let index = 0;
+ for (
+ let prevSibling = node.previousSibling;
+ prevSibling;
+ prevSibling = prevSibling.previousSibling
+ ) {
+ ++index;
+ }
+
+ const value = this.calculate(node.parentNode, [index]);
+ this.#cache.set(node, value);
+ return [...value, ...depth];
+ }
+}
+
+const compareDepths = (a: number[], b: number[]): -1 | 0 | 1 => {
+ if (a.length + b.length === 0) {
+ return 0;
+ }
+ const [i = -1, ...otherA] = a;
+ const [j = -1, ...otherB] = b;
+ if (i === j) {
+ return compareDepths(otherA, otherB);
+ }
+ return i < j ? -1 : 1;
+};
+
+const domSort = async function* (elements: AwaitableIterable<Node>) {
+ const results = new Set<Node>();
+ for await (const element of elements) {
+ results.add(element);
+ }
+ const calculator = new DepthCalculator();
+ yield* [...results.values()]
+ .map(result => {
+ return [result, calculator.calculate(result)] as const;
+ })
+ .sort(([, a], [, b]) => {
+ return compareDepths(a, b);
+ })
+ .map(([result]) => {
+ return result;
+ });
+};
+
+/**
+ * Queries the given node for all nodes matching the given text selector.
+ *
+ * @internal
+ */
+export const pQuerySelectorAll = function (
+ root: Node,
+ selector: string
+): AwaitableIterable<Node> {
+ let selectors: ComplexPSelectorList;
+ let isPureCSS: boolean;
+ try {
+ [selectors, isPureCSS] = parsePSelectors(selector);
+ } catch (error) {
+ return (root as unknown as QueryableNode).querySelectorAll(selector);
+ }
+
+ if (isPureCSS) {
+ return (root as unknown as QueryableNode).querySelectorAll(selector);
+ }
+ // If there are any empty elements, then this implies the selector has
+ // contiguous combinators (e.g. `>>> >>>>`) or starts/ends with one which we
+ // treat as illegal, similar to existing behavior.
+ if (
+ selectors.some(parts => {
+ let i = 0;
+ return parts.some(parts => {
+ if (typeof parts === 'string') {
+ ++i;
+ } else {
+ i = 0;
+ }
+ return i > 1;
+ });
+ })
+ ) {
+ throw new SelectorError(
+ selector,
+ 'Multiple deep combinators found in sequence.'
+ );
+ }
+
+ return domSort(
+ AsyncIterableUtil.flatMap(selectors, selectorParts => {
+ const query = new PQueryEngine(root, selector, selectorParts);
+ void query.run();
+ return query.elements;
+ })
+ );
+};
+
+/**
+ * Queries the given node for all nodes matching the given text selector.
+ *
+ * @internal
+ */
+export const pQuerySelector = async function (
+ root: Node,
+ selector: string
+): Promise<Node | null> {
+ for await (const element of pQuerySelectorAll(root, selector)) {
+ return element;
+ }
+ return null;
+};
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/injected/PSelectorParser.ts b/remote/test/puppeteer/packages/puppeteer-core/src/injected/PSelectorParser.ts
new file mode 100644
index 0000000000..8044562348
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/injected/PSelectorParser.ts
@@ -0,0 +1,105 @@
+/**
+ * @license
+ * Copyright 2023 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {type Token, tokenize, TOKENS, stringify} from 'parsel-js';
+
+export type CSSSelector = string;
+export interface PPseudoSelector {
+ name: string;
+ value: string;
+}
+export const enum PCombinator {
+ Descendent = '>>>',
+ Child = '>>>>',
+}
+export type CompoundPSelector = Array<CSSSelector | PPseudoSelector>;
+export type ComplexPSelector = Array<CompoundPSelector | PCombinator>;
+export type ComplexPSelectorList = ComplexPSelector[];
+
+TOKENS['combinator'] = /\s*(>>>>?|[\s>+~])\s*/g;
+
+const ESCAPE_REGEXP = /\\[\s\S]/g;
+const unquote = (text: string): string => {
+ if (text.length <= 1) {
+ return text;
+ }
+ if ((text[0] === '"' || text[0] === "'") && text.endsWith(text[0])) {
+ text = text.slice(1, -1);
+ }
+ return text.replace(ESCAPE_REGEXP, match => {
+ return match[1] as string;
+ });
+};
+
+export function parsePSelectors(
+ selector: string
+): [selector: ComplexPSelectorList, isPureCSS: boolean] {
+ let isPureCSS = true;
+ const tokens = tokenize(selector);
+ if (tokens.length === 0) {
+ return [[], isPureCSS];
+ }
+ let compoundSelector: CompoundPSelector = [];
+ let complexSelector: ComplexPSelector = [compoundSelector];
+ const selectors: ComplexPSelectorList = [complexSelector];
+ const storage: Token[] = [];
+ for (const token of tokens) {
+ switch (token.type) {
+ case 'combinator':
+ switch (token.content) {
+ case PCombinator.Descendent:
+ isPureCSS = false;
+ if (storage.length) {
+ compoundSelector.push(stringify(storage));
+ storage.splice(0);
+ }
+ compoundSelector = [];
+ complexSelector.push(PCombinator.Descendent);
+ complexSelector.push(compoundSelector);
+ continue;
+ case PCombinator.Child:
+ isPureCSS = false;
+ if (storage.length) {
+ compoundSelector.push(stringify(storage));
+ storage.splice(0);
+ }
+ compoundSelector = [];
+ complexSelector.push(PCombinator.Child);
+ complexSelector.push(compoundSelector);
+ continue;
+ }
+ break;
+ case 'pseudo-element':
+ if (!token.name.startsWith('-p-')) {
+ break;
+ }
+ isPureCSS = false;
+ if (storage.length) {
+ compoundSelector.push(stringify(storage));
+ storage.splice(0);
+ }
+ compoundSelector.push({
+ name: token.name.slice(3),
+ value: unquote(token.argument ?? ''),
+ });
+ continue;
+ case 'comma':
+ if (storage.length) {
+ compoundSelector.push(stringify(storage));
+ storage.splice(0);
+ }
+ compoundSelector = [];
+ complexSelector = [compoundSelector];
+ selectors.push(complexSelector);
+ continue;
+ }
+ storage.push(token);
+ }
+ if (storage.length) {
+ compoundSelector.push(stringify(storage));
+ }
+ return [selectors, isPureCSS];
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/injected/PierceQuerySelector.ts b/remote/test/puppeteer/packages/puppeteer-core/src/injected/PierceQuerySelector.ts
new file mode 100644
index 0000000000..c224ee8324
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/injected/PierceQuerySelector.ts
@@ -0,0 +1,65 @@
+/**
+ * @license
+ * Copyright 2022 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * @internal
+ */
+export const pierceQuerySelector = (
+ root: Node,
+ selector: string
+): Element | null => {
+ let found: Node | null = null;
+ const search = (root: Node) => {
+ const iter = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
+ do {
+ const currentNode = iter.currentNode as Element;
+ if (currentNode.shadowRoot) {
+ search(currentNode.shadowRoot);
+ }
+ if (currentNode instanceof ShadowRoot) {
+ continue;
+ }
+ if (currentNode !== root && !found && currentNode.matches(selector)) {
+ found = currentNode;
+ }
+ } while (!found && iter.nextNode());
+ };
+ if (root instanceof Document) {
+ root = root.documentElement;
+ }
+ search(root);
+ return found;
+};
+
+/**
+ * @internal
+ */
+export const pierceQuerySelectorAll = (
+ element: Node,
+ selector: string
+): Element[] => {
+ const result: Element[] = [];
+ const collect = (root: Node) => {
+ const iter = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
+ do {
+ const currentNode = iter.currentNode as Element;
+ if (currentNode.shadowRoot) {
+ collect(currentNode.shadowRoot);
+ }
+ if (currentNode instanceof ShadowRoot) {
+ continue;
+ }
+ if (currentNode !== root && currentNode.matches(selector)) {
+ result.push(currentNode);
+ }
+ } while (iter.nextNode());
+ };
+ if (element instanceof Document) {
+ element = element.documentElement;
+ }
+ collect(element);
+ return result;
+};
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/injected/Poller.ts b/remote/test/puppeteer/packages/puppeteer-core/src/injected/Poller.ts
new file mode 100644
index 0000000000..68b9f1812b
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/injected/Poller.ts
@@ -0,0 +1,168 @@
+/**
+ * @license
+ * Copyright 2022 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {assert} from '../util/assert.js';
+import {Deferred} from '../util/Deferred.js';
+
+/**
+ * @internal
+ */
+export interface Poller<T> {
+ start(): Promise<void>;
+ stop(): Promise<void>;
+ result(): Promise<T>;
+}
+
+/**
+ * @internal
+ */
+export class MutationPoller<T> implements Poller<T> {
+ #fn: () => Promise<T>;
+
+ #root: Node;
+
+ #observer?: MutationObserver;
+ #deferred?: Deferred<T>;
+ constructor(fn: () => Promise<T>, root: Node) {
+ this.#fn = fn;
+ this.#root = root;
+ }
+
+ async start(): Promise<void> {
+ const deferred = (this.#deferred = Deferred.create<T>());
+ const result = await this.#fn();
+ if (result) {
+ deferred.resolve(result);
+ return;
+ }
+
+ this.#observer = new MutationObserver(async () => {
+ const result = await this.#fn();
+ if (!result) {
+ return;
+ }
+ deferred.resolve(result);
+ await this.stop();
+ });
+ this.#observer.observe(this.#root, {
+ childList: true,
+ subtree: true,
+ attributes: true,
+ });
+ }
+
+ async stop(): Promise<void> {
+ assert(this.#deferred, 'Polling never started.');
+ if (!this.#deferred.finished()) {
+ this.#deferred.reject(new Error('Polling stopped'));
+ }
+ if (this.#observer) {
+ this.#observer.disconnect();
+ this.#observer = undefined;
+ }
+ }
+
+ result(): Promise<T> {
+ assert(this.#deferred, 'Polling never started.');
+ return this.#deferred.valueOrThrow();
+ }
+}
+
+/**
+ * @internal
+ */
+export class RAFPoller<T> implements Poller<T> {
+ #fn: () => Promise<T>;
+ #deferred?: Deferred<T>;
+ constructor(fn: () => Promise<T>) {
+ this.#fn = fn;
+ }
+
+ async start(): Promise<void> {
+ const deferred = (this.#deferred = Deferred.create<T>());
+ const result = await this.#fn();
+ if (result) {
+ deferred.resolve(result);
+ return;
+ }
+
+ const poll = async () => {
+ if (deferred.finished()) {
+ return;
+ }
+ const result = await this.#fn();
+ if (!result) {
+ window.requestAnimationFrame(poll);
+ return;
+ }
+ deferred.resolve(result);
+ await this.stop();
+ };
+ window.requestAnimationFrame(poll);
+ }
+
+ async stop(): Promise<void> {
+ assert(this.#deferred, 'Polling never started.');
+ if (!this.#deferred.finished()) {
+ this.#deferred.reject(new Error('Polling stopped'));
+ }
+ }
+
+ result(): Promise<T> {
+ assert(this.#deferred, 'Polling never started.');
+ return this.#deferred.valueOrThrow();
+ }
+}
+
+/**
+ * @internal
+ */
+
+export class IntervalPoller<T> implements Poller<T> {
+ #fn: () => Promise<T>;
+ #ms: number;
+
+ #interval?: NodeJS.Timeout;
+ #deferred?: Deferred<T>;
+ constructor(fn: () => Promise<T>, ms: number) {
+ this.#fn = fn;
+ this.#ms = ms;
+ }
+
+ async start(): Promise<void> {
+ const deferred = (this.#deferred = Deferred.create<T>());
+ const result = await this.#fn();
+ if (result) {
+ deferred.resolve(result);
+ return;
+ }
+
+ this.#interval = setInterval(async () => {
+ const result = await this.#fn();
+ if (!result) {
+ return;
+ }
+ deferred.resolve(result);
+ await this.stop();
+ }, this.#ms);
+ }
+
+ async stop(): Promise<void> {
+ assert(this.#deferred, 'Polling never started.');
+ if (!this.#deferred.finished()) {
+ this.#deferred.reject(new Error('Polling stopped'));
+ }
+ if (this.#interval) {
+ clearInterval(this.#interval);
+ this.#interval = undefined;
+ }
+ }
+
+ result(): Promise<T> {
+ assert(this.#deferred, 'Polling never started.');
+ return this.#deferred.valueOrThrow();
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/injected/TextContent.ts b/remote/test/puppeteer/packages/puppeteer-core/src/injected/TextContent.ts
new file mode 100644
index 0000000000..ffe8980d5e
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/injected/TextContent.ts
@@ -0,0 +1,146 @@
+/**
+ * @license
+ * Copyright 2022 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+interface NonTrivialValueNode extends Node {
+ value: string;
+}
+
+const TRIVIAL_VALUE_INPUT_TYPES = new Set(['checkbox', 'image', 'radio']);
+
+/**
+ * Determines if the node has a non-trivial value property.
+ *
+ * @internal
+ */
+const isNonTrivialValueNode = (node: Node): node is NonTrivialValueNode => {
+ if (node instanceof HTMLSelectElement) {
+ return true;
+ }
+ if (node instanceof HTMLTextAreaElement) {
+ return true;
+ }
+ if (
+ node instanceof HTMLInputElement &&
+ !TRIVIAL_VALUE_INPUT_TYPES.has(node.type)
+ ) {
+ return true;
+ }
+ return false;
+};
+
+const UNSUITABLE_NODE_NAMES = new Set(['SCRIPT', 'STYLE']);
+
+/**
+ * Determines whether a given node is suitable for text matching.
+ *
+ * @internal
+ */
+export const isSuitableNodeForTextMatching = (node: Node): boolean => {
+ return (
+ !UNSUITABLE_NODE_NAMES.has(node.nodeName) && !document.head?.contains(node)
+ );
+};
+
+/**
+ * @internal
+ */
+export interface TextContent {
+ // Contains the full text of the node.
+ full: string;
+ // Contains the text immediately beneath the node.
+ immediate: string[];
+}
+
+/**
+ * Maps {@link Node}s to their computed {@link TextContent}.
+ */
+const textContentCache = new WeakMap<Node, TextContent>();
+const eraseFromCache = (node: Node | null) => {
+ while (node) {
+ textContentCache.delete(node);
+ if (node instanceof ShadowRoot) {
+ node = node.host;
+ } else {
+ node = node.parentNode;
+ }
+ }
+};
+
+/**
+ * Erases the cache when the tree has mutated text.
+ */
+const observedNodes = new WeakSet<Node>();
+const textChangeObserver = new MutationObserver(mutations => {
+ for (const mutation of mutations) {
+ eraseFromCache(mutation.target);
+ }
+});
+
+/**
+ * Builds the text content of a node using some custom logic.
+ *
+ * @remarks
+ * The primary reason this function exists is due to {@link ShadowRoot}s not having
+ * text content.
+ *
+ * @internal
+ */
+export const createTextContent = (root: Node): TextContent => {
+ let value = textContentCache.get(root);
+ if (value) {
+ return value;
+ }
+ value = {full: '', immediate: []};
+ if (!isSuitableNodeForTextMatching(root)) {
+ return value;
+ }
+
+ let currentImmediate = '';
+ if (isNonTrivialValueNode(root)) {
+ value.full = root.value;
+ value.immediate.push(root.value);
+
+ root.addEventListener(
+ 'input',
+ event => {
+ eraseFromCache(event.target as HTMLInputElement);
+ },
+ {once: true, capture: true}
+ );
+ } else {
+ for (let child = root.firstChild; child; child = child.nextSibling) {
+ if (child.nodeType === Node.TEXT_NODE) {
+ value.full += child.nodeValue ?? '';
+ currentImmediate += child.nodeValue ?? '';
+ continue;
+ }
+ if (currentImmediate) {
+ value.immediate.push(currentImmediate);
+ }
+ currentImmediate = '';
+ if (child.nodeType === Node.ELEMENT_NODE) {
+ value.full += createTextContent(child).full;
+ }
+ }
+ if (currentImmediate) {
+ value.immediate.push(currentImmediate);
+ }
+ if (root instanceof Element && root.shadowRoot) {
+ value.full += createTextContent(root.shadowRoot).full;
+ }
+
+ if (!observedNodes.has(root)) {
+ textChangeObserver.observe(root, {
+ childList: true,
+ characterData: true,
+ subtree: true,
+ });
+ observedNodes.add(root);
+ }
+ }
+ textContentCache.set(root, value);
+ return value;
+};
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/injected/TextQuerySelector.ts b/remote/test/puppeteer/packages/puppeteer-core/src/injected/TextQuerySelector.ts
new file mode 100644
index 0000000000..debc423ccf
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/injected/TextQuerySelector.ts
@@ -0,0 +1,46 @@
+/**
+ * @license
+ * Copyright 2022 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {
+ createTextContent,
+ isSuitableNodeForTextMatching,
+} from './TextContent.js';
+
+/**
+ * Queries the given node for all nodes matching the given text selector.
+ *
+ * @internal
+ */
+export const textQuerySelectorAll = function* (
+ root: Node,
+ selector: string
+): Generator<Element> {
+ let yielded = false;
+ for (const node of root.childNodes) {
+ if (node instanceof Element && isSuitableNodeForTextMatching(node)) {
+ let matches: Generator<Element, boolean>;
+ if (!node.shadowRoot) {
+ matches = textQuerySelectorAll(node, selector);
+ } else {
+ matches = textQuerySelectorAll(node.shadowRoot, selector);
+ }
+ for (const match of matches) {
+ yield match;
+ yielded = true;
+ }
+ }
+ }
+ if (yielded) {
+ return;
+ }
+
+ if (root instanceof Element && isSuitableNodeForTextMatching(root)) {
+ const textContent = createTextContent(root);
+ if (textContent.full.includes(selector)) {
+ yield root;
+ }
+ }
+};
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/injected/XPathQuerySelector.ts b/remote/test/puppeteer/packages/puppeteer-core/src/injected/XPathQuerySelector.ts
new file mode 100644
index 0000000000..039bfa5e54
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/injected/XPathQuerySelector.ts
@@ -0,0 +1,39 @@
+/**
+ * @license
+ * Copyright 2022 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * @internal
+ */
+export const xpathQuerySelectorAll = function* (
+ root: Node,
+ selector: string,
+ maxResults = -1
+): Iterable<Node> {
+ const doc = root.ownerDocument || document;
+ const iterator = doc.evaluate(
+ selector,
+ root,
+ null,
+ XPathResult.ORDERED_NODE_ITERATOR_TYPE
+ );
+ const items = [];
+ let item;
+
+ // Read all results upfront to avoid
+ // https://stackoverflow.com/questions/48235278/xpath-error-the-document-has-mutated-since-the-result-was-returned.
+ while ((item = iterator.iterateNext())) {
+ items.push(item);
+ if (maxResults && items.length === maxResults) {
+ break;
+ }
+ }
+
+ for (let i = 0; i < items.length; i++) {
+ item = items[i];
+ yield item as Node;
+ delete items[i];
+ }
+};
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/injected/injected.ts b/remote/test/puppeteer/packages/puppeteer-core/src/injected/injected.ts
new file mode 100644
index 0000000000..e81d274290
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/injected/injected.ts
@@ -0,0 +1,51 @@
+/**
+ * @license
+ * Copyright 2022 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {Deferred} from '../util/Deferred.js';
+import {createFunction} from '../util/Function.js';
+
+import * as ARIAQuerySelector from './ARIAQuerySelector.js';
+import * as CustomQuerySelectors from './CustomQuerySelector.js';
+import * as PierceQuerySelector from './PierceQuerySelector.js';
+import {IntervalPoller, MutationPoller, RAFPoller} from './Poller.js';
+import * as PQuerySelector from './PQuerySelector.js';
+import {
+ createTextContent,
+ isSuitableNodeForTextMatching,
+} from './TextContent.js';
+import * as TextQuerySelector from './TextQuerySelector.js';
+import * as util from './util.js';
+import * as XPathQuerySelector from './XPathQuerySelector.js';
+
+/**
+ * @internal
+ */
+const PuppeteerUtil = Object.freeze({
+ ...ARIAQuerySelector,
+ ...CustomQuerySelectors,
+ ...PierceQuerySelector,
+ ...PQuerySelector,
+ ...TextQuerySelector,
+ ...util,
+ ...XPathQuerySelector,
+ Deferred,
+ createFunction,
+ createTextContent,
+ IntervalPoller,
+ isSuitableNodeForTextMatching,
+ MutationPoller,
+ RAFPoller,
+});
+
+/**
+ * @internal
+ */
+type PuppeteerUtil = typeof PuppeteerUtil;
+
+/**
+ * @internal
+ */
+export default PuppeteerUtil;
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/injected/util.ts b/remote/test/puppeteer/packages/puppeteer-core/src/injected/util.ts
new file mode 100644
index 0000000000..34fe8f7748
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/injected/util.ts
@@ -0,0 +1,67 @@
+const HIDDEN_VISIBILITY_VALUES = ['hidden', 'collapse'];
+
+/**
+ * @internal
+ */
+export const checkVisibility = (
+ node: Node | null,
+ visible?: boolean
+): Node | boolean => {
+ if (!node) {
+ return visible === false;
+ }
+ if (visible === undefined) {
+ return node;
+ }
+ const element = (
+ node.nodeType === Node.TEXT_NODE ? node.parentElement : node
+ ) as Element;
+
+ const style = window.getComputedStyle(element);
+ const isVisible =
+ style &&
+ !HIDDEN_VISIBILITY_VALUES.includes(style.visibility) &&
+ !isBoundingBoxEmpty(element);
+ return visible === isVisible ? node : false;
+};
+
+function isBoundingBoxEmpty(element: Element): boolean {
+ const rect = element.getBoundingClientRect();
+ return rect.width === 0 || rect.height === 0;
+}
+
+const hasShadowRoot = (node: Node): node is Node & {shadowRoot: ShadowRoot} => {
+ return 'shadowRoot' in node && node.shadowRoot instanceof ShadowRoot;
+};
+
+/**
+ * @internal
+ */
+export function* pierce(root: Node): IterableIterator<Node | ShadowRoot> {
+ if (hasShadowRoot(root)) {
+ yield root.shadowRoot;
+ } else {
+ yield root;
+ }
+}
+
+/**
+ * @internal
+ */
+export function* pierceAll(root: Node): IterableIterator<Node | ShadowRoot> {
+ root = pierce(root).next().value;
+ yield root;
+ const walkers = [document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT)];
+ for (const walker of walkers) {
+ let node: Element | null;
+ while ((node = walker.nextNode() as Element | null)) {
+ if (!node.shadowRoot) {
+ continue;
+ }
+ yield node.shadowRoot;
+ walkers.push(
+ document.createTreeWalker(node.shadowRoot, NodeFilter.SHOW_ELEMENT)
+ );
+ }
+ }
+}
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);
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/puppeteer-core.ts b/remote/test/puppeteer/packages/puppeteer-core/src/puppeteer-core.ts
new file mode 100644
index 0000000000..d19162b4a3
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/puppeteer-core.ts
@@ -0,0 +1,49 @@
+/**
+ * @license
+ * Copyright 2017 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+export type {Protocol} from 'devtools-protocol';
+
+export * from './api/api.js';
+export * from './cdp/cdp.js';
+export * from './common/common.js';
+export * from './node/node.js';
+export * from './revisions.js';
+export * from './util/util.js';
+
+/**
+ * @deprecated Use the query handler API defined on {@link Puppeteer}
+ */
+export * from './common/CustomQueryHandler.js';
+
+import {PuppeteerNode} from './node/PuppeteerNode.js';
+
+/**
+ * @public
+ */
+const puppeteer = new PuppeteerNode({
+ isPuppeteerCore: true,
+});
+
+export const {
+ /**
+ * @public
+ */
+ connect,
+ /**
+ * @public
+ */
+ defaultArgs,
+ /**
+ * @public
+ */
+ executablePath,
+ /**
+ * @public
+ */
+ launch,
+} = puppeteer;
+
+export default puppeteer;
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/revisions.ts b/remote/test/puppeteer/packages/puppeteer-core/src/revisions.ts
new file mode 100644
index 0000000000..37360204d8
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/revisions.ts
@@ -0,0 +1,14 @@
+/**
+ * @license
+ * Copyright 2020 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * @internal
+ */
+export const PUPPETEER_REVISIONS = Object.freeze({
+ chrome: '121.0.6167.85',
+ 'chrome-headless-shell': '121.0.6167.85',
+ firefox: 'latest',
+});
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/templates/injected.ts.tmpl b/remote/test/puppeteer/packages/puppeteer-core/src/templates/injected.ts.tmpl
new file mode 100644
index 0000000000..aa799e9fdb
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/templates/injected.ts.tmpl
@@ -0,0 +1,8 @@
+/**
+ * JavaScript code that provides the puppeteer utilities. See the
+ * [README](https://github.com/puppeteer/puppeteer/blob/main/src/injected/README.md)
+ * for injection for more information.
+ *
+ * @internal
+ */
+export const source = SOURCE_CODE;
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/templates/version.ts.tmpl b/remote/test/puppeteer/packages/puppeteer-core/src/templates/version.ts.tmpl
new file mode 100644
index 0000000000..73b984d2ff
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/templates/version.ts.tmpl
@@ -0,0 +1,4 @@
+/**
+ * @internal
+ */
+export const packageVersion = 'PACKAGE_VERSION';
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/tsconfig.cjs.json b/remote/test/puppeteer/packages/puppeteer-core/src/tsconfig.cjs.json
new file mode 100644
index 0000000000..897b1a03df
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/tsconfig.cjs.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {
+ "module": "CommonJS",
+ "moduleResolution": "Node",
+ "outDir": "../lib/cjs/puppeteer"
+ },
+ "references": [{"path": "../third_party/tsconfig.cjs.json"}]
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/tsconfig.esm.json b/remote/test/puppeteer/packages/puppeteer-core/src/tsconfig.esm.json
new file mode 100644
index 0000000000..2cd2ab579f
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/tsconfig.esm.json
@@ -0,0 +1,7 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {
+ "outDir": "../lib/esm/puppeteer"
+ },
+ "references": [{"path": "../third_party/tsconfig.json"}]
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/util/AsyncIterableUtil.ts b/remote/test/puppeteer/packages/puppeteer-core/src/util/AsyncIterableUtil.ts
new file mode 100644
index 0000000000..4d96d0cdf4
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/util/AsyncIterableUtil.ts
@@ -0,0 +1,46 @@
+/**
+ * @license
+ * Copyright 2023 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import type {AwaitableIterable} from '../common/types.js';
+
+/**
+ * @internal
+ */
+export class AsyncIterableUtil {
+ static async *map<T, U>(
+ iterable: AwaitableIterable<T>,
+ map: (item: T) => Promise<U>
+ ): AsyncIterable<U> {
+ for await (const value of iterable) {
+ yield await map(value);
+ }
+ }
+
+ static async *flatMap<T, U>(
+ iterable: AwaitableIterable<T>,
+ map: (item: T) => AwaitableIterable<U>
+ ): AsyncIterable<U> {
+ for await (const value of iterable) {
+ yield* map(value);
+ }
+ }
+
+ static async collect<T>(iterable: AwaitableIterable<T>): Promise<T[]> {
+ const result = [];
+ for await (const value of iterable) {
+ result.push(value);
+ }
+ return result;
+ }
+
+ static async first<T>(
+ iterable: AwaitableIterable<T>
+ ): Promise<T | undefined> {
+ for await (const value of iterable) {
+ return value;
+ }
+ return;
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/util/Deferred.test.ts b/remote/test/puppeteer/packages/puppeteer-core/src/util/Deferred.test.ts
new file mode 100644
index 0000000000..b989e3a888
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/util/Deferred.test.ts
@@ -0,0 +1,68 @@
+/**
+ * @license
+ * Copyright 2023 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {describe, it} from 'node:test';
+
+import expect from 'expect';
+import sinon from 'sinon';
+
+import {Deferred} from './Deferred.js';
+
+describe('DeferredPromise', function () {
+ it('should catch errors', async () => {
+ // Async function before try/catch.
+ async function task() {
+ await new Promise(resolve => {
+ return setTimeout(resolve, 50);
+ });
+ }
+ // Async function that fails.
+ function fails(): Deferred<void> {
+ const deferred = Deferred.create<void>();
+ setTimeout(() => {
+ deferred.reject(new Error('test'));
+ }, 25);
+ return deferred;
+ }
+
+ const expectedToFail = fails();
+ await task();
+ let caught = false;
+ try {
+ await expectedToFail.valueOrThrow();
+ } catch (err) {
+ expect((err as Error).message).toEqual('test');
+ caught = true;
+ }
+ expect(caught).toBeTruthy();
+ });
+
+ it('Deferred.race should cancel timeout', async function () {
+ const clock = sinon.useFakeTimers();
+
+ try {
+ const deferred = Deferred.create<void>();
+ const deferredTimeout = Deferred.create<void>({
+ message: 'Race did not stop timer',
+ timeout: 100,
+ });
+
+ clock.tick(50);
+
+ await Promise.all([
+ Deferred.race([deferred, deferredTimeout]),
+ deferred.resolve(),
+ ]);
+
+ clock.tick(150);
+
+ expect(deferredTimeout.value()).toBeInstanceOf(Error);
+ expect(deferredTimeout.value()?.message).toContain('Timeout cleared');
+ } finally {
+ clock.restore();
+ }
+ });
+});
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/util/Deferred.ts b/remote/test/puppeteer/packages/puppeteer-core/src/util/Deferred.ts
new file mode 100644
index 0000000000..0dfb013bb3
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/util/Deferred.ts
@@ -0,0 +1,122 @@
+import {TimeoutError} from '../common/Errors.js';
+
+/**
+ * @internal
+ */
+export interface DeferredOptions {
+ message: string;
+ timeout: number;
+}
+
+/**
+ * Creates and returns a deferred object along with the resolve/reject functions.
+ *
+ * If the deferred has not been resolved/rejected within the `timeout` period,
+ * the deferred gets resolves with a timeout error. `timeout` has to be greater than 0 or
+ * it is ignored.
+ *
+ * @internal
+ */
+export class Deferred<T, V extends Error = Error> {
+ static create<R, X extends Error = Error>(
+ opts?: DeferredOptions
+ ): Deferred<R, X> {
+ return new Deferred<R, X>(opts);
+ }
+
+ static async race<R>(
+ awaitables: Array<Promise<R> | Deferred<R>>
+ ): Promise<R> {
+ const deferredWithTimeout = new Set<Deferred<R>>();
+ try {
+ const promises = awaitables.map(value => {
+ if (value instanceof Deferred) {
+ if (value.#timeoutId) {
+ deferredWithTimeout.add(value);
+ }
+
+ return value.valueOrThrow();
+ }
+
+ return value;
+ });
+ // eslint-disable-next-line no-restricted-syntax
+ return await Promise.race(promises);
+ } finally {
+ for (const deferred of deferredWithTimeout) {
+ // We need to stop the timeout else
+ // Node.JS will keep running the event loop till the
+ // timer executes
+ deferred.reject(new Error('Timeout cleared'));
+ }
+ }
+ }
+
+ #isResolved = false;
+ #isRejected = false;
+ #value: T | V | TimeoutError | undefined;
+ // SAFETY: This is ensured by #taskPromise.
+ #resolve!: (value: void) => void;
+ #taskPromise = new Promise<void>(resolve => {
+ this.#resolve = resolve;
+ });
+ #timeoutId: ReturnType<typeof setTimeout> | undefined;
+ #timeoutError: TimeoutError | undefined;
+
+ constructor(opts?: DeferredOptions) {
+ if (opts && opts.timeout > 0) {
+ this.#timeoutError = new TimeoutError(opts.message);
+ this.#timeoutId = setTimeout(() => {
+ this.reject(this.#timeoutError!);
+ }, opts.timeout);
+ }
+ }
+
+ #finish(value: T | V | TimeoutError) {
+ clearTimeout(this.#timeoutId);
+ this.#value = value;
+ this.#resolve();
+ }
+
+ resolve(value: T): void {
+ if (this.#isRejected || this.#isResolved) {
+ return;
+ }
+ this.#isResolved = true;
+ this.#finish(value);
+ }
+
+ reject(error: V | TimeoutError): void {
+ if (this.#isRejected || this.#isResolved) {
+ return;
+ }
+ this.#isRejected = true;
+ this.#finish(error);
+ }
+
+ resolved(): boolean {
+ return this.#isResolved;
+ }
+
+ finished(): boolean {
+ return this.#isResolved || this.#isRejected;
+ }
+
+ value(): T | V | TimeoutError | undefined {
+ return this.#value;
+ }
+
+ #promise: Promise<T> | undefined;
+ valueOrThrow(): Promise<T> {
+ if (!this.#promise) {
+ this.#promise = (async () => {
+ await this.#taskPromise;
+ if (this.#isRejected) {
+ throw this.#value;
+ }
+ return this.#value as T;
+ })();
+ }
+ return this.#promise;
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/util/ErrorLike.ts b/remote/test/puppeteer/packages/puppeteer-core/src/util/ErrorLike.ts
new file mode 100644
index 0000000000..d4ab3044ab
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/util/ErrorLike.ts
@@ -0,0 +1,66 @@
+/**
+ * @license
+ * Copyright 2022 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {ProtocolError} from '../common/Errors.js';
+
+/**
+ * @internal
+ */
+export interface ErrorLike extends Error {
+ name: string;
+ message: string;
+}
+
+/**
+ * @internal
+ */
+export function isErrorLike(obj: unknown): obj is ErrorLike {
+ return (
+ typeof obj === 'object' && obj !== null && 'name' in obj && 'message' in obj
+ );
+}
+
+/**
+ * @internal
+ */
+export function isErrnoException(obj: unknown): obj is NodeJS.ErrnoException {
+ return (
+ isErrorLike(obj) &&
+ ('errno' in obj || 'code' in obj || 'path' in obj || 'syscall' in obj)
+ );
+}
+
+/**
+ * @internal
+ */
+export function rewriteError(
+ error: ProtocolError,
+ message: string,
+ originalMessage?: string
+): Error {
+ error.message = message;
+ error.originalMessage = originalMessage ?? error.originalMessage;
+ return error;
+}
+
+/**
+ * @internal
+ */
+export function createProtocolErrorMessage(object: {
+ error: {message: string; data: any; code: number};
+}): string {
+ let message = object.error.message;
+ // TODO: remove the type checks when we stop connecting to BiDi with a CDP
+ // client.
+ if (
+ object.error &&
+ typeof object.error === 'object' &&
+ 'data' in object.error
+ ) {
+ message += ` ${object.error.data}`;
+ }
+ return message;
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/util/Function.test.ts b/remote/test/puppeteer/packages/puppeteer-core/src/util/Function.test.ts
new file mode 100644
index 0000000000..c6da4cdf27
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/util/Function.test.ts
@@ -0,0 +1,36 @@
+/**
+ * @license
+ * Copyright 2018 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {describe, it} from 'node:test';
+
+import expect from 'expect';
+
+import {interpolateFunction} from './Function.js';
+
+describe('Function', function () {
+ describe('interpolateFunction', function () {
+ it('should work', async () => {
+ const test = interpolateFunction(
+ () => {
+ const test = PLACEHOLDER('test') as () => number;
+ return test();
+ },
+ {test: `() => 5`}
+ );
+ expect(test()).toBe(5);
+ });
+ it('should work inlined', async () => {
+ const test = interpolateFunction(
+ () => {
+ // Note the parenthesis will be removed by the typescript compiler.
+ return (PLACEHOLDER('test') as () => number)();
+ },
+ {test: `() => 5`}
+ );
+ expect(test()).toBe(5);
+ });
+ });
+});
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/util/Function.ts b/remote/test/puppeteer/packages/puppeteer-core/src/util/Function.ts
new file mode 100644
index 0000000000..41db98830b
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/util/Function.ts
@@ -0,0 +1,91 @@
+/**
+ * @license
+ * Copyright 2023 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+const createdFunctions = new Map<string, (...args: unknown[]) => unknown>();
+
+/**
+ * Creates a function from a string.
+ *
+ * @internal
+ */
+export const createFunction = (
+ functionValue: string
+): ((...args: unknown[]) => unknown) => {
+ let fn = createdFunctions.get(functionValue);
+ if (fn) {
+ return fn;
+ }
+ fn = new Function(`return ${functionValue}`)() as (
+ ...args: unknown[]
+ ) => unknown;
+ createdFunctions.set(functionValue, fn);
+ return fn;
+};
+
+/**
+ * @internal
+ */
+export function stringifyFunction(fn: (...args: never) => unknown): string {
+ let value = fn.toString();
+ try {
+ new Function(`(${value})`);
+ } catch {
+ // This means we might have a function shorthand (e.g. `test(){}`). Let's
+ // try prefixing.
+ let prefix = 'function ';
+ if (value.startsWith('async ')) {
+ prefix = `async ${prefix}`;
+ value = value.substring('async '.length);
+ }
+ value = `${prefix}${value}`;
+ try {
+ new Function(`(${value})`);
+ } catch {
+ // We tried hard to serialize, but there's a weird beast here.
+ throw new Error('Passed function cannot be serialized!');
+ }
+ }
+ return value;
+}
+
+/**
+ * Replaces `PLACEHOLDER`s with the given replacements.
+ *
+ * All replacements must be valid JS code.
+ *
+ * @example
+ *
+ * ```ts
+ * interpolateFunction(() => PLACEHOLDER('test'), {test: 'void 0'});
+ * // Equivalent to () => void 0
+ * ```
+ *
+ * @internal
+ */
+export const interpolateFunction = <T extends (...args: never[]) => unknown>(
+ fn: T,
+ replacements: Record<string, string>
+): T => {
+ let value = stringifyFunction(fn);
+ for (const [name, jsValue] of Object.entries(replacements)) {
+ value = value.replace(
+ new RegExp(`PLACEHOLDER\\(\\s*(?:'${name}'|"${name}")\\s*\\)`, 'g'),
+ // Wrapping this ensures tersers that accidently inline PLACEHOLDER calls
+ // are still valid. Without, we may get calls like ()=>{...}() which is
+ // not valid.
+ `(${jsValue})`
+ );
+ }
+ return createFunction(value) as unknown as T;
+};
+
+declare global {
+ /**
+ * Used for interpolation with {@link interpolateFunction}.
+ *
+ * @internal
+ */
+ function PLACEHOLDER<T>(name: string): T;
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/util/Mutex.ts b/remote/test/puppeteer/packages/puppeteer-core/src/util/Mutex.ts
new file mode 100644
index 0000000000..9498bac306
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/util/Mutex.ts
@@ -0,0 +1,41 @@
+import {Deferred} from './Deferred.js';
+import {disposeSymbol} from './disposable.js';
+
+/**
+ * @internal
+ */
+export class Mutex {
+ static Guard = class Guard {
+ #mutex: Mutex;
+ constructor(mutex: Mutex) {
+ this.#mutex = mutex;
+ }
+ [disposeSymbol](): void {
+ return this.#mutex.release();
+ }
+ };
+
+ #locked = false;
+ #acquirers: Array<() => void> = [];
+
+ // This is FIFO.
+ async acquire(): Promise<InstanceType<typeof Mutex.Guard>> {
+ if (!this.#locked) {
+ this.#locked = true;
+ return new Mutex.Guard(this);
+ }
+ const deferred = Deferred.create<void>();
+ this.#acquirers.push(deferred.resolve.bind(deferred));
+ await deferred.valueOrThrow();
+ return new Mutex.Guard(this);
+ }
+
+ release(): void {
+ const resolve = this.#acquirers.shift();
+ if (!resolve) {
+ this.#locked = false;
+ return;
+ }
+ resolve();
+ }
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/util/assert.ts b/remote/test/puppeteer/packages/puppeteer-core/src/util/assert.ts
new file mode 100644
index 0000000000..7800b3be40
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/util/assert.ts
@@ -0,0 +1,21 @@
+/**
+ * @license
+ * Copyright 2020 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * Asserts that the given value is truthy.
+ * @param value - some conditional statement
+ * @param message - the error message to throw if the value is not truthy.
+ *
+ * @internal
+ */
+export const assert: (value: unknown, message?: string) => asserts value = (
+ value,
+ message
+) => {
+ if (!value) {
+ throw new Error(message);
+ }
+};
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/util/decorators.test.ts b/remote/test/puppeteer/packages/puppeteer-core/src/util/decorators.test.ts
new file mode 100644
index 0000000000..4cdaf15d5b
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/util/decorators.test.ts
@@ -0,0 +1,79 @@
+/**
+ * @license
+ * Copyright 2023 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {describe, it} from 'node:test';
+
+import expect from 'expect';
+import sinon from 'sinon';
+
+import {invokeAtMostOnceForArguments} from './decorators.js';
+
+describe('decorators', function () {
+ describe('invokeAtMostOnceForArguments', () => {
+ it('should delegate calls', () => {
+ const spy = sinon.spy();
+ class Test {
+ @invokeAtMostOnceForArguments
+ test(obj1: object, obj2: object) {
+ spy(obj1, obj2);
+ }
+ }
+ const t = new Test();
+ expect(spy.callCount).toBe(0);
+ const obj1 = {};
+ const obj2 = {};
+ t.test(obj1, obj2);
+ expect(spy.callCount).toBe(1);
+ });
+
+ it('should prevent repeated calls', () => {
+ const spy = sinon.spy();
+ class Test {
+ @invokeAtMostOnceForArguments
+ test(obj1: object, obj2: object) {
+ spy(obj1, obj2);
+ }
+ }
+ const t = new Test();
+ expect(spy.callCount).toBe(0);
+ const obj1 = {};
+ const obj2 = {};
+ t.test(obj1, obj2);
+ expect(spy.callCount).toBe(1);
+ expect(spy.lastCall.calledWith(obj1, obj2)).toBeTruthy();
+ t.test(obj1, obj2);
+ expect(spy.callCount).toBe(1);
+ expect(spy.lastCall.calledWith(obj1, obj2)).toBeTruthy();
+ const obj3 = {};
+ t.test(obj1, obj3);
+ expect(spy.callCount).toBe(2);
+ expect(spy.lastCall.calledWith(obj1, obj3)).toBeTruthy();
+ });
+
+ it('should throw an error for dynamic argumetns', () => {
+ class Test {
+ @invokeAtMostOnceForArguments
+ test(..._args: unknown[]) {}
+ }
+ const t = new Test();
+ t.test({});
+ expect(() => {
+ t.test({}, {});
+ }).toThrow();
+ });
+
+ it('should throw an error for non object arguments', () => {
+ class Test {
+ @invokeAtMostOnceForArguments
+ test(..._args: unknown[]) {}
+ }
+ const t = new Test();
+ expect(() => {
+ t.test(1);
+ }).toThrow();
+ });
+ });
+});
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/util/decorators.ts b/remote/test/puppeteer/packages/puppeteer-core/src/util/decorators.ts
new file mode 100644
index 0000000000..af21c5fe29
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/util/decorators.ts
@@ -0,0 +1,140 @@
+/**
+ * @license
+ * Copyright 2023 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {Disposed, Moveable} from '../common/types.js';
+
+import {asyncDisposeSymbol, disposeSymbol} from './disposable.js';
+import {Mutex} from './Mutex.js';
+
+const instances = new WeakSet<object>();
+
+export function moveable<
+ Class extends abstract new (...args: never[]) => Moveable,
+>(Class: Class, _: ClassDecoratorContext<Class>): Class {
+ let hasDispose = false;
+ if (Class.prototype[disposeSymbol]) {
+ const dispose = Class.prototype[disposeSymbol];
+ Class.prototype[disposeSymbol] = function (this: InstanceType<Class>) {
+ if (instances.has(this)) {
+ instances.delete(this);
+ return;
+ }
+ return dispose.call(this);
+ };
+ hasDispose = true;
+ }
+ if (Class.prototype[asyncDisposeSymbol]) {
+ const asyncDispose = Class.prototype[asyncDisposeSymbol];
+ Class.prototype[asyncDisposeSymbol] = function (this: InstanceType<Class>) {
+ if (instances.has(this)) {
+ instances.delete(this);
+ return;
+ }
+ return asyncDispose.call(this);
+ };
+ hasDispose = true;
+ }
+ if (hasDispose) {
+ Class.prototype.move = function (
+ this: InstanceType<Class>
+ ): InstanceType<Class> {
+ instances.add(this);
+ return this;
+ };
+ }
+ return Class;
+}
+
+export function throwIfDisposed<This extends Disposed>(
+ message: (value: This) => string = value => {
+ return `Attempted to use disposed ${value.constructor.name}.`;
+ }
+) {
+ return (target: (this: This, ...args: any[]) => any, _: unknown) => {
+ return function (this: This, ...args: any[]): any {
+ if (this.disposed) {
+ throw new Error(message(this));
+ }
+ return target.call(this, ...args);
+ };
+ };
+}
+
+export function inertIfDisposed<This extends Disposed>(
+ target: (this: This, ...args: any[]) => any,
+ _: unknown
+) {
+ return function (this: This, ...args: any[]): any {
+ if (this.disposed) {
+ return;
+ }
+ return target.call(this, ...args);
+ };
+}
+
+/**
+ * The decorator only invokes the target if the target has not been invoked with
+ * the same arguments before. The decorated method throws an error if it's
+ * invoked with a different number of elements: if you decorate a method, it
+ * should have the same number of arguments
+ *
+ * @internal
+ */
+export function invokeAtMostOnceForArguments(
+ target: (this: unknown, ...args: any[]) => any,
+ _: unknown
+): typeof target {
+ const cache = new WeakMap();
+ let cacheDepth = -1;
+ return function (this: unknown, ...args: unknown[]) {
+ if (cacheDepth === -1) {
+ cacheDepth = args.length;
+ }
+ if (cacheDepth !== args.length) {
+ throw new Error(
+ 'Memoized method was called with the wrong number of arguments'
+ );
+ }
+ let freshArguments = false;
+ let cacheIterator = cache;
+ for (const arg of args) {
+ if (cacheIterator.has(arg as object)) {
+ cacheIterator = cacheIterator.get(arg as object)!;
+ } else {
+ freshArguments = true;
+ cacheIterator.set(arg as object, new WeakMap());
+ cacheIterator = cacheIterator.get(arg as object)!;
+ }
+ }
+ if (!freshArguments) {
+ return;
+ }
+ return target.call(this, ...args);
+ };
+}
+
+export function guarded<T extends object>(
+ getKey = function (this: T): object {
+ return this;
+ }
+) {
+ return (
+ target: (this: T, ...args: any[]) => Promise<any>,
+ _: ClassMethodDecoratorContext<T>
+ ): typeof target => {
+ const mutexes = new WeakMap<object, Mutex>();
+ return async function (...args) {
+ const key = getKey.call(this);
+ let mutex = mutexes.get(key);
+ if (!mutex) {
+ mutex = new Mutex();
+ mutexes.set(key, mutex);
+ }
+ await using _ = await mutex.acquire();
+ return await target.call(this, ...args);
+ };
+ };
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/util/disposable.ts b/remote/test/puppeteer/packages/puppeteer-core/src/util/disposable.ts
new file mode 100644
index 0000000000..a1848f3860
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/util/disposable.ts
@@ -0,0 +1,275 @@
+/**
+ * @license
+ * Copyright 2023 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+declare global {
+ interface SymbolConstructor {
+ /**
+ * A method that is used to release resources held by an object. Called by
+ * the semantics of the `using` statement.
+ */
+ readonly dispose: unique symbol;
+
+ /**
+ * A method that is used to asynchronously release resources held by an
+ * object. Called by the semantics of the `await using` statement.
+ */
+ readonly asyncDispose: unique symbol;
+ }
+
+ interface Disposable {
+ [Symbol.dispose](): void;
+ }
+
+ interface AsyncDisposable {
+ [Symbol.asyncDispose](): PromiseLike<void>;
+ }
+}
+
+(Symbol as any).dispose ??= Symbol('dispose');
+(Symbol as any).asyncDispose ??= Symbol('asyncDispose');
+
+/**
+ * @internal
+ */
+export const disposeSymbol: typeof Symbol.dispose = Symbol.dispose;
+
+/**
+ * @internal
+ */
+export const asyncDisposeSymbol: typeof Symbol.asyncDispose =
+ Symbol.asyncDispose;
+
+/**
+ * @internal
+ */
+export class DisposableStack {
+ #disposed = false;
+ #stack: Disposable[] = [];
+
+ /**
+ * Returns a value indicating whether this stack has been disposed.
+ */
+ get disposed(): boolean {
+ return this.#disposed;
+ }
+
+ /**
+ * Disposes each resource in the stack in the reverse order that they were added.
+ */
+ dispose(): void {
+ if (this.#disposed) {
+ return;
+ }
+ this.#disposed = true;
+ for (const resource of this.#stack.reverse()) {
+ resource[disposeSymbol]();
+ }
+ }
+
+ /**
+ * Adds a disposable resource to the stack, returning the resource.
+ *
+ * @param value - The resource to add. `null` and `undefined` will not be added,
+ * but will be returned.
+ * @returns The provided `value`.
+ */
+ use<T extends Disposable | null | undefined>(value: T): T {
+ if (value) {
+ this.#stack.push(value);
+ }
+ return value;
+ }
+
+ /**
+ * Adds a value and associated disposal callback as a resource to the stack.
+ *
+ * @param value - The value to add.
+ * @param onDispose - The callback to use in place of a `[disposeSymbol]()`
+ * method. Will be invoked with `value` as the first parameter.
+ * @returns The provided `value`.
+ */
+ adopt<T>(value: T, onDispose: (value: T) => void): T {
+ this.#stack.push({
+ [disposeSymbol]() {
+ onDispose(value);
+ },
+ });
+ return value;
+ }
+
+ /**
+ * Adds a callback to be invoked when the stack is disposed.
+ */
+ defer(onDispose: () => void): void {
+ this.#stack.push({
+ [disposeSymbol]() {
+ onDispose();
+ },
+ });
+ }
+
+ /**
+ * Move all resources out of this stack and into a new `DisposableStack`, and
+ * marks this stack as disposed.
+ *
+ * @example
+ *
+ * ```ts
+ * class C {
+ * #res1: Disposable;
+ * #res2: Disposable;
+ * #disposables: DisposableStack;
+ * constructor() {
+ * // stack will be disposed when exiting constructor for any reason
+ * using stack = new DisposableStack();
+ *
+ * // get first resource
+ * this.#res1 = stack.use(getResource1());
+ *
+ * // get second resource. If this fails, both `stack` and `#res1` will be disposed.
+ * this.#res2 = stack.use(getResource2());
+ *
+ * // all operations succeeded, move resources out of `stack` so that
+ * // they aren't disposed when constructor exits
+ * this.#disposables = stack.move();
+ * }
+ *
+ * [disposeSymbol]() {
+ * this.#disposables.dispose();
+ * }
+ * }
+ * ```
+ */
+ move(): DisposableStack {
+ if (this.#disposed) {
+ throw new ReferenceError('a disposed stack can not use anything new'); // step 3
+ }
+ const stack = new DisposableStack(); // step 4-5
+ stack.#stack = this.#stack;
+ this.#disposed = true;
+ return stack;
+ }
+
+ [disposeSymbol] = this.dispose;
+
+ readonly [Symbol.toStringTag] = 'DisposableStack';
+}
+
+/**
+ * @internal
+ */
+export class AsyncDisposableStack {
+ #disposed = false;
+ #stack: AsyncDisposable[] = [];
+
+ /**
+ * Returns a value indicating whether this stack has been disposed.
+ */
+ get disposed(): boolean {
+ return this.#disposed;
+ }
+
+ /**
+ * Disposes each resource in the stack in the reverse order that they were added.
+ */
+ async dispose(): Promise<void> {
+ if (this.#disposed) {
+ return;
+ }
+ this.#disposed = true;
+ for (const resource of this.#stack.reverse()) {
+ await resource[asyncDisposeSymbol]();
+ }
+ }
+
+ /**
+ * Adds a disposable resource to the stack, returning the resource.
+ *
+ * @param value - The resource to add. `null` and `undefined` will not be added,
+ * but will be returned.
+ * @returns The provided `value`.
+ */
+ use<T extends AsyncDisposable | null | undefined>(value: T): T {
+ if (value) {
+ this.#stack.push(value);
+ }
+ return value;
+ }
+
+ /**
+ * Adds a value and associated disposal callback as a resource to the stack.
+ *
+ * @param value - The value to add.
+ * @param onDispose - The callback to use in place of a `[disposeSymbol]()`
+ * method. Will be invoked with `value` as the first parameter.
+ * @returns The provided `value`.
+ */
+ adopt<T>(value: T, onDispose: (value: T) => Promise<void>): T {
+ this.#stack.push({
+ [asyncDisposeSymbol]() {
+ return onDispose(value);
+ },
+ });
+ return value;
+ }
+
+ /**
+ * Adds a callback to be invoked when the stack is disposed.
+ */
+ defer(onDispose: () => Promise<void>): void {
+ this.#stack.push({
+ [asyncDisposeSymbol]() {
+ return onDispose();
+ },
+ });
+ }
+
+ /**
+ * Move all resources out of this stack and into a new `DisposableStack`, and
+ * marks this stack as disposed.
+ *
+ * @example
+ *
+ * ```ts
+ * class C {
+ * #res1: Disposable;
+ * #res2: Disposable;
+ * #disposables: DisposableStack;
+ * constructor() {
+ * // stack will be disposed when exiting constructor for any reason
+ * using stack = new DisposableStack();
+ *
+ * // get first resource
+ * this.#res1 = stack.use(getResource1());
+ *
+ * // get second resource. If this fails, both `stack` and `#res1` will be disposed.
+ * this.#res2 = stack.use(getResource2());
+ *
+ * // all operations succeeded, move resources out of `stack` so that
+ * // they aren't disposed when constructor exits
+ * this.#disposables = stack.move();
+ * }
+ *
+ * [disposeSymbol]() {
+ * this.#disposables.dispose();
+ * }
+ * }
+ * ```
+ */
+ move(): AsyncDisposableStack {
+ if (this.#disposed) {
+ throw new ReferenceError('a disposed stack can not use anything new'); // step 3
+ }
+ const stack = new AsyncDisposableStack(); // step 4-5
+ stack.#stack = this.#stack;
+ this.#disposed = true;
+ return stack;
+ }
+
+ [asyncDisposeSymbol] = this.dispose;
+
+ readonly [Symbol.toStringTag] = 'AsyncDisposableStack';
+}
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/util/util.ts b/remote/test/puppeteer/packages/puppeteer-core/src/util/util.ts
new file mode 100644
index 0000000000..f55610da9e
--- /dev/null
+++ b/remote/test/puppeteer/packages/puppeteer-core/src/util/util.ts
@@ -0,0 +1,11 @@
+/**
+ * @license
+ * Copyright 2022 Google Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+export * from './assert.js';
+export * from './Deferred.js';
+export * from './ErrorLike.js';
+export * from './AsyncIterableUtil.js';
+export * from './disposable.js';