summaryrefslogtreecommitdiffstats
path: root/remote/test/puppeteer/src
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 14:29:10 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 14:29:10 +0000
commit2aa4a82499d4becd2284cdb482213d541b8804dd (patch)
treeb80bf8bf13c3766139fbacc530efd0dd9d54394c /remote/test/puppeteer/src
parentInitial commit. (diff)
downloadfirefox-2aa4a82499d4becd2284cdb482213d541b8804dd.tar.xz
firefox-2aa4a82499d4becd2284cdb482213d541b8804dd.zip
Adding upstream version 86.0.1.upstream/86.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'remote/test/puppeteer/src')
-rw-r--r--remote/test/puppeteer/src/.eslintrc.js19
-rw-r--r--remote/test/puppeteer/src/api-docs-entry.ts65
-rw-r--r--remote/test/puppeteer/src/common/Accessibility.ts502
-rw-r--r--remote/test/puppeteer/src/common/AriaQueryHandler.ts140
-rw-r--r--remote/test/puppeteer/src/common/Browser.ts734
-rw-r--r--remote/test/puppeteer/src/common/BrowserConnector.ts120
-rw-r--r--remote/test/puppeteer/src/common/BrowserWebSocketTransport.ts55
-rw-r--r--remote/test/puppeteer/src/common/Connection.ts347
-rw-r--r--remote/test/puppeteer/src/common/ConnectionTransport.ts22
-rw-r--r--remote/test/puppeteer/src/common/ConsoleMessage.ts122
-rw-r--r--remote/test/puppeteer/src/common/Coverage.ts425
-rw-r--r--remote/test/puppeteer/src/common/DOMWorld.ts940
-rw-r--r--remote/test/puppeteer/src/common/Debug.ts83
-rw-r--r--remote/test/puppeteer/src/common/DeviceDescriptors.ts964
-rw-r--r--remote/test/puppeteer/src/common/Dialog.ts111
-rw-r--r--remote/test/puppeteer/src/common/EmulationManager.ts58
-rw-r--r--remote/test/puppeteer/src/common/Errors.ts41
-rw-r--r--remote/test/puppeteer/src/common/EvalTypes.ts81
-rw-r--r--remote/test/puppeteer/src/common/EventEmitter.ts144
-rw-r--r--remote/test/puppeteer/src/common/Events.ts97
-rw-r--r--remote/test/puppeteer/src/common/ExecutionContext.ts387
-rw-r--r--remote/test/puppeteer/src/common/FileChooser.ts85
-rw-r--r--remote/test/puppeteer/src/common/FrameManager.ts1309
-rw-r--r--remote/test/puppeteer/src/common/HTTPRequest.ts537
-rw-r--r--remote/test/puppeteer/src/common/HTTPResponse.ts213
-rw-r--r--remote/test/puppeteer/src/common/Input.ts525
-rw-r--r--remote/test/puppeteer/src/common/JSHandle.ts982
-rw-r--r--remote/test/puppeteer/src/common/LifecycleWatcher.ts244
-rw-r--r--remote/test/puppeteer/src/common/NetworkManager.ts340
-rw-r--r--remote/test/puppeteer/src/common/PDFOptions.ts179
-rw-r--r--remote/test/puppeteer/src/common/Page.ts2013
-rw-r--r--remote/test/puppeteer/src/common/Product.ts21
-rw-r--r--remote/test/puppeteer/src/common/Puppeteer.ts169
-rw-r--r--remote/test/puppeteer/src/common/PuppeteerViewport.ts23
-rw-r--r--remote/test/puppeteer/src/common/QueryHandler.ts238
-rw-r--r--remote/test/puppeteer/src/common/SecurityDetails.ts88
-rw-r--r--remote/test/puppeteer/src/common/Target.ts222
-rw-r--r--remote/test/puppeteer/src/common/TimeoutSettings.ts50
-rw-r--r--remote/test/puppeteer/src/common/Tracing.ts118
-rw-r--r--remote/test/puppeteer/src/common/USKeyboardLayout.ts681
-rw-r--r--remote/test/puppeteer/src/common/WebWorker.ts172
-rw-r--r--remote/test/puppeteer/src/common/assert.ts24
-rw-r--r--remote/test/puppeteer/src/common/fetch.ts22
-rw-r--r--remote/test/puppeteer/src/common/helper.ts389
-rw-r--r--remote/test/puppeteer/src/environment.ts17
-rw-r--r--remote/test/puppeteer/src/initialize-node.ts43
-rw-r--r--remote/test/puppeteer/src/initialize-web.ts24
-rw-r--r--remote/test/puppeteer/src/node-puppeteer-core.ts25
-rw-r--r--remote/test/puppeteer/src/node.ts23
-rw-r--r--remote/test/puppeteer/src/node/BrowserFetcher.ts602
-rw-r--r--remote/test/puppeteer/src/node/BrowserRunner.ts257
-rw-r--r--remote/test/puppeteer/src/node/LaunchOptions.ts42
-rw-r--r--remote/test/puppeteer/src/node/Launcher.ts673
-rw-r--r--remote/test/puppeteer/src/node/NodeWebSocketTransport.ts59
-rw-r--r--remote/test/puppeteer/src/node/PipeTransport.ts80
-rw-r--r--remote/test/puppeteer/src/node/Puppeteer.ts230
-rw-r--r--remote/test/puppeteer/src/node/install.ts185
-rw-r--r--remote/test/puppeteer/src/revisions.ts25
-rw-r--r--remote/test/puppeteer/src/tsconfig.cjs.json11
-rw-r--r--remote/test/puppeteer/src/tsconfig.esm.json11
-rw-r--r--remote/test/puppeteer/src/web.ts24
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');