Page.ts (33552B)
1 /** 2 * @license 3 * Copyright 2022 Google Inc. 4 * SPDX-License-Identifier: Apache-2.0 5 */ 6 7 import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; 8 import type Protocol from 'devtools-protocol'; 9 10 import {firstValueFrom, from, raceWith} from '../../third_party/rxjs/rxjs.js'; 11 import type {CDPSession} from '../api/CDPSession.js'; 12 import type {BoundingBox} from '../api/ElementHandle.js'; 13 import type {WaitForOptions} from '../api/Frame.js'; 14 import type {HTTPResponse} from '../api/HTTPResponse.js'; 15 import type { 16 Credentials, 17 GeolocationOptions, 18 MediaFeature, 19 PageEvents, 20 WaitTimeoutOptions, 21 } from '../api/Page.js'; 22 import { 23 Page, 24 PageEvent, 25 type NewDocumentScriptEvaluation, 26 type ScreenshotOptions, 27 } from '../api/Page.js'; 28 import {Coverage} from '../cdp/Coverage.js'; 29 import {EmulationManager} from '../cdp/EmulationManager.js'; 30 import type { 31 InternalNetworkConditions, 32 NetworkConditions, 33 } from '../cdp/NetworkManager.js'; 34 import {Tracing} from '../cdp/Tracing.js'; 35 import type { 36 CookiePartitionKey, 37 Cookie, 38 CookieParam, 39 CookieSameSite, 40 DeleteCookiesRequest, 41 } from '../common/Cookie.js'; 42 import {UnsupportedOperation} from '../common/Errors.js'; 43 import {EventEmitter} from '../common/EventEmitter.js'; 44 import {FileChooser} from '../common/FileChooser.js'; 45 import type {PDFOptions} from '../common/PDFOptions.js'; 46 import type {Awaitable} from '../common/types.js'; 47 import { 48 evaluationString, 49 isString, 50 parsePDFOptions, 51 timeout, 52 } from '../common/util.js'; 53 import type {Viewport} from '../common/Viewport.js'; 54 import {assert} from '../util/assert.js'; 55 import {bubble} from '../util/decorators.js'; 56 import {Deferred} from '../util/Deferred.js'; 57 import {stringToTypedArray} from '../util/encoding.js'; 58 import {isErrorLike} from '../util/ErrorLike.js'; 59 60 import type {BidiBrowser} from './Browser.js'; 61 import type {BidiBrowserContext} from './BrowserContext.js'; 62 import type {BidiCdpSession} from './CDPSession.js'; 63 import type {BrowsingContext} from './core/BrowsingContext.js'; 64 import {BidiElementHandle} from './ElementHandle.js'; 65 import {BidiFrame} from './Frame.js'; 66 import type {BidiHTTPResponse} from './HTTPResponse.js'; 67 import {BidiKeyboard, BidiMouse, BidiTouchscreen} from './Input.js'; 68 import type {BidiJSHandle} from './JSHandle.js'; 69 import {rewriteNavigationError} from './util.js'; 70 import type {BidiWebWorker} from './WebWorker.js'; 71 72 /** 73 * Implements Page using WebDriver BiDi. 74 * 75 * @internal 76 */ 77 export class BidiPage extends Page { 78 static from( 79 browserContext: BidiBrowserContext, 80 browsingContext: BrowsingContext, 81 ): BidiPage { 82 const page = new BidiPage(browserContext, browsingContext); 83 page.#initialize(); 84 return page; 85 } 86 87 @bubble() 88 accessor trustedEmitter = new EventEmitter<PageEvents>(); 89 90 readonly #browserContext: BidiBrowserContext; 91 readonly #frame: BidiFrame; 92 #viewport: Viewport | null = null; 93 readonly #workers = new Set<BidiWebWorker>(); 94 95 readonly keyboard: BidiKeyboard; 96 readonly mouse: BidiMouse; 97 readonly touchscreen: BidiTouchscreen; 98 readonly tracing: Tracing; 99 readonly coverage: Coverage; 100 readonly #cdpEmulationManager: EmulationManager; 101 102 #emulatedNetworkConditions?: InternalNetworkConditions; 103 #fileChooserDeferreds = new Set<Deferred<FileChooser>>(); 104 105 _client(): BidiCdpSession { 106 return this.#frame.client; 107 } 108 109 private constructor( 110 browserContext: BidiBrowserContext, 111 browsingContext: BrowsingContext, 112 ) { 113 super(); 114 this.#browserContext = browserContext; 115 this.#frame = BidiFrame.from(this, browsingContext); 116 117 this.#cdpEmulationManager = new EmulationManager(this.#frame.client); 118 this.tracing = new Tracing(this.#frame.client); 119 this.coverage = new Coverage(this.#frame.client); 120 this.keyboard = new BidiKeyboard(this); 121 this.mouse = new BidiMouse(this); 122 this.touchscreen = new BidiTouchscreen(this); 123 } 124 125 #initialize() { 126 this.#frame.browsingContext.on('closed', () => { 127 this.trustedEmitter.emit(PageEvent.Close, undefined); 128 this.trustedEmitter.removeAllListeners(); 129 }); 130 131 this.trustedEmitter.on(PageEvent.WorkerCreated, worker => { 132 this.#workers.add(worker as BidiWebWorker); 133 }); 134 this.trustedEmitter.on(PageEvent.WorkerDestroyed, worker => { 135 this.#workers.delete(worker as BidiWebWorker); 136 }); 137 } 138 /** 139 * @internal 140 */ 141 _userAgentHeaders: Record<string, string> = {}; 142 #userAgentInterception?: string; 143 #userAgentPreloadScript?: string; 144 override async setUserAgent( 145 userAgent: string, 146 userAgentMetadata?: Protocol.Emulation.UserAgentMetadata, 147 ): Promise<void> { 148 if (!this.#browserContext.browser().cdpSupported && userAgentMetadata) { 149 throw new UnsupportedOperation( 150 'Current Browser does not support `userAgentMetadata`', 151 ); 152 } else if ( 153 this.#browserContext.browser().cdpSupported && 154 userAgentMetadata 155 ) { 156 return await this._client().send('Network.setUserAgentOverride', { 157 userAgent: userAgent, 158 userAgentMetadata: userAgentMetadata, 159 }); 160 } 161 const enable = userAgent !== ''; 162 userAgent = userAgent ?? (await this.#browserContext.browser().userAgent()); 163 164 this._userAgentHeaders = enable 165 ? { 166 'User-Agent': userAgent, 167 } 168 : {}; 169 170 this.#userAgentInterception = await this.#toggleInterception( 171 [Bidi.Network.InterceptPhase.BeforeRequestSent], 172 this.#userAgentInterception, 173 enable, 174 ); 175 176 const changeUserAgent = (userAgent: string) => { 177 Object.defineProperty(navigator, 'userAgent', { 178 value: userAgent, 179 configurable: true, 180 }); 181 }; 182 183 const frames = [this.#frame]; 184 for (const frame of frames) { 185 frames.push(...frame.childFrames()); 186 } 187 188 if (this.#userAgentPreloadScript) { 189 await this.removeScriptToEvaluateOnNewDocument( 190 this.#userAgentPreloadScript, 191 ); 192 } 193 const [evaluateToken] = await Promise.all([ 194 enable 195 ? this.evaluateOnNewDocument(changeUserAgent, userAgent) 196 : undefined, 197 // When we disable the UserAgent we want to 198 // evaluate the original value in all Browsing Contexts 199 ...frames.map(frame => { 200 return frame.evaluate(changeUserAgent, userAgent); 201 }), 202 ]); 203 this.#userAgentPreloadScript = evaluateToken?.identifier; 204 } 205 206 override async setBypassCSP(enabled: boolean): Promise<void> { 207 // TODO: handle CDP-specific cases such as mprach. 208 await this._client().send('Page.setBypassCSP', {enabled}); 209 } 210 211 override async queryObjects<Prototype>( 212 prototypeHandle: BidiJSHandle<Prototype>, 213 ): Promise<BidiJSHandle<Prototype[]>> { 214 assert(!prototypeHandle.disposed, 'Prototype JSHandle is disposed!'); 215 assert( 216 prototypeHandle.id, 217 'Prototype JSHandle must not be referencing primitive value', 218 ); 219 const response = await this.#frame.client.send('Runtime.queryObjects', { 220 prototypeObjectId: prototypeHandle.id, 221 }); 222 return this.#frame.mainRealm().createHandle({ 223 type: 'array', 224 handle: response.objects.objectId, 225 }) as BidiJSHandle<Prototype[]>; 226 } 227 228 override browser(): BidiBrowser { 229 return this.browserContext().browser(); 230 } 231 232 override browserContext(): BidiBrowserContext { 233 return this.#browserContext; 234 } 235 236 override mainFrame(): BidiFrame { 237 return this.#frame; 238 } 239 240 async focusedFrame(): Promise<BidiFrame> { 241 using handle = (await this.mainFrame() 242 .isolatedRealm() 243 .evaluateHandle(() => { 244 let win = window; 245 while ( 246 win.document.activeElement instanceof win.HTMLIFrameElement || 247 win.document.activeElement instanceof win.HTMLFrameElement 248 ) { 249 if (win.document.activeElement.contentWindow === null) { 250 break; 251 } 252 win = win.document.activeElement.contentWindow as typeof win; 253 } 254 return win; 255 })) as BidiJSHandle<Window & typeof globalThis>; 256 const value = handle.remoteValue(); 257 assert(value.type === 'window'); 258 const frame = this.frames().find(frame => { 259 return frame._id === value.value.context; 260 }); 261 assert(frame); 262 return frame; 263 } 264 265 override frames(): BidiFrame[] { 266 const frames = [this.#frame]; 267 for (const frame of frames) { 268 frames.push(...frame.childFrames()); 269 } 270 return frames; 271 } 272 273 override isClosed(): boolean { 274 return this.#frame.detached; 275 } 276 277 override async close(options?: {runBeforeUnload?: boolean}): Promise<void> { 278 using _guard = await this.#browserContext.waitForScreenshotOperations(); 279 try { 280 await this.#frame.browsingContext.close(options?.runBeforeUnload); 281 } catch { 282 return; 283 } 284 } 285 286 override async reload( 287 options: WaitForOptions = {}, 288 ): Promise<BidiHTTPResponse | null> { 289 const [response] = await Promise.all([ 290 this.#frame.waitForNavigation(options), 291 this.#frame.browsingContext.reload(), 292 ]).catch( 293 rewriteNavigationError( 294 this.url(), 295 options.timeout ?? this._timeoutSettings.navigationTimeout(), 296 ), 297 ); 298 return response; 299 } 300 301 override setDefaultNavigationTimeout(timeout: number): void { 302 this._timeoutSettings.setDefaultNavigationTimeout(timeout); 303 } 304 305 override setDefaultTimeout(timeout: number): void { 306 this._timeoutSettings.setDefaultTimeout(timeout); 307 } 308 309 override getDefaultTimeout(): number { 310 return this._timeoutSettings.timeout(); 311 } 312 313 override getDefaultNavigationTimeout(): number { 314 return this._timeoutSettings.navigationTimeout(); 315 } 316 317 override isJavaScriptEnabled(): boolean { 318 return this.#cdpEmulationManager.javascriptEnabled; 319 } 320 321 override async setGeolocation(options: GeolocationOptions): Promise<void> { 322 const {longitude, latitude, accuracy = 0} = options; 323 if (longitude < -180 || longitude > 180) { 324 throw new Error( 325 `Invalid longitude "${longitude}": precondition -180 <= LONGITUDE <= 180 failed.`, 326 ); 327 } 328 if (latitude < -90 || latitude > 90) { 329 throw new Error( 330 `Invalid latitude "${latitude}": precondition -90 <= LATITUDE <= 90 failed.`, 331 ); 332 } 333 if (accuracy < 0) { 334 throw new Error( 335 `Invalid accuracy "${accuracy}": precondition 0 <= ACCURACY failed.`, 336 ); 337 } 338 return await this.#frame.browsingContext.setGeolocationOverride({ 339 coordinates: { 340 latitude: options.latitude, 341 longitude: options.longitude, 342 accuracy: options.accuracy, 343 }, 344 }); 345 } 346 347 override async setJavaScriptEnabled(enabled: boolean): Promise<void> { 348 return await this.#cdpEmulationManager.setJavaScriptEnabled(enabled); 349 } 350 351 override async emulateMediaType(type?: string): Promise<void> { 352 return await this.#cdpEmulationManager.emulateMediaType(type); 353 } 354 355 override async emulateCPUThrottling(factor: number | null): Promise<void> { 356 return await this.#cdpEmulationManager.emulateCPUThrottling(factor); 357 } 358 359 override async emulateMediaFeatures( 360 features?: MediaFeature[], 361 ): Promise<void> { 362 return await this.#cdpEmulationManager.emulateMediaFeatures(features); 363 } 364 365 override async emulateTimezone(timezoneId?: string): Promise<void> { 366 return await this.#cdpEmulationManager.emulateTimezone(timezoneId); 367 } 368 369 override async emulateIdleState(overrides?: { 370 isUserActive: boolean; 371 isScreenUnlocked: boolean; 372 }): Promise<void> { 373 return await this.#cdpEmulationManager.emulateIdleState(overrides); 374 } 375 376 override async emulateVisionDeficiency( 377 type?: Protocol.Emulation.SetEmulatedVisionDeficiencyRequest['type'], 378 ): Promise<void> { 379 return await this.#cdpEmulationManager.emulateVisionDeficiency(type); 380 } 381 382 override async setViewport(viewport: Viewport | null): Promise<void> { 383 if (!this.browser().cdpSupported) { 384 await this.#frame.browsingContext.setViewport({ 385 viewport: 386 viewport?.width && viewport?.height 387 ? { 388 width: viewport.width, 389 height: viewport.height, 390 } 391 : null, 392 devicePixelRatio: viewport?.deviceScaleFactor 393 ? viewport.deviceScaleFactor 394 : null, 395 }); 396 this.#viewport = viewport; 397 return; 398 } 399 const needsReload = 400 await this.#cdpEmulationManager.emulateViewport(viewport); 401 this.#viewport = viewport; 402 if (needsReload) { 403 await this.reload(); 404 } 405 } 406 407 override viewport(): Viewport | null { 408 return this.#viewport; 409 } 410 411 override async pdf(options: PDFOptions = {}): Promise<Uint8Array> { 412 const {timeout: ms = this._timeoutSettings.timeout(), path = undefined} = 413 options; 414 const { 415 printBackground: background, 416 margin, 417 landscape, 418 width, 419 height, 420 pageRanges: ranges, 421 scale, 422 preferCSSPageSize, 423 } = parsePDFOptions(options, 'cm'); 424 const pageRanges = ranges ? ranges.split(', ') : []; 425 426 await firstValueFrom( 427 from( 428 this.mainFrame() 429 .isolatedRealm() 430 .evaluate(() => { 431 return document.fonts.ready; 432 }), 433 ).pipe(raceWith(timeout(ms))), 434 ); 435 436 const data = await firstValueFrom( 437 from( 438 this.#frame.browsingContext.print({ 439 background, 440 margin, 441 orientation: landscape ? 'landscape' : 'portrait', 442 page: { 443 width, 444 height, 445 }, 446 pageRanges, 447 scale, 448 shrinkToFit: !preferCSSPageSize, 449 }), 450 ).pipe(raceWith(timeout(ms))), 451 ); 452 453 const typedArray = stringToTypedArray(data, true); 454 455 await this._maybeWriteTypedArrayToFile(path, typedArray); 456 457 return typedArray; 458 } 459 460 override async createPDFStream( 461 options?: PDFOptions | undefined, 462 ): Promise<ReadableStream<Uint8Array>> { 463 const typedArray = await this.pdf(options); 464 465 return new ReadableStream({ 466 start(controller) { 467 controller.enqueue(typedArray); 468 controller.close(); 469 }, 470 }); 471 } 472 473 override async _screenshot( 474 options: Readonly<ScreenshotOptions>, 475 ): Promise<string> { 476 const {clip, type, captureBeyondViewport, quality} = options; 477 if (options.omitBackground !== undefined && options.omitBackground) { 478 throw new UnsupportedOperation(`BiDi does not support 'omitBackground'.`); 479 } 480 if (options.optimizeForSpeed !== undefined && options.optimizeForSpeed) { 481 throw new UnsupportedOperation( 482 `BiDi does not support 'optimizeForSpeed'.`, 483 ); 484 } 485 if (options.fromSurface !== undefined && !options.fromSurface) { 486 throw new UnsupportedOperation(`BiDi does not support 'fromSurface'.`); 487 } 488 if (clip !== undefined && clip.scale !== undefined && clip.scale !== 1) { 489 throw new UnsupportedOperation( 490 `BiDi does not support 'scale' in 'clip'.`, 491 ); 492 } 493 494 let box: BoundingBox | undefined; 495 if (clip) { 496 if (captureBeyondViewport) { 497 box = clip; 498 } else { 499 // The clip is always with respect to the document coordinates, so we 500 // need to convert this to viewport coordinates when we aren't capturing 501 // beyond the viewport. 502 const [pageLeft, pageTop] = await this.evaluate(() => { 503 if (!window.visualViewport) { 504 throw new Error('window.visualViewport is not supported.'); 505 } 506 return [ 507 window.visualViewport.pageLeft, 508 window.visualViewport.pageTop, 509 ] as const; 510 }); 511 box = { 512 ...clip, 513 x: clip.x - pageLeft, 514 y: clip.y - pageTop, 515 }; 516 } 517 } 518 519 const data = await this.#frame.browsingContext.captureScreenshot({ 520 origin: captureBeyondViewport ? 'document' : 'viewport', 521 format: { 522 type: `image/${type}`, 523 ...(quality !== undefined ? {quality: quality / 100} : {}), 524 }, 525 ...(box ? {clip: {type: 'box', ...box}} : {}), 526 }); 527 return data; 528 } 529 530 override async createCDPSession(): Promise<CDPSession> { 531 return await this.#frame.createCDPSession(); 532 } 533 534 override async bringToFront(): Promise<void> { 535 await this.#frame.browsingContext.activate(); 536 } 537 538 override async evaluateOnNewDocument< 539 Params extends unknown[], 540 Func extends (...args: Params) => unknown = (...args: Params) => unknown, 541 >( 542 pageFunction: Func | string, 543 ...args: Params 544 ): Promise<NewDocumentScriptEvaluation> { 545 const expression = evaluationExpression(pageFunction, ...args); 546 const script = 547 await this.#frame.browsingContext.addPreloadScript(expression); 548 549 return {identifier: script}; 550 } 551 552 override async removeScriptToEvaluateOnNewDocument( 553 id: string, 554 ): Promise<void> { 555 await this.#frame.browsingContext.removePreloadScript(id); 556 } 557 558 override async exposeFunction<Args extends unknown[], Ret>( 559 name: string, 560 pptrFunction: 561 | ((...args: Args) => Awaitable<Ret>) 562 | {default: (...args: Args) => Awaitable<Ret>}, 563 ): Promise<void> { 564 return await this.mainFrame().exposeFunction( 565 name, 566 'default' in pptrFunction ? pptrFunction.default : pptrFunction, 567 ); 568 } 569 570 override isDragInterceptionEnabled(): boolean { 571 return false; 572 } 573 574 override async setCacheEnabled(enabled?: boolean): Promise<void> { 575 if (!this.#browserContext.browser().cdpSupported) { 576 await this.#frame.browsingContext.setCacheBehavior( 577 enabled ? 'default' : 'bypass', 578 ); 579 return; 580 } 581 // TODO: handle CDP-specific cases such as mprach. 582 await this._client().send('Network.setCacheDisabled', { 583 cacheDisabled: !enabled, 584 }); 585 } 586 587 override async cookies(...urls: string[]): Promise<Cookie[]> { 588 const normalizedUrls = (urls.length ? urls : [this.url()]).map(url => { 589 return new URL(url); 590 }); 591 592 const cookies = await this.#frame.browsingContext.getCookies(); 593 return cookies 594 .map(cookie => { 595 return bidiToPuppeteerCookie(cookie); 596 }) 597 .filter(cookie => { 598 return normalizedUrls.some(url => { 599 return testUrlMatchCookie(cookie, url); 600 }); 601 }); 602 } 603 604 override isServiceWorkerBypassed(): never { 605 throw new UnsupportedOperation(); 606 } 607 608 override target(): never { 609 throw new UnsupportedOperation(); 610 } 611 612 override async waitForFileChooser( 613 options: WaitTimeoutOptions = {}, 614 ): Promise<FileChooser> { 615 const {timeout = this._timeoutSettings.timeout()} = options; 616 const deferred = Deferred.create<FileChooser>({ 617 message: `Waiting for \`FileChooser\` failed: ${timeout}ms exceeded`, 618 timeout, 619 }); 620 621 this.#fileChooserDeferreds.add(deferred); 622 623 if (options.signal) { 624 options.signal.addEventListener( 625 'abort', 626 () => { 627 deferred.reject(options.signal?.reason); 628 }, 629 {once: true}, 630 ); 631 } 632 633 this.#frame.browsingContext.once('filedialogopened', info => { 634 if (!info.element) { 635 return; 636 } 637 const chooser = new FileChooser( 638 BidiElementHandle.from<HTMLInputElement>( 639 { 640 sharedId: info.element.sharedId, 641 handle: info.element.handle, 642 type: 'node', 643 }, 644 this.#frame.mainRealm(), 645 ), 646 info.multiple, 647 ); 648 for (const deferred of this.#fileChooserDeferreds) { 649 deferred.resolve(chooser); 650 this.#fileChooserDeferreds.delete(deferred); 651 } 652 }); 653 654 try { 655 return await deferred.valueOrThrow(); 656 } catch (error) { 657 this.#fileChooserDeferreds.delete(deferred); 658 throw error; 659 } 660 } 661 662 override workers(): BidiWebWorker[] { 663 return [...this.#workers]; 664 } 665 666 #userInterception?: string; 667 override async setRequestInterception(enable: boolean): Promise<void> { 668 this.#userInterception = await this.#toggleInterception( 669 [Bidi.Network.InterceptPhase.BeforeRequestSent], 670 this.#userInterception, 671 enable, 672 ); 673 } 674 675 /** 676 * @internal 677 */ 678 _extraHTTPHeaders: Record<string, string> = {}; 679 #extraHeadersInterception?: string; 680 override async setExtraHTTPHeaders( 681 headers: Record<string, string>, 682 ): Promise<void> { 683 const extraHTTPHeaders: Record<string, string> = {}; 684 for (const [key, value] of Object.entries(headers)) { 685 assert( 686 isString(value), 687 `Expected value of header "${key}" to be String, but "${typeof value}" is found.`, 688 ); 689 extraHTTPHeaders[key.toLowerCase()] = value; 690 } 691 this._extraHTTPHeaders = extraHTTPHeaders; 692 693 this.#extraHeadersInterception = await this.#toggleInterception( 694 [Bidi.Network.InterceptPhase.BeforeRequestSent], 695 this.#extraHeadersInterception, 696 Boolean(Object.keys(this._extraHTTPHeaders).length), 697 ); 698 } 699 700 /** 701 * @internal 702 */ 703 _credentials: Credentials | null = null; 704 #authInterception?: string; 705 override async authenticate(credentials: Credentials | null): Promise<void> { 706 this.#authInterception = await this.#toggleInterception( 707 [Bidi.Network.InterceptPhase.AuthRequired], 708 this.#authInterception, 709 Boolean(credentials), 710 ); 711 712 this._credentials = credentials; 713 } 714 715 async #toggleInterception( 716 phases: [Bidi.Network.InterceptPhase, ...Bidi.Network.InterceptPhase[]], 717 interception: string | undefined, 718 expected: boolean, 719 ): Promise<string | undefined> { 720 if (expected && !interception) { 721 return await this.#frame.browsingContext.addIntercept({ 722 phases, 723 }); 724 } else if (!expected && interception) { 725 await this.#frame.browsingContext.userContext.browser.removeIntercept( 726 interception, 727 ); 728 return; 729 } 730 return interception; 731 } 732 733 override setDragInterception(): never { 734 throw new UnsupportedOperation(); 735 } 736 737 override setBypassServiceWorker(): never { 738 throw new UnsupportedOperation(); 739 } 740 741 override async setOfflineMode(enabled: boolean): Promise<void> { 742 if (!this.#browserContext.browser().cdpSupported) { 743 throw new UnsupportedOperation(); 744 } 745 746 if (!this.#emulatedNetworkConditions) { 747 this.#emulatedNetworkConditions = { 748 offline: false, 749 upload: -1, 750 download: -1, 751 latency: 0, 752 }; 753 } 754 this.#emulatedNetworkConditions.offline = enabled; 755 return await this.#applyNetworkConditions(); 756 } 757 758 override async emulateNetworkConditions( 759 networkConditions: NetworkConditions | null, 760 ): Promise<void> { 761 if (!this.#browserContext.browser().cdpSupported) { 762 throw new UnsupportedOperation(); 763 } 764 if (!this.#emulatedNetworkConditions) { 765 this.#emulatedNetworkConditions = { 766 offline: false, 767 upload: -1, 768 download: -1, 769 latency: 0, 770 }; 771 } 772 this.#emulatedNetworkConditions.upload = networkConditions 773 ? networkConditions.upload 774 : -1; 775 this.#emulatedNetworkConditions.download = networkConditions 776 ? networkConditions.download 777 : -1; 778 this.#emulatedNetworkConditions.latency = networkConditions 779 ? networkConditions.latency 780 : 0; 781 return await this.#applyNetworkConditions(); 782 } 783 784 async #applyNetworkConditions(): Promise<void> { 785 if (!this.#emulatedNetworkConditions) { 786 return; 787 } 788 await this._client().send('Network.emulateNetworkConditions', { 789 offline: this.#emulatedNetworkConditions.offline, 790 latency: this.#emulatedNetworkConditions.latency, 791 uploadThroughput: this.#emulatedNetworkConditions.upload, 792 downloadThroughput: this.#emulatedNetworkConditions.download, 793 }); 794 } 795 796 override async setCookie(...cookies: CookieParam[]): Promise<void> { 797 const pageURL = this.url(); 798 const pageUrlStartsWithHTTP = pageURL.startsWith('http'); 799 for (const cookie of cookies) { 800 let cookieUrl = cookie.url || ''; 801 if (!cookieUrl && pageUrlStartsWithHTTP) { 802 cookieUrl = pageURL; 803 } 804 assert( 805 cookieUrl !== 'about:blank', 806 `Blank page can not have cookie "${cookie.name}"`, 807 ); 808 assert( 809 !String.prototype.startsWith.call(cookieUrl || '', 'data:'), 810 `Data URL page can not have cookie "${cookie.name}"`, 811 ); 812 // TODO: Support Chrome cookie partition keys 813 assert( 814 cookie.partitionKey === undefined || 815 typeof cookie.partitionKey === 'string', 816 'BiDi only allows domain partition keys', 817 ); 818 819 const normalizedUrl = URL.canParse(cookieUrl) 820 ? new URL(cookieUrl) 821 : undefined; 822 823 const domain = cookie.domain ?? normalizedUrl?.hostname; 824 assert( 825 domain !== undefined, 826 `At least one of the url and domain needs to be specified`, 827 ); 828 829 const bidiCookie: Bidi.Storage.PartialCookie = { 830 domain: domain, 831 name: cookie.name, 832 value: { 833 type: 'string', 834 value: cookie.value, 835 }, 836 ...(cookie.path !== undefined ? {path: cookie.path} : {}), 837 ...(cookie.httpOnly !== undefined ? {httpOnly: cookie.httpOnly} : {}), 838 ...(cookie.secure !== undefined ? {secure: cookie.secure} : {}), 839 ...(cookie.sameSite !== undefined 840 ? {sameSite: convertCookiesSameSiteCdpToBiDi(cookie.sameSite)} 841 : {}), 842 ...{expiry: convertCookiesExpiryCdpToBiDi(cookie.expires)}, 843 // Chrome-specific properties. 844 ...cdpSpecificCookiePropertiesFromPuppeteerToBidi( 845 cookie, 846 'sameParty', 847 'sourceScheme', 848 'priority', 849 'url', 850 ), 851 }; 852 853 if (cookie.partitionKey !== undefined) { 854 await this.browserContext().userContext.setCookie( 855 bidiCookie, 856 cookie.partitionKey, 857 ); 858 } else { 859 await this.#frame.browsingContext.setCookie(bidiCookie); 860 } 861 } 862 } 863 864 override async deleteCookie( 865 ...cookies: DeleteCookiesRequest[] 866 ): Promise<void> { 867 await Promise.all( 868 cookies.map(async deleteCookieRequest => { 869 const cookieUrl = deleteCookieRequest.url ?? this.url(); 870 const normalizedUrl = URL.canParse(cookieUrl) 871 ? new URL(cookieUrl) 872 : undefined; 873 874 const domain = deleteCookieRequest.domain ?? normalizedUrl?.hostname; 875 assert( 876 domain !== undefined, 877 `At least one of the url and domain needs to be specified`, 878 ); 879 880 const filter = { 881 domain: domain, 882 name: deleteCookieRequest.name, 883 ...(deleteCookieRequest.path !== undefined 884 ? {path: deleteCookieRequest.path} 885 : {}), 886 }; 887 await this.#frame.browsingContext.deleteCookie(filter); 888 }), 889 ); 890 } 891 892 override async removeExposedFunction(name: string): Promise<void> { 893 await this.#frame.removeExposedFunction(name); 894 } 895 896 override metrics(): never { 897 throw new UnsupportedOperation(); 898 } 899 900 override async goBack( 901 options: WaitForOptions = {}, 902 ): Promise<HTTPResponse | null> { 903 return await this.#go(-1, options); 904 } 905 906 override async goForward( 907 options: WaitForOptions = {}, 908 ): Promise<HTTPResponse | null> { 909 return await this.#go(1, options); 910 } 911 912 async #go( 913 delta: number, 914 options: WaitForOptions, 915 ): Promise<HTTPResponse | null> { 916 const controller = new AbortController(); 917 918 try { 919 const [response] = await Promise.all([ 920 this.waitForNavigation({ 921 ...options, 922 signal: controller.signal, 923 }), 924 this.#frame.browsingContext.traverseHistory(delta), 925 ]); 926 return response; 927 } catch (error) { 928 controller.abort(); 929 if (isErrorLike(error)) { 930 if (error.message.includes('no such history entry')) { 931 return null; 932 } 933 } 934 throw error; 935 } 936 } 937 938 override waitForDevicePrompt(): never { 939 throw new UnsupportedOperation(); 940 } 941 } 942 943 // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type 944 function evaluationExpression(fun: Function | string, ...args: unknown[]) { 945 return `() => {${evaluationString(fun, ...args)}}`; 946 } 947 948 /** 949 * Check domains match. 950 */ 951 function testUrlMatchCookieHostname( 952 cookie: Cookie, 953 normalizedUrl: URL, 954 ): boolean { 955 const cookieDomain = cookie.domain.toLowerCase(); 956 const urlHostname = normalizedUrl.hostname.toLowerCase(); 957 if (cookieDomain === urlHostname) { 958 return true; 959 } 960 // TODO: does not consider additional restrictions w.r.t to IP 961 // addresses which is fine as it is for representation and does not 962 // mean that cookies actually apply that way in the browser. 963 // https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.3 964 return cookieDomain.startsWith('.') && urlHostname.endsWith(cookieDomain); 965 } 966 967 /** 968 * Check paths match. 969 * Spec: https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.4 970 */ 971 function testUrlMatchCookiePath(cookie: Cookie, normalizedUrl: URL): boolean { 972 const uriPath = normalizedUrl.pathname; 973 const cookiePath = cookie.path; 974 975 if (uriPath === cookiePath) { 976 // The cookie-path and the request-path are identical. 977 return true; 978 } 979 if (uriPath.startsWith(cookiePath)) { 980 // The cookie-path is a prefix of the request-path. 981 if (cookiePath.endsWith('/')) { 982 // The last character of the cookie-path is %x2F ("/"). 983 return true; 984 } 985 if (uriPath[cookiePath.length] === '/') { 986 // The first character of the request-path that is not included in the cookie-path 987 // is a %x2F ("/") character. 988 return true; 989 } 990 } 991 return false; 992 } 993 994 /** 995 * Checks the cookie matches the URL according to the spec: 996 */ 997 function testUrlMatchCookie(cookie: Cookie, url: URL): boolean { 998 const normalizedUrl = new URL(url); 999 assert(cookie !== undefined); 1000 if (!testUrlMatchCookieHostname(cookie, normalizedUrl)) { 1001 return false; 1002 } 1003 return testUrlMatchCookiePath(cookie, normalizedUrl); 1004 } 1005 1006 export function bidiToPuppeteerCookie( 1007 bidiCookie: Bidi.Network.Cookie, 1008 returnCompositePartitionKey = false, 1009 ): Cookie { 1010 const partitionKey = bidiCookie[CDP_SPECIFIC_PREFIX + 'partitionKey']; 1011 1012 function getParitionKey(): {partitionKey?: Cookie['partitionKey']} { 1013 if (typeof partitionKey === 'string') { 1014 return {partitionKey}; 1015 } 1016 if (typeof partitionKey === 'object' && partitionKey !== null) { 1017 if (returnCompositePartitionKey) { 1018 return { 1019 partitionKey: { 1020 sourceOrigin: partitionKey.topLevelSite, 1021 hasCrossSiteAncestor: partitionKey.hasCrossSiteAncestor ?? false, 1022 }, 1023 }; 1024 } 1025 return { 1026 // TODO: a breaking change in Puppeteer is required to change 1027 // partitionKey type and report the composite partition key. 1028 partitionKey: partitionKey.topLevelSite, 1029 }; 1030 } 1031 return {}; 1032 } 1033 1034 return { 1035 name: bidiCookie.name, 1036 // Presents binary value as base64 string. 1037 value: bidiCookie.value.value, 1038 domain: bidiCookie.domain, 1039 path: bidiCookie.path, 1040 size: bidiCookie.size, 1041 httpOnly: bidiCookie.httpOnly, 1042 secure: bidiCookie.secure, 1043 sameSite: convertCookiesSameSiteBiDiToCdp(bidiCookie.sameSite), 1044 expires: bidiCookie.expiry ?? -1, 1045 session: bidiCookie.expiry === undefined || bidiCookie.expiry <= 0, 1046 // Extending with CDP-specific properties with `goog:` prefix. 1047 ...cdpSpecificCookiePropertiesFromBidiToPuppeteer( 1048 bidiCookie, 1049 'sameParty', 1050 'sourceScheme', 1051 'partitionKeyOpaque', 1052 'priority', 1053 ), 1054 ...getParitionKey(), 1055 }; 1056 } 1057 1058 const CDP_SPECIFIC_PREFIX = 'goog:'; 1059 1060 /** 1061 * Gets CDP-specific properties from the BiDi cookie and returns them as a new object. 1062 */ 1063 function cdpSpecificCookiePropertiesFromBidiToPuppeteer( 1064 bidiCookie: Bidi.Network.Cookie, 1065 ...propertyNames: Array<keyof Cookie> 1066 ): Partial<Cookie> { 1067 const result: Partial<Cookie> = {}; 1068 for (const property of propertyNames) { 1069 if (bidiCookie[CDP_SPECIFIC_PREFIX + property] !== undefined) { 1070 result[property] = bidiCookie[CDP_SPECIFIC_PREFIX + property]; 1071 } 1072 } 1073 return result; 1074 } 1075 1076 /** 1077 * Gets CDP-specific properties from the cookie, adds CDP-specific prefixes and returns 1078 * them as a new object which can be used in BiDi. 1079 */ 1080 export function cdpSpecificCookiePropertiesFromPuppeteerToBidi( 1081 cookieParam: CookieParam, 1082 ...propertyNames: Array<keyof CookieParam> 1083 ): Record<string, unknown> { 1084 const result: Record<string, unknown> = {}; 1085 for (const property of propertyNames) { 1086 if (cookieParam[property] !== undefined) { 1087 result[CDP_SPECIFIC_PREFIX + property] = cookieParam[property]; 1088 } 1089 } 1090 return result; 1091 } 1092 1093 function convertCookiesSameSiteBiDiToCdp( 1094 sameSite: Bidi.Network.SameSite | undefined, 1095 ): CookieSameSite { 1096 return sameSite === 'strict' ? 'Strict' : sameSite === 'lax' ? 'Lax' : 'None'; 1097 } 1098 1099 export function convertCookiesSameSiteCdpToBiDi( 1100 sameSite: CookieSameSite | undefined, 1101 ): Bidi.Network.SameSite { 1102 return sameSite === 'Strict' 1103 ? Bidi.Network.SameSite.Strict 1104 : sameSite === 'Lax' 1105 ? Bidi.Network.SameSite.Lax 1106 : Bidi.Network.SameSite.None; 1107 } 1108 1109 export function convertCookiesExpiryCdpToBiDi( 1110 expiry: number | undefined, 1111 ): number | undefined { 1112 return [undefined, -1].includes(expiry) ? undefined : expiry; 1113 } 1114 1115 export function convertCookiesPartitionKeyFromPuppeteerToBiDi( 1116 partitionKey: CookiePartitionKey | string | undefined, 1117 ): string | undefined { 1118 if (partitionKey === undefined || typeof partitionKey === 'string') { 1119 return partitionKey; 1120 } 1121 if (partitionKey.hasCrossSiteAncestor) { 1122 throw new UnsupportedOperation( 1123 'WebDriver BiDi does not support `hasCrossSiteAncestor` yet.', 1124 ); 1125 } 1126 return partitionKey.sourceOrigin; 1127 }