summaryrefslogtreecommitdiffstats
path: root/remote/test/puppeteer/src
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 09:22:09 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 09:22:09 +0000
commit43a97878ce14b72f0981164f87f2e35e14151312 (patch)
tree620249daf56c0258faa40cbdcf9cfba06de2a846 /remote/test/puppeteer/src
parentInitial commit. (diff)
downloadfirefox-43a97878ce14b72f0981164f87f2e35e14151312.tar.xz
firefox-43a97878ce14b72f0981164f87f2e35e14151312.zip
Adding upstream version 110.0.1.upstream/110.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/common/Accessibility.ts572
-rw-r--r--remote/test/puppeteer/src/common/AriaQueryHandler.ts189
-rw-r--r--remote/test/puppeteer/src/common/Browser.ts980
-rw-r--r--remote/test/puppeteer/src/common/BrowserConnector.ts159
-rw-r--r--remote/test/puppeteer/src/common/BrowserWebSocketTransport.ts60
-rw-r--r--remote/test/puppeteer/src/common/ChromeTargetManager.ts411
-rw-r--r--remote/test/puppeteer/src/common/Connection.ts447
-rw-r--r--remote/test/puppeteer/src/common/ConnectionTransport.ts25
-rw-r--r--remote/test/puppeteer/src/common/ConsoleMessage.ts123
-rw-r--r--remote/test/puppeteer/src/common/Coverage.ts489
-rw-r--r--remote/test/puppeteer/src/common/Debug.ts109
-rw-r--r--remote/test/puppeteer/src/common/DeviceDescriptors.ts1565
-rw-r--r--remote/test/puppeteer/src/common/Dialog.ts114
-rw-r--r--remote/test/puppeteer/src/common/ElementHandle.ts1039
-rw-r--r--remote/test/puppeteer/src/common/EmulationManager.ts62
-rw-r--r--remote/test/puppeteer/src/common/Errors.ts84
-rw-r--r--remote/test/puppeteer/src/common/EventEmitter.ts149
-rw-r--r--remote/test/puppeteer/src/common/ExecutionContext.ts364
-rw-r--r--remote/test/puppeteer/src/common/FileChooser.ts94
-rw-r--r--remote/test/puppeteer/src/common/FirefoxTargetManager.ts256
-rw-r--r--remote/test/puppeteer/src/common/Frame.ts1097
-rw-r--r--remote/test/puppeteer/src/common/FrameManager.ts519
-rw-r--r--remote/test/puppeteer/src/common/HTTPRequest.ts839
-rw-r--r--remote/test/puppeteer/src/common/HTTPResponse.ts278
-rw-r--r--remote/test/puppeteer/src/common/Input.ts677
-rw-r--r--remote/test/puppeteer/src/common/IsolatedWorld.ts907
-rw-r--r--remote/test/puppeteer/src/common/JSHandle.ts332
-rw-r--r--remote/test/puppeteer/src/common/LifecycleWatcher.ts309
-rw-r--r--remote/test/puppeteer/src/common/NetworkConditions.ts57
-rw-r--r--remote/test/puppeteer/src/common/NetworkEventManager.ts203
-rw-r--r--remote/test/puppeteer/src/common/NetworkManager.ts636
-rw-r--r--remote/test/puppeteer/src/common/PDFOptions.ts202
-rw-r--r--remote/test/puppeteer/src/common/Page.ts3633
-rw-r--r--remote/test/puppeteer/src/common/Product.ts21
-rw-r--r--remote/test/puppeteer/src/common/Puppeteer.ts167
-rw-r--r--remote/test/puppeteer/src/common/PuppeteerViewport.ts51
-rw-r--r--remote/test/puppeteer/src/common/QueryHandler.ts354
-rw-r--r--remote/test/puppeteer/src/common/SecurityDetails.ts88
-rw-r--r--remote/test/puppeteer/src/common/Target.ts276
-rw-r--r--remote/test/puppeteer/src/common/TargetManager.ts71
-rw-r--r--remote/test/puppeteer/src/common/TaskQueue.ts39
-rw-r--r--remote/test/puppeteer/src/common/TimeoutSettings.ts55
-rw-r--r--remote/test/puppeteer/src/common/Tracing.ts143
-rw-r--r--remote/test/puppeteer/src/common/USKeyboardLayout.ts681
-rw-r--r--remote/test/puppeteer/src/common/WebWorker.ts170
-rw-r--r--remote/test/puppeteer/src/common/fetch.ts24
-rw-r--r--remote/test/puppeteer/src/common/types.ts61
-rw-r--r--remote/test/puppeteer/src/common/util.ts488
-rw-r--r--remote/test/puppeteer/src/compat.d.ts3
-rw-r--r--remote/test/puppeteer/src/constants.ts7
-rw-r--r--remote/test/puppeteer/src/environment.ts29
-rw-r--r--remote/test/puppeteer/src/generated/version.ts4
-rw-r--r--remote/test/puppeteer/src/initializePuppeteer.ts46
-rw-r--r--remote/test/puppeteer/src/injected/Poller.ts156
-rw-r--r--remote/test/puppeteer/src/injected/README.md5
-rw-r--r--remote/test/puppeteer/src/injected/injected.ts14
-rw-r--r--remote/test/puppeteer/src/injected/util.ts18
-rw-r--r--remote/test/puppeteer/src/node/BrowserFetcher.ts701
-rw-r--r--remote/test/puppeteer/src/node/BrowserRunner.ts359
-rw-r--r--remote/test/puppeteer/src/node/ChromeLauncher.ts265
-rw-r--r--remote/test/puppeteer/src/node/FirefoxLauncher.ts502
-rw-r--r--remote/test/puppeteer/src/node/LaunchOptions.ts144
-rw-r--r--remote/test/puppeteer/src/node/NodeWebSocketTransport.ts69
-rw-r--r--remote/test/puppeteer/src/node/PipeTransport.ts93
-rw-r--r--remote/test/puppeteer/src/node/ProductLauncher.ts217
-rw-r--r--remote/test/puppeteer/src/node/Puppeteer.ts244
-rw-r--r--remote/test/puppeteer/src/node/install.ts232
-rw-r--r--remote/test/puppeteer/src/node/util.ts13
-rw-r--r--remote/test/puppeteer/src/puppeteer-core.ts34
-rw-r--r--remote/test/puppeteer/src/puppeteer.ts39
-rw-r--r--remote/test/puppeteer/src/revisions.ts23
-rw-r--r--remote/test/puppeteer/src/templates/README.md3
-rw-r--r--remote/test/puppeteer/src/templates/injected.ts.tmpl10
-rw-r--r--remote/test/puppeteer/src/templates/version.ts.tmpl4
-rw-r--r--remote/test/puppeteer/src/tsconfig.cjs.json13
-rw-r--r--remote/test/puppeteer/src/tsconfig.esm.json13
-rw-r--r--remote/test/puppeteer/src/types.ts74
-rw-r--r--remote/test/puppeteer/src/util/DebuggableDeferredPromise.ts20
-rw-r--r--remote/test/puppeteer/src/util/DeferredPromise.ts68
-rw-r--r--remote/test/puppeteer/src/util/ErrorLike.ts27
-rw-r--r--remote/test/puppeteer/src/util/assert.ts31
-rw-r--r--remote/test/puppeteer/src/util/getPackageDirectory.ts18
82 files changed, 23167 insertions, 0 deletions
diff --git a/remote/test/puppeteer/src/common/Accessibility.ts b/remote/test/puppeteer/src/common/Accessibility.ts
new file mode 100644
index 0000000000..ea540c01d7
--- /dev/null
+++ b/remote/test/puppeteer/src/common/Accessibility.ts
@@ -0,0 +1,572 @@
+/**
+ * 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 {Protocol} from 'devtools-protocol';
+import {CDPSession} from './Connection.js';
+import {ElementHandle} from './ElementHandle.js';
+
+/**
+ * Represents a Node and the properties of it that are relevant to Accessibility.
+ * @public
+ */
+export interface SerializedAXNode {
+ /**
+ * The {@link https://www.w3.org/TR/wai-aria/#usage_intro | role} of the node.
+ */
+ role: string;
+ /**
+ * A human readable name for the node.
+ */
+ name?: string;
+ /**
+ * The current value of the node.
+ */
+ value?: string | number;
+ /**
+ * An additional human readable description of the node.
+ */
+ description?: string;
+ /**
+ * Any keyboard shortcuts associated with this node.
+ */
+ keyshortcuts?: string;
+ /**
+ * A human readable alternative to the role.
+ */
+ roledescription?: string;
+ /**
+ * A description of the current value.
+ */
+ valuetext?: string;
+ disabled?: boolean;
+ expanded?: boolean;
+ focused?: boolean;
+ modal?: boolean;
+ multiline?: boolean;
+ /**
+ * Whether more than one child can be selected.
+ */
+ multiselectable?: boolean;
+ readonly?: boolean;
+ required?: boolean;
+ selected?: boolean;
+ /**
+ * Whether the checkbox is checked, or in a
+ * {@link https://www.w3.org/TR/wai-aria-practices/examples/checkbox/checkbox-2/checkbox-2.html | mixed state}.
+ */
+ checked?: boolean | 'mixed';
+ /**
+ * Whether the node is checked or in a mixed state.
+ */
+ pressed?: boolean | 'mixed';
+ /**
+ * The level of a heading.
+ */
+ level?: number;
+ valuemin?: number;
+ valuemax?: number;
+ autocomplete?: string;
+ haspopup?: string;
+ /**
+ * Whether and in what way this node's value is invalid.
+ */
+ invalid?: string;
+ orientation?: string;
+ /**
+ * Children of this node, if there are any.
+ */
+ children?: SerializedAXNode[];
+}
+
+/**
+ * @public
+ */
+export interface SnapshotOptions {
+ /**
+ * Prune uninteresting nodes from the tree.
+ * @defaultValue true
+ */
+ interestingOnly?: boolean;
+ /**
+ * Root node to get the accessibility tree for
+ * @defaultValue The root node of the entire page.
+ */
+ root?: ElementHandle<Node>;
+}
+
+/**
+ * The Accessibility class provides methods for inspecting 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 {
+ #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:
+ *
+ * ```ts
+ * const snapshot = await page.accessibility.snapshot();
+ * console.log(snapshot);
+ * ```
+ *
+ * @example
+ * An example of logging the focused node's name:
+ *
+ * ```ts
+ * const snapshot = await page.accessibility.snapshot();
+ * const node = findFocusedNode(snapshot);
+ * console.log(node && node.name);
+ *
+ * function findFocusedNode(node) {
+ * if (node.focused) return node;
+ * for (const child of node.children || []) {
+ * const foundNode = findFocusedNode(child);
+ * return foundNode;
+ * }
+ * return null;
+ * }
+ * ```
+ *
+ * @returns An AXNode object representing the snapshot.
+ */
+ public async snapshot(
+ options: SnapshotOptions = {}
+ ): Promise<SerializedAXNode | null> {
+ const {interestingOnly = true, root = null} = options;
+ const {nodes} = await this.#client.send('Accessibility.getFullAXTree');
+ let backendNodeId: number | undefined;
+ if (root) {
+ const {node} = await this.#client.send('DOM.describeNode', {
+ objectId: root.remoteObject().objectId,
+ });
+ backendNodeId = node.backendNodeId;
+ }
+ const defaultRoot = AXNode.createTree(nodes);
+ let needle: AXNode | null = defaultRoot;
+ if (backendNodeId) {
+ needle = defaultRoot.find(node => {
+ return node.payload.backendDOMNodeId === backendNodeId;
+ });
+ if (!needle) {
+ return null;
+ }
+ }
+ if (!interestingOnly) {
+ return this.serializeTree(needle)[0] ?? null;
+ }
+
+ const interestingNodes = new Set<AXNode>();
+ this.collectInterestingNodes(interestingNodes, defaultRoot, false);
+ if (!interestingNodes.has(needle)) {
+ return null;
+ }
+ return this.serializeTree(needle, interestingNodes)[0] ?? null;
+ }
+
+ private serializeTree(
+ node: AXNode,
+ interestingNodes?: Set<AXNode>
+ ): SerializedAXNode[] {
+ const children: SerializedAXNode[] = [];
+ for (const child of node.children) {
+ children.push(...this.serializeTree(child, interestingNodes));
+ }
+
+ if (interestingNodes && !interestingNodes.has(node)) {
+ return children;
+ }
+
+ const serializedNode = node.serialize();
+ if (children.length) {
+ serializedNode.children = children;
+ }
+ return [serializedNode];
+ }
+
+ private collectInterestingNodes(
+ collection: Set<AXNode>,
+ node: AXNode,
+ insideControl: boolean
+ ): void {
+ if (node.isInteresting(insideControl)) {
+ collection.add(node);
+ }
+ if (node.isLeafNode()) {
+ return;
+ }
+ insideControl = insideControl || node.isControl();
+ for (const child of node.children) {
+ this.collectInterestingNodes(collection, child, insideControl);
+ }
+ }
+}
+
+class AXNode {
+ public payload: Protocol.Accessibility.AXNode;
+ public children: AXNode[] = [];
+
+ #richlyEditable = false;
+ #editable = false;
+ #focusable = false;
+ #hidden = false;
+ #name: string;
+ #role: string;
+ #ignored: boolean;
+ #cachedHasFocusableChild?: boolean;
+
+ constructor(payload: Protocol.Accessibility.AXNode) {
+ this.payload = payload;
+ this.#name = this.payload.name ? this.payload.name.value : '';
+ this.#role = this.payload.role ? this.payload.role.value : 'Unknown';
+ this.#ignored = this.payload.ignored;
+
+ for (const property of this.payload.properties || []) {
+ if (property.name === 'editable') {
+ this.#richlyEditable = property.value.value === 'richtext';
+ this.#editable = true;
+ }
+ if (property.name === 'focusable') {
+ this.#focusable = property.value.value;
+ }
+ if (property.name === 'hidden') {
+ this.#hidden = property.value.value;
+ }
+ }
+ }
+
+ #isPlainTextField(): boolean {
+ if (this.#richlyEditable) {
+ return false;
+ }
+ if (this.#editable) {
+ return true;
+ }
+ return this.#role === 'textbox' || this.#role === 'searchbox';
+ }
+
+ #isTextOnlyObject(): boolean {
+ const role = this.#role;
+ return role === 'LineBreak' || role === 'text' || role === 'InlineTextBox';
+ }
+
+ #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 => {
+ return properties.get(key) as string;
+ };
+
+ for (const userStringProperty of userStringProperties) {
+ if (!properties.has(userStringProperty)) {
+ continue;
+ }
+
+ node[userStringProperty] = getUserStringPropertyValue(userStringProperty);
+ }
+
+ type BooleanProperty =
+ | 'disabled'
+ | 'expanded'
+ | 'focused'
+ | 'modal'
+ | 'multiline'
+ | 'multiselectable'
+ | 'readonly'
+ | 'required'
+ | 'selected';
+ const booleanProperties: BooleanProperty[] = [
+ 'disabled',
+ 'expanded',
+ 'focused',
+ 'modal',
+ 'multiline',
+ 'multiselectable',
+ 'readonly',
+ 'required',
+ 'selected',
+ ];
+ const getBooleanPropertyValue = (key: BooleanProperty): boolean => {
+ return properties.get(key) as boolean;
+ };
+
+ for (const booleanProperty of booleanProperties) {
+ // RootWebArea's treat focus differently than other nodes. They report whether
+ // their frame has focus, not whether focus is specifically on the root
+ // node.
+ if (booleanProperty === 'focused' && this.#role === 'RootWebArea') {
+ continue;
+ }
+ const value = getBooleanPropertyValue(booleanProperty);
+ if (!value) {
+ continue;
+ }
+ node[booleanProperty] = getBooleanPropertyValue(booleanProperty);
+ }
+
+ type TristateProperty = 'checked' | 'pressed';
+ const tristateProperties: TristateProperty[] = ['checked', 'pressed'];
+ for (const tristateProperty of tristateProperties) {
+ if (!properties.has(tristateProperty)) {
+ continue;
+ }
+ const value = properties.get(tristateProperty);
+ node[tristateProperty] =
+ value === 'mixed' ? 'mixed' : value === 'true' ? true : false;
+ }
+
+ type NumbericalProperty = 'level' | 'valuemax' | 'valuemin';
+ const numericalProperties: NumbericalProperty[] = [
+ 'level',
+ 'valuemax',
+ 'valuemin',
+ ];
+ const getNumericalPropertyValue = (key: NumbericalProperty): number => {
+ return properties.get(key) as number;
+ };
+ for (const numericalProperty of numericalProperties) {
+ if (!properties.has(numericalProperty)) {
+ continue;
+ }
+ node[numericalProperty] = getNumericalPropertyValue(numericalProperty);
+ }
+
+ type TokenProperty =
+ | 'autocomplete'
+ | 'haspopup'
+ | 'invalid'
+ | 'orientation';
+ const tokenProperties: TokenProperty[] = [
+ 'autocomplete',
+ 'haspopup',
+ 'invalid',
+ 'orientation',
+ ];
+ const getTokenPropertyValue = (key: TokenProperty): string => {
+ return properties.get(key) as string;
+ };
+ for (const tokenProperty of tokenProperties) {
+ const value = getTokenPropertyValue(tokenProperty);
+ if (!value || value === 'false') {
+ continue;
+ }
+ node[tokenProperty] = getTokenPropertyValue(tokenProperty);
+ }
+ return node;
+ }
+
+ public static createTree(payloads: Protocol.Accessibility.AXNode[]): AXNode {
+ const nodeById = new Map<string, AXNode>();
+ for (const payload of payloads) {
+ nodeById.set(payload.nodeId, new AXNode(payload));
+ }
+ for (const node of nodeById.values()) {
+ for (const childId of node.payload.childIds || []) {
+ 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..2ec86f5bf2
--- /dev/null
+++ b/remote/test/puppeteer/src/common/AriaQueryHandler.ts
@@ -0,0 +1,189 @@
+/**
+ * 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';
+import {assert} from '../util/assert.js';
+import {CDPSession} from './Connection.js';
+import {ElementHandle} from './ElementHandle.js';
+import {Frame} from './Frame.js';
+import {MAIN_WORLD, PageBinding, PUPPETEER_WORLD} from './IsolatedWorld.js';
+import {InternalQueryHandler} from './QueryHandler.js';
+
+async function queryAXTree(
+ client: CDPSession,
+ element: ElementHandle<Node>,
+ 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) => {
+ return !node.role || node.role.value !== 'StaticText';
+ }
+ );
+ return filteredNodes;
+}
+
+const normalizeValue = (value: string): string => {
+ return value.replace(/ +/g, ' ').trim();
+};
+const knownAttributes = new Set(['name', 'role']);
+const attributeRegexp =
+ /\[\s*(?<attribute>\w+)\s*=\s*(?<quote>"|')(?<value>\\.|.*?(?=\k<quote>))\k<quote>\s*\]/g;
+
+type ARIAQueryOption = {name?: string; role?: string};
+function isKnownAttribute(
+ attribute: string
+): attribute is keyof ARIAQueryOption {
+ return knownAttributes.has(attribute);
+}
+
+/**
+ * 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'.
+ */
+function parseAriaSelector(selector: string): ARIAQueryOption {
+ const queryOptions: ARIAQueryOption = {};
+ const defaultName = selector.replace(
+ attributeRegexp,
+ (_, attribute: string, _quote: string, value: string) => {
+ attribute = attribute.trim();
+ assert(
+ isKnownAttribute(attribute),
+ `Unknown aria attribute "${attribute}" in selector`
+ );
+ queryOptions[attribute] = normalizeValue(value);
+ return '';
+ }
+ );
+ if (defaultName && !queryOptions.name) {
+ queryOptions.name = normalizeValue(defaultName);
+ }
+ return queryOptions;
+}
+
+const queryOneId = async (element: ElementHandle<Node>, selector: string) => {
+ const {name, role} = parseAriaSelector(selector);
+ const res = await queryAXTree(element.client, element, name, role);
+ if (!res[0] || !res[0].backendDOMNodeId) {
+ return null;
+ }
+ return res[0].backendDOMNodeId;
+};
+
+const queryOne: InternalQueryHandler['queryOne'] = async (
+ element,
+ selector
+) => {
+ const id = await queryOneId(element, selector);
+ if (!id) {
+ return null;
+ }
+ return (await element.frame.worlds[MAIN_WORLD].adoptBackendNode(
+ id
+ )) as ElementHandle<Node>;
+};
+
+const waitFor: InternalQueryHandler['waitFor'] = async (
+ elementOrFrame,
+ selector,
+ options
+) => {
+ let frame: Frame;
+ let element: ElementHandle<Node> | undefined;
+ if (elementOrFrame instanceof Frame) {
+ frame = elementOrFrame;
+ } else {
+ frame = elementOrFrame.frame;
+ element = await frame.worlds[PUPPETEER_WORLD].adoptHandle(elementOrFrame);
+ }
+ const binding: PageBinding = {
+ name: 'ariaQuerySelector',
+ pptrFunction: async (selector: string) => {
+ const id = await queryOneId(
+ element || (await frame.worlds[PUPPETEER_WORLD].document()),
+ selector
+ );
+ if (!id) {
+ return null;
+ }
+ return (await frame.worlds[PUPPETEER_WORLD].adoptBackendNode(
+ id
+ )) as ElementHandle<Node>;
+ },
+ };
+ const result = await frame.worlds[PUPPETEER_WORLD]._waitForSelectorInPage(
+ (_: Element, selector: string) => {
+ return (
+ globalThis as unknown as {
+ ariaQuerySelector(selector: string): Node | null;
+ }
+ ).ariaQuerySelector(selector);
+ },
+ element,
+ selector,
+ options,
+ binding
+ );
+ if (element) {
+ await element.dispose();
+ }
+ if (!result) {
+ return null;
+ }
+ if (!(result instanceof ElementHandle)) {
+ await result.dispose();
+ return null;
+ }
+ return result.frame.worlds[MAIN_WORLD].transferHandle(result);
+};
+
+const queryAll: InternalQueryHandler['queryAll'] = async (
+ element,
+ selector
+) => {
+ const exeCtx = element.executionContext();
+ const {name, role} = parseAriaSelector(selector);
+ const res = await queryAXTree(exeCtx._client, element, name, role);
+ const world = exeCtx._world!;
+ return Promise.all(
+ res.map(axNode => {
+ return world.adoptBackendNode(axNode.backendDOMNodeId) as Promise<
+ ElementHandle<Node>
+ >;
+ })
+ );
+};
+
+/**
+ * @internal
+ */
+export const ariaHandler: InternalQueryHandler = {
+ queryOne,
+ waitFor,
+ queryAll,
+};
diff --git a/remote/test/puppeteer/src/common/Browser.ts b/remote/test/puppeteer/src/common/Browser.ts
new file mode 100644
index 0000000000..253cfff291
--- /dev/null
+++ b/remote/test/puppeteer/src/common/Browser.ts
@@ -0,0 +1,980 @@
+/**
+ * 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 {ChildProcess} from 'child_process';
+import {Protocol} from 'devtools-protocol';
+import {assert} from '../util/assert.js';
+import {CDPSession, Connection, ConnectionEmittedEvents} from './Connection.js';
+import {EventEmitter} from './EventEmitter.js';
+import {waitWithTimeout} from './util.js';
+import {Page} from './Page.js';
+import {Viewport} from './PuppeteerViewport.js';
+import {Target} from './Target.js';
+import {TaskQueue} from './TaskQueue.js';
+import {TargetManager, TargetManagerEmittedEvents} from './TargetManager.js';
+import {ChromeTargetManager} from './ChromeTargetManager.js';
+import {FirefoxTargetManager} from './FirefoxTargetManager.js';
+
+/**
+ * BrowserContext options.
+ *
+ * @public
+ */
+export interface BrowserContextOptions {
+ /**
+ * Proxy server with optional port to use for all requests.
+ * Username and password can be set in `Page.authenticate`.
+ */
+ proxyServer?: string;
+ /**
+ * Bypass the proxy for the given semi-colon-separated list of hosts.
+ */
+ proxyBypassList?: string[];
+}
+
+/**
+ * @internal
+ */
+export type BrowserCloseCallback = () => Promise<void> | void;
+
+/**
+ * @public
+ */
+export type TargetFilterCallback = (
+ target: Protocol.Target.TargetInfo
+) => boolean;
+
+/**
+ * @internal
+ */
+export type IsPageTargetCallback = (
+ target: Protocol.Target.TargetInfo
+) => boolean;
+
+const WEB_PERMISSION_TO_PROTOCOL_PERMISSION = new Map<
+ Permission,
+ Protocol.Browser.PermissionType
+>([
+ ['geolocation', 'geolocation'],
+ ['midi', 'midi'],
+ ['notifications', 'notifications'],
+ // TODO: push isn't a valid type?
+ // ['push', 'push'],
+ ['camera', 'videoCapture'],
+ ['microphone', 'audioCapture'],
+ ['background-sync', 'backgroundSync'],
+ ['ambient-light-sensor', 'sensors'],
+ ['accelerometer', 'sensors'],
+ ['gyroscope', 'sensors'],
+ ['magnetometer', 'sensors'],
+ ['accessibility-events', 'accessibilityEvents'],
+ ['clipboard-read', 'clipboardReadWrite'],
+ ['clipboard-write', 'clipboardReadWrite'],
+ ['payment-handler', 'paymentHandler'],
+ ['persistent-storage', 'durableStorage'],
+ ['idle-detection', 'idleDetection'],
+ // chrome-specific permissions we have.
+ ['midi-sysex', 'midiSysex'],
+]);
+
+/**
+ * @public
+ */
+export type Permission =
+ | 'geolocation'
+ | 'midi'
+ | 'notifications'
+ | 'camera'
+ | 'microphone'
+ | 'background-sync'
+ | 'ambient-light-sensor'
+ | 'accelerometer'
+ | 'gyroscope'
+ | 'magnetometer'
+ | 'accessibility-events'
+ | 'clipboard-read'
+ | 'clipboard-write'
+ | 'payment-handler'
+ | 'persistent-storage'
+ | 'idle-detection'
+ | 'midi-sysex';
+
+/**
+ * @public
+ */
+export interface WaitForTargetOptions {
+ /**
+ * Maximum wait time in milliseconds. Pass `0` to disable the timeout.
+ * @defaultValue 30 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}:
+ *
+ * ```ts
+ * 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}:
+ *
+ * ```ts
+ * 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(
+ product: 'firefox' | 'chrome' | undefined,
+ connection: Connection,
+ contextIds: string[],
+ ignoreHTTPSErrors: boolean,
+ defaultViewport?: Viewport | null,
+ process?: ChildProcess,
+ closeCallback?: BrowserCloseCallback,
+ targetFilterCallback?: TargetFilterCallback,
+ isPageTargetCallback?: IsPageTargetCallback
+ ): Promise<Browser> {
+ const browser = new Browser(
+ product,
+ connection,
+ contextIds,
+ ignoreHTTPSErrors,
+ defaultViewport,
+ process,
+ closeCallback,
+ targetFilterCallback,
+ isPageTargetCallback
+ );
+ await browser._attach();
+ return browser;
+ }
+ #ignoreHTTPSErrors: boolean;
+ #defaultViewport?: Viewport | null;
+ #process?: ChildProcess;
+ #connection: Connection;
+ #closeCallback: BrowserCloseCallback;
+ #targetFilterCallback: TargetFilterCallback;
+ #isPageTargetCallback!: IsPageTargetCallback;
+ #defaultContext: BrowserContext;
+ #contexts: Map<string, BrowserContext>;
+ #screenshotTaskQueue: TaskQueue;
+ #targetManager: TargetManager;
+
+ /**
+ * @internal
+ */
+ get _targets(): Map<string, Target> {
+ return this.#targetManager.getAvailableTargets();
+ }
+
+ /**
+ * @internal
+ */
+ constructor(
+ product: 'chrome' | 'firefox' | undefined,
+ connection: Connection,
+ contextIds: string[],
+ ignoreHTTPSErrors: boolean,
+ defaultViewport?: Viewport | null,
+ process?: ChildProcess,
+ closeCallback?: BrowserCloseCallback,
+ targetFilterCallback?: TargetFilterCallback,
+ isPageTargetCallback?: IsPageTargetCallback
+ ) {
+ super();
+ product = product || 'chrome';
+ this.#ignoreHTTPSErrors = ignoreHTTPSErrors;
+ this.#defaultViewport = defaultViewport;
+ this.#process = process;
+ this.#screenshotTaskQueue = new TaskQueue();
+ this.#connection = connection;
+ this.#closeCallback = closeCallback || function (): void {};
+ this.#targetFilterCallback =
+ targetFilterCallback ||
+ ((): boolean => {
+ return true;
+ });
+ this.#setIsPageTargetCallback(isPageTargetCallback);
+ if (product === 'firefox') {
+ this.#targetManager = new FirefoxTargetManager(
+ connection,
+ this.#createTarget,
+ this.#targetFilterCallback
+ );
+ } else {
+ this.#targetManager = new ChromeTargetManager(
+ connection,
+ this.#createTarget,
+ this.#targetFilterCallback
+ );
+ }
+ this.#defaultContext = new BrowserContext(this.#connection, this);
+ this.#contexts = new Map();
+ for (const contextId of contextIds) {
+ this.#contexts.set(
+ contextId,
+ new BrowserContext(this.#connection, this, contextId)
+ );
+ }
+ }
+
+ #emitDisconnected = () => {
+ this.emit(BrowserEmittedEvents.Disconnected);
+ };
+
+ /**
+ * @internal
+ */
+ async _attach(): Promise<void> {
+ this.#connection.on(
+ ConnectionEmittedEvents.Disconnected,
+ this.#emitDisconnected
+ );
+ this.#targetManager.on(
+ TargetManagerEmittedEvents.TargetAvailable,
+ this.#onAttachedToTarget
+ );
+ this.#targetManager.on(
+ TargetManagerEmittedEvents.TargetGone,
+ this.#onDetachedFromTarget
+ );
+ this.#targetManager.on(
+ TargetManagerEmittedEvents.TargetChanged,
+ this.#onTargetChanged
+ );
+ this.#targetManager.on(
+ TargetManagerEmittedEvents.TargetDiscovered,
+ this.#onTargetDiscovered
+ );
+ await this.#targetManager.initialize();
+ }
+
+ /**
+ * @internal
+ */
+ _detach(): void {
+ this.#connection.off(
+ ConnectionEmittedEvents.Disconnected,
+ this.#emitDisconnected
+ );
+ this.#targetManager.off(
+ TargetManagerEmittedEvents.TargetAvailable,
+ this.#onAttachedToTarget
+ );
+ this.#targetManager.off(
+ TargetManagerEmittedEvents.TargetGone,
+ this.#onDetachedFromTarget
+ );
+ this.#targetManager.off(
+ TargetManagerEmittedEvents.TargetChanged,
+ this.#onTargetChanged
+ );
+ this.#targetManager.off(
+ TargetManagerEmittedEvents.TargetDiscovered,
+ this.#onTargetDiscovered
+ );
+ }
+
+ /**
+ * The spawned browser process. Returns `null` if the browser instance was created with
+ * {@link Puppeteer.connect}.
+ */
+ process(): ChildProcess | null {
+ return this.#process ?? null;
+ }
+
+ /**
+ * @internal
+ */
+ _targetManager(): TargetManager {
+ return this.#targetManager;
+ }
+
+ #setIsPageTargetCallback(isPageTargetCallback?: IsPageTargetCallback): void {
+ this.#isPageTargetCallback =
+ isPageTargetCallback ||
+ ((target: Protocol.Target.TargetInfo): boolean => {
+ return (
+ target.type === 'page' ||
+ target.type === 'background_page' ||
+ target.type === 'webview'
+ );
+ });
+ }
+
+ /**
+ * @internal
+ */
+ _getIsPageTargetCallback(): IsPageTargetCallback | undefined {
+ return this.#isPageTargetCallback;
+ }
+
+ /**
+ * Creates a new incognito browser context. This won't share cookies/cache with other
+ * browser contexts.
+ *
+ * @example
+ *
+ * ```ts
+ * (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(
+ options: BrowserContextOptions = {}
+ ): Promise<BrowserContext> {
+ const {proxyServer, proxyBypassList} = options;
+
+ const {browserContextId} = await this.#connection.send(
+ 'Target.createBrowserContext',
+ {
+ proxyServer,
+ proxyBypassList: proxyBypassList && proxyBypassList.join(','),
+ }
+ );
+ 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
+ */
+ async _disposeContext(contextId?: string): Promise<void> {
+ if (!contextId) {
+ return;
+ }
+ await this.#connection.send('Target.disposeBrowserContext', {
+ browserContextId: contextId,
+ });
+ this.#contexts.delete(contextId);
+ }
+
+ #createTarget = (
+ targetInfo: Protocol.Target.TargetInfo,
+ session?: CDPSession
+ ) => {
+ const {browserContextId} = targetInfo;
+ const context =
+ browserContextId && this.#contexts.has(browserContextId)
+ ? this.#contexts.get(browserContextId)
+ : this.#defaultContext;
+
+ if (!context) {
+ throw new Error('Missing browser context');
+ }
+
+ return new Target(
+ targetInfo,
+ session,
+ context,
+ this.#targetManager,
+ (isAutoAttachEmulated: boolean) => {
+ return this.#connection._createSession(
+ targetInfo,
+ isAutoAttachEmulated
+ );
+ },
+ this.#ignoreHTTPSErrors,
+ this.#defaultViewport ?? null,
+ this.#screenshotTaskQueue,
+ this.#isPageTargetCallback
+ );
+ };
+
+ #onAttachedToTarget = async (target: Target) => {
+ if (await target._initializedPromise) {
+ this.emit(BrowserEmittedEvents.TargetCreated, target);
+ target
+ .browserContext()
+ .emit(BrowserContextEmittedEvents.TargetCreated, target);
+ }
+ };
+
+ #onDetachedFromTarget = async (target: Target): Promise<void> => {
+ target._initializedCallback(false);
+ target._closedCallback();
+ if (await target._initializedPromise) {
+ this.emit(BrowserEmittedEvents.TargetDestroyed, target);
+ target
+ .browserContext()
+ .emit(BrowserContextEmittedEvents.TargetDestroyed, target);
+ }
+ };
+
+ #onTargetChanged = ({
+ target,
+ targetInfo,
+ }: {
+ target: Target;
+ targetInfo: Protocol.Target.TargetInfo;
+ }): void => {
+ const previousURL = target.url();
+ const wasInitialized = target._isInitialized;
+ target._targetInfoChanged(targetInfo);
+ if (wasInitialized && previousURL !== target.url()) {
+ this.emit(BrowserEmittedEvents.TargetChanged, target);
+ target
+ .browserContext()
+ .emit(BrowserContextEmittedEvents.TargetChanged, target);
+ }
+ };
+
+ #onTargetDiscovered = (targetInfo: Protocol.Target.TargetInfo): void => {
+ this.emit('targetdiscovered', targetInfo);
+ };
+
+ /**
+ * 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();
+ }
+
+ /**
+ * Promise which resolves to a new {@link Page} object. The Page is created in
+ * a default browser context.
+ */
+ async newPage(): Promise<Page> {
+ return this.#defaultContext.newPage();
+ }
+
+ /**
+ * @internal
+ */
+ async _createPageInContext(contextId?: string): Promise<Page> {
+ const {targetId} = await this.#connection.send('Target.createTarget', {
+ url: 'about:blank',
+ browserContextId: contextId || undefined,
+ });
+ const target = this.#targetManager.getAvailableTargets().get(targetId);
+ if (!target) {
+ throw new Error(`Missing target for page (id = ${targetId})`);
+ }
+ const initialized = await target._initializedPromise;
+ if (!initialized) {
+ throw new Error(`Failed to create target for page (id = ${targetId})`);
+ }
+ const page = await target.page();
+ if (!page) {
+ throw new Error(
+ `Failed to create a page for context (id = ${contextId})`
+ );
+ }
+ return page;
+ }
+
+ /**
+ * 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.#targetManager.getAvailableTargets().values()
+ ).filter(target => {
+ return target._isInitialized;
+ });
+ }
+
+ /**
+ * The target associated with the browser.
+ */
+ target(): Target {
+ const browserTarget = this.targets().find(target => {
+ return target.type() === 'browser';
+ });
+ if (!browserTarget) {
+ throw new Error('Browser target is not found');
+ }
+ return browserTarget;
+ }
+
+ /**
+ * 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`:
+ *
+ * ```ts
+ * await page.evaluate(() => window.open('https://www.example.com/'));
+ * const newWindowTarget = await browser.waitForTarget(
+ * target => target.url() === 'https://www.example.com/'
+ * );
+ * ```
+ */
+ async waitForTarget(
+ predicate: (x: Target) => boolean | Promise<boolean>,
+ options: WaitForTargetOptions = {}
+ ): Promise<Target> {
+ const {timeout = 30000} = options;
+ let resolve: (value: Target | PromiseLike<Target>) => void;
+ let isResolved = false;
+ const targetPromise = new Promise<Target>(x => {
+ return (resolve = x);
+ });
+ this.on(BrowserEmittedEvents.TargetCreated, check);
+ this.on(BrowserEmittedEvents.TargetChanged, check);
+ try {
+ this.targets().forEach(check);
+ if (!timeout) {
+ return await targetPromise;
+ }
+ return await waitWithTimeout(targetPromise, 'target', timeout);
+ } finally {
+ this.off(BrowserEmittedEvents.TargetCreated, check);
+ this.off(BrowserEmittedEvents.TargetChanged, check);
+ }
+
+ async function check(target: Target): Promise<void> {
+ if ((await predicate(target)) && !isResolved) {
+ isResolved = true;
+ 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 => {
+ return context.pages();
+ })
+ );
+ // Flatten array.
+ return contextPages.reduce((acc, x) => {
+ return 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.#targetManager.dispose();
+ this.#connection.dispose();
+ }
+
+ /**
+ * Indicates that the browser is connected.
+ */
+ isConnected(): boolean {
+ return !this.#connection._closed;
+ }
+
+ #getVersion(): Promise<Protocol.Browser.GetVersionResponse> {
+ return this.#connection.send('Browser.getVersion');
+ }
+}
+/**
+ * @public
+ */
+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
+ *
+ * ```ts
+ * // Create a new incognito browser context
+ * const context = await browser.createIncognitoBrowserContext();
+ * // Create a new page inside context.
+ * const page = await context.newPage();
+ * // ... do stuff with page ...
+ * await page.goto('https://example.com');
+ * // Dispose context once it's no longer needed.
+ * await context.close();
+ * ```
+ *
+ * @public
+ */
+export class BrowserContext extends EventEmitter {
+ #connection: Connection;
+ #browser: Browser;
+ #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 => {
+ return 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`:
+ *
+ * ```ts
+ * 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 | Promise<boolean>,
+ options: {timeout?: number} = {}
+ ): Promise<Target> {
+ return this.#browser.waitForTarget(target => {
+ return 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 => {
+ return (
+ target.type() === 'page' ||
+ (target.type() === 'other' &&
+ this.#browser._getIsPageTargetCallback()?.(
+ target._getTargetInfo()
+ ))
+ );
+ })
+ .map(target => {
+ return target.page();
+ })
+ );
+ return pages.filter((page): page is Page => {
+ return !!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
+ *
+ * ```ts
+ * const context = browser.defaultBrowserContext();
+ * await context.overridePermissions('https://html5demos.com', [
+ * 'geolocation',
+ * ]);
+ * ```
+ *
+ * @param origin - The origin to grant permissions to, e.g. "https://example.com".
+ * @param permissions - An array of permissions to grant.
+ * All permissions that are not listed here will be automatically denied.
+ */
+ async overridePermissions(
+ origin: string,
+ permissions: Permission[]
+ ): Promise<void> {
+ const protocolPermissions = permissions.map(permission => {
+ const protocolPermission =
+ WEB_PERMISSION_TO_PROTOCOL_PERMISSION.get(permission);
+ if (!protocolPermission) {
+ throw new Error('Unknown permission: ' + permission);
+ }
+ return protocolPermission;
+ });
+ await this.#connection.send('Browser.grantPermissions', {
+ origin,
+ browserContextId: this.#id || undefined,
+ permissions: protocolPermissions,
+ });
+ }
+
+ /**
+ * Clears all permission overrides for the browser context.
+ *
+ * @example
+ *
+ * ```ts
+ * 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..8d65873c31
--- /dev/null
+++ b/remote/test/puppeteer/src/common/BrowserConnector.ts
@@ -0,0 +1,159 @@
+/**
+ * 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 {debugError} from './util.js';
+import {isErrorLike} from '../util/ErrorLike.js';
+import {isNode} from '../environment.js';
+import {assert} from '../util/assert.js';
+import {
+ Browser,
+ IsPageTargetCallback,
+ TargetFilterCallback,
+} from './Browser.js';
+import {Connection} from './Connection.js';
+import {ConnectionTransport} from './ConnectionTransport.js';
+import {getFetch} from './fetch.js';
+import {Viewport} from './PuppeteerViewport.js';
+/**
+ * Generic browser options that can be passed when launching any browser or when
+ * connecting to an existing browser instance.
+ * @public
+ */
+export interface BrowserConnectOptions {
+ /**
+ * Whether to ignore HTTPS errors during navigation.
+ * @defaultValue false
+ */
+ ignoreHTTPSErrors?: boolean;
+ /**
+ * Sets the viewport for each page.
+ */
+ defaultViewport?: Viewport | null;
+ /**
+ * Slows down Puppeteer operations by the specified amount of milliseconds to
+ * aid debugging.
+ */
+ slowMo?: number;
+ /**
+ * Callback to decide if Puppeteer should connect to a given target or not.
+ */
+ targetFilter?: TargetFilterCallback;
+ /**
+ * @internal
+ */
+ _isPageTarget?: IsPageTargetCallback;
+}
+
+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 async function _connectToBrowser(
+ options: BrowserConnectOptions & {
+ browserWSEndpoint?: string;
+ browserURL?: string;
+ transport?: ConnectionTransport;
+ }
+): Promise<Browser> {
+ const {
+ browserWSEndpoint,
+ browserURL,
+ ignoreHTTPSErrors = false,
+ defaultViewport = {width: 800, height: 600},
+ transport,
+ slowMo = 0,
+ targetFilter,
+ _isPageTarget: isPageTarget,
+ } = options;
+
+ assert(
+ Number(!!browserWSEndpoint) + Number(!!browserURL) + Number(!!transport) ===
+ 1,
+ 'Exactly one of browserWSEndpoint, browserURL or transport must be passed to puppeteer.connect'
+ );
+
+ let connection!: Connection;
+ 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 version = await connection.send('Browser.getVersion');
+
+ const product = version.product.toLowerCase().includes('firefox')
+ ? 'firefox'
+ : 'chrome';
+
+ const {browserContextIds} = await connection.send(
+ 'Target.getBrowserContexts'
+ );
+ const browser = await Browser._create(
+ product || 'chrome',
+ connection,
+ browserContextIds,
+ ignoreHTTPSErrors,
+ defaultViewport,
+ undefined,
+ () => {
+ return connection.send('Browser.close').catch(debugError);
+ },
+ targetFilter,
+ isPageTarget
+ );
+ await browser.pages();
+ return browser;
+}
+
+async function getWSEndpoint(browserURL: string): Promise<string> {
+ const endpointURL = new URL('/json/version', browserURL);
+
+ const fetch = await getFetch();
+ try {
+ const result = await fetch(endpointURL.toString(), {
+ method: 'GET',
+ });
+ if (!result.ok) {
+ throw new Error(`HTTP ${result.statusText}`);
+ }
+ const data = await result.json();
+ return data.webSocketDebuggerUrl;
+ } catch (error) {
+ if (isErrorLike(error)) {
+ error.message =
+ `Failed to fetch browser webSocket URL from ${endpointURL}: ` +
+ error.message;
+ }
+ throw error;
+ }
+}
diff --git a/remote/test/puppeteer/src/common/BrowserWebSocketTransport.ts b/remote/test/puppeteer/src/common/BrowserWebSocketTransport.ts
new file mode 100644
index 0000000000..5fe32e6526
--- /dev/null
+++ b/remote/test/puppeteer/src/common/BrowserWebSocketTransport.ts
@@ -0,0 +1,60 @@
+/**
+ * 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';
+
+/**
+ * @internal
+ */
+export class BrowserWebSocketTransport implements ConnectionTransport {
+ static create(url: string): Promise<BrowserWebSocketTransport> {
+ return new Promise((resolve, reject) => {
+ const ws = new WebSocket(url);
+
+ ws.addEventListener('open', () => {
+ return resolve(new BrowserWebSocketTransport(ws));
+ });
+ ws.addEventListener('error', reject);
+ });
+ }
+
+ #ws: WebSocket;
+ onmessage?: (message: string) => void;
+ onclose?: () => void;
+
+ constructor(ws: WebSocket) {
+ this.#ws = ws;
+ this.#ws.addEventListener('message', event => {
+ if (this.onmessage) {
+ this.onmessage.call(null, event.data);
+ }
+ });
+ this.#ws.addEventListener('close', () => {
+ if (this.onclose) {
+ this.onclose.call(null);
+ }
+ });
+ // Silently ignore all errors - we don't know what to do with them.
+ this.#ws.addEventListener('error', () => {});
+ }
+
+ send(message: string): void {
+ this.#ws.send(message);
+ }
+
+ close(): void {
+ this.#ws.close();
+ }
+}
diff --git a/remote/test/puppeteer/src/common/ChromeTargetManager.ts b/remote/test/puppeteer/src/common/ChromeTargetManager.ts
new file mode 100644
index 0000000000..445225edf3
--- /dev/null
+++ b/remote/test/puppeteer/src/common/ChromeTargetManager.ts
@@ -0,0 +1,411 @@
+/**
+ * Copyright 2022 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';
+import {assert} from '../util/assert.js';
+import {CDPSession, Connection} from './Connection.js';
+import {EventEmitter} from './EventEmitter.js';
+import {Target} from './Target.js';
+import {debugError} from './util.js';
+import {TargetFilterCallback} from './Browser.js';
+import {
+ TargetInterceptor,
+ TargetFactory,
+ TargetManager,
+ TargetManagerEmittedEvents,
+} from './TargetManager.js';
+
+/**
+ * ChromeTargetManager uses the CDP's auto-attach mechanism to intercept
+ * new targets and allow the rest of Puppeteer to configure listeners while
+ * the target is paused.
+ *
+ * @internal
+ */
+export class ChromeTargetManager extends EventEmitter implements TargetManager {
+ #connection: Connection;
+ /**
+ * Keeps track of the following events: 'Target.targetCreated',
+ * 'Target.targetDestroyed', 'Target.targetInfoChanged'.
+ *
+ * A target becomes discovered when 'Target.targetCreated' is received.
+ * A target is removed from this map once 'Target.targetDestroyed' is
+ * received.
+ *
+ * `targetFilterCallback` has no effect on this map.
+ */
+ #discoveredTargetsByTargetId: Map<string, Protocol.Target.TargetInfo> =
+ new Map();
+ /**
+ * A target is added to this map once ChromeTargetManager has created
+ * a Target and attached at least once to it.
+ */
+ #attachedTargetsByTargetId: Map<string, Target> = new Map();
+ /**
+ *
+ * Tracks which sessions attach to which target.
+ */
+ #attachedTargetsBySessionId: Map<string, Target> = new Map();
+ /**
+ * If a target was filtered out by `targetFilterCallback`, we still receive
+ * events about it from CDP, but we don't forward them to the rest of Puppeteer.
+ */
+ #ignoredTargets = new Set<string>();
+ #targetFilterCallback: TargetFilterCallback | undefined;
+ #targetFactory: TargetFactory;
+
+ #targetInterceptors: WeakMap<CDPSession | Connection, TargetInterceptor[]> =
+ new WeakMap();
+
+ #attachedToTargetListenersBySession: WeakMap<
+ CDPSession | Connection,
+ (event: Protocol.Target.AttachedToTargetEvent) => Promise<void>
+ > = new WeakMap();
+ #detachedFromTargetListenersBySession: WeakMap<
+ CDPSession | Connection,
+ (event: Protocol.Target.DetachedFromTargetEvent) => void
+ > = new WeakMap();
+
+ #initializeCallback = () => {};
+ #initializePromise: Promise<void> = new Promise(resolve => {
+ this.#initializeCallback = resolve;
+ });
+ #targetsIdsForInit: Set<string> = new Set();
+
+ constructor(
+ connection: Connection,
+ targetFactory: TargetFactory,
+ targetFilterCallback?: TargetFilterCallback
+ ) {
+ super();
+ this.#connection = connection;
+ this.#targetFilterCallback = targetFilterCallback;
+ this.#targetFactory = targetFactory;
+
+ this.#connection.on('Target.targetCreated', this.#onTargetCreated);
+ this.#connection.on('Target.targetDestroyed', this.#onTargetDestroyed);
+ this.#connection.on('Target.targetInfoChanged', this.#onTargetInfoChanged);
+ this.#connection.on('sessiondetached', this.#onSessionDetached);
+ this.#setupAttachmentListeners(this.#connection);
+
+ // TODO: remove `as any` once the protocol definitions are updated with the
+ // next Chromium roll.
+ this.#connection
+ .send('Target.setDiscoverTargets', {
+ discover: true,
+ filter: [{type: 'tab', exclude: true}, {}],
+ } as any)
+ .then(this.#storeExistingTargetsForInit)
+ .catch(debugError);
+ }
+
+ #storeExistingTargetsForInit = () => {
+ for (const [
+ targetId,
+ targetInfo,
+ ] of this.#discoveredTargetsByTargetId.entries()) {
+ if (
+ (!this.#targetFilterCallback ||
+ this.#targetFilterCallback(targetInfo)) &&
+ targetInfo.type !== 'browser'
+ ) {
+ this.#targetsIdsForInit.add(targetId);
+ }
+ }
+ };
+
+ async initialize(): Promise<void> {
+ await this.#connection.send('Target.setAutoAttach', {
+ waitForDebuggerOnStart: true,
+ flatten: true,
+ autoAttach: true,
+ });
+ this.#finishInitializationIfReady();
+ await this.#initializePromise;
+ }
+
+ dispose(): void {
+ this.#connection.off('Target.targetCreated', this.#onTargetCreated);
+ this.#connection.off('Target.targetDestroyed', this.#onTargetDestroyed);
+ this.#connection.off('Target.targetInfoChanged', this.#onTargetInfoChanged);
+ this.#connection.off('sessiondetached', this.#onSessionDetached);
+
+ this.#removeAttachmentListeners(this.#connection);
+ }
+
+ getAvailableTargets(): Map<string, Target> {
+ return this.#attachedTargetsByTargetId;
+ }
+
+ addTargetInterceptor(
+ session: CDPSession | Connection,
+ interceptor: TargetInterceptor
+ ): void {
+ const interceptors = this.#targetInterceptors.get(session) || [];
+ interceptors.push(interceptor);
+ this.#targetInterceptors.set(session, interceptors);
+ }
+
+ removeTargetInterceptor(
+ client: CDPSession | Connection,
+ interceptor: TargetInterceptor
+ ): void {
+ const interceptors = this.#targetInterceptors.get(client) || [];
+ this.#targetInterceptors.set(
+ client,
+ interceptors.filter(currentInterceptor => {
+ return currentInterceptor !== interceptor;
+ })
+ );
+ }
+
+ #setupAttachmentListeners(session: CDPSession | Connection): void {
+ const listener = (event: Protocol.Target.AttachedToTargetEvent) => {
+ return this.#onAttachedToTarget(session, event);
+ };
+ assert(!this.#attachedToTargetListenersBySession.has(session));
+ this.#attachedToTargetListenersBySession.set(session, listener);
+ session.on('Target.attachedToTarget', listener);
+
+ const detachedListener = (
+ event: Protocol.Target.DetachedFromTargetEvent
+ ) => {
+ return this.#onDetachedFromTarget(session, event);
+ };
+ assert(!this.#detachedFromTargetListenersBySession.has(session));
+ this.#detachedFromTargetListenersBySession.set(session, detachedListener);
+ session.on('Target.detachedFromTarget', detachedListener);
+ }
+
+ #removeAttachmentListeners(session: CDPSession | Connection): void {
+ if (this.#attachedToTargetListenersBySession.has(session)) {
+ session.off(
+ 'Target.attachedToTarget',
+ this.#attachedToTargetListenersBySession.get(session)!
+ );
+ this.#attachedToTargetListenersBySession.delete(session);
+ }
+
+ if (this.#detachedFromTargetListenersBySession.has(session)) {
+ session.off(
+ 'Target.detachedFromTarget',
+ this.#detachedFromTargetListenersBySession.get(session)!
+ );
+ this.#detachedFromTargetListenersBySession.delete(session);
+ }
+ }
+
+ #onSessionDetached = (session: CDPSession) => {
+ this.#removeAttachmentListeners(session);
+ this.#targetInterceptors.delete(session);
+ };
+
+ #onTargetCreated = async (event: Protocol.Target.TargetCreatedEvent) => {
+ this.#discoveredTargetsByTargetId.set(
+ event.targetInfo.targetId,
+ event.targetInfo
+ );
+
+ this.emit(TargetManagerEmittedEvents.TargetDiscovered, event.targetInfo);
+
+ // The connection is already attached to the browser target implicitly,
+ // therefore, no new CDPSession is created and we have special handling
+ // here.
+ if (event.targetInfo.type === 'browser' && event.targetInfo.attached) {
+ if (this.#attachedTargetsByTargetId.has(event.targetInfo.targetId)) {
+ return;
+ }
+ const target = this.#targetFactory(event.targetInfo, undefined);
+ this.#attachedTargetsByTargetId.set(event.targetInfo.targetId, target);
+ }
+
+ if (event.targetInfo.type === 'shared_worker') {
+ // Special case (https://crbug.com/1338156): currently, shared_workers
+ // don't get auto-attached. This should be removed once the auto-attach
+ // works.
+ await this.#connection._createSession(event.targetInfo, true);
+ }
+ };
+
+ #onTargetDestroyed = (event: Protocol.Target.TargetDestroyedEvent) => {
+ const targetInfo = this.#discoveredTargetsByTargetId.get(event.targetId);
+ this.#discoveredTargetsByTargetId.delete(event.targetId);
+ this.#finishInitializationIfReady(event.targetId);
+ if (
+ targetInfo?.type === 'service_worker' &&
+ this.#attachedTargetsByTargetId.has(event.targetId)
+ ) {
+ // Special case for service workers: report TargetGone event when
+ // the worker is destroyed.
+ const target = this.#attachedTargetsByTargetId.get(event.targetId);
+ this.emit(TargetManagerEmittedEvents.TargetGone, target);
+ this.#attachedTargetsByTargetId.delete(event.targetId);
+ }
+ };
+
+ #onTargetInfoChanged = (event: Protocol.Target.TargetInfoChangedEvent) => {
+ this.#discoveredTargetsByTargetId.set(
+ event.targetInfo.targetId,
+ event.targetInfo
+ );
+
+ if (
+ this.#ignoredTargets.has(event.targetInfo.targetId) ||
+ !this.#attachedTargetsByTargetId.has(event.targetInfo.targetId) ||
+ !event.targetInfo.attached
+ ) {
+ return;
+ }
+
+ const target = this.#attachedTargetsByTargetId.get(
+ event.targetInfo.targetId
+ );
+ this.emit(TargetManagerEmittedEvents.TargetChanged, {
+ target: target!,
+ targetInfo: event.targetInfo,
+ });
+ };
+
+ #onAttachedToTarget = async (
+ parentSession: Connection | CDPSession,
+ event: Protocol.Target.AttachedToTargetEvent
+ ) => {
+ const targetInfo = event.targetInfo;
+ const session = this.#connection.session(event.sessionId);
+ if (!session) {
+ throw new Error(`Session ${event.sessionId} was not created.`);
+ }
+
+ const silentDetach = async () => {
+ await session.send('Runtime.runIfWaitingForDebugger').catch(debugError);
+ // We don't use `session.detach()` because that dispatches all commands on
+ // the connection instead of the parent session.
+ await parentSession
+ .send('Target.detachFromTarget', {
+ sessionId: session.id(),
+ })
+ .catch(debugError);
+ };
+
+ if (!this.#connection.isAutoAttached(targetInfo.targetId)) {
+ return;
+ }
+
+ // Special case for service workers: being attached to service workers will
+ // prevent them from ever being destroyed. Therefore, we silently detach
+ // from service workers unless the connection was manually created via
+ // `page.worker()`. To determine this, we use
+ // `this.#connection.isAutoAttached(targetInfo.targetId)`. In the future, we
+ // should determine if a target is auto-attached or not with the help of
+ // CDP.
+ if (
+ targetInfo.type === 'service_worker' &&
+ this.#connection.isAutoAttached(targetInfo.targetId)
+ ) {
+ this.#finishInitializationIfReady(targetInfo.targetId);
+ await silentDetach();
+ if (this.#attachedTargetsByTargetId.has(targetInfo.targetId)) {
+ return;
+ }
+ const target = this.#targetFactory(targetInfo);
+ this.#attachedTargetsByTargetId.set(targetInfo.targetId, target);
+ this.emit(TargetManagerEmittedEvents.TargetAvailable, target);
+ return;
+ }
+
+ if (this.#targetFilterCallback && !this.#targetFilterCallback(targetInfo)) {
+ this.#ignoredTargets.add(targetInfo.targetId);
+ this.#finishInitializationIfReady(targetInfo.targetId);
+ await silentDetach();
+ return;
+ }
+
+ const existingTarget = this.#attachedTargetsByTargetId.has(
+ targetInfo.targetId
+ );
+
+ const target = existingTarget
+ ? this.#attachedTargetsByTargetId.get(targetInfo.targetId)!
+ : this.#targetFactory(targetInfo, session);
+
+ this.#setupAttachmentListeners(session);
+
+ if (existingTarget) {
+ this.#attachedTargetsBySessionId.set(
+ session.id(),
+ this.#attachedTargetsByTargetId.get(targetInfo.targetId)!
+ );
+ } else {
+ this.#attachedTargetsByTargetId.set(targetInfo.targetId, target);
+ this.#attachedTargetsBySessionId.set(session.id(), target);
+ }
+
+ for (const interceptor of this.#targetInterceptors.get(parentSession) ||
+ []) {
+ if (!(parentSession instanceof Connection)) {
+ // Sanity check: if parent session is not a connection, it should be
+ // present in #attachedTargetsBySessionId.
+ assert(this.#attachedTargetsBySessionId.has(parentSession.id()));
+ }
+ await interceptor(
+ target,
+ parentSession instanceof Connection
+ ? null
+ : this.#attachedTargetsBySessionId.get(parentSession.id())!
+ );
+ }
+
+ this.#targetsIdsForInit.delete(target._targetId);
+ if (!existingTarget) {
+ this.emit(TargetManagerEmittedEvents.TargetAvailable, target);
+ }
+ this.#finishInitializationIfReady();
+
+ // TODO: the browser might be shutting down here. What do we do with the
+ // error?
+ await Promise.all([
+ session.send('Target.setAutoAttach', {
+ waitForDebuggerOnStart: true,
+ flatten: true,
+ autoAttach: true,
+ }),
+ session.send('Runtime.runIfWaitingForDebugger'),
+ ]).catch(debugError);
+ };
+
+ #finishInitializationIfReady(targetId?: string): void {
+ targetId !== undefined && this.#targetsIdsForInit.delete(targetId);
+ if (this.#targetsIdsForInit.size === 0) {
+ this.#initializeCallback();
+ }
+ }
+
+ #onDetachedFromTarget = (
+ _parentSession: Connection | CDPSession,
+ event: Protocol.Target.DetachedFromTargetEvent
+ ) => {
+ const target = this.#attachedTargetsBySessionId.get(event.sessionId);
+
+ this.#attachedTargetsBySessionId.delete(event.sessionId);
+
+ if (!target) {
+ return;
+ }
+
+ this.#attachedTargetsByTargetId.delete(target._targetId);
+ this.emit(TargetManagerEmittedEvents.TargetGone, target);
+ };
+}
diff --git a/remote/test/puppeteer/src/common/Connection.ts b/remote/test/puppeteer/src/common/Connection.ts
new file mode 100644
index 0000000000..541e225e4e
--- /dev/null
+++ b/remote/test/puppeteer/src/common/Connection.ts
@@ -0,0 +1,447 @@
+/**
+ * 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 '../util/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';
+import {ProtocolError} from './Errors.js';
+
+/**
+ * @public
+ */
+export {ConnectionTransport, ProtocolMapping};
+
+/**
+ * @public
+ */
+export interface ConnectionCallback {
+ resolve(args: unknown): void;
+ reject(args: unknown): void;
+ error: ProtocolError;
+ method: string;
+}
+
+/**
+ * Internal events that the Connection class emits.
+ *
+ * @internal
+ */
+export const ConnectionEmittedEvents = {
+ Disconnected: Symbol('Connection.Disconnected'),
+} as const;
+
+/**
+ * @public
+ */
+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();
+ #manuallyAttached = new Set<string>();
+
+ 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 | undefined {
+ return session.connection();
+ }
+
+ /**
+ * @internal
+ */
+ get _closed(): boolean {
+ return this.#closed;
+ }
+
+ /**
+ * @internal
+ */
+ get _sessions(): Map<string, CDPSession> {
+ return this.#sessions;
+ }
+
+ /**
+ * @param sessionId - The session id
+ * @returns The current CDP session if it exists
+ */
+ session(sessionId: string): CDPSession | null {
+ return this.#sessions.get(sessionId) || null;
+ }
+
+ url(): string {
+ return this.#url;
+ }
+
+ send<T extends keyof ProtocolMapping.Commands>(
+ method: T,
+ ...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 ProtocolError(),
+ method,
+ });
+ });
+ }
+
+ /**
+ * @internal
+ */
+ _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;
+ }
+
+ /**
+ * @internal
+ */
+ protected async onMessage(message: string): Promise<void> {
+ if (this.#delay) {
+ await new Promise(f => {
+ return 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);
+ this.emit('sessionattached', session);
+ const parentSession = this.#sessions.get(object.sessionId);
+ if (parentSession) {
+ parentSession.emit('sessionattached', session);
+ }
+ } else if (object.method === 'Target.detachedFromTarget') {
+ const session = this.#sessions.get(object.params.sessionId);
+ if (session) {
+ session._onClosed();
+ this.#sessions.delete(object.params.sessionId);
+ this.emit('sessiondetached', session);
+ const parentSession = this.#sessions.get(object.sessionId);
+ if (parentSession) {
+ parentSession.emit('sessiondetached', session);
+ }
+ }
+ }
+ 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 = undefined;
+ this.#transport.onclose = undefined;
+ 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();
+ }
+
+ /**
+ * @internal
+ */
+ isAutoAttached(targetId: string): boolean {
+ return !this.#manuallyAttached.has(targetId);
+ }
+
+ /**
+ * @internal
+ */
+ async _createSession(
+ targetInfo: Protocol.Target.TargetInfo,
+ isAutoAttachEmulated = true
+ ): Promise<CDPSession> {
+ if (!isAutoAttachEmulated) {
+ this.#manuallyAttached.add(targetInfo.targetId);
+ }
+ const {sessionId} = await this.send('Target.attachToTarget', {
+ targetId: targetInfo.targetId,
+ flatten: true,
+ });
+ this.#manuallyAttached.delete(targetInfo.targetId);
+ const session = this.#sessions.get(sessionId);
+ if (!session) {
+ throw new Error('CDPSession creation failed.');
+ }
+ return session;
+ }
+
+ /**
+ * @param targetInfo - The target info
+ * @returns The CDP session that is created
+ */
+ async createSession(
+ targetInfo: Protocol.Target.TargetInfo
+ ): Promise<CDPSession> {
+ return await this._createSession(targetInfo, false);
+ }
+}
+
+/**
+ * @public
+ */
+export interface CDPSessionOnMessageObject {
+ id?: number;
+ method: string;
+ params: Record<string, unknown>;
+ error: {message: string; data: any; code: number};
+ 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/HEAD/README.md | Getting Started with DevTools Protocol}.
+ *
+ * @example
+ *
+ * ```ts
+ * const client = await page.target().createCDPSession();
+ * await client.send('Animation.enable');
+ * client.on('Animation.animationCreated', () =>
+ * console.log('Animation created!')
+ * );
+ * const response = await client.send('Animation.getPlaybackRate');
+ * console.log('playback rate is ' + response.playbackRate);
+ * await client.send('Animation.setPlaybackRate', {
+ * playbackRate: response.playbackRate / 2,
+ * });
+ * ```
+ *
+ * @public
+ */
+export class CDPSession extends EventEmitter {
+ #sessionId: string;
+ #targetType: string;
+ #callbacks: Map<number, ConnectionCallback> = new Map();
+ #connection?: Connection;
+
+ /**
+ * @internal
+ */
+ constructor(connection: Connection, targetType: string, sessionId: string) {
+ super();
+ this.#connection = connection;
+ this.#targetType = targetType;
+ this.#sessionId = sessionId;
+ }
+
+ connection(): Connection | undefined {
+ return this.#connection;
+ }
+
+ 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,
+ params,
+ });
+
+ return new Promise((resolve, reject) => {
+ this.#callbacks.set(id, {
+ resolve,
+ reject,
+ error: new ProtocolError(),
+ method,
+ });
+ });
+ }
+
+ /**
+ * @internal
+ */
+ _onMessage(object: CDPSessionOnMessageObject): void {
+ const callback = object.id ? this.#callbacks.get(object.id) : undefined;
+ if (object.id && callback) {
+ 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 = undefined;
+ this.emit(CDPSessionEmittedEvents.Disconnected);
+ }
+
+ /**
+ * Returns the session's id.
+ */
+ id(): string {
+ return this.#sessionId;
+ }
+}
+
+function createProtocolError(
+ error: ProtocolError,
+ method: string,
+ object: {error: {message: string; data: any; code: number}}
+): Error {
+ let message = `Protocol error (${method}): ${object.error.message}`;
+ if ('data' in object.error) {
+ message += ` ${object.error.data}`;
+ }
+ return rewriteError(error, message, object.error.message);
+}
+
+function rewriteError(
+ error: ProtocolError,
+ message: string,
+ originalMessage?: string
+): Error {
+ error.message = message;
+ error.originalMessage = originalMessage ?? error.originalMessage;
+ 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..753379fd56
--- /dev/null
+++ b/remote/test/puppeteer/src/common/ConnectionTransport.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.
+ */
+
+/**
+ * @public
+ */
+export interface ConnectionTransport {
+ send(message: string): void;
+ close(): void;
+ 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..4616eea6dd
--- /dev/null
+++ b/remote/test/puppeteer/src/common/ConsoleMessage.ts
@@ -0,0 +1,123 @@
+/**
+ * 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.
+ * @public
+ */
+export type ConsoleMessageType =
+ | 'log'
+ | 'debug'
+ | 'info'
+ | 'error'
+ | 'warning'
+ | 'dir'
+ | 'dirxml'
+ | 'table'
+ | 'trace'
+ | 'clear'
+ | 'startGroup'
+ | 'startGroupCollapsed'
+ | 'endGroup'
+ | 'assert'
+ | 'profile'
+ | 'profileEnd'
+ | 'count'
+ | 'timeEnd'
+ | 'verbose';
+
+/**
+ * ConsoleMessage objects are dispatched by page via the 'console' event.
+ * @public
+ */
+export class ConsoleMessage {
+ #type: ConsoleMessageType;
+ #text: string;
+ #args: JSHandle[];
+ #stackTraceLocations: ConsoleMessageLocation[];
+
+ /**
+ * @public
+ */
+ constructor(
+ type: ConsoleMessageType,
+ text: string,
+ args: JSHandle[],
+ stackTraceLocations: ConsoleMessageLocation[]
+ ) {
+ this.#type = type;
+ this.#text = text;
+ this.#args = args;
+ this.#stackTraceLocations = stackTraceLocations;
+ }
+
+ /**
+ * @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[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..a75a772a69
--- /dev/null
+++ b/remote/test/puppeteer/src/common/Coverage.ts
@@ -0,0 +1,489 @@
+/**
+ * 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 '../util/assert.js';
+import {addEventListener, debugError, PuppeteerEventListener} from './util.js';
+import {Protocol} from 'devtools-protocol';
+import {CDPSession} from './Connection.js';
+
+import {EVALUATION_SCRIPT_URL} from './ExecutionContext.js';
+import {removeEventListeners} from './util.js';
+
+/**
+ * @internal
+ */
+export {PuppeteerEventListener};
+
+/**
+ * The CoverageEntry class represents one entry of the coverage report.
+ * @public
+ */
+export interface CoverageEntry {
+ /**
+ * The URL of the style sheet or script.
+ */
+ url: string;
+ /**
+ * The content of the style sheet or script.
+ */
+ text: string;
+ /**
+ * The covered range as start and end positions.
+ */
+ ranges: Array<{start: number; end: number}>;
+}
+
+/**
+ * The CoverageEntry class for JavaScript
+ * @public
+ */
+export interface JSCoverageEntry extends CoverageEntry {
+ /**
+ * Raw V8 script coverage entry.
+ */
+ rawScriptCoverage?: Protocol.Profiler.ScriptCoverage;
+}
+
+/**
+ * Set of configurable options for JS coverage.
+ * @public
+ */
+export interface JSCoverageOptions {
+ /**
+ * Whether to reset coverage on every navigation.
+ */
+ resetOnNavigation?: boolean;
+ /**
+ * Whether anonymous scripts generated by the page should be reported.
+ */
+ reportAnonymousScripts?: boolean;
+ /**
+ * Whether the result includes raw V8 script coverage entries.
+ */
+ includeRawScriptCoverage?: boolean;
+}
+
+/**
+ * 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:
+ *
+ * ```ts
+ * // Enable both JavaScript and CSS coverage
+ * await Promise.all([
+ * page.coverage.startJSCoverage(),
+ * page.coverage.startCSSCoverage(),
+ * ]);
+ * // Navigate to page
+ * await page.goto('https://example.com');
+ * // Disable both JavaScript and CSS coverage
+ * const [jsCoverage, cssCoverage] = await Promise.all([
+ * page.coverage.stopJSCoverage(),
+ * page.coverage.stopCSSCoverage(),
+ * ]);
+ * let totalBytes = 0;
+ * let usedBytes = 0;
+ * const coverage = [...jsCoverage, ...cssCoverage];
+ * for (const entry of coverage) {
+ * totalBytes += entry.text.length;
+ * for (const range of entry.ranges) usedBytes += range.end - range.start - 1;
+ * }
+ * console.log(`Bytes used: ${(usedBytes / totalBytes) * 100}%`);
+ * ```
+ *
+ * @public
+ */
+export class Coverage {
+ #jsCoverage: JSCoverage;
+ #cssCoverage: CSSCoverage;
+
+ constructor(client: CDPSession) {
+ this.#jsCoverage = new JSCoverage(client);
+ this.#cssCoverage = new CSSCoverage(client);
+ }
+
+ /**
+ * @param options - Set of configurable options for coverage 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 `pptr://__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<JSCoverageEntry[]> {
+ return await this.#jsCoverage.stop();
+ }
+
+ /**
+ * @param options - Set of configurable options for coverage, defaults to
+ * `resetOnNavigation : true`
+ * @returns Promise that resolves when coverage is started.
+ */
+ async startCSSCoverage(options: CSSCoverageOptions = {}): Promise<void> {
+ return await this.#cssCoverage.start(options);
+ }
+
+ /**
+ * @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();
+ }
+}
+
+/**
+ * @public
+ */
+export class JSCoverage {
+ #client: CDPSession;
+ #enabled = false;
+ #scriptURLs = new Map<string, string>();
+ #scriptSources = new Map<string, string>();
+ #eventListeners: PuppeteerEventListener[] = [];
+ #resetOnNavigation = false;
+ #reportAnonymousScripts = false;
+ #includeRawScriptCoverage = false;
+
+ constructor(client: CDPSession) {
+ this.#client = client;
+ }
+
+ async start(
+ options: {
+ resetOnNavigation?: boolean;
+ reportAnonymousScripts?: boolean;
+ includeRawScriptCoverage?: boolean;
+ } = {}
+ ): Promise<void> {
+ assert(!this.#enabled, 'JSCoverage is already enabled');
+ const {
+ resetOnNavigation = true,
+ reportAnonymousScripts = false,
+ includeRawScriptCoverage = false,
+ } = options;
+ this.#resetOnNavigation = resetOnNavigation;
+ this.#reportAnonymousScripts = reportAnonymousScripts;
+ this.#includeRawScriptCoverage = includeRawScriptCoverage;
+ this.#enabled = true;
+ this.#scriptURLs.clear();
+ this.#scriptSources.clear();
+ this.#eventListeners = [
+ addEventListener(
+ this.#client,
+ 'Debugger.scriptParsed',
+ this.#onScriptParsed.bind(this)
+ ),
+ addEventListener(
+ this.#client,
+ 'Runtime.executionContextsCleared',
+ this.#onExecutionContextsCleared.bind(this)
+ ),
+ ];
+ await Promise.all([
+ this.#client.send('Profiler.enable'),
+ this.#client.send('Profiler.startPreciseCoverage', {
+ callCount: this.#includeRawScriptCoverage,
+ detailed: 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<JSCoverageEntry[]> {
+ assert(this.#enabled, 'JSCoverage is not enabled');
+ this.#enabled = false;
+
+ const result = await Promise.all([
+ this.#client.send('Profiler.takePreciseCoverage'),
+ this.#client.send('Profiler.stopPreciseCoverage'),
+ this.#client.send('Profiler.disable'),
+ this.#client.send('Debugger.disable'),
+ ]);
+
+ 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);
+ if (!this.#includeRawScriptCoverage) {
+ coverage.push({url, ranges, text});
+ } else {
+ coverage.push({url, ranges, text, rawScriptCoverage: entry});
+ }
+ }
+ return coverage;
+ }
+}
+
+/**
+ * @public
+ */
+export class CSSCoverage {
+ #client: CDPSession;
+ #enabled = false;
+ #stylesheetURLs = new Map<string, string>();
+ #stylesheetSources = new Map<string, string>();
+ #eventListeners: PuppeteerEventListener[] = [];
+ #resetOnNavigation = 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 = [
+ addEventListener(
+ this.#client,
+ 'CSS.styleSheetAdded',
+ this.#onStyleSheet.bind(this)
+ ),
+ 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'),
+ ]);
+ 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: CoverageEntry[] = [];
+ for (const styleSheetId of this.#stylesheetURLs.keys()) {
+ const url = this.#stylesheetURLs.get(styleSheetId);
+ assert(
+ typeof url !== 'undefined',
+ `Stylesheet URL is undefined (styleSheetId=${styleSheetId})`
+ );
+ const text = this.#stylesheetSources.get(styleSheetId);
+ assert(
+ typeof text !== 'undefined',
+ `Stylesheet text is undefined (styleSheetId=${styleSheetId})`
+ );
+ const ranges = convertToDisjointRanges(
+ styleSheetIdToCoverage.get(styleSheetId) || []
+ );
+ coverage.push({url, ranges, text});
+ }
+
+ return coverage;
+ }
+}
+
+function convertToDisjointRanges(
+ nestedRanges: Array<{startOffset: number; endOffset: number; count: number}>
+): Array<{start: number; end: number}> {
+ const points = [];
+ for (const range of nestedRanges) {
+ points.push({offset: range.startOffset, type: 0, range});
+ points.push({offset: range.endOffset, type: 1, range});
+ }
+ // Sort points to form a valid parenthesis sequence.
+ points.sort((a, b) => {
+ // Sort with increasing offsets.
+ if (a.offset !== b.offset) {
+ return a.offset - b.offset;
+ }
+ // All "end" points should go before "start" points.
+ if (a.type !== b.type) {
+ return b.type - a.type;
+ }
+ const aLength = a.range.endOffset - a.range.startOffset;
+ const bLength = b.range.endOffset - b.range.startOffset;
+ // For two "start" points, the one with longer range goes first.
+ if (a.type === 0) {
+ return bLength - aLength;
+ }
+ // For two "end" points, the one with shorter range goes first.
+ return aLength - bLength;
+ });
+
+ const hitCountStack = [];
+ const results: Array<{
+ start: number;
+ end: number;
+ }> = [];
+ let lastOffset = 0;
+ // Run scanning line to intersect all ranges.
+ for (const point of points) {
+ if (
+ hitCountStack.length &&
+ lastOffset < point.offset &&
+ hitCountStack[hitCountStack.length - 1]! > 0
+ ) {
+ const lastResult = results[results.length - 1];
+ if (lastResult && lastResult.end === lastOffset) {
+ lastResult.end = point.offset;
+ } else {
+ results.push({start: lastOffset, end: point.offset});
+ }
+ }
+ lastOffset = point.offset;
+ if (point.type === 0) {
+ hitCountStack.push(point.range.count);
+ } else {
+ hitCountStack.pop();
+ }
+ }
+ // Filter out empty ranges.
+ return results.filter(range => {
+ return range.end - range.start > 0;
+ });
+}
diff --git a/remote/test/puppeteer/src/common/Debug.ts b/remote/test/puppeteer/src/common/Debug.ts
new file mode 100644
index 0000000000..425b7c305a
--- /dev/null
+++ b/remote/test/puppeteer/src/common/Debug.ts
@@ -0,0 +1,109 @@
+/**
+ * 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';
+
+declare global {
+ // eslint-disable-next-line no-var
+ var __PUPPETEER_DEBUG: string;
+}
+
+/**
+ * @internal
+ */
+let debugModule: typeof import('debug') | null = null;
+/**
+ * @internal
+ */
+export async function importDebug(): Promise<typeof import('debug')> {
+ if (!debugModule) {
+ debugModule = (await import('debug')).default;
+ }
+ return debugModule;
+}
+
+/**
+ * A debug function that can be used in any environment.
+ *
+ * @remarks
+ * If used in Node, it falls back to the
+ * {@link https://www.npmjs.com/package/debug | debug module}. In the browser it
+ * uses `console.log`.
+ *
+ * In Node, use the `DEBUG` environment variable to control logging:
+ *
+ * ```
+ * DEBUG=* // logs all channels
+ * DEBUG=foo // logs the `foo` channel
+ * DEBUG=foo* // logs any channels starting with `foo`
+ * ```
+ *
+ * In the browser, set `window.__PUPPETEER_DEBUG` to a string:
+ *
+ * ```
+ * window.__PUPPETEER_DEBUG='*'; // logs all channels
+ * window.__PUPPETEER_DEBUG='foo'; // logs the `foo` channel
+ * window.__PUPPETEER_DEBUG='foo*'; // logs any channels starting with `foo`
+ * ```
+ *
+ * @example
+ *
+ * ```
+ * const log = debug('Page');
+ *
+ * log('new page created')
+ * // logs "Page: new page created"
+ * ```
+ *
+ * @param prefix - this will be prefixed to each log.
+ * @returns a function that can be called to log to that debug channel.
+ *
+ * @internal
+ */
+export const debug = (prefix: string): ((...args: unknown[]) => void) => {
+ if (isNode) {
+ return async (...logArgs: unknown[]) => {
+ (await importDebug())(prefix)(logArgs);
+ };
+ }
+
+ return (...logArgs: unknown[]): void => {
+ const debugLevel = (globalThis as any).__PUPPETEER_DEBUG;
+ if (!debugLevel) {
+ return;
+ }
+
+ const everythingShouldBeLogged = debugLevel === '*';
+
+ const prefixMatchesDebugLevel =
+ everythingShouldBeLogged ||
+ /**
+ * If the debug level is `foo*`, that means we match any prefix that
+ * starts with `foo`. If the level is `foo`, we match only the prefix
+ * `foo`.
+ */
+ (debugLevel.endsWith('*')
+ ? prefix.startsWith(debugLevel)
+ : prefix === debugLevel);
+
+ if (!prefixMatchesDebugLevel) {
+ return;
+ }
+
+ // eslint-disable-next-line no-console
+ console.log(`${prefix}:`, ...logArgs);
+ };
+};
diff --git a/remote/test/puppeteer/src/common/DeviceDescriptors.ts b/remote/test/puppeteer/src/common/DeviceDescriptors.ts
new file mode 100644
index 0000000000..58cd9dde4c
--- /dev/null
+++ b/remote/test/puppeteer/src/common/DeviceDescriptors.ts
@@ -0,0 +1,1565 @@
+/**
+ * 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.
+ */
+
+/**
+ * @public
+ */
+export interface Device {
+ name: string;
+ userAgent: string;
+ viewport: {
+ width: number;
+ height: number;
+ deviceScaleFactor: number;
+ isMobile: boolean;
+ hasTouch: boolean;
+ isLandscape: boolean;
+ };
+}
+
+const deviceArray: 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: 'Galaxy S8',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.84 Mobile Safari/537.36',
+ viewport: {
+ width: 360,
+ height: 740,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'Galaxy S8 landscape',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 7.0; SM-G950U Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.84 Mobile Safari/537.36',
+ viewport: {
+ width: 740,
+ height: 360,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'Galaxy S9+',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.111 Mobile Safari/537.36',
+ viewport: {
+ width: 320,
+ height: 658,
+ deviceScaleFactor: 4.5,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'Galaxy S9+ landscape',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.111 Mobile Safari/537.36',
+ viewport: {
+ width: 658,
+ height: 320,
+ deviceScaleFactor: 4.5,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'Galaxy Tab S4',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.80 Safari/537.36',
+ viewport: {
+ width: 712,
+ height: 1138,
+ deviceScaleFactor: 2.25,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'Galaxy Tab S4 landscape',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 8.1.0; SM-T837A) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.80 Safari/537.36',
+ viewport: {
+ width: 1138,
+ height: 712,
+ deviceScaleFactor: 2.25,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPad',
+ userAgent:
+ 'Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1',
+ viewport: {
+ width: 768,
+ height: 1024,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPad landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1',
+ viewport: {
+ width: 1024,
+ height: 768,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPad (gen 6)',
+ userAgent:
+ 'Mozilla/5.0 (iPad; CPU OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 768,
+ height: 1024,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPad (gen 6) landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPad; CPU OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 1024,
+ height: 768,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPad (gen 7)',
+ userAgent:
+ 'Mozilla/5.0 (iPad; CPU OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 810,
+ height: 1080,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPad (gen 7) landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPad; CPU OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 1080,
+ height: 810,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPad Mini',
+ userAgent:
+ 'Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1',
+ viewport: {
+ width: 768,
+ height: 1024,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPad Mini landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1',
+ viewport: {
+ width: 1024,
+ height: 768,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPad Pro',
+ userAgent:
+ 'Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1',
+ viewport: {
+ width: 1024,
+ height: 1366,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPad Pro landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1',
+ viewport: {
+ width: 1366,
+ height: 1024,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPad Pro 11',
+ userAgent:
+ 'Mozilla/5.0 (iPad; CPU OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 834,
+ height: 1194,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPad Pro 11 landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPad; CPU OS 12_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 1194,
+ height: 834,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPhone 4',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 7_1_2 like Mac OS X) AppleWebKit/537.51.2 (KHTML, like Gecko) Version/7.0 Mobile/11D257 Safari/9537.53',
+ viewport: {
+ width: 320,
+ height: 480,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPhone 4 landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 7_1_2 like Mac OS X) AppleWebKit/537.51.2 (KHTML, like Gecko) Version/7.0 Mobile/11D257 Safari/9537.53',
+ viewport: {
+ width: 480,
+ height: 320,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPhone 5',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1',
+ viewport: {
+ width: 320,
+ height: 568,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPhone 5 landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1',
+ viewport: {
+ width: 568,
+ height: 320,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPhone 6',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1',
+ viewport: {
+ width: 375,
+ height: 667,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPhone 6 landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1',
+ viewport: {
+ width: 667,
+ height: 375,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPhone 6 Plus',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1',
+ viewport: {
+ width: 414,
+ height: 736,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPhone 6 Plus landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1',
+ viewport: {
+ width: 736,
+ height: 414,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPhone 7',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1',
+ viewport: {
+ width: 375,
+ height: 667,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPhone 7 landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1',
+ viewport: {
+ width: 667,
+ height: 375,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPhone 7 Plus',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1',
+ viewport: {
+ width: 414,
+ height: 736,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPhone 7 Plus landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1',
+ viewport: {
+ width: 736,
+ height: 414,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPhone 8',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1',
+ viewport: {
+ width: 375,
+ height: 667,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPhone 8 landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1',
+ viewport: {
+ width: 667,
+ height: 375,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPhone 8 Plus',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1',
+ viewport: {
+ width: 414,
+ height: 736,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPhone 8 Plus landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1',
+ viewport: {
+ width: 736,
+ height: 414,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPhone SE',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1',
+ viewport: {
+ width: 320,
+ height: 568,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPhone SE landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1',
+ viewport: {
+ width: 568,
+ height: 320,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPhone X',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1',
+ viewport: {
+ width: 375,
+ height: 812,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPhone X landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1',
+ viewport: {
+ width: 812,
+ height: 375,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPhone XR',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 12_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.0 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 414,
+ height: 896,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPhone XR landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 12_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.0 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 896,
+ height: 414,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPhone 11',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 414,
+ height: 828,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPhone 11 landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 828,
+ height: 414,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPhone 11 Pro',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 375,
+ height: 812,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPhone 11 Pro landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 812,
+ height: 375,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPhone 11 Pro Max',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 414,
+ height: 896,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPhone 11 Pro Max landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 896,
+ height: 414,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPhone 12',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 390,
+ height: 844,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPhone 12 landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 844,
+ height: 390,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPhone 12 Pro',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 390,
+ height: 844,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPhone 12 Pro landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 844,
+ height: 390,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPhone 12 Pro Max',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 428,
+ height: 926,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPhone 12 Pro Max landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 926,
+ height: 428,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPhone 12 Mini',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 375,
+ height: 812,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPhone 12 Mini landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 812,
+ height: 375,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPhone 13',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 390,
+ height: 844,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPhone 13 landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 844,
+ height: 390,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPhone 13 Pro',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 390,
+ height: 844,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPhone 13 Pro landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 844,
+ height: 390,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPhone 13 Pro Max',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 428,
+ height: 926,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPhone 13 Pro Max landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 926,
+ height: 428,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'iPhone 13 Mini',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 375,
+ height: 812,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'iPhone 13 Mini landscape',
+ userAgent:
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1',
+ viewport: {
+ width: 812,
+ height: 375,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'JioPhone 2',
+ userAgent:
+ 'Mozilla/5.0 (Mobile; LYF/F300B/LYF-F300B-001-01-15-130718-i;Android; rv:48.0) Gecko/48.0 Firefox/48.0 KAIOS/2.5',
+ viewport: {
+ width: 240,
+ height: 320,
+ deviceScaleFactor: 1,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'JioPhone 2 landscape',
+ userAgent:
+ 'Mozilla/5.0 (Mobile; LYF/F300B/LYF-F300B-001-01-15-130718-i;Android; rv:48.0) Gecko/48.0 Firefox/48.0 KAIOS/2.5',
+ viewport: {
+ width: 320,
+ height: 240,
+ deviceScaleFactor: 1,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'Kindle Fire HDX',
+ userAgent:
+ 'Mozilla/5.0 (Linux; U; en-us; KFAPWI Build/JDQ39) AppleWebKit/535.19 (KHTML, like Gecko) Silk/3.13 Safari/535.19 Silk-Accelerated=true',
+ viewport: {
+ width: 800,
+ height: 1280,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'Kindle Fire HDX landscape',
+ userAgent:
+ 'Mozilla/5.0 (Linux; U; en-us; KFAPWI Build/JDQ39) AppleWebKit/535.19 (KHTML, like Gecko) Silk/3.13 Safari/535.19 Silk-Accelerated=true',
+ viewport: {
+ width: 1280,
+ height: 800,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'LG Optimus L70',
+ userAgent:
+ 'Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/75.0.3765.0 Mobile Safari/537.36',
+ viewport: {
+ width: 384,
+ height: 640,
+ deviceScaleFactor: 1.25,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'LG Optimus L70 landscape',
+ userAgent:
+ 'Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/75.0.3765.0 Mobile Safari/537.36',
+ viewport: {
+ width: 640,
+ height: 384,
+ deviceScaleFactor: 1.25,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'Microsoft Lumia 550',
+ userAgent:
+ 'Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2486.0 Mobile Safari/537.36 Edge/14.14263',
+ viewport: {
+ width: 640,
+ height: 360,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'Microsoft Lumia 950',
+ userAgent:
+ 'Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2486.0 Mobile Safari/537.36 Edge/14.14263',
+ viewport: {
+ width: 360,
+ height: 640,
+ deviceScaleFactor: 4,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'Microsoft Lumia 950 landscape',
+ userAgent:
+ 'Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2486.0 Mobile Safari/537.36 Edge/14.14263',
+ viewport: {
+ width: 640,
+ height: 360,
+ deviceScaleFactor: 4,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'Nexus 10',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Safari/537.36',
+ viewport: {
+ width: 800,
+ height: 1280,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'Nexus 10 landscape',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Safari/537.36',
+ viewport: {
+ width: 1280,
+ height: 800,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'Nexus 4',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36',
+ viewport: {
+ width: 384,
+ height: 640,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'Nexus 4 landscape',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36',
+ viewport: {
+ width: 640,
+ height: 384,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'Nexus 5',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36',
+ viewport: {
+ width: 360,
+ height: 640,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'Nexus 5 landscape',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36',
+ viewport: {
+ width: 640,
+ height: 360,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'Nexus 5X',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36',
+ viewport: {
+ width: 412,
+ height: 732,
+ deviceScaleFactor: 2.625,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'Nexus 5X landscape',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36',
+ viewport: {
+ width: 732,
+ height: 412,
+ deviceScaleFactor: 2.625,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'Nexus 6',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36',
+ viewport: {
+ width: 412,
+ height: 732,
+ deviceScaleFactor: 3.5,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'Nexus 6 landscape',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36',
+ viewport: {
+ width: 732,
+ height: 412,
+ deviceScaleFactor: 3.5,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'Nexus 6P',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36',
+ viewport: {
+ width: 412,
+ height: 732,
+ deviceScaleFactor: 3.5,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'Nexus 6P landscape',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36',
+ viewport: {
+ width: 732,
+ height: 412,
+ deviceScaleFactor: 3.5,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'Nexus 7',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Safari/537.36',
+ viewport: {
+ width: 600,
+ height: 960,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'Nexus 7 landscape',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Safari/537.36',
+ viewport: {
+ width: 960,
+ height: 600,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'Nokia Lumia 520',
+ userAgent:
+ 'Mozilla/5.0 (compatible; MSIE 10.0; Windows Phone 8.0; Trident/6.0; IEMobile/10.0; ARM; Touch; NOKIA; Lumia 520)',
+ viewport: {
+ width: 320,
+ height: 533,
+ deviceScaleFactor: 1.5,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'Nokia Lumia 520 landscape',
+ userAgent:
+ 'Mozilla/5.0 (compatible; MSIE 10.0; Windows Phone 8.0; Trident/6.0; IEMobile/10.0; ARM; Touch; NOKIA; Lumia 520)',
+ viewport: {
+ width: 533,
+ height: 320,
+ deviceScaleFactor: 1.5,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'Nokia N9',
+ userAgent:
+ 'Mozilla/5.0 (MeeGo; NokiaN9) AppleWebKit/534.13 (KHTML, like Gecko) NokiaBrowser/8.5.0 Mobile Safari/534.13',
+ viewport: {
+ width: 480,
+ height: 854,
+ deviceScaleFactor: 1,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'Nokia N9 landscape',
+ userAgent:
+ 'Mozilla/5.0 (MeeGo; NokiaN9) AppleWebKit/534.13 (KHTML, like Gecko) NokiaBrowser/8.5.0 Mobile Safari/534.13',
+ viewport: {
+ width: 854,
+ height: 480,
+ deviceScaleFactor: 1,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'Pixel 2',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36',
+ viewport: {
+ width: 411,
+ height: 731,
+ deviceScaleFactor: 2.625,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'Pixel 2 landscape',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36',
+ viewport: {
+ width: 731,
+ height: 411,
+ deviceScaleFactor: 2.625,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'Pixel 2 XL',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36',
+ viewport: {
+ width: 411,
+ height: 823,
+ deviceScaleFactor: 3.5,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'Pixel 2 XL landscape',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36',
+ viewport: {
+ width: 823,
+ height: 411,
+ deviceScaleFactor: 3.5,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'Pixel 3',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.158 Mobile Safari/537.36',
+ viewport: {
+ width: 393,
+ height: 786,
+ deviceScaleFactor: 2.75,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'Pixel 3 landscape',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 9; Pixel 3 Build/PQ1A.181105.017.A1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.158 Mobile Safari/537.36',
+ viewport: {
+ width: 786,
+ height: 393,
+ deviceScaleFactor: 2.75,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'Pixel 4',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.138 Mobile Safari/537.36',
+ viewport: {
+ width: 353,
+ height: 745,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'Pixel 4 landscape',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.138 Mobile Safari/537.36',
+ viewport: {
+ width: 745,
+ height: 353,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'Pixel 4a (5G)',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4812.0 Mobile Safari/537.36',
+ viewport: {
+ width: 353,
+ height: 745,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'Pixel 4a (5G) landscape',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 11; Pixel 4a (5G)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4812.0 Mobile Safari/537.36',
+ viewport: {
+ width: 745,
+ height: 353,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'Pixel 5',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4812.0 Mobile Safari/537.36',
+ viewport: {
+ width: 393,
+ height: 851,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'Pixel 5 landscape',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4812.0 Mobile Safari/537.36',
+ viewport: {
+ width: 851,
+ height: 393,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+ {
+ name: 'Moto G4',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4812.0 Mobile Safari/537.36',
+ viewport: {
+ width: 360,
+ height: 640,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: false,
+ },
+ },
+ {
+ name: 'Moto G4 landscape',
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 7.0; Moto G (4)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4812.0 Mobile Safari/537.36',
+ viewport: {
+ width: 640,
+ height: 360,
+ deviceScaleFactor: 3,
+ isMobile: true,
+ hasTouch: true,
+ isLandscape: true,
+ },
+ },
+];
+
+/**
+ * @public
+ */
+export type DevicesMap = {
+ [name: string]: Device;
+};
+
+/**
+ * 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
+ *
+ * ```ts
+ * 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();
+ * })();
+ * ```
+ *
+ * @public
+ */
+const devices: DevicesMap = {};
+
+for (const device of deviceArray) {
+ devices[device.name] = device;
+}
+
+export {devices};
diff --git a/remote/test/puppeteer/src/common/Dialog.ts b/remote/test/puppeteer/src/common/Dialog.ts
new file mode 100644
index 0000000000..6d84fd0d0b
--- /dev/null
+++ b/remote/test/puppeteer/src/common/Dialog.ts
@@ -0,0 +1,114 @@
+/**
+ * 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 '../util/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
+ *
+ * ```ts
+ * 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'));
+ * })();
+ * ```
+ *
+ * @public
+ */
+export class Dialog {
+ #client: CDPSession;
+ #type: Protocol.Page.DialogType;
+ #message: string;
+ #defaultValue: string;
+ #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/ElementHandle.ts b/remote/test/puppeteer/src/common/ElementHandle.ts
new file mode 100644
index 0000000000..8cb19fa042
--- /dev/null
+++ b/remote/test/puppeteer/src/common/ElementHandle.ts
@@ -0,0 +1,1039 @@
+import {Protocol} from 'devtools-protocol';
+import {assert} from '../util/assert.js';
+import {ExecutionContext} from './ExecutionContext.js';
+import {Frame} from './Frame.js';
+import {FrameManager} from './FrameManager.js';
+import {WaitForSelectorOptions} from './IsolatedWorld.js';
+import {
+ BoundingBox,
+ BoxModel,
+ ClickOptions,
+ JSHandle,
+ Offset,
+ Point,
+ PressOptions,
+} from './JSHandle.js';
+import {Page, ScreenshotOptions} from './Page.js';
+import {getQueryHandlerAndSelector} from './QueryHandler.js';
+import {EvaluateFunc, HandleFor, NodeFor} from './types.js';
+import {KeyInput} from './USKeyboardLayout.js';
+import {debugError, isString} from './util.js';
+
+const applyOffsetsToQuad = (
+ quad: Point[],
+ offsetX: number,
+ offsetY: number
+) => {
+ return quad.map(part => {
+ return {x: part.x + offsetX, y: part.y + offsetY};
+ });
+};
+
+/**
+ * ElementHandle represents an in-page DOM element.
+ *
+ * @remarks
+ * ElementHandles can be created with the {@link Page.$} method.
+ *
+ * ```ts
+ * 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 Node = Element
+> extends JSHandle<ElementType> {
+ #frame: Frame;
+
+ /**
+ * @internal
+ */
+ constructor(
+ context: ExecutionContext,
+ remoteObject: Protocol.Runtime.RemoteObject,
+ frame: Frame
+ ) {
+ super(context, remoteObject);
+ this.#frame = frame;
+ }
+
+ get #frameManager(): FrameManager {
+ return this.#frame._frameManager;
+ }
+
+ get #page(): Page {
+ return this.#frame.page();
+ }
+
+ get frame(): Frame {
+ return this.#frame;
+ }
+
+ /**
+ * Queries the current element for an element matching the given selector.
+ *
+ * @param selector - The selector to query for.
+ * @returns A {@link ElementHandle | element handle} to the first element
+ * matching the given selector. Otherwise, `null`.
+ */
+ async $<Selector extends string>(
+ selector: Selector
+ ): Promise<ElementHandle<NodeFor<Selector>> | null> {
+ const {updatedSelector, queryHandler} =
+ getQueryHandlerAndSelector(selector);
+ assert(
+ queryHandler.queryOne,
+ 'Cannot handle queries for a single element with the given selector'
+ );
+ return (await queryHandler.queryOne(
+ this,
+ updatedSelector
+ )) as ElementHandle<NodeFor<Selector>> | null;
+ }
+
+ /**
+ * Queries the current element for all elements matching the given selector.
+ *
+ * @param selector - The selector to query for.
+ * @returns An array of {@link ElementHandle | element handles} that point to
+ * elements matching the given selector.
+ */
+ async $$<Selector extends string>(
+ selector: Selector
+ ): Promise<Array<ElementHandle<NodeFor<Selector>>>> {
+ const {updatedSelector, queryHandler} =
+ getQueryHandlerAndSelector(selector);
+ assert(
+ queryHandler.queryAll,
+ 'Cannot handle queries for a multiple element with the given selector'
+ );
+ return (await queryHandler.queryAll(this, updatedSelector)) as Array<
+ ElementHandle<NodeFor<Selector>>
+ >;
+ }
+
+ /**
+ * Runs the given function on the first element matching the given selector in
+ * the current element.
+ *
+ * If the given function returns a promise, then this method will wait till
+ * the promise resolves.
+ *
+ * @example
+ *
+ * ```ts
+ * const tweetHandle = await page.$('.tweet');
+ * expect(await tweetHandle.$eval('.like', node => node.innerText)).toBe(
+ * '100'
+ * );
+ * expect(await tweetHandle.$eval('.retweets', node => node.innerText)).toBe(
+ * '10'
+ * );
+ * ```
+ *
+ * @param selector - The selector to query for.
+ * @param pageFunction - The function to be evaluated in this element's page's
+ * context. The first element matching the selector will be passed in as the
+ * first argument.
+ * @param args - Additional arguments to pass to `pageFunction`.
+ * @returns A promise to the result of the function.
+ */
+ async $eval<
+ Selector extends string,
+ Params extends unknown[],
+ Func extends EvaluateFunc<
+ [ElementHandle<NodeFor<Selector>>, ...Params]
+ > = EvaluateFunc<[ElementHandle<NodeFor<Selector>>, ...Params]>
+ >(
+ selector: Selector,
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<Awaited<ReturnType<Func>>> {
+ const elementHandle = await this.$(selector);
+ if (!elementHandle) {
+ throw new Error(
+ `Error: failed to find element matching selector "${selector}"`
+ );
+ }
+ const result = await elementHandle.evaluate(pageFunction, ...args);
+ await elementHandle.dispose();
+ return result;
+ }
+
+ /**
+ * Runs the given function on an array of elements matching the given selector
+ * in the current element.
+ *
+ * If the given function returns a promise, then this method will wait till
+ * the promise resolves.
+ *
+ * @example
+ * HTML:
+ *
+ * ```html
+ * <div class="feed">
+ * <div class="tweet">Hello!</div>
+ * <div class="tweet">Hi!</div>
+ * </div>
+ * ```
+ *
+ * JavaScript:
+ *
+ * ```js
+ * const feedHandle = await page.$('.feed');
+ * expect(
+ * await feedHandle.$$eval('.tweet', nodes => nodes.map(n => n.innerText))
+ * ).toEqual(['Hello!', 'Hi!']);
+ * ```
+ *
+ * @param selector - The selector to query for.
+ * @param pageFunction - The function to be evaluated in the element's page's
+ * context. An array of elements matching the given selector will be passed to
+ * the function as its first argument.
+ * @param args - Additional arguments to pass to `pageFunction`.
+ * @returns A promise to the result of the function.
+ */
+ async $$eval<
+ Selector extends string,
+ Params extends unknown[],
+ Func extends EvaluateFunc<
+ [Array<NodeFor<Selector>>, ...Params]
+ > = EvaluateFunc<[Array<NodeFor<Selector>>, ...Params]>
+ >(
+ selector: Selector,
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<Awaited<ReturnType<Func>>> {
+ const {updatedSelector, queryHandler} =
+ getQueryHandlerAndSelector(selector);
+ assert(
+ queryHandler.queryAll,
+ 'Cannot handle queries for a multiple element with the given selector'
+ );
+ const handles = (await queryHandler.queryAll(
+ this,
+ updatedSelector
+ )) as Array<HandleFor<NodeFor<Selector>>>;
+ const elements = await this.evaluateHandle((_, ...elements) => {
+ return elements;
+ }, ...handles);
+ const [result] = await Promise.all([
+ elements.evaluate(pageFunction, ...args),
+ ...handles.map(handle => {
+ return handle.dispose();
+ }),
+ ]);
+ await elements.dispose();
+ return result;
+ }
+
+ /**
+ * @deprecated Use {@link ElementHandle.$$} with the `xpath` prefix.
+ *
+ * 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<Array<ElementHandle<Node>>> {
+ if (expression.startsWith('//')) {
+ expression = `.${expression}`;
+ }
+ return this.$$(`xpath/${expression}`);
+ }
+
+ /**
+ * Wait for an element matching the given selector to appear in the current
+ * element.
+ *
+ * Unlike {@link Frame.waitForSelector}, this method does not work across
+ * navigations or if the element is detached from DOM.
+ *
+ * @example
+ *
+ * ```ts
+ * 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 query and wait for.
+ * @param options - Options for customizing waiting behavior.
+ * @returns An element matching the given selector.
+ * @throws Throws if an element matching the given selector doesn't appear.
+ */
+ async waitForSelector<Selector extends string>(
+ selector: Selector,
+ options: WaitForSelectorOptions = {}
+ ): Promise<ElementHandle<NodeFor<Selector>> | null> {
+ const {updatedSelector, queryHandler} =
+ getQueryHandlerAndSelector(selector);
+ assert(queryHandler.waitFor, 'Query handler does not support waiting');
+ return (await queryHandler.waitFor(
+ this,
+ updatedSelector,
+ options
+ )) as ElementHandle<NodeFor<Selector>> | null;
+ }
+
+ /**
+ * @deprecated Use {@link ElementHandle.waitForSelector} with the `xpath`
+ * prefix.
+ *
+ * Wait for the `xpath` within the element. If at the moment of calling the
+ * method the `xpath` already exists, the method will return immediately. If
+ * the `xpath` doesn't appear after the `timeout` milliseconds of waiting, the
+ * function will throw.
+ *
+ * If `xpath` starts with `//` instead of `.//`, the dot will be appended
+ * automatically.
+ *
+ * This method works across navigation
+ *
+ * ```ts
+ * const puppeteer = require('puppeteer');
+ * (async () => {
+ * const browser = await puppeteer.launch();
+ * const page = await browser.newPage();
+ * let currentURL;
+ * page
+ * .waitForXPath('//img')
+ * .then(() => console.log('First URL with image: ' + currentURL));
+ * for (currentURL of [
+ * 'https://example.com',
+ * 'https://google.com',
+ * 'https://bbc.com',
+ * ]) {
+ * await page.goto(currentURL);
+ * }
+ * await browser.close();
+ * })();
+ * ```
+ *
+ * @param xpath - A
+ * {@link https://developer.mozilla.org/en-US/docs/Web/XPath | xpath} of an
+ * element to wait for
+ * @param options - Optional waiting parameters
+ * @returns Promise which resolves when element specified by xpath string is
+ * added to DOM. Resolves to `null` if waiting for `hidden: true` and xpath is
+ * not found in DOM.
+ * @remarks
+ * The optional Argument `options` have properties:
+ *
+ * - `visible`: A boolean to wait for element to be present in DOM and to be
+ * visible, i.e. to not have `display: none` or `visibility: hidden` CSS
+ * properties. Defaults to `false`.
+ *
+ * - `hidden`: A boolean wait for element to not be found in the DOM or to be
+ * hidden, i.e. have `display: none` or `visibility: hidden` CSS properties.
+ * Defaults to `false`.
+ *
+ * - `timeout`: A number which is maximum time to wait for in milliseconds.
+ * Defaults to `30000` (30 seconds). Pass `0` to disable timeout. The
+ * default value can be changed by using the {@link Page.setDefaultTimeout}
+ * method.
+ */
+ async waitForXPath(
+ xpath: string,
+ options: {
+ visible?: boolean;
+ hidden?: boolean;
+ timeout?: number;
+ } = {}
+ ): Promise<ElementHandle<Node> | null> {
+ if (xpath.startsWith('//')) {
+ xpath = `.${xpath}`;
+ }
+ return this.waitForSelector(`xpath/${xpath}`, options);
+ }
+
+ override 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);
+ }
+
+ async #scrollIntoViewIfNeeded(this: ElementHandle<Element>): Promise<void> {
+ const error = await this.evaluate(
+ async (element): Promise<string | undefined> => {
+ if (!element.isConnected) {
+ return 'Node is detached from document';
+ }
+ if (element.nodeType !== Node.ELEMENT_NODE) {
+ return 'Node is not of type HTMLElement';
+ }
+ return;
+ }
+ );
+
+ if (error) {
+ throw new Error(error);
+ }
+
+ try {
+ await this.client.send('DOM.scrollIntoViewIfNeeded', {
+ objectId: this.remoteObject().objectId,
+ });
+ } catch (_err) {
+ // Fallback to Element.scrollIntoView if DOM.scrollIntoViewIfNeeded is not supported
+ await this.evaluate(
+ async (element, pageJavascriptEnabled): Promise<void> => {
+ const visibleRatio = async () => {
+ return await new Promise(resolve => {
+ const observer = new IntersectionObserver(entries => {
+ resolve(entries[0]!.intersectionRatio);
+ observer.disconnect();
+ });
+ observer.observe(element);
+ });
+ };
+ if (!pageJavascriptEnabled || (await 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',
+ });
+ }
+ },
+ this.#page.isJavaScriptEnabled()
+ );
+ }
+ }
+
+ async #getOOPIFOffsets(
+ frame: Frame
+ ): Promise<{offsetX: number; offsetY: number}> {
+ let offsetX = 0;
+ let offsetY = 0;
+ let currentFrame: Frame | null = frame;
+ while (currentFrame && currentFrame.parentFrame()) {
+ const parent = currentFrame.parentFrame();
+ if (!currentFrame.isOOPFrame() || !parent) {
+ currentFrame = parent;
+ continue;
+ }
+ const {backendNodeId} = await parent._client().send('DOM.getFrameOwner', {
+ frameId: currentFrame._id,
+ });
+ const result = await parent._client().send('DOM.getBoxModel', {
+ backendNodeId: backendNodeId,
+ });
+ if (!result) {
+ break;
+ }
+ const contentBoxQuad = result.model.content;
+ const topLeftCorner = this.#fromProtocolQuad(contentBoxQuad)[0];
+ offsetX += topLeftCorner!.x;
+ offsetY += topLeftCorner!.y;
+ currentFrame = parent;
+ }
+ return {offsetX, offsetY};
+ }
+
+ /**
+ * Returns the middle point within an element unless a specific offset is provided.
+ */
+ async clickablePoint(offset?: Offset): Promise<Point> {
+ const [result, layoutMetrics] = await Promise.all([
+ this.client
+ .send('DOM.getContentQuads', {
+ objectId: this.remoteObject().objectId,
+ })
+ .catch(debugError),
+ this.#page._client().send('Page.getLayoutMetrics'),
+ ]);
+ if (!result || !result.quads.length) {
+ throw new Error('Node is either not clickable or not an HTMLElement');
+ }
+ // Filter out quads that have too small area to click into.
+ // Fallback to `layoutViewport` in case of using Firefox.
+ const {clientWidth, clientHeight} =
+ layoutMetrics.cssLayoutViewport || layoutMetrics.layoutViewport;
+ const {offsetX, offsetY} = await this.#getOOPIFOffsets(this.#frame);
+ const quads = result.quads
+ .map(quad => {
+ return this.#fromProtocolQuad(quad);
+ })
+ .map(quad => {
+ return applyOffsetsToQuad(quad, offsetX, offsetY);
+ })
+ .map(quad => {
+ return this.#intersectQuadWithViewport(quad, clientWidth, clientHeight);
+ })
+ .filter(quad => {
+ return computeQuadArea(quad) > 1;
+ });
+ if (!quads.length) {
+ throw new Error('Node is either not clickable or not an HTMLElement');
+ }
+ const quad = quads[0]!;
+ if (offset) {
+ // Return the point of the first quad identified by offset.
+ let minX = Number.MAX_SAFE_INTEGER;
+ let minY = Number.MAX_SAFE_INTEGER;
+ for (const point of quad) {
+ if (point.x < minX) {
+ minX = point.x;
+ }
+ if (point.y < minY) {
+ minY = point.y;
+ }
+ }
+ if (
+ minX !== Number.MAX_SAFE_INTEGER &&
+ minY !== Number.MAX_SAFE_INTEGER
+ ) {
+ return {
+ x: minX + offset.x,
+ y: minY + offset.y,
+ };
+ }
+ }
+ // Return the middle point of the first quad.
+ let x = 0;
+ let y = 0;
+ for (const point of quad) {
+ x += point.x;
+ y += point.y;
+ }
+ return {
+ x: x / 4,
+ y: y / 4,
+ };
+ }
+
+ #getBoxModel(): Promise<void | Protocol.DOM.GetBoxModelResponse> {
+ const params: Protocol.DOM.GetBoxModelRequest = {
+ objectId: this.remoteObject().objectId,
+ };
+ return this.client.send('DOM.getBoxModel', params).catch(error => {
+ return debugError(error);
+ });
+ }
+
+ #fromProtocolQuad(quad: number[]): Point[] {
+ 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]!},
+ ];
+ }
+
+ #intersectQuadWithViewport(
+ quad: Point[],
+ width: number,
+ height: number
+ ): Point[] {
+ return quad.map(point => {
+ return {
+ 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(this: ElementHandle<Element>): 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(
+ this: ElementHandle<Element>,
+ options: ClickOptions = {}
+ ): Promise<void> {
+ await this.#scrollIntoViewIfNeeded();
+ const {x, y} = await this.clickablePoint(options.offset);
+ await this.#page.mouse.click(x, y, options);
+ }
+
+ /**
+ * This method creates and captures a dragevent from the element.
+ */
+ async drag(
+ this: ElementHandle<Element>,
+ target: Point
+ ): Promise<Protocol.Input.DragData> {
+ assert(
+ this.#page.isDragInterceptionEnabled(),
+ 'Drag Interception is not enabled!'
+ );
+ await this.#scrollIntoViewIfNeeded();
+ const start = await this.clickablePoint();
+ return await this.#page.mouse.drag(start, target);
+ }
+
+ /**
+ * This method creates a `dragenter` event on the element.
+ */
+ async dragEnter(
+ this: ElementHandle<Element>,
+ data: Protocol.Input.DragData = {items: [], dragOperationsMask: 1}
+ ): Promise<void> {
+ await this.#scrollIntoViewIfNeeded();
+ const target = await this.clickablePoint();
+ await this.#page.mouse.dragEnter(target, data);
+ }
+
+ /**
+ * This method creates a `dragover` event on the element.
+ */
+ async dragOver(
+ this: ElementHandle<Element>,
+ data: Protocol.Input.DragData = {items: [], dragOperationsMask: 1}
+ ): Promise<void> {
+ await this.#scrollIntoViewIfNeeded();
+ const target = await this.clickablePoint();
+ await this.#page.mouse.dragOver(target, data);
+ }
+
+ /**
+ * This method triggers a drop on the element.
+ */
+ async drop(
+ this: ElementHandle<Element>,
+ data: Protocol.Input.DragData = {items: [], dragOperationsMask: 1}
+ ): Promise<void> {
+ await this.#scrollIntoViewIfNeeded();
+ const destination = await this.clickablePoint();
+ await this.#page.mouse.drop(destination, data);
+ }
+
+ /**
+ * This method triggers a dragenter, dragover, and drop on the element.
+ */
+ async dragAndDrop(
+ this: ElementHandle<Element>,
+ target: ElementHandle<Node>,
+ options?: {delay: number}
+ ): Promise<void> {
+ await this.#scrollIntoViewIfNeeded();
+ const startPoint = await this.clickablePoint();
+ const targetPoint = await target.clickablePoint();
+ await this.#page.mouse.dragAndDrop(startPoint, targetPoint, options);
+ }
+
+ /**
+ * Triggers a `change` and `input` event once all the provided options have been
+ * selected. If there's no `<select>` element matching `selector`, the method
+ * throws an error.
+ *
+ * @example
+ *
+ * ```ts
+ * handle.select('blue'); // single selection
+ * handle.select('red', 'green', 'blue'); // multiple selections
+ * ```
+ *
+ * @param values - Values of options to select. If the `<select>` has the
+ * `multiple` attribute, all values are considered, otherwise only the first
+ * one is taken into account.
+ */
+ async select(...values: string[]): Promise<string[]> {
+ for (const value of values) {
+ assert(
+ isString(value),
+ 'Values must be strings. Found value "' +
+ value +
+ '" of type "' +
+ typeof value +
+ '"'
+ );
+ }
+
+ return this.evaluate((element, vals): string[] => {
+ const values = new Set(vals);
+ if (!(element instanceof HTMLSelectElement)) {
+ throw new Error('Element is not a <select> element.');
+ }
+
+ const selectedValues = new Set<string>();
+ if (!element.multiple) {
+ for (const option of element.options) {
+ option.selected = false;
+ }
+ for (const option of element.options) {
+ if (values.has(option.value)) {
+ option.selected = true;
+ selectedValues.add(option.value);
+ break;
+ }
+ }
+ } else {
+ for (const option of element.options) {
+ option.selected = values.has(option.value);
+ if (option.selected) {
+ selectedValues.add(option.value);
+ }
+ }
+ }
+ element.dispatchEvent(new Event('input', {bubbles: true}));
+ element.dispatchEvent(new Event('change', {bubbles: true}));
+ return [...selectedValues.values()];
+ }, values);
+ }
+
+ /**
+ * 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 a path is relative, then it is resolved against the
+ * {@link https://nodejs.org/api/process.html#process_process_cwd | current working directory}.
+ * Note for locals script connecting to remote chrome environments,
+ * paths must be absolute.
+ */
+ async uploadFile(
+ this: ElementHandle<HTMLInputElement>,
+ ...filePaths: string[]
+ ): Promise<void> {
+ const isMultiple = await this.evaluate(element => {
+ return element.multiple;
+ });
+ assert(
+ filePaths.length <= 1 || isMultiple,
+ 'Multiple file uploads only work with <input type=file multiple>'
+ );
+
+ // Locate all files and confirm that they exist.
+ let path: typeof import('path');
+ try {
+ path = await import('path');
+ } catch (error) {
+ if (error instanceof TypeError) {
+ throw new Error(
+ `JSHandle#uploadFile can only be used in Node-like environments.`
+ );
+ }
+ throw error;
+ }
+ const files = filePaths.map(filePath => {
+ if (path.win32.isAbsolute(filePath) || path.posix.isAbsolute(filePath)) {
+ return filePath;
+ } else {
+ return path.resolve(filePath);
+ }
+ });
+ 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 => {
+ 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(this: ElementHandle<Element>): 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 => {
+ if (!(element instanceof HTMLElement)) {
+ throw new Error('Cannot focus non-HTMLElement');
+ }
+ return element.focus();
+ });
+ }
+
+ /**
+ * Focuses the element, and then sends a `keydown`, `keypress`/`input`, and
+ * `keyup` event for each character in the text.
+ *
+ * To press a special key, like `Control` or `ArrowDown`,
+ * use {@link ElementHandle.press}.
+ *
+ * @example
+ *
+ * ```ts
+ * await elementHandle.type('Hello'); // Types instantly
+ * await elementHandle.type('World', {delay: 100}); // Types slower, like a user
+ * ```
+ *
+ * @example
+ * An example of typing into a text field and then submitting the form:
+ *
+ * ```ts
+ * const elementHandle = await page.$('input');
+ * await elementHandle.type('some text');
+ * await elementHandle.press('Enter');
+ * ```
+ */
+ 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 {offsetX, offsetY} = await this.#getOOPIFOffsets(this.#frame);
+ 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: x + offsetX, y: y + offsetY, 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 {offsetX, offsetY} = await this.#getOOPIFOffsets(this.#frame);
+
+ const {content, padding, border, margin, width, height} = result.model;
+ return {
+ content: applyOffsetsToQuad(
+ this.#fromProtocolQuad(content),
+ offsetX,
+ offsetY
+ ),
+ padding: applyOffsetsToQuad(
+ this.#fromProtocolQuad(padding),
+ offsetX,
+ offsetY
+ ),
+ border: applyOffsetsToQuad(
+ this.#fromProtocolQuad(border),
+ offsetX,
+ offsetY
+ ),
+ margin: applyOffsetsToQuad(
+ this.#fromProtocolQuad(margin),
+ offsetX,
+ offsetY
+ ),
+ 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(
+ this: ElementHandle<Element>,
+ options: ScreenshotOptions = {}
+ ): Promise<string | Buffer> {
+ 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 layoutMetrics = await this.client.send('Page.getLayoutMetrics');
+ // Fallback to `layoutViewport` in case of using Firefox.
+ const {pageX, pageY} =
+ layoutMetrics.cssVisualViewport || layoutMetrics.layoutViewport;
+
+ const clip = Object.assign({}, boundingBox);
+ clip.x += pageX;
+ clip.y += pageY;
+
+ const imageData = await this.#page.screenshot(
+ Object.assign(
+ {},
+ {
+ clip,
+ },
+ options
+ )
+ );
+
+ if (needsViewportReset && viewport) {
+ await this.#page.setViewport(viewport);
+ }
+
+ return imageData;
+ }
+
+ /**
+ * Resolves to true if the element is visible in the current viewport.
+ */
+ async isIntersectingViewport(
+ this: ElementHandle<Element>,
+ options?: {
+ threshold?: number;
+ }
+ ): Promise<boolean> {
+ const {threshold = 0} = options ?? {};
+ return await this.evaluate(async (element, threshold) => {
+ const visibleRatio = await new Promise<number>(resolve => {
+ const observer = new IntersectionObserver(entries => {
+ resolve(entries[0]!.intersectionRatio);
+ observer.disconnect();
+ });
+ observer.observe(element);
+ });
+ return threshold === 1 ? visibleRatio === 1 : visibleRatio > threshold;
+ }, threshold);
+ }
+}
+
+function computeQuadArea(quad: Point[]): 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/EmulationManager.ts b/remote/test/puppeteer/src/common/EmulationManager.ts
new file mode 100644
index 0000000000..ba108d3124
--- /dev/null
+++ b/remote/test/puppeteer/src/common/EmulationManager.ts
@@ -0,0 +1,62 @@
+/**
+ * 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';
+
+/**
+ * @internal
+ */
+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..56a6117b8f
--- /dev/null
+++ b/remote/test/puppeteer/src/common/Errors.ts
@@ -0,0 +1,84 @@
+/**
+ * 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.
+ */
+
+/**
+ * @public
+ */
+export 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 {}
+
+/**
+ * ProtocolError is emitted whenever there is an error from the protocol.
+ *
+ * @public
+ */
+export class ProtocolError extends CustomError {
+ public code?: number;
+ public originalMessage = '';
+}
+
+/**
+ * @public
+ */
+export interface PuppeteerErrors {
+ TimeoutError: typeof TimeoutError;
+ ProtocolError: typeof ProtocolError;
+}
+
+/**
+ * Puppeteer methods might throw errors if they are unable to fulfill a request.
+ * For example, `page.waitForSelector(selector[, options])` might fail if the
+ * selector doesn't match any nodes during the given timeframe.
+ *
+ * For certain types of errors Puppeteer uses specific error classes. These
+ * classes are available via `puppeteer.errors`.
+ *
+ * @example
+ * An example of handling a timeout error:
+ *
+ * ```ts
+ * try {
+ * await page.waitForSelector('.foo');
+ * } catch (e) {
+ * if (e instanceof puppeteer.errors.TimeoutError) {
+ * // Do something if this is a timeout.
+ * }
+ * }
+ * ```
+ *
+ * @public
+ */
+export const errors: PuppeteerErrors = Object.freeze({
+ TimeoutError,
+ ProtocolError,
+});
diff --git a/remote/test/puppeteer/src/common/EventEmitter.ts b/remote/test/puppeteer/src/common/EventEmitter.ts
new file mode 100644
index 0000000000..ce78874b43
--- /dev/null
+++ b/remote/test/puppeteer/src/common/EventEmitter.ts
@@ -0,0 +1,149 @@
+import mitt, {
+ Emitter,
+ EventType,
+ Handler,
+} from '../../vendor/mitt/src/index.js';
+
+/**
+ * @public
+ */
+export {EventType, Handler};
+
+/**
+ * @public
+ */
+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?: unknown): 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 method 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 method calls.
+ */
+ off(event: EventType, handler: Handler): EventEmitter {
+ this.emitter.off(event, handler);
+ return this;
+ }
+
+ /**
+ * Remove an event listener.
+ * @deprecated please use {@link EventEmitter.off} instead.
+ */
+ removeListener(event: EventType, handler: Handler): EventEmitter {
+ this.off(event, handler);
+ return this;
+ }
+
+ /**
+ * Add an event listener.
+ * @deprecated please use {@link EventEmitter.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?: unknown): 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 method 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 method 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.get(event)?.length || 0;
+ }
+}
diff --git a/remote/test/puppeteer/src/common/ExecutionContext.ts b/remote/test/puppeteer/src/common/ExecutionContext.ts
new file mode 100644
index 0000000000..13dfbc5122
--- /dev/null
+++ b/remote/test/puppeteer/src/common/ExecutionContext.ts
@@ -0,0 +1,364 @@
+/**
+ * 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 {Protocol} from 'devtools-protocol';
+import {CDPSession} from './Connection.js';
+import {IsolatedWorld} from './IsolatedWorld.js';
+import {JSHandle} from './JSHandle.js';
+import {EvaluateFunc, HandleFor} from './types.js';
+import {
+ createJSHandle,
+ getExceptionMessage,
+ isString,
+ valueFromRemoteObject,
+} from './util.js';
+
+/**
+ * @public
+ */
+export const EVALUATION_SCRIPT_URL = 'pptr://__puppeteer_evaluation_script__';
+const SOURCE_URL_REGEX = /^[\040\t]*\/\/[@#] sourceURL=\s*(\S*?)\s*$/m;
+
+/**
+ * Represents a context for JavaScript execution.
+ *
+ * @example
+ * A {@link Page} can have several execution contexts:
+ *
+ * - Each {@link Frame} of a {@link Page | page} has a "default" execution
+ * context that is always created after frame is attached to DOM. This context
+ * is returned by the {@link Frame.executionContext} method.
+ * - Each {@link https://developer.chrome.com/extensions | Chrome extensions}
+ * creates additional execution contexts to isolate their code.
+ *
+ * @remarks
+ * By definition, each context is isolated from one another, however they are
+ * all able to manipulate non-JavaScript resources (such as DOM).
+ *
+ * @remarks
+ * Besides pages, execution contexts can be found in
+ * {@link WebWorker | workers}.
+ *
+ * @internal
+ */
+export class ExecutionContext {
+ /**
+ * @internal
+ */
+ _client: CDPSession;
+ /**
+ * @internal
+ */
+ _world?: IsolatedWorld;
+ /**
+ * @internal
+ */
+ _contextId: number;
+ /**
+ * @internal
+ */
+ _contextName: string;
+
+ /**
+ * @internal
+ */
+ constructor(
+ client: CDPSession,
+ contextPayload: Protocol.Runtime.ExecutionContextDescription,
+ world?: IsolatedWorld
+ ) {
+ this._client = client;
+ this._world = world;
+ this._contextId = contextPayload.id;
+ this._contextName = contextPayload.name;
+ }
+
+ /**
+ * Evaluates the given function.
+ *
+ * @example
+ *
+ * ```ts
+ * const executionContext = await page.mainFrame().executionContext();
+ * const result = await executionContext.evaluate(() => Promise.resolve(8 * 7))* ;
+ * console.log(result); // prints "56"
+ * ```
+ *
+ * @example
+ * A string can also be passed in instead of a function:
+ *
+ * ```ts
+ * console.log(await executionContext.evaluate('1 + 2')); // prints "3"
+ * ```
+ *
+ * @example
+ * Handles can also be passed as `args`. They resolve to their referenced object:
+ *
+ * ```ts
+ * const oneHandle = await executionContext.evaluateHandle(() => 1);
+ * const twoHandle = await executionContext.evaluateHandle(() => 2);
+ * const result = await executionContext.evaluate(
+ * (a, b) => a + b,
+ * oneHandle,
+ * twoHandle
+ * );
+ * await oneHandle.dispose();
+ * await twoHandle.dispose();
+ * console.log(result); // prints '3'.
+ * ```
+ *
+ * @param pageFunction - The function to evaluate.
+ * @param args - Additional arguments to pass into the function.
+ * @returns The result of evaluating the function. If the result is an object,
+ * a vanilla object containing the serializable properties of the result is
+ * returned.
+ */
+ async evaluate<
+ Params extends unknown[],
+ Func extends EvaluateFunc<Params> = EvaluateFunc<Params>
+ >(
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<Awaited<ReturnType<Func>>> {
+ return await this.#evaluate(true, pageFunction, ...args);
+ }
+
+ /**
+ * Evaluates the given function.
+ *
+ * Unlike {@link ExecutionContext.evaluate | evaluate}, this method returns a
+ * handle to the result of the function.
+ *
+ * This method may be better suited if the object cannot be serialized (e.g.
+ * `Map`) and requires further manipulation.
+ *
+ * @example
+ *
+ * ```ts
+ * const context = await page.mainFrame().executionContext();
+ * const handle: JSHandle<typeof globalThis> = await context.evaluateHandle(
+ * () => Promise.resolve(self)
+ * );
+ * ```
+ *
+ * @example
+ * A string can also be passed in instead of a function.
+ *
+ * ```ts
+ * const handle: JSHandle<number> = await context.evaluateHandle('1 + 2');
+ * ```
+ *
+ * @example
+ * Handles can also be passed as `args`. They resolve to their referenced object:
+ *
+ * ```ts
+ * const bodyHandle: ElementHandle<HTMLBodyElement> =
+ * await context.evaluateHandle(() => {
+ * return document.body;
+ * });
+ * const stringHandle: JSHandle<string> = await context.evaluateHandle(
+ * body => body.innerHTML,
+ * body
+ * );
+ * console.log(await stringHandle.jsonValue()); // prints body's innerHTML
+ * // Always dispose your garbage! :)
+ * await bodyHandle.dispose();
+ * await stringHandle.dispose();
+ * ```
+ *
+ * @param pageFunction - The function to evaluate.
+ * @param args - Additional arguments to pass into the function.
+ * @returns A {@link JSHandle | handle} to the result of evaluating the
+ * function. If the result is a `Node`, then this will return an
+ * {@link ElementHandle | element handle}.
+ */
+ async evaluateHandle<
+ Params extends unknown[],
+ Func extends EvaluateFunc<Params> = EvaluateFunc<Params>
+ >(
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<HandleFor<Awaited<ReturnType<Func>>>> {
+ return this.#evaluate(false, pageFunction, ...args);
+ }
+
+ async #evaluate<
+ Params extends unknown[],
+ Func extends EvaluateFunc<Params> = EvaluateFunc<Params>
+ >(
+ returnByValue: true,
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<Awaited<ReturnType<Func>>>;
+ async #evaluate<
+ Params extends unknown[],
+ Func extends EvaluateFunc<Params> = EvaluateFunc<Params>
+ >(
+ returnByValue: false,
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<HandleFor<Awaited<ReturnType<Func>>>>;
+ async #evaluate<
+ Params extends unknown[],
+ Func extends EvaluateFunc<Params> = EvaluateFunc<Params>
+ >(
+ returnByValue: boolean,
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<HandleFor<Awaited<ReturnType<Func>>> | Awaited<ReturnType<Func>>> {
+ const suffix = `//# sourceURL=${EVALUATION_SCRIPT_URL}`;
+
+ if (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: ' + getExceptionMessage(exceptionDetails)
+ );
+ }
+
+ return returnByValue
+ ? valueFromRemoteObject(remoteObject)
+ : createJSHandle(this, remoteObject);
+ }
+
+ 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 += ' Recursive objects are not allowed.';
+ }
+ throw error;
+ }
+ const {exceptionDetails, result: remoteObject} =
+ await callFunctionOnPromise.catch(rewriteError);
+ if (exceptionDetails) {
+ throw new Error(
+ 'Evaluation failed: ' + getExceptionMessage(exceptionDetails)
+ );
+ }
+ return returnByValue
+ ? valueFromRemoteObject(remoteObject)
+ : createJSHandle(this, remoteObject);
+
+ function convertArgument(
+ this: ExecutionContext,
+ arg: unknown
+ ): Protocol.Runtime.CallArgument {
+ 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.executionContext() !== 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};
+ }
+ }
+}
+
+const rewriteError = (error: Error): Protocol.Runtime.EvaluateResponse => {
+ if (error.message.includes('Object reference chain is too long')) {
+ return {result: {type: 'undefined'}};
+ }
+ if (error.message.includes("Object couldn't be returned by value")) {
+ return {result: {type: 'undefined'}};
+ }
+
+ if (
+ error.message.endsWith('Cannot find context with specified id') ||
+ error.message.endsWith('Inspected target navigated or closed')
+ ) {
+ throw new Error(
+ 'Execution context was destroyed, most likely because of a navigation.'
+ );
+ }
+ throw error;
+};
diff --git a/remote/test/puppeteer/src/common/FileChooser.ts b/remote/test/puppeteer/src/common/FileChooser.ts
new file mode 100644
index 0000000000..c0a68ff967
--- /dev/null
+++ b/remote/test/puppeteer/src/common/FileChooser.ts
@@ -0,0 +1,94 @@
+/**
+ * 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';
+import {assert} from '../util/assert.js';
+import {ElementHandle} from './ElementHandle.js';
+
+/**
+ * File choosers let you react to the page requesting for a file.
+ *
+ * @remarks
+ * `FileChooser` instances are returned via the {@link Page.waitForFileChooser} method.
+ *
+ * In browsers, only one file chooser can be opened at a time.
+ * All file choosers must be accepted or canceled. Not doing so will prevent
+ * subsequent file choosers from appearing.
+ *
+ * @example
+ *
+ * ```ts
+ * const [fileChooser] = await Promise.all([
+ * page.waitForFileChooser(),
+ * page.click('#upload-file-button'), // some button that triggers file selection
+ * ]);
+ * await fileChooser.accept(['/tmp/myfile.pdf']);
+ * ```
+ *
+ * @public
+ */
+export class FileChooser {
+ #element: ElementHandle<HTMLInputElement>;
+ #multiple: boolean;
+ #handled = false;
+
+ /**
+ * @internal
+ */
+ constructor(
+ element: ElementHandle<HTMLInputElement>,
+ event: Protocol.Page.FileChooserOpenedEvent
+ ) {
+ this.#element = element;
+ this.#multiple = event.mode !== 'selectSingle';
+ }
+
+ /**
+ * Whether file chooser allow for
+ * {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#attr-multiple | multiple}
+ * file selection.
+ */
+ isMultiple(): boolean {
+ return this.#multiple;
+ }
+
+ /**
+ * Accept the file chooser request with 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.
+ */
+ cancel(): void {
+ assert(
+ !this.#handled,
+ 'Cannot cancel FileChooser which is already handled!'
+ );
+ this.#handled = true;
+ }
+}
diff --git a/remote/test/puppeteer/src/common/FirefoxTargetManager.ts b/remote/test/puppeteer/src/common/FirefoxTargetManager.ts
new file mode 100644
index 0000000000..e31a205aa1
--- /dev/null
+++ b/remote/test/puppeteer/src/common/FirefoxTargetManager.ts
@@ -0,0 +1,256 @@
+/**
+ * Copyright 2022 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';
+import {assert} from '../util/assert.js';
+import {CDPSession, Connection} from './Connection.js';
+import {Target} from './Target.js';
+import {TargetFilterCallback} from './Browser.js';
+import {
+ TargetFactory,
+ TargetInterceptor,
+ TargetManagerEmittedEvents,
+ TargetManager,
+} from './TargetManager.js';
+import {EventEmitter} from './EventEmitter.js';
+
+/**
+ * FirefoxTargetManager implements target management using
+ * `Target.setDiscoverTargets` without using auto-attach. It, therefore, creates
+ * targets that lazily establish their CDP sessions.
+ *
+ * Although the approach is potentially flaky, there is no other way for Firefox
+ * because Firefox's CDP implementation does not support auto-attach.
+ *
+ * Firefox does not support targetInfoChanged and detachedFromTarget events:
+ *
+ * - https://bugzilla.mozilla.org/show_bug.cgi?id=1610855
+ * - https://bugzilla.mozilla.org/show_bug.cgi?id=1636979
+ * @internal
+ */
+export class FirefoxTargetManager
+ extends EventEmitter
+ implements TargetManager
+{
+ #connection: Connection;
+ /**
+ * Keeps track of the following events: 'Target.targetCreated',
+ * 'Target.targetDestroyed'.
+ *
+ * A target becomes discovered when 'Target.targetCreated' is received.
+ * A target is removed from this map once 'Target.targetDestroyed' is
+ * received.
+ *
+ * `targetFilterCallback` has no effect on this map.
+ */
+ #discoveredTargetsByTargetId: Map<string, Protocol.Target.TargetInfo> =
+ new Map();
+ /**
+ * Keeps track of targets that were created via 'Target.targetCreated'
+ * and which one are not filtered out by `targetFilterCallback`.
+ *
+ * The target is removed from here once it's been destroyed.
+ */
+ #availableTargetsByTargetId: Map<string, Target> = new Map();
+ /**
+ * Tracks which sessions attach to which target.
+ */
+ #availableTargetsBySessionId: Map<string, Target> = new Map();
+ /**
+ * If a target was filtered out by `targetFilterCallback`, we still receive
+ * events about it from CDP, but we don't forward them to the rest of Puppeteer.
+ */
+ #ignoredTargets = new Set<string>();
+ #targetFilterCallback: TargetFilterCallback | undefined;
+ #targetFactory: TargetFactory;
+
+ #targetInterceptors: WeakMap<CDPSession | Connection, TargetInterceptor[]> =
+ new WeakMap();
+
+ #attachedToTargetListenersBySession: WeakMap<
+ CDPSession | Connection,
+ (event: Protocol.Target.AttachedToTargetEvent) => Promise<void>
+ > = new WeakMap();
+
+ #initializeCallback = () => {};
+ #initializePromise: Promise<void> = new Promise(resolve => {
+ this.#initializeCallback = resolve;
+ });
+ #targetsIdsForInit: Set<string> = new Set();
+
+ constructor(
+ connection: Connection,
+ targetFactory: TargetFactory,
+ targetFilterCallback?: TargetFilterCallback
+ ) {
+ super();
+ this.#connection = connection;
+ this.#targetFilterCallback = targetFilterCallback;
+ this.#targetFactory = targetFactory;
+
+ this.#connection.on('Target.targetCreated', this.#onTargetCreated);
+ this.#connection.on('Target.targetDestroyed', this.#onTargetDestroyed);
+ this.#connection.on('sessiondetached', this.#onSessionDetached);
+ this.setupAttachmentListeners(this.#connection);
+ }
+
+ addTargetInterceptor(
+ client: CDPSession | Connection,
+ interceptor: TargetInterceptor
+ ): void {
+ const interceptors = this.#targetInterceptors.get(client) || [];
+ interceptors.push(interceptor);
+ this.#targetInterceptors.set(client, interceptors);
+ }
+
+ removeTargetInterceptor(
+ client: CDPSession | Connection,
+ interceptor: TargetInterceptor
+ ): void {
+ const interceptors = this.#targetInterceptors.get(client) || [];
+ this.#targetInterceptors.set(
+ client,
+ interceptors.filter(currentInterceptor => {
+ return currentInterceptor !== interceptor;
+ })
+ );
+ }
+
+ setupAttachmentListeners(session: CDPSession | Connection): void {
+ const listener = (event: Protocol.Target.AttachedToTargetEvent) => {
+ return this.#onAttachedToTarget(session, event);
+ };
+ assert(!this.#attachedToTargetListenersBySession.has(session));
+ this.#attachedToTargetListenersBySession.set(session, listener);
+ session.on('Target.attachedToTarget', listener);
+ }
+
+ #onSessionDetached = (session: CDPSession) => {
+ this.removeSessionListeners(session);
+ this.#targetInterceptors.delete(session);
+ this.#availableTargetsBySessionId.delete(session.id());
+ };
+
+ removeSessionListeners(session: CDPSession): void {
+ if (this.#attachedToTargetListenersBySession.has(session)) {
+ session.off(
+ 'Target.attachedToTarget',
+ this.#attachedToTargetListenersBySession.get(session)!
+ );
+ this.#attachedToTargetListenersBySession.delete(session);
+ }
+ }
+
+ getAvailableTargets(): Map<string, Target> {
+ return this.#availableTargetsByTargetId;
+ }
+
+ dispose(): void {
+ this.#connection.off('Target.targetCreated', this.#onTargetCreated);
+ this.#connection.off('Target.targetDestroyed', this.#onTargetDestroyed);
+ }
+
+ async initialize(): Promise<void> {
+ await this.#connection.send('Target.setDiscoverTargets', {discover: true});
+ this.#targetsIdsForInit = new Set(this.#discoveredTargetsByTargetId.keys());
+ await this.#initializePromise;
+ }
+
+ #onTargetCreated = async (
+ event: Protocol.Target.TargetCreatedEvent
+ ): Promise<void> => {
+ if (this.#discoveredTargetsByTargetId.has(event.targetInfo.targetId)) {
+ return;
+ }
+
+ this.#discoveredTargetsByTargetId.set(
+ event.targetInfo.targetId,
+ event.targetInfo
+ );
+
+ if (event.targetInfo.type === 'browser' && event.targetInfo.attached) {
+ const target = this.#targetFactory(event.targetInfo, undefined);
+ this.#availableTargetsByTargetId.set(event.targetInfo.targetId, target);
+ this.#finishInitializationIfReady(target._targetId);
+ return;
+ }
+
+ if (
+ this.#targetFilterCallback &&
+ !this.#targetFilterCallback(event.targetInfo)
+ ) {
+ this.#ignoredTargets.add(event.targetInfo.targetId);
+ this.#finishInitializationIfReady(event.targetInfo.targetId);
+ return;
+ }
+
+ const target = this.#targetFactory(event.targetInfo, undefined);
+ this.#availableTargetsByTargetId.set(event.targetInfo.targetId, target);
+ this.emit(TargetManagerEmittedEvents.TargetAvailable, target);
+ this.#finishInitializationIfReady(target._targetId);
+ };
+
+ #onTargetDestroyed = (event: Protocol.Target.TargetDestroyedEvent): void => {
+ this.#discoveredTargetsByTargetId.delete(event.targetId);
+ this.#finishInitializationIfReady(event.targetId);
+ const target = this.#availableTargetsByTargetId.get(event.targetId);
+ if (target) {
+ this.emit(TargetManagerEmittedEvents.TargetGone, target);
+ this.#availableTargetsByTargetId.delete(event.targetId);
+ }
+ };
+
+ #onAttachedToTarget = async (
+ parentSession: Connection | CDPSession,
+ event: Protocol.Target.AttachedToTargetEvent
+ ) => {
+ const targetInfo = event.targetInfo;
+ const session = this.#connection.session(event.sessionId);
+ if (!session) {
+ throw new Error(`Session ${event.sessionId} was not created.`);
+ }
+
+ const target = this.#availableTargetsByTargetId.get(targetInfo.targetId);
+
+ assert(target, `Target ${targetInfo.targetId} is missing`);
+
+ this.setupAttachmentListeners(session);
+
+ this.#availableTargetsBySessionId.set(
+ session.id(),
+ this.#availableTargetsByTargetId.get(targetInfo.targetId)!
+ );
+
+ for (const hook of this.#targetInterceptors.get(parentSession) || []) {
+ if (!(parentSession instanceof Connection)) {
+ assert(this.#availableTargetsBySessionId.has(parentSession.id()));
+ }
+ await hook(
+ target,
+ parentSession instanceof Connection
+ ? null
+ : this.#availableTargetsBySessionId.get(parentSession.id())!
+ );
+ }
+ };
+
+ #finishInitializationIfReady(targetId: string): void {
+ this.#targetsIdsForInit.delete(targetId);
+ if (this.#targetsIdsForInit.size === 0) {
+ this.#initializeCallback();
+ }
+ }
+}
diff --git a/remote/test/puppeteer/src/common/Frame.ts b/remote/test/puppeteer/src/common/Frame.ts
new file mode 100644
index 0000000000..9d2d7759f0
--- /dev/null
+++ b/remote/test/puppeteer/src/common/Frame.ts
@@ -0,0 +1,1097 @@
+import {Protocol} from 'devtools-protocol';
+import {assert} from '../util/assert.js';
+import {isErrorLike} from '../util/ErrorLike.js';
+import {CDPSession} from './Connection.js';
+import {ElementHandle} from './ElementHandle.js';
+import {ExecutionContext} from './ExecutionContext.js';
+import {FrameManager} from './FrameManager.js';
+import {HTTPResponse} from './HTTPResponse.js';
+import {MouseButton} from './Input.js';
+import {
+ IsolatedWorld,
+ IsolatedWorldChart,
+ MAIN_WORLD,
+ PUPPETEER_WORLD,
+ WaitForSelectorOptions,
+} from './IsolatedWorld.js';
+import {LifecycleWatcher, PuppeteerLifeCycleEvent} from './LifecycleWatcher.js';
+import {Page} from './Page.js';
+import {getQueryHandlerAndSelector} from './QueryHandler.js';
+import {EvaluateFunc, HandleFor, NodeFor} from './types.js';
+import {importFS} from './util.js';
+
+/**
+ * @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 {
+ /**
+ * URL of the script to be added.
+ */
+ url?: string;
+ /**
+ * Path to a JavaScript file to be injected into the frame.
+ *
+ * @remarks
+ * If `path` is a relative path, it is resolved relative to the current
+ * working directory (`process.cwd()` in Node.js).
+ */
+ path?: string;
+ /**
+ * JavaScript to be injected into the frame.
+ */
+ content?: string;
+ /**
+ * Sets the `type` of the script. Use `module` in order to load an ES2015 module.
+ */
+ type?: string;
+ /**
+ * Sets the `id` of the script.
+ */
+ id?: string;
+}
+
+/**
+ * @public
+ */
+export interface FrameAddStyleTagOptions {
+ /**
+ * the URL of the CSS file to be added.
+ */
+ url?: string;
+ /**
+ * The path to a CSS file to be injected into the frame.
+ * @remarks
+ * If `path` is a relative path, it is resolved relative to the current
+ * working directory (`process.cwd()` in Node.js).
+ */
+ path?: string;
+ /**
+ * Raw CSS content to be injected into the frame.
+ */
+ content?: string;
+}
+
+/**
+ * Represents a DOM frame.
+ *
+ * To understand frames, you can think of frames as `<iframe>` elements. Just
+ * like iframes, frames can be nested, and when JavaScript is executed in a
+ * frame, the JavaScript does not effect frames inside the ambient frame the
+ * JavaScript executes in.
+ *
+ * @example
+ * At any point in time, {@link Page | pages} expose their current frame
+ * tree via the {@link Page.mainFrame} and {@link Frame.childFrames} methods.
+ *
+ * @example
+ * An example of dumping frame tree:
+ *
+ * ```ts
+ * 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:
+ *
+ * ```ts
+ * const frame = page.frames().find(frame => frame.name() === 'myframe');
+ * const text = await frame.$eval('.selector', element => element.textContent);
+ * console.log(text);
+ * ```
+ *
+ * @remarks
+ * Frame lifecycles are controlled by three events that are all dispatched on
+ * the parent {@link Frame.page | page}:
+ *
+ * - {@link PageEmittedEvents.FrameAttached}
+ * - {@link PageEmittedEvents.FrameNavigated}
+ * - {@link PageEmittedEvents.FrameDetached}
+ *
+ * @public
+ */
+export class Frame {
+ #parentFrame: Frame | null;
+ #url = '';
+ #detached = false;
+ #client!: CDPSession;
+
+ /**
+ * @internal
+ */
+ worlds!: IsolatedWorldChart;
+ /**
+ * @internal
+ */
+ _frameManager: FrameManager;
+ /**
+ * @internal
+ */
+ _id: string;
+ /**
+ * @internal
+ */
+ _loaderId = '';
+ /**
+ * @internal
+ */
+ _name?: string;
+ /**
+ * @internal
+ */
+ _hasStartedLoading = false;
+ /**
+ * @internal
+ */
+ _lifecycleEvents = new Set<string>();
+ /**
+ * @internal
+ */
+ _childFrames: Set<Frame>;
+
+ /**
+ * @internal
+ */
+ constructor(
+ frameManager: FrameManager,
+ parentFrame: Frame | null,
+ frameId: string,
+ client: CDPSession
+ ) {
+ this._frameManager = frameManager;
+ this.#parentFrame = parentFrame ?? null;
+ this.#url = '';
+ this._id = frameId;
+ this.#detached = false;
+
+ this._loaderId = '';
+
+ this._childFrames = new Set();
+ if (this.#parentFrame) {
+ this.#parentFrame._childFrames.add(this);
+ }
+
+ this.updateClient(client);
+ }
+
+ /**
+ * @internal
+ */
+ updateClient(client: CDPSession): void {
+ this.#client = client;
+ this.worlds = {
+ [MAIN_WORLD]: new IsolatedWorld(this),
+ [PUPPETEER_WORLD]: new IsolatedWorld(this, true),
+ };
+ }
+
+ /**
+ * @returns The page associated with the frame.
+ */
+ page(): Page {
+ return this._frameManager.page();
+ }
+
+ /**
+ * @returns `true` if the frame is an out-of-process (OOP) frame. Otherwise,
+ * `false`.
+ */
+ isOOPFrame(): boolean {
+ return this.#client !== this._frameManager.client;
+ }
+
+ /**
+ * Navigates a frame to the given url.
+ *
+ * @remarks
+ * Navigation to `about:blank` or navigation to the same URL with a different
+ * hash will succeed and return `null`.
+ *
+ * :::warning
+ *
+ * Headless mode doesn't support navigation to a PDF document. See the {@link
+ * https://bugs.chromium.org/p/chromium/issues/detail?id=761295 | upstream
+ * issue}.
+ *
+ * :::
+ *
+ * @param url - 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.
+ * @throws This method 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.
+ *
+ * This method will not throw an error when any valid HTTP status code is
+ * returned by the remote server, including 404 "Not Found" and 500 "Internal
+ * Server Error". The status code for such responses can be retrieved by
+ * calling {@link HTTPResponse.status}.
+ */
+ async goto(
+ url: string,
+ options: {
+ referer?: string;
+ timeout?: number;
+ waitUntil?: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[];
+ } = {}
+ ): Promise<HTTPResponse | null> {
+ const {
+ referer = this._frameManager.networkManager.extraHTTPHeaders()['referer'],
+ waitUntil = ['load'],
+ timeout = this._frameManager.timeoutSettings.navigationTimeout(),
+ } = options;
+
+ let ensureNewDocumentNavigation = false;
+ const watcher = new LifecycleWatcher(
+ this._frameManager,
+ this,
+ waitUntil,
+ timeout
+ );
+ let error = await Promise.race([
+ navigate(this.#client, url, referer, this._id),
+ watcher.timeoutOrTerminationPromise(),
+ ]);
+ if (!error) {
+ error = await Promise.race([
+ watcher.timeoutOrTerminationPromise(),
+ ensureNewDocumentNavigation
+ ? watcher.newDocumentNavigationPromise()
+ : watcher.sameDocumentNavigationPromise(),
+ ]);
+ }
+
+ try {
+ if (error) {
+ throw error;
+ }
+ return await watcher.navigationResponse();
+ } finally {
+ watcher.dispose();
+ }
+
+ async function navigate(
+ client: CDPSession,
+ url: string,
+ referrer: string | undefined,
+ 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) {
+ if (isErrorLike(error)) {
+ return error;
+ }
+ throw error;
+ }
+ }
+ }
+
+ /**
+ * Waits for the frame to navigate. It is useful for when you run code which
+ * will indirectly cause the frame to navigate.
+ *
+ * Usage of the
+ * {@link https://developer.mozilla.org/en-US/docs/Web/API/History_API | History API}
+ * to change the URL is considered a navigation.
+ *
+ * @example
+ *
+ * ```ts
+ * const [response] = await Promise.all([
+ * // The navigation promise resolves after navigation has finished
+ * frame.waitForNavigation(),
+ * // Clicking the link will indirectly cause a navigation
+ * frame.click('a.my-link'),
+ * ]);
+ * ```
+ *
+ * @param options - options to configure 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> {
+ const {
+ waitUntil = ['load'],
+ timeout = this._frameManager.timeoutSettings.navigationTimeout(),
+ } = options;
+ const watcher = new LifecycleWatcher(
+ this._frameManager,
+ this,
+ waitUntil,
+ timeout
+ );
+ const error = await Promise.race([
+ watcher.timeoutOrTerminationPromise(),
+ watcher.sameDocumentNavigationPromise(),
+ watcher.newDocumentNavigationPromise(),
+ ]);
+ try {
+ if (error) {
+ throw error;
+ }
+ return await watcher.navigationResponse();
+ } finally {
+ watcher.dispose();
+ }
+ }
+
+ /**
+ * @internal
+ */
+ _client(): CDPSession {
+ return this.#client;
+ }
+
+ /**
+ * @internal
+ */
+ executionContext(): Promise<ExecutionContext> {
+ return this.worlds[MAIN_WORLD].executionContext();
+ }
+
+ /**
+ * Behaves identically to {@link Page.evaluateHandle} except it's run within
+ * the context of this frame.
+ *
+ * @see {@link Page.evaluateHandle} for details.
+ */
+ async evaluateHandle<
+ Params extends unknown[],
+ Func extends EvaluateFunc<Params> = EvaluateFunc<Params>
+ >(
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<HandleFor<Awaited<ReturnType<Func>>>> {
+ return this.worlds[MAIN_WORLD].evaluateHandle(pageFunction, ...args);
+ }
+
+ /**
+ * Behaves identically to {@link Page.evaluate} except it's run within the
+ * the context of this frame.
+ *
+ * @see {@link Page.evaluate} for details.
+ */
+ async evaluate<
+ Params extends unknown[],
+ Func extends EvaluateFunc<Params> = EvaluateFunc<Params>
+ >(
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<Awaited<ReturnType<Func>>> {
+ return this.worlds[MAIN_WORLD].evaluate(pageFunction, ...args);
+ }
+
+ /**
+ * Queries the frame for an element matching the given selector.
+ *
+ * @param selector - The selector to query for.
+ * @returns A {@link ElementHandle | element handle} to the first element
+ * matching the given selector. Otherwise, `null`.
+ */
+ async $<Selector extends string>(
+ selector: Selector
+ ): Promise<ElementHandle<NodeFor<Selector>> | null> {
+ return this.worlds[MAIN_WORLD].$(selector);
+ }
+
+ /**
+ * Queries the frame for all elements matching the given selector.
+ *
+ * @param selector - The selector to query for.
+ * @returns An array of {@link ElementHandle | element handles} that point to
+ * elements matching the given selector.
+ */
+ async $$<Selector extends string>(
+ selector: Selector
+ ): Promise<Array<ElementHandle<NodeFor<Selector>>>> {
+ return this.worlds[MAIN_WORLD].$$(selector);
+ }
+
+ /**
+ * Runs the given function on the first element matching the given selector in
+ * the frame.
+ *
+ * If the given function returns a promise, then this method will wait till
+ * the promise resolves.
+ *
+ * @example
+ *
+ * ```ts
+ * const searchValue = await frame.$eval('#search', el => el.value);
+ * ```
+ *
+ * @param selector - The selector to query for.
+ * @param pageFunction - The function to be evaluated in the frame's context.
+ * The first element matching the selector will be passed to the function as
+ * its first argument.
+ * @param args - Additional arguments to pass to `pageFunction`.
+ * @returns A promise to the result of the function.
+ */
+ async $eval<
+ Selector extends string,
+ Params extends unknown[],
+ Func extends EvaluateFunc<
+ [ElementHandle<NodeFor<Selector>>, ...Params]
+ > = EvaluateFunc<[ElementHandle<NodeFor<Selector>>, ...Params]>
+ >(
+ selector: Selector,
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<Awaited<ReturnType<Func>>> {
+ return this.worlds[MAIN_WORLD].$eval(selector, pageFunction, ...args);
+ }
+
+ /**
+ * Runs the given function on an array of elements matching the given selector
+ * in the frame.
+ *
+ * If the given function returns a promise, then this method will wait till
+ * the promise resolves.
+ *
+ * @example
+ *
+ * ```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.
+ * An array of elements matching the given selector will be passed to the
+ * function as its first argument.
+ * @param args - Additional arguments to pass to `pageFunction`.
+ * @returns A promise to the result of the function.
+ */
+ async $$eval<
+ Selector extends string,
+ Params extends unknown[],
+ Func extends EvaluateFunc<
+ [Array<NodeFor<Selector>>, ...Params]
+ > = EvaluateFunc<[Array<NodeFor<Selector>>, ...Params]>
+ >(
+ selector: Selector,
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<Awaited<ReturnType<Func>>> {
+ return this.worlds[MAIN_WORLD].$$eval(selector, pageFunction, ...args);
+ }
+
+ /**
+ * @deprecated Use {@link Frame.$$} with the `xpath` prefix.
+ *
+ * This method evaluates the given XPath expression and returns the results.
+ * @param expression - the XPath expression to evaluate.
+ */
+ async $x(expression: string): Promise<Array<ElementHandle<Node>>> {
+ return this.worlds[MAIN_WORLD].$x(expression);
+ }
+
+ /**
+ * Waits for an element matching the given selector to appear in the frame.
+ *
+ * This method works across navigations.
+ *
+ * @example
+ *
+ * ```ts
+ * 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 query and wait for.
+ * @param options - Options for customizing waiting behavior.
+ * @returns An element matching the given selector.
+ * @throws Throws if an element matching the given selector doesn't appear.
+ */
+ async waitForSelector<Selector extends string>(
+ selector: Selector,
+ options: WaitForSelectorOptions = {}
+ ): Promise<ElementHandle<NodeFor<Selector>> | null> {
+ const {updatedSelector, queryHandler} =
+ getQueryHandlerAndSelector(selector);
+ assert(queryHandler.waitFor, 'Query handler does not support waiting');
+ return (await queryHandler.waitFor(
+ this,
+ updatedSelector,
+ options
+ )) as ElementHandle<NodeFor<Selector>> | null;
+ }
+
+ /**
+ * @deprecated Use {@link Frame.waitForSelector} with the `xpath` prefix.
+ *
+ * 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<Node> | null> {
+ if (xpath.startsWith('//')) {
+ xpath = `.${xpath}`;
+ }
+ return this.waitForSelector(`xpath/${xpath}`, options);
+ }
+
+ /**
+ * @example
+ * The `waitForFunction` can be used to observe viewport size change:
+ *
+ * ```ts
+ * 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:
+ *
+ * ```ts
+ * const selector = '.foo';
+ * await frame.waitForFunction(
+ * selector => !!document.querySelector(selector),
+ * {}, // empty options object
+ * selector
+ * );
+ * ```
+ *
+ * @param pageFunction - the function to evaluate in the frame context.
+ * @param options - options to configure the polling method and timeout.
+ * @param args - arguments to pass to the `pageFunction`.
+ * @returns the promise which resolve when the `pageFunction` returns a truthy value.
+ */
+ waitForFunction<
+ Params extends unknown[],
+ Func extends EvaluateFunc<Params> = EvaluateFunc<Params>
+ >(
+ pageFunction: Func | string,
+ options: FrameWaitForFunctionOptions = {},
+ ...args: Params
+ ): Promise<HandleFor<Awaited<ReturnType<Func>>>> {
+ // TODO: Fix when NodeHandle has been added.
+ return this.worlds[MAIN_WORLD].waitForFunction(
+ pageFunction,
+ options,
+ ...args
+ ) as Promise<HandleFor<Awaited<ReturnType<Func>>>>;
+ }
+
+ /**
+ * @returns The full HTML contents of the frame, including the DOCTYPE.
+ */
+ async content(): Promise<string> {
+ return this.worlds[PUPPETEER_WORLD].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.worlds[PUPPETEER_WORLD].setContent(html, options);
+ }
+
+ /**
+ * @returns The frame's `name` attribute as specified in the tag.
+ *
+ * @remarks
+ * If the name is empty, it returns the `id` attribute instead.
+ *
+ * @remarks
+ * This value is calculated once when the frame is created, and will not
+ * update if the attribute is changed later.
+ */
+ name(): string {
+ return this._name || '';
+ }
+
+ /**
+ * @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. Otherwise, `false`.
+ */
+ isDetached(): boolean {
+ return this.#detached;
+ }
+
+ /**
+ * Adds a `<script>` tag into the page with the desired url or content.
+ *
+ * @param options - Options for the script.
+ * @returns An {@link ElementHandle | element handle} to the injected
+ * `<script>` element.
+ */
+ async addScriptTag(
+ options: FrameAddScriptTagOptions
+ ): Promise<ElementHandle<HTMLScriptElement>> {
+ let {content = '', type} = options;
+ const {path} = options;
+ if (+!!options.url + +!!path + +!!content !== 1) {
+ throw new Error(
+ 'Exactly one of `url`, `path`, or `content` must be specified.'
+ );
+ }
+
+ if (path) {
+ let fs;
+ try {
+ fs = (await import('fs')).promises;
+ } catch (error) {
+ if (error instanceof TypeError) {
+ throw new Error(
+ 'Can only pass a file path in a Node-like environment.'
+ );
+ }
+ throw error;
+ }
+ content = await fs.readFile(path, 'utf8');
+ content += `//# sourceURL=${path.replace(/\n/g, '')}`;
+ }
+
+ type = type ?? 'text/javascript';
+
+ return this.worlds[MAIN_WORLD].transferHandle(
+ await this.worlds[PUPPETEER_WORLD].evaluateHandle(
+ async ({url, id, type, content}) => {
+ const promise = InjectedUtil.createDeferredPromise<void>();
+ const script = document.createElement('script');
+ script.type = type;
+ script.text = content;
+ if (url) {
+ script.src = url;
+ script.addEventListener(
+ 'load',
+ () => {
+ return promise.resolve();
+ },
+ {once: true}
+ );
+ script.addEventListener(
+ 'error',
+ event => {
+ promise.reject(
+ new Error(event.message ?? 'Could not load script')
+ );
+ },
+ {once: true}
+ );
+ } else {
+ promise.resolve();
+ }
+ if (id) {
+ script.id = id;
+ }
+ document.head.appendChild(script);
+ await promise;
+ return script;
+ },
+ {...options, type, content}
+ )
+ );
+ }
+
+ /**
+ * Adds a `<link rel="stylesheet">` tag into the page with the desired URL or
+ * a `<style type="text/css">` tag with the content.
+ *
+ * @returns An {@link ElementHandle | element handle} to the loaded `<link>`
+ * or `<style>` element.
+ */
+ async addStyleTag(
+ options: Omit<FrameAddStyleTagOptions, 'url'>
+ ): Promise<ElementHandle<HTMLStyleElement>>;
+ async addStyleTag(
+ options: FrameAddStyleTagOptions
+ ): Promise<ElementHandle<HTMLLinkElement>>;
+ async addStyleTag(
+ options: FrameAddStyleTagOptions
+ ): Promise<ElementHandle<HTMLStyleElement | HTMLLinkElement>> {
+ let {content = ''} = options;
+ const {path} = options;
+ if (+!!options.url + +!!path + +!!content !== 1) {
+ throw new Error(
+ 'Exactly one of `url`, `path`, or `content` must be specified.'
+ );
+ }
+
+ if (path) {
+ let fs: typeof import('fs').promises;
+ try {
+ fs = (await importFS()).promises;
+ } catch (error) {
+ if (error instanceof TypeError) {
+ throw new Error(
+ 'Can only pass a file path in a Node-like environment.'
+ );
+ }
+ throw error;
+ }
+
+ content = await fs.readFile(path, 'utf8');
+ content += '/*# sourceURL=' + path.replace(/\n/g, '') + '*/';
+ options.content = content;
+ }
+
+ return this.worlds[MAIN_WORLD].transferHandle(
+ await this.worlds[PUPPETEER_WORLD].evaluateHandle(
+ async ({url, content}) => {
+ const promise = InjectedUtil.createDeferredPromise<void>();
+ let element: HTMLStyleElement | HTMLLinkElement;
+ if (!url) {
+ element = document.createElement('style');
+ element.appendChild(document.createTextNode(content!));
+ } else {
+ const link = document.createElement('link');
+ link.rel = 'stylesheet';
+ link.href = url;
+ element = link;
+ }
+ element.addEventListener(
+ 'load',
+ () => {
+ promise.resolve();
+ },
+ {once: true}
+ );
+ element.addEventListener(
+ 'error',
+ event => {
+ promise.reject(
+ new Error(
+ (event as ErrorEvent).message ?? 'Could not load style'
+ )
+ );
+ },
+ {once: true}
+ );
+ document.head.appendChild(element);
+ await promise;
+ return element;
+ },
+ options
+ )
+ );
+ }
+
+ /**
+ * Clicks the first element found that matches `selector`.
+ *
+ * @remarks
+ * If `click()` triggers a navigation event and there's a separate
+ * `page.waitForNavigation()` promise to be resolved, you may end up with a
+ * race condition that yields unexpected results. The correct pattern for
+ * click and wait for navigation is the following:
+ *
+ * ```ts
+ * const [response] = await Promise.all([
+ * page.waitForNavigation(waitOptions),
+ * frame.click(selector, clickOptions),
+ * ]);
+ * ```
+ *
+ * @param selector - The selector to query for.
+ */
+ async click(
+ selector: string,
+ options: {
+ delay?: number;
+ button?: MouseButton;
+ clickCount?: number;
+ } = {}
+ ): Promise<void> {
+ return this.worlds[PUPPETEER_WORLD].click(selector, options);
+ }
+
+ /**
+ * Focuses the first element that matches the `selector`.
+ *
+ * @param selector - The selector to query for.
+ * @throws Throws if there's no element matching `selector`.
+ */
+ async focus(selector: string): Promise<void> {
+ return this.worlds[PUPPETEER_WORLD].focus(selector);
+ }
+
+ /**
+ * Hovers the pointer over the center of the first element that matches the
+ * `selector`.
+ *
+ * @param selector - The selector to query for.
+ * @throws Throws if there's no element matching `selector`.
+ */
+ async hover(selector: string): Promise<void> {
+ return this.worlds[PUPPETEER_WORLD].hover(selector);
+ }
+
+ /**
+ * Selects a set of value on the first `<select>` element that matches the
+ * `selector`.
+ *
+ * @example
+ *
+ * ```ts
+ * frame.select('select#colors', 'blue'); // single selection
+ * frame.select('select#colors', 'red', 'green', 'blue'); // multiple selections
+ * ```
+ *
+ * @param selector - The selector to query for.
+ * @param values - The array of values to select. If the `<select>` has the
+ * `multiple` attribute, all values are considered, otherwise only the first
+ * one is taken into account.
+ * @returns the list of values that were successfully selected.
+ * @throws Throws if there's no `<select>` matching `selector`.
+ */
+ select(selector: string, ...values: string[]): Promise<string[]> {
+ return this.worlds[PUPPETEER_WORLD].select(selector, ...values);
+ }
+
+ /**
+ * Taps the first element that matches the `selector`.
+ *
+ * @param selector - The selector to query for.
+ * @throws Throws if there's no element matching `selector`.
+ */
+ async tap(selector: string): Promise<void> {
+ return this.worlds[PUPPETEER_WORLD].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
+ *
+ * ```ts
+ * await frame.type('#mytextarea', 'Hello'); // Types instantly
+ * await frame.type('#mytextarea', 'World', {delay: 100}); // Types slower, like a user
+ * ```
+ *
+ * @param selector - the selector for the element to type into. If there are
+ * multiple the first will be used.
+ * @param text - text to type into the element
+ * @param options - takes one option, `delay`, which sets the time to wait
+ * between key presses in milliseconds. Defaults to `0`.
+ */
+ async type(
+ selector: string,
+ text: string,
+ options?: {delay: number}
+ ): Promise<void> {
+ return this.worlds[PUPPETEER_WORLD].type(selector, text, options);
+ }
+
+ /**
+ * @deprecated Use `new Promise(r => setTimeout(r, milliseconds));`.
+ *
+ * Causes your script to wait for the given number of milliseconds.
+ *
+ * @remarks
+ * It's generally recommended to not wait for a number of seconds, but instead
+ * use {@link Frame.waitForSelector}, {@link Frame.waitForXPath} or
+ * {@link Frame.waitForFunction} to wait for exactly the conditions you want.
+ *
+ * @example
+ *
+ * Wait for 1 second:
+ *
+ * ```ts
+ * await frame.waitForTimeout(1000);
+ * ```
+ *
+ * @param milliseconds - the number of milliseconds to wait.
+ */
+ waitForTimeout(milliseconds: number): Promise<void> {
+ return new Promise(resolve => {
+ setTimeout(resolve, milliseconds);
+ });
+ }
+
+ /**
+ * @returns the frame's title.
+ */
+ async title(): Promise<string> {
+ return this.worlds[PUPPETEER_WORLD].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
+ */
+ _onLoadingStarted(): void {
+ this._hasStartedLoading = true;
+ }
+
+ /**
+ * @internal
+ */
+ _detach(): void {
+ this.#detached = true;
+ this.worlds[MAIN_WORLD]._detach();
+ this.worlds[PUPPETEER_WORLD]._detach();
+ if (this.#parentFrame) {
+ this.#parentFrame._childFrames.delete(this);
+ }
+ this.#parentFrame = null;
+ }
+}
diff --git a/remote/test/puppeteer/src/common/FrameManager.ts b/remote/test/puppeteer/src/common/FrameManager.ts
new file mode 100644
index 0000000000..8ab336bc26
--- /dev/null
+++ b/remote/test/puppeteer/src/common/FrameManager.ts
@@ -0,0 +1,519 @@
+/**
+ * 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 {Protocol} from 'devtools-protocol';
+import {assert} from '../util/assert.js';
+import {createDebuggableDeferredPromise} from '../util/DebuggableDeferredPromise.js';
+import {DeferredPromise} from '../util/DeferredPromise.js';
+import {isErrorLike} from '../util/ErrorLike.js';
+import {CDPSession} from './Connection.js';
+import {EventEmitter} from './EventEmitter.js';
+import {EVALUATION_SCRIPT_URL, ExecutionContext} from './ExecutionContext.js';
+import {Frame} from './Frame.js';
+import {IsolatedWorld, MAIN_WORLD, PUPPETEER_WORLD} from './IsolatedWorld.js';
+import {NetworkManager} from './NetworkManager.js';
+import {Page} from './Page.js';
+import {Target} from './Target.js';
+import {TimeoutSettings} from './TimeoutSettings.js';
+import {debugError} from './util.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'),
+ FrameSwapped: Symbol('FrameManager.FrameSwapped'),
+ LifecycleEvent: Symbol('FrameManager.LifecycleEvent'),
+ FrameNavigatedWithinDocument: Symbol(
+ 'FrameManager.FrameNavigatedWithinDocument'
+ ),
+ ExecutionContextCreated: Symbol('FrameManager.ExecutionContextCreated'),
+ ExecutionContextDestroyed: Symbol('FrameManager.ExecutionContextDestroyed'),
+};
+
+/**
+ * A frame manager manages the frames for a given {@link Page | page}.
+ *
+ * @internal
+ */
+export class FrameManager extends EventEmitter {
+ #page: Page;
+ #networkManager: NetworkManager;
+ #timeoutSettings: TimeoutSettings;
+ #frames = new Map<string, Frame>();
+ #contextIdToContext = new Map<string, ExecutionContext>();
+ #isolatedWorlds = new Set<string>();
+ #mainFrame?: Frame;
+ #client: CDPSession;
+ /**
+ * Keeps track of OOPIF targets/frames (target ID == frame ID for OOPIFs)
+ * that are being initialized.
+ */
+ #framesPendingTargetInit = new Map<string, DeferredPromise<void>>();
+ /**
+ * Keeps track of frames that are in the process of being attached in #onFrameAttached.
+ */
+ #framesPendingAttachment = new Map<string, DeferredPromise<void>>();
+
+ get timeoutSettings(): TimeoutSettings {
+ return this.#timeoutSettings;
+ }
+
+ get networkManager(): NetworkManager {
+ return this.#networkManager;
+ }
+
+ get client(): CDPSession {
+ return this.#client;
+ }
+
+ 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.setupEventListeners(this.#client);
+ }
+
+ private setupEventListeners(session: CDPSession) {
+ session.on('Page.frameAttached', event => {
+ this.#onFrameAttached(session, event.frameId, event.parentFrameId);
+ });
+ session.on('Page.frameNavigated', event => {
+ this.#onFrameNavigated(event.frame);
+ });
+ session.on('Page.navigatedWithinDocument', event => {
+ this.#onFrameNavigatedWithinDocument(event.frameId, event.url);
+ });
+ session.on(
+ 'Page.frameDetached',
+ (event: Protocol.Page.FrameDetachedEvent) => {
+ this.#onFrameDetached(
+ event.frameId,
+ event.reason as Protocol.Page.FrameDetachedEventReason
+ );
+ }
+ );
+ session.on('Page.frameStartedLoading', event => {
+ this.#onFrameStartedLoading(event.frameId);
+ });
+ session.on('Page.frameStoppedLoading', event => {
+ this.#onFrameStoppedLoading(event.frameId);
+ });
+ session.on('Runtime.executionContextCreated', event => {
+ this.#onExecutionContextCreated(event.context, session);
+ });
+ session.on('Runtime.executionContextDestroyed', event => {
+ this.#onExecutionContextDestroyed(event.executionContextId, session);
+ });
+ session.on('Runtime.executionContextsCleared', () => {
+ this.#onExecutionContextsCleared(session);
+ });
+ session.on('Page.lifecycleEvent', event => {
+ this.#onLifecycleEvent(event);
+ });
+ }
+
+ async initialize(
+ targetId: string,
+ client: CDPSession = this.#client
+ ): Promise<void> {
+ try {
+ if (!this.#framesPendingTargetInit.has(targetId)) {
+ this.#framesPendingTargetInit.set(
+ targetId,
+ createDebuggableDeferredPromise(
+ `Waiting for target frame ${targetId} failed`
+ )
+ );
+ }
+ const result = await Promise.all([
+ client.send('Page.enable'),
+ client.send('Page.getFrameTree'),
+ ]);
+
+ const {frameTree} = result[1];
+ this.#handleFrameTree(client, frameTree);
+ await Promise.all([
+ client.send('Page.setLifecycleEventsEnabled', {enabled: true}),
+ client.send('Runtime.enable').then(() => {
+ return this.#createIsolatedWorld(client, UTILITY_WORLD_NAME);
+ }),
+ // TODO: Network manager is not aware of OOP iframes yet.
+ client === this.#client
+ ? this.#networkManager.initialize()
+ : Promise.resolve(),
+ ]);
+ } catch (error) {
+ // The target might have been closed before the initialization finished.
+ if (
+ isErrorLike(error) &&
+ (error.message.includes('Target closed') ||
+ error.message.includes('Session closed'))
+ ) {
+ return;
+ }
+
+ throw error;
+ } finally {
+ this.#framesPendingTargetInit.get(targetId)?.resolve();
+ this.#framesPendingTargetInit.delete(targetId);
+ }
+ }
+
+ executionContextById(
+ contextId: number,
+ session: CDPSession = this.#client
+ ): ExecutionContext {
+ const key = `${session.id()}:${contextId}`;
+ const context = this.#contextIdToContext.get(key);
+ assert(context, 'INTERNAL ERROR: missing context with id = ' + contextId);
+ return context;
+ }
+
+ page(): Page {
+ return this.#page;
+ }
+
+ mainFrame(): Frame {
+ assert(this.#mainFrame, 'Requesting main frame too early!');
+ return this.#mainFrame;
+ }
+
+ frames(): Frame[] {
+ return Array.from(this.#frames.values());
+ }
+
+ frame(frameId: string): Frame | null {
+ return this.#frames.get(frameId) || null;
+ }
+
+ onAttachedToTarget(target: Target): void {
+ if (target._getTargetInfo().type !== 'iframe') {
+ return;
+ }
+
+ const frame = this.#frames.get(target._getTargetInfo().targetId);
+ if (frame) {
+ frame.updateClient(target._session()!);
+ }
+ this.setupEventListeners(target._session()!);
+ this.initialize(target._getTargetInfo().targetId, target._session());
+ }
+
+ onDetachedFromTarget(target: Target): void {
+ const frame = this.#frames.get(target._targetId);
+ if (frame && frame.isOOPFrame()) {
+ // When an OOP iframe is removed from the page, it
+ // will only get a Target.detachedFromTarget event.
+ this.#removeFramesRecursively(frame);
+ }
+ }
+
+ #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);
+ }
+
+ #onFrameStartedLoading(frameId: string): void {
+ const frame = this.#frames.get(frameId);
+ if (!frame) {
+ return;
+ }
+ frame._onLoadingStarted();
+ }
+
+ #onFrameStoppedLoading(frameId: string): void {
+ const frame = this.#frames.get(frameId);
+ if (!frame) {
+ return;
+ }
+ frame._onLoadingStopped();
+ this.emit(FrameManagerEmittedEvents.LifecycleEvent, frame);
+ }
+
+ #handleFrameTree(
+ session: CDPSession,
+ frameTree: Protocol.Page.FrameTree
+ ): void {
+ if (frameTree.frame.parentId) {
+ this.#onFrameAttached(
+ session,
+ frameTree.frame.id,
+ frameTree.frame.parentId
+ );
+ }
+ this.#onFrameNavigated(frameTree.frame);
+ if (!frameTree.childFrames) {
+ return;
+ }
+
+ for (const child of frameTree.childFrames) {
+ this.#handleFrameTree(session, child);
+ }
+ }
+
+ #onFrameAttached(
+ session: CDPSession,
+ frameId: string,
+ parentFrameId: string
+ ): void {
+ if (this.#frames.has(frameId)) {
+ const frame = this.#frames.get(frameId)!;
+ if (session && frame.isOOPFrame()) {
+ // If an OOP iframes becomes a normal iframe again
+ // it is first attached to the parent page before
+ // the target is removed.
+ frame.updateClient(session);
+ }
+ return;
+ }
+ const parentFrame = this.#frames.get(parentFrameId);
+
+ const complete = (parentFrame: Frame) => {
+ assert(parentFrame, `Parent frame ${parentFrameId} not found`);
+ const frame = new Frame(this, parentFrame, frameId, session);
+ this.#frames.set(frame._id, frame);
+ this.emit(FrameManagerEmittedEvents.FrameAttached, frame);
+ };
+
+ if (parentFrame) {
+ return complete(parentFrame);
+ }
+
+ const frame = this.#framesPendingTargetInit.get(parentFrameId);
+ if (frame) {
+ if (!this.#framesPendingAttachment.has(frameId)) {
+ this.#framesPendingAttachment.set(
+ frameId,
+ createDebuggableDeferredPromise(
+ `Waiting for frame ${frameId} to attach failed`
+ )
+ );
+ }
+ frame.then(() => {
+ complete(this.#frames.get(parentFrameId)!);
+ this.#framesPendingAttachment.get(frameId)?.resolve();
+ this.#framesPendingAttachment.delete(frameId);
+ });
+ return;
+ }
+
+ throw new Error(`Parent frame ${parentFrameId} not found`);
+ }
+
+ #onFrameNavigated(framePayload: Protocol.Page.Frame): void {
+ const frameId = framePayload.id;
+ const isMainFrame = !framePayload.parentId;
+ const frame = isMainFrame ? this.#mainFrame : this.#frames.get(frameId);
+
+ const complete = (frame?: Frame) => {
+ assert(
+ isMainFrame || frame,
+ `Missing frame isMainFrame=${isMainFrame}, frameId=${frameId}`
+ );
+
+ // Detach all child frames first.
+ if (frame) {
+ for (const child of frame.childFrames()) {
+ this.#removeFramesRecursively(child);
+ }
+ }
+
+ // Update or create main frame.
+ if (isMainFrame) {
+ if (frame) {
+ // Update frame id to retain frame identity on cross-process navigation.
+ this.#frames.delete(frame._id);
+ frame._id = frameId;
+ } else {
+ // Initial main frame navigation.
+ frame = new Frame(this, null, frameId, this.#client);
+ }
+ this.#frames.set(frameId, frame);
+ this.#mainFrame = frame;
+ }
+
+ // Update frame payload.
+ assert(frame);
+ frame._navigated(framePayload);
+
+ this.emit(FrameManagerEmittedEvents.FrameNavigated, frame);
+ };
+ const pendingFrame = this.#framesPendingAttachment.get(frameId);
+ if (pendingFrame) {
+ pendingFrame.then(() => {
+ complete(isMainFrame ? this.#mainFrame : this.#frames.get(frameId));
+ });
+ } else {
+ complete(frame);
+ }
+ }
+
+ async #createIsolatedWorld(session: CDPSession, name: string): Promise<void> {
+ const key = `${session.id()}:${name}`;
+
+ if (this.#isolatedWorlds.has(key)) {
+ return;
+ }
+
+ await session.send('Page.addScriptToEvaluateOnNewDocument', {
+ source: `//# sourceURL=${EVALUATION_SCRIPT_URL}`,
+ worldName: name,
+ });
+
+ await Promise.all(
+ this.frames()
+ .filter(frame => {
+ return frame._client() === session;
+ })
+ .map(frame => {
+ // Frames might be removed before we send this, so we don't want to
+ // throw an error.
+ return session
+ .send('Page.createIsolatedWorld', {
+ frameId: frame._id,
+ worldName: name,
+ grantUniveralAccess: true,
+ })
+ .catch(debugError);
+ })
+ );
+
+ this.#isolatedWorlds.add(key);
+ }
+
+ #onFrameNavigatedWithinDocument(frameId: string, url: string): void {
+ const frame = this.#frames.get(frameId);
+ if (!frame) {
+ return;
+ }
+ frame._navigatedWithinDocument(url);
+ this.emit(FrameManagerEmittedEvents.FrameNavigatedWithinDocument, frame);
+ this.emit(FrameManagerEmittedEvents.FrameNavigated, frame);
+ }
+
+ #onFrameDetached(
+ frameId: string,
+ reason: Protocol.Page.FrameDetachedEventReason
+ ): void {
+ const frame = this.#frames.get(frameId);
+ if (reason === 'remove') {
+ // Only remove the frame if the reason for the detached event is
+ // an actual removement of the frame.
+ // For frames that become OOP iframes, the reason would be 'swap'.
+ if (frame) {
+ this.#removeFramesRecursively(frame);
+ }
+ } else if (reason === 'swap') {
+ this.emit(FrameManagerEmittedEvents.FrameSwapped, frame);
+ }
+ }
+
+ #onExecutionContextCreated(
+ contextPayload: Protocol.Runtime.ExecutionContextDescription,
+ session: CDPSession
+ ): void {
+ const auxData = contextPayload.auxData as {frameId?: string} | undefined;
+ const frameId = auxData && auxData.frameId;
+ const frame =
+ typeof frameId === 'string' ? this.#frames.get(frameId) : undefined;
+ let world: IsolatedWorld | undefined;
+ if (frame) {
+ // Only care about execution contexts created for the current session.
+ if (frame._client() !== session) {
+ return;
+ }
+
+ if (contextPayload.auxData && !!contextPayload.auxData['isDefault']) {
+ world = frame.worlds[MAIN_WORLD];
+ } else if (
+ contextPayload.name === UTILITY_WORLD_NAME &&
+ !frame.worlds[PUPPETEER_WORLD].hasContext()
+ ) {
+ // In case of multiple sessions to the same target, there's a race between
+ // connections so we might end up creating multiple isolated worlds.
+ // We can use either.
+ world = frame.worlds[PUPPETEER_WORLD];
+ }
+ }
+ const context = new ExecutionContext(
+ frame?._client() || this.#client,
+ contextPayload,
+ world
+ );
+ if (world) {
+ world.setContext(context);
+ }
+ const key = `${session.id()}:${contextPayload.id}`;
+ this.#contextIdToContext.set(key, context);
+ }
+
+ #onExecutionContextDestroyed(
+ executionContextId: number,
+ session: CDPSession
+ ): void {
+ const key = `${session.id()}:${executionContextId}`;
+ const context = this.#contextIdToContext.get(key);
+ if (!context) {
+ return;
+ }
+ this.#contextIdToContext.delete(key);
+ if (context._world) {
+ context._world.clearContext();
+ }
+ }
+
+ #onExecutionContextsCleared(session: CDPSession): void {
+ for (const [key, context] of this.#contextIdToContext.entries()) {
+ // Make sure to only clear execution contexts that belong
+ // to the current session.
+ if (context._client !== session) {
+ continue;
+ }
+ if (context._world) {
+ context._world.clearContext();
+ }
+ this.#contextIdToContext.delete(key);
+ }
+ }
+
+ #removeFramesRecursively(frame: Frame): void {
+ for (const child of frame.childFrames()) {
+ this.#removeFramesRecursively(child);
+ }
+ frame._detach();
+ this.#frames.delete(frame._id);
+ this.emit(FrameManagerEmittedEvents.FrameDetached, frame);
+ }
+}
diff --git a/remote/test/puppeteer/src/common/HTTPRequest.ts b/remote/test/puppeteer/src/common/HTTPRequest.ts
new file mode 100644
index 0000000000..b35c80890b
--- /dev/null
+++ b/remote/test/puppeteer/src/common/HTTPRequest.ts
@@ -0,0 +1,839 @@
+/**
+ * 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';
+import {ProtocolMapping} from 'devtools-protocol/types/protocol-mapping.js';
+import {assert} from '../util/assert.js';
+import {ProtocolError} from './Errors.js';
+import {EventEmitter} from './EventEmitter.js';
+import {Frame} from './Frame.js';
+import {debugError, isString} from './util.js';
+import {HTTPResponse} from './HTTPResponse.js';
+
+/**
+ * @public
+ */
+export interface ContinueRequestOverrides {
+ /**
+ * If set, the request URL will change. This is not a redirect.
+ */
+ url?: string;
+ method?: string;
+ postData?: string;
+ headers?: Record<string, string>;
+}
+
+/**
+ * @public
+ */
+export interface InterceptResolutionState {
+ action: InterceptResolutionAction;
+ priority?: number;
+}
+
+/**
+ * Required response data to fulfill a request with.
+ *
+ * @public
+ */
+export interface ResponseForRequest {
+ status: number;
+ /**
+ * Optional response headers. All values are converted to strings.
+ */
+ headers: Record<string, unknown>;
+ contentType: string;
+ body: string | Buffer;
+}
+
+/**
+ * Resource types for HTTPRequests as perceived by the rendering engine.
+ *
+ * @public
+ */
+export type ResourceType = Lowercase<Protocol.Network.ResourceType>;
+
+/**
+ * The default cooperative request interception resolution priority
+ *
+ * @public
+ */
+export const DEFAULT_INTERCEPT_RESOLUTION_PRIORITY = 0;
+
+interface CDPSession extends EventEmitter {
+ send<T extends keyof ProtocolMapping.Commands>(
+ method: T,
+ ...paramArgs: ProtocolMapping.Commands[T]['paramsType']
+ ): Promise<ProtocolMapping.Commands[T]['returnType']>;
+}
+
+/**
+ * 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 | undefined;
+ /**
+ * @internal
+ */
+ _failureText: string | null = null;
+ /**
+ * @internal
+ */
+ _response: HTTPResponse | null = null;
+ /**
+ * @internal
+ */
+ _fromMemoryCache = false;
+ /**
+ * @internal
+ */
+ _redirectChain: HTTPRequest[];
+
+ #client: CDPSession;
+ #isNavigationRequest: boolean;
+ #allowInterception: boolean;
+ #interceptionHandled = false;
+ #url: string;
+ #resourceType: ResourceType;
+
+ #method: string;
+ #postData?: string;
+ #headers: Record<string, string> = {};
+ #frame: Frame | null;
+ #continueRequestOverrides: ContinueRequestOverrides;
+ #responseForRequest: Partial<ResponseForRequest> | null = null;
+ #abortErrorReason: Protocol.Network.ErrorReason | null = null;
+ #interceptResolutionState: InterceptResolutionState = {
+ action: InterceptResolutionAction.None,
+ };
+ #interceptHandlers: Array<() => void | PromiseLike<any>>;
+ #initiator: Protocol.Network.Initiator;
+
+ /**
+ * Warning! Using this client can break Puppeteer. Use with caution.
+ *
+ * @experimental
+ */
+ get client(): CDPSession {
+ return this.#client;
+ }
+
+ /**
+ * @internal
+ */
+ constructor(
+ client: CDPSession,
+ frame: Frame | null,
+ interceptionId: string | undefined,
+ 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 || 'other').toLowerCase() as ResourceType;
+ this.#method = event.request.method;
+ this.#postData = event.request.postData;
+ this.#frame = frame;
+ this._redirectChain = redirectChain;
+ this.#continueRequestOverrides = {};
+ this.#interceptHandlers = [];
+ this.#initiator = event.initiator;
+
+ for (const [key, value] of Object.entries(event.request.headers)) {
+ this.#headers[key.toLowerCase()] = value;
+ }
+ }
+
+ /**
+ * @returns the URL of the request
+ */
+ url(): string {
+ return this.#url;
+ }
+
+ /**
+ * @returns the `ContinueRequestOverrides` that will be used
+ * if the interception is allowed to continue (ie, `abort()` and
+ * `respond()` aren't called).
+ */
+ continueRequestOverrides(): ContinueRequestOverrides {
+ assert(this.#allowInterception, 'Request Interception is not enabled!');
+ return this.#continueRequestOverrides;
+ }
+
+ /**
+ * @returns The `ResponseForRequest` that gets used if the
+ * interception is allowed to respond (ie, `abort()` is not called).
+ */
+ responseForRequest(): Partial<ResponseForRequest> | null {
+ assert(this.#allowInterception, 'Request Interception is not enabled!');
+ return this.#responseForRequest;
+ }
+
+ /**
+ * @returns the most recent reason for aborting the request
+ */
+ abortErrorReason(): Protocol.Network.ErrorReason | null {
+ assert(this.#allowInterception, 'Request Interception is not enabled!');
+ return this.#abortErrorReason;
+ }
+
+ /**
+ * @returns An InterceptResolutionState object describing the current resolution
+ * action and priority.
+ *
+ * InterceptResolutionState contains:
+ * action: InterceptResolutionAction
+ * priority?: number
+ *
+ * InterceptResolutionAction is one of: `abort`, `respond`, `continue`,
+ * `disabled`, `none`, or `already-handled`.
+ */
+ interceptResolutionState(): InterceptResolutionState {
+ if (!this.#allowInterception) {
+ return {action: InterceptResolutionAction.Disabled};
+ }
+ if (this.#interceptionHandled) {
+ return {action: InterceptResolutionAction.AlreadyHandled};
+ }
+ return {...this.#interceptResolutionState};
+ }
+
+ /**
+ * @returns `true` if the intercept resolution has already been handled,
+ * `false` otherwise.
+ */
+ isInterceptResolutionHandled(): boolean {
+ return this.#interceptionHandled;
+ }
+
+ /**
+ * Adds an async request handler to the processing queue.
+ * Deferred handlers are not guaranteed to execute in any particular order,
+ * but they are guaranteed to resolve before the request interception
+ * is finalized.
+ */
+ enqueueInterceptAction(
+ pendingHandler: () => void | PromiseLike<unknown>
+ ): void {
+ this.#interceptHandlers.push(pendingHandler);
+ }
+
+ /**
+ * Awaits pending interception handlers and then decides how to fulfill
+ * the request interception.
+ */
+ async finalizeInterceptions(): Promise<void> {
+ await this.#interceptHandlers.reduce((promiseChain, interceptAction) => {
+ return promiseChain.then(interceptAction);
+ }, Promise.resolve());
+ const {action} = this.interceptResolutionState();
+ switch (action) {
+ case 'abort':
+ return this.#abort(this.#abortErrorReason);
+ case 'respond':
+ if (this.#responseForRequest === null) {
+ throw new Error('Response is missing for the interception');
+ }
+ return this.#respond(this.#responseForRequest);
+ case 'continue':
+ return this.#continue(this.#continueRequestOverrides);
+ }
+ }
+
+ /**
+ * Contains the request's resource type as it was perceived by the rendering
+ * engine.
+ */
+ resourceType(): ResourceType {
+ 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 A matching `HTTPResponse` object, or null if the response has not
+ * been received yet.
+ */
+ response(): HTTPResponse | null {
+ return this._response;
+ }
+
+ /**
+ * @returns the frame that initiated the request, or null if navigating to
+ * error pages.
+ */
+ 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;
+ }
+
+ /**
+ * @returns the initiator of the request.
+ */
+ initiator(): Protocol.Network.Initiator {
+ return this.#initiator;
+ }
+
+ /**
+ * A `redirectChain` is a chain of requests initiated to fetch a resource.
+ * @remarks
+ *
+ * `redirectChain` is shared between all the requests of the same chain.
+ *
+ * For example, if the website `http://example.com` has a single redirect to
+ * `https://example.com`, then the chain will contain one request:
+ *
+ * ```ts
+ * const response = await page.goto('http://example.com');
+ * const chain = response.request().redirectChain();
+ * console.log(chain.length); // 1
+ * console.log(chain[0].url()); // 'http://example.com'
+ * ```
+ *
+ * If the website `https://google.com` has no redirects, then the chain will be empty:
+ *
+ * ```ts
+ * const response = await page.goto('https://google.com');
+ * const chain = response.request().redirectChain();
+ * console.log(chain.length); // 0
+ * ```
+ *
+ * @returns the chain of requests - if a server responds with at least a
+ * single redirect, this chain will contain all requests that were redirected.
+ */
+ redirectChain(): HTTPRequest[] {
+ return this._redirectChain.slice();
+ }
+
+ /**
+ * Access information about the request's failure.
+ *
+ * @remarks
+ *
+ * @example
+ *
+ * Example of logging all failed requests:
+ *
+ * ```ts
+ * page.on('requestfailed', request => {
+ * console.log(request.url() + ' ' + request.failure().errorText);
+ * });
+ * ```
+ *
+ * @returns `null` unless the request failed. If the request fails this can
+ * return an object with `errorText` containing a human-readable error
+ * message, e.g. `net::ERR_FAILED`. It is not guaranteed that there will be
+ * failure text if the request fails.
+ */
+ 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
+ *
+ * ```ts
+ * await page.setRequestInterception(true);
+ * page.on('request', request => {
+ * // Override headers
+ * const headers = Object.assign({}, request.headers(), {
+ * foo: 'bar', // set "foo" header
+ * origin: undefined, // remove "origin" header
+ * });
+ * request.continue({headers});
+ * });
+ * ```
+ *
+ * @param overrides - optional overrides to apply to the request.
+ * @param priority - If provided, intercept is resolved using
+ * cooperative handling rules. Otherwise, intercept is resolved
+ * immediately.
+ */
+ async continue(
+ overrides: ContinueRequestOverrides = {},
+ priority?: number
+ ): Promise<void> {
+ // Request interception is not supported for data: urls.
+ if (this.#url.startsWith('data:')) {
+ return;
+ }
+ assert(this.#allowInterception, 'Request Interception is not enabled!');
+ assert(!this.#interceptionHandled, 'Request is already handled!');
+ if (priority === undefined) {
+ return this.#continue(overrides);
+ }
+ this.#continueRequestOverrides = overrides;
+ if (
+ this.#interceptResolutionState.priority === undefined ||
+ priority > this.#interceptResolutionState.priority
+ ) {
+ this.#interceptResolutionState = {
+ action: InterceptResolutionAction.Continue,
+ priority,
+ };
+ return;
+ }
+ if (priority === this.#interceptResolutionState.priority) {
+ if (
+ this.#interceptResolutionState.action === 'abort' ||
+ this.#interceptResolutionState.action === 'respond'
+ ) {
+ return;
+ }
+ this.#interceptResolutionState.action =
+ InterceptResolutionAction.Continue;
+ }
+ return;
+ }
+
+ async #continue(overrides: ContinueRequestOverrides = {}): Promise<void> {
+ const {url, method, postData, headers} = overrides;
+ this.#interceptionHandled = true;
+
+ const postDataBinaryBase64 = postData
+ ? Buffer.from(postData).toString('base64')
+ : undefined;
+
+ if (this._interceptionId === undefined) {
+ throw new Error(
+ 'HTTPRequest is missing _interceptionId needed for Fetch.continueRequest'
+ );
+ }
+ await this.#client
+ .send('Fetch.continueRequest', {
+ requestId: this._interceptionId,
+ url,
+ method,
+ postData: postDataBinaryBase64,
+ headers: headers ? headersArray(headers) : undefined,
+ })
+ .catch(error => {
+ this.#interceptionHandled = false;
+ return handleError(error);
+ });
+ }
+
+ /**
+ * 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:
+ *
+ * ```ts
+ * await page.setRequestInterception(true);
+ * page.on('request', request => {
+ * request.respond({
+ * status: 404,
+ * contentType: 'text/plain',
+ * body: 'Not Found!',
+ * });
+ * });
+ * ```
+ *
+ * NOTE: Mocking responses for dataURL requests is not supported.
+ * Calling `request.respond` for a dataURL request is a noop.
+ *
+ * @param response - the response to fulfill the request with.
+ * @param priority - If provided, intercept is resolved using
+ * cooperative handling rules. Otherwise, intercept is resolved
+ * immediately.
+ */
+ async respond(
+ response: Partial<ResponseForRequest>,
+ priority?: number
+ ): Promise<void> {
+ // Mocking responses for dataURL requests is not currently supported.
+ if (this.#url.startsWith('data:')) {
+ return;
+ }
+ assert(this.#allowInterception, 'Request Interception is not enabled!');
+ assert(!this.#interceptionHandled, 'Request is already handled!');
+ if (priority === undefined) {
+ return this.#respond(response);
+ }
+ this.#responseForRequest = response;
+ if (
+ this.#interceptResolutionState.priority === undefined ||
+ priority > this.#interceptResolutionState.priority
+ ) {
+ this.#interceptResolutionState = {
+ action: InterceptResolutionAction.Respond,
+ priority,
+ };
+ return;
+ }
+ if (priority === this.#interceptResolutionState.priority) {
+ if (this.#interceptResolutionState.action === 'abort') {
+ return;
+ }
+ this.#interceptResolutionState.action = InterceptResolutionAction.Respond;
+ }
+ }
+
+ async #respond(response: Partial<ResponseForRequest>): Promise<void> {
+ this.#interceptionHandled = true;
+
+ const responseBody: Buffer | null =
+ response.body && isString(response.body)
+ ? Buffer.from(response.body)
+ : (response.body as Buffer) || null;
+
+ const responseHeaders: Record<string, string | string[]> = {};
+ if (response.headers) {
+ for (const header of Object.keys(response.headers)) {
+ const value = response.headers[header];
+
+ responseHeaders[header.toLowerCase()] = Array.isArray(value)
+ ? value.map(item => {
+ return String(item);
+ })
+ : String(value);
+ }
+ }
+ if (response.contentType) {
+ responseHeaders['content-type'] = response.contentType;
+ }
+ if (responseBody && !('content-length' in responseHeaders)) {
+ responseHeaders['content-length'] = String(
+ Buffer.byteLength(responseBody)
+ );
+ }
+
+ const status = response.status || 200;
+ if (this._interceptionId === undefined) {
+ throw new Error(
+ 'HTTPRequest is missing _interceptionId needed for Fetch.fulfillRequest'
+ );
+ }
+ await this.#client
+ .send('Fetch.fulfillRequest', {
+ requestId: this._interceptionId,
+ responseCode: status,
+ responsePhrase: STATUS_TEXTS[status],
+ responseHeaders: headersArray(responseHeaders),
+ body: responseBody ? responseBody.toString('base64') : undefined,
+ })
+ .catch(error => {
+ this.#interceptionHandled = false;
+ return handleError(error);
+ });
+ }
+
+ /**
+ * 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.
+ * @param priority - If provided, intercept is resolved using
+ * cooperative handling rules. Otherwise, intercept is resolved
+ * immediately.
+ */
+ async abort(
+ errorCode: ErrorCode = 'failed',
+ priority?: number
+ ): Promise<void> {
+ // Request interception is not supported for data: urls.
+ if (this.#url.startsWith('data:')) {
+ return;
+ }
+ const errorReason = errorReasons[errorCode];
+ assert(errorReason, 'Unknown error code: ' + errorCode);
+ assert(this.#allowInterception, 'Request Interception is not enabled!');
+ assert(!this.#interceptionHandled, 'Request is already handled!');
+ if (priority === undefined) {
+ return this.#abort(errorReason);
+ }
+ this.#abortErrorReason = errorReason;
+ if (
+ this.#interceptResolutionState.priority === undefined ||
+ priority >= this.#interceptResolutionState.priority
+ ) {
+ this.#interceptResolutionState = {
+ action: InterceptResolutionAction.Abort,
+ priority,
+ };
+ return;
+ }
+ }
+
+ async #abort(
+ errorReason: Protocol.Network.ErrorReason | null
+ ): Promise<void> {
+ this.#interceptionHandled = true;
+ if (this._interceptionId === undefined) {
+ throw new Error(
+ 'HTTPRequest is missing _interceptionId needed for Fetch.failRequest'
+ );
+ }
+ await this.#client
+ .send('Fetch.failRequest', {
+ requestId: this._interceptionId,
+ errorReason: errorReason || 'Failed',
+ })
+ .catch(handleError);
+ }
+}
+
+/**
+ * @public
+ */
+export enum InterceptResolutionAction {
+ Abort = 'abort',
+ Respond = 'respond',
+ Continue = 'continue',
+ Disabled = 'disabled',
+ None = 'none',
+ AlreadyHandled = 'already-handled',
+}
+
+/**
+ * @public
+ *
+ * @deprecated please use {@link InterceptResolutionAction} instead.
+ */
+export type InterceptResolutionStrategy = InterceptResolutionAction;
+
+/**
+ * @public
+ */
+export type ErrorCode =
+ | 'aborted'
+ | 'accessdenied'
+ | 'addressunreachable'
+ | 'blockedbyclient'
+ | 'blockedbyresponse'
+ | 'connectionaborted'
+ | 'connectionclosed'
+ | 'connectionfailed'
+ | 'connectionrefused'
+ | 'connectionreset'
+ | 'internetdisconnected'
+ | 'namenotresolved'
+ | 'timedout'
+ | 'failed';
+
+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;
+
+/**
+ * @public
+ */
+export type ActionResult = 'continue' | 'abort' | 'respond';
+
+function headersArray(
+ headers: Record<string, string | string[]>
+): Array<{name: string; value: string}> {
+ const result = [];
+ for (const name in headers) {
+ const value = headers[name];
+
+ if (!Object.is(value, undefined)) {
+ const values = Array.isArray(value) ? value : [value];
+
+ result.push(
+ ...values.map(value => {
+ return {name, value: value + ''};
+ })
+ );
+ }
+ }
+ return result;
+}
+
+async function handleError(error: ProtocolError) {
+ if (['Invalid header'].includes(error.originalMessage)) {
+ throw error;
+ }
+ // In certain cases, protocol will return error if the request was
+ // already canceled or the page was closed. We should tolerate these
+ // errors.
+ debugError(error);
+}
+
+// List taken from
+// https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml
+// with extra 306 and 418 codes.
+const STATUS_TEXTS: {[key: string]: string | undefined} = {
+ '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..ae3c372908
--- /dev/null
+++ b/remote/test/puppeteer/src/common/HTTPResponse.ts
@@ -0,0 +1,278 @@
+/**
+ * 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 {ProtocolMapping} from 'devtools-protocol/types/protocol-mapping.js';
+
+import {EventEmitter} from './EventEmitter.js';
+import {Frame} from './Frame.js';
+import {HTTPRequest} from './HTTPRequest.js';
+import {SecurityDetails} from './SecurityDetails.js';
+import {Protocol} from 'devtools-protocol';
+import {ProtocolError} from './Errors.js';
+
+/**
+ * @public
+ */
+export interface RemoteAddress {
+ ip?: string;
+ port?: number;
+}
+
+interface CDPSession extends EventEmitter {
+ send<T extends keyof ProtocolMapping.Commands>(
+ method: T,
+ ...paramArgs: ProtocolMapping.Commands[T]['paramsType']
+ ): Promise<ProtocolMapping.Commands[T]['returnType']>;
+}
+
+/**
+ * The HTTPResponse class represents responses which are received by the
+ * {@link Page} class.
+ *
+ * @public
+ */
+export class HTTPResponse {
+ #client: CDPSession;
+ #request: HTTPRequest;
+ #contentPromise: Promise<Buffer> | null = null;
+ #bodyLoadedPromise: Promise<Error | void>;
+ #bodyLoadedPromiseFulfill: (err: Error | void) => void = () => {};
+ #remoteAddress: RemoteAddress;
+ #status: number;
+ #statusText: string;
+ #url: string;
+ #fromDiskCache: boolean;
+ #fromServiceWorker: boolean;
+ #headers: Record<string, string> = {};
+ #securityDetails: SecurityDetails | null;
+ #timing: Protocol.Network.ResourceTiming | null;
+
+ /**
+ * @internal
+ */
+ constructor(
+ client: CDPSession,
+ request: HTTPRequest,
+ responsePayload: Protocol.Network.Response,
+ extraInfo: Protocol.Network.ResponseReceivedExtraInfoEvent | null
+ ) {
+ this.#client = client;
+ this.#request = request;
+
+ this.#bodyLoadedPromise = new Promise(fulfill => {
+ this.#bodyLoadedPromiseFulfill = fulfill;
+ });
+
+ this.#remoteAddress = {
+ ip: responsePayload.remoteIPAddress,
+ port: responsePayload.remotePort,
+ };
+ this.#statusText =
+ this.#parseStatusTextFromExtrInfo(extraInfo) ||
+ responsePayload.statusText;
+ this.#url = request.url();
+ this.#fromDiskCache = !!responsePayload.fromDiskCache;
+ this.#fromServiceWorker = !!responsePayload.fromServiceWorker;
+
+ this.#status = extraInfo ? extraInfo.statusCode : responsePayload.status;
+ const headers = extraInfo ? extraInfo.headers : responsePayload.headers;
+ for (const [key, value] of Object.entries(headers)) {
+ this.#headers[key.toLowerCase()] = value;
+ }
+
+ this.#securityDetails = responsePayload.securityDetails
+ ? new SecurityDetails(responsePayload.securityDetails)
+ : null;
+ this.#timing = responsePayload.timing || null;
+ }
+
+ #parseStatusTextFromExtrInfo(
+ extraInfo: Protocol.Network.ResponseReceivedExtraInfoEvent | null
+ ): string | undefined {
+ if (!extraInfo || !extraInfo.headersText) {
+ return;
+ }
+ const firstLine = extraInfo.headersText.split('\r', 1)[0];
+ if (!firstLine) {
+ return;
+ }
+ const match = firstLine.match(/[^ ]* [^ ]* (.*)/);
+ if (!match) {
+ return;
+ }
+ const statusText = match[1];
+ if (!statusText) {
+ return;
+ }
+ return statusText;
+ }
+
+ /**
+ * @internal
+ */
+ _resolveBody(err: Error | null): void {
+ if (err) {
+ return this.#bodyLoadedPromiseFulfill(err);
+ }
+ return this.#bodyLoadedPromiseFulfill();
+ }
+
+ /**
+ * @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 Timing information related to the response.
+ */
+ timing(): Protocol.Network.ResourceTiming | null {
+ return this.#timing;
+ }
+
+ /**
+ * @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;
+ }
+ try {
+ const response = await this.#client.send('Network.getResponseBody', {
+ requestId: this.#request._requestId,
+ });
+ return Buffer.from(
+ response.body,
+ response.base64Encoded ? 'base64' : 'utf8'
+ );
+ } catch (error) {
+ if (
+ error instanceof ProtocolError &&
+ error.originalMessage === 'No resource with given identifier found'
+ ) {
+ throw new ProtocolError(
+ 'Could not load body for this request. This might happen if the request is a preflight request.'
+ );
+ }
+
+ throw error;
+ }
+ });
+ }
+ return this.#contentPromise;
+ }
+
+ /**
+ * @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..8234f49355
--- /dev/null
+++ b/remote/test/puppeteer/src/common/Input.ts
@@ -0,0 +1,677 @@
+/**
+ * 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 '../util/assert.js';
+import {CDPSession} from './Connection.js';
+import {_keyDefinitions, KeyDefinition, KeyInput} from './USKeyboardLayout.js';
+import {Protocol} from 'devtools-protocol';
+import {Point} from './JSHandle.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:
+ *
+ * ```ts
+ * await page.keyboard.type('Hello World!');
+ * await page.keyboard.press('ArrowLeft');
+ *
+ * await page.keyboard.down('Shift');
+ * for (let i = 0; i < ' World'.length; i++)
+ * await page.keyboard.press('ArrowLeft');
+ * await page.keyboard.up('Shift');
+ *
+ * await page.keyboard.press('Backspace');
+ * // Result text will end up saying 'Hello!'
+ * ```
+ *
+ * @example
+ * An example of pressing `A`
+ *
+ * ```ts
+ * await page.keyboard.down('Shift');
+ * await page.keyboard.press('KeyA');
+ * await page.keyboard.up('Shift');
+ * ```
+ *
+ * @public
+ */
+export class Keyboard {
+ #client: CDPSession;
+ #pressedKeys = new Set<string>();
+
+ /**
+ * @internal
+ */
+ _modifiers = 0;
+
+ /**
+ * @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,
+ });
+ }
+
+ #modifierBit(key: string): number {
+ if (key === 'Alt') {
+ return 1;
+ }
+ if (key === 'Control') {
+ return 2;
+ }
+ if (key === 'Meta') {
+ return 4;
+ }
+ if (key === 'Shift') {
+ return 8;
+ }
+ return 0;
+ }
+
+ #keyDescriptionForString(keyString: KeyInput): KeyDescription {
+ const shift = this._modifiers & 8;
+ const description = {
+ key: '',
+ keyCode: 0,
+ code: '',
+ text: '',
+ location: 0,
+ };
+
+ const definition = _keyDefinitions[keyString];
+ assert(definition, `Unknown key: "${keyString}"`);
+
+ if (definition.key) {
+ description.key = definition.key;
+ }
+ if (shift && definition.shiftKey) {
+ description.key = definition.shiftKey;
+ }
+
+ if (definition.keyCode) {
+ description.keyCode = definition.keyCode;
+ }
+ if (shift && definition.shiftKeyCode) {
+ description.keyCode = definition.shiftKeyCode;
+ }
+
+ if (definition.code) {
+ description.code = definition.code;
+ }
+
+ if (definition.location) {
+ description.location = definition.location;
+ }
+
+ if (description.key.length === 1) {
+ description.text = description.key;
+ }
+
+ if (definition.text) {
+ description.text = definition.text;
+ }
+ if (shift && definition.shiftText) {
+ description.text = definition.shiftText;
+ }
+
+ // if any modifiers besides shift are pressed, no text should be sent
+ if (this._modifiers & ~8) {
+ description.text = '';
+ }
+
+ return description;
+ }
+
+ /**
+ * 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
+ *
+ * ```ts
+ * 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 as KeyInput];
+ }
+
+ /**
+ * Sends a `keydown`, `keypress`/`input`,
+ * and `keyup` event for each character in the text.
+ *
+ * @remarks
+ * To press a special key, like `Control` or `ArrowDown`,
+ * use {@link Keyboard.press}.
+ *
+ * Modifier keys DO NOT effect `keyboard.type`.
+ * Holding down `Shift` will not type the text in upper case.
+ *
+ * @example
+ *
+ * ```ts
+ * await page.keyboard.type('Hello'); // Types instantly
+ * await page.keyboard.type('World', {delay: 100}); // Types slower, like a user
+ * ```
+ *
+ * @param text - A text to type into a focused element.
+ * @param options - An object of options. Accepts delay which,
+ * if specified, is the time to wait between `keydown` and `keyup` in milliseconds.
+ * Defaults to 0.
+ */
+ async type(text: string, options: {delay?: number} = {}): Promise<void> {
+ const delay = options.delay || undefined;
+ for (const char of text) {
+ if (this.charIsKey(char)) {
+ await this.press(char, {delay});
+ } else {
+ if (delay) {
+ await new Promise(f => {
+ return setTimeout(f, delay);
+ });
+ }
+ await this.sendCharacter(char);
+ }
+ }
+ }
+
+ /**
+ * 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 => {
+ return setTimeout(f, options.delay);
+ });
+ }
+ await this.up(key);
+ }
+}
+
+/**
+ * @public
+ */
+export type MouseButton = 'left' | 'right' | 'middle' | 'back' | 'forward';
+
+/**
+ * @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
+ *
+ * ```ts
+ * // Using ‘page.mouse’ to trace a 100x100 square.
+ * await page.mouse.move(0, 0);
+ * await page.mouse.down();
+ * await page.mouse.move(0, 100);
+ * await page.mouse.move(100, 100);
+ * await page.mouse.move(100, 0);
+ * await page.mouse.move(0, 0);
+ * await page.mouse.up();
+ * ```
+ *
+ * **Note**: The mouse events trigger synthetic `MouseEvent`s.
+ * This means that it does not fully replicate the functionality of what a normal user
+ * would be able to do with their mouse.
+ *
+ * For example, dragging and selecting text is not possible using `page.mouse`.
+ * Instead, you can use the {@link https://developer.mozilla.org/en-US/docs/Web/API/DocumentOrShadowRoot/getSelection | `DocumentOrShadowRoot.getSelection()`} functionality implemented in the platform.
+ *
+ * @example
+ * For example, if you want to select all content between nodes:
+ *
+ * ```ts
+ * await page.evaluate(
+ * (from, to) => {
+ * const selection = from.getRootNode().getSelection();
+ * const range = document.createRange();
+ * range.setStartBefore(from);
+ * range.setEndAfter(to);
+ * selection.removeAllRanges();
+ * selection.addRange(range);
+ * },
+ * fromJSHandle,
+ * toJSHandle
+ * );
+ * ```
+ *
+ * If you then would want to copy-paste your selection, you can use the clipboard api:
+ *
+ * ```ts
+ * // The clipboard api does not allow you to copy, unless the tab is focused.
+ * await page.bringToFront();
+ * await page.evaluate(() => {
+ * // Copy the selected content to the clipboard
+ * document.execCommand('copy');
+ * // Obtain the content of the clipboard as a string
+ * return navigator.clipboard.readText();
+ * });
+ * ```
+ *
+ * **Note**: If you want access to the clipboard API,
+ * you have to give it permission to do so:
+ *
+ * ```ts
+ * await browser
+ * .defaultBrowserContext()
+ * .overridePermissions('<your origin>', [
+ * 'clipboard-read',
+ * 'clipboard-write',
+ * ]);
+ * ```
+ *
+ * @public
+ */
+export class Mouse {
+ #client: CDPSession;
+ #keyboard: Keyboard;
+ #x = 0;
+ #y = 0;
+ #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 this.move(x, y);
+ await this.down(options);
+ await new Promise(f => {
+ return setTimeout(f, delay);
+ });
+ await this.up(options);
+ } else {
+ await this.move(x, y);
+ await this.down(options);
+ await 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:
+ *
+ * ```ts
+ * await page.goto(
+ * 'https://mdn.mozillademos.org/en-US/docs/Web/API/Element/wheel_event$samples/Scaling_an_element_via_the_wheel?revision=1587366'
+ * );
+ *
+ * const elem = await page.$('div');
+ * const boundingBox = await elem.boundingBox();
+ * await page.mouse.move(
+ * boundingBox.x + boundingBox.width / 2,
+ * boundingBox.y + boundingBox.height / 2
+ * );
+ *
+ * await page.mouse.wheel({deltaY: -100});
+ * ```
+ */
+ 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',
+ });
+ }
+
+ /**
+ * Dispatches a `drag` event.
+ * @param start - starting point for drag
+ * @param target - point to drag to
+ */
+ async drag(start: Point, target: Point): Promise<Protocol.Input.DragData> {
+ const promise = new Promise<Protocol.Input.DragData>(resolve => {
+ this.#client.once('Input.dragIntercepted', event => {
+ return resolve(event.data);
+ });
+ });
+ await this.move(start.x, start.y);
+ await this.down();
+ await this.move(target.x, target.y);
+ return promise;
+ }
+
+ /**
+ * Dispatches a `dragenter` event.
+ * @param target - point for emitting `dragenter` event
+ * @param data - drag data containing items and operations mask
+ */
+ async dragEnter(target: Point, data: Protocol.Input.DragData): Promise<void> {
+ await this.#client.send('Input.dispatchDragEvent', {
+ type: 'dragEnter',
+ x: target.x,
+ y: target.y,
+ modifiers: this.#keyboard._modifiers,
+ data,
+ });
+ }
+
+ /**
+ * Dispatches a `dragover` event.
+ * @param target - point for emitting `dragover` event
+ * @param data - drag data containing items and operations mask
+ */
+ async dragOver(target: Point, data: Protocol.Input.DragData): Promise<void> {
+ await this.#client.send('Input.dispatchDragEvent', {
+ type: 'dragOver',
+ x: target.x,
+ y: target.y,
+ modifiers: this.#keyboard._modifiers,
+ data,
+ });
+ }
+
+ /**
+ * Performs a dragenter, dragover, and drop in sequence.
+ * @param target - point to drop on
+ * @param data - drag data containing items and operations mask
+ */
+ async drop(target: Point, data: Protocol.Input.DragData): Promise<void> {
+ await this.#client.send('Input.dispatchDragEvent', {
+ type: 'drop',
+ x: target.x,
+ y: target.y,
+ modifiers: this.#keyboard._modifiers,
+ data,
+ });
+ }
+
+ /**
+ * Performs a drag, dragenter, dragover, and drop in sequence.
+ * @param target - point to drag from
+ * @param target - point to drop on
+ * @param options - An object of options. Accepts delay which,
+ * if specified, is the time to wait between `dragover` and `drop` in milliseconds.
+ * Defaults to 0.
+ */
+ async dragAndDrop(
+ start: Point,
+ target: Point,
+ options: {delay?: number} = {}
+ ): Promise<void> {
+ const {delay = null} = options;
+ const data = await this.drag(start, target);
+ await this.dragEnter(target, data);
+ await this.dragOver(target, data);
+ if (delay) {
+ await new Promise(resolve => {
+ return setTimeout(resolve, delay);
+ });
+ }
+ await this.drop(target, data);
+ await this.up();
+ }
+}
+
+/**
+ * The Touchscreen class exposes touchscreen events.
+ * @public
+ */
+export class Touchscreen {
+ #client: CDPSession;
+ #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/IsolatedWorld.ts b/remote/test/puppeteer/src/common/IsolatedWorld.ts
new file mode 100644
index 0000000000..9fe587e7c4
--- /dev/null
+++ b/remote/test/puppeteer/src/common/IsolatedWorld.ts
@@ -0,0 +1,907 @@
+/**
+ * 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 {Protocol} from 'devtools-protocol';
+import {source as injectedSource} from '../generated/injected.js';
+import {assert} from '../util/assert.js';
+import {createDeferredPromise} from '../util/DeferredPromise.js';
+import {CDPSession} from './Connection.js';
+import {ElementHandle} from './ElementHandle.js';
+import {TimeoutError} from './Errors.js';
+import {ExecutionContext} from './ExecutionContext.js';
+import {Frame} from './Frame.js';
+import {FrameManager} from './FrameManager.js';
+import {MouseButton} from './Input.js';
+import {JSHandle} from './JSHandle.js';
+import {LifecycleWatcher, PuppeteerLifeCycleEvent} from './LifecycleWatcher.js';
+import {TimeoutSettings} from './TimeoutSettings.js';
+import {EvaluateFunc, HandleFor, NodeFor} from './types.js';
+import {
+ createJSHandle,
+ debugError,
+ isNumber,
+ isString,
+ makePredicateString,
+ pageBindingInitString,
+} from './util.js';
+
+// 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 | null,
+ waitForVisible: boolean,
+ waitForHidden: boolean
+) => Element | null | boolean;
+
+/**
+ * @public
+ */
+export interface WaitForSelectorOptions {
+ /**
+ * Wait for the selected element to be present in DOM and to be visible, i.e.
+ * to not have `display: none` or `visibility: hidden` CSS properties.
+ *
+ * @defaultValue `false`
+ */
+ visible?: boolean;
+ /**
+ * Wait for the selected element to not be found in the DOM or to be hidden,
+ * i.e. have `display: none` or `visibility: hidden` CSS properties.
+ *
+ * @defaultValue `false`
+ */
+ hidden?: boolean;
+ /**
+ * Maximum time to wait in milliseconds. Pass `0` to disable timeout.
+ *
+ * The default value can be changed by using {@link Page.setDefaultTimeout}
+ *
+ * @defaultValue `30000` (30 seconds)
+ */
+ timeout?: number;
+}
+
+/**
+ * @internal
+ */
+export interface PageBinding {
+ name: string;
+ pptrFunction: Function;
+}
+
+/**
+ * A unique key for {@link IsolatedWorldChart} to denote the default world.
+ * Execution contexts are automatically created in the default world.
+ *
+ * @internal
+ */
+export const MAIN_WORLD = Symbol('mainWorld');
+/**
+ * A unique key for {@link IsolatedWorldChart} to denote the puppeteer world.
+ * This world contains all puppeteer-internal bindings/code.
+ *
+ * @internal
+ */
+export const PUPPETEER_WORLD = Symbol('puppeteerWorld');
+/**
+ * @internal
+ */
+export interface IsolatedWorldChart {
+ [key: string]: IsolatedWorld;
+ [MAIN_WORLD]: IsolatedWorld;
+ [PUPPETEER_WORLD]: IsolatedWorld;
+}
+
+/**
+ * @internal
+ */
+export class IsolatedWorld {
+ #frame: Frame;
+ #injected: boolean;
+ #document?: ElementHandle<Document>;
+ #context = createDeferredPromise<ExecutionContext>();
+ #detached = false;
+
+ // Set of bindings that have been registered in the current context.
+ #ctxBindings = new Set<string>();
+
+ // Contains mapping from functions that should be bound to Puppeteer functions.
+ #boundFunctions = new Map<string, Function>();
+ #waitTasks = new Set<WaitTask>();
+
+ get _waitTasks(): Set<WaitTask> {
+ return this.#waitTasks;
+ }
+
+ get _boundFunctions(): Map<string, Function> {
+ return this.#boundFunctions;
+ }
+
+ static #bindingIdentifier = (name: string, contextId: number) => {
+ return `${name}_${contextId}`;
+ };
+
+ constructor(frame: Frame, injected = false) {
+ // Keep own reference to client because it might differ from the FrameManager's
+ // client for OOP iframes.
+ this.#frame = frame;
+ this.#injected = injected;
+ this.#client.on('Runtime.bindingCalled', this.#onBindingCalled);
+ }
+
+ get #client(): CDPSession {
+ return this.#frame._client();
+ }
+
+ get #frameManager(): FrameManager {
+ return this.#frame._frameManager;
+ }
+
+ get #timeoutSettings(): TimeoutSettings {
+ return this.#frameManager.timeoutSettings;
+ }
+
+ frame(): Frame {
+ return this.#frame;
+ }
+
+ clearContext(): void {
+ this.#document = undefined;
+ this.#context = createDeferredPromise();
+ }
+
+ setContext(context: ExecutionContext): void {
+ if (this.#injected) {
+ context.evaluate(injectedSource).catch(debugError);
+ }
+ this.#ctxBindings.clear();
+ this.#context.resolve(context);
+ for (const waitTask of this._waitTasks) {
+ waitTask.rerun();
+ }
+ }
+
+ hasContext(): boolean {
+ return this.#context.resolved();
+ }
+
+ _detach(): void {
+ this.#detached = true;
+ this.#client.off('Runtime.bindingCalled', this.#onBindingCalled);
+ 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?)`
+ );
+ }
+ if (this.#context === null) {
+ throw new Error(`Execution content promise is missing`);
+ }
+ return this.#context;
+ }
+
+ async evaluateHandle<
+ Params extends unknown[],
+ Func extends EvaluateFunc<Params> = EvaluateFunc<Params>
+ >(
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<HandleFor<Awaited<ReturnType<Func>>>> {
+ const context = await this.executionContext();
+ return context.evaluateHandle(pageFunction, ...args);
+ }
+
+ async evaluate<
+ Params extends unknown[],
+ Func extends EvaluateFunc<Params> = EvaluateFunc<Params>
+ >(
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<Awaited<ReturnType<Func>>> {
+ const context = await this.executionContext();
+ return context.evaluate(pageFunction, ...args);
+ }
+
+ async $<Selector extends string>(
+ selector: Selector
+ ): Promise<ElementHandle<NodeFor<Selector>> | null> {
+ const document = await this.document();
+ return document.$(selector);
+ }
+
+ async $$<Selector extends string>(
+ selector: Selector
+ ): Promise<Array<ElementHandle<NodeFor<Selector>>>> {
+ const document = await this.document();
+ return document.$$(selector);
+ }
+
+ async document(): Promise<ElementHandle<Document>> {
+ if (this.#document) {
+ return this.#document;
+ }
+ const context = await this.executionContext();
+ this.#document = await context.evaluateHandle(() => {
+ return document;
+ });
+ return this.#document;
+ }
+
+ async $x(expression: string): Promise<Array<ElementHandle<Node>>> {
+ const document = await this.document();
+ return document.$x(expression);
+ }
+
+ async $eval<
+ Selector extends string,
+ Params extends unknown[],
+ Func extends EvaluateFunc<
+ [ElementHandle<NodeFor<Selector>>, ...Params]
+ > = EvaluateFunc<[ElementHandle<NodeFor<Selector>>, ...Params]>
+ >(
+ selector: Selector,
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<Awaited<ReturnType<Func>>> {
+ const document = await this.document();
+ return document.$eval(selector, pageFunction, ...args);
+ }
+
+ async $$eval<
+ Selector extends string,
+ Params extends unknown[],
+ Func extends EvaluateFunc<
+ [Array<NodeFor<Selector>>, ...Params]
+ > = EvaluateFunc<[Array<NodeFor<Selector>>, ...Params]>
+ >(
+ selector: Selector,
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<Awaited<ReturnType<Func>>> {
+ const document = await this.document();
+ return document.$$eval(selector, pageFunction, ...args);
+ }
+
+ 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(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;
+ }
+ }
+
+ async click(
+ selector: string,
+ options: {delay?: number; button?: MouseButton; clickCount?: number}
+ ): Promise<void> {
+ const handle = await this.$(selector);
+ assert(handle, `No element 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 element found for selector: ${selector}`);
+ await handle.focus();
+ await handle.dispose();
+ }
+
+ async hover(selector: string): Promise<void> {
+ const handle = await this.$(selector);
+ assert(handle, `No element 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 element 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);
+ assert(handle, `No element found for selector: ${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 element found for selector: ${selector}`);
+ await handle.type(text, options);
+ await handle.dispose();
+ }
+
+ // 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.
+ #settingUpBinding: Promise<void> | null = null;
+
+ async _addBindingToContext(
+ context: ExecutionContext,
+ name: string
+ ): Promise<void> {
+ // Previous operation added the binding so we are done.
+ if (
+ this.#ctxBindings.has(
+ IsolatedWorld.#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 = pageBindingInitString('internal', name);
+ try {
+ // TODO: In theory, it would be enough to call this just once
+ await context._client.send('Runtime.addBinding', {
+ name,
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore The protocol definition is not up to date.
+ executionContextName: context._contextName,
+ });
+ 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 as Error).message.includes(
+ 'Execution context was destroyed'
+ );
+ const ctxNotFound = (error as Error).message.includes(
+ 'Cannot find context with specified id'
+ );
+ if (ctxDestroyed || ctxNotFound) {
+ return;
+ } else {
+ debugError(error);
+ return;
+ }
+ }
+ this.#ctxBindings.add(
+ IsolatedWorld.#bindingIdentifier(name, context._contextId)
+ );
+ };
+
+ this.#settingUpBinding = bind(name);
+ await this.#settingUpBinding;
+ this.#settingUpBinding = null;
+ }
+
+ #onBindingCalled = async (
+ 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(
+ IsolatedWorld.#bindingIdentifier(name, context._contextId)
+ )
+ ) {
+ return;
+ }
+ if (context._contextId !== event.executionContextId) {
+ return;
+ }
+ try {
+ const fn = this._boundFunctions.get(name);
+ if (!fn) {
+ throw new Error(`Bound function $name is not found`);
+ }
+ const result = await fn(...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 as Error).message.includes('Protocol error')) {
+ return;
+ }
+ debugError(error);
+ }
+ function deliverResult(name: string, seq: number, result: unknown): void {
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore Code is evaluated in a different context.
+ (globalThis as any)[name].callbacks.get(seq).resolve(result);
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore Code is evaluated in a different context.
+ (globalThis as any)[name].callbacks.delete(seq);
+ }
+ };
+
+ async _waitForSelectorInPage(
+ queryOne: Function,
+ root: ElementHandle<Node> | undefined,
+ selector: string,
+ options: WaitForSelectorOptions,
+ binding?: PageBinding
+ ): Promise<JSHandle<unknown> | 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(
+ root: Element | Document,
+ selector: string,
+ waitForVisible: boolean,
+ waitForHidden: boolean
+ ): Promise<Node | null | boolean> {
+ const node = (await predicateQueryHandler(root, selector)) as Element;
+ return checkWaitForOptions(node, waitForVisible, waitForHidden);
+ }
+ const waitTaskOptions: WaitTaskOptions = {
+ isolatedWorld: this,
+ predicateBody: makePredicateString(predicate, queryOne),
+ predicateAcceptsContextElement: true,
+ title,
+ polling,
+ timeout,
+ args: [selector, waitForVisible, waitForHidden],
+ binding,
+ root,
+ };
+ const waitTask = new WaitTask(waitTaskOptions);
+ return waitTask.promise;
+ }
+
+ waitForFunction(
+ pageFunction: Function | string,
+ options: {polling?: string | number; timeout?: number} = {},
+ ...args: unknown[]
+ ): Promise<JSHandle> {
+ const {polling = 'raf', timeout = this.#timeoutSettings.timeout()} =
+ options;
+ const waitTaskOptions: WaitTaskOptions = {
+ isolatedWorld: this,
+ predicateBody: pageFunction,
+ predicateAcceptsContextElement: false,
+ title: 'function',
+ polling,
+ timeout,
+ args,
+ };
+ const waitTask = new WaitTask(waitTaskOptions);
+ return waitTask.promise;
+ }
+
+ async title(): Promise<string> {
+ return this.evaluate(() => {
+ return document.title;
+ });
+ }
+
+ async adoptBackendNode(
+ backendNodeId?: Protocol.DOM.BackendNodeId
+ ): Promise<JSHandle<Node>> {
+ const executionContext = await this.executionContext();
+ const {object} = await this.#client.send('DOM.resolveNode', {
+ backendNodeId: backendNodeId,
+ executionContextId: executionContext._contextId,
+ });
+ return createJSHandle(executionContext, object) as JSHandle<Node>;
+ }
+
+ async adoptHandle<T extends JSHandle<Node>>(handle: T): Promise<T> {
+ const executionContext = await this.executionContext();
+ assert(
+ handle.executionContext() !== executionContext,
+ 'Cannot adopt handle that already belongs to this execution context'
+ );
+ const nodeInfo = await this.#client.send('DOM.describeNode', {
+ objectId: handle.remoteObject().objectId,
+ });
+ return (await this.adoptBackendNode(nodeInfo.node.backendNodeId)) as T;
+ }
+
+ async transferHandle<T extends JSHandle<Node>>(handle: T): Promise<T> {
+ const result = await this.adoptHandle(handle);
+ await handle.dispose();
+ return result;
+ }
+}
+
+/**
+ * @internal
+ */
+export interface WaitTaskOptions {
+ isolatedWorld: IsolatedWorld;
+ predicateBody: Function | string;
+ predicateAcceptsContextElement: boolean;
+ title: string;
+ polling: string | number;
+ timeout: number;
+ binding?: PageBinding;
+ args: unknown[];
+ root?: ElementHandle<Node>;
+}
+
+const noop = (): void => {};
+
+/**
+ * @internal
+ */
+export class WaitTask {
+ #isolatedWorld: IsolatedWorld;
+ #polling: 'raf' | 'mutation' | number;
+ #timeout: number;
+ #predicateBody: string;
+ #predicateAcceptsContextElement: boolean;
+ #args: unknown[];
+ #binding?: PageBinding;
+ #runCount = 0;
+ #resolve: (x: JSHandle) => void = noop;
+ #reject: (x: Error) => void = noop;
+ #timeoutTimer?: NodeJS.Timeout;
+ #terminated = false;
+ #root: ElementHandle<Node> | null = null;
+
+ promise: Promise<JSHandle>;
+
+ constructor(options: WaitTaskOptions) {
+ if (isString(options.polling)) {
+ assert(
+ options.polling === 'raf' || options.polling === 'mutation',
+ 'Unknown polling option: ' + options.polling
+ );
+ } else if (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 (isString(predicateBody)) {
+ return `return (${predicateBody});`;
+ }
+ return `return (${predicateBody})(...args);`;
+ }
+
+ this.#isolatedWorld = options.isolatedWorld;
+ this.#polling = options.polling;
+ this.#timeout = options.timeout;
+ this.#root = options.root || null;
+ this.#predicateBody = getPredicateBody(options.predicateBody);
+ this.#predicateAcceptsContextElement =
+ options.predicateAcceptsContextElement;
+ this.#args = options.args;
+ this.#binding = options.binding;
+ this.#runCount = 0;
+ this.#isolatedWorld._waitTasks.add(this);
+ if (this.#binding) {
+ this.#isolatedWorld._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(() => {
+ return 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 = null;
+ let error: Error | null = null;
+ const context = await this.#isolatedWorld.executionContext();
+ if (this.#terminated || runCount !== this.#runCount) {
+ return;
+ }
+ if (this.#binding) {
+ await this.#isolatedWorld._addBindingToContext(
+ context,
+ this.#binding.name
+ );
+ }
+ if (this.#terminated || runCount !== this.#runCount) {
+ return;
+ }
+ try {
+ success = await context.evaluateHandle(
+ waitForPredicatePageFunction,
+ this.#root || null,
+ this.#predicateBody,
+ this.#predicateAcceptsContextElement,
+ this.#polling,
+ this.#timeout,
+ ...this.#args
+ );
+ } catch (error_) {
+ error = error_ as 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.#isolatedWorld
+ .evaluate(s => {
+ return !s;
+ }, success)
+ .catch(() => {
+ return true;
+ }))
+ ) {
+ if (!success) {
+ throw new Error('Assertion: result handle is not available');
+ }
+ 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 IsolatedWorld.
+ // This can fail if we were adding this task while the frame was detached,
+ // so we terminate here instead.
+ if (
+ error.message.includes(
+ 'Execution context is not available in detached frame'
+ )
+ ) {
+ 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 {
+ if (!success) {
+ throw new Error('Assertion: result handle is not available');
+ }
+ this.#resolve(success);
+ }
+ this.#cleanup();
+ }
+
+ #cleanup(): void {
+ this.#timeoutTimer !== undefined && clearTimeout(this.#timeoutTimer);
+ this.#isolatedWorld._waitTasks.delete(this);
+ }
+}
+
+async function waitForPredicatePageFunction(
+ root: Node | null,
+ predicateBody: string,
+ predicateAcceptsContextElement: boolean,
+ polling: 'raf' | 'mutation' | number,
+ timeout: number,
+ ...args: unknown[]
+): Promise<unknown> {
+ root = root || document;
+ const predicate = new Function('...args', predicateBody);
+ let timedOut = false;
+ if (timeout) {
+ setTimeout(() => {
+ return (timedOut = true);
+ }, timeout);
+ }
+ switch (polling) {
+ case 'raf':
+ return await pollRaf();
+ case 'mutation':
+ return await pollMutation();
+ default:
+ return await pollInterval(polling);
+ }
+
+ async function pollMutation(): Promise<unknown> {
+ const success = predicateAcceptsContextElement
+ ? await predicate(root, ...args)
+ : await predicate(...args);
+ if (success) {
+ return Promise.resolve(success);
+ }
+
+ let fulfill = (_?: unknown) => {};
+ const result = new Promise(x => {
+ return (fulfill = x);
+ });
+ const observer = new MutationObserver(async () => {
+ if (timedOut) {
+ observer.disconnect();
+ fulfill();
+ }
+ const success = predicateAcceptsContextElement
+ ? await predicate(root, ...args)
+ : await predicate(...args);
+ if (success) {
+ observer.disconnect();
+ fulfill(success);
+ }
+ });
+ if (!root) {
+ throw new Error('Root element is not found.');
+ }
+ observer.observe(root, {
+ childList: true,
+ subtree: true,
+ attributes: true,
+ });
+ return result;
+ }
+
+ async function pollRaf(): Promise<unknown> {
+ let fulfill = (_?: unknown): void => {};
+ const result = new Promise(x => {
+ return (fulfill = x);
+ });
+ await onRaf();
+ return result;
+
+ async function onRaf(): Promise<void> {
+ if (timedOut) {
+ fulfill();
+ return;
+ }
+ const success = predicateAcceptsContextElement
+ ? await predicate(root, ...args)
+ : await predicate(...args);
+ if (success) {
+ fulfill(success);
+ } else {
+ requestAnimationFrame(onRaf);
+ }
+ }
+ }
+
+ async function pollInterval(pollInterval: number): Promise<unknown> {
+ let fulfill = (_?: unknown): void => {};
+ const result = new Promise(x => {
+ return (fulfill = x);
+ });
+ await onTimeout();
+ return result;
+
+ async function onTimeout(): Promise<void> {
+ if (timedOut) {
+ fulfill();
+ return;
+ }
+ const success = predicateAcceptsContextElement
+ ? await predicate(root, ...args)
+ : await predicate(...args);
+ if (success) {
+ fulfill(success);
+ } else {
+ setTimeout(onTimeout, pollInterval);
+ }
+ }
+ }
+}
diff --git a/remote/test/puppeteer/src/common/JSHandle.ts b/remote/test/puppeteer/src/common/JSHandle.ts
new file mode 100644
index 0000000000..55613eb610
--- /dev/null
+++ b/remote/test/puppeteer/src/common/JSHandle.ts
@@ -0,0 +1,332 @@
+/**
+ * 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 {Protocol} from 'devtools-protocol';
+import {assert} from '../util/assert.js';
+import {CDPSession} from './Connection.js';
+import type {ElementHandle} from './ElementHandle.js';
+import {ExecutionContext} from './ExecutionContext.js';
+import {MouseButton} from './Input.js';
+import {EvaluateFunc, HandleFor, HandleOr} from './types.js';
+import {createJSHandle, releaseObject, valueFromRemoteObject} from './util.js';
+
+declare const __JSHandleSymbol: unique symbol;
+
+/**
+ * @public
+ */
+export interface BoxModel {
+ content: Point[];
+ padding: Point[];
+ border: Point[];
+ margin: Point[];
+ width: number;
+ height: number;
+}
+
+/**
+ * @public
+ */
+export interface BoundingBox extends Point {
+ /**
+ * the width of the element in pixels.
+ */
+ width: number;
+ /**
+ * the height of the element in pixels.
+ */
+ height: number;
+}
+
+/**
+ * Represents a reference to a JavaScript object. Instances can be created using
+ * {@link Page.evaluateHandle}.
+ *
+ * Handles prevent the referenced JavaScript object from being garbage-collected
+ * unless the handle is purposely {@link JSHandle.dispose | disposed}. JSHandles
+ * are auto-disposed when their associated frame is navigated away or the parent
+ * context gets destroyed.
+ *
+ * Handles can be used as arguments for any evaluation function such as
+ * {@link Page.$eval}, {@link Page.evaluate}, and {@link Page.evaluateHandle}.
+ * They are resolved to their referenced object.
+ *
+ * @example
+ *
+ * ```ts
+ * const windowHandle = await page.evaluateHandle(() => window);
+ * ```
+ *
+ * @public
+ */
+export class JSHandle<T = unknown> {
+ /**
+ * Used for nominally typing {@link JSHandle}.
+ */
+ [__JSHandleSymbol]?: T;
+
+ #disposed = false;
+ #context: ExecutionContext;
+ #remoteObject: Protocol.Runtime.RemoteObject;
+
+ /**
+ * @internal
+ */
+ get client(): CDPSession {
+ return this.#context._client;
+ }
+
+ /**
+ * @internal
+ */
+ get disposed(): boolean {
+ return this.#disposed;
+ }
+
+ /**
+ * @internal
+ */
+ constructor(
+ context: ExecutionContext,
+ remoteObject: Protocol.Runtime.RemoteObject
+ ) {
+ this.#context = context;
+ this.#remoteObject = remoteObject;
+ }
+
+ /**
+ * @internal
+ */
+ executionContext(): ExecutionContext {
+ return this.#context;
+ }
+
+ /**
+ * Evaluates the given function with the current handle as its first argument.
+ *
+ * @see {@link ExecutionContext.evaluate} for more details.
+ */
+ async evaluate<
+ Params extends unknown[],
+ Func extends EvaluateFunc<[this, ...Params]> = EvaluateFunc<
+ [this, ...Params]
+ >
+ >(
+ pageFunction: Func | string,
+ ...args: Params
+ ): // @ts-expect-error Circularity here is okay because we only need the return
+ // type which doesn't use `this`.
+ Promise<Awaited<ReturnType<Func>>> {
+ return await this.executionContext().evaluate(pageFunction, this, ...args);
+ }
+
+ /**
+ * Evaluates the given function with the current handle as its first argument.
+ *
+ * @see {@link ExecutionContext.evaluateHandle} for more details.
+ */
+ async evaluateHandle<
+ Params extends unknown[],
+ Func extends EvaluateFunc<[this, ...Params]> = EvaluateFunc<
+ [this, ...Params]
+ >
+ >(
+ pageFunction: Func | string,
+ ...args: Params
+ ): // @ts-expect-error Circularity here is okay because we only need the return
+ // type which doesn't use `this`.
+ Promise<HandleFor<Awaited<ReturnType<Func>>>> {
+ return await this.executionContext().evaluateHandle(
+ pageFunction,
+ this,
+ ...args
+ );
+ }
+
+ /**
+ * Fetches a single property from the referenced object.
+ */
+ async getProperty<K extends keyof T>(
+ propertyName: HandleOr<K>
+ ): Promise<HandleFor<T[K]>>;
+ async getProperty(propertyName: string): Promise<JSHandle<unknown>>;
+ async getProperty<K extends keyof T>(
+ propertyName: HandleOr<K>
+ ): Promise<HandleFor<T[K]>> {
+ return this.evaluateHandle((object, propertyName) => {
+ return object[propertyName];
+ }, propertyName);
+ }
+
+ /**
+ * Gets a map of handles representing the properties of the current handle.
+ *
+ * @example
+ *
+ * ```ts
+ * const listHandle = await page.evaluateHandle(() => document.body.children);
+ * const properties = await listHandle.getProperties();
+ * const children = [];
+ * for (const property of properties.values()) {
+ * const element = property.asElement();
+ * if (element) {
+ * children.push(element);
+ * }
+ * }
+ * children; // holds elementHandles to all children of document.body
+ * ```
+ */
+ async getProperties(): Promise<Map<string, JSHandle>> {
+ assert(this.#remoteObject.objectId);
+ // We use Runtime.getProperties rather than iterative building because the
+ // iterative approach might create a distorted snapshot.
+ 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 || !property.value) {
+ continue;
+ }
+ result.set(property.name, createJSHandle(this.#context, property.value));
+ }
+ return result;
+ }
+
+ /**
+ * @returns A vanilla object representing the serializable portions of the
+ * referenced object.
+ * @throws Throws if the object cannot be serialized due to circularity.
+ *
+ * @remarks
+ * If the object has a `toJSON` function, it **will not** be called.
+ */
+ async jsonValue(): Promise<T> {
+ if (!this.#remoteObject.objectId) {
+ return valueFromRemoteObject(this.#remoteObject);
+ }
+ const value = await this.evaluate(object => {
+ return object;
+ });
+ if (value === undefined) {
+ throw new Error('Could not serialize referenced object');
+ }
+ return value;
+ }
+
+ /**
+ * @returns Either `null` or the handle itself if the handle is an
+ * instance of {@link ElementHandle}.
+ */
+ asElement(): ElementHandle<Node> | null {
+ return null;
+ }
+
+ /**
+ * Releases the object referenced by the handle for garbage collection.
+ */
+ async dispose(): Promise<void> {
+ if (this.#disposed) {
+ return;
+ }
+ this.#disposed = true;
+ await releaseObject(this.client, this.#remoteObject);
+ }
+
+ /**
+ * Returns a string representation of the JSHandle.
+ *
+ * @remarks
+ * Useful during debugging.
+ */
+ toString(): string {
+ if (!this.#remoteObject.objectId) {
+ return 'JSHandle:' + valueFromRemoteObject(this.#remoteObject);
+ }
+ const type = this.#remoteObject.subtype || this.#remoteObject.type;
+ return 'JSHandle@' + type;
+ }
+
+ /**
+ * Provides access to the
+ * [Protocol.Runtime.RemoteObject](https://chromedevtools.github.io/devtools-protocol/tot/Runtime/#type-RemoteObject)
+ * backing this handle.
+ */
+ remoteObject(): Protocol.Runtime.RemoteObject {
+ return this.#remoteObject;
+ }
+}
+
+/**
+ * @public
+ */
+export interface Offset {
+ /**
+ * x-offset for the clickable point relative to the top-left corner of the border box.
+ */
+ x: number;
+ /**
+ * y-offset for the clickable point relative to the top-left corner of the border box.
+ */
+ y: number;
+}
+
+/**
+ * @public
+ */
+export interface ClickOptions {
+ /**
+ * Time to wait between `mousedown` and `mouseup` in milliseconds.
+ *
+ * @defaultValue 0
+ */
+ delay?: number;
+ /**
+ * @defaultValue 'left'
+ */
+ button?: MouseButton;
+ /**
+ * @defaultValue 1
+ */
+ clickCount?: number;
+ /**
+ * Offset for the clickable point relative to the top-left corner of the border box.
+ */
+ offset?: Offset;
+}
+
+/**
+ * @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;
+}
+
+/**
+ * @public
+ */
+export interface Point {
+ x: number;
+ y: number;
+}
diff --git a/remote/test/puppeteer/src/common/LifecycleWatcher.ts b/remote/test/puppeteer/src/common/LifecycleWatcher.ts
new file mode 100644
index 0000000000..b68e22dcd4
--- /dev/null
+++ b/remote/test/puppeteer/src/common/LifecycleWatcher.ts
@@ -0,0 +1,309 @@
+/**
+ * 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 '../util/assert.js';
+import {
+ addEventListener,
+ PuppeteerEventListener,
+ removeEventListeners,
+} from './util.js';
+import {
+ DeferredPromise,
+ createDeferredPromise,
+} from '../util/DeferredPromise.js';
+import {TimeoutError} from './Errors.js';
+import {FrameManager, FrameManagerEmittedEvents} from './FrameManager.js';
+import {Frame} from './Frame.js';
+import {HTTPRequest} from './HTTPRequest.js';
+import {HTTPResponse} from './HTTPResponse.js';
+import {NetworkManagerEmittedEvents} from './NetworkManager.js';
+import {CDPSessionEmittedEvents} from './Connection.js';
+/**
+ * @public
+ */
+export type PuppeteerLifeCycleEvent =
+ | 'load'
+ | 'domcontentloaded'
+ | 'networkidle0'
+ | 'networkidle2';
+
+/**
+ * @public
+ */
+export type ProtocolLifeCycleEvent =
+ | 'load'
+ | 'DOMContentLoaded'
+ | 'networkIdle'
+ | 'networkAlmostIdle';
+
+const puppeteerToProtocolLifecycle = new Map<
+ PuppeteerLifeCycleEvent,
+ ProtocolLifeCycleEvent
+>([
+ ['load', 'load'],
+ ['domcontentloaded', 'DOMContentLoaded'],
+ ['networkidle0', 'networkIdle'],
+ ['networkidle2', 'networkAlmostIdle'],
+]);
+
+const noop = (): void => {};
+
+/**
+ * @internal
+ */
+export class LifecycleWatcher {
+ #expectedLifecycle: ProtocolLifeCycleEvent[];
+ #frameManager: FrameManager;
+ #frame: Frame;
+ #timeout: number;
+ #navigationRequest: HTTPRequest | null = null;
+ #eventListeners: PuppeteerEventListener[];
+ #initialLoaderId: string;
+
+ #sameDocumentNavigationCompleteCallback: (x?: Error) => void = noop;
+ #sameDocumentNavigationPromise = new Promise<Error | undefined>(fulfill => {
+ this.#sameDocumentNavigationCompleteCallback = fulfill;
+ });
+
+ #lifecycleCallback: () => void = noop;
+ #lifecyclePromise: Promise<void> = new Promise(fulfill => {
+ this.#lifecycleCallback = fulfill;
+ });
+
+ #newDocumentNavigationCompleteCallback: (x?: Error) => void = noop;
+ #newDocumentNavigationPromise: Promise<Error | undefined> = new Promise(
+ fulfill => {
+ this.#newDocumentNavigationCompleteCallback = fulfill;
+ }
+ );
+
+ #terminationCallback: (x?: Error) => void = noop;
+ #terminationPromise: Promise<Error | undefined> = new Promise(fulfill => {
+ this.#terminationCallback = fulfill;
+ });
+
+ #timeoutPromise: Promise<TimeoutError | undefined>;
+
+ #maximumTimer?: NodeJS.Timeout;
+ #hasSameDocumentNavigation?: boolean;
+ #swapped?: boolean;
+
+ #navigationResponseReceived?: DeferredPromise<void>;
+
+ 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.#initialLoaderId = frame._loaderId;
+ this.#expectedLifecycle = waitUntil.map(value => {
+ const protocolEvent = puppeteerToProtocolLifecycle.get(value);
+ assert(protocolEvent, 'Unknown value for options.waitUntil: ' + value);
+ return protocolEvent as ProtocolLifeCycleEvent;
+ });
+
+ this.#frameManager = frameManager;
+ this.#frame = frame;
+ this.#timeout = timeout;
+ this.#eventListeners = [
+ addEventListener(
+ frameManager.client,
+ CDPSessionEmittedEvents.Disconnected,
+ this.#terminate.bind(
+ this,
+ new Error('Navigation failed because browser has disconnected!')
+ )
+ ),
+ addEventListener(
+ this.#frameManager,
+ FrameManagerEmittedEvents.LifecycleEvent,
+ this.#checkLifecycleComplete.bind(this)
+ ),
+ addEventListener(
+ this.#frameManager,
+ FrameManagerEmittedEvents.FrameNavigatedWithinDocument,
+ this.#navigatedWithinDocument.bind(this)
+ ),
+ addEventListener(
+ this.#frameManager,
+ FrameManagerEmittedEvents.FrameNavigated,
+ this.#navigated.bind(this)
+ ),
+ addEventListener(
+ this.#frameManager,
+ FrameManagerEmittedEvents.FrameSwapped,
+ this.#frameSwapped.bind(this)
+ ),
+ addEventListener(
+ this.#frameManager,
+ FrameManagerEmittedEvents.FrameDetached,
+ this.#onFrameDetached.bind(this)
+ ),
+ addEventListener(
+ this.#frameManager.networkManager,
+ NetworkManagerEmittedEvents.Request,
+ this.#onRequest.bind(this)
+ ),
+ addEventListener(
+ this.#frameManager.networkManager,
+ NetworkManagerEmittedEvents.Response,
+ this.#onResponse.bind(this)
+ ),
+ ];
+
+ this.#timeoutPromise = this.#createTimeoutPromise();
+ this.#checkLifecycleComplete();
+ }
+
+ #onRequest(request: HTTPRequest): void {
+ if (request.frame() !== this.#frame || !request.isNavigationRequest()) {
+ return;
+ }
+ this.#navigationRequest = request;
+ // Resolve previous navigation response in case there are multiple
+ // navigation requests reported by the backend. This generally should not
+ // happen by it looks like it's possible.
+ this.#navigationResponseReceived?.resolve();
+ this.#navigationResponseReceived = createDeferredPromise();
+ if (request.response() !== null) {
+ this.#navigationResponseReceived?.resolve();
+ }
+ }
+
+ #onResponse(response: HTTPResponse): void {
+ if (this.#navigationRequest?._requestId !== response.request()._requestId) {
+ return;
+ }
+ this.#navigationResponseReceived?.resolve();
+ }
+
+ #onFrameDetached(frame: Frame): void {
+ if (this.#frame === frame) {
+ this.#terminationCallback.call(
+ null,
+ new Error('Navigating frame was detached')
+ );
+ return;
+ }
+ this.#checkLifecycleComplete();
+ }
+
+ async navigationResponse(): Promise<HTTPResponse | null> {
+ // Continue with a possibly null response.
+ await this.#navigationResponseReceived?.catch(() => {});
+ return this.#navigationRequest ? this.#navigationRequest.response() : null;
+ }
+
+ #terminate(error: Error): void {
+ this.#terminationCallback.call(null, error);
+ }
+
+ sameDocumentNavigationPromise(): Promise<Error | undefined> {
+ return this.#sameDocumentNavigationPromise;
+ }
+
+ newDocumentNavigationPromise(): Promise<Error | undefined> {
+ return this.#newDocumentNavigationPromise;
+ }
+
+ lifecyclePromise(): Promise<void> {
+ return this.#lifecyclePromise;
+ }
+
+ timeoutOrTerminationPromise(): Promise<Error | TimeoutError | undefined> {
+ return Promise.race([this.#timeoutPromise, this.#terminationPromise]);
+ }
+
+ async #createTimeoutPromise(): Promise<TimeoutError | undefined> {
+ if (!this.#timeout) {
+ return new Promise(noop);
+ }
+ const errorMessage =
+ 'Navigation timeout of ' + this.#timeout + ' ms exceeded';
+ await new Promise(fulfill => {
+ return (this.#maximumTimer = setTimeout(fulfill, this.#timeout));
+ });
+ return new TimeoutError(errorMessage);
+ }
+
+ #navigatedWithinDocument(frame: Frame): void {
+ if (frame !== this.#frame) {
+ return;
+ }
+ this.#hasSameDocumentNavigation = true;
+ this.#checkLifecycleComplete();
+ }
+
+ #navigated(frame: Frame): void {
+ if (frame !== this.#frame) {
+ return;
+ }
+ this.#checkLifecycleComplete();
+ }
+
+ #frameSwapped(frame: Frame): void {
+ if (frame !== this.#frame) {
+ return;
+ }
+ this.#swapped = true;
+ this.#checkLifecycleComplete();
+ }
+
+ #checkLifecycleComplete(): void {
+ // We expect navigation to commit.
+ if (!checkLifecycle(this.#frame, this.#expectedLifecycle)) {
+ return;
+ }
+ this.#lifecycleCallback();
+ if (this.#hasSameDocumentNavigation) {
+ this.#sameDocumentNavigationCompleteCallback();
+ }
+ if (this.#swapped || this.#frame._loaderId !== this.#initialLoaderId) {
+ this.#newDocumentNavigationCompleteCallback();
+ }
+
+ 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 (
+ child._hasStartedLoading &&
+ !checkLifecycle(child, expectedLifecycle)
+ ) {
+ return false;
+ }
+ }
+ return true;
+ }
+ }
+
+ dispose(): void {
+ removeEventListeners(this.#eventListeners);
+ this.#maximumTimer !== undefined && clearTimeout(this.#maximumTimer);
+ }
+}
diff --git a/remote/test/puppeteer/src/common/NetworkConditions.ts b/remote/test/puppeteer/src/common/NetworkConditions.ts
new file mode 100644
index 0000000000..786a732305
--- /dev/null
+++ b/remote/test/puppeteer/src/common/NetworkConditions.ts
@@ -0,0 +1,57 @@
+/**
+ * Copyright 2021 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 {NetworkConditions} from './NetworkManager.js';
+
+/**
+ * A list of network conditions to be used with
+ * `page.emulateNetworkConditions(networkConditions)`. Actual list of predefined
+ * conditions can be found in
+ * {@link https://github.com/puppeteer/puppeteer/blob/main/src/common/NetworkConditions.ts | src/common/NetworkConditions.ts}.
+ *
+ * @example
+ *
+ * ```ts
+ * const puppeteer = require('puppeteer');
+ * const slow3G = puppeteer.networkConditions['Slow 3G'];
+ *
+ * (async () => {
+ * const browser = await puppeteer.launch();
+ * const page = await browser.newPage();
+ * await page.emulateNetworkConditions(slow3G);
+ * await page.goto('https://www.google.com');
+ * // other actions...
+ * await browser.close();
+ * })();
+ * ```
+ *
+ * @public
+ */
+export const networkConditions: Readonly<{
+ 'Slow 3G': NetworkConditions;
+ 'Fast 3G': NetworkConditions;
+}> = Object.freeze({
+ 'Slow 3G': {
+ download: ((500 * 1000) / 8) * 0.8,
+ upload: ((500 * 1000) / 8) * 0.8,
+ latency: 400 * 5,
+ },
+ 'Fast 3G': {
+ download: ((1.6 * 1000 * 1000) / 8) * 0.9,
+ upload: ((750 * 1000) / 8) * 0.9,
+ latency: 150 * 3.75,
+ },
+});
diff --git a/remote/test/puppeteer/src/common/NetworkEventManager.ts b/remote/test/puppeteer/src/common/NetworkEventManager.ts
new file mode 100644
index 0000000000..0b0e373fc1
--- /dev/null
+++ b/remote/test/puppeteer/src/common/NetworkEventManager.ts
@@ -0,0 +1,203 @@
+import {Protocol} from 'devtools-protocol';
+import {HTTPRequest} from './HTTPRequest.js';
+
+/**
+ * @internal
+ */
+export type QueuedEventGroup = {
+ responseReceivedEvent: Protocol.Network.ResponseReceivedEvent;
+ loadingFinishedEvent?: Protocol.Network.LoadingFinishedEvent;
+ loadingFailedEvent?: Protocol.Network.LoadingFailedEvent;
+};
+
+/**
+ * @internal
+ */
+export type FetchRequestId = string;
+
+/**
+ * @internal
+ */
+export type RedirectInfo = {
+ event: Protocol.Network.RequestWillBeSentEvent;
+ fetchRequestId?: FetchRequestId;
+};
+type RedirectInfoList = RedirectInfo[];
+
+/**
+ * @internal
+ */
+export type NetworkRequestId = string;
+
+/**
+ * Helper class to track network events by request ID
+ *
+ * @internal
+ */
+export class NetworkEventManager {
+ /**
+ * There are four possible orders of events:
+ * A. `_onRequestWillBeSent`
+ * B. `_onRequestWillBeSent`, `_onRequestPaused`
+ * C. `_onRequestPaused`, `_onRequestWillBeSent`
+ * D. `_onRequestPaused`, `_onRequestWillBeSent`, `_onRequestPaused`,
+ * `_onRequestWillBeSent`, `_onRequestPaused`, `_onRequestPaused`
+ * (see crbug.com/1196004)
+ *
+ * For `_onRequest` we need the event from `_onRequestWillBeSent` and
+ * optionally the `interceptionId` from `_onRequestPaused`.
+ *
+ * If request interception is disabled, call `_onRequest` once per call to
+ * `_onRequestWillBeSent`.
+ * If request interception is enabled, call `_onRequest` once per call to
+ * `_onRequestPaused` (once per `interceptionId`).
+ *
+ * Events are stored to allow for subsequent events to call `_onRequest`.
+ *
+ * Note that (chains of) redirect requests have the same `requestId` (!) as
+ * the original request. We have to anticipate series of events like these:
+ * A. `_onRequestWillBeSent`,
+ * `_onRequestWillBeSent`, ...
+ * B. `_onRequestWillBeSent`, `_onRequestPaused`,
+ * `_onRequestWillBeSent`, `_onRequestPaused`, ...
+ * C. `_onRequestWillBeSent`, `_onRequestPaused`,
+ * `_onRequestPaused`, `_onRequestWillBeSent`, ...
+ * D. `_onRequestPaused`, `_onRequestWillBeSent`,
+ * `_onRequestPaused`, `_onRequestWillBeSent`, `_onRequestPaused`,
+ * `_onRequestWillBeSent`, `_onRequestPaused`, `_onRequestPaused`, ...
+ * (see crbug.com/1196004)
+ */
+ #requestWillBeSentMap = new Map<
+ NetworkRequestId,
+ Protocol.Network.RequestWillBeSentEvent
+ >();
+ #requestPausedMap = new Map<
+ NetworkRequestId,
+ Protocol.Fetch.RequestPausedEvent
+ >();
+ #httpRequestsMap = new Map<NetworkRequestId, HTTPRequest>();
+
+ /*
+ * The below maps are used to reconcile Network.responseReceivedExtraInfo
+ * events with their corresponding request. Each response and redirect
+ * response gets an ExtraInfo event, and we don't know which will come first.
+ * This means that we have to store a Response or an ExtraInfo for each
+ * response, and emit the event when we get both of them. In addition, to
+ * handle redirects, we have to make them Arrays to represent the chain of
+ * events.
+ */
+ #responseReceivedExtraInfoMap = new Map<
+ NetworkRequestId,
+ Protocol.Network.ResponseReceivedExtraInfoEvent[]
+ >();
+ #queuedRedirectInfoMap = new Map<NetworkRequestId, RedirectInfoList>();
+ #queuedEventGroupMap = new Map<NetworkRequestId, QueuedEventGroup>();
+
+ forget(networkRequestId: NetworkRequestId): void {
+ this.#requestWillBeSentMap.delete(networkRequestId);
+ this.#requestPausedMap.delete(networkRequestId);
+ this.#queuedEventGroupMap.delete(networkRequestId);
+ this.#queuedRedirectInfoMap.delete(networkRequestId);
+ this.#responseReceivedExtraInfoMap.delete(networkRequestId);
+ }
+
+ responseExtraInfo(
+ networkRequestId: NetworkRequestId
+ ): Protocol.Network.ResponseReceivedExtraInfoEvent[] {
+ if (!this.#responseReceivedExtraInfoMap.has(networkRequestId)) {
+ this.#responseReceivedExtraInfoMap.set(networkRequestId, []);
+ }
+ return this.#responseReceivedExtraInfoMap.get(
+ networkRequestId
+ ) as Protocol.Network.ResponseReceivedExtraInfoEvent[];
+ }
+
+ private queuedRedirectInfo(fetchRequestId: FetchRequestId): RedirectInfoList {
+ if (!this.#queuedRedirectInfoMap.has(fetchRequestId)) {
+ this.#queuedRedirectInfoMap.set(fetchRequestId, []);
+ }
+ return this.#queuedRedirectInfoMap.get(fetchRequestId) as RedirectInfoList;
+ }
+
+ queueRedirectInfo(
+ fetchRequestId: FetchRequestId,
+ redirectInfo: RedirectInfo
+ ): void {
+ this.queuedRedirectInfo(fetchRequestId).push(redirectInfo);
+ }
+
+ takeQueuedRedirectInfo(
+ fetchRequestId: FetchRequestId
+ ): RedirectInfo | undefined {
+ return this.queuedRedirectInfo(fetchRequestId).shift();
+ }
+
+ numRequestsInProgress(): number {
+ return [...this.#httpRequestsMap].filter(([, request]) => {
+ return !request.response();
+ }).length;
+ }
+
+ storeRequestWillBeSent(
+ networkRequestId: NetworkRequestId,
+ event: Protocol.Network.RequestWillBeSentEvent
+ ): void {
+ this.#requestWillBeSentMap.set(networkRequestId, event);
+ }
+
+ getRequestWillBeSent(
+ networkRequestId: NetworkRequestId
+ ): Protocol.Network.RequestWillBeSentEvent | undefined {
+ return this.#requestWillBeSentMap.get(networkRequestId);
+ }
+
+ forgetRequestWillBeSent(networkRequestId: NetworkRequestId): void {
+ this.#requestWillBeSentMap.delete(networkRequestId);
+ }
+
+ getRequestPaused(
+ networkRequestId: NetworkRequestId
+ ): Protocol.Fetch.RequestPausedEvent | undefined {
+ return this.#requestPausedMap.get(networkRequestId);
+ }
+
+ forgetRequestPaused(networkRequestId: NetworkRequestId): void {
+ this.#requestPausedMap.delete(networkRequestId);
+ }
+
+ storeRequestPaused(
+ networkRequestId: NetworkRequestId,
+ event: Protocol.Fetch.RequestPausedEvent
+ ): void {
+ this.#requestPausedMap.set(networkRequestId, event);
+ }
+
+ getRequest(networkRequestId: NetworkRequestId): HTTPRequest | undefined {
+ return this.#httpRequestsMap.get(networkRequestId);
+ }
+
+ storeRequest(networkRequestId: NetworkRequestId, request: HTTPRequest): void {
+ this.#httpRequestsMap.set(networkRequestId, request);
+ }
+
+ forgetRequest(networkRequestId: NetworkRequestId): void {
+ this.#httpRequestsMap.delete(networkRequestId);
+ }
+
+ getQueuedEventGroup(
+ networkRequestId: NetworkRequestId
+ ): QueuedEventGroup | undefined {
+ return this.#queuedEventGroupMap.get(networkRequestId);
+ }
+
+ queueEventGroup(
+ networkRequestId: NetworkRequestId,
+ event: QueuedEventGroup
+ ): void {
+ this.#queuedEventGroupMap.set(networkRequestId, event);
+ }
+
+ forgetQueuedEventGroup(networkRequestId: NetworkRequestId): void {
+ this.#queuedEventGroupMap.delete(networkRequestId);
+ }
+}
diff --git a/remote/test/puppeteer/src/common/NetworkManager.ts b/remote/test/puppeteer/src/common/NetworkManager.ts
new file mode 100644
index 0000000000..da1a0d19f1
--- /dev/null
+++ b/remote/test/puppeteer/src/common/NetworkManager.ts
@@ -0,0 +1,636 @@
+/**
+ * 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 {Protocol} from 'devtools-protocol';
+import {ProtocolMapping} from 'devtools-protocol/types/protocol-mapping.js';
+import {assert} from '../util/assert.js';
+import {EventEmitter} from './EventEmitter.js';
+import {Frame} from './Frame.js';
+import {HTTPRequest} from './HTTPRequest.js';
+import {HTTPResponse} from './HTTPResponse.js';
+import {FetchRequestId, NetworkEventManager} from './NetworkEventManager.js';
+import {debugError, isString} from './util.js';
+import {DeferredPromise} from '../util/DeferredPromise.js';
+import {createDebuggableDeferredPromise} from '../util/DebuggableDeferredPromise.js';
+
+/**
+ * @public
+ */
+export interface Credentials {
+ username: string;
+ password: string;
+}
+
+/**
+ * @public
+ */
+export interface NetworkConditions {
+ // Download speed (bytes/s)
+ download: number;
+ // Upload speed (bytes/s)
+ upload: number;
+ // Latency (ms)
+ latency: number;
+}
+/**
+ * @public
+ */
+export interface InternalNetworkConditions extends NetworkConditions {
+ offline: boolean;
+}
+
+/**
+ * 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'),
+ RequestServedFromCache: Symbol('NetworkManager.RequestServedFromCache'),
+ Response: Symbol('NetworkManager.Response'),
+ RequestFailed: Symbol('NetworkManager.RequestFailed'),
+ RequestFinished: Symbol('NetworkManager.RequestFinished'),
+} as const;
+
+interface CDPSession extends EventEmitter {
+ send<T extends keyof ProtocolMapping.Commands>(
+ method: T,
+ ...paramArgs: ProtocolMapping.Commands[T]['paramsType']
+ ): Promise<ProtocolMapping.Commands[T]['returnType']>;
+}
+
+interface FrameManager {
+ frame(frameId: string): Frame | null;
+}
+
+/**
+ * @internal
+ */
+export class NetworkManager extends EventEmitter {
+ #client: CDPSession;
+ #ignoreHTTPSErrors: boolean;
+ #frameManager: FrameManager;
+ #networkEventManager = new NetworkEventManager();
+ #extraHTTPHeaders: Record<string, string> = {};
+ #credentials?: Credentials;
+ #attemptedAuthentications = new Set<string>();
+ #userRequestInterceptionEnabled = false;
+ #protocolRequestInterceptionEnabled = false;
+ #userCacheDisabled = false;
+ #emulatedNetworkConditions: InternalNetworkConditions = {
+ offline: false,
+ upload: -1,
+ download: -1,
+ latency: 0,
+ };
+ #deferredInitPromise?: DeferredPromise<void>;
+
+ 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));
+ this.#client.on(
+ 'Network.responseReceivedExtraInfo',
+ this.#onResponseReceivedExtraInfo.bind(this)
+ );
+ }
+
+ /**
+ * Initialize calls should avoid async dependencies between CDP calls as those
+ * might not resolve until after the target is resumed causing a deadlock.
+ */
+ initialize(): Promise<void> {
+ if (this.#deferredInitPromise) {
+ return this.#deferredInitPromise;
+ }
+ this.#deferredInitPromise = createDebuggableDeferredPromise(
+ 'NetworkManager initialization timed out'
+ );
+ const init = Promise.all([
+ this.#ignoreHTTPSErrors
+ ? this.#client.send('Security.setIgnoreCertificateErrors', {
+ ignore: true,
+ })
+ : null,
+ this.#client.send('Network.enable'),
+ ]);
+ const deferredInitPromise = this.#deferredInitPromise;
+ init
+ .then(() => {
+ deferredInitPromise.resolve();
+ })
+ .catch(err => {
+ deferredInitPromise.reject(err);
+ });
+ return this.#deferredInitPromise;
+ }
+
+ 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(
+ 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);
+ }
+
+ numRequestsInProgress(): number {
+ return this.#networkEventManager.numRequestsInProgress();
+ }
+
+ async setOfflineMode(value: boolean): Promise<void> {
+ this.#emulatedNetworkConditions.offline = value;
+ await this.#updateNetworkConditions();
+ }
+
+ async emulateNetworkConditions(
+ networkConditions: NetworkConditions | null
+ ): Promise<void> {
+ this.#emulatedNetworkConditions.upload = networkConditions
+ ? networkConditions.upload
+ : -1;
+ this.#emulatedNetworkConditions.download = networkConditions
+ ? networkConditions.download
+ : -1;
+ this.#emulatedNetworkConditions.latency = networkConditions
+ ? networkConditions.latency
+ : 0;
+
+ await this.#updateNetworkConditions();
+ }
+
+ async #updateNetworkConditions(): Promise<void> {
+ await this.#client.send('Network.emulateNetworkConditions', {
+ offline: this.#emulatedNetworkConditions.offline,
+ latency: this.#emulatedNetworkConditions.latency,
+ uploadThroughput: this.#emulatedNetworkConditions.upload,
+ downloadThroughput: this.#emulatedNetworkConditions.download,
+ });
+ }
+
+ async setUserAgent(
+ userAgent: string,
+ userAgentMetadata?: Protocol.Emulation.UserAgentMetadata
+ ): Promise<void> {
+ await this.#client.send('Network.setUserAgentOverride', {
+ userAgent: userAgent,
+ userAgentMetadata: userAgentMetadata,
+ });
+ }
+
+ 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'),
+ ]);
+ }
+ }
+
+ #cacheDisabled(): boolean {
+ return this.#userCacheDisabled;
+ }
+
+ async #updateProtocolCacheDisabled(): Promise<void> {
+ await this.#client.send('Network.setCacheDisabled', {
+ cacheDisabled: this.#cacheDisabled(),
+ });
+ }
+
+ #onRequestWillBeSent(event: Protocol.Network.RequestWillBeSentEvent): void {
+ // Request interception doesn't happen for data URLs with Network Service.
+ if (
+ this.#userRequestInterceptionEnabled &&
+ !event.request.url.startsWith('data:')
+ ) {
+ const {requestId: networkRequestId} = event;
+
+ this.#networkEventManager.storeRequestWillBeSent(networkRequestId, event);
+
+ /**
+ * CDP may have sent a Fetch.requestPaused event already. Check for it.
+ */
+ const requestPausedEvent =
+ this.#networkEventManager.getRequestPaused(networkRequestId);
+ if (requestPausedEvent) {
+ const {requestId: fetchRequestId} = requestPausedEvent;
+ this.#patchRequestEventHeaders(event, requestPausedEvent);
+ this.#onRequest(event, fetchRequestId);
+ this.#networkEventManager.forgetRequestPaused(networkRequestId);
+ }
+
+ return;
+ }
+ this.#onRequest(event, undefined);
+ }
+
+ #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);
+ }
+
+ /**
+ * CDP may send a Fetch.requestPaused without or before a
+ * Network.requestWillBeSent
+ *
+ * CDP may send multiple Fetch.requestPaused
+ * for the same Network.requestWillBeSent.
+ */
+ #onRequestPaused(event: Protocol.Fetch.RequestPausedEvent): void {
+ if (
+ !this.#userRequestInterceptionEnabled &&
+ this.#protocolRequestInterceptionEnabled
+ ) {
+ this.#client
+ .send('Fetch.continueRequest', {
+ requestId: event.requestId,
+ })
+ .catch(debugError);
+ }
+
+ const {networkId: networkRequestId, requestId: fetchRequestId} = event;
+
+ if (!networkRequestId) {
+ return;
+ }
+
+ const requestWillBeSentEvent = (() => {
+ const requestWillBeSentEvent =
+ this.#networkEventManager.getRequestWillBeSent(networkRequestId);
+
+ // redirect requests have the same `requestId`,
+ if (
+ requestWillBeSentEvent &&
+ (requestWillBeSentEvent.request.url !== event.request.url ||
+ requestWillBeSentEvent.request.method !== event.request.method)
+ ) {
+ this.#networkEventManager.forgetRequestWillBeSent(networkRequestId);
+ return;
+ }
+ return requestWillBeSentEvent;
+ })();
+
+ if (requestWillBeSentEvent) {
+ this.#patchRequestEventHeaders(requestWillBeSentEvent, event);
+ this.#onRequest(requestWillBeSentEvent, fetchRequestId);
+ } else {
+ this.#networkEventManager.storeRequestPaused(networkRequestId, event);
+ }
+ }
+
+ #patchRequestEventHeaders(
+ requestWillBeSentEvent: Protocol.Network.RequestWillBeSentEvent,
+ requestPausedEvent: Protocol.Fetch.RequestPausedEvent
+ ): void {
+ requestWillBeSentEvent.request.headers = {
+ ...requestWillBeSentEvent.request.headers,
+ // includes extra headers, like: Accept, Origin
+ ...requestPausedEvent.request.headers,
+ };
+ }
+
+ #onRequest(
+ event: Protocol.Network.RequestWillBeSentEvent,
+ fetchRequestId?: FetchRequestId
+ ): void {
+ let redirectChain: HTTPRequest[] = [];
+ if (event.redirectResponse) {
+ // We want to emit a response and requestfinished for the
+ // redirectResponse, but we can't do so unless we have a
+ // responseExtraInfo ready to pair it up with. If we don't have any
+ // responseExtraInfos saved in our queue, they we have to wait until
+ // the next one to emit response and requestfinished, *and* we should
+ // also wait to emit this Request too because it should come after the
+ // response/requestfinished.
+ let redirectResponseExtraInfo = null;
+ if (event.redirectHasExtraInfo) {
+ redirectResponseExtraInfo = this.#networkEventManager
+ .responseExtraInfo(event.requestId)
+ .shift();
+ if (!redirectResponseExtraInfo) {
+ this.#networkEventManager.queueRedirectInfo(event.requestId, {
+ event,
+ fetchRequestId,
+ });
+ return;
+ }
+ }
+
+ const request = this.#networkEventManager.getRequest(event.requestId);
+ // If we connect late to the target, we could have missed the
+ // requestWillBeSent event.
+ if (request) {
+ this.#handleRequestRedirect(
+ request,
+ event.redirectResponse,
+ redirectResponseExtraInfo
+ );
+ redirectChain = request._redirectChain;
+ }
+ }
+ const frame = event.frameId
+ ? this.#frameManager.frame(event.frameId)
+ : null;
+
+ const request = new HTTPRequest(
+ this.#client,
+ frame,
+ fetchRequestId,
+ this.#userRequestInterceptionEnabled,
+ event,
+ redirectChain
+ );
+ this.#networkEventManager.storeRequest(event.requestId, request);
+ this.emit(NetworkManagerEmittedEvents.Request, request);
+ request.finalizeInterceptions();
+ }
+
+ #onRequestServedFromCache(
+ event: Protocol.Network.RequestServedFromCacheEvent
+ ): void {
+ const request = this.#networkEventManager.getRequest(event.requestId);
+ if (request) {
+ request._fromMemoryCache = true;
+ }
+ this.emit(NetworkManagerEmittedEvents.RequestServedFromCache, request);
+ }
+
+ #handleRequestRedirect(
+ request: HTTPRequest,
+ responsePayload: Protocol.Network.Response,
+ extraInfo: Protocol.Network.ResponseReceivedExtraInfoEvent | null
+ ): void {
+ const response = new HTTPResponse(
+ this.#client,
+ request,
+ responsePayload,
+ extraInfo
+ );
+ request._response = response;
+ request._redirectChain.push(request);
+ response._resolveBody(
+ new Error('Response body is unavailable for redirect responses')
+ );
+ this.#forgetRequest(request, false);
+ this.emit(NetworkManagerEmittedEvents.Response, response);
+ this.emit(NetworkManagerEmittedEvents.RequestFinished, request);
+ }
+
+ #emitResponseEvent(
+ responseReceived: Protocol.Network.ResponseReceivedEvent,
+ extraInfo: Protocol.Network.ResponseReceivedExtraInfoEvent | null
+ ): void {
+ const request = this.#networkEventManager.getRequest(
+ responseReceived.requestId
+ );
+ // FileUpload sends a response without a matching request.
+ if (!request) {
+ return;
+ }
+
+ const extraInfos = this.#networkEventManager.responseExtraInfo(
+ responseReceived.requestId
+ );
+ if (extraInfos.length) {
+ debugError(
+ new Error(
+ 'Unexpected extraInfo events for request ' +
+ responseReceived.requestId
+ )
+ );
+ }
+
+ const response = new HTTPResponse(
+ this.#client,
+ request,
+ responseReceived.response,
+ extraInfo
+ );
+ request._response = response;
+ this.emit(NetworkManagerEmittedEvents.Response, response);
+ }
+
+ #onResponseReceived(event: Protocol.Network.ResponseReceivedEvent): void {
+ const request = this.#networkEventManager.getRequest(event.requestId);
+ let extraInfo = null;
+ if (request && !request._fromMemoryCache && event.hasExtraInfo) {
+ extraInfo = this.#networkEventManager
+ .responseExtraInfo(event.requestId)
+ .shift();
+ if (!extraInfo) {
+ // Wait until we get the corresponding ExtraInfo event.
+ this.#networkEventManager.queueEventGroup(event.requestId, {
+ responseReceivedEvent: event,
+ });
+ return;
+ }
+ }
+ this.#emitResponseEvent(event, extraInfo);
+ }
+
+ #onResponseReceivedExtraInfo(
+ event: Protocol.Network.ResponseReceivedExtraInfoEvent
+ ): void {
+ // We may have skipped a redirect response/request pair due to waiting for
+ // this ExtraInfo event. If so, continue that work now that we have the
+ // request.
+ const redirectInfo = this.#networkEventManager.takeQueuedRedirectInfo(
+ event.requestId
+ );
+ if (redirectInfo) {
+ this.#networkEventManager.responseExtraInfo(event.requestId).push(event);
+ this.#onRequest(redirectInfo.event, redirectInfo.fetchRequestId);
+ return;
+ }
+
+ // We may have skipped response and loading events because we didn't have
+ // this ExtraInfo event yet. If so, emit those events now.
+ const queuedEvents = this.#networkEventManager.getQueuedEventGroup(
+ event.requestId
+ );
+ if (queuedEvents) {
+ this.#networkEventManager.forgetQueuedEventGroup(event.requestId);
+ this.#emitResponseEvent(queuedEvents.responseReceivedEvent, event);
+ if (queuedEvents.loadingFinishedEvent) {
+ this.#emitLoadingFinished(queuedEvents.loadingFinishedEvent);
+ }
+ if (queuedEvents.loadingFailedEvent) {
+ this.#emitLoadingFailed(queuedEvents.loadingFailedEvent);
+ }
+ return;
+ }
+
+ // Wait until we get another event that can use this ExtraInfo event.
+ this.#networkEventManager.responseExtraInfo(event.requestId).push(event);
+ }
+
+ #forgetRequest(request: HTTPRequest, events: boolean): void {
+ const requestId = request._requestId;
+ const interceptionId = request._interceptionId;
+
+ this.#networkEventManager.forgetRequest(requestId);
+ interceptionId !== undefined &&
+ this.#attemptedAuthentications.delete(interceptionId);
+
+ if (events) {
+ this.#networkEventManager.forget(requestId);
+ }
+ }
+
+ #onLoadingFinished(event: Protocol.Network.LoadingFinishedEvent): void {
+ // If the response event for this request is still waiting on a
+ // corresponding ExtraInfo event, then wait to emit this event too.
+ const queuedEvents = this.#networkEventManager.getQueuedEventGroup(
+ event.requestId
+ );
+ if (queuedEvents) {
+ queuedEvents.loadingFinishedEvent = event;
+ } else {
+ this.#emitLoadingFinished(event);
+ }
+ }
+
+ #emitLoadingFinished(event: Protocol.Network.LoadingFinishedEvent): void {
+ const request = this.#networkEventManager.getRequest(event.requestId);
+ // For certain requestIds we never receive requestWillBeSent event.
+ // @see https://crbug.com/750469
+ if (!request) {
+ return;
+ }
+
+ // Under certain conditions we never get the Network.responseReceived
+ // event from protocol. @see https://crbug.com/883475
+ if (request.response()) {
+ request.response()?._resolveBody(null);
+ }
+ this.#forgetRequest(request, true);
+ this.emit(NetworkManagerEmittedEvents.RequestFinished, request);
+ }
+
+ #onLoadingFailed(event: Protocol.Network.LoadingFailedEvent): void {
+ // If the response event for this request is still waiting on a
+ // corresponding ExtraInfo event, then wait to emit this event too.
+ const queuedEvents = this.#networkEventManager.getQueuedEventGroup(
+ event.requestId
+ );
+ if (queuedEvents) {
+ queuedEvents.loadingFailedEvent = event;
+ } else {
+ this.#emitLoadingFailed(event);
+ }
+ }
+
+ #emitLoadingFailed(event: Protocol.Network.LoadingFailedEvent): void {
+ const request = this.#networkEventManager.getRequest(event.requestId);
+ // For certain requestIds we never receive requestWillBeSent event.
+ // @see https://crbug.com/750469
+ if (!request) {
+ return;
+ }
+ request._failureText = event.errorText;
+ const response = request.response();
+ if (response) {
+ response._resolveBody(null);
+ }
+ this.#forgetRequest(request, true);
+ 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..2613b4273f
--- /dev/null
+++ b/remote/test/puppeteer/src/common/PDFOptions.ts
@@ -0,0 +1,202 @@
+/**
+ * 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;
+}
+
+/**
+ * @public
+ */
+export type LowerCasePaperFormat =
+ | 'letter'
+ | 'legal'
+ | 'tabloid'
+ | 'ledger'
+ | 'a0'
+ | 'a1'
+ | 'a2'
+ | 'a3'
+ | 'a4'
+ | 'a5'
+ | 'a6';
+
+/**
+ * All the valid paper format types when printing a PDF.
+ *
+ * @remarks
+ *
+ * The sizes of each format are as follows:
+ *
+ * - `Letter`: 8.5in x 11in
+ *
+ * - `Legal`: 8.5in x 14in
+ *
+ * - `Tabloid`: 11in x 17in
+ *
+ * - `Ledger`: 17in x 11in
+ *
+ * - `A0`: 33.1in x 46.8in
+ *
+ * - `A1`: 23.4in x 33.1in
+ *
+ * - `A2`: 16.54in x 23.4in
+ *
+ * - `A3`: 11.7in x 16.54in
+ *
+ * - `A4`: 8.27in x 11.7in
+ *
+ * - `A5`: 5.83in x 8.27in
+ *
+ * - `A6`: 4.13in x 5.83in
+ *
+ * @public
+ */
+export type PaperFormat =
+ | Uppercase<LowerCasePaperFormat>
+ | Capitalize<LowerCasePaperFormat>
+ | LowerCasePaperFormat;
+
+/**
+ * Valid options to configure PDF generation via {@link Page.pdf}.
+ * @public
+ */
+export interface PDFOptions {
+ /**
+ * Scales the rendering of the web page. Amount must be between `0.1` and `2`.
+ * @defaultValue 1
+ */
+ scale?: number;
+ /**
+ * Whether to show the header and footer.
+ * @defaultValue false
+ */
+ displayHeaderFooter?: boolean;
+ /**
+ * HTML template for the print header. Should be valid HTML with the following
+ * classes used to inject values into them:
+ *
+ * - `date` formatted print date
+ *
+ * - `title` document title
+ *
+ * - `url` document location
+ *
+ * - `pageNumber` current page number
+ *
+ * - `totalPages` total pages in the document
+ */
+ headerTemplate?: string;
+ /**
+ * HTML template for the print footer. Has the same constraints and support
+ * for special classes as {@link PDFOptions.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;
+ /**
+ * Hides default white background and allows generating pdfs with transparency.
+ * @defaultValue false
+ */
+ omitBackground?: boolean;
+ /**
+ * Timeout in milliseconds
+ * @defaultValue 30000
+ */
+ timeout?: number;
+}
+
+/**
+ * @internal
+ */
+export interface PaperFormatDimensions {
+ width: number;
+ height: number;
+}
+
+/**
+ * @internal
+ */
+export const _paperFormats: Record<
+ LowerCasePaperFormat,
+ PaperFormatDimensions
+> = {
+ letter: {width: 8.5, height: 11},
+ legal: {width: 8.5, height: 14},
+ tabloid: {width: 11, height: 17},
+ ledger: {width: 17, height: 11},
+ a0: {width: 33.1, height: 46.8},
+ a1: {width: 23.4, height: 33.1},
+ a2: {width: 16.54, height: 23.4},
+ a3: {width: 11.7, height: 16.54},
+ a4: {width: 8.27, height: 11.7},
+ a5: {width: 5.83, height: 8.27},
+ a6: {width: 4.13, height: 5.83},
+} as const;
diff --git a/remote/test/puppeteer/src/common/Page.ts b/remote/test/puppeteer/src/common/Page.ts
new file mode 100644
index 0000000000..ab3a2a2c82
--- /dev/null
+++ b/remote/test/puppeteer/src/common/Page.ts
@@ -0,0 +1,3633 @@
+/**
+ * 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 {Protocol} from 'devtools-protocol';
+import type {Readable} from 'stream';
+import {assert} from '../util/assert.js';
+import {
+ createDeferredPromise,
+ DeferredPromise,
+} from '../util/DeferredPromise.js';
+import {isErrorLike} from '../util/ErrorLike.js';
+import {Accessibility} from './Accessibility.js';
+import {Browser, BrowserContext} from './Browser.js';
+import {CDPSession, CDPSessionEmittedEvents} from './Connection.js';
+import {ConsoleMessage, ConsoleMessageType} from './ConsoleMessage.js';
+import {Coverage} from './Coverage.js';
+import {Dialog} from './Dialog.js';
+import {ElementHandle} from './ElementHandle.js';
+import {EmulationManager} from './EmulationManager.js';
+import {EventEmitter, Handler} from './EventEmitter.js';
+import {FileChooser} from './FileChooser.js';
+import {
+ Frame,
+ FrameAddScriptTagOptions,
+ FrameAddStyleTagOptions,
+} from './Frame.js';
+import {FrameManager, FrameManagerEmittedEvents} from './FrameManager.js';
+import {HTTPRequest} from './HTTPRequest.js';
+import {HTTPResponse} from './HTTPResponse.js';
+import {Keyboard, Mouse, MouseButton, Touchscreen} from './Input.js';
+import {MAIN_WORLD, WaitForSelectorOptions} from './IsolatedWorld.js';
+import {JSHandle} from './JSHandle.js';
+import {PuppeteerLifeCycleEvent} from './LifecycleWatcher.js';
+import {
+ Credentials,
+ NetworkConditions,
+ NetworkManagerEmittedEvents,
+} from './NetworkManager.js';
+import {LowerCasePaperFormat, PDFOptions, _paperFormats} from './PDFOptions.js';
+import {Viewport} from './PuppeteerViewport.js';
+import {Target} from './Target.js';
+import {TargetManagerEmittedEvents} from './TargetManager.js';
+import {TaskQueue} from './TaskQueue.js';
+import {TimeoutSettings} from './TimeoutSettings.js';
+import {Tracing} from './Tracing.js';
+import {EvaluateFunc, HandleFor, NodeFor} from './types.js';
+import {
+ createJSHandle,
+ debugError,
+ evaluationString,
+ getExceptionMessage,
+ getReadableAsBuffer,
+ getReadableFromProtocolStream,
+ importFS,
+ isNumber,
+ isString,
+ pageBindingDeliverErrorString,
+ pageBindingDeliverErrorValueString,
+ pageBindingDeliverResultString,
+ pageBindingInitString,
+ releaseObject,
+ valueFromRemoteObject,
+ waitForEvent,
+ waitWithTimeout,
+} from './util.js';
+import {WebWorker} from './WebWorker.js';
+
+/**
+ * @public
+ */
+export interface Metrics {
+ Timestamp?: number;
+ Documents?: number;
+ Frames?: number;
+ JSEventListeners?: number;
+ Nodes?: number;
+ LayoutCount?: number;
+ RecalcStyleCount?: number;
+ LayoutDuration?: number;
+ RecalcStyleDuration?: number;
+ ScriptDuration?: number;
+ TaskDuration?: number;
+ JSHeapUsedSize?: number;
+ JSHeapTotalSize?: number;
+}
+
+/**
+ * @public
+ */
+export interface WaitTimeoutOptions {
+ /**
+ * Maximum wait time in milliseconds. Pass 0 to disable the timeout.
+ *
+ * The default value can be changed by using the
+ * {@link Page.setDefaultTimeout} method.
+ *
+ * @defaultValue `30000`
+ */
+ timeout?: number;
+}
+
+/**
+ * @public
+ */
+export interface WaitForOptions {
+ /**
+ * Maximum wait time in milliseconds. Pass 0 to disable the timeout.
+ *
+ * The default value can be changed by using the
+ * {@link Page.setDefaultTimeout} or {@link Page.setDefaultNavigationTimeout}
+ * methods.
+ *
+ * @defaultValue `30000`
+ */
+ timeout?: number;
+ 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;
+}
+
+/**
+ * @public
+ */
+export interface MediaFeature {
+ name: string;
+ value: string;
+}
+
+/**
+ * @public
+ */
+export interface ScreenshotClip {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ /**
+ * @defaultValue 1
+ */
+ scale?: number;
+}
+
+/**
+ * @public
+ */
+export interface ScreenshotOptions {
+ /**
+ * @defaultValue `png`
+ */
+ type?: 'png' | 'jpeg' | 'webp';
+ /**
+ * The file path to save the image to. The screenshot type will be inferred
+ * from file extension. If path is a relative path, then it is resolved
+ * relative to current working directory. If no path is provided, the image
+ * won't be saved to the disk.
+ */
+ path?: string;
+ /**
+ * When `true`, takes a screenshot of the full page.
+ * @defaultValue `false`
+ */
+ fullPage?: boolean;
+ /**
+ * An object which specifies the clipping region of the page.
+ */
+ clip?: ScreenshotClip;
+ /**
+ * Quality of the image, between 0-100. Not applicable to `png` images.
+ */
+ quality?: number;
+ /**
+ * Hides default white background and allows capturing screenshots with transparency.
+ * @defaultValue `false`
+ */
+ omitBackground?: boolean;
+ /**
+ * Encoding of the image.
+ * @defaultValue `binary`
+ */
+ encoding?: 'base64' | 'binary';
+ /**
+ * Capture the screenshot beyond the viewport.
+ * @defaultValue `true`
+ */
+ captureBeyondViewport?: boolean;
+ /**
+ * Capture the screenshot from the surface, rather than the view.
+ * @defaultValue `true`
+ */
+ fromSurface?: boolean;
+}
+
+/**
+ * All the events that a page instance may emit.
+ *
+ * @public
+ */
+export const enum PageEmittedEvents {
+ /**
+ * Emitted when the page closes.
+ * @eventProperty
+ */
+ Close = 'close',
+ /**
+ * Emitted when JavaScript within the page calls one of console API methods,
+ * e.g. `console.log` or `console.dir`. Also emitted if the page throws an
+ * error or a warning.
+ *
+ * @remarks
+ * A `console` event provides a {@link ConsoleMessage} representing the
+ * console message that was logged.
+ *
+ * @example
+ * An example of handling `console` event:
+ *
+ * ```ts
+ * page.on('console', msg => {
+ * for (let i = 0; i < msg.args().length; ++i)
+ * console.log(`${i}: ${msg.args()[i]}`);
+ * });
+ * page.evaluate(() => console.log('hello', 5, {foo: 'bar'}));
+ * ```
+ */
+ Console = 'console',
+ /**
+ * Emitted when a JavaScript dialog appears, such as `alert`, `prompt`,
+ * `confirm` or `beforeunload`. Puppeteer can respond to the dialog via
+ * {@link Dialog.accept} or {@link Dialog.dismiss}.
+ */
+ Dialog = 'dialog',
+ /**
+ * Emitted when the JavaScript
+ * {@link https://developer.mozilla.org/en-US/docs/Web/Events/DOMContentLoaded | DOMContentLoaded }
+ * event is dispatched.
+ */
+ DOMContentLoaded = 'domcontentloaded',
+ /**
+ * Emitted when the page crashes. Will contain an `Error`.
+ */
+ Error = 'error',
+ /** Emitted when a frame is attached. Will contain a {@link Frame}. */
+ FrameAttached = 'frameattached',
+ /** Emitted when a frame is detached. Will contain a {@link Frame}. */
+ FrameDetached = 'framedetached',
+ /**
+ * Emitted when a frame is navigated to a new URL. Will contain a
+ * {@link Frame}.
+ */
+ FrameNavigated = 'framenavigated',
+ /**
+ * Emitted when the JavaScript
+ * {@link https://developer.mozilla.org/en-US/docs/Web/Events/load | load}
+ * event is dispatched.
+ */
+ Load = 'load',
+ /**
+ * Emitted when the JavaScript code makes a call to `console.timeStamp`. For
+ * the list of metrics see {@link Page.metrics | page.metrics}.
+ *
+ * @remarks
+ * Contains an object with two properties:
+ *
+ * - `title`: the title passed to `console.timeStamp`
+ * - `metrics`: 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
+ *
+ * ```ts
+ * const [popup] = await Promise.all([
+ * new Promise(resolve => page.once('popup', resolve)),
+ * page.click('a[target=_blank]'),
+ * ]);
+ * ```
+ *
+ * ```ts
+ * const [popup] = await Promise.all([
+ * new Promise(resolve => page.once('popup', resolve)),
+ * page.evaluate(() => window.open('https://example.com')),
+ * ]);
+ * ```
+ */
+ Popup = 'popup',
+ /**
+ * Emitted when a page issues a request and contains a {@link HTTPRequest}.
+ *
+ * @remarks
+ * The object is readonly. See {@link Page.setRequestInterception} for
+ * intercepting and mutating requests.
+ */
+ Request = 'request',
+ /**
+ * Emitted when a request ended up loading from cache. Contains a
+ * {@link HTTPRequest}.
+ *
+ * @remarks
+ * For certain requests, might contain undefined.
+ * {@link https://crbug.com/750469}
+ */
+ RequestServedFromCache = 'requestservedfromcache',
+ /**
+ * Emitted when a request fails, for example by timing out.
+ *
+ * Contains a {@link HTTPRequest}.
+ *
+ * @remarks
+ * HTTP Error responses, such as 404 or 503, are still successful responses
+ * from HTTP standpoint, so request will complete with `requestfinished` event
+ * and not with `requestfailed`.
+ */
+ RequestFailed = 'requestfailed',
+ /**
+ * Emitted when a request finishes successfully. Contains a
+ * {@link HTTPRequest}.
+ */
+ RequestFinished = 'requestfinished',
+ /**
+ * Emitted when a response is received. Contains a {@link HTTPResponse}.
+ */
+ Response = 'response',
+ /**
+ * Emitted when a dedicated
+ * {@link https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API | WebWorker}
+ * is spawned by the page.
+ */
+ WorkerCreated = 'workercreated',
+ /**
+ * Emitted when a dedicated
+ * {@link https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API | WebWorker}
+ * is destroyed by the page.
+ */
+ WorkerDestroyed = 'workerdestroyed',
+}
+
+/**
+ * Denotes the objects received by callback functions for page events.
+ *
+ * See {@link PageEmittedEvents} for more detail on the events and when they are
+ * emitted.
+ *
+ * @public
+ */
+export interface PageEventObject {
+ close: never;
+ console: ConsoleMessage;
+ dialog: Dialog;
+ domcontentloaded: never;
+ error: Error;
+ frameattached: Frame;
+ framedetached: Frame;
+ framenavigated: Frame;
+ load: never;
+ metrics: {title: string; metrics: Metrics};
+ pageerror: Error;
+ popup: Page;
+ request: HTTPRequest;
+ response: HTTPResponse;
+ requestfailed: HTTPRequest;
+ requestfinished: HTTPRequest;
+ requestservedfromcache: HTTPRequest;
+ workercreated: WebWorker;
+ workerdestroyed: WebWorker;
+}
+
+/**
+ * Page provides methods to interact with a single tab or
+ * {@link https://developer.chrome.com/extensions/background_pages | extension background page}
+ * in Chromium.
+ *
+ * :::note
+ *
+ * One Browser instance might have multiple Page instances.
+ *
+ * :::
+ *
+ * @example
+ * This example creates a page, navigates it to a URL, and then saves a screenshot:
+ *
+ * ```ts
+ * 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:
+ *
+ * ```ts
+ * page.once('load', () => console.log('Page loaded!'));
+ * ```
+ *
+ * To unsubscribe from events use the {@link Page.off} method:
+ *
+ * ```ts
+ * function logRequest(interceptedRequest) {
+ * console.log('A request was made:', interceptedRequest.url());
+ * }
+ * page.on('request', logRequest);
+ * // Sometime later...
+ * page.off('request', logRequest);
+ * ```
+ *
+ * @public
+ */
+export class Page extends EventEmitter {
+ /**
+ * @internal
+ */
+ static async _create(
+ client: CDPSession,
+ target: Target,
+ ignoreHTTPSErrors: boolean,
+ defaultViewport: Viewport | null,
+ screenshotTaskQueue: TaskQueue
+ ): Promise<Page> {
+ const page = new Page(
+ client,
+ target,
+ ignoreHTTPSErrors,
+ screenshotTaskQueue
+ );
+ await page.#initialize();
+ if (defaultViewport) {
+ await page.setViewport(defaultViewport);
+ }
+ return page;
+ }
+
+ #closed = false;
+ #client: CDPSession;
+ #target: Target;
+ #keyboard: Keyboard;
+ #mouse: Mouse;
+ #timeoutSettings = new TimeoutSettings();
+ #touchscreen: Touchscreen;
+ #accessibility: Accessibility;
+ #frameManager: FrameManager;
+ #emulationManager: EmulationManager;
+ #tracing: Tracing;
+ #pageBindings = new Map<string, Function>();
+ #coverage: Coverage;
+ #javascriptEnabled = true;
+ #viewport: Viewport | null;
+ #screenshotTaskQueue: TaskQueue;
+ #workers = new Map<string, WebWorker>();
+ #fileChooserPromises = new Set<DeferredPromise<FileChooser>>();
+
+ #disconnectPromise?: Promise<Error>;
+ #userDragInterceptionEnabled = false;
+ #handlerMap = new WeakMap<Handler, Handler>();
+
+ /**
+ * @internal
+ */
+ constructor(
+ client: CDPSession,
+ target: Target,
+ ignoreHTTPSErrors: boolean,
+ screenshotTaskQueue: TaskQueue
+ ) {
+ 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 = screenshotTaskQueue;
+ this.#viewport = null;
+
+ this.#target
+ ._targetManager()
+ .addTargetInterceptor(this.#client, this.#onAttachedToTarget);
+
+ this.#target
+ ._targetManager()
+ .on(TargetManagerEmittedEvents.TargetGone, this.#onDetachedFromTarget);
+
+ this.#frameManager.on(FrameManagerEmittedEvents.FrameAttached, event => {
+ return this.emit(PageEmittedEvents.FrameAttached, event);
+ });
+ this.#frameManager.on(FrameManagerEmittedEvents.FrameDetached, event => {
+ return this.emit(PageEmittedEvents.FrameDetached, event);
+ });
+ this.#frameManager.on(FrameManagerEmittedEvents.FrameNavigated, event => {
+ return this.emit(PageEmittedEvents.FrameNavigated, event);
+ });
+
+ const networkManager = this.#frameManager.networkManager;
+ networkManager.on(NetworkManagerEmittedEvents.Request, event => {
+ return this.emit(PageEmittedEvents.Request, event);
+ });
+ networkManager.on(
+ NetworkManagerEmittedEvents.RequestServedFromCache,
+ event => {
+ return this.emit(PageEmittedEvents.RequestServedFromCache, event);
+ }
+ );
+ networkManager.on(NetworkManagerEmittedEvents.Response, event => {
+ return this.emit(PageEmittedEvents.Response, event);
+ });
+ networkManager.on(NetworkManagerEmittedEvents.RequestFailed, event => {
+ return this.emit(PageEmittedEvents.RequestFailed, event);
+ });
+ networkManager.on(NetworkManagerEmittedEvents.RequestFinished, event => {
+ return this.emit(PageEmittedEvents.RequestFinished, event);
+ });
+
+ client.on('Page.domContentEventFired', () => {
+ return this.emit(PageEmittedEvents.DOMContentLoaded);
+ });
+ client.on('Page.loadEventFired', () => {
+ return this.emit(PageEmittedEvents.Load);
+ });
+ client.on('Runtime.consoleAPICalled', event => {
+ return this.#onConsoleAPI(event);
+ });
+ client.on('Runtime.bindingCalled', event => {
+ return this.#onBindingCalled(event);
+ });
+ client.on('Page.javascriptDialogOpening', event => {
+ return this.#onDialog(event);
+ });
+ client.on('Runtime.exceptionThrown', exception => {
+ return this.#handleException(exception.exceptionDetails);
+ });
+ client.on('Inspector.targetCrashed', () => {
+ return this.#onTargetCrashed();
+ });
+ client.on('Performance.metrics', event => {
+ return this.#emitMetrics(event);
+ });
+ client.on('Log.entryAdded', event => {
+ return this.#onLogEntryAdded(event);
+ });
+ client.on('Page.fileChooserOpened', event => {
+ return this.#onFileChooser(event);
+ });
+ this.#target._isClosedPromise.then(() => {
+ this.#target
+ ._targetManager()
+ .removeTargetInterceptor(this.#client, this.#onAttachedToTarget);
+
+ this.#target
+ ._targetManager()
+ .off(TargetManagerEmittedEvents.TargetGone, this.#onDetachedFromTarget);
+ this.emit(PageEmittedEvents.Close);
+ this.#closed = true;
+ });
+ }
+
+ #onDetachedFromTarget = (target: Target) => {
+ const sessionId = target._session()?.id();
+
+ this.#frameManager.onDetachedFromTarget(target);
+
+ const worker = this.#workers.get(sessionId!);
+ if (!worker) {
+ return;
+ }
+ this.#workers.delete(sessionId!);
+ this.emit(PageEmittedEvents.WorkerDestroyed, worker);
+ };
+
+ #onAttachedToTarget = async (createdTarget: Target) => {
+ this.#frameManager.onAttachedToTarget(createdTarget);
+ if (createdTarget._getTargetInfo().type === 'worker') {
+ const session = createdTarget._session();
+ assert(session);
+ const worker = new WebWorker(
+ session,
+ createdTarget.url(),
+ this.#addConsoleMessage.bind(this),
+ this.#handleException.bind(this)
+ );
+ this.#workers.set(session.id(), worker);
+ this.emit(PageEmittedEvents.WorkerCreated, worker);
+ }
+ if (createdTarget._session()) {
+ this.#target
+ ._targetManager()
+ .addTargetInterceptor(
+ createdTarget._session()!,
+ this.#onAttachedToTarget
+ );
+ }
+ };
+
+ async #initialize(): Promise<void> {
+ await Promise.all([
+ this.#frameManager.initialize(this.#target._targetId),
+ this.#client.send('Performance.enable'),
+ this.#client.send('Log.enable'),
+ ]);
+ }
+
+ async #onFileChooser(
+ event: Protocol.Page.FileChooserOpenedEvent
+ ): Promise<void> {
+ if (!this.#fileChooserPromises.size) {
+ return;
+ }
+
+ const frame = this.#frameManager.frame(event.frameId);
+ assert(frame, 'This should never happen.');
+
+ // This is guaranteed to be an HTMLInputElement handle by the event.
+ const handle = (await frame.worlds[MAIN_WORLD].adoptBackendNode(
+ event.backendNodeId
+ )) as ElementHandle<HTMLInputElement>;
+
+ const fileChooser = new FileChooser(handle, event);
+ for (const promise of this.#fileChooserPromises) {
+ promise.resolve(fileChooser);
+ }
+ this.#fileChooserPromises.clear();
+ }
+
+ /**
+ * @returns `true` if drag events are being intercepted, `false` otherwise.
+ */
+ isDragInterceptionEnabled(): boolean {
+ return this.#userDragInterceptionEnabled;
+ }
+
+ /**
+ * @returns `true` if the page has JavaScript enabled, `false` otherwise.
+ */
+ isJavaScriptEnabled(): boolean {
+ return this.#javascriptEnabled;
+ }
+
+ /**
+ * Listen to page events.
+ *
+ * :::note
+ *
+ * This method exists to define event typings and handle proper wireup of
+ * cooperative request interception. Actual event listening and dispatching is
+ * delegated to {@link EventEmitter}.
+ *
+ * :::
+ */
+ override on<K extends keyof PageEventObject>(
+ eventName: K,
+ handler: (event: PageEventObject[K]) => void
+ ): EventEmitter {
+ if (eventName === 'request') {
+ const wrap =
+ this.#handlerMap.get(handler) ||
+ ((event: HTTPRequest) => {
+ event.enqueueInterceptAction(() => {
+ return handler(event as PageEventObject[K]);
+ });
+ });
+
+ this.#handlerMap.set(handler, wrap);
+
+ return super.on(eventName, wrap);
+ }
+ return super.on(eventName, handler);
+ }
+
+ override once<K extends keyof PageEventObject>(
+ eventName: K,
+ handler: (event: PageEventObject[K]) => void
+ ): EventEmitter {
+ // Note: this method only exists to define the types; we delegate the impl
+ // to EventEmitter.
+ return super.once(eventName, handler);
+ }
+
+ override off<K extends keyof PageEventObject>(
+ eventName: K,
+ handler: (event: PageEventObject[K]) => void
+ ): EventEmitter {
+ if (eventName === 'request') {
+ handler = this.#handlerMap.get(handler) || handler;
+ }
+
+ return super.off(eventName, handler);
+ }
+
+ /**
+ * This method is typically coupled with an action that triggers file
+ * choosing.
+ *
+ * :::caution
+ *
+ * This must be called before the file chooser is launched. It will not return
+ * a currently active file chooser.
+ *
+ * :::
+ *
+ * @remarks
+ * In non-headless Chromium, this method results in the native file picker
+ * dialog `not showing up` for the user.
+ *
+ * @example
+ * The following example clicks a button that issues a file chooser
+ * and then responds with `/tmp/myfile.pdf` as if a user has selected this file.
+ *
+ * ```ts
+ * const [fileChooser] = await Promise.all([
+ * page.waitForFileChooser(),
+ * page.click('#upload-file-button'),
+ * // some button that triggers file selection
+ * ]);
+ * await fileChooser.accept(['/tmp/myfile.pdf']);
+ * ```
+ */
+ waitForFileChooser(options: WaitTimeoutOptions = {}): Promise<FileChooser> {
+ const needsEnable = this.#fileChooserPromises.size === 0;
+ const {timeout = this.#timeoutSettings.timeout()} = options;
+ const promise = createDeferredPromise<FileChooser>({
+ message: `Waiting for \`FileChooser\` failed: ${timeout}ms exceeded`,
+ timeout,
+ });
+ this.#fileChooserPromises.add(promise);
+ let enablePromise: Promise<void> | undefined;
+ if (needsEnable) {
+ enablePromise = this.#client.send('Page.setInterceptFileChooserDialog', {
+ enabled: true,
+ });
+ }
+ return Promise.all([promise, enablePromise])
+ .then(([result]) => {
+ return result;
+ })
+ .catch(error => {
+ this.#fileChooserPromises.delete(promise);
+ throw error;
+ });
+ }
+
+ /**
+ * Sets the page's geolocation.
+ *
+ * @remarks
+ * Consider using {@link BrowserContext.overridePermissions} to grant
+ * permissions for the page to read its geolocation.
+ *
+ * @example
+ *
+ * ```ts
+ * await page.setGeolocation({latitude: 59.95, longitude: 30.31667});
+ * ```
+ */
+ 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;
+ }
+
+ /**
+ * @internal
+ */
+ _client(): CDPSession {
+ return this.#client;
+ }
+
+ /**
+ * Get the browser the page belongs to.
+ */
+ browser(): Browser {
+ return this.#target.browser();
+ }
+
+ /**
+ * Get the browser context that the page belongs to.
+ */
+ browserContext(): BrowserContext {
+ return this.#target.browserContext();
+ }
+
+ #onTargetCrashed(): void {
+ this.emit('error', new Error('Page crashed!'));
+ }
+
+ #onLogEntryAdded(event: Protocol.Log.EntryAddedEvent): void {
+ const {level, text, args, source, url, lineNumber} = event.entry;
+ if (args) {
+ args.map(arg => {
+ return releaseObject(this.#client, arg);
+ });
+ }
+ if (source !== 'worker') {
+ this.emit(
+ PageEmittedEvents.Console,
+ new ConsoleMessage(level, text, [], [{url, lineNumber}])
+ );
+ }
+ }
+
+ /**
+ * @returns The page's main frame.
+ *
+ * @remarks
+ * Page is guaranteed to have a main frame which persists during navigations.
+ */
+ 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.
+ *
+ * @remarks
+ * This does not contain ServiceWorkers
+ */
+ workers(): WebWorker[] {
+ return Array.from(this.#workers.values());
+ }
+
+ /**
+ * Activating request interception enables {@link HTTPRequest.abort},
+ * {@link HTTPRequest.continue} and {@link HTTPRequest.respond} methods. This
+ * provides the capability to modify network requests that are made by a page.
+ *
+ * Once request interception is enabled, every request will stall unless it's
+ * continued, responded or aborted; or completed using the browser cache.
+ *
+ * Enabling request interception disables page caching.
+ *
+ * See the
+ * {@link https://pptr.dev/next/guides/request-interception|Request interception guide}
+ * for more details.
+ *
+ * @example
+ * An example of a naïve request interceptor that aborts all image requests:
+ *
+ * ```ts
+ * 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();
+ * })();
+ * ```
+ *
+ * @param value - Whether to enable request interception.
+ */
+ async setRequestInterception(value: boolean): Promise<void> {
+ return this.#frameManager.networkManager.setRequestInterception(value);
+ }
+
+ /**
+ * @param enabled - Whether to enable drag interception.
+ *
+ * @remarks
+ * Activating drag interception enables the `Input.drag`,
+ * methods This provides the capability to capture drag events emitted
+ * on the page, which can then be used to simulate drag-and-drop.
+ */
+ async setDragInterception(enabled: boolean): Promise<void> {
+ this.#userDragInterceptionEnabled = enabled;
+ return this.#client.send('Input.setInterceptDrags', {enabled});
+ }
+
+ /**
+ * @param enabled - When `true`, enables offline mode for the page.
+ * @remarks
+ * NOTE: while this method sets the network connection to offline, it does
+ * not change the parameters used in [page.emulateNetworkConditions(networkConditions)]
+ * (#pageemulatenetworkconditionsnetworkconditions)
+ */
+ setOfflineMode(enabled: boolean): Promise<void> {
+ return this.#frameManager.networkManager.setOfflineMode(enabled);
+ }
+
+ /**
+ * @param networkConditions - Passing `null` disables network condition emulation.
+ * @example
+ *
+ * ```ts
+ * const puppeteer = require('puppeteer');
+ * const slow3G = puppeteer.networkConditions['Slow 3G'];
+ *
+ * (async () => {
+ * const browser = await puppeteer.launch();
+ * const page = await browser.newPage();
+ * await page.emulateNetworkConditions(slow3G);
+ * await page.goto('https://www.google.com');
+ * // other actions...
+ * await browser.close();
+ * })();
+ * ```
+ *
+ * @remarks
+ * NOTE: This does not affect WebSockets and WebRTC PeerConnections (see
+ * https://crbug.com/563644). To set the page offline, you can use
+ * [page.setOfflineMode(enabled)](#pagesetofflinemodeenabled).
+ */
+ emulateNetworkConditions(
+ networkConditions: NetworkConditions | null
+ ): Promise<void> {
+ return this.#frameManager.networkManager.emulateNetworkConditions(
+ networkConditions
+ );
+ }
+
+ /**
+ * This setting will change the default maximum navigation time for the
+ * following methods and related shortcuts:
+ *
+ * - {@link Page.goBack | page.goBack(options)}
+ *
+ * - {@link Page.goForward | page.goForward(options)}
+ *
+ * - {@link Page.goto | page.goto(url,options)}
+ *
+ * - {@link Page.reload | page.reload(options)}
+ *
+ * - {@link Page.setContent | page.setContent(html,options)}
+ *
+ * - {@link Page.waitForNavigation | page.waitForNavigation(options)}
+ * @param timeout - Maximum navigation time in milliseconds.
+ */
+ setDefaultNavigationTimeout(timeout: number): void {
+ this.#timeoutSettings.setDefaultNavigationTimeout(timeout);
+ }
+
+ /**
+ * @param timeout - Maximum time in milliseconds.
+ */
+ setDefaultTimeout(timeout: number): void {
+ this.#timeoutSettings.setDefaultTimeout(timeout);
+ }
+
+ /**
+ * @returns Maximum time in milliseconds.
+ */
+ getDefaultTimeout(): number {
+ return this.#timeoutSettings.timeout();
+ }
+
+ /**
+ * Runs `document.querySelector` within the page. If no element matches the
+ * selector, the return value resolves to `null`.
+ *
+ * @param selector - A `selector` to query page for
+ * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors | selector}
+ * to query page for.
+ */
+ async $<Selector extends string>(
+ selector: Selector
+ ): Promise<ElementHandle<NodeFor<Selector>> | null> {
+ return this.mainFrame().$(selector);
+ }
+
+ /**
+ * The method runs `document.querySelectorAll` within the page. If no elements
+ * match the selector, the return value resolves to `[]`.
+ * @remarks
+ * Shortcut for {@link Frame.$$ | Page.mainFrame().$$(selector) }.
+ * @param selector - A `selector` to query page for
+ */
+ async $$<Selector extends string>(
+ selector: Selector
+ ): Promise<Array<ElementHandle<NodeFor<Selector>>>> {
+ 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
+ *
+ * ```ts
+ * const aHandle = await page.evaluateHandle('document');
+ * ```
+ *
+ * @example
+ * {@link JSHandle} instances can be passed as arguments to the `pageFunction`:
+ *
+ * ```ts
+ * const aHandle = await page.evaluateHandle(() => document.body);
+ * const resultHandle = await page.evaluateHandle(
+ * body => body.innerHTML,
+ * aHandle
+ * );
+ * console.log(await resultHandle.jsonValue());
+ * await resultHandle.dispose();
+ * ```
+ *
+ * Most of the time this function returns a {@link JSHandle},
+ * but if `pageFunction` returns a reference to an element,
+ * you instead get an {@link ElementHandle} back:
+ *
+ * @example
+ *
+ * ```ts
+ * const button = await page.evaluateHandle(() =>
+ * document.querySelector('button')
+ * );
+ * // can call `click` because `button` is an `ElementHandle`
+ * await button.click();
+ * ```
+ *
+ * The TypeScript definitions assume that `evaluateHandle` returns
+ * a `JSHandle`, but if you know it's going to return an
+ * `ElementHandle`, pass it as the generic argument:
+ *
+ * ```ts
+ * const button = await page.evaluateHandle<ElementHandle>(...);
+ * ```
+ *
+ * @param pageFunction - a function that is run within the page
+ * @param args - arguments to be passed to the pageFunction
+ */
+ async evaluateHandle<
+ Params extends unknown[],
+ Func extends EvaluateFunc<Params> = EvaluateFunc<Params>
+ >(
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<HandleFor<Awaited<ReturnType<Func>>>> {
+ const context = await this.mainFrame().executionContext();
+ return context.evaluateHandle(pageFunction, ...args);
+ }
+
+ /**
+ * This method iterates the JavaScript heap and finds all objects with the
+ * given prototype.
+ *
+ * @example
+ *
+ * ```ts
+ * // Create a Map object
+ * await page.evaluate(() => (window.map = new Map()));
+ * // Get a handle to the Map object prototype
+ * const mapPrototype = await page.evaluateHandle(() => Map.prototype);
+ * // Query all map instances into an array
+ * const mapInstances = await page.queryObjects(mapPrototype);
+ * // Count amount of map objects in heap
+ * const count = await page.evaluate(maps => maps.length, mapInstances);
+ * await mapInstances.dispose();
+ * await mapPrototype.dispose();
+ * ```
+ *
+ * @param prototypeHandle - a handle to the object prototype.
+ * @returns Promise which resolves to a handle to an array of objects with
+ * this prototype.
+ */
+ async queryObjects<Prototype>(
+ prototypeHandle: JSHandle<Prototype>
+ ): Promise<JSHandle<Prototype[]>> {
+ const context = await this.mainFrame().executionContext();
+ assert(!prototypeHandle.disposed, 'Prototype JSHandle is disposed!');
+ const remoteObject = prototypeHandle.remoteObject();
+ assert(
+ remoteObject.objectId,
+ 'Prototype JSHandle must not be referencing primitive value'
+ );
+ const response = await context._client.send('Runtime.queryObjects', {
+ prototypeObjectId: remoteObject.objectId,
+ });
+ return createJSHandle(context, response.objects) as HandleFor<Prototype[]>;
+ }
+
+ /**
+ * This method runs `document.querySelector` within the page and passes the
+ * result as the first argument to the `pageFunction`.
+ *
+ * @remarks
+ *
+ * If no element is found matching `selector`, the method will throw an error.
+ *
+ * If `pageFunction` returns a promise `$eval` will wait for the promise to
+ * resolve and then return its value.
+ *
+ * @example
+ *
+ * ```ts
+ * const searchValue = await page.$eval('#search', el => el.value);
+ * const preloadHref = await page.$eval('link[rel=preload]', el => el.href);
+ * const html = await page.$eval('.main-container', el => el.outerHTML);
+ * ```
+ *
+ * If you are using TypeScript, you may have to provide an explicit type to the
+ * first argument of the `pageFunction`.
+ * By default it is typed as `Element`, but you may need to provide a more
+ * specific sub-type:
+ *
+ * @example
+ *
+ * ```ts
+ * // if you don't provide HTMLInputElement here, TS will error
+ * // as `value` is not on `Element`
+ * const searchValue = await page.$eval(
+ * '#search',
+ * (el: HTMLInputElement) => el.value
+ * );
+ * ```
+ *
+ * The compiler should be able to infer the return type
+ * from the `pageFunction` you provide. If it is unable to, you can use the generic
+ * type to tell the compiler what return type you expect from `$eval`:
+ *
+ * @example
+ *
+ * ```ts
+ * // The compiler can infer the return type in this case, but if it can't
+ * // or if you want to be more explicit, provide it as the generic type.
+ * const searchValue = await page.$eval<string>(
+ * '#search',
+ * (el: HTMLInputElement) => el.value
+ * );
+ * ```
+ *
+ * @param selector - the
+ * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors | selector}
+ * to query for
+ * @param pageFunction - the function to be evaluated in the page context.
+ * Will be passed the result of `document.querySelector(selector)` as its
+ * first argument.
+ * @param args - any additional arguments to pass through to `pageFunction`.
+ *
+ * @returns The result of calling `pageFunction`. If it returns an element it
+ * is wrapped in an {@link ElementHandle}, else the raw value itself is
+ * returned.
+ */
+ async $eval<
+ Selector extends string,
+ Params extends unknown[],
+ Func extends EvaluateFunc<
+ [ElementHandle<NodeFor<Selector>>, ...Params]
+ > = EvaluateFunc<[ElementHandle<NodeFor<Selector>>, ...Params]>
+ >(
+ selector: Selector,
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<Awaited<ReturnType<Func>>> {
+ return this.mainFrame().$eval(selector, pageFunction, ...args);
+ }
+
+ /**
+ * This method runs `Array.from(document.querySelectorAll(selector))` within
+ * the page and passes the result as the first argument to the `pageFunction`.
+ *
+ * @remarks
+ * If `pageFunction` returns a promise `$$eval` will wait for the promise to
+ * resolve and then return its value.
+ *
+ * @example
+ *
+ * ```ts
+ * // get the amount of divs on the page
+ * const divCount = await page.$$eval('div', divs => divs.length);
+ *
+ * // get the text content of all the `.options` elements:
+ * const options = await page.$$eval('div > span.options', options => {
+ * return options.map(option => option.textContent);
+ * });
+ * ```
+ *
+ * If you are using TypeScript, you may have to provide an explicit type to the
+ * first argument of the `pageFunction`.
+ * By default it is typed as `Element[]`, but you may need to provide a more
+ * specific sub-type:
+ *
+ * @example
+ *
+ * ```ts
+ * // if you don't provide HTMLInputElement here, TS will error
+ * // as `value` is not on `Element`
+ * await page.$$eval('input', (elements: HTMLInputElement[]) => {
+ * return elements.map(e => e.value);
+ * });
+ * ```
+ *
+ * The compiler should be able to infer the return type
+ * from the `pageFunction` you provide. If it is unable to, you can use the generic
+ * type to tell the compiler what return type you expect from `$$eval`:
+ *
+ * @example
+ *
+ * ```ts
+ * // The compiler can infer the return type in this case, but if it can't
+ * // or if you want to be more explicit, provide it as the generic type.
+ * const allInputValues = await page.$$eval<string[]>(
+ * 'input',
+ * (elements: HTMLInputElement[]) => elements.map(e => e.textContent)
+ * );
+ * ```
+ *
+ * @param selector - the
+ * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors | selector}
+ * to query for
+ * @param pageFunction - the function to be evaluated in the page context.
+ * Will be passed the result of
+ * `Array.from(document.querySelectorAll(selector))` as its first argument.
+ * @param args - any additional arguments to pass through to `pageFunction`.
+ *
+ * @returns The result of calling `pageFunction`. If it returns an element it
+ * is wrapped in an {@link ElementHandle}, else the raw value itself is
+ * returned.
+ */
+ async $$eval<
+ Selector extends string,
+ Params extends unknown[],
+ Func extends EvaluateFunc<
+ [Array<NodeFor<Selector>>, ...Params]
+ > = EvaluateFunc<[Array<NodeFor<Selector>>, ...Params]>
+ >(
+ selector: Selector,
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<Awaited<ReturnType<Func>>> {
+ return this.mainFrame().$$eval(selector, pageFunction, ...args);
+ }
+
+ /**
+ * The method evaluates the XPath expression relative to the page document as
+ * its context node. If there are no such elements, the method resolves to an
+ * empty array.
+ *
+ * @remarks
+ * Shortcut for {@link Frame.$x | Page.mainFrame().$x(expression) }.
+ *
+ * @param expression - Expression to evaluate
+ */
+ async $x(expression: string): Promise<Array<ElementHandle<Node>>> {
+ return 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 as unknown as Record<string, unknown>)[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);
+ }
+ }
+
+ /**
+ * @example
+ *
+ * ```ts
+ * await page.setCookie(cookieObject1, cookieObject2);
+ * ```
+ */
+ 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});
+ }
+ }
+
+ /**
+ * Adds a `<script>` tag into the page with the desired URL or content.
+ *
+ * @remarks
+ * Shortcut for
+ * {@link Frame.addScriptTag | page.mainFrame().addScriptTag(options)}.
+ *
+ * @param options - Options for the script.
+ * @returns An {@link ElementHandle | element handle} to the injected
+ * `<script>` element.
+ */
+ async addScriptTag(
+ options: FrameAddScriptTagOptions
+ ): Promise<ElementHandle<HTMLScriptElement>> {
+ return this.mainFrame().addScriptTag(options);
+ }
+
+ /**
+ * Adds a `<link rel="stylesheet">` tag into the page with the desired URL or
+ * a `<style type="text/css">` tag with the content.
+ *
+ * Shortcut for
+ * {@link Frame.addStyleTag | page.mainFrame().addStyleTag(options)}.
+ *
+ * @returns An {@link ElementHandle | element handle} to the injected `<link>`
+ * or `<style>` element.
+ */
+ async addStyleTag(
+ options: Omit<FrameAddStyleTagOptions, 'url'>
+ ): Promise<ElementHandle<HTMLStyleElement>>;
+ async addStyleTag(
+ options: FrameAddStyleTagOptions
+ ): Promise<ElementHandle<HTMLLinkElement>>;
+ async addStyleTag(
+ options: FrameAddStyleTagOptions
+ ): Promise<ElementHandle<HTMLStyleElement | HTMLLinkElement>> {
+ return this.mainFrame().addStyleTag(options);
+ }
+
+ /**
+ * The method adds a function called `name` on the page's `window` object.
+ * When called, the function executes `puppeteerFunction` in node.js and
+ * returns a `Promise` which resolves to the return value of
+ * `puppeteerFunction`.
+ *
+ * If the puppeteerFunction returns a `Promise`, it will be awaited.
+ *
+ * :::note
+ *
+ * Functions installed via `page.exposeFunction` survive navigations.
+ *
+ * :::note
+ *
+ * @example
+ * An example of adding an `md5` function into the page:
+ *
+ * ```ts
+ * const puppeteer = require('puppeteer');
+ * const crypto = require('crypto');
+ *
+ * (async () => {
+ * const browser = await puppeteer.launch();
+ * const page = await browser.newPage();
+ * page.on('console', msg => console.log(msg.text()));
+ * await page.exposeFunction('md5', text =>
+ * crypto.createHash('md5').update(text).digest('hex')
+ * );
+ * await page.evaluate(async () => {
+ * // use window.md5 to compute hashes
+ * const myString = 'PUPPETEER';
+ * const myHash = await window.md5(myString);
+ * console.log(`md5 of ${myString} is ${myHash}`);
+ * });
+ * await browser.close();
+ * })();
+ * ```
+ *
+ * @example
+ * An example of adding a `window.readfile` function into the page:
+ *
+ * ```ts
+ * const puppeteer = require('puppeteer');
+ * const fs = require('fs');
+ *
+ * (async () => {
+ * const browser = await puppeteer.launch();
+ * const page = await browser.newPage();
+ * page.on('console', msg => console.log(msg.text()));
+ * await page.exposeFunction('readfile', async filePath => {
+ * return new Promise((resolve, reject) => {
+ * fs.readFile(filePath, 'utf8', (err, text) => {
+ * if (err) reject(err);
+ * else resolve(text);
+ * });
+ * });
+ * });
+ * await page.evaluate(async () => {
+ * // use window.readfile to read contents of a file
+ * const content = await window.readfile('/etc/hosts');
+ * console.log(content);
+ * });
+ * await browser.close();
+ * })();
+ * ```
+ *
+ * @param name - Name of the function on the window object
+ * @param pptrFunction - Callback function which will be called in Puppeteer's
+ * context.
+ */
+ async exposeFunction(
+ name: string,
+ pptrFunction: Function | {default: Function}
+ ): Promise<void> {
+ if (this.#pageBindings.has(name)) {
+ throw new Error(
+ `Failed to add page binding with name ${name}: window['${name}'] already exists!`
+ );
+ }
+
+ let exposedFunction: Function;
+ switch (typeof pptrFunction) {
+ case 'function':
+ exposedFunction = pptrFunction;
+ break;
+ default:
+ exposedFunction = pptrFunction.default;
+ break;
+ }
+
+ this.#pageBindings.set(name, exposedFunction);
+
+ const expression = 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 => {
+ return frame.evaluate(expression).catch(debugError);
+ })
+ );
+ }
+
+ /**
+ * Provide credentials for `HTTP authentication`.
+ *
+ * @remarks
+ * To disable authentication, pass `null`.
+ */
+ async authenticate(credentials: Credentials): Promise<void> {
+ return this.#frameManager.networkManager.authenticate(credentials);
+ }
+
+ /**
+ * The extra HTTP headers will be sent with every request the page initiates.
+ *
+ * :::tip
+ *
+ * All HTTP header names are lowercased. (HTTP headers are
+ * case-insensitive, so this shouldn’t impact your server code.)
+ *
+ * :::
+ *
+ * :::note
+ *
+ * page.setExtraHTTPHeaders does not guarantee the order of headers in
+ * the outgoing requests.
+ *
+ * :::
+ *
+ * @param headers - An object containing additional HTTP headers to be sent
+ * with every request. All header values must be strings.
+ */
+ async setExtraHTTPHeaders(headers: Record<string, string>): Promise<void> {
+ return this.#frameManager.networkManager.setExtraHTTPHeaders(headers);
+ }
+
+ /**
+ * @param userAgent - Specific user agent to use in this page
+ * @param userAgentData - Specific user agent client hint data to use in this
+ * page
+ * @returns Promise which resolves when the user agent is set.
+ */
+ async setUserAgent(
+ userAgent: string,
+ userAgentMetadata?: Protocol.Emulation.UserAgentMetadata
+ ): Promise<void> {
+ return this.#frameManager.networkManager.setUserAgent(
+ userAgent,
+ userAgentMetadata
+ );
+ }
+
+ /**
+ * @returns Object containing metrics as key/value pairs.
+ *
+ * - `Timestamp` : The timestamp when the metrics sample was taken.
+ *
+ * - `Documents` : Number of documents in the page.
+ *
+ * - `Frames` : Number of frames in the page.
+ *
+ * - `JSEventListeners` : Number of events in the page.
+ *
+ * - `Nodes` : Number of DOM nodes in the page.
+ *
+ * - `LayoutCount` : Total number of full or partial page layout.
+ *
+ * - `RecalcStyleCount` : Total number of page style recalculations.
+ *
+ * - `LayoutDuration` : Combined durations of all page layouts.
+ *
+ * - `RecalcStyleDuration` : Combined duration of all page style
+ * recalculations.
+ *
+ * - `ScriptDuration` : Combined duration of JavaScript execution.
+ *
+ * - `TaskDuration` : Combined duration of all tasks performed by the browser.
+ *
+ * - `JSHeapUsedSize` : Used JavaScript heap size.
+ *
+ * - `JSHeapTotalSize` : Total JavaScript heap size.
+ *
+ * @remarks
+ * All timestamps are in monotonic time: monotonically increasing time
+ * in seconds since an arbitrary point in the past.
+ */
+ async metrics(): Promise<Metrics> {
+ const response = await this.#client.send('Performance.getMetrics');
+ return this.#buildMetricsObject(response.metrics);
+ }
+
+ #emitMetrics(event: Protocol.Performance.MetricsEvent): void {
+ this.emit(PageEmittedEvents.Metrics, {
+ title: event.title,
+ metrics: this.#buildMetricsObject(event.metrics),
+ });
+ }
+
+ #buildMetricsObject(metrics?: Protocol.Performance.Metric[]): Metrics {
+ const result: Record<
+ Protocol.Performance.Metric['name'],
+ Protocol.Performance.Metric['value']
+ > = {};
+ for (const metric of metrics || []) {
+ if (supportedMetrics.has(metric.name)) {
+ result[metric.name] = metric.value;
+ }
+ }
+ return result;
+ }
+
+ #handleException(exceptionDetails: Protocol.Runtime.ExceptionDetails): void {
+ const message = getExceptionMessage(exceptionDetails);
+ const err = new Error(message);
+ err.stack = ''; // Don't report clientside error with a node stack attached
+ this.emit(PageEmittedEvents.PageError, err);
+ }
+
+ 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,
+ this.#client
+ );
+ const values = event.args.map(arg => {
+ return createJSHandle(context, arg);
+ });
+ this.#addConsoleMessage(event.type, values, event.stackTrace);
+ }
+
+ 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 pageBinding = this.#pageBindings.get(name);
+ assert(pageBinding);
+ const result = await pageBinding(...args);
+ expression = pageBindingDeliverResultString(name, seq, result);
+ } catch (error) {
+ if (isErrorLike(error)) {
+ expression = pageBindingDeliverErrorString(
+ name,
+ seq,
+ error.message,
+ error.stack
+ );
+ } else {
+ expression = pageBindingDeliverErrorValueString(name, seq, error);
+ }
+ }
+ this.#client
+ .send('Runtime.evaluate', {
+ expression,
+ contextId: event.executionContextId,
+ })
+ .catch(debugError);
+ }
+
+ #addConsoleMessage(
+ eventType: ConsoleMessageType,
+ args: JSHandle[],
+ stackTrace?: Protocol.Runtime.StackTrace
+ ): void {
+ if (!this.listenerCount(PageEmittedEvents.Console)) {
+ args.forEach(arg => {
+ return arg.dispose();
+ });
+ return;
+ }
+ const textTokens = [];
+ for (const arg of args) {
+ const remoteObject = arg.remoteObject();
+ if (remoteObject.objectId) {
+ textTokens.push(arg.toString());
+ } else {
+ textTokens.push(valueFromRemoteObject(remoteObject));
+ }
+ }
+ const stackTraceLocations = [];
+ if (stackTrace) {
+ for (const callFrame of stackTrace.callFrames) {
+ stackTraceLocations.push({
+ url: callFrame.url,
+ lineNumber: callFrame.lineNumber,
+ columnNumber: callFrame.columnNumber,
+ });
+ }
+ }
+ const message = new ConsoleMessage(
+ eventType,
+ textTokens.join(' '),
+ args,
+ stackTraceLocations
+ );
+ this.emit(PageEmittedEvents.Console, message);
+ }
+
+ #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);
+ }
+
+ /**
+ * Resets default white background
+ */
+ async #resetDefaultBackgroundColor() {
+ await this.#client.send('Emulation.setDefaultBackgroundColorOverride');
+ }
+
+ /**
+ * Hides default white background
+ */
+ async #setTransparentBackgroundColor(): Promise<void> {
+ await this.#client.send('Emulation.setDefaultBackgroundColorOverride', {
+ color: {r: 0, g: 0, b: 0, a: 0},
+ });
+ }
+
+ /**
+ *
+ * @returns
+ * @remarks Shortcut for
+ * {@link Frame.url | page.mainFrame().url()}.
+ */
+ url(): string {
+ return this.mainFrame().url();
+ }
+
+ async content(): Promise<string> {
+ return await this.#frameManager.mainFrame().content();
+ }
+
+ /**
+ * @param html - HTML markup to assign to the page.
+ * @param options - Parameters that has some properties.
+ * @remarks
+ * The parameter `options` might have the following options.
+ *
+ * - `timeout` : Maximum time in milliseconds for resources to load, defaults
+ * to 30 seconds, pass `0` to disable timeout. The default value can be
+ * changed by using the {@link Page.setDefaultNavigationTimeout} or
+ * {@link Page.setDefaultTimeout} methods.
+ *
+ * - `waitUntil`: When to consider setting markup succeeded, defaults to
+ * `load`. Given an array of event strings, setting content is considered
+ * to be successful after all events have been fired. Events can be
+ * either:<br/>
+ * - `load` : consider setting content to be finished when the `load` event
+ * is fired.<br/>
+ * - `domcontentloaded` : consider setting content to be finished when the
+ * `DOMContentLoaded` event is fired.<br/>
+ * - `networkidle0` : consider setting content to be finished when there are
+ * no more than 0 network connections for at least `500` ms.<br/>
+ * - `networkidle2` : consider setting content to be finished when there are
+ * no more than 2 network connections for at least `500` ms.
+ */
+ async setContent(html: string, options: WaitForOptions = {}): Promise<void> {
+ await this.#frameManager.mainFrame().setContent(html, options);
+ }
+
+ /**
+ * @param url - URL to navigate page to. The URL should include scheme, e.g.
+ * `https://`
+ * @param options - Navigation Parameter
+ * @returns Promise which resolves to the main resource response. In case of
+ * multiple redirects, the navigation will resolve with the response of the
+ * last redirect.
+ * @remarks
+ * The argument `options` might have the following properties:
+ *
+ * - `timeout` : Maximum navigation time in milliseconds, defaults to 30
+ * seconds, pass 0 to disable timeout. The default value can be changed by
+ * using the {@link Page.setDefaultNavigationTimeout} or
+ * {@link Page.setDefaultTimeout} methods.
+ *
+ * - `waitUntil`:When to consider navigation succeeded, defaults to `load`.
+ * Given an array of event strings, navigation is considered to be
+ * successful after all events have been fired. Events can be either:<br/>
+ * - `load` : consider navigation to be finished when the load event is
+ * fired.<br/>
+ * - `domcontentloaded` : consider navigation to be finished when the
+ * DOMContentLoaded event is fired.<br/>
+ * - `networkidle0` : consider navigation to be finished when there are no
+ * more than 0 network connections for at least `500` ms.<br/>
+ * - `networkidle2` : consider navigation to be finished when there are no
+ * more than 2 network connections for at least `500` ms.
+ *
+ * - `referer` : Referer header value. If provided it will take preference
+ * over the referer header value set by
+ * {@link Page.setExtraHTTPHeaders |page.setExtraHTTPHeaders()}.
+ *
+ * `page.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.
+ *
+ * `page.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 response.status().
+ *
+ * NOTE: `page.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}.
+ *
+ * Shortcut for {@link Frame.goto | page.mainFrame().goto(url, options)}.
+ */
+ async goto(
+ url: string,
+ options: WaitForOptions & {referer?: string} = {}
+ ): Promise<HTTPResponse | null> {
+ return await this.#frameManager.mainFrame().goto(url, options);
+ }
+
+ /**
+ * @param options - Navigation parameters which might have the following
+ * properties:
+ * @returns Promise which resolves to the main resource response. In case of
+ * multiple redirects, the navigation will resolve with the response of the
+ * last redirect.
+ * @remarks
+ * The argument `options` might have the following properties:
+ *
+ * - `timeout` : Maximum navigation time in milliseconds, defaults to 30
+ * seconds, pass 0 to disable timeout. The default value can be changed by
+ * using the {@link Page.setDefaultNavigationTimeout} or
+ * {@link Page.setDefaultTimeout} methods.
+ *
+ * - `waitUntil`: When to consider navigation succeeded, defaults to `load`.
+ * Given an array of event strings, navigation is considered to be
+ * successful after all events have been fired. Events can be either:<br/>
+ * - `load` : consider navigation to be finished when the load event is
+ * fired.<br/>
+ * - `domcontentloaded` : consider navigation to be finished when the
+ * DOMContentLoaded event is fired.<br/>
+ * - `networkidle0` : consider navigation to be finished when there are no
+ * more than 0 network connections for at least `500` ms.<br/>
+ * - `networkidle2` : consider navigation to be finished when there are no
+ * more than 2 network connections for at least `500` ms.
+ */
+ async reload(options?: WaitForOptions): Promise<HTTPResponse | null> {
+ const result = await Promise.all([
+ this.waitForNavigation(options),
+ this.#client.send('Page.reload'),
+ ]);
+
+ return result[0];
+ }
+
+ /**
+ * Waits for the page to navigate to a new URL or to reload. It is useful when
+ * you run code that will indirectly cause the page to navigate.
+ *
+ * @example
+ *
+ * ```ts
+ * const [response] = await Promise.all([
+ * page.waitForNavigation(), // The promise resolves after navigation has finished
+ * page.click('a.my-link'), // Clicking the link will indirectly cause a navigation
+ * ]);
+ * ```
+ *
+ * @remarks
+ * Usage of the
+ * {@link https://developer.mozilla.org/en-US/docs/Web/API/History_API | History API}
+ * to change the URL is considered a navigation.
+ *
+ * @param options - Navigation parameters which might have the following
+ * properties:
+ * @returns A `Promise` which resolves to the main resource response.
+ *
+ * - In case of multiple redirects, the navigation will resolve with the
+ * response of the last redirect.
+ * - In case of navigation to a different anchor or navigation due to History
+ * API usage, the navigation will resolve with `null`.
+ */
+ async waitForNavigation(
+ options: WaitForOptions = {}
+ ): Promise<HTTPResponse | null> {
+ return await this.#frameManager.mainFrame().waitForNavigation(options);
+ }
+
+ #sessionClosePromise(): Promise<Error> {
+ if (!this.#disconnectPromise) {
+ this.#disconnectPromise = new Promise(fulfill => {
+ return this.#client.once(CDPSessionEmittedEvents.Disconnected, () => {
+ return fulfill(new Error('Target closed'));
+ });
+ });
+ }
+ return this.#disconnectPromise;
+ }
+
+ /**
+ * @param urlOrPredicate - A URL or predicate to wait for
+ * @param options - Optional waiting parameters
+ * @returns Promise which resolves to the matched response
+ * @example
+ *
+ * ```ts
+ * const firstResponse = await page.waitForResponse(
+ * 'https://example.com/resource'
+ * );
+ * const finalResponse = await page.waitForResponse(
+ * response =>
+ * response.url() === 'https://example.com' && response.status() === 200
+ * );
+ * const finalResponse = await page.waitForResponse(async response => {
+ * return (await response.text()).includes('<html>');
+ * });
+ * return finalResponse.ok();
+ * ```
+ *
+ * @remarks
+ * Optional Waiting Parameters have:
+ *
+ * - `timeout`: Maximum wait time in milliseconds, defaults to `30` seconds, pass
+ * `0` to disable the timeout. The default value can be changed by using the
+ * {@link Page.setDefaultTimeout} method.
+ */
+ async waitForRequest(
+ urlOrPredicate: string | ((req: HTTPRequest) => boolean | Promise<boolean>),
+ options: {timeout?: number} = {}
+ ): Promise<HTTPRequest> {
+ const {timeout = this.#timeoutSettings.timeout()} = options;
+ return waitForEvent(
+ this.#frameManager.networkManager,
+ NetworkManagerEmittedEvents.Request,
+ request => {
+ if (isString(urlOrPredicate)) {
+ return urlOrPredicate === request.url();
+ }
+ if (typeof urlOrPredicate === 'function') {
+ return !!urlOrPredicate(request);
+ }
+ return false;
+ },
+ timeout,
+ this.#sessionClosePromise()
+ );
+ }
+
+ /**
+ * @param urlOrPredicate - A URL or predicate to wait for.
+ * @param options - Optional waiting parameters
+ * @returns Promise which resolves to the matched response.
+ * @example
+ *
+ * ```ts
+ * const firstResponse = await page.waitForResponse(
+ * 'https://example.com/resource'
+ * );
+ * const finalResponse = await page.waitForResponse(
+ * response =>
+ * response.url() === 'https://example.com' && response.status() === 200
+ * );
+ * const finalResponse = await page.waitForResponse(async response => {
+ * return (await response.text()).includes('<html>');
+ * });
+ * return finalResponse.ok();
+ * ```
+ *
+ * @remarks
+ * Optional Parameter have:
+ *
+ * - `timeout`: Maximum wait time in milliseconds, defaults to `30` seconds,
+ * pass `0` to disable the timeout. The default value can be changed by using
+ * the {@link Page.setDefaultTimeout} method.
+ */
+ async waitForResponse(
+ urlOrPredicate:
+ | string
+ | ((res: HTTPResponse) => boolean | Promise<boolean>),
+ options: {timeout?: number} = {}
+ ): Promise<HTTPResponse> {
+ const {timeout = this.#timeoutSettings.timeout()} = options;
+ return waitForEvent(
+ this.#frameManager.networkManager,
+ NetworkManagerEmittedEvents.Response,
+ async response => {
+ if (isString(urlOrPredicate)) {
+ return urlOrPredicate === response.url();
+ }
+ if (typeof urlOrPredicate === 'function') {
+ return !!(await urlOrPredicate(response));
+ }
+ return false;
+ },
+ timeout,
+ this.#sessionClosePromise()
+ );
+ }
+
+ /**
+ * @param options - Optional waiting parameters
+ * @returns Promise which resolves when network is idle
+ */
+ async waitForNetworkIdle(
+ options: {idleTime?: number; timeout?: number} = {}
+ ): Promise<void> {
+ const {idleTime = 500, timeout = this.#timeoutSettings.timeout()} = options;
+
+ const networkManager = this.#frameManager.networkManager;
+
+ let idleResolveCallback: () => void;
+ const idlePromise = new Promise<void>(resolve => {
+ idleResolveCallback = resolve;
+ });
+
+ let abortRejectCallback: (error: Error) => void;
+ const abortPromise = new Promise<Error>((_, reject) => {
+ abortRejectCallback = reject;
+ });
+
+ let idleTimer: NodeJS.Timeout;
+ const onIdle = () => {
+ return idleResolveCallback();
+ };
+
+ const cleanup = () => {
+ idleTimer && clearTimeout(idleTimer);
+ abortRejectCallback(new Error('abort'));
+ };
+
+ const evaluate = () => {
+ idleTimer && clearTimeout(idleTimer);
+ if (networkManager.numRequestsInProgress() === 0) {
+ idleTimer = setTimeout(onIdle, idleTime);
+ }
+ };
+
+ evaluate();
+
+ const eventHandler = () => {
+ evaluate();
+ return false;
+ };
+
+ const listenToEvent = (event: symbol) => {
+ return waitForEvent(
+ networkManager,
+ event,
+ eventHandler,
+ timeout,
+ abortPromise
+ );
+ };
+
+ const eventPromises = [
+ listenToEvent(NetworkManagerEmittedEvents.Request),
+ listenToEvent(NetworkManagerEmittedEvents.Response),
+ ];
+
+ await Promise.race([
+ idlePromise,
+ ...eventPromises,
+ this.#sessionClosePromise(),
+ ]).then(
+ r => {
+ cleanup();
+ return r;
+ },
+ error => {
+ cleanup();
+ throw error;
+ }
+ );
+ }
+
+ /**
+ * @param urlOrPredicate - A URL or predicate to wait for.
+ * @param options - Optional waiting parameters
+ * @returns Promise which resolves to the matched frame.
+ * @example
+ *
+ * ```ts
+ * const frame = await page.waitForFrame(async frame => {
+ * return frame.name() === 'Test';
+ * });
+ * ```
+ *
+ * @remarks
+ * Optional Parameter have:
+ *
+ * - `timeout`: Maximum wait time in milliseconds, defaults to `30` seconds,
+ * pass `0` to disable the timeout. The default value can be changed by using
+ * the {@link Page.setDefaultTimeout} method.
+ */
+ async waitForFrame(
+ urlOrPredicate: string | ((frame: Frame) => boolean | Promise<boolean>),
+ options: {timeout?: number} = {}
+ ): Promise<Frame> {
+ const {timeout = this.#timeoutSettings.timeout()} = options;
+
+ let predicate: (frame: Frame) => Promise<boolean>;
+ if (isString(urlOrPredicate)) {
+ predicate = (frame: Frame) => {
+ return Promise.resolve(urlOrPredicate === frame.url());
+ };
+ } else {
+ predicate = (frame: Frame) => {
+ const value = urlOrPredicate(frame);
+ if (typeof value === 'boolean') {
+ return Promise.resolve(value);
+ }
+ return value;
+ };
+ }
+
+ const eventRace: Promise<Frame> = Promise.race([
+ waitForEvent(
+ this.#frameManager,
+ FrameManagerEmittedEvents.FrameAttached,
+ predicate,
+ timeout,
+ this.#sessionClosePromise()
+ ),
+ waitForEvent(
+ this.#frameManager,
+ FrameManagerEmittedEvents.FrameNavigated,
+ predicate,
+ timeout,
+ this.#sessionClosePromise()
+ ),
+ ...this.frames().map(async frame => {
+ if (await predicate(frame)) {
+ return frame;
+ }
+ return await eventRace;
+ }),
+ ]);
+
+ return eventRace;
+ }
+
+ /**
+ * This method navigate to the previous page in history.
+ * @param options - Navigation parameters
+ * @returns Promise which resolves to the main resource response. In case of
+ * multiple redirects, the navigation will resolve with the response of the
+ * last redirect. If can not go back, resolves to `null`.
+ * @remarks
+ * The argument `options` might have the following properties:
+ *
+ * - `timeout` : Maximum navigation time in milliseconds, defaults to 30
+ * seconds, pass 0 to disable timeout. The default value can be changed by
+ * using the {@link Page.setDefaultNavigationTimeout} or
+ * {@link Page.setDefaultTimeout} methods.
+ *
+ * - `waitUntil` : When to consider navigation succeeded, defaults to `load`.
+ * Given an array of event strings, navigation is considered to be
+ * successful after all events have been fired. Events can be either:<br/>
+ * - `load` : consider navigation to be finished when the load event is
+ * fired.<br/>
+ * - `domcontentloaded` : consider navigation to be finished when the
+ * DOMContentLoaded event is fired.<br/>
+ * - `networkidle0` : consider navigation to be finished when there are no
+ * more than 0 network connections for at least `500` ms.<br/>
+ * - `networkidle2` : consider navigation to be finished when there are no
+ * more than 2 network connections for at least `500` ms.
+ */
+ async goBack(options: WaitForOptions = {}): Promise<HTTPResponse | null> {
+ return this.#go(-1, options);
+ }
+
+ /**
+ * This method navigate to the next page in history.
+ * @param options - Navigation Parameter
+ * @returns Promise which resolves to the main resource response. In case of
+ * multiple redirects, the navigation will resolve with the response of the
+ * last redirect. If can not go forward, resolves to `null`.
+ * @remarks
+ * The argument `options` might have the following properties:
+ *
+ * - `timeout` : Maximum navigation time in milliseconds, defaults to 30
+ * seconds, pass 0 to disable timeout. The default value can be changed by
+ * using the {@link Page.setDefaultNavigationTimeout} or
+ * {@link Page.setDefaultTimeout} methods.
+ *
+ * - `waitUntil`: When to consider navigation succeeded, defaults to `load`.
+ * Given an array of event strings, navigation is considered to be
+ * successful after all events have been fired. Events can be either:<br/>
+ * - `load` : consider navigation to be finished when the load event is
+ * fired.<br/>
+ * - `domcontentloaded` : consider navigation to be finished when the
+ * DOMContentLoaded event is fired.<br/>
+ * - `networkidle0` : consider navigation to be finished when there are no
+ * more than 0 network connections for at least `500` ms.<br/>
+ * - `networkidle2` : consider navigation to be finished when there are no
+ * more than 2 network connections for at least `500` ms.
+ */
+ async goForward(options: WaitForOptions = {}): Promise<HTTPResponse | null> {
+ return this.#go(+1, options);
+ }
+
+ 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];
+ }
+
+ /**
+ * Brings page to front (activates tab).
+ */
+ async bringToFront(): Promise<void> {
+ await this.#client.send('Page.bringToFront');
+ }
+
+ /**
+ * Emulates given device metrics and user agent.
+ *
+ * @remarks
+ * This method is a shortcut for calling two methods:
+ * {@link Page.setUserAgent} and {@link Page.setViewport} To aid emulation,
+ * Puppeteer provides a list of device descriptors that can be obtained via
+ * {@link devices}. `page.emulate` will resize the page. A lot of websites
+ * don't expect phones to change size, so you should emulate before navigating
+ * to the page.
+ * @example
+ *
+ * ```ts
+ * 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();
+ * })();
+ * ```
+ *
+ * @remarks List of all available devices is available in the source code:
+ * {@link https://github.com/puppeteer/puppeteer/blob/main/src/common/DeviceDescriptors.ts | src/common/DeviceDescriptors.ts}.
+ */
+ async emulate(options: {
+ viewport: Viewport;
+ userAgent: string;
+ }): Promise<void> {
+ await Promise.all([
+ this.setViewport(options.viewport),
+ this.setUserAgent(options.userAgent),
+ ]);
+ }
+
+ /**
+ * @param enabled - Whether or not to enable JavaScript on the page.
+ * @returns
+ * @remarks
+ * NOTE: changing this value won't affect scripts that have already been run.
+ * It will take full effect on the next navigation.
+ */
+ async setJavaScriptEnabled(enabled: boolean): Promise<void> {
+ if (this.#javascriptEnabled === enabled) {
+ return;
+ }
+ this.#javascriptEnabled = enabled;
+ await this.#client.send('Emulation.setScriptExecutionDisabled', {
+ value: !enabled,
+ });
+ }
+
+ /**
+ * Toggles bypassing page's Content-Security-Policy.
+ * @param enabled - sets bypassing of page's Content-Security-Policy.
+ * @remarks
+ * NOTE: CSP bypassing happens at the moment of CSP initialization rather than
+ * evaluation. Usually, this means that `page.setBypassCSP` should be called
+ * before navigating to the domain.
+ */
+ async setBypassCSP(enabled: boolean): Promise<void> {
+ await this.#client.send('Page.setBypassCSP', {enabled});
+ }
+
+ /**
+ * @param type - Changes the CSS media type of the page. The only allowed
+ * values are `screen`, `print` and `null`. Passing `null` disables CSS media
+ * emulation.
+ * @example
+ *
+ * ```ts
+ * await page.evaluate(() => matchMedia('screen').matches);
+ * // → true
+ * await page.evaluate(() => matchMedia('print').matches);
+ * // → false
+ *
+ * await page.emulateMediaType('print');
+ * await page.evaluate(() => matchMedia('screen').matches);
+ * // → false
+ * await page.evaluate(() => matchMedia('print').matches);
+ * // → true
+ *
+ * await page.emulateMediaType(null);
+ * await page.evaluate(() => matchMedia('screen').matches);
+ * // → true
+ * await page.evaluate(() => matchMedia('print').matches);
+ * // → false
+ * ```
+ */
+ async emulateMediaType(type?: string): Promise<void> {
+ assert(
+ type === 'screen' ||
+ type === 'print' ||
+ (type ?? undefined) === undefined,
+ 'Unsupported media type: ' + type
+ );
+ await this.#client.send('Emulation.setEmulatedMedia', {
+ media: type || '',
+ });
+ }
+
+ /**
+ * Enables CPU throttling to emulate slow CPUs.
+ * @param factor - slowdown factor (1 is no throttle, 2 is 2x slowdown, etc).
+ */
+ async emulateCPUThrottling(factor: number | null): Promise<void> {
+ assert(
+ factor === null || factor >= 1,
+ 'Throttling rate should be greater or equal to 1'
+ );
+ await this.#client.send('Emulation.setCPUThrottlingRate', {
+ rate: factor !== null ? factor : 1,
+ });
+ }
+
+ /**
+ * @param features - `<?Array<Object>>` Given an array of media feature
+ * objects, emulates CSS media features on the page. Each media feature object
+ * must have the following properties:
+ * @example
+ *
+ * ```ts
+ * await page.emulateMediaFeatures([
+ * {name: 'prefers-color-scheme', value: 'dark'},
+ * ]);
+ * await page.evaluate(
+ * () => matchMedia('(prefers-color-scheme: dark)').matches
+ * );
+ * // → true
+ * await page.evaluate(
+ * () => matchMedia('(prefers-color-scheme: light)').matches
+ * );
+ * // → false
+ *
+ * await page.emulateMediaFeatures([
+ * {name: 'prefers-reduced-motion', value: 'reduce'},
+ * ]);
+ * await page.evaluate(
+ * () => matchMedia('(prefers-reduced-motion: reduce)').matches
+ * );
+ * // → true
+ * await page.evaluate(
+ * () => matchMedia('(prefers-reduced-motion: no-preference)').matches
+ * );
+ * // → false
+ *
+ * await page.emulateMediaFeatures([
+ * {name: 'prefers-color-scheme', value: 'dark'},
+ * {name: 'prefers-reduced-motion', value: 'reduce'},
+ * ]);
+ * await page.evaluate(
+ * () => matchMedia('(prefers-color-scheme: dark)').matches
+ * );
+ * // → true
+ * await page.evaluate(
+ * () => matchMedia('(prefers-color-scheme: light)').matches
+ * );
+ * // → false
+ * await page.evaluate(
+ * () => matchMedia('(prefers-reduced-motion: reduce)').matches
+ * );
+ * // → true
+ * await page.evaluate(
+ * () => matchMedia('(prefers-reduced-motion: no-preference)').matches
+ * );
+ * // → false
+ *
+ * await page.emulateMediaFeatures([{name: 'color-gamut', value: 'p3'}]);
+ * await page.evaluate(() => matchMedia('(color-gamut: srgb)').matches);
+ * // → true
+ * await page.evaluate(() => matchMedia('(color-gamut: p3)').matches);
+ * // → true
+ * await page.evaluate(() => matchMedia('(color-gamut: rec2020)').matches);
+ * // → false
+ * ```
+ */
+ async emulateMediaFeatures(features?: MediaFeature[]): Promise<void> {
+ if (!features) {
+ await this.#client.send('Emulation.setEmulatedMedia', {});
+ }
+ if (Array.isArray(features)) {
+ for (const mediaFeature of features) {
+ const name = mediaFeature.name;
+ assert(
+ /^(?:prefers-(?:color-scheme|reduced-motion)|color-gamut)$/.test(
+ name
+ ),
+ 'Unsupported media feature: ' + name
+ );
+ }
+ await this.#client.send('Emulation.setEmulatedMedia', {
+ features: features,
+ });
+ }
+ }
+
+ /**
+ * @param timezoneId - Changes the timezone of the page. See
+ * {@link https://source.chromium.org/chromium/chromium/deps/icu.git/+/faee8bc70570192d82d2978a71e2a615788597d1:source/data/misc/metaZones.txt | ICU’s metaZones.txt}
+ * for a list of supported timezone IDs. Passing
+ * `null` disables timezone emulation.
+ */
+ async emulateTimezone(timezoneId?: string): Promise<void> {
+ try {
+ await this.#client.send('Emulation.setTimezoneOverride', {
+ timezoneId: timezoneId || '',
+ });
+ } catch (error) {
+ if (isErrorLike(error) && 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
+ *
+ * ```ts
+ * // set idle emulation
+ * await page.emulateIdleState({isUserActive: true, isScreenUnlocked: false});
+ *
+ * // do some checks here
+ * ...
+ *
+ * // clear idle emulation
+ * await page.emulateIdleState();
+ * ```
+ *
+ * @param overrides - Mock idle state. If not set, clears idle overrides
+ */
+ 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
+ *
+ * ```ts
+ * 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;
+ }
+ }
+
+ /**
+ * `page.setViewport` will resize the page. A lot of websites don't expect
+ * phones to change size, so you should set the viewport before navigating to
+ * the page.
+ *
+ * In the case of multiple pages in a single browser, each page can have its
+ * own viewport size.
+ * @example
+ *
+ * ```ts
+ * const page = await browser.newPage();
+ * await page.setViewport({
+ * width: 640,
+ * height: 480,
+ * deviceScaleFactor: 1,
+ * });
+ * await page.goto('https://example.com');
+ * ```
+ *
+ * @param viewport -
+ * @remarks
+ * Argument viewport have following properties:
+ *
+ * - `width`: page width in pixels. required
+ *
+ * - `height`: page height in pixels. required
+ *
+ * - `deviceScaleFactor`: Specify device scale factor (can be thought of as
+ * DPR). Defaults to `1`.
+ *
+ * - `isMobile`: Whether the meta viewport tag is taken into account. Defaults
+ * to `false`.
+ *
+ * - `hasTouch`: Specifies if viewport supports touch events. Defaults to `false`
+ *
+ * - `isLandScape`: Specifies if viewport is in landscape mode. Defaults to false.
+ *
+ * NOTE: in certain cases, setting viewport will reload the page in order to
+ * set the isMobile or hasTouch properties.
+ */
+ async setViewport(viewport: Viewport): Promise<void> {
+ const needsReload = await this.#emulationManager.emulateViewport(viewport);
+ this.#viewport = viewport;
+ if (needsReload) {
+ await this.reload();
+ }
+ }
+
+ /**
+ * @returns
+ *
+ * - `width`: page's width in pixels
+ *
+ * - `height`: page's height in pixels
+ *
+ * - `deviceScalarFactor`: Specify device scale factor (can be though of as
+ * dpr). Defaults to `1`.
+ *
+ * - `isMobile`: Whether the meta viewport tag is taken into account. Defaults
+ * to `false`.
+ *
+ * - `hasTouch`: Specifies if viewport supports touch events. Defaults to
+ * `false`.
+ *
+ * - `isLandScape`: Specifies if viewport is in landscape mode. Defaults to
+ * `false`.
+ */
+ viewport(): Viewport | null {
+ return this.#viewport;
+ }
+
+ /**
+ * 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
+ *
+ * ```ts
+ * const result = await frame.evaluate(() => {
+ * return Promise.resolve(8 * 7);
+ * });
+ * console.log(result); // prints "56"
+ * ```
+ *
+ * You can pass a string instead of a function (although functions are
+ * recommended as they are easier to debug and use with TypeScript):
+ *
+ * @example
+ *
+ * ```ts
+ * const aHandle = await page.evaluate('1 + 2');
+ * ```
+ *
+ * To get the best TypeScript experience, you should pass in as the
+ * generic the type of `pageFunction`:
+ *
+ * ```ts
+ * const aHandle = await page.evaluate(() => 2);
+ * ```
+ *
+ * @example
+ *
+ * {@link ElementHandle} instances (including {@link JSHandle}s) can be passed
+ * as arguments to the `pageFunction`:
+ *
+ * ```ts
+ * const bodyHandle = await page.$('body');
+ * const html = await page.evaluate(body => body.innerHTML, bodyHandle);
+ * await bodyHandle.dispose();
+ * ```
+ *
+ * @param pageFunction - a function that is run within the page
+ * @param args - arguments to be passed to the pageFunction
+ *
+ * @returns the return value of `pageFunction`.
+ */
+ async evaluate<
+ Params extends unknown[],
+ Func extends EvaluateFunc<Params> = EvaluateFunc<Params>
+ >(
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<Awaited<ReturnType<Func>>> {
+ return this.#frameManager.mainFrame().evaluate(pageFunction, ...args);
+ }
+
+ /**
+ * Adds a function which would be invoked in one of the following scenarios:
+ *
+ * - whenever the page is navigated
+ *
+ * - whenever the child frame is attached or navigated. In this case, the
+ * function is invoked in the context of the newly attached frame.
+ *
+ * The function is invoked after the document was created but before any of
+ * its scripts were run. This is useful to amend the JavaScript environment,
+ * e.g. to seed `Math.random`.
+ * @param pageFunction - Function to be evaluated in browser context
+ * @param args - Arguments to pass to `pageFunction`
+ * @example
+ * An example of overriding the navigator.languages property before the page loads:
+ *
+ * ```ts
+ * // preload.js
+ *
+ * // overwrite the `languages` property to use a custom getter
+ * Object.defineProperty(navigator, 'languages', {
+ * get: function () {
+ * return ['en-US', 'en', 'bn'];
+ * },
+ * });
+ *
+ * // In your puppeteer script, assuming the preload.js file is
+ * // in same folder of our script.
+ * const preloadFile = fs.readFileSync('./preload.js', 'utf8');
+ * await page.evaluateOnNewDocument(preloadFile);
+ * ```
+ */
+ async evaluateOnNewDocument<
+ Params extends unknown[],
+ Func extends (...args: Params) => unknown = (...args: Params) => unknown
+ >(pageFunction: Func | string, ...args: Params): Promise<void> {
+ const source = evaluationString(pageFunction, ...args);
+ await this.#client.send('Page.addScriptToEvaluateOnNewDocument', {
+ source,
+ });
+ }
+
+ /**
+ * Toggles ignoring cache for each request based on the enabled state. By
+ * default, caching is enabled.
+ * @param enabled - sets the `enabled` state of cache
+ */
+ async setCacheEnabled(enabled = true): Promise<void> {
+ await this.#frameManager.networkManager.setCacheEnabled(enabled);
+ }
+
+ /**
+ * @remarks
+ * Options object which might have the following properties:
+ *
+ * - `path` : The file path to save the image to. The screenshot type
+ * will be inferred from file extension. If `path` is a relative path, then
+ * it is resolved relative to
+ * {@link https://nodejs.org/api/process.html#process_process_cwd
+ * | current working directory}.
+ * If no path is provided, the image won't be saved to the disk.
+ *
+ * - `type` : Specify screenshot type, can be either `jpeg` or `png`.
+ * Defaults to 'png'.
+ *
+ * - `quality` : The quality of the image, between 0-100. Not
+ * applicable to `png` images.
+ *
+ * - `fullPage` : When true, takes a screenshot of the full
+ * scrollable page. Defaults to `false`.
+ *
+ * - `clip` : An object which specifies clipping region of the page.
+ * Should have the following fields:<br/>
+ * - `x` : x-coordinate of top-left corner of clip area.<br/>
+ * - `y` : y-coordinate of top-left corner of clip area.<br/>
+ * - `width` : width of clipping area.<br/>
+ * - `height` : height of clipping area.
+ *
+ * - `omitBackground` : Hides default white background and allows
+ * capturing screenshots with transparency. Defaults to `false`.
+ *
+ * - `encoding` : The encoding of the image, can be either base64 or
+ * binary. Defaults to `binary`.
+ *
+ * - `captureBeyondViewport` : When true, captures screenshot
+ * {@link https://chromedevtools.github.io/devtools-protocol/tot/Page/#method-captureScreenshot
+ * | beyond the viewport}. When false, falls back to old behaviour,
+ * and cuts the screenshot by the viewport size. Defaults to `true`.
+ *
+ * - `fromSurface` : When true, captures screenshot
+ * {@link https://chromedevtools.github.io/devtools-protocol/tot/Page/#method-captureScreenshot
+ * | from the surface rather than the view}. When false, works only in
+ * headful mode and ignores page viewport (but not browser window's
+ * bounds). Defaults to `true`.
+ *
+ * NOTE: Screenshots take at least 1/6 second on OS X. See
+ * {@link https://crbug.com/741689} for discussion.
+ * @returns Promise which resolves to buffer or a base64 string (depending on
+ * the value of `encoding`) with captured screenshot.
+ */
+ async screenshot(options: ScreenshotOptions = {}): Promise<Buffer | string> {
+ let screenshotType = Protocol.Page.CaptureScreenshotRequestFormat.Png;
+ // 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) {
+ screenshotType =
+ options.type as Protocol.Page.CaptureScreenshotRequestFormat;
+ } else if (options.path) {
+ const filePath = options.path;
+ const extension = filePath
+ .slice(filePath.lastIndexOf('.') + 1)
+ .toLowerCase();
+ switch (extension) {
+ case 'png':
+ screenshotType = Protocol.Page.CaptureScreenshotRequestFormat.Png;
+ break;
+ case 'jpeg':
+ case 'jpg':
+ screenshotType = Protocol.Page.CaptureScreenshotRequestFormat.Jpeg;
+ break;
+ case 'webp':
+ screenshotType = Protocol.Page.CaptureScreenshotRequestFormat.Webp;
+ break;
+ default:
+ throw new Error(
+ `Unsupported screenshot type for extension \`.${extension}\``
+ );
+ }
+ }
+
+ if (options.quality) {
+ assert(
+ screenshotType === Protocol.Page.CaptureScreenshotRequestFormat.Jpeg ||
+ screenshotType === Protocol.Page.CaptureScreenshotRequestFormat.Webp,
+ '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(() => {
+ return this.#screenshotTask(screenshotType, options);
+ });
+ }
+
+ async #screenshotTask(
+ format: Protocol.Page.CaptureScreenshotRequestFormat,
+ options: ScreenshotOptions = {}
+ ): Promise<Buffer | string> {
+ await this.#client.send('Target.activateTarget', {
+ targetId: this.#target._targetId,
+ });
+ let clip = options.clip ? processClip(options.clip) : undefined;
+ const captureBeyondViewport =
+ typeof options.captureBeyondViewport === 'boolean'
+ ? options.captureBeyondViewport
+ : true;
+ const fromSurface =
+ typeof options.fromSurface === 'boolean'
+ ? options.fromSurface
+ : undefined;
+
+ if (options.fullPage) {
+ const metrics = await this.#client.send('Page.getLayoutMetrics');
+ // Fallback to `contentSize` in case of using Firefox.
+ const {width, height} = metrics.cssContentSize || metrics.contentSize;
+
+ // Overwrite clip for full page.
+ clip = {x: 0, y: 0, width, height, scale: 1};
+
+ if (!captureBeyondViewport) {
+ 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' || format === 'webp');
+ if (shouldSetDefaultBackground) {
+ await this.#setTransparentBackgroundColor();
+ }
+
+ const result = await this.#client.send('Page.captureScreenshot', {
+ format,
+ quality: options.quality,
+ clip: clip
+ ? {
+ ...clip,
+ scale: clip.scale === undefined ? 1 : clip.scale,
+ }
+ : undefined,
+ captureBeyondViewport,
+ fromSurface,
+ });
+ if (shouldSetDefaultBackground) {
+ await this.#resetDefaultBackgroundColor();
+ }
+
+ if (options.fullPage && this.#viewport) {
+ await this.setViewport(this.#viewport);
+ }
+
+ const buffer =
+ options.encoding === 'base64'
+ ? result.data
+ : Buffer.from(result.data, 'base64');
+
+ if (options.path) {
+ try {
+ const fs = (await importFS()).promises;
+ await fs.writeFile(options.path, buffer);
+ } catch (error) {
+ if (error instanceof TypeError) {
+ throw new Error(
+ 'Screenshots can only be written to a file path in a Node-like environment.'
+ );
+ }
+ throw error;
+ }
+ }
+ return buffer;
+
+ function processClip(clip: ScreenshotClip): ScreenshotClip {
+ 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: clip.scale};
+ }
+ }
+
+ /**
+ * Generates a PDF of the page with the `print` CSS media type.
+ * @remarks
+ *
+ * NOTE: 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 createPDFStream(options: PDFOptions = {}): Promise<Readable> {
+ const {
+ scale = 1,
+ displayHeaderFooter = false,
+ headerTemplate = '',
+ footerTemplate = '',
+ printBackground = false,
+ landscape = false,
+ pageRanges = '',
+ preferCSSPageSize = false,
+ margin = {},
+ omitBackground = false,
+ timeout = 30000,
+ } = options;
+
+ let paperWidth = 8.5;
+ let paperHeight = 11;
+ if (options.format) {
+ const format =
+ _paperFormats[options.format.toLowerCase() as LowerCasePaperFormat];
+ 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;
+
+ if (omitBackground) {
+ await this.#setTransparentBackgroundColor();
+ }
+
+ const printCommandPromise = this.#client.send('Page.printToPDF', {
+ transferMode: 'ReturnAsStream',
+ landscape,
+ displayHeaderFooter,
+ headerTemplate,
+ footerTemplate,
+ printBackground,
+ scale,
+ paperWidth,
+ paperHeight,
+ marginTop,
+ marginBottom,
+ marginLeft,
+ marginRight,
+ pageRanges,
+ preferCSSPageSize,
+ });
+
+ const result = await waitWithTimeout(
+ printCommandPromise,
+ 'Page.printToPDF',
+ timeout
+ );
+
+ if (omitBackground) {
+ await this.#resetDefaultBackgroundColor();
+ }
+
+ assert(result.stream, '`stream` is missing from `Page.printToPDF');
+ return getReadableFromProtocolStream(this.#client, result.stream);
+ }
+
+ /**
+ * @param options -
+ * @returns
+ */
+ async pdf(options: PDFOptions = {}): Promise<Buffer> {
+ const {path = undefined} = options;
+ const readable = await this.createPDFStream(options);
+ const buffer = await getReadableAsBuffer(readable, path);
+ assert(buffer, 'Could not create buffer');
+ return buffer;
+ }
+
+ /**
+ * @returns The page's title
+ * @remarks
+ * Shortcut for {@link Frame.title | page.mainFrame().title()}.
+ */
+ async title(): Promise<string> {
+ return this.mainFrame().title();
+ }
+
+ async close(
+ options: {runBeforeUnload?: boolean} = {runBeforeUnload: undefined}
+ ): Promise<void> {
+ const connection = this.#client.connection();
+ assert(
+ 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 connection.send('Target.closeTarget', {
+ targetId: this.#target._targetId,
+ });
+ await this.#target._isClosedPromise;
+ }
+ }
+
+ /**
+ * Indicates that the page has been closed.
+ * @returns
+ */
+ isClosed(): boolean {
+ return this.#closed;
+ }
+
+ get mouse(): Mouse {
+ return this.#mouse;
+ }
+
+ /**
+ * This method fetches an element with `selector`, scrolls it 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.
+ * @remarks Bear in mind that if `click()` triggers a navigation event and
+ * there's a separate `page.waitForNavigation()` promise to be resolved, you
+ * may end up with a race condition that yields unexpected results. The
+ * correct pattern for click and wait for navigation is the following:
+ *
+ * ```ts
+ * const [response] = await Promise.all([
+ * page.waitForNavigation(waitOptions),
+ * page.click(selector, clickOptions),
+ * ]);
+ * ```
+ *
+ * Shortcut for {@link Frame.click | page.mainFrame().click(selector[, options]) }.
+ * @param selector - A `selector` to search for element to click. If there are
+ * multiple elements satisfying the `selector`, the first will be clicked
+ * @param options - `Object`
+ * @returns Promise which resolves when the element matching `selector` is
+ * successfully clicked. The Promise will be rejected if there is no element
+ * matching `selector`.
+ */
+ click(
+ selector: string,
+ options: {
+ delay?: number;
+ button?: MouseButton;
+ clickCount?: number;
+ } = {}
+ ): Promise<void> {
+ return this.mainFrame().click(selector, options);
+ }
+
+ /**
+ * This method fetches an element with `selector` and focuses it. If there's no
+ * element matching `selector`, the method throws an error.
+ * @param selector - A
+ * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors | selector }
+ * of an element to focus. If there are multiple elements satisfying the
+ * selector, the first will be focused.
+ * @returns Promise which resolves when the element matching selector is
+ * successfully focused. The promise will be rejected if there is no element
+ * matching selector.
+ * @remarks
+ * Shortcut for {@link Frame.focus | page.mainFrame().focus(selector)}.
+ */
+ focus(selector: string): Promise<void> {
+ return this.mainFrame().focus(selector);
+ }
+
+ /**
+ * This method fetches an element with `selector`, scrolls it into view if
+ * needed, and then uses {@link Page.mouse} to hover over the center of the element.
+ * If there's no element matching `selector`, the method throws an error.
+ * @param selector - A
+ * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors | selector}
+ * to search for element to hover. If there are multiple elements satisfying
+ * the selector, the first will be hovered.
+ * @returns Promise which resolves when the element matching `selector` is
+ * successfully hovered. Promise gets rejected if there's no element matching
+ * `selector`.
+ * @remarks
+ * Shortcut for {@link Page.hover | page.mainFrame().hover(selector)}.
+ */
+ hover(selector: string): Promise<void> {
+ return this.mainFrame().hover(selector);
+ }
+
+ /**
+ * Triggers a `change` and `input` event once all the provided options have been
+ * selected. If there's no `<select>` element matching `selector`, the method
+ * throws an error.
+ *
+ * @example
+ *
+ * ```ts
+ * page.select('select#colors', 'blue'); // single selection
+ * page.select('select#colors', 'red', 'green', 'blue'); // multiple selections
+ * ```
+ *
+ * @param selector - A
+ * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors | Selector}
+ * to query the page for
+ * @param values - Values of options to select. If the `<select>` has the
+ * `multiple` attribute, all values are considered, otherwise only the first one
+ * is taken into account.
+ * @returns
+ *
+ * @remarks
+ * Shortcut for {@link Frame.select | page.mainFrame().select()}
+ */
+ select(selector: string, ...values: string[]): Promise<string[]> {
+ return this.mainFrame().select(selector, ...values);
+ }
+
+ /**
+ * This method fetches an element with `selector`, scrolls it into view if
+ * needed, and then uses {@link Page.touchscreen} to tap in the center of the element.
+ * If there's no element matching `selector`, the method throws an error.
+ * @param selector - A
+ * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors | Selector}
+ * to search for element to tap. If there are multiple elements satisfying the
+ * selector, the first will be tapped.
+ * @returns
+ * @remarks
+ * Shortcut for {@link Frame.tap | page.mainFrame().tap(selector)}.
+ */
+ tap(selector: string): Promise<void> {
+ return this.mainFrame().tap(selector);
+ }
+
+ /**
+ * Sends a `keydown`, `keypress/input`, and `keyup` event for each character
+ * in the text.
+ *
+ * To press a special key, like `Control` or `ArrowDown`, use {@link Keyboard.press}.
+ * @example
+ *
+ * ```ts
+ * await page.type('#mytextarea', 'Hello');
+ * // Types instantly
+ * await page.type('#mytextarea', 'World', {delay: 100});
+ * // Types slower, like a user
+ * ```
+ *
+ * @param selector - A
+ * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors | selector}
+ * of an element to type into. If there are multiple elements satisfying the
+ * selector, the first will be used.
+ * @param text - A text to type into a focused element.
+ * @param options - have property `delay` which is the Time to wait between
+ * key presses in milliseconds. Defaults to `0`.
+ * @returns
+ * @remarks
+ */
+ type(
+ selector: string,
+ text: string,
+ options?: {delay: number}
+ ): Promise<void> {
+ return this.mainFrame().type(selector, text, options);
+ }
+
+ /**
+ * @deprecated Use `new Promise(r => setTimeout(r, milliseconds));`.
+ *
+ * Causes your script to wait for the given number of milliseconds.
+ *
+ * @remarks
+ * It's generally recommended to not wait for a number of seconds, but instead
+ * use {@link Frame.waitForSelector}, {@link Frame.waitForXPath} or
+ * {@link Frame.waitForFunction} to wait for exactly the conditions you want.
+ *
+ * @example
+ *
+ * Wait for 1 second:
+ *
+ * ```ts
+ * await page.waitForTimeout(1000);
+ * ```
+ *
+ * @param milliseconds - the number of milliseconds to wait.
+ */
+ waitForTimeout(milliseconds: number): Promise<void> {
+ return this.mainFrame().waitForTimeout(milliseconds);
+ }
+
+ /**
+ * Wait for the `selector` to appear in page. If at the moment of calling the
+ * method the `selector` already exists, the method will return immediately. If
+ * the `selector` doesn't appear after the `timeout` milliseconds of waiting, the
+ * function will throw.
+ *
+ * This method works across navigations:
+ *
+ * ```ts
+ * const puppeteer = require('puppeteer');
+ * (async () => {
+ * const browser = await puppeteer.launch();
+ * const page = await browser.newPage();
+ * let currentURL;
+ * page
+ * .waitForSelector('img')
+ * .then(() => console.log('First URL with image: ' + currentURL));
+ * for (currentURL of [
+ * 'https://example.com',
+ * 'https://google.com',
+ * 'https://bbc.com',
+ * ]) {
+ * await page.goto(currentURL);
+ * }
+ * await browser.close();
+ * })();
+ * ```
+ *
+ * @param selector - A
+ * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors | selector}
+ * of an element to wait for
+ * @param options - Optional waiting parameters
+ * @returns Promise which resolves when element specified by selector string
+ * is added to DOM. Resolves to `null` if waiting for hidden: `true` and
+ * selector is not found in DOM.
+ * @remarks
+ * The optional Parameter in Arguments `options` are :
+ *
+ * - `Visible`: A boolean wait for element to be present in DOM and to be
+ * visible, i.e. to not have `display: none` or `visibility: hidden` CSS
+ * properties. Defaults to `false`.
+ *
+ * - `hidden`: Wait for element to not be found in the DOM or to be hidden,
+ * i.e. have `display: none` or `visibility: hidden` CSS properties. Defaults to
+ * `false`.
+ *
+ * - `timeout`: maximum time to wait for in milliseconds. Defaults to `30000`
+ * (30 seconds). Pass `0` to disable timeout. The default value can be changed
+ * by using the {@link Page.setDefaultTimeout} method.
+ */
+ async waitForSelector<Selector extends string>(
+ selector: Selector,
+ options: WaitForSelectorOptions = {}
+ ): Promise<ElementHandle<NodeFor<Selector>> | null> {
+ return await this.mainFrame().waitForSelector(selector, options);
+ }
+
+ /**
+ * Wait for the `xpath` to appear in page. If at the moment of calling the
+ * method the `xpath` already exists, the method will return immediately. If
+ * the `xpath` doesn't appear after the `timeout` milliseconds of waiting, the
+ * function will throw.
+ *
+ * This method works across navigation
+ *
+ * ```ts
+ * const puppeteer = require('puppeteer');
+ * (async () => {
+ * const browser = await puppeteer.launch();
+ * const page = await browser.newPage();
+ * let currentURL;
+ * page
+ * .waitForXPath('//img')
+ * .then(() => console.log('First URL with image: ' + currentURL));
+ * for (currentURL of [
+ * 'https://example.com',
+ * 'https://google.com',
+ * 'https://bbc.com',
+ * ]) {
+ * await page.goto(currentURL);
+ * }
+ * await browser.close();
+ * })();
+ * ```
+ *
+ * @param xpath - A
+ * {@link https://developer.mozilla.org/en-US/docs/Web/XPath | xpath} of an
+ * element to wait for
+ * @param options - Optional waiting parameters
+ * @returns Promise which resolves when element specified by xpath string is
+ * added to DOM. Resolves to `null` if waiting for `hidden: true` and xpath is
+ * not found in DOM.
+ * @remarks
+ * The optional Argument `options` have properties:
+ *
+ * - `visible`: A boolean to wait for element to be present in DOM and to be
+ * visible, i.e. to not have `display: none` or `visibility: hidden` CSS
+ * properties. Defaults to `false`.
+ *
+ * - `hidden`: A boolean wait for element to not be found in the DOM or to be
+ * hidden, i.e. have `display: none` or `visibility: hidden` CSS properties.
+ * Defaults to `false`.
+ *
+ * - `timeout`: A number which is maximum time to wait for in milliseconds.
+ * Defaults to `30000` (30 seconds). Pass `0` to disable timeout. The default
+ * value can be changed by using the {@link Page.setDefaultTimeout} method.
+ */
+ waitForXPath(
+ xpath: string,
+ options: {
+ visible?: boolean;
+ hidden?: boolean;
+ timeout?: number;
+ } = {}
+ ): Promise<ElementHandle<Node> | null> {
+ return this.mainFrame().waitForXPath(xpath, options);
+ }
+
+ /**
+ * Waits for a function to finish evaluating in the page's context.
+ *
+ * @example
+ * The {@link Page.waitForFunction} can be used to observe viewport size change:
+ *
+ * ```ts
+ * const puppeteer = require('puppeteer');
+ * (async () => {
+ * const browser = await puppeteer.launch();
+ * const page = await browser.newPage();
+ * const watchDog = page.waitForFunction('window.innerWidth < 100');
+ * await page.setViewport({width: 50, height: 50});
+ * await watchDog;
+ * await browser.close();
+ * })();
+ * ```
+ *
+ * @example
+ * To pass arguments from node.js to the predicate of
+ * {@link Page.waitForFunction} function:
+ *
+ * ```ts
+ * const selector = '.foo';
+ * await page.waitForFunction(
+ * selector => !!document.querySelector(selector),
+ * {},
+ * selector
+ * );
+ * ```
+ *
+ * @example
+ * The predicate of {@link Page.waitForFunction} can be asynchronous too:
+ *
+ * ```ts
+ * const username = 'github-username';
+ * await page.waitForFunction(
+ * async username => {
+ * const githubResponse = await fetch(
+ * `https://api.github.com/users/${username}`
+ * );
+ * const githubUser = await githubResponse.json();
+ * // show the avatar
+ * const img = document.createElement('img');
+ * img.src = githubUser.avatar_url;
+ * // wait 3 seconds
+ * await new Promise((resolve, reject) => setTimeout(resolve, 3000));
+ * img.remove();
+ * },
+ * {},
+ * username
+ * );
+ * ```
+ *
+ * @param pageFunction - Function to be evaluated in browser context
+ * @param options - Optional waiting parameters
+ *
+ * - `polling` - 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.
+ * - `timeout` - maximum time to wait for in milliseconds. Defaults to `30000`
+ * (30 seconds). Pass `0` to disable timeout. The default value can be
+ * changed by using the {@link Page.setDefaultTimeout} method.
+ * @param args - Arguments to pass to `pageFunction`
+ * @returns A `Promise` which resolves to a JSHandle/ElementHandle of the the
+ * `pageFunction`'s return value.
+ */
+ waitForFunction<
+ Params extends unknown[],
+ Func extends EvaluateFunc<Params> = EvaluateFunc<Params>
+ >(
+ pageFunction: Func | string,
+ options: {
+ timeout?: number;
+ polling?: string | number;
+ } = {},
+ ...args: Params
+ ): Promise<HandleFor<Awaited<ReturnType<Func>>>> {
+ 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 (isNumber(parameter)) {
+ // Treat numbers as pixel values to be aligned with phantom's paperSize.
+ pixels = parameter;
+ } else if (isString(parameter)) {
+ const text = parameter;
+ let unit = text.substring(text.length - 2).toLowerCase();
+ let valueText = '';
+ if (unit in unitToPixels) {
+ valueText = text.substring(0, text.length - 2);
+ } else {
+ // In case of unknown unit try to parse the whole parameter as number of pixels.
+ // This is consistent with phantom's paperSize behavior.
+ unit = 'px';
+ valueText = text;
+ }
+ const value = Number(valueText);
+ assert(!isNaN(value), 'Failed to parse parameter value: ' + text);
+ pixels = value * unitToPixels[unit as keyof typeof unitToPixels];
+ } else {
+ throw new Error(
+ 'page.pdf() Cannot handle parameter type: ' + typeof parameter
+ );
+ }
+ return pixels / 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..579f68a937
--- /dev/null
+++ b/remote/test/puppeteer/src/common/Puppeteer.ts
@@ -0,0 +1,167 @@
+/**
+ * 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 {Browser} from './Browser.js';
+import {BrowserConnectOptions, _connectToBrowser} from './BrowserConnector.js';
+import {ConnectionTransport} from './ConnectionTransport.js';
+import {devices} from './DeviceDescriptors.js';
+import {errors} from './Errors.js';
+import {networkConditions} from './NetworkConditions.js';
+import {
+ clearCustomQueryHandlers,
+ CustomQueryHandler,
+ customQueryHandlerNames,
+ registerCustomQueryHandler,
+ unregisterCustomQueryHandler,
+} from './QueryHandler.js';
+
+/**
+ * Settings that are common to the Puppeteer class, regardless of environment.
+ *
+ * @internal
+ */
+export interface CommonPuppeteerSettings {
+ isPuppeteerCore: boolean;
+}
+/**
+ * @public
+ */
+export interface ConnectOptions extends BrowserConnectOptions {
+ browserWSEndpoint?: string;
+ browserURL?: string;
+ transport?: ConnectionTransport;
+}
+
+/**
+ * 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.connect = this.connect.bind(this);
+ }
+
+ /**
+ * This method attaches Puppeteer to an existing browser instance.
+ *
+ * @remarks
+ *
+ * @param options - Set of configurable options to set on the browser.
+ * @returns Promise which resolves to browser instance.
+ */
+ connect(options: ConnectOptions): Promise<Browser> {
+ return _connectToBrowser(options);
+ }
+
+ /**
+ * @deprecated Import directly puppeteer.
+ * @example
+ *
+ * ```ts
+ * import {devices} from 'puppeteer';
+ * ```
+ */
+ get devices(): typeof devices {
+ return devices;
+ }
+
+ /**
+ * @deprecated Import directly puppeteer.
+ * @example
+ *
+ * ```ts
+ * import {errors} from 'puppeteer';
+ * ```
+ */
+ get errors(): typeof errors {
+ return errors;
+ }
+
+ /**
+ * @deprecated Import directly puppeteer.
+ * @example
+ *
+ * ```ts
+ * import {networkConditions} from 'puppeteer';
+ * ```
+ */
+ get networkConditions(): typeof networkConditions {
+ return networkConditions;
+ }
+
+ /**
+ * @deprecated Import directly puppeteer.
+ * @example
+ *
+ * ```ts
+ * import {registerCustomQueryHandler} from 'puppeteer';
+ * ```
+ */
+ registerCustomQueryHandler(
+ name: string,
+ queryHandler: CustomQueryHandler
+ ): void {
+ return registerCustomQueryHandler(name, queryHandler);
+ }
+
+ /**
+ * @deprecated Import directly puppeteer.
+ * @example
+ *
+ * ```ts
+ * import {unregisterCustomQueryHandler} from 'puppeteer';
+ * ```
+ */
+ unregisterCustomQueryHandler(name: string): void {
+ return unregisterCustomQueryHandler(name);
+ }
+
+ /**
+ * @deprecated Import directly puppeteer.
+ * @example
+ *
+ * ```ts
+ * import {customQueryHandlerNames} from 'puppeteer';
+ * ```
+ */
+ customQueryHandlerNames(): string[] {
+ return customQueryHandlerNames();
+ }
+
+ /**
+ * @deprecated Import directly puppeteer.
+ * @example
+ *
+ * ```ts
+ * import {clearCustomQueryHandlers} from 'puppeteer';
+ * ```
+ */
+ clearCustomQueryHandlers(): void {
+ return 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..48a0823b52
--- /dev/null
+++ b/remote/test/puppeteer/src/common/PuppeteerViewport.ts
@@ -0,0 +1,51 @@
+/**
+ * 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.
+ */
+/**
+ *
+ * Sets the viewport of the page.
+ * @public
+ */
+export interface Viewport {
+ /**
+ * The page width in pixels.
+ */
+ width: number;
+ /**
+ * The page height in pixels.
+ */
+ height: number;
+ /**
+ * Specify device scale factor.
+ * See {@link https://developer.mozilla.org/en-US/docs/Web/API/Window/devicePixelRatio | devicePixelRatio} for more info.
+ * @defaultValue 1
+ */
+ deviceScaleFactor?: number;
+ /**
+ * Whether the `meta viewport` tag is taken into account.
+ * @defaultValue false
+ */
+ isMobile?: boolean;
+ /**
+ * Specifies if the viewport is in landscape mode.
+ * @defaultValue false
+ */
+ isLandscape?: boolean;
+ /**
+ * Specify if the viewport supports touch events.
+ * @defaultValue false
+ */
+ hasTouch?: boolean;
+}
diff --git a/remote/test/puppeteer/src/common/QueryHandler.ts b/remote/test/puppeteer/src/common/QueryHandler.ts
new file mode 100644
index 0000000000..5fd360e06d
--- /dev/null
+++ b/remote/test/puppeteer/src/common/QueryHandler.ts
@@ -0,0 +1,354 @@
+/**
+ * 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 {ariaHandler} from './AriaQueryHandler.js';
+import {ElementHandle} from './ElementHandle.js';
+import {Frame} from './Frame.js';
+import {
+ MAIN_WORLD,
+ PUPPETEER_WORLD,
+ WaitForSelectorOptions,
+} from './IsolatedWorld.js';
+
+/**
+ * @public
+ */
+export interface CustomQueryHandler {
+ /**
+ * @returns A {@link Node} matching the given `selector` from {@link node}.
+ */
+ queryOne?: (node: Node, selector: string) => Node | null;
+ /**
+ * @returns Some {@link Node}s matching the given `selector` from {@link node}.
+ */
+ queryAll?: (node: Node, selector: string) => Node[];
+}
+
+/**
+ * @internal
+ */
+export interface InternalQueryHandler {
+ /**
+ * Queries for a single node given a selector and {@link ElementHandle}.
+ *
+ * Akin to {@link Window.prototype.querySelector}.
+ */
+ queryOne?: (
+ element: ElementHandle<Node>,
+ selector: string
+ ) => Promise<ElementHandle<Node> | null>;
+ /**
+ * Queries for multiple nodes given a selector and {@link ElementHandle}.
+ *
+ * Akin to {@link Window.prototype.querySelectorAll}.
+ */
+ queryAll?: (
+ element: ElementHandle<Node>,
+ selector: string
+ ) => Promise<Array<ElementHandle<Node>>>;
+
+ /**
+ * Waits until a single node appears for a given selector and
+ * {@link ElementHandle}.
+ */
+ waitFor?: (
+ elementOrFrame: ElementHandle<Node> | Frame,
+ selector: string,
+ options: WaitForSelectorOptions
+ ) => Promise<ElementHandle<Node> | null>;
+}
+
+function internalizeCustomQueryHandler(
+ handler: CustomQueryHandler
+): InternalQueryHandler {
+ const internalHandler: InternalQueryHandler = {};
+
+ if (handler.queryOne) {
+ const queryOne = handler.queryOne;
+ internalHandler.queryOne = async (element, selector) => {
+ const jsHandle = await element.evaluateHandle(queryOne, selector);
+ const elementHandle = jsHandle.asElement();
+ if (elementHandle) {
+ return elementHandle;
+ }
+ await jsHandle.dispose();
+ return null;
+ };
+ internalHandler.waitFor = async (elementOrFrame, selector, options) => {
+ let frame: Frame;
+ let element: ElementHandle<Node> | undefined;
+ if (elementOrFrame instanceof Frame) {
+ frame = elementOrFrame;
+ } else {
+ frame = elementOrFrame.frame;
+ element = await frame.worlds[PUPPETEER_WORLD].adoptHandle(
+ elementOrFrame
+ );
+ }
+ const result = await frame.worlds[PUPPETEER_WORLD]._waitForSelectorInPage(
+ queryOne,
+ element,
+ selector,
+ options
+ );
+ if (element) {
+ await element.dispose();
+ }
+ if (!result) {
+ return null;
+ }
+ if (!(result instanceof ElementHandle)) {
+ await result.dispose();
+ return null;
+ }
+ return frame.worlds[MAIN_WORLD].transferHandle(result);
+ };
+ }
+
+ if (handler.queryAll) {
+ const queryAll = handler.queryAll;
+ internalHandler.queryAll = async (element, selector) => {
+ const jsHandle = await element.evaluateHandle(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;
+ };
+ }
+
+ return internalHandler;
+}
+
+const defaultHandler = internalizeCustomQueryHandler({
+ queryOne: (element, selector) => {
+ if (!('querySelector' in element)) {
+ throw new Error(
+ `Could not invoke \`querySelector\` on node of type ${element.nodeName}.`
+ );
+ }
+ return (
+ element as unknown as {querySelector(selector: string): Element}
+ ).querySelector(selector);
+ },
+ queryAll: (element, selector) => {
+ if (!('querySelectorAll' in element)) {
+ throw new Error(
+ `Could not invoke \`querySelectorAll\` on node of type ${element.nodeName}.`
+ );
+ }
+ return [
+ ...(
+ element as unknown as {
+ querySelectorAll(selector: string): NodeList;
+ }
+ ).querySelectorAll(selector),
+ ];
+ },
+});
+
+const pierceHandler = internalizeCustomQueryHandler({
+ queryOne: (element, selector) => {
+ let found: Node | null = null;
+ const search = (root: Node) => {
+ 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 (currentNode !== root && !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: Node[] = [];
+ const collect = (root: Node) => {
+ 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 !== root && currentNode.matches(selector)) {
+ result.push(currentNode);
+ }
+ } while (iter.nextNode());
+ };
+ if (element instanceof Document) {
+ element = element.documentElement;
+ }
+ collect(element);
+ return result;
+ },
+});
+
+const xpathHandler = internalizeCustomQueryHandler({
+ queryOne: (element, selector) => {
+ const doc = element.ownerDocument || document;
+ const result = doc.evaluate(
+ selector,
+ element,
+ null,
+ XPathResult.FIRST_ORDERED_NODE_TYPE
+ );
+ return result.singleNodeValue;
+ },
+
+ queryAll: (element, selector) => {
+ const doc = element.ownerDocument || document;
+ const iterator = doc.evaluate(
+ selector,
+ element,
+ null,
+ XPathResult.ORDERED_NODE_ITERATOR_TYPE
+ );
+ const array: Node[] = [];
+ let item;
+ while ((item = iterator.iterateNext())) {
+ array.push(item);
+ }
+ return array;
+ },
+});
+
+interface RegisteredQueryHandler {
+ handler: InternalQueryHandler;
+ transformSelector?: (selector: string) => string;
+}
+
+const INTERNAL_QUERY_HANDLERS = new Map<string, RegisteredQueryHandler>([
+ ['aria', {handler: ariaHandler}],
+ ['pierce', {handler: pierceHandler}],
+ ['xpath', {handler: xpathHandler}],
+]);
+const QUERY_HANDLERS = new Map<string, RegisteredQueryHandler>();
+
+/**
+ * Registers a {@link CustomQueryHandler | custom query handler}.
+ *
+ * @remarks
+ * After registration, the handler can be used everywhere where a selector is
+ * expected by prepending the selection string with `<name>/`. The name is only
+ * allowed to consist of lower- and upper case latin letters.
+ *
+ * @example
+ *
+ * ```
+ * puppeteer.registerCustomQueryHandler('text', { … });
+ * const aHandle = await page.$('text/…');
+ * ```
+ *
+ * @param name - The name that the custom query handler will be registered
+ * under.
+ * @param queryHandler - The {@link CustomQueryHandler | custom query handler}
+ * to register.
+ *
+ * @public
+ */
+export function registerCustomQueryHandler(
+ name: string,
+ handler: CustomQueryHandler
+): void {
+ if (INTERNAL_QUERY_HANDLERS.has(name)) {
+ throw new Error(`A query handler named "${name}" already exists`);
+ }
+ if (QUERY_HANDLERS.has(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]`);
+ }
+
+ QUERY_HANDLERS.set(name, {handler: internalizeCustomQueryHandler(handler)});
+}
+
+/**
+ * @param name - The name of the query handler to unregistered.
+ *
+ * @public
+ */
+export function unregisterCustomQueryHandler(name: string): void {
+ QUERY_HANDLERS.delete(name);
+}
+
+/**
+ * @returns a list with the names of all registered custom query handlers.
+ *
+ * @public
+ */
+export function customQueryHandlerNames(): string[] {
+ return [...QUERY_HANDLERS.keys()];
+}
+
+/**
+ * Clears all registered handlers.
+ *
+ * @public
+ */
+export function clearCustomQueryHandlers(): void {
+ QUERY_HANDLERS.clear();
+}
+
+const CUSTOM_QUERY_SEPARATORS = ['=', '/'];
+
+/**
+ * @internal
+ */
+export function getQueryHandlerAndSelector(selector: string): {
+ updatedSelector: string;
+ queryHandler: InternalQueryHandler;
+} {
+ for (const handlerMap of [QUERY_HANDLERS, INTERNAL_QUERY_HANDLERS]) {
+ for (const [
+ name,
+ {handler: queryHandler, transformSelector},
+ ] of handlerMap) {
+ for (const separator of CUSTOM_QUERY_SEPARATORS) {
+ const prefix = `${name}${separator}`;
+ if (selector.startsWith(prefix)) {
+ selector = selector.slice(prefix.length);
+ if (transformSelector) {
+ selector = transformSelector(selector);
+ }
+ return {updatedSelector: selector, queryHandler};
+ }
+ }
+ }
+ }
+ return {updatedSelector: selector, queryHandler: defaultHandler};
+}
diff --git a/remote/test/puppeteer/src/common/SecurityDetails.ts b/remote/test/puppeteer/src/common/SecurityDetails.ts
new file mode 100644
index 0000000000..7df6aef88a
--- /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 {
+ #subjectName: string;
+ #issuer: string;
+ #validFrom: number;
+ #validTo: number;
+ #protocol: string;
+ #sanList: string[];
+
+ /**
+ * @internal
+ */
+ constructor(securityPayload: Protocol.Network.SecurityDetails) {
+ this.#subjectName = securityPayload.subjectName;
+ this.#issuer = securityPayload.issuer;
+ this.#validFrom = securityPayload.validFrom;
+ this.#validTo = securityPayload.validTo;
+ this.#protocol = securityPayload.protocol;
+ this.#sanList = securityPayload.sanList;
+ }
+
+ /**
+ * @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..5744834f1b
--- /dev/null
+++ b/remote/test/puppeteer/src/common/Target.ts
@@ -0,0 +1,276 @@
+/**
+ * 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, IsPageTargetCallback} from './Browser.js';
+import {Viewport} from './PuppeteerViewport.js';
+import {Protocol} from 'devtools-protocol';
+import {TaskQueue} from './TaskQueue.js';
+import {TargetManager} from './TargetManager.js';
+
+/**
+ * @public
+ */
+export class Target {
+ #browserContext: BrowserContext;
+ #session?: CDPSession;
+ #targetInfo: Protocol.Target.TargetInfo;
+ #sessionFactory: (isAutoAttachEmulated: boolean) => Promise<CDPSession>;
+ #ignoreHTTPSErrors: boolean;
+ #defaultViewport?: Viewport;
+ #pagePromise?: Promise<Page>;
+ #workerPromise?: Promise<WebWorker>;
+ #screenshotTaskQueue: TaskQueue;
+
+ /**
+ * @internal
+ */
+ _initializedPromise: Promise<boolean>;
+ /**
+ * @internal
+ */
+ _initializedCallback!: (x: boolean) => void;
+ /**
+ * @internal
+ */
+ _isClosedPromise: Promise<void>;
+ /**
+ * @internal
+ */
+ _closedCallback!: () => void;
+ /**
+ * @internal
+ */
+ _isInitialized: boolean;
+ /**
+ * @internal
+ */
+ _targetId: string;
+ /**
+ * @internal
+ */
+ _isPageTargetCallback: IsPageTargetCallback;
+
+ #targetManager: TargetManager;
+
+ /**
+ * @internal
+ */
+ constructor(
+ targetInfo: Protocol.Target.TargetInfo,
+ session: CDPSession | undefined,
+ browserContext: BrowserContext,
+ targetManager: TargetManager,
+ sessionFactory: (isAutoAttachEmulated: boolean) => Promise<CDPSession>,
+ ignoreHTTPSErrors: boolean,
+ defaultViewport: Viewport | null,
+ screenshotTaskQueue: TaskQueue,
+ isPageTargetCallback: IsPageTargetCallback
+ ) {
+ this.#session = session;
+ this.#targetManager = targetManager;
+ this.#targetInfo = targetInfo;
+ this.#browserContext = browserContext;
+ this._targetId = targetInfo.targetId;
+ this.#sessionFactory = sessionFactory;
+ this.#ignoreHTTPSErrors = ignoreHTTPSErrors;
+ this.#defaultViewport = defaultViewport ?? undefined;
+ this.#screenshotTaskQueue = screenshotTaskQueue;
+ this._isPageTargetCallback = isPageTargetCallback;
+ this._initializedPromise = new Promise<boolean>(fulfill => {
+ return (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 => {
+ return (this._closedCallback = fulfill);
+ });
+ this._isInitialized =
+ !this._isPageTargetCallback(this.#targetInfo) ||
+ this.#targetInfo.url !== '';
+ if (this._isInitialized) {
+ this._initializedCallback(true);
+ }
+ }
+
+ /**
+ * @internal
+ */
+ _session(): CDPSession | undefined {
+ return this.#session;
+ }
+
+ /**
+ * Creates a Chrome Devtools Protocol session attached to the target.
+ */
+ createCDPSession(): Promise<CDPSession> {
+ return this.#sessionFactory(false);
+ }
+
+ /**
+ * @internal
+ */
+ _targetManager(): TargetManager {
+ return this.#targetManager;
+ }
+
+ /**
+ * @internal
+ */
+ _getTargetInfo(): Protocol.Target.TargetInfo {
+ return this.#targetInfo;
+ }
+
+ /**
+ * If the target is not of type `"page"` or `"background_page"`, returns `null`.
+ */
+ async page(): Promise<Page | null> {
+ if (this._isPageTargetCallback(this.#targetInfo) && !this.#pagePromise) {
+ this.#pagePromise = (
+ this.#session
+ ? Promise.resolve(this.#session)
+ : this.#sessionFactory(true)
+ ).then(client => {
+ return Page._create(
+ client,
+ this,
+ this.#ignoreHTTPSErrors,
+ this.#defaultViewport ?? null,
+ this.#screenshotTaskQueue
+ );
+ });
+ }
+ return (await this.#pagePromise) ?? null;
+ }
+
+ /**
+ * 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.#session
+ ? Promise.resolve(this.#session)
+ : this.#sessionFactory(false)
+ ).then(client => {
+ return 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();
+ }
+
+ /**
+ * Get the browser context the target belongs to.
+ */
+ browserContext(): BrowserContext {
+ return this.#browserContext;
+ }
+
+ /**
+ * Get the target that opened this target. Top-level targets return `null`.
+ */
+ opener(): Target | undefined {
+ const {openerId} = this.#targetInfo;
+ if (!openerId) {
+ return;
+ }
+ return this.browser()._targets.get(openerId);
+ }
+
+ /**
+ * @internal
+ */
+ _targetInfoChanged(targetInfo: Protocol.Target.TargetInfo): void {
+ this.#targetInfo = targetInfo;
+
+ if (
+ !this._isInitialized &&
+ (!this._isPageTargetCallback(this.#targetInfo) ||
+ this.#targetInfo.url !== '')
+ ) {
+ this._isInitialized = true;
+ this._initializedCallback(true);
+ return;
+ }
+ }
+}
diff --git a/remote/test/puppeteer/src/common/TargetManager.ts b/remote/test/puppeteer/src/common/TargetManager.ts
new file mode 100644
index 0000000000..1b82599ada
--- /dev/null
+++ b/remote/test/puppeteer/src/common/TargetManager.ts
@@ -0,0 +1,71 @@
+/**
+ * Copyright 2022 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';
+import {CDPSession} from './Connection.js';
+import {EventEmitter} from './EventEmitter.js';
+import {Target} from './Target.js';
+
+/**
+ * @internal
+ */
+export type TargetFactory = (
+ targetInfo: Protocol.Target.TargetInfo,
+ session?: CDPSession
+) => Target;
+
+/**
+ * @internal
+ */
+export type TargetInterceptor = (
+ createdTarget: Target,
+ parentTarget: Target | null
+) => Promise<void>;
+
+/**
+ * TargetManager encapsulates all interactions with CDP targets and is
+ * responsible for coordinating the configuration of targets with the rest of
+ * Puppeteer. Code outside of this class should not subscribe `Target.*` events
+ * and only use the TargetManager events.
+ *
+ * There are two implementations: one for Chrome that uses CDP's auto-attach
+ * mechanism and one for Firefox because Firefox does not support auto-attach.
+ *
+ * @internal
+ */
+export interface TargetManager extends EventEmitter {
+ getAvailableTargets(): Map<string, Target>;
+ initialize(): Promise<void>;
+ dispose(): void;
+ addTargetInterceptor(
+ session: CDPSession,
+ interceptor: TargetInterceptor
+ ): void;
+ removeTargetInterceptor(
+ session: CDPSession,
+ interceptor: TargetInterceptor
+ ): void;
+}
+
+/**
+ * @internal
+ */
+export const enum TargetManagerEmittedEvents {
+ TargetDiscovered = 'targetDiscovered',
+ TargetAvailable = 'targetAvailable',
+ TargetGone = 'targetGone',
+ TargetChanged = 'targetChanged',
+}
diff --git a/remote/test/puppeteer/src/common/TaskQueue.ts b/remote/test/puppeteer/src/common/TaskQueue.ts
new file mode 100644
index 0000000000..97cfe7c769
--- /dev/null
+++ b/remote/test/puppeteer/src/common/TaskQueue.ts
@@ -0,0 +1,39 @@
+/**
+ * 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.
+ */
+
+/**
+ * @internal
+ */
+export class TaskQueue {
+ #chain: Promise<void>;
+
+ constructor() {
+ this.#chain = Promise.resolve();
+ }
+
+ postTask<T>(task: () => Promise<T>): Promise<T> {
+ const result = this.#chain.then(task);
+ this.#chain = result.then(
+ () => {
+ return undefined;
+ },
+ () => {
+ return undefined;
+ }
+ );
+ return result;
+ }
+}
diff --git a/remote/test/puppeteer/src/common/TimeoutSettings.ts b/remote/test/puppeteer/src/common/TimeoutSettings.ts
new file mode 100644
index 0000000000..97acc70147
--- /dev/null
+++ b/remote/test/puppeteer/src/common/TimeoutSettings.ts
@@ -0,0 +1,55 @@
+/**
+ * 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..500e17269b
--- /dev/null
+++ b/remote/test/puppeteer/src/common/Tracing.ts
@@ -0,0 +1,143 @@
+/**
+ * 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 '../util/assert.js';
+import {getReadableAsBuffer, getReadableFromProtocolStream} from './util.js';
+import {isErrorLike} from '../util/ErrorLike.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
+ *
+ * ```ts
+ * await page.tracing.start({path: 'trace.json'});
+ * await page.goto('https://www.google.com');
+ * await page.tracing.stop();
+ * ```
+ *
+ * @public
+ */
+export class Tracing {
+ #client: CDPSession;
+ #recording = false;
+ #path?: string;
+
+ /**
+ * @internal
+ */
+ constructor(client: CDPSession) {
+ this.#client = client;
+ }
+
+ /**
+ * Starts a trace for the current page.
+ * @remarks
+ * Only one trace can be active at a time per browser.
+ *
+ * @param options - Optional `TracingOptions`.
+ */
+ async start(options: TracingOptions = {}): Promise<void> {
+ assert(
+ !this.#recording,
+ 'Cannot start recording trace while already recording trace.'
+ );
+
+ const defaultCategories = [
+ '-*',
+ 'devtools.timeline',
+ 'v8.execute',
+ 'disabled-by-default-devtools.timeline',
+ 'disabled-by-default-devtools.timeline.frame',
+ 'toplevel',
+ 'blink.console',
+ 'blink.user_timing',
+ 'latencyInfo',
+ 'disabled-by-default-devtools.timeline.stack',
+ 'disabled-by-default-v8.cpu_profiler',
+ ];
+ const {path, screenshots = false, categories = defaultCategories} = options;
+
+ if (screenshots) {
+ categories.push('disabled-by-default-devtools.screenshot');
+ }
+
+ const excludedCategories = categories
+ .filter(cat => {
+ return cat.startsWith('-');
+ })
+ .map(cat => {
+ return cat.slice(1);
+ });
+ const includedCategories = categories.filter(cat => {
+ return !cat.startsWith('-');
+ });
+
+ this.#path = path;
+ this.#recording = true;
+ await this.#client.send('Tracing.start', {
+ transferMode: 'ReturnAsStream',
+ traceConfig: {
+ excludedCategories,
+ includedCategories,
+ },
+ });
+ }
+
+ /**
+ * Stops a trace started with the `start` method.
+ * @returns Promise which resolves to buffer with trace data.
+ */
+ async stop(): Promise<Buffer | undefined> {
+ let resolve: (value: Buffer | undefined) => void;
+ let reject: (err: Error) => void;
+ const contentPromise = new Promise<Buffer | undefined>((x, y) => {
+ resolve = x;
+ reject = y;
+ });
+ this.#client.once('Tracing.tracingComplete', async event => {
+ try {
+ const readable = await getReadableFromProtocolStream(
+ this.#client,
+ event.stream
+ );
+ const buffer = await getReadableAsBuffer(readable, this.#path);
+ resolve(buffer ?? undefined);
+ } catch (error) {
+ if (isErrorLike(error)) {
+ reject(error);
+ } else {
+ reject(new Error(`Unknown error: ${error}`));
+ }
+ }
+ });
+ 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..f6a042e5ce
--- /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..cfc029c75c
--- /dev/null
+++ b/remote/test/puppeteer/src/common/WebWorker.ts
@@ -0,0 +1,170 @@
+/**
+ * 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 {Protocol} from 'devtools-protocol';
+import {CDPSession} from './Connection.js';
+import {ConsoleMessageType} from './ConsoleMessage.js';
+import {EvaluateFunc, HandleFor} from './types.js';
+import {EventEmitter} from './EventEmitter.js';
+import {ExecutionContext} from './ExecutionContext.js';
+import {JSHandle} from './JSHandle.js';
+import {debugError} from './util.js';
+import {createDeferredPromise} from '../util/DeferredPromise.js';
+
+/**
+ * @internal
+ */
+export type ConsoleAPICalledCallback = (
+ eventType: ConsoleMessageType,
+ handles: JSHandle[],
+ trace: Protocol.Runtime.StackTrace
+) => void;
+
+/**
+ * @internal
+ */
+export type ExceptionThrownCallback = (
+ details: Protocol.Runtime.ExceptionDetails
+) => void;
+
+/**
+ * This class represents a
+ * {@link https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API | WebWorker}.
+ *
+ * @remarks
+ * The events `workercreated` and `workerdestroyed` are emitted on the page
+ * object to signal the worker lifecycle.
+ *
+ * @example
+ *
+ * ```ts
+ * page.on('workercreated', worker =>
+ * console.log('Worker created: ' + worker.url())
+ * );
+ * page.on('workerdestroyed', worker =>
+ * console.log('Worker destroyed: ' + worker.url())
+ * );
+ *
+ * console.log('Current workers:');
+ * for (const worker of page.workers()) {
+ * console.log(' ' + worker.url());
+ * }
+ * ```
+ *
+ * @public
+ */
+export class WebWorker extends EventEmitter {
+ #executionContext = createDeferredPromise<ExecutionContext>();
+
+ #client: CDPSession;
+ #url: string;
+
+ /**
+ * @internal
+ */
+ constructor(
+ client: CDPSession,
+ url: string,
+ consoleAPICalled: ConsoleAPICalledCallback,
+ exceptionThrown: ExceptionThrownCallback
+ ) {
+ super();
+ this.#client = client;
+ this.#url = url;
+
+ this.#client.once('Runtime.executionContextCreated', async event => {
+ const context = new ExecutionContext(client, event.context);
+ this.#executionContext.resolve(context);
+ });
+ this.#client.on('Runtime.consoleAPICalled', async event => {
+ const context = await this.#executionContext;
+ return consoleAPICalled(
+ event.type,
+ event.args.map((object: Protocol.Runtime.RemoteObject) => {
+ return new JSHandle(context, object);
+ }),
+ event.stackTrace
+ );
+ });
+ this.#client.on('Runtime.exceptionThrown', exception => {
+ return exceptionThrown(exception.exceptionDetails);
+ });
+
+ // This might fail if the target is closed before we receive all execution contexts.
+ this.#client.send('Runtime.enable').catch(debugError);
+ }
+
+ /**
+ * @internal
+ */
+ async executionContext(): Promise<ExecutionContext> {
+ return this.#executionContext;
+ }
+
+ /**
+ * @returns The URL of this web worker.
+ */
+ url(): string {
+ return this.#url;
+ }
+
+ /**
+ * 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<
+ Params extends unknown[],
+ Func extends EvaluateFunc<Params> = EvaluateFunc<Params>
+ >(
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<Awaited<ReturnType<Func>>> {
+ const context = await this.#executionContext;
+ return context.evaluate(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<
+ Params extends unknown[],
+ Func extends EvaluateFunc<Params> = EvaluateFunc<Params>
+ >(
+ pageFunction: Func | string,
+ ...args: Params
+ ): Promise<HandleFor<Awaited<ReturnType<Func>>>> {
+ const context = await this.#executionContext;
+ return context.evaluateHandle(pageFunction, ...args);
+ }
+}
diff --git a/remote/test/puppeteer/src/common/fetch.ts b/remote/test/puppeteer/src/common/fetch.ts
new file mode 100644
index 0000000000..5f13a35288
--- /dev/null
+++ b/remote/test/puppeteer/src/common/fetch.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.
+ */
+
+/**
+ * Gets the global version if we're in the browser, else loads the node-fetch module.
+ *
+ * @internal
+ */
+export const getFetch = async (): Promise<typeof fetch> => {
+ return (globalThis as any).fetch || (await import('cross-fetch')).fetch;
+};
diff --git a/remote/test/puppeteer/src/common/types.ts b/remote/test/puppeteer/src/common/types.ts
new file mode 100644
index 0000000000..8af6f23f29
--- /dev/null
+++ b/remote/test/puppeteer/src/common/types.ts
@@ -0,0 +1,61 @@
+/**
+ * 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';
+import {ElementHandle} from './ElementHandle.js';
+
+/**
+ * @public
+ */
+export type Awaitable<T> = T | PromiseLike<T>;
+
+/**
+ * @public
+ */
+export type HandleFor<T> = T extends Node ? ElementHandle<T> : JSHandle<T>;
+
+/**
+ * @public
+ */
+export type HandleOr<T> = HandleFor<T> | JSHandle<T> | T;
+
+/**
+ * @public
+ */
+export type FlattenHandle<T> = T extends HandleOr<infer U> ? U : never;
+/**
+ * @public
+ */
+export type InnerParams<T extends unknown[]> = {
+ [K in keyof T]: FlattenHandle<T[K]>;
+};
+
+/**
+ * @public
+ */
+export type EvaluateFunc<T extends unknown[]> = (
+ ...params: InnerParams<T>
+) => Awaitable<unknown>;
+
+/**
+ * @public
+ */
+export type NodeFor<Selector extends string> =
+ Selector extends keyof HTMLElementTagNameMap
+ ? HTMLElementTagNameMap[Selector]
+ : Selector extends keyof SVGElementTagNameMap
+ ? SVGElementTagNameMap[Selector]
+ : Element;
diff --git a/remote/test/puppeteer/src/common/util.ts b/remote/test/puppeteer/src/common/util.ts
new file mode 100644
index 0000000000..0b280292ee
--- /dev/null
+++ b/remote/test/puppeteer/src/common/util.ts
@@ -0,0 +1,488 @@
+/**
+ * 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 {Protocol} from 'devtools-protocol';
+import type {Readable} from 'stream';
+import {isNode} from '../environment.js';
+import {assert} from '../util/assert.js';
+import {isErrorLike} from '../util/ErrorLike.js';
+import {CDPSession} from './Connection.js';
+import {debug} from './Debug.js';
+import {ElementHandle} from './ElementHandle.js';
+import {TimeoutError} from './Errors.js';
+import {CommonEventEmitter} from './EventEmitter.js';
+import {ExecutionContext} from './ExecutionContext.js';
+import {JSHandle} from './JSHandle.js';
+
+/**
+ * @internal
+ */
+export const debugError = debug('puppeteer:error');
+
+/**
+ * @internal
+ */
+export 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;
+}
+
+/**
+ * @internal
+ */
+export function valueFromRemoteObject(
+ remoteObject: Protocol.Runtime.RemoteObject
+): any {
+ assert(!remoteObject.objectId, 'Cannot extract value when objectId is given');
+ if (remoteObject.unserializableValue) {
+ if (remoteObject.type === 'bigint' && 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;
+}
+
+/**
+ * @internal
+ */
+export async function releaseObject(
+ client: CDPSession,
+ remoteObject: Protocol.Runtime.RemoteObject
+): Promise<void> {
+ if (!remoteObject.objectId) {
+ return;
+ }
+ await client
+ .send('Runtime.releaseObject', {objectId: remoteObject.objectId})
+ .catch(error => {
+ // Exceptions might happen in case of a page been navigated or closed.
+ // Swallow these since they are harmless and we don't leak anything in this case.
+ debugError(error);
+ });
+}
+
+/**
+ * @internal
+ */
+export interface PuppeteerEventListener {
+ emitter: CommonEventEmitter;
+ eventName: string | symbol;
+ handler: (...args: any[]) => void;
+}
+
+/**
+ * @internal
+ */
+export function addEventListener(
+ emitter: CommonEventEmitter,
+ eventName: string | symbol,
+ handler: (...args: any[]) => void
+): PuppeteerEventListener {
+ emitter.on(eventName, handler);
+ return {emitter, eventName, handler};
+}
+
+/**
+ * @internal
+ */
+export 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;
+}
+
+/**
+ * @internal
+ */
+export const isString = (obj: unknown): obj is string => {
+ return typeof obj === 'string' || obj instanceof String;
+};
+
+/**
+ * @internal
+ */
+export const isNumber = (obj: unknown): obj is number => {
+ return typeof obj === 'number' || obj instanceof Number;
+};
+
+/**
+ * @internal
+ */
+export async function waitForEvent<T>(
+ emitter: CommonEventEmitter,
+ eventName: string | symbol,
+ predicate: (event: T) => Promise<boolean> | boolean,
+ timeout: number,
+ abortPromise: Promise<Error>
+): Promise<T> {
+ let eventTimeout: NodeJS.Timeout;
+ let resolveCallback: (value: T | PromiseLike<T>) => void;
+ let rejectCallback: (value: Error) => void;
+ const promise = new Promise<T>((resolve, reject) => {
+ resolveCallback = resolve;
+ rejectCallback = reject;
+ });
+ const listener = addEventListener(emitter, eventName, async event => {
+ if (!(await 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 (isErrorLike(result)) {
+ throw result;
+ }
+
+ return result;
+}
+
+/**
+ * @internal
+ */
+export function createJSHandle(
+ context: ExecutionContext,
+ remoteObject: Protocol.Runtime.RemoteObject
+): JSHandle | ElementHandle<Node> {
+ if (remoteObject.subtype === 'node' && context._world) {
+ return new ElementHandle(context, remoteObject, context._world.frame());
+ }
+ return new JSHandle(context, remoteObject);
+}
+
+/**
+ * @internal
+ */
+export function evaluationString(
+ fun: Function | string,
+ ...args: unknown[]
+): string {
+ if (isString(fun)) {
+ assert(args.length === 0, 'Cannot evaluate a string with arguments');
+ return fun;
+ }
+
+ function serializeArgument(arg: unknown): string {
+ if (Object.is(arg, undefined)) {
+ return 'undefined';
+ }
+ return JSON.stringify(arg);
+ }
+
+ return `(${fun})(${args.map(serializeArgument).join(',')})`;
+}
+
+/**
+ * @internal
+ */
+export 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 as any)[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) => {
+ return callbacks.set(seq, {resolve, reject});
+ });
+ binding(JSON.stringify({type, name: bindingName, seq, args}));
+ return promise;
+ };
+ }
+ return evaluationString(addPageBinding, type, name);
+}
+
+/**
+ * @internal
+ */
+export function pageBindingDeliverResultString(
+ name: string,
+ seq: number,
+ result: unknown
+): string {
+ function deliverResult(name: string, seq: number, result: unknown): void {
+ (window as any)[name].callbacks.get(seq).resolve(result);
+ (window as any)[name].callbacks.delete(seq);
+ }
+ return evaluationString(deliverResult, name, seq, result);
+}
+
+/**
+ * @internal
+ */
+export 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 as any)[name].callbacks.get(seq).reject(error);
+ (window as any)[name].callbacks.delete(seq);
+ }
+ return evaluationString(deliverError, name, seq, message, stack);
+}
+
+/**
+ * @internal
+ */
+export function pageBindingDeliverErrorValueString(
+ name: string,
+ seq: number,
+ value: unknown
+): string {
+ function deliverErrorValue(name: string, seq: number, value: unknown): void {
+ (window as any)[name].callbacks.get(seq).reject(value);
+ (window as any)[name].callbacks.delete(seq);
+ }
+ return evaluationString(deliverErrorValue, name, seq, value);
+}
+
+/**
+ * @internal
+ */
+export function makePredicateString(
+ predicate: Function,
+ predicateQueryHandler: Function
+): string {
+ function checkWaitForOptions(
+ node: Node | null,
+ 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 as Element)
+ : (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);
+ }
+ }
+
+ return `
+ (() => {
+ const predicateQueryHandler = ${predicateQueryHandler};
+ const checkWaitForOptions = ${checkWaitForOptions};
+ return (${predicate})(...args)
+ })() `;
+}
+
+/**
+ * @internal
+ */
+export async function waitWithTimeout<T>(
+ promise: Promise<T>,
+ taskName: string,
+ timeout: number
+): Promise<T> {
+ let reject: (reason?: Error) => void;
+ const timeoutError = new TimeoutError(
+ `waiting for ${taskName} failed: timeout ${timeout}ms exceeded`
+ );
+ const timeoutPromise = new Promise<T>((_res, rej) => {
+ return (reject = rej);
+ });
+ let timeoutTimer = null;
+ if (timeout) {
+ timeoutTimer = setTimeout(() => {
+ return reject(timeoutError);
+ }, timeout);
+ }
+ try {
+ return await Promise.race([promise, timeoutPromise]);
+ } finally {
+ if (timeoutTimer) {
+ clearTimeout(timeoutTimer);
+ }
+ }
+}
+
+/**
+ * @internal
+ */
+let fs: typeof import('fs') | null = null;
+/**
+ * @internal
+ */
+export async function importFS(): Promise<typeof import('fs')> {
+ if (!fs) {
+ fs = await import('fs');
+ }
+ return fs;
+}
+
+/**
+ * @internal
+ */
+export async function getReadableAsBuffer(
+ readable: Readable,
+ path?: string
+): Promise<Buffer | null> {
+ const buffers = [];
+ if (path) {
+ let fs: typeof import('fs').promises;
+ try {
+ fs = (await importFS()).promises;
+ } catch (error) {
+ if (error instanceof TypeError) {
+ throw new Error(
+ 'Cannot write to a path outside of a Node-like environment.'
+ );
+ }
+ throw error;
+ }
+ const fileHandle = await fs.open(path, 'w+');
+ for await (const chunk of readable) {
+ buffers.push(chunk);
+ await fileHandle.writeFile(chunk);
+ }
+ await fileHandle.close();
+ } else {
+ for await (const chunk of readable) {
+ buffers.push(chunk);
+ }
+ }
+ try {
+ return Buffer.concat(buffers);
+ } catch (error) {
+ return null;
+ }
+}
+
+/**
+ * @internal
+ */
+export async function getReadableFromProtocolStream(
+ client: CDPSession,
+ handle: string
+): Promise<Readable> {
+ // TODO: Once Node 18 becomes the lowest supported version, we can migrate to
+ // ReadableStream.
+ if (!isNode) {
+ throw new Error('Cannot create a stream outside of Node.js environment.');
+ }
+
+ const {Readable} = await import('stream');
+
+ let eof = false;
+ return new Readable({
+ async read(size: number) {
+ if (eof) {
+ return;
+ }
+
+ const response = await client.send('IO.read', {handle, size});
+ this.push(response.data, response.base64Encoded ? 'base64' : undefined);
+ if (response.eof) {
+ eof = true;
+ await client.send('IO.close', {handle});
+ this.push(null);
+ }
+ },
+ });
+}
diff --git a/remote/test/puppeteer/src/compat.d.ts b/remote/test/puppeteer/src/compat.d.ts
new file mode 100644
index 0000000000..6c330dc078
--- /dev/null
+++ b/remote/test/puppeteer/src/compat.d.ts
@@ -0,0 +1,3 @@
+declare const puppeteerDirname: string;
+
+export {puppeteerDirname};
diff --git a/remote/test/puppeteer/src/constants.ts b/remote/test/puppeteer/src/constants.ts
new file mode 100644
index 0000000000..d8e562ccec
--- /dev/null
+++ b/remote/test/puppeteer/src/constants.ts
@@ -0,0 +1,7 @@
+import {dirname} from 'path';
+import {puppeteerDirname} from './compat.js';
+
+/**
+ * @internal
+ */
+export const rootDirname = dirname(dirname(dirname(puppeteerDirname)));
diff --git a/remote/test/puppeteer/src/environment.ts b/remote/test/puppeteer/src/environment.ts
new file mode 100644
index 0000000000..80258c67fe
--- /dev/null
+++ b/remote/test/puppeteer/src/environment.ts
@@ -0,0 +1,29 @@
+/**
+ * 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.
+ */
+
+/**
+ * @internal
+ */
+export const isNode = !!(typeof process !== 'undefined' && process.version);
+
+/**
+ * @internal
+ */
+export const DEFERRED_PROMISE_DEBUG_TIMEOUT =
+ typeof process !== 'undefined' &&
+ typeof process.env['PUPPETEER_DEFERRED_PROMISE_DEBUG_TIMEOUT'] !== 'undefined'
+ ? Number(process.env['PUPPETEER_DEFERRED_PROMISE_DEBUG_TIMEOUT'])
+ : -1;
diff --git a/remote/test/puppeteer/src/generated/version.ts b/remote/test/puppeteer/src/generated/version.ts
new file mode 100644
index 0000000000..6b5116dfd5
--- /dev/null
+++ b/remote/test/puppeteer/src/generated/version.ts
@@ -0,0 +1,4 @@
+/**
+ * @internal
+ */
+export const packageVersion = '17.1.2';
diff --git a/remote/test/puppeteer/src/initializePuppeteer.ts b/remote/test/puppeteer/src/initializePuppeteer.ts
new file mode 100644
index 0000000000..8d1e9a5157
--- /dev/null
+++ b/remote/test/puppeteer/src/initializePuppeteer.ts
@@ -0,0 +1,46 @@
+/**
+ * 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 {Product} from './common/Product.js';
+import {rootDirname} from './constants.js';
+import {PuppeteerNode} from './node/Puppeteer.js';
+import {PUPPETEER_REVISIONS} from './revisions.js';
+import {getPackageDirectory} from './util/getPackageDirectory.js';
+
+/**
+ * @internal
+ */
+export const initializePuppeteer = (packageName: string): PuppeteerNode => {
+ const isPuppeteerCore = packageName === 'puppeteer-core';
+ let preferredRevision = PUPPETEER_REVISIONS.chromium;
+ // puppeteer-core ignores environment variables
+ const productName = !isPuppeteerCore
+ ? ((process.env['PUPPETEER_PRODUCT'] ||
+ process.env['npm_config_puppeteer_product'] ||
+ process.env['npm_package_config_puppeteer_product']) as Product)
+ : undefined;
+
+ if (!isPuppeteerCore && productName === 'firefox') {
+ preferredRevision = PUPPETEER_REVISIONS.firefox;
+ }
+
+ return new PuppeteerNode({
+ projectRoot: isPuppeteerCore ? undefined : getPackageDirectory(rootDirname),
+ preferredRevision,
+ isPuppeteerCore,
+ productName,
+ });
+};
diff --git a/remote/test/puppeteer/src/injected/Poller.ts b/remote/test/puppeteer/src/injected/Poller.ts
new file mode 100644
index 0000000000..c6748ebbc4
--- /dev/null
+++ b/remote/test/puppeteer/src/injected/Poller.ts
@@ -0,0 +1,156 @@
+import {
+ createDeferredPromise,
+ DeferredPromise,
+} from '../util/DeferredPromise.js';
+import {assert} from '../util/assert.js';
+
+interface Poller<T> {
+ start(): Promise<T>;
+ stop(): Promise<void>;
+ result(): Promise<T>;
+}
+
+export class MutationPoller<T> implements Poller<T> {
+ #fn: () => Promise<T>;
+
+ #root: Node;
+
+ #observer?: MutationObserver;
+ #promise?: DeferredPromise<T>;
+ constructor(fn: () => Promise<T>, root: Node) {
+ this.#fn = fn;
+ this.#root = root;
+ }
+
+ async start(): Promise<T> {
+ const promise = (this.#promise = createDeferredPromise<T>());
+ const result = await this.#fn();
+ if (result) {
+ promise.resolve(result);
+ return result;
+ }
+
+ this.#observer = new MutationObserver(async () => {
+ const result = await this.#fn();
+ if (!result) {
+ return;
+ }
+ promise.resolve(result);
+ await this.stop();
+ });
+ this.#observer.observe(this.#root, {
+ childList: true,
+ subtree: true,
+ attributes: true,
+ });
+
+ return this.#promise;
+ }
+
+ async stop(): Promise<void> {
+ assert(this.#promise, 'Polling never started.');
+ if (!this.#promise.finished()) {
+ this.#promise.reject(new Error('Polling stopped'));
+ }
+ if (this.#observer) {
+ this.#observer.disconnect();
+ }
+ }
+
+ result(): Promise<T> {
+ assert(this.#promise, 'Polling never started.');
+ return this.#promise;
+ }
+}
+
+export class RAFPoller<T> implements Poller<T> {
+ #fn: () => Promise<T>;
+ #promise?: DeferredPromise<T>;
+ constructor(fn: () => Promise<T>) {
+ this.#fn = fn;
+ }
+
+ async start(): Promise<T> {
+ const promise = (this.#promise = createDeferredPromise<T>());
+ const result = await this.#fn();
+ if (result) {
+ promise.resolve(result);
+ return result;
+ }
+
+ const poll = async () => {
+ if (promise.finished()) {
+ return;
+ }
+ const result = await this.#fn();
+ if (!result) {
+ window.requestAnimationFrame(poll);
+ return;
+ }
+ promise.resolve(result);
+ await this.stop();
+ };
+ window.requestAnimationFrame(poll);
+
+ return this.#promise;
+ }
+
+ async stop(): Promise<void> {
+ assert(this.#promise, 'Polling never started.');
+ if (!this.#promise.finished()) {
+ this.#promise.reject(new Error('Polling stopped'));
+ }
+ }
+
+ result(): Promise<T> {
+ assert(this.#promise, 'Polling never started.');
+ return this.#promise;
+ }
+}
+
+export class IntervalPoller<T> implements Poller<T> {
+ #fn: () => Promise<T>;
+ #ms: number;
+
+ #interval?: NodeJS.Timer;
+ #promise?: DeferredPromise<T>;
+ constructor(fn: () => Promise<T>, ms: number) {
+ this.#fn = fn;
+ this.#ms = ms;
+ }
+
+ async start(): Promise<T> {
+ const promise = (this.#promise = createDeferredPromise<T>());
+ const result = await this.#fn();
+ if (result) {
+ promise.resolve(result);
+ return result;
+ }
+
+ this.#interval = setInterval(async () => {
+ const result = await this.#fn();
+ if (!result) {
+ return;
+ }
+ promise.resolve(result);
+ await this.stop();
+ }, this.#ms);
+
+ return this.#promise;
+ }
+
+ async stop(): Promise<void> {
+ assert(this.#promise, 'Polling never started.');
+ if (!this.#promise.finished()) {
+ this.#promise.reject(new Error('Polling stopped'));
+ }
+ if (this.#interval) {
+ clearInterval(this.#interval);
+ }
+ }
+
+ result(): Promise<T> {
+ assert(this.#promise, 'Polling never started.');
+ return this.#promise;
+ }
+}
diff --git a/remote/test/puppeteer/src/injected/README.md b/remote/test/puppeteer/src/injected/README.md
new file mode 100644
index 0000000000..0a4af11cce
--- /dev/null
+++ b/remote/test/puppeteer/src/injected/README.md
@@ -0,0 +1,5 @@
+# Injected
+
+This folder contains code that is injected into every Puppeteer execution context. Each file is transpiled using esbuild into a script in `src/generated` which is then imported into server code.
+
+See `utils/generate_injected.ts` for more information.
diff --git a/remote/test/puppeteer/src/injected/injected.ts b/remote/test/puppeteer/src/injected/injected.ts
new file mode 100644
index 0000000000..7650a28fd5
--- /dev/null
+++ b/remote/test/puppeteer/src/injected/injected.ts
@@ -0,0 +1,14 @@
+import {createDeferredPromise} from '../util/DeferredPromise.js';
+import * as Poller from './Poller.js';
+import * as util from './util.js';
+
+Object.assign(
+ self,
+ Object.freeze({
+ InjectedUtil: {
+ ...Poller,
+ ...util,
+ createDeferredPromise,
+ },
+ })
+);
diff --git a/remote/test/puppeteer/src/injected/util.ts b/remote/test/puppeteer/src/injected/util.ts
new file mode 100644
index 0000000000..79e68e5e0f
--- /dev/null
+++ b/remote/test/puppeteer/src/injected/util.ts
@@ -0,0 +1,18 @@
+const createdFunctions = new Map<string, (...args: unknown[]) => unknown>();
+
+/**
+ * Creates a function from a string.
+ */
+export const createFunction = (
+ functionValue: string
+): ((...args: unknown[]) => unknown) => {
+ let fn = createdFunctions.get(functionValue);
+ if (fn) {
+ return fn;
+ }
+ fn = new Function(`return ${functionValue}`)() as (
+ ...args: unknown[]
+ ) => unknown;
+ createdFunctions.set(functionValue, fn);
+ return fn;
+};
diff --git a/remote/test/puppeteer/src/node/BrowserFetcher.ts b/remote/test/puppeteer/src/node/BrowserFetcher.ts
new file mode 100644
index 0000000000..7c425c2dbb
--- /dev/null
+++ b/remote/test/puppeteer/src/node/BrowserFetcher.ts
@@ -0,0 +1,701 @@
+/**
+ * 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 createHttpsProxyAgent, {
+ HttpsProxyAgent,
+ HttpsProxyAgentOptions,
+} from 'https-proxy-agent';
+import {getProxyForUrl} from 'proxy-from-env';
+import {assert} from '../util/assert.js';
+
+import tar from 'tar-fs';
+import bzip from 'unbzip2-stream';
+
+const {PUPPETEER_EXPERIMENTAL_CHROMIUM_MAC_ARM} = process.env;
+
+const debugFetcher = debug('puppeteer:fetcher');
+
+const downloadURLs: Record<Product, Partial<Record<Platform, string>>> = {
+ chrome: {
+ linux: '%s/chromium-browser-snapshots/Linux_x64/%d/%s.zip',
+ mac: '%s/chromium-browser-snapshots/Mac/%d/%s.zip',
+ mac_arm: '%s/chromium-browser-snapshots/Mac_Arm/%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',
+ },
+};
+
+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' | 'mac_arm' | 'win32' | 'win64';
+
+function archiveName(
+ product: Product,
+ platform: Platform,
+ revision: string
+): string {
+ switch (product) {
+ case 'chrome':
+ switch (platform) {
+ case 'linux':
+ return 'chrome-linux';
+ case 'mac_arm':
+ case 'mac':
+ return 'chrome-mac';
+ case 'win32':
+ case 'win64':
+ // Windows archive name changed at r591479.
+ return parseInt(revision, 10) > 591479
+ ? 'chrome-win'
+ : 'chrome-win32';
+ }
+ case 'firefox':
+ return platform;
+ }
+}
+
+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;
+}
+
+function handleArm64(): void {
+ let exists = fs.existsSync('/usr/bin/chromium-browser');
+ if (exists) {
+ return;
+ }
+ exists = fs.existsSync('/usr/bin/chromium');
+ if (exists) {
+ return;
+ }
+ console.error(
+ 'The chromium binary is not available for arm64.' +
+ '\nIf you are on Ubuntu, you can install with: ' +
+ '\n\n sudo apt install chromium\n' +
+ '\n\n sudo apt 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 => {
+ return 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:
+ *
+ * ```ts
+ * 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 {
+ #product: Product;
+ #downloadsFolder: string;
+ #downloadHost: string;
+ #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;
+
+ if (options.platform) {
+ this.#platform = options.platform;
+ } else {
+ const platform = os.platform();
+ switch (platform) {
+ case 'darwin':
+ switch (this.#product) {
+ case 'chrome':
+ this.#platform =
+ os.arch() === 'arm64' && PUPPETEER_EXPERIMENTAL_CHROMIUM_MAC_ARM
+ ? 'mac_arm'
+ : 'mac';
+ break;
+ case 'firefox':
+ this.#platform = 'mac';
+ break;
+ }
+ break;
+ case 'linux':
+ this.#platform = 'linux';
+ break;
+ case 'win32':
+ this.#platform = os.arch() === 'x64' ? 'win64' : 'win32';
+ return;
+ default:
+ assert(false, 'Unsupported platform: ' + platform);
+ }
+ }
+
+ assert(
+ downloadURLs[this.#product][this.#platform],
+ 'Unsupported platform: ' + this.#platform
+ );
+ }
+
+ /**
+ * @returns Returns the current `Platform`, which is one of `mac`, `linux`,
+ * `win32` or `win64`.
+ */
+ platform(): Platform {
+ return this.#platform;
+ }
+
+ /**
+ * @returns Returns the current `Product`, which is one of `chrome` or
+ * `firefox`.
+ */
+ 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);
+ },
+ false
+ );
+ 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 | undefined> {
+ const url = downloadURL(
+ this.#product,
+ this.#platform,
+ this.#downloadHost,
+ revision
+ );
+ const fileName = url.split('/').pop();
+ assert(fileName, `A malformed download URL was found: ${url}.`);
+ 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);
+ }
+
+ // Use system Chromium builds on Linux ARM devices
+ if (os.platform() !== 'darwin' && 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 => {
+ return parseFolderPath(this.#product, fileName);
+ })
+ .filter(
+ (
+ entry
+ ): entry is {product: string; platform: string; revision: string} => {
+ return (entry && entry.platform === this.#platform) ?? false;
+ }
+ )
+ .map(entry => {
+ return 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 => {
+ return 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' || this.#platform === 'mac_arm') {
+ 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' || this.#platform === 'mac_arm') {
+ 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,
+ };
+ }
+
+ #getFolderPath(revision: string): string {
+ return path.resolve(this.#downloadsFolder, `${this.#platform}-${revision}`);
+ }
+}
+
+function parseFolderPath(
+ product: Product,
+ folderPath: string
+): {product: string; platform: string; revision: string} | undefined {
+ const name = path.basename(folderPath);
+ const splits = name.split('-');
+ if (splits.length !== 2) {
+ return;
+ }
+ const [platform, revision] = splits;
+ if (!revision || !platform || !(platform in downloadURLs[product])) {
+ return;
+ }
+ 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: (value: void | PromiseLike<void>) => void;
+ let reject: (err: Error) => void;
+ const promise = new Promise<void>((x, y) => {
+ fulfill = x;
+ reject = y;
+ });
+
+ let downloadedBytes = 0;
+ let totalBytes = 0;
+
+ 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', () => {
+ return fulfill();
+ });
+ file.on('error', error => {
+ return reject(error);
+ });
+ response.pipe(file);
+ totalBytes = parseInt(response.headers['content-length']!, 10);
+ if (progressCallback) {
+ response.on('data', onData);
+ }
+ });
+ request.on('error', error => {
+ return 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(() => {
+ return _installDMG(archivePath, folderPath);
+ });
+ } else {
+ throw new Error(`Unsupported archive format: ${archivePath}`);
+ }
+}
+
+/**
+ * @internal
+ */
+function _extractTar(tarPath: string, folderPath: string): Promise<unknown> {
+ 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: string | undefined;
+
+ return new Promise<void>((fulfill, reject): 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.find(item => {
+ return typeof item === 'string' && item.endsWith('.app');
+ });
+ 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);
+ });
+ })
+ .catch(error => {
+ console.error(error);
+ })
+ .finally((): 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}`);
+ }
+ });
+ });
+}
+
+function httpRequest(
+ url: string,
+ method: string,
+ response: (x: http.IncomingMessage) => void,
+ keepAlive = true
+): http.ClientRequest {
+ const urlParsed = URL.parse(url);
+
+ type Options = Partial<URL.UrlWithStringQuery> & {
+ method?: string;
+ agent?: HttpsProxyAgent;
+ rejectUnauthorized?: boolean;
+ headers?: http.OutgoingHttpHeaders | undefined;
+ };
+
+ let options: Options = {
+ ...urlParsed,
+ method,
+ headers: keepAlive
+ ? {
+ Connection: 'keep-alive',
+ }
+ : undefined,
+ };
+
+ 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 HttpsProxyAgentOptions;
+
+ options.agent = createHttpsProxyAgent(proxyOptions);
+ options.rejectUnauthorized = false;
+ }
+ }
+
+ const requestCallback = (res: http.IncomingMessage): void => {
+ if (
+ res.statusCode &&
+ 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..c18350a6da
--- /dev/null
+++ b/remote/test/puppeteer/src/node/BrowserRunner.ts
@@ -0,0 +1,359 @@
+/**
+ * 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 * as childProcess from 'child_process';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as readline from 'readline';
+import removeFolder from 'rimraf';
+import {promisify} from 'util';
+import {assert} from '../util/assert.js';
+import {Connection} from '../common/Connection.js';
+import {debug} from '../common/Debug.js';
+import {TimeoutError} from '../common/Errors.js';
+import {
+ debugError,
+ addEventListener,
+ PuppeteerEventListener,
+ removeEventListeners,
+} from '../common/util.js';
+import {isErrnoException, isErrorLike} from '../util/ErrorLike.js';
+import {Product} from '../common/Product.js';
+import {NodeWebSocketTransport as WebSocketTransport} from '../node/NodeWebSocketTransport.js';
+import {LaunchOptions} from './LaunchOptions.js';
+import {PipeTransport} from './PipeTransport.js';
+
+const removeFolderAsync = promisify(removeFolder);
+const renameAsync = promisify(fs.rename);
+const unlinkAsync = promisify(fs.unlink);
+
+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.`;
+
+/**
+ * @internal
+ */
+export class BrowserRunner {
+ #product: Product;
+ #executablePath: string;
+ #processArguments: string[];
+ #userDataDir: string;
+ #isTempUserDataDir?: boolean;
+ #closed = true;
+ #listeners: PuppeteerEventListener[] = [];
+ #processClosing!: Promise<void>;
+
+ proc?: childProcess.ChildProcess;
+ connection?: Connection;
+
+ constructor(
+ product: Product,
+ executablePath: string,
+ processArguments: string[],
+ userDataDir: string,
+ isTempUserDataDir?: boolean
+ ) {
+ this.#product = product;
+ this.#executablePath = executablePath;
+ this.#processArguments = processArguments;
+ this.#userDataDir = userDataDir;
+ this.#isTempUserDataDir = isTempUserDataDir;
+ }
+
+ start(options: LaunchOptions): void {
+ const {handleSIGINT, handleSIGTERM, handleSIGHUP, dumpio, env, pipe} =
+ options;
+ let stdio: Array<'ignore' | 'pipe'>;
+ if (pipe) {
+ if (dumpio) {
+ stdio = ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'];
+ } else {
+ stdio = ['ignore', 'ignore', 'ignore', 'pipe', 'pipe'];
+ }
+ } else {
+ if (dumpio) {
+ stdio = ['pipe', 'pipe', 'pipe'];
+ } else {
+ stdio = ['pipe', 'ignore', '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, reject) => {
+ this.proc!.once('exit', async () => {
+ this.#closed = true;
+ // Cleanup as processes exit.
+ if (this.#isTempUserDataDir) {
+ try {
+ await removeFolderAsync(this.#userDataDir);
+ fulfill();
+ } catch (error) {
+ debugError(error);
+ reject(error);
+ }
+ } else {
+ if (this.#product === 'firefox') {
+ try {
+ // When an existing user profile has been used remove the user
+ // preferences file and restore possibly backuped preferences.
+ await unlinkAsync(path.join(this.#userDataDir, 'user.js'));
+
+ const prefsBackupPath = path.join(
+ this.#userDataDir,
+ 'prefs.js.puppeteer'
+ );
+ if (fs.existsSync(prefsBackupPath)) {
+ const prefsPath = path.join(this.#userDataDir, 'prefs.js');
+ await unlinkAsync(prefsPath);
+ await renameAsync(prefsBackupPath, prefsPath);
+ }
+ } catch (error) {
+ debugError(error);
+ reject(error);
+ }
+ }
+
+ fulfill();
+ }
+ });
+ });
+ this.#listeners = [addEventListener(process, 'exit', this.kill.bind(this))];
+ if (handleSIGINT) {
+ this.#listeners.push(
+ addEventListener(process, 'SIGINT', () => {
+ this.kill();
+ process.exit(130);
+ })
+ );
+ }
+ if (handleSIGTERM) {
+ this.#listeners.push(
+ addEventListener(process, 'SIGTERM', this.close.bind(this))
+ );
+ }
+ if (handleSIGHUP) {
+ this.#listeners.push(
+ addEventListener(process, 'SIGHUP', this.close.bind(this))
+ );
+ }
+ }
+
+ close(): Promise<void> {
+ if (this.#closed) {
+ return Promise.resolve();
+ }
+ if (this.#isTempUserDataDir) {
+ 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.
+ removeEventListeners(this.#listeners);
+ return this.#processClosing;
+ }
+
+ kill(): void {
+ // 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 && pidExists(this.proc.pid)) {
+ const proc = this.proc;
+ try {
+ if (process.platform === 'win32') {
+ childProcess.exec(`taskkill /pid ${this.proc.pid} /T /F`, error => {
+ if (error) {
+ // taskkill can fail to kill the process e.g. due to missing permissions.
+ // Let's kill the process via Node API. This delays killing of all child
+ // processes of `this.proc` until the main Node.js process dies.
+ proc.kill();
+ }
+ });
+ } else {
+ // on linux the process group can be killed with the group id prefixed with
+ // a minus sign. The process group id is the group leader's pid.
+ const processGroupId = -this.proc.pid;
+
+ try {
+ process.kill(processGroupId, 'SIGKILL');
+ } catch (error) {
+ // Killing the process group can fail due e.g. to missing permissions.
+ // Let's kill the process via Node API. This delays killing of all child
+ // processes of `this.proc` until the main Node.js process dies.
+ proc.kill('SIGKILL');
+ }
+ }
+ } catch (error) {
+ throw new Error(
+ `${PROCESS_ERROR_EXPLANATION}\nError cause: ${
+ isErrorLike(error) ? error.stack : error
+ }`
+ );
+ }
+ }
+
+ // Attempt to remove temporary profile directory to avoid littering.
+ try {
+ if (this.#isTempUserDataDir) {
+ removeFolder.sync(this.#userDataDir);
+ }
+ } catch (error) {}
+
+ // 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.
+ removeEventListeners(this.#listeners);
+ }
+
+ async setupConnection(options: {
+ usePipe?: boolean;
+ timeout: number;
+ slowMo: number;
+ preferredRevision: string;
+ }): Promise<Connection> {
+ assert(this.proc, 'BrowserRunner not started.');
+
+ 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> {
+ assert(browserProcess.stderr, '`browserProcess` does not have stderr.');
+ const rl = readline.createInterface(browserProcess.stderr);
+ let stderr = '';
+
+ return new Promise((resolve, reject) => {
+ const listeners = [
+ addEventListener(rl, 'line', onLine),
+ addEventListener(rl, 'close', () => {
+ return onClose();
+ }),
+ addEventListener(browserProcess, 'exit', () => {
+ return onClose();
+ }),
+ addEventListener(browserProcess, 'error', error => {
+ return onClose(error);
+ }),
+ ];
+ const timeoutId = timeout ? setTimeout(onTimeout, timeout) : 0;
+
+ 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();
+ // The RegExp matches, so this will obviously exist.
+ resolve(match[1]!);
+ }
+
+ function cleanup(): void {
+ if (timeoutId) {
+ clearTimeout(timeoutId);
+ }
+ removeEventListeners(listeners);
+ }
+ });
+}
+
+function pidExists(pid: number): boolean {
+ try {
+ return process.kill(pid, 0);
+ } catch (error) {
+ if (isErrnoException(error)) {
+ if (error.code && error.code === 'ESRCH') {
+ return false;
+ }
+ }
+ throw error;
+ }
+}
diff --git a/remote/test/puppeteer/src/node/ChromeLauncher.ts b/remote/test/puppeteer/src/node/ChromeLauncher.ts
new file mode 100644
index 0000000000..ab07b56f6e
--- /dev/null
+++ b/remote/test/puppeteer/src/node/ChromeLauncher.ts
@@ -0,0 +1,265 @@
+import fs from 'fs';
+import path from 'path';
+import {assert} from '../util/assert.js';
+import {Browser} from '../common/Browser.js';
+import {Product} from '../common/Product.js';
+import {BrowserRunner} from './BrowserRunner.js';
+import {
+ BrowserLaunchArgumentOptions,
+ ChromeReleaseChannel,
+ PuppeteerNodeLaunchOptions,
+} from './LaunchOptions.js';
+import {
+ executablePathForChannel,
+ ProductLauncher,
+ resolveExecutablePath,
+} from './ProductLauncher.js';
+import {tmpdir} from './util.js';
+
+/**
+ * @internal
+ */
+export class ChromeLauncher implements ProductLauncher {
+ /**
+ * @internal
+ */
+ _projectRoot: string | undefined;
+ /**
+ * @internal
+ */
+ _preferredRevision: string;
+ /**
+ * @internal
+ */
+ _isPuppeteerCore: boolean;
+
+ constructor(
+ projectRoot: string | undefined,
+ preferredRevision: string,
+ isPuppeteerCore: boolean
+ ) {
+ this._projectRoot = projectRoot;
+ this._preferredRevision = preferredRevision;
+ this._isPuppeteerCore = isPuppeteerCore;
+ }
+
+ async launch(options: PuppeteerNodeLaunchOptions = {}): Promise<Browser> {
+ const {
+ ignoreDefaultArgs = false,
+ args = [],
+ dumpio = false,
+ channel,
+ executablePath,
+ pipe = false,
+ env = process.env,
+ handleSIGINT = true,
+ handleSIGTERM = true,
+ handleSIGHUP = true,
+ ignoreHTTPSErrors = false,
+ defaultViewport = {width: 800, height: 600},
+ slowMo = 0,
+ timeout = 30000,
+ waitForInitialPage = true,
+ debuggingPort,
+ } = options;
+
+ const chromeArguments = [];
+ if (!ignoreDefaultArgs) {
+ chromeArguments.push(...this.defaultArgs(options));
+ } else if (Array.isArray(ignoreDefaultArgs)) {
+ chromeArguments.push(
+ ...this.defaultArgs(options).filter(arg => {
+ return !ignoreDefaultArgs.includes(arg);
+ })
+ );
+ } else {
+ chromeArguments.push(...args);
+ }
+
+ if (
+ !chromeArguments.some(argument => {
+ return argument.startsWith('--remote-debugging-');
+ })
+ ) {
+ if (pipe) {
+ assert(
+ !debuggingPort,
+ 'Browser should be launched with either pipe or debugging port - not both.'
+ );
+ chromeArguments.push('--remote-debugging-pipe');
+ } else {
+ chromeArguments.push(`--remote-debugging-port=${debuggingPort || 0}`);
+ }
+ }
+
+ let isTempUserDataDir = false;
+
+ // Check for the user data dir argument, which will always be set even
+ // with a custom directory specified via the userDataDir option.
+ let userDataDirIndex = chromeArguments.findIndex(arg => {
+ return arg.startsWith('--user-data-dir');
+ });
+ if (userDataDirIndex < 0) {
+ isTempUserDataDir = true;
+ chromeArguments.push(
+ `--user-data-dir=${await fs.promises.mkdtemp(
+ path.join(tmpdir(), 'puppeteer_dev_chrome_profile-')
+ )}`
+ );
+ userDataDirIndex = chromeArguments.length - 1;
+ }
+
+ const userDataDir = chromeArguments[userDataDirIndex]!.split('=', 2)[1];
+ assert(typeof userDataDir === 'string', '`--user-data-dir` is malformed');
+
+ let chromeExecutable = executablePath;
+ if (channel) {
+ // executablePath is detected by channel, so it should not be specified by user.
+ assert(
+ !chromeExecutable,
+ '`executablePath` must not be specified when `channel` is given.'
+ );
+
+ chromeExecutable = executablePathForChannel(channel);
+ } else if (!chromeExecutable) {
+ const {missingText, executablePath} = resolveExecutablePath(this);
+ if (missingText) {
+ throw new Error(missingText);
+ }
+ chromeExecutable = executablePath;
+ }
+
+ const usePipe = chromeArguments.includes('--remote-debugging-pipe');
+ const runner = new BrowserRunner(
+ this.product,
+ chromeExecutable,
+ chromeArguments,
+ userDataDir,
+ isTempUserDataDir
+ );
+ runner.start({
+ handleSIGHUP,
+ handleSIGTERM,
+ handleSIGINT,
+ dumpio,
+ env,
+ pipe: usePipe,
+ });
+
+ let browser;
+ try {
+ const connection = await runner.setupConnection({
+ usePipe,
+ timeout,
+ slowMo,
+ preferredRevision: this._preferredRevision,
+ });
+ browser = await Browser._create(
+ this.product,
+ connection,
+ [],
+ ignoreHTTPSErrors,
+ defaultViewport,
+ runner.proc,
+ runner.close.bind(runner),
+ options.targetFilter
+ );
+ } catch (error) {
+ runner.kill();
+ throw error;
+ }
+
+ if (waitForInitialPage) {
+ try {
+ await browser.waitForTarget(
+ t => {
+ return t.type() === 'page';
+ },
+ {timeout}
+ );
+ } catch (error) {
+ await browser.close();
+ throw error;
+ }
+ }
+
+ return browser;
+ }
+
+ defaultArgs(options: BrowserLaunchArgumentOptions = {}): string[] {
+ const chromeArguments = [
+ '--allow-pre-commit-input',
+ '--disable-background-networking',
+ '--enable-features=NetworkServiceInProcess2',
+ '--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',
+ // TODO: remove AvoidUnnecessaryBeforeUnloadCheckSync below
+ // once crbug.com/1324138 is fixed and released.
+ // AcceptCHFrame disabled because of crbug.com/1348106.
+ '--disable-features=Translate,BackForwardCache,AcceptCHFrame,AvoidUnnecessaryBeforeUnloadCheckSync',
+ '--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',
+ '--export-tagged-pdf',
+ ];
+ const {
+ devtools = false,
+ headless = !devtools,
+ args = [],
+ userDataDir,
+ } = options;
+ if (userDataDir) {
+ chromeArguments.push(`--user-data-dir=${path.resolve(userDataDir)}`);
+ }
+ if (devtools) {
+ chromeArguments.push('--auto-open-devtools-for-tabs');
+ }
+ if (headless) {
+ chromeArguments.push(
+ headless === 'chrome' ? '--headless=chrome' : '--headless',
+ '--hide-scrollbars',
+ '--mute-audio'
+ );
+ }
+ if (
+ args.every(arg => {
+ return arg.startsWith('-');
+ })
+ ) {
+ chromeArguments.push('about:blank');
+ }
+ chromeArguments.push(...args);
+ return chromeArguments;
+ }
+
+ executablePath(channel?: ChromeReleaseChannel): string {
+ if (channel) {
+ return executablePathForChannel(channel);
+ } else {
+ const results = resolveExecutablePath(this);
+ return results.executablePath;
+ }
+ }
+
+ get product(): Product {
+ return 'chrome';
+ }
+}
diff --git a/remote/test/puppeteer/src/node/FirefoxLauncher.ts b/remote/test/puppeteer/src/node/FirefoxLauncher.ts
new file mode 100644
index 0000000000..c27f1b8d8b
--- /dev/null
+++ b/remote/test/puppeteer/src/node/FirefoxLauncher.ts
@@ -0,0 +1,502 @@
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+import {assert} from '../util/assert.js';
+import {Browser} from '../common/Browser.js';
+import {Product} from '../common/Product.js';
+import {BrowserFetcher} from './BrowserFetcher.js';
+import {BrowserRunner} from './BrowserRunner.js';
+import {
+ BrowserLaunchArgumentOptions,
+ PuppeteerNodeLaunchOptions,
+} from './LaunchOptions.js';
+import {ProductLauncher, resolveExecutablePath} from './ProductLauncher.js';
+import {tmpdir} from './util.js';
+
+/**
+ * @internal
+ */
+export class FirefoxLauncher implements ProductLauncher {
+ /**
+ * @internal
+ */
+ _projectRoot: string | undefined;
+ /**
+ * @internal
+ */
+ _preferredRevision: string;
+ /**
+ * @internal
+ */
+ _isPuppeteerCore: boolean;
+
+ constructor(
+ projectRoot: string | undefined,
+ preferredRevision: string,
+ isPuppeteerCore: boolean
+ ) {
+ this._projectRoot = projectRoot;
+ this._preferredRevision = preferredRevision;
+ this._isPuppeteerCore = isPuppeteerCore;
+ }
+
+ async launch(options: PuppeteerNodeLaunchOptions = {}): 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 = {},
+ waitForInitialPage = true,
+ debuggingPort = null,
+ } = options;
+
+ const firefoxArguments = [];
+ if (!ignoreDefaultArgs) {
+ firefoxArguments.push(...this.defaultArgs(options));
+ } else if (Array.isArray(ignoreDefaultArgs)) {
+ firefoxArguments.push(
+ ...this.defaultArgs(options).filter(arg => {
+ return !ignoreDefaultArgs.includes(arg);
+ })
+ );
+ } else {
+ firefoxArguments.push(...args);
+ }
+
+ if (
+ !firefoxArguments.some(argument => {
+ return argument.startsWith('--remote-debugging-');
+ })
+ ) {
+ if (pipe) {
+ assert(
+ debuggingPort === null,
+ 'Browser should be launched with either pipe or debugging port - not both.'
+ );
+ }
+ firefoxArguments.push(`--remote-debugging-port=${debuggingPort || 0}`);
+ }
+
+ let userDataDir: string | undefined;
+ let isTempUserDataDir = true;
+
+ // Check for the profile argument, which will always be set even
+ // with a custom directory specified via the userDataDir option.
+ const profileArgIndex = firefoxArguments.findIndex(arg => {
+ return ['-profile', '--profile'].includes(arg);
+ });
+
+ if (profileArgIndex !== -1) {
+ userDataDir = firefoxArguments[profileArgIndex + 1];
+ if (!userDataDir || !fs.existsSync(userDataDir)) {
+ throw new Error(`Firefox profile not found at '${userDataDir}'`);
+ }
+
+ // When using a custom Firefox profile it needs to be populated
+ // with required preferences.
+ isTempUserDataDir = false;
+ const prefs = this.defaultPreferences(extraPrefsFirefox);
+ this.writePreferences(prefs, userDataDir);
+ } else {
+ userDataDir = await this._createProfile(extraPrefsFirefox);
+ firefoxArguments.push('--profile');
+ firefoxArguments.push(userDataDir);
+ }
+
+ await this._updateRevision();
+ let firefoxExecutable = executablePath;
+ if (!executablePath) {
+ const {missingText, executablePath} = resolveExecutablePath(this);
+ if (missingText) {
+ throw new Error(missingText);
+ }
+ firefoxExecutable = executablePath;
+ }
+
+ if (!firefoxExecutable) {
+ throw new Error('firefoxExecutable is not found.');
+ }
+
+ const runner = new BrowserRunner(
+ this.product,
+ firefoxExecutable,
+ firefoxArguments,
+ userDataDir,
+ isTempUserDataDir
+ );
+ runner.start({
+ handleSIGHUP,
+ handleSIGTERM,
+ handleSIGINT,
+ dumpio,
+ env,
+ pipe,
+ });
+
+ let browser;
+ try {
+ const connection = await runner.setupConnection({
+ usePipe: pipe,
+ timeout,
+ slowMo,
+ preferredRevision: this._preferredRevision,
+ });
+ browser = await Browser._create(
+ this.product,
+ connection,
+ [],
+ ignoreHTTPSErrors,
+ defaultViewport,
+ runner.proc,
+ runner.close.bind(runner),
+ options.targetFilter
+ );
+ } catch (error) {
+ runner.kill();
+ throw error;
+ }
+
+ if (waitForInitialPage) {
+ try {
+ await browser.waitForTarget(
+ t => {
+ return t.type() === 'page';
+ },
+ {timeout}
+ );
+ } catch (error) {
+ await browser.close();
+ throw error;
+ }
+ }
+
+ return browser;
+ }
+
+ executablePath(): string {
+ return resolveExecutablePath(this).executablePath;
+ }
+
+ async _updateRevision(): Promise<void> {
+ // replace 'latest' placeholder with actual downloaded revision
+ if (this._preferredRevision === 'latest') {
+ if (!this._projectRoot) {
+ throw new Error(
+ '_projectRoot is undefined. Unable to create a BrowserFetcher.'
+ );
+ }
+ 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: BrowserLaunchArgumentOptions = {}): string[] {
+ const {
+ devtools = false,
+ headless = !devtools,
+ args = [],
+ userDataDir = null,
+ } = options;
+
+ const firefoxArguments = ['--no-remote'];
+
+ switch (os.platform()) {
+ case 'darwin':
+ firefoxArguments.push('--foreground');
+ break;
+ case 'win32':
+ firefoxArguments.push('--wait-for-browser');
+ break;
+ }
+ if (userDataDir) {
+ firefoxArguments.push('--profile');
+ firefoxArguments.push(userDataDir);
+ }
+ if (headless) {
+ firefoxArguments.push('--headless');
+ }
+ if (devtools) {
+ firefoxArguments.push('--devtools');
+ }
+ if (
+ args.every(arg => {
+ return arg.startsWith('-');
+ })
+ ) {
+ firefoxArguments.push('about:blank');
+ }
+ firefoxArguments.push(...args);
+ return firefoxArguments;
+ }
+
+ defaultPreferences(extraPrefs: {[x: string]: unknown}): {
+ [x: string]: unknown;
+ } {
+ const server = 'dummy.test';
+
+ const defaultPrefs = {
+ // 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`,
+
+ // Temporarily force disable BFCache in parent (https://bit.ly/bug-1732263)
+ 'fission.bfcacheInParent': false,
+
+ // Force all web content to use a single content process
+ 'fission.webContentIsolationStrategy': 0,
+
+ // 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,
+
+ // Can be removed once Firefox 89 is no longer supported
+ // https://bugzilla.mozilla.org/show_bug.cgi?id=1710839
+ '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,
+ };
+
+ return Object.assign(defaultPrefs, extraPrefs);
+ }
+
+ /**
+ * Populates the user.js file with custom preferences as needed to allow
+ * Firefox's CDP support to properly function. These preferences will be
+ * automatically copied over to prefs.js during startup of Firefox. To be
+ * able to restore the original values of preferences a backup of prefs.js
+ * will be created.
+ *
+ * @param prefs - List of preferences to add.
+ * @param profilePath - Firefox profile to write the preferences to.
+ */
+ async writePreferences(
+ prefs: {[x: string]: unknown},
+ profilePath: string
+ ): Promise<void> {
+ const lines = Object.entries(prefs).map(([key, value]) => {
+ return `user_pref(${JSON.stringify(key)}, ${JSON.stringify(value)});`;
+ });
+
+ await fs.promises.writeFile(
+ path.join(profilePath, 'user.js'),
+ lines.join('\n')
+ );
+
+ // Create a backup of the preferences file if it already exitsts.
+ const prefsPath = path.join(profilePath, 'prefs.js');
+ if (fs.existsSync(prefsPath)) {
+ const prefsBackupPath = path.join(profilePath, 'prefs.js.puppeteer');
+ await fs.promises.copyFile(prefsPath, prefsBackupPath);
+ }
+ }
+
+ async _createProfile(extraPrefs: {[x: string]: unknown}): Promise<string> {
+ const temporaryProfilePath = await fs.promises.mkdtemp(
+ path.join(tmpdir(), 'puppeteer_dev_firefox_profile-')
+ );
+
+ const prefs = this.defaultPreferences(extraPrefs);
+ await this.writePreferences(prefs, temporaryProfilePath);
+
+ return temporaryProfilePath;
+ }
+}
diff --git a/remote/test/puppeteer/src/node/LaunchOptions.ts b/remote/test/puppeteer/src/node/LaunchOptions.ts
new file mode 100644
index 0000000000..ab10642d1b
--- /dev/null
+++ b/remote/test/puppeteer/src/node/LaunchOptions.ts
@@ -0,0 +1,144 @@
+/**
+ * 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 {BrowserConnectOptions} from '../common/BrowserConnector.js';
+import {Product} from '../common/Product.js';
+
+/**
+ * Launcher options that only apply to Chrome.
+ *
+ * @public
+ */
+export interface BrowserLaunchArgumentOptions {
+ /**
+ * Whether to run the browser in headless mode.
+ * @defaultValue true
+ */
+ headless?: boolean | 'chrome';
+ /**
+ * Path to a user data directory.
+ * {@link https://chromium.googlesource.com/chromium/src/+/refs/heads/main/docs/user_data_dir.md | see the Chromium docs}
+ * for more info.
+ */
+ userDataDir?: string;
+ /**
+ * Whether to auto-open a DevTools panel for each tab. If this is set to
+ * `true`, then `headless` will be forced to `false`.
+ * @defaultValue `false`
+ */
+ devtools?: boolean;
+ /**
+ *
+ */
+ debuggingPort?: number;
+ /**
+ * Additional command line arguments to pass to the browser instance.
+ */
+ args?: string[];
+}
+/**
+ * @public
+ */
+export type ChromeReleaseChannel =
+ | 'chrome'
+ | 'chrome-beta'
+ | 'chrome-canary'
+ | 'chrome-dev';
+
+/**
+ * Generic launch options that can be passed when launching any browser.
+ * @public
+ */
+export interface LaunchOptions {
+ /**
+ * Chrome Release Channel
+ */
+ channel?: ChromeReleaseChannel;
+ /**
+ * Path to a browser executable to use instead of the bundled Chromium. Note
+ * that Puppeteer is only guaranteed to work with the bundled Chromium, so use
+ * this setting at your own risk.
+ */
+ executablePath?: string;
+ /**
+ * If `true`, do not use `puppeteer.defaultArgs()` when creating a browser. If
+ * an array is provided, these args will be filtered out. Use this with care -
+ * you probably want the default arguments Puppeteer uses.
+ * @defaultValue false
+ */
+ ignoreDefaultArgs?: boolean | string[];
+ /**
+ * Close the browser process on `Ctrl+C`.
+ * @defaultValue `true`
+ */
+ handleSIGINT?: boolean;
+ /**
+ * Close the browser process on `SIGTERM`.
+ * @defaultValue `true`
+ */
+ handleSIGTERM?: boolean;
+ /**
+ * Close the browser process on `SIGHUP`.
+ * @defaultValue `true`
+ */
+ handleSIGHUP?: boolean;
+ /**
+ * Maximum time in milliseconds to wait for the browser to start.
+ * Pass `0` to disable the timeout.
+ * @defaultValue 30000 (30 seconds).
+ */
+ timeout?: number;
+ /**
+ * If true, pipes the browser process stdout and stderr to `process.stdout`
+ * and `process.stderr`.
+ * @defaultValue false
+ */
+ dumpio?: boolean;
+ /**
+ * Specify environment variables that will be visible to the browser.
+ * @defaultValue The contents of `process.env`.
+ */
+ env?: Record<string, string | undefined>;
+ /**
+ * Connect to a browser over a pipe instead of a WebSocket.
+ * @defaultValue false
+ */
+ pipe?: boolean;
+ /**
+ * Which browser to launch.
+ * @defaultValue `chrome`
+ */
+ product?: Product;
+ /**
+ * {@link https://searchfox.org/mozilla-release/source/modules/libpref/init/all.js | Additional preferences } that can be passed when launching with Firefox.
+ */
+ extraPrefsFirefox?: Record<string, unknown>;
+ /**
+ * Whether to wait for the initial page to be ready.
+ * Useful when a user explicitly disables that (e.g. `--no-startup-window` for Chrome).
+ * @defaultValue true
+ */
+ waitForInitialPage?: boolean;
+}
+
+/**
+ * Utility type exposed to enable users to define options that can be passed to
+ * `puppeteer.launch` without having to list the set of all types.
+ * @public
+ */
+export type PuppeteerNodeLaunchOptions = BrowserLaunchArgumentOptions &
+ LaunchOptions &
+ BrowserConnectOptions;
diff --git a/remote/test/puppeteer/src/node/NodeWebSocketTransport.ts b/remote/test/puppeteer/src/node/NodeWebSocketTransport.ts
new file mode 100644
index 0000000000..fbe8e7c92c
--- /dev/null
+++ b/remote/test/puppeteer/src/node/NodeWebSocketTransport.ts
@@ -0,0 +1,69 @@
+/**
+ * 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 NodeWebSocket from 'ws';
+import {ConnectionTransport} from '../common/ConnectionTransport.js';
+import {packageVersion} from '../generated/version.js';
+
+/**
+ * @internal
+ */
+export class NodeWebSocketTransport implements ConnectionTransport {
+ static create(url: string): Promise<NodeWebSocketTransport> {
+ return new Promise((resolve, reject) => {
+ const ws = new NodeWebSocket(url, [], {
+ followRedirects: true,
+ perMessageDeflate: false,
+ maxPayload: 256 * 1024 * 1024, // 256Mb
+ headers: {
+ 'User-Agent': `Puppeteer ${packageVersion}`,
+ },
+ });
+
+ ws.addEventListener('open', () => {
+ return resolve(new NodeWebSocketTransport(ws));
+ });
+ ws.addEventListener('error', reject);
+ });
+ }
+
+ #ws: NodeWebSocket;
+ onmessage?: (message: NodeWebSocket.Data) => void;
+ onclose?: () => void;
+
+ constructor(ws: NodeWebSocket) {
+ this.#ws = ws;
+ this.#ws.addEventListener('message', event => {
+ if (this.onmessage) {
+ this.onmessage.call(null, event.data);
+ }
+ });
+ this.#ws.addEventListener('close', () => {
+ if (this.onclose) {
+ this.onclose.call(null);
+ }
+ });
+ // Silently ignore all errors - we don't know what to do with them.
+ this.#ws.addEventListener('error', () => {});
+ }
+
+ send(message: string): void {
+ this.#ws.send(message);
+ }
+
+ close(): void {
+ this.#ws.close();
+ }
+}
diff --git a/remote/test/puppeteer/src/node/PipeTransport.ts b/remote/test/puppeteer/src/node/PipeTransport.ts
new file mode 100644
index 0000000000..3b7b042b0e
--- /dev/null
+++ b/remote/test/puppeteer/src/node/PipeTransport.ts
@@ -0,0 +1,93 @@
+/**
+ * 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 {assert} from '../util/assert.js';
+import {ConnectionTransport} from '../common/ConnectionTransport.js';
+import {
+ addEventListener,
+ debugError,
+ PuppeteerEventListener,
+ removeEventListeners,
+} from '../common/util.js';
+
+/**
+ * @internal
+ */
+export class PipeTransport implements ConnectionTransport {
+ #pipeWrite: NodeJS.WritableStream;
+ #eventListeners: PuppeteerEventListener[];
+
+ #isClosed = false;
+ #pendingMessage = '';
+
+ onclose?: () => void;
+ onmessage?: (value: string) => void;
+
+ constructor(
+ pipeWrite: NodeJS.WritableStream,
+ pipeRead: NodeJS.ReadableStream
+ ) {
+ this.#pipeWrite = pipeWrite;
+ this.#eventListeners = [
+ addEventListener(pipeRead, 'data', buffer => {
+ return this.#dispatch(buffer);
+ }),
+ addEventListener(pipeRead, 'close', () => {
+ if (this.onclose) {
+ this.onclose.call(null);
+ }
+ }),
+ addEventListener(pipeRead, 'error', debugError),
+ addEventListener(pipeWrite, 'error', debugError),
+ ];
+ }
+
+ send(message: string): void {
+ assert(!this.#isClosed, '`PipeTransport` is closed.');
+
+ this.#pipeWrite.write(message);
+ this.#pipeWrite.write('\0');
+ }
+
+ #dispatch(buffer: Buffer): void {
+ assert(!this.#isClosed, '`PipeTransport` is closed.');
+
+ let end = buffer.indexOf('\0');
+ if (end === -1) {
+ this.#pendingMessage += buffer.toString();
+ return;
+ }
+ const message = this.#pendingMessage + buffer.toString(undefined, 0, end);
+ if (this.onmessage) {
+ this.onmessage.call(null, message);
+ }
+
+ let start = end + 1;
+ end = buffer.indexOf('\0', start);
+ while (end !== -1) {
+ if (this.onmessage) {
+ this.onmessage.call(null, buffer.toString(undefined, start, end));
+ }
+ start = end + 1;
+ end = buffer.indexOf('\0', start);
+ }
+ this.#pendingMessage = buffer.toString(undefined, start);
+ }
+
+ close(): void {
+ this.#isClosed = true;
+ removeEventListeners(this.#eventListeners);
+ }
+}
diff --git a/remote/test/puppeteer/src/node/ProductLauncher.ts b/remote/test/puppeteer/src/node/ProductLauncher.ts
new file mode 100644
index 0000000000..a1d7c84f46
--- /dev/null
+++ b/remote/test/puppeteer/src/node/ProductLauncher.ts
@@ -0,0 +1,217 @@
+/**
+ * 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 os from 'os';
+
+import {Browser} from '../common/Browser.js';
+import {BrowserFetcher} from './BrowserFetcher.js';
+
+import {
+ BrowserLaunchArgumentOptions,
+ ChromeReleaseChannel,
+ PuppeteerNodeLaunchOptions,
+} from './LaunchOptions.js';
+
+import {Product} from '../common/Product.js';
+import {ChromeLauncher} from './ChromeLauncher.js';
+import {FirefoxLauncher} from './FirefoxLauncher.js';
+import {accessSync, existsSync} from 'fs';
+
+/**
+ * Describes a launcher - a class that is able to create and launch a browser instance.
+ * @public
+ */
+export interface ProductLauncher {
+ launch(object: PuppeteerNodeLaunchOptions): Promise<Browser>;
+ executablePath: (path?: any) => string;
+ defaultArgs(object: BrowserLaunchArgumentOptions): string[];
+ product: Product;
+}
+
+/**
+ * @internal
+ */
+export function executablePathForChannel(
+ channel: ChromeReleaseChannel
+): string {
+ const platform = os.platform();
+
+ let chromePath: string | undefined;
+ switch (platform) {
+ case 'win32':
+ switch (channel) {
+ case 'chrome':
+ chromePath = `${process.env['PROGRAMFILES']}\\Google\\Chrome\\Application\\chrome.exe`;
+ break;
+ case 'chrome-beta':
+ chromePath = `${process.env['PROGRAMFILES']}\\Google\\Chrome Beta\\Application\\chrome.exe`;
+ break;
+ case 'chrome-canary':
+ chromePath = `${process.env['PROGRAMFILES']}\\Google\\Chrome SxS\\Application\\chrome.exe`;
+ break;
+ case 'chrome-dev':
+ chromePath = `${process.env['PROGRAMFILES']}\\Google\\Chrome Dev\\Application\\chrome.exe`;
+ break;
+ }
+ break;
+ case 'darwin':
+ switch (channel) {
+ case 'chrome':
+ chromePath =
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
+ break;
+ case 'chrome-beta':
+ chromePath =
+ '/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta';
+ break;
+ case 'chrome-canary':
+ chromePath =
+ '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary';
+ break;
+ case 'chrome-dev':
+ chromePath =
+ '/Applications/Google Chrome Dev.app/Contents/MacOS/Google Chrome Dev';
+ break;
+ }
+ break;
+ case 'linux':
+ switch (channel) {
+ case 'chrome':
+ chromePath = '/opt/google/chrome/chrome';
+ break;
+ case 'chrome-beta':
+ chromePath = '/opt/google/chrome-beta/chrome';
+ break;
+ case 'chrome-dev':
+ chromePath = '/opt/google/chrome-unstable/chrome';
+ break;
+ }
+ break;
+ }
+
+ if (!chromePath) {
+ throw new Error(
+ `Unable to detect browser executable path for '${channel}' on ${platform}.`
+ );
+ }
+
+ // Check if Chrome exists and is accessible.
+ try {
+ accessSync(chromePath);
+ } catch (error) {
+ throw new Error(
+ `Could not find Google Chrome executable for channel '${channel}' at '${chromePath}'.`
+ );
+ }
+
+ return chromePath;
+}
+
+/**
+ * @internal
+ */
+export function resolveExecutablePath(
+ launcher: ChromeLauncher | FirefoxLauncher
+): {
+ executablePath: string;
+ missingText?: string;
+} {
+ const {product, _isPuppeteerCore, _projectRoot, _preferredRevision} =
+ launcher;
+ let downloadPath: string | undefined;
+ // puppeteer-core doesn't take into account PUPPETEER_* env variables.
+ if (!_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 = !existsSync(executablePath)
+ ? 'Tried to use PUPPETEER_EXECUTABLE_PATH env variable to launch browser but did not find any executable at: ' +
+ executablePath
+ : undefined;
+ return {executablePath, missingText};
+ }
+ const ubuntuChromiumPath = '/usr/bin/chromium-browser';
+ if (
+ product === 'chrome' &&
+ os.platform() !== 'darwin' &&
+ os.arch() === 'arm64' &&
+ existsSync(ubuntuChromiumPath)
+ ) {
+ return {executablePath: ubuntuChromiumPath, missingText: undefined};
+ }
+ downloadPath =
+ process.env['PUPPETEER_DOWNLOAD_PATH'] ||
+ process.env['npm_config_puppeteer_download_path'] ||
+ process.env['npm_package_config_puppeteer_download_path'];
+ }
+ if (!_projectRoot) {
+ throw new Error(
+ '_projectRoot is undefined. Unable to create a BrowserFetcher.'
+ );
+ }
+ const browserFetcher = new BrowserFetcher(_projectRoot, {
+ product: product,
+ path: downloadPath,
+ });
+
+ if (!_isPuppeteerCore && 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
+ : undefined;
+ return {executablePath: revisionInfo.executablePath, missingText};
+ }
+ }
+ const revisionInfo = browserFetcher.revisionInfo(_preferredRevision);
+
+ const firefoxHelp = `Run \`PUPPETEER_PRODUCT=firefox npm install\` to download a supported Firefox browser binary.`;
+ const chromeHelp = `Run \`npm install\` to download the correct Chromium revision (${launcher._preferredRevision}).`;
+ const missingText = !revisionInfo.local
+ ? `Could not find expected browser (${product}) locally. ${
+ product === 'chrome' ? chromeHelp : firefoxHelp
+ }`
+ : undefined;
+ return {executablePath: revisionInfo.executablePath, missingText};
+}
+
+/**
+ * @internal
+ */
+export function createLauncher(
+ projectRoot: string | undefined,
+ preferredRevision: string,
+ isPuppeteerCore: boolean,
+ product: Product = 'chrome'
+): ProductLauncher {
+ switch (product) {
+ case 'firefox':
+ return new FirefoxLauncher(
+ projectRoot,
+ preferredRevision,
+ isPuppeteerCore
+ );
+ case 'chrome':
+ return new ChromeLauncher(
+ projectRoot,
+ preferredRevision,
+ isPuppeteerCore
+ );
+ }
+}
diff --git a/remote/test/puppeteer/src/node/Puppeteer.ts b/remote/test/puppeteer/src/node/Puppeteer.ts
new file mode 100644
index 0000000000..374b861648
--- /dev/null
+++ b/remote/test/puppeteer/src/node/Puppeteer.ts
@@ -0,0 +1,244 @@
+/**
+ * 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, BrowserLaunchArgumentOptions} from './LaunchOptions.js';
+import {BrowserConnectOptions} from '../common/BrowserConnector.js';
+import {Browser} from '../common/Browser.js';
+import {createLauncher, ProductLauncher} from './ProductLauncher.js';
+import {PUPPETEER_REVISIONS} from '../revisions.js';
+import {Product} from '../common/Product.js';
+
+/**
+ * @public
+ */
+export interface PuppeteerLaunchOptions
+ extends LaunchOptions,
+ BrowserLaunchArgumentOptions,
+ BrowserConnectOptions {
+ product?: Product;
+ extraPrefsFirefox?: Record<string, unknown>;
+}
+
+/**
+ * Extends the main {@link Puppeteer} class with Node specific behaviour for
+ * fetching and downloading browsers.
+ *
+ * If you're using Puppeteer in a Node environment, this is the class you'll get
+ * when you run `require('puppeteer')` (or the equivalent ES `import`).
+ *
+ * @remarks
+ * The most common method to use is {@link PuppeteerNode.launch | launch}, which
+ * is used to launch and connect to a new browser instance.
+ *
+ * See {@link Puppeteer | the main Puppeteer class} for methods common to all
+ * environments, such as {@link Puppeteer.connect}.
+ *
+ * @example
+ * The following is a typical example of using Puppeteer to drive automation:
+ *
+ * ```ts
+ * 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 {
+ #launcher?: ProductLauncher;
+ #projectRoot?: string;
+ #productName?: Product;
+
+ _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.connect = this.connect.bind(this);
+ this.launch = this.launch.bind(this);
+ this.executablePath = this.executablePath.bind(this);
+ this.defaultArgs = this.defaultArgs.bind(this);
+ this.createBrowserFetcher = this.createBrowserFetcher.bind(this);
+ }
+
+ /**
+ * This method attaches Puppeteer to an existing browser instance.
+ *
+ * @param options - Set of configurable options to set on the browser.
+ * @returns Promise which resolves to browser instance.
+ */
+ override connect(options: ConnectOptions): Promise<Browser> {
+ return super.connect(options);
+ }
+
+ /**
+ * @internal
+ */
+ get _productName(): Product | undefined {
+ return this.#productName;
+ }
+ set _productName(name: Product | undefined) {
+ if (this.#productName !== name) {
+ this._changedProduct = true;
+ }
+ this.#productName = name;
+ }
+
+ /**
+ * Launches puppeteer and launches a browser instance with given arguments and
+ * options when specified.
+ *
+ * @example
+ * You can use `ignoreDefaultArgs` to filter out `--mute-audio` from default arguments:
+ *
+ * ```ts
+ * const browser = await puppeteer.launch({
+ * ignoreDefaultArgs: ['--mute-audio'],
+ * });
+ * ```
+ *
+ * @remarks
+ * **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: PuppeteerLaunchOptions = {}): 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(channel?: string): string {
+ return this._launcher.executablePath(channel);
+ }
+
+ /**
+ * @internal
+ */
+ get _launcher(): ProductLauncher {
+ if (
+ !this.#launcher ||
+ this.#launcher.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.#launcher = createLauncher(
+ this.#projectRoot,
+ this._preferredRevision,
+ this._isPuppeteerCore,
+ this._productName
+ );
+ }
+ return this.#launcher;
+ }
+
+ /**
+ * 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: BrowserLaunchArgumentOptions = {}): 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 {
+ if (!this.#projectRoot) {
+ throw new Error(
+ '_projectRoot is undefined. Unable to create a 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..9480a33470
--- /dev/null
+++ b/remote/test/puppeteer/src/node/install.ts
@@ -0,0 +1,232 @@
+/**
+ * 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 https, {RequestOptions} from 'https';
+import ProgressBar from 'progress';
+import URL from 'url';
+import puppeteer from '../puppeteer.js';
+import {PUPPETEER_REVISIONS} from '../revisions.js';
+import {PuppeteerNode} from './Puppeteer.js';
+import createHttpsProxyAgent, {HttpsProxyAgentOptions} from 'https-proxy-agent';
+import {getProxyForUrl} from 'proxy-from-env';
+
+/**
+ * @internal
+ */
+const supportedProducts = {
+ chrome: 'Chromium',
+ firefox: 'Firefox Nightly',
+} as const;
+
+/**
+ * @internal
+ */
+function getProduct(input: string): 'chrome' | 'firefox' {
+ if (input !== 'chrome' && input !== 'firefox') {
+ throw new Error(`Unsupported product ${input}`);
+ }
+ return input;
+}
+
+/**
+ * @internal
+ */
+export async function downloadBrowser(): Promise<void> {
+ const downloadHost =
+ process.env['PUPPETEER_DOWNLOAD_HOST'] ||
+ process.env['npm_config_puppeteer_download_host'] ||
+ process.env['npm_package_config_puppeteer_download_host'];
+ const product = getProduct(
+ 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);
+
+ async function getRevision(): Promise<string> {
+ 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: string) {
+ 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 {
+ logPolitely(
+ `${supportedProducts[product]} (${revisionInfo.revision}) downloaded to ${revisionInfo.folderPath}`
+ );
+ localRevisions = localRevisions.filter(revision => {
+ return revision !== revisionInfo.revision;
+ });
+ const cleanupOldVersions = localRevisions.map(revision => {
+ return 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: ProgressBar | null = null;
+ let lastDownloadedBytes = 0;
+ function onProgress(downloadedBytes: number, totalBytes: number) {
+ 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(() => {
+ return browserFetcher.localRevisions();
+ })
+ .then(onSuccess)
+ .catch(onError);
+ }
+
+ function toMegabytes(bytes: number) {
+ const mb = bytes / 1024 / 1024;
+ return `${Math.round(mb * 10) / 10} Mb`;
+ }
+
+ async function getFirefoxNightlyVersion(): Promise<string> {
+ const firefoxVersionsUrl =
+ 'https://product-details.mozilla.org/1.0/firefox_versions.json';
+
+ const proxyURL = getProxyForUrl(firefoxVersionsUrl);
+
+ const requestOptions: RequestOptions = {};
+
+ if (proxyURL) {
+ const parsedProxyURL = URL.parse(proxyURL);
+
+ const proxyOptions = {
+ ...parsedProxyURL,
+ secureProxy: parsedProxyURL.protocol === 'https:',
+ } as HttpsProxyAgentOptions;
+
+ requestOptions.agent = createHttpsProxyAgent(proxyOptions);
+ requestOptions.rejectUnauthorized = false;
+ }
+
+ const promise = new Promise<string>((resolve, reject) => {
+ let data = '';
+ logPolitely(
+ `Requesting latest Firefox Nightly version from ${firefoxVersionsUrl}`
+ );
+ https
+ .get(firefoxVersionsUrl, requestOptions, r => {
+ if (r.statusCode && 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;
+ }
+}
+
+/**
+ * @internal
+ */
+export function logPolitely(toBeLogged: unknown): void {
+ 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/node/util.ts b/remote/test/puppeteer/src/node/util.ts
new file mode 100644
index 0000000000..c362a39e65
--- /dev/null
+++ b/remote/test/puppeteer/src/node/util.ts
@@ -0,0 +1,13 @@
+import * as os from 'os';
+
+/**
+ * Gets the temporary directory, either from the environmental variable
+ * `PUPPETEER_TMP_DIR` or the `os.tmpdir`.
+ *
+ * @returns The temporary directory path.
+ *
+ * @internal
+ */
+export const tmpdir = (): string => {
+ return process.env['PUPPETEER_TMP_DIR'] || os.tmpdir();
+};
diff --git a/remote/test/puppeteer/src/puppeteer-core.ts b/remote/test/puppeteer/src/puppeteer-core.ts
new file mode 100644
index 0000000000..50da718345
--- /dev/null
+++ b/remote/test/puppeteer/src/puppeteer-core.ts
@@ -0,0 +1,34 @@
+/**
+ * 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 {initializePuppeteer} from './initializePuppeteer.js';
+
+export * from './common/NetworkConditions.js';
+export * from './common/QueryHandler.js';
+export * from './common/DeviceDescriptors.js';
+export * from './common/Errors.js';
+
+const puppeteer = initializePuppeteer('puppeteer-core');
+
+export const {
+ connect,
+ createBrowserFetcher,
+ defaultArgs,
+ executablePath,
+ launch,
+} = puppeteer;
+
+export default puppeteer;
diff --git a/remote/test/puppeteer/src/puppeteer.ts b/remote/test/puppeteer/src/puppeteer.ts
new file mode 100644
index 0000000000..63317cbf7d
--- /dev/null
+++ b/remote/test/puppeteer/src/puppeteer.ts
@@ -0,0 +1,39 @@
+/**
+ * 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 {initializePuppeteer} from './initializePuppeteer.js';
+
+export * from './common/NetworkConditions.js';
+export * from './common/QueryHandler.js';
+export * from './common/DeviceDescriptors.js';
+export * from './common/Errors.js';
+
+export {Protocol} from 'devtools-protocol';
+
+/**
+ * @public
+ */
+const puppeteer = initializePuppeteer('puppeteer');
+
+export const {
+ connect,
+ createBrowserFetcher,
+ defaultArgs,
+ executablePath,
+ launch,
+} = puppeteer;
+
+export default puppeteer;
diff --git a/remote/test/puppeteer/src/revisions.ts b/remote/test/puppeteer/src/revisions.ts
new file mode 100644
index 0000000000..5c7a747a8f
--- /dev/null
+++ b/remote/test/puppeteer/src/revisions.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.
+ */
+
+/**
+ * @internal
+ */
+export const PUPPETEER_REVISIONS = Object.freeze({
+ chromium: '1036745',
+ firefox: 'latest',
+});
diff --git a/remote/test/puppeteer/src/templates/README.md b/remote/test/puppeteer/src/templates/README.md
new file mode 100644
index 0000000000..548987eafc
--- /dev/null
+++ b/remote/test/puppeteer/src/templates/README.md
@@ -0,0 +1,3 @@
+# Templated Artifacts
+
+These files are generated as TypeScript files in the `src/generated` folder.
diff --git a/remote/test/puppeteer/src/templates/injected.ts.tmpl b/remote/test/puppeteer/src/templates/injected.ts.tmpl
new file mode 100644
index 0000000000..74146a4115
--- /dev/null
+++ b/remote/test/puppeteer/src/templates/injected.ts.tmpl
@@ -0,0 +1,10 @@
+import {createDeferredPromise} from '../util/DeferredPromise.js';
+
+declare global {
+ const InjectedUtil: {
+ createDeferredPromise: typeof createDeferredPromise;
+ };
+}
+
+/** @internal */
+export const source = SOURCE_CODE;
diff --git a/remote/test/puppeteer/src/templates/version.ts.tmpl b/remote/test/puppeteer/src/templates/version.ts.tmpl
new file mode 100644
index 0000000000..73b984d2ff
--- /dev/null
+++ b/remote/test/puppeteer/src/templates/version.ts.tmpl
@@ -0,0 +1,4 @@
+/**
+ * @internal
+ */
+export const packageVersion = 'PACKAGE_VERSION';
diff --git a/remote/test/puppeteer/src/tsconfig.cjs.json b/remote/test/puppeteer/src/tsconfig.cjs.json
new file mode 100644
index 0000000000..c92ca3f87c
--- /dev/null
+++ b/remote/test/puppeteer/src/tsconfig.cjs.json
@@ -0,0 +1,13 @@
+{
+ "extends": "../tsconfig.base.json",
+ "compilerOptions": {
+ "composite": true,
+ "outDir": "../lib/cjs/puppeteer",
+ "module": "CommonJS"
+ },
+ "references": [
+ {"path": "../vendor/tsconfig.cjs.json"},
+ {"path": "../compat/cjs/tsconfig.json"}
+ ],
+ "exclude": ["injected/injected.ts"]
+}
diff --git a/remote/test/puppeteer/src/tsconfig.esm.json b/remote/test/puppeteer/src/tsconfig.esm.json
new file mode 100644
index 0000000000..aba2f24035
--- /dev/null
+++ b/remote/test/puppeteer/src/tsconfig.esm.json
@@ -0,0 +1,13 @@
+{
+ "extends": "../tsconfig.base.json",
+ "compilerOptions": {
+ "composite": true,
+ "outDir": "../lib/esm/puppeteer",
+ "module": "esnext"
+ },
+ "references": [
+ {"path": "../vendor/tsconfig.esm.json"},
+ {"path": "../compat/esm/tsconfig.json"}
+ ],
+ "exclude": ["injected/injected.ts"]
+}
diff --git a/remote/test/puppeteer/src/types.ts b/remote/test/puppeteer/src/types.ts
new file mode 100644
index 0000000000..00bed28c5a
--- /dev/null
+++ b/remote/test/puppeteer/src/types.ts
@@ -0,0 +1,74 @@
+// AUTOGENERATED - Use `npm run generate:sources` to regenerate.
+
+export * from './common/Accessibility.js';
+export * from './common/AriaQueryHandler.js';
+export * from './common/Browser.js';
+export * from './common/BrowserConnector.js';
+export * from './common/BrowserWebSocketTransport.js';
+export * from './common/ChromeTargetManager.js';
+export * from './common/Connection.js';
+export * from './common/ConnectionTransport.js';
+export * from './common/ConsoleMessage.js';
+export * from './common/Coverage.js';
+export * from './common/Debug.js';
+export * from './common/DeviceDescriptors.js';
+export * from './common/Dialog.js';
+export * from './common/ElementHandle.js';
+export * from './common/EmulationManager.js';
+export * from './common/Errors.js';
+export * from './common/EventEmitter.js';
+export * from './common/ExecutionContext.js';
+export * from './common/fetch.js';
+export * from './common/FileChooser.js';
+export * from './common/FirefoxTargetManager.js';
+export * from './common/Frame.js';
+export * from './common/FrameManager.js';
+export * from './common/HTTPRequest.js';
+export * from './common/HTTPResponse.js';
+export * from './common/Input.js';
+export * from './common/IsolatedWorld.js';
+export * from './common/JSHandle.js';
+export * from './common/LifecycleWatcher.js';
+export * from './common/NetworkConditions.js';
+export * from './common/NetworkEventManager.js';
+export * from './common/NetworkManager.js';
+export * from './common/Page.js';
+export * from './common/PDFOptions.js';
+export * from './common/Product.js';
+export * from './common/Puppeteer.js';
+export * from './common/PuppeteerViewport.js';
+export * from './common/QueryHandler.js';
+export * from './common/SecurityDetails.js';
+export * from './common/Target.js';
+export * from './common/TargetManager.js';
+export * from './common/TaskQueue.js';
+export * from './common/TimeoutSettings.js';
+export * from './common/Tracing.js';
+export * from './common/types.js';
+export * from './common/USKeyboardLayout.js';
+export * from './common/util.js';
+export * from './common/WebWorker.js';
+export * from './compat.d.js';
+export * from './constants.js';
+export * from './environment.js';
+export * from './generated/injected.js';
+export * from './generated/version.js';
+export * from './initializePuppeteer.js';
+export * from './node/BrowserFetcher.js';
+export * from './node/BrowserRunner.js';
+export * from './node/ChromeLauncher.js';
+export * from './node/FirefoxLauncher.js';
+export * from './node/install.js';
+export * from './node/LaunchOptions.js';
+export * from './node/NodeWebSocketTransport.js';
+export * from './node/PipeTransport.js';
+export * from './node/ProductLauncher.js';
+export * from './node/Puppeteer.js';
+export * from './node/util.js';
+export * from './puppeteer.js';
+export * from './revisions.js';
+export * from './util/assert.js';
+export * from './util/DebuggableDeferredPromise.js';
+export * from './util/DeferredPromise.js';
+export * from './util/ErrorLike.js';
+export * from './util/getPackageDirectory.js';
diff --git a/remote/test/puppeteer/src/util/DebuggableDeferredPromise.ts b/remote/test/puppeteer/src/util/DebuggableDeferredPromise.ts
new file mode 100644
index 0000000000..474743273c
--- /dev/null
+++ b/remote/test/puppeteer/src/util/DebuggableDeferredPromise.ts
@@ -0,0 +1,20 @@
+import {DEFERRED_PROMISE_DEBUG_TIMEOUT} from '../environment.js';
+import {DeferredPromise, createDeferredPromise} from './DeferredPromise.js';
+
+/**
+ * Creates and returns a deferred promise using DEFERRED_PROMISE_DEBUG_TIMEOUT
+ * if it's specified or a normal deferred promise otherwise.
+ *
+ * @internal
+ */
+export function createDebuggableDeferredPromise<T>(
+ message: string
+): DeferredPromise<T> {
+ if (DEFERRED_PROMISE_DEBUG_TIMEOUT > 0) {
+ return createDeferredPromise({
+ message,
+ timeout: DEFERRED_PROMISE_DEBUG_TIMEOUT,
+ });
+ }
+ return createDeferredPromise();
+}
diff --git a/remote/test/puppeteer/src/util/DeferredPromise.ts b/remote/test/puppeteer/src/util/DeferredPromise.ts
new file mode 100644
index 0000000000..2f21087ba8
--- /dev/null
+++ b/remote/test/puppeteer/src/util/DeferredPromise.ts
@@ -0,0 +1,68 @@
+import {TimeoutError} from '../common/Errors.js';
+
+/**
+ * @internal
+ */
+export interface DeferredPromise<T> extends Promise<T> {
+ finished: () => boolean;
+ resolved: () => boolean;
+ resolve: (_: T) => void;
+ reject: (_: Error) => void;
+}
+
+/**
+ * @internal
+ */
+export interface DeferredPromiseOptions {
+ message: string;
+ timeout: number;
+}
+
+/**
+ * Creates and returns a promise along with the resolve/reject functions.
+ *
+ * If the promise has not been resolved/rejected within the `timeout` period,
+ * the promise gets rejected with a timeout error. `timeout` has to be greater than 0 or
+ * it is ignored.
+ *
+ * @internal
+ */
+export function createDeferredPromise<T>(
+ opts?: DeferredPromiseOptions
+): DeferredPromise<T> {
+ let isResolved = false;
+ let isRejected = false;
+ let resolver = (_: T): void => {};
+ let rejector = (_: Error) => {};
+ const taskPromise = new Promise<T>((resolve, reject) => {
+ resolver = resolve;
+ rejector = reject;
+ });
+ const timeoutId =
+ opts && opts.timeout > 0
+ ? setTimeout(() => {
+ isRejected = true;
+ rejector(new TimeoutError(opts.message));
+ }, opts.timeout)
+ : undefined;
+ return Object.assign(taskPromise, {
+ resolved: () => {
+ return isResolved;
+ },
+ finished: () => {
+ return isResolved || isRejected;
+ },
+ resolve: (value: T) => {
+ if (timeoutId) {
+ clearTimeout(timeoutId);
+ }
+ isResolved = true;
+ resolver(value);
+ },
+ reject: (err: Error) => {
+ clearTimeout(timeoutId);
+ isRejected = true;
+ rejector(err);
+ },
+ });
+}
diff --git a/remote/test/puppeteer/src/util/ErrorLike.ts b/remote/test/puppeteer/src/util/ErrorLike.ts
new file mode 100644
index 0000000000..e5659ce3e3
--- /dev/null
+++ b/remote/test/puppeteer/src/util/ErrorLike.ts
@@ -0,0 +1,27 @@
+/**
+ * @internal
+ */
+
+export interface ErrorLike extends Error {
+ name: string;
+ message: string;
+}
+/**
+ * @internal
+ */
+
+export function isErrorLike(obj: unknown): obj is ErrorLike {
+ return (
+ typeof obj === 'object' && obj !== null && 'name' in obj && 'message' in obj
+ );
+}
+/**
+ * @internal
+ */
+
+export function isErrnoException(obj: unknown): obj is NodeJS.ErrnoException {
+ return (
+ isErrorLike(obj) &&
+ ('errno' in obj || 'code' in obj || 'path' in obj || 'syscall' in obj)
+ );
+}
diff --git a/remote/test/puppeteer/src/util/assert.ts b/remote/test/puppeteer/src/util/assert.ts
new file mode 100644
index 0000000000..bd8b10e731
--- /dev/null
+++ b/remote/test/puppeteer/src/util/assert.ts
@@ -0,0 +1,31 @@
+/**
+ * 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 - some conditional statement
+ * @param message - the error message to throw if the value is not truthy.
+ *
+ * @internal
+ */
+export const assert: (value: unknown, message?: string) => asserts value = (
+ value,
+ message
+) => {
+ if (!value) {
+ throw new Error(message);
+ }
+};
diff --git a/remote/test/puppeteer/src/util/getPackageDirectory.ts b/remote/test/puppeteer/src/util/getPackageDirectory.ts
new file mode 100644
index 0000000000..d0c5fc4cad
--- /dev/null
+++ b/remote/test/puppeteer/src/util/getPackageDirectory.ts
@@ -0,0 +1,18 @@
+import {existsSync} from 'fs';
+import {dirname, join, parse} from 'path';
+
+/**
+ * @internal
+ */
+export const getPackageDirectory = (from: string): string => {
+ let found = existsSync(join(from, 'package.json'));
+ const root = parse(from).root;
+ while (!found) {
+ if (from === root) {
+ throw new Error('Cannot find package directory');
+ }
+ from = dirname(from);
+ found = existsSync(join(from, 'package.json'));
+ }
+ return from;
+};