Frame.ts (18750B)
1 /** 2 * @license 3 * Copyright 2023 Google Inc. 4 * SPDX-License-Identifier: Apache-2.0 5 */ 6 7 import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; 8 9 import type {Observable} from '../../third_party/rxjs/rxjs.js'; 10 import { 11 combineLatest, 12 defer, 13 delayWhen, 14 filter, 15 first, 16 firstValueFrom, 17 map, 18 of, 19 race, 20 raceWith, 21 switchMap, 22 } from '../../third_party/rxjs/rxjs.js'; 23 import type {CDPSession} from '../api/CDPSession.js'; 24 import { 25 Frame, 26 throwIfDetached, 27 type GoToOptions, 28 type WaitForOptions, 29 } from '../api/Frame.js'; 30 import {PageEvent} from '../api/Page.js'; 31 import {Accessibility} from '../cdp/Accessibility.js'; 32 import type {ConsoleMessageType} from '../common/ConsoleMessage.js'; 33 import { 34 ConsoleMessage, 35 type ConsoleMessageLocation, 36 } from '../common/ConsoleMessage.js'; 37 import {TargetCloseError, UnsupportedOperation} from '../common/Errors.js'; 38 import type {TimeoutSettings} from '../common/TimeoutSettings.js'; 39 import type {Awaitable} from '../common/types.js'; 40 import { 41 debugError, 42 fromAbortSignal, 43 fromEmitterEvent, 44 timeout, 45 } from '../common/util.js'; 46 import {isErrorLike} from '../util/ErrorLike.js'; 47 48 import {BidiCdpSession} from './CDPSession.js'; 49 import type {BrowsingContext} from './core/BrowsingContext.js'; 50 import type {Navigation} from './core/Navigation.js'; 51 import type {Request} from './core/Request.js'; 52 import {BidiDeserializer} from './Deserializer.js'; 53 import {BidiDialog} from './Dialog.js'; 54 import type {BidiElementHandle} from './ElementHandle.js'; 55 import {ExposableFunction} from './ExposedFunction.js'; 56 import {BidiHTTPRequest, requests} from './HTTPRequest.js'; 57 import type {BidiHTTPResponse} from './HTTPResponse.js'; 58 import {BidiJSHandle} from './JSHandle.js'; 59 import type {BidiPage} from './Page.js'; 60 import type {BidiRealm} from './Realm.js'; 61 import {BidiFrameRealm} from './Realm.js'; 62 import {rewriteNavigationError} from './util.js'; 63 import {BidiWebWorker} from './WebWorker.js'; 64 65 // TODO: Remove this and map CDP the correct method. 66 // Requires breaking change. 67 function convertConsoleMessageLevel(method: string): ConsoleMessageType { 68 switch (method) { 69 case 'group': 70 return 'startGroup'; 71 case 'groupCollapsed': 72 return 'startGroupCollapsed'; 73 case 'groupEnd': 74 return 'endGroup'; 75 default: 76 return method as ConsoleMessageType; 77 } 78 } 79 80 export class BidiFrame extends Frame { 81 static from( 82 parent: BidiPage | BidiFrame, 83 browsingContext: BrowsingContext, 84 ): BidiFrame { 85 const frame = new BidiFrame(parent, browsingContext); 86 frame.#initialize(); 87 return frame; 88 } 89 90 readonly #parent: BidiPage | BidiFrame; 91 readonly browsingContext: BrowsingContext; 92 readonly #frames = new WeakMap<BrowsingContext, BidiFrame>(); 93 readonly realms: {default: BidiFrameRealm; internal: BidiFrameRealm}; 94 95 override readonly _id: string; 96 override readonly client: BidiCdpSession; 97 override readonly accessibility: Accessibility; 98 99 private constructor( 100 parent: BidiPage | BidiFrame, 101 browsingContext: BrowsingContext, 102 ) { 103 super(); 104 this.#parent = parent; 105 this.browsingContext = browsingContext; 106 107 this._id = browsingContext.id; 108 this.client = new BidiCdpSession(this); 109 this.realms = { 110 default: BidiFrameRealm.from(this.browsingContext.defaultRealm, this), 111 internal: BidiFrameRealm.from( 112 this.browsingContext.createWindowRealm( 113 `__puppeteer_internal_${Math.ceil(Math.random() * 10000)}`, 114 ), 115 this, 116 ), 117 }; 118 this.accessibility = new Accessibility(this.realms.default, this._id); 119 } 120 121 #initialize(): void { 122 for (const browsingContext of this.browsingContext.children) { 123 this.#createFrameTarget(browsingContext); 124 } 125 126 this.browsingContext.on('browsingcontext', ({browsingContext}) => { 127 this.#createFrameTarget(browsingContext); 128 }); 129 this.browsingContext.on('closed', () => { 130 for (const session of BidiCdpSession.sessions.values()) { 131 if (session.frame === this) { 132 session.onClose(); 133 } 134 } 135 this.page().trustedEmitter.emit(PageEvent.FrameDetached, this); 136 }); 137 138 this.browsingContext.on('request', ({request}) => { 139 const httpRequest = BidiHTTPRequest.from(request, this); 140 request.once('success', () => { 141 this.page().trustedEmitter.emit(PageEvent.RequestFinished, httpRequest); 142 }); 143 144 request.once('error', () => { 145 this.page().trustedEmitter.emit(PageEvent.RequestFailed, httpRequest); 146 }); 147 void httpRequest.finalizeInterceptions(); 148 }); 149 150 this.browsingContext.on('navigation', ({navigation}) => { 151 navigation.once('fragment', () => { 152 this.page().trustedEmitter.emit(PageEvent.FrameNavigated, this); 153 }); 154 }); 155 this.browsingContext.on('load', () => { 156 this.page().trustedEmitter.emit(PageEvent.Load, undefined); 157 }); 158 this.browsingContext.on('DOMContentLoaded', () => { 159 this._hasStartedLoading = true; 160 this.page().trustedEmitter.emit(PageEvent.DOMContentLoaded, undefined); 161 this.page().trustedEmitter.emit(PageEvent.FrameNavigated, this); 162 }); 163 164 this.browsingContext.on('userprompt', ({userPrompt}) => { 165 this.page().trustedEmitter.emit( 166 PageEvent.Dialog, 167 BidiDialog.from(userPrompt), 168 ); 169 }); 170 171 this.browsingContext.on('log', ({entry}) => { 172 if (this._id !== entry.source.context) { 173 return; 174 } 175 if (isConsoleLogEntry(entry)) { 176 const args = entry.args.map(arg => { 177 return this.mainRealm().createHandle(arg); 178 }); 179 180 const text = args 181 .reduce((value, arg) => { 182 const parsedValue = 183 arg instanceof BidiJSHandle && arg.isPrimitiveValue 184 ? BidiDeserializer.deserialize(arg.remoteValue()) 185 : arg.toString(); 186 return `${value} ${parsedValue}`; 187 }, '') 188 .slice(1); 189 190 this.page().trustedEmitter.emit( 191 PageEvent.Console, 192 new ConsoleMessage( 193 convertConsoleMessageLevel(entry.method), 194 text, 195 args, 196 getStackTraceLocations(entry.stackTrace), 197 this, 198 ), 199 ); 200 } else if (isJavaScriptLogEntry(entry)) { 201 const error = new Error(entry.text ?? ''); 202 203 const messageHeight = error.message.split('\n').length; 204 const messageLines = error.stack!.split('\n').splice(0, messageHeight); 205 206 const stackLines = []; 207 if (entry.stackTrace) { 208 for (const frame of entry.stackTrace.callFrames) { 209 // Note we need to add `1` because the values are 0-indexed. 210 stackLines.push( 211 ` at ${frame.functionName || '<anonymous>'} (${frame.url}:${ 212 frame.lineNumber + 1 213 }:${frame.columnNumber + 1})`, 214 ); 215 if (stackLines.length >= Error.stackTraceLimit) { 216 break; 217 } 218 } 219 } 220 221 error.stack = [...messageLines, ...stackLines].join('\n'); 222 this.page().trustedEmitter.emit(PageEvent.PageError, error); 223 } else { 224 debugError( 225 `Unhandled LogEntry with type "${entry.type}", text "${entry.text}" and level "${entry.level}"`, 226 ); 227 } 228 }); 229 230 this.browsingContext.on('worker', ({realm}) => { 231 const worker = BidiWebWorker.from(this, realm); 232 realm.on('destroyed', () => { 233 this.page().trustedEmitter.emit(PageEvent.WorkerDestroyed, worker); 234 }); 235 this.page().trustedEmitter.emit(PageEvent.WorkerCreated, worker); 236 }); 237 } 238 239 #createFrameTarget(browsingContext: BrowsingContext) { 240 const frame = BidiFrame.from(this, browsingContext); 241 this.#frames.set(browsingContext, frame); 242 this.page().trustedEmitter.emit(PageEvent.FrameAttached, frame); 243 244 browsingContext.on('closed', () => { 245 this.#frames.delete(browsingContext); 246 }); 247 248 return frame; 249 } 250 251 get timeoutSettings(): TimeoutSettings { 252 return this.page()._timeoutSettings; 253 } 254 255 override mainRealm(): BidiFrameRealm { 256 return this.realms.default; 257 } 258 259 override isolatedRealm(): BidiFrameRealm { 260 return this.realms.internal; 261 } 262 263 realm(id: string): BidiRealm | undefined { 264 for (const realm of Object.values(this.realms)) { 265 if (realm.realm.id === id) { 266 return realm; 267 } 268 } 269 return; 270 } 271 272 override page(): BidiPage { 273 let parent = this.#parent; 274 while (parent instanceof BidiFrame) { 275 parent = parent.#parent; 276 } 277 return parent; 278 } 279 280 override url(): string { 281 return this.browsingContext.url; 282 } 283 284 override parentFrame(): BidiFrame | null { 285 if (this.#parent instanceof BidiFrame) { 286 return this.#parent; 287 } 288 return null; 289 } 290 291 override childFrames(): BidiFrame[] { 292 return [...this.browsingContext.children].map(child => { 293 return this.#frames.get(child)!; 294 }); 295 } 296 297 #detached$() { 298 return defer(() => { 299 if (this.detached) { 300 return of(this as Frame); 301 } 302 return fromEmitterEvent( 303 this.page().trustedEmitter, 304 PageEvent.FrameDetached, 305 ).pipe( 306 filter(detachedFrame => { 307 return detachedFrame === this; 308 }), 309 ); 310 }); 311 } 312 313 @throwIfDetached 314 override async goto( 315 url: string, 316 options: GoToOptions = {}, 317 ): Promise<BidiHTTPResponse | null> { 318 const [response] = await Promise.all([ 319 this.waitForNavigation(options), 320 // Some implementations currently only report errors when the 321 // readiness=interactive. 322 // 323 // Related: https://bugzilla.mozilla.org/show_bug.cgi?id=1846601 324 this.browsingContext 325 .navigate(url, Bidi.BrowsingContext.ReadinessState.Interactive) 326 .catch(error => { 327 if ( 328 isErrorLike(error) && 329 error.message.includes('net::ERR_HTTP_RESPONSE_CODE_FAILURE') 330 ) { 331 return; 332 } 333 334 if (error.message.includes('navigation canceled')) { 335 return; 336 } 337 338 if ( 339 error.message.includes( 340 'Navigation was aborted by another navigation', 341 ) 342 ) { 343 return; 344 } 345 346 throw error; 347 }), 348 ]).catch( 349 rewriteNavigationError( 350 url, 351 options.timeout ?? this.timeoutSettings.navigationTimeout(), 352 ), 353 ); 354 return response; 355 } 356 357 @throwIfDetached 358 override async setContent( 359 html: string, 360 options: WaitForOptions = {}, 361 ): Promise<void> { 362 await Promise.all([ 363 this.setFrameContent(html), 364 firstValueFrom( 365 combineLatest([ 366 this.#waitForLoad$(options), 367 this.#waitForNetworkIdle$(options), 368 ]), 369 ), 370 ]); 371 } 372 373 @throwIfDetached 374 override async waitForNavigation( 375 options: WaitForOptions = {}, 376 ): Promise<BidiHTTPResponse | null> { 377 const {timeout: ms = this.timeoutSettings.navigationTimeout(), signal} = 378 options; 379 380 const frames = this.childFrames().map(frame => { 381 return frame.#detached$(); 382 }); 383 return await firstValueFrom( 384 combineLatest([ 385 race( 386 fromEmitterEvent(this.browsingContext, 'navigation'), 387 fromEmitterEvent(this.browsingContext, 'historyUpdated').pipe( 388 map(() => { 389 return {navigation: null}; 390 }), 391 ), 392 ) 393 .pipe(first()) 394 .pipe( 395 switchMap(({navigation}) => { 396 if (navigation === null) { 397 return of(null); 398 } 399 return this.#waitForLoad$(options).pipe( 400 delayWhen(() => { 401 if (frames.length === 0) { 402 return of(undefined); 403 } 404 return combineLatest(frames); 405 }), 406 raceWith( 407 fromEmitterEvent(navigation, 'fragment'), 408 fromEmitterEvent(navigation, 'failed'), 409 fromEmitterEvent(navigation, 'aborted'), 410 ), 411 switchMap(() => { 412 if (navigation.request) { 413 function requestFinished$( 414 request: Request, 415 ): Observable<Navigation | null> { 416 if (navigation === null) { 417 return of(null); 418 } 419 // Reduces flakiness if the response events arrive after 420 // the load event. 421 // Usually, the response or error is already there at this point. 422 if (request.response || request.error) { 423 return of(navigation); 424 } 425 if (request.redirect) { 426 return requestFinished$(request.redirect); 427 } 428 return fromEmitterEvent(request, 'success') 429 .pipe( 430 raceWith(fromEmitterEvent(request, 'error')), 431 raceWith(fromEmitterEvent(request, 'redirect')), 432 ) 433 .pipe( 434 switchMap(() => { 435 return requestFinished$(request); 436 }), 437 ); 438 } 439 return requestFinished$(navigation.request); 440 } 441 return of(navigation); 442 }), 443 ); 444 }), 445 ), 446 this.#waitForNetworkIdle$(options), 447 ]).pipe( 448 map(([navigation]) => { 449 if (!navigation) { 450 return null; 451 } 452 const request = navigation.request; 453 if (!request) { 454 return null; 455 } 456 const lastRequest = request.lastRedirect ?? request; 457 const httpRequest = requests.get(lastRequest)!; 458 return httpRequest.response(); 459 }), 460 raceWith( 461 timeout(ms), 462 fromAbortSignal(signal), 463 this.#detached$().pipe( 464 map(() => { 465 throw new TargetCloseError('Frame detached.'); 466 }), 467 ), 468 ), 469 ), 470 ); 471 } 472 473 override waitForDevicePrompt(): never { 474 throw new UnsupportedOperation(); 475 } 476 477 override get detached(): boolean { 478 return this.browsingContext.closed; 479 } 480 481 #exposedFunctions = new Map<string, ExposableFunction<never[], unknown>>(); 482 async exposeFunction<Args extends unknown[], Ret>( 483 name: string, 484 apply: (...args: Args) => Awaitable<Ret>, 485 ): Promise<void> { 486 if (this.#exposedFunctions.has(name)) { 487 throw new Error( 488 `Failed to add page binding with name ${name}: globalThis['${name}'] already exists!`, 489 ); 490 } 491 const exposable = await ExposableFunction.from(this, name, apply); 492 this.#exposedFunctions.set(name, exposable); 493 } 494 495 async removeExposedFunction(name: string): Promise<void> { 496 const exposedFunction = this.#exposedFunctions.get(name); 497 if (!exposedFunction) { 498 throw new Error( 499 `Failed to remove page binding with name ${name}: window['${name}'] does not exists!`, 500 ); 501 } 502 503 this.#exposedFunctions.delete(name); 504 await exposedFunction[Symbol.asyncDispose](); 505 } 506 507 async createCDPSession(): Promise<CDPSession> { 508 if (!this.page().browser().cdpSupported) { 509 throw new UnsupportedOperation(); 510 } 511 512 const cdpConnection = this.page().browser().cdpConnection!; 513 return await cdpConnection._createSession({targetId: this._id}); 514 } 515 516 @throwIfDetached 517 #waitForLoad$(options: WaitForOptions = {}): Observable<void> { 518 let {waitUntil = 'load'} = options; 519 const {timeout: ms = this.timeoutSettings.navigationTimeout()} = options; 520 521 if (!Array.isArray(waitUntil)) { 522 waitUntil = [waitUntil]; 523 } 524 525 const events = new Set<'load' | 'DOMContentLoaded'>(); 526 for (const lifecycleEvent of waitUntil) { 527 switch (lifecycleEvent) { 528 case 'load': { 529 events.add('load'); 530 break; 531 } 532 case 'domcontentloaded': { 533 events.add('DOMContentLoaded'); 534 break; 535 } 536 } 537 } 538 if (events.size === 0) { 539 return of(undefined); 540 } 541 542 return combineLatest( 543 [...events].map(event => { 544 return fromEmitterEvent(this.browsingContext, event); 545 }), 546 ).pipe( 547 map(() => {}), 548 first(), 549 raceWith( 550 timeout(ms), 551 this.#detached$().pipe( 552 map(() => { 553 throw new Error('Frame detached.'); 554 }), 555 ), 556 ), 557 ); 558 } 559 560 @throwIfDetached 561 #waitForNetworkIdle$(options: WaitForOptions = {}): Observable<void> { 562 let {waitUntil = 'load'} = options; 563 if (!Array.isArray(waitUntil)) { 564 waitUntil = [waitUntil]; 565 } 566 567 let concurrency = Infinity; 568 for (const event of waitUntil) { 569 switch (event) { 570 case 'networkidle0': { 571 concurrency = Math.min(0, concurrency); 572 break; 573 } 574 case 'networkidle2': { 575 concurrency = Math.min(2, concurrency); 576 break; 577 } 578 } 579 } 580 if (concurrency === Infinity) { 581 return of(undefined); 582 } 583 584 return this.page().waitForNetworkIdle$({ 585 idleTime: 500, 586 timeout: options.timeout ?? this.timeoutSettings.timeout(), 587 concurrency, 588 }); 589 } 590 591 @throwIfDetached 592 async setFiles(element: BidiElementHandle, files: string[]): Promise<void> { 593 await this.browsingContext.setFiles( 594 // SAFETY: ElementHandles are always remote references. 595 element.remoteValue() as Bidi.Script.SharedReference, 596 files, 597 ); 598 } 599 600 @throwIfDetached 601 async locateNodes( 602 element: BidiElementHandle, 603 locator: Bidi.BrowsingContext.Locator, 604 ): Promise<Bidi.Script.NodeRemoteValue[]> { 605 return await this.browsingContext.locateNodes( 606 locator, 607 // SAFETY: ElementHandles are always remote references. 608 [element.remoteValue() as Bidi.Script.SharedReference], 609 ); 610 } 611 } 612 613 function isConsoleLogEntry( 614 event: Bidi.Log.Entry, 615 ): event is Bidi.Log.ConsoleLogEntry { 616 return event.type === 'console'; 617 } 618 619 function isJavaScriptLogEntry( 620 event: Bidi.Log.Entry, 621 ): event is Bidi.Log.JavascriptLogEntry { 622 return event.type === 'javascript'; 623 } 624 625 function getStackTraceLocations( 626 stackTrace?: Bidi.Script.StackTrace, 627 ): ConsoleMessageLocation[] { 628 const stackTraceLocations: ConsoleMessageLocation[] = []; 629 if (stackTrace) { 630 for (const callFrame of stackTrace.callFrames) { 631 stackTraceLocations.push({ 632 url: callFrame.url, 633 lineNumber: callFrame.lineNumber, 634 columnNumber: callFrame.columnNumber, 635 }); 636 } 637 } 638 return stackTraceLocations; 639 }