diff options
Diffstat (limited to 'remote/shared/messagehandler')
57 files changed, 6464 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..4e7d5400ec --- /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; + } + + /** + * @return {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. + * + * @return {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..05542d3446 --- /dev/null +++ b/remote/shared/messagehandler/EventsDispatcher.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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +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", +}); + +XPCOMUtils.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; + } + + /** + * 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. + * @return {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. + * @return {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. + * + * @return {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..f362cc55b2 --- /dev/null +++ b/remote/shared/messagehandler/MessageHandler.sys.mjs @@ -0,0 +1,349 @@ +/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +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", +}); + +XPCOMUtils.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; + #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. + */ + constructor(sessionId, context) { + 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); + } + + 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 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. + * @return {Array.<class<Module>=>} + * 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. + * @return {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}]`; + } + + /** + * 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 applyInitialSessionDataItems(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. + * @return {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. + * + * @return {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..b6f1bb0fd1 --- /dev/null +++ b/remote/shared/messagehandler/MessageHandlerRegistry.sys.mjs @@ -0,0 +1,243 @@ +/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +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", +}); + +XPCOMUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +/** + * Map of MessageHandler type to MessageHandler subclass. + */ +XPCOMUtils.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. + * @return {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. + * + * @return {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. + * @return {MessageHandler=} + * A MessageHandler instance, null if not found. + */ + getExistingMessageHandler(sessionId) { + return this._messageHandlersMap.get(sessionId); + } + + /** + * Retrieve an already registered MessageHandler instance matching the + * provided parameters. + * + * @param {String} sessionId + * ID of the session the handler is used for. + * @param {String} type + * MessageHandler type, one of MessageHandler.type. + * @param {Object=} context + * The context object, which depends on the type. Can be null for ROOT + * type MessageHandlers. + * @return {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. + * @return {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. + * @return {MessageHandler} + * A new MessageHandler instance. + */ + _createMessageHandler(sessionId, sessionDataItems) { + const messageHandler = new this._messageHandlerClass( + sessionId, + this._context + ); + + messageHandler.on( + "message-handler-destroyed", + this._onMessageHandlerDestroyed + ); + messageHandler.on("message-handler-event", this._onMessageHandlerEvent); + + messageHandler.applyInitialSessionDataItems(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..32684ce696 --- /dev/null +++ b/remote/shared/messagehandler/Module.sys.mjs @@ -0,0 +1,134 @@ +/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(lazy, "disabledExperimentalAPI", () => { + return !Services.prefs.getBoolPref("remote.experimental.enabled"); +}); + +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. + * + * It's required to be implemented in the sub class. + */ + destroy() { + throw new Error("Not implemented"); + } + + /** + * 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..177f5d1f2f --- /dev/null +++ b/remote/shared/messagehandler/ModuleCache.sys.mjs @@ -0,0 +1,207 @@ +/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + getMessageHandlerClass: + "chrome://remote/content/shared/messagehandler/MessageHandlerRegistry.sys.mjs", + Log: "chrome://remote/content/shared/Log.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. + getModuleClass: + "chrome://remote/content/webdriver-bidi/modules/ModuleRegistry.sys.mjs", +}); +// eslint-disable-next-line mozilla/lazy-getter-object-name +ChromeUtils.defineESModuleGetters(protocols.test, { + getModuleClass: + "chrome://mochitests/content/browser/remote/shared/messagehandler/test/browser/resources/modules/ModuleRegistry.sys.mjs", +}); + +XPCOMUtils.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}.jsm 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}.jsm 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}.jsm 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}.jsm + * - or {destinationType}-in-{currentType}/{ModuleName}.jsm + * + * 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 { + /* + * @param {MessageHandler} messageHandler + * The MessageHandler instance which owns this ModuleCache instance. + */ + constructor(messageHandler) { + this.messageHandler = messageHandler; + this._messageHandlerType = messageHandler.constructor.type; + + // 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; + + // Map of absolute module paths to module instances. + this._modules = new Map(); + } + + /** + * 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. + * @return {Array.<class<Module>=>} + * An array of Module classes. + */ + getAllModuleClasses(moduleName, destination) { + const destinationType = destination.type; + const folders = [ + this._getModuleFolder(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) { + folders.push(this._getModuleFolder(destinationType, destinationType)); + } + + return folders + .map(folder => this._protocol.getModuleClass(moduleName, folder)) + .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.jsm for the CommandDestination typedef. + * @return {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 moduleFolder = this._getModuleFolder( + this._messageHandlerType, + destination.type + ); + const ModuleClass = this._protocol.getModuleClass(moduleName, moduleFolder); + + let module = null; + if (ModuleClass) { + module = new ModuleClass(this.messageHandler); + lazy.logger.trace( + `Module ${moduleFolder}/${moduleName}.jsm found for ${destination.type}` + ); + } else { + lazy.logger.trace( + `Module ${moduleFolder}/${moduleName}.jsm not found for ${destination.type}` + ); + } + + 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. + */ + hasModule(moduleName, destination) { + const classes = this.getAllModuleClasses(moduleName, destination); + return !!classes.length; + } + + toString() { + return `[object ${this.constructor.name} ${this.messageHandler.name}]`; + } + + _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}.jsm". + return originPath; + } + // If the command is targeting another type, the module is expected to + // be in a composed folder eg "windowglobal-in-root/${moduleName}.jsm". + 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..cf64bb4502 --- /dev/null +++ b/remote/shared/messagehandler/RootMessageHandler.sys.mjs @@ -0,0 +1,154 @@ +/* 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, { + FrameTransport: + "chrome://remote/content/shared/messagehandler/transports/FrameTransport.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 { + #frameTransport; + #sessionData; + + /** + * Returns the RootMessageHandler module path. + * + * @return {String} + */ + static get modulePath() { + return "root"; + } + + /** + * Returns the RootMessageHandler type. + * + * @return {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.#frameTransport = new lazy.FrameTransport(this); + this.#sessionData = new lazy.SessionData(this); + } + + get sessionData() { + return this.#sessionData; + } + + destroy() { + this.#sessionData.destroy(); + 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. + */ + addSessionData(sessionData = {}) { + sessionData.method = lazy.SessionDataMethod.Add; + return this.updateSessionData([sessionData]); + } + + /** + * 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 + * FrameTransport. + * + * @param {Command} command + * The command to forward. See type definition in MessageHandler.js + * @return {Promise} + * Returns a promise that resolves with the result of the command. + */ + forwardCommand(command) { + switch (command.destination.type) { + case lazy.WindowGlobalMessageHandler.type: + return this.#frameTransport.forwardCommand(command); + default: + throw new Error( + `Cannot forward command to "${command.destination.type}" from "${this.constructor.type}".` + ); + } + } + + matchesContext() { + return true; + } + + /** + * 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. + */ + removeSessionData(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); + } +} 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..109bc81d1f --- /dev/null +++ b/remote/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs @@ -0,0 +1,120 @@ +/* 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"; + +/** + * 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; + + constructor() { + super(...arguments); + + this.#innerWindowId = this.context.window.windowGlobalChild.innerWindowId; + } + + /** + * Returns the WindowGlobalMessageHandler module path. + * + * @return {String} + */ + static get modulePath() { + return "windowglobal"; + } + + /** + * Returns the WindowGlobalMessageHandler type. + * + * @return {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. + * @return {String} + * The browsing context id. + */ + static getIdFromContext(context) { + return context.id; + } + + get innerWindowId() { + return this.#innerWindowId; + } + + get window() { + return this.context.window; + } + + async applyInitialSessionDataItems(sessionDataItems) { + if (!Array.isArray(sessionDataItems)) { + return; + } + + const destination = { + type: WindowGlobalMessageHandler.type, + }; + + const sessionDataPromises = sessionDataItems.map(sessionDataItem => { + const { moduleName, category, contextDescriptor } = sessionDataItem; + if (!this.matchesContext(contextDescriptor)) { + return Promise.resolve(); + } + + // Don't apply session data if the module is not present + // for the destination. + if (!this.moduleCache.hasModule(moduleName, destination)) { + return Promise.resolve(); + } + + return this.handleCommand({ + moduleName, + commandName: "_applySessionData", + params: { + category, + sessionData: sessionDataItems, + }, + destination, + }); + }); + + await Promise.all(sessionDataPromises); + + // 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, + }); + } + + forwardCommand(command) { + throw new Error( + `Cannot forward commands from a "WINDOW_GLOBAL" MessageHandler` + ); + } + + matchesContext(contextDescriptor) { + return ( + contextDescriptor.type === ContextDescriptorType.All || + (contextDescriptor.type === ContextDescriptorType.TopBrowsingContext && + contextDescriptor.id === this.context.browserId) + ); + } +} diff --git a/remote/shared/messagehandler/sessiondata/SessionData.sys.mjs b/remote/shared/messagehandler/sessiondata/SessionData.sys.mjs new file mode 100644 index 0000000000..a74aed6db0 --- /dev/null +++ b/remote/shared/messagehandler/sessiondata/SessionData.sys.mjs @@ -0,0 +1,390 @@ +/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +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", +}); + +XPCOMUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +/** + * @typedef {string} SessionDataCategory + **/ + +/** + * Enum of session data categories. + * + * @readonly + * @enum {SessionDataCategory} + **/ +export const SessionDataCategory = { + Event: "event", +}; + +/** + * @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. + * + * @return {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 + // dublicates 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. + * @return {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) && + 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 + ); + } + + _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..107ab82dc6 --- /dev/null +++ b/remote/shared/messagehandler/sessiondata/SessionDataReader.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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + SESSION_DATA_SHARED_DATA_KEY: + "chrome://remote/content/shared/messagehandler/sessiondata/SessionData.sys.mjs", +}); + +XPCOMUtils.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. + * + * @return {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.ini b/remote/shared/messagehandler/test/browser/broadcast/browser.ini new file mode 100644 index 0000000000..a2f989aaf0 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/broadcast/browser.ini @@ -0,0 +1,17 @@ +[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..74bc971850 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/broadcast/browser_filter_top_browsing_context.js @@ -0,0 +1,83 @@ +/* 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..7cb23e3309 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/broadcast/browser_with_frames.js @@ -0,0 +1,39 @@ +/* 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..7bbe96ae97 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/broadcast/head.js @@ -0,0 +1,53 @@ +/* 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"; + +/* import-globals-from ../head.js */ +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. + * @return {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 +) { + const { WindowGlobalMessageHandler } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs" + ); + + 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.ini b/remote/shared/messagehandler/test/browser/browser.ini new file mode 100644 index 0000000000..00cd152370 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/browser.ini @@ -0,0 +1,21 @@ +[DEFAULT] +tags = remote +subsuite = remote +support-files = + head.js + resources/* +prefs = + remote.messagehandler.modulecache.useBrowserTestRoot=true + +[browser_events_dispatcher.js] +[browser_events_handler.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_registry.js] +[browser_session_data.js] +[browser_session_data_broadcast.js] +[browser_session_data_browser_element.js] +[browser_session_data_constructor_race.js] 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..4f7a3ea203 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/browser_events_dispatcher.js @@ -0,0 +1,453 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { WindowGlobalMessageHandler } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs" +); + +/** + * 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); +}); + +/** + * 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..4898a5957b --- /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.addSessionData({ + 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_module.js b/remote/shared/messagehandler/test/browser/browser_events_module.js new file mode 100644 index 0000000000..4032559e9a --- /dev/null +++ b/remote/shared/messagehandler/test/browser/browser_events_module.js @@ -0,0 +1,283 @@ +/* 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" +); +const { WindowGlobalMessageHandler } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.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"); + + // 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"); + // MessageHandlerRegistry should forward all the message-handler-events. + const onRegistryEvent = RootMessageHandlerRegistry.once( + "message-handler-registry-event" + ); + + callTestEmitEvent(rootMessageHandler, browsingContext.id); + + const messageHandlerEvent = await onHandlerEvent; + 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" + ); + + const namedEvent = await onNamedEvent; + is( + namedEvent.text, + `event from ${browsingContext.id}`, + "Received the expected payload" + ); + + const registryEvent = await onRegistryEvent; + is( + registryEvent, + messageHandlerEvent, + "The event forwarded by the MessageHandlerRegistry is identical to the MessageHandler event" + ); + + 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..eec9ce048f --- /dev/null +++ b/remote/shared/messagehandler/test/browser/browser_frame_context_utils.js @@ -0,0 +1,95 @@ +/* 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/FrameContextUtils.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 FrameContextUtils 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 FrameContextUtils = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/transports/FrameContextUtils.sys.mjs" + ); + is( + FrameContextUtils.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..0a46255d89 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/browser_handle_command_errors.js @@ -0,0 +1,221 @@ +/* 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 { WindowGlobalMessageHandler } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.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"); + try { + await rootMessageHandler.handleCommand({ + moduleName: "commandwindowglobalonly", + commandName: "testError", + destination: { + type: WindowGlobalMessageHandler.type, + id: browsingContextId, + }, + }); + ok(false, "Error from window global module was not caught"); + } catch (e) { + ok(true, "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..1d26ee01ee --- /dev/null +++ b/remote/shared/messagehandler/test/browser/browser_handle_command_retry.js @@ -0,0 +1,243 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { WindowGlobalMessageHandler } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs" +); + +// 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); + + try { + await onBlockedOneTime; + ok("false", "onBlockedOneTime should not have resolved"); + } catch (e) { + is( + 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); + + try { + info( + "The call to blockedElevenTimes now exceeds the maximum attempts allowed" + ); + await onBlockedElevenTimes; + ok("false", "blockedElevenTimes should not have resolved"); + } catch (e) { + is( + 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..a5024e5cce --- /dev/null +++ b/remote/shared/messagehandler/test/browser/browser_handle_simple_command.js @@ -0,0 +1,206 @@ +/* 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 { WindowGlobalMessageHandler } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.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_registry.js b/remote/shared/messagehandler/test/browser/browser_registry.js new file mode 100644 index 0000000000..271597526e --- /dev/null +++ b/remote/shared/messagehandler/test/browser/browser_registry.js @@ -0,0 +1,38 @@ +/* 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..f5ba9d585c --- /dev/null +++ b/remote/shared/messagehandler/test/browser/browser_session_data.js @@ -0,0 +1,272 @@ +/* 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"); + + await BrowserTestUtils.loadURI( + gBrowser, + "https://example.com/document-builder.sjs?html=tab" + ); + + const windowGlobalCreated = rootMessageHandler.once("message-handler-event"); + + 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.addSessionData({ + 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.removeSessionData({ + 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_broadcast.js b/remote/shared/messagehandler/test/browser/browser_session_data_broadcast.js new file mode 100644 index 0000000000..8cfafc16a1 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/browser_session_data_broadcast.js @@ -0,0 +1,335 @@ +/* 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_PAGE = "https://example.com/document-builder.sjs?html=tab"; + +add_task(async function test_session_data_broadcast() { + const tab1 = gBrowser.selectedTab; + await loadURL(tab1.linkedBrowser, TEST_PAGE); + const browsingContext1 = tab1.linkedBrowser.browsingContext; + + const root = createRootMessageHandler("session-id-event"); + + info("Add a new session data item, expect one return value"); + const value1 = await addSessionData(root, ["text-1"]); + is(value1.length, 1); + is(value1[0].addedData, "text-1"); + is(value1[0].removedData, ""); + is(value1[0].sessionData, "text-1"); + is(value1[0].contextId, browsingContext1.id); + + info("Add two session data items, expect one return value with both items"); + const value2 = await addSessionData(root, ["text-2", "text-3"]); + is(value2.length, 1); + is(value2[0].addedData, "text-2, text-3"); + is(value2[0].removedData, ""); + is(value2[0].sessionData, "text-1, text-2, text-3"); + is(value2[0].contextId, browsingContext1.id); + + info("Try to add an existing data item, expect no return value"); + const value3 = await addSessionData(root, ["text-1"]); + is(value3.length, 0); + + info("Add an existing and a new item, expect only the new item to return"); + const value4 = await addSessionData(root, ["text-1", "text-4"]); + is(value4.length, 1); + is(value4[0].addedData, "text-4"); + is(value4[0].removedData, ""); + is(value4[0].sessionData, "text-1, text-2, text-3, text-4"); + is(value4[0].contextId, browsingContext1.id); + + info("Remove an item, expect only the new item to return"); + const value5 = await removeSessionData(root, ["text-3"]); + is(value5.length, 1); + is(value5[0].addedData, ""); + is(value5[0].removedData, "text-3"); + is(value5[0].sessionData, "text-1, text-2, text-4"); + is(value5[0].contextId, browsingContext1.id); + + info("Open a new tab on the same test URL"); + const tab2 = await addTab(TEST_PAGE); + const browsingContext2 = tab2.linkedBrowser.browsingContext; + + info("Add a new session data item, check both contexts have the same data."); + const value6 = await addSessionData(root, ["text-5"]); + is(value6.length, 2); + is(value6[0].addedData, "text-5"); + is(value6[0].removedData, ""); + is(value6[0].sessionData, "text-1, text-2, text-4, text-5"); + is(value6[0].contextId, browsingContext1.id); + is(value6[1].addedData, "text-5"); + // "text-1, text-2, text-4" were added as initial session data and + // "text-5" was added afterwards. + is(value6[1].sessionData, "text-1, text-2, text-4, text-5"); + is(value6[1].contextId, browsingContext2.id); + + info("Remove a missing item, expect no return value"); + const value7 = await removeSessionData(root, ["text-missing"]); + is(value7.length, 0); + + info("Remove an existing and a missing item"); + const value8 = await removeSessionData(root, ["text-2", "text-missing"]); + is(value8.length, 2); + is(value8[0].addedData, ""); + is(value8[0].removedData, "text-2"); + is(value8[0].sessionData, "text-1, text-4, text-5"); + is(value8[0].contextId, browsingContext1.id); + is(value8[1].addedData, ""); + is(value8[1].removedData, "text-2"); + is(value8[1].sessionData, "text-1, text-4, text-5"); + is(value8[1].contextId, browsingContext2.id); + + info("Add multiple items at once"); + const value9 = await updateSessionData(root, [ + { + method: "add", + values: ["text-6"], + moduleName: "command", + category: "testCategory", + contextDescriptor: { + type: ContextDescriptorType.All, + }, + }, + { + method: "add", + values: ["text-7"], + moduleName: "command", + category: "testCategory", + contextDescriptor: { + type: ContextDescriptorType.TopBrowsingContext, + id: browsingContext2.browserId, + }, + }, + ]); + is(value9.length, 2); + is(value9[0].addedData, "text-6"); + is(value9[0].removedData, ""); + is(value9[0].sessionData, "text-1, text-4, text-5, text-6"); + is(value9[0].contextId, browsingContext1.id); + is(value9[1].addedData, "text-6, text-7"); + is(value9[1].removedData, ""); + is(value9[1].sessionData, "text-1, text-4, text-5, text-6, text-7"); + is(value9[1].contextId, browsingContext2.id); + + info("Remove multiple items at once"); + const value10 = await updateSessionData(root, [ + { + method: "remove", + values: ["text-5"], + moduleName: "command", + category: "testCategory", + contextDescriptor: { + type: ContextDescriptorType.All, + }, + }, + { + method: "remove", + values: ["text-7"], + moduleName: "command", + category: "testCategory", + contextDescriptor: { + type: ContextDescriptorType.TopBrowsingContext, + id: browsingContext2.browserId, + }, + }, + ]); + is(value10.length, 2); + is(value10[0].addedData, ""); + is(value10[0].removedData, "text-5"); + is(value10[0].sessionData, "text-1, text-4, text-6"); + is(value10[0].contextId, browsingContext1.id); + is(value10[1].addedData, ""); + is(value10[1].removedData, "text-5, text-7"); + is(value10[1].sessionData, "text-1, text-4, text-6"); + is(value10[1].contextId, browsingContext2.id); + + info("Add and remove at once"); + const value11 = await updateSessionData(root, [ + { + method: "add", + values: ["text-8"], + moduleName: "command", + category: "testCategory", + contextDescriptor: { + type: ContextDescriptorType.All, + }, + }, + { + method: "remove", + values: ["text-6"], + moduleName: "command", + category: "testCategory", + contextDescriptor: { + type: ContextDescriptorType.All, + }, + }, + ]); + is(value11.length, 2); + is(value11[0].addedData, "text-8"); + is(value11[0].removedData, "text-6"); + is(value11[0].sessionData, "text-1, text-4, text-8"); + is(value11[0].contextId, browsingContext1.id); + is(value11[1].addedData, "text-8"); + is(value11[1].removedData, "text-6"); + is(value11[1].sessionData, "text-1, text-4, text-8"); + is(value11[1].contextId, browsingContext2.id); + + info( + "Add session data item to all contexts and remove this event for one context" + ); + // Add first an event for one context. + const value12 = await updateSessionData(root, [ + { + method: "add", + values: ["text-9"], + moduleName: "command", + category: "testCategory", + contextDescriptor: { + type: ContextDescriptorType.TopBrowsingContext, + id: browsingContext1.browserId, + }, + }, + ]); + is(value12.length, 1); + is(value12[0].addedData, "text-9"); + is(value12[0].removedData, ""); + is(value12[0].sessionData, "text-1, text-4, text-8, text-9"); + is(value12[0].contextId, browsingContext1.id); + + // Remove the item for one context and add the item for all contexts. + const value13 = await updateSessionData(root, [ + { + method: "remove", + values: ["text-9"], + moduleName: "command", + category: "testCategory", + contextDescriptor: { + type: ContextDescriptorType.TopBrowsingContext, + id: browsingContext1.browserId, + }, + }, + { + method: "add", + values: ["text-9"], + moduleName: "command", + category: "testCategory", + contextDescriptor: { + type: ContextDescriptorType.All, + }, + }, + ]); + is(value13.length, 2); + is(value13[0].addedData, ""); + // Make sure that nothing is removed. + is(value13[0].removedData, ""); + is(value13[0].sessionData, "text-1, text-4, text-8, text-9"); + is(value13[0].contextId, browsingContext1.id); + is(value13[1].addedData, "text-9"); + is(value13[1].removedData, ""); + is(value13[1].sessionData, "text-1, text-4, text-8, text-9"); + is(value13[1].contextId, browsingContext2.id); + + info( + "Remove the event, which has also an individual subscription, for all contexts." + ); + const value14 = await updateSessionData(root, [ + { + method: "add", + values: ["text-10"], + moduleName: "command", + category: "testCategory", + contextDescriptor: { + type: ContextDescriptorType.All, + }, + }, + { + method: "add", + values: ["text-10"], + moduleName: "command", + category: "testCategory", + contextDescriptor: { + type: ContextDescriptorType.TopBrowsingContext, + id: browsingContext1.browserId, + }, + }, + ]); + is(value14.length, 2); + is(value14[0].addedData, "text-10"); + is(value14[0].removedData, ""); + is(value14[0].sessionData, "text-1, text-4, text-8, text-9, text-10"); + is(value14[0].contextId, browsingContext1.id); + is(value14[1].addedData, "text-10"); + is(value14[1].removedData, ""); + is(value14[1].sessionData, "text-1, text-4, text-8, text-9, text-10"); + is(value14[1].contextId, browsingContext2.id); + + const value15 = await updateSessionData(root, [ + { + method: "remove", + values: ["text-10"], + moduleName: "command", + category: "testCategory", + contextDescriptor: { + type: ContextDescriptorType.All, + }, + }, + ]); + + is(value15.length, 2); + is(value15[0].addedData, ""); + // Make sure that nothing is removed for the first context + is(value15[0].removedData, ""); + is(value15[0].sessionData, "text-1, text-4, text-8, text-9, text-10"); + is(value15[0].contextId, browsingContext1.id); + is(value15[1].addedData, ""); + is(value15[1].removedData, "text-10"); + is(value15[1].sessionData, "text-1, text-4, text-8, text-9"); + is(value15[1].contextId, browsingContext2.id); + + root.destroy(); + + gBrowser.removeTab(tab2); +}); + +function addSessionData(rootMessageHandler, values) { + return rootMessageHandler.handleCommand({ + moduleName: "command", + commandName: "testAddSessionData", + destination: { + type: RootMessageHandler.type, + }, + params: { + values, + }, + }); +} + +function removeSessionData(rootMessageHandler, values) { + return rootMessageHandler.handleCommand({ + moduleName: "command", + commandName: "testRemoveSessionData", + destination: { + type: RootMessageHandler.type, + }, + params: { + values, + }, + }); +} + +function updateSessionData(rootMessageHandler, params) { + return rootMessageHandler.handleCommand({ + moduleName: "command", + commandName: "testUpdateSessionData", + destination: { + type: RootMessageHandler.type, + }, + params, + }); +} 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..62c51b29a8 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/browser_session_data_browser_element.js @@ -0,0 +1,98 @@ +/* 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.addSessionData({ + 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..e71c638ca3 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/browser_session_data_constructor_race.js @@ -0,0 +1,54 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { WindowGlobalMessageHandler } = ChromeUtils.importESModule( + "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs" +); + +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.addSessionData({ + 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/head.js b/remote/shared/messagehandler/test/browser/head.js new file mode 100644 index 0000000000..825db7afef --- /dev/null +++ b/remote/shared/messagehandler/test/browser/head.js @@ -0,0 +1,186 @@ +/* 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 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.loadURI(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 {xul:tab} 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". + * @return {xul:browser} + * 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. + * + * @return {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, + }; +} 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..e5d9c38719 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/resources/modules/ModuleRegistry.sys.mjs @@ -0,0 +1,30 @@ +/* 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/. */ + +/** + * Retrieve the WebDriver BiDi module class matching the provided module name + * and folder. + * + * @param {String} moduleName + * The name of the module to get the class for. + * @param {String} moduleFolder + * A valid folder name for modules. + * @return {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. + **/ +export const getModuleClass = function(moduleName, moduleFolder) { + const root = `chrome://mochitests/content/browser/remote/shared/messagehandler/test/`; + const path = `${root}browser/resources/modules/${moduleFolder}/${moduleName}.sys.mjs`; + try { + return ChromeUtils.importESModule(path)[moduleName]; + } catch (e) { + if (e.result == Cr.NS_ERROR_FILE_NOT_FOUND) { + return null; + } + throw e; + } +}; 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..a67428ddfa --- /dev/null +++ b/remote/shared/messagehandler/test/browser/resources/modules/root/command.sys.mjs @@ -0,0 +1,80 @@ +/* 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"; +import { WindowGlobalMessageHandler } from "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs"; + +class CommandModule extends Module { + destroy() {} + + async #getSessionDataUpdateFromAllContexts() { + const updates = await this.messageHandler.handleCommand({ + moduleName: "command", + commandName: "_getSessionDataUpdate", + destination: { + contextDescriptor: { + type: ContextDescriptorType.All, + }, + type: WindowGlobalMessageHandler.type, + }, + }); + + // Filter out null values, which indicate that no new session data was + // received by the windowglobal module since the last getSessionDataUpdate + // command. + return updates.filter(update => update != null); + } + + /** + * Commands + */ + + async testAddSessionData(params) { + await this.messageHandler.addSessionData({ + moduleName: "command", + category: "testCategory", + contextDescriptor: { + type: ContextDescriptorType.All, + }, + values: params.values, + }); + + return this.#getSessionDataUpdateFromAllContexts(); + } + + async testRemoveSessionData(params) { + await this.messageHandler.removeSessionData({ + moduleName: "command", + category: "testCategory", + contextDescriptor: { + type: ContextDescriptorType.All, + }, + values: params.values, + }); + + return this.#getSessionDataUpdateFromAllContexts(); + } + + async testUpdateSessionData(params) { + await this.messageHandler.updateSessionData(params); + return this.#getSessionDataUpdateFromAllContexts(); + } + + 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/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..2969e0a3e8 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal-in-root/event.sys.mjs @@ -0,0 +1,31 @@ +/* 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", + }; + } + 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..23655a464a --- /dev/null +++ b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/command.sys.mjs @@ -0,0 +1,114 @@ +/* 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._lastSessionDataUpdate = {}; + this._subscribedEvents = new Set(); + this._testCategorySessionData = []; + + this._createdByMessageHandlerConstructor = this._isCreatedByMessageHandlerConstructor(); + } + destroy() {} + + /** + * Commands + */ + + _applySessionData(params) { + if (params.category === "testCategory") { + 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._testCategorySessionData = this._testCategorySessionData + .concat(added) + .filter(value => !removed.includes(value)); + + this._lastSessionDataUpdate = { + addedData: added.join(", "), + removedData: removed.join(", "), + sessionData: this._testCategorySessionData.join(", "), + contextId: this.messageHandler.contextId, + }; + } + + if (params.category === "browser_session_data_browser_element") { + this.emitEvent("received-session-data", { + contextId: this.messageHandler.contextId, + }); + } + + return {}; + } + + _getSessionDataUpdate(params) { + const lastUpdate = this._lastSessionDataUpdate; + + // Each "lastUpdate" should only be returned once, so that the caller can + // assert when a SessionData update had no impact. + this._lastSessionDataUpdate = null; + + return lastUpdate; + } + + 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..5bb50cb83d --- /dev/null +++ b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/event.sys.mjs @@ -0,0 +1,26 @@ +/* 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 }); + } + + 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/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/webdriver/browser.ini b/remote/shared/messagehandler/test/browser/webdriver/browser.ini new file mode 100644 index 0000000000..a7ece66163 --- /dev/null +++ b/remote/shared/messagehandler/test/browser/webdriver/browser.ini @@ -0,0 +1,9 @@ +[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..33cfb5cfd7 --- /dev/null +++ b/remote/shared/messagehandler/test/xpcshell/test_Errors.js @@ -0,0 +1,99 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +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_test(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); + + run_next_test(); +}); + +add_test(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); + + run_next_test(); +}); + +add_test(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); + + run_next_test(); +}); + +add_test(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); + + run_next_test(); +}); 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.ini b/remote/shared/messagehandler/test/xpcshell/xpcshell.ini new file mode 100644 index 0000000000..9199459e0a --- /dev/null +++ b/remote/shared/messagehandler/test/xpcshell/xpcshell.ini @@ -0,0 +1,6 @@ +# 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/. + +[test_Errors.js] +[test_SessionData.js] diff --git a/remote/shared/messagehandler/transports/FrameContextUtils.sys.mjs b/remote/shared/messagehandler/transports/FrameContextUtils.sys.mjs new file mode 100644 index 0000000000..341e7918ee --- /dev/null +++ b/remote/shared/messagehandler/transports/FrameContextUtils.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). + * @return {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/FrameTransport.sys.mjs b/remote/shared/messagehandler/transports/FrameTransport.sys.mjs new file mode 100644 index 0000000000..e2d48e00f4 --- /dev/null +++ b/remote/shared/messagehandler/transports/FrameTransport.sys.mjs @@ -0,0 +1,191 @@ +/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ContextDescriptorType: + "chrome://remote/content/shared/messagehandler/MessageHandler.sys.mjs", + isBrowsingContextCompatible: + "chrome://remote/content/shared/messagehandler/transports/FrameContextUtils.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", +}); + +XPCOMUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +const MAX_RETRY_ATTEMPTS = 10; + +/** + * FrameTransport is intended to be used from a ROOT MessageHandler to communicate + * with WINDOW_GLOBAL MessageHandlers via the MessageHandlerFrame JSWindow + * actors. + */ +export class FrameTransport { + /** + * @param {MessageHandler} + * The MessageHandler instance which owns this FrameTransport instance. + */ + constructor(messageHandler) { + this._messageHandler = messageHandler; + + // FrameTransport will rely on the MessageHandlerFrame JSWindow actors. + // Make sure they are registered when instanciating a FrameTransport. + 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 + * @return {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( + `FrameTransport reached the limit of retry attempts (${MAX_RETRY_ATTEMPTS})` + + ` for command ${name} and browsing context ${webProgress.browsingContext.id}.` + ); + throw e; + } + + lazy.logger.trace( + `FrameTransport 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). + * @return {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..b0ec801098 --- /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/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ActorManagerParent: "resource://gre/modules/ActorManagerParent.sys.mjs", + + Log: "chrome://remote/content/shared/Log.sys.mjs", +}); + +XPCOMUtils.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: {}, + }, + }, + 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..4f0430df1b --- /dev/null +++ b/remote/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameChild.sys.mjs @@ -0,0 +1,77 @@ +/* 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/FrameContextUtils.sys.mjs", + MessageHandlerRegistry: + "chrome://remote/content/shared/messagehandler/MessageHandlerRegistry.sys.mjs", + WindowGlobalMessageHandler: + "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs", +}); + +/** + * Child actor for the MessageHandlerFrame JSWindowActor. The + * MessageHandlerFrame actor is used by FrameTransport 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); + 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({ type }) { + if (type == "DOMWindowCreated") { + if (lazy.isBrowsingContextCompatible(this.manager.browsingContext)) { + this._registry.createAllMessageHandlers(); + } + } + } + + 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; + } + + _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..9524fb08a5 --- /dev/null +++ b/remote/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameParent.sys.mjs @@ -0,0 +1,99 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + error: "chrome://remote/content/shared/messagehandler/Errors.sys.mjs", + RootMessageHandlerRegistry: + "chrome://remote/content/shared/messagehandler/RootMessageHandlerRegistry.sys.mjs", + WindowGlobalMessageHandler: + "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs", +}); + +XPCOMUtils.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 FrameTransport to communicate between + * ROOT MessageHandlers and WINDOW_GLOBAL MessageHandlers. + */ +export class MessageHandlerFrameParent extends JSWindowActorParent { + async receiveMessage(message) { + switch (message.name) { + case "MessageHandlerFrameChild:messageHandlerEvent": + const { name, contextInfo, data, sessionId } = message.data; + 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); + + // 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); + + break; + 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. + * @return {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; + } +} |