diff options
Diffstat (limited to 'remote/test/puppeteer/src')
61 files changed, 16432 insertions, 0 deletions
diff --git a/remote/test/puppeteer/src/.eslintrc.js b/remote/test/puppeteer/src/.eslintrc.js new file mode 100644 index 0000000000..4ebb9bb1ec --- /dev/null +++ b/remote/test/puppeteer/src/.eslintrc.js @@ -0,0 +1,19 @@ +module.exports = { + extends: '../.eslintrc.js', + /** + * ESLint rules + * + * All available rules: http://eslint.org/docs/rules/ + * + * Rules take the following form: + * "rule-name", [severity, { opts }] + * Severity: 2 == error, 1 == warning, 0 == off. + */ + rules: { + 'no-console': [ + 2, + { allow: ['warn', 'error', 'assert', 'timeStamp', 'time', 'timeEnd'] }, + ], + 'no-debugger': 0, + }, +}; diff --git a/remote/test/puppeteer/src/api-docs-entry.ts b/remote/test/puppeteer/src/api-docs-entry.ts new file mode 100644 index 0000000000..d033a3ed7c --- /dev/null +++ b/remote/test/puppeteer/src/api-docs-entry.ts @@ -0,0 +1,65 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* + * This file re-exports any APIs that we want to have documentation generated + * for. It is used by API Extractor to determine what parts of the system to + * document. + * + * The legacy DocLint system and the unit test coverage system use the list of + * modules defined in coverage-utils.js. src/api-docs-entry.ts is ONLY used by + * API Extractor. + * + * Once we have migrated to API Extractor and removed DocLint we can remove the + * duplication and use this file. + */ +export * from './common/Accessibility.js'; +export * from './common/Browser.js'; +export * from './node/BrowserFetcher.js'; +export * from './node/Puppeteer.js'; +export * from './common/Connection.js'; +export * from './common/ConsoleMessage.js'; +export * from './common/Coverage.js'; +export * from './common/DeviceDescriptors.js'; +export * from './common/Dialog.js'; +export * from './common/DOMWorld.js'; +export * from './common/JSHandle.js'; +export * from './common/ExecutionContext.js'; +export * from './common/EventEmitter.js'; +export * from './common/FileChooser.js'; +export * from './common/FrameManager.js'; +export * from './common/Input.js'; +export * from './common/Page.js'; +export * from './common/Product.js'; +export * from './common/Puppeteer.js'; +export * from './common/BrowserConnector.js'; +export * from './node/Launcher.js'; +export * from './node/LaunchOptions.js'; +export * from './common/HTTPRequest.js'; +export * from './common/HTTPResponse.js'; +export * from './common/SecurityDetails.js'; +export * from './common/Target.js'; +export * from './common/Errors.js'; +export * from './common/Tracing.js'; +export * from './common/NetworkManager.js'; +export * from './common/WebWorker.js'; +export * from './common/USKeyboardLayout.js'; +export * from './common/EvalTypes.js'; +export * from './common/PDFOptions.js'; +export * from './common/TimeoutSettings.js'; +export * from './common/LifecycleWatcher.js'; +export * from './common/QueryHandler.js'; +export * from 'devtools-protocol/types/protocol'; diff --git a/remote/test/puppeteer/src/common/Accessibility.ts b/remote/test/puppeteer/src/common/Accessibility.ts new file mode 100644 index 0000000000..69684a4770 --- /dev/null +++ b/remote/test/puppeteer/src/common/Accessibility.ts @@ -0,0 +1,502 @@ +/** + * Copyright 2018 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the 'License'); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an 'AS IS' BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { CDPSession } from './Connection.js'; +import { ElementHandle } from './JSHandle.js'; +import { Protocol } from 'devtools-protocol'; + +/** + * 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; +} + +/** + * The Accessibility class provides methods for inspecting Chromium'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 { + private _client: CDPSession; + + /** + * @internal + */ + constructor(client: CDPSession) { + 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 Chromium 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: + * ```js + * const snapshot = await page.accessibility.snapshot(); + * console.log(snapshot); + * ``` + * + * @example + * An example of logging the focused node's name: + * ```js + * 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> { + const { interestingOnly = true, root = null } = options; + const { nodes } = await this._client.send('Accessibility.getFullAXTree'); + let backendNodeId = null; + if (root) { + const { node } = await this._client.send('DOM.describeNode', { + objectId: root._remoteObject.objectId, + }); + backendNodeId = node.backendNodeId; + } + const defaultRoot = AXNode.createTree(nodes); + let needle = defaultRoot; + if (backendNodeId) { + needle = defaultRoot.find( + (node) => node.payload.backendDOMNodeId === backendNodeId + ); + if (!needle) return null; + } + if (!interestingOnly) return this.serializeTree(needle)[0]; + + const interestingNodes = new Set<AXNode>(); + this.collectInterestingNodes(interestingNodes, defaultRoot, false); + if (!interestingNodes.has(needle)) return null; + return this.serializeTree(needle, interestingNodes)[0]; + } + + 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[] = []; + + private _richlyEditable = false; + private _editable = false; + private _focusable = false; + private _hidden = false; + private _name: string; + private _role: string; + private _ignored: boolean; + private _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; + } + } + + private _isPlainTextField(): boolean { + if (this._richlyEditable) return false; + if (this._editable) return true; + return this._role === 'textbox' || this._role === 'searchbox'; + } + + private _isTextOnlyObject(): boolean { + const role = this._role; + return role === 'LineBreak' || role === 'text' || role === 'InlineTextBox'; + } + + private _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 '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 => + 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 => + properties.get(key) as boolean; + + for (const booleanProperty of booleanProperties) { + // WebArea'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 === 'WebArea') 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 => + 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 => + 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 || []) + node.children.push(nodeById.get(childId)); + } + return nodeById.values().next().value; + } +} diff --git a/remote/test/puppeteer/src/common/AriaQueryHandler.ts b/remote/test/puppeteer/src/common/AriaQueryHandler.ts new file mode 100644 index 0000000000..9b563e9e02 --- /dev/null +++ b/remote/test/puppeteer/src/common/AriaQueryHandler.ts @@ -0,0 +1,140 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { InternalQueryHandler } from './QueryHandler.js'; +import { ElementHandle, JSHandle } from './JSHandle.js'; +import { Protocol } from 'devtools-protocol'; +import { CDPSession } from './Connection.js'; +import { DOMWorld, PageBinding, WaitForSelectorOptions } from './DOMWorld.js'; + +async function queryAXTree( + client: CDPSession, + element: ElementHandle, + accessibleName?: string, + role?: string +): Promise<Protocol.Accessibility.AXNode[]> { + const { nodes } = await client.send('Accessibility.queryAXTree', { + objectId: element._remoteObject.objectId, + accessibleName, + role, + }); + const filteredNodes: Protocol.Accessibility.AXNode[] = nodes.filter( + (node: Protocol.Accessibility.AXNode) => node.role.value !== 'text' + ); + return filteredNodes; +} + +/* + * 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="img"]' queries for elements with role 'img' 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'. + */ +type ariaQueryOption = { name?: string; role?: string }; +function parseAriaSelector(selector: string): ariaQueryOption { + const normalize = (value: string): string => value.replace(/ +/g, ' ').trim(); + const knownAttributes = new Set(['name', 'role']); + const queryOptions: ariaQueryOption = {}; + const attributeRegexp = /\[\s*(?<attribute>\w+)\s*=\s*"(?<value>\\.|[^"\\]*)"\s*\]/; + const defaultName = selector.replace( + attributeRegexp, + (_, attribute: string, value: string) => { + attribute = attribute.trim(); + if (!knownAttributes.has(attribute)) + throw new Error( + 'Unkown aria attribute "${groups.attribute}" in selector' + ); + queryOptions[attribute] = normalize(value); + return ''; + } + ); + if (defaultName && !queryOptions.name) + queryOptions.name = normalize(defaultName); + return queryOptions; +} + +const queryOne = async ( + element: ElementHandle, + selector: string +): Promise<ElementHandle | null> => { + const exeCtx = element.executionContext(); + const { name, role } = parseAriaSelector(selector); + const res = await queryAXTree(exeCtx._client, element, name, role); + if (res.length < 1) { + return null; + } + return exeCtx._adoptBackendNodeId(res[0].backendDOMNodeId); +}; + +const waitFor = async ( + domWorld: DOMWorld, + selector: string, + options: WaitForSelectorOptions +): Promise<ElementHandle<Element>> => { + const binding: PageBinding = { + name: 'ariaQuerySelector', + pptrFunction: async (selector: string) => { + const document = await domWorld._document(); + const element = await queryOne(document, selector); + return element; + }, + }; + return domWorld.waitForSelectorInPage( + (_: Element, selector: string) => globalThis.ariaQuerySelector(selector), + selector, + options, + binding + ); +}; + +const queryAll = async ( + element: ElementHandle, + selector: string +): Promise<ElementHandle[]> => { + const exeCtx = element.executionContext(); + const { name, role } = parseAriaSelector(selector); + const res = await queryAXTree(exeCtx._client, element, name, role); + return Promise.all( + res.map((axNode) => exeCtx._adoptBackendNodeId(axNode.backendDOMNodeId)) + ); +}; + +const queryAllArray = async ( + element: ElementHandle, + selector: string +): Promise<JSHandle> => { + const elementHandles = await queryAll(element, selector); + const exeCtx = element.executionContext(); + const jsHandle = exeCtx.evaluateHandle( + (...elements) => elements, + ...elementHandles + ); + return jsHandle; +}; + +/** + * @internal + */ +export const ariaHandler: InternalQueryHandler = { + queryOne, + waitFor, + queryAll, + queryAllArray, +}; diff --git a/remote/test/puppeteer/src/common/Browser.ts b/remote/test/puppeteer/src/common/Browser.ts new file mode 100644 index 0000000000..e3b6a24119 --- /dev/null +++ b/remote/test/puppeteer/src/common/Browser.ts @@ -0,0 +1,734 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { assert } from './assert.js'; +import { helper } from './helper.js'; +import { Target } from './Target.js'; +import { EventEmitter } from './EventEmitter.js'; +import { Connection, ConnectionEmittedEvents } from './Connection.js'; +import { Protocol } from 'devtools-protocol'; +import { Page } from './Page.js'; +import { ChildProcess } from 'child_process'; +import { Viewport } from './PuppeteerViewport.js'; + +type BrowserCloseCallback = () => Promise<void> | void; + +/** + * @public + */ +export interface WaitForTargetOptions { + /** + * Maximum wait time in milliseconds. Pass `0` to disable the timeout. + * @defaultValue 30 seconds. + */ + timeout?: number; +} + +/** + * All the events a {@link Browser | browser instance} may emit. + * + * @public + */ +export const enum BrowserEmittedEvents { + /** + * Emitted when Puppeteer gets disconnected from the Chromium instance. This + * might happen because of one of the following: + * + * - Chromium is closed or crashed + * + * - The {@link Browser.disconnect | browser.disconnect } method was called. + */ + Disconnected = 'disconnected', + + /** + * Emitted when the url of a target changes. Contains a {@link Target} instance. + * + * @remarks + * + * Note that this includes target changes in incognito browser contexts. + */ + TargetChanged = 'targetchanged', + + /** + * Emitted when a target is created, for example when a new page is opened by + * {@link https://developer.mozilla.org/en-US/docs/Web/API/Window/open | window.open} + * or by {@link Browser.newPage | browser.newPage} + * + * Contains a {@link Target} instance. + * + * @remarks + * + * Note that this includes target creations in incognito browser contexts. + */ + TargetCreated = 'targetcreated', + /** + * Emitted when a target is destroyed, for example when a page is closed. + * Contains a {@link Target} instance. + * + * @remarks + * + * Note that this includes target destructions in incognito browser contexts. + */ + TargetDestroyed = 'targetdestroyed', +} + +/** + * A Browser is created when Puppeteer connects to a Chromium instance, either through + * {@link PuppeteerNode.launch} or {@link Puppeteer.connect}. + * + * @remarks + * + * The Browser class extends from Puppeteer's {@link EventEmitter} class and will + * emit various events which are documented in the {@link BrowserEmittedEvents} enum. + * + * @example + * + * An example of using a {@link Browser} to create a {@link Page}: + * ```js + * const puppeteer = require('puppeteer'); + * + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * await page.goto('https://example.com'); + * await browser.close(); + * })(); + * ``` + * + * @example + * + * An example of disconnecting from and reconnecting to a {@link Browser}: + * ```js + * const puppeteer = require('puppeteer'); + * + * (async () => { + * const browser = await puppeteer.launch(); + * // Store the endpoint to be able to reconnect to Chromium + * const browserWSEndpoint = browser.wsEndpoint(); + * // Disconnect puppeteer from Chromium + * browser.disconnect(); + * + * // Use the endpoint to reestablish a connection + * const browser2 = await puppeteer.connect({browserWSEndpoint}); + * // Close Chromium + * await browser2.close(); + * })(); + * ``` + * + * @public + */ +export class Browser extends EventEmitter { + /** + * @internal + */ + static async create( + connection: Connection, + contextIds: string[], + ignoreHTTPSErrors: boolean, + defaultViewport?: Viewport, + process?: ChildProcess, + closeCallback?: BrowserCloseCallback + ): Promise<Browser> { + const browser = new Browser( + connection, + contextIds, + ignoreHTTPSErrors, + defaultViewport, + process, + closeCallback + ); + await connection.send('Target.setDiscoverTargets', { discover: true }); + return browser; + } + private _ignoreHTTPSErrors: boolean; + private _defaultViewport?: Viewport; + private _process?: ChildProcess; + private _connection: Connection; + private _closeCallback: BrowserCloseCallback; + private _defaultContext: BrowserContext; + private _contexts: Map<string, BrowserContext>; + /** + * @internal + * Used in Target.ts directly so cannot be marked private. + */ + _targets: Map<string, Target>; + + /** + * @internal + */ + constructor( + connection: Connection, + contextIds: string[], + ignoreHTTPSErrors: boolean, + defaultViewport?: Viewport, + process?: ChildProcess, + closeCallback?: BrowserCloseCallback + ) { + super(); + this._ignoreHTTPSErrors = ignoreHTTPSErrors; + this._defaultViewport = defaultViewport; + this._process = process; + this._connection = connection; + this._closeCallback = closeCallback || function (): void {}; + + this._defaultContext = new BrowserContext(this._connection, this, null); + this._contexts = new Map(); + for (const contextId of contextIds) + this._contexts.set( + contextId, + new BrowserContext(this._connection, this, contextId) + ); + + this._targets = new Map(); + this._connection.on(ConnectionEmittedEvents.Disconnected, () => + this.emit(BrowserEmittedEvents.Disconnected) + ); + this._connection.on('Target.targetCreated', this._targetCreated.bind(this)); + this._connection.on( + 'Target.targetDestroyed', + this._targetDestroyed.bind(this) + ); + this._connection.on( + 'Target.targetInfoChanged', + this._targetInfoChanged.bind(this) + ); + } + + /** + * The spawned browser process. Returns `null` if the browser instance was created with + * {@link Puppeteer.connect}. + */ + process(): ChildProcess | null { + return this._process; + } + + /** + * Creates a new incognito browser context. This won't share cookies/cache with other + * browser contexts. + * + * @example + * ```js + * (async () => { + * const browser = await puppeteer.launch(); + * // Create a new incognito browser context. + * const context = await browser.createIncognitoBrowserContext(); + * // Create a new page in a pristine context. + * const page = await context.newPage(); + * // Do stuff + * await page.goto('https://example.com'); + * })(); + * ``` + */ + async createIncognitoBrowserContext(): Promise<BrowserContext> { + const { browserContextId } = await this._connection.send( + 'Target.createBrowserContext' + ); + const context = new BrowserContext( + this._connection, + this, + browserContextId + ); + this._contexts.set(browserContextId, context); + return context; + } + + /** + * Returns an array of all open browser contexts. In a newly created browser, this will + * return a single instance of {@link BrowserContext}. + */ + browserContexts(): BrowserContext[] { + return [this._defaultContext, ...Array.from(this._contexts.values())]; + } + + /** + * Returns the default browser context. The default browser context cannot be closed. + */ + defaultBrowserContext(): BrowserContext { + return this._defaultContext; + } + + /** + * @internal + * Used by BrowserContext directly so cannot be marked private. + */ + async _disposeContext(contextId?: string): Promise<void> { + await this._connection.send('Target.disposeBrowserContext', { + browserContextId: contextId || undefined, + }); + this._contexts.delete(contextId); + } + + private async _targetCreated( + event: Protocol.Target.TargetCreatedEvent + ): Promise<void> { + const targetInfo = event.targetInfo; + const { browserContextId } = targetInfo; + const context = + browserContextId && this._contexts.has(browserContextId) + ? this._contexts.get(browserContextId) + : this._defaultContext; + + const target = new Target( + targetInfo, + context, + () => this._connection.createSession(targetInfo), + this._ignoreHTTPSErrors, + this._defaultViewport + ); + assert( + !this._targets.has(event.targetInfo.targetId), + 'Target should not exist before targetCreated' + ); + this._targets.set(event.targetInfo.targetId, target); + + if (await target._initializedPromise) { + this.emit(BrowserEmittedEvents.TargetCreated, target); + context.emit(BrowserContextEmittedEvents.TargetCreated, target); + } + } + + private async _targetDestroyed(event: { targetId: string }): Promise<void> { + const target = this._targets.get(event.targetId); + target._initializedCallback(false); + this._targets.delete(event.targetId); + target._closedCallback(); + if (await target._initializedPromise) { + this.emit(BrowserEmittedEvents.TargetDestroyed, target); + target + .browserContext() + .emit(BrowserContextEmittedEvents.TargetDestroyed, target); + } + } + + private _targetInfoChanged( + event: Protocol.Target.TargetInfoChangedEvent + ): void { + const target = this._targets.get(event.targetInfo.targetId); + assert(target, 'target should exist before targetInfoChanged'); + const previousURL = target.url(); + const wasInitialized = target._isInitialized; + target._targetInfoChanged(event.targetInfo); + if (wasInitialized && previousURL !== target.url()) { + this.emit(BrowserEmittedEvents.TargetChanged, target); + target + .browserContext() + .emit(BrowserContextEmittedEvents.TargetChanged, target); + } + } + + /** + * The browser websocket endpoint which can be used as an argument to + * {@link Puppeteer.connect}. + * + * @returns The Browser websocket url. + * + * @remarks + * + * The format is `ws://${host}:${port}/devtools/browser/<id>`. + * + * You can find the `webSocketDebuggerUrl` from `http://${host}:${port}/json/version`. + * Learn more about the + * {@link https://chromedevtools.github.io/devtools-protocol | devtools protocol} and + * the {@link + * https://chromedevtools.github.io/devtools-protocol/#how-do-i-access-the-browser-target + * | browser endpoint}. + */ + wsEndpoint(): string { + return this._connection.url(); + } + + /** + * Creates a {@link Page} in the default browser context. + */ + async newPage(): Promise<Page> { + return this._defaultContext.newPage(); + } + + /** + * @internal + * Used by BrowserContext directly so cannot be marked private. + */ + async _createPageInContext(contextId?: string): Promise<Page> { + const { targetId } = await this._connection.send('Target.createTarget', { + url: 'about:blank', + browserContextId: contextId || undefined, + }); + const target = await this._targets.get(targetId); + assert( + await target._initializedPromise, + 'Failed to create target for page' + ); + const page = await target.page(); + return page; + } + + /** + * All active targets inside the Browser. In case of multiple browser contexts, returns + * an array with all the targets in all browser contexts. + */ + targets(): Target[] { + return Array.from(this._targets.values()).filter( + (target) => target._isInitialized + ); + } + + /** + * The target associated with the browser. + */ + target(): Target { + return this.targets().find((target) => target.type() === 'browser'); + } + + /** + * Searches for a target in all browser contexts. + * + * @param predicate - A function to be run for every target. + * @returns The first target found that matches the `predicate` function. + * + * @example + * + * An example of finding a target for a page opened via `window.open`: + * ```js + * await page.evaluate(() => window.open('https://www.example.com/')); + * const newWindowTarget = await browser.waitForTarget(target => target.url() === 'https://www.example.com/'); + * ``` + */ + async waitForTarget( + predicate: (x: Target) => boolean, + options: WaitForTargetOptions = {} + ): Promise<Target> { + const { timeout = 30000 } = options; + const existingTarget = this.targets().find(predicate); + if (existingTarget) return existingTarget; + let resolve; + const targetPromise = new Promise<Target>((x) => (resolve = x)); + this.on(BrowserEmittedEvents.TargetCreated, check); + this.on(BrowserEmittedEvents.TargetChanged, check); + try { + if (!timeout) return await targetPromise; + return await helper.waitWithTimeout<Target>( + targetPromise, + 'target', + timeout + ); + } finally { + this.removeListener(BrowserEmittedEvents.TargetCreated, check); + this.removeListener(BrowserEmittedEvents.TargetChanged, check); + } + + function check(target: Target): void { + if (predicate(target)) resolve(target); + } + } + + /** + * An array of all open pages inside the Browser. + * + * @remarks + * + * In case of multiple browser contexts, returns an array with all the pages in all + * browser contexts. Non-visible pages, such as `"background_page"`, will not be listed + * here. You can find them using {@link Target.page}. + */ + async pages(): Promise<Page[]> { + const contextPages = await Promise.all( + this.browserContexts().map((context) => context.pages()) + ); + // Flatten array. + return contextPages.reduce((acc, x) => acc.concat(x), []); + } + + /** + * A string representing the browser name and version. + * + * @remarks + * + * For headless Chromium, this is similar to `HeadlessChrome/61.0.3153.0`. For + * non-headless, this is similar to `Chrome/61.0.3153.0`. + * + * The format of browser.version() might change with future releases of Chromium. + */ + async version(): Promise<string> { + const version = await this._getVersion(); + return version.product; + } + + /** + * The browser's original user agent. Pages can override the browser user agent with + * {@link Page.setUserAgent}. + */ + async userAgent(): Promise<string> { + const version = await this._getVersion(); + return version.userAgent; + } + + /** + * Closes Chromium and all of its pages (if any were opened). The {@link Browser} object + * itself is considered to be disposed and cannot be used anymore. + */ + async close(): Promise<void> { + await this._closeCallback.call(null); + this.disconnect(); + } + + /** + * Disconnects Puppeteer from the browser, but leaves the Chromium process running. + * After calling `disconnect`, the {@link Browser} object is considered disposed and + * cannot be used anymore. + */ + disconnect(): void { + this._connection.dispose(); + } + + /** + * Indicates that the browser is connected. + */ + isConnected(): boolean { + return !this._connection._closed; + } + + private _getVersion(): Promise<Protocol.Browser.GetVersionResponse> { + return this._connection.send('Browser.getVersion'); + } +} + +export const enum BrowserContextEmittedEvents { + /** + * Emitted when the url of a target inside the browser context changes. + * Contains a {@link Target} instance. + */ + TargetChanged = 'targetchanged', + + /** + * Emitted when a target is created within the browser context, for example + * when a new page is opened by + * {@link https://developer.mozilla.org/en-US/docs/Web/API/Window/open | window.open} + * or by {@link BrowserContext.newPage | browserContext.newPage} + * + * Contains a {@link Target} instance. + */ + TargetCreated = 'targetcreated', + /** + * Emitted when a target is destroyed within the browser context, for example + * when a page is closed. Contains a {@link Target} instance. + */ + TargetDestroyed = 'targetdestroyed', +} + +/** + * BrowserContexts provide a way to operate multiple independent browser + * sessions. When a browser is launched, it has a single BrowserContext used by + * default. The method {@link Browser.newPage | Browser.newPage} creates a page + * in the default browser context. + * + * @remarks + * + * The Browser class extends from Puppeteer's {@link EventEmitter} class and + * will emit various events which are documented in the + * {@link BrowserContextEmittedEvents} enum. + * + * If a page opens another page, e.g. with a `window.open` call, the popup will + * belong to the parent page's browser context. + * + * Puppeteer allows creation of "incognito" browser contexts with + * {@link Browser.createIncognitoBrowserContext | Browser.createIncognitoBrowserContext} + * method. "Incognito" browser contexts don't write any browsing data to disk. + * + * @example + * ```js + * // Create a new incognito browser context + * const context = await browser.createIncognitoBrowserContext(); + * // Create a new page inside context. + * const page = await context.newPage(); + * // ... do stuff with page ... + * await page.goto('https://example.com'); + * // Dispose context once it's no longer needed. + * await context.close(); + * ``` + */ +export class BrowserContext extends EventEmitter { + private _connection: Connection; + private _browser: Browser; + private _id?: string; + + /** + * @internal + */ + constructor(connection: Connection, browser: Browser, contextId?: string) { + super(); + this._connection = connection; + this._browser = browser; + this._id = contextId; + } + + /** + * An array of all active targets inside the browser context. + */ + targets(): Target[] { + return this._browser + .targets() + .filter((target) => target.browserContext() === this); + } + + /** + * This searches for a target in this specific browser context. + * + * @example + * An example of finding a target for a page opened via `window.open`: + * ```js + * await page.evaluate(() => window.open('https://www.example.com/')); + * const newWindowTarget = await browserContext.waitForTarget(target => target.url() === 'https://www.example.com/'); + * ``` + * + * @param predicate - A function to be run for every target + * @param options - An object of options. Accepts a timout, + * which is the maximum wait time in milliseconds. + * Pass `0` to disable the timeout. Defaults to 30 seconds. + * @returns Promise which resolves to the first target found + * that matches the `predicate` function. + */ + waitForTarget( + predicate: (x: Target) => boolean, + options: { timeout?: number } = {} + ): Promise<Target> { + return this._browser.waitForTarget( + (target) => target.browserContext() === this && predicate(target), + options + ); + } + + /** + * An array of all pages inside the browser context. + * + * @returns Promise which resolves to an array of all open pages. + * Non visible pages, such as `"background_page"`, will not be listed here. + * You can find them using {@link Target.page | the target page}. + */ + async pages(): Promise<Page[]> { + const pages = await Promise.all( + this.targets() + .filter((target) => target.type() === 'page') + .map((target) => target.page()) + ); + return pages.filter((page) => !!page); + } + + /** + * Returns whether BrowserContext is incognito. + * The default browser context is the only non-incognito browser context. + * + * @remarks + * The default browser context cannot be closed. + */ + isIncognito(): boolean { + return !!this._id; + } + + /** + * @example + * ```js + * const context = browser.defaultBrowserContext(); + * await context.overridePermissions('https://html5demos.com', ['geolocation']); + * ``` + * + * @param origin - The origin to grant permissions to, e.g. "https://example.com". + * @param permissions - An array of permissions to grant. + * All permissions that are not listed here will be automatically denied. + */ + async overridePermissions( + origin: string, + permissions: string[] + ): Promise<void> { + const webPermissionToProtocol = new Map< + string, + Protocol.Browser.PermissionType + >([ + ['geolocation', 'geolocation'], + ['midi', 'midi'], + ['notifications', 'notifications'], + // TODO: push isn't a valid type? + // ['push', 'push'], + ['camera', 'videoCapture'], + ['microphone', 'audioCapture'], + ['background-sync', 'backgroundSync'], + ['ambient-light-sensor', 'sensors'], + ['accelerometer', 'sensors'], + ['gyroscope', 'sensors'], + ['magnetometer', 'sensors'], + ['accessibility-events', 'accessibilityEvents'], + ['clipboard-read', 'clipboardReadWrite'], + ['clipboard-write', 'clipboardReadWrite'], + ['payment-handler', 'paymentHandler'], + ['idle-detection', 'idleDetection'], + // chrome-specific permissions we have. + ['midi-sysex', 'midiSysex'], + ]); + const protocolPermissions = permissions.map((permission) => { + const protocolPermission = webPermissionToProtocol.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, + }); + } + + /** + * Clears all permission overrides for the browser context. + * + * @example + * ```js + * const context = browser.defaultBrowserContext(); + * context.overridePermissions('https://example.com', ['clipboard-read']); + * // do stuff .. + * context.clearPermissionOverrides(); + * ``` + */ + async clearPermissionOverrides(): Promise<void> { + await this._connection.send('Browser.resetPermissions', { + browserContextId: this._id || undefined, + }); + } + + /** + * Creates a new page in the browser context. + */ + newPage(): Promise<Page> { + return this._browser._createPageInContext(this._id); + } + + /** + * The browser this browser context belongs to. + */ + browser(): Browser { + return this._browser; + } + + /** + * Closes the browser context. All the targets that belong to the browser context + * will be closed. + * + * @remarks + * Only incognito browser contexts can be closed. + */ + 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/src/common/BrowserConnector.ts b/remote/test/puppeteer/src/common/BrowserConnector.ts new file mode 100644 index 0000000000..5da9cbc144 --- /dev/null +++ b/remote/test/puppeteer/src/common/BrowserConnector.ts @@ -0,0 +1,120 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ConnectionTransport } from './ConnectionTransport.js'; +import { Browser } from './Browser.js'; +import { assert } from './assert.js'; +import { debugError } from '../common/helper.js'; +import { Connection } from './Connection.js'; +import { getFetch } from './fetch.js'; +import { Viewport } from './PuppeteerViewport.js'; +import { isNode } from '../environment.js'; + +/** + * Generic browser options that can be passed when launching any browser. + * @public + */ +export interface BrowserOptions { + ignoreHTTPSErrors?: boolean; + defaultViewport?: Viewport; + slowMo?: number; +} + +const getWebSocketTransportClass = async () => { + return isNode + ? (await import('../node/NodeWebSocketTransport.js')).NodeWebSocketTransport + : (await import('./BrowserWebSocketTransport.js')) + .BrowserWebSocketTransport; +}; + +/** + * Users should never call this directly; it's called when calling + * `puppeteer.connect`. + * @internal + */ +export const connectToBrowser = async ( + options: BrowserOptions & { + browserWSEndpoint?: string; + browserURL?: string; + transport?: ConnectionTransport; + } +): Promise<Browser> => { + const { + browserWSEndpoint, + browserURL, + ignoreHTTPSErrors = false, + defaultViewport = { width: 800, height: 600 }, + transport, + slowMo = 0, + } = options; + + assert( + Number(!!browserWSEndpoint) + Number(!!browserURL) + Number(!!transport) === + 1, + 'Exactly one of browserWSEndpoint, browserURL or transport must be passed to puppeteer.connect' + ); + + let connection = null; + if (transport) { + connection = new Connection('', transport, slowMo); + } else if (browserWSEndpoint) { + const WebSocketClass = await getWebSocketTransportClass(); + const connectionTransport: ConnectionTransport = await WebSocketClass.create( + browserWSEndpoint + ); + connection = new Connection(browserWSEndpoint, connectionTransport, slowMo); + } else if (browserURL) { + const connectionURL = await getWSEndpoint(browserURL); + const WebSocketClass = await getWebSocketTransportClass(); + const connectionTransport: ConnectionTransport = await WebSocketClass.create( + connectionURL + ); + connection = new Connection(connectionURL, connectionTransport, slowMo); + } + + const { browserContextIds } = await connection.send( + 'Target.getBrowserContexts' + ); + return Browser.create( + connection, + browserContextIds, + ignoreHTTPSErrors, + defaultViewport, + null, + () => connection.send('Browser.close').catch(debugError) + ); +}; + +async function getWSEndpoint(browserURL: string): Promise<string> { + const endpointURL = new URL('/json/version', browserURL); + + const fetch = await getFetch(); + try { + const result = await fetch(endpointURL.toString(), { + method: 'GET', + }); + if (!result.ok) { + throw new Error(`HTTP ${result.statusText}`); + } + const data = await result.json(); + return data.webSocketDebuggerUrl; + } catch (error) { + error.message = + `Failed to fetch browser webSocket URL from ${endpointURL}: ` + + error.message; + throw error; + } +} diff --git a/remote/test/puppeteer/src/common/BrowserWebSocketTransport.ts b/remote/test/puppeteer/src/common/BrowserWebSocketTransport.ts new file mode 100644 index 0000000000..9d0e5c4592 --- /dev/null +++ b/remote/test/puppeteer/src/common/BrowserWebSocketTransport.ts @@ -0,0 +1,55 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { ConnectionTransport } from './ConnectionTransport.js'; + +export class BrowserWebSocketTransport implements ConnectionTransport { + static create(url: string): Promise<BrowserWebSocketTransport> { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url); + + ws.addEventListener('open', () => + resolve(new BrowserWebSocketTransport(ws)) + ); + ws.addEventListener('error', reject); + }); + } + + private _ws: WebSocket; + onmessage?: (message: string) => void; + onclose?: () => void; + + constructor(ws: WebSocket) { + this._ws = ws; + this._ws.addEventListener('message', (event) => { + if (this.onmessage) this.onmessage.call(null, event.data); + }); + this._ws.addEventListener('close', () => { + if (this.onclose) this.onclose.call(null); + }); + // Silently ignore all errors - we don't know what to do with them. + this._ws.addEventListener('error', () => {}); + this.onmessage = null; + this.onclose = null; + } + + send(message: string): void { + this._ws.send(message); + } + + close(): void { + this._ws.close(); + } +} diff --git a/remote/test/puppeteer/src/common/Connection.ts b/remote/test/puppeteer/src/common/Connection.ts new file mode 100644 index 0000000000..d1383157fa --- /dev/null +++ b/remote/test/puppeteer/src/common/Connection.ts @@ -0,0 +1,347 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { assert } from './assert.js'; +import { debug } from './Debug.js'; +const debugProtocolSend = debug('puppeteer:protocol:SEND ►'); +const debugProtocolReceive = debug('puppeteer:protocol:RECV ◀'); + +import { Protocol } from 'devtools-protocol'; +import { ProtocolMapping } from 'devtools-protocol/types/protocol-mapping.js'; +import { ConnectionTransport } from './ConnectionTransport.js'; +import { EventEmitter } from './EventEmitter.js'; + +interface ConnectionCallback { + resolve: Function; + reject: Function; + error: Error; + method: string; +} + +/** + * Internal events that the Connection class emits. + * + * @internal + */ +export const ConnectionEmittedEvents = { + Disconnected: Symbol('Connection.Disconnected'), +} as const; + +/** + * @internal + */ +export class Connection extends EventEmitter { + _url: string; + _transport: ConnectionTransport; + _delay: number; + _lastId = 0; + _sessions: Map<string, CDPSession> = new Map(); + _closed = false; + + _callbacks: Map<number, ConnectionCallback> = new Map(); + + constructor(url: string, transport: ConnectionTransport, delay = 0) { + super(); + this._url = url; + this._delay = delay; + + this._transport = transport; + this._transport.onmessage = this._onMessage.bind(this); + this._transport.onclose = this._onClose.bind(this); + } + + static fromSession(session: CDPSession): Connection { + return session._connection; + } + + /** + * @param {string} sessionId + * @returns {?CDPSession} + */ + session(sessionId: string): CDPSession | null { + return this._sessions.get(sessionId) || null; + } + + url(): string { + return this._url; + } + + send<T extends keyof ProtocolMapping.Commands>( + method: T, + ...paramArgs: ProtocolMapping.Commands[T]['paramsType'] + ): 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. + const params = paramArgs.length ? paramArgs[0] : undefined; + const id = this._rawSend({ method, params }); + return new Promise((resolve, reject) => { + this._callbacks.set(id, { resolve, reject, error: new Error(), method }); + }); + } + + _rawSend(message: Record<string, unknown>): number { + const id = ++this._lastId; + const stringifiedMessage = JSON.stringify( + Object.assign({}, message, { id }) + ); + debugProtocolSend(stringifiedMessage); + this._transport.send(stringifiedMessage); + return id; + } + + async _onMessage(message: string): Promise<void> { + if (this._delay) await new Promise((f) => setTimeout(f, this._delay)); + debugProtocolReceive(message); + const object = JSON.parse(message); + if (object.method === 'Target.attachedToTarget') { + const sessionId = object.params.sessionId; + const session = new CDPSession( + this, + object.params.targetInfo.type, + sessionId + ); + this._sessions.set(sessionId, 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); + } + } + if (object.sessionId) { + const session = this._sessions.get(object.sessionId); + if (session) session._onMessage(object); + } else if (object.id) { + const callback = this._callbacks.get(object.id); + // Callbacks could be all rejected if someone has called `.dispose()`. + if (callback) { + this._callbacks.delete(object.id); + if (object.error) + callback.reject( + createProtocolError(callback.error, callback.method, object) + ); + else callback.resolve(object.result); + } + } else { + this.emit(object.method, object.params); + } + } + + _onClose(): void { + if (this._closed) return; + this._closed = true; + this._transport.onmessage = null; + this._transport.onclose = null; + for (const callback of this._callbacks.values()) + callback.reject( + rewriteError( + callback.error, + `Protocol error (${callback.method}): Target closed.` + ) + ); + this._callbacks.clear(); + for (const session of this._sessions.values()) session._onClosed(); + this._sessions.clear(); + this.emit(ConnectionEmittedEvents.Disconnected); + } + + dispose(): void { + this._onClose(); + this._transport.close(); + } + + /** + * @param {Protocol.Target.TargetInfo} targetInfo + * @returns {!Promise<!CDPSession>} + */ + async createSession( + targetInfo: Protocol.Target.TargetInfo + ): Promise<CDPSession> { + const { sessionId } = await this.send('Target.attachToTarget', { + targetId: targetInfo.targetId, + flatten: true, + }); + return this._sessions.get(sessionId); + } +} + +interface CDPSessionOnMessageObject { + id?: number; + method: string; + params: Record<string, unknown>; + error: { message: string; data: any }; + result?: any; +} + +/** + * Internal events that the CDPSession class emits. + * + * @internal + */ +export const CDPSessionEmittedEvents = { + Disconnected: Symbol('CDPSession.Disconnected'), +} as const; + +/** + * The `CDPSession` instances are used to talk raw Chrome Devtools Protocol. + * + * @remarks + * + * Protocol methods can be called with {@link CDPSession.send} method and protocol + * events can be subscribed to with `CDPSession.on` method. + * + * Useful links: {@link https://chromedevtools.github.io/devtools-protocol/ | DevTools Protocol Viewer} + * and {@link https://github.com/aslushnikov/getting-started-with-cdp/blob/master/README.md | Getting Started with DevTools Protocol}. + * + * @example + * ```js + * const client = await page.target().createCDPSession(); + * await client.send('Animation.enable'); + * client.on('Animation.animationCreated', () => console.log('Animation created!')); + * const response = await client.send('Animation.getPlaybackRate'); + * console.log('playback rate is ' + response.playbackRate); + * await client.send('Animation.setPlaybackRate', { + * playbackRate: response.playbackRate / 2 + * }); + * ``` + * + * @public + */ +export class CDPSession extends EventEmitter { + /** + * @internal + */ + _connection: Connection; + private _sessionId: string; + private _targetType: string; + private _callbacks: Map<number, ConnectionCallback> = new Map(); + + /** + * @internal + */ + constructor(connection: Connection, targetType: string, sessionId: string) { + super(); + this._connection = connection; + this._targetType = targetType; + this._sessionId = sessionId; + } + + send<T extends keyof ProtocolMapping.Commands>( + method: T, + ...paramArgs: ProtocolMapping.Commands[T]['paramsType'] + ): Promise<ProtocolMapping.Commands[T]['returnType']> { + if (!this._connection) + return Promise.reject( + new Error( + `Protocol error (${method}): Session closed. Most likely the ${this._targetType} has been closed.` + ) + ); + + // See the comment in Connection#send explaining why we do this. + const params = paramArgs.length ? paramArgs[0] : undefined; + + const id = this._connection._rawSend({ + sessionId: this._sessionId, + method, + /* TODO(jacktfranklin@): once this Firefox bug is solved + * we no longer need the `|| {}` check + * https://bugzilla.mozilla.org/show_bug.cgi?id=1631570 + */ + params: params || {}, + }); + + return new Promise((resolve, reject) => { + this._callbacks.set(id, { resolve, reject, error: new Error(), method }); + }); + } + + /** + * @internal + */ + _onMessage(object: CDPSessionOnMessageObject): void { + if (object.id && this._callbacks.has(object.id)) { + const callback = this._callbacks.get(object.id); + this._callbacks.delete(object.id); + if (object.error) + callback.reject( + createProtocolError(callback.error, callback.method, object) + ); + else callback.resolve(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. + */ + 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 { + for (const callback of this._callbacks.values()) + callback.reject( + rewriteError( + callback.error, + `Protocol error (${callback.method}): Target closed.` + ) + ); + this._callbacks.clear(); + this._connection = null; + this.emit(CDPSessionEmittedEvents.Disconnected); + } +} + +/** + * @param {!Error} error + * @param {string} method + * @param {{error: {message: string, data: any}}} object + * @returns {!Error} + */ +function createProtocolError( + error: Error, + method: string, + object: { error: { message: string; data: any } } +): Error { + let message = `Protocol error (${method}): ${object.error.message}`; + if ('data' in object.error) message += ` ${object.error.data}`; + return rewriteError(error, message); +} + +/** + * @param {!Error} error + * @param {string} message + * @returns {!Error} + */ +function rewriteError(error: Error, message: string): Error { + error.message = message; + return error; +} diff --git a/remote/test/puppeteer/src/common/ConnectionTransport.ts b/remote/test/puppeteer/src/common/ConnectionTransport.ts new file mode 100644 index 0000000000..fc3deddb40 --- /dev/null +++ b/remote/test/puppeteer/src/common/ConnectionTransport.ts @@ -0,0 +1,22 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface ConnectionTransport { + send(string); + close(); + onmessage?: (message: string) => void; + onclose?: () => void; +} diff --git a/remote/test/puppeteer/src/common/ConsoleMessage.ts b/remote/test/puppeteer/src/common/ConsoleMessage.ts new file mode 100644 index 0000000000..3387dc59d0 --- /dev/null +++ b/remote/test/puppeteer/src/common/ConsoleMessage.ts @@ -0,0 +1,122 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { JSHandle } from './JSHandle.js'; + +/** + * @public + */ +export interface ConsoleMessageLocation { + /** + * URL of the resource if known or `undefined` otherwise. + */ + url?: string; + + /** + * 0-based line number in the resource if known or `undefined` otherwise. + */ + lineNumber?: number; + + /** + * 0-based column number in the resource if known or `undefined` otherwise. + */ + columnNumber?: number; +} + +/** + * The supported types for console messages. + */ +export type ConsoleMessageType = + | 'log' + | 'debug' + | 'info' + | 'error' + | 'warning' + | 'dir' + | 'dirxml' + | 'table' + | 'trace' + | 'clear' + | 'startGroup' + | 'startGroupCollapsed' + | 'endGroup' + | 'assert' + | 'profile' + | 'profileEnd' + | 'count' + | 'timeEnd' + | 'verbose'; + +/** + * ConsoleMessage objects are dispatched by page via the 'console' event. + * @public + */ +export class ConsoleMessage { + private _type: ConsoleMessageType; + private _text: string; + private _args: JSHandle[]; + private _stackTraceLocations: ConsoleMessageLocation[]; + + /** + * @public + */ + constructor( + type: ConsoleMessageType, + text: string, + args: JSHandle[], + stackTraceLocations: ConsoleMessageLocation[] + ) { + this._type = type; + this._text = text; + this._args = args; + this._stackTraceLocations = stackTraceLocations; + } + + /** + * @returns The type of the console message. + */ + type(): ConsoleMessageType { + return this._type; + } + + /** + * @returns The text of the console message. + */ + text(): string { + return this._text; + } + + /** + * @returns An array of arguments passed to the console. + */ + args(): JSHandle[] { + return this._args; + } + + /** + * @returns The location of the console message. + */ + location(): ConsoleMessageLocation { + return this._stackTraceLocations.length ? this._stackTraceLocations[0] : {}; + } + + /** + * @returns The array of locations on the stack of the console message. + */ + stackTrace(): ConsoleMessageLocation[] { + return this._stackTraceLocations; + } +} diff --git a/remote/test/puppeteer/src/common/Coverage.ts b/remote/test/puppeteer/src/common/Coverage.ts new file mode 100644 index 0000000000..63060e656a --- /dev/null +++ b/remote/test/puppeteer/src/common/Coverage.ts @@ -0,0 +1,425 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { assert } from './assert.js'; +import { helper, debugError, PuppeteerEventListener } from './helper.js'; +import { Protocol } from 'devtools-protocol'; +import { CDPSession } from './Connection.js'; + +import { EVALUATION_SCRIPT_URL } from './ExecutionContext.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 }>; +} + +/** + * 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; +} + +/** + * 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 gathers 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: + * ```js + * // 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 { + /** + * @internal + */ + _jsCoverage: JSCoverage; + /** + * @internal + */ + _cssCoverage: CSSCoverage; + + constructor(client: CDPSession) { + this._jsCoverage = new JSCoverage(client); + this._cssCoverage = new CSSCoverage(client); + } + + /** + * @param options - defaults to + * `{ resetOnNavigation : true, reportAnonymousScripts : false }` + * @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 will have `__puppeteer_evaluation_script__` as their URL. + */ + async startJSCoverage(options: JSCoverageOptions = {}): Promise<void> { + return await this._jsCoverage.start(options); + } + + /** + * @returns 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<CoverageEntry[]> { + return await this._jsCoverage.stop(); + } + + /** + * @param options - defaults to `{ resetOnNavigation : true }` + * @returns Promise that resolves when coverage is started. + */ + async startCSSCoverage(options: CSSCoverageOptions = {}): Promise<void> { + return await this._cssCoverage.start(options); + } + + /** + * @returns 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(); + } +} + +class JSCoverage { + _client: CDPSession; + _enabled = false; + _scriptURLs = new Map<string, string>(); + _scriptSources = new Map<string, string>(); + _eventListeners: PuppeteerEventListener[] = []; + _resetOnNavigation = false; + _reportAnonymousScripts = false; + + constructor(client: CDPSession) { + this._client = client; + } + + async start( + options: { + resetOnNavigation?: boolean; + reportAnonymousScripts?: boolean; + } = {} + ): Promise<void> { + assert(!this._enabled, 'JSCoverage is already enabled'); + const { + resetOnNavigation = true, + reportAnonymousScripts = false, + } = options; + this._resetOnNavigation = resetOnNavigation; + this._reportAnonymousScripts = reportAnonymousScripts; + this._enabled = true; + this._scriptURLs.clear(); + this._scriptSources.clear(); + this._eventListeners = [ + helper.addEventListener( + this._client, + 'Debugger.scriptParsed', + this._onScriptParsed.bind(this) + ), + helper.addEventListener( + this._client, + 'Runtime.executionContextsCleared', + this._onExecutionContextsCleared.bind(this) + ), + ]; + await Promise.all([ + this._client.send('Profiler.enable'), + this._client.send('Profiler.startPreciseCoverage', { + callCount: false, + detailed: true, + }), + 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 (event.url === EVALUATION_SCRIPT_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<CoverageEntry[]> { + assert(this._enabled, 'JSCoverage is not enabled'); + this._enabled = false; + + const result = await Promise.all< + Protocol.Profiler.TakePreciseCoverageResponse, + void, + void, + void + >([ + this._client.send('Profiler.takePreciseCoverage'), + this._client.send('Profiler.stopPreciseCoverage'), + this._client.send('Profiler.disable'), + this._client.send('Debugger.disable'), + ]); + + helper.removeEventListeners(this._eventListeners); + + 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); + coverage.push({ url, ranges, text }); + } + return coverage; + } +} + +class CSSCoverage { + _client: CDPSession; + _enabled = false; + _stylesheetURLs = new Map<string, string>(); + _stylesheetSources = new Map<string, string>(); + _eventListeners: PuppeteerEventListener[] = []; + _resetOnNavigation = false; + _reportAnonymousScripts = false; + + constructor(client: CDPSession) { + 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 = [ + helper.addEventListener( + this._client, + 'CSS.styleSheetAdded', + this._onStyleSheet.bind(this) + ), + helper.addEventListener( + 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'), + ]); + helper.removeEventListeners(this._eventListeners); + + // 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 = []; + for (const styleSheetId of this._stylesheetURLs.keys()) { + const url = this._stylesheetURLs.get(styleSheetId); + const text = this._stylesheetSources.get(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 = []; + 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.length ? results[results.length - 1] : null; + 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) => range.end - range.start > 1); +} diff --git a/remote/test/puppeteer/src/common/DOMWorld.ts b/remote/test/puppeteer/src/common/DOMWorld.ts new file mode 100644 index 0000000000..ae74ab9caf --- /dev/null +++ b/remote/test/puppeteer/src/common/DOMWorld.ts @@ -0,0 +1,940 @@ +/** + * Copyright 2019 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { assert } from './assert.js'; +import { helper, debugError } from './helper.js'; +import { + LifecycleWatcher, + PuppeteerLifeCycleEvent, +} from './LifecycleWatcher.js'; +import { TimeoutError } from './Errors.js'; +import { JSHandle, ElementHandle } from './JSHandle.js'; +import { ExecutionContext } from './ExecutionContext.js'; +import { TimeoutSettings } from './TimeoutSettings.js'; +import { MouseButton } from './Input.js'; +import { FrameManager, Frame } from './FrameManager.js'; +import { getQueryHandlerAndSelector } from './QueryHandler.js'; +import { + SerializableOrJSHandle, + EvaluateHandleFn, + WrapElementHandle, + EvaluateFn, + EvaluateFnReturnType, + UnwrapPromiseLike, +} from './EvalTypes.js'; +import { isNode } from '../environment.js'; +import { Protocol } from 'devtools-protocol'; + +// predicateQueryHandler and checkWaitForOptions are declared here so that +// TypeScript knows about them when used in the predicate function below. +declare const predicateQueryHandler: ( + element: Element | Document, + selector: string +) => Promise<Element | Element[] | NodeListOf<Element>>; +declare const checkWaitForOptions: ( + node: Node, + waitForVisible: boolean, + waitForHidden: boolean +) => Element | null | boolean; + +/** + * @public + */ +export interface WaitForSelectorOptions { + visible?: boolean; + hidden?: boolean; + timeout?: number; +} + +/** + * @internal + */ +export interface PageBinding { + name: string; + pptrFunction: Function; +} + +/** + * @internal + */ +export class DOMWorld { + private _frameManager: FrameManager; + private _frame: Frame; + private _timeoutSettings: TimeoutSettings; + private _documentPromise?: Promise<ElementHandle> = null; + private _contextPromise?: Promise<ExecutionContext> = null; + + private _contextResolveCallback?: (x?: ExecutionContext) => void = null; + + private _detached = false; + /** + * @internal + */ + _waitTasks = new Set<WaitTask>(); + + /** + * @internal + * Contains mapping from functions that should be bound to Puppeteer functions. + */ + _boundFunctions = new Map<string, Function>(); + // Set of bindings that have been registered in the current context. + private _ctxBindings = new Set<string>(); + private static bindingIdentifier = (name: string, contextId: number) => + `${name}_${contextId}`; + + constructor( + frameManager: FrameManager, + frame: Frame, + timeoutSettings: TimeoutSettings + ) { + this._frameManager = frameManager; + this._frame = frame; + this._timeoutSettings = timeoutSettings; + this._setContext(null); + frameManager._client.on('Runtime.bindingCalled', (event) => + this._onBindingCalled(event) + ); + } + + frame(): Frame { + return this._frame; + } + + async _setContext(context?: ExecutionContext): Promise<void> { + if (context) { + this._contextResolveCallback.call(null, context); + this._contextResolveCallback = null; + for (const waitTask of this._waitTasks) waitTask.rerun(); + } else { + this._documentPromise = null; + this._contextPromise = new Promise((fulfill) => { + this._contextResolveCallback = fulfill; + }); + } + } + + _hasContext(): boolean { + return !this._contextResolveCallback; + } + + _detach(): void { + this._detached = true; + for (const waitTask of this._waitTasks) + waitTask.terminate( + new Error('waitForFunction failed: frame got detached.') + ); + } + + executionContext(): Promise<ExecutionContext> { + if (this._detached) + throw new Error( + `Execution context is not available in detached frame "${this._frame.url()}" (are you trying to evaluate?)` + ); + return this._contextPromise; + } + + async evaluateHandle<HandlerType extends JSHandle = JSHandle>( + pageFunction: EvaluateHandleFn, + ...args: SerializableOrJSHandle[] + ): Promise<HandlerType> { + const context = await this.executionContext(); + return context.evaluateHandle(pageFunction, ...args); + } + + async evaluate<T extends EvaluateFn>( + pageFunction: T, + ...args: SerializableOrJSHandle[] + ): Promise<UnwrapPromiseLike<EvaluateFnReturnType<T>>> { + const context = await this.executionContext(); + return context.evaluate<UnwrapPromiseLike<EvaluateFnReturnType<T>>>( + pageFunction, + ...args + ); + } + + async $(selector: string): Promise<ElementHandle | null> { + const document = await this._document(); + const value = await document.$(selector); + return value; + } + + async _document(): Promise<ElementHandle> { + if (this._documentPromise) return this._documentPromise; + this._documentPromise = this.executionContext().then(async (context) => { + const document = await context.evaluateHandle('document'); + return document.asElement(); + }); + return this._documentPromise; + } + + async $x(expression: string): Promise<ElementHandle[]> { + const document = await this._document(); + const value = await document.$x(expression); + return value; + } + + async $eval<ReturnType>( + selector: string, + pageFunction: ( + element: Element, + ...args: unknown[] + ) => ReturnType | Promise<ReturnType>, + ...args: SerializableOrJSHandle[] + ): Promise<WrapElementHandle<ReturnType>> { + const document = await this._document(); + return document.$eval<ReturnType>(selector, pageFunction, ...args); + } + + async $$eval<ReturnType>( + selector: string, + pageFunction: ( + elements: Element[], + ...args: unknown[] + ) => ReturnType | Promise<ReturnType>, + ...args: SerializableOrJSHandle[] + ): Promise<WrapElementHandle<ReturnType>> { + const document = await this._document(); + const value = await document.$$eval<ReturnType>( + selector, + pageFunction, + ...args + ); + return value; + } + + async $$(selector: string): Promise<ElementHandle[]> { + const document = await this._document(); + const value = await document.$$(selector); + return value; + } + + async content(): Promise<string> { + return await this.evaluate(() => { + let retVal = ''; + if (document.doctype) + retVal = new XMLSerializer().serializeToString(document.doctype); + if (document.documentElement) + retVal += document.documentElement.outerHTML; + return retVal; + }); + } + + async setContent( + html: string, + options: { + timeout?: number; + waitUntil?: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[]; + } = {} + ): Promise<void> { + const { + waitUntil = ['load'], + timeout = this._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.evaluate<(x: string) => void>((html) => { + document.open(); + document.write(html); + document.close(); + }, html); + const watcher = new LifecycleWatcher( + this._frameManager, + this._frame, + waitUntil, + timeout + ); + const error = await Promise.race([ + watcher.timeoutOrTerminationPromise(), + watcher.lifecyclePromise(), + ]); + watcher.dispose(); + if (error) throw error; + } + + /** + * Adds a script tag into the current context. + * + * @remarks + * + * You can pass a URL, filepath or string of contents. Note that when running Puppeteer + * in a browser environment you cannot pass a filepath and should use either + * `url` or `content`. + */ + async addScriptTag(options: { + url?: string; + path?: string; + content?: string; + type?: string; + }): Promise<ElementHandle> { + const { url = null, path = null, content = null, type = '' } = options; + if (url !== null) { + try { + const context = await this.executionContext(); + return ( + await context.evaluateHandle(addScriptUrl, url, type) + ).asElement(); + } catch (error) { + throw new Error(`Loading script from ${url} failed`); + } + } + + if (path !== null) { + if (!isNode) { + throw new Error( + 'Cannot pass a filepath to addScriptTag in the browser environment.' + ); + } + const fs = await helper.importFSModule(); + let contents = await fs.promises.readFile(path, 'utf8'); + contents += '//# sourceURL=' + path.replace(/\n/g, ''); + const context = await this.executionContext(); + return ( + await context.evaluateHandle(addScriptContent, contents, type) + ).asElement(); + } + + if (content !== null) { + const context = await this.executionContext(); + return ( + await context.evaluateHandle(addScriptContent, content, type) + ).asElement(); + } + + throw new Error( + 'Provide an object with a `url`, `path` or `content` property' + ); + + async function addScriptUrl( + url: string, + type: string + ): Promise<HTMLElement> { + const script = document.createElement('script'); + script.src = url; + if (type) script.type = type; + const promise = new Promise((res, rej) => { + script.onload = res; + script.onerror = rej; + }); + document.head.appendChild(script); + await promise; + return script; + } + + function addScriptContent( + content: string, + type = 'text/javascript' + ): HTMLElement { + const script = document.createElement('script'); + script.type = type; + script.text = content; + let error = null; + script.onerror = (e) => (error = e); + document.head.appendChild(script); + if (error) throw error; + return script; + } + } + + /** + * Adds a style tag into the current context. + * + * @remarks + * + * You can pass a URL, filepath or string of contents. Note that when running Puppeteer + * in a browser environment you cannot pass a filepath and should use either + * `url` or `content`. + * + */ + async addStyleTag(options: { + url?: string; + path?: string; + content?: string; + }): Promise<ElementHandle> { + const { url = null, path = null, content = null } = options; + if (url !== null) { + try { + const context = await this.executionContext(); + return (await context.evaluateHandle(addStyleUrl, url)).asElement(); + } catch (error) { + throw new Error(`Loading style from ${url} failed`); + } + } + + if (path !== null) { + if (!isNode) { + throw new Error( + 'Cannot pass a filepath to addStyleTag in the browser environment.' + ); + } + const fs = await helper.importFSModule(); + let contents = await fs.promises.readFile(path, 'utf8'); + contents += '/*# sourceURL=' + path.replace(/\n/g, '') + '*/'; + const context = await this.executionContext(); + return ( + await context.evaluateHandle(addStyleContent, contents) + ).asElement(); + } + + if (content !== null) { + const context = await this.executionContext(); + return ( + await context.evaluateHandle(addStyleContent, content) + ).asElement(); + } + + throw new Error( + 'Provide an object with a `url`, `path` or `content` property' + ); + + async function addStyleUrl(url: string): Promise<HTMLElement> { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = url; + const promise = new Promise((res, rej) => { + link.onload = res; + link.onerror = rej; + }); + document.head.appendChild(link); + await promise; + return link; + } + + async function addStyleContent(content: string): Promise<HTMLElement> { + const style = document.createElement('style'); + style.type = 'text/css'; + style.appendChild(document.createTextNode(content)); + const promise = new Promise((res, rej) => { + style.onload = res; + style.onerror = rej; + }); + document.head.appendChild(style); + await promise; + return style; + } + } + + async click( + selector: string, + options: { delay?: number; button?: MouseButton; clickCount?: number } + ): Promise<void> { + const handle = await this.$(selector); + assert(handle, 'No node found for selector: ' + selector); + await handle.click(options); + await handle.dispose(); + } + + async focus(selector: string): Promise<void> { + const handle = await this.$(selector); + assert(handle, 'No node found for selector: ' + selector); + await handle.focus(); + await handle.dispose(); + } + + async hover(selector: string): Promise<void> { + const handle = await this.$(selector); + assert(handle, 'No node found for selector: ' + selector); + await handle.hover(); + await handle.dispose(); + } + + async select(selector: string, ...values: string[]): Promise<string[]> { + const handle = await this.$(selector); + assert(handle, 'No node found for selector: ' + selector); + const result = await handle.select(...values); + await handle.dispose(); + return result; + } + + async tap(selector: string): Promise<void> { + const handle = await this.$(selector); + await handle.tap(); + await handle.dispose(); + } + + async type( + selector: string, + text: string, + options?: { delay: number } + ): Promise<void> { + const handle = await this.$(selector); + assert(handle, 'No node found for selector: ' + selector); + await handle.type(text, options); + await handle.dispose(); + } + + async waitForSelector( + selector: string, + options: WaitForSelectorOptions + ): Promise<ElementHandle | null> { + const { updatedSelector, queryHandler } = getQueryHandlerAndSelector( + selector + ); + return queryHandler.waitFor(this, updatedSelector, options); + } + + // 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. + private _settingUpBinding: Promise<void> | null = null; + /** + * @internal + */ + async addBindingToContext( + context: ExecutionContext, + name: string + ): Promise<void> { + // Previous operation added the binding so we are done. + if ( + this._ctxBindings.has( + DOMWorld.bindingIdentifier(name, context._contextId) + ) + ) { + return; + } + // Wait for other operation to finish + if (this._settingUpBinding) { + await this._settingUpBinding; + return this.addBindingToContext(context, name); + } + + const bind = async (name: string) => { + const expression = helper.pageBindingInitString('internal', name); + try { + await context._client.send('Runtime.addBinding', { + name, + executionContextId: context._contextId, + }); + await context.evaluate(expression); + } 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 + const ctxDestroyed = error.message.includes( + 'Execution context was destroyed' + ); + const ctxNotFound = error.message.includes( + 'Cannot find context with specified id' + ); + if (ctxDestroyed || ctxNotFound) { + return; + } else { + debugError(error); + return; + } + } + this._ctxBindings.add( + DOMWorld.bindingIdentifier(name, context._contextId) + ); + }; + + this._settingUpBinding = bind(name); + await this._settingUpBinding; + this._settingUpBinding = null; + } + + private async _onBindingCalled( + event: Protocol.Runtime.BindingCalledEvent + ): Promise<void> { + let payload: { type: string; name: string; seq: number; args: unknown[] }; + if (!this._hasContext()) return; + const context = await this.executionContext(); + 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 } = payload; + if ( + type !== 'internal' || + !this._ctxBindings.has( + DOMWorld.bindingIdentifier(name, context._contextId) + ) + ) + return; + if (context._contextId !== event.executionContextId) return; + try { + const result = await this._boundFunctions.get(name)(...args); + await context.evaluate(deliverResult, name, seq, result); + } catch (error) { + // The WaitTask may already have been resolved by timing out, or the + // exection context may have been destroyed. + // In both caes, the promises above are rejected with a protocol error. + // We can safely ignores these, as the WaitTask is re-installed in + // the next execution context if needed. + if (error.message.includes('Protocol error')) return; + debugError(error); + } + function deliverResult(name: string, seq: number, result: unknown): void { + globalThis[name].callbacks.get(seq).resolve(result); + globalThis[name].callbacks.delete(seq); + } + } + + /** + * @internal + */ + async waitForSelectorInPage( + queryOne: Function, + selector: string, + options: WaitForSelectorOptions, + binding?: PageBinding + ): Promise<ElementHandle | null> { + const { + visible: waitForVisible = false, + hidden: waitForHidden = false, + timeout = this._timeoutSettings.timeout(), + } = options; + const polling = waitForVisible || waitForHidden ? 'raf' : 'mutation'; + const title = `selector \`${selector}\`${ + waitForHidden ? ' to be hidden' : '' + }`; + async function predicate( + selector: string, + waitForVisible: boolean, + waitForHidden: boolean + ): Promise<Node | null | boolean> { + const node = predicateQueryHandler + ? ((await predicateQueryHandler(document, selector)) as Element) + : document.querySelector(selector); + return checkWaitForOptions(node, waitForVisible, waitForHidden); + } + const waitTaskOptions: WaitTaskOptions = { + domWorld: this, + predicateBody: helper.makePredicateString(predicate, queryOne), + title, + polling, + timeout, + args: [selector, waitForVisible, waitForHidden], + binding, + }; + const waitTask = new WaitTask(waitTaskOptions); + const jsHandle = await waitTask.promise; + const elementHandle = jsHandle.asElement(); + if (!elementHandle) { + await jsHandle.dispose(); + return null; + } + return elementHandle; + } + + async waitForXPath( + xpath: string, + options: WaitForSelectorOptions + ): Promise<ElementHandle | null> { + const { + visible: waitForVisible = false, + hidden: waitForHidden = false, + timeout = this._timeoutSettings.timeout(), + } = options; + const polling = waitForVisible || waitForHidden ? 'raf' : 'mutation'; + const title = `XPath \`${xpath}\`${waitForHidden ? ' to be hidden' : ''}`; + function predicate( + xpath: string, + waitForVisible: boolean, + waitForHidden: boolean + ): Node | null | boolean { + const node = document.evaluate( + xpath, + document, + null, + XPathResult.FIRST_ORDERED_NODE_TYPE, + null + ).singleNodeValue; + return checkWaitForOptions(node, waitForVisible, waitForHidden); + } + const waitTaskOptions: WaitTaskOptions = { + domWorld: this, + predicateBody: helper.makePredicateString(predicate), + title, + polling, + timeout, + args: [xpath, waitForVisible, waitForHidden], + }; + const waitTask = new WaitTask(waitTaskOptions); + const jsHandle = await waitTask.promise; + const elementHandle = jsHandle.asElement(); + if (!elementHandle) { + await jsHandle.dispose(); + return null; + } + return elementHandle; + } + + waitForFunction( + pageFunction: Function | string, + options: { polling?: string | number; timeout?: number } = {}, + ...args: SerializableOrJSHandle[] + ): Promise<JSHandle> { + const { + polling = 'raf', + timeout = this._timeoutSettings.timeout(), + } = options; + const waitTaskOptions: WaitTaskOptions = { + domWorld: this, + predicateBody: pageFunction, + title: 'function', + polling, + timeout, + args, + }; + const waitTask = new WaitTask(waitTaskOptions); + return waitTask.promise; + } + + async title(): Promise<string> { + return this.evaluate(() => document.title); + } +} + +/** + * @internal + */ +export interface WaitTaskOptions { + domWorld: DOMWorld; + predicateBody: Function | string; + title: string; + polling: string | number; + timeout: number; + binding?: PageBinding; + args: SerializableOrJSHandle[]; +} + +/** + * @internal + */ +export class WaitTask { + _domWorld: DOMWorld; + _polling: string | number; + _timeout: number; + _predicateBody: string; + _args: SerializableOrJSHandle[]; + _binding: PageBinding; + _runCount = 0; + promise: Promise<JSHandle>; + _resolve: (x: JSHandle) => void; + _reject: (x: Error) => void; + _timeoutTimer?: NodeJS.Timeout; + _terminated = false; + + constructor(options: WaitTaskOptions) { + if (helper.isString(options.polling)) + assert( + options.polling === 'raf' || options.polling === 'mutation', + 'Unknown polling option: ' + options.polling + ); + else if (helper.isNumber(options.polling)) + assert( + options.polling > 0, + 'Cannot poll with non-positive interval: ' + options.polling + ); + else throw new Error('Unknown polling options: ' + options.polling); + + function getPredicateBody(predicateBody: Function | string) { + if (helper.isString(predicateBody)) return `return (${predicateBody});`; + return `return (${predicateBody})(...args);`; + } + + this._domWorld = options.domWorld; + this._polling = options.polling; + this._timeout = options.timeout; + this._predicateBody = getPredicateBody(options.predicateBody); + this._args = options.args; + this._binding = options.binding; + this._runCount = 0; + this._domWorld._waitTasks.add(this); + if (this._binding) { + this._domWorld._boundFunctions.set( + this._binding.name, + this._binding.pptrFunction + ); + } + this.promise = new Promise<JSHandle>((resolve, reject) => { + this._resolve = resolve; + this._reject = reject; + }); + // Since page navigation requires us to re-install the pageScript, we should track + // timeout on our end. + if (options.timeout) { + const timeoutError = new TimeoutError( + `waiting for ${options.title} failed: timeout ${options.timeout}ms exceeded` + ); + this._timeoutTimer = setTimeout( + () => this.terminate(timeoutError), + options.timeout + ); + } + this.rerun(); + } + + terminate(error: Error): void { + this._terminated = true; + this._reject(error); + this._cleanup(); + } + + async rerun(): Promise<void> { + const runCount = ++this._runCount; + let success: JSHandle = null; + let error: Error = null; + const context = await this._domWorld.executionContext(); + if (this._terminated || runCount !== this._runCount) return; + if (this._binding) { + await this._domWorld.addBindingToContext(context, this._binding.name); + } + if (this._terminated || runCount !== this._runCount) return; + try { + success = await context.evaluateHandle( + waitForPredicatePageFunction, + this._predicateBody, + this._polling, + this._timeout, + ...this._args + ); + } catch (error_) { + error = error_; + } + + if (this._terminated || runCount !== this._runCount) { + if (success) await success.dispose(); + return; + } + + // Ignore timeouts in pageScript - we track timeouts ourselves. + // If the frame's execution context has already changed, `frame.evaluate` will + // throw an error - ignore this predicate run altogether. + if ( + !error && + (await this._domWorld.evaluate((s) => !s, success).catch(() => true)) + ) { + await success.dispose(); + return; + } + if (error) { + if (error.message.includes('TypeError: binding is not a function')) { + return this.rerun(); + } + // When frame is detached the task should have been terminated by the DOMWorld. + // This can fail if we were adding this task while the frame was detached, + // so we terminate here instead. + if ( + error.message.includes( + 'Execution context is not available in detached frame' + ) + ) { + this.terminate( + new Error('waitForFunction failed: frame got detached.') + ); + return; + } + + // When the page is navigated, the promise is rejected. + // We will try again in the new execution context. + if (error.message.includes('Execution context was destroyed')) return; + + // We could have tried to evaluate in a context which was already + // destroyed. + if (error.message.includes('Cannot find context with specified id')) + return; + + this._reject(error); + } else { + this._resolve(success); + } + this._cleanup(); + } + + _cleanup(): void { + clearTimeout(this._timeoutTimer); + this._domWorld._waitTasks.delete(this); + } +} + +async function waitForPredicatePageFunction( + predicateBody: string, + polling: string, + timeout: number, + ...args: unknown[] +): Promise<unknown> { + const predicate = new Function('...args', predicateBody); + let timedOut = false; + if (timeout) setTimeout(() => (timedOut = true), timeout); + if (polling === 'raf') return await pollRaf(); + if (polling === 'mutation') return await pollMutation(); + if (typeof polling === 'number') return await pollInterval(polling); + + /** + * @returns {!Promise<*>} + */ + async function pollMutation(): Promise<unknown> { + const success = await predicate(...args); + if (success) return Promise.resolve(success); + + let fulfill; + const result = new Promise((x) => (fulfill = x)); + const observer = new MutationObserver(async () => { + if (timedOut) { + observer.disconnect(); + fulfill(); + } + const success = await predicate(...args); + if (success) { + observer.disconnect(); + fulfill(success); + } + }); + observer.observe(document, { + childList: true, + subtree: true, + attributes: true, + }); + return result; + } + + async function pollRaf(): Promise<unknown> { + let fulfill; + const result = new Promise((x) => (fulfill = x)); + await onRaf(); + return result; + + async function onRaf(): Promise<unknown> { + if (timedOut) { + fulfill(); + return; + } + const success = await predicate(...args); + if (success) fulfill(success); + else requestAnimationFrame(onRaf); + } + } + + async function pollInterval(pollInterval: number): Promise<unknown> { + let fulfill; + const result = new Promise((x) => (fulfill = x)); + await onTimeout(); + return result; + + async function onTimeout(): Promise<unknown> { + if (timedOut) { + fulfill(); + return; + } + const success = await predicate(...args); + if (success) fulfill(success); + else setTimeout(onTimeout, pollInterval); + } + } +} diff --git a/remote/test/puppeteer/src/common/Debug.ts b/remote/test/puppeteer/src/common/Debug.ts new file mode 100644 index 0000000000..8ff124b094 --- /dev/null +++ b/remote/test/puppeteer/src/common/Debug.ts @@ -0,0 +1,83 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { isNode } from '../environment.js'; + +/** + * A debug function that can be used in any environment. + * + * @remarks + * + * If used in Node, it falls back to the + * {@link https://www.npmjs.com/package/debug | debug module}. In the browser it + * uses `console.log`. + * + * @param prefix - this will be prefixed to each log. + * @returns a function that can be called to log to that debug channel. + * + * In Node, use the `DEBUG` environment variable to control logging: + * + * ``` + * DEBUG=* // logs all channels + * DEBUG=foo // logs the `foo` channel + * DEBUG=foo* // logs any channels starting with `foo` + * ``` + * + * In the browser, set `window.__PUPPETEER_DEBUG` to a string: + * + * ``` + * window.__PUPPETEER_DEBUG='*'; // logs all channels + * window.__PUPPETEER_DEBUG='foo'; // logs the `foo` channel + * window.__PUPPETEER_DEBUG='foo*'; // logs any channels starting with `foo` + * ``` + * + * @example + * ``` + * const log = debug('Page'); + * + * log('new page created') + * // logs "Page: new page created" + * ``` + */ +export const debug = (prefix: string): ((...args: unknown[]) => void) => { + if (isNode) { + // eslint-disable-next-line @typescript-eslint/no-var-requires + return require('debug')(prefix); + } + + return (...logArgs: unknown[]): void => { + const debugLevel = globalThis.__PUPPETEER_DEBUG as string; + if (!debugLevel) return; + + const everythingShouldBeLogged = debugLevel === '*'; + + const prefixMatchesDebugLevel = + everythingShouldBeLogged || + /** + * If the debug level is `foo*`, that means we match any prefix that + * starts with `foo`. If the level is `foo`, we match only the prefix + * `foo`. + */ + (debugLevel.endsWith('*') + ? prefix.startsWith(debugLevel) + : prefix === debugLevel); + + if (!prefixMatchesDebugLevel) return; + + // eslint-disable-next-line no-console + console.log(`${prefix}:`, ...logArgs); + }; +}; diff --git a/remote/test/puppeteer/src/common/DeviceDescriptors.ts b/remote/test/puppeteer/src/common/DeviceDescriptors.ts new file mode 100644 index 0000000000..a4a4960b94 --- /dev/null +++ b/remote/test/puppeteer/src/common/DeviceDescriptors.ts @@ -0,0 +1,964 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +interface Device { + name: string; + userAgent: string; + viewport: { + width: number; + height: number; + deviceScaleFactor: number; + isMobile: boolean; + hasTouch: boolean; + isLandscape: boolean; + }; +} + +const devices: Device[] = [ + { + name: 'Blackberry PlayBook', + userAgent: + 'Mozilla/5.0 (PlayBook; U; RIM Tablet OS 2.1.0; en-US) AppleWebKit/536.2+ (KHTML like Gecko) Version/7.2.1.0 Safari/536.2+', + viewport: { + width: 600, + height: 1024, + deviceScaleFactor: 1, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Blackberry PlayBook landscape', + userAgent: + 'Mozilla/5.0 (PlayBook; U; RIM Tablet OS 2.1.0; en-US) AppleWebKit/536.2+ (KHTML like Gecko) Version/7.2.1.0 Safari/536.2+', + viewport: { + width: 1024, + height: 600, + deviceScaleFactor: 1, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'BlackBerry Z30', + userAgent: + 'Mozilla/5.0 (BB10; Touch) AppleWebKit/537.10+ (KHTML, like Gecko) Version/10.0.9.2372 Mobile Safari/537.10+', + viewport: { + width: 360, + height: 640, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'BlackBerry Z30 landscape', + userAgent: + 'Mozilla/5.0 (BB10; Touch) AppleWebKit/537.10+ (KHTML, like Gecko) Version/10.0.9.2372 Mobile Safari/537.10+', + viewport: { + width: 640, + height: 360, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Galaxy Note 3', + userAgent: + 'Mozilla/5.0 (Linux; U; Android 4.3; en-us; SM-N900T Build/JSS15J) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30', + viewport: { + width: 360, + height: 640, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Galaxy Note 3 landscape', + userAgent: + 'Mozilla/5.0 (Linux; U; Android 4.3; en-us; SM-N900T Build/JSS15J) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30', + viewport: { + width: 640, + height: 360, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Galaxy Note II', + userAgent: + 'Mozilla/5.0 (Linux; U; Android 4.1; en-us; GT-N7100 Build/JRO03C) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30', + viewport: { + width: 360, + height: 640, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Galaxy Note II landscape', + userAgent: + 'Mozilla/5.0 (Linux; U; Android 4.1; en-us; GT-N7100 Build/JRO03C) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30', + viewport: { + width: 640, + height: 360, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Galaxy S III', + userAgent: + 'Mozilla/5.0 (Linux; U; Android 4.0; en-us; GT-I9300 Build/IMM76D) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30', + viewport: { + width: 360, + height: 640, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Galaxy S III landscape', + userAgent: + 'Mozilla/5.0 (Linux; U; Android 4.0; en-us; GT-I9300 Build/IMM76D) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30', + viewport: { + width: 640, + height: 360, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Galaxy S5', + userAgent: + 'Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 360, + height: 640, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Galaxy S5 landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 640, + height: 360, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPad', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1', + viewport: { + width: 768, + height: 1024, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPad landscape', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1', + viewport: { + width: 1024, + height: 768, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPad Mini', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1', + viewport: { + width: 768, + height: 1024, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPad Mini landscape', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1', + viewport: { + width: 1024, + height: 768, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPad Pro', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1', + viewport: { + width: 1024, + height: 1366, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPad Pro landscape', + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1', + viewport: { + width: 1366, + height: 1024, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone 4', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 7_1_2 like Mac OS X) AppleWebKit/537.51.2 (KHTML, like Gecko) Version/7.0 Mobile/11D257 Safari/9537.53', + viewport: { + width: 320, + height: 480, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone 4 landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 7_1_2 like Mac OS X) AppleWebKit/537.51.2 (KHTML, like Gecko) Version/7.0 Mobile/11D257 Safari/9537.53', + viewport: { + width: 480, + height: 320, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone 5', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1', + viewport: { + width: 320, + height: 568, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone 5 landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1', + viewport: { + width: 568, + height: 320, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone 6', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 375, + height: 667, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone 6 landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 667, + height: 375, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone 6 Plus', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 414, + height: 736, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone 6 Plus landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 736, + height: 414, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone 7', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 375, + height: 667, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone 7 landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 667, + height: 375, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone 7 Plus', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 414, + height: 736, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone 7 Plus landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 736, + height: 414, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone 8', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 375, + height: 667, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone 8 landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 667, + height: 375, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone 8 Plus', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 414, + height: 736, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone 8 Plus landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 736, + height: 414, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone SE', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1', + viewport: { + width: 320, + height: 568, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone SE landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1', + viewport: { + width: 568, + height: 320, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone X', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 375, + height: 812, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone X landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + viewport: { + width: 812, + height: 375, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'iPhone XR', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 12_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.0 Mobile/15E148 Safari/604.1', + viewport: { + width: 414, + height: 896, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'iPhone XR landscape', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 12_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.0 Mobile/15E148 Safari/604.1', + viewport: { + width: 896, + height: 414, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'JioPhone 2', + userAgent: + 'Mozilla/5.0 (Mobile; LYF/F300B/LYF-F300B-001-01-15-130718-i;Android; rv:48.0) Gecko/48.0 Firefox/48.0 KAIOS/2.5', + viewport: { + width: 240, + height: 320, + deviceScaleFactor: 1, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'JioPhone 2 landscape', + userAgent: + 'Mozilla/5.0 (Mobile; LYF/F300B/LYF-F300B-001-01-15-130718-i;Android; rv:48.0) Gecko/48.0 Firefox/48.0 KAIOS/2.5', + viewport: { + width: 320, + height: 240, + deviceScaleFactor: 1, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Kindle Fire HDX', + userAgent: + 'Mozilla/5.0 (Linux; U; en-us; KFAPWI Build/JDQ39) AppleWebKit/535.19 (KHTML, like Gecko) Silk/3.13 Safari/535.19 Silk-Accelerated=true', + viewport: { + width: 800, + height: 1280, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Kindle Fire HDX landscape', + userAgent: + 'Mozilla/5.0 (Linux; U; en-us; KFAPWI Build/JDQ39) AppleWebKit/535.19 (KHTML, like Gecko) Silk/3.13 Safari/535.19 Silk-Accelerated=true', + viewport: { + width: 1280, + height: 800, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'LG Optimus L70', + userAgent: + 'Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 384, + height: 640, + deviceScaleFactor: 1.25, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'LG Optimus L70 landscape', + userAgent: + 'Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 640, + height: 384, + deviceScaleFactor: 1.25, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Microsoft Lumia 550', + userAgent: + 'Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2486.0 Mobile Safari/537.36 Edge/14.14263', + viewport: { + width: 640, + height: 360, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Microsoft Lumia 950', + userAgent: + 'Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2486.0 Mobile Safari/537.36 Edge/14.14263', + viewport: { + width: 360, + height: 640, + deviceScaleFactor: 4, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Microsoft Lumia 950 landscape', + userAgent: + 'Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2486.0 Mobile Safari/537.36 Edge/14.14263', + viewport: { + width: 640, + height: 360, + deviceScaleFactor: 4, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Nexus 10', + userAgent: + 'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Safari/537.36', + viewport: { + width: 800, + height: 1280, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Nexus 10 landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Safari/537.36', + viewport: { + width: 1280, + height: 800, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Nexus 4', + userAgent: + 'Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 384, + height: 640, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Nexus 4 landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 640, + height: 384, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Nexus 5', + userAgent: + 'Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 360, + height: 640, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Nexus 5 landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 640, + height: 360, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Nexus 5X', + userAgent: + 'Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 412, + height: 732, + deviceScaleFactor: 2.625, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Nexus 5X landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 732, + height: 412, + deviceScaleFactor: 2.625, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Nexus 6', + userAgent: + 'Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 412, + height: 732, + deviceScaleFactor: 3.5, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Nexus 6 landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 732, + height: 412, + deviceScaleFactor: 3.5, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Nexus 6P', + userAgent: + 'Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 412, + height: 732, + deviceScaleFactor: 3.5, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Nexus 6P landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 732, + height: 412, + deviceScaleFactor: 3.5, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Nexus 7', + userAgent: + 'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Safari/537.36', + viewport: { + width: 600, + height: 960, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Nexus 7 landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Safari/537.36', + viewport: { + width: 960, + height: 600, + deviceScaleFactor: 2, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Nokia Lumia 520', + userAgent: + 'Mozilla/5.0 (compatible; MSIE 10.0; Windows Phone 8.0; Trident/6.0; IEMobile/10.0; ARM; Touch; NOKIA; Lumia 520)', + viewport: { + width: 320, + height: 533, + deviceScaleFactor: 1.5, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Nokia Lumia 520 landscape', + userAgent: + 'Mozilla/5.0 (compatible; MSIE 10.0; Windows Phone 8.0; Trident/6.0; IEMobile/10.0; ARM; Touch; NOKIA; Lumia 520)', + viewport: { + width: 533, + height: 320, + deviceScaleFactor: 1.5, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Nokia N9', + userAgent: + 'Mozilla/5.0 (MeeGo; NokiaN9) AppleWebKit/534.13 (KHTML, like Gecko) NokiaBrowser/8.5.0 Mobile Safari/534.13', + viewport: { + width: 480, + height: 854, + deviceScaleFactor: 1, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Nokia N9 landscape', + userAgent: + 'Mozilla/5.0 (MeeGo; NokiaN9) AppleWebKit/534.13 (KHTML, like Gecko) NokiaBrowser/8.5.0 Mobile Safari/534.13', + viewport: { + width: 854, + height: 480, + deviceScaleFactor: 1, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Pixel 2', + userAgent: + 'Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 411, + height: 731, + deviceScaleFactor: 2.625, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Pixel 2 landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 731, + height: 411, + deviceScaleFactor: 2.625, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, + { + name: 'Pixel 2 XL', + userAgent: + 'Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 411, + height: 823, + deviceScaleFactor: 3.5, + isMobile: true, + hasTouch: true, + isLandscape: false, + }, + }, + { + name: 'Pixel 2 XL landscape', + userAgent: + 'Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', + viewport: { + width: 823, + height: 411, + deviceScaleFactor: 3.5, + isMobile: true, + hasTouch: true, + isLandscape: true, + }, + }, +]; + +export type DevicesMap = { + [name: string]: Device; +}; + +const devicesMap: DevicesMap = {}; + +for (const device of devices) devicesMap[device.name] = device; + +export { devicesMap }; diff --git a/remote/test/puppeteer/src/common/Dialog.ts b/remote/test/puppeteer/src/common/Dialog.ts new file mode 100644 index 0000000000..48720239b6 --- /dev/null +++ b/remote/test/puppeteer/src/common/Dialog.ts @@ -0,0 +1,111 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { assert } from './assert.js'; +import { CDPSession } from './Connection.js'; +import { Protocol } from 'devtools-protocol'; + +/** + * Dialog instances are dispatched by the {@link Page} via the `dialog` event. + * + * @remarks + * + * @example + * ```js + * const puppeteer = require('puppeteer'); + * + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * page.on('dialog', async dialog => { + * console.log(dialog.message()); + * await dialog.dismiss(); + * await browser.close(); + * }); + * page.evaluate(() => alert('1')); + * })(); + * ``` + */ +export class Dialog { + private _client: CDPSession; + private _type: Protocol.Page.DialogType; + private _message: string; + private _defaultValue: string; + private _handled = false; + + /** + * @internal + */ + constructor( + client: CDPSession, + type: Protocol.Page.DialogType, + message: string, + defaultValue = '' + ) { + this._client = client; + this._type = type; + this._message = message; + this._defaultValue = defaultValue; + } + + /** + * @returns The type of the dialog. + */ + type(): Protocol.Page.DialogType { + return this._type; + } + + /** + * @returns The message displayed in the dialog. + */ + message(): string { + return this._message; + } + + /** + * @returns The default value of the prompt, or an empty string if the dialog + * is not a `prompt`. + */ + defaultValue(): string { + return this._defaultValue; + } + + /** + * @param promptText - optional text that will be entered in the dialog + * prompt. Has no effect if the dialog's type is not `prompt`. + * + * @returns A promise that resolves when the dialog has been accepted. + */ + async accept(promptText?: string): Promise<void> { + assert(!this._handled, 'Cannot accept dialog which is already handled!'); + this._handled = true; + await this._client.send('Page.handleJavaScriptDialog', { + accept: true, + promptText: promptText, + }); + } + + /** + * @returns A promise which will resolve once the dialog has been dismissed + */ + async dismiss(): Promise<void> { + assert(!this._handled, 'Cannot dismiss dialog which is already handled!'); + this._handled = true; + await this._client.send('Page.handleJavaScriptDialog', { + accept: false, + }); + } +} diff --git a/remote/test/puppeteer/src/common/EmulationManager.ts b/remote/test/puppeteer/src/common/EmulationManager.ts new file mode 100644 index 0000000000..f3130ef3c5 --- /dev/null +++ b/remote/test/puppeteer/src/common/EmulationManager.ts @@ -0,0 +1,58 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { CDPSession } from './Connection.js'; +import { Viewport } from './PuppeteerViewport.js'; +import { Protocol } from 'devtools-protocol'; + +export class EmulationManager { + _client: CDPSession; + _emulatingMobile = false; + _hasTouch = false; + + constructor(client: CDPSession) { + this._client = client; + } + + async emulateViewport(viewport: Viewport): Promise<boolean> { + 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([ + this._client.send('Emulation.setDeviceMetricsOverride', { + mobile, + width, + height, + deviceScaleFactor, + screenOrientation, + }), + this._client.send('Emulation.setTouchEmulationEnabled', { + enabled: hasTouch, + }), + ]); + + const reloadNeeded = + this._emulatingMobile !== mobile || this._hasTouch !== hasTouch; + this._emulatingMobile = mobile; + this._hasTouch = hasTouch; + return reloadNeeded; + } +} diff --git a/remote/test/puppeteer/src/common/Errors.ts b/remote/test/puppeteer/src/common/Errors.ts new file mode 100644 index 0000000000..2ba151495d --- /dev/null +++ b/remote/test/puppeteer/src/common/Errors.ts @@ -0,0 +1,41 @@ +/** + * Copyright 2018 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +class CustomError extends Error { + constructor(message: string) { + super(message); + this.name = this.constructor.name; + Error.captureStackTrace(this, this.constructor); + } +} + +/** + * TimeoutError is emitted whenever certain operations are terminated due to timeout. + * + * @remarks + * + * Example operations are {@link Page.waitForSelector | page.waitForSelector} + * or {@link PuppeteerNode.launch | puppeteer.launch}. + * + * @public + */ +export class TimeoutError extends CustomError {} + +export type PuppeteerErrors = Record<string, typeof CustomError>; + +export const puppeteerErrors: PuppeteerErrors = { + TimeoutError, +}; diff --git a/remote/test/puppeteer/src/common/EvalTypes.ts b/remote/test/puppeteer/src/common/EvalTypes.ts new file mode 100644 index 0000000000..7a9335942a --- /dev/null +++ b/remote/test/puppeteer/src/common/EvalTypes.ts @@ -0,0 +1,81 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { JSHandle, ElementHandle } from './JSHandle.js'; + +/** + * @public + */ +export type EvaluateFn<T = unknown> = + | string + | ((arg1: T, ...args: unknown[]) => unknown); + +export type UnwrapPromiseLike<T> = T extends PromiseLike<infer U> ? U : T; + +/** + * @public + */ +export type EvaluateFnReturnType<T extends EvaluateFn> = T extends ( + ...args: unknown[] +) => infer R + ? R + : unknown; + +/** + * @public + */ +export type EvaluateHandleFn = string | ((...args: unknown[]) => unknown); + +/** + * @public + */ +export type Serializable = + | number + | string + | boolean + | null + | BigInt + | JSONArray + | JSONObject; + +/** + * @public + */ +export type JSONArray = Serializable[]; + +/** + * @public + */ +export interface JSONObject { + [key: string]: Serializable; +} + +/** + * @public + */ +export type SerializableOrJSHandle = Serializable | JSHandle; + +/** + * Wraps a DOM element into an ElementHandle instance + * @public + **/ +export type WrapElementHandle<X> = X extends Element ? ElementHandle<X> : X; + +/** + * Unwraps a DOM element out of an ElementHandle instance + * @public + **/ +export type UnwrapElementHandle<X> = X extends ElementHandle<infer E> ? E : X; diff --git a/remote/test/puppeteer/src/common/EventEmitter.ts b/remote/test/puppeteer/src/common/EventEmitter.ts new file mode 100644 index 0000000000..3588f1408b --- /dev/null +++ b/remote/test/puppeteer/src/common/EventEmitter.ts @@ -0,0 +1,144 @@ +import mitt, { + Emitter, + EventType, + Handler, +} from '../../vendor/mitt/src/index.js'; + +/** + * @internal + */ +export interface CommonEventEmitter { + on(event: EventType, handler: Handler): CommonEventEmitter; + off(event: EventType, handler: Handler): CommonEventEmitter; + /* To maintain parity with the built in NodeJS event emitter which uses removeListener + * rather than `off`. + * If you're implementing new code you should use `off`. + */ + addListener(event: EventType, handler: Handler): CommonEventEmitter; + removeListener(event: EventType, handler: Handler): CommonEventEmitter; + emit(event: EventType, eventData?: any): boolean; + once(event: EventType, handler: Handler): CommonEventEmitter; + listenerCount(event: string): number; + + removeAllListeners(event?: EventType): CommonEventEmitter; +} + +/** + * The EventEmitter class that many Puppeteer classes extend. + * + * @remarks + * + * This allows you to listen to events that Puppeteer classes fire and act + * accordingly. Therefore you'll mostly use {@link EventEmitter.on | on} and + * {@link EventEmitter.off | off} to bind + * and unbind to event listeners. + * + * @public + */ +export class EventEmitter implements CommonEventEmitter { + private emitter: Emitter; + private eventsMap = new Map<EventType, Handler[]>(); + + /** + * @internal + */ + constructor() { + this.emitter = mitt(this.eventsMap); + } + + /** + * Bind an event listener to fire when an event occurs. + * @param event - the event type you'd like to listen to. Can be a string or symbol. + * @param handler - the function to be called when the event occurs. + * @returns `this` to enable you to chain calls. + */ + on(event: EventType, handler: Handler): EventEmitter { + this.emitter.on(event, handler); + return this; + } + + /** + * Remove an event listener from firing. + * @param event - the event type you'd like to stop listening to. + * @param handler - the function that should be removed. + * @returns `this` to enable you to chain calls. + */ + off(event: EventType, handler: Handler): EventEmitter { + this.emitter.off(event, handler); + return this; + } + + /** + * Remove an event listener. + * @deprecated please use `off` instead. + */ + removeListener(event: EventType, handler: Handler): EventEmitter { + this.off(event, handler); + return this; + } + + /** + * Add an event listener. + * @deprecated please use `on` instead. + */ + addListener(event: EventType, handler: Handler): EventEmitter { + this.on(event, handler); + return this; + } + + /** + * Emit an event and call any associated listeners. + * + * @param event - the event you'd like to emit + * @param eventData - any data you'd like to emit with the event + * @returns `true` if there are any listeners, `false` if there are not. + */ + emit(event: EventType, eventData?: any): boolean { + this.emitter.emit(event, eventData); + return this.eventListenersCount(event) > 0; + } + + /** + * Like `on` but the listener will only be fired once and then it will be removed. + * @param event - the event you'd like to listen to + * @param handler - the handler function to run when the event occurs + * @returns `this` to enable you to chain calls. + */ + once(event: EventType, handler: Handler): EventEmitter { + const onceHandler: Handler = (eventData) => { + handler(eventData); + this.off(event, onceHandler); + }; + + return this.on(event, onceHandler); + } + + /** + * Gets the number of listeners for a given event. + * + * @param event - the event to get the listener count for + * @returns the number of listeners bound to the given event + */ + listenerCount(event: EventType): number { + return this.eventListenersCount(event); + } + + /** + * Removes all listeners. If given an event argument, it will remove only + * listeners for that event. + * @param event - the event to remove listeners for. + * @returns `this` to enable you to chain calls. + */ + removeAllListeners(event?: EventType): EventEmitter { + if (event) { + this.eventsMap.delete(event); + } else { + this.eventsMap.clear(); + } + return this; + } + + private eventListenersCount(event: EventType): number { + return this.eventsMap.has(event) ? this.eventsMap.get(event).length : 0; + } +} diff --git a/remote/test/puppeteer/src/common/Events.ts b/remote/test/puppeteer/src/common/Events.ts new file mode 100644 index 0000000000..90d8bae785 --- /dev/null +++ b/remote/test/puppeteer/src/common/Events.ts @@ -0,0 +1,97 @@ +/** + * Copyright 2019 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * IMPORTANT: we are mid-way through migrating away from this Events.ts file + * in favour of defining events next to the class that emits them. + * + * However we need to maintain this file for now because the legacy DocLint + * system relies on them. Be aware in the mean time if you make a change here + * you probably need to replicate it in the relevant class. For example if you + * add a new Page event, you should update the PageEmittedEvents enum in + * src/common/Page.ts. + * + * Chat to @jackfranklin if you're unsure. + */ + +export const Events = { + Page: { + Close: 'close', + Console: 'console', + Dialog: 'dialog', + DOMContentLoaded: 'domcontentloaded', + Error: 'error', + // Can't use just 'error' due to node.js special treatment of error events. + // @see https://nodejs.org/api/events.html#events_error_events + PageError: 'pageerror', + Request: 'request', + Response: 'response', + RequestFailed: 'requestfailed', + RequestFinished: 'requestfinished', + FrameAttached: 'frameattached', + FrameDetached: 'framedetached', + FrameNavigated: 'framenavigated', + Load: 'load', + Metrics: 'metrics', + Popup: 'popup', + WorkerCreated: 'workercreated', + WorkerDestroyed: 'workerdestroyed', + }, + + Browser: { + TargetCreated: 'targetcreated', + TargetDestroyed: 'targetdestroyed', + TargetChanged: 'targetchanged', + Disconnected: 'disconnected', + }, + + BrowserContext: { + TargetCreated: 'targetcreated', + TargetDestroyed: 'targetdestroyed', + TargetChanged: 'targetchanged', + }, + + NetworkManager: { + Request: Symbol('Events.NetworkManager.Request'), + Response: Symbol('Events.NetworkManager.Response'), + RequestFailed: Symbol('Events.NetworkManager.RequestFailed'), + RequestFinished: Symbol('Events.NetworkManager.RequestFinished'), + }, + + FrameManager: { + FrameAttached: Symbol('Events.FrameManager.FrameAttached'), + FrameNavigated: Symbol('Events.FrameManager.FrameNavigated'), + FrameDetached: Symbol('Events.FrameManager.FrameDetached'), + LifecycleEvent: Symbol('Events.FrameManager.LifecycleEvent'), + FrameNavigatedWithinDocument: Symbol( + 'Events.FrameManager.FrameNavigatedWithinDocument' + ), + ExecutionContextCreated: Symbol( + 'Events.FrameManager.ExecutionContextCreated' + ), + ExecutionContextDestroyed: Symbol( + 'Events.FrameManager.ExecutionContextDestroyed' + ), + }, + + Connection: { + Disconnected: Symbol('Events.Connection.Disconnected'), + }, + + CDPSession: { + Disconnected: Symbol('Events.CDPSession.Disconnected'), + }, +} as const; diff --git a/remote/test/puppeteer/src/common/ExecutionContext.ts b/remote/test/puppeteer/src/common/ExecutionContext.ts new file mode 100644 index 0000000000..4420410de0 --- /dev/null +++ b/remote/test/puppeteer/src/common/ExecutionContext.ts @@ -0,0 +1,387 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { assert } from './assert.js'; +import { helper } from './helper.js'; +import { createJSHandle, JSHandle, ElementHandle } from './JSHandle.js'; +import { CDPSession } from './Connection.js'; +import { DOMWorld } from './DOMWorld.js'; +import { Frame } from './FrameManager.js'; +import { Protocol } from 'devtools-protocol'; +import { EvaluateHandleFn, SerializableOrJSHandle } from './EvalTypes.js'; + +export const EVALUATION_SCRIPT_URL = '__puppeteer_evaluation_script__'; +const SOURCE_URL_REGEX = /^[\040\t]*\/\/[@#] sourceURL=\s*(\S*?)\s*$/m; + +/** + * This class represents a context for JavaScript execution. A [Page] might have + * many execution contexts: + * - each + * {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe | + * frame } has "default" execution context that is always created after frame is + * attached to DOM. This context is returned by the + * {@link frame.executionContext()} method. + * - {@link https://developer.chrome.com/extensions | Extension}'s content scripts + * create additional execution contexts. + * + * Besides pages, execution contexts can be found in + * {@link https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API | + * workers }. + * + * @public + */ +export class ExecutionContext { + /** + * @internal + */ + _client: CDPSession; + /** + * @internal + */ + _world: DOMWorld; + /** + * @internal + */ + _contextId: number; + + /** + * @internal + */ + constructor( + client: CDPSession, + contextPayload: Protocol.Runtime.ExecutionContextDescription, + world: DOMWorld + ) { + this._client = client; + this._world = world; + this._contextId = contextPayload.id; + } + + /** + * @remarks + * + * Not every execution context is associated with a frame. For + * example, workers and extensions have execution contexts that are not + * associated with frames. + * + * @returns The frame associated with this execution context. + */ + frame(): Frame | null { + return this._world ? this._world.frame() : null; + } + + /** + * @remarks + * If the function passed to the `executionContext.evaluate` returns a + * Promise, then `executionContext.evaluate` would wait for the promise to + * resolve and return its value. If the function passed to the + * `executionContext.evaluate` returns a non-serializable value, then + * `executionContext.evaluate` resolves to `undefined`. DevTools Protocol also + * supports transferring some additional values that are not serializable by + * `JSON`: `-0`, `NaN`, `Infinity`, `-Infinity`, and bigint literals. + * + * + * @example + * ```js + * 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. + * + * ```js + * console.log(await executionContext.evaluate('1 + 2')); // prints "3" + * ``` + * + * @example + * {@link JSHandle} instances can be passed as arguments to the + * `executionContext.* evaluate`: + * ```js + * 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 a function to be evaluated in the `executionContext` + * @param args argument to pass to the page function + * + * @returns A promise that resolves to the return value of the given function. + */ + async evaluate<ReturnType extends any>( + pageFunction: Function | string, + ...args: unknown[] + ): Promise<ReturnType> { + return await this._evaluateInternal<ReturnType>( + true, + pageFunction, + ...args + ); + } + + /** + * @remarks + * The only difference between `executionContext.evaluate` and + * `executionContext.evaluateHandle` is that `executionContext.evaluateHandle` + * returns an in-page object (a {@link JSHandle}). + * If the function passed to the `executionContext.evaluateHandle` returns a + * Promise, then `executionContext.evaluateHandle` would wait for the + * promise to resolve and return its value. + * + * @example + * ```js + * const context = await page.mainFrame().executionContext(); + * const aHandle = await context.evaluateHandle(() => Promise.resolve(self)); + * aHandle; // Handle for the global object. + * ``` + * + * @example + * A string can also be passed in instead of a function. + * + * ```js + * // Handle for the '3' * object. + * const aHandle = await context.evaluateHandle('1 + 2'); + * ``` + * + * @example + * JSHandle instances can be passed as arguments + * to the `executionContext.* evaluateHandle`: + * + * ```js + * const aHandle = await context.evaluateHandle(() => document.body); + * const resultHandle = await context.evaluateHandle(body => body.innerHTML, * aHandle); + * console.log(await resultHandle.jsonValue()); // prints body's innerHTML + * await aHandle.dispose(); + * await resultHandle.dispose(); + * ``` + * + * @param pageFunction a function to be evaluated in the `executionContext` + * @param args argument to pass to the page function + * + * @returns A promise that resolves to the return value of the given function + * as an in-page object (a {@link JSHandle}). + */ + async evaluateHandle<HandleType extends JSHandle | ElementHandle = JSHandle>( + pageFunction: EvaluateHandleFn, + ...args: SerializableOrJSHandle[] + ): Promise<HandleType> { + return this._evaluateInternal<HandleType>(false, pageFunction, ...args); + } + + private async _evaluateInternal<ReturnType>( + returnByValue: boolean, + pageFunction: Function | string, + ...args: unknown[] + ): Promise<ReturnType> { + const suffix = `//# sourceURL=${EVALUATION_SCRIPT_URL}`; + + if (helper.isString(pageFunction)) { + const contextId = this._contextId; + const expression = pageFunction; + const expressionWithSourceUrl = SOURCE_URL_REGEX.test(expression) + ? expression + : expression + '\n' + suffix; + + const { exceptionDetails, result: remoteObject } = await this._client + .send('Runtime.evaluate', { + expression: expressionWithSourceUrl, + contextId, + returnByValue, + awaitPromise: true, + userGesture: true, + }) + .catch(rewriteError); + + if (exceptionDetails) + throw new Error( + 'Evaluation failed: ' + helper.getExceptionMessage(exceptionDetails) + ); + + return returnByValue + ? helper.valueFromRemoteObject(remoteObject) + : createJSHandle(this, remoteObject); + } + + if (typeof pageFunction !== 'function') + throw new Error( + `Expected to get |string| or |function| as the first argument, but got "${pageFunction}" instead.` + ); + + let functionText = pageFunction.toString(); + try { + new Function('(' + functionText + ')'); + } catch (error) { + // This means we might have a function shorthand. Try another + // time prefixing 'function '. + if (functionText.startsWith('async ')) + functionText = + 'async function ' + functionText.substring('async '.length); + else functionText = 'function ' + functionText; + try { + new Function('(' + functionText + ')'); + } catch (error) { + // We tried hard to serialize, but there's a weird beast here. + throw new Error('Passed function is not well-serializable!'); + } + } + let callFunctionOnPromise; + try { + callFunctionOnPromise = this._client.send('Runtime.callFunctionOn', { + functionDeclaration: functionText + '\n' + suffix + '\n', + executionContextId: this._contextId, + arguments: 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 += ' Are you passing a nested JSHandle?'; + throw error; + } + const { + exceptionDetails, + result: remoteObject, + } = await callFunctionOnPromise.catch(rewriteError); + if (exceptionDetails) + throw new Error( + 'Evaluation failed: ' + helper.getExceptionMessage(exceptionDetails) + ); + return returnByValue + ? helper.valueFromRemoteObject(remoteObject) + : createJSHandle(this, remoteObject); + + /** + * @param {*} arg + * @returns {*} + * @this {ExecutionContext} + */ + function convertArgument(this: ExecutionContext, arg: unknown): unknown { + 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 JSHandle ? arg : null; + if (objectHandle) { + if (objectHandle._context !== this) + 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 }; + } + + function 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; + } + } + + /** + * This method iterates the JavaScript heap and finds all the objects with the + * given prototype. + * @remarks + * @example + * ```js + * // Create a Map object + * await page.evaluate(() => window.map = new Map()); + * // Get a handle to the Map object prototype + * const mapPrototype = await page.evaluateHandle(() => Map.prototype); + * // Query all map instances into an array + * const mapInstances = await page.queryObjects(mapPrototype); + * // Count amount of map objects in heap + * const count = await page.evaluate(maps => maps.length, mapInstances); + * await mapInstances.dispose(); + * await mapPrototype.dispose(); + * ``` + * + * @param prototypeHandle a handle to the object prototype + * + * @returns A handle to an array of objects with the given prototype. + */ + async queryObjects(prototypeHandle: JSHandle): Promise<JSHandle> { + assert(!prototypeHandle._disposed, 'Prototype JSHandle is disposed!'); + assert( + prototypeHandle._remoteObject.objectId, + 'Prototype JSHandle must not be referencing primitive value' + ); + const response = await this._client.send('Runtime.queryObjects', { + prototypeObjectId: prototypeHandle._remoteObject.objectId, + }); + return createJSHandle(this, response.objects); + } + + /** + * @internal + */ + async _adoptBackendNodeId( + backendNodeId: Protocol.DOM.BackendNodeId + ): Promise<ElementHandle> { + const { object } = await this._client.send('DOM.resolveNode', { + backendNodeId: backendNodeId, + executionContextId: this._contextId, + }); + return createJSHandle(this, object) as ElementHandle; + } + + /** + * @internal + */ + async _adoptElementHandle( + elementHandle: ElementHandle + ): Promise<ElementHandle> { + assert( + elementHandle.executionContext() !== this, + 'Cannot adopt handle that already belongs to this execution context' + ); + assert(this._world, 'Cannot adopt handle without DOMWorld'); + const nodeInfo = await this._client.send('DOM.describeNode', { + objectId: elementHandle._remoteObject.objectId, + }); + return this._adoptBackendNodeId(nodeInfo.node.backendNodeId); + } +} diff --git a/remote/test/puppeteer/src/common/FileChooser.ts b/remote/test/puppeteer/src/common/FileChooser.ts new file mode 100644 index 0000000000..3406918824 --- /dev/null +++ b/remote/test/puppeteer/src/common/FileChooser.ts @@ -0,0 +1,85 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ElementHandle } from './JSHandle.js'; +import { Protocol } from 'devtools-protocol'; +import { assert } from './assert.js'; + +/** + * File choosers let you react to the page requesting for a file. + * @remarks + * `FileChooser` objects are returned via the `page.waitForFileChooser` method. + * @example + * An example of using `FileChooser`: + * ```js + * const [fileChooser] = await Promise.all([ + * page.waitForFileChooser(), + * page.click('#upload-file-button'), // some button that triggers file selection + * ]); + * await fileChooser.accept(['/tmp/myfile.pdf']); + * ``` + * **NOTE** In browsers, only one file chooser can be opened at a time. + * All file choosers must be accepted or canceled. Not doing so will prevent + * subsequent file choosers from appearing. + */ +export class FileChooser { + private _element: ElementHandle; + private _multiple: boolean; + private _handled = false; + + /** + * @internal + */ + constructor( + element: ElementHandle, + event: Protocol.Page.FileChooserOpenedEvent + ) { + this._element = element; + this._multiple = event.mode !== 'selectSingle'; + } + + /** + * Whether file chooser allow for {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#attr-multiple | multiple} file selection. + */ + isMultiple(): boolean { + return this._multiple; + } + + /** + * Accept the file chooser request with given paths. + * @param filePaths - If some of the `filePaths` are relative paths, + * then they are resolved relative to the {@link https://nodejs.org/api/process.html#process_process_cwd | current working directory}. + */ + async accept(filePaths: string[]): Promise<void> { + assert( + !this._handled, + 'Cannot accept FileChooser which is already handled!' + ); + this._handled = true; + await this._element.uploadFile(...filePaths); + } + + /** + * Closes the file chooser without selecting any files. + */ + async cancel(): Promise<void> { + assert( + !this._handled, + 'Cannot cancel FileChooser which is already handled!' + ); + this._handled = true; + } +} diff --git a/remote/test/puppeteer/src/common/FrameManager.ts b/remote/test/puppeteer/src/common/FrameManager.ts new file mode 100644 index 0000000000..e1017487d3 --- /dev/null +++ b/remote/test/puppeteer/src/common/FrameManager.ts @@ -0,0 +1,1309 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { debug } from '../common/Debug.js'; + +import { EventEmitter } from './EventEmitter.js'; +import { assert } from './assert.js'; +import { helper, debugError } from './helper.js'; +import { ExecutionContext, EVALUATION_SCRIPT_URL } from './ExecutionContext.js'; +import { + LifecycleWatcher, + PuppeteerLifeCycleEvent, +} from './LifecycleWatcher.js'; +import { DOMWorld, WaitForSelectorOptions } from './DOMWorld.js'; +import { NetworkManager } from './NetworkManager.js'; +import { TimeoutSettings } from './TimeoutSettings.js'; +import { CDPSession } from './Connection.js'; +import { JSHandle, ElementHandle } from './JSHandle.js'; +import { MouseButton } from './Input.js'; +import { Page } from './Page.js'; +import { HTTPResponse } from './HTTPResponse.js'; +import { Protocol } from 'devtools-protocol'; +import { + SerializableOrJSHandle, + EvaluateHandleFn, + WrapElementHandle, + EvaluateFn, + EvaluateFnReturnType, + UnwrapPromiseLike, +} from './EvalTypes.js'; + +const UTILITY_WORLD_NAME = '__puppeteer_utility_world__'; + +/** + * We use symbols to prevent external parties listening to these events. + * They are internal to Puppeteer. + * + * @internal + */ +export const FrameManagerEmittedEvents = { + FrameAttached: Symbol('FrameManager.FrameAttached'), + FrameNavigated: Symbol('FrameManager.FrameNavigated'), + FrameDetached: Symbol('FrameManager.FrameDetached'), + LifecycleEvent: Symbol('FrameManager.LifecycleEvent'), + FrameNavigatedWithinDocument: Symbol( + 'FrameManager.FrameNavigatedWithinDocument' + ), + ExecutionContextCreated: Symbol('FrameManager.ExecutionContextCreated'), + ExecutionContextDestroyed: Symbol('FrameManager.ExecutionContextDestroyed'), +}; + +/** + * @internal + */ +export class FrameManager extends EventEmitter { + _client: CDPSession; + private _page: Page; + private _networkManager: NetworkManager; + _timeoutSettings: TimeoutSettings; + private _frames = new Map<string, Frame>(); + private _contextIdToContext = new Map<number, ExecutionContext>(); + private _isolatedWorlds = new Set<string>(); + private _mainFrame: Frame; + + constructor( + client: CDPSession, + page: Page, + ignoreHTTPSErrors: boolean, + timeoutSettings: TimeoutSettings + ) { + super(); + this._client = client; + this._page = page; + this._networkManager = new NetworkManager(client, ignoreHTTPSErrors, this); + this._timeoutSettings = timeoutSettings; + this._client.on('Page.frameAttached', (event) => + this._onFrameAttached(event.frameId, event.parentFrameId) + ); + this._client.on('Page.frameNavigated', (event) => + this._onFrameNavigated(event.frame) + ); + this._client.on('Page.navigatedWithinDocument', (event) => + this._onFrameNavigatedWithinDocument(event.frameId, event.url) + ); + this._client.on('Page.frameDetached', (event) => + this._onFrameDetached(event.frameId) + ); + this._client.on('Page.frameStoppedLoading', (event) => + this._onFrameStoppedLoading(event.frameId) + ); + this._client.on('Runtime.executionContextCreated', (event) => + this._onExecutionContextCreated(event.context) + ); + this._client.on('Runtime.executionContextDestroyed', (event) => + this._onExecutionContextDestroyed(event.executionContextId) + ); + this._client.on('Runtime.executionContextsCleared', () => + this._onExecutionContextsCleared() + ); + this._client.on('Page.lifecycleEvent', (event) => + this._onLifecycleEvent(event) + ); + this._client.on('Target.attachedToTarget', async (event) => + this._onFrameMoved(event) + ); + } + + async initialize(): Promise<void> { + const result = await Promise.all([ + this._client.send('Page.enable'), + this._client.send('Page.getFrameTree'), + ]); + + const { frameTree } = result[1]; + this._handleFrameTree(frameTree); + await Promise.all([ + this._client.send('Page.setLifecycleEventsEnabled', { enabled: true }), + this._client + .send('Runtime.enable') + .then(() => this._ensureIsolatedWorld(UTILITY_WORLD_NAME)), + this._networkManager.initialize(), + ]); + } + + networkManager(): NetworkManager { + return this._networkManager; + } + + async navigateFrame( + frame: Frame, + url: string, + options: { + referer?: string; + timeout?: number; + waitUntil?: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[]; + } = {} + ): Promise<HTTPResponse | null> { + assertNoLegacyNavigationOptions(options); + const { + referer = this._networkManager.extraHTTPHeaders()['referer'], + waitUntil = ['load'], + timeout = this._timeoutSettings.navigationTimeout(), + } = options; + + const watcher = new LifecycleWatcher(this, frame, waitUntil, timeout); + let ensureNewDocumentNavigation = false; + let error = await Promise.race([ + navigate(this._client, url, referer, frame._id), + watcher.timeoutOrTerminationPromise(), + ]); + if (!error) { + error = await Promise.race([ + watcher.timeoutOrTerminationPromise(), + ensureNewDocumentNavigation + ? watcher.newDocumentNavigationPromise() + : watcher.sameDocumentNavigationPromise(), + ]); + } + watcher.dispose(); + if (error) throw error; + return watcher.navigationResponse(); + + async function navigate( + client: CDPSession, + url: string, + referrer: string, + frameId: string + ): Promise<Error | null> { + try { + const response = await client.send('Page.navigate', { + url, + referrer, + frameId, + }); + ensureNewDocumentNavigation = !!response.loaderId; + return response.errorText + ? new Error(`${response.errorText} at ${url}`) + : null; + } catch (error) { + return error; + } + } + } + + async waitForFrameNavigation( + frame: Frame, + options: { + timeout?: number; + waitUntil?: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[]; + } = {} + ): Promise<HTTPResponse | null> { + assertNoLegacyNavigationOptions(options); + const { + waitUntil = ['load'], + timeout = this._timeoutSettings.navigationTimeout(), + } = options; + const watcher = new LifecycleWatcher(this, frame, waitUntil, timeout); + const error = await Promise.race([ + watcher.timeoutOrTerminationPromise(), + watcher.sameDocumentNavigationPromise(), + watcher.newDocumentNavigationPromise(), + ]); + watcher.dispose(); + if (error) throw error; + return watcher.navigationResponse(); + } + + private async _onFrameMoved(event: Protocol.Target.AttachedToTargetEvent) { + if (event.targetInfo.type !== 'iframe') { + return; + } + + // TODO(sadym): Remove debug message once proper OOPIF support is + // implemented: https://github.com/puppeteer/puppeteer/issues/2548 + debug('puppeteer:frame')( + `The frame '${event.targetInfo.targetId}' moved to another session. ` + + `Out-of-process iframes (OOPIF) are not supported by Puppeteer yet. ` + + `https://github.com/puppeteer/puppeteer/issues/2548` + ); + } + + _onLifecycleEvent(event: Protocol.Page.LifecycleEventEvent): void { + const frame = this._frames.get(event.frameId); + if (!frame) return; + frame._onLifecycleEvent(event.loaderId, event.name); + this.emit(FrameManagerEmittedEvents.LifecycleEvent, frame); + } + + _onFrameStoppedLoading(frameId: string): void { + const frame = this._frames.get(frameId); + if (!frame) return; + frame._onLoadingStopped(); + this.emit(FrameManagerEmittedEvents.LifecycleEvent, frame); + } + + _handleFrameTree(frameTree: Protocol.Page.FrameTree): void { + if (frameTree.frame.parentId) + this._onFrameAttached(frameTree.frame.id, frameTree.frame.parentId); + this._onFrameNavigated(frameTree.frame); + if (!frameTree.childFrames) return; + + for (const child of frameTree.childFrames) this._handleFrameTree(child); + } + + page(): Page { + return this._page; + } + + mainFrame(): Frame { + return this._mainFrame; + } + + frames(): Frame[] { + return Array.from(this._frames.values()); + } + + frame(frameId: string): Frame | null { + return this._frames.get(frameId) || null; + } + + _onFrameAttached(frameId: string, parentFrameId?: string): void { + if (this._frames.has(frameId)) return; + assert(parentFrameId); + const parentFrame = this._frames.get(parentFrameId); + const frame = new Frame(this, parentFrame, frameId); + this._frames.set(frame._id, frame); + this.emit(FrameManagerEmittedEvents.FrameAttached, frame); + } + + _onFrameNavigated(framePayload: Protocol.Page.Frame): void { + const isMainFrame = !framePayload.parentId; + let frame = isMainFrame + ? this._mainFrame + : this._frames.get(framePayload.id); + assert( + isMainFrame || frame, + 'We either navigate top level or have old version of the navigated frame' + ); + + // 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._frames.delete(frame._id); + frame._id = framePayload.id; + } else { + // Initial main frame navigation. + frame = new Frame(this, null, framePayload.id); + } + this._frames.set(framePayload.id, frame); + this._mainFrame = frame; + } + + // Update frame payload. + frame._navigated(framePayload); + + this.emit(FrameManagerEmittedEvents.FrameNavigated, frame); + } + + async _ensureIsolatedWorld(name: string): Promise<void> { + if (this._isolatedWorlds.has(name)) return; + this._isolatedWorlds.add(name); + await this._client.send('Page.addScriptToEvaluateOnNewDocument', { + source: `//# sourceURL=${EVALUATION_SCRIPT_URL}`, + worldName: name, + }), + await Promise.all( + this.frames().map((frame) => + this._client + .send('Page.createIsolatedWorld', { + frameId: frame._id, + grantUniveralAccess: true, + worldName: name, + }) + .catch(debugError) + ) + ); // frames might be removed before we send this + } + + _onFrameNavigatedWithinDocument(frameId: string, url: string): void { + const frame = this._frames.get(frameId); + if (!frame) return; + frame._navigatedWithinDocument(url); + this.emit(FrameManagerEmittedEvents.FrameNavigatedWithinDocument, frame); + this.emit(FrameManagerEmittedEvents.FrameNavigated, frame); + } + + _onFrameDetached(frameId: string): void { + const frame = this._frames.get(frameId); + if (frame) this._removeFramesRecursively(frame); + } + + _onExecutionContextCreated( + contextPayload: Protocol.Runtime.ExecutionContextDescription + ): void { + const auxData = contextPayload.auxData as { frameId?: string }; + const frameId = auxData ? auxData.frameId : null; + const frame = this._frames.get(frameId) || null; + let world = null; + if (frame) { + if (contextPayload.auxData && !!contextPayload.auxData['isDefault']) { + world = frame._mainWorld; + } else if ( + contextPayload.name === UTILITY_WORLD_NAME && + !frame._secondaryWorld._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._secondaryWorld; + } + } + if (contextPayload.auxData && contextPayload.auxData['type'] === 'isolated') + this._isolatedWorlds.add(contextPayload.name); + const context = new ExecutionContext(this._client, contextPayload, world); + if (world) world._setContext(context); + this._contextIdToContext.set(contextPayload.id, context); + } + + private _onExecutionContextDestroyed(executionContextId: number): void { + const context = this._contextIdToContext.get(executionContextId); + if (!context) return; + this._contextIdToContext.delete(executionContextId); + if (context._world) context._world._setContext(null); + } + + private _onExecutionContextsCleared(): void { + for (const context of this._contextIdToContext.values()) { + if (context._world) context._world._setContext(null); + } + this._contextIdToContext.clear(); + } + + executionContextById(contextId: number): ExecutionContext { + const context = this._contextIdToContext.get(contextId); + assert(context, 'INTERNAL ERROR: missing context with id = ' + contextId); + return context; + } + + private _removeFramesRecursively(frame: Frame): void { + for (const child of frame.childFrames()) + this._removeFramesRecursively(child); + frame._detach(); + this._frames.delete(frame._id); + this.emit(FrameManagerEmittedEvents.FrameDetached, frame); + } +} + +/** + * @public + */ +export interface FrameWaitForFunctionOptions { + /** + * An interval at which the `pageFunction` is executed, defaults to `raf`. If + * `polling` is a number, then it is treated as an interval in milliseconds at + * which the function would be executed. If `polling` is a string, then it can + * be one of the following values: + * + * - `raf` - to constantly execute `pageFunction` in `requestAnimationFrame` + * callback. This is the tightest polling mode which is suitable to observe + * styling changes. + * + * - `mutation` - to execute `pageFunction` on every DOM mutation. + */ + polling?: string | number; + /** + * Maximum time to wait in milliseconds. Defaults to `30000` (30 seconds). + * Pass `0` to disable the timeout. Puppeteer's default timeout can be changed + * using {@link Page.setDefaultTimeout}. + */ + timeout?: number; +} + +/** + * @public + */ +export interface FrameAddScriptTagOptions { + /** + * the URL of the script to be added. + */ + url?: string; + /** + * The path to a JavaScript file to be injected into the frame. + * @remarks + * If `path` is a relative path, it is resolved relative to the current + * working directory (`process.cwd()` in Node.js). + */ + path?: string; + /** + * Raw JavaScript content to be injected into the frame. + */ + content?: string; + /** + * Set the script's `type`. Use `module` in order to load an ES2015 module. + */ + type?: string; +} + +/** + * @public + */ +export interface FrameAddStyleTagOptions { + /** + * the URL of the CSS file to be added. + */ + url?: string; + /** + * The path to a CSS file to be injected into the frame. + * @remarks + * If `path` is a relative path, it is resolved relative to the current + * working directory (`process.cwd()` in Node.js). + */ + path?: string; + /** + * Raw CSS content to be injected into the frame. + */ + content?: string; +} + +/** + * At every point of time, page exposes its current frame tree via the + * {@link Page.mainFrame | page.mainFrame} and + * {@link Frame.childFrames | frame.childFrames} methods. + * + * @remarks + * + * `Frame` object lifecycles are controlled by three events that are all + * dispatched on the page object: + * + * - {@link PageEmittedEvents.FrameAttached} + * + * - {@link PageEmittedEvents.FrameNavigated} + * + * - {@link PageEmittedEvents.FrameDetached} + * + * @Example + * An example of dumping frame tree: + * + * ```js + * const puppeteer = require('puppeteer'); + * + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * await page.goto('https://www.google.com/chrome/browser/canary.html'); + * dumpFrameTree(page.mainFrame(), ''); + * await browser.close(); + * + * function dumpFrameTree(frame, indent) { + * console.log(indent + frame.url()); + * for (const child of frame.childFrames()) { + * dumpFrameTree(child, indent + ' '); + * } + * } + * })(); + * ``` + * + * @Example + * An example of getting text from an iframe element: + * + * ```js + * const frame = page.frames().find(frame => frame.name() === 'myframe'); + * const text = await frame.$eval('.selector', element => element.textContent); + * console.log(text); + * ``` + * + * @public + */ +export class Frame { + /** + * @internal + */ + _frameManager: FrameManager; + private _parentFrame?: Frame; + /** + * @internal + */ + _id: string; + + private _url = ''; + private _detached = false; + /** + * @internal + */ + _loaderId = ''; + /** + * @internal + */ + _name?: string; + + /** + * @internal + */ + _lifecycleEvents = new Set<string>(); + /** + * @internal + */ + _mainWorld: DOMWorld; + /** + * @internal + */ + _secondaryWorld: DOMWorld; + /** + * @internal + */ + _childFrames: Set<Frame>; + + /** + * @internal + */ + constructor( + frameManager: FrameManager, + parentFrame: Frame | null, + frameId: string + ) { + this._frameManager = frameManager; + this._parentFrame = parentFrame; + this._url = ''; + this._id = frameId; + this._detached = false; + + this._loaderId = ''; + this._mainWorld = new DOMWorld( + frameManager, + this, + frameManager._timeoutSettings + ); + this._secondaryWorld = new DOMWorld( + frameManager, + this, + frameManager._timeoutSettings + ); + + this._childFrames = new Set(); + if (this._parentFrame) this._parentFrame._childFrames.add(this); + } + + /** + * @remarks + * + * `frame.goto` will throw an error if: + * - there's an SSL error (e.g. in case of self-signed certificates). + * + * - target URL is invalid. + * + * - the `timeout` is exceeded during navigation. + * + * - the remote server does not respond or is unreachable. + * + * - the main resource failed to load. + * + * `frame.goto` will not throw an error when any valid HTTP status code is + * returned by the remote server, including 404 "Not Found" and 500 "Internal + * Server Error". The status code for such responses can be retrieved by + * calling {@link HTTPResponse.status}. + * + * NOTE: `frame.goto` either throws an error or returns a main resource + * response. The only exceptions are navigation to `about:blank` or + * navigation to the same URL with a different hash, which would succeed and + * return `null`. + * + * NOTE: Headless mode doesn't support navigation to a PDF document. See + * the {@link https://bugs.chromium.org/p/chromium/issues/detail?id=761295 | upstream + * issue}. + * + * @param url - the URL to navigate the frame to. This should include the + * scheme, e.g. `https://`. + * @param options - navigation options. `waitUntil` is useful to define when + * the navigation should be considered successful - see the docs for + * {@link PuppeteerLifeCycleEvent} for more details. + * + * @returns A promise which resolves to the main resource response. In case of + * multiple redirects, the navigation will resolve with the response of the + * last redirect. + */ + async goto( + url: string, + options: { + referer?: string; + timeout?: number; + waitUntil?: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[]; + } = {} + ): Promise<HTTPResponse | null> { + return await this._frameManager.navigateFrame(this, url, options); + } + + /** + * @remarks + * + * This resolves when the frame navigates to a new URL. It is useful for when + * you run code which will indirectly cause the frame to navigate. Consider + * this example: + * + * ```js + * const [response] = await Promise.all([ + * // The navigation promise resolves after navigation has finished + * frame.waitForNavigation(), + * // Clicking the link will indirectly cause a navigation + * frame.click('a.my-link'), + * ]); + * ``` + * + * Usage of the {@link https://developer.mozilla.org/en-US/docs/Web/API/History_API | History API} to change the URL is considered a navigation. + * + * @param options - options to configure when the navigation is consided finished. + * @returns a promise that resolves when the frame navigates to a new URL. + */ + async waitForNavigation( + options: { + timeout?: number; + waitUntil?: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[]; + } = {} + ): Promise<HTTPResponse | null> { + return await this._frameManager.waitForFrameNavigation(this, options); + } + + /** + * @returns a promise that resolves to the frame's default execution context. + */ + executionContext(): Promise<ExecutionContext> { + return this._mainWorld.executionContext(); + } + + /** + * @remarks + * + * The only difference between {@link Frame.evaluate} and + * `frame.evaluateHandle` is that `evaluateHandle` will return the value + * wrapped in an in-page object. + * + * This method behaves identically to {@link Page.evaluateHandle} except it's + * run within the context of the `frame`, rather than the entire page. + * + * @param pageFunction - a function that is run within the frame + * @param args - arguments to be passed to the pageFunction + */ + async evaluateHandle<HandlerType extends JSHandle = JSHandle>( + pageFunction: EvaluateHandleFn, + ...args: SerializableOrJSHandle[] + ): Promise<HandlerType> { + return this._mainWorld.evaluateHandle<HandlerType>(pageFunction, ...args); + } + + /** + * @remarks + * + * This method behaves identically to {@link Page.evaluate} except it's run + * within the context of the `frame`, rather than the entire page. + * + * @param pageFunction - a function that is run within the frame + * @param args - arguments to be passed to the pageFunction + */ + async evaluate<T extends EvaluateFn>( + pageFunction: T, + ...args: SerializableOrJSHandle[] + ): Promise<UnwrapPromiseLike<EvaluateFnReturnType<T>>> { + return this._mainWorld.evaluate<T>(pageFunction, ...args); + } + + /** + * This method queries the frame for the given selector. + * + * @param selector - a selector to query for. + * @returns A promise which resolves to an `ElementHandle` pointing at the + * element, or `null` if it was not found. + */ + async $(selector: string): Promise<ElementHandle | null> { + return this._mainWorld.$(selector); + } + + /** + * This method evaluates the given XPath expression and returns the results. + * + * @param expression - the XPath expression to evaluate. + */ + async $x(expression: string): Promise<ElementHandle[]> { + return this._mainWorld.$x(expression); + } + + /** + * @remarks + * + * This method runs `document.querySelector` within + * the frame and passes it as the first argument to `pageFunction`. + * + * If `pageFunction` returns a Promise, then `frame.$eval` would wait for + * the promise to resolve and return its value. + * + * @example + * + * ```js + * const searchValue = await frame.$eval('#search', el => el.value); + * ``` + * + * @param selector - the selector to query for + * @param pageFunction - the function to be evaluated in the frame's context + * @param args - additional arguments to pass to `pageFuncton` + */ + async $eval<ReturnType>( + selector: string, + pageFunction: ( + element: Element, + ...args: unknown[] + ) => ReturnType | Promise<ReturnType>, + ...args: SerializableOrJSHandle[] + ): Promise<WrapElementHandle<ReturnType>> { + return this._mainWorld.$eval<ReturnType>(selector, pageFunction, ...args); + } + + /** + * @remarks + * + * This method runs `Array.from(document.querySelectorAll(selector))` within + * the frame and passes it as the first argument to `pageFunction`. + * + * If `pageFunction` returns a Promise, then `frame.$$eval` would wait for + * the promise to resolve and return its value. + * + * @example + * + * ```js + * const divsCounts = await frame.$$eval('div', divs => divs.length); + * ``` + * + * @param selector - the selector to query for + * @param pageFunction - the function to be evaluated in the frame's context + * @param args - additional arguments to pass to `pageFuncton` + */ + async $$eval<ReturnType>( + selector: string, + pageFunction: ( + elements: Element[], + ...args: unknown[] + ) => ReturnType | Promise<ReturnType>, + ...args: SerializableOrJSHandle[] + ): Promise<WrapElementHandle<ReturnType>> { + return this._mainWorld.$$eval<ReturnType>(selector, pageFunction, ...args); + } + + /** + * This runs `document.querySelectorAll` in the frame and returns the result. + * + * @param selector - a selector to search for + * @returns An array of element handles pointing to the found frame elements. + */ + async $$(selector: string): Promise<ElementHandle[]> { + return this._mainWorld.$$(selector); + } + + /** + * @returns the full HTML contents of the frame, including the doctype. + */ + async content(): Promise<string> { + return this._secondaryWorld.content(); + } + + /** + * Set the content of the frame. + * + * @param html - HTML markup to assign to the page. + * @param options - options to configure how long before timing out and at + * what point to consider the content setting successful. + */ + async setContent( + html: string, + options: { + timeout?: number; + waitUntil?: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[]; + } = {} + ): Promise<void> { + return this._secondaryWorld.setContent(html, options); + } + + /** + * @remarks + * + * If the name is empty, it returns the `id` attribute instead. + * + * Note: This value is calculated once when the frame is created, and will not + * update if the attribute is changed later. + * + * @returns the frame's `name` attribute as specified in the tag. + */ + name(): string { + return this._name || ''; + } + + /** + * @returns the frame's URL. + */ + url(): string { + return this._url; + } + + /** + * @returns the parent `Frame`, if any. Detached and main frames return `null`. + */ + parentFrame(): Frame | null { + return this._parentFrame; + } + + /** + * @returns an array of child frames. + */ + childFrames(): Frame[] { + return Array.from(this._childFrames); + } + + /** + * @returns `true` if the frame has been detached, or `false` otherwise. + */ + isDetached(): boolean { + return this._detached; + } + + /** + * Adds a `<script>` tag into the page with the desired url or content. + * + * @param options - configure the script to add to the page. + * + * @returns a promise that resolves to the added tag when the script's + * `onload` event fires or when the script content was injected into the + * frame. + */ + async addScriptTag( + options: FrameAddScriptTagOptions + ): Promise<ElementHandle> { + return this._mainWorld.addScriptTag(options); + } + + /** + * Adds a `<link rel="stylesheet">` tag into the page with the desired url or + * a `<style type="text/css">` tag with the content. + * + * @param options - configure the CSS to add to the page. + * + * @returns a promise that resolves to the added tag when the stylesheets's + * `onload` event fires or when the CSS content was injected into the + * frame. + */ + async addStyleTag(options: FrameAddStyleTagOptions): Promise<ElementHandle> { + return this._mainWorld.addStyleTag(options); + } + + /** + * + * This method clicks the first element found that matches `selector`. + * + * @remarks + * + * This method scrolls the element into view if needed, and then uses + * {@link Page.mouse} to click in the center of the element. If there's no + * element matching `selector`, the method throws an error. + * + * Bear in mind that if `click()` triggers a navigation event and there's a + * separate `page.waitForNavigation()` promise to be resolved, you may end up + * with a race condition that yields unexpected results. The correct pattern + * for click and wait for navigation is the following: + * + * ```javascript + * const [response] = await Promise.all([ + * page.waitForNavigation(waitOptions), + * frame.click(selector, clickOptions), + * ]); + * ``` + * @param selector - the selector to search for to click. If there are + * multiple elements, the first will be clicked. + */ + async click( + selector: string, + options: { + delay?: number; + button?: MouseButton; + clickCount?: number; + } = {} + ): Promise<void> { + return this._secondaryWorld.click(selector, options); + } + + /** + * This method fetches an element with `selector` and focuses it. + * + * @remarks + * If there's no element matching `selector`, the method throws an error. + * + * @param selector - the selector for the element to focus. If there are + * multiple elements, the first will be focused. + */ + async focus(selector: string): Promise<void> { + return this._secondaryWorld.focus(selector); + } + + /** + * This method fetches an element with `selector`, scrolls it into view if + * needed, and then uses {@link Page.mouse} to hover over the center of the + * element. + * + * @remarks + * If there's no element matching `selector`, the method throws an + * + * @param selector - the selector for the element to hover. If there are + * multiple elements, the first will be hovered. + */ + async hover(selector: string): Promise<void> { + return this._secondaryWorld.hover(selector); + } + + /** + * Triggers a `change` and `input` event once all the provided options have + * been selected. + * + * @remarks + * + * If there's no `<select>` element matching `selector`, the + * method throws an error. + * + * @example + * ```js + * frame.select('select#colors', 'blue'); // single selection + * frame.select('select#colors', 'red', 'green', 'blue'); // multiple selections + * ``` + * + * @param selector - a selector to query the frame for + * @param values - an array of values to select. If the `<select>` has the + * `multiple` attribute, all values are considered, otherwise only the first + * one is taken into account. + * @returns the list of values that were successfully selected. + */ + select(selector: string, ...values: string[]): Promise<string[]> { + return this._secondaryWorld.select(selector, ...values); + } + + /** + * This method fetches an element with `selector`, scrolls it into view if + * needed, and then uses {@link Page.touchscreen} to tap in the center of the + * element. + * + * @remarks + * + * If there's no element matching `selector`, the method throws an error. + * + * @param selector - the selector to tap. + * @returns a promise that resolves when the element has been tapped. + */ + async tap(selector: string): Promise<void> { + return this._secondaryWorld.tap(selector); + } + + /** + * Sends a `keydown`, `keypress`/`input`, and `keyup` event for each character + * in the text. + * + * @remarks + * To press a special key, like `Control` or `ArrowDown`, use + * {@link Keyboard.press}. + * + * @example + * ```js + * await frame.type('#mytextarea', 'Hello'); // Types instantly + * await frame.type('#mytextarea', 'World', {delay: 100}); // Types slower, like a user + * ``` + * + * @param selector - the selector for the element to type into. If there are + * multiple the first will be used. + * @param text - text to type into the element + * @param options - takes one option, `delay`, which sets the time to wait + * between key presses in milliseconds. Defaults to `0`. + * + * @returns a promise that resolves when the typing is complete. + */ + async type( + selector: string, + text: string, + options?: { delay: number } + ): Promise<void> { + return this._mainWorld.type(selector, text, options); + } + + /** + * @remarks + * + * This method behaves differently depending on the first parameter. If it's a + * `string`, it will be treated as a `selector` or `xpath` (if the string + * starts with `//`). This method then is a shortcut for + * {@link Frame.waitForSelector} or {@link Frame.waitForXPath}. + * + * If the first argument is a function this method is a shortcut for + * {@link Frame.waitForFunction}. + * + * If the first argument is a `number`, it's treated as a timeout in + * milliseconds and the method returns a promise which resolves after the + * timeout. + * + * @param selectorOrFunctionOrTimeout - a selector, predicate or timeout to + * wait for. + * @param options - optional waiting parameters. + * @param args - arguments to pass to `pageFunction`. + * + * @deprecated Don't use this method directly. Instead use the more explicit + * methods available: {@link Frame.waitForSelector}, + * {@link Frame.waitForXPath}, {@link Frame.waitForFunction} or + * {@link Frame.waitForTimeout}. + */ + waitFor( + selectorOrFunctionOrTimeout: string | number | Function, + options: Record<string, unknown> = {}, + ...args: SerializableOrJSHandle[] + ): Promise<JSHandle | null> { + const xPathPattern = '//'; + + console.warn( + 'waitFor is deprecated and will be removed in a future release. See https://github.com/puppeteer/puppeteer/issues/6214 for details and how to migrate your code.' + ); + + if (helper.isString(selectorOrFunctionOrTimeout)) { + const string = selectorOrFunctionOrTimeout; + if (string.startsWith(xPathPattern)) + return this.waitForXPath(string, options); + return this.waitForSelector(string, options); + } + if (helper.isNumber(selectorOrFunctionOrTimeout)) + return new Promise((fulfill) => + setTimeout(fulfill, selectorOrFunctionOrTimeout) + ); + if (typeof selectorOrFunctionOrTimeout === 'function') + return this.waitForFunction( + selectorOrFunctionOrTimeout, + options, + ...args + ); + return Promise.reject( + new Error( + 'Unsupported target type: ' + typeof selectorOrFunctionOrTimeout + ) + ); + } + + /** + * Causes your script to wait for the given number of milliseconds. + * + * @remarks + * It's generally recommended to not wait for a number of seconds, but instead + * use {@link Frame.waitForSelector}, {@link Frame.waitForXPath} or + * {@link Frame.waitForFunction} to wait for exactly the conditions you want. + * + * @example + * + * Wait for 1 second: + * + * ``` + * await frame.waitForTimeout(1000); + * ``` + * + * @param milliseconds - the number of milliseconds to wait. + */ + waitForTimeout(milliseconds: number): Promise<void> { + return new Promise((resolve) => { + setTimeout(resolve, milliseconds); + }); + } + + /** + * @remarks + * + * + * Wait for the `selector` to appear in page. If at the moment of calling the + * method the `selector` already exists, the method will return immediately. + * If the selector doesn't appear after the `timeout` milliseconds of waiting, + * the function will throw. + * + * This method works across navigations. + * + * @example + * ```js + * const puppeteer = require('puppeteer'); + * + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * let currentURL; + * page.mainFrame() + * .waitForSelector('img') + * .then(() => console.log('First URL with image: ' + currentURL)); + * + * for (currentURL of ['https://example.com', 'https://google.com', 'https://bbc.com']) { + * await page.goto(currentURL); + * } + * await browser.close(); + * })(); + * ``` + * @param selector - the selector to wait for. + * @param options - options to define if the element should be visible and how + * long to wait before timing out. + * @returns a promise which resolves when an element matching the selector + * string is added to the DOM. + */ + async waitForSelector( + selector: string, + options: WaitForSelectorOptions = {} + ): Promise<ElementHandle | null> { + const handle = await this._secondaryWorld.waitForSelector( + selector, + options + ); + if (!handle) return null; + const mainExecutionContext = await this._mainWorld.executionContext(); + const result = await mainExecutionContext._adoptElementHandle(handle); + await handle.dispose(); + return result; + } + + /** + * @remarks + * Wait for the `xpath` to appear in page. If at the moment of calling the + * method the `xpath` already exists, the method will return immediately. If + * the xpath doesn't appear after the `timeout` milliseconds of waiting, the + * function will throw. + * + * For a code example, see the example for {@link Frame.waitForSelector}. That + * function behaves identically other than taking a CSS selector rather than + * an XPath. + * + * @param xpath - the XPath expression to wait for. + * @param options - options to configure the visiblity of the element and how + * long to wait before timing out. + */ + async waitForXPath( + xpath: string, + options: WaitForSelectorOptions = {} + ): Promise<ElementHandle | null> { + const handle = await this._secondaryWorld.waitForXPath(xpath, options); + if (!handle) return null; + const mainExecutionContext = await this._mainWorld.executionContext(); + const result = await mainExecutionContext._adoptElementHandle(handle); + await handle.dispose(); + return result; + } + + /** + * @remarks + * + * @example + * + * The `waitForFunction` can be used to observe viewport size change: + * ```js + * const puppeteer = require('puppeteer'); + * + * (async () => { + * . const browser = await puppeteer.launch(); + * . const page = await browser.newPage(); + * . const watchDog = page.mainFrame().waitForFunction('window.innerWidth < 100'); + * . page.setViewport({width: 50, height: 50}); + * . await watchDog; + * . await browser.close(); + * })(); + * ``` + * + * To pass arguments from Node.js to the predicate of `page.waitForFunction` function: + * + * ```js + * const selector = '.foo'; + * await frame.waitForFunction( + * selector => !!document.querySelector(selector), + * {}, // empty options object + * selector + *); + * ``` + * + * @param pageFunction - the function to evaluate in the frame context. + * @param options - options to configure the polling method and timeout. + * @param args - arguments to pass to the `pageFunction`. + * @returns the promise which resolve when the `pageFunction` returns a truthy value. + */ + waitForFunction( + pageFunction: Function | string, + options: FrameWaitForFunctionOptions = {}, + ...args: SerializableOrJSHandle[] + ): Promise<JSHandle> { + return this._mainWorld.waitForFunction(pageFunction, options, ...args); + } + + /** + * @returns the frame's title. + */ + async title(): Promise<string> { + return this._secondaryWorld.title(); + } + + /** + * @internal + */ + _navigated(framePayload: Protocol.Page.Frame): void { + this._name = framePayload.name; + this._url = `${framePayload.url}${framePayload.urlFragment || ''}`; + } + + /** + * @internal + */ + _navigatedWithinDocument(url: string): void { + this._url = url; + } + + /** + * @internal + */ + _onLifecycleEvent(loaderId: string, name: string): void { + if (name === 'init') { + this._loaderId = loaderId; + this._lifecycleEvents.clear(); + } + this._lifecycleEvents.add(name); + } + + /** + * @internal + */ + _onLoadingStopped(): void { + this._lifecycleEvents.add('DOMContentLoaded'); + this._lifecycleEvents.add('load'); + } + + /** + * @internal + */ + _detach(): void { + this._detached = true; + this._mainWorld._detach(); + this._secondaryWorld._detach(); + if (this._parentFrame) this._parentFrame._childFrames.delete(this); + this._parentFrame = null; + } +} + +function assertNoLegacyNavigationOptions(options: { + [optionName: string]: unknown; +}): void { + assert( + options['networkIdleTimeout'] === undefined, + 'ERROR: networkIdleTimeout option is no longer supported.' + ); + assert( + options['networkIdleInflight'] === undefined, + 'ERROR: networkIdleInflight option is no longer supported.' + ); + assert( + options.waitUntil !== 'networkidle', + 'ERROR: "networkidle" option is no longer supported. Use "networkidle2" instead' + ); +} diff --git a/remote/test/puppeteer/src/common/HTTPRequest.ts b/remote/test/puppeteer/src/common/HTTPRequest.ts new file mode 100644 index 0000000000..f068317616 --- /dev/null +++ b/remote/test/puppeteer/src/common/HTTPRequest.ts @@ -0,0 +1,537 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { CDPSession } from './Connection.js'; +import { Frame } from './FrameManager.js'; +import { HTTPResponse } from './HTTPResponse.js'; +import { assert } from './assert.js'; +import { helper, debugError } from './helper.js'; +import { Protocol } from 'devtools-protocol'; + +/** + * @public + */ +export interface ContinueRequestOverrides { + /** + * If set, the request URL will change. This is not a redirect. + */ + url?: string; + method?: string; + postData?: string; + headers?: Record<string, string>; +} + +/** + * Required response data to fulfill a request with. + * + * @public + */ +export interface ResponseForRequest { + status: number; + headers: Record<string, string>; + contentType: string; + body: string | Buffer; +} + +/** + * + * Represents an HTTP request sent by a page. + * @remarks + * + * Whenever the page sends a request, such as for a network resource, the + * following events are emitted by Puppeteer's `page`: + * + * - `request`: emitted when the request is issued by the page. + * - `requestfinished` - emitted when the response body is downloaded and the + * request is complete. + * + * If request fails at some point, then instead of `requestfinished` event the + * `requestfailed` event is emitted. + * + * All of these events provide an instance of `HTTPRequest` representing the + * request that occurred: + * + * ``` + * page.on('request', request => ...) + * ``` + * + * NOTE: HTTP Error responses, such as 404 or 503, are still successful + * responses from HTTP standpoint, so request will complete with + * `requestfinished` event. + * + * If request gets a 'redirect' response, the request is successfully finished + * with the `requestfinished` event, and a new request is issued to a + * redirected url. + * + * @public + */ +export class HTTPRequest { + /** + * @internal + */ + _requestId: string; + /** + * @internal + */ + _interceptionId: string; + /** + * @internal + */ + _failureText = null; + /** + * @internal + */ + _response: HTTPResponse | null = null; + /** + * @internal + */ + _fromMemoryCache = false; + /** + * @internal + */ + _redirectChain: HTTPRequest[]; + + private _client: CDPSession; + private _isNavigationRequest: boolean; + private _allowInterception: boolean; + private _interceptionHandled = false; + private _url: string; + private _resourceType: string; + + private _method: string; + private _postData?: string; + private _headers: Record<string, string> = {}; + private _frame: Frame; + + /** + * @internal + */ + constructor( + client: CDPSession, + frame: Frame, + interceptionId: string, + allowInterception: boolean, + event: Protocol.Network.RequestWillBeSentEvent, + redirectChain: HTTPRequest[] + ) { + this._client = client; + this._requestId = event.requestId; + this._isNavigationRequest = + event.requestId === event.loaderId && event.type === 'Document'; + this._interceptionId = interceptionId; + this._allowInterception = allowInterception; + this._url = event.request.url; + this._resourceType = event.type.toLowerCase(); + this._method = event.request.method; + this._postData = event.request.postData; + this._frame = frame; + this._redirectChain = redirectChain; + + for (const key of Object.keys(event.request.headers)) + this._headers[key.toLowerCase()] = event.request.headers[key]; + } + + /** + * @returns the URL of the request + */ + url(): string { + return this._url; + } + + /** + * Contains the request's resource type as it was perceived by the rendering + * engine. + * @remarks + * @returns one of the following: `document`, `stylesheet`, `image`, `media`, + * `font`, `script`, `texttrack`, `xhr`, `fetch`, `eventsource`, `websocket`, + * `manifest`, `other`. + */ + resourceType(): string { + // TODO (@jackfranklin): protocol.d.ts has a type for this, but all the + // string values are uppercase. The Puppeteer docs explicitly say the + // potential values are all lower case, and the constructor takes the event + // type and calls toLowerCase() on it, so we can't reuse the type from the + // protocol.d.ts. Why do we lower case? + return this._resourceType; + } + + /** + * @returns the method used (`GET`, `POST`, etc.) + */ + method(): string { + return this._method; + } + + /** + * @returns the request's post body, if any. + */ + postData(): string | undefined { + return this._postData; + } + + /** + * @returns an object with HTTP headers associated with the request. All + * header names are lower-case. + */ + headers(): Record<string, string> { + return this._headers; + } + + /** + * @returns the response for this request, if a response has been received. + */ + response(): HTTPResponse | null { + return this._response; + } + + /** + * @returns the frame that initiated the request. + */ + frame(): Frame | null { + return this._frame; + } + + /** + * @returns true if the request is the driver of the current frame's navigation. + */ + isNavigationRequest(): boolean { + return this._isNavigationRequest; + } + + /** + * @remarks + * + * `redirectChain` is shared between all the requests of the same chain. + * + * For example, if the website `http://example.com` has a single redirect to + * `https://example.com`, then the chain will contain one request: + * + * ```js + * const response = await page.goto('http://example.com'); + * const chain = response.request().redirectChain(); + * console.log(chain.length); // 1 + * console.log(chain[0].url()); // 'http://example.com' + * ``` + * + * If the website `https://google.com` has no redirects, then the chain will be empty: + * + * ```js + * const response = await page.goto('https://google.com'); + * const chain = response.request().redirectChain(); + * console.log(chain.length); // 0 + * ``` + * + * @returns the chain of requests - if a server responds with at least a + * single redirect, this chain will contain all requests that were redirected. + */ + redirectChain(): HTTPRequest[] { + return this._redirectChain.slice(); + } + + /** + * Access information about the request's failure. + * + * @remarks + * + * @example + * + * Example of logging all failed requests: + * + * ```js + * page.on('requestfailed', request => { + * console.log(request.url() + ' ' + request.failure().errorText); + * }); + * ``` + * + * @returns `null` unless the request failed. If the request fails this can + * return an object with `errorText` containing a human-readable error + * message, e.g. `net::ERR_FAILED`. It is not guaranteeded that there will be + * failure text if the request fails. + */ + failure(): { errorText: string } | null { + if (!this._failureText) return null; + return { + errorText: this._failureText, + }; + } + + /** + * Continues request with optional request overrides. + * + * @remarks + * + * To use this, request + * interception should be enabled with {@link Page.setRequestInterception}. + * + * Exception is immediately thrown if the request interception is not enabled. + * + * @example + * ```js + * await page.setRequestInterception(true); + * page.on('request', request => { + * // Override headers + * const headers = Object.assign({}, request.headers(), { + * foo: 'bar', // set "foo" header + * origin: undefined, // remove "origin" header + * }); + * request.continue({headers}); + * }); + * ``` + * + * @param overrides - optional overrides to apply to the request. + */ + async continue(overrides: ContinueRequestOverrides = {}): 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!'); + const { url, method, postData, headers } = overrides; + this._interceptionHandled = true; + + const postDataBinaryBase64 = postData + ? Buffer.from(postData).toString('base64') + : undefined; + + await this._client + .send('Fetch.continueRequest', { + requestId: this._interceptionId, + url, + method, + postData: postDataBinaryBase64, + headers: headers ? headersArray(headers) : undefined, + }) + .catch((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); + }); + } + + /** + * Fulfills a request with the given response. + * + * @remarks + * + * To use this, request + * interception should be enabled with {@link Page.setRequestInterception}. + * + * Exception is immediately thrown if the request interception is not enabled. + * + * @example + * An example of fulfilling all requests with 404 responses: + * ```js + * await page.setRequestInterception(true); + * page.on('request', request => { + * request.respond({ + * status: 404, + * contentType: 'text/plain', + * body: 'Not Found!' + * }); + * }); + * ``` + * + * NOTE: Mocking responses for dataURL requests is not supported. + * Calling `request.respond` for a dataURL request is a noop. + * + * @param response - the response to fulfill the request with. + */ + async respond(response: ResponseForRequest): 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!'); + this._interceptionHandled = true; + + const responseBody: Buffer | null = + response.body && helper.isString(response.body) + ? Buffer.from(response.body) + : (response.body as Buffer) || null; + + const responseHeaders: Record<string, string> = {}; + if (response.headers) { + for (const header of Object.keys(response.headers)) + responseHeaders[header.toLowerCase()] = response.headers[header]; + } + if (response.contentType) + responseHeaders['content-type'] = response.contentType; + if (responseBody && !('content-length' in responseHeaders)) + responseHeaders['content-length'] = String( + Buffer.byteLength(responseBody) + ); + + await this._client + .send('Fetch.fulfillRequest', { + requestId: this._interceptionId, + responseCode: response.status || 200, + responsePhrase: STATUS_TEXTS[response.status || 200], + responseHeaders: headersArray(responseHeaders), + body: responseBody ? responseBody.toString('base64') : undefined, + }) + .catch((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); + }); + } + + /** + * Aborts a request. + * + * @remarks + * To use this, request interception should be enabled with + * {@link Page.setRequestInterception}. If it is not enabled, this method will + * throw an exception immediately. + * + * @param errorCode - optional error code to provide. + */ + async abort(errorCode: ErrorCode = 'failed'): 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!'); + this._interceptionHandled = true; + await this._client + .send('Fetch.failRequest', { + requestId: this._interceptionId, + errorReason, + }) + .catch((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); + }); + } +} + +/** + * @public + */ +export type ErrorCode = + | 'aborted' + | 'accessdenied' + | 'addressunreachable' + | 'blockedbyclient' + | 'blockedbyresponse' + | 'connectionaborted' + | 'connectionclosed' + | 'connectionfailed' + | 'connectionrefused' + | 'connectionreset' + | 'internetdisconnected' + | 'namenotresolved' + | 'timedout' + | 'failed'; + +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; + +function headersArray( + headers: Record<string, string> +): Array<{ name: string; value: string }> { + const result = []; + for (const name in headers) { + if (!Object.is(headers[name], undefined)) + result.push({ name, value: headers[name] + '' }); + } + return result; +} + +// List taken from +// https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml +// with extra 306 and 418 codes. +const STATUS_TEXTS = { + '100': 'Continue', + '101': 'Switching Protocols', + '102': 'Processing', + '103': 'Early Hints', + '200': 'OK', + '201': 'Created', + '202': 'Accepted', + '203': 'Non-Authoritative Information', + '204': 'No Content', + '205': 'Reset Content', + '206': 'Partial Content', + '207': 'Multi-Status', + '208': 'Already Reported', + '226': 'IM Used', + '300': 'Multiple Choices', + '301': 'Moved Permanently', + '302': 'Found', + '303': 'See Other', + '304': 'Not Modified', + '305': 'Use Proxy', + '306': 'Switch Proxy', + '307': 'Temporary Redirect', + '308': 'Permanent Redirect', + '400': 'Bad Request', + '401': 'Unauthorized', + '402': 'Payment Required', + '403': 'Forbidden', + '404': 'Not Found', + '405': 'Method Not Allowed', + '406': 'Not Acceptable', + '407': 'Proxy Authentication Required', + '408': 'Request Timeout', + '409': 'Conflict', + '410': 'Gone', + '411': 'Length Required', + '412': 'Precondition Failed', + '413': 'Payload Too Large', + '414': 'URI Too Long', + '415': 'Unsupported Media Type', + '416': 'Range Not Satisfiable', + '417': 'Expectation Failed', + '418': "I'm a teapot", + '421': 'Misdirected Request', + '422': 'Unprocessable Entity', + '423': 'Locked', + '424': 'Failed Dependency', + '425': 'Too Early', + '426': 'Upgrade Required', + '428': 'Precondition Required', + '429': 'Too Many Requests', + '431': 'Request Header Fields Too Large', + '451': 'Unavailable For Legal Reasons', + '500': 'Internal Server Error', + '501': 'Not Implemented', + '502': 'Bad Gateway', + '503': 'Service Unavailable', + '504': 'Gateway Timeout', + '505': 'HTTP Version Not Supported', + '506': 'Variant Also Negotiates', + '507': 'Insufficient Storage', + '508': 'Loop Detected', + '510': 'Not Extended', + '511': 'Network Authentication Required', +} as const; diff --git a/remote/test/puppeteer/src/common/HTTPResponse.ts b/remote/test/puppeteer/src/common/HTTPResponse.ts new file mode 100644 index 0000000000..3df6c62761 --- /dev/null +++ b/remote/test/puppeteer/src/common/HTTPResponse.ts @@ -0,0 +1,213 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { CDPSession } from './Connection.js'; +import { Frame } from './FrameManager.js'; +import { HTTPRequest } from './HTTPRequest.js'; +import { SecurityDetails } from './SecurityDetails.js'; +import { Protocol } from 'devtools-protocol'; + +/** + * @public + */ +export interface RemoteAddress { + ip: string; + port: number; +} + +/** + * The HTTPResponse class represents responses which are received by the + * {@link Page} class. + * + * @public + */ +export class HTTPResponse { + private _client: CDPSession; + private _request: HTTPRequest; + private _contentPromise: Promise<Buffer> | null = null; + private _bodyLoadedPromise: Promise<Error | void>; + private _bodyLoadedPromiseFulfill: (err: Error | void) => void; + private _remoteAddress: RemoteAddress; + private _status: number; + private _statusText: string; + private _url: string; + private _fromDiskCache: boolean; + private _fromServiceWorker: boolean; + private _headers: Record<string, string> = {}; + private _securityDetails: SecurityDetails | null; + + /** + * @internal + */ + constructor( + client: CDPSession, + request: HTTPRequest, + responsePayload: Protocol.Network.Response + ) { + this._client = client; + this._request = request; + + this._bodyLoadedPromise = new Promise((fulfill) => { + this._bodyLoadedPromiseFulfill = fulfill; + }); + + this._remoteAddress = { + ip: responsePayload.remoteIPAddress, + port: responsePayload.remotePort, + }; + this._status = responsePayload.status; + this._statusText = responsePayload.statusText; + this._url = request.url(); + this._fromDiskCache = !!responsePayload.fromDiskCache; + this._fromServiceWorker = !!responsePayload.fromServiceWorker; + for (const key of Object.keys(responsePayload.headers)) + this._headers[key.toLowerCase()] = responsePayload.headers[key]; + this._securityDetails = responsePayload.securityDetails + ? new SecurityDetails(responsePayload.securityDetails) + : null; + } + + /** + * @internal + */ + _resolveBody(err: Error | null): void { + return this._bodyLoadedPromiseFulfill(err); + } + + /** + * @returns The IP address and port number used to connect to the remote + * server. + */ + remoteAddress(): RemoteAddress { + return this._remoteAddress; + } + + /** + * @returns The URL of the response. + */ + url(): string { + return this._url; + } + + /** + * @returns True if the response was successful (status in the range 200-299). + */ + ok(): boolean { + // TODO: document === 0 case? + return this._status === 0 || (this._status >= 200 && this._status <= 299); + } + + /** + * @returns The status code of the response (e.g., 200 for a success). + */ + status(): number { + return this._status; + } + + /** + * @returns The status text of the response (e.g. usually an "OK" for a + * success). + */ + statusText(): string { + return this._statusText; + } + + /** + * @returns An object with HTTP headers associated with the response. All + * header names are lower-case. + */ + headers(): Record<string, string> { + return this._headers; + } + + /** + * @returns {@link SecurityDetails} if the response was received over the + * secure connection, or `null` otherwise. + */ + securityDetails(): SecurityDetails | null { + return this._securityDetails; + } + + /** + * @returns Promise which resolves to a buffer with response body. + */ + buffer(): Promise<Buffer> { + if (!this._contentPromise) { + this._contentPromise = this._bodyLoadedPromise.then(async (error) => { + if (error) throw error; + const response = await this._client.send('Network.getResponseBody', { + requestId: this._request._requestId, + }); + return Buffer.from( + response.body, + response.base64Encoded ? 'base64' : 'utf8' + ); + }); + } + return this._contentPromise; + } + + /** + * @returns Promise which resolves to a text representation of response body. + */ + async text(): Promise<string> { + const content = await this.buffer(); + return content.toString('utf8'); + } + + /** + * + * @returns Promise which resolves to a JSON representation of response body. + * + * @remarks + * + * This method will throw if the response body is not parsable via + * `JSON.parse`. + */ + async json(): Promise<any> { + const content = await this.text(); + return JSON.parse(content); + } + + /** + * @returns A matching {@link HTTPRequest} object. + */ + request(): HTTPRequest { + return this._request; + } + + /** + * @returns True if the response was served from either the browser's disk + * cache or memory cache. + */ + fromCache(): boolean { + return this._fromDiskCache || this._request._fromMemoryCache; + } + + /** + * @returns True if the response was served by a service worker. + */ + fromServiceWorker(): boolean { + return this._fromServiceWorker; + } + + /** + * @returns A {@link Frame} that initiated this response, or `null` if + * navigating to error pages. + */ + frame(): Frame | null { + return this._request.frame(); + } +} diff --git a/remote/test/puppeteer/src/common/Input.ts b/remote/test/puppeteer/src/common/Input.ts new file mode 100644 index 0000000000..3123706256 --- /dev/null +++ b/remote/test/puppeteer/src/common/Input.ts @@ -0,0 +1,525 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the 'License'); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an 'AS IS' BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { assert } from './assert.js'; +import { CDPSession } from './Connection.js'; +import { keyDefinitions, KeyDefinition, KeyInput } from './USKeyboardLayout.js'; + +type KeyDescription = Required< + Pick<KeyDefinition, 'keyCode' | 'key' | 'text' | 'code' | 'location'> +>; + +/** + * Keyboard provides an api for managing a virtual keyboard. + * The high level api is {@link Keyboard."type"}, + * which takes raw characters and generates proper keydown, keypress/input, + * and keyup events on your page. + * + * @remarks + * For finer control, you can use {@link Keyboard.down}, + * {@link Keyboard.up}, and {@link Keyboard.sendCharacter} + * to manually fire events as if they were generated from a real keyboard. + * + * On MacOS, keyboard shortcuts like `⌘ A` -\> Select All do not work. + * See {@link https://github.com/puppeteer/puppeteer/issues/1313 | #1313}. + * + * @example + * An example of holding down `Shift` in order to select and delete some text: + * ```js + * await page.keyboard.type('Hello World!'); + * await page.keyboard.press('ArrowLeft'); + * + * await page.keyboard.down('Shift'); + * for (let i = 0; i < ' World'.length; i++) + * await page.keyboard.press('ArrowLeft'); + * await page.keyboard.up('Shift'); + * + * await page.keyboard.press('Backspace'); + * // Result text will end up saying 'Hello!' + * ``` + * + * @example + * An example of pressing `A` + * ```js + * await page.keyboard.down('Shift'); + * await page.keyboard.press('KeyA'); + * await page.keyboard.up('Shift'); + * ``` + * + * @public + */ +export class Keyboard { + private _client: CDPSession; + /** @internal */ + _modifiers = 0; + private _pressedKeys = new Set<string>(); + + /** @internal */ + constructor(client: CDPSession) { + this._client = client; + } + + /** + * Dispatches a `keydown` event. + * + * @remarks + * If `key` is a single character and no modifier keys besides `Shift` + * are being held down, a `keypress`/`input` event will also generated. + * The `text` option can be specified to force an input event to be generated. + * If `key` is a modifier key, `Shift`, `Meta`, `Control`, or `Alt`, + * subsequent key presses will be sent with that modifier active. + * To release the modifier key, use {@link Keyboard.up}. + * + * After the key is pressed once, subsequent calls to + * {@link Keyboard.down} will have + * {@link https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/repeat | repeat} + * set to true. To release the key, use {@link Keyboard.up}. + * + * Modifier keys DO influence {@link Keyboard.down}. + * Holding down `Shift` will type the text in upper case. + * + * @param key - Name of key to press, such as `ArrowLeft`. + * See {@link KeyInput} for a list of all key names. + * + * @param options - An object of options. Accepts text which, if specified, + * generates an input event with this text. + */ + async down( + key: KeyInput, + options: { text?: string } = { text: undefined } + ): 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, + }); + } + + private _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; + } + + private _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; + } + + /** + * Dispatches a `keyup` event. + * + * @param key - Name of key to release, such as `ArrowLeft`. + * See {@link KeyInput | KeyInput} + * for a list of all key names. + */ + 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, + }); + } + + /** + * Dispatches a `keypress` and `input` event. + * This does not send a `keydown` or `keyup` event. + * + * @remarks + * Modifier keys DO NOT effect {@link Keyboard.sendCharacter | Keyboard.sendCharacter}. + * Holding down `Shift` will not type the text in upper case. + * + * @example + * ```js + * page.keyboard.sendCharacter('嗨'); + * ``` + * + * @param char - Character to send into the page. + */ + async sendCharacter(char: string): Promise<void> { + await this._client.send('Input.insertText', { text: char }); + } + + private charIsKey(char: string): char is KeyInput { + return !!keyDefinitions[char]; + } + + /** + * Sends a `keydown`, `keypress`/`input`, + * and `keyup` event for each character in the text. + * + * @remarks + * To press a special key, like `Control` or `ArrowDown`, + * use {@link Keyboard.press}. + * + * Modifier keys DO NOT effect `keyboard.type`. + * Holding down `Shift` will not type the text in upper case. + * + * @example + * ```js + * await page.keyboard.type('Hello'); // Types instantly + * await page.keyboard.type('World', {delay: 100}); // Types slower, like a user + * ``` + * + * @param text - A text to type into a focused element. + * @param options - An object of options. Accepts delay which, + * if specified, is the time to wait between `keydown` and `keyup` in milliseconds. + * Defaults to 0. + */ + async type(text: string, options: { delay?: number } = {}): Promise<void> { + const delay = options.delay || null; + for (const char of text) { + if (this.charIsKey(char)) { + await this.press(char, { delay }); + } else { + if (delay) await new Promise((f) => setTimeout(f, delay)); + await this.sendCharacter(char); + } + } + } + + /** + * Shortcut for {@link Keyboard.down} + * and {@link Keyboard.up}. + * + * @remarks + * If `key` is a single character and no modifier keys besides `Shift` + * are being held down, a `keypress`/`input` event will also generated. + * The `text` option can be specified to force an input event to be generated. + * + * Modifier keys DO effect {@link Keyboard.press}. + * Holding down `Shift` will type the text in upper case. + * + * @param key - Name of key to press, such as `ArrowLeft`. + * See {@link KeyInput} for a list of all key names. + * + * @param options - An object of options. Accepts text which, if specified, + * generates an input event with this text. Accepts delay which, + * if specified, is the time to wait between `keydown` and `keyup` in milliseconds. + * Defaults to 0. + */ + async press( + key: KeyInput, + options: { delay?: number; text?: string } = {} + ): Promise<void> { + const { delay = null } = options; + await this.down(key, options); + if (delay) await new Promise((f) => setTimeout(f, options.delay)); + await this.up(key); + } +} + +/** + * @public + */ +export type MouseButton = 'left' | 'right' | 'middle'; + +/** + * @public + */ +export interface MouseOptions { + button?: MouseButton; + clickCount?: number; +} + +/** + * @public + */ +export interface MouseWheelOptions { + deltaX?: number; + deltaY?: number; +} + +/** + * The Mouse class operates in main-frame CSS pixels + * relative to the top-left corner of the viewport. + * @remarks + * Every `page` object has its own Mouse, accessible with [`page.mouse`](#pagemouse). + * + * @example + * ```js + * // Using ‘page.mouse’ to trace a 100x100 square. + * await page.mouse.move(0, 0); + * await page.mouse.down(); + * await page.mouse.move(0, 100); + * await page.mouse.move(100, 100); + * await page.mouse.move(100, 0); + * await page.mouse.move(0, 0); + * await page.mouse.up(); + * ``` + * + * **Note**: The mouse events trigger synthetic `MouseEvent`s. + * This means that it does not fully replicate the functionality of what a normal user + * would be able to do with their mouse. + * + * For example, dragging and selecting text is not possible using `page.mouse`. + * Instead, you can use the {@link https://developer.mozilla.org/en-US/docs/Web/API/DocumentOrShadowRoot/getSelection | `DocumentOrShadowRoot.getSelection()`} functionality implemented in the platform. + * + * @example + * For example, if you want to select all content between nodes: + * ```js + * await page.evaluate((from, to) => { + * const selection = from.getRootNode().getSelection(); + * const range = document.createRange(); + * range.setStartBefore(from); + * range.setEndAfter(to); + * selection.removeAllRanges(); + * selection.addRange(range); + * }, fromJSHandle, toJSHandle); + * ``` + * If you then would want to copy-paste your selection, you can use the clipboard api: + * ```js + * // The clipboard api does not allow you to copy, unless the tab is focused. + * await page.bringToFront(); + * await page.evaluate(() => { + * // Copy the selected content to the clipboard + * document.execCommand('copy'); + * // Obtain the content of the clipboard as a string + * return navigator.clipboard.readText(); + * }); + * ``` + * **Note**: If you want access to the clipboard API, + * you have to give it permission to do so: + * ```js + * await browser.defaultBrowserContext().overridePermissions( + * '<your origin>', ['clipboard-read', 'clipboard-write'] + * ); + * ``` + * @public + */ +export class Mouse { + private _client: CDPSession; + private _keyboard: Keyboard; + private _x = 0; + private _y = 0; + private _button: MouseButton | 'none' = 'none'; + + /** + * @internal + */ + constructor(client: CDPSession, keyboard: Keyboard) { + this._client = client; + this._keyboard = keyboard; + } + + /** + * Dispatches a `mousemove` event. + * @param x - Horizontal position of the mouse. + * @param y - Vertical position of the mouse. + * @param options - Optional object. If specified, the `steps` property + * sends intermediate `mousemove` events when set to `1` (default). + */ + async move( + x: number, + y: number, + options: { steps?: number } = {} + ): Promise<void> { + const { steps = 1 } = options; + const fromX = this._x, + fromY = this._y; + this._x = x; + this._y = y; + for (let i = 1; i <= steps; i++) { + await this._client.send('Input.dispatchMouseEvent', { + type: 'mouseMoved', + button: this._button, + x: fromX + (this._x - fromX) * (i / steps), + y: fromY + (this._y - fromY) * (i / steps), + modifiers: this._keyboard._modifiers, + }); + } + } + + /** + * Shortcut for `mouse.move`, `mouse.down` and `mouse.up`. + * @param x - Horizontal position of the mouse. + * @param y - Vertical position of the mouse. + * @param options - Optional `MouseOptions`. + */ + async click( + x: number, + y: number, + options: MouseOptions & { delay?: number } = {} + ): Promise<void> { + const { delay = null } = options; + if (delay !== null) { + await Promise.all([this.move(x, y), this.down(options)]); + await new Promise((f) => setTimeout(f, delay)); + await this.up(options); + } else { + await Promise.all([ + this.move(x, y), + this.down(options), + this.up(options), + ]); + } + } + + /** + * Dispatches a `mousedown` event. + * @param options - Optional `MouseOptions`. + */ + async down(options: MouseOptions = {}): Promise<void> { + const { button = 'left', clickCount = 1 } = options; + this._button = button; + await this._client.send('Input.dispatchMouseEvent', { + type: 'mousePressed', + button, + x: this._x, + y: this._y, + modifiers: this._keyboard._modifiers, + clickCount, + }); + } + + /** + * Dispatches a `mouseup` event. + * @param options - Optional `MouseOptions`. + */ + async up(options: MouseOptions = {}): Promise<void> { + const { button = 'left', clickCount = 1 } = options; + this._button = 'none'; + await this._client.send('Input.dispatchMouseEvent', { + type: 'mouseReleased', + button, + x: this._x, + y: this._y, + modifiers: this._keyboard._modifiers, + clickCount, + }); + } + + /** + * Dispatches a `mousewheel` event. + * @param options - Optional: `MouseWheelOptions`. + * + * @example + * An example of zooming into an element: + * ```js + * await page.goto('https://mdn.mozillademos.org/en-US/docs/Web/API/Element/wheel_event$samples/Scaling_an_element_via_the_wheel?revision=1587366'); + * + * const elem = await page.$('div'); + * const boundingBox = await elem.boundingBox(); + * await page.mouse.move( + * boundingBox.x + boundingBox.width / 2, + * boundingBox.y + boundingBox.height / 2 + * ); + * + * await page.mouse.wheel({ deltaY: -100 }) + * ``` + */ + async wheel(options: MouseWheelOptions = {}): Promise<void> { + const { deltaX = 0, deltaY = 0 } = options; + await this._client.send('Input.dispatchMouseEvent', { + type: 'mouseWheel', + x: this._x, + y: this._y, + deltaX, + deltaY, + modifiers: this._keyboard._modifiers, + pointerType: 'mouse', + }); + } +} + +/** + * The Touchscreen class exposes touchscreen events. + * @public + */ +export class Touchscreen { + private _client: CDPSession; + private _keyboard: Keyboard; + + /** + * @internal + */ + constructor(client: CDPSession, keyboard: Keyboard) { + this._client = client; + this._keyboard = keyboard; + } + + /** + * Dispatches a `touchstart` and `touchend` event. + * @param x - Horizontal position of the tap. + * @param y - Vertical position of the tap. + */ + async tap(x: number, y: number): Promise<void> { + const touchPoints = [{ x: Math.round(x), y: Math.round(y) }]; + await this._client.send('Input.dispatchTouchEvent', { + type: 'touchStart', + touchPoints, + modifiers: this._keyboard._modifiers, + }); + await this._client.send('Input.dispatchTouchEvent', { + type: 'touchEnd', + touchPoints: [], + modifiers: this._keyboard._modifiers, + }); + } +} diff --git a/remote/test/puppeteer/src/common/JSHandle.ts b/remote/test/puppeteer/src/common/JSHandle.ts new file mode 100644 index 0000000000..c0ee4070b9 --- /dev/null +++ b/remote/test/puppeteer/src/common/JSHandle.ts @@ -0,0 +1,982 @@ +/** + * Copyright 2019 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { assert } from './assert.js'; +import { helper, debugError } from './helper.js'; +import { ExecutionContext } from './ExecutionContext.js'; +import { Page } from './Page.js'; +import { CDPSession } from './Connection.js'; +import { KeyInput } from './USKeyboardLayout.js'; +import { FrameManager, Frame } from './FrameManager.js'; +import { getQueryHandlerAndSelector } from './QueryHandler.js'; +import { Protocol } from 'devtools-protocol'; +import { + EvaluateFn, + SerializableOrJSHandle, + EvaluateFnReturnType, + EvaluateHandleFn, + WrapElementHandle, + UnwrapPromiseLike, +} from './EvalTypes.js'; +import { isNode } from '../environment.js'; + +export interface BoxModel { + content: Array<{ x: number; y: number }>; + padding: Array<{ x: number; y: number }>; + border: Array<{ x: number; y: number }>; + margin: Array<{ x: number; y: number }>; + width: number; + height: number; +} + +/** + * @public + */ +export interface BoundingBox { + /** + * the x coordinate of the element in pixels. + */ + x: number; + /** + * the y coordinate of the element in pixels. + */ + y: number; + /** + * the width of the element in pixels. + */ + width: number; + /** + * the height of the element in pixels. + */ + height: number; +} + +/** + * @internal + */ +export function createJSHandle( + context: ExecutionContext, + remoteObject: Protocol.Runtime.RemoteObject +): JSHandle { + const frame = context.frame(); + if (remoteObject.subtype === 'node' && frame) { + const frameManager = frame._frameManager; + return new ElementHandle( + context, + context._client, + remoteObject, + frameManager.page(), + frameManager + ); + } + return new JSHandle(context, context._client, remoteObject); +} + +/** + * Represents an in-page JavaScript object. JSHandles can be created with the + * {@link Page.evaluateHandle | page.evaluateHandle} method. + * + * @example + * ```js + * const windowHandle = await page.evaluateHandle(() => window); + * ``` + * + * JSHandle prevents the referenced JavaScript object from being garbage-collected + * unless the handle is {@link JSHandle.dispose | disposed}. JSHandles are auto- + * disposed when their origin frame gets navigated or the parent context gets destroyed. + * + * JSHandle instances can be used as arguments for {@link Page.$eval}, + * {@link Page.evaluate}, and {@link Page.evaluateHandle}. + * + * @public + */ +export class JSHandle { + /** + * @internal + */ + _context: ExecutionContext; + /** + * @internal + */ + _client: CDPSession; + /** + * @internal + */ + _remoteObject: Protocol.Runtime.RemoteObject; + /** + * @internal + */ + _disposed = false; + + /** + * @internal + */ + constructor( + context: ExecutionContext, + client: CDPSession, + remoteObject: Protocol.Runtime.RemoteObject + ) { + this._context = context; + this._client = client; + this._remoteObject = remoteObject; + } + + /** Returns the execution context the handle belongs to. + */ + executionContext(): ExecutionContext { + return this._context; + } + + /** + * This method passes this handle as the first argument to `pageFunction`. + * If `pageFunction` returns a Promise, then `handle.evaluate` would wait + * for the promise to resolve and return its value. + * + * @example + * ```js + * const tweetHandle = await page.$('.tweet .retweets'); + * expect(await tweetHandle.evaluate(node => node.innerText)).toBe('10'); + * ``` + */ + + async evaluate<T extends EvaluateFn>( + pageFunction: T | string, + ...args: SerializableOrJSHandle[] + ): Promise<UnwrapPromiseLike<EvaluateFnReturnType<T>>> { + return await this.executionContext().evaluate< + UnwrapPromiseLike<EvaluateFnReturnType<T>> + >(pageFunction, this, ...args); + } + + /** + * This method passes this handle as the first argument to `pageFunction`. + * + * @remarks + * + * The only difference between `jsHandle.evaluate` and + * `jsHandle.evaluateHandle` is that `jsHandle.evaluateHandle` + * returns an in-page object (JSHandle). + * + * If the function passed to `jsHandle.evaluateHandle` returns a Promise, + * then `evaluateHandle.evaluateHandle` waits for the promise to resolve and + * returns its value. + * + * See {@link Page.evaluateHandle} for more details. + */ + async evaluateHandle<HandleType extends JSHandle = JSHandle>( + pageFunction: EvaluateHandleFn, + ...args: SerializableOrJSHandle[] + ): Promise<HandleType> { + return await this.executionContext().evaluateHandle( + pageFunction, + this, + ...args + ); + } + + /** Fetches a single property from the referenced object. + */ + async getProperty(propertyName: string): Promise<JSHandle | undefined> { + const objectHandle = await this.evaluateHandle( + (object: HTMLElement, propertyName: string) => { + const result = { __proto__: null }; + result[propertyName] = object[propertyName]; + return result; + }, + propertyName + ); + const properties = await objectHandle.getProperties(); + const result = properties.get(propertyName) || null; + await objectHandle.dispose(); + return result; + } + + /** + * The method returns a map with property names as keys and JSHandle + * instances for the property values. + * + * @example + * ```js + * const listHandle = await page.evaluateHandle(() => document.body.children); + * const properties = await listHandle.getProperties(); + * const children = []; + * for (const property of properties.values()) { + * const element = property.asElement(); + * if (element) + * children.push(element); + * } + * children; // holds elementHandles to all children of document.body + * ``` + */ + async getProperties(): Promise<Map<string, JSHandle>> { + const response = await this._client.send('Runtime.getProperties', { + objectId: this._remoteObject.objectId, + ownProperties: true, + }); + const result = new Map<string, JSHandle>(); + for (const property of response.result) { + if (!property.enumerable) continue; + result.set(property.name, createJSHandle(this._context, property.value)); + } + return result; + } + + /** + * Returns a JSON representation of the object. + * + * @remarks + * + * The JSON is generated by running {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify | JSON.stringify} + * on the object in page and consequent {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse | JSON.parse} in puppeteer. + * **NOTE** The method throws if the referenced object is not stringifiable. + */ + async jsonValue(): Promise<Record<string, unknown>> { + if (this._remoteObject.objectId) { + const response = await this._client.send('Runtime.callFunctionOn', { + functionDeclaration: 'function() { return this; }', + objectId: this._remoteObject.objectId, + returnByValue: true, + awaitPromise: true, + }); + return helper.valueFromRemoteObject(response.result); + } + return helper.valueFromRemoteObject(this._remoteObject); + } + + /** + * Returns either `null` or the object handle itself, if the object handle is + * an instance of {@link ElementHandle}. + */ + asElement(): ElementHandle | null { + // This always returns null, but subclasses can override this and return an + // ElementHandle. + return null; + } + + /** + * Stops referencing the element handle, and resolves when the object handle is + * successfully disposed of. + */ + async dispose(): Promise<void> { + if (this._disposed) return; + this._disposed = true; + await helper.releaseObject(this._client, this._remoteObject); + } + + /** + * Returns a string representation of the JSHandle. + * + * @remarks Useful during debugging. + */ + toString(): string { + if (this._remoteObject.objectId) { + const type = this._remoteObject.subtype || this._remoteObject.type; + return 'JSHandle@' + type; + } + return 'JSHandle:' + helper.valueFromRemoteObject(this._remoteObject); + } +} + +/** + * ElementHandle represents an in-page DOM element. + * + * @remarks + * + * ElementHandles can be created with the {@link Page.$} method. + * + * ```js + * const puppeteer = require('puppeteer'); + * + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * await page.goto('https://example.com'); + * const hrefElement = await page.$('a'); + * await hrefElement.click(); + * // ... + * })(); + * ``` + * + * ElementHandle prevents the DOM element from being garbage-collected unless the + * handle is {@link JSHandle.dispose | disposed}. ElementHandles are auto-disposed + * when their origin frame gets navigated. + * + * ElementHandle instances can be used as arguments in {@link Page.$eval} and + * {@link Page.evaluate} methods. + * + * If you're using TypeScript, ElementHandle takes a generic argument that + * denotes the type of element the handle is holding within. For example, if you + * have a handle to a `<select>` element, you can type it as + * `ElementHandle<HTMLSelectElement>` and you get some nicer type checks. + * + * @public + */ +export class ElementHandle< + ElementType extends Element = Element +> extends JSHandle { + private _page: Page; + private _frameManager: FrameManager; + + /** + * @internal + */ + constructor( + context: ExecutionContext, + client: CDPSession, + remoteObject: Protocol.Runtime.RemoteObject, + page: Page, + frameManager: FrameManager + ) { + super(context, client, remoteObject); + this._client = client; + this._remoteObject = remoteObject; + this._page = page; + this._frameManager = frameManager; + } + + asElement(): ElementHandle<ElementType> | null { + return this; + } + + /** + * Resolves to the content frame for element handles referencing + * iframe nodes, or null otherwise + */ + async contentFrame(): Promise<Frame | null> { + const nodeInfo = await this._client.send('DOM.describeNode', { + objectId: this._remoteObject.objectId, + }); + if (typeof nodeInfo.node.frameId !== 'string') return null; + return this._frameManager.frame(nodeInfo.node.frameId); + } + + private async _scrollIntoViewIfNeeded(): Promise<void> { + const error = await this.evaluate< + ( + element: Element, + pageJavascriptEnabled: boolean + ) => Promise<string | false> + >(async (element, pageJavascriptEnabled) => { + if (!element.isConnected) return 'Node is detached from document'; + if (element.nodeType !== Node.ELEMENT_NODE) + return 'Node is not of type HTMLElement'; + // force-scroll if page's javascript is disabled. + if (!pageJavascriptEnabled) { + element.scrollIntoView({ + block: 'center', + inline: 'center', + // @ts-expect-error Chrome still supports behavior: instant but + // it's not in the spec so TS shouts We don't want to make this + // breaking change in Puppeteer yet so we'll ignore the line. + behavior: 'instant', + }); + return false; + } + const visibleRatio = await new Promise((resolve) => { + const observer = new IntersectionObserver((entries) => { + resolve(entries[0].intersectionRatio); + observer.disconnect(); + }); + observer.observe(element); + }); + if (visibleRatio !== 1.0) { + element.scrollIntoView({ + block: 'center', + inline: 'center', + // @ts-expect-error Chrome still supports behavior: instant but + // it's not in the spec so TS shouts We don't want to make this + // breaking change in Puppeteer yet so we'll ignore the line. + behavior: 'instant', + }); + } + return false; + }, this._page.isJavaScriptEnabled()); + + if (error) throw new Error(error); + } + + private async _clickablePoint(): Promise<{ x: number; y: number }> { + const [result, layoutMetrics] = await Promise.all([ + this._client + .send('DOM.getContentQuads', { + objectId: this._remoteObject.objectId, + }) + .catch(debugError), + this._client.send('Page.getLayoutMetrics'), + ]); + if (!result || !result.quads.length) + throw new Error('Node is either not visible or not an HTMLElement'); + // Filter out quads that have too small area to click into. + const { clientWidth, clientHeight } = layoutMetrics.layoutViewport; + const quads = result.quads + .map((quad) => this._fromProtocolQuad(quad)) + .map((quad) => + this._intersectQuadWithViewport(quad, clientWidth, clientHeight) + ) + .filter((quad) => computeQuadArea(quad) > 1); + if (!quads.length) + throw new Error('Node is either not visible or not an HTMLElement'); + // Return the middle point of the first quad. + const quad = quads[0]; + let x = 0; + let y = 0; + for (const point of quad) { + x += point.x; + y += point.y; + } + return { + x: x / 4, + y: y / 4, + }; + } + + private _getBoxModel(): Promise<void | Protocol.DOM.GetBoxModelResponse> { + const params: Protocol.DOM.GetBoxModelRequest = { + objectId: this._remoteObject.objectId, + }; + return this._client + .send('DOM.getBoxModel', params) + .catch((error) => debugError(error)); + } + + private _fromProtocolQuad(quad: number[]): Array<{ x: number; y: number }> { + return [ + { x: quad[0], y: quad[1] }, + { x: quad[2], y: quad[3] }, + { x: quad[4], y: quad[5] }, + { x: quad[6], y: quad[7] }, + ]; + } + + private _intersectQuadWithViewport( + quad: Array<{ x: number; y: number }>, + width: number, + height: number + ): Array<{ x: number; y: number }> { + return quad.map((point) => ({ + x: Math.min(Math.max(point.x, 0), width), + y: Math.min(Math.max(point.y, 0), height), + })); + } + + /** + * This method scrolls element into view if needed, and then + * uses {@link Page.mouse} to hover over the center of the element. + * If the element is detached from DOM, the method throws an error. + */ + async hover(): Promise<void> { + await this._scrollIntoViewIfNeeded(); + const { x, y } = await this._clickablePoint(); + await this._page.mouse.move(x, y); + } + + /** + * This method scrolls element into view if needed, and then + * uses {@link Page.mouse} to click in the center of the element. + * If the element is detached from DOM, the method throws an error. + */ + async click(options: ClickOptions = {}): Promise<void> { + await this._scrollIntoViewIfNeeded(); + const { x, y } = await this._clickablePoint(); + await this._page.mouse.click(x, y, options); + } + + /** + * Triggers a `change` and `input` event once all the provided options have been + * selected. If there's no `<select>` element matching `selector`, the method + * throws an error. + * + * @example + * ```js + * handle.select('blue'); // single selection + * handle.select('red', 'green', 'blue'); // multiple selections + * ``` + * @param values - Values of options to select. If the `<select>` has the + * `multiple` attribute, all values are considered, otherwise only the first + * one is taken into account. + */ + async select(...values: string[]): Promise<string[]> { + for (const value of values) + assert( + helper.isString(value), + 'Values must be strings. Found value "' + + value + + '" of type "' + + typeof value + + '"' + ); + + return this.evaluate< + (element: HTMLSelectElement, values: string[]) => string[] + >((element, values) => { + if (element.nodeName.toLowerCase() !== 'select') + throw new Error('Element is not a <select> element.'); + + const options = Array.from(element.options); + element.value = undefined; + for (const option of options) { + option.selected = values.includes(option.value); + if (option.selected && !element.multiple) break; + } + element.dispatchEvent(new Event('input', { bubbles: true })); + element.dispatchEvent(new Event('change', { bubbles: true })); + return options + .filter((option) => option.selected) + .map((option) => option.value); + }, values); + } + + /** + * This method expects `elementHandle` to point to an + * {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input | input element}. + * @param filePaths - Sets the value of the file input to these paths. + * If some of the `filePaths` are relative paths, then they are resolved + * relative to the {@link https://nodejs.org/api/process.html#process_process_cwd | current working directory} + */ + async uploadFile(...filePaths: string[]): Promise<void> { + const isMultiple = await this.evaluate< + (element: HTMLInputElement) => boolean + >((element) => element.multiple); + assert( + filePaths.length <= 1 || isMultiple, + 'Multiple file uploads only work with <input type=file multiple>' + ); + + if (!isNode) { + throw new Error( + `JSHandle#uploadFile can only be used in Node environments.` + ); + } + // This import is only needed for `uploadFile`, so keep it scoped here to avoid paying + // the cost unnecessarily. + const path = await import('path'); + const fs = await helper.importFSModule(); + // Locate all files and confirm that they exist. + const files = await Promise.all( + filePaths.map(async (filePath) => { + const resolvedPath: string = path.resolve(filePath); + try { + await fs.promises.access(resolvedPath, fs.constants.R_OK); + } catch (error) { + if (error.code === 'ENOENT') + throw new Error(`${filePath} does not exist or is not readable`); + } + + return resolvedPath; + }) + ); + const { objectId } = this._remoteObject; + const { node } = await this._client.send('DOM.describeNode', { objectId }); + const { backendNodeId } = node; + + // 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) { + await this.evaluate<(element: HTMLInputElement) => void>((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 })); + element.dispatchEvent(new Event('change', { bubbles: true })); + }); + } else { + await this._client.send('DOM.setFileInputFiles', { + objectId, + files, + backendNodeId, + }); + } + } + + /** + * This method scrolls element into view if needed, and then uses + * {@link Touchscreen.tap} to tap in the center of the element. + * If the element is detached from DOM, the method throws an error. + */ + async tap(): Promise<void> { + await this._scrollIntoViewIfNeeded(); + const { x, y } = await this._clickablePoint(); + await this._page.touchscreen.tap(x, y); + } + + /** + * Calls {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus | focus} on the element. + */ + async focus(): Promise<void> { + await this.evaluate<(element: HTMLElement) => void>((element) => + element.focus() + ); + } + + /** + * Focuses the element, and then sends a `keydown`, `keypress`/`input`, and + * `keyup` event for each character in the text. + * + * To press a special key, like `Control` or `ArrowDown`, + * use {@link ElementHandle.press}. + * + * @example + * ```js + * await elementHandle.type('Hello'); // Types instantly + * await elementHandle.type('World', {delay: 100}); // Types slower, like a user + * ``` + * + * @example + * An example of typing into a text field and then submitting the form: + * + * ```js + * const elementHandle = await page.$('input'); + * await elementHandle.type('some text'); + * await elementHandle.press('Enter'); + * ``` + */ + async type(text: string, options?: { delay: number }): Promise<void> { + await this.focus(); + await this._page.keyboard.type(text, options); + } + + /** + * Focuses the element, and then uses {@link Keyboard.down} and {@link Keyboard.up}. + * + * @remarks + * If `key` is a single character and no modifier keys besides `Shift` + * are being held down, a `keypress`/`input` event will also be generated. + * The `text` option can be specified to force an input event to be generated. + * + * **NOTE** Modifier keys DO affect `elementHandle.press`. Holding down `Shift` + * will type the text in upper case. + * + * @param key - Name of key to press, such as `ArrowLeft`. + * See {@link KeyInput} for a list of all key names. + */ + async press(key: KeyInput, options?: PressOptions): Promise<void> { + await this.focus(); + await this._page.keyboard.press(key, options); + } + + /** + * This method returns the bounding box of the element (relative to the main frame), + * or `null` if the element is not visible. + */ + async boundingBox(): Promise<BoundingBox | null> { + const result = await this._getBoxModel(); + + if (!result) return null; + + const quad = result.model.border; + const x = Math.min(quad[0], quad[2], quad[4], quad[6]); + const y = Math.min(quad[1], quad[3], quad[5], quad[7]); + const width = Math.max(quad[0], quad[2], quad[4], quad[6]) - x; + const height = Math.max(quad[1], quad[3], quad[5], quad[7]) - y; + + return { x, y, width, height }; + } + + /** + * This method returns boxes of the element, or `null` if the element is not visible. + * + * @remarks + * + * Boxes are represented as an array of points; + * Each Point is an object `{x, y}`. Box points are sorted clock-wise. + */ + async boxModel(): Promise<BoxModel | null> { + const result = await this._getBoxModel(); + + if (!result) return null; + + const { content, padding, border, margin, width, height } = result.model; + return { + content: this._fromProtocolQuad(content), + padding: this._fromProtocolQuad(padding), + border: this._fromProtocolQuad(border), + margin: this._fromProtocolQuad(margin), + width, + height, + }; + } + + /** + * This method scrolls element into view if needed, and then uses + * {@link Page.screenshot} to take a screenshot of the element. + * If the element is detached from DOM, the method throws an error. + */ + async screenshot(options = {}): Promise<string | Buffer | void> { + let needsViewportReset = false; + + let boundingBox = await this.boundingBox(); + assert(boundingBox, 'Node is either not visible or not an HTMLElement'); + + const viewport = this._page.viewport(); + + if ( + viewport && + (boundingBox.width > viewport.width || + boundingBox.height > viewport.height) + ) { + const newViewport = { + width: Math.max(viewport.width, Math.ceil(boundingBox.width)), + height: Math.max(viewport.height, Math.ceil(boundingBox.height)), + }; + await this._page.setViewport(Object.assign({}, viewport, newViewport)); + + needsViewportReset = true; + } + + await this._scrollIntoViewIfNeeded(); + + boundingBox = await this.boundingBox(); + assert(boundingBox, 'Node is either not visible or not an HTMLElement'); + assert(boundingBox.width !== 0, 'Node has 0 width.'); + assert(boundingBox.height !== 0, 'Node has 0 height.'); + + const { + layoutViewport: { pageX, pageY }, + } = await this._client.send('Page.getLayoutMetrics'); + + const clip = Object.assign({}, boundingBox); + clip.x += pageX; + clip.y += pageY; + + const imageData = await this._page.screenshot( + Object.assign( + {}, + { + clip, + }, + options + ) + ); + + if (needsViewportReset) await this._page.setViewport(viewport); + + return imageData; + } + + /** + * Runs `element.querySelector` within the page. If no element matches the selector, + * the return value resolves to `null`. + */ + async $(selector: string): Promise<ElementHandle | null> { + const { updatedSelector, queryHandler } = getQueryHandlerAndSelector( + selector + ); + return queryHandler.queryOne(this, updatedSelector); + } + + /** + * Runs `element.querySelectorAll` within the page. If no elements match the selector, + * the return value resolves to `[]`. + */ + async $$(selector: string): Promise<ElementHandle[]> { + const { updatedSelector, queryHandler } = getQueryHandlerAndSelector( + selector + ); + return queryHandler.queryAll(this, updatedSelector); + } + + /** + * This method runs `document.querySelector` within the element and passes it as + * the first argument to `pageFunction`. If there's no element matching `selector`, + * the method throws an error. + * + * If `pageFunction` returns a Promise, then `frame.$eval` would wait for the promise + * to resolve and return its value. + * + * @example + * ```js + * const tweetHandle = await page.$('.tweet'); + * expect(await tweetHandle.$eval('.like', node => node.innerText)).toBe('100'); + * expect(await tweetHandle.$eval('.retweets', node => node.innerText)).toBe('10'); + * ``` + */ + async $eval<ReturnType>( + selector: string, + pageFunction: ( + element: Element, + ...args: unknown[] + ) => ReturnType | Promise<ReturnType>, + ...args: SerializableOrJSHandle[] + ): Promise<WrapElementHandle<ReturnType>> { + const elementHandle = await this.$(selector); + if (!elementHandle) + throw new Error( + `Error: failed to find element matching selector "${selector}"` + ); + const result = await elementHandle.evaluate< + ( + element: Element, + ...args: SerializableOrJSHandle[] + ) => ReturnType | Promise<ReturnType> + >(pageFunction, ...args); + await elementHandle.dispose(); + + /** + * This `as` is a little unfortunate but helps TS understand the behavior of + * `elementHandle.evaluate`. If evaluate returns an element it will return an + * ElementHandle instance, rather than the plain object. All the + * WrapElementHandle type does is wrap ReturnType into + * ElementHandle<ReturnType> if it is an ElementHandle, or leave it alone as + * ReturnType if it isn't. + */ + return result as WrapElementHandle<ReturnType>; + } + + /** + * This method runs `document.querySelectorAll` within the element and passes it as + * the first argument to `pageFunction`. If there's no element matching `selector`, + * the method throws an error. + * + * If `pageFunction` returns a Promise, then `frame.$$eval` would wait for the + * promise to resolve and return its value. + * + * @example + * ```html + * <div class="feed"> + * <div class="tweet">Hello!</div> + * <div class="tweet">Hi!</div> + * </div> + * ``` + * + * @example + * ```js + * const feedHandle = await page.$('.feed'); + * expect(await feedHandle.$$eval('.tweet', nodes => nodes.map(n => n.innerText))) + * .toEqual(['Hello!', 'Hi!']); + * ``` + */ + async $$eval<ReturnType>( + selector: string, + pageFunction: ( + elements: Element[], + ...args: unknown[] + ) => ReturnType | Promise<ReturnType>, + ...args: SerializableOrJSHandle[] + ): Promise<WrapElementHandle<ReturnType>> { + const { updatedSelector, queryHandler } = getQueryHandlerAndSelector( + selector + ); + const arrayHandle = await queryHandler.queryAllArray(this, updatedSelector); + const result = await arrayHandle.evaluate< + ( + elements: Element[], + ...args: unknown[] + ) => ReturnType | Promise<ReturnType> + >(pageFunction, ...args); + await arrayHandle.dispose(); + /* This `as` exists for the same reason as the `as` in $eval above. + * See the comment there for a full explanation. + */ + return result as WrapElementHandle<ReturnType>; + } + + /** + * The method evaluates the XPath expression relative to the elementHandle. + * If there are no such elements, the method will resolve to an empty array. + * @param expression - Expression to {@link https://developer.mozilla.org/en-US/docs/Web/API/Document/evaluate | evaluate} + */ + async $x(expression: string): Promise<ElementHandle[]> { + const arrayHandle = await this.evaluateHandle( + (element: Document, expression: string) => { + const document = element.ownerDocument || element; + const iterator = document.evaluate( + expression, + element, + null, + XPathResult.ORDERED_NODE_ITERATOR_TYPE + ); + const array = []; + let item; + while ((item = iterator.iterateNext())) array.push(item); + return array; + }, + expression + ); + const properties = await arrayHandle.getProperties(); + await arrayHandle.dispose(); + const result = []; + for (const property of properties.values()) { + const elementHandle = property.asElement(); + if (elementHandle) result.push(elementHandle); + } + return result; + } + + /** + * Resolves to true if the element is visible in the current viewport. + */ + async isIntersectingViewport(): Promise<boolean> { + return await this.evaluate<(element: Element) => Promise<boolean>>( + async (element) => { + const visibleRatio = await new Promise((resolve) => { + const observer = new IntersectionObserver((entries) => { + resolve(entries[0].intersectionRatio); + observer.disconnect(); + }); + observer.observe(element); + }); + return visibleRatio > 0; + } + ); + } +} + +/** + * @public + */ +export interface ClickOptions { + /** + * Time to wait between `mousedown` and `mouseup` in milliseconds. + * + * @defaultValue 0 + */ + delay?: number; + /** + * @defaultValue 'left' + */ + button?: 'left' | 'right' | 'middle'; + /** + * @defaultValue 1 + */ + clickCount?: number; +} + +/** + * @public + */ +export interface PressOptions { + /** + * Time to wait between `keydown` and `keyup` in milliseconds. Defaults to 0. + */ + delay?: number; + /** + * If specified, generates an input event with this text. + */ + text?: string; +} + +function computeQuadArea(quad: Array<{ x: number; y: number }>): number { + // Compute sum of all directed areas of adjacent triangles + // https://en.wikipedia.org/wiki/Polygon#Simple_polygons + let area = 0; + for (let i = 0; i < quad.length; ++i) { + const p1 = quad[i]; + const p2 = quad[(i + 1) % quad.length]; + area += (p1.x * p2.y - p2.x * p1.y) / 2; + } + return Math.abs(area); +} diff --git a/remote/test/puppeteer/src/common/LifecycleWatcher.ts b/remote/test/puppeteer/src/common/LifecycleWatcher.ts new file mode 100644 index 0000000000..2b8ab60421 --- /dev/null +++ b/remote/test/puppeteer/src/common/LifecycleWatcher.ts @@ -0,0 +1,244 @@ +/** + * Copyright 2019 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { assert } from './assert.js'; +import { helper, PuppeteerEventListener } from './helper.js'; +import { TimeoutError } from './Errors.js'; +import { + FrameManager, + Frame, + FrameManagerEmittedEvents, +} from './FrameManager.js'; +import { HTTPRequest } from './HTTPRequest.js'; +import { HTTPResponse } from './HTTPResponse.js'; +import { NetworkManagerEmittedEvents } from './NetworkManager.js'; +import { CDPSessionEmittedEvents } from './Connection.js'; + +export type PuppeteerLifeCycleEvent = + | 'load' + | 'domcontentloaded' + | 'networkidle0' + | 'networkidle2'; +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[]; + _frameManager: FrameManager; + _frame: Frame; + _timeout: number; + _navigationRequest?: HTTPRequest; + _eventListeners: PuppeteerEventListener[]; + _initialLoaderId: string; + + _sameDocumentNavigationPromise: Promise<Error | null>; + _sameDocumentNavigationCompleteCallback: (x?: Error) => void; + + _lifecyclePromise: Promise<void>; + _lifecycleCallback: () => void; + + _newDocumentNavigationPromise: Promise<Error | null>; + _newDocumentNavigationCompleteCallback: (x?: Error) => void; + + _terminationPromise: Promise<Error | null>; + _terminationCallback: (x?: Error) => void; + + _timeoutPromise: Promise<TimeoutError | null>; + + _maximumTimer?: NodeJS.Timeout; + _hasSameDocumentNavigation?: boolean; + + constructor( + frameManager: FrameManager, + frame: Frame, + waitUntil: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[], + timeout: number + ) { + if (Array.isArray(waitUntil)) waitUntil = waitUntil.slice(); + else if (typeof waitUntil === 'string') waitUntil = [waitUntil]; + this._expectedLifecycle = waitUntil.map((value) => { + const protocolEvent = puppeteerToProtocolLifecycle.get(value); + assert(protocolEvent, 'Unknown value for options.waitUntil: ' + value); + return protocolEvent; + }); + + this._frameManager = frameManager; + this._frame = frame; + this._initialLoaderId = frame._loaderId; + this._timeout = timeout; + this._navigationRequest = null; + this._eventListeners = [ + helper.addEventListener( + frameManager._client, + CDPSessionEmittedEvents.Disconnected, + () => + this._terminate( + new Error('Navigation failed because browser has disconnected!') + ) + ), + helper.addEventListener( + this._frameManager, + FrameManagerEmittedEvents.LifecycleEvent, + this._checkLifecycleComplete.bind(this) + ), + helper.addEventListener( + this._frameManager, + FrameManagerEmittedEvents.FrameNavigatedWithinDocument, + this._navigatedWithinDocument.bind(this) + ), + helper.addEventListener( + this._frameManager, + FrameManagerEmittedEvents.FrameDetached, + this._onFrameDetached.bind(this) + ), + helper.addEventListener( + this._frameManager.networkManager(), + NetworkManagerEmittedEvents.Request, + this._onRequest.bind(this) + ), + ]; + + this._sameDocumentNavigationPromise = new Promise<Error | null>( + (fulfill) => { + this._sameDocumentNavigationCompleteCallback = fulfill; + } + ); + + this._lifecyclePromise = new Promise((fulfill) => { + this._lifecycleCallback = fulfill; + }); + + this._newDocumentNavigationPromise = new Promise((fulfill) => { + this._newDocumentNavigationCompleteCallback = fulfill; + }); + + this._timeoutPromise = this._createTimeoutPromise(); + this._terminationPromise = new Promise((fulfill) => { + this._terminationCallback = fulfill; + }); + this._checkLifecycleComplete(); + } + + _onRequest(request: HTTPRequest): void { + if (request.frame() !== this._frame || !request.isNavigationRequest()) + return; + this._navigationRequest = request; + } + + _onFrameDetached(frame: Frame): void { + if (this._frame === frame) { + this._terminationCallback.call( + null, + new Error('Navigating frame was detached') + ); + return; + } + this._checkLifecycleComplete(); + } + + navigationResponse(): HTTPResponse | null { + return this._navigationRequest ? this._navigationRequest.response() : null; + } + + _terminate(error: Error): void { + this._terminationCallback.call(null, error); + } + + sameDocumentNavigationPromise(): Promise<Error | null> { + return this._sameDocumentNavigationPromise; + } + + newDocumentNavigationPromise(): Promise<Error | null> { + return this._newDocumentNavigationPromise; + } + + lifecyclePromise(): Promise<void> { + return this._lifecyclePromise; + } + + timeoutOrTerminationPromise(): Promise<Error | TimeoutError | null> { + return Promise.race([this._timeoutPromise, this._terminationPromise]); + } + + _createTimeoutPromise(): Promise<TimeoutError | null> { + if (!this._timeout) return new Promise(() => {}); + const errorMessage = + 'Navigation timeout of ' + this._timeout + ' ms exceeded'; + return new Promise( + (fulfill) => (this._maximumTimer = setTimeout(fulfill, this._timeout)) + ).then(() => new TimeoutError(errorMessage)); + } + + _navigatedWithinDocument(frame: Frame): void { + if (frame !== this._frame) return; + this._hasSameDocumentNavigation = true; + this._checkLifecycleComplete(); + } + + _checkLifecycleComplete(): void { + // We expect navigation to commit. + if (!checkLifecycle(this._frame, this._expectedLifecycle)) return; + this._lifecycleCallback(); + if ( + this._frame._loaderId === this._initialLoaderId && + !this._hasSameDocumentNavigation + ) + return; + if (this._hasSameDocumentNavigation) + this._sameDocumentNavigationCompleteCallback(); + if (this._frame._loaderId !== this._initialLoaderId) + this._newDocumentNavigationCompleteCallback(); + + /** + * @param {!Frame} frame + * @param {!Array<string>} expectedLifecycle + * @returns {boolean} + */ + function checkLifecycle( + frame: Frame, + expectedLifecycle: ProtocolLifeCycleEvent[] + ): boolean { + for (const event of expectedLifecycle) { + if (!frame._lifecycleEvents.has(event)) return false; + } + for (const child of frame.childFrames()) { + if (!checkLifecycle(child, expectedLifecycle)) return false; + } + return true; + } + } + + dispose(): void { + helper.removeEventListeners(this._eventListeners); + clearTimeout(this._maximumTimer); + } +} diff --git a/remote/test/puppeteer/src/common/NetworkManager.ts b/remote/test/puppeteer/src/common/NetworkManager.ts new file mode 100644 index 0000000000..52b0aee0bf --- /dev/null +++ b/remote/test/puppeteer/src/common/NetworkManager.ts @@ -0,0 +1,340 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { EventEmitter } from './EventEmitter.js'; +import { assert } from './assert.js'; +import { helper, debugError } from './helper.js'; +import { Protocol } from 'devtools-protocol'; +import { CDPSession } from './Connection.js'; +import { FrameManager } from './FrameManager.js'; +import { HTTPRequest } from './HTTPRequest.js'; +import { HTTPResponse } from './HTTPResponse.js'; + +/** + * @public + */ +export interface Credentials { + username: string; + password: string; +} + +/** + * We use symbols to prevent any external parties listening to these events. + * They are internal to Puppeteer. + * + * @internal + */ +export const NetworkManagerEmittedEvents = { + Request: Symbol('NetworkManager.Request'), + Response: Symbol('NetworkManager.Response'), + RequestFailed: Symbol('NetworkManager.RequestFailed'), + RequestFinished: Symbol('NetworkManager.RequestFinished'), +} as const; + +/** + * @internal + */ +export class NetworkManager extends EventEmitter { + _client: CDPSession; + _ignoreHTTPSErrors: boolean; + _frameManager: FrameManager; + _requestIdToRequest = new Map<string, HTTPRequest>(); + _requestIdToRequestWillBeSentEvent = new Map< + string, + Protocol.Network.RequestWillBeSentEvent + >(); + _extraHTTPHeaders: Record<string, string> = {}; + _offline = false; + _credentials?: Credentials = null; + _attemptedAuthentications = new Set<string>(); + _userRequestInterceptionEnabled = false; + _protocolRequestInterceptionEnabled = false; + _userCacheDisabled = false; + _requestIdToInterceptionId = new Map<string, string>(); + + constructor( + client: CDPSession, + ignoreHTTPSErrors: boolean, + frameManager: FrameManager + ) { + super(); + this._client = client; + this._ignoreHTTPSErrors = ignoreHTTPSErrors; + this._frameManager = frameManager; + + this._client.on('Fetch.requestPaused', this._onRequestPaused.bind(this)); + this._client.on('Fetch.authRequired', this._onAuthRequired.bind(this)); + this._client.on( + 'Network.requestWillBeSent', + this._onRequestWillBeSent.bind(this) + ); + this._client.on( + 'Network.requestServedFromCache', + this._onRequestServedFromCache.bind(this) + ); + this._client.on( + 'Network.responseReceived', + this._onResponseReceived.bind(this) + ); + this._client.on( + 'Network.loadingFinished', + this._onLoadingFinished.bind(this) + ); + this._client.on('Network.loadingFailed', this._onLoadingFailed.bind(this)); + } + + async initialize(): Promise<void> { + await this._client.send('Network.enable'); + if (this._ignoreHTTPSErrors) + await this._client.send('Security.setIgnoreCertificateErrors', { + ignore: true, + }); + } + + async authenticate(credentials?: Credentials): Promise<void> { + this._credentials = credentials; + await this._updateProtocolRequestInterception(); + } + + async setExtraHTTPHeaders( + extraHTTPHeaders: Record<string, string> + ): Promise<void> { + this._extraHTTPHeaders = {}; + for (const key of Object.keys(extraHTTPHeaders)) { + const value = extraHTTPHeaders[key]; + assert( + helper.isString(value), + `Expected value of header "${key}" to be String, but "${typeof value}" is found.` + ); + this._extraHTTPHeaders[key.toLowerCase()] = value; + } + await this._client.send('Network.setExtraHTTPHeaders', { + headers: this._extraHTTPHeaders, + }); + } + + extraHTTPHeaders(): Record<string, string> { + return Object.assign({}, this._extraHTTPHeaders); + } + + async setOfflineMode(value: boolean): Promise<void> { + if (this._offline === value) return; + this._offline = value; + await this._client.send('Network.emulateNetworkConditions', { + offline: this._offline, + // values of 0 remove any active throttling. crbug.com/456324#c9 + latency: 0, + downloadThroughput: -1, + uploadThroughput: -1, + }); + } + + async setUserAgent(userAgent: string): Promise<void> { + await this._client.send('Network.setUserAgentOverride', { userAgent }); + } + + async setCacheEnabled(enabled: boolean): Promise<void> { + this._userCacheDisabled = !enabled; + await this._updateProtocolCacheDisabled(); + } + + async setRequestInterception(value: boolean): Promise<void> { + this._userRequestInterceptionEnabled = value; + await this._updateProtocolRequestInterception(); + } + + async _updateProtocolRequestInterception(): Promise<void> { + const enabled = this._userRequestInterceptionEnabled || !!this._credentials; + if (enabled === this._protocolRequestInterceptionEnabled) return; + this._protocolRequestInterceptionEnabled = enabled; + if (enabled) { + await Promise.all([ + this._updateProtocolCacheDisabled(), + this._client.send('Fetch.enable', { + handleAuthRequests: true, + patterns: [{ urlPattern: '*' }], + }), + ]); + } else { + await Promise.all([ + this._updateProtocolCacheDisabled(), + this._client.send('Fetch.disable'), + ]); + } + } + + async _updateProtocolCacheDisabled(): Promise<void> { + await this._client.send('Network.setCacheDisabled', { + cacheDisabled: + this._userCacheDisabled || this._protocolRequestInterceptionEnabled, + }); + } + + _onRequestWillBeSent(event: Protocol.Network.RequestWillBeSentEvent): void { + // Request interception doesn't happen for data URLs with Network Service. + if ( + this._protocolRequestInterceptionEnabled && + !event.request.url.startsWith('data:') + ) { + const requestId = event.requestId; + const interceptionId = this._requestIdToInterceptionId.get(requestId); + if (interceptionId) { + this._onRequest(event, interceptionId); + this._requestIdToInterceptionId.delete(requestId); + } else { + this._requestIdToRequestWillBeSentEvent.set(event.requestId, event); + } + return; + } + this._onRequest(event, null); + } + + _onAuthRequired(event: Protocol.Fetch.AuthRequiredEvent): void { + /* TODO(jacktfranklin): This is defined in protocol.d.ts but not + * in an easily referrable way - we should look at exposing it. + */ + type AuthResponse = 'Default' | 'CancelAuth' | 'ProvideCredentials'; + let response: AuthResponse = '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, + }; + this._client + .send('Fetch.continueWithAuth', { + requestId: event.requestId, + authChallengeResponse: { response, username, password }, + }) + .catch(debugError); + } + + _onRequestPaused(event: Protocol.Fetch.RequestPausedEvent): void { + if ( + !this._userRequestInterceptionEnabled && + this._protocolRequestInterceptionEnabled + ) { + this._client + .send('Fetch.continueRequest', { + requestId: event.requestId, + }) + .catch(debugError); + } + + const requestId = event.networkId; + const interceptionId = event.requestId; + if (requestId && this._requestIdToRequestWillBeSentEvent.has(requestId)) { + const requestWillBeSentEvent = this._requestIdToRequestWillBeSentEvent.get( + requestId + ); + this._onRequest(requestWillBeSentEvent, interceptionId); + this._requestIdToRequestWillBeSentEvent.delete(requestId); + } else { + this._requestIdToInterceptionId.set(requestId, interceptionId); + } + } + + _onRequest( + event: Protocol.Network.RequestWillBeSentEvent, + interceptionId?: string + ): void { + let redirectChain = []; + if (event.redirectResponse) { + const request = this._requestIdToRequest.get(event.requestId); + // If we connect late to the target, we could have missed the + // requestWillBeSent event. + if (request) { + this._handleRequestRedirect(request, event.redirectResponse); + redirectChain = request._redirectChain; + } + } + const frame = event.frameId + ? this._frameManager.frame(event.frameId) + : null; + const request = new HTTPRequest( + this._client, + frame, + interceptionId, + this._userRequestInterceptionEnabled, + event, + redirectChain + ); + this._requestIdToRequest.set(event.requestId, request); + this.emit(NetworkManagerEmittedEvents.Request, request); + } + + _onRequestServedFromCache( + event: Protocol.Network.RequestServedFromCacheEvent + ): void { + const request = this._requestIdToRequest.get(event.requestId); + if (request) request._fromMemoryCache = true; + } + + _handleRequestRedirect( + request: HTTPRequest, + responsePayload: Protocol.Network.Response + ): void { + const response = new HTTPResponse(this._client, request, responsePayload); + request._response = response; + request._redirectChain.push(request); + response._resolveBody( + new Error('Response body is unavailable for redirect responses') + ); + this._requestIdToRequest.delete(request._requestId); + this._attemptedAuthentications.delete(request._interceptionId); + this.emit(NetworkManagerEmittedEvents.Response, response); + this.emit(NetworkManagerEmittedEvents.RequestFinished, request); + } + + _onResponseReceived(event: Protocol.Network.ResponseReceivedEvent): void { + const request = this._requestIdToRequest.get(event.requestId); + // FileUpload sends a response without a matching request. + if (!request) return; + const response = new HTTPResponse(this._client, request, event.response); + request._response = response; + this.emit(NetworkManagerEmittedEvents.Response, response); + } + + _onLoadingFinished(event: Protocol.Network.LoadingFinishedEvent): void { + const request = this._requestIdToRequest.get(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(null); + this._requestIdToRequest.delete(request._requestId); + this._attemptedAuthentications.delete(request._interceptionId); + this.emit(NetworkManagerEmittedEvents.RequestFinished, request); + } + + _onLoadingFailed(event: Protocol.Network.LoadingFailedEvent): void { + const request = this._requestIdToRequest.get(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(null); + this._requestIdToRequest.delete(request._requestId); + this._attemptedAuthentications.delete(request._interceptionId); + this.emit(NetworkManagerEmittedEvents.RequestFailed, request); + } +} diff --git a/remote/test/puppeteer/src/common/PDFOptions.ts b/remote/test/puppeteer/src/common/PDFOptions.ts new file mode 100644 index 0000000000..743085904a --- /dev/null +++ b/remote/test/puppeteer/src/common/PDFOptions.ts @@ -0,0 +1,179 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @public + */ +export interface PDFMargin { + top?: string | number; + bottom?: string | number; + left?: string | number; + right?: string | number; +} + +/** + * All the valid paper format types when printing a PDF. + * + * @remarks + * + * The sizes of each format are as follows: + * - `Letter`: 8.5in x 11in + * + * - `Legal`: 8.5in x 14in + * + * - `Tabloid`: 11in x 17in + * + * - `Ledger`: 17in x 11in + * + * - `A0`: 33.1in x 46.8in + * + * - `A1`: 23.4in x 33.1in + * + * - `A2`: 16.54in x 23.4in + * + * - `A3`: 11.7in x 16.54in + * + * - `A4`: 8.27in x 11.7in + * + * - `A5`: 5.83in x 8.27in + * + * - `A6`: 4.13in x 5.83in + * + * @public + */ +export type PaperFormat = + | 'letter' + | 'legal' + | 'tabloid' + | 'ledger' + | 'a0' + | 'a1' + | 'a2' + | 'a3' + | 'a4' + | 'a5' + | 'a6'; + +/** + * Valid options to configure PDF generation via {@link Page.pdf}. + * @public + */ +export interface PDFOptions { + /** + * Scales the rendering of the web page. Amount must be between `0.1` and `2`. + * @defaultValue 1 + */ + scale?: number; + /** + * Whether to show the header and footer. + * @defaultValue false + */ + displayHeaderFooter?: boolean; + /** + * HTML template for the print header. Should be valid HTML with the following + * classes used to inject values into them: + * - `date` formatted print date + * + * - `title` document title + * + * - `url` document location + * + * - `pageNumber` current page number + * + * - `totalPages` total pages in the document + */ + headerTemplate?: string; + /** + * HTML template for the print footer. Has the same constraints and support + * for special classes as {@link PDFOptions.headerTemplate}. + */ + footerTemplate?: string; + /** + * Set to `true` to print background graphics. + * @defaultValue false + */ + printBackground?: boolean; + /** + * Whether to print in landscape orientation. + * @defaultValue = false + */ + landscape?: boolean; + /** + * Paper ranges to print, e.g. `1-5, 8, 11-13`. + * @defaultValue The empty string, which means all pages are printed. + */ + pageRanges?: string; + /** + * @remarks + * If set, this takes priority over the `width` and `height` options. + * @defaultValue `letter`. + */ + format?: PaperFormat; + /** + * Sets the width of paper. You can pass in a number or a string with a unit. + */ + width?: string | number; + /** + * Sets the height of paper. You can pass in a number or a string with a unit. + */ + height?: string | number; + /** + * Give any CSS `@page` size declared in the page priority over what is + * declared in the `width` or `height` or `format` option. + * @defaultValue `false`, which will scale the content to fit the paper size. + */ + preferCSSPageSize?: boolean; + /** + * Set the PDF margins. + * @defaultValue no margins are set. + */ + margin?: PDFMargin; + /** + * The path to save the file to. + * + * @remarks + * + * If the path is relative, it's resolved relative to the current working directory. + * + * @defaultValue the empty string, which means the PDF will not be written to disk. + */ + path?: string; +} + +/** + * @internal + */ +export interface PaperFormatDimensions { + width: number; + height: number; +} + +/** + * @internal + */ +export const paperFormats: Record<PaperFormat, PaperFormatDimensions> = { + letter: { width: 8.5, height: 11 }, + legal: { width: 8.5, height: 14 }, + tabloid: { width: 11, height: 17 }, + ledger: { width: 17, height: 11 }, + a0: { width: 33.1, height: 46.8 }, + a1: { width: 23.4, height: 33.1 }, + a2: { width: 16.54, height: 23.4 }, + a3: { width: 11.7, height: 16.54 }, + a4: { width: 8.27, height: 11.7 }, + a5: { width: 5.83, height: 8.27 }, + a6: { width: 4.13, height: 5.83 }, +} as const; diff --git a/remote/test/puppeteer/src/common/Page.ts b/remote/test/puppeteer/src/common/Page.ts new file mode 100644 index 0000000000..7194649bef --- /dev/null +++ b/remote/test/puppeteer/src/common/Page.ts @@ -0,0 +1,2013 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { EventEmitter } from './EventEmitter.js'; +import { + Connection, + CDPSession, + CDPSessionEmittedEvents, +} from './Connection.js'; +import { Dialog } from './Dialog.js'; +import { EmulationManager } from './EmulationManager.js'; +import { + Frame, + FrameManager, + FrameManagerEmittedEvents, +} from './FrameManager.js'; +import { Keyboard, Mouse, Touchscreen, MouseButton } from './Input.js'; +import { Tracing } from './Tracing.js'; +import { assert } from './assert.js'; +import { helper, debugError } from './helper.js'; +import { Coverage } from './Coverage.js'; +import { WebWorker } from './WebWorker.js'; +import { Browser, BrowserContext } from './Browser.js'; +import { Target } from './Target.js'; +import { createJSHandle, JSHandle, ElementHandle } from './JSHandle.js'; +import { Viewport } from './PuppeteerViewport.js'; +import { Credentials, NetworkManagerEmittedEvents } from './NetworkManager.js'; +import { HTTPRequest } from './HTTPRequest.js'; +import { HTTPResponse } from './HTTPResponse.js'; +import { Accessibility } from './Accessibility.js'; +import { TimeoutSettings } from './TimeoutSettings.js'; +import { FileChooser } from './FileChooser.js'; +import { ConsoleMessage, ConsoleMessageType } from './ConsoleMessage.js'; +import { PuppeteerLifeCycleEvent } from './LifecycleWatcher.js'; +import { Protocol } from 'devtools-protocol'; +import { + SerializableOrJSHandle, + EvaluateHandleFn, + WrapElementHandle, + EvaluateFn, + EvaluateFnReturnType, + UnwrapPromiseLike, +} from './EvalTypes.js'; +import { PDFOptions, paperFormats } from './PDFOptions.js'; +import { isNode } from '../environment.js'; + +/** + * @public + */ +export interface Metrics { + Timestamp?: number; + Documents?: number; + Frames?: number; + JSEventListeners?: number; + Nodes?: number; + LayoutCount?: number; + RecalcStyleCount?: number; + LayoutDuration?: number; + RecalcStyleDuration?: number; + ScriptDuration?: number; + TaskDuration?: number; + JSHeapUsedSize?: number; + JSHeapTotalSize?: number; +} + +/** + * @public + */ +export interface WaitTimeoutOptions { + /** + * Maximum wait time in milliseconds, defaults to 30 seconds, pass `0` to + * disable the timeout. + * + * @remarks + * The default value can be changed by using the + * {@link Page.setDefaultTimeout} method. + */ + timeout?: number; +} + +/** + * @public + */ +export interface WaitForOptions { + /** + * Maximum wait time in milliseconds, defaults to 30 seconds, pass `0` to + * disable the timeout. + * + * @remarks + * The default value can be changed by using the + * {@link Page.setDefaultTimeout} or {@link Page.setDefaultNavigationTimeout} + * methods. + */ + timeout?: number; + waitUntil?: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[]; +} + +/** + * @public + */ +export interface GeolocationOptions { + /** + * Latitude between -90 and 90. + */ + longitude: number; + /** + * Longitude between -180 and 180. + */ + latitude: number; + /** + * Optional non-negative accuracy value. + */ + accuracy?: number; +} + +interface MediaFeature { + name: string; + value: string; +} + +interface ScreenshotClip { + x: number; + y: number; + width: number; + height: number; +} + +interface ScreenshotOptions { + type?: 'png' | 'jpeg'; + path?: string; + fullPage?: boolean; + clip?: ScreenshotClip; + quality?: number; + omitBackground?: boolean; + encoding?: string; +} + +/** + * All the events that a page instance may emit. + * + * @public + */ +export const enum PageEmittedEvents { + /** Emitted when the page closes. */ + Close = 'close', + /** + * Emitted when JavaScript within the page calls one of console API methods, + * e.g. `console.log` or `console.dir`. Also emitted if the page throws an + * error or a warning. + * + * @remarks + * + * A `console` event provides a {@link ConsoleMessage} representing the + * console message that was logged. + * + * @example + * An example of handling `console` event: + * ```js + * page.on('console', msg => { + * for (let i = 0; i < msg.args().length; ++i) + * console.log(`${i}: ${msg.args()[i]}`); + * }); + * page.evaluate(() => console.log('hello', 5, {foo: 'bar'})); + * ``` + */ + Console = 'console', + /** + * Emitted when a JavaScript dialog appears, such as `alert`, `prompt`, + * `confirm` or `beforeunload`. Puppeteer can respond to the dialog via + * {@link Dialog.accept} or {@link Dialog.dismiss}. + */ + Dialog = 'dialog', + /** + * Emitted when the JavaScript + * {@link https://developer.mozilla.org/en-US/docs/Web/Events/DOMContentLoaded | DOMContentLoaded } event is dispatched. + */ + DOMContentLoaded = 'domcontentloaded', + /** + * Emitted when the page crashes. Will contain an `Error`. + */ + Error = 'error', + /** Emitted when a frame is attached. Will contain a {@link Frame}. */ + FrameAttached = 'frameattached', + /** Emitted when a frame is detached. Will contain a {@link Frame}. */ + FrameDetached = 'framedetached', + /** Emitted when a frame is navigated to a new URL. Will contain a {@link Frame}. */ + FrameNavigated = 'framenavigated', + /** + * Emitted when the JavaScript + * {@link https://developer.mozilla.org/en-US/docs/Web/Events/load | load} + * event is dispatched. + */ + Load = 'load', + /** + * Emitted when the JavaScript code makes a call to `console.timeStamp`. For + * the list of metrics see {@link Page.metrics | page.metrics}. + * + * @remarks + * Contains an object with two properties: + * - `title`: the title passed to `console.timeStamp` + * - `metrics`: objec containing metrics as key/value pairs. The values will + * be `number`s. + */ + Metrics = 'metrics', + /** + * Emitted when an uncaught exception happens within the page. + * Contains an `Error`. + */ + PageError = 'pageerror', + /** + * Emitted when the page opens a new tab or window. + * + * Contains a {@link Page} corresponding to the popup window. + * + * @example + * + * ```js + * const [popup] = await Promise.all([ + * new Promise(resolve => page.once('popup', resolve)), + * page.click('a[target=_blank]'), + * ]); + * ``` + * + * ```js + * const [popup] = await Promise.all([ + * new Promise(resolve => page.once('popup', resolve)), + * page.evaluate(() => window.open('https://example.com')), + * ]); + * ``` + */ + Popup = 'popup', + /** + * Emitted when a page issues a request and contains a {@link HTTPRequest}. + * + * @remarks + * The object is readonly. See {@Page.setRequestInterception} for intercepting + * and mutating requests. + */ + Request = 'request', + /** + * Emitted when a request fails, for example by timing out. + * + * Contains a {@link HTTPRequest}. + * + * @remarks + * + * NOTE: HTTP Error responses, such as 404 or 503, are still successful + * responses from HTTP standpoint, so request will complete with + * `requestfinished` event and not with `requestfailed`. + */ + RequestFailed = 'requestfailed', + /** + * Emitted when a request finishes successfully. Contains a {@link HTTPRequest}. + */ + RequestFinished = 'requestfinished', + /** + * Emitted when a response is received. Contains a {@link HTTPResponse}. + */ + Response = 'response', + /** + * Emitted when a dedicated + * {@link https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API | WebWorker} + * is spawned by the page. + */ + WorkerCreated = 'workercreated', + /** + * Emitted when a dedicated + * {@link https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API | WebWorker} + * is destroyed by the page. + */ + WorkerDestroyed = 'workerdestroyed', +} + +class ScreenshotTaskQueue { + _chain: Promise<Buffer | string | void>; + + constructor() { + this._chain = Promise.resolve<Buffer | string | void>(undefined); + } + + public postTask( + task: () => Promise<Buffer | string> + ): Promise<Buffer | string | void> { + const result = this._chain.then(task); + this._chain = result.catch(() => {}); + return result; + } +} + +/** + * Page provides methods to interact with a single tab or + * {@link https://developer.chrome.com/extensions/background_pages | extension background page} in Chromium. + * + * @remarks + * + * One Browser instance might have multiple Page instances. + * + * @example + * This example creates a page, navigates it to a URL, and then * saves a screenshot: + * ```js + * const puppeteer = require('puppeteer'); + * + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * await page.goto('https://example.com'); + * await page.screenshot({path: 'screenshot.png'}); + * await browser.close(); + * })(); + * ``` + * + * The Page class extends from Puppeteer's {@link EventEmitter} class and will + * emit various events which are documented in the {@link PageEmittedEvents} enum. + * + * @example + * This example logs a message for a single page `load` event: + * ```js + * page.once('load', () => console.log('Page loaded!')); + * ``` + * + * To unsubscribe from events use the `off` method: + * + * ```js + * function logRequest(interceptedRequest) { + * console.log('A request was made:', interceptedRequest.url()); + * } + * page.on('request', logRequest); + * // Sometime later... + * page.off('request', logRequest); + * ``` + * @public + */ +export class Page extends EventEmitter { + /** + * @internal + */ + static async create( + client: CDPSession, + target: Target, + ignoreHTTPSErrors: boolean, + defaultViewport: Viewport | null + ): Promise<Page> { + const page = new Page(client, target, ignoreHTTPSErrors); + await page._initialize(); + if (defaultViewport) await page.setViewport(defaultViewport); + return page; + } + + private _closed = false; + private _client: CDPSession; + private _target: Target; + private _keyboard: Keyboard; + private _mouse: Mouse; + private _timeoutSettings = new TimeoutSettings(); + private _touchscreen: Touchscreen; + private _accessibility: Accessibility; + private _frameManager: FrameManager; + private _emulationManager: EmulationManager; + private _tracing: Tracing; + private _pageBindings = new Map<string, Function>(); + private _coverage: Coverage; + private _javascriptEnabled = true; + private _viewport: Viewport | null; + private _screenshotTaskQueue: ScreenshotTaskQueue; + private _workers = new Map<string, WebWorker>(); + // TODO: improve this typedef - it's a function that takes a file chooser or + // something? + private _fileChooserInterceptors = new Set<Function>(); + + private _disconnectPromise?: Promise<Error>; + + /** + * @internal + */ + constructor(client: CDPSession, target: Target, ignoreHTTPSErrors: boolean) { + super(); + this._client = client; + this._target = target; + this._keyboard = new Keyboard(client); + this._mouse = new Mouse(client, this._keyboard); + this._touchscreen = new Touchscreen(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._screenshotTaskQueue = new ScreenshotTaskQueue(); + this._viewport = null; + + client.on('Target.attachedToTarget', (event) => { + if (event.targetInfo.type !== 'worker') { + // If we don't detach from service workers, they will never die. + client + .send('Target.detachFromTarget', { + sessionId: event.sessionId, + }) + .catch(debugError); + return; + } + const session = Connection.fromSession(client).session(event.sessionId); + const worker = new WebWorker( + session, + event.targetInfo.url, + this._addConsoleMessage.bind(this), + this._handleException.bind(this) + ); + this._workers.set(event.sessionId, worker); + this.emit(PageEmittedEvents.WorkerCreated, worker); + }); + client.on('Target.detachedFromTarget', (event) => { + const worker = this._workers.get(event.sessionId); + if (!worker) return; + this.emit(PageEmittedEvents.WorkerDestroyed, worker); + this._workers.delete(event.sessionId); + }); + + this._frameManager.on(FrameManagerEmittedEvents.FrameAttached, (event) => + this.emit(PageEmittedEvents.FrameAttached, event) + ); + this._frameManager.on(FrameManagerEmittedEvents.FrameDetached, (event) => + this.emit(PageEmittedEvents.FrameDetached, event) + ); + this._frameManager.on(FrameManagerEmittedEvents.FrameNavigated, (event) => + this.emit(PageEmittedEvents.FrameNavigated, event) + ); + + const networkManager = this._frameManager.networkManager(); + networkManager.on(NetworkManagerEmittedEvents.Request, (event) => + this.emit(PageEmittedEvents.Request, event) + ); + networkManager.on(NetworkManagerEmittedEvents.Response, (event) => + this.emit(PageEmittedEvents.Response, event) + ); + networkManager.on(NetworkManagerEmittedEvents.RequestFailed, (event) => + this.emit(PageEmittedEvents.RequestFailed, event) + ); + networkManager.on(NetworkManagerEmittedEvents.RequestFinished, (event) => + this.emit(PageEmittedEvents.RequestFinished, event) + ); + this._fileChooserInterceptors = new Set(); + + client.on('Page.domContentEventFired', () => + this.emit(PageEmittedEvents.DOMContentLoaded) + ); + client.on('Page.loadEventFired', () => this.emit(PageEmittedEvents.Load)); + client.on('Runtime.consoleAPICalled', (event) => this._onConsoleAPI(event)); + client.on('Runtime.bindingCalled', (event) => this._onBindingCalled(event)); + client.on('Page.javascriptDialogOpening', (event) => this._onDialog(event)); + client.on('Runtime.exceptionThrown', (exception) => + this._handleException(exception.exceptionDetails) + ); + client.on('Inspector.targetCrashed', () => this._onTargetCrashed()); + client.on('Performance.metrics', (event) => this._emitMetrics(event)); + client.on('Log.entryAdded', (event) => this._onLogEntryAdded(event)); + client.on('Page.fileChooserOpened', (event) => this._onFileChooser(event)); + this._target._isClosedPromise.then(() => { + this.emit(PageEmittedEvents.Close); + this._closed = true; + }); + } + + private async _initialize(): Promise<void> { + await Promise.all([ + this._frameManager.initialize(), + this._client.send('Target.setAutoAttach', { + autoAttach: true, + waitForDebuggerOnStart: false, + flatten: true, + }), + this._client.send('Performance.enable'), + this._client.send('Log.enable'), + ]); + } + + private async _onFileChooser( + event: Protocol.Page.FileChooserOpenedEvent + ): Promise<void> { + if (!this._fileChooserInterceptors.size) return; + const frame = this._frameManager.frame(event.frameId); + const context = await frame.executionContext(); + const element = await context._adoptBackendNodeId(event.backendNodeId); + const interceptors = Array.from(this._fileChooserInterceptors); + this._fileChooserInterceptors.clear(); + const fileChooser = new FileChooser(element, event); + for (const interceptor of interceptors) interceptor.call(null, fileChooser); + } + + /** + * @returns `true` if the page has JavaScript enabled, `false` otherwise. + */ + public isJavaScriptEnabled(): boolean { + return this._javascriptEnabled; + } + + /** + * @param options - Optional waiting parameters + * @returns Resolves after a page requests a file picker. + */ + async waitForFileChooser( + options: WaitTimeoutOptions = {} + ): Promise<FileChooser> { + if (!this._fileChooserInterceptors.size) + await this._client.send('Page.setInterceptFileChooserDialog', { + enabled: true, + }); + + const { timeout = this._timeoutSettings.timeout() } = options; + let callback; + const promise = new Promise<FileChooser>((x) => (callback = x)); + this._fileChooserInterceptors.add(callback); + return helper + .waitWithTimeout<FileChooser>( + promise, + 'waiting for file chooser', + timeout + ) + .catch((error) => { + this._fileChooserInterceptors.delete(callback); + throw error; + }); + } + + /** + * Sets the page's geolocation. + * + * @remarks + * Consider using {@link BrowserContext.overridePermissions} to grant + * permissions for the page to read its geolocation. + * + * @example + * ```js + * await page.setGeolocation({latitude: 59.95, longitude: 30.31667}); + * ``` + */ + 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._client.send('Emulation.setGeolocationOverride', { + longitude, + latitude, + accuracy, + }); + } + + /** + * @returns A target this page was created from. + */ + target(): Target { + return this._target; + } + + /** + * @returns The browser this page belongs to. + */ + browser(): Browser { + return this._target.browser(); + } + + /** + * @returns The browser context that the page belongs to + */ + browserContext(): BrowserContext { + return this._target.browserContext(); + } + + private _onTargetCrashed(): void { + this.emit('error', new Error('Page crashed!')); + } + + private _onLogEntryAdded(event: Protocol.Log.EntryAddedEvent): void { + const { level, text, args, source, url, lineNumber } = event.entry; + if (args) args.map((arg) => helper.releaseObject(this._client, arg)); + if (source !== 'worker') + this.emit( + PageEmittedEvents.Console, + new ConsoleMessage(level, text, [], [{ url, lineNumber }]) + ); + } + + /** + * @returns The page's main frame. + */ + mainFrame(): Frame { + return this._frameManager.mainFrame(); + } + + get keyboard(): Keyboard { + return this._keyboard; + } + + get touchscreen(): Touchscreen { + return this._touchscreen; + } + + get coverage(): Coverage { + return this._coverage; + } + + get tracing(): Tracing { + return this._tracing; + } + + get accessibility(): Accessibility { + return this._accessibility; + } + + /** + * @returns An array of all frames attached to the page. + */ + frames(): Frame[] { + return this._frameManager.frames(); + } + + /** + * @returns all of the dedicated + * {@link https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API | WebWorkers} + * associated with the page. + */ + workers(): WebWorker[] { + return Array.from(this._workers.values()); + } + + /** + * @param value - Whether to enable request interception. + * + * @remarks + * Activating request interception enables {@link HTTPRequest.abort}, + * {@link HTTPRequest.continue} and {@link HTTPRequest.respond} methods. This + * provides the capability to modify network requests that are made by a page. + * + * Once request interception is enabled, every request will stall unless it's + * continued, responded or aborted. + * + * **NOTE** Enabling request interception disables page caching. + * + * @example + * An example of a naïve request interceptor that aborts all image requests: + * ```js + * const puppeteer = require('puppeteer'); + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * await page.setRequestInterception(true); + * page.on('request', interceptedRequest => { + * if (interceptedRequest.url().endsWith('.png') || + * interceptedRequest.url().endsWith('.jpg')) + * interceptedRequest.abort(); + * else + * interceptedRequest.continue(); + * }); + * await page.goto('https://example.com'); + * await browser.close(); + * })(); + * ``` + */ + async setRequestInterception(value: boolean): Promise<void> { + return this._frameManager.networkManager().setRequestInterception(value); + } + + /** + * @param enabled - When `true`, enables offline mode for the page. + */ + setOfflineMode(enabled: boolean): Promise<void> { + return this._frameManager.networkManager().setOfflineMode(enabled); + } + + /** + * @param timeout - Maximum navigation time in milliseconds. + */ + setDefaultNavigationTimeout(timeout: number): void { + this._timeoutSettings.setDefaultNavigationTimeout(timeout); + } + + /** + * @param timeout - Maximum time in milliseconds. + */ + setDefaultTimeout(timeout: number): void { + this._timeoutSettings.setDefaultTimeout(timeout); + } + + /** + * Runs `document.querySelector` within the page. If no element matches the + * selector, the return value resolves to `null`. + * + * @remarks + * Shortcut for {@link Frame.$ | Page.mainFrame().$(selector) }. + * + * @param selector - A + * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors | selector} + * to query page for. + */ + async $(selector: string): Promise<ElementHandle | null> { + return this.mainFrame().$(selector); + } + + /** + * @remarks + * + * The only difference between {@link Page.evaluate | page.evaluate} and + * `page.evaluateHandle` is that `evaluateHandle` will return the value + * wrapped in an in-page object. + * + * If the function passed to `page.evaluteHandle` returns a Promise, the + * function will wait for the promise to resolve and return its value. + * + * You can pass a string instead of a function (although functions are + * recommended as they are easier to debug and use with TypeScript): + * + * @example + * ``` + * const aHandle = await page.evaluateHandle('document') + * ``` + * + * @example + * {@link JSHandle} instances can be passed as arguments to the `pageFunction`: + * ``` + * const aHandle = await page.evaluateHandle(() => document.body); + * const resultHandle = await page.evaluateHandle(body => body.innerHTML, aHandle); + * console.log(await resultHandle.jsonValue()); + * await resultHandle.dispose(); + * ``` + * + * Most of the time this function returns a {@link JSHandle}, + * but if `pageFunction` returns a reference to an element, + * you instead get an {@link ElementHandle} back: + * + * @example + * ``` + * const button = await page.evaluateHandle(() => document.querySelector('button')); + * // can call `click` because `button` is an `ElementHandle` + * await button.click(); + * ``` + * + * The TypeScript definitions assume that `evaluateHandle` returns + * a `JSHandle`, but if you know it's going to return an + * `ElementHandle`, pass it as the generic argument: + * + * ``` + * const button = await page.evaluateHandle<ElementHandle>(...); + * ``` + * + * @param pageFunction - a function that is run within the page + * @param args - arguments to be passed to the pageFunction + */ + async evaluateHandle<HandlerType extends JSHandle = JSHandle>( + pageFunction: EvaluateHandleFn, + ...args: SerializableOrJSHandle[] + ): Promise<HandlerType> { + const context = await this.mainFrame().executionContext(); + return context.evaluateHandle<HandlerType>(pageFunction, ...args); + } + + /** + * This method iterates the JavaScript heap and finds all objects with the + * given prototype. + * + * @remarks + * + * @example + * + * ```js + * // Create a Map object + * await page.evaluate(() => window.map = new Map()); + * // Get a handle to the Map object prototype + * const mapPrototype = await page.evaluateHandle(() => Map.prototype); + * // Query all map instances into an array + * const mapInstances = await page.queryObjects(mapPrototype); + * // Count amount of map objects in heap + * const count = await page.evaluate(maps => maps.length, mapInstances); + * await mapInstances.dispose(); + * await mapPrototype.dispose(); + * ``` + * @param prototypeHandle - a handle to the object prototype. + */ + async queryObjects(prototypeHandle: JSHandle): Promise<JSHandle> { + const context = await this.mainFrame().executionContext(); + return context.queryObjects(prototypeHandle); + } + + /** + * This method runs `document.querySelector` within the page and passes the + * result as the first argument to the `pageFunction`. + * + * @remarks + * + * If no element is found matching `selector`, the method will throw an error. + * + * If `pageFunction` returns a promise `$eval` will wait for the promise to + * resolve and then return its value. + * + * @example + * + * ``` + * const searchValue = await page.$eval('#search', el => el.value); + * const preloadHref = await page.$eval('link[rel=preload]', el => el.href); + * const html = await page.$eval('.main-container', el => el.outerHTML); + * ``` + * + * If you are using TypeScript, you may have to provide an explicit type to the + * first argument of the `pageFunction`. + * By default it is typed as `Element`, but you may need to provide a more + * specific sub-type: + * + * @example + * + * ``` + * // if you don't provide HTMLInputElement here, TS will error + * // as `value` is not on `Element` + * const searchValue = await page.$eval('#search', (el: HTMLInputElement) => el.value); + * ``` + * + * The compiler should be able to infer the return type + * from the `pageFunction` you provide. If it is unable to, you can use the generic + * type to tell the compiler what return type you expect from `$eval`: + * + * @example + * + * ``` + * // The compiler can infer the return type in this case, but if it can't + * // or if you want to be more explicit, provide it as the generic type. + * const searchValue = await page.$eval<string>( + * '#search', (el: HTMLInputElement) => el.value + * ); + * ``` + * + * @param selector - the + * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors | selector} + * to query for + * @param pageFunction - the function to be evaluated in the page context. + * Will be passed the result of `document.querySelector(selector)` as its + * first argument. + * @param args - any additional arguments to pass through to `pageFunction`. + * + * @returns The result of calling `pageFunction`. If it returns an element it + * is wrapped in an {@link ElementHandle}, else the raw value itself is + * returned. + */ + async $eval<ReturnType>( + selector: string, + pageFunction: ( + element: Element, + /* Unfortunately this has to be unknown[] because it's hard to get + * TypeScript to understand that the arguments will be left alone unless + * they are an ElementHandle, in which case they will be unwrapped. + * The nice thing about unknown vs any is that unknown will force the user + * to type the item before using it to avoid errors. + * + * TODO(@jackfranklin): We could fix this by using overloads like + * DefinitelyTyped does: + * https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/puppeteer/index.d.ts#L114 + */ + ...args: unknown[] + ) => ReturnType | Promise<ReturnType>, + ...args: SerializableOrJSHandle[] + ): Promise<WrapElementHandle<ReturnType>> { + return this.mainFrame().$eval<ReturnType>(selector, pageFunction, ...args); + } + + /** + * This method runs `Array.from(document.querySelectorAll(selector))` within + * the page and passes the result as the first argument to the `pageFunction`. + * + * @remarks + * + * If `pageFunction` returns a promise `$$eval` will wait for the promise to + * resolve and then return its value. + * + * @example + * + * ``` + * // get the amount of divs on the page + * const divCount = await page.$$eval('div', divs => divs.length); + * + * // get the text content of all the `.options` elements: + * const options = await page.$$eval('div > span.options', options => { + * return options.map(option => option.textContent) + * }); + * ``` + * + * If you are using TypeScript, you may have to provide an explicit type to the + * first argument of the `pageFunction`. + * By default it is typed as `Element[]`, but you may need to provide a more + * specific sub-type: + * + * @example + * + * ``` + * // if you don't provide HTMLInputElement here, TS will error + * // as `value` is not on `Element` + * await page.$$eval('input', (elements: HTMLInputElement[]) => { + * return elements.map(e => e.value); + * }); + * ``` + * + * The compiler should be able to infer the return type + * from the `pageFunction` you provide. If it is unable to, you can use the generic + * type to tell the compiler what return type you expect from `$$eval`: + * + * @example + * + * ``` + * // The compiler can infer the return type in this case, but if it can't + * // or if you want to be more explicit, provide it as the generic type. + * const allInputValues = await page.$$eval<string[]>( + * 'input', (elements: HTMLInputElement[]) => elements.map(e => e.textContent) + * ); + * ``` + * + * @param selector the + * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors | selector} + * to query for + * @param pageFunction the function to be evaluated in the page context. Will + * be passed the result of `Array.from(document.querySelectorAll(selector))` + * as its first argument. + * @param args any additional arguments to pass through to `pageFunction`. + * + * @returns The result of calling `pageFunction`. If it returns an element it + * is wrapped in an {@link ElementHandle}, else the raw value itself is + * returned. + */ + async $$eval<ReturnType>( + selector: string, + pageFunction: ( + elements: Element[], + /* These have to be typed as unknown[] for the same reason as the $eval + * definition above, please see that comment for more details and the TODO + * that will improve things. + */ + ...args: unknown[] + ) => ReturnType | Promise<ReturnType>, + ...args: SerializableOrJSHandle[] + ): Promise<WrapElementHandle<ReturnType>> { + return this.mainFrame().$$eval<ReturnType>(selector, pageFunction, ...args); + } + + async $$(selector: string): Promise<ElementHandle[]> { + return this.mainFrame().$$(selector); + } + + async $x(expression: string): Promise<ElementHandle[]> { + return this.mainFrame().$x(expression); + } + + /** + * If no URLs are specified, this method returns cookies for the current page + * URL. If URLs are specified, only cookies for those URLs are returned. + */ + async cookies(...urls: string[]): Promise<Protocol.Network.Cookie[]> { + const originalCookies = ( + await this._client.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[attr]; + return cookie; + }; + return originalCookies.map(filterUnsupportedAttributes); + } + + 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._client.send('Network.deleteCookies', item); + } + } + + 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._client.send('Network.setCookies', { cookies: items }); + } + + async addScriptTag(options: { + url?: string; + path?: string; + content?: string; + type?: string; + }): Promise<ElementHandle> { + return this.mainFrame().addScriptTag(options); + } + + async addStyleTag(options: { + url?: string; + path?: string; + content?: string; + }): Promise<ElementHandle> { + return this.mainFrame().addStyleTag(options); + } + + async exposeFunction( + name: string, + puppeteerFunction: Function + ): Promise<void> { + if (this._pageBindings.has(name)) + throw new Error( + `Failed to add page binding with name ${name}: window['${name}'] already exists!` + ); + this._pageBindings.set(name, puppeteerFunction); + + const expression = helper.pageBindingInitString('exposedFun', name); + await this._client.send('Runtime.addBinding', { name: name }); + await this._client.send('Page.addScriptToEvaluateOnNewDocument', { + source: expression, + }); + await Promise.all( + this.frames().map((frame) => frame.evaluate(expression).catch(debugError)) + ); + } + + async authenticate(credentials: Credentials): Promise<void> { + return this._frameManager.networkManager().authenticate(credentials); + } + + async setExtraHTTPHeaders(headers: Record<string, string>): Promise<void> { + return this._frameManager.networkManager().setExtraHTTPHeaders(headers); + } + + async setUserAgent(userAgent: string): Promise<void> { + return this._frameManager.networkManager().setUserAgent(userAgent); + } + + async metrics(): Promise<Metrics> { + const response = await this._client.send('Performance.getMetrics'); + return this._buildMetricsObject(response.metrics); + } + + private _emitMetrics(event: Protocol.Performance.MetricsEvent): void { + this.emit(PageEmittedEvents.Metrics, { + title: event.title, + metrics: this._buildMetricsObject(event.metrics), + }); + } + + private _buildMetricsObject( + metrics?: Protocol.Performance.Metric[] + ): Metrics { + const result = {}; + for (const metric of metrics || []) { + if (supportedMetrics.has(metric.name)) result[metric.name] = metric.value; + } + return result; + } + + private _handleException( + exceptionDetails: Protocol.Runtime.ExceptionDetails + ): void { + const message = helper.getExceptionMessage(exceptionDetails); + const err = new Error(message); + err.stack = ''; // Don't report clientside error with a node stack attached + this.emit(PageEmittedEvents.PageError, err); + } + + private 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.executionContextById( + event.executionContextId + ); + const values = event.args.map((arg) => createJSHandle(context, arg)); + this._addConsoleMessage(event.type, values, event.stackTrace); + } + + private async _onBindingCalled( + event: Protocol.Runtime.BindingCalledEvent + ): Promise<void> { + let payload: { type: string; name: string; seq: number; args: unknown[] }; + 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 } = payload; + if (type !== 'exposedFun' || !this._pageBindings.has(name)) return; + let expression = null; + try { + const result = await this._pageBindings.get(name)(...args); + expression = helper.pageBindingDeliverResultString(name, seq, result); + } catch (error) { + if (error instanceof Error) + expression = helper.pageBindingDeliverErrorString( + name, + seq, + error.message, + error.stack + ); + else + expression = helper.pageBindingDeliverErrorValueString( + name, + seq, + error + ); + } + this._client + .send('Runtime.evaluate', { + expression, + contextId: event.executionContextId, + }) + .catch(debugError); + } + + private _addConsoleMessage( + type: ConsoleMessageType, + args: JSHandle[], + stackTrace?: Protocol.Runtime.StackTrace + ): void { + if (!this.listenerCount(PageEmittedEvents.Console)) { + args.forEach((arg) => arg.dispose()); + return; + } + const textTokens = []; + for (const arg of args) { + const remoteObject = arg._remoteObject; + if (remoteObject.objectId) textTokens.push(arg.toString()); + else textTokens.push(helper.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( + type, + textTokens.join(' '), + args, + stackTraceLocations + ); + this.emit(PageEmittedEvents.Console, message); + } + + private _onDialog(event: Protocol.Page.JavascriptDialogOpeningEvent): void { + let dialogType = null; + const validDialogTypes = new Set<Protocol.Page.DialogType>([ + 'alert', + 'confirm', + 'prompt', + 'beforeunload', + ]); + + if (validDialogTypes.has(event.type)) { + dialogType = event.type as Protocol.Page.DialogType; + } + assert(dialogType, 'Unknown javascript dialog type: ' + event.type); + + const dialog = new Dialog( + this._client, + dialogType, + event.message, + event.defaultPrompt + ); + this.emit(PageEmittedEvents.Dialog, dialog); + } + + url(): string { + return this.mainFrame().url(); + } + + async content(): Promise<string> { + return await this._frameManager.mainFrame().content(); + } + + async setContent(html: string, options: WaitForOptions = {}): Promise<void> { + await this._frameManager.mainFrame().setContent(html, options); + } + + async goto( + url: string, + options: WaitForOptions & { referer?: string } = {} + ): Promise<HTTPResponse> { + return await this._frameManager.mainFrame().goto(url, options); + } + + async reload(options?: WaitForOptions): Promise<HTTPResponse | null> { + const result = await Promise.all<HTTPResponse, void>([ + this.waitForNavigation(options), + this._client.send('Page.reload'), + ]); + + return result[0]; + } + + async waitForNavigation( + options: WaitForOptions = {} + ): Promise<HTTPResponse | null> { + return await this._frameManager.mainFrame().waitForNavigation(options); + } + + private _sessionClosePromise(): Promise<Error> { + if (!this._disconnectPromise) + this._disconnectPromise = new Promise((fulfill) => + this._client.once(CDPSessionEmittedEvents.Disconnected, () => + fulfill(new Error('Target closed')) + ) + ); + return this._disconnectPromise; + } + + async waitForRequest( + urlOrPredicate: string | Function, + options: { timeout?: number } = {} + ): Promise<HTTPRequest> { + const { timeout = this._timeoutSettings.timeout() } = options; + return helper.waitForEvent( + this._frameManager.networkManager(), + NetworkManagerEmittedEvents.Request, + (request) => { + if (helper.isString(urlOrPredicate)) + return urlOrPredicate === request.url(); + if (typeof urlOrPredicate === 'function') + return !!urlOrPredicate(request); + return false; + }, + timeout, + this._sessionClosePromise() + ); + } + + async waitForResponse( + urlOrPredicate: string | Function, + options: { timeout?: number } = {} + ): Promise<HTTPResponse> { + const { timeout = this._timeoutSettings.timeout() } = options; + return helper.waitForEvent( + this._frameManager.networkManager(), + NetworkManagerEmittedEvents.Response, + (response) => { + if (helper.isString(urlOrPredicate)) + return urlOrPredicate === response.url(); + if (typeof urlOrPredicate === 'function') + return !!urlOrPredicate(response); + return false; + }, + timeout, + this._sessionClosePromise() + ); + } + + async goBack(options: WaitForOptions = {}): Promise<HTTPResponse | null> { + return this._go(-1, options); + } + + async goForward(options: WaitForOptions = {}): Promise<HTTPResponse | null> { + return this._go(+1, options); + } + + private async _go( + delta: number, + options: WaitForOptions + ): Promise<HTTPResponse | null> { + const history = await this._client.send('Page.getNavigationHistory'); + const entry = history.entries[history.currentIndex + delta]; + if (!entry) return null; + const result = await Promise.all([ + this.waitForNavigation(options), + this._client.send('Page.navigateToHistoryEntry', { entryId: entry.id }), + ]); + return result[0]; + } + + async bringToFront(): Promise<void> { + await this._client.send('Page.bringToFront'); + } + + async emulate(options: { + viewport: Viewport; + userAgent: string; + }): Promise<void> { + await Promise.all([ + this.setViewport(options.viewport), + this.setUserAgent(options.userAgent), + ]); + } + + async setJavaScriptEnabled(enabled: boolean): Promise<void> { + if (this._javascriptEnabled === enabled) return; + this._javascriptEnabled = enabled; + await this._client.send('Emulation.setScriptExecutionDisabled', { + value: !enabled, + }); + } + + async setBypassCSP(enabled: boolean): Promise<void> { + await this._client.send('Page.setBypassCSP', { enabled }); + } + + async emulateMediaType(type?: string): Promise<void> { + assert( + type === 'screen' || type === 'print' || type === null, + 'Unsupported media type: ' + type + ); + await this._client.send('Emulation.setEmulatedMedia', { + media: type || '', + }); + } + + async emulateMediaFeatures(features?: MediaFeature[]): Promise<void> { + if (features === null) + await this._client.send('Emulation.setEmulatedMedia', { features: null }); + if (Array.isArray(features)) { + features.every((mediaFeature) => { + const name = mediaFeature.name; + assert( + /^prefers-(?:color-scheme|reduced-motion)$/.test(name), + 'Unsupported media feature: ' + name + ); + return true; + }); + await this._client.send('Emulation.setEmulatedMedia', { + features: features, + }); + } + } + + async emulateTimezone(timezoneId?: string): Promise<void> { + try { + await this._client.send('Emulation.setTimezoneOverride', { + timezoneId: timezoneId || '', + }); + } catch (error) { + if (error.message.includes('Invalid timezone')) + throw new Error(`Invalid timezone ID: ${timezoneId}`); + throw error; + } + } + + /** + * Emulates the idle state. + * If no arguments set, clears idle state emulation. + * + * @example + * ```js + * // set idle emulation + * await page.emulateIdleState({isUserActive: true, isScreenUnlocked: false}); + * + * // do some checks here + * ... + * + * // clear idle emulation + * await page.emulateIdleState(); + * ``` + * + * @param overrides Mock idle state. If not set, clears idle overrides + * @param isUserActive Mock isUserActive + * @param isScreenUnlocked Mock isScreenUnlocked + */ + async emulateIdleState(overrides?: { + isUserActive: boolean; + isScreenUnlocked: boolean; + }): Promise<void> { + if (overrides) { + await this._client.send('Emulation.setIdleOverride', { + isUserActive: overrides.isUserActive, + isScreenUnlocked: overrides.isScreenUnlocked, + }); + } else { + await this._client.send('Emulation.clearIdleOverride'); + } + } + + /** + * Simulates the given vision deficiency on the page. + * + * @example + * ```js + * const puppeteer = require('puppeteer'); + * + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * await page.goto('https://v8.dev/blog/10-years'); + * + * await page.emulateVisionDeficiency('achromatopsia'); + * await page.screenshot({ path: 'achromatopsia.png' }); + * + * await page.emulateVisionDeficiency('deuteranopia'); + * await page.screenshot({ path: 'deuteranopia.png' }); + * + * await page.emulateVisionDeficiency('blurredVision'); + * await page.screenshot({ path: 'blurred-vision.png' }); + * + * await browser.close(); + * })(); + * ``` + * + * @param type - the type of deficiency to simulate, or `'none'` to reset. + */ + async emulateVisionDeficiency( + type?: Protocol.Emulation.SetEmulatedVisionDeficiencyRequest['type'] + ): Promise<void> { + const visionDeficiencies = new Set< + Protocol.Emulation.SetEmulatedVisionDeficiencyRequest['type'] + >([ + 'none', + 'achromatopsia', + 'blurredVision', + 'deuteranopia', + 'protanopia', + 'tritanopia', + ]); + try { + assert( + !type || visionDeficiencies.has(type), + `Unsupported vision deficiency: ${type}` + ); + await this._client.send('Emulation.setEmulatedVisionDeficiency', { + type: type || 'none', + }); + } catch (error) { + throw error; + } + } + + async setViewport(viewport: Viewport): Promise<void> { + const needsReload = await this._emulationManager.emulateViewport(viewport); + this._viewport = viewport; + if (needsReload) await this.reload(); + } + + viewport(): Viewport | null { + return this._viewport; + } + + /** + * @remarks + * + * Evaluates a function in the page's context and returns the result. + * + * If the function passed to `page.evaluteHandle` returns a Promise, the + * function will wait for the promise to resolve and return its value. + * + * @example + * + * ```js + * const result = await frame.evaluate(() => { + * return Promise.resolve(8 * 7); + * }); + * console.log(result); // prints "56" + * ``` + * + * You can pass a string instead of a function (although functions are + * recommended as they are easier to debug and use with TypeScript): + * + * @example + * ``` + * const aHandle = await page.evaluate('1 + 2'); + * ``` + * + * To get the best TypeScript experience, you should pass in as the + * generic the type of `pageFunction`: + * + * ``` + * const aHandle = await page.evaluate<() => number>(() => 2); + * ``` + * + * @example + * + * {@link ElementHandle} instances (including {@link JSHandle}s) can be passed + * as arguments to the `pageFunction`: + * + * ``` + * const bodyHandle = await page.$('body'); + * const html = await page.evaluate(body => body.innerHTML, bodyHandle); + * await bodyHandle.dispose(); + * ``` + * + * @param pageFunction - a function that is run within the page + * @param args - arguments to be passed to the pageFunction + * + * @returns the return value of `pageFunction`. + */ + async evaluate<T extends EvaluateFn>( + pageFunction: T, + ...args: SerializableOrJSHandle[] + ): Promise<UnwrapPromiseLike<EvaluateFnReturnType<T>>> { + return this._frameManager.mainFrame().evaluate<T>(pageFunction, ...args); + } + + async evaluateOnNewDocument( + pageFunction: Function | string, + ...args: unknown[] + ): Promise<void> { + const source = helper.evaluationString(pageFunction, ...args); + await this._client.send('Page.addScriptToEvaluateOnNewDocument', { + source, + }); + } + + async setCacheEnabled(enabled = true): Promise<void> { + await this._frameManager.networkManager().setCacheEnabled(enabled); + } + + async screenshot( + options: ScreenshotOptions = {} + ): Promise<Buffer | string | void> { + let screenshotType = null; + // options.type takes precedence over inferring the type from options.path + // because it may be a 0-length file with no extension created beforehand + // (i.e. as a temp file). + if (options.type) { + assert( + options.type === 'png' || options.type === 'jpeg', + 'Unknown options.type value: ' + options.type + ); + screenshotType = options.type; + } else if (options.path) { + const filePath = options.path; + const extension = filePath + .slice(filePath.lastIndexOf('.') + 1) + .toLowerCase(); + if (extension === 'png') screenshotType = 'png'; + else if (extension === 'jpg' || extension === 'jpeg') + screenshotType = 'jpeg'; + assert( + screenshotType, + `Unsupported screenshot type for extension \`.${extension}\`` + ); + } + + if (!screenshotType) screenshotType = 'png'; + + if (options.quality) { + assert( + screenshotType === 'jpeg', + 'options.quality is unsupported for the ' + + screenshotType + + ' screenshots' + ); + assert( + typeof options.quality === 'number', + 'Expected options.quality to be a number but found ' + + typeof options.quality + ); + assert( + Number.isInteger(options.quality), + 'Expected options.quality to be an integer' + ); + assert( + options.quality >= 0 && options.quality <= 100, + 'Expected options.quality to be between 0 and 100 (inclusive), got ' + + options.quality + ); + } + assert( + !options.clip || !options.fullPage, + 'options.clip and options.fullPage are exclusive' + ); + if (options.clip) { + assert( + typeof options.clip.x === 'number', + 'Expected options.clip.x to be a number but found ' + + typeof options.clip.x + ); + assert( + typeof options.clip.y === 'number', + 'Expected options.clip.y to be a number but found ' + + typeof options.clip.y + ); + assert( + typeof options.clip.width === 'number', + 'Expected options.clip.width to be a number but found ' + + typeof options.clip.width + ); + assert( + typeof options.clip.height === 'number', + 'Expected options.clip.height to be a number but found ' + + typeof options.clip.height + ); + assert( + options.clip.width !== 0, + 'Expected options.clip.width not to be 0.' + ); + assert( + options.clip.height !== 0, + 'Expected options.clip.height not to be 0.' + ); + } + return this._screenshotTaskQueue.postTask(() => + this._screenshotTask(screenshotType, options) + ); + } + + private async _screenshotTask( + format: 'png' | 'jpeg', + options?: ScreenshotOptions + ): Promise<Buffer | string> { + await this._client.send('Target.activateTarget', { + targetId: this._target._targetId, + }); + let clip = options.clip ? processClip(options.clip) : undefined; + + if (options.fullPage) { + const metrics = await this._client.send('Page.getLayoutMetrics'); + const width = Math.ceil(metrics.contentSize.width); + const height = Math.ceil(metrics.contentSize.height); + + // Overwrite clip for full page at all times. + clip = { x: 0, y: 0, width, height, scale: 1 }; + const { isMobile = false, deviceScaleFactor = 1, isLandscape = false } = + this._viewport || {}; + const screenOrientation: Protocol.Emulation.ScreenOrientation = isLandscape + ? { angle: 90, type: 'landscapePrimary' } + : { angle: 0, type: 'portraitPrimary' }; + await this._client.send('Emulation.setDeviceMetricsOverride', { + mobile: isMobile, + width, + height, + deviceScaleFactor, + screenOrientation, + }); + } + const shouldSetDefaultBackground = + options.omitBackground && format === 'png'; + if (shouldSetDefaultBackground) + await this._client.send('Emulation.setDefaultBackgroundColorOverride', { + color: { r: 0, g: 0, b: 0, a: 0 }, + }); + const result = await this._client.send('Page.captureScreenshot', { + format, + quality: options.quality, + clip, + }); + if (shouldSetDefaultBackground) + await this._client.send('Emulation.setDefaultBackgroundColorOverride'); + + if (options.fullPage && this._viewport) + await this.setViewport(this._viewport); + + const buffer = + options.encoding === 'base64' + ? result.data + : Buffer.from(result.data, 'base64'); + if (!isNode && options.path) { + throw new Error( + 'Screenshots can only be written to a file path in a Node environment.' + ); + } + const fs = await helper.importFSModule(); + if (options.path) await fs.promises.writeFile(options.path, buffer); + return buffer; + + function processClip( + clip: ScreenshotClip + ): ScreenshotClip & { scale: number } { + const x = Math.round(clip.x); + const y = Math.round(clip.y); + const width = Math.round(clip.width + clip.x - x); + const height = Math.round(clip.height + clip.y - y); + return { x, y, width, height, scale: 1 }; + } + } + + /** + * Generatees a PDF of the page with the `print` CSS media type. + * @remarks + * + * IMPORTANT: PDF generation is only supported in Chrome headless mode. + * + * To generate a PDF with the `screen` media type, call + * {@link Page.emulateMediaType | `page.emulateMediaType('screen')`} before + * calling `page.pdf()`. + * + * By default, `page.pdf()` generates a pdf with modified colors for printing. + * Use the + * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/-webkit-print-color-adjust | `-webkit-print-color-adjust`} + * property to force rendering of exact colors. + * + * + * @param options - options for generating the PDF. + */ + async pdf(options: PDFOptions = {}): Promise<Buffer> { + const { + scale = 1, + displayHeaderFooter = false, + headerTemplate = '', + footerTemplate = '', + printBackground = false, + landscape = false, + pageRanges = '', + preferCSSPageSize = false, + margin = {}, + path = null, + } = options; + + let paperWidth = 8.5; + let paperHeight = 11; + if (options.format) { + const format = paperFormats[options.format.toLowerCase()]; + assert(format, 'Unknown paper format: ' + options.format); + paperWidth = format.width; + paperHeight = format.height; + } else { + paperWidth = convertPrintParameterToInches(options.width) || paperWidth; + paperHeight = + convertPrintParameterToInches(options.height) || paperHeight; + } + + const marginTop = convertPrintParameterToInches(margin.top) || 0; + const marginLeft = convertPrintParameterToInches(margin.left) || 0; + const marginBottom = convertPrintParameterToInches(margin.bottom) || 0; + const marginRight = convertPrintParameterToInches(margin.right) || 0; + + const result = await this._client.send('Page.printToPDF', { + transferMode: 'ReturnAsStream', + landscape, + displayHeaderFooter, + headerTemplate, + footerTemplate, + printBackground, + scale, + paperWidth, + paperHeight, + marginTop, + marginBottom, + marginLeft, + marginRight, + pageRanges, + preferCSSPageSize, + }); + return await helper.readProtocolStream(this._client, result.stream, path); + } + + async title(): Promise<string> { + return this.mainFrame().title(); + } + + async close( + options: { runBeforeUnload?: boolean } = { runBeforeUnload: undefined } + ): Promise<void> { + assert( + !!this._client._connection, + 'Protocol error: Connection closed. Most likely the page has been closed.' + ); + const runBeforeUnload = !!options.runBeforeUnload; + if (runBeforeUnload) { + await this._client.send('Page.close'); + } else { + await this._client._connection.send('Target.closeTarget', { + targetId: this._target._targetId, + }); + await this._target._isClosedPromise; + } + } + + isClosed(): boolean { + return this._closed; + } + + get mouse(): Mouse { + return this._mouse; + } + + click( + selector: string, + options: { + delay?: number; + button?: MouseButton; + clickCount?: number; + } = {} + ): Promise<void> { + return this.mainFrame().click(selector, options); + } + + focus(selector: string): Promise<void> { + return this.mainFrame().focus(selector); + } + + hover(selector: string): Promise<void> { + return this.mainFrame().hover(selector); + } + + select(selector: string, ...values: string[]): Promise<string[]> { + return this.mainFrame().select(selector, ...values); + } + + tap(selector: string): Promise<void> { + return this.mainFrame().tap(selector); + } + + type( + selector: string, + text: string, + options?: { delay: number } + ): Promise<void> { + return this.mainFrame().type(selector, text, options); + } + + /** + * @remarks + * + * This method behaves differently depending on the first parameter. If it's a + * `string`, it will be treated as a `selector` or `xpath` (if the string + * starts with `//`). This method then is a shortcut for + * {@link Page.waitForSelector} or {@link Page.waitForXPath}. + * + * If the first argument is a function this method is a shortcut for + * {@link Page.waitForFunction}. + * + * If the first argument is a `number`, it's treated as a timeout in + * milliseconds and the method returns a promise which resolves after the + * timeout. + * + * @param selectorOrFunctionOrTimeout - a selector, predicate or timeout to + * wait for. + * @param options - optional waiting parameters. + * @param args - arguments to pass to `pageFunction`. + * + * @deprecated Don't use this method directly. Instead use the more explicit + * methods available: {@link Page.waitForSelector}, + * {@link Page.waitForXPath}, {@link Page.waitForFunction} or + * {@link Page.waitForTimeout}. + */ + waitFor( + selectorOrFunctionOrTimeout: string | number | Function, + options: { + visible?: boolean; + hidden?: boolean; + timeout?: number; + polling?: string | number; + } = {}, + ...args: SerializableOrJSHandle[] + ): Promise<JSHandle> { + return this.mainFrame().waitFor( + selectorOrFunctionOrTimeout, + options, + ...args + ); + } + + /** + * Causes your script to wait for the given number of milliseconds. + * + * @remarks + * + * It's generally recommended to not wait for a number of seconds, but instead + * use {@link Page.waitForSelector}, {@link Page.waitForXPath} or + * {@link Page.waitForFunction} to wait for exactly the conditions you want. + * + * @example + * + * Wait for 1 second: + * + * ``` + * await page.waitForTimeout(1000); + * ``` + * + * @param milliseconds - the number of milliseconds to wait. + */ + waitForTimeout(milliseconds: number): Promise<void> { + return this.mainFrame().waitForTimeout(milliseconds); + } + + waitForSelector( + selector: string, + options: { + visible?: boolean; + hidden?: boolean; + timeout?: number; + } = {} + ): Promise<ElementHandle | null> { + return this.mainFrame().waitForSelector(selector, options); + } + + waitForXPath( + xpath: string, + options: { + visible?: boolean; + hidden?: boolean; + timeout?: number; + } = {} + ): Promise<ElementHandle | null> { + return this.mainFrame().waitForXPath(xpath, options); + } + + waitForFunction( + pageFunction: Function | string, + options: { + timeout?: number; + polling?: string | number; + } = {}, + ...args: SerializableOrJSHandle[] + ): Promise<JSHandle> { + return this.mainFrame().waitForFunction(pageFunction, options, ...args); + } +} + +const supportedMetrics = new Set<string>([ + 'Timestamp', + 'Documents', + 'Frames', + 'JSEventListeners', + 'Nodes', + 'LayoutCount', + 'RecalcStyleCount', + 'LayoutDuration', + 'RecalcStyleDuration', + 'ScriptDuration', + 'TaskDuration', + 'JSHeapUsedSize', + 'JSHeapTotalSize', +]); + +const unitToPixels = { + px: 1, + in: 96, + cm: 37.8, + mm: 3.78, +}; + +function convertPrintParameterToInches( + parameter?: string | number +): number | undefined { + if (typeof parameter === 'undefined') return undefined; + let pixels; + if (helper.isNumber(parameter)) { + // Treat numbers as pixel values to be aligned with phantom's paperSize. + pixels = /** @type {number} */ parameter; + } else if (helper.isString(parameter)) { + const text = /** @type {string} */ parameter; + let unit = text.substring(text.length - 2).toLowerCase(); + let valueText = ''; + if (unitToPixels.hasOwnProperty(unit)) { + valueText = text.substring(0, text.length - 2); + } else { + // In case of unknown unit try to parse the whole parameter as number of pixels. + // This is consistent with phantom's paperSize behavior. + unit = 'px'; + valueText = text; + } + const value = Number(valueText); + assert(!isNaN(value), 'Failed to parse parameter value: ' + text); + pixels = value * unitToPixels[unit]; + } else { + throw new Error( + 'page.pdf() Cannot handle parameter type: ' + typeof parameter + ); + } + return pixels / 96; +} diff --git a/remote/test/puppeteer/src/common/Product.ts b/remote/test/puppeteer/src/common/Product.ts new file mode 100644 index 0000000000..58a62fad3e --- /dev/null +++ b/remote/test/puppeteer/src/common/Product.ts @@ -0,0 +1,21 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Supported products. + * @public + */ +export type Product = 'chrome' | 'firefox'; diff --git a/remote/test/puppeteer/src/common/Puppeteer.ts b/remote/test/puppeteer/src/common/Puppeteer.ts new file mode 100644 index 0000000000..0dc40d33b5 --- /dev/null +++ b/remote/test/puppeteer/src/common/Puppeteer.ts @@ -0,0 +1,169 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { puppeteerErrors, PuppeteerErrors } from './Errors.js'; +import { ConnectionTransport } from './ConnectionTransport.js'; +import { devicesMap, DevicesMap } from './DeviceDescriptors.js'; +import { Browser } from './Browser.js'; +import { + registerCustomQueryHandler, + unregisterCustomQueryHandler, + customQueryHandlerNames, + clearCustomQueryHandlers, + CustomQueryHandler, +} from './QueryHandler.js'; +import { Product } from './Product.js'; +import { connectToBrowser, BrowserOptions } from './BrowserConnector.js'; + +/** + * Settings that are common to the Puppeteer class, regardless of enviroment. + * @internal + */ +export interface CommonPuppeteerSettings { + isPuppeteerCore: boolean; +} + +export interface ConnectOptions extends BrowserOptions { + browserWSEndpoint?: string; + browserURL?: string; + transport?: ConnectionTransport; + product?: Product; +} + +/** + * The main Puppeteer class. + * + * IMPORTANT: if you are using Puppeteer in a Node environment, you will get an + * instance of {@link PuppeteerNode} when you import or require `puppeteer`. + * That class extends `Puppeteer`, so has all the methods documented below as + * well as all that are defined on {@link PuppeteerNode}. + * @public + */ +export class Puppeteer { + protected _isPuppeteerCore: boolean; + protected _changedProduct = false; + + /** + * @internal + */ + constructor(settings: CommonPuppeteerSettings) { + this._isPuppeteerCore = settings.isPuppeteerCore; + } + + /** + * This method attaches Puppeteer to an existing browser instance. + * + * @remarks + * + * @param options - Set of configurable options to set on the browser. + * @returns Promise which resolves to browser instance. + */ + connect(options: ConnectOptions): Promise<Browser> { + return connectToBrowser(options); + } + + /** + * @remarks + * A list of devices to be used with `page.emulate(options)`. Actual list of devices can be found in {@link https://github.com/puppeteer/puppeteer/blob/main/src/common/DeviceDescriptors.ts | src/common/DeviceDescriptors.ts}. + * + * @example + * + * ```js + * const puppeteer = require('puppeteer'); + * const iPhone = puppeteer.devices['iPhone 6']; + * + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * await page.emulate(iPhone); + * await page.goto('https://www.google.com'); + * // other actions... + * await browser.close(); + * })(); + * ``` + * + */ + get devices(): DevicesMap { + return devicesMap; + } + + /** + * @remarks + * + * Puppeteer methods might throw errors if they are unable to fulfill a request. + * For example, `page.waitForSelector(selector[, options])` might fail if + * the selector doesn't match any nodes during the given timeframe. + * + * For certain types of errors Puppeteer uses specific error classes. + * These classes are available via `puppeteer.errors`. + * + * @example + * An example of handling a timeout error: + * ```js + * try { + * await page.waitForSelector('.foo'); + * } catch (e) { + * if (e instanceof puppeteer.errors.TimeoutError) { + * // Do something if this is a timeout. + * } + * } + * ``` + */ + get errors(): PuppeteerErrors { + return puppeteerErrors; + } + + /** + * Registers a {@link CustomQueryHandler | custom query handler}. After + * registration, the handler can be used everywhere where a selector is + * expected by prepending the selection string with `<name>/`. The name is + * only allowed to consist of lower- and upper case latin letters. + * @example + * ``` + * puppeteer.registerCustomQueryHandler('text', { … }); + * const aHandle = await page.$('text/…'); + * ``` + * @param name - The name that the custom query handler will be registered under. + * @param queryHandler - The {@link CustomQueryHandler | custom query handler} to + * register. + */ + registerCustomQueryHandler( + name: string, + queryHandler: CustomQueryHandler + ): void { + registerCustomQueryHandler(name, queryHandler); + } + + /** + * @param name - The name of the query handler to unregistered. + */ + unregisterCustomQueryHandler(name: string): void { + unregisterCustomQueryHandler(name); + } + + /** + * @returns a list with the names of all registered custom query handlers. + */ + customQueryHandlerNames(): string[] { + return customQueryHandlerNames(); + } + + /** + * Clears all registered handlers. + */ + clearCustomQueryHandlers(): void { + clearCustomQueryHandlers(); + } +} diff --git a/remote/test/puppeteer/src/common/PuppeteerViewport.ts b/remote/test/puppeteer/src/common/PuppeteerViewport.ts new file mode 100644 index 0000000000..f626ce8d2b --- /dev/null +++ b/remote/test/puppeteer/src/common/PuppeteerViewport.ts @@ -0,0 +1,23 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export interface Viewport { + width: number; + height: number; + deviceScaleFactor?: number; + isMobile?: boolean; + isLandscape?: boolean; + hasTouch?: boolean; +} diff --git a/remote/test/puppeteer/src/common/QueryHandler.ts b/remote/test/puppeteer/src/common/QueryHandler.ts new file mode 100644 index 0000000000..b7984067ee --- /dev/null +++ b/remote/test/puppeteer/src/common/QueryHandler.ts @@ -0,0 +1,238 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { WaitForSelectorOptions, DOMWorld } from './DOMWorld.js'; +import { ElementHandle, JSHandle } from './JSHandle.js'; +import { ariaHandler } from './AriaQueryHandler.js'; + +/** + * @internal + */ +export interface InternalQueryHandler { + queryOne?: ( + element: ElementHandle, + selector: string + ) => Promise<ElementHandle | null>; + waitFor?: ( + domWorld: DOMWorld, + selector: string, + options: WaitForSelectorOptions + ) => Promise<ElementHandle | null>; + queryAll?: ( + element: ElementHandle, + selector: string + ) => Promise<ElementHandle[]>; + queryAllArray?: ( + element: ElementHandle, + selector: string + ) => Promise<JSHandle>; +} + +/** + * Contains two functions `queryOne` and `queryAll` that can + * be {@link Puppeteer.registerCustomQueryHandler | registered} + * as alternative querying strategies. The functions `queryOne` and `queryAll` + * are executed in the page context. `queryOne` should take an `Element` and a + * selector string as argument and return a single `Element` or `null` if no + * element is found. `queryAll` takes the same arguments but should instead + * return a `NodeListOf<Element>` or `Array<Element>` with all the elements + * that match the given query selector. + * @public + */ +export interface CustomQueryHandler { + queryOne?: (element: Element | Document, selector: string) => Element | null; + queryAll?: ( + element: Element | Document, + selector: string + ) => Element[] | NodeListOf<Element>; +} + +function makeQueryHandler(handler: CustomQueryHandler): InternalQueryHandler { + const internalHandler: InternalQueryHandler = {}; + + if (handler.queryOne) { + internalHandler.queryOne = async (element, selector) => { + const jsHandle = await element.evaluateHandle(handler.queryOne, selector); + const elementHandle = jsHandle.asElement(); + if (elementHandle) return elementHandle; + await jsHandle.dispose(); + return null; + }; + internalHandler.waitFor = ( + domWorld: DOMWorld, + selector: string, + options: WaitForSelectorOptions + ) => domWorld.waitForSelectorInPage(handler.queryOne, selector, options); + } + + if (handler.queryAll) { + internalHandler.queryAll = async (element, selector) => { + const jsHandle = await element.evaluateHandle(handler.queryAll, selector); + const properties = await jsHandle.getProperties(); + await jsHandle.dispose(); + const result = []; + for (const property of properties.values()) { + const elementHandle = property.asElement(); + if (elementHandle) result.push(elementHandle); + } + return result; + }; + internalHandler.queryAllArray = async (element, selector) => { + const resultHandle = await element.evaluateHandle( + handler.queryAll, + selector + ); + const arrayHandle = await resultHandle.evaluateHandle( + (res: Element[] | NodeListOf<Element>) => Array.from(res) + ); + return arrayHandle; + }; + } + + return internalHandler; +} + +const _defaultHandler = makeQueryHandler({ + queryOne: (element: Element, selector: string) => + element.querySelector(selector), + queryAll: (element: Element, selector: string) => + element.querySelectorAll(selector), +}); + +const pierceHandler = makeQueryHandler({ + queryOne: (element, selector) => { + let found: Element | null = null; + const search = (root: Element | ShadowRoot) => { + const iter = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT); + do { + const currentNode = iter.currentNode as HTMLElement; + if (currentNode.shadowRoot) { + search(currentNode.shadowRoot); + } + if (currentNode instanceof ShadowRoot) { + continue; + } + if (!found && currentNode.matches(selector)) { + found = currentNode; + } + } while (!found && iter.nextNode()); + }; + if (element instanceof Document) { + element = element.documentElement; + } + search(element); + return found; + }, + + queryAll: (element, selector) => { + const result: Element[] = []; + const collect = (root: Element | ShadowRoot) => { + const iter = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT); + do { + const currentNode = iter.currentNode as HTMLElement; + if (currentNode.shadowRoot) { + collect(currentNode.shadowRoot); + } + if (currentNode instanceof ShadowRoot) { + continue; + } + if (currentNode.matches(selector)) { + result.push(currentNode); + } + } while (iter.nextNode()); + }; + if (element instanceof Document) { + element = element.documentElement; + } + collect(element); + return result; + }, +}); + +const _builtInHandlers = new Map([ + ['aria', ariaHandler], + ['pierce', pierceHandler], +]); +const _queryHandlers = new Map(_builtInHandlers); + +/** + * @internal + */ +export function registerCustomQueryHandler( + name: string, + handler: CustomQueryHandler +): void { + if (_queryHandlers.get(name)) + throw new Error(`A custom query handler named "${name}" already exists`); + + const isValidName = /^[a-zA-Z]+$/.test(name); + if (!isValidName) + throw new Error(`Custom query handler names may only contain [a-zA-Z]`); + + const internalHandler = makeQueryHandler(handler); + + _queryHandlers.set(name, internalHandler); +} + +/** + * @internal + */ +export function unregisterCustomQueryHandler(name: string): void { + if (_queryHandlers.has(name) && !_builtInHandlers.has(name)) { + _queryHandlers.delete(name); + } +} + +/** + * @internal + */ +export function customQueryHandlerNames(): string[] { + return [..._queryHandlers.keys()].filter( + (name) => !_builtInHandlers.has(name) + ); +} + +/** + * @internal + */ +export function clearCustomQueryHandlers(): void { + customQueryHandlerNames().forEach(unregisterCustomQueryHandler); +} + +/** + * @internal + */ +export function getQueryHandlerAndSelector( + selector: string +): { updatedSelector: string; queryHandler: InternalQueryHandler } { + const hasCustomQueryHandler = /^[a-zA-Z]+\//.test(selector); + if (!hasCustomQueryHandler) + return { updatedSelector: selector, queryHandler: _defaultHandler }; + + const index = selector.indexOf('/'); + const name = selector.slice(0, index); + const updatedSelector = selector.slice(index + 1); + const queryHandler = _queryHandlers.get(name); + if (!queryHandler) + throw new Error( + `Query set to use "${name}", but no query handler of that name was found` + ); + + return { + updatedSelector, + queryHandler, + }; +} diff --git a/remote/test/puppeteer/src/common/SecurityDetails.ts b/remote/test/puppeteer/src/common/SecurityDetails.ts new file mode 100644 index 0000000000..aceba1a3d0 --- /dev/null +++ b/remote/test/puppeteer/src/common/SecurityDetails.ts @@ -0,0 +1,88 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Protocol } from 'devtools-protocol'; + +/** + * The SecurityDetails class represents the security details of a + * response that was received over a secure connection. + * + * @public + */ +export class SecurityDetails { + private _subjectName: string; + private _issuer: string; + private _validFrom: number; + private _validTo: number; + private _protocol: string; + private _sanList: string[]; + + /** + * @internal + */ + constructor(securityPayload: Protocol.Network.SecurityDetails) { + this._subjectName = securityPayload.subjectName; + this._issuer = securityPayload.issuer; + this._validFrom = securityPayload.validFrom; + this._validTo = securityPayload.validTo; + this._protocol = securityPayload.protocol; + this._sanList = securityPayload.sanList; + } + + /** + * @returns The name of the issuer of the certificate. + */ + issuer(): string { + return this._issuer; + } + + /** + * @returns {@link https://en.wikipedia.org/wiki/Unix_time | Unix timestamp} + * marking the start of the certificate's validity. + */ + validFrom(): number { + return this._validFrom; + } + + /** + * @returns {@link https://en.wikipedia.org/wiki/Unix_time | Unix timestamp} + * marking the end of the certificate's validity. + */ + validTo(): number { + return this._validTo; + } + + /** + * @returns The security protocol being used, e.g. "TLS 1.2". + */ + protocol(): string { + return this._protocol; + } + + /** + * @returns The name of the subject to which the certificate was issued. + */ + subjectName(): string { + return this._subjectName; + } + + /** + * @returns The list of {@link https://en.wikipedia.org/wiki/Subject_Alternative_Name | subject alternative names (SANs)} of the certificate. + */ + subjectAlternativeNames(): string[] { + return this._sanList; + } +} diff --git a/remote/test/puppeteer/src/common/Target.ts b/remote/test/puppeteer/src/common/Target.ts new file mode 100644 index 0000000000..0e8296a633 --- /dev/null +++ b/remote/test/puppeteer/src/common/Target.ts @@ -0,0 +1,222 @@ +/** + * Copyright 2019 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Page, PageEmittedEvents } from './Page.js'; +import { WebWorker } from './WebWorker.js'; +import { CDPSession } from './Connection.js'; +import { Browser, BrowserContext } from './Browser.js'; +import { Viewport } from './PuppeteerViewport.js'; +import { Protocol } from 'devtools-protocol'; + +/** + * @public + */ +export class Target { + private _targetInfo: Protocol.Target.TargetInfo; + private _browserContext: BrowserContext; + + private _sessionFactory: () => Promise<CDPSession>; + private _ignoreHTTPSErrors: boolean; + private _defaultViewport?: Viewport; + private _pagePromise?: Promise<Page>; + private _workerPromise?: Promise<WebWorker>; + /** + * @internal + */ + _initializedPromise: Promise<boolean>; + /** + * @internal + */ + _initializedCallback: (x: boolean) => void; + /** + * @internal + */ + _isClosedPromise: Promise<void>; + /** + * @internal + */ + _closedCallback: () => void; + /** + * @internal + */ + _isInitialized: boolean; + /** + * @internal + */ + _targetId: string; + + /** + * @internal + */ + constructor( + targetInfo: Protocol.Target.TargetInfo, + browserContext: BrowserContext, + sessionFactory: () => Promise<CDPSession>, + ignoreHTTPSErrors: boolean, + defaultViewport: Viewport | null + ) { + this._targetInfo = targetInfo; + this._browserContext = browserContext; + this._targetId = targetInfo.targetId; + this._sessionFactory = sessionFactory; + this._ignoreHTTPSErrors = ignoreHTTPSErrors; + this._defaultViewport = defaultViewport; + /** @type {?Promise<!Puppeteer.Page>} */ + this._pagePromise = null; + /** @type {?Promise<!WebWorker>} */ + this._workerPromise = null; + this._initializedPromise = new Promise<boolean>( + (fulfill) => (this._initializedCallback = fulfill) + ).then(async (success) => { + if (!success) return false; + const opener = this.opener(); + if (!opener || !opener._pagePromise || this.type() !== 'page') + return true; + const openerPage = await opener._pagePromise; + if (!openerPage.listenerCount(PageEmittedEvents.Popup)) return true; + const popupPage = await this.page(); + openerPage.emit(PageEmittedEvents.Popup, popupPage); + return true; + }); + this._isClosedPromise = new Promise<void>( + (fulfill) => (this._closedCallback = fulfill) + ); + this._isInitialized = + this._targetInfo.type !== 'page' || this._targetInfo.url !== ''; + if (this._isInitialized) this._initializedCallback(true); + } + + /** + * Creates a Chrome Devtools Protocol session attached to the target. + */ + createCDPSession(): Promise<CDPSession> { + return this._sessionFactory(); + } + + /** + * If the target is not of type `"page"` or `"background_page"`, returns `null`. + */ + async page(): Promise<Page | null> { + if ( + (this._targetInfo.type === 'page' || + this._targetInfo.type === 'background_page' || + this._targetInfo.type === 'webview') && + !this._pagePromise + ) { + this._pagePromise = this._sessionFactory().then((client) => + Page.create( + client, + this, + this._ignoreHTTPSErrors, + this._defaultViewport + ) + ); + } + return this._pagePromise; + } + + /** + * If the target is not of type `"service_worker"` or `"shared_worker"`, returns `null`. + */ + async worker(): Promise<WebWorker | null> { + if ( + this._targetInfo.type !== 'service_worker' && + this._targetInfo.type !== 'shared_worker' + ) + return null; + if (!this._workerPromise) { + // TODO(einbinder): Make workers send their console logs. + this._workerPromise = this._sessionFactory().then( + (client) => + new WebWorker( + client, + this._targetInfo.url, + () => {} /* consoleAPICalled */, + () => {} /* exceptionThrown */ + ) + ); + } + return this._workerPromise; + } + + url(): string { + return this._targetInfo.url; + } + + /** + * Identifies what kind of target this is. + * + * @remarks + * + * See {@link https://developer.chrome.com/extensions/background_pages | docs} for more info about background pages. + */ + type(): + | 'page' + | 'background_page' + | 'service_worker' + | 'shared_worker' + | 'other' + | 'browser' + | 'webview' { + const type = this._targetInfo.type; + if ( + type === 'page' || + type === 'background_page' || + type === 'service_worker' || + type === 'shared_worker' || + type === 'browser' || + type === 'webview' + ) + return type; + return 'other'; + } + + /** + * Get the browser the target belongs to. + */ + browser(): Browser { + return this._browserContext.browser(); + } + + browserContext(): BrowserContext { + return this._browserContext; + } + + /** + * Get the target that opened this target. Top-level targets return `null`. + */ + opener(): Target | null { + const { openerId } = this._targetInfo; + if (!openerId) return null; + return this.browser()._targets.get(openerId); + } + + /** + * @internal + */ + _targetInfoChanged(targetInfo: Protocol.Target.TargetInfo): void { + this._targetInfo = targetInfo; + + if ( + !this._isInitialized && + (this._targetInfo.type !== 'page' || this._targetInfo.url !== '') + ) { + this._isInitialized = true; + this._initializedCallback(true); + return; + } + } +} diff --git a/remote/test/puppeteer/src/common/TimeoutSettings.ts b/remote/test/puppeteer/src/common/TimeoutSettings.ts new file mode 100644 index 0000000000..9c441498de --- /dev/null +++ b/remote/test/puppeteer/src/common/TimeoutSettings.ts @@ -0,0 +1,50 @@ +/** + * Copyright 2019 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const DEFAULT_TIMEOUT = 30000; + +/** + * @internal + */ +export class TimeoutSettings { + _defaultTimeout: number | null; + _defaultNavigationTimeout: number | null; + + constructor() { + this._defaultTimeout = null; + this._defaultNavigationTimeout = null; + } + + setDefaultTimeout(timeout: number): void { + this._defaultTimeout = timeout; + } + + setDefaultNavigationTimeout(timeout: number): void { + this._defaultNavigationTimeout = timeout; + } + + navigationTimeout(): number { + if (this._defaultNavigationTimeout !== null) + return this._defaultNavigationTimeout; + if (this._defaultTimeout !== null) return this._defaultTimeout; + return DEFAULT_TIMEOUT; + } + + timeout(): number { + if (this._defaultTimeout !== null) return this._defaultTimeout; + return DEFAULT_TIMEOUT; + } +} diff --git a/remote/test/puppeteer/src/common/Tracing.ts b/remote/test/puppeteer/src/common/Tracing.ts new file mode 100644 index 0000000000..ad075e9bac --- /dev/null +++ b/remote/test/puppeteer/src/common/Tracing.ts @@ -0,0 +1,118 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { assert } from './assert.js'; +import { helper } from './helper.js'; +import { CDPSession } from './Connection.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 + * ```js + * 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 = ''; + + /** + * @internal + */ + constructor(client: CDPSession) { + 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', + 'disabled-by-default-v8.cpu_profiler.hires', + ]; + const { + path = null, + screenshots = false, + categories = defaultCategories, + } = options; + + if (screenshots) categories.push('disabled-by-default-devtools.screenshot'); + + this._path = path; + this._recording = true; + await this._client.send('Tracing.start', { + transferMode: 'ReturnAsStream', + categories: categories.join(','), + }); + } + + /** + * Stops a trace started with the `start` method. + * @returns Promise which resolves to buffer with trace data. + */ + async stop(): Promise<Buffer> { + let fulfill: (value: Buffer) => void; + let reject: (err: Error) => void; + const contentPromise = new Promise<Buffer>((x, y) => { + fulfill = x; + reject = y; + }); + this._client.once('Tracing.tracingComplete', (event) => { + helper + .readProtocolStream(this._client, event.stream, this._path) + .then(fulfill, reject); + }); + await this._client.send('Tracing.end'); + this._recording = false; + return contentPromise; + } +} diff --git a/remote/test/puppeteer/src/common/USKeyboardLayout.ts b/remote/test/puppeteer/src/common/USKeyboardLayout.ts new file mode 100644 index 0000000000..feb3a1f7f1 --- /dev/null +++ b/remote/test/puppeteer/src/common/USKeyboardLayout.ts @@ -0,0 +1,681 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the 'License'); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an 'AS IS' BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @internal + */ +export interface KeyDefinition { + keyCode?: number; + shiftKeyCode?: number; + key?: string; + shiftKey?: string; + code?: string; + text?: string; + shiftText?: string; + location?: number; +} + +/** + * All the valid keys that can be passed to functions that take user input, such + * as {@link Keyboard.press | keyboard.press } + * + * @public + */ +export type KeyInput = + | '0' + | '1' + | '2' + | '3' + | '4' + | '5' + | '6' + | '7' + | '8' + | '9' + | 'Power' + | 'Eject' + | 'Abort' + | 'Help' + | 'Backspace' + | 'Tab' + | 'Numpad5' + | 'NumpadEnter' + | 'Enter' + | '\r' + | '\n' + | 'ShiftLeft' + | 'ShiftRight' + | 'ControlLeft' + | 'ControlRight' + | 'AltLeft' + | 'AltRight' + | 'Pause' + | 'CapsLock' + | 'Escape' + | 'Convert' + | 'NonConvert' + | 'Space' + | 'Numpad9' + | 'PageUp' + | 'Numpad3' + | 'PageDown' + | 'End' + | 'Numpad1' + | 'Home' + | 'Numpad7' + | 'ArrowLeft' + | 'Numpad4' + | 'Numpad8' + | 'ArrowUp' + | 'ArrowRight' + | 'Numpad6' + | 'Numpad2' + | 'ArrowDown' + | 'Select' + | 'Open' + | 'PrintScreen' + | 'Insert' + | 'Numpad0' + | 'Delete' + | 'NumpadDecimal' + | 'Digit0' + | 'Digit1' + | 'Digit2' + | 'Digit3' + | 'Digit4' + | 'Digit5' + | 'Digit6' + | 'Digit7' + | 'Digit8' + | 'Digit9' + | 'KeyA' + | 'KeyB' + | 'KeyC' + | 'KeyD' + | 'KeyE' + | 'KeyF' + | 'KeyG' + | 'KeyH' + | 'KeyI' + | 'KeyJ' + | 'KeyK' + | 'KeyL' + | 'KeyM' + | 'KeyN' + | 'KeyO' + | 'KeyP' + | 'KeyQ' + | 'KeyR' + | 'KeyS' + | 'KeyT' + | 'KeyU' + | 'KeyV' + | 'KeyW' + | 'KeyX' + | 'KeyY' + | 'KeyZ' + | 'MetaLeft' + | 'MetaRight' + | 'ContextMenu' + | 'NumpadMultiply' + | 'NumpadAdd' + | 'NumpadSubtract' + | 'NumpadDivide' + | 'F1' + | 'F2' + | 'F3' + | 'F4' + | 'F5' + | 'F6' + | 'F7' + | 'F8' + | 'F9' + | 'F10' + | 'F11' + | 'F12' + | 'F13' + | 'F14' + | 'F15' + | 'F16' + | 'F17' + | 'F18' + | 'F19' + | 'F20' + | 'F21' + | 'F22' + | 'F23' + | 'F24' + | 'NumLock' + | 'ScrollLock' + | 'AudioVolumeMute' + | 'AudioVolumeDown' + | 'AudioVolumeUp' + | 'MediaTrackNext' + | 'MediaTrackPrevious' + | 'MediaStop' + | 'MediaPlayPause' + | 'Semicolon' + | 'Equal' + | 'NumpadEqual' + | 'Comma' + | 'Minus' + | 'Period' + | 'Slash' + | 'Backquote' + | 'BracketLeft' + | 'Backslash' + | 'BracketRight' + | 'Quote' + | 'AltGraph' + | 'Props' + | 'Cancel' + | 'Clear' + | 'Shift' + | 'Control' + | 'Alt' + | 'Accept' + | 'ModeChange' + | ' ' + | 'Print' + | 'Execute' + | '\u0000' + | 'a' + | 'b' + | 'c' + | 'd' + | 'e' + | 'f' + | 'g' + | 'h' + | 'i' + | 'j' + | 'k' + | 'l' + | 'm' + | 'n' + | 'o' + | 'p' + | 'q' + | 'r' + | 's' + | 't' + | 'u' + | 'v' + | 'w' + | 'x' + | 'y' + | 'z' + | 'Meta' + | '*' + | '+' + | '-' + | '/' + | ';' + | '=' + | ',' + | '.' + | '`' + | '[' + | '\\' + | ']' + | "'" + | 'Attn' + | 'CrSel' + | 'ExSel' + | 'EraseEof' + | 'Play' + | 'ZoomOut' + | ')' + | '!' + | '@' + | '#' + | '$' + | '%' + | '^' + | '&' + | '(' + | 'A' + | 'B' + | 'C' + | 'D' + | 'E' + | 'F' + | 'G' + | 'H' + | 'I' + | 'J' + | 'K' + | 'L' + | 'M' + | 'N' + | 'O' + | 'P' + | 'Q' + | 'R' + | 'S' + | 'T' + | 'U' + | 'V' + | 'W' + | 'X' + | 'Y' + | 'Z' + | ':' + | '<' + | '_' + | '>' + | '?' + | '~' + | '{' + | '|' + | '}' + | '"' + | 'SoftLeft' + | 'SoftRight' + | 'Camera' + | 'Call' + | 'EndCall' + | 'VolumeDown' + | 'VolumeUp'; + +/** + * @internal + */ +export const keyDefinitions: Readonly<Record<KeyInput, KeyDefinition>> = { + '0': { keyCode: 48, key: '0', code: 'Digit0' }, + '1': { keyCode: 49, key: '1', code: 'Digit1' }, + '2': { keyCode: 50, key: '2', code: 'Digit2' }, + '3': { keyCode: 51, key: '3', code: 'Digit3' }, + '4': { keyCode: 52, key: '4', code: 'Digit4' }, + '5': { keyCode: 53, key: '5', code: 'Digit5' }, + '6': { keyCode: 54, key: '6', code: 'Digit6' }, + '7': { keyCode: 55, key: '7', code: 'Digit7' }, + '8': { keyCode: 56, key: '8', code: 'Digit8' }, + '9': { keyCode: 57, key: '9', code: 'Digit9' }, + Power: { key: 'Power', code: 'Power' }, + Eject: { key: 'Eject', code: 'Eject' }, + Abort: { keyCode: 3, code: 'Abort', key: 'Cancel' }, + Help: { keyCode: 6, code: 'Help', key: 'Help' }, + Backspace: { keyCode: 8, code: 'Backspace', key: 'Backspace' }, + Tab: { keyCode: 9, code: 'Tab', key: 'Tab' }, + Numpad5: { + keyCode: 12, + shiftKeyCode: 101, + key: 'Clear', + code: 'Numpad5', + shiftKey: '5', + location: 3, + }, + NumpadEnter: { + keyCode: 13, + code: 'NumpadEnter', + key: 'Enter', + text: '\r', + location: 3, + }, + Enter: { keyCode: 13, code: 'Enter', key: 'Enter', text: '\r' }, + '\r': { keyCode: 13, code: 'Enter', key: 'Enter', text: '\r' }, + '\n': { keyCode: 13, code: 'Enter', key: 'Enter', text: '\r' }, + ShiftLeft: { keyCode: 16, code: 'ShiftLeft', key: 'Shift', location: 1 }, + ShiftRight: { keyCode: 16, code: 'ShiftRight', key: 'Shift', location: 2 }, + ControlLeft: { + keyCode: 17, + code: 'ControlLeft', + key: 'Control', + location: 1, + }, + ControlRight: { + keyCode: 17, + code: 'ControlRight', + key: 'Control', + location: 2, + }, + AltLeft: { keyCode: 18, code: 'AltLeft', key: 'Alt', location: 1 }, + AltRight: { keyCode: 18, code: 'AltRight', key: 'Alt', location: 2 }, + Pause: { keyCode: 19, code: 'Pause', key: 'Pause' }, + CapsLock: { keyCode: 20, code: 'CapsLock', key: 'CapsLock' }, + Escape: { keyCode: 27, code: 'Escape', key: 'Escape' }, + Convert: { keyCode: 28, code: 'Convert', key: 'Convert' }, + NonConvert: { keyCode: 29, code: 'NonConvert', key: 'NonConvert' }, + Space: { keyCode: 32, code: 'Space', key: ' ' }, + Numpad9: { + keyCode: 33, + shiftKeyCode: 105, + key: 'PageUp', + code: 'Numpad9', + shiftKey: '9', + location: 3, + }, + PageUp: { keyCode: 33, code: 'PageUp', key: 'PageUp' }, + Numpad3: { + keyCode: 34, + shiftKeyCode: 99, + key: 'PageDown', + code: 'Numpad3', + shiftKey: '3', + location: 3, + }, + PageDown: { keyCode: 34, code: 'PageDown', key: 'PageDown' }, + End: { keyCode: 35, code: 'End', key: 'End' }, + Numpad1: { + keyCode: 35, + shiftKeyCode: 97, + key: 'End', + code: 'Numpad1', + shiftKey: '1', + location: 3, + }, + Home: { keyCode: 36, code: 'Home', key: 'Home' }, + Numpad7: { + keyCode: 36, + shiftKeyCode: 103, + key: 'Home', + code: 'Numpad7', + shiftKey: '7', + location: 3, + }, + ArrowLeft: { keyCode: 37, code: 'ArrowLeft', key: 'ArrowLeft' }, + Numpad4: { + keyCode: 37, + shiftKeyCode: 100, + key: 'ArrowLeft', + code: 'Numpad4', + shiftKey: '4', + location: 3, + }, + Numpad8: { + keyCode: 38, + shiftKeyCode: 104, + key: 'ArrowUp', + code: 'Numpad8', + shiftKey: '8', + location: 3, + }, + ArrowUp: { keyCode: 38, code: 'ArrowUp', key: 'ArrowUp' }, + ArrowRight: { keyCode: 39, code: 'ArrowRight', key: 'ArrowRight' }, + Numpad6: { + keyCode: 39, + shiftKeyCode: 102, + key: 'ArrowRight', + code: 'Numpad6', + shiftKey: '6', + location: 3, + }, + Numpad2: { + keyCode: 40, + shiftKeyCode: 98, + key: 'ArrowDown', + code: 'Numpad2', + shiftKey: '2', + location: 3, + }, + ArrowDown: { keyCode: 40, code: 'ArrowDown', key: 'ArrowDown' }, + Select: { keyCode: 41, code: 'Select', key: 'Select' }, + Open: { keyCode: 43, code: 'Open', key: 'Execute' }, + PrintScreen: { keyCode: 44, code: 'PrintScreen', key: 'PrintScreen' }, + Insert: { keyCode: 45, code: 'Insert', key: 'Insert' }, + Numpad0: { + keyCode: 45, + shiftKeyCode: 96, + key: 'Insert', + code: 'Numpad0', + shiftKey: '0', + location: 3, + }, + Delete: { keyCode: 46, code: 'Delete', key: 'Delete' }, + NumpadDecimal: { + keyCode: 46, + shiftKeyCode: 110, + code: 'NumpadDecimal', + key: '\u0000', + shiftKey: '.', + location: 3, + }, + Digit0: { keyCode: 48, code: 'Digit0', shiftKey: ')', key: '0' }, + Digit1: { keyCode: 49, code: 'Digit1', shiftKey: '!', key: '1' }, + Digit2: { keyCode: 50, code: 'Digit2', shiftKey: '@', key: '2' }, + Digit3: { keyCode: 51, code: 'Digit3', shiftKey: '#', key: '3' }, + Digit4: { keyCode: 52, code: 'Digit4', shiftKey: '$', key: '4' }, + Digit5: { keyCode: 53, code: 'Digit5', shiftKey: '%', key: '5' }, + Digit6: { keyCode: 54, code: 'Digit6', shiftKey: '^', key: '6' }, + Digit7: { keyCode: 55, code: 'Digit7', shiftKey: '&', key: '7' }, + Digit8: { keyCode: 56, code: 'Digit8', shiftKey: '*', key: '8' }, + Digit9: { keyCode: 57, code: 'Digit9', shiftKey: '(', key: '9' }, + KeyA: { keyCode: 65, code: 'KeyA', shiftKey: 'A', key: 'a' }, + KeyB: { keyCode: 66, code: 'KeyB', shiftKey: 'B', key: 'b' }, + KeyC: { keyCode: 67, code: 'KeyC', shiftKey: 'C', key: 'c' }, + KeyD: { keyCode: 68, code: 'KeyD', shiftKey: 'D', key: 'd' }, + KeyE: { keyCode: 69, code: 'KeyE', shiftKey: 'E', key: 'e' }, + KeyF: { keyCode: 70, code: 'KeyF', shiftKey: 'F', key: 'f' }, + KeyG: { keyCode: 71, code: 'KeyG', shiftKey: 'G', key: 'g' }, + KeyH: { keyCode: 72, code: 'KeyH', shiftKey: 'H', key: 'h' }, + KeyI: { keyCode: 73, code: 'KeyI', shiftKey: 'I', key: 'i' }, + KeyJ: { keyCode: 74, code: 'KeyJ', shiftKey: 'J', key: 'j' }, + KeyK: { keyCode: 75, code: 'KeyK', shiftKey: 'K', key: 'k' }, + KeyL: { keyCode: 76, code: 'KeyL', shiftKey: 'L', key: 'l' }, + KeyM: { keyCode: 77, code: 'KeyM', shiftKey: 'M', key: 'm' }, + KeyN: { keyCode: 78, code: 'KeyN', shiftKey: 'N', key: 'n' }, + KeyO: { keyCode: 79, code: 'KeyO', shiftKey: 'O', key: 'o' }, + KeyP: { keyCode: 80, code: 'KeyP', shiftKey: 'P', key: 'p' }, + KeyQ: { keyCode: 81, code: 'KeyQ', shiftKey: 'Q', key: 'q' }, + KeyR: { keyCode: 82, code: 'KeyR', shiftKey: 'R', key: 'r' }, + KeyS: { keyCode: 83, code: 'KeyS', shiftKey: 'S', key: 's' }, + KeyT: { keyCode: 84, code: 'KeyT', shiftKey: 'T', key: 't' }, + KeyU: { keyCode: 85, code: 'KeyU', shiftKey: 'U', key: 'u' }, + KeyV: { keyCode: 86, code: 'KeyV', shiftKey: 'V', key: 'v' }, + KeyW: { keyCode: 87, code: 'KeyW', shiftKey: 'W', key: 'w' }, + KeyX: { keyCode: 88, code: 'KeyX', shiftKey: 'X', key: 'x' }, + KeyY: { keyCode: 89, code: 'KeyY', shiftKey: 'Y', key: 'y' }, + KeyZ: { keyCode: 90, code: 'KeyZ', shiftKey: 'Z', key: 'z' }, + MetaLeft: { keyCode: 91, code: 'MetaLeft', key: 'Meta', location: 1 }, + MetaRight: { keyCode: 92, code: 'MetaRight', key: 'Meta', location: 2 }, + ContextMenu: { keyCode: 93, code: 'ContextMenu', key: 'ContextMenu' }, + NumpadMultiply: { + keyCode: 106, + code: 'NumpadMultiply', + key: '*', + location: 3, + }, + NumpadAdd: { keyCode: 107, code: 'NumpadAdd', key: '+', location: 3 }, + NumpadSubtract: { + keyCode: 109, + code: 'NumpadSubtract', + key: '-', + location: 3, + }, + NumpadDivide: { keyCode: 111, code: 'NumpadDivide', key: '/', location: 3 }, + F1: { keyCode: 112, code: 'F1', key: 'F1' }, + F2: { keyCode: 113, code: 'F2', key: 'F2' }, + F3: { keyCode: 114, code: 'F3', key: 'F3' }, + F4: { keyCode: 115, code: 'F4', key: 'F4' }, + F5: { keyCode: 116, code: 'F5', key: 'F5' }, + F6: { keyCode: 117, code: 'F6', key: 'F6' }, + F7: { keyCode: 118, code: 'F7', key: 'F7' }, + F8: { keyCode: 119, code: 'F8', key: 'F8' }, + F9: { keyCode: 120, code: 'F9', key: 'F9' }, + F10: { keyCode: 121, code: 'F10', key: 'F10' }, + F11: { keyCode: 122, code: 'F11', key: 'F11' }, + F12: { keyCode: 123, code: 'F12', key: 'F12' }, + F13: { keyCode: 124, code: 'F13', key: 'F13' }, + F14: { keyCode: 125, code: 'F14', key: 'F14' }, + F15: { keyCode: 126, code: 'F15', key: 'F15' }, + F16: { keyCode: 127, code: 'F16', key: 'F16' }, + F17: { keyCode: 128, code: 'F17', key: 'F17' }, + F18: { keyCode: 129, code: 'F18', key: 'F18' }, + F19: { keyCode: 130, code: 'F19', key: 'F19' }, + F20: { keyCode: 131, code: 'F20', key: 'F20' }, + F21: { keyCode: 132, code: 'F21', key: 'F21' }, + F22: { keyCode: 133, code: 'F22', key: 'F22' }, + F23: { keyCode: 134, code: 'F23', key: 'F23' }, + F24: { keyCode: 135, code: 'F24', key: 'F24' }, + NumLock: { keyCode: 144, code: 'NumLock', key: 'NumLock' }, + ScrollLock: { keyCode: 145, code: 'ScrollLock', key: 'ScrollLock' }, + AudioVolumeMute: { + keyCode: 173, + code: 'AudioVolumeMute', + key: 'AudioVolumeMute', + }, + AudioVolumeDown: { + keyCode: 174, + code: 'AudioVolumeDown', + key: 'AudioVolumeDown', + }, + AudioVolumeUp: { keyCode: 175, code: 'AudioVolumeUp', key: 'AudioVolumeUp' }, + MediaTrackNext: { + keyCode: 176, + code: 'MediaTrackNext', + key: 'MediaTrackNext', + }, + MediaTrackPrevious: { + keyCode: 177, + code: 'MediaTrackPrevious', + key: 'MediaTrackPrevious', + }, + MediaStop: { keyCode: 178, code: 'MediaStop', key: 'MediaStop' }, + MediaPlayPause: { + keyCode: 179, + code: 'MediaPlayPause', + key: 'MediaPlayPause', + }, + Semicolon: { keyCode: 186, code: 'Semicolon', shiftKey: ':', key: ';' }, + Equal: { keyCode: 187, code: 'Equal', shiftKey: '+', key: '=' }, + NumpadEqual: { keyCode: 187, code: 'NumpadEqual', key: '=', location: 3 }, + Comma: { keyCode: 188, code: 'Comma', shiftKey: '<', key: ',' }, + Minus: { keyCode: 189, code: 'Minus', shiftKey: '_', key: '-' }, + Period: { keyCode: 190, code: 'Period', shiftKey: '>', key: '.' }, + Slash: { keyCode: 191, code: 'Slash', shiftKey: '?', key: '/' }, + Backquote: { keyCode: 192, code: 'Backquote', shiftKey: '~', key: '`' }, + BracketLeft: { keyCode: 219, code: 'BracketLeft', shiftKey: '{', key: '[' }, + Backslash: { keyCode: 220, code: 'Backslash', shiftKey: '|', key: '\\' }, + BracketRight: { keyCode: 221, code: 'BracketRight', shiftKey: '}', key: ']' }, + Quote: { keyCode: 222, code: 'Quote', shiftKey: '"', key: "'" }, + AltGraph: { keyCode: 225, code: 'AltGraph', key: 'AltGraph' }, + Props: { keyCode: 247, code: 'Props', key: 'CrSel' }, + Cancel: { keyCode: 3, key: 'Cancel', code: 'Abort' }, + Clear: { keyCode: 12, key: 'Clear', code: 'Numpad5', location: 3 }, + Shift: { keyCode: 16, key: 'Shift', code: 'ShiftLeft', location: 1 }, + Control: { keyCode: 17, key: 'Control', code: 'ControlLeft', location: 1 }, + Alt: { keyCode: 18, key: 'Alt', code: 'AltLeft', location: 1 }, + Accept: { keyCode: 30, key: 'Accept' }, + ModeChange: { keyCode: 31, key: 'ModeChange' }, + ' ': { keyCode: 32, key: ' ', code: 'Space' }, + Print: { keyCode: 42, key: 'Print' }, + Execute: { keyCode: 43, key: 'Execute', code: 'Open' }, + '\u0000': { keyCode: 46, key: '\u0000', code: 'NumpadDecimal', location: 3 }, + a: { keyCode: 65, key: 'a', code: 'KeyA' }, + b: { keyCode: 66, key: 'b', code: 'KeyB' }, + c: { keyCode: 67, key: 'c', code: 'KeyC' }, + d: { keyCode: 68, key: 'd', code: 'KeyD' }, + e: { keyCode: 69, key: 'e', code: 'KeyE' }, + f: { keyCode: 70, key: 'f', code: 'KeyF' }, + g: { keyCode: 71, key: 'g', code: 'KeyG' }, + h: { keyCode: 72, key: 'h', code: 'KeyH' }, + i: { keyCode: 73, key: 'i', code: 'KeyI' }, + j: { keyCode: 74, key: 'j', code: 'KeyJ' }, + k: { keyCode: 75, key: 'k', code: 'KeyK' }, + l: { keyCode: 76, key: 'l', code: 'KeyL' }, + m: { keyCode: 77, key: 'm', code: 'KeyM' }, + n: { keyCode: 78, key: 'n', code: 'KeyN' }, + o: { keyCode: 79, key: 'o', code: 'KeyO' }, + p: { keyCode: 80, key: 'p', code: 'KeyP' }, + q: { keyCode: 81, key: 'q', code: 'KeyQ' }, + r: { keyCode: 82, key: 'r', code: 'KeyR' }, + s: { keyCode: 83, key: 's', code: 'KeyS' }, + t: { keyCode: 84, key: 't', code: 'KeyT' }, + u: { keyCode: 85, key: 'u', code: 'KeyU' }, + v: { keyCode: 86, key: 'v', code: 'KeyV' }, + w: { keyCode: 87, key: 'w', code: 'KeyW' }, + x: { keyCode: 88, key: 'x', code: 'KeyX' }, + y: { keyCode: 89, key: 'y', code: 'KeyY' }, + z: { keyCode: 90, key: 'z', code: 'KeyZ' }, + Meta: { keyCode: 91, key: 'Meta', code: 'MetaLeft', location: 1 }, + '*': { keyCode: 106, key: '*', code: 'NumpadMultiply', location: 3 }, + '+': { keyCode: 107, key: '+', code: 'NumpadAdd', location: 3 }, + '-': { keyCode: 109, key: '-', code: 'NumpadSubtract', location: 3 }, + '/': { keyCode: 111, key: '/', code: 'NumpadDivide', location: 3 }, + ';': { keyCode: 186, key: ';', code: 'Semicolon' }, + '=': { keyCode: 187, key: '=', code: 'Equal' }, + ',': { keyCode: 188, key: ',', code: 'Comma' }, + '.': { keyCode: 190, key: '.', code: 'Period' }, + '`': { keyCode: 192, key: '`', code: 'Backquote' }, + '[': { keyCode: 219, key: '[', code: 'BracketLeft' }, + '\\': { keyCode: 220, key: '\\', code: 'Backslash' }, + ']': { keyCode: 221, key: ']', code: 'BracketRight' }, + "'": { keyCode: 222, key: "'", code: 'Quote' }, + Attn: { keyCode: 246, key: 'Attn' }, + CrSel: { keyCode: 247, key: 'CrSel', code: 'Props' }, + ExSel: { keyCode: 248, key: 'ExSel' }, + EraseEof: { keyCode: 249, key: 'EraseEof' }, + Play: { keyCode: 250, key: 'Play' }, + ZoomOut: { keyCode: 251, key: 'ZoomOut' }, + ')': { keyCode: 48, key: ')', code: 'Digit0' }, + '!': { keyCode: 49, key: '!', code: 'Digit1' }, + '@': { keyCode: 50, key: '@', code: 'Digit2' }, + '#': { keyCode: 51, key: '#', code: 'Digit3' }, + $: { keyCode: 52, key: '$', code: 'Digit4' }, + '%': { keyCode: 53, key: '%', code: 'Digit5' }, + '^': { keyCode: 54, key: '^', code: 'Digit6' }, + '&': { keyCode: 55, key: '&', code: 'Digit7' }, + '(': { keyCode: 57, key: '(', code: 'Digit9' }, + A: { keyCode: 65, key: 'A', code: 'KeyA' }, + B: { keyCode: 66, key: 'B', code: 'KeyB' }, + C: { keyCode: 67, key: 'C', code: 'KeyC' }, + D: { keyCode: 68, key: 'D', code: 'KeyD' }, + E: { keyCode: 69, key: 'E', code: 'KeyE' }, + F: { keyCode: 70, key: 'F', code: 'KeyF' }, + G: { keyCode: 71, key: 'G', code: 'KeyG' }, + H: { keyCode: 72, key: 'H', code: 'KeyH' }, + I: { keyCode: 73, key: 'I', code: 'KeyI' }, + J: { keyCode: 74, key: 'J', code: 'KeyJ' }, + K: { keyCode: 75, key: 'K', code: 'KeyK' }, + L: { keyCode: 76, key: 'L', code: 'KeyL' }, + M: { keyCode: 77, key: 'M', code: 'KeyM' }, + N: { keyCode: 78, key: 'N', code: 'KeyN' }, + O: { keyCode: 79, key: 'O', code: 'KeyO' }, + P: { keyCode: 80, key: 'P', code: 'KeyP' }, + Q: { keyCode: 81, key: 'Q', code: 'KeyQ' }, + R: { keyCode: 82, key: 'R', code: 'KeyR' }, + S: { keyCode: 83, key: 'S', code: 'KeyS' }, + T: { keyCode: 84, key: 'T', code: 'KeyT' }, + U: { keyCode: 85, key: 'U', code: 'KeyU' }, + V: { keyCode: 86, key: 'V', code: 'KeyV' }, + W: { keyCode: 87, key: 'W', code: 'KeyW' }, + X: { keyCode: 88, key: 'X', code: 'KeyX' }, + Y: { keyCode: 89, key: 'Y', code: 'KeyY' }, + Z: { keyCode: 90, key: 'Z', code: 'KeyZ' }, + ':': { keyCode: 186, key: ':', code: 'Semicolon' }, + '<': { keyCode: 188, key: '<', code: 'Comma' }, + _: { keyCode: 189, key: '_', code: 'Minus' }, + '>': { keyCode: 190, key: '>', code: 'Period' }, + '?': { keyCode: 191, key: '?', code: 'Slash' }, + '~': { keyCode: 192, key: '~', code: 'Backquote' }, + '{': { keyCode: 219, key: '{', code: 'BracketLeft' }, + '|': { keyCode: 220, key: '|', code: 'Backslash' }, + '}': { keyCode: 221, key: '}', code: 'BracketRight' }, + '"': { keyCode: 222, key: '"', code: 'Quote' }, + SoftLeft: { key: 'SoftLeft', code: 'SoftLeft', location: 4 }, + SoftRight: { key: 'SoftRight', code: 'SoftRight', location: 4 }, + Camera: { keyCode: 44, key: 'Camera', code: 'Camera', location: 4 }, + Call: { key: 'Call', code: 'Call', location: 4 }, + EndCall: { keyCode: 95, key: 'EndCall', code: 'EndCall', location: 4 }, + VolumeDown: { + keyCode: 182, + key: 'VolumeDown', + code: 'VolumeDown', + location: 4, + }, + VolumeUp: { keyCode: 183, key: 'VolumeUp', code: 'VolumeUp', location: 4 }, +}; diff --git a/remote/test/puppeteer/src/common/WebWorker.ts b/remote/test/puppeteer/src/common/WebWorker.ts new file mode 100644 index 0000000000..a4d415315e --- /dev/null +++ b/remote/test/puppeteer/src/common/WebWorker.ts @@ -0,0 +1,172 @@ +/** + * Copyright 2018 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { EventEmitter } from './EventEmitter.js'; +import { debugError } from './helper.js'; +import { ExecutionContext } from './ExecutionContext.js'; +import { JSHandle } from './JSHandle.js'; +import { CDPSession } from './Connection.js'; +import { Protocol } from 'devtools-protocol'; +import { EvaluateHandleFn, SerializableOrJSHandle } from './EvalTypes.js'; + +/** + * @internal + */ +type ConsoleAPICalledCallback = ( + eventType: string, + handles: JSHandle[], + trace: Protocol.Runtime.StackTrace +) => void; + +/** + * @internal + */ +type ExceptionThrownCallback = ( + details: Protocol.Runtime.ExceptionDetails +) => void; +type JSHandleFactory = (obj: Protocol.Runtime.RemoteObject) => JSHandle; + +/** + * The WebWorker class represents a + * {@link https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API | WebWorker}. + * + * @remarks + * The events `workercreated` and `workerdestroyed` are emitted on the page + * object to signal the worker lifecycle. + * + * @example + * ```js + * page.on('workercreated', worker => console.log('Worker created: ' + worker.url())); + * page.on('workerdestroyed', worker => console.log('Worker destroyed: ' + worker.url())); + * + * console.log('Current workers:'); + * for (const worker of page.workers()) { + * console.log(' ' + worker.url()); + * } + * ``` + * + * @public + */ +export class WebWorker extends EventEmitter { + _client: CDPSession; + _url: string; + _executionContextPromise: Promise<ExecutionContext>; + _executionContextCallback: (value: ExecutionContext) => void; + + /** + * + * @internal + */ + constructor( + client: CDPSession, + url: string, + consoleAPICalled: ConsoleAPICalledCallback, + exceptionThrown: ExceptionThrownCallback + ) { + super(); + this._client = client; + this._url = url; + this._executionContextPromise = new Promise<ExecutionContext>( + (x) => (this._executionContextCallback = x) + ); + + let jsHandleFactory: JSHandleFactory; + this._client.once('Runtime.executionContextCreated', async (event) => { + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + jsHandleFactory = (remoteObject) => + new JSHandle(executionContext, client, remoteObject); + const executionContext = new ExecutionContext( + client, + event.context, + null + ); + this._executionContextCallback(executionContext); + }); + + // This might fail if the target is closed before we recieve all execution contexts. + this._client.send('Runtime.enable').catch(debugError); + this._client.on('Runtime.consoleAPICalled', (event) => + consoleAPICalled( + event.type, + event.args.map(jsHandleFactory), + event.stackTrace + ) + ); + this._client.on('Runtime.exceptionThrown', (exception) => + exceptionThrown(exception.exceptionDetails) + ); + } + + /** + * @returns The URL of this web worker. + */ + url(): string { + return this._url; + } + + /** + * Returns the ExecutionContext the WebWorker runs in + * @returns The ExecutionContext the web worker runs in. + */ + async executionContext(): Promise<ExecutionContext> { + return this._executionContextPromise; + } + + /** + * If the function passed to the `worker.evaluate` returns a Promise, then + * `worker.evaluate` would wait for the promise to resolve and return its + * value. If the function passed to the `worker.evaluate` returns a + * non-serializable value, then `worker.evaluate` resolves to `undefined`. + * DevTools Protocol also supports transferring some additional values that + * are not serializable by `JSON`: `-0`, `NaN`, `Infinity`, `-Infinity`, and + * bigint literals. + * Shortcut for `await worker.executionContext()).evaluate(pageFunction, ...args)`. + * + * @param pageFunction - Function to be evaluated in the worker context. + * @param args - Arguments to pass to `pageFunction`. + * @returns Promise which resolves to the return value of `pageFunction`. + */ + async evaluate<ReturnType extends any>( + pageFunction: Function | string, + ...args: any[] + ): Promise<ReturnType> { + return (await this._executionContextPromise).evaluate<ReturnType>( + pageFunction, + ...args + ); + } + + /** + * The only difference between `worker.evaluate` and `worker.evaluateHandle` + * is that `worker.evaluateHandle` returns in-page object (JSHandle). If the + * function passed to the `worker.evaluateHandle` returns a [Promise], then + * `worker.evaluateHandle` would wait for the promise to resolve and return + * its value. Shortcut for + * `await worker.executionContext()).evaluateHandle(pageFunction, ...args)` + * + * @param pageFunction - Function to be evaluated in the page context. + * @param args - Arguments to pass to `pageFunction`. + * @returns Promise which resolves to the return value of `pageFunction`. + */ + async evaluateHandle<HandlerType extends JSHandle = JSHandle>( + pageFunction: EvaluateHandleFn, + ...args: SerializableOrJSHandle[] + ): Promise<JSHandle> { + return (await this._executionContextPromise).evaluateHandle<HandlerType>( + pageFunction, + ...args + ); + } +} diff --git a/remote/test/puppeteer/src/common/assert.ts b/remote/test/puppeteer/src/common/assert.ts new file mode 100644 index 0000000000..6ba090ce26 --- /dev/null +++ b/remote/test/puppeteer/src/common/assert.ts @@ -0,0 +1,24 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Asserts that the given value is truthy. + * @param value + * @param message - the error message to throw if the value is not truthy. + */ +export const assert = (value: unknown, message?: string): void => { + if (!value) throw new Error(message); +}; diff --git a/remote/test/puppeteer/src/common/fetch.ts b/remote/test/puppeteer/src/common/fetch.ts new file mode 100644 index 0000000000..ae4b65c45f --- /dev/null +++ b/remote/test/puppeteer/src/common/fetch.ts @@ -0,0 +1,22 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { isNode } from '../environment.js'; + +/* Use the global version if we're in the browser, else load the node-fetch module. */ +export const getFetch = async (): Promise<typeof fetch> => { + return isNode ? await import('node-fetch') : globalThis.fetch; +}; diff --git a/remote/test/puppeteer/src/common/helper.ts b/remote/test/puppeteer/src/common/helper.ts new file mode 100644 index 0000000000..d8ca9b4ef4 --- /dev/null +++ b/remote/test/puppeteer/src/common/helper.ts @@ -0,0 +1,389 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { TimeoutError } from './Errors.js'; +import { debug } from './Debug.js'; +import { CDPSession } from './Connection.js'; +import { Protocol } from 'devtools-protocol'; +import { CommonEventEmitter } from './EventEmitter.js'; +import { assert } from './assert.js'; +import { isNode } from '../environment.js'; + +export const debugError = debug('puppeteer:error'); + +function getExceptionMessage( + exceptionDetails: Protocol.Runtime.ExceptionDetails +): string { + if (exceptionDetails.exception) + return ( + exceptionDetails.exception.description || exceptionDetails.exception.value + ); + let message = exceptionDetails.text; + if (exceptionDetails.stackTrace) { + for (const callframe of exceptionDetails.stackTrace.callFrames) { + const location = + callframe.url + + ':' + + callframe.lineNumber + + ':' + + callframe.columnNumber; + const functionName = callframe.functionName || '<anonymous>'; + message += `\n at ${functionName} (${location})`; + } + } + return message; +} + +function valueFromRemoteObject( + remoteObject: Protocol.Runtime.RemoteObject +): any { + assert(!remoteObject.objectId, 'Cannot extract value when objectId is given'); + if (remoteObject.unserializableValue) { + if (remoteObject.type === 'bigint' && typeof BigInt !== 'undefined') + 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; +} + +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); + }); +} + +export interface PuppeteerEventListener { + emitter: CommonEventEmitter; + eventName: string | symbol; + handler: (...args: any[]) => void; +} + +function addEventListener( + emitter: CommonEventEmitter, + eventName: string | symbol, + handler: (...args: any[]) => void +): PuppeteerEventListener { + emitter.on(eventName, handler); + return { emitter, eventName, handler }; +} + +function removeEventListeners( + listeners: Array<{ + emitter: CommonEventEmitter; + eventName: string | symbol; + handler: (...args: any[]) => void; + }> +): void { + for (const listener of listeners) + listener.emitter.removeListener(listener.eventName, listener.handler); + listeners.length = 0; +} + +function isString(obj: unknown): obj is string { + return typeof obj === 'string' || obj instanceof String; +} + +function isNumber(obj: unknown): obj is number { + return typeof obj === 'number' || obj instanceof Number; +} + +async function waitForEvent<T extends any>( + emitter: CommonEventEmitter, + eventName: string | symbol, + predicate: (event: T) => boolean, + timeout: number, + abortPromise: Promise<Error> +): Promise<T> { + let eventTimeout, resolveCallback, rejectCallback; + const promise = new Promise<T>((resolve, reject) => { + resolveCallback = resolve; + rejectCallback = reject; + }); + const listener = addEventListener(emitter, eventName, (event) => { + if (!predicate(event)) return; + resolveCallback(event); + }); + if (timeout) { + eventTimeout = setTimeout(() => { + rejectCallback( + new TimeoutError('Timeout exceeded while waiting for event') + ); + }, timeout); + } + function cleanup(): void { + removeEventListeners([listener]); + clearTimeout(eventTimeout); + } + const result = await Promise.race([promise, abortPromise]).then( + (r) => { + cleanup(); + return r; + }, + (error) => { + cleanup(); + throw error; + } + ); + if (result instanceof Error) throw result; + + return result; +} + +function evaluationString(fun: Function | string, ...args: unknown[]): string { + if (isString(fun)) { + assert(args.length === 0, 'Cannot evaluate a string with arguments'); + return fun; + } + + function serializeArgument(arg: unknown): string { + if (Object.is(arg, undefined)) return 'undefined'; + return JSON.stringify(arg); + } + + return `(${fun})(${args.map(serializeArgument).join(',')})`; +} + +function pageBindingInitString(type: string, name: string): string { + function addPageBinding(type: string, bindingName: string): void { + /* Cast window to any here as we're about to add properties to it + * via win[bindingName] which TypeScript doesn't like. + */ + const win = window as any; + const binding = win[bindingName]; + + win[bindingName] = (...args: unknown[]): Promise<unknown> => { + const me = window[bindingName]; + let callbacks = me.callbacks; + if (!callbacks) { + callbacks = new Map(); + me.callbacks = callbacks; + } + const seq = (me.lastSeq || 0) + 1; + me.lastSeq = seq; + const promise = new Promise((resolve, reject) => + callbacks.set(seq, { resolve, reject }) + ); + binding(JSON.stringify({ type, name: bindingName, seq, args })); + return promise; + }; + } + return evaluationString(addPageBinding, type, name); +} + +function pageBindingDeliverResultString( + name: string, + seq: number, + result: unknown +): string { + function deliverResult(name: string, seq: number, result: unknown): void { + window[name].callbacks.get(seq).resolve(result); + window[name].callbacks.delete(seq); + } + return evaluationString(deliverResult, name, seq, result); +} + +function pageBindingDeliverErrorString( + name: string, + seq: number, + message: string, + stack: string +): string { + function deliverError( + name: string, + seq: number, + message: string, + stack: string + ): void { + const error = new Error(message); + error.stack = stack; + window[name].callbacks.get(seq).reject(error); + window[name].callbacks.delete(seq); + } + return evaluationString(deliverError, name, seq, message, stack); +} + +function pageBindingDeliverErrorValueString( + name: string, + seq: number, + value: unknown +): string { + function deliverErrorValue(name: string, seq: number, value: unknown): void { + window[name].callbacks.get(seq).reject(value); + window[name].callbacks.delete(seq); + } + return evaluationString(deliverErrorValue, name, seq, value); +} + +function makePredicateString( + predicate: Function, + predicateQueryHandler?: Function +): string { + function checkWaitForOptions( + node: Node, + waitForVisible: boolean, + waitForHidden: boolean + ): Node | null | boolean { + if (!node) return waitForHidden; + if (!waitForVisible && !waitForHidden) return node; + const element = + node.nodeType === Node.TEXT_NODE ? node.parentElement : (node as Element); + + const style = window.getComputedStyle(element); + const isVisible = + style && style.visibility !== 'hidden' && hasVisibleBoundingBox(); + const success = + waitForVisible === isVisible || waitForHidden === !isVisible; + return success ? node : null; + + function hasVisibleBoundingBox(): boolean { + const rect = element.getBoundingClientRect(); + return !!(rect.top || rect.bottom || rect.width || rect.height); + } + } + const predicateQueryHandlerDef = predicateQueryHandler + ? `const predicateQueryHandler = ${predicateQueryHandler};` + : ''; + return ` + (() => { + ${predicateQueryHandlerDef} + const checkWaitForOptions = ${checkWaitForOptions}; + return (${predicate})(...args) + })() `; +} + +async function waitWithTimeout<T extends any>( + promise: Promise<T>, + taskName: string, + timeout: number +): Promise<T> { + let reject; + const timeoutError = new TimeoutError( + `waiting for ${taskName} failed: timeout ${timeout}ms exceeded` + ); + const timeoutPromise = new Promise<T>((resolve, x) => (reject = x)); + let timeoutTimer = null; + if (timeout) timeoutTimer = setTimeout(() => reject(timeoutError), timeout); + try { + return await Promise.race([promise, timeoutPromise]); + } finally { + if (timeoutTimer) clearTimeout(timeoutTimer); + } +} + +async function readProtocolStream( + client: CDPSession, + handle: string, + path?: string +): Promise<Buffer> { + if (!isNode && path) { + throw new Error('Cannot write to a path outside of Node.js environment.'); + } + + const fs = isNode ? await importFSModule() : null; + + let eof = false; + let fileHandle: import('fs').promises.FileHandle; + + if (path && fs) { + fileHandle = await fs.promises.open(path, 'w'); + } + const bufs = []; + while (!eof) { + const response = await client.send('IO.read', { handle }); + eof = response.eof; + const buf = Buffer.from( + response.data, + response.base64Encoded ? 'base64' : undefined + ); + bufs.push(buf); + if (path && fs) { + await fs.promises.writeFile(fileHandle, buf); + } + } + if (path) await fileHandle.close(); + await client.send('IO.close', { handle }); + let resultBuffer = null; + try { + resultBuffer = Buffer.concat(bufs); + } finally { + return resultBuffer; + } +} + +/** + * Loads the Node fs promises API. Needed because on Node 10.17 and below, + * fs.promises is experimental, and therefore not marked as enumerable. That + * means when TypeScript compiles an `import('fs')`, its helper doesn't spot the + * promises declaration and therefore on Node <10.17 you get an error as + * fs.promises is undefined in compiled TypeScript land. + * + * See https://github.com/puppeteer/puppeteer/issues/6548 for more details. + * + * Once Node 10 is no longer supported (April 2021) we can remove this and use + * `(await import('fs')).promises`. + */ +async function importFSModule(): Promise<typeof import('fs')> { + if (!isNode) { + throw new Error('Cannot load the fs module API outside of Node.'); + } + + const fs = await import('fs'); + if (fs.promises) { + return fs; + } + return fs.default; +} + +export const helper = { + evaluationString, + pageBindingInitString, + pageBindingDeliverResultString, + pageBindingDeliverErrorString, + pageBindingDeliverErrorValueString, + makePredicateString, + readProtocolStream, + waitWithTimeout, + waitForEvent, + isString, + isNumber, + importFSModule, + addEventListener, + removeEventListeners, + valueFromRemoteObject, + getExceptionMessage, + releaseObject, +}; diff --git a/remote/test/puppeteer/src/environment.ts b/remote/test/puppeteer/src/environment.ts new file mode 100644 index 0000000000..f7d869775b --- /dev/null +++ b/remote/test/puppeteer/src/environment.ts @@ -0,0 +1,17 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const isNode = !!(typeof process !== 'undefined' && process.version); diff --git a/remote/test/puppeteer/src/initialize-node.ts b/remote/test/puppeteer/src/initialize-node.ts new file mode 100644 index 0000000000..e533665403 --- /dev/null +++ b/remote/test/puppeteer/src/initialize-node.ts @@ -0,0 +1,43 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { PuppeteerNode } from './node/Puppeteer.js'; +import { PUPPETEER_REVISIONS } from './revisions.js'; +import pkgDir from 'pkg-dir'; +import { Product } from './common/Product.js'; + +export const initializePuppeteerNode = (packageName: string): PuppeteerNode => { + const puppeteerRootDirectory = pkgDir.sync(__dirname); + + let preferredRevision = PUPPETEER_REVISIONS.chromium; + const isPuppeteerCore = packageName === 'puppeteer-core'; + // puppeteer-core ignores environment variables + const productName = isPuppeteerCore + ? undefined + : process.env.PUPPETEER_PRODUCT || + process.env.npm_config_puppeteer_product || + process.env.npm_package_config_puppeteer_product; + + if (!isPuppeteerCore && productName === 'firefox') + preferredRevision = PUPPETEER_REVISIONS.firefox; + + return new PuppeteerNode({ + projectRoot: puppeteerRootDirectory, + preferredRevision, + isPuppeteerCore, + productName: productName as Product, + }); +}; diff --git a/remote/test/puppeteer/src/initialize-web.ts b/remote/test/puppeteer/src/initialize-web.ts new file mode 100644 index 0000000000..4ec42ce3fe --- /dev/null +++ b/remote/test/puppeteer/src/initialize-web.ts @@ -0,0 +1,24 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Puppeteer } from './common/Puppeteer.js'; + +export const initializePuppeteerWeb = (packageName: string): Puppeteer => { + const isPuppeteerCore = packageName === 'puppeteer-core'; + return new Puppeteer({ + isPuppeteerCore, + }); +}; diff --git a/remote/test/puppeteer/src/node-puppeteer-core.ts b/remote/test/puppeteer/src/node-puppeteer-core.ts new file mode 100644 index 0000000000..976dfd5715 --- /dev/null +++ b/remote/test/puppeteer/src/node-puppeteer-core.ts @@ -0,0 +1,25 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { initializePuppeteerNode } from './initialize-node.js'; +import { isNode } from './environment.js'; + +if (!isNode) { + throw new Error('Cannot run puppeteer-core outside of Node.js'); +} + +const puppeteer = initializePuppeteerNode('puppeteer-core'); +export default puppeteer; diff --git a/remote/test/puppeteer/src/node.ts b/remote/test/puppeteer/src/node.ts new file mode 100644 index 0000000000..714f8c2b94 --- /dev/null +++ b/remote/test/puppeteer/src/node.ts @@ -0,0 +1,23 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { initializePuppeteerNode } from './initialize-node.js'; +import { isNode } from './environment.js'; + +if (!isNode) { + throw new Error('Trying to run Puppeteer-Node in a web environment.'); +} +export default initializePuppeteerNode('puppeteer'); diff --git a/remote/test/puppeteer/src/node/BrowserFetcher.ts b/remote/test/puppeteer/src/node/BrowserFetcher.ts new file mode 100644 index 0000000000..4653cd230c --- /dev/null +++ b/remote/test/puppeteer/src/node/BrowserFetcher.ts @@ -0,0 +1,602 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as os from 'os'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as util from 'util'; +import * as childProcess from 'child_process'; +import * as https from 'https'; +import * as http from 'http'; + +import { Product } from '../common/Product.js'; +import extractZip from 'extract-zip'; +import { debug } from '../common/Debug.js'; +import { promisify } from 'util'; +import removeRecursive from 'rimraf'; +import * as URL from 'url'; +import ProxyAgent from 'https-proxy-agent'; +import { getProxyForUrl } from 'proxy-from-env'; +import { assert } from '../common/assert.js'; + +const debugFetcher = debug(`puppeteer:fetcher`); + +const downloadURLs = { + chrome: { + linux: '%s/chromium-browser-snapshots/Linux_x64/%d/%s.zip', + mac: '%s/chromium-browser-snapshots/Mac/%d/%s.zip', + win32: '%s/chromium-browser-snapshots/Win/%d/%s.zip', + win64: '%s/chromium-browser-snapshots/Win_x64/%d/%s.zip', + }, + firefox: { + linux: '%s/firefox-%s.en-US.%s-x86_64.tar.bz2', + mac: '%s/firefox-%s.en-US.%s.dmg', + win32: '%s/firefox-%s.en-US.%s.zip', + win64: '%s/firefox-%s.en-US.%s.zip', + }, +} as const; + +const browserConfig = { + chrome: { + host: 'https://storage.googleapis.com', + destination: '.local-chromium', + }, + firefox: { + host: + 'https://archive.mozilla.org/pub/firefox/nightly/latest-mozilla-central', + destination: '.local-firefox', + }, +} as const; + +/** + * Supported platforms. + * @public + */ +export type Platform = 'linux' | 'mac' | 'win32' | 'win64'; + +function archiveName( + product: Product, + platform: Platform, + revision: string +): string { + if (product === 'chrome') { + if (platform === 'linux') return 'chrome-linux'; + if (platform === 'mac') return 'chrome-mac'; + if (platform === 'win32' || platform === 'win64') { + // Windows archive name changed at r591479. + return parseInt(revision, 10) > 591479 ? 'chrome-win' : 'chrome-win32'; + } + } else if (product === 'firefox') { + return platform; + } +} + +/** + * @internal + */ +function downloadURL( + product: Product, + platform: Platform, + host: string, + revision: string +): string { + const url = util.format( + downloadURLs[product][platform], + host, + revision, + archiveName(product, platform, revision) + ); + return url; +} + +/** + * @internal + */ +function handleArm64(): void { + fs.stat('/usr/bin/chromium-browser', function (err, stats) { + if (stats === undefined) { + console.error(`The chromium binary is not available for arm64: `); + console.error(`If you are on Ubuntu, you can install with: `); + console.error(`\n apt-get install chromium-browser\n`); + throw new Error(); + } + }); +} +const readdirAsync = promisify(fs.readdir.bind(fs)); +const mkdirAsync = promisify(fs.mkdir.bind(fs)); +const unlinkAsync = promisify(fs.unlink.bind(fs)); +const chmodAsync = promisify(fs.chmod.bind(fs)); + +function existsAsync(filePath: string): Promise<boolean> { + return new Promise((resolve) => { + fs.access(filePath, (err) => resolve(!err)); + }); +} + +/** + * @public + */ +export interface BrowserFetcherOptions { + platform?: Platform; + product?: string; + path?: string; + host?: string; +} + +/** + * @public + */ +export interface BrowserFetcherRevisionInfo { + folderPath: string; + executablePath: string; + url: string; + local: boolean; + revision: string; + product: string; +} +/** + * BrowserFetcher can download and manage different versions of Chromium and Firefox. + * + * @remarks + * BrowserFetcher operates on revision strings that specify a precise version of Chromium, e.g. `"533271"`. Revision strings can be obtained from {@link http://omahaproxy.appspot.com/ | omahaproxy.appspot.com}. + * In the Firefox case, BrowserFetcher downloads Firefox Nightly and + * operates on version numbers such as `"75"`. + * + * @example + * An example of using BrowserFetcher to download a specific version of Chromium + * and running Puppeteer against it: + * + * ```js + * const browserFetcher = puppeteer.createBrowserFetcher(); + * const revisionInfo = await browserFetcher.download('533271'); + * const browser = await puppeteer.launch({executablePath: revisionInfo.executablePath}) + * ``` + * + * **NOTE** BrowserFetcher is not designed to work concurrently with other + * instances of BrowserFetcher that share the same downloads directory. + * + * @public + */ + +export class BrowserFetcher { + private _product: Product; + private _downloadsFolder: string; + private _downloadHost: string; + private _platform: Platform; + + /** + * @internal + */ + constructor(projectRoot: string, options: BrowserFetcherOptions = {}) { + this._product = (options.product || 'chrome').toLowerCase() as Product; + assert( + this._product === 'chrome' || this._product === 'firefox', + `Unknown product: "${options.product}"` + ); + + this._downloadsFolder = + options.path || + path.join(projectRoot, browserConfig[this._product].destination); + this._downloadHost = options.host || browserConfig[this._product].host; + this.setPlatform(options.platform); + assert( + downloadURLs[this._product][this._platform], + 'Unsupported platform: ' + this._platform + ); + } + + private setPlatform(platformFromOptions?: Platform): void { + if (platformFromOptions) { + this._platform = platformFromOptions; + return; + } + + const platform = os.platform(); + if (platform === 'darwin') this._platform = 'mac'; + else if (platform === 'linux') this._platform = 'linux'; + else if (platform === 'win32') + this._platform = os.arch() === 'x64' ? 'win64' : 'win32'; + else assert(this._platform, 'Unsupported platform: ' + os.platform()); + } + + /** + * @returns Returns the current `Platform`. + */ + platform(): Platform { + return this._platform; + } + + /** + * @returns Returns the current `Product`. + */ + product(): Product { + return this._product; + } + + /** + * @returns The download host being used. + */ + host(): string { + return this._downloadHost; + } + + /** + * Initiates a HEAD request to check if the revision is available. + * @remarks + * This method is affected by the current `product`. + * @param revision - The revision to check availability for. + * @returns A promise that resolves to `true` if the revision could be downloaded + * from the host. + */ + canDownload(revision: string): Promise<boolean> { + const url = downloadURL( + this._product, + this._platform, + this._downloadHost, + revision + ); + return new Promise((resolve) => { + const request = httpRequest(url, 'HEAD', (response) => { + resolve(response.statusCode === 200); + }); + request.on('error', (error) => { + console.error(error); + resolve(false); + }); + }); + } + + /** + * Initiates a GET request to download the revision from the host. + * @remarks + * This method is affected by the current `product`. + * @param revision - The revision to download. + * @param progressCallback - A function that will be called with two arguments: + * How many bytes have been downloaded and the total number of bytes of the download. + * @returns A promise with revision information when the revision is downloaded + * and extracted. + */ + async download( + revision: string, + progressCallback: (x: number, y: number) => void = (): void => {} + ): Promise<BrowserFetcherRevisionInfo> { + const url = downloadURL( + this._product, + this._platform, + this._downloadHost, + revision + ); + const fileName = url.split('/').pop(); + const archivePath = path.join(this._downloadsFolder, fileName); + const outputPath = this._getFolderPath(revision); + if (await existsAsync(outputPath)) return this.revisionInfo(revision); + if (!(await existsAsync(this._downloadsFolder))) + await mkdirAsync(this._downloadsFolder); + if (os.arch() === 'arm64') { + handleArm64(); + return; + } + try { + await downloadFile(url, archivePath, progressCallback); + await install(archivePath, outputPath); + } finally { + if (await existsAsync(archivePath)) await unlinkAsync(archivePath); + } + const revisionInfo = this.revisionInfo(revision); + if (revisionInfo) await chmodAsync(revisionInfo.executablePath, 0o755); + return revisionInfo; + } + + /** + * @remarks + * This method is affected by the current `product`. + * @returns A promise with a list of all revision strings (for the current `product`) + * available locally on disk. + */ + async localRevisions(): Promise<string[]> { + if (!(await existsAsync(this._downloadsFolder))) return []; + const fileNames = await readdirAsync(this._downloadsFolder); + return fileNames + .map((fileName) => parseFolderPath(this._product, fileName)) + .filter((entry) => entry && entry.platform === this._platform) + .map((entry) => entry.revision); + } + + /** + * @remarks + * This method is affected by the current `product`. + * @param revision - A revision to remove for the current `product`. + * @returns A promise that resolves when the revision has been removes or + * throws if the revision has not been downloaded. + */ + async remove(revision: string): Promise<void> { + const folderPath = this._getFolderPath(revision); + assert( + await existsAsync(folderPath), + `Failed to remove: revision ${revision} is not downloaded` + ); + await new Promise((fulfill) => removeRecursive(folderPath, fulfill)); + } + + /** + * @param revision - The revision to get info for. + * @returns The revision info for the given revision. + */ + revisionInfo(revision: string): BrowserFetcherRevisionInfo { + const folderPath = this._getFolderPath(revision); + let executablePath = ''; + if (this._product === 'chrome') { + if (this._platform === 'mac') + executablePath = path.join( + folderPath, + archiveName(this._product, this._platform, revision), + 'Chromium.app', + 'Contents', + 'MacOS', + 'Chromium' + ); + else if (this._platform === 'linux') + executablePath = path.join( + folderPath, + archiveName(this._product, this._platform, revision), + 'chrome' + ); + else if (this._platform === 'win32' || this._platform === 'win64') + executablePath = path.join( + folderPath, + archiveName(this._product, this._platform, revision), + 'chrome.exe' + ); + else throw new Error('Unsupported platform: ' + this._platform); + } else if (this._product === 'firefox') { + if (this._platform === 'mac') + executablePath = path.join( + folderPath, + 'Firefox Nightly.app', + 'Contents', + 'MacOS', + 'firefox' + ); + else if (this._platform === 'linux') + executablePath = path.join(folderPath, 'firefox', 'firefox'); + else if (this._platform === 'win32' || this._platform === 'win64') + executablePath = path.join(folderPath, 'firefox', 'firefox.exe'); + else throw new Error('Unsupported platform: ' + this._platform); + } else { + throw new Error('Unsupported product: ' + this._product); + } + const url = downloadURL( + this._product, + this._platform, + this._downloadHost, + revision + ); + const local = fs.existsSync(folderPath); + debugFetcher({ + revision, + executablePath, + folderPath, + local, + url, + product: this._product, + }); + return { + revision, + executablePath, + folderPath, + local, + url, + product: this._product, + }; + } + + /** + * @internal + */ + _getFolderPath(revision: string): string { + return path.join(this._downloadsFolder, this._platform + '-' + revision); + } +} + +function parseFolderPath( + product: Product, + folderPath: string +): { product: string; platform: string; revision: string } | null { + const name = path.basename(folderPath); + const splits = name.split('-'); + if (splits.length !== 2) return null; + const [platform, revision] = splits; + if (!downloadURLs[product][platform]) return null; + return { product, platform, revision }; +} + +/** + * @internal + */ +function downloadFile( + url: string, + destinationPath: string, + progressCallback: (x: number, y: number) => void +): Promise<void> { + debugFetcher(`Downloading binary from ${url}`); + let fulfill, reject; + let downloadedBytes = 0; + let totalBytes = 0; + + const promise = new Promise<void>((x, y) => { + fulfill = x; + reject = y; + }); + + const request = httpRequest(url, 'GET', (response) => { + if (response.statusCode !== 200) { + const error = new Error( + `Download failed: server returned code ${response.statusCode}. URL: ${url}` + ); + // consume response data to free up memory + response.resume(); + reject(error); + return; + } + const file = fs.createWriteStream(destinationPath); + file.on('finish', () => fulfill()); + file.on('error', (error) => reject(error)); + response.pipe(file); + totalBytes = parseInt( + /** @type {string} */ response.headers['content-length'], + 10 + ); + if (progressCallback) response.on('data', onData); + }); + request.on('error', (error) => reject(error)); + return promise; + + function onData(chunk: string): void { + downloadedBytes += chunk.length; + progressCallback(downloadedBytes, totalBytes); + } +} + +function install(archivePath: string, folderPath: string): Promise<unknown> { + debugFetcher(`Installing ${archivePath} to ${folderPath}`); + if (archivePath.endsWith('.zip')) + return extractZip(archivePath, { dir: folderPath }); + else if (archivePath.endsWith('.tar.bz2')) + return extractTar(archivePath, folderPath); + else if (archivePath.endsWith('.dmg')) + return mkdirAsync(folderPath).then(() => + installDMG(archivePath, folderPath) + ); + else throw new Error(`Unsupported archive format: ${archivePath}`); +} + +/** + * @internal + */ +function extractTar(tarPath: string, folderPath: string): Promise<unknown> { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const tar = require('tar-fs'); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const bzip = require('unbzip2-stream'); + return new Promise((fulfill, reject) => { + const tarStream = tar.extract(folderPath); + tarStream.on('error', reject); + tarStream.on('finish', fulfill); + const readStream = fs.createReadStream(tarPath); + readStream.pipe(bzip()).pipe(tarStream); + }); +} + +/** + * @internal + */ +function installDMG(dmgPath: string, folderPath: string): Promise<void> { + let mountPath; + + function mountAndCopy(fulfill: () => void, reject: (Error) => void): void { + const mountCommand = `hdiutil attach -nobrowse -noautoopen "${dmgPath}"`; + childProcess.exec(mountCommand, (err, stdout) => { + if (err) return reject(err); + const volumes = stdout.match(/\/Volumes\/(.*)/m); + if (!volumes) + return reject(new Error(`Could not find volume path in ${stdout}`)); + mountPath = volumes[0]; + readdirAsync(mountPath) + .then((fileNames) => { + const appName = fileNames.filter( + (item) => typeof item === 'string' && item.endsWith('.app') + )[0]; + if (!appName) + return reject(new Error(`Cannot find app in ${mountPath}`)); + const copyPath = path.join(mountPath, appName); + debugFetcher(`Copying ${copyPath} to ${folderPath}`); + childProcess.exec(`cp -R "${copyPath}" "${folderPath}"`, (err) => { + if (err) reject(err); + else fulfill(); + }); + }) + .catch(reject); + }); + } + + function unmount(): void { + if (!mountPath) return; + const unmountCommand = `hdiutil detach "${mountPath}" -quiet`; + debugFetcher(`Unmounting ${mountPath}`); + childProcess.exec(unmountCommand, (err) => { + if (err) console.error(`Error unmounting dmg: ${err}`); + }); + } + + return new Promise<void>(mountAndCopy) + .catch((error) => { + console.error(error); + }) + .finally(unmount); +} + +function httpRequest( + url: string, + method: string, + response: (x: http.IncomingMessage) => void +): http.ClientRequest { + const urlParsed = URL.parse(url); + + type Options = Partial<URL.UrlWithStringQuery> & { + method?: string; + agent?: ProxyAgent; + rejectUnauthorized?: boolean; + }; + + let options: Options = { + ...urlParsed, + method, + }; + + const proxyURL = getProxyForUrl(url); + if (proxyURL) { + if (url.startsWith('http:')) { + const proxy = URL.parse(proxyURL); + options = { + path: options.href, + host: proxy.hostname, + port: proxy.port, + }; + } else { + const parsedProxyURL = URL.parse(proxyURL); + + const proxyOptions = { + ...parsedProxyURL, + secureProxy: parsedProxyURL.protocol === 'https:', + } as ProxyAgent.HttpsProxyAgentOptions; + + options.agent = new ProxyAgent(proxyOptions); + options.rejectUnauthorized = false; + } + } + + const requestCallback = (res: http.IncomingMessage): void => { + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) + httpRequest(res.headers.location, method, response); + else response(res); + }; + const request = + options.protocol === 'https:' + ? https.request(options, requestCallback) + : http.request(options, requestCallback); + request.end(); + return request; +} diff --git a/remote/test/puppeteer/src/node/BrowserRunner.ts b/remote/test/puppeteer/src/node/BrowserRunner.ts new file mode 100644 index 0000000000..e7e11bb143 --- /dev/null +++ b/remote/test/puppeteer/src/node/BrowserRunner.ts @@ -0,0 +1,257 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { debug } from '../common/Debug.js'; + +import removeFolder from 'rimraf'; +import * as childProcess from 'child_process'; +import { assert } from '../common/assert.js'; +import { helper, debugError } from '../common/helper.js'; +import { LaunchOptions } from './LaunchOptions.js'; +import { Connection } from '../common/Connection.js'; +import { NodeWebSocketTransport as WebSocketTransport } from '../node/NodeWebSocketTransport.js'; +import { PipeTransport } from './PipeTransport.js'; +import * as readline from 'readline'; +import { TimeoutError } from '../common/Errors.js'; +import { promisify } from 'util'; + +const removeFolderAsync = promisify(removeFolder); +const debugLauncher = debug('puppeteer:launcher'); +const PROCESS_ERROR_EXPLANATION = `Puppeteer was unable to kill the process which ran the browser binary. +This means that, on future Puppeteer launches, Puppeteer might not be able to launch the browser. +Please check your open processes and ensure that the browser processes that Puppeteer launched have been killed. +If you think this is a bug, please report it on the Puppeteer issue tracker.`; + +export class BrowserRunner { + private _executablePath: string; + private _processArguments: string[]; + private _tempDirectory?: string; + + proc = null; + connection = null; + + private _closed = true; + private _listeners = []; + private _processClosing: Promise<void>; + + constructor( + executablePath: string, + processArguments: string[], + tempDirectory?: string + ) { + this._executablePath = executablePath; + this._processArguments = processArguments; + this._tempDirectory = tempDirectory; + } + + start(options: LaunchOptions): void { + const { + handleSIGINT, + handleSIGTERM, + handleSIGHUP, + dumpio, + env, + pipe, + } = options; + let stdio: Array<'ignore' | 'pipe'> = ['pipe', 'pipe', 'pipe']; + if (pipe) { + if (dumpio) stdio = ['ignore', 'pipe', 'pipe', 'pipe', 'pipe']; + else stdio = ['ignore', 'ignore', 'ignore', 'pipe', 'pipe']; + } + assert(!this.proc, 'This process has previously been started.'); + debugLauncher( + `Calling ${this._executablePath} ${this._processArguments.join(' ')}` + ); + this.proc = childProcess.spawn( + this._executablePath, + this._processArguments, + { + // On non-windows platforms, `detached: true` makes child process a + // leader of a new process group, making it possible to kill child + // process tree with `.kill(-pid)` command. @see + // https://nodejs.org/api/child_process.html#child_process_options_detached + detached: process.platform !== 'win32', + env, + stdio, + } + ); + if (dumpio) { + this.proc.stderr.pipe(process.stderr); + this.proc.stdout.pipe(process.stdout); + } + this._closed = false; + this._processClosing = new Promise((fulfill) => { + this.proc.once('exit', () => { + this._closed = true; + // Cleanup as processes exit. + if (this._tempDirectory) { + removeFolderAsync(this._tempDirectory) + .then(() => fulfill()) + .catch((error) => console.error(error)); + } else { + fulfill(); + } + }); + }); + this._listeners = [ + helper.addEventListener(process, 'exit', this.kill.bind(this)), + ]; + if (handleSIGINT) + this._listeners.push( + helper.addEventListener(process, 'SIGINT', () => { + this.kill(); + process.exit(130); + }) + ); + if (handleSIGTERM) + this._listeners.push( + helper.addEventListener(process, 'SIGTERM', this.close.bind(this)) + ); + if (handleSIGHUP) + this._listeners.push( + helper.addEventListener(process, 'SIGHUP', this.close.bind(this)) + ); + } + + close(): Promise<void> { + if (this._closed) return Promise.resolve(); + if (this._tempDirectory) { + this.kill(); + } else if (this.connection) { + // Attempt to close the browser gracefully + this.connection.send('Browser.close').catch((error) => { + debugError(error); + this.kill(); + }); + } + // Cleanup this listener last, as that makes sure the full callback runs. If we + // perform this earlier, then the previous function calls would not happen. + helper.removeEventListeners(this._listeners); + return this._processClosing; + } + + kill(): void { + // Attempt to remove temporary profile directory to avoid littering. + try { + removeFolder.sync(this._tempDirectory); + } catch (error) {} + + // If the process failed to launch (for example if the browser executable path + // is invalid), then the process does not get a pid assigned. A call to + // `proc.kill` would error, as the `pid` to-be-killed can not be found. + if (this.proc && this.proc.pid && !this.proc.killed) { + try { + this.proc.kill('SIGKILL'); + } catch (error) { + throw new Error( + `${PROCESS_ERROR_EXPLANATION}\nError cause: ${error.stack}` + ); + } + } + // Cleanup this listener last, as that makes sure the full callback runs. If we + // perform this earlier, then the previous function calls would not happen. + helper.removeEventListeners(this._listeners); + } + + async setupConnection(options: { + usePipe?: boolean; + timeout: number; + slowMo: number; + preferredRevision: string; + }): Promise<Connection> { + const { usePipe, timeout, slowMo, preferredRevision } = options; + if (!usePipe) { + const browserWSEndpoint = await waitForWSEndpoint( + this.proc, + timeout, + preferredRevision + ); + const transport = await WebSocketTransport.create(browserWSEndpoint); + this.connection = new Connection(browserWSEndpoint, transport, slowMo); + } else { + // stdio was assigned during start(), and the 'pipe' option there adds the + // 4th and 5th items to stdio array + const { 3: pipeWrite, 4: pipeRead } = this.proc.stdio; + const transport = new PipeTransport( + pipeWrite as NodeJS.WritableStream, + pipeRead as NodeJS.ReadableStream + ); + this.connection = new Connection('', transport, slowMo); + } + return this.connection; + } +} + +function waitForWSEndpoint( + browserProcess: childProcess.ChildProcess, + timeout: number, + preferredRevision: string +): Promise<string> { + return new Promise((resolve, reject) => { + const rl = readline.createInterface({ input: browserProcess.stderr }); + let stderr = ''; + const listeners = [ + helper.addEventListener(rl, 'line', onLine), + helper.addEventListener(rl, 'close', () => onClose()), + helper.addEventListener(browserProcess, 'exit', () => onClose()), + helper.addEventListener(browserProcess, 'error', (error) => + onClose(error) + ), + ]; + const timeoutId = timeout ? setTimeout(onTimeout, timeout) : 0; + + /** + * @param {!Error=} error + */ + function onClose(error?: Error): void { + cleanup(); + reject( + new Error( + [ + 'Failed to launch the browser process!' + + (error ? ' ' + error.message : ''), + stderr, + '', + 'TROUBLESHOOTING: https://github.com/puppeteer/puppeteer/blob/main/docs/troubleshooting.md', + '', + ].join('\n') + ) + ); + } + + function onTimeout(): void { + cleanup(); + reject( + new TimeoutError( + `Timed out after ${timeout} ms while trying to connect to the browser! Only Chrome at revision r${preferredRevision} is guaranteed to work.` + ) + ); + } + + function onLine(line: string): void { + stderr += line + '\n'; + const match = line.match(/^DevTools listening on (ws:\/\/.*)$/); + if (!match) return; + cleanup(); + resolve(match[1]); + } + + function cleanup(): void { + if (timeoutId) clearTimeout(timeoutId); + helper.removeEventListeners(listeners); + } + }); +} diff --git a/remote/test/puppeteer/src/node/LaunchOptions.ts b/remote/test/puppeteer/src/node/LaunchOptions.ts new file mode 100644 index 0000000000..0eb99b6980 --- /dev/null +++ b/remote/test/puppeteer/src/node/LaunchOptions.ts @@ -0,0 +1,42 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * Launcher options that only apply to Chrome. + * + * @public + */ +export interface ChromeArgOptions { + headless?: boolean; + args?: string[]; + userDataDir?: string; + devtools?: boolean; +} + +/** + * Generic launch options that can be passed when launching any browser. + * @public + */ +export interface LaunchOptions { + executablePath?: string; + ignoreDefaultArgs?: boolean | string[]; + handleSIGINT?: boolean; + handleSIGTERM?: boolean; + handleSIGHUP?: boolean; + timeout?: number; + dumpio?: boolean; + env?: Record<string, string | undefined>; + pipe?: boolean; +} diff --git a/remote/test/puppeteer/src/node/Launcher.ts b/remote/test/puppeteer/src/node/Launcher.ts new file mode 100644 index 0000000000..8ca62db389 --- /dev/null +++ b/remote/test/puppeteer/src/node/Launcher.ts @@ -0,0 +1,673 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import * as os from 'os'; +import * as path from 'path'; +import * as fs from 'fs'; + +import { BrowserFetcher } from './BrowserFetcher.js'; +import { Browser } from '../common/Browser.js'; +import { BrowserRunner } from './BrowserRunner.js'; +import { promisify } from 'util'; + +const mkdtempAsync = promisify(fs.mkdtemp); +const writeFileAsync = promisify(fs.writeFile); + +import { ChromeArgOptions, LaunchOptions } from './LaunchOptions.js'; +import { BrowserOptions } from '../common/BrowserConnector.js'; +import { Product } from '../common/Product.js'; + +/** + * Describes a launcher - a class that is able to create and launch a browser instance. + * @public + */ +export interface ProductLauncher { + launch(object); + executablePath: () => string; + defaultArgs(object); + product: Product; +} + +/** + * @internal + */ +class ChromeLauncher implements ProductLauncher { + _projectRoot: string; + _preferredRevision: string; + _isPuppeteerCore: boolean; + + constructor( + projectRoot: string, + preferredRevision: string, + isPuppeteerCore: boolean + ) { + this._projectRoot = projectRoot; + this._preferredRevision = preferredRevision; + this._isPuppeteerCore = isPuppeteerCore; + } + + async launch( + options: LaunchOptions & ChromeArgOptions & BrowserOptions = {} + ): Promise<Browser> { + const { + ignoreDefaultArgs = false, + args = [], + dumpio = false, + executablePath = null, + pipe = false, + env = process.env, + handleSIGINT = true, + handleSIGTERM = true, + handleSIGHUP = true, + ignoreHTTPSErrors = false, + defaultViewport = { width: 800, height: 600 }, + slowMo = 0, + timeout = 30000, + } = options; + + const profilePath = path.join(os.tmpdir(), 'puppeteer_dev_chrome_profile-'); + const chromeArguments = []; + if (!ignoreDefaultArgs) chromeArguments.push(...this.defaultArgs(options)); + else if (Array.isArray(ignoreDefaultArgs)) + chromeArguments.push( + ...this.defaultArgs(options).filter( + (arg) => !ignoreDefaultArgs.includes(arg) + ) + ); + else chromeArguments.push(...args); + + let temporaryUserDataDir = null; + + if ( + !chromeArguments.some((argument) => + argument.startsWith('--remote-debugging-') + ) + ) + chromeArguments.push( + pipe ? '--remote-debugging-pipe' : '--remote-debugging-port=0' + ); + if (!chromeArguments.some((arg) => arg.startsWith('--user-data-dir'))) { + temporaryUserDataDir = await mkdtempAsync(profilePath); + chromeArguments.push(`--user-data-dir=${temporaryUserDataDir}`); + } + + let chromeExecutable = executablePath; + if (os.arch() === 'arm64') { + chromeExecutable = '/usr/bin/chromium-browser'; + } else if (!executablePath) { + const { missingText, executablePath } = resolveExecutablePath(this); + if (missingText) throw new Error(missingText); + chromeExecutable = executablePath; + } + + const usePipe = chromeArguments.includes('--remote-debugging-pipe'); + const runner = new BrowserRunner( + chromeExecutable, + chromeArguments, + temporaryUserDataDir + ); + runner.start({ + handleSIGHUP, + handleSIGTERM, + handleSIGINT, + dumpio, + env, + pipe: usePipe, + }); + + try { + const connection = await runner.setupConnection({ + usePipe, + timeout, + slowMo, + preferredRevision: this._preferredRevision, + }); + const browser = await Browser.create( + connection, + [], + ignoreHTTPSErrors, + defaultViewport, + runner.proc, + runner.close.bind(runner) + ); + await browser.waitForTarget((t) => t.type() === 'page'); + return browser; + } catch (error) { + runner.kill(); + throw error; + } + } + + /** + * @param {!Launcher.ChromeArgOptions=} options + * @returns {!Array<string>} + */ + defaultArgs(options: ChromeArgOptions = {}): string[] { + const chromeArguments = [ + '--disable-background-networking', + '--enable-features=NetworkService,NetworkServiceInProcess', + '--disable-background-timer-throttling', + '--disable-backgrounding-occluded-windows', + '--disable-breakpad', + '--disable-client-side-phishing-detection', + '--disable-component-extensions-with-background-pages', + '--disable-default-apps', + '--disable-dev-shm-usage', + '--disable-extensions', + '--disable-features=TranslateUI', + '--disable-hang-monitor', + '--disable-ipc-flooding-protection', + '--disable-popup-blocking', + '--disable-prompt-on-repost', + '--disable-renderer-backgrounding', + '--disable-sync', + '--force-color-profile=srgb', + '--metrics-recording-only', + '--no-first-run', + '--enable-automation', + '--password-store=basic', + '--use-mock-keychain', + // TODO(sadym): remove '--enable-blink-features=IdleDetection' + // once IdleDetection is turned on by default. + '--enable-blink-features=IdleDetection', + ]; + const { + devtools = false, + headless = !devtools, + args = [], + userDataDir = null, + } = options; + if (userDataDir) + chromeArguments.push(`--user-data-dir=${path.resolve(userDataDir)}`); + if (devtools) chromeArguments.push('--auto-open-devtools-for-tabs'); + if (headless) { + chromeArguments.push('--headless', '--hide-scrollbars', '--mute-audio'); + } + if (args.every((arg) => arg.startsWith('-'))) + chromeArguments.push('about:blank'); + chromeArguments.push(...args); + return chromeArguments; + } + + executablePath(): string { + return resolveExecutablePath(this).executablePath; + } + + get product(): Product { + return 'chrome'; + } +} + +/** + * @internal + */ +class FirefoxLauncher implements ProductLauncher { + _projectRoot: string; + _preferredRevision: string; + _isPuppeteerCore: boolean; + + constructor( + projectRoot: string, + preferredRevision: string, + isPuppeteerCore: boolean + ) { + this._projectRoot = projectRoot; + this._preferredRevision = preferredRevision; + this._isPuppeteerCore = isPuppeteerCore; + } + + async launch( + options: LaunchOptions & + ChromeArgOptions & + BrowserOptions & { + extraPrefsFirefox?: { [x: string]: unknown }; + } = {} + ): Promise<Browser> { + const { + ignoreDefaultArgs = false, + args = [], + dumpio = false, + executablePath = null, + pipe = false, + env = process.env, + handleSIGINT = true, + handleSIGTERM = true, + handleSIGHUP = true, + ignoreHTTPSErrors = false, + defaultViewport = { width: 800, height: 600 }, + slowMo = 0, + timeout = 30000, + extraPrefsFirefox = {}, + } = options; + + const firefoxArguments = []; + if (!ignoreDefaultArgs) firefoxArguments.push(...this.defaultArgs(options)); + else if (Array.isArray(ignoreDefaultArgs)) + firefoxArguments.push( + ...this.defaultArgs(options).filter( + (arg) => !ignoreDefaultArgs.includes(arg) + ) + ); + else firefoxArguments.push(...args); + + if ( + !firefoxArguments.some((argument) => + argument.startsWith('--remote-debugging-') + ) + ) + firefoxArguments.push('--remote-debugging-port=0'); + + let temporaryUserDataDir = null; + + if ( + !firefoxArguments.includes('-profile') && + !firefoxArguments.includes('--profile') + ) { + temporaryUserDataDir = await this._createProfile(extraPrefsFirefox); + firefoxArguments.push('--profile'); + firefoxArguments.push(temporaryUserDataDir); + } + + await this._updateRevision(); + let firefoxExecutable = executablePath; + if (!executablePath) { + const { missingText, executablePath } = resolveExecutablePath(this); + if (missingText) throw new Error(missingText); + firefoxExecutable = executablePath; + } + + const runner = new BrowserRunner( + firefoxExecutable, + firefoxArguments, + temporaryUserDataDir + ); + runner.start({ + handleSIGHUP, + handleSIGTERM, + handleSIGINT, + dumpio, + env, + pipe, + }); + + try { + const connection = await runner.setupConnection({ + usePipe: pipe, + timeout, + slowMo, + preferredRevision: this._preferredRevision, + }); + const browser = await Browser.create( + connection, + [], + ignoreHTTPSErrors, + defaultViewport, + runner.proc, + runner.close.bind(runner) + ); + await browser.waitForTarget((t) => t.type() === 'page'); + return browser; + } catch (error) { + runner.kill(); + throw error; + } + } + + executablePath(): string { + return resolveExecutablePath(this).executablePath; + } + + async _updateRevision(): Promise<void> { + // replace 'latest' placeholder with actual downloaded revision + if (this._preferredRevision === 'latest') { + const browserFetcher = new BrowserFetcher(this._projectRoot, { + product: this.product, + }); + const localRevisions = await browserFetcher.localRevisions(); + if (localRevisions[0]) this._preferredRevision = localRevisions[0]; + } + } + + get product(): Product { + return 'firefox'; + } + + defaultArgs(options: ChromeArgOptions = {}): string[] { + const firefoxArguments = ['--no-remote', '--foreground']; + if (os.platform().startsWith('win')) { + firefoxArguments.push('--wait-for-browser'); + } + const { + devtools = false, + headless = !devtools, + args = [], + userDataDir = null, + } = options; + if (userDataDir) { + firefoxArguments.push('--profile'); + firefoxArguments.push(userDataDir); + } + if (headless) firefoxArguments.push('--headless'); + if (devtools) firefoxArguments.push('--devtools'); + if (args.every((arg) => arg.startsWith('-'))) + firefoxArguments.push('about:blank'); + firefoxArguments.push(...args); + return firefoxArguments; + } + + async _createProfile(extraPrefs: { [x: string]: unknown }): Promise<string> { + const profilePath = await mkdtempAsync( + path.join(os.tmpdir(), 'puppeteer_dev_firefox_profile-') + ); + const prefsJS = []; + const userJS = []; + const server = 'dummy.test'; + const defaultPreferences = { + // Make sure Shield doesn't hit the network. + 'app.normandy.api_url': '', + // Disable Firefox old build background check + 'app.update.checkInstallTime': false, + // Disable automatically upgrading Firefox + 'app.update.disabledForTesting': true, + + // Increase the APZ content response timeout to 1 minute + 'apz.content_response_timeout': 60000, + + // Prevent various error message on the console + // jest-puppeteer asserts that no error message is emitted by the console + 'browser.contentblocking.features.standard': + '-tp,tpPrivate,cookieBehavior0,-cm,-fp', + + // Enable the dump function: which sends messages to the system + // console + // https://bugzilla.mozilla.org/show_bug.cgi?id=1543115 + 'browser.dom.window.dump.enabled': true, + // Disable topstories + 'browser.newtabpage.activity-stream.feeds.system.topstories': false, + // Always display a blank page + 'browser.newtabpage.enabled': false, + // Background thumbnails in particular cause grief: and disabling + // thumbnails in general cannot hurt + 'browser.pagethumbnails.capturing_disabled': true, + + // Disable safebrowsing components. + 'browser.safebrowsing.blockedURIs.enabled': false, + 'browser.safebrowsing.downloads.enabled': false, + 'browser.safebrowsing.malware.enabled': false, + 'browser.safebrowsing.passwords.enabled': false, + 'browser.safebrowsing.phishing.enabled': false, + + // Disable updates to search engines. + 'browser.search.update': false, + // Do not restore the last open set of tabs if the browser has crashed + 'browser.sessionstore.resume_from_crash': false, + // Skip check for default browser on startup + 'browser.shell.checkDefaultBrowser': false, + + // Disable newtabpage + 'browser.startup.homepage': 'about:blank', + // Do not redirect user when a milstone upgrade of Firefox is detected + 'browser.startup.homepage_override.mstone': 'ignore', + // Start with a blank page about:blank + 'browser.startup.page': 0, + + // Do not allow background tabs to be zombified on Android: otherwise for + // tests that open additional tabs: the test harness tab itself might get + // unloaded + 'browser.tabs.disableBackgroundZombification': false, + // Do not warn when closing all other open tabs + 'browser.tabs.warnOnCloseOtherTabs': false, + // Do not warn when multiple tabs will be opened + 'browser.tabs.warnOnOpen': false, + + // Disable the UI tour. + 'browser.uitour.enabled': false, + // Turn off search suggestions in the location bar so as not to trigger + // network connections. + 'browser.urlbar.suggest.searches': false, + // Disable first run splash page on Windows 10 + 'browser.usedOnWindows10.introURL': '', + // Do not warn on quitting Firefox + 'browser.warnOnQuit': false, + + // Defensively disable data reporting systems + 'datareporting.healthreport.documentServerURI': `http://${server}/dummy/healthreport/`, + 'datareporting.healthreport.logging.consoleEnabled': false, + 'datareporting.healthreport.service.enabled': false, + 'datareporting.healthreport.service.firstRun': false, + 'datareporting.healthreport.uploadEnabled': false, + + // Do not show datareporting policy notifications which can interfere with tests + 'datareporting.policy.dataSubmissionEnabled': false, + 'datareporting.policy.dataSubmissionPolicyBypassNotification': true, + + // DevTools JSONViewer sometimes fails to load dependencies with its require.js. + // This doesn't affect Puppeteer but spams console (Bug 1424372) + 'devtools.jsonview.enabled': false, + + // Disable popup-blocker + 'dom.disable_open_during_load': false, + + // Enable the support for File object creation in the content process + // Required for |Page.setFileInputFiles| protocol method. + 'dom.file.createInChild': true, + + // Disable the ProcessHangMonitor + 'dom.ipc.reportProcessHangs': false, + + // Disable slow script dialogues + 'dom.max_chrome_script_run_time': 0, + 'dom.max_script_run_time': 0, + + // Only load extensions from the application and user profile + // AddonManager.SCOPE_PROFILE + AddonManager.SCOPE_APPLICATION + 'extensions.autoDisableScopes': 0, + 'extensions.enabledScopes': 5, + + // Disable metadata caching for installed add-ons by default + 'extensions.getAddons.cache.enabled': false, + + // Disable installing any distribution extensions or add-ons. + 'extensions.installDistroAddons': false, + + // Disabled screenshots extension + 'extensions.screenshots.disabled': true, + + // Turn off extension updates so they do not bother tests + 'extensions.update.enabled': false, + + // Turn off extension updates so they do not bother tests + 'extensions.update.notifyUser': false, + + // Make sure opening about:addons will not hit the network + 'extensions.webservice.discoverURL': `http://${server}/dummy/discoveryURL`, + + // Force disable Fission until the Remote Agent is compatible + 'fission.autostart': false, + + // Allow the application to have focus even it runs in the background + 'focusmanager.testmode': true, + // Disable useragent updates + 'general.useragent.updates.enabled': false, + // Always use network provider for geolocation tests so we bypass the + // macOS dialog raised by the corelocation provider + 'geo.provider.testing': true, + // Do not scan Wifi + 'geo.wifi.scan': false, + // No hang monitor + 'hangmonitor.timeout': 0, + // Show chrome errors and warnings in the error console + 'javascript.options.showInConsole': true, + + // Disable download and usage of OpenH264: and Widevine plugins + 'media.gmp-manager.updateEnabled': false, + // Prevent various error message on the console + // jest-puppeteer asserts that no error message is emitted by the console + 'network.cookie.cookieBehavior': 0, + + // Disable experimental feature that is only available in Nightly + 'network.cookie.sameSite.laxByDefault': false, + + // Do not prompt for temporary redirects + 'network.http.prompt-temp-redirect': false, + + // Disable speculative connections so they are not reported as leaking + // when they are hanging around + 'network.http.speculative-parallel-limit': 0, + + // Do not automatically switch between offline and online + 'network.manage-offline-status': false, + + // Make sure SNTP requests do not hit the network + 'network.sntp.pools': server, + + // Disable Flash. + 'plugin.state.flash': 0, + + 'privacy.trackingprotection.enabled': false, + + // Enable Remote Agent + // https://bugzilla.mozilla.org/show_bug.cgi?id=1544393 + 'remote.enabled': true, + + // Don't do network connections for mitm priming + 'security.certerrors.mitm.priming.enabled': false, + // Local documents have access to all other local documents, + // including directory listings + 'security.fileuri.strict_origin_policy': false, + // Do not wait for the notification button security delay + 'security.notification_enable_delay': 0, + + // Ensure blocklist updates do not hit the network + 'services.settings.server': `http://${server}/dummy/blocklist/`, + + // Do not automatically fill sign-in forms with known usernames and + // passwords + 'signon.autofillForms': false, + // Disable password capture, so that tests that include forms are not + // influenced by the presence of the persistent doorhanger notification + 'signon.rememberSignons': false, + + // Disable first-run welcome page + 'startup.homepage_welcome_url': 'about:blank', + + // Disable first-run welcome page + 'startup.homepage_welcome_url.additional': '', + + // Disable browser animations (tabs, fullscreen, sliding alerts) + 'toolkit.cosmeticAnimations.enabled': false, + + // Prevent starting into safe mode after application crashes + 'toolkit.startup.max_resumed_crashes': -1, + }; + + Object.assign(defaultPreferences, extraPrefs); + for (const [key, value] of Object.entries(defaultPreferences)) + userJS.push( + `user_pref(${JSON.stringify(key)}, ${JSON.stringify(value)});` + ); + await writeFileAsync(path.join(profilePath, 'user.js'), userJS.join('\n')); + await writeFileAsync( + path.join(profilePath, 'prefs.js'), + prefsJS.join('\n') + ); + return profilePath; + } +} + +function resolveExecutablePath( + launcher: ChromeLauncher | FirefoxLauncher +): { executablePath: string; missingText?: string } { + let downloadPath: string; + // puppeteer-core doesn't take into account PUPPETEER_* env variables. + if (!launcher._isPuppeteerCore) { + const executablePath = + process.env.PUPPETEER_EXECUTABLE_PATH || + process.env.npm_config_puppeteer_executable_path || + process.env.npm_package_config_puppeteer_executable_path; + if (executablePath) { + const missingText = !fs.existsSync(executablePath) + ? 'Tried to use PUPPETEER_EXECUTABLE_PATH env variable to launch browser but did not find any executable at: ' + + executablePath + : null; + return { executablePath, missingText }; + } + downloadPath = + process.env.PUPPETEER_DOWNLOAD_PATH || + process.env.npm_config_puppeteer_download_path || + process.env.npm_package_config_puppeteer_download_path; + } + const browserFetcher = new BrowserFetcher(launcher._projectRoot, { + product: launcher.product, + path: downloadPath, + }); + if (!launcher._isPuppeteerCore && launcher.product === 'chrome') { + const revision = process.env['PUPPETEER_CHROMIUM_REVISION']; + if (revision) { + const revisionInfo = browserFetcher.revisionInfo(revision); + const missingText = !revisionInfo.local + ? 'Tried to use PUPPETEER_CHROMIUM_REVISION env variable to launch browser but did not find executable at: ' + + revisionInfo.executablePath + : null; + return { executablePath: revisionInfo.executablePath, missingText }; + } + } + const revisionInfo = browserFetcher.revisionInfo(launcher._preferredRevision); + const missingText = !revisionInfo.local + ? `Could not find browser revision ${launcher._preferredRevision}. Run "PUPPETEER_PRODUCT=firefox npm install" or "PUPPETEER_PRODUCT=firefox yarn install" to download a supported Firefox browser binary.` + : null; + return { executablePath: revisionInfo.executablePath, missingText }; +} + +/** + * @internal + */ +export default function Launcher( + projectRoot: string, + preferredRevision: string, + isPuppeteerCore: boolean, + product?: string +): ProductLauncher { + // puppeteer-core doesn't take into account PUPPETEER_* env variables. + if (!product && !isPuppeteerCore) + product = + process.env.PUPPETEER_PRODUCT || + process.env.npm_config_puppeteer_product || + process.env.npm_package_config_puppeteer_product; + switch (product) { + case 'firefox': + return new FirefoxLauncher( + projectRoot, + preferredRevision, + isPuppeteerCore + ); + case 'chrome': + default: + if (typeof product !== 'undefined' && product !== 'chrome') { + /* The user gave us an incorrect product name + * we'll default to launching Chrome, but log to the console + * to let the user know (they've probably typoed). + */ + console.warn( + `Warning: unknown product name ${product}. Falling back to chrome.` + ); + } + return new ChromeLauncher( + projectRoot, + preferredRevision, + isPuppeteerCore + ); + } +} diff --git a/remote/test/puppeteer/src/node/NodeWebSocketTransport.ts b/remote/test/puppeteer/src/node/NodeWebSocketTransport.ts new file mode 100644 index 0000000000..d1077ae7c2 --- /dev/null +++ b/remote/test/puppeteer/src/node/NodeWebSocketTransport.ts @@ -0,0 +1,59 @@ +/** + * Copyright 2018 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { ConnectionTransport } from '../common/ConnectionTransport.js'; +import NodeWebSocket from 'ws'; + +export class NodeWebSocketTransport implements ConnectionTransport { + static create(url: string): Promise<NodeWebSocketTransport> { + return new Promise((resolve, reject) => { + const ws = new NodeWebSocket(url, [], { + perMessageDeflate: false, + maxPayload: 256 * 1024 * 1024, // 256Mb + }); + + ws.addEventListener('open', () => + resolve(new NodeWebSocketTransport(ws)) + ); + ws.addEventListener('error', reject); + }); + } + + private _ws: NodeWebSocket; + onmessage?: (message: string) => void; + onclose?: () => void; + + constructor(ws: NodeWebSocket) { + this._ws = ws; + this._ws.addEventListener('message', (event) => { + if (this.onmessage) this.onmessage.call(null, event.data); + }); + this._ws.addEventListener('close', () => { + if (this.onclose) this.onclose.call(null); + }); + // Silently ignore all errors - we don't know what to do with them. + this._ws.addEventListener('error', () => {}); + this.onmessage = null; + this.onclose = null; + } + + send(message: string): void { + this._ws.send(message); + } + + close(): void { + this._ws.close(); + } +} diff --git a/remote/test/puppeteer/src/node/PipeTransport.ts b/remote/test/puppeteer/src/node/PipeTransport.ts new file mode 100644 index 0000000000..e66c2c0b47 --- /dev/null +++ b/remote/test/puppeteer/src/node/PipeTransport.ts @@ -0,0 +1,80 @@ +/** + * Copyright 2018 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + helper, + debugError, + PuppeteerEventListener, +} from '../common/helper.js'; +import { ConnectionTransport } from '../common/ConnectionTransport.js'; + +export class PipeTransport implements ConnectionTransport { + _pipeWrite: NodeJS.WritableStream; + _pendingMessage: string; + _eventListeners: PuppeteerEventListener[]; + + onclose?: () => void; + onmessage?: () => void; + + constructor( + pipeWrite: NodeJS.WritableStream, + pipeRead: NodeJS.ReadableStream + ) { + this._pipeWrite = pipeWrite; + this._pendingMessage = ''; + this._eventListeners = [ + helper.addEventListener(pipeRead, 'data', (buffer) => + this._dispatch(buffer) + ), + helper.addEventListener(pipeRead, 'close', () => { + if (this.onclose) this.onclose.call(null); + }), + helper.addEventListener(pipeRead, 'error', debugError), + helper.addEventListener(pipeWrite, 'error', debugError), + ]; + this.onmessage = null; + this.onclose = null; + } + + send(message: string): void { + this._pipeWrite.write(message); + this._pipeWrite.write('\0'); + } + + _dispatch(buffer: Buffer): void { + let end = buffer.indexOf('\0'); + if (end === -1) { + this._pendingMessage += buffer.toString(); + return; + } + const message = this._pendingMessage + buffer.toString(undefined, 0, end); + if (this.onmessage) this.onmessage.call(null, message); + + let start = end + 1; + end = buffer.indexOf('\0', start); + while (end !== -1) { + if (this.onmessage) + this.onmessage.call(null, buffer.toString(undefined, start, end)); + start = end + 1; + end = buffer.indexOf('\0', start); + } + this._pendingMessage = buffer.toString(undefined, start); + } + + close(): void { + this._pipeWrite = null; + helper.removeEventListeners(this._eventListeners); + } +} diff --git a/remote/test/puppeteer/src/node/Puppeteer.ts b/remote/test/puppeteer/src/node/Puppeteer.ts new file mode 100644 index 0000000000..924d2fa96e --- /dev/null +++ b/remote/test/puppeteer/src/node/Puppeteer.ts @@ -0,0 +1,230 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + Puppeteer, + CommonPuppeteerSettings, + ConnectOptions, +} from '../common/Puppeteer.js'; +import { BrowserFetcher, BrowserFetcherOptions } from './BrowserFetcher.js'; +import { LaunchOptions, ChromeArgOptions } from './LaunchOptions.js'; +import { BrowserOptions } from '../common/BrowserConnector.js'; +import { Browser } from '../common/Browser.js'; +import Launcher, { ProductLauncher } from './Launcher.js'; +import { PUPPETEER_REVISIONS } from '../revisions.js'; +import { Product } from '../common/Product.js'; + +/** + * Extends the main {@link Puppeteer} class with Node specific behaviour for fetching and + * downloading browsers. + * + * If you're using Puppeteer in a Node environment, this is the class you'll get + * when you run `require('puppeteer')` (or the equivalent ES `import`). + * + * @remarks + * + * The most common method to use is {@link PuppeteerNode.launch | launch}, which + * is used to launch and connect to a new browser instance. + * + * See {@link Puppeteer | the main Puppeteer class} for methods common to all + * environments, such as {@link Puppeteer.connect}. + * + * @example + * The following is a typical example of using Puppeteer to drive automation: + * ```js + * const puppeteer = require('puppeteer'); + * + * (async () => { + * const browser = await puppeteer.launch(); + * const page = await browser.newPage(); + * await page.goto('https://www.google.com'); + * // other actions... + * await browser.close(); + * })(); + * ``` + * + * Once you have created a `page` you have access to a large API to interact + * with the page, navigate, or find certain elements in that page. + * The {@link Page | `page` documentation} lists all the available methods. + * + * @public + */ +export class PuppeteerNode extends Puppeteer { + private _lazyLauncher: ProductLauncher; + private _projectRoot: string; + private __productName?: Product; + /** + * @internal + */ + _preferredRevision: string; + + /** + * @internal + */ + constructor( + settings: { + projectRoot: string; + preferredRevision: string; + productName?: Product; + } & CommonPuppeteerSettings + ) { + const { + projectRoot, + preferredRevision, + productName, + ...commonSettings + } = settings; + super(commonSettings); + this._projectRoot = projectRoot; + this.__productName = productName; + this._preferredRevision = preferredRevision; + } + + /** + * This method attaches Puppeteer to an existing browser instance. + * + * @remarks + * + * @param options - Set of configurable options to set on the browser. + * @returns Promise which resolves to browser instance. + */ + connect(options: ConnectOptions): Promise<Browser> { + if (options.product) this._productName = options.product; + return super.connect(options); + } + + /** + * @internal + */ + get _productName(): Product { + return this.__productName; + } + + // don't need any TSDoc here - because the getter is internal the setter is too. + set _productName(name: Product) { + if (this.__productName !== name) this._changedProduct = true; + this.__productName = name; + } + + /** + * Launches puppeteer and launches a browser instance with given arguments + * and options when specified. + * + * @remarks + * + * @example + * You can use `ignoreDefaultArgs` to filter out `--mute-audio` from default arguments: + * ```js + * const browser = await puppeteer.launch({ + * ignoreDefaultArgs: ['--mute-audio'] + * }); + * ``` + * + * **NOTE** Puppeteer can also be used to control the Chrome browser, + * but it works best with the version of Chromium it is bundled with. + * There is no guarantee it will work with any other version. + * Use `executablePath` option with extreme caution. + * If Google Chrome (rather than Chromium) is preferred, a {@link https://www.google.com/chrome/browser/canary.html | Chrome Canary} or {@link https://www.chromium.org/getting-involved/dev-channel | Dev Channel} build is suggested. + * In `puppeteer.launch([options])`, any mention of Chromium also applies to Chrome. + * See {@link https://www.howtogeek.com/202825/what%E2%80%99s-the-difference-between-chromium-and-chrome/ | this article} for a description of the differences between Chromium and Chrome. {@link https://chromium.googlesource.com/chromium/src/+/lkgr/docs/chromium_browser_vs_google_chrome.md | This article} describes some differences for Linux users. + * + * @param options - Set of configurable options to set on the browser. + * @returns Promise which resolves to browser instance. + */ + launch( + options: LaunchOptions & + ChromeArgOptions & + BrowserOptions & { + product?: Product; + extraPrefsFirefox?: Record<string, unknown>; + } = {} + ): Promise<Browser> { + if (options.product) this._productName = options.product; + return this._launcher.launch(options); + } + + /** + * @remarks + * + * **NOTE** `puppeteer.executablePath()` is affected by the `PUPPETEER_EXECUTABLE_PATH` + * and `PUPPETEER_CHROMIUM_REVISION` environment variables. + * + * @returns A path where Puppeteer expects to find the bundled browser. + * The browser binary might not be there if the download was skipped with + * the `PUPPETEER_SKIP_DOWNLOAD` environment variable. + */ + executablePath(): string { + return this._launcher.executablePath(); + } + + /** + * @internal + */ + get _launcher(): ProductLauncher { + if ( + !this._lazyLauncher || + this._lazyLauncher.product !== this._productName || + this._changedProduct + ) { + switch (this._productName) { + case 'firefox': + this._preferredRevision = PUPPETEER_REVISIONS.firefox; + break; + case 'chrome': + default: + this._preferredRevision = PUPPETEER_REVISIONS.chromium; + } + this._changedProduct = false; + this._lazyLauncher = Launcher( + this._projectRoot, + this._preferredRevision, + this._isPuppeteerCore, + this._productName + ); + } + return this._lazyLauncher; + } + + /** + * The name of the browser that is under automation (`"chrome"` or `"firefox"`) + * + * @remarks + * The product is set by the `PUPPETEER_PRODUCT` environment variable or the `product` + * option in `puppeteer.launch([options])` and defaults to `chrome`. + * Firefox support is experimental. + */ + get product(): string { + return this._launcher.product; + } + + /** + * + * @param options - Set of configurable options to set on the browser. + * @returns The default flags that Chromium will be launched with. + */ + defaultArgs(options: ChromeArgOptions = {}): string[] { + return this._launcher.defaultArgs(options); + } + + /** + * @param options - Set of configurable options to specify the settings + * of the BrowserFetcher. + * @returns A new BrowserFetcher instance. + */ + createBrowserFetcher(options: BrowserFetcherOptions): BrowserFetcher { + return new BrowserFetcher(this._projectRoot, options); + } +} diff --git a/remote/test/puppeteer/src/node/install.ts b/remote/test/puppeteer/src/node/install.ts new file mode 100644 index 0000000000..41f2834d80 --- /dev/null +++ b/remote/test/puppeteer/src/node/install.ts @@ -0,0 +1,185 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import os from 'os'; +import https from 'https'; +import ProgressBar from 'progress'; +import puppeteer from '../node.js'; +import { PUPPETEER_REVISIONS } from '../revisions.js'; +import { PuppeteerNode } from './Puppeteer.js'; + +const supportedProducts = { + chrome: 'Chromium', + firefox: 'Firefox Nightly', +} as const; + +export async function downloadBrowser() { + const downloadHost = + process.env.PUPPETEER_DOWNLOAD_HOST || + process.env.npm_config_puppeteer_download_host || + process.env.npm_package_config_puppeteer_download_host; + const product = + process.env.PUPPETEER_PRODUCT || + process.env.npm_config_puppeteer_product || + process.env.npm_package_config_puppeteer_product || + 'chrome'; + const downloadPath = + process.env.PUPPETEER_DOWNLOAD_PATH || + process.env.npm_config_puppeteer_download_path || + process.env.npm_package_config_puppeteer_download_path; + const browserFetcher = (puppeteer as PuppeteerNode).createBrowserFetcher({ + product, + host: downloadHost, + path: downloadPath, + }); + const revision = await getRevision(); + await fetchBinary(revision); + + function getRevision() { + if (product === 'chrome') { + return ( + process.env.PUPPETEER_CHROMIUM_REVISION || + process.env.npm_config_puppeteer_chromium_revision || + PUPPETEER_REVISIONS.chromium + ); + } else if (product === 'firefox') { + (puppeteer as PuppeteerNode)._preferredRevision = + PUPPETEER_REVISIONS.firefox; + return getFirefoxNightlyVersion().catch((error) => { + console.error(error); + process.exit(1); + }); + } else { + throw new Error(`Unsupported product ${product}`); + } + } + + function fetchBinary(revision) { + const revisionInfo = browserFetcher.revisionInfo(revision); + + // Do nothing if the revision is already downloaded. + if (revisionInfo.local) { + logPolitely( + `${supportedProducts[product]} is already in ${revisionInfo.folderPath}; skipping download.` + ); + return; + } + + // Override current environment proxy settings with npm configuration, if any. + const NPM_HTTPS_PROXY = + process.env.npm_config_https_proxy || process.env.npm_config_proxy; + const NPM_HTTP_PROXY = + process.env.npm_config_http_proxy || process.env.npm_config_proxy; + const NPM_NO_PROXY = process.env.npm_config_no_proxy; + + if (NPM_HTTPS_PROXY) process.env.HTTPS_PROXY = NPM_HTTPS_PROXY; + if (NPM_HTTP_PROXY) process.env.HTTP_PROXY = NPM_HTTP_PROXY; + if (NPM_NO_PROXY) process.env.NO_PROXY = NPM_NO_PROXY; + + function onSuccess(localRevisions: string[]): void { + if (os.arch() !== 'arm64') { + logPolitely( + `${supportedProducts[product]} (${revisionInfo.revision}) downloaded to ${revisionInfo.folderPath}` + ); + } + localRevisions = localRevisions.filter( + (revision) => revision !== revisionInfo.revision + ); + const cleanupOldVersions = localRevisions.map((revision) => + browserFetcher.remove(revision) + ); + Promise.all([...cleanupOldVersions]); + } + + function onError(error: Error) { + console.error( + `ERROR: Failed to set up ${supportedProducts[product]} r${revision}! Set "PUPPETEER_SKIP_DOWNLOAD" env variable to skip download.` + ); + console.error(error); + process.exit(1); + } + + let progressBar = null; + let lastDownloadedBytes = 0; + function onProgress(downloadedBytes, totalBytes) { + if (!progressBar) { + progressBar = new ProgressBar( + `Downloading ${ + supportedProducts[product] + } r${revision} - ${toMegabytes(totalBytes)} [:bar] :percent :etas `, + { + complete: '=', + incomplete: ' ', + width: 20, + total: totalBytes, + } + ); + } + const delta = downloadedBytes - lastDownloadedBytes; + lastDownloadedBytes = downloadedBytes; + progressBar.tick(delta); + } + + return browserFetcher + .download(revisionInfo.revision, onProgress) + .then(() => browserFetcher.localRevisions()) + .then(onSuccess) + .catch(onError); + } + + function toMegabytes(bytes) { + const mb = bytes / 1024 / 1024; + return `${Math.round(mb * 10) / 10} Mb`; + } + + function getFirefoxNightlyVersion() { + const firefoxVersions = + 'https://product-details.mozilla.org/1.0/firefox_versions.json'; + + const promise = new Promise((resolve, reject) => { + let data = ''; + logPolitely( + `Requesting latest Firefox Nightly version from ${firefoxVersions}` + ); + https + .get(firefoxVersions, (r) => { + if (r.statusCode >= 400) + return reject(new Error(`Got status code ${r.statusCode}`)); + r.on('data', (chunk) => { + data += chunk; + }); + r.on('end', () => { + try { + const versions = JSON.parse(data); + return resolve(versions.FIREFOX_NIGHTLY); + } catch { + return reject(new Error('Firefox version not found')); + } + }); + }) + .on('error', reject); + }); + return promise; + } +} + +export function logPolitely(toBeLogged) { + const logLevel = process.env.npm_config_loglevel; + const logLevelDisplay = ['silent', 'error', 'warn'].indexOf(logLevel) > -1; + + // eslint-disable-next-line no-console + if (!logLevelDisplay) console.log(toBeLogged); +} diff --git a/remote/test/puppeteer/src/revisions.ts b/remote/test/puppeteer/src/revisions.ts new file mode 100644 index 0000000000..5e9169adb0 --- /dev/null +++ b/remote/test/puppeteer/src/revisions.ts @@ -0,0 +1,25 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +type Revisions = Readonly<{ + readonly chromium: string; + readonly firefox: string; +}>; + +export const PUPPETEER_REVISIONS: Revisions = { + chromium: '818858', + firefox: 'latest', +}; diff --git a/remote/test/puppeteer/src/tsconfig.cjs.json b/remote/test/puppeteer/src/tsconfig.cjs.json new file mode 100644 index 0000000000..c144b956bf --- /dev/null +++ b/remote/test/puppeteer/src/tsconfig.cjs.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "../lib/cjs/puppeteer", + "module": "CommonJS" + }, + "references": [ + { "path": "../vendor/tsconfig.cjs.json"} + ] +} diff --git a/remote/test/puppeteer/src/tsconfig.esm.json b/remote/test/puppeteer/src/tsconfig.esm.json new file mode 100644 index 0000000000..487533061f --- /dev/null +++ b/remote/test/puppeteer/src/tsconfig.esm.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "../lib/esm/puppeteer", + "module": "esnext" + }, + "references": [ + { "path": "../vendor/tsconfig.esm.json"} + ] +} diff --git a/remote/test/puppeteer/src/web.ts b/remote/test/puppeteer/src/web.ts new file mode 100644 index 0000000000..a48a5cf14d --- /dev/null +++ b/remote/test/puppeteer/src/web.ts @@ -0,0 +1,24 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { initializePuppeteerWeb } from './initialize-web.js'; +import { isNode } from './environment.js'; + +if (isNode) { + throw new Error('Trying to run Puppeteer-Web in a Node environment'); +} + +export default initializePuppeteerWeb('puppeteer'); |