diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /remote/test/puppeteer/packages/puppeteer-core/src/cdp | |
parent | Initial commit. (diff) | |
download | firefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz firefox-26a029d407be480d791972afb5975cf62c9360a6.zip |
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'remote/test/puppeteer/packages/puppeteer-core/src/cdp')
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); +} |