914 lines
26 KiB
TypeScript
914 lines
26 KiB
TypeScript
/**
|
|
* @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)}}`;
|
|
}
|