bidi_browsingContext.js

// Licensed to the Software Freedom Conservancy (SFC) under one
// or more contributor license agreements.  See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership.  The SFC licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License.  You may obtain a copy of the License at
//
//   http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied.  See the License for the
// specific language governing permissions and limitations
// under the License.

const { InvalidArgumentError, NoSuchFrameError } = require('../lib/error')
const { BrowsingContextInfo } = require('./browsingContextTypes')
const { SerializationOptions, ReferenceValue, RemoteValue } = require('./protocolValue')
const { WebElement } = require('../lib/webdriver')
const { CaptureScreenshotParameters } = require('./captureScreenshotParameters')
const { CreateContextParameters } = require('./createContextParameters')

/**
 * Represents the locator to locate nodes in the browsing context.
 * Described in https://w3c.github.io/webdriver-bidi/#type-browsingContext-Locator.
 */
class Locator {
  static Type = Object.freeze({
    CSS: 'css',
    INNER_TEXT: 'innerText',
    XPATH: 'xpath',
  })

  #type
  #value
  #ignoreCase
  #matchType
  #maxDepth

  constructor(type, value, ignoreCase = undefined, matchType = undefined, maxDepth = undefined) {
    this.#type = type
    this.#value = value
    this.#ignoreCase = ignoreCase
    this.#matchType = matchType
    this.#maxDepth = maxDepth
  }

  /**
   * Creates a new Locator object with CSS selector type.
   *
   * @param {string} value - The CSS selector value.
   * @returns {Locator} A new Locator object with CSS selector type.
   */
  static css(value) {
    return new Locator(Locator.Type.CSS, value)
  }

  /**
   * Creates a new Locator object with the given XPath value.
   *
   * @param {string} value - The XPath value.
   * @returns {Locator} A new Locator object.
   */
  static xpath(value) {
    return new Locator(Locator.Type.XPATH, value)
  }

  /**
   * Creates a new Locator object with the specified inner text value.
   *
   * @param {string} value - The inner text value to locate.
   * @param {boolean|undefined} [ignoreCase] - Whether to ignore the case when matching the inner text value.
   * @param {string|undefined} [matchType] - The type of matching to perform (full or partial).
   * @param {number|undefined} [maxDepth] - The maximum depth to search for the inner text value.
   * @returns {Locator} A new Locator object with the specified inner text value.
   */
  static innerText(value, ignoreCase = undefined, matchType = undefined, maxDepth = undefined) {
    return new Locator(Locator.Type.INNER_TEXT, value, ignoreCase, matchType, maxDepth)
  }

