/** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the 'License'); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an 'AS IS' BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import type { BrowserContext } from '../browserContext'; import type { Frame } from '../frames'; import { Page, Worker } from '../page'; import type * as channels from '@protocol/channels'; import { Dispatcher, existingDispatcher } from './dispatcher'; import { parseError, serializeError } from '../../protocol/serializers'; import { ConsoleMessageDispatcher } from './consoleMessageDispatcher'; import { DialogDispatcher } from './dialogDispatcher'; import { FrameDispatcher } from './frameDispatcher'; import { RequestDispatcher } from './networkDispatchers'; import { ResponseDispatcher } from './networkDispatchers'; import { RouteDispatcher, WebSocketDispatcher } from './networkDispatchers'; import { serializeResult, parseArgument } from './jsHandleDispatcher'; import { ElementHandleDispatcher } from './elementHandlerDispatcher'; import type { FileChooser } from '../fileChooser'; import type { CRCoverage } from '../chromium/crCoverage'; import type { JSHandle } from '../javascript'; import type { CallMetadata } from '../instrumentation'; import type { Artifact } from '../artifact'; import { ArtifactDispatcher } from './artifactDispatcher'; import type { Download } from '../download'; import { createGuid, urlMatches } from '../../utils'; import type { BrowserContextDispatcher } from './browserContextDispatcher'; export class PageDispatcher extends Dispatcher implements channels.PageChannel { _type_EventTarget = true; _type_Page = true; private _page: Page; _subscriptions = new Set(); static from(parentScope: BrowserContextDispatcher, page: Page): PageDispatcher { return PageDispatcher.fromNullable(parentScope, page)!; } static fromNullable(parentScope: BrowserContextDispatcher, page: Page | undefined): PageDispatcher | undefined { if (!page) return undefined; const result = existingDispatcher(page); return result || new PageDispatcher(parentScope, page); } private constructor(parentScope: BrowserContextDispatcher, page: Page) { // TODO: theoretically, there could be more than one frame already. // If we split pageCreated and pageReady, there should be no main frame during pageCreated. // We will reparent it to the page below using adopt. const mainFrame = FrameDispatcher.from(parentScope as any as PageDispatcher, page.mainFrame()); super(parentScope, page, 'Page', { mainFrame, viewportSize: page.viewportSize() || undefined, isClosed: page.isClosed(), opener: PageDispatcher.fromNullable(parentScope, page.opener()) }); this.adopt(mainFrame); this._page = page; this.addObjectListener(Page.Events.Close, () => { this._dispatchEvent('close'); this._dispose(); }); this.addObjectListener(Page.Events.Console, message => this._dispatchEvent('console', { message: new ConsoleMessageDispatcher(this, message) })); this.addObjectListener(Page.Events.Crash, () => this._dispatchEvent('crash')); this.addObjectListener(Page.Events.Dialog, dialog => this._dispatchEvent('dialog', { dialog: new DialogDispatcher(this, dialog) })); this.addObjectListener(Page.Events.Download, (download: Download) => { // Artifact can outlive the page, so bind to the context scope. this._dispatchEvent('download', { url: download.url, suggestedFilename: download.suggestedFilename(), artifact: ArtifactDispatcher.from(parentScope, download.artifact) }); }); this.addObjectListener(Page.Events.FileChooser, (fileChooser: FileChooser) => this._dispatchEvent('fileChooser', { element: ElementHandleDispatcher.from(this, fileChooser.element()), isMultiple: fileChooser.isMultiple() })); this.addObjectListener(Page.Events.FrameAttached, frame => this._onFrameAttached(frame)); this.addObjectListener(Page.Events.FrameDetached, frame => this._onFrameDetached(frame)); this.addObjectListener(Page.Events.PageError, error => this._dispatchEvent('pageError', { error: serializeError(error) })); this.addObjectListener(Page.Events.WebSocket, webSocket => this._dispatchEvent('webSocket', { webSocket: new WebSocketDispatcher(this, webSocket) })); this.addObjectListener(Page.Events.Worker, worker => this._dispatchEvent('worker', { worker: new WorkerDispatcher(this, worker) })); this.addObjectListener(Page.Events.Video, (artifact: Artifact) => this._dispatchEvent('video', { artifact: ArtifactDispatcher.from(parentScope, artifact) })); if (page._video) this._dispatchEvent('video', { artifact: ArtifactDispatcher.from(this.parentScope(), page._video) }); // Ensure client knows about all frames. const frames = page._frameManager.frames(); for (let i = 1; i < frames.length; i++) this._onFrameAttached(frames[i]); } page(): Page { return this._page; } async setDefaultNavigationTimeoutNoReply(params: channels.PageSetDefaultNavigationTimeoutNoReplyParams, metadata: CallMetadata): Promise { this._page.setDefaultNavigationTimeout(params.timeout); } async setDefaultTimeoutNoReply(params: channels.PageSetDefaultTimeoutNoReplyParams, metadata: CallMetadata): Promise { this._page.setDefaultTimeout(params.timeout); } async exposeBinding(params: channels.PageExposeBindingParams, metadata: CallMetadata): Promise { await this._page.exposeBinding(params.name, !!params.needsHandle, (source, ...args) => { const binding = new BindingCallDispatcher(this, params.name, !!params.needsHandle, source, args); this._dispatchEvent('bindingCall', { binding }); return binding.promise(); }); } async setExtraHTTPHeaders(params: channels.PageSetExtraHTTPHeadersParams, metadata: CallMetadata): Promise { await this._page.setExtraHTTPHeaders(params.headers); } async reload(params: channels.PageReloadParams, metadata: CallMetadata): Promise { return { response: ResponseDispatcher.fromNullable(this.parentScope(), await this._page.reload(metadata, params)) }; } async goBack(params: channels.PageGoBackParams, metadata: CallMetadata): Promise { return { response: ResponseDispatcher.fromNullable(this.parentScope(), await this._page.goBack(metadata, params)) }; } async goForward(params: channels.PageGoForwardParams, metadata: CallMetadata): Promise { return { response: ResponseDispatcher.fromNullable(this.parentScope(), await this._page.goForward(metadata, params)) }; } async emulateMedia(params: channels.PageEmulateMediaParams, metadata: CallMetadata): Promise { await this._page.emulateMedia({ media: params.media, colorScheme: params.colorScheme, reducedMotion: params.reducedMotion, forcedColors: params.forcedColors, }); } async setViewportSize(params: channels.PageSetViewportSizeParams, metadata: CallMetadata): Promise { await this._page.setViewportSize(params.viewportSize); } async addInitScript(params: channels.PageAddInitScriptParams, metadata: CallMetadata): Promise { await this._page.addInitScript(params.source); } async setNetworkInterceptionPatterns(params: channels.PageSetNetworkInterceptionPatternsParams, metadata: CallMetadata): Promise { if (!params.patterns.length) { await this._page.setClientRequestInterceptor(undefined); return; } const urlMatchers = params.patterns.map(pattern => pattern.regexSource ? new RegExp(pattern.regexSource, pattern.regexFlags!) : pattern.glob!); await this._page.setClientRequestInterceptor((route, request) => { const matchesSome = urlMatchers.some(urlMatch => urlMatches(this._page._browserContext._options.baseURL, request.url(), urlMatch)); if (!matchesSome) return false; this._dispatchEvent('route', { route: RouteDispatcher.from(RequestDispatcher.from(this.parentScope(), request), route) }); return true; }); } async expectScreenshot(params: channels.PageExpectScreenshotParams, metadata: CallMetadata): Promise { const mask: { frame: Frame, selector: string }[] = (params.screenshotOptions?.mask || []).map(({ frame, selector }) => ({ frame: (frame as FrameDispatcher)._object, selector, })); const locator: { frame: Frame, selector: string } | undefined = params.locator ? { frame: (params.locator.frame as FrameDispatcher)._object, selector: params.locator.selector, } : undefined; return await this._page.expectScreenshot(metadata, { ...params, locator, screenshotOptions: { ...params.screenshotOptions, mask, }, }); } async screenshot(params: channels.PageScreenshotParams, metadata: CallMetadata): Promise { const mask: { frame: Frame, selector: string }[] = (params.mask || []).map(({ frame, selector }) => ({ frame: (frame as FrameDispatcher)._object, selector, })); return { binary: await this._page.screenshot(metadata, { ...params, mask }) }; } async close(params: channels.PageCloseParams, metadata: CallMetadata): Promise { await this._page.close(metadata, params); } async updateSubscription(params: channels.PageUpdateSubscriptionParams, metadata?: channels.Metadata | undefined): Promise { if (params.event === 'fileChooser') await this._page.setFileChooserIntercepted(params.enabled); if (params.enabled) this._subscriptions.add(params.event); else this._subscriptions.delete(params.event); } async keyboardDown(params: channels.PageKeyboardDownParams, metadata: CallMetadata): Promise { await this._page.keyboard.down(params.key); } async keyboardUp(params: channels.PageKeyboardUpParams, metadata: CallMetadata): Promise { await this._page.keyboard.up(params.key); } async keyboardInsertText(params: channels.PageKeyboardInsertTextParams, metadata: CallMetadata): Promise { await this._page.keyboard.insertText(params.text); } async keyboardType(params: channels.PageKeyboardTypeParams, metadata: CallMetadata): Promise { await this._page.keyboard.type(params.text, params); } async keyboardPress(params: channels.PageKeyboardPressParams, metadata: CallMetadata): Promise { await this._page.keyboard.press(params.key, params); } async mouseMove(params: channels.PageMouseMoveParams, metadata: CallMetadata): Promise { await this._page.mouse.move(params.x, params.y, params); } async mouseDown(params: channels.PageMouseDownParams, metadata: CallMetadata): Promise { await this._page.mouse.down(params); } async mouseUp(params: channels.PageMouseUpParams, metadata: CallMetadata): Promise { await this._page.mouse.up(params); } async mouseClick(params: channels.PageMouseClickParams, metadata: CallMetadata): Promise { await this._page.mouse.click(params.x, params.y, params); } async mouseWheel(params: channels.PageMouseWheelParams, metadata: CallMetadata): Promise { await this._page.mouse.wheel(params.deltaX, params.deltaY); } async touchscreenTap(params: channels.PageTouchscreenTapParams, metadata: CallMetadata): Promise { await this._page.touchscreen.tap(params.x, params.y); } async accessibilitySnapshot(params: channels.PageAccessibilitySnapshotParams, metadata: CallMetadata): Promise { const rootAXNode = await this._page.accessibility.snapshot({ interestingOnly: params.interestingOnly, root: params.root ? (params.root as ElementHandleDispatcher)._elementHandle : undefined }); return { rootAXNode: rootAXNode || undefined }; } async pdf(params: channels.PagePdfParams, metadata: CallMetadata): Promise { if (!this._page.pdf) throw new Error('PDF generation is only supported for Headless Chromium'); const buffer = await this._page.pdf(params); return { pdf: buffer }; } async bringToFront(params: channels.PageBringToFrontParams, metadata: CallMetadata): Promise { await this._page.bringToFront(); } async startJSCoverage(params: channels.PageStartJSCoverageParams, metadata: CallMetadata): Promise { const coverage = this._page.coverage as CRCoverage; await coverage.startJSCoverage(params); } async stopJSCoverage(params: channels.PageStopJSCoverageParams, metadata: CallMetadata): Promise { const coverage = this._page.coverage as CRCoverage; return await coverage.stopJSCoverage(); } async startCSSCoverage(params: channels.PageStartCSSCoverageParams, metadata: CallMetadata): Promise { const coverage = this._page.coverage as CRCoverage; await coverage.startCSSCoverage(params); } async stopCSSCoverage(params: channels.PageStopCSSCoverageParams, metadata: CallMetadata): Promise { const coverage = this._page.coverage as CRCoverage; return await coverage.stopCSSCoverage(); } _onFrameAttached(frame: Frame) { this._dispatchEvent('frameAttached', { frame: FrameDispatcher.from(this, frame) }); } _onFrameDetached(frame: Frame) { this._dispatchEvent('frameDetached', { frame: FrameDispatcher.from(this, frame) }); } override _onDispose() { // Avoid protocol calls for the closed page. if (!this._page.isClosedOrClosingOrCrashed()) this._page.setClientRequestInterceptor(undefined).catch(() => {}); } } export class WorkerDispatcher extends Dispatcher implements channels.WorkerChannel { _type_Worker = true; static fromNullable(scope: PageDispatcher | BrowserContextDispatcher, worker: Worker | null): WorkerDispatcher | undefined { if (!worker) return undefined; const result = existingDispatcher(worker); return result || new WorkerDispatcher(scope, worker); } constructor(scope: PageDispatcher | BrowserContextDispatcher, worker: Worker) { super(scope, worker, 'Worker', { url: worker.url() }); this.addObjectListener(Worker.Events.Close, () => this._dispatchEvent('close')); } async evaluateExpression(params: channels.WorkerEvaluateExpressionParams, metadata: CallMetadata): Promise { return { value: serializeResult(await this._object.evaluateExpression(params.expression, params.isFunction, parseArgument(params.arg))) }; } async evaluateExpressionHandle(params: channels.WorkerEvaluateExpressionHandleParams, metadata: CallMetadata): Promise { return { handle: ElementHandleDispatcher.fromJSHandle(this, await this._object.evaluateExpressionHandle(params.expression, params.isFunction, parseArgument(params.arg))) }; } } export class BindingCallDispatcher extends Dispatcher<{ guid: string }, channels.BindingCallChannel, PageDispatcher | BrowserContextDispatcher> implements channels.BindingCallChannel { _type_BindingCall = true; private _resolve: ((arg: any) => void) | undefined; private _reject: ((error: any) => void) | undefined; private _promise: Promise; constructor(scope: PageDispatcher, name: string, needsHandle: boolean, source: { context: BrowserContext, page: Page, frame: Frame }, args: any[]) { super(scope, { guid: 'bindingCall@' + createGuid() }, 'BindingCall', { frame: FrameDispatcher.from(scope, source.frame), name, args: needsHandle ? undefined : args.map(serializeResult), handle: needsHandle ? ElementHandleDispatcher.fromJSHandle(scope, args[0] as JSHandle) : undefined, }); this._promise = new Promise((resolve, reject) => { this._resolve = resolve; this._reject = reject; }); } promise() { return this._promise; } async resolve(params: channels.BindingCallResolveParams, metadata: CallMetadata): Promise { this._resolve!(parseArgument(params.result)); } async reject(params: channels.BindingCallRejectParams, metadata: CallMetadata): Promise { this._reject!(parseError(params.error)); } }