Page.ts (36648B)
1 /** 2 * @license 3 * Copyright 2017 Google Inc. 4 * SPDX-License-Identifier: Apache-2.0 5 */ 6 7 import type {Protocol} from 'devtools-protocol'; 8 9 import {firstValueFrom, from, raceWith} from '../../third_party/rxjs/rxjs.js'; 10 import type {Browser} from '../api/Browser.js'; 11 import type {BrowserContext} from '../api/BrowserContext.js'; 12 import {CDPSessionEvent, type CDPSession} from '../api/CDPSession.js'; 13 import type {ElementHandle} from '../api/ElementHandle.js'; 14 import type {Frame, WaitForOptions} from '../api/Frame.js'; 15 import type {HTTPResponse} from '../api/HTTPResponse.js'; 16 import type {JSHandle} from '../api/JSHandle.js'; 17 import type {Credentials} from '../api/Page.js'; 18 import { 19 Page, 20 PageEvent, 21 type GeolocationOptions, 22 type MediaFeature, 23 type Metrics, 24 type NewDocumentScriptEvaluation, 25 type ScreenshotClip, 26 type ScreenshotOptions, 27 type WaitTimeoutOptions, 28 } from '../api/Page.js'; 29 import { 30 ConsoleMessage, 31 type ConsoleMessageType, 32 } from '../common/ConsoleMessage.js'; 33 import type { 34 Cookie, 35 DeleteCookiesRequest, 36 CookieParam, 37 CookiePartitionKey, 38 } from '../common/Cookie.js'; 39 import {TargetCloseError} from '../common/Errors.js'; 40 import {EventEmitter} from '../common/EventEmitter.js'; 41 import {FileChooser} from '../common/FileChooser.js'; 42 import {NetworkManagerEvent} from '../common/NetworkManagerEvents.js'; 43 import type {PDFOptions} from '../common/PDFOptions.js'; 44 import type {BindingPayload, HandleFor} from '../common/types.js'; 45 import { 46 debugError, 47 evaluationString, 48 getReadableAsTypedArray, 49 getReadableFromProtocolStream, 50 parsePDFOptions, 51 timeout, 52 validateDialogType, 53 } from '../common/util.js'; 54 import type {Viewport} from '../common/Viewport.js'; 55 import {assert} from '../util/assert.js'; 56 import {Deferred} from '../util/Deferred.js'; 57 import {AsyncDisposableStack} from '../util/disposable.js'; 58 import {isErrorLike} from '../util/ErrorLike.js'; 59 60 import {Binding} from './Binding.js'; 61 import {CdpCDPSession} from './CdpSession.js'; 62 import {isTargetClosedError} from './Connection.js'; 63 import {Coverage} from './Coverage.js'; 64 import type {DeviceRequestPrompt} from './DeviceRequestPrompt.js'; 65 import {CdpDialog} from './Dialog.js'; 66 import {EmulationManager} from './EmulationManager.js'; 67 import type {CdpFrame} from './Frame.js'; 68 import {FrameManager} from './FrameManager.js'; 69 import {FrameManagerEvent} from './FrameManagerEvents.js'; 70 import {CdpKeyboard, CdpMouse, CdpTouchscreen} from './Input.js'; 71 import type {IsolatedWorld} from './IsolatedWorld.js'; 72 import {MAIN_WORLD} from './IsolatedWorlds.js'; 73 import {releaseObject} from './JSHandle.js'; 74 import type {NetworkConditions} from './NetworkManager.js'; 75 import type {CdpTarget} from './Target.js'; 76 import {TargetManagerEvent} from './TargetManageEvents.js'; 77 import type {TargetManager} from './TargetManager.js'; 78 import {Tracing} from './Tracing.js'; 79 import { 80 createClientError, 81 pageBindingInitString, 82 valueFromRemoteObject, 83 } from './utils.js'; 84 import {CdpWebWorker} from './WebWorker.js'; 85 86 function convertConsoleMessageLevel(method: string): ConsoleMessageType { 87 switch (method) { 88 case 'warning': 89 return 'warn'; 90 default: 91 return method as ConsoleMessageType; 92 } 93 } 94 95 /** 96 * @internal 97 */ 98 export class CdpPage extends Page { 99 static async _create( 100 client: CdpCDPSession, 101 target: CdpTarget, 102 defaultViewport: Viewport | null, 103 ): Promise<CdpPage> { 104 const page = new CdpPage(client, target); 105 await page.#initialize(); 106 if (defaultViewport) { 107 try { 108 await page.setViewport(defaultViewport); 109 } catch (err) { 110 if (isErrorLike(err) && isTargetClosedError(err)) { 111 debugError(err); 112 } else { 113 throw err; 114 } 115 } 116 } 117 return page; 118 } 119 120 #closed = false; 121 readonly #targetManager: TargetManager; 122 123 #primaryTargetClient: CdpCDPSession; 124 #primaryTarget: CdpTarget; 125 #tabTargetClient: CDPSession; 126 #tabTarget: CdpTarget; 127 #keyboard: CdpKeyboard; 128 #mouse: CdpMouse; 129 #touchscreen: CdpTouchscreen; 130 #frameManager: FrameManager; 131 #emulationManager: EmulationManager; 132 #tracing: Tracing; 133 #bindings = new Map<string, Binding>(); 134 #exposedFunctions = new Map<string, string>(); 135 #coverage: Coverage; 136 #viewport: Viewport | null; 137 #workers = new Map<string, CdpWebWorker>(); 138 #fileChooserDeferreds = new Set<Deferred<FileChooser>>(); 139 #sessionCloseDeferred = Deferred.create<never, TargetCloseError>(); 140 #serviceWorkerBypassed = false; 141 #userDragInterceptionEnabled = false; 142 143 constructor(client: CdpCDPSession, target: CdpTarget) { 144 super(); 145 this.#primaryTargetClient = client; 146 this.#tabTargetClient = client.parentSession()!; 147 assert(this.#tabTargetClient, 'Tab target session is not defined.'); 148 this.#tabTarget = (this.#tabTargetClient as CdpCDPSession).target(); 149 assert(this.#tabTarget, 'Tab target is not defined.'); 150 this.#primaryTarget = target; 151 this.#targetManager = target._targetManager(); 152 this.#keyboard = new CdpKeyboard(client); 153 this.#mouse = new CdpMouse(client, this.#keyboard); 154 this.#touchscreen = new CdpTouchscreen(client, this.#keyboard); 155 this.#frameManager = new FrameManager(client, this, this._timeoutSettings); 156 this.#emulationManager = new EmulationManager(client); 157 this.#tracing = new Tracing(client); 158 this.#coverage = new Coverage(client); 159 this.#viewport = null; 160 161 const frameManagerEmitter = new EventEmitter(this.#frameManager); 162 frameManagerEmitter.on(FrameManagerEvent.FrameAttached, frame => { 163 this.emit(PageEvent.FrameAttached, frame); 164 }); 165 frameManagerEmitter.on(FrameManagerEvent.FrameDetached, frame => { 166 this.emit(PageEvent.FrameDetached, frame); 167 }); 168 frameManagerEmitter.on(FrameManagerEvent.FrameNavigated, frame => { 169 this.emit(PageEvent.FrameNavigated, frame); 170 }); 171 frameManagerEmitter.on( 172 FrameManagerEvent.ConsoleApiCalled, 173 ([world, event]) => { 174 this.#onConsoleAPI(world, event); 175 }, 176 ); 177 frameManagerEmitter.on( 178 FrameManagerEvent.BindingCalled, 179 ([world, event]) => { 180 void this.#onBindingCalled(world, event); 181 }, 182 ); 183 184 const networkManagerEmitter = new EventEmitter( 185 this.#frameManager.networkManager, 186 ); 187 networkManagerEmitter.on(NetworkManagerEvent.Request, request => { 188 this.emit(PageEvent.Request, request); 189 }); 190 networkManagerEmitter.on( 191 NetworkManagerEvent.RequestServedFromCache, 192 request => { 193 this.emit(PageEvent.RequestServedFromCache, request!); 194 }, 195 ); 196 networkManagerEmitter.on(NetworkManagerEvent.Response, response => { 197 this.emit(PageEvent.Response, response); 198 }); 199 networkManagerEmitter.on(NetworkManagerEvent.RequestFailed, request => { 200 this.emit(PageEvent.RequestFailed, request); 201 }); 202 networkManagerEmitter.on(NetworkManagerEvent.RequestFinished, request => { 203 this.emit(PageEvent.RequestFinished, request); 204 }); 205 206 this.#tabTargetClient.on( 207 CDPSessionEvent.Swapped, 208 this.#onActivation.bind(this), 209 ); 210 211 this.#tabTargetClient.on( 212 CDPSessionEvent.Ready, 213 this.#onSecondaryTarget.bind(this), 214 ); 215 216 this.#targetManager.on( 217 TargetManagerEvent.TargetGone, 218 this.#onDetachedFromTarget, 219 ); 220 221 this.#tabTarget._isClosedDeferred 222 .valueOrThrow() 223 .then(() => { 224 this.#targetManager.off( 225 TargetManagerEvent.TargetGone, 226 this.#onDetachedFromTarget, 227 ); 228 229 this.emit(PageEvent.Close, undefined); 230 this.#closed = true; 231 }) 232 .catch(debugError); 233 234 this.#setupPrimaryTargetListeners(); 235 this.#attachExistingTargets(); 236 } 237 238 #attachExistingTargets(): void { 239 const queue = []; 240 for (const childTarget of this.#targetManager.getChildTargets( 241 this.#primaryTarget, 242 )) { 243 queue.push(childTarget); 244 } 245 let idx = 0; 246 while (idx < queue.length) { 247 const next = queue[idx] as CdpTarget; 248 idx++; 249 const session = next._session(); 250 if (session) { 251 this.#onAttachedToTarget(session); 252 } 253 for (const childTarget of this.#targetManager.getChildTargets(next)) { 254 queue.push(childTarget); 255 } 256 } 257 } 258 259 async #onActivation(newSession: CDPSession): Promise<void> { 260 // TODO: Remove assert once we have separate Event type for CdpCDPSession. 261 assert( 262 newSession instanceof CdpCDPSession, 263 'CDPSession is not instance of CdpCDPSession', 264 ); 265 this.#primaryTargetClient = newSession; 266 this.#primaryTarget = newSession.target(); 267 assert(this.#primaryTarget, 'Missing target on swap'); 268 this.#keyboard.updateClient(newSession); 269 this.#mouse.updateClient(newSession); 270 this.#touchscreen.updateClient(newSession); 271 this.#emulationManager.updateClient(newSession); 272 this.#tracing.updateClient(newSession); 273 this.#coverage.updateClient(newSession); 274 await this.#frameManager.swapFrameTree(newSession); 275 this.#setupPrimaryTargetListeners(); 276 } 277 278 async #onSecondaryTarget(session: CDPSession): Promise<void> { 279 assert(session instanceof CdpCDPSession); 280 if (session.target()._subtype() !== 'prerender') { 281 return; 282 } 283 this.#frameManager.registerSpeculativeSession(session).catch(debugError); 284 this.#emulationManager 285 .registerSpeculativeSession(session) 286 .catch(debugError); 287 } 288 289 /** 290 * Sets up listeners for the primary target. The primary target can change 291 * during a navigation to a prerended page. 292 */ 293 #setupPrimaryTargetListeners() { 294 const clientEmitter = new EventEmitter(this.#primaryTargetClient); 295 clientEmitter.on(CDPSessionEvent.Ready, this.#onAttachedToTarget); 296 clientEmitter.on(CDPSessionEvent.Disconnected, () => { 297 this.#sessionCloseDeferred.reject(new TargetCloseError('Target closed')); 298 }); 299 clientEmitter.on('Page.domContentEventFired', () => { 300 this.emit(PageEvent.DOMContentLoaded, undefined); 301 }); 302 clientEmitter.on('Page.loadEventFired', () => { 303 this.emit(PageEvent.Load, undefined); 304 }); 305 clientEmitter.on('Page.javascriptDialogOpening', this.#onDialog.bind(this)); 306 clientEmitter.on( 307 'Runtime.exceptionThrown', 308 this.#handleException.bind(this), 309 ); 310 clientEmitter.on( 311 'Inspector.targetCrashed', 312 this.#onTargetCrashed.bind(this), 313 ); 314 clientEmitter.on('Performance.metrics', this.#emitMetrics.bind(this)); 315 clientEmitter.on('Log.entryAdded', this.#onLogEntryAdded.bind(this)); 316 clientEmitter.on('Page.fileChooserOpened', this.#onFileChooser.bind(this)); 317 } 318 319 #onDetachedFromTarget = (target: CdpTarget) => { 320 const sessionId = target._session()?.id(); 321 const worker = this.#workers.get(sessionId!); 322 if (!worker) { 323 return; 324 } 325 this.#workers.delete(sessionId!); 326 this.emit(PageEvent.WorkerDestroyed, worker); 327 }; 328 329 #onAttachedToTarget = (session: CDPSession) => { 330 assert(session instanceof CdpCDPSession); 331 this.#frameManager.onAttachedToTarget(session.target()); 332 if (session.target()._getTargetInfo().type === 'worker') { 333 const worker = new CdpWebWorker( 334 session, 335 session.target().url(), 336 session.target()._targetId, 337 session.target().type(), 338 this.#addConsoleMessage.bind(this), 339 this.#handleException.bind(this), 340 this.#frameManager.networkManager, 341 ); 342 this.#workers.set(session.id(), worker); 343 this.emit(PageEvent.WorkerCreated, worker); 344 } 345 session.on(CDPSessionEvent.Ready, this.#onAttachedToTarget); 346 }; 347 348 async #initialize(): Promise<void> { 349 try { 350 await Promise.all([ 351 this.#frameManager.initialize(this.#primaryTargetClient), 352 this.#primaryTargetClient.send('Performance.enable'), 353 this.#primaryTargetClient.send('Log.enable'), 354 ]); 355 } catch (err) { 356 if (isErrorLike(err) && isTargetClosedError(err)) { 357 debugError(err); 358 } else { 359 throw err; 360 } 361 } 362 } 363 364 async #onFileChooser( 365 event: Protocol.Page.FileChooserOpenedEvent, 366 ): Promise<void> { 367 if (!this.#fileChooserDeferreds.size) { 368 return; 369 } 370 371 const frame = this.#frameManager.frame(event.frameId); 372 assert(frame, 'This should never happen.'); 373 374 // This is guaranteed to be an HTMLInputElement handle by the event. 375 using handle = (await frame.worlds[MAIN_WORLD].adoptBackendNode( 376 event.backendNodeId, 377 )) as ElementHandle<HTMLInputElement>; 378 379 const fileChooser = new FileChooser( 380 handle.move(), 381 event.mode !== 'selectSingle', 382 ); 383 for (const promise of this.#fileChooserDeferreds) { 384 promise.resolve(fileChooser); 385 } 386 this.#fileChooserDeferreds.clear(); 387 } 388 389 _client(): CDPSession { 390 return this.#primaryTargetClient; 391 } 392 393 override isServiceWorkerBypassed(): boolean { 394 return this.#serviceWorkerBypassed; 395 } 396 397 override isDragInterceptionEnabled(): boolean { 398 return this.#userDragInterceptionEnabled; 399 } 400 401 override isJavaScriptEnabled(): boolean { 402 return this.#emulationManager.javascriptEnabled; 403 } 404 405 override async waitForFileChooser( 406 options: WaitTimeoutOptions = {}, 407 ): Promise<FileChooser> { 408 const needsEnable = this.#fileChooserDeferreds.size === 0; 409 const {timeout = this._timeoutSettings.timeout()} = options; 410 const deferred = Deferred.create<FileChooser>({ 411 message: `Waiting for \`FileChooser\` failed: ${timeout}ms exceeded`, 412 timeout, 413 }); 414 415 if (options.signal) { 416 options.signal.addEventListener( 417 'abort', 418 () => { 419 deferred.reject(options.signal?.reason); 420 }, 421 {once: true}, 422 ); 423 } 424 425 this.#fileChooserDeferreds.add(deferred); 426 let enablePromise: Promise<void> | undefined; 427 if (needsEnable) { 428 enablePromise = this.#primaryTargetClient.send( 429 'Page.setInterceptFileChooserDialog', 430 { 431 enabled: true, 432 }, 433 ); 434 } 435 try { 436 const [result] = await Promise.all([ 437 deferred.valueOrThrow(), 438 enablePromise, 439 ]); 440 return result; 441 } catch (error) { 442 this.#fileChooserDeferreds.delete(deferred); 443 throw error; 444 } 445 } 446 447 override async setGeolocation(options: GeolocationOptions): Promise<void> { 448 return await this.#emulationManager.setGeolocation(options); 449 } 450 451 override target(): CdpTarget { 452 return this.#primaryTarget; 453 } 454 455 override browser(): Browser { 456 return this.#primaryTarget.browser(); 457 } 458 459 override browserContext(): BrowserContext { 460 return this.#primaryTarget.browserContext(); 461 } 462 463 #onTargetCrashed(): void { 464 this.emit(PageEvent.Error, new Error('Page crashed!')); 465 } 466 467 #onLogEntryAdded(event: Protocol.Log.EntryAddedEvent): void { 468 const {level, text, args, source, url, lineNumber} = event.entry; 469 if (args) { 470 args.map(arg => { 471 void releaseObject(this.#primaryTargetClient, arg); 472 }); 473 } 474 if (source !== 'worker') { 475 this.emit( 476 PageEvent.Console, 477 new ConsoleMessage( 478 convertConsoleMessageLevel(level), 479 text, 480 [], 481 [{url, lineNumber}], 482 ), 483 ); 484 } 485 } 486 487 override mainFrame(): CdpFrame { 488 return this.#frameManager.mainFrame(); 489 } 490 491 override get keyboard(): CdpKeyboard { 492 return this.#keyboard; 493 } 494 495 override get touchscreen(): CdpTouchscreen { 496 return this.#touchscreen; 497 } 498 499 override get coverage(): Coverage { 500 return this.#coverage; 501 } 502 503 override get tracing(): Tracing { 504 return this.#tracing; 505 } 506 507 override frames(): Frame[] { 508 return this.#frameManager.frames(); 509 } 510 511 override workers(): CdpWebWorker[] { 512 return Array.from(this.#workers.values()); 513 } 514 515 override async setRequestInterception(value: boolean): Promise<void> { 516 return await this.#frameManager.networkManager.setRequestInterception( 517 value, 518 ); 519 } 520 521 override async setBypassServiceWorker(bypass: boolean): Promise<void> { 522 this.#serviceWorkerBypassed = bypass; 523 return await this.#primaryTargetClient.send( 524 'Network.setBypassServiceWorker', 525 {bypass}, 526 ); 527 } 528 529 override async setDragInterception(enabled: boolean): Promise<void> { 530 this.#userDragInterceptionEnabled = enabled; 531 return await this.#primaryTargetClient.send('Input.setInterceptDrags', { 532 enabled, 533 }); 534 } 535 536 override async setOfflineMode(enabled: boolean): Promise<void> { 537 return await this.#frameManager.networkManager.setOfflineMode(enabled); 538 } 539 540 override async emulateNetworkConditions( 541 networkConditions: NetworkConditions | null, 542 ): Promise<void> { 543 return await this.#frameManager.networkManager.emulateNetworkConditions( 544 networkConditions, 545 ); 546 } 547 548 override setDefaultNavigationTimeout(timeout: number): void { 549 this._timeoutSettings.setDefaultNavigationTimeout(timeout); 550 } 551 552 override setDefaultTimeout(timeout: number): void { 553 this._timeoutSettings.setDefaultTimeout(timeout); 554 } 555 556 override getDefaultTimeout(): number { 557 return this._timeoutSettings.timeout(); 558 } 559 560 override getDefaultNavigationTimeout(): number { 561 return this._timeoutSettings.navigationTimeout(); 562 } 563 564 override async queryObjects<Prototype>( 565 prototypeHandle: JSHandle<Prototype>, 566 ): Promise<JSHandle<Prototype[]>> { 567 assert(!prototypeHandle.disposed, 'Prototype JSHandle is disposed!'); 568 assert( 569 prototypeHandle.id, 570 'Prototype JSHandle must not be referencing primitive value', 571 ); 572 const response = await this.mainFrame().client.send( 573 'Runtime.queryObjects', 574 { 575 prototypeObjectId: prototypeHandle.id, 576 }, 577 ); 578 return this.mainFrame() 579 .mainRealm() 580 .createCdpHandle(response.objects) as HandleFor<Prototype[]>; 581 } 582 583 override async cookies(...urls: string[]): Promise<Cookie[]> { 584 const originalCookies = ( 585 await this.#primaryTargetClient.send('Network.getCookies', { 586 urls: urls.length ? urls : [this.url()], 587 }) 588 ).cookies; 589 590 const unsupportedCookieAttributes = ['sourcePort']; 591 const filterUnsupportedAttributes = ( 592 cookie: Protocol.Network.Cookie, 593 ): Protocol.Network.Cookie => { 594 for (const attr of unsupportedCookieAttributes) { 595 delete (cookie as unknown as Record<string, unknown>)[attr]; 596 } 597 return cookie; 598 }; 599 return originalCookies.map(filterUnsupportedAttributes).map(cookie => { 600 return { 601 ...cookie, 602 // TODO: a breaking change is needed in Puppeteer types to support other 603 // partition keys. 604 partitionKey: cookie.partitionKey 605 ? cookie.partitionKey.topLevelSite 606 : undefined, 607 }; 608 }); 609 } 610 611 override async deleteCookie( 612 ...cookies: DeleteCookiesRequest[] 613 ): Promise<void> { 614 const pageURL = this.url(); 615 for (const cookie of cookies) { 616 const item = { 617 ...cookie, 618 partitionKey: convertCookiesPartitionKeyFromPuppeteerToCdp( 619 cookie.partitionKey, 620 ), 621 }; 622 if (!cookie.url && pageURL.startsWith('http')) { 623 item.url = pageURL; 624 } 625 await this.#primaryTargetClient.send('Network.deleteCookies', item); 626 if (pageURL.startsWith('http') && !item.partitionKey) { 627 const url = new URL(pageURL); 628 // Delete also cookies from the page's partition. 629 await this.#primaryTargetClient.send('Network.deleteCookies', { 630 ...item, 631 partitionKey: { 632 topLevelSite: url.origin.replace(`:${url.port}`, ''), 633 hasCrossSiteAncestor: false, 634 }, 635 }); 636 } 637 } 638 } 639 640 override async setCookie(...cookies: CookieParam[]): Promise<void> { 641 const pageURL = this.url(); 642 const startsWithHTTP = pageURL.startsWith('http'); 643 const items = cookies.map(cookie => { 644 const item = Object.assign({}, cookie); 645 if (!item.url && startsWithHTTP) { 646 item.url = pageURL; 647 } 648 assert( 649 item.url !== 'about:blank', 650 `Blank page can not have cookie "${item.name}"`, 651 ); 652 assert( 653 !String.prototype.startsWith.call(item.url || '', 'data:'), 654 `Data URL page can not have cookie "${item.name}"`, 655 ); 656 return item; 657 }); 658 await this.deleteCookie(...items); 659 if (items.length) { 660 await this.#primaryTargetClient.send('Network.setCookies', { 661 cookies: items.map(cookieParam => { 662 return { 663 ...cookieParam, 664 partitionKey: convertCookiesPartitionKeyFromPuppeteerToCdp( 665 cookieParam.partitionKey, 666 ), 667 }; 668 }), 669 }); 670 } 671 } 672 673 override async exposeFunction( 674 name: string, 675 // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type 676 pptrFunction: Function | {default: Function}, 677 ): Promise<void> { 678 if (this.#bindings.has(name)) { 679 throw new Error( 680 `Failed to add page binding with name ${name}: window['${name}'] already exists!`, 681 ); 682 } 683 const source = pageBindingInitString('exposedFun', name); 684 let binding: Binding; 685 switch (typeof pptrFunction) { 686 case 'function': 687 binding = new Binding( 688 name, 689 pptrFunction as (...args: unknown[]) => unknown, 690 source, 691 ); 692 break; 693 default: 694 binding = new Binding( 695 name, 696 pptrFunction.default as (...args: unknown[]) => unknown, 697 source, 698 ); 699 break; 700 } 701 this.#bindings.set(name, binding); 702 const [{identifier}] = await Promise.all([ 703 this.#frameManager.evaluateOnNewDocument(source), 704 this.#frameManager.addExposedFunctionBinding(binding), 705 ]); 706 this.#exposedFunctions.set(name, identifier); 707 } 708 709 override async removeExposedFunction(name: string): Promise<void> { 710 const exposedFunctionId = this.#exposedFunctions.get(name); 711 if (!exposedFunctionId) { 712 throw new Error(`Function with name "${name}" does not exist`); 713 } 714 // #bindings must be updated together with #exposedFunctions. 715 const binding = this.#bindings.get(name)!; 716 this.#exposedFunctions.delete(name); 717 this.#bindings.delete(name); 718 await Promise.all([ 719 this.#frameManager.removeScriptToEvaluateOnNewDocument(exposedFunctionId), 720 this.#frameManager.removeExposedFunctionBinding(binding), 721 ]); 722 } 723 724 override async authenticate(credentials: Credentials | null): Promise<void> { 725 return await this.#frameManager.networkManager.authenticate(credentials); 726 } 727 728 override async setExtraHTTPHeaders( 729 headers: Record<string, string>, 730 ): Promise<void> { 731 return await this.#frameManager.networkManager.setExtraHTTPHeaders(headers); 732 } 733 734 override async setUserAgent( 735 userAgent: string, 736 userAgentMetadata?: Protocol.Emulation.UserAgentMetadata, 737 ): Promise<void> { 738 return await this.#frameManager.networkManager.setUserAgent( 739 userAgent, 740 userAgentMetadata, 741 ); 742 } 743 744 override async metrics(): Promise<Metrics> { 745 const response = await this.#primaryTargetClient.send( 746 'Performance.getMetrics', 747 ); 748 return this.#buildMetricsObject(response.metrics); 749 } 750 751 #emitMetrics(event: Protocol.Performance.MetricsEvent): void { 752 this.emit(PageEvent.Metrics, { 753 title: event.title, 754 metrics: this.#buildMetricsObject(event.metrics), 755 }); 756 } 757 758 #buildMetricsObject(metrics?: Protocol.Performance.Metric[]): Metrics { 759 const result: Record< 760 Protocol.Performance.Metric['name'], 761 Protocol.Performance.Metric['value'] 762 > = {}; 763 for (const metric of metrics || []) { 764 if (supportedMetrics.has(metric.name)) { 765 result[metric.name] = metric.value; 766 } 767 } 768 return result; 769 } 770 771 #handleException(exception: Protocol.Runtime.ExceptionThrownEvent): void { 772 this.emit( 773 PageEvent.PageError, 774 createClientError(exception.exceptionDetails), 775 ); 776 } 777 778 #onConsoleAPI( 779 world: IsolatedWorld, 780 event: Protocol.Runtime.ConsoleAPICalledEvent, 781 ): void { 782 const values = event.args.map(arg => { 783 return world.createCdpHandle(arg); 784 }); 785 this.#addConsoleMessage( 786 convertConsoleMessageLevel(event.type), 787 values, 788 event.stackTrace, 789 ); 790 } 791 792 async #onBindingCalled( 793 world: IsolatedWorld, 794 event: Protocol.Runtime.BindingCalledEvent, 795 ): Promise<void> { 796 let payload: BindingPayload; 797 try { 798 payload = JSON.parse(event.payload); 799 } catch { 800 // The binding was either called by something in the page or it was 801 // called before our wrapper was initialized. 802 return; 803 } 804 const {type, name, seq, args, isTrivial} = payload; 805 if (type !== 'exposedFun') { 806 return; 807 } 808 809 const context = world.context; 810 if (!context) { 811 return; 812 } 813 814 const binding = this.#bindings.get(name); 815 await binding?.run(context, seq, args, isTrivial); 816 } 817 818 #addConsoleMessage( 819 eventType: string, 820 args: JSHandle[], 821 stackTrace?: Protocol.Runtime.StackTrace, 822 ): void { 823 if (!this.listenerCount(PageEvent.Console)) { 824 args.forEach(arg => { 825 return arg.dispose(); 826 }); 827 return; 828 } 829 const textTokens = []; 830 // eslint-disable-next-line max-len -- The comment is long. 831 // eslint-disable-next-line rulesdir/use-using -- These are not owned by this function. 832 for (const arg of args) { 833 const remoteObject = arg.remoteObject(); 834 if (remoteObject.objectId) { 835 textTokens.push(arg.toString()); 836 } else { 837 textTokens.push(valueFromRemoteObject(remoteObject)); 838 } 839 } 840 const stackTraceLocations = []; 841 if (stackTrace) { 842 for (const callFrame of stackTrace.callFrames) { 843 stackTraceLocations.push({ 844 url: callFrame.url, 845 lineNumber: callFrame.lineNumber, 846 columnNumber: callFrame.columnNumber, 847 }); 848 } 849 } 850 const message = new ConsoleMessage( 851 convertConsoleMessageLevel(eventType), 852 textTokens.join(' '), 853 args, 854 stackTraceLocations, 855 ); 856 this.emit(PageEvent.Console, message); 857 } 858 859 #onDialog(event: Protocol.Page.JavascriptDialogOpeningEvent): void { 860 const type = validateDialogType(event.type); 861 const dialog = new CdpDialog( 862 this.#primaryTargetClient, 863 type, 864 event.message, 865 event.defaultPrompt, 866 ); 867 this.emit(PageEvent.Dialog, dialog); 868 } 869 870 override async reload( 871 options?: WaitForOptions, 872 ): Promise<HTTPResponse | null> { 873 const [result] = await Promise.all([ 874 this.waitForNavigation({ 875 ...options, 876 ignoreSameDocumentNavigation: true, 877 }), 878 this.#primaryTargetClient.send('Page.reload'), 879 ]); 880 881 return result; 882 } 883 884 override async createCDPSession(): Promise<CDPSession> { 885 return await this.target().createCDPSession(); 886 } 887 888 override async goBack( 889 options: WaitForOptions = {}, 890 ): Promise<HTTPResponse | null> { 891 return await this.#go(-1, options); 892 } 893 894 override async goForward( 895 options: WaitForOptions = {}, 896 ): Promise<HTTPResponse | null> { 897 return await this.#go(+1, options); 898 } 899 900 async #go( 901 delta: number, 902 options: WaitForOptions, 903 ): Promise<HTTPResponse | null> { 904 const history = await this.#primaryTargetClient.send( 905 'Page.getNavigationHistory', 906 ); 907 const entry = history.entries[history.currentIndex + delta]; 908 if (!entry) { 909 return null; 910 } 911 const result = await Promise.all([ 912 this.waitForNavigation(options), 913 this.#primaryTargetClient.send('Page.navigateToHistoryEntry', { 914 entryId: entry.id, 915 }), 916 ]); 917 return result[0]; 918 } 919 920 override async bringToFront(): Promise<void> { 921 await this.#primaryTargetClient.send('Page.bringToFront'); 922 } 923 924 override async setJavaScriptEnabled(enabled: boolean): Promise<void> { 925 return await this.#emulationManager.setJavaScriptEnabled(enabled); 926 } 927 928 override async setBypassCSP(enabled: boolean): Promise<void> { 929 await this.#primaryTargetClient.send('Page.setBypassCSP', {enabled}); 930 } 931 932 override async emulateMediaType(type?: string): Promise<void> { 933 return await this.#emulationManager.emulateMediaType(type); 934 } 935 936 override async emulateCPUThrottling(factor: number | null): Promise<void> { 937 return await this.#emulationManager.emulateCPUThrottling(factor); 938 } 939 940 override async emulateMediaFeatures( 941 features?: MediaFeature[], 942 ): Promise<void> { 943 return await this.#emulationManager.emulateMediaFeatures(features); 944 } 945 946 override async emulateTimezone(timezoneId?: string): Promise<void> { 947 return await this.#emulationManager.emulateTimezone(timezoneId); 948 } 949 950 override async emulateIdleState(overrides?: { 951 isUserActive: boolean; 952 isScreenUnlocked: boolean; 953 }): Promise<void> { 954 return await this.#emulationManager.emulateIdleState(overrides); 955 } 956 957 override async emulateVisionDeficiency( 958 type?: Protocol.Emulation.SetEmulatedVisionDeficiencyRequest['type'], 959 ): Promise<void> { 960 return await this.#emulationManager.emulateVisionDeficiency(type); 961 } 962 963 override async setViewport(viewport: Viewport | null): Promise<void> { 964 const needsReload = await this.#emulationManager.emulateViewport(viewport); 965 this.#viewport = viewport; 966 if (needsReload) { 967 await this.reload(); 968 } 969 } 970 971 override viewport(): Viewport | null { 972 return this.#viewport; 973 } 974 975 override async evaluateOnNewDocument< 976 Params extends unknown[], 977 Func extends (...args: Params) => unknown = (...args: Params) => unknown, 978 >( 979 pageFunction: Func | string, 980 ...args: Params 981 ): Promise<NewDocumentScriptEvaluation> { 982 const source = evaluationString(pageFunction, ...args); 983 return await this.#frameManager.evaluateOnNewDocument(source); 984 } 985 986 override async removeScriptToEvaluateOnNewDocument( 987 identifier: string, 988 ): Promise<void> { 989 return await this.#frameManager.removeScriptToEvaluateOnNewDocument( 990 identifier, 991 ); 992 } 993 994 override async setCacheEnabled(enabled = true): Promise<void> { 995 await this.#frameManager.networkManager.setCacheEnabled(enabled); 996 } 997 998 override async _screenshot( 999 options: Readonly<ScreenshotOptions>, 1000 ): Promise<string> { 1001 const { 1002 fromSurface, 1003 omitBackground, 1004 optimizeForSpeed, 1005 quality, 1006 clip: userClip, 1007 type, 1008 captureBeyondViewport, 1009 } = options; 1010 1011 await using stack = new AsyncDisposableStack(); 1012 if (omitBackground && (type === 'png' || type === 'webp')) { 1013 await this.#emulationManager.setTransparentBackgroundColor(); 1014 stack.defer(async () => { 1015 await this.#emulationManager 1016 .resetDefaultBackgroundColor() 1017 .catch(debugError); 1018 }); 1019 } 1020 1021 let clip = userClip; 1022 if (clip && !captureBeyondViewport) { 1023 const viewport = await this.mainFrame() 1024 .isolatedRealm() 1025 .evaluate(() => { 1026 const { 1027 height, 1028 pageLeft: x, 1029 pageTop: y, 1030 width, 1031 } = window.visualViewport!; 1032 return {x, y, height, width}; 1033 }); 1034 clip = getIntersectionRect(clip, viewport); 1035 } 1036 1037 const {data} = await this.#primaryTargetClient.send( 1038 'Page.captureScreenshot', 1039 { 1040 format: type, 1041 optimizeForSpeed, 1042 fromSurface, 1043 ...(quality !== undefined ? {quality: Math.round(quality)} : {}), 1044 ...(clip ? {clip: {...clip, scale: clip.scale ?? 1}} : {}), 1045 captureBeyondViewport, 1046 }, 1047 ); 1048 return data; 1049 } 1050 1051 override async createPDFStream( 1052 options: PDFOptions = {}, 1053 ): Promise<ReadableStream<Uint8Array>> { 1054 const {timeout: ms = this._timeoutSettings.timeout()} = options; 1055 const { 1056 landscape, 1057 displayHeaderFooter, 1058 headerTemplate, 1059 footerTemplate, 1060 printBackground, 1061 scale, 1062 width: paperWidth, 1063 height: paperHeight, 1064 margin, 1065 pageRanges, 1066 preferCSSPageSize, 1067 omitBackground, 1068 tagged: generateTaggedPDF, 1069 outline: generateDocumentOutline, 1070 waitForFonts, 1071 } = parsePDFOptions(options); 1072 1073 if (omitBackground) { 1074 await this.#emulationManager.setTransparentBackgroundColor(); 1075 } 1076 1077 if (waitForFonts) { 1078 await firstValueFrom( 1079 from( 1080 this.mainFrame() 1081 .isolatedRealm() 1082 .evaluate(() => { 1083 return document.fonts.ready; 1084 }), 1085 ).pipe(raceWith(timeout(ms))), 1086 ); 1087 } 1088 1089 const printCommandPromise = this.#primaryTargetClient.send( 1090 'Page.printToPDF', 1091 { 1092 transferMode: 'ReturnAsStream', 1093 landscape, 1094 displayHeaderFooter, 1095 headerTemplate, 1096 footerTemplate, 1097 printBackground, 1098 scale, 1099 paperWidth, 1100 paperHeight, 1101 marginTop: margin.top, 1102 marginBottom: margin.bottom, 1103 marginLeft: margin.left, 1104 marginRight: margin.right, 1105 pageRanges, 1106 preferCSSPageSize, 1107 generateTaggedPDF, 1108 generateDocumentOutline, 1109 }, 1110 ); 1111 1112 const result = await firstValueFrom( 1113 from(printCommandPromise).pipe(raceWith(timeout(ms))), 1114 ); 1115 1116 if (omitBackground) { 1117 await this.#emulationManager.resetDefaultBackgroundColor(); 1118 } 1119 1120 assert(result.stream, '`stream` is missing from `Page.printToPDF'); 1121 return await getReadableFromProtocolStream( 1122 this.#primaryTargetClient, 1123 result.stream, 1124 ); 1125 } 1126 1127 override async pdf(options: PDFOptions = {}): Promise<Uint8Array> { 1128 const {path = undefined} = options; 1129 const readable = await this.createPDFStream(options); 1130 const typedArray = await getReadableAsTypedArray(readable, path); 1131 assert(typedArray, 'Could not create typed array'); 1132 return typedArray; 1133 } 1134 1135 override async close( 1136 options: {runBeforeUnload?: boolean} = {runBeforeUnload: undefined}, 1137 ): Promise<void> { 1138 using _guard = await this.browserContext().waitForScreenshotOperations(); 1139 const connection = this.#primaryTargetClient.connection(); 1140 assert( 1141 connection, 1142 'Protocol error: Connection closed. Most likely the page has been closed.', 1143 ); 1144 const runBeforeUnload = !!options.runBeforeUnload; 1145 if (runBeforeUnload) { 1146 await this.#primaryTargetClient.send('Page.close'); 1147 } else { 1148 await connection.send('Target.closeTarget', { 1149 targetId: this.#primaryTarget._targetId, 1150 }); 1151 await this.#tabTarget._isClosedDeferred.valueOrThrow(); 1152 } 1153 } 1154 1155 override isClosed(): boolean { 1156 return this.#closed; 1157 } 1158 1159 override get mouse(): CdpMouse { 1160 return this.#mouse; 1161 } 1162 1163 /** 1164 * This method is typically coupled with an action that triggers a device 1165 * request from an api such as WebBluetooth. 1166 * 1167 * :::caution 1168 * 1169 * This must be called before the device request is made. It will not return a 1170 * currently active device prompt. 1171 * 1172 * ::: 1173 * 1174 * @example 1175 * 1176 * ```ts 1177 * const [devicePrompt] = Promise.all([ 1178 * page.waitForDevicePrompt(), 1179 * page.click('#connect-bluetooth'), 1180 * ]); 1181 * await devicePrompt.select( 1182 * await devicePrompt.waitForDevice(({name}) => name.includes('My Device')), 1183 * ); 1184 * ``` 1185 */ 1186 override async waitForDevicePrompt( 1187 options: WaitTimeoutOptions = {}, 1188 ): Promise<DeviceRequestPrompt> { 1189 return await this.mainFrame().waitForDevicePrompt(options); 1190 } 1191 } 1192 1193 const supportedMetrics = new Set<string>([ 1194 'Timestamp', 1195 'Documents', 1196 'Frames', 1197 'JSEventListeners', 1198 'Nodes', 1199 'LayoutCount', 1200 'RecalcStyleCount', 1201 'LayoutDuration', 1202 'RecalcStyleDuration', 1203 'ScriptDuration', 1204 'TaskDuration', 1205 'JSHeapUsedSize', 1206 'JSHeapTotalSize', 1207 ]); 1208 1209 /** @see https://w3c.github.io/webdriver-bidi/#rectangle-intersection */ 1210 function getIntersectionRect( 1211 clip: Readonly<ScreenshotClip>, 1212 viewport: Readonly<Protocol.DOM.Rect>, 1213 ): ScreenshotClip { 1214 // Note these will already be normalized. 1215 const x = Math.max(clip.x, viewport.x); 1216 const y = Math.max(clip.y, viewport.y); 1217 return { 1218 x, 1219 y, 1220 width: Math.max( 1221 Math.min(clip.x + clip.width, viewport.x + viewport.width) - x, 1222 0, 1223 ), 1224 height: Math.max( 1225 Math.min(clip.y + clip.height, viewport.y + viewport.height) - y, 1226 0, 1227 ), 1228 }; 1229 } 1230 1231 export function convertCookiesPartitionKeyFromPuppeteerToCdp( 1232 partitionKey: CookiePartitionKey | string | undefined, 1233 ): Protocol.Network.CookiePartitionKey | undefined { 1234 if (partitionKey === undefined) { 1235 return undefined; 1236 } 1237 if (typeof partitionKey === 'string') { 1238 return { 1239 topLevelSite: partitionKey, 1240 hasCrossSiteAncestor: false, 1241 }; 1242 } 1243 return { 1244 topLevelSite: partitionKey.sourceOrigin, 1245 hasCrossSiteAncestor: partitionKey.hasCrossSiteAncestor ?? false, 1246 }; 1247 }