/** * @license * Copyright 2017 Google Inc. * SPDX-License-Identifier: Apache-2.0 */ import type {ChildProcess} from 'node:child_process'; import type {Protocol} from 'devtools-protocol'; import type { CreatePageOptions, DebugInfo, ExtensionInstallOptions, } from '../api/Browser.js'; import { Browser as BrowserBase, BrowserEvent, type BrowserCloseCallback, type BrowserContextOptions, type IsPageTargetCallback, type TargetFilterCallback, type ScreenInfo, type AddScreenParams, type WindowBounds, type WindowId, type InstallPWAOptions, type UninstallPWAOptions, type LaunchPWAOptions, type GetPWAStateOptions, type PWAState, type NetworkRestrictions, } from '../api/Browser.js'; import {BrowserContextEvent} from '../api/BrowserContext.js'; import {CDPSessionEvent} from '../api/CDPSession.js'; import type {Extension} from '../api/Extension.js'; import type {Page} from '../api/Page.js'; import type {Target} from '../api/Target.js'; import {DEBUG_PREFIXES} from '../common/Debug.js'; import type {Logger} from '../common/Debug.js'; import type {DownloadBehavior} from '../common/DownloadBehavior.js'; import {EventEmitter} from '../common/EventEmitter.js'; import type {Viewport} from '../common/Viewport.js'; import {Deferred} from '../util/Deferred.js'; import {DisposableStack} from '../util/disposable.js'; import {CdpBrowserContext} from './BrowserContext.js'; import type {CdpCDPSession} from './CdpSession.js'; import type {Connection} from './Connection.js'; import {CdpExtension} from './Extension.js'; import { DevToolsTarget, InitializationStatus, OtherTarget, PageTarget, WorkerTarget, type CdpTarget, } from './Target.js'; import {TargetManagerEvent} from './TargetManageEvents.js'; import {TargetManager} from './TargetManager.js'; /** * @internal */ function isDevToolsPageTarget(url: string): boolean { return url.startsWith('devtools://devtools/bundled/devtools_app.html'); } /** * @internal */ export class CdpBrowser extends BrowserBase { readonly protocol = 'cdp'; static async _create( connection: Connection, contextIds: string[], acceptInsecureCerts: boolean, defaultViewport: Viewport | null | undefined = undefined, downloadBehavior: DownloadBehavior | undefined = undefined, process: ChildProcess | undefined = undefined, closeCallback: BrowserCloseCallback | undefined = undefined, targetFilterCallback: TargetFilterCallback | undefined = undefined, isPageTargetCallback: IsPageTargetCallback | undefined = undefined, waitForInitiallyDiscoveredTargets = true, networkEnabled = true, issuesEnabled = true, handleDevToolsAsPage = false, blocklist: string[] | undefined = undefined, allowlist: string[] | undefined = undefined, logger: Logger, ): Promise { const browser = new CdpBrowser( connection, contextIds, defaultViewport, process, closeCallback, targetFilterCallback, isPageTargetCallback, waitForInitiallyDiscoveredTargets, networkEnabled, issuesEnabled, handleDevToolsAsPage, blocklist, allowlist, logger, ); if (acceptInsecureCerts) { await connection.send('Security.setIgnoreCertificateErrors', { ignore: true, }); } await browser._attach(downloadBehavior); return browser; } #defaultViewport?: Viewport | null; #process?: ChildProcess; #connection: Connection; #closeCallback: BrowserCloseCallback; #targetFilterCallback: TargetFilterCallback; #isPageTargetCallback!: IsPageTargetCallback; #defaultContext: CdpBrowserContext; #contexts = new Map(); #networkEnabled = true; #issuesEnabled = true; #targetManager: TargetManager; #handleDevToolsAsPage = false; #extensions = new Map(); #version?: Deferred; #hasNetworkRestrictions = false; #subscriptions = new DisposableStack(); constructor( connection: Connection, contextIds: string[], defaultViewport: Viewport | null | undefined = undefined, process: ChildProcess | undefined = undefined, closeCallback: BrowserCloseCallback | undefined = undefined, targetFilterCallback: TargetFilterCallback | undefined = undefined, isPageTargetCallback: IsPageTargetCallback | undefined = undefined, waitForInitiallyDiscoveredTargets = true, networkEnabled = true, issuesEnabled = true, handleDevToolsAsPage = false, blocklist: string[] | undefined = undefined, allowlist: string[] | undefined = undefined, logger: Logger, ) { super(logger); this.#networkEnabled = networkEnabled; this.#issuesEnabled = issuesEnabled; this.#defaultViewport = defaultViewport; this.#process = process; this.#connection = connection; this.#closeCallback = closeCallback || (() => {}); this.#targetFilterCallback = targetFilterCallback || (() => { return true; }); this.#handleDevToolsAsPage = handleDevToolsAsPage; this.#setIsPageTargetCallback(isPageTargetCallback); this.#hasNetworkRestrictions = Boolean( (blocklist && blocklist.length > 0) || (allowlist && allowlist.length > 0), ); connection.rejectEmulateNetworkConditionsCalls = this.#hasNetworkRestrictions; this.#targetManager = new TargetManager( connection, this.#createTarget, this.#targetFilterCallback, waitForInitiallyDiscoveredTargets, blocklist, allowlist, logger, ); this.#defaultContext = new CdpBrowserContext( this.#connection, this, undefined, logger, ); for (const contextId of contextIds) { this.#contexts.set( contextId, new CdpBrowserContext(this.#connection, this, contextId, logger), ); } } #emitDisconnected = () => { this.emit(BrowserEvent.Disconnected, undefined); }; async _attach(downloadBehavior: DownloadBehavior | undefined): Promise { const connectionEmitter = this.#subscriptions.use( new EventEmitter(this.#connection), ); connectionEmitter.on(CDPSessionEvent.Disconnected, this.#emitDisconnected); if (downloadBehavior) { await this.#defaultContext.setDownloadBehavior(downloadBehavior); } const targetManagerEmitter = this.#subscriptions.use( new EventEmitter(this.#targetManager), ); targetManagerEmitter.on( TargetManagerEvent.TargetAvailable, this.#onAttachedToTarget, ); targetManagerEmitter.on( TargetManagerEvent.TargetGone, this.#onDetachedFromTarget, ); targetManagerEmitter.on( TargetManagerEvent.TargetChanged, this.#onTargetChanged, ); targetManagerEmitter.on( TargetManagerEvent.TargetDiscovered, this.#onTargetDiscovered, ); await this.#targetManager.initialize(); } _detach(): void { this.#subscriptions.dispose(); } override process(): ChildProcess | null { return this.#process ?? null; } _targetManager(): TargetManager { return this.#targetManager; } override async restrictNetwork( conditions?: NetworkRestrictions | null, ): Promise { await this.#targetManager.setNetworkConditions(conditions); // Only update the state once the target manager has accepted the new // conditions so that failures (e.g. invalid URL patterns) do not leave // the browser and connection out of sync with the target manager. this.#hasNetworkRestrictions = Boolean( (conditions?.blocklist && conditions.blocklist.length > 0) || (conditions?.allowlist && conditions.allowlist.length > 0), ); this.#connection.rejectEmulateNetworkConditionsCalls = this.#hasNetworkRestrictions; } #setIsPageTargetCallback(isPageTargetCallback?: IsPageTargetCallback): void { this.#isPageTargetCallback = isPageTargetCallback || ((target: Target): boolean => { return ( target.type() === 'page' || target.type() === 'background_page' || target.type() === 'webview' || (this.#handleDevToolsAsPage && target.type() === 'other' && isDevToolsPageTarget(target.url())) ); }); } _getIsPageTargetCallback(): IsPageTargetCallback | undefined { return this.#isPageTargetCallback; } override async createBrowserContext( options: BrowserContextOptions = {}, ): Promise { const {proxyServer, proxyBypassList, downloadBehavior} = options; const {browserContextId} = await this.#connection.send( 'Target.createBrowserContext', { proxyServer, proxyBypassList: proxyBypassList && proxyBypassList.join(','), }, ); const context = new CdpBrowserContext( this.#connection, this, browserContextId, this.logger, ); if (downloadBehavior) { await context.setDownloadBehavior(downloadBehavior); } 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 { if (!contextId) { return; } await this.#connection.send('Target.disposeBrowserContext', { browserContextId: contextId, }); this.#contexts.delete(contextId); } #createTarget = ( targetInfo: Protocol.Target.TargetInfo, session?: CdpCDPSession, ) => { 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, this.logger, ); if (targetInfo.url && isDevToolsPageTarget(targetInfo.url)) { return new DevToolsTarget( targetInfo, session, context, this.#targetManager, createSession, this.#defaultViewport ?? null, this.logger, ); } if (this.#isPageTargetCallback(otherTarget)) { return new PageTarget( targetInfo, session, context, this.#targetManager, createSession, this.#defaultViewport ?? null, this.logger, ); } if ( targetInfo.type === 'service_worker' || targetInfo.type === 'shared_worker' ) { return new WorkerTarget( targetInfo, session, context, this.#targetManager, createSession, this.logger, ); } 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 => { 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(options?: CreatePageOptions): Promise { return await this.#defaultContext.newPage(options); } async _createPageInContext( contextId?: string, options?: CreatePageOptions, ): Promise { const hasTargets = this.targets().filter(t => { return t.browserContext().id === contextId; }).length > 0; const windowBounds = options?.type === 'window' ? options.windowBounds : undefined; const {targetId} = await this.#connection.send('Target.createTarget', { url: 'about:blank', browserContextId: contextId || undefined, left: windowBounds?.left, top: windowBounds?.top, width: windowBounds?.width, height: windowBounds?.height, windowState: windowBounds?.windowState, // Works around crbug.com/454825274. newWindow: hasTargets && options?.type === 'window' ? true : undefined, background: options?.background, }); 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; } async _createDevToolsPage(pageTargetId: string): Promise { const openDevToolsResponse = await this.#connection.send( 'Target.openDevTools', { targetId: pageTargetId, }, ); return await this._getDevToolsTargetPage(openDevToolsResponse.targetId); } async _getDevToolsTargetPage(devtoolsTargetId: string): Promise { const target = (await this.waitForTarget(t => { return (t as CdpTarget)._targetId === devtoolsTargetId; })) as CdpTarget; if (!target) { throw new Error( `Missing target for DevTools page (id = ${devtoolsTargetId})`, ); } const initialized = (await target._initializedDeferred.valueOrThrow()) === InitializationStatus.SUCCESS; if (!initialized) { throw new Error( `Failed to create target for DevTools page (id = ${devtoolsTargetId})`, ); } const page = await target.page(); if (!page) { throw new Error( `Failed to create a DevTools Page for target (id = ${devtoolsTargetId})`, ); } return page; } async _hasDevToolsTarget(pageTargetId: string): Promise { const response = await this.#connection.send('Target.getDevToolsTarget', { targetId: pageTargetId, }); return response.targetId; } override async installExtension( path: string, options?: ExtensionInstallOptions, ): Promise { const existingTargetIds = new Set( this._targetManager().getDiscoveredTargetInfos().keys(), ); const {id} = await this.#connection.send('Extensions.loadUnpacked', { path, enableInIncognito: options?.enabledInIncognito ?? false, }); await this.#destroyExtensionServiceWorkerTargets(id, existingTargetIds); this.#extensions.delete(id); return id; } override async uninstallExtension(id: string): Promise { await this.#connection.send('Extensions.uninstall', {id}); await this.#destroyExtensionServiceWorkerTargets(id); this.#extensions.delete(id); } async #destroyExtensionServiceWorkerTargets( id: string, existingTargetIds?: ReadonlySet, ): Promise { // Currently sending Extensions.loadUnpacked (when re-installing an // existing extension) or Extensions.uninstall does not trigger the // Target.targetDestroyed event for replaced/uninstalled service workers. // This causes flakiness in extension tests and leaves stale targets in // browser.targets(). // TODO(nroscino): Remove this once the event is correctly emitted. const targetDestroyedPromises = []; for (const [targetId, targetInfo] of this._targetManager() .getDiscoveredTargetInfos() .entries()) { if ( (!existingTargetIds || existingTargetIds.has(targetId)) && targetInfo.url.includes(id) && targetInfo.type === 'service_worker' ) { this._targetManager().addToIgnoreTarget(targetId); targetDestroyedPromises.push( new Promise(resolve => { return setTimeout(() => { this.#connection.emit('Target.targetDestroyed', { targetId: targetId, }); resolve(null); }, 0); }), ); } } await Promise.all(targetDestroyedPromises); } override async installPWA(options: InstallPWAOptions): Promise { if (this.#hasNetworkRestrictions) { throw new Error( 'PWA APIs are not supported when network restrictions are configured.', ); } await this.#connection.send('PWA.install', { manifestId: options.manifestId, installUrlOrBundleUrl: options.installUrlOrBundleUrl, }); if (options.displayMode) { await this.#connection.send('PWA.changeAppUserSettings', { manifestId: options.manifestId, displayMode: options.displayMode, }); } return options.manifestId; } override async uninstallPWA(options: UninstallPWAOptions): Promise { if (this.#hasNetworkRestrictions) { throw new Error( 'PWA APIs are not supported when network restrictions are configured.', ); } await this.#connection.send('PWA.uninstall', { manifestId: options.manifestId, }); } override async launchPWA(options: LaunchPWAOptions): Promise { if (this.#hasNetworkRestrictions) { throw new Error( 'PWA APIs are not supported when network restrictions are configured.', ); } // `PWA.launch` resolves with the id of the launched *tab* target (see the // CDP `PWA.LaunchResponse` docs). Tab targets sit above page targets in the // target hierarchy and are not exposed through `browser.targets()`, so the // returned id can't be awaited directly. const {targetId: tabTargetId} = await this.#connection.send('PWA.launch', { manifestId: options.manifestId, url: options.url, }); const target = (await this.waitForTarget( candidate => { const tab = this.#targetManager.getAvailableTargets().get(tabTargetId); if (tab?.type() !== 'tab') { return false; } for (const child of tab._childTargets()) { if (child === candidate) { return true; } } return false; }, {timeout: options.timeout}, )) as CdpTarget; const page = await target.page(); if (!page) { throw new Error( `Failed to create a page for the launched PWA (manifestId = ${options.manifestId})`, ); } return page; } override async getPWAState(options: GetPWAStateOptions): Promise { if (this.#hasNetworkRestrictions) { throw new Error( 'PWA APIs are not supported when network restrictions are configured.', ); } const {badgeCount, fileHandlers} = await this.#connection.send( 'PWA.getOsAppState', {manifestId: options.manifestId}, ); return {badgeCount, fileHandlers}; } override async screens(): Promise { const {screenInfos} = await this.#connection.send( 'Emulation.getScreenInfos', ); return screenInfos; } override async addScreen(params: AddScreenParams): Promise { const {screenInfo} = await this.#connection.send( 'Emulation.addScreen', params, ); return screenInfo; } override async removeScreen(screenId: string): Promise { return await this.#connection.send('Emulation.removeScreen', {screenId}); } override async getWindowBounds(windowId: WindowId): Promise { const {bounds} = await this.#connection.send('Browser.getWindowBounds', { windowId: Number(windowId), }); return bounds; } override async setWindowBounds( windowId: WindowId, windowBounds: WindowBounds, ): Promise { await this.#connection.send('Browser.setWindowBounds', { windowId: Number(windowId), bounds: windowBounds, }); } 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 { const version = await this.#getVersion(); return version.product; } override async userAgent(): Promise { const version = await this.#getVersion(); return version.userAgent; } override async close(): Promise { try { await this.#closeCallback.call(null); } catch (error) { // Fail silently. this.logger?.(DEBUG_PREFIXES.error)?.(error); } finally { await this.disconnect(); } } override disconnect(): Promise { this.#targetManager.dispose(); this.#connection.dispose(); this._detach(); return Promise.resolve(); } /** * @internal */ get _connection(): Connection { return this.#connection; } override get connected(): boolean { return !this.#connection._closed; } async #getVersion(): Promise { if (!this.#version) { this.#version = Deferred.create(); try { this.#version.resolve( await this.#connection.send('Browser.getVersion'), ); } catch (error) { this.#version.reject(error as Error); } } return await this.#version.valueOrThrow(); } override get debugInfo(): DebugInfo { return { pendingProtocolErrors: this.#connection.getPendingProtocolErrors(), }; } override isNetworkEnabled(): boolean { return this.#networkEnabled; } override async extensions(): Promise> { const response = await this.#connection.send('Extensions.getExtensions'); const extensionsMap = new Map(); for (const currExtension of response.extensions) { if (this.#extensions.has(currExtension.id)) { extensionsMap.set( currExtension.id, this.#extensions.get(currExtension.id)!, ); } else { const newExtension = new CdpExtension( currExtension.id, currExtension.version, currExtension.name, currExtension.path, currExtension.enabled, this, this.logger, ); extensionsMap.set(currExtension.id, newExtension); } } this.#extensions = extensionsMap; return this.#extensions; } override isIssuesEnabled(): boolean { return this.#issuesEnabled; } }