/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { WebSocketConnection } from "chrome://remote/content/shared/WebSocketConnection.sys.mjs"; const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs", error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", Log: "chrome://remote/content/shared/Log.sys.mjs", pprint: "chrome://remote/content/shared/Format.sys.mjs", processCapabilities: "chrome://remote/content/shared/webdriver/Capabilities.sys.mjs", quit: "chrome://remote/content/shared/Browser.sys.mjs", RemoteAgent: "chrome://remote/content/components/RemoteAgent.sys.mjs", WebDriverSession: "chrome://remote/content/shared/webdriver/Session.sys.mjs", }); ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get(lazy.Log.TYPES.WEBDRIVER_BIDI) ); export class WebDriverBiDiConnection extends WebSocketConnection { #sessionConfigFlags; /** * @param {WebSocket} webSocket * The WebSocket server connection to wrap. * @param {Connection} httpdConnection * Reference to the httpd.js's connection needed for clean-up. */ constructor(webSocket, httpdConnection) { super(webSocket, httpdConnection); // Each connection has only a single associated WebDriver session. this.session = null; this.#sessionConfigFlags = new Set([ lazy.WebDriverSession.SESSION_FLAG_BIDI, ]); } /** * Perform required steps to end the session. */ endSession() { // TODO Bug 1838269. Implement session ending logic // for the case of classic + bidi session. // We currently only support one session, see Bug 1720707. lazy.RemoteAgent.webDriverBiDi.deleteSession(); } /** * Register a new WebDriver Session to forward the messages to. * * @param {Session} session * The WebDriverSession to register. */ registerSession(session) { if (this.session) { throw new lazy.error.UnknownError( "A WebDriver session has already been set" ); } this.session = session; lazy.logger.debug( `Connection ${this.id} attached to session ${session.id}` ); } /** * Unregister the already set WebDriver session. */ unregisterSession() { if (!this.session) { return; } this.session.removeConnection(this); this.session = null; } /** * Send an error back to the WebDriver BiDi client. * * @param {number} id * Id of the packet which lead to an error. * @param {Error} err * Error object with `status`, `message` and `stack` attributes. */ sendError(id, err) { // Convert specific MessageHandler errors to WebDriver errors let webDriverError; switch (err.name) { case "DiscardedBrowsingContextError": webDriverError = lazy.error.wrap(err, lazy.error.NoSuchFrameError); break; default: webDriverError = lazy.error.wrap(err); } this.send({ type: "error", id, error: webDriverError.status, message: webDriverError.message, stacktrace: webDriverError.stack, }); } /** * Send an event coming from a module to the WebDriver BiDi client. * * @param {string} method * The event name. This is composed by a module name, a dot character * followed by the event name, e.g. `log.entryAdded`. * @param {object} params * A JSON-serializable object, which is the payload of this event. */ sendEvent(method, params) { this.send({ type: "event", method, params }); if (Services.profiler?.IsActive()) { ChromeUtils.addProfilerMarker( "BiDi: Event", { category: "Remote-Protocol" }, method ); } } /** * Send the result of a call to a module's method back to the * WebDriver BiDi client. * * @param {number} id * The request id being sent by the client to call the module's method. * @param {object} result * A JSON-serializable object, which is the actual result. */ sendResult(id, result) { result = typeof result !== "undefined" ? result : {}; this.send({ type: "success", id, result }); } observe(subject, topic) { switch (topic) { case "quit-application-requested": this.endSession(); break; } } // Transport hooks /** * Called by the `transport` when the connection is closed. */ onConnectionClose() { this.unregisterSession(); super.onConnectionClose(); } /** * Receive a packet from the WebSocket layer. * * This packet is sent by a WebDriver BiDi client and is meant to execute * a particular method on a given module. * * @param {object} packet * JSON-serializable object sent by the client */ async onPacket(packet) { super.onPacket(packet); const { id, method, params } = packet; const startTime = Cu.now(); try { // First check for mandatory field in the command packet lazy.assert.positiveInteger( id, lazy.pprint`Expected "id" to be a postive integer, got ${id}` ); lazy.assert.string( method, lazy.pprint`Expected "method" to be a string, got ${method}` ); lazy.assert.object( params, lazy.pprint`Expected "params" to be an object, got ${params}` ); // Extract the module and the command name out of `method` attribute const { module, command } = splitMethod(method); let result; // Handle static commands first if (module === "session" && command === "new") { const processedCapabilities = lazy.processCapabilities(params); result = await lazy.RemoteAgent.webDriverBiDi.createSession( processedCapabilities, this.#sessionConfigFlags, this ); // Until the spec (see: https://github.com/w3c/webdriver/issues/1834) // is updated to specify what should be the default value for bidi session, // remove this capability from the return value when it's not provided by a client. if (!("unhandledPromptBehavior" in processedCapabilities)) { // We don't want to modify the original `capabilities` field // because it points to an original Capabilities object used by the session. // Since before the result is sent to a client we're going anyway to call // `JSON.stringify` on `result` which will call `toJSON` method recursively, // we can call it already here for the `capabilities` property // to update only the command response object. result.capabilities = result.capabilities.toJSON(); delete result.capabilities.unhandledPromptBehavior; } } else if (module === "session" && command === "status") { result = lazy.RemoteAgent.webDriverBiDi.getSessionReadinessStatus(); } else { lazy.assert.session(this.session); // Bug 1741854 - Workaround to deny internal methods to be called if (command.startsWith("_")) { throw new lazy.error.UnknownCommandError(method); } // Finally, instruct the session to execute the command result = await this.session.execute(module, command, params); } this.sendResult(id, result); // Session clean up. if (module === "session" && command === "end") { this.endSession(); } // Close the browser. // TODO Bug 1842018. Refactor this part to return the response // when the quitting of the browser is finished. else if (module === "browser" && command === "close") { // Register handler to run WebDriver BiDi specific shutdown code. Services.obs.addObserver(this, "quit-application-requested"); // TODO Bug 1836282. Add as the third argument "moz:windowless" capability // from the session, when this capability is supported by Webdriver BiDi. await lazy.quit(["eForceQuit"], false); Services.obs.removeObserver(this, "quit-application-requested"); } } catch (e) { this.sendError(id, e); } if (Services.profiler?.IsActive()) { ChromeUtils.addProfilerMarker( "BiDi: Command", { startTime, category: "Remote-Protocol" }, `${method} (${id})` ); } } } /** * Splits a WebDriver BiDi method into module and command components. * * @param {string} method * Name of the method to split, e.g. "session.subscribe". * * @returns {Record} * Object with the module ("session") and command ("subscribe") * as properties. */ export function splitMethod(method) { const parts = method.split("."); if (parts.length != 2 || !parts[0].length || !parts[1].length) { throw new TypeError(`Invalid method format: '${method}'`); } return { module: parts[0], command: parts[1], }; }