summaryrefslogtreecommitdiffstats
path: root/remote/webdriver-bidi
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /remote/webdriver-bidi
parentInitial commit. (diff)
downloadfirefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz
firefox-26a029d407be480d791972afb5975cf62c9360a6.zip
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'remote/webdriver-bidi')
-rw-r--r--remote/webdriver-bidi/NewSessionHandler.sys.mjs57
-rw-r--r--remote/webdriver-bidi/RemoteValue.sys.mjs1045
-rw-r--r--remote/webdriver-bidi/WebDriverBiDi.sys.mjs240
-rw-r--r--remote/webdriver-bidi/WebDriverBiDiConnection.sys.mjs268
-rw-r--r--remote/webdriver-bidi/jar.mn37
-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
-rw-r--r--remote/webdriver-bidi/moz.build14
-rw-r--r--remote/webdriver-bidi/test/browser/browser.toml8
-rw-r--r--remote/webdriver-bidi/test/browser/browser_RemoteValue.js1117
-rw-r--r--remote/webdriver-bidi/test/browser/browser_RemoteValueDOM.js845
-rw-r--r--remote/webdriver-bidi/test/browser/head.js28
-rw-r--r--remote/webdriver-bidi/test/xpcshell/test_WebDriverBiDiConnection.js25
-rw-r--r--remote/webdriver-bidi/test/xpcshell/xpcshell.toml3
30 files changed, 11453 insertions, 0 deletions
diff --git a/remote/webdriver-bidi/NewSessionHandler.sys.mjs b/remote/webdriver-bidi/NewSessionHandler.sys.mjs
new file mode 100644
index 0000000000..342419033f
--- /dev/null
+++ b/remote/webdriver-bidi/NewSessionHandler.sys.mjs
@@ -0,0 +1,57 @@
+/* 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, {
+ WebDriverBiDiConnection:
+ "chrome://remote/content/webdriver-bidi/WebDriverBiDiConnection.sys.mjs",
+ WebSocketHandshake:
+ "chrome://remote/content/server/WebSocketHandshake.sys.mjs",
+});
+
+/**
+ * httpd.js JSON handler for direct BiDi connections.
+ */
+export class WebDriverNewSessionHandler {
+ /**
+ * Construct a new JSON handler.
+ *
+ * @param {WebDriverBiDi} webDriverBiDi
+ * Reference to the WebDriver BiDi protocol implementation.
+ */
+ constructor(webDriverBiDi) {
+ this.webDriverBiDi = webDriverBiDi;
+ }
+
+ // nsIHttpRequestHandler
+
+ /**
+ * Handle new direct WebSocket connection requests.
+ *
+ * WebSocket clients not using the WebDriver BiDi opt-in mechanism via the
+ * WebDriver HTTP implementation will attempt to directly connect at
+ * `/session`. Hereby a WebSocket upgrade will automatically be performed.
+ *
+ * @param {Request} request
+ * HTTP request (httpd.js)
+ * @param {Response} response
+ * Response to an HTTP request (httpd.js)
+ */
+ async handle(request, response) {
+ const webSocket = await lazy.WebSocketHandshake.upgrade(request, response);
+ const conn = new lazy.WebDriverBiDiConnection(
+ webSocket,
+ response._connection
+ );
+
+ this.webDriverBiDi.addSessionlessConnection(conn);
+ }
+
+ // XPCOM
+
+ get QueryInterface() {
+ return ChromeUtils.generateQI(["nsIHttpRequestHandler"]);
+ }
+}
diff --git a/remote/webdriver-bidi/RemoteValue.sys.mjs b/remote/webdriver-bidi/RemoteValue.sys.mjs
new file mode 100644
index 0000000000..cd2f58d066
--- /dev/null
+++ b/remote/webdriver-bidi/RemoteValue.sys.mjs
@@ -0,0 +1,1045 @@
+/* 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, {
+ assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs",
+ dom: "chrome://remote/content/shared/DOM.sys.mjs",
+ error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
+ generateUUID: "chrome://remote/content/shared/UUID.sys.mjs",
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "logger", () =>
+ lazy.Log.get(lazy.Log.TYPES.WEBDRIVER_BIDI)
+);
+
+/**
+ * @typedef {object} IncludeShadowTreeMode
+ */
+
+/**
+ * Enum of include shadow tree modes supported by the serialization.
+ *
+ * @readonly
+ * @enum {IncludeShadowTreeMode}
+ */
+export const IncludeShadowTreeMode = {
+ All: "all",
+ None: "none",
+ Open: "open",
+};
+
+/**
+ * @typedef {object} OwnershipModel
+ */
+
+/**
+ * Enum of ownership models supported by the serialization.
+ *
+ * @readonly
+ * @enum {OwnershipModel}
+ */
+export const OwnershipModel = {
+ None: "none",
+ Root: "root",
+};
+
+/**
+ * Extra options for deserializing remote values.
+ *
+ * @typedef {object} ExtraDeserializationOptions
+ *
+ * @property {NodeCache=} nodeCache
+ * The cache containing DOM node references.
+ * @property {Function=} emitScriptMessage
+ * The function to emit "script.message" event.
+ */
+
+/**
+ * Extra options for serializing remote values.
+ *
+ * @typedef {object} ExtraSerializationOptions
+ *
+ * @property {NodeCache=} nodeCache
+ * The cache containing DOM node references.
+ * @property {Map<BrowsingContext, Array<string>>} seenNodeIds
+ * Map of browsing contexts to their seen node ids during the current
+ * serialization.
+ */
+
+/**
+ * An object which holds the information of how
+ * ECMAScript objects should be serialized.
+ *
+ * @typedef {object} SerializationOptions
+ *
+ * @property {number} [maxDomDepth=0]
+ * Depth of a serialization of DOM Nodes. Defaults to 0.
+ * @property {number} [maxObjectDepth=null]
+ * Depth of a serialization of objects. Defaults to null.
+ * @property {IncludeShadowTreeMode} [includeShadowTree=IncludeShadowTreeMode.None]
+ * Mode of a serialization of shadow dom. Defaults to "none".
+ */
+
+const TYPED_ARRAY_CLASSES = [
+ "Uint8Array",
+ "Uint8ClampedArray",
+ "Uint16Array",
+ "Uint32Array",
+ "Int8Array",
+ "Int16Array",
+ "Int32Array",
+ "Float32Array",
+ "Float64Array",
+ "BigInt64Array",
+ "BigUint64Array",
+];
+
+/**
+ * Build the serialized RemoteValue.
+ *
+ * @returns {object}
+ * An object with a mandatory `type` property, and optional `handle`,
+ * depending on the OwnershipModel, used for the serialization and
+ * on the value's type.
+ */
+function buildSerialized(type, handle = null) {
+ const serialized = { type };
+
+ if (handle !== null) {
+ serialized.handle = handle;
+ }
+
+ return serialized;
+}
+
+/**
+ * Helper to deserialize value list.
+ *
+ * @see https://w3c.github.io/webdriver-bidi/#deserialize-value-list
+ *
+ * @param {Array} serializedValueList
+ * List of serialized values.
+ * @param {Realm} realm
+ * The Realm in which the value is deserialized.
+ * @param {ExtraDeserializationOptions} extraOptions
+ * Extra Remote Value deserialization options.
+ *
+ * @returns {Array} List of deserialized values.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>serializedValueList</var> is not an array.
+ */
+function deserializeValueList(serializedValueList, realm, extraOptions) {
+ lazy.assert.array(
+ serializedValueList,
+ `Expected "serializedValueList" to be an array, got ${serializedValueList}`
+ );
+
+ const deserializedValues = [];
+
+ for (const item of serializedValueList) {
+ deserializedValues.push(deserialize(item, realm, extraOptions));
+ }
+
+ return deserializedValues;
+}
+
+/**
+ * Helper to deserialize key-value list.
+ *
+ * @see https://w3c.github.io/webdriver-bidi/#deserialize-key-value-list
+ *
+ * @param {Array} serializedKeyValueList
+ * List of serialized key-value.
+ * @param {Realm} realm
+ * The Realm in which the value is deserialized.
+ * @param {ExtraDeserializationOptions} extraOptions
+ * Extra Remote Value deserialization options.
+ *
+ * @returns {Array} List of deserialized key-value.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>serializedKeyValueList</var> is not an array or
+ * not an array of key-value arrays.
+ */
+function deserializeKeyValueList(serializedKeyValueList, realm, extraOptions) {
+ lazy.assert.array(
+ serializedKeyValueList,
+ `Expected "serializedKeyValueList" to be an array, got ${serializedKeyValueList}`
+ );
+
+ const deserializedKeyValueList = [];
+
+ for (const serializedKeyValue of serializedKeyValueList) {
+ if (!Array.isArray(serializedKeyValue) || serializedKeyValue.length != 2) {
+ throw new lazy.error.InvalidArgumentError(
+ `Expected key-value pair to be an array with 2 elements, got ${serializedKeyValue}`
+ );
+ }
+ const [serializedKey, serializedValue] = serializedKeyValue;
+ const deserializedKey =
+ typeof serializedKey == "string"
+ ? serializedKey
+ : deserialize(serializedKey, realm, extraOptions);
+ const deserializedValue = deserialize(serializedValue, realm, extraOptions);
+
+ deserializedKeyValueList.push([deserializedKey, deserializedValue]);
+ }
+
+ return deserializedKeyValueList;
+}
+
+/**
+ * Deserialize a Node as referenced by the shared unique reference.
+ *
+ * This unique reference can be shared by WebDriver clients with the WebDriver
+ * classic implementation (Marionette) if the reference is for an Element or
+ * ShadowRoot.
+ *
+ * @param {string} sharedRef
+ * Shared unique reference of the Node.
+ * @param {Realm} realm
+ * The Realm in which the value is deserialized.
+ * @param {ExtraDeserializationOptions} extraOptions
+ * Extra Remote Value deserialization options.
+ *
+ * @returns {Node} The deserialized DOM node.
+ */
+function deserializeSharedReference(sharedRef, realm, extraOptions) {
+ const { nodeCache } = extraOptions;
+
+ const browsingContext = realm.browsingContext;
+ if (!browsingContext) {
+ throw new lazy.error.NoSuchNodeError("Realm isn't a Window global");
+ }
+
+ const node = nodeCache.getNode(browsingContext, sharedRef);
+
+ if (node === null) {
+ throw new lazy.error.NoSuchNodeError(
+ `The node with the reference ${sharedRef} is not known`
+ );
+ }
+
+ // Bug 1819902: Instead of a browsing context check compare the origin
+ const isSameBrowsingContext = sharedRef => {
+ const nodeDetails = nodeCache.getReferenceDetails(sharedRef);
+
+ if (nodeDetails.isTopBrowsingContext && browsingContext.parent === null) {
+ // As long as Navigables are not available any cross-group navigation will
+ // cause a swap of the current top-level browsing context. The only unique
+ // identifier in such a case is the browser id the top-level browsing
+ // context actually lives in.
+ return nodeDetails.browserId === browsingContext.browserId;
+ }
+
+ return nodeDetails.browsingContextId === browsingContext.id;
+ };
+
+ if (!isSameBrowsingContext(sharedRef)) {
+ return null;
+ }
+
+ return node;
+}
+
+/**
+ * Deserialize a local value.
+ *
+ * @see https://w3c.github.io/webdriver-bidi/#deserialize-local-value
+ *
+ * @param {object} serializedValue
+ * Value of any type to be deserialized.
+ * @param {Realm} realm
+ * The Realm in which the value is deserialized.
+ * @param {ExtraDeserializationOptions} extraOptions
+ * Extra Remote Value deserialization options.
+ *
+ * @returns {object} Deserialized representation of the value.
+ */
+export function deserialize(serializedValue, realm, extraOptions) {
+ const { handle, sharedId, type, value } = serializedValue;
+
+ // With a shared id present deserialize as node reference.
+ if (sharedId !== undefined) {
+ lazy.assert.string(
+ sharedId,
+ `Expected "sharedId" to be a string, got ${sharedId}`
+ );
+
+ return deserializeSharedReference(sharedId, realm, extraOptions);
+ }
+
+ // With a handle present deserialize as remote reference.
+ if (handle !== undefined) {
+ lazy.assert.string(
+ handle,
+ `Expected "handle" to be a string, got ${handle}`
+ );
+
+ const object = realm.getObjectForHandle(handle);
+ if (!object) {
+ throw new lazy.error.NoSuchHandleError(
+ `Unable to find an object reference for "handle" ${handle}`
+ );
+ }
+
+ return object;
+ }
+
+ lazy.assert.string(type, `Expected "type" to be a string, got ${type}`);
+
+ // Primitive protocol values
+ switch (type) {
+ case "undefined":
+ return undefined;
+ case "null":
+ return null;
+ case "string":
+ lazy.assert.string(
+ value,
+ `Expected "value" to be a string, got ${value}`
+ );
+ return value;
+ case "number":
+ // If value is already a number return its value.
+ if (typeof value === "number") {
+ return value;
+ }
+
+ // Otherwise it has to be one of the special strings
+ lazy.assert.in(
+ value,
+ ["NaN", "-0", "Infinity", "-Infinity"],
+ `Expected "value" to be one of "NaN", "-0", "Infinity", "-Infinity", got ${value}`
+ );
+ return Number(value);
+ case "boolean":
+ lazy.assert.boolean(
+ value,
+ `Expected "value" to be a boolean, got ${value}`
+ );
+ return value;
+ case "bigint":
+ lazy.assert.string(
+ value,
+ `Expected "value" to be a string, got ${value}`
+ );
+ try {
+ return BigInt(value);
+ } catch (e) {
+ throw new lazy.error.InvalidArgumentError(
+ `Failed to deserialize value as BigInt: ${value}`
+ );
+ }
+
+ // Script channel
+ case "channel": {
+ const channel = message =>
+ extraOptions.emitScriptMessage(realm, value, message);
+ return realm.cloneIntoRealm(channel);
+ }
+
+ // Non-primitive protocol values
+ case "array":
+ const array = realm.cloneIntoRealm([]);
+ deserializeValueList(value, realm, extraOptions).forEach(v =>
+ array.push(v)
+ );
+ return array;
+ case "date":
+ // We want to support only Date Time String format,
+ // check if the value follows it.
+ if (!ChromeUtils.isISOStyleDate(value)) {
+ throw new lazy.error.InvalidArgumentError(
+ `Expected "value" for Date to be a Date Time string, got ${value}`
+ );
+ }
+
+ return realm.cloneIntoRealm(new Date(value));
+ case "map":
+ const map = realm.cloneIntoRealm(new Map());
+ deserializeKeyValueList(value, realm, extraOptions).forEach(([k, v]) =>
+ map.set(k, v)
+ );
+
+ return map;
+ case "object":
+ const object = realm.cloneIntoRealm({});
+ deserializeKeyValueList(value, realm, extraOptions).forEach(
+ ([k, v]) => (object[k] = v)
+ );
+ return object;
+ case "regexp":
+ lazy.assert.object(
+ value,
+ `Expected "value" for RegExp to be an object, got ${value}`
+ );
+ const { pattern, flags } = value;
+ lazy.assert.string(
+ pattern,
+ `Expected "pattern" for RegExp to be a string, got ${pattern}`
+ );
+ if (flags !== undefined) {
+ lazy.assert.string(
+ flags,
+ `Expected "flags" for RegExp to be a string, got ${flags}`
+ );
+ }
+ try {
+ return realm.cloneIntoRealm(new RegExp(pattern, flags));
+ } catch (e) {
+ throw new lazy.error.InvalidArgumentError(
+ `Failed to deserialize value as RegExp: ${value}`
+ );
+ }
+ case "set":
+ const set = realm.cloneIntoRealm(new Set());
+ deserializeValueList(value, realm, extraOptions).forEach(v => set.add(v));
+ return set;
+ }
+
+ lazy.logger.warn(`Unsupported type for local value ${type}`);
+ return undefined;
+}
+
+/**
+ * Helper to retrieve the handle id for a given object, for the provided realm
+ * and ownership type.
+ *
+ * See https://w3c.github.io/webdriver-bidi/#handle-for-an-object
+ *
+ * @param {Realm} realm
+ * The Realm from which comes the value being serialized.
+ * @param {OwnershipModel} ownershipType
+ * The ownership model to use for this serialization.
+ * @param {object} object
+ * The object being serialized.
+ *
+ * @returns {string} The unique handle id for the object. Will be null if the
+ * Ownership type is "none".
+ */
+function getHandleForObject(realm, ownershipType, object) {
+ if (ownershipType === OwnershipModel.None) {
+ return null;
+ }
+ return realm.getHandleForObject(object);
+}
+
+/**
+ * Gets or creates a new shared unique reference for the DOM node.
+ *
+ * This unique reference can be shared by WebDriver clients with the WebDriver
+ * classic implementation (Marionette) if the reference is for an Element or
+ * ShadowRoot.
+ *
+ * @param {Node} node
+ * Node to create the unique reference for.
+ * @param {ExtraSerializationOptions} extraOptions
+ * Extra Remote Value serialization options.
+ *
+ * @returns {string}
+ * Shared unique reference for the Node.
+ */
+function getSharedIdForNode(node, extraOptions) {
+ const { nodeCache, seenNodeIds } = extraOptions;
+
+ if (!Node.isInstance(node)) {
+ return null;
+ }
+
+ const browsingContext = node.ownerGlobal.browsingContext;
+ if (!browsingContext) {
+ return null;
+ }
+
+ return nodeCache.getOrCreateNodeReference(node, seenNodeIds);
+}
+
+/**
+ * Helper to serialize an Array-like object.
+ *
+ * @see https://w3c.github.io/webdriver-bidi/#serialize-an-array-like
+ *
+ * @param {string} production
+ * Type of object
+ * @param {string} handleId
+ * The unique id of the <var>value</var>.
+ * @param {boolean} knownObject
+ * Indicates if the <var>value</var> has already been serialized.
+ * @param {object} value
+ * The Array-like object to serialize.
+ * @param {SerializationOptions} serializationOptions
+ * Options which define how ECMAScript objects should be serialized.
+ * @param {OwnershipModel} ownershipType
+ * The ownership model to use for this serialization.
+ * @param {Map} serializationInternalMap
+ * Map of internal ids.
+ * @param {Realm} realm
+ * The Realm from which comes the value being serialized.
+ * @param {ExtraSerializationOptions} extraOptions
+ * Extra Remote Value serialization options.
+ *
+ * @returns {object} Object for serialized values.
+ */
+function serializeArrayLike(
+ production,
+ handleId,
+ knownObject,
+ value,
+ serializationOptions,
+ ownershipType,
+ serializationInternalMap,
+ realm,
+ extraOptions
+) {
+ const serialized = buildSerialized(production, handleId);
+ setInternalIdsIfNeeded(serializationInternalMap, serialized, value);
+
+ if (!knownObject && serializationOptions.maxObjectDepth !== 0) {
+ serialized.value = serializeList(
+ value,
+ serializationOptions,
+ ownershipType,
+ serializationInternalMap,
+ realm,
+ extraOptions
+ );
+ }
+
+ return serialized;
+}
+
+/**
+ * Helper to serialize as a list.
+ *
+ * @see https://w3c.github.io/webdriver-bidi/#serialize-as-a-list
+ *
+ * @param {Iterable} iterable
+ * List of values 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 {Map} serializationInternalMap
+ * Map of internal ids.
+ * @param {Realm} realm
+ * The Realm from which comes the value being serialized.
+ * @param {ExtraSerializationOptions} extraOptions
+ * Extra Remote Value serialization options.
+ *
+ * @returns {Array} List of serialized values.
+ */
+function serializeList(
+ iterable,
+ serializationOptions,
+ ownershipType,
+ serializationInternalMap,
+ realm,
+ extraOptions
+) {
+ const { maxObjectDepth } = serializationOptions;
+ const serialized = [];
+ const childSerializationOptions = {
+ ...serializationOptions,
+ };
+ if (maxObjectDepth !== null) {
+ childSerializationOptions.maxObjectDepth = maxObjectDepth - 1;
+ }
+
+ for (const item of iterable) {
+ serialized.push(
+ serialize(
+ item,
+ childSerializationOptions,
+ ownershipType,
+ serializationInternalMap,
+ realm,
+ extraOptions
+ )
+ );
+ }
+
+ return serialized;
+}
+
+/**
+ * Helper to serialize as a mapping.
+ *
+ * @see https://w3c.github.io/webdriver-bidi/#serialize-as-a-mapping
+ *
+ * @param {Iterable} iterable
+ * List of values 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 {Map} serializationInternalMap
+ * Map of internal ids.
+ * @param {Realm} realm
+ * The Realm from which comes the value being serialized.
+ * @param {ExtraSerializationOptions} extraOptions
+ * Extra Remote Value serialization options.
+ *
+ * @returns {Array} List of serialized values.
+ */
+function serializeMapping(
+ iterable,
+ serializationOptions,
+ ownershipType,
+ serializationInternalMap,
+ realm,
+ extraOptions
+) {
+ const { maxObjectDepth } = serializationOptions;
+ const serialized = [];
+ const childSerializationOptions = {
+ ...serializationOptions,
+ };
+ if (maxObjectDepth !== null) {
+ childSerializationOptions.maxObjectDepth = maxObjectDepth - 1;
+ }
+
+ for (const [key, item] of iterable) {
+ const serializedKey =
+ typeof key == "string"
+ ? key
+ : serialize(
+ key,
+ childSerializationOptions,
+ ownershipType,
+ serializationInternalMap,
+ realm,
+ extraOptions
+ );
+ const serializedValue = serialize(
+ item,
+ childSerializationOptions,
+ ownershipType,
+ serializationInternalMap,
+ realm,
+ extraOptions
+ );
+
+ serialized.push([serializedKey, serializedValue]);
+ }
+
+ return serialized;
+}
+
+/**
+ * Helper to serialize as a Node.
+ *
+ * @param {Node} node
+ * Node 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 {Map} serializationInternalMap
+ * Map of internal ids.
+ * @param {Realm} realm
+ * The Realm from which comes the value being serialized.
+ * @param {ExtraSerializationOptions} extraOptions
+ * Extra Remote Value serialization options.
+ *
+ * @returns {object} Serialized value.
+ */
+function serializeNode(
+ node,
+ serializationOptions,
+ ownershipType,
+ serializationInternalMap,
+ realm,
+ extraOptions
+) {
+ const { includeShadowTree, maxDomDepth } = serializationOptions;
+ const isAttribute = Attr.isInstance(node);
+ const isElement = Element.isInstance(node);
+
+ const serialized = {
+ nodeType: node.nodeType,
+ };
+
+ if (node.nodeValue !== null) {
+ serialized.nodeValue = node.nodeValue;
+ }
+
+ if (isElement || isAttribute) {
+ serialized.localName = node.localName;
+ serialized.namespaceURI = node.namespaceURI;
+ }
+
+ serialized.childNodeCount = node.childNodes.length;
+ if (
+ maxDomDepth !== 0 &&
+ (!lazy.dom.isShadowRoot(node) ||
+ (includeShadowTree === IncludeShadowTreeMode.Open &&
+ node.mode === "open") ||
+ includeShadowTree === IncludeShadowTreeMode.All)
+ ) {
+ const children = [];
+ const childSerializationOptions = {
+ ...serializationOptions,
+ };
+ if (maxDomDepth !== null) {
+ childSerializationOptions.maxDomDepth = maxDomDepth - 1;
+ }
+ for (const child of node.childNodes) {
+ children.push(
+ serialize(
+ child,
+ childSerializationOptions,
+ ownershipType,
+ serializationInternalMap,
+ realm,
+ extraOptions
+ )
+ );
+ }
+
+ serialized.children = children;
+ }
+
+ if (isElement) {
+ serialized.attributes = [...node.attributes].reduce((map, attr) => {
+ map[attr.name] = attr.value;
+ return map;
+ }, {});
+
+ const shadowRoot = node.openOrClosedShadowRoot;
+ serialized.shadowRoot = null;
+ if (shadowRoot !== null) {
+ serialized.shadowRoot = serialize(
+ shadowRoot,
+ serializationOptions,
+ ownershipType,
+ serializationInternalMap,
+ realm,
+ extraOptions
+ );
+ }
+ }
+
+ if (lazy.dom.isShadowRoot(node)) {
+ serialized.mode = node.mode;
+ }
+
+ return serialized;
+}
+
+/**
+ * Serialize a value as a remote value.
+ *
+ * @see https://w3c.github.io/webdriver-bidi/#serialize-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 {Map} serializationInternalMap
+ * Map of internal ids.
+ * @param {Realm} realm
+ * The Realm from which comes the value being serialized.
+ * @param {ExtraSerializationOptions} extraOptions
+ * Extra Remote Value serialization options.
+ *
+ * @returns {object} Serialized representation of the value.
+ */
+export function serialize(
+ value,
+ serializationOptions,
+ ownershipType,
+ serializationInternalMap,
+ realm,
+ extraOptions
+) {
+ const { maxObjectDepth } = serializationOptions;
+ const type = typeof value;
+
+ // Primitive protocol values
+ if (type == "undefined") {
+ return { type };
+ } else if (Object.is(value, null)) {
+ return { type: "null" };
+ } else if (Object.is(value, NaN)) {
+ return { type: "number", value: "NaN" };
+ } else if (Object.is(value, -0)) {
+ return { type: "number", value: "-0" };
+ } else if (Object.is(value, Infinity)) {
+ return { type: "number", value: "Infinity" };
+ } else if (Object.is(value, -Infinity)) {
+ return { type: "number", value: "-Infinity" };
+ } else if (type == "bigint") {
+ return { type, value: value.toString() };
+ } else if (["boolean", "number", "string"].includes(type)) {
+ return { type, value };
+ }
+
+ const handleId = getHandleForObject(realm, ownershipType, value);
+ const knownObject = serializationInternalMap.has(value);
+
+ // Set the OwnershipModel to use for all complex object serializations.
+ ownershipType = OwnershipModel.None;
+
+ // Remote values
+
+ // symbols are primitive JS values which can only be serialized
+ // as remote values.
+ if (type == "symbol") {
+ return buildSerialized("symbol", handleId);
+ }
+
+ // All other remote values are non-primitives and their
+ // className can be extracted with ChromeUtils.getClassName
+ const className = ChromeUtils.getClassName(value);
+ if (["Array", "HTMLCollection", "NodeList"].includes(className)) {
+ return serializeArrayLike(
+ className.toLowerCase(),
+ handleId,
+ knownObject,
+ value,
+ serializationOptions,
+ ownershipType,
+ serializationInternalMap,
+ realm,
+ extraOptions
+ );
+ } else if (className == "RegExp") {
+ const serialized = buildSerialized("regexp", handleId);
+ serialized.value = { pattern: value.source, flags: value.flags };
+ return serialized;
+ } else if (className == "Date") {
+ const serialized = buildSerialized("date", handleId);
+ serialized.value = value.toISOString();
+ return serialized;
+ } else if (className == "Map") {
+ const serialized = buildSerialized("map", handleId);
+ setInternalIdsIfNeeded(serializationInternalMap, serialized, value);
+
+ if (!knownObject && maxObjectDepth !== 0) {
+ serialized.value = serializeMapping(
+ value.entries(),
+ serializationOptions,
+ ownershipType,
+ serializationInternalMap,
+ realm,
+ extraOptions
+ );
+ }
+ return serialized;
+ } else if (className == "Set") {
+ const serialized = buildSerialized("set", handleId);
+ setInternalIdsIfNeeded(serializationInternalMap, serialized, value);
+
+ if (!knownObject && maxObjectDepth !== 0) {
+ serialized.value = serializeList(
+ value.values(),
+ serializationOptions,
+ ownershipType,
+ serializationInternalMap,
+ realm,
+ extraOptions
+ );
+ }
+ return serialized;
+ } else if (
+ ["ArrayBuffer", "Function", "Promise", "WeakMap", "WeakSet"].includes(
+ className
+ )
+ ) {
+ return buildSerialized(className.toLowerCase(), handleId);
+ } else if (className.includes("Generator")) {
+ return buildSerialized("generator", handleId);
+ } else if (lazy.error.isError(value)) {
+ return buildSerialized("error", handleId);
+ } else if (Cu.isProxy(value)) {
+ return buildSerialized("proxy", handleId);
+ } else if (TYPED_ARRAY_CLASSES.includes(className)) {
+ return buildSerialized("typedarray", handleId);
+ } else if (Node.isInstance(value)) {
+ const serialized = buildSerialized("node", handleId);
+
+ value = Cu.unwaiveXrays(value);
+
+ // Get or create the shared id for WebDriver classic compat from the node.
+ const sharedId = getSharedIdForNode(value, extraOptions);
+ if (sharedId !== null) {
+ serialized.sharedId = sharedId;
+ }
+
+ setInternalIdsIfNeeded(serializationInternalMap, serialized, value);
+
+ if (!knownObject) {
+ serialized.value = serializeNode(
+ value,
+ serializationOptions,
+ ownershipType,
+ serializationInternalMap,
+ realm,
+ extraOptions
+ );
+ }
+
+ return serialized;
+ } else if (Window.isInstance(value)) {
+ const serialized = buildSerialized("window", handleId);
+ const window = Cu.unwaiveXrays(value);
+
+ if (window.browsingContext.parent == null) {
+ serialized.value = {
+ context: window.browsingContext.browserId.toString(),
+ isTopBrowsingContext: true,
+ };
+ } else {
+ serialized.value = {
+ context: window.browsingContext.id.toString(),
+ };
+ }
+
+ return serialized;
+ } else if (ChromeUtils.isDOMObject(value)) {
+ const serialized = buildSerialized("object", handleId);
+ return serialized;
+ }
+
+ // Otherwise serialize the JavaScript object as generic object.
+ const serialized = buildSerialized("object", handleId);
+ setInternalIdsIfNeeded(serializationInternalMap, serialized, value);
+
+ if (!knownObject && maxObjectDepth !== 0) {
+ serialized.value = serializeMapping(
+ Object.entries(value),
+ serializationOptions,
+ ownershipType,
+ serializationInternalMap,
+ realm,
+ extraOptions
+ );
+ }
+ return serialized;
+}
+
+/**
+ * Set default serialization options.
+ *
+ * @param {SerializationOptions} options
+ * Options which define how ECMAScript objects should be serialized.
+ * @returns {SerializationOptions}
+ * Serialiation options with default value added.
+ */
+export function setDefaultSerializationOptions(options = {}) {
+ const serializationOptions = { ...options };
+ if (!("maxDomDepth" in serializationOptions)) {
+ serializationOptions.maxDomDepth = 0;
+ }
+ if (!("maxObjectDepth" in serializationOptions)) {
+ serializationOptions.maxObjectDepth = null;
+ }
+ if (!("includeShadowTree" in serializationOptions)) {
+ serializationOptions.includeShadowTree = IncludeShadowTreeMode.None;
+ }
+
+ return serializationOptions;
+}
+
+/**
+ * Set default values and assert if serialization options have
+ * expected types.
+ *
+ * @param {SerializationOptions} options
+ * Options which define how ECMAScript objects should be serialized.
+ * @returns {SerializationOptions}
+ * Serialiation options with default value added.
+ */
+export function setDefaultAndAssertSerializationOptions(options = {}) {
+ lazy.assert.object(options);
+
+ const serializationOptions = setDefaultSerializationOptions(options);
+
+ const { includeShadowTree, maxDomDepth, maxObjectDepth } =
+ serializationOptions;
+
+ if (maxDomDepth !== null) {
+ lazy.assert.positiveInteger(maxDomDepth);
+ }
+ if (maxObjectDepth !== null) {
+ lazy.assert.positiveInteger(maxObjectDepth);
+ }
+ const includeShadowTreeModesValues = Object.values(IncludeShadowTreeMode);
+ lazy.assert.that(
+ includeShadowTree =>
+ includeShadowTreeModesValues.includes(includeShadowTree),
+ `includeShadowTree ${includeShadowTree} doesn't match allowed values "${includeShadowTreeModesValues.join(
+ "/"
+ )}"`
+ )(includeShadowTree);
+
+ return serializationOptions;
+}
+
+/**
+ * Set the internalId property of a provided serialized RemoteValue,
+ * and potentially of a previously created serialized RemoteValue,
+ * corresponding to the same provided object.
+ *
+ * @see https://w3c.github.io/webdriver-bidi/#set-internal-ids-if-needed
+ *
+ * @param {Map} serializationInternalMap
+ * Map of objects to remote values.
+ * @param {object} remoteValue
+ * A serialized RemoteValue for the provided object.
+ * @param {object} object
+ * Object of any type to be serialized.
+ */
+function setInternalIdsIfNeeded(serializationInternalMap, remoteValue, object) {
+ if (!serializationInternalMap.has(object)) {
+ // If the object was not tracked yet in the current serialization, add
+ // a new entry in the serialization internal map. An internal id will only
+ // be generated if the same object is encountered again.
+ serializationInternalMap.set(object, remoteValue);
+ } else {
+ // This is at least the second time this object is encountered, retrieve the
+ // original remote value stored for this object.
+ const previousRemoteValue = serializationInternalMap.get(object);
+
+ if (!previousRemoteValue.internalId) {
+ // If the original remote value has no internal id yet, generate a uuid
+ // and update the internalId of the original remote value with it.
+ previousRemoteValue.internalId = lazy.generateUUID();
+ }
+
+ // Copy the internalId of the original remote value to the new remote value.
+ remoteValue.internalId = previousRemoteValue.internalId;
+ }
+}
+
+/**
+ * Safely stringify a value.
+ *
+ * @param {object} obj
+ * Value of any type to be stringified.
+ *
+ * @returns {string} String representation of the value.
+ */
+export function stringify(obj) {
+ let text;
+ try {
+ text =
+ obj !== null && typeof obj === "object" ? obj.toString() : String(obj);
+ } catch (e) {
+ // The error-case will also be handled in `finally {}`.
+ } finally {
+ if (typeof text != "string") {
+ text = Object.prototype.toString.apply(obj);
+ }
+ }
+
+ return text;
+}
diff --git a/remote/webdriver-bidi/WebDriverBiDi.sys.mjs b/remote/webdriver-bidi/WebDriverBiDi.sys.mjs
new file mode 100644
index 0000000000..00503ca2f6
--- /dev/null
+++ b/remote/webdriver-bidi/WebDriverBiDi.sys.mjs
@@ -0,0 +1,240 @@
+/* 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, {
+ error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+ WebDriverNewSessionHandler:
+ "chrome://remote/content/webdriver-bidi/NewSessionHandler.sys.mjs",
+ WebDriverSession: "chrome://remote/content/shared/webdriver/Session.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "logger", () =>
+ lazy.Log.get(lazy.Log.TYPES.WEBDRIVER_BIDI)
+);
+ChromeUtils.defineLazyGetter(lazy, "textEncoder", () => new TextEncoder());
+
+/**
+ * Entry class for the WebDriver BiDi support.
+ *
+ * @see https://w3c.github.io/webdriver-bidi
+ */
+export class WebDriverBiDi {
+ /**
+ * Creates a new instance of the WebDriverBiDi class.
+ *
+ * @param {RemoteAgent} agent
+ * Reference to the Remote Agent instance.
+ */
+ constructor(agent) {
+ this.agent = agent;
+ this._running = false;
+
+ this._session = null;
+ this._sessionlessConnections = new Set();
+ }
+
+ get address() {
+ return `ws://${this.agent.host}:${this.agent.port}`;
+ }
+
+ get session() {
+ return this._session;
+ }
+
+ /**
+ * Add a new connection that is not yet attached to a WebDriver session.
+ *
+ * @param {WebDriverBiDiConnection} connection
+ * The connection without an accociated WebDriver session.
+ */
+ addSessionlessConnection(connection) {
+ this._sessionlessConnections.add(connection);
+ }
+
+ /**
+ * Create a new WebDriver session.
+ *
+ * @param {Object<string, *>=} capabilities
+ * JSON Object containing any of the recognised capabilities as listed
+ * on the `WebDriverSession` class.
+ *
+ * @param {WebDriverBiDiConnection=} sessionlessConnection
+ * Optional connection that is not yet accociated with a WebDriver
+ * session, and has to be associated with the new WebDriver session.
+ *
+ * @returns {Object<string, Capabilities>}
+ * Object containing the current session ID, and all its capabilities.
+ *
+ * @throws {SessionNotCreatedError}
+ * If, for whatever reason, a session could not be created.
+ */
+ async createSession(capabilities, sessionlessConnection) {
+ if (this.session) {
+ throw new lazy.error.SessionNotCreatedError(
+ "Maximum number of active sessions"
+ );
+ }
+
+ const session = new lazy.WebDriverSession(
+ capabilities,
+ sessionlessConnection
+ );
+
+ // When the Remote Agent is listening, and a BiDi WebSocket connection
+ // has been requested, register a path handler for the session.
+ let webSocketUrl = null;
+ if (
+ this.agent.running &&
+ (session.capabilities.get("webSocketUrl") || sessionlessConnection)
+ ) {
+ // Creating a WebDriver BiDi session too early can cause issues with
+ // clients in not being able to find any available browsing context.
+ // Also when closing the application while it's still starting up can
+ // cause shutdown hangs. As such WebDriver BiDi will return a new session
+ // once the initial application window has finished initializing.
+ lazy.logger.debug(`Waiting for initial application window`);
+ await this.agent.browserStartupFinished;
+
+ this.agent.server.registerPathHandler(session.path, session);
+ webSocketUrl = `${this.address}${session.path}`;
+
+ lazy.logger.debug(`Registered session handler: ${session.path}`);
+
+ if (sessionlessConnection) {
+ // Remove temporary session-less connection
+ this._sessionlessConnections.delete(sessionlessConnection);
+ }
+ }
+
+ // Also update the webSocketUrl capability to contain the session URL if
+ // a path handler has been registered. Otherwise set its value to null.
+ session.capabilities.set("webSocketUrl", webSocketUrl);
+
+ this._session = session;
+
+ return {
+ sessionId: this.session.id,
+ capabilities: this.session.capabilities,
+ };
+ }
+
+ /**
+ * Delete the current WebDriver session.
+ */
+ deleteSession() {
+ if (!this.session) {
+ return;
+ }
+
+ // When the Remote Agent is listening, and a BiDi WebSocket is active,
+ // unregister the path handler for the session.
+ if (this.agent.running && this.session.capabilities.get("webSocketUrl")) {
+ this.agent.server.registerPathHandler(this.session.path, null);
+ lazy.logger.debug(`Unregistered session handler: ${this.session.path}`);
+ }
+
+ this.session.destroy();
+ this._session = null;
+ }
+
+ /**
+ * Retrieve the readiness state of the remote end, regarding the creation of
+ * new WebDriverBiDi sessions.
+ *
+ * See https://w3c.github.io/webdriver-bidi/#command-session-status
+ *
+ * @returns {object}
+ * The readiness state.
+ */
+ getSessionReadinessStatus() {
+ if (this.session) {
+ // We currently only support one session, see Bug 1720707.
+ return {
+ ready: false,
+ message: "Session already started",
+ };
+ }
+
+ return {
+ ready: true,
+ message: "",
+ };
+ }
+
+ /**
+ * Starts the WebDriver BiDi support.
+ */
+ async start() {
+ if (this._running) {
+ return;
+ }
+
+ this._running = true;
+
+ // Install a HTTP handler for direct WebDriver BiDi connection requests.
+ this.agent.server.registerPathHandler(
+ "/session",
+ new lazy.WebDriverNewSessionHandler(this)
+ );
+
+ Cu.printStderr(`WebDriver BiDi listening on ${this.address}\n`);
+
+ // Write WebSocket connection details to the WebDriverBiDiServer.json file
+ // located within the application's profile.
+ this._bidiServerPath = PathUtils.join(
+ PathUtils.profileDir,
+ "WebDriverBiDiServer.json"
+ );
+
+ const data = {
+ ws_host: this.agent.host,
+ ws_port: this.agent.port,
+ };
+
+ try {
+ await IOUtils.write(
+ this._bidiServerPath,
+ lazy.textEncoder.encode(JSON.stringify(data, undefined, " "))
+ );
+ } catch (e) {
+ lazy.logger.warn(
+ `Failed to create ${this._bidiServerPath} (${e.message})`
+ );
+ }
+ }
+
+ /**
+ * Stops the WebDriver BiDi support.
+ */
+ async stop() {
+ if (!this._running) {
+ return;
+ }
+
+ try {
+ await IOUtils.remove(this._bidiServerPath);
+ } catch (e) {
+ lazy.logger.warn(
+ `Failed to remove ${this._bidiServerPath} (${e.message})`
+ );
+ }
+
+ try {
+ // Close open session
+ this.deleteSession();
+ this.agent.server.registerPathHandler("/session", null);
+
+ // Close all open session-less connections
+ this._sessionlessConnections.forEach(connection => connection.close());
+ this._sessionlessConnections.clear();
+ } catch (e) {
+ lazy.logger.error("Failed to stop protocol", e);
+ } finally {
+ this._running = false;
+ }
+ }
+}
diff --git a/remote/webdriver-bidi/WebDriverBiDiConnection.sys.mjs b/remote/webdriver-bidi/WebDriverBiDiConnection.sys.mjs
new file mode 100644
index 0000000000..5ec7ff9a06
--- /dev/null
+++ b/remote/webdriver-bidi/WebDriverBiDiConnection.sys.mjs
@@ -0,0 +1,268 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { WebSocketConnection } from "chrome://remote/content/shared/WebSocketConnection.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs",
+ error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+ processCapabilities:
+ "chrome://remote/content/shared/webdriver/Capabilities.sys.mjs",
+ quit: "chrome://remote/content/shared/Browser.sys.mjs",
+ RemoteAgent: "chrome://remote/content/components/RemoteAgent.sys.mjs",
+ WEBDRIVER_CLASSIC_CAPABILITIES:
+ "chrome://remote/content/shared/webdriver/Capabilities.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "logger", () =>
+ lazy.Log.get(lazy.Log.TYPES.WEBDRIVER_BIDI)
+);
+
+export class WebDriverBiDiConnection extends WebSocketConnection {
+ /**
+ * @param {WebSocket} webSocket
+ * The WebSocket server connection to wrap.
+ * @param {Connection} httpdConnection
+ * Reference to the httpd.js's connection needed for clean-up.
+ */
+ constructor(webSocket, httpdConnection) {
+ super(webSocket, httpdConnection);
+
+ // Each connection has only a single associated WebDriver session.
+ this.session = null;
+ }
+
+ /**
+ * Perform required steps to end the session.
+ */
+ endSession() {
+ // TODO Bug 1838269. Implement session ending logic
+ // for the case of classic + bidi session.
+ // We currently only support one session, see Bug 1720707.
+ lazy.RemoteAgent.webDriverBiDi.deleteSession();
+ }
+
+ /**
+ * Register a new WebDriver Session to forward the messages to.
+ *
+ * @param {Session} session
+ * The WebDriverSession to register.
+ */
+ registerSession(session) {
+ if (this.session) {
+ throw new lazy.error.UnknownError(
+ "A WebDriver session has already been set"
+ );
+ }
+
+ this.session = session;
+ lazy.logger.debug(
+ `Connection ${this.id} attached to session ${session.id}`
+ );
+ }
+
+ /**
+ * Unregister the already set WebDriver session.
+ */
+ unregisterSession() {
+ if (!this.session) {
+ return;
+ }
+
+ this.session.removeConnection(this);
+ this.session = null;
+ }
+
+ /**
+ * Send an error back to the WebDriver BiDi client.
+ *
+ * @param {number} id
+ * Id of the packet which lead to an error.
+ * @param {Error} err
+ * Error object with `status`, `message` and `stack` attributes.
+ */
+ sendError(id, err) {
+ const webDriverError = lazy.error.wrap(err);
+
+ this.send({
+ type: "error",
+ id,
+ error: webDriverError.status,
+ message: webDriverError.message,
+ stacktrace: webDriverError.stack,
+ });
+ }
+
+ /**
+ * Send an event coming from a module to the WebDriver BiDi client.
+ *
+ * @param {string} method
+ * The event name. This is composed by a module name, a dot character
+ * followed by the event name, e.g. `log.entryAdded`.
+ * @param {object} params
+ * A JSON-serializable object, which is the payload of this event.
+ */
+ sendEvent(method, params) {
+ this.send({ type: "event", method, params });
+
+ if (Services.profiler?.IsActive()) {
+ ChromeUtils.addProfilerMarker(
+ "BiDi: Event",
+ { category: "Remote-Protocol" },
+ method
+ );
+ }
+ }
+
+ /**
+ * Send the result of a call to a module's method back to the
+ * WebDriver BiDi client.
+ *
+ * @param {number} id
+ * The request id being sent by the client to call the module's method.
+ * @param {object} result
+ * A JSON-serializable object, which is the actual result.
+ */
+ sendResult(id, result) {
+ result = typeof result !== "undefined" ? result : {};
+ this.send({ type: "success", id, result });
+ }
+
+ observe(subject, topic) {
+ switch (topic) {
+ case "quit-application-requested":
+ this.endSession();
+ break;
+ }
+ }
+
+ // Transport hooks
+
+ /**
+ * Called by the `transport` when the connection is closed.
+ */
+ onConnectionClose() {
+ this.unregisterSession();
+
+ super.onConnectionClose();
+ }
+
+ /**
+ * Receive a packet from the WebSocket layer.
+ *
+ * This packet is sent by a WebDriver BiDi client and is meant to execute
+ * a particular method on a given module.
+ *
+ * @param {object} packet
+ * JSON-serializable object sent by the client
+ */
+ async onPacket(packet) {
+ super.onPacket(packet);
+
+ const { id, method, params } = packet;
+ const startTime = Cu.now();
+
+ try {
+ // First check for mandatory field in the command packet
+ lazy.assert.positiveInteger(id, "id: unsigned integer value expected");
+ lazy.assert.string(method, "method: string value expected");
+ lazy.assert.object(params, "params: object value expected");
+
+ // Extract the module and the command name out of `method` attribute
+ const { module, command } = splitMethod(method);
+ let result;
+
+ // Handle static commands first
+ if (module === "session" && command === "new") {
+ const processedCapabilities = lazy.processCapabilities(params);
+
+ result = await lazy.RemoteAgent.webDriverBiDi.createSession(
+ processedCapabilities,
+ this
+ );
+
+ // Since in Capabilities class we setup default values also for capabilities which are
+ // not relevant for bidi, we want to remove them from the payload before returning to a client.
+ result.capabilities = Array.from(result.capabilities.entries()).reduce(
+ (object, [key, value]) => {
+ if (!lazy.WEBDRIVER_CLASSIC_CAPABILITIES.includes(key)) {
+ object[key] = value;
+ }
+
+ return object;
+ },
+ {}
+ );
+ } else if (module === "session" && command === "status") {
+ result = lazy.RemoteAgent.webDriverBiDi.getSessionReadinessStatus();
+ } else {
+ lazy.assert.session(this.session);
+
+ // Bug 1741854 - Workaround to deny internal methods to be called
+ if (command.startsWith("_")) {
+ throw new lazy.error.UnknownCommandError(method);
+ }
+
+ // Finally, instruct the session to execute the command
+ result = await this.session.execute(module, command, params);
+ }
+
+ this.sendResult(id, result);
+
+ // Session clean up.
+ if (module === "session" && command === "end") {
+ this.endSession();
+ }
+ // Close the browser.
+ // TODO Bug 1842018. Refactor this part to return the response
+ // when the quitting of the browser is finished.
+ else if (module === "browser" && command === "close") {
+ // Register handler to run WebDriver BiDi specific shutdown code.
+ Services.obs.addObserver(this, "quit-application-requested");
+
+ // TODO Bug 1836282. Add as the third argument "moz:windowless" capability
+ // from the session, when this capability is supported by Webdriver BiDi.
+ await lazy.quit(["eForceQuit"], false);
+
+ Services.obs.removeObserver(this, "quit-application-requested");
+ }
+ } catch (e) {
+ this.sendError(id, e);
+ }
+
+ if (Services.profiler?.IsActive()) {
+ ChromeUtils.addProfilerMarker(
+ "BiDi: Command",
+ { startTime, category: "Remote-Protocol" },
+ `${method} (${id})`
+ );
+ }
+ }
+}
+
+/**
+ * Splits a WebDriver BiDi method into module and command components.
+ *
+ * @param {string} method
+ * Name of the method to split, e.g. "session.subscribe".
+ *
+ * @returns {Object<string, string>}
+ * Object with the module ("session") and command ("subscribe")
+ * as properties.
+ */
+export function splitMethod(method) {
+ const parts = method.split(".");
+
+ if (parts.length != 2 || !parts[0].length || !parts[1].length) {
+ throw new TypeError(`Invalid method format: '${method}'`);
+ }
+
+ return {
+ module: parts[0],
+ command: parts[1],
+ };
+}
diff --git a/remote/webdriver-bidi/jar.mn b/remote/webdriver-bidi/jar.mn
new file mode 100644
index 0000000000..6f0b2493d8
--- /dev/null
+++ b/remote/webdriver-bidi/jar.mn
@@ -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/.
+
+remote.jar:
+% content remote %content/
+
+ content/webdriver-bidi/NewSessionHandler.sys.mjs (NewSessionHandler.sys.mjs)
+ content/webdriver-bidi/RemoteValue.sys.mjs (RemoteValue.sys.mjs)
+ content/webdriver-bidi/WebDriverBiDi.sys.mjs (WebDriverBiDi.sys.mjs)
+ content/webdriver-bidi/WebDriverBiDiConnection.sys.mjs (WebDriverBiDiConnection.sys.mjs)
+
+ # WebDriver BiDi modules
+ content/webdriver-bidi/modules/Intercept.sys.mjs (modules/Intercept.sys.mjs)
+ content/webdriver-bidi/modules/ModuleRegistry.sys.mjs (modules/ModuleRegistry.sys.mjs)
+ content/webdriver-bidi/modules/WindowGlobalBiDiModule.sys.mjs (modules/WindowGlobalBiDiModule.sys.mjs)
+
+ # WebDriver BiDi root modules
+ content/webdriver-bidi/modules/root/browser.sys.mjs (modules/root/browser.sys.mjs)
+ content/webdriver-bidi/modules/root/browsingContext.sys.mjs (modules/root/browsingContext.sys.mjs)
+ content/webdriver-bidi/modules/root/input.sys.mjs (modules/root/input.sys.mjs)
+ content/webdriver-bidi/modules/root/log.sys.mjs (modules/root/log.sys.mjs)
+ content/webdriver-bidi/modules/root/network.sys.mjs (modules/root/network.sys.mjs)
+ content/webdriver-bidi/modules/root/script.sys.mjs (modules/root/script.sys.mjs)
+ content/webdriver-bidi/modules/root/session.sys.mjs (modules/root/session.sys.mjs)
+ content/webdriver-bidi/modules/root/storage.sys.mjs (modules/root/storage.sys.mjs)
+
+ # WebDriver BiDi windowglobal modules
+ content/webdriver-bidi/modules/windowglobal/browsingContext.sys.mjs (modules/windowglobal/browsingContext.sys.mjs)
+ content/webdriver-bidi/modules/windowglobal/input.sys.mjs (modules/windowglobal/input.sys.mjs)
+ content/webdriver-bidi/modules/windowglobal/log.sys.mjs (modules/windowglobal/log.sys.mjs)
+ content/webdriver-bidi/modules/windowglobal/script.sys.mjs (modules/windowglobal/script.sys.mjs)
+
+ # WebDriver BiDi windowglobal-in-root modules
+ content/webdriver-bidi/modules/windowglobal-in-root/browsingContext.sys.mjs (modules/windowglobal-in-root/browsingContext.sys.mjs)
+ content/webdriver-bidi/modules/windowglobal-in-root/log.sys.mjs (modules/windowglobal-in-root/log.sys.mjs)
+ content/webdriver-bidi/modules/windowglobal-in-root/script.sys.mjs (modules/windowglobal-in-root/script.sys.mjs)
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;
diff --git a/remote/webdriver-bidi/moz.build b/remote/webdriver-bidi/moz.build
new file mode 100644
index 0000000000..1b58153c45
--- /dev/null
+++ b/remote/webdriver-bidi/moz.build
@@ -0,0 +1,14 @@
+# 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/.
+
+JAR_MANIFESTS += ["jar.mn"]
+
+with Files("**"):
+ BUG_COMPONENT = ("Remote Protocol", "WebDriver BiDi")
+
+BROWSER_CHROME_MANIFESTS += [
+ "test/browser/browser.toml",
+]
+
+XPCSHELL_TESTS_MANIFESTS += ["test/xpcshell/xpcshell.toml"]
diff --git a/remote/webdriver-bidi/test/browser/browser.toml b/remote/webdriver-bidi/test/browser/browser.toml
new file mode 100644
index 0000000000..21e54bf538
--- /dev/null
+++ b/remote/webdriver-bidi/test/browser/browser.toml
@@ -0,0 +1,8 @@
+[DEFAULT]
+tags = "wd"
+subsuite = "remote"
+support-files = ["head.js"]
+
+["browser_RemoteValue.js"]
+
+["browser_RemoteValueDOM.js"]
diff --git a/remote/webdriver-bidi/test/browser/browser_RemoteValue.js b/remote/webdriver-bidi/test/browser/browser_RemoteValue.js
new file mode 100644
index 0000000000..b95636037b
--- /dev/null
+++ b/remote/webdriver-bidi/test/browser/browser_RemoteValue.js
@@ -0,0 +1,1117 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { Realm } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/Realm.sys.mjs"
+);
+const { deserialize, serialize, setDefaultSerializationOptions, stringify } =
+ ChromeUtils.importESModule(
+ "chrome://remote/content/webdriver-bidi/RemoteValue.sys.mjs"
+ );
+
+const PRIMITIVE_TYPES = [
+ { value: undefined, serialized: { type: "undefined" } },
+ { value: null, serialized: { type: "null" } },
+ { value: "foo", serialized: { type: "string", value: "foo" } },
+ { value: Number.NaN, serialized: { type: "number", value: "NaN" } },
+ { value: -0, serialized: { type: "number", value: "-0" } },
+ {
+ value: Number.POSITIVE_INFINITY,
+ serialized: { type: "number", value: "Infinity" },
+ },
+ {
+ value: Number.NEGATIVE_INFINITY,
+ serialized: { type: "number", value: "-Infinity" },
+ },
+ { value: 42, serialized: { type: "number", value: 42 } },
+ { value: false, serialized: { type: "boolean", value: false } },
+ { value: 42n, serialized: { type: "bigint", value: "42" } },
+];
+
+const REMOTE_SIMPLE_VALUES = [
+ {
+ value: new RegExp(/foo/),
+ serialized: {
+ type: "regexp",
+ value: {
+ pattern: "foo",
+ flags: "",
+ },
+ },
+ deserializable: true,
+ },
+ {
+ value: new RegExp(/foo/g),
+ serialized: {
+ type: "regexp",
+ value: {
+ pattern: "foo",
+ flags: "g",
+ },
+ },
+ deserializable: true,
+ },
+ {
+ value: new Date(1654004849000),
+ serialized: {
+ type: "date",
+ value: "2022-05-31T13:47:29.000Z",
+ },
+ deserializable: true,
+ },
+];
+
+const REMOTE_COMPLEX_VALUES = [
+ { value: Symbol("foo"), serialized: { type: "symbol" } },
+ {
+ value: [1],
+ serialized: {
+ type: "array",
+ value: [{ type: "number", value: 1 }],
+ },
+ },
+ {
+ value: [1],
+ serializationOptions: {
+ maxObjectDepth: 0,
+ },
+ serialized: {
+ type: "array",
+ },
+ },
+ {
+ value: [1, "2", true, new RegExp(/foo/g)],
+ serializationOptions: {
+ maxObjectDepth: 1,
+ },
+ serialized: {
+ type: "array",
+ value: [
+ { type: "number", value: 1 },
+ { type: "string", value: "2" },
+ { type: "boolean", value: true },
+ {
+ type: "regexp",
+ value: {
+ pattern: "foo",
+ flags: "g",
+ },
+ },
+ ],
+ },
+ deserializable: true,
+ },
+ {
+ value: [1, [3, "4"]],
+ serializationOptions: {
+ maxObjectDepth: 1,
+ },
+ serialized: {
+ type: "array",
+ value: [{ type: "number", value: 1 }, { type: "array" }],
+ },
+ },
+ {
+ value: [1, [3, "4"]],
+ serializationOptions: {
+ maxObjectDepth: 2,
+ },
+ serialized: {
+ type: "array",
+ value: [
+ { type: "number", value: 1 },
+ {
+ type: "array",
+ value: [
+ { type: "number", value: 3 },
+ { type: "string", value: "4" },
+ ],
+ },
+ ],
+ },
+ deserializable: true,
+ },
+ {
+ value: new Map(),
+ serializationOptions: {
+ maxObjectDepth: 1,
+ },
+ serialized: {
+ type: "map",
+ value: [],
+ },
+ deserializable: true,
+ },
+ {
+ value: new Map([]),
+ serializationOptions: {
+ maxObjectDepth: 1,
+ },
+ serialized: {
+ type: "map",
+ value: [],
+ },
+ deserializable: true,
+ },
+ {
+ value: new Map([
+ [1, 2],
+ ["2", "3"],
+ [true, false],
+ ]),
+ serialized: {
+ type: "map",
+ value: [
+ [
+ { type: "number", value: 1 },
+ { type: "number", value: 2 },
+ ],
+ ["2", { type: "string", value: "3" }],
+ [
+ { type: "boolean", value: true },
+ { type: "boolean", value: false },
+ ],
+ ],
+ },
+ },
+ {
+ value: new Map([
+ [1, 2],
+ ["2", "3"],
+ [true, false],
+ ]),
+ serializationOptions: {
+ maxObjectDepth: 0,
+ },
+ serialized: {
+ type: "map",
+ },
+ },
+ {
+ value: new Map([
+ [1, 2],
+ ["2", "3"],
+ [true, false],
+ ]),
+ serializationOptions: {
+ maxObjectDepth: 1,
+ },
+ serialized: {
+ type: "map",
+ value: [
+ [
+ { type: "number", value: 1 },
+ { type: "number", value: 2 },
+ ],
+ ["2", { type: "string", value: "3" }],
+ [
+ { type: "boolean", value: true },
+ { type: "boolean", value: false },
+ ],
+ ],
+ },
+ deserializable: true,
+ },
+ {
+ value: new Set(),
+ serializationOptions: {
+ maxObjectDepth: 1,
+ },
+ serialized: {
+ type: "set",
+ value: [],
+ },
+ deserializable: true,
+ },
+ {
+ value: new Set([]),
+ serializationOptions: {
+ maxObjectDepth: 1,
+ },
+ serialized: {
+ type: "set",
+ value: [],
+ },
+ deserializable: true,
+ },
+ {
+ value: new Set([1, "2", true]),
+ serialized: {
+ type: "set",
+ value: [
+ { type: "number", value: 1 },
+ { type: "string", value: "2" },
+ { type: "boolean", value: true },
+ ],
+ },
+ },
+ {
+ value: new Set([1, "2", true]),
+ serializationOptions: {
+ maxObjectDepth: 0,
+ },
+ serialized: {
+ type: "set",
+ },
+ },
+ {
+ value: new Set([1, "2", true]),
+ serializationOptions: {
+ maxObjectDepth: 1,
+ },
+ serialized: {
+ type: "set",
+ value: [
+ { type: "number", value: 1 },
+ { type: "string", value: "2" },
+ { type: "boolean", value: true },
+ ],
+ },
+ deserializable: true,
+ },
+ { value: new WeakMap([[{}, 1]]), serialized: { type: "weakmap" } },
+ { value: new WeakSet([{}]), serialized: { type: "weakset" } },
+ {
+ value: (function* () {
+ yield "a";
+ })(),
+ serialized: { type: "generator" },
+ },
+ {
+ value: (async function* () {
+ yield await Promise.resolve(1);
+ })(),
+ serialized: { type: "generator" },
+ },
+ { value: new Error("error message"), serialized: { type: "error" } },
+ {
+ value: new SyntaxError("syntax error message"),
+ serialized: { type: "error" },
+ },
+ {
+ value: new TypeError("type error message"),
+ serialized: { type: "error" },
+ },
+ { value: new Proxy({}, {}), serialized: { type: "proxy" } },
+ { value: new Promise(() => true), serialized: { type: "promise" } },
+ { value: new Int8Array(), serialized: { type: "typedarray" } },
+ { value: new ArrayBuffer(), serialized: { type: "arraybuffer" } },
+ { value: new URL("https://example.com"), serialized: { type: "object" } },
+ { value: () => true, serialized: { type: "function" } },
+ { value() {}, serialized: { type: "function" } },
+ {
+ value: {},
+ serializationOptions: {
+ maxObjectDepth: 1,
+ },
+ serialized: {
+ type: "object",
+ value: [],
+ },
+ deserializable: true,
+ },
+ {
+ value: {
+ 1: 1,
+ 2: "2",
+ foo: true,
+ },
+ serialized: {
+ type: "object",
+ value: [
+ ["1", { type: "number", value: 1 }],
+ ["2", { type: "string", value: "2" }],
+ ["foo", { type: "boolean", value: true }],
+ ],
+ },
+ },
+ {
+ value: {
+ 1: 1,
+ 2: "2",
+ foo: true,
+ },
+ serializationOptions: {
+ maxObjectDepth: 0,
+ },
+ serialized: {
+ type: "object",
+ },
+ },
+ {
+ value: {
+ 1: 1,
+ 2: "2",
+ foo: true,
+ },
+ serializationOptions: {
+ maxObjectDepth: 1,
+ },
+ serialized: {
+ type: "object",
+ value: [
+ ["1", { type: "number", value: 1 }],
+ ["2", { type: "string", value: "2" }],
+ ["foo", { type: "boolean", value: true }],
+ ],
+ },
+ deserializable: true,
+ },
+ {
+ value: {
+ 1: 1,
+ 2: "2",
+ 3: {
+ bar: "foo",
+ },
+ foo: true,
+ },
+ serializationOptions: {
+ maxObjectDepth: 2,
+ },
+ serialized: {
+ type: "object",
+ value: [
+ ["1", { type: "number", value: 1 }],
+ ["2", { type: "string", value: "2" }],
+ [
+ "3",
+ {
+ type: "object",
+ value: [["bar", { type: "string", value: "foo" }]],
+ },
+ ],
+ ["foo", { type: "boolean", value: true }],
+ ],
+ },
+ deserializable: true,
+ },
+];
+
+add_task(function test_deserializePrimitiveTypes() {
+ const realm = new Realm();
+
+ for (const type of PRIMITIVE_TYPES) {
+ const { value: expectedValue, serialized } = type;
+
+ info(`Checking '${serialized.type}'`);
+ const value = deserialize(serialized, realm, {});
+
+ if (serialized.value == "NaN") {
+ ok(Number.isNaN(value), `Got expected value for ${serialized}`);
+ } else {
+ Assert.strictEqual(
+ value,
+ expectedValue,
+ `Got expected value for ${serialized}`
+ );
+ }
+ }
+});
+
+add_task(function test_deserializeDateLocalValue() {
+ const realm = new Realm();
+
+ const validaDateStrings = [
+ "2009",
+ "2009-05",
+ "2009-05-19",
+ "2022-02-29",
+ "2009T15:00",
+ "2009-05T15:00",
+ "2022-06-31T15:00",
+ "2009-05-19T15:00",
+ "2009-05-19T15:00:15",
+ "2009-05-19T15:00-00:00",
+ "2009-05-19T15:00:15.452",
+ "2009-05-19T15:00:15.452Z",
+ "2009-05-19T15:00:15.452+02:00",
+ "2009-05-19T15:00:15.452-02:00",
+ "-271821-04-20T00:00:00Z",
+ "+000000-01-01T00:00:00Z",
+ ];
+ for (const dateString of validaDateStrings) {
+ info(`Checking '${dateString}'`);
+ const value = deserialize({ type: "date", value: dateString }, realm, {});
+
+ Assert.equal(
+ value.getTime(),
+ new Date(dateString).getTime(),
+ `Got expected value for ${dateString}`
+ );
+ }
+});
+
+add_task(function test_deserializeLocalValues() {
+ const realm = new Realm();
+
+ for (const type of REMOTE_SIMPLE_VALUES.concat(REMOTE_COMPLEX_VALUES)) {
+ const { value: expectedValue, serialized, deserializable } = type;
+
+ // Skip non deserializable cases
+ if (!deserializable) {
+ continue;
+ }
+
+ info(`Checking '${serialized.type}'`);
+ const value = deserialize(serialized, realm, {});
+ assertLocalValue(serialized.type, value, expectedValue);
+ }
+});
+
+add_task(async function test_deserializeLocalValuesInWindowRealm() {
+ for (const type of REMOTE_SIMPLE_VALUES.concat(REMOTE_COMPLEX_VALUES)) {
+ const { value: expectedValue, serialized, deserializable } = type;
+
+ // Skip non deserializable cases
+ if (!deserializable) {
+ continue;
+ }
+
+ const value = await deserializeInWindowRealm(serialized);
+ assertLocalValue(serialized.type, value, expectedValue);
+ }
+});
+
+add_task(async function test_deserializeChannel() {
+ const realm = new Realm();
+ const channel = {
+ type: "channel",
+ value: { channel: "channel_name" },
+ };
+ const deserializationOptions = {
+ emitScriptMessage: (realm, channelProperties, message) => message,
+ };
+
+ info(`Checking 'channel'`);
+ const value = deserialize(channel, realm, deserializationOptions, {});
+ Assert.equal(
+ Object.prototype.toString.call(value),
+ "[object Function]",
+ "Got expected type Function"
+ );
+ Assert.equal(value("foo"), "foo", "Got expected result");
+});
+
+add_task(function test_deserializeLocalValuesByHandle() {
+ // Create two realms, realm1 will be used to serialize values, while realm2
+ // will be used as a reference empty realm without any object reference.
+ const realm1 = new Realm();
+ const realm2 = new Realm();
+
+ for (const type of REMOTE_SIMPLE_VALUES.concat(REMOTE_COMPLEX_VALUES)) {
+ const { value: expectedValue, serialized } = type;
+
+ // No need to skip non-deserializable cases here.
+
+ info(`Checking '${serialized.type}'`);
+ // Serialize the value once to get a handle.
+ const serializedValue = serialize(
+ expectedValue,
+ { maxObjectDepth: 0 },
+ "root",
+ new Map(),
+ realm1,
+ {}
+ );
+
+ // Create a remote reference containing only the handle.
+ // `deserialize` should not need any other property.
+ const remoteReference = { handle: serializedValue.handle };
+
+ // Check that the remote reference can be deserialized in realm1.
+ const value = deserialize(remoteReference, realm1, {});
+ assertLocalValue(serialized.type, value, expectedValue);
+
+ Assert.throws(
+ () => deserialize(remoteReference, realm2, {}),
+ /NoSuchHandleError:/,
+ `Got expected error when using the wrong realm for deserialize`
+ );
+
+ realm1.removeObjectHandle(serializedValue.handle);
+ Assert.throws(
+ () => deserialize(remoteReference, realm1, {}),
+ /NoSuchHandleError:/,
+ `Got expected error when after deleting the object handle`
+ );
+ }
+});
+
+add_task(function test_deserializeHandleInvalidTypes() {
+ const realm = new Realm();
+
+ for (const invalidType of [false, 42, {}, []]) {
+ info(`Checking type: '${invalidType}'`);
+
+ Assert.throws(
+ () => deserialize({ type: "object", handle: invalidType }, realm, {}),
+ /InvalidArgumentError:/,
+ `Got expected error for type ${invalidType}`
+ );
+ }
+});
+
+add_task(function test_deserializePrimitiveTypesInvalidValues() {
+ const realm = new Realm();
+
+ const invalidValues = [
+ { type: "bigint", values: [undefined, null, false, "foo", [], {}] },
+ { type: "boolean", values: [undefined, null, 42, "foo", [], {}] },
+ {
+ type: "number",
+ values: [undefined, null, false, "43", [], {}],
+ },
+ { type: "string", values: [undefined, null, false, 42, [], {}] },
+ ];
+
+ for (const invalidValue of invalidValues) {
+ const { type, values } = invalidValue;
+
+ for (const value of values) {
+ info(`Checking '${type}' with value ${value}`);
+
+ Assert.throws(
+ () => deserialize({ type, value }, realm, {}),
+ /InvalidArgument/,
+ `Got expected error for type ${type} and value ${value}`
+ );
+ }
+ }
+});
+
+add_task(function test_deserializeDateLocalValueInvalidValues() {
+ const realm = new Realm();
+
+ const invalidaDateStrings = [
+ "10",
+ "20009",
+ "+20009",
+ "2009-",
+ "2009-0",
+ "2009-15",
+ "2009-02-1",
+ "2009-02-50",
+ "15:00",
+ "T15:00",
+ "9-05-19T15:00",
+ "2009-5-19T15:00",
+ "2009-05-1T15:00",
+ "2009-02-10T15",
+ "2009-05-19T15:",
+ "2009-05-19T1:00",
+ "2009-05-19T10:1",
+ "2009-05-19T60:00",
+ "2009-05-19T15:70",
+ "2009-05-19T15:00.25",
+ "2009-05-19+10:00",
+ "2009-05-19Z",
+ "2009-05-19 15:00",
+ "2009-05-19t15:00Z",
+ "2009-05-19T15:00z",
+ "2009-05-19T15:00+01",
+ "2009-05-19T10:10+1:00",
+ "2009-05-19T10:10+01:1",
+ "2009-05-19T15:00+75:00",
+ "2009-05-19T15:00+02:80",
+ "02009-05-19T15:00",
+ ];
+ for (const dateString of invalidaDateStrings) {
+ info(`Checking '${dateString}'`);
+
+ Assert.throws(
+ () => deserialize({ type: "date", value: dateString }, realm, {}),
+ /InvalidArgumentError:/,
+ `Got expected error for date string: ${dateString}`
+ );
+ }
+});
+
+add_task(function test_deserializeLocalValuesInvalidType() {
+ const realm = new Realm();
+
+ const invalidTypes = [undefined, null, false, 42, {}];
+
+ for (const invalidType of invalidTypes) {
+ info(`Checking type: '${invalidType}'`);
+
+ Assert.throws(
+ () => deserialize({ type: invalidType }, realm, {}),
+ /InvalidArgumentError:/,
+ `Got expected error for type ${invalidType}`
+ );
+
+ Assert.throws(
+ () =>
+ deserialize(
+ {
+ type: "array",
+ value: [{ type: invalidType }],
+ },
+ realm,
+ {}
+ ),
+ /InvalidArgumentError:/,
+ `Got expected error for nested type ${invalidType}`
+ );
+ }
+});
+
+add_task(function test_deserializeLocalValuesInvalidValues() {
+ const realm = new Realm();
+
+ const invalidValues = [
+ { type: "array", values: [undefined, null, false, 42, "foo", {}] },
+ {
+ type: "regexp",
+ values: [
+ undefined,
+ null,
+ false,
+ "foo",
+ 42,
+ [],
+ {},
+ { pattern: null },
+ { pattern: 1 },
+ { pattern: true },
+ { pattern: "foo", flags: null },
+ { pattern: "foo", flags: 1 },
+ { pattern: "foo", flags: false },
+ { pattern: "foo", flags: "foo" },
+ ],
+ },
+ {
+ type: "date",
+ values: [
+ undefined,
+ null,
+ false,
+ "foo",
+ "05 October 2011 14:48 UTC",
+ "Tue Jun 14 2022 10:46:50 GMT+0200!",
+ 42,
+ [],
+ {},
+ ],
+ },
+ {
+ type: "map",
+ values: [
+ undefined,
+ null,
+ false,
+ "foo",
+ 42,
+ ["1"],
+ [[]],
+ [["1"]],
+ [{ 1: "2" }],
+ {},
+ ],
+ },
+ {
+ type: "set",
+ values: [undefined, null, false, "foo", 42, {}],
+ },
+ {
+ type: "object",
+ values: [
+ undefined,
+ null,
+ false,
+ "foo",
+ 42,
+ {},
+ ["1"],
+ [[]],
+ [["1"]],
+ [{ 1: "2" }],
+ [
+ [
+ { type: "number", value: "1" },
+ { type: "number", value: "2" },
+ ],
+ ],
+ [
+ [
+ { type: "object", value: [] },
+ { type: "number", value: "1" },
+ ],
+ ],
+ [
+ [
+ {
+ type: "regexp",
+ value: {
+ pattern: "foo",
+ },
+ },
+ { type: "number", value: "1" },
+ ],
+ ],
+ ],
+ },
+ ];
+
+ for (const invalidValue of invalidValues) {
+ const { type, values } = invalidValue;
+
+ for (const value of values) {
+ info(`Checking '${type}' with value ${value}`);
+
+ Assert.throws(
+ () => deserialize({ type, value }, realm, {}),
+ /InvalidArgumentError:/,
+ `Got expected error for type ${type} and value ${value}`
+ );
+ }
+ }
+});
+
+add_task(function test_serializePrimitiveTypes() {
+ const realm = new Realm();
+
+ for (const type of PRIMITIVE_TYPES) {
+ const { value, serialized } = type;
+ const defaultSerializationOptions = setDefaultSerializationOptions();
+
+ const serializationInternalMap = new Map();
+ const serializedValue = serialize(
+ value,
+ defaultSerializationOptions,
+ "none",
+ serializationInternalMap,
+ realm,
+ {}
+ );
+ assertInternalIds(serializationInternalMap, 0);
+ Assert.deepEqual(serialized, serializedValue, "Got expected structure");
+
+ // For primitive values, the serialization with ownershipType=root should
+ // be exactly identical to the one with ownershipType=none.
+ const serializationInternalMapWithRoot = new Map();
+ const serializedWithRoot = serialize(
+ value,
+ defaultSerializationOptions,
+ "root",
+ serializationInternalMapWithRoot,
+ realm,
+ {}
+ );
+ assertInternalIds(serializationInternalMapWithRoot, 0);
+ Assert.deepEqual(serialized, serializedWithRoot, "Got expected structure");
+ }
+});
+
+add_task(function test_serializeRemoteSimpleValues() {
+ const realm = new Realm();
+
+ for (const type of REMOTE_SIMPLE_VALUES) {
+ const { value, serialized } = type;
+ const defaultSerializationOptions = setDefaultSerializationOptions();
+
+ info(`Checking '${serialized.type}' with none ownershipType`);
+ const serializationInternalMapWithNone = new Map();
+ const serializedValue = serialize(
+ value,
+ defaultSerializationOptions,
+ "none",
+ serializationInternalMapWithNone,
+ realm,
+ {}
+ );
+
+ assertInternalIds(serializationInternalMapWithNone, 0);
+ Assert.deepEqual(serialized, serializedValue, "Got expected structure");
+
+ info(`Checking '${serialized.type}' with root ownershipType`);
+ const serializationInternalMapWithRoot = new Map();
+ const serializedWithRoot = serialize(
+ value,
+ defaultSerializationOptions,
+ "root",
+ serializationInternalMapWithRoot,
+ realm,
+ {}
+ );
+
+ assertInternalIds(serializationInternalMapWithRoot, 0);
+ Assert.equal(
+ typeof serializedWithRoot.handle,
+ "string",
+ "Got a handle property"
+ );
+ Assert.deepEqual(
+ Object.assign({}, serialized, { handle: serializedWithRoot.handle }),
+ serializedWithRoot,
+ "Got expected structure, plus a generated handle id"
+ );
+ }
+});
+
+add_task(function test_serializeRemoteComplexValues() {
+ for (const type of REMOTE_COMPLEX_VALUES) {
+ const { value, serialized, serializationOptions } = type;
+ const serializationOptionsWithDefaults =
+ setDefaultSerializationOptions(serializationOptions);
+
+ info(`Checking '${serialized.type}' with none ownershipType`);
+ const realm = new Realm();
+ const serializationInternalMapWithNone = new Map();
+
+ const serializedValue = serialize(
+ value,
+ serializationOptionsWithDefaults,
+ "none",
+ serializationInternalMapWithNone,
+ realm,
+ {}
+ );
+
+ assertInternalIds(serializationInternalMapWithNone, 0);
+ Assert.deepEqual(serialized, serializedValue, "Got expected structure");
+
+ info(`Checking '${serialized.type}' with root ownershipType`);
+ const serializationInternalMapWithRoot = new Map();
+ const serializedWithRoot = serialize(
+ value,
+ serializationOptionsWithDefaults,
+ "root",
+ serializationInternalMapWithRoot,
+ realm,
+ {}
+ );
+
+ assertInternalIds(serializationInternalMapWithRoot, 0);
+ Assert.equal(
+ typeof serializedWithRoot.handle,
+ "string",
+ "Got a handle property"
+ );
+ Assert.deepEqual(
+ Object.assign({}, serialized, { handle: serializedWithRoot.handle }),
+ serializedWithRoot,
+ "Got expected structure, plus a generated handle id"
+ );
+ }
+});
+
+add_task(function test_serializeWithSerializationInternalMap() {
+ const dataSet = [
+ {
+ data: [1],
+ serializedData: [{ type: "number", value: 1 }],
+ type: "array",
+ },
+ {
+ data: new Map([[true, false]]),
+ serializedData: [
+ [
+ { type: "boolean", value: true },
+ { type: "boolean", value: false },
+ ],
+ ],
+ type: "map",
+ },
+ {
+ data: new Set(["foo"]),
+ serializedData: [{ type: "string", value: "foo" }],
+ type: "set",
+ },
+ {
+ data: { foo: "bar" },
+ serializedData: [["foo", { type: "string", value: "bar" }]],
+ type: "object",
+ },
+ ];
+ const realm = new Realm();
+
+ for (const { type, data, serializedData } of dataSet) {
+ info(`Checking '${type}' with serializationInternalMap`);
+
+ const serializationInternalMap = new Map();
+ const value = [
+ data,
+ data,
+ [data],
+ new Set([data]),
+ new Map([["bar", data]]),
+ { bar: data },
+ ];
+
+ const serializedValue = serialize(
+ value,
+ { maxObjectDepth: 2 },
+ "none",
+ serializationInternalMap,
+ realm,
+ {}
+ );
+
+ assertInternalIds(serializationInternalMap, 1);
+
+ const internalId = serializationInternalMap.get(data).internalId;
+
+ const serialized = {
+ type: "array",
+ value: [
+ {
+ type,
+ value: serializedData,
+ internalId,
+ },
+ {
+ type,
+ internalId,
+ },
+ {
+ type: "array",
+ value: [{ type, internalId }],
+ },
+ {
+ type: "set",
+ value: [{ type, internalId }],
+ },
+ {
+ type: "map",
+ value: [["bar", { type, internalId }]],
+ },
+ {
+ type: "object",
+ value: [["bar", { type, internalId }]],
+ },
+ ],
+ };
+
+ Assert.deepEqual(serialized, serializedValue, "Got expected structure");
+ }
+});
+
+add_task(function test_serializeMultipleValuesWithSerializationInternalMap() {
+ const realm = new Realm();
+ const serializationInternalMap = new Map();
+ const obj1 = { foo: "bar" };
+ const obj2 = [1, 2];
+ const value = [obj1, obj2, obj1, obj2];
+
+ serialize(
+ value,
+ { maxObjectDepth: 2 },
+ "none",
+ serializationInternalMap,
+ realm,
+ {}
+ );
+
+ assertInternalIds(serializationInternalMap, 2);
+
+ const internalId1 = serializationInternalMap.get(obj1).internalId;
+ const internalId2 = serializationInternalMap.get(obj2).internalId;
+
+ Assert.notEqual(
+ internalId1,
+ internalId2,
+ "Internal ids for different object are also different"
+ );
+});
+
+add_task(function test_stringify() {
+ const STRINGIFY_TEST_CASES = [
+ [undefined, "undefined"],
+ [null, "null"],
+ ["foobar", "foobar"],
+ ["2", "2"],
+ [-0, "0"],
+ [Infinity, "Infinity"],
+ [-Infinity, "-Infinity"],
+ [3, "3"],
+ [1.4, "1.4"],
+ [true, "true"],
+ [42n, "42"],
+ [{ toString: () => "bar" }, "bar", "toString: () => 'bar'"],
+ [{ toString: () => 4 }, "[object Object]", "toString: () => 4"],
+ [{ toString: undefined }, "[object Object]", "toString: undefined"],
+ [{ toString: null }, "[object Object]", "toString: null"],
+ [
+ {
+ toString: () => {
+ throw new Error("toString error");
+ },
+ },
+ "[object Object]",
+ "toString: () => { throw new Error('toString error'); }",
+ ],
+ ];
+
+ for (const [value, expectedString, description] of STRINGIFY_TEST_CASES) {
+ info(`Checking '${description || value}'`);
+ const stringifiedValue = stringify(value);
+
+ Assert.strictEqual(expectedString, stringifiedValue, "Got expected string");
+ }
+});
+
+function assertLocalValue(type, value, expectedValue) {
+ let formattedValue = value;
+ let formattedExpectedValue = expectedValue;
+
+ // Format certain types for easier assertion
+ if (type == "map") {
+ Assert.equal(
+ Object.prototype.toString.call(expectedValue),
+ "[object Map]",
+ "Got expected type Map"
+ );
+
+ formattedValue = Array.from(value.values());
+ formattedExpectedValue = Array.from(expectedValue.values());
+ } else if (type == "set") {
+ Assert.equal(
+ Object.prototype.toString.call(expectedValue),
+ "[object Set]",
+ "Got expected type Set"
+ );
+
+ formattedValue = Array.from(value);
+ formattedExpectedValue = Array.from(expectedValue);
+ }
+
+ Assert.deepEqual(
+ formattedValue,
+ formattedExpectedValue,
+ "Got expected structure"
+ );
+}
+
+function assertInternalIds(serializationInternalMap, amount) {
+ const remoteValuesWithInternalIds = Array.from(
+ serializationInternalMap.values()
+ ).filter(remoteValue => !!remoteValue.internalId);
+
+ Assert.equal(
+ remoteValuesWithInternalIds.length,
+ amount,
+ "Got expected amount of internalIds in serializationInternalMap"
+ );
+}
+
+function deserializeInWindowRealm(serialized) {
+ return SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [serialized],
+ async _serialized => {
+ const { WindowRealm } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/Realm.sys.mjs"
+ );
+ const { deserialize } = ChromeUtils.importESModule(
+ "chrome://remote/content/webdriver-bidi/RemoteValue.sys.mjs"
+ );
+ const realm = new WindowRealm(content);
+ info(`Checking '${_serialized.type}'`);
+ return deserialize(_serialized, realm, {});
+ }
+ );
+}
diff --git a/remote/webdriver-bidi/test/browser/browser_RemoteValueDOM.js b/remote/webdriver-bidi/test/browser/browser_RemoteValueDOM.js
new file mode 100644
index 0000000000..3e72c9c659
--- /dev/null
+++ b/remote/webdriver-bidi/test/browser/browser_RemoteValueDOM.js
@@ -0,0 +1,845 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/* eslint no-undef: 0 no-unused-vars: 0 */
+
+add_task(async function test_deserializeSharedIdInvalidTypes() {
+ await runTestInContent(() => {
+ for (const invalidType of [false, 42, {}, []]) {
+ info(`Checking type: '${invalidType}'`);
+
+ const serializedValue = {
+ sharedId: invalidType,
+ };
+
+ Assert.throws(
+ () => deserialize(serializedValue, realm, { nodeCache }),
+ /InvalidArgumentError:/,
+ `Got expected error for type ${invalidType}`
+ );
+ }
+ });
+});
+
+add_task(async function test_deserializeSharedIdInvalidValue() {
+ await runTestInContent(() => {
+ const serializedValue = {
+ sharedId: "foo",
+ };
+
+ Assert.throws(
+ () => deserialize(serializedValue, realm, { nodeCache }),
+ /NoSuchNodeError:/,
+ "Got expected error for unknown 'sharedId'"
+ );
+ });
+});
+
+add_task(async function test_deserializeSharedId() {
+ await loadURL(inline("<div>"));
+
+ await runTestInContent(() => {
+ const domEl = content.document.querySelector("div");
+ const domElRef = nodeCache.getOrCreateNodeReference(domEl, seenNodeIds);
+
+ const serializedValue = {
+ sharedId: domElRef,
+ };
+
+ const node = deserialize(serializedValue, realm, { nodeCache });
+
+ Assert.equal(node, domEl);
+ });
+});
+
+add_task(async function test_deserializeSharedIdPrecedenceOverHandle() {
+ await loadURL(inline("<div>"));
+
+ await runTestInContent(() => {
+ const domEl = content.document.querySelector("div");
+ const domElRef = nodeCache.getOrCreateNodeReference(domEl, seenNodeIds);
+
+ const serializedValue = {
+ handle: "foo",
+ sharedId: domElRef,
+ };
+
+ const node = deserialize(serializedValue, realm, { nodeCache });
+
+ Assert.equal(node, domEl);
+ });
+});
+
+add_task(async function test_deserializeSharedIdNoWindowRealm() {
+ await loadURL(inline("<div>"));
+
+ await runTestInContent(() => {
+ const domEl = content.document.querySelector("div");
+ const domElRef = nodeCache.getOrCreateNodeReference(domEl, seenNodeIds);
+
+ const serializedValue = {
+ sharedId: domElRef,
+ };
+
+ Assert.throws(
+ () => deserialize(serializedValue, new Realm(), { nodeCache }),
+ /NoSuchNodeError/,
+ `Got expected error for a non-window realm`
+ );
+ });
+});
+
+// Bug 1819902: Instead of a browsing context check compare the origin
+add_task(async function test_deserializeSharedIdOtherBrowsingContext() {
+ await loadURL(inline("<iframe>"));
+
+ await runTestInContent(() => {
+ const iframeEl = content.document.querySelector("iframe");
+ const domEl = iframeEl.contentWindow.document.createElement("div");
+ iframeEl.contentWindow.document.body.appendChild(domEl);
+
+ const domElRef = nodeCache.getOrCreateNodeReference(domEl, seenNodeIds);
+
+ const serializedValue = {
+ sharedId: domElRef,
+ };
+
+ const node = deserialize(serializedValue, realm, { nodeCache });
+
+ Assert.equal(node, null);
+ });
+});
+
+add_task(async function test_serializeRemoteComplexValues() {
+ await loadURL(inline("<div>"));
+
+ await runTestInContent(() => {
+ const domEl = content.document.querySelector("div");
+ const domElRef = nodeCache.getOrCreateNodeReference(domEl, seenNodeIds);
+
+ const REMOTE_COMPLEX_VALUES = [
+ {
+ value: content.document.querySelector("div"),
+ serialized: {
+ type: "node",
+ sharedId: domElRef,
+ value: {
+ attributes: {},
+ childNodeCount: 0,
+ localName: "div",
+ namespaceURI: "http://www.w3.org/1999/xhtml",
+ nodeType: 1,
+ shadowRoot: null,
+ },
+ },
+ },
+ {
+ value: content.document.querySelectorAll("div"),
+ serialized: {
+ type: "nodelist",
+ value: [
+ {
+ type: "node",
+ sharedId: domElRef,
+ value: {
+ nodeType: 1,
+ localName: "div",
+ namespaceURI: "http://www.w3.org/1999/xhtml",
+ childNodeCount: 0,
+ attributes: {},
+ shadowRoot: null,
+ },
+ },
+ ],
+ },
+ },
+ {
+ value: content.document.getElementsByTagName("div"),
+ serialized: {
+ type: "htmlcollection",
+ value: [
+ {
+ type: "node",
+ sharedId: domElRef,
+ value: {
+ nodeType: 1,
+ localName: "div",
+ namespaceURI: "http://www.w3.org/1999/xhtml",
+ childNodeCount: 0,
+ attributes: {},
+ shadowRoot: null,
+ },
+ },
+ ],
+ },
+ },
+ ];
+
+ for (const type of REMOTE_COMPLEX_VALUES) {
+ serializeAndAssertRemoteValue(type);
+ }
+ });
+});
+
+add_task(async function test_serializeWindow() {
+ await loadURL(inline("<iframe>"));
+
+ await runTestInContent(() => {
+ const REMOTE_COMPLEX_VALUES = [
+ {
+ value: content,
+ serialized: {
+ type: "window",
+ value: {
+ context: content.browsingContext.browserId.toString(),
+ isTopBrowsingContext: true,
+ },
+ },
+ },
+ {
+ value: content.frames[0],
+ serialized: {
+ type: "window",
+ value: {
+ context: content.frames[0].browsingContext.id.toString(),
+ },
+ },
+ },
+ {
+ value: content.document.querySelector("iframe").contentWindow,
+ serialized: {
+ type: "window",
+ value: {
+ context: content.document
+ .querySelector("iframe")
+ .contentWindow.browsingContext.id.toString(),
+ },
+ },
+ },
+ ];
+
+ for (const type of REMOTE_COMPLEX_VALUES) {
+ serializeAndAssertRemoteValue(type);
+ }
+ });
+});
+
+add_task(async function test_serializeNodeChildren() {
+ await loadURL(inline("<div></div><iframe/>"));
+
+ await runTestInContent(() => {
+ // Add the used elements to the cache so that we know the unique reference.
+ const bodyEl = content.document.body;
+ const domEl = bodyEl.querySelector("div");
+ const iframeEl = bodyEl.querySelector("iframe");
+
+ const bodyElRef = nodeCache.getOrCreateNodeReference(bodyEl, seenNodeIds);
+ const domElRef = nodeCache.getOrCreateNodeReference(domEl, seenNodeIds);
+ const iframeElRef = nodeCache.getOrCreateNodeReference(
+ iframeEl,
+ seenNodeIds
+ );
+
+ const dataSet = [
+ {
+ node: bodyEl,
+ serializationOptions: {
+ maxDomDepth: null,
+ },
+ serialized: {
+ type: "node",
+ sharedId: bodyElRef,
+ value: {
+ nodeType: 1,
+ localName: "body",
+ namespaceURI: "http://www.w3.org/1999/xhtml",
+ childNodeCount: 2,
+ children: [
+ {
+ type: "node",
+ sharedId: domElRef,
+ value: {
+ nodeType: 1,
+ localName: "div",
+ namespaceURI: "http://www.w3.org/1999/xhtml",
+ childNodeCount: 0,
+ children: [],
+ attributes: {},
+ shadowRoot: null,
+ },
+ },
+ {
+ type: "node",
+ sharedId: iframeElRef,
+ value: {
+ nodeType: 1,
+ localName: "iframe",
+ namespaceURI: "http://www.w3.org/1999/xhtml",
+ childNodeCount: 0,
+ children: [],
+ attributes: {},
+ shadowRoot: null,
+ },
+ },
+ ],
+ attributes: {},
+ shadowRoot: null,
+ },
+ },
+ },
+ {
+ node: bodyEl,
+ serializationOptions: {
+ maxDomDepth: 0,
+ },
+ serialized: {
+ type: "node",
+ sharedId: bodyElRef,
+ value: {
+ attributes: {},
+ childNodeCount: 2,
+ localName: "body",
+ namespaceURI: "http://www.w3.org/1999/xhtml",
+ nodeType: 1,
+ shadowRoot: null,
+ },
+ },
+ },
+ {
+ node: bodyEl,
+ serializationOptions: {
+ maxDomDepth: 1,
+ },
+ serialized: {
+ type: "node",
+ sharedId: bodyElRef,
+ value: {
+ attributes: {},
+ childNodeCount: 2,
+ children: [
+ {
+ type: "node",
+ sharedId: domElRef,
+ value: {
+ attributes: {},
+ childNodeCount: 0,
+ localName: "div",
+ namespaceURI: "http://www.w3.org/1999/xhtml",
+ nodeType: 1,
+ shadowRoot: null,
+ },
+ },
+ {
+ type: "node",
+ sharedId: iframeElRef,
+ value: {
+ attributes: {},
+ childNodeCount: 0,
+ localName: "iframe",
+ namespaceURI: "http://www.w3.org/1999/xhtml",
+ nodeType: 1,
+ shadowRoot: null,
+ },
+ },
+ ],
+ localName: "body",
+ namespaceURI: "http://www.w3.org/1999/xhtml",
+ nodeType: 1,
+ shadowRoot: null,
+ },
+ },
+ },
+ {
+ node: domEl,
+ serializationOptions: {
+ maxDomDepth: 0,
+ },
+ serialized: {
+ type: "node",
+ sharedId: domElRef,
+ value: {
+ attributes: {},
+ childNodeCount: 0,
+ localName: "div",
+ namespaceURI: "http://www.w3.org/1999/xhtml",
+ nodeType: 1,
+ shadowRoot: null,
+ },
+ },
+ },
+ {
+ node: domEl,
+ serializationOptions: {
+ maxDomDepth: 1,
+ },
+ serialized: {
+ type: "node",
+ sharedId: domElRef,
+ value: {
+ attributes: {},
+ childNodeCount: 0,
+ children: [],
+ localName: "div",
+ namespaceURI: "http://www.w3.org/1999/xhtml",
+ nodeType: 1,
+ shadowRoot: null,
+ },
+ },
+ },
+ ];
+
+ for (const { node, serializationOptions, serialized } of dataSet) {
+ const { maxDomDepth } = serializationOptions;
+ info(`Checking '${node.localName}' with maxDomDepth ${maxDomDepth}`);
+
+ const serializationInternalMap = new Map();
+
+ const serializedValue = serialize(
+ node,
+ serializationOptions,
+ "none",
+ serializationInternalMap,
+ realm,
+ { nodeCache, seenNodeIds }
+ );
+
+ Assert.deepEqual(serializedValue, serialized, "Got expected structure");
+ }
+ });
+});
+
+add_task(async function test_serializeNodeEmbeddedWithin() {
+ await loadURL(inline("<div>"));
+
+ await runTestInContent(() => {
+ // Add the used elements to the cache so that we know the unique reference.
+ const bodyEl = content.document.body;
+ const domEl = bodyEl.querySelector("div");
+ const domElRef = nodeCache.getOrCreateNodeReference(domEl, seenNodeIds);
+
+ const dataSet = [
+ {
+ embedder: "array",
+ wrapper: node => [node],
+ serialized: {
+ type: "array",
+ value: [
+ {
+ type: "node",
+ sharedId: domElRef,
+ value: {
+ attributes: {},
+ childNodeCount: 0,
+ localName: "div",
+ namespaceURI: "http://www.w3.org/1999/xhtml",
+ nodeType: 1,
+ shadowRoot: null,
+ },
+ },
+ ],
+ },
+ },
+ {
+ embedder: "map",
+ wrapper: node => {
+ const map = new Map();
+ map.set(node, "elem");
+ return map;
+ },
+ serialized: {
+ type: "map",
+ value: [
+ [
+ {
+ type: "node",
+ sharedId: domElRef,
+ value: {
+ attributes: {},
+ childNodeCount: 0,
+ localName: "div",
+ namespaceURI: "http://www.w3.org/1999/xhtml",
+ nodeType: 1,
+ shadowRoot: null,
+ },
+ },
+ {
+ type: "string",
+ value: "elem",
+ },
+ ],
+ ],
+ },
+ },
+ {
+ embedder: "map",
+ wrapper: node => {
+ const map = new Map();
+ map.set("elem", node);
+ return map;
+ },
+ serialized: {
+ type: "map",
+ value: [
+ [
+ "elem",
+ {
+ type: "node",
+ sharedId: domElRef,
+ value: {
+ attributes: {},
+ childNodeCount: 0,
+ localName: "div",
+ namespaceURI: "http://www.w3.org/1999/xhtml",
+ nodeType: 1,
+ shadowRoot: null,
+ },
+ },
+ ],
+ ],
+ },
+ },
+ {
+ embedder: "object",
+ wrapper: node => ({ elem: node }),
+ serialized: {
+ type: "object",
+ value: [
+ [
+ "elem",
+ {
+ type: "node",
+ sharedId: domElRef,
+ value: {
+ attributes: {},
+ childNodeCount: 0,
+ localName: "div",
+ namespaceURI: "http://www.w3.org/1999/xhtml",
+ nodeType: 1,
+ shadowRoot: null,
+ },
+ },
+ ],
+ ],
+ },
+ },
+ {
+ embedder: "set",
+ wrapper: node => {
+ const set = new Set();
+ set.add(node);
+ return set;
+ },
+ serialized: {
+ type: "set",
+ value: [
+ {
+ type: "node",
+ sharedId: domElRef,
+ value: {
+ attributes: {},
+ childNodeCount: 0,
+ localName: "div",
+ namespaceURI: "http://www.w3.org/1999/xhtml",
+ nodeType: 1,
+ shadowRoot: null,
+ },
+ },
+ ],
+ },
+ },
+ ];
+
+ for (const { embedder, wrapper, serialized } of dataSet) {
+ info(`Checking embedding node within ${embedder}`);
+
+ const serializationInternalMap = new Map();
+
+ const serializedValue = serialize(
+ wrapper(domEl),
+ { maxDomDepth: 0 },
+ "none",
+ serializationInternalMap,
+ realm,
+ { nodeCache }
+ );
+
+ Assert.deepEqual(serializedValue, serialized, "Got expected structure");
+ }
+ });
+});
+
+add_task(async function test_serializeShadowRoot() {
+ await runTestInContent(() => {
+ for (const mode of ["open", "closed"]) {
+ info(`Checking shadow root with mode '${mode}'`);
+ const customElement = content.document.createElement(
+ `${mode}-custom-element`
+ );
+ const insideShadowRootElement = content.document.createElement("input");
+ content.document.body.appendChild(customElement);
+ const shadowRoot = customElement.attachShadow({ mode });
+ shadowRoot.appendChild(insideShadowRootElement);
+
+ // Add the used elements to the cache so that we know the unique reference.
+ const customElementRef = nodeCache.getOrCreateNodeReference(
+ customElement,
+ seenNodeIds
+ );
+ const shadowRootRef = nodeCache.getOrCreateNodeReference(
+ shadowRoot,
+ seenNodeIds
+ );
+ const insideShadowRootElementRef = nodeCache.getOrCreateNodeReference(
+ insideShadowRootElement,
+ seenNodeIds
+ );
+
+ const dataSet = [
+ {
+ node: customElement,
+ serializationOptions: {
+ maxDomDepth: 1,
+ },
+ serialized: {
+ type: "node",
+ sharedId: customElementRef,
+ value: {
+ attributes: {},
+ childNodeCount: 0,
+ children: [],
+ localName: `${mode}-custom-element`,
+ namespaceURI: "http://www.w3.org/1999/xhtml",
+ nodeType: 1,
+ shadowRoot: {
+ sharedId: shadowRootRef,
+ type: "node",
+ value: {
+ childNodeCount: 1,
+ mode,
+ nodeType: 11,
+ },
+ },
+ },
+ },
+ },
+ {
+ node: customElement,
+ serializationOptions: {
+ includeShadowTree: "open",
+ maxDomDepth: 1,
+ },
+ serialized: {
+ type: "node",
+ sharedId: customElementRef,
+ value: {
+ attributes: {},
+ childNodeCount: 0,
+ children: [],
+ localName: `${mode}-custom-element`,
+ namespaceURI: "http://www.w3.org/1999/xhtml",
+ nodeType: 1,
+ shadowRoot: {
+ sharedId: shadowRootRef,
+ type: "node",
+ value: {
+ childNodeCount: 1,
+ mode,
+ nodeType: 11,
+ ...(mode === "open"
+ ? {
+ children: [
+ {
+ type: "node",
+ sharedId: insideShadowRootElementRef,
+ value: {
+ nodeType: 1,
+ localName: "input",
+ namespaceURI: "http://www.w3.org/1999/xhtml",
+ childNodeCount: 0,
+ attributes: {},
+ shadowRoot: null,
+ },
+ },
+ ],
+ }
+ : {}),
+ },
+ },
+ },
+ },
+ },
+ {
+ node: customElement,
+ serializationOptions: {
+ includeShadowTree: "all",
+ maxDomDepth: 1,
+ },
+ serialized: {
+ type: "node",
+ sharedId: customElementRef,
+ value: {
+ attributes: {},
+ childNodeCount: 0,
+ children: [],
+ localName: `${mode}-custom-element`,
+ namespaceURI: "http://www.w3.org/1999/xhtml",
+ nodeType: 1,
+ shadowRoot: {
+ sharedId: shadowRootRef,
+ type: "node",
+ value: {
+ childNodeCount: 1,
+ mode,
+ nodeType: 11,
+ children: [
+ {
+ type: "node",
+ sharedId: insideShadowRootElementRef,
+ value: {
+ nodeType: 1,
+ localName: "input",
+ namespaceURI: "http://www.w3.org/1999/xhtml",
+ childNodeCount: 0,
+ attributes: {},
+ shadowRoot: null,
+ },
+ },
+ ],
+ },
+ },
+ },
+ },
+ },
+ ];
+
+ for (const { node, serializationOptions, serialized } of dataSet) {
+ const { maxDomDepth, includeShadowTree } = serializationOptions;
+ info(
+ `Checking shadow root with maxDomDepth ${maxDomDepth} and includeShadowTree ${includeShadowTree}`
+ );
+
+ const serializationInternalMap = new Map();
+
+ const serializedValue = serialize(
+ node,
+ serializationOptions,
+ "none",
+ serializationInternalMap,
+ realm,
+ { nodeCache }
+ );
+
+ Assert.deepEqual(serializedValue, serialized, "Got expected structure");
+ }
+ }
+ });
+});
+
+add_task(async function test_serializeNodeSharedId() {
+ await loadURL(inline("<div>"));
+
+ await runTestInContent(() => {
+ const domEl = content.document.querySelector("div");
+
+ // Already add the domEl to the cache so that we know the unique reference.
+ const domElRef = nodeCache.getOrCreateNodeReference(domEl, seenNodeIds);
+
+ const serializedValue = serialize(
+ domEl,
+ { maxDomDepth: 0 },
+ "root",
+ serializationInternalMap,
+ realm,
+ { nodeCache, seenNodeIds }
+ );
+
+ Assert.equal(nodeCache.size, 1, "No additional reference added");
+ Assert.equal(serializedValue.sharedId, domElRef);
+ Assert.notEqual(serializedValue.handle, domElRef);
+ });
+});
+
+function runTestInContent(callback) {
+ return SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [callback.toString()],
+ async callback => {
+ const { NodeCache } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/webdriver/NodeCache.sys.mjs"
+ );
+ const { Realm, WindowRealm } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/Realm.sys.mjs"
+ );
+ const { deserialize, serialize, setDefaultSerializationOptions } =
+ ChromeUtils.importESModule(
+ "chrome://remote/content/webdriver-bidi/RemoteValue.sys.mjs"
+ );
+
+ function assertInternalIds(serializationInternalMap, amount) {
+ const remoteValuesWithInternalIds = Array.from(
+ serializationInternalMap.values()
+ ).filter(remoteValue => !!remoteValue.internalId);
+
+ Assert.equal(
+ remoteValuesWithInternalIds.length,
+ amount,
+ "Got expected amount of internalIds in serializationInternalMap"
+ );
+ }
+
+ const nodeCache = new NodeCache();
+ const seenNodeIds = new Map();
+ const realm = new WindowRealm(content);
+ const serializationInternalMap = new Map();
+
+ function serializeAndAssertRemoteValue(remoteValue) {
+ const { value, serialized } = remoteValue;
+ const serializationOptionsWithDefaults =
+ setDefaultSerializationOptions();
+ const serializationInternalMapWithNone = new Map();
+
+ info(`Checking '${serialized.type}' with none ownershipType`);
+
+ const serializedValue = serialize(
+ value,
+ serializationOptionsWithDefaults,
+ "none",
+ serializationInternalMapWithNone,
+ realm,
+ { nodeCache, seenNodeIds }
+ );
+
+ assertInternalIds(serializationInternalMapWithNone, 0);
+ Assert.deepEqual(serialized, serializedValue, "Got expected structure");
+
+ info(`Checking '${serialized.type}' with root ownershipType`);
+ const serializationInternalMapWithRoot = new Map();
+ const serializedWithRoot = serialize(
+ value,
+ serializationOptionsWithDefaults,
+ "root",
+ serializationInternalMapWithRoot,
+ realm,
+ { nodeCache, seenNodeIds }
+ );
+
+ assertInternalIds(serializationInternalMapWithRoot, 0);
+ Assert.equal(
+ typeof serializedWithRoot.handle,
+ "string",
+ "Got a handle property"
+ );
+ Assert.deepEqual(
+ Object.assign({}, serialized, { handle: serializedWithRoot.handle }),
+ serializedWithRoot,
+ "Got expected structure, plus a generated handle id"
+ );
+ }
+
+ // eslint-disable-next-line no-eval
+ eval(`(${callback})()`);
+ }
+ );
+}
diff --git a/remote/webdriver-bidi/test/browser/head.js b/remote/webdriver-bidi/test/browser/head.js
new file mode 100644
index 0000000000..e9a125a193
--- /dev/null
+++ b/remote/webdriver-bidi/test/browser/head.js
@@ -0,0 +1,28 @@
+/**
+ * Load a given URL in the currently selected tab
+ */
+async function loadURL(url, expectedURL = undefined) {
+ expectedURL = expectedURL || url;
+
+ const browser = gBrowser.selectedTab.linkedBrowser;
+ const loaded = BrowserTestUtils.browserLoaded(browser, true, expectedURL);
+
+ BrowserTestUtils.startLoadingURIString(browser, url);
+ await loaded;
+}
+
+/** Creates an inline URL for the given source document. */
+function inline(src, doctype = "html") {
+ let doc;
+ switch (doctype) {
+ case "html":
+ doc = `<!doctype html>\n<meta charset=utf-8>\n${src}`;
+ break;
+ default:
+ throw new Error("Unexpected doctype: " + doctype);
+ }
+
+ return `https://example.com/document-builder.sjs?html=${encodeURIComponent(
+ doc
+ )}`;
+}
diff --git a/remote/webdriver-bidi/test/xpcshell/test_WebDriverBiDiConnection.js b/remote/webdriver-bidi/test/xpcshell/test_WebDriverBiDiConnection.js
new file mode 100644
index 0000000000..e10c77caf3
--- /dev/null
+++ b/remote/webdriver-bidi/test/xpcshell/test_WebDriverBiDiConnection.js
@@ -0,0 +1,25 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { splitMethod } = ChromeUtils.importESModule(
+ "chrome://remote/content/webdriver-bidi/WebDriverBiDiConnection.sys.mjs"
+);
+
+add_task(function test_Connection_splitMethod() {
+ for (const t of [42, null, true, {}, [], undefined]) {
+ Assert.throws(() => splitMethod(t), /TypeError/, `${typeof t} throws`);
+ }
+ for (const s of ["", ".", "foo.", ".bar", "foo.bar.baz"]) {
+ Assert.throws(
+ () => splitMethod(s),
+ /Invalid method format: '.*'/,
+ `"${s}" throws`
+ );
+ }
+ deepEqual(splitMethod("foo.bar"), {
+ module: "foo",
+ command: "bar",
+ });
+});
diff --git a/remote/webdriver-bidi/test/xpcshell/xpcshell.toml b/remote/webdriver-bidi/test/xpcshell/xpcshell.toml
new file mode 100644
index 0000000000..31cd9e3f04
--- /dev/null
+++ b/remote/webdriver-bidi/test/xpcshell/xpcshell.toml
@@ -0,0 +1,3 @@
+[DEFAULT]
+
+["test_WebDriverBiDiConnection.js"]