summaryrefslogtreecommitdiffstats
path: root/remote/shared
diff options
context:
space:
mode:
Diffstat (limited to 'remote/shared')
-rw-r--r--remote/shared/AppInfo.sys.mjs78
-rw-r--r--remote/shared/Browser.sys.mjs102
-rw-r--r--remote/shared/Capture.sys.mjs203
-rw-r--r--remote/shared/ChallengeHeaderParser.sys.mjs74
-rw-r--r--remote/shared/DOM.sys.mjs1219
-rw-r--r--remote/shared/Format.sys.mjs186
-rw-r--r--remote/shared/Log.sys.mjs71
-rw-r--r--remote/shared/MobileTabBrowser.sys.mjs86
-rw-r--r--remote/shared/Navigate.sys.mjs435
-rw-r--r--remote/shared/NavigationManager.sys.mjs414
-rw-r--r--remote/shared/PDF.sys.mjs244
-rw-r--r--remote/shared/Prompt.sys.mjs233
-rw-r--r--remote/shared/Realm.sys.mjs382
-rw-r--r--remote/shared/RecommendedPreferences.sys.mjs440
-rw-r--r--remote/shared/RemoteError.sys.mjs19
-rw-r--r--remote/shared/Stack.sys.mjs73
-rw-r--r--remote/shared/Sync.sys.mjs335
-rw-r--r--remote/shared/TabManager.sys.mjs455
-rw-r--r--remote/shared/UUID.sys.mjs14
-rw-r--r--remote/shared/UserContextManager.sys.mjs214
-rw-r--r--remote/shared/WebSocketConnection.sys.mjs171
-rw-r--r--remote/shared/WindowManager.sys.mjs288
-rw-r--r--remote/shared/js-window-actors/NavigationListenerActor.sys.mjs80
-rw-r--r--remote/shared/js-window-actors/NavigationListenerChild.sys.mjs167
-rw-r--r--remote/shared/js-window-actors/NavigationListenerParent.sys.mjs58
-rw-r--r--remote/shared/listeners/BrowsingContextListener.sys.mjs122
-rw-r--r--remote/shared/listeners/ConsoleAPIListener.sys.mjs124
-rw-r--r--remote/shared/listeners/ConsoleListener.sys.mjs154
-rw-r--r--remote/shared/listeners/ContextualIdentityListener.sys.mjs85
-rw-r--r--remote/shared/listeners/LoadListener.sys.mjs103
-rw-r--r--remote/shared/listeners/NavigationListener.sys.mjs90
-rw-r--r--remote/shared/listeners/NetworkEventRecord.sys.mjs455
-rw-r--r--remote/shared/listeners/NetworkListener.sys.mjs109
-rw-r--r--remote/shared/listeners/PromptListener.sys.mjs285
-rw-r--r--remote/shared/listeners/test/browser/browser.toml21
-rw-r--r--remote/shared/listeners/test/browser/browser_BrowsingContextListener.js117
-rw-r--r--remote/shared/listeners/test/browser/browser_ConsoleAPIListener.js162
-rw-r--r--remote/shared/listeners/test/browser/browser_ConsoleAPIListener_cached_messages.js100
-rw-r--r--remote/shared/listeners/test/browser/browser_ConsoleListener.js148
-rw-r--r--remote/shared/listeners/test/browser/browser_ConsoleListener_cached_messages.js82
-rw-r--r--remote/shared/listeners/test/browser/browser_ContextualIdentityListener.js38
-rw-r--r--remote/shared/listeners/test/browser/browser_NetworkListener.js100
-rw-r--r--remote/shared/listeners/test/browser/browser_PromptListener.js173
-rw-r--r--remote/shared/listeners/test/browser/head.js89
-rw-r--r--remote/shared/messagehandler/Errors.sys.mjs90
-rw-r--r--remote/shared/messagehandler/EventsDispatcher.sys.mjs260
-rw-r--r--remote/shared/messagehandler/MessageHandler.sys.mjs355
-rw-r--r--remote/shared/messagehandler/MessageHandlerRegistry.sys.mjs236
-rw-r--r--remote/shared/messagehandler/Module.sys.mjs135
-rw-r--r--remote/shared/messagehandler/ModuleCache.sys.mjs263
-rw-r--r--remote/shared/messagehandler/RootMessageHandler.sys.mjs237
-rw-r--r--remote/shared/messagehandler/RootMessageHandlerRegistry.sys.mjs17
-rw-r--r--remote/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs264
-rw-r--r--remote/shared/messagehandler/sessiondata/SessionData.sys.mjs392
-rw-r--r--remote/shared/messagehandler/sessiondata/SessionDataReader.sys.mjs27
-rw-r--r--remote/shared/messagehandler/test/browser/broadcast/browser.toml22
-rw-r--r--remote/shared/messagehandler/test/browser/broadcast/browser_filter_top_browsing_context.js84
-rw-r--r--remote/shared/messagehandler/test/browser/broadcast/browser_only_content_process.js46
-rw-r--r--remote/shared/messagehandler/test/browser/broadcast/browser_two_tabs.js46
-rw-r--r--remote/shared/messagehandler/test/browser/broadcast/browser_two_tabs_with_params.js47
-rw-r--r--remote/shared/messagehandler/test/browser/broadcast/browser_two_windows.js47
-rw-r--r--remote/shared/messagehandler/test/browser/broadcast/browser_with_frames.js40
-rw-r--r--remote/shared/messagehandler/test/browser/broadcast/doc_messagehandler_broadcasting_xul.xhtml3
-rw-r--r--remote/shared/messagehandler/test/browser/broadcast/head.js48
-rw-r--r--remote/shared/messagehandler/test/browser/browser.toml46
-rw-r--r--remote/shared/messagehandler/test/browser/browser_bfcache.js98
-rw-r--r--remote/shared/messagehandler/test/browser/browser_events_dispatcher.js532
-rw-r--r--remote/shared/messagehandler/test/browser/browser_events_handler.js57
-rw-r--r--remote/shared/messagehandler/test/browser/browser_events_interception.js112
-rw-r--r--remote/shared/messagehandler/test/browser/browser_events_module.js296
-rw-r--r--remote/shared/messagehandler/test/browser/browser_frame_context_utils.js98
-rw-r--r--remote/shared/messagehandler/test/browser/browser_handle_command_errors.js218
-rw-r--r--remote/shared/messagehandler/test/browser/browser_handle_command_retry.js229
-rw-r--r--remote/shared/messagehandler/test/browser/browser_handle_simple_command.js203
-rw-r--r--remote/shared/messagehandler/test/browser/browser_navigation_manager.js59
-rw-r--r--remote/shared/messagehandler/test/browser/browser_realms.js152
-rw-r--r--remote/shared/messagehandler/test/browser/browser_registry.js37
-rw-r--r--remote/shared/messagehandler/test/browser/browser_session_data.js273
-rw-r--r--remote/shared/messagehandler/test/browser/browser_session_data_browser_element.js94
-rw-r--r--remote/shared/messagehandler/test/browser/browser_session_data_constructor_race.js50
-rw-r--r--remote/shared/messagehandler/test/browser/browser_session_data_update.js113
-rw-r--r--remote/shared/messagehandler/test/browser/browser_session_data_update_categories.js91
-rw-r--r--remote/shared/messagehandler/test/browser/browser_session_data_update_contexts.js194
-rw-r--r--remote/shared/messagehandler/test/browser/browser_windowglobal_to_root.js47
-rw-r--r--remote/shared/messagehandler/test/browser/head.js236
-rw-r--r--remote/shared/messagehandler/test/browser/resources/modules/ModuleRegistry.sys.mjs40
-rw-r--r--remote/shared/messagehandler/test/browser/resources/modules/root/command.sys.mjs29
-rw-r--r--remote/shared/messagehandler/test/browser/resources/modules/root/event.sys.mjs21
-rw-r--r--remote/shared/messagehandler/test/browser/resources/modules/root/invalid.sys.mjs4
-rw-r--r--remote/shared/messagehandler/test/browser/resources/modules/root/rootOnly.sys.mjs70
-rw-r--r--remote/shared/messagehandler/test/browser/resources/modules/root/windowglobaltoroot.sys.mjs29
-rw-r--r--remote/shared/messagehandler/test/browser/resources/modules/windowglobal-in-root/command.sys.mjs28
-rw-r--r--remote/shared/messagehandler/test/browser/resources/modules/windowglobal-in-root/event.sys.mjs39
-rw-r--r--remote/shared/messagehandler/test/browser/resources/modules/windowglobal/command.sys.mjs85
-rw-r--r--remote/shared/messagehandler/test/browser/resources/modules/windowglobal/commandwindowglobalonly.sys.mjs41
-rw-r--r--remote/shared/messagehandler/test/browser/resources/modules/windowglobal/event.sys.mjs32
-rw-r--r--remote/shared/messagehandler/test/browser/resources/modules/windowglobal/eventemitter.sys.mjs81
-rw-r--r--remote/shared/messagehandler/test/browser/resources/modules/windowglobal/eventnointercept.sys.mjs16
-rw-r--r--remote/shared/messagehandler/test/browser/resources/modules/windowglobal/eventonprefchange.sys.mjs33
-rw-r--r--remote/shared/messagehandler/test/browser/resources/modules/windowglobal/retry.sys.mjs84
-rw-r--r--remote/shared/messagehandler/test/browser/resources/modules/windowglobal/sessiondataupdate.sys.mjs33
-rw-r--r--remote/shared/messagehandler/test/browser/resources/modules/windowglobal/windowglobaltoroot.sys.mjs47
-rw-r--r--remote/shared/messagehandler/test/browser/webdriver/browser.toml7
-rw-r--r--remote/shared/messagehandler/test/browser/webdriver/browser_session_execute_command_errors.js40
-rw-r--r--remote/shared/messagehandler/test/xpcshell/test_Errors.js91
-rw-r--r--remote/shared/messagehandler/test/xpcshell/test_SessionData.js296
-rw-r--r--remote/shared/messagehandler/test/xpcshell/xpcshell.toml5
-rw-r--r--remote/shared/messagehandler/transports/BrowsingContextUtils.sys.mjs57
-rw-r--r--remote/shared/messagehandler/transports/RootTransport.sys.mjs188
-rw-r--r--remote/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameActor.sys.mjs51
-rw-r--r--remote/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameChild.sys.mjs111
-rw-r--r--remote/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameParent.sys.mjs127
-rw-r--r--remote/shared/moz.build17
-rw-r--r--remote/shared/test/browser/browser.toml16
-rw-r--r--remote/shared/test/browser/browser_NavigationManager.js372
-rw-r--r--remote/shared/test/browser/browser_NavigationManager_failed_navigation.js99
-rw-r--r--remote/shared/test/browser/browser_NavigationManager_no_navigation.js60
-rw-r--r--remote/shared/test/browser/browser_NavigationManager_notify.js170
-rw-r--r--remote/shared/test/browser/browser_TabManager.js178
-rw-r--r--remote/shared/test/browser/browser_UserContextManager.js236
-rw-r--r--remote/shared/test/browser/head.js205
-rw-r--r--remote/shared/test/xpcshell/head.js3
-rw-r--r--remote/shared/test/xpcshell/test_AppInfo.js53
-rw-r--r--remote/shared/test/xpcshell/test_ChallengeHeaderParser.js140
-rw-r--r--remote/shared/test/xpcshell/test_DOM.js479
-rw-r--r--remote/shared/test/xpcshell/test_Format.js108
-rw-r--r--remote/shared/test/xpcshell/test_Navigate.js879
-rw-r--r--remote/shared/test/xpcshell/test_Realm.js116
-rw-r--r--remote/shared/test/xpcshell/test_RecommendedPreferences.js118
-rw-r--r--remote/shared/test/xpcshell/test_Stack.js120
-rw-r--r--remote/shared/test/xpcshell/test_Sync.js436
-rw-r--r--remote/shared/test/xpcshell/test_TabManager.js56
-rw-r--r--remote/shared/test/xpcshell/test_UUID.js21
-rw-r--r--remote/shared/test/xpcshell/xpcshell.toml24
-rw-r--r--remote/shared/webdriver/Actions.sys.mjs2376
-rw-r--r--remote/shared/webdriver/Assert.sys.mjs489
-rw-r--r--remote/shared/webdriver/Capabilities.sys.mjs1061
-rw-r--r--remote/shared/webdriver/Errors.sys.mjs881
-rw-r--r--remote/shared/webdriver/KeyData.sys.mjs338
-rw-r--r--remote/shared/webdriver/NodeCache.sys.mjs179
-rw-r--r--remote/shared/webdriver/Session.sys.mjs418
-rw-r--r--remote/shared/webdriver/URLPattern.sys.mjs521
-rw-r--r--remote/shared/webdriver/process-actors/WebDriverProcessDataChild.sys.mjs93
-rw-r--r--remote/shared/webdriver/process-actors/WebDriverProcessDataParent.sys.mjs37
-rw-r--r--remote/shared/webdriver/test/xpcshell/head.js15
-rw-r--r--remote/shared/webdriver/test/xpcshell/test_Actions.js758
-rw-r--r--remote/shared/webdriver/test/xpcshell/test_Assert.js183
-rw-r--r--remote/shared/webdriver/test/xpcshell/test_Capabilities.js700
-rw-r--r--remote/shared/webdriver/test/xpcshell/test_Errors.js543
-rw-r--r--remote/shared/webdriver/test/xpcshell/test_NodeCache.js265
-rw-r--r--remote/shared/webdriver/test/xpcshell/test_Session.js72
-rw-r--r--remote/shared/webdriver/test/xpcshell/test_URLPattern_invalid.js129
-rw-r--r--remote/shared/webdriver/test/xpcshell/test_URLPattern_matchURLPattern.js607
-rw-r--r--remote/shared/webdriver/test/xpcshell/test_URLPattern_parseURLPattern.js369
-rw-r--r--remote/shared/webdriver/test/xpcshell/xpcshell.toml20
155 files changed, 30137 insertions, 0 deletions
diff --git a/remote/shared/AppInfo.sys.mjs b/remote/shared/AppInfo.sys.mjs
new file mode 100644
index 0000000000..9e354503ef
--- /dev/null
+++ b/remote/shared/AppInfo.sys.mjs
@@ -0,0 +1,78 @@
+/* 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 { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+const ID_FIREFOX = "{ec8030f7-c20a-464f-9b0e-13a3a9e97384}";
+const ID_THUNDERBIRD = "{3550f703-e582-4d05-9a08-453d09bdfdc6}";
+
+/**
+ * Extends Services.appinfo with further properties that are
+ * used by different protocols as handled by the Remote Agent.
+ *
+ * @typedef {object} RemoteAgent.AppInfo
+ * @property {boolean} isAndroid - Whether the application runs on Android.
+ * @property {boolean} isLinux - Whether the application runs on Linux.
+ * @property {boolean} isMac - Whether the application runs on Mac OS.
+ * @property {boolean} isWindows - Whether the application runs on Windows.
+ * @property {boolean} isFirefox - Whether the application is Firefox.
+ * @property {boolean} isThunderbird - Whether the application is Thunderbird.
+ *
+ * @since 88
+ */
+export const AppInfo = new Proxy(
+ {},
+ {
+ get(target, prop, receiver) {
+ if (target.hasOwnProperty(prop)) {
+ return target[prop];
+ }
+
+ return Services.appinfo[prop];
+ },
+ }
+);
+
+// Platform support
+
+ChromeUtils.defineLazyGetter(AppInfo, "isAndroid", () => {
+ return Services.appinfo.OS === "Android";
+});
+
+ChromeUtils.defineLazyGetter(AppInfo, "isLinux", () => {
+ return Services.appinfo.OS === "Linux";
+});
+
+ChromeUtils.defineLazyGetter(AppInfo, "isMac", () => {
+ return Services.appinfo.OS === "Darwin";
+});
+
+ChromeUtils.defineLazyGetter(AppInfo, "isWindows", () => {
+ return Services.appinfo.OS === "WINNT";
+});
+
+// Application type
+
+ChromeUtils.defineLazyGetter(AppInfo, "isFirefox", () => {
+ return Services.appinfo.ID == ID_FIREFOX;
+});
+
+ChromeUtils.defineLazyGetter(AppInfo, "isThunderbird", () => {
+ return Services.appinfo.ID == ID_THUNDERBIRD;
+});
+
+export function getTimeoutMultiplier() {
+ if (
+ AppConstants.DEBUG ||
+ AppConstants.MOZ_CODE_COVERAGE ||
+ AppConstants.ASAN
+ ) {
+ return 4;
+ }
+ if (AppConstants.TSAN) {
+ return 8;
+ }
+
+ return 1;
+}
diff --git a/remote/shared/Browser.sys.mjs b/remote/shared/Browser.sys.mjs
new file mode 100644
index 0000000000..c8bff1f55a
--- /dev/null
+++ b/remote/shared/Browser.sys.mjs
@@ -0,0 +1,102 @@
+/* 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, {
+ pprint: "chrome://remote/content/shared/Format.sys.mjs",
+ waitForObserverTopic: "chrome://remote/content/marionette/sync.sys.mjs",
+});
+
+/**
+ * Quits the application with the provided flags.
+ *
+ * Optional {@link nsIAppStartup} flags may be provided as
+ * an array of masks, and these will be combined by ORing
+ * them with a bitmask. The available masks are defined in
+ * https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM/Reference/Interface/nsIAppStartup.
+ *
+ * Crucially, only one of the *Quit flags can be specified. The |eRestart|
+ * flag may be bit-wise combined with one of the *Quit flags to cause
+ * the application to restart after it quits.
+ *
+ * @param {Array.<string>=} flags
+ * Constant name of masks to pass to |Services.startup.quit|.
+ * If empty or undefined, |nsIAppStartup.eAttemptQuit| is used.
+ * @param {boolean=} safeMode
+ * Optional flag to indicate that the application has to
+ * be restarted in safe mode.
+ * @param {boolean=} isWindowless
+ * Optional flag to indicate that the browser was started in windowless mode.
+ *
+ * @returns {Object<string,boolean>}
+ * Dictionary containing information that explains the shutdown reason.
+ * The value for `cause` contains the shutdown kind like "shutdown" or
+ * "restart", while `forced` will indicate if it was a normal or forced
+ * shutdown of the application. "in_app" is always set to indicate that
+ * it is a shutdown triggered from within the application.
+ */
+export async function quit(flags = [], safeMode = false, isWindowless = false) {
+ if (flags.includes("eSilently")) {
+ if (!isWindowless) {
+ throw new Error(
+ `Silent restarts only allowed with "moz:windowless" capability set`
+ );
+ }
+ if (!flags.includes("eRestart")) {
+ throw new TypeError(`"silently" only works with restart flag`);
+ }
+ }
+
+ const quits = ["eConsiderQuit", "eAttemptQuit", "eForceQuit"];
+
+ let quitSeen;
+ let mode = 0;
+ if (flags.length) {
+ for (let k of flags) {
+ if (!(k in Ci.nsIAppStartup)) {
+ throw new TypeError(lazy.pprint`Expected ${k} in ${Ci.nsIAppStartup}`);
+ }
+
+ if (quits.includes(k)) {
+ if (quitSeen) {
+ throw new TypeError(`${k} cannot be combined with ${quitSeen}`);
+ }
+ quitSeen = k;
+ }
+
+ mode |= Ci.nsIAppStartup[k];
+ }
+ }
+
+ if (!quitSeen) {
+ mode |= Ci.nsIAppStartup.eAttemptQuit;
+ }
+
+ // Notify all windows that an application quit has been requested.
+ const cancelQuit = Cc["@mozilla.org/supports-PRBool;1"].createInstance(
+ Ci.nsISupportsPRBool
+ );
+ Services.obs.notifyObservers(cancelQuit, "quit-application-requested");
+
+ // If the shutdown of the application is prevented force quit it instead.
+ if (cancelQuit.data) {
+ mode |= Ci.nsIAppStartup.eForceQuit;
+ }
+
+ // Delay response until the application is about to quit.
+ const quitApplication = lazy.waitForObserverTopic("quit-application");
+
+ if (safeMode) {
+ Services.startup.restartInSafeMode(mode);
+ } else {
+ Services.startup.quit(mode);
+ }
+
+ return {
+ cause: (await quitApplication).data,
+ forced: cancelQuit.data,
+ in_app: true,
+ };
+}
diff --git a/remote/shared/Capture.sys.mjs b/remote/shared/Capture.sys.mjs
new file mode 100644
index 0000000000..ec34d09aba
--- /dev/null
+++ b/remote/shared/Capture.sys.mjs
@@ -0,0 +1,203 @@
+/* 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, {
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
+
+const CONTEXT_2D = "2d";
+const BG_COLOUR = "rgb(255,255,255)";
+const MAX_CANVAS_DIMENSION = 32767;
+const MAX_CANVAS_AREA = 472907776;
+const PNG_MIME = "image/png";
+const XHTML_NS = "http://www.w3.org/1999/xhtml";
+
+/**
+ * Provides primitives to capture screenshots.
+ *
+ * @namespace
+ */
+export const capture = {};
+
+capture.Format = {
+ Base64: 0,
+ Hash: 1,
+};
+
+/**
+ * Draw a rectangle off the framebuffer.
+ *
+ * @param {DOMWindow} win
+ * The DOM window used for the framebuffer, and providing the interfaces
+ * for creating an HTMLCanvasElement.
+ * @param {BrowsingContext} browsingContext
+ * The BrowsingContext from which the snapshot should be taken.
+ * @param {number} left
+ * The left, X axis offset of the rectangle.
+ * @param {number} top
+ * The top, Y axis offset of the rectangle.
+ * @param {number} width
+ * The width dimension of the rectangle to paint.
+ * @param {number} height
+ * The height dimension of the rectangle to paint.
+ * @param {object=} options
+ * @param {HTMLCanvasElement=} options.canvas
+ * Optional canvas to reuse for the screenshot.
+ * @param {number=} options.flags
+ * Optional integer representing flags to pass to drawWindow; these
+ * are defined on CanvasRenderingContext2D.
+ * @param {number=} options.dX
+ * Horizontal offset between the browser window and content area. Defaults to 0.
+ * @param {number=} options.dY
+ * Vertical offset between the browser window and content area. Defaults to 0.
+ * @param {boolean=} options.readback
+ * If true, read back a snapshot of the pixel data currently in the
+ * compositor/window. Defaults to false.
+ *
+ * @returns {HTMLCanvasElement}
+ * The canvas on which the selection from the window's framebuffer
+ * has been painted on.
+ */
+capture.canvas = async function (
+ win,
+ browsingContext,
+ left,
+ top,
+ width,
+ height,
+ { canvas = null, flags = null, dX = 0, dY = 0, readback = false } = {}
+) {
+ // FIXME(bug 1761032): This looks a bit sketchy, overrideDPPX doesn't
+ // influence rendering...
+ const scale = win.browsingContext.overrideDPPX || win.devicePixelRatio;
+
+ let canvasHeight = height * scale;
+ let canvasWidth = width * scale;
+
+ // Cap the screenshot size for width and height at 2^16 pixels,
+ // which is the maximum allowed canvas size. Higher dimensions will
+ // trigger exceptions in Gecko.
+ if (canvasWidth > MAX_CANVAS_DIMENSION) {
+ lazy.logger.warn(
+ "Limiting screen capture width to maximum allowed " +
+ MAX_CANVAS_DIMENSION +
+ " pixels"
+ );
+ width = Math.floor(MAX_CANVAS_DIMENSION / scale);
+ canvasWidth = width * scale;
+ }
+
+ if (canvasHeight > MAX_CANVAS_DIMENSION) {
+ lazy.logger.warn(
+ "Limiting screen capture height to maximum allowed " +
+ MAX_CANVAS_DIMENSION +
+ " pixels"
+ );
+ height = Math.floor(MAX_CANVAS_DIMENSION / scale);
+ canvasHeight = height * scale;
+ }
+
+ // If the area is larger, reduce the height to keep the full width.
+ if (canvasWidth * canvasHeight > MAX_CANVAS_AREA) {
+ lazy.logger.warn(
+ "Limiting screen capture area to maximum allowed " +
+ MAX_CANVAS_AREA +
+ " pixels"
+ );
+ height = Math.floor(MAX_CANVAS_AREA / (canvasWidth * scale));
+ canvasHeight = height * scale;
+ }
+
+ if (canvas === null) {
+ canvas = win.document.createElementNS(XHTML_NS, "canvas");
+ canvas.width = canvasWidth;
+ canvas.height = canvasHeight;
+ }
+
+ const ctx = canvas.getContext(CONTEXT_2D);
+
+ if (readback) {
+ if (flags === null) {
+ flags =
+ ctx.DRAWWINDOW_DRAW_CARET |
+ ctx.DRAWWINDOW_DRAW_VIEW |
+ ctx.DRAWWINDOW_USE_WIDGET_LAYERS;
+ }
+
+ // drawWindow doesn't take scaling into account.
+ ctx.scale(scale, scale);
+ ctx.drawWindow(win, left + dX, top + dY, width, height, BG_COLOUR, flags);
+ } else {
+ let rect = new DOMRect(left, top, width, height);
+ let snapshot = await browsingContext.currentWindowGlobal.drawSnapshot(
+ rect,
+ scale,
+ BG_COLOUR
+ );
+
+ ctx.drawImage(snapshot, 0, 0);
+
+ // Bug 1574935 - Huge dimensions can trigger an OOM because multiple copies
+ // of the bitmap will exist in memory. Force the removal of the snapshot
+ // because it is no longer needed.
+ snapshot.close();
+ }
+
+ return canvas;
+};
+
+/**
+ * Encode the contents of an HTMLCanvasElement to a Base64 encoded string.
+ *
+ * @param {HTMLCanvasElement} canvas
+ * The canvas to encode.
+ *
+ * @returns {string}
+ * A Base64 encoded string.
+ */
+capture.toBase64 = function (canvas) {
+ let u = canvas.toDataURL(PNG_MIME);
+ return u.substring(u.indexOf(",") + 1);
+};
+
+/**
+ * Hash the contents of an HTMLCanvasElement to a SHA-256 hex digest.
+ *
+ * @param {HTMLCanvasElement} canvas
+ * The canvas to encode.
+ *
+ * @returns {string}
+ * A hex digest of the SHA-256 hash of the base64 encoded string.
+ */
+capture.toHash = function (canvas) {
+ let u = capture.toBase64(canvas);
+ let buffer = new TextEncoder().encode(u);
+ return crypto.subtle.digest("SHA-256", buffer).then(hash => hex(hash));
+};
+
+/**
+ * Convert buffer into to hex.
+ *
+ * @param {ArrayBuffer} buffer
+ * The buffer containing the data to convert to hex.
+ *
+ * @returns {string}
+ * A hex digest of the input buffer.
+ */
+function hex(buffer) {
+ let hexCodes = [];
+ let view = new DataView(buffer);
+ for (let i = 0; i < view.byteLength; i += 4) {
+ let value = view.getUint32(i);
+ let stringValue = value.toString(16);
+ let padding = "00000000";
+ let paddedValue = (padding + stringValue).slice(-padding.length);
+ hexCodes.push(paddedValue);
+ }
+ return hexCodes.join("");
+}
diff --git a/remote/shared/ChallengeHeaderParser.sys.mjs b/remote/shared/ChallengeHeaderParser.sys.mjs
new file mode 100644
index 0000000000..7cb73a4146
--- /dev/null
+++ b/remote/shared/ChallengeHeaderParser.sys.mjs
@@ -0,0 +1,74 @@
+/* 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/. */
+
+/**
+ * Parse the parameter in a name/value pair and remove quotes.
+ *
+ * @param {string} paramValue
+ * A string representing a challenge parameter.
+ *
+ * @returns {object}
+ * An object with name and value string properties.
+ */
+function parseChallengeParameter(paramValue) {
+ const [name, value] = paramValue.split("=");
+ return { name, value: value?.replace(/["']/g, "") };
+}
+
+/**
+ * Simple parser for authenticate (WWW-Authenticate or Proxy-Authenticate)
+ * headers.
+ *
+ * Bug 1857847: Replace with Necko's ChallengeParser once exposed to JS.
+ *
+ * @param {string} headerValue
+ * The value of an authenticate header.
+ *
+ * @returns {Array<object>}
+ * Array of challenge objects containing two properties:
+ * - {string} scheme: The scheme for the challenge
+ * - {Array<object>} params: Array of { name, value } objects representing
+ * all the parameters of the challenge.
+ */
+export function parseChallengeHeader(headerValue) {
+ const challenges = [];
+ const parts = headerValue.split(",").map(part => part.trim());
+
+ let scheme = null;
+ let params = [];
+
+ const schemeRegex = /^(\w+)(?:\s+(.*))?$/;
+ for (const part of parts) {
+ const matches = part.match(schemeRegex);
+ if (matches !== null) {
+ // This is a new scheme.
+ if (scheme !== null) {
+ // If we have a challenge recorded, add it to the array.
+ challenges.push({ scheme, params });
+ }
+
+ // Reset the state for a new scheme.
+ scheme = matches[1];
+ params = [];
+ if (matches[2]) {
+ params.push(parseChallengeParameter(matches[2]));
+ }
+ } else {
+ if (scheme === null) {
+ // A scheme should always be found before parameters, this header
+ // probably needs a more careful parsing solution.
+ return [];
+ }
+
+ params.push(parseChallengeParameter(part));
+ }
+ }
+
+ if (scheme !== null) {
+ // If we have a challenge recorded, add it to the array.
+ challenges.push({ scheme, params });
+ }
+
+ return challenges;
+}
diff --git a/remote/shared/DOM.sys.mjs b/remote/shared/DOM.sys.mjs
new file mode 100644
index 0000000000..664f02328c
--- /dev/null
+++ b/remote/shared/DOM.sys.mjs
@@ -0,0 +1,1219 @@
+/* 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, {
+ atom: "chrome://remote/content/marionette/atom.sys.mjs",
+ error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
+ PollPromise: "chrome://remote/content/marionette/sync.sys.mjs",
+});
+
+const ORDERED_NODE_ITERATOR_TYPE = 5;
+const FIRST_ORDERED_NODE_TYPE = 9;
+
+const DOCUMENT_FRAGMENT_NODE = 11;
+const ELEMENT_NODE = 1;
+
+const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+
+/** XUL elements that support checked property. */
+const XUL_CHECKED_ELS = new Set(["button", "checkbox", "toolbarbutton"]);
+
+/** XUL elements that support selected property. */
+const XUL_SELECTED_ELS = new Set([
+ "menu",
+ "menuitem",
+ "menuseparator",
+ "radio",
+ "richlistitem",
+ "tab",
+]);
+
+/**
+ * This module provides shared functionality for dealing with DOM-
+ * and web elements in Marionette.
+ *
+ * A web element is an abstraction used to identify an element when it
+ * is transported across the protocol, between remote- and local ends.
+ *
+ * Each element has an associated web element reference (a UUID) that
+ * uniquely identifies the the element across all browsing contexts. The
+ * web element reference for every element representing the same element
+ * is the same.
+ *
+ * @namespace
+ */
+export const dom = {};
+
+dom.Strategy = {
+ ClassName: "class name",
+ Selector: "css selector",
+ ID: "id",
+ Name: "name",
+ LinkText: "link text",
+ PartialLinkText: "partial link text",
+ TagName: "tag name",
+ XPath: "xpath",
+};
+
+/**
+ * Find a single element or a collection of elements starting at the
+ * document root or a given node.
+ *
+ * If |timeout| is above 0, an implicit search technique is used.
+ * This will wait for the duration of <var>timeout</var> for the
+ * element to appear in the DOM.
+ *
+ * See the {@link dom.Strategy} enum for a full list of supported
+ * search strategies that can be passed to <var>strategy</var>.
+ *
+ * @param {Object<string, WindowProxy>} container
+ * Window object.
+ * @param {string} strategy
+ * Search strategy whereby to locate the element(s).
+ * @param {string} selector
+ * Selector search pattern. The selector must be compatible with
+ * the chosen search <var>strategy</var>.
+ * @param {object=} options
+ * @param {boolean=} options.all
+ * If true, a multi-element search selector is used and a sequence of
+ * elements will be returned, otherwise a single element. Defaults to false.
+ * @param {Element=} options.startNode
+ * Element to use as the root of the search.
+ * @param {number=} options.timeout
+ * Duration to wait before timing out the search. If <code>all</code>
+ * is false, a {@link NoSuchElementError} is thrown if unable to
+ * find the element within the timeout duration.
+ *
+ * @returns {Promise.<(Element|Array.<Element>)>}
+ * Single element or a sequence of elements.
+ *
+ * @throws InvalidSelectorError
+ * If <var>strategy</var> is unknown.
+ * @throws InvalidSelectorError
+ * If <var>selector</var> is malformed.
+ * @throws NoSuchElementError
+ * If a single element is requested, this error will throw if the
+ * element is not found.
+ */
+dom.find = function (container, strategy, selector, options = {}) {
+ const { all = false, startNode, timeout = 0 } = options;
+
+ let searchFn;
+ if (all) {
+ searchFn = findElements.bind(this);
+ } else {
+ searchFn = findElement.bind(this);
+ }
+
+ return new Promise((resolve, reject) => {
+ let findElements = new lazy.PollPromise(
+ async (resolve, reject) => {
+ try {
+ let res = await find_(container, strategy, selector, searchFn, {
+ all,
+ startNode,
+ });
+ if (res.length) {
+ resolve(Array.from(res));
+ } else {
+ reject([]);
+ }
+ } catch (e) {
+ reject(e);
+ }
+ },
+ { timeout }
+ );
+
+ findElements.then(foundEls => {
+ // the following code ought to be moved into findElement
+ // and findElements when bug 1254486 is addressed
+ if (!all && (!foundEls || !foundEls.length)) {
+ let msg = `Unable to locate element: ${selector}`;
+ reject(new lazy.error.NoSuchElementError(msg));
+ }
+
+ if (all) {
+ resolve(foundEls);
+ }
+ resolve(foundEls[0]);
+ }, reject);
+ });
+};
+
+async function find_(
+ container,
+ strategy,
+ selector,
+ searchFn,
+ { startNode = null, all = false } = {}
+) {
+ let rootNode;
+
+ if (dom.isShadowRoot(startNode)) {
+ rootNode = startNode.ownerDocument;
+ } else {
+ rootNode = container.frame.document;
+ }
+
+ if (!startNode) {
+ startNode = rootNode;
+ }
+
+ let res;
+ try {
+ res = await searchFn(strategy, selector, rootNode, startNode);
+ } catch (e) {
+ throw new lazy.error.InvalidSelectorError(
+ `Given ${strategy} expression "${selector}" is invalid: ${e}`
+ );
+ }
+
+ if (res) {
+ if (all) {
+ return res;
+ }
+ return [res];
+ }
+ return [];
+}
+
+/**
+ * Find a single element by XPath expression.
+ *
+ * @param {Document} document
+ * Document root.
+ * @param {Element} startNode
+ * Where in the DOM hiearchy to begin searching.
+ * @param {string} expression
+ * XPath search expression.
+ *
+ * @returns {Node}
+ * First element matching <var>expression</var>.
+ */
+dom.findByXPath = function (document, startNode, expression) {
+ let iter = document.evaluate(
+ expression,
+ startNode,
+ null,
+ FIRST_ORDERED_NODE_TYPE,
+ null
+ );
+ return iter.singleNodeValue;
+};
+
+/**
+ * Find elements by XPath expression.
+ *
+ * @param {Document} document
+ * Document root.
+ * @param {Element} startNode
+ * Where in the DOM hierarchy to begin searching.
+ * @param {string} expression
+ * XPath search expression.
+ *
+ * @returns {Iterable.<Node>}
+ * Iterator over nodes matching <var>expression</var>.
+ */
+dom.findByXPathAll = function* (document, startNode, expression) {
+ let iter = document.evaluate(
+ expression,
+ startNode,
+ null,
+ ORDERED_NODE_ITERATOR_TYPE,
+ null
+ );
+ let el = iter.iterateNext();
+ while (el) {
+ yield el;
+ el = iter.iterateNext();
+ }
+};
+
+/**
+ * Find all hyperlinks descendant of <var>startNode</var> which
+ * link text is <var>linkText</var>.
+ *
+ * @param {Element} startNode
+ * Where in the DOM hierarchy to begin searching.
+ * @param {string} linkText
+ * Link text to search for.
+ *
+ * @returns {Iterable.<HTMLAnchorElement>}
+ * Sequence of link elements which text is <var>s</var>.
+ */
+dom.findByLinkText = function (startNode, linkText) {
+ return filterLinks(startNode, async link => {
+ const visibleText = await lazy.atom.getVisibleText(link, link.ownerGlobal);
+ return visibleText.trim() === linkText;
+ });
+};
+
+/**
+ * Find all hyperlinks descendant of <var>startNode</var> which
+ * link text contains <var>linkText</var>.
+ *
+ * @param {Element} startNode
+ * Where in the DOM hierachy to begin searching.
+ * @param {string} linkText
+ * Link text to search for.
+ *
+ * @returns {Iterable.<HTMLAnchorElement>}
+ * Iterator of link elements which text containins
+ * <var>linkText</var>.
+ */
+dom.findByPartialLinkText = function (startNode, linkText) {
+ return filterLinks(startNode, async link => {
+ const visibleText = await lazy.atom.getVisibleText(link, link.ownerGlobal);
+
+ return visibleText.includes(linkText);
+ });
+};
+
+/**
+ * Filters all hyperlinks that are descendant of <var>startNode</var>
+ * by <var>predicate</var>.
+ *
+ * @param {Element} startNode
+ * Where in the DOM hierarchy to begin searching.
+ * @param {function(HTMLAnchorElement): boolean} predicate
+ * Function that determines if given link should be included in
+ * return value or filtered away.
+ *
+ * @returns {Array.<HTMLAnchorElement>}
+ * Array of link elements matching <var>predicate</var>.
+ */
+async function filterLinks(startNode, predicate) {
+ const links = [];
+
+ for (const link of getLinks(startNode)) {
+ if (await predicate(link)) {
+ links.push(link);
+ }
+ }
+
+ return links;
+}
+
+/**
+ * Finds a single element.
+ *
+ * @param {dom.Strategy} strategy
+ * Selector strategy to use.
+ * @param {string} selector
+ * Selector expression.
+ * @param {Document} document
+ * Document root.
+ * @param {Element=} startNode
+ * Optional Element from which to start searching.
+ *
+ * @returns {Element}
+ * Found element.
+ *
+ * @throws {InvalidSelectorError}
+ * If strategy <var>using</var> is not recognised.
+ * @throws {Error}
+ * If selector expression <var>selector</var> is malformed.
+ */
+async function findElement(
+ strategy,
+ selector,
+ document,
+ startNode = undefined
+) {
+ switch (strategy) {
+ case dom.Strategy.ID: {
+ if (startNode.getElementById) {
+ return startNode.getElementById(selector);
+ }
+ let expr = `.//*[@id="${selector}"]`;
+ return dom.findByXPath(document, startNode, expr);
+ }
+
+ case dom.Strategy.Name: {
+ if (startNode.getElementsByName) {
+ return startNode.getElementsByName(selector)[0];
+ }
+ let expr = `.//*[@name="${selector}"]`;
+ return dom.findByXPath(document, startNode, expr);
+ }
+
+ case dom.Strategy.ClassName:
+ return startNode.getElementsByClassName(selector)[0];
+
+ case dom.Strategy.TagName:
+ return startNode.getElementsByTagName(selector)[0];
+
+ case dom.Strategy.XPath:
+ return dom.findByXPath(document, startNode, selector);
+
+ case dom.Strategy.LinkText: {
+ const links = getLinks(startNode);
+ for (const link of links) {
+ const visibleText = await lazy.atom.getVisibleText(
+ link,
+ link.ownerGlobal
+ );
+ if (visibleText.trim() === selector) {
+ return link;
+ }
+ }
+ return undefined;
+ }
+
+ case dom.Strategy.PartialLinkText: {
+ const links = getLinks(startNode);
+ for (const link of links) {
+ const visibleText = await lazy.atom.getVisibleText(
+ link,
+ link.ownerGlobal
+ );
+ if (visibleText.includes(selector)) {
+ return link;
+ }
+ }
+ return undefined;
+ }
+
+ case dom.Strategy.Selector:
+ try {
+ return startNode.querySelector(selector);
+ } catch (e) {
+ throw new lazy.error.InvalidSelectorError(
+ `${e.message}: "${selector}"`
+ );
+ }
+ }
+
+ throw new lazy.error.InvalidSelectorError(`No such strategy: ${strategy}`);
+}
+
+/**
+ * Find multiple elements.
+ *
+ * @param {dom.Strategy} strategy
+ * Selector strategy to use.
+ * @param {string} selector
+ * Selector expression.
+ * @param {Document} document
+ * Document root.
+ * @param {Element=} startNode
+ * Optional Element from which to start searching.
+ *
+ * @returns {Array.<Element>}
+ * Found elements.
+ *
+ * @throws {InvalidSelectorError}
+ * If strategy <var>strategy</var> is not recognised.
+ * @throws {Error}
+ * If selector expression <var>selector</var> is malformed.
+ */
+async function findElements(
+ strategy,
+ selector,
+ document,
+ startNode = undefined
+) {
+ switch (strategy) {
+ case dom.Strategy.ID:
+ selector = `.//*[@id="${selector}"]`;
+
+ // fall through
+ case dom.Strategy.XPath:
+ return [...dom.findByXPathAll(document, startNode, selector)];
+
+ case dom.Strategy.Name:
+ if (startNode.getElementsByName) {
+ return startNode.getElementsByName(selector);
+ }
+ return [
+ ...dom.findByXPathAll(document, startNode, `.//*[@name="${selector}"]`),
+ ];
+
+ case dom.Strategy.ClassName:
+ return startNode.getElementsByClassName(selector);
+
+ case dom.Strategy.TagName:
+ return startNode.getElementsByTagName(selector);
+
+ case dom.Strategy.LinkText:
+ return [...(await dom.findByLinkText(startNode, selector))];
+
+ case dom.Strategy.PartialLinkText:
+ return [...(await dom.findByPartialLinkText(startNode, selector))];
+
+ case dom.Strategy.Selector:
+ return startNode.querySelectorAll(selector);
+
+ default:
+ throw new lazy.error.InvalidSelectorError(
+ `No such strategy: ${strategy}`
+ );
+ }
+}
+
+function getLinks(startNode) {
+ // DocumentFragment doesn't have `getElementsByTagName` so using `querySelectorAll`.
+ if (dom.isShadowRoot(startNode)) {
+ return startNode.querySelectorAll("a");
+ }
+ return startNode.getElementsByTagName("a");
+}
+
+/**
+ * Finds the closest parent node of <var>startNode</var> matching a CSS
+ * <var>selector</var> expression.
+ *
+ * @param {Node} startNode
+ * Cycle through <var>startNode</var>'s parent nodes in tree-order
+ * and return the first match to <var>selector</var>.
+ * @param {string} selector
+ * CSS selector expression.
+ *
+ * @returns {Node=}
+ * First match to <var>selector</var>, or null if no match was found.
+ */
+dom.findClosest = function (startNode, selector) {
+ let node = startNode;
+ while (node.parentNode && node.parentNode.nodeType == ELEMENT_NODE) {
+ node = node.parentNode;
+ if (node.matches(selector)) {
+ return node;
+ }
+ }
+ return null;
+};
+
+/**
+ * Determines if <var>obj<var> is an HTML or JS collection.
+ *
+ * @param {object} seq
+ * Type to determine.
+ *
+ * @returns {boolean}
+ * True if <var>seq</va> is a collection.
+ */
+dom.isCollection = function (seq) {
+ switch (Object.prototype.toString.call(seq)) {
+ case "[object Arguments]":
+ case "[object Array]":
+ case "[object DOMTokenList]":
+ case "[object FileList]":
+ case "[object HTMLAllCollection]":
+ case "[object HTMLCollection]":
+ case "[object HTMLFormControlsCollection]":
+ case "[object HTMLOptionsCollection]":
+ case "[object NodeList]":
+ return true;
+
+ default:
+ return false;
+ }
+};
+
+/**
+ * Determines if <var>shadowRoot</var> is detached.
+ *
+ * A ShadowRoot is detached if its node document is not the active document
+ * or if the element node referred to as its host is stale.
+ *
+ * @param {ShadowRoot} shadowRoot
+ * ShadowRoot to check for detached state.
+ *
+ * @returns {boolean}
+ * True if <var>shadowRoot</var> is detached, false otherwise.
+ */
+dom.isDetached = function (shadowRoot) {
+ return !shadowRoot.ownerDocument.isActive() || dom.isStale(shadowRoot.host);
+};
+
+/**
+ * Determines if <var>el</var> is stale.
+ *
+ * An element is stale if its node document is not the active document
+ * or if it is not connected.
+ *
+ * @param {Element} el
+ * Element to check for staleness.
+ *
+ * @returns {boolean}
+ * True if <var>el</var> is stale, false otherwise.
+ */
+dom.isStale = function (el) {
+ if (!el.ownerGlobal) {
+ // Without a valid inner window the document is basically closed.
+ return true;
+ }
+
+ return !el.ownerDocument.isActive() || !el.isConnected;
+};
+
+/**
+ * Determine if <var>el</var> is selected or not.
+ *
+ * This operation only makes sense on
+ * <tt>&lt;input type=checkbox&gt;</tt>,
+ * <tt>&lt;input type=radio&gt;</tt>,
+ * and <tt>&gt;option&gt;</tt> elements.
+ *
+ * @param {Element} el
+ * Element to test if selected.
+ *
+ * @returns {boolean}
+ * True if element is selected, false otherwise.
+ */
+dom.isSelected = function (el) {
+ if (!el) {
+ return false;
+ }
+
+ if (dom.isXULElement(el)) {
+ if (XUL_CHECKED_ELS.has(el.tagName)) {
+ return el.checked;
+ } else if (XUL_SELECTED_ELS.has(el.tagName)) {
+ return el.selected;
+ }
+ } else if (dom.isDOMElement(el)) {
+ if (el.localName == "input" && ["checkbox", "radio"].includes(el.type)) {
+ return el.checked;
+ } else if (el.localName == "option") {
+ return el.selected;
+ }
+ }
+
+ return false;
+};
+
+/**
+ * An element is considered read only if it is an
+ * <code>&lt;input&gt;</code> or <code>&lt;textarea&gt;</code>
+ * element whose <code>readOnly</code> content IDL attribute is set.
+ *
+ * @param {Element} el
+ * Element to test is read only.
+ *
+ * @returns {boolean}
+ * True if element is read only.
+ */
+dom.isReadOnly = function (el) {
+ return (
+ dom.isDOMElement(el) &&
+ ["input", "textarea"].includes(el.localName) &&
+ el.readOnly
+ );
+};
+
+/**
+ * An element is considered disabled if it is a an element
+ * that can be disabled, or it belongs to a container group which
+ * <code>disabled</code> content IDL attribute affects it.
+ *
+ * @param {Element} el
+ * Element to test for disabledness.
+ *
+ * @returns {boolean}
+ * True if element, or its container group, is disabled.
+ */
+dom.isDisabled = function (el) {
+ if (!dom.isDOMElement(el)) {
+ return false;
+ }
+
+ switch (el.localName) {
+ case "option":
+ case "optgroup":
+ if (el.disabled) {
+ return true;
+ }
+ let parent = dom.findClosest(el, "optgroup,select");
+ return dom.isDisabled(parent);
+
+ case "button":
+ case "input":
+ case "select":
+ case "textarea":
+ return el.disabled;
+
+ default:
+ return false;
+ }
+};
+
+/**
+ * Denotes elements that can be used for typing and clearing.
+ *
+ * Elements that are considered WebDriver-editable are non-readonly
+ * and non-disabled <code>&lt;input&gt;</code> elements in the Text,
+ * Search, URL, Telephone, Email, Password, Date, Month, Date and
+ * Time Local, Number, Range, Color, and File Upload states, and
+ * <code>&lt;textarea&gt;</code> elements.
+ *
+ * @param {Element} el
+ * Element to test.
+ *
+ * @returns {boolean}
+ * True if editable, false otherwise.
+ */
+dom.isMutableFormControl = function (el) {
+ if (!dom.isDOMElement(el)) {
+ return false;
+ }
+ if (dom.isReadOnly(el) || dom.isDisabled(el)) {
+ return false;
+ }
+
+ if (el.localName == "textarea") {
+ return true;
+ }
+
+ if (el.localName != "input") {
+ return false;
+ }
+
+ switch (el.type) {
+ case "color":
+ case "date":
+ case "datetime-local":
+ case "email":
+ case "file":
+ case "month":
+ case "number":
+ case "password":
+ case "range":
+ case "search":
+ case "tel":
+ case "text":
+ case "time":
+ case "url":
+ case "week":
+ return true;
+
+ default:
+ return false;
+ }
+};
+
+/**
+ * An editing host is a node that is either an HTML element with a
+ * <code>contenteditable</code> attribute, or the HTML element child
+ * of a document whose <code>designMode</code> is enabled.
+ *
+ * @param {Element} el
+ * Element to determine if is an editing host.
+ *
+ * @returns {boolean}
+ * True if editing host, false otherwise.
+ */
+dom.isEditingHost = function (el) {
+ return (
+ dom.isDOMElement(el) &&
+ (el.isContentEditable || el.ownerDocument.designMode == "on")
+ );
+};
+
+/**
+ * Determines if an element is editable according to WebDriver.
+ *
+ * An element is considered editable if it is not read-only or
+ * disabled, and one of the following conditions are met:
+ *
+ * <ul>
+ * <li>It is a <code>&lt;textarea&gt;</code> element.
+ *
+ * <li>It is an <code>&lt;input&gt;</code> element that is not of
+ * the <code>checkbox</code>, <code>radio</code>, <code>hidden</code>,
+ * <code>submit</code>, <code>button</code>, or <code>image</code> types.
+ *
+ * <li>It is content-editable.
+ *
+ * <li>It belongs to a document in design mode.
+ * </ul>
+ *
+ * @param {Element} el
+ * Element to test if editable.
+ *
+ * @returns {boolean}
+ * True if editable, false otherwise.
+ */
+dom.isEditable = function (el) {
+ if (!dom.isDOMElement(el)) {
+ return false;
+ }
+
+ if (dom.isReadOnly(el) || dom.isDisabled(el)) {
+ return false;
+ }
+
+ return dom.isMutableFormControl(el) || dom.isEditingHost(el);
+};
+
+/**
+ * This function generates a pair of coordinates relative to the viewport
+ * given a target element and coordinates relative to that element's
+ * top-left corner.
+ *
+ * @param {Node} node
+ * Target node.
+ * @param {number=} xOffset
+ * Horizontal offset relative to target's top-left corner.
+ * Defaults to the centre of the target's bounding box.
+ * @param {number=} yOffset
+ * Vertical offset relative to target's top-left corner. Defaults to
+ * the centre of the target's bounding box.
+ *
+ * @returns {Object<string, number>}
+ * X- and Y coordinates.
+ *
+ * @throws TypeError
+ * If <var>xOffset</var> or <var>yOffset</var> are not numbers.
+ */
+dom.coordinates = function (node, xOffset = undefined, yOffset = undefined) {
+ let box = node.getBoundingClientRect();
+
+ if (typeof xOffset == "undefined" || xOffset === null) {
+ xOffset = box.width / 2.0;
+ }
+ if (typeof yOffset == "undefined" || yOffset === null) {
+ yOffset = box.height / 2.0;
+ }
+
+ if (typeof yOffset != "number" || typeof xOffset != "number") {
+ throw new TypeError("Offset must be a number");
+ }
+
+ return {
+ x: box.left + xOffset,
+ y: box.top + yOffset,
+ };
+};
+
+/**
+ * This function returns true if the node is in the viewport.
+ *
+ * @param {Element} el
+ * Target element.
+ * @param {number=} x
+ * Horizontal offset relative to target. Defaults to the centre of
+ * the target's bounding box.
+ * @param {number=} y
+ * Vertical offset relative to target. Defaults to the centre of
+ * the target's bounding box.
+ *
+ * @returns {boolean}
+ * True if if <var>el</var> is in viewport, false otherwise.
+ */
+dom.inViewport = function (el, x = undefined, y = undefined) {
+ let win = el.ownerGlobal;
+ let c = dom.coordinates(el, x, y);
+ let vp = {
+ top: win.pageYOffset,
+ left: win.pageXOffset,
+ bottom: win.pageYOffset + win.innerHeight,
+ right: win.pageXOffset + win.innerWidth,
+ };
+
+ return (
+ vp.left <= c.x + win.pageXOffset &&
+ c.x + win.pageXOffset <= vp.right &&
+ vp.top <= c.y + win.pageYOffset &&
+ c.y + win.pageYOffset <= vp.bottom
+ );
+};
+
+/**
+ * Gets the element's container element.
+ *
+ * An element container is defined by the WebDriver
+ * specification to be an <tt>&lt;option&gt;</tt> element in a
+ * <a href="https://html.spec.whatwg.org/#concept-element-contexts">valid
+ * element context</a>, meaning that it has an ancestral element
+ * that is either <tt>&lt;datalist&gt;</tt> or <tt>&lt;select&gt;</tt>.
+ *
+ * If the element does not have a valid context, its container element
+ * is itself.
+ *
+ * @param {Element} el
+ * Element to get the container of.
+ *
+ * @returns {Element}
+ * Container element of <var>el</var>.
+ */
+dom.getContainer = function (el) {
+ // Does <option> or <optgroup> have a valid context,
+ // meaning is it a child of <datalist> or <select>?
+ if (["option", "optgroup"].includes(el.localName)) {
+ return dom.findClosest(el, "datalist,select") || el;
+ }
+
+ return el;
+};
+
+/**
+ * An element is in view if it is a member of its own pointer-interactable
+ * paint tree.
+ *
+ * This means an element is considered to be in view, but not necessarily
+ * pointer-interactable, if it is found somewhere in the
+ * <code>elementsFromPoint</code> list at <var>el</var>'s in-view
+ * centre coordinates.
+ *
+ * Before running the check, we change <var>el</var>'s pointerEvents
+ * style property to "auto", since elements without pointer events
+ * enabled do not turn up in the paint tree we get from
+ * document.elementsFromPoint. This is a specialisation that is only
+ * relevant when checking if the element is in view.
+ *
+ * @param {Element} el
+ * Element to check if is in view.
+ *
+ * @returns {boolean}
+ * True if <var>el</var> is inside the viewport, or false otherwise.
+ */
+dom.isInView = function (el) {
+ let originalPointerEvents = el.style.pointerEvents;
+
+ try {
+ el.style.pointerEvents = "auto";
+ const tree = dom.getPointerInteractablePaintTree(el);
+
+ // Bug 1413493 - <tr> is not part of the returned paint tree yet. As
+ // workaround check the visibility based on the first contained cell.
+ if (el.localName === "tr" && el.cells && el.cells.length) {
+ return tree.includes(el.cells[0]);
+ }
+
+ return tree.includes(el);
+ } finally {
+ el.style.pointerEvents = originalPointerEvents;
+ }
+};
+
+/**
+ * This function throws the visibility of the element error if the element is
+ * not displayed or the given coordinates are not within the viewport.
+ *
+ * @param {Element} el
+ * Element to check if visible.
+ * @param {number=} x
+ * Horizontal offset relative to target. Defaults to the centre of
+ * the target's bounding box.
+ * @param {number=} y
+ * Vertical offset relative to target. Defaults to the centre of
+ * the target's bounding box.
+ *
+ * @returns {boolean}
+ * True if visible, false otherwise.
+ */
+dom.isVisible = async function (el, x = undefined, y = undefined) {
+ let win = el.ownerGlobal;
+
+ if (!(await lazy.atom.isElementDisplayed(el, win))) {
+ return false;
+ }
+
+ if (el.tagName.toLowerCase() == "body") {
+ return true;
+ }
+
+ if (!dom.inViewport(el, x, y)) {
+ dom.scrollIntoView(el);
+ if (!dom.inViewport(el)) {
+ return false;
+ }
+ }
+ return true;
+};
+
+/**
+ * A pointer-interactable element is defined to be the first
+ * non-transparent element, defined by the paint order found at the centre
+ * point of its rectangle that is inside the viewport, excluding the size
+ * of any rendered scrollbars.
+ *
+ * An element is obscured if the pointer-interactable paint tree at its
+ * centre point is empty, or the first element in this tree is not an
+ * inclusive descendant of itself.
+ *
+ * @param {DOMElement} el
+ * Element determine if is pointer-interactable.
+ *
+ * @returns {boolean}
+ * True if element is obscured, false otherwise.
+ */
+dom.isObscured = function (el) {
+ let tree = dom.getPointerInteractablePaintTree(el);
+ return !el.contains(tree[0]);
+};
+
+// TODO(ato): Only used by deprecated action API
+// https://bugzil.la/1354578
+/**
+ * Calculates the in-view centre point of an element's client rect.
+ *
+ * The portion of an element that is said to be _in view_, is the
+ * intersection of two squares: the first square being the initial
+ * viewport, and the second a DOM element. From this square we
+ * calculate the in-view _centre point_ and convert it into CSS pixels.
+ *
+ * Although Gecko's system internals allow click points to be
+ * given in floating point precision, the DOM operates in CSS pixels.
+ * When the in-view centre point is later used to retrieve a coordinate's
+ * paint tree, we need to ensure to operate in the same language.
+ *
+ * As a word of warning, there appears to be inconsistencies between
+ * how `DOMElement.elementsFromPoint` and `DOMWindowUtils.sendMouseEvent`
+ * internally rounds (ceils/floors) coordinates.
+ *
+ * @param {DOMRect} rect
+ * Element off a DOMRect sequence produced by calling
+ * `getClientRects` on an {@link Element}.
+ * @param {WindowProxy} win
+ * Current window global.
+ *
+ * @returns {Map.<string, number>}
+ * X and Y coordinates that denotes the in-view centre point of
+ * `rect`.
+ */
+dom.getInViewCentrePoint = function (rect, win) {
+ const { floor, max, min } = Math;
+
+ // calculate the intersection of the rect that is inside the viewport
+ let visible = {
+ left: max(0, min(rect.x, rect.x + rect.width)),
+ right: min(win.innerWidth, max(rect.x, rect.x + rect.width)),
+ top: max(0, min(rect.y, rect.y + rect.height)),
+ bottom: min(win.innerHeight, max(rect.y, rect.y + rect.height)),
+ };
+
+ // arrive at the centre point of the visible rectangle
+ let x = (visible.left + visible.right) / 2.0;
+ let y = (visible.top + visible.bottom) / 2.0;
+
+ // convert to CSS pixels, as centre point can be float
+ x = floor(x);
+ y = floor(y);
+
+ return { x, y };
+};
+
+/**
+ * Produces a pointer-interactable elements tree from a given element.
+ *
+ * The tree is defined by the paint order found at the centre point of
+ * the element's rectangle that is inside the viewport, excluding the size
+ * of any rendered scrollbars.
+ *
+ * @param {DOMElement} el
+ * Element to determine if is pointer-interactable.
+ *
+ * @returns {Array.<DOMElement>}
+ * Sequence of elements in paint order.
+ */
+dom.getPointerInteractablePaintTree = function (el) {
+ const win = el.ownerGlobal;
+ const rootNode = el.getRootNode();
+
+ // pointer-interactable elements tree, step 1
+ if (!el.isConnected) {
+ return [];
+ }
+
+ // steps 2-3
+ let rects = el.getClientRects();
+ if (!rects.length) {
+ return [];
+ }
+
+ // step 4
+ let centre = dom.getInViewCentrePoint(rects[0], win);
+
+ // step 5
+ return rootNode.elementsFromPoint(centre.x, centre.y);
+};
+
+// TODO(ato): Not implemented.
+// In fact, it's not defined in the spec.
+dom.isKeyboardInteractable = () => true;
+
+/**
+ * Attempts to scroll into view |el|.
+ *
+ * @param {DOMElement} el
+ * Element to scroll into view.
+ */
+dom.scrollIntoView = function (el) {
+ if (el.scrollIntoView) {
+ el.scrollIntoView({ block: "end", inline: "nearest" });
+ }
+};
+
+/**
+ * Ascertains whether <var>obj</var> is a DOM-, SVG-, or XUL element.
+ *
+ * @param {object} obj
+ * Object thought to be an <code>Element</code> or
+ * <code>XULElement</code>.
+ *
+ * @returns {boolean}
+ * True if <var>obj</var> is an element, false otherwise.
+ */
+dom.isElement = function (obj) {
+ return dom.isDOMElement(obj) || dom.isXULElement(obj);
+};
+
+/**
+ * Returns the shadow root of an element.
+ *
+ * @param {Element} el
+ * Element thought to have a <code>shadowRoot</code>
+ * @returns {ShadowRoot}
+ * Shadow root of the element.
+ */
+dom.getShadowRoot = function (el) {
+ const shadowRoot = el.openOrClosedShadowRoot;
+ if (!shadowRoot) {
+ throw new lazy.error.NoSuchShadowRootError();
+ }
+ return shadowRoot;
+};
+
+/**
+ * Ascertains whether <var>node</var> is a shadow root.
+ *
+ * @param {ShadowRoot} node
+ * The node that will be checked to see if it has a shadow root
+ *
+ * @returns {boolean}
+ * True if <var>node</var> is a shadow root, false otherwise.
+ */
+dom.isShadowRoot = function (node) {
+ return (
+ node &&
+ node.nodeType === DOCUMENT_FRAGMENT_NODE &&
+ node.containingShadowRoot == node
+ );
+};
+
+/**
+ * Ascertains whether <var>obj</var> is a DOM element.
+ *
+ * @param {object} obj
+ * Object to check.
+ *
+ * @returns {boolean}
+ * True if <var>obj</var> is a DOM element, false otherwise.
+ */
+dom.isDOMElement = function (obj) {
+ return obj && obj.nodeType == ELEMENT_NODE && !dom.isXULElement(obj);
+};
+
+/**
+ * Ascertains whether <var>obj</var> is a XUL element.
+ *
+ * @param {object} obj
+ * Object to check.
+ *
+ * @returns {boolean}
+ * True if <var>obj</var> is a XULElement, false otherwise.
+ */
+dom.isXULElement = function (obj) {
+ return obj && obj.nodeType === ELEMENT_NODE && obj.namespaceURI === XUL_NS;
+};
+
+/**
+ * Ascertains whether <var>node</var> is in a privileged document.
+ *
+ * @param {Node} node
+ * Node to check.
+ *
+ * @returns {boolean}
+ * True if <var>node</var> is in a privileged document,
+ * false otherwise.
+ */
+dom.isInPrivilegedDocument = function (node) {
+ return !!node?.nodePrincipal?.isSystemPrincipal;
+};
+
+/**
+ * Ascertains whether <var>obj</var> is a <code>WindowProxy</code>.
+ *
+ * @param {object} obj
+ * Object to check.
+ *
+ * @returns {boolean}
+ * True if <var>obj</var> is a DOM window.
+ */
+dom.isDOMWindow = function (obj) {
+ // TODO(ato): This should use Object.prototype.toString.call(node)
+ // but it's not clear how to write a good xpcshell test for that,
+ // seeing as we stub out a WindowProxy.
+ return (
+ typeof obj == "object" &&
+ obj !== null &&
+ typeof obj.toString == "function" &&
+ obj.toString() == "[object Window]" &&
+ obj.self === obj
+ );
+};
+
+const boolEls = {
+ audio: ["autoplay", "controls", "loop", "muted"],
+ button: ["autofocus", "disabled", "formnovalidate"],
+ details: ["open"],
+ dialog: ["open"],
+ fieldset: ["disabled"],
+ form: ["novalidate"],
+ iframe: ["allowfullscreen"],
+ img: ["ismap"],
+ input: [
+ "autofocus",
+ "checked",
+ "disabled",
+ "formnovalidate",
+ "multiple",
+ "readonly",
+ "required",
+ ],
+ keygen: ["autofocus", "disabled"],
+ menuitem: ["checked", "default", "disabled"],
+ ol: ["reversed"],
+ optgroup: ["disabled"],
+ option: ["disabled", "selected"],
+ script: ["async", "defer"],
+ select: ["autofocus", "disabled", "multiple", "required"],
+ textarea: ["autofocus", "disabled", "readonly", "required"],
+ track: ["default"],
+ video: ["autoplay", "controls", "loop", "muted"],
+};
+
+/**
+ * Tests if the attribute is a boolean attribute on element.
+ *
+ * @param {Element} el
+ * Element to test if <var>attr</var> is a boolean attribute on.
+ * @param {string} attr
+ * Attribute to test is a boolean attribute.
+ *
+ * @returns {boolean}
+ * True if the attribute is boolean, false otherwise.
+ */
+dom.isBooleanAttribute = function (el, attr) {
+ if (!dom.isDOMElement(el)) {
+ return false;
+ }
+
+ // global boolean attributes that apply to all HTML elements,
+ // except for custom elements
+ const customElement = !el.localName.includes("-");
+ if ((attr == "hidden" || attr == "itemscope") && customElement) {
+ return true;
+ }
+
+ if (!boolEls.hasOwnProperty(el.localName)) {
+ return false;
+ }
+ return boolEls[el.localName].includes(attr);
+};
diff --git a/remote/shared/Format.sys.mjs b/remote/shared/Format.sys.mjs
new file mode 100644
index 0000000000..5da8bc9161
--- /dev/null
+++ b/remote/shared/Format.sys.mjs
@@ -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/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "truncateLog",
+ "remote.log.truncate",
+ false
+);
+
+const ELEMENT_NODE = 1;
+const MAX_STRING_LENGTH = 250;
+
+/**
+ * Pretty-print values passed to template strings.
+ *
+ * Usage::
+ *
+ * let bool = {value: true};
+ * pprint`Expected boolean, got ${bool}`;
+ * => 'Expected boolean, got [object Object] {"value": true}'
+ *
+ * let htmlElement = document.querySelector("input#foo");
+ * pprint`Expected element ${htmlElement}`;
+ * => 'Expected element <input id="foo" class="bar baz" type="input">'
+ *
+ * pprint`Current window: ${window}`;
+ * => '[object Window https://www.mozilla.org/]'
+ */
+export function pprint(ss, ...values) {
+ function pretty(val) {
+ let proto = Object.prototype.toString.call(val);
+ if (
+ typeof val == "object" &&
+ val !== null &&
+ "nodeType" in val &&
+ val.nodeType === ELEMENT_NODE
+ ) {
+ return prettyElement(val);
+ } else if (["[object Window]", "[object ChromeWindow]"].includes(proto)) {
+ return prettyWindowGlobal(val);
+ } else if (proto == "[object Attr]") {
+ return prettyAttr(val);
+ }
+ return prettyObject(val);
+ }
+
+ function prettyElement(el) {
+ let attrs = ["id", "class", "href", "name", "src", "type"];
+
+ let idents = "";
+ for (let attr of attrs) {
+ if (el.hasAttribute(attr)) {
+ idents += ` ${attr}="${el.getAttribute(attr)}"`;
+ }
+ }
+
+ return `<${el.localName}${idents}>`;
+ }
+
+ function prettyWindowGlobal(win) {
+ let proto = Object.prototype.toString.call(win);
+ return `[${proto.substring(1, proto.length - 1)} ${win.location}]`;
+ }
+
+ function prettyAttr(obj) {
+ return `[object Attr ${obj.name}="${obj.value}"]`;
+ }
+
+ function prettyObject(obj) {
+ let proto = Object.prototype.toString.call(obj);
+ let s = "";
+ try {
+ s = JSON.stringify(obj);
+ } catch (e) {
+ if (e instanceof TypeError) {
+ s = `<${e.message}>`;
+ } else {
+ throw e;
+ }
+ }
+ return `${proto} ${s}`;
+ }
+
+ let res = [];
+ for (let i = 0; i < ss.length; i++) {
+ res.push(ss[i]);
+ if (i < values.length) {
+ let s;
+ try {
+ s = pretty(values[i]);
+ } catch (e) {
+ lazy.logger.warn("Problem pretty printing:", e);
+ s = typeof values[i];
+ }
+ res.push(s);
+ }
+ }
+ return res.join("");
+}
+
+/**
+ * Template literal that truncates string values in arbitrary objects.
+ *
+ * Given any object, the template will walk the object and truncate
+ * any strings it comes across to a reasonable limit. This is suitable
+ * when you have arbitrary data and data integrity is not important.
+ *
+ * The strings are truncated in the middle so that the beginning and
+ * the end is preserved. This will make a long, truncated string look
+ * like "X <...> Y", where X and Y are half the number of characters
+ * of the maximum string length from either side of the string.
+ *
+ *
+ * Usage::
+ *
+ * truncate`Hello ${"x".repeat(260)}!`;
+ * // Hello xxx ... xxx!
+ *
+ * Functions named `toJSON` or `toString` on objects will be called.
+ */
+export function truncate(strings, ...values) {
+ function walk(obj) {
+ const typ = Object.prototype.toString.call(obj);
+
+ switch (typ) {
+ case "[object Undefined]":
+ case "[object Null]":
+ case "[object Boolean]":
+ case "[object Number]":
+ return obj;
+
+ case "[object String]":
+ if (lazy.truncateLog && obj.length > MAX_STRING_LENGTH) {
+ let s1 = obj.substring(0, MAX_STRING_LENGTH / 2);
+ let s2 = obj.substring(obj.length - MAX_STRING_LENGTH / 2);
+ return `${s1} ... ${s2}`;
+ }
+ return obj;
+
+ case "[object Array]":
+ return obj.map(walk);
+
+ // arbitrary object
+ default:
+ if (
+ Object.getOwnPropertyNames(obj).includes("toString") &&
+ typeof obj.toString == "function"
+ ) {
+ return walk(obj.toString());
+ }
+
+ let rv = {};
+ for (let prop in obj) {
+ rv[prop] = walk(obj[prop]);
+ }
+ return rv;
+ }
+ }
+
+ let res = [];
+ for (let i = 0; i < strings.length; ++i) {
+ res.push(strings[i]);
+ if (i < values.length) {
+ let obj = walk(values[i]);
+ let t = Object.prototype.toString.call(obj);
+ if (t == "[object Array]" || t == "[object Object]") {
+ res.push(JSON.stringify(obj));
+ } else {
+ res.push(obj);
+ }
+ }
+ }
+ return res.join("");
+}
diff --git a/remote/shared/Log.sys.mjs b/remote/shared/Log.sys.mjs
new file mode 100644
index 0000000000..f1b3706391
--- /dev/null
+++ b/remote/shared/Log.sys.mjs
@@ -0,0 +1,71 @@
+/* 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 { Log as StdLog } from "resource://gre/modules/Log.sys.mjs";
+
+const PREF_REMOTE_LOG_LEVEL = "remote.log.level";
+
+const lazy = {};
+
+// Lazy getter which returns a cached value of the remote log level. Should be
+// used for static getters used to guard hot paths for logging, eg
+// isTraceLevelOrMore.
+ChromeUtils.defineLazyGetter(lazy, "logLevel", () =>
+ Services.prefs.getCharPref(PREF_REMOTE_LOG_LEVEL, StdLog.Level.Fatal)
+);
+
+/** E10s compatible wrapper for the standard logger from Log.sys.mjs. */
+export class Log {
+ static TYPES = {
+ CDP: "CDP",
+ MARIONETTE: "Marionette",
+ REMOTE_AGENT: "RemoteAgent",
+ WEBDRIVER_BIDI: "WebDriver BiDi",
+ };
+
+ /**
+ * Get a logger instance. For each provided type, a dedicated logger instance
+ * will be returned, but all loggers are relying on the same preference.
+ *
+ * @param {string} type
+ * The type of logger to use. Protocol-specific modules should use the
+ * corresponding logger type. Eg. files under /marionette should use
+ * Log.TYPES.MARIONETTE.
+ */
+ static get(type = Log.TYPES.REMOTE_AGENT) {
+ const logger = StdLog.repository.getLogger(type);
+ if (!logger.ownAppenders.length) {
+ logger.addAppender(new StdLog.DumpAppender());
+ logger.manageLevelFromPref(PREF_REMOTE_LOG_LEVEL);
+ }
+ return logger;
+ }
+
+ /**
+ * Check if the current log level matches the Debug log level, or any level
+ * above that. This should be used to guard logger.debug calls and avoid
+ * instanciating logger instances unnecessarily.
+ */
+ static get isDebugLevelOrMore() {
+ // Debug is assigned 20, more verbose log levels have lower values.
+ return StdLog.Level[lazy.logLevel] <= StdLog.Level.Debug;
+ }
+
+ /**
+ * Check if the current log level matches the Trace log level, or any level
+ * above that. This should be used to guard logger.trace calls and avoid
+ * instanciating logger instances unnecessarily.
+ */
+ static get isTraceLevelOrMore() {
+ // Trace is assigned 10, more verbose log levels have lower values.
+ return StdLog.Level[lazy.logLevel] <= StdLog.Level.Trace;
+ }
+
+ static get verbose() {
+ // we can't use Preferences.sys.mjs before first paint,
+ // see ../browser/base/content/test/performance/browser_startup.js
+ const level = Services.prefs.getStringPref(PREF_REMOTE_LOG_LEVEL, "Info");
+ return StdLog.Level[level] >= StdLog.Level.Info;
+ }
+}
diff --git a/remote/shared/MobileTabBrowser.sys.mjs b/remote/shared/MobileTabBrowser.sys.mjs
new file mode 100644
index 0000000000..b61a1f9a9b
--- /dev/null
+++ b/remote/shared/MobileTabBrowser.sys.mjs
@@ -0,0 +1,86 @@
+/* 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, {
+ GeckoViewTabUtil: "resource://gre/modules/GeckoViewTestUtils.sys.mjs",
+
+ windowManager: "chrome://remote/content/shared/WindowManager.sys.mjs",
+});
+
+// GeckoView shim for Desktop's gBrowser
+export class MobileTabBrowser {
+ constructor(window) {
+ this.window = window;
+ }
+
+ get tabs() {
+ return [this.window.tab];
+ }
+
+ get selectedTab() {
+ return this.window.tab;
+ }
+
+ set selectedTab(tab) {
+ if (tab != this.selectedTab) {
+ throw new Error("GeckoView only supports a single tab");
+ }
+
+ // Synthesize a custom TabSelect event to indicate that a tab has been
+ // selected even when we don't change it.
+ const event = this.window.CustomEvent("TabSelect", {
+ bubbles: true,
+ cancelable: false,
+ detail: {
+ previousTab: this.selectedTab,
+ },
+ });
+ this.window.document.dispatchEvent(event);
+ }
+
+ get selectedBrowser() {
+ return this.selectedTab.linkedBrowser;
+ }
+
+ addEventListener() {
+ this.window.addEventListener(...arguments);
+ }
+
+ /**
+ * Create a new tab.
+ *
+ * @param {string} uriString
+ * The URI string to load within the newly opened tab.
+ *
+ * @returns {Promise<Tab>}
+ * The created tab.
+ * @throws {Error}
+ * Throws an error if the tab cannot be created.
+ */
+ addTab(uriString) {
+ return lazy.GeckoViewTabUtil.createNewTab(uriString);
+ }
+
+ getTabForBrowser(browser) {
+ if (browser != this.selectedBrowser) {
+ throw new Error("GeckoView only supports a single tab");
+ }
+
+ return this.selectedTab;
+ }
+
+ removeEventListener() {
+ this.window.removeEventListener(...arguments);
+ }
+
+ removeTab(tab) {
+ if (tab != this.selectedTab) {
+ throw new Error("GeckoView only supports a single tab");
+ }
+
+ return lazy.windowManager.closeWindow(this.window);
+ }
+}
diff --git a/remote/shared/Navigate.sys.mjs b/remote/shared/Navigate.sys.mjs
new file mode 100644
index 0000000000..9b72c0dfbf
--- /dev/null
+++ b/remote/shared/Navigate.sys.mjs
@@ -0,0 +1,435 @@
+/* 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 { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ clearTimeout: "resource://gre/modules/Timer.sys.mjs",
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+
+ Deferred: "chrome://remote/content/shared/Sync.sys.mjs",
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+ truncate: "chrome://remote/content/shared/Format.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "logger", () =>
+ lazy.Log.get(lazy.Log.TYPES.REMOTE_AGENT)
+);
+
+// Define a custom multiplier to apply to the unload timer on various platforms.
+// This multiplier should only reflect the navigation performance of the
+// platform and not the overall performance.
+ChromeUtils.defineLazyGetter(lazy, "UNLOAD_TIMEOUT_MULTIPLIER", () => {
+ if (AppConstants.MOZ_CODE_COVERAGE) {
+ // Navigation on ccov platforms can be extremely slow because new processes
+ // need to be instrumented for coverage on startup.
+ return 16;
+ }
+
+ if (AppConstants.ASAN || AppConstants.DEBUG || AppConstants.TSAN) {
+ // Use an extended timeout on slow platforms.
+ return 8;
+ }
+
+ return 1;
+});
+
+export const DEFAULT_UNLOAD_TIMEOUT = 200;
+
+/**
+ * Returns the multiplier used for the unload timer. Useful for tests which
+ * assert the behavior of this timeout.
+ */
+export function getUnloadTimeoutMultiplier() {
+ return lazy.UNLOAD_TIMEOUT_MULTIPLIER;
+}
+
+// Used to keep weak references of webProgressListeners alive.
+const webProgressListeners = new Set();
+
+/**
+ * Wait until the initial load of the given WebProgress is done.
+ *
+ * @param {WebProgress} webProgress
+ * The WebProgress instance to observe.
+ * @param {object=} options
+ * @param {boolean=} options.resolveWhenStarted
+ * Flag to indicate that the Promise has to be resolved when the
+ * page load has been started. Otherwise wait until the page has
+ * finished loading. Defaults to `false`.
+ * @param {number=} options.unloadTimeout
+ * Time to allow before the page gets unloaded. See ProgressListener options.
+ * @returns {Promise}
+ * Promise which resolves when the page load is in the expected state.
+ * Values as returned:
+ * - {nsIURI} currentURI The current URI of the page
+ * - {nsIURI} targetURI Target URI of the navigation
+ */
+export async function waitForInitialNavigationCompleted(
+ webProgress,
+ options = {}
+) {
+ const { resolveWhenStarted = false, unloadTimeout } = options;
+
+ const browsingContext = webProgress.browsingContext;
+
+ // Start the listener right away to avoid race conditions.
+ const listener = new ProgressListener(webProgress, {
+ resolveWhenStarted,
+ unloadTimeout,
+ });
+ const navigated = listener.start();
+
+ // Right after a browsing context has been attached it could happen that
+ // no window global has been set yet. Consider this as nothing has been
+ // loaded yet.
+ let isInitial = true;
+ if (browsingContext.currentWindowGlobal) {
+ isInitial = browsingContext.currentWindowGlobal.isInitialDocument;
+ }
+
+ // If the current document is not the initial "about:blank" and is also
+ // no longer loading, assume the navigation is done and return.
+ if (!isInitial && !listener.isLoadingDocument) {
+ lazy.logger.trace(
+ lazy.truncate`[${browsingContext.id}] Document already finished loading: ${browsingContext.currentURI?.spec}`
+ );
+
+ // Will resolve the navigated promise.
+ listener.stop();
+ }
+
+ await navigated;
+
+ return {
+ currentURI: listener.currentURI,
+ targetURI: listener.targetURI,
+ };
+}
+
+/**
+ * WebProgressListener to observe for page loads.
+ */
+export class ProgressListener {
+ #expectNavigation;
+ #resolveWhenStarted;
+ #unloadTimeout;
+ #waitForExplicitStart;
+ #webProgress;
+
+ #deferredNavigation;
+ #seenStartFlag;
+ #targetURI;
+ #unloadTimerId;
+
+ /**
+ * Create a new WebProgressListener instance.
+ *
+ * @param {WebProgress} webProgress
+ * The web progress to attach the listener to.
+ * @param {object=} options
+ * @param {boolean=} options.expectNavigation
+ * Flag to indicate that a navigation is guaranteed to happen.
+ * When set to `true`, the ProgressListener will ignore options.unloadTimeout
+ * and will only resolve when the expected navigation happens.
+ * Defaults to `false`.
+ * @param {boolean=} options.resolveWhenStarted
+ * Flag to indicate that the Promise has to be resolved when the
+ * page load has been started. Otherwise wait until the page has
+ * finished loading. Defaults to `false`.
+ * @param {number=} options.unloadTimeout
+ * Time to allow before the page gets unloaded. Defaults to 200ms on
+ * regular platforms. A multiplier will be applied on slower platforms
+ * (eg. debug, ccov...).
+ * Ignored if options.expectNavigation is set to `true`
+ * @param {boolean=} options.waitForExplicitStart
+ * Flag to indicate that the Promise can only resolve after receiving a
+ * STATE_START state change. In other words, if the webProgress is already
+ * navigating, the Promise will only resolve for the next navigation.
+ * Defaults to `false`.
+ */
+ constructor(webProgress, options = {}) {
+ const {
+ expectNavigation = false,
+ resolveWhenStarted = false,
+ unloadTimeout = DEFAULT_UNLOAD_TIMEOUT,
+ waitForExplicitStart = false,
+ } = options;
+
+ this.#expectNavigation = expectNavigation;
+ this.#resolveWhenStarted = resolveWhenStarted;
+ this.#unloadTimeout = unloadTimeout * lazy.UNLOAD_TIMEOUT_MULTIPLIER;
+ this.#waitForExplicitStart = waitForExplicitStart;
+ this.#webProgress = webProgress;
+
+ this.#deferredNavigation = null;
+ this.#seenStartFlag = false;
+ this.#targetURI = null;
+ this.#unloadTimerId = null;
+ }
+
+ get #messagePrefix() {
+ return `[${this.browsingContext.id}] ${this.constructor.name}`;
+ }
+
+ get browsingContext() {
+ return this.#webProgress.browsingContext;
+ }
+
+ get currentURI() {
+ return this.#webProgress.browsingContext.currentURI;
+ }
+
+ get isLoadingDocument() {
+ return this.#webProgress.isLoadingDocument;
+ }
+
+ get isStarted() {
+ return !!this.#deferredNavigation;
+ }
+
+ get targetURI() {
+ return this.#targetURI;
+ }
+
+ #checkLoadingState(request, options = {}) {
+ const { isStart = false, isStop = false, status = 0 } = options;
+
+ this.#trace(`Check loading state: isStart=${isStart} isStop=${isStop}`);
+ if (isStart && !this.#seenStartFlag) {
+ this.#seenStartFlag = true;
+
+ this.#targetURI = this.#getTargetURI(request);
+
+ this.#trace(`state=start: ${this.targetURI?.spec}`);
+
+ if (this.#unloadTimerId !== null) {
+ lazy.clearTimeout(this.#unloadTimerId);
+ this.#trace("Cleared the unload timer");
+ this.#unloadTimerId = null;
+ }
+
+ if (this.#resolveWhenStarted) {
+ this.#trace("Request to stop listening when navigation started");
+ this.stop();
+ return;
+ }
+ }
+
+ if (isStop && this.#seenStartFlag) {
+ // Treat NS_ERROR_PARSED_DATA_CACHED as a success code
+ // since navigation happened and content has been loaded.
+ if (
+ !Components.isSuccessCode(status) &&
+ status != Cr.NS_ERROR_PARSED_DATA_CACHED
+ ) {
+ if (
+ status == Cr.NS_BINDING_ABORTED &&
+ this.browsingContext.currentWindowGlobal.isInitialDocument
+ ) {
+ this.#trace(
+ "Ignore aborted navigation error to the initial document, real document will be loaded."
+ );
+ return;
+ }
+
+ // The navigation request caused an error.
+ const errorName = ChromeUtils.getXPCOMErrorName(status);
+ this.#trace(
+ `state=stop: error=0x${status.toString(16)} (${errorName})`
+ );
+ this.stop({ error: new Error(errorName) });
+ return;
+ }
+
+ this.#trace(`state=stop: ${this.currentURI.spec}`);
+
+ // If a non initial page finished loading the navigation is done.
+ if (!this.browsingContext.currentWindowGlobal.isInitialDocument) {
+ this.stop();
+ return;
+ }
+
+ // Otherwise wait for a potential additional page load.
+ this.#trace(
+ "Initial document loaded. Wait for a potential further navigation."
+ );
+ this.#seenStartFlag = false;
+ this.#setUnloadTimer();
+ }
+ }
+
+ #getTargetURI(request) {
+ try {
+ return request.QueryInterface(Ci.nsIChannel).originalURI;
+ } catch (e) {}
+
+ return null;
+ }
+
+ #setUnloadTimer() {
+ if (this.#expectNavigation) {
+ this.#trace("Skip setting the unload timer");
+ } else {
+ this.#trace(`Setting unload timer (${this.#unloadTimeout}ms)`);
+
+ this.#unloadTimerId = lazy.setTimeout(() => {
+ this.#trace(`No navigation detected: ${this.currentURI?.spec}`);
+ // Assume the target is the currently loaded URI.
+ this.#targetURI = this.currentURI;
+ this.stop();
+ }, this.#unloadTimeout);
+ }
+ }
+
+ #trace(message) {
+ lazy.logger.trace(lazy.truncate`${this.#messagePrefix} ${message}`);
+ }
+
+ onStateChange(progress, request, flag, status) {
+ this.#checkLoadingState(request, {
+ isStart: flag & Ci.nsIWebProgressListener.STATE_START,
+ isStop: flag & Ci.nsIWebProgressListener.STATE_STOP,
+ status,
+ });
+ }
+
+ onLocationChange(progress, request, location, flag) {
+ // If an error page has been loaded abort the navigation.
+ if (flag & Ci.nsIWebProgressListener.LOCATION_CHANGE_ERROR_PAGE) {
+ this.#trace(`location=errorPage: ${location.spec}`);
+ this.stop({ error: new Error("Address restricted") });
+ return;
+ }
+
+ // If location has changed in the same document the navigation is done.
+ if (flag & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT) {
+ this.#targetURI = location;
+ this.#trace(`location=sameDocument: ${this.targetURI?.spec}`);
+ this.stop();
+ }
+ }
+
+ /**
+ * Start observing web progress changes.
+ *
+ * @returns {Promise}
+ * A promise that will resolve when the navigation has been finished.
+ */
+ start() {
+ if (this.#deferredNavigation) {
+ throw new Error(`Progress listener already started`);
+ }
+
+ this.#trace(
+ `Start: expectNavigation=${this.#expectNavigation} resolveWhenStarted=${
+ this.#resolveWhenStarted
+ } unloadTimeout=${this.#unloadTimeout} waitForExplicitStart=${
+ this.#waitForExplicitStart
+ }`
+ );
+
+ if (this.#webProgress.isLoadingDocument) {
+ this.#targetURI = this.#getTargetURI(this.#webProgress.documentRequest);
+ this.#trace(`Document already loading ${this.#targetURI?.spec}`);
+
+ if (this.#resolveWhenStarted && !this.#waitForExplicitStart) {
+ this.#trace(
+ "Resolve on document loading if not waiting for a load or a new navigation"
+ );
+ return Promise.resolve();
+ }
+ }
+
+ this.#deferredNavigation = new lazy.Deferred();
+
+ // Enable all location change and state notifications to get informed about an upcoming load
+ // as early as possible.
+ this.#webProgress.addProgressListener(
+ this,
+ Ci.nsIWebProgress.NOTIFY_LOCATION | Ci.nsIWebProgress.NOTIFY_STATE_ALL
+ );
+
+ webProgressListeners.add(this);
+
+ if (this.#webProgress.isLoadingDocument && !this.#waitForExplicitStart) {
+ this.#checkLoadingState(this.#webProgress.documentRequest, {
+ isStart: true,
+ });
+ } else {
+ // If the document is not loading yet wait some time for the navigation
+ // to be started.
+ this.#setUnloadTimer();
+ }
+
+ return this.#deferredNavigation.promise;
+ }
+
+ /**
+ * Stop observing web progress changes.
+ *
+ * @param {object=} options
+ * @param {Error=} options.error
+ * If specified the navigation promise will be rejected with this error.
+ */
+ stop(options = {}) {
+ const { error } = options;
+
+ this.#trace(`Stop: has error=${!!error}`);
+
+ if (!this.#deferredNavigation) {
+ throw new Error("Progress listener not yet started");
+ }
+
+ lazy.clearTimeout(this.#unloadTimerId);
+ this.#unloadTimerId = null;
+
+ this.#webProgress.removeProgressListener(
+ this,
+ Ci.nsIWebProgress.NOTIFY_LOCATION | Ci.nsIWebProgress.NOTIFY_STATE_ALL
+ );
+ webProgressListeners.delete(this);
+
+ if (!this.#targetURI) {
+ // If no target URI has been set yet it should be the current URI
+ this.#targetURI = this.browsingContext.currentURI;
+ }
+
+ if (error) {
+ this.#deferredNavigation.reject(error);
+ } else {
+ this.#deferredNavigation.resolve();
+ }
+
+ this.#deferredNavigation = null;
+ }
+
+ /**
+ * Stop the progress listener if and only if we already detected a navigation
+ * start.
+ *
+ * @param {object=} options
+ * @param {Error=} options.error
+ * If specified the navigation promise will be rejected with this error.
+ */
+ stopIfStarted(options) {
+ this.#trace(`Stop if started: seenStartFlag=${this.#seenStartFlag}`);
+ if (this.#seenStartFlag) {
+ this.stop(options);
+ }
+ }
+
+ toString() {
+ return `[object ${this.constructor.name}]`;
+ }
+
+ get QueryInterface() {
+ return ChromeUtils.generateQI([
+ "nsIWebProgressListener",
+ "nsISupportsWeakReference",
+ ]);
+ }
+}
diff --git a/remote/shared/NavigationManager.sys.mjs b/remote/shared/NavigationManager.sys.mjs
new file mode 100644
index 0000000000..1f19ef3c0d
--- /dev/null
+++ b/remote/shared/NavigationManager.sys.mjs
@@ -0,0 +1,414 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ generateUUID: "chrome://remote/content/shared/UUID.sys.mjs",
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+ registerNavigationListenerActor:
+ "chrome://remote/content/shared/js-window-actors/NavigationListenerActor.sys.mjs",
+ TabManager: "chrome://remote/content/shared/TabManager.sys.mjs",
+ truncate: "chrome://remote/content/shared/Format.sys.mjs",
+ unregisterNavigationListenerActor:
+ "chrome://remote/content/shared/js-window-actors/NavigationListenerActor.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
+
+/**
+ * @typedef {object} BrowsingContextDetails
+ * @property {string} browsingContextId - The browsing context id.
+ * @property {string} browserId - The id of the Browser owning the browsing
+ * context.
+ * @property {BrowsingContext=} context - The BrowsingContext itself, if
+ * available.
+ * @property {boolean} isTopBrowsingContext - Whether the browsing context is
+ * top level.
+ */
+
+/**
+ * @typedef {object} NavigationInfo
+ * @property {boolean} finished - Whether the navigation is finished or not.
+ * @property {string} navigationId - The UUID for the navigation.
+ * @property {string} navigable - The UUID for the navigable.
+ * @property {string} url - The target url for the navigation.
+ */
+
+/**
+ * The NavigationRegistry is responsible for monitoring all navigations happening
+ * in the browser.
+ *
+ * It relies on a JSWindowActor pair called NavigationListener{Parent|Child},
+ * found under remote/shared/js-window-actors. As a simple overview, the
+ * NavigationListenerChild will monitor navigations in all window globals using
+ * content process WebProgressListener, and will forward each relevant update to
+ * the NavigationListenerParent
+ *
+ * The NavigationRegistry singleton holds the map of navigations, from navigable
+ * to NavigationInfo. It will also be called by NavigationListenerParent
+ * whenever a navigation event happens.
+ *
+ * This singleton is not exported outside of this class, and consumers instead
+ * need to use the NavigationManager class. The NavigationRegistry keeps track
+ * of how many NavigationListener instances are currently listening in order to
+ * know if the NavigationListenerActor should be registered or not.
+ *
+ * The NavigationRegistry exposes an API to retrieve the current or last
+ * navigation for a given navigable, and also forwards events to notify about
+ * navigation updates to individual NavigationManager instances.
+ *
+ * @class NavigationRegistry
+ */
+class NavigationRegistry extends EventEmitter {
+ #managers;
+ #navigations;
+ #navigationIds;
+
+ constructor() {
+ super();
+
+ // Set of NavigationManager instances currently used.
+ this.#managers = new Set();
+
+ // Maps navigable to NavigationInfo.
+ this.#navigations = new WeakMap();
+
+ // Maps navigable id to navigation id. Only used to pre-register navigation
+ // ids before the actual event is detected.
+ this.#navigationIds = new Map();
+ }
+
+ /**
+ * Retrieve the last known navigation data for a given browsing context.
+ *
+ * @param {BrowsingContext} context
+ * The browsing context for which the navigation event was recorded.
+ * @returns {NavigationInfo|null}
+ * The last known navigation data, or null.
+ */
+ getNavigationForBrowsingContext(context) {
+ if (!lazy.TabManager.isValidCanonicalBrowsingContext(context)) {
+ // Bail out if the provided context is not a valid CanonicalBrowsingContext
+ // instance.
+ return null;
+ }
+
+ const navigable = lazy.TabManager.getNavigableForBrowsingContext(context);
+ if (!this.#navigations.has(navigable)) {
+ return null;
+ }
+
+ return this.#navigations.get(navigable);
+ }
+
+ /**
+ * Start monitoring navigations in all browsing contexts. This will register
+ * the NavigationListener JSWindowActor and will initialize them in all
+ * existing browsing contexts.
+ */
+ startMonitoring(listener) {
+ if (this.#managers.size == 0) {
+ lazy.registerNavigationListenerActor();
+ }
+
+ this.#managers.add(listener);
+ }
+
+ /**
+ * Stop monitoring navigations. This will unregister the NavigationListener
+ * JSWindowActor and clear the information collected about navigations so far.
+ */
+ stopMonitoring(listener) {
+ if (!this.#managers.has(listener)) {
+ return;
+ }
+
+ this.#managers.delete(listener);
+ if (this.#managers.size == 0) {
+ lazy.unregisterNavigationListenerActor();
+ // Clear the map.
+ this.#navigations = new WeakMap();
+ }
+ }
+
+ /**
+ * Called when a same-document navigation is recorded from the
+ * NavigationListener actors.
+ *
+ * This entry point is only intended to be called from
+ * NavigationListenerParent, to avoid setting up observers or listeners,
+ * which are unnecessary since NavigationManager has to be a singleton.
+ *
+ * @param {object} data
+ * @param {BrowsingContext} data.context
+ * The browsing context for which the navigation event was recorded.
+ * @param {string} data.url
+ * The URL as string for the navigation.
+ * @returns {NavigationInfo}
+ * The navigation created for this same-document navigation.
+ */
+ notifyLocationChanged(data) {
+ const { contextDetails, url } = data;
+
+ const context = this.#getContextFromContextDetails(contextDetails);
+ const navigable = lazy.TabManager.getNavigableForBrowsingContext(context);
+ const navigableId = lazy.TabManager.getIdForBrowsingContext(context);
+
+ const navigationId = this.#getOrCreateNavigationId(navigableId);
+ const navigation = { finished: true, navigationId, url };
+ this.#navigations.set(navigable, navigation);
+
+ // Same document navigations are immediately done, fire a single event.
+ this.emit("location-changed", { navigationId, navigableId, url });
+
+ return navigation;
+ }
+
+ /**
+ * Called when a navigation-started event is recorded from the
+ * NavigationListener actors.
+ *
+ * This entry point is only intended to be called from
+ * NavigationListenerParent, to avoid setting up observers or listeners,
+ * which are unnecessary since NavigationManager has to be a singleton.
+ *
+ * @param {object} data
+ * @param {BrowsingContextDetails} data.contextDetails
+ * The details about the browsing context for this navigation.
+ * @param {string} data.url
+ * The URL as string for the navigation.
+ * @returns {NavigationInfo}
+ * The created navigation or the ongoing navigation, if applicable.
+ */
+ notifyNavigationStarted(data) {
+ const { contextDetails, url } = data;
+
+ const context = this.#getContextFromContextDetails(contextDetails);
+ const navigable = lazy.TabManager.getNavigableForBrowsingContext(context);
+ const navigableId = lazy.TabManager.getIdForBrowsingContext(context);
+
+ let navigation = this.#navigations.get(navigable);
+ if (navigation && !navigation.finished) {
+ // If we are already monitoring a navigation for this navigable, for which
+ // we did not receive a navigation-stopped event, this navigation
+ // is already tracked and we don't want to create another id & event.
+ lazy.logger.trace(
+ `[${navigableId}] Skipping already tracked navigation, navigationId: ${navigation.navigationId}`
+ );
+ return navigation;
+ }
+
+ const navigationId = this.#getOrCreateNavigationId(navigableId);
+ navigation = { finished: false, navigationId, url };
+ this.#navigations.set(navigable, navigation);
+
+ lazy.logger.trace(
+ lazy.truncate`[${navigableId}] Navigation started for url: ${url} (${navigationId})`
+ );
+
+ this.emit("navigation-started", { navigationId, navigableId, url });
+
+ return navigation;
+ }
+
+ /**
+ * Called when a navigation-stopped event is recorded from the
+ * NavigationListener actors.
+ *
+ * @param {object} data
+ * @param {BrowsingContextDetails} data.contextDetails
+ * The details about the browsing context for this navigation.
+ * @param {string} data.url
+ * The URL as string for the navigation.
+ * @returns {NavigationInfo}
+ * The stopped navigation if any, or null.
+ */
+ notifyNavigationStopped(data) {
+ const { contextDetails, url } = data;
+
+ const context = this.#getContextFromContextDetails(contextDetails);
+ const navigable = lazy.TabManager.getNavigableForBrowsingContext(context);
+ const navigableId = lazy.TabManager.getIdForBrowsingContext(context);
+
+ const navigation = this.#navigations.get(navigable);
+ if (!navigation) {
+ lazy.logger.trace(
+ lazy.truncate`[${navigableId}] No navigation found to stop for url: ${url}`
+ );
+ return null;
+ }
+
+ if (navigation.finished) {
+ lazy.logger.trace(
+ `[${navigableId}] Navigation already marked as finished, navigationId: ${navigation.navigationId}`
+ );
+ return navigation;
+ }
+
+ lazy.logger.trace(
+ lazy.truncate`[${navigableId}] Navigation finished for url: ${url} (${navigation.navigationId})`
+ );
+
+ navigation.finished = true;
+
+ this.emit("navigation-stopped", {
+ navigationId: navigation.navigationId,
+ navigableId,
+ url,
+ });
+
+ return navigation;
+ }
+
+ /**
+ * Register a navigation id to be used for the next navigation for the
+ * provided browsing context details.
+ *
+ * @param {object} data
+ * @param {BrowsingContextDetails} data.contextDetails
+ * The details about the browsing context for this navigation.
+ * @returns {string}
+ * The UUID created the upcoming navigation.
+ */
+ registerNavigationId(data) {
+ const { contextDetails } = data;
+ const context = this.#getContextFromContextDetails(contextDetails);
+ const navigableId = lazy.TabManager.getIdForBrowsingContext(context);
+
+ const navigationId = lazy.generateUUID();
+ this.#navigationIds.set(navigableId, navigationId);
+
+ return navigationId;
+ }
+
+ #getContextFromContextDetails(contextDetails) {
+ if (contextDetails.context) {
+ return contextDetails.context;
+ }
+
+ return contextDetails.isTopBrowsingContext
+ ? BrowsingContext.getCurrentTopByBrowserId(contextDetails.browserId)
+ : BrowsingContext.get(contextDetails.browsingContextId);
+ }
+
+ #getOrCreateNavigationId(navigableId) {
+ let navigationId;
+ if (this.#navigationIds.has(navigableId)) {
+ navigationId = this.#navigationIds.get(navigableId, navigationId);
+ this.#navigationIds.delete(navigableId);
+ } else {
+ navigationId = lazy.generateUUID();
+ }
+ return navigationId;
+ }
+}
+
+// Create a private NavigationRegistry singleton.
+const navigationRegistry = new NavigationRegistry();
+
+/**
+ * See NavigationRegistry.notifyLocationChanged.
+ *
+ * This entry point is only intended to be called from NavigationListenerParent,
+ * to avoid setting up observers or listeners, which are unnecessary since
+ * NavigationRegistry has to be a singleton.
+ */
+export function notifyLocationChanged(data) {
+ return navigationRegistry.notifyLocationChanged(data);
+}
+
+/**
+ * See NavigationRegistry.notifyNavigationStarted.
+ *
+ * This entry point is only intended to be called from NavigationListenerParent,
+ * to avoid setting up observers or listeners, which are unnecessary since
+ * NavigationRegistry has to be a singleton.
+ */
+export function notifyNavigationStarted(data) {
+ return navigationRegistry.notifyNavigationStarted(data);
+}
+
+/**
+ * See NavigationRegistry.notifyNavigationStopped.
+ *
+ * This entry point is only intended to be called from NavigationListenerParent,
+ * to avoid setting up observers or listeners, which are unnecessary since
+ * NavigationRegistry has to be a singleton.
+ */
+export function notifyNavigationStopped(data) {
+ return navigationRegistry.notifyNavigationStopped(data);
+}
+
+export function registerNavigationId(data) {
+ return navigationRegistry.registerNavigationId(data);
+}
+
+/**
+ * The NavigationManager exposes the NavigationRegistry data via a class which
+ * needs to be individually instantiated by each consumer. This allow to track
+ * how many consumers need navigation data at any point so that the
+ * NavigationRegistry can register or unregister the underlying JSWindowActors
+ * correctly.
+ *
+ * @fires navigation-started
+ * The NavigationManager emits "navigation-started" when a new navigation is
+ * detected, with the following object as payload:
+ * - {string} navigationId - The UUID for the navigation.
+ * - {string} navigableId - The UUID for the navigable.
+ * - {string} url - The target url for the navigation.
+ * @fires navigation-stopped
+ * The NavigationManager emits "navigation-stopped" when a known navigation
+ * is stopped, with the following object as payload:
+ * - {string} navigationId - The UUID for the navigation.
+ * - {string} navigableId - The UUID for the navigable.
+ * - {string} url - The target url for the navigation.
+ */
+export class NavigationManager extends EventEmitter {
+ #monitoring;
+
+ constructor() {
+ super();
+
+ this.#monitoring = false;
+ }
+
+ destroy() {
+ this.stopMonitoring();
+ }
+
+ getNavigationForBrowsingContext(context) {
+ return navigationRegistry.getNavigationForBrowsingContext(context);
+ }
+
+ startMonitoring() {
+ if (this.#monitoring) {
+ return;
+ }
+
+ this.#monitoring = true;
+ navigationRegistry.startMonitoring(this);
+ navigationRegistry.on("navigation-started", this.#onNavigationEvent);
+ navigationRegistry.on("location-changed", this.#onNavigationEvent);
+ navigationRegistry.on("navigation-stopped", this.#onNavigationEvent);
+ }
+
+ stopMonitoring() {
+ if (!this.#monitoring) {
+ return;
+ }
+
+ this.#monitoring = false;
+ navigationRegistry.stopMonitoring(this);
+ navigationRegistry.off("navigation-started", this.#onNavigationEvent);
+ navigationRegistry.off("location-changed", this.#onNavigationEvent);
+ navigationRegistry.off("navigation-stopped", this.#onNavigationEvent);
+ }
+
+ #onNavigationEvent = (eventName, data) => {
+ this.emit(eventName, data);
+ };
+}
diff --git a/remote/shared/PDF.sys.mjs b/remote/shared/PDF.sys.mjs
new file mode 100644
index 0000000000..10fc2b0bae
--- /dev/null
+++ b/remote/shared/PDF.sys.mjs
@@ -0,0 +1,244 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs",
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
+
+export const print = {
+ maxScaleValue: 2.0,
+ minScaleValue: 0.1,
+};
+
+print.defaults = {
+ // The size of the page in centimeters.
+ page: {
+ width: 21.59,
+ height: 27.94,
+ },
+ margin: {
+ top: 1.0,
+ bottom: 1.0,
+ left: 1.0,
+ right: 1.0,
+ },
+ orientationValue: ["landscape", "portrait"],
+};
+
+print.addDefaultSettings = function (settings) {
+ const {
+ background = false,
+ margin = {},
+ orientation = "portrait",
+ page = {},
+ pageRanges = [],
+ scale = 1.0,
+ shrinkToFit = true,
+ } = settings;
+
+ lazy.assert.object(page, `Expected "page" to be a object, got ${page}`);
+ lazy.assert.object(margin, `Expected "margin" to be a object, got ${margin}`);
+
+ if (!("width" in page)) {
+ page.width = print.defaults.page.width;
+ }
+
+ if (!("height" in page)) {
+ page.height = print.defaults.page.height;
+ }
+
+ if (!("top" in margin)) {
+ margin.top = print.defaults.margin.top;
+ }
+
+ if (!("bottom" in margin)) {
+ margin.bottom = print.defaults.margin.bottom;
+ }
+
+ if (!("right" in margin)) {
+ margin.right = print.defaults.margin.right;
+ }
+
+ if (!("left" in margin)) {
+ margin.left = print.defaults.margin.left;
+ }
+
+ return {
+ background,
+ margin,
+ orientation,
+ page,
+ pageRanges,
+ scale,
+ shrinkToFit,
+ };
+};
+
+print.getPrintSettings = function (settings) {
+ const psService = Cc["@mozilla.org/gfx/printsettings-service;1"].getService(
+ Ci.nsIPrintSettingsService
+ );
+
+ let cmToInches = cm => cm / 2.54;
+ const printSettings = psService.createNewPrintSettings();
+ printSettings.isInitializedFromPrinter = true;
+ printSettings.isInitializedFromPrefs = true;
+ printSettings.outputFormat = Ci.nsIPrintSettings.kOutputFormatPDF;
+ printSettings.printerName = "marionette";
+ printSettings.printSilent = true;
+
+ // Setting the paperSizeUnit to kPaperSizeMillimeters doesn't work on mac
+ printSettings.paperSizeUnit = Ci.nsIPrintSettings.kPaperSizeInches;
+ printSettings.paperWidth = cmToInches(settings.page.width);
+ printSettings.paperHeight = cmToInches(settings.page.height);
+ printSettings.usePageRuleSizeAsPaperSize = true;
+
+ printSettings.marginBottom = cmToInches(settings.margin.bottom);
+ printSettings.marginLeft = cmToInches(settings.margin.left);
+ printSettings.marginRight = cmToInches(settings.margin.right);
+ printSettings.marginTop = cmToInches(settings.margin.top);
+
+ printSettings.printBGColors = settings.background;
+ printSettings.printBGImages = settings.background;
+ printSettings.scaling = settings.scale;
+ printSettings.shrinkToFit = settings.shrinkToFit;
+
+ printSettings.headerStrCenter = "";
+ printSettings.headerStrLeft = "";
+ printSettings.headerStrRight = "";
+ printSettings.footerStrCenter = "";
+ printSettings.footerStrLeft = "";
+ printSettings.footerStrRight = "";
+
+ // Override any os-specific unwriteable margins
+ printSettings.unwriteableMarginTop = 0;
+ printSettings.unwriteableMarginLeft = 0;
+ printSettings.unwriteableMarginBottom = 0;
+ printSettings.unwriteableMarginRight = 0;
+
+ if (settings.orientation === "landscape") {
+ printSettings.orientation = Ci.nsIPrintSettings.kLandscapeOrientation;
+ }
+
+ if (settings.pageRanges?.length) {
+ printSettings.pageRanges = parseRanges(settings.pageRanges);
+ }
+
+ return printSettings;
+};
+
+/**
+ * Convert array of strings of the form ["1-3", "2-4", "7", "9-"] to an flat array of
+ * limits, like [1, 4, 7, 7, 9, 2**31 - 1] (meaning 1-4, 7, 9-end)
+ *
+ * @param {Array.<string|number>} ranges
+ * Page ranges to print, e.g., ['1-5', '8', '11-13'].
+ * Defaults to the empty string, which means print all pages.
+ *
+ * @returns {Array.<number>}
+ * Even-length array containing page range limits
+ */
+function parseRanges(ranges) {
+ const MAX_PAGES = 0x7fffffff;
+
+ if (ranges.length === 0) {
+ return [];
+ }
+
+ let allLimits = [];
+
+ for (let range of ranges) {
+ let limits;
+ if (typeof range !== "string") {
+ // We got a single integer so the limits are just that page
+ lazy.assert.positiveInteger(range);
+ limits = [range, range];
+ } else {
+ // We got a string presumably of the form <int> | <int>? "-" <int>?
+ const msg = `Expected a range of the form <int> or <int>-<int>, got ${range}`;
+
+ limits = range.split("-").map(x => x.trim());
+ lazy.assert.that(o => [1, 2].includes(o.length), msg)(limits);
+
+ // Single numbers map to a range with that page at the start and the end
+ if (limits.length == 1) {
+ limits.push(limits[0]);
+ }
+
+ // Need to check that both limits are strings conisting only of
+ // decimal digits (or empty strings)
+ const assertNumeric = lazy.assert.that(o => /^\d*$/.test(o), msg);
+ limits.every(x => assertNumeric(x));
+
+ // Convert from strings representing numbers to actual numbers
+ // If we don't have an upper bound, choose something very large;
+ // the print code will later truncate this to the number of pages
+ limits = limits.map((limitStr, i) => {
+ if (limitStr == "") {
+ return i == 0 ? 1 : MAX_PAGES;
+ }
+ return parseInt(limitStr);
+ });
+ }
+ lazy.assert.that(
+ x => x[0] <= x[1],
+ "Lower limit ${parts[0]} is higher than upper limit ${parts[1]}"
+ )(limits);
+
+ allLimits.push(limits);
+ }
+ // Order by lower limit
+ allLimits.sort((a, b) => a[0] - b[0]);
+ let parsedRanges = [allLimits.shift()];
+ for (let limits of allLimits) {
+ let prev = parsedRanges[parsedRanges.length - 1];
+ let prevMax = prev[1];
+ let [min, max] = limits;
+ if (min <= prevMax) {
+ // min is inside previous range, so extend the max if needed
+ if (max > prevMax) {
+ prev[1] = max;
+ }
+ } else {
+ // Otherwise we have a new range
+ parsedRanges.push(limits);
+ }
+ }
+
+ let rv = parsedRanges.flat();
+ lazy.logger.debug(`Got page ranges [${rv.join(", ")}]`);
+ return rv;
+}
+
+print.printToBinaryString = async function (browsingContext, printSettings) {
+ // Create a stream to write to.
+ const stream = Cc["@mozilla.org/storagestream;1"].createInstance(
+ Ci.nsIStorageStream
+ );
+ stream.init(4096, 0xffffffff);
+
+ printSettings.outputDestination =
+ Ci.nsIPrintSettings.kOutputDestinationStream;
+ printSettings.outputStream = stream.getOutputStream(0);
+
+ await browsingContext.print(printSettings);
+
+ const inputStream = Cc["@mozilla.org/binaryinputstream;1"].createInstance(
+ Ci.nsIBinaryInputStream
+ );
+
+ inputStream.setInputStream(stream.newInputStream(0));
+
+ const available = inputStream.available();
+ const bytes = inputStream.readBytes(available);
+
+ stream.close();
+
+ return bytes;
+};
diff --git a/remote/shared/Prompt.sys.mjs b/remote/shared/Prompt.sys.mjs
new file mode 100644
index 0000000000..bacf24c5d1
--- /dev/null
+++ b/remote/shared/Prompt.sys.mjs
@@ -0,0 +1,233 @@
+/* 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, {
+ AppInfo: "chrome://remote/content/shared/AppInfo.sys.mjs",
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
+
+const COMMON_DIALOG = "chrome://global/content/commonDialog.xhtml";
+
+/** @namespace */
+export const modal = {
+ ACTION_CLOSED: "closed",
+ ACTION_OPENED: "opened",
+};
+
+/**
+ * Check for already existing modal or tab modal dialogs and
+ * return the first one.
+ *
+ * @param {browser.Context} context
+ * Reference to the browser context to check for existent dialogs.
+ *
+ * @returns {modal.Dialog}
+ * Returns instance of the Dialog class, or `null` if no modal dialog
+ * is present.
+ */
+modal.findPrompt = function (context) {
+ // First check if there is a modal dialog already present for the
+ // current browser window.
+ for (let win of Services.wm.getEnumerator(null)) {
+ // TODO: Use BrowserWindowTracker.getTopWindow for modal dialogs without
+ // an opener.
+ if (
+ win.document.documentURI === COMMON_DIALOG &&
+ win.opener &&
+ win.opener === context.window
+ ) {
+ lazy.logger.trace("Found open window modal prompt");
+ return new modal.Dialog(() => context, win);
+ }
+ }
+
+ if (lazy.AppInfo.isAndroid) {
+ const geckoViewPrompts = context.window.prompts();
+ if (geckoViewPrompts.length) {
+ lazy.logger.trace("Found open GeckoView prompt");
+ const prompt = geckoViewPrompts[0];
+ return new modal.Dialog(() => context, prompt);
+ }
+ }
+
+ const contentBrowser = context.contentBrowser;
+
+ // If no modal dialog has been found yet, also check for tab and content modal
+ // dialogs for the current tab.
+ //
+ // TODO: Find an adequate implementation for Firefox on Android (bug 1708105)
+ if (contentBrowser?.tabDialogBox) {
+ let dialogs = contentBrowser.tabDialogBox.getTabDialogManager().dialogs;
+ if (dialogs.length) {
+ lazy.logger.trace("Found open tab modal prompt");
+ return new modal.Dialog(() => context, dialogs[0].frameContentWindow);
+ }
+
+ dialogs = contentBrowser.tabDialogBox.getContentDialogManager().dialogs;
+
+ // Even with the dialog manager handing back a dialog, the `Dialog` property
+ // gets lazily added. If it's not set yet, ignore the dialog for now.
+ if (dialogs.length && dialogs[0].frameContentWindow.Dialog) {
+ lazy.logger.trace("Found open content prompt");
+ return new modal.Dialog(() => context, dialogs[0].frameContentWindow);
+ }
+ }
+
+ // If no modal dialog has been found yet, check for old non SubDialog based
+ // content modal dialogs. Even with those deprecated in Firefox 89 we should
+ // keep supporting applications that don't have them implemented yet.
+ if (contentBrowser?.tabModalPromptBox) {
+ const prompts = contentBrowser.tabModalPromptBox.listPrompts();
+ if (prompts.length) {
+ lazy.logger.trace("Found open old-style content prompt");
+ return new modal.Dialog(() => context, null);
+ }
+ }
+
+ return null;
+};
+
+/**
+ * Represents a modal dialog.
+ *
+ * @param {function(): browser.Context} curBrowserFn
+ * Function that returns the current |browser.Context|.
+ * @param {DOMWindow} dialog
+ * DOMWindow of the dialog.
+ */
+modal.Dialog = class {
+ constructor(curBrowserFn, dialog) {
+ this.curBrowserFn_ = curBrowserFn;
+ this.win_ = Cu.getWeakReference(dialog);
+ }
+
+ get args() {
+ if (lazy.AppInfo.isAndroid) {
+ return this.window.args;
+ }
+ let tm = this.tabModal;
+ return tm ? tm.args : null;
+ }
+
+ get curBrowser_() {
+ return this.curBrowserFn_();
+ }
+
+ get isOpen() {
+ if (lazy.AppInfo.isAndroid) {
+ return this.window !== null;
+ }
+ if (!this.ui) {
+ return false;
+ }
+ return true;
+ }
+
+ get isWindowModal() {
+ return [
+ Services.prompt.MODAL_TYPE_WINDOW,
+ Services.prompt.MODAL_TYPE_INTERNAL_WINDOW,
+ ].includes(this.args.modalType);
+ }
+
+ get tabModal() {
+ let win = this.window;
+ if (win) {
+ return win.Dialog;
+ }
+ return this.curBrowser_.getTabModal();
+ }
+
+ get promptType() {
+ const promptType = this.args.promptType;
+
+ if (promptType === "confirmEx" && this.args.inPermitUnload) {
+ return "beforeunload";
+ }
+
+ return promptType;
+ }
+
+ get ui() {
+ let tm = this.tabModal;
+ return tm ? tm.ui : null;
+ }
+
+ /**
+ * For Android, this returns a GeckoViewPrompter, which can be used to control prompts.
+ * Otherwise, this returns the ChromeWindow associated with an open dialog window if
+ * it is currently attached to the DOM.
+ */
+ get window() {
+ if (this.win_) {
+ let win = this.win_.get();
+ if (win && (lazy.AppInfo.isAndroid || win.parent)) {
+ return win;
+ }
+ }
+ return null;
+ }
+
+ set text(inputText) {
+ if (lazy.AppInfo.isAndroid) {
+ this.window.setInputText(inputText);
+ } else {
+ // see toolkit/components/prompts/content/commonDialog.js
+ let { loginTextbox } = this.ui;
+ loginTextbox.value = inputText;
+ }
+ }
+
+ accept() {
+ if (lazy.AppInfo.isAndroid) {
+ // GeckoView does not have a UI, so the methods are called directly
+ this.window.acceptPrompt();
+ } else {
+ const { button0 } = this.ui;
+ button0.click();
+ }
+ }
+
+ dismiss() {
+ if (lazy.AppInfo.isAndroid) {
+ // GeckoView does not have a UI, so the methods are called directly
+ this.window.dismissPrompt();
+ } else {
+ const { button0, button1 } = this.ui;
+ (button1 ? button1 : button0).click();
+ }
+ }
+
+ /**
+ * Returns text of the prompt.
+ *
+ * @returns {string | Promise}
+ * Returns string on desktop and Promise on Android.
+ */
+ async getText() {
+ if (lazy.AppInfo.isAndroid) {
+ const textPromise = await this.window.getPromptText();
+ return textPromise;
+ }
+ return this.ui.infoBody.textContent;
+ }
+
+ /**
+ * Returns text of the prompt input.
+ *
+ * @returns {string}
+ * Returns string on desktop and Promise on Android.
+ */
+ async getInputText() {
+ if (lazy.AppInfo.isAndroid) {
+ const textPromise = await this.window.getInputText();
+ return textPromise;
+ }
+ return this.ui.loginTextbox.value;
+ }
+};
diff --git a/remote/shared/Realm.sys.mjs b/remote/shared/Realm.sys.mjs
new file mode 100644
index 0000000000..5bf4a2fa3a
--- /dev/null
+++ b/remote/shared/Realm.sys.mjs
@@ -0,0 +1,382 @@
+/* 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, {
+ addDebuggerToGlobal: "resource://gre/modules/jsdebugger.sys.mjs",
+
+ generateUUID: "chrome://remote/content/shared/UUID.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "dbg", () => {
+ // eslint-disable-next-line mozilla/reject-globalThis-modification
+ lazy.addDebuggerToGlobal(globalThis);
+ return new Debugger();
+});
+
+/**
+ * @typedef {string} RealmType
+ */
+
+/**
+ * Enum of realm types.
+ *
+ * @readonly
+ * @enum {RealmType}
+ */
+export const RealmType = {
+ AudioWorklet: "audio-worklet",
+ DedicatedWorker: "dedicated-worker",
+ PaintWorklet: "paint-worklet",
+ ServiceWorker: "service-worker",
+ SharedWorker: "shared-worker",
+ Window: "window",
+ Worker: "worker",
+ Worklet: "worklet",
+};
+
+/**
+ * Base class that wraps any kind of WebDriver BiDi realm.
+ */
+export class Realm {
+ #handleObjectMap;
+ #id;
+
+ constructor() {
+ this.#id = lazy.generateUUID();
+
+ // Map of unique handles (UUIDs) to objects belonging to this realm.
+ this.#handleObjectMap = new Map();
+ }
+
+ destroy() {
+ this.#handleObjectMap = null;
+ }
+
+ /**
+ * Get the browsing context of the realm instance.
+ */
+ get browsingContext() {
+ return null;
+ }
+
+ /**
+ * Get the unique identifier of the realm instance.
+ *
+ * @returns {string} The unique identifier.
+ */
+ get id() {
+ return this.#id;
+ }
+
+ /**
+ * A getter to get a realm origin.
+ *
+ * It's required to be implemented in the sub class.
+ */
+ get origin() {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * Ensure the provided object can be used within this realm.
+
+ * @param {object} obj
+ * Any non-primitive object.
+
+ * @returns {object}
+ * An object usable in the current realm.
+ */
+ cloneIntoRealm(obj) {
+ return obj;
+ }
+
+ /**
+ * Remove the reference corresponding to the provided unique handle.
+ *
+ * @param {string} handle
+ * The unique handle of an object reference tracked in this realm.
+ */
+ removeObjectHandle(handle) {
+ this.#handleObjectMap.delete(handle);
+ }
+
+ /**
+ * Get a new unique handle for the provided object, creating a strong
+ * reference on the object.
+ *
+ * @param {object} object
+ * Any non-primitive object.
+ * @returns {string} The unique handle created for this strong reference.
+ */
+ getHandleForObject(object) {
+ const handle = lazy.generateUUID();
+ this.#handleObjectMap.set(handle, object);
+ return handle;
+ }
+
+ /**
+ * Get the basic realm information.
+ *
+ * @returns {BaseRealmInfo}
+ */
+ getInfo() {
+ return {
+ realm: this.#id,
+ origin: this.origin,
+ };
+ }
+
+ /**
+ * Retrieve the object corresponding to the provided unique handle.
+ *
+ * @param {string} handle
+ * The unique handle of an object reference tracked in this realm.
+ * @returns {object} object
+ * Any non-primitive object.
+ */
+ getObjectForHandle(handle) {
+ return this.#handleObjectMap.get(handle);
+ }
+}
+
+/**
+ * Wrapper for Window realms including sandbox objects.
+ */
+export class WindowRealm extends Realm {
+ #realmAutomationFeaturesEnabled;
+ #globalObject;
+ #globalObjectReference;
+ #isSandbox;
+ #sandboxName;
+ #userActivationEnabled;
+ #window;
+
+ static type = RealmType.Window;
+
+ /**
+ *
+ * @param {Window} window
+ * The window global to wrap.
+ * @param {object} options
+ * @param {string=} options.sandboxName
+ * Name of the sandbox to create if specified. Defaults to `null`.
+ */
+ constructor(window, options = {}) {
+ const { sandboxName = null } = options;
+
+ super();
+
+ this.#isSandbox = sandboxName !== null;
+ this.#sandboxName = sandboxName;
+ this.#window = window;
+ this.#globalObject = this.#isSandbox ? this.#createSandbox() : this.#window;
+ this.#globalObjectReference = lazy.dbg.makeGlobalObjectReference(
+ this.#globalObject
+ );
+ this.#realmAutomationFeaturesEnabled = false;
+ this.#userActivationEnabled = false;
+ }
+
+ destroy() {
+ if (this.#realmAutomationFeaturesEnabled) {
+ lazy.dbg.disableAsyncStack(this.#globalObject);
+ lazy.dbg.disableUnlimitedStacksCapturing(this.#globalObject);
+ this.#realmAutomationFeaturesEnabled = false;
+ }
+
+ this.#globalObjectReference = null;
+ this.#globalObject = null;
+ this.#window = null;
+
+ super.destroy();
+ }
+
+ get browsingContext() {
+ return this.#window.browsingContext;
+ }
+
+ get globalObjectReference() {
+ return this.#globalObjectReference;
+ }
+
+ get isSandbox() {
+ return this.#isSandbox;
+ }
+
+ get origin() {
+ return this.#window.origin;
+ }
+
+ get userActivationEnabled() {
+ return this.#userActivationEnabled;
+ }
+
+ set userActivationEnabled(enable) {
+ if (enable === this.#userActivationEnabled) {
+ return;
+ }
+
+ const document = this.#window.document;
+ if (enable) {
+ document.notifyUserGestureActivation();
+ } else {
+ document.clearUserGestureActivation();
+ }
+
+ this.#userActivationEnabled = enable;
+ }
+
+ #createDebuggerObject(obj) {
+ return this.#globalObjectReference.makeDebuggeeValue(obj);
+ }
+
+ #createSandbox() {
+ const win = this.#window;
+ const opts = {
+ sameZoneAs: win,
+ sandboxPrototype: win,
+ wantComponents: false,
+ wantXrays: true,
+ };
+
+ return new Cu.Sandbox(win, opts);
+ }
+
+ #enableRealmAutomationFeatures() {
+ if (!this.#realmAutomationFeaturesEnabled) {
+ lazy.dbg.enableAsyncStack(this.#globalObject);
+ lazy.dbg.enableUnlimitedStacksCapturing(this.#globalObject);
+ this.#realmAutomationFeaturesEnabled = true;
+ }
+ }
+
+ /**
+ * Clone the provided object into the scope of this Realm (either a window
+ * global, or a sandbox).
+ *
+ * @param {object} obj
+ * Any non-primitive object.
+ *
+ * @returns {object}
+ * The cloned object.
+ */
+ cloneIntoRealm(obj) {
+ return Cu.cloneInto(obj, this.#globalObject, { cloneFunctions: true });
+ }
+
+ /**
+ * Evaluates a provided expression in the context of the current realm.
+ *
+ * @param {string} expression
+ * The expression to evaluate.
+ *
+ * @returns {object}
+ * - evaluationStatus {EvaluationStatus} One of "normal", "throw".
+ * - exceptionDetails {ExceptionDetails=} the details of the exception if
+ * the evaluation status was "throw".
+ * - result {RemoteValue=} the result of the evaluation serialized as a
+ * RemoteValue if the evaluation status was "normal".
+ */
+ executeInGlobal(expression) {
+ this.#enableRealmAutomationFeatures();
+ return this.#globalObjectReference.executeInGlobal(expression, {
+ url: this.#window.document.baseURI,
+ });
+ }
+
+ /**
+ * Call a function in the context of the current realm.
+ *
+ * @param {string} functionDeclaration
+ * The body of the function to call.
+ * @param {Array<object>} functionArguments
+ * The arguments to pass to the function call.
+ * @param {object} thisParameter
+ * The value of the `this` keyword for the function call.
+ *
+ * @returns {object}
+ * - evaluationStatus {EvaluationStatus} One of "normal", "throw".
+ * - exceptionDetails {ExceptionDetails=} the details of the exception if
+ * the evaluation status was "throw".
+ * - result {RemoteValue=} the result of the evaluation serialized as a
+ * RemoteValue if the evaluation status was "normal".
+ */
+ executeInGlobalWithBindings(
+ functionDeclaration,
+ functionArguments,
+ thisParameter
+ ) {
+ this.#enableRealmAutomationFeatures();
+ const expression = `(${functionDeclaration}).apply(__bidi_this, __bidi_args)`;
+
+ const args = this.cloneIntoRealm([]);
+ for (const arg of functionArguments) {
+ args.push(arg);
+ }
+
+ return this.#globalObjectReference.executeInGlobalWithBindings(
+ expression,
+ {
+ __bidi_args: this.#createDebuggerObject(args),
+ __bidi_this: this.#createDebuggerObject(thisParameter),
+ },
+ {
+ url: this.#window.document.baseURI,
+ }
+ );
+ }
+
+ /**
+ * Get the realm information.
+ *
+ * @returns {object}
+ * - context {BrowsingContext} The browsing context, associated with the realm.
+ * - id {string} The realm unique identifier.
+ * - origin {string} The serialization of an origin.
+ * - sandbox {string=} The name of the sandbox.
+ * - type {RealmType.Window} The window realm type.
+ */
+ getInfo() {
+ const baseInfo = super.getInfo();
+ const info = {
+ ...baseInfo,
+ context: this.#window.browsingContext,
+ type: WindowRealm.type,
+ };
+
+ if (this.#isSandbox) {
+ info.sandbox = this.#sandboxName;
+ }
+
+ return info;
+ }
+
+ /**
+ * Log an error caused by a script evaluation.
+ *
+ * @param {string} message
+ * The error message.
+ * @param {Stack} stack
+ * The JavaScript stack trace.
+ */
+ reportError(message, stack) {
+ const { column, line, source: sourceLine } = stack;
+
+ const scriptErrorClass = Cc["@mozilla.org/scripterror;1"];
+ const scriptError = scriptErrorClass.createInstance(Ci.nsIScriptError);
+
+ scriptError.initWithWindowID(
+ message,
+ this.#window.document.baseURI,
+ sourceLine,
+ line,
+ column,
+ Ci.nsIScriptError.errorFlag,
+ "content javascript",
+ this.#window.windowGlobalChild.innerWindowId
+ );
+ Services.console.logMessage(scriptError);
+ }
+}
diff --git a/remote/shared/RecommendedPreferences.sys.mjs b/remote/shared/RecommendedPreferences.sys.mjs
new file mode 100644
index 0000000000..d0a7739e52
--- /dev/null
+++ b/remote/shared/RecommendedPreferences.sys.mjs
@@ -0,0 +1,440 @@
+/* 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, {
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "useRecommendedPrefs",
+ "remote.prefs.recommended",
+ false
+);
+
+ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
+
+// Ensure we are in the parent process.
+if (Services.appinfo.processType != Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT) {
+ throw new Error(
+ "RecommendedPreferences should only be loaded in the parent process"
+ );
+}
+
+// ALL CHANGES TO THIS LIST MUST HAVE REVIEW FROM A WEBDRIVER PEER!
+//
+// Preferences are set for automation on startup, unless
+// remote.prefs.recommended has been set to false.
+//
+// Note: Clients do not always use the latest version of the application. As
+// such backward compatibility has to be ensured at least for the last three
+// releases.
+
+// INSTRUCTIONS TO ADD A NEW PREFERENCE
+//
+// Preferences for remote control and automation can be set from several entry
+// points:
+// - remote/shared/RecommendedPreferences.sys.mjs
+// - remote/test/puppeteer/packages/browsers/src/browser-data/firefox.ts
+// - testing/geckodriver/src/prefs.rs
+// - testing/marionette/client/marionette_driver/geckoinstance.py
+//
+// The preferences in `firefox.ts`, `prefs.rs` and `geckoinstance.py`
+// will be applied before the application starts, and should typically be used
+// for preferences which cannot be updated during the lifetime of the application.
+//
+// The preferences in `RecommendedPreferences.sys.mjs` are applied after
+// the application has started, which means that the application must apply this
+// change dynamically and behave correctly. Note that you can also define
+// protocol specific preferences (CDP, WebDriver, ...) which are merged with the
+// COMMON_PREFERENCES from `RecommendedPreferences.sys.mjs`.
+//
+// Additionally, users relying on the Marionette Python client (ie. using
+// geckoinstance.py) set `remote.prefs.recommended = false`. This means that
+// preferences from `RecommendedPreferences.sys.mjs` are not applied and have to
+// be added to the list of preferences in that Python file. Note that there are
+// several lists of preferences, either common or specific to a given application
+// (Firefox Desktop, Fennec, Thunderbird).
+//
+// Depending on how users interact with the Remote Agent, they will use different
+// combinations of preferences. So it's important to update the preferences files
+// so that all users have the proper preferences.
+//
+// When adding a new preference, follow this guide to decide where to add it:
+// - Add the preference to `geckoinstance.py`
+// - If the preference has to be set before startup:
+// - Add the preference to `prefs.rs`
+// - Add the preference `browser-data/firefox.ts` in the puppeteer folder
+// - Create a PR to upstream the change on `browser-data/firefox.ts` to puppeteer
+// - Otherwise, if the preference can be set after startup:
+// - Add the preference to `RecommendedPreferences.sys.mjs`
+const COMMON_PREFERENCES = new Map([
+ // Make sure Shield doesn't hit the network.
+ ["app.normandy.api_url", ""],
+
+ // Disable automatically upgrading Firefox
+ //
+ // Note: This preference should have already been set by the client when
+ // creating the profile. But if not and to absolutely make sure that updates
+ // of Firefox aren't downloaded and applied, enforce its presence.
+ ["app.update.disabledForTesting", true],
+
+ // Increase the APZ content response timeout in tests to 1 minute.
+ // This is to accommodate the fact that test environments tends to be
+ // slower than production environments (with the b2g emulator being
+ // the slowest of them all), resulting in the production timeout value
+ // sometimes being exceeded and causing false-positive test failures.
+ //
+ // (bug 1176798, bug 1177018, bug 1210465)
+ ["apz.content_response_timeout", 60000],
+
+ // Don't show the content blocking introduction panel.
+ // We use a larger number than the default 22 to have some buffer
+ // This can be removed once Firefox 69 and 68 ESR and are no longer supported.
+ ["browser.contentblocking.introCount", 99],
+
+ // Indicate that the download panel has been shown once so that
+ // whichever download test runs first doesn't show the popup
+ // inconsistently.
+ ["browser.download.panel.shown", true],
+
+ // Make sure Topsites doesn't hit the network to retrieve sponsored tiles.
+ ["browser.newtabpage.activity-stream.showSponsoredTopSites", false],
+
+ // Always display a blank page
+ ["browser.newtabpage.enabled", false],
+
+ // Background thumbnails in particular cause grief, and disabling
+ // thumbnails in general cannot hurt
+ ["browser.pagethumbnails.capturing_disabled", true],
+
+ // Disable geolocation ping(#1)
+ ["browser.region.network.url", ""],
+
+ // Disable safebrowsing components.
+ //
+ // These should also be set in the profile prior to starting Firefox,
+ // as it is picked up at runtime.
+ ["browser.safebrowsing.blockedURIs.enabled", false],
+ ["browser.safebrowsing.downloads.enabled", false],
+ ["browser.safebrowsing.malware.enabled", false],
+ ["browser.safebrowsing.phishing.enabled", false],
+
+ // Disable updates to search engines.
+ //
+ // Should be set in profile.
+ ["browser.search.update", false],
+
+ // Do not restore the last open set of tabs if the browser has crashed
+ ["browser.sessionstore.resume_from_crash", false],
+
+ // Don't check for the default web browser during startup.
+ //
+ // These should also be set in the profile prior to starting Firefox,
+ // as it is picked up at runtime.
+ ["browser.shell.checkDefaultBrowser", false],
+
+ // Disable session restore infobar
+ ["browser.startup.couldRestoreSession.count", -1],
+
+ // Do not redirect user when a milstone upgrade of Firefox is detected
+ ["browser.startup.homepage_override.mstone", "ignore"],
+
+ // Don't unload tabs when available memory is running low
+ ["browser.tabs.unloadOnLowMemory", false],
+
+ // Do not warn when closing all open tabs
+ ["browser.tabs.warnOnClose", false],
+
+ // Do not warn when closing all other open tabs
+ ["browser.tabs.warnOnCloseOtherTabs", false],
+
+ // Do not warn when multiple tabs will be opened
+ ["browser.tabs.warnOnOpen", false],
+
+ // Don't show the Bookmarks Toolbar on any tab (the above pref that
+ // disables the New Tab Page ends up showing the toolbar on about:blank).
+ ["browser.toolbars.bookmarks.visibility", "never"],
+
+ // Make sure Topsites doesn't hit the network to retrieve tiles from Contile.
+ ["browser.topsites.contile.enabled", false],
+
+ // Disable first run splash page on Windows 10
+ ["browser.usedOnWindows10.introURL", ""],
+
+ // Turn off Merino suggestions in the location bar so as not to trigger
+ // network connections.
+ ["browser.urlbar.merino.endpointURL", ""],
+
+ // Turn off search suggestions in the location bar so as not to trigger
+ // network connections.
+ ["browser.urlbar.suggest.searches", false],
+
+ // Do not warn on quitting Firefox
+ ["browser.warnOnQuit", false],
+
+ // Do not show datareporting policy notifications which can
+ // interfere with tests
+ [
+ "datareporting.healthreport.documentServerURI",
+ "http://%(server)s/dummy/healthreport/",
+ ],
+ ["datareporting.healthreport.logging.consoleEnabled", false],
+ ["datareporting.healthreport.service.enabled", false],
+ ["datareporting.healthreport.service.firstRun", false],
+ ["datareporting.healthreport.uploadEnabled", false],
+ ["datareporting.policy.dataSubmissionEnabled", false],
+ ["datareporting.policy.dataSubmissionPolicyAccepted", false],
+ ["datareporting.policy.dataSubmissionPolicyBypassNotification", true],
+
+ // Disable popup-blocker
+ ["dom.disable_open_during_load", false],
+
+ // Enabling the support for File object creation in the content process
+ ["dom.file.createInChild", true],
+
+ // Disable delayed user input event handling
+ ["dom.input_events.security.minNumTicks", 0],
+ ["dom.input_events.security.minTimeElapsedInMS", 0],
+
+ // Disable the ProcessHangMonitor
+ ["dom.ipc.reportProcessHangs", false],
+
+ // Disable slow script dialogues
+ ["dom.max_chrome_script_run_time", 0],
+ ["dom.max_script_run_time", 0],
+
+ // Disable location change rate limitation
+ ["dom.navigation.locationChangeRateLimit.count", 0],
+
+ // DOM Push
+ ["dom.push.connection.enabled", false],
+
+ // Screen Orientation API
+ ["dom.screenorientation.allow-lock", true],
+
+ // Disable dialog abuse if alerts are triggered too quickly.
+ ["dom.successive_dialog_time_limit", 0],
+
+ // Only load extensions from the application and user profile
+ // AddonManager.SCOPE_PROFILE + AddonManager.SCOPE_APPLICATION
+ //
+ // Should be set in profile.
+ ["extensions.autoDisableScopes", 0],
+ ["extensions.enabledScopes", 5],
+
+ // Disable metadata caching for installed add-ons by default
+ ["extensions.getAddons.cache.enabled", false],
+
+ // Disable installing any distribution extensions or add-ons.
+ // Should be set in profile.
+ ["extensions.installDistroAddons", false],
+
+ // Turn off extension updates so they do not bother tests
+ ["extensions.update.enabled", false],
+ ["extensions.update.notifyUser", false],
+
+ // Make sure opening about:addons will not hit the network
+ ["extensions.getAddons.discovery.api_url", "data:, "],
+
+ // Redirect various extension update URLs
+ [
+ "extensions.blocklist.detailsURL",
+ "http://%(server)s/extensions-dummy/blocklistDetailsURL",
+ ],
+ [
+ "extensions.blocklist.itemURL",
+ "http://%(server)s/extensions-dummy/blocklistItemURL",
+ ],
+ ["extensions.hotfix.url", "http://%(server)s/extensions-dummy/hotfixURL"],
+ [
+ "extensions.systemAddon.update.url",
+ "http://%(server)s/dummy-system-addons.xml",
+ ],
+ [
+ "extensions.update.background.url",
+ "http://%(server)s/extensions-dummy/updateBackgroundURL",
+ ],
+ ["extensions.update.url", "http://%(server)s/extensions-dummy/updateURL"],
+
+ // Make sure opening about: addons won't hit the network
+ ["extensions.getAddons.discovery.api_url", "data:, "],
+ [
+ "extensions.getAddons.get.url",
+ "http://%(server)s/extensions-dummy/repositoryGetURL",
+ ],
+ [
+ "extensions.getAddons.search.browseURL",
+ "http://%(server)s/extensions-dummy/repositoryBrowseURL",
+ ],
+
+ // Allow the application to have focus even it runs in the background
+ ["focusmanager.testmode", true],
+
+ // Disable useragent updates
+ ["general.useragent.updates.enabled", false],
+
+ // Disable geolocation ping(#2)
+ ["geo.provider.network.url", ""],
+
+ // Always use network provider for geolocation tests so we bypass the
+ // macOS dialog raised by the corelocation provider
+ ["geo.provider.testing", true],
+
+ // Do not scan Wifi
+ ["geo.wifi.scan", false],
+
+ // Disable Firefox accounts ping
+ ["identity.fxaccounts.auth.uri", "https://{server}/dummy/fxa"],
+
+ // Disable connectivity service pings
+ ["network.connectivity-service.enabled", false],
+
+ // Do not prompt with long usernames or passwords in URLs
+ ["network.http.phishy-userpass-length", 255],
+
+ // Do not prompt for temporary redirects
+ ["network.http.prompt-temp-redirect", false],
+
+ // Do not automatically switch between offline and online
+ ["network.manage-offline-status", false],
+
+ // Make sure SNTP requests do not hit the network
+ ["network.sntp.pools", "%(server)s"],
+
+ // Privacy and Tracking Protection
+ ["privacy.trackingprotection.enabled", false],
+
+ // Don't do network connections for mitm priming
+ ["security.certerrors.mitm.priming.enabled", false],
+
+ // Local documents have access to all other local documents,
+ // including directory listings
+ ["security.fileuri.strict_origin_policy", false],
+
+ // Tests do not wait for the notification button security delay
+ ["security.notification_enable_delay", 0],
+
+ // Do not download intermediate certificates
+ ["security.remote_settings.intermediates.enabled", false],
+
+ // Ensure remote settings do not hit the network
+ ["services.settings.server", "data:,#remote-settings-dummy/v1"],
+
+ // Do not automatically fill sign-in forms with known usernames and
+ // passwords
+ ["signon.autofillForms", false],
+
+ // Disable password capture, so that tests that include forms are not
+ // influenced by the presence of the persistent doorhanger notification
+ ["signon.rememberSignons", false],
+
+ // Disable first-run welcome page
+ ["startup.homepage_welcome_url", "about:blank"],
+ ["startup.homepage_welcome_url.additional", ""],
+
+ // Prevent starting into safe mode after application crashes
+ ["toolkit.startup.max_resumed_crashes", -1],
+
+ // Disable all telemetry pings
+ ["toolkit.telemetry.server", "https://%(server)s/telemetry-dummy/"],
+
+ // Disable window occlusion on Windows, which can prevent webdriver commands
+ // such as WebDriver:FindElements from working properly (Bug 1802473).
+ ["widget.windows.window_occlusion_tracking.enabled", false],
+]);
+
+export const RecommendedPreferences = {
+ alteredPrefs: new Set(),
+
+ isInitialized: false,
+
+ /**
+ * Apply the provided map of preferences.
+ *
+ * Note, that they will be automatically reset on application shutdown.
+ *
+ * @param {Map<string, object>=} preferences
+ * Map of preference name to preference value.
+ */
+ applyPreferences(preferences) {
+ if (!lazy.useRecommendedPrefs) {
+ // If remote.prefs.recommended is set to false, do not set any preference
+ // here. Needed for our Firefox CI.
+ return;
+ }
+
+ // Only apply common recommended preferences on first call to
+ // applyPreferences.
+ if (!this.isInitialized) {
+ // Merge common preferences and optionally provided preferences in a
+ // single map. Hereby the extra preferences have higher priority.
+ if (preferences) {
+ preferences = new Map([...COMMON_PREFERENCES, ...preferences]);
+ } else {
+ preferences = COMMON_PREFERENCES;
+ }
+
+ Services.obs.addObserver(this, "quit-application");
+ this.isInitialized = true;
+ }
+
+ for (const [k, v] of preferences) {
+ if (!Services.prefs.prefHasUserValue(k)) {
+ lazy.logger.debug(`Setting recommended pref ${k} to ${v}`);
+
+ switch (typeof v) {
+ case "string":
+ Services.prefs.setStringPref(k, v);
+ break;
+ case "boolean":
+ Services.prefs.setBoolPref(k, v);
+ break;
+ case "number":
+ Services.prefs.setIntPref(k, v);
+ break;
+ default:
+ throw new TypeError(`Invalid preference type: ${typeof v}`);
+ }
+
+ // Keep track all the altered preferences to restore them on
+ // quit-application.
+ this.alteredPrefs.add(k);
+ }
+ }
+ },
+
+ observe(subject, topic) {
+ if (topic === "quit-application") {
+ Services.obs.removeObserver(this, "quit-application");
+ this.restoreAllPreferences();
+ }
+ },
+
+ /**
+ * Restore all the altered preferences.
+ */
+ restoreAllPreferences() {
+ this.restorePreferences(this.alteredPrefs);
+ this.isInitialized = false;
+ },
+
+ /**
+ * Restore provided preferences.
+ *
+ * @param {Map} preferences
+ * Map of preferences that should be restored.
+ */
+ restorePreferences(preferences) {
+ for (const k of preferences.keys()) {
+ lazy.logger.debug(`Resetting recommended pref ${k}`);
+ Services.prefs.clearUserPref(k);
+ this.alteredPrefs.delete(k);
+ }
+ },
+};
diff --git a/remote/shared/RemoteError.sys.mjs b/remote/shared/RemoteError.sys.mjs
new file mode 100644
index 0000000000..2e326d5d18
--- /dev/null
+++ b/remote/shared/RemoteError.sys.mjs
@@ -0,0 +1,19 @@
+/* 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/. */
+
+/**
+ * Base class for all remote protocol errors.
+ */
+export class RemoteError extends Error {
+ get isRemoteError() {
+ return true;
+ }
+
+ /**
+ * Convert to a serializable object. Should be implemented by subclasses.
+ */
+ toJSON() {
+ throw new Error("Not implemented");
+ }
+}
diff --git a/remote/shared/Stack.sys.mjs b/remote/shared/Stack.sys.mjs
new file mode 100644
index 0000000000..d0c7f9407d
--- /dev/null
+++ b/remote/shared/Stack.sys.mjs
@@ -0,0 +1,73 @@
+/* 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/. */
+
+/**
+ * An object that contains details of a stack frame.
+ *
+ * @typedef {object} StackFrame
+ * @see nsIStackFrame
+ *
+ * @property {string=} asyncCause
+ * Type of asynchronous call by which this frame was invoked.
+ * @property {number} columnNumber
+ * The column number for this stack frame.
+ * @property {string} filename
+ * The source URL for this stack frame.
+ * @property {string} function
+ * SpiderMonkey’s inferred name for this stack frame’s function, or null.
+ * @property {number} lineNumber
+ * The line number for this stack frame (starts with 1).
+ * @property {number} sourceId
+ * The process-unique internal integer ID of this source.
+ */
+
+/**
+ * Return a list of stack frames from the given stack.
+ *
+ * Convert stack objects to the JSON attributes expected by consumers.
+ *
+ * @param {object} stack
+ * The native stack object to process.
+ *
+ * @returns {Array<StackFrame>=}
+ */
+export function getFramesFromStack(stack) {
+ if (!stack || (Cu && Cu.isDeadWrapper(stack))) {
+ // If the global from which this error came from has been nuked,
+ // stack is going to be a dead wrapper.
+ return null;
+ }
+
+ const frames = [];
+ while (stack) {
+ frames.push({
+ asyncCause: stack.asyncCause,
+ columnNumber: stack.column,
+ filename: stack.source,
+ functionName: stack.functionDisplayName || "",
+ lineNumber: stack.line,
+ sourceId: stack.sourceId,
+ });
+
+ stack = stack.parent || stack.asyncParent;
+ }
+
+ return frames;
+}
+
+/**
+ * Check if a frame is from chrome scope.
+ *
+ * @param {object} frame
+ * The frame to check
+ *
+ * @returns {boolean}
+ * True, if frame is from chrome scope
+ */
+export function isChromeFrame(frame) {
+ return (
+ frame.filename.startsWith("chrome://") ||
+ frame.filename.startsWith("resource://")
+ );
+}
diff --git a/remote/shared/Sync.sys.mjs b/remote/shared/Sync.sys.mjs
new file mode 100644
index 0000000000..7b14f8b2c8
--- /dev/null
+++ b/remote/shared/Sync.sys.mjs
@@ -0,0 +1,335 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+});
+
+const { TYPE_ONE_SHOT, TYPE_REPEATING_SLACK } = Ci.nsITimer;
+
+ChromeUtils.defineLazyGetter(lazy, "logger", () =>
+ lazy.Log.get(lazy.Log.TYPES.REMOTE_AGENT)
+);
+
+/**
+ * Throttle until the `window` has performed an animation frame.
+ *
+ * @param {ChromeWindow} win
+ * Window to request the animation frame from.
+ *
+ * @returns {Promise}
+ */
+export function AnimationFramePromise(win) {
+ const animationFramePromise = new Promise(resolve => {
+ win.requestAnimationFrame(resolve);
+ });
+
+ // Abort if the underlying window gets closed
+ const windowClosedPromise = new PollPromise(resolve => {
+ if (win.closed) {
+ resolve();
+ }
+ });
+
+ return Promise.race([animationFramePromise, windowClosedPromise]);
+}
+
+/**
+ * Create a helper object to defer a promise.
+ *
+ * @returns {object}
+ * An object that returns the following properties:
+ * - fulfilled Flag that indicates that the promise got resolved
+ * - pending Flag that indicates a not yet fulfilled/rejected promise
+ * - promise The actual promise
+ * - reject Callback to reject the promise
+ * - rejected Flag that indicates that the promise got rejected
+ * - resolve Callback to resolve the promise
+ */
+export function Deferred() {
+ const deferred = {};
+
+ deferred.promise = new Promise((resolve, reject) => {
+ deferred.fulfilled = false;
+ deferred.pending = true;
+ deferred.rejected = false;
+
+ deferred.resolve = (...args) => {
+ deferred.fulfilled = true;
+ deferred.pending = false;
+ resolve(...args);
+ };
+
+ deferred.reject = (...args) => {
+ deferred.pending = false;
+ deferred.rejected = true;
+ reject(...args);
+ };
+ });
+
+ return deferred;
+}
+
+/**
+ * Wait for an event to be fired on a specified element.
+ *
+ * The returned promise is guaranteed to not resolve before the
+ * next event tick after the event listener is called, so that all
+ * other event listeners for the element are executed before the
+ * handler is executed. For example:
+ *
+ * const promise = new EventPromise(element, "myEvent");
+ * // same event tick here
+ * await promise;
+ * // next event tick here
+ *
+ * @param {Element} subject
+ * The element that should receive the event.
+ * @param {string} eventName
+ * Case-sensitive string representing the event name to listen for.
+ * @param {object=} options
+ * @param {boolean=} options.capture
+ * Indicates the event will be despatched to this subject,
+ * before it bubbles down to any EventTarget beneath it in the
+ * DOM tree. Defaults to false.
+ * @param {Function=} options.checkFn
+ * Called with the Event object as argument, should return true if the
+ * event is the expected one, or false if it should be ignored and
+ * listening should continue. If not specified, the first event with
+ * the specified name resolves the returned promise. Defaults to null.
+ * @param {number=} options.timeout
+ * Timeout duration in milliseconds, if provided.
+ * If specified, then the returned promise will be rejected with
+ * TimeoutError, if not already resolved, after this duration has elapsed.
+ * If not specified, then no timeout is used. Defaults to null.
+ * @param {boolean=} options.mozSystemGroup
+ * Determines whether to add listener to the system group. Defaults to
+ * false.
+ * @param {boolean=} options.wantUntrusted
+ * Receive synthetic events despatched by web content. Defaults to false.
+ *
+ * @returns {Promise<Event>}
+ * Either fulfilled with the first described event, satisfying
+ * options.checkFn if specified, or rejected with TimeoutError after
+ * options.timeout milliseconds if specified.
+ *
+ * @throws {TypeError}
+ * @throws {RangeError}
+ */
+export function EventPromise(subject, eventName, options = {}) {
+ const {
+ capture = false,
+ checkFn = null,
+ timeout = null,
+ mozSystemGroup = false,
+ wantUntrusted = false,
+ } = options;
+ if (
+ !subject ||
+ !("addEventListener" in subject) ||
+ typeof eventName != "string" ||
+ typeof capture != "boolean" ||
+ (checkFn && typeof checkFn != "function") ||
+ (timeout !== null && typeof timeout != "number") ||
+ typeof mozSystemGroup != "boolean" ||
+ typeof wantUntrusted != "boolean"
+ ) {
+ throw new TypeError();
+ }
+ if (timeout < 0) {
+ throw new RangeError();
+ }
+
+ return new Promise((resolve, reject) => {
+ let timer;
+
+ function cleanUp() {
+ subject.removeEventListener(eventName, listener, capture);
+ timer?.cancel();
+ }
+
+ function listener(event) {
+ lazy.logger.trace(`Received DOM event ${event.type} for ${event.target}`);
+ try {
+ if (checkFn && !checkFn(event)) {
+ return;
+ }
+ } catch (e) {
+ // Treat an exception in the callback as a falsy value
+ lazy.logger.warn(`Event check failed: ${e.message}`);
+ }
+
+ cleanUp();
+ executeSoon(() => resolve(event));
+ }
+
+ subject.addEventListener(eventName, listener, {
+ capture,
+ mozSystemGroup,
+ wantUntrusted,
+ });
+
+ if (timeout !== null) {
+ timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ timer.init(
+ () => {
+ cleanUp();
+ reject(
+ new lazy.error.TimeoutError(
+ `EventPromise timed out after ${timeout} ms`
+ )
+ );
+ },
+ timeout,
+ TYPE_ONE_SHOT
+ );
+ }
+ });
+}
+
+/**
+ * Wait for the next tick in the event loop to execute a callback.
+ *
+ * @param {Function} fn
+ * Function to be executed.
+ */
+export function executeSoon(fn) {
+ if (typeof fn != "function") {
+ throw new TypeError();
+ }
+
+ Services.tm.dispatchToMainThread(fn);
+}
+
+/**
+ * Runs a Promise-like function off the main thread until it is resolved
+ * through ``resolve`` or ``rejected`` callbacks. The function is
+ * guaranteed to be run at least once, irregardless of the timeout.
+ *
+ * The ``func`` is evaluated every ``interval`` for as long as its
+ * runtime duration does not exceed ``interval``. Evaluations occur
+ * sequentially, meaning that evaluations of ``func`` are queued if
+ * the runtime evaluation duration of ``func`` is greater than ``interval``.
+ *
+ * ``func`` is given two arguments, ``resolve`` and ``reject``,
+ * of which one must be called for the evaluation to complete.
+ * Calling ``resolve`` with an argument indicates that the expected
+ * wait condition was met and will return the passed value to the
+ * caller. Conversely, calling ``reject`` will evaluate ``func``
+ * again until the ``timeout`` duration has elapsed or ``func`` throws.
+ * The passed value to ``reject`` will also be returned to the caller
+ * once the wait has expired.
+ *
+ * Usage::
+ *
+ * let els = new PollPromise((resolve, reject) => {
+ * let res = document.querySelectorAll("p");
+ * if (res.length > 0) {
+ * resolve(Array.from(res));
+ * } else {
+ * reject([]);
+ * }
+ * }, {timeout: 1000});
+ *
+ * @param {Condition} func
+ * Function to run off the main thread.
+ * @param {object=} options
+ * @param {string=} options.errorMessage
+ * Message to use to send a warning if ``timeout`` is over.
+ * Defaults to `PollPromise timed out`.
+ * @param {number=} options.timeout
+ * Desired timeout if wanted. If 0 or less than the runtime evaluation
+ * time of ``func``, ``func`` is guaranteed to run at least once.
+ * Defaults to using no timeout.
+ * @param {number=} options.interval
+ * Duration between each poll of ``func`` in milliseconds.
+ * Defaults to 10 milliseconds.
+ *
+ * @returns {Promise.<*>}
+ * Yields the value passed to ``func``'s
+ * ``resolve`` or ``reject`` callbacks.
+ *
+ * @throws {*}
+ * If ``func`` throws, its error is propagated.
+ * @throws {TypeError}
+ * If `timeout` or `interval`` are not numbers.
+ * @throws {RangeError}
+ * If `timeout` or `interval` are not unsigned integers.
+ */
+export function PollPromise(func, options = {}) {
+ const {
+ errorMessage = "PollPromise timed out",
+ interval = 10,
+ timeout = null,
+ } = options;
+ const timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ let didTimeOut = false;
+
+ if (typeof func != "function") {
+ throw new TypeError();
+ }
+ if (timeout != null && typeof timeout != "number") {
+ throw new TypeError();
+ }
+ if (typeof interval != "number") {
+ throw new TypeError();
+ }
+ if (
+ (timeout && (!Number.isInteger(timeout) || timeout < 0)) ||
+ !Number.isInteger(interval) ||
+ interval < 0
+ ) {
+ throw new RangeError();
+ }
+
+ return new Promise((resolve, reject) => {
+ let start, end;
+
+ if (Number.isInteger(timeout)) {
+ start = new Date().getTime();
+ end = start + timeout;
+ }
+
+ let evalFn = () => {
+ new Promise(func)
+ .then(resolve, rejected => {
+ if (typeof rejected != "undefined") {
+ throw rejected;
+ }
+
+ // return if there is a timeout and set to 0,
+ // allowing |func| to be evaluated at least once
+ if (
+ typeof end != "undefined" &&
+ (start == end || new Date().getTime() >= end)
+ ) {
+ didTimeOut = true;
+ resolve(rejected);
+ }
+ })
+ .catch(reject);
+ };
+
+ // the repeating slack timer waits |interval|
+ // before invoking |evalFn|
+ evalFn();
+
+ timer.init(evalFn, interval, TYPE_REPEATING_SLACK);
+ }).then(
+ res => {
+ if (didTimeOut) {
+ lazy.logger.warn(`${errorMessage} after ${timeout} ms`);
+ }
+ timer.cancel();
+ return res;
+ },
+ err => {
+ timer.cancel();
+ throw err;
+ }
+ );
+}
diff --git a/remote/shared/TabManager.sys.mjs b/remote/shared/TabManager.sys.mjs
new file mode 100644
index 0000000000..c7170f8810
--- /dev/null
+++ b/remote/shared/TabManager.sys.mjs
@@ -0,0 +1,455 @@
+/* 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, {
+ AppInfo: "chrome://remote/content/shared/AppInfo.sys.mjs",
+ BrowsingContextListener:
+ "chrome://remote/content/shared/listeners/BrowsingContextListener.sys.mjs",
+ EventPromise: "chrome://remote/content/shared/Sync.sys.mjs",
+ generateUUID: "chrome://remote/content/shared/UUID.sys.mjs",
+ MobileTabBrowser: "chrome://remote/content/shared/MobileTabBrowser.sys.mjs",
+ UserContextManager:
+ "chrome://remote/content/shared/UserContextManager.sys.mjs",
+});
+
+class TabManagerClass {
+ #browserUniqueIds;
+ #contextListener;
+ #navigableIds;
+
+ constructor() {
+ // Maps browser's permanentKey to uuid: WeakMap.<Object, string>
+ this.#browserUniqueIds = new WeakMap();
+
+ // Maps browsing contexts to uuid: WeakMap.<BrowsingContext, string>.
+ // It's required as a fallback, since in the case when a context was discarded
+ // embedderElement is gone, and we cannot retrieve
+ // the context id from this.#browserUniqueIds.
+ this.#navigableIds = new WeakMap();
+
+ this.#contextListener = new lazy.BrowsingContextListener();
+ this.#contextListener.on("attached", this.#onContextAttached);
+ this.#contextListener.startListening();
+
+ this.browsers.forEach(browser => {
+ if (this.isValidCanonicalBrowsingContext(browser.browsingContext)) {
+ this.#navigableIds.set(
+ browser.browsingContext,
+ this.getIdForBrowsingContext(browser.browsingContext)
+ );
+ }
+ });
+ }
+
+ /**
+ * Retrieve all the browser elements from tabs as contained in open windows.
+ *
+ * @returns {Array<XULBrowser>}
+ * All the found <xul:browser>s. Will return an empty array if
+ * no windows and tabs can be found.
+ */
+ get browsers() {
+ const browsers = [];
+
+ for (const win of this.windows) {
+ for (const tab of this.getTabsForWindow(win)) {
+ const contentBrowser = this.getBrowserForTab(tab);
+ if (contentBrowser !== null) {
+ browsers.push(contentBrowser);
+ }
+ }
+ }
+
+ return browsers;
+ }
+
+ get windows() {
+ return Services.wm.getEnumerator(null);
+ }
+
+ /**
+ * Array of unique browser ids (UUIDs) for all content browsers of all
+ * windows.
+ *
+ * TODO: Similarly to getBrowserById, we should improve the performance of
+ * this getter in Bug 1750065.
+ *
+ * @returns {Array<string>}
+ * Array of UUIDs for all content browsers.
+ */
+ get allBrowserUniqueIds() {
+ const browserIds = [];
+
+ for (const win of this.windows) {
+ // Only return handles for browser windows
+ for (const tab of this.getTabsForWindow(win)) {
+ const contentBrowser = this.getBrowserForTab(tab);
+ const winId = this.getIdForBrowser(contentBrowser);
+ if (winId !== null) {
+ browserIds.push(winId);
+ }
+ }
+ }
+
+ return browserIds;
+ }
+
+ /**
+ * Get the <code>&lt;xul:browser&gt;</code> for the specified tab.
+ *
+ * @param {Tab} tab
+ * The tab whose browser needs to be returned.
+ *
+ * @returns {XULBrowser}
+ * The linked browser for the tab or null if no browser can be found.
+ */
+ getBrowserForTab(tab) {
+ if (tab && "linkedBrowser" in tab) {
+ return tab.linkedBrowser;
+ }
+
+ return null;
+ }
+
+ /**
+ * Return the tab browser for the specified chrome window.
+ *
+ * @param {ChromeWindow} win
+ * Window whose <code>tabbrowser</code> needs to be accessed.
+ *
+ * @returns {Tab}
+ * Tab browser or null if it's not a browser window.
+ */
+ getTabBrowser(win) {
+ if (lazy.AppInfo.isAndroid) {
+ return new lazy.MobileTabBrowser(win);
+ } else if (lazy.AppInfo.isFirefox) {
+ return win.gBrowser;
+ }
+
+ return null;
+ }
+
+ /**
+ * Create a new tab.
+ *
+ * @param {object} options
+ * @param {boolean=} options.focus
+ * Set to true if the new tab should be focused (selected). Defaults to
+ * false. `false` value is not properly supported on Android, additional
+ * focus of previously selected tab is required after initial navigation.
+ * @param {Tab=} options.referenceTab
+ * The reference tab after which the new tab will be added. If no
+ * reference tab is provided, the new tab will be added after all the
+ * other tabs.
+ * @param {string=} options.userContextId
+ * A user context id from UserContextManager.
+ * @param {window=} options.window
+ * The window where the new tab will open. Defaults to Services.wm.getMostRecentWindow
+ * if no window is provided. Will be ignored if referenceTab is provided.
+ */
+ async addTab(options = {}) {
+ let {
+ focus = false,
+ referenceTab = null,
+ userContextId = null,
+ window = Services.wm.getMostRecentWindow(null),
+ } = options;
+
+ let index;
+ if (referenceTab != null) {
+ // If a reference tab was specified, the window should be the window
+ // owning the reference tab.
+ window = this.getWindowForTab(referenceTab);
+ }
+
+ if (referenceTab != null) {
+ index = this.getTabsForWindow(window).indexOf(referenceTab) + 1;
+ }
+
+ const tabBrowser = this.getTabBrowser(window);
+
+ const tab = await tabBrowser.addTab("about:blank", {
+ index,
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ userContextId: lazy.UserContextManager.getInternalIdById(userContextId),
+ });
+
+ if (focus) {
+ await this.selectTab(tab);
+ }
+
+ return tab;
+ }
+
+ /**
+ * Retrieve the browser element corresponding to the provided unique id,
+ * previously generated via getIdForBrowser.
+ *
+ * TODO: To avoid creating strong references on browser elements and
+ * potentially leaking those elements, this method loops over all windows and
+ * all tabs. It should be replaced by a faster implementation in Bug 1750065.
+ *
+ * @param {string} id
+ * A browser unique id created by getIdForBrowser.
+ * @returns {XULBrowser}
+ * The <xul:browser> corresponding to the provided id. Will return null if
+ * no matching browser element is found.
+ */
+ getBrowserById(id) {
+ for (const win of this.windows) {
+ for (const tab of this.getTabsForWindow(win)) {
+ const contentBrowser = this.getBrowserForTab(tab);
+ if (this.getIdForBrowser(contentBrowser) == id) {
+ return contentBrowser;
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Retrieve the browsing context corresponding to the provided unique id.
+ *
+ * @param {string} id
+ * A browsing context unique id (created by getIdForBrowsingContext).
+ * @returns {BrowsingContext=}
+ * The browsing context found for this id, null if none was found.
+ */
+ getBrowsingContextById(id) {
+ const browser = this.getBrowserById(id);
+ if (browser) {
+ return browser.browsingContext;
+ }
+
+ return BrowsingContext.get(id);
+ }
+
+ /**
+ * Retrieve the unique id for the given xul browser element. The id is a
+ * dynamically generated uuid associated with the permanentKey property of the
+ * given browser element. This method is preferable over getIdForBrowsingContext
+ * in case of working with browser element of a tab, since we can not guarantee
+ * that browsing context is attached to it.
+ *
+ * @param {XULBrowser} browserElement
+ * The <xul:browser> for which we want to retrieve the id.
+ * @returns {string} The unique id for this browser.
+ */
+ getIdForBrowser(browserElement) {
+ if (browserElement === null) {
+ return null;
+ }
+
+ const key = browserElement.permanentKey;
+ if (key === undefined) {
+ return null;
+ }
+
+ if (!this.#browserUniqueIds.has(key)) {
+ this.#browserUniqueIds.set(key, lazy.generateUUID());
+ }
+ return this.#browserUniqueIds.get(key);
+ }
+
+ /**
+ * Retrieve the id of a Browsing Context.
+ *
+ * For a top-level browsing context a custom unique id will be returned.
+ *
+ * @param {BrowsingContext=} browsingContext
+ * The browsing context to get the id from.
+ *
+ * @returns {string}
+ * The id of the browsing context.
+ */
+ getIdForBrowsingContext(browsingContext) {
+ if (!browsingContext) {
+ return null;
+ }
+
+ if (!browsingContext.parent) {
+ // Top-level browsing contexts have their own custom unique id.
+ // If a context was discarded, embedderElement is already gone,
+ // so use navigable id instead.
+ return browsingContext.embedderElement
+ ? this.getIdForBrowser(browsingContext.embedderElement)
+ : this.#navigableIds.get(browsingContext);
+ }
+
+ return browsingContext.id.toString();
+ }
+
+ /**
+ * Get the navigable for the given browsing context.
+ *
+ * Because Gecko doesn't support the Navigable concept in content
+ * scope the content browser could be used to uniquely identify
+ * top-level browsing contexts.
+ *
+ * @param {BrowsingContext} browsingContext
+ *
+ * @returns {BrowsingContext|XULBrowser} The navigable
+ *
+ * @throws {TypeError}
+ * If `browsingContext` is not a CanonicalBrowsingContext instance.
+ */
+ getNavigableForBrowsingContext(browsingContext) {
+ if (!this.isValidCanonicalBrowsingContext(browsingContext)) {
+ throw new TypeError(
+ `Expected browsingContext to be a CanonicalBrowsingContext, got ${browsingContext}`
+ );
+ }
+
+ if (browsingContext.isContent && browsingContext.parent === null) {
+ return browsingContext.embedderElement;
+ }
+
+ return browsingContext;
+ }
+
+ getTabCount() {
+ let count = 0;
+ for (const win of this.windows) {
+ // For browser windows count the tabs. Otherwise take the window itself.
+ const tabsLength = this.getTabsForWindow(win).length;
+ count += tabsLength ? tabsLength : 1;
+ }
+ return count;
+ }
+
+ /**
+ * Retrieve the tab owning a Browsing Context.
+ *
+ * @param {BrowsingContext=} browsingContext
+ * The browsing context to get the tab from.
+ *
+ * @returns {Tab|null}
+ * The tab owning the Browsing Context.
+ */
+ getTabForBrowsingContext(browsingContext) {
+ const browser = browsingContext?.top.embedderElement;
+ if (!browser) {
+ return null;
+ }
+
+ const tabBrowser = this.getTabBrowser(browser.ownerGlobal);
+ return tabBrowser.getTabForBrowser(browser);
+ }
+
+ /**
+ * Retrieve the list of tabs for a given window.
+ *
+ * @param {ChromeWindow} win
+ * Window whose <code>tabs</code> need to be returned.
+ *
+ * @returns {Array<Tab>}
+ * The list of tabs. Will return an empty list if tab browser is not available
+ * or tabs are undefined.
+ */
+ getTabsForWindow(win) {
+ const tabBrowser = this.getTabBrowser(win);
+ // For web-platform reftests a faked tabbrowser is used,
+ // which does not actually have tabs.
+ if (tabBrowser && tabBrowser.tabs) {
+ return tabBrowser.tabs;
+ }
+ return [];
+ }
+
+ getWindowForTab(tab) {
+ // `.linkedBrowser.ownerGlobal` works both with Firefox Desktop and Mobile.
+ // Other accessors (eg `.ownerGlobal` or `.browser.ownerGlobal`) fail on one
+ // of the platforms.
+ return tab.linkedBrowser.ownerGlobal;
+ }
+
+ /**
+ * Check if the given argument is a valid canonical browsing context and was not
+ * discarded.
+ *
+ * @param {BrowsingContext} browsingContext
+ * The browsing context to check.
+ *
+ * @returns {boolean}
+ * True if the browsing context is valid, false otherwise.
+ */
+ isValidCanonicalBrowsingContext(browsingContext) {
+ return (
+ CanonicalBrowsingContext.isInstance(browsingContext) &&
+ !browsingContext.isDiscarded
+ );
+ }
+
+ /**
+ * Remove the given tab.
+ *
+ * @param {Tab} tab
+ * Tab to remove.
+ */
+ async removeTab(tab) {
+ if (!tab) {
+ return;
+ }
+
+ const ownerWindow = this.getWindowForTab(tab);
+ const tabBrowser = this.getTabBrowser(ownerWindow);
+ await tabBrowser.removeTab(tab);
+ }
+
+ /**
+ * Select the given tab.
+ *
+ * @param {Tab} tab
+ * Tab to select.
+ *
+ * @returns {Promise}
+ * Promise that resolves when the given tab has been selected.
+ */
+ async selectTab(tab) {
+ if (!tab) {
+ return Promise.resolve();
+ }
+
+ const ownerWindow = this.getWindowForTab(tab);
+ const tabBrowser = this.getTabBrowser(ownerWindow);
+
+ if (tab === tabBrowser.selectedTab) {
+ return Promise.resolve();
+ }
+
+ const selected = new lazy.EventPromise(ownerWindow, "TabSelect");
+ tabBrowser.selectedTab = tab;
+
+ await selected;
+
+ // Sometimes at that point window is not focused.
+ if (Services.focus.activeWindow != ownerWindow) {
+ const activated = new lazy.EventPromise(ownerWindow, "activate");
+ ownerWindow.focus();
+ return activated;
+ }
+
+ return Promise.resolve();
+ }
+
+ supportsTabs() {
+ return lazy.AppInfo.isAndroid || lazy.AppInfo.isFirefox;
+ }
+
+ #onContextAttached = (eventName, data = {}) => {
+ const { browsingContext } = data;
+ if (this.isValidCanonicalBrowsingContext(browsingContext)) {
+ this.#navigableIds.set(
+ browsingContext,
+ this.getIdForBrowsingContext(browsingContext)
+ );
+ }
+ };
+}
+
+// Expose a shared singleton.
+export const TabManager = new TabManagerClass();
diff --git a/remote/shared/UUID.sys.mjs b/remote/shared/UUID.sys.mjs
new file mode 100644
index 0000000000..b77ed7a562
--- /dev/null
+++ b/remote/shared/UUID.sys.mjs
@@ -0,0 +1,14 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Creates a unique UUID without enclosing curly brackets
+ * Example: '86c832d2-cf1c-4001-b3e0-8628fdd41b29'
+ *
+ * @returns {string}
+ * The generated UUID as a string.
+ */
+export function generateUUID() {
+ return Services.uuid.generateUUID().toString().slice(1, -1);
+}
diff --git a/remote/shared/UserContextManager.sys.mjs b/remote/shared/UserContextManager.sys.mjs
new file mode 100644
index 0000000000..679b24b2bc
--- /dev/null
+++ b/remote/shared/UserContextManager.sys.mjs
@@ -0,0 +1,214 @@
+/* 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, {
+ ContextualIdentityService:
+ "resource://gre/modules/ContextualIdentityService.sys.mjs",
+
+ ContextualIdentityListener:
+ "chrome://remote/content/shared/listeners/ContextualIdentityListener.sys.mjs",
+ generateUUID: "chrome://remote/content/shared/UUID.sys.mjs",
+});
+
+const DEFAULT_CONTEXT_ID = "default";
+const DEFAULT_INTERNAL_ID = 0;
+
+/**
+ * A UserContextManager instance keeps track of all public user contexts and
+ * maps their internal platform.
+ *
+ * This class is exported for test purposes. Otherwise the UserContextManager
+ * singleton should be used.
+ */
+export class UserContextManagerClass {
+ #contextualIdentityListener;
+ #userContextIds;
+
+ constructor() {
+ // Map from internal ids (numbers) from the ContextualIdentityService to
+ // opaque UUIDs (string).
+ this.#userContextIds = new Map();
+
+ // The default user context is always using 0 as internal user context id
+ // and should be exposed as "default" instead of a randomly generated id.
+ this.#userContextIds.set(DEFAULT_INTERNAL_ID, DEFAULT_CONTEXT_ID);
+
+ // Register other (non-default) public contexts.
+ lazy.ContextualIdentityService.getPublicIdentities().forEach(identity =>
+ this.#registerIdentity(identity)
+ );
+
+ this.#contextualIdentityListener = new lazy.ContextualIdentityListener();
+ this.#contextualIdentityListener.on("created", this.#onIdentityCreated);
+ this.#contextualIdentityListener.on("deleted", this.#onIdentityDeleted);
+ this.#contextualIdentityListener.startListening();
+ }
+
+ destroy() {
+ this.#contextualIdentityListener.off("created", this.#onIdentityCreated);
+ this.#contextualIdentityListener.off("deleted", this.#onIdentityDeleted);
+ this.#contextualIdentityListener.destroy();
+
+ this.#userContextIds = null;
+ }
+
+ /**
+ * Retrieve the user context id corresponding to the default user context.
+ *
+ * @returns {string}
+ * The default user context id.
+ */
+ get defaultUserContextId() {
+ return DEFAULT_CONTEXT_ID;
+ }
+
+ /**
+ * Creates a new user context.
+ *
+ * @param {string} prefix
+ * The prefix to use for the name of the user context.
+ *
+ * @returns {string}
+ * The user context id of the new user context.
+ */
+ createContext(prefix = "remote") {
+ // Prepare the opaque id and name beforehand.
+ const userContextId = lazy.generateUUID();
+ const name = `${prefix}-${userContextId}`;
+
+ // Create the user context.
+ const identity = lazy.ContextualIdentityService.create(name);
+ const internalId = identity.userContextId;
+
+ // An id has been set already by the contextual-identity-created observer.
+ // Override it with `userContextId` to match the container name.
+ this.#userContextIds.set(internalId, userContextId);
+
+ return userContextId;
+ }
+
+ /**
+ * Retrieve the user context id corresponding to the provided browsing context.
+ *
+ * @param {BrowsingContext} browsingContext
+ * The browsing context to get the user context id from.
+ *
+ * @returns {string}
+ * The corresponding user context id.
+ *
+ * @throws {TypeError}
+ * If `browsingContext` is not a CanonicalBrowsingContext instance.
+ */
+ getIdByBrowsingContext(browsingContext) {
+ if (!CanonicalBrowsingContext.isInstance(browsingContext)) {
+ throw new TypeError(
+ `Expected browsingContext to be a CanonicalBrowsingContext, got ${browsingContext}`
+ );
+ }
+
+ return this.getIdByInternalId(
+ browsingContext.originAttributes.userContextId
+ );
+ }
+
+ /**
+ * Retrieve the user context id corresponding to the provided internal id.
+ *
+ * @param {number} internalId
+ * The internal user context id.
+ *
+ * @returns {string|null}
+ * The corresponding user context id or null if the user context does not
+ * exist.
+ */
+ getIdByInternalId(internalId) {
+ if (this.#userContextIds.has(internalId)) {
+ return this.#userContextIds.get(internalId);
+ }
+ return null;
+ }
+
+ /**
+ * Retrieve the internal id corresponding to the provided user
+ * context id.
+ *
+ * @param {string} userContextId
+ * The user context id.
+ *
+ * @returns {number|null}
+ * The internal user context id or null if the user context does not
+ * exist.
+ */
+ getInternalIdById(userContextId) {
+ for (const [internalId, id] of this.#userContextIds) {
+ if (userContextId == id) {
+ return internalId;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Returns an array of all known user context ids.
+ *
+ * @returns {Array<string>}
+ * The array of user context ids.
+ */
+ getUserContextIds() {
+ return Array.from(this.#userContextIds.values());
+ }
+
+ /**
+ * Checks if the provided user context id is known by this UserContextManager.
+ *
+ * @param {string} userContextId
+ * The id of the user context to check.
+ */
+ hasUserContextId(userContextId) {
+ return this.getUserContextIds().includes(userContextId);
+ }
+
+ /**
+ * Removes a user context and closes all related container tabs.
+ *
+ * @param {string} userContextId
+ * The id of the user context to remove.
+ * @param {object=} options
+ * @param {boolean=} options.closeContextTabs
+ * Pass true if the tabs owned by the user context should also be closed.
+ * Defaults to false.
+ */
+ removeUserContext(userContextId, options = {}) {
+ const { closeContextTabs = false } = options;
+
+ if (!this.hasUserContextId(userContextId)) {
+ return;
+ }
+
+ const internalId = this.getInternalIdById(userContextId);
+ if (closeContextTabs) {
+ lazy.ContextualIdentityService.closeContainerTabs(internalId);
+ }
+ lazy.ContextualIdentityService.remove(internalId);
+ }
+
+ #onIdentityCreated = (eventName, data) => {
+ this.#registerIdentity(data.identity);
+ };
+
+ #onIdentityDeleted = (eventName, data) => {
+ this.#userContextIds.delete(data.identity.userContextId);
+ };
+
+ #registerIdentity(identity) {
+ // Note: the id for identities created via UserContextManagerClass.createContext
+ // are overridden in createContext.
+ this.#userContextIds.set(identity.userContextId, lazy.generateUUID());
+ }
+}
+
+// Expose a shared singleton.
+export const UserContextManager = new UserContextManagerClass();
diff --git a/remote/shared/WebSocketConnection.sys.mjs b/remote/shared/WebSocketConnection.sys.mjs
new file mode 100644
index 0000000000..c9ef050dc5
--- /dev/null
+++ b/remote/shared/WebSocketConnection.sys.mjs
@@ -0,0 +1,171 @@
+/* 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, {
+ generateUUID: "chrome://remote/content/shared/UUID.sys.mjs",
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+ truncate: "chrome://remote/content/shared/Format.sys.mjs",
+ WebSocketTransport:
+ "chrome://remote/content/server/WebSocketTransport.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "truncateLog",
+ "remote.log.truncate",
+ false
+);
+
+const MAX_LOG_LENGTH = 2500;
+
+export class WebSocketConnection {
+ /**
+ * @param {WebSocket} webSocket
+ * The WebSocket server connection to wrap.
+ * @param {Connection} httpdConnection
+ * Reference to the httpd.js's connection needed for clean-up.
+ */
+ constructor(webSocket, httpdConnection) {
+ this.id = lazy.generateUUID();
+
+ this.httpdConnection = httpdConnection;
+
+ this.transport = new lazy.WebSocketTransport(webSocket);
+ this.transport.hooks = this;
+ this.transport.ready();
+
+ lazy.logger.debug(`${this.constructor.name} ${this.id} accepted`);
+ }
+
+ #log(direction, data) {
+ if (lazy.Log.isDebugLevelOrMore) {
+ function replacer(key, value) {
+ if (typeof value === "string") {
+ return lazy.truncate`${value}`;
+ }
+ return value;
+ }
+
+ let payload = JSON.stringify(
+ data,
+ replacer,
+ lazy.Log.verbose ? "\t" : null
+ );
+
+ if (lazy.truncateLog && payload.length > MAX_LOG_LENGTH) {
+ // Even if we truncate individual values, the resulting message might be
+ // huge if we are serializing big objects with many properties or items.
+ // Truncate the overall message to avoid issues in logs.
+ const truncated = payload.substring(0, MAX_LOG_LENGTH);
+ payload = `${truncated} [... truncated after ${MAX_LOG_LENGTH} characters]`;
+ }
+
+ lazy.logger.debug(
+ `${this.constructor.name} ${this.id} ${direction} ${payload}`
+ );
+ }
+ }
+
+ /**
+ * Close the WebSocket connection.
+ */
+ close() {
+ this.transport.close();
+ }
+
+ /**
+ * Register a new Session to forward the messages to.
+ *
+ * Needs to be implemented in the sub class.
+ *
+ * @param {Session} session
+ * The session to register.
+ */
+ registerSession(session) {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * Send the JSON-serializable object to the client.
+ *
+ * @param {object} data
+ * The object to be sent.
+ */
+ send(data) {
+ this.#log("<-", data);
+ this.transport.send(data);
+ }
+
+ /**
+ * Send an error back to the client.
+ *
+ * Needs to be implemented in the sub class.
+ */
+ sendError() {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * Send an event back to the client.
+ *
+ * Needs to be implemented in the sub class.
+ */
+ sendEvent() {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * Send the result of a call to a method back to the client.
+ *
+ * Needs to be implemented in the sub class.
+ */
+ sendResult() {
+ throw new Error("Not implemented");
+ }
+
+ toString() {
+ return `[object ${this.constructor.name} ${this.id}]`;
+ }
+
+ // Transport hooks
+
+ /**
+ * Called by the `transport` when the connection is closed.
+ */
+ onConnectionClose(status) {
+ lazy.logger.debug(`${this.constructor.name} ${this.id} closed`);
+ }
+
+ /**
+ * Called when the socket is closed.
+ */
+ onSocketClose() {
+ // In addition to the WebSocket transport, we also have to close the
+ // connection used internally within httpd.js. Otherwise the server doesn't
+ // shut down correctly, and keeps these Connection instances alive.
+ this.httpdConnection.close();
+ }
+
+ /**
+ * Receive a packet from the WebSocket layer.
+ *
+ * This packet is sent by a WebSocket client and is meant to execute
+ * a particular method.
+ *
+ * Needs to be implemented in the sub class.
+ *
+ * @param {object} packet
+ * JSON-serializable object sent by the client.
+ */
+ async onPacket(packet) {
+ this.#log("->", packet);
+ }
+}
diff --git a/remote/shared/WindowManager.sys.mjs b/remote/shared/WindowManager.sys.mjs
new file mode 100644
index 0000000000..94b1ed13c1
--- /dev/null
+++ b/remote/shared/WindowManager.sys.mjs
@@ -0,0 +1,288 @@
+/* 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, {
+ URILoadingHelper: "resource:///modules/URILoadingHelper.sys.mjs",
+
+ AppInfo: "chrome://remote/content/shared/AppInfo.sys.mjs",
+ error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
+ EventPromise: "chrome://remote/content/shared/Sync.sys.mjs",
+ generateUUID: "chrome://remote/content/shared/UUID.sys.mjs",
+ TabManager: "chrome://remote/content/shared/TabManager.sys.mjs",
+ TimedPromise: "chrome://remote/content/marionette/sync.sys.mjs",
+ UserContextManager:
+ "chrome://remote/content/shared/UserContextManager.sys.mjs",
+ waitForObserverTopic: "chrome://remote/content/marionette/sync.sys.mjs",
+});
+
+/**
+ * Provides helpers to interact with Window objects.
+ *
+ * @class WindowManager
+ */
+class WindowManager {
+ constructor() {
+ // Maps ChromeWindow to uuid: WeakMap.<Object, string>
+ this._chromeWindowHandles = new WeakMap();
+ }
+
+ get chromeWindowHandles() {
+ const chromeWindowHandles = [];
+
+ for (const win of this.windows) {
+ chromeWindowHandles.push(this.getIdForWindow(win));
+ }
+
+ return chromeWindowHandles;
+ }
+
+ get windows() {
+ return Services.wm.getEnumerator(null);
+ }
+
+ /**
+ * Find a specific window matching the provided window handle.
+ *
+ * @param {string} handle
+ * The unique handle of either a chrome window or a content browser, as
+ * returned by :js:func:`#getIdForBrowser` or :js:func:`#getIdForWindow`.
+ *
+ * @returns {object} A window properties object,
+ * @see :js:func:`GeckoDriver#getWindowProperties`
+ */
+ findWindowByHandle(handle) {
+ for (const win of this.windows) {
+ // In case the wanted window is a chrome window, we are done.
+ const chromeWindowId = this.getIdForWindow(win);
+ if (chromeWindowId == handle) {
+ return this.getWindowProperties(win);
+ }
+
+ // Otherwise check if the chrome window has a tab browser, and that it
+ // contains a tab with the wanted window handle.
+ const tabBrowser = lazy.TabManager.getTabBrowser(win);
+ if (tabBrowser && tabBrowser.tabs) {
+ for (let i = 0; i < tabBrowser.tabs.length; ++i) {
+ let contentBrowser = lazy.TabManager.getBrowserForTab(
+ tabBrowser.tabs[i]
+ );
+ let contentWindowId = lazy.TabManager.getIdForBrowser(contentBrowser);
+
+ if (contentWindowId == handle) {
+ return this.getWindowProperties(win, { tabIndex: i });
+ }
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * A set of properties describing a window and that should allow to uniquely
+ * identify it. The described window can either be a Chrome Window or a
+ * Content Window.
+ *
+ * @typedef {object} WindowProperties
+ * @property {Window} win - The Chrome Window containing the window.
+ * When describing a Chrome Window, this is the window itself.
+ * @property {string} id - The unique id of the containing Chrome Window.
+ * @property {boolean} hasTabBrowser - `true` if the Chrome Window has a
+ * tabBrowser.
+ * @property {number} tabIndex - Optional, the index of the specific tab
+ * within the window.
+ */
+
+ /**
+ * Returns a WindowProperties object, that can be used with :js:func:`GeckoDriver#setWindowHandle`.
+ *
+ * @param {Window} win
+ * The Chrome Window for which we want to create a properties object.
+ * @param {object} options
+ * @param {number} options.tabIndex
+ * Tab index of a specific Content Window in the specified Chrome Window.
+ * @returns {WindowProperties} A window properties object.
+ */
+ getWindowProperties(win, options = {}) {
+ if (!Window.isInstance(win)) {
+ throw new TypeError("Invalid argument, expected a Window object");
+ }
+
+ return {
+ win,
+ id: this.getIdForWindow(win),
+ hasTabBrowser: !!lazy.TabManager.getTabBrowser(win),
+ tabIndex: options.tabIndex,
+ };
+ }
+
+ /**
+ * Retrieves an id for the given chrome window. The id is a dynamically
+ * generated uuid associated with the window object.
+ *
+ * @param {window} win
+ * The window object for which we want to retrieve the id.
+ * @returns {string} The unique id for this chrome window.
+ */
+ getIdForWindow(win) {
+ if (!this._chromeWindowHandles.has(win)) {
+ this._chromeWindowHandles.set(win, lazy.generateUUID());
+ }
+ return this._chromeWindowHandles.get(win);
+ }
+
+ /**
+ * Close the specified window.
+ *
+ * @param {window} win
+ * The window to close.
+ * @returns {Promise}
+ * A promise which is resolved when the current window has been closed.
+ */
+ async closeWindow(win) {
+ const destroyed = lazy.waitForObserverTopic("xul-window-destroyed", {
+ checkFn: () => win && win.closed,
+ });
+
+ win.close();
+
+ return destroyed;
+ }
+
+ /**
+ * Focus the specified window.
+ *
+ * @param {window} win
+ * The window to focus.
+ * @returns {Promise}
+ * A promise which is resolved when the window has been focused.
+ */
+ async focusWindow(win) {
+ if (Services.focus.activeWindow != win) {
+ let activated = new lazy.EventPromise(win, "activate");
+ let focused = new lazy.EventPromise(win, "focus", { capture: true });
+
+ win.focus();
+
+ await Promise.all([activated, focused]);
+ }
+ }
+
+ /**
+ * Open a new browser window.
+ *
+ * @param {object=} options
+ * @param {boolean=} options.focus
+ * If true, the opened window will receive the focus. Defaults to false.
+ * @param {boolean=} options.isPrivate
+ * If true, the opened window will be a private window. Defaults to false.
+ * @param {ChromeWindow=} options.openerWindow
+ * Use this window as the opener of the new window. Defaults to the
+ * topmost window.
+ * @param {string=} options.userContextId
+ * The id of the user context which should own the initial tab of the new
+ * window.
+ * @returns {Promise}
+ * A promise resolving to the newly created chrome window.
+ */
+ async openBrowserWindow(options = {}) {
+ let {
+ focus = false,
+ isPrivate = false,
+ openerWindow = null,
+ userContextId = null,
+ } = options;
+
+ switch (lazy.AppInfo.name) {
+ case "Firefox":
+ if (openerWindow === null) {
+ // If no opener was provided, fallback to the topmost window.
+ openerWindow = Services.wm.getMostRecentBrowserWindow();
+ }
+
+ if (!openerWindow) {
+ throw new lazy.error.UnsupportedOperationError(
+ `openWindow() could not find a valid opener window`
+ );
+ }
+
+ // Open new browser window, and wait until it is fully loaded.
+ // Also wait for the window to be focused and activated to prevent a
+ // race condition when promptly focusing to the original window again.
+ const browser = await new Promise(resolveOnContentBrowserCreated =>
+ lazy.URILoadingHelper.openTrustedLinkIn(
+ openerWindow,
+ "about:blank",
+ "window",
+ {
+ private: isPrivate,
+ resolveOnContentBrowserCreated,
+ userContextId:
+ lazy.UserContextManager.getInternalIdById(userContextId),
+ }
+ )
+ );
+
+ // TODO: Both for WebDriver BiDi and classic, opening a new window
+ // should not run the focus steps. When focus is false we should avoid
+ // focusing the new window completely. See Bug 1766329
+
+ if (focus) {
+ // Focus the currently selected tab.
+ browser.focus();
+ } else {
+ // If the new window shouldn't get focused, set the
+ // focus back to the opening window.
+ await this.focusWindow(openerWindow);
+ }
+
+ return browser.ownerGlobal;
+
+ default:
+ throw new lazy.error.UnsupportedOperationError(
+ `openWindow() not supported in ${lazy.AppInfo.name}`
+ );
+ }
+ }
+
+ /**
+ * Wait until the initial application window has been opened and loaded.
+ *
+ * @returns {Promise<WindowProxy>}
+ * A promise that resolved to the application window.
+ */
+ waitForInitialApplicationWindowLoaded() {
+ return new lazy.TimedPromise(
+ async resolve => {
+ // This call includes a fallback to "mail3:pane" as well.
+ const win = Services.wm.getMostRecentBrowserWindow();
+
+ const windowLoaded = lazy.waitForObserverTopic(
+ "browser-delayed-startup-finished",
+ {
+ checkFn: subject => (win !== null ? subject == win : true),
+ }
+ );
+
+ // The current window has already been finished loading.
+ if (win && win.document.readyState == "complete") {
+ resolve(win);
+ return;
+ }
+
+ // Wait for the next browser/mail window to open and finished loading.
+ const { subject } = await windowLoaded;
+ resolve(subject);
+ },
+ {
+ errorMessage: "No applicable application window found",
+ }
+ );
+ }
+}
+
+// Expose a shared singleton.
+export const windowManager = new WindowManager();
diff --git a/remote/shared/js-window-actors/NavigationListenerActor.sys.mjs b/remote/shared/js-window-actors/NavigationListenerActor.sys.mjs
new file mode 100644
index 0000000000..13335177c6
--- /dev/null
+++ b/remote/shared/js-window-actors/NavigationListenerActor.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/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+ TabManager: "chrome://remote/content/shared/TabManager.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
+
+let registered = false;
+export function isNavigationListenerActorRegistered() {
+ return registered;
+}
+
+/**
+ * Register the NavigationListener actor that will keep track of all ongoing
+ * navigations.
+ */
+export function registerNavigationListenerActor() {
+ if (registered) {
+ return;
+ }
+
+ try {
+ ChromeUtils.registerWindowActor("NavigationListener", {
+ kind: "JSWindowActor",
+ parent: {
+ esModuleURI:
+ "chrome://remote/content/shared/js-window-actors/NavigationListenerParent.sys.mjs",
+ },
+ child: {
+ esModuleURI:
+ "chrome://remote/content/shared/js-window-actors/NavigationListenerChild.sys.mjs",
+ events: {
+ DOMWindowCreated: {},
+ },
+ },
+ allFrames: true,
+ messageManagerGroups: ["browsers"],
+ });
+ registered = true;
+
+ // Ensure the navigation listener is started in existing contexts.
+ for (const browser of lazy.TabManager.browsers) {
+ if (!browser?.browsingContext) {
+ continue;
+ }
+
+ for (const context of browser.browsingContext.getAllBrowsingContextsInSubtree()) {
+ if (!context.currentWindowGlobal) {
+ continue;
+ }
+
+ context.currentWindowGlobal
+ .getActor("NavigationListener")
+ // Note that "createActor" is not explicitly referenced in the child
+ // actor, this is only used to trigger the creation of the actor.
+ .sendAsyncMessage("createActor");
+ }
+ }
+ } catch (e) {
+ if (e.name === "NotSupportedError") {
+ lazy.logger.warn(`NavigationListener actor is already registered!`);
+ } else {
+ throw e;
+ }
+ }
+}
+
+export function unregisterNavigationListenerActor() {
+ if (!registered) {
+ return;
+ }
+ ChromeUtils.unregisterWindowActor("NavigationListener");
+ registered = false;
+}
diff --git a/remote/shared/js-window-actors/NavigationListenerChild.sys.mjs b/remote/shared/js-window-actors/NavigationListenerChild.sys.mjs
new file mode 100644
index 0000000000..a2cd8ccf10
--- /dev/null
+++ b/remote/shared/js-window-actors/NavigationListenerChild.sys.mjs
@@ -0,0 +1,167 @@
+/* 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, {
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+ truncate: "chrome://remote/content/shared/Format.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
+
+export class NavigationListenerChild extends JSWindowActorChild {
+ #listener;
+ #webProgress;
+
+ constructor() {
+ super();
+
+ this.#listener = {
+ onLocationChange: this.#onLocationChange,
+ onStateChange: this.#onStateChange,
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIWebProgressListener",
+ "nsISupportsWeakReference",
+ ]),
+ };
+ this.#webProgress = null;
+ }
+
+ actorCreated() {
+ this.#webProgress = this.manager.browsingContext.docShell
+ .QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIWebProgress);
+
+ this.#webProgress.addProgressListener(
+ this.#listener,
+ Ci.nsIWebProgress.NOTIFY_LOCATION |
+ Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT
+ );
+ }
+
+ didDestroy() {
+ try {
+ this.#webProgress.removeProgressListener(this.#listener);
+ } catch (e) {
+ // Ignore potential errors if the window global was already destroyed.
+ }
+ }
+
+ // Note: we rely on events and messages to trigger the actor creation, but
+ // all the logic is in the actorCreated callback. The handleEvent and
+ // receiveMessage methods are only there as placeholders to avoid errors.
+
+ /**
+ * See note above
+ */
+ handleEvent(event) {}
+
+ /**
+ * See note above
+ */
+ receiveMessage(message) {}
+
+ /**
+ * A browsing context might be replaced before reaching the parent process,
+ * instead we serialize enough information to retrieve the navigable in the
+ * parent process.
+ *
+ * If the browsing context is top level, then the browserId can be used to
+ * find the browser element and the new browsing context.
+ * Otherwise (frames) the browsing context should not be replaced and the
+ * browsing context id should be enough to find the browsing context.
+ *
+ * @param {BrowsingContext} browsingContext
+ * The browsing context for which we want to get details.
+ * @returns {object}
+ * An object that returns the following properties:
+ * - browserId: browser id for this browsing context
+ * - browsingContextId: browsing context id
+ * - isTopBrowsingContext: flag that indicates if the browsing context is
+ * top level
+ *
+ */
+ #getBrowsingContextDetails(browsingContext) {
+ return {
+ browserId: browsingContext.browserId,
+ browsingContextId: browsingContext.id,
+ isTopBrowsingContext: browsingContext.parent === null,
+ };
+ }
+
+ #getTargetURI(request) {
+ try {
+ return request.QueryInterface(Ci.nsIChannel).originalURI;
+ } catch (e) {}
+
+ return null;
+ }
+
+ #onLocationChange = (progress, request, location, stateFlags) => {
+ if (stateFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT) {
+ const context = progress.browsingContext;
+
+ lazy.logger.trace(
+ `[${context.id}] NavigationListener onLocationChange,` +
+ lazy.truncate` location: ${location.spec}`
+ );
+
+ this.sendAsyncMessage("NavigationListenerChild:locationChanged", {
+ contextDetails: this.#getBrowsingContextDetails(context),
+ url: location.spec,
+ });
+ }
+ };
+
+ #onStateChange = (progress, request, stateFlags, status) => {
+ const context = progress.browsingContext;
+ const targetURI = this.#getTargetURI(request);
+
+ const isBindingAborted = status == Cr.NS_BINDING_ABORTED;
+ const isStart = !!(stateFlags & Ci.nsIWebProgressListener.STATE_START);
+ const isStop = !!(stateFlags & Ci.nsIWebProgressListener.STATE_STOP);
+
+ if (lazy.Log.isTraceLevelOrMore) {
+ const isNetwork = !!(
+ stateFlags & Ci.nsIWebProgressListener.STATE_IS_NETWORK
+ );
+ lazy.logger.trace(
+ `[${context.id}] NavigationListener onStateChange,` +
+ ` stateFlags: ${stateFlags}, status: ${status}, isStart: ${isStart},` +
+ ` isStop: ${isStop}, isNetwork: ${isNetwork},` +
+ ` isBindingAborted: ${isBindingAborted},` +
+ lazy.truncate` targetURI: ${targetURI?.spec}`
+ );
+ }
+
+ try {
+ if (isStart) {
+ this.sendAsyncMessage("NavigationListenerChild:navigationStarted", {
+ contextDetails: this.#getBrowsingContextDetails(context),
+ url: targetURI?.spec,
+ });
+
+ return;
+ }
+
+ if (isStop && !isBindingAborted) {
+ // Skip NS_BINDING_ABORTED state changes as this can happen during a
+ // browsing context + process change and we should get the real stop state
+ // change from the correct process later.
+ this.sendAsyncMessage("NavigationListenerChild:navigationStopped", {
+ contextDetails: this.#getBrowsingContextDetails(context),
+ url: targetURI?.spec,
+ });
+ }
+ } catch (e) {
+ if (e.name === "InvalidStateError") {
+ // We'll arrive here if we no longer have our manager, so we can
+ // just swallow this error.
+ return;
+ }
+ throw e;
+ }
+ };
+}
diff --git a/remote/shared/js-window-actors/NavigationListenerParent.sys.mjs b/remote/shared/js-window-actors/NavigationListenerParent.sys.mjs
new file mode 100644
index 0000000000..334f9953d6
--- /dev/null
+++ b/remote/shared/js-window-actors/NavigationListenerParent.sys.mjs
@@ -0,0 +1,58 @@
+/* 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, {
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+ notifyLocationChanged:
+ "chrome://remote/content/shared/NavigationManager.sys.mjs",
+ notifyNavigationStarted:
+ "chrome://remote/content/shared/NavigationManager.sys.mjs",
+ notifyNavigationStopped:
+ "chrome://remote/content/shared/NavigationManager.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
+
+export class NavigationListenerParent extends JSWindowActorParent {
+ async receiveMessage(message) {
+ try {
+ switch (message.name) {
+ case "NavigationListenerChild:locationChanged": {
+ lazy.notifyLocationChanged({
+ contextDetails: message.data.contextDetails,
+ url: message.data.url,
+ });
+ break;
+ }
+ case "NavigationListenerChild:navigationStarted": {
+ lazy.notifyNavigationStarted({
+ contextDetails: message.data.contextDetails,
+ url: message.data.url,
+ });
+ break;
+ }
+ case "NavigationListenerChild:navigationStopped": {
+ lazy.notifyNavigationStopped({
+ contextDetails: message.data.contextDetails,
+ url: message.data.url,
+ });
+ break;
+ }
+ default:
+ throw new Error("Unsupported message:" + message.name);
+ }
+ } catch (e) {
+ if (e instanceof TypeError) {
+ // Avoid error spam from errors due to unavailable browsing contexts.
+ lazy.logger.trace(
+ `Failed to handle a navigation listener message: ${e.message}`
+ );
+ } else {
+ throw e;
+ }
+ }
+ }
+}
diff --git a/remote/shared/listeners/BrowsingContextListener.sys.mjs b/remote/shared/listeners/BrowsingContextListener.sys.mjs
new file mode 100644
index 0000000000..d4e3539ca9
--- /dev/null
+++ b/remote/shared/listeners/BrowsingContextListener.sys.mjs
@@ -0,0 +1,122 @@
+/* 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, {
+ EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs",
+});
+
+const OBSERVER_TOPIC_ATTACHED = "browsing-context-attached";
+const OBSERVER_TOPIC_DISCARDED = "browsing-context-discarded";
+
+const OBSERVER_TOPIC_SET_EMBEDDER = "browsing-context-did-set-embedder";
+
+/**
+ * The BrowsingContextListener can be used to listen for notifications coming
+ * from browsing contexts that get attached or discarded.
+ *
+ * Example:
+ * ```
+ * const listener = new BrowsingContextListener();
+ * listener.on("attached", onAttached);
+ * listener.startListening();
+ *
+ * const onAttached = (eventName, data = {}) => {
+ * const { browsingContext, why } = data;
+ * ...
+ * };
+ * ```
+ *
+ * @fires message
+ * The BrowsingContextListener emits "attached" and "discarded" events,
+ * with the following object as payload:
+ * - {BrowsingContext} browsingContext
+ * Browsing context the notification relates to.
+ * - {string} why
+ * Usually "attach" or "discard", but will contain "replace" if the
+ * browsing context gets replaced by a cross-group navigation.
+ */
+export class BrowsingContextListener {
+ #listening;
+ #topContextsToAttach;
+
+ /**
+ * Create a new BrowsingContextListener instance.
+ */
+ constructor() {
+ lazy.EventEmitter.decorate(this);
+
+ // A map that temporarily holds attached top-level browsing contexts until
+ // their embedder element is set, which is required to successfully
+ // retrieve a unique id for the content browser by the TabManager.
+ this.#topContextsToAttach = new Map();
+
+ this.#listening = false;
+ }
+
+ destroy() {
+ this.stopListening();
+ this.#topContextsToAttach = null;
+ }
+
+ observe(subject, topic, data) {
+ switch (topic) {
+ case OBSERVER_TOPIC_ATTACHED:
+ // Delay emitting the event for top-level browsing contexts until
+ // the embedder element has been set.
+ if (!subject.parent) {
+ this.#topContextsToAttach.set(subject, data);
+ return;
+ }
+
+ this.emit("attached", { browsingContext: subject, why: data });
+ break;
+
+ case OBSERVER_TOPIC_DISCARDED:
+ // Remove a recently attached top-level browsing context if it's
+ // immediately discarded.
+ if (this.#topContextsToAttach.has(subject)) {
+ this.#topContextsToAttach.delete(subject);
+ }
+
+ this.emit("discarded", { browsingContext: subject, why: data });
+ break;
+
+ case OBSERVER_TOPIC_SET_EMBEDDER:
+ const why = this.#topContextsToAttach.get(subject);
+ if (why !== undefined) {
+ this.emit("attached", { browsingContext: subject, why });
+ this.#topContextsToAttach.delete(subject);
+ }
+ break;
+ }
+ }
+
+ startListening() {
+ if (this.#listening) {
+ return;
+ }
+
+ Services.obs.addObserver(this, OBSERVER_TOPIC_ATTACHED);
+ Services.obs.addObserver(this, OBSERVER_TOPIC_DISCARDED);
+ Services.obs.addObserver(this, OBSERVER_TOPIC_SET_EMBEDDER);
+
+ this.#listening = true;
+ }
+
+ stopListening() {
+ if (!this.#listening) {
+ return;
+ }
+
+ Services.obs.removeObserver(this, OBSERVER_TOPIC_ATTACHED);
+ Services.obs.removeObserver(this, OBSERVER_TOPIC_DISCARDED);
+ Services.obs.removeObserver(this, OBSERVER_TOPIC_SET_EMBEDDER);
+
+ this.#topContextsToAttach.clear();
+
+ this.#listening = false;
+ }
+}
diff --git a/remote/shared/listeners/ConsoleAPIListener.sys.mjs b/remote/shared/listeners/ConsoleAPIListener.sys.mjs
new file mode 100644
index 0000000000..7f5c850945
--- /dev/null
+++ b/remote/shared/listeners/ConsoleAPIListener.sys.mjs
@@ -0,0 +1,124 @@
+/* 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, {
+ EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "ConsoleAPIStorage", () => {
+ return Cc["@mozilla.org/consoleAPI-storage;1"].getService(
+ Ci.nsIConsoleAPIStorage
+ );
+});
+
+/**
+ * The ConsoleAPIListener can be used to listen for messages coming from console
+ * API usage in a given windowGlobal, eg. console.log, console.error, ...
+ *
+ * Example:
+ * ```
+ * const listener = new ConsoleAPIListener(innerWindowId);
+ * listener.on("message", onConsoleAPIMessage);
+ * listener.startListening();
+ *
+ * const onConsoleAPIMessage = (eventName, data = {}) => {
+ * const { arguments: msgArguments, level, stacktrace, timeStamp } = data;
+ * ...
+ * };
+ * ```
+ *
+ * @fires message
+ * The ConsoleAPIListener emits "message" events, with the following object as
+ * payload:
+ * - {Array<Object>} arguments - Arguments as passed-in when the method was called.
+ * - {String} level - Importance, one of `info`, `warn`, `error`, `debug`, `trace`.
+ * - {Array<Object>} stacktrace - List of stack frames, starting from most recent.
+ * - {Number} timeStamp - Timestamp when the method was called.
+ */
+export class ConsoleAPIListener {
+ #emittedMessages;
+ #innerWindowId;
+ #listening;
+
+ /**
+ * Create a new ConsoleAPIListener instance.
+ *
+ * @param {number} innerWindowId
+ * The inner window id to filter the messages for.
+ */
+ constructor(innerWindowId) {
+ lazy.EventEmitter.decorate(this);
+
+ this.#emittedMessages = new Set();
+ this.#innerWindowId = innerWindowId;
+ this.#listening = false;
+ }
+
+ destroy() {
+ this.stopListening();
+ this.#emittedMessages = null;
+ }
+
+ startListening() {
+ if (this.#listening) {
+ return;
+ }
+
+ lazy.ConsoleAPIStorage.addLogEventListener(
+ this.#onConsoleAPIMessage,
+ Cc["@mozilla.org/systemprincipal;1"].createInstance(Ci.nsIPrincipal)
+ );
+
+ // Emit cached messages after registering the listener, to make sure we
+ // don't miss any message.
+ this.#emitCachedMessages();
+
+ this.#listening = true;
+ }
+
+ stopListening() {
+ if (!this.#listening) {
+ return;
+ }
+
+ lazy.ConsoleAPIStorage.removeLogEventListener(this.#onConsoleAPIMessage);
+ this.#listening = false;
+ }
+
+ #emitCachedMessages() {
+ const cachedMessages = lazy.ConsoleAPIStorage.getEvents(
+ this.#innerWindowId
+ );
+ for (const message of cachedMessages) {
+ this.#onConsoleAPIMessage(message);
+ }
+ }
+
+ #onConsoleAPIMessage = message => {
+ const messageObject = message.wrappedJSObject;
+
+ // Bail if this message was already emitted, useful to filter out cached
+ // messages already received by the consumer.
+ if (this.#emittedMessages.has(messageObject)) {
+ return;
+ }
+
+ this.#emittedMessages.add(messageObject);
+
+ if (messageObject.innerID !== this.#innerWindowId) {
+ // If the message doesn't match the innerWindowId of the current context
+ // ignore it.
+ return;
+ }
+
+ this.emit("message", {
+ arguments: messageObject.arguments,
+ level: messageObject.level,
+ stacktrace: messageObject.stacktrace,
+ timeStamp: messageObject.timeStamp,
+ });
+ };
+}
diff --git a/remote/shared/listeners/ConsoleListener.sys.mjs b/remote/shared/listeners/ConsoleListener.sys.mjs
new file mode 100644
index 0000000000..0344cf2be2
--- /dev/null
+++ b/remote/shared/listeners/ConsoleListener.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/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs",
+
+ getFramesFromStack: "chrome://remote/content/shared/Stack.sys.mjs",
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
+
+/**
+ * The ConsoleListener can be used to listen for console messages related to
+ * Javascript errors, certain warnings which all happen within a specific
+ * windowGlobal. Consumers can listen for the message types "error",
+ * "warn" and "info".
+ *
+ * Example:
+ * ```
+ * const onJavascriptError = (eventName, data = {}) => {
+ * const { level, message, stacktrace, timestamp } = data;
+ * ...
+ * };
+ *
+ * const listener = new ConsoleListener(innerWindowId);
+ * listener.on("error", onJavascriptError);
+ * listener.startListening();
+ * ...
+ * listener.stopListening();
+ * ```
+ *
+ * @fires message
+ * The ConsoleListener emits "error", "warn" and "info" events, with the
+ * following object as payload:
+ * - {String} level - Importance, one of `info`, `warn`, `error`,
+ * `debug`, `trace`.
+ * - {String} message - Actual message from the console entry.
+ * - {Array<StackFrame>} stacktrace - List of stack frames,
+ * starting from most recent.
+ * - {Number} timeStamp - Timestamp when the method was called.
+ */
+export class ConsoleListener {
+ #emittedMessages;
+ #innerWindowId;
+ #listening;
+
+ /**
+ * Create a new ConsoleListener instance.
+ *
+ * @param {number} innerWindowId
+ * The inner window id to filter the messages for.
+ */
+ constructor(innerWindowId) {
+ lazy.EventEmitter.decorate(this);
+
+ this.#emittedMessages = new Set();
+ this.#innerWindowId = innerWindowId;
+ this.#listening = false;
+ }
+
+ get listening() {
+ return this.#listening;
+ }
+
+ destroy() {
+ this.stopListening();
+ this.#emittedMessages = null;
+ }
+
+ startListening() {
+ if (this.#listening) {
+ return;
+ }
+
+ Services.console.registerListener(this.#onConsoleMessage);
+
+ // Emit cached messages after registering the listener, to make sure we
+ // don't miss any message.
+ this.#emitCachedMessages();
+
+ this.#listening = true;
+ }
+
+ stopListening() {
+ if (!this.#listening) {
+ return;
+ }
+
+ Services.console.unregisterListener(this.#onConsoleMessage);
+ this.#listening = false;
+ }
+
+ #emitCachedMessages() {
+ const cachedMessages = Services.console.getMessageArray() || [];
+
+ for (const message of cachedMessages) {
+ this.#onConsoleMessage(message);
+ }
+ }
+
+ #onConsoleMessage = message => {
+ if (!(message instanceof Ci.nsIScriptError)) {
+ // For now ignore basic nsIConsoleMessage instances, which are only
+ // relevant to Chrome code and do not have a valid window reference.
+ return;
+ }
+
+ // Bail if this message was already emitted, useful to filter out cached
+ // messages already received by the consumer.
+ if (this.#emittedMessages.has(message)) {
+ return;
+ }
+
+ this.#emittedMessages.add(message);
+
+ if (message.innerWindowID !== this.#innerWindowId) {
+ // If the message doesn't match the innerWindowId of the current context
+ // ignore it.
+ return;
+ }
+
+ const { errorFlag, warningFlag, infoFlag } = Ci.nsIScriptError;
+ let level;
+
+ if ((message.flags & warningFlag) == warningFlag) {
+ level = "warn";
+ } else if ((message.flags & infoFlag) == infoFlag) {
+ level = "info";
+ } else if ((message.flags & errorFlag) == errorFlag) {
+ level = "error";
+ } else {
+ lazy.logger.warn(
+ `Not able to process console message with unknown flags ${message.flags}`
+ );
+ return;
+ }
+
+ // Send event when actively listening.
+ this.emit(level, {
+ level,
+ message: message.errorMessage,
+ stacktrace: lazy.getFramesFromStack(message.stack),
+ timeStamp: message.timeStamp,
+ });
+ };
+
+ get QueryInterface() {
+ return ChromeUtils.generateQI(["nsIConsoleListener"]);
+ }
+}
diff --git a/remote/shared/listeners/ContextualIdentityListener.sys.mjs b/remote/shared/listeners/ContextualIdentityListener.sys.mjs
new file mode 100644
index 0000000000..d93b44ed77
--- /dev/null
+++ b/remote/shared/listeners/ContextualIdentityListener.sys.mjs
@@ -0,0 +1,85 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs",
+});
+
+const OBSERVER_TOPIC_CREATED = "contextual-identity-created";
+const OBSERVER_TOPIC_DELETED = "contextual-identity-deleted";
+
+/**
+ * The ContextualIdentityListener can be used to listen for notifications about
+ * contextual identities (containers) being created or deleted.
+ *
+ * Example:
+ * ```
+ * const listener = new ContextualIdentityListener();
+ * listener.on("created", onCreated);
+ * listener.startListening();
+ *
+ * const onCreated = (eventName, data = {}) => {
+ * const { identity } = data;
+ * ...
+ * };
+ * ```
+ *
+ * @fires message
+ * The ContextualIdentityListener emits "created" and "deleted" events,
+ * with the following object as payload:
+ * - {object} identity
+ * The contextual identity which was created or deleted.
+ */
+export class ContextualIdentityListener {
+ #listening;
+
+ /**
+ * Create a new BrowsingContextListener instance.
+ */
+ constructor() {
+ lazy.EventEmitter.decorate(this);
+
+ this.#listening = false;
+ }
+
+ destroy() {
+ this.stopListening();
+ }
+
+ observe(subject, topic, data) {
+ switch (topic) {
+ case OBSERVER_TOPIC_CREATED:
+ this.emit("created", { identity: subject.wrappedJSObject });
+ break;
+
+ case OBSERVER_TOPIC_DELETED:
+ this.emit("deleted", { identity: subject.wrappedJSObject });
+ break;
+ }
+ }
+
+ startListening() {
+ if (this.#listening) {
+ return;
+ }
+
+ Services.obs.addObserver(this, OBSERVER_TOPIC_CREATED);
+ Services.obs.addObserver(this, OBSERVER_TOPIC_DELETED);
+
+ this.#listening = true;
+ }
+
+ stopListening() {
+ if (!this.#listening) {
+ return;
+ }
+
+ Services.obs.removeObserver(this, OBSERVER_TOPIC_CREATED);
+ Services.obs.removeObserver(this, OBSERVER_TOPIC_DELETED);
+
+ this.#listening = false;
+ }
+}
diff --git a/remote/shared/listeners/LoadListener.sys.mjs b/remote/shared/listeners/LoadListener.sys.mjs
new file mode 100644
index 0000000000..cccfca7a90
--- /dev/null
+++ b/remote/shared/listeners/LoadListener.sys.mjs
@@ -0,0 +1,103 @@
+/* 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, {
+ EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs",
+});
+
+/**
+ * The LoadListener can be used to listen for load events.
+ *
+ * Example:
+ * ```
+ * const listener = new LoadListener();
+ * listener.on("DOMContentLoaded", onDOMContentLoaded);
+ * listener.startListening();
+ *
+ * const onDOMContentLoaded = (eventName, data = {}) => {
+ * const { target } = data;
+ * ...
+ * };
+ * ```
+ *
+ * @fires message
+ * The LoadListener emits "DOMContentLoaded" and "load" events,
+ * with the following object as payload:
+ * - {Document} target
+ * The target document.
+ */
+export class LoadListener {
+ #abortController;
+ #window;
+
+ /**
+ * Create a new LoadListener instance.
+ */
+ constructor(win) {
+ lazy.EventEmitter.decorate(this);
+
+ // Use an abort controller instead of removeEventListener because destroy
+ // might be called close to the window global destruction.
+ this.#abortController = null;
+
+ this.#window = win;
+ }
+
+ destroy() {
+ this.stopListening();
+ }
+
+ startListening() {
+ if (this.#abortController) {
+ return;
+ }
+
+ this.#abortController = new AbortController();
+
+ // Events are attached to the windowRoot instead of the regular window to
+ // avoid issues with document.open (Bug 1822772).
+ this.#window.windowRoot.addEventListener(
+ "DOMContentLoaded",
+ this.#onDOMContentLoaded,
+ {
+ capture: true,
+ mozSystemGroup: true,
+ signal: this.#abortController.signal,
+ }
+ );
+
+ this.#window.windowRoot.addEventListener("load", this.#onLoad, {
+ capture: true,
+ mozSystemGroup: true,
+ signal: this.#abortController.signal,
+ });
+ }
+
+ stopListening() {
+ if (!this.#abortController) {
+ return;
+ }
+
+ this.#abortController.abort();
+ this.#abortController = null;
+ }
+
+ #onDOMContentLoaded = event => {
+ // Check that this event was emitted for the relevant window, because events
+ // from inner frames can bubble to the windowRoot.
+ if (event.target.defaultView === this.#window) {
+ this.emit("DOMContentLoaded", { target: event.target });
+ }
+ };
+
+ #onLoad = event => {
+ // Check that this event was emitted for the relevant window, because events
+ // from inner frames can bubble to the windowRoot.
+ if (event.target.defaultView === this.#window) {
+ this.emit("load", { target: event.target });
+ }
+ };
+}
diff --git a/remote/shared/listeners/NavigationListener.sys.mjs b/remote/shared/listeners/NavigationListener.sys.mjs
new file mode 100644
index 0000000000..c911bb53f6
--- /dev/null
+++ b/remote/shared/listeners/NavigationListener.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/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs",
+});
+
+/**
+ * The NavigationListener simply wraps a NavigationManager instance and exposes
+ * it with a convenient listener API, more consistent with the rest of the
+ * remote codebase. NavigationManager is a singleton per session so it can't
+ * be instanciated for each and every consumer.
+ *
+ * Example:
+ * ```
+ * const onNavigationStarted = (eventName, data = {}) => {
+ * const { level, message, stacktrace, timestamp } = data;
+ * ...
+ * };
+ *
+ * const listener = new NavigationListener(this.messageHandler.navigationManager);
+ * listener.on("navigation-started", onNavigationStarted);
+ * listener.startListening();
+ * ...
+ * listener.stopListening();
+ * ```
+ *
+ * @fires message
+ * The NavigationListener emits "navigation-started", "location-changed" and
+ * "navigation-stopped" events, with the following object as payload:
+ * - {string} navigationId - The UUID for the navigation.
+ * - {string} navigableId - The UUID for the navigable.
+ * - {string} url - The target url for the navigation.
+ */
+export class NavigationListener {
+ #listening;
+ #navigationManager;
+
+ /**
+ * Create a new NavigationListener instance.
+ *
+ * @param {NavigationManager} navigationManager
+ * The underlying NavigationManager for this listener.
+ */
+ constructor(navigationManager) {
+ lazy.EventEmitter.decorate(this);
+
+ this.#listening = false;
+ this.#navigationManager = navigationManager;
+ }
+
+ get listening() {
+ return this.#listening;
+ }
+
+ destroy() {
+ this.stopListening();
+ }
+
+ startListening() {
+ if (this.#listening) {
+ return;
+ }
+
+ this.#navigationManager.on("navigation-started", this.#forwardEvent);
+ this.#navigationManager.on("navigation-stopped", this.#forwardEvent);
+ this.#navigationManager.on("location-changed", this.#forwardEvent);
+
+ this.#listening = true;
+ }
+
+ stopListening() {
+ if (!this.#listening) {
+ return;
+ }
+
+ this.#navigationManager.off("navigation-started", this.#forwardEvent);
+ this.#navigationManager.off("navigation-stopped", this.#forwardEvent);
+ this.#navigationManager.off("location-changed", this.#forwardEvent);
+
+ this.#listening = false;
+ }
+
+ #forwardEvent = (name, data) => {
+ this.emit(name, data);
+ };
+}
diff --git a/remote/shared/listeners/NetworkEventRecord.sys.mjs b/remote/shared/listeners/NetworkEventRecord.sys.mjs
new file mode 100644
index 0000000000..a41f3edd7d
--- /dev/null
+++ b/remote/shared/listeners/NetworkEventRecord.sys.mjs
@@ -0,0 +1,455 @@
+/* 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, {
+ NetworkUtils:
+ "resource://devtools/shared/network-observer/NetworkUtils.sys.mjs",
+
+ TabManager: "chrome://remote/content/shared/TabManager.sys.mjs",
+});
+
+/**
+ * The NetworkEventRecord implements the interface expected from network event
+ * owners for consumers of the DevTools NetworkObserver.
+ *
+ * The NetworkEventRecord emits the before-request-sent event on behalf of the
+ * NetworkListener instance which created it.
+ */
+export class NetworkEventRecord {
+ #contextId;
+ #fromCache;
+ #isMainDocumentChannel;
+ #networkListener;
+ #redirectCount;
+ #requestChannel;
+ #requestData;
+ #requestId;
+ #responseChannel;
+ #responseData;
+ #wrappedChannel;
+
+ /**
+ *
+ * @param {object} networkEvent
+ * The initial network event information (see createNetworkEvent() in
+ * NetworkUtils.sys.mjs).
+ * @param {nsIChannel} channel
+ * The nsIChannel behind this network event.
+ * @param {NetworkListener} networkListener
+ * The NetworkListener which created this NetworkEventRecord.
+ */
+ constructor(networkEvent, channel, networkListener) {
+ this.#requestChannel = channel;
+ this.#responseChannel = null;
+
+ this.#fromCache = networkEvent.fromCache;
+ this.#isMainDocumentChannel = channel.isMainDocumentChannel;
+
+ this.#wrappedChannel = ChannelWrapper.get(channel);
+
+ this.#networkListener = networkListener;
+
+ // The context ids computed by TabManager have the lifecycle of a navigable
+ // and can be reused for all the events emitted from this record.
+ this.#contextId = this.#getContextId();
+
+ // The wrappedChannel id remains identical across redirects, whereas
+ // nsIChannel.channelId is different for each and every request.
+ this.#requestId = this.#wrappedChannel.id.toString();
+
+ const { cookies, headers } =
+ lazy.NetworkUtils.fetchRequestHeadersAndCookies(channel);
+
+ // See the RequestData type definition for the full list of properties that
+ // should be set on this object.
+ this.#requestData = {
+ bodySize: null,
+ cookies,
+ headers,
+ headersSize: networkEvent.rawHeaders ? networkEvent.rawHeaders.length : 0,
+ method: channel.requestMethod,
+ request: this.#requestId,
+ timings: {},
+ url: channel.URI.spec,
+ };
+
+ // See the ResponseData type definition for the full list of properties that
+ // should be set on this object.
+ this.#responseData = {
+ // encoded size (body)
+ bodySize: null,
+ content: {
+ // decoded size
+ size: null,
+ },
+ // encoded size (headers)
+ headersSize: null,
+ url: channel.URI.spec,
+ };
+
+ // NetworkObserver creates a network event when request headers have been
+ // parsed.
+ // According to the BiDi spec, we should emit beforeRequestSent when adding
+ // request headers, see https://whatpr.org/fetch/1540.html#http-network-or-cache-fetch
+ // step 8.17
+ // Bug 1802181: switch the NetworkObserver to an event-based API.
+ this.#emitBeforeRequestSent();
+
+ // If the request is already blocked, we will not receive further updates,
+ // emit a network.fetchError event immediately.
+ if (networkEvent.blockedReason) {
+ this.#emitFetchError();
+ }
+ }
+
+ /**
+ * Add network request POST data.
+ *
+ * Required API for a NetworkObserver event owner.
+ *
+ * @param {object} postData
+ * The request POST data.
+ */
+ addRequestPostData(postData) {
+ // Only the postData size is needed for RemoteAgent consumers.
+ this.#requestData.bodySize = postData.size;
+ }
+
+ /**
+ * Add the initial network response information.
+ *
+ * Required API for a NetworkObserver event owner.
+ *
+ *
+ * @param {object} options
+ * @param {nsIChannel} options.channel
+ * The channel.
+ * @param {boolean} options.fromCache
+ * @param {string} options.rawHeaders
+ */
+ addResponseStart(options) {
+ const { channel, fromCache, rawHeaders = "" } = options;
+ this.#responseChannel = channel;
+
+ const { headers } =
+ lazy.NetworkUtils.fetchResponseHeadersAndCookies(channel);
+
+ const headersSize = rawHeaders.length;
+ this.#responseData = {
+ ...this.#responseData,
+ bodySize: 0,
+ bytesReceived: headersSize,
+ fromCache: this.#fromCache || !!fromCache,
+ headers,
+ headersSize,
+ mimeType: this.#getMimeType(),
+ protocol: lazy.NetworkUtils.getProtocol(channel),
+ status: channel.responseStatus,
+ statusText: channel.responseStatusText,
+ };
+
+ // This should be triggered when all headers have been received, matching
+ // the WebDriverBiDi response started trigger in `4.6. HTTP-network fetch`
+ // from the fetch specification, based on the PR visible at
+ // https://github.com/whatwg/fetch/pull/1540
+ this.#emitResponseStarted();
+ }
+
+ /**
+ * Add connection security information.
+ *
+ * Required API for a NetworkObserver event owner.
+ *
+ * Not used for RemoteAgent.
+ *
+ * @param {object} info
+ * The object containing security information.
+ * @param {boolean} isRacing
+ * True if the corresponding channel raced the cache and network requests.
+ */
+ addSecurityInfo(info, isRacing) {}
+
+ /**
+ * Add network event timings.
+ *
+ * Required API for a NetworkObserver event owner.
+ *
+ * Not used for RemoteAgent.
+ *
+ * @param {number} total
+ * The total time for the request.
+ * @param {object} timings
+ * The har-like timings.
+ * @param {object} offsets
+ * The har-like timings, but as offset from the request start.
+ */
+ addEventTimings(total, timings, offsets) {}
+
+ /**
+ * Add response cache entry.
+ *
+ * Required API for a NetworkObserver event owner.
+ *
+ * Not used for RemoteAgent.
+ *
+ * @param {object} options
+ * An object which contains a single responseCache property.
+ */
+ addResponseCache(options) {}
+
+ /**
+ * Add response content.
+ *
+ * Required API for a NetworkObserver event owner.
+ *
+ * @param {object} response
+ * An object which represents the response content.
+ * @param {object} responseInfo
+ * Additional meta data about the response.
+ */
+ addResponseContent(response, responseInfo) {
+ // Update content-related sizes with the latest data from addResponseContent.
+ this.#responseData = {
+ ...this.#responseData,
+ bodySize: response.bodySize,
+ bytesReceived: response.transferredSize,
+ content: {
+ size: response.decodedBodySize,
+ },
+ };
+
+ if (responseInfo.blockedReason) {
+ this.#emitFetchError();
+ } else {
+ this.#emitResponseCompleted();
+ }
+ }
+
+ /**
+ * Add server timings.
+ *
+ * Required API for a NetworkObserver event owner.
+ *
+ * Not used for RemoteAgent.
+ *
+ * @param {Array} serverTimings
+ * The server timings.
+ */
+ addServerTimings(serverTimings) {}
+
+ /**
+ * Add service worker timings.
+ *
+ * Required API for a NetworkObserver event owner.
+ *
+ * Not used for RemoteAgent.
+ *
+ * @param {object} serviceWorkerTimings
+ * The server timings.
+ */
+ addServiceWorkerTimings(serviceWorkerTimings) {}
+
+ onAuthPrompt(authDetails, authCallbacks) {
+ this.#emitAuthRequired(authCallbacks);
+ }
+
+ /**
+ * Convert the provided request timing to a timing relative to the beginning
+ * of the request. All timings are numbers representing high definition
+ * timestamps.
+ *
+ * @param {number} timing
+ * High definition timestamp for a request timing relative from the time
+ * origin.
+ * @param {number} requestTime
+ * High definition timestamp for the request start time relative from the
+ * time origin.
+ * @returns {number}
+ * High definition timestamp for the request timing relative to the start
+ * time of the request, or 0 if the provided timing was 0.
+ */
+ #convertTimestamp(timing, requestTime) {
+ if (timing == 0) {
+ return 0;
+ }
+
+ return timing - requestTime;
+ }
+
+ #emitAuthRequired(authCallbacks) {
+ this.#updateDataFromTimedChannel();
+
+ this.#networkListener.emit("auth-required", {
+ authCallbacks,
+ contextId: this.#contextId,
+ isNavigationRequest: this.#isMainDocumentChannel,
+ redirectCount: this.#redirectCount,
+ requestChannel: this.#requestChannel,
+ requestData: this.#requestData,
+ responseChannel: this.#responseChannel,
+ responseData: this.#responseData,
+ timestamp: Date.now(),
+ });
+ }
+
+ #emitBeforeRequestSent() {
+ this.#updateDataFromTimedChannel();
+
+ this.#networkListener.emit("before-request-sent", {
+ contextId: this.#contextId,
+ isNavigationRequest: this.#isMainDocumentChannel,
+ redirectCount: this.#redirectCount,
+ requestChannel: this.#requestChannel,
+ requestData: this.#requestData,
+ timestamp: Date.now(),
+ });
+ }
+
+ #emitFetchError() {
+ this.#updateDataFromTimedChannel();
+
+ this.#networkListener.emit("fetch-error", {
+ contextId: this.#contextId,
+ // TODO: Update with a proper error text. Bug 1873037.
+ errorText: ChromeUtils.getXPCOMErrorName(this.#requestChannel.status),
+ isNavigationRequest: this.#isMainDocumentChannel,
+ redirectCount: this.#redirectCount,
+ requestChannel: this.#requestChannel,
+ requestData: this.#requestData,
+ timestamp: Date.now(),
+ });
+ }
+
+ #emitResponseCompleted() {
+ this.#updateDataFromTimedChannel();
+
+ this.#networkListener.emit("response-completed", {
+ contextId: this.#contextId,
+ isNavigationRequest: this.#isMainDocumentChannel,
+ redirectCount: this.#redirectCount,
+ requestChannel: this.#requestChannel,
+ requestData: this.#requestData,
+ responseChannel: this.#responseChannel,
+ responseData: this.#responseData,
+ timestamp: Date.now(),
+ });
+ }
+
+ #emitResponseStarted() {
+ this.#updateDataFromTimedChannel();
+
+ this.#networkListener.emit("response-started", {
+ contextId: this.#contextId,
+ isNavigationRequest: this.#isMainDocumentChannel,
+ redirectCount: this.#redirectCount,
+ requestChannel: this.#requestChannel,
+ requestData: this.#requestData,
+ responseChannel: this.#responseChannel,
+ responseData: this.#responseData,
+ timestamp: Date.now(),
+ });
+ }
+
+ #getBrowsingContext() {
+ const id = lazy.NetworkUtils.getChannelBrowsingContextID(
+ this.#requestChannel
+ );
+ return BrowsingContext.get(id);
+ }
+
+ /**
+ * Retrieve the navigable id for the current browsing context associated to
+ * the requests' channel. Network events are recorded in the parent process
+ * so we always expect to be able to use TabManager.getIdForBrowsingContext.
+ *
+ * @returns {string}
+ * The navigable id corresponding to the given browsing context.
+ */
+ #getContextId() {
+ return lazy.TabManager.getIdForBrowsingContext(this.#getBrowsingContext());
+ }
+
+ #getMimeType() {
+ // TODO: DevTools NetworkObserver is computing a similar value in
+ // addResponseContent, but uses an inconsistent implementation in
+ // addResponseStart. This approach can only be used as early as in
+ // addResponseHeaders. We should move this logic to the NetworkObserver and
+ // expose mimeType in addResponseStart. Bug 1809670.
+ let mimeType = "";
+
+ try {
+ mimeType = this.#wrappedChannel.contentType;
+ const contentCharset = this.#requestChannel.contentCharset;
+ if (contentCharset) {
+ mimeType += `;charset=${contentCharset}`;
+ }
+ } catch (e) {
+ // Ignore exceptions when reading contentType/contentCharset
+ }
+
+ return mimeType;
+ }
+
+ #getTimingsFromTimedChannel(timedChannel) {
+ const {
+ channelCreationTime,
+ redirectStartTime,
+ redirectEndTime,
+ dispatchFetchEventStartTime,
+ cacheReadStartTime,
+ domainLookupStartTime,
+ domainLookupEndTime,
+ connectStartTime,
+ connectEndTime,
+ secureConnectionStartTime,
+ requestStartTime,
+ responseStartTime,
+ responseEndTime,
+ } = timedChannel;
+
+ // fetchStart should be the post-redirect start time, which should be the
+ // first non-zero timing from: dispatchFetchEventStart, cacheReadStart and
+ // domainLookupStart. See https://www.w3.org/TR/navigation-timing-2/#processing-model
+ const fetchStartTime =
+ dispatchFetchEventStartTime ||
+ cacheReadStartTime ||
+ domainLookupStartTime;
+
+ // Bug 1805478: Per spec, the origin time should match Performance API's
+ // timeOrigin for the global which initiated the request. This is not
+ // available in the parent process, so for now we will use 0.
+ const timeOrigin = 0;
+
+ return {
+ timeOrigin,
+ requestTime: this.#convertTimestamp(channelCreationTime, timeOrigin),
+ redirectStart: this.#convertTimestamp(redirectStartTime, timeOrigin),
+ redirectEnd: this.#convertTimestamp(redirectEndTime, timeOrigin),
+ fetchStart: this.#convertTimestamp(fetchStartTime, timeOrigin),
+ dnsStart: this.#convertTimestamp(domainLookupStartTime, timeOrigin),
+ dnsEnd: this.#convertTimestamp(domainLookupEndTime, timeOrigin),
+ connectStart: this.#convertTimestamp(connectStartTime, timeOrigin),
+ connectEnd: this.#convertTimestamp(connectEndTime, timeOrigin),
+ tlsStart: this.#convertTimestamp(secureConnectionStartTime, timeOrigin),
+ tlsEnd: this.#convertTimestamp(connectEndTime, timeOrigin),
+ requestStart: this.#convertTimestamp(requestStartTime, timeOrigin),
+ responseStart: this.#convertTimestamp(responseStartTime, timeOrigin),
+ responseEnd: this.#convertTimestamp(responseEndTime, timeOrigin),
+ };
+ }
+
+ /**
+ * Update the timings and the redirect count from the nsITimedChannel
+ * corresponding to the current channel. This should be called before emitting
+ * any event from this class.
+ */
+ #updateDataFromTimedChannel() {
+ const timedChannel = this.#requestChannel.QueryInterface(
+ Ci.nsITimedChannel
+ );
+ this.#redirectCount = timedChannel.redirectCount;
+ this.#requestData.timings = this.#getTimingsFromTimedChannel(timedChannel);
+ }
+}
diff --git a/remote/shared/listeners/NetworkListener.sys.mjs b/remote/shared/listeners/NetworkListener.sys.mjs
new file mode 100644
index 0000000000..500d2005dc
--- /dev/null
+++ b/remote/shared/listeners/NetworkListener.sys.mjs
@@ -0,0 +1,109 @@
+/* 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, {
+ EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs",
+ NetworkObserver:
+ "resource://devtools/shared/network-observer/NetworkObserver.sys.mjs",
+
+ NetworkEventRecord:
+ "chrome://remote/content/shared/listeners/NetworkEventRecord.sys.mjs",
+});
+
+/**
+ * The NetworkListener listens to all network activity from the parent
+ * process.
+ *
+ * Example:
+ * ```
+ * const listener = new NetworkListener();
+ * listener.on("before-request-sent", onBeforeRequestSent);
+ * listener.startListening();
+ *
+ * const onBeforeRequestSent = (eventName, data = {}) => {
+ * const { cntextId, redirectCount, requestData, requestId, timestamp } = data;
+ * ...
+ * };
+ * ```
+ *
+ * @fires before-request-sent
+ * The NetworkListener emits "before-request-sent" events, with the
+ * following object as payload:
+ * - {number} browsingContextId - The browsing context id of the browsing
+ * context where this request was performed.
+ * - {number} redirectCount - The request's redirect count.
+ * - {RequestData} requestData - The request's data as expected by
+ * WebDriver BiDi.
+ * - {string} requestId - The id of the request, consistent across
+ * redirects.
+ * - {number} timestamp - Timestamp when the event was generated.
+ */
+export class NetworkListener {
+ #devtoolsNetworkObserver;
+ #listening;
+
+ constructor() {
+ lazy.EventEmitter.decorate(this);
+
+ this.#listening = false;
+ }
+
+ destroy() {
+ this.stopListening();
+ }
+
+ startListening() {
+ if (this.#listening) {
+ return;
+ }
+
+ this.#devtoolsNetworkObserver = new lazy.NetworkObserver({
+ ignoreChannelFunction: this.#ignoreChannelFunction,
+ onNetworkEvent: this.#onNetworkEvent,
+ });
+
+ // Enable the auth prompt listening to support the auth-required event and
+ // phase.
+ this.#devtoolsNetworkObserver.setAuthPromptListenerEnabled(true);
+
+ this.#listening = true;
+ }
+
+ stopListening() {
+ if (!this.#listening) {
+ return;
+ }
+
+ this.#devtoolsNetworkObserver.destroy();
+ this.#devtoolsNetworkObserver = null;
+
+ this.#listening = false;
+ }
+
+ #ignoreChannelFunction = channel => {
+ // Bug 1826210: Ignore file channels which don't support the same APIs as
+ // regular HTTP channels.
+ if (channel instanceof Ci.nsIFileChannel) {
+ return true;
+ }
+
+ // Ignore chrome-privileged or DevTools-initiated requests
+ if (
+ channel.loadInfo?.loadingDocument === null &&
+ (channel.loadInfo.loadingPrincipal ===
+ Services.scriptSecurityManager.getSystemPrincipal() ||
+ channel.loadInfo.isInDevToolsContext)
+ ) {
+ return true;
+ }
+
+ return false;
+ };
+
+ #onNetworkEvent = (networkEvent, channel) => {
+ return new lazy.NetworkEventRecord(networkEvent, channel, this);
+ };
+}
diff --git a/remote/shared/listeners/PromptListener.sys.mjs b/remote/shared/listeners/PromptListener.sys.mjs
new file mode 100644
index 0000000000..e04c766970
--- /dev/null
+++ b/remote/shared/listeners/PromptListener.sys.mjs
@@ -0,0 +1,285 @@
+/* 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, {
+ AppInfo: "chrome://remote/content/shared/AppInfo.sys.mjs",
+ EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs",
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+ modal: "chrome://remote/content/shared/Prompt.sys.mjs",
+ TabManager: "chrome://remote/content/shared/TabManager.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
+
+/**
+ * The PromptListener listens to the DialogObserver events.
+ *
+ * Example:
+ * ```
+ * const listener = new PromptListener();
+ * listener.on("opened", onPromptOpened);
+ * listener.startListening();
+ *
+ * const onPromptOpened = (eventName, data = {}) => {
+ * const { contentBrowser, prompt } = data;
+ * ...
+ * };
+ * ```
+ *
+ * @fires message
+ * The PromptListener emits "opened" events,
+ * with the following object as payload:
+ * - {XULBrowser} contentBrowser
+ * The <xul:browser> which hold the <var>prompt</var>.
+ * - {modal.Dialog} prompt
+ * Returns instance of the Dialog class.
+ *
+ * The PromptListener emits "closed" events,
+ * with the following object as payload:
+ * - {XULBrowser} contentBrowser
+ * The <xul:browser> which is the target of the event.
+ * - {object} detail
+ * {boolean=} detail.accepted
+ * Returns true if a user prompt was accepted
+ * and false if it was dismissed.
+ * {string=} detail.userText
+ * The user text specified in a prompt.
+ */
+export class PromptListener {
+ #curBrowserFn;
+ #listening;
+
+ constructor(curBrowserFn) {
+ lazy.EventEmitter.decorate(this);
+
+ // curBrowserFn is used only for Marionette (WebDriver classic).
+ this.#curBrowserFn = curBrowserFn;
+ this.#listening = false;
+ }
+
+ destroy() {
+ this.stopListening();
+ }
+
+ /**
+ * Waits for the prompt to be closed.
+ *
+ * @returns {Promise}
+ * Promise that resolves when the prompt is closed.
+ */
+ async dialogClosed() {
+ return new Promise(resolve => {
+ const dialogClosed = () => {
+ this.off("closed", dialogClosed);
+ resolve();
+ };
+
+ this.on("closed", dialogClosed);
+ });
+ }
+
+ /**
+ * Handles `DOMModalDialogClosed` events.
+ */
+ handleEvent(event) {
+ lazy.logger.trace(`Received event ${event.type}`);
+
+ const chromeWin = event.target.opener
+ ? event.target.opener.ownerGlobal
+ : event.target.ownerGlobal;
+ const curBrowser = this.#curBrowserFn && this.#curBrowserFn();
+
+ // For Marionette (WebDriver classic) we only care about events which come
+ // the currently selected browser.
+ if (curBrowser && chromeWin != curBrowser.window) {
+ return;
+ }
+
+ let contentBrowser;
+ if (lazy.AppInfo.isAndroid) {
+ const tabBrowser = lazy.TabManager.getTabBrowser(event.target);
+ // Since on Android we always have only one tab we can just check
+ // the selected tab.
+ const tab = tabBrowser.selectedTab;
+ contentBrowser = lazy.TabManager.getBrowserForTab(tab);
+ } else {
+ contentBrowser = event.target;
+ }
+
+ const detail = {};
+
+ // At the moment the event details are present for GeckoView and on desktop
+ // only for Services.prompt.MODAL_TYPE_CONTENT prompts.
+ if (event.detail) {
+ const { areLeaving, value } = event.detail;
+ // `areLeaving` returns undefined for alerts, for confirms and prompts
+ // it returns true if a user prompt was accepted and false if it was dismissed.
+ detail.accepted = areLeaving === undefined ? true : areLeaving;
+ if (value) {
+ detail.userText = value;
+ }
+ }
+
+ this.emit("closed", {
+ contentBrowser,
+ detail,
+ });
+ }
+
+ /**
+ * Observes the following notifications:
+ * `common-dialog-loaded` - when a modal dialog loaded on desktop,
+ * `domwindowopened` - when a new chrome window opened,
+ * `geckoview-prompt-show` - when a modal dialog opened on Android.
+ */
+ observe(subject, topic) {
+ lazy.logger.trace(`Received observer notification ${topic}`);
+
+ let curBrowser = this.#curBrowserFn && this.#curBrowserFn();
+ switch (topic) {
+ case "common-dialog-loaded":
+ if (curBrowser) {
+ if (
+ !this.#hasCommonDialog(
+ curBrowser.contentBrowser,
+ curBrowser.window,
+ subject
+ )
+ ) {
+ return;
+ }
+ } else {
+ const chromeWin = subject.opener
+ ? subject.opener.ownerGlobal
+ : subject.ownerGlobal;
+
+ for (const tab of lazy.TabManager.getTabsForWindow(chromeWin)) {
+ const contentBrowser = lazy.TabManager.getBrowserForTab(tab);
+ const window = lazy.TabManager.getWindowForTab(tab);
+
+ if (this.#hasCommonDialog(contentBrowser, window, subject)) {
+ curBrowser = {
+ contentBrowser,
+ window,
+ };
+
+ break;
+ }
+ }
+ }
+ this.emit("opened", {
+ contentBrowser: curBrowser.contentBrowser,
+ prompt: new lazy.modal.Dialog(() => curBrowser, subject),
+ });
+
+ break;
+
+ case "domwindowopened":
+ subject.addEventListener("DOMModalDialogClosed", this);
+ break;
+
+ case "geckoview-prompt-show":
+ for (let win of Services.wm.getEnumerator(null)) {
+ const prompt = win.prompts().find(item => item.id == subject.id);
+ if (prompt) {
+ const tabBrowser = lazy.TabManager.getTabBrowser(win);
+ // Since on Android we always have only one tab we can just check
+ // the selected tab.
+ const tab = tabBrowser.selectedTab;
+ const contentBrowser = lazy.TabManager.getBrowserForTab(tab);
+ const window = lazy.TabManager.getWindowForTab(tab);
+
+ // Do not send the event if the curBrowser is specified,
+ // and it's different from prompt browser.
+ if (curBrowser && contentBrowser !== curBrowser.contentBrowser) {
+ continue;
+ }
+
+ this.emit("opened", {
+ contentBrowser,
+ prompt: new lazy.modal.Dialog(
+ () => ({
+ contentBrowser,
+ window,
+ }),
+ prompt
+ ),
+ });
+ return;
+ }
+ }
+ break;
+ }
+ }
+
+ startListening() {
+ if (this.#listening) {
+ return;
+ }
+
+ this.#register();
+ this.#listening = true;
+ }
+
+ stopListening() {
+ if (!this.#listening) {
+ return;
+ }
+
+ this.#unregister();
+ this.#listening = false;
+ }
+
+ #hasCommonDialog(contentBrowser, window, prompt) {
+ const modalType = prompt.Dialog.args.modalType;
+ if (
+ modalType === Services.prompt.MODAL_TYPE_TAB ||
+ modalType === Services.prompt.MODAL_TYPE_CONTENT
+ ) {
+ // Find the container of the dialog in the parent document, and ensure
+ // it is a descendant of the same container as the content browser.
+ const container = contentBrowser.closest(".browserSidebarContainer");
+
+ return container.contains(prompt.docShell.chromeEventHandler);
+ }
+
+ return prompt.ownerGlobal == window || prompt.opener?.ownerGlobal == window;
+ }
+
+ #register() {
+ Services.obs.addObserver(this, "common-dialog-loaded");
+ Services.obs.addObserver(this, "domwindowopened");
+ Services.obs.addObserver(this, "geckoview-prompt-show");
+
+ // Register event listener and save already open prompts for all already open windows.
+ for (const win of Services.wm.getEnumerator(null)) {
+ win.addEventListener("DOMModalDialogClosed", this);
+ }
+ }
+
+ #unregister() {
+ const removeObserver = observerName => {
+ try {
+ Services.obs.removeObserver(this, observerName);
+ } catch (e) {
+ lazy.logger.debug(`Failed to remove observer "${observerName}"`);
+ }
+ };
+
+ for (const observerName of [
+ "common-dialog-loaded",
+ "domwindowopened",
+ "geckoview-prompt-show",
+ ]) {
+ removeObserver(observerName);
+ }
+
+ // Unregister event listener for all open windows
+ for (const win of Services.wm.getEnumerator(null)) {
+ win.removeEventListener("DOMModalDialogClosed", this);
+ }
+ }
+}
diff --git a/remote/shared/listeners/test/browser/browser.toml b/remote/shared/listeners/test/browser/browser.toml
new file mode 100644
index 0000000000..d462bf1e82
--- /dev/null
+++ b/remote/shared/listeners/test/browser/browser.toml
@@ -0,0 +1,21 @@
+[DEFAULT]
+tags = "remote"
+subsuite = "remote"
+support-files = ["head.js"]
+prefs = ["remote.messagehandler.modulecache.useBrowserTestRoot=true"]
+
+["browser_BrowsingContextListener.js"]
+
+["browser_ConsoleAPIListener.js"]
+
+["browser_ConsoleAPIListener_cached_messages.js"]
+
+["browser_ConsoleListener.js"]
+
+["browser_ConsoleListener_cached_messages.js"]
+
+["browser_ContextualIdentityListener.js"]
+
+["browser_NetworkListener.js"]
+
+["browser_PromptListener.js"]
diff --git a/remote/shared/listeners/test/browser/browser_BrowsingContextListener.js b/remote/shared/listeners/test/browser/browser_BrowsingContextListener.js
new file mode 100644
index 0000000000..9a08df7857
--- /dev/null
+++ b/remote/shared/listeners/test/browser/browser_BrowsingContextListener.js
@@ -0,0 +1,117 @@
+/* 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 { BrowsingContextListener } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/listeners/BrowsingContextListener.sys.mjs"
+);
+
+add_task(async function test_attachedOnNewTab() {
+ const listener = new BrowsingContextListener();
+ const attached = listener.once("attached");
+
+ listener.startListening();
+
+ const tab = BrowserTestUtils.addTab(gBrowser, "about:blank");
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+
+ const { browsingContext, why } = await attached;
+
+ is(
+ browsingContext.id,
+ tab.linkedBrowser.browsingContext.id,
+ "Received expected browsing context"
+ );
+ is(why, "attach", "Browsing context has been attached");
+
+ listener.stopListening();
+ gBrowser.removeTab(tab);
+});
+
+add_task(async function test_attachedValidEmbedderElement() {
+ const listener = new BrowsingContextListener();
+
+ let hasEmbedderElement = false;
+ listener.on(
+ "attached",
+ (evtName, { browsingContext }) => {
+ hasEmbedderElement = !!browsingContext.embedderElement;
+ },
+ { once: true }
+ );
+
+ listener.startListening();
+
+ const tab = BrowserTestUtils.addTab(gBrowser, "about:blank");
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+
+ ok(
+ hasEmbedderElement,
+ "Attached browsing context has a valid embedder element"
+ );
+
+ listener.stopListening();
+ gBrowser.removeTab(tab);
+});
+
+add_task(async function test_discardedOnCloseTab() {
+ const listener = new BrowsingContextListener();
+ const discarded = listener.once("discarded");
+
+ const tab = BrowserTestUtils.addTab(gBrowser, "about:blank");
+ const browsingContext = tab.linkedBrowser.browsingContext;
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+
+ listener.startListening();
+ gBrowser.removeTab(tab);
+ const { browsingContext: discardedBrowsingContext, why } = await discarded;
+
+ is(
+ discardedBrowsingContext.id,
+ browsingContext.id,
+ "Received expected browsing context"
+ );
+ is(why, "discard", "Browsing context has been discarded");
+
+ listener.stopListening();
+});
+
+add_task(async function test_replaceTopLevelOnNavigation() {
+ const listener = new BrowsingContextListener();
+ const attached = listener.once("attached");
+ const discarded = listener.once("discarded");
+
+ const tab = BrowserTestUtils.addTab(gBrowser, "about:blank");
+ const browsingContext = tab.linkedBrowser.browsingContext;
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+
+ listener.startListening();
+
+ await loadURL(tab.linkedBrowser, "about:mozilla");
+
+ const discardEvent = await discarded;
+ const attachEvent = await attached;
+
+ is(
+ discardEvent.browsingContext.id,
+ browsingContext.id,
+ "Received expected browsing context for discarded"
+ );
+ is(discardEvent.why, "replace", "Browsing context has been replaced");
+
+ is(
+ attachEvent.browsingContext.id,
+ tab.linkedBrowser.browsingContext.id,
+ "Received expected browsing context for attached"
+ );
+ is(discardEvent.why, "replace", "Browsing context has been replaced");
+
+ isnot(
+ discardEvent.browsingContext,
+ attachEvent.browsingContext,
+ "Got different browsing contexts"
+ );
+
+ listener.stopListening();
+ gBrowser.removeTab(gBrowser.selectedTab);
+});
diff --git a/remote/shared/listeners/test/browser/browser_ConsoleAPIListener.js b/remote/shared/listeners/test/browser/browser_ConsoleAPIListener.js
new file mode 100644
index 0000000000..ccff78c7a0
--- /dev/null
+++ b/remote/shared/listeners/test/browser/browser_ConsoleAPIListener.js
@@ -0,0 +1,162 @@
+/* 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 TESTS = [
+ { method: "log", args: ["log1"] },
+ { method: "log", args: ["log2", "log3"] },
+ { method: "log", args: [[1, 2, 3], { someProperty: "someValue" }] },
+ { method: "warn", args: ["warn1"] },
+ { method: "error", args: ["error1"] },
+ { method: "info", args: ["info1"] },
+ { method: "debug", args: ["debug1"] },
+ { method: "trace", args: ["trace1"] },
+ { method: "assert", args: [false, "assert1"] },
+];
+
+const TEST_PAGE = "https://example.com/document-builder.sjs?html=tab";
+
+add_task(async function test_method_and_arguments() {
+ for (const { method, args } of TESTS) {
+ // Use a dedicated tab for each test to avoid cached messages.
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, TEST_PAGE);
+ await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+
+ info(`Test ConsoleApiListener for ${JSON.stringify({ method, args })}`);
+
+ const listenerId = await listenToConsoleAPIMessage();
+ await useConsoleInContent(method, args);
+ const {
+ arguments: msgArguments,
+ level,
+ timeStamp,
+ stacktrace,
+ } = await getConsoleAPIMessage(listenerId);
+
+ if (method == "assert") {
+ // console.assert() consumes first argument.
+ args.shift();
+ }
+
+ is(
+ msgArguments.length,
+ args.length,
+ "Message event has the expected number of arguments"
+ );
+ for (let i = 0; i < args.length; i++) {
+ Assert.deepEqual(
+ msgArguments[i],
+ args[i],
+ `Message event has the expected argument at index ${i}`
+ );
+ }
+ is(level, method, "Message event has the expected level");
+ ok(Number.isInteger(timeStamp), "Message event has a valid timestamp");
+
+ if (["assert", "error", "warn", "trace"].includes(method)) {
+ // Check stacktrace if method is allowed to contain one.
+ if (method === "warn") {
+ todo(
+ Array.isArray(stacktrace),
+ "stacktrace is of expected type Array (Bug 1744705)"
+ );
+ } else {
+ ok(Array.isArray(stacktrace), "stacktrace is of expected type Array");
+ Assert.greaterOrEqual(
+ stacktrace.length,
+ 1,
+ "stack trace contains at least one frame"
+ );
+ }
+ } else {
+ is(typeof stacktrace, "undefined", "stack trace is is not present");
+ }
+
+ gBrowser.removeTab(gBrowser.selectedTab);
+ }
+});
+
+add_task(async function test_stacktrace() {
+ const script = `
+ function foo() { console.error("cheese"); }
+ function bar() { foo(); }
+ bar();
+ `;
+
+ const listenerId = await listenToConsoleAPIMessage();
+ await createScriptNode(script);
+ const { stacktrace } = await getConsoleAPIMessage(listenerId);
+
+ ok(Array.isArray(stacktrace), "stacktrace is of expected type Array");
+
+ // First 3 frames are from the test script.
+ Assert.greaterOrEqual(
+ stacktrace.length,
+ 3,
+ "stack trace contains at least 3 frames"
+ );
+ checkStackFrame(stacktrace[0], "about:blank", "foo", 2, 30);
+ checkStackFrame(stacktrace[1], "about:blank", "bar", 3, 22);
+ checkStackFrame(stacktrace[2], "about:blank", "", 4, 5);
+});
+
+function useConsoleInContent(method, args) {
+ info(`Call console API: console.${method}("${args.join('", "')}");`);
+ return SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [method, args],
+ (_method, _args) => {
+ content.console[_method].apply(content.console, _args);
+ }
+ );
+}
+
+function listenToConsoleAPIMessage() {
+ info("Listen to a console api message in content");
+ return SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => {
+ const innerWindowId = content.windowGlobalChild.innerWindowId;
+ const { ConsoleAPIListener } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/listeners/ConsoleAPIListener.sys.mjs"
+ );
+ const consoleAPIListener = new ConsoleAPIListener(innerWindowId);
+ const onMessage = consoleAPIListener.once("message");
+ consoleAPIListener.startListening();
+
+ const listenerId = Math.random();
+ content[listenerId] = { consoleAPIListener, onMessage };
+ return listenerId;
+ });
+}
+
+function getConsoleAPIMessage(listenerId) {
+ info("Retrieve the message event captured for listener: " + listenerId);
+ return SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [listenerId],
+ async _listenerId => {
+ const { consoleAPIListener, onMessage } = content[_listenerId];
+ const message = await onMessage;
+
+ consoleAPIListener.destroy();
+
+ return message;
+ }
+ );
+}
+
+function checkStackFrame(
+ frame,
+ filename,
+ functionName,
+ lineNumber,
+ columnNumber
+) {
+ is(frame.filename, filename, "Received expected filename for frame");
+ is(
+ frame.functionName,
+ functionName,
+ "Received expected function name for frame"
+ );
+ is(frame.lineNumber, lineNumber, "Received expected line for frame");
+ is(frame.columnNumber, columnNumber, "Received expected column for frame");
+}
diff --git a/remote/shared/listeners/test/browser/browser_ConsoleAPIListener_cached_messages.js b/remote/shared/listeners/test/browser/browser_ConsoleAPIListener_cached_messages.js
new file mode 100644
index 0000000000..dae35a0b9a
--- /dev/null
+++ b/remote/shared/listeners/test/browser/browser_ConsoleAPIListener_cached_messages.js
@@ -0,0 +1,100 @@
+/* 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 TEST_PAGE = "https://example.com/document-builder.sjs?html=tab";
+
+add_task(async function test_cached_messages() {
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, TEST_PAGE);
+ await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => {
+ const innerWindowId = content.windowGlobalChild.innerWindowId;
+ const { ConsoleAPIListener } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/listeners/ConsoleAPIListener.sys.mjs"
+ );
+
+ info("Log two messages before starting the ConsoleAPIListener");
+ content.console.log("message_1");
+ content.console.log("message_2");
+
+ const listener = new ConsoleAPIListener(innerWindowId);
+ const messages = [];
+
+ // We will keep the onMessage callback attached to the ConsoleAPIListener
+ // during the whole test to catch all the emitted events.
+ const onMessage = (evtName, message) => messages.push(message.arguments[0]);
+
+ listener.on("message", onMessage);
+ listener.startListening();
+
+ info("Wait until the 2 cached messages have been emitted");
+ await ContentTaskUtils.waitForCondition(() => messages.length == 2);
+ is(messages[0], "message_1");
+ is(messages[1], "message_2");
+
+ info("Stop listening and log another message");
+ listener.stopListening();
+ content.backup = { listener, messages, onMessage };
+ });
+
+ // Force a GC to check that old cached messages which have been garbage
+ // collected are not re-displayed.
+ await doGC();
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => {
+ const { listener, messages, onMessage } = content.backup;
+ content.console.log("message_3");
+
+ info("Start listening again and check the previous message is emitted");
+ listener.startListening();
+ await ContentTaskUtils.waitForCondition(() => messages.length == 3);
+ is(messages[2], "message_3");
+
+ info("Log another message and wait until it is emitted");
+ content.console.log("message_4");
+ await ContentTaskUtils.waitForCondition(() => messages.length == 4);
+ is(messages[3], "message_4");
+
+ listener.off("message", onMessage);
+ listener.destroy();
+
+ is(messages.length, 4, "Received 4 messages in total");
+ });
+
+ info("Reload the current tab and check only new messages are emitted");
+ await BrowserTestUtils.reloadTab(gBrowser.selectedTab);
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => {
+ const innerWindowId = content.windowGlobalChild.innerWindowId;
+ const { ConsoleAPIListener } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/listeners/ConsoleAPIListener.sys.mjs"
+ );
+
+ info("Log a message before creating the ConsoleAPIListener");
+ content.console.log("new_message_1");
+
+ const listener = new ConsoleAPIListener(innerWindowId);
+ const newMessages = [];
+ const onMessage = (evtName, message) =>
+ newMessages.push(message.arguments[0]);
+ listener.on("message", onMessage);
+
+ info("Start listening and wait for the cached message");
+ listener.startListening();
+ await ContentTaskUtils.waitForCondition(() => newMessages.length == 1);
+ is(newMessages[0], "new_message_1");
+
+ info("Log another message and wait until it is emitted");
+ content.console.log("new_message_2");
+ await ContentTaskUtils.waitForCondition(() => newMessages.length == 2);
+ is(newMessages[1], "new_message_2");
+
+ listener.off("message", onMessage);
+ listener.destroy();
+
+ is(newMessages.length, 2, "Received 2 messages in total");
+ });
+
+ gBrowser.removeTab(gBrowser.selectedTab);
+});
diff --git a/remote/shared/listeners/test/browser/browser_ConsoleListener.js b/remote/shared/listeners/test/browser/browser_ConsoleListener.js
new file mode 100644
index 0000000000..41936a6c0d
--- /dev/null
+++ b/remote/shared/listeners/test/browser/browser_ConsoleListener.js
@@ -0,0 +1,148 @@
+/* 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/. */
+
+add_task(async function test_message_properties() {
+ const listenerId = await listenToConsoleMessage("error");
+ await logConsoleMessage({ message: "foo" });
+ const { level, message, timeStamp, stack } = await getConsoleMessage(
+ listenerId
+ );
+
+ is(level, "error", "Received expected log level");
+ is(message, "foo", "Received expected log message");
+ // Services.console.logMessage() doesn't include a stack.
+ is(stack, undefined, "No stack present");
+ is(typeof timeStamp, "number", "timestamp is of expected type number");
+
+ // Clear the console to avoid side effects with other tests in this file.
+ await clearConsole();
+});
+
+add_task(async function test_level() {
+ for (const level of ["error", "info", "warn"]) {
+ const listenerId = await listenToConsoleMessage(level);
+ await logConsoleMessage({ message: "foo", level });
+ const message = await getConsoleMessage(listenerId);
+
+ is(message.level, level, "Received expected log level");
+ }
+
+ // Clear the console to avoid side effects with other tests in this file.
+ await clearConsole();
+});
+
+add_task(async function test_stacktrace() {
+ const script = `
+ function foo() { throw new Error("cheese"); }
+ function bar() { foo(); }
+ bar();
+ `;
+
+ const listenerId = await listenToConsoleMessage("error");
+ await createScriptNode(script);
+ const { message, level, stacktrace } = await getConsoleMessage(listenerId);
+ is(level, "error", "Received expected log level");
+ is(message, "Error: cheese", "Received expected log message");
+ ok(Array.isArray(stacktrace), "frames is of expected type Array");
+ Assert.greaterOrEqual(stacktrace.length, 4, "Got at least 4 stack frames");
+
+ // First 3 stack frames are from the injected script and one more frame comes
+ // from head.js (chrome scope) where we inject the script.
+ checkStackFrame(stacktrace[0], "about:blank", "foo", 2, 28);
+ checkStackFrame(stacktrace[1], "about:blank", "bar", 3, 22);
+ checkStackFrame(stacktrace[2], "about:blank", "", 4, 5);
+ checkStackFrame(
+ stacktrace[3],
+ "chrome://mochitests/content/browser/remote/shared/listeners/test/browser/head.js",
+ "",
+ 34,
+ 29
+ );
+
+ // Clear the console to avoid side effects with other tests in this file.
+ await clearConsole();
+});
+
+function logConsoleMessage(options = {}) {
+ info(`Log console message ${options.message}`);
+ return SpecialPowers.spawn(gBrowser.selectedBrowser, [options], _options => {
+ const { level = "error" } = _options;
+
+ const levelToFlags = {
+ error: Ci.nsIScriptError.errorFlag,
+ info: Ci.nsIScriptError.infoFlag,
+ warn: Ci.nsIScriptError.warningFlag,
+ };
+
+ const scriptError = Cc["@mozilla.org/scripterror;1"].createInstance(
+ Ci.nsIScriptError
+ );
+ scriptError.initWithWindowID(
+ _options.message,
+ _options.sourceName || "sourceName",
+ null,
+ _options.lineNumber || 0,
+ _options.columnNumber || 0,
+ levelToFlags[level],
+ _options.category || "javascript",
+ content.windowGlobalChild.innerWindowId
+ );
+
+ Services.console.logMessage(scriptError);
+ });
+}
+
+function listenToConsoleMessage(level) {
+ info("Listen to a console message in content");
+ return SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [level],
+ async _level => {
+ const innerWindowId = content.windowGlobalChild.innerWindowId;
+ const { ConsoleListener } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/listeners/ConsoleListener.sys.mjs"
+ );
+ const consoleListener = new ConsoleListener(innerWindowId);
+ const onMessage = consoleListener.once(_level);
+ consoleListener.startListening();
+
+ const listenerId = Math.random();
+ content[listenerId] = { consoleListener, onMessage };
+ return listenerId;
+ }
+ );
+}
+
+function getConsoleMessage(listenerId) {
+ info("Retrieve the message event captured for listener: " + listenerId);
+ return SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [listenerId],
+ async _listenerId => {
+ const { consoleListener, onMessage } = content[_listenerId];
+ const message = await onMessage;
+
+ consoleListener.destroy();
+
+ return message;
+ }
+ );
+}
+
+function checkStackFrame(
+ frame,
+ filename,
+ functionName,
+ lineNumber,
+ columnNumber
+) {
+ is(frame.filename, filename, "Received expected filename for frame");
+ is(
+ frame.functionName,
+ functionName,
+ "Received expected function name for frame"
+ );
+ is(frame.lineNumber, lineNumber, "Received expected line for frame");
+ is(frame.columnNumber, columnNumber, "Received expected column for frame");
+}
diff --git a/remote/shared/listeners/test/browser/browser_ConsoleListener_cached_messages.js b/remote/shared/listeners/test/browser/browser_ConsoleListener_cached_messages.js
new file mode 100644
index 0000000000..1020aee661
--- /dev/null
+++ b/remote/shared/listeners/test/browser/browser_ConsoleListener_cached_messages.js
@@ -0,0 +1,82 @@
+/* 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 TEST_PAGE =
+ "https://example.com/document-builder.sjs?html=<meta charset=utf8></meta>";
+
+add_task(async function test_cached_javascript_errors() {
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, TEST_PAGE);
+ await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+
+ await createScriptNode(`(() => {throw "error1"})()`);
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => {
+ const innerWindowId = content.windowGlobalChild.innerWindowId;
+ const { ConsoleListener } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/listeners/ConsoleListener.sys.mjs"
+ );
+
+ const listener = new ConsoleListener(innerWindowId);
+
+ const errors = [];
+ // Do not push the whole error object in the array. It would create a strong
+ // reference preventing from reproducing GC-related bugs.
+ const onError = (evtName, error) => errors.push(error.message);
+ listener.on("error", onError);
+
+ const waitForMessage = listener.once("error");
+ listener.startListening();
+ const error = await waitForMessage;
+ is(error.message, "uncaught exception: error1");
+ is(errors.length, 1);
+
+ listener.stopListening();
+ content.backup = { listener, errors, onError };
+ });
+
+ // Force a GC to check that old cached messages which have been garbage
+ // collected are not re-displayed.
+ await doGC();
+ await createScriptNode(`(() => {throw "error2"})()`);
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => {
+ const { listener, errors, onError } = content.backup;
+
+ const waitForMessage = listener.once("error");
+ listener.startListening();
+ const { message } = await waitForMessage;
+ is(message, "uncaught exception: error2");
+ is(errors.length, 2);
+
+ listener.off("error", onError);
+ listener.destroy();
+ });
+
+ info("Reload the current tab and check only new messages are emitted");
+ await BrowserTestUtils.reloadTab(gBrowser.selectedTab);
+
+ await createScriptNode(`(() => {throw "error3"})()`);
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => {
+ const innerWindowId = content.windowGlobalChild.innerWindowId;
+ const { ConsoleListener } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/listeners/ConsoleListener.sys.mjs"
+ );
+
+ const listener = new ConsoleListener(innerWindowId);
+
+ const errors = [];
+ const onError = (evtName, error) => errors.push(error.message);
+ listener.on("error", onError);
+
+ const waitForMessage = listener.once("error");
+ listener.startListening();
+ const error = await waitForMessage;
+ is(error.message, "uncaught exception: error3");
+ is(errors.length, 1);
+
+ listener.off("error", onError);
+ listener.destroy();
+ });
+
+ gBrowser.removeTab(gBrowser.selectedTab);
+});
diff --git a/remote/shared/listeners/test/browser/browser_ContextualIdentityListener.js b/remote/shared/listeners/test/browser/browser_ContextualIdentityListener.js
new file mode 100644
index 0000000000..df783a5688
--- /dev/null
+++ b/remote/shared/listeners/test/browser/browser_ContextualIdentityListener.js
@@ -0,0 +1,38 @@
+/* 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 { ContextualIdentityListener } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/listeners/ContextualIdentityListener.sys.mjs"
+);
+
+add_task(async function test_createdOnNewContextualIdentity() {
+ const listener = new ContextualIdentityListener();
+ const created = listener.once("created");
+
+ listener.startListening();
+
+ ContextualIdentityService.create("test_name");
+
+ const { identity } = await created;
+ is(identity.name, "test_name", "Received expected identity");
+
+ listener.stopListening();
+
+ ContextualIdentityService.remove(identity.userContextId);
+});
+
+add_task(async function test_deletedOnRemovedContextualIdentity() {
+ const listener = new ContextualIdentityListener();
+ const deleted = listener.once("deleted");
+
+ listener.startListening();
+
+ const testIdentity = ContextualIdentityService.create("test_name");
+ ContextualIdentityService.remove(testIdentity.userContextId);
+
+ const { identity } = await deleted;
+ is(identity.name, "test_name", "Received expected identity");
+
+ listener.stopListening();
+});
diff --git a/remote/shared/listeners/test/browser/browser_NetworkListener.js b/remote/shared/listeners/test/browser/browser_NetworkListener.js
new file mode 100644
index 0000000000..78865f6b80
--- /dev/null
+++ b/remote/shared/listeners/test/browser/browser_NetworkListener.js
@@ -0,0 +1,100 @@
+/* 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 { NetworkListener } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/listeners/NetworkListener.sys.mjs"
+);
+const { TabManager } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/TabManager.sys.mjs"
+);
+
+add_task(async function test_beforeRequestSent() {
+ const listener = new NetworkListener();
+ const events = [];
+ const onEvent = (name, data) => events.push(data);
+ listener.on("before-request-sent", onEvent);
+
+ const tab1 = BrowserTestUtils.addTab(
+ gBrowser,
+ "https://example.com/document-builder.sjs?html=tab"
+ );
+ await BrowserTestUtils.browserLoaded(tab1.linkedBrowser);
+ const contextId1 = TabManager.getIdForBrowser(tab1.linkedBrowser);
+
+ const tab2 = BrowserTestUtils.addTab(
+ gBrowser,
+ "https://example.com/document-builder.sjs?html=tab2"
+ );
+ await BrowserTestUtils.browserLoaded(tab2.linkedBrowser);
+ const contextId2 = TabManager.getIdForBrowser(tab2.linkedBrowser);
+
+ listener.startListening();
+
+ await fetch(tab1.linkedBrowser, "https://example.com/?1");
+ ok(events.length == 1, "One event was received");
+ assertNetworkEvent(events[0], contextId1, "https://example.com/?1");
+
+ info("Check that events are no longer emitted after calling stopListening");
+ listener.stopListening();
+ await fetch(tab1.linkedBrowser, "https://example.com/?2");
+ ok(events.length == 1, "No new event was received");
+
+ listener.startListening();
+ await fetch(tab1.linkedBrowser, "https://example.com/?3");
+ ok(events.length == 2, "A new event was received");
+ assertNetworkEvent(events[1], contextId1, "https://example.com/?3");
+
+ info("Check network event from the new tab");
+ await fetch(tab2.linkedBrowser, "https://example.com/?4");
+ ok(events.length == 3, "A new event was received");
+ assertNetworkEvent(events[2], contextId2, "https://example.com/?4");
+
+ gBrowser.removeTab(tab1);
+ gBrowser.removeTab(tab2);
+ listener.off("before-request-sent", onEvent);
+ listener.destroy();
+});
+
+add_task(async function test_beforeRequestSent_newTab() {
+ const listener = new NetworkListener();
+ const onBeforeRequestSent = listener.once("before-request-sent");
+ listener.startListening();
+
+ info("Check network event related to loading a new tab");
+ const tab = BrowserTestUtils.addTab(
+ gBrowser,
+ "https://example.com/document-builder.sjs?html=tab"
+ );
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ const contextId = TabManager.getIdForBrowser(tab.linkedBrowser);
+ const event = await onBeforeRequestSent;
+
+ assertNetworkEvent(
+ event,
+ contextId,
+ "https://example.com/document-builder.sjs?html=tab"
+ );
+ gBrowser.removeTab(tab);
+});
+
+add_task(async function test_fetchError() {
+ const listener = new NetworkListener();
+ const onFetchError = listener.once("fetch-error");
+ listener.startListening();
+
+ info("Check fetchError event when loading a new tab");
+ const tab = BrowserTestUtils.addTab(gBrowser, "https://not_a_valid_url/");
+ BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ const contextId = TabManager.getIdForBrowser(tab.linkedBrowser);
+ const event = await onFetchError;
+
+ assertNetworkEvent(event, contextId, "https://not_a_valid_url/");
+ is(event.errorText, "NS_ERROR_UNKNOWN_HOST");
+ gBrowser.removeTab(tab);
+});
+
+function assertNetworkEvent(event, expectedContextId, expectedUrl) {
+ is(event.contextId, expectedContextId, "Event has the expected context id");
+ is(event.requestData.url, expectedUrl, "Event has the expected url");
+}
diff --git a/remote/shared/listeners/test/browser/browser_PromptListener.js b/remote/shared/listeners/test/browser/browser_PromptListener.js
new file mode 100644
index 0000000000..0d3f23db3f
--- /dev/null
+++ b/remote/shared/listeners/test/browser/browser_PromptListener.js
@@ -0,0 +1,173 @@
+/* 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 { PromptListener } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/listeners/PromptListener.sys.mjs"
+);
+
+add_task(async function test_without_curBrowser() {
+ const listener = new PromptListener();
+ const opened = listener.once("opened");
+ const closed = listener.once("closed");
+
+ listener.startListening();
+
+ const dialogPromise = BrowserTestUtils.promiseAlertDialogOpen();
+ await createScriptNode(`setTimeout(() => window.confirm('test'))`);
+ const dialogWin = await dialogPromise;
+
+ const openedEvent = await opened;
+
+ is(openedEvent.prompt.window, dialogWin, "Received expected prompt");
+
+ dialogWin.document.querySelector("dialog").acceptDialog();
+
+ const closedEvent = await closed;
+
+ is(closedEvent.detail.accepted, true, "Received correct event details");
+
+ listener.destroy();
+});
+
+add_task(async function test_with_curBrowser() {
+ const listener = new PromptListener(() => ({
+ contentBrowser: gBrowser.selectedBrowser,
+ window,
+ }));
+ const opened = listener.once("opened");
+ const closed = listener.once("closed");
+
+ listener.startListening();
+
+ const dialogPromise = BrowserTestUtils.promiseAlertDialogOpen();
+ await createScriptNode(`setTimeout(() => window.confirm('test'))`);
+ const dialogWin = await dialogPromise;
+
+ const openedEvent = await opened;
+
+ is(openedEvent.prompt.window, dialogWin, "Received expected prompt");
+
+ dialogWin.document.querySelector("dialog").acceptDialog();
+
+ const closedEvent = await closed;
+
+ is(closedEvent.detail.accepted, true, "Received correct event details");
+
+ listener.destroy();
+});
+
+add_task(async function test_close_event_details() {
+ const listener = new PromptListener();
+ let closed = listener.once("closed");
+
+ listener.startListening();
+
+ let dialogPromise = BrowserTestUtils.promiseAlertDialogOpen();
+ await createScriptNode(`setTimeout(() => window.prompt('Enter your name:'))`);
+ let dialogWin = await dialogPromise;
+
+ dialogWin.document.getElementById("loginTextbox").value = "Test";
+ dialogWin.document.querySelector("dialog").acceptDialog();
+
+ let closedEvent = await closed;
+
+ is(
+ closedEvent.detail.accepted,
+ true,
+ "Received correct `accepted` value in event details"
+ );
+ is(
+ closedEvent.detail.userText,
+ "Test",
+ "Received correct `userText` value in event details"
+ );
+
+ closed = listener.once("closed");
+
+ dialogPromise = BrowserTestUtils.promiseAlertDialogOpen();
+ await createScriptNode(`setTimeout(() => window.prompt('Enter your name:'))`);
+ dialogWin = await dialogPromise;
+
+ dialogWin.document.getElementById("loginTextbox").value = "Test";
+ dialogWin.document.querySelector("dialog").cancelDialog();
+
+ closedEvent = await closed;
+
+ is(
+ closedEvent.detail.accepted,
+ false,
+ "Received correct `accepted` value in event details"
+ );
+ is(
+ closedEvent.detail.userText,
+ undefined,
+ "Received correct `userText` value in event details"
+ );
+
+ listener.destroy();
+});
+
+add_task(async function test_dialogClosed() {
+ const listener = new PromptListener();
+
+ listener.startListening();
+
+ let dialogPromise = BrowserTestUtils.promiseAlertDialogOpen();
+ await createScriptNode(`setTimeout(() => window.alert('test'))`);
+ let dialogWin = await dialogPromise;
+ let closed = listener.dialogClosed();
+
+ dialogWin.document.querySelector("dialog").acceptDialog();
+
+ await closed;
+
+ is(true, true, "Close promise got resolved");
+
+ dialogPromise = BrowserTestUtils.promiseAlertDialogOpen();
+ await createScriptNode(`setTimeout(() => window.alert('test'))`);
+ dialogWin = await dialogPromise;
+ closed = listener.dialogClosed();
+
+ dialogWin.document.querySelector("dialog").cancelDialog();
+
+ await closed;
+
+ is(true, true, "Close promise got resolved");
+
+ listener.destroy();
+});
+
+add_task(async function test_events_in_another_browser() {
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+ const selectedBrowser = win.gBrowser.selectedBrowser;
+ const listener = new PromptListener(() => ({
+ contentBrowser: selectedBrowser,
+ window: selectedBrowser.ownerGlobal,
+ }));
+ const events = [];
+ const onEvent = (name, data) => events.push(data);
+ listener.on("opened", onEvent);
+ listener.on("closed", onEvent);
+
+ listener.startListening();
+
+ const dialogPromise = BrowserTestUtils.promiseAlertDialogOpen();
+ await createScriptNode(`setTimeout(() => window.confirm('test'))`);
+ const dialogWin = await dialogPromise;
+
+ ok(events.length === 0, "No event was received");
+
+ dialogWin.document.querySelector("dialog").acceptDialog();
+
+ // Wait a bit to make sure that the event didn't come.
+ await new Promise(resolve => {
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ setTimeout(resolve, 500);
+ });
+
+ ok(events.length === 0, "No event was received");
+
+ listener.destroy();
+ await BrowserTestUtils.closeWindow(win);
+});
diff --git a/remote/shared/listeners/test/browser/head.js b/remote/shared/listeners/test/browser/head.js
new file mode 100644
index 0000000000..1691a6f59b
--- /dev/null
+++ b/remote/shared/listeners/test/browser/head.js
@@ -0,0 +1,89 @@
+/* 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";
+
+async function clearConsole() {
+ for (const tab of gBrowser.tabs) {
+ await SpecialPowers.spawn(tab.linkedBrowser, [], () => {
+ Services.console.reset();
+ });
+ }
+ Services.console.reset();
+}
+
+/**
+ * Execute the provided script content by generating a dynamic script tag and
+ * inserting it in the page for the current selected browser.
+ *
+ * @param {string} script
+ * The script to execute.
+ * @returns {Promise}
+ * A promise that resolves when the script node was added and removed from
+ * the content page.
+ */
+function createScriptNode(script) {
+ return SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [script],
+ function (_script) {
+ var script = content.document.createElement("script");
+ script.append(content.document.createTextNode(_script));
+ content.document.body.append(script);
+ }
+ );
+}
+
+registerCleanupFunction(async () => {
+ await clearConsole();
+});
+
+async function doGC() {
+ // Run GC and CC a few times to make sure that as much as possible is freed.
+ const numCycles = 3;
+ for (let i = 0; i < numCycles; i++) {
+ Cu.forceGC();
+ Cu.forceCC();
+ await new Promise(resolve => Cu.schedulePreciseShrinkingGC(resolve));
+ }
+
+ const MemoryReporter = Cc[
+ "@mozilla.org/memory-reporter-manager;1"
+ ].getService(Ci.nsIMemoryReporterManager);
+ await new Promise(resolve => MemoryReporter.minimizeMemoryUsage(resolve));
+}
+
+/**
+ * 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.
+ */
+async function loadURL(browser, url) {
+ const loaded = BrowserTestUtils.browserLoaded(browser);
+ BrowserTestUtils.startLoadingURIString(browser, url);
+ return loaded;
+}
+
+/**
+ * Create a fetch request to `url` from the content page loaded in the provided
+ * `browser`.
+ *
+ *
+ * @param {Browser} browser
+ * The browser element where the fetch should be performed.
+ * @param {string} url
+ * The URL to fetch.
+ */
+function fetch(browser, url) {
+ return SpecialPowers.spawn(browser, [url], async _url => {
+ const response = await content.fetch(_url);
+ // Wait for response.text() to resolve as well to make sure the response
+ // has completed before returning.
+ await response.text();
+ });
+}
diff --git a/remote/shared/messagehandler/Errors.sys.mjs b/remote/shared/messagehandler/Errors.sys.mjs
new file mode 100644
index 0000000000..69c65acd09
--- /dev/null
+++ b/remote/shared/messagehandler/Errors.sys.mjs
@@ -0,0 +1,90 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { RemoteError } from "chrome://remote/content/shared/RemoteError.sys.mjs";
+
+class MessageHandlerError extends RemoteError {
+ /**
+ * @param {(string|Error)=} x
+ * Optional string describing error situation or Error instance
+ * to propagate.
+ */
+ constructor(x) {
+ super(x);
+ this.name = this.constructor.name;
+ this.status = "message handler error";
+
+ // Error's ctor does not preserve x' stack
+ if (typeof x?.stack !== "undefined") {
+ this.stack = x.stack;
+ }
+ }
+
+ get isMessageHandlerError() {
+ return true;
+ }
+
+ /**
+ * @returns {Object<string, string>}
+ * JSON serialisation of error prototype.
+ */
+ toJSON() {
+ return {
+ error: this.status,
+ message: this.message || "",
+ stacktrace: this.stack || "",
+ };
+ }
+
+ /**
+ * Unmarshals a JSON error representation to the appropriate MessageHandler
+ * error type.
+ *
+ * @param {Object<string, string>} json
+ * Error object.
+ *
+ * @returns {Error}
+ * Error prototype.
+ */
+ static fromJSON(json) {
+ if (typeof json.error == "undefined") {
+ let s = JSON.stringify(json);
+ throw new TypeError("Undeserialisable error type: " + s);
+ }
+ if (!STATUSES.has(json.error)) {
+ throw new TypeError("Not of MessageHandlerError descent: " + json.error);
+ }
+
+ let cls = STATUSES.get(json.error);
+ let err = new cls();
+ if ("message" in json) {
+ err.message = json.message;
+ }
+ if ("stacktrace" in json) {
+ err.stack = json.stacktrace;
+ }
+ return err;
+ }
+}
+
+/**
+ * A command could not be handled by the message handler network.
+ */
+class UnsupportedCommandError extends MessageHandlerError {
+ constructor(message) {
+ super(message);
+ this.status = "unsupported message handler command";
+ }
+}
+
+const STATUSES = new Map([
+ ["message handler error", MessageHandlerError],
+ ["unsupported message handler command", UnsupportedCommandError],
+]);
+
+/** @namespace */
+export const error = {
+ MessageHandlerError,
+ UnsupportedCommandError,
+};
diff --git a/remote/shared/messagehandler/EventsDispatcher.sys.mjs b/remote/shared/messagehandler/EventsDispatcher.sys.mjs
new file mode 100644
index 0000000000..9620febcc1
--- /dev/null
+++ b/remote/shared/messagehandler/EventsDispatcher.sys.mjs
@@ -0,0 +1,260 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ContextDescriptorType:
+ "chrome://remote/content/shared/messagehandler/MessageHandler.sys.mjs",
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+ SessionDataCategory:
+ "chrome://remote/content/shared/messagehandler/sessiondata/SessionData.sys.mjs",
+ SessionDataMethod:
+ "chrome://remote/content/shared/messagehandler/sessiondata/SessionData.sys.mjs",
+ TabManager: "chrome://remote/content/shared/TabManager.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
+
+/**
+ * Helper to listen to events which rely on SessionData.
+ * In order to support the EventsDispatcher, a module emitting events should
+ * subscribe and unsubscribe to those events based on SessionData updates
+ * and should use the "event" SessionData category.
+ */
+export class EventsDispatcher {
+ // The MessageHandler owning this EventsDispatcher.
+ #messageHandler;
+
+ /**
+ * @typedef {object} EventListenerInfo
+ * @property {ContextDescriptor} contextDescriptor
+ * The ContextDescriptor to which those callbacks are associated
+ * @property {Set<Function>} callbacks
+ * The callbacks to trigger when an event matching the ContextDescriptor
+ * is received.
+ */
+
+ // Map from event name to map of strings (context keys) to EventListenerInfo.
+ #listenersByEventName;
+
+ /**
+ * Create a new EventsDispatcher instance.
+ *
+ * @param {MessageHandler} messageHandler
+ * The MessageHandler owning this EventsDispatcher.
+ */
+ constructor(messageHandler) {
+ this.#messageHandler = messageHandler;
+
+ this.#listenersByEventName = new Map();
+ }
+
+ destroy() {
+ for (const event of this.#listenersByEventName.keys()) {
+ this.#messageHandler.off(event, this.#onMessageHandlerEvent);
+ }
+
+ this.#listenersByEventName = null;
+ }
+
+ /**
+ * Check for existing listeners for a given event name and a given context.
+ *
+ * @param {string} name
+ * Name of the event to check.
+ * @param {ContextInfo} contextInfo
+ * ContextInfo identifying the context to check.
+ *
+ * @returns {boolean}
+ * True if there is a registered listener matching the provided arguments.
+ */
+ hasListener(name, contextInfo) {
+ if (!this.#listenersByEventName.has(name)) {
+ return false;
+ }
+
+ const listeners = this.#listenersByEventName.get(name);
+ for (const { contextDescriptor } of listeners.values()) {
+ if (this.#matchesContext(contextInfo, contextDescriptor)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Stop listening for an event relying on SessionData and relayed by the
+ * message handler.
+ *
+ * @param {string} event
+ * Name of the event to unsubscribe from.
+ * @param {ContextDescriptor} contextDescriptor
+ * Context descriptor for this event.
+ * @param {Function} callback
+ * Event listener callback.
+ * @returns {Promise}
+ * Promise which resolves when the event fully unsubscribed, including
+ * propagating the necessary session data.
+ */
+ async off(event, contextDescriptor, callback) {
+ return this.update([{ event, contextDescriptor, callback, enable: false }]);
+ }
+
+ /**
+ * Listen for an event relying on SessionData and relayed by the message
+ * handler.
+ *
+ * @param {string} event
+ * Name of the event to subscribe to.
+ * @param {ContextDescriptor} contextDescriptor
+ * Context descriptor for this event.
+ * @param {Function} callback
+ * Event listener callback.
+ * @returns {Promise}
+ * Promise which resolves when the event fully subscribed to, including
+ * propagating the necessary session data.
+ */
+ async on(event, contextDescriptor, callback) {
+ return this.update([{ event, contextDescriptor, callback, enable: true }]);
+ }
+
+ /**
+ * An object that holds information about subscription/unsubscription
+ * of an event.
+ *
+ * @typedef Subscription
+ *
+ * @param {string} event
+ * Name of the event to subscribe/unsubscribe to.
+ * @param {ContextDescriptor} contextDescriptor
+ * Context descriptor for this event.
+ * @param {Function} callback
+ * Event listener callback.
+ * @param {boolean} enable
+ * True, if we need to subscribe to an event.
+ * Otherwise false.
+ */
+
+ /**
+ * Start or stop listening to a list of events relying on SessionData
+ * and relayed by the message handler.
+ *
+ * @param {Array<Subscription>} subscriptions
+ * The list of information to subscribe/unsubscribe to.
+ *
+ * @returns {Promise}
+ * Promise which resolves when the events fully subscribed/unsubscribed to,
+ * including propagating the necessary session data.
+ */
+ async update(subscriptions) {
+ const sessionDataItemUpdates = [];
+ subscriptions.forEach(({ event, contextDescriptor, callback, enable }) => {
+ if (enable) {
+ // Setup listeners.
+ if (!this.#listenersByEventName.has(event)) {
+ this.#listenersByEventName.set(event, new Map());
+ this.#messageHandler.on(event, this.#onMessageHandlerEvent);
+ }
+
+ const key = this.#getContextKey(contextDescriptor);
+ const listeners = this.#listenersByEventName.get(event);
+ if (listeners.has(key)) {
+ const { callbacks } = listeners.get(key);
+ callbacks.add(callback);
+ } else {
+ const callbacks = new Set([callback]);
+ listeners.set(key, { callbacks, contextDescriptor });
+
+ sessionDataItemUpdates.push({
+ ...this.#getSessionDataItem(event, contextDescriptor),
+ method: lazy.SessionDataMethod.Add,
+ });
+ }
+ } else {
+ // Remove listeners.
+ const listeners = this.#listenersByEventName.get(event);
+ if (!listeners) {
+ return;
+ }
+
+ const key = this.#getContextKey(contextDescriptor);
+ if (!listeners.has(key)) {
+ return;
+ }
+
+ const { callbacks } = listeners.get(key);
+ if (callbacks.has(callback)) {
+ callbacks.delete(callback);
+ if (callbacks.size === 0) {
+ listeners.delete(key);
+ if (listeners.size === 0) {
+ this.#messageHandler.off(event, this.#onMessageHandlerEvent);
+ this.#listenersByEventName.delete(event);
+ }
+
+ sessionDataItemUpdates.push({
+ ...this.#getSessionDataItem(event, contextDescriptor),
+ method: lazy.SessionDataMethod.Remove,
+ });
+ }
+ }
+ }
+ });
+
+ // Update all sessionData at once.
+ await this.#messageHandler.updateSessionData(sessionDataItemUpdates);
+ }
+
+ #getContextKey(contextDescriptor) {
+ const { id, type } = contextDescriptor;
+ return `${type}-${id}`;
+ }
+
+ #getSessionDataItem(event, contextDescriptor) {
+ const [moduleName] = event.split(".");
+ return {
+ moduleName,
+ category: lazy.SessionDataCategory.Event,
+ contextDescriptor,
+ values: [event],
+ };
+ }
+
+ #matchesContext(contextInfo, contextDescriptor) {
+ if (contextDescriptor.type === lazy.ContextDescriptorType.All) {
+ return true;
+ }
+
+ if (
+ contextDescriptor.type === lazy.ContextDescriptorType.TopBrowsingContext
+ ) {
+ const eventBrowsingContext = lazy.TabManager.getBrowsingContextById(
+ contextInfo.contextId
+ );
+ return eventBrowsingContext?.browserId === contextDescriptor.id;
+ }
+
+ return false;
+ }
+
+ #onMessageHandlerEvent = (name, event, contextInfo) => {
+ const listeners = this.#listenersByEventName.get(name);
+ for (const { callbacks, contextDescriptor } of listeners.values()) {
+ if (!this.#matchesContext(contextInfo, contextDescriptor)) {
+ continue;
+ }
+
+ for (const callback of callbacks) {
+ try {
+ callback(name, event);
+ } catch (e) {
+ lazy.logger.debug(
+ `Error while executing callback for ${name}: ${e.message}`
+ );
+ }
+ }
+ }
+ };
+}
diff --git a/remote/shared/messagehandler/MessageHandler.sys.mjs b/remote/shared/messagehandler/MessageHandler.sys.mjs
new file mode 100644
index 0000000000..18ec6b820c
--- /dev/null
+++ b/remote/shared/messagehandler/MessageHandler.sys.mjs
@@ -0,0 +1,355 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ error: "chrome://remote/content/shared/messagehandler/Errors.sys.mjs",
+ EventsDispatcher:
+ "chrome://remote/content/shared/messagehandler/EventsDispatcher.sys.mjs",
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+ ModuleCache:
+ "chrome://remote/content/shared/messagehandler/ModuleCache.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
+
+/**
+ * A ContextDescriptor object provides information to decide if a broadcast or
+ * a session data item should be applied to a specific MessageHandler context.
+ *
+ * @typedef {object} ContextDescriptor
+ * @property {ContextDescriptorType} type
+ * The type of context
+ * @property {string=} id
+ * Unique id of a given context for the provided type.
+ * For ContextDescriptorType.All, id can be ommitted.
+ * For ContextDescriptorType.TopBrowsingContext, the id should be the
+ * browserId corresponding to a top-level browsing context.
+ */
+
+/**
+ * Enum of ContextDescriptor types.
+ *
+ * @enum {string}
+ */
+export const ContextDescriptorType = {
+ All: "All",
+ TopBrowsingContext: "TopBrowsingContext",
+};
+
+/**
+ * A ContextInfo identifies a given context that can be linked to a MessageHandler
+ * instance. It should be used to identify events coming from this context.
+ *
+ * It can either be provided by the MessageHandler itself, when the event is
+ * emitted from the context it relates to.
+ *
+ * Or it can be assembled manually, for instance when emitting an event which
+ * relates to a window global from the root layer (eg browsingContext.contextCreated).
+ *
+ * @typedef {object} ContextInfo
+ * @property {string} contextId
+ * Unique id of the MessageHandler corresponding to this context.
+ * @property {string} type
+ * One of MessageHandler.type.
+ */
+
+/**
+ * MessageHandler instances are dedicated to handle both Commands and Events
+ * to enable automation and introspection for remote control protocols.
+ *
+ * MessageHandler instances are designed to form a network, where each instance
+ * should allow to inspect a specific context (eg. a BrowsingContext, a Worker,
+ * etc). Those instances might live in different processes and threads but
+ * should be linked together by the usage of a single sessionId, shared by all
+ * the instances of a single MessageHandler network.
+ *
+ * MessageHandler instances will be dynamically spawned depending on which
+ * Command or which Event needs to be processed and should therefore not be
+ * explicitly created by consumers, nor used directly.
+ *
+ * The only exception is the ROOT MessageHandler. This MessageHandler will be
+ * the entry point to send commands to the rest of the network. It will also
+ * emit all the relevant events captured by the network.
+ *
+ * However, even to create this ROOT MessageHandler, consumers should use the
+ * RootMessageHandlerRegistry. This singleton will ensure that MessageHandler
+ * instances are properly registered and can be retrieved based on a given
+ * session id as well as some other context information.
+ */
+export class MessageHandler extends EventEmitter {
+ #context;
+ #contextId;
+ #eventsDispatcher;
+ #moduleCache;
+ #registry;
+ #sessionId;
+
+ /**
+ * Create a new MessageHandler instance.
+ *
+ * @param {string} sessionId
+ * ID of the session the handler is used for.
+ * @param {object} context
+ * The context linked to this MessageHandler instance.
+ * @param {MessageHandlerRegistry} registry
+ * The MessageHandlerRegistry which owns this MessageHandler instance.
+ */
+ constructor(sessionId, context, registry) {
+ super();
+
+ this.#moduleCache = new lazy.ModuleCache(this);
+
+ this.#sessionId = sessionId;
+ this.#context = context;
+ this.#contextId = this.constructor.getIdFromContext(context);
+ this.#eventsDispatcher = new lazy.EventsDispatcher(this);
+ this.#registry = registry;
+ }
+
+ get context() {
+ return this.#context;
+ }
+
+ get contextId() {
+ return this.#contextId;
+ }
+
+ get eventsDispatcher() {
+ return this.#eventsDispatcher;
+ }
+
+ get moduleCache() {
+ return this.#moduleCache;
+ }
+
+ get name() {
+ return [this.sessionId, this.constructor.type, this.contextId].join("-");
+ }
+
+ get registry() {
+ return this.#registry;
+ }
+
+ get sessionId() {
+ return this.#sessionId;
+ }
+
+ destroy() {
+ lazy.logger.trace(
+ `MessageHandler ${this.constructor.type} for session ${this.sessionId} is being destroyed`
+ );
+ this.#eventsDispatcher.destroy();
+ this.#moduleCache.destroy();
+
+ // At least the MessageHandlerRegistry will be expecting this event in order
+ // to remove the instance from the registry when destroyed.
+ this.emit("message-handler-destroyed", this);
+ }
+
+ /**
+ * Emit a message handler event.
+ *
+ * Such events should bubble up to the root of a MessageHandler network.
+ *
+ * @param {string} name
+ * Name of the event. Protocol level events should be of the
+ * form [module name].[event name].
+ * @param {object} data
+ * The event's data.
+ * @param {ContextInfo=} contextInfo
+ * The event's context info, used to identify the origin of the event.
+ * If not provided, the context info of the current MessageHandler will be
+ * used.
+ */
+ emitEvent(name, data, contextInfo) {
+ // If no contextInfo field is provided on the event, extract it from the
+ // MessageHandler instance.
+ contextInfo = contextInfo || this.#getContextInfo();
+
+ // Events are emitted both under their own name for consumers listening to
+ // a specific and as `message-handler-event` for consumers which need to
+ // catch all events.
+ this.emit(name, data, contextInfo);
+ this.emit("message-handler-event", {
+ name,
+ contextInfo,
+ data,
+ sessionId: this.sessionId,
+ });
+ }
+
+ /**
+ * @typedef {object} CommandDestination
+ * @property {string} type
+ * One of MessageHandler.type.
+ * @property {string=} id
+ * Unique context identifier. The format depends on the type.
+ * For WINDOW_GLOBAL destinations, this is a browsing context id.
+ * Optional, should only be provided if `contextDescriptor` is missing.
+ * @property {ContextDescriptor=} contextDescriptor
+ * Descriptor used to match several contexts, which will all receive the
+ * command.
+ * Optional, should only be provided if `id` is missing.
+ */
+
+ /**
+ * @typedef {object} Command
+ * @property {string} commandName
+ * The name of the command to execute.
+ * @property {string} moduleName
+ * The name of the module.
+ * @property {object} params
+ * Optional command parameters.
+ * @property {CommandDestination} destination
+ * The destination describing a debuggable context.
+ * @property {boolean=} retryOnAbort
+ * Optional. When true, commands will be retried upon AbortError, which
+ * can occur when the underlying JSWindowActor pair is destroyed.
+ * Defaults to `false`.
+ */
+
+ /**
+ * Retrieve all module classes matching the moduleName and destination.
+ * See `getAllModuleClasses` (ModuleCache.jsm) for more details.
+ *
+ * @param {string} moduleName
+ * The name of the module.
+ * @param {Destination} destination
+ * The destination.
+ * @returns {Array.<class<Module>|null>}
+ * An array of Module classes.
+ */
+ getAllModuleClasses(moduleName, destination) {
+ return this.#moduleCache.getAllModuleClasses(moduleName, destination);
+ }
+
+ /**
+ * Handle a command, either in one of the modules owned by this MessageHandler
+ * or in a another MessageHandler after forwarding the command.
+ *
+ * @param {Command} command
+ * The command that should be either handled in this layer or forwarded to
+ * the next layer leading to the destination.
+ * @returns {Promise} A Promise that will resolve with the return value of the
+ * command once it has been executed.
+ */
+ handleCommand(command) {
+ const { moduleName, commandName, params, destination } = command;
+ lazy.logger.trace(
+ `Received command ${moduleName}.${commandName} for destination ${destination.type}`
+ );
+
+ if (!this.supportsCommand(moduleName, commandName, destination)) {
+ throw new lazy.error.UnsupportedCommandError(
+ `${moduleName}.${commandName} not supported for destination ${destination?.type}`
+ );
+ }
+
+ const module = this.#moduleCache.getModuleInstance(moduleName, destination);
+ if (module && module.supportsMethod(commandName)) {
+ return module[commandName](params, destination);
+ }
+
+ return this.forwardCommand(command);
+ }
+
+ toString() {
+ return `[object ${this.constructor.name} ${this.name}]`;
+ }
+
+ /**
+ * Execute the required initialization steps, inlcluding apply the initial session data items
+ * provided to this MessageHandler on startup. Implementation is specific to each MessageHandler class.
+ *
+ * By default the implementation is a no-op.
+ *
+ * @param {Array<SessionDataItem>} sessionDataItems
+ * Initial session data items for this MessageHandler.
+ */
+ async initialize(sessionDataItems) {}
+
+ /**
+ * Returns the module path corresponding to this MessageHandler class.
+ *
+ * Needs to be implemented in the sub class.
+ */
+ static get modulePath() {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * Returns the type corresponding to this MessageHandler class.
+ *
+ * Needs to be implemented in the sub class.
+ */
+ static get type() {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * Returns the id corresponding to a context compatible with this
+ * MessageHandler class.
+ *
+ * Needs to be implemented in the sub class.
+ */
+ static getIdFromContext(context) {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * Forward a command to other MessageHandlers.
+ *
+ * Needs to be implemented in the sub class.
+ */
+ forwardCommand(command) {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * Check if contextDescriptor matches the context linked
+ * to this MessageHandler instance.
+ *
+ * Needs to be implemented in the sub class.
+ */
+ matchesContext(contextDescriptor) {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * Check if the given command is supported in the module
+ * for the destination
+ *
+ * @param {string} moduleName
+ * The name of the module.
+ * @param {string} commandName
+ * The name of the command.
+ * @param {Destination} destination
+ * The destination.
+ * @returns {boolean}
+ * True if the command is supported.
+ */
+ supportsCommand(moduleName, commandName, destination) {
+ return this.getAllModuleClasses(moduleName, destination).some(cls =>
+ cls.supportsMethod(commandName)
+ );
+ }
+
+ /**
+ * Return the context information for this MessageHandler instance, which
+ * can be used to identify the origin of an event.
+ *
+ * @returns {ContextInfo}
+ * The context information for this MessageHandler.
+ */
+ #getContextInfo() {
+ return {
+ contextId: this.contextId,
+ type: this.constructor.type,
+ };
+ }
+}
diff --git a/remote/shared/messagehandler/MessageHandlerRegistry.sys.mjs b/remote/shared/messagehandler/MessageHandlerRegistry.sys.mjs
new file mode 100644
index 0000000000..6a09173f50
--- /dev/null
+++ b/remote/shared/messagehandler/MessageHandlerRegistry.sys.mjs
@@ -0,0 +1,236 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+ readSessionData:
+ "chrome://remote/content/shared/messagehandler/sessiondata/SessionDataReader.sys.mjs",
+ RootMessageHandler:
+ "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs",
+ WindowGlobalMessageHandler:
+ "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
+
+/**
+ * Map of MessageHandler type to MessageHandler subclass.
+ */
+ChromeUtils.defineLazyGetter(
+ lazy,
+ "MessageHandlerClasses",
+ () =>
+ new Map([
+ [lazy.RootMessageHandler.type, lazy.RootMessageHandler],
+ [lazy.WindowGlobalMessageHandler.type, lazy.WindowGlobalMessageHandler],
+ ])
+);
+
+/**
+ * Get the MessageHandler subclass corresponding to the provided type.
+
+ * @param {string} type
+ * MessageHandler type, one of MessageHandler.type.
+ * @returns {Class}
+ * A MessageHandler subclass
+ * @throws {Error}
+ * Throws if no MessageHandler subclass is found for the provided type.
+ */
+export function getMessageHandlerClass(type) {
+ if (!lazy.MessageHandlerClasses.has(type)) {
+ throw new Error(`No MessageHandler class available for type "${type}"`);
+ }
+ return lazy.MessageHandlerClasses.get(type);
+}
+
+/**
+ * The MessageHandlerRegistry allows to create and retrieve MessageHandler
+ * instances for different session ids.
+ *
+ * A MessageHandlerRegistry instance is bound to a specific MessageHandler type
+ * and context. All MessageHandler instances created by the same registry will
+ * use the type and context of the registry, but each will be associated to a
+ * different session id.
+ *
+ * The registry is useful to retrieve the appropriate MessageHandler instance
+ * after crossing a technical boundary (eg process, thread...).
+ */
+export class MessageHandlerRegistry extends EventEmitter {
+ /*
+ * @param {String} type
+ * MessageHandler type, one of MessageHandler.type.
+ * @param {Object} context
+ * The context object, which depends on the type.
+ */
+ constructor(type, context) {
+ super();
+
+ this._messageHandlerClass = getMessageHandlerClass(type);
+ this._context = context;
+ this._type = type;
+
+ /**
+ * Map of session id to MessageHandler instance
+ */
+ this._messageHandlersMap = new Map();
+
+ this._onMessageHandlerDestroyed =
+ this._onMessageHandlerDestroyed.bind(this);
+ this._onMessageHandlerEvent = this._onMessageHandlerEvent.bind(this);
+ }
+
+ /**
+ * Create all message handlers for the current context, based on the content
+ * of the session data.
+ * This should typically be called when the context is ready to be used and
+ * to receive/send commands.
+ */
+ createAllMessageHandlers() {
+ const data = lazy.readSessionData();
+ for (const [sessionId, sessionDataItems] of data) {
+ // Create a message handler for this context for each active message
+ // handler session.
+ // TODO: In the future, to support debugging use cases we might want to
+ // only create a message handler if there is relevant data.
+ // For automation scenarios, this is less critical.
+ this._createMessageHandler(sessionId, sessionDataItems);
+ }
+ }
+
+ destroy() {
+ this._messageHandlersMap.forEach(messageHandler => {
+ messageHandler.destroy();
+ });
+ }
+
+ /**
+ * Retrieve all MessageHandler instances held in this registry, for all
+ * session IDs.
+ *
+ * @returns {Iterable.<MessageHandler>}
+ * Iterator of MessageHandler instances
+ */
+ getAllMessageHandlers() {
+ return this._messageHandlersMap.values();
+ }
+
+ /**
+ * Retrieve an existing MessageHandler instance matching the provided session
+ * id. Returns null if no MessageHandler was found.
+ *
+ * @param {string} sessionId
+ * ID of the session the handler is used for.
+ * @returns {MessageHandler=}
+ * A MessageHandler instance, null if not found.
+ */
+ getExistingMessageHandler(sessionId) {
+ return this._messageHandlersMap.get(sessionId);
+ }
+
+ /**
+ * Retrieve the MessageHandler instance registered for the provided session
+ * id. Will create and register a MessageHander if no instance was found.
+ *
+ * @param {string} sessionId
+ * ID of the session the handler is used for.
+ * @returns {MessageHandler}
+ * A MessageHandler instance.
+ */
+ getOrCreateMessageHandler(sessionId) {
+ let messageHandler = this.getExistingMessageHandler(sessionId);
+ if (!messageHandler) {
+ messageHandler = this._createMessageHandler(sessionId);
+ }
+
+ return messageHandler;
+ }
+
+ /**
+ * Retrieve an already registered RootMessageHandler instance matching the
+ * provided sessionId.
+ *
+ * @param {string} sessionId
+ * ID of the session the handler is used for.
+ * @returns {RootMessageHandler}
+ * A RootMessageHandler instance.
+ * @throws {Error}
+ * If no root MessageHandler can be found for the provided session id.
+ */
+ getRootMessageHandler(sessionId) {
+ const rootMessageHandler = this.getExistingMessageHandler(
+ sessionId,
+ lazy.RootMessageHandler.type
+ );
+ if (!rootMessageHandler) {
+ throw new Error(
+ `Unable to find a root MessageHandler for session id ${sessionId}`
+ );
+ }
+ return rootMessageHandler;
+ }
+
+ toString() {
+ return `[object ${this.constructor.name}]`;
+ }
+
+ /**
+ * Create a new MessageHandler instance.
+ *
+ * @param {string} sessionId
+ * ID of the session the handler will be used for.
+ * @param {Array<SessionDataItem>=} sessionDataItems
+ * Optional array of session data items to be applied automatically to the
+ * MessageHandler.
+ * @returns {MessageHandler}
+ * A new MessageHandler instance.
+ */
+ _createMessageHandler(sessionId, sessionDataItems) {
+ const messageHandler = new this._messageHandlerClass(
+ sessionId,
+ this._context,
+ this
+ );
+
+ messageHandler.on(
+ "message-handler-destroyed",
+ this._onMessageHandlerDestroyed
+ );
+ messageHandler.on("message-handler-event", this._onMessageHandlerEvent);
+
+ messageHandler.initialize(sessionDataItems);
+
+ this._messageHandlersMap.set(sessionId, messageHandler);
+
+ lazy.logger.trace(
+ `Created MessageHandler ${this._type} for session ${sessionId}`
+ );
+
+ return messageHandler;
+ }
+
+ // Event handlers
+
+ _onMessageHandlerDestroyed(eventName, messageHandler) {
+ messageHandler.off(
+ "message-handler-destroyed",
+ this._onMessageHandlerDestroyed
+ );
+ messageHandler.off("message-handler-event", this._onMessageHandlerEvent);
+ this._messageHandlersMap.delete(messageHandler.sessionId);
+
+ lazy.logger.trace(
+ `Unregistered MessageHandler ${messageHandler.constructor.type} for session ${messageHandler.sessionId}`
+ );
+ }
+
+ _onMessageHandlerEvent(eventName, messageHandlerEvent) {
+ // The registry simply re-emits MessageHandler events so that consumers
+ // don't have to attach listeners to individual MessageHandler instances.
+ this.emit("message-handler-registry-event", messageHandlerEvent);
+ }
+}
diff --git a/remote/shared/messagehandler/Module.sys.mjs b/remote/shared/messagehandler/Module.sys.mjs
new file mode 100644
index 0000000000..30b26938e2
--- /dev/null
+++ b/remote/shared/messagehandler/Module.sys.mjs
@@ -0,0 +1,135 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "disabledExperimentalAPI", () => {
+ return !Services.prefs.getBoolPref("remote.experimental.enabled");
+});
+
+ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
+
+export class Module {
+ #messageHandler;
+
+ /**
+ * Create a new module instance.
+ *
+ * @param {MessageHandler} messageHandler
+ * The MessageHandler instance which owns this Module instance.
+ */
+ constructor(messageHandler) {
+ this.#messageHandler = messageHandler;
+ }
+
+ /**
+ * Clean-up the module instance.
+ */
+ destroy() {
+ lazy.logger.warn(
+ `Module ${this.constructor.name} is missing a destroy method`
+ );
+ }
+
+ /**
+ * Emit a message handler event.
+ *
+ * Such events should bubble up to the root of a MessageHandler network.
+ *
+ * @param {string} name
+ * Name of the event. Protocol level events should be of the
+ * form [module name].[event name].
+ * @param {object} data
+ * The event's data.
+ * @param {ContextInfo=} contextInfo
+ * The event's context info, see MessageHandler:emitEvent. Optional.
+ */
+ emitEvent(name, data, contextInfo) {
+ this.messageHandler.emitEvent(name, data, contextInfo);
+ }
+
+ /**
+ * Intercept an event and modify the payload.
+ *
+ * It's required to be implemented in windowglobal-in-root modules.
+ *
+ * @param {string} name
+ * Name of the event.
+ * @param {object} payload
+ * The event's payload.
+ * @returns {object}
+ * The modified event payload.
+ */
+ interceptEvent(name, payload) {
+ throw new Error(
+ `Could not intercept event ${name}, interceptEvent is not implemented in windowglobal-in-root module`
+ );
+ }
+
+ /**
+ * Assert if experimental commands are enabled.
+ *
+ * @param {string} methodName
+ * Name of the command.
+ *
+ * @throws {UnknownCommandError}
+ * If experimental commands are disabled.
+ */
+ assertExperimentalCommandsEnabled(methodName) {
+ // TODO: 1778987. Move it to a BiDi specific place.
+ if (lazy.disabledExperimentalAPI) {
+ throw new lazy.error.UnknownCommandError(methodName);
+ }
+ }
+
+ /**
+ * Assert if experimental events are enabled.
+ *
+ * @param {string} moduleName
+ * Name of the module.
+ *
+ * @param {string} event
+ * Name of the event.
+ *
+ * @throws {InvalidArgumentError}
+ * If experimental events are disabled.
+ */
+ assertExperimentalEventsEnabled(moduleName, event) {
+ // TODO: 1778987. Move it to a BiDi specific place.
+ if (lazy.disabledExperimentalAPI) {
+ throw new lazy.error.InvalidArgumentError(
+ `Module ${moduleName} does not support event ${event}`
+ );
+ }
+ }
+
+ /**
+ * Instance shortcut for supportsMethod to avoid reaching the constructor for
+ * consumers which directly deal with an instance.
+ */
+ supportsMethod(methodName) {
+ return this.constructor.supportsMethod(methodName);
+ }
+
+ get messageHandler() {
+ return this.#messageHandler;
+ }
+
+ static get supportedEvents() {
+ return [];
+ }
+
+ static supportsEvent(event) {
+ return this.supportedEvents.includes(event);
+ }
+
+ static supportsMethod(methodName) {
+ return typeof this.prototype[methodName] === "function";
+ }
+}
diff --git a/remote/shared/messagehandler/ModuleCache.sys.mjs b/remote/shared/messagehandler/ModuleCache.sys.mjs
new file mode 100644
index 0000000000..6cff8dff60
--- /dev/null
+++ b/remote/shared/messagehandler/ModuleCache.sys.mjs
@@ -0,0 +1,263 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ getMessageHandlerClass:
+ "chrome://remote/content/shared/messagehandler/MessageHandlerRegistry.sys.mjs",
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+ RootMessageHandler:
+ "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs",
+});
+
+const protocols = {
+ bidi: {},
+ test: {},
+};
+// eslint-disable-next-line mozilla/lazy-getter-object-name
+ChromeUtils.defineESModuleGetters(protocols.bidi, {
+ // Additional protocols might use a different registry for their modules,
+ // in which case this will no longer be a constant but will instead depend on
+ // the protocol owning the MessageHandler. See Bug 1722464.
+ modules:
+ "chrome://remote/content/webdriver-bidi/modules/ModuleRegistry.sys.mjs",
+});
+// eslint-disable-next-line mozilla/lazy-getter-object-name
+ChromeUtils.defineESModuleGetters(protocols.test, {
+ modules:
+ "chrome://mochitests/content/browser/remote/shared/messagehandler/test/browser/resources/modules/ModuleRegistry.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
+
+/**
+ * ModuleCache instances are dedicated to lazily create and cache the instances
+ * of all the modules related to a specific MessageHandler instance.
+ *
+ * ModuleCache also implements the logic to resolve the path to the file for a
+ * given module, which depends both on the current MessageHandler context and on
+ * the expected destination.
+ *
+ * In order to implement module logic in any context, separate module files
+ * should be created for each situation. For instance, for a given module,
+ * - ${MODULES_FOLDER}/root/{ModuleName}.sys.mjs contains the implementation for
+ * commands intended for the destination ROOT, and will be created for a ROOT
+ * MessageHandler only. Typically, they will run in the parent process.
+ * - ${MODULES_FOLDER}/windowglobal/{ModuleName}.sys.mjs contains the implementation
+ * for commands intended for a WINDOW_GLOBAL destination, and will be created
+ * for a WINDOW_GLOBAL MessageHandler only. Those will usually run in a
+ * content process.
+ * - ${MODULES_FOLDER}/windowglobal-in-root/{ModuleName}.sys.mjs also handles
+ * commands intended for a WINDOW_GLOBAL destination, but they will be created
+ * for the ROOT MessageHandler and will run in the parent process. This can be
+ * useful if some code has to be executed in the parent process, even though
+ * the final destination is a WINDOW_GLOBAL.
+ * - And so on, as more MessageHandler types get added, more combinations will
+ * follow based on the same pattern:
+ * - {contextName}/{ModuleName}.sys.mjs
+ * - or {destinationType}-in-{currentType}/{ModuleName}.sys.mjs
+ *
+ * All those implementations are optional. If a module cannot be found, based on
+ * the logic detailed above, the MessageHandler will assume that the command
+ * should simply be forwarded to the next layer of the network.
+ */
+export class ModuleCache {
+ #messageHandler;
+ #messageHandlerType;
+ #modules;
+ #protocol;
+
+ /*
+ * @param {MessageHandler} messageHandler
+ * The MessageHandler instance which owns this ModuleCache instance.
+ */
+ constructor(messageHandler) {
+ this.#messageHandler = messageHandler;
+ this.#messageHandlerType = messageHandler.constructor.type;
+
+ // Map of absolute module paths to module instances.
+ this.#modules = new Map();
+
+ // Use the module class from the WebDriverBiDi ModuleRegistry if we
+ // are not using test modules.
+ this.#protocol = Services.prefs.getBoolPref(
+ "remote.messagehandler.modulecache.useBrowserTestRoot",
+ false
+ )
+ ? protocols.test
+ : protocols.bidi;
+ }
+
+ /**
+ * Destroy all instantiated modules.
+ */
+ destroy() {
+ this.#modules.forEach(module => module?.destroy());
+ }
+
+ /**
+ * Retrieve all module classes matching the provided module name to reach the
+ * provided destination, from the current context.
+ *
+ * This corresponds to the path a command can take to reach its destination.
+ * A command's method must be implemented in one of the classes returned by
+ * getAllModuleClasses in order to be successfully handled.
+ *
+ * @param {string} moduleName
+ * The name of the module.
+ * @param {Destination} destination
+ * The destination.
+ * @returns {Array<class<Module>|null>}
+ * An array of Module classes.
+ */
+ getAllModuleClasses(moduleName, destination) {
+ const destinationType = destination.type;
+ const classes = [
+ this.#getModuleClass(
+ moduleName,
+ this.#messageHandlerType,
+ destinationType
+ ),
+ ];
+
+ // Bug 1733242: Extend the implementation of this method to handle workers.
+ // It assumes layers have at most one level of nesting, for instance
+ // "root -> windowglobal", but it wouldn't work for something such as
+ // "root -> windowglobal -> worker".
+ if (destinationType !== this.#messageHandlerType) {
+ classes.push(
+ this.#getModuleClass(moduleName, destinationType, destinationType)
+ );
+ }
+
+ return classes.filter(cls => !!cls);
+ }
+
+ /**
+ * Get a module instance corresponding to the provided moduleName and
+ * destination. If no existing module can be found in the cache, ModuleCache
+ * will attempt to import the module file and create a new instance, which
+ * will then be cached and returned for subsequent calls.
+ *
+ * @param {string} moduleName
+ * The name of the module which should implement the command.
+ * @param {CommandDestination} destination
+ * The destination of the command for which we need to instantiate a
+ * module. See MessageHandler.sys.mjs for the CommandDestination typedef.
+ * @returns {object=}
+ * A module instance corresponding to the provided moduleName and
+ * destination, or null if it could not be instantiated.
+ */
+ getModuleInstance(moduleName, destination) {
+ const key = `${moduleName}-${destination.type}`;
+
+ if (this.#modules.has(key)) {
+ // If there is already a cached instance (potentially null) for the
+ // module name + destination type pair, return it.
+ return this.#modules.get(key);
+ }
+
+ const ModuleClass = this.#getModuleClass(
+ moduleName,
+ this.#messageHandlerType,
+ destination.type
+ );
+
+ let module = null;
+ if (ModuleClass) {
+ module = new ModuleClass(this.#messageHandler);
+ }
+
+ this.#modules.set(key, module);
+ return module;
+ }
+
+ /**
+ * Check if the given module exists for the destination.
+ *
+ * @param {string} moduleName
+ * The name of the module.
+ * @param {Destination} destination
+ * The destination.
+ * @returns {boolean}
+ * True if the module exists.
+ */
+ hasModuleClass(moduleName, destination) {
+ const classes = this.getAllModuleClasses(moduleName, destination);
+ return !!classes.length;
+ }
+
+ toString() {
+ return `[object ${this.constructor.name} ${this.#messageHandler.name}]`;
+ }
+
+ /**
+ * Retrieve the module class matching the provided module name and folder.
+ *
+ * @param {string} moduleName
+ * The name of the module to get the class for.
+ * @param {string} originType
+ * The MessageHandler type from where the command comes.
+ * @param {string} destinationType
+ * The MessageHandler type where the command should go to.
+ * @returns {Class=}
+ * The class corresponding to the module name and folder, null if no match
+ * was found.
+ * @throws {Error}
+ * If the provided module folder is unexpected.
+ */
+ #getModuleClass = function (moduleName, originType, destinationType) {
+ if (
+ destinationType === lazy.RootMessageHandler.type &&
+ originType !== destinationType
+ ) {
+ // If we are trying to reach the root layer from a lower layer, no module
+ // class should attempt to handle the command in the current layer and
+ // the command should be forwarded unconditionally.
+ return null;
+ }
+
+ const moduleFolder = this.#getModuleFolder(originType, destinationType);
+ if (!this.#protocol.modules[moduleFolder]) {
+ throw new Error(
+ `Invalid module folder "${moduleFolder}", expected one of "${Object.keys(
+ this.#protocol.modules
+ )}"`
+ );
+ }
+
+ let moduleClass = null;
+ if (this.#protocol.modules[moduleFolder][moduleName]) {
+ moduleClass = this.#protocol.modules[moduleFolder][moduleName];
+ }
+
+ if (moduleClass) {
+ lazy.logger.trace(
+ `Module ${moduleFolder}/${moduleName}.sys.mjs found for ${destinationType}`
+ );
+ } else {
+ lazy.logger.trace(
+ `Module ${moduleFolder}/${moduleName}.sys.mjs not found for ${destinationType}`
+ );
+ }
+
+ return moduleClass;
+ };
+
+ #getModuleFolder(originType, destinationType) {
+ const originPath = lazy.getMessageHandlerClass(originType).modulePath;
+ if (originType === destinationType) {
+ // If the command is targeting the current type, the module is expected to
+ // be in eg "windowglobal/${moduleName}.sys.mjs".
+ return originPath;
+ }
+
+ // If the command is targeting another type, the module is expected to
+ // be in a composed folder eg "windowglobal-in-root/${moduleName}.sys.mjs".
+ const destinationPath =
+ lazy.getMessageHandlerClass(destinationType).modulePath;
+ return `${destinationPath}-in-${originPath}`;
+ }
+}
diff --git a/remote/shared/messagehandler/RootMessageHandler.sys.mjs b/remote/shared/messagehandler/RootMessageHandler.sys.mjs
new file mode 100644
index 0000000000..06a8cd6f18
--- /dev/null
+++ b/remote/shared/messagehandler/RootMessageHandler.sys.mjs
@@ -0,0 +1,237 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { MessageHandler } from "chrome://remote/content/shared/messagehandler/MessageHandler.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ NavigationManager: "chrome://remote/content/shared/NavigationManager.sys.mjs",
+ RootTransport:
+ "chrome://remote/content/shared/messagehandler/transports/RootTransport.sys.mjs",
+ SessionData:
+ "chrome://remote/content/shared/messagehandler/sessiondata/SessionData.sys.mjs",
+ SessionDataMethod:
+ "chrome://remote/content/shared/messagehandler/sessiondata/SessionData.sys.mjs",
+ WindowGlobalMessageHandler:
+ "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs",
+});
+
+/**
+ * A RootMessageHandler is the root node of a MessageHandler network. It lives
+ * in the parent process. It can forward commands to MessageHandlers in other
+ * layers (at the moment WindowGlobalMessageHandlers in content processes).
+ */
+export class RootMessageHandler extends MessageHandler {
+ #navigationManager;
+ #realms;
+ #rootTransport;
+ #sessionData;
+
+ /**
+ * Returns the RootMessageHandler module path.
+ *
+ * @returns {string}
+ */
+ static get modulePath() {
+ return "root";
+ }
+
+ /**
+ * Returns the RootMessageHandler type.
+ *
+ * @returns {string}
+ */
+ static get type() {
+ return "ROOT";
+ }
+
+ /**
+ * The ROOT MessageHandler is unique for a given MessageHandler network
+ * (ie for a given sessionId). Reuse the type as context id here.
+ */
+ static getIdFromContext(context) {
+ return RootMessageHandler.type;
+ }
+
+ /**
+ * Create a new RootMessageHandler instance.
+ *
+ * @param {string} sessionId
+ * ID of the session the handler is used for.
+ */
+ constructor(sessionId) {
+ super(sessionId, null);
+
+ this.#rootTransport = new lazy.RootTransport(this);
+ this.#sessionData = new lazy.SessionData(this);
+ this.#navigationManager = new lazy.NavigationManager();
+ this.#navigationManager.startMonitoring();
+
+ // Map with inner window ids as keys, and sets of realm ids, assosiated with
+ // this window as values.
+ this.#realms = new Map();
+ // In the general case, we don't get notified that realms got destroyed,
+ // because there is no communication between content and parent process at this moment,
+ // so we have to listen to the this notification to clean up the internal
+ // map and trigger the events.
+ Services.obs.addObserver(this, "window-global-destroyed");
+ }
+
+ get navigationManager() {
+ return this.#navigationManager;
+ }
+
+ get realms() {
+ return this.#realms;
+ }
+
+ get sessionData() {
+ return this.#sessionData;
+ }
+
+ destroy() {
+ this.#sessionData.destroy();
+ this.#navigationManager.destroy();
+
+ Services.obs.removeObserver(this, "window-global-destroyed");
+ this.#realms = null;
+
+ super.destroy();
+ }
+
+ /**
+ * Add new session data items of a given module, category and
+ * contextDescriptor.
+ *
+ * Forwards the call to the SessionData instance owned by this
+ * RootMessageHandler and propagates the information via a command to existing
+ * MessageHandlers.
+ */
+ addSessionDataItem(sessionData = {}) {
+ sessionData.method = lazy.SessionDataMethod.Add;
+ return this.updateSessionData([sessionData]);
+ }
+
+ emitEvent(name, eventPayload, contextInfo) {
+ // Intercept realm created and destroyed events to update internal map.
+ if (name === "realm-created") {
+ this.#onRealmCreated(eventPayload);
+ }
+ // We receive this events in the case of moving the page to BFCache.
+ if (name === "windowglobal-pagehide") {
+ this.#cleanUpRealmsForWindow(
+ eventPayload.innerWindowId,
+ eventPayload.context
+ );
+ }
+
+ super.emitEvent(name, eventPayload, contextInfo);
+ }
+
+ /**
+ * Emit a public protocol event. This event will be sent over to the client.
+ *
+ * @param {string} name
+ * Name of the event. Protocol level events should be of the
+ * form [module name].[event name].
+ * @param {object} data
+ * The event's data.
+ */
+ emitProtocolEvent(name, data) {
+ this.emit("message-handler-protocol-event", {
+ name,
+ data,
+ sessionId: this.sessionId,
+ });
+ }
+
+ /**
+ * Forward the provided command to WINDOW_GLOBAL MessageHandlers via the
+ * RootTransport.
+ *
+ * @param {Command} command
+ * The command to forward. See type definition in MessageHandler.js
+ * @returns {Promise}
+ * Returns a promise that resolves with the result of the command.
+ */
+ forwardCommand(command) {
+ switch (command.destination.type) {
+ case lazy.WindowGlobalMessageHandler.type:
+ return this.#rootTransport.forwardCommand(command);
+ default:
+ throw new Error(
+ `Cannot forward command to "${command.destination.type}" from "${this.constructor.type}".`
+ );
+ }
+ }
+
+ matchesContext() {
+ return true;
+ }
+
+ observe(subject, topic) {
+ if (topic !== "window-global-destroyed") {
+ return;
+ }
+
+ this.#cleanUpRealmsForWindow(
+ subject.innerWindowId,
+ subject.browsingContext
+ );
+ }
+
+ /**
+ * Remove session data items of a given module, category and
+ * contextDescriptor.
+ *
+ * Forwards the call to the SessionData instance owned by this
+ * RootMessageHandler and propagates the information via a command to existing
+ * MessageHandlers.
+ */
+ removeSessionDataItem(sessionData = {}) {
+ sessionData.method = lazy.SessionDataMethod.Remove;
+ return this.updateSessionData([sessionData]);
+ }
+
+ /**
+ * Update session data items of a given module, category and
+ * contextDescriptor.
+ *
+ * Forwards the call to the SessionData instance owned by this
+ * RootMessageHandler.
+ */
+ async updateSessionData(sessionData = []) {
+ await this.#sessionData.updateSessionData(sessionData);
+ }
+
+ #cleanUpRealmsForWindow(innerWindowId, context) {
+ const realms = this.#realms.get(innerWindowId);
+
+ if (!realms) {
+ return;
+ }
+
+ realms.forEach(realm => {
+ this.#realms.get(innerWindowId).delete(realm);
+
+ this.emitEvent("realm-destroyed", {
+ context,
+ realm,
+ });
+ });
+
+ this.#realms.delete(innerWindowId);
+ }
+
+ #onRealmCreated = data => {
+ const { innerWindowId, realmInfo } = data;
+
+ if (!this.#realms.has(innerWindowId)) {
+ this.#realms.set(innerWindowId, new Set());
+ }
+
+ this.#realms.get(innerWindowId).add(realmInfo.realm);
+ };
+}
diff --git a/remote/shared/messagehandler/RootMessageHandlerRegistry.sys.mjs b/remote/shared/messagehandler/RootMessageHandlerRegistry.sys.mjs
new file mode 100644
index 0000000000..09ac489182
--- /dev/null
+++ b/remote/shared/messagehandler/RootMessageHandlerRegistry.sys.mjs
@@ -0,0 +1,17 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { MessageHandlerRegistry } from "chrome://remote/content/shared/messagehandler/MessageHandlerRegistry.sys.mjs";
+
+import { RootMessageHandler } from "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs";
+
+/**
+ * In the parent process, only one Root MessageHandlerRegistry should ever be
+ * created. All consumers can safely use this singleton to retrieve the Root
+ * registry and from there either create or retrieve Root MessageHandler
+ * instances for a specific session.
+ */
+export var RootMessageHandlerRegistry = new MessageHandlerRegistry(
+ RootMessageHandler.type
+);
diff --git a/remote/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs b/remote/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs
new file mode 100644
index 0000000000..584c73d72f
--- /dev/null
+++ b/remote/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs
@@ -0,0 +1,264 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import {
+ ContextDescriptorType,
+ MessageHandler,
+} from "chrome://remote/content/shared/messagehandler/MessageHandler.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
+ getMessageHandlerFrameChildActor:
+ "chrome://remote/content/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameChild.sys.mjs",
+ RootMessageHandler:
+ "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs",
+ WindowRealm: "chrome://remote/content/shared/Realm.sys.mjs",
+});
+
+/**
+ * A WindowGlobalMessageHandler is dedicated to debugging a single window
+ * global. It follows the lifecycle of the corresponding window global and will
+ * therefore not survive any navigation. This MessageHandler cannot forward
+ * commands further to other MessageHandlers and represents a leaf node in a
+ * MessageHandler network.
+ */
+export class WindowGlobalMessageHandler extends MessageHandler {
+ #innerWindowId;
+ #realms;
+
+ constructor() {
+ super(...arguments);
+
+ this.#innerWindowId = this.context.window.windowGlobalChild.innerWindowId;
+
+ // Maps sandbox names to instances of window realms.
+ this.#realms = new Map();
+ }
+
+ initialize(sessionDataItems) {
+ // Create the default realm, it is mapped to an empty string sandbox name.
+ this.#realms.set("", this.#createRealm());
+
+ // This method, even though being async, is not awaited on purpose,
+ // since for now the sessionDataItems are passed in response to an event in a for loop.
+ this.#applyInitialSessionDataItems(sessionDataItems);
+
+ // With the session data applied the handler is now ready to be used.
+ this.emitEvent("window-global-handler-created", {
+ contextId: this.contextId,
+ innerWindowId: this.#innerWindowId,
+ });
+ }
+
+ destroy() {
+ for (const realm of this.#realms.values()) {
+ realm.destroy();
+ }
+ this.emitEvent("windowglobal-pagehide", {
+ context: this.context,
+ innerWindowId: this.innerWindowId,
+ });
+ this.#realms = null;
+
+ super.destroy();
+ }
+
+ /**
+ * Returns the WindowGlobalMessageHandler module path.
+ *
+ * @returns {string}
+ */
+ static get modulePath() {
+ return "windowglobal";
+ }
+
+ /**
+ * Returns the WindowGlobalMessageHandler type.
+ *
+ * @returns {string}
+ */
+ static get type() {
+ return "WINDOW_GLOBAL";
+ }
+
+ /**
+ * For WINDOW_GLOBAL MessageHandlers, `context` is a BrowsingContext,
+ * and BrowsingContext.id can be used as the context id.
+ *
+ * @param {BrowsingContext} context
+ * WindowGlobalMessageHandler contexts are expected to be
+ * BrowsingContexts.
+ * @returns {string}
+ * The browsing context id.
+ */
+ static getIdFromContext(context) {
+ return context.id;
+ }
+
+ get innerWindowId() {
+ return this.#innerWindowId;
+ }
+
+ get realms() {
+ return this.#realms;
+ }
+
+ get window() {
+ return this.context.window;
+ }
+
+ #createRealm(sandboxName = null) {
+ const realm = new lazy.WindowRealm(this.context.window, {
+ sandboxName,
+ });
+
+ this.emitEvent("realm-created", {
+ realmInfo: realm.getInfo(),
+ innerWindowId: this.innerWindowId,
+ });
+
+ return realm;
+ }
+
+ #getRealmFromSandboxName(sandboxName = null) {
+ if (sandboxName === null || sandboxName === "") {
+ return this.#realms.get("");
+ }
+
+ if (this.#realms.has(sandboxName)) {
+ return this.#realms.get(sandboxName);
+ }
+
+ const realm = this.#createRealm(sandboxName);
+
+ this.#realms.set(sandboxName, realm);
+
+ return realm;
+ }
+
+ async #applyInitialSessionDataItems(sessionDataItems) {
+ if (!Array.isArray(sessionDataItems)) {
+ return;
+ }
+
+ const destination = {
+ type: WindowGlobalMessageHandler.type,
+ };
+
+ // Create a Map with the structure moduleName -> category -> relevant session data items.
+ const structuredUpdates = new Map();
+ for (const sessionDataItem of sessionDataItems) {
+ const { category, contextDescriptor, moduleName } = sessionDataItem;
+
+ if (!this.matchesContext(contextDescriptor)) {
+ continue;
+ }
+ if (!structuredUpdates.has(moduleName)) {
+ // Skip session data item if the module is not present
+ // for the destination.
+ if (!this.moduleCache.hasModuleClass(moduleName, destination)) {
+ continue;
+ }
+ structuredUpdates.set(moduleName, new Map());
+ }
+
+ if (!structuredUpdates.get(moduleName).has(category)) {
+ structuredUpdates.get(moduleName).set(category, new Set());
+ }
+
+ structuredUpdates.get(moduleName).get(category).add(sessionDataItem);
+ }
+
+ const sessionDataPromises = [];
+
+ for (const [moduleName, categories] of structuredUpdates.entries()) {
+ for (const [category, relevantSessionData] of categories.entries()) {
+ sessionDataPromises.push(
+ this.handleCommand({
+ moduleName,
+ commandName: "_applySessionData",
+ params: {
+ category,
+ sessionData: Array.from(relevantSessionData),
+ },
+ destination,
+ })
+ );
+ }
+ }
+
+ await Promise.all(sessionDataPromises);
+ }
+
+ forwardCommand(command) {
+ switch (command.destination.type) {
+ case lazy.RootMessageHandler.type:
+ return lazy
+ .getMessageHandlerFrameChildActor(this)
+ .sendCommand(command, this.sessionId);
+ default:
+ throw new Error(
+ `Cannot forward command to "${command.destination.type}" from "${this.constructor.type}".`
+ );
+ }
+ }
+
+ /**
+ * If <var>realmId</var> is null or not provided get the realm for
+ * a given <var>sandboxName</var>, otherwise find the realm
+ * in the cache with the realm id equal given <var>realmId</var>.
+ *
+ * @param {object} options
+ * @param {string|null=} options.realmId
+ * The realm id.
+ * @param {string=} options.sandboxName
+ * The name of sandbox
+ *
+ * @returns {Realm}
+ * The realm object.
+ */
+ getRealm(options = {}) {
+ const { realmId = null, sandboxName } = options;
+ if (realmId === null) {
+ return this.#getRealmFromSandboxName(sandboxName);
+ }
+
+ const realm = Array.from(this.#realms.values()).find(
+ realm => realm.id === realmId
+ );
+
+ if (realm) {
+ return realm;
+ }
+
+ throw new lazy.error.NoSuchFrameError(`Realm with id ${realmId} not found`);
+ }
+
+ matchesContext(contextDescriptor) {
+ return (
+ contextDescriptor.type === ContextDescriptorType.All ||
+ (contextDescriptor.type === ContextDescriptorType.TopBrowsingContext &&
+ contextDescriptor.id === this.context.browserId)
+ );
+ }
+
+ /**
+ * Send a command to the root MessageHandler.
+ *
+ * @param {Command} command
+ * The command to send to the root MessageHandler.
+ * @returns {Promise}
+ * A promise which resolves with the return value of the command.
+ */
+ sendRootCommand(command) {
+ return this.handleCommand({
+ ...command,
+ destination: {
+ type: lazy.RootMessageHandler.type,
+ },
+ });
+ }
+}
diff --git a/remote/shared/messagehandler/sessiondata/SessionData.sys.mjs b/remote/shared/messagehandler/sessiondata/SessionData.sys.mjs
new file mode 100644
index 0000000000..10da617f77
--- /dev/null
+++ b/remote/shared/messagehandler/sessiondata/SessionData.sys.mjs
@@ -0,0 +1,392 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ContextDescriptorType:
+ "chrome://remote/content/shared/messagehandler/MessageHandler.sys.mjs",
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+ RootMessageHandler:
+ "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs",
+ WindowGlobalMessageHandler:
+ "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
+
+/**
+ * @typedef {string} SessionDataCategory
+ */
+
+/**
+ * Enum of session data categories.
+ *
+ * @readonly
+ * @enum {SessionDataCategory}
+ */
+export const SessionDataCategory = {
+ Event: "event",
+ PreloadScript: "preload-script",
+};
+
+/**
+ * @typedef {string} SessionDataMethod
+ */
+
+/**
+ * Enum of session data methods.
+ *
+ * @readonly
+ * @enum {SessionDataMethod}
+ */
+export const SessionDataMethod = {
+ Add: "add",
+ Remove: "remove",
+};
+
+export const SESSION_DATA_SHARED_DATA_KEY = "MessageHandlerSessionData";
+
+// This is a map from session id to session data, which will be persisted and
+// propagated to all processes using Services' sharedData.
+// We have to store this as a unique object under a unique shared data key
+// because new MessageHandlers in other processes will need to access this data
+// without any notion of a specific session.
+// This is a singleton.
+const sessionDataMap = new Map();
+
+/**
+ * @typedef {object} SessionDataItem
+ * @property {string} moduleName
+ * The name of the module responsible for this data item.
+ * @property {SessionDataCategory} category
+ * The category of data. The supported categories depend on the module.
+ * @property {(string|number|boolean)} value
+ * Value of the session data item.
+ * @property {ContextDescriptor} contextDescriptor
+ * ContextDescriptor to which this session data applies.
+ */
+
+/**
+ * @typedef SessionDataItemUpdate
+ * @property {SessionDataMethod} method
+ * The way sessionData is updated.
+ * @property {string} moduleName
+ * The name of the module responsible for this data item.
+ * @property {SessionDataCategory} category
+ * The category of data. The supported categories depend on the module.
+ * @property {Array<(string|number|boolean)>} values
+ * Values of the session data item update.
+ * @property {ContextDescriptor} contextDescriptor
+ * ContextDescriptor to which this session data applies.
+ */
+
+/**
+ * SessionData provides APIs to read and write the session data for a specific
+ * ROOT message handler. It holds the session data as a property and acts as the
+ * source of truth for this session data.
+ *
+ * The session data of a given message handler network should contain all the
+ * information that might be needed to setup new contexts, for instance a list
+ * of subscribed events, a list of breakpoints etc.
+ *
+ * The actual session data is an array of SessionDataItems. Example below:
+ * ```
+ * data: [
+ * {
+ * moduleName: "log",
+ * category: "event",
+ * value: "log.entryAdded",
+ * contextDescriptor: { type: "all" }
+ * },
+ * {
+ * moduleName: "browsingContext",
+ * category: "event",
+ * value: "browsingContext.contextCreated",
+ * contextDescriptor: { type: "browser-element", id: "7"}
+ * },
+ * {
+ * moduleName: "browsingContext",
+ * category: "event",
+ * value: "browsingContext.contextCreated",
+ * contextDescriptor: { type: "browser-element", id: "12"}
+ * },
+ * ]
+ * ```
+ *
+ * The session data will be persisted using Services.ppmm.sharedData, so that
+ * new contexts living in different processes can also access the information
+ * during their startup.
+ *
+ * This class should only be used from a ROOT MessageHandler, or from modules
+ * owned by a ROOT MessageHandler. Other MessageHandlers should rely on
+ * SessionDataReader's readSessionData to get read-only access to session data.
+ *
+ */
+export class SessionData {
+ constructor(messageHandler) {
+ if (messageHandler.constructor.type != lazy.RootMessageHandler.type) {
+ throw new Error(
+ "SessionData should only be used from a ROOT MessageHandler"
+ );
+ }
+
+ this._messageHandler = messageHandler;
+
+ /*
+ * The actual data for this session. This is an array of SessionDataItems.
+ */
+ this._data = [];
+ }
+
+ destroy() {
+ // Update the sessionDataMap singleton.
+ sessionDataMap.delete(this._messageHandler.sessionId);
+
+ // Update sharedData and flush to force consistency.
+ Services.ppmm.sharedData.set(SESSION_DATA_SHARED_DATA_KEY, sessionDataMap);
+ Services.ppmm.sharedData.flush();
+ }
+
+ /**
+ * Update session data items of a given module, category and
+ * contextDescriptor.
+ *
+ * A SessionDataItem will be added or removed for each value of each update
+ * in the provided array.
+ *
+ * Attempting to add a duplicate SessionDataItem or to remove an unknown
+ * SessionDataItem will be silently skipped (no-op).
+ *
+ * The data will be persisted across processes at the end of this method.
+ *
+ * @param {Array<SessionDataItemUpdate>} sessionDataItemUpdates
+ * Array of session data item updates.
+ *
+ * @returns {Array<SessionDataItemUpdate>}
+ * The subset of session data item updates which want to be applied.
+ */
+ applySessionData(sessionDataItemUpdates = []) {
+ // The subset of session data item updates, which are cleaned up from
+ // duplicates and unknown items.
+ let updates = [];
+ for (const sessionDataItemUpdate of sessionDataItemUpdates) {
+ const { category, contextDescriptor, method, moduleName, values } =
+ sessionDataItemUpdate;
+ const updatedValues = [];
+ for (const value of values) {
+ const item = { moduleName, category, contextDescriptor, value };
+
+ if (method === SessionDataMethod.Add) {
+ const hasItem = this._findIndex(item) != -1;
+
+ if (!hasItem) {
+ this._data.push(item);
+ updatedValues.push(value);
+ } else {
+ lazy.logger.warn(
+ `Duplicated session data item was not added: ${JSON.stringify(
+ item
+ )}`
+ );
+ }
+ } else {
+ const itemIndex = this._findIndex(item);
+
+ if (itemIndex != -1) {
+ // The item was found in the session data, remove it.
+ this._data.splice(itemIndex, 1);
+ updatedValues.push(value);
+ } else {
+ lazy.logger.warn(
+ `Missing session data item was not removed: ${JSON.stringify(
+ item
+ )}`
+ );
+ }
+ }
+ }
+
+ if (updatedValues.length) {
+ updates.push({
+ ...sessionDataItemUpdate,
+ values: updatedValues,
+ });
+ }
+ }
+ // Persist the sessionDataMap.
+ this._persist();
+
+ return updates;
+ }
+
+ /**
+ * Retrieve the SessionDataItems for a given module and type.
+ *
+ * @param {string} moduleName
+ * The name of the module responsible for this data item.
+ * @param {string} category
+ * The session data category.
+ * @param {ContextDescriptor=} contextDescriptor
+ * Optional context descriptor, to retrieve only session data items added
+ * for a specific context descriptor.
+ * @returns {Array<SessionDataItem>}
+ * Array of SessionDataItems for the provided module and type.
+ */
+ getSessionData(moduleName, category, contextDescriptor) {
+ return this._data.filter(
+ item =>
+ item.moduleName === moduleName &&
+ item.category === category &&
+ (!contextDescriptor ||
+ this._isSameContextDescriptor(
+ item.contextDescriptor,
+ contextDescriptor
+ ))
+ );
+ }
+
+ /**
+ * Update session data items of a given module, category and
+ * contextDescriptor and propagate the information
+ * via a command to existing MessageHandlers.
+ *
+ * @param {Array<SessionDataItemUpdate>} sessionDataItemUpdates
+ * Array of session data item updates.
+ */
+ async updateSessionData(sessionDataItemUpdates = []) {
+ const updates = this.applySessionData(sessionDataItemUpdates);
+
+ if (!updates.length) {
+ // Avoid unnecessary broadcast if no items were updated.
+ return;
+ }
+
+ // Create a Map with the structure moduleName -> category -> list of descriptors.
+ const structuredUpdates = new Map();
+ for (const { moduleName, category, contextDescriptor } of updates) {
+ if (!structuredUpdates.has(moduleName)) {
+ structuredUpdates.set(moduleName, new Map());
+ }
+ if (!structuredUpdates.get(moduleName).has(category)) {
+ structuredUpdates.get(moduleName).set(category, new Set());
+ }
+ const descriptors = structuredUpdates.get(moduleName).get(category);
+ // If there is at least one update for all contexts,
+ // keep only this descriptor in the list of descriptors
+ if (contextDescriptor.type === lazy.ContextDescriptorType.All) {
+ structuredUpdates
+ .get(moduleName)
+ .set(category, new Set([contextDescriptor]));
+ }
+ // Add an individual descriptor if there is no descriptor for all contexts.
+ else if (
+ descriptors.size !== 1 ||
+ Array.from(descriptors)[0]?.type !== lazy.ContextDescriptorType.All
+ ) {
+ descriptors.add(contextDescriptor);
+ }
+ }
+
+ const rootDestination = {
+ type: lazy.RootMessageHandler.type,
+ };
+ const sessionDataPromises = [];
+
+ for (const [moduleName, categories] of structuredUpdates.entries()) {
+ for (const [category, contextDescriptors] of categories.entries()) {
+ // Find sessionData for the category and the moduleName.
+ const relevantSessionData = this._data.filter(
+ item => item.category == category && item.moduleName === moduleName
+ );
+ for (const contextDescriptor of contextDescriptors.values()) {
+ const windowGlobalDestination = {
+ type: lazy.WindowGlobalMessageHandler.type,
+ contextDescriptor,
+ };
+
+ for (const destination of [
+ windowGlobalDestination,
+ rootDestination,
+ ]) {
+ // Only apply session data if the module is present for the destination.
+ if (
+ this._messageHandler.supportsCommand(
+ moduleName,
+ "_applySessionData",
+ destination
+ )
+ ) {
+ sessionDataPromises.push(
+ this._messageHandler
+ .handleCommand({
+ moduleName,
+ commandName: "_applySessionData",
+ params: {
+ sessionData: relevantSessionData,
+ category,
+ contextDescriptor,
+ },
+ destination,
+ })
+ ?.catch(reason =>
+ lazy.logger.error(
+ `_applySessionData for module: ${moduleName} failed, reason: ${reason}`
+ )
+ )
+ );
+ }
+ }
+ }
+ }
+ }
+
+ await Promise.allSettled(sessionDataPromises);
+ }
+
+ _isSameItem(item1, item2) {
+ const descriptor1 = item1.contextDescriptor;
+ const descriptor2 = item2.contextDescriptor;
+
+ return (
+ item1.moduleName === item2.moduleName &&
+ item1.category === item2.category &&
+ this._isSameContextDescriptor(descriptor1, descriptor2) &&
+ this._isSameValue(item1.category, item1.value, item2.value)
+ );
+ }
+
+ _isSameContextDescriptor(contextDescriptor1, contextDescriptor2) {
+ if (contextDescriptor1.type === lazy.ContextDescriptorType.All) {
+ // Ignore the id for type "all" since we made the id optional for this type.
+ return contextDescriptor1.type === contextDescriptor2.type;
+ }
+
+ return (
+ contextDescriptor1.type === contextDescriptor2.type &&
+ contextDescriptor1.id === contextDescriptor2.id
+ );
+ }
+
+ _isSameValue(category, value1, value2) {
+ if (category === SessionDataCategory.PreloadScript) {
+ return value1.script === value2.script;
+ }
+
+ return value1 === value2;
+ }
+
+ _findIndex(item) {
+ return this._data.findIndex(_item => this._isSameItem(item, _item));
+ }
+
+ _persist() {
+ // Update the sessionDataMap singleton.
+ sessionDataMap.set(this._messageHandler.sessionId, this._data);
+
+ // Update sharedData and flush to force consistency.
+ Services.ppmm.sharedData.set(SESSION_DATA_SHARED_DATA_KEY, sessionDataMap);
+ Services.ppmm.sharedData.flush();
+ }
+}
diff --git a/remote/shared/messagehandler/sessiondata/SessionDataReader.sys.mjs b/remote/shared/messagehandler/sessiondata/SessionDataReader.sys.mjs
new file mode 100644
index 0000000000..6d5ea08e59
--- /dev/null
+++ b/remote/shared/messagehandler/sessiondata/SessionDataReader.sys.mjs
@@ -0,0 +1,27 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ SESSION_DATA_SHARED_DATA_KEY:
+ "chrome://remote/content/shared/messagehandler/sessiondata/SessionData.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "sharedData", () => {
+ const isInParent =
+ Services.appinfo.processType == Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT;
+
+ return isInParent ? Services.ppmm.sharedData : Services.cpmm.sharedData;
+});
+
+/**
+ * Returns a snapshot of the session data map, which is cloned from the
+ * sessionDataMap singleton of SessionData.jsm.
+ *
+ * @returns {Map.<string, Array<SessionDataItem>>}
+ * Map of session id to arrays of SessionDataItems.
+ */
+export const readSessionData = () =>
+ lazy.sharedData.get(lazy.SESSION_DATA_SHARED_DATA_KEY) || new Map();
diff --git a/remote/shared/messagehandler/test/browser/broadcast/browser.toml b/remote/shared/messagehandler/test/browser/broadcast/browser.toml
new file mode 100644
index 0000000000..f18bfdaab2
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/broadcast/browser.toml
@@ -0,0 +1,22 @@
+[DEFAULT]
+tags = "remote"
+subsuite = "remote"
+support-files = [
+ "doc_messagehandler_broadcasting_xul.xhtml",
+ "head.js",
+ "!/remote/shared/messagehandler/test/browser/head.js",
+ "!/remote/shared/messagehandler/test/browser/resources/*"
+]
+prefs = ["remote.messagehandler.modulecache.useBrowserTestRoot=true"]
+
+["browser_filter_top_browsing_context.js"]
+
+["browser_only_content_process.js"]
+
+["browser_two_tabs.js"]
+
+["browser_two_tabs_with_params.js"]
+
+["browser_two_windows.js"]
+
+["browser_with_frames.js"]
diff --git a/remote/shared/messagehandler/test/browser/broadcast/browser_filter_top_browsing_context.js b/remote/shared/messagehandler/test/browser/broadcast/browser_filter_top_browsing_context.js
new file mode 100644
index 0000000000..c140c26fc6
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/broadcast/browser_filter_top_browsing_context.js
@@ -0,0 +1,84 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const COM_TEST_PAGE = "https://example.com/document-builder.sjs?html=COM";
+const FRAME_TEST_PAGE = createTestMarkupWithFrames();
+
+add_task(async function test_broadcasting_filter_top_browsing_context() {
+ info("Navigate the initial tab to the COM test URL");
+ const tab1 = gBrowser.selectedTab;
+ await loadURL(tab1.linkedBrowser, COM_TEST_PAGE);
+ const browsingContext1 = tab1.linkedBrowser.browsingContext;
+
+ info("Open a second tab on the frame test URL");
+ const tab2 = await addTab(FRAME_TEST_PAGE);
+ const browsingContext2 = tab2.linkedBrowser.browsingContext;
+
+ const contextsForTab2 =
+ tab2.linkedBrowser.browsingContext.getAllBrowsingContextsInSubtree();
+ is(
+ contextsForTab2.length,
+ 4,
+ "Frame test tab has 3 children contexts (4 in total)"
+ );
+
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-broadcasting_filter_top_browsing_context"
+ );
+
+ const broadcastValue1 = await sendBroadcastForTopBrowsingContext(
+ browsingContext1,
+ rootMessageHandler
+ );
+
+ ok(
+ Array.isArray(broadcastValue1),
+ "The broadcast returned an array of values"
+ );
+
+ is(broadcastValue1.length, 1, "The broadcast returned one value as expected");
+
+ ok(
+ broadcastValue1.includes("broadcast-" + browsingContext1.id),
+ "The broadcast returned the expected value from tab1"
+ );
+
+ const broadcastValue2 = await sendBroadcastForTopBrowsingContext(
+ browsingContext2,
+ rootMessageHandler
+ );
+
+ ok(
+ Array.isArray(broadcastValue2),
+ "The broadcast returned an array of values"
+ );
+
+ is(broadcastValue2.length, 4, "The broadcast returned 4 values as expected");
+
+ for (const context of contextsForTab2) {
+ ok(
+ broadcastValue2.includes("broadcast-" + context.id),
+ "The broadcast contains the value for browsing context " + context.id
+ );
+ }
+
+ rootMessageHandler.destroy();
+});
+
+function sendBroadcastForTopBrowsingContext(
+ topBrowsingContext,
+ rootMessageHandler
+) {
+ return sendTestBroadcastCommand(
+ "commandwindowglobalonly",
+ "testBroadcast",
+ {},
+ {
+ type: ContextDescriptorType.TopBrowsingContext,
+ id: topBrowsingContext.browserId,
+ },
+ rootMessageHandler
+ );
+}
diff --git a/remote/shared/messagehandler/test/browser/broadcast/browser_only_content_process.js b/remote/shared/messagehandler/test/browser/broadcast/browser_only_content_process.js
new file mode 100644
index 0000000000..d5090c701e
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/broadcast/browser_only_content_process.js
@@ -0,0 +1,46 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_broadcasting_only_content_process() {
+ info("Navigate the initial tab to the test URL");
+ const tab1 = gBrowser.selectedTab;
+ await loadURL(
+ tab1.linkedBrowser,
+ "https://example.com/document-builder.sjs?html=tab"
+ );
+ const browsingContext1 = tab1.linkedBrowser.browsingContext;
+
+ info("Open a new tab on a parent process about: page");
+ await addTab("about:robots");
+
+ info("Open a new tab on a XUL page");
+ await addTab(
+ getRootDirectory(gTestPath) + "doc_messagehandler_broadcasting_xul.xhtml"
+ );
+
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-broadcasting_only_content_process"
+ );
+ const broadcastValue = await sendTestBroadcastCommand(
+ "commandwindowglobalonly",
+ "testBroadcast",
+ {},
+ contextDescriptorAll,
+ rootMessageHandler
+ );
+
+ ok(
+ Array.isArray(broadcastValue),
+ "The broadcast returned an array of values"
+ );
+
+ is(broadcastValue.length, 1, "The broadcast returned 1 value as expected");
+ ok(
+ broadcastValue.includes("broadcast-" + browsingContext1.id),
+ "The broadcast returned the expected value from tab1"
+ );
+
+ rootMessageHandler.destroy();
+});
diff --git a/remote/shared/messagehandler/test/browser/broadcast/browser_two_tabs.js b/remote/shared/messagehandler/test/browser/broadcast/browser_two_tabs.js
new file mode 100644
index 0000000000..16b97e2a0a
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/broadcast/browser_two_tabs.js
@@ -0,0 +1,46 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_PAGE = "https://example.com/document-builder.sjs?html=tab";
+
+add_task(async function test_broadcasting_two_tabs_command() {
+ info("Navigate the initial tab to the test URL");
+ const tab1 = gBrowser.selectedTab;
+ await loadURL(tab1.linkedBrowser, TEST_PAGE);
+ const browsingContext1 = tab1.linkedBrowser.browsingContext;
+
+ info("Open a new tab on the same test URL");
+ const tab2 = await addTab(TEST_PAGE);
+ const browsingContext2 = tab2.linkedBrowser.browsingContext;
+
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-broadcasting_two_tabs_command"
+ );
+
+ const broadcastValue = await sendTestBroadcastCommand(
+ "commandwindowglobalonly",
+ "testBroadcast",
+ {},
+ contextDescriptorAll,
+ rootMessageHandler
+ );
+
+ ok(
+ Array.isArray(broadcastValue),
+ "The broadcast returned an array of values"
+ );
+
+ is(broadcastValue.length, 2, "The broadcast returned 2 values as expected");
+
+ ok(
+ broadcastValue.includes("broadcast-" + browsingContext1.id),
+ "The broadcast returned the expected value from tab1"
+ );
+ ok(
+ broadcastValue.includes("broadcast-" + browsingContext2.id),
+ "The broadcast returned the expected value from tab2"
+ );
+ rootMessageHandler.destroy();
+});
diff --git a/remote/shared/messagehandler/test/browser/broadcast/browser_two_tabs_with_params.js b/remote/shared/messagehandler/test/browser/broadcast/browser_two_tabs_with_params.js
new file mode 100644
index 0000000000..261b8c4cd6
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/broadcast/browser_two_tabs_with_params.js
@@ -0,0 +1,47 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_PAGE = "https://example.com/document-builder.sjs?html=tab";
+
+add_task(async function test_broadcasting_two_tabs_with_params_command() {
+ info("Navigate the initial tab to the test URL");
+ const tab1 = gBrowser.selectedTab;
+ await loadURL(tab1.linkedBrowser, TEST_PAGE);
+ const browsingContext1 = tab1.linkedBrowser.browsingContext;
+
+ info("Open a new tab on the same test URL");
+ const tab2 = await addTab(TEST_PAGE);
+ const browsingContext2 = tab2.linkedBrowser.browsingContext;
+
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-broadcasting_two_tabs_command"
+ );
+
+ const broadcastValue = await sendTestBroadcastCommand(
+ "commandwindowglobalonly",
+ "testBroadcastWithParameter",
+ {
+ value: "some-value",
+ },
+ contextDescriptorAll,
+ rootMessageHandler
+ );
+ ok(
+ Array.isArray(broadcastValue),
+ "The broadcast returned an array of values"
+ );
+
+ is(broadcastValue.length, 2, "The broadcast returned 2 values as expected");
+
+ ok(
+ broadcastValue.includes("broadcast-" + browsingContext1.id + "-some-value"),
+ "The broadcast returned the expected value from tab1"
+ );
+ ok(
+ broadcastValue.includes("broadcast-" + browsingContext2.id + "-some-value"),
+ "The broadcast returned the expected value from tab2"
+ );
+ rootMessageHandler.destroy();
+});
diff --git a/remote/shared/messagehandler/test/browser/broadcast/browser_two_windows.js b/remote/shared/messagehandler/test/browser/broadcast/browser_two_windows.js
new file mode 100644
index 0000000000..f59bebba69
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/broadcast/browser_two_windows.js
@@ -0,0 +1,47 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_PAGE = "https://example.com/document-builder.sjs?html=tab";
+
+add_task(async function test_broadcasting_two_windows_command() {
+ const window1Browser = gBrowser.selectedTab.linkedBrowser;
+ await loadURL(window1Browser, TEST_PAGE);
+ const browsingContext1 = window1Browser.browsingContext;
+
+ const window2 = await BrowserTestUtils.openNewBrowserWindow();
+ registerCleanupFunction(() => BrowserTestUtils.closeWindow(window2));
+
+ const window2Browser = window2.gBrowser.selectedBrowser;
+ await loadURL(window2Browser, TEST_PAGE);
+ const browsingContext2 = window2Browser.browsingContext;
+
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-broadcasting_two_windows_command"
+ );
+ const broadcastValue = await sendTestBroadcastCommand(
+ "commandwindowglobalonly",
+ "testBroadcast",
+ {},
+ contextDescriptorAll,
+ rootMessageHandler
+ );
+
+ ok(
+ Array.isArray(broadcastValue),
+ "The broadcast returned an array of values"
+ );
+ is(broadcastValue.length, 2, "The broadcast returned 2 values as expected");
+
+ ok(
+ broadcastValue.includes("broadcast-" + browsingContext1.id),
+ "The broadcast returned the expected value from tab1"
+ );
+ ok(
+ broadcastValue.includes("broadcast-" + browsingContext2.id),
+ "The broadcast returned the expected value from tab2"
+ );
+
+ rootMessageHandler.destroy();
+});
diff --git a/remote/shared/messagehandler/test/browser/broadcast/browser_with_frames.js b/remote/shared/messagehandler/test/browser/broadcast/browser_with_frames.js
new file mode 100644
index 0000000000..50326d3885
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/broadcast/browser_with_frames.js
@@ -0,0 +1,40 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_broadcasting_with_frames() {
+ info("Navigate the initial tab to the test URL");
+ const tab = gBrowser.selectedTab;
+ await loadURL(tab.linkedBrowser, createTestMarkupWithFrames());
+
+ const contexts =
+ tab.linkedBrowser.browsingContext.getAllBrowsingContextsInSubtree();
+ is(contexts.length, 4, "Test tab has 3 children contexts (4 in total)");
+
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-broadcasting_with_frames"
+ );
+ const broadcastValue = await sendTestBroadcastCommand(
+ "commandwindowglobalonly",
+ "testBroadcast",
+ {},
+ contextDescriptorAll,
+ rootMessageHandler
+ );
+
+ ok(
+ Array.isArray(broadcastValue),
+ "The broadcast returned an array of values"
+ );
+ is(broadcastValue.length, 4, "The broadcast returned 4 values as expected");
+
+ for (const context of contexts) {
+ ok(
+ broadcastValue.includes("broadcast-" + context.id),
+ "The broadcast contains the value for browsing context " + context.id
+ );
+ }
+
+ rootMessageHandler.destroy();
+});
diff --git a/remote/shared/messagehandler/test/browser/broadcast/doc_messagehandler_broadcasting_xul.xhtml b/remote/shared/messagehandler/test/browser/broadcast/doc_messagehandler_broadcasting_xul.xhtml
new file mode 100644
index 0000000000..91f3503ac3
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/broadcast/doc_messagehandler_broadcasting_xul.xhtml
@@ -0,0 +1,3 @@
+<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+ <box id="box" style="background-color: red;">Test chrome broadcasting</box>
+</window>
diff --git a/remote/shared/messagehandler/test/browser/broadcast/head.js b/remote/shared/messagehandler/test/browser/broadcast/head.js
new file mode 100644
index 0000000000..eb97549c26
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/broadcast/head.js
@@ -0,0 +1,48 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/remote/shared/messagehandler/test/browser/head.js",
+ this
+);
+
+/**
+ * Broadcast the provided method to WindowGlobal contexts on a MessageHandler
+ * network.
+ * Returns a promise which will resolve the result of the command broadcast.
+ *
+ * @param {string} module
+ * The name of the module implementing the command to broadcast.
+ * @param {string} command
+ * The name of the command to broadcast.
+ * @param {object} params
+ * The parameters for the command.
+ * @param {ContextDescriptor} contextDescriptor
+ * The context descriptor to use for this broadcast
+ * @param {RootMessageHandler} rootMessageHandler
+ * The root of the MessageHandler network.
+ * @returns {Promise.<Array>}
+ * Promise which resolves an array where each item is the result of the
+ * command handled by an individual context.
+ */
+function sendTestBroadcastCommand(
+ module,
+ command,
+ params,
+ contextDescriptor,
+ rootMessageHandler
+) {
+ info("Send a test broadcast command");
+ return rootMessageHandler.handleCommand({
+ moduleName: module,
+ commandName: command,
+ params,
+ destination: {
+ contextDescriptor,
+ type: WindowGlobalMessageHandler.type,
+ },
+ });
+}
diff --git a/remote/shared/messagehandler/test/browser/browser.toml b/remote/shared/messagehandler/test/browser/browser.toml
new file mode 100644
index 0000000000..ffbc880a0a
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/browser.toml
@@ -0,0 +1,46 @@
+[DEFAULT]
+tags = "remote"
+subsuite = "remote"
+support-files = [
+ "head.js",
+ "resources/*"
+]
+prefs = ["remote.messagehandler.modulecache.useBrowserTestRoot=true"]
+
+["browser_bfcache.js"]
+
+["browser_events_dispatcher.js"]
+
+["browser_events_handler.js"]
+
+["browser_events_interception.js"]
+
+["browser_events_module.js"]
+
+["browser_frame_context_utils.js"]
+
+["browser_handle_command_errors.js"]
+
+["browser_handle_command_retry.js"]
+
+["browser_handle_simple_command.js"]
+
+["browser_navigation_manager.js"]
+
+["browser_realms.js"]
+
+["browser_registry.js"]
+
+["browser_session_data.js"]
+
+["browser_session_data_browser_element.js"]
+
+["browser_session_data_constructor_race.js"]
+
+["browser_session_data_update.js"]
+
+["browser_session_data_update_categories.js"]
+
+["browser_session_data_update_contexts.js"]
+
+["browser_windowglobal_to_root.js"]
diff --git a/remote/shared/messagehandler/test/browser/browser_bfcache.js b/remote/shared/messagehandler/test/browser/browser_bfcache.js
new file mode 100644
index 0000000000..f829d8b58d
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/browser_bfcache.js
@@ -0,0 +1,98 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { RootMessageHandler } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs"
+);
+
+const TEST_PREF = "remote.messagehandler.test.pref";
+
+// Check that pages in bfcache no longer have message handlers attached to them,
+// and that they will not emit unexpected events.
+add_task(async function test_bfcache_broadcast() {
+ const tab = await addTab("https://example.com/document-builder.sjs?html=tab");
+ const rootMessageHandler = createRootMessageHandler("session-id-bfcache");
+
+ try {
+ const browsingContext = tab.linkedBrowser.browsingContext;
+ const contextDescriptor = {
+ type: ContextDescriptorType.TopBrowsingContext,
+ id: browsingContext.browserId,
+ };
+
+ // Whenever a "preference-changed" event from the eventonprefchange module
+ // will be received on the root MessageHandler, increment a counter.
+ let preferenceChangeEventCount = 0;
+ const onEvent = (evtName, wrappedEvt) => {
+ if (wrappedEvt.name === "preference-changed") {
+ preferenceChangeEventCount++;
+ }
+ };
+ rootMessageHandler.on("message-handler-event", onEvent);
+
+ // Initialize the preference, no eventonprefchange module should be created
+ // yet so preferenceChangeEventCount is not expected to be updated.
+ Services.prefs.setIntPref(TEST_PREF, 0);
+ await TestUtils.waitForCondition(() => preferenceChangeEventCount >= 0);
+ is(preferenceChangeEventCount, 0);
+
+ // Broadcast a "ping" command to force the creation of the eventonprefchange
+ // module
+ let values = await sendPingCommand(rootMessageHandler, contextDescriptor);
+ is(values.length, 1, "Broadcast returned a single value");
+
+ Services.prefs.setIntPref(TEST_PREF, 1);
+ await TestUtils.waitForCondition(() => preferenceChangeEventCount >= 1);
+ is(preferenceChangeEventCount, 1);
+
+ info("Navigate to another page");
+ await loadURL(
+ tab.linkedBrowser,
+ "https://example.com/document-builder.sjs?html=othertab"
+ );
+
+ values = await sendPingCommand(rootMessageHandler, contextDescriptor);
+ is(values.length, 1, "Broadcast returned a single value after navigation");
+
+ info("Update the preference and check we only receive 1 event");
+ Services.prefs.setIntPref(TEST_PREF, 2);
+ await TestUtils.waitForCondition(() => preferenceChangeEventCount >= 2);
+ is(preferenceChangeEventCount, 2);
+
+ info("Navigate to another origin");
+ await loadURL(
+ tab.linkedBrowser,
+ "https://example.org/document-builder.sjs?html=otherorigin"
+ );
+
+ values = await sendPingCommand(rootMessageHandler, contextDescriptor);
+ is(
+ values.length,
+ 1,
+ "Broadcast returned a single value after cross origin navigation"
+ );
+
+ info("Update the preference and check again that we only receive 1 event");
+ Services.prefs.setIntPref(TEST_PREF, 3);
+ await TestUtils.waitForCondition(() => preferenceChangeEventCount >= 3);
+ is(preferenceChangeEventCount, 3);
+ } finally {
+ rootMessageHandler.destroy();
+ gBrowser.removeTab(tab);
+ Services.prefs.clearUserPref(TEST_PREF);
+ }
+});
+
+function sendPingCommand(rootMessageHandler, contextDescriptor) {
+ return rootMessageHandler.handleCommand({
+ moduleName: "eventonprefchange",
+ commandName: "ping",
+ params: {},
+ destination: {
+ contextDescriptor,
+ type: WindowGlobalMessageHandler.type,
+ },
+ });
+}
diff --git a/remote/shared/messagehandler/test/browser/browser_events_dispatcher.js b/remote/shared/messagehandler/test/browser/browser_events_dispatcher.js
new file mode 100644
index 0000000000..98d9fd2890
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/browser_events_dispatcher.js
@@ -0,0 +1,532 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Check the basic behavior of on/off.
+ */
+add_task(async function test_add_remove_event_listener() {
+ const tab = await addTab("https://example.com/document-builder.sjs?html=tab");
+ const browsingContext = tab.linkedBrowser.browsingContext;
+ const contextDescriptor = {
+ type: ContextDescriptorType.TopBrowsingContext,
+ id: browsingContext.browserId,
+ };
+
+ const root = createRootMessageHandler("session-id-event");
+ const monitoringEvents = await setupEventMonitoring(root);
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(await isSubscribed(root, browsingContext), false);
+
+ info("Add an listener for eventemitter.testEvent");
+ const events = [];
+ const onEvent = (event, data) => events.push(data.text);
+ await root.eventsDispatcher.on(
+ "eventemitter.testEvent",
+ contextDescriptor,
+ onEvent
+ );
+ is(await isSubscribed(root, browsingContext), true);
+
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(events.length, 1);
+
+ info(
+ "Remove a listener for a callback not added before and check that the first one is still registered"
+ );
+ const anotherCallback = () => {};
+ await root.eventsDispatcher.off(
+ "eventemitter.testEvent",
+ contextDescriptor,
+ anotherCallback
+ );
+ is(await isSubscribed(root, browsingContext), true);
+
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(events.length, 2);
+
+ info("Remove the listener for eventemitter.testEvent");
+ await root.eventsDispatcher.off(
+ "eventemitter.testEvent",
+ contextDescriptor,
+ onEvent
+ );
+ is(await isSubscribed(root, browsingContext), false);
+
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(events.length, 2);
+
+ info("Add the listener for eventemitter.testEvent again");
+ await root.eventsDispatcher.on(
+ "eventemitter.testEvent",
+ contextDescriptor,
+ onEvent
+ );
+ is(await isSubscribed(root, browsingContext), true);
+
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(events.length, 3);
+
+ info("Remove the listener for eventemitter.testEvent");
+ await root.eventsDispatcher.off(
+ "eventemitter.testEvent",
+ contextDescriptor,
+ onEvent
+ );
+ is(await isSubscribed(root, browsingContext), false);
+
+ info("Remove the listener again to check the API will not throw");
+ await root.eventsDispatcher.off(
+ "eventemitter.testEvent",
+ contextDescriptor,
+ onEvent
+ );
+
+ root.destroy();
+ gBrowser.removeTab(tab);
+});
+
+add_task(async function test_has_listener() {
+ const tab1 = await addTab("https://example.com/document-builder.sjs?html=1");
+ const browsingContext1 = tab1.linkedBrowser.browsingContext;
+
+ const tab2 = await addTab("https://example.com/document-builder.sjs?html=2");
+ const browsingContext2 = tab2.linkedBrowser.browsingContext;
+
+ const contextDescriptor1 = {
+ type: ContextDescriptorType.TopBrowsingContext,
+ id: browsingContext1.browserId,
+ };
+ const contextDescriptor2 = {
+ type: ContextDescriptorType.TopBrowsingContext,
+ id: browsingContext2.browserId,
+ };
+
+ const root = createRootMessageHandler("session-id-event");
+
+ // Shortcut for the EventsDispatcher.hasListener API.
+ function hasListener(contextId) {
+ return root.eventsDispatcher.hasListener("eventemitter.testEvent", {
+ contextId,
+ });
+ }
+
+ const onEvent = () => {};
+ await root.eventsDispatcher.on(
+ "eventemitter.testEvent",
+ contextDescriptor1,
+ onEvent
+ );
+ ok(hasListener(browsingContext1.id), "Has a listener for browsingContext1");
+ ok(!hasListener(browsingContext2.id), "No listener for browsingContext2");
+
+ await root.eventsDispatcher.on(
+ "eventemitter.testEvent",
+ contextDescriptor2,
+ onEvent
+ );
+ ok(hasListener(browsingContext1.id), "Still a listener for browsingContext1");
+ ok(hasListener(browsingContext2.id), "Has a listener for browsingContext2");
+
+ await root.eventsDispatcher.off(
+ "eventemitter.testEvent",
+ contextDescriptor1,
+ onEvent
+ );
+ ok(!hasListener(browsingContext1.id), "No listener for browsingContext1");
+ ok(hasListener(browsingContext2.id), "Still a listener for browsingContext2");
+
+ await root.eventsDispatcher.off(
+ "eventemitter.testEvent",
+ contextDescriptor2,
+ onEvent
+ );
+ ok(!hasListener(browsingContext1.id), "No listener for browsingContext1");
+ ok(!hasListener(browsingContext2.id), "No listener for browsingContext2");
+
+ await root.eventsDispatcher.on(
+ "eventemitter.testEvent",
+ {
+ type: ContextDescriptorType.All,
+ },
+ onEvent
+ );
+ ok(hasListener(browsingContext1.id), "Has a listener for browsingContext1");
+ ok(hasListener(browsingContext2.id), "Has a listener for browsingContext2");
+
+ await root.eventsDispatcher.off(
+ "eventemitter.testEvent",
+ {
+ type: ContextDescriptorType.All,
+ },
+ onEvent
+ );
+ ok(!hasListener(browsingContext1.id), "No listener for browsingContext1");
+ ok(!hasListener(browsingContext2.id), "No listener for browsingContext2");
+
+ root.destroy();
+ gBrowser.removeTab(tab2);
+ gBrowser.removeTab(tab1);
+});
+
+/**
+ * Check that two callbacks can subscribe to the same event in the same context
+ * in parallel.
+ */
+add_task(async function test_two_callbacks() {
+ const tab = await addTab("https://example.com/document-builder.sjs?html=tab");
+ const browsingContext = tab.linkedBrowser.browsingContext;
+ const contextDescriptor = {
+ type: ContextDescriptorType.TopBrowsingContext,
+ id: browsingContext.browserId,
+ };
+
+ const root = createRootMessageHandler("session-id-event");
+ const monitoringEvents = await setupEventMonitoring(root);
+
+ info("Add an listener for eventemitter.testEvent");
+ const events = [];
+ const onEvent = (event, data) => events.push(data.text);
+ await root.eventsDispatcher.on(
+ "eventemitter.testEvent",
+ contextDescriptor,
+ onEvent
+ );
+
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(events.length, 1);
+
+ info("Add another listener for eventemitter.testEvent");
+ const otherevents = [];
+ const otherCallback = (event, data) => otherevents.push(data.text);
+ await root.eventsDispatcher.on(
+ "eventemitter.testEvent",
+ contextDescriptor,
+ otherCallback
+ );
+ is(await isSubscribed(root, browsingContext), true);
+
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(events.length, 2);
+ is(otherevents.length, 1);
+
+ info("Remove the other listener for eventemitter.testEvent");
+ await root.eventsDispatcher.off(
+ "eventemitter.testEvent",
+ contextDescriptor,
+ otherCallback
+ );
+ is(await isSubscribed(root, browsingContext), true);
+
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(events.length, 3);
+ is(otherevents.length, 1);
+
+ info("Remove the first listener for eventemitter.testEvent");
+ await root.eventsDispatcher.off(
+ "eventemitter.testEvent",
+ contextDescriptor,
+ onEvent
+ );
+ is(await isSubscribed(root, browsingContext), false);
+
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(events.length, 3);
+ is(otherevents.length, 1);
+
+ root.destroy();
+ gBrowser.removeTab(tab);
+});
+
+/**
+ * Check that two callbacks can subscribe to the same event in the two contexts.
+ */
+add_task(async function test_two_contexts() {
+ const tab1 = await addTab("https://example.com/document-builder.sjs?html=1");
+ const browsingContext1 = tab1.linkedBrowser.browsingContext;
+
+ const tab2 = await addTab("https://example.com/document-builder.sjs?html=2");
+ const browsingContext2 = tab2.linkedBrowser.browsingContext;
+
+ const contextDescriptor1 = {
+ type: ContextDescriptorType.TopBrowsingContext,
+ id: browsingContext1.browserId,
+ };
+ const contextDescriptor2 = {
+ type: ContextDescriptorType.TopBrowsingContext,
+ id: browsingContext2.browserId,
+ };
+
+ const root = createRootMessageHandler("session-id-event");
+
+ const monitoringEvents = await setupEventMonitoring(root);
+
+ const events1 = [];
+ const onEvent1 = (event, data) => events1.push(data.text);
+ await root.eventsDispatcher.on(
+ "eventemitter.testEvent",
+ contextDescriptor1,
+ onEvent1
+ );
+ is(await isSubscribed(root, browsingContext1), true);
+ is(await isSubscribed(root, browsingContext2), false);
+
+ const events2 = [];
+ const onEvent2 = (event, data) => events2.push(data.text);
+ await root.eventsDispatcher.on(
+ "eventemitter.testEvent",
+ contextDescriptor2,
+ onEvent2
+ );
+ is(await isSubscribed(root, browsingContext1), true);
+ is(await isSubscribed(root, browsingContext2), true);
+
+ await emitTestEvent(root, browsingContext1, monitoringEvents);
+ is(events1.length, 1);
+ is(events2.length, 0);
+
+ await emitTestEvent(root, browsingContext2, monitoringEvents);
+ is(events1.length, 1);
+ is(events2.length, 1);
+
+ await root.eventsDispatcher.off(
+ "eventemitter.testEvent",
+ contextDescriptor1,
+ onEvent1
+ );
+ is(await isSubscribed(root, browsingContext1), false);
+ is(await isSubscribed(root, browsingContext2), true);
+
+ // No event expected here since the module for browsingContext1 is no longer
+ // subscribed
+ await emitTestEvent(root, browsingContext1, monitoringEvents);
+ is(events1.length, 1);
+ is(events2.length, 1);
+
+ // Whereas the module for browsingContext2 is still subscribed
+ await emitTestEvent(root, browsingContext2, monitoringEvents);
+ is(events1.length, 1);
+ is(events2.length, 2);
+
+ await root.eventsDispatcher.off(
+ "eventemitter.testEvent",
+ contextDescriptor2,
+ onEvent2
+ );
+ is(await isSubscribed(root, browsingContext1), false);
+ is(await isSubscribed(root, browsingContext2), false);
+
+ await emitTestEvent(root, browsingContext1, monitoringEvents);
+ await emitTestEvent(root, browsingContext2, monitoringEvents);
+ is(events1.length, 1);
+ is(events2.length, 2);
+
+ root.destroy();
+ gBrowser.removeTab(tab2);
+ gBrowser.removeTab(tab1);
+});
+
+/**
+ * Check that adding and removing first listener for the specific context and then
+ * for the global context works as expected.
+ */
+add_task(
+ async function test_remove_context_event_listener_and_then_global_event_listener() {
+ const tab = await addTab(
+ "https://example.com/document-builder.sjs?html=tab"
+ );
+ const browsingContext = tab.linkedBrowser.browsingContext;
+ const contextDescriptor = {
+ type: ContextDescriptorType.TopBrowsingContext,
+ id: browsingContext.browserId,
+ };
+ const contextDescriptorAll = {
+ type: ContextDescriptorType.All,
+ };
+
+ const root = createRootMessageHandler("session-id-event");
+ const monitoringEvents = await setupEventMonitoring(root);
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(await isSubscribed(root, browsingContext), false);
+
+ info("Add an listener for eventemitter.testEvent");
+ const events = [];
+ const onEvent = (event, data) => events.push(data.text);
+ await root.eventsDispatcher.on(
+ "eventemitter.testEvent",
+ contextDescriptor,
+ onEvent
+ );
+ is(await isSubscribed(root, browsingContext), true);
+
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(events.length, 1);
+
+ info(
+ "Add another listener for eventemitter.testEvent, using global context"
+ );
+ const eventsAll = [];
+ const onEventAll = (event, data) => eventsAll.push(data.text);
+ await root.eventsDispatcher.on(
+ "eventemitter.testEvent",
+ contextDescriptorAll,
+ onEventAll
+ );
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(eventsAll.length, 1);
+ is(events.length, 2);
+
+ info("Remove the first listener for eventemitter.testEvent");
+ await root.eventsDispatcher.off(
+ "eventemitter.testEvent",
+ contextDescriptor,
+ onEvent
+ );
+
+ info("Check that we are still subscribed to eventemitter.testEvent");
+ is(await isSubscribed(root, browsingContext), true);
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(eventsAll.length, 2);
+ is(events.length, 2);
+
+ await root.eventsDispatcher.off(
+ "eventemitter.testEvent",
+ contextDescriptorAll,
+ onEventAll
+ );
+ is(await isSubscribed(root, browsingContext), false);
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(eventsAll.length, 2);
+ is(events.length, 2);
+
+ root.destroy();
+ gBrowser.removeTab(tab);
+ }
+);
+
+/**
+ * Check that adding and removing first listener for the global context and then
+ * for the specific context works as expected.
+ */
+add_task(
+ async function test_global_event_listener_and_then_remove_context_event_listener() {
+ const tab = await addTab(
+ "https://example.com/document-builder.sjs?html=tab"
+ );
+ const browsingContext = tab.linkedBrowser.browsingContext;
+ const contextDescriptor = {
+ type: ContextDescriptorType.TopBrowsingContext,
+ id: browsingContext.browserId,
+ };
+ const contextDescriptorAll = {
+ type: ContextDescriptorType.All,
+ };
+
+ const root = createRootMessageHandler("session-id-event");
+ const monitoringEvents = await setupEventMonitoring(root);
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(await isSubscribed(root, browsingContext), false);
+
+ info("Add an listener for eventemitter.testEvent");
+ const events = [];
+ const onEvent = (event, data) => events.push(data.text);
+ await root.eventsDispatcher.on(
+ "eventemitter.testEvent",
+ contextDescriptor,
+ onEvent
+ );
+ is(await isSubscribed(root, browsingContext), true);
+
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(events.length, 1);
+
+ info(
+ "Add another listener for eventemitter.testEvent, using global context"
+ );
+ const eventsAll = [];
+ const onEventAll = (event, data) => eventsAll.push(data.text);
+ await root.eventsDispatcher.on(
+ "eventemitter.testEvent",
+ contextDescriptorAll,
+ onEventAll
+ );
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(eventsAll.length, 1);
+ is(events.length, 2);
+
+ info("Remove the global listener for eventemitter.testEvent");
+ await root.eventsDispatcher.off(
+ "eventemitter.testEvent",
+ contextDescriptorAll,
+ onEventAll
+ );
+
+ info(
+ "Check that we are still subscribed to eventemitter.testEvent for the specific context"
+ );
+ is(await isSubscribed(root, browsingContext), true);
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(eventsAll.length, 1);
+ is(events.length, 3);
+
+ await root.eventsDispatcher.off(
+ "eventemitter.testEvent",
+ contextDescriptor,
+ onEvent
+ );
+
+ is(await isSubscribed(root, browsingContext), false);
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(eventsAll.length, 1);
+ is(events.length, 3);
+
+ root.destroy();
+ gBrowser.removeTab(tab);
+ }
+);
+
+async function setupEventMonitoring(root) {
+ const monitoringEvents = [];
+ const onMonitoringEvent = (event, data) => monitoringEvents.push(data.text);
+ root.on("eventemitter.monitoringEvent", onMonitoringEvent);
+
+ registerCleanupFunction(() =>
+ root.off("eventemitter.monitoringEvent", onMonitoringEvent)
+ );
+
+ return monitoringEvents;
+}
+
+async function emitTestEvent(root, browsingContext, monitoringEvents) {
+ const count = monitoringEvents.length;
+ info("Call eventemitter.emitTestEvent");
+ await root.handleCommand({
+ moduleName: "eventemitter",
+ commandName: "emitTestEvent",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContext.id,
+ },
+ });
+
+ // The monitoring event is always emitted, regardless of the status of the
+ // module. Wait for catching this event before resuming the assertions.
+ info("Wait for the monitoring event");
+ await BrowserTestUtils.waitForCondition(
+ () => monitoringEvents.length >= count + 1
+ );
+ is(monitoringEvents.length, count + 1);
+}
+
+function isSubscribed(root, browsingContext) {
+ info("Call eventemitter.isSubscribed");
+ return root.handleCommand({
+ moduleName: "eventemitter",
+ commandName: "isSubscribed",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContext.id,
+ },
+ });
+}
diff --git a/remote/shared/messagehandler/test/browser/browser_events_handler.js b/remote/shared/messagehandler/test/browser/browser_events_handler.js
new file mode 100644
index 0000000000..705c306de3
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/browser_events_handler.js
@@ -0,0 +1,57 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Test that the window-global-handler-created event gets emitted for each
+ * individual frame's browsing context.
+ */
+add_task(async function test_windowGlobalHandlerCreated() {
+ const events = [];
+
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-event_with_frames"
+ );
+
+ info("Add a new session data item to get window global handlers created");
+ await rootMessageHandler.addSessionDataItem({
+ moduleName: "command",
+ category: "browser_session_data_browser_element",
+ contextDescriptor: {
+ type: ContextDescriptorType.All,
+ },
+ values: [true],
+ });
+
+ const onEvent = (evtName, wrappedEvt) => {
+ if (wrappedEvt.name === "window-global-handler-created") {
+ console.info(`Received event for context ${wrappedEvt.data.contextId}`);
+ events.push(wrappedEvt.data);
+ }
+ };
+ rootMessageHandler.on("message-handler-event", onEvent);
+
+ info("Navigate the initial tab to the test URL");
+ const browser = gBrowser.selectedTab.linkedBrowser;
+ await loadURL(browser, createTestMarkupWithFrames());
+
+ const contexts = browser.browsingContext.getAllBrowsingContextsInSubtree();
+ is(contexts.length, 4, "Test tab has 3 children contexts (4 in total)");
+
+ // Wait for all the events
+ await TestUtils.waitForCondition(() => events.length >= 4);
+
+ for (const context of contexts) {
+ const contextEvents = events.filter(evt => {
+ return (
+ evt.contextId === context.id &&
+ evt.innerWindowId === context.currentWindowGlobal.innerWindowId
+ );
+ });
+ is(contextEvents.length, 1, `Found event for context ${context.id}`);
+ }
+
+ rootMessageHandler.off("message-handler-event", onEvent);
+ rootMessageHandler.destroy();
+});
diff --git a/remote/shared/messagehandler/test/browser/browser_events_interception.js b/remote/shared/messagehandler/test/browser/browser_events_interception.js
new file mode 100644
index 0000000000..aaf39353a6
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/browser_events_interception.js
@@ -0,0 +1,112 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { RootMessageHandlerRegistry } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/RootMessageHandlerRegistry.sys.mjs"
+);
+const { RootMessageHandler } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs"
+);
+
+/**
+ * Test that events can be intercepted in the windowglobal-in-root layer.
+ */
+add_task(async function test_intercepted_event() {
+ const tab = BrowserTestUtils.addTab(
+ gBrowser,
+ "https://example.com/document-builder.sjs?html=tab"
+ );
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ const browsingContext = tab.linkedBrowser.browsingContext;
+
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-intercepted_event"
+ );
+
+ const onInterceptedEvent = rootMessageHandler.once(
+ "event.testEventWithInterception"
+ );
+ rootMessageHandler.handleCommand({
+ moduleName: "event",
+ commandName: "testEmitEventWithInterception",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContext.id,
+ },
+ });
+
+ const interceptedEvent = await onInterceptedEvent;
+ is(
+ interceptedEvent.additionalInformation,
+ "information added through interception",
+ "Intercepted event contained additional information"
+ );
+
+ rootMessageHandler.destroy();
+ gBrowser.removeTab(tab);
+});
+
+/**
+ * Test that events can be canceled in the windowglobal-in-root layer.
+ */
+add_task(async function test_cancelable_event() {
+ const tab = BrowserTestUtils.addTab(
+ gBrowser,
+ "https://example.com/document-builder.sjs?html=tab"
+ );
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ const browsingContext = tab.linkedBrowser.browsingContext;
+
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-cancelable_event"
+ );
+
+ const cancelableEvents = [];
+ const onCancelableEvent = (name, event) => cancelableEvents.push(event);
+ rootMessageHandler.on(
+ "event.testEventCancelableWithInterception",
+ onCancelableEvent
+ );
+
+ // Emit an event that should be canceled in the windowglobal-in-root layer.
+ // Note that `shouldCancel` is only something supported for this test event,
+ // and not a general message handler mechanism to cancel events.
+ await rootMessageHandler.handleCommand({
+ moduleName: "event",
+ commandName: "testEmitEventCancelableWithInterception",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContext.id,
+ },
+ params: {
+ shouldCancel: true,
+ },
+ });
+
+ is(cancelableEvents.length, 0, "No event was received");
+
+ // Emit another event which should not be canceled (shouldCancel: false).
+ await rootMessageHandler.handleCommand({
+ moduleName: "event",
+ commandName: "testEmitEventCancelableWithInterception",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContext.id,
+ },
+ params: {
+ shouldCancel: false,
+ },
+ });
+
+ await TestUtils.waitForCondition(() => cancelableEvents.length == 1);
+ is(cancelableEvents[0].shouldCancel, false, "Expected event was received");
+
+ rootMessageHandler.off(
+ "event.testEventCancelableWithInterception",
+ onCancelableEvent
+ );
+ rootMessageHandler.destroy();
+ gBrowser.removeTab(tab);
+});
diff --git a/remote/shared/messagehandler/test/browser/browser_events_module.js b/remote/shared/messagehandler/test/browser/browser_events_module.js
new file mode 100644
index 0000000000..32b60d34b1
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/browser_events_module.js
@@ -0,0 +1,296 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { RootMessageHandlerRegistry } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/RootMessageHandlerRegistry.sys.mjs"
+);
+const { RootMessageHandler } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs"
+);
+
+/**
+ * Emit an event from a WindowGlobal module triggered by a specific command.
+ * Check that the event is emitted on the RootMessageHandler as well as on
+ * the parent process MessageHandlerRegistry.
+ */
+add_task(async function test_event() {
+ const tab = BrowserTestUtils.addTab(
+ gBrowser,
+ "https://example.com/document-builder.sjs?html=tab"
+ );
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ const browsingContext = tab.linkedBrowser.browsingContext;
+
+ const rootMessageHandler = createRootMessageHandler("session-id-event");
+ let messageHandlerEvent;
+ let registryEvent;
+
+ // Events are emitted both as generic message-handler-event events as well
+ // as under their own name. We expect to receive the event for both.
+ const _onMessageHandlerEvent = (eventName, eventData) => {
+ if (eventData.name === "event-from-window-global") {
+ messageHandlerEvent = eventData;
+ }
+ };
+ rootMessageHandler.on("message-handler-event", _onMessageHandlerEvent);
+ const onNamedEvent = rootMessageHandler.once("event-from-window-global");
+ // MessageHandlerRegistry should forward all the message-handler-events.
+ const _onMessageHandlerRegistryEvent = (eventName, eventData) => {
+ if (eventData.name === "event-from-window-global") {
+ registryEvent = eventData;
+ }
+ };
+ RootMessageHandlerRegistry.on(
+ "message-handler-registry-event",
+ _onMessageHandlerRegistryEvent
+ );
+
+ callTestEmitEvent(rootMessageHandler, browsingContext.id);
+
+ const namedEvent = await onNamedEvent;
+ is(
+ namedEvent.text,
+ `event from ${browsingContext.id}`,
+ "Received the expected payload"
+ );
+
+ is(
+ messageHandlerEvent.name,
+ "event-from-window-global",
+ "Received event on the ROOT MessageHandler"
+ );
+ is(
+ messageHandlerEvent.data.text,
+ `event from ${browsingContext.id}`,
+ "Received the expected payload"
+ );
+
+ is(
+ registryEvent,
+ messageHandlerEvent,
+ "The event forwarded by the MessageHandlerRegistry is identical to the MessageHandler event"
+ );
+ rootMessageHandler.off("message-handler-event", _onMessageHandlerEvent);
+ RootMessageHandlerRegistry.off(
+ "message-handler-registry-event",
+ _onMessageHandlerRegistryEvent
+ );
+ rootMessageHandler.destroy();
+ gBrowser.removeTab(tab);
+});
+
+/**
+ * Emit an event from a Root module triggered by a specific command.
+ * Check that the event is emitted on the RootMessageHandler.
+ */
+add_task(async function test_root_event() {
+ const rootMessageHandler = createRootMessageHandler("session-id-root_event");
+
+ // events are emitted both as generic message-handler-event events as
+ // well as under their own name. We expect to receive the event for both.
+ const onHandlerEvent = rootMessageHandler.once("message-handler-event");
+ const onNamedEvent = rootMessageHandler.once("event-from-root");
+
+ rootMessageHandler.handleCommand({
+ moduleName: "event",
+ commandName: "testEmitRootEvent",
+ destination: {
+ type: RootMessageHandler.type,
+ },
+ });
+
+ const { name, data } = await onHandlerEvent;
+ is(name, "event-from-root", "Received event on the ROOT MessageHandler");
+ is(data.text, "event from root", "Received the expected payload");
+
+ const namedEvent = await onNamedEvent;
+ is(namedEvent.text, "event from root", "Received the expected payload");
+
+ rootMessageHandler.destroy();
+});
+
+/**
+ * Emit an event from a windowglobal-in-root module triggered by a specific command.
+ * Check that the event is emitted on the RootMessageHandler.
+ */
+add_task(async function test_windowglobal_in_root_event() {
+ const tab = BrowserTestUtils.addTab(
+ gBrowser,
+ "https://example.com/document-builder.sjs?html=tab"
+ );
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ const browsingContext = tab.linkedBrowser.browsingContext;
+
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-windowglobal_in_root_event"
+ );
+
+ // events are emitted both as generic message-handler-event events as
+ // well as under their own name. We expect to receive the event for both.
+ const onHandlerEvent = rootMessageHandler.once("message-handler-event");
+ const onNamedEvent = rootMessageHandler.once(
+ "event-from-window-global-in-root"
+ );
+ rootMessageHandler.handleCommand({
+ moduleName: "event",
+ commandName: "testEmitWindowGlobalInRootEvent",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContext.id,
+ },
+ });
+
+ const { name, data } = await onHandlerEvent;
+ is(
+ name,
+ "event-from-window-global-in-root",
+ "Received event on the ROOT MessageHandler"
+ );
+ is(
+ data.text,
+ `windowglobal-in-root event for ${browsingContext.id}`,
+ "Received the expected payload"
+ );
+
+ const namedEvent = await onNamedEvent;
+ is(
+ namedEvent.text,
+ `windowglobal-in-root event for ${browsingContext.id}`,
+ "Received the expected payload"
+ );
+
+ rootMessageHandler.destroy();
+ gBrowser.removeTab(tab);
+});
+
+/**
+ * Emit an event from a windowglobal module, but from 2 different sessions.
+ * Check that the event is emitted by the corresponding RootMessageHandler as
+ * well as by the parent process MessageHandlerRegistry.
+ */
+add_task(async function test_event_multisession() {
+ const tab = BrowserTestUtils.addTab(
+ gBrowser,
+ "https://example.com/document-builder.sjs?html=tab"
+ );
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ const browsingContextId = tab.linkedBrowser.browsingContext.id;
+
+ const root1 = createRootMessageHandler("session-id-event_multisession-1");
+ let root1Events = 0;
+ const onRoot1Event = function (evtName, wrappedEvt) {
+ if (wrappedEvt.name === "event-from-window-global") {
+ root1Events++;
+ }
+ };
+ root1.on("message-handler-event", onRoot1Event);
+
+ const root2 = createRootMessageHandler("session-id-event_multisession-2");
+ let root2Events = 0;
+ const onRoot2Event = function (evtName, wrappedEvt) {
+ if (wrappedEvt.name === "event-from-window-global") {
+ root2Events++;
+ }
+ };
+ root2.on("message-handler-event", onRoot2Event);
+
+ let registryEvents = 0;
+ const onRegistryEvent = function (evtName, wrappedEvt) {
+ if (wrappedEvt.name === "event-from-window-global") {
+ registryEvents++;
+ }
+ };
+ RootMessageHandlerRegistry.on(
+ "message-handler-registry-event",
+ onRegistryEvent
+ );
+
+ callTestEmitEvent(root1, browsingContextId);
+ callTestEmitEvent(root2, browsingContextId);
+
+ info("Wait for root1 event to be received");
+ await TestUtils.waitForCondition(() => root1Events === 1);
+ info("Wait for root2 event to be received");
+ await TestUtils.waitForCondition(() => root2Events === 1);
+
+ await TestUtils.waitForTick();
+ is(root1Events, 1, "Session 1 only received 1 event");
+ is(root2Events, 1, "Session 2 only received 1 event");
+ is(
+ registryEvents,
+ 2,
+ "MessageHandlerRegistry forwarded events from both sessions"
+ );
+
+ root1.off("message-handler-event", onRoot1Event);
+ root2.off("message-handler-event", onRoot2Event);
+ RootMessageHandlerRegistry.off(
+ "message-handler-registry-event",
+ onRegistryEvent
+ );
+ root1.destroy();
+ root2.destroy();
+ gBrowser.removeTab(tab);
+});
+
+/**
+ * Test that events can be emitted from individual frame contexts and that
+ * events going through a shared content process MessageHandlerRegistry are not
+ * duplicated.
+ */
+add_task(async function test_event_with_frames() {
+ info("Navigate the initial tab to the test URL");
+ const tab = gBrowser.selectedTab;
+ await loadURL(tab.linkedBrowser, createTestMarkupWithFrames());
+
+ const contexts =
+ tab.linkedBrowser.browsingContext.getAllBrowsingContextsInSubtree();
+ is(contexts.length, 4, "Test tab has 3 children contexts (4 in total)");
+
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-event_with_frames"
+ );
+
+ const rootEvents = [];
+ const onRootEvent = function (evtName, wrappedEvt) {
+ if (wrappedEvt.name === "event-from-window-global") {
+ rootEvents.push(wrappedEvt.data.text);
+ }
+ };
+ rootMessageHandler.on("message-handler-event", onRootEvent);
+
+ const namedEvents = [];
+ const onNamedEvent = (name, event) => namedEvents.push(event.text);
+ rootMessageHandler.on("event-from-window-global", onNamedEvent);
+
+ for (const context of contexts) {
+ callTestEmitEvent(rootMessageHandler, context.id);
+ info("Wait for root event to be received in both event arrays");
+ await TestUtils.waitForCondition(() =>
+ [namedEvents, rootEvents].every(events =>
+ events.includes(`event from ${context.id}`)
+ )
+ );
+ }
+
+ info("Wait for a bit and check that we did not receive duplicated events");
+ await TestUtils.waitForTick();
+ is(rootEvents.length, 4, "Only received 4 events");
+
+ rootMessageHandler.off("message-handler-event", onRootEvent);
+ rootMessageHandler.off("event-from-window-global", onNamedEvent);
+ rootMessageHandler.destroy();
+});
+
+function callTestEmitEvent(rootMessageHandler, browsingContextId) {
+ rootMessageHandler.handleCommand({
+ moduleName: "event",
+ commandName: "testEmitEvent",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContextId,
+ },
+ });
+}
diff --git a/remote/shared/messagehandler/test/browser/browser_frame_context_utils.js b/remote/shared/messagehandler/test/browser/browser_frame_context_utils.js
new file mode 100644
index 0000000000..cddcba3529
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/browser_frame_context_utils.js
@@ -0,0 +1,98 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { isBrowsingContextCompatible } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/transports/BrowsingContextUtils.sys.mjs"
+);
+const TEST_COM_PAGE = "https://example.com/document-builder.sjs?html=com";
+const TEST_NET_PAGE = "https://example.net/document-builder.sjs?html=net";
+
+// Test helpers from BrowsingContextUtils in various processes.
+add_task(async function () {
+ const tab1 = BrowserTestUtils.addTab(gBrowser, TEST_COM_PAGE);
+ const contentBrowser1 = tab1.linkedBrowser;
+ await BrowserTestUtils.browserLoaded(contentBrowser1);
+ const browserId1 = contentBrowser1.browsingContext.browserId;
+
+ const tab2 = BrowserTestUtils.addTab(gBrowser, TEST_NET_PAGE);
+ const contentBrowser2 = tab2.linkedBrowser;
+ await BrowserTestUtils.browserLoaded(contentBrowser2);
+ const browserId2 = contentBrowser2.browsingContext.browserId;
+
+ const { extension, sidebarBrowser } = await installSidebarExtension();
+
+ const tab3 = BrowserTestUtils.addTab(
+ gBrowser,
+ `moz-extension://${extension.uuid}/tab.html`
+ );
+ const { bcId } = await extension.awaitMessage("tab-loaded");
+ const tabExtensionBrowser = BrowsingContext.get(bcId).top.embedderElement;
+
+ const parentBrowser1 = createParentBrowserElement(tab1, "content");
+ const parentBrowser2 = createParentBrowserElement(tab1, "chrome");
+
+ info("Check browsing context compatibility for content browser 1");
+ await checkBrowsingContextCompatible(contentBrowser1, undefined, true);
+ await checkBrowsingContextCompatible(contentBrowser1, browserId1, true);
+ await checkBrowsingContextCompatible(contentBrowser1, browserId2, false);
+
+ info("Check browsing context compatibility for content browser 2");
+ await checkBrowsingContextCompatible(contentBrowser2, undefined, true);
+ await checkBrowsingContextCompatible(contentBrowser2, browserId1, false);
+ await checkBrowsingContextCompatible(contentBrowser2, browserId2, true);
+
+ info("Check browsing context compatibility for parent browser 1");
+ await checkBrowsingContextCompatible(parentBrowser1, undefined, false);
+ await checkBrowsingContextCompatible(parentBrowser1, browserId1, false);
+ await checkBrowsingContextCompatible(parentBrowser1, browserId2, false);
+
+ info("Check browsing context compatibility for parent browser 2");
+ await checkBrowsingContextCompatible(parentBrowser2, undefined, false);
+ await checkBrowsingContextCompatible(parentBrowser2, browserId1, false);
+ await checkBrowsingContextCompatible(parentBrowser2, browserId2, false);
+
+ info("Check browsing context compatibility for extension");
+ await checkBrowsingContextCompatible(sidebarBrowser, undefined, false);
+ await checkBrowsingContextCompatible(sidebarBrowser, browserId1, false);
+ await checkBrowsingContextCompatible(sidebarBrowser, browserId2, false);
+
+ info("Check browsing context compatibility for extension viewed in a tab");
+ await checkBrowsingContextCompatible(tabExtensionBrowser, undefined, false);
+ await checkBrowsingContextCompatible(tabExtensionBrowser, browserId1, false);
+ await checkBrowsingContextCompatible(tabExtensionBrowser, browserId2, false);
+
+ gBrowser.removeTab(tab1);
+ gBrowser.removeTab(tab2);
+ gBrowser.removeTab(tab3);
+ await extension.unload();
+});
+
+async function checkBrowsingContextCompatible(browser, browserId, expected) {
+ const options = { browserId };
+ info("Check browsing context compatibility from the parent process");
+ is(isBrowsingContextCompatible(browser.browsingContext, options), expected);
+
+ info(
+ "Check browsing context compatibility from the browsing context's process"
+ );
+ await SpecialPowers.spawn(
+ browser,
+ [browserId, expected],
+ (_browserId, _expected) => {
+ const BrowsingContextUtils = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/transports/BrowsingContextUtils.sys.mjs"
+ );
+ is(
+ BrowsingContextUtils.isBrowsingContextCompatible(
+ content.browsingContext,
+ {
+ browserId: _browserId,
+ }
+ ),
+ _expected
+ );
+ }
+ );
+}
diff --git a/remote/shared/messagehandler/test/browser/browser_handle_command_errors.js b/remote/shared/messagehandler/test/browser/browser_handle_command_errors.js
new file mode 100644
index 0000000000..c115517980
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/browser_handle_command_errors.js
@@ -0,0 +1,218 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { RootMessageHandler } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs"
+);
+
+// Check that errors from WindowGlobal modules can be caught by the consumer
+// of the RootMessageHandler.
+add_task(async function test_module_error() {
+ const browsingContextId = gBrowser.selectedBrowser.browsingContext.id;
+
+ const rootMessageHandler = createRootMessageHandler("session-id-error");
+
+ info("Call a module method which will throw");
+
+ await Assert.rejects(
+ rootMessageHandler.handleCommand({
+ moduleName: "commandwindowglobalonly",
+ commandName: "testError",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContextId,
+ },
+ }),
+ err => err.message.includes("error-from-module"),
+ "Error from window global module caught"
+ );
+
+ rootMessageHandler.destroy();
+});
+
+// Check that sending commands to incorrect destinations creates an error which
+// can be caught by the consumer of the RootMessageHandler.
+add_task(async function test_destination_error() {
+ const rootMessageHandler = createRootMessageHandler("session-id-error");
+
+ const fakeBrowsingContextId = -1;
+ ok(
+ !BrowsingContext.get(fakeBrowsingContextId),
+ "No browsing context matches fakeBrowsingContextId"
+ );
+
+ info("Call a valid module method, but on a non-existent browsing context id");
+ Assert.throws(
+ () =>
+ rootMessageHandler.handleCommand({
+ moduleName: "commandwindowglobalonly",
+ commandName: "testOnlyInWindowGlobal",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: fakeBrowsingContextId,
+ },
+ }),
+ err => err.message == `Unable to find a BrowsingContext for id -1`
+ );
+
+ rootMessageHandler.destroy();
+});
+
+add_task(async function test_invalid_module_error() {
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-missing_module"
+ );
+
+ info("Attempt to call a Root module which has a syntax error");
+ Assert.throws(
+ () =>
+ rootMessageHandler.handleCommand({
+ moduleName: "invalid",
+ commandName: "someMethod",
+ destination: {
+ type: RootMessageHandler.type,
+ },
+ }),
+ err =>
+ err.name === "SyntaxError" &&
+ err.message == "expected expression, got ';'"
+ );
+
+ rootMessageHandler.destroy();
+});
+
+add_task(async function test_missing_root_module_error() {
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-missing_module"
+ );
+
+ info("Attempt to call a Root module which doesn't exist");
+ Assert.throws(
+ () =>
+ rootMessageHandler.handleCommand({
+ moduleName: "missingmodule",
+ commandName: "someMethod",
+ destination: {
+ type: RootMessageHandler.type,
+ },
+ }),
+ err =>
+ err.name == "UnsupportedCommandError" &&
+ err.message ==
+ `missingmodule.someMethod not supported for destination ROOT`
+ );
+
+ rootMessageHandler.destroy();
+});
+
+add_task(async function test_missing_windowglobal_module_error() {
+ const browsingContextId = gBrowser.selectedBrowser.browsingContext.id;
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-missing_windowglobal_module"
+ );
+
+ info("Attempt to call a WindowGlobal module which doesn't exist");
+ Assert.throws(
+ () =>
+ rootMessageHandler.handleCommand({
+ moduleName: "missingmodule",
+ commandName: "someMethod",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContextId,
+ },
+ }),
+ err =>
+ err.name == "UnsupportedCommandError" &&
+ err.message ==
+ `missingmodule.someMethod not supported for destination WINDOW_GLOBAL`
+ );
+
+ rootMessageHandler.destroy();
+});
+
+add_task(async function test_missing_root_method_error() {
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-missing_root_method"
+ );
+
+ info("Attempt to call an invalid method on a Root module");
+ Assert.throws(
+ () =>
+ rootMessageHandler.handleCommand({
+ moduleName: "command",
+ commandName: "wrongMethod",
+ destination: {
+ type: RootMessageHandler.type,
+ },
+ }),
+ err =>
+ err.name == "UnsupportedCommandError" &&
+ err.message == `command.wrongMethod not supported for destination ROOT`
+ );
+
+ rootMessageHandler.destroy();
+});
+
+add_task(async function test_missing_windowglobal_method_error() {
+ const browsingContextId = gBrowser.selectedBrowser.browsingContext.id;
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-missing_windowglobal_method"
+ );
+
+ info("Attempt to call an invalid method on a WindowGlobal module");
+ Assert.throws(
+ () =>
+ rootMessageHandler.handleCommand({
+ moduleName: "commandwindowglobalonly",
+ commandName: "wrongMethod",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContextId,
+ },
+ }),
+ err =>
+ err.name == "UnsupportedCommandError" &&
+ err.message ==
+ `commandwindowglobalonly.wrongMethod not supported for destination WINDOW_GLOBAL`
+ );
+
+ rootMessageHandler.destroy();
+});
+
+/**
+ * This test checks that even if a command is rerouted to another command after
+ * the RootMessageHandler, we still check the new command and log a useful
+ * error message.
+ *
+ * This illustrates why it is important to perform the command check at each
+ * layer of the MessageHandler network.
+ */
+add_task(async function test_missing_intermediary_method_error() {
+ const browsingContextId = gBrowser.selectedBrowser.browsingContext.id;
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-missing_intermediary_method"
+ );
+
+ info(
+ "Call a (valid) command that relies on another (missing) command on a WindowGlobal module"
+ );
+ await Assert.rejects(
+ rootMessageHandler.handleCommand({
+ moduleName: "commandwindowglobalonly",
+ commandName: "testMissingIntermediaryMethod",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContextId,
+ },
+ }),
+ err =>
+ err.name == "UnsupportedCommandError" &&
+ err.message ==
+ `commandwindowglobalonly.missingMethod not supported for destination WINDOW_GLOBAL`
+ );
+
+ rootMessageHandler.destroy();
+});
diff --git a/remote/shared/messagehandler/test/browser/browser_handle_command_retry.js b/remote/shared/messagehandler/test/browser/browser_handle_command_retry.js
new file mode 100644
index 0000000000..1d020397e1
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/browser_handle_command_retry.js
@@ -0,0 +1,229 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// We are forcing the actors to shutdown while queries are unresolved.
+const { PromiseTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PromiseTestUtils.sys.mjs"
+);
+PromiseTestUtils.allowMatchingRejectionsGlobally(
+ /Actor 'MessageHandlerFrame' destroyed before query 'MessageHandlerFrameParent:sendCommand' was resolved/
+);
+
+// The tests in this file assert the retry behavior for MessageHandler commands.
+// We call "blocked" commands from resources/modules/windowglobal/retry.jsm and
+// then trigger reload and navigations to simulate AbortErrors and force the
+// MessageHandler to retry the commands, when possible.
+
+// Test that without retry behavior, a pending command rejects when the
+// underlying JSWindowActor pair is destroyed.
+add_task(async function test_no_retry() {
+ const tab = BrowserTestUtils.addTab(
+ gBrowser,
+ "https://example.com/document-builder.sjs?html=tab"
+ );
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ const browsingContext = tab.linkedBrowser.browsingContext;
+
+ const rootMessageHandler = createRootMessageHandler("session-id-no-retry");
+
+ try {
+ info("Call a module method which will throw");
+ const onBlockedOneTime = rootMessageHandler.handleCommand({
+ moduleName: "retry",
+ commandName: "blockedOneTime",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContext.id,
+ },
+ });
+
+ // Reloading the tab will reject the pending query with an AbortError.
+ await BrowserTestUtils.reloadTab(tab);
+
+ await Assert.rejects(
+ onBlockedOneTime,
+ e => e.name == "AbortError",
+ "Caught the expected abort error when reloading"
+ );
+ } finally {
+ await cleanup(rootMessageHandler, tab);
+ }
+});
+
+// Test various commands, which all need a different number of "retries" to
+// succeed. Check that they only resolve when the expected number of "retries"
+// was reached. For commands which require more "retries" than we allow, check
+// that we still fail with an AbortError once all the attempts are consumed.
+add_task(async function test_retry() {
+ const tab = BrowserTestUtils.addTab(
+ gBrowser,
+ "https://example.com/document-builder.sjs?html=tab"
+ );
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ const browsingContext = tab.linkedBrowser.browsingContext;
+
+ const rootMessageHandler = createRootMessageHandler("session-id-retry");
+
+ try {
+ // This command will return if called twice.
+ const onBlockedOneTime = rootMessageHandler.handleCommand({
+ moduleName: "retry",
+ commandName: "blockedOneTime",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContext.id,
+ },
+ params: {
+ foo: "bar",
+ },
+ retryOnAbort: true,
+ });
+
+ // This command will return if called three times.
+ const onBlockedTenTimes = rootMessageHandler.handleCommand({
+ moduleName: "retry",
+ commandName: "blockedTenTimes",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContext.id,
+ },
+ params: {
+ foo: "baz",
+ },
+ retryOnAbort: true,
+ });
+
+ // This command will return if called twelve times, which is greater than the
+ // maximum amount of retries allowed.
+ const onBlockedElevenTimes = rootMessageHandler.handleCommand({
+ moduleName: "retry",
+ commandName: "blockedElevenTimes",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContext.id,
+ },
+ retryOnAbort: true,
+ });
+
+ info("Reload one time");
+ await BrowserTestUtils.reloadTab(tab);
+
+ info("blockedOneTime should resolve on the first retry");
+ let { callsToCommand, foo } = await onBlockedOneTime;
+ is(
+ callsToCommand,
+ 2,
+ "The command was called twice (initial call + 1 retry)"
+ );
+ is(foo, "bar", "The parameter was sent when the command was retried");
+
+ // We already reloaded 1 time. Reload 9 more times to unblock blockedTenTimes.
+ for (let i = 2; i < 11; i++) {
+ info("blockedTenTimes/blockedElevenTimes should not have resolved yet");
+ ok(!(await hasPromiseResolved(onBlockedTenTimes)));
+ ok(!(await hasPromiseResolved(onBlockedElevenTimes)));
+
+ info(`Reload the tab (time: ${i})`);
+ await BrowserTestUtils.reloadTab(tab);
+ }
+
+ info("blockedTenTimes should resolve on the 10th reload");
+ ({ callsToCommand, foo } = await onBlockedTenTimes);
+ is(
+ callsToCommand,
+ 11,
+ "The command was called 11 times (initial call + 10 retry)"
+ );
+ is(foo, "baz", "The parameter was sent when the command was retried");
+
+ info("Reload one more time");
+ await BrowserTestUtils.reloadTab(tab);
+
+ info(
+ "The call to blockedElevenTimes now exceeds the maximum attempts allowed"
+ );
+ await Assert.rejects(
+ onBlockedElevenTimes,
+ e => e.name == "AbortError",
+ "Caught the expected abort error when reloading"
+ );
+ } finally {
+ await cleanup(rootMessageHandler, tab);
+ }
+});
+
+// Test cross-group navigations to check that the retry mechanism will
+// transparently switch to the new Browsing Context created by the cross-group
+// navigation.
+add_task(async function test_retry_cross_group() {
+ const tab = BrowserTestUtils.addTab(
+ gBrowser,
+ "https://example.com/document-builder.sjs?html=COM" +
+ // Attach an unload listener to prevent the page from going into bfcache,
+ // so that pending queries will be rejected with an AbortError.
+ "<script type='text/javascript'>window.onunload = function() {};</script>"
+ );
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ const browsingContext = tab.linkedBrowser.browsingContext;
+
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-retry-cross-group"
+ );
+
+ try {
+ // This command hangs and only returns if the current domain is example.net.
+ // We send the command while on example.com, perform a series of reload and
+ // navigations, and the retry mechanism should allow onBlockedOnNetDomain to
+ // resolve.
+ const onBlockedOnNetDomain = rootMessageHandler.handleCommand({
+ moduleName: "retry",
+ commandName: "blockedOnNetDomain",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContext.id,
+ },
+ params: {
+ foo: "bar",
+ },
+ retryOnAbort: true,
+ });
+
+ info("Reload one time");
+ await BrowserTestUtils.reloadTab(tab);
+
+ info("blockedOnNetDomain should not have resolved yet");
+ ok(!(await hasPromiseResolved(onBlockedOnNetDomain)));
+
+ info(
+ "Navigate to example.net with COOP headers to destroy browsing context"
+ );
+ await loadURL(
+ tab.linkedBrowser,
+ "https://example.net/document-builder.sjs?headers=Cross-Origin-Opener-Policy:same-origin&html=NET"
+ );
+
+ info("blockedOnNetDomain should resolve now");
+ let { foo } = await onBlockedOnNetDomain;
+ is(foo, "bar", "The parameter was sent when the command was retried");
+ } finally {
+ await cleanup(rootMessageHandler, tab);
+ }
+});
+
+async function cleanup(rootMessageHandler, tab) {
+ const browsingContext = tab.linkedBrowser.browsingContext;
+ // Cleanup global JSM state in the test module.
+ await rootMessageHandler.handleCommand({
+ moduleName: "retry",
+ commandName: "cleanup",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContext.id,
+ },
+ });
+
+ rootMessageHandler.destroy();
+ gBrowser.removeTab(tab);
+}
diff --git a/remote/shared/messagehandler/test/browser/browser_handle_simple_command.js b/remote/shared/messagehandler/test/browser/browser_handle_simple_command.js
new file mode 100644
index 0000000000..0a086d6f09
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/browser_handle_simple_command.js
@@ -0,0 +1,203 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { RootMessageHandler } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs"
+);
+
+// Test calling methods only implemented in the root version of a module.
+add_task(async function test_rootModule_command() {
+ const rootMessageHandler = createRootMessageHandler("session-id-rootModule");
+ const rootValue = await rootMessageHandler.handleCommand({
+ moduleName: "command",
+ commandName: "testRootModule",
+ destination: {
+ type: RootMessageHandler.type,
+ },
+ });
+
+ is(
+ rootValue,
+ "root-value",
+ "Retrieved the expected value from testRootModule"
+ );
+
+ rootMessageHandler.destroy();
+});
+
+// Test calling methods only implemented in the windowglobal-in-root version of
+// a module.
+add_task(async function test_windowglobalInRootModule_command() {
+ const browsingContextId = gBrowser.selectedBrowser.browsingContext.id;
+
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-windowglobalInRootModule"
+ );
+ const interceptedValue = await rootMessageHandler.handleCommand({
+ moduleName: "command",
+ commandName: "testInterceptModule",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContextId,
+ },
+ });
+
+ is(
+ interceptedValue,
+ "intercepted-value",
+ "Retrieved the expected value from testInterceptModule"
+ );
+
+ rootMessageHandler.destroy();
+});
+
+// Test calling methods only implemented in the windowglobal version of a
+// module.
+add_task(async function test_windowglobalModule_command() {
+ const browsingContextId = gBrowser.selectedBrowser.browsingContext.id;
+
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-windowglobalModule"
+ );
+ const windowGlobalValue = await rootMessageHandler.handleCommand({
+ moduleName: "command",
+ commandName: "testWindowGlobalModule",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContextId,
+ },
+ });
+
+ is(
+ windowGlobalValue,
+ "windowglobal-value",
+ "Retrieved the expected value from testWindowGlobalModule"
+ );
+
+ rootMessageHandler.destroy();
+});
+
+// Test calling a method on a module which is only available in the "windowglobal"
+// folder. This will check that the MessageHandler/ModuleCache correctly moves
+// on to the next layer when no implementation can be found in the root layer.
+add_task(async function test_windowglobalOnlyModule_command() {
+ const browsingContextId = gBrowser.selectedBrowser.browsingContext.id;
+
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-windowglobalOnlyModule"
+ );
+ const windowGlobalOnlyValue = await rootMessageHandler.handleCommand({
+ moduleName: "commandwindowglobalonly",
+ commandName: "testOnlyInWindowGlobal",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContextId,
+ },
+ });
+
+ is(
+ windowGlobalOnlyValue,
+ "only-in-windowglobal",
+ "Retrieved the expected value from testOnlyInWindowGlobal"
+ );
+
+ rootMessageHandler.destroy();
+});
+
+// Try to create 2 sessions which will both set values in individual modules
+// via a command `testSetValue`, and then retrieve the values via another
+// command `testGetValue`.
+// This will ensure that different sessions use different module instances.
+add_task(async function test_multisession() {
+ const browsingContextId = gBrowser.selectedBrowser.browsingContext.id;
+
+ const rootMessageHandler1 = createRootMessageHandler(
+ "session-id-multisession-1"
+ );
+ const rootMessageHandler2 = createRootMessageHandler(
+ "session-id-multisession-2"
+ );
+
+ info("Set value for session 1");
+ await rootMessageHandler1.handleCommand({
+ moduleName: "command",
+ commandName: "testSetValue",
+ params: { value: "session1-value" },
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContextId,
+ },
+ });
+
+ info("Set value for session 2");
+ await rootMessageHandler2.handleCommand({
+ moduleName: "command",
+ commandName: "testSetValue",
+ params: { value: "session2-value" },
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContextId,
+ },
+ });
+
+ const session1Value = await rootMessageHandler1.handleCommand({
+ moduleName: "command",
+ commandName: "testGetValue",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContextId,
+ },
+ });
+
+ is(
+ session1Value,
+ "session1-value",
+ "Retrieved the expected value for session 1"
+ );
+
+ const session2Value = await rootMessageHandler2.handleCommand({
+ moduleName: "command",
+ commandName: "testGetValue",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContextId,
+ },
+ });
+
+ is(
+ session2Value,
+ "session2-value",
+ "Retrieved the expected value for session 2"
+ );
+
+ rootMessageHandler1.destroy();
+ rootMessageHandler2.destroy();
+});
+
+// Test calling a method from the windowglobal-in-root module which will
+// internally forward to the windowglobal module and will return a composite
+// result built both in parent and content process.
+add_task(async function test_forwarding_command() {
+ const browsingContextId = gBrowser.selectedBrowser.browsingContext.id;
+
+ const rootMessageHandler = createRootMessageHandler("session-id-forwarding");
+ const interceptAndForwardValue = await rootMessageHandler.handleCommand({
+ moduleName: "command",
+ commandName: "testInterceptAndForwardModule",
+ params: { id: "value" },
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContextId,
+ },
+ });
+
+ is(
+ interceptAndForwardValue,
+ "intercepted-and-forward+forward-to-windowglobal-value",
+ "Retrieved the expected value from testInterceptAndForwardModule"
+ );
+
+ rootMessageHandler.destroy();
+});
diff --git a/remote/shared/messagehandler/test/browser/browser_navigation_manager.js b/remote/shared/messagehandler/test/browser/browser_navigation_manager.js
new file mode 100644
index 0000000000..474605e90f
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/browser_navigation_manager.js
@@ -0,0 +1,59 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { MessageHandlerRegistry } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/MessageHandlerRegistry.sys.mjs"
+);
+const { RootMessageHandler } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs"
+);
+
+// Check that a functional navigation manager is available on the
+// RootMessageHandler.
+add_task(async function test_navigationManager() {
+ const sessionId = "navigationManager-test";
+ const type = RootMessageHandler.type;
+
+ const rootMessageHandlerRegistry = new MessageHandlerRegistry(type);
+
+ const rootMessageHandler =
+ rootMessageHandlerRegistry.getOrCreateMessageHandler(sessionId);
+
+ const navigationManager = rootMessageHandler.navigationManager;
+ ok(!!navigationManager, "ROOT MessageHandler provides a navigation manager");
+
+ const events = [];
+ const onEvent = (name, data) => events.push({ name, data });
+ navigationManager.on("navigation-started", onEvent);
+ navigationManager.on("navigation-stopped", onEvent);
+
+ info("Check the navigation manager monitors navigations");
+
+ const testUrl = "https://example.com/document-builder.sjs?html=test";
+ const tab1 = BrowserTestUtils.addTab(gBrowser, testUrl);
+ const contentBrowser1 = tab1.linkedBrowser;
+ await BrowserTestUtils.browserLoaded(contentBrowser1);
+
+ const navigation = navigationManager.getNavigationForBrowsingContext(
+ contentBrowser1.browsingContext
+ );
+ is(navigation.url, testUrl, "Navigation has the expected URL");
+
+ is(events.length, 2, "Received 2 navigation events");
+ is(events[0].name, "navigation-started");
+ is(events[1].name, "navigation-stopped");
+
+ info(
+ "Check the navigation manager is destroyed after destroying the message handler"
+ );
+ rootMessageHandler.destroy();
+ const otherUrl = "https://example.com/document-builder.sjs?html=other";
+ const tab2 = BrowserTestUtils.addTab(gBrowser, otherUrl);
+ await BrowserTestUtils.browserLoaded(tab2.linkedBrowser);
+ is(events.length, 2, "No new navigation event received");
+
+ gBrowser.removeTab(tab1);
+ gBrowser.removeTab(tab2);
+});
diff --git a/remote/shared/messagehandler/test/browser/browser_realms.js b/remote/shared/messagehandler/test/browser/browser_realms.js
new file mode 100644
index 0000000000..815bfbbe85
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/browser_realms.js
@@ -0,0 +1,152 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { RootMessageHandler } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs"
+);
+
+add_task(async function test_tab_is_removed() {
+ const tab = await addTab("https://example.com/document-builder.sjs?html=tab");
+ const sessionId = "realms";
+ const browsingContext = tab.linkedBrowser.browsingContext;
+ const contextDescriptor = {
+ type: ContextDescriptorType.TopBrowsingContext,
+ id: browsingContext.browserId,
+ };
+
+ const rootMessageHandler = createRootMessageHandler(sessionId);
+
+ const onRealmCreated = rootMessageHandler.once("realm-created");
+
+ // Add a new session data item to get window global handlers created
+ await rootMessageHandler.addSessionDataItem({
+ moduleName: "command",
+ category: "browser_realms",
+ contextDescriptor,
+ values: [true],
+ });
+
+ const realmCreatedEvent = await onRealmCreated;
+ const createdRealmId = realmCreatedEvent.realmInfo.realm;
+
+ is(rootMessageHandler.realms.size, 1, "Realm is added in the internal map");
+
+ const onRealmDestroyed = rootMessageHandler.once("realm-destroyed");
+
+ gBrowser.removeTab(tab);
+
+ const realmDestroyedEvent = await onRealmDestroyed;
+
+ is(
+ realmDestroyedEvent.realm,
+ createdRealmId,
+ "Received a correct realm id in realm-destroyed event"
+ );
+ is(rootMessageHandler.realms.size, 0, "The realm map is cleaned up");
+
+ rootMessageHandler.destroy();
+});
+
+add_task(async function test_same_origin_navigation() {
+ const tab = await addTab("https://example.com/document-builder.sjs?html=tab");
+ const sessionId = "realms";
+ const browsingContext = tab.linkedBrowser.browsingContext;
+ const contextDescriptor = {
+ type: ContextDescriptorType.TopBrowsingContext,
+ id: browsingContext.browserId,
+ };
+
+ const rootMessageHandler = createRootMessageHandler(sessionId);
+
+ const onRealmCreated = rootMessageHandler.once("realm-created");
+
+ // Add a new session data item to get window global handlers created
+ await rootMessageHandler.addSessionDataItem({
+ moduleName: "command",
+ category: "browser_realms",
+ contextDescriptor,
+ values: [true],
+ });
+
+ const realmCreatedEvent = await onRealmCreated;
+ const createdRealmId = realmCreatedEvent.realmInfo.realm;
+
+ is(rootMessageHandler.realms.size, 1, "Realm is added in the internal map");
+
+ const onRealmDestroyed = rootMessageHandler.once("realm-destroyed");
+ const onNewRealmCreated = rootMessageHandler.once("realm-created");
+
+ // Navigate to another page with the same origin
+ await loadURL(
+ tab.linkedBrowser,
+ "https://example.com/document-builder.sjs?html=othertab"
+ );
+
+ const realmDestroyedEvent = await onRealmDestroyed;
+
+ is(
+ realmDestroyedEvent.realm,
+ createdRealmId,
+ "Received a correct realm id in realm-destroyed event"
+ );
+
+ await onNewRealmCreated;
+
+ is(rootMessageHandler.realms.size, 1, "Realm is added in the internal map");
+
+ gBrowser.removeTab(tab);
+ rootMessageHandler.destroy();
+});
+
+add_task(async function test_cross_origin_navigation() {
+ const tab = await addTab("https://example.com/document-builder.sjs?html=tab");
+ const sessionId = "realms";
+ const browsingContext = tab.linkedBrowser.browsingContext;
+ const contextDescriptor = {
+ type: ContextDescriptorType.TopBrowsingContext,
+ id: browsingContext.browserId,
+ };
+
+ const rootMessageHandler = createRootMessageHandler(sessionId);
+
+ const onRealmCreated = rootMessageHandler.once("realm-created");
+
+ // Add a new session data item to get window global handlers created
+ await rootMessageHandler.addSessionDataItem({
+ moduleName: "command",
+ category: "browser_realms",
+ contextDescriptor,
+ values: [true],
+ });
+
+ const realmCreatedEvent = await onRealmCreated;
+ const createdRealmId = realmCreatedEvent.realmInfo.realm;
+
+ is(rootMessageHandler.realms.size, 1, "Realm is added in the internal map");
+
+ const onRealmDestroyed = rootMessageHandler.once("realm-destroyed");
+ const onNewRealmCreated = rootMessageHandler.once("realm-created");
+
+ // Navigate to another page with the different origin
+ await loadURL(
+ tab.linkedBrowser,
+ "https://example.com/document-builder.sjs?html=otherorigin"
+ );
+
+ const realmDestroyedEvent = await onRealmDestroyed;
+
+ is(
+ realmDestroyedEvent.realm,
+ createdRealmId,
+ "Received a correct realm id in realm-destroyed event"
+ );
+
+ await onNewRealmCreated;
+
+ is(rootMessageHandler.realms.size, 1, "Realm is added in the internal map");
+
+ gBrowser.removeTab(tab);
+ rootMessageHandler.destroy();
+});
diff --git a/remote/shared/messagehandler/test/browser/browser_registry.js b/remote/shared/messagehandler/test/browser/browser_registry.js
new file mode 100644
index 0000000000..945ac06c19
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/browser_registry.js
@@ -0,0 +1,37 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { MessageHandlerRegistry } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/MessageHandlerRegistry.sys.mjs"
+);
+const { RootMessageHandler } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs"
+);
+
+add_task(async function test_messageHandlerRegistry_API() {
+ const sessionId = 1;
+ const type = RootMessageHandler.type;
+
+ const rootMessageHandlerRegistry = new MessageHandlerRegistry(type);
+
+ const rootMessageHandler =
+ rootMessageHandlerRegistry.getOrCreateMessageHandler(sessionId);
+ ok(rootMessageHandler, "Valid ROOT MessageHandler created");
+
+ const contextId = rootMessageHandler.contextId;
+ ok(contextId, "ROOT MessageHandler has a valid contextId");
+
+ is(
+ rootMessageHandler,
+ rootMessageHandlerRegistry.getExistingMessageHandler(sessionId),
+ "ROOT MessageHandler can be retrieved from the registry"
+ );
+
+ rootMessageHandler.destroy();
+ ok(
+ !rootMessageHandlerRegistry.getExistingMessageHandler(sessionId),
+ "Destroyed ROOT MessageHandler is no longer returned by the Registry"
+ );
+});
diff --git a/remote/shared/messagehandler/test/browser/browser_session_data.js b/remote/shared/messagehandler/test/browser/browser_session_data.js
new file mode 100644
index 0000000000..591073feb6
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/browser_session_data.js
@@ -0,0 +1,273 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { MessageHandlerRegistry } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/MessageHandlerRegistry.sys.mjs"
+);
+const { RootMessageHandler } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs"
+);
+const { SessionData } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/sessiondata/SessionData.sys.mjs"
+);
+
+const TEST_PAGE = "http://example.com/document-builder.sjs?html=tab";
+
+add_task(async function test_sessionData() {
+ info("Navigate the initial tab to the test URL");
+ const tab1 = gBrowser.selectedTab;
+ await loadURL(tab1.linkedBrowser, TEST_PAGE);
+
+ const sessionId = "sessionData-test";
+
+ const rootMessageHandlerRegistry = new MessageHandlerRegistry(
+ RootMessageHandler.type
+ );
+
+ const rootMessageHandler =
+ rootMessageHandlerRegistry.getOrCreateMessageHandler(sessionId);
+ ok(rootMessageHandler, "Valid ROOT MessageHandler created");
+
+ const sessionData = rootMessageHandler.sessionData;
+ ok(
+ sessionData instanceof SessionData,
+ "ROOT MessageHandler has a valid sessionData"
+ );
+
+ let sessionDataSnapshot = await getSessionDataFromContent();
+ is(sessionDataSnapshot.size, 0, "session data is empty");
+
+ info("Store a string value in session data");
+ sessionData.updateSessionData([
+ {
+ method: "add",
+ moduleName: "fakemodule",
+ category: "testCategory",
+ contextDescriptor: contextDescriptorAll,
+ values: ["value-1"],
+ },
+ ]);
+
+ sessionDataSnapshot = await getSessionDataFromContent();
+ is(sessionDataSnapshot.size, 1, "session data contains 1 session");
+ ok(sessionDataSnapshot.has(sessionId));
+ let snapshot = sessionDataSnapshot.get(sessionId);
+ ok(Array.isArray(snapshot));
+ is(snapshot.length, 1);
+
+ const stringDataItem = snapshot[0];
+ checkSessionDataItem(
+ stringDataItem,
+ "fakemodule",
+ "testCategory",
+ ContextDescriptorType.All,
+ "value-1"
+ );
+
+ info("Store a number value in session data");
+ sessionData.updateSessionData([
+ {
+ method: "add",
+ moduleName: "fakemodule",
+ category: "testCategory",
+ contextDescriptor: contextDescriptorAll,
+ values: [12],
+ },
+ ]);
+ snapshot = (await getSessionDataFromContent()).get(sessionId);
+ is(snapshot.length, 2);
+
+ const numberDataItem = snapshot[1];
+ checkSessionDataItem(
+ numberDataItem,
+ "fakemodule",
+ "testCategory",
+ ContextDescriptorType.All,
+ 12
+ );
+
+ info("Store a boolean value in session data");
+ sessionData.updateSessionData([
+ {
+ method: "add",
+ moduleName: "fakemodule",
+ category: "testCategory",
+ contextDescriptor: contextDescriptorAll,
+ values: [true],
+ },
+ ]);
+ snapshot = (await getSessionDataFromContent()).get(sessionId);
+ is(snapshot.length, 3);
+
+ const boolDataItem = snapshot[2];
+ checkSessionDataItem(
+ boolDataItem,
+ "fakemodule",
+ "testCategory",
+ ContextDescriptorType.All,
+ true
+ );
+
+ info("Remove one value");
+ sessionData.updateSessionData([
+ {
+ method: "remove",
+ moduleName: "fakemodule",
+ category: "testCategory",
+ contextDescriptor: contextDescriptorAll,
+ values: [12],
+ },
+ ]);
+ snapshot = (await getSessionDataFromContent()).get(sessionId);
+ is(snapshot.length, 2);
+ checkSessionDataItem(
+ snapshot[0],
+ "fakemodule",
+ "testCategory",
+ ContextDescriptorType.All,
+ "value-1"
+ );
+ checkSessionDataItem(
+ snapshot[1],
+ "fakemodule",
+ "testCategory",
+ ContextDescriptorType.All,
+ true
+ );
+
+ info("Remove all values");
+ sessionData.updateSessionData([
+ {
+ method: "remove",
+ moduleName: "fakemodule",
+ category: "testCategory",
+ contextDescriptor: contextDescriptorAll,
+ values: ["value-1", true],
+ },
+ ]);
+ snapshot = (await getSessionDataFromContent()).get(sessionId);
+ is(snapshot.length, 0, "Session data is now empty");
+
+ info("Add another value before destroy");
+ sessionData.updateSessionData([
+ {
+ method: "add",
+ moduleName: "fakemodule",
+ category: "testCategory",
+ contextDescriptor: contextDescriptorAll,
+ values: ["value-2"],
+ },
+ ]);
+ snapshot = (await getSessionDataFromContent()).get(sessionId);
+ is(snapshot.length, 1);
+ checkSessionDataItem(
+ snapshot[0],
+ "fakemodule",
+ "testCategory",
+ ContextDescriptorType.All,
+ "value-2"
+ );
+
+ sessionData.destroy();
+ sessionDataSnapshot = await getSessionDataFromContent();
+ is(sessionDataSnapshot.size, 0, "session data should be empty again");
+});
+
+add_task(async function test_sessionDataRootOnlyModule() {
+ const sessionId = "sessionData-test-rootOnly";
+
+ const rootMessageHandler = createRootMessageHandler(sessionId);
+ ok(rootMessageHandler, "Valid ROOT MessageHandler created");
+
+ BrowserTestUtils.startLoadingURIString(
+ gBrowser.selectedBrowser,
+ "https://example.com/document-builder.sjs?html=tab"
+ );
+
+ const windowGlobalCreated = rootMessageHandler.once(
+ "window-global-handler-created"
+ );
+
+ info("Test that adding SessionData items works the root module");
+ // Updating the session data on the root message handler should not cause
+ // failures for other message handlers if the module only exists for root.
+ await rootMessageHandler.addSessionDataItem({
+ moduleName: "rootOnly",
+ category: "session_data_root_only",
+ contextDescriptor: {
+ type: ContextDescriptorType.All,
+ },
+ values: [true],
+ });
+
+ await windowGlobalCreated;
+ ok(true, "Window global has been initialized");
+
+ let sessionDataReceivedByRoot = await rootMessageHandler.handleCommand({
+ moduleName: "rootOnly",
+ commandName: "getSessionDataReceived",
+ destination: {
+ type: RootMessageHandler.type,
+ },
+ });
+
+ is(sessionDataReceivedByRoot.length, 1);
+ is(sessionDataReceivedByRoot[0].category, "session_data_root_only");
+ is(sessionDataReceivedByRoot[0].added.length, 1);
+ is(sessionDataReceivedByRoot[0].added[0], true);
+ is(
+ sessionDataReceivedByRoot[0].contextDescriptor.type,
+ ContextDescriptorType.All
+ );
+
+ info("Now test that removing items also works on the root module");
+ await rootMessageHandler.removeSessionDataItem({
+ moduleName: "rootOnly",
+ category: "session_data_root_only",
+ contextDescriptor: {
+ type: ContextDescriptorType.All,
+ },
+ values: [true],
+ });
+
+ sessionDataReceivedByRoot = await rootMessageHandler.handleCommand({
+ moduleName: "rootOnly",
+ commandName: "getSessionDataReceived",
+ destination: {
+ type: RootMessageHandler.type,
+ },
+ });
+
+ is(sessionDataReceivedByRoot.length, 2);
+ is(sessionDataReceivedByRoot[1].category, "session_data_root_only");
+ is(sessionDataReceivedByRoot[1].removed.length, 1);
+ is(sessionDataReceivedByRoot[1].removed[0], true);
+ is(
+ sessionDataReceivedByRoot[1].contextDescriptor.type,
+ ContextDescriptorType.All
+ );
+
+ rootMessageHandler.destroy();
+});
+
+function checkSessionDataItem(item, moduleName, category, contextType, value) {
+ is(item.moduleName, moduleName, "Data item has the expected module name");
+ is(item.category, category, "Data item has the expected category");
+ is(
+ item.contextDescriptor.type,
+ contextType,
+ "Data item has the expected context type"
+ );
+ is(item.value, value, "Data item has the expected value");
+}
+
+function getSessionDataFromContent() {
+ return SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
+ const { readSessionData } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/sessiondata/SessionDataReader.sys.mjs"
+ );
+ return readSessionData();
+ });
+}
diff --git a/remote/shared/messagehandler/test/browser/browser_session_data_browser_element.js b/remote/shared/messagehandler/test/browser/browser_session_data_browser_element.js
new file mode 100644
index 0000000000..9c15974ae6
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/browser_session_data_browser_element.js
@@ -0,0 +1,94 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_PAGE = "https://example.com/document-builder.sjs?html=tab";
+
+/**
+ * Check that message handlers are not created for parent process browser
+ * elements, even if they have the type="content" attribute (eg used for the
+ * DevTools toolbox), as well as for webextension contexts.
+ */
+add_task(async function test_session_data_broadcast() {
+ // Prepare:
+ // - one content tab
+ // - one browser type content
+ // - one browser type chrome
+ // - one sidebar webextension
+ // We only expect session data to be applied to the content tab
+ const tab1 = BrowserTestUtils.addTab(gBrowser, TEST_PAGE);
+ const contentBrowser1 = tab1.linkedBrowser;
+ await BrowserTestUtils.browserLoaded(contentBrowser1);
+ const parentBrowser1 = createParentBrowserElement(tab1, "content");
+ const parentBrowser2 = createParentBrowserElement(tab1, "chrome");
+ const { extension: extension1, sidebarBrowser: extSidebarBrowser1 } =
+ await installSidebarExtension();
+
+ const root = createRootMessageHandler("session-id-event");
+
+ // When the windowglobal command.jsm module applies the session data
+ // browser_session_data_browser_element, it will emit an event.
+ // Collect the events to detect which MessageHandlers have been started.
+ info("Watch events emitted when session data is applied");
+ const sessionDataEvents = [];
+ const onRootEvent = function (evtName, wrappedEvt) {
+ if (wrappedEvt.name === "received-session-data") {
+ sessionDataEvents.push(wrappedEvt.data.contextId);
+ }
+ };
+ root.on("message-handler-event", onRootEvent);
+
+ info("Add a new session data item, expect one return value");
+ await root.addSessionDataItem({
+ moduleName: "command",
+ category: "browser_session_data_browser_element",
+ contextDescriptor: {
+ type: ContextDescriptorType.All,
+ },
+ values: [true],
+ });
+
+ function hasSessionData(browsingContext) {
+ return sessionDataEvents.includes(browsingContext.id);
+ }
+
+ info(
+ "Check that only the content tab window global received the session data"
+ );
+ is(hasSessionData(contentBrowser1.browsingContext), true);
+ is(hasSessionData(parentBrowser1.browsingContext), false);
+ is(hasSessionData(parentBrowser2.browsingContext), false);
+ is(hasSessionData(extSidebarBrowser1.browsingContext), false);
+
+ const tab2 = BrowserTestUtils.addTab(gBrowser, TEST_PAGE);
+ const contentBrowser2 = tab2.linkedBrowser;
+ await BrowserTestUtils.browserLoaded(contentBrowser2);
+ const parentBrowser3 = createParentBrowserElement(contentBrowser2, "content");
+ const parentBrowser4 = createParentBrowserElement(contentBrowser2, "chrome");
+
+ const { extension: extension2, sidebarBrowser: extSidebarBrowser2 } =
+ await installSidebarExtension();
+
+ info("Wait until the session data was applied to the new tab");
+ await TestUtils.waitForCondition(() =>
+ sessionDataEvents.includes(contentBrowser2.browsingContext.id)
+ );
+
+ info("Check that parent browser elements did not apply the session data");
+ is(hasSessionData(parentBrowser3.browsingContext), false);
+ is(hasSessionData(parentBrowser4.browsingContext), false);
+
+ info(
+ "Check that extension did not apply the session data, " +
+ extSidebarBrowser2.browsingContext.id
+ );
+ is(hasSessionData(extSidebarBrowser2.browsingContext), false);
+
+ root.destroy();
+
+ gBrowser.removeTab(tab1);
+ gBrowser.removeTab(tab2);
+ await extension1.unload();
+ await extension2.unload();
+});
diff --git a/remote/shared/messagehandler/test/browser/browser_session_data_constructor_race.js b/remote/shared/messagehandler/test/browser/browser_session_data_constructor_race.js
new file mode 100644
index 0000000000..03ed59166f
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/browser_session_data_constructor_race.js
@@ -0,0 +1,50 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_PAGE = "https://example.com/document-builder.sjs?html=tab";
+
+/**
+ * Check that modules created early for session data are still created with a
+ * fully initialized MessageHandler. See Bug 1743083.
+ */
+add_task(async function () {
+ const tab = BrowserTestUtils.addTab(gBrowser, TEST_PAGE);
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ const browsingContext = tab.linkedBrowser.browsingContext;
+
+ const root = createRootMessageHandler("session-id-event");
+
+ info("Add some session data for the command module");
+ await root.addSessionDataItem({
+ moduleName: "command",
+ category: "testCategory",
+ contextDescriptor: contextDescriptorAll,
+ values: ["some-value"],
+ });
+
+ info("Reload the current tab to create new message handlers and modules");
+ await BrowserTestUtils.reloadTab(tab);
+
+ info(
+ "Check if the command module was created by the MessageHandler constructor"
+ );
+ const isCreatedByMessageHandlerConstructor = await root.handleCommand({
+ moduleName: "command",
+ commandName: "testIsCreatedByMessageHandlerConstructor",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContext.id,
+ },
+ });
+
+ is(
+ isCreatedByMessageHandlerConstructor,
+ false,
+ "The command module from session data should not be created by the MessageHandler constructor"
+ );
+ root.destroy();
+
+ gBrowser.removeTab(tab);
+});
diff --git a/remote/shared/messagehandler/test/browser/browser_session_data_update.js b/remote/shared/messagehandler/test/browser/browser_session_data_update.js
new file mode 100644
index 0000000000..342a4a6139
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/browser_session_data_update.js
@@ -0,0 +1,113 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_PAGE = "https://example.com/document-builder.sjs?html=tab";
+
+const { assertUpdate, createSessionDataUpdate, getUpdates } =
+ SessionDataUpdateHelpers;
+
+// Test various session data update scenarios against a single browsing context.
+add_task(async function test_session_data_update() {
+ const tab1 = gBrowser.selectedTab;
+ await loadURL(tab1.linkedBrowser, TEST_PAGE);
+ const browsingContext1 = tab1.linkedBrowser.browsingContext;
+
+ const root = createRootMessageHandler("session-data-update");
+
+ info("Add a new session data item, expect one return value");
+ await root.updateSessionData([
+ createSessionDataUpdate(["text-1"], "add", "category1"),
+ ]);
+ let processedUpdates = await getUpdates(root, browsingContext1);
+ is(processedUpdates.length, 1);
+ assertUpdate(processedUpdates.at(-1), ["text-1"], "category1");
+
+ info("Add two session data items, expect one return value with both items");
+ await root.updateSessionData([
+ createSessionDataUpdate(["text-2"], "add", "category1"),
+ createSessionDataUpdate(["text-3"], "add", "category1"),
+ ]);
+ processedUpdates = await getUpdates(root, browsingContext1);
+ is(processedUpdates.length, 2);
+ assertUpdate(
+ processedUpdates.at(-1),
+ ["text-1", "text-2", "text-3"],
+ "category1"
+ );
+
+ info("Try to add an existing data item, expect no update broadcast");
+ await root.updateSessionData([
+ createSessionDataUpdate(["text-1"], "add", "category1"),
+ ]);
+ processedUpdates = await getUpdates(root, browsingContext1);
+ is(processedUpdates.length, 2);
+
+ info("Add an existing and a new item");
+ await root.updateSessionData([
+ createSessionDataUpdate(["text-2", "text-4"], "add", "category1"),
+ ]);
+ processedUpdates = await getUpdates(root, browsingContext1);
+ is(processedUpdates.length, 3);
+ assertUpdate(
+ processedUpdates.at(-1),
+ ["text-1", "text-2", "text-3", "text-4"],
+ "category1"
+ );
+
+ info("Remove an item, expect only the new item to return");
+ await root.updateSessionData([
+ createSessionDataUpdate(["text-3"], "remove", "category1"),
+ ]);
+ processedUpdates = await getUpdates(root, browsingContext1);
+ is(processedUpdates.length, 4);
+ assertUpdate(
+ processedUpdates.at(-1),
+ ["text-1", "text-2", "text-4"],
+ "category1"
+ );
+
+ info("Remove a unknown item, expect no return value");
+ await root.updateSessionData([
+ createSessionDataUpdate(["text-unknown"], "remove", "category1"),
+ ]);
+ processedUpdates = await getUpdates(root, browsingContext1);
+ is(processedUpdates.length, 4);
+ assertUpdate(
+ processedUpdates.at(-1),
+ ["text-1", "text-2", "text-4"],
+ "category1"
+ );
+
+ info("Remove an existing and a unknown item");
+ await root.updateSessionData([
+ createSessionDataUpdate(["text-2"], "remove", "category1"),
+ createSessionDataUpdate(["text-unknown"], "remove", "category1"),
+ ]);
+ processedUpdates = await getUpdates(root, browsingContext1);
+ is(processedUpdates.length, 5);
+ assertUpdate(processedUpdates.at(-1), ["text-1", "text-4"], "category1");
+
+ info("Add and remove at once");
+ await root.updateSessionData([
+ createSessionDataUpdate(["text-5"], "add", "category1"),
+ createSessionDataUpdate(["text-4"], "remove", "category1"),
+ ]);
+ processedUpdates = await getUpdates(root, browsingContext1);
+ is(processedUpdates.length, 6);
+ assertUpdate(processedUpdates.at(-1), ["text-1", "text-5"], "category1");
+
+ info("Adding and removing an item does not trigger any update");
+ await root.updateSessionData([
+ createSessionDataUpdate(["text-6"], "add", "category1"),
+ createSessionDataUpdate(["text-6"], "remove", "category1"),
+ ]);
+ processedUpdates = await getUpdates(root, browsingContext1);
+ // TODO: We could detect transactions which can't have any impact and fully
+ // ignore them. See Bug 1810807.
+ todo_is(processedUpdates.length, 6);
+ assertUpdate(processedUpdates.at(-1), ["text-1", "text-5"], "category1");
+
+ root.destroy();
+});
diff --git a/remote/shared/messagehandler/test/browser/browser_session_data_update_categories.js b/remote/shared/messagehandler/test/browser/browser_session_data_update_categories.js
new file mode 100644
index 0000000000..b1cadcf095
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/browser_session_data_update_categories.js
@@ -0,0 +1,91 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_PAGE = "https://example.com/document-builder.sjs?html=tab";
+
+const { assertUpdate, createSessionDataUpdate, getUpdates } =
+ SessionDataUpdateHelpers;
+
+// Test session data update scenarios involving different session data item
+// categories.
+add_task(async function test_session_data_update_categories() {
+ const tab1 = gBrowser.selectedTab;
+ await loadURL(tab1.linkedBrowser, TEST_PAGE);
+ const browsingContext1 = tab1.linkedBrowser.browsingContext;
+
+ const root = createRootMessageHandler("session-data-update-categories");
+ await root.updateSessionData([
+ createSessionDataUpdate(["value1-1"], "add", "category1"),
+ createSessionDataUpdate(["value1-2"], "add", "category1"),
+ ]);
+
+ let processedUpdates = await getUpdates(root, browsingContext1);
+
+ is(processedUpdates.length, 1);
+ assertUpdate(processedUpdates.at(-1), ["value1-1", "value1-2"], "category1");
+
+ info("Adding a new item in category1 broadcasts all category1 items");
+ await root.updateSessionData([
+ createSessionDataUpdate(["value1-3"], "add", "category1"),
+ ]);
+ processedUpdates = await getUpdates(root, browsingContext1);
+ is(processedUpdates.length, 2);
+ assertUpdate(
+ processedUpdates.at(-1),
+ ["value1-1", "value1-2", "value1-3"],
+ "category1"
+ );
+
+ info("Removing a new item in category1 broadcasts all category1 items");
+ await root.updateSessionData([
+ createSessionDataUpdate(["value1-1"], "remove", "category1"),
+ ]);
+ processedUpdates = await getUpdates(root, browsingContext1);
+ is(processedUpdates.length, 3);
+ assertUpdate(processedUpdates.at(-1), ["value1-2", "value1-3"], "category1");
+
+ info("Adding a new category does not broadcast category1 items");
+ await root.updateSessionData([
+ createSessionDataUpdate(["value2-1"], "add", "category2"),
+ ]);
+ processedUpdates = await getUpdates(root, browsingContext1);
+ is(processedUpdates.length, 4);
+ assertUpdate(processedUpdates.at(-1), ["value2-1"], "category2");
+
+ info("Adding an item in 2 categories triggers an update for each category");
+ await root.updateSessionData([
+ createSessionDataUpdate(["value1-4"], "add", "category1"),
+ createSessionDataUpdate(["value2-2"], "add", "category2"),
+ ]);
+ processedUpdates = await getUpdates(root, browsingContext1);
+ is(processedUpdates.length, 6);
+ assertUpdate(
+ processedUpdates.at(-2),
+ ["value1-2", "value1-3", "value1-4"],
+ "category1"
+ );
+ assertUpdate(processedUpdates.at(-1), ["value2-1", "value2-2"], "category2");
+
+ info("Removing an item in 2 categories triggers an update for each category");
+ await root.updateSessionData([
+ createSessionDataUpdate(["value1-4"], "remove", "category1"),
+ createSessionDataUpdate(["value2-2"], "remove", "category2"),
+ ]);
+ processedUpdates = await getUpdates(root, browsingContext1);
+ is(processedUpdates.length, 8);
+ assertUpdate(processedUpdates.at(-2), ["value1-2", "value1-3"], "category1");
+ assertUpdate(processedUpdates.at(-1), ["value2-1"], "category2");
+
+ info("Opening a new tab triggers an update for each category");
+ const tab2 = await addTab(TEST_PAGE);
+ const browsingContext2 = tab2.linkedBrowser.browsingContext;
+ processedUpdates = await getUpdates(root, browsingContext2);
+ is(processedUpdates.length, 2);
+ assertUpdate(processedUpdates.at(-2), ["value1-2", "value1-3"], "category1");
+ assertUpdate(processedUpdates.at(-1), ["value2-1"], "category2");
+
+ root.destroy();
+ gBrowser.removeTab(tab2);
+});
diff --git a/remote/shared/messagehandler/test/browser/browser_session_data_update_contexts.js b/remote/shared/messagehandler/test/browser/browser_session_data_update_contexts.js
new file mode 100644
index 0000000000..711df1fc56
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/browser_session_data_update_contexts.js
@@ -0,0 +1,194 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_PAGE = "https://example.com/document-builder.sjs?html=tab";
+
+const { assertUpdate, createSessionDataUpdate, getUpdates } =
+ SessionDataUpdateHelpers;
+
+// Test session data update scenarios involving 2 browsing contexts, and using
+// the TopBrowsingContext ContextDescriptor type.
+add_task(async function test_session_data_update_contexts() {
+ const tab1 = gBrowser.selectedTab;
+ await loadURL(tab1.linkedBrowser, TEST_PAGE);
+ const browsingContext1 = tab1.linkedBrowser.browsingContext;
+
+ const root = createRootMessageHandler("session-data-update-contexts");
+
+ info("Add several items over 2 separate updates for all contexts");
+ await root.updateSessionData([
+ createSessionDataUpdate(["text-1"], "add", "category1"),
+ ]);
+ await root.updateSessionData([
+ createSessionDataUpdate(["text-2"], "add", "category1"),
+ createSessionDataUpdate(["text-3"], "add", "category1"),
+ ]);
+
+ info("Check we processed two distinct updates in browsingContext 1");
+ let processedUpdates = await getUpdates(root, browsingContext1);
+ is(processedUpdates.length, 2);
+ assertUpdate(
+ processedUpdates.at(-1),
+ ["text-1", "text-2", "text-3"],
+ "category1"
+ );
+
+ info("Open a new tab on the same test URL");
+ const tab2 = await addTab(TEST_PAGE);
+ const browsingContext2 = tab2.linkedBrowser.browsingContext;
+
+ processedUpdates = await getUpdates(root, browsingContext2);
+ is(processedUpdates.length, 1);
+ assertUpdate(
+ processedUpdates.at(-1),
+ ["text-1", "text-2", "text-3"],
+ "category1"
+ );
+
+ info("Add two items: one globally and one in a single context");
+ await root.updateSessionData([
+ createSessionDataUpdate(["text-4"], "add", "category1"),
+ createSessionDataUpdate(["text-5"], "add", "category1", {
+ type: ContextDescriptorType.TopBrowsingContext,
+ id: browsingContext2.browserId,
+ }),
+ ]);
+
+ processedUpdates = await getUpdates(root, browsingContext1);
+ is(processedUpdates.length, 3);
+ assertUpdate(
+ processedUpdates.at(-1),
+ ["text-1", "text-2", "text-3", "text-4"],
+ "category1"
+ );
+
+ processedUpdates = await getUpdates(root, browsingContext2);
+ is(processedUpdates.length, 2);
+ assertUpdate(
+ processedUpdates.at(-1),
+ ["text-1", "text-2", "text-3", "text-4", "text-5"],
+ "category1"
+ );
+
+ info("Remove two items: one globally and one in a single context");
+ await root.updateSessionData([
+ createSessionDataUpdate(["text-1"], "remove", "category1"),
+ createSessionDataUpdate(["text-5"], "remove", "category1", {
+ type: ContextDescriptorType.TopBrowsingContext,
+ id: browsingContext2.browserId,
+ }),
+ ]);
+
+ processedUpdates = await getUpdates(root, browsingContext1);
+ is(processedUpdates.length, 4);
+ assertUpdate(
+ processedUpdates.at(-1),
+ ["text-2", "text-3", "text-4"],
+ "category1"
+ );
+
+ processedUpdates = await getUpdates(root, browsingContext2);
+ is(processedUpdates.length, 3);
+ assertUpdate(
+ processedUpdates.at(-1),
+ ["text-2", "text-3", "text-4"],
+ "category1"
+ );
+
+ info(
+ "Add session data item to all contexts and remove this event for one context (2 steps)"
+ );
+
+ info("First step: add an item to browsingContext1");
+ await root.updateSessionData([
+ createSessionDataUpdate(["text-6"], "add", "category1", {
+ type: ContextDescriptorType.TopBrowsingContext,
+ id: browsingContext1.browserId,
+ }),
+ ]);
+
+ info(
+ "Second step: remove the item from browsingContext1, and add it globally"
+ );
+ await root.updateSessionData([
+ createSessionDataUpdate(["text-6"], "remove", "category1", {
+ type: ContextDescriptorType.TopBrowsingContext,
+ id: browsingContext1.browserId,
+ }),
+ createSessionDataUpdate(["text-6"], "add", "category1"),
+ ]);
+
+ processedUpdates = await getUpdates(root, browsingContext1);
+ is(processedUpdates.length, 6);
+ assertUpdate(
+ processedUpdates.at(-1),
+ ["text-2", "text-3", "text-4", "text-6"],
+ "category1"
+ );
+
+ processedUpdates = await getUpdates(root, browsingContext2);
+ is(processedUpdates.length, 4);
+ assertUpdate(
+ processedUpdates.at(-1),
+ ["text-2", "text-3", "text-4", "text-6"],
+ "category1"
+ );
+
+ info(
+ "Remove the event, which has also an individual subscription, for all contexts (2 steps)"
+ );
+
+ info("First step: Add the same item for browsingContext1 and globally");
+ await root.updateSessionData([
+ createSessionDataUpdate(["text-7"], "add", "category1", {
+ type: ContextDescriptorType.TopBrowsingContext,
+ id: browsingContext1.browserId,
+ }),
+ createSessionDataUpdate(["text-7"], "add", "category1"),
+ ]);
+ processedUpdates = await getUpdates(root, browsingContext1);
+ is(processedUpdates.length, 7);
+ // We will find text-7 twice here, the module is responsible for not applying
+ // the same session data item twice. Each item corresponds to a different
+ // descriptor which matched browsingContext1.
+ assertUpdate(
+ processedUpdates.at(-1),
+ ["text-2", "text-3", "text-4", "text-6", "text-7", "text-7"],
+ "category1"
+ );
+
+ processedUpdates = await getUpdates(root, browsingContext2);
+ is(processedUpdates.length, 5);
+ assertUpdate(
+ processedUpdates.at(-1),
+ ["text-2", "text-3", "text-4", "text-6", "text-7"],
+ "category1"
+ );
+
+ info("Second step: Remove the item globally");
+ await root.updateSessionData([
+ createSessionDataUpdate(["text-7"], "remove", "category1"),
+ ]);
+
+ processedUpdates = await getUpdates(root, browsingContext1);
+ is(processedUpdates.length, 8);
+ assertUpdate(
+ processedUpdates.at(-1),
+ ["text-2", "text-3", "text-4", "text-6", "text-7"],
+ "category1"
+ );
+
+ processedUpdates = await getUpdates(root, browsingContext2);
+ is(processedUpdates.length, 6);
+ assertUpdate(
+ processedUpdates.at(-1),
+ ["text-2", "text-3", "text-4", "text-6"],
+ "category1"
+ );
+
+ root.destroy();
+
+ gBrowser.removeTab(tab2);
+});
diff --git a/remote/shared/messagehandler/test/browser/browser_windowglobal_to_root.js b/remote/shared/messagehandler/test/browser/browser_windowglobal_to_root.js
new file mode 100644
index 0000000000..57629e5485
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/browser_windowglobal_to_root.js
@@ -0,0 +1,47 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { RootMessageHandler } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs"
+);
+
+add_task(async function test_windowGlobal_to_root_command() {
+ // Navigate to a page to make sure that the windowglobal modules run in a
+ // different process than the root module.
+ const tab = BrowserTestUtils.addTab(
+ gBrowser,
+ "https://example.com/document-builder.sjs?html=tab"
+ );
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ const browsingContextId = tab.linkedBrowser.browsingContext.id;
+
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-windowglobal-to-rootModule"
+ );
+
+ for (const commandName of [
+ "testHandleCommandToRoot",
+ "testSendRootCommand",
+ ]) {
+ const valueFromRoot = await rootMessageHandler.handleCommand({
+ moduleName: "windowglobaltoroot",
+ commandName,
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContextId,
+ },
+ });
+
+ is(
+ valueFromRoot,
+ "root-value-called-from-windowglobal",
+ "Retrieved the expected value from windowglobaltoroot using " +
+ commandName
+ );
+ }
+
+ rootMessageHandler.destroy();
+ gBrowser.removeTab(tab);
+});
diff --git a/remote/shared/messagehandler/test/browser/head.js b/remote/shared/messagehandler/test/browser/head.js
new file mode 100644
index 0000000000..81cf0942d3
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/head.js
@@ -0,0 +1,236 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var { ContextDescriptorType } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/MessageHandler.sys.mjs"
+);
+
+var { WindowGlobalMessageHandler } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs"
+);
+
+var contextDescriptorAll = {
+ type: ContextDescriptorType.All,
+};
+
+function createRootMessageHandler(sessionId) {
+ const { RootMessageHandlerRegistry } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/RootMessageHandlerRegistry.sys.mjs"
+ );
+ return RootMessageHandlerRegistry.getOrCreateMessageHandler(sessionId);
+}
+
+/**
+ * Load the provided url in an existing browser.
+ * Returns a promise which will resolve when the page is loaded.
+ *
+ * @param {Browser} browser
+ * The browser element where the URL should be loaded.
+ * @param {string} url
+ * The URL to load in the new tab
+ */
+async function loadURL(browser, url) {
+ const loaded = BrowserTestUtils.browserLoaded(browser);
+ BrowserTestUtils.startLoadingURIString(browser, url);
+ return loaded;
+}
+
+/**
+ * Create a new foreground tab loading the provided url.
+ * Returns a promise which will resolve when the page is loaded.
+ *
+ * @param {string} url
+ * The URL to load in the new tab
+ */
+async function addTab(url) {
+ const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ registerCleanupFunction(() => {
+ gBrowser.removeTab(tab);
+ });
+ return tab;
+}
+
+/**
+ * Create inline markup for a simple iframe that can be used with
+ * document-builder.sjs. The iframe will be served under the provided domain.
+ *
+ * @param {string} domain
+ * A domain (eg "example.com"), compatible with build/pgo/server-locations.txt
+ */
+function createFrame(domain) {
+ return createFrameForUri(
+ `https://${domain}/document-builder.sjs?html=frame-${domain}`
+ );
+}
+
+function createFrameForUri(uri) {
+ return `<iframe src="${encodeURI(uri)}"></iframe>`;
+}
+
+/**
+ * Create a XUL browser element in the provided XUL tab, with the provided type.
+ *
+ * @param {XULTab} tab
+ * The XUL tab in which the browser element should be inserted.
+ * @param {string} type
+ * The type attribute of the browser element, "chrome" or "content".
+ * @returns {XULBrowser}
+ * The created browser element.
+ */
+function createParentBrowserElement(tab, type) {
+ const parentBrowser = gBrowser.ownerDocument.createXULElement("browser");
+ parentBrowser.setAttribute("type", type);
+ const container = gBrowser.getBrowserContainer(tab.linkedBrowser);
+ container.appendChild(parentBrowser);
+
+ return parentBrowser;
+}
+
+// Create a test page with 2 iframes:
+// - one with a different eTLD+1 (example.com)
+// - one with a nested iframe on a different eTLD+1 (example.net)
+//
+// Overall the document structure should look like:
+//
+// html (example.org)
+// iframe (example.org)
+// iframe (example.net)
+// iframe(example.com)
+//
+// Which means we should have 4 browsing contexts in total.
+function createTestMarkupWithFrames() {
+ // Create the markup for an example.net frame nested in an example.com frame.
+ const NESTED_FRAME_MARKUP = createFrameForUri(
+ `https://example.org/document-builder.sjs?html=${createFrame(
+ "example.net"
+ )}`
+ );
+
+ // Combine the nested frame markup created above with an example.com frame.
+ const TEST_URI_MARKUP = `${NESTED_FRAME_MARKUP}${createFrame("example.com")}`;
+
+ // Create the test page URI on example.org.
+ return `https://example.org/document-builder.sjs?html=${encodeURI(
+ TEST_URI_MARKUP
+ )}`;
+}
+
+const hasPromiseResolved = async function (promise) {
+ let resolved = false;
+ promise.finally(() => (resolved = true));
+ // Make sure microtasks have time to run.
+ await new Promise(resolve => Services.tm.dispatchToMainThread(resolve));
+ return resolved;
+};
+
+/**
+ * Install a sidebar extension.
+ *
+ * @returns {object}
+ * Return value with two properties:
+ * - extension: test wrapper as returned by SpecialPowers.loadExtension.
+ * Make sure to explicitly call extension.unload() before the end of the test.
+ * - sidebarBrowser: the browser element containing the extension sidebar.
+ */
+async function installSidebarExtension() {
+ info("Load the test extension");
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ sidebar_action: {
+ default_panel: "sidebar.html",
+ },
+ },
+ useAddonManager: "temporary",
+
+ files: {
+ "sidebar.html": `
+ <!DOCTYPE html>
+ <html>
+ Test extension
+ <script src="sidebar.js"></script>
+ </html>
+ `,
+ "sidebar.js": function () {
+ const { browser } = this;
+ browser.test.sendMessage("sidebar-loaded", {
+ bcId: SpecialPowers.wrap(window).browsingContext.id,
+ });
+ },
+ "tab.html": `
+ <!DOCTYPE html>
+ <html>
+ Test extension (tab)
+ <script src="tab.js"></script>
+ </html>
+ `,
+ "tab.js": function () {
+ const { browser } = this;
+ browser.test.sendMessage("tab-loaded", {
+ bcId: SpecialPowers.wrap(window).browsingContext.id,
+ });
+ },
+ },
+ });
+
+ info("Wait for the extension to start");
+ await extension.startup();
+
+ info("Wait for the extension browsing context");
+ const { bcId } = await extension.awaitMessage("sidebar-loaded");
+ const sidebarBrowser = BrowsingContext.get(bcId).top.embedderElement;
+ ok(sidebarBrowser, "Got a browser element for the extension sidebar");
+
+ return {
+ extension,
+ sidebarBrowser,
+ };
+}
+
+const SessionDataUpdateHelpers = {
+ getUpdates(rootMessageHandler, browsingContext) {
+ return rootMessageHandler.handleCommand({
+ moduleName: "sessiondataupdate",
+ commandName: "getSessionDataUpdates",
+ destination: {
+ id: browsingContext.id,
+ type: WindowGlobalMessageHandler.type,
+ },
+ });
+ },
+
+ createSessionDataUpdate(
+ values,
+ method,
+ category,
+ descriptor = { type: ContextDescriptorType.All }
+ ) {
+ return {
+ method,
+ values,
+ moduleName: "sessiondataupdate",
+ category,
+ contextDescriptor: descriptor,
+ };
+ },
+
+ assertUpdate(update, expectedValues, expectedCategory) {
+ is(
+ update.length,
+ expectedValues.length,
+ "Update has the expected number of values"
+ );
+
+ for (const item of update) {
+ info(`Check session data update item '${item.value}'`);
+ is(item.category, expectedCategory, "Item has the expected category");
+ is(
+ expectedValues[update.indexOf(item)],
+ item.value,
+ "Item has the expected value"
+ );
+ }
+ },
+};
diff --git a/remote/shared/messagehandler/test/browser/resources/modules/ModuleRegistry.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/ModuleRegistry.sys.mjs
new file mode 100644
index 0000000000..7d93f45b33
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/resources/modules/ModuleRegistry.sys.mjs
@@ -0,0 +1,40 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+export const modules = {
+ root: {},
+ "windowglobal-in-root": {},
+ windowglobal: {},
+};
+
+const BASE_FOLDER =
+ "chrome://mochitests/content/browser/remote/shared/messagehandler/test/browser/resources/modules";
+
+// eslint-disable-next-line mozilla/lazy-getter-object-name
+ChromeUtils.defineESModuleGetters(modules.root, {
+ command: `${BASE_FOLDER}/root/command.sys.mjs`,
+ event: `${BASE_FOLDER}/root/event.sys.mjs`,
+ invalid: `${BASE_FOLDER}/root/invalid.sys.mjs`,
+ rootOnly: `${BASE_FOLDER}/root/rootOnly.sys.mjs`,
+ windowglobaltoroot: `${BASE_FOLDER}/root/windowglobaltoroot.sys.mjs`,
+});
+
+// eslint-disable-next-line mozilla/lazy-getter-object-name
+ChromeUtils.defineESModuleGetters(modules["windowglobal-in-root"], {
+ command: `${BASE_FOLDER}/windowglobal-in-root/command.sys.mjs`,
+ event: `${BASE_FOLDER}/windowglobal-in-root/event.sys.mjs`,
+});
+
+// eslint-disable-next-line mozilla/lazy-getter-object-name
+ChromeUtils.defineESModuleGetters(modules.windowglobal, {
+ command: `${BASE_FOLDER}/windowglobal/command.sys.mjs`,
+ commandwindowglobalonly: `${BASE_FOLDER}/windowglobal/commandwindowglobalonly.sys.mjs`,
+ event: `${BASE_FOLDER}/windowglobal/event.sys.mjs`,
+ eventemitter: `${BASE_FOLDER}/windowglobal/eventemitter.sys.mjs`,
+ eventnointercept: `${BASE_FOLDER}/windowglobal/eventnointercept.sys.mjs`,
+ eventonprefchange: `${BASE_FOLDER}/windowglobal/eventonprefchange.sys.mjs`,
+ retry: `${BASE_FOLDER}/windowglobal/retry.sys.mjs`,
+ sessiondataupdate: `${BASE_FOLDER}/windowglobal/sessiondataupdate.sys.mjs`,
+ windowglobaltoroot: `${BASE_FOLDER}/windowglobal/windowglobaltoroot.sys.mjs`,
+});
diff --git a/remote/shared/messagehandler/test/browser/resources/modules/root/command.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/root/command.sys.mjs
new file mode 100644
index 0000000000..29e4a75828
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/resources/modules/root/command.sys.mjs
@@ -0,0 +1,29 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs";
+
+class CommandModule extends Module {
+ destroy() {}
+
+ /**
+ * Commands
+ */
+
+ testRootModule() {
+ return "root-value";
+ }
+
+ testMissingIntermediaryMethod(params, destination) {
+ // Spawn a new internal command, but with a commandName which doesn't match
+ // any method.
+ return this.messageHandler.handleCommand({
+ moduleName: "command",
+ commandName: "missingMethod",
+ destination,
+ });
+ }
+}
+
+export const command = CommandModule;
diff --git a/remote/shared/messagehandler/test/browser/resources/modules/root/event.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/root/event.sys.mjs
new file mode 100644
index 0000000000..e49437e80d
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/resources/modules/root/event.sys.mjs
@@ -0,0 +1,21 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs";
+
+class EventModule extends Module {
+ destroy() {}
+
+ /**
+ * Commands
+ */
+
+ testEmitRootEvent() {
+ this.emitEvent("event-from-root", {
+ text: "event from root",
+ });
+ }
+}
+
+export const event = EventModule;
diff --git a/remote/shared/messagehandler/test/browser/resources/modules/root/invalid.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/root/invalid.sys.mjs
new file mode 100644
index 0000000000..3b74769d06
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/resources/modules/root/invalid.sys.mjs
@@ -0,0 +1,4 @@
+// This module is meant to check error reporting when importing a module fails
+// due to an actual issue (syntax error etc...).
+
+SyntaxError(;
diff --git a/remote/shared/messagehandler/test/browser/resources/modules/root/rootOnly.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/root/rootOnly.sys.mjs
new file mode 100644
index 0000000000..0931a7ee8e
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/resources/modules/root/rootOnly.sys.mjs
@@ -0,0 +1,70 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { ContextDescriptorType } from "chrome://remote/content/shared/messagehandler/MessageHandler.sys.mjs";
+import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs";
+
+class RootOnlyModule extends Module {
+ #sessionDataReceived;
+ #subscribedEvents;
+
+ constructor(messageHandler) {
+ super(messageHandler);
+
+ this.#sessionDataReceived = [];
+ this.#subscribedEvents = new Set();
+ }
+
+ destroy() {}
+
+ /**
+ * Commands
+ */
+
+ getSessionDataReceived() {
+ return this.#sessionDataReceived;
+ }
+
+ testCommand(params = {}) {
+ return params;
+ }
+
+ _applySessionData(params) {
+ const added = [];
+ const removed = [];
+
+ const filteredSessionData = params.sessionData.filter(item =>
+ this.messageHandler.matchesContext(item.contextDescriptor)
+ );
+ for (const event of this.#subscribedEvents.values()) {
+ const hasSessionItem = filteredSessionData.some(
+ item => item.value === event
+ );
+ // If there are no session items for this context, we should unsubscribe from the event.
+ if (!hasSessionItem) {
+ this.#subscribedEvents.delete(event);
+ removed.push(event);
+ }
+ }
+
+ // Subscribe to all events, which have an item in SessionData
+ for (const { value } of filteredSessionData) {
+ if (!this.#subscribedEvents.has(value)) {
+ this.#subscribedEvents.add(value);
+ added.push(value);
+ }
+ }
+
+ this.#sessionDataReceived.push({
+ category: params.category,
+ added,
+ removed,
+ contextDescriptor: {
+ type: ContextDescriptorType.All,
+ },
+ });
+ }
+}
+
+export const rootOnly = RootOnlyModule;
diff --git a/remote/shared/messagehandler/test/browser/resources/modules/root/windowglobaltoroot.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/root/windowglobaltoroot.sys.mjs
new file mode 100644
index 0000000000..0975c4abd5
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/resources/modules/root/windowglobaltoroot.sys.mjs
@@ -0,0 +1,29 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs";
+
+class WindowGlobalToRootModule extends Module {
+ destroy() {}
+
+ /**
+ * Commands
+ */
+
+ getValueFromRoot() {
+ this.#assertParentProcess();
+ return "root-value-called-from-windowglobal";
+ }
+
+ #assertParentProcess() {
+ const isParent =
+ Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_DEFAULT;
+
+ if (!isParent) {
+ throw new Error("Can only run in the parent process");
+ }
+ }
+}
+
+export const windowglobaltoroot = WindowGlobalToRootModule;
diff --git a/remote/shared/messagehandler/test/browser/resources/modules/windowglobal-in-root/command.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal-in-root/command.sys.mjs
new file mode 100644
index 0000000000..f9a2e5d4eb
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal-in-root/command.sys.mjs
@@ -0,0 +1,28 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs";
+
+class CommandModule extends Module {
+ destroy() {}
+
+ /**
+ * Commands
+ */
+
+ testInterceptModule() {
+ return "intercepted-value";
+ }
+
+ async testInterceptAndForwardModule(params, destination) {
+ const windowGlobalValue = await this.messageHandler.handleCommand({
+ moduleName: "command",
+ commandName: "testForwardToWindowGlobal",
+ destination,
+ });
+ return "intercepted-and-forward+" + windowGlobalValue;
+ }
+}
+
+export const command = CommandModule;
diff --git a/remote/shared/messagehandler/test/browser/resources/modules/windowglobal-in-root/event.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal-in-root/event.sys.mjs
new file mode 100644
index 0000000000..be8b284e8d
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal-in-root/event.sys.mjs
@@ -0,0 +1,39 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs";
+
+class EventModule extends Module {
+ destroy() {}
+
+ interceptEvent(name, payload) {
+ if (name === "event.testEventWithInterception") {
+ return {
+ ...payload,
+ additionalInformation: "information added through interception",
+ };
+ }
+
+ if (name === "event.testEventCancelableWithInterception") {
+ if (payload.shouldCancel) {
+ return null;
+ }
+ return payload;
+ }
+
+ return payload;
+ }
+
+ /**
+ * Commands
+ */
+
+ testEmitWindowGlobalInRootEvent(params, destination) {
+ this.emitEvent("event-from-window-global-in-root", {
+ text: `windowglobal-in-root event for ${destination.id}`,
+ });
+ }
+}
+
+export const event = EventModule;
diff --git a/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/command.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/command.sys.mjs
new file mode 100644
index 0000000000..99ee76a4b8
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/command.sys.mjs
@@ -0,0 +1,85 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs";
+
+class CommandModule extends Module {
+ constructor(messageHandler) {
+ super(messageHandler);
+ this._subscribedEvents = new Set();
+
+ this._createdByMessageHandlerConstructor =
+ this._isCreatedByMessageHandlerConstructor();
+ }
+ destroy() {}
+
+ /**
+ * Commands
+ */
+
+ _applySessionData(params) {
+ if (params.category === "testCategory") {
+ const filteredSessionData = params.sessionData.filter(item =>
+ this.messageHandler.matchesContext(item.contextDescriptor)
+ );
+ for (const event of this._subscribedEvents.values()) {
+ const hasSessionItem = filteredSessionData.some(
+ item => item.value === event
+ );
+ // If there are no session items for this context, we should unsubscribe from the event.
+ if (!hasSessionItem) {
+ this._subscribedEvents.delete(event);
+ }
+ }
+
+ // Subscribe to all events, which have an item in SessionData
+ for (const { value } of filteredSessionData) {
+ if (!this._subscribedEvents.has(value)) {
+ this._subscribedEvents.add(value);
+ }
+ }
+ }
+
+ if (params.category === "browser_session_data_browser_element") {
+ this.emitEvent("received-session-data", {
+ contextId: this.messageHandler.contextId,
+ });
+ }
+ }
+
+ testWindowGlobalModule() {
+ return "windowglobal-value";
+ }
+
+ testSetValue(params) {
+ const { value } = params;
+
+ this._testValue = value;
+ }
+
+ testGetValue() {
+ return this._testValue;
+ }
+
+ testForwardToWindowGlobal() {
+ return "forward-to-windowglobal-value";
+ }
+
+ testIsCreatedByMessageHandlerConstructor() {
+ return this._createdByMessageHandlerConstructor;
+ }
+
+ _isCreatedByMessageHandlerConstructor() {
+ let caller = Components.stack.caller;
+ while (caller) {
+ if (caller.name === this.messageHandler.constructor.name) {
+ return true;
+ }
+ caller = caller.caller;
+ }
+ return false;
+ }
+}
+
+export const command = CommandModule;
diff --git a/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/commandwindowglobalonly.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/commandwindowglobalonly.sys.mjs
new file mode 100644
index 0000000000..1e4e6c1574
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/commandwindowglobalonly.sys.mjs
@@ -0,0 +1,41 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs";
+
+class CommandWindowGlobalOnlyModule extends Module {
+ destroy() {}
+
+ /**
+ * Commands
+ */
+
+ testOnlyInWindowGlobal() {
+ return "only-in-windowglobal";
+ }
+
+ testBroadcast() {
+ return `broadcast-${this.messageHandler.contextId}`;
+ }
+
+ testBroadcastWithParameter(params) {
+ return `broadcast-${this.messageHandler.contextId}-${params.value}`;
+ }
+
+ testError() {
+ throw new Error("error-from-module");
+ }
+
+ testMissingIntermediaryMethod(params, destination) {
+ // Spawn a new internal command, but with a commandName which doesn't match
+ // any method.
+ return this.messageHandler.handleCommand({
+ moduleName: "commandwindowglobalonly",
+ commandName: "missingMethod",
+ destination,
+ });
+ }
+}
+
+export const commandwindowglobalonly = CommandWindowGlobalOnlyModule;
diff --git a/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/event.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/event.sys.mjs
new file mode 100644
index 0000000000..415f32032e
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/event.sys.mjs
@@ -0,0 +1,32 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs";
+
+class EventModule extends Module {
+ destroy() {}
+
+ /**
+ * Commands
+ */
+
+ testEmitEvent() {
+ // Emit a payload including the contextId to check which context emitted
+ // a specific event.
+ const text = `event from ${this.messageHandler.contextId}`;
+ this.emitEvent("event-from-window-global", { text });
+ }
+
+ testEmitEventCancelableWithInterception(params) {
+ this.emitEvent("event.testEventCancelableWithInterception", {
+ shouldCancel: params.shouldCancel,
+ });
+ }
+
+ testEmitEventWithInterception() {
+ this.emitEvent("event.testEventWithInterception", {});
+ }
+}
+
+export const event = EventModule;
diff --git a/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/eventemitter.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/eventemitter.sys.mjs
new file mode 100644
index 0000000000..c86954c5e0
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/eventemitter.sys.mjs
@@ -0,0 +1,81 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs";
+
+class EventEmitterModule extends Module {
+ #isSubscribed;
+ #subscribedEvents;
+
+ constructor(messageHandler) {
+ super(messageHandler);
+ this.#isSubscribed = false;
+ this.#subscribedEvents = new Set();
+ }
+
+ destroy() {}
+
+ /**
+ * Commands
+ */
+
+ emitTestEvent() {
+ if (this.#isSubscribed) {
+ const text = `event from ${this.messageHandler.contextId}`;
+ this.emitEvent("eventemitter.testEvent", { text });
+ }
+
+ // Emit another event consistently for monitoring during the test.
+ this.emitEvent("eventemitter.monitoringEvent", {});
+ }
+
+ isSubscribed() {
+ return this.#isSubscribed;
+ }
+
+ _applySessionData(params) {
+ const { category } = params;
+ if (category === "event") {
+ const filteredSessionData = params.sessionData.filter(item =>
+ this.messageHandler.matchesContext(item.contextDescriptor)
+ );
+ for (const event of this.#subscribedEvents.values()) {
+ const hasSessionItem = filteredSessionData.some(
+ item => item.value === event
+ );
+ // If there are no session items for this context, we should unsubscribe from the event.
+ if (!hasSessionItem) {
+ this.#unsubscribeEvent(event);
+ }
+ }
+
+ // Subscribe to all events, which have an item in SessionData
+ for (const { value } of filteredSessionData) {
+ this.#subscribeEvent(value);
+ }
+ }
+ }
+
+ #subscribeEvent(event) {
+ if (event === "eventemitter.testEvent") {
+ if (this.#isSubscribed) {
+ throw new Error("Already subscribed to eventemitter.testEvent");
+ }
+ this.#isSubscribed = true;
+ this.#subscribedEvents.add(event);
+ }
+ }
+
+ #unsubscribeEvent(event) {
+ if (event === "eventemitter.testEvent") {
+ if (!this.#isSubscribed) {
+ throw new Error("Not subscribed to eventemitter.testEvent");
+ }
+ this.#isSubscribed = false;
+ this.#subscribedEvents.delete(event);
+ }
+ }
+}
+
+export const eventemitter = EventEmitterModule;
diff --git a/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/eventnointercept.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/eventnointercept.sys.mjs
new file mode 100644
index 0000000000..48bbfbf951
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/eventnointercept.sys.mjs
@@ -0,0 +1,16 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs";
+
+class EventNoInterceptModule extends Module {
+ destroy() {}
+
+ testEvent() {
+ const text = `event no interception`;
+ this.emitEvent("eventnointercept.testEvent", { text });
+ }
+}
+
+export const eventnointercept = EventNoInterceptModule;
diff --git a/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/eventonprefchange.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/eventonprefchange.sys.mjs
new file mode 100644
index 0000000000..33cb25d10b
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/eventonprefchange.sys.mjs
@@ -0,0 +1,33 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs";
+
+const TEST_PREF = "remote.messagehandler.test.pref";
+
+class EventOnPrefChangeModule extends Module {
+ constructor(messageHandler) {
+ super(messageHandler);
+ Services.prefs.addObserver(TEST_PREF, this.#onPreferenceUpdated);
+ }
+
+ destroy() {
+ Services.prefs.removeObserver(TEST_PREF, this.#onPreferenceUpdated);
+ }
+
+ #onPreferenceUpdated = () => {
+ this.emitEvent("preference-changed");
+ };
+
+ /**
+ * Commands
+ */
+
+ ping() {
+ // We only use this command to force creating the module.
+ return 1;
+ }
+}
+
+export const eventonprefchange = EventOnPrefChangeModule;
diff --git a/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/retry.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/retry.sys.mjs
new file mode 100644
index 0000000000..f7b2279018
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/retry.sys.mjs
@@ -0,0 +1,84 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs";
+
+// Store counters in the JSM scope to persist them across reloads.
+let callsToBlockedOneTime = 0;
+let callsToBlockedTenTimes = 0;
+let callsToBlockedElevenTimes = 0;
+
+// This module provides various commands which all hang for various reasons.
+// The test is supposed to trigger the command and then destroy the
+// JSWindowActor pair by any mean (eg a navigation) in order to trigger an
+// AbortError and a retry.
+class RetryModule extends Module {
+ destroy() {}
+
+ /**
+ * Commands
+ */
+
+ // Resolves only if called while on the example.net domain.
+ async blockedOnNetDomain(params) {
+ // Note: we do not store a call counter here, because this is used for a
+ // cross-group navigation test, and the JSM will be loaded in different
+ // processes.
+ const uri = this.messageHandler.window.document.baseURI;
+ if (!uri.includes("example.net")) {
+ await new Promise(r => {});
+ }
+
+ return { ...params };
+ }
+
+ // Resolves only if called more than once.
+ async blockedOneTime(params) {
+ callsToBlockedOneTime++;
+ if (callsToBlockedOneTime < 2) {
+ await new Promise(r => {});
+ }
+
+ // Return:
+ // - params sent to the command to check that retries have correct params
+ // - the call counter
+ return { ...params, callsToCommand: callsToBlockedOneTime };
+ }
+
+ // Resolves only if called more than ten times (which is exactly the maximum
+ // of retry attempts).
+ async blockedTenTimes(params) {
+ callsToBlockedTenTimes++;
+ if (callsToBlockedTenTimes < 11) {
+ await new Promise(r => {});
+ }
+
+ // Return:
+ // - params sent to the command to check that retries have correct params
+ // - the call counter
+ return { ...params, callsToCommand: callsToBlockedTenTimes };
+ }
+
+ // Resolves only if called more than eleven times (which is greater than the
+ // maximum of retry attempts).
+ async blockedElevenTimes(params) {
+ callsToBlockedElevenTimes++;
+ if (callsToBlockedElevenTimes < 12) {
+ await new Promise(r => {});
+ }
+
+ // Return:
+ // - params sent to the command to check that retries have correct params
+ // - the call counter
+ return { ...params, callsToCommand: callsToBlockedElevenTimes };
+ }
+
+ cleanup() {
+ callsToBlockedOneTime = 0;
+ callsToBlockedTenTimes = 0;
+ callsToBlockedElevenTimes = 0;
+ }
+}
+
+export const retry = RetryModule;
diff --git a/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/sessiondataupdate.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/sessiondataupdate.sys.mjs
new file mode 100644
index 0000000000..5e9ce00b46
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/sessiondataupdate.sys.mjs
@@ -0,0 +1,33 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs";
+
+class SessionDataUpdateModule extends Module {
+ #sessionDataUpdates;
+
+ constructor(messageHandler) {
+ super(messageHandler);
+ this.#sessionDataUpdates = [];
+ }
+
+ destroy() {}
+
+ /**
+ * Commands
+ */
+
+ _applySessionData(params) {
+ const filteredSessionData = params.sessionData.filter(item =>
+ this.messageHandler.matchesContext(item.contextDescriptor)
+ );
+ this.#sessionDataUpdates.push(filteredSessionData);
+ }
+
+ getSessionDataUpdates() {
+ return this.#sessionDataUpdates;
+ }
+}
+
+export const sessiondataupdate = SessionDataUpdateModule;
diff --git a/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/windowglobaltoroot.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/windowglobaltoroot.sys.mjs
new file mode 100644
index 0000000000..815a836d9c
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/windowglobaltoroot.sys.mjs
@@ -0,0 +1,47 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs";
+import { RootMessageHandler } from "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs";
+
+class WindowGlobalToRootModule extends Module {
+ constructor(messageHandler) {
+ super(messageHandler);
+ this.#assertContentProcess();
+ }
+
+ destroy() {}
+
+ /**
+ * Commands
+ */
+
+ testHandleCommandToRoot(params, destination) {
+ return this.messageHandler.handleCommand({
+ moduleName: "windowglobaltoroot",
+ commandName: "getValueFromRoot",
+ destination: {
+ type: RootMessageHandler.type,
+ },
+ });
+ }
+
+ testSendRootCommand(params, destination) {
+ return this.messageHandler.sendRootCommand({
+ moduleName: "windowglobaltoroot",
+ commandName: "getValueFromRoot",
+ });
+ }
+
+ #assertContentProcess() {
+ const isContent =
+ Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_CONTENT;
+
+ if (!isContent) {
+ throw new Error("Can only run in a content process");
+ }
+ }
+}
+
+export const windowglobaltoroot = WindowGlobalToRootModule;
diff --git a/remote/shared/messagehandler/test/browser/webdriver/browser.toml b/remote/shared/messagehandler/test/browser/webdriver/browser.toml
new file mode 100644
index 0000000000..45ccca74ef
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/webdriver/browser.toml
@@ -0,0 +1,7 @@
+[DEFAULT]
+tags = "remote"
+subsuite = "remote"
+support-files = ["!/remote/shared/messagehandler/test/browser/resources/*"]
+prefs = ["remote.messagehandler.modulecache.useBrowserTestRoot=true"]
+
+["browser_session_execute_command_errors.js"]
diff --git a/remote/shared/messagehandler/test/browser/webdriver/browser_session_execute_command_errors.js b/remote/shared/messagehandler/test/browser/webdriver/browser_session_execute_command_errors.js
new file mode 100644
index 0000000000..36a510bb29
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/webdriver/browser_session_execute_command_errors.js
@@ -0,0 +1,40 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { WebDriverSession } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/webdriver/Session.sys.mjs"
+);
+
+const { error } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/webdriver/Errors.sys.mjs"
+);
+
+add_task(async function test_execute_missing_command_error() {
+ const session = new WebDriverSession();
+
+ info("Attempt to execute an unknown protocol command");
+ await Assert.rejects(
+ session.execute("command", "missingCommand"),
+ err =>
+ err.name == "UnknownCommandError" &&
+ err.message == `command.missingCommand`
+ );
+});
+
+add_task(async function test_execute_missing_internal_command_error() {
+ const session = new WebDriverSession();
+
+ info(
+ "Attempt to execute a protocol command which relies on an unknown internal method"
+ );
+ await Assert.rejects(
+ session.execute("command", "testMissingIntermediaryMethod"),
+ err =>
+ err.name == "UnsupportedCommandError" &&
+ err.message ==
+ `command.missingMethod not supported for destination ROOT` &&
+ !error.isWebDriverError(err)
+ );
+});
diff --git a/remote/shared/messagehandler/test/xpcshell/test_Errors.js b/remote/shared/messagehandler/test/xpcshell/test_Errors.js
new file mode 100644
index 0000000000..26187dac11
--- /dev/null
+++ b/remote/shared/messagehandler/test/xpcshell/test_Errors.js
@@ -0,0 +1,91 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { error } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/Errors.sys.mjs"
+);
+
+// Note: this test file is similar to remote/shared/webdriver/test/xpcshell/test_Errors.js
+// because shared/webdriver/Errors.jsm and shared/messagehandler/Errors.jsm share
+// similar helpers.
+
+add_task(function test_toJSON() {
+ let e0 = new error.MessageHandlerError();
+ let e0s = e0.toJSON();
+ equal(e0s.error, "message handler error");
+ equal(e0s.message, "");
+
+ let e1 = new error.MessageHandlerError("a");
+ let e1s = e1.toJSON();
+ equal(e1s.message, e1.message);
+
+ let e2 = new error.UnsupportedCommandError("foo");
+ let e2s = e2.toJSON();
+ equal(e2.status, e2s.error);
+ equal(e2.message, e2s.message);
+});
+
+add_task(function test_fromJSON() {
+ Assert.throws(
+ () => error.MessageHandlerError.fromJSON({ error: "foo" }),
+ /Not of MessageHandlerError descent/
+ );
+ Assert.throws(
+ () => error.MessageHandlerError.fromJSON({ error: "Error" }),
+ /Not of MessageHandlerError descent/
+ );
+ Assert.throws(
+ () => error.MessageHandlerError.fromJSON({}),
+ /Undeserialisable error type/
+ );
+ Assert.throws(
+ () => error.MessageHandlerError.fromJSON(undefined),
+ /TypeError/
+ );
+
+ let e1 = new error.MessageHandlerError("1");
+ let e1r = error.MessageHandlerError.fromJSON({
+ error: "message handler error",
+ message: "1",
+ });
+ ok(e1r instanceof error.MessageHandlerError);
+ equal(e1r.name, e1.name);
+ equal(e1r.status, e1.status);
+ equal(e1r.message, e1.message);
+
+ let e2 = new error.UnsupportedCommandError("foo");
+ let e2r = error.MessageHandlerError.fromJSON({
+ error: "unsupported message handler command",
+ message: "foo",
+ });
+ ok(e2r instanceof error.MessageHandlerError);
+ ok(e2r instanceof error.UnsupportedCommandError);
+ equal(e2r.name, e2.name);
+ equal(e2r.status, e2.status);
+ equal(e2r.message, e2.message);
+
+ // parity with toJSON
+ let e3 = new error.UnsupportedCommandError("foo");
+ let e3toJSON = e3.toJSON();
+ let e3fromJSON = error.MessageHandlerError.fromJSON(e3toJSON);
+ equal(e3toJSON.error, e3fromJSON.status);
+ equal(e3toJSON.message, e3fromJSON.message);
+ equal(e3toJSON.stacktrace, e3fromJSON.stack);
+});
+
+add_task(function test_MessageHandlerError() {
+ let err = new error.MessageHandlerError("foo");
+ equal("MessageHandlerError", err.name);
+ equal("foo", err.message);
+ equal("message handler error", err.status);
+ ok(err instanceof error.MessageHandlerError);
+});
+
+add_task(function test_UnsupportedCommandError() {
+ let e = new error.UnsupportedCommandError("foo");
+ equal("UnsupportedCommandError", e.name);
+ equal("foo", e.message);
+ equal("unsupported message handler command", e.status);
+ ok(e instanceof error.MessageHandlerError);
+});
diff --git a/remote/shared/messagehandler/test/xpcshell/test_SessionData.js b/remote/shared/messagehandler/test/xpcshell/test_SessionData.js
new file mode 100644
index 0000000000..ef61ce27d4
--- /dev/null
+++ b/remote/shared/messagehandler/test/xpcshell/test_SessionData.js
@@ -0,0 +1,296 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { ContextDescriptorType } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/MessageHandler.sys.mjs"
+);
+const { RootMessageHandler } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs"
+);
+const { SessionData, SessionDataMethod } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/sessiondata/SessionData.sys.mjs"
+);
+
+add_task(async function test_sessionData() {
+ const sessionData = new SessionData(new RootMessageHandler("session-id-1"));
+ equal(sessionData.getSessionData("mod", "event").length, 0);
+
+ const globalContext = {
+ type: ContextDescriptorType.All,
+ };
+ const otherContext = { type: "other-type", id: "some-id" };
+
+ info("Add a first event for the global context");
+ let updatedItems = sessionData.applySessionData([
+ createUpdate(SessionDataMethod.Add, globalContext, ["first.event"]),
+ ]);
+ equal(updatedItems.length, 1, "One item updated");
+ equal(updatedItems[0].method, SessionDataMethod.Add, "One item added");
+ let updatedValues = updatedItems[0].values;
+ equal(updatedValues.length, 1, "One value added");
+ equal(updatedValues[0], "first.event", "Expected value was added");
+ checkEvents(sessionData.getSessionData("mod", "event"), [
+ {
+ value: "first.event",
+ contextDescriptor: globalContext,
+ },
+ ]);
+
+ info("Add the exact same data (same module, type, context, value)");
+ updatedItems = sessionData.applySessionData([
+ createUpdate(SessionDataMethod.Add, globalContext, ["first.event"]),
+ ]);
+ equal(updatedItems.length, 0, "No new item updated");
+ checkEvents(sessionData.getSessionData("mod", "event"), [
+ {
+ value: "first.event",
+ contextDescriptor: globalContext,
+ },
+ ]);
+
+ info("Add another context for the same event");
+ updatedItems = sessionData.applySessionData([
+ createUpdate(SessionDataMethod.Add, otherContext, ["first.event"]),
+ ]);
+ equal(updatedItems.length, 1, "One item updated");
+ equal(updatedItems[0].method, SessionDataMethod.Add, "One item added");
+ updatedValues = updatedItems[0].values;
+ equal(updatedValues.length, 1, "One value added");
+ equal(updatedValues[0], "first.event", "Expected value was added");
+ checkEvents(sessionData.getSessionData("mod", "event"), [
+ {
+ value: "first.event",
+ contextDescriptor: globalContext,
+ },
+ {
+ value: "first.event",
+ contextDescriptor: otherContext,
+ },
+ ]);
+
+ info("Add a second event for the global context");
+ updatedItems = sessionData.applySessionData([
+ createUpdate(SessionDataMethod.Add, globalContext, ["second.event"]),
+ ]);
+ equal(updatedItems.length, 1, "One item updated");
+ equal(updatedItems[0].method, SessionDataMethod.Add, "One item added");
+ updatedValues = updatedItems[0].values;
+ equal(updatedValues.length, 1, "One value added");
+ equal(updatedValues[0], "second.event", "Expected value was added");
+ checkEvents(sessionData.getSessionData("mod", "event"), [
+ {
+ value: "first.event",
+ contextDescriptor: globalContext,
+ },
+ {
+ value: "first.event",
+ contextDescriptor: otherContext,
+ },
+ {
+ value: "second.event",
+ contextDescriptor: globalContext,
+ },
+ ]);
+
+ info("Add two events for the global context");
+ updatedItems = sessionData.applySessionData([
+ createUpdate(SessionDataMethod.Add, globalContext, [
+ "third.event",
+ "fourth.event",
+ ]),
+ ]);
+ equal(updatedItems.length, 1, "One item updated");
+ equal(updatedItems[0].method, SessionDataMethod.Add, "One item added");
+ updatedValues = updatedItems[0].values;
+ equal(updatedValues.length, 2, "Two values added");
+ equal(updatedValues[0], "third.event", "Expected value was added");
+ equal(updatedValues[1], "fourth.event", "Expected value was added");
+ checkEvents(sessionData.getSessionData("mod", "event"), [
+ {
+ value: "first.event",
+ contextDescriptor: globalContext,
+ },
+ {
+ value: "first.event",
+ contextDescriptor: otherContext,
+ },
+ {
+ value: "second.event",
+ contextDescriptor: globalContext,
+ },
+ {
+ value: "third.event",
+ contextDescriptor: globalContext,
+ },
+ {
+ value: "fourth.event",
+ contextDescriptor: globalContext,
+ },
+ ]);
+
+ info("Remove the second, third and fourth events");
+ updatedItems = sessionData.applySessionData([
+ createUpdate(SessionDataMethod.Remove, globalContext, [
+ "second.event",
+ "third.event",
+ "fourth.event",
+ ]),
+ ]);
+ equal(updatedItems.length, 1, "One item updated");
+ equal(updatedItems[0].method, SessionDataMethod.Remove, "One item removed");
+ updatedValues = updatedItems[0].values;
+ equal(updatedValues.length, 3, "Three values removed");
+ equal(updatedValues[0], "second.event", "Expected value was removed");
+ equal(updatedValues[1], "third.event", "Expected value was removed");
+ equal(updatedValues[2], "fourth.event", "Expected value was removed");
+ checkEvents(sessionData.getSessionData("mod", "event"), [
+ {
+ value: "first.event",
+ contextDescriptor: globalContext,
+ },
+ {
+ value: "first.event",
+ contextDescriptor: otherContext,
+ },
+ ]);
+
+ info("Remove the global context from the first event");
+ updatedItems = sessionData.applySessionData([
+ createUpdate(SessionDataMethod.Remove, globalContext, ["first.event"]),
+ ]);
+ equal(updatedItems.length, 1, "One item updated");
+ equal(updatedItems[0].method, SessionDataMethod.Remove, "One item removed");
+ updatedValues = updatedItems[0].values;
+ equal(updatedValues.length, 1, "One value removed");
+ equal(updatedValues[0], "first.event", "Expected value was removed");
+ checkEvents(sessionData.getSessionData("mod", "event"), [
+ {
+ value: "first.event",
+ contextDescriptor: otherContext,
+ },
+ ]);
+
+ info("Remove the other context from the first event");
+ updatedItems = sessionData.applySessionData([
+ createUpdate(SessionDataMethod.Remove, otherContext, ["first.event"]),
+ ]);
+ equal(updatedItems.length, 1, "One item updated");
+ equal(updatedItems[0].method, SessionDataMethod.Remove, "One item removed");
+ updatedValues = updatedItems[0].values;
+ equal(updatedValues.length, 1, "One value removed");
+ equal(updatedValues[0], "first.event", "Expected value was removed");
+ checkEvents(sessionData.getSessionData("mod", "event"), []);
+
+ info("Add two events for different contexts");
+ updatedItems = sessionData.applySessionData([
+ createUpdate(SessionDataMethod.Add, otherContext, ["first.event"]),
+ createUpdate(SessionDataMethod.Add, globalContext, ["second.event"]),
+ ]);
+ equal(updatedItems.length, 2, "Two items updated");
+ equal(updatedItems[0].method, SessionDataMethod.Add, "First item added");
+ updatedValues = updatedItems[0].values;
+ equal(updatedValues.length, 1, "One value for first item added");
+ equal(updatedValues[0], "first.event", "Expected value first item was added");
+ equal(updatedItems[1].method, SessionDataMethod.Add, "Second item added");
+ updatedValues = updatedItems[1].values;
+ equal(updatedValues.length, 1, "One value for second item added");
+ equal(
+ updatedValues[0],
+ "second.event",
+ "Expected value second item was added"
+ );
+ checkEvents(sessionData.getSessionData("mod", "event"), [
+ {
+ value: "first.event",
+ contextDescriptor: otherContext,
+ },
+ {
+ value: "second.event",
+ contextDescriptor: globalContext,
+ },
+ ]);
+
+ info("Remove two events for different contexts");
+ updatedItems = sessionData.applySessionData([
+ createUpdate(SessionDataMethod.Remove, otherContext, ["first.event"]),
+ createUpdate(SessionDataMethod.Remove, globalContext, ["second.event"]),
+ ]);
+ equal(updatedItems.length, 2, "Two items updated");
+ equal(updatedItems[0].method, SessionDataMethod.Remove, "First item removed");
+ updatedValues = updatedItems[0].values;
+ equal(updatedValues.length, 1, "One value for first item removed");
+ equal(
+ updatedValues[0],
+ "first.event",
+ "Expected value first item was removed"
+ );
+ equal(
+ updatedItems[1].method,
+ SessionDataMethod.Remove,
+ "Second item removed"
+ );
+ updatedValues = updatedItems[1].values;
+ equal(updatedValues.length, 1, "One value for second item removed");
+ equal(
+ updatedValues[0],
+ "second.event",
+ "Expected value second item was removed"
+ );
+ checkEvents(sessionData.getSessionData("mod", "event"), []);
+
+ info("Add and remove event in different order");
+ updatedItems = sessionData.applySessionData([
+ createUpdate(SessionDataMethod.Remove, otherContext, ["first.event"]),
+ createUpdate(SessionDataMethod.Add, otherContext, ["first.event"]),
+ ]);
+ equal(updatedItems.length, 1, "One item updated");
+ equal(updatedItems[0].method, SessionDataMethod.Add, "One item added");
+ updatedValues = updatedItems[0].values;
+ equal(updatedValues.length, 1, "One value added");
+ equal(updatedValues[0], "first.event", "Expected value was added");
+ checkEvents(sessionData.getSessionData("mod", "event"), [
+ {
+ value: "first.event",
+ contextDescriptor: otherContext,
+ },
+ ]);
+
+ updatedItems = sessionData.applySessionData([
+ createUpdate(SessionDataMethod.Add, otherContext, ["first.event"]),
+ createUpdate(SessionDataMethod.Remove, otherContext, ["first.event"]),
+ ]);
+ equal(updatedItems.length, 1, "No item update");
+ equal(updatedItems[0].method, SessionDataMethod.Remove, "One item removed");
+ updatedValues = updatedItems[0].values;
+ equal(updatedValues.length, 1, "One value removed");
+ equal(updatedValues[0], "first.event", "Expected value was removed");
+ checkEvents(sessionData.getSessionData("mod", "event"), []);
+});
+
+function checkEvents(events, expectedEvents) {
+ // Check the arrays have the same size.
+ equal(events.length, expectedEvents.length);
+
+ // Check all the expectedEvents can be found in the events array.
+ for (const expected of expectedEvents) {
+ ok(
+ events.some(
+ event =>
+ expected.contextDescriptor.type === event.contextDescriptor.type &&
+ expected.contextDescriptor.id === event.contextDescriptor.id &&
+ expected.value == event.value
+ )
+ );
+ }
+}
+
+function createUpdate(method, contextDescriptor, values) {
+ return {
+ method,
+ moduleName: "mod",
+ category: "event",
+ contextDescriptor,
+ values,
+ };
+}
diff --git a/remote/shared/messagehandler/test/xpcshell/xpcshell.toml b/remote/shared/messagehandler/test/xpcshell/xpcshell.toml
new file mode 100644
index 0000000000..10f8b2f715
--- /dev/null
+++ b/remote/shared/messagehandler/test/xpcshell/xpcshell.toml
@@ -0,0 +1,5 @@
+[DEFAULT]
+
+["test_Errors.js"]
+
+["test_SessionData.js"]
diff --git a/remote/shared/messagehandler/transports/BrowsingContextUtils.sys.mjs b/remote/shared/messagehandler/transports/BrowsingContextUtils.sys.mjs
new file mode 100644
index 0000000000..482f90948a
--- /dev/null
+++ b/remote/shared/messagehandler/transports/BrowsingContextUtils.sys.mjs
@@ -0,0 +1,57 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function isExtensionContext(browsingContext) {
+ let principal;
+ if (CanonicalBrowsingContext.isInstance(browsingContext)) {
+ principal = browsingContext.currentWindowGlobal.documentPrincipal;
+ } else {
+ principal = browsingContext.window.document.nodePrincipal;
+ }
+
+ // In practice, note that the principal will never be an expanded principal.
+ // The are only used for content scripts executed in a Sandbox, and do not
+ // have a browsing context on their own.
+ // But we still use this flag because there is no isAddonPrincipal flag.
+ return principal.isAddonOrExpandedAddonPrincipal;
+}
+
+function isParentProcess(browsingContext) {
+ if (CanonicalBrowsingContext.isInstance(browsingContext)) {
+ return browsingContext.currentWindowGlobal.osPid === -1;
+ }
+
+ // If `browsingContext` is not a `CanonicalBrowsingContext`, then we are
+ // necessarily in a content process page.
+ return false;
+}
+
+/**
+ * Check if the given browsing context is valid for the message handler
+ * to use.
+ *
+ * @param {BrowsingContext} browsingContext
+ * The browsing context to check.
+ * @param {object=} options
+ * @param {string=} options.browserId
+ * The id of the browser to filter the browsing contexts by (optional).
+ * @returns {boolean}
+ * True if the browsing context is valid, false otherwise.
+ */
+export function isBrowsingContextCompatible(browsingContext, options = {}) {
+ const { browserId } = options;
+
+ // If a browserId was provided, skip browsing contexts which are not
+ // associated with this browserId.
+ if (browserId !== undefined && browsingContext.browserId !== browserId) {
+ return false;
+ }
+
+ // Skip:
+ // - extension contexts until we support debugging webextensions, see Bug 1755014.
+ // - privileged contexts until we support debugging Chrome context, see Bug 1713440.
+ return (
+ !isExtensionContext(browsingContext) && !isParentProcess(browsingContext)
+ );
+}
diff --git a/remote/shared/messagehandler/transports/RootTransport.sys.mjs b/remote/shared/messagehandler/transports/RootTransport.sys.mjs
new file mode 100644
index 0000000000..b60d3726ef
--- /dev/null
+++ b/remote/shared/messagehandler/transports/RootTransport.sys.mjs
@@ -0,0 +1,188 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ContextDescriptorType:
+ "chrome://remote/content/shared/messagehandler/MessageHandler.sys.mjs",
+ isBrowsingContextCompatible:
+ "chrome://remote/content/shared/messagehandler/transports/BrowsingContextUtils.sys.mjs",
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+ MessageHandlerFrameActor:
+ "chrome://remote/content/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameActor.sys.mjs",
+ TabManager: "chrome://remote/content/shared/TabManager.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
+
+const MAX_RETRY_ATTEMPTS = 10;
+
+/**
+ * RootTransport is intended to be used from a ROOT MessageHandler to communicate
+ * with WINDOW_GLOBAL MessageHandlers via the MessageHandlerFrame JSWindow
+ * actors.
+ */
+export class RootTransport {
+ /**
+ * @param {MessageHandler} messageHandler
+ * The MessageHandler instance which owns this RootTransport instance.
+ */
+ constructor(messageHandler) {
+ this._messageHandler = messageHandler;
+
+ // RootTransport will rely on the MessageHandlerFrame JSWindow actors.
+ // Make sure they are registered when instanciating a RootTransport.
+ lazy.MessageHandlerFrameActor.register();
+ }
+
+ /**
+ * Forward the provided command to WINDOW_GLOBAL MessageHandlers via the
+ * MessageHandlerFrame actors.
+ *
+ * @param {Command} command
+ * The command to forward. See type definition in MessageHandler.js
+ * @returns {Promise}
+ * Returns a promise that resolves with the result of the command after
+ * being processed by WINDOW_GLOBAL MessageHandlers.
+ */
+ forwardCommand(command) {
+ if (command.destination.id && command.destination.contextDescriptor) {
+ throw new Error(
+ "Invalid command destination with both 'id' and 'contextDescriptor' properties"
+ );
+ }
+
+ // With an id given forward the command to only this specific destination.
+ if (command.destination.id) {
+ const browsingContext = BrowsingContext.get(command.destination.id);
+ if (!browsingContext) {
+ throw new Error(
+ "Unable to find a BrowsingContext for id " + command.destination.id
+ );
+ }
+ return this._sendCommandToBrowsingContext(command, browsingContext);
+ }
+
+ // ... otherwise broadcast to destinations matching the contextDescriptor.
+ if (command.destination.contextDescriptor) {
+ return this._broadcastCommand(command);
+ }
+
+ throw new Error(
+ "Unrecognized command destination, missing 'id' or 'contextDescriptor' properties"
+ );
+ }
+
+ _broadcastCommand(command) {
+ const { contextDescriptor } = command.destination;
+ const browsingContexts =
+ this._getBrowsingContextsForDescriptor(contextDescriptor);
+
+ return Promise.all(
+ browsingContexts.map(async browsingContext => {
+ try {
+ return await this._sendCommandToBrowsingContext(
+ command,
+ browsingContext
+ );
+ } catch (e) {
+ console.error(
+ `Failed to broadcast a command to browsingContext ${browsingContext.id}`,
+ e
+ );
+ return null;
+ }
+ })
+ );
+ }
+
+ async _sendCommandToBrowsingContext(command, browsingContext) {
+ const name = `${command.moduleName}.${command.commandName}`;
+
+ // The browsing context might be destroyed by a navigation. Keep a reference
+ // to the webProgress, which will persist, and always use it to retrieve the
+ // currently valid browsing context.
+ const webProgress = browsingContext.webProgress;
+
+ const { retryOnAbort = false } = command;
+
+ let attempts = 0;
+ while (true) {
+ try {
+ return await webProgress.browsingContext.currentWindowGlobal
+ .getActor("MessageHandlerFrame")
+ .sendCommand(command, this._messageHandler.sessionId);
+ } catch (e) {
+ if (!retryOnAbort || e.name != "AbortError") {
+ // Only retry if the command supports retryOnAbort and when the
+ // JSWindowActor pair gets destroyed.
+ throw e;
+ }
+
+ if (++attempts > MAX_RETRY_ATTEMPTS) {
+ lazy.logger.trace(
+ `RootTransport reached the limit of retry attempts (${MAX_RETRY_ATTEMPTS})` +
+ ` for command ${name} and browsing context ${webProgress.browsingContext.id}.`
+ );
+ throw e;
+ }
+
+ lazy.logger.trace(
+ `RootTransport retrying command ${name} for ` +
+ `browsing context ${webProgress.browsingContext.id}, attempt: ${attempts}.`
+ );
+ await new Promise(resolve => Services.tm.dispatchToMainThread(resolve));
+ }
+ }
+ }
+
+ toString() {
+ return `[object ${this.constructor.name} ${this._messageHandler.name}]`;
+ }
+
+ _getBrowsingContextsForDescriptor(contextDescriptor) {
+ const { id, type } = contextDescriptor;
+
+ if (type === lazy.ContextDescriptorType.All) {
+ return this._getBrowsingContexts();
+ }
+
+ if (type === lazy.ContextDescriptorType.TopBrowsingContext) {
+ return this._getBrowsingContexts({ browserId: id });
+ }
+
+ // TODO: Handle other types of context descriptors.
+ throw new Error(
+ `Unsupported contextDescriptor type for broadcasting: ${type}`
+ );
+ }
+
+ /**
+ * Get all browsing contexts, optionally matching the provided options.
+ *
+ * @param {object} options
+ * @param {string=} options.browserId
+ * The id of the browser to filter the browsing contexts by (optional).
+ * @returns {Array<BrowsingContext>}
+ * The browsing contexts matching the provided options or all browsing contexts
+ * if no options are provided.
+ */
+ _getBrowsingContexts(options = {}) {
+ // extract browserId from options
+ const { browserId } = options;
+ let browsingContexts = [];
+
+ // Fetch all tab related browsing contexts for top-level windows.
+ for (const { browsingContext } of lazy.TabManager.browsers) {
+ if (lazy.isBrowsingContextCompatible(browsingContext, { browserId })) {
+ browsingContexts = browsingContexts.concat(
+ browsingContext.getAllBrowsingContextsInSubtree()
+ );
+ }
+ }
+
+ return browsingContexts;
+ }
+}
diff --git a/remote/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameActor.sys.mjs b/remote/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameActor.sys.mjs
new file mode 100644
index 0000000000..c236cebac7
--- /dev/null
+++ b/remote/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameActor.sys.mjs
@@ -0,0 +1,51 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ActorManagerParent: "resource://gre/modules/ActorManagerParent.sys.mjs",
+
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
+
+const FRAME_ACTOR_CONFIG = {
+ parent: {
+ esModuleURI:
+ "chrome://remote/content/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameParent.sys.mjs",
+ },
+ child: {
+ esModuleURI:
+ "chrome://remote/content/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameChild.sys.mjs",
+ events: {
+ DOMWindowCreated: {},
+ pagehide: {},
+ pageshow: {},
+ },
+ },
+ allFrames: true,
+ messageManagerGroups: ["browsers"],
+};
+
+/**
+ * MessageHandlerFrameActor exposes a simple registration helper to lazily
+ * register MessageHandlerFrame JSWindow actors.
+ */
+export const MessageHandlerFrameActor = {
+ registered: false,
+
+ register() {
+ if (this.registered) {
+ return;
+ }
+
+ lazy.ActorManagerParent.addJSWindowActors({
+ MessageHandlerFrame: FRAME_ACTOR_CONFIG,
+ });
+ this.registered = true;
+ lazy.logger.trace("Registered MessageHandlerFrame actors");
+ },
+};
diff --git a/remote/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameChild.sys.mjs b/remote/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameChild.sys.mjs
new file mode 100644
index 0000000000..52a8fdc4c9
--- /dev/null
+++ b/remote/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameChild.sys.mjs
@@ -0,0 +1,111 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ isBrowsingContextCompatible:
+ "chrome://remote/content/shared/messagehandler/transports/BrowsingContextUtils.sys.mjs",
+ MessageHandlerRegistry:
+ "chrome://remote/content/shared/messagehandler/MessageHandlerRegistry.sys.mjs",
+ WindowGlobalMessageHandler:
+ "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs",
+});
+
+/**
+ * Map from MessageHandlerRegistry to MessageHandlerFrameChild actor. This will
+ * allow a WindowGlobalMessageHandler to find the JSWindowActorChild instance to
+ * use to send commands.
+ */
+const registryToActor = new WeakMap();
+
+/**
+ * Retrieve the MessageHandlerFrameChild which is linked to the provided
+ * WindowGlobalMessageHandler instance.
+ *
+ * @param {WindowGlobalMessageHandler} messageHandler
+ * The WindowGlobalMessageHandler for which to get the JSWindowActor.
+ * @returns {MessageHandlerFrameChild}
+ * The corresponding MessageHandlerFrameChild instance.
+ */
+export function getMessageHandlerFrameChildActor(messageHandler) {
+ return registryToActor.get(messageHandler.registry);
+}
+
+/**
+ * Child actor for the MessageHandlerFrame JSWindowActor. The
+ * MessageHandlerFrame actor is used by RootTransport to communicate between
+ * ROOT MessageHandlers and WINDOW_GLOBAL MessageHandlers.
+ */
+export class MessageHandlerFrameChild extends JSWindowActorChild {
+ actorCreated() {
+ this.type = lazy.WindowGlobalMessageHandler.type;
+ this.context = this.manager.browsingContext;
+
+ this._registry = new lazy.MessageHandlerRegistry(this.type, this.context);
+ registryToActor.set(this._registry, this);
+
+ this._onRegistryEvent = this._onRegistryEvent.bind(this);
+
+ // MessageHandlerFrameChild is responsible for forwarding events from
+ // WindowGlobalMessageHandler to the parent process.
+ // Such events are re-emitted on the MessageHandlerRegistry to avoid
+ // setting up listeners on individual MessageHandler instances.
+ this._registry.on("message-handler-registry-event", this._onRegistryEvent);
+ }
+
+ handleEvent({ persisted, type }) {
+ if (type == "DOMWindowCreated" || (type == "pageshow" && persisted)) {
+ // When the window is created or is retrieved from BFCache, instantiate
+ // a MessageHandler for all sessions which might need it.
+ if (lazy.isBrowsingContextCompatible(this.manager.browsingContext)) {
+ this._registry.createAllMessageHandlers();
+ }
+ } else if (type == "pagehide" && persisted) {
+ // When the page is moved to BFCache, all the currently created message
+ // handlers should be destroyed.
+ this._registry.destroy();
+ }
+ }
+
+ async receiveMessage(message) {
+ if (message.name === "MessageHandlerFrameParent:sendCommand") {
+ const { sessionId, command } = message.data;
+ const messageHandler =
+ this._registry.getOrCreateMessageHandler(sessionId);
+ try {
+ return await messageHandler.handleCommand(command);
+ } catch (e) {
+ if (e?.isRemoteError) {
+ return {
+ error: e.toJSON(),
+ isMessageHandlerError: e.isMessageHandlerError,
+ };
+ }
+ throw e;
+ }
+ }
+
+ return null;
+ }
+
+ sendCommand(command, sessionId) {
+ return this.sendQuery("MessageHandlerFrameChild:sendCommand", {
+ command,
+ sessionId,
+ });
+ }
+
+ _onRegistryEvent(eventName, wrappedEvent) {
+ this.sendAsyncMessage(
+ "MessageHandlerFrameChild:messageHandlerEvent",
+ wrappedEvent
+ );
+ }
+
+ didDestroy() {
+ this._registry.off("message-handler-registry-event", this._onRegistryEvent);
+ this._registry.destroy();
+ }
+}
diff --git a/remote/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameParent.sys.mjs b/remote/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameParent.sys.mjs
new file mode 100644
index 0000000000..a4901571d9
--- /dev/null
+++ b/remote/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameParent.sys.mjs
@@ -0,0 +1,127 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ error: "chrome://remote/content/shared/messagehandler/Errors.sys.mjs",
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+ RootMessageHandlerRegistry:
+ "chrome://remote/content/shared/messagehandler/RootMessageHandlerRegistry.sys.mjs",
+ WindowGlobalMessageHandler:
+ "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
+
+ChromeUtils.defineLazyGetter(lazy, "WebDriverError", () => {
+ return ChromeUtils.importESModule(
+ "chrome://remote/content/shared/webdriver/Errors.sys.mjs"
+ ).error.WebDriverError;
+});
+
+/**
+ * Parent actor for the MessageHandlerFrame JSWindowActor. The
+ * MessageHandlerFrame actor is used by RootTransport to communicate between
+ * ROOT MessageHandlers and WINDOW_GLOBAL MessageHandlers.
+ */
+export class MessageHandlerFrameParent extends JSWindowActorParent {
+ async receiveMessage(message) {
+ switch (message.name) {
+ case "MessageHandlerFrameChild:sendCommand": {
+ return this.#handleSendCommandMessage(message.data);
+ }
+ case "MessageHandlerFrameChild:messageHandlerEvent": {
+ return this.#handleMessageHandlerEventMessage(message.data);
+ }
+ default:
+ throw new Error("Unsupported message:" + message.name);
+ }
+ }
+
+ /**
+ * Send a command to the corresponding MessageHandlerFrameChild actor via a
+ * JSWindowActor query.
+ *
+ * @param {Command} command
+ * The command to forward. See type definition in MessageHandler.js
+ * @param {string} sessionId
+ * ID of the session that sent the command.
+ * @returns {Promise}
+ * Promise that will resolve with the result of query sent to the
+ * MessageHandlerFrameChild actor.
+ */
+ async sendCommand(command, sessionId) {
+ const result = await this.sendQuery(
+ "MessageHandlerFrameParent:sendCommand",
+ {
+ command,
+ sessionId,
+ }
+ );
+
+ if (result?.error) {
+ if (result.isMessageHandlerError) {
+ throw lazy.error.MessageHandlerError.fromJSON(result.error);
+ }
+
+ // TODO: Do not assume WebDriver is the session protocol, see Bug 1779026.
+ throw lazy.WebDriverError.fromJSON(result.error);
+ }
+
+ return result;
+ }
+
+ async #handleMessageHandlerEventMessage(messageData) {
+ const { name, contextInfo, data, sessionId } = messageData;
+ const [moduleName] = name.split(".");
+
+ // Re-emit the event on the RootMessageHandler.
+ const messageHandler =
+ lazy.RootMessageHandlerRegistry.getExistingMessageHandler(sessionId);
+ // TODO: getModuleInstance expects a CommandDestination in theory,
+ // but only uses the MessageHandler type in practice, see Bug 1776389.
+ const module = messageHandler.moduleCache.getModuleInstance(moduleName, {
+ type: lazy.WindowGlobalMessageHandler.type,
+ });
+ let eventPayload = data;
+
+ // Modify an event payload if there is a special method in the targeted module.
+ // If present it can be found in windowglobal-in-root module.
+ if (module?.interceptEvent) {
+ eventPayload = await module.interceptEvent(name, data);
+
+ if (eventPayload === null) {
+ lazy.logger.trace(
+ `${moduleName}.interceptEvent returned null, skipping event: ${name}, data: ${data}`
+ );
+ return;
+ }
+ // Make sure that an event payload is returned.
+ if (!eventPayload) {
+ throw new Error(
+ `${moduleName}.interceptEvent doesn't return the event payload`
+ );
+ }
+ }
+ messageHandler.emitEvent(name, eventPayload, contextInfo);
+ }
+
+ async #handleSendCommandMessage(messageData) {
+ const { sessionId, command } = messageData;
+ const messageHandler =
+ lazy.RootMessageHandlerRegistry.getExistingMessageHandler(sessionId);
+ try {
+ return await messageHandler.handleCommand(command);
+ } catch (e) {
+ if (e?.isRemoteError) {
+ return {
+ error: e.toJSON(),
+ isMessageHandlerError: e.isMessageHandlerError,
+ };
+ }
+ throw e;
+ }
+ }
+}
diff --git a/remote/shared/moz.build b/remote/shared/moz.build
new file mode 100644
index 0000000000..69b7d9e8a1
--- /dev/null
+++ b/remote/shared/moz.build
@@ -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/.
+
+BROWSER_CHROME_MANIFESTS += [
+ "listeners/test/browser/browser.toml",
+ "messagehandler/test/browser/broadcast/browser.toml",
+ "messagehandler/test/browser/browser.toml",
+ "messagehandler/test/browser/webdriver/browser.toml",
+ "test/browser/browser.toml",
+]
+
+XPCSHELL_TESTS_MANIFESTS += [
+ "messagehandler/test/xpcshell/xpcshell.toml",
+ "test/xpcshell/xpcshell.toml",
+ "webdriver/test/xpcshell/xpcshell.toml",
+]
diff --git a/remote/shared/test/browser/browser.toml b/remote/shared/test/browser/browser.toml
new file mode 100644
index 0000000000..de336a1cb7
--- /dev/null
+++ b/remote/shared/test/browser/browser.toml
@@ -0,0 +1,16 @@
+[DEFAULT]
+tags = "remote"
+subsuite = "remote"
+support-files = ["head.js"]
+
+["browser_NavigationManager.js"]
+
+["browser_NavigationManager_failed_navigation.js"]
+
+["browser_NavigationManager_no_navigation.js"]
+
+["browser_NavigationManager_notify.js"]
+
+["browser_TabManager.js"]
+
+["browser_UserContextManager.js"]
diff --git a/remote/shared/test/browser/browser_NavigationManager.js b/remote/shared/test/browser/browser_NavigationManager.js
new file mode 100644
index 0000000000..7e0464c2fa
--- /dev/null
+++ b/remote/shared/test/browser/browser_NavigationManager.js
@@ -0,0 +1,372 @@
+/* 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 { NavigationManager } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/NavigationManager.sys.mjs"
+);
+const { TabManager } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/TabManager.sys.mjs"
+);
+
+const FIRST_URL = "https://example.com/document-builder.sjs?html=first";
+const SECOND_URL = "https://example.com/document-builder.sjs?html=second";
+const THIRD_URL = "https://example.com/document-builder.sjs?html=third";
+
+const FIRST_COOP_URL =
+ "https://example.com/document-builder.sjs?headers=Cross-Origin-Opener-Policy:same-origin&html=first_coop";
+const SECOND_COOP_URL =
+ "https://example.net/document-builder.sjs?headers=Cross-Origin-Opener-Policy:same-origin&html=second_coop";
+
+add_task(async function test_simpleNavigation() {
+ const events = [];
+ const onEvent = (name, data) => events.push({ name, data });
+
+ const navigationManager = new NavigationManager();
+ navigationManager.on("navigation-started", onEvent);
+ navigationManager.on("navigation-stopped", onEvent);
+
+ const tab = addTab(gBrowser, FIRST_URL);
+ const browser = tab.linkedBrowser;
+ await BrowserTestUtils.browserLoaded(browser);
+
+ const navigableId = TabManager.getIdForBrowser(browser);
+
+ navigationManager.startMonitoring();
+ is(
+ navigationManager.getNavigationForBrowsingContext(browser.browsingContext),
+ null,
+ "No navigation recorded yet"
+ );
+ is(events.length, 0, "No event recorded");
+
+ await loadURL(browser, SECOND_URL);
+
+ const firstNavigation = navigationManager.getNavigationForBrowsingContext(
+ browser.browsingContext
+ );
+ assertNavigation(firstNavigation, SECOND_URL);
+
+ is(events.length, 2, "Two events recorded");
+ assertNavigationEvents(
+ events,
+ SECOND_URL,
+ firstNavigation.navigationId,
+ navigableId
+ );
+
+ await loadURL(browser, THIRD_URL);
+
+ const secondNavigation = navigationManager.getNavigationForBrowsingContext(
+ browser.browsingContext
+ );
+ assertNavigation(secondNavigation, THIRD_URL);
+ assertUniqueNavigationIds(firstNavigation, secondNavigation);
+
+ is(events.length, 4, "Two new events recorded");
+ assertNavigationEvents(
+ events,
+ THIRD_URL,
+ secondNavigation.navigationId,
+ navigableId
+ );
+
+ navigationManager.stopMonitoring();
+
+ // Navigate again to the first URL
+ await loadURL(browser, FIRST_URL);
+ is(events.length, 4, "No new event recorded");
+ is(
+ navigationManager.getNavigationForBrowsingContext(browser.browsingContext),
+ null,
+ "No navigation recorded"
+ );
+
+ navigationManager.off("navigation-started", onEvent);
+ navigationManager.off("navigation-stopped", onEvent);
+});
+
+add_task(async function test_loadTwoTabsSimultaneously() {
+ const events = [];
+ const onEvent = (name, data) => events.push({ name, data });
+
+ const navigationManager = new NavigationManager();
+ navigationManager.on("navigation-started", onEvent);
+ navigationManager.on("navigation-stopped", onEvent);
+
+ navigationManager.startMonitoring();
+
+ info("Add two tabs simultaneously");
+ const tab1 = addTab(gBrowser, FIRST_URL);
+ const browser1 = tab1.linkedBrowser;
+ const navigableId1 = TabManager.getIdForBrowser(browser1);
+ const onLoad1 = BrowserTestUtils.browserLoaded(browser1, false, FIRST_URL);
+
+ const tab2 = addTab(gBrowser, SECOND_URL);
+ const browser2 = tab2.linkedBrowser;
+ const navigableId2 = TabManager.getIdForBrowser(browser2);
+ const onLoad2 = BrowserTestUtils.browserLoaded(browser2, false, SECOND_URL);
+
+ info("Wait for the tabs to load");
+ await Promise.all([onLoad1, onLoad2]);
+
+ is(events.length, 4, "Recorded 4 navigation events");
+
+ info("Check navigation monitored for tab1");
+ const nav1 = navigationManager.getNavigationForBrowsingContext(
+ browser1.browsingContext
+ );
+ assertNavigation(nav1, FIRST_URL);
+ assertNavigationEvents(events, FIRST_URL, nav1.navigationId, navigableId1);
+
+ info("Check navigation monitored for tab2");
+ const nav2 = navigationManager.getNavigationForBrowsingContext(
+ browser2.browsingContext
+ );
+ assertNavigation(nav2, SECOND_URL);
+ assertNavigationEvents(events, SECOND_URL, nav2.navigationId, navigableId2);
+ assertUniqueNavigationIds(nav1, nav2);
+
+ info("Reload the two tabs simultaneously");
+ await Promise.all([
+ BrowserTestUtils.reloadTab(tab1),
+ BrowserTestUtils.reloadTab(tab2),
+ ]);
+
+ is(events.length, 8, "Recorded 8 navigation events");
+
+ info("Check the second navigation for tab1");
+ const nav3 = navigationManager.getNavigationForBrowsingContext(
+ browser1.browsingContext
+ );
+ assertNavigation(nav3, FIRST_URL);
+ assertNavigationEvents(events, FIRST_URL, nav3.navigationId, navigableId1);
+
+ info("Check the second navigation monitored for tab2");
+ const nav4 = navigationManager.getNavigationForBrowsingContext(
+ browser2.browsingContext
+ );
+ assertNavigation(nav4, SECOND_URL);
+ assertNavigationEvents(events, SECOND_URL, nav4.navigationId, navigableId2);
+ assertUniqueNavigationIds(nav1, nav2, nav3, nav4);
+
+ navigationManager.off("navigation-started", onEvent);
+ navigationManager.off("navigation-stopped", onEvent);
+ navigationManager.stopMonitoring();
+});
+
+add_task(async function test_loadPageWithIframes() {
+ const events = [];
+ const onEvent = (name, data) => events.push({ name, data });
+
+ const navigationManager = new NavigationManager();
+ navigationManager.on("navigation-started", onEvent);
+ navigationManager.on("navigation-stopped", onEvent);
+
+ navigationManager.startMonitoring();
+
+ info("Add a tab with iframes");
+ const testUrl = createTestPageWithFrames();
+ const tab = addTab(gBrowser, testUrl);
+ const browser = tab.linkedBrowser;
+ await BrowserTestUtils.browserLoaded(browser, false, testUrl);
+
+ is(events.length, 8, "Recorded 8 navigation events");
+ const contexts = browser.browsingContext.getAllBrowsingContextsInSubtree();
+
+ const navigations = [];
+ for (const context of contexts) {
+ const navigation =
+ navigationManager.getNavigationForBrowsingContext(context);
+ const navigable = TabManager.getIdForBrowsingContext(context);
+
+ const url = context.currentWindowGlobal.documentURI.spec;
+ assertNavigation(navigation, url);
+ assertNavigationEvents(events, url, navigation.navigationId, navigable);
+ navigations.push(navigation);
+ }
+ assertUniqueNavigationIds(...navigations);
+
+ await BrowserTestUtils.reloadTab(tab);
+
+ is(events.length, 16, "Recorded 8 additional navigation events");
+ const newContexts = browser.browsingContext.getAllBrowsingContextsInSubtree();
+
+ for (const context of newContexts) {
+ const navigation =
+ navigationManager.getNavigationForBrowsingContext(context);
+ const navigable = TabManager.getIdForBrowsingContext(context);
+
+ const url = context.currentWindowGlobal.documentURI.spec;
+ assertNavigation(navigation, url);
+ assertNavigationEvents(events, url, navigation.navigationId, navigable);
+ navigations.push(navigation);
+ }
+ assertUniqueNavigationIds(...navigations);
+
+ navigationManager.off("navigation-started", onEvent);
+ navigationManager.off("navigation-stopped", onEvent);
+ navigationManager.stopMonitoring();
+});
+
+add_task(async function test_loadPageWithCoop() {
+ const tab = addTab(gBrowser, FIRST_COOP_URL);
+ const browser = tab.linkedBrowser;
+ await BrowserTestUtils.browserLoaded(browser, false, FIRST_COOP_URL);
+
+ const events = [];
+ const onEvent = (name, data) => events.push({ name, data });
+
+ const navigationManager = new NavigationManager();
+ navigationManager.on("navigation-started", onEvent);
+ navigationManager.on("navigation-stopped", onEvent);
+
+ navigationManager.startMonitoring();
+
+ const navigableId = TabManager.getIdForBrowser(browser);
+ await loadURL(browser, SECOND_COOP_URL);
+
+ const coopNavigation = navigationManager.getNavigationForBrowsingContext(
+ browser.browsingContext
+ );
+ assertNavigation(coopNavigation, SECOND_COOP_URL);
+
+ is(events.length, 2, "Two events recorded");
+ assertNavigationEvents(
+ events,
+ SECOND_COOP_URL,
+ coopNavigation.navigationId,
+ navigableId
+ );
+
+ navigationManager.off("navigation-started", onEvent);
+ navigationManager.off("navigation-stopped", onEvent);
+ navigationManager.stopMonitoring();
+});
+
+add_task(async function test_sameDocumentNavigation() {
+ const events = [];
+ const onEvent = (name, data) => events.push({ name, data });
+
+ const navigationManager = new NavigationManager();
+ navigationManager.on("navigation-started", onEvent);
+ navigationManager.on("location-changed", onEvent);
+ navigationManager.on("navigation-stopped", onEvent);
+
+ const url = "https://example.com/document-builder.sjs?html=test";
+ const tab = addTab(gBrowser, url);
+ const browser = tab.linkedBrowser;
+ await BrowserTestUtils.browserLoaded(browser);
+
+ navigationManager.startMonitoring();
+ const navigableId = TabManager.getIdForBrowser(browser);
+
+ is(events.length, 0, "No event recorded");
+
+ info("Perform a same-document navigation");
+ let onLocationChanged = navigationManager.once("location-changed");
+ BrowserTestUtils.startLoadingURIString(browser, url + "#hash");
+ await onLocationChanged;
+
+ const hashNavigation = navigationManager.getNavigationForBrowsingContext(
+ browser.browsingContext
+ );
+ is(events.length, 1, "Recorded 1 navigation event");
+ assertNavigationEvents(
+ events,
+ url + "#hash",
+ hashNavigation.navigationId,
+ navigableId,
+ true
+ );
+
+ // Navigate from `url + "#hash"` to `url`, this will trigger a regular
+ // navigation and we can use `loadURL` to properly wait for the navigation to
+ // complete.
+ info("Perform a regular navigation");
+ await loadURL(browser, url);
+
+ const regularNavigation = navigationManager.getNavigationForBrowsingContext(
+ browser.browsingContext
+ );
+ is(events.length, 3, "Recorded 2 additional navigation events");
+ assertNavigationEvents(
+ events,
+ url,
+ regularNavigation.navigationId,
+ navigableId
+ );
+
+ info("Perform another same-document navigation");
+ onLocationChanged = navigationManager.once("location-changed");
+ BrowserTestUtils.startLoadingURIString(browser, url + "#foo");
+ await onLocationChanged;
+
+ const otherHashNavigation = navigationManager.getNavigationForBrowsingContext(
+ browser.browsingContext
+ );
+
+ is(events.length, 4, "Recorded 1 additional navigation event");
+
+ info("Perform a same-hash navigation");
+ onLocationChanged = navigationManager.once("location-changed");
+ BrowserTestUtils.startLoadingURIString(browser, url + "#foo");
+ await onLocationChanged;
+
+ const sameHashNavigation = navigationManager.getNavigationForBrowsingContext(
+ browser.browsingContext
+ );
+
+ is(events.length, 5, "Recorded 1 additional navigation event");
+ assertNavigationEvents(
+ events,
+ url + "#foo",
+ sameHashNavigation.navigationId,
+ navigableId,
+ true
+ );
+
+ assertUniqueNavigationIds([
+ hashNavigation,
+ regularNavigation,
+ otherHashNavigation,
+ sameHashNavigation,
+ ]);
+
+ navigationManager.off("navigation-started", onEvent);
+ navigationManager.off("location-changed", onEvent);
+ navigationManager.off("navigation-stopped", onEvent);
+
+ navigationManager.stopMonitoring();
+});
+
+add_task(async function test_startNavigationAndCloseTab() {
+ const events = [];
+ const onEvent = (name, data) => events.push({ name, data });
+
+ const navigationManager = new NavigationManager();
+ navigationManager.on("navigation-started", onEvent);
+ navigationManager.on("navigation-stopped", onEvent);
+
+ const tab = addTab(gBrowser, FIRST_URL);
+ const browser = tab.linkedBrowser;
+ await BrowserTestUtils.browserLoaded(browser);
+
+ navigationManager.startMonitoring();
+ loadURL(browser, SECOND_URL);
+ gBrowser.removeTab(tab);
+
+ // On top of the assertions below, the test also validates that there is no
+ // unhandled promise rejection related to handling the navigation-started event
+ // for the destroyed browsing context.
+ is(events.length, 0, "No event was received");
+ is(
+ navigationManager.getNavigationForBrowsingContext(browser.browsingContext),
+ null,
+ "No navigation was recorded for the destroyed tab"
+ );
+ navigationManager.stopMonitoring();
+
+ navigationManager.off("navigation-started", onEvent);
+ navigationManager.off("navigation-stopped", onEvent);
+});
diff --git a/remote/shared/test/browser/browser_NavigationManager_failed_navigation.js b/remote/shared/test/browser/browser_NavigationManager_failed_navigation.js
new file mode 100644
index 0000000000..70c695b7ac
--- /dev/null
+++ b/remote/shared/test/browser/browser_NavigationManager_failed_navigation.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 { NavigationManager } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/NavigationManager.sys.mjs"
+);
+const { TabManager } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/TabManager.sys.mjs"
+);
+
+const TEST_URL = "https://example.com/document-builder.sjs?html=test1";
+const TEST_URL_CLOSED_PORT = "http://127.0.0.1:36325/";
+const TEST_URL_WRONG_URI = "https://www.wronguri.wronguri/";
+
+add_task(async function testClosedPort() {
+ const events = [];
+ const onEvent = (name, data) => events.push({ name, data });
+
+ const navigationManager = new NavigationManager();
+ navigationManager.on("navigation-started", onEvent);
+ navigationManager.on("navigation-stopped", onEvent);
+
+ const tab = addTab(gBrowser, TEST_URL);
+ const browser = tab.linkedBrowser;
+ await BrowserTestUtils.browserLoaded(browser);
+
+ const navigableId = TabManager.getIdForBrowser(browser);
+
+ navigationManager.startMonitoring();
+ is(
+ navigationManager.getNavigationForBrowsingContext(browser.browsingContext),
+ null,
+ "No navigation recorded yet"
+ );
+ is(events.length, 0, "No event recorded");
+
+ await loadURL(browser, TEST_URL_CLOSED_PORT, { maybeErrorPage: true });
+
+ const firstNavigation = navigationManager.getNavigationForBrowsingContext(
+ browser.browsingContext
+ );
+ assertNavigation(firstNavigation, TEST_URL_CLOSED_PORT);
+
+ is(events.length, 2, "Two events recorded");
+ assertNavigationEvents(
+ events,
+ TEST_URL_CLOSED_PORT,
+ firstNavigation.navigationId,
+ navigableId
+ );
+
+ navigationManager.off("navigation-started", onEvent);
+ navigationManager.off("navigation-stopped", onEvent);
+ navigationManager.stopMonitoring();
+});
+
+add_task(async function testWrongURI() {
+ const events = [];
+ const onEvent = (name, data) => events.push({ name, data });
+
+ const navigationManager = new NavigationManager();
+ navigationManager.on("navigation-started", onEvent);
+ navigationManager.on("navigation-stopped", onEvent);
+
+ const tab = addTab(gBrowser, TEST_URL);
+ const browser = tab.linkedBrowser;
+ await BrowserTestUtils.browserLoaded(browser);
+
+ const navigableId = TabManager.getIdForBrowser(browser);
+
+ navigationManager.startMonitoring();
+
+ is(
+ navigationManager.getNavigationForBrowsingContext(browser.browsingContext),
+ null,
+ "No navigation recorded yet"
+ );
+ is(events.length, 0, "No event recorded");
+
+ await loadURL(browser, TEST_URL_WRONG_URI, { maybeErrorPage: true });
+
+ const firstNavigation = navigationManager.getNavigationForBrowsingContext(
+ browser.browsingContext
+ );
+ assertNavigation(firstNavigation, TEST_URL_WRONG_URI);
+
+ is(events.length, 2, "Two events recorded");
+ assertNavigationEvents(
+ events,
+ TEST_URL_WRONG_URI,
+ firstNavigation.navigationId,
+ navigableId
+ );
+
+ navigationManager.off("navigation-started", onEvent);
+ navigationManager.off("navigation-stopped", onEvent);
+ navigationManager.stopMonitoring();
+});
diff --git a/remote/shared/test/browser/browser_NavigationManager_no_navigation.js b/remote/shared/test/browser/browser_NavigationManager_no_navigation.js
new file mode 100644
index 0000000000..370c09d351
--- /dev/null
+++ b/remote/shared/test/browser/browser_NavigationManager_no_navigation.js
@@ -0,0 +1,60 @@
+/* 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 { NavigationManager } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/NavigationManager.sys.mjs"
+);
+const { TabManager } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/TabManager.sys.mjs"
+);
+
+add_task(async function testDocumentOpenWriteClose() {
+ const events = [];
+ const onEvent = (name, data) => events.push({ name, data });
+
+ const navigationManager = new NavigationManager();
+ navigationManager.on("location-changed", onEvent);
+ navigationManager.on("navigation-started", onEvent);
+ navigationManager.on("navigation-stopped", onEvent);
+
+ const url = "https://example.com/document-builder.sjs?html=test";
+
+ const tab = addTab(gBrowser, url);
+ const browser = tab.linkedBrowser;
+ await BrowserTestUtils.browserLoaded(browser);
+
+ navigationManager.startMonitoring();
+ is(events.length, 0, "No event recorded");
+
+ info("Replace the document");
+ await SpecialPowers.spawn(browser, [], async () => {
+ // Note: we need to use eval here to have reduced permissions and avoid
+ // security errors.
+ content.eval(`
+ document.open();
+ document.write("<h1 class='replaced'>Replaced</h1>");
+ document.close();
+ `);
+
+ await ContentTaskUtils.waitForCondition(() =>
+ content.document.querySelector(".replaced")
+ );
+ });
+
+ // See Bug 1844517.
+ // document.open/write/close is identical to same-url + same-hash navigations.
+ todo_is(events.length, 0, "No event recorded after replacing the document");
+
+ info("Reload the page, which should trigger a navigation");
+ await loadURL(browser, url);
+
+ // See Bug 1844517.
+ // document.open/write/close is identical to same-url + same-hash navigations.
+ todo_is(events.length, 2, "Recorded navigation events");
+
+ navigationManager.off("location-changed", onEvent);
+ navigationManager.off("navigation-started", onEvent);
+ navigationManager.off("navigation-stopped", onEvent);
+ navigationManager.stopMonitoring();
+});
diff --git a/remote/shared/test/browser/browser_NavigationManager_notify.js b/remote/shared/test/browser/browser_NavigationManager_notify.js
new file mode 100644
index 0000000000..4dca0f7b4e
--- /dev/null
+++ b/remote/shared/test/browser/browser_NavigationManager_notify.js
@@ -0,0 +1,170 @@
+/* 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 { NavigationManager, notifyNavigationStarted, notifyNavigationStopped } =
+ ChromeUtils.importESModule(
+ "chrome://remote/content/shared/NavigationManager.sys.mjs"
+ );
+const { TabManager } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/TabManager.sys.mjs"
+);
+
+const FIRST_URL = "https://example.com/document-builder.sjs?html=first";
+const SECOND_URL = "https://example.com/document-builder.sjs?html=second";
+
+add_task(async function test_notifyNavigationStartedStopped() {
+ const tab = addTab(gBrowser, FIRST_URL);
+ const browser = tab.linkedBrowser;
+ await BrowserTestUtils.browserLoaded(browser, false, FIRST_URL);
+
+ const events = [];
+ const onEvent = (name, data) => events.push({ name, data });
+
+ const navigationManager = new NavigationManager();
+ navigationManager.on("navigation-started", onEvent);
+ navigationManager.on("navigation-stopped", onEvent);
+
+ navigationManager.startMonitoring();
+
+ const navigableId = TabManager.getIdForBrowser(browser);
+
+ info("Programmatically start a navigation");
+ const startedNavigation = notifyNavigationStarted({
+ contextDetails: {
+ context: browser.browsingContext,
+ },
+ url: SECOND_URL,
+ });
+
+ const navigation = navigationManager.getNavigationForBrowsingContext(
+ browser.browsingContext
+ );
+ assertNavigation(navigation, SECOND_URL);
+
+ is(
+ startedNavigation,
+ navigation,
+ "notifyNavigationStarted returned the expected navigation"
+ );
+ is(events.length, 1, "Only one event recorded");
+
+ info("Attempt to start a navigation while another one is in progress");
+ const alreadyStartedNavigation = notifyNavigationStarted({
+ contextDetails: {
+ context: browser.browsingContext,
+ },
+ url: SECOND_URL,
+ });
+ is(
+ alreadyStartedNavigation,
+ navigation,
+ "notifyNavigationStarted returned the ongoing navigation"
+ );
+ is(events.length, 1, "Still only one event recorded");
+
+ info("Programmatically stop the navigation");
+ const stoppedNavigation = notifyNavigationStopped({
+ contextDetails: {
+ context: browser.browsingContext,
+ },
+ url: SECOND_URL,
+ });
+ is(
+ stoppedNavigation,
+ navigation,
+ "notifyNavigationStopped returned the expected navigation"
+ );
+
+ is(events.length, 2, "Two events recorded");
+ assertNavigationEvents(
+ events,
+ SECOND_URL,
+ navigation.navigationId,
+ navigableId
+ );
+
+ info("Attempt to stop an already stopped navigation");
+ const alreadyStoppedNavigation = notifyNavigationStopped({
+ contextDetails: {
+ context: browser.browsingContext,
+ },
+ url: SECOND_URL,
+ });
+ is(
+ alreadyStoppedNavigation,
+ navigation,
+ "notifyNavigationStopped returned the already stopped navigation"
+ );
+ is(events.length, 2, "Still only two events recorded");
+
+ navigationManager.off("navigation-started", onEvent);
+ navigationManager.off("navigation-stopped", onEvent);
+ navigationManager.stopMonitoring();
+});
+
+add_task(async function test_notifyNavigationWithContextDetails() {
+ const tab = addTab(gBrowser, FIRST_URL);
+ const browser = tab.linkedBrowser;
+ await BrowserTestUtils.browserLoaded(browser, false, FIRST_URL);
+
+ const events = [];
+ const onEvent = (name, data) => events.push({ name, data });
+
+ const navigationManager = new NavigationManager();
+ navigationManager.on("navigation-started", onEvent);
+ navigationManager.on("navigation-stopped", onEvent);
+
+ navigationManager.startMonitoring();
+
+ const navigableId = TabManager.getIdForBrowser(browser);
+
+ info("Programmatically start a navigation using browsing context details");
+ const startedNavigation = notifyNavigationStarted({
+ contextDetails: {
+ browsingContextId: browser.browsingContext.id,
+ browserId: browser.browsingContext.browserId,
+ isTopBrowsingContext: browser.browsingContext.parent === null,
+ },
+ url: SECOND_URL,
+ });
+
+ const navigation = navigationManager.getNavigationForBrowsingContext(
+ browser.browsingContext
+ );
+ assertNavigation(navigation, SECOND_URL);
+
+ is(
+ startedNavigation,
+ navigation,
+ "notifyNavigationStarted returned the expected navigation"
+ );
+ is(events.length, 1, "Only one event recorded");
+
+ info("Programmatically stop the navigation using browsing context details");
+ const stoppedNavigation = notifyNavigationStopped({
+ contextDetails: {
+ browsingContextId: browser.browsingContext.id,
+ browserId: browser.browsingContext.browserId,
+ isTopBrowsingContext: browser.browsingContext.parent === null,
+ },
+ url: SECOND_URL,
+ });
+ is(
+ stoppedNavigation,
+ navigation,
+ "notifyNavigationStopped returned the expected navigation"
+ );
+
+ is(events.length, 2, "Two events recorded");
+ assertNavigationEvents(
+ events,
+ SECOND_URL,
+ navigation.navigationId,
+ navigableId
+ );
+
+ navigationManager.off("navigation-started", onEvent);
+ navigationManager.off("navigation-stopped", onEvent);
+ navigationManager.stopMonitoring();
+});
diff --git a/remote/shared/test/browser/browser_TabManager.js b/remote/shared/test/browser/browser_TabManager.js
new file mode 100644
index 0000000000..fdc0d5c8b1
--- /dev/null
+++ b/remote/shared/test/browser/browser_TabManager.js
@@ -0,0 +1,178 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { TabManager } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/TabManager.sys.mjs"
+);
+
+const FRAME_URL = "https://example.com/document-builder.sjs?html=frame";
+const FRAME_MARKUP = `<iframe src="${encodeURI(FRAME_URL)}"></iframe>`;
+const TEST_URL = `https://example.com/document-builder.sjs?html=${encodeURI(
+ FRAME_MARKUP
+)}`;
+
+add_task(async function test_getBrowsingContextById() {
+ const browser = gBrowser.selectedBrowser;
+
+ is(TabManager.getBrowsingContextById(null), null);
+ is(TabManager.getBrowsingContextById(undefined), null);
+ is(TabManager.getBrowsingContextById("wrong-id"), null);
+
+ info(`Navigate to ${TEST_URL}`);
+ await loadURL(browser, TEST_URL);
+
+ const contexts = browser.browsingContext.getAllBrowsingContextsInSubtree();
+ is(contexts.length, 2, "Top context has 1 child");
+
+ const topContextId = TabManager.getIdForBrowsingContext(contexts[0]);
+ is(TabManager.getBrowsingContextById(topContextId), contexts[0]);
+ const childContextId = TabManager.getIdForBrowsingContext(contexts[1]);
+ is(TabManager.getBrowsingContextById(childContextId), contexts[1]);
+});
+
+add_task(async function test_addTab_focus() {
+ let tabsCount = gBrowser.tabs.length;
+
+ let newTab1, newTab2, newTab3;
+ try {
+ newTab1 = await TabManager.addTab({ focus: true });
+
+ ok(gBrowser.tabs.includes(newTab1), "A new tab was created");
+ is(gBrowser.tabs.length, tabsCount + 1);
+ is(gBrowser.selectedTab, newTab1, "Tab added with focus: true is selected");
+
+ newTab2 = await TabManager.addTab({ focus: false });
+
+ ok(gBrowser.tabs.includes(newTab2), "A new tab was created");
+ is(gBrowser.tabs.length, tabsCount + 2);
+ is(
+ gBrowser.selectedTab,
+ newTab1,
+ "Tab added with focus: false is not selected"
+ );
+
+ newTab3 = await TabManager.addTab();
+
+ ok(gBrowser.tabs.includes(newTab3), "A new tab was created");
+ is(gBrowser.tabs.length, tabsCount + 3);
+ is(
+ gBrowser.selectedTab,
+ newTab1,
+ "Tab added with no focus parameter is not selected (defaults to false)"
+ );
+ } finally {
+ gBrowser.removeTab(newTab1);
+ gBrowser.removeTab(newTab2);
+ gBrowser.removeTab(newTab3);
+ }
+});
+
+add_task(async function test_addTab_referenceTab() {
+ let tab1, tab2, tab3, tab4;
+ try {
+ tab1 = await TabManager.addTab();
+ // Add a second tab with no referenceTab, should be added at the end.
+ tab2 = await TabManager.addTab();
+ // Add a third tab with tab1 as referenceTab, should be added right after tab1.
+ tab3 = await TabManager.addTab({ referenceTab: tab1 });
+ // Add a fourth tab with tab2 as referenceTab, should be added right after tab2.
+ tab4 = await TabManager.addTab({ referenceTab: tab2 });
+
+ // Check that the tab order is as expected: tab1 > tab3 > tab2 > tab4
+ const tab1Index = gBrowser.tabs.indexOf(tab1);
+ is(gBrowser.tabs[tab1Index + 1], tab3);
+ is(gBrowser.tabs[tab1Index + 2], tab2);
+ is(gBrowser.tabs[tab1Index + 3], tab4);
+ } finally {
+ gBrowser.removeTab(tab1);
+ gBrowser.removeTab(tab2);
+ gBrowser.removeTab(tab3);
+ gBrowser.removeTab(tab4);
+ }
+});
+
+add_task(async function test_addTab_window() {
+ const win1 = await BrowserTestUtils.openNewBrowserWindow();
+ const win2 = await BrowserTestUtils.openNewBrowserWindow();
+ try {
+ // openNewBrowserWindow should ensure the new window is focused.
+ is(Services.wm.getMostRecentBrowserWindow(null), win2);
+
+ const newTab1 = await TabManager.addTab({ window: win1 });
+ is(
+ newTab1.ownerGlobal,
+ win1,
+ "The new tab was opened in the specified window"
+ );
+
+ const newTab2 = await TabManager.addTab({ window: win2 });
+ is(
+ newTab2.ownerGlobal,
+ win2,
+ "The new tab was opened in the specified window"
+ );
+
+ const newTab3 = await TabManager.addTab();
+ is(
+ newTab3.ownerGlobal,
+ win2,
+ "The new tab was opened in the foreground window"
+ );
+ } finally {
+ await BrowserTestUtils.closeWindow(win1);
+ await BrowserTestUtils.closeWindow(win2);
+ }
+});
+
+add_task(async function test_getNavigableForBrowsingContext() {
+ const browser = gBrowser.selectedBrowser;
+
+ info(`Navigate to ${TEST_URL}`);
+ await loadURL(browser, TEST_URL);
+
+ const contexts = browser.browsingContext.getAllBrowsingContextsInSubtree();
+ is(contexts.length, 2, "Top context has 1 child");
+
+ // For a top-level browsing context the content browser is returned.
+ const topContext = contexts[0];
+ is(
+ TabManager.getNavigableForBrowsingContext(topContext),
+ browser,
+ "Top-Level browsing context has the content browser as navigable"
+ );
+
+ // For child browsing contexts the browsing context itself is returned.
+ const childContext = contexts[1];
+ is(
+ TabManager.getNavigableForBrowsingContext(childContext),
+ childContext,
+ "Child browsing context has itself as navigable"
+ );
+
+ const invalidValues = [undefined, null, 1, "test", {}, []];
+ for (const invalidValue of invalidValues) {
+ Assert.throws(
+ () => TabManager.getNavigableForBrowsingContext(invalidValue),
+ /Expected browsingContext to be a CanonicalBrowsingContext/
+ );
+ }
+});
+
+add_task(async function test_getTabForBrowsingContext() {
+ const tab = await TabManager.addTab();
+ try {
+ const browser = tab.linkedBrowser;
+
+ info(`Navigate to ${TEST_URL}`);
+ await loadURL(browser, TEST_URL);
+
+ const contexts = browser.browsingContext.getAllBrowsingContextsInSubtree();
+ is(TabManager.getTabForBrowsingContext(contexts[0]), tab);
+ is(TabManager.getTabForBrowsingContext(contexts[1]), tab);
+ is(TabManager.getTabForBrowsingContext(null), null);
+ } finally {
+ gBrowser.removeTab(tab);
+ }
+});
diff --git a/remote/shared/test/browser/browser_UserContextManager.js b/remote/shared/test/browser/browser_UserContextManager.js
new file mode 100644
index 0000000000..2060c2bacd
--- /dev/null
+++ b/remote/shared/test/browser/browser_UserContextManager.js
@@ -0,0 +1,236 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { UserContextManagerClass } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/UserContextManager.sys.mjs"
+);
+
+add_task(async function test_invalid() {
+ const userContextManager = new UserContextManagerClass();
+
+ // Check invalid types for hasUserContextId/getInternalIdById which expects
+ // a string.
+ for (const value of [null, undefined, 1, [], {}]) {
+ is(userContextManager.hasUserContextId(value), false);
+ is(userContextManager.getInternalIdById(value), null);
+ }
+
+ // Check an invalid value for hasUserContextId/getInternalIdById which expects
+ // either "default" or a UUID from Services.uuid.generateUUID.
+ is(userContextManager.hasUserContextId("foo"), false);
+ is(userContextManager.getInternalIdById("foo"), null);
+
+ // Check invalid types for getIdByInternalId which expects a number.
+ for (const value of [null, undefined, "foo", [], {}]) {
+ is(userContextManager.getIdByInternalId(value), null);
+ }
+
+ userContextManager.destroy();
+});
+
+add_task(async function test_default_context() {
+ const userContextManager = new UserContextManagerClass();
+ ok(
+ userContextManager.hasUserContextId("default"),
+ `Context id default is known by the manager`
+ );
+ ok(
+ userContextManager.getUserContextIds().includes("default"),
+ `Context id default is listed by the manager`
+ );
+ is(
+ userContextManager.getInternalIdById("default"),
+ 0,
+ "Default user context has the expected internal id"
+ );
+
+ userContextManager.destroy();
+});
+
+add_task(async function test_new_internal_contexts() {
+ info("Create a new user context with ContextualIdentityService");
+ const beforeInternalId =
+ ContextualIdentityService.create("before").userContextId;
+
+ info("Create the UserContextManager");
+ const userContextManager = new UserContextManagerClass();
+
+ const beforeContextId =
+ userContextManager.getIdByInternalId(beforeInternalId);
+ assertContextAvailable(userContextManager, beforeContextId, beforeInternalId);
+
+ info("Create another user context with ContextualIdentityService");
+ const afterInternalId =
+ ContextualIdentityService.create("after").userContextId;
+ const afterContextId = userContextManager.getIdByInternalId(afterInternalId);
+ assertContextAvailable(userContextManager, afterContextId, afterInternalId);
+
+ info("Delete both user contexts");
+ ContextualIdentityService.remove(beforeInternalId);
+ ContextualIdentityService.remove(afterInternalId);
+ assertContextRemoved(userContextManager, afterContextId, afterInternalId);
+ assertContextRemoved(userContextManager, beforeContextId, beforeInternalId);
+
+ userContextManager.destroy();
+});
+
+add_task(async function test_create_remove_context() {
+ const userContextManager = new UserContextManagerClass();
+
+ for (const closeContextTabs of [true, false]) {
+ info("Create two contexts via createContext");
+ const userContextId1 = userContextManager.createContext();
+ const internalId1 = userContextManager.getInternalIdById(userContextId1);
+ assertContextAvailable(userContextManager, userContextId1);
+
+ const userContextId2 = userContextManager.createContext();
+ const internalId2 = userContextManager.getInternalIdById(userContextId2);
+ assertContextAvailable(userContextManager, userContextId2);
+
+ info("Create tabs in various user contexts");
+ const url = "https://example.com/document-builder.sjs?html=tab";
+ const tabDefault = await addTab(gBrowser, url);
+ const tabContext1 = await addTab(gBrowser, url, {
+ userContextId: internalId1,
+ });
+ const tabContext2 = await addTab(gBrowser, url, {
+ userContextId: internalId2,
+ });
+
+ info("Remove the user context 1 via removeUserContext");
+ userContextManager.removeUserContext(userContextId1, { closeContextTabs });
+
+ assertContextRemoved(userContextManager, userContextId1, internalId1);
+ if (closeContextTabs) {
+ ok(!gBrowser.tabs.includes(tabContext1), "Tab context 1 is closed");
+ } else {
+ ok(gBrowser.tabs.includes(tabContext1), "Tab context 1 is not closed");
+ }
+ ok(gBrowser.tabs.includes(tabDefault), "Tab default is not closed");
+ ok(gBrowser.tabs.includes(tabContext2), "Tab context 2 is not closed");
+
+ info("Remove the user context 2 via removeUserContext");
+ userContextManager.removeUserContext(userContextId2, { closeContextTabs });
+ assertContextRemoved(userContextManager, userContextId2, internalId2);
+ if (closeContextTabs) {
+ ok(!gBrowser.tabs.includes(tabContext2), "Tab context 2 is closed");
+ } else {
+ ok(gBrowser.tabs.includes(tabContext2), "Tab context 2 is not closed");
+ }
+ ok(gBrowser.tabs.includes(tabDefault), "Tab default is not closed");
+ }
+
+ userContextManager.destroy();
+});
+
+add_task(async function test_create_context_prefix() {
+ const userContextManager = new UserContextManagerClass();
+
+ info("Create a context with a custom prefix via createContext");
+ const userContextId = userContextManager.createContext("test_prefix");
+ const internalId = userContextManager.getInternalIdById(userContextId);
+ const identity =
+ ContextualIdentityService.getPublicIdentityFromId(internalId);
+ ok(
+ identity.name.startsWith("test_prefix"),
+ "The new identity used the provided prefix"
+ );
+
+ userContextManager.removeUserContext(userContextId);
+ userContextManager.destroy();
+});
+
+add_task(async function test_several_managers() {
+ const manager1 = new UserContextManagerClass();
+ const manager2 = new UserContextManagerClass();
+
+ info("Create a context via manager1");
+ const contextId1 = manager1.createContext();
+ const internalId = manager1.getInternalIdById(contextId1);
+ assertContextUnknown(manager2, contextId1);
+
+ info("Retrieve the corresponding user context id in manager2");
+ const contextId2 = manager2.getIdByInternalId(internalId);
+ is(
+ typeof contextId2,
+ "string",
+ "manager2 has a valid id for the user context created by manager 1"
+ );
+
+ ok(
+ contextId1 != contextId2,
+ "manager1 and manager2 have different ids for the same internal context id"
+ );
+
+ info("Remove the user context via manager2");
+ manager2.removeUserContext(contextId2);
+
+ info("Check that the user context is removed from both managers");
+ assertContextRemoved(manager1, contextId1, internalId);
+ assertContextRemoved(manager2, contextId2, internalId);
+
+ manager1.destroy();
+ manager2.destroy();
+});
+
+function assertContextAvailable(manager, contextId, expectedInternalId = null) {
+ ok(
+ manager.getUserContextIds().includes(contextId),
+ `Context id ${contextId} is listed by the manager`
+ );
+ ok(
+ manager.hasUserContextId(contextId),
+ `Context id ${contextId} is known by the manager`
+ );
+
+ const internalId = manager.getInternalIdById(contextId);
+ if (expectedInternalId != null) {
+ is(internalId, expectedInternalId, "Internal id has the expected value");
+ }
+
+ is(
+ typeof internalId,
+ "number",
+ `Context id ${contextId} corresponds to a valid internal id (${internalId})`
+ );
+ is(
+ manager.getIdByInternalId(internalId),
+ contextId,
+ `Context id ${contextId} is returned for internal id ${internalId}`
+ );
+ ok(
+ ContextualIdentityService.getPublicUserContextIds().includes(internalId),
+ `User context for context id ${contextId} is found by ContextualIdentityService`
+ );
+}
+
+function assertContextUnknown(manager, contextId) {
+ ok(
+ !manager.getUserContextIds().includes(contextId),
+ `Context id ${contextId} is not listed by the manager`
+ );
+ ok(
+ !manager.hasUserContextId(contextId),
+ `Context id ${contextId} is not known by the manager`
+ );
+ is(
+ manager.getInternalIdById(contextId),
+ null,
+ `Context id ${contextId} does not match any internal id`
+ );
+}
+
+function assertContextRemoved(manager, contextId, internalId) {
+ assertContextUnknown(manager, contextId);
+ is(
+ manager.getIdByInternalId(internalId),
+ null,
+ `Internal id ${internalId} cannot be converted to user context id`
+ );
+ ok(
+ !ContextualIdentityService.getPublicUserContextIds().includes(internalId),
+ `Internal id ${internalId} is not found in ContextualIdentityService`
+ );
+}
diff --git a/remote/shared/test/browser/head.js b/remote/shared/test/browser/head.js
new file mode 100644
index 0000000000..7960d99c9c
--- /dev/null
+++ b/remote/shared/test/browser/head.js
@@ -0,0 +1,205 @@
+/* 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";
+
+/**
+ * Add a new tab in a given browser, pointing to a given URL and automatically
+ * register the cleanup function to remove it at the end of the test.
+ *
+ * @param {Browser} browser
+ * The browser element where the tab should be added.
+ * @param {string} url
+ * The URL for the tab.
+ * @param {object=} options
+ * Options object to forward to BrowserTestUtils.addTab.
+ * @returns {Tab}
+ * The created tab.
+ */
+function addTab(browser, url, options) {
+ const tab = BrowserTestUtils.addTab(browser, url, options);
+ registerCleanupFunction(() => browser.removeTab(tab));
+ return tab;
+}
+
+/**
+ * Check if a given navigation is valid and has the expected url.
+ *
+ * @param {object} navigation
+ * The navigation to validate.
+ * @param {string} expectedUrl
+ * The expected url for the navigation.
+ */
+function assertNavigation(navigation, expectedUrl) {
+ ok(!!navigation, "Retrieved a navigation");
+ is(navigation.url, expectedUrl, "Navigation has the expected URL");
+ is(
+ typeof navigation.navigationId,
+ "string",
+ "Navigation has a string navigationId"
+ );
+}
+
+/**
+ * Check a pair of navigation events have the expected URL, navigation id and
+ * navigable id. The pair is expected to be ordered as follows: navigation-started
+ * and then navigation-stopped.
+ *
+ * @param {Array<object>} events
+ * The pair of events to validate.
+ * @param {string} url
+ * The expected url for the navigation.
+ * @param {string} navigationId
+ * The expected navigation id.
+ * @param {string} navigableId
+ * The expected navigable id.
+ * @param {boolean} isSameDocument
+ * If the navigation should be a same document navigation.
+ */
+function assertNavigationEvents(
+ events,
+ url,
+ navigationId,
+ navigableId,
+ isSameDocument
+) {
+ const expectedEvents = isSameDocument ? 1 : 2;
+
+ const navigationEvents = events.filter(
+ e => e.data.navigationId == navigationId
+ );
+ is(
+ navigationEvents.length,
+ expectedEvents,
+ `Found ${expectedEvents} events for navigationId ${navigationId}`
+ );
+
+ if (isSameDocument) {
+ // Check there are no navigation-started/stopped events.
+ ok(!navigationEvents.some(e => e.name === "navigation-started"));
+ ok(!navigationEvents.some(e => e.name === "navigation-stopped"));
+
+ const locationChanged = navigationEvents.find(
+ e => e.name === "location-changed"
+ );
+ is(locationChanged.name, "location-changed", "event has the expected name");
+ is(locationChanged.data.url, url, "event has the expected url");
+ is(
+ locationChanged.data.navigableId,
+ navigableId,
+ "event has the expected navigable"
+ );
+ } else {
+ // Check there is no location-changed event.
+ ok(!navigationEvents.some(e => e.name === "location-changed"));
+
+ const started = navigationEvents.find(e => e.name === "navigation-started");
+ const stopped = navigationEvents.find(e => e.name === "navigation-stopped");
+
+ // Check navigation-started
+ is(started.name, "navigation-started", "event has the expected name");
+ is(started.data.url, url, "event has the expected url");
+ is(
+ started.data.navigableId,
+ navigableId,
+ "event has the expected navigable"
+ );
+
+ // Check navigation-stopped
+ is(stopped.name, "navigation-stopped", "event has the expected name");
+ is(stopped.data.url, url, "event has the expected url");
+ is(
+ stopped.data.navigableId,
+ navigableId,
+ "event has the expected navigable"
+ );
+ }
+}
+
+/**
+ * Assert that the given navigations all have unique/different ids.
+ *
+ * @param {Array<object>} navigations
+ * The navigations to validate.
+ */
+function assertUniqueNavigationIds(...navigations) {
+ const ids = navigations.map(navigation => navigation.navigationId);
+ is(new Set(ids).size, ids.length, "Navigation ids are all different");
+}
+
+/**
+ * Create a document-builder based page with an iframe served by a given domain.
+ *
+ * @param {string} domain
+ * The domain which should serve the page.
+ * @returns {string}
+ * The URI for the page.
+ */
+function createFrame(domain) {
+ return createFrameForUri(
+ `https://${domain}/document-builder.sjs?html=frame-${domain}`
+ );
+}
+
+/**
+ * Create the markup for an iframe pointing to a given URI.
+ *
+ * @param {string} uri
+ * The uri for the iframe.
+ * @returns {string}
+ * The iframe markup.
+ */
+function createFrameForUri(uri) {
+ return `<iframe src="${encodeURI(uri)}"></iframe>`;
+}
+
+/**
+ * Create the URL for a test page containing nested iframes
+ *
+ * @returns {string}
+ * The test page url.
+ */
+function createTestPageWithFrames() {
+ // 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
+ )}`;
+}
+
+/**
+ * Load the provided url in an existing browser.
+ *
+ * @param {Browser} browser
+ * The browser element where the URL should be loaded.
+ * @param {string} url
+ * The URL to load.
+ * @param {object=} options
+ * @param {boolean} options.includeSubFrames
+ * Whether we should monitor load of sub frames. Defaults to false.
+ * @param {boolean} options.maybeErrorPage
+ * Whether we might reach an error page or not. Defaults to false.
+ * @returns {Promise}
+ * Promise which will resolve when the page is loaded with the expected url.
+ */
+async function loadURL(browser, url, options = {}) {
+ const { includeSubFrames = false, maybeErrorPage = false } = options;
+ const loaded = BrowserTestUtils.browserLoaded(
+ browser,
+ includeSubFrames,
+ url,
+ maybeErrorPage
+ );
+ BrowserTestUtils.startLoadingURIString(browser, url);
+ return loaded;
+}
diff --git a/remote/shared/test/xpcshell/head.js b/remote/shared/test/xpcshell/head.js
new file mode 100644
index 0000000000..2e7cf578d3
--- /dev/null
+++ b/remote/shared/test/xpcshell/head.js
@@ -0,0 +1,3 @@
+const SVG_NS = "http://www.w3.org/2000/svg";
+const XHTML_NS = "http://www.w3.org/1999/xhtml";
+const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
diff --git a/remote/shared/test/xpcshell/test_AppInfo.js b/remote/shared/test/xpcshell/test_AppInfo.js
new file mode 100644
index 0000000000..9149564aa1
--- /dev/null
+++ b/remote/shared/test/xpcshell/test_AppInfo.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";
+
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+const { AppInfo, getTimeoutMultiplier } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/AppInfo.sys.mjs"
+);
+
+// Minimal xpcshell tests for AppInfo; Services.appinfo.* is not available
+
+add_task(function test_custom_properties() {
+ const properties = [
+ // platforms
+ "isAndroid",
+ "isLinux",
+ "isMac",
+ "isWindows",
+ // applications
+ "isFirefox",
+ "isThunderbird",
+ ];
+
+ for (const prop of properties) {
+ equal(
+ typeof AppInfo[prop],
+ "boolean",
+ `Custom property ${prop} has expected type`
+ );
+ }
+});
+
+add_task(function test_getTimeoutMultiplier() {
+ const message = "Timeout multiplier has expected value";
+ const timeoutMultiplier = getTimeoutMultiplier();
+
+ if (
+ AppConstants.DEBUG ||
+ AppConstants.MOZ_CODE_COVERAGE ||
+ AppConstants.ASAN
+ ) {
+ equal(timeoutMultiplier, 4, message);
+ } else if (AppConstants.TSAN) {
+ equal(timeoutMultiplier, 8, message);
+ } else {
+ equal(timeoutMultiplier, 1, message);
+ }
+});
diff --git a/remote/shared/test/xpcshell/test_ChallengeHeaderParser.js b/remote/shared/test/xpcshell/test_ChallengeHeaderParser.js
new file mode 100644
index 0000000000..fa624e9c20
--- /dev/null
+++ b/remote/shared/test/xpcshell/test_ChallengeHeaderParser.js
@@ -0,0 +1,140 @@
+/* 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 { parseChallengeHeader } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/ChallengeHeaderParser.sys.mjs"
+);
+
+add_task(async function test_single_scheme() {
+ const TEST_HEADERS = [
+ {
+ // double quotes
+ header: 'Basic realm="test"',
+ params: [{ name: "realm", value: "test" }],
+ },
+ {
+ // single quote
+ header: "Basic realm='test'",
+ params: [{ name: "realm", value: "test" }],
+ },
+ {
+ // multiline
+ header: `Basic
+ realm='test'`,
+ params: [{ name: "realm", value: "test" }],
+ },
+ {
+ // with additional parameter.
+ header: 'Basic realm="test", charset="UTF-8"',
+ params: [
+ { name: "realm", value: "test" },
+ { name: "charset", value: "UTF-8" },
+ ],
+ },
+ ];
+ for (const { header, params } of TEST_HEADERS) {
+ const challenges = parseChallengeHeader(header);
+ equal(challenges.length, 1);
+ equal(challenges[0].scheme, "Basic");
+ deepEqual(challenges[0].params, params);
+ }
+});
+
+add_task(async function test_realmless_scheme() {
+ const TEST_HEADERS = [
+ {
+ // no parameter
+ header: "Custom",
+ params: [],
+ },
+ {
+ // one non-realm parameter
+ header: "Custom charset='UTF-8'",
+ params: [{ name: "charset", value: "UTF-8" }],
+ },
+ ];
+
+ for (const { header, params } of TEST_HEADERS) {
+ const challenges = parseChallengeHeader(header);
+ equal(challenges.length, 1);
+ equal(challenges[0].scheme, "Custom");
+ deepEqual(challenges[0].params, params);
+ }
+});
+
+add_task(async function test_multiple_schemes() {
+ const TEST_HEADERS = [
+ {
+ header: 'Scheme1 realm="foo", Scheme2 realm="bar"',
+ params: [
+ [{ name: "realm", value: "foo" }],
+ [{ name: "realm", value: "bar" }],
+ ],
+ },
+ {
+ header: 'Scheme1 realm="foo", charset="UTF-8", Scheme2 realm="bar"',
+ params: [
+ [
+ { name: "realm", value: "foo" },
+ { name: "charset", value: "UTF-8" },
+ ],
+ [{ name: "realm", value: "bar" }],
+ ],
+ },
+ {
+ header: `Scheme1 realm="foo",
+ charset="UTF-8",
+ Scheme2 realm="bar"`,
+ params: [
+ [
+ { name: "realm", value: "foo" },
+ { name: "charset", value: "UTF-8" },
+ ],
+ [{ name: "realm", value: "bar" }],
+ ],
+ },
+ ];
+ for (const { header, params } of TEST_HEADERS) {
+ const challenges = parseChallengeHeader(header);
+ equal(challenges.length, 2);
+ equal(challenges[0].scheme, "Scheme1");
+ deepEqual(challenges[0].params, params[0]);
+ equal(challenges[1].scheme, "Scheme2");
+ deepEqual(challenges[1].params, params[1]);
+ }
+});
+
+add_task(async function test_digest_scheme() {
+ const header = `Digest
+ realm="http-auth@example.org",
+ qop="auth, auth-int",
+ algorithm=SHA-256,
+ nonce="7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v",
+ opaque="FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS"`;
+
+ const challenges = parseChallengeHeader(header);
+ equal(challenges.length, 1);
+ equal(challenges[0].scheme, "Digest");
+
+ // Note: we are not doing a deepEqual check here, because one of the params
+ // actually contains a `,` inside quotes for its value, which will not be
+ // handled properly by the current ChallengeHeaderParser. See Bug 1857847.
+ const realmParam = challenges[0].params.find(param => param.name === "realm");
+ ok(realmParam);
+ equal(realmParam.value, "http-auth@example.org");
+
+ // Once Bug 1857847 is addressed, this should start failing and can be
+ // switched to deepEqual.
+ notDeepEqual(
+ challenges[0].params,
+ [
+ { name: "realm", value: "http-auth@example.org" },
+ { name: "qop", value: "auth, auth-int" },
+ { name: "algorithm", value: "SHA-256" },
+ { name: "nonce", value: "7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v" },
+ { name: "opaque", value: "FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS" },
+ ],
+ "notDeepEqual should be changed to deepEqual when Bug 1857847 is fixed"
+ );
+});
diff --git a/remote/shared/test/xpcshell/test_DOM.js b/remote/shared/test/xpcshell/test_DOM.js
new file mode 100644
index 0000000000..19844659b9
--- /dev/null
+++ b/remote/shared/test/xpcshell/test_DOM.js
@@ -0,0 +1,479 @@
+/* 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 { dom } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/DOM.sys.mjs"
+);
+const { NodeCache } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/webdriver/NodeCache.sys.mjs"
+);
+
+class MockElement {
+ constructor(tagName, attrs = {}) {
+ this.tagName = tagName;
+ this.localName = tagName;
+
+ this.isConnected = false;
+ this.ownerGlobal = {
+ document: {
+ isActive() {
+ return true;
+ },
+ },
+ };
+
+ for (let attr in attrs) {
+ this[attr] = attrs[attr];
+ }
+ }
+
+ get nodeType() {
+ return 1;
+ }
+
+ get ELEMENT_NODE() {
+ return 1;
+ }
+
+ // this is a severely limited CSS selector
+ // that only supports lists of tag names
+ matches(selector) {
+ let tags = selector.split(",");
+ return tags.includes(this.localName);
+ }
+}
+
+class MockXULElement extends MockElement {
+ constructor(tagName, attrs = {}) {
+ super(tagName, attrs);
+ this.namespaceURI = XUL_NS;
+
+ if (typeof this.ownerDocument == "undefined") {
+ this.ownerDocument = {};
+ }
+ if (typeof this.ownerDocument.documentElement == "undefined") {
+ this.ownerDocument.documentElement = { namespaceURI: XUL_NS };
+ }
+ }
+}
+
+const xulEl = new MockXULElement("text");
+
+const domElInPrivilegedDocument = new MockElement("input", {
+ nodePrincipal: { isSystemPrincipal: true },
+});
+const xulElInPrivilegedDocument = new MockXULElement("text", {
+ nodePrincipal: { isSystemPrincipal: true },
+});
+
+function setupTest() {
+ const browser = Services.appShell.createWindowlessBrowser(false);
+
+ browser.document.body.innerHTML = `
+ <div id="foo" style="margin: 50px">
+ <iframe></iframe>
+ <video></video>
+ <svg xmlns="http://www.w3.org/2000/svg"></svg>
+ <textarea></textarea>
+ </div>
+ `;
+
+ const divEl = browser.document.querySelector("div");
+ const svgEl = browser.document.querySelector("svg");
+ const textareaEl = browser.document.querySelector("textarea");
+ const videoEl = browser.document.querySelector("video");
+
+ const iframeEl = browser.document.querySelector("iframe");
+ const childEl = iframeEl.contentDocument.createElement("div");
+ iframeEl.contentDocument.body.appendChild(childEl);
+
+ const shadowRoot = videoEl.openOrClosedShadowRoot;
+
+ return {
+ browser,
+ nodeCache: new NodeCache(),
+ childEl,
+ divEl,
+ iframeEl,
+ shadowRoot,
+ svgEl,
+ textareaEl,
+ videoEl,
+ };
+}
+
+add_task(function test_findClosest() {
+ const { divEl, videoEl } = setupTest();
+
+ equal(dom.findClosest(divEl, "foo"), null);
+ equal(dom.findClosest(videoEl, "div"), divEl);
+});
+
+add_task(function test_isSelected() {
+ const { browser, divEl } = setupTest();
+
+ const checkbox = browser.document.createElement("input");
+ checkbox.setAttribute("type", "checkbox");
+
+ ok(!dom.isSelected(checkbox));
+ checkbox.checked = true;
+ ok(dom.isSelected(checkbox));
+
+ // selected is not a property of <input type=checkbox>
+ checkbox.selected = true;
+ checkbox.checked = false;
+ ok(!dom.isSelected(checkbox));
+
+ const option = browser.document.createElement("option");
+
+ ok(!dom.isSelected(option));
+ option.selected = true;
+ ok(dom.isSelected(option));
+
+ // checked is not a property of <option>
+ option.checked = true;
+ option.selected = false;
+ ok(!dom.isSelected(option));
+
+ // anything else should not be selected
+ for (const type of [undefined, null, "foo", true, [], {}, divEl]) {
+ ok(!dom.isSelected(type));
+ }
+});
+
+add_task(function test_isElement() {
+ const { divEl, iframeEl, shadowRoot, svgEl } = setupTest();
+
+ ok(dom.isElement(divEl));
+ ok(dom.isElement(svgEl));
+ ok(dom.isElement(xulEl));
+ ok(dom.isElement(domElInPrivilegedDocument));
+ ok(dom.isElement(xulElInPrivilegedDocument));
+
+ ok(!dom.isElement(shadowRoot));
+ ok(!dom.isElement(divEl.ownerGlobal));
+ ok(!dom.isElement(iframeEl.contentWindow));
+
+ for (const type of [true, 42, {}, [], undefined, null]) {
+ ok(!dom.isElement(type));
+ }
+});
+
+add_task(function test_isDOMElement() {
+ const { divEl, iframeEl, shadowRoot, svgEl } = setupTest();
+
+ ok(dom.isDOMElement(divEl));
+ ok(dom.isDOMElement(svgEl));
+ ok(dom.isDOMElement(domElInPrivilegedDocument));
+
+ ok(!dom.isDOMElement(shadowRoot));
+ ok(!dom.isDOMElement(divEl.ownerGlobal));
+ ok(!dom.isDOMElement(iframeEl.contentWindow));
+ ok(!dom.isDOMElement(xulEl));
+ ok(!dom.isDOMElement(xulElInPrivilegedDocument));
+
+ for (const type of [true, 42, "foo", {}, [], undefined, null]) {
+ ok(!dom.isDOMElement(type));
+ }
+});
+
+add_task(function test_isXULElement() {
+ const { divEl, iframeEl, shadowRoot, svgEl } = setupTest();
+
+ ok(dom.isXULElement(xulEl));
+ ok(dom.isXULElement(xulElInPrivilegedDocument));
+
+ ok(!dom.isXULElement(divEl));
+ ok(!dom.isXULElement(domElInPrivilegedDocument));
+ ok(!dom.isXULElement(svgEl));
+ ok(!dom.isXULElement(shadowRoot));
+ ok(!dom.isXULElement(divEl.ownerGlobal));
+ ok(!dom.isXULElement(iframeEl.contentWindow));
+
+ for (const type of [true, 42, "foo", {}, [], undefined, null]) {
+ ok(!dom.isXULElement(type));
+ }
+});
+
+add_task(function test_isDOMWindow() {
+ const { divEl, iframeEl, shadowRoot, svgEl } = setupTest();
+
+ ok(dom.isDOMWindow(divEl.ownerGlobal));
+ ok(dom.isDOMWindow(iframeEl.contentWindow));
+
+ ok(!dom.isDOMWindow(divEl));
+ ok(!dom.isDOMWindow(svgEl));
+ ok(!dom.isDOMWindow(shadowRoot));
+ ok(!dom.isDOMWindow(domElInPrivilegedDocument));
+ ok(!dom.isDOMWindow(xulEl));
+ ok(!dom.isDOMWindow(xulElInPrivilegedDocument));
+
+ for (const type of [true, 42, {}, [], undefined, null]) {
+ ok(!dom.isDOMWindow(type));
+ }
+});
+
+add_task(function test_isShadowRoot() {
+ const { browser, divEl, iframeEl, shadowRoot, svgEl } = setupTest();
+
+ ok(dom.isShadowRoot(shadowRoot));
+
+ ok(!dom.isShadowRoot(divEl));
+ ok(!dom.isShadowRoot(svgEl));
+ ok(!dom.isShadowRoot(divEl.ownerGlobal));
+ ok(!dom.isShadowRoot(iframeEl.contentWindow));
+ ok(!dom.isShadowRoot(xulEl));
+ ok(!dom.isShadowRoot(domElInPrivilegedDocument));
+ ok(!dom.isShadowRoot(xulElInPrivilegedDocument));
+
+ for (const type of [true, 42, "foo", {}, [], undefined, null]) {
+ ok(!dom.isShadowRoot(type));
+ }
+
+ const documentFragment = browser.document.createDocumentFragment();
+ ok(!dom.isShadowRoot(documentFragment));
+});
+
+add_task(function test_isReadOnly() {
+ const { browser, divEl, textareaEl } = setupTest();
+
+ const input = browser.document.createElement("input");
+ input.readOnly = true;
+ ok(dom.isReadOnly(input));
+
+ textareaEl.readOnly = true;
+ ok(dom.isReadOnly(textareaEl));
+
+ ok(!dom.isReadOnly(divEl));
+ divEl.readOnly = true;
+ ok(!dom.isReadOnly(divEl));
+
+ ok(!dom.isReadOnly(null));
+});
+
+add_task(function test_isDisabled() {
+ const { browser, divEl, svgEl } = setupTest();
+
+ const select = browser.document.createElement("select");
+ const option = browser.document.createElement("option");
+ select.appendChild(option);
+ select.disabled = true;
+ ok(dom.isDisabled(option));
+
+ const optgroup = browser.document.createElement("optgroup");
+ option.parentNode = optgroup;
+ ok(dom.isDisabled(option));
+
+ optgroup.parentNode = select;
+ ok(dom.isDisabled(option));
+
+ select.disabled = false;
+ ok(!dom.isDisabled(option));
+
+ for (const type of ["button", "input", "select", "textarea"]) {
+ const elem = browser.document.createElement(type);
+ ok(!dom.isDisabled(elem));
+ elem.disabled = true;
+ ok(dom.isDisabled(elem));
+ }
+
+ ok(!dom.isDisabled(divEl));
+
+ svgEl.disabled = true;
+ ok(!dom.isDisabled(svgEl));
+
+ ok(!dom.isDisabled(new MockXULElement("browser", { disabled: true })));
+});
+
+add_task(function test_isEditingHost() {
+ const { browser, divEl, svgEl } = setupTest();
+
+ ok(!dom.isEditingHost(null));
+
+ ok(!dom.isEditingHost(divEl));
+ divEl.contentEditable = true;
+ ok(dom.isEditingHost(divEl));
+
+ ok(!dom.isEditingHost(svgEl));
+ browser.document.designMode = "on";
+ ok(dom.isEditingHost(svgEl));
+});
+
+add_task(function test_isEditable() {
+ const { browser, divEl, svgEl, textareaEl } = setupTest();
+
+ ok(!dom.isEditable(null));
+
+ for (let type of [
+ "checkbox",
+ "radio",
+ "hidden",
+ "submit",
+ "button",
+ "image",
+ ]) {
+ const input = browser.document.createElement("input");
+ input.setAttribute("type", type);
+
+ ok(!dom.isEditable(input));
+ }
+
+ const input = browser.document.createElement("input");
+ ok(dom.isEditable(input));
+ input.setAttribute("type", "text");
+ ok(dom.isEditable(input));
+
+ ok(dom.isEditable(textareaEl));
+
+ const textareaDisabled = browser.document.createElement("textarea");
+ textareaDisabled.disabled = true;
+ ok(!dom.isEditable(textareaDisabled));
+
+ const textareaReadOnly = browser.document.createElement("textarea");
+ textareaReadOnly.readOnly = true;
+ ok(!dom.isEditable(textareaReadOnly));
+
+ ok(!dom.isEditable(divEl));
+ divEl.contentEditable = true;
+ ok(dom.isEditable(divEl));
+
+ ok(!dom.isEditable(svgEl));
+ browser.document.designMode = "on";
+ ok(dom.isEditable(svgEl));
+});
+
+add_task(function test_isMutableFormControlElement() {
+ const { browser, divEl, textareaEl } = setupTest();
+
+ ok(!dom.isMutableFormControl(null));
+
+ ok(dom.isMutableFormControl(textareaEl));
+
+ const textareaDisabled = browser.document.createElement("textarea");
+ textareaDisabled.disabled = true;
+ ok(!dom.isMutableFormControl(textareaDisabled));
+
+ const textareaReadOnly = browser.document.createElement("textarea");
+ textareaReadOnly.readOnly = true;
+ ok(!dom.isMutableFormControl(textareaReadOnly));
+
+ const mutableStates = new Set([
+ "color",
+ "date",
+ "datetime-local",
+ "email",
+ "file",
+ "month",
+ "number",
+ "password",
+ "range",
+ "search",
+ "tel",
+ "text",
+ "url",
+ "week",
+ ]);
+ for (const type of mutableStates) {
+ const input = browser.document.createElement("input");
+ input.setAttribute("type", type);
+ ok(dom.isMutableFormControl(input));
+ }
+
+ const inputHidden = browser.document.createElement("input");
+ inputHidden.setAttribute("type", "hidden");
+ ok(!dom.isMutableFormControl(inputHidden));
+
+ ok(!dom.isMutableFormControl(divEl));
+ divEl.contentEditable = true;
+ ok(!dom.isMutableFormControl(divEl));
+ browser.document.designMode = "on";
+ ok(!dom.isMutableFormControl(divEl));
+});
+
+add_task(function test_coordinates() {
+ const { divEl } = setupTest();
+
+ let coords = dom.coordinates(divEl);
+ ok(coords.hasOwnProperty("x"));
+ ok(coords.hasOwnProperty("y"));
+ equal(typeof coords.x, "number");
+ equal(typeof coords.y, "number");
+
+ deepEqual(dom.coordinates(divEl), { x: 0, y: 0 });
+ deepEqual(dom.coordinates(divEl, 10, 10), { x: 10, y: 10 });
+ deepEqual(dom.coordinates(divEl, -5, -5), { x: -5, y: -5 });
+
+ Assert.throws(() => dom.coordinates(null), /node is null/);
+
+ Assert.throws(
+ () => dom.coordinates(divEl, "string", undefined),
+ /Offset must be a number/
+ );
+ Assert.throws(
+ () => dom.coordinates(divEl, undefined, "string"),
+ /Offset must be a number/
+ );
+ Assert.throws(
+ () => dom.coordinates(divEl, "string", "string"),
+ /Offset must be a number/
+ );
+ Assert.throws(
+ () => dom.coordinates(divEl, {}, undefined),
+ /Offset must be a number/
+ );
+ Assert.throws(
+ () => dom.coordinates(divEl, undefined, {}),
+ /Offset must be a number/
+ );
+ Assert.throws(
+ () => dom.coordinates(divEl, {}, {}),
+ /Offset must be a number/
+ );
+ Assert.throws(
+ () => dom.coordinates(divEl, [], undefined),
+ /Offset must be a number/
+ );
+ Assert.throws(
+ () => dom.coordinates(divEl, undefined, []),
+ /Offset must be a number/
+ );
+ Assert.throws(
+ () => dom.coordinates(divEl, [], []),
+ /Offset must be a number/
+ );
+});
+
+add_task(function test_isDetached() {
+ const { childEl, iframeEl } = setupTest();
+
+ let detachedShadowRoot = childEl.attachShadow({ mode: "open" });
+ detachedShadowRoot.innerHTML = "<input></input>";
+
+ // Connected to the DOM
+ ok(!dom.isDetached(detachedShadowRoot));
+
+ // Node document (ownerDocument) is not the active document
+ iframeEl.remove();
+ ok(dom.isDetached(detachedShadowRoot));
+
+ // host element is stale (eg. not connected)
+ detachedShadowRoot.host.remove();
+ equal(childEl.isConnected, false);
+ ok(dom.isDetached(detachedShadowRoot));
+});
+
+add_task(function test_isStale() {
+ const { childEl, iframeEl } = setupTest();
+
+ // Connected to the DOM
+ ok(!dom.isStale(childEl));
+
+ // Not part of the active document
+ iframeEl.remove();
+ ok(dom.isStale(childEl));
+
+ // Not connected to the DOM
+ childEl.remove();
+ ok(dom.isStale(childEl));
+});
diff --git a/remote/shared/test/xpcshell/test_Format.js b/remote/shared/test/xpcshell/test_Format.js
new file mode 100644
index 0000000000..cfdd35be08
--- /dev/null
+++ b/remote/shared/test/xpcshell/test_Format.js
@@ -0,0 +1,108 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { truncate, pprint } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/Format.sys.mjs"
+);
+
+const MAX_STRING_LENGTH = 250;
+const HALF = "x".repeat(MAX_STRING_LENGTH / 2);
+
+add_task(function test_pprint() {
+ equal('[object Object] {"foo":"bar"}', pprint`${{ foo: "bar" }}`);
+
+ equal("[object Number] 42", pprint`${42}`);
+ equal("[object Boolean] true", pprint`${true}`);
+ equal("[object Undefined] undefined", pprint`${undefined}`);
+ equal("[object Null] null", pprint`${null}`);
+
+ let complexObj = { toJSON: () => "foo" };
+ equal('[object Object] "foo"', pprint`${complexObj}`);
+
+ let cyclic = {};
+ cyclic.me = cyclic;
+ equal("[object Object] <cyclic object value>", pprint`${cyclic}`);
+
+ let el = {
+ hasAttribute: attr => attr in el,
+ getAttribute: attr => (attr in el ? el[attr] : null),
+ nodeType: 1,
+ localName: "input",
+ id: "foo",
+ class: "a b",
+ href: "#",
+ name: "bar",
+ src: "s",
+ type: "t",
+ };
+ equal(
+ '<input id="foo" class="a b" href="#" name="bar" src="s" type="t">',
+ pprint`${el}`
+ );
+});
+
+add_task(function test_truncate_empty() {
+ equal(truncate``, "");
+});
+
+add_task(function test_truncate_noFields() {
+ equal(truncate`foo bar`, "foo bar");
+});
+
+add_task(function test_truncate_multipleFields() {
+ equal(truncate`${0}`, "0");
+ equal(truncate`${1}${2}${3}`, "123");
+ equal(truncate`a${1}b${2}c${3}`, "a1b2c3");
+});
+
+add_task(function test_truncate_primitiveFields() {
+ equal(truncate`${123}`, "123");
+ equal(truncate`${true}`, "true");
+ equal(truncate`${null}`, "");
+ equal(truncate`${undefined}`, "");
+});
+
+add_task(function test_truncate_string() {
+ equal(truncate`${"foo"}`, "foo");
+ equal(truncate`${"x".repeat(250)}`, "x".repeat(250));
+ equal(truncate`${"x".repeat(260)}`, `${HALF} ... ${HALF}`);
+});
+
+add_task(function test_truncate_array() {
+ equal(truncate`${["foo"]}`, JSON.stringify(["foo"]));
+ equal(truncate`${"foo"} ${["bar"]}`, `foo ${JSON.stringify(["bar"])}`);
+ equal(
+ truncate`${["x".repeat(260)]}`,
+ JSON.stringify([`${HALF} ... ${HALF}`])
+ );
+});
+
+add_task(function test_truncate_object() {
+ equal(truncate`${{}}`, JSON.stringify({}));
+ equal(truncate`${{ foo: "bar" }}`, JSON.stringify({ foo: "bar" }));
+ equal(
+ truncate`${{ foo: "x".repeat(260) }}`,
+ JSON.stringify({ foo: `${HALF} ... ${HALF}` })
+ );
+ equal(truncate`${{ foo: ["bar"] }}`, JSON.stringify({ foo: ["bar"] }));
+ equal(
+ truncate`${{ foo: ["bar", { baz: 42 }] }}`,
+ JSON.stringify({ foo: ["bar", { baz: 42 }] })
+ );
+
+ let complex = {
+ toString() {
+ return "hello world";
+ },
+ };
+ equal(truncate`${complex}`, "hello world");
+
+ let longComplex = {
+ toString() {
+ return "x".repeat(260);
+ },
+ };
+ equal(truncate`${longComplex}`, `${HALF} ... ${HALF}`);
+});
diff --git a/remote/shared/test/xpcshell/test_Navigate.js b/remote/shared/test/xpcshell/test_Navigate.js
new file mode 100644
index 0000000000..e41508189a
--- /dev/null
+++ b/remote/shared/test/xpcshell/test_Navigate.js
@@ -0,0 +1,879 @@
+/* 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 { setTimeout } = ChromeUtils.importESModule(
+ "resource://gre/modules/Timer.sys.mjs"
+);
+
+const {
+ DEFAULT_UNLOAD_TIMEOUT,
+ getUnloadTimeoutMultiplier,
+ ProgressListener,
+ waitForInitialNavigationCompleted,
+} = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/Navigate.sys.mjs"
+);
+
+const CURRENT_URI = Services.io.newURI("http://foo.bar/");
+const INITIAL_URI = Services.io.newURI("about:blank");
+const TARGET_URI = Services.io.newURI("http://foo.cheese/");
+const TARGET_URI_IS_ERROR_PAGE = Services.io.newURI("doesnotexist://");
+const TARGET_URI_WITH_HASH = Services.io.newURI("http://foo.cheese/#foo");
+
+function wait(time) {
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ return new Promise(resolve => setTimeout(resolve, time));
+}
+
+class MockRequest {
+ constructor(uri) {
+ this.originalURI = uri;
+ }
+
+ get QueryInterface() {
+ return ChromeUtils.generateQI(["nsIRequest", "nsIChannel"]);
+ }
+}
+
+class MockWebProgress {
+ constructor(browsingContext) {
+ this.browsingContext = browsingContext;
+
+ this.documentRequest = null;
+ this.isLoadingDocument = false;
+ this.listener = null;
+ this.progressListenerRemoved = false;
+ }
+
+ addProgressListener(listener) {
+ if (this.listener) {
+ throw new Error("Cannot register listener twice");
+ }
+
+ this.listener = listener;
+ }
+
+ removeProgressListener(listener) {
+ if (listener === this.listener) {
+ this.listener = null;
+ this.progressListenerRemoved = true;
+ } else {
+ throw new Error("Unknown listener");
+ }
+ }
+
+ sendLocationChange(options = {}) {
+ const { flag = 0 } = options;
+
+ this.documentRequest = null;
+
+ if (flag & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT) {
+ this.browsingContext.currentURI = TARGET_URI_WITH_HASH;
+ } else if (flag & Ci.nsIWebProgressListener.LOCATION_CHANGE_ERROR_PAGE) {
+ this.browsingContext.currentURI = TARGET_URI_IS_ERROR_PAGE;
+ }
+
+ this.listener?.onLocationChange(
+ this,
+ this.documentRequest,
+ TARGET_URI_WITH_HASH,
+ flag
+ );
+
+ return new Promise(executeSoon);
+ }
+
+ sendStartState(options = {}) {
+ const { coop = false, isInitial = false } = options;
+
+ if (coop) {
+ this.browsingContext = new MockTopContext(this);
+ }
+
+ if (!this.browsingContext.currentWindowGlobal) {
+ this.browsingContext.currentWindowGlobal = {};
+ }
+
+ this.browsingContext.currentWindowGlobal.isInitialDocument = isInitial;
+
+ this.isLoadingDocument = true;
+ const uri = isInitial ? INITIAL_URI : TARGET_URI;
+ this.documentRequest = new MockRequest(uri);
+
+ this.listener?.onStateChange(
+ this,
+ this.documentRequest,
+ Ci.nsIWebProgressListener.STATE_START,
+ null
+ );
+
+ return new Promise(executeSoon);
+ }
+
+ sendStopState(options = {}) {
+ const { errorFlag = 0 } = options;
+
+ this.browsingContext.currentURI = this.documentRequest.originalURI;
+
+ this.isLoadingDocument = false;
+ this.documentRequest = null;
+
+ this.listener?.onStateChange(
+ this,
+ this.documentRequest,
+ Ci.nsIWebProgressListener.STATE_STOP,
+ errorFlag
+ );
+
+ return new Promise(executeSoon);
+ }
+}
+
+class MockTopContext {
+ constructor(webProgress = null) {
+ this.currentURI = CURRENT_URI;
+ this.currentWindowGlobal = { isInitialDocument: true };
+ this.id = 7;
+ this.top = this;
+ this.webProgress = webProgress || new MockWebProgress(this);
+ }
+}
+
+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;
+};
+
+const hasPromiseRejected = async function (promise) {
+ let rejected = false;
+ promise.catch(() => (rejected = true));
+ // Make sure microtasks have time to run.
+ await new Promise(resolve => Services.tm.dispatchToMainThread(resolve));
+ return rejected;
+};
+
+add_task(
+ async function test_waitForInitialNavigation_initialDocumentNoWindowGlobal() {
+ const browsingContext = new MockTopContext();
+ const webProgress = browsingContext.webProgress;
+
+ // In some cases there might be no window global yet.
+ delete browsingContext.currentWindowGlobal;
+
+ ok(!webProgress.isLoadingDocument, "Document is not loading");
+
+ const navigated = waitForInitialNavigationCompleted(webProgress);
+ await webProgress.sendStartState({ isInitial: true });
+
+ ok(
+ !(await hasPromiseResolved(navigated)),
+ "waitForInitialNavigationCompleted has not resolved yet"
+ );
+
+ await webProgress.sendStopState();
+ const { currentURI, targetURI } = await navigated;
+
+ ok(!webProgress.isLoadingDocument, "Document is not loading");
+ ok(
+ webProgress.browsingContext.currentWindowGlobal.isInitialDocument,
+ "Is initial document"
+ );
+ equal(
+ currentURI.spec,
+ INITIAL_URI.spec,
+ "Expected current URI has been set"
+ );
+ equal(targetURI.spec, INITIAL_URI.spec, "Expected target URI has been set");
+ }
+);
+
+add_task(
+ async function test_waitForInitialNavigation_initialDocumentNotLoaded() {
+ const browsingContext = new MockTopContext();
+ const webProgress = browsingContext.webProgress;
+
+ ok(!webProgress.isLoadingDocument, "Document is not loading");
+
+ const navigated = waitForInitialNavigationCompleted(webProgress);
+
+ await webProgress.sendStartState({ isInitial: true });
+
+ ok(
+ !(await hasPromiseResolved(navigated)),
+ "waitForInitialNavigationCompleted has not resolved yet"
+ );
+
+ await webProgress.sendStopState();
+ const { currentURI, targetURI } = await navigated;
+
+ ok(!webProgress.isLoadingDocument, "Document is not loading");
+ ok(
+ webProgress.browsingContext.currentWindowGlobal.isInitialDocument,
+ "Is initial document"
+ );
+ equal(
+ currentURI.spec,
+ INITIAL_URI.spec,
+ "Expected current URI has been set"
+ );
+ equal(targetURI.spec, INITIAL_URI.spec, "Expected target URI has been set");
+ }
+);
+
+add_task(
+ async function test_waitForInitialNavigation_initialDocumentLoadingAndNoAdditionalLoad() {
+ const browsingContext = new MockTopContext();
+ const webProgress = browsingContext.webProgress;
+
+ await webProgress.sendStartState({ isInitial: true });
+ ok(webProgress.isLoadingDocument, "Document is loading");
+
+ const navigated = waitForInitialNavigationCompleted(webProgress);
+
+ ok(
+ !(await hasPromiseResolved(navigated)),
+ "waitForInitialNavigationCompleted has not resolved yet"
+ );
+
+ await webProgress.sendStopState();
+ const { currentURI, targetURI } = await navigated;
+
+ ok(!webProgress.isLoadingDocument, "Document is not loading");
+ ok(
+ webProgress.browsingContext.currentWindowGlobal.isInitialDocument,
+ "Is initial document"
+ );
+ equal(
+ currentURI.spec,
+ INITIAL_URI.spec,
+ "Expected current URI has been set"
+ );
+ equal(targetURI.spec, INITIAL_URI.spec, "Expected target URI has been set");
+ }
+);
+
+add_task(
+ async function test_waitForInitialNavigation_initialDocumentFinishedLoadingNoAdditionalLoad() {
+ const browsingContext = new MockTopContext();
+ const webProgress = browsingContext.webProgress;
+
+ await webProgress.sendStartState({ isInitial: true });
+ await webProgress.sendStopState();
+
+ ok(!webProgress.isLoadingDocument, "Document is not loading");
+
+ const navigated = waitForInitialNavigationCompleted(webProgress);
+
+ ok(
+ !(await hasPromiseResolved(navigated)),
+ "waitForInitialNavigationCompleted has not resolved yet"
+ );
+
+ const { currentURI, targetURI } = await navigated;
+
+ ok(!webProgress.isLoadingDocument, "Document is not loading");
+ ok(
+ webProgress.browsingContext.currentWindowGlobal.isInitialDocument,
+ "Is initial document"
+ );
+ equal(
+ currentURI.spec,
+ INITIAL_URI.spec,
+ "Expected current URI has been set"
+ );
+ equal(targetURI.spec, INITIAL_URI.spec, "Expected target URI has been set");
+ }
+);
+
+add_task(
+ async function test_waitForInitialNavigation_initialDocumentLoadingAndAdditionalLoad() {
+ const browsingContext = new MockTopContext();
+ const webProgress = browsingContext.webProgress;
+
+ await webProgress.sendStartState({ isInitial: true });
+
+ ok(webProgress.isLoadingDocument, "Document is loading");
+
+ const navigated = waitForInitialNavigationCompleted(webProgress);
+
+ ok(
+ !(await hasPromiseResolved(navigated)),
+ "waitForInitialNavigationCompleted has not resolved yet"
+ );
+
+ await webProgress.sendStopState();
+
+ await wait(100);
+
+ await webProgress.sendStartState({ isInitial: false });
+ await webProgress.sendStopState();
+
+ const { currentURI, targetURI } = await navigated;
+
+ ok(!webProgress.isLoadingDocument, "Document is not loading");
+ ok(
+ !webProgress.browsingContext.currentWindowGlobal.isInitialDocument,
+ "Is not initial document"
+ );
+ equal(
+ currentURI.spec,
+ TARGET_URI.spec,
+ "Expected current URI has been set"
+ );
+ equal(targetURI.spec, TARGET_URI.spec, "Expected target URI has been set");
+ }
+);
+
+add_task(
+ async function test_waitForInitialNavigation_initialDocumentFinishedLoadingAndAdditionalLoad() {
+ const browsingContext = new MockTopContext();
+ const webProgress = browsingContext.webProgress;
+
+ await webProgress.sendStartState({ isInitial: true });
+ await webProgress.sendStopState();
+
+ ok(!webProgress.isLoadingDocument, "Document is not loading");
+
+ const navigated = waitForInitialNavigationCompleted(webProgress);
+
+ ok(
+ !(await hasPromiseResolved(navigated)),
+ "waitForInitialNavigationCompleted has not resolved yet"
+ );
+
+ await wait(100);
+
+ await webProgress.sendStartState({ isInitial: false });
+ await webProgress.sendStopState();
+
+ const { currentURI, targetURI } = await navigated;
+
+ ok(!webProgress.isLoadingDocument, "Document is not loading");
+ ok(
+ !webProgress.browsingContext.currentWindowGlobal.isInitialDocument,
+ "Is not initial document"
+ );
+ equal(
+ currentURI.spec,
+ TARGET_URI.spec,
+ "Expected current URI has been set"
+ );
+ equal(targetURI.spec, TARGET_URI.spec, "Expected target URI has been set");
+ }
+);
+
+add_task(
+ async function test_waitForInitialNavigation_notInitialDocumentNotLoading() {
+ const browsingContext = new MockTopContext();
+ const webProgress = browsingContext.webProgress;
+
+ ok(!webProgress.isLoadingDocument, "Document is not loading");
+
+ const navigated = waitForInitialNavigationCompleted(webProgress);
+ await webProgress.sendStartState({ isInitial: false });
+
+ ok(
+ !(await hasPromiseResolved(navigated)),
+ "waitForInitialNavigationCompleted has not resolved yet"
+ );
+
+ await webProgress.sendStopState();
+ const { currentURI, targetURI } = await navigated;
+
+ ok(!webProgress.isLoadingDocument, "Document is not loading");
+ ok(
+ !browsingContext.currentWindowGlobal.isInitialDocument,
+ "Is not initial document"
+ );
+ equal(
+ currentURI.spec,
+ TARGET_URI.spec,
+ "Expected current URI has been set"
+ );
+ equal(targetURI.spec, TARGET_URI.spec, "Expected target URI has been set");
+ }
+);
+
+add_task(
+ async function test_waitForInitialNavigation_notInitialDocumentAlreadyLoading() {
+ const browsingContext = new MockTopContext();
+ const webProgress = browsingContext.webProgress;
+
+ await webProgress.sendStartState({ isInitial: false });
+ ok(webProgress.isLoadingDocument, "Document is loading");
+
+ const navigated = waitForInitialNavigationCompleted(webProgress);
+
+ ok(
+ !(await hasPromiseResolved(navigated)),
+ "waitForInitialNavigationCompleted has not resolved yet"
+ );
+
+ await webProgress.sendStopState();
+ const { currentURI, targetURI } = await navigated;
+
+ ok(!webProgress.isLoadingDocument, "Document is not loading");
+ ok(
+ !browsingContext.currentWindowGlobal.isInitialDocument,
+ "Is not initial document"
+ );
+ equal(
+ currentURI.spec,
+ TARGET_URI.spec,
+ "Expected current URI has been set"
+ );
+ equal(targetURI.spec, TARGET_URI.spec, "Expected target URI has been set");
+ }
+);
+
+add_task(
+ async function test_waitForInitialNavigation_notInitialDocumentFinishedLoading() {
+ const browsingContext = new MockTopContext();
+ const webProgress = browsingContext.webProgress;
+
+ await webProgress.sendStartState({ isInitial: false });
+ await webProgress.sendStopState();
+
+ ok(!webProgress.isLoadingDocument, "Document is not loading");
+
+ const { currentURI, targetURI } = await waitForInitialNavigationCompleted(
+ webProgress
+ );
+
+ ok(!webProgress.isLoadingDocument, "Document is not loading");
+ ok(
+ !webProgress.browsingContext.currentWindowGlobal.isInitialDocument,
+ "Is not initial document"
+ );
+ equal(
+ currentURI.spec,
+ TARGET_URI.spec,
+ "Expected current URI has been set"
+ );
+ equal(targetURI.spec, TARGET_URI.spec, "Expected target URI has been set");
+ }
+);
+
+add_task(async function test_waitForInitialNavigation_resolveWhenStarted() {
+ const browsingContext = new MockTopContext();
+ const webProgress = browsingContext.webProgress;
+
+ await webProgress.sendStartState({ isInitial: true });
+ ok(webProgress.isLoadingDocument, "Document is already loading");
+
+ const { currentURI, targetURI } = await waitForInitialNavigationCompleted(
+ webProgress,
+ {
+ resolveWhenStarted: true,
+ }
+ );
+
+ ok(webProgress.isLoadingDocument, "Document is still loading");
+ ok(
+ webProgress.browsingContext.currentWindowGlobal.isInitialDocument,
+ "Is initial document"
+ );
+ equal(currentURI.spec, CURRENT_URI.spec, "Expected current URI has been set");
+ equal(targetURI.spec, INITIAL_URI.spec, "Expected target URI has been set");
+});
+
+add_task(async function test_waitForInitialNavigation_crossOrigin() {
+ const browsingContext = new MockTopContext();
+ const webProgress = browsingContext.webProgress;
+
+ ok(!webProgress.isLoadingDocument, "Document is not loading");
+
+ const navigated = waitForInitialNavigationCompleted(webProgress);
+ await webProgress.sendStartState({ coop: true });
+
+ ok(
+ !(await hasPromiseResolved(navigated)),
+ "waitForInitialNavigationCompleted has not resolved yet"
+ );
+
+ await webProgress.sendStopState();
+ const { currentURI, targetURI } = await navigated;
+
+ notEqual(
+ browsingContext,
+ webProgress.browsingContext,
+ "Got new browsing context"
+ );
+ ok(!webProgress.isLoadingDocument, "Document is not loading");
+ ok(
+ !webProgress.browsingContext.currentWindowGlobal.isInitialDocument,
+ "Is not initial document"
+ );
+ equal(currentURI.spec, TARGET_URI.spec, "Expected current URI has been set");
+ equal(targetURI.spec, TARGET_URI.spec, "Expected target URI has been set");
+});
+
+add_task(async function test_waitForInitialNavigation_unloadTimeout_default() {
+ const browsingContext = new MockTopContext();
+ const webProgress = browsingContext.webProgress;
+
+ // Stop the navigation on an initial page which is not loading anymore.
+ // This situation happens with new tabs on Android, even though they are on
+ // the initial document, they will not start another navigation on their own.
+ await webProgress.sendStartState({ isInitial: true });
+ await webProgress.sendStopState();
+
+ ok(!webProgress.isLoadingDocument, "Document is not loading");
+
+ const navigated = waitForInitialNavigationCompleted(webProgress);
+
+ // Start a timer longer than the timeout which will be used by
+ // waitForInitialNavigationCompleted, and check that navigated resolves first.
+ const waitForMoreThanDefaultTimeout = wait(
+ DEFAULT_UNLOAD_TIMEOUT * 1.5 * getUnloadTimeoutMultiplier()
+ );
+ await Promise.race([navigated, waitForMoreThanDefaultTimeout]);
+
+ ok(
+ await hasPromiseResolved(navigated),
+ "waitForInitialNavigationCompleted has resolved"
+ );
+
+ ok(!webProgress.isLoadingDocument, "Document is not loading");
+ ok(
+ webProgress.browsingContext.currentWindowGlobal.isInitialDocument,
+ "Document is still on the initial document"
+ );
+});
+
+add_task(async function test_waitForInitialNavigation_unloadTimeout_longer() {
+ const browsingContext = new MockTopContext();
+ const webProgress = browsingContext.webProgress;
+
+ // Stop the navigation on an initial page which is not loading anymore.
+ // This situation happens with new tabs on Android, even though they are on
+ // the initial document, they will not start another navigation on their own.
+ await webProgress.sendStartState({ isInitial: true });
+ await webProgress.sendStopState();
+
+ ok(!webProgress.isLoadingDocument, "Document is not loading");
+
+ const navigated = waitForInitialNavigationCompleted(webProgress, {
+ unloadTimeout: DEFAULT_UNLOAD_TIMEOUT * 3,
+ });
+
+ // Start a timer longer than the default timeout of the Navigate module.
+ // However here we used a custom timeout, so we expect that the navigation
+ // will not be done yet by the time this timer is done.
+ const waitForMoreThanDefaultTimeout = wait(
+ DEFAULT_UNLOAD_TIMEOUT * 1.5 * getUnloadTimeoutMultiplier()
+ );
+ await Promise.race([navigated, waitForMoreThanDefaultTimeout]);
+
+ // The promise should not have resolved because we didn't reached the custom
+ // timeout which is 3 times the default one.
+ ok(
+ !(await hasPromiseResolved(navigated)),
+ "waitForInitialNavigationCompleted has not resolved yet"
+ );
+
+ // The navigation should eventually resolve once we reach the custom timeout.
+ await navigated;
+
+ ok(!webProgress.isLoadingDocument, "Document is not loading");
+ ok(
+ webProgress.browsingContext.currentWindowGlobal.isInitialDocument,
+ "Document is still on the initial document"
+ );
+});
+
+add_task(async function test_ProgressListener_expectNavigation() {
+ const browsingContext = new MockTopContext();
+ const webProgress = browsingContext.webProgress;
+
+ const progressListener = new ProgressListener(webProgress, {
+ expectNavigation: true,
+ unloadTimeout: 10,
+ });
+ const navigated = progressListener.start();
+
+ // Wait for unloadTimeout to finish in case it started
+ await wait(30);
+
+ ok(!(await hasPromiseResolved(navigated)), "Listener has not resolved yet");
+
+ await webProgress.sendStartState();
+ await webProgress.sendStopState();
+
+ ok(await hasPromiseResolved(navigated), "Listener has resolved");
+});
+
+add_task(
+ async function test_ProgressListener_expectNavigation_initialDocumentFinishedLoading() {
+ const browsingContext = new MockTopContext();
+ const webProgress = browsingContext.webProgress;
+
+ const progressListener = new ProgressListener(webProgress, {
+ expectNavigation: true,
+ unloadTimeout: 10,
+ });
+ const navigated = progressListener.start();
+
+ ok(!(await hasPromiseResolved(navigated)), "Listener has not resolved yet");
+
+ await webProgress.sendStartState({ isInitial: true });
+ await webProgress.sendStopState();
+
+ // Wait for unloadTimeout to finish in case it started
+ await wait(30);
+
+ ok(!(await hasPromiseResolved(navigated)), "Listener has not resolved yet");
+
+ await webProgress.sendStartState();
+ await webProgress.sendStopState();
+
+ ok(await hasPromiseResolved(navigated), "Listener has resolved");
+ }
+);
+
+add_task(async function test_ProgressListener_isStarted() {
+ const browsingContext = new MockTopContext();
+ const webProgress = browsingContext.webProgress;
+
+ const progressListener = new ProgressListener(webProgress);
+ ok(!progressListener.isStarted);
+
+ progressListener.start();
+ ok(progressListener.isStarted);
+
+ progressListener.stop();
+ ok(!progressListener.isStarted);
+});
+
+add_task(async function test_ProgressListener_notWaitForExplicitStart() {
+ // Create a webprogress and start it before creating the progress listener.
+ const browsingContext = new MockTopContext();
+ const webProgress = browsingContext.webProgress;
+ await webProgress.sendStartState();
+
+ // Create the progress listener for a webprogress already in a navigation.
+ const progressListener = new ProgressListener(webProgress, {
+ waitForExplicitStart: false,
+ });
+ const navigated = progressListener.start();
+
+ // Send stop state to complete the initial navigation
+ await webProgress.sendStopState();
+ ok(
+ await hasPromiseResolved(navigated),
+ "Listener has resolved after initial navigation"
+ );
+});
+
+add_task(async function test_ProgressListener_waitForExplicitStart() {
+ // Create a webprogress and start it before creating the progress listener.
+ const browsingContext = new MockTopContext();
+ const webProgress = browsingContext.webProgress;
+ await webProgress.sendStartState();
+
+ // Create the progress listener for a webprogress already in a navigation.
+ const progressListener = new ProgressListener(webProgress, {
+ waitForExplicitStart: true,
+ });
+ const navigated = progressListener.start();
+
+ // Send stop state to complete the initial navigation
+ await webProgress.sendStopState();
+ ok(
+ !(await hasPromiseResolved(navigated)),
+ "Listener has not resolved after initial navigation"
+ );
+
+ // Start a new navigation
+ await webProgress.sendStartState();
+ ok(
+ !(await hasPromiseResolved(navigated)),
+ "Listener has not resolved after starting new navigation"
+ );
+
+ // Finish the new navigation
+ await webProgress.sendStopState();
+ ok(
+ await hasPromiseResolved(navigated),
+ "Listener resolved after finishing the new navigation"
+ );
+});
+
+add_task(
+ async function test_ProgressListener_waitForExplicitStartAndResolveWhenStarted() {
+ // Create a webprogress and start it before creating the progress listener.
+ const browsingContext = new MockTopContext();
+ const webProgress = browsingContext.webProgress;
+ await webProgress.sendStartState();
+
+ // Create the progress listener for a webprogress already in a navigation.
+ const progressListener = new ProgressListener(webProgress, {
+ resolveWhenStarted: true,
+ waitForExplicitStart: true,
+ });
+ const navigated = progressListener.start();
+
+ // Send stop state to complete the initial navigation
+ await webProgress.sendStopState();
+ ok(
+ !(await hasPromiseResolved(navigated)),
+ "Listener has not resolved after initial navigation"
+ );
+
+ // Start a new navigation
+ await webProgress.sendStartState();
+ ok(
+ await hasPromiseResolved(navigated),
+ "Listener resolved after starting the new navigation"
+ );
+ }
+);
+
+add_task(
+ async function test_ProgressListener_resolveWhenNavigatingInsideDocument() {
+ const browsingContext = new MockTopContext();
+ const webProgress = browsingContext.webProgress;
+
+ const progressListener = new ProgressListener(webProgress);
+ const navigated = progressListener.start();
+
+ ok(!(await hasPromiseResolved(navigated)), "Listener has not resolved");
+
+ // Send hash change location change notification to complete the navigation
+ await webProgress.sendLocationChange({
+ flag: Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT,
+ });
+
+ ok(await hasPromiseResolved(navigated), "Listener has resolved");
+
+ const { currentURI, targetURI } = progressListener;
+ equal(
+ currentURI.spec,
+ TARGET_URI_WITH_HASH.spec,
+ "Expected current URI has been set"
+ );
+ equal(
+ targetURI.spec,
+ TARGET_URI_WITH_HASH.spec,
+ "Expected target URI has been set"
+ );
+ }
+);
+
+add_task(async function test_ProgressListener_ignoreCacheError() {
+ const browsingContext = new MockTopContext();
+ const webProgress = browsingContext.webProgress;
+
+ const progressListener = new ProgressListener(webProgress);
+ const navigated = progressListener.start();
+
+ ok(!(await hasPromiseResolved(navigated)), "Listener has not resolved");
+
+ await webProgress.sendStartState();
+ await webProgress.sendStopState({
+ errorFlag: Cr.NS_ERROR_PARSED_DATA_CACHED,
+ });
+
+ ok(await hasPromiseResolved(navigated), "Listener has resolved");
+});
+
+add_task(async function test_ProgressListener_navigationRejectedOnErrorPage() {
+ const browsingContext = new MockTopContext();
+ const webProgress = browsingContext.webProgress;
+
+ const progressListener = new ProgressListener(webProgress, {
+ waitForExplicitStart: false,
+ });
+ const navigated = progressListener.start();
+
+ await webProgress.sendStartState();
+ await webProgress.sendLocationChange({
+ flag:
+ Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT |
+ Ci.nsIWebProgressListener.LOCATION_CHANGE_ERROR_PAGE,
+ });
+
+ ok(
+ await hasPromiseRejected(navigated),
+ "Listener has rejected in location change for error page"
+ );
+});
+
+add_task(async function test_ProgressListener_navigationRejectedOnStopState() {
+ const browsingContext = new MockTopContext();
+ const webProgress = browsingContext.webProgress;
+
+ const progressListener = new ProgressListener(webProgress, {
+ waitForExplicitStart: false,
+ });
+ const navigated = progressListener.start();
+
+ await webProgress.sendStartState();
+ await webProgress.sendStopState({ errorFlag: Cr.NS_BINDING_ABORTED });
+
+ ok(
+ await hasPromiseRejected(navigated),
+ "Listener has rejected in stop state for erroneous navigation"
+ );
+});
+
+add_task(async function test_ProgressListener_stopIfStarted() {
+ const browsingContext = new MockTopContext();
+ const webProgress = browsingContext.webProgress;
+
+ const progressListener = new ProgressListener(webProgress);
+ const navigated = progressListener.start();
+
+ progressListener.stopIfStarted();
+ ok(!(await hasPromiseResolved(navigated)), "Listener has not resolved");
+
+ await webProgress.sendStartState();
+ progressListener.stopIfStarted();
+ ok(await hasPromiseResolved(navigated), "Listener has resolved");
+});
+
+add_task(async function test_ProgressListener_stopIfStarted_alreadyStarted() {
+ // Create an already navigating browsing context.
+ const browsingContext = new MockTopContext();
+ const webProgress = browsingContext.webProgress;
+ await webProgress.sendStartState();
+
+ // Create a progress listener which accepts already ongoing navigations.
+ const progressListener = new ProgressListener(webProgress, {
+ waitForExplicitStart: false,
+ });
+ const navigated = progressListener.start();
+
+ // stopIfStarted should stop the listener because of the ongoing navigation.
+ progressListener.stopIfStarted();
+ ok(await hasPromiseResolved(navigated), "Listener has resolved");
+});
+
+add_task(
+ async function test_ProgressListener_stopIfStarted_alreadyStarted_waitForExplicitStart() {
+ // Create an already navigating browsing context.
+ const browsingContext = new MockTopContext();
+ const webProgress = browsingContext.webProgress;
+ await webProgress.sendStartState();
+
+ // Create a progress listener which rejects already ongoing navigations.
+ const progressListener = new ProgressListener(webProgress, {
+ waitForExplicitStart: true,
+ });
+ const navigated = progressListener.start();
+
+ // stopIfStarted will not stop the listener for the existing navigation.
+ progressListener.stopIfStarted();
+ ok(!(await hasPromiseResolved(navigated)), "Listener has not resolved");
+
+ // stopIfStarted will stop the listener when called after starting a new
+ // navigation.
+ await webProgress.sendStartState();
+ progressListener.stopIfStarted();
+ ok(await hasPromiseResolved(navigated), "Listener has resolved");
+ }
+);
diff --git a/remote/shared/test/xpcshell/test_Realm.js b/remote/shared/test/xpcshell/test_Realm.js
new file mode 100644
index 0000000000..3990cce482
--- /dev/null
+++ b/remote/shared/test/xpcshell/test_Realm.js
@@ -0,0 +1,116 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { Realm, WindowRealm } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/Realm.sys.mjs"
+);
+
+add_task(function test_id() {
+ const realm1 = new Realm();
+ const id1 = realm1.id;
+ Assert.equal(typeof id1, "string");
+
+ const realm2 = new Realm();
+ const id2 = realm2.id;
+ Assert.equal(typeof id2, "string");
+
+ Assert.notEqual(id1, id2, "Ids for different realms are different");
+});
+
+add_task(function test_handleObjectMap() {
+ const realm = new Realm();
+
+ // Test an unknown handle.
+ Assert.equal(
+ realm.getObjectForHandle("unknown"),
+ undefined,
+ "Unknown handles return undefined"
+ );
+
+ // Test creating a simple handle.
+ const object = {};
+ const handle = realm.getHandleForObject(object);
+ Assert.equal(typeof handle, "string", "Created a valid handle");
+ Assert.equal(
+ realm.getObjectForHandle(handle),
+ object,
+ "Using the handle returned the original object"
+ );
+
+ // Test another handle for the same object.
+ const secondHandle = realm.getHandleForObject(object);
+ Assert.equal(typeof secondHandle, "string", "Created a valid handle");
+ Assert.notEqual(secondHandle, handle, "A different handle was generated");
+ Assert.equal(
+ realm.getObjectForHandle(secondHandle),
+ object,
+ "Using the second handle also returned the original object"
+ );
+
+ // Test using the handles in another realm.
+ const otherRealm = new Realm();
+ Assert.equal(
+ otherRealm.getObjectForHandle(handle),
+ undefined,
+ "A realm returns undefined for handles from another realm"
+ );
+
+ // Removing an unknown handle should not throw or have any side effect on
+ // existing handles.
+ realm.removeObjectHandle("unknown");
+ Assert.equal(realm.getObjectForHandle(handle), object);
+ Assert.equal(realm.getObjectForHandle(secondHandle), object);
+
+ // Remove the second handle
+ realm.removeObjectHandle(secondHandle);
+ Assert.equal(
+ realm.getObjectForHandle(handle),
+ object,
+ "The first handle is still resolving the object"
+ );
+ Assert.equal(
+ realm.getObjectForHandle(secondHandle),
+ undefined,
+ "The second handle returns undefined after calling removeObjectHandle"
+ );
+
+ // Remove the original handle
+ realm.removeObjectHandle(handle);
+ Assert.equal(
+ realm.getObjectForHandle(handle),
+ undefined,
+ "The first handle returns undefined as well"
+ );
+});
+
+add_task(async function test_windowRealm_isSandbox() {
+ const windowlessBrowser = Services.appShell.createWindowlessBrowser(false);
+ const contentWindow = windowlessBrowser.docShell.domWindow;
+
+ const realm1 = new WindowRealm(contentWindow);
+ Assert.equal(realm1.isSandbox, false);
+
+ const realm2 = new WindowRealm(contentWindow, { sandboxName: "test" });
+ Assert.equal(realm2.isSandbox, true);
+});
+
+add_task(async function test_windowRealm_userActivationEnabled() {
+ const windowlessBrowser = Services.appShell.createWindowlessBrowser(false);
+ const contentWindow = windowlessBrowser.docShell.domWindow;
+ const userActivation = contentWindow.navigator.userActivation;
+
+ const realm = new WindowRealm(contentWindow);
+
+ Assert.equal(realm.userActivationEnabled, false);
+ Assert.equal(userActivation.isActive && userActivation.hasBeenActive, false);
+
+ realm.userActivationEnabled = true;
+ Assert.equal(realm.userActivationEnabled, true);
+ Assert.equal(userActivation.isActive && userActivation.hasBeenActive, true);
+
+ realm.userActivationEnabled = false;
+ Assert.equal(realm.userActivationEnabled, false);
+ Assert.equal(userActivation.isActive && userActivation.hasBeenActive, false);
+});
diff --git a/remote/shared/test/xpcshell/test_RecommendedPreferences.js b/remote/shared/test/xpcshell/test_RecommendedPreferences.js
new file mode 100644
index 0000000000..20de07a528
--- /dev/null
+++ b/remote/shared/test/xpcshell/test_RecommendedPreferences.js
@@ -0,0 +1,118 @@
+/* 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 { RecommendedPreferences } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/RecommendedPreferences.sys.mjs"
+);
+
+const COMMON_PREF = "toolkit.startup.max_resumed_crashes";
+
+const PROTOCOL_1_PREF = "dom.disable_beforeunload";
+const PROTOCOL_1_RECOMMENDED_PREFS = new Map([[PROTOCOL_1_PREF, true]]);
+
+const PROTOCOL_2_PREF = "browser.contentblocking.features.standard";
+const PROTOCOL_2_RECOMMENDED_PREFS = new Map([
+ [PROTOCOL_2_PREF, "-tp,tpPrivate,cookieBehavior0,-cm,-fp"],
+]);
+
+function cleanup() {
+ info("Restore recommended preferences and test preferences");
+ Services.prefs.clearUserPref("remote.prefs.recommended");
+ RecommendedPreferences.restoreAllPreferences();
+}
+
+// cleanup() should be called:
+// - explicitly after each test to avoid side effects
+// - via registerCleanupFunction in case a test crashes/times out
+registerCleanupFunction(cleanup);
+
+add_task(async function test_multipleClients() {
+ info("Check initial values for the test preferences");
+ checkPreferences({ common: false, protocol_1: false, protocol_2: false });
+
+ checkPreferences({ common: false, protocol_1: false, protocol_2: false });
+
+ info("Apply recommended preferences for a protocol_1 client");
+ RecommendedPreferences.applyPreferences(PROTOCOL_1_RECOMMENDED_PREFS);
+ checkPreferences({ common: true, protocol_1: true, protocol_2: false });
+
+ info("Apply recommended preferences for a protocol_2 client");
+ RecommendedPreferences.applyPreferences(PROTOCOL_2_RECOMMENDED_PREFS);
+ checkPreferences({ common: true, protocol_1: true, protocol_2: true });
+
+ info("Restore protocol_1 preferences");
+ RecommendedPreferences.restorePreferences(PROTOCOL_1_RECOMMENDED_PREFS);
+ checkPreferences({ common: true, protocol_1: false, protocol_2: true });
+
+ info("Restore protocol_2 preferences");
+ RecommendedPreferences.restorePreferences(PROTOCOL_2_RECOMMENDED_PREFS);
+ checkPreferences({ common: true, protocol_1: false, protocol_2: false });
+
+ info("Restore all the altered preferences");
+ RecommendedPreferences.restoreAllPreferences();
+ checkPreferences({ common: false, protocol_1: false, protocol_2: false });
+
+ info("Attemps to restore again");
+ RecommendedPreferences.restoreAllPreferences();
+ checkPreferences({ common: false, protocol_1: false, protocol_2: false });
+
+ cleanup();
+});
+
+add_task(async function test_disabled() {
+ info("Disable RecommendedPreferences");
+ Services.prefs.setBoolPref("remote.prefs.recommended", false);
+
+ info("Check initial values for the test preferences");
+ checkPreferences({ common: false, protocol_1: false, protocol_2: false });
+
+ info("Recommended preferences are not applied, applyPreferences is a no-op");
+ RecommendedPreferences.applyPreferences(PROTOCOL_1_RECOMMENDED_PREFS);
+ checkPreferences({ common: false, protocol_1: false, protocol_2: false });
+
+ cleanup();
+});
+
+add_task(async function test_noCustomPreferences() {
+ info("Applying preferences without any custom preference should not throw");
+ RecommendedPreferences.applyPreferences();
+
+ cleanup();
+});
+
+// Check that protocols can override common preferences.
+add_task(async function test_override() {
+ info("Make sure the common preference has no user value");
+ Services.prefs.clearUserPref(COMMON_PREF);
+
+ const OVERRIDE_VALUE = 42;
+ const OVERRIDE_COMMON_PREF = new Map([[COMMON_PREF, OVERRIDE_VALUE]]);
+
+ info("Apply a map of preferences overriding a common preference");
+ RecommendedPreferences.applyPreferences(OVERRIDE_COMMON_PREF);
+
+ equal(
+ Services.prefs.getIntPref(COMMON_PREF),
+ OVERRIDE_VALUE,
+ "The common preference was set to the expected value"
+ );
+
+ cleanup();
+});
+
+function checkPreferences({ common, protocol_1, protocol_2 }) {
+ checkPreference(COMMON_PREF, { hasValue: common });
+ checkPreference(PROTOCOL_1_PREF, { hasValue: protocol_1 });
+ checkPreference(PROTOCOL_2_PREF, { hasValue: protocol_2 });
+}
+
+function checkPreference(pref, { hasValue }) {
+ equal(
+ Services.prefs.prefHasUserValue(pref),
+ hasValue,
+ hasValue
+ ? `The preference ${pref} has a user value`
+ : `The preference ${pref} has no user value`
+ );
+}
diff --git a/remote/shared/test/xpcshell/test_Stack.js b/remote/shared/test/xpcshell/test_Stack.js
new file mode 100644
index 0000000000..c41c5f0240
--- /dev/null
+++ b/remote/shared/test/xpcshell/test_Stack.js
@@ -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/. */
+
+const { getFramesFromStack, isChromeFrame } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/Stack.sys.mjs"
+);
+
+const sourceFrames = [
+ {
+ column: 1,
+ functionDisplayName: "foo",
+ line: 2,
+ source: "cheese",
+ sourceId: 1,
+ },
+ {
+ column: 3,
+ functionDisplayName: null,
+ line: 4,
+ source: "cake",
+ sourceId: 2,
+ },
+ {
+ column: 5,
+ functionDisplayName: "chrome",
+ line: 6,
+ source: "chrome://foo",
+ sourceId: 3,
+ },
+];
+
+const targetFrames = [
+ {
+ columnNumber: 1,
+ functionName: "foo",
+ lineNumber: 2,
+ filename: "cheese",
+ sourceId: 1,
+ },
+ {
+ columnNumber: 3,
+ functionName: "",
+ lineNumber: 4,
+ filename: "cake",
+ sourceId: 2,
+ },
+ {
+ columnNumber: 5,
+ functionName: "chrome",
+ lineNumber: 6,
+ filename: "chrome://foo",
+ sourceId: 3,
+ },
+];
+
+add_task(async function test_getFramesFromStack() {
+ const stack = buildStack(sourceFrames);
+ const frames = getFramesFromStack(stack, { includeChrome: false });
+
+ ok(Array.isArray(frames), "frames is of expected type Array");
+ equal(frames.length, 3, "Got expected amount of frames");
+ checkFrame(frames.at(0), targetFrames.at(0));
+ checkFrame(frames.at(1), targetFrames.at(1));
+ checkFrame(frames.at(2), targetFrames.at(2));
+});
+
+add_task(async function test_getFramesFromStack_asyncStack() {
+ const stack = buildStack(sourceFrames, true);
+ const frames = getFramesFromStack(stack);
+
+ ok(Array.isArray(frames), "frames is of expected type Array");
+ equal(frames.length, 3, "Got expected amount of frames");
+ checkFrame(frames.at(0), targetFrames.at(0));
+ checkFrame(frames.at(1), targetFrames.at(1));
+ checkFrame(frames.at(2), targetFrames.at(2));
+});
+
+add_task(async function test_isChromeFrame() {
+ for (const filename of ["chrome://foo/bar", "resource://foo/bar"]) {
+ ok(isChromeFrame({ filename }), "Frame is of expected chrome scope");
+ }
+
+ for (const filename of ["http://foo.bar", "about:blank"]) {
+ ok(!isChromeFrame({ filename }), "Frame is of expected content scope");
+ }
+});
+
+function buildStack(frames, async = false) {
+ const parent = async ? "asyncParent" : "parent";
+
+ let currentFrame, stack;
+ for (const frame of frames) {
+ if (currentFrame) {
+ currentFrame[parent] = Object.assign({}, frame);
+ currentFrame = currentFrame[parent];
+ } else {
+ stack = Object.assign({}, frame);
+ currentFrame = stack;
+ }
+ }
+
+ return stack;
+}
+
+function checkFrame(frame, expectedFrame) {
+ equal(
+ frame.columnNumber,
+ expectedFrame.columnNumber,
+ "Got expected column number"
+ );
+ equal(
+ frame.functionName,
+ expectedFrame.functionName,
+ "Got expected function name"
+ );
+ equal(frame.lineNumber, expectedFrame.lineNumber, "Got expected line number");
+ equal(frame.filename, expectedFrame.filename, "Got expected filename");
+ equal(frame.sourceId, expectedFrame.sourceId, "Got expected source id");
+}
diff --git a/remote/shared/test/xpcshell/test_Sync.js b/remote/shared/test/xpcshell/test_Sync.js
new file mode 100644
index 0000000000..de4a4d30fe
--- /dev/null
+++ b/remote/shared/test/xpcshell/test_Sync.js
@@ -0,0 +1,436 @@
+/* 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 { setTimeout } = ChromeUtils.importESModule(
+ "resource://gre/modules/Timer.sys.mjs"
+);
+
+const { AnimationFramePromise, Deferred, EventPromise, PollPromise } =
+ ChromeUtils.importESModule("chrome://remote/content/shared/Sync.sys.mjs");
+
+const { Log } = ChromeUtils.importESModule(
+ "resource://gre/modules/Log.sys.mjs"
+);
+
+/**
+ * Mimic a DOM node for listening for events.
+ */
+class MockElement {
+ constructor() {
+ this.capture = false;
+ this.eventName = null;
+ this.func = null;
+ this.mozSystemGroup = false;
+ this.wantUntrusted = false;
+ this.untrusted = false;
+ }
+
+ addEventListener(name, func, options = {}) {
+ const { capture, mozSystemGroup, wantUntrusted } = options;
+
+ this.eventName = name;
+ this.func = func;
+ this.capture = capture ?? false;
+ this.mozSystemGroup = mozSystemGroup ?? false;
+ this.wantUntrusted = wantUntrusted ?? false;
+ }
+
+ click() {
+ if (this.func) {
+ const event = {
+ capture: this.capture,
+ mozSystemGroup: this.mozSystemGroup,
+ target: this,
+ type: this.eventName,
+ untrusted: this.untrusted,
+ wantUntrusted: this.wantUntrusted,
+ };
+ this.func(event);
+ }
+ }
+
+ dispatchEvent(event) {
+ if (this.wantUntrusted) {
+ this.untrusted = true;
+ }
+ this.click();
+ }
+
+ removeEventListener(name, func) {
+ this.capture = false;
+ this.eventName = null;
+ this.func = null;
+ this.mozSystemGroup = false;
+ this.untrusted = false;
+ this.wantUntrusted = false;
+ }
+}
+
+class MockAppender extends Log.Appender {
+ constructor(formatter) {
+ super(formatter);
+ this.messages = [];
+ }
+
+ append(message) {
+ this.doAppend(message);
+ }
+
+ doAppend(message) {
+ this.messages.push(message);
+ }
+}
+
+add_task(async function test_AnimationFramePromise() {
+ let called = false;
+ let win = {
+ requestAnimationFrame(callback) {
+ called = true;
+ callback();
+ },
+ };
+ await AnimationFramePromise(win);
+ ok(called);
+});
+
+add_task(async function test_AnimationFramePromiseAbortWhenWindowClosed() {
+ let win = {
+ closed: true,
+ requestAnimationFrame() {},
+ };
+ await AnimationFramePromise(win);
+});
+
+add_task(async function test_DeferredPending() {
+ const deferred = Deferred();
+ ok(deferred.pending);
+
+ deferred.resolve();
+ await deferred.promise;
+ ok(!deferred.pending);
+});
+
+add_task(async function test_DeferredRejected() {
+ const deferred = Deferred();
+
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ setTimeout(() => deferred.reject(new Error("foo")), 100);
+
+ try {
+ await deferred.promise;
+ ok(false);
+ } catch (e) {
+ ok(!deferred.pending);
+
+ ok(!deferred.fulfilled);
+ ok(deferred.rejected);
+ equal(e.message, "foo");
+ }
+});
+
+add_task(async function test_DeferredResolved() {
+ const deferred = Deferred();
+ ok(deferred.pending);
+
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ setTimeout(() => deferred.resolve("foo"), 100);
+
+ const result = await deferred.promise;
+ ok(!deferred.pending);
+
+ ok(deferred.fulfilled);
+ ok(!deferred.rejected);
+ equal(result, "foo");
+});
+
+add_task(async function test_EventPromise_subjectTypes() {
+ for (const subject of ["foo", 42, null, undefined, true, [], {}]) {
+ Assert.throws(() => new EventPromise(subject, "click"), /TypeError/);
+ }
+});
+
+add_task(async function test_EventPromise_eventNameTypes() {
+ const element = new MockElement();
+
+ for (const eventName of [42, null, undefined, true, [], {}]) {
+ Assert.throws(() => new EventPromise(element, eventName), /TypeError/);
+ }
+});
+
+add_task(async function test_EventPromise_subjectAndEventNameEvent() {
+ const element = new MockElement();
+
+ const clicked = new EventPromise(element, "click");
+ element.click();
+ const event = await clicked;
+
+ equal(element, event.target);
+});
+
+add_task(async function test_EventPromise_captureTypes() {
+ const element = new MockElement();
+
+ for (const capture of [null, "foo", 42, [], {}]) {
+ Assert.throws(
+ () => new EventPromise(element, "click", { capture }),
+ /TypeError/
+ );
+ }
+});
+
+add_task(async function test_EventPromise_captureEvent() {
+ const element = new MockElement();
+
+ for (const capture of [undefined, false, true]) {
+ const expectedCapture = capture ?? false;
+
+ const clicked = new EventPromise(element, "click", { capture });
+ element.click();
+ const event = await clicked;
+
+ equal(element, event.target);
+ equal(expectedCapture, event.capture);
+ }
+});
+
+add_task(async function test_EventPromise_checkFnTypes() {
+ const element = new MockElement();
+
+ for (const checkFn of ["foo", 42, true, [], {}]) {
+ Assert.throws(
+ () => new EventPromise(element, "click", { checkFn }),
+ /TypeError/
+ );
+ }
+});
+
+add_task(async function test_EventPromise_checkFnCallback() {
+ const element = new MockElement();
+
+ let count;
+ const data = [
+ { checkFn: null, expected_count: 0 },
+ { checkFn: undefined, expected_count: 0 },
+ {
+ checkFn: event => {
+ throw new Error("foo");
+ },
+ expected_count: 0,
+ },
+ { checkFn: event => count++ > 0, expected_count: 2 },
+ ];
+
+ for (const { checkFn, expected_count } of data) {
+ count = 0;
+
+ const clicked = new EventPromise(element, "click", { checkFn });
+ element.click();
+ element.click();
+ const event = await clicked;
+
+ equal(element, event.target);
+ equal(expected_count, count);
+ }
+});
+
+add_task(async function test_EventPromise_mozSystemGroupTypes() {
+ const element = new MockElement();
+
+ for (const mozSystemGroup of [null, "foo", 42, [], {}]) {
+ Assert.throws(
+ () => new EventPromise(element, "click", { mozSystemGroup }),
+ /TypeError/
+ );
+ }
+});
+
+add_task(async function test_EventPromise_mozSystemGroupEvent() {
+ const element = new MockElement();
+
+ for (const mozSystemGroup of [undefined, false, true]) {
+ const expectedMozSystemGroup = mozSystemGroup ?? false;
+
+ const clicked = new EventPromise(element, "click", { mozSystemGroup });
+ element.click();
+ const event = await clicked;
+
+ equal(element, event.target);
+ equal(expectedMozSystemGroup, event.mozSystemGroup);
+ }
+});
+
+add_task(async function test_EventPromise_wantUntrustedTypes() {
+ const element = new MockElement();
+
+ for (let wantUntrusted of [null, "foo", 42, [], {}]) {
+ Assert.throws(
+ () => new EventPromise(element, "click", { wantUntrusted }),
+ /TypeError/
+ );
+ }
+});
+
+add_task(async function test_EventPromise_wantUntrustedEvent() {
+ for (const wantUntrusted of [undefined, false, true]) {
+ let expected_untrusted = wantUntrusted ?? false;
+
+ const element = new MockElement();
+
+ const clicked = new EventPromise(element, "click", { wantUntrusted });
+ element.dispatchEvent(new CustomEvent("click", {}));
+ const event = await clicked;
+
+ equal(element, event.target);
+ equal(expected_untrusted, event.untrusted);
+ }
+});
+
+add_task(function test_executeSoon_callback() {
+ // executeSoon() is already defined for xpcshell in head.js. As such import
+ // our implementation into a custom namespace.
+ let sync = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/Sync.sys.mjs"
+ );
+
+ for (let func of ["foo", null, true, [], {}]) {
+ Assert.throws(() => sync.executeSoon(func), /TypeError/);
+ }
+
+ let a;
+ sync.executeSoon(() => {
+ a = 1;
+ });
+ executeSoon(() => equal(1, a));
+});
+
+add_task(function test_PollPromise_funcTypes() {
+ for (let type of ["foo", 42, null, undefined, true, [], {}]) {
+ Assert.throws(() => new PollPromise(type), /TypeError/);
+ }
+ new PollPromise(() => {});
+ new PollPromise(function () {});
+});
+
+add_task(function test_PollPromise_timeoutTypes() {
+ for (let timeout of ["foo", true, [], {}]) {
+ Assert.throws(() => new PollPromise(() => {}, { timeout }), /TypeError/);
+ }
+ for (let timeout of [1.2, -1]) {
+ Assert.throws(() => new PollPromise(() => {}, { timeout }), /RangeError/);
+ }
+ for (let timeout of [null, undefined, 42]) {
+ new PollPromise(resolve => resolve(1), { timeout });
+ }
+});
+
+add_task(function test_PollPromise_intervalTypes() {
+ for (let interval of ["foo", null, true, [], {}]) {
+ Assert.throws(() => new PollPromise(() => {}, { interval }), /TypeError/);
+ }
+ for (let interval of [1.2, -1]) {
+ Assert.throws(() => new PollPromise(() => {}, { interval }), /RangeError/);
+ }
+ new PollPromise(() => {}, { interval: 42 });
+});
+
+add_task(async function test_PollPromise_retvalTypes() {
+ for (let typ of [true, false, "foo", 42, [], {}]) {
+ strictEqual(typ, await new PollPromise(resolve => resolve(typ)));
+ }
+});
+
+add_task(async function test_PollPromise_rethrowError() {
+ let nevals = 0;
+ let err;
+ try {
+ await PollPromise(() => {
+ ++nevals;
+ throw new Error();
+ });
+ } catch (e) {
+ err = e;
+ }
+ equal(1, nevals);
+ ok(err instanceof Error);
+});
+
+add_task(async function test_PollPromise_noTimeout() {
+ let nevals = 0;
+ await new PollPromise((resolve, reject) => {
+ ++nevals;
+ nevals < 100 ? reject() : resolve();
+ });
+ equal(100, nevals);
+});
+
+add_task(async function test_PollPromise_zeroTimeout() {
+ // run at least once when timeout is 0
+ let nevals = 0;
+ let start = new Date().getTime();
+ await new PollPromise(
+ (resolve, reject) => {
+ ++nevals;
+ reject();
+ },
+ { timeout: 0 }
+ );
+ let end = new Date().getTime();
+ equal(1, nevals);
+ less(end - start, 500);
+});
+
+add_task(async function test_PollPromise_timeoutElapse() {
+ let nevals = 0;
+ let start = new Date().getTime();
+ await new PollPromise(
+ (resolve, reject) => {
+ ++nevals;
+ reject();
+ },
+ { timeout: 100 }
+ );
+ let end = new Date().getTime();
+ lessOrEqual(nevals, 11);
+ greaterOrEqual(end - start, 100);
+});
+
+add_task(async function test_PollPromise_interval() {
+ let nevals = 0;
+ await new PollPromise(
+ (resolve, reject) => {
+ ++nevals;
+ reject();
+ },
+ { timeout: 100, interval: 100 }
+ );
+ equal(2, nevals);
+});
+
+add_task(async function test_PollPromise_resolve() {
+ const log = Log.repository.getLogger("RemoteAgent");
+ const appender = new MockAppender(new Log.BasicFormatter());
+ appender.level = Log.Level.Info;
+ log.addAppender(appender);
+
+ const errorMessage = "PollingFailed";
+ const timeout = 100;
+
+ await new PollPromise(
+ (resolve, reject) => {
+ resolve();
+ },
+ { timeout, errorMessage }
+ );
+ Assert.equal(appender.messages.length, 0);
+
+ await new PollPromise(
+ (resolve, reject) => {
+ reject();
+ },
+ { timeout, errorMessage: "PollingFailed" }
+ );
+ Assert.equal(appender.messages.length, 1);
+ Assert.equal(appender.messages[0].level, Log.Level.Warn);
+ Assert.equal(appender.messages[0].message, "PollingFailed after 100 ms");
+});
diff --git a/remote/shared/test/xpcshell/test_TabManager.js b/remote/shared/test/xpcshell/test_TabManager.js
new file mode 100644
index 0000000000..e9da02c861
--- /dev/null
+++ b/remote/shared/test/xpcshell/test_TabManager.js
@@ -0,0 +1,56 @@
+/* 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 { TabManager } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/TabManager.sys.mjs"
+);
+
+class MockTopBrowsingContext {
+ constructor() {
+ this.embedderElement = { permanentKey: {} };
+ this.id = 1;
+ this.top = this;
+ }
+}
+
+class MockBrowsingContext {
+ constructor() {
+ this.id = 2;
+
+ const topContext = new MockTopBrowsingContext();
+ this.parent = topContext;
+ this.top = topContext;
+ }
+}
+
+const mockTopBrowsingContext = new MockTopBrowsingContext();
+const mockBrowsingContext = new MockBrowsingContext();
+
+add_task(async function test_getIdForBrowsingContext() {
+ // Browsing context not set.
+ equal(TabManager.getIdForBrowsingContext(null), null);
+ equal(TabManager.getIdForBrowsingContext(undefined), null);
+
+ // Child browsing context.
+ equal(
+ TabManager.getIdForBrowsingContext(mockBrowsingContext),
+ mockBrowsingContext.id
+ );
+
+ const browser = mockTopBrowsingContext.embedderElement;
+ equal(
+ TabManager.getIdForBrowsingContext(mockTopBrowsingContext),
+ TabManager.getIdForBrowser(browser)
+ );
+});
+
+add_task(async function test_removeTab() {
+ // Tab not defined.
+ await TabManager.removeTab(null);
+});
+
+add_task(async function test_selectTab() {
+ // Tab not defined.
+ await TabManager.selectTab(null);
+});
diff --git a/remote/shared/test/xpcshell/test_UUID.js b/remote/shared/test/xpcshell/test_UUID.js
new file mode 100644
index 0000000000..e929a9e0a8
--- /dev/null
+++ b/remote/shared/test/xpcshell/test_UUID.js
@@ -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/. */
+
+const { generateUUID } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/UUID.sys.mjs"
+);
+
+add_task(function test_UUID_valid() {
+ const uuid = generateUUID();
+ const regExp = new RegExp(
+ /^[a-f|0-9]{8}-[a-f|0-9]{4}-[a-f|0-9]{4}-[a-f|0-9]{4}-[a-f|0-9]{12}$/g
+ );
+ ok(regExp.test(uuid));
+});
+
+add_task(function test_UUID_unique() {
+ const uuid1 = generateUUID();
+ const uuid2 = generateUUID();
+ notEqual(uuid1, uuid2);
+});
diff --git a/remote/shared/test/xpcshell/xpcshell.toml b/remote/shared/test/xpcshell/xpcshell.toml
new file mode 100644
index 0000000000..ebb6c77950
--- /dev/null
+++ b/remote/shared/test/xpcshell/xpcshell.toml
@@ -0,0 +1,24 @@
+[DEFAULT]
+head = "head.js"
+
+["test_AppInfo.js"]
+
+["test_ChallengeHeaderParser.js"]
+
+["test_DOM.js"]
+
+["test_Format.js"]
+
+["test_Navigate.js"]
+
+["test_Realm.js"]
+
+["test_RecommendedPreferences.js"]
+
+["test_Stack.js"]
+
+["test_Sync.js"]
+
+["test_TabManager.js"]
+
+["test_UUID.js"]
diff --git a/remote/shared/webdriver/Actions.sys.mjs b/remote/shared/webdriver/Actions.sys.mjs
new file mode 100644
index 0000000000..4f5a41a421
--- /dev/null
+++ b/remote/shared/webdriver/Actions.sys.mjs
@@ -0,0 +1,2376 @@
+/* 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/. */
+
+/* eslint no-dupe-keys:off */
+/* eslint-disable no-restricted-globals */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AppInfo: "chrome://remote/content/shared/AppInfo.sys.mjs",
+ assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs",
+ clearTimeout: "resource://gre/modules/Timer.sys.mjs",
+ dom: "chrome://remote/content/shared/DOM.sys.mjs",
+ error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
+ event: "chrome://remote/content/marionette/event.sys.mjs",
+ keyData: "chrome://remote/content/shared/webdriver/KeyData.sys.mjs",
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+ pprint: "chrome://remote/content/shared/Format.sys.mjs",
+ Sleep: "chrome://remote/content/marionette/sync.sys.mjs",
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "logger", () =>
+ lazy.Log.get(lazy.Log.TYPES.MARIONETTE)
+);
+
+// TODO? With ES 2016 and Symbol you can make a safer approximation
+// to an enum e.g. https://gist.github.com/xmlking/e86e4f15ec32b12c4689
+/**
+ * Implements WebDriver Actions API: a low-level interface for providing
+ * virtualised device input to the web browser.
+ *
+ * Typical usage is to construct an action chain and then dispatch it:
+ * const state = new action.State();
+ * const chain = new action.Chain.fromJSON(state, protocolData);
+ * await chain.dispatch(state, window);
+ *
+ * @namespace
+ */
+export const action = {};
+
+// Max interval between two clicks that should result in a dblclick or a tripleclick (in ms)
+export const CLICK_INTERVAL = 640;
+
+/** Map from normalized key value to UI Events modifier key name */
+const MODIFIER_NAME_LOOKUP = {
+ Alt: "alt",
+ Shift: "shift",
+ Control: "ctrl",
+ Meta: "meta",
+};
+
+/**
+ * State associated with actions
+ *
+ * Typically each top-level browsing context in a session should have a single State object
+ */
+action.State = class {
+ constructor() {
+ this.clickTracker = new ClickTracker();
+ /**
+ * A map between input ID and the device state for that input
+ * source, with one entry for each active input source.
+ *
+ * Maps string => InputSource
+ */
+ this.inputStateMap = new Map();
+
+ /**
+ * List of {@link Action} associated with current session. Used to
+ * manage dispatching events when resetting the state of the input sources.
+ * Reset operations are assumed to be idempotent.
+ */
+ this.inputsToCancel = new TickActions();
+
+ /**
+ * Map between string input id and numeric pointer id
+ */
+ this.pointerIdMap = new Map();
+ }
+
+ toString() {
+ return `[object ${this.constructor.name} ${JSON.stringify(this)}]`;
+ }
+
+ /**
+ * Reset state stored in this object.
+ * It is an error to use the State object after calling release().
+ *
+ * @param {WindowProxy} win Current window global.
+ */
+ async release(win) {
+ this.inputsToCancel.reverse();
+ await this.inputsToCancel.dispatch(this, win);
+ }
+
+ /**
+ * Get the state for a given input source.
+ *
+ * @param {string} id Input source id.
+ * @returns {InputSource} Input source state.
+ */
+ getInputSource(id) {
+ return this.inputStateMap.get(id);
+ }
+
+ /**
+ * Find or add state for an input source. The caller should verify
+ * that the returned state is the expected type.
+ *
+ * @param {string} id Input source id.
+ * @param {InputSource} newInputSource Input source state.
+ */
+ getOrAddInputSource(id, newInputSource) {
+ let inputSource = this.getInputSource(id);
+
+ if (inputSource === undefined) {
+ this.inputStateMap.set(id, newInputSource);
+ inputSource = newInputSource;
+ }
+
+ return inputSource;
+ }
+
+ /**
+ * Iterate over all input states of a given type
+ *
+ * @param {string} type Input source type name (e.g. "pointer").
+ * @returns {Iterator} Iterator over [id, input source].
+ */
+ *inputSourcesByType(type) {
+ for (const [id, inputSource] of this.inputStateMap) {
+ if (inputSource.type === type) {
+ yield [id, inputSource];
+ }
+ }
+ }
+
+ /**
+ * Get a numerical pointer id for a given pointer
+ *
+ * Pointer ids are positive integers. Mouse pointers are typically
+ * ids 0 or 1. Non-mouse pointers never get assigned id < 2. Each
+ * pointer gets a unique id.
+ *
+ * @param {string} id Pointer id.
+ * @param {string} type Pointer type.
+ * @returns {number} Numerical pointer id.
+ */
+ getPointerId(id, type) {
+ let pointerId = this.pointerIdMap.get(id);
+
+ if (pointerId === undefined) {
+ // Reserve pointer ids 0 and 1 for mouse pointers
+ const idValues = Array.from(this.pointerIdMap.values());
+
+ if (type === "mouse") {
+ for (const mouseId of [0, 1]) {
+ if (!idValues.includes(mouseId)) {
+ pointerId = mouseId;
+ break;
+ }
+ }
+ }
+
+ if (pointerId === undefined) {
+ pointerId = Math.max(1, ...idValues) + 1;
+ }
+ this.pointerIdMap.set(id, pointerId);
+ }
+
+ return pointerId;
+ }
+};
+
+export class ClickTracker {
+ #count;
+ #lastButtonClicked;
+ #timer;
+
+ constructor() {
+ this.#count = 0;
+ this.#lastButtonClicked = null;
+ }
+
+ get count() {
+ return this.#count;
+ }
+
+ #cancelTimer() {
+ lazy.clearTimeout(this.#timer);
+ }
+
+ #startTimer() {
+ this.#timer = lazy.setTimeout(this.reset.bind(this), CLICK_INTERVAL);
+ }
+
+ /**
+ * Reset tracking mouse click counter.
+ */
+ reset() {
+ this.#cancelTimer();
+ this.#count = 0;
+ this.#lastButtonClicked = null;
+ }
+
+ /**
+ * Track |button| click to identify possible double or triple click.
+ *
+ * @param {number} button
+ * A positive integer that refers to a mouse button.
+ */
+ setClick(button) {
+ this.#cancelTimer();
+
+ if (
+ this.#lastButtonClicked === null ||
+ this.#lastButtonClicked === button
+ ) {
+ this.#count++;
+ } else {
+ this.#count = 1;
+ }
+
+ this.#lastButtonClicked = button;
+ this.#startTimer();
+ }
+}
+
+/**
+ * Device state for an input source.
+ */
+class InputSource {
+ #id;
+ static type = null;
+
+ constructor(id) {
+ this.#id = id;
+ this.type = this.constructor.type;
+ }
+
+ toString() {
+ return `[object ${this.constructor.name} id: ${this.#id} type: ${
+ this.type
+ }]`;
+ }
+
+ /**
+ * @param {State} state Actions state.
+ * @param {Sequence} actionSequence Actions for a specific input source.
+ *
+ * @returns {InputSource}
+ * An {@link InputSource} object for the type of the
+ * {@link actionSequence}.
+ *
+ * @throws {InvalidArgumentError}
+ * If {@link actionSequence.type} is not valid.
+ */
+ static fromJSON(state, actionSequence) {
+ const { id, type } = actionSequence;
+
+ lazy.assert.string(
+ id,
+ lazy.pprint`Expected "id" to be a string, got ${id}`
+ );
+
+ const cls = inputSourceTypes.get(type);
+ if (cls === undefined) {
+ throw new lazy.error.InvalidArgumentError(
+ lazy.pprint`Expected known action type, got ${type}`
+ );
+ }
+
+ const sequenceInputSource = cls.fromJSON(state, actionSequence);
+ const inputSource = state.getOrAddInputSource(id, sequenceInputSource);
+
+ if (inputSource.type !== type) {
+ throw new lazy.error.InvalidArgumentError(
+ lazy.pprint`Expected input source ${id} to be ` +
+ `type ${inputSource.type}, got ${type}`
+ );
+ }
+ }
+}
+
+/**
+ * Input state not associated with a specific physical device.
+ */
+class NullInputSource extends InputSource {
+ static type = "none";
+
+ static fromJSON(state, actionSequence) {
+ const { id } = actionSequence;
+
+ return new this(id);
+ }
+}
+
+/**
+ * Input state associated with a keyboard-type device.
+ */
+class KeyInputSource extends InputSource {
+ static type = "key";
+
+ constructor(id) {
+ super(id);
+
+ this.pressed = new Set();
+ this.alt = false;
+ this.shift = false;
+ this.ctrl = false;
+ this.meta = false;
+ }
+
+ static fromJSON(state, actionSequence) {
+ const { id } = actionSequence;
+
+ return new this(id);
+ }
+
+ /**
+ * Update modifier state according to |key|.
+ *
+ * @param {string} key
+ * Normalized key value of a modifier key.
+ * @param {boolean} value
+ * Value to set the modifier attribute to.
+ *
+ * @throws {InvalidArgumentError}
+ * If |key| is not a modifier.
+ */
+ setModState(key, value) {
+ if (key in MODIFIER_NAME_LOOKUP) {
+ this[MODIFIER_NAME_LOOKUP[key]] = value;
+ } else {
+ throw new lazy.error.InvalidArgumentError(
+ lazy.pprint`Expected "key" to be one of ${Object.keys(
+ MODIFIER_NAME_LOOKUP
+ )}, got ${key}`
+ );
+ }
+ }
+
+ /**
+ * Check whether |key| is pressed.
+ *
+ * @param {string} key
+ * Normalized key value.
+ *
+ * @returns {boolean}
+ * True if |key| is in set of pressed keys.
+ */
+ isPressed(key) {
+ return this.pressed.has(key);
+ }
+
+ /**
+ * Add |key| to the set of pressed keys.
+ *
+ * @param {string} key
+ * Normalized key value.
+ *
+ * @returns {boolean}
+ * True if |key| is in list of pressed keys.
+ */
+ press(key) {
+ return this.pressed.add(key);
+ }
+
+ /**
+ * Remove |key| from the set of pressed keys.
+ *
+ * @param {string} key
+ * Normalized key value.
+ *
+ * @returns {boolean}
+ * True if |key| was present before removal, false otherwise.
+ */
+ release(key) {
+ return this.pressed.delete(key);
+ }
+}
+
+/**
+ * Input state associated with a pointer-type device.
+ */
+class PointerInputSource extends InputSource {
+ static type = "pointer";
+
+ /**
+ * @param {string} id InputSource id.
+ * @param {Pointer} pointer Object representing the specific pointer
+ * type associated with this input source.
+ */
+ constructor(id, pointer) {
+ super(id);
+
+ this.pointer = pointer;
+ this.x = 0;
+ this.y = 0;
+ this.pressed = new Set();
+ }
+
+ /**
+ * Check whether |button| is pressed.
+ *
+ * @param {number} button
+ * Positive integer that refers to a mouse button.
+ *
+ * @returns {boolean}
+ * True if |button| is in set of pressed buttons.
+ */
+ isPressed(button) {
+ lazy.assert.positiveInteger(button);
+ return this.pressed.has(button);
+ }
+
+ /**
+ * Add |button| to the set of pressed keys.
+ *
+ * @param {number} button
+ * Positive integer that refers to a mouse button.
+ *
+ * @returns {Set}
+ * Set of pressed buttons.
+ */
+ press(button) {
+ lazy.assert.positiveInteger(button);
+ this.pressed.add(button);
+ }
+
+ /**
+ * Remove |button| from the set of pressed buttons.
+ *
+ * @param {number} button
+ * A positive integer that refers to a mouse button.
+ *
+ * @returns {boolean}
+ * True if |button| was present before removals, false otherwise.
+ */
+ release(button) {
+ lazy.assert.positiveInteger(button);
+ return this.pressed.delete(button);
+ }
+
+ static fromJSON(state, actionSequence) {
+ const { id, parameters } = actionSequence;
+ let pointerType = "mouse";
+
+ if (parameters !== undefined) {
+ lazy.assert.object(
+ parameters,
+ lazy.pprint`Expected "parameters" to be an object, got ${parameters}`
+ );
+
+ if (parameters.pointerType !== undefined) {
+ pointerType = lazy.assert.string(
+ parameters.pointerType,
+ lazy.pprint(
+ `Expected "pointerType" to be a string, got ${parameters.pointerType}`
+ )
+ );
+
+ if (!["mouse", "pen", "touch"].includes(pointerType)) {
+ throw new lazy.error.InvalidArgumentError(
+ lazy.pprint`Expected "pointerType" to be one of "mouse", "pen", or "touch"`
+ );
+ }
+ }
+ }
+
+ const pointerId = state.getPointerId(id, pointerType);
+ const pointer = Pointer.fromJSON(pointerId, pointerType);
+
+ return new this(id, pointer);
+ }
+}
+
+/**
+ * Input state associated with a wheel-type device.
+ */
+class WheelInputSource extends InputSource {
+ static type = "wheel";
+
+ static fromJSON(state, actionSequence) {
+ const { id } = actionSequence;
+
+ return new this(id);
+ }
+}
+
+const inputSourceTypes = new Map();
+for (const cls of [
+ NullInputSource,
+ KeyInputSource,
+ PointerInputSource,
+ WheelInputSource,
+]) {
+ inputSourceTypes.set(cls.type, cls);
+}
+
+/**
+ * Representation of a coordinate origin
+ */
+class Origin {
+ /**
+ * Viewport coordinates of the origin of this coordinate system.
+ *
+ * This is overridden in subclasses to provide a class-specific origin.
+ *
+ * @param {InputSource} inputSource - State of current input device.
+ * @param {WindowProxy} win - Current window global
+ */
+ getOriginCoordinates(inputSource, win) {
+ throw new Error(
+ `originCoordinates not defined for ${this.constructor.name}`
+ );
+ }
+
+ /**
+ * Convert [x, y] coordinates to viewport coordinates
+ *
+ * @param {InputSource} inputSource - State of the current input device
+ * @param {Array<number>} coords - [x, y] coordinate of target relative to origin
+ * @param {WindowProxy} win - Current window global
+ */
+ getTargetCoordinates(inputSource, coords, win) {
+ const [x, y] = coords;
+ const origin = this.getOriginCoordinates(inputSource, win);
+
+ return [origin.x + x, origin.y + y];
+ }
+
+ /**
+ * @param {Element|string=} origin - Type of orgin, one of "viewport", "pointer", element or undefined.
+ *
+ * @returns {Origin} - An origin object representing the origin.
+ *
+ * @throws {InvalidArgumentError}
+ * If <code>origin</code> isn't a valid origin.
+ */
+ static fromJSON(origin) {
+ if (origin === undefined || origin === "viewport") {
+ return new ViewportOrigin();
+ }
+ if (origin === "pointer") {
+ return new PointerOrigin();
+ }
+ if (lazy.dom.isElement(origin)) {
+ return new ElementOrigin(origin);
+ }
+
+ throw new lazy.error.InvalidArgumentError(
+ `Expected "origin" to be undefined, "viewport", "pointer", ` +
+ lazy.pprint`or an element, got: ${origin}`
+ );
+ }
+}
+
+class ViewportOrigin extends Origin {
+ getOriginCoordinates(inputSource, win) {
+ return { x: 0, y: 0 };
+ }
+}
+
+class PointerOrigin extends Origin {
+ getOriginCoordinates(inputSource, win) {
+ return { x: inputSource.x, y: inputSource.y };
+ }
+}
+
+class ElementOrigin extends Origin {
+ /**
+ * @param {Element} element - The element providing the coordinate origin.
+ */
+ constructor(element) {
+ super();
+
+ this.element = element;
+ }
+
+ getOriginCoordinates(inputSource, win) {
+ const clientRects = this.element.getClientRects();
+
+ // The spec doesn't handle this case; https://github.com/w3c/webdriver/issues/1642
+ if (!clientRects.length) {
+ throw new lazy.error.MoveTargetOutOfBoundsError(
+ lazy.pprint`Origin element ${this.element} is not displayed`
+ );
+ }
+
+ return lazy.dom.getInViewCentrePoint(clientRects[0], win);
+ }
+}
+
+/**
+ * Repesents the behaviour of a single input source at a single
+ * point in time.
+ *
+ * @param {string} id - Input source ID.
+ */
+class Action {
+ /** Type of the input source associated with this action */
+ static type = null;
+ /** Type of action specific to the input source */
+ static subtype = null;
+ /** Whether this kind of action affects the overall duration of a tick */
+ affectsWallClockTime = false;
+
+ constructor(id) {
+ this.id = id;
+ this.type = this.constructor.type;
+ this.subtype = this.constructor.subtype;
+ }
+
+ toString() {
+ return `[${this.constructor.name} ${this.type}:${this.subtype}]`;
+ }
+
+ /**
+ * Dispatch the action to the relevant window.
+ *
+ * This is overridden by subclasses to implement the type-specific
+ * dispatch of the action.
+ *
+ * @param {State} state - Actions state.
+ * @param {InputSource} inputSource - State of the current input device.
+ * @param {number} tickDuration - Length of the current tick, in ms.
+ * @param {WindowProxy} win - Current window global.
+ * @returns {Promise} - Promise that is resolved once the action is complete.
+ */
+ dispatch(state, inputSource, tickDuration, win) {
+ throw new Error(
+ `Action subclass ${this.constructor.name} must override dispatch()`
+ );
+ }
+
+ /**
+ * @param {string} type - Input source type.
+ * @param {string} id - Input source id.
+ * @param {object} actionItem - Object representing a single action.
+ *
+ * @returns {Action} - An action that can be dispatched.
+ *
+ * @throws {InvalidArgumentError}
+ * If any <code>actionSequence</code> or <code>actionItem</code>
+ * attributes are invalid.
+ */
+ static fromJSON(type, id, actionItem) {
+ lazy.assert.object(
+ actionItem,
+ lazy.pprint`Expected "action" to be an object, got ${actionItem}`
+ );
+
+ const subtype = actionItem.type;
+ const subtypeMap = actionTypes.get(type);
+
+ if (subtypeMap === undefined) {
+ throw new lazy.error.InvalidArgumentError(
+ lazy.pprint`Expected known action type, got ${type}`
+ );
+ }
+
+ let cls = subtypeMap.get(subtype);
+ // Non-device specific actions can happen for any action type
+ if (cls === undefined) {
+ cls = actionTypes.get("none").get(subtype);
+ }
+ if (cls === undefined) {
+ throw new lazy.error.InvalidArgumentError(
+ lazy.pprint`Expected known subtype for type ${type}, got ${subtype}`
+ );
+ }
+
+ return cls.fromJSON(id, actionItem);
+ }
+}
+
+/**
+ * Action not associated with a specific input device.
+ */
+class NullAction extends Action {
+ static type = "none";
+}
+
+/**
+ * Action that waits for a given duration.
+ *
+ * @param {string} id - Input source ID.
+ * @param {object} options - Named arguments.
+ * @param {number} options.duration - Time to pause, in ms.
+ */
+class PauseAction extends NullAction {
+ static subtype = "pause";
+ affectsWallClockTime = true;
+
+ constructor(id, options) {
+ super(id);
+
+ const { duration } = options;
+ this.duration = duration;
+ }
+
+ /**
+ * Dispatch pause action
+ *
+ * @param {State} state - Actions state.
+ * @param {InputSource} inputSource - State of the current input device.
+ * @param {number} tickDuration - Length of the current tick, in ms.
+ * @param {WindowProxy} win - Current window global.
+ * @returns {Promise} - Promise that is resolved once the action is complete.
+ */
+ dispatch(state, inputSource, tickDuration, win) {
+ const ms = this.duration ?? tickDuration;
+
+ lazy.logger.trace(
+ ` Dispatch ${this.constructor.name} with ${this.id} ${ms}`
+ );
+
+ return lazy.Sleep(ms);
+ }
+
+ static fromJSON(id, actionItem) {
+ const { duration } = actionItem;
+
+ if (duration !== undefined) {
+ lazy.assert.positiveInteger(
+ duration,
+ lazy.pprint`Expected "duration" to be a positive integer, got ${duration}`
+ );
+ }
+
+ return new this(id, { duration });
+ }
+}
+
+/**
+ * Action associated with a keyboard input device
+ *
+ * @param {string} id - Input source ID.
+ * @param {object} options - Named arguments.
+ * @param {string} options.value - Key character.
+ */
+class KeyAction extends Action {
+ static type = "key";
+
+ constructor(id, options) {
+ super(id);
+
+ const { value } = options;
+ this.value = value;
+ }
+
+ getEventData(inputSource) {
+ let value = this.value;
+
+ if (inputSource.shift) {
+ value = lazy.keyData.getShiftedKey(value);
+ }
+
+ return new KeyEventData(value);
+ }
+
+ static fromJSON(id, actionItem) {
+ const { value } = actionItem;
+
+ // TODO countGraphemes
+ // TODO key.value could be a single code point like "\uE012"
+ // (see rawKey) or "grapheme cluster"
+ // https://bugzilla.mozilla.org/show_bug.cgi?id=1496323
+
+ lazy.assert.string(
+ value,
+ 'Expected "value" to be a string that represents single code point ' +
+ lazy.pprint`or grapheme cluster, got ${value}`
+ );
+
+ return new this(id, { value });
+ }
+}
+
+/**
+ * Action equivalent to pressing a key on a keyboard.
+ *
+ * @param {string} id - Input source ID.
+ * @param {string} value - Key character.
+ */
+class KeyDownAction extends KeyAction {
+ static subtype = "keyDown";
+
+ dispatch(state, inputSource, tickDuration, win) {
+ lazy.logger.trace(
+ `Dispatch ${this.constructor.name} with ${this.id} ${this.value}`
+ );
+
+ return new Promise(resolve => {
+ const keyEvent = this.getEventData(inputSource);
+ keyEvent.repeat = inputSource.isPressed(keyEvent.key);
+ inputSource.press(keyEvent.key);
+
+ if (keyEvent.key in MODIFIER_NAME_LOOKUP) {
+ inputSource.setModState(keyEvent.key, true);
+ }
+
+ // Append a copy of |a| with keyUp subtype
+ state.inputsToCancel.push(new KeyUpAction(this.id, this));
+ keyEvent.update(state, inputSource);
+ lazy.event.sendKeyDown(keyEvent, win);
+
+ resolve();
+ });
+ }
+}
+
+/**
+ * Action equivalent to releasing a key on a keyboard.
+ *
+ * @param {string} id - Input source ID.
+ * @param {string} value - Key character.
+ */
+class KeyUpAction extends KeyAction {
+ static subtype = "keyUp";
+
+ dispatch(state, inputSource, tickDuration, win) {
+ lazy.logger.trace(
+ `Dispatch ${this.constructor.name} with ${this.id} ${this.value}`
+ );
+
+ return new Promise(resolve => {
+ const keyEvent = this.getEventData(inputSource);
+
+ if (!inputSource.isPressed(keyEvent.key)) {
+ resolve();
+ return;
+ }
+
+ if (keyEvent.key in MODIFIER_NAME_LOOKUP) {
+ inputSource.setModState(keyEvent.key, false);
+ }
+
+ inputSource.release(keyEvent.key);
+ keyEvent.update(state, inputSource);
+
+ lazy.event.sendKeyUp(keyEvent, win);
+ resolve();
+ });
+ }
+}
+
+/**
+ * Action associated with a pointer input device
+ *
+ * @param {string} id - Input source ID.
+ * @param {object} options - Named arguments.
+ * @param {number=} options.width - Pointer width in pixels.
+ * @param {number=} options.height - Pointer height in pixels.
+ * @param {number=} options.pressure - Pointer pressure.
+ * @param {number=} options.tangentialPressure - Pointer tangential pressure.
+ * @param {number=} options.tiltX - Pointer X tilt angle.
+ * @param {number=} options.tiltX - Pointer Y tilt angle.
+ * @param {number=} options.twist - Pointer twist angle.
+ * @param {number=} options.altitudeAngle - Pointer altitude angle.
+ * @param {number=} options.azimuthAngle - Pointer azimuth angle.
+ */
+class PointerAction extends Action {
+ static type = "pointer";
+
+ constructor(id, options) {
+ super(id);
+ const {
+ width,
+ height,
+ pressure,
+ tangentialPressure,
+ tiltX,
+ tiltY,
+ twist,
+ altitudeAngle,
+ azimuthAngle,
+ } = options;
+ this.width = width;
+ this.height = height;
+ this.pressure = pressure;
+ this.tangentialPressure = tangentialPressure;
+ this.tiltX = tiltX;
+ this.tiltY = tiltY;
+ this.twist = twist;
+ this.altitudeAngle = altitudeAngle;
+ this.azimuthAngle = azimuthAngle;
+ }
+
+ /**
+ * Validate properties common to all pointer types
+ *
+ * @param {object} actionItem - Object representing a single action.
+ */
+ static validateCommon(actionItem) {
+ const {
+ width,
+ height,
+ pressure,
+ tangentialPressure,
+ tiltX,
+ tiltY,
+ twist,
+ altitudeAngle,
+ azimuthAngle,
+ } = actionItem;
+ if (width !== undefined) {
+ lazy.assert.positiveInteger(
+ width,
+ lazy.pprint`Expected "width" to be a positive integer, got ${width}`
+ );
+ }
+ if (height !== undefined) {
+ lazy.assert.positiveInteger(
+ height,
+ lazy.pprint`Expected "height" to be a positive integer, got ${height}`
+ );
+ }
+ if (pressure !== undefined) {
+ lazy.assert.numberInRange(
+ pressure,
+ [0, 1],
+ lazy.pprint`Expected "pressure" to be in range 0 to 1, got ${pressure}`
+ );
+ }
+ if (tangentialPressure !== undefined) {
+ lazy.assert.numberInRange(
+ tangentialPressure,
+ [-1, 1],
+ 'Expected "tangentialPressure" to be in range -1 to 1, ' +
+ lazy.pprint`got ${tangentialPressure}`
+ );
+ }
+ if (tiltX !== undefined) {
+ lazy.assert.integerInRange(
+ tiltX,
+ [-90, 90],
+ lazy.pprint`Expected "tiltX" to be in range -90 to 90, got ${tiltX}`
+ );
+ }
+ if (tiltY !== undefined) {
+ lazy.assert.integerInRange(
+ tiltY,
+ [-90, 90],
+ lazy.pprint`Expected "tiltY" to be in range -90 to 90, got ${tiltY}`
+ );
+ }
+ if (twist !== undefined) {
+ lazy.assert.integerInRange(
+ twist,
+ [0, 359],
+ lazy.pprint`Expected "twist" to be in range 0 to 359, got ${twist}`
+ );
+ }
+ if (altitudeAngle !== undefined) {
+ lazy.assert.numberInRange(
+ altitudeAngle,
+ [0, Math.PI / 2],
+ 'Expected "altitudeAngle" to be in range 0 to ${Math.PI / 2}, ' +
+ lazy.pprint`got ${altitudeAngle}`
+ );
+ }
+ if (azimuthAngle !== undefined) {
+ lazy.assert.numberInRange(
+ azimuthAngle,
+ [0, 2 * Math.PI],
+ 'Expected "azimuthAngle" to be in range 0 to ${2 * Math.PI}, ' +
+ lazy.pprint`got ${azimuthAngle}`
+ );
+ }
+
+ return {
+ width,
+ height,
+ pressure,
+ tangentialPressure,
+ tiltX,
+ tiltY,
+ twist,
+ altitudeAngle,
+ azimuthAngle,
+ };
+ }
+}
+
+/**
+ * Action associated with a pointer input device being depressed.
+ *
+ * @param {string} id - Input source ID.
+ * @param {object} options - Named arguments.
+ * @param {number} options.button - Button being pressed. For devices without buttons (e.g. touch), this should be 0.
+ * @param {number=} options.width - Pointer width in pixels.
+ * @param {number=} options.height - Pointer height in pixels.
+ * @param {number=} options.pressure - Pointer pressure.
+ * @param {number=} options.tangentialPressure - Pointer tangential pressure.
+ * @param {number=} options.tiltX - Pointer X tilt angle.
+ * @param {number=} options.tiltX - Pointer Y tilt angle.
+ * @param {number=} options.twist - Pointer twist angle.
+ * @param {number=} options.altitudeAngle - Pointer altitude angle.
+ * @param {number=} options.azimuthAngle - Pointer azimuth angle.
+ */
+class PointerDownAction extends PointerAction {
+ static subtype = "pointerDown";
+
+ constructor(id, options) {
+ super(id, options);
+
+ const { button } = options;
+ this.button = button;
+ }
+
+ dispatch(state, inputSource, tickDuration, win) {
+ lazy.logger.trace(
+ `Dispatch ${this.constructor.name} ${inputSource.pointer.type} with id: ${this.id} button: ${this.button}`
+ );
+
+ return new Promise(resolve => {
+ if (inputSource.isPressed(this.button)) {
+ resolve();
+ return;
+ }
+
+ inputSource.press(this.button);
+ // Append a copy of |a| with pointerUp subtype
+ state.inputsToCancel.push(new PointerUpAction(this.id, this));
+ inputSource.pointer.pointerDown(state, inputSource, this, win);
+ resolve();
+ });
+ }
+
+ static fromJSON(id, actionItem) {
+ const { button } = actionItem;
+ const props = PointerAction.validateCommon(actionItem);
+
+ lazy.assert.positiveInteger(
+ button,
+ lazy.pprint`Expected "button" to be a positive integer, got ${button}`
+ );
+
+ props.button = button;
+
+ return new this(id, props);
+ }
+}
+
+/**
+ * Action associated with a pointer input device being released.
+ *
+ * @param {string} id - Input source ID.
+ * @param {object} options - Named arguments.
+ * @param {number} options.button - Button being released. For devices without buttons (e.g. touch), this should be 0.
+ * @param {number=} options.width - Pointer width in pixels.
+ * @param {number=} options.height - Pointer height in pixels.
+ * @param {number=} options.pressure - Pointer pressure.
+ * @param {number=} options.tangentialPressure - Pointer tangential pressure.
+ * @param {number=} options.tiltX - Pointer X tilt angle.
+ * @param {number=} options.tiltX - Pointer Y tilt angle.
+ * @param {number=} options.twist - Pointer twist angle.
+ * @param {number=} options.altitudeAngle - Pointer altitude angle.
+ * @param {number=} options.azimuthAngle - Pointer azimuth angle.
+ */
+class PointerUpAction extends PointerAction {
+ static subtype = "pointerUp";
+
+ constructor(id, options) {
+ super(id, options);
+
+ const { button } = options;
+ this.button = button;
+ }
+
+ dispatch(state, inputSource, tickDuration, win) {
+ lazy.logger.trace(
+ `Dispatch ${this.constructor.name} ${inputSource.pointer.type} with id: ${this.id} button: ${this.button}`
+ );
+
+ return new Promise(resolve => {
+ if (!inputSource.isPressed(this.button)) {
+ resolve();
+ return;
+ }
+
+ inputSource.release(this.button);
+ inputSource.pointer.pointerUp(state, inputSource, this, win);
+
+ resolve();
+ });
+ }
+
+ static fromJSON(id, actionItem) {
+ const { button } = actionItem;
+ const props = PointerAction.validateCommon(actionItem);
+
+ lazy.assert.positiveInteger(
+ button,
+ lazy.pprint`Expected "button" to be a positive integer, got ${button}`
+ );
+
+ props.button = button;
+
+ return new this(id, props);
+ }
+}
+
+/**
+ * Action associated with a pointer input device being moved.
+ *
+ * @param {string} id - Input source ID.
+ * @param {object} options - Named arguments.
+ * @param {number=} options.width - Pointer width in pixels.
+ * @param {number=} options.height - Pointer height in pixels.
+ * @param {number=} options.pressure - Pointer pressure.
+ * @param {number=} options.tangentialPressure - Pointer tangential pressure.
+ * @param {number=} options.tiltX - Pointer X tilt angle.
+ * @param {number=} options.tiltX - Pointer Y tilt angle.
+ * @param {number=} options.twist - Pointer twist angle.
+ * @param {number=} options.altitudeAngle - Pointer altitude angle.
+ * @param {number=} options.azimuthAngle - Pointer azimuth angle.
+ * @param {number=} options.duration - Duration of move in ms.
+ * @param {Origin} options.origin - Origin of target coordinates.
+ * @param {number} options.x - X value of target coordinates.
+ * @param {number} options.y - Y value of target coordinates.
+ */
+class PointerMoveAction extends PointerAction {
+ static subtype = "pointerMove";
+ affectsWallClockTime = true;
+
+ constructor(id, options) {
+ super(id, options);
+
+ const { duration, origin, x, y } = options;
+ this.duration = duration;
+ this.origin = origin;
+ this.x = x;
+ this.y = y;
+ }
+
+ dispatch(state, inputSource, tickDuration, win) {
+ lazy.logger.trace(
+ `Dispatch ${this.constructor.name} ${inputSource.pointer.type} with id: ${this.id} x: ${this.x} y: ${this.y}`
+ );
+
+ const target = this.origin.getTargetCoordinates(
+ inputSource,
+ [this.x, this.y],
+ win
+ );
+
+ assertInViewPort(target, win);
+
+ return moveOverTime(
+ [[inputSource.x, inputSource.y]],
+ [target],
+ this.duration ?? tickDuration,
+ target => this.performPointerMoveStep(state, inputSource, target, win)
+ );
+ }
+
+ /**
+ * Perform one part of a pointer move corresponding to a specific emitted event.
+ *
+ * @param {State} state - Actions state.
+ * @param {InputSource} inputSource - State of the current input device.
+ * @param {Array<Array<number>>} targets - Array of [x, y] arrays
+ * specifying the viewport coordinates to move to.
+ * @param {WindowProxy} win - Current window global.
+ */
+ performPointerMoveStep(state, inputSource, targets, win) {
+ if (targets.length !== 1) {
+ throw new Error(
+ "PointerMoveAction.performPointerMoveStep requires a single target"
+ );
+ }
+
+ const target = targets[0];
+ lazy.logger.trace(
+ `PointerMoveAction.performPointerMoveStep ${JSON.stringify(target)}`
+ );
+ if (target[0] == inputSource.x && target[1] == inputSource.y) {
+ return;
+ }
+
+ inputSource.pointer.pointerMove(
+ state,
+ inputSource,
+ this,
+ target[0],
+ target[1],
+ win
+ );
+
+ inputSource.x = target[0];
+ inputSource.y = target[1];
+ }
+
+ static fromJSON(id, actionItem) {
+ const { duration, origin, x, y } = actionItem;
+
+ if (duration !== undefined) {
+ lazy.assert.positiveInteger(
+ duration,
+ lazy.pprint`Expected "duration" to be a positive integer, got ${duration}`
+ );
+ }
+
+ const originObject = Origin.fromJSON(origin);
+ lazy.assert.integer(
+ x,
+ lazy.pprint`Expected "x" to be an integer, got ${x}`
+ );
+ lazy.assert.integer(
+ y,
+ lazy.pprint`Expected "y" to be an integer, got ${y}`
+ );
+ const props = PointerAction.validateCommon(actionItem);
+
+ props.duration = duration;
+ props.origin = originObject;
+ props.x = x;
+ props.y = y;
+
+ return new this(id, props);
+ }
+}
+
+/**
+ * Action associated with a wheel input device
+ *
+ */
+class WheelAction extends Action {
+ static type = "wheel";
+}
+
+/**
+ * Action associated with scrolling a scroll wheel
+ *
+ * @param {number} duration - Duration of scroll in ms.
+ * @param {Origin} origin - Origin of target coordinates.
+ * @param {number} x - X value of scroll coordinates.
+ * @param {number} y - Y value of scroll coordinates.
+ * @param {number} deltaX - Number of CSS pixels to scroll in X direction.
+ * @param {number} deltaY - Number of CSS pixels to scroll in Y direction
+ */
+class WheelScrollAction extends WheelAction {
+ static subtype = "scroll";
+ affectsWallClockTime = true;
+
+ constructor(id, { duration, origin, x, y, deltaX, deltaY }) {
+ super(id);
+
+ this.duration = duration;
+ this.origin = origin;
+ this.x = x;
+ this.y = y;
+ this.deltaX = deltaX;
+ this.deltaY = deltaY;
+ }
+
+ static fromJSON(id, actionItem) {
+ const { duration, origin, x, y, deltaX, deltaY } = actionItem;
+
+ if (duration !== undefined) {
+ lazy.assert.positiveInteger(
+ duration,
+ lazy.pprint`Expected "duration" to be a positive integer, got ${duration}`
+ );
+ }
+
+ const originObject = Origin.fromJSON(origin);
+ if (originObject instanceof PointerOrigin) {
+ throw new lazy.error.InvalidArgumentError(
+ `"pointer" origin not supported for "wheel" input source.`
+ );
+ }
+
+ lazy.assert.integer(
+ x,
+ lazy.pprint`Expected "x" to be an Integer, got ${x}`
+ );
+ lazy.assert.integer(
+ y,
+ lazy.pprint`Expected "y" to be an Integer, got ${y}`
+ );
+ lazy.assert.integer(
+ deltaX,
+ lazy.pprint`Expected "deltaX" to be an Integer, got ${deltaX}`
+ );
+ lazy.assert.integer(
+ deltaY,
+ lazy.pprint`Expected "deltaY" to be an Integer, got ${deltaY}`
+ );
+
+ return new this(id, {
+ duration,
+ origin: originObject,
+ x,
+ y,
+ deltaX,
+ deltaY,
+ });
+ }
+
+ async dispatch(state, inputSource, tickDuration, win) {
+ lazy.logger.trace(
+ `Dispatch ${this.constructor.name} with id: ${this.id} deltaX: ${this.deltaX} deltaY: ${this.deltaY}`
+ );
+
+ const scrollCoordinates = this.origin.getTargetCoordinates(
+ inputSource,
+ [this.x, this.y],
+ win
+ );
+ assertInViewPort(scrollCoordinates, win);
+
+ const startX = 0;
+ const startY = 0;
+ // This is an action-local state that holds the amount of scroll completed
+ const deltaPosition = [startX, startY];
+
+ await moveOverTime(
+ [[startX, startY]],
+ [[this.deltaX, this.deltaY]],
+ this.duration ?? tickDuration,
+ deltaTarget =>
+ this.performOneWheelScroll(
+ scrollCoordinates,
+ deltaPosition,
+ deltaTarget,
+ win
+ )
+ );
+ }
+
+ /**
+ * Perform one part of a wheel scroll corresponding to a specific emitted event.
+ *
+ * @param {Array<number>} scrollCoordinates - [x, y] viewport coordinates of the scroll.
+ * @param {Array<number>} deltaPosition - [deltaX, deltaY] coordinates of the scroll before this event.
+ * @param {Array<Array<number>>} deltaTargets - Array of [deltaX, deltaY] coordinates to scroll to.
+ * @param {WindowProxy} win - Current window global.
+ */
+ performOneWheelScroll(scrollCoordinates, deltaPosition, deltaTargets, win) {
+ if (deltaTargets.length !== 1) {
+ throw new Error("Can only scroll one wheel at a time");
+ }
+ if (deltaPosition[0] == this.deltaX && deltaPosition[1] == this.deltaY) {
+ return;
+ }
+
+ const deltaTarget = deltaTargets[0];
+ const deltaX = deltaTarget[0] - deltaPosition[0];
+ const deltaY = deltaTarget[1] - deltaPosition[1];
+ const eventData = new WheelEventData({
+ deltaX,
+ deltaY,
+ deltaZ: 0,
+ });
+
+ lazy.event.synthesizeWheelAtPoint(
+ scrollCoordinates[0],
+ scrollCoordinates[1],
+ eventData,
+ win
+ );
+
+ // Update the current scroll position for the caller
+ deltaPosition[0] = deltaTarget[0];
+ deltaPosition[1] = deltaTarget[1];
+ }
+}
+
+/**
+ * Group of actions representing behaviour of all touch pointers during a single tick.
+ *
+ * For touch pointers, we need to call into the platform once with all
+ * the actions so that they are regarded as simultaneous. This means
+ * we don't use the `dispatch()` method on the underlying actions, but
+ * instead use one on this group object.
+ */
+class TouchActionGroup {
+ static type = null;
+
+ constructor() {
+ this.type = this.constructor.type;
+ this.actions = new Map();
+ }
+
+ static forType(type) {
+ const cls = touchActionGroupTypes.get(type);
+
+ return new cls();
+ }
+
+ /**
+ * Add action corresponding to a specific pointer to the group.
+ *
+ * @param {InputSource} inputSource - State of the current input device.
+ * @param {Action} action - Action to add to the group
+ */
+ addPointer(inputSource, action) {
+ if (action.subtype !== this.type) {
+ throw new Error(
+ `Added action of unexpected type, got ${action.subtype}, expected ${this.type}`
+ );
+ }
+
+ this.actions.set(action.id, [inputSource, action]);
+ }
+
+ /**
+ * Dispatch the action group to the relevant window.
+ *
+ * This is overridden by subclasses to implement the type-specific
+ * dispatch of the action.
+ *
+ * @param {State} state - Actions state.
+ * @param {null} inputSource
+ * This is always null; the argument only exists for compatibility
+ * with {@link Action.dispatch}.
+ * @param {number} tickDuration - Length of the current tick, in ms.
+ * @param {WindowProxy} win - Current window global.
+ * @returns {Promise} - Promise that is resolved once the action is complete.
+ */
+ dispatch(state, inputSource, tickDuration, win) {
+ throw new Error(
+ "TouchActionGroup subclass missing dispatch implementation"
+ );
+ }
+}
+
+/**
+ * Group of actions representing behaviour of all touch pointers
+ * depressed during a single tick.
+ */
+class PointerDownTouchActionGroup extends TouchActionGroup {
+ static type = "pointerDown";
+
+ dispatch(state, inputSource, tickDuration, win) {
+ lazy.logger.trace(
+ `Dispatch ${this.constructor.name} with ${Array.from(
+ this.actions.values()
+ ).map(x => x[1].id)}`
+ );
+
+ return new Promise(resolve => {
+ if (inputSource !== null) {
+ throw new Error(
+ "Expected null inputSource for PointerDownTouchActionGroup.dispatch"
+ );
+ }
+
+ // Only include pointers that are not already depressed
+ const actions = Array.from(this.actions.values()).filter(
+ ([actionInputSource, action]) =>
+ !actionInputSource.isPressed(action.button)
+ );
+
+ if (actions.length) {
+ const eventData = new MultiTouchEventData("touchstart");
+
+ for (const [actionInputSource, action] of actions) {
+ // Skip if already pressed
+ eventData.addPointerEventData(actionInputSource, action);
+ actionInputSource.press(action.button);
+ // Append a copy of |action| with pointerUp subtype
+ state.inputsToCancel.push(new PointerUpAction(action.id, action));
+ eventData.update(state, actionInputSource);
+ }
+
+ // Touch start events must include all depressed touch pointers
+ for (const [id, pointerInputSource] of state.inputSourcesByType(
+ "pointer"
+ )) {
+ if (
+ pointerInputSource.pointer.type === "touch" &&
+ !this.actions.has(id) &&
+ pointerInputSource.isPressed(0)
+ ) {
+ eventData.addPointerEventData(pointerInputSource, {});
+ eventData.update(state, pointerInputSource);
+ }
+ }
+ lazy.event.synthesizeMultiTouch(eventData, win);
+ }
+
+ resolve();
+ });
+ }
+}
+
+/**
+ * Group of actions representing behaviour of all touch pointers
+ * released during a single tick.
+ */
+class PointerUpTouchActionGroup extends TouchActionGroup {
+ static type = "pointerUp";
+
+ dispatch(state, inputSource, tickDuration, win) {
+ lazy.logger.trace(
+ `Dispatch ${this.constructor.name} with ${Array.from(
+ this.actions.values()
+ ).map(x => x[1].id)}`
+ );
+
+ return new Promise(resolve => {
+ if (inputSource !== null) {
+ throw new Error(
+ "Expected null inputSource for PointerUpTouchActionGroup.dispatch"
+ );
+ }
+
+ // Only include pointers that are not already depressed
+ const actions = Array.from(this.actions.values()).filter(
+ ([actionInputSource, action]) =>
+ actionInputSource.isPressed(action.button)
+ );
+
+ if (actions.length) {
+ const eventData = new MultiTouchEventData("touchend");
+ for (const [actionInputSource, action] of actions) {
+ eventData.addPointerEventData(actionInputSource, action);
+ actionInputSource.release(action.button);
+ eventData.update(state, actionInputSource);
+ }
+ lazy.event.synthesizeMultiTouch(eventData, win);
+ }
+
+ resolve();
+ });
+ }
+}
+
+/**
+ * Group of actions representing behaviour of all touch pointers
+ * moved during a single tick.
+ */
+class PointerMoveTouchActionGroup extends TouchActionGroup {
+ static type = "pointerMove";
+
+ dispatch(state, inputSource, tickDuration, win) {
+ lazy.logger.trace(
+ `Dispatch ${this.constructor.name} with ${Array.from(this.actions).map(
+ x => x[1].id
+ )}`
+ );
+ if (inputSource !== null) {
+ throw new Error(
+ "Expected null inputSource for PointerMoveTouchActionGroup.dispatch"
+ );
+ }
+
+ let startCoords = [];
+ let targetCoords = [];
+
+ for (const [actionInputSource, action] of this.actions.values()) {
+ const target = action.origin.getTargetCoordinates(
+ actionInputSource,
+ [action.x, action.y],
+ win
+ );
+
+ assertInViewPort(target, win);
+ startCoords.push([actionInputSource.x, actionInputSource.y]);
+ targetCoords.push(target);
+ }
+
+ // Touch move events must include all depressed touch pointers, even if they are static
+ // This can end up generating pointermove events even for static pointers, but Gecko
+ // seems to generate a lot of pointermove events anyway, so this seems like the lesser
+ // problem.
+ // See https://bugzilla.mozilla.org/show_bug.cgi?id=1779206
+ const staticTouchPointers = [];
+ for (const [id, pointerInputSource] of state.inputSourcesByType(
+ "pointer"
+ )) {
+ if (
+ pointerInputSource.pointer.type === "touch" &&
+ !this.actions.has(id) &&
+ pointerInputSource.isPressed(0)
+ ) {
+ staticTouchPointers.push(pointerInputSource);
+ }
+ }
+
+ return moveOverTime(
+ startCoords,
+ targetCoords,
+ this.duration ?? tickDuration,
+ currentTargetCoords =>
+ this.performPointerMoveStep(
+ state,
+ staticTouchPointers,
+ currentTargetCoords,
+ win
+ )
+ );
+ }
+
+ /**
+ * Perform one part of a pointer move corresponding to a specific emitted event.
+ *
+ * @param {State} state - Actions state.
+ * @param {Array.<PointerInputSource>} staticTouchPointers
+ * Array of PointerInputSource objects for pointers that aren't involved in
+ * the touch move.
+ * @param {Array.<Array>} targetCoords
+ * Array of [x, y] arrays specifying the viewport coordinates to move to.
+ * @param {WindowProxy} win - Current window global.
+ */
+ performPointerMoveStep(state, staticTouchPointers, targetCoords, win) {
+ if (targetCoords.length !== this.actions.size) {
+ throw new Error("Expected one target per pointer");
+ }
+
+ const perPointerData = Array.from(this.actions.values()).map(
+ ([inputSource, action], i) => {
+ const target = targetCoords[i];
+ return [inputSource, action, target];
+ }
+ );
+ const reachedTarget = perPointerData.every(
+ ([inputSource, action, target]) =>
+ target[0] === inputSource.x && target[1] === inputSource.y
+ );
+
+ if (reachedTarget) {
+ return;
+ }
+
+ const eventData = new MultiTouchEventData("touchmove");
+ for (const [inputSource, action, target] of perPointerData) {
+ inputSource.x = target[0];
+ inputSource.y = target[1];
+ eventData.addPointerEventData(inputSource, action);
+ eventData.update(state, inputSource);
+ }
+
+ for (const inputSource of staticTouchPointers) {
+ eventData.addPointerEventData(inputSource, {});
+ eventData.update(state, inputSource);
+ }
+
+ lazy.event.synthesizeMultiTouch(eventData, win);
+ }
+}
+
+const touchActionGroupTypes = new Map();
+for (const cls of [
+ PointerDownTouchActionGroup,
+ PointerUpTouchActionGroup,
+ PointerMoveTouchActionGroup,
+]) {
+ touchActionGroupTypes.set(cls.type, cls);
+}
+
+/**
+ * Split a transition from startCoord to targetCoord linearly over duration.
+ *
+ * startCoords and targetCoords are lists of [x,y] positions in some space
+ * (e.g. screen position or scroll delta). This function will linearly
+ * interpolate intermediate positions, sending out roughly one event
+ * per frame to simulate moving between startCoord and targetCoord in
+ * a time of tickDuration milliseconds. The callback function is
+ * responsible for actually emitting the event, given the current
+ * position in the coordinate space.
+ *
+ * @param {Array.<Array>} startCoords
+ * Array of initial [x, y] coordinates for each input source involved
+ * in the move.
+ * @param {Array.<Array>} targetCoords
+ * Array of target [x, y] coordinates for each input source involved
+ * in the move.
+ * @param {number} duration - Time in ms the move will take.
+ * @param {Function} callback
+ * Function that actually performs the move. This takes a single parameter
+ * which is an array of [x, y] coordinates corresponding to the move
+ * targets.
+ */
+async function moveOverTime(startCoords, targetCoords, duration, callback) {
+ lazy.logger.trace(
+ `moveOverTime start: ${startCoords} target: ${targetCoords} duration: ${duration}`
+ );
+
+ if (startCoords.length !== targetCoords.length) {
+ throw new Error(
+ "Expected equal number of start coordinates and target coordinates"
+ );
+ }
+
+ if (
+ !startCoords.every(item => item.length == 2) ||
+ !targetCoords.every(item => item.length == 2)
+ ) {
+ throw new Error(
+ "Expected start coordinates target coordinates to be Array of multiple [x,y] coordinates."
+ );
+ }
+
+ if (duration === 0) {
+ // transition to destination in one step
+ callback(targetCoords);
+ return;
+ }
+
+ const timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ // interval between transitions in ms, based on common vsync
+ const fps60 = 17;
+
+ const distances = targetCoords.map((targetCoord, i) => {
+ const startCoord = startCoords[i];
+ return [targetCoord[0] - startCoord[0], targetCoord[1] - startCoord[1]];
+ });
+ const ONE_SHOT = Ci.nsITimer.TYPE_ONE_SHOT;
+ const startTime = Date.now();
+ const transitions = (async () => {
+ // wait |fps60| ms before performing first incremental transition
+ await new Promise(resolveTimer =>
+ timer.initWithCallback(resolveTimer, fps60, ONE_SHOT)
+ );
+
+ let durationRatio = Math.floor(Date.now() - startTime) / duration;
+ const epsilon = fps60 / duration / 10;
+ while (1 - durationRatio > epsilon) {
+ const intermediateTargets = startCoords.map((startCoord, i) => {
+ let distance = distances[i];
+ return [
+ Math.floor(durationRatio * distance[0] + startCoord[0]),
+ Math.floor(durationRatio * distance[1] + startCoord[1]),
+ ];
+ });
+ callback(intermediateTargets);
+ // wait |fps60| ms before performing next transition
+ await new Promise(resolveTimer =>
+ timer.initWithCallback(resolveTimer, fps60, ONE_SHOT)
+ );
+
+ durationRatio = Math.floor(Date.now() - startTime) / duration;
+ }
+ })();
+
+ await transitions;
+
+ // perform last transitionafter all incremental moves are resolved and
+ // durationRatio is close enough to 1
+ callback(targetCoords);
+}
+
+const actionTypes = new Map();
+for (const cls of [
+ KeyDownAction,
+ KeyUpAction,
+ PauseAction,
+ PointerDownAction,
+ PointerUpAction,
+ PointerMoveAction,
+ WheelScrollAction,
+]) {
+ if (!actionTypes.has(cls.type)) {
+ actionTypes.set(cls.type, new Map());
+ }
+ actionTypes.get(cls.type).set(cls.subtype, cls);
+}
+
+/**
+ * Implementation of the behaviour of a specific type of pointer
+ */
+class Pointer {
+ /** Type of pointer */
+ static type = null;
+
+ constructor(id) {
+ this.id = id;
+ this.type = this.constructor.type;
+ }
+
+ /**
+ * Implementation of depressing the pointer.
+ *
+ * @param {State} state - Actions state.
+ * @param {InputSource} inputSource - State of the current input device.
+ * @param {Action} action - The Action object invoking the pointer
+ * @param {WindowProxy} win - Current window global.
+ */
+ pointerDown(state, inputSource, action, win) {
+ throw new Error(`Unimplemented pointerDown for pointerType ${this.type}`);
+ }
+
+ /**
+ * Implementation of releasing the pointer.
+ *
+ * @param {State} state - Actions state.
+ * @param {InputSource} inputSource - State of the current input device.
+ * @param {Action} action - The Action object invoking the pointer
+ * @param {WindowProxy} win - Current window global.
+ */
+ pointerUp(state, inputSource, action, win) {
+ throw new Error(`Unimplemented pointerUp for pointerType ${this.type}`);
+ }
+
+ /**
+ * Implementation of moving the pointer.
+ *
+ * @param {State} state - Actions state.
+ * @param {InputSource} inputSource - State of the current input device.
+ * @param {number} targetX - Target X coordinate of the pointer move
+ * @param {number} targetY - Target Y coordinate of the pointer move
+ * @param {WindowProxy} win - Current window global.
+ */
+ pointerMove(state, inputSource, targetX, targetY, win) {
+ throw new Error(`Unimplemented pointerMove for pointerType ${this.type}`);
+ }
+
+ /**
+ * @param {number} pointerId - Numeric pointer id.
+ * @param {string} pointerType - Pointer type.
+ * @returns {Pointer} - The pointer class for {@link pointerType}
+ *
+ * @throws {InvalidArgumentError} - If {@link pointerType} is not a valid pointer type.
+ */
+ static fromJSON(pointerId, pointerType) {
+ const cls = pointerTypes.get(pointerType);
+
+ if (cls === undefined) {
+ throw new lazy.error.InvalidArgumentError(
+ 'Expected "pointerType" type to be one of ' +
+ lazy.pprint`${pointerTypes}, got ${pointerType}`
+ );
+ }
+
+ return new cls(pointerId);
+ }
+}
+
+/**
+ * Implementation of mouse pointer behaviour
+ */
+class MousePointer extends Pointer {
+ static type = "mouse";
+
+ pointerDown(state, inputSource, action, win) {
+ const mouseEvent = new MouseEventData("mousedown", {
+ button: action.button,
+ });
+ mouseEvent.update(state, inputSource);
+
+ if (mouseEvent.ctrlKey) {
+ if (lazy.AppInfo.isMac) {
+ mouseEvent.button = 2;
+ state.clickTracker.reset();
+ }
+ } else {
+ mouseEvent.clickCount = state.clickTracker.count + 1;
+ }
+
+ lazy.event.synthesizeMouseAtPoint(
+ inputSource.x,
+ inputSource.y,
+ mouseEvent,
+ win
+ );
+
+ if (
+ lazy.event.MouseButton.isSecondary(mouseEvent.button) ||
+ (mouseEvent.ctrlKey && lazy.AppInfo.isMac)
+ ) {
+ const contextMenuEvent = { ...mouseEvent, type: "contextmenu" };
+ lazy.event.synthesizeMouseAtPoint(
+ inputSource.x,
+ inputSource.y,
+ contextMenuEvent,
+ win
+ );
+ }
+ }
+
+ pointerUp(state, inputSource, action, win) {
+ const mouseEvent = new MouseEventData("mouseup", {
+ button: action.button,
+ });
+ mouseEvent.update(state, inputSource);
+
+ state.clickTracker.setClick(action.button);
+ mouseEvent.clickCount = state.clickTracker.count;
+
+ lazy.event.synthesizeMouseAtPoint(
+ inputSource.x,
+ inputSource.y,
+ mouseEvent,
+ win
+ );
+ }
+
+ pointerMove(state, inputSource, action, targetX, targetY, win) {
+ const mouseEvent = new MouseEventData("mousemove");
+ mouseEvent.update(state, inputSource);
+
+ lazy.event.synthesizeMouseAtPoint(targetX, targetY, mouseEvent, win);
+
+ state.clickTracker.reset();
+ }
+}
+
+/*
+ * The implementation here is empty because touch actions have to go via the
+ * TouchActionGroup. So if we end up calling these methods that's a bug in
+ * the code.
+ */
+class TouchPointer extends Pointer {
+ static type = "touch";
+}
+
+/*
+ * Placeholder for future pen type pointer support.
+ */
+class PenPointer extends Pointer {
+ static type = "pen";
+}
+
+const pointerTypes = new Map();
+for (const cls of [MousePointer, TouchPointer, PenPointer]) {
+ pointerTypes.set(cls.type, cls);
+}
+
+/**
+ * Represents a series of ticks, specifying which actions to perform at
+ * each tick.
+ */
+action.Chain = class extends Array {
+ toString() {
+ return `[chain ${super.toString()}]`;
+ }
+
+ /**
+ * Dispatch the action chain to the relevant window.
+ *
+ * @param {State} state - Actions state.
+ * @param {WindowProxy} win - Current window global.
+ * @returns {Promise} - Promise that is resolved once the action
+ * chain is complete.
+ */
+ dispatch(state, win) {
+ let i = 1;
+
+ const chainEvents = (async () => {
+ for (const tickActions of this) {
+ lazy.logger.trace(`Dispatching tick ${i++}/${this.length}`);
+ await tickActions.dispatch(state, win);
+ }
+ })();
+
+ // Reset the current click tracker counter. We shouldn't be able to simulate
+ // a double click with multiple action chains.
+ state.clickTracker.reset();
+
+ return chainEvents;
+ }
+
+ /**
+ * @param {State} state - Actions state.
+ * @param {Array.<object>} actions - Array of objects that each
+ * represent an action sequence.
+ * @returns {action.Chain} - Object that allows dispatching a chain
+ * of actions.
+ * @throws {InvalidArgumentError} - If actions doesn't correspond to
+ * a valid action chain.
+ */
+ static fromJSON(state, actions) {
+ lazy.assert.array(
+ actions,
+ lazy.pprint`Expected "actions" to be an array, got ${actions}`
+ );
+
+ const actionsByTick = new this();
+ for (const actionSequence of actions) {
+ lazy.assert.object(
+ actionSequence,
+ 'Expected "actions" item to be an object, ' +
+ lazy.pprint`got ${actionSequence}`
+ );
+
+ const inputSourceActions = Sequence.fromJSON(state, actionSequence);
+
+ for (let i = 0; i < inputSourceActions.length; i++) {
+ // new tick
+ if (actionsByTick.length < i + 1) {
+ actionsByTick.push(new TickActions());
+ }
+ actionsByTick[i].push(inputSourceActions[i]);
+ }
+ }
+
+ return actionsByTick;
+ }
+};
+
+/**
+ * Represents the action for each input device to perform in a single tick.
+ */
+class TickActions extends Array {
+ /**
+ * Tick duration in milliseconds.
+ *
+ * @returns {number} - Longest action duration in |tickActions| if any, or 0.
+ */
+ getDuration() {
+ let max = 0;
+
+ for (const action of this) {
+ if (action.affectsWallClockTime && action.duration) {
+ max = Math.max(action.duration, max);
+ }
+ }
+
+ return max;
+ }
+
+ /**
+ * Dispatch sequence of actions for this tick.
+ *
+ * This creates a Promise for one tick that resolves once the Promise
+ * for each tick-action is resolved, which takes at least |tickDuration|
+ * milliseconds. The resolved set of events for each tick is followed by
+ * firing of pending DOM events.
+ *
+ * Note that the tick-actions are dispatched in order, but they may have
+ * different durations and therefore may not end in the same order.
+ *
+ * @param {State} state - Actions state.
+ * @param {WindowProxy} win - Current window global.
+ *
+ * @returns {Promise} - Promise that resolves when tick is complete.
+ */
+ dispatch(state, win) {
+ const tickDuration = this.getDuration();
+ const tickActions = this.groupTickActions(state);
+ const pendingEvents = tickActions.map(([inputSource, action]) =>
+ action.dispatch(state, inputSource, tickDuration, win)
+ );
+
+ return Promise.all(pendingEvents);
+ }
+
+ /**
+ * Group together actions from input sources that have to be
+ * dispatched together.
+ *
+ * The actual transformation here is to group together touch pointer
+ * actions into {@link TouchActionGroup} instances.
+ *
+ * @param {State} state - Actions state.
+ * @returns {Array.<Array.<InputSource?,Action|TouchActionGroup>>}
+ * Array of pairs. For ungrouped actions each element is
+ * [InputSource, Action] For touch actions there are multiple
+ * pointers handled at once, so the first item of the array is
+ * null, meaning the group has to perform its own handling of the
+ * relevant state, and the second element is a TouuchActionGroup.
+ */
+ groupTickActions(state) {
+ const touchActions = new Map();
+ const actions = [];
+
+ for (const action of this) {
+ const inputSource = state.getInputSource(action.id);
+ if (action.type == "pointer" && inputSource.pointer.type === "touch") {
+ lazy.logger.debug(
+ `Grouping action ${action.type} ${action.id} ${action.subtype}`
+ );
+ let group = touchActions.get(action.subtype);
+ if (group === undefined) {
+ group = TouchActionGroup.forType(action.subtype);
+ touchActions.set(action.subtype, group);
+ actions.push([null, group]);
+ }
+ group.addPointer(inputSource, action);
+ } else {
+ actions.push([inputSource, action]);
+ }
+ }
+
+ return actions;
+ }
+}
+
+/**
+ * Represents one input source action sequence; this is essentially an
+ * |Array.<Action>|.
+ *
+ * This is a temporary object only used when constructing an {@link
+ * action.Chain}.
+ */
+class Sequence extends Array {
+ toString() {
+ return `[sequence ${super.toString()}]`;
+ }
+
+ /**
+ * @param {State} state - Actions state.
+ * @param {object} actionSequence
+ * Protocol representation of the actions for a specific input source.
+ * @returns {Array.<Array>} - Array of [InputSource?,Action|TouchActionGroup]
+ */
+ static fromJSON(state, actionSequence) {
+ // used here to validate 'type' in addition to InputSource type below
+ const { id, type, actions } = actionSequence;
+
+ // type and id get validated in InputSource.fromJSON
+ lazy.assert.array(
+ actions,
+ 'Expected "actionSequence.actions" to be an array, ' +
+ lazy.pprint`got ${actionSequence.actions}`
+ );
+
+ // This sets the input state in the global state map, if it's new
+ InputSource.fromJSON(state, actionSequence);
+
+ const sequence = new this();
+ for (const actionItem of actions) {
+ sequence.push(Action.fromJSON(type, id, actionItem));
+ }
+
+ return sequence;
+ }
+}
+
+/**
+ * Representation of an input event
+ */
+class InputEventData {
+ constructor() {
+ this.altKey = false;
+ this.shiftKey = false;
+ this.ctrlKey = false;
+ this.metaKey = false;
+ }
+
+ /**
+ * Update the input data based on global and input state
+ *
+ * @param {State} state - Actions state.
+ * @param {InputSource} inputSource - State of the current input device.
+ */
+ update(state, inputSource) {}
+
+ toString() {
+ return `${this.constructor.name} ${JSON.stringify(this)}`;
+ }
+}
+
+/**
+ * Representation of a key input event
+ *
+ * @param {string} rawKey - Key value.
+ */
+class KeyEventData extends InputEventData {
+ constructor(rawKey) {
+ super();
+ const { key, code, location, printable } = lazy.keyData.getData(rawKey);
+
+ this.key = key;
+ this.code = code;
+ this.location = location;
+ this.printable = printable;
+ this.repeat = false;
+ // keyCode will be computed by event.sendKeyDown
+ }
+
+ update(state, inputSource) {
+ this.altKey = inputSource.alt;
+ this.shiftKey = inputSource.shift;
+ this.ctrlKey = inputSource.ctrl;
+ this.metaKey = inputSource.meta;
+ }
+}
+
+/**
+ * Representation of a pointer input event
+ *
+ * @param {string} type - Event type.
+ */
+class PointerEventData extends InputEventData {
+ constructor(type) {
+ super();
+
+ this.type = type;
+ this.buttons = 0;
+ }
+
+ update(state, inputSource) {
+ // set modifier properties based on whether any corresponding keys are
+ // pressed on any key input source
+ for (const [, otherInputSource] of state.inputSourcesByType("key")) {
+ this.altKey = otherInputSource.alt || this.altKey;
+ this.ctrlKey = otherInputSource.ctrl || this.ctrlKey;
+ this.metaKey = otherInputSource.meta || this.metaKey;
+ this.shiftKey = otherInputSource.shift || this.shiftKey;
+ }
+ const allButtons = Array.from(inputSource.pressed);
+ this.buttons = allButtons.reduce(
+ (a, i) => a + PointerEventData.getButtonFlag(i),
+ 0
+ );
+ }
+
+ /**
+ * Return a flag for buttons which indicates a button is pressed.
+ *
+ * @param {integer} button - Mouse button number.
+ */
+ static getButtonFlag(button) {
+ switch (button) {
+ case 1:
+ return 4;
+ case 2:
+ return 2;
+ default:
+ return Math.pow(2, button);
+ }
+ }
+}
+
+/**
+ * Representation of a mouse input event
+ *
+ * @param {string} type - Event type.
+ * @param {object=} options
+ * @param {number} options.button - Mouse button number.
+ */
+class MouseEventData extends PointerEventData {
+ constructor(type, options = {}) {
+ super(type);
+
+ const { button = 0 } = options;
+
+ this.button = button;
+ this.buttons = 0;
+
+ // Some WPTs try to synthesize DnD only with mouse events. However,
+ // Gecko waits DnD events directly and non-WPT-tests use Gecko specific
+ // test API to synthesize DnD. Therefore, we want new path only for
+ // synthesized events coming from the webdriver.
+ this.allowToHandleDragDrop = true;
+ }
+
+ update(state, inputSource) {
+ super.update(state, inputSource);
+
+ this.id = inputSource.pointer.id;
+ }
+}
+
+/**
+ * Representation of a wheel scroll event
+ *
+ * @param {object} options
+ * @param {number} options.deltaX - Scroll delta X.
+ * @param {number} options.deltaY - Scroll delta Y.
+ * @param {number} options.deltaY - Scroll delta Z (current always 0).
+ * @param {number=} options.deltaMode - Scroll delta mode (current always 0).
+ */
+class WheelEventData extends InputEventData {
+ constructor(options) {
+ super();
+
+ const { deltaX, deltaY, deltaZ, deltaMode = 0 } = options;
+
+ this.deltaX = deltaX;
+ this.deltaY = deltaY;
+ this.deltaZ = deltaZ;
+ this.deltaMode = deltaMode;
+ }
+}
+
+/**
+ * Representation of a multitouch event
+ *
+ * @param {string} type - Event type.
+ */
+class MultiTouchEventData extends PointerEventData {
+ #setGlobalState;
+
+ constructor(type) {
+ super(type);
+
+ this.id = [];
+ this.x = [];
+ this.y = [];
+ this.rx = [];
+ this.ry = [];
+ this.angle = [];
+ this.force = [];
+ this.tiltx = [];
+ this.tilty = [];
+ this.twist = [];
+ this.#setGlobalState = false;
+ }
+
+ /**
+ * Add the data from one pointer to the event.
+ *
+ * @param {InputSource} inputSource - State of the pointer.
+ * @param {PointerAction} action - Action for the pointer.
+ */
+ addPointerEventData(inputSource, action) {
+ this.x.push(inputSource.x);
+ this.y.push(inputSource.y);
+ this.id.push(inputSource.pointer.id);
+ this.rx.push(action.width || 1);
+ this.ry.push(action.height || 1);
+ this.angle.push(0);
+ this.force.push(action.pressure || (this.type === "touchend" ? 0 : 1));
+ this.tiltx.push(action.tiltX || 0);
+ this.tilty.push(action.tiltY || 0);
+ this.twist.push(action.twist || 0);
+ }
+
+ update(state, inputSource) {
+ // We call update once per input source, but only want to update global state once.
+ // Instead of introducing a new lifecycle method, or changing the API to allow multiple
+ // input sources in a single call, use a small bit of state to avoid repeatedly setting
+ // global state.
+ if (!this.#setGlobalState) {
+ // set modifier properties based on whether any corresponding keys are
+ // pressed on any key input source
+ for (const [, otherInputSource] of state.inputSourcesByType("key")) {
+ this.altKey = otherInputSource.alt || this.altKey;
+ this.ctrlKey = otherInputSource.ctrl || this.ctrlKey;
+ this.metaKey = otherInputSource.meta || this.metaKey;
+ this.shiftKey = otherInputSource.shift || this.shiftKey;
+ }
+ this.#setGlobalState = true;
+ }
+
+ // Note that we currently emit Touch events that don't have this property
+ // but pointer events should have a `buttons` property, so we'll compute it
+ // anyway.
+ const allButtons = Array.from(inputSource.pressed);
+ this.buttons =
+ this.buttons |
+ allButtons.reduce((a, i) => a + PointerEventData.getButtonFlag(i), 0);
+ }
+}
+
+// helpers
+
+/**
+ * Assert that target is in the viewport of win.
+ *
+ * @param {Array.<number>} target - [x, y] coordinates of target
+ * relative to viewport.
+ * @param {WindowProxy} win - target window.
+ * @throws {MoveTargetOutOfBoundsError} - If target is outside the
+ * viewport.
+ */
+function assertInViewPort(target, win) {
+ const [x, y] = target;
+
+ lazy.assert.number(
+ x,
+ lazy.pprint`Expected "x" to be finite number, got ${x}`
+ );
+ lazy.assert.number(
+ y,
+ lazy.pprint`Expected "y" to be finite number, got ${y}`
+ );
+
+ // Viewport includes scrollbars if rendered.
+ if (x < 0 || y < 0 || x > win.innerWidth || y > win.innerHeight) {
+ throw new lazy.error.MoveTargetOutOfBoundsError(
+ `Move target (${x}, ${y}) is out of bounds of viewport dimensions ` +
+ `(${win.innerWidth}, ${win.innerHeight})`
+ );
+ }
+}
diff --git a/remote/shared/webdriver/Assert.sys.mjs b/remote/shared/webdriver/Assert.sys.mjs
new file mode 100644
index 0000000000..6c254173aa
--- /dev/null
+++ b/remote/shared/webdriver/Assert.sys.mjs
@@ -0,0 +1,489 @@
+/* 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, {
+ AppInfo: "chrome://remote/content/shared/AppInfo.sys.mjs",
+ error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
+ pprint: "chrome://remote/content/shared/Format.sys.mjs",
+});
+
+/**
+ * Shorthands for common assertions made in WebDriver.
+ *
+ * @namespace
+ */
+export const assert = {};
+
+/**
+ * Asserts that WebDriver has an active session.
+ *
+ * @param {WebDriverSession} session
+ * WebDriver session instance.
+ * @param {string=} msg
+ * Custom error message.
+ *
+ * @throws {InvalidSessionIDError}
+ * If session does not exist, or has an invalid id.
+ */
+assert.session = function (session, msg = "") {
+ msg = msg || "WebDriver session does not exist, or is not active";
+ assert.that(
+ session => session && typeof session.id == "string",
+ msg,
+ lazy.error.InvalidSessionIDError
+ )(session);
+};
+
+/**
+ * Asserts that the current browser is Firefox Desktop.
+ *
+ * @param {string=} msg
+ * Custom error message.
+ *
+ * @throws {UnsupportedOperationError}
+ * If current browser is not Firefox.
+ */
+assert.firefox = function (msg = "") {
+ msg = msg || "Only supported in Firefox";
+ assert.that(
+ isFirefox => isFirefox,
+ msg,
+ lazy.error.UnsupportedOperationError
+ )(lazy.AppInfo.isFirefox);
+};
+
+/**
+ * Asserts that the current application is Firefox Desktop or Thunderbird.
+ *
+ * @param {string=} msg
+ * Custom error message.
+ *
+ * @throws {UnsupportedOperationError}
+ * If current application is not running on desktop.
+ */
+assert.desktop = function (msg = "") {
+ msg = msg || "Only supported in desktop applications";
+ assert.that(
+ isDesktop => isDesktop,
+ msg,
+ lazy.error.UnsupportedOperationError
+ )(!lazy.AppInfo.isAndroid);
+};
+
+/**
+ * Asserts that the current application runs on Android.
+ *
+ * @param {string=} msg
+ * Custom error message.
+ *
+ * @throws {UnsupportedOperationError}
+ * If current application is not running on Android.
+ */
+assert.mobile = function (msg = "") {
+ msg = msg || "Only supported on Android";
+ assert.that(
+ isAndroid => isAndroid,
+ msg,
+ lazy.error.UnsupportedOperationError
+ )(lazy.AppInfo.isAndroid);
+};
+
+/**
+ * Asserts that the current <var>context</var> is content.
+ *
+ * @param {string} context
+ * Context to test.
+ * @param {string=} msg
+ * Custom error message.
+ *
+ * @returns {string}
+ * <var>context</var> is returned unaltered.
+ *
+ * @throws {UnsupportedOperationError}
+ * If <var>context</var> is not content.
+ */
+assert.content = function (context, msg = "") {
+ msg = msg || "Only supported in content context";
+ assert.that(
+ c => c.toString() == "content",
+ msg,
+ lazy.error.UnsupportedOperationError
+ )(context);
+};
+
+/**
+ * Asserts that the {@link CanonicalBrowsingContext} is open.
+ *
+ * @param {CanonicalBrowsingContext} browsingContext
+ * Canonical browsing context to check.
+ * @param {string=} msg
+ * Custom error message.
+ *
+ * @returns {CanonicalBrowsingContext}
+ * <var>browsingContext</var> is returned unaltered.
+ *
+ * @throws {NoSuchWindowError}
+ * If <var>browsingContext</var> is no longer open.
+ */
+assert.open = function (browsingContext, msg = "") {
+ msg = msg || "Browsing context has been discarded";
+ return assert.that(
+ browsingContext => {
+ if (!browsingContext?.currentWindowGlobal) {
+ return false;
+ }
+
+ if (browsingContext.isContent && !browsingContext.top.embedderElement) {
+ return false;
+ }
+
+ return true;
+ },
+ msg,
+ lazy.error.NoSuchWindowError
+ )(browsingContext);
+};
+
+/**
+ * Asserts that there is no current user prompt.
+ *
+ * @param {modal.Dialog} dialog
+ * Reference to current dialogue.
+ * @param {string=} msg
+ * Custom error message.
+ *
+ * @throws {UnexpectedAlertOpenError}
+ * If there is a user prompt.
+ */
+assert.noUserPrompt = function (dialog, msg = "") {
+ assert.that(
+ d => d === null || typeof d == "undefined",
+ msg,
+ lazy.error.UnexpectedAlertOpenError
+ )(dialog);
+};
+
+/**
+ * Asserts that <var>obj</var> is defined.
+ *
+ * @param {?} obj
+ * Value to test.
+ * @param {string=} msg
+ * Custom error message.
+ *
+ * @returns {?}
+ * <var>obj</var> is returned unaltered.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>obj</var> is not defined.
+ */
+assert.defined = function (obj, msg = "") {
+ msg = msg || lazy.pprint`Expected ${obj} to be defined`;
+ return assert.that(o => typeof o != "undefined", msg)(obj);
+};
+
+/**
+ * Asserts that <var>obj</var> is a finite number.
+ *
+ * @param {?} obj
+ * Value to test.
+ * @param {string=} msg
+ * Custom error message.
+ *
+ * @returns {number}
+ * <var>obj</var> is returned unaltered.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>obj</var> is not a number.
+ */
+assert.number = function (obj, msg = "") {
+ msg = msg || lazy.pprint`Expected ${obj} to be finite number`;
+ return assert.that(Number.isFinite, msg)(obj);
+};
+
+/**
+ * Asserts that <var>obj</var> is a positive number.
+ *
+ * @param {?} obj
+ * Value to test.
+ * @param {string=} msg
+ * Custom error message.
+ *
+ * @returns {number}
+ * <var>obj</var> is returned unaltered.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>obj</var> is not a positive integer.
+ */
+assert.positiveNumber = function (obj, msg = "") {
+ assert.number(obj, msg);
+ msg = msg || lazy.pprint`Expected ${obj} to be >= 0`;
+ return assert.that(n => n >= 0, msg)(obj);
+};
+
+/**
+ * Asserts that <var>obj</var> is a number in the inclusive range <var>lower</var> to <var>upper</var>.
+ *
+ * @param {?} obj
+ * Value to test.
+ * @param {Array<number>} range
+ * Array range [lower, upper]
+ * @param {string=} msg
+ * Custom error message.
+ *
+ * @returns {number}
+ * <var>obj</var> is returned unaltered.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>obj</var> is not a number in the specified range.
+ */
+assert.numberInRange = function (obj, range, msg = "") {
+ const [lower, upper] = range;
+ assert.number(obj, msg);
+ msg = msg || lazy.pprint`Expected ${obj} to be >= ${lower} and <= ${upper}`;
+ return assert.that(n => n >= lower && n <= upper, msg)(obj);
+};
+
+/**
+ * Asserts that <var>obj</var> is callable.
+ *
+ * @param {?} obj
+ * Value to test.
+ * @param {string=} msg
+ * Custom error message.
+ *
+ * @returns {Function}
+ * <var>obj</var> is returned unaltered.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>obj</var> is not callable.
+ */
+assert.callable = function (obj, msg = "") {
+ msg = msg || lazy.pprint`${obj} is not callable`;
+ return assert.that(o => typeof o == "function", msg)(obj);
+};
+
+/**
+ * Asserts that <var>obj</var> is an unsigned short number.
+ *
+ * @param {?} obj
+ * Value to test.
+ * @param {string=} msg
+ * Custom error message.
+ *
+ * @returns {number}
+ * <var>obj</var> is returned unaltered.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>obj</var> is not an unsigned short.
+ */
+assert.unsignedShort = function (obj, msg = "") {
+ msg = msg || lazy.pprint`Expected ${obj} to be >= 0 and < 65536`;
+ return assert.that(n => n >= 0 && n < 65536, msg)(obj);
+};
+
+/**
+ * Asserts that <var>obj</var> is an integer.
+ *
+ * @param {?} obj
+ * Value to test.
+ * @param {string=} msg
+ * Custom error message.
+ *
+ * @returns {number}
+ * <var>obj</var> is returned unaltered.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>obj</var> is not an integer.
+ */
+assert.integer = function (obj, msg = "") {
+ msg = msg || lazy.pprint`Expected ${obj} to be an integer`;
+ return assert.that(Number.isSafeInteger, msg)(obj);
+};
+
+/**
+ * Asserts that <var>obj</var> is a positive integer.
+ *
+ * @param {?} obj
+ * Value to test.
+ * @param {string=} msg
+ * Custom error message.
+ *
+ * @returns {number}
+ * <var>obj</var> is returned unaltered.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>obj</var> is not a positive integer.
+ */
+assert.positiveInteger = function (obj, msg = "") {
+ assert.integer(obj, msg);
+ msg = msg || lazy.pprint`Expected ${obj} to be >= 0`;
+ return assert.that(n => n >= 0, msg)(obj);
+};
+
+/**
+ * Asserts that <var>obj</var> is an integer in the inclusive range <var>lower</var> to <var>upper</var>.
+ *
+ * @param {?} obj
+ * Value to test.
+ * @param {Array<number>} range
+ * Array range [lower, upper]
+ * @param {string=} msg
+ * Custom error message.
+ *
+ * @returns {number}
+ * <var>obj</var> is returned unaltered.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>obj</var> is not a number in the specified range.
+ */
+assert.integerInRange = function (obj, range, msg = "") {
+ const [lower, upper] = range;
+ assert.integer(obj, msg);
+ msg = msg || lazy.pprint`Expected ${obj} to be >= ${lower} and <= ${upper}`;
+ return assert.that(n => n >= lower && n <= upper, msg)(obj);
+};
+
+/**
+ * Asserts that <var>obj</var> is a boolean.
+ *
+ * @param {?} obj
+ * Value to test.
+ * @param {string=} msg
+ * Custom error message.
+ *
+ * @returns {boolean}
+ * <var>obj</var> is returned unaltered.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>obj</var> is not a boolean.
+ */
+assert.boolean = function (obj, msg = "") {
+ msg = msg || lazy.pprint`Expected ${obj} to be boolean`;
+ return assert.that(b => typeof b == "boolean", msg)(obj);
+};
+
+/**
+ * Asserts that <var>obj</var> is a string.
+ *
+ * @param {?} obj
+ * Value to test.
+ * @param {string=} msg
+ * Custom error message.
+ *
+ * @returns {string}
+ * <var>obj</var> is returned unaltered.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>obj</var> is not a string.
+ */
+assert.string = function (obj, msg = "") {
+ msg = msg || lazy.pprint`Expected ${obj} to be a string`;
+ return assert.that(s => typeof s == "string", msg)(obj);
+};
+
+/**
+ * Asserts that <var>obj</var> is an object.
+ *
+ * @param {?} obj
+ * Value to test.
+ * @param {string=} msg
+ * Custom error message.
+ *
+ * @returns {object}
+ * obj| is returned unaltered.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>obj</var> is not an object.
+ */
+assert.object = function (obj, msg = "") {
+ msg = msg || lazy.pprint`Expected ${obj} to be an object`;
+ return assert.that(o => {
+ // unable to use instanceof because LHS and RHS may come from
+ // different globals
+ let s = Object.prototype.toString.call(o);
+ return s == "[object Object]" || s == "[object nsJSIID]";
+ }, msg)(obj);
+};
+
+/**
+ * Asserts that <var>prop</var> is in <var>obj</var>.
+ *
+ * @param {?} prop
+ * An array element or own property to test if is in <var>obj</var>.
+ * @param {?} obj
+ * An array or an Object that is being tested.
+ * @param {string=} msg
+ * Custom error message.
+ *
+ * @returns {?}
+ * The array element, or the value of <var>obj</var>'s own property
+ * <var>prop</var>.
+ *
+ * @throws {InvalidArgumentError}
+ * If the <var>obj</var> was an array and did not contain <var>prop</var>.
+ * Otherwise if <var>prop</var> is not in <var>obj</var>, or <var>obj</var>
+ * is not an object.
+ */
+assert.in = function (prop, obj, msg = "") {
+ if (Array.isArray(obj)) {
+ assert.that(p => obj.includes(p), msg)(prop);
+ return prop;
+ }
+ assert.object(obj, msg);
+ msg = msg || lazy.pprint`Expected ${prop} in ${obj}`;
+ assert.that(p => obj.hasOwnProperty(p), msg)(prop);
+ return obj[prop];
+};
+
+/**
+ * Asserts that <var>obj</var> is an Array.
+ *
+ * @param {?} obj
+ * Value to test.
+ * @param {string=} msg
+ * Custom error message.
+ *
+ * @returns {object}
+ * <var>obj</var> is returned unaltered.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>obj</var> is not an Array.
+ */
+assert.array = function (obj, msg = "") {
+ msg = msg || lazy.pprint`Expected ${obj} to be an Array`;
+ return assert.that(Array.isArray, msg)(obj);
+};
+
+/**
+ * Returns a function that is used to assert the |predicate|.
+ *
+ * @param {function(?): boolean} predicate
+ * Evaluated on calling the return value of this function. If its
+ * return value of the inner function is false, <var>error</var>
+ * is thrown with <var>message</var>.
+ * @param {string=} message
+ * Custom error message.
+ * @param {Error=} err
+ * Custom error type by its class.
+ *
+ * @returns {function(?): ?}
+ * Function that takes and returns the passed in value unaltered,
+ * and which may throw <var>error</var> with <var>message</var>
+ * if <var>predicate</var> evaluates to false.
+ */
+assert.that = function (
+ predicate,
+ message = "",
+ err = lazy.error.InvalidArgumentError
+) {
+ return obj => {
+ if (!predicate(obj)) {
+ throw new err(message);
+ }
+ return obj;
+ };
+};
diff --git a/remote/shared/webdriver/Capabilities.sys.mjs b/remote/shared/webdriver/Capabilities.sys.mjs
new file mode 100644
index 0000000000..e3761315f2
--- /dev/null
+++ b/remote/shared/webdriver/Capabilities.sys.mjs
@@ -0,0 +1,1061 @@
+/* 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, {
+ AppInfo: "chrome://remote/content/shared/AppInfo.sys.mjs",
+ assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs",
+ error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
+ pprint: "chrome://remote/content/shared/Format.sys.mjs",
+ RemoteAgent: "chrome://remote/content/components/RemoteAgent.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "remoteAgent", () => {
+ return Cc["@mozilla.org/remote/agent;1"].createInstance(Ci.nsIRemoteAgent);
+});
+
+// List of capabilities which are only relevant for Webdriver Classic.
+export const WEBDRIVER_CLASSIC_CAPABILITIES = [
+ "pageLoadStrategy",
+ "timeouts",
+ "strictFileInteractability",
+ "unhandledPromptBehavior",
+ "webSocketUrl",
+ "moz:useNonSpecCompliantPointerOrigin",
+ "moz:webdriverClick",
+ "moz:debuggerAddress",
+ "moz:firefoxOptions",
+];
+
+/** Representation of WebDriver session timeouts. */
+export class Timeouts {
+ constructor() {
+ // disabled
+ this.implicit = 0;
+ // five minutes
+ this.pageLoad = 300000;
+ // 30 seconds
+ this.script = 30000;
+ }
+
+ toString() {
+ return "[object Timeouts]";
+ }
+
+ /** Marshals timeout durations to a JSON Object. */
+ toJSON() {
+ return {
+ implicit: this.implicit,
+ pageLoad: this.pageLoad,
+ script: this.script,
+ };
+ }
+
+ static fromJSON(json) {
+ lazy.assert.object(
+ json,
+ lazy.pprint`Expected "timeouts" to be an object, got ${json}`
+ );
+ let t = new Timeouts();
+
+ for (let [type, ms] of Object.entries(json)) {
+ switch (type) {
+ case "implicit":
+ t.implicit = lazy.assert.positiveInteger(
+ ms,
+ lazy.pprint`Expected ${type} to be a positive integer, got ${ms}`
+ );
+ break;
+
+ case "script":
+ if (ms !== null) {
+ lazy.assert.positiveInteger(
+ ms,
+ lazy.pprint`Expected ${type} to be a positive integer, got ${ms}`
+ );
+ }
+ t.script = ms;
+ break;
+
+ case "pageLoad":
+ t.pageLoad = lazy.assert.positiveInteger(
+ ms,
+ lazy.pprint`Expected ${type} to be a positive integer, got ${ms}`
+ );
+ break;
+
+ default:
+ throw new lazy.error.InvalidArgumentError(
+ "Unrecognised timeout: " + type
+ );
+ }
+ }
+
+ return t;
+ }
+}
+
+/**
+ * Enum of page loading strategies.
+ *
+ * @enum
+ */
+export const PageLoadStrategy = {
+ /** No page load strategy. Navigation will return immediately. */
+ None: "none",
+ /**
+ * Eager, causing navigation to complete when the document reaches
+ * the <code>interactive</code> ready state.
+ */
+ Eager: "eager",
+ /**
+ * Normal, causing navigation to return when the document reaches the
+ * <code>complete</code> ready state.
+ */
+ Normal: "normal",
+};
+
+/** Proxy configuration object representation. */
+export class Proxy {
+ /** @class */
+ constructor() {
+ this.proxyType = null;
+ this.httpProxy = null;
+ this.httpProxyPort = null;
+ this.noProxy = null;
+ this.sslProxy = null;
+ this.sslProxyPort = null;
+ this.socksProxy = null;
+ this.socksProxyPort = null;
+ this.socksVersion = null;
+ this.proxyAutoconfigUrl = null;
+ }
+
+ /**
+ * Sets Firefox proxy settings.
+ *
+ * @returns {boolean}
+ * True if proxy settings were updated as a result of calling this
+ * function, or false indicating that this function acted as
+ * a no-op.
+ */
+ init() {
+ switch (this.proxyType) {
+ case "autodetect":
+ Services.prefs.setIntPref("network.proxy.type", 4);
+ return true;
+
+ case "direct":
+ Services.prefs.setIntPref("network.proxy.type", 0);
+ return true;
+
+ case "manual":
+ Services.prefs.setIntPref("network.proxy.type", 1);
+
+ if (this.httpProxy) {
+ Services.prefs.setStringPref("network.proxy.http", this.httpProxy);
+ if (Number.isInteger(this.httpProxyPort)) {
+ Services.prefs.setIntPref(
+ "network.proxy.http_port",
+ this.httpProxyPort
+ );
+ }
+ }
+
+ if (this.sslProxy) {
+ Services.prefs.setStringPref("network.proxy.ssl", this.sslProxy);
+ if (Number.isInteger(this.sslProxyPort)) {
+ Services.prefs.setIntPref(
+ "network.proxy.ssl_port",
+ this.sslProxyPort
+ );
+ }
+ }
+
+ if (this.socksProxy) {
+ Services.prefs.setStringPref("network.proxy.socks", this.socksProxy);
+ if (Number.isInteger(this.socksProxyPort)) {
+ Services.prefs.setIntPref(
+ "network.proxy.socks_port",
+ this.socksProxyPort
+ );
+ }
+ if (this.socksVersion) {
+ Services.prefs.setIntPref(
+ "network.proxy.socks_version",
+ this.socksVersion
+ );
+ }
+ }
+
+ if (this.noProxy) {
+ Services.prefs.setStringPref(
+ "network.proxy.no_proxies_on",
+ this.noProxy.join(", ")
+ );
+ }
+ return true;
+
+ case "pac":
+ Services.prefs.setIntPref("network.proxy.type", 2);
+ Services.prefs.setStringPref(
+ "network.proxy.autoconfig_url",
+ this.proxyAutoconfigUrl
+ );
+ return true;
+
+ case "system":
+ Services.prefs.setIntPref("network.proxy.type", 5);
+ return true;
+
+ default:
+ return false;
+ }
+ }
+
+ /**
+ * @param {Object<string, ?>} json
+ * JSON Object to unmarshal.
+ *
+ * @throws {InvalidArgumentError}
+ * When proxy configuration is invalid.
+ */
+ static fromJSON(json) {
+ function stripBracketsFromIpv6Hostname(hostname) {
+ return hostname.includes(":")
+ ? hostname.replace(/[\[\]]/g, "")
+ : hostname;
+ }
+
+ // Parse hostname and optional port from host
+ function fromHost(scheme, host) {
+ lazy.assert.string(
+ host,
+ lazy.pprint`Expected proxy "host" to be a string, got ${host}`
+ );
+
+ if (host.includes("://")) {
+ throw new lazy.error.InvalidArgumentError(`${host} contains a scheme`);
+ }
+
+ let url;
+ try {
+ // To parse the host a scheme has to be added temporarily.
+ // If the returned value for the port is an empty string it
+ // could mean no port or the default port for this scheme was
+ // specified. In such a case parse again with a different
+ // scheme to ensure we filter out the default port.
+ url = new URL("http://" + host);
+ if (url.port == "") {
+ url = new URL("https://" + host);
+ }
+ } catch (e) {
+ throw new lazy.error.InvalidArgumentError(e.message);
+ }
+
+ let hostname = stripBracketsFromIpv6Hostname(url.hostname);
+
+ // If the port hasn't been set, use the default port of
+ // the selected scheme (except for socks which doesn't have one).
+ let port = parseInt(url.port);
+ if (!Number.isInteger(port)) {
+ if (scheme === "socks") {
+ port = null;
+ } else {
+ port = Services.io.getDefaultPort(scheme);
+ }
+ }
+
+ if (
+ url.username != "" ||
+ url.password != "" ||
+ url.pathname != "/" ||
+ url.search != "" ||
+ url.hash != ""
+ ) {
+ throw new lazy.error.InvalidArgumentError(
+ `${host} was not of the form host[:port]`
+ );
+ }
+
+ return [hostname, port];
+ }
+
+ let p = new Proxy();
+ if (typeof json == "undefined" || json === null) {
+ return p;
+ }
+
+ lazy.assert.object(
+ json,
+ lazy.pprint`Expected "proxy" to be an object, got ${json}`
+ );
+
+ lazy.assert.in(
+ "proxyType",
+ json,
+ lazy.pprint`Expected "proxyType" in "proxy" object, got ${json}`
+ );
+ p.proxyType = lazy.assert.string(
+ json.proxyType,
+ lazy.pprint`Expected "proxyType" to be a string, got ${json.proxyType}`
+ );
+
+ switch (p.proxyType) {
+ case "autodetect":
+ case "direct":
+ case "system":
+ break;
+
+ case "pac":
+ p.proxyAutoconfigUrl = lazy.assert.string(
+ json.proxyAutoconfigUrl,
+ `Expected "proxyAutoconfigUrl" to be a string, ` +
+ lazy.pprint`got ${json.proxyAutoconfigUrl}`
+ );
+ break;
+
+ case "manual":
+ if (typeof json.ftpProxy != "undefined") {
+ throw new lazy.error.InvalidArgumentError(
+ "Since Firefox 90 'ftpProxy' is no longer supported"
+ );
+ }
+ if (typeof json.httpProxy != "undefined") {
+ [p.httpProxy, p.httpProxyPort] = fromHost("http", json.httpProxy);
+ }
+ if (typeof json.sslProxy != "undefined") {
+ [p.sslProxy, p.sslProxyPort] = fromHost("https", json.sslProxy);
+ }
+ if (typeof json.socksProxy != "undefined") {
+ [p.socksProxy, p.socksProxyPort] = fromHost("socks", json.socksProxy);
+ p.socksVersion = lazy.assert.positiveInteger(
+ json.socksVersion,
+ lazy.pprint`Expected "socksVersion" to be a positive integer, got ${json.socksVersion}`
+ );
+ }
+ if (typeof json.noProxy != "undefined") {
+ let entries = lazy.assert.array(
+ json.noProxy,
+ lazy.pprint`Expected "noProxy" to be an array, got ${json.noProxy}`
+ );
+ p.noProxy = entries.map(entry => {
+ lazy.assert.string(
+ entry,
+ lazy.pprint`Expected "noProxy" entry to be a string, got ${entry}`
+ );
+ return stripBracketsFromIpv6Hostname(entry);
+ });
+ }
+ break;
+
+ default:
+ throw new lazy.error.InvalidArgumentError(
+ `Invalid type of proxy: ${p.proxyType}`
+ );
+ }
+
+ return p;
+ }
+
+ /**
+ * @returns {Object<string, (number | string)>}
+ * JSON serialisation of proxy object.
+ */
+ toJSON() {
+ function addBracketsToIpv6Hostname(hostname) {
+ return hostname.includes(":") ? `[${hostname}]` : hostname;
+ }
+
+ function toHost(hostname, port) {
+ if (!hostname) {
+ return null;
+ }
+
+ // Add brackets around IPv6 addresses
+ hostname = addBracketsToIpv6Hostname(hostname);
+
+ if (port != null) {
+ return `${hostname}:${port}`;
+ }
+
+ return hostname;
+ }
+
+ let excludes = this.noProxy;
+ if (excludes) {
+ excludes = excludes.map(addBracketsToIpv6Hostname);
+ }
+
+ return marshal({
+ proxyType: this.proxyType,
+ httpProxy: toHost(this.httpProxy, this.httpProxyPort),
+ noProxy: excludes,
+ sslProxy: toHost(this.sslProxy, this.sslProxyPort),
+ socksProxy: toHost(this.socksProxy, this.socksProxyPort),
+ socksVersion: this.socksVersion,
+ proxyAutoconfigUrl: this.proxyAutoconfigUrl,
+ });
+ }
+
+ toString() {
+ return "[object Proxy]";
+ }
+}
+
+/**
+ * Enum of unhandled prompt behavior.
+ *
+ * @enum
+ */
+export const UnhandledPromptBehavior = {
+ /** All simple dialogs encountered should be accepted. */
+ Accept: "accept",
+ /**
+ * All simple dialogs encountered should be accepted, and an error
+ * returned that the dialog was handled.
+ */
+ AcceptAndNotify: "accept and notify",
+ /** All simple dialogs encountered should be dismissed. */
+ Dismiss: "dismiss",
+ /**
+ * All simple dialogs encountered should be dismissed, and an error
+ * returned that the dialog was handled.
+ */
+ DismissAndNotify: "dismiss and notify",
+ /** All simple dialogs encountered should be left to the user to handle. */
+ Ignore: "ignore",
+};
+
+/** WebDriver session capabilities representation. */
+export class Capabilities extends Map {
+ /** @class */
+ constructor() {
+ super([
+ // webdriver
+ ["browserName", getWebDriverBrowserName()],
+ ["browserVersion", lazy.AppInfo.version],
+ ["platformName", getWebDriverPlatformName()],
+ ["acceptInsecureCerts", false],
+ ["pageLoadStrategy", PageLoadStrategy.Normal],
+ ["proxy", new Proxy()],
+ ["setWindowRect", !lazy.AppInfo.isAndroid],
+ ["timeouts", new Timeouts()],
+ ["strictFileInteractability", false],
+ ["unhandledPromptBehavior", UnhandledPromptBehavior.DismissAndNotify],
+ ["webSocketUrl", null],
+
+ // proprietary
+ ["moz:accessibilityChecks", false],
+ ["moz:buildID", lazy.AppInfo.appBuildID],
+ [
+ "moz:debuggerAddress",
+ // With bug 1715481 fixed always use the Remote Agent instance
+ lazy.RemoteAgent.running && lazy.RemoteAgent.cdp
+ ? lazy.remoteAgent.debuggerAddress
+ : null,
+ ],
+ [
+ "moz:headless",
+ Cc["@mozilla.org/gfx/info;1"].getService(Ci.nsIGfxInfo).isHeadless,
+ ],
+ ["moz:platformVersion", Services.sysinfo.getProperty("version")],
+ ["moz:processID", lazy.AppInfo.processID],
+ ["moz:profile", maybeProfile()],
+ [
+ "moz:shutdownTimeout",
+ Services.prefs.getIntPref("toolkit.asyncshutdown.crash_timeout"),
+ ],
+ ["moz:webdriverClick", true],
+ ["moz:windowless", false],
+ ]);
+ }
+
+ /**
+ * @param {string} key
+ * Capability key.
+ * @param {(string|number|boolean)} value
+ * JSON-safe capability value.
+ */
+ set(key, value) {
+ if (key === "timeouts" && !(value instanceof Timeouts)) {
+ throw new TypeError();
+ } else if (key === "proxy" && !(value instanceof Proxy)) {
+ throw new TypeError();
+ }
+
+ return super.set(key, value);
+ }
+
+ toString() {
+ return "[object Capabilities]";
+ }
+
+ /**
+ * JSON serialisation of capabilities object.
+ *
+ * @returns {Object<string, ?>}
+ */
+ toJSON() {
+ let marshalled = marshal(this);
+
+ // Always return the proxy capability even if it's empty
+ if (!("proxy" in marshalled)) {
+ marshalled.proxy = {};
+ }
+
+ marshalled.timeouts = super.get("timeouts");
+
+ return marshalled;
+ }
+
+ /**
+ * Unmarshal a JSON object representation of WebDriver capabilities.
+ *
+ * @param {Object<string, *>=} json
+ * WebDriver capabilities.
+ *
+ * @returns {Capabilities}
+ * Internal representation of WebDriver capabilities.
+ */
+ static fromJSON(json) {
+ if (typeof json == "undefined" || json === null) {
+ json = {};
+ }
+ lazy.assert.object(
+ json,
+ lazy.pprint`Expected "capabilities" to be an object, got ${json}"`
+ );
+
+ const capabilities = new Capabilities();
+ // TODO: Bug 1823907. We can start using here spec compliant method `validate`,
+ // as soon as `desiredCapabilities` and `requiredCapabilities` are not supported.
+ for (let [k, v] of Object.entries(json)) {
+ switch (k) {
+ case "acceptInsecureCerts":
+ lazy.assert.boolean(
+ v,
+ lazy.pprint`Expected ${k} to be a boolean, got ${v}`
+ );
+ break;
+
+ case "pageLoadStrategy":
+ lazy.assert.string(
+ v,
+ lazy.pprint`Expected ${k} to be a string, got ${v}`
+ );
+ if (!Object.values(PageLoadStrategy).includes(v)) {
+ throw new lazy.error.InvalidArgumentError(
+ "Unknown page load strategy: " + v
+ );
+ }
+ break;
+
+ case "proxy":
+ v = Proxy.fromJSON(v);
+ break;
+
+ case "setWindowRect":
+ lazy.assert.boolean(
+ v,
+ lazy.pprint`Expected ${k} to be boolean, got ${v}`
+ );
+ if (!lazy.AppInfo.isAndroid && !v) {
+ throw new lazy.error.InvalidArgumentError(
+ "setWindowRect cannot be disabled"
+ );
+ } else if (lazy.AppInfo.isAndroid && v) {
+ throw new lazy.error.InvalidArgumentError(
+ "setWindowRect is only supported on desktop"
+ );
+ }
+ break;
+
+ case "timeouts":
+ v = Timeouts.fromJSON(v);
+ break;
+
+ case "strictFileInteractability":
+ v = lazy.assert.boolean(v);
+ break;
+
+ case "unhandledPromptBehavior":
+ lazy.assert.string(
+ v,
+ lazy.pprint`Expected ${k} to be a string, got ${v}`
+ );
+ if (!Object.values(UnhandledPromptBehavior).includes(v)) {
+ throw new lazy.error.InvalidArgumentError(
+ `Unknown unhandled prompt behavior: ${v}`
+ );
+ }
+ break;
+
+ case "webSocketUrl":
+ lazy.assert.boolean(
+ v,
+ lazy.pprint`Expected ${k} to be boolean, got ${v}`
+ );
+
+ if (!v) {
+ throw new lazy.error.InvalidArgumentError(
+ lazy.pprint`Expected ${k} to be true, got ${v}`
+ );
+ }
+ break;
+
+ case "webauthn:virtualAuthenticators":
+ lazy.assert.boolean(
+ v,
+ lazy.pprint`Expected ${k} to be a boolean, got ${v}`
+ );
+ break;
+
+ case "webauthn:extension:uvm":
+ lazy.assert.boolean(
+ v,
+ lazy.pprint`Expected ${k} to be a boolean, got ${v}`
+ );
+ break;
+
+ case "webauthn:extension:prf":
+ lazy.assert.boolean(
+ v,
+ lazy.pprint`Expected ${k} to be a boolean, got ${v}`
+ );
+ break;
+
+ case "webauthn:extension:largeBlob":
+ lazy.assert.boolean(
+ v,
+ lazy.pprint`Expected ${k} to be a boolean, got ${v}`
+ );
+ break;
+
+ case "webauthn:extension:credBlob":
+ lazy.assert.boolean(
+ v,
+ lazy.pprint`Expected ${k} to be a boolean, got ${v}`
+ );
+ break;
+
+ case "moz:accessibilityChecks":
+ lazy.assert.boolean(
+ v,
+ lazy.pprint`Expected ${k} to be boolean, got ${v}`
+ );
+ break;
+
+ // Don't set the value because it's only used to return the address
+ // of the Remote Agent's debugger (HTTP server).
+ case "moz:debuggerAddress":
+ continue;
+
+ case "moz:useNonSpecCompliantPointerOrigin":
+ if (v !== undefined) {
+ throw new lazy.error.InvalidArgumentError(
+ `Since Firefox 116 the capability ${k} is no longer supported`
+ );
+ }
+ break;
+
+ case "moz:webdriverClick":
+ lazy.assert.boolean(
+ v,
+ lazy.pprint`Expected ${k} to be boolean, got ${v}`
+ );
+ break;
+
+ case "moz:windowless":
+ lazy.assert.boolean(
+ v,
+ lazy.pprint`Expected ${k} to be boolean, got ${v}`
+ );
+
+ // Only supported on MacOS
+ if (v && !lazy.AppInfo.isMac) {
+ throw new lazy.error.InvalidArgumentError(
+ "moz:windowless only supported on MacOS"
+ );
+ }
+ break;
+ }
+ capabilities.set(k, v);
+ }
+
+ return capabilities;
+ }
+
+ /**
+ * Validate WebDriver capability.
+ *
+ * @param {string} name
+ * The name of capability.
+ * @param {string} value
+ * The value of capability.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>value</var> doesn't pass validation,
+ * which depends on <var>name</var>.
+ *
+ * @returns {string}
+ * The validated capability value.
+ */
+ static validate(name, value) {
+ if (value === null) {
+ return value;
+ }
+ switch (name) {
+ case "acceptInsecureCerts":
+ lazy.assert.boolean(
+ value,
+ lazy.pprint`Expected ${name} to be a boolean, got ${value}`
+ );
+ return value;
+
+ case "browserName":
+ case "browserVersion":
+ case "platformName":
+ return lazy.assert.string(
+ value,
+ lazy.pprint`Expected ${name} to be a string, got ${value}`
+ );
+
+ case "pageLoadStrategy":
+ lazy.assert.string(
+ value,
+ lazy.pprint`Expected ${name} to be a string, got ${value}`
+ );
+ if (!Object.values(PageLoadStrategy).includes(value)) {
+ throw new lazy.error.InvalidArgumentError(
+ "Unknown page load strategy: " + value
+ );
+ }
+ return value;
+
+ case "proxy":
+ return Proxy.fromJSON(value);
+
+ case "strictFileInteractability":
+ return lazy.assert.boolean(value);
+
+ case "timeouts":
+ return Timeouts.fromJSON(value);
+
+ case "unhandledPromptBehavior":
+ lazy.assert.string(
+ value,
+ lazy.pprint`Expected ${name} to be a string, got ${value}`
+ );
+ if (!Object.values(UnhandledPromptBehavior).includes(value)) {
+ throw new lazy.error.InvalidArgumentError(
+ `Unknown unhandled prompt behavior: ${value}`
+ );
+ }
+ return value;
+
+ case "webSocketUrl":
+ lazy.assert.boolean(
+ value,
+ lazy.pprint`Expected ${name} to be a boolean, got ${value}`
+ );
+
+ if (!value) {
+ throw new lazy.error.InvalidArgumentError(
+ lazy.pprint`Expected ${name} to be true, got ${value}`
+ );
+ }
+ return value;
+
+ case "webauthn:virtualAuthenticators":
+ lazy.assert.boolean(
+ value,
+ lazy.pprint`Expected ${name} to be a boolean, got ${value}`
+ );
+ return value;
+
+ case "webauthn:extension:uvm":
+ lazy.assert.boolean(
+ value,
+ lazy.pprint`Expected ${name} to be a boolean, got ${value}`
+ );
+ return value;
+
+ case "webauthn:extension:largeBlob":
+ lazy.assert.boolean(
+ value,
+ lazy.pprint`Expected ${name} to be a boolean, got ${value}`
+ );
+ return value;
+
+ case "moz:firefoxOptions":
+ return lazy.assert.object(
+ value,
+ lazy.pprint`Expected ${name} to be an object, got ${value}`
+ );
+
+ case "moz:accessibilityChecks":
+ return lazy.assert.boolean(
+ value,
+ lazy.pprint`Expected ${name} to be a boolean, got ${value}`
+ );
+
+ case "moz:webdriverClick":
+ return lazy.assert.boolean(
+ value,
+ lazy.pprint`Expected ${name} to be a boolean, got ${value}`
+ );
+
+ case "moz:windowless":
+ lazy.assert.boolean(
+ value,
+ lazy.pprint`Expected ${name} to be a boolean, got ${value}`
+ );
+
+ // Only supported on MacOS
+ if (value && !lazy.AppInfo.isMac) {
+ throw new lazy.error.InvalidArgumentError(
+ "moz:windowless only supported on MacOS"
+ );
+ }
+ return value;
+
+ case "moz:debuggerAddress":
+ return lazy.assert.boolean(
+ value,
+ lazy.pprint`Expected ${name} to be a boolean, got ${value}`
+ );
+
+ default:
+ lazy.assert.string(
+ name,
+ lazy.pprint`Expected capability name to be a string, got ${name}`
+ );
+ if (name.includes(":")) {
+ const [prefix] = name.split(":");
+ if (prefix !== "moz") {
+ return value;
+ }
+ }
+ throw new lazy.error.InvalidArgumentError(
+ `${name} is not the name of a known capability or extension capability`
+ );
+ }
+ }
+}
+
+function getWebDriverBrowserName() {
+ // Similar to chromedriver which reports "chrome" as browser name for all
+ // WebView apps, we will report "firefox" for all GeckoView apps.
+ if (lazy.AppInfo.isAndroid) {
+ return "firefox";
+ }
+
+ return lazy.AppInfo.name?.toLowerCase();
+}
+
+function getWebDriverPlatformName() {
+ let name = Services.sysinfo.getProperty("name");
+
+ if (lazy.AppInfo.isAndroid) {
+ return "android";
+ }
+
+ switch (name) {
+ case "Windows_NT":
+ return "windows";
+
+ case "Darwin":
+ return "mac";
+
+ default:
+ return name.toLowerCase();
+ }
+}
+
+// Specialisation of |JSON.stringify| that produces JSON-safe object
+// literals, dropping empty objects and entries which values are undefined
+// or null. Objects are allowed to produce their own JSON representations
+// by implementing a |toJSON| function.
+function marshal(obj) {
+ let rv = Object.create(null);
+
+ function* iter(mapOrObject) {
+ if (mapOrObject instanceof Map) {
+ for (const [k, v] of mapOrObject) {
+ yield [k, v];
+ }
+ } else {
+ for (const k of Object.keys(mapOrObject)) {
+ yield [k, mapOrObject[k]];
+ }
+ }
+ }
+
+ for (let [k, v] of iter(obj)) {
+ // Skip empty values when serialising to JSON.
+ if (typeof v == "undefined" || v === null) {
+ continue;
+ }
+
+ // Recursively marshal objects that are able to produce their own
+ // JSON representation.
+ if (typeof v.toJSON == "function") {
+ v = marshal(v.toJSON());
+
+ // Or do the same for object literals.
+ } else if (isObject(v)) {
+ v = marshal(v);
+ }
+
+ // And finally drop (possibly marshaled) objects which have no
+ // entries.
+ if (!isObjectEmpty(v)) {
+ rv[k] = v;
+ }
+ }
+
+ return rv;
+}
+
+function isObject(obj) {
+ return Object.prototype.toString.call(obj) == "[object Object]";
+}
+
+function isObjectEmpty(obj) {
+ return isObject(obj) && Object.keys(obj).length === 0;
+}
+
+// Services.dirsvc is not accessible from JSWindowActor child,
+// but we should not panic about that.
+function maybeProfile() {
+ try {
+ return Services.dirsvc.get("ProfD", Ci.nsIFile).path;
+ } catch (e) {
+ return "<protected>";
+ }
+}
+
+/**
+ * Merge WebDriver capabilities.
+ *
+ * @see https://w3c.github.io/webdriver/#dfn-merging-capabilities
+ *
+ * @param {object} primary
+ * Required capabilities which need to be merged with <var>secondary</var>.
+ * @param {object=} secondary
+ * Secondary capabilities.
+ *
+ * @returns {object} Merged capabilities.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>primary</var> and <var>secondary</var> have the same keys.
+ */
+export function mergeCapabilities(primary, secondary) {
+ const result = { ...primary };
+
+ if (secondary === undefined) {
+ return result;
+ }
+
+ Object.entries(secondary).forEach(([name, value]) => {
+ if (primary[name] !== undefined) {
+ // Since at the moment we always pass as `primary` `alwaysMatch` object
+ // and as `secondary` an item from `firstMatch` array from `capabilities`,
+ // we can make this error message more specific.
+ throw new lazy.error.InvalidArgumentError(
+ `firstMatch key ${name} shadowed a value in alwaysMatch`
+ );
+ }
+ result[name] = value;
+ });
+
+ return result;
+}
+
+/**
+ * Validate WebDriver capabilities.
+ *
+ * @see https://w3c.github.io/webdriver/#dfn-validate-capabilities
+ *
+ * @param {object} capabilities
+ * Capabilities which need to be validated.
+ *
+ * @returns {object} Validated capabilities.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>capabilities</var> is not an object.
+ */
+export function validateCapabilities(capabilities) {
+ lazy.assert.object(capabilities);
+
+ const result = {};
+
+ Object.entries(capabilities).forEach(([name, value]) => {
+ const deserialized = Capabilities.validate(name, value);
+ if (deserialized !== null) {
+ if (name === "proxy" || name === "timeouts") {
+ // Return pure value, the Proxy and Timeouts objects will be setup
+ // during session creation.
+ result[name] = value;
+ } else {
+ result[name] = deserialized;
+ }
+ }
+ });
+
+ return result;
+}
+
+/**
+ * Process WebDriver capabilities.
+ *
+ * @see https://w3c.github.io/webdriver/#processing-capabilities
+ *
+ * @param {object} params
+ * @param {object} params.capabilities
+ * Capabilities which need to be processed.
+ *
+ * @returns {object} Processed capabilities.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>capabilities</var> do not satisfy the criteria.
+ */
+export function processCapabilities(params) {
+ const { capabilities } = params;
+ lazy.assert.object(capabilities);
+
+ let {
+ alwaysMatch: requiredCapabilities = {},
+ firstMatch: allFirstMatchCapabilities = [{}],
+ } = capabilities;
+
+ requiredCapabilities = validateCapabilities(requiredCapabilities);
+
+ lazy.assert.array(allFirstMatchCapabilities);
+ lazy.assert.that(
+ firstMatch => firstMatch.length >= 1,
+ lazy.pprint`Expected firstMatch ${allFirstMatchCapabilities} to have at least 1 entry`
+ )(allFirstMatchCapabilities);
+
+ const validatedFirstMatchCapabilities =
+ allFirstMatchCapabilities.map(validateCapabilities);
+
+ const mergedCapabilities = [];
+ validatedFirstMatchCapabilities.forEach(firstMatchCapabilities => {
+ const merged = mergeCapabilities(
+ requiredCapabilities,
+ firstMatchCapabilities
+ );
+ mergedCapabilities.push(merged);
+ });
+
+ // TODO: Bug 1836288. Implement the capability matching logic
+ // for "browserName", "browserVersion" and "platformName" features,
+ // for now we can just pick the first merged capability.
+ const matchedCapabilities = mergedCapabilities[0];
+
+ return matchedCapabilities;
+}
diff --git a/remote/shared/webdriver/Errors.sys.mjs b/remote/shared/webdriver/Errors.sys.mjs
new file mode 100644
index 0000000000..53b9d4426b
--- /dev/null
+++ b/remote/shared/webdriver/Errors.sys.mjs
@@ -0,0 +1,881 @@
+/* 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";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ pprint: "chrome://remote/content/shared/Format.sys.mjs",
+});
+
+const ERRORS = new Set([
+ "DetachedShadowRootError",
+ "ElementClickInterceptedError",
+ "ElementNotAccessibleError",
+ "ElementNotInteractableError",
+ "InsecureCertificateError",
+ "InvalidArgumentError",
+ "InvalidCookieDomainError",
+ "InvalidElementStateError",
+ "InvalidSelectorError",
+ "InvalidSessionIDError",
+ "JavaScriptError",
+ "MoveTargetOutOfBoundsError",
+ "NoSuchAlertError",
+ "NoSuchElementError",
+ "NoSuchFrameError",
+ "NoSuchHandleError",
+ "NoSuchHistoryEntryError",
+ "NoSuchInterceptError",
+ "NoSuchNodeError",
+ "NoSuchRequestError",
+ "NoSuchScriptError",
+ "NoSuchShadowRootError",
+ "NoSuchUserContextError",
+ "NoSuchWindowError",
+ "ScriptTimeoutError",
+ "SessionNotCreatedError",
+ "StaleElementReferenceError",
+ "TimeoutError",
+ "UnableToCaptureScreen",
+ "UnableToSetCookieError",
+ "UnexpectedAlertOpenError",
+ "UnknownCommandError",
+ "UnknownError",
+ "UnsupportedOperationError",
+ "WebDriverError",
+]);
+
+const BUILTIN_ERRORS = new Set([
+ "Error",
+ "EvalError",
+ "InternalError",
+ "RangeError",
+ "ReferenceError",
+ "SyntaxError",
+ "TypeError",
+ "URIError",
+]);
+
+/** @namespace */
+export const error = {
+ /**
+ * Check if ``val`` is an instance of the ``Error`` prototype.
+ *
+ * Because error objects may originate from different globals, comparing
+ * the prototype of the left hand side with the prototype property from
+ * the right hand side, which is what ``instanceof`` does, will not work.
+ * If the LHS and RHS come from different globals, this check will always
+ * fail because the two objects will not have the same identity.
+ *
+ * Therefore it is not safe to use ``instanceof`` in any multi-global
+ * situation, e.g. in content across multiple ``Window`` objects or anywhere
+ * in chrome scope.
+ *
+ * This function also contains a special check if ``val`` is an XPCOM
+ * ``nsIException`` because they are special snowflakes and may indeed
+ * cause Firefox to crash if used with ``instanceof``.
+ *
+ * @param {*} val
+ * Any value that should be undergo the test for errorness.
+ * @returns {boolean}
+ * True if error, false otherwise.
+ */
+ isError(val) {
+ if (val === null || typeof val != "object") {
+ return false;
+ } else if (val instanceof Ci.nsIException) {
+ return true;
+ }
+
+ // DOMRectList errors on string comparison
+ try {
+ let proto = Object.getPrototypeOf(val);
+ return BUILTIN_ERRORS.has(proto.toString());
+ } catch (e) {
+ return false;
+ }
+ },
+
+ /**
+ * Checks if ``obj`` is an object in the :js:class:`WebDriverError`
+ * prototypal chain.
+ *
+ * @param {*} obj
+ * Arbitrary object to test.
+ *
+ * @returns {boolean}
+ * True if ``obj`` is of the WebDriverError prototype chain,
+ * false otherwise.
+ */
+ isWebDriverError(obj) {
+ // Don't use "instanceof" to compare error objects because of possible
+ // problems when the other instance was created in a different global and
+ // as such won't have the same prototype object.
+ return error.isError(obj) && "name" in obj && ERRORS.has(obj.name);
+ },
+
+ /**
+ * Ensures error instance is a :js:class:`WebDriverError`.
+ *
+ * If the given error is already in the WebDriverError prototype
+ * chain, ``err`` is returned unmodified. If it is not, it is wrapped
+ * in :js:class:`UnknownError`.
+ *
+ * @param {Error} err
+ * Error to conditionally turn into a WebDriverError.
+ *
+ * @returns {WebDriverError}
+ * If ``err`` is a WebDriverError, it is returned unmodified.
+ * Otherwise an UnknownError type is returned.
+ */
+ wrap(err) {
+ if (error.isWebDriverError(err)) {
+ return err;
+ }
+ return new UnknownError(err);
+ },
+
+ /**
+ * Unhandled error reporter. Dumps the error and its stacktrace to console,
+ * and reports error to the Browser Console.
+ */
+ report(err) {
+ let msg = "Marionette threw an error: " + error.stringify(err);
+ dump(msg + "\n");
+ console.error(msg);
+ },
+
+ /**
+ * Prettifies an instance of Error and its stacktrace to a string.
+ */
+ stringify(err) {
+ try {
+ let s = err.toString();
+ if ("stack" in err) {
+ s += "\n" + err.stack;
+ }
+ return s;
+ } catch (e) {
+ return "<unprintable error>";
+ }
+ },
+
+ /** Create a stacktrace to the current line in the program. */
+ stack() {
+ let trace = new Error().stack;
+ let sa = trace.split("\n");
+ sa = sa.slice(1);
+ let rv = "stacktrace:\n" + sa.join("\n");
+ return rv.trimEnd();
+ },
+};
+
+/**
+ * WebDriverError is the prototypal parent of all WebDriver errors.
+ * It should not be used directly, as it does not correspond to a real
+ * error in the specification.
+ */
+class WebDriverError extends RemoteError {
+ /**
+ * Base error for WebDriver protocols.
+ *
+ * @param {(string|Error)=} obj
+ * Optional string describing error situation or Error instance
+ * to propagate.
+ * @param {object=} data
+ * Additional error data helpful in diagnosing the error.
+ */
+ constructor(obj, data = {}) {
+ super(obj);
+
+ this.name = this.constructor.name;
+ this.status = "webdriver error";
+ this.data = data;
+
+ // Error's ctor does not preserve x' stack
+ if (error.isError(obj)) {
+ this.stack = obj.stack;
+ }
+
+ if (error.isWebDriverError(obj)) {
+ this.message = obj.message;
+ this.data = obj.data;
+ }
+ }
+
+ /**
+ * @returns {Object<string, string>}
+ * JSON serialisation of error prototype.
+ */
+ toJSON() {
+ const result = {
+ error: this.status,
+ message: this.message || "",
+ stacktrace: this.stack || "",
+ };
+
+ // Only add the field if additional data has been specified.
+ if (Object.keys(this.data).length) {
+ result.data = this.data;
+ }
+
+ return result;
+ }
+
+ /**
+ * Unmarshals a JSON error representation to the appropriate Marionette
+ * error type.
+ *
+ * @param {Object<string, string>} json
+ * Error object.
+ *
+ * @returns {Error}
+ * Error prototype.
+ */
+ static fromJSON(json) {
+ if (typeof json.error == "undefined") {
+ let s = JSON.stringify(json);
+ throw new TypeError("Undeserialisable error type: " + s);
+ }
+ if (!STATUSES.has(json.error)) {
+ throw new TypeError("Not of WebDriverError 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;
+ }
+ if ("data" in json) {
+ err.data = json.data;
+ }
+
+ return err;
+ }
+}
+
+/**
+ * The Gecko a11y API indicates that the element is not accessible.
+ *
+ * @param {string=} message
+ * Optional string describing error situation.
+ * @param {object=} data
+ * Additional error data helpful in diagnosing the error.
+ */
+class ElementNotAccessibleError extends WebDriverError {
+ constructor(message, data = {}) {
+ super(message, data);
+ this.status = "element not accessible";
+ }
+}
+
+/**
+ * An element click could not be completed because the element receiving
+ * the events is obscuring the element that was requested clicked.
+ *
+ * @param {string=} message
+ * Optional string describing error situation. Will be replaced if both
+ * `data.obscuredEl` and `data.coords` are provided.
+ * @param {object=} data
+ * Additional error data helpful in diagnosing the error.
+ * @param {Element=} obscuredEl
+ * Element obscuring the element receiving the click. Providing this
+ * is not required, but will produce a nicer error message.
+ * @param {Map.<string, number>=} coords
+ * Original click location. Providing this is not required, but
+ * will produce a nicer error message.
+ */
+class ElementClickInterceptedError extends WebDriverError {
+ constructor(message, data = {}, obscuredEl = undefined, coords = undefined) {
+ let obscuredElDetails = null;
+ let overlayingElDetails = null;
+
+ if (obscuredEl && coords) {
+ const doc = obscuredEl.ownerDocument;
+ const overlayingEl = doc.elementFromPoint(coords.x, coords.y);
+
+ obscuredElDetails = lazy.pprint`${obscuredEl}`;
+ overlayingElDetails = lazy.pprint`${overlayingEl}`;
+
+ switch (obscuredEl.style.pointerEvents) {
+ case "none":
+ message =
+ `Element ${obscuredElDetails} is not clickable ` +
+ `at point (${coords.x},${coords.y}) ` +
+ `because it does not have pointer events enabled, ` +
+ `and element ${overlayingElDetails} ` +
+ `would receive the click instead`;
+ break;
+
+ default:
+ message =
+ `Element ${obscuredElDetails} is not clickable ` +
+ `at point (${coords.x},${coords.y}) ` +
+ `because another element ${overlayingElDetails} ` +
+ `obscures it`;
+ break;
+ }
+ }
+
+ if (coords) {
+ data.coords = coords;
+ }
+ if (obscuredElDetails) {
+ data.obscuredElement = obscuredElDetails;
+ }
+ if (overlayingElDetails) {
+ data.overlayingElement = overlayingElDetails;
+ }
+
+ super(message, data);
+ this.status = "element click intercepted";
+ }
+}
+
+/**
+ * A command could not be completed because the element is not pointer-
+ * or keyboard interactable.
+ *
+ * @param {string=} message
+ * Optional string describing error situation.
+ * @param {object=} data
+ * Additional error data helpful in diagnosing the error.
+ */
+class ElementNotInteractableError extends WebDriverError {
+ constructor(message, data = {}) {
+ super(message, data);
+ this.status = "element not interactable";
+ }
+}
+
+/**
+ * Navigation caused the user agent to hit a certificate warning, which
+ * is usually the result of an expired or invalid TLS certificate.
+ *
+ * @param {string=} message
+ * Optional string describing error situation.
+ * @param {object=} data
+ * Additional error data helpful in diagnosing the error.
+ */
+class InsecureCertificateError extends WebDriverError {
+ constructor(message, data = {}) {
+ super(message, data);
+ this.status = "insecure certificate";
+ }
+}
+
+/**
+ * The arguments passed to a command are either invalid or malformed.
+ *
+ * @param {string=} message
+ * Optional string describing error situation.
+ * @param {object=} data
+ * Additional error data helpful in diagnosing the error.
+ */
+class InvalidArgumentError extends WebDriverError {
+ constructor(message, data = {}) {
+ super(message, data);
+ this.status = "invalid argument";
+ }
+}
+
+/**
+ * An illegal attempt was made to set a cookie under a different
+ * domain than the current page.
+ *
+ * @param {string=} message
+ * Optional string describing error situation.
+ * @param {object=} data
+ * Additional error data helpful in diagnosing the error.
+ */
+class InvalidCookieDomainError extends WebDriverError {
+ constructor(message, data = {}) {
+ super(message, data);
+ this.status = "invalid cookie domain";
+ }
+}
+
+/**
+ * A command could not be completed because the element is in an
+ * invalid state, e.g. attempting to clear an element that isn't both
+ * editable and resettable.
+ *
+ * @param {string=} message
+ * Optional string describing error situation.
+ * @param {object=} data
+ * Additional error data helpful in diagnosing the error.
+ */
+class InvalidElementStateError extends WebDriverError {
+ constructor(message, data = {}) {
+ super(message, data);
+ this.status = "invalid element state";
+ }
+}
+
+/**
+ * Argument was an invalid selector.
+ *
+ * @param {string=} message
+ * Optional string describing error situation.
+ * @param {object=} data
+ * Additional error data helpful in diagnosing the error.
+ */
+class InvalidSelectorError extends WebDriverError {
+ constructor(message, data = {}) {
+ super(message, data);
+ this.status = "invalid selector";
+ }
+}
+
+/**
+ * Occurs if the given session ID is not in the list of active sessions,
+ * meaning the session either does not exist or that it's not active.
+ *
+ * @param {string=} message
+ * Optional string describing error situation.
+ * @param {object=} data
+ * Additional error data helpful in diagnosing the error.
+ */
+class InvalidSessionIDError extends WebDriverError {
+ constructor(message, data = {}) {
+ super(message, data);
+ this.status = "invalid session id";
+ }
+}
+
+/**
+ * An error occurred whilst executing JavaScript supplied by the user.
+ *
+ * @param {string=} message
+ * Optional string describing error situation.
+ * @param {object=} data
+ * Additional error data helpful in diagnosing the error.
+ */
+class JavaScriptError extends WebDriverError {
+ constructor(message, data = {}) {
+ super(message, data);
+ this.status = "javascript error";
+ }
+}
+
+/**
+ * The target for mouse interaction is not in the browser's viewport
+ * and cannot be brought into that viewport.
+ *
+ * @param {string=} message
+ * Optional string describing error situation.
+ * @param {object=} data
+ * Additional error data helpful in diagnosing the error.
+ */
+class MoveTargetOutOfBoundsError extends WebDriverError {
+ constructor(message, data = {}) {
+ super(message, data);
+ this.status = "move target out of bounds";
+ }
+}
+
+/**
+ * An attempt was made to operate on a modal dialog when one was
+ * not open.
+ *
+ * @param {string=} message
+ * Optional string describing error situation.
+ * @param {object=} data
+ * Additional error data helpful in diagnosing the error.
+ */
+class NoSuchAlertError extends WebDriverError {
+ constructor(message, data = {}) {
+ super(message, data);
+ this.status = "no such alert";
+ }
+}
+
+/**
+ * An element could not be located on the page using the given
+ * search parameters.
+ *
+ * @param {string=} message
+ * Optional string describing error situation.
+ * @param {object=} data
+ * Additional error data helpful in diagnosing the error.
+ */
+class NoSuchElementError extends WebDriverError {
+ constructor(message, data = {}) {
+ super(message, data);
+ this.status = "no such element";
+ }
+}
+
+/**
+ * A command tried to remove an unknown preload script.
+ *
+ * @param {string=} message
+ * Optional string describing error situation.
+ * @param {object=} data
+ * Additional error data helpful in diagnosing the error.
+ */
+class NoSuchScriptError extends WebDriverError {
+ constructor(message, data = {}) {
+ super(message, data);
+ this.status = "no such script";
+ }
+}
+
+/**
+ * A shadow root was not attached to the element.
+ *
+ * @param {string=} message
+ * Optional string describing error situation.
+ * @param {object=} data
+ * Additional error data helpful in diagnosing the error.
+ */
+class NoSuchShadowRootError extends WebDriverError {
+ constructor(message, data = {}) {
+ super(message, data);
+ this.status = "no such shadow root";
+ }
+}
+
+/**
+ * A shadow root is no longer attached to the document.
+ *
+ * @param {string=} message
+ * Optional string describing error situation.
+ * @param {object=} data
+ * Additional error data helpful in diagnosing the error.
+ */
+class DetachedShadowRootError extends WebDriverError {
+ constructor(message, data = {}) {
+ super(message, data);
+ this.status = "detached shadow root";
+ }
+}
+
+/**
+ * A command to switch to a frame could not be satisfied because
+ * the frame could not be found.
+ *
+ * @param {string=} message
+ * Optional string describing error situation.
+ * @param {object=} data
+ * Additional error data helpful in diagnosing the error.
+ */
+class NoSuchFrameError extends WebDriverError {
+ constructor(message, data = {}) {
+ super(message, data);
+ this.status = "no such frame";
+ }
+}
+
+/**
+ * The handle of a strong object reference could not be found.
+ *
+ * @param {string=} message
+ * Optional string describing error situation.
+ * @param {object=} data
+ * Additional error data helpful in diagnosing the error.
+ */
+class NoSuchHandleError extends WebDriverError {
+ constructor(message, data = {}) {
+ super(message, data);
+ this.status = "no such handle";
+ }
+}
+
+/**
+ * The entry of the history could not be found.
+ *
+ * @param {string=} message
+ * Optional string describing error situation.
+ * @param {object=} data
+ * Additional error data helpful in diagnosing the error.
+ */
+class NoSuchHistoryEntryError extends WebDriverError {
+ constructor(message, data = {}) {
+ super(message, data);
+ this.status = "no such history entry";
+ }
+}
+
+/**
+ * Tried to remove an unknown network intercept.
+ *
+ * @param {string=} message
+ * Optional string describing error situation.
+ * @param {object=} data
+ * Additional error data helpful in diagnosing the error.
+ */
+class NoSuchInterceptError extends WebDriverError {
+ constructor(message, data = {}) {
+ super(message, data);
+ this.status = "no such intercept";
+ }
+}
+
+/**
+ * A node as given by its unique shared id could not be found within the cache
+ * of known nodes.
+ *
+ * @param {string=} message
+ * Optional string describing error situation.
+ * @param {object=} data
+ * Additional error data helpful in diagnosing the error.
+ */
+class NoSuchNodeError extends WebDriverError {
+ constructor(message, data = {}) {
+ super(message, data);
+ this.status = "no such node";
+ }
+}
+
+/**
+ * Tried to continue an unknown request.
+ *
+ * @param {string=} message
+ * Optional string describing error situation.
+ * @param {object=} data
+ * Additional error data helpful in diagnosing the error.
+ */
+class NoSuchRequestError extends WebDriverError {
+ constructor(message, data = {}) {
+ super(message, data);
+ this.status = "no such request";
+ }
+}
+
+/**
+ * A command tried to reference an unknown user context (containers in Firefox).
+ *
+ * @param {string=} message
+ * Optional string describing error situation.
+ * @param {object=} data
+ * Additional error data helpful in diagnosing the error.
+ */
+class NoSuchUserContextError extends WebDriverError {
+ constructor(message, data = {}) {
+ super(message, data);
+ this.status = "no such user context";
+ }
+}
+
+/**
+ * A command to switch to a window could not be satisfied because
+ * the window could not be found.
+ *
+ * @param {string=} message
+ * Optional string describing error situation.
+ * @param {object=} data
+ * Additional error data helpful in diagnosing the error.
+ */
+class NoSuchWindowError extends WebDriverError {
+ constructor(message, data = {}) {
+ super(message, data);
+ this.status = "no such window";
+ }
+}
+
+/**
+ * A script did not complete before its timeout expired.
+ *
+ * @param {string=} message
+ * Optional string describing error situation.
+ * @param {object=} data
+ * Additional error data helpful in diagnosing the error.
+ */
+class ScriptTimeoutError extends WebDriverError {
+ constructor(message, data = {}) {
+ super(message, data);
+ this.status = "script timeout";
+ }
+}
+
+/**
+ * A new session could not be created.
+ *
+ * @param {string=} message
+ * Optional string describing error situation.
+ * @param {object=} data
+ * Additional error data helpful in diagnosing the error.
+ */
+class SessionNotCreatedError extends WebDriverError {
+ constructor(message, data = {}) {
+ super(message, data);
+ this.status = "session not created";
+ }
+}
+
+/**
+ * A command failed because the referenced element is no longer
+ * attached to the DOM.
+ *
+ * @param {string=} message
+ * Optional string describing error situation.
+ * @param {object=} data
+ * Additional error data helpful in diagnosing the error.
+ */
+class StaleElementReferenceError extends WebDriverError {
+ constructor(message, options = {}) {
+ super(message, options);
+ this.status = "stale element reference";
+ }
+}
+
+/**
+ * An operation did not complete before its timeout expired.
+ *
+ * @param {string=} message
+ * Optional string describing error situation.
+ * @param {object=} data
+ * Additional error data helpful in diagnosing the error.
+ */
+class TimeoutError extends WebDriverError {
+ constructor(message, data = {}) {
+ super(message, data);
+ this.status = "timeout";
+ }
+}
+
+/**
+ * A command to set a cookie's value could not be satisfied.
+ *
+ * @param {string=} message
+ * Optional string describing error situation.
+ * @param {object=} data
+ * Additional error data helpful in diagnosing the error.
+ */
+class UnableToSetCookieError extends WebDriverError {
+ constructor(message, data = {}) {
+ super(message, data);
+ this.status = "unable to set cookie";
+ }
+}
+
+/**
+ * A command to capture a screenshot could not be satisfied.
+ *
+ * @param {string=} message
+ * Optional string describing error situation.
+ * @param {object=} data
+ * Additional error data helpful in diagnosing the error.
+ */
+class UnableToCaptureScreen extends WebDriverError {
+ constructor(message, data = {}) {
+ super(message, data);
+ this.status = "unable to capture screen";
+ }
+}
+
+/**
+ * A modal dialog was open, blocking this operation.
+ *
+ * @param {string=} message
+ * Optional string describing error situation.
+ * @param {object=} data
+ * Additional error data helpful in diagnosing the error.
+ */
+class UnexpectedAlertOpenError extends WebDriverError {
+ constructor(message, data = {}) {
+ super(message, data);
+ this.status = "unexpected alert open";
+ }
+}
+
+/**
+ * A command could not be executed because the remote end is not
+ * aware of it.
+ *
+ * @param {string=} message
+ * Optional string describing error situation.
+ * @param {object=} data
+ * Additional error data helpful in diagnosing the error.
+ */
+class UnknownCommandError extends WebDriverError {
+ constructor(message, data = {}) {
+ super(message, data);
+ this.status = "unknown command";
+ }
+}
+
+/**
+ * An unknown error occurred in the remote end while processing
+ * the command.
+ *
+ * @param {string=} message
+ * Optional string describing error situation.
+ * @param {object=} data
+ * Additional error data helpful in diagnosing the error.
+ */
+class UnknownError extends WebDriverError {
+ constructor(message, data = {}) {
+ super(message, data);
+ this.status = "unknown error";
+ }
+}
+
+/**
+ * Indicates that a command that should have executed properly
+ * cannot be supported for some reason.
+ *
+ * @param {string=} message
+ * Optional string describing error situation.
+ * @param {object=} data
+ * Additional error data helpful in diagnosing the error.
+ */
+class UnsupportedOperationError extends WebDriverError {
+ constructor(message, data = {}) {
+ super(message, data);
+ this.status = "unsupported operation";
+ }
+}
+
+const STATUSES = new Map([
+ ["detached shadow root", DetachedShadowRootError],
+ ["element click intercepted", ElementClickInterceptedError],
+ ["element not accessible", ElementNotAccessibleError],
+ ["element not interactable", ElementNotInteractableError],
+ ["insecure certificate", InsecureCertificateError],
+ ["invalid argument", InvalidArgumentError],
+ ["invalid cookie domain", InvalidCookieDomainError],
+ ["invalid element state", InvalidElementStateError],
+ ["invalid selector", InvalidSelectorError],
+ ["invalid session id", InvalidSessionIDError],
+ ["javascript error", JavaScriptError],
+ ["move target out of bounds", MoveTargetOutOfBoundsError],
+ ["no such alert", NoSuchAlertError],
+ ["no such element", NoSuchElementError],
+ ["no such frame", NoSuchFrameError],
+ ["no such handle", NoSuchHandleError],
+ ["no such history entry", NoSuchHistoryEntryError],
+ ["no such intercept", NoSuchInterceptError],
+ ["no such node", NoSuchNodeError],
+ ["no such request", NoSuchRequestError],
+ ["no such script", NoSuchScriptError],
+ ["no such shadow root", NoSuchShadowRootError],
+ ["no such user context", NoSuchUserContextError],
+ ["no such window", NoSuchWindowError],
+ ["script timeout", ScriptTimeoutError],
+ ["session not created", SessionNotCreatedError],
+ ["stale element reference", StaleElementReferenceError],
+ ["timeout", TimeoutError],
+ ["unable to capture screen", UnableToCaptureScreen],
+ ["unable to set cookie", UnableToSetCookieError],
+ ["unexpected alert open", UnexpectedAlertOpenError],
+ ["unknown command", UnknownCommandError],
+ ["unknown error", UnknownError],
+ ["unsupported operation", UnsupportedOperationError],
+ ["webdriver error", WebDriverError],
+]);
+
+// Errors must be expored on the local this scope so that the
+// EXPORTED_SYMBOLS and the ChromeUtils.import("foo") machinery sees them.
+// We could assign each error definition directly to |this|, but
+// because they are Error prototypes this would mess up their names.
+for (let cls of STATUSES.values()) {
+ error[cls.name] = cls;
+}
diff --git a/remote/shared/webdriver/KeyData.sys.mjs b/remote/shared/webdriver/KeyData.sys.mjs
new file mode 100644
index 0000000000..dc19d19f35
--- /dev/null
+++ b/remote/shared/webdriver/KeyData.sys.mjs
@@ -0,0 +1,338 @@
+/* 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 KEY_DATA = {
+ " ": { code: "Space" },
+ "!": { code: "Digit1", shifted: true },
+ "#": { code: "Digit3", shifted: true },
+ $: { code: "Digit4", shifted: true },
+ "%": { code: "Digit5", shifted: true },
+ "&": { code: "Digit7", shifted: true },
+ "'": { code: "Quote" },
+ "(": { code: "Digit9", shifted: true },
+ ")": { code: "Digit0", shifted: true },
+ "*": { code: "Digit8", shifted: true },
+ "+": { code: "Equal", shifted: true },
+ ",": { code: "Comma" },
+ "-": { code: "Minus" },
+ ".": { code: "Period" },
+ "/": { code: "Slash" },
+ 0: { code: "Digit0" },
+ 1: { code: "Digit1" },
+ 2: { code: "Digit2" },
+ 3: { code: "Digit3" },
+ 4: { code: "Digit4" },
+ 5: { code: "Digit5" },
+ 6: { code: "Digit6" },
+ 7: { code: "Digit7" },
+ 8: { code: "Digit8" },
+ 9: { code: "Digit9" },
+ ":": { code: "Semicolon", shifted: true },
+ ";": { code: "Semicolon" },
+ "<": { code: "Comma", shifted: true },
+ "=": { code: "Equal" },
+ ">": { code: "Period", shifted: true },
+ "?": { code: "Slash", shifted: true },
+ "@": { code: "Digit2", shifted: true },
+ A: { code: "KeyA", shifted: true },
+ B: { code: "KeyB", shifted: true },
+ C: { code: "KeyC", shifted: true },
+ D: { code: "KeyD", shifted: true },
+ E: { code: "KeyE", shifted: true },
+ F: { code: "KeyF", shifted: true },
+ G: { code: "KeyG", shifted: true },
+ H: { code: "KeyH", shifted: true },
+ I: { code: "KeyI", shifted: true },
+ J: { code: "KeyJ", shifted: true },
+ K: { code: "KeyK", shifted: true },
+ L: { code: "KeyL", shifted: true },
+ M: { code: "KeyM", shifted: true },
+ N: { code: "KeyN", shifted: true },
+ O: { code: "KeyO", shifted: true },
+ P: { code: "KeyP", shifted: true },
+ Q: { code: "KeyQ", shifted: true },
+ R: { code: "KeyR", shifted: true },
+ S: { code: "KeyS", shifted: true },
+ T: { code: "KeyT", shifted: true },
+ U: { code: "KeyU", shifted: true },
+ V: { code: "KeyV", shifted: true },
+ W: { code: "KeyW", shifted: true },
+ X: { code: "KeyX", shifted: true },
+ Y: { code: "KeyY", shifted: true },
+ Z: { code: "KeyZ", shifted: true },
+ "[": { code: "BracketLeft" },
+ '"': { code: "Quote", shifted: true },
+ "\\": { code: "Backslash" },
+ "]": { code: "BracketRight" },
+ "^": { code: "Digit6", shifted: true },
+ _: { code: "Minus", shifted: true },
+ "`": { code: "Backquote" },
+ a: { code: "KeyA" },
+ b: { code: "KeyB" },
+ c: { code: "KeyC" },
+ d: { code: "KeyD" },
+ e: { code: "KeyE" },
+ f: { code: "KeyF" },
+ g: { code: "KeyG" },
+ h: { code: "KeyH" },
+ i: { code: "KeyI" },
+ j: { code: "KeyJ" },
+ k: { code: "KeyK" },
+ l: { code: "KeyL" },
+ m: { code: "KeyM" },
+ n: { code: "KeyN" },
+ o: { code: "KeyO" },
+ p: { code: "KeyP" },
+ q: { code: "KeyQ" },
+ r: { code: "KeyR" },
+ s: { code: "KeyS" },
+ t: { code: "KeyT" },
+ u: { code: "KeyU" },
+ v: { code: "KeyV" },
+ w: { code: "KeyW" },
+ x: { code: "KeyX" },
+ y: { code: "KeyY" },
+ z: { code: "KeyZ" },
+ "{": { code: "BracketLeft", shifted: true },
+ "|": { code: "Backslash", shifted: true },
+ "}": { code: "BracketRight", shifted: true },
+ "~": { code: "Backquote", shifted: true },
+ "\uE000": { key: "Unidentified", printable: false },
+ "\uE001": { key: "Cancel", printable: false },
+ "\uE002": { code: "Help", key: "Help", printable: false },
+ "\uE003": { code: "Backspace", key: "Backspace", printable: false },
+ "\uE004": { code: "Tab", key: "Tab", printable: false },
+ "\uE005": { code: "", key: "Clear", printable: false },
+ "\uE006": { code: "Enter", key: "Enter", printable: false },
+ "\uE007": {
+ code: "NumpadEnter",
+ key: "Enter",
+ location: 1,
+ printable: false,
+ },
+ "\uE008": {
+ code: "ShiftLeft",
+ key: "Shift",
+ location: 1,
+ modifier: "shiftKey",
+ printable: false,
+ },
+ "\uE009": {
+ code: "ControlLeft",
+ key: "Control",
+ location: 1,
+ modifier: "ctrlKey",
+ printable: false,
+ },
+ "\uE00A": {
+ code: "AltLeft",
+ key: "Alt",
+ location: 1,
+ modifier: "altKey",
+ printable: false,
+ },
+ "\uE00B": { code: "Pause", key: "Pause", printable: false },
+ "\uE00C": { code: "Escape", key: "Escape", printable: false },
+ "\uE00D": { code: "Space", key: " ", shifted: true },
+ "\uE00E": { code: "PageUp", key: "PageUp", printable: false },
+ "\uE00F": { code: "PageDown", key: "PageDown", printable: false },
+ "\uE010": { code: "End", key: "End", printable: false },
+ "\uE011": { code: "Home", key: "Home", printable: false },
+ "\uE012": { code: "ArrowLeft", key: "ArrowLeft", printable: false },
+ "\uE013": { code: "ArrowUp", key: "ArrowUp", printable: false },
+ "\uE014": { code: "ArrowRight", key: "ArrowRight", printable: false },
+ "\uE015": { code: "ArrowDown", key: "ArrowDown", printable: false },
+ "\uE016": { code: "Insert", key: "Insert", printable: false },
+ "\uE017": { code: "Delete", key: "Delete", printable: false },
+ "\uE018": { code: "", key: ";" },
+ "\uE019": { code: "NumpadEqual", key: "=", location: 3 },
+ "\uE01A": { code: "Numpad0", key: "0", location: 3 },
+ "\uE01B": { code: "Numpad1", key: "1", location: 3 },
+ "\uE01C": { code: "Numpad2", key: "2", location: 3 },
+ "\uE01D": { code: "Numpad3", key: "3", location: 3 },
+ "\uE01E": { code: "Numpad4", key: "4", location: 3 },
+ "\uE01F": { code: "Numpad5", key: "5", location: 3 },
+ "\uE020": { code: "Numpad6", key: "6", location: 3 },
+ "\uE021": { code: "Numpad7", key: "7", location: 3 },
+ "\uE022": { code: "Numpad8", key: "8", location: 3 },
+ "\uE023": { code: "Numpad9", key: "9", location: 3 },
+ "\uE024": { code: "NumpadMultiply", key: "*", location: 3 },
+ "\uE025": { code: "NumpadAdd", key: "+", location: 3 },
+ "\uE026": { code: "NumpadComma", key: ",", location: 3 },
+ "\uE027": { code: "NumpadSubtract", key: "-", location: 3 },
+ "\uE028": { code: "NumpadDecimal", key: ".", location: 3 },
+ "\uE029": { code: "NumpadDivide", key: "/", location: 3 },
+ "\uE031": { code: "F1", key: "F1", printable: false },
+ "\uE032": { code: "F2", key: "F2", printable: false },
+ "\uE033": { code: "F3", key: "F3", printable: false },
+ "\uE034": { code: "F4", key: "F4", printable: false },
+ "\uE035": { code: "F5", key: "F5", printable: false },
+ "\uE036": { code: "F6", key: "F6", printable: false },
+ "\uE037": { code: "F7", key: "F7", printable: false },
+ "\uE038": { code: "F8", key: "F8", printable: false },
+ "\uE039": { code: "F9", key: "F9", printable: false },
+ "\uE03A": { code: "F10", key: "F10", printable: false },
+ "\uE03B": { code: "F11", key: "F11", printable: false },
+ "\uE03C": { code: "F12", key: "F12", printable: false },
+ "\uE03D": {
+ code: "MetaLeft",
+ key: "Meta",
+ location: 1,
+ modifier: "metaKey",
+ printable: false,
+ },
+ "\uE040": { code: "", key: "ZenkakuHankaku", printable: false },
+ "\uE050": {
+ code: "ShiftRight",
+ key: "Shift",
+ location: 2,
+ modifier: "shiftKey",
+ printable: false,
+ },
+ "\uE051": {
+ code: "ControlRight",
+ key: "Control",
+ location: 2,
+ modifier: "ctrlKey",
+ printable: false,
+ },
+ "\uE052": {
+ code: "AltRight",
+ key: "Alt",
+ location: 2,
+ modifier: "altKey",
+ printable: false,
+ },
+ "\uE053": {
+ code: "MetaRight",
+ key: "Meta",
+ location: 2,
+ modifier: "metaKey",
+ printable: false,
+ },
+ "\uE054": {
+ code: "Numpad9",
+ key: "PageUp",
+ location: 3,
+ printable: false,
+ shifted: true,
+ },
+ "\uE055": {
+ code: "Numpad3",
+ key: "PageDown",
+ location: 3,
+ printable: false,
+ shifted: true,
+ },
+ "\uE056": {
+ code: "Numpad1",
+ key: "End",
+ location: 3,
+ printable: false,
+ shifted: true,
+ },
+ "\uE057": {
+ code: "Numpad7",
+ key: "Home",
+ location: 3,
+ printable: false,
+ shifted: true,
+ },
+ "\uE058": {
+ code: "Numpad4",
+ key: "ArrowLeft",
+ location: 3,
+ printable: false,
+ shifted: true,
+ },
+ "\uE059": {
+ code: "Numpad8",
+ key: "ArrowUp",
+ location: 3,
+ printable: false,
+ shifted: true,
+ },
+ "\uE05A": {
+ code: "Numpad6",
+ key: "ArrowRight",
+ location: 3,
+ printable: false,
+ shifted: true,
+ },
+ "\uE05B": {
+ code: "Numpad2",
+ key: "ArrowDown",
+ location: 3,
+ printable: false,
+ shifted: true,
+ },
+ "\uE05C": {
+ code: "Numpad0",
+ key: "Insert",
+ location: 3,
+ printable: false,
+ shifted: true,
+ },
+ "\uE05D": {
+ code: "NumpadDecimal",
+ key: "Delete",
+ location: 3,
+ printable: false,
+ shifted: true,
+ },
+};
+
+const lazy = {};
+
+ChromeUtils.defineLazyGetter(lazy, "SHIFT_DATA", () => {
+ // Initalize the shift mapping
+ const shiftData = new Map();
+ const byCode = new Map();
+ for (let [key, props] of Object.entries(KEY_DATA)) {
+ if (props.code) {
+ if (!byCode.has(props.code)) {
+ byCode.set(props.code, [null, null]);
+ }
+ byCode.get(props.code)[props.shifted ? 1 : 0] = key;
+ }
+ }
+ for (let [unshifted, shifted] of byCode.values()) {
+ if (unshifted !== null && shifted !== null) {
+ shiftData.set(unshifted, shifted);
+ }
+ }
+ return shiftData;
+});
+
+export const keyData = {
+ /**
+ * Get key event data for a given key character.
+ *
+ * @param {string} rawKey
+ * Key for which to get data. This can either be the key codepoint
+ * itself or one of the codepoints in the range U+E000-U+E05D that
+ * WebDriver uses to represent keys not corresponding directly to
+ * a codepoint.
+ * @returns {object} Key event data object.
+ */
+ getData(rawKey) {
+ let keyData = { key: rawKey, location: 0, printable: true, shifted: false };
+ if (KEY_DATA.hasOwnProperty(rawKey)) {
+ keyData = { ...keyData, ...KEY_DATA[rawKey] };
+ }
+ return keyData;
+ },
+
+ /**
+ * Get shifted key character for a given key character.
+ *
+ * For characters unaffected by the shift key, this returns the input.
+ *
+ * @param {string} rawKey Key for which to get shifted key.
+ * @returns {string} Key string to use when the shift modifier is set.
+ */
+ getShiftedKey(rawKey) {
+ return lazy.SHIFT_DATA.get(rawKey) ?? rawKey;
+ },
+};
diff --git a/remote/shared/webdriver/NodeCache.sys.mjs b/remote/shared/webdriver/NodeCache.sys.mjs
new file mode 100644
index 0000000000..032eae2543
--- /dev/null
+++ b/remote/shared/webdriver/NodeCache.sys.mjs
@@ -0,0 +1,179 @@
+/* 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, {
+ generateUUID: "chrome://remote/content/shared/UUID.sys.mjs",
+});
+
+/**
+ * @typedef {object} NodeReferenceDetails
+ * @property {number} browserId
+ * @property {number} browsingContextGroupId
+ * @property {number} browsingContextId
+ * @property {boolean} isTopBrowsingContext
+ * @property {WeakRef} nodeWeakRef
+ */
+
+/**
+ * The class provides a mapping between DOM nodes and a unique node references.
+ * Supported types of nodes are Element and ShadowRoot.
+ */
+export class NodeCache {
+ #nodeIdMap;
+ #seenNodesMap;
+
+ constructor() {
+ // node => node id
+ this.#nodeIdMap = new WeakMap();
+
+ // Reverse map for faster lookup requests of node references. Values do
+ // not only contain the resolved DOM node but also further details like
+ // browsing context information.
+ //
+ // node id => node details
+ this.#seenNodesMap = new Map();
+ }
+
+ /**
+ * Get the number of nodes in the cache.
+ */
+ get size() {
+ return this.#seenNodesMap.size;
+ }
+
+ /**
+ * Get or if not yet existent create a unique reference for an Element or
+ * ShadowRoot node.
+ *
+ * @param {Node} node
+ * The node to be added.
+ * @param {Map<BrowsingContext, Array<string>>} seenNodeIds
+ * Map of browsing contexts to their seen node ids during the current
+ * serialization.
+ *
+ * @returns {string}
+ * The unique node reference for the DOM node.
+ */
+ getOrCreateNodeReference(node, seenNodeIds) {
+ if (!Node.isInstance(node)) {
+ throw new TypeError(`Failed to create node reference for ${node}`);
+ }
+
+ let nodeId;
+ if (this.#nodeIdMap.has(node)) {
+ // For already known nodes return the cached node id.
+ nodeId = this.#nodeIdMap.get(node);
+ } else {
+ // Bug 1820734: For some Node types like `CDATA` no `ownerGlobal`
+ // property is available, and as such they cannot be deserialized
+ // right now.
+ const browsingContext = node.ownerGlobal?.browsingContext;
+
+ // For not yet cached nodes generate a unique id without curly braces.
+ nodeId = lazy.generateUUID();
+
+ const details = {
+ browserId: browsingContext?.browserId,
+ browsingContextGroupId: browsingContext?.group.id,
+ browsingContextId: browsingContext?.id,
+ isTopBrowsingContext: browsingContext?.parent === null,
+ nodeWeakRef: Cu.getWeakReference(node),
+ };
+
+ this.#nodeIdMap.set(node, nodeId);
+ this.#seenNodesMap.set(nodeId, details);
+
+ // Also add the information for the node id and its correlated browsing
+ // context to allow the parent process to update the seen nodes.
+ if (!seenNodeIds.has(browsingContext)) {
+ seenNodeIds.set(browsingContext, []);
+ }
+ seenNodeIds.get(browsingContext).push(nodeId);
+ }
+
+ return nodeId;
+ }
+
+ /**
+ * Clear known DOM nodes.
+ *
+ * @param {object=} options
+ * @param {boolean=} options.all
+ * Clear all references from any browsing context. Defaults to false.
+ * @param {BrowsingContext=} options.browsingContext
+ * Clear all references living in that browsing context.
+ */
+ clear(options = {}) {
+ const { all = false, browsingContext } = options;
+
+ if (all) {
+ this.#nodeIdMap = new WeakMap();
+ this.#seenNodesMap.clear();
+ return;
+ }
+
+ if (browsingContext) {
+ for (const [nodeId, identifier] of this.#seenNodesMap.entries()) {
+ const { browsingContextId, nodeWeakRef } = identifier;
+ const node = nodeWeakRef.get();
+
+ if (browsingContextId === browsingContext.id) {
+ this.#nodeIdMap.delete(node);
+ this.#seenNodesMap.delete(nodeId);
+ }
+ }
+
+ return;
+ }
+
+ throw new Error(`Requires "browsingContext" or "all" to be set.`);
+ }
+
+ /**
+ * Get a DOM node by its unique reference.
+ *
+ * @param {BrowsingContext} browsingContext
+ * The browsing context the node should be part of.
+ * @param {string} nodeId
+ * The unique node reference of the DOM node.
+ *
+ * @returns {Node|null}
+ * The DOM node that the unique identifier was generated for or
+ * `null` if the node does not exist anymore.
+ */
+ getNode(browsingContext, nodeId) {
+ const nodeDetails = this.getReferenceDetails(nodeId);
+
+ // Check that the node reference is known, and is associated with a
+ // browsing context that shares the same browsing context group.
+ if (
+ nodeDetails === null ||
+ nodeDetails.browsingContextGroupId !== browsingContext.group.id
+ ) {
+ return null;
+ }
+
+ if (nodeDetails.nodeWeakRef) {
+ return nodeDetails.nodeWeakRef.get();
+ }
+
+ return null;
+ }
+
+ /**
+ * Get detailed information for the node reference.
+ *
+ * @param {string} nodeId
+ *
+ * @returns {NodeReferenceDetails}
+ * Node details like: browsingContextId
+ */
+ getReferenceDetails(nodeId) {
+ const details = this.#seenNodesMap.get(nodeId);
+
+ return details !== undefined ? details : null;
+ }
+}
diff --git a/remote/shared/webdriver/Session.sys.mjs b/remote/shared/webdriver/Session.sys.mjs
new file mode 100644
index 0000000000..edffeea7b6
--- /dev/null
+++ b/remote/shared/webdriver/Session.sys.mjs
@@ -0,0 +1,418 @@
+/* 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, {
+ accessibility: "chrome://remote/content/marionette/accessibility.sys.mjs",
+ allowAllCerts: "chrome://remote/content/marionette/cert.sys.mjs",
+ Capabilities: "chrome://remote/content/shared/webdriver/Capabilities.sys.mjs",
+ error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
+ generateUUID: "chrome://remote/content/shared/UUID.sys.mjs",
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+ registerProcessDataActor:
+ "chrome://remote/content/shared/webdriver/process-actors/WebDriverProcessDataParent.sys.mjs",
+ RootMessageHandler:
+ "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs",
+ RootMessageHandlerRegistry:
+ "chrome://remote/content/shared/messagehandler/RootMessageHandlerRegistry.sys.mjs",
+ TabManager: "chrome://remote/content/shared/TabManager.sys.mjs",
+ unregisterProcessDataActor:
+ "chrome://remote/content/shared/webdriver/process-actors/WebDriverProcessDataParent.sys.mjs",
+ WebDriverBiDiConnection:
+ "chrome://remote/content/webdriver-bidi/WebDriverBiDiConnection.sys.mjs",
+ WebSocketHandshake:
+ "chrome://remote/content/server/WebSocketHandshake.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
+
+// Global singleton that holds active WebDriver sessions
+const webDriverSessions = new Map();
+
+/**
+ * Representation of WebDriver session.
+ */
+export class WebDriverSession {
+ /**
+ * Construct a new WebDriver session.
+ *
+ * It is expected that the caller performs the necessary checks on
+ * the requested capabilities to be WebDriver conforming. The WebDriver
+ * service offered by Marionette does not match or negotiate capabilities
+ * beyond type- and bounds checks.
+ *
+ * <h3>Capabilities</h3>
+ *
+ * <dl>
+ * <dt><code>acceptInsecureCerts</code> (boolean)
+ * <dd>Indicates whether untrusted and self-signed TLS certificates
+ * are implicitly trusted on navigation for the duration of the session.
+ *
+ * <dt><code>pageLoadStrategy</code> (string)
+ * <dd>The page load strategy to use for the current session. Must be
+ * one of "<tt>none</tt>", "<tt>eager</tt>", and "<tt>normal</tt>".
+ *
+ * <dt><code>proxy</code> (Proxy object)
+ * <dd>Defines the proxy configuration.
+ *
+ * <dt><code>setWindowRect</code> (boolean)
+ * <dd>Indicates whether the remote end supports all of the resizing
+ * and repositioning commands.
+ *
+ * <dt><code>timeouts</code> (Timeouts object)
+ * <dd>Describes the timeouts imposed on certian session operations.
+ *
+ * <dt><code>strictFileInteractability</code> (boolean)
+ * <dd>Defines the current session’s strict file interactability.
+ *
+ * <dt><code>unhandledPromptBehavior</code> (string)
+ * <dd>Describes the current session’s user prompt handler. Must be one of
+ * "<tt>accept</tt>", "<tt>accept and notify</tt>", "<tt>dismiss</tt>",
+ * "<tt>dismiss and notify</tt>", and "<tt>ignore</tt>". Defaults to the
+ * "<tt>dismiss and notify</tt>" state.
+ *
+ * <dt><code>moz:accessibilityChecks</code> (boolean)
+ * <dd>Run a11y checks when clicking elements.
+ *
+ * <dt><code>moz:debuggerAddress</code> (boolean)
+ * <dd>Indicate that the Chrome DevTools Protocol (CDP) has to be enabled.
+ *
+ * <dt><code>moz:webdriverClick</code> (boolean)
+ * <dd>Use a WebDriver conforming <i>WebDriver::ElementClick</i>.
+ * </dl>
+ *
+ * <h4>WebAuthn</h4>
+ *
+ * <dl>
+ * <dt><code>webauthn:virtualAuthenticators</code> (boolean)
+ * <dd>Indicates whether the endpoint node supports all Virtual
+ * Authenticators commands.
+ *
+ * <dt><code>webauthn:extension:uvm</code> (boolean)
+ * <dd>Indicates whether the endpoint node WebAuthn WebDriver
+ * implementation supports the User Verification Method extension.
+ *
+ * <dt><code>webauthn:extension:prf</code> (boolean)
+ * <dd>Indicates whether the endpoint node WebAuthn WebDriver
+ * implementation supports the prf extension.
+ *
+ * <dt><code>webauthn:extension:largeBlob</code> (boolean)
+ * <dd>Indicates whether the endpoint node WebAuthn WebDriver implementation
+ * supports the largeBlob extension.
+ *
+ * <dt><code>webauthn:extension:credBlob</code> (boolean)
+ * <dd>Indicates whether the endpoint node WebAuthn WebDriver implementation
+ * supports the credBlob extension.
+ * </dl>
+ *
+ * <h4>Timeouts object</h4>
+ *
+ * <dl>
+ * <dt><code>script</code> (number)
+ * <dd>Determines when to interrupt a script that is being evaluates.
+ *
+ * <dt><code>pageLoad</code> (number)
+ * <dd>Provides the timeout limit used to interrupt navigation of the
+ * browsing context.
+ *
+ * <dt><code>implicit</code> (number)
+ * <dd>Gives the timeout of when to abort when locating an element.
+ * </dl>
+ *
+ * <h4>Proxy object</h4>
+ *
+ * <dl>
+ * <dt><code>proxyType</code> (string)
+ * <dd>Indicates the type of proxy configuration. Must be one
+ * of "<tt>pac</tt>", "<tt>direct</tt>", "<tt>autodetect</tt>",
+ * "<tt>system</tt>", or "<tt>manual</tt>".
+ *
+ * <dt><code>proxyAutoconfigUrl</code> (string)
+ * <dd>Defines the URL for a proxy auto-config file if
+ * <code>proxyType</code> is equal to "<tt>pac</tt>".
+ *
+ * <dt><code>httpProxy</code> (string)
+ * <dd>Defines the proxy host for HTTP traffic when the
+ * <code>proxyType</code> is "<tt>manual</tt>".
+ *
+ * <dt><code>noProxy</code> (string)
+ * <dd>Lists the adress for which the proxy should be bypassed when
+ * the <code>proxyType</code> is "<tt>manual</tt>". Must be a JSON
+ * List containing any number of any of domains, IPv4 addresses, or IPv6
+ * addresses.
+ *
+ * <dt><code>sslProxy</code> (string)
+ * <dd>Defines the proxy host for encrypted TLS traffic when the
+ * <code>proxyType</code> is "<tt>manual</tt>".
+ *
+ * <dt><code>socksProxy</code> (string)
+ * <dd>Defines the proxy host for a SOCKS proxy traffic when the
+ * <code>proxyType</code> is "<tt>manual</tt>".
+ *
+ * <dt><code>socksVersion</code> (string)
+ * <dd>Defines the SOCKS proxy version when the <code>proxyType</code> is
+ * "<tt>manual</tt>". It must be any integer between 0 and 255
+ * inclusive.
+ * </dl>
+ *
+ * <h3>Example</h3>
+ *
+ * Input:
+ *
+ * <pre><code>
+ * {"capabilities": {"acceptInsecureCerts": true}}
+ * </code></pre>
+ *
+ * @param {Object<string, *>=} capabilities
+ * JSON Object containing any of the recognised capabilities listed
+ * above.
+ *
+ * @param {WebDriverBiDiConnection=} connection
+ * An optional existing WebDriver BiDi connection to associate with the
+ * new session.
+ *
+ * @throws {SessionNotCreatedError}
+ * If, for whatever reason, a session could not be created.
+ */
+ constructor(capabilities, connection) {
+ // WebSocket connections that use this session. This also accounts for
+ // possible disconnects due to network outages, which require clients
+ // to reconnect.
+ this._connections = new Set();
+
+ this.id = lazy.generateUUID();
+
+ // Define the HTTP path to query this session via WebDriver BiDi
+ this.path = `/session/${this.id}`;
+
+ try {
+ this.capabilities = lazy.Capabilities.fromJSON(capabilities, this.path);
+ } catch (e) {
+ throw new lazy.error.SessionNotCreatedError(e);
+ }
+
+ if (this.capabilities.get("acceptInsecureCerts")) {
+ lazy.logger.warn(
+ "TLS certificate errors will be ignored for this session"
+ );
+ lazy.allowAllCerts.enable();
+ }
+
+ if (this.proxy.init()) {
+ lazy.logger.info(
+ `Proxy settings initialised: ${JSON.stringify(this.proxy)}`
+ );
+ }
+
+ // If we are testing accessibility with marionette, start a11y service in
+ // chrome first. This will ensure that we do not have any content-only
+ // services hanging around.
+ if (this.a11yChecks && lazy.accessibility.service) {
+ lazy.logger.info("Preemptively starting accessibility service in Chrome");
+ }
+
+ // If a connection without an associated session has been specified
+ // immediately register the newly created session for it.
+ if (connection) {
+ connection.registerSession(this);
+ this._connections.add(connection);
+ }
+
+ // Maps a Navigable (browsing context or content browser for top-level
+ // browsing contexts) to a Set of nodeId's.
+ this.navigableSeenNodes = new WeakMap();
+
+ lazy.registerProcessDataActor();
+
+ webDriverSessions.set(this.id, this);
+ }
+
+ destroy() {
+ webDriverSessions.delete(this.id);
+
+ lazy.unregisterProcessDataActor();
+
+ this.navigableSeenNodes = null;
+
+ lazy.allowAllCerts.disable();
+
+ // Close all open connections which unregister themselves.
+ this._connections.forEach(connection => connection.close());
+ if (this._connections.size > 0) {
+ lazy.logger.warn(
+ `Failed to close ${this._connections.size} WebSocket connections`
+ );
+ }
+
+ // Destroy the dedicated MessageHandler instance if we created one.
+ if (this._messageHandler) {
+ this._messageHandler.off(
+ "message-handler-protocol-event",
+ this._onMessageHandlerProtocolEvent
+ );
+ this._messageHandler.destroy();
+ }
+ }
+
+ async execute(module, command, params) {
+ // XXX: At the moment, commands do not describe consistently their destination,
+ // so we will need a translation step based on a specific command and its params
+ // in order to extract a destination that can be understood by the MessageHandler.
+ //
+ // For now, an option is to send all commands to ROOT, and all BiDi MessageHandler
+ // modules will therefore need to implement this translation step in the root
+ // implementation of their module.
+ const destination = {
+ type: lazy.RootMessageHandler.type,
+ };
+ if (!this.messageHandler.supportsCommand(module, command, destination)) {
+ throw new lazy.error.UnknownCommandError(`${module}.${command}`);
+ }
+
+ return this.messageHandler.handleCommand({
+ moduleName: module,
+ commandName: command,
+ params,
+ destination,
+ });
+ }
+
+ get a11yChecks() {
+ return this.capabilities.get("moz:accessibilityChecks");
+ }
+
+ get messageHandler() {
+ if (!this._messageHandler) {
+ this._messageHandler =
+ lazy.RootMessageHandlerRegistry.getOrCreateMessageHandler(this.id);
+ this._onMessageHandlerProtocolEvent =
+ this._onMessageHandlerProtocolEvent.bind(this);
+ this._messageHandler.on(
+ "message-handler-protocol-event",
+ this._onMessageHandlerProtocolEvent
+ );
+ }
+
+ return this._messageHandler;
+ }
+
+ get pageLoadStrategy() {
+ return this.capabilities.get("pageLoadStrategy");
+ }
+
+ get proxy() {
+ return this.capabilities.get("proxy");
+ }
+
+ get strictFileInteractability() {
+ return this.capabilities.get("strictFileInteractability");
+ }
+
+ get timeouts() {
+ return this.capabilities.get("timeouts");
+ }
+
+ set timeouts(timeouts) {
+ this.capabilities.set("timeouts", timeouts);
+ }
+
+ get unhandledPromptBehavior() {
+ return this.capabilities.get("unhandledPromptBehavior");
+ }
+
+ /**
+ * Remove the specified WebDriver BiDi connection.
+ *
+ * @param {WebDriverBiDiConnection} connection
+ */
+ removeConnection(connection) {
+ if (this._connections.has(connection)) {
+ this._connections.delete(connection);
+ } else {
+ lazy.logger.warn("Trying to remove a connection that doesn't exist.");
+ }
+ }
+
+ toString() {
+ return `[object ${this.constructor.name} ${this.id}]`;
+ }
+
+ // nsIHttpRequestHandler
+
+ /**
+ * Handle new WebSocket connection requests.
+ *
+ * WebSocket clients will attempt to connect to this session at
+ * `/session/:id`. Hereby a WebSocket upgrade will automatically
+ * be performed.
+ *
+ * @param {Request} request
+ * HTTP request (httpd.js)
+ * @param {Response} response
+ * Response to an HTTP request (httpd.js)
+ */
+ async handle(request, response) {
+ const webSocket = await lazy.WebSocketHandshake.upgrade(request, response);
+ const conn = new lazy.WebDriverBiDiConnection(
+ webSocket,
+ response._connection
+ );
+ conn.registerSession(this);
+ this._connections.add(conn);
+ }
+
+ _onMessageHandlerProtocolEvent(eventName, messageHandlerEvent) {
+ const { name, data } = messageHandlerEvent;
+ this._connections.forEach(connection => connection.sendEvent(name, data));
+ }
+
+ // XPCOM
+
+ get QueryInterface() {
+ return ChromeUtils.generateQI(["nsIHttpRequestHandler"]);
+ }
+}
+
+/**
+ * Get the list of seen nodes for the given browsing context unique to a
+ * WebDriver session.
+ *
+ * @param {string} sessionId
+ * The id of the WebDriver session to use.
+ * @param {BrowsingContext} browsingContext
+ * Browsing context the node is part of.
+ *
+ * @returns {Set}
+ * The list of seen nodes.
+ */
+export function getSeenNodesForBrowsingContext(sessionId, browsingContext) {
+ if (!lazy.TabManager.isValidCanonicalBrowsingContext(browsingContext)) {
+ // If browsingContext is not a valid Browsing Context, return an empty set.
+ return new Set();
+ }
+
+ const navigable =
+ lazy.TabManager.getNavigableForBrowsingContext(browsingContext);
+ const session = getWebDriverSessionById(sessionId);
+
+ if (!session.navigableSeenNodes.has(navigable)) {
+ // The navigable hasn't been seen yet.
+ session.navigableSeenNodes.set(navigable, new Set());
+ }
+
+ return session.navigableSeenNodes.get(navigable);
+}
+
+/**
+ *
+ * @param {string} sessionId
+ * The ID of the WebDriver session to retrieve.
+ *
+ * @returns {WebDriverSession|undefined}
+ * The WebDriver session or undefined if the id is not known.
+ */
+export function getWebDriverSessionById(sessionId) {
+ return webDriverSessions.get(sessionId);
+}
diff --git a/remote/shared/webdriver/URLPattern.sys.mjs b/remote/shared/webdriver/URLPattern.sys.mjs
new file mode 100644
index 0000000000..0033cced66
--- /dev/null
+++ b/remote/shared/webdriver/URLPattern.sys.mjs
@@ -0,0 +1,521 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs",
+ error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
+});
+
+/**
+ * Parsed pattern to use for URL matching.
+ *
+ * @typedef {object} ParsedURLPattern
+ * @property {string|null} protocol
+ * The protocol, for instance "https".
+ * @property {string|null} hostname
+ * The hostname, for instance "example.com".
+ * @property {string|null} port
+ * The serialized port. Empty string for default ports of special schemes.
+ * @property {string|null} path
+ * The path, starting with "/".
+ * @property {string|null} search
+ * The search query string, without the leading "?"
+ */
+
+/**
+ * Subset of properties extracted from a parsed URL.
+ *
+ * @typedef {object} ParsedURL
+ * @property {string=} host
+ * @property {string|Array<string>} path
+ * Either a string if the path is an opaque path, or an array of strings
+ * (path segments).
+ * @property {number=} port
+ * @property {string=} query
+ * @property {string=} scheme
+ */
+
+/**
+ * Enum of URLPattern types.
+ *
+ * @readonly
+ * @enum {URLPatternType}
+ */
+const URLPatternType = {
+ Pattern: "pattern",
+ String: "string",
+};
+
+const supportedURLPatternTypes = Object.values(URLPatternType);
+
+const SPECIAL_SCHEMES = ["file", "http", "https", "ws", "wss"];
+const DEFAULT_PORTS = {
+ file: null,
+ http: 80,
+ https: 443,
+ ws: 80,
+ wss: 443,
+};
+
+/**
+ * Check if a given URL pattern is compatible with the provided URL.
+ *
+ * Implements https://w3c.github.io/webdriver-bidi/#match-url-pattern
+ *
+ * @param {ParsedURLPattern} urlPattern
+ * The URL pattern to match.
+ * @param {string} url
+ * The string representation of a URL to test against the pattern.
+ *
+ * @returns {boolean}
+ * True if the pattern is compatible with the provided URL, false otherwise.
+ */
+export function matchURLPattern(urlPattern, url) {
+ const parsedURL = parseURL(url);
+
+ if (urlPattern.protocol !== null && urlPattern.protocol != parsedURL.scheme) {
+ return false;
+ }
+
+ if (urlPattern.hostname !== null && urlPattern.hostname != parsedURL.host) {
+ return false;
+ }
+
+ if (urlPattern.port !== null && urlPattern.port != serializePort(parsedURL)) {
+ return false;
+ }
+
+ if (
+ urlPattern.pathname !== null &&
+ urlPattern.pathname != serializePath(parsedURL)
+ ) {
+ return false;
+ }
+
+ if (urlPattern.search !== null) {
+ const urlQuery = parsedURL.query === null ? "" : parsedURL.query;
+ if (urlPattern.search != urlQuery) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+/**
+ * Parse a URLPattern into a parsed pattern object which can be used to match
+ * URLs using `matchURLPattern`.
+ *
+ * Implements https://w3c.github.io/webdriver-bidi/#parse-url-pattern
+ *
+ * @param {URLPattern} pattern
+ * The pattern to parse.
+ *
+ * @returns {ParsedURLPattern}
+ * The parsed URL pattern.
+ *
+ * @throws {InvalidArgumentError}
+ * Raised if an argument is of an invalid type or value.
+ * @throws {UnsupportedOperationError}
+ * Raised if the pattern uses a protocol not supported by Firefox.
+ */
+export function parseURLPattern(pattern) {
+ lazy.assert.object(
+ pattern,
+ `Expected url pattern to be an object, got ${pattern}`
+ );
+
+ let hasProtocol = true;
+ let hasHostname = true;
+ let hasPort = true;
+ let hasPathname = true;
+ let hasSearch = true;
+
+ let patternUrl;
+ switch (pattern.type) {
+ case URLPatternType.Pattern:
+ patternUrl = "";
+ if ("protocol" in pattern) {
+ patternUrl += parseProtocol(pattern.protocol);
+ } else {
+ hasProtocol = false;
+ patternUrl += "http";
+ }
+
+ const scheme = patternUrl.toLowerCase();
+ patternUrl += ":";
+ if (SPECIAL_SCHEMES.includes(scheme)) {
+ patternUrl += "//";
+ }
+
+ if ("hostname" in pattern) {
+ patternUrl += parseHostname(pattern.hostname, scheme);
+ } else {
+ if (scheme != "file") {
+ patternUrl += "placeholder";
+ }
+ hasHostname = false;
+ }
+
+ if ("port" in pattern) {
+ patternUrl += parsePort(pattern.port);
+ } else {
+ hasPort = false;
+ }
+
+ if ("pathname" in pattern) {
+ patternUrl += parsePathname(pattern.pathname);
+ } else {
+ hasPathname = false;
+ }
+
+ if ("search" in pattern) {
+ patternUrl += parseSearch(pattern.search);
+ } else {
+ hasSearch = false;
+ }
+ break;
+ case URLPatternType.String:
+ lazy.assert.string(
+ pattern.pattern,
+ `Expected "urlPattern" of type "string" to have a string "pattern" property, got ${pattern.pattern}`
+ );
+ patternUrl = unescapeUrlPattern(pattern.pattern);
+ break;
+ default:
+ throw new lazy.error.InvalidArgumentError(
+ `Expected "urlPattern" type to be one of ${supportedURLPatternTypes}, got ${pattern.type}`
+ );
+ }
+
+ if (!URL.canParse(patternUrl)) {
+ throw new lazy.error.InvalidArgumentError(
+ `Unable to parse URL "${patternUrl}"`
+ );
+ }
+
+ let parsedURL;
+ try {
+ parsedURL = parseURL(patternUrl);
+ } catch (e) {
+ throw new lazy.error.InvalidArgumentError(
+ `Failed to parse URL "${patternUrl}"`
+ );
+ }
+
+ if (hasProtocol && !SPECIAL_SCHEMES.includes(parsedURL.scheme)) {
+ throw new lazy.error.UnsupportedOperationError(
+ `URL pattern did not specify a supported protocol (one of ${SPECIAL_SCHEMES}), got ${parsedURL.scheme}`
+ );
+ }
+
+ return {
+ protocol: hasProtocol ? parsedURL.scheme : null,
+ hostname: hasHostname ? parsedURL.host : null,
+ port: hasPort ? serializePort(parsedURL) : null,
+ pathname:
+ hasPathname && parsedURL.path.length ? serializePath(parsedURL) : null,
+ search: hasSearch ? parsedURL.query || "" : null,
+ };
+}
+
+/**
+ * Parse the hostname property of a URLPatternPattern.
+ *
+ * @param {string} hostname
+ * A hostname property.
+ * @param {string} scheme
+ * The scheme for the URLPatternPattern.
+ *
+ * @returns {string}
+ * The parsed property.
+ *
+ * @throws {InvalidArgumentError}
+ * Raised if an argument is of an invalid type or value.
+ */
+function parseHostname(hostname, scheme) {
+ if (typeof hostname != "string" || hostname == "") {
+ throw new lazy.error.InvalidArgumentError(
+ `Expected URLPattern "hostname" to be a non-empty string, got ${hostname}`
+ );
+ }
+
+ if (scheme == "file") {
+ throw new lazy.error.InvalidArgumentError(
+ `URLPattern with "file" scheme cannot specify a hostname, got ${hostname}`
+ );
+ }
+
+ hostname = unescapeUrlPattern(hostname);
+
+ const forbiddenHostnameCharacters = ["/", "?", "#"];
+ let insideBrackets = false;
+ for (const codepoint of hostname) {
+ if (
+ forbiddenHostnameCharacters.includes(codepoint) ||
+ (!insideBrackets && codepoint == ":")
+ ) {
+ throw new lazy.error.InvalidArgumentError(
+ `URL pattern "hostname" contained a forbidden character, got "${hostname}"`
+ );
+ }
+
+ if (codepoint == "[") {
+ insideBrackets = true;
+ } else if (codepoint == "]") {
+ insideBrackets = false;
+ }
+ }
+
+ return hostname;
+}
+
+/**
+ * Parse the pathname property of a URLPatternPattern.
+ *
+ * @param {string} pathname
+ * A pathname property.
+ *
+ * @returns {string}
+ * The parsed property.
+ *
+ * @throws {InvalidArgumentError}
+ * Raised if an argument is of an invalid type or value.
+ */
+function parsePathname(pathname) {
+ lazy.assert.string(
+ pathname,
+ `Expected URLPattern "pathname" to be a string, got ${pathname}`
+ );
+
+ pathname = unescapeUrlPattern(pathname);
+ if (!pathname.startsWith("/")) {
+ pathname = `/${pathname}`;
+ }
+
+ if (pathname.includes("?") || pathname.includes("#")) {
+ throw new lazy.error.InvalidArgumentError(
+ `URL pattern "pathname" contained a forbidden character, got "${pathname}"`
+ );
+ }
+
+ return pathname;
+}
+
+/**
+ * Parse the port property of a URLPatternPattern.
+ *
+ * @param {string} port
+ * A port property.
+ *
+ * @returns {string}
+ * The parsed property.
+ *
+ * @throws {InvalidArgumentError}
+ * Raised if an argument is of an invalid type or value.
+ */
+function parsePort(port) {
+ if (typeof port != "string" || port == "") {
+ throw new lazy.error.InvalidArgumentError(
+ `Expected URLPattern "port" to be a non-empty string, got ${port}`
+ );
+ }
+
+ port = unescapeUrlPattern(port);
+
+ const isNumber = /^\d*$/.test(port);
+ if (!isNumber) {
+ throw new lazy.error.InvalidArgumentError(
+ `URL pattern "port" is not a valid number, got "${port}"`
+ );
+ }
+
+ return `:${port}`;
+}
+
+/**
+ * Parse the protocol property of a URLPatternPattern.
+ *
+ * @param {string} protocol
+ * A protocol property.
+ *
+ * @returns {string}
+ * The parsed property.
+ *
+ * @throws {InvalidArgumentError}
+ * Raised if an argument is of an invalid type or value.
+ */
+function parseProtocol(protocol) {
+ if (typeof protocol != "string" || protocol == "") {
+ throw new lazy.error.InvalidArgumentError(
+ `Expected URLPattern "protocol" to be a non-empty string, got ${protocol}`
+ );
+ }
+
+ protocol = unescapeUrlPattern(protocol);
+ if (!/^[a-zA-Z0-9+-.]*$/.test(protocol)) {
+ throw new lazy.error.InvalidArgumentError(
+ `URL pattern "protocol" contained a forbidden character, got "${protocol}"`
+ );
+ }
+
+ return protocol;
+}
+
+/**
+ * Parse the search property of a URLPatternPattern.
+ *
+ * @param {string} search
+ * A search property.
+ *
+ * @returns {string}
+ * The parsed property.
+ *
+ * @throws {InvalidArgumentError}
+ * Raised if an argument is of an invalid type or value.
+ */
+function parseSearch(search) {
+ lazy.assert.string(
+ search,
+ `Expected URLPattern "search" to be a string, got ${search}`
+ );
+
+ search = unescapeUrlPattern(search);
+ if (!search.startsWith("?")) {
+ search = `?${search}`;
+ }
+
+ if (search.includes("#")) {
+ throw new lazy.error.InvalidArgumentError(
+ `Expected URLPattern "search" to never contain "#", got ${search}`
+ );
+ }
+
+ return search;
+}
+
+/**
+ * Parse a string URL. This tries to be close to Basic URL Parser, however since
+ * this is not currently implemented in Firefox and URL parsing has many edge
+ * cases, it does not try to be a faithful implementation.
+ *
+ * Edge cases which are not supported are mostly about non-special URLs, which
+ * in practice should not be observable in automation.
+ *
+ * @param {string} url
+ * The string based URL to parse.
+ * @returns {ParsedURL}
+ * The parsed URL.
+ */
+function parseURL(url) {
+ const urlObj = new URL(url);
+ const uri = urlObj.URI;
+
+ return {
+ scheme: uri.scheme,
+ // Note: Use urlObj instead of uri for hostname:
+ // nsIURI removes brackets from ipv6 hostnames (eg [::1] becomes ::1).
+ host: urlObj.hostname,
+ path: uri.filePath,
+ // Note: Use urlObj instead of uri for port:
+ // nsIURI throws on the port getter for non-special schemes.
+ port: urlObj.port != "" ? Number(uri.port) : null,
+ query: uri.hasQuery ? uri.query : null,
+ };
+}
+
+/**
+ * Serialize the path of a parsed URL.
+ *
+ * @see https://pr-preview.s3.amazonaws.com/w3c/webdriver-bidi/pull/429.html#parse-url-pattern
+ *
+ * @param {ParsedURL} url
+ * A parsed url.
+ *
+ * @returns {string}
+ * The serialized path
+ */
+function serializePath(url) {
+ // Check for opaque path
+ if (typeof url.path == "string") {
+ return url.path;
+ }
+
+ let serialized = "";
+ for (const segment of url.path) {
+ serialized += `/${segment}`;
+ }
+
+ return serialized;
+}
+
+/**
+ * Serialize the port of a parsed URL.
+ *
+ * @see https://pr-preview.s3.amazonaws.com/w3c/webdriver-bidi/pull/429.html#parse-url-pattern
+ *
+ * @param {ParsedURL} url
+ * A parsed url.
+ *
+ * @returns {string}
+ * The serialized port
+ */
+function serializePort(url) {
+ let port = null;
+ if (
+ SPECIAL_SCHEMES.includes(url.scheme) &&
+ DEFAULT_PORTS[url.scheme] !== null &&
+ (url.port === null || url.port == DEFAULT_PORTS[url.scheme])
+ ) {
+ port = "";
+ } else if (url.port !== null) {
+ port = `${url.port}`;
+ }
+
+ return port;
+}
+
+/**
+ * Unescape and check a pattern string against common forbidden characters.
+ *
+ * @see https://pr-preview.s3.amazonaws.com/w3c/webdriver-bidi/pull/429.html#unescape-url-pattern
+ *
+ * @param {string} pattern
+ * Either a full URLPatternString pattern or a property of a URLPatternPattern.
+ *
+ * @returns {string}
+ * The unescaped pattern
+ *
+ * @throws {InvalidArgumentError}
+ * Raised if an argument is of an invalid type or value.
+ */
+function unescapeUrlPattern(pattern) {
+ const forbiddenCharacters = ["(", ")", "*", "{", "}"];
+ const escapeCharacter = "\\";
+
+ let isEscaped = false;
+ let result = "";
+
+ for (const codepoint of Array.from(pattern)) {
+ if (!isEscaped) {
+ if (forbiddenCharacters.includes(codepoint)) {
+ throw new lazy.error.InvalidArgumentError(
+ `URL pattern contained an unescaped forbidden character ${codepoint}`
+ );
+ }
+
+ if (codepoint == escapeCharacter) {
+ isEscaped = true;
+ continue;
+ }
+ }
+
+ result += codepoint;
+ isEscaped = false;
+ }
+
+ return result;
+}
diff --git a/remote/shared/webdriver/process-actors/WebDriverProcessDataChild.sys.mjs b/remote/shared/webdriver/process-actors/WebDriverProcessDataChild.sys.mjs
new file mode 100644
index 0000000000..39db9d939e
--- /dev/null
+++ b/remote/shared/webdriver/process-actors/WebDriverProcessDataChild.sys.mjs
@@ -0,0 +1,93 @@
+/* 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, {
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+ NodeCache: "chrome://remote/content/shared/webdriver/NodeCache.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
+
+// Observer to clean-up element references for closed browsing contexts.
+class BrowsingContextObserver {
+ constructor(actor) {
+ this.actor = actor;
+ }
+
+ async observe(subject, topic, data) {
+ if (topic === "browsing-context-discarded") {
+ this.actor.cleanUp({ browsingContext: subject });
+ }
+ }
+}
+
+export class WebDriverProcessDataChild extends JSProcessActorChild {
+ #browsingContextObserver;
+ #nodeCache;
+
+ constructor() {
+ super();
+
+ // For now have a single reference store only. Once multiple WebDriver
+ // sessions are supported, it needs to be hashed by the session id.
+ this.#nodeCache = new lazy.NodeCache();
+
+ // Register observer to cleanup element references when a browsing context
+ // gets destroyed.
+ this.#browsingContextObserver = new BrowsingContextObserver(this);
+ Services.obs.addObserver(
+ this.#browsingContextObserver,
+ "browsing-context-discarded"
+ );
+ }
+
+ actorCreated() {
+ lazy.logger.trace(
+ `WebDriverProcessData actor created for PID ${Services.appinfo.processID}`
+ );
+ }
+
+ didDestroy() {
+ Services.obs.removeObserver(
+ this.#browsingContextObserver,
+ "browsing-context-discarded"
+ );
+ }
+
+ /**
+ * Clean up all the process specific data.
+ *
+ * @param {object=} options
+ * @param {BrowsingContext=} options.browsingContext
+ * If specified only clear data living in that browsing context.
+ */
+ cleanUp(options = {}) {
+ const { browsingContext = null } = options;
+
+ this.#nodeCache.clear({ browsingContext });
+ }
+
+ /**
+ * Get the node cache.
+ *
+ * @returns {NodeCache}
+ * The cache containing DOM node references.
+ */
+ getNodeCache() {
+ return this.#nodeCache;
+ }
+
+ async receiveMessage(msg) {
+ switch (msg.name) {
+ case "WebDriverProcessDataParent:CleanUp":
+ return this.cleanUp(msg.data);
+ default:
+ return Promise.reject(
+ new Error(`Unexpected message received: ${msg.name}`)
+ );
+ }
+ }
+}
diff --git a/remote/shared/webdriver/process-actors/WebDriverProcessDataParent.sys.mjs b/remote/shared/webdriver/process-actors/WebDriverProcessDataParent.sys.mjs
new file mode 100644
index 0000000000..a895106c4b
--- /dev/null
+++ b/remote/shared/webdriver/process-actors/WebDriverProcessDataParent.sys.mjs
@@ -0,0 +1,37 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
+
+/**
+ * Register the WebDriverProcessData actor that holds session data.
+ */
+export function registerProcessDataActor() {
+ try {
+ ChromeUtils.registerProcessActor("WebDriverProcessData", {
+ kind: "JSProcessActor",
+ child: {
+ esModuleURI:
+ "chrome://remote/content/shared/webdriver/process-actors/WebDriverProcessDataChild.sys.mjs",
+ },
+ includeParent: true,
+ });
+ } catch (e) {
+ if (e.name === "NotSupportedError") {
+ lazy.logger.warn(`WebDriverProcessData actor is already registered!`);
+ } else {
+ throw e;
+ }
+ }
+}
+
+export function unregisterProcessDataActor() {
+ ChromeUtils.unregisterProcessActor("WebDriverProcessData");
+}
diff --git a/remote/shared/webdriver/test/xpcshell/head.js b/remote/shared/webdriver/test/xpcshell/head.js
new file mode 100644
index 0000000000..ddc5573d78
--- /dev/null
+++ b/remote/shared/webdriver/test/xpcshell/head.js
@@ -0,0 +1,15 @@
+async function doGC() {
+ // Run GC and CC a few times to make sure that as much as possible is freed.
+ const numCycles = 3;
+ for (let i = 0; i < numCycles; i++) {
+ Cu.forceGC();
+ Cu.forceCC();
+ await new Promise(resolve => Cu.schedulePreciseShrinkingGC(resolve));
+ }
+
+ const MemoryReporter = Cc[
+ "@mozilla.org/memory-reporter-manager;1"
+ ].getService(Ci.nsIMemoryReporterManager);
+
+ await new Promise(resolve => MemoryReporter.minimizeMemoryUsage(resolve));
+}
diff --git a/remote/shared/webdriver/test/xpcshell/test_Actions.js b/remote/shared/webdriver/test/xpcshell/test_Actions.js
new file mode 100644
index 0000000000..24eac2e09d
--- /dev/null
+++ b/remote/shared/webdriver/test/xpcshell/test_Actions.js
@@ -0,0 +1,758 @@
+/* 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";
+
+const { action, CLICK_INTERVAL, ClickTracker } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/webdriver/Actions.sys.mjs"
+);
+
+const { setTimeout } = ChromeUtils.importESModule(
+ "resource://gre/modules/Timer.sys.mjs"
+);
+
+const XHTMLNS = "http://www.w3.org/1999/xhtml";
+
+const domEl = {
+ nodeType: 1,
+ ELEMENT_NODE: 1,
+ namespaceURI: XHTMLNS,
+};
+
+add_task(function test_createInputState() {
+ for (let type of ["none", "key", "pointer" /*"wheel"*/]) {
+ const state = new action.State();
+ const id = "device";
+ const actionSequence = {
+ type,
+ id,
+ actions: [],
+ };
+ action.Chain.fromJSON(state, [actionSequence]);
+ equal(state.inputStateMap.size, 1);
+ equal(state.inputStateMap.get(id).constructor.type, type);
+ }
+});
+
+add_task(function test_defaultPointerParameters() {
+ let state = new action.State();
+ const inputTickActions = [
+ { type: "pointer", subtype: "pointerDown", button: 0 },
+ ];
+ const chain = action.Chain.fromJSON(state, chainForTick(inputTickActions));
+ const pointerAction = chain[0][0];
+ equal(
+ state.getInputSource(pointerAction.id).pointer.constructor.type,
+ "mouse"
+ );
+});
+
+add_task(function test_processPointerParameters() {
+ for (let subtype of ["pointerDown", "pointerUp"]) {
+ for (let pointerType of [2, true, {}, []]) {
+ const inputTickActions = [
+ {
+ type: "pointer",
+ parameters: { pointerType },
+ subtype,
+ button: 0,
+ },
+ ];
+ let message = `Action sequence with parameters: {pointerType: ${pointerType} subtype: ${subtype}}`;
+ checkFromJSONErrors(
+ inputTickActions,
+ /Expected "pointerType" to be a string/,
+ message
+ );
+ }
+
+ for (let pointerType of ["", "foo"]) {
+ const inputTickActions = [
+ {
+ type: "pointer",
+ parameters: { pointerType },
+ subtype,
+ button: 0,
+ },
+ ];
+ let message = `Action sequence with parameters: {pointerType: ${pointerType} subtype: ${subtype}}`;
+ checkFromJSONErrors(
+ inputTickActions,
+ /Expected "pointerType" to be one of/,
+ message
+ );
+ }
+ }
+
+ for (let pointerType of ["mouse" /*"touch"*/]) {
+ let state = new action.State();
+ const inputTickActions = [
+ {
+ type: "pointer",
+ parameters: { pointerType },
+ subtype: "pointerDown",
+ button: 0,
+ },
+ ];
+ const chain = action.Chain.fromJSON(state, chainForTick(inputTickActions));
+ const pointerAction = chain[0][0];
+ equal(
+ state.getInputSource(pointerAction.id).pointer.constructor.type,
+ pointerType
+ );
+ }
+});
+
+add_task(function test_processPointerDownAction() {
+ for (let button of [-1, "a"]) {
+ const inputTickActions = [
+ { type: "pointer", subtype: "pointerDown", button },
+ ];
+ checkFromJSONErrors(
+ inputTickActions,
+ /Expected "button" to be a positive integer/,
+ `pointerDown with {button: ${button}}`
+ );
+ }
+ let state = new action.State();
+ const inputTickActions = [
+ { type: "pointer", subtype: "pointerDown", button: 5 },
+ ];
+ const chain = action.Chain.fromJSON(state, chainForTick(inputTickActions));
+ equal(chain[0][0].button, 5);
+});
+
+add_task(function test_validateActionDurationAndCoordinates() {
+ for (let [type, subtype] of [
+ ["none", "pause"],
+ ["pointer", "pointerMove"],
+ ]) {
+ for (let duration of [-1, "a"]) {
+ const inputTickActions = [{ type, subtype, duration }];
+ checkFromJSONErrors(
+ inputTickActions,
+ /Expected "duration" to be a positive integer/,
+ `{subtype} with {duration: ${duration}}`
+ );
+ }
+ }
+ for (let name of ["x", "y"]) {
+ const actionItem = {
+ type: "pointer",
+ subtype: "pointerMove",
+ duration: 5000,
+ };
+ actionItem[name] = "a";
+ checkFromJSONErrors(
+ [actionItem],
+ /Expected ".*" to be an integer/,
+ `${name}: "a", subtype: pointerMove`
+ );
+ }
+});
+
+add_task(function test_processPointerMoveActionOriginValidation() {
+ for (let origin of [-1, { a: "blah" }, []]) {
+ const inputTickActions = [
+ { type: "pointer", duration: 5000, subtype: "pointerMove", origin },
+ ];
+ checkFromJSONErrors(
+ inputTickActions,
+ /Expected "origin" to be undefined, "viewport", "pointer", or an element/,
+ `actionItem.origin: (${getTypeString(origin)})`
+ );
+ }
+});
+
+add_task(function test_processPointerMoveActionOriginStringValidation() {
+ for (let origin of ["", "viewports", "pointers"]) {
+ const inputTickActions = [
+ { type: "pointer", duration: 5000, subtype: "pointerMove", origin },
+ ];
+ checkFromJSONErrors(
+ inputTickActions,
+ /Expected "origin" to be undefined, "viewport", "pointer", or an element/,
+ `actionItem.origin: ${origin}`
+ );
+ }
+});
+
+add_task(function test_processPointerMoveActionElementOrigin() {
+ let state = new action.State();
+ const inputTickActions = [
+ {
+ type: "pointer",
+ duration: 5000,
+ subtype: "pointerMove",
+ origin: domEl,
+ x: 0,
+ y: 0,
+ },
+ ];
+ const chain = action.Chain.fromJSON(state, chainForTick(inputTickActions));
+ deepEqual(chain[0][0].origin.element, domEl);
+});
+
+add_task(function test_processPointerMoveActionDefaultOrigin() {
+ let state = new action.State();
+ const inputTickActions = [
+ { type: "pointer", duration: 5000, subtype: "pointerMove", x: 0, y: 0 },
+ ];
+ const chain = action.Chain.fromJSON(state, chainForTick(inputTickActions));
+ // The default is viewport coordinates which have an origin at [0,0] and don't depend on inputSource
+ deepEqual(chain[0][0].origin.getOriginCoordinates(null, null), {
+ x: 0,
+ y: 0,
+ });
+});
+
+add_task(function test_processPointerMoveAction() {
+ let state = new action.State();
+ const actionItems = [
+ {
+ duration: 5000,
+ type: "pointerMove",
+ origin: undefined,
+ x: 0,
+ y: 0,
+ },
+ {
+ duration: undefined,
+ type: "pointerMove",
+ origin: domEl,
+ x: 0,
+ y: 0,
+ },
+ {
+ duration: 5000,
+ type: "pointerMove",
+ x: 1,
+ y: 2,
+ origin: undefined,
+ },
+ ];
+ const actionSequence = {
+ id: "some_id",
+ type: "pointer",
+ actions: actionItems,
+ };
+ let chain = action.Chain.fromJSON(state, [actionSequence]);
+ equal(chain.length, actionItems.length);
+ for (let i = 0; i < actionItems.length; i++) {
+ let actual = chain[i][0];
+ let expected = actionItems[i];
+ equal(actual.duration, expected.duration);
+ equal(actual.x, expected.x);
+ equal(actual.y, expected.y);
+
+ let originClass;
+ if (expected.origin === undefined || expected.origin == "viewport") {
+ originClass = "ViewportOrigin";
+ } else if (expected.origin === "pointer") {
+ originClass = "PointerOrigin";
+ } else {
+ originClass = "ElementOrigin";
+ }
+ deepEqual(actual.origin.constructor.name, originClass);
+ }
+});
+
+add_task(function test_computePointerDestinationViewport() {
+ const state = new action.State();
+ const inputTickActions = [
+ {
+ type: "pointer",
+ subtype: "pointerMove",
+ x: 100,
+ y: 200,
+ origin: "viewport",
+ },
+ ];
+ const chain = action.Chain.fromJSON(state, chainForTick(inputTickActions));
+ const actionItem = chain[0][0];
+ const inputSource = state.getInputSource(actionItem.id);
+ // these values should not affect the outcome
+ inputSource.x = "99";
+ inputSource.y = "10";
+ const target = actionItem.origin.getTargetCoordinates(
+ inputSource,
+ [actionItem.x, actionItem.y],
+ null
+ );
+ equal(actionItem.x, target[0]);
+ equal(actionItem.y, target[1]);
+});
+
+add_task(function test_computePointerDestinationPointer() {
+ const state = new action.State();
+ const inputTickActions = [
+ {
+ type: "pointer",
+ subtype: "pointerMove",
+ x: 100,
+ y: 200,
+ origin: "pointer",
+ },
+ ];
+ const chain = action.Chain.fromJSON(state, chainForTick(inputTickActions));
+ const actionItem = chain[0][0];
+ const inputSource = state.getInputSource(actionItem.id);
+ inputSource.x = 10;
+ inputSource.y = 99;
+ const target = actionItem.origin.getTargetCoordinates(
+ inputSource,
+ [actionItem.x, actionItem.y],
+ null
+ );
+ equal(actionItem.x + inputSource.x, target[0]);
+ equal(actionItem.y + inputSource.y, target[1]);
+});
+
+add_task(function test_processPointerAction() {
+ for (let pointerType of ["mouse", "touch"]) {
+ const actionItems = [
+ {
+ duration: 2000,
+ type: "pause",
+ },
+ {
+ type: "pointerMove",
+ duration: 2000,
+ x: 0,
+ y: 0,
+ },
+ {
+ type: "pointerUp",
+ button: 1,
+ },
+ ];
+ let actionSequence = {
+ type: "pointer",
+ id: "some_id",
+ parameters: {
+ pointerType,
+ },
+ actions: actionItems,
+ };
+ const state = new action.State();
+ const chain = action.Chain.fromJSON(state, [actionSequence]);
+ equal(chain.length, actionItems.length);
+ for (let i = 0; i < actionItems.length; i++) {
+ const actual = chain[i][0];
+ const expected = actionItems[i];
+ equal(actual.type, expected.type === "pause" ? "none" : "pointer");
+ equal(actual.subtype, expected.type);
+ equal(actual.id, actionSequence.id);
+ if (expected.type === "pointerUp") {
+ equal(actual.button, expected.button);
+ } else {
+ equal(actual.duration, expected.duration);
+ }
+ if (expected.type !== "pause") {
+ equal(
+ state.getInputSource(actual.id).pointer.constructor.type,
+ pointerType
+ );
+ }
+ }
+ }
+});
+
+add_task(function test_processPauseAction() {
+ for (let type of ["none", "key", "pointer"]) {
+ const state = new action.State();
+ const actionSequence = {
+ type,
+ id: "some_id",
+ actions: [{ type: "pause", duration: 5000 }],
+ };
+ const actionItem = action.Chain.fromJSON(state, [actionSequence])[0][0];
+ equal(actionItem.type, "none");
+ equal(actionItem.subtype, "pause");
+ equal(actionItem.id, "some_id");
+ equal(actionItem.duration, 5000);
+ }
+ const state = new action.State();
+ const actionSequence = {
+ type: "none",
+ id: "some_id",
+ actions: [{ type: "pause" }],
+ };
+ const actionItem = action.Chain.fromJSON(state, [actionSequence])[0][0];
+ equal(actionItem.duration, undefined);
+});
+
+add_task(function test_processActionSubtypeValidation() {
+ for (let type of ["none", "key", "pointer"]) {
+ const message = `type: ${type}, subtype: dancing`;
+ const inputTickActions = [{ type, subtype: "dancing" }];
+ checkFromJSONErrors(
+ inputTickActions,
+ new RegExp(`Expected known subtype for type`),
+ message
+ );
+ }
+});
+
+add_task(function test_processKeyActionDown() {
+ for (let value of [-1, undefined, [], ["a"], { length: 1 }, null]) {
+ const inputTickActions = [{ type: "key", subtype: "keyDown", value }];
+ const message = `actionItem.value: (${getTypeString(value)})`;
+ checkFromJSONErrors(
+ inputTickActions,
+ /Expected "value" to be a string that represents single code point/,
+ message
+ );
+ }
+
+ const state = new action.State();
+ const actionSequence = {
+ type: "key",
+ id: "keyboard",
+ actions: [{ type: "keyDown", value: "\uE004" }],
+ };
+ const actionItem = action.Chain.fromJSON(state, [actionSequence])[0][0];
+
+ equal(actionItem.type, "key");
+ equal(actionItem.id, "keyboard");
+ equal(actionItem.subtype, "keyDown");
+ equal(actionItem.value, "\ue004");
+});
+
+add_task(function test_processInputSourceActionSequenceValidation() {
+ checkFromJSONErrors(
+ [{ type: "swim", subtype: "pause", id: "some id" }],
+ /Expected known action type/,
+ "actionSequence type: swim"
+ );
+
+ checkFromJSONErrors(
+ [{ type: "none", subtype: "pause", id: -1 }],
+ /Expected "id" to be a string/,
+ "actionSequence id: -1"
+ );
+
+ checkFromJSONErrors(
+ [{ type: "none", subtype: "pause", id: undefined }],
+ /Expected "id" to be a string/,
+ "actionSequence id: undefined"
+ );
+
+ const state = new action.State();
+ const actionSequence = [
+ { type: "none", subtype: "pause", id: "some_id", actions: -1 },
+ ];
+ const errorRegex = /Expected "actionSequence.actions" to be an array/;
+ const message = "actionSequence actions: -1";
+
+ Assert.throws(
+ () => action.Chain.fromJSON(state, actionSequence),
+ /InvalidArgumentError/,
+ message
+ );
+ Assert.throws(
+ () => action.Chain.fromJSON(state, actionSequence),
+ errorRegex,
+ message
+ );
+});
+
+add_task(function test_processInputSourceActionSequence() {
+ const state = new action.State();
+ const actionItem = { type: "pause", duration: 5 };
+ const actionSequence = {
+ type: "none",
+ id: "some id",
+ actions: [actionItem],
+ };
+ const chain = action.Chain.fromJSON(state, [actionSequence]);
+ equal(chain.length, 1);
+ const tickActions = chain[0];
+ equal(tickActions.length, 1);
+ equal(tickActions[0].type, "none");
+ equal(tickActions[0].subtype, "pause");
+ equal(tickActions[0].duration, 5);
+ equal(tickActions[0].id, "some id");
+});
+
+add_task(function test_processInputSourceActionSequencePointer() {
+ const state = new action.State();
+ const actionItem = { type: "pointerDown", button: 1 };
+ const actionSequence = {
+ type: "pointer",
+ id: "9",
+ actions: [actionItem],
+ parameters: {
+ pointerType: "mouse", // TODO "pen"
+ },
+ };
+ const chain = action.Chain.fromJSON(state, [actionSequence]);
+ equal(chain.length, 1);
+ const tickActions = chain[0];
+ equal(tickActions.length, 1);
+ equal(tickActions[0].type, "pointer");
+ equal(tickActions[0].subtype, "pointerDown");
+ equal(tickActions[0].button, 1);
+ equal(tickActions[0].id, "9");
+ const inputSource = state.getInputSource(tickActions[0].id);
+ equal(inputSource.constructor.type, "pointer");
+ equal(inputSource.pointer.constructor.type, "mouse");
+});
+
+add_task(function test_processInputSourceActionSequenceKey() {
+ const state = new action.State();
+ const actionItem = { type: "keyUp", value: "a" };
+ const actionSequence = {
+ type: "key",
+ id: "9",
+ actions: [actionItem],
+ };
+ const chain = action.Chain.fromJSON(state, [actionSequence]);
+ equal(chain.length, 1);
+ const tickActions = chain[0];
+ equal(tickActions.length, 1);
+ equal(tickActions[0].type, "key");
+ equal(tickActions[0].subtype, "keyUp");
+ equal(tickActions[0].value, "a");
+ equal(tickActions[0].id, "9");
+});
+
+add_task(function test_processInputSourceActionSequenceInputStateMap() {
+ const state = new action.State();
+ const id = "1";
+ const actionItem = { type: "pause", duration: 5000 };
+ const actionSequence = {
+ type: "key",
+ id,
+ actions: [actionItem],
+ };
+ action.Chain.fromJSON(state, [actionSequence]);
+ equal(state.inputStateMap.size, 1);
+ equal(state.inputStateMap.get(id).constructor.type, "key");
+
+ // Construct a different state with the same input id
+ const state1 = new action.State();
+ const actionItem1 = { type: "pointerDown", button: 0 };
+ const actionSequence1 = {
+ type: "pointer",
+ id,
+ actions: [actionItem1],
+ };
+ action.Chain.fromJSON(state1, [actionSequence1]);
+ equal(state1.inputStateMap.size, 1);
+
+ // Overwrite the state in the initial map with one of a different type
+ state.inputStateMap.set(id, state1.inputStateMap.get(id));
+ equal(state.inputStateMap.get(id).constructor.type, "pointer");
+
+ const message = "Wrong state for input id type";
+ Assert.throws(
+ () => action.Chain.fromJSON(state, [actionSequence]),
+ /InvalidArgumentError/,
+ message
+ );
+ Assert.throws(
+ () => action.Chain.fromJSON(state, [actionSequence]),
+ /Expected input source \[object String\] "1" to be type pointer/,
+ message
+ );
+});
+
+add_task(function test_extractActionChainValidation() {
+ for (let actions of [-1, "a", undefined, null]) {
+ const state = new action.State();
+ let message = `actions: ${getTypeString(actions)}`;
+ Assert.throws(
+ () => action.Chain.fromJSON(state, actions),
+ /InvalidArgumentError/,
+ message
+ );
+ Assert.throws(
+ () => action.Chain.fromJSON(state, actions),
+ /Expected "actions" to be an array/,
+ message
+ );
+ }
+});
+
+add_task(function test_extractActionChainEmpty() {
+ const state = new action.State();
+ deepEqual(action.Chain.fromJSON(state, []), []);
+});
+
+add_task(function test_extractActionChain_oneTickOneInput() {
+ const state = new action.State();
+ const actionItem = { type: "pause", duration: 5000 };
+ const actionSequence = {
+ type: "none",
+ id: "some id",
+ actions: [actionItem],
+ };
+ const actionsByTick = action.Chain.fromJSON(state, [actionSequence]);
+ equal(1, actionsByTick.length);
+ equal(1, actionsByTick[0].length);
+ equal(actionsByTick[0][0].id, actionSequence.id);
+ equal(actionsByTick[0][0].type, "none");
+ equal(actionsByTick[0][0].subtype, "pause");
+ equal(actionsByTick[0][0].duration, actionItem.duration);
+});
+
+add_task(function test_extractActionChain_twoAndThreeTicks() {
+ const state = new action.State();
+ const mouseActionItems = [
+ {
+ type: "pointerDown",
+ button: 2,
+ },
+ {
+ type: "pointerUp",
+ button: 2,
+ },
+ ];
+ const mouseActionSequence = {
+ type: "pointer",
+ id: "7",
+ actions: mouseActionItems,
+ parameters: {
+ pointerType: "mouse",
+ },
+ };
+ const keyActionItems = [
+ {
+ type: "keyDown",
+ value: "a",
+ },
+ {
+ type: "pause",
+ duration: 4,
+ },
+ {
+ type: "keyUp",
+ value: "a",
+ },
+ ];
+ let keyActionSequence = {
+ type: "key",
+ id: "1",
+ actions: keyActionItems,
+ };
+ let actionsByTick = action.Chain.fromJSON(state, [
+ keyActionSequence,
+ mouseActionSequence,
+ ]);
+ // number of ticks is same as longest action sequence
+ equal(keyActionItems.length, actionsByTick.length);
+ equal(2, actionsByTick[0].length);
+ equal(2, actionsByTick[1].length);
+ equal(1, actionsByTick[2].length);
+
+ equal(actionsByTick[2][0].id, keyActionSequence.id);
+ equal(actionsByTick[2][0].type, "key");
+ equal(actionsByTick[2][0].subtype, "keyUp");
+});
+
+add_task(function test_computeTickDuration() {
+ const state = new action.State();
+ const expected = 8000;
+ const inputTickActions = [
+ { type: "none", subtype: "pause", duration: 5000 },
+ { type: "key", subtype: "pause", duration: 1000 },
+ { type: "pointer", subtype: "pointerMove", duration: 6000, x: 0, y: 0 },
+ // invalid because keyDown should not have duration, so duration should be ignored.
+ { type: "key", subtype: "keyDown", duration: 100000, value: "a" },
+ { type: "pointer", subtype: "pause", duration: expected },
+ { type: "pointer", subtype: "pointerUp", button: 0 },
+ ];
+ const chain = action.Chain.fromJSON(state, chainForTick(inputTickActions));
+ equal(1, chain.length);
+ const tickActions = chain[0];
+ equal(expected, tickActions.getDuration());
+});
+
+add_task(function test_computeTickDuration_noDurations() {
+ const state = new action.State();
+ const inputTickActions = [
+ // invalid because keyDown should not have duration, so duration should be ignored.
+ { type: "key", subtype: "keyDown", duration: 100000, value: "a" },
+ // undefined duration permitted
+ { type: "none", subtype: "pause" },
+ { type: "pointer", subtype: "pointerMove", button: 0, x: 0, y: 0 },
+ { type: "pointer", subtype: "pointerDown", button: 0 },
+ { type: "key", subtype: "keyUp", value: "a" },
+ ];
+ const chain = action.Chain.fromJSON(state, chainForTick(inputTickActions));
+ equal(0, chain[0].getDuration());
+});
+
+add_task(function test_ClickTracker_setClick() {
+ const clickTracker = new ClickTracker();
+ const button1 = 1;
+ const button2 = 2;
+
+ clickTracker.setClick(button1);
+ equal(1, clickTracker.count);
+
+ // Make sure that clicking different mouse buttons doesn't increase the count.
+ clickTracker.setClick(button2);
+ equal(1, clickTracker.count);
+
+ clickTracker.setClick(button2);
+ equal(2, clickTracker.count);
+
+ clickTracker.reset();
+ equal(0, clickTracker.count);
+});
+
+add_task(function test_ClickTracker_reset_after_timeout() {
+ const clickTracker = new ClickTracker();
+
+ clickTracker.setClick(1);
+ equal(1, clickTracker.count);
+
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ setTimeout(() => equal(0, clickTracker.count), CLICK_INTERVAL + 10);
+});
+
+// helpers
+function getTypeString(obj) {
+ return Object.prototype.toString.call(obj);
+}
+
+function checkFromJSONErrors(inputTickActions, regex, message) {
+ const state = new action.State();
+
+ if (typeof message == "undefined") {
+ message = `fromJSON`;
+ }
+ Assert.throws(
+ () => action.Chain.fromJSON(state, chainForTick(inputTickActions)),
+ /InvalidArgumentError/,
+ message
+ );
+ Assert.throws(
+ () => action.Chain.fromJSON(state, chainForTick(inputTickActions)),
+ regex,
+ message
+ );
+}
+
+function chainForTick(tickActions) {
+ const actions = [];
+ let lastId = 0;
+ for (let { type, subtype, parameters, ...props } of tickActions) {
+ let id;
+ if (!props.hasOwnProperty("id")) {
+ id = `${type}_${lastId++}`;
+ } else {
+ id = props.id;
+ delete props.id;
+ }
+ const inputAction = { type, id, actions: [{ type: subtype, ...props }] };
+ if (parameters !== undefined) {
+ inputAction.parameters = parameters;
+ }
+ actions.push(inputAction);
+ }
+ return actions;
+}
diff --git a/remote/shared/webdriver/test/xpcshell/test_Assert.js b/remote/shared/webdriver/test/xpcshell/test_Assert.js
new file mode 100644
index 0000000000..cf474868b6
--- /dev/null
+++ b/remote/shared/webdriver/test/xpcshell/test_Assert.js
@@ -0,0 +1,183 @@
+/* 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";
+/* eslint-disable no-array-constructor, no-object-constructor */
+
+const { assert } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/webdriver/Assert.sys.mjs"
+);
+const { error } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/webdriver/Errors.sys.mjs"
+);
+
+add_task(function test_session() {
+ assert.session({ id: "foo" });
+
+ const invalidTypes = [
+ null,
+ undefined,
+ [],
+ {},
+ { id: undefined },
+ { id: null },
+ { id: true },
+ { id: 1 },
+ { id: [] },
+ { id: {} },
+ ];
+
+ for (const invalidType of invalidTypes) {
+ Assert.throws(() => assert.session(invalidType), /InvalidSessionIDError/);
+ }
+
+ Assert.throws(() => assert.session({ id: null }, "custom"), /custom/);
+});
+
+add_task(function test_platforms() {
+ // at least one will fail
+ let raised;
+ for (let fn of [assert.desktop, assert.mobile]) {
+ try {
+ fn();
+ } catch (e) {
+ raised = e;
+ }
+ }
+ ok(raised instanceof error.UnsupportedOperationError);
+});
+
+add_task(function test_noUserPrompt() {
+ assert.noUserPrompt(null);
+ assert.noUserPrompt(undefined);
+ Assert.throws(() => assert.noUserPrompt({}), /UnexpectedAlertOpenError/);
+ Assert.throws(() => assert.noUserPrompt({}, "custom"), /custom/);
+});
+
+add_task(function test_defined() {
+ assert.defined({});
+ Assert.throws(() => assert.defined(undefined), /InvalidArgumentError/);
+ Assert.throws(() => assert.noUserPrompt({}, "custom"), /custom/);
+});
+
+add_task(function test_number() {
+ assert.number(1);
+ assert.number(0);
+ assert.number(-1);
+ assert.number(1.2);
+ for (let i of ["foo", "1", {}, [], NaN, Infinity, undefined]) {
+ Assert.throws(() => assert.number(i), /InvalidArgumentError/);
+ }
+
+ Assert.throws(() => assert.number("foo", "custom"), /custom/);
+});
+
+add_task(function test_callable() {
+ assert.callable(function () {});
+ assert.callable(() => {});
+
+ for (let typ of [undefined, "", true, {}, []]) {
+ Assert.throws(() => assert.callable(typ), /InvalidArgumentError/);
+ }
+
+ Assert.throws(() => assert.callable("foo", "custom"), /custom/);
+});
+
+add_task(function test_integer() {
+ assert.integer(1);
+ assert.integer(0);
+ assert.integer(-1);
+ Assert.throws(() => assert.integer("foo"), /InvalidArgumentError/);
+ Assert.throws(() => assert.integer(1.2), /InvalidArgumentError/);
+
+ Assert.throws(() => assert.integer("foo", "custom"), /custom/);
+});
+
+add_task(function test_positiveInteger() {
+ assert.positiveInteger(1);
+ assert.positiveInteger(0);
+ Assert.throws(() => assert.positiveInteger(-1), /InvalidArgumentError/);
+ Assert.throws(() => assert.positiveInteger("foo"), /InvalidArgumentError/);
+ Assert.throws(() => assert.positiveInteger("foo", "custom"), /custom/);
+});
+
+add_task(function test_positiveNumber() {
+ assert.positiveNumber(1);
+ assert.positiveNumber(0);
+ assert.positiveNumber(1.1);
+ assert.positiveNumber(Number.MAX_VALUE);
+ // eslint-disable-next-line no-loss-of-precision
+ Assert.throws(() => assert.positiveNumber(1.8e308), /InvalidArgumentError/);
+ Assert.throws(() => assert.positiveNumber(-1), /InvalidArgumentError/);
+ Assert.throws(() => assert.positiveNumber(Infinity), /InvalidArgumentError/);
+ Assert.throws(() => assert.positiveNumber("foo"), /InvalidArgumentError/);
+ Assert.throws(() => assert.positiveNumber("foo", "custom"), /custom/);
+});
+
+add_task(function test_boolean() {
+ assert.boolean(true);
+ assert.boolean(false);
+ Assert.throws(() => assert.boolean("false"), /InvalidArgumentError/);
+ Assert.throws(() => assert.boolean(undefined), /InvalidArgumentError/);
+ Assert.throws(() => assert.boolean(undefined, "custom"), /custom/);
+});
+
+add_task(function test_string() {
+ assert.string("foo");
+ assert.string(`bar`);
+ Assert.throws(() => assert.string(42), /InvalidArgumentError/);
+ Assert.throws(() => assert.string(42, "custom"), /custom/);
+});
+
+add_task(function test_open() {
+ assert.open({ currentWindowGlobal: {} });
+
+ for (let typ of [null, undefined, { currentWindowGlobal: null }]) {
+ Assert.throws(() => assert.open(typ), /NoSuchWindowError/);
+ }
+
+ Assert.throws(() => assert.open(null, "custom"), /custom/);
+});
+
+add_task(function test_object() {
+ assert.object({});
+ assert.object(new Object());
+ for (let typ of [42, "foo", true, null, undefined]) {
+ Assert.throws(() => assert.object(typ), /InvalidArgumentError/);
+ }
+
+ Assert.throws(() => assert.object(null, "custom"), /custom/);
+});
+
+add_task(function test_in() {
+ assert.in("foo", { foo: 42 });
+ for (let typ of [{}, 42, true, null, undefined]) {
+ Assert.throws(() => assert.in("foo", typ), /InvalidArgumentError/);
+ }
+
+ Assert.throws(() => assert.in("foo", { bar: 42 }, "custom"), /custom/);
+});
+
+add_task(function test_array() {
+ assert.array([]);
+ assert.array(new Array());
+ Assert.throws(() => assert.array(42), /InvalidArgumentError/);
+ Assert.throws(() => assert.array({}), /InvalidArgumentError/);
+
+ Assert.throws(() => assert.array(42, "custom"), /custom/);
+});
+
+add_task(function test_that() {
+ equal(1, assert.that(n => n + 1)(1));
+ Assert.throws(() => assert.that(() => false)(), /InvalidArgumentError/);
+ Assert.throws(() => assert.that(val => val)(false), /InvalidArgumentError/);
+ Assert.throws(
+ () => assert.that(val => val, "foo", error.SessionNotCreatedError)(false),
+ /SessionNotCreatedError/
+ );
+
+ Assert.throws(() => assert.that(() => false, "custom")(), /custom/);
+});
+
+/* eslint-enable no-array-constructor, no-new-object */
diff --git a/remote/shared/webdriver/test/xpcshell/test_Capabilities.js b/remote/shared/webdriver/test/xpcshell/test_Capabilities.js
new file mode 100644
index 0000000000..19401dd463
--- /dev/null
+++ b/remote/shared/webdriver/test/xpcshell/test_Capabilities.js
@@ -0,0 +1,700 @@
+/* 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";
+
+const { AppInfo } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/AppInfo.sys.mjs"
+);
+const { error } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/webdriver/Errors.sys.mjs"
+);
+const {
+ Capabilities,
+ mergeCapabilities,
+ PageLoadStrategy,
+ processCapabilities,
+ Proxy,
+ Timeouts,
+ UnhandledPromptBehavior,
+ validateCapabilities,
+} = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/webdriver/Capabilities.sys.mjs"
+);
+
+add_task(function test_Timeouts_ctor() {
+ let ts = new Timeouts();
+ equal(ts.implicit, 0);
+ equal(ts.pageLoad, 300000);
+ equal(ts.script, 30000);
+});
+
+add_task(function test_Timeouts_toString() {
+ equal(new Timeouts().toString(), "[object Timeouts]");
+});
+
+add_task(function test_Timeouts_toJSON() {
+ let ts = new Timeouts();
+ deepEqual(ts.toJSON(), { implicit: 0, pageLoad: 300000, script: 30000 });
+});
+
+add_task(function test_Timeouts_fromJSON() {
+ let json = {
+ implicit: 0,
+ pageLoad: 2.0,
+ script: Number.MAX_SAFE_INTEGER,
+ };
+ let ts = Timeouts.fromJSON(json);
+ equal(ts.implicit, json.implicit);
+ equal(ts.pageLoad, json.pageLoad);
+ equal(ts.script, json.script);
+});
+
+add_task(function test_Timeouts_fromJSON_unrecognised_field() {
+ let json = {
+ sessionId: "foobar",
+ };
+ try {
+ Timeouts.fromJSON(json);
+ } catch (e) {
+ equal(e.name, error.InvalidArgumentError.name);
+ equal(e.message, "Unrecognised timeout: sessionId");
+ }
+});
+
+add_task(function test_Timeouts_fromJSON_invalid_types() {
+ for (let value of [null, [], {}, false, "10", 2.5]) {
+ Assert.throws(
+ () => Timeouts.fromJSON({ implicit: value }),
+ /InvalidArgumentError/
+ );
+ }
+});
+
+add_task(function test_Timeouts_fromJSON_bounds() {
+ for (let value of [-1, Number.MAX_SAFE_INTEGER + 1]) {
+ Assert.throws(
+ () => Timeouts.fromJSON({ script: value }),
+ /InvalidArgumentError/
+ );
+ }
+});
+
+add_task(function test_PageLoadStrategy() {
+ equal(PageLoadStrategy.None, "none");
+ equal(PageLoadStrategy.Eager, "eager");
+ equal(PageLoadStrategy.Normal, "normal");
+});
+
+add_task(function test_Proxy_ctor() {
+ let p = new Proxy();
+ let props = [
+ "proxyType",
+ "httpProxy",
+ "sslProxy",
+ "socksProxy",
+ "socksVersion",
+ "proxyAutoconfigUrl",
+ ];
+ for (let prop of props) {
+ ok(prop in p, `${prop} in ${JSON.stringify(props)}`);
+ equal(p[prop], null);
+ }
+});
+
+add_task(function test_Proxy_init() {
+ let p = new Proxy();
+
+ // no changed made, and 5 (system) is default
+ equal(p.init(), false);
+ equal(Services.prefs.getIntPref("network.proxy.type"), 5);
+
+ // pac
+ p.proxyType = "pac";
+ p.proxyAutoconfigUrl = "http://localhost:1234";
+ ok(p.init());
+
+ equal(Services.prefs.getIntPref("network.proxy.type"), 2);
+ equal(
+ Services.prefs.getStringPref("network.proxy.autoconfig_url"),
+ "http://localhost:1234"
+ );
+
+ // direct
+ p = new Proxy();
+ p.proxyType = "direct";
+ ok(p.init());
+ equal(Services.prefs.getIntPref("network.proxy.type"), 0);
+
+ // autodetect
+ p = new Proxy();
+ p.proxyType = "autodetect";
+ ok(p.init());
+ equal(Services.prefs.getIntPref("network.proxy.type"), 4);
+
+ // system
+ p = new Proxy();
+ p.proxyType = "system";
+ ok(p.init());
+ equal(Services.prefs.getIntPref("network.proxy.type"), 5);
+
+ // manual
+ for (let proxy of ["http", "ssl", "socks"]) {
+ p = new Proxy();
+ p.proxyType = "manual";
+ p.noProxy = ["foo", "bar"];
+ p[`${proxy}Proxy`] = "foo";
+ p[`${proxy}ProxyPort`] = 42;
+ if (proxy === "socks") {
+ p[`${proxy}Version`] = 4;
+ }
+
+ ok(p.init());
+ equal(Services.prefs.getIntPref("network.proxy.type"), 1);
+ equal(
+ Services.prefs.getStringPref("network.proxy.no_proxies_on"),
+ "foo, bar"
+ );
+ equal(Services.prefs.getStringPref(`network.proxy.${proxy}`), "foo");
+ equal(Services.prefs.getIntPref(`network.proxy.${proxy}_port`), 42);
+ if (proxy === "socks") {
+ equal(Services.prefs.getIntPref(`network.proxy.${proxy}_version`), 4);
+ }
+ }
+
+ // empty no proxy should reset default exclustions
+ p = new Proxy();
+ p.proxyType = "manual";
+ p.noProxy = [];
+ ok(p.init());
+ equal(Services.prefs.getStringPref("network.proxy.no_proxies_on"), "");
+});
+
+add_task(function test_Proxy_toString() {
+ equal(new Proxy().toString(), "[object Proxy]");
+});
+
+add_task(function test_Proxy_toJSON() {
+ let p = new Proxy();
+ deepEqual(p.toJSON(), {});
+
+ // autoconfig url
+ p = new Proxy();
+ p.proxyType = "pac";
+ p.proxyAutoconfigUrl = "foo";
+ deepEqual(p.toJSON(), { proxyType: "pac", proxyAutoconfigUrl: "foo" });
+
+ // manual proxy
+ p = new Proxy();
+ p.proxyType = "manual";
+ deepEqual(p.toJSON(), { proxyType: "manual" });
+
+ for (let proxy of ["httpProxy", "sslProxy", "socksProxy"]) {
+ let expected = { proxyType: "manual" };
+
+ p = new Proxy();
+ p.proxyType = "manual";
+
+ if (proxy == "socksProxy") {
+ p.socksVersion = 5;
+ expected.socksVersion = 5;
+ }
+
+ // without port
+ p[proxy] = "foo";
+ expected[proxy] = "foo";
+ deepEqual(p.toJSON(), expected);
+
+ // with port
+ p[proxy] = "foo";
+ p[`${proxy}Port`] = 0;
+ expected[proxy] = "foo:0";
+ deepEqual(p.toJSON(), expected);
+
+ p[`${proxy}Port`] = 42;
+ expected[proxy] = "foo:42";
+ deepEqual(p.toJSON(), expected);
+
+ // add brackets for IPv6 address as proxy hostname
+ p[proxy] = "2001:db8::1";
+ p[`${proxy}Port`] = 42;
+ expected[proxy] = "foo:42";
+ expected[proxy] = "[2001:db8::1]:42";
+ deepEqual(p.toJSON(), expected);
+ }
+
+ // noProxy: add brackets for IPv6 address
+ p = new Proxy();
+ p.proxyType = "manual";
+ p.noProxy = ["2001:db8::1"];
+ let expected = { proxyType: "manual", noProxy: "[2001:db8::1]" };
+ deepEqual(p.toJSON(), expected);
+});
+
+add_task(function test_Proxy_fromJSON() {
+ let p = new Proxy();
+ deepEqual(p, Proxy.fromJSON(undefined));
+ deepEqual(p, Proxy.fromJSON(null));
+
+ for (let typ of [true, 42, "foo", []]) {
+ Assert.throws(() => Proxy.fromJSON(typ), /InvalidArgumentError/);
+ }
+
+ // must contain a valid proxyType
+ Assert.throws(() => Proxy.fromJSON({}), /InvalidArgumentError/);
+ Assert.throws(
+ () => Proxy.fromJSON({ proxyType: "foo" }),
+ /InvalidArgumentError/
+ );
+
+ // autoconfig url
+ for (let url of [true, 42, [], {}]) {
+ Assert.throws(
+ () => Proxy.fromJSON({ proxyType: "pac", proxyAutoconfigUrl: url }),
+ /InvalidArgumentError/
+ );
+ }
+
+ p = new Proxy();
+ p.proxyType = "pac";
+ p.proxyAutoconfigUrl = "foo";
+ deepEqual(p, Proxy.fromJSON({ proxyType: "pac", proxyAutoconfigUrl: "foo" }));
+
+ // manual proxy
+ p = new Proxy();
+ p.proxyType = "manual";
+ deepEqual(p, Proxy.fromJSON({ proxyType: "manual" }));
+
+ for (let proxy of ["httpProxy", "sslProxy", "socksProxy"]) {
+ let manual = { proxyType: "manual" };
+
+ // invalid hosts
+ for (let host of [
+ true,
+ 42,
+ [],
+ {},
+ null,
+ "http://foo",
+ "foo:-1",
+ "foo:65536",
+ "foo/test",
+ "foo#42",
+ "foo?foo=bar",
+ "2001:db8::1",
+ ]) {
+ manual[proxy] = host;
+ Assert.throws(() => Proxy.fromJSON(manual), /InvalidArgumentError/);
+ }
+
+ p = new Proxy();
+ p.proxyType = "manual";
+ if (proxy == "socksProxy") {
+ manual.socksVersion = 5;
+ p.socksVersion = 5;
+ }
+
+ let host_map = {
+ "foo:1": { hostname: "foo", port: 1 },
+ "foo:21": { hostname: "foo", port: 21 },
+ "foo:80": { hostname: "foo", port: 80 },
+ "foo:443": { hostname: "foo", port: 443 },
+ "foo:65535": { hostname: "foo", port: 65535 },
+ "127.0.0.1:42": { hostname: "127.0.0.1", port: 42 },
+ "[2001:db8::1]:42": { hostname: "2001:db8::1", port: "42" },
+ };
+
+ // valid proxy hosts with port
+ for (let host in host_map) {
+ manual[proxy] = host;
+
+ p[`${proxy}`] = host_map[host].hostname;
+ p[`${proxy}Port`] = host_map[host].port;
+
+ deepEqual(p, Proxy.fromJSON(manual));
+ }
+
+ // Without a port the default port of the scheme is used
+ for (let host of ["foo", "foo:"]) {
+ manual[proxy] = host;
+
+ // For socks no default port is available
+ p[proxy] = `foo`;
+ if (proxy === "socksProxy") {
+ p[`${proxy}Port`] = null;
+ } else {
+ let default_ports = { httpProxy: 80, sslProxy: 443 };
+
+ p[`${proxy}Port`] = default_ports[proxy];
+ }
+
+ deepEqual(p, Proxy.fromJSON(manual));
+ }
+ }
+
+ // missing required socks version
+ Assert.throws(
+ () => Proxy.fromJSON({ proxyType: "manual", socksProxy: "foo:1234" }),
+ /InvalidArgumentError/
+ );
+
+ // Bug 1703805: Since Firefox 90 ftpProxy is no longer supported
+ Assert.throws(
+ () => Proxy.fromJSON({ proxyType: "manual", ftpProxy: "foo:21" }),
+ /InvalidArgumentError/
+ );
+
+ // noProxy: invalid settings
+ for (let noProxy of [true, 42, {}, null, "foo", [true], [42], [{}], [null]]) {
+ Assert.throws(
+ () => Proxy.fromJSON({ proxyType: "manual", noProxy }),
+ /InvalidArgumentError/
+ );
+ }
+
+ // noProxy: valid settings
+ p = new Proxy();
+ p.proxyType = "manual";
+ for (let noProxy of [[], ["foo"], ["foo", "bar"], ["127.0.0.1"]]) {
+ let manual = { proxyType: "manual", noProxy };
+ p.noProxy = noProxy;
+ deepEqual(p, Proxy.fromJSON(manual));
+ }
+
+ // noProxy: IPv6 needs brackets removed
+ p = new Proxy();
+ p.proxyType = "manual";
+ p.noProxy = ["2001:db8::1"];
+ let manual = { proxyType: "manual", noProxy: ["[2001:db8::1]"] };
+ deepEqual(p, Proxy.fromJSON(manual));
+});
+
+add_task(function test_UnhandledPromptBehavior() {
+ equal(UnhandledPromptBehavior.Accept, "accept");
+ equal(UnhandledPromptBehavior.AcceptAndNotify, "accept and notify");
+ equal(UnhandledPromptBehavior.Dismiss, "dismiss");
+ equal(UnhandledPromptBehavior.DismissAndNotify, "dismiss and notify");
+ equal(UnhandledPromptBehavior.Ignore, "ignore");
+});
+
+add_task(function test_Capabilities_ctor() {
+ let caps = new Capabilities();
+ ok(caps.has("browserName"));
+ ok(caps.has("browserVersion"));
+ ok(caps.has("platformName"));
+ ok(["linux", "mac", "windows", "android"].includes(caps.get("platformName")));
+ equal(PageLoadStrategy.Normal, caps.get("pageLoadStrategy"));
+ equal(false, caps.get("acceptInsecureCerts"));
+ ok(caps.get("timeouts") instanceof Timeouts);
+ ok(caps.get("proxy") instanceof Proxy);
+ equal(caps.get("setWindowRect"), !AppInfo.isAndroid);
+ equal(caps.get("strictFileInteractability"), false);
+ equal(caps.get("webSocketUrl"), null);
+
+ equal(false, caps.get("moz:accessibilityChecks"));
+ ok(caps.has("moz:buildID"));
+ ok(caps.has("moz:debuggerAddress"));
+ ok(caps.has("moz:platformVersion"));
+ ok(caps.has("moz:processID"));
+ ok(caps.has("moz:profile"));
+ equal(true, caps.get("moz:webdriverClick"));
+
+ // No longer supported capabilities
+ ok(!caps.has("moz:useNonSpecCompliantPointerOrigin"));
+});
+
+add_task(function test_Capabilities_toString() {
+ equal("[object Capabilities]", new Capabilities().toString());
+});
+
+add_task(function test_Capabilities_toJSON() {
+ let caps = new Capabilities();
+ let json = caps.toJSON();
+
+ equal(caps.get("browserName"), json.browserName);
+ equal(caps.get("browserVersion"), json.browserVersion);
+ equal(caps.get("platformName"), json.platformName);
+ equal(caps.get("pageLoadStrategy"), json.pageLoadStrategy);
+ equal(caps.get("acceptInsecureCerts"), json.acceptInsecureCerts);
+ deepEqual(caps.get("proxy").toJSON(), json.proxy);
+ deepEqual(caps.get("timeouts").toJSON(), json.timeouts);
+ equal(caps.get("setWindowRect"), json.setWindowRect);
+ equal(caps.get("strictFileInteractability"), json.strictFileInteractability);
+ equal(caps.get("webSocketUrl"), json.webSocketUrl);
+
+ equal(caps.get("moz:accessibilityChecks"), json["moz:accessibilityChecks"]);
+ equal(caps.get("moz:buildID"), json["moz:buildID"]);
+ equal(caps.get("moz:debuggerAddress"), json["moz:debuggerAddress"]);
+ equal(caps.get("moz:platformVersion"), json["moz:platformVersion"]);
+ equal(caps.get("moz:processID"), json["moz:processID"]);
+ equal(caps.get("moz:profile"), json["moz:profile"]);
+ equal(caps.get("moz:webdriverClick"), json["moz:webdriverClick"]);
+});
+
+add_task(function test_Capabilities_fromJSON() {
+ const { fromJSON } = Capabilities;
+
+ // plain
+ for (let typ of [{}, null, undefined]) {
+ ok(fromJSON(typ).has("browserName"));
+ }
+
+ // matching
+ let caps = new Capabilities();
+
+ caps = fromJSON({ acceptInsecureCerts: true });
+ equal(true, caps.get("acceptInsecureCerts"));
+ caps = fromJSON({ acceptInsecureCerts: false });
+ equal(false, caps.get("acceptInsecureCerts"));
+
+ for (let strategy of Object.values(PageLoadStrategy)) {
+ caps = fromJSON({ pageLoadStrategy: strategy });
+ equal(strategy, caps.get("pageLoadStrategy"));
+ }
+
+ let proxyConfig = { proxyType: "manual" };
+ caps = fromJSON({ proxy: proxyConfig });
+ equal("manual", caps.get("proxy").proxyType);
+
+ let timeoutsConfig = { implicit: 123 };
+ caps = fromJSON({ timeouts: timeoutsConfig });
+ equal(123, caps.get("timeouts").implicit);
+
+ caps = fromJSON({ strictFileInteractability: false });
+ equal(false, caps.get("strictFileInteractability"));
+ caps = fromJSON({ strictFileInteractability: true });
+ equal(true, caps.get("strictFileInteractability"));
+
+ caps = fromJSON({ webSocketUrl: true });
+ equal(true, caps.get("webSocketUrl"));
+
+ caps = fromJSON({ "webauthn:virtualAuthenticators": true });
+ equal(true, caps.get("webauthn:virtualAuthenticators"));
+ caps = fromJSON({ "webauthn:virtualAuthenticators": false });
+ equal(false, caps.get("webauthn:virtualAuthenticators"));
+ Assert.throws(
+ () => fromJSON({ "webauthn:virtualAuthenticators": "foo" }),
+ /InvalidArgumentError/
+ );
+
+ caps = fromJSON({ "webauthn:extension:uvm": true });
+ equal(true, caps.get("webauthn:extension:uvm"));
+ caps = fromJSON({ "webauthn:extension:uvm": false });
+ equal(false, caps.get("webauthn:extension:uvm"));
+ Assert.throws(
+ () => fromJSON({ "webauthn:extension:uvm": "foo" }),
+ /InvalidArgumentError/
+ );
+
+ caps = fromJSON({ "webauthn:extension:prf": true });
+ equal(true, caps.get("webauthn:extension:prf"));
+ caps = fromJSON({ "webauthn:extension:prf": false });
+ equal(false, caps.get("webauthn:extension:prf"));
+ Assert.throws(
+ () => fromJSON({ "webauthn:extension:prf": "foo" }),
+ /InvalidArgumentError/
+ );
+
+ caps = fromJSON({ "webauthn:extension:largeBlob": true });
+ equal(true, caps.get("webauthn:extension:largeBlob"));
+ caps = fromJSON({ "webauthn:extension:largeBlob": false });
+ equal(false, caps.get("webauthn:extension:largeBlob"));
+ Assert.throws(
+ () => fromJSON({ "webauthn:extension:largeBlob": "foo" }),
+ /InvalidArgumentError/
+ );
+
+ caps = fromJSON({ "webauthn:extension:credBlob": true });
+ equal(true, caps.get("webauthn:extension:credBlob"));
+ caps = fromJSON({ "webauthn:extension:credBlob": false });
+ equal(false, caps.get("webauthn:extension:credBlob"));
+ Assert.throws(
+ () => fromJSON({ "webauthn:extension:credBlob": "foo" }),
+ /InvalidArgumentError/
+ );
+
+ caps = fromJSON({ "moz:accessibilityChecks": true });
+ equal(true, caps.get("moz:accessibilityChecks"));
+ caps = fromJSON({ "moz:accessibilityChecks": false });
+ equal(false, caps.get("moz:accessibilityChecks"));
+
+ // capability is always populated with null if remote agent is not listening
+ caps = fromJSON({});
+ equal(null, caps.get("moz:debuggerAddress"));
+ caps = fromJSON({ "moz:debuggerAddress": "foo" });
+ equal(null, caps.get("moz:debuggerAddress"));
+ caps = fromJSON({ "moz:debuggerAddress": true });
+ equal(null, caps.get("moz:debuggerAddress"));
+
+ caps = fromJSON({ "moz:webdriverClick": true });
+ equal(true, caps.get("moz:webdriverClick"));
+ caps = fromJSON({ "moz:webdriverClick": false });
+ equal(false, caps.get("moz:webdriverClick"));
+
+ // No longer supported capabilities
+ Assert.throws(
+ () => fromJSON({ "moz:useNonSpecCompliantPointerOrigin": false }),
+ /InvalidArgumentError/
+ );
+ Assert.throws(
+ () => fromJSON({ "moz:useNonSpecCompliantPointerOrigin": true }),
+ /InvalidArgumentError/
+ );
+});
+
+add_task(function test_mergeCapabilities() {
+ // Shadowed values.
+ Assert.throws(
+ () =>
+ mergeCapabilities(
+ { acceptInsecureCerts: true },
+ { acceptInsecureCerts: false }
+ ),
+ /InvalidArgumentError/
+ );
+
+ deepEqual(
+ { acceptInsecureCerts: true },
+ mergeCapabilities({ acceptInsecureCerts: true }, undefined)
+ );
+ deepEqual(
+ { acceptInsecureCerts: true, browserName: "Firefox" },
+ mergeCapabilities({ acceptInsecureCerts: true }, { browserName: "Firefox" })
+ );
+});
+
+add_task(function test_validateCapabilities_invalid() {
+ const invalidCapabilities = [
+ true,
+ 42,
+ "foo",
+ [],
+ { acceptInsecureCerts: "foo" },
+ { browserName: true },
+ { browserVersion: true },
+ { platformName: true },
+ { pageLoadStrategy: "foo" },
+ { proxy: false },
+ { strictFileInteractability: "foo" },
+ { timeouts: false },
+ { unhandledPromptBehavior: false },
+ { webSocketUrl: false },
+ { webSocketUrl: "foo" },
+ { "moz:firefoxOptions": "foo" },
+ { "moz:accessibilityChecks": "foo" },
+ { "moz:webdriverClick": "foo" },
+ { "moz:webdriverClick": 1 },
+ { "moz:useNonSpecCompliantPointerOrigin": false },
+ { "moz:debuggerAddress": "foo" },
+ { "moz:someRandomString": {} },
+ ];
+ for (const capabilities of invalidCapabilities) {
+ Assert.throws(
+ () => validateCapabilities(capabilities),
+ /InvalidArgumentError/
+ );
+ }
+});
+
+add_task(function test_validateCapabilities_valid() {
+ // Ignore null value.
+ deepEqual({}, validateCapabilities({ test: null }));
+
+ const validCapabilities = [
+ { acceptInsecureCerts: true },
+ { browserName: "firefox" },
+ { browserVersion: "12" },
+ { platformName: "linux" },
+ { pageLoadStrategy: "eager" },
+ { proxy: { proxyType: "manual", httpProxy: "test.com" } },
+ { strictFileInteractability: true },
+ { timeouts: { pageLoad: 500 } },
+ { unhandledPromptBehavior: "accept" },
+ { webSocketUrl: true },
+ { "moz:firefoxOptions": {} },
+ { "moz:accessibilityChecks": true },
+ { "moz:webdriverClick": true },
+ { "moz:debuggerAddress": true },
+ { "test:extension": "foo" },
+ ];
+ for (const validCapability of validCapabilities) {
+ deepEqual(validCapability, validateCapabilities(validCapability));
+ }
+});
+
+add_task(function test_processCapabilities() {
+ for (const invalidValue of [
+ { capabilities: null },
+ { capabilities: undefined },
+ { capabilities: "foo" },
+ { capabilities: true },
+ { capabilities: [] },
+ { capabilities: { alwaysMatch: null } },
+ { capabilities: { alwaysMatch: "foo" } },
+ { capabilities: { alwaysMatch: true } },
+ { capabilities: { alwaysMatch: [] } },
+ { capabilities: { firstMatch: null } },
+ { capabilities: { firstMatch: "foo" } },
+ { capabilities: { firstMatch: true } },
+ { capabilities: { firstMatch: {} } },
+ { capabilities: { firstMatch: [] } },
+ ]) {
+ Assert.throws(
+ () => processCapabilities(invalidValue),
+ /InvalidArgumentError/
+ );
+ }
+
+ deepEqual(
+ { acceptInsecureCerts: true },
+ processCapabilities({
+ capabilities: { alwaysMatch: { acceptInsecureCerts: true } },
+ })
+ );
+ deepEqual(
+ { browserName: "Firefox" },
+ processCapabilities({
+ capabilities: { firstMatch: [{ browserName: "Firefox" }] },
+ })
+ );
+ deepEqual(
+ { acceptInsecureCerts: true, browserName: "Firefox" },
+ processCapabilities({
+ capabilities: {
+ alwaysMatch: { acceptInsecureCerts: true },
+ firstMatch: [{ browserName: "Firefox" }],
+ },
+ })
+ );
+});
+
+// use Proxy.toJSON to test marshal
+add_task(function test_marshal() {
+ let proxy = new Proxy();
+
+ // drop empty fields
+ deepEqual({}, proxy.toJSON());
+ proxy.proxyType = "manual";
+ deepEqual({ proxyType: "manual" }, proxy.toJSON());
+ proxy.proxyType = null;
+ deepEqual({}, proxy.toJSON());
+ proxy.proxyType = undefined;
+ deepEqual({}, proxy.toJSON());
+
+ // iterate over object literals
+ proxy.proxyType = { foo: "bar" };
+ deepEqual({ proxyType: { foo: "bar" } }, proxy.toJSON());
+
+ // iterate over complex object that implement toJSON
+ proxy.proxyType = new Proxy();
+ deepEqual({}, proxy.toJSON());
+ proxy.proxyType.proxyType = "manual";
+ deepEqual({ proxyType: { proxyType: "manual" } }, proxy.toJSON());
+
+ // drop objects with no entries
+ proxy.proxyType = { foo: {} };
+ deepEqual({}, proxy.toJSON());
+ proxy.proxyType = { foo: new Proxy() };
+ deepEqual({}, proxy.toJSON());
+});
diff --git a/remote/shared/webdriver/test/xpcshell/test_Errors.js b/remote/shared/webdriver/test/xpcshell/test_Errors.js
new file mode 100644
index 0000000000..22e3526039
--- /dev/null
+++ b/remote/shared/webdriver/test/xpcshell/test_Errors.js
@@ -0,0 +1,543 @@
+/* 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/webdriver/Errors.sys.mjs"
+);
+
+const errors = [
+ error.WebDriverError,
+
+ error.DetachedShadowRootError,
+ error.ElementClickInterceptedError,
+ error.ElementNotAccessibleError,
+ error.ElementNotInteractableError,
+ error.InsecureCertificateError,
+ error.InvalidArgumentError,
+ error.InvalidCookieDomainError,
+ error.InvalidElementStateError,
+ error.InvalidSelectorError,
+ error.InvalidSessionIDError,
+ error.JavaScriptError,
+ error.MoveTargetOutOfBoundsError,
+ error.NoSuchAlertError,
+ error.NoSuchElementError,
+ error.NoSuchFrameError,
+ error.NoSuchHandleError,
+ error.NoSuchInterceptError,
+ error.NoSuchNodeError,
+ error.NoSuchRequestError,
+ error.NoSuchScriptError,
+ error.NoSuchShadowRootError,
+ error.NoSuchWindowError,
+ error.ScriptTimeoutError,
+ error.SessionNotCreatedError,
+ error.StaleElementReferenceError,
+ error.TimeoutError,
+ error.UnableToSetCookieError,
+ error.UnexpectedAlertOpenError,
+ error.UnknownCommandError,
+ error.UnknownError,
+ error.UnsupportedOperationError,
+];
+
+function notok(condition) {
+ ok(!condition);
+}
+
+add_task(function test_isError() {
+ notok(error.isError(null));
+ notok(error.isError([]));
+ notok(error.isError(new Date()));
+
+ ok(error.isError(new Components.Exception()));
+ ok(error.isError(new Error()));
+ ok(error.isError(new EvalError()));
+ ok(error.isError(new InternalError()));
+ ok(error.isError(new RangeError()));
+ ok(error.isError(new ReferenceError()));
+ ok(error.isError(new SyntaxError()));
+ ok(error.isError(new TypeError()));
+ ok(error.isError(new URIError()));
+
+ errors.forEach(err => ok(error.isError(new err())));
+});
+
+add_task(function test_isWebDriverError() {
+ notok(error.isWebDriverError(new Components.Exception()));
+ notok(error.isWebDriverError(new Error()));
+ notok(error.isWebDriverError(new EvalError()));
+ notok(error.isWebDriverError(new InternalError()));
+ notok(error.isWebDriverError(new RangeError()));
+ notok(error.isWebDriverError(new ReferenceError()));
+ notok(error.isWebDriverError(new SyntaxError()));
+ notok(error.isWebDriverError(new TypeError()));
+ notok(error.isWebDriverError(new URIError()));
+
+ errors.forEach(err => ok(error.isWebDriverError(new err())));
+});
+
+add_task(function test_wrap() {
+ // webdriver-derived errors should not be wrapped
+ errors.forEach(err => {
+ const unwrappedError = new err("foo");
+ const wrappedError = error.wrap(unwrappedError);
+
+ ok(wrappedError instanceof error.WebDriverError);
+ ok(wrappedError instanceof err);
+ equal(wrappedError.name, unwrappedError.name);
+ equal(wrappedError.status, unwrappedError.status);
+ equal(wrappedError.message, "foo");
+ });
+
+ // JS errors should be wrapped in UnknownError and retain their type
+ // as part of the message field.
+ const jsErrors = [
+ Error,
+ EvalError,
+ InternalError,
+ RangeError,
+ ReferenceError,
+ SyntaxError,
+ TypeError,
+ URIError,
+ ];
+
+ jsErrors.forEach(err => {
+ const originalError = new err("foo");
+ const wrappedError = error.wrap(originalError);
+
+ ok(wrappedError instanceof error.UnknownError);
+ equal(wrappedError.name, "UnknownError");
+ equal(wrappedError.status, "unknown error");
+ equal(wrappedError.message, `${originalError.name}: foo`);
+ });
+});
+
+add_task(function test_stringify() {
+ equal("<unprintable error>", error.stringify());
+ equal("<unprintable error>", error.stringify("foo"));
+ equal("[object Object]", error.stringify({}));
+ equal("[object Object]\nfoo", error.stringify({ stack: "foo" }));
+ equal("Error: foo", error.stringify(new Error("foo")).split("\n")[0]);
+
+ errors.forEach(err => {
+ const e = new err("foo");
+
+ equal(`${e.name}: foo`, error.stringify(e).split("\n")[0]);
+ });
+});
+
+add_task(function test_constructor_from_error() {
+ const data = { a: 3, b: "bar" };
+ const origError = new error.WebDriverError("foo", data);
+
+ errors.forEach(err => {
+ const newError = new err(origError);
+
+ ok(newError instanceof err);
+ equal(newError.message, origError.message);
+ equal(newError.stack, origError.stack);
+ equal(newError.data, origError.data);
+ });
+});
+
+add_task(function test_stack() {
+ equal("string", typeof error.stack());
+ ok(error.stack().includes("test_stack"));
+ ok(!error.stack().includes("add_task"));
+});
+
+add_task(function test_toJSON() {
+ errors.forEach(err => {
+ const e0 = new err();
+ const e0_json = e0.toJSON();
+ equal(e0_json.error, e0.status);
+ equal(e0_json.message, "");
+ equal(e0_json.stacktrace, e0.stack);
+ equal(e0_json.data, undefined);
+
+ // message property
+ const e1 = new err("a");
+ const e1_json = e1.toJSON();
+
+ equal(e1_json.message, e1.message);
+ equal(e1_json.stacktrace, e1.stack);
+ equal(e1_json.data, undefined);
+
+ // message and optional data property
+ const data = { a: 3, b: "bar" };
+ const e2 = new err("foo", data);
+ const e2_json = e2.toJSON();
+
+ equal(e2.status, e2_json.error);
+ equal(e2.message, e2_json.message);
+ equal(e2_json.data, data);
+ });
+});
+
+add_task(function test_fromJSON() {
+ errors.forEach(err => {
+ Assert.throws(
+ () => err.fromJSON({ error: "foo" }),
+ /Not of WebDriverError descent/
+ );
+ Assert.throws(
+ () => err.fromJSON({ error: "Error" }),
+ /Not of WebDriverError descent/
+ );
+ Assert.throws(() => err.fromJSON({}), /Undeserialisable error type/);
+ Assert.throws(() => err.fromJSON(undefined), /TypeError/);
+
+ // message and stack
+ const e1 = new err("1");
+ const e1_json = { error: e1.status, message: "3", stacktrace: "4" };
+ const e1_fromJSON = error.WebDriverError.fromJSON(e1_json);
+
+ ok(e1_fromJSON instanceof error.WebDriverError);
+ ok(e1_fromJSON instanceof err);
+ equal(e1_fromJSON.name, e1.name);
+ equal(e1_fromJSON.status, e1_json.error);
+ equal(e1_fromJSON.message, e1_json.message);
+ equal(e1_fromJSON.stack, e1_json.stacktrace);
+
+ // message and optional data
+ const e2_data = { a: 3, b: "bar" };
+ const e2 = new err("1", e2_data);
+ const e2_json = { error: e1.status, message: "3", data: e2_data };
+ const e2_fromJSON = error.WebDriverError.fromJSON(e2_json);
+
+ ok(e2_fromJSON instanceof error.WebDriverError);
+ ok(e2_fromJSON instanceof err);
+ equal(e2_fromJSON.name, e2.name);
+ equal(e2_fromJSON.status, e2_json.error);
+ equal(e2_fromJSON.message, e2_json.message);
+ equal(e2_fromJSON.data, e2_json.data);
+
+ // parity with toJSON
+ const e3_data = { a: 3, b: "bar" };
+ const e3 = new err("1", e3_data);
+ const e3_json = e3.toJSON();
+ const e3_fromJSON = error.WebDriverError.fromJSON(e3_json);
+
+ equal(e3_json.error, e3_fromJSON.status);
+ equal(e3_json.message, e3_fromJSON.message);
+ equal(e3_json.stacktrace, e3_fromJSON.stack);
+ });
+});
+
+add_task(function test_WebDriverError() {
+ let err = new error.WebDriverError("foo");
+ equal("WebDriverError", err.name);
+ equal("foo", err.message);
+ equal("webdriver error", err.status);
+ ok(err instanceof error.WebDriverError);
+});
+
+add_task(function test_DetachedShadowRootError() {
+ let err = new error.DetachedShadowRootError("foo");
+ equal("DetachedShadowRootError", err.name);
+ equal("foo", err.message);
+ equal("detached shadow root", err.status);
+ ok(err instanceof error.WebDriverError);
+});
+
+add_task(function test_ElementClickInterceptedError() {
+ let otherEl = {
+ hasAttribute: attr => attr in otherEl,
+ getAttribute: attr => (attr in otherEl ? otherEl[attr] : null),
+ nodeType: 1,
+ localName: "a",
+ };
+ let obscuredEl = {
+ hasAttribute: attr => attr in obscuredEl,
+ getAttribute: attr => (attr in obscuredEl ? obscuredEl[attr] : null),
+ nodeType: 1,
+ localName: "b",
+ ownerDocument: {
+ elementFromPoint() {
+ return otherEl;
+ },
+ },
+ style: {
+ pointerEvents: "auto",
+ },
+ };
+
+ let err1 = new error.ElementClickInterceptedError(
+ undefined,
+ undefined,
+ obscuredEl,
+ { x: 1, y: 2 }
+ );
+ equal("ElementClickInterceptedError", err1.name);
+ equal(
+ "Element <b> is not clickable at point (1,2) " +
+ "because another element <a> obscures it",
+ err1.message
+ );
+ equal("element click intercepted", err1.status);
+ ok(err1 instanceof error.WebDriverError);
+
+ obscuredEl.style.pointerEvents = "none";
+ let err2 = new error.ElementClickInterceptedError(
+ undefined,
+ undefined,
+ obscuredEl,
+ { x: 1, y: 2 }
+ );
+ equal(
+ "Element <b> is not clickable at point (1,2) " +
+ "because it does not have pointer events enabled, " +
+ "and element <a> would receive the click instead",
+ err2.message
+ );
+});
+
+add_task(function test_ElementNotAccessibleError() {
+ let err = new error.ElementNotAccessibleError("foo");
+ equal("ElementNotAccessibleError", err.name);
+ equal("foo", err.message);
+ equal("element not accessible", err.status);
+ ok(err instanceof error.WebDriverError);
+});
+
+add_task(function test_ElementNotInteractableError() {
+ let err = new error.ElementNotInteractableError("foo");
+ equal("ElementNotInteractableError", err.name);
+ equal("foo", err.message);
+ equal("element not interactable", err.status);
+ ok(err instanceof error.WebDriverError);
+});
+
+add_task(function test_InsecureCertificateError() {
+ let err = new error.InsecureCertificateError("foo");
+ equal("InsecureCertificateError", err.name);
+ equal("foo", err.message);
+ equal("insecure certificate", err.status);
+ ok(err instanceof error.WebDriverError);
+});
+
+add_task(function test_InvalidArgumentError() {
+ let err = new error.InvalidArgumentError("foo");
+ equal("InvalidArgumentError", err.name);
+ equal("foo", err.message);
+ equal("invalid argument", err.status);
+ ok(err instanceof error.WebDriverError);
+});
+
+add_task(function test_InvalidCookieDomainError() {
+ let err = new error.InvalidCookieDomainError("foo");
+ equal("InvalidCookieDomainError", err.name);
+ equal("foo", err.message);
+ equal("invalid cookie domain", err.status);
+ ok(err instanceof error.WebDriverError);
+});
+
+add_task(function test_InvalidElementStateError() {
+ let err = new error.InvalidElementStateError("foo");
+ equal("InvalidElementStateError", err.name);
+ equal("foo", err.message);
+ equal("invalid element state", err.status);
+ ok(err instanceof error.WebDriverError);
+});
+
+add_task(function test_InvalidSelectorError() {
+ let err = new error.InvalidSelectorError("foo");
+ equal("InvalidSelectorError", err.name);
+ equal("foo", err.message);
+ equal("invalid selector", err.status);
+ ok(err instanceof error.WebDriverError);
+});
+
+add_task(function test_InvalidSessionIDError() {
+ let err = new error.InvalidSessionIDError("foo");
+ equal("InvalidSessionIDError", err.name);
+ equal("foo", err.message);
+ equal("invalid session id", err.status);
+ ok(err instanceof error.WebDriverError);
+});
+
+add_task(function test_JavaScriptError() {
+ let err = new error.JavaScriptError("foo");
+ equal("JavaScriptError", err.name);
+ equal("foo", err.message);
+ equal("javascript error", err.status);
+ ok(err instanceof error.WebDriverError);
+
+ equal("", new error.JavaScriptError(undefined).message);
+
+ let superErr = new RangeError("foo");
+ let inheritedErr = new error.JavaScriptError(superErr);
+ equal("RangeError: foo", inheritedErr.message);
+ equal(superErr.stack, inheritedErr.stack);
+});
+
+add_task(function test_MoveTargetOutOfBoundsError() {
+ let err = new error.MoveTargetOutOfBoundsError("foo");
+ equal("MoveTargetOutOfBoundsError", err.name);
+ equal("foo", err.message);
+ equal("move target out of bounds", err.status);
+ ok(err instanceof error.WebDriverError);
+});
+
+add_task(function test_NoSuchAlertError() {
+ let err = new error.NoSuchAlertError("foo");
+ equal("NoSuchAlertError", err.name);
+ equal("foo", err.message);
+ equal("no such alert", err.status);
+ ok(err instanceof error.WebDriverError);
+});
+
+add_task(function test_NoSuchElementError() {
+ let err = new error.NoSuchElementError("foo");
+ equal("NoSuchElementError", err.name);
+ equal("foo", err.message);
+ equal("no such element", err.status);
+ ok(err instanceof error.WebDriverError);
+});
+
+add_task(function test_NoSuchFrameError() {
+ let err = new error.NoSuchFrameError("foo");
+ equal("NoSuchFrameError", err.name);
+ equal("foo", err.message);
+ equal("no such frame", err.status);
+ ok(err instanceof error.WebDriverError);
+});
+
+add_task(function test_NoSuchHandleError() {
+ let err = new error.NoSuchHandleError("foo");
+ equal("NoSuchHandleError", err.name);
+ equal("foo", err.message);
+ equal("no such handle", err.status);
+ ok(err instanceof error.WebDriverError);
+});
+
+add_task(function test_NoSuchInterceptError() {
+ let err = new error.NoSuchInterceptError("foo");
+ equal("NoSuchInterceptError", err.name);
+ equal("foo", err.message);
+ equal("no such intercept", err.status);
+ ok(err instanceof error.WebDriverError);
+});
+
+add_task(function test_NoSuchNodeError() {
+ let err = new error.NoSuchNodeError("foo");
+ equal("NoSuchNodeError", err.name);
+ equal("foo", err.message);
+ equal("no such node", err.status);
+ ok(err instanceof error.WebDriverError);
+});
+
+add_task(function test_NoSuchRequestError() {
+ let err = new error.NoSuchRequestError("foo");
+ equal("NoSuchRequestError", err.name);
+ equal("foo", err.message);
+ equal("no such request", err.status);
+ ok(err instanceof error.WebDriverError);
+});
+
+add_task(function test_NoSuchScriptError() {
+ let err = new error.NoSuchScriptError("foo");
+ equal("NoSuchScriptError", err.name);
+ equal("foo", err.message);
+ equal("no such script", err.status);
+ ok(err instanceof error.WebDriverError);
+});
+
+add_task(function test_NoSuchShadowRootError() {
+ let err = new error.NoSuchShadowRootError("foo");
+ equal("NoSuchShadowRootError", err.name);
+ equal("foo", err.message);
+ equal("no such shadow root", err.status);
+ ok(err instanceof error.WebDriverError);
+});
+
+add_task(function test_NoSuchUserContextError() {
+ let err = new error.NoSuchUserContextError("foo");
+ equal("NoSuchUserContextError", err.name);
+ equal("foo", err.message);
+ equal("no such user context", err.status);
+ ok(err instanceof error.WebDriverError);
+});
+
+add_task(function test_NoSuchWindowError() {
+ let err = new error.NoSuchWindowError("foo");
+ equal("NoSuchWindowError", err.name);
+ equal("foo", err.message);
+ equal("no such window", err.status);
+ ok(err instanceof error.WebDriverError);
+});
+
+add_task(function test_ScriptTimeoutError() {
+ let err = new error.ScriptTimeoutError("foo");
+ equal("ScriptTimeoutError", err.name);
+ equal("foo", err.message);
+ equal("script timeout", err.status);
+ ok(err instanceof error.WebDriverError);
+});
+
+add_task(function test_SessionNotCreatedError() {
+ let err = new error.SessionNotCreatedError("foo");
+ equal("SessionNotCreatedError", err.name);
+ equal("foo", err.message);
+ equal("session not created", err.status);
+ ok(err instanceof error.WebDriverError);
+});
+
+add_task(function test_StaleElementReferenceError() {
+ let err = new error.StaleElementReferenceError("foo");
+ equal("StaleElementReferenceError", err.name);
+ equal("foo", err.message);
+ equal("stale element reference", err.status);
+ ok(err instanceof error.WebDriverError);
+});
+
+add_task(function test_TimeoutError() {
+ let err = new error.TimeoutError("foo");
+ equal("TimeoutError", err.name);
+ equal("foo", err.message);
+ equal("timeout", err.status);
+ ok(err instanceof error.WebDriverError);
+});
+
+add_task(function test_UnableToSetCookieError() {
+ let err = new error.UnableToSetCookieError("foo");
+ equal("UnableToSetCookieError", err.name);
+ equal("foo", err.message);
+ equal("unable to set cookie", err.status);
+ ok(err instanceof error.WebDriverError);
+});
+
+add_task(function test_UnexpectedAlertOpenError() {
+ let err = new error.UnexpectedAlertOpenError("foo");
+ equal("UnexpectedAlertOpenError", err.name);
+ equal("foo", err.message);
+ equal("unexpected alert open", err.status);
+ ok(err instanceof error.WebDriverError);
+});
+
+add_task(function test_UnknownCommandError() {
+ let err = new error.UnknownCommandError("foo");
+ equal("UnknownCommandError", err.name);
+ equal("foo", err.message);
+ equal("unknown command", err.status);
+ ok(err instanceof error.WebDriverError);
+});
+
+add_task(function test_UnknownError() {
+ let err = new error.UnknownError("foo");
+ equal("UnknownError", err.name);
+ equal("foo", err.message);
+ equal("unknown error", err.status);
+ ok(err instanceof error.WebDriverError);
+});
+
+add_task(function test_UnsupportedOperationError() {
+ let err = new error.UnsupportedOperationError("foo");
+ equal("UnsupportedOperationError", err.name);
+ equal("foo", err.message);
+ equal("unsupported operation", err.status);
+ ok(err instanceof error.WebDriverError);
+});
diff --git a/remote/shared/webdriver/test/xpcshell/test_NodeCache.js b/remote/shared/webdriver/test/xpcshell/test_NodeCache.js
new file mode 100644
index 0000000000..4efe9fba3a
--- /dev/null
+++ b/remote/shared/webdriver/test/xpcshell/test_NodeCache.js
@@ -0,0 +1,265 @@
+const { NodeCache } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/webdriver/NodeCache.sys.mjs"
+);
+
+function setupTest() {
+ const browser = Services.appShell.createWindowlessBrowser(false);
+
+ browser.document.body.innerHTML = `
+ <div id="foo" style="margin: 50px">
+ <iframe></iframe>
+ <video></video>
+ <svg xmlns="http://www.w3.org/2000/svg"></svg>
+ <textarea></textarea>
+ </div>
+ <div id="with-comment"><!-- Comment --></div>
+ `;
+
+ const divEl = browser.document.querySelector("div");
+ const svgEl = browser.document.querySelector("svg");
+ const textareaEl = browser.document.querySelector("textarea");
+ const videoEl = browser.document.querySelector("video");
+
+ const iframeEl = browser.document.querySelector("iframe");
+ const childEl = iframeEl.contentDocument.createElement("div");
+ iframeEl.contentDocument.body.appendChild(childEl);
+
+ const shadowRoot = videoEl.openOrClosedShadowRoot;
+
+ return {
+ browser,
+ nodeCache: new NodeCache(),
+ childEl,
+ divEl,
+ iframeEl,
+ shadowRoot,
+ seenNodeIds: new Map(),
+ svgEl,
+ textareaEl,
+ videoEl,
+ };
+}
+
+add_task(function getOrCreateNodeReference_invalid() {
+ const { nodeCache, seenNodeIds } = setupTest();
+
+ const invalidValues = [null, undefined, "foo", 42, true, [], {}];
+
+ for (const value of invalidValues) {
+ info(`Testing value: ${value}`);
+ Assert.throws(
+ () => nodeCache.getOrCreateNodeReference(value, seenNodeIds),
+ /TypeError/
+ );
+ }
+});
+
+add_task(function getOrCreateNodeReference_supportedNodeTypes() {
+ const { browser, divEl, nodeCache, seenNodeIds } = setupTest();
+
+ // Bug 1820734: No ownerGlobal is available in XPCShell tests
+ // const xmlDocument = new DOMParser().parseFromString(
+ // "<xml></xml>",
+ // "application/xml"
+ // );
+
+ const values = [
+ { node: divEl, type: Node.ELEMENT_NODE },
+ { node: divEl.attributes[0], type: Node.ATTRIBUTE_NODE },
+ { node: browser.document.createTextNode("foo"), type: Node.TEXT_NODE },
+ // Bug 1820734: No ownerGlobal is available in XPCShell tests
+ // {
+ // node: xmlDocument.createCDATASection("foo"),
+ // type: Node.CDATA_SECTION_NODE,
+ // },
+ {
+ node: browser.document.createProcessingInstruction(
+ "xml-stylesheet",
+ "href='foo.css'"
+ ),
+ type: Node.PROCESSING_INSTRUCTION_NODE_NODE,
+ },
+ { node: browser.document.createComment("foo"), type: Node.COMMENT_NODE },
+ { node: browser.document, type: Node.Document_NODE },
+ {
+ node: browser.document.implementation.createDocumentType(
+ "foo",
+ "bar",
+ "dtd"
+ ),
+ type: Node.DOCUMENT_TYPE_NODE_NODE,
+ },
+ {
+ node: browser.document.createDocumentFragment(),
+ type: Node.DOCUMENT_FRAGMENT_NODE,
+ },
+ ];
+
+ values.forEach((value, index) => {
+ info(`Testing value: ${value.type}`);
+ const nodeRef = nodeCache.getOrCreateNodeReference(value.node, seenNodeIds);
+ equal(nodeCache.size, index + 1);
+ equal(typeof nodeRef, "string");
+ ok(seenNodeIds.get(browser.browsingContext).includes(nodeRef));
+ });
+});
+
+add_task(function getOrCreateNodeReference_referenceAlreadyCreated() {
+ const { browser, divEl, nodeCache, seenNodeIds } = setupTest();
+
+ const divElRef = nodeCache.getOrCreateNodeReference(divEl, seenNodeIds);
+ const divElRefOther = nodeCache.getOrCreateNodeReference(divEl, seenNodeIds);
+
+ equal(divElRefOther, divElRef);
+ equal(nodeCache.size, 1);
+ equal(seenNodeIds.size, 1);
+ ok(seenNodeIds.get(browser.browsingContext).includes(divElRef));
+});
+
+add_task(function getOrCreateNodeReference_differentReference() {
+ const { browser, divEl, nodeCache, seenNodeIds, shadowRoot } = setupTest();
+
+ const divElRef = nodeCache.getOrCreateNodeReference(divEl, seenNodeIds);
+ equal(nodeCache.size, 1);
+ equal(seenNodeIds.size, 1);
+ ok(seenNodeIds.get(browser.browsingContext).includes(divElRef));
+
+ const shadowRootRef = nodeCache.getOrCreateNodeReference(
+ shadowRoot,
+ seenNodeIds
+ );
+ equal(nodeCache.size, 2);
+ equal(seenNodeIds.size, 1);
+ ok(seenNodeIds.get(browser.browsingContext).includes(divElRef));
+ ok(seenNodeIds.get(browser.browsingContext).includes(shadowRootRef));
+
+ notEqual(divElRef, shadowRootRef);
+});
+
+add_task(function getOrCreateNodeReference_differentReferencePerNodeCache() {
+ const { browser, divEl, nodeCache, seenNodeIds } = setupTest();
+ const nodeCache2 = new NodeCache();
+
+ const divElRef1 = nodeCache.getOrCreateNodeReference(divEl, seenNodeIds);
+ const divElRef2 = nodeCache2.getOrCreateNodeReference(divEl, seenNodeIds);
+
+ notEqual(divElRef1, divElRef2);
+ equal(
+ nodeCache.getNode(browser.browsingContext, divElRef1),
+ nodeCache2.getNode(browser.browsingContext, divElRef2)
+ );
+
+ equal(seenNodeIds.size, 1);
+ ok(seenNodeIds.get(browser.browsingContext).includes(divElRef1));
+ ok(seenNodeIds.get(browser.browsingContext).includes(divElRef2));
+
+ equal(nodeCache.getNode(browser.browsingContext, divElRef2), null);
+});
+
+add_task(function clear() {
+ const { browser, divEl, nodeCache, seenNodeIds, svgEl } = setupTest();
+
+ nodeCache.getOrCreateNodeReference(divEl, seenNodeIds);
+ nodeCache.getOrCreateNodeReference(svgEl, seenNodeIds);
+ equal(nodeCache.size, 2);
+ equal(seenNodeIds.size, 1);
+
+ // Clear requires explicit arguments.
+ Assert.throws(() => nodeCache.clear(), /Error/);
+
+ // Clear references for a different browsing context
+ const browser2 = Services.appShell.createWindowlessBrowser(false);
+ const imgEl = browser2.document.createElement("img");
+ const imgElRef = nodeCache.getOrCreateNodeReference(imgEl, seenNodeIds);
+ equal(nodeCache.size, 3);
+ equal(seenNodeIds.size, 2);
+
+ nodeCache.clear({ browsingContext: browser.browsingContext });
+ equal(nodeCache.size, 1);
+ equal(nodeCache.getNode(browser2.browsingContext, imgElRef), imgEl);
+
+ // Clear all references
+ nodeCache.getOrCreateNodeReference(divEl, seenNodeIds);
+ equal(nodeCache.size, 2);
+ equal(seenNodeIds.size, 2);
+
+ nodeCache.clear({ all: true });
+ equal(nodeCache.size, 0);
+});
+
+add_task(function getNode_multiple_nodes() {
+ const { browser, divEl, nodeCache, seenNodeIds, svgEl } = setupTest();
+
+ const divElRef = nodeCache.getOrCreateNodeReference(divEl, seenNodeIds);
+ const svgElRef = nodeCache.getOrCreateNodeReference(svgEl, seenNodeIds);
+
+ equal(nodeCache.getNode(browser.browsingContext, svgElRef), svgEl);
+ equal(nodeCache.getNode(browser.browsingContext, divElRef), divEl);
+});
+
+add_task(function getNode_differentBrowsingContextInSameGroup() {
+ const { iframeEl, divEl, nodeCache, seenNodeIds } = setupTest();
+
+ const divElRef = nodeCache.getOrCreateNodeReference(divEl, seenNodeIds);
+ equal(nodeCache.size, 1);
+
+ equal(
+ nodeCache.getNode(iframeEl.contentWindow.browsingContext, divElRef),
+ divEl
+ );
+});
+
+add_task(function getNode_differentBrowsingContextInOtherGroup() {
+ const { divEl, nodeCache, seenNodeIds } = setupTest();
+
+ const divElRef = nodeCache.getOrCreateNodeReference(divEl, seenNodeIds);
+ equal(nodeCache.size, 1);
+
+ const browser2 = Services.appShell.createWindowlessBrowser(false);
+ equal(nodeCache.getNode(browser2.browsingContext, divElRef), null);
+});
+
+add_task(async function getNode_nodeDeleted() {
+ const { browser, nodeCache, seenNodeIds } = setupTest();
+ let el = browser.document.createElement("div");
+
+ const elRef = nodeCache.getOrCreateNodeReference(el, seenNodeIds);
+
+ // Delete element and force a garbage collection
+ el = null;
+
+ await doGC();
+
+ equal(nodeCache.getNode(browser.browsingContext, elRef), null);
+});
+
+add_task(function getNodeDetails_forTopBrowsingContext() {
+ const { browser, divEl, nodeCache, seenNodeIds } = setupTest();
+
+ const divElRef = nodeCache.getOrCreateNodeReference(divEl, seenNodeIds);
+
+ const nodeDetails = nodeCache.getReferenceDetails(divElRef);
+ equal(nodeDetails.browserId, browser.browsingContext.browserId);
+ equal(nodeDetails.browsingContextGroupId, browser.browsingContext.group.id);
+ equal(nodeDetails.browsingContextId, browser.browsingContext.id);
+ ok(nodeDetails.isTopBrowsingContext);
+ ok(nodeDetails.nodeWeakRef);
+ equal(nodeDetails.nodeWeakRef.get(), divEl);
+});
+
+add_task(async function getNodeDetails_forChildBrowsingContext() {
+ const { browser, iframeEl, childEl, nodeCache, seenNodeIds } = setupTest();
+
+ const childElRef = nodeCache.getOrCreateNodeReference(childEl, seenNodeIds);
+
+ const nodeDetails = nodeCache.getReferenceDetails(childElRef);
+ equal(nodeDetails.browserId, browser.browsingContext.browserId);
+ equal(nodeDetails.browsingContextGroupId, browser.browsingContext.group.id);
+ equal(
+ nodeDetails.browsingContextId,
+ iframeEl.contentWindow.browsingContext.id
+ );
+ ok(!nodeDetails.isTopBrowsingContext);
+ ok(nodeDetails.nodeWeakRef);
+ equal(nodeDetails.nodeWeakRef.get(), childEl);
+});
diff --git a/remote/shared/webdriver/test/xpcshell/test_Session.js b/remote/shared/webdriver/test/xpcshell/test_Session.js
new file mode 100644
index 0000000000..3b3d893319
--- /dev/null
+++ b/remote/shared/webdriver/test/xpcshell/test_Session.js
@@ -0,0 +1,72 @@
+/* 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";
+
+const { Capabilities, Timeouts } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/webdriver/Capabilities.sys.mjs"
+);
+const { getWebDriverSessionById, WebDriverSession } =
+ ChromeUtils.importESModule(
+ "chrome://remote/content/shared/webdriver/Session.sys.mjs"
+ );
+
+add_task(function test_WebDriverSession_ctor() {
+ const session = new WebDriverSession();
+
+ equal(typeof session.id, "string");
+ ok(session.capabilities instanceof Capabilities);
+});
+
+add_task(function test_WebDriverSession_destroy() {
+ const session = new WebDriverSession();
+
+ session.destroy();
+});
+
+add_task(function test_WebDriverSession_getters() {
+ const session = new WebDriverSession();
+
+ equal(
+ session.a11yChecks,
+ session.capabilities.get("moz:accessibilityChecks")
+ );
+ equal(session.pageLoadStrategy, session.capabilities.get("pageLoadStrategy"));
+ equal(session.proxy, session.capabilities.get("proxy"));
+ equal(
+ session.strictFileInteractability,
+ session.capabilities.get("strictFileInteractability")
+ );
+ equal(session.timeouts, session.capabilities.get("timeouts"));
+ equal(
+ session.unhandledPromptBehavior,
+ session.capabilities.get("unhandledPromptBehavior")
+ );
+});
+
+add_task(function test_WebDriverSession_setters() {
+ const session = new WebDriverSession();
+
+ const timeouts = new Timeouts();
+ timeouts.pageLoad = 45;
+
+ session.timeouts = timeouts;
+ equal(session.timeouts, session.capabilities.get("timeouts"));
+});
+
+add_task(function test_getWebDriverSessionById() {
+ const session1 = new WebDriverSession();
+ const session2 = new WebDriverSession();
+
+ equal(getWebDriverSessionById(session1.id), session1);
+ equal(getWebDriverSessionById(session2.id), session2);
+
+ session1.destroy();
+ equal(getWebDriverSessionById(session1.id), undefined);
+ equal(getWebDriverSessionById(session2.id), session2);
+
+ session2.destroy();
+ equal(getWebDriverSessionById(session1.id), undefined);
+ equal(getWebDriverSessionById(session2.id), undefined);
+});
diff --git a/remote/shared/webdriver/test/xpcshell/test_URLPattern_invalid.js b/remote/shared/webdriver/test/xpcshell/test_URLPattern_invalid.js
new file mode 100644
index 0000000000..0e537a210f
--- /dev/null
+++ b/remote/shared/webdriver/test/xpcshell/test_URLPattern_invalid.js
@@ -0,0 +1,129 @@
+/* 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 { parseURLPattern } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/webdriver/URLPattern.sys.mjs"
+);
+
+add_task(
+ async function test_parseURLPattern_patternPattern_unescapedCharacters() {
+ const properties = ["protocol", "hostname", "port", "pathname", "search"];
+ const values = ["*", "(", ")", "{", "}"];
+ for (const property of properties) {
+ for (const value of values) {
+ Assert.throws(
+ () => parseURLPattern({ type: "pattern", [property]: value }),
+ /InvalidArgumentError/
+ );
+ }
+ }
+ }
+);
+
+add_task(async function test_parseURLPattern_patternPattern_protocol() {
+ const values = [
+ "",
+ "http/",
+ "http\\*",
+ "http\\(",
+ "http\\)",
+ "http\\{",
+ "http\\}",
+ "http#",
+ "http@",
+ "http%",
+ ];
+ for (const value of values) {
+ Assert.throws(
+ () => parseURLPattern({ type: "pattern", protocol: value }),
+ /InvalidArgumentError/
+ );
+ }
+});
+
+add_task(
+ async function test_parseURLPattern_patternPattern_unsupported_protocol() {
+ const values = ["ftp", "abc", "webpack"];
+ for (const value of values) {
+ Assert.throws(
+ () => parseURLPattern({ type: "pattern", protocol: value }),
+ /UnsupportedOperationError/
+ );
+ }
+ }
+);
+
+add_task(async function test_parseURLPattern_patternPattern_hostname() {
+ const values = ["", "abc/com/", "abc?com", "abc#com", "abc:com"];
+ for (const value of values) {
+ Assert.throws(
+ () => parseURLPattern({ type: "pattern", hostname: value }),
+ /InvalidArgumentError/
+ );
+ }
+});
+
+add_task(async function test_parseURLPattern_patternPattern_port() {
+ const values = ["", "abcd", "-1", "80 ", "1.3", ":80", "65536"];
+ for (const value of values) {
+ Assert.throws(
+ () => parseURLPattern({ type: "pattern", port: value }),
+ /InvalidArgumentError/
+ );
+ }
+});
+
+add_task(async function test_parseURLPattern_patternPattern_pathname() {
+ const values = ["path?", "path#"];
+ for (const value of values) {
+ Assert.throws(
+ () => parseURLPattern({ type: "pattern", pathname: value }),
+ /InvalidArgumentError/
+ );
+ }
+});
+
+add_task(async function test_parseURLPattern_patternPattern_search() {
+ const values = ["search#"];
+ for (const value of values) {
+ Assert.throws(
+ () => parseURLPattern({ type: "pattern", search: value }),
+ /InvalidArgumentError/
+ );
+ }
+});
+
+add_task(async function test_parseURLPattern_stringPattern_invalid_url() {
+ const values = ["", "invalid", "http:invalid:url", "[1::", "127.0..1"];
+ for (const value of values) {
+ Assert.throws(
+ () => parseURLPattern({ type: "string", pattern: value }),
+ /InvalidArgumentError/
+ );
+ }
+});
+
+add_task(
+ async function test_parseURLPattern_stringPattern_unescaped_characters() {
+ const values = ["*", "(", ")", "{", "}"];
+ for (const value of values) {
+ Assert.throws(
+ () => parseURLPattern({ type: "string", pattern: value }),
+ /InvalidArgumentError/
+ );
+ }
+ }
+);
+
+add_task(
+ async function test_parseURLPattern_stringPattern_unsupported_protocol() {
+ const values = ["ftp://some/path", "abc:pathplaceholder", "webpack://test"];
+ for (const value of values) {
+ Assert.throws(
+ () => parseURLPattern({ type: "string", pattern: value }),
+ /UnsupportedOperationError/
+ );
+ }
+ }
+);
diff --git a/remote/shared/webdriver/test/xpcshell/test_URLPattern_matchURLPattern.js b/remote/shared/webdriver/test/xpcshell/test_URLPattern_matchURLPattern.js
new file mode 100644
index 0000000000..f4831d583f
--- /dev/null
+++ b/remote/shared/webdriver/test/xpcshell/test_URLPattern_matchURLPattern.js
@@ -0,0 +1,607 @@
+/* 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 { matchURLPattern, parseURLPattern } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/webdriver/URLPattern.sys.mjs"
+);
+
+// Test several variations which should match a string based http://example.com
+// pattern.
+add_task(async function test_matchURLPattern_url_variations() {
+ const pattern = parseURLPattern({
+ type: "string",
+ pattern: "http://example.com",
+ });
+
+ const urls = [
+ "http://example.com",
+ "http://EXAMPLE.com",
+ "http://user:password@example.com",
+ "http://example.com:80",
+ "http://example.com/",
+ "http://example.com/#some-hash",
+ "http:example.com",
+ "http:/example.com",
+ "http://example.com?",
+ "http://example.com/?",
+ ];
+ for (const url of urls) {
+ ok(
+ matchURLPattern(pattern, url),
+ `url "${url}" should match pattern "http://example.com"`
+ );
+ }
+
+ // Test URLs close to http://example.com but which should not match.
+ const failingUrls = [
+ "https://example.com",
+ "http://example.com:88",
+ "http://example.com/a",
+ "http://example.com/?abc",
+ ];
+ for (const url of failingUrls) {
+ ok(
+ !matchURLPattern(pattern, url),
+ `url "${url}" should not match pattern "http://example.com"`
+ );
+ }
+});
+
+add_task(async function test_matchURLPattern_stringPatterns() {
+ const tests = [
+ {
+ pattern: "http://example.com",
+ url: "http://example.com",
+ match: true,
+ },
+ {
+ pattern: "HTTP://example.com:80",
+ url: "http://example.com",
+ match: true,
+ },
+ {
+ pattern: "http://example.com:80",
+ url: "http://example.com",
+ match: true,
+ },
+ {
+ pattern: "http://example.com/path",
+ url: "http://example.com/path",
+ match: true,
+ },
+ {
+ pattern: "http://example.com/PATH_CASE",
+ url: "http://example.com/path_case",
+ match: false,
+ },
+ {
+ pattern: "http://example.com/path_single_segment",
+ url: "http://example.com/path_single_segment/",
+ match: false,
+ },
+ {
+ pattern: "http://example.com/path",
+ url: "http://example.com/path_continued",
+ match: false,
+ },
+ {
+ pattern: "http://example.com/path_two_segments/",
+ url: "http://example.com/path_two_segments/",
+ match: true,
+ },
+ {
+ pattern: "http://example.com/path_two_segments/",
+ url: "http://example.com/path_two_segments",
+ match: false,
+ },
+ {
+ pattern: "http://example.com/emptysearch?",
+ url: "http://example.com/emptysearch?",
+ match: true,
+ },
+ {
+ pattern: "http://example.com/emptysearch?",
+ url: "http://example.com/emptysearch",
+ match: true,
+ },
+ {
+ pattern: "http://example.com/emptysearch?",
+ url: "http://example.com/emptysearch??",
+ match: false,
+ },
+ {
+ pattern: "http://example.com/emptysearch?",
+ url: "http://example.com/emptysearch?a",
+ match: false,
+ },
+ {
+ pattern: "http://example.com/search?param",
+ url: "http://example.com/search?param",
+ match: true,
+ },
+ {
+ pattern: "http://example.com/search?param",
+ url: "http://example.com/search?param=value",
+ match: false,
+ },
+ {
+ pattern: "http://example.com/search?param=value",
+ url: "http://example.com/search?param=value",
+ match: true,
+ },
+ {
+ pattern: "http://example.com/search?a=b&c=d",
+ url: "http://example.com/search?a=b&c=d",
+ match: true,
+ },
+ {
+ pattern: "http://example.com/search?a=b&c=d",
+ url: "http://example.com/search?c=d&a=b",
+ match: false,
+ },
+ {
+ pattern: "http://example.com/search?param",
+ url: "http://example.com/search?param#ref",
+ match: true,
+ },
+ {
+ pattern: "http://example.com/search?param#ref",
+ url: "http://example.com/search?param#ref",
+ match: true,
+ },
+ {
+ pattern: "http://example.com/search?param#ref",
+ url: "http://example.com/search?param",
+ match: true,
+ },
+ {
+ pattern: "http://example.com/search?param",
+ url: "http://example.com/search?parameter",
+ match: false,
+ },
+ {
+ pattern: "http://example.com/search?parameter",
+ url: "http://example.com/search?param",
+ match: false,
+ },
+ {
+ pattern: "https://example.com:80",
+ url: "https://example.com",
+ match: false,
+ },
+ {
+ pattern: "https://example.com:443",
+ url: "https://example.com",
+ match: true,
+ },
+ {
+ pattern: "ws://example.com",
+ url: "ws://example.com:80",
+ match: true,
+ },
+ ];
+
+ runMatchPatternTests(tests, "string");
+});
+
+add_task(async function test_patternPatterns_no_property() {
+ const tests = [
+ // Test protocol
+ {
+ pattern: {},
+ url: "https://example.com",
+ match: true,
+ },
+ {
+ pattern: {},
+ url: "https://example.com",
+ match: true,
+ },
+ {
+ pattern: {},
+ url: "https://example.com:1234",
+ match: true,
+ },
+ {
+ pattern: {},
+ url: "https://example.com/a",
+ match: true,
+ },
+ {
+ pattern: {},
+ url: "https://example.com/a?test",
+ match: true,
+ },
+ ];
+
+ runMatchPatternTests(tests, "pattern");
+});
+
+add_task(async function test_patternPatterns_protocol() {
+ const tests = [
+ // Test protocol
+ {
+ pattern: {
+ protocol: "http",
+ },
+ url: "http://example.com",
+ match: true,
+ },
+ {
+ pattern: {
+ protocol: "HTTP",
+ },
+ url: "http://example.com",
+ match: true,
+ },
+ {
+ pattern: {
+ protocol: "http",
+ },
+ url: "http://example.com:80",
+ match: true,
+ },
+ {
+ pattern: {
+ protocol: "http",
+ },
+ url: "http://example.com:1234",
+ match: true,
+ },
+ {
+ pattern: {
+ protocol: "http",
+ port: "80",
+ },
+ url: "http://example.com:80",
+ match: true,
+ },
+ {
+ pattern: {
+ protocol: "http",
+ port: "1234",
+ },
+ url: "http://example.com:1234",
+ match: true,
+ },
+ {
+ pattern: {
+ protocol: "http",
+ port: "1234",
+ },
+ url: "http://example.com",
+ match: false,
+ },
+ {
+ pattern: {
+ protocol: "http",
+ },
+ url: "https://wrong-scheme.com",
+ match: false,
+ },
+ {
+ pattern: {
+ protocol: "http",
+ },
+ url: "http://whatever.com/?search#ref",
+ match: true,
+ },
+ {
+ pattern: {
+ protocol: "http",
+ },
+ url: "http://example.com/a",
+ match: true,
+ },
+ {
+ pattern: {
+ protocol: "http",
+ },
+ url: "http://whatever.com/path?search#ref",
+ match: true,
+ },
+ ];
+
+ runMatchPatternTests(tests, "pattern");
+});
+
+add_task(async function test_patternPatterns_port() {
+ const tests = [
+ {
+ pattern: {
+ protocol: "http",
+ port: "80",
+ },
+ url: "http://abc.com/",
+ match: true,
+ },
+ {
+ pattern: {
+ port: "1234",
+ },
+ url: "http://a.com:1234",
+ match: true,
+ },
+ {
+ pattern: {
+ port: "1234",
+ },
+ url: "https://a.com:1234",
+ match: true,
+ },
+ ];
+
+ runMatchPatternTests(tests, "pattern");
+});
+
+add_task(async function test_patternPatterns_hostname() {
+ const tests = [
+ {
+ pattern: {
+ hostname: "example.com",
+ },
+ url: "http://example.com",
+ match: true,
+ },
+ {
+ pattern: {
+ hostname: "example.com",
+ },
+ url: "http://example.com:80",
+ match: true,
+ },
+ {
+ pattern: {
+ hostname: "example.com",
+ },
+ url: "https://example.com",
+ match: true,
+ },
+ {
+ pattern: {
+ hostname: "example.com",
+ },
+ url: "https://example.com:443",
+ match: true,
+ },
+ {
+ pattern: {
+ hostname: "example.com",
+ },
+ url: "ws://example.com",
+ match: true,
+ },
+ {
+ pattern: {
+ hostname: "example.com",
+ },
+ url: "ws://example.com:80",
+ match: true,
+ },
+ {
+ pattern: {
+ hostname: "example.com",
+ },
+ url: "http://example.com/path",
+ match: true,
+ },
+ {
+ pattern: {
+ hostname: "example.com",
+ },
+ url: "http://example.com/?search",
+ match: true,
+ },
+ {
+ pattern: {
+ hostname: "example\\{.com",
+ },
+ url: "http://example{.com/",
+ match: true,
+ },
+ {
+ pattern: {
+ hostname: "example\\{.com",
+ },
+ url: "http://example\\{.com/",
+ match: false,
+ },
+ {
+ pattern: {
+ hostname: "127.0.0.1",
+ },
+ url: "http://127.0.0.1/",
+ match: true,
+ },
+ {
+ pattern: {
+ hostname: "127.0.0.1",
+ },
+ url: "http://127.0.0.2/",
+ match: false,
+ },
+ {
+ pattern: {
+ hostname: "[2001:db8::1]",
+ },
+ url: "http://[2001:db8::1]/",
+ match: true,
+ },
+ {
+ pattern: {
+ hostname: "[::AB:1]",
+ },
+ url: "http://[::ab:1]/",
+ match: true,
+ },
+ ];
+
+ runMatchPatternTests(tests, "pattern");
+});
+
+add_task(async function test_patternPatterns_pathname() {
+ const tests = [
+ {
+ pattern: {
+ pathname: "/",
+ },
+ url: "http://example.com",
+ match: true,
+ },
+ {
+ pattern: {
+ pathname: "/",
+ },
+ url: "http://example.com/",
+ match: true,
+ },
+ {
+ pattern: {
+ pathname: "/",
+ },
+ url: "http://example.com/?",
+ match: true,
+ },
+ {
+ pattern: {
+ pathname: "path",
+ },
+ url: "http://example.com/path",
+ match: true,
+ },
+ {
+ pattern: {
+ pathname: "/path",
+ },
+ url: "http://example.com/path",
+ match: true,
+ },
+ {
+ pattern: {
+ pathname: "path",
+ },
+ url: "http://example.com/path/",
+ match: false,
+ },
+ {
+ pattern: {
+ pathname: "path",
+ },
+ url: "http://example.com/path_continued",
+ match: false,
+ },
+ {
+ pattern: {
+ pathname: "/",
+ },
+ url: "http://example.com/path",
+ match: false,
+ },
+ ];
+
+ runMatchPatternTests(tests, "pattern");
+});
+
+add_task(async function test_patternPatterns_search() {
+ const tests = [
+ {
+ pattern: {
+ search: "",
+ },
+ url: "http://example.com/?",
+ match: true,
+ },
+ {
+ pattern: {
+ search: "",
+ },
+ url: "http://example.com/",
+ match: true,
+ },
+ {
+ pattern: {
+ search: "",
+ },
+ url: "http://example.com/?#",
+ match: true,
+ },
+ {
+ pattern: {
+ search: "?",
+ },
+ url: "http://example.com/?",
+ match: true,
+ },
+ {
+ pattern: {
+ search: "?a",
+ },
+ url: "http://example.com/?a",
+ match: true,
+ },
+ {
+ pattern: {
+ search: "?",
+ },
+ url: "http://example.com/??",
+ match: false,
+ },
+ {
+ pattern: {
+ search: "query",
+ },
+ url: "http://example.com/?query",
+ match: true,
+ },
+ {
+ pattern: {
+ search: "?query",
+ },
+ url: "http://example.com/?query",
+ match: true,
+ },
+ {
+ pattern: {
+ search: "query=value",
+ },
+ url: "http://example.com/?query=value",
+ match: true,
+ },
+ {
+ pattern: {
+ search: "query",
+ },
+ url: "http://example.com/?query=value",
+ match: false,
+ },
+ {
+ pattern: {
+ search: "query",
+ },
+ url: "http://example.com/?query#value",
+ match: true,
+ },
+ ];
+
+ runMatchPatternTests(tests, "pattern");
+});
+
+function runMatchPatternTests(tests, type) {
+ for (const test of tests) {
+ let pattern;
+ if (type == "pattern") {
+ pattern = parseURLPattern({ type: "pattern", ...test.pattern });
+ } else {
+ pattern = parseURLPattern({ type: "string", pattern: test.pattern });
+ }
+
+ equal(
+ matchURLPattern(pattern, test.url),
+ test.match,
+ `url "${test.url}" ${
+ test.match ? "should" : "should not"
+ } match pattern ${JSON.stringify(test.pattern)}`
+ );
+ }
+}
diff --git a/remote/shared/webdriver/test/xpcshell/test_URLPattern_parseURLPattern.js b/remote/shared/webdriver/test/xpcshell/test_URLPattern_parseURLPattern.js
new file mode 100644
index 0000000000..d4bf3c5fdf
--- /dev/null
+++ b/remote/shared/webdriver/test/xpcshell/test_URLPattern_parseURLPattern.js
@@ -0,0 +1,369 @@
+/* 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 { parseURLPattern } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/webdriver/URLPattern.sys.mjs"
+);
+
+add_task(async function test_parseURLPattern_stringPatterns() {
+ const STRING_PATTERN_TESTS = [
+ {
+ input: "http://example.com",
+ protocol: "http",
+ hostname: "example.com",
+ port: "",
+ pathname: "/",
+ search: "",
+ },
+ {
+ input: "http://example.com/",
+ protocol: "http",
+ hostname: "example.com",
+ port: "",
+ pathname: "/",
+ search: "",
+ },
+ {
+ input: "http://EXAMPLE.com",
+ protocol: "http",
+ hostname: "example.com",
+ port: "",
+ pathname: "/",
+ search: "",
+ },
+ {
+ input: "http://example%2Ecom",
+ protocol: "http",
+ hostname: "example.com",
+ port: "",
+ pathname: "/",
+ search: "",
+ },
+
+ {
+ input: "http://example.com:80",
+ protocol: "http",
+ hostname: "example.com",
+ port: "",
+ pathname: "/",
+ search: "",
+ },
+ {
+ input: "http://example.com:8888",
+ protocol: "http",
+ hostname: "example.com",
+ port: "8888",
+ pathname: "/",
+ search: "",
+ },
+ {
+ input: "http://example.com/a////b",
+ protocol: "http",
+ hostname: "example.com",
+ port: "",
+ pathname: "/a////b",
+ search: "",
+ },
+ {
+ input: "http://example.com/?",
+ protocol: "http",
+ hostname: "example.com",
+ port: "",
+ pathname: "/",
+ search: "",
+ },
+ {
+ input: "http://example.com/??",
+ protocol: "http",
+ hostname: "example.com",
+ port: "",
+ pathname: "/",
+ search: "?",
+ },
+ {
+ input: "http://example.com/?/",
+ protocol: "http",
+ hostname: "example.com",
+ port: "",
+ pathname: "/",
+ search: "/",
+ },
+ {
+ input: "file:///testfolder/test.zip",
+ protocol: "file",
+ hostname: "",
+ port: null,
+ pathname: "/testfolder/test.zip",
+ search: "",
+ },
+ {
+ input: "http://example\\{.com/",
+ protocol: "http",
+ hostname: "example{.com",
+ port: "",
+ pathname: "/",
+ search: "",
+ },
+ {
+ input: "http://[2001:db8::1]/",
+ protocol: "http",
+ hostname: "[2001:db8::1]",
+ port: "",
+ pathname: "/",
+ search: "",
+ },
+ {
+ input: "http://127.0.0.1/",
+ protocol: "http",
+ hostname: "127.0.0.1",
+ port: "",
+ pathname: "/",
+ search: "",
+ },
+ ];
+
+ for (const test of STRING_PATTERN_TESTS) {
+ const pattern = parseURLPattern({
+ type: "string",
+ pattern: test.input,
+ });
+
+ equal(pattern.protocol, "protocol" in test ? test.protocol : null);
+ equal(pattern.hostname, "hostname" in test ? test.hostname : null);
+ equal(pattern.port, "port" in test ? test.port : null);
+ equal(pattern.pathname, "pathname" in test ? test.pathname : null);
+ equal(pattern.search, "search" in test ? test.search : null);
+ }
+});
+
+add_task(async function test_parseURLPattern_patternPatterns() {
+ const PATTERN_PATTERN_TESTS = [
+ {
+ pattern: {
+ protocol: "http",
+ },
+ protocol: "http",
+ hostname: null,
+ port: null,
+ pathname: null,
+ search: null,
+ },
+ {
+ pattern: {
+ protocol: "HTTP",
+ },
+ protocol: "http",
+ hostname: null,
+ port: null,
+ pathname: null,
+ search: null,
+ },
+ {
+ pattern: {
+ hostname: "example.com",
+ },
+ protocol: null,
+ hostname: "example.com",
+ port: null,
+ pathname: null,
+ search: null,
+ },
+ {
+ pattern: {
+ hostname: "EXAMPLE.com",
+ },
+ protocol: null,
+ hostname: "example.com",
+ port: null,
+ pathname: null,
+ search: null,
+ },
+ {
+ pattern: {
+ hostname: "127.0.0.1",
+ },
+ protocol: null,
+ hostname: "127.0.0.1",
+ port: null,
+ pathname: null,
+ search: null,
+ },
+ {
+ pattern: {
+ hostname: "[2001:db8::1]",
+ },
+ protocol: null,
+ hostname: "[2001:db8::1]",
+ port: null,
+ pathname: null,
+ search: null,
+ },
+ {
+ pattern: {
+ port: "80",
+ },
+ protocol: null,
+ hostname: null,
+ port: "",
+ pathname: null,
+ search: null,
+ },
+ {
+ pattern: {
+ port: "1234",
+ },
+ protocol: null,
+ hostname: null,
+ port: "1234",
+ pathname: null,
+ search: null,
+ },
+ {
+ pattern: {
+ pathname: "path/to",
+ },
+ protocol: null,
+ hostname: null,
+ port: null,
+ pathname: "/path/to",
+ search: null,
+ },
+ {
+ pattern: {
+ pathname: "/path/to",
+ },
+ protocol: null,
+ hostname: null,
+ port: null,
+ pathname: "/path/to",
+ search: null,
+ },
+ {
+ pattern: {
+ pathname: "/path/to/",
+ },
+ protocol: null,
+ hostname: null,
+ port: null,
+ pathname: "/path/to/",
+ search: null,
+ },
+ {
+ pattern: {
+ search: "?search",
+ },
+ protocol: null,
+ hostname: null,
+ port: null,
+ pathname: null,
+ search: "search",
+ },
+ {
+ pattern: {
+ search: "search",
+ },
+ protocol: null,
+ hostname: null,
+ port: null,
+ pathname: null,
+ search: "search",
+ },
+ {
+ pattern: {
+ search: "?search=something",
+ },
+ protocol: null,
+ hostname: null,
+ port: null,
+ pathname: null,
+ search: "search=something",
+ },
+ {
+ pattern: {
+ search: "search=something",
+ },
+ protocol: null,
+ hostname: null,
+ port: null,
+ pathname: null,
+ search: "search=something",
+ },
+ ];
+
+ for (const test of PATTERN_PATTERN_TESTS) {
+ const pattern = parseURLPattern({
+ type: "pattern",
+ ...test.pattern,
+ });
+
+ equal(pattern.protocol, "protocol" in test ? test.protocol : null);
+ equal(pattern.hostname, "hostname" in test ? test.hostname : null);
+ equal(pattern.port, "port" in test ? test.port : null);
+ equal(pattern.pathname, "pathname" in test ? test.pathname : null);
+ equal(pattern.search, "search" in test ? test.search : null);
+ }
+});
+
+add_task(async function test_parseURLPattern_invalid_type() {
+ const values = [null, undefined, 1, [], "string"];
+ for (const value of values) {
+ Assert.throws(() => parseURLPattern(value), /InvalidArgumentError/);
+ }
+});
+
+add_task(async function test_parseURLPattern_invalid_type_type() {
+ const values = [null, undefined, 1, {}, []];
+ for (const type of values) {
+ Assert.throws(() => parseURLPattern({ type }), /InvalidArgumentError/);
+ }
+});
+
+add_task(async function test_parseURLPattern_invalid_type_value() {
+ const values = ["", "unknownType"];
+ for (const type of values) {
+ Assert.throws(() => parseURLPattern({ type }), /InvalidArgumentError/);
+ }
+});
+
+add_task(async function test_parseURLPattern_invalid_stringPatternType() {
+ const values = [null, undefined, 1, {}, []];
+ for (const pattern of values) {
+ Assert.throws(
+ () => parseURLPattern({ type: "string", pattern }),
+ /InvalidArgumentError/
+ );
+ }
+});
+
+add_task(async function test_parseURLPattern_invalid_stringPattern() {
+ const values = [
+ "foo",
+ "*",
+ "(",
+ ")",
+ "{",
+ "}",
+ "http\\{s\\}://example.com",
+ "https://example.com:port/",
+ ];
+ for (const pattern of values) {
+ Assert.throws(
+ () => parseURLPattern({ type: "string", pattern }),
+ /InvalidArgumentError/
+ );
+ }
+});
+
+add_task(async function test_parseURLPattern_invalid_patternPattern_type() {
+ const properties = ["protocol", "hostname", "port", "pathname", "search"];
+ const values = [false, 42, [], {}];
+ for (const property of properties) {
+ for (const value of values) {
+ Assert.throws(
+ () => parseURLPattern({ type: "pattern", [property]: value }),
+ /InvalidArgumentError/
+ );
+ }
+ }
+});
diff --git a/remote/shared/webdriver/test/xpcshell/xpcshell.toml b/remote/shared/webdriver/test/xpcshell/xpcshell.toml
new file mode 100644
index 0000000000..1cdd1eb47c
--- /dev/null
+++ b/remote/shared/webdriver/test/xpcshell/xpcshell.toml
@@ -0,0 +1,20 @@
+[DEFAULT]
+head = "head.js"
+
+["test_Actions.js"]
+
+["test_Assert.js"]
+
+["test_Capabilities.js"]
+
+["test_Errors.js"]
+
+["test_NodeCache.js"]
+
+["test_Session.js"]
+
+["test_URLPattern_invalid.js"]
+
+["test_URLPattern_matchURLPattern.js"]
+
+["test_URLPattern_parseURLPattern.js"]