diff options
Diffstat (limited to 'remote/webdriver-bidi')
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"] |