tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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 }