// 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