summaryrefslogtreecommitdiffstats
path: root/remote/test/puppeteer/packages/puppeteer-core/src/cdp
diff options
context:
space:
mode:
Diffstat (limited to 'remote/test/puppeteer/packages/puppeteer-core/src/cdp')
-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
38 files changed, 12480 insertions, 0 deletions
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);
+}