summaryrefslogtreecommitdiffstats
path: root/remote/shared/messagehandler
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /remote/shared/messagehandler
parentInitial commit. (diff)
downloadfirefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz
firefox-26a029d407be480d791972afb5975cf62c9360a6.zip
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'remote/shared/messagehandler')
-rw-r--r--remote/shared/messagehandler/Errors.sys.mjs90
-rw-r--r--remote/shared/messagehandler/EventsDispatcher.sys.mjs260
-rw-r--r--remote/shared/messagehandler/MessageHandler.sys.mjs355
-rw-r--r--remote/shared/messagehandler/MessageHandlerRegistry.sys.mjs236
-rw-r--r--remote/shared/messagehandler/Module.sys.mjs135
-rw-r--r--remote/shared/messagehandler/ModuleCache.sys.mjs263
-rw-r--r--remote/shared/messagehandler/RootMessageHandler.sys.mjs237
-rw-r--r--remote/shared/messagehandler/RootMessageHandlerRegistry.sys.mjs17
-rw-r--r--remote/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs264
-rw-r--r--remote/shared/messagehandler/sessiondata/SessionData.sys.mjs392
-rw-r--r--remote/shared/messagehandler/sessiondata/SessionDataReader.sys.mjs27
-rw-r--r--remote/shared/messagehandler/test/browser/broadcast/browser.toml22
-rw-r--r--remote/shared/messagehandler/test/browser/broadcast/browser_filter_top_browsing_context.js84
-rw-r--r--remote/shared/messagehandler/test/browser/broadcast/browser_only_content_process.js46
-rw-r--r--remote/shared/messagehandler/test/browser/broadcast/browser_two_tabs.js46
-rw-r--r--remote/shared/messagehandler/test/browser/broadcast/browser_two_tabs_with_params.js47
-rw-r--r--remote/shared/messagehandler/test/browser/broadcast/browser_two_windows.js47
-rw-r--r--remote/shared/messagehandler/test/browser/broadcast/browser_with_frames.js40
-rw-r--r--remote/shared/messagehandler/test/browser/broadcast/doc_messagehandler_broadcasting_xul.xhtml3
-rw-r--r--remote/shared/messagehandler/test/browser/broadcast/head.js48
-rw-r--r--remote/shared/messagehandler/test/browser/browser.toml46
-rw-r--r--remote/shared/messagehandler/test/browser/browser_bfcache.js98
-rw-r--r--remote/shared/messagehandler/test/browser/browser_events_dispatcher.js532
-rw-r--r--remote/shared/messagehandler/test/browser/browser_events_handler.js57
-rw-r--r--remote/shared/messagehandler/test/browser/browser_events_interception.js112
-rw-r--r--remote/shared/messagehandler/test/browser/browser_events_module.js296
-rw-r--r--remote/shared/messagehandler/test/browser/browser_frame_context_utils.js98
-rw-r--r--remote/shared/messagehandler/test/browser/browser_handle_command_errors.js218
-rw-r--r--remote/shared/messagehandler/test/browser/browser_handle_command_retry.js229
-rw-r--r--remote/shared/messagehandler/test/browser/browser_handle_simple_command.js203
-rw-r--r--remote/shared/messagehandler/test/browser/browser_navigation_manager.js59
-rw-r--r--remote/shared/messagehandler/test/browser/browser_realms.js152
-rw-r--r--remote/shared/messagehandler/test/browser/browser_registry.js37
-rw-r--r--remote/shared/messagehandler/test/browser/browser_session_data.js273
-rw-r--r--remote/shared/messagehandler/test/browser/browser_session_data_browser_element.js94
-rw-r--r--remote/shared/messagehandler/test/browser/browser_session_data_constructor_race.js50
-rw-r--r--remote/shared/messagehandler/test/browser/browser_session_data_update.js113
-rw-r--r--remote/shared/messagehandler/test/browser/browser_session_data_update_categories.js91
-rw-r--r--remote/shared/messagehandler/test/browser/browser_session_data_update_contexts.js194
-rw-r--r--remote/shared/messagehandler/test/browser/browser_windowglobal_to_root.js47
-rw-r--r--remote/shared/messagehandler/test/browser/head.js236
-rw-r--r--remote/shared/messagehandler/test/browser/resources/modules/ModuleRegistry.sys.mjs40
-rw-r--r--remote/shared/messagehandler/test/browser/resources/modules/root/command.sys.mjs29
-rw-r--r--remote/shared/messagehandler/test/browser/resources/modules/root/event.sys.mjs21
-rw-r--r--remote/shared/messagehandler/test/browser/resources/modules/root/invalid.sys.mjs4
-rw-r--r--remote/shared/messagehandler/test/browser/resources/modules/root/rootOnly.sys.mjs70
-rw-r--r--remote/shared/messagehandler/test/browser/resources/modules/root/windowglobaltoroot.sys.mjs29
-rw-r--r--remote/shared/messagehandler/test/browser/resources/modules/windowglobal-in-root/command.sys.mjs28
-rw-r--r--remote/shared/messagehandler/test/browser/resources/modules/windowglobal-in-root/event.sys.mjs39
-rw-r--r--remote/shared/messagehandler/test/browser/resources/modules/windowglobal/command.sys.mjs85
-rw-r--r--remote/shared/messagehandler/test/browser/resources/modules/windowglobal/commandwindowglobalonly.sys.mjs41
-rw-r--r--remote/shared/messagehandler/test/browser/resources/modules/windowglobal/event.sys.mjs32
-rw-r--r--remote/shared/messagehandler/test/browser/resources/modules/windowglobal/eventemitter.sys.mjs81
-rw-r--r--remote/shared/messagehandler/test/browser/resources/modules/windowglobal/eventnointercept.sys.mjs16
-rw-r--r--remote/shared/messagehandler/test/browser/resources/modules/windowglobal/eventonprefchange.sys.mjs33
-rw-r--r--remote/shared/messagehandler/test/browser/resources/modules/windowglobal/retry.sys.mjs84
-rw-r--r--remote/shared/messagehandler/test/browser/resources/modules/windowglobal/sessiondataupdate.sys.mjs33
-rw-r--r--remote/shared/messagehandler/test/browser/resources/modules/windowglobal/windowglobaltoroot.sys.mjs47
-rw-r--r--remote/shared/messagehandler/test/browser/webdriver/browser.toml7
-rw-r--r--remote/shared/messagehandler/test/browser/webdriver/browser_session_execute_command_errors.js40
-rw-r--r--remote/shared/messagehandler/test/xpcshell/test_Errors.js91
-rw-r--r--remote/shared/messagehandler/test/xpcshell/test_SessionData.js296
-rw-r--r--remote/shared/messagehandler/test/xpcshell/xpcshell.toml5
-rw-r--r--remote/shared/messagehandler/transports/BrowsingContextUtils.sys.mjs57
-rw-r--r--remote/shared/messagehandler/transports/RootTransport.sys.mjs188
-rw-r--r--remote/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameActor.sys.mjs51
-rw-r--r--remote/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameChild.sys.mjs111
-rw-r--r--remote/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameParent.sys.mjs127
68 files changed, 7579 insertions, 0 deletions
diff --git a/remote/shared/messagehandler/Errors.sys.mjs b/remote/shared/messagehandler/Errors.sys.mjs
new file mode 100644
index 0000000000..69c65acd09
--- /dev/null
+++ b/remote/shared/messagehandler/Errors.sys.mjs
@@ -0,0 +1,90 @@
+/* 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 { RemoteError } from "chrome://remote/content/shared/RemoteError.sys.mjs";
+
+class MessageHandlerError extends RemoteError {
+ /**
+ * @param {(string|Error)=} x
+ * Optional string describing error situation or Error instance
+ * to propagate.
+ */
+ constructor(x) {
+ super(x);
+ this.name = this.constructor.name;
+ this.status = "message handler error";
+
+ // Error's ctor does not preserve x' stack
+ if (typeof x?.stack !== "undefined") {
+ this.stack = x.stack;
+ }
+ }
+
+ get isMessageHandlerError() {
+ return true;
+ }
+
+ /**
+ * @returns {Object<string, string>}
+ * JSON serialisation of error prototype.
+ */
+ toJSON() {
+ return {
+ error: this.status,
+ message: this.message || "",
+ stacktrace: this.stack || "",
+ };
+ }
+
+ /**
+ * Unmarshals a JSON error representation to the appropriate MessageHandler
+ * error type.
+ *
+ * @param {Object<string, string>} json
+ * Error object.
+ *
+ * @returns {Error}
+ * Error prototype.
+ */
+ static fromJSON(json) {
+ if (typeof json.error == "undefined") {
+ let s = JSON.stringify(json);
+ throw new TypeError("Undeserialisable error type: " + s);
+ }
+ if (!STATUSES.has(json.error)) {
+ throw new TypeError("Not of MessageHandlerError descent: " + json.error);
+ }
+
+ let cls = STATUSES.get(json.error);
+ let err = new cls();
+ if ("message" in json) {
+ err.message = json.message;
+ }
+ if ("stacktrace" in json) {
+ err.stack = json.stacktrace;
+ }
+ return err;
+ }
+}
+
+/**
+ * A command could not be handled by the message handler network.
+ */
+class UnsupportedCommandError extends MessageHandlerError {
+ constructor(message) {
+ super(message);
+ this.status = "unsupported message handler command";
+ }
+}
+
+const STATUSES = new Map([
+ ["message handler error", MessageHandlerError],
+ ["unsupported message handler command", UnsupportedCommandError],
+]);
+
+/** @namespace */
+export const error = {
+ MessageHandlerError,
+ UnsupportedCommandError,
+};
diff --git a/remote/shared/messagehandler/EventsDispatcher.sys.mjs b/remote/shared/messagehandler/EventsDispatcher.sys.mjs
new file mode 100644
index 0000000000..9620febcc1
--- /dev/null
+++ b/remote/shared/messagehandler/EventsDispatcher.sys.mjs
@@ -0,0 +1,260 @@
+/* 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, {
+ ContextDescriptorType:
+ "chrome://remote/content/shared/messagehandler/MessageHandler.sys.mjs",
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+ SessionDataCategory:
+ "chrome://remote/content/shared/messagehandler/sessiondata/SessionData.sys.mjs",
+ SessionDataMethod:
+ "chrome://remote/content/shared/messagehandler/sessiondata/SessionData.sys.mjs",
+ TabManager: "chrome://remote/content/shared/TabManager.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
+
+/**
+ * Helper to listen to events which rely on SessionData.
+ * In order to support the EventsDispatcher, a module emitting events should
+ * subscribe and unsubscribe to those events based on SessionData updates
+ * and should use the "event" SessionData category.
+ */
+export class EventsDispatcher {
+ // The MessageHandler owning this EventsDispatcher.
+ #messageHandler;
+
+ /**
+ * @typedef {object} EventListenerInfo
+ * @property {ContextDescriptor} contextDescriptor
+ * The ContextDescriptor to which those callbacks are associated
+ * @property {Set<Function>} callbacks
+ * The callbacks to trigger when an event matching the ContextDescriptor
+ * is received.
+ */
+
+ // Map from event name to map of strings (context keys) to EventListenerInfo.
+ #listenersByEventName;
+
+ /**
+ * Create a new EventsDispatcher instance.
+ *
+ * @param {MessageHandler} messageHandler
+ * The MessageHandler owning this EventsDispatcher.
+ */
+ constructor(messageHandler) {
+ this.#messageHandler = messageHandler;
+
+ this.#listenersByEventName = new Map();
+ }
+
+ destroy() {
+ for (const event of this.#listenersByEventName.keys()) {
+ this.#messageHandler.off(event, this.#onMessageHandlerEvent);
+ }
+
+ this.#listenersByEventName = null;
+ }
+
+ /**
+ * Check for existing listeners for a given event name and a given context.
+ *
+ * @param {string} name
+ * Name of the event to check.
+ * @param {ContextInfo} contextInfo
+ * ContextInfo identifying the context to check.
+ *
+ * @returns {boolean}
+ * True if there is a registered listener matching the provided arguments.
+ */
+ hasListener(name, contextInfo) {
+ if (!this.#listenersByEventName.has(name)) {
+ return false;
+ }
+
+ const listeners = this.#listenersByEventName.get(name);
+ for (const { contextDescriptor } of listeners.values()) {
+ if (this.#matchesContext(contextInfo, contextDescriptor)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Stop listening for an event relying on SessionData and relayed by the
+ * message handler.
+ *
+ * @param {string} event
+ * Name of the event to unsubscribe from.
+ * @param {ContextDescriptor} contextDescriptor
+ * Context descriptor for this event.
+ * @param {Function} callback
+ * Event listener callback.
+ * @returns {Promise}
+ * Promise which resolves when the event fully unsubscribed, including
+ * propagating the necessary session data.
+ */
+ async off(event, contextDescriptor, callback) {
+ return this.update([{ event, contextDescriptor, callback, enable: false }]);
+ }
+
+ /**
+ * Listen for an event relying on SessionData and relayed by the message
+ * handler.
+ *
+ * @param {string} event
+ * Name of the event to subscribe to.
+ * @param {ContextDescriptor} contextDescriptor
+ * Context descriptor for this event.
+ * @param {Function} callback
+ * Event listener callback.
+ * @returns {Promise}
+ * Promise which resolves when the event fully subscribed to, including
+ * propagating the necessary session data.
+ */
+ async on(event, contextDescriptor, callback) {
+ return this.update([{ event, contextDescriptor, callback, enable: true }]);
+ }
+
+ /**
+ * An object that holds information about subscription/unsubscription
+ * of an event.
+ *
+ * @typedef Subscription
+ *
+ * @param {string} event
+ * Name of the event to subscribe/unsubscribe to.
+ * @param {ContextDescriptor} contextDescriptor
+ * Context descriptor for this event.
+ * @param {Function} callback
+ * Event listener callback.
+ * @param {boolean} enable
+ * True, if we need to subscribe to an event.
+ * Otherwise false.
+ */
+
+ /**
+ * Start or stop listening to a list of events relying on SessionData
+ * and relayed by the message handler.
+ *
+ * @param {Array<Subscription>} subscriptions
+ * The list of information to subscribe/unsubscribe to.
+ *
+ * @returns {Promise}
+ * Promise which resolves when the events fully subscribed/unsubscribed to,
+ * including propagating the necessary session data.
+ */
+ async update(subscriptions) {
+ const sessionDataItemUpdates = [];
+ subscriptions.forEach(({ event, contextDescriptor, callback, enable }) => {
+ if (enable) {
+ // Setup listeners.
+ if (!this.#listenersByEventName.has(event)) {
+ this.#listenersByEventName.set(event, new Map());
+ this.#messageHandler.on(event, this.#onMessageHandlerEvent);
+ }
+
+ const key = this.#getContextKey(contextDescriptor);
+ const listeners = this.#listenersByEventName.get(event);
+ if (listeners.has(key)) {
+ const { callbacks } = listeners.get(key);
+ callbacks.add(callback);
+ } else {
+ const callbacks = new Set([callback]);
+ listeners.set(key, { callbacks, contextDescriptor });
+
+ sessionDataItemUpdates.push({
+ ...this.#getSessionDataItem(event, contextDescriptor),
+ method: lazy.SessionDataMethod.Add,
+ });
+ }
+ } else {
+ // Remove listeners.
+ const listeners = this.#listenersByEventName.get(event);
+ if (!listeners) {
+ return;
+ }
+
+ const key = this.#getContextKey(contextDescriptor);
+ if (!listeners.has(key)) {
+ return;
+ }
+
+ const { callbacks } = listeners.get(key);
+ if (callbacks.has(callback)) {
+ callbacks.delete(callback);
+ if (callbacks.size === 0) {
+ listeners.delete(key);
+ if (listeners.size === 0) {
+ this.#messageHandler.off(event, this.#onMessageHandlerEvent);
+ this.#listenersByEventName.delete(event);
+ }
+
+ sessionDataItemUpdates.push({
+ ...this.#getSessionDataItem(event, contextDescriptor),
+ method: lazy.SessionDataMethod.Remove,
+ });
+ }
+ }
+ }
+ });
+
+ // Update all sessionData at once.
+ await this.#messageHandler.updateSessionData(sessionDataItemUpdates);
+ }
+
+ #getContextKey(contextDescriptor) {
+ const { id, type } = contextDescriptor;
+ return `${type}-${id}`;
+ }
+
+ #getSessionDataItem(event, contextDescriptor) {
+ const [moduleName] = event.split(".");
+ return {
+ moduleName,
+ category: lazy.SessionDataCategory.Event,
+ contextDescriptor,
+ values: [event],
+ };
+ }
+
+ #matchesContext(contextInfo, contextDescriptor) {
+ if (contextDescriptor.type === lazy.ContextDescriptorType.All) {
+ return true;
+ }
+
+ if (
+ contextDescriptor.type === lazy.ContextDescriptorType.TopBrowsingContext
+ ) {
+ const eventBrowsingContext = lazy.TabManager.getBrowsingContextById(
+ contextInfo.contextId
+ );
+ return eventBrowsingContext?.browserId === contextDescriptor.id;
+ }
+
+ return false;
+ }
+
+ #onMessageHandlerEvent = (name, event, contextInfo) => {
+ const listeners = this.#listenersByEventName.get(name);
+ for (const { callbacks, contextDescriptor } of listeners.values()) {
+ if (!this.#matchesContext(contextInfo, contextDescriptor)) {
+ continue;
+ }
+
+ for (const callback of callbacks) {
+ try {
+ callback(name, event);
+ } catch (e) {
+ lazy.logger.debug(
+ `Error while executing callback for ${name}: ${e.message}`
+ );
+ }
+ }
+ }
+ };
+}
diff --git a/remote/shared/messagehandler/MessageHandler.sys.mjs b/remote/shared/messagehandler/MessageHandler.sys.mjs
new file mode 100644
index 0000000000..18ec6b820c
--- /dev/null
+++ b/remote/shared/messagehandler/MessageHandler.sys.mjs
@@ -0,0 +1,355 @@
+/* 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 { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ error: "chrome://remote/content/shared/messagehandler/Errors.sys.mjs",
+ EventsDispatcher:
+ "chrome://remote/content/shared/messagehandler/EventsDispatcher.sys.mjs",
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+ ModuleCache:
+ "chrome://remote/content/shared/messagehandler/ModuleCache.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
+
+/**
+ * A ContextDescriptor object provides information to decide if a broadcast or
+ * a session data item should be applied to a specific MessageHandler context.
+ *
+ * @typedef {object} ContextDescriptor
+ * @property {ContextDescriptorType} type
+ * The type of context
+ * @property {string=} id
+ * Unique id of a given context for the provided type.
+ * For ContextDescriptorType.All, id can be ommitted.
+ * For ContextDescriptorType.TopBrowsingContext, the id should be the
+ * browserId corresponding to a top-level browsing context.
+ */
+
+/**
+ * Enum of ContextDescriptor types.
+ *
+ * @enum {string}
+ */
+export const ContextDescriptorType = {
+ All: "All",
+ TopBrowsingContext: "TopBrowsingContext",
+};
+
+/**
+ * A ContextInfo identifies a given context that can be linked to a MessageHandler
+ * instance. It should be used to identify events coming from this context.
+ *
+ * It can either be provided by the MessageHandler itself, when the event is
+ * emitted from the context it relates to.
+ *
+ * Or it can be assembled manually, for instance when emitting an event which
+ * relates to a window global from the root layer (eg browsingContext.contextCreated).
+ *
+ * @typedef {object} ContextInfo
+ * @property {string} contextId
+ * Unique id of the MessageHandler corresponding to this context.
+ * @property {string} type
+ * One of MessageHandler.type.
+ */
+
+/**
+ * MessageHandler instances are dedicated to handle both Commands and Events
+ * to enable automation and introspection for remote control protocols.
+ *
+ * MessageHandler instances are designed to form a network, where each instance
+ * should allow to inspect a specific context (eg. a BrowsingContext, a Worker,
+ * etc). Those instances might live in different processes and threads but
+ * should be linked together by the usage of a single sessionId, shared by all
+ * the instances of a single MessageHandler network.
+ *
+ * MessageHandler instances will be dynamically spawned depending on which
+ * Command or which Event needs to be processed and should therefore not be
+ * explicitly created by consumers, nor used directly.
+ *
+ * The only exception is the ROOT MessageHandler. This MessageHandler will be
+ * the entry point to send commands to the rest of the network. It will also
+ * emit all the relevant events captured by the network.
+ *
+ * However, even to create this ROOT MessageHandler, consumers should use the
+ * RootMessageHandlerRegistry. This singleton will ensure that MessageHandler
+ * instances are properly registered and can be retrieved based on a given
+ * session id as well as some other context information.
+ */
+export class MessageHandler extends EventEmitter {
+ #context;
+ #contextId;
+ #eventsDispatcher;
+ #moduleCache;
+ #registry;
+ #sessionId;
+
+ /**
+ * Create a new MessageHandler instance.
+ *
+ * @param {string} sessionId
+ * ID of the session the handler is used for.
+ * @param {object} context
+ * The context linked to this MessageHandler instance.
+ * @param {MessageHandlerRegistry} registry
+ * The MessageHandlerRegistry which owns this MessageHandler instance.
+ */
+ constructor(sessionId, context, registry) {
+ super();
+
+ this.#moduleCache = new lazy.ModuleCache(this);
+
+ this.#sessionId = sessionId;
+ this.#context = context;
+ this.#contextId = this.constructor.getIdFromContext(context);
+ this.#eventsDispatcher = new lazy.EventsDispatcher(this);
+ this.#registry = registry;
+ }
+
+ get context() {
+ return this.#context;
+ }
+
+ get contextId() {
+ return this.#contextId;
+ }
+
+ get eventsDispatcher() {
+ return this.#eventsDispatcher;
+ }
+
+ get moduleCache() {
+ return this.#moduleCache;
+ }
+
+ get name() {
+ return [this.sessionId, this.constructor.type, this.contextId].join("-");
+ }
+
+ get registry() {
+ return this.#registry;
+ }
+
+ get sessionId() {
+ return this.#sessionId;
+ }
+
+ destroy() {
+ lazy.logger.trace(
+ `MessageHandler ${this.constructor.type} for session ${this.sessionId} is being destroyed`
+ );
+ this.#eventsDispatcher.destroy();
+ this.#moduleCache.destroy();
+
+ // At least the MessageHandlerRegistry will be expecting this event in order
+ // to remove the instance from the registry when destroyed.
+ this.emit("message-handler-destroyed", this);
+ }
+
+ /**
+ * Emit a message handler event.
+ *
+ * Such events should bubble up to the root of a MessageHandler network.
+ *
+ * @param {string} name
+ * Name of the event. Protocol level events should be of the
+ * form [module name].[event name].
+ * @param {object} data
+ * The event's data.
+ * @param {ContextInfo=} contextInfo
+ * The event's context info, used to identify the origin of the event.
+ * If not provided, the context info of the current MessageHandler will be
+ * used.
+ */
+ emitEvent(name, data, contextInfo) {
+ // If no contextInfo field is provided on the event, extract it from the
+ // MessageHandler instance.
+ contextInfo = contextInfo || this.#getContextInfo();
+
+ // Events are emitted both under their own name for consumers listening to
+ // a specific and as `message-handler-event` for consumers which need to
+ // catch all events.
+ this.emit(name, data, contextInfo);
+ this.emit("message-handler-event", {
+ name,
+ contextInfo,
+ data,
+ sessionId: this.sessionId,
+ });
+ }
+
+ /**
+ * @typedef {object} CommandDestination
+ * @property {string} type
+ * One of MessageHandler.type.
+ * @property {string=} id
+ * Unique context identifier. The format depends on the type.
+ * For WINDOW_GLOBAL destinations, this is a browsing context id.
+ * Optional, should only be provided if `contextDescriptor` is missing.
+ * @property {ContextDescriptor=} contextDescriptor
+ * Descriptor used to match several contexts, which will all receive the
+ * command.
+ * Optional, should only be provided if `id` is missing.
+ */
+
+ /**
+ * @typedef {object} Command
+ * @property {string} commandName
+ * The name of the command to execute.
+ * @property {string} moduleName
+ * The name of the module.
+ * @property {object} params
+ * Optional command parameters.
+ * @property {CommandDestination} destination
+ * The destination describing a debuggable context.
+ * @property {boolean=} retryOnAbort
+ * Optional. When true, commands will be retried upon AbortError, which
+ * can occur when the underlying JSWindowActor pair is destroyed.
+ * Defaults to `false`.
+ */
+
+ /**
+ * Retrieve all module classes matching the moduleName and destination.
+ * See `getAllModuleClasses` (ModuleCache.jsm) for more details.
+ *
+ * @param {string} moduleName
+ * The name of the module.
+ * @param {Destination} destination
+ * The destination.
+ * @returns {Array.<class<Module>|null>}
+ * An array of Module classes.
+ */
+ getAllModuleClasses(moduleName, destination) {
+ return this.#moduleCache.getAllModuleClasses(moduleName, destination);
+ }
+
+ /**
+ * Handle a command, either in one of the modules owned by this MessageHandler
+ * or in a another MessageHandler after forwarding the command.
+ *
+ * @param {Command} command
+ * The command that should be either handled in this layer or forwarded to
+ * the next layer leading to the destination.
+ * @returns {Promise} A Promise that will resolve with the return value of the
+ * command once it has been executed.
+ */
+ handleCommand(command) {
+ const { moduleName, commandName, params, destination } = command;
+ lazy.logger.trace(
+ `Received command ${moduleName}.${commandName} for destination ${destination.type}`
+ );
+
+ if (!this.supportsCommand(moduleName, commandName, destination)) {
+ throw new lazy.error.UnsupportedCommandError(
+ `${moduleName}.${commandName} not supported for destination ${destination?.type}`
+ );
+ }
+
+ const module = this.#moduleCache.getModuleInstance(moduleName, destination);
+ if (module && module.supportsMethod(commandName)) {
+ return module[commandName](params, destination);
+ }
+
+ return this.forwardCommand(command);
+ }
+
+ toString() {
+ return `[object ${this.constructor.name} ${this.name}]`;
+ }
+
+ /**
+ * Execute the required initialization steps, inlcluding apply the initial session data items
+ * provided to this MessageHandler on startup. Implementation is specific to each MessageHandler class.
+ *
+ * By default the implementation is a no-op.
+ *
+ * @param {Array<SessionDataItem>} sessionDataItems
+ * Initial session data items for this MessageHandler.
+ */
+ async initialize(sessionDataItems) {}
+
+ /**
+ * Returns the module path corresponding to this MessageHandler class.
+ *
+ * Needs to be implemented in the sub class.
+ */
+ static get modulePath() {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * Returns the type corresponding to this MessageHandler class.
+ *
+ * Needs to be implemented in the sub class.
+ */
+ static get type() {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * Returns the id corresponding to a context compatible with this
+ * MessageHandler class.
+ *
+ * Needs to be implemented in the sub class.
+ */
+ static getIdFromContext(context) {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * Forward a command to other MessageHandlers.
+ *
+ * Needs to be implemented in the sub class.
+ */
+ forwardCommand(command) {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * Check if contextDescriptor matches the context linked
+ * to this MessageHandler instance.
+ *
+ * Needs to be implemented in the sub class.
+ */
+ matchesContext(contextDescriptor) {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * Check if the given command is supported in the module
+ * for the destination
+ *
+ * @param {string} moduleName
+ * The name of the module.
+ * @param {string} commandName
+ * The name of the command.
+ * @param {Destination} destination
+ * The destination.
+ * @returns {boolean}
+ * True if the command is supported.
+ */
+ supportsCommand(moduleName, commandName, destination) {
+ return this.getAllModuleClasses(moduleName, destination).some(cls =>
+ cls.supportsMethod(commandName)
+ );
+ }
+
+ /**
+ * Return the context information for this MessageHandler instance, which
+ * can be used to identify the origin of an event.
+ *
+ * @returns {ContextInfo}
+ * The context information for this MessageHandler.
+ */
+ #getContextInfo() {
+ return {
+ contextId: this.contextId,
+ type: this.constructor.type,
+ };
+ }
+}
diff --git a/remote/shared/messagehandler/MessageHandlerRegistry.sys.mjs b/remote/shared/messagehandler/MessageHandlerRegistry.sys.mjs
new file mode 100644
index 0000000000..6a09173f50
--- /dev/null
+++ b/remote/shared/messagehandler/MessageHandlerRegistry.sys.mjs
@@ -0,0 +1,236 @@
+/* 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 { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+ readSessionData:
+ "chrome://remote/content/shared/messagehandler/sessiondata/SessionDataReader.sys.mjs",
+ RootMessageHandler:
+ "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs",
+ WindowGlobalMessageHandler:
+ "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
+
+/**
+ * Map of MessageHandler type to MessageHandler subclass.
+ */
+ChromeUtils.defineLazyGetter(
+ lazy,
+ "MessageHandlerClasses",
+ () =>
+ new Map([
+ [lazy.RootMessageHandler.type, lazy.RootMessageHandler],
+ [lazy.WindowGlobalMessageHandler.type, lazy.WindowGlobalMessageHandler],
+ ])
+);
+
+/**
+ * Get the MessageHandler subclass corresponding to the provided type.
+
+ * @param {string} type
+ * MessageHandler type, one of MessageHandler.type.
+ * @returns {Class}
+ * A MessageHandler subclass
+ * @throws {Error}
+ * Throws if no MessageHandler subclass is found for the provided type.
+ */
+export function getMessageHandlerClass(type) {
+ if (!lazy.MessageHandlerClasses.has(type)) {
+ throw new Error(`No MessageHandler class available for type "${type}"`);
+ }
+ return lazy.MessageHandlerClasses.get(type);
+}
+
+/**
+ * The MessageHandlerRegistry allows to create and retrieve MessageHandler
+ * instances for different session ids.
+ *
+ * A MessageHandlerRegistry instance is bound to a specific MessageHandler type
+ * and context. All MessageHandler instances created by the same registry will
+ * use the type and context of the registry, but each will be associated to a
+ * different session id.
+ *
+ * The registry is useful to retrieve the appropriate MessageHandler instance
+ * after crossing a technical boundary (eg process, thread...).
+ */
+export class MessageHandlerRegistry extends EventEmitter {
+ /*
+ * @param {String} type
+ * MessageHandler type, one of MessageHandler.type.
+ * @param {Object} context
+ * The context object, which depends on the type.
+ */
+ constructor(type, context) {
+ super();
+
+ this._messageHandlerClass = getMessageHandlerClass(type);
+ this._context = context;
+ this._type = type;
+
+ /**
+ * Map of session id to MessageHandler instance
+ */
+ this._messageHandlersMap = new Map();
+
+ this._onMessageHandlerDestroyed =
+ this._onMessageHandlerDestroyed.bind(this);
+ this._onMessageHandlerEvent = this._onMessageHandlerEvent.bind(this);
+ }
+
+ /**
+ * Create all message handlers for the current context, based on the content
+ * of the session data.
+ * This should typically be called when the context is ready to be used and
+ * to receive/send commands.
+ */
+ createAllMessageHandlers() {
+ const data = lazy.readSessionData();
+ for (const [sessionId, sessionDataItems] of data) {
+ // Create a message handler for this context for each active message
+ // handler session.
+ // TODO: In the future, to support debugging use cases we might want to
+ // only create a message handler if there is relevant data.
+ // For automation scenarios, this is less critical.
+ this._createMessageHandler(sessionId, sessionDataItems);
+ }
+ }
+
+ destroy() {
+ this._messageHandlersMap.forEach(messageHandler => {
+ messageHandler.destroy();
+ });
+ }
+
+ /**
+ * Retrieve all MessageHandler instances held in this registry, for all
+ * session IDs.
+ *
+ * @returns {Iterable.<MessageHandler>}
+ * Iterator of MessageHandler instances
+ */
+ getAllMessageHandlers() {
+ return this._messageHandlersMap.values();
+ }
+
+ /**
+ * Retrieve an existing MessageHandler instance matching the provided session
+ * id. Returns null if no MessageHandler was found.
+ *
+ * @param {string} sessionId
+ * ID of the session the handler is used for.
+ * @returns {MessageHandler=}
+ * A MessageHandler instance, null if not found.
+ */
+ getExistingMessageHandler(sessionId) {
+ return this._messageHandlersMap.get(sessionId);
+ }
+
+ /**
+ * Retrieve the MessageHandler instance registered for the provided session
+ * id. Will create and register a MessageHander if no instance was found.
+ *
+ * @param {string} sessionId
+ * ID of the session the handler is used for.
+ * @returns {MessageHandler}
+ * A MessageHandler instance.
+ */
+ getOrCreateMessageHandler(sessionId) {
+ let messageHandler = this.getExistingMessageHandler(sessionId);
+ if (!messageHandler) {
+ messageHandler = this._createMessageHandler(sessionId);
+ }
+
+ return messageHandler;
+ }
+
+ /**
+ * Retrieve an already registered RootMessageHandler instance matching the
+ * provided sessionId.
+ *
+ * @param {string} sessionId
+ * ID of the session the handler is used for.
+ * @returns {RootMessageHandler}
+ * A RootMessageHandler instance.
+ * @throws {Error}
+ * If no root MessageHandler can be found for the provided session id.
+ */
+ getRootMessageHandler(sessionId) {
+ const rootMessageHandler = this.getExistingMessageHandler(
+ sessionId,
+ lazy.RootMessageHandler.type
+ );
+ if (!rootMessageHandler) {
+ throw new Error(
+ `Unable to find a root MessageHandler for session id ${sessionId}`
+ );
+ }
+ return rootMessageHandler;
+ }
+
+ toString() {
+ return `[object ${this.constructor.name}]`;
+ }
+
+ /**
+ * Create a new MessageHandler instance.
+ *
+ * @param {string} sessionId
+ * ID of the session the handler will be used for.
+ * @param {Array<SessionDataItem>=} sessionDataItems
+ * Optional array of session data items to be applied automatically to the
+ * MessageHandler.
+ * @returns {MessageHandler}
+ * A new MessageHandler instance.
+ */
+ _createMessageHandler(sessionId, sessionDataItems) {
+ const messageHandler = new this._messageHandlerClass(
+ sessionId,
+ this._context,
+ this
+ );
+
+ messageHandler.on(
+ "message-handler-destroyed",
+ this._onMessageHandlerDestroyed
+ );
+ messageHandler.on("message-handler-event", this._onMessageHandlerEvent);
+
+ messageHandler.initialize(sessionDataItems);
+
+ this._messageHandlersMap.set(sessionId, messageHandler);
+
+ lazy.logger.trace(
+ `Created MessageHandler ${this._type} for session ${sessionId}`
+ );
+
+ return messageHandler;
+ }
+
+ // Event handlers
+
+ _onMessageHandlerDestroyed(eventName, messageHandler) {
+ messageHandler.off(
+ "message-handler-destroyed",
+ this._onMessageHandlerDestroyed
+ );
+ messageHandler.off("message-handler-event", this._onMessageHandlerEvent);
+ this._messageHandlersMap.delete(messageHandler.sessionId);
+
+ lazy.logger.trace(
+ `Unregistered MessageHandler ${messageHandler.constructor.type} for session ${messageHandler.sessionId}`
+ );
+ }
+
+ _onMessageHandlerEvent(eventName, messageHandlerEvent) {
+ // The registry simply re-emits MessageHandler events so that consumers
+ // don't have to attach listeners to individual MessageHandler instances.
+ this.emit("message-handler-registry-event", messageHandlerEvent);
+ }
+}
diff --git a/remote/shared/messagehandler/Module.sys.mjs b/remote/shared/messagehandler/Module.sys.mjs
new file mode 100644
index 0000000000..30b26938e2
--- /dev/null
+++ b/remote/shared/messagehandler/Module.sys.mjs
@@ -0,0 +1,135 @@
+/* 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",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "disabledExperimentalAPI", () => {
+ return !Services.prefs.getBoolPref("remote.experimental.enabled");
+});
+
+ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
+
+export class Module {
+ #messageHandler;
+
+ /**
+ * Create a new module instance.
+ *
+ * @param {MessageHandler} messageHandler
+ * The MessageHandler instance which owns this Module instance.
+ */
+ constructor(messageHandler) {
+ this.#messageHandler = messageHandler;
+ }
+
+ /**
+ * Clean-up the module instance.
+ */
+ destroy() {
+ lazy.logger.warn(
+ `Module ${this.constructor.name} is missing a destroy method`
+ );
+ }
+
+ /**
+ * Emit a message handler event.
+ *
+ * Such events should bubble up to the root of a MessageHandler network.
+ *
+ * @param {string} name
+ * Name of the event. Protocol level events should be of the
+ * form [module name].[event name].
+ * @param {object} data
+ * The event's data.
+ * @param {ContextInfo=} contextInfo
+ * The event's context info, see MessageHandler:emitEvent. Optional.
+ */
+ emitEvent(name, data, contextInfo) {
+ this.messageHandler.emitEvent(name, data, contextInfo);
+ }
+
+ /**
+ * Intercept an event and modify the payload.
+ *
+ * It's required to be implemented in windowglobal-in-root modules.
+ *
+ * @param {string} name
+ * Name of the event.
+ * @param {object} payload
+ * The event's payload.
+ * @returns {object}
+ * The modified event payload.
+ */
+ interceptEvent(name, payload) {
+ throw new Error(
+ `Could not intercept event ${name}, interceptEvent is not implemented in windowglobal-in-root module`
+ );
+ }
+
+ /**
+ * Assert if experimental commands are enabled.
+ *
+ * @param {string} methodName
+ * Name of the command.
+ *
+ * @throws {UnknownCommandError}
+ * If experimental commands are disabled.
+ */
+ assertExperimentalCommandsEnabled(methodName) {
+ // TODO: 1778987. Move it to a BiDi specific place.
+ if (lazy.disabledExperimentalAPI) {
+ throw new lazy.error.UnknownCommandError(methodName);
+ }
+ }
+
+ /**
+ * Assert if experimental events are enabled.
+ *
+ * @param {string} moduleName
+ * Name of the module.
+ *
+ * @param {string} event
+ * Name of the event.
+ *
+ * @throws {InvalidArgumentError}
+ * If experimental events are disabled.
+ */
+ assertExperimentalEventsEnabled(moduleName, event) {
+ // TODO: 1778987. Move it to a BiDi specific place.
+ if (lazy.disabledExperimentalAPI) {
+ throw new lazy.error.InvalidArgumentError(
+ `Module ${moduleName} does not support event ${event}`
+ );
+ }
+ }
+
+ /**
+ * Instance shortcut for supportsMethod to avoid reaching the constructor for
+ * consumers which directly deal with an instance.
+ */
+ supportsMethod(methodName) {
+ return this.constructor.supportsMethod(methodName);
+ }
+
+ get messageHandler() {
+ return this.#messageHandler;
+ }
+
+ static get supportedEvents() {
+ return [];
+ }
+
+ static supportsEvent(event) {
+ return this.supportedEvents.includes(event);
+ }
+
+ static supportsMethod(methodName) {
+ return typeof this.prototype[methodName] === "function";
+ }
+}
diff --git a/remote/shared/messagehandler/ModuleCache.sys.mjs b/remote/shared/messagehandler/ModuleCache.sys.mjs
new file mode 100644
index 0000000000..6cff8dff60
--- /dev/null
+++ b/remote/shared/messagehandler/ModuleCache.sys.mjs
@@ -0,0 +1,263 @@
+/* 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, {
+ getMessageHandlerClass:
+ "chrome://remote/content/shared/messagehandler/MessageHandlerRegistry.sys.mjs",
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+ RootMessageHandler:
+ "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs",
+});
+
+const protocols = {
+ bidi: {},
+ test: {},
+};
+// eslint-disable-next-line mozilla/lazy-getter-object-name
+ChromeUtils.defineESModuleGetters(protocols.bidi, {
+ // Additional protocols might use a different registry for their modules,
+ // in which case this will no longer be a constant but will instead depend on
+ // the protocol owning the MessageHandler. See Bug 1722464.
+ modules:
+ "chrome://remote/content/webdriver-bidi/modules/ModuleRegistry.sys.mjs",
+});
+// eslint-disable-next-line mozilla/lazy-getter-object-name
+ChromeUtils.defineESModuleGetters(protocols.test, {
+ modules:
+ "chrome://mochitests/content/browser/remote/shared/messagehandler/test/browser/resources/modules/ModuleRegistry.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
+
+/**
+ * ModuleCache instances are dedicated to lazily create and cache the instances
+ * of all the modules related to a specific MessageHandler instance.
+ *
+ * ModuleCache also implements the logic to resolve the path to the file for a
+ * given module, which depends both on the current MessageHandler context and on
+ * the expected destination.
+ *
+ * In order to implement module logic in any context, separate module files
+ * should be created for each situation. For instance, for a given module,
+ * - ${MODULES_FOLDER}/root/{ModuleName}.sys.mjs contains the implementation for
+ * commands intended for the destination ROOT, and will be created for a ROOT
+ * MessageHandler only. Typically, they will run in the parent process.
+ * - ${MODULES_FOLDER}/windowglobal/{ModuleName}.sys.mjs contains the implementation
+ * for commands intended for a WINDOW_GLOBAL destination, and will be created
+ * for a WINDOW_GLOBAL MessageHandler only. Those will usually run in a
+ * content process.
+ * - ${MODULES_FOLDER}/windowglobal-in-root/{ModuleName}.sys.mjs also handles
+ * commands intended for a WINDOW_GLOBAL destination, but they will be created
+ * for the ROOT MessageHandler and will run in the parent process. This can be
+ * useful if some code has to be executed in the parent process, even though
+ * the final destination is a WINDOW_GLOBAL.
+ * - And so on, as more MessageHandler types get added, more combinations will
+ * follow based on the same pattern:
+ * - {contextName}/{ModuleName}.sys.mjs
+ * - or {destinationType}-in-{currentType}/{ModuleName}.sys.mjs
+ *
+ * All those implementations are optional. If a module cannot be found, based on
+ * the logic detailed above, the MessageHandler will assume that the command
+ * should simply be forwarded to the next layer of the network.
+ */
+export class ModuleCache {
+ #messageHandler;
+ #messageHandlerType;
+ #modules;
+ #protocol;
+
+ /*
+ * @param {MessageHandler} messageHandler
+ * The MessageHandler instance which owns this ModuleCache instance.
+ */
+ constructor(messageHandler) {
+ this.#messageHandler = messageHandler;
+ this.#messageHandlerType = messageHandler.constructor.type;
+
+ // Map of absolute module paths to module instances.
+ this.#modules = new Map();
+
+ // Use the module class from the WebDriverBiDi ModuleRegistry if we
+ // are not using test modules.
+ this.#protocol = Services.prefs.getBoolPref(
+ "remote.messagehandler.modulecache.useBrowserTestRoot",
+ false
+ )
+ ? protocols.test
+ : protocols.bidi;
+ }
+
+ /**
+ * Destroy all instantiated modules.
+ */
+ destroy() {
+ this.#modules.forEach(module => module?.destroy());
+ }
+
+ /**
+ * Retrieve all module classes matching the provided module name to reach the
+ * provided destination, from the current context.
+ *
+ * This corresponds to the path a command can take to reach its destination.
+ * A command's method must be implemented in one of the classes returned by
+ * getAllModuleClasses in order to be successfully handled.
+ *
+ * @param {string} moduleName
+ * The name of the module.
+ * @param {Destination} destination
+ * The destination.
+ * @returns {Array<class<Module>|null>}
+ * An array of Module classes.
+ */
+ getAllModuleClasses(moduleName, destination) {
+ const destinationType = destination.type;
+ const classes = [
+ this.#getModuleClass(
+ moduleName,
+ this.#messageHandlerType,
+ destinationType
+ ),
+ ];
+
+ // Bug 1733242: Extend the implementation of this method to handle workers.
+ // It assumes layers have at most one level of nesting, for instance
+ // "root -> windowglobal", but it wouldn't work for something such as
+ // "root -> windowglobal -> worker".
+ if (destinationType !== this.#messageHandlerType) {
+ classes.push(
+ this.#getModuleClass(moduleName, destinationType, destinationType)
+ );
+ }
+
+ return classes.filter(cls => !!cls);
+ }
+
+ /**
+ * Get a module instance corresponding to the provided moduleName and
+ * destination. If no existing module can be found in the cache, ModuleCache
+ * will attempt to import the module file and create a new instance, which
+ * will then be cached and returned for subsequent calls.
+ *
+ * @param {string} moduleName
+ * The name of the module which should implement the command.
+ * @param {CommandDestination} destination
+ * The destination of the command for which we need to instantiate a
+ * module. See MessageHandler.sys.mjs for the CommandDestination typedef.
+ * @returns {object=}
+ * A module instance corresponding to the provided moduleName and
+ * destination, or null if it could not be instantiated.
+ */
+ getModuleInstance(moduleName, destination) {
+ const key = `${moduleName}-${destination.type}`;
+
+ if (this.#modules.has(key)) {
+ // If there is already a cached instance (potentially null) for the
+ // module name + destination type pair, return it.
+ return this.#modules.get(key);
+ }
+
+ const ModuleClass = this.#getModuleClass(
+ moduleName,
+ this.#messageHandlerType,
+ destination.type
+ );
+
+ let module = null;
+ if (ModuleClass) {
+ module = new ModuleClass(this.#messageHandler);
+ }
+
+ this.#modules.set(key, module);
+ return module;
+ }
+
+ /**
+ * Check if the given module exists for the destination.
+ *
+ * @param {string} moduleName
+ * The name of the module.
+ * @param {Destination} destination
+ * The destination.
+ * @returns {boolean}
+ * True if the module exists.
+ */
+ hasModuleClass(moduleName, destination) {
+ const classes = this.getAllModuleClasses(moduleName, destination);
+ return !!classes.length;
+ }
+
+ toString() {
+ return `[object ${this.constructor.name} ${this.#messageHandler.name}]`;
+ }
+
+ /**
+ * Retrieve the module class matching the provided module name and folder.
+ *
+ * @param {string} moduleName
+ * The name of the module to get the class for.
+ * @param {string} originType
+ * The MessageHandler type from where the command comes.
+ * @param {string} destinationType
+ * The MessageHandler type where the command should go to.
+ * @returns {Class=}
+ * The class corresponding to the module name and folder, null if no match
+ * was found.
+ * @throws {Error}
+ * If the provided module folder is unexpected.
+ */
+ #getModuleClass = function (moduleName, originType, destinationType) {
+ if (
+ destinationType === lazy.RootMessageHandler.type &&
+ originType !== destinationType
+ ) {
+ // If we are trying to reach the root layer from a lower layer, no module
+ // class should attempt to handle the command in the current layer and
+ // the command should be forwarded unconditionally.
+ return null;
+ }
+
+ const moduleFolder = this.#getModuleFolder(originType, destinationType);
+ if (!this.#protocol.modules[moduleFolder]) {
+ throw new Error(
+ `Invalid module folder "${moduleFolder}", expected one of "${Object.keys(
+ this.#protocol.modules
+ )}"`
+ );
+ }
+
+ let moduleClass = null;
+ if (this.#protocol.modules[moduleFolder][moduleName]) {
+ moduleClass = this.#protocol.modules[moduleFolder][moduleName];
+ }
+
+ if (moduleClass) {
+ lazy.logger.trace(
+ `Module ${moduleFolder}/${moduleName}.sys.mjs found for ${destinationType}`
+ );
+ } else {
+ lazy.logger.trace(
+ `Module ${moduleFolder}/${moduleName}.sys.mjs not found for ${destinationType}`
+ );
+ }
+
+ return moduleClass;
+ };
+
+ #getModuleFolder(originType, destinationType) {
+ const originPath = lazy.getMessageHandlerClass(originType).modulePath;
+ if (originType === destinationType) {
+ // If the command is targeting the current type, the module is expected to
+ // be in eg "windowglobal/${moduleName}.sys.mjs".
+ return originPath;
+ }
+
+ // If the command is targeting another type, the module is expected to
+ // be in a composed folder eg "windowglobal-in-root/${moduleName}.sys.mjs".
+ const destinationPath =
+ lazy.getMessageHandlerClass(destinationType).modulePath;
+ return `${destinationPath}-in-${originPath}`;
+ }
+}
diff --git a/remote/shared/messagehandler/RootMessageHandler.sys.mjs b/remote/shared/messagehandler/RootMessageHandler.sys.mjs
new file mode 100644
index 0000000000..06a8cd6f18
--- /dev/null
+++ b/remote/shared/messagehandler/RootMessageHandler.sys.mjs
@@ -0,0 +1,237 @@
+/* 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 { MessageHandler } from "chrome://remote/content/shared/messagehandler/MessageHandler.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ NavigationManager: "chrome://remote/content/shared/NavigationManager.sys.mjs",
+ RootTransport:
+ "chrome://remote/content/shared/messagehandler/transports/RootTransport.sys.mjs",
+ SessionData:
+ "chrome://remote/content/shared/messagehandler/sessiondata/SessionData.sys.mjs",
+ SessionDataMethod:
+ "chrome://remote/content/shared/messagehandler/sessiondata/SessionData.sys.mjs",
+ WindowGlobalMessageHandler:
+ "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs",
+});
+
+/**
+ * A RootMessageHandler is the root node of a MessageHandler network. It lives
+ * in the parent process. It can forward commands to MessageHandlers in other
+ * layers (at the moment WindowGlobalMessageHandlers in content processes).
+ */
+export class RootMessageHandler extends MessageHandler {
+ #navigationManager;
+ #realms;
+ #rootTransport;
+ #sessionData;
+
+ /**
+ * Returns the RootMessageHandler module path.
+ *
+ * @returns {string}
+ */
+ static get modulePath() {
+ return "root";
+ }
+
+ /**
+ * Returns the RootMessageHandler type.
+ *
+ * @returns {string}
+ */
+ static get type() {
+ return "ROOT";
+ }
+
+ /**
+ * The ROOT MessageHandler is unique for a given MessageHandler network
+ * (ie for a given sessionId). Reuse the type as context id here.
+ */
+ static getIdFromContext(context) {
+ return RootMessageHandler.type;
+ }
+
+ /**
+ * Create a new RootMessageHandler instance.
+ *
+ * @param {string} sessionId
+ * ID of the session the handler is used for.
+ */
+ constructor(sessionId) {
+ super(sessionId, null);
+
+ this.#rootTransport = new lazy.RootTransport(this);
+ this.#sessionData = new lazy.SessionData(this);
+ this.#navigationManager = new lazy.NavigationManager();
+ this.#navigationManager.startMonitoring();
+
+ // Map with inner window ids as keys, and sets of realm ids, assosiated with
+ // this window as values.
+ this.#realms = new Map();
+ // In the general case, we don't get notified that realms got destroyed,
+ // because there is no communication between content and parent process at this moment,
+ // so we have to listen to the this notification to clean up the internal
+ // map and trigger the events.
+ Services.obs.addObserver(this, "window-global-destroyed");
+ }
+
+ get navigationManager() {
+ return this.#navigationManager;
+ }
+
+ get realms() {
+ return this.#realms;
+ }
+
+ get sessionData() {
+ return this.#sessionData;
+ }
+
+ destroy() {
+ this.#sessionData.destroy();
+ this.#navigationManager.destroy();
+
+ Services.obs.removeObserver(this, "window-global-destroyed");
+ this.#realms = null;
+
+ super.destroy();
+ }
+
+ /**
+ * Add new session data items of a given module, category and
+ * contextDescriptor.
+ *
+ * Forwards the call to the SessionData instance owned by this
+ * RootMessageHandler and propagates the information via a command to existing
+ * MessageHandlers.
+ */
+ addSessionDataItem(sessionData = {}) {
+ sessionData.method = lazy.SessionDataMethod.Add;
+ return this.updateSessionData([sessionData]);
+ }
+
+ emitEvent(name, eventPayload, contextInfo) {
+ // Intercept realm created and destroyed events to update internal map.
+ if (name === "realm-created") {
+ this.#onRealmCreated(eventPayload);
+ }
+ // We receive this events in the case of moving the page to BFCache.
+ if (name === "windowglobal-pagehide") {
+ this.#cleanUpRealmsForWindow(
+ eventPayload.innerWindowId,
+ eventPayload.context
+ );
+ }
+
+ super.emitEvent(name, eventPayload, contextInfo);
+ }
+
+ /**
+ * Emit a public protocol event. This event will be sent over to the client.
+ *
+ * @param {string} name
+ * Name of the event. Protocol level events should be of the
+ * form [module name].[event name].
+ * @param {object} data
+ * The event's data.
+ */
+ emitProtocolEvent(name, data) {
+ this.emit("message-handler-protocol-event", {
+ name,
+ data,
+ sessionId: this.sessionId,
+ });
+ }
+
+ /**
+ * Forward the provided command to WINDOW_GLOBAL MessageHandlers via the
+ * RootTransport.
+ *
+ * @param {Command} command
+ * The command to forward. See type definition in MessageHandler.js
+ * @returns {Promise}
+ * Returns a promise that resolves with the result of the command.
+ */
+ forwardCommand(command) {
+ switch (command.destination.type) {
+ case lazy.WindowGlobalMessageHandler.type:
+ return this.#rootTransport.forwardCommand(command);
+ default:
+ throw new Error(
+ `Cannot forward command to "${command.destination.type}" from "${this.constructor.type}".`
+ );
+ }
+ }
+
+ matchesContext() {
+ return true;
+ }
+
+ observe(subject, topic) {
+ if (topic !== "window-global-destroyed") {
+ return;
+ }
+
+ this.#cleanUpRealmsForWindow(
+ subject.innerWindowId,
+ subject.browsingContext
+ );
+ }
+
+ /**
+ * Remove session data items of a given module, category and
+ * contextDescriptor.
+ *
+ * Forwards the call to the SessionData instance owned by this
+ * RootMessageHandler and propagates the information via a command to existing
+ * MessageHandlers.
+ */
+ removeSessionDataItem(sessionData = {}) {
+ sessionData.method = lazy.SessionDataMethod.Remove;
+ return this.updateSessionData([sessionData]);
+ }
+
+ /**
+ * Update session data items of a given module, category and
+ * contextDescriptor.
+ *
+ * Forwards the call to the SessionData instance owned by this
+ * RootMessageHandler.
+ */
+ async updateSessionData(sessionData = []) {
+ await this.#sessionData.updateSessionData(sessionData);
+ }
+
+ #cleanUpRealmsForWindow(innerWindowId, context) {
+ const realms = this.#realms.get(innerWindowId);
+
+ if (!realms) {
+ return;
+ }
+
+ realms.forEach(realm => {
+ this.#realms.get(innerWindowId).delete(realm);
+
+ this.emitEvent("realm-destroyed", {
+ context,
+ realm,
+ });
+ });
+
+ this.#realms.delete(innerWindowId);
+ }
+
+ #onRealmCreated = data => {
+ const { innerWindowId, realmInfo } = data;
+
+ if (!this.#realms.has(innerWindowId)) {
+ this.#realms.set(innerWindowId, new Set());
+ }
+
+ this.#realms.get(innerWindowId).add(realmInfo.realm);
+ };
+}
diff --git a/remote/shared/messagehandler/RootMessageHandlerRegistry.sys.mjs b/remote/shared/messagehandler/RootMessageHandlerRegistry.sys.mjs
new file mode 100644
index 0000000000..09ac489182
--- /dev/null
+++ b/remote/shared/messagehandler/RootMessageHandlerRegistry.sys.mjs
@@ -0,0 +1,17 @@
+/* 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 { MessageHandlerRegistry } from "chrome://remote/content/shared/messagehandler/MessageHandlerRegistry.sys.mjs";
+
+import { RootMessageHandler } from "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs";
+
+/**
+ * In the parent process, only one Root MessageHandlerRegistry should ever be
+ * created. All consumers can safely use this singleton to retrieve the Root
+ * registry and from there either create or retrieve Root MessageHandler
+ * instances for a specific session.
+ */
+export var RootMessageHandlerRegistry = new MessageHandlerRegistry(
+ RootMessageHandler.type
+);
diff --git a/remote/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs b/remote/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs
new file mode 100644
index 0000000000..584c73d72f
--- /dev/null
+++ b/remote/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs
@@ -0,0 +1,264 @@
+/* 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 {
+ ContextDescriptorType,
+ MessageHandler,
+} from "chrome://remote/content/shared/messagehandler/MessageHandler.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
+ getMessageHandlerFrameChildActor:
+ "chrome://remote/content/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameChild.sys.mjs",
+ RootMessageHandler:
+ "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs",
+ WindowRealm: "chrome://remote/content/shared/Realm.sys.mjs",
+});
+
+/**
+ * A WindowGlobalMessageHandler is dedicated to debugging a single window
+ * global. It follows the lifecycle of the corresponding window global and will
+ * therefore not survive any navigation. This MessageHandler cannot forward
+ * commands further to other MessageHandlers and represents a leaf node in a
+ * MessageHandler network.
+ */
+export class WindowGlobalMessageHandler extends MessageHandler {
+ #innerWindowId;
+ #realms;
+
+ constructor() {
+ super(...arguments);
+
+ this.#innerWindowId = this.context.window.windowGlobalChild.innerWindowId;
+
+ // Maps sandbox names to instances of window realms.
+ this.#realms = new Map();
+ }
+
+ initialize(sessionDataItems) {
+ // Create the default realm, it is mapped to an empty string sandbox name.
+ this.#realms.set("", this.#createRealm());
+
+ // This method, even though being async, is not awaited on purpose,
+ // since for now the sessionDataItems are passed in response to an event in a for loop.
+ this.#applyInitialSessionDataItems(sessionDataItems);
+
+ // With the session data applied the handler is now ready to be used.
+ this.emitEvent("window-global-handler-created", {
+ contextId: this.contextId,
+ innerWindowId: this.#innerWindowId,
+ });
+ }
+
+ destroy() {
+ for (const realm of this.#realms.values()) {
+ realm.destroy();
+ }
+ this.emitEvent("windowglobal-pagehide", {
+ context: this.context,
+ innerWindowId: this.innerWindowId,
+ });
+ this.#realms = null;
+
+ super.destroy();
+ }
+
+ /**
+ * Returns the WindowGlobalMessageHandler module path.
+ *
+ * @returns {string}
+ */
+ static get modulePath() {
+ return "windowglobal";
+ }
+
+ /**
+ * Returns the WindowGlobalMessageHandler type.
+ *
+ * @returns {string}
+ */
+ static get type() {
+ return "WINDOW_GLOBAL";
+ }
+
+ /**
+ * For WINDOW_GLOBAL MessageHandlers, `context` is a BrowsingContext,
+ * and BrowsingContext.id can be used as the context id.
+ *
+ * @param {BrowsingContext} context
+ * WindowGlobalMessageHandler contexts are expected to be
+ * BrowsingContexts.
+ * @returns {string}
+ * The browsing context id.
+ */
+ static getIdFromContext(context) {
+ return context.id;
+ }
+
+ get innerWindowId() {
+ return this.#innerWindowId;
+ }
+
+ get realms() {
+ return this.#realms;
+ }
+
+ get window() {
+ return this.context.window;
+ }
+
+ #createRealm(sandboxName = null) {
+ const realm = new lazy.WindowRealm(this.context.window, {
+ sandboxName,
+ });
+
+ this.emitEvent("realm-created", {
+ realmInfo: realm.getInfo(),
+ innerWindowId: this.innerWindowId,
+ });
+
+ return realm;
+ }
+
+ #getRealmFromSandboxName(sandboxName = null) {
+ if (sandboxName === null || sandboxName === "") {
+ return this.#realms.get("");
+ }
+
+ if (this.#realms.has(sandboxName)) {
+ return this.#realms.get(sandboxName);
+ }
+
+ const realm = this.#createRealm(sandboxName);
+
+ this.#realms.set(sandboxName, realm);
+
+ return realm;
+ }
+
+ async #applyInitialSessionDataItems(sessionDataItems) {
+ if (!Array.isArray(sessionDataItems)) {
+ return;
+ }
+
+ const destination = {
+ type: WindowGlobalMessageHandler.type,
+ };
+
+ // Create a Map with the structure moduleName -> category -> relevant session data items.
+ const structuredUpdates = new Map();
+ for (const sessionDataItem of sessionDataItems) {
+ const { category, contextDescriptor, moduleName } = sessionDataItem;
+
+ if (!this.matchesContext(contextDescriptor)) {
+ continue;
+ }
+ if (!structuredUpdates.has(moduleName)) {
+ // Skip session data item if the module is not present
+ // for the destination.
+ if (!this.moduleCache.hasModuleClass(moduleName, destination)) {
+ continue;
+ }
+ structuredUpdates.set(moduleName, new Map());
+ }
+
+ if (!structuredUpdates.get(moduleName).has(category)) {
+ structuredUpdates.get(moduleName).set(category, new Set());
+ }
+
+ structuredUpdates.get(moduleName).get(category).add(sessionDataItem);
+ }
+
+ const sessionDataPromises = [];
+
+ for (const [moduleName, categories] of structuredUpdates.entries()) {
+ for (const [category, relevantSessionData] of categories.entries()) {
+ sessionDataPromises.push(
+ this.handleCommand({
+ moduleName,
+ commandName: "_applySessionData",
+ params: {
+ category,
+ sessionData: Array.from(relevantSessionData),
+ },
+ destination,
+ })
+ );
+ }
+ }
+
+ await Promise.all(sessionDataPromises);
+ }
+
+ forwardCommand(command) {
+ switch (command.destination.type) {
+ case lazy.RootMessageHandler.type:
+ return lazy
+ .getMessageHandlerFrameChildActor(this)
+ .sendCommand(command, this.sessionId);
+ default:
+ throw new Error(
+ `Cannot forward command to "${command.destination.type}" from "${this.constructor.type}".`
+ );
+ }
+ }
+
+ /**
+ * If <var>realmId</var> is null or not provided get the realm for
+ * a given <var>sandboxName</var>, otherwise find the realm
+ * in the cache with the realm id equal given <var>realmId</var>.
+ *
+ * @param {object} options
+ * @param {string|null=} options.realmId
+ * The realm id.
+ * @param {string=} options.sandboxName
+ * The name of sandbox
+ *
+ * @returns {Realm}
+ * The realm object.
+ */
+ getRealm(options = {}) {
+ const { realmId = null, sandboxName } = options;
+ if (realmId === null) {
+ return this.#getRealmFromSandboxName(sandboxName);
+ }
+
+ const realm = Array.from(this.#realms.values()).find(
+ realm => realm.id === realmId
+ );
+
+ if (realm) {
+ return realm;
+ }
+
+ throw new lazy.error.NoSuchFrameError(`Realm with id ${realmId} not found`);
+ }
+
+ matchesContext(contextDescriptor) {
+ return (
+ contextDescriptor.type === ContextDescriptorType.All ||
+ (contextDescriptor.type === ContextDescriptorType.TopBrowsingContext &&
+ contextDescriptor.id === this.context.browserId)
+ );
+ }
+
+ /**
+ * Send a command to the root MessageHandler.
+ *
+ * @param {Command} command
+ * The command to send to the root MessageHandler.
+ * @returns {Promise}
+ * A promise which resolves with the return value of the command.
+ */
+ sendRootCommand(command) {
+ return this.handleCommand({
+ ...command,
+ destination: {
+ type: lazy.RootMessageHandler.type,
+ },
+ });
+ }
+}
diff --git a/remote/shared/messagehandler/sessiondata/SessionData.sys.mjs b/remote/shared/messagehandler/sessiondata/SessionData.sys.mjs
new file mode 100644
index 0000000000..10da617f77
--- /dev/null
+++ b/remote/shared/messagehandler/sessiondata/SessionData.sys.mjs
@@ -0,0 +1,392 @@
+/* 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, {
+ ContextDescriptorType:
+ "chrome://remote/content/shared/messagehandler/MessageHandler.sys.mjs",
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+ RootMessageHandler:
+ "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs",
+ WindowGlobalMessageHandler:
+ "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
+
+/**
+ * @typedef {string} SessionDataCategory
+ */
+
+/**
+ * Enum of session data categories.
+ *
+ * @readonly
+ * @enum {SessionDataCategory}
+ */
+export const SessionDataCategory = {
+ Event: "event",
+ PreloadScript: "preload-script",
+};
+
+/**
+ * @typedef {string} SessionDataMethod
+ */
+
+/**
+ * Enum of session data methods.
+ *
+ * @readonly
+ * @enum {SessionDataMethod}
+ */
+export const SessionDataMethod = {
+ Add: "add",
+ Remove: "remove",
+};
+
+export const SESSION_DATA_SHARED_DATA_KEY = "MessageHandlerSessionData";
+
+// This is a map from session id to session data, which will be persisted and
+// propagated to all processes using Services' sharedData.
+// We have to store this as a unique object under a unique shared data key
+// because new MessageHandlers in other processes will need to access this data
+// without any notion of a specific session.
+// This is a singleton.
+const sessionDataMap = new Map();
+
+/**
+ * @typedef {object} SessionDataItem
+ * @property {string} moduleName
+ * The name of the module responsible for this data item.
+ * @property {SessionDataCategory} category
+ * The category of data. The supported categories depend on the module.
+ * @property {(string|number|boolean)} value
+ * Value of the session data item.
+ * @property {ContextDescriptor} contextDescriptor
+ * ContextDescriptor to which this session data applies.
+ */
+
+/**
+ * @typedef SessionDataItemUpdate
+ * @property {SessionDataMethod} method
+ * The way sessionData is updated.
+ * @property {string} moduleName
+ * The name of the module responsible for this data item.
+ * @property {SessionDataCategory} category
+ * The category of data. The supported categories depend on the module.
+ * @property {Array<(string|number|boolean)>} values
+ * Values of the session data item update.
+ * @property {ContextDescriptor} contextDescriptor
+ * ContextDescriptor to which this session data applies.
+ */
+
+/**
+ * SessionData provides APIs to read and write the session data for a specific
+ * ROOT message handler. It holds the session data as a property and acts as the
+ * source of truth for this session data.
+ *
+ * The session data of a given message handler network should contain all the
+ * information that might be needed to setup new contexts, for instance a list
+ * of subscribed events, a list of breakpoints etc.
+ *
+ * The actual session data is an array of SessionDataItems. Example below:
+ * ```
+ * data: [
+ * {
+ * moduleName: "log",
+ * category: "event",
+ * value: "log.entryAdded",
+ * contextDescriptor: { type: "all" }
+ * },
+ * {
+ * moduleName: "browsingContext",
+ * category: "event",
+ * value: "browsingContext.contextCreated",
+ * contextDescriptor: { type: "browser-element", id: "7"}
+ * },
+ * {
+ * moduleName: "browsingContext",
+ * category: "event",
+ * value: "browsingContext.contextCreated",
+ * contextDescriptor: { type: "browser-element", id: "12"}
+ * },
+ * ]
+ * ```
+ *
+ * The session data will be persisted using Services.ppmm.sharedData, so that
+ * new contexts living in different processes can also access the information
+ * during their startup.
+ *
+ * This class should only be used from a ROOT MessageHandler, or from modules
+ * owned by a ROOT MessageHandler. Other MessageHandlers should rely on
+ * SessionDataReader's readSessionData to get read-only access to session data.
+ *
+ */
+export class SessionData {
+ constructor(messageHandler) {
+ if (messageHandler.constructor.type != lazy.RootMessageHandler.type) {
+ throw new Error(
+ "SessionData should only be used from a ROOT MessageHandler"
+ );
+ }
+
+ this._messageHandler = messageHandler;
+
+ /*
+ * The actual data for this session. This is an array of SessionDataItems.
+ */
+ this._data = [];
+ }
+
+ destroy() {
+ // Update the sessionDataMap singleton.
+ sessionDataMap.delete(this._messageHandler.sessionId);
+
+ // Update sharedData and flush to force consistency.
+ Services.ppmm.sharedData.set(SESSION_DATA_SHARED_DATA_KEY, sessionDataMap);
+ Services.ppmm.sharedData.flush();
+ }
+
+ /**
+ * Update session data items of a given module, category and
+ * contextDescriptor.
+ *
+ * A SessionDataItem will be added or removed for each value of each update
+ * in the provided array.
+ *
+ * Attempting to add a duplicate SessionDataItem or to remove an unknown
+ * SessionDataItem will be silently skipped (no-op).
+ *
+ * The data will be persisted across processes at the end of this method.
+ *
+ * @param {Array<SessionDataItemUpdate>} sessionDataItemUpdates
+ * Array of session data item updates.
+ *
+ * @returns {Array<SessionDataItemUpdate>}
+ * The subset of session data item updates which want to be applied.
+ */
+ applySessionData(sessionDataItemUpdates = []) {
+ // The subset of session data item updates, which are cleaned up from
+ // duplicates and unknown items.
+ let updates = [];
+ for (const sessionDataItemUpdate of sessionDataItemUpdates) {
+ const { category, contextDescriptor, method, moduleName, values } =
+ sessionDataItemUpdate;
+ const updatedValues = [];
+ for (const value of values) {
+ const item = { moduleName, category, contextDescriptor, value };
+
+ if (method === SessionDataMethod.Add) {
+ const hasItem = this._findIndex(item) != -1;
+
+ if (!hasItem) {
+ this._data.push(item);
+ updatedValues.push(value);
+ } else {
+ lazy.logger.warn(
+ `Duplicated session data item was not added: ${JSON.stringify(
+ item
+ )}`
+ );
+ }
+ } else {
+ const itemIndex = this._findIndex(item);
+
+ if (itemIndex != -1) {
+ // The item was found in the session data, remove it.
+ this._data.splice(itemIndex, 1);
+ updatedValues.push(value);
+ } else {
+ lazy.logger.warn(
+ `Missing session data item was not removed: ${JSON.stringify(
+ item
+ )}`
+ );
+ }
+ }
+ }
+
+ if (updatedValues.length) {
+ updates.push({
+ ...sessionDataItemUpdate,
+ values: updatedValues,
+ });
+ }
+ }
+ // Persist the sessionDataMap.
+ this._persist();
+
+ return updates;
+ }
+
+ /**
+ * Retrieve the SessionDataItems for a given module and type.
+ *
+ * @param {string} moduleName
+ * The name of the module responsible for this data item.
+ * @param {string} category
+ * The session data category.
+ * @param {ContextDescriptor=} contextDescriptor
+ * Optional context descriptor, to retrieve only session data items added
+ * for a specific context descriptor.
+ * @returns {Array<SessionDataItem>}
+ * Array of SessionDataItems for the provided module and type.
+ */
+ getSessionData(moduleName, category, contextDescriptor) {
+ return this._data.filter(
+ item =>
+ item.moduleName === moduleName &&
+ item.category === category &&
+ (!contextDescriptor ||
+ this._isSameContextDescriptor(
+ item.contextDescriptor,
+ contextDescriptor
+ ))
+ );
+ }
+
+ /**
+ * Update session data items of a given module, category and
+ * contextDescriptor and propagate the information
+ * via a command to existing MessageHandlers.
+ *
+ * @param {Array<SessionDataItemUpdate>} sessionDataItemUpdates
+ * Array of session data item updates.
+ */
+ async updateSessionData(sessionDataItemUpdates = []) {
+ const updates = this.applySessionData(sessionDataItemUpdates);
+
+ if (!updates.length) {
+ // Avoid unnecessary broadcast if no items were updated.
+ return;
+ }
+
+ // Create a Map with the structure moduleName -> category -> list of descriptors.
+ const structuredUpdates = new Map();
+ for (const { moduleName, category, contextDescriptor } of updates) {
+ if (!structuredUpdates.has(moduleName)) {
+ structuredUpdates.set(moduleName, new Map());
+ }
+ if (!structuredUpdates.get(moduleName).has(category)) {
+ structuredUpdates.get(moduleName).set(category, new Set());
+ }
+ const descriptors = structuredUpdates.get(moduleName).get(category);
+ // If there is at least one update for all contexts,
+ // keep only this descriptor in the list of descriptors
+ if (contextDescriptor.type === lazy.ContextDescriptorType.All) {
+ structuredUpdates
+ .get(moduleName)
+ .set(category, new Set([contextDescriptor]));
+ }
+ // Add an individual descriptor if there is no descriptor for all contexts.
+ else if (
+ descriptors.size !== 1 ||
+ Array.from(descriptors)[0]?.type !== lazy.ContextDescriptorType.All
+ ) {
+ descriptors.add(contextDescriptor);
+ }
+ }
+
+ const rootDestination = {
+ type: lazy.RootMessageHandler.type,
+ };
+ const sessionDataPromises = [];
+
+ for (const [moduleName, categories] of structuredUpdates.entries()) {
+ for (const [category, contextDescriptors] of categories.entries()) {
+ // Find sessionData for the category and the moduleName.
+ const relevantSessionData = this._data.filter(
+ item => item.category == category && item.moduleName === moduleName
+ );
+ for (const contextDescriptor of contextDescriptors.values()) {
+ const windowGlobalDestination = {
+ type: lazy.WindowGlobalMessageHandler.type,
+ contextDescriptor,
+ };
+
+ for (const destination of [
+ windowGlobalDestination,
+ rootDestination,
+ ]) {
+ // Only apply session data if the module is present for the destination.
+ if (
+ this._messageHandler.supportsCommand(
+ moduleName,
+ "_applySessionData",
+ destination
+ )
+ ) {
+ sessionDataPromises.push(
+ this._messageHandler
+ .handleCommand({
+ moduleName,
+ commandName: "_applySessionData",
+ params: {
+ sessionData: relevantSessionData,
+ category,
+ contextDescriptor,
+ },
+ destination,
+ })
+ ?.catch(reason =>
+ lazy.logger.error(
+ `_applySessionData for module: ${moduleName} failed, reason: ${reason}`
+ )
+ )
+ );
+ }
+ }
+ }
+ }
+ }
+
+ await Promise.allSettled(sessionDataPromises);
+ }
+
+ _isSameItem(item1, item2) {
+ const descriptor1 = item1.contextDescriptor;
+ const descriptor2 = item2.contextDescriptor;
+
+ return (
+ item1.moduleName === item2.moduleName &&
+ item1.category === item2.category &&
+ this._isSameContextDescriptor(descriptor1, descriptor2) &&
+ this._isSameValue(item1.category, item1.value, item2.value)
+ );
+ }
+
+ _isSameContextDescriptor(contextDescriptor1, contextDescriptor2) {
+ if (contextDescriptor1.type === lazy.ContextDescriptorType.All) {
+ // Ignore the id for type "all" since we made the id optional for this type.
+ return contextDescriptor1.type === contextDescriptor2.type;
+ }
+
+ return (
+ contextDescriptor1.type === contextDescriptor2.type &&
+ contextDescriptor1.id === contextDescriptor2.id
+ );
+ }
+
+ _isSameValue(category, value1, value2) {
+ if (category === SessionDataCategory.PreloadScript) {
+ return value1.script === value2.script;
+ }
+
+ return value1 === value2;
+ }
+
+ _findIndex(item) {
+ return this._data.findIndex(_item => this._isSameItem(item, _item));
+ }
+
+ _persist() {
+ // Update the sessionDataMap singleton.
+ sessionDataMap.set(this._messageHandler.sessionId, this._data);
+
+ // Update sharedData and flush to force consistency.
+ Services.ppmm.sharedData.set(SESSION_DATA_SHARED_DATA_KEY, sessionDataMap);
+ Services.ppmm.sharedData.flush();
+ }
+}
diff --git a/remote/shared/messagehandler/sessiondata/SessionDataReader.sys.mjs b/remote/shared/messagehandler/sessiondata/SessionDataReader.sys.mjs
new file mode 100644
index 0000000000..6d5ea08e59
--- /dev/null
+++ b/remote/shared/messagehandler/sessiondata/SessionDataReader.sys.mjs
@@ -0,0 +1,27 @@
+/* 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, {
+ SESSION_DATA_SHARED_DATA_KEY:
+ "chrome://remote/content/shared/messagehandler/sessiondata/SessionData.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "sharedData", () => {
+ const isInParent =
+ Services.appinfo.processType == Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT;
+
+ return isInParent ? Services.ppmm.sharedData : Services.cpmm.sharedData;
+});
+
+/**
+ * Returns a snapshot of the session data map, which is cloned from the
+ * sessionDataMap singleton of SessionData.jsm.
+ *
+ * @returns {Map.<string, Array<SessionDataItem>>}
+ * Map of session id to arrays of SessionDataItems.
+ */
+export const readSessionData = () =>
+ lazy.sharedData.get(lazy.SESSION_DATA_SHARED_DATA_KEY) || new Map();
diff --git a/remote/shared/messagehandler/test/browser/broadcast/browser.toml b/remote/shared/messagehandler/test/browser/broadcast/browser.toml
new file mode 100644
index 0000000000..f18bfdaab2
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/broadcast/browser.toml
@@ -0,0 +1,22 @@
+[DEFAULT]
+tags = "remote"
+subsuite = "remote"
+support-files = [
+ "doc_messagehandler_broadcasting_xul.xhtml",
+ "head.js",
+ "!/remote/shared/messagehandler/test/browser/head.js",
+ "!/remote/shared/messagehandler/test/browser/resources/*"
+]
+prefs = ["remote.messagehandler.modulecache.useBrowserTestRoot=true"]
+
+["browser_filter_top_browsing_context.js"]
+
+["browser_only_content_process.js"]
+
+["browser_two_tabs.js"]
+
+["browser_two_tabs_with_params.js"]
+
+["browser_two_windows.js"]
+
+["browser_with_frames.js"]
diff --git a/remote/shared/messagehandler/test/browser/broadcast/browser_filter_top_browsing_context.js b/remote/shared/messagehandler/test/browser/broadcast/browser_filter_top_browsing_context.js
new file mode 100644
index 0000000000..c140c26fc6
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/broadcast/browser_filter_top_browsing_context.js
@@ -0,0 +1,84 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const COM_TEST_PAGE = "https://example.com/document-builder.sjs?html=COM";
+const FRAME_TEST_PAGE = createTestMarkupWithFrames();
+
+add_task(async function test_broadcasting_filter_top_browsing_context() {
+ info("Navigate the initial tab to the COM test URL");
+ const tab1 = gBrowser.selectedTab;
+ await loadURL(tab1.linkedBrowser, COM_TEST_PAGE);
+ const browsingContext1 = tab1.linkedBrowser.browsingContext;
+
+ info("Open a second tab on the frame test URL");
+ const tab2 = await addTab(FRAME_TEST_PAGE);
+ const browsingContext2 = tab2.linkedBrowser.browsingContext;
+
+ const contextsForTab2 =
+ tab2.linkedBrowser.browsingContext.getAllBrowsingContextsInSubtree();
+ is(
+ contextsForTab2.length,
+ 4,
+ "Frame test tab has 3 children contexts (4 in total)"
+ );
+
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-broadcasting_filter_top_browsing_context"
+ );
+
+ const broadcastValue1 = await sendBroadcastForTopBrowsingContext(
+ browsingContext1,
+ rootMessageHandler
+ );
+
+ ok(
+ Array.isArray(broadcastValue1),
+ "The broadcast returned an array of values"
+ );
+
+ is(broadcastValue1.length, 1, "The broadcast returned one value as expected");
+
+ ok(
+ broadcastValue1.includes("broadcast-" + browsingContext1.id),
+ "The broadcast returned the expected value from tab1"
+ );
+
+ const broadcastValue2 = await sendBroadcastForTopBrowsingContext(
+ browsingContext2,
+ rootMessageHandler
+ );
+
+ ok(
+ Array.isArray(broadcastValue2),
+ "The broadcast returned an array of values"
+ );
+
+ is(broadcastValue2.length, 4, "The broadcast returned 4 values as expected");
+
+ for (const context of contextsForTab2) {
+ ok(
+ broadcastValue2.includes("broadcast-" + context.id),
+ "The broadcast contains the value for browsing context " + context.id
+ );
+ }
+
+ rootMessageHandler.destroy();
+});
+
+function sendBroadcastForTopBrowsingContext(
+ topBrowsingContext,
+ rootMessageHandler
+) {
+ return sendTestBroadcastCommand(
+ "commandwindowglobalonly",
+ "testBroadcast",
+ {},
+ {
+ type: ContextDescriptorType.TopBrowsingContext,
+ id: topBrowsingContext.browserId,
+ },
+ rootMessageHandler
+ );
+}
diff --git a/remote/shared/messagehandler/test/browser/broadcast/browser_only_content_process.js b/remote/shared/messagehandler/test/browser/broadcast/browser_only_content_process.js
new file mode 100644
index 0000000000..d5090c701e
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/broadcast/browser_only_content_process.js
@@ -0,0 +1,46 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_broadcasting_only_content_process() {
+ info("Navigate the initial tab to the test URL");
+ const tab1 = gBrowser.selectedTab;
+ await loadURL(
+ tab1.linkedBrowser,
+ "https://example.com/document-builder.sjs?html=tab"
+ );
+ const browsingContext1 = tab1.linkedBrowser.browsingContext;
+
+ info("Open a new tab on a parent process about: page");
+ await addTab("about:robots");
+
+ info("Open a new tab on a XUL page");
+ await addTab(
+ getRootDirectory(gTestPath) + "doc_messagehandler_broadcasting_xul.xhtml"
+ );
+
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-broadcasting_only_content_process"
+ );
+ const broadcastValue = await sendTestBroadcastCommand(
+ "commandwindowglobalonly",
+ "testBroadcast",
+ {},
+ contextDescriptorAll,
+ rootMessageHandler
+ );
+
+ ok(
+ Array.isArray(broadcastValue),
+ "The broadcast returned an array of values"
+ );
+
+ is(broadcastValue.length, 1, "The broadcast returned 1 value as expected");
+ ok(
+ broadcastValue.includes("broadcast-" + browsingContext1.id),
+ "The broadcast returned the expected value from tab1"
+ );
+
+ rootMessageHandler.destroy();
+});
diff --git a/remote/shared/messagehandler/test/browser/broadcast/browser_two_tabs.js b/remote/shared/messagehandler/test/browser/broadcast/browser_two_tabs.js
new file mode 100644
index 0000000000..16b97e2a0a
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/broadcast/browser_two_tabs.js
@@ -0,0 +1,46 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_PAGE = "https://example.com/document-builder.sjs?html=tab";
+
+add_task(async function test_broadcasting_two_tabs_command() {
+ info("Navigate the initial tab to the test URL");
+ const tab1 = gBrowser.selectedTab;
+ await loadURL(tab1.linkedBrowser, TEST_PAGE);
+ const browsingContext1 = tab1.linkedBrowser.browsingContext;
+
+ info("Open a new tab on the same test URL");
+ const tab2 = await addTab(TEST_PAGE);
+ const browsingContext2 = tab2.linkedBrowser.browsingContext;
+
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-broadcasting_two_tabs_command"
+ );
+
+ const broadcastValue = await sendTestBroadcastCommand(
+ "commandwindowglobalonly",
+ "testBroadcast",
+ {},
+ contextDescriptorAll,
+ rootMessageHandler
+ );
+
+ ok(
+ Array.isArray(broadcastValue),
+ "The broadcast returned an array of values"
+ );
+
+ is(broadcastValue.length, 2, "The broadcast returned 2 values as expected");
+
+ ok(
+ broadcastValue.includes("broadcast-" + browsingContext1.id),
+ "The broadcast returned the expected value from tab1"
+ );
+ ok(
+ broadcastValue.includes("broadcast-" + browsingContext2.id),
+ "The broadcast returned the expected value from tab2"
+ );
+ rootMessageHandler.destroy();
+});
diff --git a/remote/shared/messagehandler/test/browser/broadcast/browser_two_tabs_with_params.js b/remote/shared/messagehandler/test/browser/broadcast/browser_two_tabs_with_params.js
new file mode 100644
index 0000000000..261b8c4cd6
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/broadcast/browser_two_tabs_with_params.js
@@ -0,0 +1,47 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_PAGE = "https://example.com/document-builder.sjs?html=tab";
+
+add_task(async function test_broadcasting_two_tabs_with_params_command() {
+ info("Navigate the initial tab to the test URL");
+ const tab1 = gBrowser.selectedTab;
+ await loadURL(tab1.linkedBrowser, TEST_PAGE);
+ const browsingContext1 = tab1.linkedBrowser.browsingContext;
+
+ info("Open a new tab on the same test URL");
+ const tab2 = await addTab(TEST_PAGE);
+ const browsingContext2 = tab2.linkedBrowser.browsingContext;
+
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-broadcasting_two_tabs_command"
+ );
+
+ const broadcastValue = await sendTestBroadcastCommand(
+ "commandwindowglobalonly",
+ "testBroadcastWithParameter",
+ {
+ value: "some-value",
+ },
+ contextDescriptorAll,
+ rootMessageHandler
+ );
+ ok(
+ Array.isArray(broadcastValue),
+ "The broadcast returned an array of values"
+ );
+
+ is(broadcastValue.length, 2, "The broadcast returned 2 values as expected");
+
+ ok(
+ broadcastValue.includes("broadcast-" + browsingContext1.id + "-some-value"),
+ "The broadcast returned the expected value from tab1"
+ );
+ ok(
+ broadcastValue.includes("broadcast-" + browsingContext2.id + "-some-value"),
+ "The broadcast returned the expected value from tab2"
+ );
+ rootMessageHandler.destroy();
+});
diff --git a/remote/shared/messagehandler/test/browser/broadcast/browser_two_windows.js b/remote/shared/messagehandler/test/browser/broadcast/browser_two_windows.js
new file mode 100644
index 0000000000..f59bebba69
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/broadcast/browser_two_windows.js
@@ -0,0 +1,47 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_PAGE = "https://example.com/document-builder.sjs?html=tab";
+
+add_task(async function test_broadcasting_two_windows_command() {
+ const window1Browser = gBrowser.selectedTab.linkedBrowser;
+ await loadURL(window1Browser, TEST_PAGE);
+ const browsingContext1 = window1Browser.browsingContext;
+
+ const window2 = await BrowserTestUtils.openNewBrowserWindow();
+ registerCleanupFunction(() => BrowserTestUtils.closeWindow(window2));
+
+ const window2Browser = window2.gBrowser.selectedBrowser;
+ await loadURL(window2Browser, TEST_PAGE);
+ const browsingContext2 = window2Browser.browsingContext;
+
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-broadcasting_two_windows_command"
+ );
+ const broadcastValue = await sendTestBroadcastCommand(
+ "commandwindowglobalonly",
+ "testBroadcast",
+ {},
+ contextDescriptorAll,
+ rootMessageHandler
+ );
+
+ ok(
+ Array.isArray(broadcastValue),
+ "The broadcast returned an array of values"
+ );
+ is(broadcastValue.length, 2, "The broadcast returned 2 values as expected");
+
+ ok(
+ broadcastValue.includes("broadcast-" + browsingContext1.id),
+ "The broadcast returned the expected value from tab1"
+ );
+ ok(
+ broadcastValue.includes("broadcast-" + browsingContext2.id),
+ "The broadcast returned the expected value from tab2"
+ );
+
+ rootMessageHandler.destroy();
+});
diff --git a/remote/shared/messagehandler/test/browser/broadcast/browser_with_frames.js b/remote/shared/messagehandler/test/browser/broadcast/browser_with_frames.js
new file mode 100644
index 0000000000..50326d3885
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/broadcast/browser_with_frames.js
@@ -0,0 +1,40 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_broadcasting_with_frames() {
+ info("Navigate the initial tab to the test URL");
+ const tab = gBrowser.selectedTab;
+ await loadURL(tab.linkedBrowser, createTestMarkupWithFrames());
+
+ const contexts =
+ tab.linkedBrowser.browsingContext.getAllBrowsingContextsInSubtree();
+ is(contexts.length, 4, "Test tab has 3 children contexts (4 in total)");
+
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-broadcasting_with_frames"
+ );
+ const broadcastValue = await sendTestBroadcastCommand(
+ "commandwindowglobalonly",
+ "testBroadcast",
+ {},
+ contextDescriptorAll,
+ rootMessageHandler
+ );
+
+ ok(
+ Array.isArray(broadcastValue),
+ "The broadcast returned an array of values"
+ );
+ is(broadcastValue.length, 4, "The broadcast returned 4 values as expected");
+
+ for (const context of contexts) {
+ ok(
+ broadcastValue.includes("broadcast-" + context.id),
+ "The broadcast contains the value for browsing context " + context.id
+ );
+ }
+
+ rootMessageHandler.destroy();
+});
diff --git a/remote/shared/messagehandler/test/browser/broadcast/doc_messagehandler_broadcasting_xul.xhtml b/remote/shared/messagehandler/test/browser/broadcast/doc_messagehandler_broadcasting_xul.xhtml
new file mode 100644
index 0000000000..91f3503ac3
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/broadcast/doc_messagehandler_broadcasting_xul.xhtml
@@ -0,0 +1,3 @@
+<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+ <box id="box" style="background-color: red;">Test chrome broadcasting</box>
+</window>
diff --git a/remote/shared/messagehandler/test/browser/broadcast/head.js b/remote/shared/messagehandler/test/browser/broadcast/head.js
new file mode 100644
index 0000000000..eb97549c26
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/broadcast/head.js
@@ -0,0 +1,48 @@
+/* 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/. */
+
+"use strict";
+
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/remote/shared/messagehandler/test/browser/head.js",
+ this
+);
+
+/**
+ * Broadcast the provided method to WindowGlobal contexts on a MessageHandler
+ * network.
+ * Returns a promise which will resolve the result of the command broadcast.
+ *
+ * @param {string} module
+ * The name of the module implementing the command to broadcast.
+ * @param {string} command
+ * The name of the command to broadcast.
+ * @param {object} params
+ * The parameters for the command.
+ * @param {ContextDescriptor} contextDescriptor
+ * The context descriptor to use for this broadcast
+ * @param {RootMessageHandler} rootMessageHandler
+ * The root of the MessageHandler network.
+ * @returns {Promise.<Array>}
+ * Promise which resolves an array where each item is the result of the
+ * command handled by an individual context.
+ */
+function sendTestBroadcastCommand(
+ module,
+ command,
+ params,
+ contextDescriptor,
+ rootMessageHandler
+) {
+ info("Send a test broadcast command");
+ return rootMessageHandler.handleCommand({
+ moduleName: module,
+ commandName: command,
+ params,
+ destination: {
+ contextDescriptor,
+ type: WindowGlobalMessageHandler.type,
+ },
+ });
+}
diff --git a/remote/shared/messagehandler/test/browser/browser.toml b/remote/shared/messagehandler/test/browser/browser.toml
new file mode 100644
index 0000000000..ffbc880a0a
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/browser.toml
@@ -0,0 +1,46 @@
+[DEFAULT]
+tags = "remote"
+subsuite = "remote"
+support-files = [
+ "head.js",
+ "resources/*"
+]
+prefs = ["remote.messagehandler.modulecache.useBrowserTestRoot=true"]
+
+["browser_bfcache.js"]
+
+["browser_events_dispatcher.js"]
+
+["browser_events_handler.js"]
+
+["browser_events_interception.js"]
+
+["browser_events_module.js"]
+
+["browser_frame_context_utils.js"]
+
+["browser_handle_command_errors.js"]
+
+["browser_handle_command_retry.js"]
+
+["browser_handle_simple_command.js"]
+
+["browser_navigation_manager.js"]
+
+["browser_realms.js"]
+
+["browser_registry.js"]
+
+["browser_session_data.js"]
+
+["browser_session_data_browser_element.js"]
+
+["browser_session_data_constructor_race.js"]
+
+["browser_session_data_update.js"]
+
+["browser_session_data_update_categories.js"]
+
+["browser_session_data_update_contexts.js"]
+
+["browser_windowglobal_to_root.js"]
diff --git a/remote/shared/messagehandler/test/browser/browser_bfcache.js b/remote/shared/messagehandler/test/browser/browser_bfcache.js
new file mode 100644
index 0000000000..f829d8b58d
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/browser_bfcache.js
@@ -0,0 +1,98 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { RootMessageHandler } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs"
+);
+
+const TEST_PREF = "remote.messagehandler.test.pref";
+
+// Check that pages in bfcache no longer have message handlers attached to them,
+// and that they will not emit unexpected events.
+add_task(async function test_bfcache_broadcast() {
+ const tab = await addTab("https://example.com/document-builder.sjs?html=tab");
+ const rootMessageHandler = createRootMessageHandler("session-id-bfcache");
+
+ try {
+ const browsingContext = tab.linkedBrowser.browsingContext;
+ const contextDescriptor = {
+ type: ContextDescriptorType.TopBrowsingContext,
+ id: browsingContext.browserId,
+ };
+
+ // Whenever a "preference-changed" event from the eventonprefchange module
+ // will be received on the root MessageHandler, increment a counter.
+ let preferenceChangeEventCount = 0;
+ const onEvent = (evtName, wrappedEvt) => {
+ if (wrappedEvt.name === "preference-changed") {
+ preferenceChangeEventCount++;
+ }
+ };
+ rootMessageHandler.on("message-handler-event", onEvent);
+
+ // Initialize the preference, no eventonprefchange module should be created
+ // yet so preferenceChangeEventCount is not expected to be updated.
+ Services.prefs.setIntPref(TEST_PREF, 0);
+ await TestUtils.waitForCondition(() => preferenceChangeEventCount >= 0);
+ is(preferenceChangeEventCount, 0);
+
+ // Broadcast a "ping" command to force the creation of the eventonprefchange
+ // module
+ let values = await sendPingCommand(rootMessageHandler, contextDescriptor);
+ is(values.length, 1, "Broadcast returned a single value");
+
+ Services.prefs.setIntPref(TEST_PREF, 1);
+ await TestUtils.waitForCondition(() => preferenceChangeEventCount >= 1);
+ is(preferenceChangeEventCount, 1);
+
+ info("Navigate to another page");
+ await loadURL(
+ tab.linkedBrowser,
+ "https://example.com/document-builder.sjs?html=othertab"
+ );
+
+ values = await sendPingCommand(rootMessageHandler, contextDescriptor);
+ is(values.length, 1, "Broadcast returned a single value after navigation");
+
+ info("Update the preference and check we only receive 1 event");
+ Services.prefs.setIntPref(TEST_PREF, 2);
+ await TestUtils.waitForCondition(() => preferenceChangeEventCount >= 2);
+ is(preferenceChangeEventCount, 2);
+
+ info("Navigate to another origin");
+ await loadURL(
+ tab.linkedBrowser,
+ "https://example.org/document-builder.sjs?html=otherorigin"
+ );
+
+ values = await sendPingCommand(rootMessageHandler, contextDescriptor);
+ is(
+ values.length,
+ 1,
+ "Broadcast returned a single value after cross origin navigation"
+ );
+
+ info("Update the preference and check again that we only receive 1 event");
+ Services.prefs.setIntPref(TEST_PREF, 3);
+ await TestUtils.waitForCondition(() => preferenceChangeEventCount >= 3);
+ is(preferenceChangeEventCount, 3);
+ } finally {
+ rootMessageHandler.destroy();
+ gBrowser.removeTab(tab);
+ Services.prefs.clearUserPref(TEST_PREF);
+ }
+});
+
+function sendPingCommand(rootMessageHandler, contextDescriptor) {
+ return rootMessageHandler.handleCommand({
+ moduleName: "eventonprefchange",
+ commandName: "ping",
+ params: {},
+ destination: {
+ contextDescriptor,
+ type: WindowGlobalMessageHandler.type,
+ },
+ });
+}
diff --git a/remote/shared/messagehandler/test/browser/browser_events_dispatcher.js b/remote/shared/messagehandler/test/browser/browser_events_dispatcher.js
new file mode 100644
index 0000000000..98d9fd2890
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/browser_events_dispatcher.js
@@ -0,0 +1,532 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Check the basic behavior of on/off.
+ */
+add_task(async function test_add_remove_event_listener() {
+ const tab = await addTab("https://example.com/document-builder.sjs?html=tab");
+ const browsingContext = tab.linkedBrowser.browsingContext;
+ const contextDescriptor = {
+ type: ContextDescriptorType.TopBrowsingContext,
+ id: browsingContext.browserId,
+ };
+
+ const root = createRootMessageHandler("session-id-event");
+ const monitoringEvents = await setupEventMonitoring(root);
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(await isSubscribed(root, browsingContext), false);
+
+ info("Add an listener for eventemitter.testEvent");
+ const events = [];
+ const onEvent = (event, data) => events.push(data.text);
+ await root.eventsDispatcher.on(
+ "eventemitter.testEvent",
+ contextDescriptor,
+ onEvent
+ );
+ is(await isSubscribed(root, browsingContext), true);
+
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(events.length, 1);
+
+ info(
+ "Remove a listener for a callback not added before and check that the first one is still registered"
+ );
+ const anotherCallback = () => {};
+ await root.eventsDispatcher.off(
+ "eventemitter.testEvent",
+ contextDescriptor,
+ anotherCallback
+ );
+ is(await isSubscribed(root, browsingContext), true);
+
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(events.length, 2);
+
+ info("Remove the listener for eventemitter.testEvent");
+ await root.eventsDispatcher.off(
+ "eventemitter.testEvent",
+ contextDescriptor,
+ onEvent
+ );
+ is(await isSubscribed(root, browsingContext), false);
+
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(events.length, 2);
+
+ info("Add the listener for eventemitter.testEvent again");
+ await root.eventsDispatcher.on(
+ "eventemitter.testEvent",
+ contextDescriptor,
+ onEvent
+ );
+ is(await isSubscribed(root, browsingContext), true);
+
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(events.length, 3);
+
+ info("Remove the listener for eventemitter.testEvent");
+ await root.eventsDispatcher.off(
+ "eventemitter.testEvent",
+ contextDescriptor,
+ onEvent
+ );
+ is(await isSubscribed(root, browsingContext), false);
+
+ info("Remove the listener again to check the API will not throw");
+ await root.eventsDispatcher.off(
+ "eventemitter.testEvent",
+ contextDescriptor,
+ onEvent
+ );
+
+ root.destroy();
+ gBrowser.removeTab(tab);
+});
+
+add_task(async function test_has_listener() {
+ const tab1 = await addTab("https://example.com/document-builder.sjs?html=1");
+ const browsingContext1 = tab1.linkedBrowser.browsingContext;
+
+ const tab2 = await addTab("https://example.com/document-builder.sjs?html=2");
+ const browsingContext2 = tab2.linkedBrowser.browsingContext;
+
+ const contextDescriptor1 = {
+ type: ContextDescriptorType.TopBrowsingContext,
+ id: browsingContext1.browserId,
+ };
+ const contextDescriptor2 = {
+ type: ContextDescriptorType.TopBrowsingContext,
+ id: browsingContext2.browserId,
+ };
+
+ const root = createRootMessageHandler("session-id-event");
+
+ // Shortcut for the EventsDispatcher.hasListener API.
+ function hasListener(contextId) {
+ return root.eventsDispatcher.hasListener("eventemitter.testEvent", {
+ contextId,
+ });
+ }
+
+ const onEvent = () => {};
+ await root.eventsDispatcher.on(
+ "eventemitter.testEvent",
+ contextDescriptor1,
+ onEvent
+ );
+ ok(hasListener(browsingContext1.id), "Has a listener for browsingContext1");
+ ok(!hasListener(browsingContext2.id), "No listener for browsingContext2");
+
+ await root.eventsDispatcher.on(
+ "eventemitter.testEvent",
+ contextDescriptor2,
+ onEvent
+ );
+ ok(hasListener(browsingContext1.id), "Still a listener for browsingContext1");
+ ok(hasListener(browsingContext2.id), "Has a listener for browsingContext2");
+
+ await root.eventsDispatcher.off(
+ "eventemitter.testEvent",
+ contextDescriptor1,
+ onEvent
+ );
+ ok(!hasListener(browsingContext1.id), "No listener for browsingContext1");
+ ok(hasListener(browsingContext2.id), "Still a listener for browsingContext2");
+
+ await root.eventsDispatcher.off(
+ "eventemitter.testEvent",
+ contextDescriptor2,
+ onEvent
+ );
+ ok(!hasListener(browsingContext1.id), "No listener for browsingContext1");
+ ok(!hasListener(browsingContext2.id), "No listener for browsingContext2");
+
+ await root.eventsDispatcher.on(
+ "eventemitter.testEvent",
+ {
+ type: ContextDescriptorType.All,
+ },
+ onEvent
+ );
+ ok(hasListener(browsingContext1.id), "Has a listener for browsingContext1");
+ ok(hasListener(browsingContext2.id), "Has a listener for browsingContext2");
+
+ await root.eventsDispatcher.off(
+ "eventemitter.testEvent",
+ {
+ type: ContextDescriptorType.All,
+ },
+ onEvent
+ );
+ ok(!hasListener(browsingContext1.id), "No listener for browsingContext1");
+ ok(!hasListener(browsingContext2.id), "No listener for browsingContext2");
+
+ root.destroy();
+ gBrowser.removeTab(tab2);
+ gBrowser.removeTab(tab1);
+});
+
+/**
+ * Check that two callbacks can subscribe to the same event in the same context
+ * in parallel.
+ */
+add_task(async function test_two_callbacks() {
+ const tab = await addTab("https://example.com/document-builder.sjs?html=tab");
+ const browsingContext = tab.linkedBrowser.browsingContext;
+ const contextDescriptor = {
+ type: ContextDescriptorType.TopBrowsingContext,
+ id: browsingContext.browserId,
+ };
+
+ const root = createRootMessageHandler("session-id-event");
+ const monitoringEvents = await setupEventMonitoring(root);
+
+ info("Add an listener for eventemitter.testEvent");
+ const events = [];
+ const onEvent = (event, data) => events.push(data.text);
+ await root.eventsDispatcher.on(
+ "eventemitter.testEvent",
+ contextDescriptor,
+ onEvent
+ );
+
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(events.length, 1);
+
+ info("Add another listener for eventemitter.testEvent");
+ const otherevents = [];
+ const otherCallback = (event, data) => otherevents.push(data.text);
+ await root.eventsDispatcher.on(
+ "eventemitter.testEvent",
+ contextDescriptor,
+ otherCallback
+ );
+ is(await isSubscribed(root, browsingContext), true);
+
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(events.length, 2);
+ is(otherevents.length, 1);
+
+ info("Remove the other listener for eventemitter.testEvent");
+ await root.eventsDispatcher.off(
+ "eventemitter.testEvent",
+ contextDescriptor,
+ otherCallback
+ );
+ is(await isSubscribed(root, browsingContext), true);
+
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(events.length, 3);
+ is(otherevents.length, 1);
+
+ info("Remove the first listener for eventemitter.testEvent");
+ await root.eventsDispatcher.off(
+ "eventemitter.testEvent",
+ contextDescriptor,
+ onEvent
+ );
+ is(await isSubscribed(root, browsingContext), false);
+
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(events.length, 3);
+ is(otherevents.length, 1);
+
+ root.destroy();
+ gBrowser.removeTab(tab);
+});
+
+/**
+ * Check that two callbacks can subscribe to the same event in the two contexts.
+ */
+add_task(async function test_two_contexts() {
+ const tab1 = await addTab("https://example.com/document-builder.sjs?html=1");
+ const browsingContext1 = tab1.linkedBrowser.browsingContext;
+
+ const tab2 = await addTab("https://example.com/document-builder.sjs?html=2");
+ const browsingContext2 = tab2.linkedBrowser.browsingContext;
+
+ const contextDescriptor1 = {
+ type: ContextDescriptorType.TopBrowsingContext,
+ id: browsingContext1.browserId,
+ };
+ const contextDescriptor2 = {
+ type: ContextDescriptorType.TopBrowsingContext,
+ id: browsingContext2.browserId,
+ };
+
+ const root = createRootMessageHandler("session-id-event");
+
+ const monitoringEvents = await setupEventMonitoring(root);
+
+ const events1 = [];
+ const onEvent1 = (event, data) => events1.push(data.text);
+ await root.eventsDispatcher.on(
+ "eventemitter.testEvent",
+ contextDescriptor1,
+ onEvent1
+ );
+ is(await isSubscribed(root, browsingContext1), true);
+ is(await isSubscribed(root, browsingContext2), false);
+
+ const events2 = [];
+ const onEvent2 = (event, data) => events2.push(data.text);
+ await root.eventsDispatcher.on(
+ "eventemitter.testEvent",
+ contextDescriptor2,
+ onEvent2
+ );
+ is(await isSubscribed(root, browsingContext1), true);
+ is(await isSubscribed(root, browsingContext2), true);
+
+ await emitTestEvent(root, browsingContext1, monitoringEvents);
+ is(events1.length, 1);
+ is(events2.length, 0);
+
+ await emitTestEvent(root, browsingContext2, monitoringEvents);
+ is(events1.length, 1);
+ is(events2.length, 1);
+
+ await root.eventsDispatcher.off(
+ "eventemitter.testEvent",
+ contextDescriptor1,
+ onEvent1
+ );
+ is(await isSubscribed(root, browsingContext1), false);
+ is(await isSubscribed(root, browsingContext2), true);
+
+ // No event expected here since the module for browsingContext1 is no longer
+ // subscribed
+ await emitTestEvent(root, browsingContext1, monitoringEvents);
+ is(events1.length, 1);
+ is(events2.length, 1);
+
+ // Whereas the module for browsingContext2 is still subscribed
+ await emitTestEvent(root, browsingContext2, monitoringEvents);
+ is(events1.length, 1);
+ is(events2.length, 2);
+
+ await root.eventsDispatcher.off(
+ "eventemitter.testEvent",
+ contextDescriptor2,
+ onEvent2
+ );
+ is(await isSubscribed(root, browsingContext1), false);
+ is(await isSubscribed(root, browsingContext2), false);
+
+ await emitTestEvent(root, browsingContext1, monitoringEvents);
+ await emitTestEvent(root, browsingContext2, monitoringEvents);
+ is(events1.length, 1);
+ is(events2.length, 2);
+
+ root.destroy();
+ gBrowser.removeTab(tab2);
+ gBrowser.removeTab(tab1);
+});
+
+/**
+ * Check that adding and removing first listener for the specific context and then
+ * for the global context works as expected.
+ */
+add_task(
+ async function test_remove_context_event_listener_and_then_global_event_listener() {
+ const tab = await addTab(
+ "https://example.com/document-builder.sjs?html=tab"
+ );
+ const browsingContext = tab.linkedBrowser.browsingContext;
+ const contextDescriptor = {
+ type: ContextDescriptorType.TopBrowsingContext,
+ id: browsingContext.browserId,
+ };
+ const contextDescriptorAll = {
+ type: ContextDescriptorType.All,
+ };
+
+ const root = createRootMessageHandler("session-id-event");
+ const monitoringEvents = await setupEventMonitoring(root);
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(await isSubscribed(root, browsingContext), false);
+
+ info("Add an listener for eventemitter.testEvent");
+ const events = [];
+ const onEvent = (event, data) => events.push(data.text);
+ await root.eventsDispatcher.on(
+ "eventemitter.testEvent",
+ contextDescriptor,
+ onEvent
+ );
+ is(await isSubscribed(root, browsingContext), true);
+
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(events.length, 1);
+
+ info(
+ "Add another listener for eventemitter.testEvent, using global context"
+ );
+ const eventsAll = [];
+ const onEventAll = (event, data) => eventsAll.push(data.text);
+ await root.eventsDispatcher.on(
+ "eventemitter.testEvent",
+ contextDescriptorAll,
+ onEventAll
+ );
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(eventsAll.length, 1);
+ is(events.length, 2);
+
+ info("Remove the first listener for eventemitter.testEvent");
+ await root.eventsDispatcher.off(
+ "eventemitter.testEvent",
+ contextDescriptor,
+ onEvent
+ );
+
+ info("Check that we are still subscribed to eventemitter.testEvent");
+ is(await isSubscribed(root, browsingContext), true);
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(eventsAll.length, 2);
+ is(events.length, 2);
+
+ await root.eventsDispatcher.off(
+ "eventemitter.testEvent",
+ contextDescriptorAll,
+ onEventAll
+ );
+ is(await isSubscribed(root, browsingContext), false);
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(eventsAll.length, 2);
+ is(events.length, 2);
+
+ root.destroy();
+ gBrowser.removeTab(tab);
+ }
+);
+
+/**
+ * Check that adding and removing first listener for the global context and then
+ * for the specific context works as expected.
+ */
+add_task(
+ async function test_global_event_listener_and_then_remove_context_event_listener() {
+ const tab = await addTab(
+ "https://example.com/document-builder.sjs?html=tab"
+ );
+ const browsingContext = tab.linkedBrowser.browsingContext;
+ const contextDescriptor = {
+ type: ContextDescriptorType.TopBrowsingContext,
+ id: browsingContext.browserId,
+ };
+ const contextDescriptorAll = {
+ type: ContextDescriptorType.All,
+ };
+
+ const root = createRootMessageHandler("session-id-event");
+ const monitoringEvents = await setupEventMonitoring(root);
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(await isSubscribed(root, browsingContext), false);
+
+ info("Add an listener for eventemitter.testEvent");
+ const events = [];
+ const onEvent = (event, data) => events.push(data.text);
+ await root.eventsDispatcher.on(
+ "eventemitter.testEvent",
+ contextDescriptor,
+ onEvent
+ );
+ is(await isSubscribed(root, browsingContext), true);
+
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(events.length, 1);
+
+ info(
+ "Add another listener for eventemitter.testEvent, using global context"
+ );
+ const eventsAll = [];
+ const onEventAll = (event, data) => eventsAll.push(data.text);
+ await root.eventsDispatcher.on(
+ "eventemitter.testEvent",
+ contextDescriptorAll,
+ onEventAll
+ );
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(eventsAll.length, 1);
+ is(events.length, 2);
+
+ info("Remove the global listener for eventemitter.testEvent");
+ await root.eventsDispatcher.off(
+ "eventemitter.testEvent",
+ contextDescriptorAll,
+ onEventAll
+ );
+
+ info(
+ "Check that we are still subscribed to eventemitter.testEvent for the specific context"
+ );
+ is(await isSubscribed(root, browsingContext), true);
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(eventsAll.length, 1);
+ is(events.length, 3);
+
+ await root.eventsDispatcher.off(
+ "eventemitter.testEvent",
+ contextDescriptor,
+ onEvent
+ );
+
+ is(await isSubscribed(root, browsingContext), false);
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(eventsAll.length, 1);
+ is(events.length, 3);
+
+ root.destroy();
+ gBrowser.removeTab(tab);
+ }
+);
+
+async function setupEventMonitoring(root) {
+ const monitoringEvents = [];
+ const onMonitoringEvent = (event, data) => monitoringEvents.push(data.text);
+ root.on("eventemitter.monitoringEvent", onMonitoringEvent);
+
+ registerCleanupFunction(() =>
+ root.off("eventemitter.monitoringEvent", onMonitoringEvent)
+ );
+
+ return monitoringEvents;
+}
+
+async function emitTestEvent(root, browsingContext, monitoringEvents) {
+ const count = monitoringEvents.length;
+ info("Call eventemitter.emitTestEvent");
+ await root.handleCommand({
+ moduleName: "eventemitter",
+ commandName: "emitTestEvent",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContext.id,
+ },
+ });
+
+ // The monitoring event is always emitted, regardless of the status of the
+ // module. Wait for catching this event before resuming the assertions.
+ info("Wait for the monitoring event");
+ await BrowserTestUtils.waitForCondition(
+ () => monitoringEvents.length >= count + 1
+ );
+ is(monitoringEvents.length, count + 1);
+}
+
+function isSubscribed(root, browsingContext) {
+ info("Call eventemitter.isSubscribed");
+ return root.handleCommand({
+ moduleName: "eventemitter",
+ commandName: "isSubscribed",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContext.id,
+ },
+ });
+}
diff --git a/remote/shared/messagehandler/test/browser/browser_events_handler.js b/remote/shared/messagehandler/test/browser/browser_events_handler.js
new file mode 100644
index 0000000000..705c306de3
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/browser_events_handler.js
@@ -0,0 +1,57 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Test that the window-global-handler-created event gets emitted for each
+ * individual frame's browsing context.
+ */
+add_task(async function test_windowGlobalHandlerCreated() {
+ const events = [];
+
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-event_with_frames"
+ );
+
+ info("Add a new session data item to get window global handlers created");
+ await rootMessageHandler.addSessionDataItem({
+ moduleName: "command",
+ category: "browser_session_data_browser_element",
+ contextDescriptor: {
+ type: ContextDescriptorType.All,
+ },
+ values: [true],
+ });
+
+ const onEvent = (evtName, wrappedEvt) => {
+ if (wrappedEvt.name === "window-global-handler-created") {
+ console.info(`Received event for context ${wrappedEvt.data.contextId}`);
+ events.push(wrappedEvt.data);
+ }
+ };
+ rootMessageHandler.on("message-handler-event", onEvent);
+
+ info("Navigate the initial tab to the test URL");
+ const browser = gBrowser.selectedTab.linkedBrowser;
+ await loadURL(browser, createTestMarkupWithFrames());
+
+ const contexts = browser.browsingContext.getAllBrowsingContextsInSubtree();
+ is(contexts.length, 4, "Test tab has 3 children contexts (4 in total)");
+
+ // Wait for all the events
+ await TestUtils.waitForCondition(() => events.length >= 4);
+
+ for (const context of contexts) {
+ const contextEvents = events.filter(evt => {
+ return (
+ evt.contextId === context.id &&
+ evt.innerWindowId === context.currentWindowGlobal.innerWindowId
+ );
+ });
+ is(contextEvents.length, 1, `Found event for context ${context.id}`);
+ }
+
+ rootMessageHandler.off("message-handler-event", onEvent);
+ rootMessageHandler.destroy();
+});
diff --git a/remote/shared/messagehandler/test/browser/browser_events_interception.js b/remote/shared/messagehandler/test/browser/browser_events_interception.js
new file mode 100644
index 0000000000..aaf39353a6
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/browser_events_interception.js
@@ -0,0 +1,112 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { RootMessageHandlerRegistry } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/RootMessageHandlerRegistry.sys.mjs"
+);
+const { RootMessageHandler } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs"
+);
+
+/**
+ * Test that events can be intercepted in the windowglobal-in-root layer.
+ */
+add_task(async function test_intercepted_event() {
+ const tab = BrowserTestUtils.addTab(
+ gBrowser,
+ "https://example.com/document-builder.sjs?html=tab"
+ );
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ const browsingContext = tab.linkedBrowser.browsingContext;
+
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-intercepted_event"
+ );
+
+ const onInterceptedEvent = rootMessageHandler.once(
+ "event.testEventWithInterception"
+ );
+ rootMessageHandler.handleCommand({
+ moduleName: "event",
+ commandName: "testEmitEventWithInterception",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContext.id,
+ },
+ });
+
+ const interceptedEvent = await onInterceptedEvent;
+ is(
+ interceptedEvent.additionalInformation,
+ "information added through interception",
+ "Intercepted event contained additional information"
+ );
+
+ rootMessageHandler.destroy();
+ gBrowser.removeTab(tab);
+});
+
+/**
+ * Test that events can be canceled in the windowglobal-in-root layer.
+ */
+add_task(async function test_cancelable_event() {
+ const tab = BrowserTestUtils.addTab(
+ gBrowser,
+ "https://example.com/document-builder.sjs?html=tab"
+ );
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ const browsingContext = tab.linkedBrowser.browsingContext;
+
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-cancelable_event"
+ );
+
+ const cancelableEvents = [];
+ const onCancelableEvent = (name, event) => cancelableEvents.push(event);
+ rootMessageHandler.on(
+ "event.testEventCancelableWithInterception",
+ onCancelableEvent
+ );
+
+ // Emit an event that should be canceled in the windowglobal-in-root layer.
+ // Note that `shouldCancel` is only something supported for this test event,
+ // and not a general message handler mechanism to cancel events.
+ await rootMessageHandler.handleCommand({
+ moduleName: "event",
+ commandName: "testEmitEventCancelableWithInterception",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContext.id,
+ },
+ params: {
+ shouldCancel: true,
+ },
+ });
+
+ is(cancelableEvents.length, 0, "No event was received");
+
+ // Emit another event which should not be canceled (shouldCancel: false).
+ await rootMessageHandler.handleCommand({
+ moduleName: "event",
+ commandName: "testEmitEventCancelableWithInterception",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContext.id,
+ },
+ params: {
+ shouldCancel: false,
+ },
+ });
+
+ await TestUtils.waitForCondition(() => cancelableEvents.length == 1);
+ is(cancelableEvents[0].shouldCancel, false, "Expected event was received");
+
+ rootMessageHandler.off(
+ "event.testEventCancelableWithInterception",
+ onCancelableEvent
+ );
+ rootMessageHandler.destroy();
+ gBrowser.removeTab(tab);
+});
diff --git a/remote/shared/messagehandler/test/browser/browser_events_module.js b/remote/shared/messagehandler/test/browser/browser_events_module.js
new file mode 100644
index 0000000000..32b60d34b1
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/browser_events_module.js
@@ -0,0 +1,296 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { RootMessageHandlerRegistry } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/RootMessageHandlerRegistry.sys.mjs"
+);
+const { RootMessageHandler } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs"
+);
+
+/**
+ * Emit an event from a WindowGlobal module triggered by a specific command.
+ * Check that the event is emitted on the RootMessageHandler as well as on
+ * the parent process MessageHandlerRegistry.
+ */
+add_task(async function test_event() {
+ const tab = BrowserTestUtils.addTab(
+ gBrowser,
+ "https://example.com/document-builder.sjs?html=tab"
+ );
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ const browsingContext = tab.linkedBrowser.browsingContext;
+
+ const rootMessageHandler = createRootMessageHandler("session-id-event");
+ let messageHandlerEvent;
+ let registryEvent;
+
+ // Events are emitted both as generic message-handler-event events as well
+ // as under their own name. We expect to receive the event for both.
+ const _onMessageHandlerEvent = (eventName, eventData) => {
+ if (eventData.name === "event-from-window-global") {
+ messageHandlerEvent = eventData;
+ }
+ };
+ rootMessageHandler.on("message-handler-event", _onMessageHandlerEvent);
+ const onNamedEvent = rootMessageHandler.once("event-from-window-global");
+ // MessageHandlerRegistry should forward all the message-handler-events.
+ const _onMessageHandlerRegistryEvent = (eventName, eventData) => {
+ if (eventData.name === "event-from-window-global") {
+ registryEvent = eventData;
+ }
+ };
+ RootMessageHandlerRegistry.on(
+ "message-handler-registry-event",
+ _onMessageHandlerRegistryEvent
+ );
+
+ callTestEmitEvent(rootMessageHandler, browsingContext.id);
+
+ const namedEvent = await onNamedEvent;
+ is(
+ namedEvent.text,
+ `event from ${browsingContext.id}`,
+ "Received the expected payload"
+ );
+
+ is(
+ messageHandlerEvent.name,
+ "event-from-window-global",
+ "Received event on the ROOT MessageHandler"
+ );
+ is(
+ messageHandlerEvent.data.text,
+ `event from ${browsingContext.id}`,
+ "Received the expected payload"
+ );
+
+ is(
+ registryEvent,
+ messageHandlerEvent,
+ "The event forwarded by the MessageHandlerRegistry is identical to the MessageHandler event"
+ );
+ rootMessageHandler.off("message-handler-event", _onMessageHandlerEvent);
+ RootMessageHandlerRegistry.off(
+ "message-handler-registry-event",
+ _onMessageHandlerRegistryEvent
+ );
+ rootMessageHandler.destroy();
+ gBrowser.removeTab(tab);
+});
+
+/**
+ * Emit an event from a Root module triggered by a specific command.
+ * Check that the event is emitted on the RootMessageHandler.
+ */
+add_task(async function test_root_event() {
+ const rootMessageHandler = createRootMessageHandler("session-id-root_event");
+
+ // events are emitted both as generic message-handler-event events as
+ // well as under their own name. We expect to receive the event for both.
+ const onHandlerEvent = rootMessageHandler.once("message-handler-event");
+ const onNamedEvent = rootMessageHandler.once("event-from-root");
+
+ rootMessageHandler.handleCommand({
+ moduleName: "event",
+ commandName: "testEmitRootEvent",
+ destination: {
+ type: RootMessageHandler.type,
+ },
+ });
+
+ const { name, data } = await onHandlerEvent;
+ is(name, "event-from-root", "Received event on the ROOT MessageHandler");
+ is(data.text, "event from root", "Received the expected payload");
+
+ const namedEvent = await onNamedEvent;
+ is(namedEvent.text, "event from root", "Received the expected payload");
+
+ rootMessageHandler.destroy();
+});
+
+/**
+ * Emit an event from a windowglobal-in-root module triggered by a specific command.
+ * Check that the event is emitted on the RootMessageHandler.
+ */
+add_task(async function test_windowglobal_in_root_event() {
+ const tab = BrowserTestUtils.addTab(
+ gBrowser,
+ "https://example.com/document-builder.sjs?html=tab"
+ );
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ const browsingContext = tab.linkedBrowser.browsingContext;
+
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-windowglobal_in_root_event"
+ );
+
+ // events are emitted both as generic message-handler-event events as
+ // well as under their own name. We expect to receive the event for both.
+ const onHandlerEvent = rootMessageHandler.once("message-handler-event");
+ const onNamedEvent = rootMessageHandler.once(
+ "event-from-window-global-in-root"
+ );
+ rootMessageHandler.handleCommand({
+ moduleName: "event",
+ commandName: "testEmitWindowGlobalInRootEvent",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContext.id,
+ },
+ });
+
+ const { name, data } = await onHandlerEvent;
+ is(
+ name,
+ "event-from-window-global-in-root",
+ "Received event on the ROOT MessageHandler"
+ );
+ is(
+ data.text,
+ `windowglobal-in-root event for ${browsingContext.id}`,
+ "Received the expected payload"
+ );
+
+ const namedEvent = await onNamedEvent;
+ is(
+ namedEvent.text,
+ `windowglobal-in-root event for ${browsingContext.id}`,
+ "Received the expected payload"
+ );
+
+ rootMessageHandler.destroy();
+ gBrowser.removeTab(tab);
+});
+
+/**
+ * Emit an event from a windowglobal module, but from 2 different sessions.
+ * Check that the event is emitted by the corresponding RootMessageHandler as
+ * well as by the parent process MessageHandlerRegistry.
+ */
+add_task(async function test_event_multisession() {
+ const tab = BrowserTestUtils.addTab(
+ gBrowser,
+ "https://example.com/document-builder.sjs?html=tab"
+ );
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ const browsingContextId = tab.linkedBrowser.browsingContext.id;
+
+ const root1 = createRootMessageHandler("session-id-event_multisession-1");
+ let root1Events = 0;
+ const onRoot1Event = function (evtName, wrappedEvt) {
+ if (wrappedEvt.name === "event-from-window-global") {
+ root1Events++;
+ }
+ };
+ root1.on("message-handler-event", onRoot1Event);
+
+ const root2 = createRootMessageHandler("session-id-event_multisession-2");
+ let root2Events = 0;
+ const onRoot2Event = function (evtName, wrappedEvt) {
+ if (wrappedEvt.name === "event-from-window-global") {
+ root2Events++;
+ }
+ };
+ root2.on("message-handler-event", onRoot2Event);
+
+ let registryEvents = 0;
+ const onRegistryEvent = function (evtName, wrappedEvt) {
+ if (wrappedEvt.name === "event-from-window-global") {
+ registryEvents++;
+ }
+ };
+ RootMessageHandlerRegistry.on(
+ "message-handler-registry-event",
+ onRegistryEvent
+ );
+
+ callTestEmitEvent(root1, browsingContextId);
+ callTestEmitEvent(root2, browsingContextId);
+
+ info("Wait for root1 event to be received");
+ await TestUtils.waitForCondition(() => root1Events === 1);
+ info("Wait for root2 event to be received");
+ await TestUtils.waitForCondition(() => root2Events === 1);
+
+ await TestUtils.waitForTick();
+ is(root1Events, 1, "Session 1 only received 1 event");
+ is(root2Events, 1, "Session 2 only received 1 event");
+ is(
+ registryEvents,
+ 2,
+ "MessageHandlerRegistry forwarded events from both sessions"
+ );
+
+ root1.off("message-handler-event", onRoot1Event);
+ root2.off("message-handler-event", onRoot2Event);
+ RootMessageHandlerRegistry.off(
+ "message-handler-registry-event",
+ onRegistryEvent
+ );
+ root1.destroy();
+ root2.destroy();
+ gBrowser.removeTab(tab);
+});
+
+/**
+ * Test that events can be emitted from individual frame contexts and that
+ * events going through a shared content process MessageHandlerRegistry are not
+ * duplicated.
+ */
+add_task(async function test_event_with_frames() {
+ info("Navigate the initial tab to the test URL");
+ const tab = gBrowser.selectedTab;
+ await loadURL(tab.linkedBrowser, createTestMarkupWithFrames());
+
+ const contexts =
+ tab.linkedBrowser.browsingContext.getAllBrowsingContextsInSubtree();
+ is(contexts.length, 4, "Test tab has 3 children contexts (4 in total)");
+
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-event_with_frames"
+ );
+
+ const rootEvents = [];
+ const onRootEvent = function (evtName, wrappedEvt) {
+ if (wrappedEvt.name === "event-from-window-global") {
+ rootEvents.push(wrappedEvt.data.text);
+ }
+ };
+ rootMessageHandler.on("message-handler-event", onRootEvent);
+
+ const namedEvents = [];
+ const onNamedEvent = (name, event) => namedEvents.push(event.text);
+ rootMessageHandler.on("event-from-window-global", onNamedEvent);
+
+ for (const context of contexts) {
+ callTestEmitEvent(rootMessageHandler, context.id);
+ info("Wait for root event to be received in both event arrays");
+ await TestUtils.waitForCondition(() =>
+ [namedEvents, rootEvents].every(events =>
+ events.includes(`event from ${context.id}`)
+ )
+ );
+ }
+
+ info("Wait for a bit and check that we did not receive duplicated events");
+ await TestUtils.waitForTick();
+ is(rootEvents.length, 4, "Only received 4 events");
+
+ rootMessageHandler.off("message-handler-event", onRootEvent);
+ rootMessageHandler.off("event-from-window-global", onNamedEvent);
+ rootMessageHandler.destroy();
+});
+
+function callTestEmitEvent(rootMessageHandler, browsingContextId) {
+ rootMessageHandler.handleCommand({
+ moduleName: "event",
+ commandName: "testEmitEvent",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContextId,
+ },
+ });
+}
diff --git a/remote/shared/messagehandler/test/browser/browser_frame_context_utils.js b/remote/shared/messagehandler/test/browser/browser_frame_context_utils.js
new file mode 100644
index 0000000000..cddcba3529
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/browser_frame_context_utils.js
@@ -0,0 +1,98 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { isBrowsingContextCompatible } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/transports/BrowsingContextUtils.sys.mjs"
+);
+const TEST_COM_PAGE = "https://example.com/document-builder.sjs?html=com";
+const TEST_NET_PAGE = "https://example.net/document-builder.sjs?html=net";
+
+// Test helpers from BrowsingContextUtils in various processes.
+add_task(async function () {
+ const tab1 = BrowserTestUtils.addTab(gBrowser, TEST_COM_PAGE);
+ const contentBrowser1 = tab1.linkedBrowser;
+ await BrowserTestUtils.browserLoaded(contentBrowser1);
+ const browserId1 = contentBrowser1.browsingContext.browserId;
+
+ const tab2 = BrowserTestUtils.addTab(gBrowser, TEST_NET_PAGE);
+ const contentBrowser2 = tab2.linkedBrowser;
+ await BrowserTestUtils.browserLoaded(contentBrowser2);
+ const browserId2 = contentBrowser2.browsingContext.browserId;
+
+ const { extension, sidebarBrowser } = await installSidebarExtension();
+
+ const tab3 = BrowserTestUtils.addTab(
+ gBrowser,
+ `moz-extension://${extension.uuid}/tab.html`
+ );
+ const { bcId } = await extension.awaitMessage("tab-loaded");
+ const tabExtensionBrowser = BrowsingContext.get(bcId).top.embedderElement;
+
+ const parentBrowser1 = createParentBrowserElement(tab1, "content");
+ const parentBrowser2 = createParentBrowserElement(tab1, "chrome");
+
+ info("Check browsing context compatibility for content browser 1");
+ await checkBrowsingContextCompatible(contentBrowser1, undefined, true);
+ await checkBrowsingContextCompatible(contentBrowser1, browserId1, true);
+ await checkBrowsingContextCompatible(contentBrowser1, browserId2, false);
+
+ info("Check browsing context compatibility for content browser 2");
+ await checkBrowsingContextCompatible(contentBrowser2, undefined, true);
+ await checkBrowsingContextCompatible(contentBrowser2, browserId1, false);
+ await checkBrowsingContextCompatible(contentBrowser2, browserId2, true);
+
+ info("Check browsing context compatibility for parent browser 1");
+ await checkBrowsingContextCompatible(parentBrowser1, undefined, false);
+ await checkBrowsingContextCompatible(parentBrowser1, browserId1, false);
+ await checkBrowsingContextCompatible(parentBrowser1, browserId2, false);
+
+ info("Check browsing context compatibility for parent browser 2");
+ await checkBrowsingContextCompatible(parentBrowser2, undefined, false);
+ await checkBrowsingContextCompatible(parentBrowser2, browserId1, false);
+ await checkBrowsingContextCompatible(parentBrowser2, browserId2, false);
+
+ info("Check browsing context compatibility for extension");
+ await checkBrowsingContextCompatible(sidebarBrowser, undefined, false);
+ await checkBrowsingContextCompatible(sidebarBrowser, browserId1, false);
+ await checkBrowsingContextCompatible(sidebarBrowser, browserId2, false);
+
+ info("Check browsing context compatibility for extension viewed in a tab");
+ await checkBrowsingContextCompatible(tabExtensionBrowser, undefined, false);
+ await checkBrowsingContextCompatible(tabExtensionBrowser, browserId1, false);
+ await checkBrowsingContextCompatible(tabExtensionBrowser, browserId2, false);
+
+ gBrowser.removeTab(tab1);
+ gBrowser.removeTab(tab2);
+ gBrowser.removeTab(tab3);
+ await extension.unload();
+});
+
+async function checkBrowsingContextCompatible(browser, browserId, expected) {
+ const options = { browserId };
+ info("Check browsing context compatibility from the parent process");
+ is(isBrowsingContextCompatible(browser.browsingContext, options), expected);
+
+ info(
+ "Check browsing context compatibility from the browsing context's process"
+ );
+ await SpecialPowers.spawn(
+ browser,
+ [browserId, expected],
+ (_browserId, _expected) => {
+ const BrowsingContextUtils = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/transports/BrowsingContextUtils.sys.mjs"
+ );
+ is(
+ BrowsingContextUtils.isBrowsingContextCompatible(
+ content.browsingContext,
+ {
+ browserId: _browserId,
+ }
+ ),
+ _expected
+ );
+ }
+ );
+}
diff --git a/remote/shared/messagehandler/test/browser/browser_handle_command_errors.js b/remote/shared/messagehandler/test/browser/browser_handle_command_errors.js
new file mode 100644
index 0000000000..c115517980
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/browser_handle_command_errors.js
@@ -0,0 +1,218 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { RootMessageHandler } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs"
+);
+
+// Check that errors from WindowGlobal modules can be caught by the consumer
+// of the RootMessageHandler.
+add_task(async function test_module_error() {
+ const browsingContextId = gBrowser.selectedBrowser.browsingContext.id;
+
+ const rootMessageHandler = createRootMessageHandler("session-id-error");
+
+ info("Call a module method which will throw");
+
+ await Assert.rejects(
+ rootMessageHandler.handleCommand({
+ moduleName: "commandwindowglobalonly",
+ commandName: "testError",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContextId,
+ },
+ }),
+ err => err.message.includes("error-from-module"),
+ "Error from window global module caught"
+ );
+
+ rootMessageHandler.destroy();
+});
+
+// Check that sending commands to incorrect destinations creates an error which
+// can be caught by the consumer of the RootMessageHandler.
+add_task(async function test_destination_error() {
+ const rootMessageHandler = createRootMessageHandler("session-id-error");
+
+ const fakeBrowsingContextId = -1;
+ ok(
+ !BrowsingContext.get(fakeBrowsingContextId),
+ "No browsing context matches fakeBrowsingContextId"
+ );
+
+ info("Call a valid module method, but on a non-existent browsing context id");
+ Assert.throws(
+ () =>
+ rootMessageHandler.handleCommand({
+ moduleName: "commandwindowglobalonly",
+ commandName: "testOnlyInWindowGlobal",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: fakeBrowsingContextId,
+ },
+ }),
+ err => err.message == `Unable to find a BrowsingContext for id -1`
+ );
+
+ rootMessageHandler.destroy();
+});
+
+add_task(async function test_invalid_module_error() {
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-missing_module"
+ );
+
+ info("Attempt to call a Root module which has a syntax error");
+ Assert.throws(
+ () =>
+ rootMessageHandler.handleCommand({
+ moduleName: "invalid",
+ commandName: "someMethod",
+ destination: {
+ type: RootMessageHandler.type,
+ },
+ }),
+ err =>
+ err.name === "SyntaxError" &&
+ err.message == "expected expression, got ';'"
+ );
+
+ rootMessageHandler.destroy();
+});
+
+add_task(async function test_missing_root_module_error() {
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-missing_module"
+ );
+
+ info("Attempt to call a Root module which doesn't exist");
+ Assert.throws(
+ () =>
+ rootMessageHandler.handleCommand({
+ moduleName: "missingmodule",
+ commandName: "someMethod",
+ destination: {
+ type: RootMessageHandler.type,
+ },
+ }),
+ err =>
+ err.name == "UnsupportedCommandError" &&
+ err.message ==
+ `missingmodule.someMethod not supported for destination ROOT`
+ );
+
+ rootMessageHandler.destroy();
+});
+
+add_task(async function test_missing_windowglobal_module_error() {
+ const browsingContextId = gBrowser.selectedBrowser.browsingContext.id;
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-missing_windowglobal_module"
+ );
+
+ info("Attempt to call a WindowGlobal module which doesn't exist");
+ Assert.throws(
+ () =>
+ rootMessageHandler.handleCommand({
+ moduleName: "missingmodule",
+ commandName: "someMethod",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContextId,
+ },
+ }),
+ err =>
+ err.name == "UnsupportedCommandError" &&
+ err.message ==
+ `missingmodule.someMethod not supported for destination WINDOW_GLOBAL`
+ );
+
+ rootMessageHandler.destroy();
+});
+
+add_task(async function test_missing_root_method_error() {
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-missing_root_method"
+ );
+
+ info("Attempt to call an invalid method on a Root module");
+ Assert.throws(
+ () =>
+ rootMessageHandler.handleCommand({
+ moduleName: "command",
+ commandName: "wrongMethod",
+ destination: {
+ type: RootMessageHandler.type,
+ },
+ }),
+ err =>
+ err.name == "UnsupportedCommandError" &&
+ err.message == `command.wrongMethod not supported for destination ROOT`
+ );
+
+ rootMessageHandler.destroy();
+});
+
+add_task(async function test_missing_windowglobal_method_error() {
+ const browsingContextId = gBrowser.selectedBrowser.browsingContext.id;
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-missing_windowglobal_method"
+ );
+
+ info("Attempt to call an invalid method on a WindowGlobal module");
+ Assert.throws(
+ () =>
+ rootMessageHandler.handleCommand({
+ moduleName: "commandwindowglobalonly",
+ commandName: "wrongMethod",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContextId,
+ },
+ }),
+ err =>
+ err.name == "UnsupportedCommandError" &&
+ err.message ==
+ `commandwindowglobalonly.wrongMethod not supported for destination WINDOW_GLOBAL`
+ );
+
+ rootMessageHandler.destroy();
+});
+
+/**
+ * This test checks that even if a command is rerouted to another command after
+ * the RootMessageHandler, we still check the new command and log a useful
+ * error message.
+ *
+ * This illustrates why it is important to perform the command check at each
+ * layer of the MessageHandler network.
+ */
+add_task(async function test_missing_intermediary_method_error() {
+ const browsingContextId = gBrowser.selectedBrowser.browsingContext.id;
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-missing_intermediary_method"
+ );
+
+ info(
+ "Call a (valid) command that relies on another (missing) command on a WindowGlobal module"
+ );
+ await Assert.rejects(
+ rootMessageHandler.handleCommand({
+ moduleName: "commandwindowglobalonly",
+ commandName: "testMissingIntermediaryMethod",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContextId,
+ },
+ }),
+ err =>
+ err.name == "UnsupportedCommandError" &&
+ err.message ==
+ `commandwindowglobalonly.missingMethod not supported for destination WINDOW_GLOBAL`
+ );
+
+ rootMessageHandler.destroy();
+});
diff --git a/remote/shared/messagehandler/test/browser/browser_handle_command_retry.js b/remote/shared/messagehandler/test/browser/browser_handle_command_retry.js
new file mode 100644
index 0000000000..1d020397e1
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/browser_handle_command_retry.js
@@ -0,0 +1,229 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// We are forcing the actors to shutdown while queries are unresolved.
+const { PromiseTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PromiseTestUtils.sys.mjs"
+);
+PromiseTestUtils.allowMatchingRejectionsGlobally(
+ /Actor 'MessageHandlerFrame' destroyed before query 'MessageHandlerFrameParent:sendCommand' was resolved/
+);
+
+// The tests in this file assert the retry behavior for MessageHandler commands.
+// We call "blocked" commands from resources/modules/windowglobal/retry.jsm and
+// then trigger reload and navigations to simulate AbortErrors and force the
+// MessageHandler to retry the commands, when possible.
+
+// Test that without retry behavior, a pending command rejects when the
+// underlying JSWindowActor pair is destroyed.
+add_task(async function test_no_retry() {
+ const tab = BrowserTestUtils.addTab(
+ gBrowser,
+ "https://example.com/document-builder.sjs?html=tab"
+ );
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ const browsingContext = tab.linkedBrowser.browsingContext;
+
+ const rootMessageHandler = createRootMessageHandler("session-id-no-retry");
+
+ try {
+ info("Call a module method which will throw");
+ const onBlockedOneTime = rootMessageHandler.handleCommand({
+ moduleName: "retry",
+ commandName: "blockedOneTime",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContext.id,
+ },
+ });
+
+ // Reloading the tab will reject the pending query with an AbortError.
+ await BrowserTestUtils.reloadTab(tab);
+
+ await Assert.rejects(
+ onBlockedOneTime,
+ e => e.name == "AbortError",
+ "Caught the expected abort error when reloading"
+ );
+ } finally {
+ await cleanup(rootMessageHandler, tab);
+ }
+});
+
+// Test various commands, which all need a different number of "retries" to
+// succeed. Check that they only resolve when the expected number of "retries"
+// was reached. For commands which require more "retries" than we allow, check
+// that we still fail with an AbortError once all the attempts are consumed.
+add_task(async function test_retry() {
+ const tab = BrowserTestUtils.addTab(
+ gBrowser,
+ "https://example.com/document-builder.sjs?html=tab"
+ );
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ const browsingContext = tab.linkedBrowser.browsingContext;
+
+ const rootMessageHandler = createRootMessageHandler("session-id-retry");
+
+ try {
+ // This command will return if called twice.
+ const onBlockedOneTime = rootMessageHandler.handleCommand({
+ moduleName: "retry",
+ commandName: "blockedOneTime",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContext.id,
+ },
+ params: {
+ foo: "bar",
+ },
+ retryOnAbort: true,
+ });
+
+ // This command will return if called three times.
+ const onBlockedTenTimes = rootMessageHandler.handleCommand({
+ moduleName: "retry",
+ commandName: "blockedTenTimes",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContext.id,
+ },
+ params: {
+ foo: "baz",
+ },
+ retryOnAbort: true,
+ });
+
+ // This command will return if called twelve times, which is greater than the
+ // maximum amount of retries allowed.
+ const onBlockedElevenTimes = rootMessageHandler.handleCommand({
+ moduleName: "retry",
+ commandName: "blockedElevenTimes",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContext.id,
+ },
+ retryOnAbort: true,
+ });
+
+ info("Reload one time");
+ await BrowserTestUtils.reloadTab(tab);
+
+ info("blockedOneTime should resolve on the first retry");
+ let { callsToCommand, foo } = await onBlockedOneTime;
+ is(
+ callsToCommand,
+ 2,
+ "The command was called twice (initial call + 1 retry)"
+ );
+ is(foo, "bar", "The parameter was sent when the command was retried");
+
+ // We already reloaded 1 time. Reload 9 more times to unblock blockedTenTimes.
+ for (let i = 2; i < 11; i++) {
+ info("blockedTenTimes/blockedElevenTimes should not have resolved yet");
+ ok(!(await hasPromiseResolved(onBlockedTenTimes)));
+ ok(!(await hasPromiseResolved(onBlockedElevenTimes)));
+
+ info(`Reload the tab (time: ${i})`);
+ await BrowserTestUtils.reloadTab(tab);
+ }
+
+ info("blockedTenTimes should resolve on the 10th reload");
+ ({ callsToCommand, foo } = await onBlockedTenTimes);
+ is(
+ callsToCommand,
+ 11,
+ "The command was called 11 times (initial call + 10 retry)"
+ );
+ is(foo, "baz", "The parameter was sent when the command was retried");
+
+ info("Reload one more time");
+ await BrowserTestUtils.reloadTab(tab);
+
+ info(
+ "The call to blockedElevenTimes now exceeds the maximum attempts allowed"
+ );
+ await Assert.rejects(
+ onBlockedElevenTimes,
+ e => e.name == "AbortError",
+ "Caught the expected abort error when reloading"
+ );
+ } finally {
+ await cleanup(rootMessageHandler, tab);
+ }
+});
+
+// Test cross-group navigations to check that the retry mechanism will
+// transparently switch to the new Browsing Context created by the cross-group
+// navigation.
+add_task(async function test_retry_cross_group() {
+ const tab = BrowserTestUtils.addTab(
+ gBrowser,
+ "https://example.com/document-builder.sjs?html=COM" +
+ // Attach an unload listener to prevent the page from going into bfcache,
+ // so that pending queries will be rejected with an AbortError.
+ "<script type='text/javascript'>window.onunload = function() {};</script>"
+ );
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ const browsingContext = tab.linkedBrowser.browsingContext;
+
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-retry-cross-group"
+ );
+
+ try {
+ // This command hangs and only returns if the current domain is example.net.
+ // We send the command while on example.com, perform a series of reload and
+ // navigations, and the retry mechanism should allow onBlockedOnNetDomain to
+ // resolve.
+ const onBlockedOnNetDomain = rootMessageHandler.handleCommand({
+ moduleName: "retry",
+ commandName: "blockedOnNetDomain",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContext.id,
+ },
+ params: {
+ foo: "bar",
+ },
+ retryOnAbort: true,
+ });
+
+ info("Reload one time");
+ await BrowserTestUtils.reloadTab(tab);
+
+ info("blockedOnNetDomain should not have resolved yet");
+ ok(!(await hasPromiseResolved(onBlockedOnNetDomain)));
+
+ info(
+ "Navigate to example.net with COOP headers to destroy browsing context"
+ );
+ await loadURL(
+ tab.linkedBrowser,
+ "https://example.net/document-builder.sjs?headers=Cross-Origin-Opener-Policy:same-origin&html=NET"
+ );
+
+ info("blockedOnNetDomain should resolve now");
+ let { foo } = await onBlockedOnNetDomain;
+ is(foo, "bar", "The parameter was sent when the command was retried");
+ } finally {
+ await cleanup(rootMessageHandler, tab);
+ }
+});
+
+async function cleanup(rootMessageHandler, tab) {
+ const browsingContext = tab.linkedBrowser.browsingContext;
+ // Cleanup global JSM state in the test module.
+ await rootMessageHandler.handleCommand({
+ moduleName: "retry",
+ commandName: "cleanup",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContext.id,
+ },
+ });
+
+ rootMessageHandler.destroy();
+ gBrowser.removeTab(tab);
+}
diff --git a/remote/shared/messagehandler/test/browser/browser_handle_simple_command.js b/remote/shared/messagehandler/test/browser/browser_handle_simple_command.js
new file mode 100644
index 0000000000..0a086d6f09
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/browser_handle_simple_command.js
@@ -0,0 +1,203 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { RootMessageHandler } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs"
+);
+
+// Test calling methods only implemented in the root version of a module.
+add_task(async function test_rootModule_command() {
+ const rootMessageHandler = createRootMessageHandler("session-id-rootModule");
+ const rootValue = await rootMessageHandler.handleCommand({
+ moduleName: "command",
+ commandName: "testRootModule",
+ destination: {
+ type: RootMessageHandler.type,
+ },
+ });
+
+ is(
+ rootValue,
+ "root-value",
+ "Retrieved the expected value from testRootModule"
+ );
+
+ rootMessageHandler.destroy();
+});
+
+// Test calling methods only implemented in the windowglobal-in-root version of
+// a module.
+add_task(async function test_windowglobalInRootModule_command() {
+ const browsingContextId = gBrowser.selectedBrowser.browsingContext.id;
+
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-windowglobalInRootModule"
+ );
+ const interceptedValue = await rootMessageHandler.handleCommand({
+ moduleName: "command",
+ commandName: "testInterceptModule",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContextId,
+ },
+ });
+
+ is(
+ interceptedValue,
+ "intercepted-value",
+ "Retrieved the expected value from testInterceptModule"
+ );
+
+ rootMessageHandler.destroy();
+});
+
+// Test calling methods only implemented in the windowglobal version of a
+// module.
+add_task(async function test_windowglobalModule_command() {
+ const browsingContextId = gBrowser.selectedBrowser.browsingContext.id;
+
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-windowglobalModule"
+ );
+ const windowGlobalValue = await rootMessageHandler.handleCommand({
+ moduleName: "command",
+ commandName: "testWindowGlobalModule",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContextId,
+ },
+ });
+
+ is(
+ windowGlobalValue,
+ "windowglobal-value",
+ "Retrieved the expected value from testWindowGlobalModule"
+ );
+
+ rootMessageHandler.destroy();
+});
+
+// Test calling a method on a module which is only available in the "windowglobal"
+// folder. This will check that the MessageHandler/ModuleCache correctly moves
+// on to the next layer when no implementation can be found in the root layer.
+add_task(async function test_windowglobalOnlyModule_command() {
+ const browsingContextId = gBrowser.selectedBrowser.browsingContext.id;
+
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-windowglobalOnlyModule"
+ );
+ const windowGlobalOnlyValue = await rootMessageHandler.handleCommand({
+ moduleName: "commandwindowglobalonly",
+ commandName: "testOnlyInWindowGlobal",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContextId,
+ },
+ });
+
+ is(
+ windowGlobalOnlyValue,
+ "only-in-windowglobal",
+ "Retrieved the expected value from testOnlyInWindowGlobal"
+ );
+
+ rootMessageHandler.destroy();
+});
+
+// Try to create 2 sessions which will both set values in individual modules
+// via a command `testSetValue`, and then retrieve the values via another
+// command `testGetValue`.
+// This will ensure that different sessions use different module instances.
+add_task(async function test_multisession() {
+ const browsingContextId = gBrowser.selectedBrowser.browsingContext.id;
+
+ const rootMessageHandler1 = createRootMessageHandler(
+ "session-id-multisession-1"
+ );
+ const rootMessageHandler2 = createRootMessageHandler(
+ "session-id-multisession-2"
+ );
+
+ info("Set value for session 1");
+ await rootMessageHandler1.handleCommand({
+ moduleName: "command",
+ commandName: "testSetValue",
+ params: { value: "session1-value" },
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContextId,
+ },
+ });
+
+ info("Set value for session 2");
+ await rootMessageHandler2.handleCommand({
+ moduleName: "command",
+ commandName: "testSetValue",
+ params: { value: "session2-value" },
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContextId,
+ },
+ });
+
+ const session1Value = await rootMessageHandler1.handleCommand({
+ moduleName: "command",
+ commandName: "testGetValue",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContextId,
+ },
+ });
+
+ is(
+ session1Value,
+ "session1-value",
+ "Retrieved the expected value for session 1"
+ );
+
+ const session2Value = await rootMessageHandler2.handleCommand({
+ moduleName: "command",
+ commandName: "testGetValue",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContextId,
+ },
+ });
+
+ is(
+ session2Value,
+ "session2-value",
+ "Retrieved the expected value for session 2"
+ );
+
+ rootMessageHandler1.destroy();
+ rootMessageHandler2.destroy();
+});
+
+// Test calling a method from the windowglobal-in-root module which will
+// internally forward to the windowglobal module and will return a composite
+// result built both in parent and content process.
+add_task(async function test_forwarding_command() {
+ const browsingContextId = gBrowser.selectedBrowser.browsingContext.id;
+
+ const rootMessageHandler = createRootMessageHandler("session-id-forwarding");
+ const interceptAndForwardValue = await rootMessageHandler.handleCommand({
+ moduleName: "command",
+ commandName: "testInterceptAndForwardModule",
+ params: { id: "value" },
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContextId,
+ },
+ });
+
+ is(
+ interceptAndForwardValue,
+ "intercepted-and-forward+forward-to-windowglobal-value",
+ "Retrieved the expected value from testInterceptAndForwardModule"
+ );
+
+ rootMessageHandler.destroy();
+});
diff --git a/remote/shared/messagehandler/test/browser/browser_navigation_manager.js b/remote/shared/messagehandler/test/browser/browser_navigation_manager.js
new file mode 100644
index 0000000000..474605e90f
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/browser_navigation_manager.js
@@ -0,0 +1,59 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { MessageHandlerRegistry } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/MessageHandlerRegistry.sys.mjs"
+);
+const { RootMessageHandler } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs"
+);
+
+// Check that a functional navigation manager is available on the
+// RootMessageHandler.
+add_task(async function test_navigationManager() {
+ const sessionId = "navigationManager-test";
+ const type = RootMessageHandler.type;
+
+ const rootMessageHandlerRegistry = new MessageHandlerRegistry(type);
+
+ const rootMessageHandler =
+ rootMessageHandlerRegistry.getOrCreateMessageHandler(sessionId);
+
+ const navigationManager = rootMessageHandler.navigationManager;
+ ok(!!navigationManager, "ROOT MessageHandler provides a navigation manager");
+
+ const events = [];
+ const onEvent = (name, data) => events.push({ name, data });
+ navigationManager.on("navigation-started", onEvent);
+ navigationManager.on("navigation-stopped", onEvent);
+
+ info("Check the navigation manager monitors navigations");
+
+ const testUrl = "https://example.com/document-builder.sjs?html=test";
+ const tab1 = BrowserTestUtils.addTab(gBrowser, testUrl);
+ const contentBrowser1 = tab1.linkedBrowser;
+ await BrowserTestUtils.browserLoaded(contentBrowser1);
+
+ const navigation = navigationManager.getNavigationForBrowsingContext(
+ contentBrowser1.browsingContext
+ );
+ is(navigation.url, testUrl, "Navigation has the expected URL");
+
+ is(events.length, 2, "Received 2 navigation events");
+ is(events[0].name, "navigation-started");
+ is(events[1].name, "navigation-stopped");
+
+ info(
+ "Check the navigation manager is destroyed after destroying the message handler"
+ );
+ rootMessageHandler.destroy();
+ const otherUrl = "https://example.com/document-builder.sjs?html=other";
+ const tab2 = BrowserTestUtils.addTab(gBrowser, otherUrl);
+ await BrowserTestUtils.browserLoaded(tab2.linkedBrowser);
+ is(events.length, 2, "No new navigation event received");
+
+ gBrowser.removeTab(tab1);
+ gBrowser.removeTab(tab2);
+});
diff --git a/remote/shared/messagehandler/test/browser/browser_realms.js b/remote/shared/messagehandler/test/browser/browser_realms.js
new file mode 100644
index 0000000000..815bfbbe85
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/browser_realms.js
@@ -0,0 +1,152 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { RootMessageHandler } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs"
+);
+
+add_task(async function test_tab_is_removed() {
+ const tab = await addTab("https://example.com/document-builder.sjs?html=tab");
+ const sessionId = "realms";
+ const browsingContext = tab.linkedBrowser.browsingContext;
+ const contextDescriptor = {
+ type: ContextDescriptorType.TopBrowsingContext,
+ id: browsingContext.browserId,
+ };
+
+ const rootMessageHandler = createRootMessageHandler(sessionId);
+
+ const onRealmCreated = rootMessageHandler.once("realm-created");
+
+ // Add a new session data item to get window global handlers created
+ await rootMessageHandler.addSessionDataItem({
+ moduleName: "command",
+ category: "browser_realms",
+ contextDescriptor,
+ values: [true],
+ });
+
+ const realmCreatedEvent = await onRealmCreated;
+ const createdRealmId = realmCreatedEvent.realmInfo.realm;
+
+ is(rootMessageHandler.realms.size, 1, "Realm is added in the internal map");
+
+ const onRealmDestroyed = rootMessageHandler.once("realm-destroyed");
+
+ gBrowser.removeTab(tab);
+
+ const realmDestroyedEvent = await onRealmDestroyed;
+
+ is(
+ realmDestroyedEvent.realm,
+ createdRealmId,
+ "Received a correct realm id in realm-destroyed event"
+ );
+ is(rootMessageHandler.realms.size, 0, "The realm map is cleaned up");
+
+ rootMessageHandler.destroy();
+});
+
+add_task(async function test_same_origin_navigation() {
+ const tab = await addTab("https://example.com/document-builder.sjs?html=tab");
+ const sessionId = "realms";
+ const browsingContext = tab.linkedBrowser.browsingContext;
+ const contextDescriptor = {
+ type: ContextDescriptorType.TopBrowsingContext,
+ id: browsingContext.browserId,
+ };
+
+ const rootMessageHandler = createRootMessageHandler(sessionId);
+
+ const onRealmCreated = rootMessageHandler.once("realm-created");
+
+ // Add a new session data item to get window global handlers created
+ await rootMessageHandler.addSessionDataItem({
+ moduleName: "command",
+ category: "browser_realms",
+ contextDescriptor,
+ values: [true],
+ });
+
+ const realmCreatedEvent = await onRealmCreated;
+ const createdRealmId = realmCreatedEvent.realmInfo.realm;
+
+ is(rootMessageHandler.realms.size, 1, "Realm is added in the internal map");
+
+ const onRealmDestroyed = rootMessageHandler.once("realm-destroyed");
+ const onNewRealmCreated = rootMessageHandler.once("realm-created");
+
+ // Navigate to another page with the same origin
+ await loadURL(
+ tab.linkedBrowser,
+ "https://example.com/document-builder.sjs?html=othertab"
+ );
+
+ const realmDestroyedEvent = await onRealmDestroyed;
+
+ is(
+ realmDestroyedEvent.realm,
+ createdRealmId,
+ "Received a correct realm id in realm-destroyed event"
+ );
+
+ await onNewRealmCreated;
+
+ is(rootMessageHandler.realms.size, 1, "Realm is added in the internal map");
+
+ gBrowser.removeTab(tab);
+ rootMessageHandler.destroy();
+});
+
+add_task(async function test_cross_origin_navigation() {
+ const tab = await addTab("https://example.com/document-builder.sjs?html=tab");
+ const sessionId = "realms";
+ const browsingContext = tab.linkedBrowser.browsingContext;
+ const contextDescriptor = {
+ type: ContextDescriptorType.TopBrowsingContext,
+ id: browsingContext.browserId,
+ };
+
+ const rootMessageHandler = createRootMessageHandler(sessionId);
+
+ const onRealmCreated = rootMessageHandler.once("realm-created");
+
+ // Add a new session data item to get window global handlers created
+ await rootMessageHandler.addSessionDataItem({
+ moduleName: "command",
+ category: "browser_realms",
+ contextDescriptor,
+ values: [true],
+ });
+
+ const realmCreatedEvent = await onRealmCreated;
+ const createdRealmId = realmCreatedEvent.realmInfo.realm;
+
+ is(rootMessageHandler.realms.size, 1, "Realm is added in the internal map");
+
+ const onRealmDestroyed = rootMessageHandler.once("realm-destroyed");
+ const onNewRealmCreated = rootMessageHandler.once("realm-created");
+
+ // Navigate to another page with the different origin
+ await loadURL(
+ tab.linkedBrowser,
+ "https://example.com/document-builder.sjs?html=otherorigin"
+ );
+
+ const realmDestroyedEvent = await onRealmDestroyed;
+
+ is(
+ realmDestroyedEvent.realm,
+ createdRealmId,
+ "Received a correct realm id in realm-destroyed event"
+ );
+
+ await onNewRealmCreated;
+
+ is(rootMessageHandler.realms.size, 1, "Realm is added in the internal map");
+
+ gBrowser.removeTab(tab);
+ rootMessageHandler.destroy();
+});
diff --git a/remote/shared/messagehandler/test/browser/browser_registry.js b/remote/shared/messagehandler/test/browser/browser_registry.js
new file mode 100644
index 0000000000..945ac06c19
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/browser_registry.js
@@ -0,0 +1,37 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { MessageHandlerRegistry } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/MessageHandlerRegistry.sys.mjs"
+);
+const { RootMessageHandler } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs"
+);
+
+add_task(async function test_messageHandlerRegistry_API() {
+ const sessionId = 1;
+ const type = RootMessageHandler.type;
+
+ const rootMessageHandlerRegistry = new MessageHandlerRegistry(type);
+
+ const rootMessageHandler =
+ rootMessageHandlerRegistry.getOrCreateMessageHandler(sessionId);
+ ok(rootMessageHandler, "Valid ROOT MessageHandler created");
+
+ const contextId = rootMessageHandler.contextId;
+ ok(contextId, "ROOT MessageHandler has a valid contextId");
+
+ is(
+ rootMessageHandler,
+ rootMessageHandlerRegistry.getExistingMessageHandler(sessionId),
+ "ROOT MessageHandler can be retrieved from the registry"
+ );
+
+ rootMessageHandler.destroy();
+ ok(
+ !rootMessageHandlerRegistry.getExistingMessageHandler(sessionId),
+ "Destroyed ROOT MessageHandler is no longer returned by the Registry"
+ );
+});
diff --git a/remote/shared/messagehandler/test/browser/browser_session_data.js b/remote/shared/messagehandler/test/browser/browser_session_data.js
new file mode 100644
index 0000000000..591073feb6
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/browser_session_data.js
@@ -0,0 +1,273 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { MessageHandlerRegistry } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/MessageHandlerRegistry.sys.mjs"
+);
+const { RootMessageHandler } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs"
+);
+const { SessionData } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/sessiondata/SessionData.sys.mjs"
+);
+
+const TEST_PAGE = "http://example.com/document-builder.sjs?html=tab";
+
+add_task(async function test_sessionData() {
+ info("Navigate the initial tab to the test URL");
+ const tab1 = gBrowser.selectedTab;
+ await loadURL(tab1.linkedBrowser, TEST_PAGE);
+
+ const sessionId = "sessionData-test";
+
+ const rootMessageHandlerRegistry = new MessageHandlerRegistry(
+ RootMessageHandler.type
+ );
+
+ const rootMessageHandler =
+ rootMessageHandlerRegistry.getOrCreateMessageHandler(sessionId);
+ ok(rootMessageHandler, "Valid ROOT MessageHandler created");
+
+ const sessionData = rootMessageHandler.sessionData;
+ ok(
+ sessionData instanceof SessionData,
+ "ROOT MessageHandler has a valid sessionData"
+ );
+
+ let sessionDataSnapshot = await getSessionDataFromContent();
+ is(sessionDataSnapshot.size, 0, "session data is empty");
+
+ info("Store a string value in session data");
+ sessionData.updateSessionData([
+ {
+ method: "add",
+ moduleName: "fakemodule",
+ category: "testCategory",
+ contextDescriptor: contextDescriptorAll,
+ values: ["value-1"],
+ },
+ ]);
+
+ sessionDataSnapshot = await getSessionDataFromContent();
+ is(sessionDataSnapshot.size, 1, "session data contains 1 session");
+ ok(sessionDataSnapshot.has(sessionId));
+ let snapshot = sessionDataSnapshot.get(sessionId);
+ ok(Array.isArray(snapshot));
+ is(snapshot.length, 1);
+
+ const stringDataItem = snapshot[0];
+ checkSessionDataItem(
+ stringDataItem,
+ "fakemodule",
+ "testCategory",
+ ContextDescriptorType.All,
+ "value-1"
+ );
+
+ info("Store a number value in session data");
+ sessionData.updateSessionData([
+ {
+ method: "add",
+ moduleName: "fakemodule",
+ category: "testCategory",
+ contextDescriptor: contextDescriptorAll,
+ values: [12],
+ },
+ ]);
+ snapshot = (await getSessionDataFromContent()).get(sessionId);
+ is(snapshot.length, 2);
+
+ const numberDataItem = snapshot[1];
+ checkSessionDataItem(
+ numberDataItem,
+ "fakemodule",
+ "testCategory",
+ ContextDescriptorType.All,
+ 12
+ );
+
+ info("Store a boolean value in session data");
+ sessionData.updateSessionData([
+ {
+ method: "add",
+ moduleName: "fakemodule",
+ category: "testCategory",
+ contextDescriptor: contextDescriptorAll,
+ values: [true],
+ },
+ ]);
+ snapshot = (await getSessionDataFromContent()).get(sessionId);
+ is(snapshot.length, 3);
+
+ const boolDataItem = snapshot[2];
+ checkSessionDataItem(
+ boolDataItem,
+ "fakemodule",
+ "testCategory",
+ ContextDescriptorType.All,
+ true
+ );
+
+ info("Remove one value");
+ sessionData.updateSessionData([
+ {
+ method: "remove",
+ moduleName: "fakemodule",
+ category: "testCategory",
+ contextDescriptor: contextDescriptorAll,
+ values: [12],
+ },
+ ]);
+ snapshot = (await getSessionDataFromContent()).get(sessionId);
+ is(snapshot.length, 2);
+ checkSessionDataItem(
+ snapshot[0],
+ "fakemodule",
+ "testCategory",
+ ContextDescriptorType.All,
+ "value-1"
+ );
+ checkSessionDataItem(
+ snapshot[1],
+ "fakemodule",
+ "testCategory",
+ ContextDescriptorType.All,
+ true
+ );
+
+ info("Remove all values");
+ sessionData.updateSessionData([
+ {
+ method: "remove",
+ moduleName: "fakemodule",
+ category: "testCategory",
+ contextDescriptor: contextDescriptorAll,
+ values: ["value-1", true],
+ },
+ ]);
+ snapshot = (await getSessionDataFromContent()).get(sessionId);
+ is(snapshot.length, 0, "Session data is now empty");
+
+ info("Add another value before destroy");
+ sessionData.updateSessionData([
+ {
+ method: "add",
+ moduleName: "fakemodule",
+ category: "testCategory",
+ contextDescriptor: contextDescriptorAll,
+ values: ["value-2"],
+ },
+ ]);
+ snapshot = (await getSessionDataFromContent()).get(sessionId);
+ is(snapshot.length, 1);
+ checkSessionDataItem(
+ snapshot[0],
+ "fakemodule",
+ "testCategory",
+ ContextDescriptorType.All,
+ "value-2"
+ );
+
+ sessionData.destroy();
+ sessionDataSnapshot = await getSessionDataFromContent();
+ is(sessionDataSnapshot.size, 0, "session data should be empty again");
+});
+
+add_task(async function test_sessionDataRootOnlyModule() {
+ const sessionId = "sessionData-test-rootOnly";
+
+ const rootMessageHandler = createRootMessageHandler(sessionId);
+ ok(rootMessageHandler, "Valid ROOT MessageHandler created");
+
+ BrowserTestUtils.startLoadingURIString(
+ gBrowser.selectedBrowser,
+ "https://example.com/document-builder.sjs?html=tab"
+ );
+
+ const windowGlobalCreated = rootMessageHandler.once(
+ "window-global-handler-created"
+ );
+
+ info("Test that adding SessionData items works the root module");
+ // Updating the session data on the root message handler should not cause
+ // failures for other message handlers if the module only exists for root.
+ await rootMessageHandler.addSessionDataItem({
+ moduleName: "rootOnly",
+ category: "session_data_root_only",
+ contextDescriptor: {
+ type: ContextDescriptorType.All,
+ },
+ values: [true],
+ });
+
+ await windowGlobalCreated;
+ ok(true, "Window global has been initialized");
+
+ let sessionDataReceivedByRoot = await rootMessageHandler.handleCommand({
+ moduleName: "rootOnly",
+ commandName: "getSessionDataReceived",
+ destination: {
+ type: RootMessageHandler.type,
+ },
+ });
+
+ is(sessionDataReceivedByRoot.length, 1);
+ is(sessionDataReceivedByRoot[0].category, "session_data_root_only");
+ is(sessionDataReceivedByRoot[0].added.length, 1);
+ is(sessionDataReceivedByRoot[0].added[0], true);
+ is(
+ sessionDataReceivedByRoot[0].contextDescriptor.type,
+ ContextDescriptorType.All
+ );
+
+ info("Now test that removing items also works on the root module");
+ await rootMessageHandler.removeSessionDataItem({
+ moduleName: "rootOnly",
+ category: "session_data_root_only",
+ contextDescriptor: {
+ type: ContextDescriptorType.All,
+ },
+ values: [true],
+ });
+
+ sessionDataReceivedByRoot = await rootMessageHandler.handleCommand({
+ moduleName: "rootOnly",
+ commandName: "getSessionDataReceived",
+ destination: {
+ type: RootMessageHandler.type,
+ },
+ });
+
+ is(sessionDataReceivedByRoot.length, 2);
+ is(sessionDataReceivedByRoot[1].category, "session_data_root_only");
+ is(sessionDataReceivedByRoot[1].removed.length, 1);
+ is(sessionDataReceivedByRoot[1].removed[0], true);
+ is(
+ sessionDataReceivedByRoot[1].contextDescriptor.type,
+ ContextDescriptorType.All
+ );
+
+ rootMessageHandler.destroy();
+});
+
+function checkSessionDataItem(item, moduleName, category, contextType, value) {
+ is(item.moduleName, moduleName, "Data item has the expected module name");
+ is(item.category, category, "Data item has the expected category");
+ is(
+ item.contextDescriptor.type,
+ contextType,
+ "Data item has the expected context type"
+ );
+ is(item.value, value, "Data item has the expected value");
+}
+
+function getSessionDataFromContent() {
+ return SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
+ const { readSessionData } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/sessiondata/SessionDataReader.sys.mjs"
+ );
+ return readSessionData();
+ });
+}
diff --git a/remote/shared/messagehandler/test/browser/browser_session_data_browser_element.js b/remote/shared/messagehandler/test/browser/browser_session_data_browser_element.js
new file mode 100644
index 0000000000..9c15974ae6
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/browser_session_data_browser_element.js
@@ -0,0 +1,94 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_PAGE = "https://example.com/document-builder.sjs?html=tab";
+
+/**
+ * Check that message handlers are not created for parent process browser
+ * elements, even if they have the type="content" attribute (eg used for the
+ * DevTools toolbox), as well as for webextension contexts.
+ */
+add_task(async function test_session_data_broadcast() {
+ // Prepare:
+ // - one content tab
+ // - one browser type content
+ // - one browser type chrome
+ // - one sidebar webextension
+ // We only expect session data to be applied to the content tab
+ const tab1 = BrowserTestUtils.addTab(gBrowser, TEST_PAGE);
+ const contentBrowser1 = tab1.linkedBrowser;
+ await BrowserTestUtils.browserLoaded(contentBrowser1);
+ const parentBrowser1 = createParentBrowserElement(tab1, "content");
+ const parentBrowser2 = createParentBrowserElement(tab1, "chrome");
+ const { extension: extension1, sidebarBrowser: extSidebarBrowser1 } =
+ await installSidebarExtension();
+
+ const root = createRootMessageHandler("session-id-event");
+
+ // When the windowglobal command.jsm module applies the session data
+ // browser_session_data_browser_element, it will emit an event.
+ // Collect the events to detect which MessageHandlers have been started.
+ info("Watch events emitted when session data is applied");
+ const sessionDataEvents = [];
+ const onRootEvent = function (evtName, wrappedEvt) {
+ if (wrappedEvt.name === "received-session-data") {
+ sessionDataEvents.push(wrappedEvt.data.contextId);
+ }
+ };
+ root.on("message-handler-event", onRootEvent);
+
+ info("Add a new session data item, expect one return value");
+ await root.addSessionDataItem({
+ moduleName: "command",
+ category: "browser_session_data_browser_element",
+ contextDescriptor: {
+ type: ContextDescriptorType.All,
+ },
+ values: [true],
+ });
+
+ function hasSessionData(browsingContext) {
+ return sessionDataEvents.includes(browsingContext.id);
+ }
+
+ info(
+ "Check that only the content tab window global received the session data"
+ );
+ is(hasSessionData(contentBrowser1.browsingContext), true);
+ is(hasSessionData(parentBrowser1.browsingContext), false);
+ is(hasSessionData(parentBrowser2.browsingContext), false);
+ is(hasSessionData(extSidebarBrowser1.browsingContext), false);
+
+ const tab2 = BrowserTestUtils.addTab(gBrowser, TEST_PAGE);
+ const contentBrowser2 = tab2.linkedBrowser;
+ await BrowserTestUtils.browserLoaded(contentBrowser2);
+ const parentBrowser3 = createParentBrowserElement(contentBrowser2, "content");
+ const parentBrowser4 = createParentBrowserElement(contentBrowser2, "chrome");
+
+ const { extension: extension2, sidebarBrowser: extSidebarBrowser2 } =
+ await installSidebarExtension();
+
+ info("Wait until the session data was applied to the new tab");
+ await TestUtils.waitForCondition(() =>
+ sessionDataEvents.includes(contentBrowser2.browsingContext.id)
+ );
+
+ info("Check that parent browser elements did not apply the session data");
+ is(hasSessionData(parentBrowser3.browsingContext), false);
+ is(hasSessionData(parentBrowser4.browsingContext), false);
+
+ info(
+ "Check that extension did not apply the session data, " +
+ extSidebarBrowser2.browsingContext.id
+ );
+ is(hasSessionData(extSidebarBrowser2.browsingContext), false);
+
+ root.destroy();
+
+ gBrowser.removeTab(tab1);
+ gBrowser.removeTab(tab2);
+ await extension1.unload();
+ await extension2.unload();
+});
diff --git a/remote/shared/messagehandler/test/browser/browser_session_data_constructor_race.js b/remote/shared/messagehandler/test/browser/browser_session_data_constructor_race.js
new file mode 100644
index 0000000000..03ed59166f
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/browser_session_data_constructor_race.js
@@ -0,0 +1,50 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_PAGE = "https://example.com/document-builder.sjs?html=tab";
+
+/**
+ * Check that modules created early for session data are still created with a
+ * fully initialized MessageHandler. See Bug 1743083.
+ */
+add_task(async function () {
+ const tab = BrowserTestUtils.addTab(gBrowser, TEST_PAGE);
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ const browsingContext = tab.linkedBrowser.browsingContext;
+
+ const root = createRootMessageHandler("session-id-event");
+
+ info("Add some session data for the command module");
+ await root.addSessionDataItem({
+ moduleName: "command",
+ category: "testCategory",
+ contextDescriptor: contextDescriptorAll,
+ values: ["some-value"],
+ });
+
+ info("Reload the current tab to create new message handlers and modules");
+ await BrowserTestUtils.reloadTab(tab);
+
+ info(
+ "Check if the command module was created by the MessageHandler constructor"
+ );
+ const isCreatedByMessageHandlerConstructor = await root.handleCommand({
+ moduleName: "command",
+ commandName: "testIsCreatedByMessageHandlerConstructor",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContext.id,
+ },
+ });
+
+ is(
+ isCreatedByMessageHandlerConstructor,
+ false,
+ "The command module from session data should not be created by the MessageHandler constructor"
+ );
+ root.destroy();
+
+ gBrowser.removeTab(tab);
+});
diff --git a/remote/shared/messagehandler/test/browser/browser_session_data_update.js b/remote/shared/messagehandler/test/browser/browser_session_data_update.js
new file mode 100644
index 0000000000..342a4a6139
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/browser_session_data_update.js
@@ -0,0 +1,113 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_PAGE = "https://example.com/document-builder.sjs?html=tab";
+
+const { assertUpdate, createSessionDataUpdate, getUpdates } =
+ SessionDataUpdateHelpers;
+
+// Test various session data update scenarios against a single browsing context.
+add_task(async function test_session_data_update() {
+ const tab1 = gBrowser.selectedTab;
+ await loadURL(tab1.linkedBrowser, TEST_PAGE);
+ const browsingContext1 = tab1.linkedBrowser.browsingContext;
+
+ const root = createRootMessageHandler("session-data-update");
+
+ info("Add a new session data item, expect one return value");
+ await root.updateSessionData([
+ createSessionDataUpdate(["text-1"], "add", "category1"),
+ ]);
+ let processedUpdates = await getUpdates(root, browsingContext1);
+ is(processedUpdates.length, 1);
+ assertUpdate(processedUpdates.at(-1), ["text-1"], "category1");
+
+ info("Add two session data items, expect one return value with both items");
+ await root.updateSessionData([
+ createSessionDataUpdate(["text-2"], "add", "category1"),
+ createSessionDataUpdate(["text-3"], "add", "category1"),
+ ]);
+ processedUpdates = await getUpdates(root, browsingContext1);
+ is(processedUpdates.length, 2);
+ assertUpdate(
+ processedUpdates.at(-1),
+ ["text-1", "text-2", "text-3"],
+ "category1"
+ );
+
+ info("Try to add an existing data item, expect no update broadcast");
+ await root.updateSessionData([
+ createSessionDataUpdate(["text-1"], "add", "category1"),
+ ]);
+ processedUpdates = await getUpdates(root, browsingContext1);
+ is(processedUpdates.length, 2);
+
+ info("Add an existing and a new item");
+ await root.updateSessionData([
+ createSessionDataUpdate(["text-2", "text-4"], "add", "category1"),
+ ]);
+ processedUpdates = await getUpdates(root, browsingContext1);
+ is(processedUpdates.length, 3);
+ assertUpdate(
+ processedUpdates.at(-1),
+ ["text-1", "text-2", "text-3", "text-4"],
+ "category1"
+ );
+
+ info("Remove an item, expect only the new item to return");
+ await root.updateSessionData([
+ createSessionDataUpdate(["text-3"], "remove", "category1"),
+ ]);
+ processedUpdates = await getUpdates(root, browsingContext1);
+ is(processedUpdates.length, 4);
+ assertUpdate(
+ processedUpdates.at(-1),
+ ["text-1", "text-2", "text-4"],
+ "category1"
+ );
+
+ info("Remove a unknown item, expect no return value");
+ await root.updateSessionData([
+ createSessionDataUpdate(["text-unknown"], "remove", "category1"),
+ ]);
+ processedUpdates = await getUpdates(root, browsingContext1);
+ is(processedUpdates.length, 4);
+ assertUpdate(
+ processedUpdates.at(-1),
+ ["text-1", "text-2", "text-4"],
+ "category1"
+ );
+
+ info("Remove an existing and a unknown item");
+ await root.updateSessionData([
+ createSessionDataUpdate(["text-2"], "remove", "category1"),
+ createSessionDataUpdate(["text-unknown"], "remove", "category1"),
+ ]);
+ processedUpdates = await getUpdates(root, browsingContext1);
+ is(processedUpdates.length, 5);
+ assertUpdate(processedUpdates.at(-1), ["text-1", "text-4"], "category1");
+
+ info("Add and remove at once");
+ await root.updateSessionData([
+ createSessionDataUpdate(["text-5"], "add", "category1"),
+ createSessionDataUpdate(["text-4"], "remove", "category1"),
+ ]);
+ processedUpdates = await getUpdates(root, browsingContext1);
+ is(processedUpdates.length, 6);
+ assertUpdate(processedUpdates.at(-1), ["text-1", "text-5"], "category1");
+
+ info("Adding and removing an item does not trigger any update");
+ await root.updateSessionData([
+ createSessionDataUpdate(["text-6"], "add", "category1"),
+ createSessionDataUpdate(["text-6"], "remove", "category1"),
+ ]);
+ processedUpdates = await getUpdates(root, browsingContext1);
+ // TODO: We could detect transactions which can't have any impact and fully
+ // ignore them. See Bug 1810807.
+ todo_is(processedUpdates.length, 6);
+ assertUpdate(processedUpdates.at(-1), ["text-1", "text-5"], "category1");
+
+ root.destroy();
+});
diff --git a/remote/shared/messagehandler/test/browser/browser_session_data_update_categories.js b/remote/shared/messagehandler/test/browser/browser_session_data_update_categories.js
new file mode 100644
index 0000000000..b1cadcf095
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/browser_session_data_update_categories.js
@@ -0,0 +1,91 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_PAGE = "https://example.com/document-builder.sjs?html=tab";
+
+const { assertUpdate, createSessionDataUpdate, getUpdates } =
+ SessionDataUpdateHelpers;
+
+// Test session data update scenarios involving different session data item
+// categories.
+add_task(async function test_session_data_update_categories() {
+ const tab1 = gBrowser.selectedTab;
+ await loadURL(tab1.linkedBrowser, TEST_PAGE);
+ const browsingContext1 = tab1.linkedBrowser.browsingContext;
+
+ const root = createRootMessageHandler("session-data-update-categories");
+ await root.updateSessionData([
+ createSessionDataUpdate(["value1-1"], "add", "category1"),
+ createSessionDataUpdate(["value1-2"], "add", "category1"),
+ ]);
+
+ let processedUpdates = await getUpdates(root, browsingContext1);
+
+ is(processedUpdates.length, 1);
+ assertUpdate(processedUpdates.at(-1), ["value1-1", "value1-2"], "category1");
+
+ info("Adding a new item in category1 broadcasts all category1 items");
+ await root.updateSessionData([
+ createSessionDataUpdate(["value1-3"], "add", "category1"),
+ ]);
+ processedUpdates = await getUpdates(root, browsingContext1);
+ is(processedUpdates.length, 2);
+ assertUpdate(
+ processedUpdates.at(-1),
+ ["value1-1", "value1-2", "value1-3"],
+ "category1"
+ );
+
+ info("Removing a new item in category1 broadcasts all category1 items");
+ await root.updateSessionData([
+ createSessionDataUpdate(["value1-1"], "remove", "category1"),
+ ]);
+ processedUpdates = await getUpdates(root, browsingContext1);
+ is(processedUpdates.length, 3);
+ assertUpdate(processedUpdates.at(-1), ["value1-2", "value1-3"], "category1");
+
+ info("Adding a new category does not broadcast category1 items");
+ await root.updateSessionData([
+ createSessionDataUpdate(["value2-1"], "add", "category2"),
+ ]);
+ processedUpdates = await getUpdates(root, browsingContext1);
+ is(processedUpdates.length, 4);
+ assertUpdate(processedUpdates.at(-1), ["value2-1"], "category2");
+
+ info("Adding an item in 2 categories triggers an update for each category");
+ await root.updateSessionData([
+ createSessionDataUpdate(["value1-4"], "add", "category1"),
+ createSessionDataUpdate(["value2-2"], "add", "category2"),
+ ]);
+ processedUpdates = await getUpdates(root, browsingContext1);
+ is(processedUpdates.length, 6);
+ assertUpdate(
+ processedUpdates.at(-2),
+ ["value1-2", "value1-3", "value1-4"],
+ "category1"
+ );
+ assertUpdate(processedUpdates.at(-1), ["value2-1", "value2-2"], "category2");
+
+ info("Removing an item in 2 categories triggers an update for each category");
+ await root.updateSessionData([
+ createSessionDataUpdate(["value1-4"], "remove", "category1"),
+ createSessionDataUpdate(["value2-2"], "remove", "category2"),
+ ]);
+ processedUpdates = await getUpdates(root, browsingContext1);
+ is(processedUpdates.length, 8);
+ assertUpdate(processedUpdates.at(-2), ["value1-2", "value1-3"], "category1");
+ assertUpdate(processedUpdates.at(-1), ["value2-1"], "category2");
+
+ info("Opening a new tab triggers an update for each category");
+ const tab2 = await addTab(TEST_PAGE);
+ const browsingContext2 = tab2.linkedBrowser.browsingContext;
+ processedUpdates = await getUpdates(root, browsingContext2);
+ is(processedUpdates.length, 2);
+ assertUpdate(processedUpdates.at(-2), ["value1-2", "value1-3"], "category1");
+ assertUpdate(processedUpdates.at(-1), ["value2-1"], "category2");
+
+ root.destroy();
+ gBrowser.removeTab(tab2);
+});
diff --git a/remote/shared/messagehandler/test/browser/browser_session_data_update_contexts.js b/remote/shared/messagehandler/test/browser/browser_session_data_update_contexts.js
new file mode 100644
index 0000000000..711df1fc56
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/browser_session_data_update_contexts.js
@@ -0,0 +1,194 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_PAGE = "https://example.com/document-builder.sjs?html=tab";
+
+const { assertUpdate, createSessionDataUpdate, getUpdates } =
+ SessionDataUpdateHelpers;
+
+// Test session data update scenarios involving 2 browsing contexts, and using
+// the TopBrowsingContext ContextDescriptor type.
+add_task(async function test_session_data_update_contexts() {
+ const tab1 = gBrowser.selectedTab;
+ await loadURL(tab1.linkedBrowser, TEST_PAGE);
+ const browsingContext1 = tab1.linkedBrowser.browsingContext;
+
+ const root = createRootMessageHandler("session-data-update-contexts");
+
+ info("Add several items over 2 separate updates for all contexts");
+ await root.updateSessionData([
+ createSessionDataUpdate(["text-1"], "add", "category1"),
+ ]);
+ await root.updateSessionData([
+ createSessionDataUpdate(["text-2"], "add", "category1"),
+ createSessionDataUpdate(["text-3"], "add", "category1"),
+ ]);
+
+ info("Check we processed two distinct updates in browsingContext 1");
+ let processedUpdates = await getUpdates(root, browsingContext1);
+ is(processedUpdates.length, 2);
+ assertUpdate(
+ processedUpdates.at(-1),
+ ["text-1", "text-2", "text-3"],
+ "category1"
+ );
+
+ info("Open a new tab on the same test URL");
+ const tab2 = await addTab(TEST_PAGE);
+ const browsingContext2 = tab2.linkedBrowser.browsingContext;
+
+ processedUpdates = await getUpdates(root, browsingContext2);
+ is(processedUpdates.length, 1);
+ assertUpdate(
+ processedUpdates.at(-1),
+ ["text-1", "text-2", "text-3"],
+ "category1"
+ );
+
+ info("Add two items: one globally and one in a single context");
+ await root.updateSessionData([
+ createSessionDataUpdate(["text-4"], "add", "category1"),
+ createSessionDataUpdate(["text-5"], "add", "category1", {
+ type: ContextDescriptorType.TopBrowsingContext,
+ id: browsingContext2.browserId,
+ }),
+ ]);
+
+ processedUpdates = await getUpdates(root, browsingContext1);
+ is(processedUpdates.length, 3);
+ assertUpdate(
+ processedUpdates.at(-1),
+ ["text-1", "text-2", "text-3", "text-4"],
+ "category1"
+ );
+
+ processedUpdates = await getUpdates(root, browsingContext2);
+ is(processedUpdates.length, 2);
+ assertUpdate(
+ processedUpdates.at(-1),
+ ["text-1", "text-2", "text-3", "text-4", "text-5"],
+ "category1"
+ );
+
+ info("Remove two items: one globally and one in a single context");
+ await root.updateSessionData([
+ createSessionDataUpdate(["text-1"], "remove", "category1"),
+ createSessionDataUpdate(["text-5"], "remove", "category1", {
+ type: ContextDescriptorType.TopBrowsingContext,
+ id: browsingContext2.browserId,
+ }),
+ ]);
+
+ processedUpdates = await getUpdates(root, browsingContext1);
+ is(processedUpdates.length, 4);
+ assertUpdate(
+ processedUpdates.at(-1),
+ ["text-2", "text-3", "text-4"],
+ "category1"
+ );
+
+ processedUpdates = await getUpdates(root, browsingContext2);
+ is(processedUpdates.length, 3);
+ assertUpdate(
+ processedUpdates.at(-1),
+ ["text-2", "text-3", "text-4"],
+ "category1"
+ );
+
+ info(
+ "Add session data item to all contexts and remove this event for one context (2 steps)"
+ );
+
+ info("First step: add an item to browsingContext1");
+ await root.updateSessionData([
+ createSessionDataUpdate(["text-6"], "add", "category1", {
+ type: ContextDescriptorType.TopBrowsingContext,
+ id: browsingContext1.browserId,
+ }),
+ ]);
+
+ info(
+ "Second step: remove the item from browsingContext1, and add it globally"
+ );
+ await root.updateSessionData([
+ createSessionDataUpdate(["text-6"], "remove", "category1", {
+ type: ContextDescriptorType.TopBrowsingContext,
+ id: browsingContext1.browserId,
+ }),
+ createSessionDataUpdate(["text-6"], "add", "category1"),
+ ]);
+
+ processedUpdates = await getUpdates(root, browsingContext1);
+ is(processedUpdates.length, 6);
+ assertUpdate(
+ processedUpdates.at(-1),
+ ["text-2", "text-3", "text-4", "text-6"],
+ "category1"
+ );
+
+ processedUpdates = await getUpdates(root, browsingContext2);
+ is(processedUpdates.length, 4);
+ assertUpdate(
+ processedUpdates.at(-1),
+ ["text-2", "text-3", "text-4", "text-6"],
+ "category1"
+ );
+
+ info(
+ "Remove the event, which has also an individual subscription, for all contexts (2 steps)"
+ );
+
+ info("First step: Add the same item for browsingContext1 and globally");
+ await root.updateSessionData([
+ createSessionDataUpdate(["text-7"], "add", "category1", {
+ type: ContextDescriptorType.TopBrowsingContext,
+ id: browsingContext1.browserId,
+ }),
+ createSessionDataUpdate(["text-7"], "add", "category1"),
+ ]);
+ processedUpdates = await getUpdates(root, browsingContext1);
+ is(processedUpdates.length, 7);
+ // We will find text-7 twice here, the module is responsible for not applying
+ // the same session data item twice. Each item corresponds to a different
+ // descriptor which matched browsingContext1.
+ assertUpdate(
+ processedUpdates.at(-1),
+ ["text-2", "text-3", "text-4", "text-6", "text-7", "text-7"],
+ "category1"
+ );
+
+ processedUpdates = await getUpdates(root, browsingContext2);
+ is(processedUpdates.length, 5);
+ assertUpdate(
+ processedUpdates.at(-1),
+ ["text-2", "text-3", "text-4", "text-6", "text-7"],
+ "category1"
+ );
+
+ info("Second step: Remove the item globally");
+ await root.updateSessionData([
+ createSessionDataUpdate(["text-7"], "remove", "category1"),
+ ]);
+
+ processedUpdates = await getUpdates(root, browsingContext1);
+ is(processedUpdates.length, 8);
+ assertUpdate(
+ processedUpdates.at(-1),
+ ["text-2", "text-3", "text-4", "text-6", "text-7"],
+ "category1"
+ );
+
+ processedUpdates = await getUpdates(root, browsingContext2);
+ is(processedUpdates.length, 6);
+ assertUpdate(
+ processedUpdates.at(-1),
+ ["text-2", "text-3", "text-4", "text-6"],
+ "category1"
+ );
+
+ root.destroy();
+
+ gBrowser.removeTab(tab2);
+});
diff --git a/remote/shared/messagehandler/test/browser/browser_windowglobal_to_root.js b/remote/shared/messagehandler/test/browser/browser_windowglobal_to_root.js
new file mode 100644
index 0000000000..57629e5485
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/browser_windowglobal_to_root.js
@@ -0,0 +1,47 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { RootMessageHandler } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs"
+);
+
+add_task(async function test_windowGlobal_to_root_command() {
+ // Navigate to a page to make sure that the windowglobal modules run in a
+ // different process than the root module.
+ const tab = BrowserTestUtils.addTab(
+ gBrowser,
+ "https://example.com/document-builder.sjs?html=tab"
+ );
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ const browsingContextId = tab.linkedBrowser.browsingContext.id;
+
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-windowglobal-to-rootModule"
+ );
+
+ for (const commandName of [
+ "testHandleCommandToRoot",
+ "testSendRootCommand",
+ ]) {
+ const valueFromRoot = await rootMessageHandler.handleCommand({
+ moduleName: "windowglobaltoroot",
+ commandName,
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContextId,
+ },
+ });
+
+ is(
+ valueFromRoot,
+ "root-value-called-from-windowglobal",
+ "Retrieved the expected value from windowglobaltoroot using " +
+ commandName
+ );
+ }
+
+ rootMessageHandler.destroy();
+ gBrowser.removeTab(tab);
+});
diff --git a/remote/shared/messagehandler/test/browser/head.js b/remote/shared/messagehandler/test/browser/head.js
new file mode 100644
index 0000000000..81cf0942d3
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/head.js
@@ -0,0 +1,236 @@
+/* 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/. */
+
+"use strict";
+
+var { ContextDescriptorType } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/MessageHandler.sys.mjs"
+);
+
+var { WindowGlobalMessageHandler } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs"
+);
+
+var contextDescriptorAll = {
+ type: ContextDescriptorType.All,
+};
+
+function createRootMessageHandler(sessionId) {
+ const { RootMessageHandlerRegistry } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/RootMessageHandlerRegistry.sys.mjs"
+ );
+ return RootMessageHandlerRegistry.getOrCreateMessageHandler(sessionId);
+}
+
+/**
+ * Load the provided url in an existing browser.
+ * Returns a promise which will resolve when the page is loaded.
+ *
+ * @param {Browser} browser
+ * The browser element where the URL should be loaded.
+ * @param {string} url
+ * The URL to load in the new tab
+ */
+async function loadURL(browser, url) {
+ const loaded = BrowserTestUtils.browserLoaded(browser);
+ BrowserTestUtils.startLoadingURIString(browser, url);
+ return loaded;
+}
+
+/**
+ * Create a new foreground tab loading the provided url.
+ * Returns a promise which will resolve when the page is loaded.
+ *
+ * @param {string} url
+ * The URL to load in the new tab
+ */
+async function addTab(url) {
+ const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ registerCleanupFunction(() => {
+ gBrowser.removeTab(tab);
+ });
+ return tab;
+}
+
+/**
+ * Create inline markup for a simple iframe that can be used with
+ * document-builder.sjs. The iframe will be served under the provided domain.
+ *
+ * @param {string} domain
+ * A domain (eg "example.com"), compatible with build/pgo/server-locations.txt
+ */
+function createFrame(domain) {
+ return createFrameForUri(
+ `https://${domain}/document-builder.sjs?html=frame-${domain}`
+ );
+}
+
+function createFrameForUri(uri) {
+ return `<iframe src="${encodeURI(uri)}"></iframe>`;
+}
+
+/**
+ * Create a XUL browser element in the provided XUL tab, with the provided type.
+ *
+ * @param {XULTab} tab
+ * The XUL tab in which the browser element should be inserted.
+ * @param {string} type
+ * The type attribute of the browser element, "chrome" or "content".
+ * @returns {XULBrowser}
+ * The created browser element.
+ */
+function createParentBrowserElement(tab, type) {
+ const parentBrowser = gBrowser.ownerDocument.createXULElement("browser");
+ parentBrowser.setAttribute("type", type);
+ const container = gBrowser.getBrowserContainer(tab.linkedBrowser);
+ container.appendChild(parentBrowser);
+
+ return parentBrowser;
+}
+
+// Create a test page with 2 iframes:
+// - one with a different eTLD+1 (example.com)
+// - one with a nested iframe on a different eTLD+1 (example.net)
+//
+// Overall the document structure should look like:
+//
+// html (example.org)
+// iframe (example.org)
+// iframe (example.net)
+// iframe(example.com)
+//
+// Which means we should have 4 browsing contexts in total.
+function createTestMarkupWithFrames() {
+ // Create the markup for an example.net frame nested in an example.com frame.
+ const NESTED_FRAME_MARKUP = createFrameForUri(
+ `https://example.org/document-builder.sjs?html=${createFrame(
+ "example.net"
+ )}`
+ );
+
+ // Combine the nested frame markup created above with an example.com frame.
+ const TEST_URI_MARKUP = `${NESTED_FRAME_MARKUP}${createFrame("example.com")}`;
+
+ // Create the test page URI on example.org.
+ return `https://example.org/document-builder.sjs?html=${encodeURI(
+ TEST_URI_MARKUP
+ )}`;
+}
+
+const hasPromiseResolved = async function (promise) {
+ let resolved = false;
+ promise.finally(() => (resolved = true));
+ // Make sure microtasks have time to run.
+ await new Promise(resolve => Services.tm.dispatchToMainThread(resolve));
+ return resolved;
+};
+
+/**
+ * Install a sidebar extension.
+ *
+ * @returns {object}
+ * Return value with two properties:
+ * - extension: test wrapper as returned by SpecialPowers.loadExtension.
+ * Make sure to explicitly call extension.unload() before the end of the test.
+ * - sidebarBrowser: the browser element containing the extension sidebar.
+ */
+async function installSidebarExtension() {
+ info("Load the test extension");
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ sidebar_action: {
+ default_panel: "sidebar.html",
+ },
+ },
+ useAddonManager: "temporary",
+
+ files: {
+ "sidebar.html": `
+ <!DOCTYPE html>
+ <html>
+ Test extension
+ <script src="sidebar.js"></script>
+ </html>
+ `,
+ "sidebar.js": function () {
+ const { browser } = this;
+ browser.test.sendMessage("sidebar-loaded", {
+ bcId: SpecialPowers.wrap(window).browsingContext.id,
+ });
+ },
+ "tab.html": `
+ <!DOCTYPE html>
+ <html>
+ Test extension (tab)
+ <script src="tab.js"></script>
+ </html>
+ `,
+ "tab.js": function () {
+ const { browser } = this;
+ browser.test.sendMessage("tab-loaded", {
+ bcId: SpecialPowers.wrap(window).browsingContext.id,
+ });
+ },
+ },
+ });
+
+ info("Wait for the extension to start");
+ await extension.startup();
+
+ info("Wait for the extension browsing context");
+ const { bcId } = await extension.awaitMessage("sidebar-loaded");
+ const sidebarBrowser = BrowsingContext.get(bcId).top.embedderElement;
+ ok(sidebarBrowser, "Got a browser element for the extension sidebar");
+
+ return {
+ extension,
+ sidebarBrowser,
+ };
+}
+
+const SessionDataUpdateHelpers = {
+ getUpdates(rootMessageHandler, browsingContext) {
+ return rootMessageHandler.handleCommand({
+ moduleName: "sessiondataupdate",
+ commandName: "getSessionDataUpdates",
+ destination: {
+ id: browsingContext.id,
+ type: WindowGlobalMessageHandler.type,
+ },
+ });
+ },
+
+ createSessionDataUpdate(
+ values,
+ method,
+ category,
+ descriptor = { type: ContextDescriptorType.All }
+ ) {
+ return {
+ method,
+ values,
+ moduleName: "sessiondataupdate",
+ category,
+ contextDescriptor: descriptor,
+ };
+ },
+
+ assertUpdate(update, expectedValues, expectedCategory) {
+ is(
+ update.length,
+ expectedValues.length,
+ "Update has the expected number of values"
+ );
+
+ for (const item of update) {
+ info(`Check session data update item '${item.value}'`);
+ is(item.category, expectedCategory, "Item has the expected category");
+ is(
+ expectedValues[update.indexOf(item)],
+ item.value,
+ "Item has the expected value"
+ );
+ }
+ },
+};
diff --git a/remote/shared/messagehandler/test/browser/resources/modules/ModuleRegistry.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/ModuleRegistry.sys.mjs
new file mode 100644
index 0000000000..7d93f45b33
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/resources/modules/ModuleRegistry.sys.mjs
@@ -0,0 +1,40 @@
+/* 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: {},
+};
+
+const BASE_FOLDER =
+ "chrome://mochitests/content/browser/remote/shared/messagehandler/test/browser/resources/modules";
+
+// eslint-disable-next-line mozilla/lazy-getter-object-name
+ChromeUtils.defineESModuleGetters(modules.root, {
+ command: `${BASE_FOLDER}/root/command.sys.mjs`,
+ event: `${BASE_FOLDER}/root/event.sys.mjs`,
+ invalid: `${BASE_FOLDER}/root/invalid.sys.mjs`,
+ rootOnly: `${BASE_FOLDER}/root/rootOnly.sys.mjs`,
+ windowglobaltoroot: `${BASE_FOLDER}/root/windowglobaltoroot.sys.mjs`,
+});
+
+// eslint-disable-next-line mozilla/lazy-getter-object-name
+ChromeUtils.defineESModuleGetters(modules["windowglobal-in-root"], {
+ command: `${BASE_FOLDER}/windowglobal-in-root/command.sys.mjs`,
+ event: `${BASE_FOLDER}/windowglobal-in-root/event.sys.mjs`,
+});
+
+// eslint-disable-next-line mozilla/lazy-getter-object-name
+ChromeUtils.defineESModuleGetters(modules.windowglobal, {
+ command: `${BASE_FOLDER}/windowglobal/command.sys.mjs`,
+ commandwindowglobalonly: `${BASE_FOLDER}/windowglobal/commandwindowglobalonly.sys.mjs`,
+ event: `${BASE_FOLDER}/windowglobal/event.sys.mjs`,
+ eventemitter: `${BASE_FOLDER}/windowglobal/eventemitter.sys.mjs`,
+ eventnointercept: `${BASE_FOLDER}/windowglobal/eventnointercept.sys.mjs`,
+ eventonprefchange: `${BASE_FOLDER}/windowglobal/eventonprefchange.sys.mjs`,
+ retry: `${BASE_FOLDER}/windowglobal/retry.sys.mjs`,
+ sessiondataupdate: `${BASE_FOLDER}/windowglobal/sessiondataupdate.sys.mjs`,
+ windowglobaltoroot: `${BASE_FOLDER}/windowglobal/windowglobaltoroot.sys.mjs`,
+});
diff --git a/remote/shared/messagehandler/test/browser/resources/modules/root/command.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/root/command.sys.mjs
new file mode 100644
index 0000000000..29e4a75828
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/resources/modules/root/command.sys.mjs
@@ -0,0 +1,29 @@
+/* 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 CommandModule extends Module {
+ destroy() {}
+
+ /**
+ * Commands
+ */
+
+ testRootModule() {
+ return "root-value";
+ }
+
+ testMissingIntermediaryMethod(params, destination) {
+ // Spawn a new internal command, but with a commandName which doesn't match
+ // any method.
+ return this.messageHandler.handleCommand({
+ moduleName: "command",
+ commandName: "missingMethod",
+ destination,
+ });
+ }
+}
+
+export const command = CommandModule;
diff --git a/remote/shared/messagehandler/test/browser/resources/modules/root/event.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/root/event.sys.mjs
new file mode 100644
index 0000000000..e49437e80d
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/resources/modules/root/event.sys.mjs
@@ -0,0 +1,21 @@
+/* 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 EventModule extends Module {
+ destroy() {}
+
+ /**
+ * Commands
+ */
+
+ testEmitRootEvent() {
+ this.emitEvent("event-from-root", {
+ text: "event from root",
+ });
+ }
+}
+
+export const event = EventModule;
diff --git a/remote/shared/messagehandler/test/browser/resources/modules/root/invalid.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/root/invalid.sys.mjs
new file mode 100644
index 0000000000..3b74769d06
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/resources/modules/root/invalid.sys.mjs
@@ -0,0 +1,4 @@
+// This module is meant to check error reporting when importing a module fails
+// due to an actual issue (syntax error etc...).
+
+SyntaxError(;
diff --git a/remote/shared/messagehandler/test/browser/resources/modules/root/rootOnly.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/root/rootOnly.sys.mjs
new file mode 100644
index 0000000000..0931a7ee8e
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/resources/modules/root/rootOnly.sys.mjs
@@ -0,0 +1,70 @@
+/* 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 { ContextDescriptorType } from "chrome://remote/content/shared/messagehandler/MessageHandler.sys.mjs";
+import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs";
+
+class RootOnlyModule extends Module {
+ #sessionDataReceived;
+ #subscribedEvents;
+
+ constructor(messageHandler) {
+ super(messageHandler);
+
+ this.#sessionDataReceived = [];
+ this.#subscribedEvents = new Set();
+ }
+
+ destroy() {}
+
+ /**
+ * Commands
+ */
+
+ getSessionDataReceived() {
+ return this.#sessionDataReceived;
+ }
+
+ testCommand(params = {}) {
+ return params;
+ }
+
+ _applySessionData(params) {
+ const added = [];
+ const removed = [];
+
+ 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.#subscribedEvents.delete(event);
+ removed.push(event);
+ }
+ }
+
+ // Subscribe to all events, which have an item in SessionData
+ for (const { value } of filteredSessionData) {
+ if (!this.#subscribedEvents.has(value)) {
+ this.#subscribedEvents.add(value);
+ added.push(value);
+ }
+ }
+
+ this.#sessionDataReceived.push({
+ category: params.category,
+ added,
+ removed,
+ contextDescriptor: {
+ type: ContextDescriptorType.All,
+ },
+ });
+ }
+}
+
+export const rootOnly = RootOnlyModule;
diff --git a/remote/shared/messagehandler/test/browser/resources/modules/root/windowglobaltoroot.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/root/windowglobaltoroot.sys.mjs
new file mode 100644
index 0000000000..0975c4abd5
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/resources/modules/root/windowglobaltoroot.sys.mjs
@@ -0,0 +1,29 @@
+/* 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 WindowGlobalToRootModule extends Module {
+ destroy() {}
+
+ /**
+ * Commands
+ */
+
+ getValueFromRoot() {
+ this.#assertParentProcess();
+ return "root-value-called-from-windowglobal";
+ }
+
+ #assertParentProcess() {
+ const isParent =
+ Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_DEFAULT;
+
+ if (!isParent) {
+ throw new Error("Can only run in the parent process");
+ }
+ }
+}
+
+export const windowglobaltoroot = WindowGlobalToRootModule;
diff --git a/remote/shared/messagehandler/test/browser/resources/modules/windowglobal-in-root/command.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal-in-root/command.sys.mjs
new file mode 100644
index 0000000000..f9a2e5d4eb
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal-in-root/command.sys.mjs
@@ -0,0 +1,28 @@
+/* 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 CommandModule extends Module {
+ destroy() {}
+
+ /**
+ * Commands
+ */
+
+ testInterceptModule() {
+ return "intercepted-value";
+ }
+
+ async testInterceptAndForwardModule(params, destination) {
+ const windowGlobalValue = await this.messageHandler.handleCommand({
+ moduleName: "command",
+ commandName: "testForwardToWindowGlobal",
+ destination,
+ });
+ return "intercepted-and-forward+" + windowGlobalValue;
+ }
+}
+
+export const command = CommandModule;
diff --git a/remote/shared/messagehandler/test/browser/resources/modules/windowglobal-in-root/event.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal-in-root/event.sys.mjs
new file mode 100644
index 0000000000..be8b284e8d
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal-in-root/event.sys.mjs
@@ -0,0 +1,39 @@
+/* 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 EventModule extends Module {
+ destroy() {}
+
+ interceptEvent(name, payload) {
+ if (name === "event.testEventWithInterception") {
+ return {
+ ...payload,
+ additionalInformation: "information added through interception",
+ };
+ }
+
+ if (name === "event.testEventCancelableWithInterception") {
+ if (payload.shouldCancel) {
+ return null;
+ }
+ return payload;
+ }
+
+ return payload;
+ }
+
+ /**
+ * Commands
+ */
+
+ testEmitWindowGlobalInRootEvent(params, destination) {
+ this.emitEvent("event-from-window-global-in-root", {
+ text: `windowglobal-in-root event for ${destination.id}`,
+ });
+ }
+}
+
+export const event = EventModule;
diff --git a/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/command.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/command.sys.mjs
new file mode 100644
index 0000000000..99ee76a4b8
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/command.sys.mjs
@@ -0,0 +1,85 @@
+/* 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 CommandModule extends Module {
+ constructor(messageHandler) {
+ super(messageHandler);
+ this._subscribedEvents = new Set();
+
+ this._createdByMessageHandlerConstructor =
+ this._isCreatedByMessageHandlerConstructor();
+ }
+ destroy() {}
+
+ /**
+ * Commands
+ */
+
+ _applySessionData(params) {
+ if (params.category === "testCategory") {
+ 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._subscribedEvents.delete(event);
+ }
+ }
+
+ // Subscribe to all events, which have an item in SessionData
+ for (const { value } of filteredSessionData) {
+ if (!this._subscribedEvents.has(value)) {
+ this._subscribedEvents.add(value);
+ }
+ }
+ }
+
+ if (params.category === "browser_session_data_browser_element") {
+ this.emitEvent("received-session-data", {
+ contextId: this.messageHandler.contextId,
+ });
+ }
+ }
+
+ testWindowGlobalModule() {
+ return "windowglobal-value";
+ }
+
+ testSetValue(params) {
+ const { value } = params;
+
+ this._testValue = value;
+ }
+
+ testGetValue() {
+ return this._testValue;
+ }
+
+ testForwardToWindowGlobal() {
+ return "forward-to-windowglobal-value";
+ }
+
+ testIsCreatedByMessageHandlerConstructor() {
+ return this._createdByMessageHandlerConstructor;
+ }
+
+ _isCreatedByMessageHandlerConstructor() {
+ let caller = Components.stack.caller;
+ while (caller) {
+ if (caller.name === this.messageHandler.constructor.name) {
+ return true;
+ }
+ caller = caller.caller;
+ }
+ return false;
+ }
+}
+
+export const command = CommandModule;
diff --git a/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/commandwindowglobalonly.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/commandwindowglobalonly.sys.mjs
new file mode 100644
index 0000000000..1e4e6c1574
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/commandwindowglobalonly.sys.mjs
@@ -0,0 +1,41 @@
+/* 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 CommandWindowGlobalOnlyModule extends Module {
+ destroy() {}
+
+ /**
+ * Commands
+ */
+
+ testOnlyInWindowGlobal() {
+ return "only-in-windowglobal";
+ }
+
+ testBroadcast() {
+ return `broadcast-${this.messageHandler.contextId}`;
+ }
+
+ testBroadcastWithParameter(params) {
+ return `broadcast-${this.messageHandler.contextId}-${params.value}`;
+ }
+
+ testError() {
+ throw new Error("error-from-module");
+ }
+
+ testMissingIntermediaryMethod(params, destination) {
+ // Spawn a new internal command, but with a commandName which doesn't match
+ // any method.
+ return this.messageHandler.handleCommand({
+ moduleName: "commandwindowglobalonly",
+ commandName: "missingMethod",
+ destination,
+ });
+ }
+}
+
+export const commandwindowglobalonly = CommandWindowGlobalOnlyModule;
diff --git a/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/event.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/event.sys.mjs
new file mode 100644
index 0000000000..415f32032e
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/event.sys.mjs
@@ -0,0 +1,32 @@
+/* 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 EventModule extends Module {
+ destroy() {}
+
+ /**
+ * Commands
+ */
+
+ testEmitEvent() {
+ // Emit a payload including the contextId to check which context emitted
+ // a specific event.
+ const text = `event from ${this.messageHandler.contextId}`;
+ this.emitEvent("event-from-window-global", { text });
+ }
+
+ testEmitEventCancelableWithInterception(params) {
+ this.emitEvent("event.testEventCancelableWithInterception", {
+ shouldCancel: params.shouldCancel,
+ });
+ }
+
+ testEmitEventWithInterception() {
+ this.emitEvent("event.testEventWithInterception", {});
+ }
+}
+
+export const event = EventModule;
diff --git a/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/eventemitter.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/eventemitter.sys.mjs
new file mode 100644
index 0000000000..c86954c5e0
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/eventemitter.sys.mjs
@@ -0,0 +1,81 @@
+/* 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 EventEmitterModule extends Module {
+ #isSubscribed;
+ #subscribedEvents;
+
+ constructor(messageHandler) {
+ super(messageHandler);
+ this.#isSubscribed = false;
+ this.#subscribedEvents = new Set();
+ }
+
+ destroy() {}
+
+ /**
+ * Commands
+ */
+
+ emitTestEvent() {
+ if (this.#isSubscribed) {
+ const text = `event from ${this.messageHandler.contextId}`;
+ this.emitEvent("eventemitter.testEvent", { text });
+ }
+
+ // Emit another event consistently for monitoring during the test.
+ this.emitEvent("eventemitter.monitoringEvent", {});
+ }
+
+ isSubscribed() {
+ return this.#isSubscribed;
+ }
+
+ _applySessionData(params) {
+ 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);
+ }
+ }
+ }
+
+ #subscribeEvent(event) {
+ if (event === "eventemitter.testEvent") {
+ if (this.#isSubscribed) {
+ throw new Error("Already subscribed to eventemitter.testEvent");
+ }
+ this.#isSubscribed = true;
+ this.#subscribedEvents.add(event);
+ }
+ }
+
+ #unsubscribeEvent(event) {
+ if (event === "eventemitter.testEvent") {
+ if (!this.#isSubscribed) {
+ throw new Error("Not subscribed to eventemitter.testEvent");
+ }
+ this.#isSubscribed = false;
+ this.#subscribedEvents.delete(event);
+ }
+ }
+}
+
+export const eventemitter = EventEmitterModule;
diff --git a/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/eventnointercept.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/eventnointercept.sys.mjs
new file mode 100644
index 0000000000..48bbfbf951
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/eventnointercept.sys.mjs
@@ -0,0 +1,16 @@
+/* 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 EventNoInterceptModule extends Module {
+ destroy() {}
+
+ testEvent() {
+ const text = `event no interception`;
+ this.emitEvent("eventnointercept.testEvent", { text });
+ }
+}
+
+export const eventnointercept = EventNoInterceptModule;
diff --git a/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/eventonprefchange.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/eventonprefchange.sys.mjs
new file mode 100644
index 0000000000..33cb25d10b
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/eventonprefchange.sys.mjs
@@ -0,0 +1,33 @@
+/* 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 TEST_PREF = "remote.messagehandler.test.pref";
+
+class EventOnPrefChangeModule extends Module {
+ constructor(messageHandler) {
+ super(messageHandler);
+ Services.prefs.addObserver(TEST_PREF, this.#onPreferenceUpdated);
+ }
+
+ destroy() {
+ Services.prefs.removeObserver(TEST_PREF, this.#onPreferenceUpdated);
+ }
+
+ #onPreferenceUpdated = () => {
+ this.emitEvent("preference-changed");
+ };
+
+ /**
+ * Commands
+ */
+
+ ping() {
+ // We only use this command to force creating the module.
+ return 1;
+ }
+}
+
+export const eventonprefchange = EventOnPrefChangeModule;
diff --git a/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/retry.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/retry.sys.mjs
new file mode 100644
index 0000000000..f7b2279018
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/retry.sys.mjs
@@ -0,0 +1,84 @@
+/* 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";
+
+// Store counters in the JSM scope to persist them across reloads.
+let callsToBlockedOneTime = 0;
+let callsToBlockedTenTimes = 0;
+let callsToBlockedElevenTimes = 0;
+
+// This module provides various commands which all hang for various reasons.
+// The test is supposed to trigger the command and then destroy the
+// JSWindowActor pair by any mean (eg a navigation) in order to trigger an
+// AbortError and a retry.
+class RetryModule extends Module {
+ destroy() {}
+
+ /**
+ * Commands
+ */
+
+ // Resolves only if called while on the example.net domain.
+ async blockedOnNetDomain(params) {
+ // Note: we do not store a call counter here, because this is used for a
+ // cross-group navigation test, and the JSM will be loaded in different
+ // processes.
+ const uri = this.messageHandler.window.document.baseURI;
+ if (!uri.includes("example.net")) {
+ await new Promise(r => {});
+ }
+
+ return { ...params };
+ }
+
+ // Resolves only if called more than once.
+ async blockedOneTime(params) {
+ callsToBlockedOneTime++;
+ if (callsToBlockedOneTime < 2) {
+ await new Promise(r => {});
+ }
+
+ // Return:
+ // - params sent to the command to check that retries have correct params
+ // - the call counter
+ return { ...params, callsToCommand: callsToBlockedOneTime };
+ }
+
+ // Resolves only if called more than ten times (which is exactly the maximum
+ // of retry attempts).
+ async blockedTenTimes(params) {
+ callsToBlockedTenTimes++;
+ if (callsToBlockedTenTimes < 11) {
+ await new Promise(r => {});
+ }
+
+ // Return:
+ // - params sent to the command to check that retries have correct params
+ // - the call counter
+ return { ...params, callsToCommand: callsToBlockedTenTimes };
+ }
+
+ // Resolves only if called more than eleven times (which is greater than the
+ // maximum of retry attempts).
+ async blockedElevenTimes(params) {
+ callsToBlockedElevenTimes++;
+ if (callsToBlockedElevenTimes < 12) {
+ await new Promise(r => {});
+ }
+
+ // Return:
+ // - params sent to the command to check that retries have correct params
+ // - the call counter
+ return { ...params, callsToCommand: callsToBlockedElevenTimes };
+ }
+
+ cleanup() {
+ callsToBlockedOneTime = 0;
+ callsToBlockedTenTimes = 0;
+ callsToBlockedElevenTimes = 0;
+ }
+}
+
+export const retry = RetryModule;
diff --git a/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/sessiondataupdate.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/sessiondataupdate.sys.mjs
new file mode 100644
index 0000000000..5e9ce00b46
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/sessiondataupdate.sys.mjs
@@ -0,0 +1,33 @@
+/* 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 SessionDataUpdateModule extends Module {
+ #sessionDataUpdates;
+
+ constructor(messageHandler) {
+ super(messageHandler);
+ this.#sessionDataUpdates = [];
+ }
+
+ destroy() {}
+
+ /**
+ * Commands
+ */
+
+ _applySessionData(params) {
+ const filteredSessionData = params.sessionData.filter(item =>
+ this.messageHandler.matchesContext(item.contextDescriptor)
+ );
+ this.#sessionDataUpdates.push(filteredSessionData);
+ }
+
+ getSessionDataUpdates() {
+ return this.#sessionDataUpdates;
+ }
+}
+
+export const sessiondataupdate = SessionDataUpdateModule;
diff --git a/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/windowglobaltoroot.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/windowglobaltoroot.sys.mjs
new file mode 100644
index 0000000000..815a836d9c
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/windowglobaltoroot.sys.mjs
@@ -0,0 +1,47 @@
+/* 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";
+import { RootMessageHandler } from "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs";
+
+class WindowGlobalToRootModule extends Module {
+ constructor(messageHandler) {
+ super(messageHandler);
+ this.#assertContentProcess();
+ }
+
+ destroy() {}
+
+ /**
+ * Commands
+ */
+
+ testHandleCommandToRoot(params, destination) {
+ return this.messageHandler.handleCommand({
+ moduleName: "windowglobaltoroot",
+ commandName: "getValueFromRoot",
+ destination: {
+ type: RootMessageHandler.type,
+ },
+ });
+ }
+
+ testSendRootCommand(params, destination) {
+ return this.messageHandler.sendRootCommand({
+ moduleName: "windowglobaltoroot",
+ commandName: "getValueFromRoot",
+ });
+ }
+
+ #assertContentProcess() {
+ const isContent =
+ Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_CONTENT;
+
+ if (!isContent) {
+ throw new Error("Can only run in a content process");
+ }
+ }
+}
+
+export const windowglobaltoroot = WindowGlobalToRootModule;
diff --git a/remote/shared/messagehandler/test/browser/webdriver/browser.toml b/remote/shared/messagehandler/test/browser/webdriver/browser.toml
new file mode 100644
index 0000000000..45ccca74ef
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/webdriver/browser.toml
@@ -0,0 +1,7 @@
+[DEFAULT]
+tags = "remote"
+subsuite = "remote"
+support-files = ["!/remote/shared/messagehandler/test/browser/resources/*"]
+prefs = ["remote.messagehandler.modulecache.useBrowserTestRoot=true"]
+
+["browser_session_execute_command_errors.js"]
diff --git a/remote/shared/messagehandler/test/browser/webdriver/browser_session_execute_command_errors.js b/remote/shared/messagehandler/test/browser/webdriver/browser_session_execute_command_errors.js
new file mode 100644
index 0000000000..36a510bb29
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/webdriver/browser_session_execute_command_errors.js
@@ -0,0 +1,40 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { WebDriverSession } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/webdriver/Session.sys.mjs"
+);
+
+const { error } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/webdriver/Errors.sys.mjs"
+);
+
+add_task(async function test_execute_missing_command_error() {
+ const session = new WebDriverSession();
+
+ info("Attempt to execute an unknown protocol command");
+ await Assert.rejects(
+ session.execute("command", "missingCommand"),
+ err =>
+ err.name == "UnknownCommandError" &&
+ err.message == `command.missingCommand`
+ );
+});
+
+add_task(async function test_execute_missing_internal_command_error() {
+ const session = new WebDriverSession();
+
+ info(
+ "Attempt to execute a protocol command which relies on an unknown internal method"
+ );
+ await Assert.rejects(
+ session.execute("command", "testMissingIntermediaryMethod"),
+ err =>
+ err.name == "UnsupportedCommandError" &&
+ err.message ==
+ `command.missingMethod not supported for destination ROOT` &&
+ !error.isWebDriverError(err)
+ );
+});
diff --git a/remote/shared/messagehandler/test/xpcshell/test_Errors.js b/remote/shared/messagehandler/test/xpcshell/test_Errors.js
new file mode 100644
index 0000000000..26187dac11
--- /dev/null
+++ b/remote/shared/messagehandler/test/xpcshell/test_Errors.js
@@ -0,0 +1,91 @@
+/* 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 { error } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/Errors.sys.mjs"
+);
+
+// Note: this test file is similar to remote/shared/webdriver/test/xpcshell/test_Errors.js
+// because shared/webdriver/Errors.jsm and shared/messagehandler/Errors.jsm share
+// similar helpers.
+
+add_task(function test_toJSON() {
+ let e0 = new error.MessageHandlerError();
+ let e0s = e0.toJSON();
+ equal(e0s.error, "message handler error");
+ equal(e0s.message, "");
+
+ let e1 = new error.MessageHandlerError("a");
+ let e1s = e1.toJSON();
+ equal(e1s.message, e1.message);
+
+ let e2 = new error.UnsupportedCommandError("foo");
+ let e2s = e2.toJSON();
+ equal(e2.status, e2s.error);
+ equal(e2.message, e2s.message);
+});
+
+add_task(function test_fromJSON() {
+ Assert.throws(
+ () => error.MessageHandlerError.fromJSON({ error: "foo" }),
+ /Not of MessageHandlerError descent/
+ );
+ Assert.throws(
+ () => error.MessageHandlerError.fromJSON({ error: "Error" }),
+ /Not of MessageHandlerError descent/
+ );
+ Assert.throws(
+ () => error.MessageHandlerError.fromJSON({}),
+ /Undeserialisable error type/
+ );
+ Assert.throws(
+ () => error.MessageHandlerError.fromJSON(undefined),
+ /TypeError/
+ );
+
+ let e1 = new error.MessageHandlerError("1");
+ let e1r = error.MessageHandlerError.fromJSON({
+ error: "message handler error",
+ message: "1",
+ });
+ ok(e1r instanceof error.MessageHandlerError);
+ equal(e1r.name, e1.name);
+ equal(e1r.status, e1.status);
+ equal(e1r.message, e1.message);
+
+ let e2 = new error.UnsupportedCommandError("foo");
+ let e2r = error.MessageHandlerError.fromJSON({
+ error: "unsupported message handler command",
+ message: "foo",
+ });
+ ok(e2r instanceof error.MessageHandlerError);
+ ok(e2r instanceof error.UnsupportedCommandError);
+ equal(e2r.name, e2.name);
+ equal(e2r.status, e2.status);
+ equal(e2r.message, e2.message);
+
+ // parity with toJSON
+ let e3 = new error.UnsupportedCommandError("foo");
+ let e3toJSON = e3.toJSON();
+ let e3fromJSON = error.MessageHandlerError.fromJSON(e3toJSON);
+ equal(e3toJSON.error, e3fromJSON.status);
+ equal(e3toJSON.message, e3fromJSON.message);
+ equal(e3toJSON.stacktrace, e3fromJSON.stack);
+});
+
+add_task(function test_MessageHandlerError() {
+ let err = new error.MessageHandlerError("foo");
+ equal("MessageHandlerError", err.name);
+ equal("foo", err.message);
+ equal("message handler error", err.status);
+ ok(err instanceof error.MessageHandlerError);
+});
+
+add_task(function test_UnsupportedCommandError() {
+ let e = new error.UnsupportedCommandError("foo");
+ equal("UnsupportedCommandError", e.name);
+ equal("foo", e.message);
+ equal("unsupported message handler command", e.status);
+ ok(e instanceof error.MessageHandlerError);
+});
diff --git a/remote/shared/messagehandler/test/xpcshell/test_SessionData.js b/remote/shared/messagehandler/test/xpcshell/test_SessionData.js
new file mode 100644
index 0000000000..ef61ce27d4
--- /dev/null
+++ b/remote/shared/messagehandler/test/xpcshell/test_SessionData.js
@@ -0,0 +1,296 @@
+/* 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 { ContextDescriptorType } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/MessageHandler.sys.mjs"
+);
+const { RootMessageHandler } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs"
+);
+const { SessionData, SessionDataMethod } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/sessiondata/SessionData.sys.mjs"
+);
+
+add_task(async function test_sessionData() {
+ const sessionData = new SessionData(new RootMessageHandler("session-id-1"));
+ equal(sessionData.getSessionData("mod", "event").length, 0);
+
+ const globalContext = {
+ type: ContextDescriptorType.All,
+ };
+ const otherContext = { type: "other-type", id: "some-id" };
+
+ info("Add a first event for the global context");
+ let updatedItems = sessionData.applySessionData([
+ createUpdate(SessionDataMethod.Add, globalContext, ["first.event"]),
+ ]);
+ equal(updatedItems.length, 1, "One item updated");
+ equal(updatedItems[0].method, SessionDataMethod.Add, "One item added");
+ let updatedValues = updatedItems[0].values;
+ equal(updatedValues.length, 1, "One value added");
+ equal(updatedValues[0], "first.event", "Expected value was added");
+ checkEvents(sessionData.getSessionData("mod", "event"), [
+ {
+ value: "first.event",
+ contextDescriptor: globalContext,
+ },
+ ]);
+
+ info("Add the exact same data (same module, type, context, value)");
+ updatedItems = sessionData.applySessionData([
+ createUpdate(SessionDataMethod.Add, globalContext, ["first.event"]),
+ ]);
+ equal(updatedItems.length, 0, "No new item updated");
+ checkEvents(sessionData.getSessionData("mod", "event"), [
+ {
+ value: "first.event",
+ contextDescriptor: globalContext,
+ },
+ ]);
+
+ info("Add another context for the same event");
+ updatedItems = sessionData.applySessionData([
+ createUpdate(SessionDataMethod.Add, otherContext, ["first.event"]),
+ ]);
+ equal(updatedItems.length, 1, "One item updated");
+ equal(updatedItems[0].method, SessionDataMethod.Add, "One item added");
+ updatedValues = updatedItems[0].values;
+ equal(updatedValues.length, 1, "One value added");
+ equal(updatedValues[0], "first.event", "Expected value was added");
+ checkEvents(sessionData.getSessionData("mod", "event"), [
+ {
+ value: "first.event",
+ contextDescriptor: globalContext,
+ },
+ {
+ value: "first.event",
+ contextDescriptor: otherContext,
+ },
+ ]);
+
+ info("Add a second event for the global context");
+ updatedItems = sessionData.applySessionData([
+ createUpdate(SessionDataMethod.Add, globalContext, ["second.event"]),
+ ]);
+ equal(updatedItems.length, 1, "One item updated");
+ equal(updatedItems[0].method, SessionDataMethod.Add, "One item added");
+ updatedValues = updatedItems[0].values;
+ equal(updatedValues.length, 1, "One value added");
+ equal(updatedValues[0], "second.event", "Expected value was added");
+ checkEvents(sessionData.getSessionData("mod", "event"), [
+ {
+ value: "first.event",
+ contextDescriptor: globalContext,
+ },
+ {
+ value: "first.event",
+ contextDescriptor: otherContext,
+ },
+ {
+ value: "second.event",
+ contextDescriptor: globalContext,
+ },
+ ]);
+
+ info("Add two events for the global context");
+ updatedItems = sessionData.applySessionData([
+ createUpdate(SessionDataMethod.Add, globalContext, [
+ "third.event",
+ "fourth.event",
+ ]),
+ ]);
+ equal(updatedItems.length, 1, "One item updated");
+ equal(updatedItems[0].method, SessionDataMethod.Add, "One item added");
+ updatedValues = updatedItems[0].values;
+ equal(updatedValues.length, 2, "Two values added");
+ equal(updatedValues[0], "third.event", "Expected value was added");
+ equal(updatedValues[1], "fourth.event", "Expected value was added");
+ checkEvents(sessionData.getSessionData("mod", "event"), [
+ {
+ value: "first.event",
+ contextDescriptor: globalContext,
+ },
+ {
+ value: "first.event",
+ contextDescriptor: otherContext,
+ },
+ {
+ value: "second.event",
+ contextDescriptor: globalContext,
+ },
+ {
+ value: "third.event",
+ contextDescriptor: globalContext,
+ },
+ {
+ value: "fourth.event",
+ contextDescriptor: globalContext,
+ },
+ ]);
+
+ info("Remove the second, third and fourth events");
+ updatedItems = sessionData.applySessionData([
+ createUpdate(SessionDataMethod.Remove, globalContext, [
+ "second.event",
+ "third.event",
+ "fourth.event",
+ ]),
+ ]);
+ equal(updatedItems.length, 1, "One item updated");
+ equal(updatedItems[0].method, SessionDataMethod.Remove, "One item removed");
+ updatedValues = updatedItems[0].values;
+ equal(updatedValues.length, 3, "Three values removed");
+ equal(updatedValues[0], "second.event", "Expected value was removed");
+ equal(updatedValues[1], "third.event", "Expected value was removed");
+ equal(updatedValues[2], "fourth.event", "Expected value was removed");
+ checkEvents(sessionData.getSessionData("mod", "event"), [
+ {
+ value: "first.event",
+ contextDescriptor: globalContext,
+ },
+ {
+ value: "first.event",
+ contextDescriptor: otherContext,
+ },
+ ]);
+
+ info("Remove the global context from the first event");
+ updatedItems = sessionData.applySessionData([
+ createUpdate(SessionDataMethod.Remove, globalContext, ["first.event"]),
+ ]);
+ equal(updatedItems.length, 1, "One item updated");
+ equal(updatedItems[0].method, SessionDataMethod.Remove, "One item removed");
+ updatedValues = updatedItems[0].values;
+ equal(updatedValues.length, 1, "One value removed");
+ equal(updatedValues[0], "first.event", "Expected value was removed");
+ checkEvents(sessionData.getSessionData("mod", "event"), [
+ {
+ value: "first.event",
+ contextDescriptor: otherContext,
+ },
+ ]);
+
+ info("Remove the other context from the first event");
+ updatedItems = sessionData.applySessionData([
+ createUpdate(SessionDataMethod.Remove, otherContext, ["first.event"]),
+ ]);
+ equal(updatedItems.length, 1, "One item updated");
+ equal(updatedItems[0].method, SessionDataMethod.Remove, "One item removed");
+ updatedValues = updatedItems[0].values;
+ equal(updatedValues.length, 1, "One value removed");
+ equal(updatedValues[0], "first.event", "Expected value was removed");
+ checkEvents(sessionData.getSessionData("mod", "event"), []);
+
+ info("Add two events for different contexts");
+ updatedItems = sessionData.applySessionData([
+ createUpdate(SessionDataMethod.Add, otherContext, ["first.event"]),
+ createUpdate(SessionDataMethod.Add, globalContext, ["second.event"]),
+ ]);
+ equal(updatedItems.length, 2, "Two items updated");
+ equal(updatedItems[0].method, SessionDataMethod.Add, "First item added");
+ updatedValues = updatedItems[0].values;
+ equal(updatedValues.length, 1, "One value for first item added");
+ equal(updatedValues[0], "first.event", "Expected value first item was added");
+ equal(updatedItems[1].method, SessionDataMethod.Add, "Second item added");
+ updatedValues = updatedItems[1].values;
+ equal(updatedValues.length, 1, "One value for second item added");
+ equal(
+ updatedValues[0],
+ "second.event",
+ "Expected value second item was added"
+ );
+ checkEvents(sessionData.getSessionData("mod", "event"), [
+ {
+ value: "first.event",
+ contextDescriptor: otherContext,
+ },
+ {
+ value: "second.event",
+ contextDescriptor: globalContext,
+ },
+ ]);
+
+ info("Remove two events for different contexts");
+ updatedItems = sessionData.applySessionData([
+ createUpdate(SessionDataMethod.Remove, otherContext, ["first.event"]),
+ createUpdate(SessionDataMethod.Remove, globalContext, ["second.event"]),
+ ]);
+ equal(updatedItems.length, 2, "Two items updated");
+ equal(updatedItems[0].method, SessionDataMethod.Remove, "First item removed");
+ updatedValues = updatedItems[0].values;
+ equal(updatedValues.length, 1, "One value for first item removed");
+ equal(
+ updatedValues[0],
+ "first.event",
+ "Expected value first item was removed"
+ );
+ equal(
+ updatedItems[1].method,
+ SessionDataMethod.Remove,
+ "Second item removed"
+ );
+ updatedValues = updatedItems[1].values;
+ equal(updatedValues.length, 1, "One value for second item removed");
+ equal(
+ updatedValues[0],
+ "second.event",
+ "Expected value second item was removed"
+ );
+ checkEvents(sessionData.getSessionData("mod", "event"), []);
+
+ info("Add and remove event in different order");
+ updatedItems = sessionData.applySessionData([
+ createUpdate(SessionDataMethod.Remove, otherContext, ["first.event"]),
+ createUpdate(SessionDataMethod.Add, otherContext, ["first.event"]),
+ ]);
+ equal(updatedItems.length, 1, "One item updated");
+ equal(updatedItems[0].method, SessionDataMethod.Add, "One item added");
+ updatedValues = updatedItems[0].values;
+ equal(updatedValues.length, 1, "One value added");
+ equal(updatedValues[0], "first.event", "Expected value was added");
+ checkEvents(sessionData.getSessionData("mod", "event"), [
+ {
+ value: "first.event",
+ contextDescriptor: otherContext,
+ },
+ ]);
+
+ updatedItems = sessionData.applySessionData([
+ createUpdate(SessionDataMethod.Add, otherContext, ["first.event"]),
+ createUpdate(SessionDataMethod.Remove, otherContext, ["first.event"]),
+ ]);
+ equal(updatedItems.length, 1, "No item update");
+ equal(updatedItems[0].method, SessionDataMethod.Remove, "One item removed");
+ updatedValues = updatedItems[0].values;
+ equal(updatedValues.length, 1, "One value removed");
+ equal(updatedValues[0], "first.event", "Expected value was removed");
+ checkEvents(sessionData.getSessionData("mod", "event"), []);
+});
+
+function checkEvents(events, expectedEvents) {
+ // Check the arrays have the same size.
+ equal(events.length, expectedEvents.length);
+
+ // Check all the expectedEvents can be found in the events array.
+ for (const expected of expectedEvents) {
+ ok(
+ events.some(
+ event =>
+ expected.contextDescriptor.type === event.contextDescriptor.type &&
+ expected.contextDescriptor.id === event.contextDescriptor.id &&
+ expected.value == event.value
+ )
+ );
+ }
+}
+
+function createUpdate(method, contextDescriptor, values) {
+ return {
+ method,
+ moduleName: "mod",
+ category: "event",
+ contextDescriptor,
+ values,
+ };
+}
diff --git a/remote/shared/messagehandler/test/xpcshell/xpcshell.toml b/remote/shared/messagehandler/test/xpcshell/xpcshell.toml
new file mode 100644
index 0000000000..10f8b2f715
--- /dev/null
+++ b/remote/shared/messagehandler/test/xpcshell/xpcshell.toml
@@ -0,0 +1,5 @@
+[DEFAULT]
+
+["test_Errors.js"]
+
+["test_SessionData.js"]
diff --git a/remote/shared/messagehandler/transports/BrowsingContextUtils.sys.mjs b/remote/shared/messagehandler/transports/BrowsingContextUtils.sys.mjs
new file mode 100644
index 0000000000..482f90948a
--- /dev/null
+++ b/remote/shared/messagehandler/transports/BrowsingContextUtils.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/. */
+
+function isExtensionContext(browsingContext) {
+ let principal;
+ if (CanonicalBrowsingContext.isInstance(browsingContext)) {
+ principal = browsingContext.currentWindowGlobal.documentPrincipal;
+ } else {
+ principal = browsingContext.window.document.nodePrincipal;
+ }
+
+ // In practice, note that the principal will never be an expanded principal.
+ // The are only used for content scripts executed in a Sandbox, and do not
+ // have a browsing context on their own.
+ // But we still use this flag because there is no isAddonPrincipal flag.
+ return principal.isAddonOrExpandedAddonPrincipal;
+}
+
+function isParentProcess(browsingContext) {
+ if (CanonicalBrowsingContext.isInstance(browsingContext)) {
+ return browsingContext.currentWindowGlobal.osPid === -1;
+ }
+
+ // If `browsingContext` is not a `CanonicalBrowsingContext`, then we are
+ // necessarily in a content process page.
+ return false;
+}
+
+/**
+ * Check if the given browsing context is valid for the message handler
+ * to use.
+ *
+ * @param {BrowsingContext} browsingContext
+ * The browsing context to check.
+ * @param {object=} options
+ * @param {string=} options.browserId
+ * The id of the browser to filter the browsing contexts by (optional).
+ * @returns {boolean}
+ * True if the browsing context is valid, false otherwise.
+ */
+export function isBrowsingContextCompatible(browsingContext, options = {}) {
+ const { browserId } = options;
+
+ // If a browserId was provided, skip browsing contexts which are not
+ // associated with this browserId.
+ if (browserId !== undefined && browsingContext.browserId !== browserId) {
+ return false;
+ }
+
+ // Skip:
+ // - extension contexts until we support debugging webextensions, see Bug 1755014.
+ // - privileged contexts until we support debugging Chrome context, see Bug 1713440.
+ return (
+ !isExtensionContext(browsingContext) && !isParentProcess(browsingContext)
+ );
+}
diff --git a/remote/shared/messagehandler/transports/RootTransport.sys.mjs b/remote/shared/messagehandler/transports/RootTransport.sys.mjs
new file mode 100644
index 0000000000..b60d3726ef
--- /dev/null
+++ b/remote/shared/messagehandler/transports/RootTransport.sys.mjs
@@ -0,0 +1,188 @@
+/* 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, {
+ ContextDescriptorType:
+ "chrome://remote/content/shared/messagehandler/MessageHandler.sys.mjs",
+ isBrowsingContextCompatible:
+ "chrome://remote/content/shared/messagehandler/transports/BrowsingContextUtils.sys.mjs",
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+ MessageHandlerFrameActor:
+ "chrome://remote/content/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameActor.sys.mjs",
+ TabManager: "chrome://remote/content/shared/TabManager.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
+
+const MAX_RETRY_ATTEMPTS = 10;
+
+/**
+ * RootTransport is intended to be used from a ROOT MessageHandler to communicate
+ * with WINDOW_GLOBAL MessageHandlers via the MessageHandlerFrame JSWindow
+ * actors.
+ */
+export class RootTransport {
+ /**
+ * @param {MessageHandler} messageHandler
+ * The MessageHandler instance which owns this RootTransport instance.
+ */
+ constructor(messageHandler) {
+ this._messageHandler = messageHandler;
+
+ // RootTransport will rely on the MessageHandlerFrame JSWindow actors.
+ // Make sure they are registered when instanciating a RootTransport.
+ lazy.MessageHandlerFrameActor.register();
+ }
+
+ /**
+ * Forward the provided command to WINDOW_GLOBAL MessageHandlers via the
+ * MessageHandlerFrame actors.
+ *
+ * @param {Command} command
+ * The command to forward. See type definition in MessageHandler.js
+ * @returns {Promise}
+ * Returns a promise that resolves with the result of the command after
+ * being processed by WINDOW_GLOBAL MessageHandlers.
+ */
+ forwardCommand(command) {
+ if (command.destination.id && command.destination.contextDescriptor) {
+ throw new Error(
+ "Invalid command destination with both 'id' and 'contextDescriptor' properties"
+ );
+ }
+
+ // With an id given forward the command to only this specific destination.
+ if (command.destination.id) {
+ const browsingContext = BrowsingContext.get(command.destination.id);
+ if (!browsingContext) {
+ throw new Error(
+ "Unable to find a BrowsingContext for id " + command.destination.id
+ );
+ }
+ return this._sendCommandToBrowsingContext(command, browsingContext);
+ }
+
+ // ... otherwise broadcast to destinations matching the contextDescriptor.
+ if (command.destination.contextDescriptor) {
+ return this._broadcastCommand(command);
+ }
+
+ throw new Error(
+ "Unrecognized command destination, missing 'id' or 'contextDescriptor' properties"
+ );
+ }
+
+ _broadcastCommand(command) {
+ const { contextDescriptor } = command.destination;
+ const browsingContexts =
+ this._getBrowsingContextsForDescriptor(contextDescriptor);
+
+ return Promise.all(
+ browsingContexts.map(async browsingContext => {
+ try {
+ return await this._sendCommandToBrowsingContext(
+ command,
+ browsingContext
+ );
+ } catch (e) {
+ console.error(
+ `Failed to broadcast a command to browsingContext ${browsingContext.id}`,
+ e
+ );
+ return null;
+ }
+ })
+ );
+ }
+
+ async _sendCommandToBrowsingContext(command, browsingContext) {
+ const name = `${command.moduleName}.${command.commandName}`;
+
+ // The browsing context might be destroyed by a navigation. Keep a reference
+ // to the webProgress, which will persist, and always use it to retrieve the
+ // currently valid browsing context.
+ const webProgress = browsingContext.webProgress;
+
+ const { retryOnAbort = false } = command;
+
+ let attempts = 0;
+ while (true) {
+ try {
+ return await webProgress.browsingContext.currentWindowGlobal
+ .getActor("MessageHandlerFrame")
+ .sendCommand(command, this._messageHandler.sessionId);
+ } catch (e) {
+ if (!retryOnAbort || e.name != "AbortError") {
+ // Only retry if the command supports retryOnAbort and when the
+ // JSWindowActor pair gets destroyed.
+ throw e;
+ }
+
+ if (++attempts > MAX_RETRY_ATTEMPTS) {
+ lazy.logger.trace(
+ `RootTransport reached the limit of retry attempts (${MAX_RETRY_ATTEMPTS})` +
+ ` for command ${name} and browsing context ${webProgress.browsingContext.id}.`
+ );
+ throw e;
+ }
+
+ lazy.logger.trace(
+ `RootTransport retrying command ${name} for ` +
+ `browsing context ${webProgress.browsingContext.id}, attempt: ${attempts}.`
+ );
+ await new Promise(resolve => Services.tm.dispatchToMainThread(resolve));
+ }
+ }
+ }
+
+ toString() {
+ return `[object ${this.constructor.name} ${this._messageHandler.name}]`;
+ }
+
+ _getBrowsingContextsForDescriptor(contextDescriptor) {
+ const { id, type } = contextDescriptor;
+
+ if (type === lazy.ContextDescriptorType.All) {
+ return this._getBrowsingContexts();
+ }
+
+ if (type === lazy.ContextDescriptorType.TopBrowsingContext) {
+ return this._getBrowsingContexts({ browserId: id });
+ }
+
+ // TODO: Handle other types of context descriptors.
+ throw new Error(
+ `Unsupported contextDescriptor type for broadcasting: ${type}`
+ );
+ }
+
+ /**
+ * Get all browsing contexts, optionally matching the provided options.
+ *
+ * @param {object} options
+ * @param {string=} options.browserId
+ * The id of the browser to filter the browsing contexts by (optional).
+ * @returns {Array<BrowsingContext>}
+ * The browsing contexts matching the provided options or all browsing contexts
+ * if no options are provided.
+ */
+ _getBrowsingContexts(options = {}) {
+ // extract browserId from options
+ const { browserId } = options;
+ let browsingContexts = [];
+
+ // Fetch all tab related browsing contexts for top-level windows.
+ for (const { browsingContext } of lazy.TabManager.browsers) {
+ if (lazy.isBrowsingContextCompatible(browsingContext, { browserId })) {
+ browsingContexts = browsingContexts.concat(
+ browsingContext.getAllBrowsingContextsInSubtree()
+ );
+ }
+ }
+
+ return browsingContexts;
+ }
+}
diff --git a/remote/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameActor.sys.mjs b/remote/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameActor.sys.mjs
new file mode 100644
index 0000000000..c236cebac7
--- /dev/null
+++ b/remote/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameActor.sys.mjs
@@ -0,0 +1,51 @@
+/* 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, {
+ ActorManagerParent: "resource://gre/modules/ActorManagerParent.sys.mjs",
+
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
+
+const FRAME_ACTOR_CONFIG = {
+ parent: {
+ esModuleURI:
+ "chrome://remote/content/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameParent.sys.mjs",
+ },
+ child: {
+ esModuleURI:
+ "chrome://remote/content/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameChild.sys.mjs",
+ events: {
+ DOMWindowCreated: {},
+ pagehide: {},
+ pageshow: {},
+ },
+ },
+ allFrames: true,
+ messageManagerGroups: ["browsers"],
+};
+
+/**
+ * MessageHandlerFrameActor exposes a simple registration helper to lazily
+ * register MessageHandlerFrame JSWindow actors.
+ */
+export const MessageHandlerFrameActor = {
+ registered: false,
+
+ register() {
+ if (this.registered) {
+ return;
+ }
+
+ lazy.ActorManagerParent.addJSWindowActors({
+ MessageHandlerFrame: FRAME_ACTOR_CONFIG,
+ });
+ this.registered = true;
+ lazy.logger.trace("Registered MessageHandlerFrame actors");
+ },
+};
diff --git a/remote/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameChild.sys.mjs b/remote/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameChild.sys.mjs
new file mode 100644
index 0000000000..52a8fdc4c9
--- /dev/null
+++ b/remote/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameChild.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/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ isBrowsingContextCompatible:
+ "chrome://remote/content/shared/messagehandler/transports/BrowsingContextUtils.sys.mjs",
+ MessageHandlerRegistry:
+ "chrome://remote/content/shared/messagehandler/MessageHandlerRegistry.sys.mjs",
+ WindowGlobalMessageHandler:
+ "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs",
+});
+
+/**
+ * Map from MessageHandlerRegistry to MessageHandlerFrameChild actor. This will
+ * allow a WindowGlobalMessageHandler to find the JSWindowActorChild instance to
+ * use to send commands.
+ */
+const registryToActor = new WeakMap();
+
+/**
+ * Retrieve the MessageHandlerFrameChild which is linked to the provided
+ * WindowGlobalMessageHandler instance.
+ *
+ * @param {WindowGlobalMessageHandler} messageHandler
+ * The WindowGlobalMessageHandler for which to get the JSWindowActor.
+ * @returns {MessageHandlerFrameChild}
+ * The corresponding MessageHandlerFrameChild instance.
+ */
+export function getMessageHandlerFrameChildActor(messageHandler) {
+ return registryToActor.get(messageHandler.registry);
+}
+
+/**
+ * Child actor for the MessageHandlerFrame JSWindowActor. The
+ * MessageHandlerFrame actor is used by RootTransport to communicate between
+ * ROOT MessageHandlers and WINDOW_GLOBAL MessageHandlers.
+ */
+export class MessageHandlerFrameChild extends JSWindowActorChild {
+ actorCreated() {
+ this.type = lazy.WindowGlobalMessageHandler.type;
+ this.context = this.manager.browsingContext;
+
+ this._registry = new lazy.MessageHandlerRegistry(this.type, this.context);
+ registryToActor.set(this._registry, this);
+
+ this._onRegistryEvent = this._onRegistryEvent.bind(this);
+
+ // MessageHandlerFrameChild is responsible for forwarding events from
+ // WindowGlobalMessageHandler to the parent process.
+ // Such events are re-emitted on the MessageHandlerRegistry to avoid
+ // setting up listeners on individual MessageHandler instances.
+ this._registry.on("message-handler-registry-event", this._onRegistryEvent);
+ }
+
+ handleEvent({ persisted, type }) {
+ if (type == "DOMWindowCreated" || (type == "pageshow" && persisted)) {
+ // When the window is created or is retrieved from BFCache, instantiate
+ // a MessageHandler for all sessions which might need it.
+ if (lazy.isBrowsingContextCompatible(this.manager.browsingContext)) {
+ this._registry.createAllMessageHandlers();
+ }
+ } else if (type == "pagehide" && persisted) {
+ // When the page is moved to BFCache, all the currently created message
+ // handlers should be destroyed.
+ this._registry.destroy();
+ }
+ }
+
+ async receiveMessage(message) {
+ if (message.name === "MessageHandlerFrameParent:sendCommand") {
+ const { sessionId, command } = message.data;
+ const messageHandler =
+ this._registry.getOrCreateMessageHandler(sessionId);
+ try {
+ return await messageHandler.handleCommand(command);
+ } catch (e) {
+ if (e?.isRemoteError) {
+ return {
+ error: e.toJSON(),
+ isMessageHandlerError: e.isMessageHandlerError,
+ };
+ }
+ throw e;
+ }
+ }
+
+ return null;
+ }
+
+ sendCommand(command, sessionId) {
+ return this.sendQuery("MessageHandlerFrameChild:sendCommand", {
+ command,
+ sessionId,
+ });
+ }
+
+ _onRegistryEvent(eventName, wrappedEvent) {
+ this.sendAsyncMessage(
+ "MessageHandlerFrameChild:messageHandlerEvent",
+ wrappedEvent
+ );
+ }
+
+ didDestroy() {
+ this._registry.off("message-handler-registry-event", this._onRegistryEvent);
+ this._registry.destroy();
+ }
+}
diff --git a/remote/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameParent.sys.mjs b/remote/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameParent.sys.mjs
new file mode 100644
index 0000000000..a4901571d9
--- /dev/null
+++ b/remote/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameParent.sys.mjs
@@ -0,0 +1,127 @@
+/* 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/messagehandler/Errors.sys.mjs",
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+ RootMessageHandlerRegistry:
+ "chrome://remote/content/shared/messagehandler/RootMessageHandlerRegistry.sys.mjs",
+ WindowGlobalMessageHandler:
+ "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
+
+ChromeUtils.defineLazyGetter(lazy, "WebDriverError", () => {
+ return ChromeUtils.importESModule(
+ "chrome://remote/content/shared/webdriver/Errors.sys.mjs"
+ ).error.WebDriverError;
+});
+
+/**
+ * Parent actor for the MessageHandlerFrame JSWindowActor. The
+ * MessageHandlerFrame actor is used by RootTransport to communicate between
+ * ROOT MessageHandlers and WINDOW_GLOBAL MessageHandlers.
+ */
+export class MessageHandlerFrameParent extends JSWindowActorParent {
+ async receiveMessage(message) {
+ switch (message.name) {
+ case "MessageHandlerFrameChild:sendCommand": {
+ return this.#handleSendCommandMessage(message.data);
+ }
+ case "MessageHandlerFrameChild:messageHandlerEvent": {
+ return this.#handleMessageHandlerEventMessage(message.data);
+ }
+ default:
+ throw new Error("Unsupported message:" + message.name);
+ }
+ }
+
+ /**
+ * Send a command to the corresponding MessageHandlerFrameChild actor via a
+ * JSWindowActor query.
+ *
+ * @param {Command} command
+ * The command to forward. See type definition in MessageHandler.js
+ * @param {string} sessionId
+ * ID of the session that sent the command.
+ * @returns {Promise}
+ * Promise that will resolve with the result of query sent to the
+ * MessageHandlerFrameChild actor.
+ */
+ async sendCommand(command, sessionId) {
+ const result = await this.sendQuery(
+ "MessageHandlerFrameParent:sendCommand",
+ {
+ command,
+ sessionId,
+ }
+ );
+
+ if (result?.error) {
+ if (result.isMessageHandlerError) {
+ throw lazy.error.MessageHandlerError.fromJSON(result.error);
+ }
+
+ // TODO: Do not assume WebDriver is the session protocol, see Bug 1779026.
+ throw lazy.WebDriverError.fromJSON(result.error);
+ }
+
+ return result;
+ }
+
+ async #handleMessageHandlerEventMessage(messageData) {
+ const { name, contextInfo, data, sessionId } = messageData;
+ const [moduleName] = name.split(".");
+
+ // Re-emit the event on the RootMessageHandler.
+ const messageHandler =
+ lazy.RootMessageHandlerRegistry.getExistingMessageHandler(sessionId);
+ // TODO: getModuleInstance expects a CommandDestination in theory,
+ // but only uses the MessageHandler type in practice, see Bug 1776389.
+ const module = messageHandler.moduleCache.getModuleInstance(moduleName, {
+ type: lazy.WindowGlobalMessageHandler.type,
+ });
+ let eventPayload = data;
+
+ // Modify an event payload if there is a special method in the targeted module.
+ // If present it can be found in windowglobal-in-root module.
+ if (module?.interceptEvent) {
+ eventPayload = await module.interceptEvent(name, data);
+
+ if (eventPayload === null) {
+ lazy.logger.trace(
+ `${moduleName}.interceptEvent returned null, skipping event: ${name}, data: ${data}`
+ );
+ return;
+ }
+ // Make sure that an event payload is returned.
+ if (!eventPayload) {
+ throw new Error(
+ `${moduleName}.interceptEvent doesn't return the event payload`
+ );
+ }
+ }
+ messageHandler.emitEvent(name, eventPayload, contextInfo);
+ }
+
+ async #handleSendCommandMessage(messageData) {
+ const { sessionId, command } = messageData;
+ const messageHandler =
+ lazy.RootMessageHandlerRegistry.getExistingMessageHandler(sessionId);
+ try {
+ return await messageHandler.handleCommand(command);
+ } catch (e) {
+ if (e?.isRemoteError) {
+ return {
+ error: e.toJSON(),
+ isMessageHandlerError: e.isMessageHandlerError,
+ };
+ }
+ throw e;
+ }
+ }
+}