summaryrefslogtreecommitdiffstats
path: root/remote/webdriver-bidi/modules
diff options
context:
space:
mode:
Diffstat (limited to 'remote/webdriver-bidi/modules')
-rw-r--r--remote/webdriver-bidi/modules/Intercept.sys.mjs101
-rw-r--r--remote/webdriver-bidi/modules/ModuleRegistry.sys.mjs46
-rw-r--r--remote/webdriver-bidi/modules/WindowGlobalBiDiModule.sys.mjs83
-rw-r--r--remote/webdriver-bidi/modules/root/browser.sys.mjs128
-rw-r--r--remote/webdriver-bidi/modules/root/browsingContext.sys.mjs1964
-rw-r--r--remote/webdriver-bidi/modules/root/input.sys.mjs99
-rw-r--r--remote/webdriver-bidi/modules/root/log.sys.mjs15
-rw-r--r--remote/webdriver-bidi/modules/root/network.sys.mjs1730
-rw-r--r--remote/webdriver-bidi/modules/root/script.sys.mjs959
-rw-r--r--remote/webdriver-bidi/modules/root/session.sys.mjs419
-rw-r--r--remote/webdriver-bidi/modules/root/storage.sys.mjs770
-rw-r--r--remote/webdriver-bidi/modules/windowglobal-in-root/browsingContext.sys.mjs43
-rw-r--r--remote/webdriver-bidi/modules/windowglobal-in-root/log.sys.mjs37
-rw-r--r--remote/webdriver-bidi/modules/windowglobal-in-root/script.sys.mjs37
-rw-r--r--remote/webdriver-bidi/modules/windowglobal/browsingContext.sys.mjs475
-rw-r--r--remote/webdriver-bidi/modules/windowglobal/input.sys.mjs111
-rw-r--r--remote/webdriver-bidi/modules/windowglobal/log.sys.mjs256
-rw-r--r--remote/webdriver-bidi/modules/windowglobal/script.sys.mjs493
18 files changed, 7766 insertions, 0 deletions
diff --git a/remote/webdriver-bidi/modules/Intercept.sys.mjs b/remote/webdriver-bidi/modules/Intercept.sys.mjs
new file mode 100644
index 0000000000..4e3a9bb9e7
--- /dev/null
+++ b/remote/webdriver-bidi/modules/Intercept.sys.mjs
@@ -0,0 +1,101 @@
+/* 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/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ getSeenNodesForBrowsingContext:
+ "chrome://remote/content/shared/webdriver/Session.sys.mjs",
+ TabManager: "chrome://remote/content/shared/TabManager.sys.mjs",
+});
+
+/**
+ * The serialization of JavaScript objects in the content process might produce
+ * extra data that needs to be transfered and then processed by the parent
+ * process. This extra data is part of the payload as returned by commands
+ * and events and can contain the following:
+ *
+ * - {Map<BrowsingContext, Array<string>>} seenNodeIds
+ * DOM nodes that need to be added to the navigable seen nodes map.
+ *
+ * @param {string} sessionId
+ * Id of the WebDriver session
+ * @param {object} payload
+ * Payload of the response for the command and event that might contain
+ * a `_extraData` field.
+ *
+ * @returns {object}
+ * The payload with the extra data removed if it was present.
+ */
+export function processExtraData(sessionId, payload) {
+ // Process extra data if present and delete it from the payload
+ if ("_extraData" in payload) {
+ const { seenNodeIds } = payload._extraData;
+
+ // Updates the seen nodes for the current session and browsing context.
+ seenNodeIds?.forEach((nodeIds, browsingContext) => {
+ const seenNodes = lazy.getSeenNodesForBrowsingContext(
+ sessionId,
+ browsingContext
+ );
+
+ nodeIds.forEach(nodeId => seenNodes.add(nodeId));
+ });
+
+ delete payload._extraData;
+ }
+
+ // Find serialized WindowProxy and resolve browsing context to a navigable id.
+ if (payload?.result) {
+ payload.result = addContextIdToSerializedWindow(payload.result);
+ } else if (payload.exceptionDetails) {
+ payload.exceptionDetails = addContextIdToSerializedWindow(
+ payload.exceptionDetails
+ );
+ }
+
+ return payload;
+}
+
+function addContextIdToSerializedWindow(serialized) {
+ if (serialized.value) {
+ switch (serialized.type) {
+ case "array":
+ case "htmlcollection":
+ case "nodelist":
+ case "set": {
+ serialized.value = serialized.value.map(value =>
+ addContextIdToSerializedWindow(value)
+ );
+ break;
+ }
+
+ case "map":
+ case "object": {
+ serialized.value = serialized.value.map(([key, value]) => [
+ key,
+ addContextIdToSerializedWindow(value),
+ ]);
+ break;
+ }
+
+ case "window": {
+ if (serialized.value.isTopBrowsingContext) {
+ const browsingContext = BrowsingContext.getCurrentTopByBrowserId(
+ serialized.value.context
+ );
+
+ serialized.value = {
+ context: lazy.TabManager.getIdForBrowsingContext(browsingContext),
+ };
+ }
+ break;
+ }
+ }
+ } else if (serialized.exception) {
+ serialized.exception = addContextIdToSerializedWindow(serialized.exception);
+ }
+
+ return serialized;
+}
diff --git a/remote/webdriver-bidi/modules/ModuleRegistry.sys.mjs b/remote/webdriver-bidi/modules/ModuleRegistry.sys.mjs
new file mode 100644
index 0000000000..63713f1f02
--- /dev/null
+++ b/remote/webdriver-bidi/modules/ModuleRegistry.sys.mjs
@@ -0,0 +1,46 @@
+/* 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/. */
+
+export const modules = {
+ root: {},
+ "windowglobal-in-root": {},
+ windowglobal: {},
+};
+
+// eslint-disable-next-line mozilla/lazy-getter-object-name
+ChromeUtils.defineESModuleGetters(modules.root, {
+ browser:
+ "chrome://remote/content/webdriver-bidi/modules/root/browser.sys.mjs",
+ browsingContext:
+ "chrome://remote/content/webdriver-bidi/modules/root/browsingContext.sys.mjs",
+ input: "chrome://remote/content/webdriver-bidi/modules/root/input.sys.mjs",
+ log: "chrome://remote/content/webdriver-bidi/modules/root/log.sys.mjs",
+ network:
+ "chrome://remote/content/webdriver-bidi/modules/root/network.sys.mjs",
+ script: "chrome://remote/content/webdriver-bidi/modules/root/script.sys.mjs",
+ session:
+ "chrome://remote/content/webdriver-bidi/modules/root/session.sys.mjs",
+ storage:
+ "chrome://remote/content/webdriver-bidi/modules/root/storage.sys.mjs",
+});
+
+// eslint-disable-next-line mozilla/lazy-getter-object-name
+ChromeUtils.defineESModuleGetters(modules["windowglobal-in-root"], {
+ browsingContext:
+ "chrome://remote/content/webdriver-bidi/modules/windowglobal-in-root/browsingContext.sys.mjs",
+ log: "chrome://remote/content/webdriver-bidi/modules/windowglobal-in-root/log.sys.mjs",
+ script:
+ "chrome://remote/content/webdriver-bidi/modules/windowglobal-in-root/script.sys.mjs",
+});
+
+// eslint-disable-next-line mozilla/lazy-getter-object-name
+ChromeUtils.defineESModuleGetters(modules.windowglobal, {
+ browsingContext:
+ "chrome://remote/content/webdriver-bidi/modules/windowglobal/browsingContext.sys.mjs",
+ input:
+ "chrome://remote/content/webdriver-bidi/modules/windowglobal/input.sys.mjs",
+ log: "chrome://remote/content/webdriver-bidi/modules/windowglobal/log.sys.mjs",
+ script:
+ "chrome://remote/content/webdriver-bidi/modules/windowglobal/script.sys.mjs",
+});
diff --git a/remote/webdriver-bidi/modules/WindowGlobalBiDiModule.sys.mjs b/remote/webdriver-bidi/modules/WindowGlobalBiDiModule.sys.mjs
new file mode 100644
index 0000000000..025ce5f4ab
--- /dev/null
+++ b/remote/webdriver-bidi/modules/WindowGlobalBiDiModule.sys.mjs
@@ -0,0 +1,83 @@
+/* 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 { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ deserialize: "chrome://remote/content/webdriver-bidi/RemoteValue.sys.mjs",
+ serialize: "chrome://remote/content/webdriver-bidi/RemoteValue.sys.mjs",
+});
+
+/**
+ * Base class for all WindowGlobal BiDi MessageHandler modules.
+ */
+export class WindowGlobalBiDiModule extends Module {
+ get #nodeCache() {
+ return this.#processActor.getNodeCache();
+ }
+
+ get #processActor() {
+ return ChromeUtils.domProcessChild.getActor("WebDriverProcessData");
+ }
+
+ /**
+ * Wrapper to deserialize a local / remote value.
+ *
+ * @param {object} serializedValue
+ * Value of any type to be deserialized.
+ * @param {Realm} realm
+ * The Realm in which the value is deserialized.
+ * @param {ExtraSerializationOptions=} extraOptions
+ * Extra Remote Value deserialization options.
+ *
+ * @returns {object}
+ * Deserialized representation of the value.
+ */
+ deserialize(serializedValue, realm, extraOptions = {}) {
+ extraOptions.nodeCache = this.#nodeCache;
+
+ return lazy.deserialize(serializedValue, realm, extraOptions);
+ }
+
+ /**
+ * Wrapper to serialize a value as a remote value.
+ *
+ * @param {object} value
+ * Value of any type to be serialized.
+ * @param {SerializationOptions} serializationOptions
+ * Options which define how ECMAScript objects should be serialized.
+ * @param {OwnershipModel} ownershipType
+ * The ownership model to use for this serialization.
+ * @param {Realm} realm
+ * The Realm from which comes the value being serialized.
+ * @param {ExtraSerializationOptions} extraOptions
+ * Extra Remote Value serialization options.
+ *
+ * @returns {object}
+ * Promise that resolves to the serialized representation of the value.
+ */
+ serialize(
+ value,
+ serializationOptions,
+ ownershipType,
+ realm,
+ extraOptions = {}
+ ) {
+ const { nodeCache = this.#nodeCache, seenNodeIds = new Map() } =
+ extraOptions;
+
+ const serializedValue = lazy.serialize(
+ value,
+ serializationOptions,
+ ownershipType,
+ new Map(),
+ realm,
+ { nodeCache, seenNodeIds }
+ );
+
+ return serializedValue;
+ }
+}
diff --git a/remote/webdriver-bidi/modules/root/browser.sys.mjs b/remote/webdriver-bidi/modules/root/browser.sys.mjs
new file mode 100644
index 0000000000..57d40e74e9
--- /dev/null
+++ b/remote/webdriver-bidi/modules/root/browser.sys.mjs
@@ -0,0 +1,128 @@
+/* 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 { Module } from "chrome://remote/content/shared/messagehandler/Module.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",
+ Marionette: "chrome://remote/content/components/Marionette.sys.mjs",
+ UserContextManager:
+ "chrome://remote/content/shared/UserContextManager.sys.mjs",
+});
+
+/**
+ * An object that holds information about a user context.
+ *
+ * @typedef UserContextInfo
+ *
+ * @property {string} userContext
+ * The id of the user context.
+ */
+
+/**
+ * Return value for the getUserContexts command.
+ *
+ * @typedef GetUserContextsResult
+ *
+ * @property {Array<UserContextInfo>} userContexts
+ * Array of UserContextInfo for the current user contexts.
+ */
+
+class BrowserModule extends Module {
+ constructor(messageHandler) {
+ super(messageHandler);
+ }
+
+ destroy() {}
+
+ /**
+ * Commands
+ */
+
+ /**
+ * Terminate all WebDriver sessions and clean up automation state in the remote browser instance.
+ *
+ * Session clean up and actual broser closure will happen later in WebDriverBiDiConnection class.
+ */
+ async close() {
+ // TODO Bug 1838269. Enable browser.close command for the case of classic + bidi session, when
+ // session ending for this type of session is supported.
+ if (lazy.Marionette.running) {
+ throw new lazy.error.UnsupportedOperationError(
+ "Closing browser with the session which was started with Webdriver classic is not supported," +
+ "you can use Webdriver classic session delete command which will also close the browser."
+ );
+ }
+ }
+
+ /**
+ * Creates a user context.
+ *
+ * @returns {UserContextInfo}
+ * UserContextInfo object for the created user context.
+ */
+ async createUserContext() {
+ const userContextId = lazy.UserContextManager.createContext("webdriver");
+ return { userContext: userContextId };
+ }
+
+ /**
+ * Returns the list of available user contexts.
+ *
+ * @returns {GetUserContextsResult}
+ * Object containing an array of UserContextInfo.
+ */
+ async getUserContexts() {
+ const userContexts = lazy.UserContextManager.getUserContextIds().map(
+ userContextId => ({
+ userContext: userContextId,
+ })
+ );
+
+ return { userContexts };
+ }
+
+ /**
+ * Closes a user context and all browsing contexts in it without running
+ * beforeunload handlers.
+ *
+ * @param {object=} options
+ * @param {string} options.userContext
+ * Id of the user context to close.
+ *
+ * @throws {InvalidArgumentError}
+ * Raised if an argument is of an invalid type or value.
+ * @throws {NoSuchUserContextError}
+ * Raised if the user context id could not be found.
+ */
+ async removeUserContext(options = {}) {
+ const { userContext: userContextId } = options;
+
+ lazy.assert.string(
+ userContextId,
+ `Expected "userContext" to be a string, got ${userContextId}`
+ );
+
+ if (userContextId === lazy.UserContextManager.defaultUserContextId) {
+ throw new lazy.error.InvalidArgumentError(
+ `Default user context cannot be removed`
+ );
+ }
+
+ if (!lazy.UserContextManager.hasUserContextId(userContextId)) {
+ throw new lazy.error.NoSuchUserContextError(
+ `User Context with id ${userContextId} was not found`
+ );
+ }
+ lazy.UserContextManager.removeUserContext(userContextId, {
+ closeContextTabs: true,
+ });
+ }
+}
+
+// To export the class as lower-case
+export const browser = BrowserModule;
diff --git a/remote/webdriver-bidi/modules/root/browsingContext.sys.mjs b/remote/webdriver-bidi/modules/root/browsingContext.sys.mjs
new file mode 100644
index 0000000000..bc600e89cd
--- /dev/null
+++ b/remote/webdriver-bidi/modules/root/browsingContext.sys.mjs
@@ -0,0 +1,1964 @@
+/* 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 { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AppInfo: "chrome://remote/content/shared/AppInfo.sys.mjs",
+ assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs",
+ BrowsingContextListener:
+ "chrome://remote/content/shared/listeners/BrowsingContextListener.sys.mjs",
+ capture: "chrome://remote/content/shared/Capture.sys.mjs",
+ ContextDescriptorType:
+ "chrome://remote/content/shared/messagehandler/MessageHandler.sys.mjs",
+ error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
+ EventPromise: "chrome://remote/content/shared/Sync.sys.mjs",
+ getTimeoutMultiplier: "chrome://remote/content/shared/AppInfo.sys.mjs",
+ modal: "chrome://remote/content/shared/Prompt.sys.mjs",
+ registerNavigationId:
+ "chrome://remote/content/shared/NavigationManager.sys.mjs",
+ NavigationListener:
+ "chrome://remote/content/shared/listeners/NavigationListener.sys.mjs",
+ OwnershipModel: "chrome://remote/content/webdriver-bidi/RemoteValue.sys.mjs",
+ PollPromise: "chrome://remote/content/shared/Sync.sys.mjs",
+ pprint: "chrome://remote/content/shared/Format.sys.mjs",
+ print: "chrome://remote/content/shared/PDF.sys.mjs",
+ ProgressListener: "chrome://remote/content/shared/Navigate.sys.mjs",
+ PromptListener:
+ "chrome://remote/content/shared/listeners/PromptListener.sys.mjs",
+ setDefaultAndAssertSerializationOptions:
+ "chrome://remote/content/webdriver-bidi/RemoteValue.sys.mjs",
+ TabManager: "chrome://remote/content/shared/TabManager.sys.mjs",
+ UserContextManager:
+ "chrome://remote/content/shared/UserContextManager.sys.mjs",
+ waitForInitialNavigationCompleted:
+ "chrome://remote/content/shared/Navigate.sys.mjs",
+ WindowGlobalMessageHandler:
+ "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs",
+ windowManager: "chrome://remote/content/shared/WindowManager.sys.mjs",
+});
+
+// Maximal window dimension allowed when emulating a viewport.
+const MAX_WINDOW_SIZE = 10000000;
+
+/**
+ * @typedef {string} ClipRectangleType
+ */
+
+/**
+ * Enum of possible clip rectangle types supported by the
+ * browsingContext.captureScreenshot command.
+ *
+ * @readonly
+ * @enum {ClipRectangleType}
+ */
+export const ClipRectangleType = {
+ Box: "box",
+ Element: "element",
+};
+
+/**
+ * @typedef {object} CreateType
+ */
+
+/**
+ * Enum of types supported by the browsingContext.create command.
+ *
+ * @readonly
+ * @enum {CreateType}
+ */
+const CreateType = {
+ tab: "tab",
+ window: "window",
+};
+
+/**
+ * @typedef {string} LocatorType
+ */
+
+/**
+ * Enum of types supported by the browsingContext.locateNodes command.
+ *
+ * @readonly
+ * @enum {LocatorType}
+ */
+export const LocatorType = {
+ css: "css",
+ innerText: "innerText",
+ xpath: "xpath",
+};
+
+/**
+ * @typedef {string} OriginType
+ */
+
+/**
+ * Enum of origin type supported by the
+ * browsingContext.captureScreenshot command.
+ *
+ * @readonly
+ * @enum {OriginType}
+ */
+export const OriginType = {
+ document: "document",
+ viewport: "viewport",
+};
+
+const TIMEOUT_SET_HISTORY_INDEX = 1000;
+
+/**
+ * Enum of user prompt types supported by the browsingContext.handleUserPrompt
+ * command, these types can be retrieved from `dialog.args.promptType`.
+ *
+ * @readonly
+ * @enum {UserPromptType}
+ */
+const UserPromptType = {
+ alert: "alert",
+ confirm: "confirm",
+ prompt: "prompt",
+ beforeunload: "beforeunload",
+};
+
+/**
+ * An object that contains details of a viewport.
+ *
+ * @typedef {object} Viewport
+ *
+ * @property {number} height
+ * The height of the viewport.
+ * @property {number} width
+ * The width of the viewport.
+ */
+
+/**
+ * @typedef {string} WaitCondition
+ */
+
+/**
+ * Wait conditions supported by WebDriver BiDi for navigation.
+ *
+ * @enum {WaitCondition}
+ */
+const WaitCondition = {
+ None: "none",
+ Interactive: "interactive",
+ Complete: "complete",
+};
+
+class BrowsingContextModule extends Module {
+ #contextListener;
+ #navigationListener;
+ #promptListener;
+ #subscribedEvents;
+
+ /**
+ * Create a new module instance.
+ *
+ * @param {MessageHandler} messageHandler
+ * The MessageHandler instance which owns this Module instance.
+ */
+ constructor(messageHandler) {
+ super(messageHandler);
+
+ this.#contextListener = new lazy.BrowsingContextListener();
+ this.#contextListener.on("attached", this.#onContextAttached);
+ this.#contextListener.on("discarded", this.#onContextDiscarded);
+
+ // Create the navigation listener and listen to "navigation-started" and
+ // "location-changed" events.
+ this.#navigationListener = new lazy.NavigationListener(
+ this.messageHandler.navigationManager
+ );
+ this.#navigationListener.on("location-changed", this.#onLocationChanged);
+ this.#navigationListener.on(
+ "navigation-started",
+ this.#onNavigationStarted
+ );
+
+ // Create the prompt listener and listen to "closed" and "opened" events.
+ this.#promptListener = new lazy.PromptListener();
+ this.#promptListener.on("closed", this.#onPromptClosed);
+ this.#promptListener.on("opened", this.#onPromptOpened);
+
+ // Set of event names which have active subscriptions.
+ this.#subscribedEvents = new Set();
+
+ // Treat the event of moving a page to BFCache as context discarded event for iframes.
+ this.messageHandler.on("windowglobal-pagehide", this.#onPageHideEvent);
+ }
+
+ destroy() {
+ this.#contextListener.off("attached", this.#onContextAttached);
+ this.#contextListener.off("discarded", this.#onContextDiscarded);
+ this.#contextListener.destroy();
+
+ this.#promptListener.off("closed", this.#onPromptClosed);
+ this.#promptListener.off("opened", this.#onPromptOpened);
+ this.#promptListener.destroy();
+
+ this.#subscribedEvents = null;
+
+ this.messageHandler.off("windowglobal-pagehide", this.#onPageHideEvent);
+ }
+
+ /**
+ * Activates and focuses the given top-level browsing context.
+ *
+ * @param {object=} options
+ * @param {string} options.context
+ * Id of the browsing context.
+ *
+ * @throws {InvalidArgumentError}
+ * Raised if an argument is of an invalid type or value.
+ * @throws {NoSuchFrameError}
+ * If the browsing context cannot be found.
+ */
+ async activate(options = {}) {
+ const { context: contextId } = options;
+
+ lazy.assert.string(
+ contextId,
+ `Expected "context" to be a string, got ${contextId}`
+ );
+ const context = this.#getBrowsingContext(contextId);
+
+ if (context.parent) {
+ throw new lazy.error.InvalidArgumentError(
+ `Browsing Context with id ${contextId} is not top-level`
+ );
+ }
+
+ const tab = lazy.TabManager.getTabForBrowsingContext(context);
+ const window = lazy.TabManager.getWindowForTab(tab);
+
+ await lazy.windowManager.focusWindow(window);
+ await lazy.TabManager.selectTab(tab);
+ }
+
+ /**
+ * Used as an argument for browsingContext.captureScreenshot command, as one of the available variants
+ * {BoxClipRectangle} or {ElementClipRectangle}, to represent a target of the command.
+ *
+ * @typedef ClipRectangle
+ */
+
+ /**
+ * Used as an argument for browsingContext.captureScreenshot command
+ * to represent a box which is going to be a target of the command.
+ *
+ * @typedef BoxClipRectangle
+ *
+ * @property {ClipRectangleType} [type=ClipRectangleType.Box]
+ * @property {number} x
+ * @property {number} y
+ * @property {number} width
+ * @property {number} height
+ */
+
+ /**
+ * Used as an argument for browsingContext.captureScreenshot command
+ * to represent an element which is going to be a target of the command.
+ *
+ * @typedef ElementClipRectangle
+ *
+ * @property {ClipRectangleType} [type=ClipRectangleType.Element]
+ * @property {SharedReference} element
+ */
+
+ /**
+ * Capture a base64-encoded screenshot of the provided browsing context.
+ *
+ * @param {object=} options
+ * @param {string} options.context
+ * Id of the browsing context to screenshot.
+ * @param {ClipRectangle=} options.clip
+ * A box or an element of which a screenshot should be taken.
+ * If not present, take a screenshot of the whole viewport.
+ * @param {OriginType=} options.origin
+ *
+ * @throws {NoSuchFrameError}
+ * If the browsing context cannot be found.
+ */
+ async captureScreenshot(options = {}) {
+ const {
+ clip = null,
+ context: contextId,
+ origin = OriginType.viewport,
+ } = options;
+
+ lazy.assert.string(
+ contextId,
+ `Expected "context" to be a string, got ${contextId}`
+ );
+ const context = this.#getBrowsingContext(contextId);
+
+ const originTypeValues = Object.values(OriginType);
+ lazy.assert.that(
+ value => originTypeValues.includes(value),
+ `Expected "origin" to be one of ${originTypeValues}, got ${origin}`
+ )(origin);
+
+ if (clip !== null) {
+ lazy.assert.object(clip, `Expected "clip" to be a object, got ${clip}`);
+
+ const { type } = clip;
+ switch (type) {
+ case ClipRectangleType.Box: {
+ const { x, y, width, height } = clip;
+
+ lazy.assert.number(x, `Expected "x" to be a number, got ${x}`);
+ lazy.assert.number(y, `Expected "y" to be a number, got ${y}`);
+ lazy.assert.number(
+ width,
+ `Expected "width" to be a number, got ${width}`
+ );
+ lazy.assert.number(
+ height,
+ `Expected "height" to be a number, got ${height}`
+ );
+
+ break;
+ }
+
+ case ClipRectangleType.Element: {
+ const { element } = clip;
+
+ lazy.assert.object(
+ element,
+ `Expected "element" to be an object, got ${element}`
+ );
+
+ break;
+ }
+
+ default:
+ throw new lazy.error.InvalidArgumentError(
+ `Expected "type" to be one of ${Object.values(
+ ClipRectangleType
+ )}, got ${type}`
+ );
+ }
+ }
+
+ const rect = await this.messageHandler.handleCommand({
+ moduleName: "browsingContext",
+ commandName: "_getScreenshotRect",
+ destination: {
+ type: lazy.WindowGlobalMessageHandler.type,
+ id: context.id,
+ },
+ params: {
+ clip,
+ origin,
+ },
+ retryOnAbort: true,
+ });
+
+ if (rect.width === 0 || rect.height === 0) {
+ throw new lazy.error.UnableToCaptureScreen(
+ `The dimensions of requested screenshot are incorrect, got width: ${rect.width} and height: ${rect.height}.`
+ );
+ }
+
+ const canvas = await lazy.capture.canvas(
+ context.topChromeWindow,
+ context,
+ rect.x,
+ rect.y,
+ rect.width,
+ rect.height
+ );
+
+ return {
+ data: lazy.capture.toBase64(canvas),
+ };
+ }
+
+ /**
+ * Close the provided browsing context.
+ *
+ * @param {object=} options
+ * @param {string} options.context
+ * Id of the browsing context to close.
+ *
+ * @throws {NoSuchFrameError}
+ * If the browsing context cannot be found.
+ * @throws {InvalidArgumentError}
+ * If the browsing context is not a top-level one.
+ */
+ async close(options = {}) {
+ const { context: contextId } = options;
+
+ lazy.assert.string(
+ contextId,
+ `Expected "context" to be a string, got ${contextId}`
+ );
+
+ const context = lazy.TabManager.getBrowsingContextById(contextId);
+ if (!context) {
+ throw new lazy.error.NoSuchFrameError(
+ `Browsing Context with id ${contextId} not found`
+ );
+ }
+
+ if (context.parent) {
+ throw new lazy.error.InvalidArgumentError(
+ `Browsing Context with id ${contextId} is not top-level`
+ );
+ }
+
+ if (lazy.TabManager.getTabCount() === 1) {
+ // The behavior when closing the very last tab is currently unspecified.
+ // As such behave like Marionette and don't allow closing it.
+ // See: https://github.com/w3c/webdriver-bidi/issues/187
+ return;
+ }
+
+ const tab = lazy.TabManager.getTabForBrowsingContext(context);
+ await lazy.TabManager.removeTab(tab);
+ }
+
+ /**
+ * Create a new browsing context using the provided type "tab" or "window".
+ *
+ * @param {object=} options
+ * @param {boolean=} options.background
+ * Whether the tab/window should be open in the background. Defaults to false,
+ * which means that the tab/window will be open in the foreground.
+ * @param {string=} options.referenceContext
+ * Id of the top-level browsing context to use as reference.
+ * If options.type is "tab", the new tab will open in the same window as
+ * the reference context, and will be added next to the reference context.
+ * If options.type is "window", the reference context is ignored.
+ * @param {CreateType} options.type
+ * Type of browsing context to create.
+ * @param {string=} options.userContext
+ * The id of the user context which should own the browsing context.
+ * Defaults to the default user context.
+ *
+ * @throws {InvalidArgumentError}
+ * If the browsing context is not a top-level one.
+ * @throws {NoSuchFrameError}
+ * If the browsing context cannot be found.
+ */
+ async create(options = {}) {
+ const {
+ background = false,
+ referenceContext: referenceContextId = null,
+ type: typeHint,
+ userContext: userContextId = null,
+ } = options;
+
+ if (![CreateType.tab, CreateType.window].includes(typeHint)) {
+ throw new lazy.error.InvalidArgumentError(
+ `Expected "type" to be one of ${Object.values(
+ CreateType
+ )}, got ${typeHint}`
+ );
+ }
+
+ lazy.assert.boolean(
+ background,
+ lazy.pprint`Expected "background" to be a boolean, got ${background}`
+ );
+
+ let referenceContext = null;
+ if (referenceContextId !== null) {
+ lazy.assert.string(
+ referenceContextId,
+ lazy.pprint`Expected "referenceContext" to be a string, got ${referenceContextId}`
+ );
+
+ referenceContext =
+ lazy.TabManager.getBrowsingContextById(referenceContextId);
+ if (!referenceContext) {
+ throw new lazy.error.NoSuchFrameError(
+ `Browsing Context with id ${referenceContextId} not found`
+ );
+ }
+
+ if (referenceContext.parent) {
+ throw new lazy.error.InvalidArgumentError(
+ `referenceContext with id ${referenceContextId} is not a top-level browsing context`
+ );
+ }
+ }
+
+ let userContext = lazy.UserContextManager.defaultUserContextId;
+ if (referenceContext !== null) {
+ userContext =
+ lazy.UserContextManager.getIdByBrowsingContext(referenceContext);
+ }
+
+ if (userContextId !== null) {
+ lazy.assert.string(
+ userContextId,
+ lazy.pprint`Expected "userContext" to be a string, got ${userContextId}`
+ );
+
+ if (!lazy.UserContextManager.hasUserContextId(userContextId)) {
+ throw new lazy.error.NoSuchUserContextError(
+ `User Context with id ${userContextId} was not found`
+ );
+ }
+
+ userContext = userContextId;
+
+ if (
+ lazy.AppInfo.isAndroid &&
+ userContext != lazy.UserContextManager.defaultUserContextId
+ ) {
+ throw new lazy.error.UnsupportedOperationError(
+ `browsingContext.create with non-default "userContext" not supported for ${lazy.AppInfo.name}`
+ );
+ }
+ }
+
+ let browser;
+
+ // Since each tab in GeckoView has its own Gecko instance running,
+ // which means also its own window object, for Android we will need to focus
+ // a previously focused window in case of opening the tab in the background.
+ const previousWindow = Services.wm.getMostRecentBrowserWindow();
+ const previousTab =
+ lazy.TabManager.getTabBrowser(previousWindow).selectedTab;
+
+ // On Android there is only a single window allowed. As such fallback to
+ // open a new tab instead.
+ const type = lazy.AppInfo.isAndroid ? "tab" : typeHint;
+
+ switch (type) {
+ case "window":
+ const newWindow = await lazy.windowManager.openBrowserWindow({
+ focus: !background,
+ userContextId: userContext,
+ });
+ browser = lazy.TabManager.getTabBrowser(newWindow).selectedBrowser;
+ break;
+
+ case "tab":
+ if (!lazy.TabManager.supportsTabs()) {
+ throw new lazy.error.UnsupportedOperationError(
+ `browsingContext.create with type "tab" not supported in ${lazy.AppInfo.name}`
+ );
+ }
+
+ let referenceTab;
+ if (referenceContext !== null) {
+ referenceTab =
+ lazy.TabManager.getTabForBrowsingContext(referenceContext);
+ }
+
+ const tab = await lazy.TabManager.addTab({
+ focus: !background,
+ referenceTab,
+ userContextId: userContext,
+ });
+ browser = lazy.TabManager.getBrowserForTab(tab);
+ }
+
+ await lazy.waitForInitialNavigationCompleted(
+ browser.browsingContext.webProgress,
+ {
+ unloadTimeout: 5000,
+ }
+ );
+
+ // The tab on Android is always opened in the foreground,
+ // so we need to select the previous tab,
+ // and we have to wait until is fully loaded.
+ // TODO: Bug 1845559. This workaround can be removed,
+ // when the API to create a tab for Android supports the background option.
+ if (lazy.AppInfo.isAndroid && background) {
+ await lazy.windowManager.focusWindow(previousWindow);
+ await lazy.TabManager.selectTab(previousTab);
+ }
+
+ // Force a reflow by accessing `clientHeight` (see Bug 1847044).
+ browser.parentElement.clientHeight;
+
+ return {
+ context: lazy.TabManager.getIdForBrowser(browser),
+ };
+ }
+
+ /**
+ * An object that holds the WebDriver Bidi browsing context information.
+ *
+ * @typedef BrowsingContextInfo
+ *
+ * @property {string} context
+ * The id of the browsing context.
+ * @property {string=} parent
+ * The parent of the browsing context if it's the root browsing context
+ * of the to be processed browsing context tree.
+ * @property {string} url
+ * The current documents location.
+ * @property {string} userContext
+ * The id of the user context owning this browsing context.
+ * @property {Array<BrowsingContextInfo>=} children
+ * List of child browsing contexts. Only set if maxDepth hasn't been
+ * reached yet.
+ */
+
+ /**
+ * An object that holds the WebDriver Bidi browsing context tree information.
+ *
+ * @typedef BrowsingContextGetTreeResult
+ *
+ * @property {Array<BrowsingContextInfo>} contexts
+ * List of child browsing contexts.
+ */
+
+ /**
+ * Returns a tree of all browsing contexts that are descendents of the
+ * given context, or all top-level contexts when no root is provided.
+ *
+ * @param {object=} options
+ * @param {number=} options.maxDepth
+ * Depth of the browsing context tree to traverse. If not specified
+ * the whole tree is returned.
+ * @param {string=} options.root
+ * Id of the root browsing context.
+ *
+ * @returns {BrowsingContextGetTreeResult}
+ * Tree of browsing context information.
+ * @throws {NoSuchFrameError}
+ * If the browsing context cannot be found.
+ */
+ getTree(options = {}) {
+ const { maxDepth = null, root: rootId = null } = options;
+
+ if (maxDepth !== null) {
+ lazy.assert.positiveInteger(
+ maxDepth,
+ `Expected "maxDepth" to be a positive integer, got ${maxDepth}`
+ );
+ }
+
+ let contexts;
+ if (rootId !== null) {
+ // With a root id specified return the context info for itself
+ // and the full tree.
+ lazy.assert.string(
+ rootId,
+ `Expected "root" to be a string, got ${rootId}`
+ );
+ contexts = [this.#getBrowsingContext(rootId)];
+ } else {
+ // Return all top-level browsing contexts.
+ contexts = lazy.TabManager.browsers.map(
+ browser => browser.browsingContext
+ );
+ }
+
+ const contextsInfo = contexts.map(context => {
+ return this.#getBrowsingContextInfo(context, { maxDepth });
+ });
+
+ return { contexts: contextsInfo };
+ }
+
+ /**
+ * Closes an open prompt.
+ *
+ * @param {object=} options
+ * @param {string} options.context
+ * Id of the browsing context.
+ * @param {boolean=} options.accept
+ * Whether user prompt should be accepted or dismissed.
+ * Defaults to true.
+ * @param {string=} options.userText
+ * Input to the user prompt's value field.
+ * Defaults to an empty string.
+ *
+ * @throws {InvalidArgumentError}
+ * Raised if an argument is of an invalid type or value.
+ * @throws {NoSuchAlertError}
+ * If there is no current user prompt.
+ * @throws {NoSuchFrameError}
+ * If the browsing context cannot be found.
+ * @throws {UnsupportedOperationError}
+ * Raised when the command is called for "beforeunload" prompt.
+ */
+ async handleUserPrompt(options = {}) {
+ const { accept = true, context: contextId, userText = "" } = options;
+
+ lazy.assert.string(
+ contextId,
+ `Expected "context" to be a string, got ${contextId}`
+ );
+
+ const context = this.#getBrowsingContext(contextId);
+
+ lazy.assert.boolean(
+ accept,
+ `Expected "accept" to be a boolean, got ${accept}`
+ );
+
+ lazy.assert.string(
+ userText,
+ `Expected "userText" to be a string, got ${userText}`
+ );
+
+ const tab = lazy.TabManager.getTabForBrowsingContext(context);
+ const browser = lazy.TabManager.getBrowserForTab(tab);
+ const window = lazy.TabManager.getWindowForTab(tab);
+ const dialog = lazy.modal.findPrompt({
+ window,
+ contentBrowser: browser,
+ });
+
+ const closePrompt = async callback => {
+ const dialogClosed = new lazy.EventPromise(
+ window,
+ "DOMModalDialogClosed"
+ );
+ callback();
+ await dialogClosed;
+ };
+
+ if (dialog && dialog.isOpen) {
+ switch (dialog.promptType) {
+ case UserPromptType.alert: {
+ await closePrompt(() => dialog.accept());
+ return;
+ }
+ case UserPromptType.confirm: {
+ await closePrompt(() => {
+ if (accept) {
+ dialog.accept();
+ } else {
+ dialog.dismiss();
+ }
+ });
+
+ return;
+ }
+ case UserPromptType.prompt: {
+ await closePrompt(() => {
+ if (accept) {
+ dialog.text = userText;
+ dialog.accept();
+ } else {
+ dialog.dismiss();
+ }
+ });
+
+ return;
+ }
+ case UserPromptType.beforeunload: {
+ // TODO: Bug 1824220. Implement support for "beforeunload" prompts.
+ throw new lazy.error.UnsupportedOperationError(
+ '"beforeunload" prompts are not supported yet.'
+ );
+ }
+ }
+ }
+
+ throw new lazy.error.NoSuchAlertError();
+ }
+
+ /**
+ * Used as an argument for browsingContext.locateNodes command, as one of the available variants
+ * {CssLocator}, {InnerTextLocator} or {XPathLocator}, to represent a way of how lookup of nodes
+ * is going to be performed.
+ *
+ * @typedef Locator
+ */
+
+ /**
+ * Used as an argument for browsingContext.locateNodes command
+ * to represent a lookup by css selector.
+ *
+ * @typedef CssLocator
+ *
+ * @property {LocatorType} [type=LocatorType.css]
+ * @property {string} value
+ */
+
+ /**
+ * Used as an argument for browsingContext.locateNodes command
+ * to represent a lookup by inner text.
+ *
+ * @typedef InnerTextLocator
+ *
+ * @property {LocatorType} [type=LocatorType.innerText]
+ * @property {string} value
+ * @property {boolean=} ignoreCase
+ * @property {("full"|"partial")=} matchType
+ * @property {number=} maxDepth
+ */
+
+ /**
+ * Used as an argument for browsingContext.locateNodes command
+ * to represent a lookup by xpath.
+ *
+ * @typedef XPathLocator
+ *
+ * @property {LocatorType} [type=LocatorType.xpath]
+ * @property {string} value
+ */
+
+ /**
+ * Returns a list of all nodes matching
+ * the specified locator.
+ *
+ * @param {object} options
+ * @param {string} options.context
+ * Id of the browsing context.
+ * @param {Locator} options.locator
+ * The type of lookup which is going to be used.
+ * @param {number=} options.maxNodeCount
+ * The maximum amount of nodes which is going to be returned.
+ * Defaults to return all the found nodes.
+ * @param {OwnershipModel=} options.ownership
+ * The ownership model to use for the serialization
+ * of the DOM nodes. Defaults to `OwnershipModel.None`.
+ * @property {string=} sandbox
+ * The name of the sandbox. If the value is null or empty
+ * string, the default realm will be used.
+ * @property {SerializationOptions=} serializationOptions
+ * An object which holds the information of how the DOM nodes
+ * should be serialized.
+ * @property {Array<SharedReference>=} startNodes
+ * A list of references to nodes, which are used as
+ * starting points for lookup.
+ *
+ * @throws {InvalidArgumentError}
+ * Raised if an argument is of an invalid type or value.
+ * @throws {InvalidSelectorError}
+ * Raised if a locator value is invalid.
+ * @throws {NoSuchFrameError}
+ * If the browsing context cannot be found.
+ * @throws {UnsupportedOperationError}
+ * Raised when unsupported lookup types are used.
+ */
+ async locateNodes(options = {}) {
+ const {
+ context: contextId,
+ locator,
+ maxNodeCount = null,
+ ownership = lazy.OwnershipModel.None,
+ sandbox = null,
+ serializationOptions,
+ startNodes = null,
+ } = options;
+
+ lazy.assert.string(
+ contextId,
+ `Expected "context" to be a string, got ${contextId}`
+ );
+
+ const context = this.#getBrowsingContext(contextId);
+
+ lazy.assert.object(
+ locator,
+ `Expected "locator" to be an object, got ${locator}`
+ );
+
+ const locatorTypes = Object.values(LocatorType);
+
+ lazy.assert.that(
+ locatorType => locatorTypes.includes(locatorType),
+ `Expected "locator.type" to be one of ${locatorTypes}, got ${locator.type}`
+ )(locator.type);
+
+ if (![LocatorType.css, LocatorType.xpath].includes(locator.type)) {
+ throw new lazy.error.UnsupportedOperationError(
+ `"locator.type" argument with value: ${locator.type} is not supported yet.`
+ );
+ }
+
+ if (maxNodeCount != null) {
+ const maxNodeCountErrorMsg = `Expected "maxNodeCount" to be an integer and greater than 0, got ${maxNodeCount}`;
+ lazy.assert.that(maxNodeCount => {
+ lazy.assert.integer(maxNodeCount, maxNodeCountErrorMsg);
+ return maxNodeCount > 0;
+ }, maxNodeCountErrorMsg)(maxNodeCount);
+ }
+
+ const ownershipTypes = Object.values(lazy.OwnershipModel);
+ lazy.assert.that(
+ ownership => ownershipTypes.includes(ownership),
+ `Expected "ownership" to be one of ${ownershipTypes}, got ${ownership}`
+ )(ownership);
+
+ if (sandbox != null) {
+ lazy.assert.string(
+ sandbox,
+ `Expected "sandbox" to be a string, got ${sandbox}`
+ );
+ }
+
+ const serializationOptionsWithDefaults =
+ lazy.setDefaultAndAssertSerializationOptions(serializationOptions);
+
+ if (startNodes != null) {
+ lazy.assert.that(startNodes => {
+ lazy.assert.array(
+ startNodes,
+ `Expected "startNodes" to be an array, got ${startNodes}`
+ );
+ return !!startNodes.length;
+ }, `Expected "startNodes" to have at least one element, got ${startNodes}`)(
+ startNodes
+ );
+ }
+
+ const result = await this.messageHandler.forwardCommand({
+ moduleName: "browsingContext",
+ commandName: "_locateNodes",
+ destination: {
+ type: lazy.WindowGlobalMessageHandler.type,
+ id: context.id,
+ },
+ params: {
+ locator,
+ maxNodeCount,
+ resultOwnership: ownership,
+ sandbox,
+ serializationOptions: serializationOptionsWithDefaults,
+ startNodes,
+ },
+ });
+
+ return {
+ nodes: result.serializedNodes,
+ };
+ }
+
+ /**
+ * An object that holds the WebDriver Bidi navigation information.
+ *
+ * @typedef BrowsingContextNavigateResult
+ *
+ * @property {string} navigation
+ * Unique id for this navigation.
+ * @property {string} url
+ * The requested or reached URL.
+ */
+
+ /**
+ * Navigate the given context to the provided url, with the provided wait condition.
+ *
+ * @param {object=} options
+ * @param {string} options.context
+ * Id of the browsing context to navigate.
+ * @param {string} options.url
+ * Url for the navigation.
+ * @param {WaitCondition=} options.wait
+ * Wait condition for the navigation, one of "none", "interactive", "complete".
+ *
+ * @returns {BrowsingContextNavigateResult}
+ * Navigation result.
+ *
+ * @throws {InvalidArgumentError}
+ * Raised if an argument is of an invalid type or value.
+ * @throws {NoSuchFrameError}
+ * If the browsing context for context cannot be found.
+ */
+ async navigate(options = {}) {
+ const { context: contextId, url, wait = WaitCondition.None } = options;
+
+ lazy.assert.string(
+ contextId,
+ `Expected "context" to be a string, got ${contextId}`
+ );
+
+ lazy.assert.string(url, `Expected "url" to be string, got ${url}`);
+
+ const waitConditions = Object.values(WaitCondition);
+ if (!waitConditions.includes(wait)) {
+ throw new lazy.error.InvalidArgumentError(
+ `Expected "wait" to be one of ${waitConditions}, got ${wait}`
+ );
+ }
+
+ const context = this.#getBrowsingContext(contextId);
+
+ // webProgress will be stable even if the context navigates, retrieve it
+ // immediately before doing any asynchronous call.
+ const webProgress = context.webProgress;
+
+ const base = await this.messageHandler.handleCommand({
+ moduleName: "browsingContext",
+ commandName: "_getBaseURL",
+ destination: {
+ type: lazy.WindowGlobalMessageHandler.type,
+ id: context.id,
+ },
+ retryOnAbort: true,
+ });
+
+ let targetURI;
+ try {
+ const baseURI = Services.io.newURI(base);
+ targetURI = Services.io.newURI(url, null, baseURI);
+ } catch (e) {
+ throw new lazy.error.InvalidArgumentError(
+ `Expected "url" to be a valid URL (${e.message})`
+ );
+ }
+
+ return this.#awaitNavigation(
+ webProgress,
+ () => {
+ context.loadURI(targetURI, {
+ loadFlags: Ci.nsIWebNavigation.LOAD_FLAGS_IS_LINK,
+ triggeringPrincipal:
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ hasValidUserGestureActivation: true,
+ });
+ },
+ {
+ wait,
+ }
+ );
+ }
+
+ /**
+ * An object that holds the information about margins
+ * for Webdriver BiDi browsingContext.print command.
+ *
+ * @typedef BrowsingContextPrintMarginParameters
+ *
+ * @property {number=} bottom
+ * Bottom margin in cm. Defaults to 1cm (~0.4 inches).
+ * @property {number=} left
+ * Left margin in cm. Defaults to 1cm (~0.4 inches).
+ * @property {number=} right
+ * Right margin in cm. Defaults to 1cm (~0.4 inches).
+ * @property {number=} top
+ * Top margin in cm. Defaults to 1cm (~0.4 inches).
+ */
+
+ /**
+ * An object that holds the information about paper size
+ * for Webdriver BiDi browsingContext.print command.
+ *
+ * @typedef BrowsingContextPrintPageParameters
+ *
+ * @property {number=} height
+ * Paper height in cm. Defaults to US letter height (27.94cm / 11 inches).
+ * @property {number=} width
+ * Paper width in cm. Defaults to US letter width (21.59cm / 8.5 inches).
+ */
+
+ /**
+ * Used as return value for Webdriver BiDi browsingContext.print command.
+ *
+ * @typedef BrowsingContextPrintResult
+ *
+ * @property {string} data
+ * Base64 encoded PDF representing printed document.
+ */
+
+ /**
+ * Creates a paginated PDF representation of a document
+ * of the provided browsing context, and returns it
+ * as a Base64-encoded string.
+ *
+ * @param {object=} options
+ * @param {string} options.context
+ * Id of the browsing context.
+ * @param {boolean=} options.background
+ * Whether or not to print background colors and images.
+ * Defaults to false, which prints without background graphics.
+ * @param {BrowsingContextPrintMarginParameters=} options.margin
+ * Paper margins.
+ * @param {('landscape'|'portrait')=} options.orientation
+ * Paper orientation. Defaults to 'portrait'.
+ * @param {BrowsingContextPrintPageParameters=} options.page
+ * Paper size.
+ * @param {Array<number|string>=} options.pageRanges
+ * Paper ranges to print, e.g., ['1-5', 8, '11-13'].
+ * Defaults to the empty array, which means print all pages.
+ * @param {number=} options.scale
+ * Scale of the webpage rendering. Defaults to 1.0.
+ * @param {boolean=} options.shrinkToFit
+ * Whether or not to override page size as defined by CSS.
+ * Defaults to true, in which case the content will be scaled
+ * to fit the paper size.
+ *
+ * @returns {BrowsingContextPrintResult}
+ *
+ * @throws {InvalidArgumentError}
+ * Raised if an argument is of an invalid type or value.
+ * @throws {NoSuchFrameError}
+ * If the browsing context cannot be found.
+ */
+ async print(options = {}) {
+ const {
+ context: contextId,
+ background,
+ margin,
+ orientation,
+ page,
+ pageRanges,
+ scale,
+ shrinkToFit,
+ } = options;
+
+ lazy.assert.string(
+ contextId,
+ `Expected "context" to be a string, got ${contextId}`
+ );
+ const context = this.#getBrowsingContext(contextId);
+
+ const settings = lazy.print.addDefaultSettings({
+ background,
+ margin,
+ orientation,
+ page,
+ pageRanges,
+ scale,
+ shrinkToFit,
+ });
+
+ for (const prop of ["top", "bottom", "left", "right"]) {
+ lazy.assert.positiveNumber(
+ settings.margin[prop],
+ lazy.pprint`margin.${prop} is not a positive number`
+ );
+ }
+ for (const prop of ["width", "height"]) {
+ lazy.assert.positiveNumber(
+ settings.page[prop],
+ lazy.pprint`page.${prop} is not a positive number`
+ );
+ }
+ lazy.assert.positiveNumber(
+ settings.scale,
+ `scale ${settings.scale} is not a positive number`
+ );
+ lazy.assert.that(
+ scale =>
+ scale >= lazy.print.minScaleValue && scale <= lazy.print.maxScaleValue,
+ `scale ${settings.scale} is outside the range ${lazy.print.minScaleValue}-${lazy.print.maxScaleValue}`
+ )(settings.scale);
+ lazy.assert.boolean(settings.shrinkToFit);
+ lazy.assert.that(
+ orientation => lazy.print.defaults.orientationValue.includes(orientation),
+ `orientation ${
+ settings.orientation
+ } doesn't match allowed values "${lazy.print.defaults.orientationValue.join(
+ "/"
+ )}"`
+ )(settings.orientation);
+ lazy.assert.boolean(
+ settings.background,
+ `background ${settings.background} is not boolean`
+ );
+ lazy.assert.array(settings.pageRanges);
+
+ const printSettings = await lazy.print.getPrintSettings(settings);
+ const binaryString = await lazy.print.printToBinaryString(
+ context,
+ printSettings
+ );
+
+ return {
+ data: btoa(binaryString),
+ };
+ }
+
+ /**
+ * Reload the given context's document, with the provided wait condition.
+ *
+ * @param {object=} options
+ * @param {string} options.context
+ * Id of the browsing context to navigate.
+ * @param {bool=} options.ignoreCache
+ * If true ignore the browser cache. [Not yet supported]
+ * @param {WaitCondition=} options.wait
+ * Wait condition for the navigation, one of "none", "interactive", "complete".
+ *
+ * @returns {BrowsingContextNavigateResult}
+ * Navigation result.
+ *
+ * @throws {InvalidArgumentError}
+ * Raised if an argument is of an invalid type or value.
+ * @throws {NoSuchFrameError}
+ * If the browsing context for context cannot be found.
+ */
+ async reload(options = {}) {
+ const {
+ context: contextId,
+ ignoreCache,
+ wait = WaitCondition.None,
+ } = options;
+
+ lazy.assert.string(
+ contextId,
+ `Expected "context" to be a string, got ${contextId}`
+ );
+
+ if (typeof ignoreCache != "undefined") {
+ throw new lazy.error.UnsupportedOperationError(
+ `Argument "ignoreCache" is not supported yet.`
+ );
+ }
+
+ const waitConditions = Object.values(WaitCondition);
+ if (!waitConditions.includes(wait)) {
+ throw new lazy.error.InvalidArgumentError(
+ `Expected "wait" to be one of ${waitConditions}, got ${wait}`
+ );
+ }
+
+ const context = this.#getBrowsingContext(contextId);
+
+ // webProgress will be stable even if the context navigates, retrieve it
+ // immediately before doing any asynchronous call.
+ const webProgress = context.webProgress;
+
+ return this.#awaitNavigation(
+ webProgress,
+ () => {
+ context.reload(Ci.nsIWebNavigation.LOAD_FLAGS_NONE);
+ },
+ { wait }
+ );
+ }
+
+ /**
+ * Set the top-level browsing context's viewport to a given dimension.
+ *
+ * @param {object=} options
+ * @param {string} options.context
+ * Id of the browsing context.
+ * @param {Viewport|null} options.viewport
+ * Dimensions to set the viewport to, or `null` to reset it
+ * to the original dimensions.
+ *
+ * @throws {InvalidArgumentError}
+ * Raised if an argument is of an invalid type or value.
+ * @throws {UnsupportedOperationError}
+ * Raised when the command is called on Android.
+ */
+ async setViewport(options = {}) {
+ const { context: contextId, viewport } = options;
+
+ if (lazy.AppInfo.isAndroid) {
+ // Bug 1840084: Add Android support for modifying the viewport.
+ throw new lazy.error.UnsupportedOperationError(
+ `Command not yet supported for ${lazy.AppInfo.name}`
+ );
+ }
+
+ lazy.assert.string(
+ contextId,
+ `Expected "context" to be a string, got ${contextId}`
+ );
+
+ const context = this.#getBrowsingContext(contextId);
+ if (context.parent) {
+ throw new lazy.error.InvalidArgumentError(
+ `Browsing Context with id ${contextId} is not top-level`
+ );
+ }
+
+ const browser = context.embedderElement;
+ const currentHeight = browser.clientHeight;
+ const currentWidth = browser.clientWidth;
+
+ let targetHeight, targetWidth;
+ if (viewport === undefined) {
+ // Don't modify the viewport's size.
+ targetHeight = currentHeight;
+ targetWidth = currentWidth;
+ } else if (viewport === null) {
+ // Reset viewport to the original dimensions.
+ targetHeight = browser.parentElement.clientHeight;
+ targetWidth = browser.parentElement.clientWidth;
+
+ browser.style.removeProperty("height");
+ browser.style.removeProperty("width");
+ } else {
+ lazy.assert.object(
+ viewport,
+ `Expected "viewport" to be an object, got ${viewport}`
+ );
+
+ const { height, width } = viewport;
+ targetHeight = lazy.assert.positiveInteger(
+ height,
+ `Expected viewport's "height" to be a positive integer, got ${height}`
+ );
+ targetWidth = lazy.assert.positiveInteger(
+ width,
+ `Expected viewport's "width" to be a positive integer, got ${width}`
+ );
+
+ if (targetHeight > MAX_WINDOW_SIZE || targetWidth > MAX_WINDOW_SIZE) {
+ throw new lazy.error.UnsupportedOperationError(
+ `"width" or "height" cannot be larger than ${MAX_WINDOW_SIZE} px`
+ );
+ }
+
+ browser.style.setProperty("height", targetHeight + "px");
+ browser.style.setProperty("width", targetWidth + "px");
+ }
+
+ if (targetHeight !== currentHeight || targetWidth !== currentWidth) {
+ // Wait until the viewport has been resized
+ await this.messageHandler.forwardCommand({
+ moduleName: "browsingContext",
+ commandName: "_awaitViewportDimensions",
+ destination: {
+ type: lazy.WindowGlobalMessageHandler.type,
+ id: context.id,
+ },
+ params: {
+ height: targetHeight,
+ width: targetWidth,
+ },
+ });
+ }
+ }
+
+ /**
+ * Traverses the history of a given context by a given delta.
+ *
+ * @param {object=} options
+ * @param {string} options.context
+ * Id of the browsing context.
+ * @param {number} options.delta
+ * The number of steps we have to traverse.
+ *
+ * @throws {InvalidArgumentError}
+ * Raised if an argument is of an invalid type or value.
+ * @throws {NoSuchFrameException}
+ * When a context is not available.
+ * @throws {NoSuchHistoryEntryError}
+ * When a requested history entry does not exist.
+ */
+ async traverseHistory(options = {}) {
+ const { context: contextId, delta } = options;
+
+ lazy.assert.string(
+ contextId,
+ `Expected "context" to be a string, got ${contextId}`
+ );
+
+ const context = this.#getBrowsingContext(contextId);
+
+ lazy.assert.integer(
+ delta,
+ `Expected "delta" to be an integer, got ${delta}`
+ );
+
+ const sessionHistory = context.sessionHistory;
+ const allSteps = sessionHistory.count;
+ const currentIndex = sessionHistory.index;
+ const targetIndex = currentIndex + delta;
+ const validEntry = targetIndex >= 0 && targetIndex < allSteps;
+
+ if (!validEntry) {
+ throw new lazy.error.NoSuchHistoryEntryError(
+ `History entry with delta ${delta} not found`
+ );
+ }
+
+ context.goToIndex(targetIndex);
+
+ // On some platforms the requested index isn't set immediately.
+ await lazy.PollPromise(
+ (resolve, reject) => {
+ if (sessionHistory.index == targetIndex) {
+ resolve();
+ } else {
+ reject();
+ }
+ },
+ {
+ errorMessage: `History was not updated for index "${targetIndex}"`,
+ timeout: TIMEOUT_SET_HISTORY_INDEX * lazy.getTimeoutMultiplier(),
+ }
+ );
+ }
+
+ /**
+ * Start and await a navigation on the provided BrowsingContext. Returns a
+ * promise which resolves when the navigation is done according to the provided
+ * navigation strategy.
+ *
+ * @param {WebProgress} webProgress
+ * The WebProgress instance to observe for this navigation.
+ * @param {Function} startNavigationFn
+ * A callback that starts a navigation.
+ * @param {object} options
+ * @param {WaitCondition} options.wait
+ * The WaitCondition to use to wait for the navigation.
+ *
+ * @returns {Promise<BrowsingContextNavigateResult>}
+ * A Promise that resolves to navigate results when the navigation is done.
+ */
+ async #awaitNavigation(webProgress, startNavigationFn, options) {
+ const { wait } = options;
+
+ const context = webProgress.browsingContext;
+ const browserId = context.browserId;
+
+ const resolveWhenStarted = wait === WaitCondition.None;
+ const listener = new lazy.ProgressListener(webProgress, {
+ expectNavigation: true,
+ resolveWhenStarted,
+ // In case the webprogress is already navigating, always wait for an
+ // explicit start flag.
+ waitForExplicitStart: true,
+ });
+
+ const onDocumentInteractive = (evtName, wrappedEvt) => {
+ if (webProgress.browsingContext.id !== wrappedEvt.contextId) {
+ // Ignore load events for unrelated browsing contexts.
+ return;
+ }
+
+ if (wrappedEvt.readyState === "interactive") {
+ listener.stopIfStarted();
+ }
+ };
+
+ const contextDescriptor = {
+ type: lazy.ContextDescriptorType.TopBrowsingContext,
+ id: browserId,
+ };
+
+ // For the Interactive wait condition, resolve as soon as
+ // the document becomes interactive.
+ if (wait === WaitCondition.Interactive) {
+ await this.messageHandler.eventsDispatcher.on(
+ "browsingContext._documentInteractive",
+ contextDescriptor,
+ onDocumentInteractive
+ );
+ }
+
+ // If WaitCondition is Complete, we should try to wait for the corresponding
+ // responseCompleted event to be received.
+ let onNavigationRequestCompleted;
+
+ // However, a navigation will not necessarily have network events.
+ // For instance: same document navigation, or when using file or data
+ // protocols (for which we don't have network events yet).
+ // Therefore we will not unconditionally wait for a navigation request and
+ // this flag should only be set when a responseCompleted event should be
+ // expected.
+ let shouldWaitForNavigationRequest = false;
+
+ // Cleaning up the listeners will be done at the end of this method.
+ let unsubscribeNavigationListeners;
+
+ if (wait === WaitCondition.Complete) {
+ let resolveOnNetworkEvent;
+ onNavigationRequestCompleted = new Promise(
+ r => (resolveOnNetworkEvent = r)
+ );
+ const onBeforeRequestSent = (name, data) => {
+ if (data.navigation) {
+ shouldWaitForNavigationRequest = true;
+ }
+ };
+ const onNetworkRequestCompleted = (name, data) => {
+ if (data.navigation) {
+ resolveOnNetworkEvent();
+ }
+ };
+
+ // The network request can either end with _responseCompleted or _fetchError
+ await this.messageHandler.eventsDispatcher.on(
+ "network._beforeRequestSent",
+ contextDescriptor,
+ onBeforeRequestSent
+ );
+ await this.messageHandler.eventsDispatcher.on(
+ "network._responseCompleted",
+ contextDescriptor,
+ onNetworkRequestCompleted
+ );
+ await this.messageHandler.eventsDispatcher.on(
+ "network._fetchError",
+ contextDescriptor,
+ onNetworkRequestCompleted
+ );
+
+ unsubscribeNavigationListeners = async () => {
+ await this.messageHandler.eventsDispatcher.off(
+ "network._beforeRequestSent",
+ contextDescriptor,
+ onBeforeRequestSent
+ );
+ await this.messageHandler.eventsDispatcher.off(
+ "network._responseCompleted",
+ contextDescriptor,
+ onNetworkRequestCompleted
+ );
+ await this.messageHandler.eventsDispatcher.off(
+ "network._fetchError",
+ contextDescriptor,
+ onNetworkRequestCompleted
+ );
+ };
+ }
+
+ const navigated = listener.start();
+
+ try {
+ const navigationId = lazy.registerNavigationId({
+ contextDetails: { context: webProgress.browsingContext },
+ });
+
+ await startNavigationFn();
+ await navigated;
+
+ if (shouldWaitForNavigationRequest) {
+ await onNavigationRequestCompleted;
+ }
+
+ let url;
+ if (wait === WaitCondition.None) {
+ // If wait condition is None, the navigation resolved before the current
+ // context has navigated.
+ url = listener.targetURI.spec;
+ } else {
+ url = listener.currentURI.spec;
+ }
+
+ return {
+ navigation: navigationId,
+ url,
+ };
+ } finally {
+ if (listener.isStarted) {
+ listener.stop();
+ }
+
+ if (wait === WaitCondition.Interactive) {
+ await this.messageHandler.eventsDispatcher.off(
+ "browsingContext._documentInteractive",
+ contextDescriptor,
+ onDocumentInteractive
+ );
+ } else if (
+ wait === WaitCondition.Complete &&
+ shouldWaitForNavigationRequest
+ ) {
+ await unsubscribeNavigationListeners();
+ }
+ }
+ }
+
+ /**
+ * Retrieves a browsing context based on its id.
+ *
+ * @param {number} contextId
+ * Id of the browsing context.
+ * @returns {BrowsingContext=}
+ * The browsing context or null if <var>contextId</var> is null.
+ * @throws {NoSuchFrameError}
+ * If the browsing context cannot be found.
+ */
+ #getBrowsingContext(contextId) {
+ // The WebDriver BiDi specification expects null to be
+ // returned if no browsing context id has been specified.
+ if (contextId === null) {
+ return null;
+ }
+
+ const context = lazy.TabManager.getBrowsingContextById(contextId);
+ if (context === null) {
+ throw new lazy.error.NoSuchFrameError(
+ `Browsing Context with id ${contextId} not found`
+ );
+ }
+
+ return context;
+ }
+
+ /**
+ * Get the WebDriver BiDi browsing context information.
+ *
+ * @param {BrowsingContext} context
+ * The browsing context to get the information from.
+ * @param {object=} options
+ * @param {boolean=} options.isRoot
+ * Flag that indicates if this browsing context is the root of all the
+ * browsing contexts to be returned. Defaults to true.
+ * @param {number=} options.maxDepth
+ * Depth of the browsing context tree to traverse. If not specified
+ * the whole tree is returned.
+ * @returns {BrowsingContextInfo}
+ * The information about the browsing context.
+ */
+ #getBrowsingContextInfo(context, options = {}) {
+ const { isRoot = true, maxDepth = null } = options;
+
+ let children = null;
+ if (maxDepth === null || maxDepth > 0) {
+ children = context.children.map(context =>
+ this.#getBrowsingContextInfo(context, {
+ maxDepth: maxDepth === null ? maxDepth : maxDepth - 1,
+ isRoot: false,
+ })
+ );
+ }
+
+ const userContext = lazy.UserContextManager.getIdByBrowsingContext(context);
+ const contextInfo = {
+ children,
+ context: lazy.TabManager.getIdForBrowsingContext(context),
+ url: context.currentURI.spec,
+ userContext,
+ };
+
+ if (isRoot) {
+ // Only emit the parent id for the top-most browsing context.
+ const parentId = lazy.TabManager.getIdForBrowsingContext(context.parent);
+ contextInfo.parent = parentId;
+ }
+
+ return contextInfo;
+ }
+
+ #onContextAttached = async (eventName, data = {}) => {
+ if (this.#subscribedEvents.has("browsingContext.contextCreated")) {
+ const { browsingContext, why } = data;
+
+ // Filter out top-level browsing contexts that are created because of a
+ // cross-group navigation.
+ if (why === "replace") {
+ return;
+ }
+
+ // TODO: Bug 1852941. We should also filter out events which are emitted
+ // for DevTools frames.
+
+ // Filter out notifications for chrome context until support gets
+ // added (bug 1722679).
+ if (!browsingContext.webProgress) {
+ return;
+ }
+
+ const browsingContextInfo = this.#getBrowsingContextInfo(
+ browsingContext,
+ {
+ maxDepth: 0,
+ }
+ );
+
+ // This event is emitted from the parent process but for a given browsing
+ // context. Set the event's contextInfo to the message handler corresponding
+ // to this browsing context.
+ const contextInfo = {
+ contextId: browsingContext.id,
+ type: lazy.WindowGlobalMessageHandler.type,
+ };
+ this.emitEvent(
+ "browsingContext.contextCreated",
+ browsingContextInfo,
+ contextInfo
+ );
+ }
+ };
+
+ #onContextDiscarded = async (eventName, data = {}) => {
+ if (this.#subscribedEvents.has("browsingContext.contextDestroyed")) {
+ const { browsingContext, why } = data;
+
+ // Filter out top-level browsing contexts that are destroyed because of a
+ // cross-group navigation.
+ if (why === "replace") {
+ return;
+ }
+
+ // TODO: Bug 1852941. We should also filter out events which are emitted
+ // for DevTools frames.
+
+ // Filter out notifications for chrome context until support gets
+ // added (bug 1722679).
+ if (!browsingContext.webProgress) {
+ return;
+ }
+
+ // If this event is for a child context whose top or parent context is also destroyed,
+ // we don't need to send it, in this case the event for the top/parent context is enough.
+ if (
+ browsingContext.parent &&
+ (browsingContext.top.isDiscarded || browsingContext.parent.isDiscarded)
+ ) {
+ return;
+ }
+
+ const browsingContextInfo = this.#getBrowsingContextInfo(
+ browsingContext,
+ {
+ maxDepth: 0,
+ }
+ );
+
+ // This event is emitted from the parent process but for a given browsing
+ // context. Set the event's contextInfo to the message handler corresponding
+ // to this browsing context.
+ const contextInfo = {
+ contextId: browsingContext.id,
+ type: lazy.WindowGlobalMessageHandler.type,
+ };
+ this.emitEvent(
+ "browsingContext.contextDestroyed",
+ browsingContextInfo,
+ contextInfo
+ );
+ }
+ };
+
+ #onLocationChanged = async (eventName, data) => {
+ const { navigationId, navigableId, url } = data;
+ const context = this.#getBrowsingContext(navigableId);
+
+ if (this.#subscribedEvents.has("browsingContext.fragmentNavigated")) {
+ const contextInfo = {
+ contextId: context.id,
+ type: lazy.WindowGlobalMessageHandler.type,
+ };
+ this.emitEvent(
+ "browsingContext.fragmentNavigated",
+ {
+ context: navigableId,
+ navigation: navigationId,
+ timestamp: Date.now(),
+ url,
+ },
+ contextInfo
+ );
+ }
+ };
+
+ #onPromptClosed = async (eventName, data) => {
+ if (this.#subscribedEvents.has("browsingContext.userPromptClosed")) {
+ const { contentBrowser, detail } = data;
+ const contextId = lazy.TabManager.getIdForBrowser(contentBrowser);
+
+ if (contextId === null) {
+ return;
+ }
+
+ // This event is emitted from the parent process but for a given browsing
+ // context. Set the event's contextInfo to the message handler corresponding
+ // to this browsing context.
+ const contextInfo = {
+ contextId,
+ type: lazy.WindowGlobalMessageHandler.type,
+ };
+
+ const params = {
+ context: contextId,
+ ...detail,
+ };
+
+ this.emitEvent("browsingContext.userPromptClosed", params, contextInfo);
+ }
+ };
+
+ #onPromptOpened = async (eventName, data) => {
+ if (this.#subscribedEvents.has("browsingContext.userPromptOpened")) {
+ const { contentBrowser, prompt } = data;
+
+ // Do not send opened event for unsupported prompt types.
+ if (!(prompt.promptType in UserPromptType)) {
+ return;
+ }
+
+ const contextId = lazy.TabManager.getIdForBrowser(contentBrowser);
+ // This event is emitted from the parent process but for a given browsing
+ // context. Set the event's contextInfo to the message handler corresponding
+ // to this browsing context.
+ const contextInfo = {
+ contextId,
+ type: lazy.WindowGlobalMessageHandler.type,
+ };
+
+ const eventPayload = {
+ context: contextId,
+ type: prompt.promptType,
+ message: await prompt.getText(),
+ };
+
+ // Bug 1859814: Since the platform doesn't provide the access to the `defaultValue` of the prompt,
+ // we use prompt the `value` instead. The `value` is set to `defaultValue` when `defaultValue` is provided.
+ // This approach doesn't allow us to distinguish between the `defaultValue` being set to an empty string and
+ // `defaultValue` not set, because `value` is always defaulted to an empty string.
+ // We should switch to using the actual `defaultValue` when it's available and check for the `null` here.
+ const defaultValue = await prompt.getInputText();
+ if (defaultValue) {
+ eventPayload.defaultValue = defaultValue;
+ }
+
+ this.emitEvent(
+ "browsingContext.userPromptOpened",
+ eventPayload,
+ contextInfo
+ );
+ }
+ };
+
+ #onNavigationStarted = async (eventName, data) => {
+ const { navigableId, navigationId, url } = data;
+ const context = this.#getBrowsingContext(navigableId);
+
+ if (this.#subscribedEvents.has("browsingContext.navigationStarted")) {
+ const contextInfo = {
+ contextId: context.id,
+ type: lazy.WindowGlobalMessageHandler.type,
+ };
+
+ this.emitEvent(
+ "browsingContext.navigationStarted",
+ {
+ context: navigableId,
+ navigation: navigationId,
+ timestamp: Date.now(),
+ url,
+ },
+ contextInfo
+ );
+ }
+ };
+
+ #onPageHideEvent = (name, eventPayload) => {
+ const { context } = eventPayload;
+ if (context.parent) {
+ this.#onContextDiscarded("windowglobal-pagehide", {
+ browsingContext: context,
+ });
+ }
+ };
+
+ #stopListeningToContextEvent(event) {
+ this.#subscribedEvents.delete(event);
+
+ const hasContextEvent =
+ this.#subscribedEvents.has("browsingContext.contextCreated") ||
+ this.#subscribedEvents.has("browsingContext.contextDestroyed");
+
+ if (!hasContextEvent) {
+ this.#contextListener.stopListening();
+ }
+ }
+
+ #stopListeningToNavigationEvent(event) {
+ this.#subscribedEvents.delete(event);
+
+ const hasNavigationEvent =
+ this.#subscribedEvents.has("browsingContext.fragmentNavigated") ||
+ this.#subscribedEvents.has("browsingContext.navigationStarted");
+
+ if (!hasNavigationEvent) {
+ this.#navigationListener.stopListening();
+ }
+ }
+
+ #stopListeningToPromptEvent(event) {
+ this.#subscribedEvents.delete(event);
+
+ const hasPromptEvent =
+ this.#subscribedEvents.has("browsingContext.userPromptClosed") ||
+ this.#subscribedEvents.has("browsingContext.userPromptOpened");
+
+ if (!hasPromptEvent) {
+ this.#promptListener.stopListening();
+ }
+ }
+
+ #subscribeEvent(event) {
+ switch (event) {
+ case "browsingContext.contextCreated":
+ case "browsingContext.contextDestroyed": {
+ this.#contextListener.startListening();
+ this.#subscribedEvents.add(event);
+ break;
+ }
+ case "browsingContext.fragmentNavigated":
+ case "browsingContext.navigationStarted": {
+ this.#navigationListener.startListening();
+ this.#subscribedEvents.add(event);
+ break;
+ }
+ case "browsingContext.userPromptClosed":
+ case "browsingContext.userPromptOpened": {
+ this.#promptListener.startListening();
+ this.#subscribedEvents.add(event);
+ break;
+ }
+ }
+ }
+
+ #unsubscribeEvent(event) {
+ switch (event) {
+ case "browsingContext.contextCreated":
+ case "browsingContext.contextDestroyed": {
+ this.#stopListeningToContextEvent(event);
+ break;
+ }
+ case "browsingContext.fragmentNavigated":
+ case "browsingContext.navigationStarted": {
+ this.#stopListeningToNavigationEvent(event);
+ break;
+ }
+ case "browsingContext.userPromptClosed":
+ case "browsingContext.userPromptOpened": {
+ this.#stopListeningToPromptEvent(event);
+ break;
+ }
+ }
+ }
+
+ /**
+ * Internal commands
+ */
+
+ _applySessionData(params) {
+ // TODO: Bug 1775231. Move this logic to a shared module or an abstract
+ // class.
+ const { category } = params;
+ if (category === "event") {
+ const filteredSessionData = params.sessionData.filter(item =>
+ this.messageHandler.matchesContext(item.contextDescriptor)
+ );
+ for (const event of this.#subscribedEvents.values()) {
+ const hasSessionItem = filteredSessionData.some(
+ item => item.value === event
+ );
+ // If there are no session items for this context, we should unsubscribe from the event.
+ if (!hasSessionItem) {
+ this.#unsubscribeEvent(event);
+ }
+ }
+
+ // Subscribe to all events, which have an item in SessionData.
+ for (const { value } of filteredSessionData) {
+ this.#subscribeEvent(value);
+ }
+ }
+ }
+
+ static get supportedEvents() {
+ return [
+ "browsingContext.contextCreated",
+ "browsingContext.contextDestroyed",
+ "browsingContext.domContentLoaded",
+ "browsingContext.fragmentNavigated",
+ "browsingContext.load",
+ "browsingContext.navigationStarted",
+ "browsingContext.userPromptClosed",
+ "browsingContext.userPromptOpened",
+ ];
+ }
+}
+
+export const browsingContext = BrowsingContextModule;
diff --git a/remote/webdriver-bidi/modules/root/input.sys.mjs b/remote/webdriver-bidi/modules/root/input.sys.mjs
new file mode 100644
index 0000000000..8edd8299b7
--- /dev/null
+++ b/remote/webdriver-bidi/modules/root/input.sys.mjs
@@ -0,0 +1,99 @@
+/* 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 { Module } from "chrome://remote/content/shared/messagehandler/Module.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",
+ TabManager: "chrome://remote/content/shared/TabManager.sys.mjs",
+ WindowGlobalMessageHandler:
+ "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs",
+});
+
+class InputModule extends Module {
+ destroy() {}
+
+ async performActions(options = {}) {
+ const { actions, context: contextId } = options;
+
+ lazy.assert.string(
+ contextId,
+ `Expected "context" to be a string, got ${contextId}`
+ );
+
+ const context = lazy.TabManager.getBrowsingContextById(contextId);
+ if (!context) {
+ throw new lazy.error.NoSuchFrameError(
+ `Browsing context with id ${contextId} not found`
+ );
+ }
+
+ // Bug 1821460: Fetch top-level browsing context.
+
+ await this.messageHandler.forwardCommand({
+ moduleName: "input",
+ commandName: "performActions",
+ destination: {
+ type: lazy.WindowGlobalMessageHandler.type,
+ id: context.id,
+ },
+ params: {
+ actions,
+ },
+ });
+
+ return {};
+ }
+
+ /**
+ * Reset the input state in the provided browsing context.
+ *
+ * @param {object=} options
+ * @param {string} options.context
+ * Id of the browsing context to reset the input state.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>context</var> is not valid type.
+ * @throws {NoSuchFrameError}
+ * If the browsing context cannot be found.
+ */
+ async releaseActions(options = {}) {
+ const { context: contextId } = options;
+
+ lazy.assert.string(
+ contextId,
+ `Expected "context" to be a string, got ${contextId}`
+ );
+
+ const context = lazy.TabManager.getBrowsingContextById(contextId);
+ if (!context) {
+ throw new lazy.error.NoSuchFrameError(
+ `Browsing context with id ${contextId} not found`
+ );
+ }
+
+ // Bug 1821460: Fetch top-level browsing context.
+
+ await this.messageHandler.forwardCommand({
+ moduleName: "input",
+ commandName: "releaseActions",
+ destination: {
+ type: lazy.WindowGlobalMessageHandler.type,
+ id: context.id,
+ },
+ params: {},
+ });
+
+ return {};
+ }
+
+ static get supportedEvents() {
+ return [];
+ }
+}
+
+export const input = InputModule;
diff --git a/remote/webdriver-bidi/modules/root/log.sys.mjs b/remote/webdriver-bidi/modules/root/log.sys.mjs
new file mode 100644
index 0000000000..db2390d3ba
--- /dev/null
+++ b/remote/webdriver-bidi/modules/root/log.sys.mjs
@@ -0,0 +1,15 @@
+/* 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 { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs";
+
+class LogModule extends Module {
+ destroy() {}
+
+ static get supportedEvents() {
+ return ["log.entryAdded"];
+ }
+}
+
+export const log = LogModule;
diff --git a/remote/webdriver-bidi/modules/root/network.sys.mjs b/remote/webdriver-bidi/modules/root/network.sys.mjs
new file mode 100644
index 0000000000..238b9f3640
--- /dev/null
+++ b/remote/webdriver-bidi/modules/root/network.sys.mjs
@@ -0,0 +1,1730 @@
+/* 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 { Module } from "chrome://remote/content/shared/messagehandler/Module.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",
+ generateUUID: "chrome://remote/content/shared/UUID.sys.mjs",
+ matchURLPattern:
+ "chrome://remote/content/shared/webdriver/URLPattern.sys.mjs",
+ notifyNavigationStarted:
+ "chrome://remote/content/shared/NavigationManager.sys.mjs",
+ NetworkListener:
+ "chrome://remote/content/shared/listeners/NetworkListener.sys.mjs",
+ parseChallengeHeader:
+ "chrome://remote/content/shared/ChallengeHeaderParser.sys.mjs",
+ parseURLPattern:
+ "chrome://remote/content/shared/webdriver/URLPattern.sys.mjs",
+ TabManager: "chrome://remote/content/shared/TabManager.sys.mjs",
+ WindowGlobalMessageHandler:
+ "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs",
+});
+
+/**
+ * @typedef {object} AuthChallenge
+ * @property {string} scheme
+ * @property {string} realm
+ */
+
+/**
+ * @typedef {object} AuthCredentials
+ * @property {'password'} type
+ * @property {string} username
+ * @property {string} password
+ */
+
+/**
+ * @typedef {object} BaseParameters
+ * @property {string=} context
+ * @property {Array<string>?} intercepts
+ * @property {boolean} isBlocked
+ * @property {Navigation=} navigation
+ * @property {number} redirectCount
+ * @property {RequestData} request
+ * @property {number} timestamp
+ */
+
+/**
+ * @typedef {object} BlockedRequest
+ * @property {NetworkEventRecord} networkEventRecord
+ * @property {InterceptPhase} phase
+ */
+
+/**
+ * Enum of possible BytesValue types.
+ *
+ * @readonly
+ * @enum {BytesValueType}
+ */
+export const BytesValueType = {
+ Base64: "base64",
+ String: "string",
+};
+
+/**
+ * @typedef {object} BytesValue
+ * @property {BytesValueType} type
+ * @property {string} value
+ */
+
+/**
+ * Enum of possible continueWithAuth actions.
+ *
+ * @readonly
+ * @enum {ContinueWithAuthAction}
+ */
+const ContinueWithAuthAction = {
+ Cancel: "cancel",
+ Default: "default",
+ ProvideCredentials: "provideCredentials",
+};
+
+/**
+ * @typedef {object} Cookie
+ * @property {string} domain
+ * @property {number=} expires
+ * @property {boolean} httpOnly
+ * @property {string} name
+ * @property {string} path
+ * @property {SameSite} sameSite
+ * @property {boolean} secure
+ * @property {number} size
+ * @property {BytesValue} value
+ */
+
+/**
+ * @typedef {object} CookieHeader
+ * @property {string} name
+ * @property {BytesValue} value
+ */
+
+/**
+ * @typedef {object} FetchTimingInfo
+ * @property {number} timeOrigin
+ * @property {number} requestTime
+ * @property {number} redirectStart
+ * @property {number} redirectEnd
+ * @property {number} fetchStart
+ * @property {number} dnsStart
+ * @property {number} dnsEnd
+ * @property {number} connectStart
+ * @property {number} connectEnd
+ * @property {number} tlsStart
+ * @property {number} requestStart
+ * @property {number} responseStart
+ * @property {number} responseEnd
+ */
+
+/**
+ * @typedef {object} Header
+ * @property {string} name
+ * @property {BytesValue} value
+ */
+
+/**
+ * @typedef {string} InitiatorType
+ */
+
+/**
+ * Enum of possible initiator types.
+ *
+ * @readonly
+ * @enum {InitiatorType}
+ */
+const InitiatorType = {
+ Other: "other",
+ Parser: "parser",
+ Preflight: "preflight",
+ Script: "script",
+};
+
+/**
+ * @typedef {object} Initiator
+ * @property {InitiatorType} type
+ * @property {number=} columnNumber
+ * @property {number=} lineNumber
+ * @property {string=} request
+ * @property {StackTrace=} stackTrace
+ */
+
+/**
+ * Enum of intercept phases.
+ *
+ * @readonly
+ * @enum {InterceptPhase}
+ */
+const InterceptPhase = {
+ AuthRequired: "authRequired",
+ BeforeRequestSent: "beforeRequestSent",
+ ResponseStarted: "responseStarted",
+};
+
+/**
+ * @typedef {object} InterceptProperties
+ * @property {Array<InterceptPhase>} phases
+ * @property {Array<URLPattern>} urlPatterns
+ */
+
+/**
+ * @typedef {object} RequestData
+ * @property {number|null} bodySize
+ * Defaults to null.
+ * @property {Array<Cookie>} cookies
+ * @property {Array<Header>} headers
+ * @property {number} headersSize
+ * @property {string} method
+ * @property {string} request
+ * @property {FetchTimingInfo} timings
+ * @property {string} url
+ */
+
+/**
+ * @typedef {object} BeforeRequestSentParametersProperties
+ * @property {Initiator} initiator
+ */
+
+/* eslint-disable jsdoc/valid-types */
+/**
+ * Parameters for the BeforeRequestSent event
+ *
+ * @typedef {BaseParameters & BeforeRequestSentParametersProperties} BeforeRequestSentParameters
+ */
+/* eslint-enable jsdoc/valid-types */
+
+/**
+ * @typedef {object} ResponseContent
+ * @property {number|null} size
+ * Defaults to null.
+ */
+
+/**
+ * @typedef {object} ResponseData
+ * @property {string} url
+ * @property {string} protocol
+ * @property {number} status
+ * @property {string} statusText
+ * @property {boolean} fromCache
+ * @property {Array<Header>} headers
+ * @property {string} mimeType
+ * @property {number} bytesReceived
+ * @property {number|null} headersSize
+ * Defaults to null.
+ * @property {number|null} bodySize
+ * Defaults to null.
+ * @property {ResponseContent} content
+ * @property {Array<AuthChallenge>=} authChallenges
+ */
+
+/**
+ * @typedef {object} ResponseStartedParametersProperties
+ * @property {ResponseData} response
+ */
+
+/* eslint-disable jsdoc/valid-types */
+/**
+ * Parameters for the ResponseStarted event
+ *
+ * @typedef {BaseParameters & ResponseStartedParametersProperties} ResponseStartedParameters
+ */
+/* eslint-enable jsdoc/valid-types */
+
+/**
+ * @typedef {object} ResponseCompletedParametersProperties
+ * @property {ResponseData} response
+ */
+
+/**
+ * Enum of possible sameSite values.
+ *
+ * @readonly
+ * @enum {SameSite}
+ */
+const SameSite = {
+ Lax: "lax",
+ None: "none",
+ Script: "script",
+};
+
+/**
+ * @typedef {object} SetCookieHeader
+ * @property {string} name
+ * @property {BytesValue} value
+ * @property {string=} domain
+ * @property {boolean=} httpOnly
+ * @property {string=} expiry
+ * @property {number=} maxAge
+ * @property {string=} path
+ * @property {SameSite=} sameSite
+ * @property {boolean=} secure
+ */
+
+/**
+ * @typedef {object} URLPatternPattern
+ * @property {'pattern'} type
+ * @property {string=} protocol
+ * @property {string=} hostname
+ * @property {string=} port
+ * @property {string=} pathname
+ * @property {string=} search
+ */
+
+/**
+ * @typedef {object} URLPatternString
+ * @property {'string'} type
+ * @property {string} pattern
+ */
+
+/**
+ * @typedef {(URLPatternPattern|URLPatternString)} URLPattern
+ */
+
+/* eslint-disable jsdoc/valid-types */
+/**
+ * Parameters for the ResponseCompleted event
+ *
+ * @typedef {BaseParameters & ResponseCompletedParametersProperties} ResponseCompletedParameters
+ */
+/* eslint-enable jsdoc/valid-types */
+
+class NetworkModule extends Module {
+ #blockedRequests;
+ #interceptMap;
+ #networkListener;
+ #subscribedEvents;
+
+ constructor(messageHandler) {
+ super(messageHandler);
+
+ // Map of request id to BlockedRequest
+ this.#blockedRequests = new Map();
+
+ // Map of intercept id to InterceptProperties
+ this.#interceptMap = new Map();
+
+ // Set of event names which have active subscriptions
+ this.#subscribedEvents = new Set();
+
+ this.#networkListener = new lazy.NetworkListener();
+ this.#networkListener.on("auth-required", this.#onAuthRequired);
+ this.#networkListener.on("before-request-sent", this.#onBeforeRequestSent);
+ this.#networkListener.on("fetch-error", this.#onFetchError);
+ this.#networkListener.on("response-completed", this.#onResponseEvent);
+ this.#networkListener.on("response-started", this.#onResponseEvent);
+ }
+
+ destroy() {
+ this.#networkListener.off("auth-required", this.#onAuthRequired);
+ this.#networkListener.off("before-request-sent", this.#onBeforeRequestSent);
+ this.#networkListener.off("fetch-error", this.#onFetchError);
+ this.#networkListener.off("response-completed", this.#onResponseEvent);
+ this.#networkListener.off("response-started", this.#onResponseEvent);
+ this.#networkListener.destroy();
+
+ this.#blockedRequests = null;
+ this.#interceptMap = null;
+ this.#subscribedEvents = null;
+ }
+
+ /**
+ * Adds a network intercept, which allows to intercept and modify network
+ * requests and responses.
+ *
+ * The network intercept will be created for the provided phases
+ * (InterceptPhase) and for specific url patterns. When a network event
+ * corresponding to an intercept phase has a URL which matches any url pattern
+ * of any intercept, the request will be suspended.
+ *
+ * @param {object=} options
+ * @param {Array<InterceptPhase>} options.phases
+ * The phases where this intercept should be checked.
+ * @param {Array<URLPattern>=} options.urlPatterns
+ * The URL patterns for this intercept. Optional, defaults to empty array.
+ *
+ * @returns {object}
+ * An object with the following property:
+ * - intercept {string} The unique id of the network intercept.
+ *
+ * @throws {InvalidArgumentError}
+ * Raised if an argument is of an invalid type or value.
+ */
+ addIntercept(options = {}) {
+ const { phases, urlPatterns = [] } = options;
+
+ lazy.assert.array(
+ phases,
+ `Expected "phases" to be an array, got ${phases}`
+ );
+
+ if (!options.phases.length) {
+ throw new lazy.error.InvalidArgumentError(
+ `Expected "phases" to contain at least one phase, got an empty array`
+ );
+ }
+
+ const supportedInterceptPhases = Object.values(InterceptPhase);
+ for (const phase of phases) {
+ if (!supportedInterceptPhases.includes(phase)) {
+ throw new lazy.error.InvalidArgumentError(
+ `Expected "phases" values to be one of ${supportedInterceptPhases}, got ${phase}`
+ );
+ }
+ }
+
+ lazy.assert.array(
+ urlPatterns,
+ `Expected "urlPatterns" to be an array, got ${urlPatterns}`
+ );
+
+ const parsedPatterns = urlPatterns.map(urlPattern =>
+ lazy.parseURLPattern(urlPattern)
+ );
+
+ const interceptId = lazy.generateUUID();
+ this.#interceptMap.set(interceptId, {
+ phases,
+ urlPatterns: parsedPatterns,
+ });
+
+ return {
+ intercept: interceptId,
+ };
+ }
+
+ /**
+ * Continues a request that is blocked by a network intercept at the
+ * beforeRequestSent phase.
+ *
+ * @param {object=} options
+ * @param {string} options.request
+ * The id of the blocked request that should be continued.
+ * @param {BytesValue=} options.body [unsupported]
+ * Optional BytesValue to replace the body of the request.
+ * @param {Array<CookieHeader>=} options.cookies [unsupported]
+ * Optional array of cookie header values to replace the cookie header of
+ * the request.
+ * @param {Array<Header>=} options.headers [unsupported]
+ * Optional array of headers to replace the headers of the request.
+ * request.
+ * @param {string=} options.method [unsupported]
+ * Optional string to replace the method of the request.
+ * @param {string=} options.url [unsupported]
+ * Optional string to replace the url of the request. If the provided url
+ * is not a valid URL, an InvalidArgumentError will be thrown.
+ *
+ * @throws {InvalidArgumentError}
+ * Raised if an argument is of an invalid type or value.
+ * @throws {NoSuchRequestError}
+ * Raised if the request id does not match any request in the blocked
+ * requests map.
+ */
+ async continueRequest(options = {}) {
+ const {
+ body = null,
+ cookies = null,
+ headers = null,
+ method = null,
+ url = null,
+ request: requestId,
+ } = options;
+
+ lazy.assert.string(
+ requestId,
+ `Expected "request" to be a string, got ${requestId}`
+ );
+
+ if (body !== null) {
+ this.#assertBytesValue(
+ body,
+ `Expected "body" to be a network.BytesValue, got ${body}`
+ );
+
+ throw new lazy.error.UnsupportedOperationError(
+ `"body" not supported yet in network.continueRequest`
+ );
+ }
+
+ if (cookies !== null) {
+ lazy.assert.array(
+ cookies,
+ `Expected "cookies" to be an array got ${cookies}`
+ );
+
+ for (const cookie of cookies) {
+ this.#assertHeader(
+ cookie,
+ `Expected values in "cookies" to be network.CookieHeader, got ${cookie}`
+ );
+ }
+
+ throw new lazy.error.UnsupportedOperationError(
+ `"cookies" not supported yet in network.continueRequest`
+ );
+ }
+
+ if (headers !== null) {
+ lazy.assert.array(
+ headers,
+ `Expected "headers" to be an array got ${headers}`
+ );
+
+ for (const header of headers) {
+ this.#assertHeader(
+ header,
+ `Expected values in "headers" to be network.Header, got ${header}`
+ );
+ }
+
+ throw new lazy.error.UnsupportedOperationError(
+ `"headers" not supported yet in network.continueRequest`
+ );
+ }
+
+ if (method !== null) {
+ lazy.assert.string(
+ method,
+ `Expected "method" to be a string, got ${method}`
+ );
+
+ throw new lazy.error.UnsupportedOperationError(
+ `"method" not supported yet in network.continueRequest`
+ );
+ }
+
+ if (url !== null) {
+ lazy.assert.string(url, `Expected "url" to be a string, got ${url}`);
+
+ throw new lazy.error.UnsupportedOperationError(
+ `"url" not supported yet in network.continueRequest`
+ );
+ }
+
+ if (!this.#blockedRequests.has(requestId)) {
+ throw new lazy.error.NoSuchRequestError(
+ `Blocked request with id ${requestId} not found`
+ );
+ }
+
+ const { phase, request, resolveBlockedEvent } =
+ this.#blockedRequests.get(requestId);
+
+ if (phase !== InterceptPhase.BeforeRequestSent) {
+ throw new lazy.error.InvalidArgumentError(
+ `Expected blocked request to be in "beforeRequestSent" phase, got ${phase}`
+ );
+ }
+
+ const wrapper = ChannelWrapper.get(request);
+ wrapper.resume();
+
+ resolveBlockedEvent();
+ }
+
+ /**
+ * Continues a response that is blocked by a network intercept at the
+ * responseStarted or authRequired phase.
+ *
+ * @param {object=} options
+ * @param {string} options.request
+ * The id of the blocked request that should be continued.
+ * @param {Array<SetCookieHeader>=} options.cookies [unsupported]
+ * Optional array of set-cookie header values to replace the set-cookie
+ * headers of the response.
+ * @param {AuthCredentials=} options.credentials
+ * Optional AuthCredentials to use.
+ * @param {Array<Header>=} options.headers [unsupported]
+ * Optional array of header values to replace the headers of the response.
+ * @param {string=} options.reasonPhrase [unsupported]
+ * Optional string to replace the status message of the response.
+ * @param {number=} options.statusCode [unsupported]
+ * Optional number to replace the status code of the response.
+ *
+ * @throws {InvalidArgumentError}
+ * Raised if an argument is of an invalid type or value.
+ * @throws {NoSuchRequestError}
+ * Raised if the request id does not match any request in the blocked
+ * requests map.
+ */
+ async continueResponse(options = {}) {
+ const {
+ cookies = null,
+ credentials = null,
+ headers = null,
+ reasonPhrase = null,
+ request: requestId,
+ statusCode = null,
+ } = options;
+
+ lazy.assert.string(
+ requestId,
+ `Expected "request" to be a string, got ${requestId}`
+ );
+
+ if (cookies !== null) {
+ lazy.assert.array(
+ cookies,
+ `Expected "cookies" to be an array got ${cookies}`
+ );
+
+ for (const cookie of cookies) {
+ this.#assertSetCookieHeader(cookie);
+ }
+
+ throw new lazy.error.UnsupportedOperationError(
+ `"cookies" not supported yet in network.continueResponse`
+ );
+ }
+
+ if (credentials !== null) {
+ this.#assertAuthCredentials(credentials);
+ }
+
+ if (headers !== null) {
+ lazy.assert.array(
+ headers,
+ `Expected "headers" to be an array got ${headers}`
+ );
+
+ for (const header of headers) {
+ this.#assertHeader(
+ header,
+ `Expected values in "headers" to be network.Header, got ${header}`
+ );
+ }
+
+ throw new lazy.error.UnsupportedOperationError(
+ `"headers" not supported yet in network.continueResponse`
+ );
+ }
+
+ if (reasonPhrase !== null) {
+ lazy.assert.string(
+ reasonPhrase,
+ `Expected "reasonPhrase" to be a string, got ${reasonPhrase}`
+ );
+
+ throw new lazy.error.UnsupportedOperationError(
+ `"reasonPhrase" not supported yet in network.continueResponse`
+ );
+ }
+
+ if (statusCode !== null) {
+ lazy.assert.positiveInteger(
+ statusCode,
+ `Expected "statusCode" to be a positive integer, got ${statusCode}`
+ );
+
+ throw new lazy.error.UnsupportedOperationError(
+ `"statusCode" not supported yet in network.continueResponse`
+ );
+ }
+
+ if (!this.#blockedRequests.has(requestId)) {
+ throw new lazy.error.NoSuchRequestError(
+ `Blocked request with id ${requestId} not found`
+ );
+ }
+
+ const { authCallbacks, phase, request, resolveBlockedEvent } =
+ this.#blockedRequests.get(requestId);
+
+ if (
+ phase !== InterceptPhase.ResponseStarted &&
+ phase !== InterceptPhase.AuthRequired
+ ) {
+ throw new lazy.error.InvalidArgumentError(
+ `Expected blocked request to be in "responseStarted" or "authRequired" phase, got ${phase}`
+ );
+ }
+
+ if (phase === InterceptPhase.AuthRequired) {
+ // Requests blocked in the AuthRequired phase should be resumed using
+ // authCallbacks.
+ if (credentials !== null) {
+ await authCallbacks.provideAuthCredentials(
+ credentials.username,
+ credentials.password
+ );
+ } else {
+ await authCallbacks.provideAuthCredentials();
+ }
+ } else {
+ const wrapper = ChannelWrapper.get(request);
+ wrapper.resume();
+ }
+
+ resolveBlockedEvent();
+ }
+
+ /**
+ * Continues a response that is blocked by a network intercept at the
+ * authRequired phase.
+ *
+ * @param {object=} options
+ * @param {string} options.request
+ * The id of the blocked request that should be continued.
+ * @param {string} options.action
+ * The continueWithAuth action, one of ContinueWithAuthAction.
+ * @param {AuthCredentials=} options.credentials
+ * The credentials to use for the ContinueWithAuthAction.ProvideCredentials
+ * action.
+ *
+ * @throws {InvalidArgumentError}
+ * Raised if an argument is of an invalid type or value.
+ * @throws {NoSuchRequestError}
+ * Raised if the request id does not match any request in the blocked
+ * requests map.
+ */
+ async continueWithAuth(options = {}) {
+ const { action, credentials, request: requestId } = options;
+
+ lazy.assert.string(
+ requestId,
+ `Expected "request" to be a string, got ${requestId}`
+ );
+
+ if (!Object.values(ContinueWithAuthAction).includes(action)) {
+ throw new lazy.error.InvalidArgumentError(
+ `Expected "action" to be one of ${Object.values(
+ ContinueWithAuthAction
+ )} got ${action}`
+ );
+ }
+
+ if (action == ContinueWithAuthAction.ProvideCredentials) {
+ this.#assertAuthCredentials(credentials);
+ }
+
+ if (!this.#blockedRequests.has(requestId)) {
+ throw new lazy.error.NoSuchRequestError(
+ `Blocked request with id ${requestId} not found`
+ );
+ }
+
+ const { authCallbacks, phase, resolveBlockedEvent } =
+ this.#blockedRequests.get(requestId);
+
+ if (phase !== InterceptPhase.AuthRequired) {
+ throw new lazy.error.InvalidArgumentError(
+ `Expected blocked request to be in "authRequired" phase, got ${phase}`
+ );
+ }
+
+ switch (action) {
+ case ContinueWithAuthAction.Cancel: {
+ authCallbacks.cancelAuthPrompt();
+ break;
+ }
+ case ContinueWithAuthAction.Default: {
+ authCallbacks.forwardAuthPrompt();
+ break;
+ }
+ case ContinueWithAuthAction.ProvideCredentials: {
+ await authCallbacks.provideAuthCredentials(
+ credentials.username,
+ credentials.password
+ );
+
+ break;
+ }
+ }
+
+ resolveBlockedEvent();
+ }
+
+ /**
+ * Fails a request that is blocked by a network intercept.
+ *
+ * @param {object=} options
+ * @param {string} options.request
+ * The id of the blocked request that should be continued.
+ *
+ * @throws {InvalidArgumentError}
+ * Raised if an argument is of an invalid type or value.
+ * @throws {NoSuchRequestError}
+ * Raised if the request id does not match any request in the blocked
+ * requests map.
+ */
+ async failRequest(options = {}) {
+ const { request: requestId } = options;
+
+ lazy.assert.string(
+ requestId,
+ `Expected "request" to be a string, got ${requestId}`
+ );
+
+ if (!this.#blockedRequests.has(requestId)) {
+ throw new lazy.error.NoSuchRequestError(
+ `Blocked request with id ${requestId} not found`
+ );
+ }
+
+ const { phase, request, resolveBlockedEvent } =
+ this.#blockedRequests.get(requestId);
+
+ if (phase === InterceptPhase.AuthRequired) {
+ throw new lazy.error.InvalidArgumentError(
+ `Expected blocked request not to be in "authRequired" phase`
+ );
+ }
+
+ const wrapper = ChannelWrapper.get(request);
+ wrapper.resume();
+ wrapper.cancel(
+ Cr.NS_ERROR_ABORT,
+ Ci.nsILoadInfo.BLOCKING_REASON_WEBDRIVER_BIDI
+ );
+
+ resolveBlockedEvent();
+ }
+
+ /**
+ * Continues a request that’s blocked by a network intercept, by providing a
+ * complete response.
+ *
+ * @param {object=} options
+ * @param {string} options.request
+ * The id of the blocked request for which the response should be
+ * provided.
+ * @param {BytesValue=} options.body [unsupported]
+ * Optional BytesValue to replace the body of the response.
+ * @param {Array<SetCookieHeader>=} options.cookies [unsupported]
+ * Optional array of set-cookie header values to use for the provided
+ * response.
+ * @param {Array<Header>=} options.headers [unsupported]
+ * Optional array of header values to use for the provided
+ * response.
+ * @param {string=} options.reasonPhrase [unsupported]
+ * Optional string to use as the status message for the provided response.
+ * @param {number=} options.statusCode [unsupported]
+ * Optional number to use as the status code for the provided response.
+ *
+ * @throws {InvalidArgumentError}
+ * Raised if an argument is of an invalid type or value.
+ * @throws {NoSuchRequestError}
+ * Raised if the request id does not match any request in the blocked
+ * requests map.
+ */
+ async provideResponse(options = {}) {
+ const {
+ body = null,
+ cookies = null,
+ headers = null,
+ reasonPhrase = null,
+ request: requestId,
+ statusCode = null,
+ } = options;
+
+ lazy.assert.string(
+ requestId,
+ `Expected "request" to be a string, got ${requestId}`
+ );
+
+ if (body !== null) {
+ this.#assertBytesValue(
+ body,
+ `Expected "body" to be a network.BytesValue, got ${body}`
+ );
+
+ throw new lazy.error.UnsupportedOperationError(
+ `"body" not supported yet in network.provideResponse`
+ );
+ }
+
+ if (cookies !== null) {
+ lazy.assert.array(
+ cookies,
+ `Expected "cookies" to be an array got ${cookies}`
+ );
+
+ for (const cookie of cookies) {
+ this.#assertSetCookieHeader(cookie);
+ }
+
+ throw new lazy.error.UnsupportedOperationError(
+ `"cookies" not supported yet in network.provideResponse`
+ );
+ }
+
+ if (headers !== null) {
+ lazy.assert.array(
+ headers,
+ `Expected "headers" to be an array got ${headers}`
+ );
+
+ for (const header of headers) {
+ this.#assertHeader(
+ header,
+ `Expected values in "headers" to be network.Header, got ${header}`
+ );
+ }
+
+ throw new lazy.error.UnsupportedOperationError(
+ `"headers" not supported yet in network.provideResponse`
+ );
+ }
+
+ if (reasonPhrase !== null) {
+ lazy.assert.string(
+ reasonPhrase,
+ `Expected "reasonPhrase" to be a string, got ${reasonPhrase}`
+ );
+
+ throw new lazy.error.UnsupportedOperationError(
+ `"reasonPhrase" not supported yet in network.provideResponse`
+ );
+ }
+
+ if (statusCode !== null) {
+ lazy.assert.positiveInteger(
+ statusCode,
+ `Expected "statusCode" to be a positive integer, got ${statusCode}`
+ );
+
+ throw new lazy.error.UnsupportedOperationError(
+ `"statusCode" not supported yet in network.provideResponse`
+ );
+ }
+
+ if (!this.#blockedRequests.has(requestId)) {
+ throw new lazy.error.NoSuchRequestError(
+ `Blocked request with id ${requestId} not found`
+ );
+ }
+
+ const { authCallbacks, phase, request, resolveBlockedEvent } =
+ this.#blockedRequests.get(requestId);
+
+ if (phase === InterceptPhase.AuthRequired) {
+ await authCallbacks.provideAuthCredentials();
+ } else {
+ const wrapper = ChannelWrapper.get(request);
+ wrapper.resume();
+ }
+
+ resolveBlockedEvent();
+ }
+
+ /**
+ * Removes an existing network intercept.
+ *
+ * @param {object=} options
+ * @param {string} options.intercept
+ * The id of the intercept to remove.
+ *
+ * @throws {InvalidArgumentError}
+ * Raised if an argument is of an invalid type or value.
+ * @throws {NoSuchInterceptError}
+ * Raised if the intercept id could not be found in the internal intercept
+ * map.
+ */
+ removeIntercept(options = {}) {
+ const { intercept } = options;
+
+ lazy.assert.string(
+ intercept,
+ `Expected "intercept" to be a string, got ${intercept}`
+ );
+
+ if (!this.#interceptMap.has(intercept)) {
+ throw new lazy.error.NoSuchInterceptError(
+ `Network intercept with id ${intercept} not found`
+ );
+ }
+
+ this.#interceptMap.delete(intercept);
+ }
+
+ /**
+ * Add a new request in the blockedRequests map.
+ *
+ * @param {string} requestId
+ * The request id.
+ * @param {InterceptPhase} phase
+ * The phase where the request is blocked.
+ * @param {object=} options
+ * @param {object=} options.authCallbacks
+ * Only defined for requests blocked in the authRequired phase.
+ * Provides callbacks to handle the authentication.
+ * @param {nsIChannel=} options.requestChannel
+ * The request channel.
+ * @param {nsIChannel=} options.responseChannel
+ * The response channel.
+ */
+ #addBlockedRequest(requestId, phase, options = {}) {
+ const {
+ authCallbacks,
+ requestChannel: request,
+ responseChannel: response,
+ } = options;
+ const { promise: blockedEventPromise, resolve: resolveBlockedEvent } =
+ Promise.withResolvers();
+
+ this.#blockedRequests.set(requestId, {
+ authCallbacks,
+ request,
+ response,
+ resolveBlockedEvent,
+ phase,
+ });
+
+ blockedEventPromise.finally(() => {
+ this.#blockedRequests.delete(requestId);
+ });
+ }
+
+ #assertAuthCredentials(credentials) {
+ lazy.assert.object(
+ credentials,
+ `Expected "credentials" to be an object, got ${credentials}`
+ );
+
+ if (credentials.type !== "password") {
+ throw new lazy.error.InvalidArgumentError(
+ `Expected credentials "type" to be "password" got ${credentials.type}`
+ );
+ }
+
+ lazy.assert.string(
+ credentials.username,
+ `Expected credentials "username" to be a string, got ${credentials.username}`
+ );
+ lazy.assert.string(
+ credentials.password,
+ `Expected credentials "password" to be a string, got ${credentials.password}`
+ );
+ }
+
+ #assertBytesValue(obj, msg) {
+ lazy.assert.object(obj, msg);
+ lazy.assert.string(obj.value, msg);
+ lazy.assert.in(obj.type, Object.values(BytesValueType), msg);
+ }
+
+ #assertHeader(value, msg) {
+ lazy.assert.object(value, msg);
+ lazy.assert.string(value.name, msg);
+ this.#assertBytesValue(value.value, msg);
+ }
+
+ #assertSetCookieHeader(setCookieHeader) {
+ lazy.assert.object(
+ setCookieHeader,
+ `Expected set-cookie header to be an object, got ${setCookieHeader}`
+ );
+
+ const {
+ name,
+ value,
+ domain = null,
+ httpOnly = null,
+ expiry = null,
+ maxAge = null,
+ path = null,
+ sameSite = null,
+ secure = null,
+ } = setCookieHeader;
+
+ lazy.assert.string(
+ name,
+ `Expected set-cookie header "name" to be a string, got ${name}`
+ );
+
+ this.#assertBytesValue(
+ value,
+ `Expected set-cookie header "value" to be a BytesValue, got ${name}`
+ );
+
+ if (domain !== null) {
+ lazy.assert.string(
+ domain,
+ `Expected set-cookie header "domain" to be a string, got ${domain}`
+ );
+ }
+ if (httpOnly !== null) {
+ lazy.assert.boolean(
+ httpOnly,
+ `Expected set-cookie header "httpOnly" to be a boolean, got ${httpOnly}`
+ );
+ }
+ if (expiry !== null) {
+ lazy.assert.string(
+ expiry,
+ `Expected set-cookie header "expiry" to be a string, got ${expiry}`
+ );
+ }
+ if (maxAge !== null) {
+ lazy.assert.integer(
+ maxAge,
+ `Expected set-cookie header "maxAge" to be an integer, got ${maxAge}`
+ );
+ }
+ if (path !== null) {
+ lazy.assert.string(
+ path,
+ `Expected set-cookie header "path" to be a string, got ${path}`
+ );
+ }
+ if (sameSite !== null) {
+ lazy.assert.in(
+ sameSite,
+ Object.values(SameSite),
+ `Expected set-cookie header "sameSite" to be one of ${Object.values(
+ SameSite
+ )}, got ${sameSite}`
+ );
+ }
+ if (secure !== null) {
+ lazy.assert.boolean(
+ secure,
+ `Expected set-cookie header "secure" to be a boolean, got ${secure}`
+ );
+ }
+ }
+
+ #extractChallenges(responseData) {
+ let headerName;
+
+ // Using case-insensitive match for header names, so we use the lowercase
+ // version of the "WWW-Authenticate" / "Proxy-Authenticate" strings.
+ if (responseData.status === 401) {
+ headerName = "www-authenticate";
+ } else if (responseData.status === 407) {
+ headerName = "proxy-authenticate";
+ } else {
+ return null;
+ }
+
+ const challenges = [];
+
+ for (const header of responseData.headers) {
+ if (header.name.toLowerCase() === headerName) {
+ // A single header can contain several challenges.
+ const headerChallenges = lazy.parseChallengeHeader(header.value);
+ for (const headerChallenge of headerChallenges) {
+ const realmParam = headerChallenge.params.find(
+ param => param.name == "realm"
+ );
+ const realm = realmParam ? realmParam.value : undefined;
+ const challenge = {
+ scheme: headerChallenge.scheme,
+ realm,
+ };
+ challenges.push(challenge);
+ }
+ }
+ }
+
+ return challenges;
+ }
+
+ #getContextInfo(browsingContext) {
+ return {
+ contextId: browsingContext.id,
+ type: lazy.WindowGlobalMessageHandler.type,
+ };
+ }
+
+ #getSuspendMarkerText(requestData, phase) {
+ return `Request (id: ${requestData.request}) suspended by WebDriver BiDi in ${phase} phase`;
+ }
+
+ #getNetworkIntercepts(event, requestData) {
+ const intercepts = [];
+
+ let phase;
+ switch (event) {
+ case "network.beforeRequestSent":
+ phase = InterceptPhase.BeforeRequestSent;
+ break;
+ case "network.responseStarted":
+ phase = InterceptPhase.ResponseStarted;
+ break;
+ case "network.authRequired":
+ phase = InterceptPhase.AuthRequired;
+ break;
+ case "network.responseCompleted":
+ // The network.responseCompleted event does not match any interception
+ // phase. Return immediately.
+ return intercepts;
+ }
+
+ const url = requestData.url;
+ for (const [interceptId, intercept] of this.#interceptMap) {
+ if (intercept.phases.includes(phase)) {
+ const urlPatterns = intercept.urlPatterns;
+ if (
+ !urlPatterns.length ||
+ urlPatterns.some(pattern => lazy.matchURLPattern(pattern, url))
+ ) {
+ intercepts.push(interceptId);
+ }
+ }
+ }
+
+ return intercepts;
+ }
+
+ #getNavigationId(eventName, isNavigationRequest, browsingContext, url) {
+ if (!isNavigationRequest) {
+ // Not a navigation request return null.
+ return null;
+ }
+
+ let navigation =
+ this.messageHandler.navigationManager.getNavigationForBrowsingContext(
+ browsingContext
+ );
+
+ // `onBeforeRequestSent` might be too early for the NavigationManager.
+ // If there is no ongoing navigation, create one ourselves.
+ // TODO: Bug 1835704 to detect navigations earlier and avoid this.
+ if (
+ eventName === "network.beforeRequestSent" &&
+ (!navigation || navigation.finished)
+ ) {
+ navigation = lazy.notifyNavigationStarted({
+ contextDetails: { context: browsingContext },
+ url,
+ });
+ }
+
+ return navigation ? navigation.navigationId : null;
+ }
+
+ #onAuthRequired = (name, data) => {
+ const {
+ authCallbacks,
+ contextId,
+ isNavigationRequest,
+ redirectCount,
+ requestChannel,
+ requestData,
+ responseChannel,
+ responseData,
+ timestamp,
+ } = data;
+
+ let isBlocked = false;
+ try {
+ const browsingContext = lazy.TabManager.getBrowsingContextById(contextId);
+ if (!browsingContext) {
+ // Do not emit events if the context id does not match any existing
+ // browsing context.
+ return;
+ }
+
+ const protocolEventName = "network.authRequired";
+
+ // Process the navigation to create potentially missing navigation ids
+ // before the early return below.
+ const navigation = this.#getNavigationId(
+ protocolEventName,
+ isNavigationRequest,
+ browsingContext,
+ requestData.url
+ );
+
+ const isListening = this.messageHandler.eventsDispatcher.hasListener(
+ protocolEventName,
+ { contextId }
+ );
+ if (!isListening) {
+ // If there are no listeners subscribed to this event and this context,
+ // bail out.
+ return;
+ }
+
+ const baseParameters = this.#processNetworkEvent(protocolEventName, {
+ contextId,
+ navigation,
+ redirectCount,
+ requestData,
+ timestamp,
+ });
+
+ const authRequiredEvent = this.#serializeNetworkEvent({
+ ...baseParameters,
+ response: responseData,
+ });
+
+ const authChallenges = this.#extractChallenges(responseData);
+ // authChallenges should never be null for a request which triggered an
+ // authRequired event.
+ authRequiredEvent.response.authChallenges = authChallenges;
+
+ this.emitEvent(
+ protocolEventName,
+ authRequiredEvent,
+ this.#getContextInfo(browsingContext)
+ );
+
+ if (authRequiredEvent.isBlocked) {
+ isBlocked = true;
+
+ // requestChannel.suspend() is not needed here because the request is
+ // already blocked on the authentication prompt notification until
+ // one of the authCallbacks is called.
+ this.#addBlockedRequest(
+ authRequiredEvent.request.request,
+ InterceptPhase.AuthRequired,
+ {
+ authCallbacks,
+ requestChannel,
+ responseChannel,
+ }
+ );
+ }
+ } finally {
+ if (!isBlocked) {
+ // If the request was not blocked, forward the auth prompt notification
+ // to the next consumer.
+ authCallbacks.forwardAuthPrompt();
+ }
+ }
+ };
+
+ #onBeforeRequestSent = (name, data) => {
+ const {
+ contextId,
+ isNavigationRequest,
+ redirectCount,
+ requestChannel,
+ requestData,
+ timestamp,
+ } = data;
+
+ const browsingContext = lazy.TabManager.getBrowsingContextById(contextId);
+ if (!browsingContext) {
+ // Do not emit events if the context id does not match any existing
+ // browsing context.
+ return;
+ }
+
+ const internalEventName = "network._beforeRequestSent";
+ const protocolEventName = "network.beforeRequestSent";
+
+ // Process the navigation to create potentially missing navigation ids
+ // before the early return below.
+ const navigation = this.#getNavigationId(
+ protocolEventName,
+ isNavigationRequest,
+ browsingContext,
+ requestData.url
+ );
+
+ // Always emit internal events, they are used to support the browsingContext
+ // navigate command.
+ // Bug 1861922: Replace internal events with a Network listener helper
+ // directly using the NetworkObserver.
+ this.emitEvent(
+ internalEventName,
+ {
+ navigation,
+ url: requestData.url,
+ },
+ this.#getContextInfo(browsingContext)
+ );
+
+ const isListening = this.messageHandler.eventsDispatcher.hasListener(
+ protocolEventName,
+ { contextId }
+ );
+ if (!isListening) {
+ // If there are no listeners subscribed to this event and this context,
+ // bail out.
+ return;
+ }
+
+ const baseParameters = this.#processNetworkEvent(protocolEventName, {
+ contextId,
+ navigation,
+ redirectCount,
+ requestData,
+ timestamp,
+ });
+
+ // Bug 1805479: Handle the initiator, including stacktrace details.
+ const initiator = {
+ type: InitiatorType.Other,
+ };
+
+ const beforeRequestSentEvent = this.#serializeNetworkEvent({
+ ...baseParameters,
+ initiator,
+ });
+
+ this.emitEvent(
+ protocolEventName,
+ beforeRequestSentEvent,
+ this.#getContextInfo(browsingContext)
+ );
+
+ if (beforeRequestSentEvent.isBlocked) {
+ // TODO: Requests suspended in beforeRequestSent still reach the server at
+ // the moment. https://bugzilla.mozilla.org/show_bug.cgi?id=1849686
+ const wrapper = ChannelWrapper.get(requestChannel);
+ wrapper.suspend(
+ this.#getSuspendMarkerText(requestData, "beforeRequestSent")
+ );
+
+ this.#addBlockedRequest(
+ beforeRequestSentEvent.request.request,
+ InterceptPhase.BeforeRequestSent,
+ {
+ requestChannel,
+ }
+ );
+ }
+ };
+
+ #onFetchError = (name, data) => {
+ const {
+ contextId,
+ errorText,
+ isNavigationRequest,
+ redirectCount,
+ requestData,
+ timestamp,
+ } = data;
+
+ const browsingContext = lazy.TabManager.getBrowsingContextById(contextId);
+ if (!browsingContext) {
+ // Do not emit events if the context id does not match any existing
+ // browsing context.
+ return;
+ }
+
+ const internalEventName = "network._fetchError";
+ const protocolEventName = "network.fetchError";
+
+ // Process the navigation to create potentially missing navigation ids
+ // before the early return below.
+ const navigation = this.#getNavigationId(
+ protocolEventName,
+ isNavigationRequest,
+ browsingContext,
+ requestData.url
+ );
+
+ // Always emit internal events, they are used to support the browsingContext
+ // navigate command.
+ // Bug 1861922: Replace internal events with a Network listener helper
+ // directly using the NetworkObserver.
+ this.emitEvent(
+ internalEventName,
+ {
+ navigation,
+ url: requestData.url,
+ },
+ this.#getContextInfo(browsingContext)
+ );
+
+ const isListening = this.messageHandler.eventsDispatcher.hasListener(
+ protocolEventName,
+ { contextId }
+ );
+ if (!isListening) {
+ // If there are no listeners subscribed to this event and this context,
+ // bail out.
+ return;
+ }
+
+ const baseParameters = this.#processNetworkEvent(protocolEventName, {
+ contextId,
+ navigation,
+ redirectCount,
+ requestData,
+ timestamp,
+ });
+
+ const fetchErrorEvent = this.#serializeNetworkEvent({
+ ...baseParameters,
+ errorText,
+ });
+
+ this.emitEvent(
+ protocolEventName,
+ fetchErrorEvent,
+ this.#getContextInfo(browsingContext)
+ );
+ };
+
+ #onResponseEvent = (name, data) => {
+ const {
+ contextId,
+ isNavigationRequest,
+ redirectCount,
+ requestChannel,
+ requestData,
+ responseChannel,
+ responseData,
+ timestamp,
+ } = data;
+
+ const browsingContext = lazy.TabManager.getBrowsingContextById(contextId);
+ if (!browsingContext) {
+ // Do not emit events if the context id does not match any existing
+ // browsing context.
+ return;
+ }
+
+ const protocolEventName =
+ name === "response-started"
+ ? "network.responseStarted"
+ : "network.responseCompleted";
+
+ const internalEventName =
+ name === "response-started"
+ ? "network._responseStarted"
+ : "network._responseCompleted";
+
+ // Process the navigation to create potentially missing navigation ids
+ // before the early return below.
+ const navigation = this.#getNavigationId(
+ protocolEventName,
+ isNavigationRequest,
+ browsingContext,
+ requestData.url
+ );
+
+ // Always emit internal events, they are used to support the browsingContext
+ // navigate command.
+ // Bug 1861922: Replace internal events with a Network listener helper
+ // directly using the NetworkObserver.
+ this.emitEvent(
+ internalEventName,
+ {
+ navigation,
+ url: requestData.url,
+ },
+ this.#getContextInfo(browsingContext)
+ );
+
+ const isListening = this.messageHandler.eventsDispatcher.hasListener(
+ protocolEventName,
+ { contextId }
+ );
+ if (!isListening) {
+ // If there are no listeners subscribed to this event and this context,
+ // bail out.
+ return;
+ }
+
+ const baseParameters = this.#processNetworkEvent(protocolEventName, {
+ contextId,
+ navigation,
+ redirectCount,
+ requestData,
+ timestamp,
+ });
+
+ const responseEvent = this.#serializeNetworkEvent({
+ ...baseParameters,
+ response: responseData,
+ });
+
+ const authChallenges = this.#extractChallenges(responseData);
+ if (authChallenges !== null) {
+ responseEvent.response.authChallenges = authChallenges;
+ }
+
+ this.emitEvent(
+ protocolEventName,
+ responseEvent,
+ this.#getContextInfo(browsingContext)
+ );
+
+ if (
+ protocolEventName === "network.responseStarted" &&
+ responseEvent.isBlocked
+ ) {
+ const wrapper = ChannelWrapper.get(requestChannel);
+ wrapper.suspend(
+ this.#getSuspendMarkerText(requestData, "responseStarted")
+ );
+
+ this.#addBlockedRequest(
+ responseEvent.request.request,
+ InterceptPhase.ResponseStarted,
+ {
+ requestChannel,
+ responseChannel,
+ }
+ );
+ }
+ };
+
+ /**
+ * Process the network event data for a given network event name and create
+ * the corresponding base parameters.
+ *
+ * @param {string} eventName
+ * One of the supported network event names.
+ * @param {object} data
+ * @param {string} data.contextId
+ * The browsing context id for the network event.
+ * @param {string|null} data.navigation
+ * The navigation id if this is a network event for a navigation request.
+ * @param {number} data.redirectCount
+ * The redirect count for the network event.
+ * @param {RequestData} data.requestData
+ * The network.RequestData information for the network event.
+ * @param {number} data.timestamp
+ * The timestamp when the network event was created.
+ */
+ #processNetworkEvent(eventName, data) {
+ const { contextId, navigation, redirectCount, requestData, timestamp } =
+ data;
+ const intercepts = this.#getNetworkIntercepts(eventName, requestData);
+ const isBlocked = !!intercepts.length;
+
+ const baseParameters = {
+ context: contextId,
+ isBlocked,
+ navigation,
+ redirectCount,
+ request: requestData,
+ timestamp,
+ };
+
+ if (isBlocked) {
+ baseParameters.intercepts = intercepts;
+ }
+
+ return baseParameters;
+ }
+
+ #serializeHeadersOrCookies(headersOrCookies) {
+ return headersOrCookies.map(item => ({
+ name: item.name,
+ value: this.#serializeStringAsBytesValue(item.value),
+ }));
+ }
+
+ /**
+ * Serialize in-place all cookies and headers arrays found in a given network
+ * event payload.
+ *
+ * @param {object} networkEvent
+ * The network event parameters object to serialize.
+ * @returns {object}
+ * The serialized network event parameters.
+ */
+ #serializeNetworkEvent(networkEvent) {
+ // Make a shallow copy of networkEvent before serializing the headers and
+ // cookies arrays in request/response.
+ const serialized = { ...networkEvent };
+
+ // Make a shallow copy of the request data.
+ serialized.request = { ...networkEvent.request };
+ serialized.request.cookies = this.#serializeHeadersOrCookies(
+ networkEvent.request.cookies
+ );
+ serialized.request.headers = this.#serializeHeadersOrCookies(
+ networkEvent.request.headers
+ );
+
+ if (networkEvent.response?.headers) {
+ // Make a shallow copy of the response data.
+ serialized.response = { ...networkEvent.response };
+ serialized.response.headers = this.#serializeHeadersOrCookies(
+ networkEvent.response.headers
+ );
+ }
+
+ return serialized;
+ }
+
+ /**
+ * Serialize a string value as BytesValue.
+ *
+ * Note: This does not attempt to fully implement serialize protocol bytes
+ * (https://w3c.github.io/webdriver-bidi/#serialize-protocol-bytes) as the
+ * header values read from the Channel are already serialized as strings at
+ * the moment.
+ *
+ * @param {string} value
+ * The value to serialize.
+ */
+ #serializeStringAsBytesValue(value) {
+ // TODO: For now, we handle all headers and cookies with the "string" type.
+ // See Bug 1835216 to add support for "base64" type and handle non-utf8
+ // values.
+ return {
+ type: BytesValueType.String,
+ value,
+ };
+ }
+
+ #startListening(event) {
+ if (this.#subscribedEvents.size == 0) {
+ this.#networkListener.startListening();
+ }
+ this.#subscribedEvents.add(event);
+ }
+
+ #stopListening(event) {
+ this.#subscribedEvents.delete(event);
+ if (this.#subscribedEvents.size == 0) {
+ this.#networkListener.stopListening();
+ }
+ }
+
+ #subscribeEvent(event) {
+ if (this.constructor.supportedEvents.includes(event)) {
+ this.#startListening(event);
+ }
+ }
+
+ #unsubscribeEvent(event) {
+ if (this.constructor.supportedEvents.includes(event)) {
+ this.#stopListening(event);
+ }
+ }
+
+ /**
+ * Internal commands
+ */
+
+ _applySessionData(params) {
+ // TODO: Bug 1775231. Move this logic to a shared module or an abstract
+ // class.
+ const { category } = params;
+ if (category === "event") {
+ const filteredSessionData = params.sessionData.filter(item =>
+ this.messageHandler.matchesContext(item.contextDescriptor)
+ );
+ for (const event of this.#subscribedEvents.values()) {
+ const hasSessionItem = filteredSessionData.some(
+ item => item.value === event
+ );
+ // If there are no session items for this context, we should unsubscribe from the event.
+ if (!hasSessionItem) {
+ this.#unsubscribeEvent(event);
+ }
+ }
+
+ // Subscribe to all events, which have an item in SessionData.
+ for (const { value } of filteredSessionData) {
+ this.#subscribeEvent(value);
+ }
+ }
+ }
+
+ static get supportedEvents() {
+ return [
+ "network.authRequired",
+ "network.beforeRequestSent",
+ "network.fetchError",
+ "network.responseCompleted",
+ "network.responseStarted",
+ ];
+ }
+}
+
+export const network = NetworkModule;
diff --git a/remote/webdriver-bidi/modules/root/script.sys.mjs b/remote/webdriver-bidi/modules/root/script.sys.mjs
new file mode 100644
index 0000000000..80fa4d76d0
--- /dev/null
+++ b/remote/webdriver-bidi/modules/root/script.sys.mjs
@@ -0,0 +1,959 @@
+/* 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 { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs",
+ ContextDescriptorType:
+ "chrome://remote/content/shared/messagehandler/MessageHandler.sys.mjs",
+ error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
+ generateUUID: "chrome://remote/content/shared/UUID.sys.mjs",
+ OwnershipModel: "chrome://remote/content/webdriver-bidi/RemoteValue.sys.mjs",
+ processExtraData:
+ "chrome://remote/content/webdriver-bidi/modules/Intercept.sys.mjs",
+ RealmType: "chrome://remote/content/shared/Realm.sys.mjs",
+ SessionDataMethod:
+ "chrome://remote/content/shared/messagehandler/sessiondata/SessionData.sys.mjs",
+ setDefaultAndAssertSerializationOptions:
+ "chrome://remote/content/webdriver-bidi/RemoteValue.sys.mjs",
+ TabManager: "chrome://remote/content/shared/TabManager.sys.mjs",
+ WindowGlobalMessageHandler:
+ "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs",
+});
+
+/**
+ * @typedef {string} ScriptEvaluateResultType
+ */
+
+/**
+ * Enum of possible evaluation result types.
+ *
+ * @readonly
+ * @enum {ScriptEvaluateResultType}
+ */
+const ScriptEvaluateResultType = {
+ Exception: "exception",
+ Success: "success",
+};
+
+class ScriptModule extends Module {
+ #preloadScriptMap;
+ #subscribedEvents;
+
+ constructor(messageHandler) {
+ super(messageHandler);
+
+ // Map in which the keys are UUIDs, and the values are structs
+ // with an item named expression, which is a string,
+ // and an item named sandbox which is a string or null.
+ this.#preloadScriptMap = new Map();
+
+ // Set of event names which have active subscriptions.
+ this.#subscribedEvents = new Set();
+ }
+
+ destroy() {
+ this.#preloadScriptMap = null;
+ this.#subscribedEvents = null;
+ }
+
+ /**
+ * Used as return value for script.addPreloadScript command.
+ *
+ * @typedef AddPreloadScriptResult
+ *
+ * @property {string} script
+ * The unique id associated with added preload script.
+ */
+
+ /**
+ * @typedef ChannelProperties
+ *
+ * @property {string} channel
+ * The channel id.
+ * @property {SerializationOptions=} serializationOptions
+ * An object which holds the information of how the result of evaluation
+ * in case of ECMAScript objects should be serialized.
+ * @property {OwnershipModel=} ownership
+ * The ownership model to use for the results of this evaluation. Defaults
+ * to `OwnershipModel.None`.
+ */
+
+ /**
+ * Represents a channel used to send custom messages from preload script
+ * to clients.
+ *
+ * @typedef ChannelValue
+ *
+ * @property {'channel'} type
+ * @property {ChannelProperties} value
+ */
+
+ /**
+ * Adds a preload script, which runs on creation of a new Window,
+ * before any author-defined script have run.
+ *
+ * @param {object=} options
+ * @param {Array<ChannelValue>=} options.arguments
+ * The arguments to pass to the function call.
+ * @param {Array<string>=} options.contexts
+ * The list of the browsing context ids.
+ * @param {string} options.functionDeclaration
+ * The expression to evaluate.
+ * @param {string=} options.sandbox
+ * The name of the sandbox. If the value is null or empty
+ * string, the default realm will be used.
+ *
+ * @returns {AddPreloadScriptResult}
+ *
+ * @throws {InvalidArgumentError}
+ * If any of the arguments does not have the expected type.
+ */
+ async addPreloadScript(options = {}) {
+ const {
+ arguments: commandArguments = [],
+ contexts: contextIds = null,
+ functionDeclaration,
+ sandbox = null,
+ } = options;
+ let contexts = null;
+
+ if (contextIds != null) {
+ lazy.assert.array(
+ contextIds,
+ `Expected "contexts" to be an array, got ${contextIds}`
+ );
+ lazy.assert.that(
+ contexts => !!contexts.length,
+ `Expected "contexts" array to have at least one item, got ${contextIds}`
+ )(contextIds);
+
+ contexts = new Set();
+ for (const contextId of contextIds) {
+ lazy.assert.string(
+ contextId,
+ `Expected elements of "contexts" to be a string, got ${contextId}`
+ );
+ const context = this.#getBrowsingContext(contextId);
+
+ if (context.parent) {
+ throw new lazy.error.InvalidArgumentError(
+ `Context with id ${contextId} is not a top-level browsing context`
+ );
+ }
+
+ contexts.add(context.browserId);
+ }
+ }
+
+ lazy.assert.string(
+ functionDeclaration,
+ `Expected "functionDeclaration" to be a string, got ${functionDeclaration}`
+ );
+
+ if (sandbox != null) {
+ lazy.assert.string(
+ sandbox,
+ `Expected "sandbox" to be a string, got ${sandbox}`
+ );
+ }
+
+ lazy.assert.array(
+ commandArguments,
+ `Expected "arguments" to be an array, got ${commandArguments}`
+ );
+ lazy.assert.that(
+ commandArguments =>
+ commandArguments.every(({ type, value }) => {
+ if (type === "channel") {
+ this.#assertChannelArgument(value);
+ return true;
+ }
+ return false;
+ }),
+ `One of the arguments has an unsupported type, only type "channel" is supported`
+ )(commandArguments);
+
+ const script = lazy.generateUUID();
+ const preloadScript = {
+ arguments: commandArguments,
+ contexts,
+ functionDeclaration,
+ sandbox,
+ };
+
+ this.#preloadScriptMap.set(script, preloadScript);
+
+ const preloadScriptDataItem = {
+ category: "preload-script",
+ moduleName: "script",
+ values: [
+ {
+ ...preloadScript,
+ script,
+ },
+ ],
+ };
+
+ if (contexts === null) {
+ await this.messageHandler.addSessionDataItem({
+ ...preloadScriptDataItem,
+ contextDescriptor: {
+ type: lazy.ContextDescriptorType.All,
+ },
+ });
+ } else {
+ const preloadScriptDataItems = [];
+ for (const id of contexts) {
+ preloadScriptDataItems.push({
+ ...preloadScriptDataItem,
+ contextDescriptor: {
+ type: lazy.ContextDescriptorType.TopBrowsingContext,
+ id,
+ },
+ method: lazy.SessionDataMethod.Add,
+ });
+ }
+
+ await this.messageHandler.updateSessionData(preloadScriptDataItems);
+ }
+
+ return { script };
+ }
+
+ /**
+ * Used to represent a frame of a JavaScript stack trace.
+ *
+ * @typedef StackFrame
+ *
+ * @property {number} columnNumber
+ * @property {string} functionName
+ * @property {number} lineNumber
+ * @property {string} url
+ */
+
+ /**
+ * Used to represent a JavaScript stack at a point in script execution.
+ *
+ * @typedef StackTrace
+ *
+ * @property {Array<StackFrame>} callFrames
+ */
+
+ /**
+ * Used to represent a JavaScript exception.
+ *
+ * @typedef ExceptionDetails
+ *
+ * @property {number} columnNumber
+ * @property {RemoteValue} exception
+ * @property {number} lineNumber
+ * @property {StackTrace} stackTrace
+ * @property {string} text
+ */
+
+ /**
+ * Used as return value for script.evaluate, as one of the available variants
+ * {ScriptEvaluateResultException} or {ScriptEvaluateResultSuccess}.
+ *
+ * @typedef ScriptEvaluateResult
+ */
+
+ /**
+ * Used as return value for script.evaluate when the script completes with a
+ * thrown exception.
+ *
+ * @typedef ScriptEvaluateResultException
+ *
+ * @property {ExceptionDetails} exceptionDetails
+ * @property {string} realm
+ * @property {ScriptEvaluateResultType} [type=ScriptEvaluateResultType.Exception]
+ */
+
+ /**
+ * Used as return value for script.evaluate when the script completes
+ * normally.
+ *
+ * @typedef ScriptEvaluateResultSuccess
+ *
+ * @property {string} realm
+ * @property {RemoteValue} result
+ * @property {ScriptEvaluateResultType} [type=ScriptEvaluateResultType.Success]
+ */
+
+ /**
+ * Calls a provided function with given arguments and scope in the provided
+ * target, which is either a realm or a browsing context.
+ *
+ * @param {object=} options
+ * @param {Array<RemoteValue>=} options.arguments
+ * The arguments to pass to the function call.
+ * @param {boolean} options.awaitPromise
+ * Determines if the command should wait for the return value of the
+ * expression to resolve, if this return value is a Promise.
+ * @param {string} options.functionDeclaration
+ * The expression to evaluate.
+ * @param {OwnershipModel=} options.resultOwnership
+ * The ownership model to use for the results of this evaluation. Defaults
+ * to `OwnershipModel.None`.
+ * @param {SerializationOptions=} options.serializationOptions
+ * An object which holds the information of how the result of evaluation
+ * in case of ECMAScript objects should be serialized.
+ * @param {object} options.target
+ * The target for the evaluation, which either matches the definition for
+ * a RealmTarget or for ContextTarget.
+ * @param {RemoteValue=} options.this
+ * The value of the this keyword for the function call.
+ * @param {boolean=} options.userActivation
+ * Determines whether execution should be treated as initiated by user.
+ * Defaults to `false`.
+ *
+ * @returns {ScriptEvaluateResult}
+ *
+ * @throws {InvalidArgumentError}
+ * If any of the arguments does not have the expected type.
+ * @throws {NoSuchFrameError}
+ * If the target cannot be found.
+ */
+ async callFunction(options = {}) {
+ const {
+ arguments: commandArguments = null,
+ awaitPromise,
+ functionDeclaration,
+ resultOwnership = lazy.OwnershipModel.None,
+ serializationOptions,
+ target = {},
+ this: thisParameter = null,
+ userActivation = false,
+ } = options;
+
+ lazy.assert.string(
+ functionDeclaration,
+ `Expected "functionDeclaration" to be a string, got ${functionDeclaration}`
+ );
+
+ lazy.assert.boolean(
+ awaitPromise,
+ `Expected "awaitPromise" to be a boolean, got ${awaitPromise}`
+ );
+
+ lazy.assert.boolean(
+ userActivation,
+ `Expected "userActivation" to be a boolean, got ${userActivation}`
+ );
+
+ this.#assertResultOwnership(resultOwnership);
+
+ if (commandArguments != null) {
+ lazy.assert.array(
+ commandArguments,
+ `Expected "arguments" to be an array, got ${commandArguments}`
+ );
+ commandArguments.forEach(({ type, value }) => {
+ if (type === "channel") {
+ this.#assertChannelArgument(value);
+ }
+ });
+ }
+
+ const { contextId, realmId, sandbox } = this.#assertTarget(target);
+ const context = await this.#getContextFromTarget({ contextId, realmId });
+ const serializationOptionsWithDefaults =
+ lazy.setDefaultAndAssertSerializationOptions(serializationOptions);
+ const evaluationResult = await this.messageHandler.forwardCommand({
+ moduleName: "script",
+ commandName: "callFunctionDeclaration",
+ destination: {
+ type: lazy.WindowGlobalMessageHandler.type,
+ id: context.id,
+ },
+ params: {
+ awaitPromise,
+ commandArguments,
+ functionDeclaration,
+ realmId,
+ resultOwnership,
+ sandbox,
+ serializationOptions: serializationOptionsWithDefaults,
+ thisParameter,
+ userActivation,
+ },
+ });
+
+ return this.#buildReturnValue(evaluationResult);
+ }
+
+ /**
+ * The script.disown command disowns the given handles. This does not
+ * guarantee the handled object will be garbage collected, as there can be
+ * other handles or strong ECMAScript references.
+ *
+ * @param {object=} options
+ * @param {Array<string>} options.handles
+ * Array of handle ids to disown.
+ * @param {object} options.target
+ * The target owning the handles, which either matches the definition for
+ * a RealmTarget or for ContextTarget.
+ */
+ async disown(options = {}) {
+ const { handles, target = {} } = options;
+
+ lazy.assert.array(
+ handles,
+ `Expected "handles" to be an array, got ${handles}`
+ );
+ handles.forEach(handle => {
+ lazy.assert.string(
+ handle,
+ `Expected "handles" to be an array of strings, got ${handle}`
+ );
+ });
+
+ const { contextId, realmId, sandbox } = this.#assertTarget(target);
+ const context = await this.#getContextFromTarget({ contextId, realmId });
+ await this.messageHandler.forwardCommand({
+ moduleName: "script",
+ commandName: "disownHandles",
+ destination: {
+ type: lazy.WindowGlobalMessageHandler.type,
+ id: context.id,
+ },
+ params: {
+ handles,
+ realmId,
+ sandbox,
+ },
+ });
+ }
+
+ /**
+ * Evaluate a provided expression in the provided target, which is either a
+ * realm or a browsing context.
+ *
+ * @param {object=} options
+ * @param {boolean} options.awaitPromise
+ * Determines if the command should wait for the return value of the
+ * expression to resolve, if this return value is a Promise.
+ * @param {string} options.expression
+ * The expression to evaluate.
+ * @param {OwnershipModel=} options.resultOwnership
+ * The ownership model to use for the results of this evaluation. Defaults
+ * to `OwnershipModel.None`.
+ * @param {SerializationOptions=} options.serializationOptions
+ * An object which holds the information of how the result of evaluation
+ * in case of ECMAScript objects should be serialized.
+ * @param {object} options.target
+ * The target for the evaluation, which either matches the definition for
+ * a RealmTarget or for ContextTarget.
+ * @param {boolean=} options.userActivation
+ * Determines whether execution should be treated as initiated by user.
+ * Defaults to `false`.
+ *
+ * @returns {ScriptEvaluateResult}
+ *
+ * @throws {InvalidArgumentError}
+ * If any of the arguments does not have the expected type.
+ * @throws {NoSuchFrameError}
+ * If the target cannot be found.
+ */
+ async evaluate(options = {}) {
+ const {
+ awaitPromise,
+ expression: source,
+ resultOwnership = lazy.OwnershipModel.None,
+ serializationOptions,
+ target = {},
+ userActivation = false,
+ } = options;
+
+ lazy.assert.string(
+ source,
+ `Expected "expression" to be a string, got ${source}`
+ );
+
+ lazy.assert.boolean(
+ awaitPromise,
+ `Expected "awaitPromise" to be a boolean, got ${awaitPromise}`
+ );
+
+ lazy.assert.boolean(
+ userActivation,
+ `Expected "userActivation" to be a boolean, got ${userActivation}`
+ );
+
+ this.#assertResultOwnership(resultOwnership);
+
+ const { contextId, realmId, sandbox } = this.#assertTarget(target);
+ const context = await this.#getContextFromTarget({ contextId, realmId });
+ const serializationOptionsWithDefaults =
+ lazy.setDefaultAndAssertSerializationOptions(serializationOptions);
+ const evaluationResult = await this.messageHandler.forwardCommand({
+ moduleName: "script",
+ commandName: "evaluateExpression",
+ destination: {
+ type: lazy.WindowGlobalMessageHandler.type,
+ id: context.id,
+ },
+ params: {
+ awaitPromise,
+ expression: source,
+ realmId,
+ resultOwnership,
+ sandbox,
+ serializationOptions: serializationOptionsWithDefaults,
+ userActivation,
+ },
+ });
+
+ return this.#buildReturnValue(evaluationResult);
+ }
+
+ /**
+ * An object that holds basic information about a realm.
+ *
+ * @typedef BaseRealmInfo
+ *
+ * @property {string} id
+ * The realm unique identifier.
+ * @property {string} origin
+ * The serialization of an origin.
+ */
+
+ /**
+ *
+ * @typedef WindowRealmInfoProperties
+ *
+ * @property {string} context
+ * The browsing context id, associated with the realm.
+ * @property {string=} sandbox
+ * The name of the sandbox. If the value is null or empty
+ * string, the default realm will be returned.
+ * @property {RealmType.Window} type
+ * The window realm type.
+ */
+
+ /* eslint-disable jsdoc/valid-types */
+ /**
+ * An object that holds information about a window realm.
+ *
+ * @typedef {BaseRealmInfo & WindowRealmInfoProperties} WindowRealmInfo
+ */
+ /* eslint-enable jsdoc/valid-types */
+
+ /**
+ * An object that holds information about a realm.
+ *
+ * @typedef {WindowRealmInfo} RealmInfo
+ */
+
+ /**
+ * An object that holds a list of realms.
+ *
+ * @typedef ScriptGetRealmsResult
+ *
+ * @property {Array<RealmInfo>} realms
+ * List of realms.
+ */
+
+ /**
+ * Returns a list of all realms, optionally filtered to realms
+ * of a specific type, or to the realms associated with
+ * a specified browsing context.
+ *
+ * @param {object=} options
+ * @param {string=} options.context
+ * The id of the browsing context to filter
+ * only realms associated with it. If not provided, return realms
+ * associated with all browsing contexts.
+ * @param {RealmType=} options.type
+ * Type of realm to filter.
+ * If not provided, return realms of all types.
+ *
+ * @returns {ScriptGetRealmsResult}
+ *
+ * @throws {InvalidArgumentError}
+ * If any of the arguments does not have the expected type.
+ * @throws {NoSuchFrameError}
+ * If the context cannot be found.
+ */
+ async getRealms(options = {}) {
+ const { context: contextId = null, type = null } = options;
+ const destination = {};
+
+ if (contextId !== null) {
+ lazy.assert.string(
+ contextId,
+ `Expected "context" to be a string, got ${contextId}`
+ );
+ destination.id = this.#getBrowsingContext(contextId).id;
+ } else {
+ destination.contextDescriptor = {
+ type: lazy.ContextDescriptorType.All,
+ };
+ }
+
+ if (type !== null) {
+ const supportedRealmTypes = Object.values(lazy.RealmType);
+ if (!supportedRealmTypes.includes(type)) {
+ throw new lazy.error.InvalidArgumentError(
+ `Expected "type" to be one of ${supportedRealmTypes}, got ${type}`
+ );
+ }
+
+ // Remove this check when other realm types are supported
+ if (type !== lazy.RealmType.Window) {
+ throw new lazy.error.UnsupportedOperationError(
+ `Unsupported "type": ${type}. Only "type" ${lazy.RealmType.Window} is currently supported.`
+ );
+ }
+ }
+
+ return { realms: await this.#getRealmInfos(destination) };
+ }
+
+ /**
+ * Removes a preload script.
+ *
+ * @param {object=} options
+ * @param {string} options.script
+ * The unique id associated with a preload script.
+ *
+ * @throws {InvalidArgumentError}
+ * If any of the arguments does not have the expected type.
+ * @throws {NoSuchScriptError}
+ * If the script cannot be found.
+ */
+ async removePreloadScript(options = {}) {
+ const { script } = options;
+
+ lazy.assert.string(
+ script,
+ `Expected "script" to be a string, got ${script}`
+ );
+
+ if (!this.#preloadScriptMap.has(script)) {
+ throw new lazy.error.NoSuchScriptError(
+ `Preload script with id ${script} not found`
+ );
+ }
+
+ const preloadScript = this.#preloadScriptMap.get(script);
+ const sessionDataItem = {
+ category: "preload-script",
+ moduleName: "script",
+ values: [
+ {
+ ...preloadScript,
+ script,
+ },
+ ],
+ };
+
+ if (preloadScript.contexts === null) {
+ await this.messageHandler.removeSessionDataItem({
+ ...sessionDataItem,
+ contextDescriptor: {
+ type: lazy.ContextDescriptorType.All,
+ },
+ });
+ } else {
+ const sessionDataItemToUpdate = [];
+ for (const id of preloadScript.contexts) {
+ sessionDataItemToUpdate.push({
+ ...sessionDataItem,
+ contextDescriptor: {
+ type: lazy.ContextDescriptorType.TopBrowsingContext,
+ id,
+ },
+ method: lazy.SessionDataMethod.Remove,
+ });
+ }
+
+ await this.messageHandler.updateSessionData(sessionDataItemToUpdate);
+ }
+
+ this.#preloadScriptMap.delete(script);
+ }
+
+ #assertChannelArgument(value) {
+ lazy.assert.object(value);
+ const {
+ channel,
+ ownership = lazy.OwnershipModel.None,
+ serializationOptions,
+ } = value;
+ lazy.assert.string(channel);
+ lazy.setDefaultAndAssertSerializationOptions(serializationOptions);
+ lazy.assert.that(
+ ownership =>
+ [lazy.OwnershipModel.None, lazy.OwnershipModel.Root].includes(
+ ownership
+ ),
+ `Expected "ownership" to be one of ${Object.values(
+ lazy.OwnershipModel
+ )}, got ${ownership}`
+ )(ownership);
+
+ return true;
+ }
+
+ #assertResultOwnership(resultOwnership) {
+ if (
+ ![lazy.OwnershipModel.None, lazy.OwnershipModel.Root].includes(
+ resultOwnership
+ )
+ ) {
+ throw new lazy.error.InvalidArgumentError(
+ `Expected "resultOwnership" to be one of ${Object.values(
+ lazy.OwnershipModel
+ )}, got ${resultOwnership}`
+ );
+ }
+ }
+
+ #assertTarget(target) {
+ lazy.assert.object(
+ target,
+ `Expected "target" to be an object, got ${target}`
+ );
+
+ const { context: contextId = null, sandbox = null } = target;
+ let { realm: realmId = null } = target;
+
+ if (contextId != null) {
+ lazy.assert.string(
+ contextId,
+ `Expected "context" to be a string, got ${contextId}`
+ );
+
+ if (sandbox != null) {
+ lazy.assert.string(
+ sandbox,
+ `Expected "sandbox" to be a string, got ${sandbox}`
+ );
+ }
+
+ // Ignore realm if context is provided.
+ realmId = null;
+ } else if (realmId != null) {
+ lazy.assert.string(
+ realmId,
+ `Expected "realm" to be a string, got ${realmId}`
+ );
+ } else {
+ throw new lazy.error.InvalidArgumentError(`No context or realm provided`);
+ }
+
+ return { contextId, realmId, sandbox };
+ }
+
+ #buildReturnValue(evaluationResult) {
+ evaluationResult = lazy.processExtraData(
+ this.messageHandler.sessionId,
+ evaluationResult
+ );
+
+ const rv = { realm: evaluationResult.realmId };
+ switch (evaluationResult.evaluationStatus) {
+ // TODO: Compare with EvaluationStatus.Normal after Bug 1774444 is fixed.
+ case "normal":
+ rv.type = ScriptEvaluateResultType.Success;
+ rv.result = evaluationResult.result;
+ break;
+ // TODO: Compare with EvaluationStatus.Throw after Bug 1774444 is fixed.
+ case "throw":
+ rv.type = ScriptEvaluateResultType.Exception;
+ rv.exceptionDetails = evaluationResult.exceptionDetails;
+ break;
+ default:
+ throw new lazy.error.UnsupportedOperationError(
+ `Unsupported evaluation status ${evaluationResult.evaluationStatus}`
+ );
+ }
+ return rv;
+ }
+
+ #getBrowsingContext(contextId) {
+ const context = lazy.TabManager.getBrowsingContextById(contextId);
+ if (context === null) {
+ throw new lazy.error.NoSuchFrameError(
+ `Browsing Context with id ${contextId} not found`
+ );
+ }
+
+ if (!context.currentWindowGlobal) {
+ throw new lazy.error.NoSuchFrameError(
+ `No window found for BrowsingContext with id ${contextId}`
+ );
+ }
+
+ return context;
+ }
+
+ async #getContextFromTarget({ contextId, realmId }) {
+ if (contextId !== null) {
+ return this.#getBrowsingContext(contextId);
+ }
+
+ const destination = {
+ contextDescriptor: {
+ type: lazy.ContextDescriptorType.All,
+ },
+ };
+ const realms = await this.#getRealmInfos(destination);
+ const realm = realms.find(realm => realm.realm == realmId);
+
+ if (realm && realm.context !== null) {
+ return this.#getBrowsingContext(realm.context);
+ }
+
+ throw new lazy.error.NoSuchFrameError(`Realm with id ${realmId} not found`);
+ }
+
+ async #getRealmInfos(destination) {
+ let realms = await this.messageHandler.forwardCommand({
+ moduleName: "script",
+ commandName: "getWindowRealms",
+ destination: {
+ type: lazy.WindowGlobalMessageHandler.type,
+ ...destination,
+ },
+ });
+
+ const isBroadcast = !!destination.contextDescriptor;
+ if (!isBroadcast) {
+ realms = [realms];
+ }
+
+ return realms
+ .flat()
+ .map(realm => {
+ // Resolve browsing context to a TabManager id.
+ realm.context = lazy.TabManager.getIdForBrowsingContext(realm.context);
+ return realm;
+ })
+ .filter(realm => realm.context !== null);
+ }
+
+ #onRealmCreated = (eventName, { realmInfo }) => {
+ // This event is emitted from the parent process but for a given browsing
+ // context. Set the event's contextInfo to the message handler corresponding
+ // to this browsing context.
+ const contextInfo = {
+ contextId: realmInfo.context.id,
+ type: lazy.WindowGlobalMessageHandler.type,
+ };
+
+ // Resolve browsing context to a TabManager id.
+ const context = lazy.TabManager.getIdForBrowsingContext(realmInfo.context);
+
+ // Don not emit the event, if the browsing context is gone.
+ if (context === null) {
+ return;
+ }
+
+ realmInfo.context = context;
+ this.emitEvent("script.realmCreated", realmInfo, contextInfo);
+ };
+
+ #onRealmDestroyed = (eventName, { realm, context }) => {
+ // This event is emitted from the parent process but for a given browsing
+ // context. Set the event's contextInfo to the message handler corresponding
+ // to this browsing context.
+ const contextInfo = {
+ contextId: context.id,
+ type: lazy.WindowGlobalMessageHandler.type,
+ };
+
+ this.emitEvent("script.realmDestroyed", { realm }, contextInfo);
+ };
+
+ #startListingOnRealmCreated() {
+ if (!this.#subscribedEvents.has("script.realmCreated")) {
+ this.messageHandler.on("realm-created", this.#onRealmCreated);
+ }
+ }
+
+ #stopListingOnRealmCreated() {
+ if (this.#subscribedEvents.has("script.realmCreated")) {
+ this.messageHandler.off("realm-created", this.#onRealmCreated);
+ }
+ }
+
+ #startListingOnRealmDestroyed() {
+ if (!this.#subscribedEvents.has("script.realmDestroyed")) {
+ this.messageHandler.on("realm-destroyed", this.#onRealmDestroyed);
+ }
+ }
+
+ #stopListingOnRealmDestroyed() {
+ if (this.#subscribedEvents.has("script.realmDestroyed")) {
+ this.messageHandler.off("realm-destroyed", this.#onRealmDestroyed);
+ }
+ }
+
+ #subscribeEvent(event) {
+ switch (event) {
+ case "script.realmCreated": {
+ this.#startListingOnRealmCreated();
+ this.#subscribedEvents.add(event);
+ break;
+ }
+ case "script.realmDestroyed": {
+ this.#startListingOnRealmDestroyed();
+ this.#subscribedEvents.add(event);
+ break;
+ }
+ }
+ }
+
+ #unsubscribeEvent(event) {
+ switch (event) {
+ case "script.realmCreated": {
+ this.#stopListingOnRealmCreated();
+ this.#subscribedEvents.delete(event);
+ break;
+ }
+ case "script.realmDestroyed": {
+ this.#stopListingOnRealmDestroyed();
+ this.#subscribedEvents.delete(event);
+ break;
+ }
+ }
+ }
+
+ _applySessionData(params) {
+ // TODO: Bug 1775231. Move this logic to a shared module or an abstract
+ // class.
+ const { category } = params;
+ if (category === "event") {
+ const filteredSessionData = params.sessionData.filter(item =>
+ this.messageHandler.matchesContext(item.contextDescriptor)
+ );
+ for (const event of this.#subscribedEvents.values()) {
+ const hasSessionItem = filteredSessionData.some(
+ item => item.value === event
+ );
+ // If there are no session items for this context, we should unsubscribe from the event.
+ if (!hasSessionItem) {
+ this.#unsubscribeEvent(event);
+ }
+ }
+
+ // Subscribe to all events, which have an item in SessionData.
+ for (const { value } of filteredSessionData) {
+ this.#subscribeEvent(value);
+ }
+ }
+ }
+
+ static get supportedEvents() {
+ return ["script.message", "script.realmCreated", "script.realmDestroyed"];
+ }
+}
+
+export const script = ScriptModule;
diff --git a/remote/webdriver-bidi/modules/root/session.sys.mjs b/remote/webdriver-bidi/modules/root/session.sys.mjs
new file mode 100644
index 0000000000..a34ca514e3
--- /dev/null
+++ b/remote/webdriver-bidi/modules/root/session.sys.mjs
@@ -0,0 +1,419 @@
+/* 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 { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs",
+ ContextDescriptorType:
+ "chrome://remote/content/shared/messagehandler/MessageHandler.sys.mjs",
+ error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
+ Marionette: "chrome://remote/content/components/Marionette.sys.mjs",
+ RootMessageHandler:
+ "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs",
+ TabManager: "chrome://remote/content/shared/TabManager.sys.mjs",
+});
+
+class SessionModule extends Module {
+ #browsingContextIdEventMap;
+ #globalEventSet;
+
+ constructor(messageHandler) {
+ super(messageHandler);
+
+ // Map with top-level browsing context id keys and values
+ // that are a set of event names for events
+ // that are enabled in the given browsing context.
+ // TODO: Bug 1804417. Use navigable instead of browsing context id.
+ this.#browsingContextIdEventMap = new Map();
+
+ // Set of event names which are strings of the form [moduleName].[eventName]
+ // for events that are enabled for all browsing contexts.
+ // We should only add an actual event listener on the MessageHandler the
+ // first time an event is subscribed to.
+ this.#globalEventSet = new Set();
+ }
+
+ destroy() {
+ this.#browsingContextIdEventMap = null;
+ this.#globalEventSet = null;
+ }
+
+ /**
+ * Commands
+ */
+
+ /**
+ * End the current session.
+ *
+ * Session clean up will happen later in WebDriverBiDiConnection class.
+ */
+ async end() {
+ if (lazy.Marionette.running) {
+ throw new lazy.error.UnsupportedOperationError(
+ "Ending session which was started with Webdriver classic is not supported, use Webdriver classic delete command instead."
+ );
+ }
+ }
+
+ /**
+ * Enable certain events either globally, or for a list of browsing contexts.
+ *
+ * @param {object=} params
+ * @param {Array<string>} params.events
+ * List of events to subscribe to.
+ * @param {Array<string>=} params.contexts
+ * Optional list of top-level browsing context ids
+ * to subscribe the events for.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>events</var> or <var>contexts</var> are not valid types.
+ */
+ async subscribe(params = {}) {
+ const { events, contexts: contextIds = null } = params;
+
+ // Check input types until we run schema validation.
+ lazy.assert.array(events, "events: array value expected");
+ events.forEach(name => {
+ lazy.assert.string(name, `${name}: string value expected`);
+ });
+
+ if (contextIds !== null) {
+ lazy.assert.array(contextIds, "contexts: array value expected");
+ contextIds.forEach(contextId => {
+ lazy.assert.string(contextId, `${contextId}: string value expected`);
+ });
+ }
+
+ const listeners = this.#updateEventMap(events, contextIds, true);
+
+ // TODO: Bug 1801284. Add subscribe priority sorting of subscribeStepEvents (step 4 to 6, and 8).
+
+ // Subscribe to the relevant engine-internal events.
+ await this.messageHandler.eventsDispatcher.update(listeners);
+ }
+
+ /**
+ * Disable certain events either globally, or for a list of browsing contexts.
+ *
+ * @param {object=} params
+ * @param {Array<string>} params.events
+ * List of events to unsubscribe from.
+ * @param {Array<string>=} params.contexts
+ * Optional list of top-level browsing context ids
+ * to unsubscribe the events from.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>events</var> or <var>contexts</var> are not valid types.
+ */
+ async unsubscribe(params = {}) {
+ const { events, contexts: contextIds = null } = params;
+
+ // Check input types until we run schema validation.
+ lazy.assert.array(events, "events: array value expected");
+ events.forEach(name => {
+ lazy.assert.string(name, `${name}: string value expected`);
+ });
+ if (contextIds !== null) {
+ lazy.assert.array(contextIds, "contexts: array value expected");
+ contextIds.forEach(contextId => {
+ lazy.assert.string(contextId, `${contextId}: string value expected`);
+ });
+ }
+
+ const listeners = this.#updateEventMap(events, contextIds, false);
+
+ // Unsubscribe from the relevant engine-internal events.
+ await this.messageHandler.eventsDispatcher.update(listeners);
+ }
+
+ #assertModuleSupportsEvent(moduleName, event) {
+ const rootModuleClass = this.#getRootModuleClass(moduleName);
+ if (!rootModuleClass?.supportsEvent(event)) {
+ throw new lazy.error.InvalidArgumentError(
+ `${event} is not a valid event name`
+ );
+ }
+ }
+
+ #getBrowserIdForContextId(contextId) {
+ const context = lazy.TabManager.getBrowsingContextById(contextId);
+ if (!context) {
+ throw new lazy.error.NoSuchFrameError(
+ `Browsing context with id ${contextId} not found`
+ );
+ }
+
+ return context.browserId;
+ }
+
+ #getRootModuleClass(moduleName) {
+ // Modules which support event subscriptions should have a root module
+ // defining supported events.
+ const rootDestination = { type: lazy.RootMessageHandler.type };
+ const moduleClasses = this.messageHandler.getAllModuleClasses(
+ moduleName,
+ rootDestination
+ );
+
+ if (!moduleClasses.length) {
+ throw new lazy.error.InvalidArgumentError(
+ `Module ${moduleName} does not exist`
+ );
+ }
+
+ return moduleClasses[0];
+ }
+
+ #getTopBrowsingContextId(contextId) {
+ const context = lazy.TabManager.getBrowsingContextById(contextId);
+ if (!context) {
+ throw new lazy.error.NoSuchFrameError(
+ `Browsing context with id ${contextId} not found`
+ );
+ }
+ const topContext = context.top;
+ return lazy.TabManager.getIdForBrowsingContext(topContext);
+ }
+
+ /**
+ * Obtain a set of events based on the given event name.
+ *
+ * Could contain a period for a specific event,
+ * or just the module name for all events.
+ *
+ * @param {string} event
+ * Name of the event to process.
+ *
+ * @returns {Set<string>}
+ * A Set with the expanded events in the form of `<module>.<event>`.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>event</var> does not reference a valid event.
+ */
+ #obtainEvents(event) {
+ const events = new Set();
+
+ // Check if a period is present that splits the event name into the module,
+ // and the actual event. Hereby only care about the first found instance.
+ const index = event.indexOf(".");
+ if (index >= 0) {
+ const [moduleName] = event.split(".");
+ this.#assertModuleSupportsEvent(moduleName, event);
+ events.add(event);
+ } else {
+ // Interpret the name as module, and register all its available events
+ const rootModuleClass = this.#getRootModuleClass(event);
+ const supportedEvents = rootModuleClass?.supportedEvents;
+
+ for (const eventName of supportedEvents) {
+ events.add(eventName);
+ }
+ }
+
+ return events;
+ }
+
+ /**
+ * Obtain a list of event enabled browsing context ids.
+ *
+ * @see https://w3c.github.io/webdriver-bidi/#event-enabled-browsing-contexts
+ *
+ * @param {string} eventName
+ * The name of the event.
+ *
+ * @returns {Set<string>} The set of browsing context.
+ */
+ #obtainEventEnabledBrowsingContextIds(eventName) {
+ const contextIds = new Set();
+ for (const [
+ contextId,
+ events,
+ ] of this.#browsingContextIdEventMap.entries()) {
+ if (events.has(eventName)) {
+ // Check that a browsing context still exists for a given id
+ const context = lazy.TabManager.getBrowsingContextById(contextId);
+ if (context) {
+ contextIds.add(contextId);
+ }
+ }
+ }
+
+ return contextIds;
+ }
+
+ #onMessageHandlerEvent = (name, event) => {
+ this.messageHandler.emitProtocolEvent(name, event);
+ };
+
+ /**
+ * Update global event state for top-level browsing contexts.
+ *
+ * @see https://w3c.github.io/webdriver-bidi/#update-the-event-map
+ *
+ * @param {Array<string>} requestedEventNames
+ * The list of the event names to run the update for.
+ * @param {Array<string>|null} browsingContextIds
+ * The list of the browsing context ids to update or null.
+ * @param {boolean} enabled
+ * True, if events have to be enabled. Otherwise false.
+ *
+ * @returns {Array<Subscription>} subscriptions
+ * The list of information to subscribe/unsubscribe to.
+ *
+ * @throws {InvalidArgumentError}
+ * If failed unsubscribe from event from <var>requestedEventNames</var> for
+ * browsing context id from <var>browsingContextIds</var>, if present.
+ */
+ #updateEventMap(requestedEventNames, browsingContextIds, enabled) {
+ const globalEventSet = new Set(this.#globalEventSet);
+ const eventMap = structuredClone(this.#browsingContextIdEventMap);
+
+ const eventNames = new Set();
+
+ requestedEventNames.forEach(name => {
+ this.#obtainEvents(name).forEach(event => eventNames.add(event));
+ });
+ const enabledEvents = new Map();
+ const subscriptions = [];
+
+ if (browsingContextIds === null) {
+ // Subscribe or unsubscribe events for all browsing contexts.
+ if (enabled) {
+ // Subscribe to each event.
+
+ // Get the list of all top level browsing context ids.
+ const allTopBrowsingContextIds = lazy.TabManager.allBrowserUniqueIds;
+
+ for (const eventName of eventNames) {
+ if (!globalEventSet.has(eventName)) {
+ const alreadyEnabledContextIds =
+ this.#obtainEventEnabledBrowsingContextIds(eventName);
+ globalEventSet.add(eventName);
+ for (const contextId of alreadyEnabledContextIds) {
+ eventMap.get(contextId).delete(eventName);
+
+ // Since we're going to subscribe to all top-level
+ // browsing context ids to not have duplicate subscriptions,
+ // we have to unsubscribe from already subscribed.
+ subscriptions.push({
+ event: eventName,
+ contextDescriptor: {
+ type: lazy.ContextDescriptorType.TopBrowsingContext,
+ id: this.#getBrowserIdForContextId(contextId),
+ },
+ callback: this.#onMessageHandlerEvent,
+ enable: false,
+ });
+ }
+
+ // Get a list of all top-level browsing context ids
+ // that are not contained in alreadyEnabledContextIds.
+ const newlyEnabledContextIds = allTopBrowsingContextIds.filter(
+ contextId => !alreadyEnabledContextIds.has(contextId)
+ );
+
+ enabledEvents.set(eventName, newlyEnabledContextIds);
+
+ subscriptions.push({
+ event: eventName,
+ contextDescriptor: {
+ type: lazy.ContextDescriptorType.All,
+ },
+ callback: this.#onMessageHandlerEvent,
+ enable: true,
+ });
+ }
+ }
+ } else {
+ // Unsubscribe each event which has a global subscription.
+ for (const eventName of eventNames) {
+ if (globalEventSet.has(eventName)) {
+ globalEventSet.delete(eventName);
+
+ subscriptions.push({
+ event: eventName,
+ contextDescriptor: {
+ type: lazy.ContextDescriptorType.All,
+ },
+ callback: this.#onMessageHandlerEvent,
+ enable: false,
+ });
+ } else {
+ throw new lazy.error.InvalidArgumentError(
+ `Failed to unsubscribe from event ${eventName}`
+ );
+ }
+ }
+ }
+ } else {
+ // Subscribe or unsubscribe events for given list of browsing context ids.
+ const targets = new Map();
+ for (const contextId of browsingContextIds) {
+ const topLevelContextId = this.#getTopBrowsingContextId(contextId);
+ if (!eventMap.has(topLevelContextId)) {
+ eventMap.set(topLevelContextId, new Set());
+ }
+ targets.set(topLevelContextId, eventMap.get(topLevelContextId));
+ }
+
+ for (const eventName of eventNames) {
+ // Do nothing if we want to subscribe,
+ // but the event has already a global subscription.
+ if (enabled && this.#globalEventSet.has(eventName)) {
+ continue;
+ }
+ for (const [contextId, target] of targets.entries()) {
+ // Subscribe if an event doesn't have a subscription for a specific context id.
+ if (enabled && !target.has(eventName)) {
+ target.add(eventName);
+ if (!enabledEvents.has(eventName)) {
+ enabledEvents.set(eventName, new Set());
+ }
+ enabledEvents.get(eventName).add(contextId);
+
+ subscriptions.push({
+ event: eventName,
+ contextDescriptor: {
+ type: lazy.ContextDescriptorType.TopBrowsingContext,
+ id: this.#getBrowserIdForContextId(contextId),
+ },
+ callback: this.#onMessageHandlerEvent,
+ enable: true,
+ });
+ } else if (!enabled) {
+ // Unsubscribe from each event for a specific context id if the event has a subscription.
+ if (target.has(eventName)) {
+ target.delete(eventName);
+
+ subscriptions.push({
+ event: eventName,
+ contextDescriptor: {
+ type: lazy.ContextDescriptorType.TopBrowsingContext,
+ id: this.#getBrowserIdForContextId(contextId),
+ },
+ callback: this.#onMessageHandlerEvent,
+ enable: false,
+ });
+ } else {
+ throw new lazy.error.InvalidArgumentError(
+ `Failed to unsubscribe from event ${eventName} for context ${contextId}`
+ );
+ }
+ }
+ }
+ }
+ }
+
+ this.#globalEventSet = globalEventSet;
+ this.#browsingContextIdEventMap = eventMap;
+
+ return subscriptions;
+ }
+}
+
+// To export the class as lower-case
+export const session = SessionModule;
diff --git a/remote/webdriver-bidi/modules/root/storage.sys.mjs b/remote/webdriver-bidi/modules/root/storage.sys.mjs
new file mode 100644
index 0000000000..50fbd8ecd6
--- /dev/null
+++ b/remote/webdriver-bidi/modules/root/storage.sys.mjs
@@ -0,0 +1,770 @@
+/* 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 { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs",
+ BytesValueType:
+ "chrome://remote/content/webdriver-bidi/modules/root/network.sys.mjs",
+ error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
+ TabManager: "chrome://remote/content/shared/TabManager.sys.mjs",
+});
+
+const CookieFieldsMapping = {
+ domain: "host",
+ expiry: "expiry",
+ httpOnly: "isHttpOnly",
+ name: "name",
+ path: "path",
+ sameSite: "sameSite",
+ secure: "isSecure",
+ size: "size",
+ value: "value",
+};
+
+const MAX_COOKIE_EXPIRY = Number.MAX_SAFE_INTEGER;
+
+/**
+ * Enum of possible partition types supported by the
+ * storage.getCookies command.
+ *
+ * @readonly
+ * @enum {PartitionType}
+ */
+const PartitionType = {
+ Context: "context",
+ StorageKey: "storageKey",
+};
+
+const PartitionKeyAttributes = ["sourceOrigin", "userContext"];
+
+/**
+ * Enum of possible SameSite types supported by the
+ * storage.getCookies command.
+ *
+ * @readonly
+ * @enum {SameSiteType}
+ */
+const SameSiteType = {
+ [Ci.nsICookie.SAMESITE_NONE]: "none",
+ [Ci.nsICookie.SAMESITE_LAX]: "lax",
+ [Ci.nsICookie.SAMESITE_STRICT]: "strict",
+};
+
+class StorageModule extends Module {
+ destroy() {}
+
+ /**
+ * Used as an argument for storage.getCookies command
+ * to represent fields which should be used to filter the output
+ * of the command.
+ *
+ * @typedef CookieFilter
+ *
+ * @property {string=} domain
+ * @property {number=} expiry
+ * @property {boolean=} httpOnly
+ * @property {string=} name
+ * @property {string=} path
+ * @property {SameSiteType=} sameSite
+ * @property {boolean=} secure
+ * @property {number=} size
+ * @property {Network.BytesValueType=} value
+ */
+
+ /**
+ * Used as an argument for storage.getCookies command as one of the available variants
+ * {BrowsingContextPartitionDescriptor} or {StorageKeyPartitionDescriptor}, to represent
+ * fields should be used to build a partition key.
+ *
+ * @typedef PartitionDescriptor
+ */
+
+ /**
+ * @typedef BrowsingContextPartitionDescriptor
+ *
+ * @property {PartitionType} [type=PartitionType.context]
+ * @property {string} context
+ */
+
+ /**
+ * @typedef StorageKeyPartitionDescriptor
+ *
+ * @property {PartitionType} [type=PartitionType.storageKey]
+ * @property {string=} sourceOrigin
+ * @property {string=} userContext (not supported)
+ */
+
+ /**
+ * @typedef PartitionKey
+ *
+ * @property {string=} sourceOrigin
+ * @property {string=} userContext (not supported)
+ */
+
+ /**
+ * An object that holds the result of storage.getCookies command.
+ *
+ * @typedef GetCookiesResult
+ *
+ * @property {Array<Cookie>} cookies
+ * List of cookies.
+ * @property {PartitionKey} partitionKey
+ * An object which represent the partition key which was used
+ * to retrieve the cookies.
+ */
+
+ /**
+ * Retrieve zero or more cookies which match a set of provided parameters.
+ *
+ * @param {object=} options
+ * @param {CookieFilter=} options.filter
+ * An object which holds field names and values, which
+ * should be used to filter the output of the command.
+ * @param {PartitionDescriptor=} options.partition
+ * An object which holds the information which
+ * should be used to build a partition key.
+ *
+ * @returns {GetCookiesResult}
+ * An object which holds a list of retrieved cookies and
+ * the partition key which was used.
+ * @throws {InvalidArgumentError}
+ * If the provided arguments are not valid.
+ * @throws {NoSuchFrameError}
+ * If the provided browsing context cannot be found.
+ * @throws {UnsupportedOperationError}
+ * Raised when the command is called with `userContext` as
+ * in `partition` argument.
+ */
+ async getCookies(options = {}) {
+ let { filter = {} } = options;
+ const { partition: partitionSpec = null } = options;
+
+ this.#assertPartition(partitionSpec);
+ filter = this.#assertGetCookieFilter(filter);
+
+ const partitionKey = this.#expandStoragePartitionSpec(partitionSpec);
+ const store = this.#getTheCookieStore(partitionKey);
+ const cookies = this.#getMatchingCookies(store, filter);
+
+ // Bug 1875255. Exchange platform id for Webdriver BiDi id for the user context to return it to the client.
+ // For now we use platform user context id for returning cookies for a specific browsing context in the platform API,
+ // but we can not return it directly to the client, so for now we just remove it from the response.
+ delete partitionKey.userContext;
+
+ return { cookies, partitionKey };
+ }
+
+ /**
+ * An object representation of the cookie which should be set.
+ *
+ * @typedef PartialCookie
+ *
+ * @property {string} domain
+ * @property {number=} expiry
+ * @property {boolean=} httpOnly
+ * @property {string} name
+ * @property {string=} path
+ * @property {SameSiteType=} sameSite
+ * @property {boolean=} secure
+ * @property {number=} size
+ * @property {Network.BytesValueType} value
+ */
+
+ /**
+ * Create a new cookie in a cookie store.
+ *
+ * @param {object=} options
+ * @param {PartialCookie} options.cookie
+ * An object representation of the cookie which
+ * should be set.
+ * @param {PartitionDescriptor=} options.partition
+ * An object which holds the information which
+ * should be used to build a partition key.
+ *
+ * @returns {PartitionKey}
+ * An object with the partition key which was used to
+ * add the cookie.
+ * @throws {InvalidArgumentError}
+ * If the provided arguments are not valid.
+ * @throws {NoSuchFrameError}
+ * If the provided browsing context cannot be found.
+ * @throws {UnableToSetCookieError}
+ * If the cookie was not added.
+ * @throws {UnsupportedOperationError}
+ * Raised when the command is called with `userContext` as
+ * in `partition` argument.
+ */
+ async setCookie(options = {}) {
+ const { cookie: cookieSpec, partition: partitionSpec = null } = options;
+ lazy.assert.object(
+ cookieSpec,
+ `Expected "cookie" to be an object, got ${cookieSpec}`
+ );
+
+ const {
+ domain,
+ expiry = null,
+ httpOnly = null,
+ name,
+ path = null,
+ sameSite = null,
+ secure = null,
+ value,
+ } = cookieSpec;
+ this.#assertCookie({
+ domain,
+ expiry,
+ httpOnly,
+ name,
+ path,
+ sameSite,
+ secure,
+ value,
+ });
+ this.#assertPartition(partitionSpec);
+
+ const partitionKey = this.#expandStoragePartitionSpec(partitionSpec);
+
+ // The cookie store is defined by originAttributes.
+ const originAttributes = this.#getOriginAttributes(partitionKey);
+
+ const deserializedValue = this.#deserializeProtocolBytes(value);
+
+ // The XPCOM interface requires to be specified if a cookie is session.
+ const isSession = expiry === null;
+
+ let schemeType;
+ if (secure) {
+ schemeType = Ci.nsICookie.SCHEME_HTTPS;
+ } else {
+ schemeType = Ci.nsICookie.SCHEME_HTTP;
+ }
+
+ try {
+ Services.cookies.add(
+ domain,
+ path === null ? "/" : path,
+ name,
+ deserializedValue,
+ secure === null ? false : secure,
+ httpOnly === null ? false : httpOnly,
+ isSession,
+ // The XPCOM interface requires the expiry field even for session cookies.
+ expiry === null ? MAX_COOKIE_EXPIRY : expiry,
+ originAttributes,
+ this.#getSameSitePlatformProperty(sameSite),
+ schemeType
+ );
+ } catch (e) {
+ throw new lazy.error.UnableToSetCookieError(e);
+ }
+
+ // Bug 1875255. Exchange platform id for Webdriver BiDi id for the user context to return it to the client.
+ // For now we use platform user context id for returning cookies for a specific browsing context in the platform API,
+ // but we can not return it directly to the client, so for now we just remove it from the response.
+ delete partitionKey.userContext;
+
+ return { partitionKey };
+ }
+
+ #assertCookie(cookie) {
+ lazy.assert.object(
+ cookie,
+ `Expected "cookie" to be an object, got ${cookie}`
+ );
+
+ const { domain, expiry, httpOnly, name, path, sameSite, secure, value } =
+ cookie;
+
+ lazy.assert.string(
+ domain,
+ `Expected "domain" to be a string, got ${domain}`
+ );
+
+ lazy.assert.string(name, `Expected "name" to be a string, got ${name}`);
+
+ this.#assertValue(value);
+
+ if (expiry !== null) {
+ lazy.assert.positiveInteger(
+ expiry,
+ `Expected "expiry" to be a positive number, got ${expiry}`
+ );
+ }
+
+ if (httpOnly !== null) {
+ lazy.assert.boolean(
+ httpOnly,
+ `Expected "httpOnly" to be a boolean, got ${httpOnly}`
+ );
+ }
+
+ if (path !== null) {
+ lazy.assert.string(path, `Expected "path" to be a string, got ${path}`);
+ }
+
+ this.#assertSameSite(sameSite);
+
+ if (secure !== null) {
+ lazy.assert.boolean(
+ secure,
+ `Expected "secure" to be a boolean, got ${secure}`
+ );
+ }
+ }
+
+ #assertGetCookieFilter(filter) {
+ lazy.assert.object(
+ filter,
+ `Expected "filter" to be an object, got ${filter}`
+ );
+
+ const {
+ domain = null,
+ expiry = null,
+ httpOnly = null,
+ name = null,
+ path = null,
+ sameSite = null,
+ secure = null,
+ size = null,
+ value = null,
+ } = filter;
+
+ if (domain !== null) {
+ lazy.assert.string(
+ domain,
+ `Expected "filter.domain" to be a string, got ${domain}`
+ );
+ }
+
+ if (expiry !== null) {
+ lazy.assert.positiveInteger(
+ expiry,
+ `Expected "filter.expiry" to be a positive number, got ${expiry}`
+ );
+ }
+
+ if (httpOnly !== null) {
+ lazy.assert.boolean(
+ httpOnly,
+ `Expected "filter.httpOnly" to be a boolean, got ${httpOnly}`
+ );
+ }
+
+ if (name !== null) {
+ lazy.assert.string(
+ name,
+ `Expected "filter.name" to be a string, got ${name}`
+ );
+ }
+
+ if (path !== null) {
+ lazy.assert.string(
+ path,
+ `Expected "filter.path" to be a string, got ${path}`
+ );
+ }
+
+ this.#assertSameSite(sameSite, "filter.sameSite");
+
+ if (secure !== null) {
+ lazy.assert.boolean(
+ secure,
+ `Expected "filter.secure" to be a boolean, got ${secure}`
+ );
+ }
+
+ if (size !== null) {
+ lazy.assert.positiveInteger(
+ size,
+ `Expected "filter.size" to be a positive number, got ${size}`
+ );
+ }
+
+ if (value !== null) {
+ this.#assertValue(value, "filter.value");
+ }
+
+ return {
+ domain,
+ expiry,
+ httpOnly,
+ name,
+ path,
+ sameSite,
+ secure,
+ size,
+ value,
+ };
+ }
+
+ #assertPartition(partitionSpec) {
+ if (partitionSpec === null) {
+ return;
+ }
+ lazy.assert.object(
+ partitionSpec,
+ `Expected "partition" to be an object, got ${partitionSpec}`
+ );
+
+ const { type } = partitionSpec;
+ lazy.assert.string(
+ type,
+ `Expected "partition.type" to be a string, got ${type}`
+ );
+
+ switch (type) {
+ case PartitionType.Context: {
+ const { context } = partitionSpec;
+ lazy.assert.string(
+ context,
+ `Expected "partition.context" to be a string, got ${context}`
+ );
+
+ break;
+ }
+
+ case PartitionType.StorageKey: {
+ const { sourceOrigin = null, userContext = null } = partitionSpec;
+ if (sourceOrigin !== null) {
+ lazy.assert.string(
+ sourceOrigin,
+ `Expected "partition.sourceOrigin" to be a string, got ${sourceOrigin}`
+ );
+ lazy.assert.that(
+ sourceOrigin => URL.canParse(sourceOrigin),
+ `Expected "partition.sourceOrigin" to be a valid URL, got ${sourceOrigin}`
+ )(sourceOrigin);
+
+ const url = new URL(sourceOrigin);
+ lazy.assert.that(
+ url => url.pathname === "/" && url.hash === "" && url.search === "",
+ `Expected "partition.sourceOrigin" to contain only origin, got ${sourceOrigin}`
+ )(url);
+ }
+ if (userContext !== null) {
+ lazy.assert.string(
+ userContext,
+ `Expected "partition.userContext" to be a string, got ${userContext}`
+ );
+
+ // TODO: Bug 1875255. Implement support for "userContext" field.
+ throw new lazy.error.UnsupportedOperationError(
+ `"userContext" as a field on "partition" argument is not supported yet for "storage.getCookies" command`
+ );
+ }
+ break;
+ }
+
+ default: {
+ throw new lazy.error.InvalidArgumentError(
+ `Expected "partition.type" to be one of ${Object.values(
+ PartitionType
+ )}, got ${type}`
+ );
+ }
+ }
+ }
+
+ #assertSameSite(sameSite, fieldName = "sameSite") {
+ if (sameSite !== null) {
+ const sameSiteTypeValue = Object.values(SameSiteType);
+ lazy.assert.in(
+ sameSite,
+ sameSiteTypeValue,
+ `Expected "${fieldName}" to be one of ${sameSiteTypeValue}, got ${sameSite}`
+ );
+ }
+ }
+
+ #assertValue(value, fieldName = "value") {
+ lazy.assert.object(
+ value,
+ `Expected "${fieldName}" to be an object, got ${value}`
+ );
+
+ const { type, value: protocolBytesValue } = value;
+
+ const bytesValueTypeValue = Object.values(lazy.BytesValueType);
+ lazy.assert.in(
+ type,
+ bytesValueTypeValue,
+ `Expected "${fieldName}.type" to be one of ${bytesValueTypeValue}, got ${type}`
+ );
+
+ lazy.assert.string(
+ protocolBytesValue,
+ `Expected "${fieldName}.value" to be string, got ${protocolBytesValue}`
+ );
+ }
+
+ /**
+ * Deserialize the value to string, since platform API
+ * returns cookie's value as a string.
+ */
+ #deserializeProtocolBytes(cookieValue) {
+ const { type, value } = cookieValue;
+
+ if (type === lazy.BytesValueType.String) {
+ return value;
+ }
+
+ // For type === BytesValueType.Base64.
+ return atob(value);
+ }
+
+ /**
+ * Build a partition key.
+ *
+ * @see https://w3c.github.io/webdriver-bidi/#expand-a-storage-partition-spec
+ */
+ #expandStoragePartitionSpec(partitionSpec) {
+ if (partitionSpec === null) {
+ partitionSpec = {};
+ }
+
+ if (partitionSpec.type === PartitionType.Context) {
+ const { context: contextId } = partitionSpec;
+ const browsingContext = this.#getBrowsingContext(contextId);
+
+ // Define browsing context’s associated storage partition as combination of user context id
+ // and the origin of the document in this browsing context.
+ return {
+ sourceOrigin: browsingContext.currentURI.prePath,
+ userContext: browsingContext.originAttributes.userContextId,
+ };
+ }
+
+ const partitionKey = {};
+ for (const keyName of PartitionKeyAttributes) {
+ if (keyName in partitionSpec) {
+ partitionKey[keyName] = partitionSpec[keyName];
+ }
+ }
+
+ return partitionKey;
+ }
+
+ /**
+ * Retrieves a browsing context based on its id.
+ *
+ * @param {number} contextId
+ * Id of the browsing context.
+ * @returns {BrowsingContext}
+ * The browsing context.
+ * @throws {NoSuchFrameError}
+ * If the browsing context cannot be found.
+ */
+ #getBrowsingContext(contextId) {
+ const context = lazy.TabManager.getBrowsingContextById(contextId);
+ if (context === null) {
+ throw new lazy.error.NoSuchFrameError(
+ `Browsing Context with id ${contextId} not found`
+ );
+ }
+
+ return context;
+ }
+
+ /**
+ * Since cookies retrieved from the platform API
+ * always contain expiry even for session cookies,
+ * we should check ourselves if it's a session cookie
+ * and do not return expiry in case it is.
+ */
+ #getCookieExpiry(cookie) {
+ const { expiry, isSession } = cookie;
+ return isSession ? null : expiry;
+ }
+
+ #getCookieSize(cookie) {
+ const { name, value } = cookie;
+ return name.length + value.length;
+ }
+
+ /**
+ * Filter and serialize given cookies with provided filter.
+ *
+ * @see https://w3c.github.io/webdriver-bidi/#get-matching-cookies
+ */
+ #getMatchingCookies(cookieStore, filter) {
+ const cookies = [];
+
+ for (const storedCookie of cookieStore) {
+ const serializedCookie = this.#serializeCookie(storedCookie);
+ if (this.#matchCookie(serializedCookie, filter)) {
+ cookies.push(serializedCookie);
+ }
+ }
+ return cookies;
+ }
+
+ /**
+ * Prepare the data in the required for platform API format.
+ */
+ #getOriginAttributes(partitionKey) {
+ const originAttributes = {};
+
+ if (partitionKey.sourceOrigin) {
+ originAttributes.partitionKey = ChromeUtils.getPartitionKeyFromURL(
+ partitionKey.sourceOrigin
+ );
+ }
+ if ("userContext" in partitionKey) {
+ originAttributes.userContextId = partitionKey.userContext;
+ }
+
+ return originAttributes;
+ }
+
+ #getSameSitePlatformProperty(sameSite) {
+ switch (sameSite) {
+ case "lax": {
+ return Ci.nsICookie.SAMESITE_LAX;
+ }
+ case "strict": {
+ return Ci.nsICookie.SAMESITE_STRICT;
+ }
+ }
+
+ return Ci.nsICookie.SAMESITE_NONE;
+ }
+
+ /**
+ * Return a cookie store of the storage partition for a given storage partition key.
+ *
+ * The implementation differs here from the spec, since in gecko there is no
+ * direct way to get all the cookies for a given partition key.
+ *
+ * @see https://w3c.github.io/webdriver-bidi/#get-the-cookie-store
+ */
+ #getTheCookieStore(storagePartitionKey) {
+ let store = [];
+
+ // Prepare the data in the format required for the platform API.
+ const originAttributes = this.#getOriginAttributes(storagePartitionKey);
+ // In case we want to get the cookies for a certain `sourceOrigin`,
+ // we have to additionally specify `hostname`. When `sourceOrigin` is not present
+ // `hostname` will stay equal undefined.
+ let hostname;
+
+ // In case we want to get the cookies for a certain `sourceOrigin`,
+ // we have to separately retrieve cookies for a hostname built from `sourceOrigin`,
+ // and with `partitionKey` equal an empty string to retrieve the cookies that which were set
+ // by this hostname but without `partitionKey`, e.g. with `document.cookie`
+ if (storagePartitionKey.sourceOrigin) {
+ const url = new URL(storagePartitionKey.sourceOrigin);
+ hostname = url.hostname;
+
+ const principal = Services.scriptSecurityManager.createContentPrincipal(
+ Services.io.newURI(url),
+ {}
+ );
+ const isSecureProtocol = principal.isOriginPotentiallyTrustworthy;
+
+ // We want to keep `userContext` id here, if it's present,
+ // but set the `partitionKey` to an empty string.
+ const cookiesMatchingHostname =
+ Services.cookies.getCookiesWithOriginAttributes(
+ JSON.stringify({ ...originAttributes, partitionKey: "" }),
+ hostname
+ );
+
+ for (const cookie of cookiesMatchingHostname) {
+ // Ignore secure cookies for non-secure protocols.
+ if (cookie.isSecure && !isSecureProtocol) {
+ continue;
+ }
+ store.push(cookie);
+ }
+ }
+
+ // Add the cookies which exactly match a built partition attributes.
+ store = store.concat(
+ Services.cookies.getCookiesWithOriginAttributes(
+ JSON.stringify(originAttributes),
+ hostname
+ )
+ );
+
+ return store;
+ }
+
+ /**
+ * Match a provided cookie with provided filter.
+ *
+ * @see https://w3c.github.io/webdriver-bidi/#match-cookie
+ */
+ #matchCookie(storedCookie, filter) {
+ for (const [fieldName] of Object.entries(CookieFieldsMapping)) {
+ let value = filter[fieldName];
+ if (value !== null) {
+ let storedCookieValue = storedCookie[fieldName];
+
+ if (fieldName === "value") {
+ value = this.#deserializeProtocolBytes(value);
+ storedCookieValue = this.#deserializeProtocolBytes(storedCookieValue);
+ }
+
+ if (storedCookieValue !== value) {
+ return false;
+ }
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Serialize a cookie.
+ *
+ * @see https://w3c.github.io/webdriver-bidi/#serialize-cookie
+ */
+ #serializeCookie(storedCookie) {
+ const cookie = {};
+ for (const [serializedName, cookieName] of Object.entries(
+ CookieFieldsMapping
+ )) {
+ switch (serializedName) {
+ case "expiry": {
+ const expiry = this.#getCookieExpiry(storedCookie);
+ if (expiry !== null) {
+ cookie.expiry = expiry;
+ }
+ break;
+ }
+
+ case "sameSite":
+ cookie.sameSite = SameSiteType[storedCookie.sameSite];
+ break;
+
+ case "size":
+ cookie.size = this.#getCookieSize(storedCookie);
+ break;
+
+ case "value":
+ // Bug 1879309. Add support for non-UTF8 cookies,
+ // when a byte representation of value is available.
+ // For now, use a value field, which is returned as a string.
+ cookie.value = {
+ type: lazy.BytesValueType.String,
+ value: storedCookie.value,
+ };
+ break;
+
+ default:
+ cookie[serializedName] = storedCookie[cookieName];
+ }
+ }
+
+ return cookie;
+ }
+}
+
+export const storage = StorageModule;
diff --git a/remote/webdriver-bidi/modules/windowglobal-in-root/browsingContext.sys.mjs b/remote/webdriver-bidi/modules/windowglobal-in-root/browsingContext.sys.mjs
new file mode 100644
index 0000000000..45cee66eb3
--- /dev/null
+++ b/remote/webdriver-bidi/modules/windowglobal-in-root/browsingContext.sys.mjs
@@ -0,0 +1,43 @@
+/* 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 { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ TabManager: "chrome://remote/content/shared/TabManager.sys.mjs",
+});
+
+class BrowsingContextModule extends Module {
+ destroy() {}
+
+ interceptEvent(name, payload) {
+ if (
+ name == "browsingContext.domContentLoaded" ||
+ name == "browsingContext.load"
+ ) {
+ const browsingContext = payload.context;
+ if (!lazy.TabManager.isValidCanonicalBrowsingContext(browsingContext)) {
+ // Discard events for invalid browsing contexts.
+ return null;
+ }
+
+ // Resolve browsing context to a Navigable id.
+ payload.context =
+ lazy.TabManager.getIdForBrowsingContext(browsingContext);
+
+ // Resolve navigation id.
+ const navigation =
+ this.messageHandler.navigationManager.getNavigationForBrowsingContext(
+ browsingContext
+ );
+ payload.navigation = navigation ? navigation.navigationId : null;
+ }
+
+ return payload;
+ }
+}
+
+export const browsingContext = BrowsingContextModule;
diff --git a/remote/webdriver-bidi/modules/windowglobal-in-root/log.sys.mjs b/remote/webdriver-bidi/modules/windowglobal-in-root/log.sys.mjs
new file mode 100644
index 0000000000..4cfbc61e63
--- /dev/null
+++ b/remote/webdriver-bidi/modules/windowglobal-in-root/log.sys.mjs
@@ -0,0 +1,37 @@
+/* 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 { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ processExtraData:
+ "chrome://remote/content/webdriver-bidi/modules/Intercept.sys.mjs",
+ TabManager: "chrome://remote/content/shared/TabManager.sys.mjs",
+});
+
+class LogModule extends Module {
+ destroy() {}
+
+ interceptEvent(name, payload) {
+ if (name == "log.entryAdded") {
+ const browsingContext = payload.source.context;
+ if (!lazy.TabManager.isValidCanonicalBrowsingContext(browsingContext)) {
+ // Discard events for invalid browsing contexts.
+ return null;
+ }
+
+ // Resolve browsing context to a Navigable id.
+ payload.source.context =
+ lazy.TabManager.getIdForBrowsingContext(browsingContext);
+
+ payload = lazy.processExtraData(this.messageHandler.sessionId, payload);
+ }
+
+ return payload;
+ }
+}
+
+export const log = LogModule;
diff --git a/remote/webdriver-bidi/modules/windowglobal-in-root/script.sys.mjs b/remote/webdriver-bidi/modules/windowglobal-in-root/script.sys.mjs
new file mode 100644
index 0000000000..30af29dd21
--- /dev/null
+++ b/remote/webdriver-bidi/modules/windowglobal-in-root/script.sys.mjs
@@ -0,0 +1,37 @@
+/* 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 { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ processExtraData:
+ "chrome://remote/content/webdriver-bidi/modules/Intercept.sys.mjs",
+ TabManager: "chrome://remote/content/shared/TabManager.sys.mjs",
+});
+
+class ScriptModule extends Module {
+ destroy() {}
+
+ interceptEvent(name, payload) {
+ if (name == "script.message") {
+ const browsingContext = payload.source.context;
+ if (!lazy.TabManager.isValidCanonicalBrowsingContext(browsingContext)) {
+ // Discard events for invalid browsing contexts.
+ return null;
+ }
+
+ // Resolve browsing context to a Navigable id.
+ payload.source.context =
+ lazy.TabManager.getIdForBrowsingContext(browsingContext);
+
+ payload = lazy.processExtraData(this.messageHandler.sessionId, payload);
+ }
+
+ return payload;
+ }
+}
+
+export const script = ScriptModule;
diff --git a/remote/webdriver-bidi/modules/windowglobal/browsingContext.sys.mjs b/remote/webdriver-bidi/modules/windowglobal/browsingContext.sys.mjs
new file mode 100644
index 0000000000..8421445d2c
--- /dev/null
+++ b/remote/webdriver-bidi/modules/windowglobal/browsingContext.sys.mjs
@@ -0,0 +1,475 @@
+/* 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 { WindowGlobalBiDiModule } from "chrome://remote/content/webdriver-bidi/modules/WindowGlobalBiDiModule.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AnimationFramePromise: "chrome://remote/content/shared/Sync.sys.mjs",
+ assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs",
+ ClipRectangleType:
+ "chrome://remote/content/webdriver-bidi/modules/root/browsingContext.sys.mjs",
+ error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
+ LoadListener: "chrome://remote/content/shared/listeners/LoadListener.sys.mjs",
+ LocatorType:
+ "chrome://remote/content/webdriver-bidi/modules/root/browsingContext.sys.mjs",
+ OriginType:
+ "chrome://remote/content/webdriver-bidi/modules/root/browsingContext.sys.mjs",
+});
+
+const DOCUMENT_FRAGMENT_NODE = 11;
+const DOCUMENT_NODE = 9;
+const ELEMENT_NODE = 1;
+
+const ORDERED_NODE_SNAPSHOT_TYPE = 7;
+
+class BrowsingContextModule extends WindowGlobalBiDiModule {
+ #loadListener;
+ #subscribedEvents;
+
+ constructor(messageHandler) {
+ super(messageHandler);
+
+ // Setup the LoadListener as early as possible.
+ this.#loadListener = new lazy.LoadListener(this.messageHandler.window);
+ this.#loadListener.on("DOMContentLoaded", this.#onDOMContentLoaded);
+ this.#loadListener.on("load", this.#onLoad);
+
+ // Set of event names which have active subscriptions.
+ this.#subscribedEvents = new Set();
+ }
+
+ destroy() {
+ this.#loadListener.destroy();
+ this.#subscribedEvents = null;
+ }
+
+ #getNavigationInfo(data) {
+ // Note: the navigation id is collected in the parent-process and will be
+ // added via event interception by the windowglobal-in-root module.
+ return {
+ context: this.messageHandler.context,
+ timestamp: Date.now(),
+ url: data.target.URL,
+ };
+ }
+
+ #getOriginRectangle(origin) {
+ const win = this.messageHandler.window;
+
+ if (origin === lazy.OriginType.viewport) {
+ const viewport = win.visualViewport;
+ // Until it's clarified in the scope of the issue:
+ // https://github.com/w3c/webdriver-bidi/issues/592
+ // if we should take into account scrollbar dimensions, when calculating
+ // the viewport size, we match the behavior of WebDriver Classic,
+ // meaning we include scrollbar dimensions.
+ return new DOMRect(
+ viewport.pageLeft,
+ viewport.pageTop,
+ win.innerWidth,
+ win.innerHeight
+ );
+ }
+
+ const documentElement = win.document.documentElement;
+ return new DOMRect(
+ 0,
+ 0,
+ documentElement.scrollWidth,
+ documentElement.scrollHeight
+ );
+ }
+
+ #startListening() {
+ if (this.#subscribedEvents.size == 0) {
+ this.#loadListener.startListening();
+ }
+ }
+
+ #stopListening() {
+ if (this.#subscribedEvents.size == 0) {
+ this.#loadListener.stopListening();
+ }
+ }
+
+ #subscribeEvent(event) {
+ switch (event) {
+ case "browsingContext._documentInteractive":
+ this.#startListening();
+ this.#subscribedEvents.add("browsingContext._documentInteractive");
+ break;
+ case "browsingContext.domContentLoaded":
+ this.#startListening();
+ this.#subscribedEvents.add("browsingContext.domContentLoaded");
+ break;
+ case "browsingContext.load":
+ this.#startListening();
+ this.#subscribedEvents.add("browsingContext.load");
+ break;
+ }
+ }
+
+ #unsubscribeEvent(event) {
+ switch (event) {
+ case "browsingContext._documentInteractive":
+ this.#subscribedEvents.delete("browsingContext._documentInteractive");
+ break;
+ case "browsingContext.domContentLoaded":
+ this.#subscribedEvents.delete("browsingContext.domContentLoaded");
+ break;
+ case "browsingContext.load":
+ this.#subscribedEvents.delete("browsingContext.load");
+ break;
+ }
+
+ this.#stopListening();
+ }
+
+ #onDOMContentLoaded = (eventName, data) => {
+ if (this.#subscribedEvents.has("browsingContext._documentInteractive")) {
+ this.messageHandler.emitEvent("browsingContext._documentInteractive", {
+ baseURL: data.target.baseURI,
+ contextId: this.messageHandler.contextId,
+ documentURL: data.target.URL,
+ innerWindowId: this.messageHandler.innerWindowId,
+ readyState: data.target.readyState,
+ });
+ }
+
+ if (this.#subscribedEvents.has("browsingContext.domContentLoaded")) {
+ this.emitEvent(
+ "browsingContext.domContentLoaded",
+ this.#getNavigationInfo(data)
+ );
+ }
+ };
+
+ #onLoad = (eventName, data) => {
+ if (this.#subscribedEvents.has("browsingContext.load")) {
+ this.emitEvent("browsingContext.load", this.#getNavigationInfo(data));
+ }
+ };
+
+ /**
+ * Locate nodes using css selector.
+ *
+ * @see https://w3c.github.io/webdriver-bidi/#locate-nodes-using-css
+ */
+ #locateNodesUsingCss(contextNodes, selector, maxReturnedNodeCount) {
+ const returnedNodes = [];
+
+ for (const contextNode of contextNodes) {
+ let elements;
+ try {
+ elements = contextNode.querySelectorAll(selector);
+ } catch (e) {
+ throw new lazy.error.InvalidSelectorError(
+ `${e.message}: "${selector}"`
+ );
+ }
+
+ if (maxReturnedNodeCount === null) {
+ returnedNodes.push(...elements);
+ } else {
+ for (const element of elements) {
+ returnedNodes.push(element);
+
+ if (returnedNodes.length === maxReturnedNodeCount) {
+ return returnedNodes;
+ }
+ }
+ }
+ }
+
+ return returnedNodes;
+ }
+
+ /**
+ * Locate nodes using XPath.
+ *
+ * @see https://w3c.github.io/webdriver-bidi/#locate-nodes-using-xpath
+ */
+ #locateNodesUsingXPath(contextNodes, selector, maxReturnedNodeCount) {
+ const returnedNodes = [];
+
+ for (const contextNode of contextNodes) {
+ let evaluationResult;
+ try {
+ evaluationResult = this.messageHandler.window.document.evaluate(
+ selector,
+ contextNode,
+ null,
+ ORDERED_NODE_SNAPSHOT_TYPE,
+ null
+ );
+ } catch (e) {
+ const errorMessage = `${e.message}: "${selector}"`;
+ if (DOMException.isInstance(e) && e.name === "SyntaxError") {
+ throw new lazy.error.InvalidSelectorError(errorMessage);
+ }
+
+ throw new lazy.error.UnknownError(errorMessage);
+ }
+
+ for (let index = 0; index < evaluationResult.snapshotLength; index++) {
+ const node = evaluationResult.snapshotItem(index);
+ returnedNodes.push(node);
+
+ if (
+ maxReturnedNodeCount !== null &&
+ returnedNodes.length === maxReturnedNodeCount
+ ) {
+ return returnedNodes;
+ }
+ }
+ }
+
+ return returnedNodes;
+ }
+
+ /**
+ * Normalize rectangle. This ensures that the resulting rect has
+ * positive width and height dimensions.
+ *
+ * @see https://w3c.github.io/webdriver-bidi/#normalise-rect
+ *
+ * @param {DOMRect} rect
+ * An object which describes the size and position of a rectangle.
+ *
+ * @returns {DOMRect} Normalized rectangle.
+ */
+ #normalizeRect(rect) {
+ let { x, y, width, height } = rect;
+
+ if (width < 0) {
+ x += width;
+ width = -width;
+ }
+
+ if (height < 0) {
+ y += height;
+ height = -height;
+ }
+
+ return new DOMRect(x, y, width, height);
+ }
+
+ /**
+ * Create a new rectangle which will be an intersection of
+ * rectangles specified as arguments.
+ *
+ * @see https://w3c.github.io/webdriver-bidi/#rectangle-intersection
+ *
+ * @param {DOMRect} rect1
+ * An object which describes the size and position of a rectangle.
+ * @param {DOMRect} rect2
+ * An object which describes the size and position of a rectangle.
+ *
+ * @returns {DOMRect} Rectangle, representing an intersection of <var>rect1</var> and <var>rect2</var>.
+ */
+ #rectangleIntersection(rect1, rect2) {
+ rect1 = this.#normalizeRect(rect1);
+ rect2 = this.#normalizeRect(rect2);
+
+ const x_min = Math.max(rect1.x, rect2.x);
+ const x_max = Math.min(rect1.x + rect1.width, rect2.x + rect2.width);
+
+ const y_min = Math.max(rect1.y, rect2.y);
+ const y_max = Math.min(rect1.y + rect1.height, rect2.y + rect2.height);
+
+ const width = Math.max(x_max - x_min, 0);
+ const height = Math.max(y_max - y_min, 0);
+
+ return new DOMRect(x_min, y_min, width, height);
+ }
+
+ /**
+ * Internal commands
+ */
+
+ _applySessionData(params) {
+ // TODO: Bug 1775231. Move this logic to a shared module or an abstract
+ // class.
+ const { category } = params;
+ if (category === "event") {
+ const filteredSessionData = params.sessionData.filter(item =>
+ this.messageHandler.matchesContext(item.contextDescriptor)
+ );
+ for (const event of this.#subscribedEvents.values()) {
+ const hasSessionItem = filteredSessionData.some(
+ item => item.value === event
+ );
+ // If there are no session items for this context, we should unsubscribe from the event.
+ if (!hasSessionItem) {
+ this.#unsubscribeEvent(event);
+ }
+ }
+
+ // Subscribe to all events, which have an item in SessionData.
+ for (const { value } of filteredSessionData) {
+ this.#subscribeEvent(value);
+ }
+ }
+ }
+
+ /**
+ * Waits until the viewport has reached the new dimensions.
+ *
+ * @param {object} options
+ * @param {number} options.height
+ * Expected height the viewport will resize to.
+ * @param {number} options.width
+ * Expected width the viewport will resize to.
+ *
+ * @returns {Promise}
+ * Promise that resolves when the viewport has been resized.
+ */
+ async _awaitViewportDimensions(options) {
+ const { height, width } = options;
+
+ const win = this.messageHandler.window;
+ let resized;
+
+ // Updates for background tabs are throttled, and we also have to make
+ // sure that the new browser dimensions have been received by the content
+ // process. As such wait for the next animation frame.
+ await lazy.AnimationFramePromise(win);
+
+ const checkBrowserSize = () => {
+ if (win.innerWidth === width && win.innerHeight === height) {
+ resized();
+ }
+ };
+
+ return new Promise(resolve => {
+ resized = resolve;
+
+ win.addEventListener("resize", checkBrowserSize);
+
+ // Trigger a layout flush in case none happened yet.
+ checkBrowserSize();
+ }).finally(() => {
+ win.removeEventListener("resize", checkBrowserSize);
+ });
+ }
+
+ _getBaseURL() {
+ return this.messageHandler.window.document.baseURI;
+ }
+
+ _getScreenshotRect(params = {}) {
+ const { clip, origin } = params;
+
+ const originRect = this.#getOriginRectangle(origin);
+ let clipRect = originRect;
+
+ if (clip !== null) {
+ switch (clip.type) {
+ case lazy.ClipRectangleType.Box: {
+ clipRect = new DOMRect(
+ clip.x + originRect.x,
+ clip.y + originRect.y,
+ clip.width,
+ clip.height
+ );
+ break;
+ }
+
+ case lazy.ClipRectangleType.Element: {
+ const realm = this.messageHandler.getRealm();
+ const element = this.deserialize(clip.element, realm);
+ const viewportRect = this.#getOriginRectangle(
+ lazy.OriginType.viewport
+ );
+ const elementRect = element.getBoundingClientRect();
+
+ clipRect = new DOMRect(
+ elementRect.x + viewportRect.x,
+ elementRect.y + viewportRect.y,
+ elementRect.width,
+ elementRect.height
+ );
+ break;
+ }
+ }
+ }
+
+ return this.#rectangleIntersection(originRect, clipRect);
+ }
+
+ _locateNodes(params = {}) {
+ const {
+ locator,
+ maxNodeCount,
+ resultOwnership,
+ sandbox,
+ serializationOptions,
+ startNodes,
+ } = params;
+
+ const realm = this.messageHandler.getRealm({ sandboxName: sandbox });
+
+ const contextNodes = [];
+ if (startNodes === null) {
+ contextNodes.push(this.messageHandler.window.document.documentElement);
+ } else {
+ for (const serializedStartNode of startNodes) {
+ const startNode = this.deserialize(serializedStartNode, realm);
+ lazy.assert.that(
+ startNode =>
+ Node.isInstance(startNode) &&
+ [DOCUMENT_FRAGMENT_NODE, DOCUMENT_NODE, ELEMENT_NODE].includes(
+ startNode.nodeType
+ ),
+ `Expected an item of "startNodes" to be an Element, got ${startNode}`
+ )(startNode);
+
+ contextNodes.push(startNode);
+ }
+ }
+
+ let returnedNodes;
+ switch (locator.type) {
+ case lazy.LocatorType.css: {
+ returnedNodes = this.#locateNodesUsingCss(
+ contextNodes,
+ locator.value,
+ maxNodeCount
+ );
+ break;
+ }
+ case lazy.LocatorType.xpath: {
+ returnedNodes = this.#locateNodesUsingXPath(
+ contextNodes,
+ locator.value,
+ maxNodeCount
+ );
+ break;
+ }
+ }
+
+ const serializedNodes = [];
+ const seenNodeIds = new Map();
+ for (const returnedNode of returnedNodes) {
+ serializedNodes.push(
+ this.serialize(
+ returnedNode,
+ serializationOptions,
+ resultOwnership,
+ realm,
+ { seenNodeIds }
+ )
+ );
+ }
+
+ return {
+ serializedNodes,
+ _extraData: { seenNodeIds },
+ };
+ }
+}
+
+export const browsingContext = BrowsingContextModule;
diff --git a/remote/webdriver-bidi/modules/windowglobal/input.sys.mjs b/remote/webdriver-bidi/modules/windowglobal/input.sys.mjs
new file mode 100644
index 0000000000..099cf53d46
--- /dev/null
+++ b/remote/webdriver-bidi/modules/windowglobal/input.sys.mjs
@@ -0,0 +1,111 @@
+/* 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 { WindowGlobalBiDiModule } from "chrome://remote/content/webdriver-bidi/modules/WindowGlobalBiDiModule.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ action: "chrome://remote/content/shared/webdriver/Actions.sys.mjs",
+ dom: "chrome://remote/content/shared/DOM.sys.mjs",
+ error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
+});
+
+class InputModule extends WindowGlobalBiDiModule {
+ #actionState;
+
+ constructor(messageHandler) {
+ super(messageHandler);
+
+ this.#actionState = null;
+ }
+
+ destroy() {}
+
+ async performActions(options) {
+ const { actions } = options;
+ if (this.#actionState === null) {
+ this.#actionState = new lazy.action.State();
+ }
+
+ await this.#deserializeActionOrigins(actions);
+ const actionChain = lazy.action.Chain.fromJSON(this.#actionState, actions);
+
+ await actionChain.dispatch(this.#actionState, this.messageHandler.window);
+ }
+
+ async releaseActions() {
+ if (this.#actionState === null) {
+ return;
+ }
+ await this.#actionState.release(this.messageHandler.window);
+ this.#actionState = null;
+ }
+
+ /**
+ * In the provided array of input.SourceActions, replace all origins matching
+ * the input.ElementOrigin production with the Element corresponding to this
+ * origin.
+ *
+ * Note that this method replaces the content of the `actions` in place, and
+ * does not return a new array.
+ *
+ * @param {Array<input.SourceActions>} actions
+ * The array of SourceActions to deserialize.
+ * @returns {Promise}
+ * A promise which resolves when all ElementOrigin origins have been
+ * deserialized.
+ */
+ async #deserializeActionOrigins(actions) {
+ const promises = [];
+
+ if (!Array.isArray(actions)) {
+ // Silently ignore invalid action chains because they are fully parsed later.
+ return Promise.resolve();
+ }
+
+ for (const actionsByTick of actions) {
+ if (!Array.isArray(actionsByTick?.actions)) {
+ // Silently ignore invalid actions because they are fully parsed later.
+ return Promise.resolve();
+ }
+
+ for (const action of actionsByTick.actions) {
+ if (action?.origin?.type === "element") {
+ promises.push(
+ (async () => {
+ action.origin = await this.#getElementFromElementOrigin(
+ action.origin
+ );
+ })()
+ );
+ }
+ }
+ }
+
+ return Promise.all(promises);
+ }
+
+ async #getElementFromElementOrigin(origin) {
+ const sharedReference = origin.element;
+ if (typeof sharedReference?.sharedId !== "string") {
+ throw new lazy.error.InvalidArgumentError(
+ `Expected "origin.element" to be a SharedReference, got: ${sharedReference}`
+ );
+ }
+
+ const realm = this.messageHandler.getRealm();
+
+ const element = this.deserialize(sharedReference, realm);
+ if (!lazy.dom.isElement(element)) {
+ throw new lazy.error.NoSuchElementError(
+ `No element found for shared id: ${sharedReference.sharedId}`
+ );
+ }
+
+ return element;
+ }
+}
+
+export const input = InputModule;
diff --git a/remote/webdriver-bidi/modules/windowglobal/log.sys.mjs b/remote/webdriver-bidi/modules/windowglobal/log.sys.mjs
new file mode 100644
index 0000000000..9f3934c1bd
--- /dev/null
+++ b/remote/webdriver-bidi/modules/windowglobal/log.sys.mjs
@@ -0,0 +1,256 @@
+/* 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 { WindowGlobalBiDiModule } from "chrome://remote/content/webdriver-bidi/modules/WindowGlobalBiDiModule.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ConsoleAPIListener:
+ "chrome://remote/content/shared/listeners/ConsoleAPIListener.sys.mjs",
+ ConsoleListener:
+ "chrome://remote/content/shared/listeners/ConsoleListener.sys.mjs",
+ isChromeFrame: "chrome://remote/content/shared/Stack.sys.mjs",
+ OwnershipModel: "chrome://remote/content/webdriver-bidi/RemoteValue.sys.mjs",
+ setDefaultSerializationOptions:
+ "chrome://remote/content/webdriver-bidi/RemoteValue.sys.mjs",
+});
+
+class LogModule extends WindowGlobalBiDiModule {
+ #consoleAPIListener;
+ #consoleMessageListener;
+ #subscribedEvents;
+
+ constructor(messageHandler) {
+ super(messageHandler);
+
+ // Create the console-api listener and listen on "message" events.
+ this.#consoleAPIListener = new lazy.ConsoleAPIListener(
+ this.messageHandler.innerWindowId
+ );
+ this.#consoleAPIListener.on("message", this.#onConsoleAPIMessage);
+
+ // Create the console listener and listen on error messages.
+ this.#consoleMessageListener = new lazy.ConsoleListener(
+ this.messageHandler.innerWindowId
+ );
+ this.#consoleMessageListener.on("error", this.#onJavaScriptError);
+
+ // Set of event names which have active subscriptions.
+ this.#subscribedEvents = new Set();
+ }
+
+ destroy() {
+ this.#consoleAPIListener.off("message", this.#onConsoleAPIMessage);
+ this.#consoleAPIListener.destroy();
+ this.#consoleMessageListener.off("error", this.#onJavaScriptError);
+ this.#consoleMessageListener.destroy();
+
+ this.#subscribedEvents = null;
+ }
+
+ #buildSource(realm) {
+ return {
+ realm: realm.id,
+ context: this.messageHandler.context,
+ };
+ }
+
+ /**
+ * Map the internal stacktrace representation to a WebDriver BiDi
+ * compatible one.
+ *
+ * Currently chrome frames will be filtered out until chrome scope
+ * is supported (bug 1722679).
+ *
+ * @param {Array<StackFrame>=} stackTrace
+ * Stack frames to process.
+ *
+ * @returns {object=} Object, containing the list of frames as `callFrames`.
+ */
+ #buildStackTrace(stackTrace) {
+ if (stackTrace == undefined) {
+ return undefined;
+ }
+
+ const callFrames = stackTrace
+ .filter(frame => !lazy.isChromeFrame(frame))
+ .map(frame => {
+ return {
+ columnNumber: frame.columnNumber - 1,
+ functionName: frame.functionName,
+ lineNumber: frame.lineNumber - 1,
+ url: frame.filename,
+ };
+ });
+
+ return { callFrames };
+ }
+
+ #getLogEntryLevelFromConsoleMethod(method) {
+ switch (method) {
+ case "assert":
+ case "error":
+ return "error";
+ case "debug":
+ case "trace":
+ return "debug";
+ case "warn":
+ return "warn";
+ default:
+ return "info";
+ }
+ }
+
+ #onConsoleAPIMessage = (eventName, data = {}) => {
+ const {
+ // `arguments` cannot be used as variable name in functions
+ arguments: messageArguments,
+ // `level` corresponds to the console method used
+ level: method,
+ stacktrace,
+ timeStamp,
+ } = data;
+
+ // Step numbers below refer to the specifications at
+ // https://w3c.github.io/webdriver-bidi/#event-log-entryAdded
+
+ // Translate the console message method to a log.LogEntry level
+ const logEntrylevel = this.#getLogEntryLevelFromConsoleMethod(method);
+
+ // Use the message's timeStamp or fallback on the current time value.
+ const timestamp = timeStamp || Date.now();
+
+ // Start assembling the text representation of the message.
+ let text = "";
+
+ // Formatters have already been applied at this points.
+ // message.arguments corresponds to the "formatted args" from the
+ // specifications.
+
+ // Concatenate all formatted arguments in text
+ // TODO: For m1 we only support string arguments, so we rely on the builtin
+ // toString for each argument which will be available in message.arguments.
+ const args = messageArguments || [];
+ text += args.map(String).join(" ");
+
+ const defaultRealm = this.messageHandler.getRealm();
+ const serializedArgs = [];
+ const seenNodeIds = new Map();
+
+ // Serialize each arg as remote value.
+ for (const arg of args) {
+ // Note that we can pass a default realm for now since realms are only
+ // involved when creating object references, which will not happen with
+ // OwnershipModel.None. This will be revisited in Bug 1742589.
+ serializedArgs.push(
+ this.serialize(
+ Cu.waiveXrays(arg),
+ lazy.setDefaultSerializationOptions(),
+ lazy.OwnershipModel.None,
+ defaultRealm,
+ { seenNodeIds }
+ )
+ );
+ }
+
+ // Set source to an object which contains realm and browsing context.
+ // TODO: Bug 1742589. Use an actual realm from which the event came from.
+ const source = this.#buildSource(defaultRealm);
+
+ // Set stack trace only for certain methods.
+ let stackTrace;
+ if (["assert", "error", "trace", "warn"].includes(method)) {
+ stackTrace = this.#buildStackTrace(stacktrace);
+ }
+
+ // Build the ConsoleLogEntry
+ const entry = {
+ type: "console",
+ method,
+ source,
+ args: serializedArgs,
+ level: logEntrylevel,
+ text,
+ timestamp,
+ stackTrace,
+ _extraData: { seenNodeIds },
+ };
+
+ // TODO: Those steps relate to:
+ // - emitting associated BrowsingContext. See log.entryAdded full support
+ // in https://bugzilla.mozilla.org/show_bug.cgi?id=1724669#c0
+ // - handling cases where session doesn't exist or the event is not
+ // monitored. The implementation differs from the spec here because we
+ // only react to events if there is a session & if the session subscribed
+ // to those events.
+
+ this.emitEvent("log.entryAdded", entry);
+ };
+
+ #onJavaScriptError = (eventName, data = {}) => {
+ const { level, message, stacktrace, timeStamp } = data;
+ const defaultRealm = this.messageHandler.getRealm();
+
+ // Build the JavascriptLogEntry
+ const entry = {
+ type: "javascript",
+ level,
+ // TODO: Bug 1742589. Use an actual realm from which the event came from.
+ source: this.#buildSource(defaultRealm),
+ text: message,
+ timestamp: timeStamp || Date.now(),
+ stackTrace: this.#buildStackTrace(stacktrace),
+ };
+
+ this.emitEvent("log.entryAdded", entry);
+ };
+
+ #subscribeEvent(event) {
+ if (event === "log.entryAdded") {
+ this.#consoleAPIListener.startListening();
+ this.#consoleMessageListener.startListening();
+ this.#subscribedEvents.add(event);
+ }
+ }
+
+ #unsubscribeEvent(event) {
+ if (event === "log.entryAdded") {
+ this.#consoleAPIListener.stopListening();
+ this.#consoleMessageListener.stopListening();
+ this.#subscribedEvents.delete(event);
+ }
+ }
+
+ /**
+ * Internal commands
+ */
+
+ _applySessionData(params) {
+ // TODO: Bug 1775231. Move this logic to a shared module or an abstract
+ // class.
+ const { category } = params;
+ if (category === "event") {
+ const filteredSessionData = params.sessionData.filter(item =>
+ this.messageHandler.matchesContext(item.contextDescriptor)
+ );
+ for (const event of this.#subscribedEvents.values()) {
+ const hasSessionItem = filteredSessionData.some(
+ item => item.value === event
+ );
+ // If there are no session items for this context, we should unsubscribe from the event.
+ if (!hasSessionItem) {
+ this.#unsubscribeEvent(event);
+ }
+ }
+
+ // Subscribe to all events, which have an item in SessionData.
+ for (const { value } of filteredSessionData) {
+ this.#subscribeEvent(value);
+ }
+ }
+ }
+}
+
+export const log = LogModule;
diff --git a/remote/webdriver-bidi/modules/windowglobal/script.sys.mjs b/remote/webdriver-bidi/modules/windowglobal/script.sys.mjs
new file mode 100644
index 0000000000..e0f9542bdd
--- /dev/null
+++ b/remote/webdriver-bidi/modules/windowglobal/script.sys.mjs
@@ -0,0 +1,493 @@
+/* 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 { WindowGlobalBiDiModule } from "chrome://remote/content/webdriver-bidi/modules/WindowGlobalBiDiModule.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
+ getFramesFromStack: "chrome://remote/content/shared/Stack.sys.mjs",
+ isChromeFrame: "chrome://remote/content/shared/Stack.sys.mjs",
+ OwnershipModel: "chrome://remote/content/webdriver-bidi/RemoteValue.sys.mjs",
+ setDefaultSerializationOptions:
+ "chrome://remote/content/webdriver-bidi/RemoteValue.sys.mjs",
+ stringify: "chrome://remote/content/webdriver-bidi/RemoteValue.sys.mjs",
+});
+
+/**
+ * @typedef {string} EvaluationStatus
+ */
+
+/**
+ * Enum of possible evaluation states.
+ *
+ * @readonly
+ * @enum {EvaluationStatus}
+ */
+const EvaluationStatus = {
+ Normal: "normal",
+ Throw: "throw",
+};
+
+class ScriptModule extends WindowGlobalBiDiModule {
+ #observerListening;
+ #preloadScripts;
+
+ constructor(messageHandler) {
+ super(messageHandler);
+
+ // Set of structs with an item named expression, which is a string,
+ // and an item named sandbox which is a string or null.
+ this.#preloadScripts = new Set();
+ }
+
+ destroy() {
+ this.#preloadScripts = null;
+
+ this.#stopObserving();
+ }
+
+ observe(subject, topic) {
+ if (topic !== "document-element-inserted") {
+ return;
+ }
+
+ const window = subject?.defaultView;
+
+ // Ignore events without a window and those from other tabs.
+ if (window === this.messageHandler.window) {
+ this.#evaluatePreloadScripts();
+ }
+ }
+
+ #buildExceptionDetails(
+ exception,
+ stack,
+ realm,
+ resultOwnership,
+ seenNodeIds
+ ) {
+ exception = this.#toRawObject(exception);
+
+ // A stacktrace is mandatory to build exception details and a missing stack
+ // means we encountered an unexpected issue. Throw with an explicit error.
+ if (!stack) {
+ throw new Error(
+ `Missing stack, unable to build exceptionDetails for exception: ${lazy.stringify(
+ exception
+ )}`
+ );
+ }
+
+ const frames = lazy.getFramesFromStack(stack) || [];
+ const callFrames = frames
+ // Remove chrome/internal frames
+ .filter(frame => !lazy.isChromeFrame(frame))
+ // Translate frames from getFramesFromStack to frames expected by
+ // WebDriver BiDi.
+ .map(frame => {
+ return {
+ columnNumber: frame.columnNumber - 1,
+ functionName: frame.functionName,
+ lineNumber: frame.lineNumber - 1,
+ url: frame.filename,
+ };
+ });
+
+ return {
+ columnNumber: stack.column - 1,
+ exception: this.serialize(
+ exception,
+ lazy.setDefaultSerializationOptions(),
+ resultOwnership,
+ realm,
+ { seenNodeIds }
+ ),
+ lineNumber: stack.line - 1,
+ stackTrace: { callFrames },
+ text: lazy.stringify(exception),
+ };
+ }
+
+ async #buildReturnValue(
+ rv,
+ realm,
+ awaitPromise,
+ resultOwnership,
+ serializationOptions
+ ) {
+ let evaluationStatus, exception, result, stack;
+
+ if ("return" in rv) {
+ evaluationStatus = EvaluationStatus.Normal;
+ if (
+ awaitPromise &&
+ // Only non-primitive return values are wrapped in Debugger.Object.
+ rv.return instanceof Debugger.Object &&
+ rv.return.isPromise
+ ) {
+ try {
+ // Force wrapping the promise resolution result in a Debugger.Object
+ // wrapper for consistency with the synchronous codepath.
+ const asyncResult = await rv.return.unsafeDereference();
+ result = realm.globalObjectReference.makeDebuggeeValue(asyncResult);
+ } catch (asyncException) {
+ evaluationStatus = EvaluationStatus.Throw;
+ exception =
+ realm.globalObjectReference.makeDebuggeeValue(asyncException);
+
+ // If the returned promise was rejected by calling its reject callback
+ // the stack will be available on promiseResolutionSite.
+ // Otherwise, (eg. rejected Promise chained with a then() call) we
+ // fallback on the promiseAllocationSite.
+ stack =
+ rv.return.promiseResolutionSite || rv.return.promiseAllocationSite;
+ }
+ } else {
+ // rv.return is a Debugger.Object or a primitive.
+ result = rv.return;
+ }
+ } else if ("throw" in rv) {
+ // rv.throw will be set if the evaluation synchronously failed, either if
+ // the script contains a syntax error or throws an exception.
+ evaluationStatus = EvaluationStatus.Throw;
+ exception = rv.throw;
+ stack = rv.stack;
+ }
+
+ const seenNodeIds = new Map();
+ switch (evaluationStatus) {
+ case EvaluationStatus.Normal:
+ const dataSuccess = this.serialize(
+ this.#toRawObject(result),
+ serializationOptions,
+ resultOwnership,
+ realm,
+ { seenNodeIds }
+ );
+
+ return {
+ evaluationStatus,
+ realmId: realm.id,
+ result: dataSuccess,
+ _extraData: { seenNodeIds },
+ };
+ case EvaluationStatus.Throw:
+ const dataThrow = this.#buildExceptionDetails(
+ exception,
+ stack,
+ realm,
+ resultOwnership,
+ seenNodeIds
+ );
+
+ return {
+ evaluationStatus,
+ exceptionDetails: dataThrow,
+ realmId: realm.id,
+ _extraData: { seenNodeIds },
+ };
+ default:
+ throw new lazy.error.UnsupportedOperationError(
+ `Unsupported completion value for expression evaluation`
+ );
+ }
+ }
+
+ /**
+ * Emit "script.message" event with provided data.
+ *
+ * @param {Realm} realm
+ * @param {ChannelProperties} channelProperties
+ * @param {RemoteValue} message
+ */
+ #emitScriptMessage = (realm, channelProperties, message) => {
+ const {
+ channel,
+ ownership: ownershipType = lazy.OwnershipModel.None,
+ serializationOptions,
+ } = channelProperties;
+
+ const seenNodeIds = new Map();
+ const data = this.serialize(
+ this.#toRawObject(message),
+ lazy.setDefaultSerializationOptions(serializationOptions),
+ ownershipType,
+ realm,
+ { seenNodeIds }
+ );
+
+ this.emitEvent("script.message", {
+ channel,
+ data,
+ source: this.#getSource(realm),
+ _extraData: { seenNodeIds },
+ });
+ };
+
+ #evaluatePreloadScripts() {
+ let resolveBlockerPromise;
+ const blockerPromise = new Promise(resolve => {
+ resolveBlockerPromise = resolve;
+ });
+
+ // Block script parsing.
+ this.messageHandler.window.document.blockParsing(blockerPromise);
+ for (const script of this.#preloadScripts.values()) {
+ const {
+ arguments: commandArguments,
+ functionDeclaration,
+ sandbox,
+ } = script;
+ const realm = this.messageHandler.getRealm({ sandboxName: sandbox });
+ const deserializedArguments = commandArguments.map(arg =>
+ this.deserialize(arg, realm, {
+ emitScriptMessage: this.#emitScriptMessage,
+ })
+ );
+ const rv = realm.executeInGlobalWithBindings(
+ functionDeclaration,
+ deserializedArguments
+ );
+
+ if ("throw" in rv) {
+ const exception = this.#toRawObject(rv.throw);
+ realm.reportError(lazy.stringify(exception), rv.stack);
+ }
+ }
+
+ // Continue script parsing.
+ resolveBlockerPromise();
+ }
+
+ #getSource(realm) {
+ return {
+ realm: realm.id,
+ context: this.messageHandler.context,
+ };
+ }
+
+ #startObserving() {
+ if (!this.#observerListening) {
+ Services.obs.addObserver(this, "document-element-inserted");
+ this.#observerListening = true;
+ }
+ }
+
+ #stopObserving() {
+ if (this.#observerListening) {
+ Services.obs.removeObserver(this, "document-element-inserted");
+ this.#observerListening = false;
+ }
+ }
+
+ #toRawObject(maybeDebuggerObject) {
+ if (maybeDebuggerObject instanceof Debugger.Object) {
+ // Retrieve the referent for the provided Debugger.object.
+ // See https://firefox-source-docs.mozilla.org/devtools-user/debugger-api/debugger.object/index.html
+ const rawObject = maybeDebuggerObject.unsafeDereference();
+
+ // TODO: Getters for Maps and Sets iterators return "Opaque" objects and
+ // are not iterable. RemoteValue.jsm' serializer should handle calling
+ // waiveXrays on Maps/Sets/... and then unwaiveXrays on entries but since
+ // we serialize with maxDepth=1, calling waiveXrays once on the root
+ // object allows to return correctly serialized values.
+ return Cu.waiveXrays(rawObject);
+ }
+
+ // If maybeDebuggerObject was not a Debugger.Object, it is a primitive value
+ // which can be used as is.
+ return maybeDebuggerObject;
+ }
+
+ /**
+ * Call a function in the current window global.
+ *
+ * @param {object} options
+ * @param {boolean} options.awaitPromise
+ * Determines if the command should wait for the return value of the
+ * expression to resolve, if this return value is a Promise.
+ * @param {Array<RemoteValue>=} options.commandArguments
+ * The arguments to pass to the function call.
+ * @param {string} options.functionDeclaration
+ * The body of the function to call.
+ * @param {string=} options.realmId
+ * The id of the realm.
+ * @param {OwnershipModel} options.resultOwnership
+ * The ownership model to use for the results of this evaluation.
+ * @param {string=} options.sandbox
+ * The name of the sandbox.
+ * @param {SerializationOptions=} options.serializationOptions
+ * An object which holds the information of how the result of evaluation
+ * in case of ECMAScript objects should be serialized.
+ * @param {RemoteValue=} options.thisParameter
+ * The value of the this keyword for the function call.
+ * @param {boolean=} options.userActivation
+ * Determines whether execution should be treated as initiated by user.
+ *
+ * @returns {object}
+ * - evaluationStatus {EvaluationStatus} One of "normal", "throw".
+ * - exceptionDetails {ExceptionDetails=} the details of the exception if
+ * the evaluation status was "throw".
+ * - result {RemoteValue=} the result of the evaluation serialized as a
+ * RemoteValue if the evaluation status was "normal".
+ */
+ async callFunctionDeclaration(options) {
+ const {
+ awaitPromise,
+ commandArguments = null,
+ functionDeclaration,
+ realmId = null,
+ resultOwnership,
+ sandbox: sandboxName = null,
+ serializationOptions,
+ thisParameter = null,
+ userActivation,
+ } = options;
+
+ const realm = this.messageHandler.getRealm({ realmId, sandboxName });
+
+ const deserializedArguments =
+ commandArguments !== null
+ ? commandArguments.map(arg =>
+ this.deserialize(arg, realm, {
+ emitScriptMessage: this.#emitScriptMessage,
+ })
+ )
+ : [];
+
+ const deserializedThis =
+ thisParameter !== null
+ ? this.deserialize(thisParameter, realm, {
+ emitScriptMessage: this.#emitScriptMessage,
+ })
+ : null;
+
+ realm.userActivationEnabled = userActivation;
+
+ const rv = realm.executeInGlobalWithBindings(
+ functionDeclaration,
+ deserializedArguments,
+ deserializedThis
+ );
+
+ return this.#buildReturnValue(
+ rv,
+ realm,
+ awaitPromise,
+ resultOwnership,
+ serializationOptions
+ );
+ }
+
+ /**
+ * Delete the provided handles from the realm corresponding to the provided
+ * sandbox name.
+ *
+ * @param {object=} options
+ * @param {Array<string>} options.handles
+ * Array of handle ids to disown.
+ * @param {string=} options.realmId
+ * The id of the realm.
+ * @param {string=} options.sandbox
+ * The name of the sandbox.
+ */
+ disownHandles(options) {
+ const { handles, realmId = null, sandbox: sandboxName = null } = options;
+ const realm = this.messageHandler.getRealm({ realmId, sandboxName });
+ for (const handle of handles) {
+ realm.removeObjectHandle(handle);
+ }
+ }
+
+ /**
+ * Evaluate a provided expression in the current window global.
+ *
+ * @param {object} options
+ * @param {boolean} options.awaitPromise
+ * Determines if the command should wait for the return value of the
+ * expression to resolve, if this return value is a Promise.
+ * @param {string} options.expression
+ * The expression to evaluate.
+ * @param {string=} options.realmId
+ * The id of the realm.
+ * @param {OwnershipModel} options.resultOwnership
+ * The ownership model to use for the results of this evaluation.
+ * @param {string=} options.sandbox
+ * The name of the sandbox.
+ * @param {boolean=} options.userActivation
+ * Determines whether execution should be treated as initiated by user.
+ *
+ * @returns {object}
+ * - evaluationStatus {EvaluationStatus} One of "normal", "throw".
+ * - exceptionDetails {ExceptionDetails=} the details of the exception if
+ * the evaluation status was "throw".
+ * - result {RemoteValue=} the result of the evaluation serialized as a
+ * RemoteValue if the evaluation status was "normal".
+ */
+ async evaluateExpression(options) {
+ const {
+ awaitPromise,
+ expression,
+ realmId = null,
+ resultOwnership,
+ sandbox: sandboxName = null,
+ serializationOptions,
+ userActivation,
+ } = options;
+
+ const realm = this.messageHandler.getRealm({ realmId, sandboxName });
+
+ realm.userActivationEnabled = userActivation;
+
+ const rv = realm.executeInGlobal(expression);
+
+ return this.#buildReturnValue(
+ rv,
+ realm,
+ awaitPromise,
+ resultOwnership,
+ serializationOptions
+ );
+ }
+
+ /**
+ * Get realms for the current window global.
+ *
+ * @returns {Array<object>}
+ * - context {BrowsingContext} The browsing context, associated with the realm.
+ * - origin {string} The serialization of an origin.
+ * - realm {string} The realm unique identifier.
+ * - sandbox {string=} The name of the sandbox.
+ * - type {RealmType.Window} The window realm type.
+ */
+ getWindowRealms() {
+ return Array.from(this.messageHandler.realms.values()).map(realm => {
+ const { context, origin, realm: id, sandbox, type } = realm.getInfo();
+ return { context, origin, realm: id, sandbox, type };
+ });
+ }
+
+ /**
+ * Internal commands
+ */
+
+ _applySessionData(params) {
+ if (params.category === "preload-script") {
+ this.#preloadScripts = new Set();
+ for (const item of params.sessionData) {
+ if (this.messageHandler.matchesContext(item.contextDescriptor)) {
+ this.#preloadScripts.add(item.value);
+ }
+ }
+
+ if (this.#preloadScripts.size) {
+ this.#startObserving();
+ }
+ }
+ }
+}
+
+export const script = ScriptModule;