  toMap() {
    const map = new Map()

    map.set('type', this.#type.toString())
    map.set('value', this.#value)
    map.set('ignoreCase', this.#ignoreCase)
    map.set('matchType', this.#matchType)
    map.set('maxDepth', this.#maxDepth)

    return map
  }
}

/**
 * Represents the contains under BrowsingContext module commands.
 * Described in https://w3c.github.io/webdriver-bidi/#module-browsingContext
 * Each browsing context command requires a browsing context id.
 * Hence, this class represent browsing context lifecycle.
 */
class BrowsingContext {
  constructor(driver) {
    this._driver = driver
  }

  /**
   * @returns id
   */
  get id() {
    return this._id
  }

  async init({ browsingContextId = undefined, type = undefined, createParameters = undefined }) {
    if (!(await this._driver.getCapabilities()).get('webSocketUrl')) {
      throw Error('WebDriver instance must support BiDi protocol')
    }

    if (browsingContextId === undefined && type === undefined && createParameters === undefined) {
      throw Error('Either BrowsingContextId or Type or CreateParameters must be provided')
    }

    if (type === undefined && createParameters !== undefined) {
      throw Error('Type must be provided with CreateParameters')
    }

    if (type !== undefined && !['window', 'tab'].includes(type)) {
      throw Error(`Valid types are 'window' & 'tab'. Received: ${type}`)
    }

    this.bidi = await this._driver.getBidi()
    this._id =
      browsingContextId === undefined
        ? (await this.create(type, createParameters))['result']['context']
        : browsingContextId
  }

  /**
   * Creates a browsing context for the given type with the given parameters
   */
  async create(type, createParameters = undefined) {
    if (createParameters !== undefined && (!createParameters) instanceof CreateContextParameters) {
      throw Error(`Pass in the instance of CreateContextParameters. Received: ${createParameters}`)
    }

    let parameters = new Map()
    parameters.set('type', type)

    if (createParameters !== undefined) {
      createParameters.asMap().forEach((value, key) => {
        parameters.set(key, value)
      })
    }

    const params = {
      method: 'browsingContext.create',
      params: Object.fromEntries(parameters),
    }
    const res = await this.bidi.send(params)
    return res
  }

  /**
   * @param url the url to navigate to
   * @param readinessState type of readiness state: "none" / "interactive" / "complete"
   * @returns NavigateResult object
   */
  async navigate(url, readinessState = undefined) {
    if (readinessState !== undefined && !['none', 'interactive', 'complete'].includes(readinessState)) {
      throw Error(`Valid readiness states are 'none', 'interactive' & 'complete'. Received: ${readinessState}`)
    }

    const params = {
      method: 'browsingContext.navigate',
      params: {
        context: this._id,
        url: url,
        wait: readinessState,
      },
    }
    const navigateResult = (await this.bidi.send(params))['result']

    return new NavigateResult(navigateResult['url'], navigateResult['navigation'])
  }

  /**
   * @param maxDepth the max depth of the descendents of browsing context tree
   * @returns BrowsingContextInfo object
   */
  async getTree(maxDepth = undefined) {
    const params = {
      method: 'browsingContext.getTree',
      params: {
        root: this._id,
        maxDepth: maxDepth,
      },
    }

    let result = await this.bidi.send(params)
    if ('error' in result) {
      throw Error(result['error'])
    }

    result = result['result']['contexts'][0]
    return new BrowsingContextInfo(result['context'], result['url'], result['children'], result['parent'])
  }

  /**
   * @returns {Promise<Array<BrowsingContextInfo>>} A Promise that resolves to an array of BrowsingContextInfo objects representing the top-level browsing contexts.
   */
  async getTopLevelContexts() {
    const params = {
      method: 'browsingContext.getTree',
      params: {},
    }

    let result = await this.bidi.send(params)
    if ('error' in result) {
      throw Error(result['error'])
    }

    const contexts = result['result']['contexts']
    const browsingContexts = contexts.map((context) => {
      return new BrowsingContextInfo(context['id'], context['url'], context['children'], context['parent'])
    })
    return browsingContexts
  }

  /**
   * Closes the browsing context
   * @returns {Promise<void>}
   */
  async close() {
    const params = {
      method: 'browsingContext.close',
      params: {
        context: this._id,
      },
    }
    await this.bidi.send(params)
  }

  /**
   * Prints PDF of the webpage
   * @param options print options given by the user
   * @returns PrintResult object
   */
  async printPage(options = {}) {
    let params = {
      method: 'browsingContext.print',
      // Setting default values for parameters
      params: {
        context: this._id,
        background: false,
        margin: {
          bottom: 1.0,
          left: 1.0,
          right: 1.0,
          top: 1.0,
        },
        orientation: 'portrait',
        page: {
          height: 27.94,
          width: 21.59,
        },
        pageRanges: [],
        scale: 1.0,
        shrinkToFit: true,
      },
    }

    // Updating parameter values based on the options passed
    params.params = this._driver.validatePrintPageParams(options, params.params)

    const response = await this.bidi.send(params)
    return new PrintResult(response.result.data)
  }

  /**
   * Captures a screenshot of the browsing context.
   *
   * @param {CaptureScreenshotParameters|undefined} [captureScreenshotParameters] - Optional parameters for capturing the screenshot.
   * @returns {Promise<string>} - A promise that resolves to the base64-encoded string representation of the captured screenshot.
   * @throws {InvalidArgumentError} - If the provided captureScreenshotParameters is not an instance of CaptureScreenshotParameters.
   */
  async captureScreenshot(captureScreenshotParameters = undefined) {
    if (
      captureScreenshotParameters !== undefined &&
      !(captureScreenshotParameters instanceof CaptureScreenshotParameters)
    ) {
      throw new InvalidArgumentError(
        `Pass in a CaptureScreenshotParameters object. Received: ${captureScreenshotParameters}`,
      )
    }

    const screenshotParams = new Map()
    screenshotParams.set('context', this._id)
    if (captureScreenshotParameters !== undefined) {
      captureScreenshotParameters.asMap().forEach((value, key) => {
        screenshotParams.set(key, value)
      })
    }

    let params = {
      method: 'browsingContext.captureScreenshot',
      params: Object.fromEntries(screenshotParams),
    }

    const response = await this.bidi.send(params)
    this.checkErrorInScreenshot(response)
    return response['result']['data']
  }

  async captureBoxScreenshot(x, y, width, height) {
    let params = {
      method: 'browsingContext.captureScreenshot',
      params: {
        context: this._id,
        clip: {
          type: 'box',
          x: x,
          y: y,
          width: width,
          height: height,
        },
      },
    }

    const response = await this.bidi.send(params)
    this.checkErrorInScreenshot(response)
    return response['result']['data']
  }

  /**
   * Captures a screenshot of a specific element within the browsing context.
   * @param {string} sharedId - The shared ID of the element to capture.
   * @param {string} [handle] - The handle of the element to capture (optional).
   * @returns {Promise<string>} A promise that resolves to the base64-encoded screenshot data.
   */
  async captureElementScreenshot(sharedId, handle = undefined) {
    let params = {
      method: 'browsingContext.captureScreenshot',
      params: {
        context: this._id,
        clip: {
          type: 'element',
          element: {
            sharedId: sharedId,
            handle: handle,
          },
        },
      },
    }

    const response = await this.bidi.send(params)
    this.checkErrorInScreenshot(response)
    return response['result']['data']
  }

  checkErrorInScreenshot(response) {
    if ('error' in response) {
      const { error, msg } = response

      switch (error) {
        case 'invalid argument':
          throw new InvalidArgumentError(msg)

        case 'no such frame':
          throw new NoSuchFrameError(msg)
      }
    }
  }

  /**
   * Activates and focuses the top-level browsing context.
   * @returns {Promise<void>} A promise that resolves when the browsing context is activated.
   * @throws {Error} If there is an error while activating the browsing context.
   */
  async activate() {
    const params = {
      method: 'browsingContext.activate',
      params: {
        context: this._id,
      },
    }

    let result = await this.bidi.send(params)
    if ('error' in result) {
      throw Error(result['error'])
    }
  }

  /**
   * Handles a user prompt in the browsing context.
   *
   * @param {boolean} [accept] - Optional. Indicates whether to accept or dismiss the prompt.
   * @param {string} [userText] - Optional. The text to enter.
   * @throws {Error} If an error occurs while handling the user prompt.
   */
  async handleUserPrompt(accept = undefined, userText = undefined) {
    const params = {
      method: 'browsingContext.handleUserPrompt',
      params: {
        context: this._id,
        accept: accept,
        userText: userText,
      },
    }

    let result = await this.bidi.send(params)
    if ('error' in result) {
      throw Error(result['error'])
    }
  }

  /**
   * Reloads the current browsing context.
   *
   * @param {boolean} [ignoreCache] - Whether to ignore the cache when reloading.
   * @param {string} [readinessState] - The readiness state to wait for before returning.
   *        Valid readiness states are 'none', 'interactive', and 'complete'.
   * @returns {Promise<NavigateResult>} - A promise that resolves to the result of the reload operation.
   * @throws {Error} - If an invalid readiness state is provided.
   */
  async reload(ignoreCache = undefined, readinessState = undefined) {
    if (readinessState !== undefined && !['none', 'interactive', 'complete'].includes(readinessState)) {
      throw Error(`Valid readiness states are 'none', 'interactive' & 'complete'. Received: ${readinessState}`)
    }

    const params = {
      method: 'browsingContext.reload',
      params: {
        context: this._id,
        ignoreCache: ignoreCache,
        wait: readinessState,
      },
    }
    const navigateResult = (await this.bidi.send(params))['result']

    return new NavigateResult(navigateResult['url'], navigateResult['navigation'])
  }

  /**
   * Sets the viewport size and device pixel ratio for the browsing context.
   * @param {number} width - The width of the viewport.
   * @param {number} height - The height of the viewport.
   * @param {number} [devicePixelRatio] - The device pixel ratio (optional)
   * @throws {Error} If an error occurs while setting the viewport.
   */
  async setViewport(width, height, devicePixelRatio = undefined) {
    const params = {
      method: 'browsingContext.setViewport',
      params: {
        context: this._id,
        viewport: { width: width, height: height },
        devicePixelRatio: devicePixelRatio,
      },
    }
    let result = await this.bidi.send(params)
    if ('error' in result) {
      throw Error(result['error'])
    }
  }

  /**
   * Traverses the browsing context history by a given delta.
   *
   * @param {number} delta - The delta value to traverse the history. A positive value moves forward, while a negative value moves backward.
   * @returns {Promise<void>} - A promise that resolves when the history traversal is complete.
   */
  async traverseHistory(delta) {
    const params = {
      method: 'browsingContext.traverseHistory',
      params: {
        context: this._id,
        delta: delta,
      },
    }
    await this.bidi.send(params)
  }

  /**
   * Moves the browsing context forward by one step in the history.
   * @returns {Promise<void>} A promise that resolves when the browsing context has moved forward.
   */
  async forward() {
    await this.traverseHistory(1)
  }

  /**
   * Navigates the browsing context to the previous page in the history.
   * @returns {Promise<void>} A promise that resolves when the navigation is complete.
   */
  async back() {
    await this.traverseHistory(-1)
  }

  /**
   * Locates nodes in the browsing context.
   *
   * @param {Locator} locator - The locator object used to locate the nodes.
   * @param {number} [maxNodeCount] - The maximum number of nodes to locate (optional).
   * @param {string} [sandbox] - The sandbox name for locating nodes (optional).
   * @param {SerializationOptions} [serializationOptions] - The serialization options for locating nodes (optional).
   * @param {ReferenceValue[]} [startNodes] - The array of start nodes for locating nodes (optional).
   * @returns {Promise<RemoteValue[]>} - A promise that resolves to the arrays of located nodes.
   * @throws {Error} - If the locator is not an instance of Locator.
   * @throws {Error} - If the serializationOptions is provided but not an instance of SerializationOptions.
   * @throws {Error} - If the startNodes is provided but not an array of ReferenceValue objects.
   * @throws {Error} - If any of the startNodes is not an instance of ReferenceValue.
   */
  async locateNodes(
    locator,
    maxNodeCount = undefined,
    sandbox = undefined,
    serializationOptions = undefined,
    startNodes = undefined,
  ) {
    if (!(locator instanceof Locator)) {
      throw Error(`Pass in a Locator object. Received: ${locator}`)
    }

    if (serializationOptions !== undefined && !(serializationOptions instanceof SerializationOptions)) {
      throw Error(`Pass in SerializationOptions object. Received: ${serializationOptions} `)
    }

    if (startNodes !== undefined && !Array.isArray(startNodes)) {
      throw Error(`Pass in an array of ReferenceValue objects. Received: ${startNodes}`)
    }

    if (startNodes !== undefined && Array.isArray(startNodes)) {
      startNodes.forEach((node) => {
        if (!(node instanceof ReferenceValue)) {
          throw Error(`Pass in a ReferenceValue object. Received: ${node}`)
        }
      })
    }

    const params = {
      method: 'browsingContext.locateNodes',
      params: {
        context: this._id,
        locator: Object.fromEntries(locator.toMap()),
        maxNodeCount: maxNodeCount,
        sandbox: sandbox,
        serializationOptions: serializationOptions,
        startNodes: startNodes,
      },
    }

    let response = await this.bidi.send(params)
    if ('error' in response) {
      throw Error(response['error'])
    }

    const nodes = response.result.nodes
    const remoteValues = []

    nodes.forEach((node) => {
      remoteValues.push(new RemoteValue(node))
    })
    return remoteValues
  }

  /**
   * Locates a single node in the browsing context.
   *
   * @param {Locator} locator - The locator used to find the node.
   * @param {string} [sandbox] - The sandbox of the node (optional).
   * @param {SerializationOptions} [serializationOptions] - The serialization options for the node (optional).
   * @param {Array} [startNodes] - The starting nodes for the search (optional).
   * @returns {Promise<RemoteValue>} - A promise that resolves to the located node.
   */
  async locateNode(locator, sandbox = undefined, serializationOptions = undefined, startNodes = undefined) {
    const elements = await this.locateNodes(locator, 1, sandbox, serializationOptions, startNodes)
    return elements[0]
  }

  async locateElement(locator) {
    const elements = await this.locateNodes(locator, 1)
    return new WebElement(this._driver, elements[0].sharedId)
  }

  async locateElements(locator) {
    const elements = await this.locateNodes(locator)

    let webElements = []
    elements.forEach((element) => {
      webElements.push(new WebElement(this._driver, element.sharedId))
    })
    return webElements
  }
}

/**
 * Represents the result of a navigation operation.
 */
class NavigateResult {
  constructor(url, navigationId) {
    this._url = url
    this._navigationId = navigationId
  }

  /**
   * Gets the URL of the navigated page.
   * @returns {string} The URL of the navigated page.
   */
  get url() {
    return this._url
  }

  /**
   * Gets the ID of the navigation operation.
   * @returns {number} The ID of the navigation operation.
   */
  get navigationId() {
    return this._navigationId
  }
}

/**
 * Represents a print result.
 */
class PrintResult {
  constructor(data) {
    this._data = data
  }

  /**
   * Gets the data associated with the print result.
   * @returns {any} The data associated with the print result.
   */
  get data() {
    return this._data
  }
}

/**
 * initiate browsing context instance and return
 * @param driver
 * @param browsingContextId The browsing context of current window/tab
 * @param type "window" or "tab"
 * @param createParameters The parameters for creating a new browsing context
 * @returns {Promise<BrowsingContext>}
 */
async function getBrowsingContextInstance(
  driver,
  { browsingContextId = undefined, type = undefined, createParameters = undefined },
) {
  let instance = new BrowsingContext(driver)
  await instance.init({ browsingContextId, type, createParameters })
  return instance
}

module.exports = getBrowsingContextInstance
module.exports.Locator = Locator