Initial commit

This commit is contained in:
Joe
2026-06-26 14:12:10 +02:00
commit 12518b259c
5258 changed files with 732924 additions and 0 deletions
+454
View File
@@ -0,0 +1,454 @@
/**
* @license
* Copyright 2017 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type {ChildProcess} from 'child_process';
import type {Protocol} from 'devtools-protocol';
import {
filterAsync,
firstValueFrom,
from,
merge,
raceWith,
} from '../../third_party/rxjs/rxjs.js';
import type {ProtocolType} from '../common/ConnectOptions.js';
import {EventEmitter, type EventType} from '../common/EventEmitter.js';
import {debugError, fromEmitterEvent, timeout} from '../common/util.js';
import {asyncDisposeSymbol, disposeSymbol} from '../util/disposable.js';
import type {BrowserContext} from './BrowserContext.js';
import type {Page} from './Page.js';
import type {Target} from './Target.js';
/**
* @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> | void;
/**
* @public
*/
export type TargetFilterCallback = (target: Target) => boolean;
/**
* @internal
*/
export type IsPageTargetCallback = (target: Target) => 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'],
['clipboard-sanitized-write', 'clipboardSanitizedWrite'],
['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'
| 'clipboard-sanitized-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_000`
*/
timeout?: number;
}
/**
* All the events a {@link Browser | browser instance} may emit.
*
* @public
*/
export const enum BrowserEvent {
/**
* Emitted when Puppeteer gets disconnected from the browser instance. This
* might happen because either:
*
* - The browser closes/crashes or
* - {@link Browser.disconnect} 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',
/**
* @internal
*/
TargetDiscovered = 'targetdiscovered',
}
export {
/**
* @deprecated Use {@link BrowserEvent}.
*/
BrowserEvent as BrowserEmittedEvents,
};
/**
* @public
*/
export interface BrowserEvents extends Record<EventType, unknown> {
[BrowserEvent.Disconnected]: undefined;
[BrowserEvent.TargetCreated]: Target;
[BrowserEvent.TargetDestroyed]: Target;
[BrowserEvent.TargetChanged]: Target;
/**
* @internal
*/
[BrowserEvent.TargetDiscovered]: Protocol.Target.TargetInfo;
}
/**
* @public
* @experimental
*/
export interface DebugInfo {
pendingProtocolErrors: Error[];
}
/**
* {@link Browser} represents a browser instance that is either:
*
* - connected to via {@link Puppeteer.connect} or
* - launched by {@link PuppeteerNode.launch}.
*
* {@link Browser} {@link EventEmitter | emits} various events which are
* documented in the {@link BrowserEvent} enum.
*
* @example Using a {@link Browser} to create a {@link Page}:
*
* ```ts
* import puppeteer from 'puppeteer';
*
* const browser = await puppeteer.launch();
* const page = await browser.newPage();
* await page.goto('https://example.com');
* await browser.close();
* ```
*
* @example Disconnecting from and reconnecting to a {@link Browser}:
*
* ```ts
* import puppeteer from 'puppeteer';
*
* const browser = await puppeteer.launch();
* // Store the endpoint to be able to reconnect to the browser.
* const browserWSEndpoint = browser.wsEndpoint();
* // Disconnect puppeteer from the browser.
* await browser.disconnect();
*
* // Use the endpoint to reestablish a connection
* const browser2 = await puppeteer.connect({browserWSEndpoint});
* // Close the browser.
* await browser2.close();
* ```
*
* @public
*/
export abstract class Browser extends EventEmitter<BrowserEvents> {
/**
* @internal
*/
constructor() {
super();
}
/**
* Gets the associated
* {@link https://nodejs.org/api/child_process.html#class-childprocess | ChildProcess}.
*
* @returns `null` if this instance was connected to via
* {@link Puppeteer.connect}.
*/
abstract process(): ChildProcess | null;
/**
* Creates a new incognito {@link BrowserContext | browser context}.
*
* This won't share cookies/cache with other {@link BrowserContext | browser contexts}.
*
* @example
*
* ```ts
* import puppeteer from 'puppeteer';
*
* 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');
* ```
*/
abstract createIncognitoBrowserContext(
options?: BrowserContextOptions
): Promise<BrowserContext>;
/**
* Gets a list of open {@link BrowserContext | browser contexts}.
*
* In a newly-created {@link Browser | browser}, this will return a single
* instance of {@link BrowserContext}.
*/
abstract browserContexts(): BrowserContext[];
/**
* Gets the default {@link BrowserContext | browser context}.
*
* @remarks The default {@link BrowserContext | browser context} cannot be
* closed.
*/
abstract defaultBrowserContext(): BrowserContext;
/**
* Gets the WebSocket URL to connect to this {@link Browser | browser}.
*
* This is usually used with {@link Puppeteer.connect}.
*
* You can find the debugger URL (`webSocketDebuggerUrl`) from
* `http://HOST:PORT/json/version`.
*
* See {@link
* https://chromedevtools.github.io/devtools-protocol/#how-do-i-access-the-browser-target
* | browser endpoint} for more information.
*
* @remarks The format is always `ws://HOST:PORT/devtools/browser/<id>`.
*/
abstract wsEndpoint(): string;
/**
* Creates a new {@link Page | page} in the
* {@link Browser.defaultBrowserContext | default browser context}.
*/
abstract newPage(): Promise<Page>;
/**
* Gets all active {@link Target | targets}.
*
* In case of multiple {@link BrowserContext | browser contexts}, this returns
* all {@link Target | targets} in all
* {@link BrowserContext | browser contexts}.
*/
abstract targets(): Target[];
/**
* Gets the {@link Target | target} associated with the
* {@link Browser.defaultBrowserContext | default browser context}).
*/
abstract target(): Target;
/**
* Waits until a {@link Target | target} matching the given `predicate`
* appears and returns it.
*
* This will look all open {@link BrowserContext | browser contexts}.
*
* @example 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: ms = 30000} = options;
return await firstValueFrom(
merge(
fromEmitterEvent(this, BrowserEvent.TargetCreated),
fromEmitterEvent(this, BrowserEvent.TargetChanged),
from(this.targets())
).pipe(filterAsync(predicate), raceWith(timeout(ms)))
);
}
/**
* Gets a list of all open {@link Page | pages} inside this {@link Browser}.
*
* If there ar multiple {@link BrowserContext | browser contexts}, this
* returns all {@link Page | pages} in all
* {@link BrowserContext | browser contexts}.
*
* @remarks Non-visible {@link Page | 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);
}, []);
}
/**
* Gets a string representing this {@link Browser | browser's} name and
* version.
*
* For headless browser, this is similar to `"HeadlessChrome/61.0.3153.0"`. For
* non-headless or new-headless, this is similar to `"Chrome/61.0.3153.0"`. For
* Firefox, it is similar to `"Firefox/116.0a1"`.
*
* The format of {@link Browser.version} might change with future releases of
* browsers.
*/
abstract version(): Promise<string>;
/**
* Gets this {@link Browser | browser's} original user agent.
*
* {@link Page | Pages} can override the user agent with
* {@link Page.setUserAgent}.
*
*/
abstract userAgent(): Promise<string>;
/**
* Closes this {@link Browser | browser} and all associated
* {@link Page | pages}.
*/
abstract close(): Promise<void>;
/**
* Disconnects Puppeteer from this {@link Browser | browser}, but leaves the
* process running.
*/
abstract disconnect(): Promise<void>;
/**
* Whether Puppeteer is connected to this {@link Browser | browser}.
*
* @deprecated Use {@link Browser | Browser.connected}.
*/
isConnected(): boolean {
return this.connected;
}
/**
* Whether Puppeteer is connected to this {@link Browser | browser}.
*/
abstract get connected(): boolean;
/** @internal */
[disposeSymbol](): void {
return void this.close().catch(debugError);
}
/** @internal */
[asyncDisposeSymbol](): Promise<void> {
return this.close();
}
/**
* @internal
*/
abstract get protocol(): ProtocolType;
/**
* Get debug information from Puppeteer.
*
* @remarks
*
* Currently, includes pending protocol calls. In the future, we might add more info.
*
* @public
* @experimental
*/
abstract get debugInfo(): DebugInfo;
}
+224
View File
@@ -0,0 +1,224 @@
/**
* @license
* Copyright 2017 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import {EventEmitter, type EventType} from '../common/EventEmitter.js';
import {debugError} from '../common/util.js';
import {asyncDisposeSymbol, disposeSymbol} from '../util/disposable.js';
import type {Browser, Permission, WaitForTargetOptions} from './Browser.js';
import type {Page} from './Page.js';
import type {Target} from './Target.js';
/**
* @public
*/
export const enum BrowserContextEvent {
/**
* 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',
}
export {
/**
* @deprecated Use {@link BrowserContextEvent}
*/
BrowserContextEvent as BrowserContextEmittedEvents,
};
/**
* @public
*/
export interface BrowserContextEvents extends Record<EventType, unknown> {
[BrowserContextEvent.TargetChanged]: Target;
[BrowserContextEvent.TargetCreated]: Target;
[BrowserContextEvent.TargetDestroyed]: Target;
}
/**
* {@link BrowserContext} represents individual sessions within a
* {@link Browser | browser}.
*
* When a {@link Browser | browser} is launched, it has a single
* {@link BrowserContext | browser context} by default. Others can be created
* using {@link Browser.createIncognitoBrowserContext}.
*
* {@link BrowserContext} {@link EventEmitter | emits} various events which are
* documented in the {@link BrowserContextEvent} enum.
*
* If a {@link Page | page} opens another {@link Page | page}, e.g. using
* `window.open`, the popup will belong to the parent {@link Page.browserContext
* | page's browser context}.
*
* @example Creating an incognito {@link BrowserContext | browser context}:
*
* ```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 abstract class BrowserContext extends EventEmitter<BrowserContextEvents> {
/**
* @internal
*/
constructor() {
super();
}
/**
* Gets all active {@link Target | targets} inside this
* {@link BrowserContext | browser context}.
*/
abstract targets(): Target[];
/**
* Waits until a {@link Target | target} matching the given `predicate`
* appears and returns it.
*
* This will look all open {@link BrowserContext | browser contexts}.
*
* @example 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/'
* );
* ```
*/
abstract waitForTarget(
predicate: (x: Target) => boolean | Promise<boolean>,
options?: WaitForTargetOptions
): Promise<Target>;
/**
* Gets a list of all open {@link Page | pages} inside this
* {@link BrowserContext | browser context}.
*
* @remarks Non-visible {@link Page | pages}, such as `"background_page"`,
* will not be listed here. You can find them using {@link Target.page}.
*/
abstract pages(): Promise<Page[]>;
/**
* Whether this {@link BrowserContext | browser context} is incognito.
*
* The {@link Browser.defaultBrowserContext | default browser context} is the
* only non-incognito browser context.
*/
abstract isIncognito(): boolean;
/**
* Grants this {@link BrowserContext | browser context} the given
* `permissions` within the given `origin`.
*
* @example Overriding permissions in the
* {@link Browser.defaultBrowserContext | default browser context}:
*
* ```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.
*/
abstract overridePermissions(
origin: string,
permissions: Permission[]
): Promise<void>;
/**
* Clears all permission overrides for this
* {@link BrowserContext | browser context}.
*
* @example Clearing overridden permissions in the
* {@link Browser.defaultBrowserContext | default browser context}:
*
* ```ts
* const context = browser.defaultBrowserContext();
* context.overridePermissions('https://example.com', ['clipboard-read']);
* // do stuff ..
* context.clearPermissionOverrides();
* ```
*/
abstract clearPermissionOverrides(): Promise<void>;
/**
* Creates a new {@link Page | page} in this
* {@link BrowserContext | browser context}.
*/
abstract newPage(): Promise<Page>;
/**
* Gets the {@link Browser | browser} associated with this
* {@link BrowserContext | browser context}.
*/
abstract browser(): Browser;
/**
* Closes this {@link BrowserContext | browser context} and all associated
* {@link Page | pages}.
*
* @remarks The
* {@link Browser.defaultBrowserContext | default browser context} cannot be
* closed.
*/
abstract close(): Promise<void>;
/**
* Whether this {@link BrowserContext | browser context} is closed.
*/
get closed(): boolean {
return !this.browser().browserContexts().includes(this);
}
/**
* Identifier for this {@link BrowserContext | browser context}.
*/
get id(): string | undefined {
return undefined;
}
/** @internal */
[disposeSymbol](): void {
return void this.close().catch(debugError);
}
/** @internal */
[asyncDisposeSymbol](): Promise<void> {
return this.close();
}
}
+121
View File
@@ -0,0 +1,121 @@
import type {ProtocolMapping} from 'devtools-protocol/types/protocol-mapping.js';
import type {Connection} from '../cdp/Connection.js';
import {EventEmitter, type EventType} from '../common/EventEmitter.js';
/**
* @public
*/
export type CDPEvents = {
[Property in keyof ProtocolMapping.Events]: ProtocolMapping.Events[Property][0];
};
/**
* Events that the CDPSession class emits.
*
* @public
*/
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace CDPSessionEvent {
/** @internal */
export const Disconnected = Symbol('CDPSession.Disconnected');
/** @internal */
export const Swapped = Symbol('CDPSession.Swapped');
/**
* Emitted when the session is ready to be configured during the auto-attach
* process. Right after the event is handled, the session will be resumed.
*
* @internal
*/
export const Ready = Symbol('CDPSession.Ready');
export const SessionAttached = 'sessionattached' as const;
export const SessionDetached = 'sessiondetached' as const;
}
/**
* @public
*/
export interface CDPSessionEvents
extends CDPEvents,
Record<EventType, unknown> {
/** @internal */
[CDPSessionEvent.Disconnected]: undefined;
/** @internal */
[CDPSessionEvent.Swapped]: CDPSession;
/** @internal */
[CDPSessionEvent.Ready]: CDPSession;
[CDPSessionEvent.SessionAttached]: CDPSession;
[CDPSessionEvent.SessionDetached]: CDPSession;
}
/**
* @public
*/
export interface CommandOptions {
timeout: number;
}
/**
* 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 abstract class CDPSession extends EventEmitter<CDPSessionEvents> {
/**
* @internal
*/
constructor() {
super();
}
abstract connection(): Connection | undefined;
/**
* Parent session in terms of CDP's auto-attach mechanism.
*
* @internal
*/
parentSession(): CDPSession | undefined {
return undefined;
}
abstract send<T extends keyof ProtocolMapping.Commands>(
method: T,
params?: ProtocolMapping.Commands[T]['paramsType'][0],
options?: CommandOptions
): Promise<ProtocolMapping.Commands[T]['returnType']>;
/**
* Detaches the cdpSession from the target. Once detached, the cdpSession object
* won't emit any events and can't be used to send messages.
*/
abstract detach(): Promise<void>;
/**
* Returns the session's id.
*/
abstract id(): string;
}
+110
View File
@@ -0,0 +1,110 @@
/**
* @license
* Copyright 2017 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type {Protocol} from 'devtools-protocol';
import {assert} from '../util/assert.js';
/**
* Dialog instances are dispatched by the {@link Page} via the `dialog` event.
*
* @remarks
*
* @example
*
* ```ts
* import puppeteer from '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 abstract class Dialog {
#type: Protocol.Page.DialogType;
#message: string;
#defaultValue: string;
#handled = false;
/**
* @internal
*/
constructor(
type: Protocol.Page.DialogType,
message: string,
defaultValue = ''
) {
this.#type = type;
this.#message = message;
this.#defaultValue = defaultValue;
}
/**
* The type of the dialog.
*/
type(): Protocol.Page.DialogType {
return this.#type;
}
/**
* The message displayed in the dialog.
*/
message(): string {
return this.#message;
}
/**
* The default value of the prompt, or an empty string if the dialog
* is not a `prompt`.
*/
defaultValue(): string {
return this.#defaultValue;
}
/**
* @internal
*/
protected abstract handle(options: {
accept: boolean;
text?: string;
}): Promise<void>;
/**
* A promise that resolves when the dialog has been accepted.
*
* @param promptText - optional text that will be entered in the dialog
* prompt. Has no effect if the dialog's type is not `prompt`.
*
*/
async accept(promptText?: string): Promise<void> {
assert(!this.#handled, 'Cannot accept dialog which is already handled!');
this.#handled = true;
await this.handle({
accept: true,
text: promptText,
});
}
/**
* 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.handle({
accept: false,
});
}
}
File diff suppressed because it is too large Load Diff
+10
View File
@@ -0,0 +1,10 @@
/**
* @license
* Copyright 2023 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @internal
*/
export const _isElementHandle = Symbol('_isElementHandle');
+16
View File
@@ -0,0 +1,16 @@
/**
* @license
* Copyright 2023 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type {CDPSession} from './CDPSession.js';
import type {Realm} from './Realm.js';
/**
* @internal
*/
export interface Environment {
get client(): CDPSession;
mainRealm(): Realm;
}
+1218
View File
File diff suppressed because it is too large Load Diff
+521
View File
@@ -0,0 +1,521 @@
/**
* @license
* Copyright 2020 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type {Protocol} from 'devtools-protocol';
import type {CDPSession} from './CDPSession.js';
import type {Frame} from './Frame.js';
import type {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;
/**
* 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 abstract class HTTPRequest {
/**
* @internal
*/
_requestId = '';
/**
* @internal
*/
_interceptionId: string | undefined;
/**
* @internal
*/
_failureText: string | null = null;
/**
* @internal
*/
_response: HTTPResponse | null = null;
/**
* @internal
*/
_fromMemoryCache = false;
/**
* @internal
*/
_redirectChain: HTTPRequest[] = [];
/**
* Warning! Using this client can break Puppeteer. Use with caution.
*
* @experimental
*/
abstract get client(): CDPSession;
/**
* @internal
*/
constructor() {}
/**
* The URL of the request
*/
abstract url(): string;
/**
* The `ContinueRequestOverrides` that will be used
* if the interception is allowed to continue (ie, `abort()` and
* `respond()` aren't called).
*/
abstract continueRequestOverrides(): ContinueRequestOverrides;
/**
* The `ResponseForRequest` that gets used if the
* interception is allowed to respond (ie, `abort()` is not called).
*/
abstract responseForRequest(): Partial<ResponseForRequest> | null;
/**
* The most recent reason for aborting the request
*/
abstract abortErrorReason(): Protocol.Network.ErrorReason | null;
/**
* 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`.
*/
abstract interceptResolutionState(): InterceptResolutionState;
/**
* Is `true` if the intercept resolution has already been handled,
* `false` otherwise.
*/
abstract isInterceptResolutionHandled(): boolean;
/**
* 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.
*/
abstract enqueueInterceptAction(
pendingHandler: () => void | PromiseLike<unknown>
): void;
/**
* Awaits pending interception handlers and then decides how to fulfill
* the request interception.
*/
abstract finalizeInterceptions(): Promise<void>;
/**
* Contains the request's resource type as it was perceived by the rendering
* engine.
*/
abstract resourceType(): ResourceType;
/**
* The method used (`GET`, `POST`, etc.)
*/
abstract method(): string;
/**
* The request's post body, if any.
*/
abstract postData(): string | undefined;
/**
* True when the request has POST data. Note that {@link HTTPRequest.postData}
* might still be undefined when this flag is true when the data is too long
* or not readily available in the decoded form. In that case, use
* {@link HTTPRequest.fetchPostData}.
*/
abstract hasPostData(): boolean;
/**
* Fetches the POST data for the request from the browser.
*/
abstract fetchPostData(): Promise<string | undefined>;
/**
* An object with HTTP headers associated with the request. All
* header names are lower-case.
*/
abstract headers(): Record<string, string>;
/**
* A matching `HTTPResponse` object, or null if the response has not
* been received yet.
*/
abstract response(): HTTPResponse | null;
/**
* The frame that initiated the request, or null if navigating to
* error pages.
*/
abstract frame(): Frame | null;
/**
* True if the request is the driver of the current frame's navigation.
*/
abstract isNavigationRequest(): boolean;
/**
* The initiator of the request.
*/
abstract initiator(): Protocol.Network.Initiator | undefined;
/**
* 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.
*/
abstract redirectChain(): HTTPRequest[];
/**
* 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.
*/
abstract failure(): {errorText: string} | null;
/**
* Continues request with optional request overrides.
*
* @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.
*
* @remarks
*
* To use this, request interception should be enabled with
* {@link Page.setRequestInterception}.
*
* Exception is immediately thrown if the request interception is not enabled.
*/
abstract continue(
overrides?: ContinueRequestOverrides,
priority?: number
): Promise<void>;
/**
* Fulfills a request with the given response.
*
* @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.
*
* @remarks
*
* To use this, request
* interception should be enabled with {@link Page.setRequestInterception}.
*
* Exception is immediately thrown if the request interception is not enabled.
*/
abstract respond(
response: Partial<ResponseForRequest>,
priority?: number
): Promise<void>;
/**
* Aborts a request.
*
* @param errorCode - optional error code to provide.
* @param priority - If provided, intercept is resolved using
* cooperative handling rules. Otherwise, intercept is resolved
* immediately.
*
* @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.
*/
abstract abort(errorCode?: ErrorCode, priority?: number): Promise<void>;
}
/**
* @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';
/**
* @public
*/
export type ActionResult = 'continue' | 'abort' | 'respond';
/**
* @internal
*/
export 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;
}
/**
* @internal
*
* @remarks
* List taken from {@link https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml}
* with extra 306 and 418 codes.
*/
export const STATUS_TEXTS: Record<string, string> = {
'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;
+129
View File
@@ -0,0 +1,129 @@
/**
* @license
* Copyright 2023 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type Protocol from 'devtools-protocol';
import type {SecurityDetails} from '../common/SecurityDetails.js';
import type {Frame} from './Frame.js';
import type {HTTPRequest} from './HTTPRequest.js';
/**
* @public
*/
export interface RemoteAddress {
ip?: string;
port?: number;
}
/**
* The HTTPResponse class represents responses which are received by the
* {@link Page} class.
*
* @public
*/
export abstract class HTTPResponse {
/**
* @internal
*/
constructor() {}
/**
* The IP address and port number used to connect to the remote
* server.
*/
abstract remoteAddress(): RemoteAddress;
/**
* The URL of the response.
*/
abstract url(): string;
/**
* True if the response was successful (status in the range 200-299).
*/
ok(): boolean {
// TODO: document === 0 case?
const status = this.status();
return status === 0 || (status >= 200 && status <= 299);
}
/**
* The status code of the response (e.g., 200 for a success).
*/
abstract status(): number;
/**
* The status text of the response (e.g. usually an "OK" for a
* success).
*/
abstract statusText(): string;
/**
* An object with HTTP headers associated with the response. All
* header names are lower-case.
*/
abstract headers(): Record<string, string>;
/**
* {@link SecurityDetails} if the response was received over the
* secure connection, or `null` otherwise.
*/
abstract securityDetails(): SecurityDetails | null;
/**
* Timing information related to the response.
*/
abstract timing(): Protocol.Network.ResourceTiming | null;
/**
* Promise which resolves to a buffer with response body.
*/
abstract buffer(): Promise<Buffer>;
/**
* Promise which resolves to a text representation of response body.
*/
async text(): Promise<string> {
const content = await this.buffer();
return content.toString('utf8');
}
/**
* 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);
}
/**
* A matching {@link HTTPRequest} object.
*/
abstract request(): HTTPRequest;
/**
* True if the response was served from either the browser's disk
* cache or memory cache.
*/
abstract fromCache(): boolean;
/**
* True if the response was served by a service worker.
*/
abstract fromServiceWorker(): boolean;
/**
* A {@link Frame} that initiated this response, or `null` if
* navigating to error pages.
*/
abstract frame(): Frame | null;
}
+517
View File
@@ -0,0 +1,517 @@
/**
* @license
* Copyright 2017 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type {Protocol} from 'devtools-protocol';
import type {KeyInput} from '../common/USKeyboardLayout.js';
import type {Point} from './ElementHandle.js';
/**
* @public
*/
export interface KeyDownOptions {
/**
* @deprecated Do not use. This is automatically handled.
*/
text?: string;
/**
* @deprecated Do not use. This is automatically handled.
*/
commands?: string[];
}
/**
* @public
*/
export interface KeyboardTypeOptions {
delay?: number;
}
/**
* @public
*/
export type KeyPressOptions = KeyDownOptions & KeyboardTypeOptions;
/**
* 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 abstract class Keyboard {
/**
* @internal
*/
constructor() {}
/**
* 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. Accepts commands which, if specified,
* is the commands of keyboard shortcuts,
* see {@link https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/editing/commands/editor_command_names.h | Chromium Source Code} for valid command names.
*/
abstract down(
key: KeyInput,
options?: Readonly<KeyDownOptions>
): Promise<void>;
/**
* 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.
*/
abstract up(key: KeyInput): Promise<void>;
/**
* 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.
*/
abstract sendCharacter(char: string): Promise<void>;
/**
* 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.
*/
abstract type(
text: string,
options?: Readonly<KeyboardTypeOptions>
): Promise<void>;
/**
* 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. Accepts commands which, if specified,
* is the commands of keyboard shortcuts,
* see {@link https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/editing/commands/editor_command_names.h | Chromium Source Code} for valid command names.
*/
abstract press(
key: KeyInput,
options?: Readonly<KeyPressOptions>
): Promise<void>;
}
/**
* @public
*/
export interface MouseOptions {
/**
* Determines which button will be pressed.
*
* @defaultValue `'left'`
*/
button?: MouseButton;
/**
* Determines the click count for the mouse event. This does not perform
* multiple clicks.
*
* @deprecated Use {@link MouseClickOptions.count}.
* @defaultValue `1`
*/
clickCount?: number;
}
/**
* @public
*/
export interface MouseClickOptions extends MouseOptions {
/**
* Time (in ms) to delay the mouse release after the mouse press.
*/
delay?: number;
/**
* Number of clicks to perform.
*
* @defaultValue `1`
*/
count?: number;
}
/**
* @public
*/
export interface MouseWheelOptions {
deltaX?: number;
deltaY?: number;
}
/**
* @public
*/
export interface MouseMoveOptions {
/**
* Determines the number of movements to make from the current mouse position
* to the new one.
*
* @defaultValue `1`
*/
steps?: number;
}
/**
* Enum of valid mouse buttons.
*
* @public
*/
export const MouseButton = Object.freeze({
Left: 'left',
Right: 'right',
Middle: 'middle',
Back: 'back',
Forward: 'forward',
}) satisfies Record<string, Protocol.Input.MouseButton>;
/**
* @public
*/
export type MouseButton = (typeof MouseButton)[keyof typeof MouseButton];
/**
* 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 abstract class Mouse {
/**
* @internal
*/
constructor() {}
/**
* Resets the mouse to the default state: No buttons pressed; position at
* (0,0).
*/
abstract reset(): Promise<void>;
/**
* Moves the mouse to the given coordinate.
*
* @param x - Horizontal position of the mouse.
* @param y - Vertical position of the mouse.
* @param options - Options to configure behavior.
*/
abstract move(
x: number,
y: number,
options?: Readonly<MouseMoveOptions>
): Promise<void>;
/**
* Presses the mouse.
*
* @param options - Options to configure behavior.
*/
abstract down(options?: Readonly<MouseOptions>): Promise<void>;
/**
* Releases the mouse.
*
* @param options - Options to configure behavior.
*/
abstract up(options?: Readonly<MouseOptions>): Promise<void>;
/**
* 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 - Options to configure behavior.
*/
abstract click(
x: number,
y: number,
options?: Readonly<MouseClickOptions>
): Promise<void>;
/**
* 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});
* ```
*/
abstract wheel(options?: Readonly<MouseWheelOptions>): Promise<void>;
/**
* Dispatches a `drag` event.
* @param start - starting point for drag
* @param target - point to drag to
*/
abstract drag(start: Point, target: Point): Promise<Protocol.Input.DragData>;
/**
* Dispatches a `dragenter` event.
* @param target - point for emitting `dragenter` event
* @param data - drag data containing items and operations mask
*/
abstract dragEnter(
target: Point,
data: Protocol.Input.DragData
): Promise<void>;
/**
* Dispatches a `dragover` event.
* @param target - point for emitting `dragover` event
* @param data - drag data containing items and operations mask
*/
abstract dragOver(
target: Point,
data: Protocol.Input.DragData
): Promise<void>;
/**
* Performs a dragenter, dragover, and drop in sequence.
* @param target - point to drop on
* @param data - drag data containing items and operations mask
*/
abstract drop(target: Point, data: Protocol.Input.DragData): Promise<void>;
/**
* Performs a drag, dragenter, dragover, and drop in sequence.
* @param start - 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.
*/
abstract dragAndDrop(
start: Point,
target: Point,
options?: {delay?: number}
): Promise<void>;
}
/**
* The Touchscreen class exposes touchscreen events.
* @public
*/
export abstract class Touchscreen {
/**
* @internal
*/
constructor() {}
/**
* 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> {
await this.touchStart(x, y);
await this.touchEnd();
}
/**
* Dispatches a `touchstart` event.
* @param x - Horizontal position of the tap.
* @param y - Vertical position of the tap.
*/
abstract touchStart(x: number, y: number): Promise<void>;
/**
* Dispatches a `touchMove` event.
* @param x - Horizontal position of the move.
* @param y - Vertical position of the move.
*
* @remarks
*
* Not every `touchMove` call results in a `touchmove` event being emitted,
* depending on the browser's optimizations. For example, Chrome
* {@link https://developer.chrome.com/blog/a-more-compatible-smoother-touch/#chromes-new-model-the-throttled-async-touchmove-model | throttles}
* touch move events.
*/
abstract touchMove(x: number, y: number): Promise<void>;
/**
* Dispatches a `touchend` event.
*/
abstract touchEnd(): Promise<void>;
}
+212
View File
@@ -0,0 +1,212 @@
/**
* @license
* Copyright 2023 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type Protocol from 'devtools-protocol';
import type {EvaluateFuncWith, HandleFor, HandleOr} from '../common/types.js';
import {debugError, withSourcePuppeteerURLIfNone} from '../common/util.js';
import {moveable, throwIfDisposed} from '../util/decorators.js';
import {disposeSymbol, asyncDisposeSymbol} from '../util/disposable.js';
import type {ElementHandle} from './ElementHandle.js';
import type {Realm} from './Realm.js';
/**
* 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
*/
@moveable
export abstract class JSHandle<T = unknown> {
declare move: () => this;
/**
* Used for nominally typing {@link JSHandle}.
*/
declare _?: T;
/**
* @internal
*/
constructor() {}
/**
* @internal
*/
abstract get realm(): Realm;
/**
* @internal
*/
abstract get disposed(): boolean;
/**
* Evaluates the given function with the current handle as its first argument.
*/
async evaluate<
Params extends unknown[],
Func extends EvaluateFuncWith<T, Params> = EvaluateFuncWith<T, Params>,
>(
pageFunction: Func | string,
...args: Params
): Promise<Awaited<ReturnType<Func>>> {
pageFunction = withSourcePuppeteerURLIfNone(
this.evaluate.name,
pageFunction
);
return await this.realm.evaluate(pageFunction, this, ...args);
}
/**
* Evaluates the given function with the current handle as its first argument.
*
*/
async evaluateHandle<
Params extends unknown[],
Func extends EvaluateFuncWith<T, Params> = EvaluateFuncWith<T, Params>,
>(
pageFunction: Func | string,
...args: Params
): Promise<HandleFor<Awaited<ReturnType<Func>>>> {
pageFunction = withSourcePuppeteerURLIfNone(
this.evaluateHandle.name,
pageFunction
);
return await this.realm.evaluateHandle(pageFunction, this, ...args);
}
/**
* Fetches a single property from the referenced object.
*/
getProperty<K extends keyof T>(
propertyName: HandleOr<K>
): Promise<HandleFor<T[K]>>;
getProperty(propertyName: string): Promise<JSHandle<unknown>>;
/**
* @internal
*/
@throwIfDisposed()
async getProperty<K extends keyof T>(
propertyName: HandleOr<K>
): Promise<HandleFor<T[K]>> {
return await this.evaluateHandle((object, propertyName) => {
return object[propertyName as K];
}, 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
* ```
*/
@throwIfDisposed()
async getProperties(): Promise<Map<string, JSHandle>> {
const propertyNames = await this.evaluate(object => {
const enumerableProperties = [];
const descriptors = Object.getOwnPropertyDescriptors(object);
for (const propertyName in descriptors) {
if (descriptors[propertyName]?.enumerable) {
enumerableProperties.push(propertyName);
}
}
return enumerableProperties;
});
const map = new Map<string, JSHandle>();
const results = await Promise.all(
propertyNames.map(key => {
return this.getProperty(key);
})
);
for (const [key, value] of Object.entries(propertyNames)) {
using handle = results[key as any];
if (handle) {
map.set(value, handle.move());
}
}
return map;
}
/**
* 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.
*/
abstract jsonValue(): Promise<T>;
/**
* Either `null` or the handle itself if the handle is an
* instance of {@link ElementHandle}.
*/
abstract asElement(): ElementHandle<Node> | null;
/**
* Releases the object referenced by the handle for garbage collection.
*/
abstract dispose(): Promise<void>;
/**
* Returns a string representation of the JSHandle.
*
* @remarks
* Useful during debugging.
*/
abstract toString(): string;
/**
* @internal
*/
abstract get id(): string | undefined;
/**
* Provides access to the
* {@link https://chromedevtools.github.io/devtools-protocol/tot/Runtime/#type-RemoteObject | Protocol.Runtime.RemoteObject}
* backing this handle.
*/
abstract remoteObject(): Protocol.Runtime.RemoteObject;
/** @internal */
[disposeSymbol](): void {
return void this.dispose().catch(debugError);
}
/** @internal */
[asyncDisposeSymbol](): Promise<void> {
return this.dispose();
}
}
+3087
View File
File diff suppressed because it is too large Load Diff
+104
View File
@@ -0,0 +1,104 @@
/**
* @license
* Copyright 2023 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type {TimeoutSettings} from '../common/TimeoutSettings.js';
import type {
EvaluateFunc,
HandleFor,
InnerLazyParams,
} from '../common/types.js';
import {TaskManager, WaitTask} from '../common/WaitTask.js';
import {disposeSymbol} from '../util/disposable.js';
import type {ElementHandle} from './ElementHandle.js';
import type {Environment} from './Environment.js';
import type {JSHandle} from './JSHandle.js';
/**
* @internal
*/
export abstract class Realm implements Disposable {
protected readonly timeoutSettings: TimeoutSettings;
readonly taskManager = new TaskManager();
constructor(timeoutSettings: TimeoutSettings) {
this.timeoutSettings = timeoutSettings;
}
abstract get environment(): Environment;
abstract adoptHandle<T extends JSHandle<Node>>(handle: T): Promise<T>;
abstract transferHandle<T extends JSHandle<Node>>(handle: T): Promise<T>;
abstract evaluateHandle<
Params extends unknown[],
Func extends EvaluateFunc<Params> = EvaluateFunc<Params>,
>(
pageFunction: Func | string,
...args: Params
): Promise<HandleFor<Awaited<ReturnType<Func>>>>;
abstract evaluate<
Params extends unknown[],
Func extends EvaluateFunc<Params> = EvaluateFunc<Params>,
>(
pageFunction: Func | string,
...args: Params
): Promise<Awaited<ReturnType<Func>>>;
async waitForFunction<
Params extends unknown[],
Func extends EvaluateFunc<InnerLazyParams<Params>> = EvaluateFunc<
InnerLazyParams<Params>
>,
>(
pageFunction: Func | string,
options: {
polling?: 'raf' | 'mutation' | number;
timeout?: number;
root?: ElementHandle<Node>;
signal?: AbortSignal;
} = {},
...args: Params
): Promise<HandleFor<Awaited<ReturnType<Func>>>> {
const {
polling = 'raf',
timeout = this.timeoutSettings.timeout(),
root,
signal,
} = options;
if (typeof polling === 'number' && polling < 0) {
throw new Error('Cannot poll with non-positive interval');
}
const waitTask = new WaitTask(
this,
{
polling,
root,
timeout,
signal,
},
pageFunction as unknown as
| ((...args: unknown[]) => Promise<Awaited<ReturnType<Func>>>)
| string,
...args
);
return await waitTask.result;
}
abstract adoptBackendNode(backendNodeId?: number): Promise<JSHandle<Node>>;
get disposed(): boolean {
return this.#disposed;
}
#disposed = false;
/** @internal */
[disposeSymbol](): void {
this.#disposed = true;
this.taskManager.terminateAll(
new Error('waitForFunction failed: frame got detached.')
);
}
}
+95
View File
@@ -0,0 +1,95 @@
/**
* @license
* Copyright 2023 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type {Browser} from './Browser.js';
import type {BrowserContext} from './BrowserContext.js';
import type {CDPSession} from './CDPSession.js';
import type {Page} from './Page.js';
import type {WebWorker} from './WebWorker.js';
/**
* @public
*/
export enum TargetType {
PAGE = 'page',
BACKGROUND_PAGE = 'background_page',
SERVICE_WORKER = 'service_worker',
SHARED_WORKER = 'shared_worker',
BROWSER = 'browser',
WEBVIEW = 'webview',
OTHER = 'other',
/**
* @internal
*/
TAB = 'tab',
}
/**
* Target represents a
* {@link https://chromedevtools.github.io/devtools-protocol/tot/Target/ | CDP target}.
* In CDP a target is something that can be debugged such a frame, a page or a
* worker.
* @public
*/
export abstract class Target {
/**
* @internal
*/
protected constructor() {}
/**
* If the target is not of type `"service_worker"` or `"shared_worker"`, returns `null`.
*/
async worker(): Promise<WebWorker | null> {
return null;
}
/**
* If the target is not of type `"page"`, `"webview"` or `"background_page"`,
* returns `null`.
*/
async page(): Promise<Page | null> {
return null;
}
/**
* Forcefully creates a page for a target of any type. It is useful if you
* want to handle a CDP target of type `other` as a page. If you deal with a
* regular page target, use {@link Target.page}.
*/
abstract asPage(): Promise<Page>;
abstract url(): string;
/**
* Creates a Chrome Devtools Protocol session attached to the target.
*/
abstract createCDPSession(): Promise<CDPSession>;
/**
* Identifies what kind of target this is.
*
* @remarks
*
* See {@link https://developer.chrome.com/extensions/background_pages | docs} for more info about background pages.
*/
abstract type(): TargetType;
/**
* Get the browser the target belongs to.
*/
abstract browser(): Browser;
/**
* Get the browser context the target belongs to.
*/
abstract browserContext(): BrowserContext;
/**
* Get the target that opened this target. Top-level targets return `null`.
*/
abstract opener(): Target | undefined;
}
+134
View File
@@ -0,0 +1,134 @@
/**
* @license
* Copyright 2018 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import {EventEmitter, type EventType} from '../common/EventEmitter.js';
import {TimeoutSettings} from '../common/TimeoutSettings.js';
import type {EvaluateFunc, HandleFor} from '../common/types.js';
import {withSourcePuppeteerURLIfNone} from '../common/util.js';
import type {CDPSession} from './CDPSession.js';
import type {Realm} from './Realm.js';
/**
* 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 abstract class WebWorker extends EventEmitter<
Record<EventType, unknown>
> {
/**
* @internal
*/
readonly timeoutSettings = new TimeoutSettings();
readonly #url: string;
/**
* @internal
*/
constructor(url: string) {
super();
this.#url = url;
}
/**
* @internal
*/
abstract mainRealm(): Realm;
/**
* The URL of this web worker.
*/
url(): string {
return this.#url;
}
/**
* The CDP session client the WebWorker belongs to.
*/
abstract get client(): CDPSession;
/**
* Evaluates a given function in the {@link WebWorker | worker}.
*
* @remarks If the given function returns a promise,
* {@link WebWorker.evaluate | evaluate} will wait for the promise to resolve.
*
* As a rule of thumb, if the return value of the given function is more
* complicated than a JSON object (e.g. most classes), then
* {@link WebWorker.evaluate | evaluate} will _likely_ return some truncated
* value (or `{}`). This is because we are not returning the actual return
* value, but a deserialized version as a result of transferring the return
* value through a protocol to Puppeteer.
*
* In general, you should use
* {@link WebWorker.evaluateHandle | evaluateHandle} if
* {@link WebWorker.evaluate | evaluate} cannot serialize the return value
* properly or you need a mutable {@link JSHandle | handle} to the return
* object.
*
* @param func - Function to be evaluated.
* @param args - Arguments to pass into `func`.
* @returns The result of `func`.
*/
async evaluate<
Params extends unknown[],
Func extends EvaluateFunc<Params> = EvaluateFunc<Params>,
>(func: Func | string, ...args: Params): Promise<Awaited<ReturnType<Func>>> {
func = withSourcePuppeteerURLIfNone(this.evaluate.name, func);
return await this.mainRealm().evaluate(func, ...args);
}
/**
* Evaluates a given function in the {@link WebWorker | worker}.
*
* @remarks If the given function returns a promise,
* {@link WebWorker.evaluate | evaluate} will wait for the promise to resolve.
*
* In general, you should use
* {@link WebWorker.evaluateHandle | evaluateHandle} if
* {@link WebWorker.evaluate | evaluate} cannot serialize the return value
* properly or you need a mutable {@link JSHandle | handle} to the return
* object.
*
* @param func - Function to be evaluated.
* @param args - Arguments to pass into `func`.
* @returns A {@link JSHandle | handle} to the return value of `func`.
*/
async evaluateHandle<
Params extends unknown[],
Func extends EvaluateFunc<Params> = EvaluateFunc<Params>,
>(
func: Func | string,
...args: Params
): Promise<HandleFor<Awaited<ReturnType<Func>>>> {
func = withSourcePuppeteerURLIfNone(this.evaluateHandle.name, func);
return await this.mainRealm().evaluateHandle(func, ...args);
}
}
+22
View File
@@ -0,0 +1,22 @@
/**
* @license
* Copyright 2022 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
export * from './Browser.js';
export * from './BrowserContext.js';
export * from './CDPSession.js';
export * from './Dialog.js';
export * from './ElementHandle.js';
export * from './Environment.js';
export * from './Frame.js';
export * from './HTTPRequest.js';
export * from './HTTPResponse.js';
export * from './Input.js';
export * from './JSHandle.js';
export * from './Page.js';
export * from './Realm.js';
export * from './Target.js';
export * from './WebWorker.js';
export * from './locators/locators.js';
File diff suppressed because it is too large Load Diff
+209
View File
@@ -0,0 +1,209 @@
/**
* @license
* Copyright 2023 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import * as BidiMapper from 'chromium-bidi/lib/cjs/bidiMapper/BidiMapper.js';
import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
import type {ProtocolMapping} from 'devtools-protocol/types/protocol-mapping.js';
import type {CDPEvents, CDPSession} from '../api/CDPSession.js';
import type {Connection as CdpConnection} from '../cdp/Connection.js';
import {debug} from '../common/Debug.js';
import {TargetCloseError} from '../common/Errors.js';
import type {Handler} from '../common/EventEmitter.js';
import {BidiConnection} from './Connection.js';
const bidiServerLogger = (prefix: string, ...args: unknown[]): void => {
debug(`bidi:${prefix}`)(args);
};
/**
* @internal
*/
export async function connectBidiOverCdp(
cdp: CdpConnection,
// TODO: replace with `BidiMapper.MapperOptions`, once it's exported in
// https://github.com/puppeteer/puppeteer/pull/11415.
options: {acceptInsecureCerts: boolean}
): Promise<BidiConnection> {
const transportBiDi = new NoOpTransport();
const cdpConnectionAdapter = new CdpConnectionAdapter(cdp);
const pptrTransport = {
send(message: string): void {
// Forwards a BiDi command sent by Puppeteer to the input of the BidiServer.
transportBiDi.emitMessage(JSON.parse(message));
},
close(): void {
bidiServer.close();
cdpConnectionAdapter.close();
cdp.dispose();
},
onmessage(_message: string): void {
// The method is overridden by the Connection.
},
};
transportBiDi.on('bidiResponse', (message: object) => {
// Forwards a BiDi event sent by BidiServer to Puppeteer.
pptrTransport.onmessage(JSON.stringify(message));
});
const pptrBiDiConnection = new BidiConnection(cdp.url(), pptrTransport);
const bidiServer = await BidiMapper.BidiServer.createAndStart(
transportBiDi,
cdpConnectionAdapter,
// TODO: most likely need a little bit of refactoring
cdpConnectionAdapter.browserClient(),
'',
options,
undefined,
bidiServerLogger
);
return pptrBiDiConnection;
}
/**
* Manages CDPSessions for BidiServer.
* @internal
*/
class CdpConnectionAdapter {
#cdp: CdpConnection;
#adapters = new Map<CDPSession, CDPClientAdapter<CDPSession>>();
#browserCdpConnection: CDPClientAdapter<CdpConnection>;
constructor(cdp: CdpConnection) {
this.#cdp = cdp;
this.#browserCdpConnection = new CDPClientAdapter(cdp);
}
browserClient(): CDPClientAdapter<CdpConnection> {
return this.#browserCdpConnection;
}
getCdpClient(id: string) {
const session = this.#cdp.session(id);
if (!session) {
throw new Error(`Unknown CDP session with id ${id}`);
}
if (!this.#adapters.has(session)) {
const adapter = new CDPClientAdapter(
session,
id,
this.#browserCdpConnection
);
this.#adapters.set(session, adapter);
return adapter;
}
return this.#adapters.get(session)!;
}
close() {
this.#browserCdpConnection.close();
for (const adapter of this.#adapters.values()) {
adapter.close();
}
}
}
/**
* Wrapper on top of CDPSession/CDPConnection to satisfy CDP interface that
* BidiServer needs.
*
* @internal
*/
class CDPClientAdapter<T extends CDPSession | CdpConnection>
extends BidiMapper.EventEmitter<CDPEvents>
implements BidiMapper.CdpClient
{
#closed = false;
#client: T;
sessionId: string | undefined = undefined;
#browserClient?: BidiMapper.CdpClient;
constructor(
client: T,
sessionId?: string,
browserClient?: BidiMapper.CdpClient
) {
super();
this.#client = client;
this.sessionId = sessionId;
this.#browserClient = browserClient;
this.#client.on('*', this.#forwardMessage as Handler<any>);
}
browserClient(): BidiMapper.CdpClient {
return this.#browserClient!;
}
#forwardMessage = <T extends keyof CDPEvents>(
method: T,
event: CDPEvents[T]
) => {
this.emit(method, event);
};
async sendCommand<T extends keyof ProtocolMapping.Commands>(
method: T,
...params: ProtocolMapping.Commands[T]['paramsType']
): Promise<ProtocolMapping.Commands[T]['returnType']> {
if (this.#closed) {
return;
}
try {
return await this.#client.send(method, ...params);
} catch (err) {
if (this.#closed) {
return;
}
throw err;
}
}
close() {
this.#client.off('*', this.#forwardMessage as Handler<any>);
this.#closed = true;
}
isCloseError(error: unknown): boolean {
return error instanceof TargetCloseError;
}
}
/**
* This transport is given to the BiDi server instance and allows Puppeteer
* to send and receive commands to the BiDiServer.
* @internal
*/
class NoOpTransport
extends BidiMapper.EventEmitter<{
bidiResponse: Bidi.ChromiumBidi.Message;
}>
implements BidiMapper.BidiTransport
{
#onMessage: (message: Bidi.ChromiumBidi.Command) => Promise<void> | void =
async (_m: Bidi.ChromiumBidi.Command): Promise<void> => {
return;
};
emitMessage(message: Bidi.ChromiumBidi.Command) {
void this.#onMessage(message);
}
setOnMessage(
onMessage: (message: Bidi.ChromiumBidi.Command) => Promise<void> | void
): void {
this.#onMessage = onMessage;
}
async sendMessage(message: Bidi.ChromiumBidi.Message): Promise<void> {
this.emit('bidiResponse', message);
}
close() {
this.#onMessage = async (_m: Bidi.ChromiumBidi.Command): Promise<void> => {
return;
};
}
}
+322
View File
@@ -0,0 +1,322 @@
/**
* @license
* Copyright 2022 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type {ChildProcess} from 'child_process';
import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
import {
Browser,
BrowserEvent,
type BrowserCloseCallback,
type BrowserContextOptions,
type DebugInfo,
} from '../api/Browser.js';
import {BrowserContextEvent} from '../api/BrowserContext.js';
import type {Page} from '../api/Page.js';
import type {Target} from '../api/Target.js';
import {UnsupportedOperation} from '../common/Errors.js';
import type {Handler} from '../common/EventEmitter.js';
import {debugError} from '../common/util.js';
import type {Viewport} from '../common/Viewport.js';
import {BidiBrowserContext} from './BrowserContext.js';
import {BrowsingContext, BrowsingContextEvent} from './BrowsingContext.js';
import type {BidiConnection} from './Connection.js';
import type {Browser as BrowserCore} from './core/Browser.js';
import {Session} from './core/Session.js';
import type {UserContext} from './core/UserContext.js';
import {
BiDiBrowserTarget,
BiDiBrowsingContextTarget,
BiDiPageTarget,
type BidiTarget,
} from './Target.js';
/**
* @internal
*/
export interface BidiBrowserOptions {
process?: ChildProcess;
closeCallback?: BrowserCloseCallback;
connection: BidiConnection;
defaultViewport: Viewport | null;
ignoreHTTPSErrors?: boolean;
}
/**
* @internal
*/
export class BidiBrowser extends Browser {
readonly protocol = 'webDriverBiDi';
// TODO: Update generator to include fully module
static readonly subscribeModules: string[] = [
'browsingContext',
'network',
'log',
'script',
];
static readonly subscribeCdpEvents: Bidi.Cdp.EventNames[] = [
// Coverage
'cdp.Debugger.scriptParsed',
'cdp.CSS.styleSheetAdded',
'cdp.Runtime.executionContextsCleared',
// Tracing
'cdp.Tracing.tracingComplete',
// TODO: subscribe to all CDP events in the future.
'cdp.Network.requestWillBeSent',
'cdp.Debugger.scriptParsed',
'cdp.Page.screencastFrame',
];
static async create(opts: BidiBrowserOptions): Promise<BidiBrowser> {
const session = await Session.from(opts.connection, {
alwaysMatch: {
acceptInsecureCerts: opts.ignoreHTTPSErrors,
webSocketUrl: true,
},
});
await session.subscribe(
session.capabilities.browserName.toLocaleLowerCase().includes('firefox')
? BidiBrowser.subscribeModules
: [...BidiBrowser.subscribeModules, ...BidiBrowser.subscribeCdpEvents]
);
const browser = new BidiBrowser(session.browser, opts);
browser.#initialize();
await browser.#getTree();
return browser;
}
#process?: ChildProcess;
#closeCallback?: BrowserCloseCallback;
#browserCore: BrowserCore;
#defaultViewport: Viewport | null;
#targets = new Map<string, BidiTarget>();
#browserContexts = new WeakMap<UserContext, BidiBrowserContext>();
#browserTarget: BiDiBrowserTarget;
#connectionEventHandlers = new Map<
Bidi.BrowsingContextEvent['method'],
Handler<any>
>([
['browsingContext.contextCreated', this.#onContextCreated.bind(this)],
['browsingContext.contextDestroyed', this.#onContextDestroyed.bind(this)],
['browsingContext.domContentLoaded', this.#onContextDomLoaded.bind(this)],
['browsingContext.fragmentNavigated', this.#onContextNavigation.bind(this)],
['browsingContext.navigationStarted', this.#onContextNavigation.bind(this)],
]);
private constructor(browserCore: BrowserCore, opts: BidiBrowserOptions) {
super();
this.#process = opts.process;
this.#closeCallback = opts.closeCallback;
this.#browserCore = browserCore;
this.#defaultViewport = opts.defaultViewport;
this.#browserTarget = new BiDiBrowserTarget(this);
for (const context of this.#browserCore.userContexts) {
this.#createBrowserContext(context);
}
}
#initialize() {
this.#browserCore.once('disconnected', () => {
this.emit(BrowserEvent.Disconnected, undefined);
});
this.#process?.once('close', () => {
this.#browserCore.dispose('Browser process exited.', true);
this.connection.dispose();
});
for (const [eventName, handler] of this.#connectionEventHandlers) {
this.connection.on(eventName, handler);
}
}
get #browserName() {
return this.#browserCore.session.capabilities.browserName;
}
get #browserVersion() {
return this.#browserCore.session.capabilities.browserVersion;
}
override userAgent(): never {
throw new UnsupportedOperation();
}
#createBrowserContext(userContext: UserContext) {
const browserContext = new BidiBrowserContext(this, userContext, {
defaultViewport: this.#defaultViewport,
});
this.#browserContexts.set(userContext, browserContext);
return browserContext;
}
#onContextDomLoaded(event: Bidi.BrowsingContext.Info) {
const target = this.#targets.get(event.context);
if (target) {
this.emit(BrowserEvent.TargetChanged, target);
target.browserContext().emit(BrowserContextEvent.TargetChanged, target);
}
}
#onContextNavigation(event: Bidi.BrowsingContext.NavigationInfo) {
const target = this.#targets.get(event.context);
if (target) {
this.emit(BrowserEvent.TargetChanged, target);
target.browserContext().emit(BrowserContextEvent.TargetChanged, target);
}
}
#onContextCreated(event: Bidi.BrowsingContext.ContextCreated['params']) {
const context = new BrowsingContext(
this.connection,
event,
this.#browserName
);
this.connection.registerBrowsingContexts(context);
const browserContext =
event.userContext === 'default'
? this.defaultBrowserContext()
: this.browserContexts().find(browserContext => {
return browserContext.id === event.userContext;
});
if (!browserContext) {
throw new Error('Missing browser contexts');
}
const target = !context.parent
? new BiDiPageTarget(browserContext, context)
: new BiDiBrowsingContextTarget(browserContext, context);
this.#targets.set(event.context, target);
this.emit(BrowserEvent.TargetCreated, target);
target.browserContext().emit(BrowserContextEvent.TargetCreated, target);
if (context.parent) {
const topLevel = this.connection.getTopLevelContext(context.parent);
topLevel.emit(BrowsingContextEvent.Created, context);
}
}
async #getTree(): Promise<void> {
const {result} = await this.connection.send('browsingContext.getTree', {});
for (const context of result.contexts) {
this.#onContextCreated(context);
}
}
async #onContextDestroyed(
event: Bidi.BrowsingContext.ContextDestroyed['params']
) {
const context = this.connection.getBrowsingContext(event.context);
const topLevelContext = this.connection.getTopLevelContext(event.context);
topLevelContext.emit(BrowsingContextEvent.Destroyed, context);
const target = this.#targets.get(event.context);
const page = await target?.page();
await page?.close().catch(debugError);
this.#targets.delete(event.context);
if (target) {
this.emit(BrowserEvent.TargetDestroyed, target);
target.browserContext().emit(BrowserContextEvent.TargetDestroyed, target);
}
}
get connection(): BidiConnection {
// SAFETY: We only have one implementation.
return this.#browserCore.session.connection as BidiConnection;
}
override wsEndpoint(): string {
return this.connection.url;
}
override async close(): Promise<void> {
for (const [eventName, handler] of this.#connectionEventHandlers) {
this.connection.off(eventName, handler);
}
if (this.connection.closed) {
return;
}
try {
await this.#browserCore.close();
await this.#closeCallback?.call(null);
} catch (error) {
// Fail silently.
debugError(error);
} finally {
this.connection.dispose();
}
}
override get connected(): boolean {
return !this.#browserCore.disposed;
}
override process(): ChildProcess | null {
return this.#process ?? null;
}
override async createIncognitoBrowserContext(
_options?: BrowserContextOptions
): Promise<BidiBrowserContext> {
const userContext = await this.#browserCore.createUserContext();
return this.#createBrowserContext(userContext);
}
override async version(): Promise<string> {
return `${this.#browserName}/${this.#browserVersion}`;
}
override browserContexts(): BidiBrowserContext[] {
return [...this.#browserCore.userContexts].map(context => {
return this.#browserContexts.get(context)!;
});
}
override defaultBrowserContext(): BidiBrowserContext {
return this.#browserContexts.get(this.#browserCore.defaultUserContext)!;
}
override newPage(): Promise<Page> {
return this.defaultBrowserContext().newPage();
}
override targets(): Target[] {
return [this.#browserTarget, ...Array.from(this.#targets.values())];
}
_getTargetById(id: string): BidiTarget {
const target = this.#targets.get(id);
if (!target) {
throw new Error('Target not found');
}
return target;
}
override target(): Target {
return this.#browserTarget;
}
override async disconnect(): Promise<void> {
try {
await this.#browserCore.session.end();
} catch (error) {
// Fail silently.
debugError(error);
} finally {
this.connection.dispose();
}
}
override get debugInfo(): DebugInfo {
return {
pendingProtocolErrors: this.connection.getPendingProtocolErrors(),
};
}
}
+123
View File
@@ -0,0 +1,123 @@
/**
* @license
* Copyright 2023 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type {BrowserCloseCallback} from '../api/Browser.js';
import {Connection} from '../cdp/Connection.js';
import type {ConnectionTransport} from '../common/ConnectionTransport.js';
import type {
BrowserConnectOptions,
ConnectOptions,
} from '../common/ConnectOptions.js';
import {ProtocolError, UnsupportedOperation} from '../common/Errors.js';
import {debugError, DEFAULT_VIEWPORT} from '../common/util.js';
import type {BidiBrowser} from './Browser.js';
import type {BidiConnection} from './Connection.js';
/**
* Users should never call this directly; it's called when calling `puppeteer.connect`
* with `protocol: 'webDriverBiDi'`. This method attaches Puppeteer to an existing browser
* instance. First it tries to connect to the browser using pure BiDi. If the protocol is
* not supported, connects to the browser using BiDi over CDP.
*
* @internal
*/
export async function _connectToBiDiBrowser(
connectionTransport: ConnectionTransport,
url: string,
options: BrowserConnectOptions & ConnectOptions
): Promise<BidiBrowser> {
const {ignoreHTTPSErrors = false, defaultViewport = DEFAULT_VIEWPORT} =
options;
const {bidiConnection, closeCallback} = await getBiDiConnection(
connectionTransport,
url,
options
);
const BiDi = await import(/* webpackIgnore: true */ './bidi.js');
const bidiBrowser = await BiDi.BidiBrowser.create({
connection: bidiConnection,
closeCallback,
process: undefined,
defaultViewport: defaultViewport,
ignoreHTTPSErrors: ignoreHTTPSErrors,
});
return bidiBrowser;
}
/**
* Returns a BiDiConnection established to the endpoint specified by the options and a
* callback closing the browser. Callback depends on whether the connection is pure BiDi
* or BiDi over CDP.
* The method tries to connect to the browser using pure BiDi protocol, and falls back
* to BiDi over CDP.
*/
async function getBiDiConnection(
connectionTransport: ConnectionTransport,
url: string,
options: BrowserConnectOptions
): Promise<{
bidiConnection: BidiConnection;
closeCallback: BrowserCloseCallback;
}> {
const BiDi = await import(/* webpackIgnore: true */ './bidi.js');
const {ignoreHTTPSErrors = false, slowMo = 0, protocolTimeout} = options;
// Try pure BiDi first.
const pureBidiConnection = new BiDi.BidiConnection(
url,
connectionTransport,
slowMo,
protocolTimeout
);
try {
const result = await pureBidiConnection.send('session.status', {});
if ('type' in result && result.type === 'success') {
// The `browserWSEndpoint` points to an endpoint supporting pure WebDriver BiDi.
return {
bidiConnection: pureBidiConnection,
closeCallback: async () => {
await pureBidiConnection.send('browser.close', {}).catch(debugError);
},
};
}
} catch (e) {
if (!(e instanceof ProtocolError)) {
// Unexpected exception not related to BiDi / CDP. Rethrow.
throw e;
}
}
// Unbind the connection to avoid memory leaks.
pureBidiConnection.unbind();
// Fall back to CDP over BiDi reusing the WS connection.
const cdpConnection = new Connection(
url,
connectionTransport,
slowMo,
protocolTimeout
);
const version = await cdpConnection.send('Browser.getVersion');
if (version.product.toLowerCase().includes('firefox')) {
throw new UnsupportedOperation(
'Firefox is not supported in BiDi over CDP mode.'
);
}
// TODO: use other options too.
const bidiOverCdpConnection = await BiDi.connectBidiOverCdp(cdpConnection, {
acceptInsecureCerts: ignoreHTTPSErrors,
});
return {
bidiConnection: bidiOverCdpConnection,
closeCallback: async () => {
// In case of BiDi over CDP, we need to close browser via CDP.
await cdpConnection.send('Browser.close').catch(debugError);
},
};
}
+143
View File
@@ -0,0 +1,143 @@
/**
* @license
* Copyright 2022 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
import type {WaitForTargetOptions} from '../api/Browser.js';
import {BrowserContext} from '../api/BrowserContext.js';
import type {Page} from '../api/Page.js';
import type {Target} from '../api/Target.js';
import {UnsupportedOperation} from '../common/Errors.js';
import {debugError} from '../common/util.js';
import type {Viewport} from '../common/Viewport.js';
import type {BidiBrowser} from './Browser.js';
import type {BidiConnection} from './Connection.js';
import {UserContext} from './core/UserContext.js';
import type {BidiPage} from './Page.js';
/**
* @internal
*/
export interface BidiBrowserContextOptions {
defaultViewport: Viewport | null;
}
/**
* @internal
*/
export class BidiBrowserContext extends BrowserContext {
#browser: BidiBrowser;
#connection: BidiConnection;
#defaultViewport: Viewport | null;
#userContext: UserContext;
constructor(
browser: BidiBrowser,
userContext: UserContext,
options: BidiBrowserContextOptions
) {
super();
this.#browser = browser;
this.#userContext = userContext;
this.#connection = this.#browser.connection;
this.#defaultViewport = options.defaultViewport;
}
override targets(): Target[] {
return this.#browser.targets().filter(target => {
return target.browserContext() === this;
});
}
override waitForTarget(
predicate: (x: Target) => boolean | Promise<boolean>,
options: WaitForTargetOptions = {}
): Promise<Target> {
return this.#browser.waitForTarget(target => {
return target.browserContext() === this && predicate(target);
}, options);
}
get connection(): BidiConnection {
return this.#connection;
}
override async newPage(): Promise<Page> {
const {result} = await this.#connection.send('browsingContext.create', {
type: Bidi.BrowsingContext.CreateType.Tab,
userContext: this.#userContext.id,
});
const target = this.#browser._getTargetById(result.context);
// TODO: once BiDi has some concept matching BrowserContext, the newly
// created contexts should get automatically assigned to the right
// BrowserContext. For now, we assume that only explicitly created pages go
// to the current BrowserContext. Otherwise, the contexts get assigned to
// the default BrowserContext by the Browser.
target._setBrowserContext(this);
const page = await target.page();
if (!page) {
throw new Error('Page is not found');
}
if (this.#defaultViewport) {
try {
await page.setViewport(this.#defaultViewport);
} catch {
// No support for setViewport in Firefox.
}
}
return page;
}
override async close(): Promise<void> {
if (!this.isIncognito()) {
throw new Error('Default context cannot be closed!');
}
try {
await this.#userContext.remove();
} catch (error) {
debugError(error);
}
}
override browser(): BidiBrowser {
return this.#browser;
}
override async pages(): Promise<BidiPage[]> {
const results = await Promise.all(
[...this.targets()].map(t => {
return t.page();
})
);
return results.filter((p): p is BidiPage => {
return p !== null;
});
}
override isIncognito(): boolean {
return this.#userContext.id !== UserContext.DEFAULT;
}
override overridePermissions(): never {
throw new UnsupportedOperation();
}
override clearPermissionOverrides(): never {
throw new UnsupportedOperation();
}
override get id(): string | undefined {
if (this.#userContext.id === 'default') {
return undefined;
}
return this.#userContext.id;
}
}
+187
View File
@@ -0,0 +1,187 @@
import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
import type ProtocolMapping from 'devtools-protocol/types/protocol-mapping.js';
import {CDPSession} from '../api/CDPSession.js';
import type {Connection as CdpConnection} from '../cdp/Connection.js';
import {TargetCloseError, UnsupportedOperation} from '../common/Errors.js';
import type {EventType} from '../common/EventEmitter.js';
import {debugError} from '../common/util.js';
import {Deferred} from '../util/Deferred.js';
import type {BidiConnection} from './Connection.js';
import {BidiRealm} from './Realm.js';
/**
* @internal
*/
export const cdpSessions = new Map<string, CdpSessionWrapper>();
/**
* @internal
*/
export class CdpSessionWrapper extends CDPSession {
#context: BrowsingContext;
#sessionId = Deferred.create<string>();
#detached = false;
constructor(context: BrowsingContext, sessionId?: string) {
super();
this.#context = context;
if (!this.#context.supportsCdp()) {
return;
}
if (sessionId) {
this.#sessionId.resolve(sessionId);
cdpSessions.set(sessionId, this);
} else {
context.connection
.send('cdp.getSession', {
context: context.id,
})
.then(session => {
this.#sessionId.resolve(session.result.session!);
cdpSessions.set(session.result.session!, this);
})
.catch(err => {
this.#sessionId.reject(err);
});
}
}
override connection(): CdpConnection | undefined {
return undefined;
}
override async send<T extends keyof ProtocolMapping.Commands>(
method: T,
...paramArgs: ProtocolMapping.Commands[T]['paramsType']
): Promise<ProtocolMapping.Commands[T]['returnType']> {
if (!this.#context.supportsCdp()) {
throw new UnsupportedOperation(
'CDP support is required for this feature. The current browser does not support CDP.'
);
}
if (this.#detached) {
throw new TargetCloseError(
`Protocol error (${method}): Session closed. Most likely the page has been closed.`
);
}
const session = await this.#sessionId.valueOrThrow();
const {result} = await this.#context.connection.send('cdp.sendCommand', {
method: method,
params: paramArgs[0],
session,
});
return result.result;
}
override async detach(): Promise<void> {
cdpSessions.delete(this.id());
if (!this.#detached && this.#context.supportsCdp()) {
await this.#context.cdpSession.send('Target.detachFromTarget', {
sessionId: this.id(),
});
}
this.#detached = true;
}
override id(): string {
const val = this.#sessionId.value();
return val instanceof Error || val === undefined ? '' : val;
}
}
/**
* Internal events that the BrowsingContext class emits.
*
* @internal
*/
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace BrowsingContextEvent {
/**
* Emitted on the top-level context, when a descendant context is created.
*/
export const Created = Symbol('BrowsingContext.created');
/**
* Emitted on the top-level context, when a descendant context or the
* top-level context itself is destroyed.
*/
export const Destroyed = Symbol('BrowsingContext.destroyed');
}
/**
* @internal
*/
export interface BrowsingContextEvents extends Record<EventType, unknown> {
[BrowsingContextEvent.Created]: BrowsingContext;
[BrowsingContextEvent.Destroyed]: BrowsingContext;
}
/**
* @internal
*/
export class BrowsingContext extends BidiRealm {
#id: string;
#url: string;
#cdpSession: CDPSession;
#parent?: string | null;
#browserName = '';
constructor(
connection: BidiConnection,
info: Bidi.BrowsingContext.Info,
browserName: string
) {
super(connection);
this.#id = info.context;
this.#url = info.url;
this.#parent = info.parent;
this.#browserName = browserName;
this.#cdpSession = new CdpSessionWrapper(this, undefined);
this.on('browsingContext.domContentLoaded', this.#updateUrl.bind(this));
this.on('browsingContext.fragmentNavigated', this.#updateUrl.bind(this));
this.on('browsingContext.load', this.#updateUrl.bind(this));
}
supportsCdp(): boolean {
return !this.#browserName.toLowerCase().includes('firefox');
}
#updateUrl(info: Bidi.BrowsingContext.NavigationInfo) {
this.#url = info.url;
}
createRealmForSandbox(): BidiRealm {
return new BidiRealm(this.connection);
}
get url(): string {
return this.#url;
}
get id(): string {
return this.#id;
}
get parent(): string | undefined | null {
return this.#parent;
}
get cdpSession(): CDPSession {
return this.#cdpSession;
}
async sendCdpCommand<T extends keyof ProtocolMapping.Commands>(
method: T,
...paramArgs: ProtocolMapping.Commands[T]['paramsType']
): Promise<ProtocolMapping.Commands[T]['returnType']> {
return await this.#cdpSession.send(method, ...paramArgs);
}
dispose(): void {
this.removeAllListeners();
this.connection.unregisterBrowsingContexts(this.#id);
void this.#cdpSession.detach().catch(debugError);
}
}
+256
View File
@@ -0,0 +1,256 @@
/**
* @license
* Copyright 2017 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
import {CallbackRegistry} from '../common/CallbackRegistry.js';
import type {ConnectionTransport} from '../common/ConnectionTransport.js';
import {debug} from '../common/Debug.js';
import type {EventsWithWildcard} from '../common/EventEmitter.js';
import {EventEmitter} from '../common/EventEmitter.js';
import {debugError} from '../common/util.js';
import {assert} from '../util/assert.js';
import {cdpSessions, type BrowsingContext} from './BrowsingContext.js';
import type {
BidiEvents,
Commands as BidiCommands,
Connection,
} from './core/Connection.js';
const debugProtocolSend = debug('puppeteer:webDriverBiDi:SEND ►');
const debugProtocolReceive = debug('puppeteer:webDriverBiDi:RECV ◀');
/**
* @internal
*/
export interface Commands extends BidiCommands {
'cdp.sendCommand': {
params: Bidi.Cdp.SendCommandParameters;
returnType: Bidi.Cdp.SendCommandResult;
};
'cdp.getSession': {
params: Bidi.Cdp.GetSessionParameters;
returnType: Bidi.Cdp.GetSessionResult;
};
}
/**
* @internal
*/
export class BidiConnection
extends EventEmitter<BidiEvents>
implements Connection
{
#url: string;
#transport: ConnectionTransport;
#delay: number;
#timeout? = 0;
#closed = false;
#callbacks = new CallbackRegistry();
#browsingContexts = new Map<string, BrowsingContext>();
#emitters: Array<EventEmitter<any>> = [];
constructor(
url: string,
transport: ConnectionTransport,
delay = 0,
timeout?: number
) {
super();
this.#url = url;
this.#delay = delay;
this.#timeout = timeout ?? 180_000;
this.#transport = transport;
this.#transport.onmessage = this.onMessage.bind(this);
this.#transport.onclose = this.unbind.bind(this);
}
get closed(): boolean {
return this.#closed;
}
get url(): string {
return this.#url;
}
pipeTo<Events extends BidiEvents>(emitter: EventEmitter<Events>): void {
this.#emitters.push(emitter);
}
override emit<Key extends keyof EventsWithWildcard<BidiEvents>>(
type: Key,
event: EventsWithWildcard<BidiEvents>[Key]
): boolean {
for (const emitter of this.#emitters) {
emitter.emit(type, event);
}
return super.emit(type, event);
}
send<T extends keyof Commands>(
method: T,
params: Commands[T]['params']
): Promise<{result: Commands[T]['returnType']}> {
assert(!this.#closed, 'Protocol error: Connection closed.');
return this.#callbacks.create(method, this.#timeout, id => {
const stringifiedMessage = JSON.stringify({
id,
method,
params,
} as Bidi.Command);
debugProtocolSend(stringifiedMessage);
this.#transport.send(stringifiedMessage);
}) as Promise<{result: Commands[T]['returnType']}>;
}
/**
* @internal
*/
protected async onMessage(message: string): Promise<void> {
if (this.#delay) {
await new Promise(f => {
return setTimeout(f, this.#delay);
});
}
debugProtocolReceive(message);
const object: Bidi.ChromiumBidi.Message = JSON.parse(message);
if ('type' in object) {
switch (object.type) {
case 'success':
this.#callbacks.resolve(object.id, object);
return;
case 'error':
if (object.id === null) {
break;
}
this.#callbacks.reject(
object.id,
createProtocolError(object),
object.message
);
return;
case 'event':
if (isCdpEvent(object)) {
cdpSessions
.get(object.params.session)
?.emit(object.params.event, object.params.params);
return;
}
this.#maybeEmitOnContext(object);
// SAFETY: We know the method and parameter still match here.
this.emit(
object.method,
object.params as BidiEvents[keyof BidiEvents]
);
return;
}
}
// Even if the response in not in BiDi protocol format but `id` is provided, reject
// the callback. This can happen if the endpoint supports CDP instead of BiDi.
if ('id' in object) {
this.#callbacks.reject(
(object as {id: number}).id,
`Protocol Error. Message is not in BiDi protocol format: '${message}'`,
object.message
);
}
debugError(object);
}
#maybeEmitOnContext(event: Bidi.ChromiumBidi.Event) {
let context: BrowsingContext | undefined;
// Context specific events
if ('context' in event.params && event.params.context !== null) {
context = this.#browsingContexts.get(event.params.context);
// `log.entryAdded` specific context
} else if (
'source' in event.params &&
event.params.source.context !== undefined
) {
context = this.#browsingContexts.get(event.params.source.context);
}
context?.emit(event.method, event.params);
}
registerBrowsingContexts(context: BrowsingContext): void {
this.#browsingContexts.set(context.id, context);
}
getBrowsingContext(contextId: string): BrowsingContext {
const currentContext = this.#browsingContexts.get(contextId);
if (!currentContext) {
throw new Error(`BrowsingContext ${contextId} does not exist.`);
}
return currentContext;
}
getTopLevelContext(contextId: string): BrowsingContext {
let currentContext = this.#browsingContexts.get(contextId);
if (!currentContext) {
throw new Error(`BrowsingContext ${contextId} does not exist.`);
}
while (currentContext.parent) {
contextId = currentContext.parent;
currentContext = this.#browsingContexts.get(contextId);
if (!currentContext) {
throw new Error(`BrowsingContext ${contextId} does not exist.`);
}
}
return currentContext;
}
unregisterBrowsingContexts(id: string): void {
this.#browsingContexts.delete(id);
}
/**
* Unbinds the connection, but keeps the transport open. Useful when the transport will
* be reused by other connection e.g. with different protocol.
* @internal
*/
unbind(): void {
if (this.#closed) {
return;
}
this.#closed = true;
// Both may still be invoked and produce errors
this.#transport.onmessage = () => {};
this.#transport.onclose = () => {};
this.#browsingContexts.clear();
this.#callbacks.clear();
}
/**
* Unbinds the connection and closes the transport.
*/
dispose(): void {
this.unbind();
this.#transport.close();
}
getPendingProtocolErrors(): Error[] {
return this.#callbacks.getPendingProtocolErrors();
}
}
/**
* @internal
*/
function createProtocolError(object: Bidi.ErrorResponse): string {
let message = `${object.error} ${object.message}`;
if (object.stacktrace) {
message += ` ${object.stacktrace}`;
}
return message;
}
function isCdpEvent(event: Bidi.ChromiumBidi.Event): event is Bidi.Cdp.Event {
return event.method.startsWith('cdp.');
}
+96
View File
@@ -0,0 +1,96 @@
/**
* @license
* Copyright 2023 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
import {debugError} from '../common/util.js';
/**
* @internal
*/
export class BidiDeserializer {
static deserializeNumber(value: Bidi.Script.SpecialNumber | number): number {
switch (value) {
case '-0':
return -0;
case 'NaN':
return NaN;
case 'Infinity':
return Infinity;
case '-Infinity':
return -Infinity;
default:
return value;
}
}
static deserializeLocalValue(result: Bidi.Script.RemoteValue): unknown {
switch (result.type) {
case 'array':
return result.value?.map(value => {
return BidiDeserializer.deserializeLocalValue(value);
});
case 'set':
return result.value?.reduce((acc: Set<unknown>, value) => {
return acc.add(BidiDeserializer.deserializeLocalValue(value));
}, new Set());
case 'object':
return result.value?.reduce((acc: Record<any, unknown>, tuple) => {
const {key, value} = BidiDeserializer.deserializeTuple(tuple);
acc[key as any] = value;
return acc;
}, {});
case 'map':
return result.value?.reduce((acc: Map<unknown, unknown>, tuple) => {
const {key, value} = BidiDeserializer.deserializeTuple(tuple);
return acc.set(key, value);
}, new Map());
case 'promise':
return {};
case 'regexp':
return new RegExp(result.value.pattern, result.value.flags);
case 'date':
return new Date(result.value);
case 'undefined':
return undefined;
case 'null':
return null;
case 'number':
return BidiDeserializer.deserializeNumber(result.value);
case 'bigint':
return BigInt(result.value);
case 'boolean':
return Boolean(result.value);
case 'string':
return result.value;
}
debugError(`Deserialization of type ${result.type} not supported.`);
return undefined;
}
static deserializeTuple([serializedKey, serializedValue]: [
Bidi.Script.RemoteValue | string,
Bidi.Script.RemoteValue,
]): {key: unknown; value: unknown} {
const key =
typeof serializedKey === 'string'
? serializedKey
: BidiDeserializer.deserializeLocalValue(serializedKey);
const value = BidiDeserializer.deserializeLocalValue(serializedValue);
return {key, value};
}
static deserialize(result: Bidi.Script.RemoteValue): any {
if (!result) {
debugError('Service did not produce a result.');
return undefined;
}
return BidiDeserializer.deserializeLocalValue(result);
}
}
+45
View File
@@ -0,0 +1,45 @@
/**
* @license
* Copyright 2017 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
import {Dialog} from '../api/Dialog.js';
import type {BrowsingContext} from './BrowsingContext.js';
/**
* @internal
*/
export class BidiDialog extends Dialog {
#context: BrowsingContext;
/**
* @internal
*/
constructor(
context: BrowsingContext,
type: Bidi.BrowsingContext.UserPromptOpenedParameters['type'],
message: string,
defaultValue?: string
) {
super(type, message, defaultValue);
this.#context = context;
}
/**
* @internal
*/
override async handle(options: {
accept: boolean;
text?: string;
}): Promise<void> {
await this.#context.connection.send('browsingContext.handleUserPrompt', {
context: this.#context.id,
accept: options.accept,
userText: options.text,
});
}
}
+87
View File
@@ -0,0 +1,87 @@
/**
* @license
* Copyright 2023 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
import {type AutofillData, ElementHandle} from '../api/ElementHandle.js';
import {UnsupportedOperation} from '../common/Errors.js';
import {throwIfDisposed} from '../util/decorators.js';
import type {BidiFrame} from './Frame.js';
import {BidiJSHandle} from './JSHandle.js';
import type {BidiRealm} from './Realm.js';
import type {Sandbox} from './Sandbox.js';
/**
* @internal
*/
export class BidiElementHandle<
ElementType extends Node = Element,
> extends ElementHandle<ElementType> {
declare handle: BidiJSHandle<ElementType>;
constructor(sandbox: Sandbox, remoteValue: Bidi.Script.RemoteValue) {
super(new BidiJSHandle(sandbox, remoteValue));
}
override get realm(): Sandbox {
return this.handle.realm;
}
override get frame(): BidiFrame {
return this.realm.environment;
}
context(): BidiRealm {
return this.handle.context();
}
get isPrimitiveValue(): boolean {
return this.handle.isPrimitiveValue;
}
remoteValue(): Bidi.Script.RemoteValue {
return this.handle.remoteValue();
}
@throwIfDisposed()
override async autofill(data: AutofillData): Promise<void> {
const client = this.frame.client;
const nodeInfo = await client.send('DOM.describeNode', {
objectId: this.handle.id,
});
const fieldId = nodeInfo.node.backendNodeId;
const frameId = this.frame._id;
await client.send('Autofill.trigger', {
fieldId,
frameId,
card: data.creditCard,
});
}
override async contentFrame(
this: BidiElementHandle<HTMLIFrameElement>
): Promise<BidiFrame>;
@throwIfDisposed()
@ElementHandle.bindIsolatedHandle
override async contentFrame(): Promise<BidiFrame | null> {
using handle = (await this.evaluateHandle(element => {
if (element instanceof HTMLIFrameElement) {
return element.contentWindow;
}
return;
})) as BidiJSHandle;
const value = handle.remoteValue();
if (value.type === 'window') {
return this.frame.page().frame(value.value.context);
}
return null;
}
override uploadFile(this: ElementHandle<HTMLInputElement>): never {
throw new UnsupportedOperation();
}
}
+35
View File
@@ -0,0 +1,35 @@
/**
* @license
* Copyright 2023 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type {Viewport} from '../common/Viewport.js';
import type {BrowsingContext} from './BrowsingContext.js';
/**
* @internal
*/
export class EmulationManager {
#browsingContext: BrowsingContext;
constructor(browsingContext: BrowsingContext) {
this.#browsingContext = browsingContext;
}
async emulateViewport(viewport: Viewport): Promise<void> {
await this.#browsingContext.connection.send('browsingContext.setViewport', {
context: this.#browsingContext.id,
viewport:
viewport.width && viewport.height
? {
width: viewport.width,
height: viewport.height,
}
: null,
devicePixelRatio: viewport.deviceScaleFactor
? viewport.deviceScaleFactor
: null,
});
}
}
+295
View File
@@ -0,0 +1,295 @@
/**
* @license
* Copyright 2023 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
import type {Awaitable, FlattenHandle} from '../common/types.js';
import {debugError} from '../common/util.js';
import {assert} from '../util/assert.js';
import {Deferred} from '../util/Deferred.js';
import {interpolateFunction, stringifyFunction} from '../util/Function.js';
import type {BidiConnection} from './Connection.js';
import {BidiDeserializer} from './Deserializer.js';
import type {BidiFrame} from './Frame.js';
import {BidiSerializer} from './Serializer.js';
type SendArgsChannel<Args> = (value: [id: number, args: Args]) => void;
type SendResolveChannel<Ret> = (
value: [id: number, resolve: (ret: FlattenHandle<Awaited<Ret>>) => void]
) => void;
type SendRejectChannel = (
value: [id: number, reject: (error: unknown) => void]
) => void;
interface RemotePromiseCallbacks {
resolve: Deferred<Bidi.Script.RemoteValue>;
reject: Deferred<Bidi.Script.RemoteValue>;
}
/**
* @internal
*/
export class ExposeableFunction<Args extends unknown[], Ret> {
readonly #frame;
readonly name;
readonly #apply;
readonly #channels;
readonly #callerInfos = new Map<
string,
Map<number, RemotePromiseCallbacks>
>();
#preloadScriptId?: Bidi.Script.PreloadScript;
constructor(
frame: BidiFrame,
name: string,
apply: (...args: Args) => Awaitable<Ret>
) {
this.#frame = frame;
this.name = name;
this.#apply = apply;
this.#channels = {
args: `__puppeteer__${this.#frame._id}_page_exposeFunction_${this.name}_args`,
resolve: `__puppeteer__${this.#frame._id}_page_exposeFunction_${this.name}_resolve`,
reject: `__puppeteer__${this.#frame._id}_page_exposeFunction_${this.name}_reject`,
};
}
async expose(): Promise<void> {
const connection = this.#connection;
const channelArguments = this.#channelArguments;
// TODO(jrandolf): Implement cleanup with removePreloadScript.
connection.on(
Bidi.ChromiumBidi.Script.EventNames.Message,
this.#handleArgumentsMessage
);
connection.on(
Bidi.ChromiumBidi.Script.EventNames.Message,
this.#handleResolveMessage
);
connection.on(
Bidi.ChromiumBidi.Script.EventNames.Message,
this.#handleRejectMessage
);
const functionDeclaration = stringifyFunction(
interpolateFunction(
(
sendArgs: SendArgsChannel<Args>,
sendResolve: SendResolveChannel<Ret>,
sendReject: SendRejectChannel
) => {
let id = 0;
Object.assign(globalThis, {
[PLACEHOLDER('name') as string]: function (...args: Args) {
return new Promise<FlattenHandle<Awaited<Ret>>>(
(resolve, reject) => {
sendArgs([id, args]);
sendResolve([id, resolve]);
sendReject([id, reject]);
++id;
}
);
},
});
},
{name: JSON.stringify(this.name)}
)
);
const {result} = await connection.send('script.addPreloadScript', {
functionDeclaration,
arguments: channelArguments,
contexts: [this.#frame.page().mainFrame()._id],
});
this.#preloadScriptId = result.script;
await Promise.all(
this.#frame
.page()
.frames()
.map(async frame => {
return await connection.send('script.callFunction', {
functionDeclaration,
arguments: channelArguments,
awaitPromise: false,
target: frame.mainRealm().realm.target,
});
})
);
}
#handleArgumentsMessage = async (params: Bidi.Script.MessageParameters) => {
if (params.channel !== this.#channels.args) {
return;
}
const connection = this.#connection;
const {callbacks, remoteValue} = this.#getCallbacksAndRemoteValue(params);
const args = remoteValue.value?.[1];
assert(args);
try {
const result = await this.#apply(...BidiDeserializer.deserialize(args));
await connection.send('script.callFunction', {
functionDeclaration: stringifyFunction(([_, resolve]: any, result) => {
resolve(result);
}),
arguments: [
(await callbacks.resolve.valueOrThrow()) as Bidi.Script.LocalValue,
BidiSerializer.serializeRemoteValue(result),
],
awaitPromise: false,
target: {
realm: params.source.realm,
},
});
} catch (error) {
try {
if (error instanceof Error) {
await connection.send('script.callFunction', {
functionDeclaration: stringifyFunction(
(
[_, reject]: [unknown, (error: Error) => void],
name: string,
message: string,
stack?: string
) => {
const error = new Error(message);
error.name = name;
if (stack) {
error.stack = stack;
}
reject(error);
}
),
arguments: [
(await callbacks.reject.valueOrThrow()) as Bidi.Script.LocalValue,
BidiSerializer.serializeRemoteValue(error.name),
BidiSerializer.serializeRemoteValue(error.message),
BidiSerializer.serializeRemoteValue(error.stack),
],
awaitPromise: false,
target: {
realm: params.source.realm,
},
});
} else {
await connection.send('script.callFunction', {
functionDeclaration: stringifyFunction(
(
[_, reject]: [unknown, (error: unknown) => void],
error: unknown
) => {
reject(error);
}
),
arguments: [
(await callbacks.reject.valueOrThrow()) as Bidi.Script.LocalValue,
BidiSerializer.serializeRemoteValue(error),
],
awaitPromise: false,
target: {
realm: params.source.realm,
},
});
}
} catch (error) {
debugError(error);
}
}
};
get #connection(): BidiConnection {
return this.#frame.context().connection;
}
get #channelArguments() {
return [
{
type: 'channel' as const,
value: {
channel: this.#channels.args,
ownership: Bidi.Script.ResultOwnership.Root,
},
},
{
type: 'channel' as const,
value: {
channel: this.#channels.resolve,
ownership: Bidi.Script.ResultOwnership.Root,
},
},
{
type: 'channel' as const,
value: {
channel: this.#channels.reject,
ownership: Bidi.Script.ResultOwnership.Root,
},
},
];
}
#handleResolveMessage = (params: Bidi.Script.MessageParameters) => {
if (params.channel !== this.#channels.resolve) {
return;
}
const {callbacks, remoteValue} = this.#getCallbacksAndRemoteValue(params);
callbacks.resolve.resolve(remoteValue);
};
#handleRejectMessage = (params: Bidi.Script.MessageParameters) => {
if (params.channel !== this.#channels.reject) {
return;
}
const {callbacks, remoteValue} = this.#getCallbacksAndRemoteValue(params);
callbacks.reject.resolve(remoteValue);
};
#getCallbacksAndRemoteValue(params: Bidi.Script.MessageParameters) {
const {data, source} = params;
assert(data.type === 'array');
assert(data.value);
const callerIdRemote = data.value[0];
assert(callerIdRemote);
assert(callerIdRemote.type === 'number');
assert(typeof callerIdRemote.value === 'number');
let bindingMap = this.#callerInfos.get(source.realm);
if (!bindingMap) {
bindingMap = new Map();
this.#callerInfos.set(source.realm, bindingMap);
}
const callerId = callerIdRemote.value;
let callbacks = bindingMap.get(callerId);
if (!callbacks) {
callbacks = {
resolve: new Deferred(),
reject: new Deferred(),
};
bindingMap.set(callerId, callbacks);
}
return {callbacks, remoteValue: data};
}
[Symbol.dispose](): void {
void this[Symbol.asyncDispose]().catch(debugError);
}
async [Symbol.asyncDispose](): Promise<void> {
if (this.#preloadScriptId) {
await this.#connection.send('script.removePreloadScript', {
script: this.#preloadScriptId,
});
}
}
}
+313
View File
@@ -0,0 +1,313 @@
/**
* @license
* Copyright 2023 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
import {
first,
firstValueFrom,
forkJoin,
from,
map,
merge,
raceWith,
zip,
} from '../../third_party/rxjs/rxjs.js';
import type {CDPSession} from '../api/CDPSession.js';
import type {ElementHandle} from '../api/ElementHandle.js';
import {
Frame,
throwIfDetached,
type GoToOptions,
type WaitForOptions,
} from '../api/Frame.js';
import type {WaitForSelectorOptions} from '../api/Page.js';
import {UnsupportedOperation} from '../common/Errors.js';
import type {TimeoutSettings} from '../common/TimeoutSettings.js';
import type {Awaitable, NodeFor} from '../common/types.js';
import {
fromEmitterEvent,
NETWORK_IDLE_TIME,
timeout,
UTILITY_WORLD_NAME,
} from '../common/util.js';
import {Deferred} from '../util/Deferred.js';
import {disposeSymbol} from '../util/disposable.js';
import type {BrowsingContext} from './BrowsingContext.js';
import {ExposeableFunction} from './ExposedFunction.js';
import type {BidiHTTPResponse} from './HTTPResponse.js';
import {
getBiDiLifecycleEvent,
getBiDiReadinessState,
rewriteNavigationError,
} from './lifecycle.js';
import type {BidiPage} from './Page.js';
import {
MAIN_SANDBOX,
PUPPETEER_SANDBOX,
Sandbox,
type SandboxChart,
} from './Sandbox.js';
/**
* Puppeteer's Frame class could be viewed as a BiDi BrowsingContext implementation
* @internal
*/
export class BidiFrame extends Frame {
#page: BidiPage;
#context: BrowsingContext;
#timeoutSettings: TimeoutSettings;
#abortDeferred = Deferred.create<never>();
#disposed = false;
sandboxes: SandboxChart;
override _id: string;
constructor(
page: BidiPage,
context: BrowsingContext,
timeoutSettings: TimeoutSettings,
parentId?: string | null
) {
super();
this.#page = page;
this.#context = context;
this.#timeoutSettings = timeoutSettings;
this._id = this.#context.id;
this._parentId = parentId ?? undefined;
this.sandboxes = {
[MAIN_SANDBOX]: new Sandbox(undefined, this, context, timeoutSettings),
[PUPPETEER_SANDBOX]: new Sandbox(
UTILITY_WORLD_NAME,
this,
context.createRealmForSandbox(),
timeoutSettings
),
};
}
override get client(): CDPSession {
return this.context().cdpSession;
}
override mainRealm(): Sandbox {
return this.sandboxes[MAIN_SANDBOX];
}
override isolatedRealm(): Sandbox {
return this.sandboxes[PUPPETEER_SANDBOX];
}
override page(): BidiPage {
return this.#page;
}
override isOOPFrame(): never {
throw new UnsupportedOperation();
}
override url(): string {
return this.#context.url;
}
override parentFrame(): BidiFrame | null {
return this.#page.frame(this._parentId ?? '');
}
override childFrames(): BidiFrame[] {
return this.#page.childFrames(this.#context.id);
}
@throwIfDetached
override async goto(
url: string,
options: GoToOptions = {}
): Promise<BidiHTTPResponse | null> {
const {
waitUntil = 'load',
timeout: ms = this.#timeoutSettings.navigationTimeout(),
} = options;
const [readiness, networkIdle] = getBiDiReadinessState(waitUntil);
const result$ = zip(
from(
this.#context.connection.send('browsingContext.navigate', {
context: this.#context.id,
url,
wait: readiness,
})
),
...(networkIdle !== null
? [
this.#page.waitForNetworkIdle$({
timeout: ms,
concurrency: networkIdle === 'networkidle2' ? 2 : 0,
idleTime: NETWORK_IDLE_TIME,
}),
]
: [])
).pipe(
map(([{result}]) => {
return result;
}),
raceWith(timeout(ms), from(this.#abortDeferred.valueOrThrow())),
rewriteNavigationError(url, ms)
);
const result = await firstValueFrom(result$);
return this.#page.getNavigationResponse(result.navigation);
}
@throwIfDetached
override async setContent(
html: string,
options: WaitForOptions = {}
): Promise<void> {
const {
waitUntil = 'load',
timeout: ms = this.#timeoutSettings.navigationTimeout(),
} = options;
const [waitEvent, networkIdle] = getBiDiLifecycleEvent(waitUntil);
const result$ = zip(
forkJoin([
fromEmitterEvent(this.#context, waitEvent).pipe(first()),
from(this.setFrameContent(html)),
]).pipe(
map(() => {
return null;
})
),
...(networkIdle !== null
? [
this.#page.waitForNetworkIdle$({
timeout: ms,
concurrency: networkIdle === 'networkidle2' ? 2 : 0,
idleTime: NETWORK_IDLE_TIME,
}),
]
: [])
).pipe(
raceWith(timeout(ms), from(this.#abortDeferred.valueOrThrow())),
rewriteNavigationError('setContent', ms)
);
await firstValueFrom(result$);
}
context(): BrowsingContext {
return this.#context;
}
@throwIfDetached
override async waitForNavigation(
options: WaitForOptions = {}
): Promise<BidiHTTPResponse | null> {
const {
waitUntil = 'load',
timeout: ms = this.#timeoutSettings.navigationTimeout(),
} = options;
const [waitUntilEvent, networkIdle] = getBiDiLifecycleEvent(waitUntil);
const navigation$ = merge(
forkJoin([
fromEmitterEvent(
this.#context,
Bidi.ChromiumBidi.BrowsingContext.EventNames.NavigationStarted
).pipe(first()),
fromEmitterEvent(this.#context, waitUntilEvent).pipe(first()),
]),
fromEmitterEvent(
this.#context,
Bidi.ChromiumBidi.BrowsingContext.EventNames.FragmentNavigated
)
).pipe(
map(result => {
if (Array.isArray(result)) {
return {result: result[1]};
}
return {result};
})
);
const result$ = zip(
navigation$,
...(networkIdle !== null
? [
this.#page.waitForNetworkIdle$({
timeout: ms,
concurrency: networkIdle === 'networkidle2' ? 2 : 0,
idleTime: NETWORK_IDLE_TIME,
}),
]
: [])
).pipe(
map(([{result}]) => {
return result;
}),
raceWith(timeout(ms), from(this.#abortDeferred.valueOrThrow()))
);
const result = await firstValueFrom(result$);
return this.#page.getNavigationResponse(result.navigation);
}
override waitForDevicePrompt(): never {
throw new UnsupportedOperation();
}
override get detached(): boolean {
return this.#disposed;
}
[disposeSymbol](): void {
if (this.#disposed) {
return;
}
this.#disposed = true;
this.#abortDeferred.reject(new Error('Frame detached'));
this.#context.dispose();
this.sandboxes[MAIN_SANDBOX][disposeSymbol]();
this.sandboxes[PUPPETEER_SANDBOX][disposeSymbol]();
}
#exposedFunctions = new Map<string, ExposeableFunction<never[], unknown>>();
async exposeFunction<Args extends unknown[], Ret>(
name: string,
apply: (...args: Args) => Awaitable<Ret>
): Promise<void> {
if (this.#exposedFunctions.has(name)) {
throw new Error(
`Failed to add page binding with name ${name}: globalThis['${name}'] already exists!`
);
}
const exposeable = new ExposeableFunction(this, name, apply);
this.#exposedFunctions.set(name, exposeable);
try {
await exposeable.expose();
} catch (error) {
this.#exposedFunctions.delete(name);
throw error;
}
}
override waitForSelector<Selector extends string>(
selector: Selector,
options?: WaitForSelectorOptions
): Promise<ElementHandle<NodeFor<Selector>> | null> {
if (selector.startsWith('aria')) {
throw new UnsupportedOperation(
'ARIA selector is not supported for BiDi!'
);
}
return super.waitForSelector(selector, options);
}
}
+163
View File
@@ -0,0 +1,163 @@
/**
* @license
* Copyright 2020 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
import type {Frame} from '../api/Frame.js';
import type {
ContinueRequestOverrides,
ResponseForRequest,
} from '../api/HTTPRequest.js';
import {HTTPRequest, type ResourceType} from '../api/HTTPRequest.js';
import {UnsupportedOperation} from '../common/Errors.js';
import type {BidiHTTPResponse} from './HTTPResponse.js';
/**
* @internal
*/
export class BidiHTTPRequest extends HTTPRequest {
override _response: BidiHTTPResponse | null = null;
override _redirectChain: BidiHTTPRequest[];
_navigationId: string | null;
#url: string;
#resourceType: ResourceType;
#method: string;
#postData?: string;
#headers: Record<string, string> = {};
#initiator: Bidi.Network.Initiator;
#frame: Frame | null;
constructor(
event: Bidi.Network.BeforeRequestSentParameters,
frame: Frame | null,
redirectChain: BidiHTTPRequest[] = []
) {
super();
this.#url = event.request.url;
this.#resourceType = event.initiator.type.toLowerCase() as ResourceType;
this.#method = event.request.method;
this.#postData = undefined;
this.#initiator = event.initiator;
this.#frame = frame;
this._requestId = event.request.request;
this._redirectChain = redirectChain;
this._navigationId = event.navigation;
for (const header of event.request.headers) {
// TODO: How to handle Binary Headers
// https://w3c.github.io/webdriver-bidi/#type-network-Header
if (header.value.type === 'string') {
this.#headers[header.name.toLowerCase()] = header.value.value;
}
}
}
override get client(): never {
throw new UnsupportedOperation();
}
override url(): string {
return this.#url;
}
override resourceType(): ResourceType {
return this.#resourceType;
}
override method(): string {
return this.#method;
}
override postData(): string | undefined {
return this.#postData;
}
override hasPostData(): boolean {
return this.#postData !== undefined;
}
override async fetchPostData(): Promise<string | undefined> {
return this.#postData;
}
override headers(): Record<string, string> {
return this.#headers;
}
override response(): BidiHTTPResponse | null {
return this._response;
}
override isNavigationRequest(): boolean {
return Boolean(this._navigationId);
}
override initiator(): Bidi.Network.Initiator {
return this.#initiator;
}
override redirectChain(): BidiHTTPRequest[] {
return this._redirectChain.slice();
}
override enqueueInterceptAction(
pendingHandler: () => void | PromiseLike<unknown>
): void {
// Execute the handler when interception is not supported
void pendingHandler();
}
override frame(): Frame | null {
return this.#frame;
}
override continueRequestOverrides(): never {
throw new UnsupportedOperation();
}
override continue(_overrides: ContinueRequestOverrides = {}): never {
throw new UnsupportedOperation();
}
override responseForRequest(): never {
throw new UnsupportedOperation();
}
override abortErrorReason(): never {
throw new UnsupportedOperation();
}
override interceptResolutionState(): never {
throw new UnsupportedOperation();
}
override isInterceptResolutionHandled(): never {
throw new UnsupportedOperation();
}
override finalizeInterceptions(): never {
throw new UnsupportedOperation();
}
override abort(): never {
throw new UnsupportedOperation();
}
override respond(
_response: Partial<ResponseForRequest>,
_priority?: number
): never {
throw new UnsupportedOperation();
}
override failure(): never {
throw new UnsupportedOperation();
}
}
+107
View File
@@ -0,0 +1,107 @@
/**
* @license
* Copyright 2020 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
import type Protocol from 'devtools-protocol';
import type {Frame} from '../api/Frame.js';
import {
HTTPResponse as HTTPResponse,
type RemoteAddress,
} from '../api/HTTPResponse.js';
import {UnsupportedOperation} from '../common/Errors.js';
import type {BidiHTTPRequest} from './HTTPRequest.js';
/**
* @internal
*/
export class BidiHTTPResponse extends HTTPResponse {
#request: BidiHTTPRequest;
#remoteAddress: RemoteAddress;
#status: number;
#statusText: string;
#url: string;
#fromCache: boolean;
#headers: Record<string, string> = {};
#timings: Record<string, string> | null;
constructor(
request: BidiHTTPRequest,
{response}: Bidi.Network.ResponseCompletedParameters
) {
super();
this.#request = request;
this.#remoteAddress = {
ip: '',
port: -1,
};
this.#url = response.url;
this.#fromCache = response.fromCache;
this.#status = response.status;
this.#statusText = response.statusText;
// TODO: File and issue with BiDi spec
this.#timings = null;
// TODO: Removed once the Firefox implementation is compliant with https://w3c.github.io/webdriver-bidi/#get-the-response-data.
for (const header of response.headers || []) {
// TODO: How to handle Binary Headers
// https://w3c.github.io/webdriver-bidi/#type-network-Header
if (header.value.type === 'string') {
this.#headers[header.name.toLowerCase()] = header.value.value;
}
}
}
override remoteAddress(): RemoteAddress {
return this.#remoteAddress;
}
override url(): string {
return this.#url;
}
override status(): number {
return this.#status;
}
override statusText(): string {
return this.#statusText;
}
override headers(): Record<string, string> {
return this.#headers;
}
override request(): BidiHTTPRequest {
return this.#request;
}
override fromCache(): boolean {
return this.#fromCache;
}
override timing(): Protocol.Network.ResourceTiming | null {
return this.#timings as any;
}
override frame(): Frame | null {
return this.#request.frame();
}
override fromServiceWorker(): boolean {
return false;
}
override securityDetails(): never {
throw new UnsupportedOperation();
}
override buffer(): never {
throw new UnsupportedOperation();
}
}
+732
View File
@@ -0,0 +1,732 @@
/**
* @license
* Copyright 2017 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
import type {Point} from '../api/ElementHandle.js';
import {
Keyboard,
Mouse,
MouseButton,
Touchscreen,
type KeyDownOptions,
type KeyPressOptions,
type KeyboardTypeOptions,
type MouseClickOptions,
type MouseMoveOptions,
type MouseOptions,
type MouseWheelOptions,
} from '../api/Input.js';
import {UnsupportedOperation} from '../common/Errors.js';
import type {KeyInput} from '../common/USKeyboardLayout.js';
import type {BrowsingContext} from './BrowsingContext.js';
import type {BidiPage} from './Page.js';
const enum InputId {
Mouse = '__puppeteer_mouse',
Keyboard = '__puppeteer_keyboard',
Wheel = '__puppeteer_wheel',
Finger = '__puppeteer_finger',
}
enum SourceActionsType {
None = 'none',
Key = 'key',
Pointer = 'pointer',
Wheel = 'wheel',
}
enum ActionType {
Pause = 'pause',
KeyDown = 'keyDown',
KeyUp = 'keyUp',
PointerUp = 'pointerUp',
PointerDown = 'pointerDown',
PointerMove = 'pointerMove',
Scroll = 'scroll',
}
const getBidiKeyValue = (key: KeyInput) => {
switch (key) {
case '\r':
case '\n':
key = 'Enter';
break;
}
// Measures the number of code points rather than UTF-16 code units.
if ([...key].length === 1) {
return key;
}
switch (key) {
case 'Cancel':
return '\uE001';
case 'Help':
return '\uE002';
case 'Backspace':
return '\uE003';
case 'Tab':
return '\uE004';
case 'Clear':
return '\uE005';
case 'Enter':
return '\uE007';
case 'Shift':
case 'ShiftLeft':
return '\uE008';
case 'Control':
case 'ControlLeft':
return '\uE009';
case 'Alt':
case 'AltLeft':
return '\uE00A';
case 'Pause':
return '\uE00B';
case 'Escape':
return '\uE00C';
case 'PageUp':
return '\uE00E';
case 'PageDown':
return '\uE00F';
case 'End':
return '\uE010';
case 'Home':
return '\uE011';
case 'ArrowLeft':
return '\uE012';
case 'ArrowUp':
return '\uE013';
case 'ArrowRight':
return '\uE014';
case 'ArrowDown':
return '\uE015';
case 'Insert':
return '\uE016';
case 'Delete':
return '\uE017';
case 'NumpadEqual':
return '\uE019';
case 'Numpad0':
return '\uE01A';
case 'Numpad1':
return '\uE01B';
case 'Numpad2':
return '\uE01C';
case 'Numpad3':
return '\uE01D';
case 'Numpad4':
return '\uE01E';
case 'Numpad5':
return '\uE01F';
case 'Numpad6':
return '\uE020';
case 'Numpad7':
return '\uE021';
case 'Numpad8':
return '\uE022';
case 'Numpad9':
return '\uE023';
case 'NumpadMultiply':
return '\uE024';
case 'NumpadAdd':
return '\uE025';
case 'NumpadSubtract':
return '\uE027';
case 'NumpadDecimal':
return '\uE028';
case 'NumpadDivide':
return '\uE029';
case 'F1':
return '\uE031';
case 'F2':
return '\uE032';
case 'F3':
return '\uE033';
case 'F4':
return '\uE034';
case 'F5':
return '\uE035';
case 'F6':
return '\uE036';
case 'F7':
return '\uE037';
case 'F8':
return '\uE038';
case 'F9':
return '\uE039';
case 'F10':
return '\uE03A';
case 'F11':
return '\uE03B';
case 'F12':
return '\uE03C';
case 'Meta':
case 'MetaLeft':
return '\uE03D';
case 'ShiftRight':
return '\uE050';
case 'ControlRight':
return '\uE051';
case 'AltRight':
return '\uE052';
case 'MetaRight':
return '\uE053';
case 'Digit0':
return '0';
case 'Digit1':
return '1';
case 'Digit2':
return '2';
case 'Digit3':
return '3';
case 'Digit4':
return '4';
case 'Digit5':
return '5';
case 'Digit6':
return '6';
case 'Digit7':
return '7';
case 'Digit8':
return '8';
case 'Digit9':
return '9';
case 'KeyA':
return 'a';
case 'KeyB':
return 'b';
case 'KeyC':
return 'c';
case 'KeyD':
return 'd';
case 'KeyE':
return 'e';
case 'KeyF':
return 'f';
case 'KeyG':
return 'g';
case 'KeyH':
return 'h';
case 'KeyI':
return 'i';
case 'KeyJ':
return 'j';
case 'KeyK':
return 'k';
case 'KeyL':
return 'l';
case 'KeyM':
return 'm';
case 'KeyN':
return 'n';
case 'KeyO':
return 'o';
case 'KeyP':
return 'p';
case 'KeyQ':
return 'q';
case 'KeyR':
return 'r';
case 'KeyS':
return 's';
case 'KeyT':
return 't';
case 'KeyU':
return 'u';
case 'KeyV':
return 'v';
case 'KeyW':
return 'w';
case 'KeyX':
return 'x';
case 'KeyY':
return 'y';
case 'KeyZ':
return 'z';
case 'Semicolon':
return ';';
case 'Equal':
return '=';
case 'Comma':
return ',';
case 'Minus':
return '-';
case 'Period':
return '.';
case 'Slash':
return '/';
case 'Backquote':
return '`';
case 'BracketLeft':
return '[';
case 'Backslash':
return '\\';
case 'BracketRight':
return ']';
case 'Quote':
return '"';
default:
throw new Error(`Unknown key: "${key}"`);
}
};
/**
* @internal
*/
export class BidiKeyboard extends Keyboard {
#page: BidiPage;
constructor(page: BidiPage) {
super();
this.#page = page;
}
override async down(
key: KeyInput,
_options?: Readonly<KeyDownOptions>
): Promise<void> {
await this.#page.connection.send('input.performActions', {
context: this.#page.mainFrame()._id,
actions: [
{
type: SourceActionsType.Key,
id: InputId.Keyboard,
actions: [
{
type: ActionType.KeyDown,
value: getBidiKeyValue(key),
},
],
},
],
});
}
override async up(key: KeyInput): Promise<void> {
await this.#page.connection.send('input.performActions', {
context: this.#page.mainFrame()._id,
actions: [
{
type: SourceActionsType.Key,
id: InputId.Keyboard,
actions: [
{
type: ActionType.KeyUp,
value: getBidiKeyValue(key),
},
],
},
],
});
}
override async press(
key: KeyInput,
options: Readonly<KeyPressOptions> = {}
): Promise<void> {
const {delay = 0} = options;
const actions: Bidi.Input.KeySourceAction[] = [
{
type: ActionType.KeyDown,
value: getBidiKeyValue(key),
},
];
if (delay > 0) {
actions.push({
type: ActionType.Pause,
duration: delay,
});
}
actions.push({
type: ActionType.KeyUp,
value: getBidiKeyValue(key),
});
await this.#page.connection.send('input.performActions', {
context: this.#page.mainFrame()._id,
actions: [
{
type: SourceActionsType.Key,
id: InputId.Keyboard,
actions,
},
],
});
}
override async type(
text: string,
options: Readonly<KeyboardTypeOptions> = {}
): Promise<void> {
const {delay = 0} = options;
// This spread separates the characters into code points rather than UTF-16
// code units.
const values = ([...text] as KeyInput[]).map(getBidiKeyValue);
const actions: Bidi.Input.KeySourceAction[] = [];
if (delay <= 0) {
for (const value of values) {
actions.push(
{
type: ActionType.KeyDown,
value,
},
{
type: ActionType.KeyUp,
value,
}
);
}
} else {
for (const value of values) {
actions.push(
{
type: ActionType.KeyDown,
value,
},
{
type: ActionType.Pause,
duration: delay,
},
{
type: ActionType.KeyUp,
value,
}
);
}
}
await this.#page.connection.send('input.performActions', {
context: this.#page.mainFrame()._id,
actions: [
{
type: SourceActionsType.Key,
id: InputId.Keyboard,
actions,
},
],
});
}
override async sendCharacter(char: string): Promise<void> {
// Measures the number of code points rather than UTF-16 code units.
if ([...char].length > 1) {
throw new Error('Cannot send more than 1 character.');
}
const frame = await this.#page.focusedFrame();
await frame.isolatedRealm().evaluate(async char => {
document.execCommand('insertText', false, char);
}, char);
}
}
/**
* @internal
*/
export interface BidiMouseClickOptions extends MouseClickOptions {
origin?: Bidi.Input.Origin;
}
/**
* @internal
*/
export interface BidiMouseMoveOptions extends MouseMoveOptions {
origin?: Bidi.Input.Origin;
}
/**
* @internal
*/
export interface BidiTouchMoveOptions {
origin?: Bidi.Input.Origin;
}
const getBidiButton = (button: MouseButton) => {
switch (button) {
case MouseButton.Left:
return 0;
case MouseButton.Middle:
return 1;
case MouseButton.Right:
return 2;
case MouseButton.Back:
return 3;
case MouseButton.Forward:
return 4;
}
};
/**
* @internal
*/
export class BidiMouse extends Mouse {
#context: BrowsingContext;
#lastMovePoint: Point = {x: 0, y: 0};
constructor(context: BrowsingContext) {
super();
this.#context = context;
}
override async reset(): Promise<void> {
this.#lastMovePoint = {x: 0, y: 0};
await this.#context.connection.send('input.releaseActions', {
context: this.#context.id,
});
}
override async move(
x: number,
y: number,
options: Readonly<BidiMouseMoveOptions> = {}
): Promise<void> {
const from = this.#lastMovePoint;
const to = {
x: Math.round(x),
y: Math.round(y),
};
const actions: Bidi.Input.PointerSourceAction[] = [];
const steps = options.steps ?? 0;
for (let i = 0; i < steps; ++i) {
actions.push({
type: ActionType.PointerMove,
x: from.x + (to.x - from.x) * (i / steps),
y: from.y + (to.y - from.y) * (i / steps),
origin: options.origin,
});
}
actions.push({
type: ActionType.PointerMove,
...to,
origin: options.origin,
});
// https://w3c.github.io/webdriver-bidi/#command-input-performActions:~:text=input.PointerMoveAction%20%3D%20%7B%0A%20%20type%3A%20%22pointerMove%22%2C%0A%20%20x%3A%20js%2Dint%2C
this.#lastMovePoint = to;
await this.#context.connection.send('input.performActions', {
context: this.#context.id,
actions: [
{
type: SourceActionsType.Pointer,
id: InputId.Mouse,
actions,
},
],
});
}
override async down(options: Readonly<MouseOptions> = {}): Promise<void> {
await this.#context.connection.send('input.performActions', {
context: this.#context.id,
actions: [
{
type: SourceActionsType.Pointer,
id: InputId.Mouse,
actions: [
{
type: ActionType.PointerDown,
button: getBidiButton(options.button ?? MouseButton.Left),
},
],
},
],
});
}
override async up(options: Readonly<MouseOptions> = {}): Promise<void> {
await this.#context.connection.send('input.performActions', {
context: this.#context.id,
actions: [
{
type: SourceActionsType.Pointer,
id: InputId.Mouse,
actions: [
{
type: ActionType.PointerUp,
button: getBidiButton(options.button ?? MouseButton.Left),
},
],
},
],
});
}
override async click(
x: number,
y: number,
options: Readonly<BidiMouseClickOptions> = {}
): Promise<void> {
const actions: Bidi.Input.PointerSourceAction[] = [
{
type: ActionType.PointerMove,
x: Math.round(x),
y: Math.round(y),
origin: options.origin,
},
];
const pointerDownAction = {
type: ActionType.PointerDown,
button: getBidiButton(options.button ?? MouseButton.Left),
} as const;
const pointerUpAction = {
type: ActionType.PointerUp,
button: pointerDownAction.button,
} as const;
for (let i = 1; i < (options.count ?? 1); ++i) {
actions.push(pointerDownAction, pointerUpAction);
}
actions.push(pointerDownAction);
if (options.delay) {
actions.push({
type: ActionType.Pause,
duration: options.delay,
});
}
actions.push(pointerUpAction);
await this.#context.connection.send('input.performActions', {
context: this.#context.id,
actions: [
{
type: SourceActionsType.Pointer,
id: InputId.Mouse,
actions,
},
],
});
}
override async wheel(
options: Readonly<MouseWheelOptions> = {}
): Promise<void> {
await this.#context.connection.send('input.performActions', {
context: this.#context.id,
actions: [
{
type: SourceActionsType.Wheel,
id: InputId.Wheel,
actions: [
{
type: ActionType.Scroll,
...(this.#lastMovePoint ?? {
x: 0,
y: 0,
}),
deltaX: options.deltaX ?? 0,
deltaY: options.deltaY ?? 0,
},
],
},
],
});
}
override drag(): never {
throw new UnsupportedOperation();
}
override dragOver(): never {
throw new UnsupportedOperation();
}
override dragEnter(): never {
throw new UnsupportedOperation();
}
override drop(): never {
throw new UnsupportedOperation();
}
override dragAndDrop(): never {
throw new UnsupportedOperation();
}
}
/**
* @internal
*/
export class BidiTouchscreen extends Touchscreen {
#context: BrowsingContext;
constructor(context: BrowsingContext) {
super();
this.#context = context;
}
override async touchStart(
x: number,
y: number,
options: BidiTouchMoveOptions = {}
): Promise<void> {
await this.#context.connection.send('input.performActions', {
context: this.#context.id,
actions: [
{
type: SourceActionsType.Pointer,
id: InputId.Finger,
parameters: {
pointerType: Bidi.Input.PointerType.Touch,
},
actions: [
{
type: ActionType.PointerMove,
x: Math.round(x),
y: Math.round(y),
origin: options.origin,
},
{
type: ActionType.PointerDown,
button: 0,
},
],
},
],
});
}
override async touchMove(
x: number,
y: number,
options: BidiTouchMoveOptions = {}
): Promise<void> {
await this.#context.connection.send('input.performActions', {
context: this.#context.id,
actions: [
{
type: SourceActionsType.Pointer,
id: InputId.Finger,
parameters: {
pointerType: Bidi.Input.PointerType.Touch,
},
actions: [
{
type: ActionType.PointerMove,
x: Math.round(x),
y: Math.round(y),
origin: options.origin,
},
],
},
],
});
}
override async touchEnd(): Promise<void> {
await this.#context.connection.send('input.performActions', {
context: this.#context.id,
actions: [
{
type: SourceActionsType.Pointer,
id: InputId.Finger,
parameters: {
pointerType: Bidi.Input.PointerType.Touch,
},
actions: [
{
type: ActionType.PointerUp,
button: 0,
},
],
},
],
});
}
}
+101
View File
@@ -0,0 +1,101 @@
/**
* @license
* Copyright 2023 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
import type {ElementHandle} from '../api/ElementHandle.js';
import {JSHandle} from '../api/JSHandle.js';
import {UnsupportedOperation} from '../common/Errors.js';
import {BidiDeserializer} from './Deserializer.js';
import type {BidiRealm} from './Realm.js';
import type {Sandbox} from './Sandbox.js';
import {releaseReference} from './util.js';
/**
* @internal
*/
export class BidiJSHandle<T = unknown> extends JSHandle<T> {
#disposed = false;
readonly #sandbox: Sandbox;
readonly #remoteValue: Bidi.Script.RemoteValue;
constructor(sandbox: Sandbox, remoteValue: Bidi.Script.RemoteValue) {
super();
this.#sandbox = sandbox;
this.#remoteValue = remoteValue;
}
context(): BidiRealm {
return this.realm.environment.context();
}
override get realm(): Sandbox {
return this.#sandbox;
}
override get disposed(): boolean {
return this.#disposed;
}
override async jsonValue(): Promise<T> {
return await this.evaluate(value => {
return value;
});
}
override asElement(): ElementHandle<Node> | null {
return null;
}
override async dispose(): Promise<void> {
if (this.#disposed) {
return;
}
this.#disposed = true;
if ('handle' in this.#remoteValue) {
await releaseReference(
this.context(),
this.#remoteValue as Bidi.Script.RemoteReference
);
}
}
get isPrimitiveValue(): boolean {
switch (this.#remoteValue.type) {
case 'string':
case 'number':
case 'bigint':
case 'boolean':
case 'undefined':
case 'null':
return true;
default:
return false;
}
}
override toString(): string {
if (this.isPrimitiveValue) {
return 'JSHandle:' + BidiDeserializer.deserialize(this.#remoteValue);
}
return 'JSHandle@' + this.#remoteValue.type;
}
override get id(): string | undefined {
return 'handle' in this.#remoteValue ? this.#remoteValue.handle : undefined;
}
remoteValue(): Bidi.Script.RemoteValue {
return this.#remoteValue;
}
override remoteObject(): never {
throw new UnsupportedOperation('Not available in WebDriver BiDi');
}
}
+155
View File
@@ -0,0 +1,155 @@
/**
* @license
* Copyright 2023 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
import {EventEmitter, EventSubscription} from '../common/EventEmitter.js';
import {
NetworkManagerEvent,
type NetworkManagerEvents,
} from '../common/NetworkManagerEvents.js';
import {DisposableStack} from '../util/disposable.js';
import type {BidiConnection} from './Connection.js';
import type {BidiFrame} from './Frame.js';
import {BidiHTTPRequest} from './HTTPRequest.js';
import {BidiHTTPResponse} from './HTTPResponse.js';
import type {BidiPage} from './Page.js';
/**
* @internal
*/
export class BidiNetworkManager extends EventEmitter<NetworkManagerEvents> {
#connection: BidiConnection;
#page: BidiPage;
#subscriptions = new DisposableStack();
#requestMap = new Map<string, BidiHTTPRequest>();
#navigationMap = new Map<string, BidiHTTPResponse>();
constructor(connection: BidiConnection, page: BidiPage) {
super();
this.#connection = connection;
this.#page = page;
// TODO: Subscribe to the Frame individually
this.#subscriptions.use(
new EventSubscription(
this.#connection,
'network.beforeRequestSent',
this.#onBeforeRequestSent.bind(this)
)
);
this.#subscriptions.use(
new EventSubscription(
this.#connection,
'network.responseStarted',
this.#onResponseStarted.bind(this)
)
);
this.#subscriptions.use(
new EventSubscription(
this.#connection,
'network.responseCompleted',
this.#onResponseCompleted.bind(this)
)
);
this.#subscriptions.use(
new EventSubscription(
this.#connection,
'network.fetchError',
this.#onFetchError.bind(this)
)
);
}
#onBeforeRequestSent(event: Bidi.Network.BeforeRequestSentParameters): void {
const frame = this.#page.frame(event.context ?? '');
if (!frame) {
return;
}
const request = this.#requestMap.get(event.request.request);
let upsertRequest: BidiHTTPRequest;
if (request) {
request._redirectChain.push(request);
upsertRequest = new BidiHTTPRequest(event, frame, request._redirectChain);
} else {
upsertRequest = new BidiHTTPRequest(event, frame, []);
}
this.#requestMap.set(event.request.request, upsertRequest);
this.emit(NetworkManagerEvent.Request, upsertRequest);
}
#onResponseStarted(_event: Bidi.Network.ResponseStartedParameters) {}
#onResponseCompleted(event: Bidi.Network.ResponseCompletedParameters): void {
const request = this.#requestMap.get(event.request.request);
if (!request) {
return;
}
const response = new BidiHTTPResponse(request, event);
request._response = response;
if (event.navigation) {
this.#navigationMap.set(event.navigation, response);
}
if (response.fromCache()) {
this.emit(NetworkManagerEvent.RequestServedFromCache, request);
}
this.emit(NetworkManagerEvent.Response, response);
this.emit(NetworkManagerEvent.RequestFinished, request);
}
#onFetchError(event: Bidi.Network.FetchErrorParameters) {
const request = this.#requestMap.get(event.request.request);
if (!request) {
return;
}
request._failureText = event.errorText;
this.emit(NetworkManagerEvent.RequestFailed, request);
this.#requestMap.delete(event.request.request);
}
getNavigationResponse(navigationId?: string | null): BidiHTTPResponse | null {
if (!navigationId) {
return null;
}
const response = this.#navigationMap.get(navigationId);
return response ?? null;
}
inFlightRequestsCount(): number {
let inFlightRequestCounter = 0;
for (const request of this.#requestMap.values()) {
if (!request.response() || request._failureText) {
inFlightRequestCounter++;
}
}
return inFlightRequestCounter;
}
clearMapAfterFrameDispose(frame: BidiFrame): void {
for (const [id, request] of this.#requestMap.entries()) {
if (request.frame() === frame) {
this.#requestMap.delete(id);
}
}
for (const [id, response] of this.#navigationMap.entries()) {
if (response.frame() === frame) {
this.#navigationMap.delete(id);
}
}
}
dispose(): void {
this.removeAllListeners();
this.#requestMap.clear();
this.#navigationMap.clear();
this.#subscriptions.dispose();
}
}
+913
View File
@@ -0,0 +1,913 @@
/**
* @license
* Copyright 2022 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type {Readable} from 'stream';
import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
import type Protocol from 'devtools-protocol';
import {
firstValueFrom,
from,
map,
raceWith,
zip,
} from '../../third_party/rxjs/rxjs.js';
import type {CDPSession} from '../api/CDPSession.js';
import type {BoundingBox} from '../api/ElementHandle.js';
import type {WaitForOptions} from '../api/Frame.js';
import type {HTTPResponse} from '../api/HTTPResponse.js';
import {
Page,
PageEvent,
type GeolocationOptions,
type MediaFeature,
type NewDocumentScriptEvaluation,
type ScreenshotOptions,
} from '../api/Page.js';
import {Accessibility} from '../cdp/Accessibility.js';
import {Coverage} from '../cdp/Coverage.js';
import {EmulationManager as CdpEmulationManager} from '../cdp/EmulationManager.js';
import {FrameTree} from '../cdp/FrameTree.js';
import {Tracing} from '../cdp/Tracing.js';
import {
ConsoleMessage,
type ConsoleMessageLocation,
} from '../common/ConsoleMessage.js';
import {TargetCloseError, UnsupportedOperation} from '../common/Errors.js';
import type {Handler} from '../common/EventEmitter.js';
import {NetworkManagerEvent} from '../common/NetworkManagerEvents.js';
import type {PDFOptions} from '../common/PDFOptions.js';
import type {Awaitable} from '../common/types.js';
import {
debugError,
evaluationString,
NETWORK_IDLE_TIME,
parsePDFOptions,
timeout,
validateDialogType,
} from '../common/util.js';
import type {Viewport} from '../common/Viewport.js';
import {assert} from '../util/assert.js';
import {Deferred} from '../util/Deferred.js';
import {disposeSymbol} from '../util/disposable.js';
import {isErrorLike} from '../util/ErrorLike.js';
import type {BidiBrowser} from './Browser.js';
import type {BidiBrowserContext} from './BrowserContext.js';
import {
BrowsingContextEvent,
CdpSessionWrapper,
type BrowsingContext,
} from './BrowsingContext.js';
import type {BidiConnection} from './Connection.js';
import {BidiDeserializer} from './Deserializer.js';
import {BidiDialog} from './Dialog.js';
import {BidiElementHandle} from './ElementHandle.js';
import {EmulationManager} from './EmulationManager.js';
import {BidiFrame} from './Frame.js';
import type {BidiHTTPRequest} from './HTTPRequest.js';
import type {BidiHTTPResponse} from './HTTPResponse.js';
import {BidiKeyboard, BidiMouse, BidiTouchscreen} from './Input.js';
import type {BidiJSHandle} from './JSHandle.js';
import {getBiDiReadinessState, rewriteNavigationError} from './lifecycle.js';
import {BidiNetworkManager} from './NetworkManager.js';
import {createBidiHandle} from './Realm.js';
import type {BiDiPageTarget} from './Target.js';
/**
* @internal
*/
export class BidiPage extends Page {
#accessibility: Accessibility;
#connection: BidiConnection;
#frameTree = new FrameTree<BidiFrame>();
#networkManager: BidiNetworkManager;
#viewport: Viewport | null = null;
#closedDeferred = Deferred.create<never, TargetCloseError>();
#subscribedEvents = new Map<Bidi.Event['method'], Handler<any>>([
['log.entryAdded', this.#onLogEntryAdded.bind(this)],
['browsingContext.load', this.#onFrameLoaded.bind(this)],
[
'browsingContext.fragmentNavigated',
this.#onFrameFragmentNavigated.bind(this),
],
[
'browsingContext.domContentLoaded',
this.#onFrameDOMContentLoaded.bind(this),
],
['browsingContext.userPromptOpened', this.#onDialog.bind(this)],
]);
readonly #networkManagerEvents = [
[
NetworkManagerEvent.Request,
(request: BidiHTTPRequest) => {
this.emit(PageEvent.Request, request);
},
],
[
NetworkManagerEvent.RequestServedFromCache,
(request: BidiHTTPRequest) => {
this.emit(PageEvent.RequestServedFromCache, request);
},
],
[
NetworkManagerEvent.RequestFailed,
(request: BidiHTTPRequest) => {
this.emit(PageEvent.RequestFailed, request);
},
],
[
NetworkManagerEvent.RequestFinished,
(request: BidiHTTPRequest) => {
this.emit(PageEvent.RequestFinished, request);
},
],
[
NetworkManagerEvent.Response,
(response: BidiHTTPResponse) => {
this.emit(PageEvent.Response, response);
},
],
] as const;
readonly #browsingContextEvents = new Map<symbol, Handler<any>>([
[BrowsingContextEvent.Created, this.#onContextCreated.bind(this)],
[BrowsingContextEvent.Destroyed, this.#onContextDestroyed.bind(this)],
]);
#tracing: Tracing;
#coverage: Coverage;
#cdpEmulationManager: CdpEmulationManager;
#emulationManager: EmulationManager;
#mouse: BidiMouse;
#touchscreen: BidiTouchscreen;
#keyboard: BidiKeyboard;
#browsingContext: BrowsingContext;
#browserContext: BidiBrowserContext;
#target: BiDiPageTarget;
_client(): CDPSession {
return this.mainFrame().context().cdpSession;
}
constructor(
browsingContext: BrowsingContext,
browserContext: BidiBrowserContext,
target: BiDiPageTarget
) {
super();
this.#browsingContext = browsingContext;
this.#browserContext = browserContext;
this.#target = target;
this.#connection = browsingContext.connection;
for (const [event, subscriber] of this.#browsingContextEvents) {
this.#browsingContext.on(event, subscriber);
}
this.#networkManager = new BidiNetworkManager(this.#connection, this);
for (const [event, subscriber] of this.#subscribedEvents) {
this.#connection.on(event, subscriber);
}
for (const [event, subscriber] of this.#networkManagerEvents) {
// TODO: remove any
this.#networkManager.on(event, subscriber as any);
}
const frame = new BidiFrame(
this,
this.#browsingContext,
this._timeoutSettings,
this.#browsingContext.parent
);
this.#frameTree.addFrame(frame);
this.emit(PageEvent.FrameAttached, frame);
// TODO: https://github.com/w3c/webdriver-bidi/issues/443
this.#accessibility = new Accessibility(
this.mainFrame().context().cdpSession
);
this.#tracing = new Tracing(this.mainFrame().context().cdpSession);
this.#coverage = new Coverage(this.mainFrame().context().cdpSession);
this.#cdpEmulationManager = new CdpEmulationManager(
this.mainFrame().context().cdpSession
);
this.#emulationManager = new EmulationManager(browsingContext);
this.#mouse = new BidiMouse(this.mainFrame().context());
this.#touchscreen = new BidiTouchscreen(this.mainFrame().context());
this.#keyboard = new BidiKeyboard(this);
}
/**
* @internal
*/
get connection(): BidiConnection {
return this.#connection;
}
override async setUserAgent(
userAgent: string,
userAgentMetadata?: Protocol.Emulation.UserAgentMetadata | undefined
): Promise<void> {
// TODO: handle CDP-specific cases such as mprach.
await this._client().send('Network.setUserAgentOverride', {
userAgent: userAgent,
userAgentMetadata: userAgentMetadata,
});
}
override async setBypassCSP(enabled: boolean): Promise<void> {
// TODO: handle CDP-specific cases such as mprach.
await this._client().send('Page.setBypassCSP', {enabled});
}
override async queryObjects<Prototype>(
prototypeHandle: BidiJSHandle<Prototype>
): Promise<BidiJSHandle<Prototype[]>> {
assert(!prototypeHandle.disposed, 'Prototype JSHandle is disposed!');
assert(
prototypeHandle.id,
'Prototype JSHandle must not be referencing primitive value'
);
const response = await this.mainFrame().client.send(
'Runtime.queryObjects',
{
prototypeObjectId: prototypeHandle.id,
}
);
return createBidiHandle(this.mainFrame().mainRealm(), {
type: 'array',
handle: response.objects.objectId,
}) as BidiJSHandle<Prototype[]>;
}
_setBrowserContext(browserContext: BidiBrowserContext): void {
this.#browserContext = browserContext;
}
override get accessibility(): Accessibility {
return this.#accessibility;
}
override get tracing(): Tracing {
return this.#tracing;
}
override get coverage(): Coverage {
return this.#coverage;
}
override get mouse(): BidiMouse {
return this.#mouse;
}
override get touchscreen(): BidiTouchscreen {
return this.#touchscreen;
}
override get keyboard(): BidiKeyboard {
return this.#keyboard;
}
override browser(): BidiBrowser {
return this.browserContext().browser();
}
override browserContext(): BidiBrowserContext {
return this.#browserContext;
}
override mainFrame(): BidiFrame {
const mainFrame = this.#frameTree.getMainFrame();
assert(mainFrame, 'Requesting main frame too early!');
return mainFrame;
}
/**
* @internal
*/
async focusedFrame(): Promise<BidiFrame> {
using frame = await this.mainFrame()
.isolatedRealm()
.evaluateHandle(() => {
let frame: HTMLIFrameElement | undefined;
let win: Window | null = window;
while (win?.document.activeElement instanceof HTMLIFrameElement) {
frame = win.document.activeElement;
win = frame.contentWindow;
}
return frame;
});
if (!(frame instanceof BidiElementHandle)) {
return this.mainFrame();
}
return await frame.contentFrame();
}
override frames(): BidiFrame[] {
return Array.from(this.#frameTree.frames());
}
frame(frameId?: string): BidiFrame | null {
return this.#frameTree.getById(frameId ?? '') || null;
}
childFrames(frameId: string): BidiFrame[] {
return this.#frameTree.childFrames(frameId);
}
#onFrameLoaded(info: Bidi.BrowsingContext.NavigationInfo): void {
const frame = this.frame(info.context);
if (frame && this.mainFrame() === frame) {
this.emit(PageEvent.Load, undefined);
}
}
#onFrameFragmentNavigated(info: Bidi.BrowsingContext.NavigationInfo): void {
const frame = this.frame(info.context);
if (frame) {
this.emit(PageEvent.FrameNavigated, frame);
}
}
#onFrameDOMContentLoaded(info: Bidi.BrowsingContext.NavigationInfo): void {
const frame = this.frame(info.context);
if (frame) {
frame._hasStartedLoading = true;
if (this.mainFrame() === frame) {
this.emit(PageEvent.DOMContentLoaded, undefined);
}
this.emit(PageEvent.FrameNavigated, frame);
}
}
#onContextCreated(context: BrowsingContext): void {
if (
!this.frame(context.id) &&
(this.frame(context.parent ?? '') || !this.#frameTree.getMainFrame())
) {
const frame = new BidiFrame(
this,
context,
this._timeoutSettings,
context.parent
);
this.#frameTree.addFrame(frame);
if (frame !== this.mainFrame()) {
this.emit(PageEvent.FrameAttached, frame);
}
}
}
#onContextDestroyed(context: BrowsingContext): void {
const frame = this.frame(context.id);
if (frame) {
if (frame === this.mainFrame()) {
this.emit(PageEvent.Close, undefined);
}
this.#removeFramesRecursively(frame);
}
}
#removeFramesRecursively(frame: BidiFrame): void {
for (const child of frame.childFrames()) {
this.#removeFramesRecursively(child);
}
frame[disposeSymbol]();
this.#networkManager.clearMapAfterFrameDispose(frame);
this.#frameTree.removeFrame(frame);
this.emit(PageEvent.FrameDetached, frame);
}
#onLogEntryAdded(event: Bidi.Log.Entry): void {
const frame = this.frame(event.source.context);
if (!frame) {
return;
}
if (isConsoleLogEntry(event)) {
const args = event.args.map(arg => {
return createBidiHandle(frame.mainRealm(), arg);
});
const text = args
.reduce((value, arg) => {
const parsedValue = arg.isPrimitiveValue
? BidiDeserializer.deserialize(arg.remoteValue())
: arg.toString();
return `${value} ${parsedValue}`;
}, '')
.slice(1);
this.emit(
PageEvent.Console,
new ConsoleMessage(
event.method as any,
text,
args,
getStackTraceLocations(event.stackTrace)
)
);
} else if (isJavaScriptLogEntry(event)) {
const error = new Error(event.text ?? '');
const messageHeight = error.message.split('\n').length;
const messageLines = error.stack!.split('\n').splice(0, messageHeight);
const stackLines = [];
if (event.stackTrace) {
for (const frame of event.stackTrace.callFrames) {
// Note we need to add `1` because the values are 0-indexed.
stackLines.push(
` at ${frame.functionName || '<anonymous>'} (${frame.url}:${
frame.lineNumber + 1
}:${frame.columnNumber + 1})`
);
if (stackLines.length >= Error.stackTraceLimit) {
break;
}
}
}
error.stack = [...messageLines, ...stackLines].join('\n');
this.emit(PageEvent.PageError, error);
} else {
debugError(
`Unhandled LogEntry with type "${event.type}", text "${event.text}" and level "${event.level}"`
);
}
}
#onDialog(event: Bidi.BrowsingContext.UserPromptOpenedParameters): void {
const frame = this.frame(event.context);
if (!frame) {
return;
}
const type = validateDialogType(event.type);
const dialog = new BidiDialog(
frame.context(),
type,
event.message,
event.defaultValue
);
this.emit(PageEvent.Dialog, dialog);
}
getNavigationResponse(id?: string | null): BidiHTTPResponse | null {
return this.#networkManager.getNavigationResponse(id);
}
override isClosed(): boolean {
return this.#closedDeferred.finished();
}
override async close(options?: {runBeforeUnload?: boolean}): Promise<void> {
if (this.#closedDeferred.finished()) {
return;
}
this.#closedDeferred.reject(new TargetCloseError('Page closed!'));
this.#networkManager.dispose();
await this.#connection.send('browsingContext.close', {
context: this.mainFrame()._id,
promptUnload: options?.runBeforeUnload ?? false,
});
this.emit(PageEvent.Close, undefined);
this.removeAllListeners();
}
override async reload(
options: WaitForOptions = {}
): Promise<BidiHTTPResponse | null> {
const {
waitUntil = 'load',
timeout: ms = this._timeoutSettings.navigationTimeout(),
} = options;
const [readiness, networkIdle] = getBiDiReadinessState(waitUntil);
const result$ = zip(
from(
this.#connection.send('browsingContext.reload', {
context: this.mainFrame()._id,
wait: readiness,
})
),
...(networkIdle !== null
? [
this.waitForNetworkIdle$({
timeout: ms,
concurrency: networkIdle === 'networkidle2' ? 2 : 0,
idleTime: NETWORK_IDLE_TIME,
}),
]
: [])
).pipe(
map(([{result}]) => {
return result;
}),
raceWith(timeout(ms), from(this.#closedDeferred.valueOrThrow())),
rewriteNavigationError(this.url(), ms)
);
const result = await firstValueFrom(result$);
return this.getNavigationResponse(result.navigation);
}
override setDefaultNavigationTimeout(timeout: number): void {
this._timeoutSettings.setDefaultNavigationTimeout(timeout);
}
override setDefaultTimeout(timeout: number): void {
this._timeoutSettings.setDefaultTimeout(timeout);
}
override getDefaultTimeout(): number {
return this._timeoutSettings.timeout();
}
override isJavaScriptEnabled(): boolean {
return this.#cdpEmulationManager.javascriptEnabled;
}
override async setGeolocation(options: GeolocationOptions): Promise<void> {
return await this.#cdpEmulationManager.setGeolocation(options);
}
override async setJavaScriptEnabled(enabled: boolean): Promise<void> {
return await this.#cdpEmulationManager.setJavaScriptEnabled(enabled);
}
override async emulateMediaType(type?: string): Promise<void> {
return await this.#cdpEmulationManager.emulateMediaType(type);
}
override async emulateCPUThrottling(factor: number | null): Promise<void> {
return await this.#cdpEmulationManager.emulateCPUThrottling(factor);
}
override async emulateMediaFeatures(
features?: MediaFeature[]
): Promise<void> {
return await this.#cdpEmulationManager.emulateMediaFeatures(features);
}
override async emulateTimezone(timezoneId?: string): Promise<void> {
return await this.#cdpEmulationManager.emulateTimezone(timezoneId);
}
override async emulateIdleState(overrides?: {
isUserActive: boolean;
isScreenUnlocked: boolean;
}): Promise<void> {
return await this.#cdpEmulationManager.emulateIdleState(overrides);
}
override async emulateVisionDeficiency(
type?: Protocol.Emulation.SetEmulatedVisionDeficiencyRequest['type']
): Promise<void> {
return await this.#cdpEmulationManager.emulateVisionDeficiency(type);
}
override async setViewport(viewport: Viewport): Promise<void> {
if (!this.#browsingContext.supportsCdp()) {
await this.#emulationManager.emulateViewport(viewport);
this.#viewport = viewport;
return;
}
const needsReload =
await this.#cdpEmulationManager.emulateViewport(viewport);
this.#viewport = viewport;
if (needsReload) {
await this.reload();
}
}
override viewport(): Viewport | null {
return this.#viewport;
}
override async pdf(options: PDFOptions = {}): Promise<Buffer> {
const {timeout: ms = this._timeoutSettings.timeout(), path = undefined} =
options;
const {
printBackground: background,
margin,
landscape,
width,
height,
pageRanges: ranges,
scale,
preferCSSPageSize,
} = parsePDFOptions(options, 'cm');
const pageRanges = ranges ? ranges.split(', ') : [];
const {result} = await firstValueFrom(
from(
this.#connection.send('browsingContext.print', {
context: this.mainFrame()._id,
background,
margin,
orientation: landscape ? 'landscape' : 'portrait',
page: {
width,
height,
},
pageRanges,
scale,
shrinkToFit: !preferCSSPageSize,
})
).pipe(raceWith(timeout(ms)))
);
const buffer = Buffer.from(result.data, 'base64');
await this._maybeWriteBufferToFile(path, buffer);
return buffer;
}
override async createPDFStream(
options?: PDFOptions | undefined
): Promise<Readable> {
const buffer = await this.pdf(options);
try {
const {Readable} = await import('stream');
return Readable.from(buffer);
} catch (error) {
if (error instanceof TypeError) {
throw new Error(
'Can only pass a file path in a Node-like environment.'
);
}
throw error;
}
}
override async _screenshot(
options: Readonly<ScreenshotOptions>
): Promise<string> {
const {clip, type, captureBeyondViewport, quality} = options;
if (options.omitBackground !== undefined && options.omitBackground) {
throw new UnsupportedOperation(`BiDi does not support 'omitBackground'.`);
}
if (options.optimizeForSpeed !== undefined && options.optimizeForSpeed) {
throw new UnsupportedOperation(
`BiDi does not support 'optimizeForSpeed'.`
);
}
if (options.fromSurface !== undefined && !options.fromSurface) {
throw new UnsupportedOperation(`BiDi does not support 'fromSurface'.`);
}
if (clip !== undefined && clip.scale !== undefined && clip.scale !== 1) {
throw new UnsupportedOperation(
`BiDi does not support 'scale' in 'clip'.`
);
}
let box: BoundingBox | undefined;
if (clip) {
if (captureBeyondViewport) {
box = clip;
} else {
// The clip is always with respect to the document coordinates, so we
// need to convert this to viewport coordinates when we aren't capturing
// beyond the viewport.
const [pageLeft, pageTop] = await this.evaluate(() => {
if (!window.visualViewport) {
throw new Error('window.visualViewport is not supported.');
}
return [
window.visualViewport.pageLeft,
window.visualViewport.pageTop,
] as const;
});
box = {
...clip,
x: clip.x - pageLeft,
y: clip.y - pageTop,
};
}
}
const {
result: {data},
} = await this.#connection.send('browsingContext.captureScreenshot', {
context: this.mainFrame()._id,
origin: captureBeyondViewport ? 'document' : 'viewport',
format: {
type: `image/${type}`,
...(quality !== undefined ? {quality: quality / 100} : {}),
},
...(box ? {clip: {type: 'box', ...box}} : {}),
});
return data;
}
override async createCDPSession(): Promise<CDPSession> {
const {sessionId} = await this.mainFrame()
.context()
.cdpSession.send('Target.attachToTarget', {
targetId: this.mainFrame()._id,
flatten: true,
});
return new CdpSessionWrapper(this.mainFrame().context(), sessionId);
}
override async bringToFront(): Promise<void> {
await this.#connection.send('browsingContext.activate', {
context: this.mainFrame()._id,
});
}
override async evaluateOnNewDocument<
Params extends unknown[],
Func extends (...args: Params) => unknown = (...args: Params) => unknown,
>(
pageFunction: Func | string,
...args: Params
): Promise<NewDocumentScriptEvaluation> {
const expression = evaluationExpression(pageFunction, ...args);
const {result} = await this.#connection.send('script.addPreloadScript', {
functionDeclaration: expression,
contexts: [this.mainFrame()._id],
});
return {identifier: result.script};
}
override async removeScriptToEvaluateOnNewDocument(
id: string
): Promise<void> {
await this.#connection.send('script.removePreloadScript', {
script: id,
});
}
override async exposeFunction<Args extends unknown[], Ret>(
name: string,
pptrFunction:
| ((...args: Args) => Awaitable<Ret>)
| {default: (...args: Args) => Awaitable<Ret>}
): Promise<void> {
return await this.mainFrame().exposeFunction(
name,
'default' in pptrFunction ? pptrFunction.default : pptrFunction
);
}
override isDragInterceptionEnabled(): boolean {
return false;
}
override async setCacheEnabled(enabled?: boolean): Promise<void> {
// TODO: handle CDP-specific cases such as mprach.
await this._client().send('Network.setCacheDisabled', {
cacheDisabled: !enabled,
});
}
override isServiceWorkerBypassed(): never {
throw new UnsupportedOperation();
}
override target(): BiDiPageTarget {
return this.#target;
}
override waitForFileChooser(): never {
throw new UnsupportedOperation();
}
override workers(): never {
throw new UnsupportedOperation();
}
override setRequestInterception(): never {
throw new UnsupportedOperation();
}
override setDragInterception(): never {
throw new UnsupportedOperation();
}
override setBypassServiceWorker(): never {
throw new UnsupportedOperation();
}
override setOfflineMode(): never {
throw new UnsupportedOperation();
}
override emulateNetworkConditions(): never {
throw new UnsupportedOperation();
}
override cookies(): never {
throw new UnsupportedOperation();
}
override setCookie(): never {
throw new UnsupportedOperation();
}
override deleteCookie(): never {
throw new UnsupportedOperation();
}
override removeExposedFunction(): never {
// TODO: Quick win?
throw new UnsupportedOperation();
}
override authenticate(): never {
throw new UnsupportedOperation();
}
override setExtraHTTPHeaders(): never {
throw new UnsupportedOperation();
}
override metrics(): never {
throw new UnsupportedOperation();
}
override async goBack(
options: WaitForOptions = {}
): Promise<HTTPResponse | null> {
return await this.#go(-1, options);
}
override async goForward(
options: WaitForOptions = {}
): Promise<HTTPResponse | null> {
return await this.#go(+1, options);
}
async #go(
delta: number,
options: WaitForOptions
): Promise<HTTPResponse | null> {
try {
const result = await Promise.all([
this.waitForNavigation(options),
this.#connection.send('browsingContext.traverseHistory', {
delta,
context: this.mainFrame()._id,
}),
]);
return result[0];
} catch (err) {
// TODO: waitForNavigation should be cancelled if an error happens.
if (isErrorLike(err)) {
if (err.message.includes('no such history entry')) {
return null;
}
}
throw err;
}
}
override waitForDevicePrompt(): never {
throw new UnsupportedOperation();
}
}
function isConsoleLogEntry(
event: Bidi.Log.Entry
): event is Bidi.Log.ConsoleLogEntry {
return event.type === 'console';
}
function isJavaScriptLogEntry(
event: Bidi.Log.Entry
): event is Bidi.Log.JavascriptLogEntry {
return event.type === 'javascript';
}
function getStackTraceLocations(
stackTrace?: Bidi.Script.StackTrace
): ConsoleMessageLocation[] {
const stackTraceLocations: ConsoleMessageLocation[] = [];
if (stackTrace) {
for (const callFrame of stackTrace.callFrames) {
stackTraceLocations.push({
url: callFrame.url,
lineNumber: callFrame.lineNumber,
columnNumber: callFrame.columnNumber,
});
}
}
return stackTraceLocations;
}
function evaluationExpression(fun: Function | string, ...args: unknown[]) {
return `() => {${evaluationString(fun, ...args)}}`;
}
+228
View File
@@ -0,0 +1,228 @@
import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
import {EventEmitter, type EventType} from '../common/EventEmitter.js';
import {scriptInjector} from '../common/ScriptInjector.js';
import type {EvaluateFunc, HandleFor} from '../common/types.js';
import {
PuppeteerURL,
SOURCE_URL_REGEX,
getSourcePuppeteerURLIfAvailable,
getSourceUrlComment,
isString,
} from '../common/util.js';
import type PuppeteerUtil from '../injected/injected.js';
import {disposeSymbol} from '../util/disposable.js';
import {stringifyFunction} from '../util/Function.js';
import type {BidiConnection} from './Connection.js';
import {BidiDeserializer} from './Deserializer.js';
import {BidiElementHandle} from './ElementHandle.js';
import {BidiJSHandle} from './JSHandle.js';
import type {Sandbox} from './Sandbox.js';
import {BidiSerializer} from './Serializer.js';
import {createEvaluationError} from './util.js';
/**
* @internal
*/
export class BidiRealm extends EventEmitter<Record<EventType, any>> {
readonly connection: BidiConnection;
#id!: string;
#sandbox!: Sandbox;
constructor(connection: BidiConnection) {
super();
this.connection = connection;
}
get target(): Bidi.Script.Target {
return {
context: this.#sandbox.environment._id,
sandbox: this.#sandbox.name,
};
}
handleRealmDestroyed = async (
params: Bidi.Script.RealmDestroyed['params']
): Promise<void> => {
if (params.realm === this.#id) {
// Note: The Realm is destroyed, so in theory the handle should be as
// well.
this.internalPuppeteerUtil = undefined;
this.#sandbox.environment.clearDocumentHandle();
}
};
handleRealmCreated = (params: Bidi.Script.RealmCreated['params']): void => {
if (
params.type === 'window' &&
params.context === this.#sandbox.environment._id &&
params.sandbox === this.#sandbox.name
) {
this.#id = params.realm;
void this.#sandbox.taskManager.rerunAll();
}
};
setSandbox(sandbox: Sandbox): void {
this.#sandbox = sandbox;
this.connection.on(
Bidi.ChromiumBidi.Script.EventNames.RealmCreated,
this.handleRealmCreated
);
this.connection.on(
Bidi.ChromiumBidi.Script.EventNames.RealmDestroyed,
this.handleRealmDestroyed
);
}
protected internalPuppeteerUtil?: Promise<BidiJSHandle<PuppeteerUtil>>;
get puppeteerUtil(): Promise<BidiJSHandle<PuppeteerUtil>> {
const promise = Promise.resolve() as Promise<unknown>;
scriptInjector.inject(script => {
if (this.internalPuppeteerUtil) {
void this.internalPuppeteerUtil.then(handle => {
void handle.dispose();
});
}
this.internalPuppeteerUtil = promise.then(() => {
return this.evaluateHandle(script) as Promise<
BidiJSHandle<PuppeteerUtil>
>;
});
}, !this.internalPuppeteerUtil);
return this.internalPuppeteerUtil as Promise<BidiJSHandle<PuppeteerUtil>>;
}
async evaluateHandle<
Params extends unknown[],
Func extends EvaluateFunc<Params> = EvaluateFunc<Params>,
>(
pageFunction: Func | string,
...args: Params
): Promise<HandleFor<Awaited<ReturnType<Func>>>> {
return await this.#evaluate(false, pageFunction, ...args);
}
async evaluate<
Params extends unknown[],
Func extends EvaluateFunc<Params> = EvaluateFunc<Params>,
>(
pageFunction: Func | string,
...args: Params
): Promise<Awaited<ReturnType<Func>>> {
return await this.#evaluate(true, pageFunction, ...args);
}
async #evaluate<
Params extends unknown[],
Func extends EvaluateFunc<Params> = EvaluateFunc<Params>,
>(
returnByValue: true,
pageFunction: Func | string,
...args: Params
): Promise<Awaited<ReturnType<Func>>>;
async #evaluate<
Params extends unknown[],
Func extends EvaluateFunc<Params> = EvaluateFunc<Params>,
>(
returnByValue: false,
pageFunction: Func | string,
...args: Params
): Promise<HandleFor<Awaited<ReturnType<Func>>>>;
async #evaluate<
Params extends unknown[],
Func extends EvaluateFunc<Params> = EvaluateFunc<Params>,
>(
returnByValue: boolean,
pageFunction: Func | string,
...args: Params
): Promise<HandleFor<Awaited<ReturnType<Func>>> | Awaited<ReturnType<Func>>> {
const sourceUrlComment = getSourceUrlComment(
getSourcePuppeteerURLIfAvailable(pageFunction)?.toString() ??
PuppeteerURL.INTERNAL_URL
);
const sandbox = this.#sandbox;
let responsePromise;
const resultOwnership = returnByValue
? Bidi.Script.ResultOwnership.None
: Bidi.Script.ResultOwnership.Root;
const serializationOptions: Bidi.Script.SerializationOptions = returnByValue
? {}
: {
maxObjectDepth: 0,
maxDomDepth: 0,
};
if (isString(pageFunction)) {
const expression = SOURCE_URL_REGEX.test(pageFunction)
? pageFunction
: `${pageFunction}\n${sourceUrlComment}\n`;
responsePromise = this.connection.send('script.evaluate', {
expression,
target: this.target,
resultOwnership,
awaitPromise: true,
userActivation: true,
serializationOptions,
});
} else {
let functionDeclaration = stringifyFunction(pageFunction);
functionDeclaration = SOURCE_URL_REGEX.test(functionDeclaration)
? functionDeclaration
: `${functionDeclaration}\n${sourceUrlComment}\n`;
responsePromise = this.connection.send('script.callFunction', {
functionDeclaration,
arguments: args.length
? await Promise.all(
args.map(arg => {
return BidiSerializer.serialize(sandbox, arg);
})
)
: [],
target: this.target,
resultOwnership,
awaitPromise: true,
userActivation: true,
serializationOptions,
});
}
const {result} = await responsePromise;
if ('type' in result && result.type === 'exception') {
throw createEvaluationError(result.exceptionDetails);
}
return returnByValue
? BidiDeserializer.deserialize(result.result)
: createBidiHandle(sandbox, result.result);
}
[disposeSymbol](): void {
this.connection.off(
Bidi.ChromiumBidi.Script.EventNames.RealmCreated,
this.handleRealmCreated
);
this.connection.off(
Bidi.ChromiumBidi.Script.EventNames.RealmDestroyed,
this.handleRealmDestroyed
);
}
}
/**
* @internal
*/
export function createBidiHandle(
sandbox: Sandbox,
result: Bidi.Script.RemoteValue
): BidiJSHandle<unknown> | BidiElementHandle<Node> {
if (result.type === 'node' || result.type === 'window') {
return new BidiElementHandle(sandbox, result);
}
return new BidiJSHandle(sandbox, result);
}
+123
View File
@@ -0,0 +1,123 @@
/**
* @license
* Copyright 2023 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type {JSHandle} from '../api/JSHandle.js';
import {Realm} from '../api/Realm.js';
import type {TimeoutSettings} from '../common/TimeoutSettings.js';
import type {EvaluateFunc, HandleFor} from '../common/types.js';
import {withSourcePuppeteerURLIfNone} from '../common/util.js';
import type {BrowsingContext} from './BrowsingContext.js';
import {BidiElementHandle} from './ElementHandle.js';
import type {BidiFrame} from './Frame.js';
import type {BidiRealm as BidiRealm} from './Realm.js';
/**
* A unique key for {@link SandboxChart} to denote the default world.
* Realms are automatically created in the default sandbox.
*
* @internal
*/
export const MAIN_SANDBOX = Symbol('mainSandbox');
/**
* A unique key for {@link SandboxChart} to denote the puppeteer sandbox.
* This world contains all puppeteer-internal bindings/code.
*
* @internal
*/
export const PUPPETEER_SANDBOX = Symbol('puppeteerSandbox');
/**
* @internal
*/
export interface SandboxChart {
[key: string]: Sandbox;
[MAIN_SANDBOX]: Sandbox;
[PUPPETEER_SANDBOX]: Sandbox;
}
/**
* @internal
*/
export class Sandbox extends Realm {
readonly name: string | undefined;
readonly realm: BidiRealm;
#frame: BidiFrame;
constructor(
name: string | undefined,
frame: BidiFrame,
// TODO: We should split the Realm and BrowsingContext
realm: BidiRealm | BrowsingContext,
timeoutSettings: TimeoutSettings
) {
super(timeoutSettings);
this.name = name;
this.realm = realm;
this.#frame = frame;
this.realm.setSandbox(this);
}
override get environment(): BidiFrame {
return this.#frame;
}
async evaluateHandle<
Params extends unknown[],
Func extends EvaluateFunc<Params> = EvaluateFunc<Params>,
>(
pageFunction: Func | string,
...args: Params
): Promise<HandleFor<Awaited<ReturnType<Func>>>> {
pageFunction = withSourcePuppeteerURLIfNone(
this.evaluateHandle.name,
pageFunction
);
return await this.realm.evaluateHandle(pageFunction, ...args);
}
async evaluate<
Params extends unknown[],
Func extends EvaluateFunc<Params> = EvaluateFunc<Params>,
>(
pageFunction: Func | string,
...args: Params
): Promise<Awaited<ReturnType<Func>>> {
pageFunction = withSourcePuppeteerURLIfNone(
this.evaluate.name,
pageFunction
);
return await this.realm.evaluate(pageFunction, ...args);
}
async adoptHandle<T extends JSHandle<Node>>(handle: T): Promise<T> {
return (await this.evaluateHandle(node => {
return node;
}, handle)) as unknown as T;
}
async transferHandle<T extends JSHandle<Node>>(handle: T): Promise<T> {
if (handle.realm === this) {
return handle;
}
const transferredHandle = await this.evaluateHandle(node => {
return node;
}, handle);
await handle.dispose();
return transferredHandle as unknown as T;
}
override async adoptBackendNode(
backendNodeId?: number
): Promise<JSHandle<Node>> {
const {object} = await this.environment.client.send('DOM.resolveNode', {
backendNodeId: backendNodeId,
});
return new BidiElementHandle(this, {
handle: object.objectId,
type: 'node',
});
}
}
+164
View File
@@ -0,0 +1,164 @@
/**
* @license
* Copyright 2023 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
import {LazyArg} from '../common/LazyArg.js';
import {isDate, isPlainObject, isRegExp} from '../common/util.js';
import {BidiElementHandle} from './ElementHandle.js';
import {BidiJSHandle} from './JSHandle.js';
import type {Sandbox} from './Sandbox.js';
/**
* @internal
*/
class UnserializableError extends Error {}
/**
* @internal
*/
export class BidiSerializer {
static serializeNumber(arg: number): Bidi.Script.LocalValue {
let value: Bidi.Script.SpecialNumber | number;
if (Object.is(arg, -0)) {
value = '-0';
} else if (Object.is(arg, Infinity)) {
value = 'Infinity';
} else if (Object.is(arg, -Infinity)) {
value = '-Infinity';
} else if (Object.is(arg, NaN)) {
value = 'NaN';
} else {
value = arg;
}
return {
type: 'number',
value,
};
}
static serializeObject(arg: object | null): Bidi.Script.LocalValue {
if (arg === null) {
return {
type: 'null',
};
} else if (Array.isArray(arg)) {
const parsedArray = arg.map(subArg => {
return BidiSerializer.serializeRemoteValue(subArg);
});
return {
type: 'array',
value: parsedArray,
};
} else if (isPlainObject(arg)) {
try {
JSON.stringify(arg);
} catch (error) {
if (
error instanceof TypeError &&
error.message.startsWith('Converting circular structure to JSON')
) {
error.message += ' Recursive objects are not allowed.';
}
throw error;
}
const parsedObject: Bidi.Script.MappingLocalValue = [];
for (const key in arg) {
parsedObject.push([
BidiSerializer.serializeRemoteValue(key),
BidiSerializer.serializeRemoteValue(arg[key]),
]);
}
return {
type: 'object',
value: parsedObject,
};
} else if (isRegExp(arg)) {
return {
type: 'regexp',
value: {
pattern: arg.source,
flags: arg.flags,
},
};
} else if (isDate(arg)) {
return {
type: 'date',
value: arg.toISOString(),
};
}
throw new UnserializableError(
'Custom object sterilization not possible. Use plain objects instead.'
);
}
static serializeRemoteValue(arg: unknown): Bidi.Script.LocalValue {
switch (typeof arg) {
case 'symbol':
case 'function':
throw new UnserializableError(`Unable to serializable ${typeof arg}`);
case 'object':
return BidiSerializer.serializeObject(arg);
case 'undefined':
return {
type: 'undefined',
};
case 'number':
return BidiSerializer.serializeNumber(arg);
case 'bigint':
return {
type: 'bigint',
value: arg.toString(),
};
case 'string':
return {
type: 'string',
value: arg,
};
case 'boolean':
return {
type: 'boolean',
value: arg,
};
}
}
static async serialize(
sandbox: Sandbox,
arg: unknown
): Promise<Bidi.Script.LocalValue> {
if (arg instanceof LazyArg) {
arg = await arg.get(sandbox.realm);
}
// eslint-disable-next-line rulesdir/use-using -- We want this to continue living.
const objectHandle =
arg && (arg instanceof BidiJSHandle || arg instanceof BidiElementHandle)
? arg
: null;
if (objectHandle) {
if (
objectHandle.realm.environment.context() !==
sandbox.environment.context()
) {
throw new Error(
'JSHandles can be evaluated only in the context they were created!'
);
}
if (objectHandle.disposed) {
throw new Error('JSHandle is disposed!');
}
return objectHandle.remoteValue() as Bidi.Script.RemoteReference;
}
return BidiSerializer.serializeRemoteValue(arg);
}
}
+151
View File
@@ -0,0 +1,151 @@
/**
* @license
* Copyright 2023 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type {CDPSession} from '../api/CDPSession.js';
import type {Page} from '../api/Page.js';
import {Target, TargetType} from '../api/Target.js';
import {UnsupportedOperation} from '../common/Errors.js';
import type {BidiBrowser} from './Browser.js';
import type {BidiBrowserContext} from './BrowserContext.js';
import {type BrowsingContext, CdpSessionWrapper} from './BrowsingContext.js';
import {BidiPage} from './Page.js';
/**
* @internal
*/
export abstract class BidiTarget extends Target {
protected _browserContext: BidiBrowserContext;
constructor(browserContext: BidiBrowserContext) {
super();
this._browserContext = browserContext;
}
_setBrowserContext(browserContext: BidiBrowserContext): void {
this._browserContext = browserContext;
}
override asPage(): Promise<Page> {
throw new UnsupportedOperation();
}
override browser(): BidiBrowser {
return this._browserContext.browser();
}
override browserContext(): BidiBrowserContext {
return this._browserContext;
}
override opener(): never {
throw new UnsupportedOperation();
}
override createCDPSession(): Promise<CDPSession> {
throw new UnsupportedOperation();
}
}
/**
* @internal
*/
export class BiDiBrowserTarget extends Target {
#browser: BidiBrowser;
constructor(browser: BidiBrowser) {
super();
this.#browser = browser;
}
override url(): string {
return '';
}
override type(): TargetType {
return TargetType.BROWSER;
}
override asPage(): Promise<Page> {
throw new UnsupportedOperation();
}
override browser(): BidiBrowser {
return this.#browser;
}
override browserContext(): BidiBrowserContext {
return this.#browser.defaultBrowserContext();
}
override opener(): never {
throw new UnsupportedOperation();
}
override createCDPSession(): Promise<CDPSession> {
throw new UnsupportedOperation();
}
}
/**
* @internal
*/
export class BiDiBrowsingContextTarget extends BidiTarget {
protected _browsingContext: BrowsingContext;
constructor(
browserContext: BidiBrowserContext,
browsingContext: BrowsingContext
) {
super(browserContext);
this._browsingContext = browsingContext;
}
override url(): string {
return this._browsingContext.url;
}
override async createCDPSession(): Promise<CDPSession> {
const {sessionId} = await this._browsingContext.cdpSession.send(
'Target.attachToTarget',
{
targetId: this._browsingContext.id,
flatten: true,
}
);
return new CdpSessionWrapper(this._browsingContext, sessionId);
}
override type(): TargetType {
return TargetType.PAGE;
}
}
/**
* @internal
*/
export class BiDiPageTarget extends BiDiBrowsingContextTarget {
#page: BidiPage;
constructor(
browserContext: BidiBrowserContext,
browsingContext: BrowsingContext
) {
super(browserContext, browsingContext);
this.#page = new BidiPage(browsingContext, browserContext, this);
}
override async page(): Promise<BidiPage> {
return this.#page;
}
override _setBrowserContext(browserContext: BidiBrowserContext): void {
super._setBrowserContext(browserContext);
this.#page._setBrowserContext(browserContext);
}
}
+22
View File
@@ -0,0 +1,22 @@
/**
* @license
* Copyright 2022 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
export * from './BidiOverCdp.js';
export * from './Browser.js';
export * from './BrowserContext.js';
export * from './BrowsingContext.js';
export * from './Connection.js';
export * from './ElementHandle.js';
export * from './Frame.js';
export * from './HTTPRequest.js';
export * from './HTTPResponse.js';
export * from './Input.js';
export * from './JSHandle.js';
export * from './NetworkManager.js';
export * from './Page.js';
export * from './Realm.js';
export * from './Sandbox.js';
export * from './Target.js';
+240
View File
@@ -0,0 +1,240 @@
/**
* @license
* Copyright 2024 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
import {EventEmitter} from '../../common/EventEmitter.js';
import {inertIfDisposed, throwIfDisposed} from '../../util/decorators.js';
import {DisposableStack, disposeSymbol} from '../../util/disposable.js';
import type {BrowsingContext} from './BrowsingContext.js';
import type {SharedWorkerRealm} from './Realm.js';
import type {Session} from './Session.js';
import {UserContext} from './UserContext.js';
/**
* @internal
*/
export type AddPreloadScriptOptions = Omit<
Bidi.Script.AddPreloadScriptParameters,
'functionDeclaration' | 'contexts'
> & {
contexts?: [BrowsingContext, ...BrowsingContext[]];
};
/**
* @internal
*/
export class Browser extends EventEmitter<{
/** Emitted before the browser closes. */
closed: {
/** The reason for closing the browser. */
reason: string;
};
/** Emitted after the browser disconnects. */
disconnected: {
/** The reason for disconnecting the browser. */
reason: string;
};
/** Emitted when a shared worker is created. */
sharedworker: {
/** The realm of the shared worker. */
realm: SharedWorkerRealm;
};
}> {
static async from(session: Session): Promise<Browser> {
const browser = new Browser(session);
await browser.#initialize();
return browser;
}
// keep-sorted start
#closed = false;
#reason: string | undefined;
readonly #disposables = new DisposableStack();
readonly #userContexts = new Map<string, UserContext>();
readonly session: Session;
// keep-sorted end
private constructor(session: Session) {
super();
// keep-sorted start
this.session = session;
// keep-sorted end
this.#userContexts.set(
UserContext.DEFAULT,
UserContext.create(this, UserContext.DEFAULT)
);
}
async #initialize() {
const sessionEmitter = this.#disposables.use(
new EventEmitter(this.session)
);
sessionEmitter.once('ended', ({reason}) => {
this.dispose(reason);
});
sessionEmitter.on('script.realmCreated', info => {
if (info.type === 'shared-worker') {
// TODO: Create a SharedWorkerRealm.
}
});
await this.#syncUserContexts();
await this.#syncBrowsingContexts();
}
async #syncUserContexts() {
const {
result: {userContexts},
} = await this.session.send('browser.getUserContexts', {});
for (const context of userContexts) {
if (context.userContext === UserContext.DEFAULT) {
continue;
}
this.#userContexts.set(
context.userContext,
UserContext.create(this, context.userContext)
);
}
}
async #syncBrowsingContexts() {
// In case contexts are created or destroyed during `getTree`, we use this
// set to detect them.
const contextIds = new Set<string>();
let contexts: Bidi.BrowsingContext.Info[];
{
using sessionEmitter = new EventEmitter(this.session);
sessionEmitter.on('browsingContext.contextCreated', info => {
contextIds.add(info.context);
});
sessionEmitter.on('browsingContext.contextDestroyed', info => {
contextIds.delete(info.context);
});
const {result} = await this.session.send('browsingContext.getTree', {});
contexts = result.contexts;
}
// Simulating events so contexts are created naturally.
for (const info of contexts) {
if (contextIds.has(info.context)) {
this.session.emit('browsingContext.contextCreated', info);
}
if (info.children) {
contexts.push(...info.children);
}
}
}
// keep-sorted start block=yes
get closed(): boolean {
return this.#closed;
}
get defaultUserContext(): UserContext {
// SAFETY: A UserContext is always created for the default context.
return this.#userContexts.get(UserContext.DEFAULT)!;
}
get disconnected(): boolean {
return this.#reason !== undefined;
}
get disposed(): boolean {
return this.disconnected;
}
get userContexts(): Iterable<UserContext> {
return this.#userContexts.values();
}
// keep-sorted end
@inertIfDisposed
dispose(reason?: string, closed = false): void {
this.#closed = closed;
this.#reason = reason;
this[disposeSymbol]();
}
@throwIfDisposed<Browser>(browser => {
// SAFETY: By definition of `disposed`, `#reason` is defined.
return browser.#reason!;
})
async close(): Promise<void> {
try {
await this.session.send('browser.close', {});
} finally {
this.dispose('Browser already closed.', true);
}
}
@throwIfDisposed<Browser>(browser => {
// SAFETY: By definition of `disposed`, `#reason` is defined.
return browser.#reason!;
})
async addPreloadScript(
functionDeclaration: string,
options: AddPreloadScriptOptions = {}
): Promise<string> {
const {
result: {script},
} = await this.session.send('script.addPreloadScript', {
functionDeclaration,
...options,
contexts: options.contexts?.map(context => {
return context.id;
}) as [string, ...string[]],
});
return script;
}
@throwIfDisposed<Browser>(browser => {
// SAFETY: By definition of `disposed`, `#reason` is defined.
return browser.#reason!;
})
async removePreloadScript(script: string): Promise<void> {
await this.session.send('script.removePreloadScript', {
script,
});
}
@throwIfDisposed<Browser>(browser => {
// SAFETY: By definition of `disposed`, `#reason` is defined.
return browser.#reason!;
})
async createUserContext(): Promise<UserContext> {
const {
result: {userContext: context},
} = await this.session.send('browser.createUserContext', {});
const userContext = UserContext.create(this, context);
this.#userContexts.set(userContext.id, userContext);
const userContextEmitter = this.#disposables.use(
new EventEmitter(userContext)
);
userContextEmitter.once('closed', () => {
userContextEmitter.removeAllListeners();
this.#userContexts.delete(context);
});
return userContext;
}
[disposeSymbol](): void {
this.#reason ??=
'Browser was disconnected, probably because the session ended.';
if (this.closed) {
this.emit('closed', {reason: this.#reason});
}
this.emit('disconnected', {reason: this.#reason});
this.#disposables.dispose();
super[disposeSymbol]();
}
}
+475
View File
@@ -0,0 +1,475 @@
/**
* @license
* Copyright 2024 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
import {EventEmitter} from '../../common/EventEmitter.js';
import {inertIfDisposed, throwIfDisposed} from '../../util/decorators.js';
import {DisposableStack, disposeSymbol} from '../../util/disposable.js';
import type {AddPreloadScriptOptions} from './Browser.js';
import {Navigation} from './Navigation.js';
import {WindowRealm} from './Realm.js';
import {Request} from './Request.js';
import type {UserContext} from './UserContext.js';
import {UserPrompt} from './UserPrompt.js';
/**
* @internal
*/
export type CaptureScreenshotOptions = Omit<
Bidi.BrowsingContext.CaptureScreenshotParameters,
'context'
>;
/**
* @internal
*/
export type ReloadOptions = Omit<
Bidi.BrowsingContext.ReloadParameters,
'context'
>;
/**
* @internal
*/
export type PrintOptions = Omit<
Bidi.BrowsingContext.PrintParameters,
'context'
>;
/**
* @internal
*/
export type HandleUserPromptOptions = Omit<
Bidi.BrowsingContext.HandleUserPromptParameters,
'context'
>;
/**
* @internal
*/
export type SetViewportOptions = Omit<
Bidi.BrowsingContext.SetViewportParameters,
'context'
>;
/**
* @internal
*/
export class BrowsingContext extends EventEmitter<{
/** Emitted when this context is closed. */
closed: {
/** The reason the browsing context was closed */
reason: string;
};
/** Emitted when a child browsing context is created. */
browsingcontext: {
/** The newly created child browsing context. */
browsingContext: BrowsingContext;
};
/** Emitted whenever a navigation occurs. */
navigation: {
/** The navigation that occurred. */
navigation: Navigation;
};
/** Emitted whenever a request is made. */
request: {
/** The request that was made. */
request: Request;
};
/** Emitted whenever a log entry is added. */
log: {
/** Entry added to the log. */
entry: Bidi.Log.Entry;
};
/** Emitted whenever a prompt is opened. */
userprompt: {
/** The prompt that was opened. */
userPrompt: UserPrompt;
};
/** Emitted whenever the frame emits `DOMContentLoaded` */
DOMContentLoaded: void;
/** Emitted whenever the frame emits `load` */
load: void;
}> {
static from(
userContext: UserContext,
parent: BrowsingContext | undefined,
id: string,
url: string
): BrowsingContext {
const browsingContext = new BrowsingContext(userContext, parent, id, url);
browsingContext.#initialize();
return browsingContext;
}
// keep-sorted start
#navigation: Navigation | undefined;
#reason?: string;
#url: string;
readonly #children = new Map<string, BrowsingContext>();
readonly #disposables = new DisposableStack();
readonly #realms = new Map<string, WindowRealm>();
readonly #requests = new Map<string, Request>();
readonly defaultRealm: WindowRealm;
readonly id: string;
readonly parent: BrowsingContext | undefined;
readonly userContext: UserContext;
// keep-sorted end
private constructor(
context: UserContext,
parent: BrowsingContext | undefined,
id: string,
url: string
) {
super();
// keep-sorted start
this.#url = url;
this.id = id;
this.parent = parent;
this.userContext = context;
// keep-sorted end
this.defaultRealm = WindowRealm.from(this);
}
#initialize() {
const userContextEmitter = this.#disposables.use(
new EventEmitter(this.userContext)
);
userContextEmitter.once('closed', ({reason}) => {
this.dispose(`Browsing context already closed: ${reason}`);
});
const sessionEmitter = this.#disposables.use(
new EventEmitter(this.#session)
);
sessionEmitter.on('browsingContext.contextCreated', info => {
if (info.parent !== this.id) {
return;
}
const browsingContext = BrowsingContext.from(
this.userContext,
this,
info.context,
info.url
);
this.#children.set(info.context, browsingContext);
const browsingContextEmitter = this.#disposables.use(
new EventEmitter(browsingContext)
);
browsingContextEmitter.once('closed', () => {
browsingContextEmitter.removeAllListeners();
this.#children.delete(browsingContext.id);
});
this.emit('browsingcontext', {browsingContext});
});
sessionEmitter.on('browsingContext.contextDestroyed', info => {
if (info.context !== this.id) {
return;
}
this.dispose('Browsing context already closed.');
});
sessionEmitter.on('browsingContext.domContentLoaded', info => {
if (info.context !== this.id) {
return;
}
this.#url = info.url;
this.emit('DOMContentLoaded', undefined);
});
sessionEmitter.on('browsingContext.load', info => {
if (info.context !== this.id) {
return;
}
this.#url = info.url;
this.emit('load', undefined);
});
sessionEmitter.on('browsingContext.navigationStarted', info => {
if (info.context !== this.id) {
return;
}
this.#url = info.url;
this.#requests.clear();
// Note the navigation ID is null for this event.
this.#navigation = Navigation.from(this);
const navigationEmitter = this.#disposables.use(
new EventEmitter(this.#navigation)
);
for (const eventName of ['fragment', 'failed', 'aborted'] as const) {
navigationEmitter.once(eventName, ({url}) => {
navigationEmitter[disposeSymbol]();
this.#url = url;
});
}
this.emit('navigation', {navigation: this.#navigation});
});
sessionEmitter.on('network.beforeRequestSent', event => {
if (event.context !== this.id) {
return;
}
if (this.#requests.has(event.request.request)) {
return;
}
const request = Request.from(this, event);
this.#requests.set(request.id, request);
this.emit('request', {request});
});
sessionEmitter.on('log.entryAdded', entry => {
if (entry.source.context !== this.id) {
return;
}
this.emit('log', {entry});
});
sessionEmitter.on('browsingContext.userPromptOpened', info => {
if (info.context !== this.id) {
return;
}
const userPrompt = UserPrompt.from(this, info);
this.emit('userprompt', {userPrompt});
});
}
// keep-sorted start block=yes
get #session() {
return this.userContext.browser.session;
}
get children(): Iterable<BrowsingContext> {
return this.#children.values();
}
get closed(): boolean {
return this.#reason !== undefined;
}
get disposed(): boolean {
return this.closed;
}
get realms(): Iterable<WindowRealm> {
return this.#realms.values();
}
get top(): BrowsingContext {
let context = this as BrowsingContext;
for (let {parent} = context; parent; {parent} = context) {
context = parent;
}
return context;
}
get url(): string {
return this.#url;
}
// keep-sorted end
@inertIfDisposed
private dispose(reason?: string): void {
this.#reason = reason;
this[disposeSymbol]();
}
@throwIfDisposed<BrowsingContext>(context => {
// SAFETY: Disposal implies this exists.
return context.#reason!;
})
async activate(): Promise<void> {
await this.#session.send('browsingContext.activate', {
context: this.id,
});
}
@throwIfDisposed<BrowsingContext>(context => {
// SAFETY: Disposal implies this exists.
return context.#reason!;
})
async captureScreenshot(
options: CaptureScreenshotOptions = {}
): Promise<string> {
const {
result: {data},
} = await this.#session.send('browsingContext.captureScreenshot', {
context: this.id,
...options,
});
return data;
}
@throwIfDisposed<BrowsingContext>(context => {
// SAFETY: Disposal implies this exists.
return context.#reason!;
})
async close(promptUnload?: boolean): Promise<void> {
await Promise.all(
[...this.#children.values()].map(async child => {
await child.close(promptUnload);
})
);
await this.#session.send('browsingContext.close', {
context: this.id,
promptUnload,
});
}
@throwIfDisposed<BrowsingContext>(context => {
// SAFETY: Disposal implies this exists.
return context.#reason!;
})
async traverseHistory(delta: number): Promise<void> {
await this.#session.send('browsingContext.traverseHistory', {
context: this.id,
delta,
});
}
@throwIfDisposed<BrowsingContext>(context => {
// SAFETY: Disposal implies this exists.
return context.#reason!;
})
async navigate(
url: string,
wait?: Bidi.BrowsingContext.ReadinessState
): Promise<Navigation> {
await this.#session.send('browsingContext.navigate', {
context: this.id,
url,
wait,
});
return await new Promise(resolve => {
this.once('navigation', ({navigation}) => {
resolve(navigation);
});
});
}
@throwIfDisposed<BrowsingContext>(context => {
// SAFETY: Disposal implies this exists.
return context.#reason!;
})
async reload(options: ReloadOptions = {}): Promise<Navigation> {
await this.#session.send('browsingContext.reload', {
context: this.id,
...options,
});
return await new Promise(resolve => {
this.once('navigation', ({navigation}) => {
resolve(navigation);
});
});
}
@throwIfDisposed<BrowsingContext>(context => {
// SAFETY: Disposal implies this exists.
return context.#reason!;
})
async print(options: PrintOptions = {}): Promise<string> {
const {
result: {data},
} = await this.#session.send('browsingContext.print', {
context: this.id,
...options,
});
return data;
}
@throwIfDisposed<BrowsingContext>(context => {
// SAFETY: Disposal implies this exists.
return context.#reason!;
})
async handleUserPrompt(options: HandleUserPromptOptions = {}): Promise<void> {
await this.#session.send('browsingContext.handleUserPrompt', {
context: this.id,
...options,
});
}
@throwIfDisposed<BrowsingContext>(context => {
// SAFETY: Disposal implies this exists.
return context.#reason!;
})
async setViewport(options: SetViewportOptions = {}): Promise<void> {
await this.#session.send('browsingContext.setViewport', {
context: this.id,
...options,
});
}
@throwIfDisposed<BrowsingContext>(context => {
// SAFETY: Disposal implies this exists.
return context.#reason!;
})
async performActions(actions: Bidi.Input.SourceActions[]): Promise<void> {
await this.#session.send('input.performActions', {
context: this.id,
actions,
});
}
@throwIfDisposed<BrowsingContext>(context => {
// SAFETY: Disposal implies this exists.
return context.#reason!;
})
async releaseActions(): Promise<void> {
await this.#session.send('input.releaseActions', {
context: this.id,
});
}
@throwIfDisposed<BrowsingContext>(context => {
// SAFETY: Disposal implies this exists.
return context.#reason!;
})
createWindowRealm(sandbox: string): WindowRealm {
return WindowRealm.from(this, sandbox);
}
@throwIfDisposed<BrowsingContext>(context => {
// SAFETY: Disposal implies this exists.
return context.#reason!;
})
async addPreloadScript(
functionDeclaration: string,
options: AddPreloadScriptOptions = {}
): Promise<string> {
return await this.userContext.browser.addPreloadScript(
functionDeclaration,
{
...options,
contexts: [this, ...(options.contexts ?? [])],
}
);
}
@throwIfDisposed<BrowsingContext>(context => {
// SAFETY: Disposal implies this exists.
return context.#reason!;
})
async removePreloadScript(script: string): Promise<void> {
await this.userContext.browser.removePreloadScript(script);
}
[disposeSymbol](): void {
this.#reason ??=
'Browsing context already closed, probably because the user context closed.';
this.emit('closed', {reason: this.#reason});
this.#disposables.dispose();
super[disposeSymbol]();
}
}
+154
View File
@@ -0,0 +1,154 @@
/**
* @license
* Copyright 2024 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
import type {EventEmitter} from '../../common/EventEmitter.js';
/**
* @internal
*/
export interface Commands {
'script.evaluate': {
params: Bidi.Script.EvaluateParameters;
returnType: Bidi.Script.EvaluateResult;
};
'script.callFunction': {
params: Bidi.Script.CallFunctionParameters;
returnType: Bidi.Script.EvaluateResult;
};
'script.disown': {
params: Bidi.Script.DisownParameters;
returnType: Bidi.EmptyResult;
};
'script.addPreloadScript': {
params: Bidi.Script.AddPreloadScriptParameters;
returnType: Bidi.Script.AddPreloadScriptResult;
};
'script.removePreloadScript': {
params: Bidi.Script.RemovePreloadScriptParameters;
returnType: Bidi.EmptyResult;
};
'browser.close': {
params: Bidi.EmptyParams;
returnType: Bidi.EmptyResult;
};
'browser.createUserContext': {
params: Bidi.EmptyParams;
returnType: Bidi.Browser.CreateUserContextResult;
};
'browser.getUserContexts': {
params: Bidi.EmptyParams;
returnType: Bidi.Browser.GetUserContextsResult;
};
'browser.removeUserContext': {
params: {
userContext: Bidi.Browser.UserContext;
};
returnType: Bidi.Browser.RemoveUserContext;
};
'browsingContext.activate': {
params: Bidi.BrowsingContext.ActivateParameters;
returnType: Bidi.EmptyResult;
};
'browsingContext.create': {
params: Bidi.BrowsingContext.CreateParameters;
returnType: Bidi.BrowsingContext.CreateResult;
};
'browsingContext.close': {
params: Bidi.BrowsingContext.CloseParameters;
returnType: Bidi.EmptyResult;
};
'browsingContext.getTree': {
params: Bidi.BrowsingContext.GetTreeParameters;
returnType: Bidi.BrowsingContext.GetTreeResult;
};
'browsingContext.navigate': {
params: Bidi.BrowsingContext.NavigateParameters;
returnType: Bidi.BrowsingContext.NavigateResult;
};
'browsingContext.reload': {
params: Bidi.BrowsingContext.ReloadParameters;
returnType: Bidi.BrowsingContext.NavigateResult;
};
'browsingContext.print': {
params: Bidi.BrowsingContext.PrintParameters;
returnType: Bidi.BrowsingContext.PrintResult;
};
'browsingContext.captureScreenshot': {
params: Bidi.BrowsingContext.CaptureScreenshotParameters;
returnType: Bidi.BrowsingContext.CaptureScreenshotResult;
};
'browsingContext.handleUserPrompt': {
params: Bidi.BrowsingContext.HandleUserPromptParameters;
returnType: Bidi.EmptyResult;
};
'browsingContext.setViewport': {
params: Bidi.BrowsingContext.SetViewportParameters;
returnType: Bidi.EmptyResult;
};
'browsingContext.traverseHistory': {
params: Bidi.BrowsingContext.TraverseHistoryParameters;
returnType: Bidi.EmptyResult;
};
'input.performActions': {
params: Bidi.Input.PerformActionsParameters;
returnType: Bidi.EmptyResult;
};
'input.releaseActions': {
params: Bidi.Input.ReleaseActionsParameters;
returnType: Bidi.EmptyResult;
};
'session.end': {
params: Bidi.EmptyParams;
returnType: Bidi.EmptyResult;
};
'session.new': {
params: Bidi.Session.NewParameters;
returnType: Bidi.Session.NewResult;
};
'session.status': {
params: object;
returnType: Bidi.Session.StatusResult;
};
'session.subscribe': {
params: Bidi.Session.SubscriptionRequest;
returnType: Bidi.EmptyResult;
};
'session.unsubscribe': {
params: Bidi.Session.SubscriptionRequest;
returnType: Bidi.EmptyResult;
};
}
/**
* @internal
*/
export type BidiEvents = {
[K in Bidi.ChromiumBidi.Event['method']]: Extract<
Bidi.ChromiumBidi.Event,
{method: K}
>['params'];
};
/**
* @internal
*/
export interface Connection<Events extends BidiEvents = BidiEvents>
extends EventEmitter<Events> {
send<T extends keyof Commands>(
method: T,
params: Commands[T]['params']
): Promise<{result: Commands[T]['returnType']}>;
// This will pipe events into the provided emitter.
pipeTo<Events extends BidiEvents>(emitter: EventEmitter<Events>): void;
}
+144
View File
@@ -0,0 +1,144 @@
/**
* @license
* Copyright 2024 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import {EventEmitter} from '../../common/EventEmitter.js';
import {inertIfDisposed} from '../../util/decorators.js';
import {Deferred} from '../../util/Deferred.js';
import {DisposableStack, disposeSymbol} from '../../util/disposable.js';
import type {BrowsingContext} from './BrowsingContext.js';
import type {Request} from './Request.js';
/**
* @internal
*/
export interface NavigationInfo {
url: string;
timestamp: Date;
}
/**
* @internal
*/
export class Navigation extends EventEmitter<{
/** Emitted when navigation has a request associated with it. */
request: Request;
/** Emitted when fragment navigation occurred. */
fragment: NavigationInfo;
/** Emitted when navigation failed. */
failed: NavigationInfo;
/** Emitted when navigation was aborted. */
aborted: NavigationInfo;
}> {
static from(context: BrowsingContext): Navigation {
const navigation = new Navigation(context);
navigation.#initialize();
return navigation;
}
// keep-sorted start
#request: Request | undefined;
readonly #browsingContext: BrowsingContext;
readonly #disposables = new DisposableStack();
readonly #id = new Deferred<string>();
// keep-sorted end
private constructor(context: BrowsingContext) {
super();
// keep-sorted start
this.#browsingContext = context;
// keep-sorted end
}
#initialize() {
const browsingContextEmitter = this.#disposables.use(
new EventEmitter(this.#browsingContext)
);
browsingContextEmitter.once('closed', () => {
this.emit('failed', {
url: this.#browsingContext.url,
timestamp: new Date(),
});
this.dispose();
});
this.#browsingContext.on('request', ({request}) => {
if (request.navigation === this.#id.value()) {
this.#request = request;
this.emit('request', request);
}
});
const sessionEmitter = this.#disposables.use(
new EventEmitter(this.#session)
);
// To get the navigation ID if any.
for (const eventName of [
'browsingContext.domContentLoaded',
'browsingContext.load',
] as const) {
sessionEmitter.on(eventName, info => {
if (info.context !== this.#browsingContext.id) {
return;
}
if (!info.navigation) {
return;
}
if (!this.#id.resolved()) {
this.#id.resolve(info.navigation);
}
});
}
for (const [eventName, event] of [
['browsingContext.fragmentNavigated', 'fragment'],
['browsingContext.navigationFailed', 'failed'],
['browsingContext.navigationAborted', 'aborted'],
] as const) {
sessionEmitter.on(eventName, info => {
if (info.context !== this.#browsingContext.id) {
return;
}
if (!info.navigation) {
return;
}
if (!this.#id.resolved()) {
this.#id.resolve(info.navigation);
}
if (this.#id.value() !== info.navigation) {
return;
}
this.emit(event, {
url: info.url,
timestamp: new Date(info.timestamp),
});
this.dispose();
});
}
}
// keep-sorted start block=yes
get #session() {
return this.#browsingContext.userContext.browser.session;
}
get disposed(): boolean {
return this.#disposables.disposed;
}
get request(): Request | undefined {
return this.#request;
}
// keep-sorted end
@inertIfDisposed
private dispose(): void {
this[disposeSymbol]();
}
[disposeSymbol](): void {
this.#disposables.dispose();
super[disposeSymbol]();
}
}
+351
View File
@@ -0,0 +1,351 @@
/**
* @license
* Copyright 2024 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
import {EventEmitter} from '../../common/EventEmitter.js';
import {inertIfDisposed, throwIfDisposed} from '../../util/decorators.js';
import {DisposableStack, disposeSymbol} from '../../util/disposable.js';
import type {BrowsingContext} from './BrowsingContext.js';
import type {Session} from './Session.js';
/**
* @internal
*/
export type CallFunctionOptions = Omit<
Bidi.Script.CallFunctionParameters,
'functionDeclaration' | 'awaitPromise' | 'target'
>;
/**
* @internal
*/
export type EvaluateOptions = Omit<
Bidi.Script.EvaluateParameters,
'expression' | 'awaitPromise' | 'target'
>;
/**
* @internal
*/
export abstract class Realm extends EventEmitter<{
/** Emitted when the realm is destroyed. */
destroyed: {reason: string};
/** Emitted when a dedicated worker is created in the realm. */
worker: DedicatedWorkerRealm;
/** Emitted when a shared worker is created in the realm. */
sharedworker: SharedWorkerRealm;
}> {
// keep-sorted start
#reason?: string;
protected readonly disposables = new DisposableStack();
readonly id: string;
readonly origin: string;
// keep-sorted end
protected constructor(id: string, origin: string) {
super();
// keep-sorted start
this.id = id;
this.origin = origin;
// keep-sorted end
}
protected initialize(): void {
const sessionEmitter = this.disposables.use(new EventEmitter(this.session));
sessionEmitter.on('script.realmDestroyed', info => {
if (info.realm !== this.id) {
return;
}
this.dispose('Realm already destroyed.');
});
}
// keep-sorted start block=yes
get disposed(): boolean {
return this.#reason !== undefined;
}
protected abstract get session(): Session;
protected get target(): Bidi.Script.Target {
return {realm: this.id};
}
// keep-sorted end
@inertIfDisposed
protected dispose(reason?: string): void {
this.#reason = reason;
this[disposeSymbol]();
}
@throwIfDisposed<Realm>(realm => {
// SAFETY: Disposal implies this exists.
return realm.#reason!;
})
async disown(handles: string[]): Promise<void> {
await this.session.send('script.disown', {
target: this.target,
handles,
});
}
@throwIfDisposed<Realm>(realm => {
// SAFETY: Disposal implies this exists.
return realm.#reason!;
})
async callFunction(
functionDeclaration: string,
awaitPromise: boolean,
options: CallFunctionOptions = {}
): Promise<Bidi.Script.EvaluateResult> {
const {result} = await this.session.send('script.callFunction', {
functionDeclaration,
awaitPromise,
target: this.target,
...options,
});
return result;
}
@throwIfDisposed<Realm>(realm => {
// SAFETY: Disposal implies this exists.
return realm.#reason!;
})
async evaluate(
expression: string,
awaitPromise: boolean,
options: EvaluateOptions = {}
): Promise<Bidi.Script.EvaluateResult> {
const {result} = await this.session.send('script.evaluate', {
expression,
awaitPromise,
target: this.target,
...options,
});
return result;
}
[disposeSymbol](): void {
this.#reason ??=
'Realm already destroyed, probably because all associated browsing contexts closed.';
this.emit('destroyed', {reason: this.#reason});
this.disposables.dispose();
super[disposeSymbol]();
}
}
/**
* @internal
*/
export class WindowRealm extends Realm {
static from(context: BrowsingContext, sandbox?: string): WindowRealm {
const realm = new WindowRealm(context, sandbox);
realm.initialize();
return realm;
}
// keep-sorted start
readonly browsingContext: BrowsingContext;
readonly sandbox?: string;
// keep-sorted end
readonly #workers: {
dedicated: Map<string, DedicatedWorkerRealm>;
shared: Map<string, SharedWorkerRealm>;
} = {
dedicated: new Map(),
shared: new Map(),
};
private constructor(context: BrowsingContext, sandbox?: string) {
super('', '');
// keep-sorted start
this.browsingContext = context;
this.sandbox = sandbox;
// keep-sorted end
}
override initialize(): void {
super.initialize();
const sessionEmitter = this.disposables.use(new EventEmitter(this.session));
sessionEmitter.on('script.realmCreated', info => {
if (info.type !== 'window') {
return;
}
(this as any).id = info.realm;
(this as any).origin = info.origin;
});
sessionEmitter.on('script.realmCreated', info => {
if (info.type !== 'dedicated-worker') {
return;
}
if (!info.owners.includes(this.id)) {
return;
}
const realm = DedicatedWorkerRealm.from(this, info.realm, info.origin);
this.#workers.dedicated.set(realm.id, realm);
const realmEmitter = this.disposables.use(new EventEmitter(realm));
realmEmitter.once('destroyed', () => {
realmEmitter.removeAllListeners();
this.#workers.dedicated.delete(realm.id);
});
this.emit('worker', realm);
});
this.browsingContext.userContext.browser.on('sharedworker', ({realm}) => {
if (!realm.owners.has(this)) {
return;
}
this.#workers.shared.set(realm.id, realm);
const realmEmitter = this.disposables.use(new EventEmitter(realm));
realmEmitter.once('destroyed', () => {
realmEmitter.removeAllListeners();
this.#workers.shared.delete(realm.id);
});
this.emit('sharedworker', realm);
});
}
override get session(): Session {
return this.browsingContext.userContext.browser.session;
}
override get target(): Bidi.Script.Target {
return {context: this.browsingContext.id, sandbox: this.sandbox};
}
}
/**
* @internal
*/
export type DedicatedWorkerOwnerRealm =
| DedicatedWorkerRealm
| SharedWorkerRealm
| WindowRealm;
/**
* @internal
*/
export class DedicatedWorkerRealm extends Realm {
static from(
owner: DedicatedWorkerOwnerRealm,
id: string,
origin: string
): DedicatedWorkerRealm {
const realm = new DedicatedWorkerRealm(owner, id, origin);
realm.initialize();
return realm;
}
// keep-sorted start
readonly #workers = new Map<string, DedicatedWorkerRealm>();
readonly owners: Set<DedicatedWorkerOwnerRealm>;
// keep-sorted end
private constructor(
owner: DedicatedWorkerOwnerRealm,
id: string,
origin: string
) {
super(id, origin);
this.owners = new Set([owner]);
}
override initialize(): void {
super.initialize();
const sessionEmitter = this.disposables.use(new EventEmitter(this.session));
sessionEmitter.on('script.realmCreated', info => {
if (info.type !== 'dedicated-worker') {
return;
}
if (!info.owners.includes(this.id)) {
return;
}
const realm = DedicatedWorkerRealm.from(this, info.realm, info.origin);
this.#workers.set(realm.id, realm);
const realmEmitter = this.disposables.use(new EventEmitter(realm));
realmEmitter.once('destroyed', () => {
this.#workers.delete(realm.id);
});
this.emit('worker', realm);
});
}
override get session(): Session {
// SAFETY: At least one owner will exist.
return this.owners.values().next().value.session;
}
}
/**
* @internal
*/
export class SharedWorkerRealm extends Realm {
static from(
owners: [WindowRealm, ...WindowRealm[]],
id: string,
origin: string
): SharedWorkerRealm {
const realm = new SharedWorkerRealm(owners, id, origin);
realm.initialize();
return realm;
}
// keep-sorted start
readonly #workers = new Map<string, DedicatedWorkerRealm>();
readonly owners: Set<WindowRealm>;
// keep-sorted end
private constructor(
owners: [WindowRealm, ...WindowRealm[]],
id: string,
origin: string
) {
super(id, origin);
this.owners = new Set(owners);
}
override initialize(): void {
super.initialize();
const sessionEmitter = this.disposables.use(new EventEmitter(this.session));
sessionEmitter.on('script.realmCreated', info => {
if (info.type !== 'dedicated-worker') {
return;
}
if (!info.owners.includes(this.id)) {
return;
}
const realm = DedicatedWorkerRealm.from(this, info.realm, info.origin);
this.#workers.set(realm.id, realm);
const realmEmitter = this.disposables.use(new EventEmitter(realm));
realmEmitter.once('destroyed', () => {
this.#workers.delete(realm.id);
});
this.emit('worker', realm);
});
}
override get session(): Session {
// SAFETY: At least one owner will exist.
return this.owners.values().next().value.session;
}
}
+148
View File
@@ -0,0 +1,148 @@
/**
* @license
* Copyright 2024 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
import {EventEmitter} from '../../common/EventEmitter.js';
import {inertIfDisposed} from '../../util/decorators.js';
import {DisposableStack, disposeSymbol} from '../../util/disposable.js';
import type {BrowsingContext} from './BrowsingContext.js';
/**
* @internal
*/
export class Request extends EventEmitter<{
/** Emitted when the request is redirected. */
redirect: Request;
/** Emitted when the request succeeds. */
success: Bidi.Network.ResponseData;
/** Emitted when the request fails. */
error: string;
}> {
static from(
browsingContext: BrowsingContext,
event: Bidi.Network.BeforeRequestSentParameters
): Request {
const request = new Request(browsingContext, event);
request.#initialize();
return request;
}
// keep-sorted start
#error?: string;
#redirect?: Request;
#response?: Bidi.Network.ResponseData;
readonly #browsingContext: BrowsingContext;
readonly #disposables = new DisposableStack();
readonly #event: Bidi.Network.BeforeRequestSentParameters;
// keep-sorted end
private constructor(
browsingContext: BrowsingContext,
event: Bidi.Network.BeforeRequestSentParameters
) {
super();
// keep-sorted start
this.#browsingContext = browsingContext;
this.#event = event;
// keep-sorted end
}
#initialize() {
const browsingContextEmitter = this.#disposables.use(
new EventEmitter(this.#browsingContext)
);
browsingContextEmitter.once('closed', ({reason}) => {
this.#error = reason;
this.emit('error', this.#error);
this.dispose();
});
const sessionEmitter = this.#disposables.use(
new EventEmitter(this.#session)
);
sessionEmitter.on('network.beforeRequestSent', event => {
if (event.context !== this.#browsingContext.id) {
return;
}
if (event.request.request !== this.id) {
return;
}
this.#redirect = Request.from(this.#browsingContext, event);
this.emit('redirect', this.#redirect);
this.dispose();
});
sessionEmitter.on('network.fetchError', event => {
if (event.context !== this.#browsingContext.id) {
return;
}
if (event.request.request !== this.id) {
return;
}
this.#error = event.errorText;
this.emit('error', this.#error);
this.dispose();
});
sessionEmitter.on('network.responseCompleted', event => {
if (event.context !== this.#browsingContext.id) {
return;
}
if (event.request.request !== this.id) {
return;
}
this.#response = event.response;
this.emit('success', this.#response);
this.dispose();
});
}
// keep-sorted start block=yes
get #session() {
return this.#browsingContext.userContext.browser.session;
}
get disposed(): boolean {
return this.#disposables.disposed;
}
get error(): string | undefined {
return this.#error;
}
get headers(): Bidi.Network.Header[] {
return this.#event.request.headers;
}
get id(): string {
return this.#event.request.request;
}
get initiator(): Bidi.Network.Initiator {
return this.#event.initiator;
}
get method(): string {
return this.#event.request.method;
}
get navigation(): string | undefined {
return this.#event.navigation ?? undefined;
}
get redirect(): Request | undefined {
return this.redirect;
}
get response(): Bidi.Network.ResponseData | undefined {
return this.#response;
}
get url(): string {
return this.#event.request.url;
}
// keep-sorted end
@inertIfDisposed
private dispose(): void {
this[disposeSymbol]();
}
[disposeSymbol](): void {
this.#disposables.dispose();
super[disposeSymbol]();
}
}
+180
View File
@@ -0,0 +1,180 @@
/**
* @license
* Copyright 2024 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
import {EventEmitter} from '../../common/EventEmitter.js';
import {debugError} from '../../common/util.js';
import {inertIfDisposed, throwIfDisposed} from '../../util/decorators.js';
import {DisposableStack, disposeSymbol} from '../../util/disposable.js';
import {Browser} from './Browser.js';
import type {BidiEvents, Commands, Connection} from './Connection.js';
// TODO: Once Chrome supports session.status properly, uncomment this block.
// const MAX_RETRIES = 5;
/**
* @internal
*/
export class Session
extends EventEmitter<BidiEvents & {ended: {reason: string}}>
implements Connection<BidiEvents & {ended: {reason: string}}>
{
static async from(
connection: Connection,
capabilities: Bidi.Session.CapabilitiesRequest
): Promise<Session> {
// Wait until the session is ready.
//
// TODO: Once Chrome supports session.status properly, uncomment this block
// and remove `getBiDiConnection` in BrowserConnector.
// let status = {message: '', ready: false};
// for (let i = 0; i < MAX_RETRIES; ++i) {
// status = (await connection.send('session.status', {})).result;
// if (status.ready) {
// break;
// }
// // Backoff a little bit each time.
// await new Promise(resolve => {
// return setTimeout(resolve, (1 << i) * 100);
// });
// }
// if (!status.ready) {
// throw new Error(status.message);
// }
let result;
try {
result = (
await connection.send('session.new', {
capabilities,
})
).result;
} catch (err) {
// Chrome does not support session.new.
debugError(err);
result = {
sessionId: '',
capabilities: {
acceptInsecureCerts: false,
browserName: '',
browserVersion: '',
platformName: '',
setWindowRect: false,
webSocketUrl: '',
},
};
}
const session = new Session(connection, result);
await session.#initialize();
return session;
}
// keep-sorted start
#reason: string | undefined;
readonly #disposables = new DisposableStack();
readonly #info: Bidi.Session.NewResult;
readonly browser!: Browser;
readonly connection: Connection;
// keep-sorted end
private constructor(connection: Connection, info: Bidi.Session.NewResult) {
super();
// keep-sorted start
this.#info = info;
this.connection = connection;
// keep-sorted end
}
async #initialize(): Promise<void> {
this.connection.pipeTo(this);
// SAFETY: We use `any` to allow assignment of the readonly property.
(this as any).browser = await Browser.from(this);
const browserEmitter = this.#disposables.use(this.browser);
browserEmitter.once('closed', ({reason}) => {
this.dispose(reason);
});
}
// keep-sorted start block=yes
get capabilities(): Bidi.Session.NewResult['capabilities'] {
return this.#info.capabilities;
}
get disposed(): boolean {
return this.ended;
}
get ended(): boolean {
return this.#reason !== undefined;
}
get id(): string {
return this.#info.sessionId;
}
// keep-sorted end
@inertIfDisposed
private dispose(reason?: string): void {
this.#reason = reason;
this[disposeSymbol]();
}
pipeTo<Events extends BidiEvents>(emitter: EventEmitter<Events>): void {
this.connection.pipeTo(emitter);
}
/**
* Currently, there is a 1:1 relationship between the session and the
* session. In the future, we might support multiple sessions and in that
* case we always needs to make sure that the session for the right session
* object is used, so we implement this method here, although it's not defined
* in the spec.
*/
@throwIfDisposed<Session>(session => {
// SAFETY: By definition of `disposed`, `#reason` is defined.
return session.#reason!;
})
async send<T extends keyof Commands>(
method: T,
params: Commands[T]['params']
): Promise<{result: Commands[T]['returnType']}> {
return await this.connection.send(method, params);
}
@throwIfDisposed<Session>(session => {
// SAFETY: By definition of `disposed`, `#reason` is defined.
return session.#reason!;
})
async subscribe(events: string[]): Promise<void> {
await this.send('session.subscribe', {
events,
});
}
@throwIfDisposed<Session>(session => {
// SAFETY: By definition of `disposed`, `#reason` is defined.
return session.#reason!;
})
async end(): Promise<void> {
try {
await this.send('session.end', {});
} finally {
this.dispose(`Session already ended.`);
}
}
[disposeSymbol](): void {
this.#reason ??=
'Session already destroyed, probably because the connection broke.';
this.emit('ended', {reason: this.#reason});
this.#disposables.dispose();
super[disposeSymbol]();
}
}
+185
View File
@@ -0,0 +1,185 @@
/**
* @license
* Copyright 2024 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
import {EventEmitter} from '../../common/EventEmitter.js';
import {assert} from '../../util/assert.js';
import {inertIfDisposed, throwIfDisposed} from '../../util/decorators.js';
import {DisposableStack, disposeSymbol} from '../../util/disposable.js';
import type {Browser} from './Browser.js';
import {BrowsingContext} from './BrowsingContext.js';
/**
* @internal
*/
export type CreateBrowsingContextOptions = Omit<
Bidi.BrowsingContext.CreateParameters,
'type' | 'referenceContext'
> & {
referenceContext?: BrowsingContext;
};
/**
* @internal
*/
export class UserContext extends EventEmitter<{
/**
* Emitted when a new browsing context is created.
*/
browsingcontext: {
/** The new browsing context. */
browsingContext: BrowsingContext;
};
/**
* Emitted when the user context is closed.
*/
closed: {
/** The reason the user context was closed. */
reason: string;
};
}> {
static DEFAULT = 'default';
static create(browser: Browser, id: string): UserContext {
const context = new UserContext(browser, id);
context.#initialize();
return context;
}
// keep-sorted start
#reason?: string;
// Note these are only top-level contexts.
readonly #browsingContexts = new Map<string, BrowsingContext>();
readonly #disposables = new DisposableStack();
readonly #id: string;
readonly browser: Browser;
// keep-sorted end
private constructor(browser: Browser, id: string) {
super();
// keep-sorted start
this.#id = id;
this.browser = browser;
// keep-sorted end
}
#initialize() {
const browserEmitter = this.#disposables.use(
new EventEmitter(this.browser)
);
browserEmitter.once('closed', ({reason}) => {
this.dispose(`User context already closed: ${reason}`);
});
const sessionEmitter = this.#disposables.use(
new EventEmitter(this.#session)
);
sessionEmitter.on('browsingContext.contextCreated', info => {
if (info.parent) {
return;
}
if (info.userContext !== this.#id) {
return;
}
const browsingContext = BrowsingContext.from(
this,
undefined,
info.context,
info.url
);
this.#browsingContexts.set(browsingContext.id, browsingContext);
const browsingContextEmitter = this.#disposables.use(
new EventEmitter(browsingContext)
);
browsingContextEmitter.on('closed', () => {
browsingContextEmitter.removeAllListeners();
this.#browsingContexts.delete(browsingContext.id);
});
this.emit('browsingcontext', {browsingContext});
});
}
// keep-sorted start block=yes
get #session() {
return this.browser.session;
}
get browsingContexts(): Iterable<BrowsingContext> {
return this.#browsingContexts.values();
}
get closed(): boolean {
return this.#reason !== undefined;
}
get disposed(): boolean {
return this.closed;
}
get id(): string {
return this.#id;
}
// keep-sorted end
@inertIfDisposed
private dispose(reason?: string): void {
this.#reason = reason;
this[disposeSymbol]();
}
@throwIfDisposed<UserContext>(context => {
// SAFETY: Disposal implies this exists.
return context.#reason!;
})
async createBrowsingContext(
type: Bidi.BrowsingContext.CreateType,
options: CreateBrowsingContextOptions = {}
): Promise<BrowsingContext> {
const {
result: {context: contextId},
} = await this.#session.send('browsingContext.create', {
type,
...options,
referenceContext: options.referenceContext?.id,
userContext: this.#id,
});
const browsingContext = this.#browsingContexts.get(contextId);
assert(
browsingContext,
'The WebDriver BiDi implementation is failing to create a browsing context correctly.'
);
// We use an array to avoid the promise from being awaited.
return browsingContext;
}
@throwIfDisposed<UserContext>(context => {
// SAFETY: Disposal implies this exists.
return context.#reason!;
})
async remove(): Promise<void> {
try {
await this.#session.send('browser.removeUserContext', {
userContext: this.#id,
});
} finally {
this.dispose('User context already closed.');
}
}
[disposeSymbol](): void {
this.#reason ??=
'User context already closed, probably because the browser disconnected/closed.';
this.emit('closed', {reason: this.#reason});
this.#disposables.dispose();
super[disposeSymbol]();
}
}
+137
View File
@@ -0,0 +1,137 @@
/**
* @license
* Copyright 2024 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
import {EventEmitter} from '../../common/EventEmitter.js';
import {inertIfDisposed, throwIfDisposed} from '../../util/decorators.js';
import {DisposableStack, disposeSymbol} from '../../util/disposable.js';
import type {BrowsingContext} from './BrowsingContext.js';
/**
* @internal
*/
export type HandleOptions = Omit<
Bidi.BrowsingContext.HandleUserPromptParameters,
'context'
>;
/**
* @internal
*/
export type UserPromptResult = Omit<
Bidi.BrowsingContext.UserPromptClosedParameters,
'context'
>;
/**
* @internal
*/
export class UserPrompt extends EventEmitter<{
/** Emitted when the user prompt is handled. */
handled: UserPromptResult;
/** Emitted when the user prompt is closed. */
closed: {
/** The reason the user prompt was closed. */
reason: string;
};
}> {
static from(
browsingContext: BrowsingContext,
info: Bidi.BrowsingContext.UserPromptOpenedParameters
): UserPrompt {
const userPrompt = new UserPrompt(browsingContext, info);
userPrompt.#initialize();
return userPrompt;
}
// keep-sorted start
#reason?: string;
#result?: UserPromptResult;
readonly #disposables = new DisposableStack();
readonly browsingContext: BrowsingContext;
readonly info: Bidi.BrowsingContext.UserPromptOpenedParameters;
// keep-sorted end
private constructor(
context: BrowsingContext,
info: Bidi.BrowsingContext.UserPromptOpenedParameters
) {
super();
// keep-sorted start
this.browsingContext = context;
this.info = info;
// keep-sorted end
}
#initialize() {
const browserContextEmitter = this.#disposables.use(
new EventEmitter(this.browsingContext)
);
browserContextEmitter.once('closed', ({reason}) => {
this.dispose(`User prompt already closed: ${reason}`);
});
const sessionEmitter = this.#disposables.use(
new EventEmitter(this.#session)
);
sessionEmitter.on('browsingContext.userPromptClosed', parameters => {
if (parameters.context !== this.browsingContext.id) {
return;
}
this.#result = parameters;
this.emit('handled', parameters);
this.dispose('User prompt already handled.');
});
}
// keep-sorted start block=yes
get #session() {
return this.browsingContext.userContext.browser.session;
}
get closed(): boolean {
return this.#reason !== undefined;
}
get disposed(): boolean {
return this.closed;
}
get handled(): boolean {
return this.#result !== undefined;
}
get result(): UserPromptResult | undefined {
return this.#result;
}
// keep-sorted end
@inertIfDisposed
private dispose(reason?: string): void {
this.#reason = reason;
this[disposeSymbol]();
}
@throwIfDisposed<UserPrompt>(prompt => {
// SAFETY: Disposal implies this exists.
return prompt.#reason!;
})
async handle(options: HandleOptions = {}): Promise<UserPromptResult> {
await this.#session.send('browsingContext.handleUserPrompt', {
...options,
context: this.info.context,
});
// SAFETY: `handled` is triggered before the above promise resolved.
return this.#result!;
}
[disposeSymbol](): void {
this.#reason ??=
'User prompt already closed, probably because the associated browsing context was destroyed.';
this.emit('closed', {reason: this.#reason});
this.#disposables.dispose();
super[disposeSymbol]();
}
}
+15
View File
@@ -0,0 +1,15 @@
/**
* @license
* Copyright 2024 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
export * from './Browser.js';
export * from './BrowsingContext.js';
export * from './Connection.js';
export * from './Navigation.js';
export * from './Realm.js';
export * from './Request.js';
export * from './Session.js';
export * from './UserContext.js';
export * from './UserPrompt.js';
+119
View File
@@ -0,0 +1,119 @@
/**
* @license
* Copyright 2023 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
import type {
ObservableInput,
ObservedValueOf,
OperatorFunction,
} from '../../third_party/rxjs/rxjs.js';
import {catchError} from '../../third_party/rxjs/rxjs.js';
import type {PuppeteerLifeCycleEvent} from '../cdp/LifecycleWatcher.js';
import {ProtocolError, TimeoutError} from '../common/Errors.js';
/**
* @internal
*/
export type BiDiNetworkIdle = Extract<
PuppeteerLifeCycleEvent,
'networkidle0' | 'networkidle2'
> | null;
/**
* @internal
*/
export function getBiDiLifeCycles(
event: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[]
): [
Extract<PuppeteerLifeCycleEvent, 'load' | 'domcontentloaded'>,
BiDiNetworkIdle,
] {
if (Array.isArray(event)) {
const pageLifeCycle = event.some(lifeCycle => {
return lifeCycle !== 'domcontentloaded';
})
? 'load'
: 'domcontentloaded';
const networkLifeCycle = event.reduce((acc, lifeCycle) => {
if (lifeCycle === 'networkidle0') {
return lifeCycle;
} else if (acc !== 'networkidle0' && lifeCycle === 'networkidle2') {
return lifeCycle;
}
return acc;
}, null as BiDiNetworkIdle);
return [pageLifeCycle, networkLifeCycle];
}
if (event === 'networkidle0' || event === 'networkidle2') {
return ['load', event];
}
return [event, null];
}
/**
* @internal
*/
export const lifeCycleToReadinessState = new Map<
PuppeteerLifeCycleEvent,
Bidi.BrowsingContext.ReadinessState
>([
['load', Bidi.BrowsingContext.ReadinessState.Complete],
['domcontentloaded', Bidi.BrowsingContext.ReadinessState.Interactive],
]);
export function getBiDiReadinessState(
event: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[]
): [Bidi.BrowsingContext.ReadinessState, BiDiNetworkIdle] {
const lifeCycles = getBiDiLifeCycles(event);
const readiness = lifeCycleToReadinessState.get(lifeCycles[0])!;
return [readiness, lifeCycles[1]];
}
/**
* @internal
*/
export const lifeCycleToSubscribedEvent = new Map<
PuppeteerLifeCycleEvent,
'browsingContext.load' | 'browsingContext.domContentLoaded'
>([
['load', 'browsingContext.load'],
['domcontentloaded', 'browsingContext.domContentLoaded'],
]);
/**
* @internal
*/
export function getBiDiLifecycleEvent(
event: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[]
): [
'browsingContext.load' | 'browsingContext.domContentLoaded',
BiDiNetworkIdle,
] {
const lifeCycles = getBiDiLifeCycles(event);
const bidiEvent = lifeCycleToSubscribedEvent.get(lifeCycles[0])!;
return [bidiEvent, lifeCycles[1]];
}
/**
* @internal
*/
export function rewriteNavigationError<T, R extends ObservableInput<T>>(
message: string,
ms: number
): OperatorFunction<T, T | ObservedValueOf<R>> {
return catchError<T, R>(error => {
if (error instanceof ProtocolError) {
error.message += ` at ${message}`;
} else if (error instanceof TimeoutError) {
error.message = `Navigation timeout of ${ms} ms exceeded`;
}
throw error;
});
}
+81
View File
@@ -0,0 +1,81 @@
/**
* @license
* Copyright 2023 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
import {PuppeteerURL, debugError} from '../common/util.js';
import {BidiDeserializer} from './Deserializer.js';
import type {BidiRealm} from './Realm.js';
/**
* @internal
*/
export async function releaseReference(
client: BidiRealm,
remoteReference: Bidi.Script.RemoteReference
): Promise<void> {
if (!remoteReference.handle) {
return;
}
await client.connection
.send('script.disown', {
target: client.target,
handles: [remoteReference.handle],
})
.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 function createEvaluationError(
details: Bidi.Script.ExceptionDetails
): unknown {
if (details.exception.type !== 'error') {
return BidiDeserializer.deserialize(details.exception);
}
const [name = '', ...parts] = details.text.split(': ');
const message = parts.join(': ');
const error = new Error(message);
error.name = name;
// The first line is this function which we ignore.
const stackLines = [];
if (details.stackTrace && stackLines.length < Error.stackTraceLimit) {
for (const frame of details.stackTrace.callFrames.reverse()) {
if (
PuppeteerURL.isPuppeteerURL(frame.url) &&
frame.url !== PuppeteerURL.INTERNAL_URL
) {
const url = PuppeteerURL.parse(frame.url);
stackLines.unshift(
` at ${frame.functionName || url.functionName} (${
url.functionName
} at ${url.siteString}, <anonymous>:${frame.lineNumber}:${
frame.columnNumber
})`
);
} else {
stackLines.push(
` at ${frame.functionName || '<anonymous>'} (${frame.url}:${
frame.lineNumber
}:${frame.columnNumber})`
);
}
if (stackLines.length >= Error.stackTraceLimit) {
break;
}
}
}
error.stack = [details.text, ...stackLines].join('\n');
return error;
}
+579
View File
@@ -0,0 +1,579 @@
/**
* @license
* Copyright 2018 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type {Protocol} from 'devtools-protocol';
import type {CDPSession} from '../api/CDPSession.js';
import type {ElementHandle} from '../api/ElementHandle.js';
/**
* Represents a Node and the properties of it that are relevant to Accessibility.
* @public
*/
export interface SerializedAXNode {
/**
* The {@link https://www.w3.org/TR/wai-aria/#usage_intro | role} of the node.
*/
role: string;
/**
* A human readable name for the node.
*/
name?: string;
/**
* The current value of the node.
*/
value?: string | number;
/**
* An additional human readable description of the node.
*/
description?: string;
/**
* Any keyboard shortcuts associated with this node.
*/
keyshortcuts?: string;
/**
* A human readable alternative to the role.
*/
roledescription?: string;
/**
* A description of the current value.
*/
valuetext?: string;
disabled?: boolean;
expanded?: boolean;
focused?: boolean;
modal?: boolean;
multiline?: boolean;
/**
* Whether more than one child can be selected.
*/
multiselectable?: boolean;
readonly?: boolean;
required?: boolean;
selected?: boolean;
/**
* Whether the checkbox is checked, or in a
* {@link https://www.w3.org/TR/wai-aria-practices/examples/checkbox/checkbox-2/checkbox-2.html | mixed state}.
*/
checked?: boolean | 'mixed';
/**
* Whether the node is checked or in a mixed state.
*/
pressed?: boolean | 'mixed';
/**
* The level of a heading.
*/
level?: number;
valuemin?: number;
valuemax?: number;
autocomplete?: string;
haspopup?: string;
/**
* Whether and in what way this node's value is invalid.
*/
invalid?: string;
orientation?: string;
/**
* Children of this node, if there are any.
*/
children?: SerializedAXNode[];
}
/**
* @public
*/
export interface SnapshotOptions {
/**
* Prune uninteresting nodes from the tree.
* @defaultValue `true`
*/
interestingOnly?: boolean;
/**
* Root node to get the accessibility tree for
* @defaultValue The root node of the entire page.
*/
root?: ElementHandle<Node>;
}
/**
* The Accessibility class provides methods for inspecting the browser's
* accessibility tree. The accessibility tree is used by assistive technology
* such as {@link https://en.wikipedia.org/wiki/Screen_reader | screen readers} or
* {@link https://en.wikipedia.org/wiki/Switch_access | switches}.
*
* @remarks
*
* Accessibility is a very platform-specific thing. On different platforms,
* there are different screen readers that might have wildly different output.
*
* Blink - Chrome's rendering engine - has a concept of "accessibility tree",
* which is then translated into different platform-specific APIs. Accessibility
* namespace gives users access to the Blink Accessibility Tree.
*
* Most of the accessibility tree gets filtered out when converting from Blink
* AX Tree to Platform-specific AX-Tree or by assistive technologies themselves.
* By default, Puppeteer tries to approximate this filtering, exposing only
* the "interesting" nodes of the tree.
*
* @public
*/
export class Accessibility {
#client: CDPSession;
/**
* @internal
*/
constructor(client: CDPSession) {
this.#client = client;
}
/**
* @internal
*/
updateClient(client: CDPSession): void {
this.#client = client;
}
/**
* Captures the current state of the accessibility tree.
* The returned object represents the root accessible node of the page.
*
* @remarks
*
* **NOTE** The Chrome accessibility tree contains nodes that go unused on
* most platforms and by most screen readers. Puppeteer will discard them as
* well for an easier to process tree, unless `interestingOnly` is set to
* `false`.
*
* @example
* An example of dumping the entire accessibility tree:
*
* ```ts
* const snapshot = await page.accessibility.snapshot();
* console.log(snapshot);
* ```
*
* @example
* An example of logging the focused node's name:
*
* ```ts
* const snapshot = await page.accessibility.snapshot();
* const node = findFocusedNode(snapshot);
* console.log(node && node.name);
*
* function findFocusedNode(node) {
* if (node.focused) return node;
* for (const child of node.children || []) {
* const foundNode = findFocusedNode(child);
* return foundNode;
* }
* return null;
* }
* ```
*
* @returns An AXNode object representing the snapshot.
*/
public async snapshot(
options: SnapshotOptions = {}
): Promise<SerializedAXNode | null> {
const {interestingOnly = true, root = null} = options;
const {nodes} = await this.#client.send('Accessibility.getFullAXTree');
let backendNodeId: number | undefined;
if (root) {
const {node} = await this.#client.send('DOM.describeNode', {
objectId: root.id,
});
backendNodeId = node.backendNodeId;
}
const defaultRoot = AXNode.createTree(nodes);
let needle: AXNode | null = defaultRoot;
if (backendNodeId) {
needle = defaultRoot.find(node => {
return node.payload.backendDOMNodeId === backendNodeId;
});
if (!needle) {
return null;
}
}
if (!interestingOnly) {
return this.serializeTree(needle)[0] ?? null;
}
const interestingNodes = new Set<AXNode>();
this.collectInterestingNodes(interestingNodes, defaultRoot, false);
if (!interestingNodes.has(needle)) {
return null;
}
return this.serializeTree(needle, interestingNodes)[0] ?? null;
}
private serializeTree(
node: AXNode,
interestingNodes?: Set<AXNode>
): SerializedAXNode[] {
const children: SerializedAXNode[] = [];
for (const child of node.children) {
children.push(...this.serializeTree(child, interestingNodes));
}
if (interestingNodes && !interestingNodes.has(node)) {
return children;
}
const serializedNode = node.serialize();
if (children.length) {
serializedNode.children = children;
}
return [serializedNode];
}
private collectInterestingNodes(
collection: Set<AXNode>,
node: AXNode,
insideControl: boolean
): void {
if (node.isInteresting(insideControl)) {
collection.add(node);
}
if (node.isLeafNode()) {
return;
}
insideControl = insideControl || node.isControl();
for (const child of node.children) {
this.collectInterestingNodes(collection, child, insideControl);
}
}
}
class AXNode {
public payload: Protocol.Accessibility.AXNode;
public children: AXNode[] = [];
#richlyEditable = false;
#editable = false;
#focusable = false;
#hidden = false;
#name: string;
#role: string;
#ignored: boolean;
#cachedHasFocusableChild?: boolean;
constructor(payload: Protocol.Accessibility.AXNode) {
this.payload = payload;
this.#name = this.payload.name ? this.payload.name.value : '';
this.#role = this.payload.role ? this.payload.role.value : 'Unknown';
this.#ignored = this.payload.ignored;
for (const property of this.payload.properties || []) {
if (property.name === 'editable') {
this.#richlyEditable = property.value.value === 'richtext';
this.#editable = true;
}
if (property.name === 'focusable') {
this.#focusable = property.value.value;
}
if (property.name === 'hidden') {
this.#hidden = property.value.value;
}
}
}
#isPlainTextField(): boolean {
if (this.#richlyEditable) {
return false;
}
if (this.#editable) {
return true;
}
return this.#role === 'textbox' || this.#role === 'searchbox';
}
#isTextOnlyObject(): boolean {
const role = this.#role;
return (
role === 'LineBreak' ||
role === 'text' ||
role === 'InlineTextBox' ||
role === 'StaticText'
);
}
#hasFocusableChild(): boolean {
if (this.#cachedHasFocusableChild === undefined) {
this.#cachedHasFocusableChild = false;
for (const child of this.children) {
if (child.#focusable || child.#hasFocusableChild()) {
this.#cachedHasFocusableChild = true;
break;
}
}
}
return this.#cachedHasFocusableChild;
}
public find(predicate: (x: AXNode) => boolean): AXNode | null {
if (predicate(this)) {
return this;
}
for (const child of this.children) {
const result = child.find(predicate);
if (result) {
return result;
}
}
return null;
}
public isLeafNode(): boolean {
if (!this.children.length) {
return true;
}
// These types of objects may have children that we use as internal
// implementation details, but we want to expose them as leaves to platform
// accessibility APIs because screen readers might be confused if they find
// any children.
if (this.#isPlainTextField() || this.#isTextOnlyObject()) {
return true;
}
// Roles whose children are only presentational according to the ARIA and
// HTML5 Specs should be hidden from screen readers.
// (Note that whilst ARIA buttons can have only presentational children, HTML5
// buttons are allowed to have content.)
switch (this.#role) {
case 'doc-cover':
case 'graphics-symbol':
case 'img':
case 'image':
case 'Meter':
case 'scrollbar':
case 'slider':
case 'separator':
case 'progressbar':
return true;
default:
break;
}
// Here and below: Android heuristics
if (this.#hasFocusableChild()) {
return false;
}
if (this.#focusable && this.#name) {
return true;
}
if (this.#role === 'heading' && this.#name) {
return true;
}
return false;
}
public isControl(): boolean {
switch (this.#role) {
case 'button':
case 'checkbox':
case 'ColorWell':
case 'combobox':
case 'DisclosureTriangle':
case 'listbox':
case 'menu':
case 'menubar':
case 'menuitem':
case 'menuitemcheckbox':
case 'menuitemradio':
case 'radio':
case 'scrollbar':
case 'searchbox':
case 'slider':
case 'spinbutton':
case 'switch':
case 'tab':
case 'textbox':
case 'tree':
case 'treeitem':
return true;
default:
return false;
}
}
public isInteresting(insideControl: boolean): boolean {
const role = this.#role;
if (role === 'Ignored' || this.#hidden || this.#ignored) {
return false;
}
if (this.#focusable || this.#richlyEditable) {
return true;
}
// If it's not focusable but has a control role, then it's interesting.
if (this.isControl()) {
return true;
}
// A non focusable child of a control is not interesting
if (insideControl) {
return false;
}
return this.isLeafNode() && !!this.#name;
}
public serialize(): SerializedAXNode {
const properties = new Map<string, number | string | boolean>();
for (const property of this.payload.properties || []) {
properties.set(property.name.toLowerCase(), property.value.value);
}
if (this.payload.name) {
properties.set('name', this.payload.name.value);
}
if (this.payload.value) {
properties.set('value', this.payload.value.value);
}
if (this.payload.description) {
properties.set('description', this.payload.description.value);
}
const node: SerializedAXNode = {
role: this.#role,
};
type UserStringProperty =
| 'name'
| 'value'
| 'description'
| 'keyshortcuts'
| 'roledescription'
| 'valuetext';
const userStringProperties: UserStringProperty[] = [
'name',
'value',
'description',
'keyshortcuts',
'roledescription',
'valuetext',
];
const getUserStringPropertyValue = (key: UserStringProperty): string => {
return properties.get(key) as string;
};
for (const userStringProperty of userStringProperties) {
if (!properties.has(userStringProperty)) {
continue;
}
node[userStringProperty] = getUserStringPropertyValue(userStringProperty);
}
type BooleanProperty =
| 'disabled'
| 'expanded'
| 'focused'
| 'modal'
| 'multiline'
| 'multiselectable'
| 'readonly'
| 'required'
| 'selected';
const booleanProperties: BooleanProperty[] = [
'disabled',
'expanded',
'focused',
'modal',
'multiline',
'multiselectable',
'readonly',
'required',
'selected',
];
const getBooleanPropertyValue = (key: BooleanProperty): boolean => {
return properties.get(key) as boolean;
};
for (const booleanProperty of booleanProperties) {
// RootWebArea's treat focus differently than other nodes. They report whether
// their frame has focus, not whether focus is specifically on the root
// node.
if (booleanProperty === 'focused' && this.#role === 'RootWebArea') {
continue;
}
const value = getBooleanPropertyValue(booleanProperty);
if (!value) {
continue;
}
node[booleanProperty] = getBooleanPropertyValue(booleanProperty);
}
type TristateProperty = 'checked' | 'pressed';
const tristateProperties: TristateProperty[] = ['checked', 'pressed'];
for (const tristateProperty of tristateProperties) {
if (!properties.has(tristateProperty)) {
continue;
}
const value = properties.get(tristateProperty);
node[tristateProperty] =
value === 'mixed' ? 'mixed' : value === 'true' ? true : false;
}
type NumbericalProperty = 'level' | 'valuemax' | 'valuemin';
const numericalProperties: NumbericalProperty[] = [
'level',
'valuemax',
'valuemin',
];
const getNumericalPropertyValue = (key: NumbericalProperty): number => {
return properties.get(key) as number;
};
for (const numericalProperty of numericalProperties) {
if (!properties.has(numericalProperty)) {
continue;
}
node[numericalProperty] = getNumericalPropertyValue(numericalProperty);
}
type TokenProperty =
| 'autocomplete'
| 'haspopup'
| 'invalid'
| 'orientation';
const tokenProperties: TokenProperty[] = [
'autocomplete',
'haspopup',
'invalid',
'orientation',
];
const getTokenPropertyValue = (key: TokenProperty): string => {
return properties.get(key) as string;
};
for (const tokenProperty of tokenProperties) {
const value = getTokenPropertyValue(tokenProperty);
if (!value || value === 'false') {
continue;
}
node[tokenProperty] = getTokenPropertyValue(tokenProperty);
}
return node;
}
public static createTree(payloads: Protocol.Accessibility.AXNode[]): AXNode {
const nodeById = new Map<string, AXNode>();
for (const payload of payloads) {
nodeById.set(payload.nodeId, new AXNode(payload));
}
for (const node of nodeById.values()) {
for (const childId of node.payload.childIds || []) {
const child = nodeById.get(childId);
if (child) {
node.children.push(child);
}
}
}
return nodeById.values().next().value;
}
}
+120
View File
@@ -0,0 +1,120 @@
/**
* @license
* Copyright 2020 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type {Protocol} from 'devtools-protocol';
import type {CDPSession} from '../api/CDPSession.js';
import type {ElementHandle} from '../api/ElementHandle.js';
import {QueryHandler, type QuerySelector} from '../common/QueryHandler.js';
import type {AwaitableIterable} from '../common/types.js';
import {assert} from '../util/assert.js';
import {AsyncIterableUtil} from '../util/AsyncIterableUtil.js';
const NON_ELEMENT_NODE_ROLES = new Set(['StaticText', 'InlineTextBox']);
const queryAXTree = async (
client: CDPSession,
element: ElementHandle<Node>,
accessibleName?: string,
role?: string
): Promise<Protocol.Accessibility.AXNode[]> => {
const {nodes} = await client.send('Accessibility.queryAXTree', {
objectId: element.id,
accessibleName,
role,
});
return nodes.filter((node: Protocol.Accessibility.AXNode) => {
return !node.role || !NON_ELEMENT_NODE_ROLES.has(node.role.value);
});
};
interface ARIASelector {
name?: string;
role?: string;
}
const isKnownAttribute = (
attribute: string
): attribute is keyof ARIASelector => {
return ['name', 'role'].includes(attribute);
};
const normalizeValue = (value: string): string => {
return value.replace(/ +/g, ' ').trim();
};
/**
* The selectors consist of an accessible name to query for and optionally
* further aria attributes on the form `[<attribute>=<value>]`.
* Currently, we only support the `name` and `role` attribute.
* The following examples showcase how the syntax works wrt. querying:
*
* - 'title[role="heading"]' queries for elements with name 'title' and role 'heading'.
* - '[role="image"]' queries for elements with role 'image' and any name.
* - 'label' queries for elements with name 'label' and any role.
* - '[name=""][role="button"]' queries for elements with no name and role 'button'.
*/
const ATTRIBUTE_REGEXP =
/\[\s*(?<attribute>\w+)\s*=\s*(?<quote>"|')(?<value>\\.|.*?(?=\k<quote>))\k<quote>\s*\]/g;
const parseARIASelector = (selector: string): ARIASelector => {
const queryOptions: ARIASelector = {};
const defaultName = selector.replace(
ATTRIBUTE_REGEXP,
(_, attribute, __, value) => {
attribute = attribute.trim();
assert(
isKnownAttribute(attribute),
`Unknown aria attribute "${attribute}" in selector`
);
queryOptions[attribute] = normalizeValue(value);
return '';
}
);
if (defaultName && !queryOptions.name) {
queryOptions.name = normalizeValue(defaultName);
}
return queryOptions;
};
/**
* @internal
*/
export class ARIAQueryHandler extends QueryHandler {
static override querySelector: QuerySelector = async (
node,
selector,
{ariaQuerySelector}
) => {
return await ariaQuerySelector(node, selector);
};
static override async *queryAll(
element: ElementHandle<Node>,
selector: string
): AwaitableIterable<ElementHandle<Node>> {
const {name, role} = parseARIASelector(selector);
const results = await queryAXTree(
element.realm.environment.client,
element,
name,
role
);
yield* AsyncIterableUtil.map(results, node => {
return element.realm.adoptBackendNode(node.backendDOMNodeId) as Promise<
ElementHandle<Node>
>;
});
}
static override queryOne = async (
element: ElementHandle<Node>,
selector: string
): Promise<ElementHandle<Node> | null> => {
return (
(await AsyncIterableUtil.first(this.queryAll(element, selector))) ?? null
);
};
}
+118
View File
@@ -0,0 +1,118 @@
import {JSHandle} from '../api/JSHandle.js';
import {debugError} from '../common/util.js';
import {DisposableStack} from '../util/disposable.js';
import {isErrorLike} from '../util/ErrorLike.js';
import type {ExecutionContext} from './ExecutionContext.js';
/**
* @internal
*/
export class Binding {
#name: string;
#fn: (...args: unknown[]) => unknown;
constructor(name: string, fn: (...args: unknown[]) => unknown) {
this.#name = name;
this.#fn = fn;
}
get name(): string {
return this.#name;
}
/**
* @param context - Context to run the binding in; the context should have
* the binding added to it beforehand.
* @param id - ID of the call. This should come from the CDP
* `onBindingCalled` response.
* @param args - Plain arguments from CDP.
*/
async run(
context: ExecutionContext,
id: number,
args: unknown[],
isTrivial: boolean
): Promise<void> {
const stack = new DisposableStack();
try {
if (!isTrivial) {
// Getting non-trivial arguments.
using handles = await context.evaluateHandle(
(name, seq) => {
// @ts-expect-error Code is evaluated in a different context.
return globalThis[name].args.get(seq);
},
this.#name,
id
);
const properties = await handles.getProperties();
for (const [index, handle] of properties) {
// This is not straight-forward since some arguments can stringify, but
// aren't plain objects so add subtypes when the use-case arises.
if (index in args) {
switch (handle.remoteObject().subtype) {
case 'node':
args[+index] = handle;
break;
default:
stack.use(handle);
}
} else {
stack.use(handle);
}
}
}
await context.evaluate(
(name, seq, result) => {
// @ts-expect-error Code is evaluated in a different context.
const callbacks = globalThis[name].callbacks;
callbacks.get(seq).resolve(result);
callbacks.delete(seq);
},
this.#name,
id,
await this.#fn(...args)
);
for (const arg of args) {
if (arg instanceof JSHandle) {
stack.use(arg);
}
}
} catch (error) {
if (isErrorLike(error)) {
await context
.evaluate(
(name, seq, message, stack) => {
const error = new Error(message);
error.stack = stack;
// @ts-expect-error Code is evaluated in a different context.
const callbacks = globalThis[name].callbacks;
callbacks.get(seq).reject(error);
callbacks.delete(seq);
},
this.#name,
id,
error.message,
error.stack
)
.catch(debugError);
} else {
await context
.evaluate(
(name, seq, error) => {
// @ts-expect-error Code is evaluated in a different context.
const callbacks = globalThis[name].callbacks;
callbacks.get(seq).reject(error);
callbacks.delete(seq);
},
this.#name,
id,
error
)
.catch(debugError);
}
}
}
}
+523
View File
@@ -0,0 +1,523 @@
/**
* @license
* Copyright 2017 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type {ChildProcess} from 'child_process';
import type {Protocol} from 'devtools-protocol';
import type {DebugInfo} from '../api/Browser.js';
import {
Browser as BrowserBase,
BrowserEvent,
WEB_PERMISSION_TO_PROTOCOL_PERMISSION,
type BrowserCloseCallback,
type BrowserContextOptions,
type IsPageTargetCallback,
type Permission,
type TargetFilterCallback,
type WaitForTargetOptions,
} from '../api/Browser.js';
import {BrowserContext, BrowserContextEvent} from '../api/BrowserContext.js';
import {CDPSessionEvent, type CDPSession} from '../api/CDPSession.js';
import type {Page} from '../api/Page.js';
import type {Target} from '../api/Target.js';
import type {Viewport} from '../common/Viewport.js';
import {assert} from '../util/assert.js';
import {ChromeTargetManager} from './ChromeTargetManager.js';
import type {Connection} from './Connection.js';
import {FirefoxTargetManager} from './FirefoxTargetManager.js';
import {
DevToolsTarget,
InitializationStatus,
OtherTarget,
PageTarget,
WorkerTarget,
type CdpTarget,
} from './Target.js';
import {TargetManagerEvent, type TargetManager} from './TargetManager.js';
/**
* @internal
*/
export class CdpBrowser extends BrowserBase {
readonly protocol = 'cdp';
static async _create(
product: 'firefox' | 'chrome' | undefined,
connection: Connection,
contextIds: string[],
ignoreHTTPSErrors: boolean,
defaultViewport?: Viewport | null,
process?: ChildProcess,
closeCallback?: BrowserCloseCallback,
targetFilterCallback?: TargetFilterCallback,
isPageTargetCallback?: IsPageTargetCallback,
waitForInitiallyDiscoveredTargets = true
): Promise<CdpBrowser> {
const browser = new CdpBrowser(
product,
connection,
contextIds,
ignoreHTTPSErrors,
defaultViewport,
process,
closeCallback,
targetFilterCallback,
isPageTargetCallback,
waitForInitiallyDiscoveredTargets
);
await browser._attach();
return browser;
}
#ignoreHTTPSErrors: boolean;
#defaultViewport?: Viewport | null;
#process?: ChildProcess;
#connection: Connection;
#closeCallback: BrowserCloseCallback;
#targetFilterCallback: TargetFilterCallback;
#isPageTargetCallback!: IsPageTargetCallback;
#defaultContext: CdpBrowserContext;
#contexts = new Map<string, CdpBrowserContext>();
#targetManager: TargetManager;
constructor(
product: 'chrome' | 'firefox' | undefined,
connection: Connection,
contextIds: string[],
ignoreHTTPSErrors: boolean,
defaultViewport?: Viewport | null,
process?: ChildProcess,
closeCallback?: BrowserCloseCallback,
targetFilterCallback?: TargetFilterCallback,
isPageTargetCallback?: IsPageTargetCallback,
waitForInitiallyDiscoveredTargets = true
) {
super();
product = product || 'chrome';
this.#ignoreHTTPSErrors = ignoreHTTPSErrors;
this.#defaultViewport = defaultViewport;
this.#process = process;
this.#connection = connection;
this.#closeCallback = closeCallback || function (): void {};
this.#targetFilterCallback =
targetFilterCallback ||
((): boolean => {
return true;
});
this.#setIsPageTargetCallback(isPageTargetCallback);
if (product === 'firefox') {
this.#targetManager = new FirefoxTargetManager(
connection,
this.#createTarget,
this.#targetFilterCallback
);
} else {
this.#targetManager = new ChromeTargetManager(
connection,
this.#createTarget,
this.#targetFilterCallback,
waitForInitiallyDiscoveredTargets
);
}
this.#defaultContext = new CdpBrowserContext(this.#connection, this);
for (const contextId of contextIds) {
this.#contexts.set(
contextId,
new CdpBrowserContext(this.#connection, this, contextId)
);
}
}
#emitDisconnected = () => {
this.emit(BrowserEvent.Disconnected, undefined);
};
async _attach(): Promise<void> {
this.#connection.on(CDPSessionEvent.Disconnected, this.#emitDisconnected);
this.#targetManager.on(
TargetManagerEvent.TargetAvailable,
this.#onAttachedToTarget
);
this.#targetManager.on(
TargetManagerEvent.TargetGone,
this.#onDetachedFromTarget
);
this.#targetManager.on(
TargetManagerEvent.TargetChanged,
this.#onTargetChanged
);
this.#targetManager.on(
TargetManagerEvent.TargetDiscovered,
this.#onTargetDiscovered
);
await this.#targetManager.initialize();
}
_detach(): void {
this.#connection.off(CDPSessionEvent.Disconnected, this.#emitDisconnected);
this.#targetManager.off(
TargetManagerEvent.TargetAvailable,
this.#onAttachedToTarget
);
this.#targetManager.off(
TargetManagerEvent.TargetGone,
this.#onDetachedFromTarget
);
this.#targetManager.off(
TargetManagerEvent.TargetChanged,
this.#onTargetChanged
);
this.#targetManager.off(
TargetManagerEvent.TargetDiscovered,
this.#onTargetDiscovered
);
}
override process(): ChildProcess | null {
return this.#process ?? null;
}
_targetManager(): TargetManager {
return this.#targetManager;
}
#setIsPageTargetCallback(isPageTargetCallback?: IsPageTargetCallback): void {
this.#isPageTargetCallback =
isPageTargetCallback ||
((target: Target): boolean => {
return (
target.type() === 'page' ||
target.type() === 'background_page' ||
target.type() === 'webview'
);
});
}
_getIsPageTargetCallback(): IsPageTargetCallback | undefined {
return this.#isPageTargetCallback;
}
override async createIncognitoBrowserContext(
options: BrowserContextOptions = {}
): Promise<CdpBrowserContext> {
const {proxyServer, proxyBypassList} = options;
const {browserContextId} = await this.#connection.send(
'Target.createBrowserContext',
{
proxyServer,
proxyBypassList: proxyBypassList && proxyBypassList.join(','),
}
);
const context = new CdpBrowserContext(
this.#connection,
this,
browserContextId
);
this.#contexts.set(browserContextId, context);
return context;
}
override browserContexts(): CdpBrowserContext[] {
return [this.#defaultContext, ...Array.from(this.#contexts.values())];
}
override defaultBrowserContext(): CdpBrowserContext {
return this.#defaultContext;
}
async _disposeContext(contextId?: string): Promise<void> {
if (!contextId) {
return;
}
await this.#connection.send('Target.disposeBrowserContext', {
browserContextId: contextId,
});
this.#contexts.delete(contextId);
}
#createTarget = (
targetInfo: Protocol.Target.TargetInfo,
session?: CDPSession
) => {
const {browserContextId} = targetInfo;
const context =
browserContextId && this.#contexts.has(browserContextId)
? this.#contexts.get(browserContextId)
: this.#defaultContext;
if (!context) {
throw new Error('Missing browser context');
}
const createSession = (isAutoAttachEmulated: boolean) => {
return this.#connection._createSession(targetInfo, isAutoAttachEmulated);
};
const otherTarget = new OtherTarget(
targetInfo,
session,
context,
this.#targetManager,
createSession
);
if (targetInfo.url?.startsWith('devtools://')) {
return new DevToolsTarget(
targetInfo,
session,
context,
this.#targetManager,
createSession,
this.#ignoreHTTPSErrors,
this.#defaultViewport ?? null
);
}
if (this.#isPageTargetCallback(otherTarget)) {
return new PageTarget(
targetInfo,
session,
context,
this.#targetManager,
createSession,
this.#ignoreHTTPSErrors,
this.#defaultViewport ?? null
);
}
if (
targetInfo.type === 'service_worker' ||
targetInfo.type === 'shared_worker'
) {
return new WorkerTarget(
targetInfo,
session,
context,
this.#targetManager,
createSession
);
}
return otherTarget;
};
#onAttachedToTarget = async (target: CdpTarget) => {
if (
target._isTargetExposed() &&
(await target._initializedDeferred.valueOrThrow()) ===
InitializationStatus.SUCCESS
) {
this.emit(BrowserEvent.TargetCreated, target);
target.browserContext().emit(BrowserContextEvent.TargetCreated, target);
}
};
#onDetachedFromTarget = async (target: CdpTarget): Promise<void> => {
target._initializedDeferred.resolve(InitializationStatus.ABORTED);
target._isClosedDeferred.resolve();
if (
target._isTargetExposed() &&
(await target._initializedDeferred.valueOrThrow()) ===
InitializationStatus.SUCCESS
) {
this.emit(BrowserEvent.TargetDestroyed, target);
target.browserContext().emit(BrowserContextEvent.TargetDestroyed, target);
}
};
#onTargetChanged = ({target}: {target: CdpTarget}): void => {
this.emit(BrowserEvent.TargetChanged, target);
target.browserContext().emit(BrowserContextEvent.TargetChanged, target);
};
#onTargetDiscovered = (targetInfo: Protocol.Target.TargetInfo): void => {
this.emit(BrowserEvent.TargetDiscovered, targetInfo);
};
override wsEndpoint(): string {
return this.#connection.url();
}
override async newPage(): Promise<Page> {
return await this.#defaultContext.newPage();
}
async _createPageInContext(contextId?: string): Promise<Page> {
const {targetId} = await this.#connection.send('Target.createTarget', {
url: 'about:blank',
browserContextId: contextId || undefined,
});
const target = (await this.waitForTarget(t => {
return (t as CdpTarget)._targetId === targetId;
})) as CdpTarget;
if (!target) {
throw new Error(`Missing target for page (id = ${targetId})`);
}
const initialized =
(await target._initializedDeferred.valueOrThrow()) ===
InitializationStatus.SUCCESS;
if (!initialized) {
throw new Error(`Failed to create target for page (id = ${targetId})`);
}
const page = await target.page();
if (!page) {
throw new Error(
`Failed to create a page for context (id = ${contextId})`
);
}
return page;
}
override targets(): CdpTarget[] {
return Array.from(
this.#targetManager.getAvailableTargets().values()
).filter(target => {
return (
target._isTargetExposed() &&
target._initializedDeferred.value() === InitializationStatus.SUCCESS
);
});
}
override target(): CdpTarget {
const browserTarget = this.targets().find(target => {
return target.type() === 'browser';
});
if (!browserTarget) {
throw new Error('Browser target is not found');
}
return browserTarget;
}
override async version(): Promise<string> {
const version = await this.#getVersion();
return version.product;
}
override async userAgent(): Promise<string> {
const version = await this.#getVersion();
return version.userAgent;
}
override async close(): Promise<void> {
await this.#closeCallback.call(null);
await this.disconnect();
}
override disconnect(): Promise<void> {
this.#targetManager.dispose();
this.#connection.dispose();
this._detach();
return Promise.resolve();
}
override get connected(): boolean {
return !this.#connection._closed;
}
#getVersion(): Promise<Protocol.Browser.GetVersionResponse> {
return this.#connection.send('Browser.getVersion');
}
override get debugInfo(): DebugInfo {
return {
pendingProtocolErrors: this.#connection.getPendingProtocolErrors(),
};
}
}
/**
* @internal
*/
export class CdpBrowserContext extends BrowserContext {
#connection: Connection;
#browser: CdpBrowser;
#id?: string;
constructor(connection: Connection, browser: CdpBrowser, contextId?: string) {
super();
this.#connection = connection;
this.#browser = browser;
this.#id = contextId;
}
override get id(): string | undefined {
return this.#id;
}
override targets(): CdpTarget[] {
return this.#browser.targets().filter(target => {
return target.browserContext() === this;
});
}
override waitForTarget(
predicate: (x: Target) => boolean | Promise<boolean>,
options: WaitForTargetOptions = {}
): Promise<Target> {
return this.#browser.waitForTarget(target => {
return target.browserContext() === this && predicate(target);
}, options);
}
override async pages(): Promise<Page[]> {
const pages = await Promise.all(
this.targets()
.filter(target => {
return (
target.type() === 'page' ||
(target.type() === 'other' &&
this.#browser._getIsPageTargetCallback()?.(target))
);
})
.map(target => {
return target.page();
})
);
return pages.filter((page): page is Page => {
return !!page;
});
}
override isIncognito(): boolean {
return !!this.#id;
}
override async overridePermissions(
origin: string,
permissions: Permission[]
): Promise<void> {
const protocolPermissions = permissions.map(permission => {
const protocolPermission =
WEB_PERMISSION_TO_PROTOCOL_PERMISSION.get(permission);
if (!protocolPermission) {
throw new Error('Unknown permission: ' + permission);
}
return protocolPermission;
});
await this.#connection.send('Browser.grantPermissions', {
origin,
browserContextId: this.#id || undefined,
permissions: protocolPermissions,
});
}
override async clearPermissionOverrides(): Promise<void> {
await this.#connection.send('Browser.resetPermissions', {
browserContextId: this.#id || undefined,
});
}
override newPage(): Promise<Page> {
return this.#browser._createPageInContext(this.#id);
}
override browser(): CdpBrowser {
return this.#browser;
}
override async close(): Promise<void> {
assert(this.#id, 'Non-incognito profiles cannot be closed!');
await this.#browser._disposeContext(this.#id);
}
}
+66
View File
@@ -0,0 +1,66 @@
/**
* @license
* Copyright 2020 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type {ConnectionTransport} from '../common/ConnectionTransport.js';
import type {
BrowserConnectOptions,
ConnectOptions,
} from '../common/ConnectOptions.js';
import {debugError, DEFAULT_VIEWPORT} from '../common/util.js';
import {CdpBrowser} from './Browser.js';
import {Connection} from './Connection.js';
/**
* Users should never call this directly; it's called when calling
* `puppeteer.connect` with `protocol: 'cdp'`.
*
* @internal
*/
export async function _connectToCdpBrowser(
connectionTransport: ConnectionTransport,
url: string,
options: BrowserConnectOptions & ConnectOptions
): Promise<CdpBrowser> {
const {
ignoreHTTPSErrors = false,
defaultViewport = DEFAULT_VIEWPORT,
targetFilter,
_isPageTarget: isPageTarget,
slowMo = 0,
protocolTimeout,
} = options;
const connection = new Connection(
url,
connectionTransport,
slowMo,
protocolTimeout
);
const version = await connection.send('Browser.getVersion');
const product = version.product.toLowerCase().includes('firefox')
? 'firefox'
: 'chrome';
const {browserContextIds} = await connection.send(
'Target.getBrowserContexts'
);
const browser = await CdpBrowser._create(
product || 'chrome',
connection,
browserContextIds,
ignoreHTTPSErrors,
defaultViewport,
undefined,
() => {
return connection.send('Browser.close').catch(debugError);
},
targetFilter,
isPageTarget
);
return browser;
}
+167
View File
@@ -0,0 +1,167 @@
/**
* @license
* Copyright 2017 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type {ProtocolMapping} from 'devtools-protocol/types/protocol-mapping.js';
import {
type CDPEvents,
CDPSession,
CDPSessionEvent,
type CommandOptions,
} from '../api/CDPSession.js';
import {CallbackRegistry} from '../common/CallbackRegistry.js';
import {TargetCloseError} from '../common/Errors.js';
import {assert} from '../util/assert.js';
import {createProtocolErrorMessage} from '../util/ErrorLike.js';
import type {Connection} from './Connection.js';
import type {CdpTarget} from './Target.js';
/**
* @internal
*/
export class CdpCDPSession extends CDPSession {
#sessionId: string;
#targetType: string;
#callbacks = new CallbackRegistry();
#connection?: Connection;
#parentSessionId?: string;
#target?: CdpTarget;
/**
* @internal
*/
constructor(
connection: Connection,
targetType: string,
sessionId: string,
parentSessionId: string | undefined
) {
super();
this.#connection = connection;
this.#targetType = targetType;
this.#sessionId = sessionId;
this.#parentSessionId = parentSessionId;
}
/**
* Sets the {@link CdpTarget} associated with the session instance.
*
* @internal
*/
_setTarget(target: CdpTarget): void {
this.#target = target;
}
/**
* Gets the {@link CdpTarget} associated with the session instance.
*
* @internal
*/
_target(): CdpTarget {
assert(this.#target, 'Target must exist');
return this.#target;
}
override connection(): Connection | undefined {
return this.#connection;
}
override parentSession(): CDPSession | undefined {
if (!this.#parentSessionId) {
// To make it work in Firefox that does not have parent (tab) sessions.
return this;
}
const parent = this.#connection?.session(this.#parentSessionId);
return parent ?? undefined;
}
override send<T extends keyof ProtocolMapping.Commands>(
method: T,
params?: ProtocolMapping.Commands[T]['paramsType'][0],
options?: CommandOptions
): Promise<ProtocolMapping.Commands[T]['returnType']> {
if (!this.#connection) {
return Promise.reject(
new TargetCloseError(
`Protocol error (${method}): Session closed. Most likely the ${this.#targetType} has been closed.`
)
);
}
return this.#connection._rawSend(
this.#callbacks,
method,
params,
this.#sessionId,
options
);
}
/**
* @internal
*/
_onMessage(object: {
id?: number;
method: keyof CDPEvents;
params: CDPEvents[keyof CDPEvents];
error: {message: string; data: any; code: number};
result?: any;
}): void {
if (object.id) {
if (object.error) {
this.#callbacks.reject(
object.id,
createProtocolErrorMessage(object),
object.error.message
);
} else {
this.#callbacks.resolve(object.id, object.result);
}
} else {
assert(!object.id);
this.emit(object.method, object.params);
}
}
/**
* Detaches the cdpSession from the target. Once detached, the cdpSession object
* won't emit any events and can't be used to send messages.
*/
override async detach(): Promise<void> {
if (!this.#connection) {
throw new Error(
`Session already detached. Most likely the ${this.#targetType} has been closed.`
);
}
await this.#connection.send('Target.detachFromTarget', {
sessionId: this.#sessionId,
});
}
/**
* @internal
*/
_onClosed(): void {
this.#callbacks.clear();
this.#connection = undefined;
this.emit(CDPSessionEvent.Disconnected, undefined);
}
/**
* Returns the session's id.
*/
override id(): string {
return this.#sessionId;
}
/**
* @internal
*/
getPendingProtocolErrors(): Error[] {
return this.#callbacks.getPendingProtocolErrors();
}
}
+417
View File
@@ -0,0 +1,417 @@
/**
* @license
* Copyright 2022 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type {Protocol} from 'devtools-protocol';
import type {TargetFilterCallback} from '../api/Browser.js';
import {CDPSession, CDPSessionEvent} from '../api/CDPSession.js';
import {EventEmitter} from '../common/EventEmitter.js';
import {debugError} from '../common/util.js';
import {assert} from '../util/assert.js';
import {Deferred} from '../util/Deferred.js';
import type {CdpCDPSession} from './CDPSession.js';
import type {Connection} from './Connection.js';
import {CdpTarget, InitializationStatus} from './Target.js';
import {
type TargetFactory,
type TargetManager,
TargetManagerEvent,
type TargetManagerEvents,
} from './TargetManager.js';
function isPageTargetBecomingPrimary(
target: CdpTarget,
newTargetInfo: Protocol.Target.TargetInfo
): boolean {
return Boolean(target._subtype()) && !newTargetInfo.subtype;
}
/**
* ChromeTargetManager uses the CDP's auto-attach mechanism to intercept
* new targets and allow the rest of Puppeteer to configure listeners while
* the target is paused.
*
* @internal
*/
export class ChromeTargetManager
extends EventEmitter<TargetManagerEvents>
implements TargetManager
{
#connection: Connection;
/**
* Keeps track of the following events: 'Target.targetCreated',
* 'Target.targetDestroyed', 'Target.targetInfoChanged'.
*
* A target becomes discovered when 'Target.targetCreated' is received.
* A target is removed from this map once 'Target.targetDestroyed' is
* received.
*
* `targetFilterCallback` has no effect on this map.
*/
#discoveredTargetsByTargetId = new Map<string, Protocol.Target.TargetInfo>();
/**
* A target is added to this map once ChromeTargetManager has created
* a Target and attached at least once to it.
*/
#attachedTargetsByTargetId = new Map<string, CdpTarget>();
/**
* Tracks which sessions attach to which target.
*/
#attachedTargetsBySessionId = new Map<string, CdpTarget>();
/**
* If a target was filtered out by `targetFilterCallback`, we still receive
* events about it from CDP, but we don't forward them to the rest of Puppeteer.
*/
#ignoredTargets = new Set<string>();
#targetFilterCallback: TargetFilterCallback | undefined;
#targetFactory: TargetFactory;
#attachedToTargetListenersBySession = new WeakMap<
CDPSession | Connection,
(event: Protocol.Target.AttachedToTargetEvent) => void
>();
#detachedFromTargetListenersBySession = new WeakMap<
CDPSession | Connection,
(event: Protocol.Target.DetachedFromTargetEvent) => void
>();
#initializeDeferred = Deferred.create<void>();
#targetsIdsForInit = new Set<string>();
#waitForInitiallyDiscoveredTargets = true;
#discoveryFilter: Protocol.Target.FilterEntry[] = [{}];
constructor(
connection: Connection,
targetFactory: TargetFactory,
targetFilterCallback?: TargetFilterCallback,
waitForInitiallyDiscoveredTargets = true
) {
super();
this.#connection = connection;
this.#targetFilterCallback = targetFilterCallback;
this.#targetFactory = targetFactory;
this.#waitForInitiallyDiscoveredTargets = waitForInitiallyDiscoveredTargets;
this.#connection.on('Target.targetCreated', this.#onTargetCreated);
this.#connection.on('Target.targetDestroyed', this.#onTargetDestroyed);
this.#connection.on('Target.targetInfoChanged', this.#onTargetInfoChanged);
this.#connection.on(
CDPSessionEvent.SessionDetached,
this.#onSessionDetached
);
this.#setupAttachmentListeners(this.#connection);
}
#storeExistingTargetsForInit = () => {
if (!this.#waitForInitiallyDiscoveredTargets) {
return;
}
for (const [
targetId,
targetInfo,
] of this.#discoveredTargetsByTargetId.entries()) {
const targetForFilter = new CdpTarget(
targetInfo,
undefined,
undefined,
this,
undefined
);
if (
(!this.#targetFilterCallback ||
this.#targetFilterCallback(targetForFilter)) &&
targetInfo.type !== 'browser'
) {
this.#targetsIdsForInit.add(targetId);
}
}
};
async initialize(): Promise<void> {
await this.#connection.send('Target.setDiscoverTargets', {
discover: true,
filter: this.#discoveryFilter,
});
this.#storeExistingTargetsForInit();
await this.#connection.send('Target.setAutoAttach', {
waitForDebuggerOnStart: true,
flatten: true,
autoAttach: true,
filter: [
{
type: 'page',
exclude: true,
},
...this.#discoveryFilter,
],
});
this.#finishInitializationIfReady();
await this.#initializeDeferred.valueOrThrow();
}
dispose(): void {
this.#connection.off('Target.targetCreated', this.#onTargetCreated);
this.#connection.off('Target.targetDestroyed', this.#onTargetDestroyed);
this.#connection.off('Target.targetInfoChanged', this.#onTargetInfoChanged);
this.#connection.off(
CDPSessionEvent.SessionDetached,
this.#onSessionDetached
);
this.#removeAttachmentListeners(this.#connection);
}
getAvailableTargets(): ReadonlyMap<string, CdpTarget> {
return this.#attachedTargetsByTargetId;
}
#setupAttachmentListeners(session: CDPSession | Connection): void {
const listener = (event: Protocol.Target.AttachedToTargetEvent) => {
void this.#onAttachedToTarget(session, event);
};
assert(!this.#attachedToTargetListenersBySession.has(session));
this.#attachedToTargetListenersBySession.set(session, listener);
session.on('Target.attachedToTarget', listener);
const detachedListener = (
event: Protocol.Target.DetachedFromTargetEvent
) => {
return this.#onDetachedFromTarget(session, event);
};
assert(!this.#detachedFromTargetListenersBySession.has(session));
this.#detachedFromTargetListenersBySession.set(session, detachedListener);
session.on('Target.detachedFromTarget', detachedListener);
}
#removeAttachmentListeners(session: CDPSession | Connection): void {
const listener = this.#attachedToTargetListenersBySession.get(session);
if (listener) {
session.off('Target.attachedToTarget', listener);
this.#attachedToTargetListenersBySession.delete(session);
}
if (this.#detachedFromTargetListenersBySession.has(session)) {
session.off(
'Target.detachedFromTarget',
this.#detachedFromTargetListenersBySession.get(session)!
);
this.#detachedFromTargetListenersBySession.delete(session);
}
}
#onSessionDetached = (session: CDPSession) => {
this.#removeAttachmentListeners(session);
};
#onTargetCreated = async (event: Protocol.Target.TargetCreatedEvent) => {
this.#discoveredTargetsByTargetId.set(
event.targetInfo.targetId,
event.targetInfo
);
this.emit(TargetManagerEvent.TargetDiscovered, event.targetInfo);
// The connection is already attached to the browser target implicitly,
// therefore, no new CDPSession is created and we have special handling
// here.
if (event.targetInfo.type === 'browser' && event.targetInfo.attached) {
if (this.#attachedTargetsByTargetId.has(event.targetInfo.targetId)) {
return;
}
const target = this.#targetFactory(event.targetInfo, undefined);
target._initialize();
this.#attachedTargetsByTargetId.set(event.targetInfo.targetId, target);
}
};
#onTargetDestroyed = (event: Protocol.Target.TargetDestroyedEvent) => {
const targetInfo = this.#discoveredTargetsByTargetId.get(event.targetId);
this.#discoveredTargetsByTargetId.delete(event.targetId);
this.#finishInitializationIfReady(event.targetId);
if (
targetInfo?.type === 'service_worker' &&
this.#attachedTargetsByTargetId.has(event.targetId)
) {
// Special case for service workers: report TargetGone event when
// the worker is destroyed.
const target = this.#attachedTargetsByTargetId.get(event.targetId);
if (target) {
this.emit(TargetManagerEvent.TargetGone, target);
this.#attachedTargetsByTargetId.delete(event.targetId);
}
}
};
#onTargetInfoChanged = (event: Protocol.Target.TargetInfoChangedEvent) => {
this.#discoveredTargetsByTargetId.set(
event.targetInfo.targetId,
event.targetInfo
);
if (
this.#ignoredTargets.has(event.targetInfo.targetId) ||
!this.#attachedTargetsByTargetId.has(event.targetInfo.targetId) ||
!event.targetInfo.attached
) {
return;
}
const target = this.#attachedTargetsByTargetId.get(
event.targetInfo.targetId
);
if (!target) {
return;
}
const previousURL = target.url();
const wasInitialized =
target._initializedDeferred.value() === InitializationStatus.SUCCESS;
if (isPageTargetBecomingPrimary(target, event.targetInfo)) {
const session = target?._session();
assert(
session,
'Target that is being activated is missing a CDPSession.'
);
session.parentSession()?.emit(CDPSessionEvent.Swapped, session);
}
target._targetInfoChanged(event.targetInfo);
if (wasInitialized && previousURL !== target.url()) {
this.emit(TargetManagerEvent.TargetChanged, {
target,
wasInitialized,
previousURL,
});
}
};
#onAttachedToTarget = async (
parentSession: Connection | CDPSession,
event: Protocol.Target.AttachedToTargetEvent
) => {
const targetInfo = event.targetInfo;
const session = this.#connection.session(event.sessionId);
if (!session) {
throw new Error(`Session ${event.sessionId} was not created.`);
}
const silentDetach = async () => {
await session.send('Runtime.runIfWaitingForDebugger').catch(debugError);
// We don't use `session.detach()` because that dispatches all commands on
// the connection instead of the parent session.
await parentSession
.send('Target.detachFromTarget', {
sessionId: session.id(),
})
.catch(debugError);
};
if (!this.#connection.isAutoAttached(targetInfo.targetId)) {
return;
}
// Special case for service workers: being attached to service workers will
// prevent them from ever being destroyed. Therefore, we silently detach
// from service workers unless the connection was manually created via
// `page.worker()`. To determine this, we use
// `this.#connection.isAutoAttached(targetInfo.targetId)`. In the future, we
// should determine if a target is auto-attached or not with the help of
// CDP.
if (targetInfo.type === 'service_worker') {
this.#finishInitializationIfReady(targetInfo.targetId);
await silentDetach();
if (this.#attachedTargetsByTargetId.has(targetInfo.targetId)) {
return;
}
const target = this.#targetFactory(targetInfo);
target._initialize();
this.#attachedTargetsByTargetId.set(targetInfo.targetId, target);
this.emit(TargetManagerEvent.TargetAvailable, target);
return;
}
const isExistingTarget = this.#attachedTargetsByTargetId.has(
targetInfo.targetId
);
const target = isExistingTarget
? this.#attachedTargetsByTargetId.get(targetInfo.targetId)!
: this.#targetFactory(
targetInfo,
session,
parentSession instanceof CDPSession ? parentSession : undefined
);
if (this.#targetFilterCallback && !this.#targetFilterCallback(target)) {
this.#ignoredTargets.add(targetInfo.targetId);
this.#finishInitializationIfReady(targetInfo.targetId);
await silentDetach();
return;
}
this.#setupAttachmentListeners(session);
if (isExistingTarget) {
(session as CdpCDPSession)._setTarget(target);
this.#attachedTargetsBySessionId.set(
session.id(),
this.#attachedTargetsByTargetId.get(targetInfo.targetId)!
);
} else {
target._initialize();
this.#attachedTargetsByTargetId.set(targetInfo.targetId, target);
this.#attachedTargetsBySessionId.set(session.id(), target);
}
parentSession.emit(CDPSessionEvent.Ready, session);
this.#targetsIdsForInit.delete(target._targetId);
if (!isExistingTarget) {
this.emit(TargetManagerEvent.TargetAvailable, target);
}
this.#finishInitializationIfReady();
// TODO: the browser might be shutting down here. What do we do with the
// error?
await Promise.all([
session.send('Target.setAutoAttach', {
waitForDebuggerOnStart: true,
flatten: true,
autoAttach: true,
filter: this.#discoveryFilter,
}),
session.send('Runtime.runIfWaitingForDebugger'),
]).catch(debugError);
};
#finishInitializationIfReady(targetId?: string): void {
targetId !== undefined && this.#targetsIdsForInit.delete(targetId);
if (this.#targetsIdsForInit.size === 0) {
this.#initializeDeferred.resolve();
}
}
#onDetachedFromTarget = (
_parentSession: Connection | CDPSession,
event: Protocol.Target.DetachedFromTargetEvent
) => {
const target = this.#attachedTargetsBySessionId.get(event.sessionId);
this.#attachedTargetsBySessionId.delete(event.sessionId);
if (!target) {
return;
}
this.#attachedTargetsByTargetId.delete(target._targetId);
this.emit(TargetManagerEvent.TargetGone, target);
};
}
+273
View File
@@ -0,0 +1,273 @@
/**
* @license
* Copyright 2017 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type {Protocol} from 'devtools-protocol';
import type {ProtocolMapping} from 'devtools-protocol/types/protocol-mapping.js';
import type {CommandOptions} from '../api/CDPSession.js';
import {
CDPSessionEvent,
type CDPSession,
type CDPSessionEvents,
} from '../api/CDPSession.js';
import {CallbackRegistry} from '../common/CallbackRegistry.js';
import type {ConnectionTransport} from '../common/ConnectionTransport.js';
import {debug} from '../common/Debug.js';
import {TargetCloseError} from '../common/Errors.js';
import {EventEmitter} from '../common/EventEmitter.js';
import {createProtocolErrorMessage} from '../util/ErrorLike.js';
import {CdpCDPSession} from './CDPSession.js';
const debugProtocolSend = debug('puppeteer:protocol:SEND ►');
const debugProtocolReceive = debug('puppeteer:protocol:RECV ◀');
/**
* @public
*/
export type {ConnectionTransport, ProtocolMapping};
/**
* @public
*/
export class Connection extends EventEmitter<CDPSessionEvents> {
#url: string;
#transport: ConnectionTransport;
#delay: number;
#timeout: number;
#sessions = new Map<string, CdpCDPSession>();
#closed = false;
#manuallyAttached = new Set<string>();
#callbacks = new CallbackRegistry();
constructor(
url: string,
transport: ConnectionTransport,
delay = 0,
timeout?: number
) {
super();
this.#url = url;
this.#delay = delay;
this.#timeout = timeout ?? 180_000;
this.#transport = transport;
this.#transport.onmessage = this.onMessage.bind(this);
this.#transport.onclose = this.#onClose.bind(this);
}
static fromSession(session: CDPSession): Connection | undefined {
return session.connection();
}
get timeout(): number {
return this.#timeout;
}
/**
* @internal
*/
get _closed(): boolean {
return this.#closed;
}
/**
* @internal
*/
get _sessions(): Map<string, CDPSession> {
return this.#sessions;
}
/**
* @param sessionId - The session id
* @returns The current CDP session if it exists
*/
session(sessionId: string): CDPSession | null {
return this.#sessions.get(sessionId) || null;
}
url(): string {
return this.#url;
}
send<T extends keyof ProtocolMapping.Commands>(
method: T,
params?: ProtocolMapping.Commands[T]['paramsType'][0],
options?: CommandOptions
): Promise<ProtocolMapping.Commands[T]['returnType']> {
// There is only ever 1 param arg passed, but the Protocol defines it as an
// array of 0 or 1 items See this comment:
// https://github.com/ChromeDevTools/devtools-protocol/pull/113#issuecomment-412603285
// which explains why the protocol defines the params this way for better
// type-inference.
// So now we check if there are any params or not and deal with them accordingly.
return this._rawSend(this.#callbacks, method, params, undefined, options);
}
/**
* @internal
*/
_rawSend<T extends keyof ProtocolMapping.Commands>(
callbacks: CallbackRegistry,
method: T,
params: ProtocolMapping.Commands[T]['paramsType'][0],
sessionId?: string,
options?: CommandOptions
): Promise<ProtocolMapping.Commands[T]['returnType']> {
return callbacks.create(method, options?.timeout ?? this.#timeout, id => {
const stringifiedMessage = JSON.stringify({
method,
params,
id,
sessionId,
});
debugProtocolSend(stringifiedMessage);
this.#transport.send(stringifiedMessage);
}) as Promise<ProtocolMapping.Commands[T]['returnType']>;
}
/**
* @internal
*/
async closeBrowser(): Promise<void> {
await this.send('Browser.close');
}
/**
* @internal
*/
protected async onMessage(message: string): Promise<void> {
if (this.#delay) {
await new Promise(r => {
return setTimeout(r, this.#delay);
});
}
debugProtocolReceive(message);
const object = JSON.parse(message);
if (object.method === 'Target.attachedToTarget') {
const sessionId = object.params.sessionId;
const session = new CdpCDPSession(
this,
object.params.targetInfo.type,
sessionId,
object.sessionId
);
this.#sessions.set(sessionId, session);
this.emit(CDPSessionEvent.SessionAttached, session);
const parentSession = this.#sessions.get(object.sessionId);
if (parentSession) {
parentSession.emit(CDPSessionEvent.SessionAttached, session);
}
} else if (object.method === 'Target.detachedFromTarget') {
const session = this.#sessions.get(object.params.sessionId);
if (session) {
session._onClosed();
this.#sessions.delete(object.params.sessionId);
this.emit(CDPSessionEvent.SessionDetached, session);
const parentSession = this.#sessions.get(object.sessionId);
if (parentSession) {
parentSession.emit(CDPSessionEvent.SessionDetached, session);
}
}
}
if (object.sessionId) {
const session = this.#sessions.get(object.sessionId);
if (session) {
session._onMessage(object);
}
} else if (object.id) {
if (object.error) {
this.#callbacks.reject(
object.id,
createProtocolErrorMessage(object),
object.error.message
);
} else {
this.#callbacks.resolve(object.id, object.result);
}
} else {
this.emit(object.method, object.params);
}
}
#onClose(): void {
if (this.#closed) {
return;
}
this.#closed = true;
this.#transport.onmessage = undefined;
this.#transport.onclose = undefined;
this.#callbacks.clear();
for (const session of this.#sessions.values()) {
session._onClosed();
}
this.#sessions.clear();
this.emit(CDPSessionEvent.Disconnected, undefined);
}
dispose(): void {
this.#onClose();
this.#transport.close();
}
/**
* @internal
*/
isAutoAttached(targetId: string): boolean {
return !this.#manuallyAttached.has(targetId);
}
/**
* @internal
*/
async _createSession(
targetInfo: Protocol.Target.TargetInfo,
isAutoAttachEmulated = true
): Promise<CDPSession> {
if (!isAutoAttachEmulated) {
this.#manuallyAttached.add(targetInfo.targetId);
}
const {sessionId} = await this.send('Target.attachToTarget', {
targetId: targetInfo.targetId,
flatten: true,
});
this.#manuallyAttached.delete(targetInfo.targetId);
const session = this.#sessions.get(sessionId);
if (!session) {
throw new Error('CDPSession creation failed.');
}
return session;
}
/**
* @param targetInfo - The target info
* @returns The CDP session that is created
*/
async createSession(
targetInfo: Protocol.Target.TargetInfo
): Promise<CDPSession> {
return await this._createSession(targetInfo, false);
}
/**
* @internal
*/
getPendingProtocolErrors(): Error[] {
const result: Error[] = [];
result.push(...this.#callbacks.getPendingProtocolErrors());
for (const session of this.#sessions.values()) {
result.push(...session.getPendingProtocolErrors());
}
return result;
}
}
/**
* @internal
*/
export function isTargetClosedError(error: Error): boolean {
return error instanceof TargetCloseError;
}
+513
View File
@@ -0,0 +1,513 @@
/**
* @license
* Copyright 2017 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type {Protocol} from 'devtools-protocol';
import type {CDPSession} from '../api/CDPSession.js';
import {EventSubscription} from '../common/EventEmitter.js';
import {debugError, PuppeteerURL} from '../common/util.js';
import {assert} from '../util/assert.js';
import {DisposableStack} from '../util/disposable.js';
/**
* The CoverageEntry class represents one entry of the coverage report.
* @public
*/
export interface CoverageEntry {
/**
* The URL of the style sheet or script.
*/
url: string;
/**
* The content of the style sheet or script.
*/
text: string;
/**
* The covered range as start and end positions.
*/
ranges: Array<{start: number; end: number}>;
}
/**
* The CoverageEntry class for JavaScript
* @public
*/
export interface JSCoverageEntry extends CoverageEntry {
/**
* Raw V8 script coverage entry.
*/
rawScriptCoverage?: Protocol.Profiler.ScriptCoverage;
}
/**
* Set of configurable options for JS coverage.
* @public
*/
export interface JSCoverageOptions {
/**
* Whether to reset coverage on every navigation.
*/
resetOnNavigation?: boolean;
/**
* Whether anonymous scripts generated by the page should be reported.
*/
reportAnonymousScripts?: boolean;
/**
* Whether the result includes raw V8 script coverage entries.
*/
includeRawScriptCoverage?: boolean;
/**
* Whether to collect coverage information at the block level.
* If true, coverage will be collected at the block level (this is the default).
* If false, coverage will be collected at the function level.
*/
useBlockCoverage?: boolean;
}
/**
* Set of configurable options for CSS coverage.
* @public
*/
export interface CSSCoverageOptions {
/**
* Whether to reset coverage on every navigation.
*/
resetOnNavigation?: boolean;
}
/**
* The Coverage class provides methods to gather information about parts of
* JavaScript and CSS that were used by the page.
*
* @remarks
* To output coverage in a form consumable by {@link https://github.com/istanbuljs | Istanbul},
* see {@link https://github.com/istanbuljs/puppeteer-to-istanbul | puppeteer-to-istanbul}.
*
* @example
* An example of using JavaScript and CSS coverage to get percentage of initially
* executed code:
*
* ```ts
* // Enable both JavaScript and CSS coverage
* await Promise.all([
* page.coverage.startJSCoverage(),
* page.coverage.startCSSCoverage(),
* ]);
* // Navigate to page
* await page.goto('https://example.com');
* // Disable both JavaScript and CSS coverage
* const [jsCoverage, cssCoverage] = await Promise.all([
* page.coverage.stopJSCoverage(),
* page.coverage.stopCSSCoverage(),
* ]);
* let totalBytes = 0;
* let usedBytes = 0;
* const coverage = [...jsCoverage, ...cssCoverage];
* for (const entry of coverage) {
* totalBytes += entry.text.length;
* for (const range of entry.ranges) usedBytes += range.end - range.start - 1;
* }
* console.log(`Bytes used: ${(usedBytes / totalBytes) * 100}%`);
* ```
*
* @public
*/
export class Coverage {
#jsCoverage: JSCoverage;
#cssCoverage: CSSCoverage;
constructor(client: CDPSession) {
this.#jsCoverage = new JSCoverage(client);
this.#cssCoverage = new CSSCoverage(client);
}
/**
* @internal
*/
updateClient(client: CDPSession): void {
this.#jsCoverage.updateClient(client);
this.#cssCoverage.updateClient(client);
}
/**
* @param options - Set of configurable options for coverage defaults to
* `resetOnNavigation : true, reportAnonymousScripts : false,`
* `includeRawScriptCoverage : false, useBlockCoverage : true`
* @returns Promise that resolves when coverage is started.
*
* @remarks
* Anonymous scripts are ones that don't have an associated url. These are
* scripts that are dynamically created on the page using `eval` or
* `new Function`. If `reportAnonymousScripts` is set to `true`, anonymous
* scripts URL will start with `debugger://VM` (unless a magic //# sourceURL
* comment is present, in which case that will the be URL).
*/
async startJSCoverage(options: JSCoverageOptions = {}): Promise<void> {
return await this.#jsCoverage.start(options);
}
/**
* Promise that resolves to the array of coverage reports for
* all scripts.
*
* @remarks
* JavaScript Coverage doesn't include anonymous scripts by default.
* However, scripts with sourceURLs are reported.
*/
async stopJSCoverage(): Promise<JSCoverageEntry[]> {
return await this.#jsCoverage.stop();
}
/**
* @param options - Set of configurable options for coverage, defaults to
* `resetOnNavigation : true`
* @returns Promise that resolves when coverage is started.
*/
async startCSSCoverage(options: CSSCoverageOptions = {}): Promise<void> {
return await this.#cssCoverage.start(options);
}
/**
* Promise that resolves to the array of coverage reports
* for all stylesheets.
*
* @remarks
* CSS Coverage doesn't include dynamically injected style tags
* without sourceURLs.
*/
async stopCSSCoverage(): Promise<CoverageEntry[]> {
return await this.#cssCoverage.stop();
}
}
/**
* @public
*/
export class JSCoverage {
#client: CDPSession;
#enabled = false;
#scriptURLs = new Map<string, string>();
#scriptSources = new Map<string, string>();
#subscriptions?: DisposableStack;
#resetOnNavigation = false;
#reportAnonymousScripts = false;
#includeRawScriptCoverage = false;
constructor(client: CDPSession) {
this.#client = client;
}
/**
* @internal
*/
updateClient(client: CDPSession): void {
this.#client = client;
}
async start(
options: {
resetOnNavigation?: boolean;
reportAnonymousScripts?: boolean;
includeRawScriptCoverage?: boolean;
useBlockCoverage?: boolean;
} = {}
): Promise<void> {
assert(!this.#enabled, 'JSCoverage is already enabled');
const {
resetOnNavigation = true,
reportAnonymousScripts = false,
includeRawScriptCoverage = false,
useBlockCoverage = true,
} = options;
this.#resetOnNavigation = resetOnNavigation;
this.#reportAnonymousScripts = reportAnonymousScripts;
this.#includeRawScriptCoverage = includeRawScriptCoverage;
this.#enabled = true;
this.#scriptURLs.clear();
this.#scriptSources.clear();
this.#subscriptions = new DisposableStack();
this.#subscriptions.use(
new EventSubscription(
this.#client,
'Debugger.scriptParsed',
this.#onScriptParsed.bind(this)
)
);
this.#subscriptions.use(
new EventSubscription(
this.#client,
'Runtime.executionContextsCleared',
this.#onExecutionContextsCleared.bind(this)
)
);
await Promise.all([
this.#client.send('Profiler.enable'),
this.#client.send('Profiler.startPreciseCoverage', {
callCount: this.#includeRawScriptCoverage,
detailed: useBlockCoverage,
}),
this.#client.send('Debugger.enable'),
this.#client.send('Debugger.setSkipAllPauses', {skip: true}),
]);
}
#onExecutionContextsCleared(): void {
if (!this.#resetOnNavigation) {
return;
}
this.#scriptURLs.clear();
this.#scriptSources.clear();
}
async #onScriptParsed(
event: Protocol.Debugger.ScriptParsedEvent
): Promise<void> {
// Ignore puppeteer-injected scripts
if (PuppeteerURL.isPuppeteerURL(event.url)) {
return;
}
// Ignore other anonymous scripts unless the reportAnonymousScripts option is true.
if (!event.url && !this.#reportAnonymousScripts) {
return;
}
try {
const response = await this.#client.send('Debugger.getScriptSource', {
scriptId: event.scriptId,
});
this.#scriptURLs.set(event.scriptId, event.url);
this.#scriptSources.set(event.scriptId, response.scriptSource);
} catch (error) {
// This might happen if the page has already navigated away.
debugError(error);
}
}
async stop(): Promise<JSCoverageEntry[]> {
assert(this.#enabled, 'JSCoverage is not enabled');
this.#enabled = false;
const result = await Promise.all([
this.#client.send('Profiler.takePreciseCoverage'),
this.#client.send('Profiler.stopPreciseCoverage'),
this.#client.send('Profiler.disable'),
this.#client.send('Debugger.disable'),
]);
this.#subscriptions?.dispose();
const coverage = [];
const profileResponse = result[0];
for (const entry of profileResponse.result) {
let url = this.#scriptURLs.get(entry.scriptId);
if (!url && this.#reportAnonymousScripts) {
url = 'debugger://VM' + entry.scriptId;
}
const text = this.#scriptSources.get(entry.scriptId);
if (text === undefined || url === undefined) {
continue;
}
const flattenRanges = [];
for (const func of entry.functions) {
flattenRanges.push(...func.ranges);
}
const ranges = convertToDisjointRanges(flattenRanges);
if (!this.#includeRawScriptCoverage) {
coverage.push({url, ranges, text});
} else {
coverage.push({url, ranges, text, rawScriptCoverage: entry});
}
}
return coverage;
}
}
/**
* @public
*/
export class CSSCoverage {
#client: CDPSession;
#enabled = false;
#stylesheetURLs = new Map<string, string>();
#stylesheetSources = new Map<string, string>();
#eventListeners?: DisposableStack;
#resetOnNavigation = false;
constructor(client: CDPSession) {
this.#client = client;
}
/**
* @internal
*/
updateClient(client: CDPSession): void {
this.#client = client;
}
async start(options: {resetOnNavigation?: boolean} = {}): Promise<void> {
assert(!this.#enabled, 'CSSCoverage is already enabled');
const {resetOnNavigation = true} = options;
this.#resetOnNavigation = resetOnNavigation;
this.#enabled = true;
this.#stylesheetURLs.clear();
this.#stylesheetSources.clear();
this.#eventListeners = new DisposableStack();
this.#eventListeners.use(
new EventSubscription(
this.#client,
'CSS.styleSheetAdded',
this.#onStyleSheet.bind(this)
)
);
this.#eventListeners.use(
new EventSubscription(
this.#client,
'Runtime.executionContextsCleared',
this.#onExecutionContextsCleared.bind(this)
)
);
await Promise.all([
this.#client.send('DOM.enable'),
this.#client.send('CSS.enable'),
this.#client.send('CSS.startRuleUsageTracking'),
]);
}
#onExecutionContextsCleared(): void {
if (!this.#resetOnNavigation) {
return;
}
this.#stylesheetURLs.clear();
this.#stylesheetSources.clear();
}
async #onStyleSheet(event: Protocol.CSS.StyleSheetAddedEvent): Promise<void> {
const header = event.header;
// Ignore anonymous scripts
if (!header.sourceURL) {
return;
}
try {
const response = await this.#client.send('CSS.getStyleSheetText', {
styleSheetId: header.styleSheetId,
});
this.#stylesheetURLs.set(header.styleSheetId, header.sourceURL);
this.#stylesheetSources.set(header.styleSheetId, response.text);
} catch (error) {
// This might happen if the page has already navigated away.
debugError(error);
}
}
async stop(): Promise<CoverageEntry[]> {
assert(this.#enabled, 'CSSCoverage is not enabled');
this.#enabled = false;
const ruleTrackingResponse = await this.#client.send(
'CSS.stopRuleUsageTracking'
);
await Promise.all([
this.#client.send('CSS.disable'),
this.#client.send('DOM.disable'),
]);
this.#eventListeners?.dispose();
// aggregate by styleSheetId
const styleSheetIdToCoverage = new Map();
for (const entry of ruleTrackingResponse.ruleUsage) {
let ranges = styleSheetIdToCoverage.get(entry.styleSheetId);
if (!ranges) {
ranges = [];
styleSheetIdToCoverage.set(entry.styleSheetId, ranges);
}
ranges.push({
startOffset: entry.startOffset,
endOffset: entry.endOffset,
count: entry.used ? 1 : 0,
});
}
const coverage: CoverageEntry[] = [];
for (const styleSheetId of this.#stylesheetURLs.keys()) {
const url = this.#stylesheetURLs.get(styleSheetId);
assert(
typeof url !== 'undefined',
`Stylesheet URL is undefined (styleSheetId=${styleSheetId})`
);
const text = this.#stylesheetSources.get(styleSheetId);
assert(
typeof text !== 'undefined',
`Stylesheet text is undefined (styleSheetId=${styleSheetId})`
);
const ranges = convertToDisjointRanges(
styleSheetIdToCoverage.get(styleSheetId) || []
);
coverage.push({url, ranges, text});
}
return coverage;
}
}
function convertToDisjointRanges(
nestedRanges: Array<{startOffset: number; endOffset: number; count: number}>
): Array<{start: number; end: number}> {
const points = [];
for (const range of nestedRanges) {
points.push({offset: range.startOffset, type: 0, range});
points.push({offset: range.endOffset, type: 1, range});
}
// Sort points to form a valid parenthesis sequence.
points.sort((a, b) => {
// Sort with increasing offsets.
if (a.offset !== b.offset) {
return a.offset - b.offset;
}
// All "end" points should go before "start" points.
if (a.type !== b.type) {
return b.type - a.type;
}
const aLength = a.range.endOffset - a.range.startOffset;
const bLength = b.range.endOffset - b.range.startOffset;
// For two "start" points, the one with longer range goes first.
if (a.type === 0) {
return bLength - aLength;
}
// For two "end" points, the one with shorter range goes first.
return aLength - bLength;
});
const hitCountStack = [];
const results: Array<{
start: number;
end: number;
}> = [];
let lastOffset = 0;
// Run scanning line to intersect all ranges.
for (const point of points) {
if (
hitCountStack.length &&
lastOffset < point.offset &&
hitCountStack[hitCountStack.length - 1]! > 0
) {
const lastResult = results[results.length - 1];
if (lastResult && lastResult.end === lastOffset) {
lastResult.end = point.offset;
} else {
results.push({start: lastOffset, end: point.offset});
}
}
lastOffset = point.offset;
if (point.type === 0) {
hitCountStack.push(point.range.count);
} else {
hitCountStack.pop();
}
}
// Filter out empty ranges.
return results.filter(range => {
return range.end - range.start > 0;
});
}
+280
View File
@@ -0,0 +1,280 @@
/**
* @license
* Copyright 2022 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type Protocol from 'devtools-protocol';
import type {CDPSession} from '../api/CDPSession.js';
import type {WaitTimeoutOptions} from '../api/Page.js';
import type {TimeoutSettings} from '../common/TimeoutSettings.js';
import {assert} from '../util/assert.js';
import {Deferred} from '../util/Deferred.js';
/**
* Device in a request prompt.
*
* @public
*/
export class DeviceRequestPromptDevice {
/**
* Device id during a prompt.
*/
id: string;
/**
* Device name as it appears in a prompt.
*/
name: string;
/**
* @internal
*/
constructor(id: string, name: string) {
this.id = id;
this.name = name;
}
}
/**
* Device request prompts let you respond to the page requesting for a device
* through an API like WebBluetooth.
*
* @remarks
* `DeviceRequestPrompt` instances are returned via the
* {@link Page.waitForDevicePrompt} method.
*
* @example
*
* ```ts
* const [deviceRequest] = Promise.all([
* page.waitForDevicePrompt(),
* page.click('#connect-bluetooth'),
* ]);
* await devicePrompt.select(
* await devicePrompt.waitForDevice(({name}) => name.includes('My Device'))
* );
* ```
*
* @public
*/
export class DeviceRequestPrompt {
#client: CDPSession | null;
#timeoutSettings: TimeoutSettings;
#id: string;
#handled = false;
#updateDevicesHandle = this.#updateDevices.bind(this);
#waitForDevicePromises = new Set<{
filter: (device: DeviceRequestPromptDevice) => boolean;
promise: Deferred<DeviceRequestPromptDevice>;
}>();
/**
* Current list of selectable devices.
*/
devices: DeviceRequestPromptDevice[] = [];
/**
* @internal
*/
constructor(
client: CDPSession,
timeoutSettings: TimeoutSettings,
firstEvent: Protocol.DeviceAccess.DeviceRequestPromptedEvent
) {
this.#client = client;
this.#timeoutSettings = timeoutSettings;
this.#id = firstEvent.id;
this.#client.on(
'DeviceAccess.deviceRequestPrompted',
this.#updateDevicesHandle
);
this.#client.on('Target.detachedFromTarget', () => {
this.#client = null;
});
this.#updateDevices(firstEvent);
}
#updateDevices(event: Protocol.DeviceAccess.DeviceRequestPromptedEvent) {
if (event.id !== this.#id) {
return;
}
for (const rawDevice of event.devices) {
if (
this.devices.some(device => {
return device.id === rawDevice.id;
})
) {
continue;
}
const newDevice = new DeviceRequestPromptDevice(
rawDevice.id,
rawDevice.name
);
this.devices.push(newDevice);
for (const waitForDevicePromise of this.#waitForDevicePromises) {
if (waitForDevicePromise.filter(newDevice)) {
waitForDevicePromise.promise.resolve(newDevice);
}
}
}
}
/**
* Resolve to the first device in the prompt matching a filter.
*/
async waitForDevice(
filter: (device: DeviceRequestPromptDevice) => boolean,
options: WaitTimeoutOptions = {}
): Promise<DeviceRequestPromptDevice> {
for (const device of this.devices) {
if (filter(device)) {
return device;
}
}
const {timeout = this.#timeoutSettings.timeout()} = options;
const deferred = Deferred.create<DeviceRequestPromptDevice>({
message: `Waiting for \`DeviceRequestPromptDevice\` failed: ${timeout}ms exceeded`,
timeout,
});
const handle = {filter, promise: deferred};
this.#waitForDevicePromises.add(handle);
try {
return await deferred.valueOrThrow();
} finally {
this.#waitForDevicePromises.delete(handle);
}
}
/**
* Select a device in the prompt's list.
*/
async select(device: DeviceRequestPromptDevice): Promise<void> {
assert(
this.#client !== null,
'Cannot select device through detached session!'
);
assert(this.devices.includes(device), 'Cannot select unknown device!');
assert(
!this.#handled,
'Cannot select DeviceRequestPrompt which is already handled!'
);
this.#client.off(
'DeviceAccess.deviceRequestPrompted',
this.#updateDevicesHandle
);
this.#handled = true;
return await this.#client.send('DeviceAccess.selectPrompt', {
id: this.#id,
deviceId: device.id,
});
}
/**
* Cancel the prompt.
*/
async cancel(): Promise<void> {
assert(
this.#client !== null,
'Cannot cancel prompt through detached session!'
);
assert(
!this.#handled,
'Cannot cancel DeviceRequestPrompt which is already handled!'
);
this.#client.off(
'DeviceAccess.deviceRequestPrompted',
this.#updateDevicesHandle
);
this.#handled = true;
return await this.#client.send('DeviceAccess.cancelPrompt', {id: this.#id});
}
}
/**
* @internal
*/
export class DeviceRequestPromptManager {
#client: CDPSession | null;
#timeoutSettings: TimeoutSettings;
#deviceRequestPrompDeferreds = new Set<Deferred<DeviceRequestPrompt>>();
/**
* @internal
*/
constructor(client: CDPSession, timeoutSettings: TimeoutSettings) {
this.#client = client;
this.#timeoutSettings = timeoutSettings;
this.#client.on('DeviceAccess.deviceRequestPrompted', event => {
this.#onDeviceRequestPrompted(event);
});
this.#client.on('Target.detachedFromTarget', () => {
this.#client = null;
});
}
/**
* Wait for device prompt created by an action like calling WebBluetooth's
* requestDevice.
*/
async waitForDevicePrompt(
options: WaitTimeoutOptions = {}
): Promise<DeviceRequestPrompt> {
assert(
this.#client !== null,
'Cannot wait for device prompt through detached session!'
);
const needsEnable = this.#deviceRequestPrompDeferreds.size === 0;
let enablePromise: Promise<void> | undefined;
if (needsEnable) {
enablePromise = this.#client.send('DeviceAccess.enable');
}
const {timeout = this.#timeoutSettings.timeout()} = options;
const deferred = Deferred.create<DeviceRequestPrompt>({
message: `Waiting for \`DeviceRequestPrompt\` failed: ${timeout}ms exceeded`,
timeout,
});
this.#deviceRequestPrompDeferreds.add(deferred);
try {
const [result] = await Promise.all([
deferred.valueOrThrow(),
enablePromise,
]);
return result;
} finally {
this.#deviceRequestPrompDeferreds.delete(deferred);
}
}
/**
* @internal
*/
#onDeviceRequestPrompted(
event: Protocol.DeviceAccess.DeviceRequestPromptedEvent
) {
if (!this.#deviceRequestPrompDeferreds.size) {
return;
}
assert(this.#client !== null);
const devicePrompt = new DeviceRequestPrompt(
this.#client,
this.#timeoutSettings,
event
);
for (const promise of this.#deviceRequestPrompDeferreds) {
promise.resolve(devicePrompt);
}
this.#deviceRequestPrompDeferreds.clear();
}
}
+37
View File
@@ -0,0 +1,37 @@
/**
* @license
* Copyright 2017 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type {Protocol} from 'devtools-protocol';
import type {CDPSession} from '../api/CDPSession.js';
import {Dialog} from '../api/Dialog.js';
/**
* @internal
*/
export class CdpDialog extends Dialog {
#client: CDPSession;
constructor(
client: CDPSession,
type: Protocol.Page.DialogType,
message: string,
defaultValue = ''
) {
super(type, message, defaultValue);
this.#client = client;
}
override async handle(options: {
accept: boolean;
text?: string;
}): Promise<void> {
await this.#client.send('Page.handleJavaScriptDialog', {
accept: options.accept,
promptText: options.text,
});
}
}
+172
View File
@@ -0,0 +1,172 @@
/**
* @license
* Copyright 2019 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type Path from 'path';
import type {Protocol} from 'devtools-protocol';
import type {CDPSession} from '../api/CDPSession.js';
import {ElementHandle, type AutofillData} from '../api/ElementHandle.js';
import {debugError} from '../common/util.js';
import {assert} from '../util/assert.js';
import {throwIfDisposed} from '../util/decorators.js';
import type {CdpFrame} from './Frame.js';
import type {FrameManager} from './FrameManager.js';
import type {IsolatedWorld} from './IsolatedWorld.js';
import {CdpJSHandle} from './JSHandle.js';
/**
* The CdpElementHandle extends ElementHandle now to keep compatibility
* with `instanceof` because of that we need to have methods for
* CdpJSHandle to in this implementation as well.
*
* @internal
*/
export class CdpElementHandle<
ElementType extends Node = Element,
> extends ElementHandle<ElementType> {
protected declare readonly handle: CdpJSHandle<ElementType>;
constructor(
world: IsolatedWorld,
remoteObject: Protocol.Runtime.RemoteObject
) {
super(new CdpJSHandle(world, remoteObject));
}
override get realm(): IsolatedWorld {
return this.handle.realm;
}
get client(): CDPSession {
return this.handle.client;
}
override remoteObject(): Protocol.Runtime.RemoteObject {
return this.handle.remoteObject();
}
get #frameManager(): FrameManager {
return this.frame._frameManager;
}
override get frame(): CdpFrame {
return this.realm.environment as CdpFrame;
}
override async contentFrame(
this: ElementHandle<HTMLIFrameElement>
): Promise<CdpFrame>;
@throwIfDisposed()
override async contentFrame(): Promise<CdpFrame | null> {
const nodeInfo = await this.client.send('DOM.describeNode', {
objectId: this.id,
});
if (typeof nodeInfo.node.frameId !== 'string') {
return null;
}
return this.#frameManager.frame(nodeInfo.node.frameId);
}
@throwIfDisposed()
@ElementHandle.bindIsolatedHandle
override async scrollIntoView(
this: CdpElementHandle<Element>
): Promise<void> {
await this.assertConnectedElement();
try {
await this.client.send('DOM.scrollIntoViewIfNeeded', {
objectId: this.id,
});
} catch (error) {
debugError(error);
// Fallback to Element.scrollIntoView if DOM.scrollIntoViewIfNeeded is not supported
await super.scrollIntoView();
}
}
@throwIfDisposed()
@ElementHandle.bindIsolatedHandle
override async uploadFile(
this: CdpElementHandle<HTMLInputElement>,
...filePaths: string[]
): Promise<void> {
const isMultiple = await this.evaluate(element => {
return element.multiple;
});
assert(
filePaths.length <= 1 || isMultiple,
'Multiple file uploads only work with <input type=file multiple>'
);
// Locate all files and confirm that they exist.
let path: typeof Path;
try {
path = await import('path');
} catch (error) {
if (error instanceof TypeError) {
throw new Error(
`JSHandle#uploadFile can only be used in Node-like environments.`
);
}
throw error;
}
const files = filePaths.map(filePath => {
if (path.win32.isAbsolute(filePath) || path.posix.isAbsolute(filePath)) {
return filePath;
} else {
return path.resolve(filePath);
}
});
/**
* The zero-length array is a special case, it seems that
* DOM.setFileInputFiles does not actually update the files in that case, so
* the solution is to eval the element value to a new FileList directly.
*/
if (files.length === 0) {
// XXX: These events should converted to trusted events. Perhaps do this
// in `DOM.setFileInputFiles`?
await this.evaluate(element => {
element.files = new DataTransfer().files;
// Dispatch events for this case because it should behave akin to a user action.
element.dispatchEvent(
new Event('input', {bubbles: true, composed: true})
);
element.dispatchEvent(new Event('change', {bubbles: true}));
});
return;
}
const {
node: {backendNodeId},
} = await this.client.send('DOM.describeNode', {
objectId: this.id,
});
await this.client.send('DOM.setFileInputFiles', {
objectId: this.id,
files,
backendNodeId,
});
}
@throwIfDisposed()
override async autofill(data: AutofillData): Promise<void> {
const nodeInfo = await this.client.send('DOM.describeNode', {
objectId: this.handle.id,
});
const fieldId = nodeInfo.node.backendNodeId;
const frameId = this.frame._id;
await this.client.send('Autofill.trigger', {
fieldId,
frameId,
card: data.creditCard,
});
}
}
+554
View File
@@ -0,0 +1,554 @@
/**
* @license
* Copyright 2017 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type {Protocol} from 'devtools-protocol';
import {type CDPSession, CDPSessionEvent} from '../api/CDPSession.js';
import type {GeolocationOptions, MediaFeature} from '../api/Page.js';
import {debugError} from '../common/util.js';
import type {Viewport} from '../common/Viewport.js';
import {assert} from '../util/assert.js';
import {invokeAtMostOnceForArguments} from '../util/decorators.js';
import {isErrorLike} from '../util/ErrorLike.js';
interface ViewportState {
viewport?: Viewport;
active: boolean;
}
interface IdleOverridesState {
overrides?: {
isUserActive: boolean;
isScreenUnlocked: boolean;
};
active: boolean;
}
interface TimezoneState {
timezoneId?: string;
active: boolean;
}
interface VisionDeficiencyState {
visionDeficiency?: Protocol.Emulation.SetEmulatedVisionDeficiencyRequest['type'];
active: boolean;
}
interface CpuThrottlingState {
factor?: number;
active: boolean;
}
interface MediaFeaturesState {
mediaFeatures?: MediaFeature[];
active: boolean;
}
interface MediaTypeState {
type?: string;
active: boolean;
}
interface GeoLocationState {
geoLocation?: GeolocationOptions;
active: boolean;
}
interface DefaultBackgroundColorState {
color?: Protocol.DOM.RGBA;
active: boolean;
}
interface JavascriptEnabledState {
javaScriptEnabled: boolean;
active: boolean;
}
/**
* @internal
*/
export interface ClientProvider {
clients(): CDPSession[];
registerState(state: EmulatedState<any>): void;
}
/**
* @internal
*/
export class EmulatedState<T extends {active: boolean}> {
#state: T;
#clientProvider: ClientProvider;
#updater: (client: CDPSession, state: T) => Promise<void>;
constructor(
initialState: T,
clientProvider: ClientProvider,
updater: (client: CDPSession, state: T) => Promise<void>
) {
this.#state = initialState;
this.#clientProvider = clientProvider;
this.#updater = updater;
this.#clientProvider.registerState(this);
}
async setState(state: T): Promise<void> {
this.#state = state;
await this.sync();
}
get state(): T {
return this.#state;
}
async sync(): Promise<void> {
await Promise.all(
this.#clientProvider.clients().map(client => {
return this.#updater(client, this.#state);
})
);
}
}
/**
* @internal
*/
export class EmulationManager {
#client: CDPSession;
#emulatingMobile = false;
#hasTouch = false;
#states: Array<EmulatedState<any>> = [];
#viewportState = new EmulatedState<ViewportState>(
{
active: false,
},
this,
this.#applyViewport
);
#idleOverridesState = new EmulatedState<IdleOverridesState>(
{
active: false,
},
this,
this.#emulateIdleState
);
#timezoneState = new EmulatedState<TimezoneState>(
{
active: false,
},
this,
this.#emulateTimezone
);
#visionDeficiencyState = new EmulatedState<VisionDeficiencyState>(
{
active: false,
},
this,
this.#emulateVisionDeficiency
);
#cpuThrottlingState = new EmulatedState<CpuThrottlingState>(
{
active: false,
},
this,
this.#emulateCpuThrottling
);
#mediaFeaturesState = new EmulatedState<MediaFeaturesState>(
{
active: false,
},
this,
this.#emulateMediaFeatures
);
#mediaTypeState = new EmulatedState<MediaTypeState>(
{
active: false,
},
this,
this.#emulateMediaType
);
#geoLocationState = new EmulatedState<GeoLocationState>(
{
active: false,
},
this,
this.#setGeolocation
);
#defaultBackgroundColorState = new EmulatedState<DefaultBackgroundColorState>(
{
active: false,
},
this,
this.#setDefaultBackgroundColor
);
#javascriptEnabledState = new EmulatedState<JavascriptEnabledState>(
{
javaScriptEnabled: true,
active: false,
},
this,
this.#setJavaScriptEnabled
);
#secondaryClients = new Set<CDPSession>();
constructor(client: CDPSession) {
this.#client = client;
}
updateClient(client: CDPSession): void {
this.#client = client;
this.#secondaryClients.delete(client);
}
registerState(state: EmulatedState<any>): void {
this.#states.push(state);
}
clients(): CDPSession[] {
return [this.#client, ...Array.from(this.#secondaryClients)];
}
async registerSpeculativeSession(client: CDPSession): Promise<void> {
this.#secondaryClients.add(client);
client.once(CDPSessionEvent.Disconnected, () => {
this.#secondaryClients.delete(client);
});
// We don't await here because we want to register all state changes before
// the target is unpaused.
void Promise.all(
this.#states.map(s => {
return s.sync().catch(debugError);
})
);
}
get javascriptEnabled(): boolean {
return this.#javascriptEnabledState.state.javaScriptEnabled;
}
async emulateViewport(viewport: Viewport): Promise<boolean> {
await this.#viewportState.setState({
viewport,
active: true,
});
const mobile = viewport.isMobile || false;
const hasTouch = viewport.hasTouch || false;
const reloadNeeded =
this.#emulatingMobile !== mobile || this.#hasTouch !== hasTouch;
this.#emulatingMobile = mobile;
this.#hasTouch = hasTouch;
return reloadNeeded;
}
@invokeAtMostOnceForArguments
async #applyViewport(
client: CDPSession,
viewportState: ViewportState
): Promise<void> {
if (!viewportState.viewport) {
return;
}
const {viewport} = viewportState;
const mobile = viewport.isMobile || false;
const width = viewport.width;
const height = viewport.height;
const deviceScaleFactor = viewport.deviceScaleFactor ?? 1;
const screenOrientation: Protocol.Emulation.ScreenOrientation =
viewport.isLandscape
? {angle: 90, type: 'landscapePrimary'}
: {angle: 0, type: 'portraitPrimary'};
const hasTouch = viewport.hasTouch || false;
await Promise.all([
client.send('Emulation.setDeviceMetricsOverride', {
mobile,
width,
height,
deviceScaleFactor,
screenOrientation,
}),
client.send('Emulation.setTouchEmulationEnabled', {
enabled: hasTouch,
}),
]);
}
async emulateIdleState(overrides?: {
isUserActive: boolean;
isScreenUnlocked: boolean;
}): Promise<void> {
await this.#idleOverridesState.setState({
active: true,
overrides,
});
}
@invokeAtMostOnceForArguments
async #emulateIdleState(
client: CDPSession,
idleStateState: IdleOverridesState
): Promise<void> {
if (!idleStateState.active) {
return;
}
if (idleStateState.overrides) {
await client.send('Emulation.setIdleOverride', {
isUserActive: idleStateState.overrides.isUserActive,
isScreenUnlocked: idleStateState.overrides.isScreenUnlocked,
});
} else {
await client.send('Emulation.clearIdleOverride');
}
}
@invokeAtMostOnceForArguments
async #emulateTimezone(
client: CDPSession,
timezoneState: TimezoneState
): Promise<void> {
if (!timezoneState.active) {
return;
}
try {
await client.send('Emulation.setTimezoneOverride', {
timezoneId: timezoneState.timezoneId || '',
});
} catch (error) {
if (isErrorLike(error) && error.message.includes('Invalid timezone')) {
throw new Error(`Invalid timezone ID: ${timezoneState.timezoneId}`);
}
throw error;
}
}
async emulateTimezone(timezoneId?: string): Promise<void> {
await this.#timezoneState.setState({
timezoneId,
active: true,
});
}
@invokeAtMostOnceForArguments
async #emulateVisionDeficiency(
client: CDPSession,
visionDeficiency: VisionDeficiencyState
): Promise<void> {
if (!visionDeficiency.active) {
return;
}
await client.send('Emulation.setEmulatedVisionDeficiency', {
type: visionDeficiency.visionDeficiency || 'none',
});
}
async emulateVisionDeficiency(
type?: Protocol.Emulation.SetEmulatedVisionDeficiencyRequest['type']
): Promise<void> {
const visionDeficiencies = new Set<
Protocol.Emulation.SetEmulatedVisionDeficiencyRequest['type']
>([
'none',
'achromatopsia',
'blurredVision',
'deuteranopia',
'protanopia',
'tritanopia',
]);
assert(
!type || visionDeficiencies.has(type),
`Unsupported vision deficiency: ${type}`
);
await this.#visionDeficiencyState.setState({
active: true,
visionDeficiency: type,
});
}
@invokeAtMostOnceForArguments
async #emulateCpuThrottling(
client: CDPSession,
state: CpuThrottlingState
): Promise<void> {
if (!state.active) {
return;
}
await client.send('Emulation.setCPUThrottlingRate', {
rate: state.factor ?? 1,
});
}
async emulateCPUThrottling(factor: number | null): Promise<void> {
assert(
factor === null || factor >= 1,
'Throttling rate should be greater or equal to 1'
);
await this.#cpuThrottlingState.setState({
active: true,
factor: factor ?? undefined,
});
}
@invokeAtMostOnceForArguments
async #emulateMediaFeatures(
client: CDPSession,
state: MediaFeaturesState
): Promise<void> {
if (!state.active) {
return;
}
await client.send('Emulation.setEmulatedMedia', {
features: state.mediaFeatures,
});
}
async emulateMediaFeatures(features?: MediaFeature[]): Promise<void> {
if (Array.isArray(features)) {
for (const mediaFeature of features) {
const name = mediaFeature.name;
assert(
/^(?:prefers-(?:color-scheme|reduced-motion)|color-gamut)$/.test(
name
),
'Unsupported media feature: ' + name
);
}
}
await this.#mediaFeaturesState.setState({
active: true,
mediaFeatures: features,
});
}
@invokeAtMostOnceForArguments
async #emulateMediaType(
client: CDPSession,
state: MediaTypeState
): Promise<void> {
if (!state.active) {
return;
}
await client.send('Emulation.setEmulatedMedia', {
media: state.type || '',
});
}
async emulateMediaType(type?: string): Promise<void> {
assert(
type === 'screen' ||
type === 'print' ||
(type ?? undefined) === undefined,
'Unsupported media type: ' + type
);
await this.#mediaTypeState.setState({
type,
active: true,
});
}
@invokeAtMostOnceForArguments
async #setGeolocation(
client: CDPSession,
state: GeoLocationState
): Promise<void> {
if (!state.active) {
return;
}
await client.send(
'Emulation.setGeolocationOverride',
state.geoLocation
? {
longitude: state.geoLocation.longitude,
latitude: state.geoLocation.latitude,
accuracy: state.geoLocation.accuracy,
}
: undefined
);
}
async setGeolocation(options: GeolocationOptions): Promise<void> {
const {longitude, latitude, accuracy = 0} = options;
if (longitude < -180 || longitude > 180) {
throw new Error(
`Invalid longitude "${longitude}": precondition -180 <= LONGITUDE <= 180 failed.`
);
}
if (latitude < -90 || latitude > 90) {
throw new Error(
`Invalid latitude "${latitude}": precondition -90 <= LATITUDE <= 90 failed.`
);
}
if (accuracy < 0) {
throw new Error(
`Invalid accuracy "${accuracy}": precondition 0 <= ACCURACY failed.`
);
}
await this.#geoLocationState.setState({
active: true,
geoLocation: {
longitude,
latitude,
accuracy,
},
});
}
@invokeAtMostOnceForArguments
async #setDefaultBackgroundColor(
client: CDPSession,
state: DefaultBackgroundColorState
): Promise<void> {
if (!state.active) {
return;
}
await client.send('Emulation.setDefaultBackgroundColorOverride', {
color: state.color,
});
}
/**
* Resets default white background
*/
async resetDefaultBackgroundColor(): Promise<void> {
await this.#defaultBackgroundColorState.setState({
active: true,
color: undefined,
});
}
/**
* Hides default white background
*/
async setTransparentBackgroundColor(): Promise<void> {
await this.#defaultBackgroundColorState.setState({
active: true,
color: {r: 0, g: 0, b: 0, a: 0},
});
}
@invokeAtMostOnceForArguments
async #setJavaScriptEnabled(
client: CDPSession,
state: JavascriptEnabledState
): Promise<void> {
if (!state.active) {
return;
}
await client.send('Emulation.setScriptExecutionDisabled', {
value: !state.javaScriptEnabled,
});
}
async setJavaScriptEnabled(enabled: boolean): Promise<void> {
await this.#javascriptEnabledState.setState({
active: true,
javaScriptEnabled: enabled,
});
}
}
+392
View File
@@ -0,0 +1,392 @@
/**
* @license
* Copyright 2017 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type {Protocol} from 'devtools-protocol';
import type {CDPSession} from '../api/CDPSession.js';
import type {ElementHandle} from '../api/ElementHandle.js';
import type {JSHandle} from '../api/JSHandle.js';
import {LazyArg} from '../common/LazyArg.js';
import {scriptInjector} from '../common/ScriptInjector.js';
import type {EvaluateFunc, HandleFor} from '../common/types.js';
import {
PuppeteerURL,
SOURCE_URL_REGEX,
getSourcePuppeteerURLIfAvailable,
getSourceUrlComment,
isString,
} from '../common/util.js';
import type PuppeteerUtil from '../injected/injected.js';
import {AsyncIterableUtil} from '../util/AsyncIterableUtil.js';
import {stringifyFunction} from '../util/Function.js';
import {ARIAQueryHandler} from './AriaQueryHandler.js';
import {Binding} from './Binding.js';
import {CdpElementHandle} from './ElementHandle.js';
import type {IsolatedWorld} from './IsolatedWorld.js';
import {CdpJSHandle} from './JSHandle.js';
import {createEvaluationError, valueFromRemoteObject} from './utils.js';
/**
* @internal
*/
export class ExecutionContext {
_client: CDPSession;
_world: IsolatedWorld;
_contextId: number;
_contextName?: string;
constructor(
client: CDPSession,
contextPayload: Protocol.Runtime.ExecutionContextDescription,
world: IsolatedWorld
) {
this._client = client;
this._world = world;
this._contextId = contextPayload.id;
if (contextPayload.name) {
this._contextName = contextPayload.name;
}
}
#bindingsInstalled = false;
#puppeteerUtil?: Promise<JSHandle<PuppeteerUtil>>;
get puppeteerUtil(): Promise<JSHandle<PuppeteerUtil>> {
let promise = Promise.resolve() as Promise<unknown>;
if (!this.#bindingsInstalled) {
promise = Promise.all([
this.#installGlobalBinding(
new Binding(
'__ariaQuerySelector',
ARIAQueryHandler.queryOne as (...args: unknown[]) => unknown
)
),
this.#installGlobalBinding(
new Binding('__ariaQuerySelectorAll', (async (
element: ElementHandle<Node>,
selector: string
): Promise<JSHandle<Node[]>> => {
const results = ARIAQueryHandler.queryAll(element, selector);
return await element.realm.evaluateHandle(
(...elements) => {
return elements;
},
...(await AsyncIterableUtil.collect(results))
);
}) as (...args: unknown[]) => unknown)
),
]);
this.#bindingsInstalled = true;
}
scriptInjector.inject(script => {
if (this.#puppeteerUtil) {
void this.#puppeteerUtil.then(handle => {
void handle.dispose();
});
}
this.#puppeteerUtil = promise.then(() => {
return this.evaluateHandle(script) as Promise<JSHandle<PuppeteerUtil>>;
});
}, !this.#puppeteerUtil);
return this.#puppeteerUtil as Promise<JSHandle<PuppeteerUtil>>;
}
async #installGlobalBinding(binding: Binding) {
try {
if (this._world) {
this._world._bindings.set(binding.name, binding);
await this._world._addBindingToContext(this, binding.name);
}
} catch {
// If the binding cannot be added, then either the browser doesn't support
// bindings (e.g. Firefox) or the context is broken. Either breakage is
// okay, so we ignore the error.
}
}
/**
* Evaluates the given function.
*
* @example
*
* ```ts
* const executionContext = await page.mainFrame().executionContext();
* const result = await executionContext.evaluate(() => Promise.resolve(8 * 7))* ;
* console.log(result); // prints "56"
* ```
*
* @example
* A string can also be passed in instead of a function:
*
* ```ts
* console.log(await executionContext.evaluate('1 + 2')); // prints "3"
* ```
*
* @example
* Handles can also be passed as `args`. They resolve to their referenced object:
*
* ```ts
* const oneHandle = await executionContext.evaluateHandle(() => 1);
* const twoHandle = await executionContext.evaluateHandle(() => 2);
* const result = await executionContext.evaluate(
* (a, b) => a + b,
* oneHandle,
* twoHandle
* );
* await oneHandle.dispose();
* await twoHandle.dispose();
* console.log(result); // prints '3'.
* ```
*
* @param pageFunction - The function to evaluate.
* @param args - Additional arguments to pass into the function.
* @returns The result of evaluating the function. If the result is an object,
* a vanilla object containing the serializable properties of the result is
* returned.
*/
async evaluate<
Params extends unknown[],
Func extends EvaluateFunc<Params> = EvaluateFunc<Params>,
>(
pageFunction: Func | string,
...args: Params
): Promise<Awaited<ReturnType<Func>>> {
return await this.#evaluate(true, pageFunction, ...args);
}
/**
* Evaluates the given function.
*
* Unlike {@link ExecutionContext.evaluate | evaluate}, this method returns a
* handle to the result of the function.
*
* This method may be better suited if the object cannot be serialized (e.g.
* `Map`) and requires further manipulation.
*
* @example
*
* ```ts
* const context = await page.mainFrame().executionContext();
* const handle: JSHandle<typeof globalThis> = await context.evaluateHandle(
* () => Promise.resolve(self)
* );
* ```
*
* @example
* A string can also be passed in instead of a function.
*
* ```ts
* const handle: JSHandle<number> = await context.evaluateHandle('1 + 2');
* ```
*
* @example
* Handles can also be passed as `args`. They resolve to their referenced object:
*
* ```ts
* const bodyHandle: ElementHandle<HTMLBodyElement> =
* await context.evaluateHandle(() => {
* return document.body;
* });
* const stringHandle: JSHandle<string> = await context.evaluateHandle(
* body => body.innerHTML,
* body
* );
* console.log(await stringHandle.jsonValue()); // prints body's innerHTML
* // Always dispose your garbage! :)
* await bodyHandle.dispose();
* await stringHandle.dispose();
* ```
*
* @param pageFunction - The function to evaluate.
* @param args - Additional arguments to pass into the function.
* @returns A {@link JSHandle | handle} to the result of evaluating the
* function. If the result is a `Node`, then this will return an
* {@link ElementHandle | element handle}.
*/
async evaluateHandle<
Params extends unknown[],
Func extends EvaluateFunc<Params> = EvaluateFunc<Params>,
>(
pageFunction: Func | string,
...args: Params
): Promise<HandleFor<Awaited<ReturnType<Func>>>> {
return await this.#evaluate(false, pageFunction, ...args);
}
async #evaluate<
Params extends unknown[],
Func extends EvaluateFunc<Params> = EvaluateFunc<Params>,
>(
returnByValue: true,
pageFunction: Func | string,
...args: Params
): Promise<Awaited<ReturnType<Func>>>;
async #evaluate<
Params extends unknown[],
Func extends EvaluateFunc<Params> = EvaluateFunc<Params>,
>(
returnByValue: false,
pageFunction: Func | string,
...args: Params
): Promise<HandleFor<Awaited<ReturnType<Func>>>>;
async #evaluate<
Params extends unknown[],
Func extends EvaluateFunc<Params> = EvaluateFunc<Params>,
>(
returnByValue: boolean,
pageFunction: Func | string,
...args: Params
): Promise<HandleFor<Awaited<ReturnType<Func>>> | Awaited<ReturnType<Func>>> {
const sourceUrlComment = getSourceUrlComment(
getSourcePuppeteerURLIfAvailable(pageFunction)?.toString() ??
PuppeteerURL.INTERNAL_URL
);
if (isString(pageFunction)) {
const contextId = this._contextId;
const expression = pageFunction;
const expressionWithSourceUrl = SOURCE_URL_REGEX.test(expression)
? expression
: `${expression}\n${sourceUrlComment}\n`;
const {exceptionDetails, result: remoteObject} = await this._client
.send('Runtime.evaluate', {
expression: expressionWithSourceUrl,
contextId,
returnByValue,
awaitPromise: true,
userGesture: true,
})
.catch(rewriteError);
if (exceptionDetails) {
throw createEvaluationError(exceptionDetails);
}
return returnByValue
? valueFromRemoteObject(remoteObject)
: createCdpHandle(this._world, remoteObject);
}
const functionDeclaration = stringifyFunction(pageFunction);
const functionDeclarationWithSourceUrl = SOURCE_URL_REGEX.test(
functionDeclaration
)
? functionDeclaration
: `${functionDeclaration}\n${sourceUrlComment}\n`;
let callFunctionOnPromise;
try {
callFunctionOnPromise = this._client.send('Runtime.callFunctionOn', {
functionDeclaration: functionDeclarationWithSourceUrl,
executionContextId: this._contextId,
arguments: args.length
? await Promise.all(args.map(convertArgument.bind(this)))
: [],
returnByValue,
awaitPromise: true,
userGesture: true,
});
} catch (error) {
if (
error instanceof TypeError &&
error.message.startsWith('Converting circular structure to JSON')
) {
error.message += ' Recursive objects are not allowed.';
}
throw error;
}
const {exceptionDetails, result: remoteObject} =
await callFunctionOnPromise.catch(rewriteError);
if (exceptionDetails) {
throw createEvaluationError(exceptionDetails);
}
return returnByValue
? valueFromRemoteObject(remoteObject)
: createCdpHandle(this._world, remoteObject);
async function convertArgument(
this: ExecutionContext,
arg: unknown
): Promise<Protocol.Runtime.CallArgument> {
if (arg instanceof LazyArg) {
arg = await arg.get(this);
}
if (typeof arg === 'bigint') {
// eslint-disable-line valid-typeof
return {unserializableValue: `${arg.toString()}n`};
}
if (Object.is(arg, -0)) {
return {unserializableValue: '-0'};
}
if (Object.is(arg, Infinity)) {
return {unserializableValue: 'Infinity'};
}
if (Object.is(arg, -Infinity)) {
return {unserializableValue: '-Infinity'};
}
if (Object.is(arg, NaN)) {
return {unserializableValue: 'NaN'};
}
const objectHandle =
arg && (arg instanceof CdpJSHandle || arg instanceof CdpElementHandle)
? arg
: null;
if (objectHandle) {
if (objectHandle.realm !== this._world) {
throw new Error(
'JSHandles can be evaluated only in the context they were created!'
);
}
if (objectHandle.disposed) {
throw new Error('JSHandle is disposed!');
}
if (objectHandle.remoteObject().unserializableValue) {
return {
unserializableValue:
objectHandle.remoteObject().unserializableValue,
};
}
if (!objectHandle.remoteObject().objectId) {
return {value: objectHandle.remoteObject().value};
}
return {objectId: objectHandle.remoteObject().objectId};
}
return {value: arg};
}
}
}
const rewriteError = (error: Error): Protocol.Runtime.EvaluateResponse => {
if (error.message.includes('Object reference chain is too long')) {
return {result: {type: 'undefined'}};
}
if (error.message.includes("Object couldn't be returned by value")) {
return {result: {type: 'undefined'}};
}
if (
error.message.endsWith('Cannot find context with specified id') ||
error.message.endsWith('Inspected target navigated or closed')
) {
throw new Error(
'Execution context was destroyed, most likely because of a navigation.'
);
}
throw error;
};
/**
* @internal
*/
export function createCdpHandle(
realm: IsolatedWorld,
remoteObject: Protocol.Runtime.RemoteObject
): JSHandle | ElementHandle<Node> {
if (remoteObject.subtype === 'node') {
return new CdpElementHandle(realm, remoteObject);
}
return new CdpJSHandle(realm, remoteObject);
}
+210
View File
@@ -0,0 +1,210 @@
/**
* @license
* Copyright 2022 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type {Protocol} from 'devtools-protocol';
import type {TargetFilterCallback} from '../api/Browser.js';
import {type CDPSession, CDPSessionEvent} from '../api/CDPSession.js';
import {EventEmitter} from '../common/EventEmitter.js';
import {assert} from '../util/assert.js';
import {Deferred} from '../util/Deferred.js';
import type {CdpCDPSession} from './CDPSession.js';
import type {Connection} from './Connection.js';
import type {CdpTarget} from './Target.js';
import {
type TargetFactory,
TargetManagerEvent,
type TargetManager,
type TargetManagerEvents,
} from './TargetManager.js';
/**
* FirefoxTargetManager implements target management using
* `Target.setDiscoverTargets` without using auto-attach. It, therefore, creates
* targets that lazily establish their CDP sessions.
*
* Although the approach is potentially flaky, there is no other way for Firefox
* because Firefox's CDP implementation does not support auto-attach.
*
* Firefox does not support targetInfoChanged and detachedFromTarget events:
*
* - https://bugzilla.mozilla.org/show_bug.cgi?id=1610855
* - https://bugzilla.mozilla.org/show_bug.cgi?id=1636979
* @internal
*/
export class FirefoxTargetManager
extends EventEmitter<TargetManagerEvents>
implements TargetManager
{
#connection: Connection;
/**
* Keeps track of the following events: 'Target.targetCreated',
* 'Target.targetDestroyed'.
*
* A target becomes discovered when 'Target.targetCreated' is received.
* A target is removed from this map once 'Target.targetDestroyed' is
* received.
*
* `targetFilterCallback` has no effect on this map.
*/
#discoveredTargetsByTargetId = new Map<string, Protocol.Target.TargetInfo>();
/**
* Keeps track of targets that were created via 'Target.targetCreated'
* and which one are not filtered out by `targetFilterCallback`.
*
* The target is removed from here once it's been destroyed.
*/
#availableTargetsByTargetId = new Map<string, CdpTarget>();
/**
* Tracks which sessions attach to which target.
*/
#availableTargetsBySessionId = new Map<string, CdpTarget>();
#targetFilterCallback: TargetFilterCallback | undefined;
#targetFactory: TargetFactory;
#attachedToTargetListenersBySession = new WeakMap<
CDPSession | Connection,
(event: Protocol.Target.AttachedToTargetEvent) => Promise<void>
>();
#initializeDeferred = Deferred.create<void>();
#targetsIdsForInit = new Set<string>();
constructor(
connection: Connection,
targetFactory: TargetFactory,
targetFilterCallback?: TargetFilterCallback
) {
super();
this.#connection = connection;
this.#targetFilterCallback = targetFilterCallback;
this.#targetFactory = targetFactory;
this.#connection.on('Target.targetCreated', this.#onTargetCreated);
this.#connection.on('Target.targetDestroyed', this.#onTargetDestroyed);
this.#connection.on(
CDPSessionEvent.SessionDetached,
this.#onSessionDetached
);
this.setupAttachmentListeners(this.#connection);
}
setupAttachmentListeners(session: CDPSession | Connection): void {
const listener = (event: Protocol.Target.AttachedToTargetEvent) => {
return this.#onAttachedToTarget(session, event);
};
assert(!this.#attachedToTargetListenersBySession.has(session));
this.#attachedToTargetListenersBySession.set(session, listener);
session.on('Target.attachedToTarget', listener);
}
#onSessionDetached = (session: CDPSession) => {
this.removeSessionListeners(session);
this.#availableTargetsBySessionId.delete(session.id());
};
removeSessionListeners(session: CDPSession): void {
if (this.#attachedToTargetListenersBySession.has(session)) {
session.off(
'Target.attachedToTarget',
this.#attachedToTargetListenersBySession.get(session)!
);
this.#attachedToTargetListenersBySession.delete(session);
}
}
getAvailableTargets(): ReadonlyMap<string, CdpTarget> {
return this.#availableTargetsByTargetId;
}
dispose(): void {
this.#connection.off('Target.targetCreated', this.#onTargetCreated);
this.#connection.off('Target.targetDestroyed', this.#onTargetDestroyed);
}
async initialize(): Promise<void> {
await this.#connection.send('Target.setDiscoverTargets', {
discover: true,
filter: [{}],
});
this.#targetsIdsForInit = new Set(this.#discoveredTargetsByTargetId.keys());
await this.#initializeDeferred.valueOrThrow();
}
#onTargetCreated = async (
event: Protocol.Target.TargetCreatedEvent
): Promise<void> => {
if (this.#discoveredTargetsByTargetId.has(event.targetInfo.targetId)) {
return;
}
this.#discoveredTargetsByTargetId.set(
event.targetInfo.targetId,
event.targetInfo
);
if (event.targetInfo.type === 'browser' && event.targetInfo.attached) {
const target = this.#targetFactory(event.targetInfo, undefined);
target._initialize();
this.#availableTargetsByTargetId.set(event.targetInfo.targetId, target);
this.#finishInitializationIfReady(target._targetId);
return;
}
const target = this.#targetFactory(event.targetInfo, undefined);
if (this.#targetFilterCallback && !this.#targetFilterCallback(target)) {
this.#finishInitializationIfReady(event.targetInfo.targetId);
return;
}
target._initialize();
this.#availableTargetsByTargetId.set(event.targetInfo.targetId, target);
this.emit(TargetManagerEvent.TargetAvailable, target);
this.#finishInitializationIfReady(target._targetId);
};
#onTargetDestroyed = (event: Protocol.Target.TargetDestroyedEvent): void => {
this.#discoveredTargetsByTargetId.delete(event.targetId);
this.#finishInitializationIfReady(event.targetId);
const target = this.#availableTargetsByTargetId.get(event.targetId);
if (target) {
this.emit(TargetManagerEvent.TargetGone, target);
this.#availableTargetsByTargetId.delete(event.targetId);
}
};
#onAttachedToTarget = async (
parentSession: Connection | CDPSession,
event: Protocol.Target.AttachedToTargetEvent
) => {
const targetInfo = event.targetInfo;
const session = this.#connection.session(event.sessionId);
if (!session) {
throw new Error(`Session ${event.sessionId} was not created.`);
}
const target = this.#availableTargetsByTargetId.get(targetInfo.targetId);
assert(target, `Target ${targetInfo.targetId} is missing`);
(session as CdpCDPSession)._setTarget(target);
this.setupAttachmentListeners(session);
this.#availableTargetsBySessionId.set(
session.id(),
this.#availableTargetsByTargetId.get(targetInfo.targetId)!
);
parentSession.emit(CDPSessionEvent.Ready, session);
};
#finishInitializationIfReady(targetId: string): void {
this.#targetsIdsForInit.delete(targetId);
if (this.#targetsIdsForInit.size === 0) {
this.#initializeDeferred.resolve();
}
}
}
+351
View File
@@ -0,0 +1,351 @@
/**
* @license
* Copyright 2017 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type {Protocol} from 'devtools-protocol';
import type {CDPSession} from '../api/CDPSession.js';
import {Frame, FrameEvent, throwIfDetached} from '../api/Frame.js';
import type {HTTPResponse} from '../api/HTTPResponse.js';
import type {WaitTimeoutOptions} from '../api/Page.js';
import {UnsupportedOperation} from '../common/Errors.js';
import {Deferred} from '../util/Deferred.js';
import {disposeSymbol} from '../util/disposable.js';
import {isErrorLike} from '../util/ErrorLike.js';
import type {
DeviceRequestPrompt,
DeviceRequestPromptManager,
} from './DeviceRequestPrompt.js';
import type {FrameManager} from './FrameManager.js';
import {IsolatedWorld} from './IsolatedWorld.js';
import {MAIN_WORLD, PUPPETEER_WORLD} from './IsolatedWorlds.js';
import {
LifecycleWatcher,
type PuppeteerLifeCycleEvent,
} from './LifecycleWatcher.js';
import type {CdpPage} from './Page.js';
/**
* @internal
*/
export class CdpFrame extends Frame {
#url = '';
#detached = false;
#client!: CDPSession;
_frameManager: FrameManager;
override _id: string;
_loaderId = '';
_lifecycleEvents = new Set<string>();
override _parentId?: string;
constructor(
frameManager: FrameManager,
frameId: string,
parentFrameId: string | undefined,
client: CDPSession
) {
super();
this._frameManager = frameManager;
this.#url = '';
this._id = frameId;
this._parentId = parentFrameId;
this.#detached = false;
this._loaderId = '';
this.updateClient(client);
this.on(FrameEvent.FrameSwappedByActivation, () => {
// Emulate loading process for swapped frames.
this._onLoadingStarted();
this._onLoadingStopped();
});
}
/**
* This is used internally in DevTools.
*
* @internal
*/
_client(): CDPSession {
return this.#client;
}
/**
* Updates the frame ID with the new ID. This happens when the main frame is
* replaced by a different frame.
*/
updateId(id: string): void {
this._id = id;
}
updateClient(client: CDPSession, keepWorlds = false): void {
this.#client = client;
if (!keepWorlds) {
// Clear the current contexts on previous world instances.
if (this.worlds) {
this.worlds[MAIN_WORLD].clearContext();
this.worlds[PUPPETEER_WORLD].clearContext();
}
this.worlds = {
[MAIN_WORLD]: new IsolatedWorld(
this,
this._frameManager.timeoutSettings
),
[PUPPETEER_WORLD]: new IsolatedWorld(
this,
this._frameManager.timeoutSettings
),
};
} else {
this.worlds[MAIN_WORLD].frameUpdated();
this.worlds[PUPPETEER_WORLD].frameUpdated();
}
}
override page(): CdpPage {
return this._frameManager.page();
}
override isOOPFrame(): boolean {
return this.#client !== this._frameManager.client;
}
@throwIfDetached
override async goto(
url: string,
options: {
referer?: string;
referrerPolicy?: string;
timeout?: number;
waitUntil?: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[];
} = {}
): Promise<HTTPResponse | null> {
const {
referer = this._frameManager.networkManager.extraHTTPHeaders()['referer'],
referrerPolicy = this._frameManager.networkManager.extraHTTPHeaders()[
'referer-policy'
],
waitUntil = ['load'],
timeout = this._frameManager.timeoutSettings.navigationTimeout(),
} = options;
let ensureNewDocumentNavigation = false;
const watcher = new LifecycleWatcher(
this._frameManager.networkManager,
this,
waitUntil,
timeout
);
let error = await Deferred.race([
navigate(
this.#client,
url,
referer,
referrerPolicy as Protocol.Page.ReferrerPolicy,
this._id
),
watcher.terminationPromise(),
]);
if (!error) {
error = await Deferred.race([
watcher.terminationPromise(),
ensureNewDocumentNavigation
? watcher.newDocumentNavigationPromise()
: watcher.sameDocumentNavigationPromise(),
]);
}
try {
if (error) {
throw error;
}
return await watcher.navigationResponse();
} finally {
watcher.dispose();
}
async function navigate(
client: CDPSession,
url: string,
referrer: string | undefined,
referrerPolicy: Protocol.Page.ReferrerPolicy | undefined,
frameId: string
): Promise<Error | null> {
try {
const response = await client.send('Page.navigate', {
url,
referrer,
frameId,
referrerPolicy,
});
ensureNewDocumentNavigation = !!response.loaderId;
if (response.errorText === 'net::ERR_HTTP_RESPONSE_CODE_FAILURE') {
return null;
}
return response.errorText
? new Error(`${response.errorText} at ${url}`)
: null;
} catch (error) {
if (isErrorLike(error)) {
return error;
}
throw error;
}
}
}
@throwIfDetached
override async waitForNavigation(
options: {
timeout?: number;
waitUntil?: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[];
} = {}
): Promise<HTTPResponse | null> {
const {
waitUntil = ['load'],
timeout = this._frameManager.timeoutSettings.navigationTimeout(),
} = options;
const watcher = new LifecycleWatcher(
this._frameManager.networkManager,
this,
waitUntil,
timeout
);
const error = await Deferred.race([
watcher.terminationPromise(),
watcher.sameDocumentNavigationPromise(),
watcher.newDocumentNavigationPromise(),
]);
try {
if (error) {
throw error;
}
return await watcher.navigationResponse();
} finally {
watcher.dispose();
}
}
override get client(): CDPSession {
return this.#client;
}
override mainRealm(): IsolatedWorld {
return this.worlds[MAIN_WORLD];
}
override isolatedRealm(): IsolatedWorld {
return this.worlds[PUPPETEER_WORLD];
}
@throwIfDetached
override async setContent(
html: string,
options: {
timeout?: number;
waitUntil?: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[];
} = {}
): Promise<void> {
const {
waitUntil = ['load'],
timeout = this._frameManager.timeoutSettings.navigationTimeout(),
} = options;
// We rely upon the fact that document.open() will reset frame lifecycle with "init"
// lifecycle event. @see https://crrev.com/608658
await this.setFrameContent(html);
const watcher = new LifecycleWatcher(
this._frameManager.networkManager,
this,
waitUntil,
timeout
);
const error = await Deferred.race<void | Error | undefined>([
watcher.terminationPromise(),
watcher.lifecyclePromise(),
]);
watcher.dispose();
if (error) {
throw error;
}
}
override url(): string {
return this.#url;
}
override parentFrame(): CdpFrame | null {
return this._frameManager._frameTree.parentFrame(this._id) || null;
}
override childFrames(): CdpFrame[] {
return this._frameManager._frameTree.childFrames(this._id);
}
#deviceRequestPromptManager(): DeviceRequestPromptManager {
const rootFrame = this.page().mainFrame();
if (this.isOOPFrame() || rootFrame === null) {
return this._frameManager._deviceRequestPromptManager(this.#client);
} else {
return rootFrame._frameManager._deviceRequestPromptManager(this.#client);
}
}
@throwIfDetached
override async waitForDevicePrompt(
options: WaitTimeoutOptions = {}
): Promise<DeviceRequestPrompt> {
return await this.#deviceRequestPromptManager().waitForDevicePrompt(
options
);
}
_navigated(framePayload: Protocol.Page.Frame): void {
this._name = framePayload.name;
this.#url = `${framePayload.url}${framePayload.urlFragment || ''}`;
}
_navigatedWithinDocument(url: string): void {
this.#url = url;
}
_onLifecycleEvent(loaderId: string, name: string): void {
if (name === 'init') {
this._loaderId = loaderId;
this._lifecycleEvents.clear();
}
this._lifecycleEvents.add(name);
}
_onLoadingStopped(): void {
this._lifecycleEvents.add('DOMContentLoaded');
this._lifecycleEvents.add('load');
}
_onLoadingStarted(): void {
this._hasStartedLoading = true;
}
override get detached(): boolean {
return this.#detached;
}
[disposeSymbol](): void {
if (this.#detached) {
return;
}
this.#detached = true;
this.worlds[MAIN_WORLD][disposeSymbol]();
this.worlds[PUPPETEER_WORLD][disposeSymbol]();
}
exposeFunction(): never {
throw new UnsupportedOperation();
}
}
+551
View File
@@ -0,0 +1,551 @@
/**
* @license
* Copyright 2017 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type {Protocol} from 'devtools-protocol';
import {type CDPSession, CDPSessionEvent} from '../api/CDPSession.js';
import {FrameEvent} from '../api/Frame.js';
import {EventEmitter} from '../common/EventEmitter.js';
import type {TimeoutSettings} from '../common/TimeoutSettings.js';
import {debugError, PuppeteerURL, UTILITY_WORLD_NAME} from '../common/util.js';
import {assert} from '../util/assert.js';
import {Deferred} from '../util/Deferred.js';
import {disposeSymbol} from '../util/disposable.js';
import {isErrorLike} from '../util/ErrorLike.js';
import {CdpCDPSession} from './CDPSession.js';
import {isTargetClosedError} from './Connection.js';
import {DeviceRequestPromptManager} from './DeviceRequestPrompt.js';
import {ExecutionContext} from './ExecutionContext.js';
import {CdpFrame} from './Frame.js';
import type {FrameManagerEvents} from './FrameManagerEvents.js';
import {FrameManagerEvent} from './FrameManagerEvents.js';
import {FrameTree} from './FrameTree.js';
import type {IsolatedWorld} from './IsolatedWorld.js';
import {MAIN_WORLD, PUPPETEER_WORLD} from './IsolatedWorlds.js';
import {NetworkManager} from './NetworkManager.js';
import type {CdpPage} from './Page.js';
import type {CdpTarget} from './Target.js';
const TIME_FOR_WAITING_FOR_SWAP = 100; // ms.
/**
* A frame manager manages the frames for a given {@link Page | page}.
*
* @internal
*/
export class FrameManager extends EventEmitter<FrameManagerEvents> {
#page: CdpPage;
#networkManager: NetworkManager;
#timeoutSettings: TimeoutSettings;
#contextIdToContext = new Map<string, ExecutionContext>();
#isolatedWorlds = new Set<string>();
#client: CDPSession;
_frameTree = new FrameTree<CdpFrame>();
/**
* Set of frame IDs stored to indicate if a frame has received a
* frameNavigated event so that frame tree responses could be ignored as the
* frameNavigated event usually contains the latest information.
*/
#frameNavigatedReceived = new Set<string>();
#deviceRequestPromptManagerMap = new WeakMap<
CDPSession,
DeviceRequestPromptManager
>();
#frameTreeHandled?: Deferred<void>;
get timeoutSettings(): TimeoutSettings {
return this.#timeoutSettings;
}
get networkManager(): NetworkManager {
return this.#networkManager;
}
get client(): CDPSession {
return this.#client;
}
constructor(
client: CDPSession,
page: CdpPage,
ignoreHTTPSErrors: boolean,
timeoutSettings: TimeoutSettings
) {
super();
this.#client = client;
this.#page = page;
this.#networkManager = new NetworkManager(ignoreHTTPSErrors, this);
this.#timeoutSettings = timeoutSettings;
this.setupEventListeners(this.#client);
client.once(CDPSessionEvent.Disconnected, () => {
this.#onClientDisconnect().catch(debugError);
});
}
/**
* Called when the frame's client is disconnected. We don't know if the
* disconnect means that the frame is removed or if it will be replaced by a
* new frame. Therefore, we wait for a swap event.
*/
async #onClientDisconnect() {
const mainFrame = this._frameTree.getMainFrame();
if (!mainFrame) {
return;
}
for (const child of mainFrame.childFrames()) {
this.#removeFramesRecursively(child);
}
const swapped = Deferred.create<void>({
timeout: TIME_FOR_WAITING_FOR_SWAP,
message: 'Frame was not swapped',
});
mainFrame.once(FrameEvent.FrameSwappedByActivation, () => {
swapped.resolve();
});
try {
await swapped.valueOrThrow();
} catch (err) {
this.#removeFramesRecursively(mainFrame);
}
}
/**
* When the main frame is replaced by another main frame,
* we maintain the main frame object identity while updating
* its frame tree and ID.
*/
async swapFrameTree(client: CDPSession): Promise<void> {
this.#onExecutionContextsCleared(this.#client);
this.#client = client;
assert(
this.#client instanceof CdpCDPSession,
'CDPSession is not an instance of CDPSessionImpl.'
);
const frame = this._frameTree.getMainFrame();
if (frame) {
this.#frameNavigatedReceived.add(this.#client._target()._targetId);
this._frameTree.removeFrame(frame);
frame.updateId(this.#client._target()._targetId);
frame.mainRealm().clearContext();
frame.isolatedRealm().clearContext();
this._frameTree.addFrame(frame);
frame.updateClient(client, true);
}
this.setupEventListeners(client);
client.once(CDPSessionEvent.Disconnected, () => {
this.#onClientDisconnect().catch(debugError);
});
await this.initialize(client);
await this.#networkManager.addClient(client);
if (frame) {
frame.emit(FrameEvent.FrameSwappedByActivation, undefined);
}
}
async registerSpeculativeSession(client: CdpCDPSession): Promise<void> {
await this.#networkManager.addClient(client);
}
private setupEventListeners(session: CDPSession) {
session.on('Page.frameAttached', async event => {
await this.#frameTreeHandled?.valueOrThrow();
this.#onFrameAttached(session, event.frameId, event.parentFrameId);
});
session.on('Page.frameNavigated', async event => {
this.#frameNavigatedReceived.add(event.frame.id);
await this.#frameTreeHandled?.valueOrThrow();
void this.#onFrameNavigated(event.frame, event.type);
});
session.on('Page.navigatedWithinDocument', async event => {
await this.#frameTreeHandled?.valueOrThrow();
this.#onFrameNavigatedWithinDocument(event.frameId, event.url);
});
session.on(
'Page.frameDetached',
async (event: Protocol.Page.FrameDetachedEvent) => {
await this.#frameTreeHandled?.valueOrThrow();
this.#onFrameDetached(
event.frameId,
event.reason as Protocol.Page.FrameDetachedEventReason
);
}
);
session.on('Page.frameStartedLoading', async event => {
await this.#frameTreeHandled?.valueOrThrow();
this.#onFrameStartedLoading(event.frameId);
});
session.on('Page.frameStoppedLoading', async event => {
await this.#frameTreeHandled?.valueOrThrow();
this.#onFrameStoppedLoading(event.frameId);
});
session.on('Runtime.executionContextCreated', async event => {
await this.#frameTreeHandled?.valueOrThrow();
this.#onExecutionContextCreated(event.context, session);
});
session.on('Runtime.executionContextDestroyed', async event => {
await this.#frameTreeHandled?.valueOrThrow();
this.#onExecutionContextDestroyed(event.executionContextId, session);
});
session.on('Runtime.executionContextsCleared', async () => {
await this.#frameTreeHandled?.valueOrThrow();
this.#onExecutionContextsCleared(session);
});
session.on('Page.lifecycleEvent', async event => {
await this.#frameTreeHandled?.valueOrThrow();
this.#onLifecycleEvent(event);
});
}
async initialize(client: CDPSession): Promise<void> {
try {
this.#frameTreeHandled?.resolve();
this.#frameTreeHandled = Deferred.create();
// We need to schedule all these commands while the target is paused,
// therefore, it needs to happen synchroniously. At the same time we
// should not start processing execution context and frame events before
// we received the initial information about the frame tree.
await Promise.all([
this.#networkManager.addClient(client),
client.send('Page.enable'),
client.send('Page.getFrameTree').then(({frameTree}) => {
this.#handleFrameTree(client, frameTree);
this.#frameTreeHandled?.resolve();
}),
client.send('Page.setLifecycleEventsEnabled', {enabled: true}),
client.send('Runtime.enable').then(() => {
return this.#createIsolatedWorld(client, UTILITY_WORLD_NAME);
}),
]);
} catch (error) {
this.#frameTreeHandled?.resolve();
// The target might have been closed before the initialization finished.
if (isErrorLike(error) && isTargetClosedError(error)) {
return;
}
throw error;
}
}
executionContextById(
contextId: number,
session: CDPSession = this.#client
): ExecutionContext {
const context = this.getExecutionContextById(contextId, session);
assert(context, 'INTERNAL ERROR: missing context with id = ' + contextId);
return context;
}
getExecutionContextById(
contextId: number,
session: CDPSession = this.#client
): ExecutionContext | undefined {
return this.#contextIdToContext.get(`${session.id()}:${contextId}`);
}
page(): CdpPage {
return this.#page;
}
mainFrame(): CdpFrame {
const mainFrame = this._frameTree.getMainFrame();
assert(mainFrame, 'Requesting main frame too early!');
return mainFrame;
}
frames(): CdpFrame[] {
return Array.from(this._frameTree.frames());
}
frame(frameId: string): CdpFrame | null {
return this._frameTree.getById(frameId) || null;
}
onAttachedToTarget(target: CdpTarget): void {
if (target._getTargetInfo().type !== 'iframe') {
return;
}
const frame = this.frame(target._getTargetInfo().targetId);
if (frame) {
frame.updateClient(target._session()!);
}
this.setupEventListeners(target._session()!);
void this.initialize(target._session()!);
}
_deviceRequestPromptManager(client: CDPSession): DeviceRequestPromptManager {
let manager = this.#deviceRequestPromptManagerMap.get(client);
if (manager === undefined) {
manager = new DeviceRequestPromptManager(client, this.#timeoutSettings);
this.#deviceRequestPromptManagerMap.set(client, manager);
}
return manager;
}
#onLifecycleEvent(event: Protocol.Page.LifecycleEventEvent): void {
const frame = this.frame(event.frameId);
if (!frame) {
return;
}
frame._onLifecycleEvent(event.loaderId, event.name);
this.emit(FrameManagerEvent.LifecycleEvent, frame);
frame.emit(FrameEvent.LifecycleEvent, undefined);
}
#onFrameStartedLoading(frameId: string): void {
const frame = this.frame(frameId);
if (!frame) {
return;
}
frame._onLoadingStarted();
}
#onFrameStoppedLoading(frameId: string): void {
const frame = this.frame(frameId);
if (!frame) {
return;
}
frame._onLoadingStopped();
this.emit(FrameManagerEvent.LifecycleEvent, frame);
frame.emit(FrameEvent.LifecycleEvent, undefined);
}
#handleFrameTree(
session: CDPSession,
frameTree: Protocol.Page.FrameTree
): void {
if (frameTree.frame.parentId) {
this.#onFrameAttached(
session,
frameTree.frame.id,
frameTree.frame.parentId
);
}
if (!this.#frameNavigatedReceived.has(frameTree.frame.id)) {
void this.#onFrameNavigated(frameTree.frame, 'Navigation');
} else {
this.#frameNavigatedReceived.delete(frameTree.frame.id);
}
if (!frameTree.childFrames) {
return;
}
for (const child of frameTree.childFrames) {
this.#handleFrameTree(session, child);
}
}
#onFrameAttached(
session: CDPSession,
frameId: string,
parentFrameId: string
): void {
let frame = this.frame(frameId);
if (frame) {
if (session && frame.isOOPFrame()) {
// If an OOP iframes becomes a normal iframe again
// it is first attached to the parent page before
// the target is removed.
frame.updateClient(session);
}
return;
}
frame = new CdpFrame(this, frameId, parentFrameId, session);
this._frameTree.addFrame(frame);
this.emit(FrameManagerEvent.FrameAttached, frame);
}
async #onFrameNavigated(
framePayload: Protocol.Page.Frame,
navigationType: Protocol.Page.NavigationType
): Promise<void> {
const frameId = framePayload.id;
const isMainFrame = !framePayload.parentId;
let frame = this._frameTree.getById(frameId);
// Detach all child frames first.
if (frame) {
for (const child of frame.childFrames()) {
this.#removeFramesRecursively(child);
}
}
// Update or create main frame.
if (isMainFrame) {
if (frame) {
// Update frame id to retain frame identity on cross-process navigation.
this._frameTree.removeFrame(frame);
frame._id = frameId;
} else {
// Initial main frame navigation.
frame = new CdpFrame(this, frameId, undefined, this.#client);
}
this._frameTree.addFrame(frame);
}
frame = await this._frameTree.waitForFrame(frameId);
frame._navigated(framePayload);
this.emit(FrameManagerEvent.FrameNavigated, frame);
frame.emit(FrameEvent.FrameNavigated, navigationType);
}
async #createIsolatedWorld(session: CDPSession, name: string): Promise<void> {
const key = `${session.id()}:${name}`;
if (this.#isolatedWorlds.has(key)) {
return;
}
await session.send('Page.addScriptToEvaluateOnNewDocument', {
source: `//# sourceURL=${PuppeteerURL.INTERNAL_URL}`,
worldName: name,
});
await Promise.all(
this.frames()
.filter(frame => {
return frame.client === session;
})
.map(frame => {
// Frames might be removed before we send this, so we don't want to
// throw an error.
return session
.send('Page.createIsolatedWorld', {
frameId: frame._id,
worldName: name,
grantUniveralAccess: true,
})
.catch(debugError);
})
);
this.#isolatedWorlds.add(key);
}
#onFrameNavigatedWithinDocument(frameId: string, url: string): void {
const frame = this.frame(frameId);
if (!frame) {
return;
}
frame._navigatedWithinDocument(url);
this.emit(FrameManagerEvent.FrameNavigatedWithinDocument, frame);
frame.emit(FrameEvent.FrameNavigatedWithinDocument, undefined);
this.emit(FrameManagerEvent.FrameNavigated, frame);
frame.emit(FrameEvent.FrameNavigated, 'Navigation');
}
#onFrameDetached(
frameId: string,
reason: Protocol.Page.FrameDetachedEventReason
): void {
const frame = this.frame(frameId);
if (!frame) {
return;
}
switch (reason) {
case 'remove':
// Only remove the frame if the reason for the detached event is
// an actual removement of the frame.
// For frames that become OOP iframes, the reason would be 'swap'.
this.#removeFramesRecursively(frame);
break;
case 'swap':
this.emit(FrameManagerEvent.FrameSwapped, frame);
frame.emit(FrameEvent.FrameSwapped, undefined);
break;
}
}
#onExecutionContextCreated(
contextPayload: Protocol.Runtime.ExecutionContextDescription,
session: CDPSession
): void {
const auxData = contextPayload.auxData as {frameId?: string} | undefined;
const frameId = auxData && auxData.frameId;
const frame = typeof frameId === 'string' ? this.frame(frameId) : undefined;
let world: IsolatedWorld | undefined;
if (frame) {
// Only care about execution contexts created for the current session.
if (frame.client !== session) {
return;
}
if (contextPayload.auxData && contextPayload.auxData['isDefault']) {
world = frame.worlds[MAIN_WORLD];
} else if (
contextPayload.name === UTILITY_WORLD_NAME &&
!frame.worlds[PUPPETEER_WORLD].hasContext()
) {
// In case of multiple sessions to the same target, there's a race between
// connections so we might end up creating multiple isolated worlds.
// We can use either.
world = frame.worlds[PUPPETEER_WORLD];
}
}
// If there is no world, the context is not meant to be handled by us.
if (!world) {
return;
}
const context = new ExecutionContext(
frame?.client || this.#client,
contextPayload,
world
);
if (world) {
world.setContext(context);
}
const key = `${session.id()}:${contextPayload.id}`;
this.#contextIdToContext.set(key, context);
}
#onExecutionContextDestroyed(
executionContextId: number,
session: CDPSession
): void {
const key = `${session.id()}:${executionContextId}`;
const context = this.#contextIdToContext.get(key);
if (!context) {
return;
}
this.#contextIdToContext.delete(key);
if (context._world) {
context._world.clearContext();
}
}
#onExecutionContextsCleared(session: CDPSession): void {
for (const [key, context] of this.#contextIdToContext.entries()) {
// Make sure to only clear execution contexts that belong
// to the current session.
if (context._client !== session) {
continue;
}
if (context._world) {
context._world.clearContext();
}
this.#contextIdToContext.delete(key);
}
}
#removeFramesRecursively(frame: CdpFrame): void {
for (const child of frame.childFrames()) {
this.#removeFramesRecursively(child);
}
frame[disposeSymbol]();
this._frameTree.removeFrame(frame);
this.emit(FrameManagerEvent.FrameDetached, frame);
frame.emit(FrameEvent.FrameDetached, frame);
}
}
+39
View File
@@ -0,0 +1,39 @@
/**
* @license
* Copyright 2023 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type {EventType} from '../common/EventEmitter.js';
import type {CdpFrame} from './Frame.js';
/**
* We use symbols to prevent external parties listening to these events.
* They are internal to Puppeteer.
*
* @internal
*/
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace FrameManagerEvent {
export const FrameAttached = Symbol('FrameManager.FrameAttached');
export const FrameNavigated = Symbol('FrameManager.FrameNavigated');
export const FrameDetached = Symbol('FrameManager.FrameDetached');
export const FrameSwapped = Symbol('FrameManager.FrameSwapped');
export const LifecycleEvent = Symbol('FrameManager.LifecycleEvent');
export const FrameNavigatedWithinDocument = Symbol(
'FrameManager.FrameNavigatedWithinDocument'
);
}
/**
* @internal
*/
export interface FrameManagerEvents extends Record<EventType, unknown> {
[FrameManagerEvent.FrameAttached]: CdpFrame;
[FrameManagerEvent.FrameNavigated]: CdpFrame;
[FrameManagerEvent.FrameDetached]: CdpFrame;
[FrameManagerEvent.FrameSwapped]: CdpFrame;
[FrameManagerEvent.LifecycleEvent]: CdpFrame;
[FrameManagerEvent.FrameNavigatedWithinDocument]: CdpFrame;
}
+98
View File
@@ -0,0 +1,98 @@
/**
* @license
* Copyright 2022 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type {Frame} from '../api/Frame.js';
import {Deferred} from '../util/Deferred.js';
/**
* Keeps track of the page frame tree and it's is managed by
* {@link FrameManager}. FrameTree uses frame IDs to reference frame and it
* means that referenced frames might not be in the tree anymore. Thus, the tree
* structure is eventually consistent.
* @internal
*/
export class FrameTree<FrameType extends Frame> {
#frames = new Map<string, FrameType>();
// frameID -> parentFrameID
#parentIds = new Map<string, string>();
// frameID -> childFrameIDs
#childIds = new Map<string, Set<string>>();
#mainFrame?: FrameType;
#waitRequests = new Map<string, Set<Deferred<FrameType>>>();
getMainFrame(): FrameType | undefined {
return this.#mainFrame;
}
getById(frameId: string): FrameType | undefined {
return this.#frames.get(frameId);
}
/**
* Returns a promise that is resolved once the frame with
* the given ID is added to the tree.
*/
waitForFrame(frameId: string): Promise<FrameType> {
const frame = this.getById(frameId);
if (frame) {
return Promise.resolve(frame);
}
const deferred = Deferred.create<FrameType>();
const callbacks =
this.#waitRequests.get(frameId) || new Set<Deferred<FrameType>>();
callbacks.add(deferred);
return deferred.valueOrThrow();
}
frames(): FrameType[] {
return Array.from(this.#frames.values());
}
addFrame(frame: FrameType): void {
this.#frames.set(frame._id, frame);
if (frame._parentId) {
this.#parentIds.set(frame._id, frame._parentId);
if (!this.#childIds.has(frame._parentId)) {
this.#childIds.set(frame._parentId, new Set());
}
this.#childIds.get(frame._parentId)!.add(frame._id);
} else if (!this.#mainFrame) {
this.#mainFrame = frame;
}
this.#waitRequests.get(frame._id)?.forEach(request => {
return request.resolve(frame);
});
}
removeFrame(frame: FrameType): void {
this.#frames.delete(frame._id);
this.#parentIds.delete(frame._id);
if (frame._parentId) {
this.#childIds.get(frame._parentId)?.delete(frame._id);
} else {
this.#mainFrame = undefined;
}
}
childFrames(frameId: string): FrameType[] {
const childIds = this.#childIds.get(frameId);
if (!childIds) {
return [];
}
return Array.from(childIds)
.map(id => {
return this.getById(id);
})
.filter((frame): frame is FrameType => {
return frame !== undefined;
});
}
parentFrame(frameId: string): FrameType | undefined {
const parentId = this.#parentIds.get(frameId);
return parentId ? this.getById(parentId) : undefined;
}
}
+449
View File
@@ -0,0 +1,449 @@
/**
* @license
* Copyright 2020 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type {Protocol} from 'devtools-protocol';
import type {CDPSession} from '../api/CDPSession.js';
import type {Frame} from '../api/Frame.js';
import {
type ContinueRequestOverrides,
type ErrorCode,
headersArray,
HTTPRequest,
InterceptResolutionAction,
type InterceptResolutionState,
type ResourceType,
type ResponseForRequest,
STATUS_TEXTS,
} from '../api/HTTPRequest.js';
import type {ProtocolError} from '../common/Errors.js';
import {debugError, isString} from '../common/util.js';
import {assert} from '../util/assert.js';
import type {CdpHTTPResponse} from './HTTPResponse.js';
/**
* @internal
*/
export class CdpHTTPRequest extends HTTPRequest {
declare _redirectChain: CdpHTTPRequest[];
declare _response: CdpHTTPResponse | null;
#client: CDPSession;
#isNavigationRequest: boolean;
#allowInterception: boolean;
#interceptionHandled = false;
#url: string;
#resourceType: ResourceType;
#method: string;
#hasPostData = false;
#postData?: string;
#headers: Record<string, string> = {};
#frame: Frame | null;
#continueRequestOverrides: ContinueRequestOverrides;
#responseForRequest: Partial<ResponseForRequest> | null = null;
#abortErrorReason: Protocol.Network.ErrorReason | null = null;
#interceptResolutionState: InterceptResolutionState = {
action: InterceptResolutionAction.None,
};
#interceptHandlers: Array<() => void | PromiseLike<any>>;
#initiator?: Protocol.Network.Initiator;
override get client(): CDPSession {
return this.#client;
}
constructor(
client: CDPSession,
frame: Frame | null,
interceptionId: string | undefined,
allowInterception: boolean,
data: {
/**
* Request identifier.
*/
requestId: Protocol.Network.RequestId;
/**
* Loader identifier. Empty string if the request is fetched from worker.
*/
loaderId?: Protocol.Network.LoaderId;
/**
* URL of the document this request is loaded for.
*/
documentURL?: string;
/**
* Request data.
*/
request: Protocol.Network.Request;
/**
* Request initiator.
*/
initiator?: Protocol.Network.Initiator;
/**
* Type of this resource.
*/
type?: Protocol.Network.ResourceType;
},
redirectChain: CdpHTTPRequest[]
) {
super();
this.#client = client;
this._requestId = data.requestId;
this.#isNavigationRequest =
data.requestId === data.loaderId && data.type === 'Document';
this._interceptionId = interceptionId;
this.#allowInterception = allowInterception;
this.#url = data.request.url;
this.#resourceType = (data.type || 'other').toLowerCase() as ResourceType;
this.#method = data.request.method;
this.#postData = data.request.postData;
this.#hasPostData = data.request.hasPostData ?? false;
this.#frame = frame;
this._redirectChain = redirectChain;
this.#continueRequestOverrides = {};
this.#interceptHandlers = [];
this.#initiator = data.initiator;
for (const [key, value] of Object.entries(data.request.headers)) {
this.#headers[key.toLowerCase()] = value;
}
}
override url(): string {
return this.#url;
}
override continueRequestOverrides(): ContinueRequestOverrides {
assert(this.#allowInterception, 'Request Interception is not enabled!');
return this.#continueRequestOverrides;
}
override responseForRequest(): Partial<ResponseForRequest> | null {
assert(this.#allowInterception, 'Request Interception is not enabled!');
return this.#responseForRequest;
}
override abortErrorReason(): Protocol.Network.ErrorReason | null {
assert(this.#allowInterception, 'Request Interception is not enabled!');
return this.#abortErrorReason;
}
override interceptResolutionState(): InterceptResolutionState {
if (!this.#allowInterception) {
return {action: InterceptResolutionAction.Disabled};
}
if (this.#interceptionHandled) {
return {action: InterceptResolutionAction.AlreadyHandled};
}
return {...this.#interceptResolutionState};
}
override isInterceptResolutionHandled(): boolean {
return this.#interceptionHandled;
}
enqueueInterceptAction(
pendingHandler: () => void | PromiseLike<unknown>
): void {
this.#interceptHandlers.push(pendingHandler);
}
override async finalizeInterceptions(): Promise<void> {
await this.#interceptHandlers.reduce((promiseChain, interceptAction) => {
return promiseChain.then(interceptAction);
}, Promise.resolve());
const {action} = this.interceptResolutionState();
switch (action) {
case 'abort':
return await this.#abort(this.#abortErrorReason);
case 'respond':
if (this.#responseForRequest === null) {
throw new Error('Response is missing for the interception');
}
return await this.#respond(this.#responseForRequest);
case 'continue':
return await this.#continue(this.#continueRequestOverrides);
}
}
override resourceType(): ResourceType {
return this.#resourceType;
}
override method(): string {
return this.#method;
}
override postData(): string | undefined {
return this.#postData;
}
override hasPostData(): boolean {
return this.#hasPostData;
}
override async fetchPostData(): Promise<string | undefined> {
try {
const result = await this.#client.send('Network.getRequestPostData', {
requestId: this._requestId,
});
return result.postData;
} catch (err) {
debugError(err);
return;
}
}
override headers(): Record<string, string> {
return this.#headers;
}
override response(): CdpHTTPResponse | null {
return this._response;
}
override frame(): Frame | null {
return this.#frame;
}
override isNavigationRequest(): boolean {
return this.#isNavigationRequest;
}
override initiator(): Protocol.Network.Initiator | undefined {
return this.#initiator;
}
override redirectChain(): CdpHTTPRequest[] {
return this._redirectChain.slice();
}
override failure(): {errorText: string} | null {
if (!this._failureText) {
return null;
}
return {
errorText: this._failureText,
};
}
override async continue(
overrides: ContinueRequestOverrides = {},
priority?: number
): Promise<void> {
// Request interception is not supported for data: urls.
if (this.#url.startsWith('data:')) {
return;
}
assert(this.#allowInterception, 'Request Interception is not enabled!');
assert(!this.#interceptionHandled, 'Request is already handled!');
if (priority === undefined) {
return await this.#continue(overrides);
}
this.#continueRequestOverrides = overrides;
if (
this.#interceptResolutionState.priority === undefined ||
priority > this.#interceptResolutionState.priority
) {
this.#interceptResolutionState = {
action: InterceptResolutionAction.Continue,
priority,
};
return;
}
if (priority === this.#interceptResolutionState.priority) {
if (
this.#interceptResolutionState.action === 'abort' ||
this.#interceptResolutionState.action === 'respond'
) {
return;
}
this.#interceptResolutionState.action =
InterceptResolutionAction.Continue;
}
return;
}
async #continue(overrides: ContinueRequestOverrides = {}): Promise<void> {
const {url, method, postData, headers} = overrides;
this.#interceptionHandled = true;
const postDataBinaryBase64 = postData
? Buffer.from(postData).toString('base64')
: undefined;
if (this._interceptionId === undefined) {
throw new Error(
'HTTPRequest is missing _interceptionId needed for Fetch.continueRequest'
);
}
await this.#client
.send('Fetch.continueRequest', {
requestId: this._interceptionId,
url,
method,
postData: postDataBinaryBase64,
headers: headers ? headersArray(headers) : undefined,
})
.catch(error => {
this.#interceptionHandled = false;
return handleError(error);
});
}
override async respond(
response: Partial<ResponseForRequest>,
priority?: number
): Promise<void> {
// Mocking responses for dataURL requests is not currently supported.
if (this.#url.startsWith('data:')) {
return;
}
assert(this.#allowInterception, 'Request Interception is not enabled!');
assert(!this.#interceptionHandled, 'Request is already handled!');
if (priority === undefined) {
return await this.#respond(response);
}
this.#responseForRequest = response;
if (
this.#interceptResolutionState.priority === undefined ||
priority > this.#interceptResolutionState.priority
) {
this.#interceptResolutionState = {
action: InterceptResolutionAction.Respond,
priority,
};
return;
}
if (priority === this.#interceptResolutionState.priority) {
if (this.#interceptResolutionState.action === 'abort') {
return;
}
this.#interceptResolutionState.action = InterceptResolutionAction.Respond;
}
}
async #respond(response: Partial<ResponseForRequest>): Promise<void> {
this.#interceptionHandled = true;
const responseBody: Buffer | null =
response.body && isString(response.body)
? Buffer.from(response.body)
: (response.body as Buffer) || null;
const responseHeaders: Record<string, string | string[]> = {};
if (response.headers) {
for (const header of Object.keys(response.headers)) {
const value = response.headers[header];
responseHeaders[header.toLowerCase()] = Array.isArray(value)
? value.map(item => {
return String(item);
})
: String(value);
}
}
if (response.contentType) {
responseHeaders['content-type'] = response.contentType;
}
if (responseBody && !('content-length' in responseHeaders)) {
responseHeaders['content-length'] = String(
Buffer.byteLength(responseBody)
);
}
const status = response.status || 200;
if (this._interceptionId === undefined) {
throw new Error(
'HTTPRequest is missing _interceptionId needed for Fetch.fulfillRequest'
);
}
await this.#client
.send('Fetch.fulfillRequest', {
requestId: this._interceptionId,
responseCode: status,
responsePhrase: STATUS_TEXTS[status],
responseHeaders: headersArray(responseHeaders),
body: responseBody ? responseBody.toString('base64') : undefined,
})
.catch(error => {
this.#interceptionHandled = false;
return handleError(error);
});
}
override async abort(
errorCode: ErrorCode = 'failed',
priority?: number
): Promise<void> {
// Request interception is not supported for data: urls.
if (this.#url.startsWith('data:')) {
return;
}
const errorReason = errorReasons[errorCode];
assert(errorReason, 'Unknown error code: ' + errorCode);
assert(this.#allowInterception, 'Request Interception is not enabled!');
assert(!this.#interceptionHandled, 'Request is already handled!');
if (priority === undefined) {
return await this.#abort(errorReason);
}
this.#abortErrorReason = errorReason;
if (
this.#interceptResolutionState.priority === undefined ||
priority >= this.#interceptResolutionState.priority
) {
this.#interceptResolutionState = {
action: InterceptResolutionAction.Abort,
priority,
};
return;
}
}
async #abort(
errorReason: Protocol.Network.ErrorReason | null
): Promise<void> {
this.#interceptionHandled = true;
if (this._interceptionId === undefined) {
throw new Error(
'HTTPRequest is missing _interceptionId needed for Fetch.failRequest'
);
}
await this.#client
.send('Fetch.failRequest', {
requestId: this._interceptionId,
errorReason: errorReason || 'Failed',
})
.catch(handleError);
}
}
const errorReasons: Record<ErrorCode, Protocol.Network.ErrorReason> = {
aborted: 'Aborted',
accessdenied: 'AccessDenied',
addressunreachable: 'AddressUnreachable',
blockedbyclient: 'BlockedByClient',
blockedbyresponse: 'BlockedByResponse',
connectionaborted: 'ConnectionAborted',
connectionclosed: 'ConnectionClosed',
connectionfailed: 'ConnectionFailed',
connectionrefused: 'ConnectionRefused',
connectionreset: 'ConnectionReset',
internetdisconnected: 'InternetDisconnected',
namenotresolved: 'NameNotResolved',
timedout: 'TimedOut',
failed: 'Failed',
} as const;
async function handleError(error: ProtocolError) {
if (['Invalid header'].includes(error.originalMessage)) {
throw error;
}
// In certain cases, protocol will return error if the request was
// already canceled or the page was closed. We should tolerate these
// errors.
debugError(error);
}
+173
View File
@@ -0,0 +1,173 @@
/**
* @license
* Copyright 2020 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type {Protocol} from 'devtools-protocol';
import type {CDPSession} from '../api/CDPSession.js';
import type {Frame} from '../api/Frame.js';
import {HTTPResponse, type RemoteAddress} from '../api/HTTPResponse.js';
import {ProtocolError} from '../common/Errors.js';
import {SecurityDetails} from '../common/SecurityDetails.js';
import {Deferred} from '../util/Deferred.js';
import type {CdpHTTPRequest} from './HTTPRequest.js';
/**
* @internal
*/
export class CdpHTTPResponse extends HTTPResponse {
#client: CDPSession;
#request: CdpHTTPRequest;
#contentPromise: Promise<Buffer> | null = null;
#bodyLoadedDeferred = Deferred.create<void, Error>();
#remoteAddress: RemoteAddress;
#status: number;
#statusText: string;
#url: string;
#fromDiskCache: boolean;
#fromServiceWorker: boolean;
#headers: Record<string, string> = {};
#securityDetails: SecurityDetails | null;
#timing: Protocol.Network.ResourceTiming | null;
constructor(
client: CDPSession,
request: CdpHTTPRequest,
responsePayload: Protocol.Network.Response,
extraInfo: Protocol.Network.ResponseReceivedExtraInfoEvent | null
) {
super();
this.#client = client;
this.#request = request;
this.#remoteAddress = {
ip: responsePayload.remoteIPAddress,
port: responsePayload.remotePort,
};
this.#statusText =
this.#parseStatusTextFromExtraInfo(extraInfo) ||
responsePayload.statusText;
this.#url = request.url();
this.#fromDiskCache = !!responsePayload.fromDiskCache;
this.#fromServiceWorker = !!responsePayload.fromServiceWorker;
this.#status = extraInfo ? extraInfo.statusCode : responsePayload.status;
const headers = extraInfo ? extraInfo.headers : responsePayload.headers;
for (const [key, value] of Object.entries(headers)) {
this.#headers[key.toLowerCase()] = value;
}
this.#securityDetails = responsePayload.securityDetails
? new SecurityDetails(responsePayload.securityDetails)
: null;
this.#timing = responsePayload.timing || null;
}
#parseStatusTextFromExtraInfo(
extraInfo: Protocol.Network.ResponseReceivedExtraInfoEvent | null
): string | undefined {
if (!extraInfo || !extraInfo.headersText) {
return;
}
const firstLine = extraInfo.headersText.split('\r', 1)[0];
if (!firstLine) {
return;
}
const match = firstLine.match(/[^ ]* [^ ]* (.*)/);
if (!match) {
return;
}
const statusText = match[1];
if (!statusText) {
return;
}
return statusText;
}
_resolveBody(err?: Error): void {
if (err) {
return this.#bodyLoadedDeferred.reject(err);
}
return this.#bodyLoadedDeferred.resolve();
}
override remoteAddress(): RemoteAddress {
return this.#remoteAddress;
}
override url(): string {
return this.#url;
}
override status(): number {
return this.#status;
}
override statusText(): string {
return this.#statusText;
}
override headers(): Record<string, string> {
return this.#headers;
}
override securityDetails(): SecurityDetails | null {
return this.#securityDetails;
}
override timing(): Protocol.Network.ResourceTiming | null {
return this.#timing;
}
override buffer(): Promise<Buffer> {
if (!this.#contentPromise) {
this.#contentPromise = this.#bodyLoadedDeferred
.valueOrThrow()
.then(async () => {
try {
const response = await this.#client.send(
'Network.getResponseBody',
{
requestId: this.#request._requestId,
}
);
return Buffer.from(
response.body,
response.base64Encoded ? 'base64' : 'utf8'
);
} catch (error) {
if (
error instanceof ProtocolError &&
error.originalMessage ===
'No resource with given identifier found'
) {
throw new ProtocolError(
'Could not load body for this request. This might happen if the request is a preflight request.'
);
}
throw error;
}
});
}
return this.#contentPromise;
}
override request(): CdpHTTPRequest {
return this.#request;
}
override fromCache(): boolean {
return this.#fromDiskCache || this.#request._fromMemoryCache;
}
override fromServiceWorker(): boolean {
return this.#fromServiceWorker;
}
override frame(): Frame | null {
return this.#request.frame();
}
}
+604
View File
@@ -0,0 +1,604 @@
/**
* @license
* Copyright 2017 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type {Protocol} from 'devtools-protocol';
import type {CDPSession} from '../api/CDPSession.js';
import type {Point} from '../api/ElementHandle.js';
import {
Keyboard,
type KeyDownOptions,
type KeyPressOptions,
Mouse,
MouseButton,
type MouseClickOptions,
type MouseMoveOptions,
type MouseOptions,
type MouseWheelOptions,
Touchscreen,
type KeyboardTypeOptions,
} from '../api/Input.js';
import {
_keyDefinitions,
type KeyDefinition,
type KeyInput,
} from '../common/USKeyboardLayout.js';
import {assert} from '../util/assert.js';
type KeyDescription = Required<
Pick<KeyDefinition, 'keyCode' | 'key' | 'text' | 'code' | 'location'>
>;
/**
* @internal
*/
export class CdpKeyboard extends Keyboard {
#client: CDPSession;
#pressedKeys = new Set<string>();
_modifiers = 0;
constructor(client: CDPSession) {
super();
this.#client = client;
}
updateClient(client: CDPSession): void {
this.#client = client;
}
override async down(
key: KeyInput,
options: Readonly<KeyDownOptions> = {
text: undefined,
commands: [],
}
): Promise<void> {
const description = this.#keyDescriptionForString(key);
const autoRepeat = this.#pressedKeys.has(description.code);
this.#pressedKeys.add(description.code);
this._modifiers |= this.#modifierBit(description.key);
const text = options.text === undefined ? description.text : options.text;
await this.#client.send('Input.dispatchKeyEvent', {
type: text ? 'keyDown' : 'rawKeyDown',
modifiers: this._modifiers,
windowsVirtualKeyCode: description.keyCode,
code: description.code,
key: description.key,
text: text,
unmodifiedText: text,
autoRepeat,
location: description.location,
isKeypad: description.location === 3,
commands: options.commands,
});
}
#modifierBit(key: string): number {
if (key === 'Alt') {
return 1;
}
if (key === 'Control') {
return 2;
}
if (key === 'Meta') {
return 4;
}
if (key === 'Shift') {
return 8;
}
return 0;
}
#keyDescriptionForString(keyString: KeyInput): KeyDescription {
const shift = this._modifiers & 8;
const description = {
key: '',
keyCode: 0,
code: '',
text: '',
location: 0,
};
const definition = _keyDefinitions[keyString];
assert(definition, `Unknown key: "${keyString}"`);
if (definition.key) {
description.key = definition.key;
}
if (shift && definition.shiftKey) {
description.key = definition.shiftKey;
}
if (definition.keyCode) {
description.keyCode = definition.keyCode;
}
if (shift && definition.shiftKeyCode) {
description.keyCode = definition.shiftKeyCode;
}
if (definition.code) {
description.code = definition.code;
}
if (definition.location) {
description.location = definition.location;
}
if (description.key.length === 1) {
description.text = description.key;
}
if (definition.text) {
description.text = definition.text;
}
if (shift && definition.shiftText) {
description.text = definition.shiftText;
}
// if any modifiers besides shift are pressed, no text should be sent
if (this._modifiers & ~8) {
description.text = '';
}
return description;
}
override async up(key: KeyInput): Promise<void> {
const description = this.#keyDescriptionForString(key);
this._modifiers &= ~this.#modifierBit(description.key);
this.#pressedKeys.delete(description.code);
await this.#client.send('Input.dispatchKeyEvent', {
type: 'keyUp',
modifiers: this._modifiers,
key: description.key,
windowsVirtualKeyCode: description.keyCode,
code: description.code,
location: description.location,
});
}
override async sendCharacter(char: string): Promise<void> {
await this.#client.send('Input.insertText', {text: char});
}
private charIsKey(char: string): char is KeyInput {
return !!_keyDefinitions[char as KeyInput];
}
override async type(
text: string,
options: Readonly<KeyboardTypeOptions> = {}
): Promise<void> {
const delay = options.delay || undefined;
for (const char of text) {
if (this.charIsKey(char)) {
await this.press(char, {delay});
} else {
if (delay) {
await new Promise(f => {
return setTimeout(f, delay);
});
}
await this.sendCharacter(char);
}
}
}
override async press(
key: KeyInput,
options: Readonly<KeyPressOptions> = {}
): Promise<void> {
const {delay = null} = options;
await this.down(key, options);
if (delay) {
await new Promise(f => {
return setTimeout(f, options.delay);
});
}
await this.up(key);
}
}
/**
* This must follow {@link Protocol.Input.DispatchMouseEventRequest.buttons}.
*/
const enum MouseButtonFlag {
None = 0,
Left = 1,
Right = 1 << 1,
Middle = 1 << 2,
Back = 1 << 3,
Forward = 1 << 4,
}
const getFlag = (button: MouseButton): MouseButtonFlag => {
switch (button) {
case MouseButton.Left:
return MouseButtonFlag.Left;
case MouseButton.Right:
return MouseButtonFlag.Right;
case MouseButton.Middle:
return MouseButtonFlag.Middle;
case MouseButton.Back:
return MouseButtonFlag.Back;
case MouseButton.Forward:
return MouseButtonFlag.Forward;
}
};
/**
* This should match
* https://source.chromium.org/chromium/chromium/src/+/refs/heads/main:content/browser/renderer_host/input/web_input_event_builders_mac.mm;drc=a61b95c63b0b75c1cfe872d9c8cdf927c226046e;bpv=1;bpt=1;l=221.
*/
const getButtonFromPressedButtons = (
buttons: number
): Protocol.Input.MouseButton => {
if (buttons & MouseButtonFlag.Left) {
return MouseButton.Left;
} else if (buttons & MouseButtonFlag.Right) {
return MouseButton.Right;
} else if (buttons & MouseButtonFlag.Middle) {
return MouseButton.Middle;
} else if (buttons & MouseButtonFlag.Back) {
return MouseButton.Back;
} else if (buttons & MouseButtonFlag.Forward) {
return MouseButton.Forward;
}
return 'none';
};
interface MouseState {
/**
* The current position of the mouse.
*/
position: Point;
/**
* The buttons that are currently being pressed.
*/
buttons: number;
}
/**
* @internal
*/
export class CdpMouse extends Mouse {
#client: CDPSession;
#keyboard: CdpKeyboard;
constructor(client: CDPSession, keyboard: CdpKeyboard) {
super();
this.#client = client;
this.#keyboard = keyboard;
}
updateClient(client: CDPSession): void {
this.#client = client;
}
#_state: Readonly<MouseState> = {
position: {x: 0, y: 0},
buttons: MouseButtonFlag.None,
};
get #state(): MouseState {
return Object.assign({...this.#_state}, ...this.#transactions);
}
// Transactions can run in parallel, so we store each of thme in this array.
#transactions: Array<Partial<MouseState>> = [];
#createTransaction(): {
update: (updates: Partial<MouseState>) => void;
commit: () => void;
rollback: () => void;
} {
const transaction: Partial<MouseState> = {};
this.#transactions.push(transaction);
const popTransaction = () => {
this.#transactions.splice(this.#transactions.indexOf(transaction), 1);
};
return {
update: (updates: Partial<MouseState>) => {
Object.assign(transaction, updates);
},
commit: () => {
this.#_state = {...this.#_state, ...transaction};
popTransaction();
},
rollback: popTransaction,
};
}
/**
* This is a shortcut for a typical update, commit/rollback lifecycle based on
* the error of the action.
*/
async #withTransaction(
action: (update: (updates: Partial<MouseState>) => void) => Promise<unknown>
): Promise<void> {
const {update, commit, rollback} = this.#createTransaction();
try {
await action(update);
commit();
} catch (error) {
rollback();
throw error;
}
}
override async reset(): Promise<void> {
const actions = [];
for (const [flag, button] of [
[MouseButtonFlag.Left, MouseButton.Left],
[MouseButtonFlag.Middle, MouseButton.Middle],
[MouseButtonFlag.Right, MouseButton.Right],
[MouseButtonFlag.Forward, MouseButton.Forward],
[MouseButtonFlag.Back, MouseButton.Back],
] as const) {
if (this.#state.buttons & flag) {
actions.push(this.up({button: button}));
}
}
if (this.#state.position.x !== 0 || this.#state.position.y !== 0) {
actions.push(this.move(0, 0));
}
await Promise.all(actions);
}
override async move(
x: number,
y: number,
options: Readonly<MouseMoveOptions> = {}
): Promise<void> {
const {steps = 1} = options;
const from = this.#state.position;
const to = {x, y};
for (let i = 1; i <= steps; i++) {
await this.#withTransaction(updateState => {
updateState({
position: {
x: from.x + (to.x - from.x) * (i / steps),
y: from.y + (to.y - from.y) * (i / steps),
},
});
const {buttons, position} = this.#state;
return this.#client.send('Input.dispatchMouseEvent', {
type: 'mouseMoved',
modifiers: this.#keyboard._modifiers,
buttons,
button: getButtonFromPressedButtons(buttons),
...position,
});
});
}
}
override async down(options: Readonly<MouseOptions> = {}): Promise<void> {
const {button = MouseButton.Left, clickCount = 1} = options;
const flag = getFlag(button);
if (!flag) {
throw new Error(`Unsupported mouse button: ${button}`);
}
if (this.#state.buttons & flag) {
throw new Error(`'${button}' is already pressed.`);
}
await this.#withTransaction(updateState => {
updateState({
buttons: this.#state.buttons | flag,
});
const {buttons, position} = this.#state;
return this.#client.send('Input.dispatchMouseEvent', {
type: 'mousePressed',
modifiers: this.#keyboard._modifiers,
clickCount,
buttons,
button,
...position,
});
});
}
override async up(options: Readonly<MouseOptions> = {}): Promise<void> {
const {button = MouseButton.Left, clickCount = 1} = options;
const flag = getFlag(button);
if (!flag) {
throw new Error(`Unsupported mouse button: ${button}`);
}
if (!(this.#state.buttons & flag)) {
throw new Error(`'${button}' is not pressed.`);
}
await this.#withTransaction(updateState => {
updateState({
buttons: this.#state.buttons & ~flag,
});
const {buttons, position} = this.#state;
return this.#client.send('Input.dispatchMouseEvent', {
type: 'mouseReleased',
modifiers: this.#keyboard._modifiers,
clickCount,
buttons,
button,
...position,
});
});
}
override async click(
x: number,
y: number,
options: Readonly<MouseClickOptions> = {}
): Promise<void> {
const {delay, count = 1, clickCount = count} = options;
if (count < 1) {
throw new Error('Click must occur a positive number of times.');
}
const actions: Array<Promise<void>> = [this.move(x, y)];
if (clickCount === count) {
for (let i = 1; i < count; ++i) {
actions.push(
this.down({...options, clickCount: i}),
this.up({...options, clickCount: i})
);
}
}
actions.push(this.down({...options, clickCount}));
if (typeof delay === 'number') {
await Promise.all(actions);
actions.length = 0;
await new Promise(resolve => {
setTimeout(resolve, delay);
});
}
actions.push(this.up({...options, clickCount}));
await Promise.all(actions);
}
override async wheel(
options: Readonly<MouseWheelOptions> = {}
): Promise<void> {
const {deltaX = 0, deltaY = 0} = options;
const {position, buttons} = this.#state;
await this.#client.send('Input.dispatchMouseEvent', {
type: 'mouseWheel',
pointerType: 'mouse',
modifiers: this.#keyboard._modifiers,
deltaY,
deltaX,
buttons,
...position,
});
}
override async drag(
start: Point,
target: Point
): Promise<Protocol.Input.DragData> {
const promise = new Promise<Protocol.Input.DragData>(resolve => {
this.#client.once('Input.dragIntercepted', event => {
return resolve(event.data);
});
});
await this.move(start.x, start.y);
await this.down();
await this.move(target.x, target.y);
return await promise;
}
override async dragEnter(
target: Point,
data: Protocol.Input.DragData
): Promise<void> {
await this.#client.send('Input.dispatchDragEvent', {
type: 'dragEnter',
x: target.x,
y: target.y,
modifiers: this.#keyboard._modifiers,
data,
});
}
override async dragOver(
target: Point,
data: Protocol.Input.DragData
): Promise<void> {
await this.#client.send('Input.dispatchDragEvent', {
type: 'dragOver',
x: target.x,
y: target.y,
modifiers: this.#keyboard._modifiers,
data,
});
}
override async drop(
target: Point,
data: Protocol.Input.DragData
): Promise<void> {
await this.#client.send('Input.dispatchDragEvent', {
type: 'drop',
x: target.x,
y: target.y,
modifiers: this.#keyboard._modifiers,
data,
});
}
override async dragAndDrop(
start: Point,
target: Point,
options: {delay?: number} = {}
): Promise<void> {
const {delay = null} = options;
const data = await this.drag(start, target);
await this.dragEnter(target, data);
await this.dragOver(target, data);
if (delay) {
await new Promise(resolve => {
return setTimeout(resolve, delay);
});
}
await this.drop(target, data);
await this.up();
}
}
/**
* @internal
*/
export class CdpTouchscreen extends Touchscreen {
#client: CDPSession;
#keyboard: CdpKeyboard;
constructor(client: CDPSession, keyboard: CdpKeyboard) {
super();
this.#client = client;
this.#keyboard = keyboard;
}
updateClient(client: CDPSession): void {
this.#client = client;
}
override async touchStart(x: number, y: number): Promise<void> {
await this.#client.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [
{
x: Math.round(x),
y: Math.round(y),
radiusX: 0.5,
radiusY: 0.5,
},
],
modifiers: this.#keyboard._modifiers,
});
}
override async touchMove(x: number, y: number): Promise<void> {
await this.#client.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [
{
x: Math.round(x),
y: Math.round(y),
radiusX: 0.5,
radiusY: 0.5,
},
],
modifiers: this.#keyboard._modifiers,
});
}
override async touchEnd(): Promise<void> {
await this.#client.send('Input.dispatchTouchEvent', {
type: 'touchEnd',
touchPoints: [],
modifiers: this.#keyboard._modifiers,
});
}
}
+273
View File
@@ -0,0 +1,273 @@
/**
* @license
* Copyright 2019 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type {Protocol} from 'devtools-protocol';
import type {CDPSession} from '../api/CDPSession.js';
import type {JSHandle} from '../api/JSHandle.js';
import {Realm} from '../api/Realm.js';
import type {TimeoutSettings} from '../common/TimeoutSettings.js';
import type {BindingPayload, EvaluateFunc, HandleFor} from '../common/types.js';
import {debugError, withSourcePuppeteerURLIfNone} from '../common/util.js';
import {Deferred} from '../util/Deferred.js';
import {disposeSymbol} from '../util/disposable.js';
import {Mutex} from '../util/Mutex.js';
import type {Binding} from './Binding.js';
import {ExecutionContext, createCdpHandle} from './ExecutionContext.js';
import type {CdpFrame} from './Frame.js';
import type {MAIN_WORLD, PUPPETEER_WORLD} from './IsolatedWorlds.js';
import {addPageBinding} from './utils.js';
import type {CdpWebWorker} from './WebWorker.js';
/**
* @internal
*/
export interface PageBinding {
name: string;
pptrFunction: Function;
}
/**
* @internal
*/
export interface IsolatedWorldChart {
[key: string]: IsolatedWorld;
[MAIN_WORLD]: IsolatedWorld;
[PUPPETEER_WORLD]: IsolatedWorld;
}
/**
* @internal
*/
export class IsolatedWorld extends Realm {
#context = Deferred.create<ExecutionContext>();
// Set of bindings that have been registered in the current context.
#contextBindings = new Set<string>();
// Contains mapping from functions that should be bound to Puppeteer functions.
#bindings = new Map<string, Binding>();
get _bindings(): Map<string, Binding> {
return this.#bindings;
}
readonly #frameOrWorker: CdpFrame | CdpWebWorker;
constructor(
frameOrWorker: CdpFrame | CdpWebWorker,
timeoutSettings: TimeoutSettings
) {
super(timeoutSettings);
this.#frameOrWorker = frameOrWorker;
this.frameUpdated();
}
get environment(): CdpFrame | CdpWebWorker {
return this.#frameOrWorker;
}
frameUpdated(): void {
this.client.on('Runtime.bindingCalled', this.#onBindingCalled);
}
get client(): CDPSession {
return this.#frameOrWorker.client;
}
clearContext(): void {
// The message has to match the CDP message expected by the WaitTask class.
this.#context?.reject(new Error('Execution context was destroyed'));
this.#context = Deferred.create();
if ('clearDocumentHandle' in this.#frameOrWorker) {
this.#frameOrWorker.clearDocumentHandle();
}
}
setContext(context: ExecutionContext): void {
this.#contextBindings.clear();
this.#context.resolve(context);
void this.taskManager.rerunAll();
}
hasContext(): boolean {
return this.#context.resolved();
}
#executionContext(): Promise<ExecutionContext> {
if (this.disposed) {
throw new Error(
`Execution context is not available in detached frame "${this.environment.url()}" (are you trying to evaluate?)`
);
}
if (this.#context === null) {
throw new Error(`Execution content promise is missing`);
}
return this.#context.valueOrThrow();
}
async evaluateHandle<
Params extends unknown[],
Func extends EvaluateFunc<Params> = EvaluateFunc<Params>,
>(
pageFunction: Func | string,
...args: Params
): Promise<HandleFor<Awaited<ReturnType<Func>>>> {
pageFunction = withSourcePuppeteerURLIfNone(
this.evaluateHandle.name,
pageFunction
);
const context = await this.#executionContext();
return await context.evaluateHandle(pageFunction, ...args);
}
async evaluate<
Params extends unknown[],
Func extends EvaluateFunc<Params> = EvaluateFunc<Params>,
>(
pageFunction: Func | string,
...args: Params
): Promise<Awaited<ReturnType<Func>>> {
pageFunction = withSourcePuppeteerURLIfNone(
this.evaluate.name,
pageFunction
);
let context = this.#context.value();
if (!context || !(context instanceof ExecutionContext)) {
context = await this.#executionContext();
}
return await context.evaluate(pageFunction, ...args);
}
// If multiple waitFor are set up asynchronously, we need to wait for the
// first one to set up the binding in the page before running the others.
#mutex = new Mutex();
async _addBindingToContext(
context: ExecutionContext,
name: string
): Promise<void> {
if (this.#contextBindings.has(name)) {
return;
}
using _ = await this.#mutex.acquire();
try {
await context._client.send(
'Runtime.addBinding',
context._contextName
? {
name,
executionContextName: context._contextName,
}
: {
name,
executionContextId: context._contextId,
}
);
await context.evaluate(addPageBinding, 'internal', name);
this.#contextBindings.add(name);
} catch (error) {
// We could have tried to evaluate in a context which was already
// destroyed. This happens, for example, if the page is navigated while
// we are trying to add the binding
if (error instanceof Error) {
// Destroyed context.
if (error.message.includes('Execution context was destroyed')) {
return;
}
// Missing context.
if (error.message.includes('Cannot find context with specified id')) {
return;
}
}
debugError(error);
}
}
#onBindingCalled = async (
event: Protocol.Runtime.BindingCalledEvent
): Promise<void> => {
let payload: BindingPayload;
try {
payload = JSON.parse(event.payload);
} catch {
// The binding was either called by something in the page or it was
// called before our wrapper was initialized.
return;
}
const {type, name, seq, args, isTrivial} = payload;
if (type !== 'internal') {
return;
}
if (!this.#contextBindings.has(name)) {
return;
}
try {
const context = await this.#context.valueOrThrow();
if (event.executionContextId !== context._contextId) {
return;
}
const binding = this._bindings.get(name);
await binding?.run(context, seq, args, isTrivial);
} catch (err) {
debugError(err);
}
};
override async adoptBackendNode(
backendNodeId?: Protocol.DOM.BackendNodeId
): Promise<JSHandle<Node>> {
const executionContext = await this.#executionContext();
const {object} = await this.client.send('DOM.resolveNode', {
backendNodeId: backendNodeId,
executionContextId: executionContext._contextId,
});
return createCdpHandle(this, object) as JSHandle<Node>;
}
async adoptHandle<T extends JSHandle<Node>>(handle: T): Promise<T> {
if (handle.realm === this) {
// If the context has already adopted this handle, clone it so downstream
// disposal doesn't become an issue.
return (await handle.evaluateHandle(value => {
return value;
})) as unknown as T;
}
const nodeInfo = await this.client.send('DOM.describeNode', {
objectId: handle.id,
});
return (await this.adoptBackendNode(nodeInfo.node.backendNodeId)) as T;
}
async transferHandle<T extends JSHandle<Node>>(handle: T): Promise<T> {
if (handle.realm === this) {
return handle;
}
// Implies it's a primitive value, probably.
if (handle.remoteObject().objectId === undefined) {
return handle;
}
const info = await this.client.send('DOM.describeNode', {
objectId: handle.remoteObject().objectId,
});
const newHandle = (await this.adoptBackendNode(
info.node.backendNodeId
)) as T;
await handle.dispose();
return newHandle;
}
[disposeSymbol](): void {
super[disposeSymbol]();
this.client.off('Runtime.bindingCalled', this.#onBindingCalled);
}
}
+20
View File
@@ -0,0 +1,20 @@
/**
* @license
* Copyright 2022 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
/**
* A unique key for {@link IsolatedWorldChart} to denote the default world.
* Execution contexts are automatically created in the default world.
*
* @internal
*/
export const MAIN_WORLD = Symbol('mainWorld');
/**
* A unique key for {@link IsolatedWorldChart} to denote the puppeteer world.
* This world contains all puppeteer-internal bindings/code.
*
* @internal
*/
export const PUPPETEER_WORLD = Symbol('puppeteerWorld');
+109
View File
@@ -0,0 +1,109 @@
/**
* @license
* Copyright 2019 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type {Protocol} from 'devtools-protocol';
import type {CDPSession} from '../api/CDPSession.js';
import {JSHandle} from '../api/JSHandle.js';
import {debugError} from '../common/util.js';
import type {CdpElementHandle} from './ElementHandle.js';
import type {IsolatedWorld} from './IsolatedWorld.js';
import {valueFromRemoteObject} from './utils.js';
/**
* @internal
*/
export class CdpJSHandle<T = unknown> extends JSHandle<T> {
#disposed = false;
readonly #remoteObject: Protocol.Runtime.RemoteObject;
readonly #world: IsolatedWorld;
constructor(
world: IsolatedWorld,
remoteObject: Protocol.Runtime.RemoteObject
) {
super();
this.#world = world;
this.#remoteObject = remoteObject;
}
override get disposed(): boolean {
return this.#disposed;
}
override get realm(): IsolatedWorld {
return this.#world;
}
get client(): CDPSession {
return this.realm.environment.client;
}
override async jsonValue(): Promise<T> {
if (!this.#remoteObject.objectId) {
return valueFromRemoteObject(this.#remoteObject);
}
const value = await this.evaluate(object => {
return object;
});
if (value === undefined) {
throw new Error('Could not serialize referenced object');
}
return value;
}
/**
* Either `null` or the handle itself if the handle is an
* instance of {@link ElementHandle}.
*/
override asElement(): CdpElementHandle<Node> | null {
return null;
}
override async dispose(): Promise<void> {
if (this.#disposed) {
return;
}
this.#disposed = true;
await releaseObject(this.client, this.#remoteObject);
}
override toString(): string {
if (!this.#remoteObject.objectId) {
return 'JSHandle:' + valueFromRemoteObject(this.#remoteObject);
}
const type = this.#remoteObject.subtype || this.#remoteObject.type;
return 'JSHandle@' + type;
}
override get id(): string | undefined {
return this.#remoteObject.objectId;
}
override remoteObject(): Protocol.Runtime.RemoteObject {
return this.#remoteObject;
}
}
/**
* @internal
*/
export async function releaseObject(
client: CDPSession,
remoteObject: Protocol.Runtime.RemoteObject
): Promise<void> {
if (!remoteObject.objectId) {
return;
}
await client
.send('Runtime.releaseObject', {objectId: remoteObject.objectId})
.catch(error => {
// Exceptions might happen in case of a page been navigated or closed.
// Swallow these since they are harmless and we don't leak anything in this case.
debugError(error);
});
}
+298
View File
@@ -0,0 +1,298 @@
/**
* @license
* Copyright 2019 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type Protocol from 'devtools-protocol';
import {type Frame, FrameEvent} from '../api/Frame.js';
import type {HTTPRequest} from '../api/HTTPRequest.js';
import type {HTTPResponse} from '../api/HTTPResponse.js';
import type {TimeoutError} from '../common/Errors.js';
import {EventSubscription} from '../common/EventEmitter.js';
import {NetworkManagerEvent} from '../common/NetworkManagerEvents.js';
import {assert} from '../util/assert.js';
import {Deferred} from '../util/Deferred.js';
import {DisposableStack} from '../util/disposable.js';
import type {CdpFrame} from './Frame.js';
import {FrameManagerEvent} from './FrameManagerEvents.js';
import type {NetworkManager} from './NetworkManager.js';
/**
* @public
*/
export type PuppeteerLifeCycleEvent =
/**
* Waits for the 'load' event.
*/
| 'load'
/**
* Waits for the 'DOMContentLoaded' event.
*/
| 'domcontentloaded'
/**
* Waits till there are no more than 0 network connections for at least `500`
* ms.
*/
| 'networkidle0'
/**
* Waits till there are no more than 2 network connections for at least `500`
* ms.
*/
| 'networkidle2';
/**
* @public
*/
export type ProtocolLifeCycleEvent =
| 'load'
| 'DOMContentLoaded'
| 'networkIdle'
| 'networkAlmostIdle';
const puppeteerToProtocolLifecycle = new Map<
PuppeteerLifeCycleEvent,
ProtocolLifeCycleEvent
>([
['load', 'load'],
['domcontentloaded', 'DOMContentLoaded'],
['networkidle0', 'networkIdle'],
['networkidle2', 'networkAlmostIdle'],
]);
/**
* @internal
*/
export class LifecycleWatcher {
#expectedLifecycle: ProtocolLifeCycleEvent[];
#frame: CdpFrame;
#timeout: number;
#navigationRequest: HTTPRequest | null = null;
#subscriptions = new DisposableStack();
#initialLoaderId: string;
#terminationDeferred: Deferred<Error>;
#sameDocumentNavigationDeferred = Deferred.create<undefined>();
#lifecycleDeferred = Deferred.create<void>();
#newDocumentNavigationDeferred = Deferred.create<undefined>();
#hasSameDocumentNavigation?: boolean;
#swapped?: boolean;
#navigationResponseReceived?: Deferred<void>;
constructor(
networkManager: NetworkManager,
frame: CdpFrame,
waitUntil: PuppeteerLifeCycleEvent | PuppeteerLifeCycleEvent[],
timeout: number
) {
if (Array.isArray(waitUntil)) {
waitUntil = waitUntil.slice();
} else if (typeof waitUntil === 'string') {
waitUntil = [waitUntil];
}
this.#initialLoaderId = frame._loaderId;
this.#expectedLifecycle = waitUntil.map(value => {
const protocolEvent = puppeteerToProtocolLifecycle.get(value);
assert(protocolEvent, 'Unknown value for options.waitUntil: ' + value);
return protocolEvent as ProtocolLifeCycleEvent;
});
this.#frame = frame;
this.#timeout = timeout;
this.#subscriptions.use(
// Revert if TODO #1 is done
new EventSubscription(
frame._frameManager,
FrameManagerEvent.LifecycleEvent,
this.#checkLifecycleComplete.bind(this)
)
);
this.#subscriptions.use(
new EventSubscription(
frame,
FrameEvent.FrameNavigatedWithinDocument,
this.#navigatedWithinDocument.bind(this)
)
);
this.#subscriptions.use(
new EventSubscription(
frame,
FrameEvent.FrameNavigated,
this.#navigated.bind(this)
)
);
this.#subscriptions.use(
new EventSubscription(
frame,
FrameEvent.FrameSwapped,
this.#frameSwapped.bind(this)
)
);
this.#subscriptions.use(
new EventSubscription(
frame,
FrameEvent.FrameSwappedByActivation,
this.#frameSwapped.bind(this)
)
);
this.#subscriptions.use(
new EventSubscription(
frame,
FrameEvent.FrameDetached,
this.#onFrameDetached.bind(this)
)
);
this.#subscriptions.use(
new EventSubscription(
networkManager,
NetworkManagerEvent.Request,
this.#onRequest.bind(this)
)
);
this.#subscriptions.use(
new EventSubscription(
networkManager,
NetworkManagerEvent.Response,
this.#onResponse.bind(this)
)
);
this.#subscriptions.use(
new EventSubscription(
networkManager,
NetworkManagerEvent.RequestFailed,
this.#onRequestFailed.bind(this)
)
);
this.#terminationDeferred = Deferred.create<Error>({
timeout: this.#timeout,
message: `Navigation timeout of ${this.#timeout} ms exceeded`,
});
this.#checkLifecycleComplete();
}
#onRequest(request: HTTPRequest): void {
if (request.frame() !== this.#frame || !request.isNavigationRequest()) {
return;
}
this.#navigationRequest = request;
// Resolve previous navigation response in case there are multiple
// navigation requests reported by the backend. This generally should not
// happen by it looks like it's possible.
this.#navigationResponseReceived?.resolve();
this.#navigationResponseReceived = Deferred.create();
if (request.response() !== null) {
this.#navigationResponseReceived?.resolve();
}
}
#onRequestFailed(request: HTTPRequest): void {
if (this.#navigationRequest?._requestId !== request._requestId) {
return;
}
this.#navigationResponseReceived?.resolve();
}
#onResponse(response: HTTPResponse): void {
if (this.#navigationRequest?._requestId !== response.request()._requestId) {
return;
}
this.#navigationResponseReceived?.resolve();
}
#onFrameDetached(frame: Frame): void {
if (this.#frame === frame) {
this.#terminationDeferred.resolve(
new Error('Navigating frame was detached')
);
return;
}
this.#checkLifecycleComplete();
}
async navigationResponse(): Promise<HTTPResponse | null> {
// Continue with a possibly null response.
await this.#navigationResponseReceived?.valueOrThrow();
return this.#navigationRequest ? this.#navigationRequest.response() : null;
}
sameDocumentNavigationPromise(): Promise<Error | undefined> {
return this.#sameDocumentNavigationDeferred.valueOrThrow();
}
newDocumentNavigationPromise(): Promise<Error | undefined> {
return this.#newDocumentNavigationDeferred.valueOrThrow();
}
lifecyclePromise(): Promise<void> {
return this.#lifecycleDeferred.valueOrThrow();
}
terminationPromise(): Promise<Error | TimeoutError | undefined> {
return this.#terminationDeferred.valueOrThrow();
}
#navigatedWithinDocument(): void {
this.#hasSameDocumentNavigation = true;
this.#checkLifecycleComplete();
}
#navigated(navigationType: Protocol.Page.NavigationType): void {
if (navigationType === 'BackForwardCacheRestore') {
return this.#frameSwapped();
}
this.#checkLifecycleComplete();
}
#frameSwapped(): void {
this.#swapped = true;
this.#checkLifecycleComplete();
}
#checkLifecycleComplete(): void {
// We expect navigation to commit.
if (!checkLifecycle(this.#frame, this.#expectedLifecycle)) {
return;
}
this.#lifecycleDeferred.resolve();
if (this.#hasSameDocumentNavigation) {
this.#sameDocumentNavigationDeferred.resolve(undefined);
}
if (this.#swapped || this.#frame._loaderId !== this.#initialLoaderId) {
this.#newDocumentNavigationDeferred.resolve(undefined);
}
function checkLifecycle(
frame: CdpFrame,
expectedLifecycle: ProtocolLifeCycleEvent[]
): boolean {
for (const event of expectedLifecycle) {
if (!frame._lifecycleEvents.has(event)) {
return false;
}
}
// TODO(#1): Its possible we don't need this check
// CDP provided the correct order for Loading Events
// And NetworkIdle is a global state
// Consider removing
for (const child of frame.childFrames()) {
if (
child._hasStartedLoading &&
!checkLifecycle(child, expectedLifecycle)
) {
return false;
}
}
return true;
}
}
dispose(): void {
this.#subscriptions.dispose();
this.#terminationDeferred.resolve(new Error('LifecycleWatcher disposed'));
}
}
+217
View File
@@ -0,0 +1,217 @@
/**
* @license
* Copyright 2022 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type {Protocol} from 'devtools-protocol';
import type {CdpHTTPRequest} from './HTTPRequest.js';
/**
* @internal
*/
export interface QueuedEventGroup {
responseReceivedEvent: Protocol.Network.ResponseReceivedEvent;
loadingFinishedEvent?: Protocol.Network.LoadingFinishedEvent;
loadingFailedEvent?: Protocol.Network.LoadingFailedEvent;
}
/**
* @internal
*/
export type FetchRequestId = string;
/**
* @internal
*/
export interface RedirectInfo {
event: Protocol.Network.RequestWillBeSentEvent;
fetchRequestId?: FetchRequestId;
}
type RedirectInfoList = RedirectInfo[];
/**
* @internal
*/
export type NetworkRequestId = string;
/**
* Helper class to track network events by request ID
*
* @internal
*/
export class NetworkEventManager {
/**
* There are four possible orders of events:
* A. `_onRequestWillBeSent`
* B. `_onRequestWillBeSent`, `_onRequestPaused`
* C. `_onRequestPaused`, `_onRequestWillBeSent`
* D. `_onRequestPaused`, `_onRequestWillBeSent`, `_onRequestPaused`,
* `_onRequestWillBeSent`, `_onRequestPaused`, `_onRequestPaused`
* (see crbug.com/1196004)
*
* For `_onRequest` we need the event from `_onRequestWillBeSent` and
* optionally the `interceptionId` from `_onRequestPaused`.
*
* If request interception is disabled, call `_onRequest` once per call to
* `_onRequestWillBeSent`.
* If request interception is enabled, call `_onRequest` once per call to
* `_onRequestPaused` (once per `interceptionId`).
*
* Events are stored to allow for subsequent events to call `_onRequest`.
*
* Note that (chains of) redirect requests have the same `requestId` (!) as
* the original request. We have to anticipate series of events like these:
* A. `_onRequestWillBeSent`,
* `_onRequestWillBeSent`, ...
* B. `_onRequestWillBeSent`, `_onRequestPaused`,
* `_onRequestWillBeSent`, `_onRequestPaused`, ...
* C. `_onRequestWillBeSent`, `_onRequestPaused`,
* `_onRequestPaused`, `_onRequestWillBeSent`, ...
* D. `_onRequestPaused`, `_onRequestWillBeSent`,
* `_onRequestPaused`, `_onRequestWillBeSent`, `_onRequestPaused`,
* `_onRequestWillBeSent`, `_onRequestPaused`, `_onRequestPaused`, ...
* (see crbug.com/1196004)
*/
#requestWillBeSentMap = new Map<
NetworkRequestId,
Protocol.Network.RequestWillBeSentEvent
>();
#requestPausedMap = new Map<
NetworkRequestId,
Protocol.Fetch.RequestPausedEvent
>();
#httpRequestsMap = new Map<NetworkRequestId, CdpHTTPRequest>();
/*
* The below maps are used to reconcile Network.responseReceivedExtraInfo
* events with their corresponding request. Each response and redirect
* response gets an ExtraInfo event, and we don't know which will come first.
* This means that we have to store a Response or an ExtraInfo for each
* response, and emit the event when we get both of them. In addition, to
* handle redirects, we have to make them Arrays to represent the chain of
* events.
*/
#responseReceivedExtraInfoMap = new Map<
NetworkRequestId,
Protocol.Network.ResponseReceivedExtraInfoEvent[]
>();
#queuedRedirectInfoMap = new Map<NetworkRequestId, RedirectInfoList>();
#queuedEventGroupMap = new Map<NetworkRequestId, QueuedEventGroup>();
forget(networkRequestId: NetworkRequestId): void {
this.#requestWillBeSentMap.delete(networkRequestId);
this.#requestPausedMap.delete(networkRequestId);
this.#queuedEventGroupMap.delete(networkRequestId);
this.#queuedRedirectInfoMap.delete(networkRequestId);
this.#responseReceivedExtraInfoMap.delete(networkRequestId);
}
responseExtraInfo(
networkRequestId: NetworkRequestId
): Protocol.Network.ResponseReceivedExtraInfoEvent[] {
if (!this.#responseReceivedExtraInfoMap.has(networkRequestId)) {
this.#responseReceivedExtraInfoMap.set(networkRequestId, []);
}
return this.#responseReceivedExtraInfoMap.get(
networkRequestId
) as Protocol.Network.ResponseReceivedExtraInfoEvent[];
}
private queuedRedirectInfo(fetchRequestId: FetchRequestId): RedirectInfoList {
if (!this.#queuedRedirectInfoMap.has(fetchRequestId)) {
this.#queuedRedirectInfoMap.set(fetchRequestId, []);
}
return this.#queuedRedirectInfoMap.get(fetchRequestId) as RedirectInfoList;
}
queueRedirectInfo(
fetchRequestId: FetchRequestId,
redirectInfo: RedirectInfo
): void {
this.queuedRedirectInfo(fetchRequestId).push(redirectInfo);
}
takeQueuedRedirectInfo(
fetchRequestId: FetchRequestId
): RedirectInfo | undefined {
return this.queuedRedirectInfo(fetchRequestId).shift();
}
inFlightRequestsCount(): number {
let inFlightRequestCounter = 0;
for (const request of this.#httpRequestsMap.values()) {
if (!request.response()) {
inFlightRequestCounter++;
}
}
return inFlightRequestCounter;
}
storeRequestWillBeSent(
networkRequestId: NetworkRequestId,
event: Protocol.Network.RequestWillBeSentEvent
): void {
this.#requestWillBeSentMap.set(networkRequestId, event);
}
getRequestWillBeSent(
networkRequestId: NetworkRequestId
): Protocol.Network.RequestWillBeSentEvent | undefined {
return this.#requestWillBeSentMap.get(networkRequestId);
}
forgetRequestWillBeSent(networkRequestId: NetworkRequestId): void {
this.#requestWillBeSentMap.delete(networkRequestId);
}
getRequestPaused(
networkRequestId: NetworkRequestId
): Protocol.Fetch.RequestPausedEvent | undefined {
return this.#requestPausedMap.get(networkRequestId);
}
forgetRequestPaused(networkRequestId: NetworkRequestId): void {
this.#requestPausedMap.delete(networkRequestId);
}
storeRequestPaused(
networkRequestId: NetworkRequestId,
event: Protocol.Fetch.RequestPausedEvent
): void {
this.#requestPausedMap.set(networkRequestId, event);
}
getRequest(networkRequestId: NetworkRequestId): CdpHTTPRequest | undefined {
return this.#httpRequestsMap.get(networkRequestId);
}
storeRequest(
networkRequestId: NetworkRequestId,
request: CdpHTTPRequest
): void {
this.#httpRequestsMap.set(networkRequestId, request);
}
forgetRequest(networkRequestId: NetworkRequestId): void {
this.#httpRequestsMap.delete(networkRequestId);
}
getQueuedEventGroup(
networkRequestId: NetworkRequestId
): QueuedEventGroup | undefined {
return this.#queuedEventGroupMap.get(networkRequestId);
}
queueEventGroup(
networkRequestId: NetworkRequestId,
event: QueuedEventGroup
): void {
this.#queuedEventGroupMap.set(networkRequestId, event);
}
forgetQueuedEventGroup(networkRequestId: NetworkRequestId): void {
this.#queuedEventGroupMap.delete(networkRequestId);
}
}
+710
View File
@@ -0,0 +1,710 @@
/**
* @license
* Copyright 2017 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type {Protocol} from 'devtools-protocol';
import {CDPSessionEvent, type CDPSession} from '../api/CDPSession.js';
import type {Frame} from '../api/Frame.js';
import {EventEmitter, EventSubscription} from '../common/EventEmitter.js';
import {
NetworkManagerEvent,
type NetworkManagerEvents,
} from '../common/NetworkManagerEvents.js';
import {debugError, isString} from '../common/util.js';
import {assert} from '../util/assert.js';
import {DisposableStack} from '../util/disposable.js';
import {CdpHTTPRequest} from './HTTPRequest.js';
import {CdpHTTPResponse} from './HTTPResponse.js';
import {
NetworkEventManager,
type FetchRequestId,
} from './NetworkEventManager.js';
/**
* @public
*/
export interface Credentials {
username: string;
password: string;
}
/**
* @public
*/
export interface NetworkConditions {
// Download speed (bytes/s)
download: number;
// Upload speed (bytes/s)
upload: number;
// Latency (ms)
latency: number;
}
/**
* @public
*/
export interface InternalNetworkConditions extends NetworkConditions {
offline: boolean;
}
/**
* @internal
*/
export interface FrameProvider {
frame(id: string): Frame | null;
}
/**
* @internal
*/
export class NetworkManager extends EventEmitter<NetworkManagerEvents> {
#ignoreHTTPSErrors: boolean;
#frameManager: FrameProvider;
#networkEventManager = new NetworkEventManager();
#extraHTTPHeaders?: Record<string, string>;
#credentials?: Credentials;
#attemptedAuthentications = new Set<string>();
#userRequestInterceptionEnabled = false;
#protocolRequestInterceptionEnabled = false;
#userCacheDisabled?: boolean;
#emulatedNetworkConditions?: InternalNetworkConditions;
#userAgent?: string;
#userAgentMetadata?: Protocol.Emulation.UserAgentMetadata;
readonly #handlers = [
['Fetch.requestPaused', this.#onRequestPaused],
['Fetch.authRequired', this.#onAuthRequired],
['Network.requestWillBeSent', this.#onRequestWillBeSent],
['Network.requestServedFromCache', this.#onRequestServedFromCache],
['Network.responseReceived', this.#onResponseReceived],
['Network.loadingFinished', this.#onLoadingFinished],
['Network.loadingFailed', this.#onLoadingFailed],
['Network.responseReceivedExtraInfo', this.#onResponseReceivedExtraInfo],
[CDPSessionEvent.Disconnected, this.#removeClient],
] as const;
#clients = new Map<CDPSession, DisposableStack>();
constructor(ignoreHTTPSErrors: boolean, frameManager: FrameProvider) {
super();
this.#ignoreHTTPSErrors = ignoreHTTPSErrors;
this.#frameManager = frameManager;
}
async addClient(client: CDPSession): Promise<void> {
if (this.#clients.has(client)) {
return;
}
const subscriptions = new DisposableStack();
this.#clients.set(client, subscriptions);
for (const [event, handler] of this.#handlers) {
subscriptions.use(
// TODO: Remove any here.
new EventSubscription(client, event, (arg: any) => {
return handler.bind(this)(client, arg);
})
);
}
await Promise.all([
this.#ignoreHTTPSErrors
? client.send('Security.setIgnoreCertificateErrors', {
ignore: true,
})
: null,
client.send('Network.enable'),
this.#applyExtraHTTPHeaders(client),
this.#applyNetworkConditions(client),
this.#applyProtocolCacheDisabled(client),
this.#applyProtocolRequestInterception(client),
this.#applyUserAgent(client),
]);
}
async #removeClient(client: CDPSession) {
this.#clients.get(client)?.dispose();
this.#clients.delete(client);
}
async authenticate(credentials?: Credentials): Promise<void> {
this.#credentials = credentials;
const enabled = this.#userRequestInterceptionEnabled || !!this.#credentials;
if (enabled === this.#protocolRequestInterceptionEnabled) {
return;
}
this.#protocolRequestInterceptionEnabled = enabled;
await this.#applyToAllClients(
this.#applyProtocolRequestInterception.bind(this)
);
}
async setExtraHTTPHeaders(
extraHTTPHeaders: Record<string, string>
): Promise<void> {
this.#extraHTTPHeaders = {};
for (const key of Object.keys(extraHTTPHeaders)) {
const value = extraHTTPHeaders[key];
assert(
isString(value),
`Expected value of header "${key}" to be String, but "${typeof value}" is found.`
);
this.#extraHTTPHeaders[key.toLowerCase()] = value;
}
await this.#applyToAllClients(this.#applyExtraHTTPHeaders.bind(this));
}
async #applyExtraHTTPHeaders(client: CDPSession) {
if (this.#extraHTTPHeaders === undefined) {
return;
}
await client.send('Network.setExtraHTTPHeaders', {
headers: this.#extraHTTPHeaders,
});
}
extraHTTPHeaders(): Record<string, string> {
return Object.assign({}, this.#extraHTTPHeaders);
}
inFlightRequestsCount(): number {
return this.#networkEventManager.inFlightRequestsCount();
}
async setOfflineMode(value: boolean): Promise<void> {
if (!this.#emulatedNetworkConditions) {
this.#emulatedNetworkConditions = {
offline: false,
upload: -1,
download: -1,
latency: 0,
};
}
this.#emulatedNetworkConditions.offline = value;
await this.#applyToAllClients(this.#applyNetworkConditions.bind(this));
}
async emulateNetworkConditions(
networkConditions: NetworkConditions | null
): Promise<void> {
if (!this.#emulatedNetworkConditions) {
this.#emulatedNetworkConditions = {
offline: false,
upload: -1,
download: -1,
latency: 0,
};
}
this.#emulatedNetworkConditions.upload = networkConditions
? networkConditions.upload
: -1;
this.#emulatedNetworkConditions.download = networkConditions
? networkConditions.download
: -1;
this.#emulatedNetworkConditions.latency = networkConditions
? networkConditions.latency
: 0;
await this.#applyToAllClients(this.#applyNetworkConditions.bind(this));
}
async #applyToAllClients(fn: (client: CDPSession) => Promise<unknown>) {
await Promise.all(
Array.from(this.#clients.keys()).map(client => {
return fn(client);
})
);
}
async #applyNetworkConditions(client: CDPSession): Promise<void> {
if (this.#emulatedNetworkConditions === undefined) {
return;
}
await client.send('Network.emulateNetworkConditions', {
offline: this.#emulatedNetworkConditions.offline,
latency: this.#emulatedNetworkConditions.latency,
uploadThroughput: this.#emulatedNetworkConditions.upload,
downloadThroughput: this.#emulatedNetworkConditions.download,
});
}
async setUserAgent(
userAgent: string,
userAgentMetadata?: Protocol.Emulation.UserAgentMetadata
): Promise<void> {
this.#userAgent = userAgent;
this.#userAgentMetadata = userAgentMetadata;
await this.#applyToAllClients(this.#applyUserAgent.bind(this));
}
async #applyUserAgent(client: CDPSession) {
if (this.#userAgent === undefined) {
return;
}
await client.send('Network.setUserAgentOverride', {
userAgent: this.#userAgent,
userAgentMetadata: this.#userAgentMetadata,
});
}
async setCacheEnabled(enabled: boolean): Promise<void> {
this.#userCacheDisabled = !enabled;
await this.#applyToAllClients(this.#applyProtocolCacheDisabled.bind(this));
}
async setRequestInterception(value: boolean): Promise<void> {
this.#userRequestInterceptionEnabled = value;
const enabled = this.#userRequestInterceptionEnabled || !!this.#credentials;
if (enabled === this.#protocolRequestInterceptionEnabled) {
return;
}
this.#protocolRequestInterceptionEnabled = enabled;
await this.#applyToAllClients(
this.#applyProtocolRequestInterception.bind(this)
);
}
async #applyProtocolRequestInterception(client: CDPSession): Promise<void> {
if (this.#userCacheDisabled === undefined) {
this.#userCacheDisabled = false;
}
if (this.#protocolRequestInterceptionEnabled) {
await Promise.all([
this.#applyProtocolCacheDisabled(client),
client.send('Fetch.enable', {
handleAuthRequests: true,
patterns: [{urlPattern: '*'}],
}),
]);
} else {
await Promise.all([
this.#applyProtocolCacheDisabled(client),
client.send('Fetch.disable'),
]);
}
}
async #applyProtocolCacheDisabled(client: CDPSession): Promise<void> {
if (this.#userCacheDisabled === undefined) {
return;
}
await client.send('Network.setCacheDisabled', {
cacheDisabled: this.#userCacheDisabled,
});
}
#onRequestWillBeSent(
client: CDPSession,
event: Protocol.Network.RequestWillBeSentEvent
): void {
// Request interception doesn't happen for data URLs with Network Service.
if (
this.#userRequestInterceptionEnabled &&
!event.request.url.startsWith('data:')
) {
const {requestId: networkRequestId} = event;
this.#networkEventManager.storeRequestWillBeSent(networkRequestId, event);
/**
* CDP may have sent a Fetch.requestPaused event already. Check for it.
*/
const requestPausedEvent =
this.#networkEventManager.getRequestPaused(networkRequestId);
if (requestPausedEvent) {
const {requestId: fetchRequestId} = requestPausedEvent;
this.#patchRequestEventHeaders(event, requestPausedEvent);
this.#onRequest(client, event, fetchRequestId);
this.#networkEventManager.forgetRequestPaused(networkRequestId);
}
return;
}
this.#onRequest(client, event, undefined);
}
#onAuthRequired(
client: CDPSession,
event: Protocol.Fetch.AuthRequiredEvent
): void {
let response: Protocol.Fetch.AuthChallengeResponse['response'] = 'Default';
if (this.#attemptedAuthentications.has(event.requestId)) {
response = 'CancelAuth';
} else if (this.#credentials) {
response = 'ProvideCredentials';
this.#attemptedAuthentications.add(event.requestId);
}
const {username, password} = this.#credentials || {
username: undefined,
password: undefined,
};
client
.send('Fetch.continueWithAuth', {
requestId: event.requestId,
authChallengeResponse: {response, username, password},
})
.catch(debugError);
}
/**
* CDP may send a Fetch.requestPaused without or before a
* Network.requestWillBeSent
*
* CDP may send multiple Fetch.requestPaused
* for the same Network.requestWillBeSent.
*/
#onRequestPaused(
client: CDPSession,
event: Protocol.Fetch.RequestPausedEvent
): void {
if (
!this.#userRequestInterceptionEnabled &&
this.#protocolRequestInterceptionEnabled
) {
client
.send('Fetch.continueRequest', {
requestId: event.requestId,
})
.catch(debugError);
}
const {networkId: networkRequestId, requestId: fetchRequestId} = event;
if (!networkRequestId) {
this.#onRequestWithoutNetworkInstrumentation(client, event);
return;
}
const requestWillBeSentEvent = (() => {
const requestWillBeSentEvent =
this.#networkEventManager.getRequestWillBeSent(networkRequestId);
// redirect requests have the same `requestId`,
if (
requestWillBeSentEvent &&
(requestWillBeSentEvent.request.url !== event.request.url ||
requestWillBeSentEvent.request.method !== event.request.method)
) {
this.#networkEventManager.forgetRequestWillBeSent(networkRequestId);
return;
}
return requestWillBeSentEvent;
})();
if (requestWillBeSentEvent) {
this.#patchRequestEventHeaders(requestWillBeSentEvent, event);
this.#onRequest(client, requestWillBeSentEvent, fetchRequestId);
} else {
this.#networkEventManager.storeRequestPaused(networkRequestId, event);
}
}
#patchRequestEventHeaders(
requestWillBeSentEvent: Protocol.Network.RequestWillBeSentEvent,
requestPausedEvent: Protocol.Fetch.RequestPausedEvent
): void {
requestWillBeSentEvent.request.headers = {
...requestWillBeSentEvent.request.headers,
// includes extra headers, like: Accept, Origin
...requestPausedEvent.request.headers,
};
}
#onRequestWithoutNetworkInstrumentation(
client: CDPSession,
event: Protocol.Fetch.RequestPausedEvent
): void {
// If an event has no networkId it should not have any network events. We
// still want to dispatch it for the interception by the user.
const frame = event.frameId
? this.#frameManager.frame(event.frameId)
: null;
const request = new CdpHTTPRequest(
client,
frame,
event.requestId,
this.#userRequestInterceptionEnabled,
event,
[]
);
this.emit(NetworkManagerEvent.Request, request);
void request.finalizeInterceptions();
}
#onRequest(
client: CDPSession,
event: Protocol.Network.RequestWillBeSentEvent,
fetchRequestId?: FetchRequestId
): void {
let redirectChain: CdpHTTPRequest[] = [];
if (event.redirectResponse) {
// We want to emit a response and requestfinished for the
// redirectResponse, but we can't do so unless we have a
// responseExtraInfo ready to pair it up with. If we don't have any
// responseExtraInfos saved in our queue, they we have to wait until
// the next one to emit response and requestfinished, *and* we should
// also wait to emit this Request too because it should come after the
// response/requestfinished.
let redirectResponseExtraInfo = null;
if (event.redirectHasExtraInfo) {
redirectResponseExtraInfo = this.#networkEventManager
.responseExtraInfo(event.requestId)
.shift();
if (!redirectResponseExtraInfo) {
this.#networkEventManager.queueRedirectInfo(event.requestId, {
event,
fetchRequestId,
});
return;
}
}
const request = this.#networkEventManager.getRequest(event.requestId);
// If we connect late to the target, we could have missed the
// requestWillBeSent event.
if (request) {
this.#handleRequestRedirect(
client,
request,
event.redirectResponse,
redirectResponseExtraInfo
);
redirectChain = request._redirectChain;
}
}
const frame = event.frameId
? this.#frameManager.frame(event.frameId)
: null;
const request = new CdpHTTPRequest(
client,
frame,
fetchRequestId,
this.#userRequestInterceptionEnabled,
event,
redirectChain
);
this.#networkEventManager.storeRequest(event.requestId, request);
this.emit(NetworkManagerEvent.Request, request);
void request.finalizeInterceptions();
}
#onRequestServedFromCache(
_client: CDPSession,
event: Protocol.Network.RequestServedFromCacheEvent
): void {
const request = this.#networkEventManager.getRequest(event.requestId);
if (request) {
request._fromMemoryCache = true;
}
this.emit(NetworkManagerEvent.RequestServedFromCache, request);
}
#handleRequestRedirect(
client: CDPSession,
request: CdpHTTPRequest,
responsePayload: Protocol.Network.Response,
extraInfo: Protocol.Network.ResponseReceivedExtraInfoEvent | null
): void {
const response = new CdpHTTPResponse(
client,
request,
responsePayload,
extraInfo
);
request._response = response;
request._redirectChain.push(request);
response._resolveBody(
new Error('Response body is unavailable for redirect responses')
);
this.#forgetRequest(request, false);
this.emit(NetworkManagerEvent.Response, response);
this.emit(NetworkManagerEvent.RequestFinished, request);
}
#emitResponseEvent(
client: CDPSession,
responseReceived: Protocol.Network.ResponseReceivedEvent,
extraInfo: Protocol.Network.ResponseReceivedExtraInfoEvent | null
): void {
const request = this.#networkEventManager.getRequest(
responseReceived.requestId
);
// FileUpload sends a response without a matching request.
if (!request) {
return;
}
const extraInfos = this.#networkEventManager.responseExtraInfo(
responseReceived.requestId
);
if (extraInfos.length) {
debugError(
new Error(
'Unexpected extraInfo events for request ' +
responseReceived.requestId
)
);
}
// Chromium sends wrong extraInfo events for responses served from cache.
// See https://github.com/puppeteer/puppeteer/issues/9965 and
// https://crbug.com/1340398.
if (responseReceived.response.fromDiskCache) {
extraInfo = null;
}
const response = new CdpHTTPResponse(
client,
request,
responseReceived.response,
extraInfo
);
request._response = response;
this.emit(NetworkManagerEvent.Response, response);
}
#onResponseReceived(
client: CDPSession,
event: Protocol.Network.ResponseReceivedEvent
): void {
const request = this.#networkEventManager.getRequest(event.requestId);
let extraInfo = null;
if (request && !request._fromMemoryCache && event.hasExtraInfo) {
extraInfo = this.#networkEventManager
.responseExtraInfo(event.requestId)
.shift();
if (!extraInfo) {
// Wait until we get the corresponding ExtraInfo event.
this.#networkEventManager.queueEventGroup(event.requestId, {
responseReceivedEvent: event,
});
return;
}
}
this.#emitResponseEvent(client, event, extraInfo);
}
#onResponseReceivedExtraInfo(
client: CDPSession,
event: Protocol.Network.ResponseReceivedExtraInfoEvent
): void {
// We may have skipped a redirect response/request pair due to waiting for
// this ExtraInfo event. If so, continue that work now that we have the
// request.
const redirectInfo = this.#networkEventManager.takeQueuedRedirectInfo(
event.requestId
);
if (redirectInfo) {
this.#networkEventManager.responseExtraInfo(event.requestId).push(event);
this.#onRequest(client, redirectInfo.event, redirectInfo.fetchRequestId);
return;
}
// We may have skipped response and loading events because we didn't have
// this ExtraInfo event yet. If so, emit those events now.
const queuedEvents = this.#networkEventManager.getQueuedEventGroup(
event.requestId
);
if (queuedEvents) {
this.#networkEventManager.forgetQueuedEventGroup(event.requestId);
this.#emitResponseEvent(
client,
queuedEvents.responseReceivedEvent,
event
);
if (queuedEvents.loadingFinishedEvent) {
this.#emitLoadingFinished(queuedEvents.loadingFinishedEvent);
}
if (queuedEvents.loadingFailedEvent) {
this.#emitLoadingFailed(queuedEvents.loadingFailedEvent);
}
return;
}
// Wait until we get another event that can use this ExtraInfo event.
this.#networkEventManager.responseExtraInfo(event.requestId).push(event);
}
#forgetRequest(request: CdpHTTPRequest, events: boolean): void {
const requestId = request._requestId;
const interceptionId = request._interceptionId;
this.#networkEventManager.forgetRequest(requestId);
interceptionId !== undefined &&
this.#attemptedAuthentications.delete(interceptionId);
if (events) {
this.#networkEventManager.forget(requestId);
}
}
#onLoadingFinished(
_client: CDPSession,
event: Protocol.Network.LoadingFinishedEvent
): void {
// If the response event for this request is still waiting on a
// corresponding ExtraInfo event, then wait to emit this event too.
const queuedEvents = this.#networkEventManager.getQueuedEventGroup(
event.requestId
);
if (queuedEvents) {
queuedEvents.loadingFinishedEvent = event;
} else {
this.#emitLoadingFinished(event);
}
}
#emitLoadingFinished(event: Protocol.Network.LoadingFinishedEvent): void {
const request = this.#networkEventManager.getRequest(event.requestId);
// For certain requestIds we never receive requestWillBeSent event.
// @see https://crbug.com/750469
if (!request) {
return;
}
// Under certain conditions we never get the Network.responseReceived
// event from protocol. @see https://crbug.com/883475
if (request.response()) {
request.response()?._resolveBody();
}
this.#forgetRequest(request, true);
this.emit(NetworkManagerEvent.RequestFinished, request);
}
#onLoadingFailed(
_client: CDPSession,
event: Protocol.Network.LoadingFailedEvent
): void {
// If the response event for this request is still waiting on a
// corresponding ExtraInfo event, then wait to emit this event too.
const queuedEvents = this.#networkEventManager.getQueuedEventGroup(
event.requestId
);
if (queuedEvents) {
queuedEvents.loadingFailedEvent = event;
} else {
this.#emitLoadingFailed(event);
}
}
#emitLoadingFailed(event: Protocol.Network.LoadingFailedEvent): void {
const request = this.#networkEventManager.getRequest(event.requestId);
// For certain requestIds we never receive requestWillBeSent event.
// @see https://crbug.com/750469
if (!request) {
return;
}
request._failureText = event.errorText;
const response = request.response();
if (response) {
response._resolveBody();
}
this.#forgetRequest(request, true);
this.emit(NetworkManagerEvent.RequestFailed, request);
}
}
+1251
View File
File diff suppressed because it is too large Load Diff
+49
View File
@@ -0,0 +1,49 @@
/**
* @license
* Copyright 2021 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type {NetworkConditions} from './NetworkManager.js';
/**
* A list of network conditions to be used with
* {@link Page.emulateNetworkConditions}.
*
* @example
*
* ```ts
* import {PredefinedNetworkConditions} from 'puppeteer';
* const slow3G = PredefinedNetworkConditions['Slow 3G'];
*
* (async () => {
* const browser = await puppeteer.launch();
* const page = await browser.newPage();
* await page.emulateNetworkConditions(slow3G);
* await page.goto('https://www.google.com');
* // other actions...
* await browser.close();
* })();
* ```
*
* @public
*/
export const PredefinedNetworkConditions = Object.freeze({
'Slow 3G': {
download: ((500 * 1000) / 8) * 0.8,
upload: ((500 * 1000) / 8) * 0.8,
latency: 400 * 5,
} as NetworkConditions,
'Fast 3G': {
download: ((1.6 * 1000 * 1000) / 8) * 0.9,
upload: ((750 * 1000) / 8) * 0.9,
latency: 150 * 3.75,
} as NetworkConditions,
});
/**
* @deprecated Import {@link PredefinedNetworkConditions}.
*
* @public
*/
export const networkConditions = PredefinedNetworkConditions;
+305
View File
@@ -0,0 +1,305 @@
/**
* @license
* Copyright 2019 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type {Protocol} from 'devtools-protocol';
import type {Browser} from '../api/Browser.js';
import type {BrowserContext} from '../api/BrowserContext.js';
import type {CDPSession} from '../api/CDPSession.js';
import {PageEvent, type Page} from '../api/Page.js';
import {Target, TargetType} from '../api/Target.js';
import {debugError} from '../common/util.js';
import type {Viewport} from '../common/Viewport.js';
import {Deferred} from '../util/Deferred.js';
import {CdpCDPSession} from './CDPSession.js';
import {CdpPage} from './Page.js';
import type {TargetManager} from './TargetManager.js';
import {CdpWebWorker} from './WebWorker.js';
/**
* @internal
*/
export enum InitializationStatus {
SUCCESS = 'success',
ABORTED = 'aborted',
}
/**
* @internal
*/
export class CdpTarget extends Target {
#browserContext?: BrowserContext;
#session?: CDPSession;
#targetInfo: Protocol.Target.TargetInfo;
#targetManager?: TargetManager;
#sessionFactory:
| ((isAutoAttachEmulated: boolean) => Promise<CDPSession>)
| undefined;
_initializedDeferred = Deferred.create<InitializationStatus>();
_isClosedDeferred = Deferred.create<void>();
_targetId: string;
/**
* To initialize the target for use, call initialize.
*
* @internal
*/
constructor(
targetInfo: Protocol.Target.TargetInfo,
session: CDPSession | undefined,
browserContext: BrowserContext | undefined,
targetManager: TargetManager | undefined,
sessionFactory:
| ((isAutoAttachEmulated: boolean) => Promise<CDPSession>)
| undefined
) {
super();
this.#session = session;
this.#targetManager = targetManager;
this.#targetInfo = targetInfo;
this.#browserContext = browserContext;
this._targetId = targetInfo.targetId;
this.#sessionFactory = sessionFactory;
if (this.#session && this.#session instanceof CdpCDPSession) {
this.#session._setTarget(this);
}
}
override async asPage(): Promise<Page> {
const session = this._session();
if (!session) {
return await this.createCDPSession().then(client => {
return CdpPage._create(client, this, false, null);
});
}
return await CdpPage._create(session, this, false, null);
}
_subtype(): string | undefined {
return this.#targetInfo.subtype;
}
_session(): CDPSession | undefined {
return this.#session;
}
protected _sessionFactory(): (
isAutoAttachEmulated: boolean
) => Promise<CDPSession> {
if (!this.#sessionFactory) {
throw new Error('sessionFactory is not initialized');
}
return this.#sessionFactory;
}
override createCDPSession(): Promise<CDPSession> {
if (!this.#sessionFactory) {
throw new Error('sessionFactory is not initialized');
}
return this.#sessionFactory(false).then(session => {
(session as CdpCDPSession)._setTarget(this);
return session;
});
}
override url(): string {
return this.#targetInfo.url;
}
override type(): TargetType {
const type = this.#targetInfo.type;
switch (type) {
case 'page':
return TargetType.PAGE;
case 'background_page':
return TargetType.BACKGROUND_PAGE;
case 'service_worker':
return TargetType.SERVICE_WORKER;
case 'shared_worker':
return TargetType.SHARED_WORKER;
case 'browser':
return TargetType.BROWSER;
case 'webview':
return TargetType.WEBVIEW;
case 'tab':
return TargetType.TAB;
default:
return TargetType.OTHER;
}
}
_targetManager(): TargetManager {
if (!this.#targetManager) {
throw new Error('targetManager is not initialized');
}
return this.#targetManager;
}
_getTargetInfo(): Protocol.Target.TargetInfo {
return this.#targetInfo;
}
override browser(): Browser {
if (!this.#browserContext) {
throw new Error('browserContext is not initialized');
}
return this.#browserContext.browser();
}
override browserContext(): BrowserContext {
if (!this.#browserContext) {
throw new Error('browserContext is not initialized');
}
return this.#browserContext;
}
override opener(): Target | undefined {
const {openerId} = this.#targetInfo;
if (!openerId) {
return;
}
return this.browser()
.targets()
.find(target => {
return (target as CdpTarget)._targetId === openerId;
});
}
_targetInfoChanged(targetInfo: Protocol.Target.TargetInfo): void {
this.#targetInfo = targetInfo;
this._checkIfInitialized();
}
_initialize(): void {
this._initializedDeferred.resolve(InitializationStatus.SUCCESS);
}
_isTargetExposed(): boolean {
return this.type() !== TargetType.TAB && !this._subtype();
}
protected _checkIfInitialized(): void {
if (!this._initializedDeferred.resolved()) {
this._initializedDeferred.resolve(InitializationStatus.SUCCESS);
}
}
}
/**
* @internal
*/
export class PageTarget extends CdpTarget {
#defaultViewport?: Viewport;
protected pagePromise?: Promise<Page>;
#ignoreHTTPSErrors: boolean;
constructor(
targetInfo: Protocol.Target.TargetInfo,
session: CDPSession | undefined,
browserContext: BrowserContext,
targetManager: TargetManager,
sessionFactory: (isAutoAttachEmulated: boolean) => Promise<CDPSession>,
ignoreHTTPSErrors: boolean,
defaultViewport: Viewport | null
) {
super(targetInfo, session, browserContext, targetManager, sessionFactory);
this.#ignoreHTTPSErrors = ignoreHTTPSErrors;
this.#defaultViewport = defaultViewport ?? undefined;
}
override _initialize(): void {
this._initializedDeferred
.valueOrThrow()
.then(async result => {
if (result === InitializationStatus.ABORTED) {
return;
}
const opener = this.opener();
if (!(opener instanceof PageTarget)) {
return;
}
if (!opener || !opener.pagePromise || this.type() !== 'page') {
return true;
}
const openerPage = await opener.pagePromise;
if (!openerPage.listenerCount(PageEvent.Popup)) {
return true;
}
const popupPage = await this.page();
openerPage.emit(PageEvent.Popup, popupPage);
return true;
})
.catch(debugError);
this._checkIfInitialized();
}
override async page(): Promise<Page | null> {
if (!this.pagePromise) {
const session = this._session();
this.pagePromise = (
session
? Promise.resolve(session)
: this._sessionFactory()(/* isAutoAttachEmulated=*/ false)
).then(client => {
return CdpPage._create(
client,
this,
this.#ignoreHTTPSErrors,
this.#defaultViewport ?? null
);
});
}
return (await this.pagePromise) ?? null;
}
override _checkIfInitialized(): void {
if (this._initializedDeferred.resolved()) {
return;
}
if (this._getTargetInfo().url !== '') {
this._initializedDeferred.resolve(InitializationStatus.SUCCESS);
}
}
}
/**
* @internal
*/
export class DevToolsTarget extends PageTarget {}
/**
* @internal
*/
export class WorkerTarget extends CdpTarget {
#workerPromise?: Promise<CdpWebWorker>;
override async worker(): Promise<CdpWebWorker | null> {
if (!this.#workerPromise) {
const session = this._session();
// TODO(einbinder): Make workers send their console logs.
this.#workerPromise = (
session
? Promise.resolve(session)
: this._sessionFactory()(/* isAutoAttachEmulated=*/ false)
).then(client => {
return new CdpWebWorker(
client,
this._getTargetInfo().url,
() => {} /* consoleAPICalled */,
() => {} /* exceptionThrown */
);
});
}
return await this.#workerPromise;
}
}
/**
* @internal
*/
export class OtherTarget extends CdpTarget {}
+65
View File
@@ -0,0 +1,65 @@
/**
* @license
* Copyright 2022 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type {Protocol} from 'devtools-protocol';
import type {CDPSession} from '../api/CDPSession.js';
import type {EventEmitter, EventType} from '../common/EventEmitter.js';
import type {CdpTarget} from './Target.js';
/**
* @internal
*/
export type TargetFactory = (
targetInfo: Protocol.Target.TargetInfo,
session?: CDPSession,
parentSession?: CDPSession
) => CdpTarget;
/**
* @internal
*/
export const enum TargetManagerEvent {
TargetDiscovered = 'targetDiscovered',
TargetAvailable = 'targetAvailable',
TargetGone = 'targetGone',
/**
* Emitted after a target has been initialized and whenever its URL changes.
*/
TargetChanged = 'targetChanged',
}
/**
* @internal
*/
export interface TargetManagerEvents extends Record<EventType, unknown> {
[TargetManagerEvent.TargetAvailable]: CdpTarget;
[TargetManagerEvent.TargetDiscovered]: Protocol.Target.TargetInfo;
[TargetManagerEvent.TargetGone]: CdpTarget;
[TargetManagerEvent.TargetChanged]: {
target: CdpTarget;
wasInitialized: true;
previousURL: string;
};
}
/**
* TargetManager encapsulates all interactions with CDP targets and is
* responsible for coordinating the configuration of targets with the rest of
* Puppeteer. Code outside of this class should not subscribe `Target.*` events
* and only use the TargetManager events.
*
* There are two implementations: one for Chrome that uses CDP's auto-attach
* mechanism and one for Firefox because Firefox does not support auto-attach.
*
* @internal
*/
export interface TargetManager extends EventEmitter<TargetManagerEvents> {
getAvailableTargets(): ReadonlyMap<string, CdpTarget>;
initialize(): Promise<void>;
dispose(): void;
}
+140
View File
@@ -0,0 +1,140 @@
/**
* @license
* Copyright 2017 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type {CDPSession} from '../api/CDPSession.js';
import {
getReadableAsBuffer,
getReadableFromProtocolStream,
} from '../common/util.js';
import {assert} from '../util/assert.js';
import {Deferred} from '../util/Deferred.js';
import {isErrorLike} from '../util/ErrorLike.js';
/**
* @public
*/
export interface TracingOptions {
path?: string;
screenshots?: boolean;
categories?: string[];
}
/**
* The Tracing class exposes the tracing audit interface.
* @remarks
* You can use `tracing.start` and `tracing.stop` to create a trace file
* which can be opened in Chrome DevTools or {@link https://chromedevtools.github.io/timeline-viewer/ | timeline viewer}.
*
* @example
*
* ```ts
* await page.tracing.start({path: 'trace.json'});
* await page.goto('https://www.google.com');
* await page.tracing.stop();
* ```
*
* @public
*/
export class Tracing {
#client: CDPSession;
#recording = false;
#path?: string;
/**
* @internal
*/
constructor(client: CDPSession) {
this.#client = client;
}
/**
* @internal
*/
updateClient(client: CDPSession): void {
this.#client = client;
}
/**
* Starts a trace for the current page.
* @remarks
* Only one trace can be active at a time per browser.
*
* @param options - Optional `TracingOptions`.
*/
async start(options: TracingOptions = {}): Promise<void> {
assert(
!this.#recording,
'Cannot start recording trace while already recording trace.'
);
const defaultCategories = [
'-*',
'devtools.timeline',
'v8.execute',
'disabled-by-default-devtools.timeline',
'disabled-by-default-devtools.timeline.frame',
'toplevel',
'blink.console',
'blink.user_timing',
'latencyInfo',
'disabled-by-default-devtools.timeline.stack',
'disabled-by-default-v8.cpu_profiler',
];
const {path, screenshots = false, categories = defaultCategories} = options;
if (screenshots) {
categories.push('disabled-by-default-devtools.screenshot');
}
const excludedCategories = categories
.filter(cat => {
return cat.startsWith('-');
})
.map(cat => {
return cat.slice(1);
});
const includedCategories = categories.filter(cat => {
return !cat.startsWith('-');
});
this.#path = path;
this.#recording = true;
await this.#client.send('Tracing.start', {
transferMode: 'ReturnAsStream',
traceConfig: {
excludedCategories,
includedCategories,
},
});
}
/**
* Stops a trace started with the `start` method.
* @returns Promise which resolves to buffer with trace data.
*/
async stop(): Promise<Buffer | undefined> {
const contentDeferred = Deferred.create<Buffer | undefined>();
this.#client.once('Tracing.tracingComplete', async event => {
try {
assert(event.stream, 'Missing "stream"');
const readable = await getReadableFromProtocolStream(
this.#client,
event.stream
);
const buffer = await getReadableAsBuffer(readable, this.#path);
contentDeferred.resolve(buffer ?? undefined);
} catch (error) {
if (isErrorLike(error)) {
contentDeferred.reject(error);
} else {
contentDeferred.reject(new Error(`Unknown error: ${error}`));
}
}
});
await this.#client.send('Tracing.end');
this.#recording = false;
return await contentDeferred.valueOrThrow();
}
}
+83
View File
@@ -0,0 +1,83 @@
/**
* @license
* Copyright 2018 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type {Protocol} from 'devtools-protocol';
import type {CDPSession} from '../api/CDPSession.js';
import type {Realm} from '../api/Realm.js';
import {WebWorker} from '../api/WebWorker.js';
import type {ConsoleMessageType} from '../common/ConsoleMessage.js';
import {TimeoutSettings} from '../common/TimeoutSettings.js';
import {debugError} from '../common/util.js';
import {ExecutionContext} from './ExecutionContext.js';
import {IsolatedWorld} from './IsolatedWorld.js';
import {CdpJSHandle} from './JSHandle.js';
/**
* @internal
*/
export type ConsoleAPICalledCallback = (
eventType: ConsoleMessageType,
handles: CdpJSHandle[],
trace?: Protocol.Runtime.StackTrace
) => void;
/**
* @internal
*/
export type ExceptionThrownCallback = (
event: Protocol.Runtime.ExceptionThrownEvent
) => void;
/**
* @internal
*/
export class CdpWebWorker extends WebWorker {
#world: IsolatedWorld;
#client: CDPSession;
constructor(
client: CDPSession,
url: string,
consoleAPICalled: ConsoleAPICalledCallback,
exceptionThrown: ExceptionThrownCallback
) {
super(url);
this.#client = client;
this.#world = new IsolatedWorld(this, new TimeoutSettings());
this.#client.once('Runtime.executionContextCreated', async event => {
this.#world.setContext(
new ExecutionContext(client, event.context, this.#world)
);
});
this.#client.on('Runtime.consoleAPICalled', async event => {
try {
return consoleAPICalled(
event.type,
event.args.map((object: Protocol.Runtime.RemoteObject) => {
return new CdpJSHandle(this.#world, object);
}),
event.stackTrace
);
} catch (err) {
debugError(err);
}
});
this.#client.on('Runtime.exceptionThrown', exceptionThrown);
// This might fail if the target is closed before we receive all execution contexts.
this.#client.send('Runtime.enable').catch(debugError);
}
mainRealm(): Realm {
return this.#world;
}
get client(): CDPSession {
return this.#client;
}
}
+42
View File
@@ -0,0 +1,42 @@
/**
* @license
* Copyright 2023 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
export * from './Accessibility.js';
export * from './AriaQueryHandler.js';
export * from './Binding.js';
export * from './Browser.js';
export * from './BrowserConnector.js';
export * from './cdp.js';
export * from './CDPSession.js';
export * from './ChromeTargetManager.js';
export * from './Connection.js';
export * from './Coverage.js';
export * from './DeviceRequestPrompt.js';
export * from './Dialog.js';
export * from './ElementHandle.js';
export * from './EmulationManager.js';
export * from './ExecutionContext.js';
export * from './FirefoxTargetManager.js';
export * from './Frame.js';
export * from './FrameManager.js';
export * from './FrameManagerEvents.js';
export * from './FrameTree.js';
export * from './HTTPRequest.js';
export * from './HTTPResponse.js';
export * from './Input.js';
export * from './IsolatedWorld.js';
export * from './IsolatedWorlds.js';
export * from './JSHandle.js';
export * from './LifecycleWatcher.js';
export * from './NetworkEventManager.js';
export * from './NetworkManager.js';
export * from './Page.js';
export * from './PredefinedNetworkConditions.js';
export * from './Target.js';
export * from './TargetManager.js';
export * from './Tracing.js';
export * from './utils.js';
export * from './WebWorker.js';
+232
View File
@@ -0,0 +1,232 @@
/**
* @license
* Copyright 2017 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type {Protocol} from 'devtools-protocol';
import {PuppeteerURL, evaluationString} from '../common/util.js';
import {assert} from '../util/assert.js';
/**
* @internal
*/
export function createEvaluationError(
details: Protocol.Runtime.ExceptionDetails
): unknown {
let name: string;
let message: string;
if (!details.exception) {
name = 'Error';
message = details.text;
} else if (
(details.exception.type !== 'object' ||
details.exception.subtype !== 'error') &&
!details.exception.objectId
) {
return valueFromRemoteObject(details.exception);
} else {
const detail = getErrorDetails(details);
name = detail.name;
message = detail.message;
}
const messageHeight = message.split('\n').length;
const error = new Error(message);
error.name = name;
const stackLines = error.stack!.split('\n');
const messageLines = stackLines.splice(0, messageHeight);
// The first line is this function which we ignore.
stackLines.shift();
if (details.stackTrace && stackLines.length < Error.stackTraceLimit) {
for (const frame of details.stackTrace.callFrames.reverse()) {
if (
PuppeteerURL.isPuppeteerURL(frame.url) &&
frame.url !== PuppeteerURL.INTERNAL_URL
) {
const url = PuppeteerURL.parse(frame.url);
stackLines.unshift(
` at ${frame.functionName || url.functionName} (${
url.functionName
} at ${url.siteString}, <anonymous>:${frame.lineNumber}:${
frame.columnNumber
})`
);
} else {
stackLines.push(
` at ${frame.functionName || '<anonymous>'} (${frame.url}:${
frame.lineNumber
}:${frame.columnNumber})`
);
}
if (stackLines.length >= Error.stackTraceLimit) {
break;
}
}
}
error.stack = [...messageLines, ...stackLines].join('\n');
return error;
}
const getErrorDetails = (details: Protocol.Runtime.ExceptionDetails) => {
let name = '';
let message: string;
const lines = details.exception?.description?.split('\n at ') ?? [];
const size = Math.min(
details.stackTrace?.callFrames.length ?? 0,
lines.length - 1
);
lines.splice(-size, size);
if (details.exception?.className) {
name = details.exception.className;
}
message = lines.join('\n');
if (name && message.startsWith(`${name}: `)) {
message = message.slice(name.length + 2);
}
return {message, name};
};
/**
* @internal
*/
export function createClientError(
details: Protocol.Runtime.ExceptionDetails
): Error {
let name: string;
let message: string;
if (!details.exception) {
name = 'Error';
message = details.text;
} else if (
(details.exception.type !== 'object' ||
details.exception.subtype !== 'error') &&
!details.exception.objectId
) {
return valueFromRemoteObject(details.exception);
} else {
const detail = getErrorDetails(details);
name = detail.name;
message = detail.message;
}
const error = new Error(message);
error.name = name;
const messageHeight = error.message.split('\n').length;
const messageLines = error.stack!.split('\n').splice(0, messageHeight);
const stackLines = [];
if (details.stackTrace) {
for (const frame of details.stackTrace.callFrames) {
// Note we need to add `1` because the values are 0-indexed.
stackLines.push(
` at ${frame.functionName || '<anonymous>'} (${frame.url}:${
frame.lineNumber + 1
}:${frame.columnNumber + 1})`
);
if (stackLines.length >= Error.stackTraceLimit) {
break;
}
}
}
error.stack = [...messageLines, ...stackLines].join('\n');
return error;
}
/**
* @internal
*/
export function valueFromRemoteObject(
remoteObject: Protocol.Runtime.RemoteObject
): any {
assert(!remoteObject.objectId, 'Cannot extract value when objectId is given');
if (remoteObject.unserializableValue) {
if (remoteObject.type === 'bigint') {
return BigInt(remoteObject.unserializableValue.replace('n', ''));
}
switch (remoteObject.unserializableValue) {
case '-0':
return -0;
case 'NaN':
return NaN;
case 'Infinity':
return Infinity;
case '-Infinity':
return -Infinity;
default:
throw new Error(
'Unsupported unserializable value: ' +
remoteObject.unserializableValue
);
}
}
return remoteObject.value;
}
/**
* @internal
*/
export function addPageBinding(type: string, name: string): void {
// This is the CDP binding.
// @ts-expect-error: In a different context.
const callCdp = globalThis[name];
// Depending on the frame loading state either Runtime.evaluate or
// Page.addScriptToEvaluateOnNewDocument might succeed. Let's check that we
// don't re-wrap Puppeteer's binding.
if (callCdp[Symbol.toStringTag] === 'PuppeteerBinding') {
return;
}
// We replace the CDP binding with a Puppeteer binding.
Object.assign(globalThis, {
[name](...args: unknown[]): Promise<unknown> {
// This is the Puppeteer binding.
// @ts-expect-error: In a different context.
const callPuppeteer = globalThis[name];
callPuppeteer.args ??= new Map();
callPuppeteer.callbacks ??= new Map();
const seq = (callPuppeteer.lastSeq ?? 0) + 1;
callPuppeteer.lastSeq = seq;
callPuppeteer.args.set(seq, args);
callCdp(
JSON.stringify({
type,
name,
seq,
args,
isTrivial: !args.some(value => {
return value instanceof Node;
}),
})
);
return new Promise((resolve, reject) => {
callPuppeteer.callbacks.set(seq, {
resolve(value: unknown) {
callPuppeteer.args.delete(seq);
resolve(value);
},
reject(value?: unknown) {
callPuppeteer.args.delete(seq);
reject(value);
},
});
});
},
});
// @ts-expect-error: In a different context.
globalThis[name][Symbol.toStringTag] = 'PuppeteerBinding';
}
/**
* @internal
*/
export function pageBindingInitString(type: string, name: string): string {
return evaluationString(addPageBinding, type, name);
}
+114
View File
@@ -0,0 +1,114 @@
/**
* @license
* Copyright 2023 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type {Browser} from '../api/Browser.js';
import {_connectToBiDiBrowser} from '../bidi/BrowserConnector.js';
import {_connectToCdpBrowser} from '../cdp/BrowserConnector.js';
import {isNode} from '../environment.js';
import {assert} from '../util/assert.js';
import {isErrorLike} from '../util/ErrorLike.js';
import type {ConnectionTransport} from './ConnectionTransport.js';
import type {ConnectOptions} from './ConnectOptions.js';
import type {BrowserConnectOptions} from './ConnectOptions.js';
import {getFetch} from './fetch.js';
const getWebSocketTransportClass = async () => {
return isNode
? (await import('../node/NodeWebSocketTransport.js')).NodeWebSocketTransport
: (await import('../common/BrowserWebSocketTransport.js'))
.BrowserWebSocketTransport;
};
/**
* Users should never call this directly; it's called when calling
* `puppeteer.connect`. This method attaches Puppeteer to an existing browser instance.
*
* @internal
*/
export async function _connectToBrowser(
options: ConnectOptions
): Promise<Browser> {
const {connectionTransport, endpointUrl} =
await getConnectionTransport(options);
if (options.protocol === 'webDriverBiDi') {
const bidiBrowser = await _connectToBiDiBrowser(
connectionTransport,
endpointUrl,
options
);
return bidiBrowser;
} else {
const cdpBrowser = await _connectToCdpBrowser(
connectionTransport,
endpointUrl,
options
);
return cdpBrowser;
}
}
/**
* Establishes a websocket connection by given options and returns both transport and
* endpoint url the transport is connected to.
*/
async function getConnectionTransport(
options: BrowserConnectOptions & ConnectOptions
): Promise<{connectionTransport: ConnectionTransport; endpointUrl: string}> {
const {browserWSEndpoint, browserURL, transport, headers = {}} = options;
assert(
Number(!!browserWSEndpoint) + Number(!!browserURL) + Number(!!transport) ===
1,
'Exactly one of browserWSEndpoint, browserURL or transport must be passed to puppeteer.connect'
);
if (transport) {
return {connectionTransport: transport, endpointUrl: ''};
} else if (browserWSEndpoint) {
const WebSocketClass = await getWebSocketTransportClass();
const connectionTransport: ConnectionTransport =
await WebSocketClass.create(browserWSEndpoint, headers);
return {
connectionTransport: connectionTransport,
endpointUrl: browserWSEndpoint,
};
} else if (browserURL) {
const connectionURL = await getWSEndpoint(browserURL);
const WebSocketClass = await getWebSocketTransportClass();
const connectionTransport: ConnectionTransport =
await WebSocketClass.create(connectionURL);
return {
connectionTransport: connectionTransport,
endpointUrl: connectionURL,
};
}
throw new Error('Invalid connection options');
}
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;
}
}
+50
View File
@@ -0,0 +1,50 @@
/**
* @license
* Copyright 2020 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type {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();
}
}
+177
View File
@@ -0,0 +1,177 @@
/**
* @license
* Copyright 2023 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import {Deferred} from '../util/Deferred.js';
import {rewriteError} from '../util/ErrorLike.js';
import {ProtocolError, TargetCloseError} from './Errors.js';
import {debugError} from './util.js';
/**
* Manages callbacks and their IDs for the protocol request/response communication.
*
* @internal
*/
export class CallbackRegistry {
#callbacks = new Map<number, Callback>();
#idGenerator = createIncrementalIdGenerator();
create(
label: string,
timeout: number | undefined,
request: (id: number) => void
): Promise<unknown> {
const callback = new Callback(this.#idGenerator(), label, timeout);
this.#callbacks.set(callback.id, callback);
try {
request(callback.id);
} catch (error) {
// We still throw sync errors synchronously and clean up the scheduled
// callback.
callback.promise
.valueOrThrow()
.catch(debugError)
.finally(() => {
this.#callbacks.delete(callback.id);
});
callback.reject(error as Error);
throw error;
}
// Must only have sync code up until here.
return callback.promise.valueOrThrow().finally(() => {
this.#callbacks.delete(callback.id);
});
}
reject(id: number, message: string, originalMessage?: string): void {
const callback = this.#callbacks.get(id);
if (!callback) {
return;
}
this._reject(callback, message, originalMessage);
}
_reject(
callback: Callback,
errorMessage: string | ProtocolError,
originalMessage?: string
): void {
let error: ProtocolError;
let message: string;
if (errorMessage instanceof ProtocolError) {
error = errorMessage;
error.cause = callback.error;
message = errorMessage.message;
} else {
error = callback.error;
message = errorMessage;
}
callback.reject(
rewriteError(
error,
`Protocol error (${callback.label}): ${message}`,
originalMessage
)
);
}
resolve(id: number, value: unknown): void {
const callback = this.#callbacks.get(id);
if (!callback) {
return;
}
callback.resolve(value);
}
clear(): void {
for (const callback of this.#callbacks.values()) {
// TODO: probably we can accept error messages as params.
this._reject(callback, new TargetCloseError('Target closed'));
}
this.#callbacks.clear();
}
/**
* @internal
*/
getPendingProtocolErrors(): Error[] {
const result: Error[] = [];
for (const callback of this.#callbacks.values()) {
result.push(
new Error(`${callback.label} timed out. Trace: ${callback.error.stack}`)
);
}
return result;
}
}
/**
* @internal
*/
export class Callback {
#id: number;
#error = new ProtocolError();
#deferred = Deferred.create<unknown>();
#timer?: ReturnType<typeof setTimeout>;
#label: string;
constructor(id: number, label: string, timeout?: number) {
this.#id = id;
this.#label = label;
if (timeout) {
this.#timer = setTimeout(() => {
this.#deferred.reject(
rewriteError(
this.#error,
`${label} timed out. Increase the 'protocolTimeout' setting in launch/connect calls for a higher timeout if needed.`
)
);
}, timeout);
}
}
resolve(value: unknown): void {
clearTimeout(this.#timer);
this.#deferred.resolve(value);
}
reject(error: Error): void {
clearTimeout(this.#timer);
this.#deferred.reject(error);
}
get id(): number {
return this.#id;
}
get promise(): Deferred<unknown> {
return this.#deferred;
}
get error(): ProtocolError {
return this.#error;
}
get label(): string {
return this.#label;
}
}
/**
* @internal
*/
export function createIncrementalIdGenerator(): GetIdFn {
let id = 0;
return (): number => {
return ++id;
};
}
/**
* @internal
*/
export type GetIdFn = () => number;
+126
View File
@@ -0,0 +1,126 @@
/**
* @license
* Copyright 2022 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type {Product} from './Product.js';
/**
* Defines experiment options for Puppeteer.
*
* See individual properties for more information.
*
* @public
*/
export type ExperimentsConfiguration = Record<string, never>;
/**
* Defines options to configure Puppeteer's behavior during installation and
* runtime.
*
* See individual properties for more information.
*
* @public
*/
export interface Configuration {
/**
* Specifies a certain version of the browser you'd like Puppeteer to use.
*
* Can be overridden by `PUPPETEER_BROWSER_REVISION`.
*
* See {@link PuppeteerNode.launch | puppeteer.launch} on how executable path
* is inferred.
*
* Use a specific browser version (e.g., 119.0.6045.105). If you use an alias
* such `stable` or `canary` it will only work during the installation of
* Puppeteer and it will fail when launching the browser.
*
* @example 119.0.6045.105
* @defaultValue The pinned browser version supported by the current Puppeteer
* version.
*/
browserRevision?: string;
/**
* Defines the directory to be used by Puppeteer for caching.
*
* Can be overridden by `PUPPETEER_CACHE_DIR`.
*
* @defaultValue `path.join(os.homedir(), '.cache', 'puppeteer')`
*/
cacheDirectory?: string;
/**
* Specifies the URL prefix that is used to download the browser.
*
* Can be overridden by `PUPPETEER_DOWNLOAD_BASE_URL`.
*
* @remarks
* This must include the protocol and may even need a path prefix.
*
* @defaultValue Either https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing or
* https://archive.mozilla.org/pub/firefox/nightly/latest-mozilla-central,
* depending on the product.
*/
downloadBaseUrl?: string;
/**
* Specifies the path for the downloads folder.
*
* Can be overridden by `PUPPETEER_DOWNLOAD_PATH`.
*
* @defaultValue `<cacheDirectory>`
*/
downloadPath?: string;
/**
* Specifies an executable path to be used in
* {@link PuppeteerNode.launch | puppeteer.launch}.
*
* Can be overridden by `PUPPETEER_EXECUTABLE_PATH`.
*
* @defaultValue **Auto-computed.**
*/
executablePath?: string;
/**
* Specifies which browser you'd like Puppeteer to use.
*
* Can be overridden by `PUPPETEER_PRODUCT`.
*
* @defaultValue `chrome`
*/
defaultProduct?: Product;
/**
* Defines the directory to be used by Puppeteer for creating temporary files.
*
* Can be overridden by `PUPPETEER_TMP_DIR`.
*
* @defaultValue `os.tmpdir()`
*/
temporaryDirectory?: string;
/**
* Tells Puppeteer to not download during installation.
*
* Can be overridden by `PUPPETEER_SKIP_DOWNLOAD`.
*/
skipDownload?: boolean;
/**
* Tells Puppeteer to not Chrome download during installation.
*
* Can be overridden by `PUPPETEER_SKIP_CHROME_DOWNLOAD`.
*/
skipChromeDownload?: boolean;
/**
* Tells Puppeteer to not chrome-headless-shell download during installation.
*
* Can be overridden by `PUPPETEER_SKIP_CHROME_HEADLESSS_HELL_DOWNLOAD`.
*/
skipChromeHeadlessShellDownload?: boolean;
/**
* Tells Puppeteer to log at the given level.
*
* @defaultValue `warn`
*/
logLevel?: 'silent' | 'error' | 'warn';
/**
* Defines experimental options for Puppeteer.
*/
experiments?: ExperimentsConfiguration;
}
+77
View File
@@ -0,0 +1,77 @@
/**
* @license
* Copyright 2020 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type {
IsPageTargetCallback,
TargetFilterCallback,
} from '../api/Browser.js';
import type {ConnectionTransport} from './ConnectionTransport.js';
import type {Viewport} from './Viewport.js';
/**
* @public
*/
export type ProtocolType = 'cdp' | 'webDriverBiDi';
/**
* 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.
*
* @defaultValue '\{width: 800, height: 600\}'
*/
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;
/**
* @defaultValue 'cdp'
* @public
*/
protocol?: ProtocolType;
/**
* Timeout setting for individual protocol (CDP) calls.
*
* @defaultValue `180_000`
*/
protocolTimeout?: number;
}
/**
* @public
*/
export interface ConnectOptions extends BrowserConnectOptions {
browserWSEndpoint?: string;
browserURL?: string;
transport?: ConnectionTransport;
/**
* Headers to use for the web socket connection.
* @remarks
* Only works in the Node.js environment.
*/
headers?: Record<string, string>;
}
+15
View File
@@ -0,0 +1,15 @@
/**
* @license
* Copyright 2020 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @public
*/
export interface ConnectionTransport {
send(message: string): void;
close(): void;
onmessage?: (message: string) => void;
onclose?: () => void;
}
+113
View File
@@ -0,0 +1,113 @@
/**
* @license
* Copyright 2020 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type {JSHandle} from '../api/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;
}
/**
* The type of the console message.
*/
type(): ConsoleMessageType {
return this.#type;
}
/**
* The text of the console message.
*/
text(): string {
return this.#text;
}
/**
* An array of arguments passed to the console.
*/
args(): JSHandle[] {
return this.#args;
}
/**
* The location of the console message.
*/
location(): ConsoleMessageLocation {
return this.#stackTraceLocations[0] ?? {};
}
/**
* The array of locations on the stack of the console message.
*/
stackTrace(): ConsoleMessageLocation[] {
return this.#stackTraceLocations;
}
}
+207
View File
@@ -0,0 +1,207 @@
/**
* @license
* Copyright 2023 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type PuppeteerUtil from '../injected/injected.js';
import {assert} from '../util/assert.js';
import {interpolateFunction, stringifyFunction} from '../util/Function.js';
import {
QueryHandler,
type QuerySelector,
type QuerySelectorAll,
} from './QueryHandler.js';
import {scriptInjector} from './ScriptInjector.js';
/**
* @public
*/
export interface CustomQueryHandler {
/**
* Searches for a {@link https://developer.mozilla.org/en-US/docs/Web/API/Node | Node} matching the given `selector` from {@link https://developer.mozilla.org/en-US/docs/Web/API/Node | node}.
*/
queryOne?: (node: Node, selector: string) => Node | null;
/**
* Searches for some {@link https://developer.mozilla.org/en-US/docs/Web/API/Node | Nodes} matching the given `selector` from {@link https://developer.mozilla.org/en-US/docs/Web/API/Node | node}.
*/
queryAll?: (node: Node, selector: string) => Iterable<Node>;
}
/**
* The registry of {@link CustomQueryHandler | custom query handlers}.
*
* @example
*
* ```ts
* Puppeteer.customQueryHandlers.register('lit', { … });
* const aHandle = await page.$('lit/…');
* ```
*
* @internal
*/
export class CustomQueryHandlerRegistry {
#handlers = new Map<
string,
[registerScript: string, Handler: typeof QueryHandler]
>();
get(name: string): typeof QueryHandler | undefined {
const handler = this.#handlers.get(name);
return handler ? handler[1] : undefined;
}
/**
* 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
*
* ```ts
* Puppeteer.customQueryHandlers.register('lit', { … });
* const aHandle = await page.$('lit/…');
* ```
*
* @param name - Name to register under.
* @param queryHandler - {@link CustomQueryHandler | Custom query handler} to
* register.
*/
register(name: string, handler: CustomQueryHandler): void {
assert(
!this.#handlers.has(name),
`Cannot register over existing handler: ${name}`
);
assert(
/^[a-zA-Z]+$/.test(name),
`Custom query handler names may only contain [a-zA-Z]`
);
assert(
handler.queryAll || handler.queryOne,
`At least one query method must be implemented.`
);
const Handler = class extends QueryHandler {
static override querySelectorAll: QuerySelectorAll = interpolateFunction(
(node, selector, PuppeteerUtil) => {
return PuppeteerUtil.customQuerySelectors
.get(PLACEHOLDER('name'))!
.querySelectorAll(node, selector);
},
{name: JSON.stringify(name)}
);
static override querySelector: QuerySelector = interpolateFunction(
(node, selector, PuppeteerUtil) => {
return PuppeteerUtil.customQuerySelectors
.get(PLACEHOLDER('name'))!
.querySelector(node, selector);
},
{name: JSON.stringify(name)}
);
};
const registerScript = interpolateFunction(
(PuppeteerUtil: PuppeteerUtil) => {
PuppeteerUtil.customQuerySelectors.register(PLACEHOLDER('name'), {
queryAll: PLACEHOLDER('queryAll'),
queryOne: PLACEHOLDER('queryOne'),
});
},
{
name: JSON.stringify(name),
queryAll: handler.queryAll
? stringifyFunction(handler.queryAll)
: String(undefined),
queryOne: handler.queryOne
? stringifyFunction(handler.queryOne)
: String(undefined),
}
).toString();
this.#handlers.set(name, [registerScript, Handler]);
scriptInjector.append(registerScript);
}
/**
* Unregisters the {@link CustomQueryHandler | custom query handler} for the
* given name.
*
* @throws `Error` if there is no handler under the given name.
*/
unregister(name: string): void {
const handler = this.#handlers.get(name);
if (!handler) {
throw new Error(`Cannot unregister unknown handler: ${name}`);
}
scriptInjector.pop(handler[0]);
this.#handlers.delete(name);
}
/**
* Gets the names of all {@link CustomQueryHandler | custom query handlers}.
*/
names(): string[] {
return [...this.#handlers.keys()];
}
/**
* Unregisters all custom query handlers.
*/
clear(): void {
for (const [registerScript] of this.#handlers) {
scriptInjector.pop(registerScript);
}
this.#handlers.clear();
}
}
/**
* @internal
*/
export const customQueryHandlers = new CustomQueryHandlerRegistry();
/**
* @deprecated Import {@link Puppeteer} and use the static method
* {@link Puppeteer.registerCustomQueryHandler}
*
* @public
*/
export function registerCustomQueryHandler(
name: string,
handler: CustomQueryHandler
): void {
customQueryHandlers.register(name, handler);
}
/**
* @deprecated Import {@link Puppeteer} and use the static method
* {@link Puppeteer.unregisterCustomQueryHandler}
*
* @public
*/
export function unregisterCustomQueryHandler(name: string): void {
customQueryHandlers.unregister(name);
}
/**
* @deprecated Import {@link Puppeteer} and use the static method
* {@link Puppeteer.customQueryHandlerNames}
*
* @public
*/
export function customQueryHandlerNames(): string[] {
return customQueryHandlers.names();
}
/**
* @deprecated Import {@link Puppeteer} and use the static method
* {@link Puppeteer.clearCustomQueryHandlers}
*
* @public
*/
export function clearCustomQueryHandlers(): void {
customQueryHandlers.clear();
}
+128
View File
@@ -0,0 +1,128 @@
/**
* @license
* Copyright 2020 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import type Debug from 'debug';
import {isNode} from '../environment.js';
declare global {
// eslint-disable-next-line no-var
var __PUPPETEER_DEBUG: string;
}
/**
* @internal
*/
let debugModule: typeof Debug | null = null;
/**
* @internal
*/
export async function importDebug(): Promise<typeof 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[]) => {
if (captureLogs) {
capturedLogs.push(prefix + logArgs);
}
(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);
};
};
/**
* @internal
*/
let capturedLogs: string[] = [];
/**
* @internal
*/
let captureLogs = false;
/**
* @internal
*/
export function setLogCapture(value: boolean): void {
capturedLogs = [];
captureLogs = value;
}
/**
* @internal
*/
export function getCapturedLogs(): string[] {
return capturedLogs;
}
+1552
View File
File diff suppressed because it is too large Load Diff
+124
View File
@@ -0,0 +1,124 @@
/**
* @license
* Copyright 2018 Google Inc.
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @deprecated Do not use.
*
* @public
*/
export class CustomError extends Error {
/**
* @internal
*/
constructor(message?: string) {
super(message);
this.name = this.constructor.name;
}
/**
* @internal
*/
get [Symbol.toStringTag](): string {
return this.constructor.name;
}
}
/**
* 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 {
#code?: number;
#originalMessage = '';
set code(code: number | undefined) {
this.#code = code;
}
/**
* @readonly
* @public
*/
get code(): number | undefined {
return this.#code;
}
set originalMessage(originalMessage: string) {
this.#originalMessage = originalMessage;
}
/**
* @readonly
* @public
*/
get originalMessage(): string {
return this.#originalMessage;
}
}
/**
* Puppeteer will throw this error if a method is not
* supported by the currently used protocol
*
* @public
*/
export class UnsupportedOperation extends CustomError {}
/**
* @internal
*/
export class TargetCloseError extends ProtocolError {}
/**
* @deprecated Do not use.
*
* @public
*/
export interface PuppeteerErrors {
TimeoutError: typeof TimeoutError;
ProtocolError: typeof ProtocolError;
}
/**
* @deprecated Import error classes directly.
*
* 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 TimeoutError) {
* // Do something if this is a timeout.
* }
* }
* ```
*
* @public
*/
export const errors: PuppeteerErrors = Object.freeze({
TimeoutError,
ProtocolError,
});

Some files were not shown because too many files have changed in this diff Show More