From db28203e646140e39367682aa383d6efc27a2097 Mon Sep 17 00:00:00 2001 From: Alex Rudenko Date: Wed, 14 Sep 2022 16:40:58 +0200 Subject: [PATCH] chore: support WebDriver BiDi browser instances (#8932) This PR adds a basic support for WebDriver BiDi that currently includes only the ability to establish a connection and shutdown the browser. Therefore, the implementation is marked as internal and won't show up in the changelog as it's barely useful at the moment. The API classes are kept as classes instead of interfaces so that clients relying on instanceof checks still work. --- .mocharc.cjs | 2 +- docs/api/puppeteer.browsercontextoptions.md | 2 +- ...r.browsercontextoptions.proxybypasslist.md | 2 +- src/api/Browser.ts | 628 ++++++++++++++++++ src/common/Browser.ts | 348 ++-------- src/common/BrowserConnector.ts | 18 +- src/common/ChromeTargetManager.ts | 2 +- src/common/FirefoxTargetManager.ts | 2 +- src/common/Page.ts | 2 +- src/common/Puppeteer.ts | 9 +- src/common/Target.ts | 6 +- src/common/bidi/Browser.ts | 52 ++ src/common/bidi/Connection.ts | 167 +++++ src/node/BrowserRunner.ts | 25 +- src/node/ChromeLauncher.ts | 6 +- src/node/FirefoxLauncher.ts | 28 +- src/node/ProductLauncher.ts | 2 +- src/node/Puppeteer.ts | 2 +- src/types.ts | 1 + test/TestExpectations.json | 18 + test/TestSuites.json | 8 + test/src/TargetManager.spec.ts | 12 +- test/src/bidi/Connection.spec.ts | 59 ++ test/src/ignorehttpserrors.spec.ts | 5 +- test/src/launcher.spec.ts | 5 + test/src/mocha-utils.ts | 7 +- test/src/oopif.spec.ts | 5 +- test/src/proxy.spec.ts | 2 +- test/src/tracing.spec.ts | 2 +- utils/generate_sources.ts | 2 +- 30 files changed, 1093 insertions(+), 336 deletions(-) create mode 100644 src/api/Browser.ts create mode 100644 src/common/bidi/Browser.ts create mode 100644 src/common/bidi/Connection.ts create mode 100644 test/src/bidi/Connection.spec.ts diff --git a/.mocharc.cjs b/.mocharc.cjs index 30bf43695b9..d1cd74f4eff 100644 --- a/.mocharc.cjs +++ b/.mocharc.cjs @@ -18,7 +18,7 @@ module.exports = { reporter: 'dot', logLevel: 'debug', require: ['./test/build/mocha-utils.js', 'source-map-support/register'], - spec: 'test/build/*.spec.js', + spec: 'test/build/**/*.spec.js', exit: !!process.env.CI, retries: process.env.CI ? 2 : 0, parallel: !!process.env.PARALLEL, diff --git a/docs/api/puppeteer.browsercontextoptions.md b/docs/api/puppeteer.browsercontextoptions.md index 0bf827371f5..0dce2f259d5 100644 --- a/docs/api/puppeteer.browsercontextoptions.md +++ b/docs/api/puppeteer.browsercontextoptions.md @@ -16,5 +16,5 @@ export interface BrowserContextOptions | Property | Modifiers | Type | Description | | ------------------------------------------------------------------------ | --------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| [proxyBypassList?](./puppeteer.browsercontextoptions.proxybypasslist.md) | | string\[\] | (Optional) Bypass the proxy for the given semi-colon-separated list of hosts. | +| [proxyBypassList?](./puppeteer.browsercontextoptions.proxybypasslist.md) | | string\[\] | (Optional) Bypass the proxy for the given list of hosts. | | [proxyServer?](./puppeteer.browsercontextoptions.proxyserver.md) | | string | (Optional) Proxy server with optional port to use for all requests. Username and password can be set in Page.authenticate. | diff --git a/docs/api/puppeteer.browsercontextoptions.proxybypasslist.md b/docs/api/puppeteer.browsercontextoptions.proxybypasslist.md index 42f9d077e7f..46262d0c9dd 100644 --- a/docs/api/puppeteer.browsercontextoptions.proxybypasslist.md +++ b/docs/api/puppeteer.browsercontextoptions.proxybypasslist.md @@ -4,7 +4,7 @@ sidebar_label: BrowserContextOptions.proxyBypassList # BrowserContextOptions.proxyBypassList property -Bypass the proxy for the given semi-colon-separated list of hosts. +Bypass the proxy for the given list of hosts. **Signature:** diff --git a/src/api/Browser.ts b/src/api/Browser.ts new file mode 100644 index 00000000000..24ffcdffa29 --- /dev/null +++ b/src/api/Browser.ts @@ -0,0 +1,628 @@ +/** + * 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. + */ + +/* eslint-disable @typescript-eslint/no-unused-vars */ + +import {ChildProcess} from 'child_process'; +import {Protocol} from 'devtools-protocol'; +import {EventEmitter} from '../common/EventEmitter.js'; +import type {Page} from '../common/Page.js'; // TODO: move to ./api +import type {Target} from '../common/Target.js'; // TODO: move to ./api + +/** + * 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 list of hosts. + */ + proxyBypassList?: string[]; +} + +/** + * @internal + */ +export type BrowserCloseCallback = () => Promise | void; + +/** + * @public + */ +export type TargetFilterCallback = ( + target: Protocol.Target.TargetInfo +) => boolean; + +/** + * @internal + */ +export type IsPageTargetCallback = ( + target: Protocol.Target.TargetInfo +) => boolean; + +/** + * @internal + */ +export 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 + */ + constructor() { + super(); + } + + /** + * @internal + */ + _attach(): Promise { + throw new Error('Not implemented'); + } + + /** + * @internal + */ + _detach(): void { + throw new Error('Not implemented'); + } + + /** + * @internal + */ + get _targets(): Map { + throw new Error('Not implemented'); + } + + /** + * The spawned browser process. Returns `null` if the browser instance was created with + * {@link Puppeteer.connect}. + */ + process(): ChildProcess | null { + throw new Error('Not implemented'); + } + + /** + * @internal + */ + _getIsPageTargetCallback(): IsPageTargetCallback | undefined { + throw new Error('Not implemented'); + } + + /** + * 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'); + * })(); + * ``` + */ + createIncognitoBrowserContext( + options?: BrowserContextOptions + ): Promise; + createIncognitoBrowserContext(): Promise { + throw new Error('Not implemented'); + } + + /** + * Returns an array of all open browser contexts. In a newly created browser, this will + * return a single instance of {@link BrowserContext}. + */ + browserContexts(): BrowserContext[] { + throw new Error('Not implemented'); + } + + /** + * Returns the default browser context. The default browser context cannot be closed. + */ + defaultBrowserContext(): BrowserContext { + throw new Error('Not implemented'); + } + + /** + * @internal + */ + _disposeContext(contextId?: string): Promise; + _disposeContext(): Promise { + throw new Error('Not implemented'); + } + + /** + * 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/`. + * + * 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 { + throw new Error('Not implemented'); + } + + /** + * Promise which resolves to a new {@link Page} object. The Page is created in + * a default browser context. + */ + newPage(): Promise { + throw new Error('Not implemented'); + } + + /** + * @internal + */ + _createPageInContext(contextId?: string): Promise; + _createPageInContext(): Promise { + throw new Error('Not implemented'); + } + + /** + * 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[] { + throw new Error('Not implemented'); + } + + /** + * The target associated with the browser. + */ + target(): Target { + throw new Error('Not implemented'); + } + + /** + * 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/' + * ); + * ``` + */ + waitForTarget( + predicate: (x: Target) => boolean | Promise, + options?: WaitForTargetOptions + ): Promise; + waitForTarget(): Promise { + throw new Error('Not implemented'); + } + + /** + * 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}. + */ + pages(): Promise { + throw new Error('Not implemented'); + } + + /** + * 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. + */ + version(): Promise { + throw new Error('Not implemented'); + } + + /** + * The browser's original user agent. Pages can override the browser user agent with + * {@link Page.setUserAgent}. + */ + userAgent(): Promise { + throw new Error('Not implemented'); + } + + /** + * 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. + */ + close(): Promise { + throw new Error('Not implemented'); + } + + /** + * 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 { + throw new Error('Not implemented'); + } + + /** + * Indicates that the browser is connected. + */ + isConnected(): boolean { + throw new Error('Not implemented'); + } +} +/** + * @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 { + /** + * @internal + */ + constructor() { + super(); + } + + /** + * An array of all active targets inside the browser context. + */ + targets(): Target[] { + throw new Error('Not implemented'); + } + + /** + * 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, + options?: {timeout?: number} + ): Promise; + waitForTarget(): Promise { + throw new Error('Not implemented'); + } + + /** + * 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}. + */ + pages(): Promise { + throw new Error('Not implemented'); + } + + /** + * 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 { + throw new Error('Not implemented'); + } + + /** + * @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. + */ + overridePermissions(origin: string, permissions: Permission[]): Promise; + overridePermissions(): Promise { + throw new Error('Not implemented'); + } + + /** + * 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(); + * ``` + */ + clearPermissionOverrides(): Promise { + throw new Error('Not implemented'); + } + + /** + * Creates a new page in the browser context. + */ + newPage(): Promise { + throw new Error('Not implemented'); + } + + /** + * The browser this browser context belongs to. + */ + browser(): Browser { + throw new Error('Not implemented'); + } + + /** + * Closes the browser context. All the targets that belong to the browser context + * will be closed. + * + * @remarks + * Only incognito browser contexts can be closed. + */ + close(): Promise { + throw new Error('Not implemented'); + } +} diff --git a/src/common/Browser.ts b/src/common/Browser.ts index 253cfff2914..0f4ab11b50f 100644 --- a/src/common/Browser.ts +++ b/src/common/Browser.ts @@ -18,7 +18,6 @@ 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'; @@ -27,196 +26,24 @@ 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; - -/** - * @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< +import { + Browser as BrowserBase, + BrowserContext, + BrowserCloseCallback, + TargetFilterCallback, + IsPageTargetCallback, + BrowserEmittedEvents, + BrowserContextEmittedEvents, + BrowserContextOptions, + WEB_PERMISSION_TO_PROTOCOL_PERMISSION, + WaitForTargetOptions, 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'], -]); +} from '../api/Browser.js'; /** - * @public + * @internal */ -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 { +export class CDPBrowser extends BrowserBase { /** * @internal */ @@ -230,8 +57,8 @@ export class Browser extends EventEmitter { closeCallback?: BrowserCloseCallback, targetFilterCallback?: TargetFilterCallback, isPageTargetCallback?: IsPageTargetCallback - ): Promise { - const browser = new Browser( + ): Promise { + const browser = new CDPBrowser( product, connection, contextIds, @@ -252,15 +79,15 @@ export class Browser extends EventEmitter { #closeCallback: BrowserCloseCallback; #targetFilterCallback: TargetFilterCallback; #isPageTargetCallback!: IsPageTargetCallback; - #defaultContext: BrowserContext; - #contexts: Map; + #defaultContext: CDPBrowserContext; + #contexts: Map; #screenshotTaskQueue: TaskQueue; #targetManager: TargetManager; /** * @internal */ - get _targets(): Map { + override get _targets(): Map { return this.#targetManager.getAvailableTargets(); } @@ -305,12 +132,12 @@ export class Browser extends EventEmitter { this.#targetFilterCallback ); } - this.#defaultContext = new BrowserContext(this.#connection, this); + this.#defaultContext = new CDPBrowserContext(this.#connection, this); this.#contexts = new Map(); for (const contextId of contextIds) { this.#contexts.set( contextId, - new BrowserContext(this.#connection, this, contextId) + new CDPBrowserContext(this.#connection, this, contextId) ); } } @@ -322,7 +149,7 @@ export class Browser extends EventEmitter { /** * @internal */ - async _attach(): Promise { + override async _attach(): Promise { this.#connection.on( ConnectionEmittedEvents.Disconnected, this.#emitDisconnected @@ -349,7 +176,7 @@ export class Browser extends EventEmitter { /** * @internal */ - _detach(): void { + override _detach(): void { this.#connection.off( ConnectionEmittedEvents.Disconnected, this.#emitDisconnected @@ -376,7 +203,7 @@ export class Browser extends EventEmitter { * The spawned browser process. Returns `null` if the browser instance was created with * {@link Puppeteer.connect}. */ - process(): ChildProcess | null { + override process(): ChildProcess | null { return this.#process ?? null; } @@ -402,7 +229,7 @@ export class Browser extends EventEmitter { /** * @internal */ - _getIsPageTargetCallback(): IsPageTargetCallback | undefined { + override _getIsPageTargetCallback(): IsPageTargetCallback | undefined { return this.#isPageTargetCallback; } @@ -424,9 +251,9 @@ export class Browser extends EventEmitter { * })(); * ``` */ - async createIncognitoBrowserContext( + override async createIncognitoBrowserContext( options: BrowserContextOptions = {} - ): Promise { + ): Promise { const {proxyServer, proxyBypassList} = options; const {browserContextId} = await this.#connection.send( @@ -436,7 +263,7 @@ export class Browser extends EventEmitter { proxyBypassList: proxyBypassList && proxyBypassList.join(','), } ); - const context = new BrowserContext( + const context = new CDPBrowserContext( this.#connection, this, browserContextId @@ -449,21 +276,21 @@ export class Browser extends EventEmitter { * Returns an array of all open browser contexts. In a newly created browser, this will * return a single instance of {@link BrowserContext}. */ - browserContexts(): BrowserContext[] { + override browserContexts(): CDPBrowserContext[] { return [this.#defaultContext, ...Array.from(this.#contexts.values())]; } /** * Returns the default browser context. The default browser context cannot be closed. */ - defaultBrowserContext(): BrowserContext { + override defaultBrowserContext(): CDPBrowserContext { return this.#defaultContext; } /** * @internal */ - async _disposeContext(contextId?: string): Promise { + override async _disposeContext(contextId?: string): Promise { if (!contextId) { return; } @@ -564,7 +391,7 @@ export class Browser extends EventEmitter { * https://chromedevtools.github.io/devtools-protocol/#how-do-i-access-the-browser-target * | browser endpoint}. */ - wsEndpoint(): string { + override wsEndpoint(): string { return this.#connection.url(); } @@ -572,14 +399,14 @@ export class Browser extends EventEmitter { * Promise which resolves to a new {@link Page} object. The Page is created in * a default browser context. */ - async newPage(): Promise { + override async newPage(): Promise { return this.#defaultContext.newPage(); } /** * @internal */ - async _createPageInContext(contextId?: string): Promise { + override async _createPageInContext(contextId?: string): Promise { const {targetId} = await this.#connection.send('Target.createTarget', { url: 'about:blank', browserContextId: contextId || undefined, @@ -605,7 +432,7 @@ export class Browser extends EventEmitter { * 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[] { + override targets(): Target[] { return Array.from( this.#targetManager.getAvailableTargets().values() ).filter(target => { @@ -616,7 +443,7 @@ export class Browser extends EventEmitter { /** * The target associated with the browser. */ - target(): Target { + override target(): Target { const browserTarget = this.targets().find(target => { return target.type() === 'browser'; }); @@ -643,7 +470,7 @@ export class Browser extends EventEmitter { * ); * ``` */ - async waitForTarget( + override async waitForTarget( predicate: (x: Target) => boolean | Promise, options: WaitForTargetOptions = {} ): Promise { @@ -683,7 +510,7 @@ export class Browser extends EventEmitter { * 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 { + override async pages(): Promise { const contextPages = await Promise.all( this.browserContexts().map(context => { return context.pages(); @@ -705,7 +532,7 @@ export class Browser extends EventEmitter { * * The format of browser.version() might change with future releases of Chromium. */ - async version(): Promise { + override async version(): Promise { const version = await this.#getVersion(); return version.product; } @@ -714,26 +541,27 @@ export class Browser extends EventEmitter { * The browser's original user agent. Pages can override the browser user agent with * {@link Page.setUserAgent}. */ - async userAgent(): Promise { + override async userAgent(): Promise { 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. + * Closes Chromium and all of its pages (if any were opened). The + * {@link CDPBrowser} object itself is considered to be disposed and cannot be + * used anymore. */ - async close(): Promise { + override async close(): Promise { 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 + * After calling `disconnect`, the {@link CDPBrowser} object is considered disposed and * cannot be used anymore. */ - disconnect(): void { + override disconnect(): void { this.#targetManager.dispose(); this.#connection.dispose(); } @@ -741,7 +569,7 @@ export class Browser extends EventEmitter { /** * Indicates that the browser is connected. */ - isConnected(): boolean { + override isConnected(): boolean { return !this.#connection._closed; } @@ -749,75 +577,19 @@ export class Browser extends EventEmitter { 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 + * @internal */ -export class BrowserContext extends EventEmitter { +export class CDPBrowserContext extends BrowserContext { #connection: Connection; - #browser: Browser; + #browser: CDPBrowser; #id?: string; /** * @internal */ - constructor(connection: Connection, browser: Browser, contextId?: string) { + constructor(connection: Connection, browser: CDPBrowser, contextId?: string) { super(); this.#connection = connection; this.#browser = browser; @@ -827,7 +599,7 @@ export class BrowserContext extends EventEmitter { /** * An array of all active targets inside the browser context. */ - targets(): Target[] { + override targets(): Target[] { return this.#browser.targets().filter(target => { return target.browserContext() === this; }); @@ -853,7 +625,7 @@ export class BrowserContext extends EventEmitter { * @returns Promise which resolves to the first target found * that matches the `predicate` function. */ - waitForTarget( + override waitForTarget( predicate: (x: Target) => boolean | Promise, options: {timeout?: number} = {} ): Promise { @@ -869,7 +641,7 @@ export class BrowserContext extends EventEmitter { * 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 { + override async pages(): Promise { const pages = await Promise.all( this.targets() .filter(target => { @@ -897,7 +669,7 @@ export class BrowserContext extends EventEmitter { * @remarks * The default browser context cannot be closed. */ - isIncognito(): boolean { + override isIncognito(): boolean { return !!this.#id; } @@ -915,7 +687,7 @@ export class BrowserContext extends EventEmitter { * @param permissions - An array of permissions to grant. * All permissions that are not listed here will be automatically denied. */ - async overridePermissions( + override async overridePermissions( origin: string, permissions: Permission[] ): Promise { @@ -946,7 +718,7 @@ export class BrowserContext extends EventEmitter { * context.clearPermissionOverrides(); * ``` */ - async clearPermissionOverrides(): Promise { + override async clearPermissionOverrides(): Promise { await this.#connection.send('Browser.resetPermissions', { browserContextId: this.#id || undefined, }); @@ -955,14 +727,14 @@ export class BrowserContext extends EventEmitter { /** * Creates a new page in the browser context. */ - newPage(): Promise { + override newPage(): Promise { return this.#browser._createPageInContext(this.#id); } /** * The browser this browser context belongs to. */ - browser(): Browser { + override browser(): CDPBrowser { return this.#browser; } @@ -973,7 +745,7 @@ export class BrowserContext extends EventEmitter { * @remarks * Only incognito browser contexts can be closed. */ - async close(): Promise { + override async close(): Promise { assert(this.#id, 'Non-incognito profiles cannot be closed!'); await this.#browser._disposeContext(this.#id); } diff --git a/src/common/BrowserConnector.ts b/src/common/BrowserConnector.ts index de6ceda23be..4eaba3bd2cd 100644 --- a/src/common/BrowserConnector.ts +++ b/src/common/BrowserConnector.ts @@ -18,11 +18,8 @@ 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 {IsPageTargetCallback, TargetFilterCallback} from '../api/Browser.js'; +import {CDPBrowser} from './Browser.js'; import {Connection} from './Connection.js'; import {ConnectionTransport} from './ConnectionTransport.js'; import {getFetch} from './fetch.js'; @@ -55,6 +52,11 @@ export interface BrowserConnectOptions { * @internal */ _isPageTarget?: IsPageTargetCallback; + /** + * @defaultValue 'cdp' + * @internal + */ + protocol?: 'cdp' | 'webDriverBiDi'; } const getWebSocketTransportClass = async () => { @@ -70,13 +72,13 @@ const getWebSocketTransportClass = async () => { * * @internal */ -export async function _connectToBrowser( +export async function _connectToCDPBrowser( options: BrowserConnectOptions & { browserWSEndpoint?: string; browserURL?: string; transport?: ConnectionTransport; } -): Promise { +): Promise { const { browserWSEndpoint, browserURL, @@ -118,7 +120,7 @@ export async function _connectToBrowser( const {browserContextIds} = await connection.send( 'Target.getBrowserContexts' ); - const browser = await Browser._create( + const browser = await CDPBrowser._create( product || 'chrome', connection, browserContextIds, diff --git a/src/common/ChromeTargetManager.ts b/src/common/ChromeTargetManager.ts index 445225edf35..b967adb0870 100644 --- a/src/common/ChromeTargetManager.ts +++ b/src/common/ChromeTargetManager.ts @@ -20,7 +20,7 @@ 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 {TargetFilterCallback} from '../api/Browser.js'; import { TargetInterceptor, TargetFactory, diff --git a/src/common/FirefoxTargetManager.ts b/src/common/FirefoxTargetManager.ts index e31a205aa1d..38bd042b187 100644 --- a/src/common/FirefoxTargetManager.ts +++ b/src/common/FirefoxTargetManager.ts @@ -18,7 +18,7 @@ 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 {TargetFilterCallback} from '../api/Browser.js'; import { TargetFactory, TargetInterceptor, diff --git a/src/common/Page.ts b/src/common/Page.ts index 3e6c4dee666..7a7d5605c07 100644 --- a/src/common/Page.ts +++ b/src/common/Page.ts @@ -23,7 +23,7 @@ import { } from '../util/DeferredPromise.js'; import {isErrorLike} from '../util/ErrorLike.js'; import {Accessibility} from './Accessibility.js'; -import {Browser, BrowserContext} from './Browser.js'; +import type {Browser, BrowserContext} from '../api/Browser.js'; import { CDPSession, CDPSessionEmittedEvents, diff --git a/src/common/Puppeteer.ts b/src/common/Puppeteer.ts index c147e5cd8e3..8fab36e1c5b 100644 --- a/src/common/Puppeteer.ts +++ b/src/common/Puppeteer.ts @@ -13,8 +13,11 @@ * 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 {Browser} from '../api/Browser.js'; +import { + BrowserConnectOptions, + _connectToCDPBrowser, +} from './BrowserConnector.js'; import {ConnectionTransport} from './ConnectionTransport.js'; import {devices} from './DeviceDescriptors.js'; import {errors} from './Errors.js'; @@ -81,7 +84,7 @@ export class Puppeteer { * @returns Promise which resolves to browser instance. */ connect(options: ConnectOptions): Promise { - return _connectToBrowser(options); + return _connectToCDPBrowser(options); } /** diff --git a/src/common/Target.ts b/src/common/Target.ts index 5744834f1b3..b1611d24bad 100644 --- a/src/common/Target.ts +++ b/src/common/Target.ts @@ -17,7 +17,11 @@ import {Page, PageEmittedEvents} from './Page.js'; import {WebWorker} from './WebWorker.js'; import {CDPSession} from './Connection.js'; -import {Browser, BrowserContext, IsPageTargetCallback} from './Browser.js'; +import type { + Browser, + BrowserContext, + IsPageTargetCallback, +} from '../api/Browser.js'; import {Viewport} from './PuppeteerViewport.js'; import {Protocol} from 'devtools-protocol'; import {TaskQueue} from './TaskQueue.js'; diff --git a/src/common/bidi/Browser.ts b/src/common/bidi/Browser.ts new file mode 100644 index 00000000000..c94319f2316 --- /dev/null +++ b/src/common/bidi/Browser.ts @@ -0,0 +1,52 @@ +import { + Browser as BrowserBase, + BrowserCloseCallback, +} from '../../api/Browser.js'; +import {Connection} from './Connection.js'; +import {ChildProcess} from 'child_process'; + +/** + * @internal + */ +export class Browser extends BrowserBase { + /** + * @internal + */ + static async create(opts: Options): Promise { + // TODO: await until the connection is established. + return new Browser(opts); + } + + #process?: ChildProcess; + #closeCallback?: BrowserCloseCallback; + #connection: Connection; + + /** + * @internal + */ + constructor(opts: Options) { + super(); + this.#process = opts.process; + this.#closeCallback = opts.closeCallback; + this.#connection = opts.connection; + } + + override async close(): Promise { + await this.#closeCallback?.call(null); + this.#connection.dispose(); + } + + override isConnected(): boolean { + return !this.#connection.closed; + } + + override process(): ChildProcess | null { + return this.#process ?? null; + } +} + +interface Options { + process?: ChildProcess; + closeCallback?: BrowserCloseCallback; + connection: Connection; +} diff --git a/src/common/bidi/Connection.ts b/src/common/bidi/Connection.ts new file mode 100644 index 00000000000..0f9de01a7b4 --- /dev/null +++ b/src/common/bidi/Connection.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 {debug} from '../Debug.js'; +const debugProtocolSend = debug('puppeteer:webDriverBiDi:SEND ►'); +const debugProtocolReceive = debug('puppeteer:webDriverBiDi:RECV ◀'); + +import {ConnectionTransport} from '../ConnectionTransport.js'; +import {EventEmitter} from '../EventEmitter.js'; +import {ProtocolError} from '../Errors.js'; +import {ConnectionCallback} from '../Connection.js'; + +interface Command { + id: number; + method: string; + params: object; +} + +interface CommandResponse { + id: number; + result: object; +} + +interface ErrorResponse { + id: number; + error: string; + message: string; + stacktrace?: string; +} + +interface Event { + method: string; + params: object; +} + +/** + * @internal + */ +export class Connection extends EventEmitter { + #transport: ConnectionTransport; + #delay: number; + #lastId = 0; + #closed = false; + #callbacks: Map = new Map(); + + constructor(transport: ConnectionTransport, delay = 0) { + super(); + this.#delay = delay; + + this.#transport = transport; + this.#transport.onmessage = this.onMessage.bind(this); + this.#transport.onclose = this.#onClose.bind(this); + } + + get closed(): boolean { + return this.#closed; + } + + send(method: string, params: object): Promise { + const id = ++this.#lastId; + const stringifiedMessage = JSON.stringify({ + id, + method, + params, + } as Command); + debugProtocolSend(stringifiedMessage); + this.#transport.send(stringifiedMessage); + return new Promise((resolve, reject) => { + this.#callbacks.set(id, { + resolve, + reject, + error: new ProtocolError(), + method, + }); + }); + } + + /** + * @internal + */ + protected async onMessage(message: string): Promise { + if (this.#delay) { + await new Promise(f => { + return setTimeout(f, this.#delay); + }); + } + debugProtocolReceive(message); + const object = JSON.parse(message) as + | Event + | ErrorResponse + | CommandResponse; + if ('id' in object) { + 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 ('error' in object) { + 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}): Connection closed.` + ) + ); + } + this.#callbacks.clear(); + } + + dispose(): void { + this.#onClose(); + this.#transport.close(); + } +} + +function rewriteError( + error: ProtocolError, + message: string, + originalMessage?: string +): Error { + error.message = message; + error.originalMessage = originalMessage ?? error.originalMessage; + return error; +} + +function createProtocolError( + error: ProtocolError, + method: string, + object: ErrorResponse +): Error { + let message = `Protocol error (${method}): ${object.error} ${object.message}`; + if (object.stacktrace) { + message += ` ${object.stacktrace}`; + } + return rewriteError(error, message, object.message); +} diff --git a/src/node/BrowserRunner.ts b/src/node/BrowserRunner.ts index c18350a6da1..4567ba73df5 100644 --- a/src/node/BrowserRunner.ts +++ b/src/node/BrowserRunner.ts @@ -22,6 +22,7 @@ import removeFolder from 'rimraf'; import {promisify} from 'util'; import {assert} from '../util/assert.js'; import {Connection} from '../common/Connection.js'; +import {Connection as BiDiConnection} from '../common/bidi/Connection.js'; import {debug} from '../common/Debug.js'; import {TimeoutError} from '../common/Errors.js'; import { @@ -245,6 +246,25 @@ export class BrowserRunner { removeEventListeners(this.#listeners); } + async setupWebDriverBiDiConnection(options: { + timeout: number; + slowMo: number; + preferredRevision: string; + }): Promise { + assert(this.proc, 'BrowserRunner not started.'); + + const {timeout, slowMo, preferredRevision} = options; + let browserWSEndpoint = await waitForWSEndpoint( + this.proc, + timeout, + preferredRevision, + /^WebDriver BiDi listening on (ws:\/\/.*)$/ + ); + browserWSEndpoint += '/session'; + const transport = await WebSocketTransport.create(browserWSEndpoint); + return new BiDiConnection(transport, slowMo); + } + async setupConnection(options: { usePipe?: boolean; timeout: number; @@ -279,7 +299,8 @@ export class BrowserRunner { function waitForWSEndpoint( browserProcess: childProcess.ChildProcess, timeout: number, - preferredRevision: string + preferredRevision: string, + regex = /^DevTools listening on (ws:\/\/.*)$/ ): Promise { assert(browserProcess.stderr, '`browserProcess` does not have stderr.'); const rl = readline.createInterface(browserProcess.stderr); @@ -327,7 +348,7 @@ function waitForWSEndpoint( function onLine(line: string): void { stderr += line + '\n'; - const match = line.match(/^DevTools listening on (ws:\/\/.*)$/); + const match = line.match(regex); if (!match) { return; } diff --git a/src/node/ChromeLauncher.ts b/src/node/ChromeLauncher.ts index ab07b56f6e6..c9196617680 100644 --- a/src/node/ChromeLauncher.ts +++ b/src/node/ChromeLauncher.ts @@ -1,7 +1,7 @@ import fs from 'fs'; import path from 'path'; import {assert} from '../util/assert.js'; -import {Browser} from '../common/Browser.js'; +import {CDPBrowser} from '../common/Browser.js'; import {Product} from '../common/Product.js'; import {BrowserRunner} from './BrowserRunner.js'; import { @@ -43,7 +43,7 @@ export class ChromeLauncher implements ProductLauncher { this._isPuppeteerCore = isPuppeteerCore; } - async launch(options: PuppeteerNodeLaunchOptions = {}): Promise { + async launch(options: PuppeteerNodeLaunchOptions = {}): Promise { const { ignoreDefaultArgs = false, args = [], @@ -154,7 +154,7 @@ export class ChromeLauncher implements ProductLauncher { slowMo, preferredRevision: this._preferredRevision, }); - browser = await Browser._create( + browser = await CDPBrowser._create( this.product, connection, [], diff --git a/src/node/FirefoxLauncher.ts b/src/node/FirefoxLauncher.ts index 7f36fb42f01..6295057c381 100644 --- a/src/node/FirefoxLauncher.ts +++ b/src/node/FirefoxLauncher.ts @@ -2,7 +2,9 @@ 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 {Browser} from '../api/Browser.js'; +import {CDPBrowser as CDPBrowser} from '../common/Browser.js'; +import {Browser as BiDiBrowser} from '../common/bidi/Browser.js'; import {Product} from '../common/Product.js'; import {BrowserFetcher} from './BrowserFetcher.js'; import {BrowserRunner} from './BrowserRunner.js'; @@ -58,6 +60,7 @@ export class FirefoxLauncher implements ProductLauncher { extraPrefsFirefox = {}, waitForInitialPage = true, debuggingPort = null, + protocol = 'cdp', } = options; const firefoxArguments = []; @@ -145,6 +148,27 @@ export class FirefoxLauncher implements ProductLauncher { pipe, }); + if (protocol === 'webDriverBiDi') { + let browser; + try { + const connection = await runner.setupWebDriverBiDiConnection({ + timeout, + slowMo, + preferredRevision: this._preferredRevision, + }); + browser = await BiDiBrowser.create({ + connection, + closeCallback: runner.close.bind(runner), + process: runner.proc, + }); + } catch (error) { + runner.kill(); + throw error; + } + + return browser; + } + let browser; try { const connection = await runner.setupConnection({ @@ -153,7 +177,7 @@ export class FirefoxLauncher implements ProductLauncher { slowMo, preferredRevision: this._preferredRevision, }); - browser = await Browser._create( + browser = await CDPBrowser._create( this.product, connection, [], diff --git a/src/node/ProductLauncher.ts b/src/node/ProductLauncher.ts index a1d7c84f460..e3dc3aabcec 100644 --- a/src/node/ProductLauncher.ts +++ b/src/node/ProductLauncher.ts @@ -15,7 +15,7 @@ */ import os from 'os'; -import {Browser} from '../common/Browser.js'; +import {Browser} from '../api/Browser.js'; import {BrowserFetcher} from './BrowserFetcher.js'; import { diff --git a/src/node/Puppeteer.ts b/src/node/Puppeteer.ts index 2ec38f668d4..b9968f469bf 100644 --- a/src/node/Puppeteer.ts +++ b/src/node/Puppeteer.ts @@ -22,7 +22,7 @@ import { 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 {Browser} from '../api/Browser.js'; import {createLauncher, ProductLauncher} from './ProductLauncher.js'; import {PUPPETEER_REVISIONS} from '../revisions.js'; import {Product} from '../common/Product.js'; diff --git a/src/types.ts b/src/types.ts index 00bed28c5a2..29176c03f0b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,6 @@ // AUTOGENERATED - Use `npm run generate:sources` to regenerate. +export * from './api/Browser.js'; export * from './common/Accessibility.js'; export * from './common/AriaQueryHandler.js'; export * from './common/Browser.js'; diff --git a/test/TestExpectations.json b/test/TestExpectations.json index 4983051534c..98c0f8f22ad 100644 --- a/test/TestExpectations.json +++ b/test/TestExpectations.json @@ -1942,5 +1942,23 @@ "platforms": ["darwin", "win32", "linux"], "parameters": ["firefox"], "expectations": ["SKIP"] + }, + { + "testIdPattern": "", + "platforms": ["darwin", "win32", "linux"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["SKIP"] + }, + { + "testIdPattern": "[launcher.spec] Launcher specs Puppeteer Puppeteer.launch can launch and close the browser", + "platforms": ["darwin", "win32", "linux"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["PASS"] + }, + { + "testIdPattern": "[Connection.spec] WebDriver BiDi", + "platforms": ["darwin", "win32", "linux"], + "parameters": ["firefox", "webDriverBiDi"], + "expectations": ["PASS"] } ] diff --git a/test/TestSuites.json b/test/TestSuites.json index 43d02dd09ef..dfa1cade5dd 100644 --- a/test/TestSuites.json +++ b/test/TestSuites.json @@ -19,6 +19,11 @@ "id": "firefox-headless", "platforms": ["linux"], "parameters": ["firefox", "headless"] + }, + { + "id": "firefox-bidi", + "platforms": ["linux"], + "parameters": ["firefox", "headless", "webDriverBiDi"] } ], "parameterDefinitons": { @@ -36,6 +41,9 @@ }, "chrome-headless": { "HEADLESS": "chrome" + }, + "webDriverBiDi": { + "PUPPETEER_PROTOCOL": "webDriverBiDi" } } } diff --git a/test/src/TargetManager.spec.ts b/test/src/TargetManager.spec.ts index e47ac9cc83b..76aedefaa89 100644 --- a/test/src/TargetManager.spec.ts +++ b/test/src/TargetManager.spec.ts @@ -20,18 +20,18 @@ import utils from './utils.js'; import expect from 'expect'; import { - Browser, - BrowserContext, + CDPBrowser, + CDPBrowserContext, } from '../../lib/cjs/puppeteer/common/Browser.js'; describe('TargetManager', () => { /* We use a special browser for this test as we need the --site-per-process flag */ - let browser: Browser; - let context: BrowserContext; + let browser: CDPBrowser; + let context: CDPBrowserContext; before(async () => { const {puppeteer, defaultBrowserOptions} = getTestState(); - browser = await puppeteer.launch( + browser = (await puppeteer.launch( Object.assign({}, defaultBrowserOptions, { args: (defaultBrowserOptions.args || []).concat([ '--site-per-process', @@ -39,7 +39,7 @@ describe('TargetManager', () => { '--host-rules=MAP * 127.0.0.1', ]), }) - ); + )) as CDPBrowser; }); beforeEach(async () => { diff --git a/test/src/bidi/Connection.spec.ts b/test/src/bidi/Connection.spec.ts new file mode 100644 index 00000000000..0bfa68fd411 --- /dev/null +++ b/test/src/bidi/Connection.spec.ts @@ -0,0 +1,59 @@ +/** + * 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 expect from 'expect'; +import {Connection} from '../../../lib/cjs/puppeteer/common/bidi/Connection.js'; +import {ConnectionTransport} from '../../../lib/cjs/puppeteer/common/ConnectionTransport.js'; + +describe('WebDriver BiDi', () => { + describe('Connection', () => { + class TestConnectionTransport implements ConnectionTransport { + sent: string[] = []; + closed = false; + + send(message: string) { + this.sent.push(message); + } + + close(): void { + this.closed = true; + } + } + + it('should work', async () => { + const transport = new TestConnectionTransport(); + const connection = new Connection(transport); + const responsePromise = connection.send('session.status', { + context: 'context', + }); + expect(transport.sent).toEqual([ + `{"id":1,"method":"session.status","params":{"context":"context"}}`, + ]); + const id = JSON.parse(transport.sent[0]!).id; + const rawResponse = { + id, + result: {ready: false, message: 'already connected'}, + }; + (transport as ConnectionTransport).onmessage?.( + JSON.stringify(rawResponse) + ); + const response = await responsePromise; + expect(response).toEqual(rawResponse.result); + connection.dispose(); + expect(transport.closed).toBeTruthy(); + }); + }); +}); diff --git a/test/src/ignorehttpserrors.spec.ts b/test/src/ignorehttpserrors.spec.ts index 493452de348..fd435f2405b 100644 --- a/test/src/ignorehttpserrors.spec.ts +++ b/test/src/ignorehttpserrors.spec.ts @@ -16,10 +16,7 @@ import expect from 'expect'; import {TLSSocket} from 'tls'; -import { - Browser, - BrowserContext, -} from '../../lib/cjs/puppeteer/common/Browser.js'; +import {Browser, BrowserContext} from '../../lib/cjs/puppeteer/api/Browser.js'; import {Page} from '../../lib/cjs/puppeteer/common/Page.js'; import {HTTPResponse} from '../../lib/cjs/puppeteer/common/HTTPResponse.js'; import {getTestState} from './mocha-utils.js'; diff --git a/test/src/launcher.spec.ts b/test/src/launcher.spec.ts index 49db77320ed..3f6a5a6897b 100644 --- a/test/src/launcher.spec.ts +++ b/test/src/launcher.spec.ts @@ -203,6 +203,11 @@ describe('Launcher specs', function () { }); }); describe('Puppeteer.launch', function () { + it('can launch and close the browser', async () => { + const {defaultBrowserOptions, puppeteer} = getTestState(); + const browser = await puppeteer.launch(defaultBrowserOptions); + await browser.close(); + }); it('should reject all promises when browser is closed', async () => { const {defaultBrowserOptions, puppeteer} = getTestState(); const browser = await puppeteer.launch(defaultBrowserOptions); diff --git a/test/src/mocha-utils.ts b/test/src/mocha-utils.ts index 487c7df0f9f..6e193f49dd7 100644 --- a/test/src/mocha-utils.ts +++ b/test/src/mocha-utils.ts @@ -20,10 +20,7 @@ import * as fs from 'fs'; import * as path from 'path'; import rimraf from 'rimraf'; import sinon from 'sinon'; -import { - Browser, - BrowserContext, -} from '../../lib/cjs/puppeteer/common/Browser.js'; +import {Browser, BrowserContext} from '../../lib/cjs/puppeteer/api/Browser.js'; import {Page} from '../../lib/cjs/puppeteer/common/Page.js'; import {isErrorLike} from '../../lib/cjs/puppeteer/util/ErrorLike.js'; import { @@ -71,6 +68,7 @@ const headless = (process.env['HEADLESS'] || 'true').trim().toLowerCase(); const isHeadless = headless === 'true' || headless === 'chrome'; const isFirefox = product === 'firefox'; const isChrome = product === 'chrome'; +const protocol = process.env['PUPPETEER_PROTOCOL'] || 'cdp'; let extraLaunchOptions = {}; try { @@ -91,6 +89,7 @@ const defaultBrowserOptions = Object.assign( executablePath: process.env['BINARY'], headless: headless === 'chrome' ? ('chrome' as const) : isHeadless, dumpio: !!process.env['DUMPIO'], + protocol: protocol as 'cdp' | 'webDriverBiDi', }, extraLaunchOptions ); diff --git a/test/src/oopif.spec.ts b/test/src/oopif.spec.ts index beb46cc18d6..dbc98eef9ba 100644 --- a/test/src/oopif.spec.ts +++ b/test/src/oopif.spec.ts @@ -17,10 +17,7 @@ import utils from './utils.js'; import expect from 'expect'; import {getTestState} from './mocha-utils.js'; -import { - Browser, - BrowserContext, -} from '../../lib/cjs/puppeteer/common/Browser.js'; +import {Browser, BrowserContext} from '../../lib/cjs/puppeteer/api/Browser.js'; import {Page} from '../../lib/cjs/puppeteer/common/Page.js'; describe('OOPIF', function () { diff --git a/test/src/proxy.spec.ts b/test/src/proxy.spec.ts index 921e6274037..10f7ccd76c5 100644 --- a/test/src/proxy.spec.ts +++ b/test/src/proxy.spec.ts @@ -19,7 +19,7 @@ import http from 'http'; import os from 'os'; import {getTestState} from './mocha-utils.js'; import type {Server, IncomingMessage, ServerResponse} from 'http'; -import type {Browser} from '../../lib/cjs/puppeteer/common/Browser.js'; +import type {Browser} from '../../lib/cjs/puppeteer/api/Browser.js'; import type {AddressInfo} from 'net'; import {TestServer} from '../../utils/testserver/lib/index.js'; diff --git a/test/src/tracing.spec.ts b/test/src/tracing.spec.ts index 3f582e73a77..b271a17243a 100644 --- a/test/src/tracing.spec.ts +++ b/test/src/tracing.spec.ts @@ -18,7 +18,7 @@ import fs from 'fs'; import path from 'path'; import expect from 'expect'; import {getTestState} from './mocha-utils.js'; -import {Browser} from '../../lib/cjs/puppeteer/common/Browser.js'; +import {Browser} from '../../lib/cjs/puppeteer/api/Browser.js'; import {Page} from '../../lib/cjs/puppeteer/common/Page.js'; describe('Tracing', function () { diff --git a/utils/generate_sources.ts b/utils/generate_sources.ts index 1507d11e5e0..8aa0d502d0d 100644 --- a/utils/generate_sources.ts +++ b/utils/generate_sources.ts @@ -6,7 +6,7 @@ import {sync as glob} from 'glob'; import path from 'path'; import {job} from './internal/job.js'; -const INCLUDED_FOLDERS = ['common', 'node', 'generated', 'util']; +const INCLUDED_FOLDERS = ['common', 'node', 'generated', 'util', 'api']; (async () => { await job('', async ({outputs}) => {