summaryrefslogtreecommitdiffstats
path: root/remote/shared
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 09:22:09 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 09:22:09 +0000
commit43a97878ce14b72f0981164f87f2e35e14151312 (patch)
tree620249daf56c0258faa40cbdcf9cfba06de2a846 /remote/shared
parentInitial commit. (diff)
downloadfirefox-43a97878ce14b72f0981164f87f2e35e14151312.tar.xz
firefox-43a97878ce14b72f0981164f87f2e35e14151312.zip
Adding upstream version 110.0.1.upstream/110.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'remote/shared')
-rw-r--r--remote/shared/AppInfo.sys.mjs63
-rw-r--r--remote/shared/Capture.sys.mjs202
-rw-r--r--remote/shared/Format.sys.mjs186
-rw-r--r--remote/shared/Log.sys.mjs79
-rw-r--r--remote/shared/MobileTabBrowser.sys.mjs87
-rw-r--r--remote/shared/Navigate.sys.mjs425
-rw-r--r--remote/shared/PDF.sys.mjs222
-rw-r--r--remote/shared/RecommendedPreferences.sys.mjs380
-rw-r--r--remote/shared/RemoteError.sys.mjs19
-rw-r--r--remote/shared/Stack.sys.mjs73
-rw-r--r--remote/shared/Sync.sys.mjs322
-rw-r--r--remote/shared/TabManager.sys.mjs341
-rw-r--r--remote/shared/WebSocketConnection.sys.mjs149
-rw-r--r--remote/shared/WindowManager.sys.mjs273
-rw-r--r--remote/shared/listeners/BrowsingContextListener.sys.mjs121
-rw-r--r--remote/shared/listeners/ConsoleAPIListener.sys.mjs126
-rw-r--r--remote/shared/listeners/ConsoleListener.sys.mjs156
-rw-r--r--remote/shared/listeners/LoadListener.sys.mjs90
-rw-r--r--remote/shared/listeners/NetworkEventRecord.sys.mjs422
-rw-r--r--remote/shared/listeners/NetworkListener.sys.mjs103
-rw-r--r--remote/shared/listeners/test/browser/browser.ini14
-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_NetworkListener.js84
-rw-r--r--remote/shared/listeners/test/browser/head.js87
-rw-r--r--remote/shared/messagehandler/Errors.sys.mjs90
-rw-r--r--remote/shared/messagehandler/EventsDispatcher.sys.mjs237
-rw-r--r--remote/shared/messagehandler/MessageHandler.sys.mjs349
-rw-r--r--remote/shared/messagehandler/MessageHandlerRegistry.sys.mjs243
-rw-r--r--remote/shared/messagehandler/Module.sys.mjs134
-rw-r--r--remote/shared/messagehandler/ModuleCache.sys.mjs207
-rw-r--r--remote/shared/messagehandler/RootMessageHandler.sys.mjs154
-rw-r--r--remote/shared/messagehandler/RootMessageHandlerRegistry.sys.mjs17
-rw-r--r--remote/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs120
-rw-r--r--remote/shared/messagehandler/sessiondata/SessionData.sys.mjs390
-rw-r--r--remote/shared/messagehandler/sessiondata/SessionDataReader.sys.mjs29
-rw-r--r--remote/shared/messagehandler/test/browser/broadcast/browser.ini17
-rw-r--r--remote/shared/messagehandler/test/browser/broadcast/browser_filter_top_browsing_context.js83
-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.js39
-rw-r--r--remote/shared/messagehandler/test/browser/broadcast/doc_messagehandler_broadcasting_xul.xhtml3
-rw-r--r--remote/shared/messagehandler/test/browser/broadcast/head.js53
-rw-r--r--remote/shared/messagehandler/test/browser/browser.ini21
-rw-r--r--remote/shared/messagehandler/test/browser/browser_events_dispatcher.js453
-rw-r--r--remote/shared/messagehandler/test/browser/browser_events_handler.js57
-rw-r--r--remote/shared/messagehandler/test/browser/browser_events_module.js283
-rw-r--r--remote/shared/messagehandler/test/browser/browser_frame_context_utils.js95
-rw-r--r--remote/shared/messagehandler/test/browser/browser_handle_command_errors.js221
-rw-r--r--remote/shared/messagehandler/test/browser/browser_handle_command_retry.js243
-rw-r--r--remote/shared/messagehandler/test/browser/browser_handle_simple_command.js206
-rw-r--r--remote/shared/messagehandler/test/browser/browser_registry.js38
-rw-r--r--remote/shared/messagehandler/test/browser/browser_session_data.js272
-rw-r--r--remote/shared/messagehandler/test/browser/browser_session_data_broadcast.js335
-rw-r--r--remote/shared/messagehandler/test/browser/browser_session_data_browser_element.js98
-rw-r--r--remote/shared/messagehandler/test/browser/browser_session_data_constructor_race.js54
-rw-r--r--remote/shared/messagehandler/test/browser/head.js186
-rw-r--r--remote/shared/messagehandler/test/browser/resources/modules/ModuleRegistry.sys.mjs30
-rw-r--r--remote/shared/messagehandler/test/browser/resources/modules/root/command.sys.mjs80
-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/windowglobal-in-root/command.sys.mjs28
-rw-r--r--remote/shared/messagehandler/test/browser/resources/modules/windowglobal-in-root/event.sys.mjs31
-rw-r--r--remote/shared/messagehandler/test/browser/resources/modules/windowglobal/command.sys.mjs114
-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.mjs26
-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/retry.sys.mjs84
-rw-r--r--remote/shared/messagehandler/test/browser/webdriver/browser.ini9
-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.js99
-rw-r--r--remote/shared/messagehandler/test/xpcshell/test_SessionData.js296
-rw-r--r--remote/shared/messagehandler/test/xpcshell/xpcshell.ini6
-rw-r--r--remote/shared/messagehandler/transports/FrameContextUtils.sys.mjs57
-rw-r--r--remote/shared/messagehandler/transports/FrameTransport.sys.mjs191
-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.mjs77
-rw-r--r--remote/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameParent.sys.mjs99
-rw-r--r--remote/shared/moz.build17
-rw-r--r--remote/shared/test/browser/browser.ini5
-rw-r--r--remote/shared/test/browser/browser_TabManager.js148
-rw-r--r--remote/shared/test/xpcshell/test_AppInfo.js34
-rw-r--r--remote/shared/test/xpcshell/test_Format.js119
-rw-r--r--remote/shared/test/xpcshell/test_Navigate.js850
-rw-r--r--remote/shared/test/xpcshell/test_RecommendedPreferences.js111
-rw-r--r--remote/shared/test/xpcshell/test_Stack.js120
-rw-r--r--remote/shared/test/xpcshell/test_Sync.js393
-rw-r--r--remote/shared/test/xpcshell/test_TabManager.js56
-rw-r--r--remote/shared/test/xpcshell/xpcshell.ini11
-rw-r--r--remote/shared/webdriver/Assert.sys.mjs489
-rw-r--r--remote/shared/webdriver/Capabilities.sys.mjs737
-rw-r--r--remote/shared/webdriver/Errors.sys.mjs559
-rw-r--r--remote/shared/webdriver/KeyData.sys.mjs340
-rw-r--r--remote/shared/webdriver/NodeCache.sys.mjs134
-rw-r--r--remote/shared/webdriver/Session.sys.mjs348
-rw-r--r--remote/shared/webdriver/process-actors/WebDriverProcessDataChild.sys.mjs95
-rw-r--r--remote/shared/webdriver/process-actors/WebDriverProcessDataParent.sys.mjs39
-rw-r--r--remote/shared/webdriver/test/xpcshell/head.js14
-rw-r--r--remote/shared/webdriver/test/xpcshell/test_Assert.js215
-rw-r--r--remote/shared/webdriver/test/xpcshell/test_Capabilities.js623
-rw-r--r--remote/shared/webdriver/test/xpcshell/test_Errors.js499
-rw-r--r--remote/shared/webdriver/test/xpcshell/test_NodeCache.js123
-rw-r--r--remote/shared/webdriver/test/xpcshell/test_Session.js55
-rw-r--r--remote/shared/webdriver/test/xpcshell/xpcshell.ini12
111 files changed, 17243 insertions, 0 deletions
diff --git a/remote/shared/AppInfo.sys.mjs b/remote/shared/AppInfo.sys.mjs
new file mode 100644
index 0000000000..f47046fe1c
--- /dev/null
+++ b/remote/shared/AppInfo.sys.mjs
@@ -0,0 +1,63 @@
+/* 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 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
+
+XPCOMUtils.defineLazyGetter(AppInfo, "isAndroid", () => {
+ return Services.appinfo.OS === "Android";
+});
+
+XPCOMUtils.defineLazyGetter(AppInfo, "isLinux", () => {
+ return Services.appinfo.OS === "Linux";
+});
+
+XPCOMUtils.defineLazyGetter(AppInfo, "isMac", () => {
+ return Services.appinfo.OS === "Darwin";
+});
+
+XPCOMUtils.defineLazyGetter(AppInfo, "isWindows", () => {
+ return Services.appinfo.OS === "WINNT";
+});
+
+// Application type
+
+XPCOMUtils.defineLazyGetter(AppInfo, "isFirefox", () => {
+ return Services.appinfo.ID == ID_FIREFOX;
+});
+
+XPCOMUtils.defineLazyGetter(AppInfo, "isThunderbird", () => {
+ return Services.appinfo.ID == ID_THUNDERBIRD;
+});
diff --git a/remote/shared/Capture.sys.mjs b/remote/shared/Capture.sys.mjs
new file mode 100644
index 0000000000..5281ec5ddf
--- /dev/null
+++ b/remote/shared/Capture.sys.mjs
@@ -0,0 +1,202 @@
+/* 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.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 {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 {HTMLCanvasElement=} canvas
+ * Optional canvas to reuse for the screenshot.
+ * @param {number=} flags
+ * Optional integer representing flags to pass to drawWindow; these
+ * are defined on CanvasRenderingContext2D.
+ * @param {number=} dX
+ * Horizontal offset between the browser window and content area. Defaults to 0.
+ * @param {number=} dY
+ * Vertical offset between the browser window and content area. Defaults to 0.
+ * @param {boolean=} readback
+ * If true, read back a snapshot of the pixel data currently in the
+ * compositor/window. Defaults to false.
+ *
+ * @return {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.
+ *
+ * @return {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.
+ *
+ * @return {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("utf-8").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.
+ *
+ * @return {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/Format.sys.mjs b/remote/shared/Format.sys.mjs
new file mode 100644
index 0000000000..95e9ff339f
--- /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",
+});
+
+XPCOMUtils.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..9cc97d9d51
--- /dev/null
+++ b/remote/shared/Log.sys.mjs
@@ -0,0 +1,79 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+import { Log as StdLog } from "resource://gre/modules/Log.sys.mjs";
+
+const PREF_REMOTE_LOG_LEVEL = "remote.log.level";
+
+// We still check the marionette log preference for backward compatibility.
+// This can be removed when geckodriver 0.30 (bug 1686110) has been released.
+const PREF_MARIONETTE_LOG_LEVEL = "marionette.log.level";
+
+const lazy = {};
+
+// Lazy getter which will return the preference (remote or marionette) which has
+// the most verbose log level.
+XPCOMUtils.defineLazyGetter(lazy, "prefLogLevel", () => {
+ function getLogLevelNumber(pref) {
+ const level = Services.prefs.getCharPref(pref, "Fatal");
+ return (
+ StdLog.Level.Numbers[level.toUpperCase()] || StdLog.Level.Numbers.FATAL
+ );
+ }
+
+ const marionetteNumber = getLogLevelNumber(PREF_MARIONETTE_LOG_LEVEL);
+ const remoteNumber = getLogLevelNumber(PREF_REMOTE_LOG_LEVEL);
+
+ if (marionetteNumber < remoteNumber) {
+ return PREF_MARIONETTE_LOG_LEVEL;
+ }
+
+ return PREF_REMOTE_LOG_LEVEL;
+});
+
+/** 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(lazy.prefLogLevel);
+ }
+ return logger;
+ }
+
+ /**
+ * Check if the current log level matches the Trace log level. This should be
+ * used to guard logger.trace calls and avoid instanciating logger instances
+ * unnecessarily.
+ */
+ static get isTraceLevel() {
+ return [StdLog.Level.All, StdLog.Level.Trace].includes(lazy.prefLogLevel);
+ }
+
+ 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..fd8e78e45a
--- /dev/null
+++ b/remote/shared/MobileTabBrowser.sys.mjs
@@ -0,0 +1,87 @@
+/* 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, {
+ windowManager: "chrome://remote/content/shared/WindowManager.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ GeckoViewTabUtil: "resource://gre/modules/GeckoViewTestUtils.jsm",
+});
+
+// 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 url URL to load within the newly opended tab.
+ *
+ * @return {Promise<Tab>} The created tab.
+ * @throws {Error} Throws an error if the tab cannot be created.
+ */
+ addTab(url) {
+ return lazy.GeckoViewTabUtil.createNewTab(url);
+ }
+
+ 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..be8a24e872
--- /dev/null
+++ b/remote/shared/Navigate.sys.mjs
@@ -0,0 +1,425 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+import { 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",
+});
+
+XPCOMUtils.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.
+XPCOMUtils.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;
+});
+
+// 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`.
+ *
+ * @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 } = options;
+
+ const browsingContext = webProgress.browsingContext;
+
+ // Start the listener right away to avoid race conditions.
+ const listener = new ProgressListener(webProgress, {
+ resolveWhenStarted,
+ });
+ 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 = 200,
+ 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/PDF.sys.mjs b/remote/shared/PDF.sys.mjs
new file mode 100644
index 0000000000..a171fef590
--- /dev/null
+++ b/remote/shared/PDF.sys.mjs
@@ -0,0 +1,222 @@
+/* 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, {
+ clearInterval: "resource://gre/modules/Timer.sys.mjs",
+ setInterval: "resource://gre/modules/Timer.sys.mjs",
+
+ assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs",
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
+
+export const print = {
+ maxScaleValue: 2.0,
+ minScaleValue: 0.1,
+ letterPaperSizeCm: {
+ width: 21.59,
+ height: 27.94,
+ },
+};
+
+print.addDefaultSettings = function(settings) {
+ const {
+ landscape = false,
+ margin = {
+ top: 1,
+ bottom: 1,
+ left: 1,
+ right: 1,
+ },
+ page = print.letterPaperSizeCm,
+ shrinkToFit = true,
+ printBackground = false,
+ scale = 1.0,
+ pageRanges = [],
+ } = settings;
+
+ return {
+ landscape,
+ margin,
+ page,
+ shrinkToFit,
+ printBackground,
+ scale,
+ pageRanges,
+ };
+};
+
+function getPrintSettings(settings, filePath) {
+ 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;
+ printSettings.outputDestination = Ci.nsIPrintSettings.kOutputDestinationFile;
+ printSettings.toFileName = filePath;
+
+ // 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.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.printBackground;
+ printSettings.printBGImages = settings.printBackground;
+ 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.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.
+ *
+ * @return {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.printToFile = async function(browser, settings) {
+ // Create a unique filename for the temporary PDF file
+ const filePath = await IOUtils.createUniqueFile(
+ PathUtils.tempDir,
+ "marionette.pdf",
+ 0o600
+ );
+
+ let printSettings = getPrintSettings(settings, filePath);
+
+ await browser.browsingContext.print(printSettings);
+
+ // Bug 1603739 - With e10s enabled the promise returned by print() resolves
+ // too early, which means the file hasn't been completely written.
+ await new Promise(resolve => {
+ const DELAY_CHECK_FILE_COMPLETELY_WRITTEN = 100;
+
+ let lastSize = 0;
+ const timerId = lazy.setInterval(async () => {
+ const fileInfo = await IOUtils.stat(filePath);
+ if (lastSize > 0 && fileInfo.size == lastSize) {
+ lazy.clearInterval(timerId);
+ resolve();
+ }
+ lastSize = fileInfo.size;
+ }, DELAY_CHECK_FILE_COMPLETELY_WRITTEN);
+ });
+
+ lazy.logger.debug(`PDF output written to ${filePath}`);
+ return filePath;
+};
diff --git a/remote/shared/RecommendedPreferences.sys.mjs b/remote/shared/RecommendedPreferences.sys.mjs
new file mode 100644
index 0000000000..e99c41c704
--- /dev/null
+++ b/remote/shared/RecommendedPreferences.sys.mjs
@@ -0,0 +1,380 @@
+/* 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, {
+ Preferences: "resource://gre/modules/Preferences.sys.mjs",
+
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "useRecommendedPrefs",
+ "remote.prefs.recommended",
+ false
+);
+
+XPCOMUtils.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/src/node/FirefoxLauncher.ts
+// - testing/geckodriver/src/prefs.rs
+// - testing/marionette/client/marionette_driver/geckoinstance.py
+//
+// The preferences in `FirefoxLauncher.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 `FirefoxLauncher.ts`
+// - Create a PR to upstream the change on `FirefoxLauncher.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],
+
+ // 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 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.passwords.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"],
+
+ // Do not close the window when the last tab gets closed
+ ["browser.tabs.closeWindowWithLastTab", false],
+
+ // Do not allow background tabs to be zombified on Android, otherwise for
+ // tests that open additional tabs, the test harness tab itself might get
+ // unloaded
+ ["browser.tabs.disableBackgroundZombification", false],
+
+ // 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", ""],
+
+ // Disable the UI tour.
+ //
+ // Should be set in profile.
+ ["browser.uitour.enabled", false],
+
+ // 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 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:, "],
+
+ // Allow the application to have focus even it runs in the background
+ ["focusmanager.testmode", true],
+
+ // Disable useragent updates
+ ["general.useragent.updates.enabled", false],
+
+ // 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],
+
+ // 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],
+
+ // Ensure blocklist updates do not hit the network
+ ["services.settings.server", "http://%(server)s/dummy/blocklist/"],
+
+ // 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 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.
+ * They will be automatically reset on application shutdown.
+ *
+ * @param {Map} preferences
+ * Map of preference key 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 provided preferences in a single map.
+ preferences = new Map([...COMMON_PREFERENCES, ...preferences]);
+ Services.obs.addObserver(this, "quit-application");
+ this.isInitialized = true;
+ }
+
+ for (const [k, v] of preferences) {
+ if (!lazy.Preferences.isSet(k)) {
+ lazy.logger.debug(`Setting recommended pref ${k} to ${v}`);
+ lazy.Preferences.set(k, 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}`);
+ lazy.Preferences.reset(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..4611dbb04f
--- /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..c05f27a868
--- /dev/null
+++ b/remote/shared/Sync.sys.mjs
@@ -0,0 +1,322 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+});
+
+const { TYPE_ONE_SHOT, TYPE_REPEATING_SLACK } = Ci.nsITimer;
+
+XPCOMUtils.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.
+ *
+ * @return {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=} [false] options.capture
+ * Indicates the event will be despatched to this subject,
+ * before it bubbles down to any EventTarget beneath it in the
+ * DOM tree.
+ * @param {function=} [null] 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.
+ * @param {number=} [null] 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.
+ * @param {boolean=} [false] options.mozSystemGroup
+ * Determines whether to add listener to the system group.
+ * @param {boolean=} [false] options.wantUntrusted
+ * Receive synthetic events despatched by web content.
+ *
+ * @return {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 {number=} [timeout] 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=} [interval=10] interval
+ * Duration between each poll of ``func`` in milliseconds.
+ * Defaults to 10 milliseconds.
+ *
+ * @return {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, { timeout = null, interval = 10 } = {}) {
+ const timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+
+ 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)
+ ) {
+ resolve(rejected);
+ }
+ })
+ .catch(reject);
+ };
+
+ // the repeating slack timer waits |interval|
+ // before invoking |evalFn|
+ evalFn();
+
+ timer.init(evalFn, interval, TYPE_REPEATING_SLACK);
+ }).then(
+ res => {
+ 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..6a55ba8475
--- /dev/null
+++ b/remote/shared/TabManager.sys.mjs
@@ -0,0 +1,341 @@
+/* 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",
+ EventPromise: "chrome://remote/content/shared/Sync.sys.mjs",
+ MobileTabBrowser: "chrome://remote/content/shared/MobileTabBrowser.sys.mjs",
+});
+
+// Maps browser's permanentKey to uuid: WeakMap.<Object, string>
+const browserUniqueIds = new WeakMap();
+
+export var TabManager = {
+ /**
+ * Retrieve all the browser elements from tabs as contained in open windows.
+ *
+ * @return {Array<xul:browser>}
+ * 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) {
+ const tabBrowser = this.getTabBrowser(win);
+
+ if (tabBrowser && tabBrowser.tabs) {
+ const contentBrowsers = tabBrowser.tabs.map(tab => {
+ return this.getBrowserForTab(tab);
+ });
+ browsers.push(...contentBrowsers);
+ }
+ }
+
+ 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.
+ *
+ * @return {Array<String>}
+ * Array of UUIDs for all content browsers.
+ */
+ get allBrowserUniqueIds() {
+ const browserIds = [];
+
+ for (const win of this.windows) {
+ const tabBrowser = this.getTabBrowser(win);
+
+ // Only return handles for browser windows
+ if (tabBrowser && tabBrowser.tabs) {
+ for (const tab of tabBrowser.tabs) {
+ 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.
+ *
+ * @return {xul:browser}
+ * 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.
+ *
+ * @return {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.
+ * @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 {Number} options.userContextId
+ * The user context (container) id.
+ * @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,
+ 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);
+ }
+
+ const tabBrowser = this.getTabBrowser(window);
+ if (referenceTab != null) {
+ index = tabBrowser.tabs.indexOf(referenceTab) + 1;
+ }
+
+ const tab = await tabBrowser.addTab("about:blank", {
+ index,
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ 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.
+ * @return {xul:browser}
+ * 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) {
+ const tabBrowser = this.getTabBrowser(win);
+ if (tabBrowser && tabBrowser.tabs) {
+ for (let i = 0; i < tabBrowser.tabs.length; ++i) {
+ const contentBrowser = this.getBrowserForTab(tabBrowser.tabs[i]);
+ 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).
+ * @return {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 {xul:browser} browserElement
+ * The <xul:browser> for which we want to retrieve the id.
+ * @return {String} The unique id for this browser.
+ */
+ getIdForBrowser(browserElement) {
+ if (browserElement === null) {
+ return null;
+ }
+
+ const key = browserElement.permanentKey;
+ if (!browserUniqueIds.has(key)) {
+ const uuid = Services.uuid.generateUUID().toString();
+ browserUniqueIds.set(key, uuid.substring(1, uuid.length - 1));
+ }
+ return 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.
+ return this.getIdForBrowser(browsingContext.embedderElement);
+ }
+
+ return browsingContext.id.toString();
+ },
+
+ getTabCount() {
+ let count = 0;
+ for (const win of this.windows) {
+ // For browser windows count the tabs. Otherwise take the window itself.
+ const tabbrowser = this.getTabBrowser(win);
+ if (tabbrowser?.tabs) {
+ count += tabbrowser.tabs.length;
+ } else {
+ count += 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);
+ },
+
+ /**
+ * 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.
+ */
+ 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;
+ return selected;
+ },
+
+ supportsTabs() {
+ return lazy.AppInfo.isAndroid || lazy.AppInfo.isFirefox;
+ },
+
+ _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;
+ },
+};
diff --git a/remote/shared/WebSocketConnection.sys.mjs b/remote/shared/WebSocketConnection.sys.mjs
new file mode 100644
index 0000000000..2ce8ca2cdd
--- /dev/null
+++ b/remote/shared/WebSocketConnection.sys.mjs
@@ -0,0 +1,149 @@
+/* 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",
+ truncate: "chrome://remote/content/shared/Format.sys.mjs",
+ WebSocketTransport:
+ "chrome://remote/content/server/WebSocketTransport.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
+
+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 = Services.uuid
+ .generateUUID()
+ .toString()
+ .slice(1, -1);
+
+ 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) {
+ function replacer(key, value) {
+ if (typeof value === "string") {
+ return lazy.truncate`${value}`;
+ }
+ return value;
+ }
+
+ const payload = JSON.stringify(
+ data,
+ replacer,
+ lazy.Log.verbose ? "\t" : null
+ );
+
+ lazy.logger.trace(
+ `${this.constructor.name} ${this.id} ${direction} ${payload}`
+ );
+ }
+
+ /**
+ * Close the WebSocket connection.
+ */
+ close() {
+ this.transport.close();
+
+ // 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();
+ }
+
+ /**
+ * 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.
+ */
+ onClosed(status) {
+ lazy.logger.debug(`${this.constructor.name} ${this.id} closed`);
+ }
+
+ /**
+ * 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..1b918f9971
--- /dev/null
+++ b/remote/shared/WindowManager.sys.mjs
@@ -0,0 +1,273 @@
+/* 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",
+ EventPromise: "chrome://remote/content/shared/Sync.sys.mjs",
+ TabManager: "chrome://remote/content/shared/TabManager.sys.mjs",
+ TimedPromise: "chrome://remote/content/marionette/sync.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`.
+ *
+ * @return {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.
+ * @return {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.
+ * @return {String} The unique id for this chrome window.
+ */
+ getIdForWindow(win) {
+ if (!this._chromeWindowHandles.has(win)) {
+ const uuid = Services.uuid.generateUUID().toString();
+ this._chromeWindowHandles.set(win, uuid.substring(1, uuid.length - 1));
+ }
+ return this._chromeWindowHandles.get(win);
+ }
+
+ /**
+ * Close the specified window.
+ *
+ * @param {window} win
+ * The window to close.
+ * @return {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.
+ * @return {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.
+ * @return {Promise}
+ * A promise resolving to the newly created chrome window.
+ */
+ async openBrowserWindow(options = {}) {
+ let { focus = false, isPrivate = false, openerWindow = 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 win = openerWindow.OpenBrowserWindow({ private: isPrivate });
+
+ const activated = new lazy.EventPromise(win, "activate");
+ const focused = new lazy.EventPromise(win, "focus", { capture: true });
+ const startup = lazy.waitForObserverTopic(
+ "browser-delayed-startup-finished",
+ {
+ checkFn: subject => subject == win,
+ }
+ );
+
+ // 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
+ win.focus();
+
+ await Promise.all([activated, focused, startup]);
+
+ // The new window shouldn't get focused. As such set the
+ // focus back to the opening window.
+ if (!focus) {
+ await this.focusWindow(openerWindow);
+ }
+
+ return win;
+
+ default:
+ throw new lazy.error.UnsupportedOperationError(
+ `openWindow() not supported in ${lazy.AppInfo.name}`
+ );
+ }
+ }
+
+ /**
+ * Wait until the initial application window has been opened and loaded.
+ *
+ * @return {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/listeners/BrowsingContextListener.sys.mjs b/remote/shared/listeners/BrowsingContextListener.sys.mjs
new file mode 100644
index 0000000000..e201caed3a
--- /dev/null
+++ b/remote/shared/listeners/BrowsingContextListener.sys.mjs
@@ -0,0 +1,121 @@
+/* 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;
+ * ...
+ * };
+ * ```
+ *
+ * @emits 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();
+ }
+
+ 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..c8b8560e7f
--- /dev/null
+++ b/remote/shared/listeners/ConsoleAPIListener.sys.mjs
@@ -0,0 +1,126 @@
+/* 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, {
+ EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs",
+});
+
+XPCOMUtils.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;
+ * ...
+ * };
+ * ```
+ *
+ * @emits 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 ConsolerListener 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..d9ccd50daf
--- /dev/null
+++ b/remote/shared/listeners/ConsoleListener.sys.mjs
@@ -0,0 +1,156 @@
+/* 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, {
+ EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs",
+
+ getFramesFromStack: "chrome://remote/content/shared/Stack.sys.mjs",
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+});
+
+XPCOMUtils.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();
+ * ```
+ *
+ * @emits 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 ConsolerListener 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/LoadListener.sys.mjs b/remote/shared/listeners/LoadListener.sys.mjs
new file mode 100644
index 0000000000..ed7ba3f6d4
--- /dev/null
+++ b/remote/shared/listeners/LoadListener.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 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;
+ * ...
+ * };
+ * ```
+ *
+ * @emits 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();
+ this.#window.addEventListener(
+ "DOMContentLoaded",
+ this.#onDOMContentLoaded,
+ {
+ mozSystemGroup: true,
+ signal: this.#abortController.signal,
+ }
+ );
+
+ this.#window.addEventListener("load", this.#onLoad, {
+ mozSystemGroup: true,
+ signal: this.#abortController.signal,
+ });
+ }
+
+ stopListening() {
+ if (!this.#abortController) {
+ return;
+ }
+
+ this.#abortController.abort();
+ this.#abortController = null;
+ }
+
+ #onDOMContentLoaded = event => {
+ this.emit("DOMContentLoaded", { target: event.target });
+ };
+
+ #onLoad = event => {
+ this.emit("load", { target: event.target });
+ };
+}
diff --git a/remote/shared/listeners/NetworkEventRecord.sys.mjs b/remote/shared/listeners/NetworkEventRecord.sys.mjs
new file mode 100644
index 0000000000..ef8bbb4170
--- /dev/null
+++ b/remote/shared/listeners/NetworkEventRecord.sys.mjs
@@ -0,0 +1,422 @@
+/* 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 {
+ #channel;
+ #networkListener;
+ #redirectCount;
+ #requestData;
+ #requestId;
+ #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.#channel = channel;
+ this.#wrappedChannel = ChannelWrapper.get(channel);
+
+ this.#networkListener = networkListener;
+
+ // The wrappedChannel id remains identical across redirects, whereas
+ // nsIChannel.channelId is different for each and every request.
+ this.#requestId = this.#wrappedChannel.id.toString();
+
+ // 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.headersSize,
+ method: networkEvent.method,
+ request: this.#requestId,
+ timings: {},
+ url: networkEvent.url,
+ };
+
+ // 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: networkEvent.url,
+ };
+ }
+
+ /**
+ * Set network request headers.
+ *
+ * Required API for a NetworkObserver event owner.
+ *
+ * It will only be called once per network event record, so
+ * despite the name we will simply store the headers and rawHeaders.
+ *
+ * @param {Array} headers
+ * The request headers array.
+ * @param {string=} rawHeaders
+ * The raw headers source.
+ */
+ addRequestHeaders(headers, rawHeaders) {
+ if (typeof headers == "object") {
+ this.#requestData.headers = headers;
+ }
+ this.#requestData.rawHeaders = rawHeaders;
+ }
+
+ /**
+ * Set network request cookies.
+ *
+ * Required API for a NetworkObserver event owner.
+ *
+ * It will only be called once per network event record, so
+ * despite the name we will simply store the cookies.
+ *
+ * @param {Array} cookies
+ * The request cookies array.
+ */
+ addRequestCookies(cookies) {
+ if (typeof cookies == "object") {
+ this.#requestData.cookies = cookies;
+ }
+
+ // By design, the NetworkObserver will synchronously create a "network event"
+ // then call addRequestHeaders and finally addRequestCookies.
+ // 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();
+ }
+
+ /**
+ * 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} response
+ * The response information.
+ * @param {string} rawHeaders
+ * The raw headers source.
+ */
+ addResponseStart(response, rawHeaders) {
+ this.#responseData = {
+ ...this.#responseData,
+ bodySize: response.bodySize,
+ bytesReceived: response.transferredSize,
+ fromCache: response.fromCache,
+ // Note: at this point we only have access to the headers size. Parsed
+ // headers will be added in addResponseHeaders.
+ headersSize: response.headersSize,
+ protocol: response.protocol,
+ status: parseInt(response.status),
+ statusText: response.statusText,
+ };
+ }
+
+ /**
+ * 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 response headers.
+ *
+ * Required API for a NetworkObserver event owner.
+ *
+ * @param {Array} headers
+ * The response headers array.
+ */
+ addResponseHeaders(headers) {
+ this.#responseData.headers = headers;
+
+ // The mimetype info should also be available on the wrapped channel after
+ // headers have been parsed.
+ this.#responseData.mimeType = this.#getMimeType();
+
+ // 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 network response cookies.
+ *
+ * Required API for a NetworkObserver event owner.
+ *
+ * Not used for RemoteAgent.
+ *
+ * @param {Array} cookies
+ * The response cookies array.
+ */
+ addResponseCookies(cookies) {}
+
+ /**
+ * 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.
+ * @param {Array} serverTimings
+ * The server timings.
+ */
+ addEventTimings(total, timings, offsets, serverTimings) {}
+
+ /**
+ * 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,
+ },
+ };
+
+ this.#emitResponseCompleted();
+ }
+
+ /**
+ * Add server timings.
+ *
+ * Required API for a NetworkObserver event owner.
+ *
+ * Not used for RemoteAgent.
+ *
+ * @param {Array} serverTimings
+ * The server timings.
+ */
+ addServerTimings(serverTimings) {}
+
+ #emitBeforeRequestSent() {
+ this.#updateDataFromTimedChannel();
+
+ this.#networkListener.emit("before-request-sent", {
+ contextId: this.#getContextId(),
+ redirectCount: this.#redirectCount,
+ requestData: this.#requestData,
+ timestamp: Date.now(),
+ });
+ }
+
+ #emitResponseCompleted() {
+ this.#updateDataFromTimedChannel();
+
+ this.#networkListener.emit("response-completed", {
+ contextId: this.#getContextId(),
+ redirectCount: this.#redirectCount,
+ requestData: this.#requestData,
+ responseData: this.#responseData,
+ timestamp: Date.now(),
+ });
+ }
+
+ #emitResponseStarted() {
+ this.#updateDataFromTimedChannel();
+
+ this.#networkListener.emit("response-started", {
+ contextId: this.#getContextId(),
+ redirectCount: this.#redirectCount,
+ requestData: this.#requestData,
+ responseData: this.#responseData,
+ timestamp: Date.now(),
+ });
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * Retrieve the context id corresponding to the current channel, this could
+ * change dynamically during a cross group navigation for an iframe, so this
+ * should always be retrieved dynamically.
+ */
+ #getContextId() {
+ const id = lazy.NetworkUtils.getChannelBrowsingContextID(this.#channel);
+ const browsingContext = BrowsingContext.get(id);
+ return lazy.TabManager.getIdForBrowsingContext(browsingContext);
+ }
+
+ #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.#channel.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
+ // originTime for the global which initiated the request. This is not
+ // available in the parent process, so for now we will use 0.
+ const originTime = 0;
+
+ return {
+ originTime,
+ requestTime: this.#convertTimestamp(channelCreationTime, originTime),
+ redirectStart: this.#convertTimestamp(redirectStartTime, originTime),
+ redirectEnd: this.#convertTimestamp(redirectEndTime, originTime),
+ fetchStart: this.#convertTimestamp(fetchStartTime, originTime),
+ dnsStart: this.#convertTimestamp(domainLookupStartTime, originTime),
+ dnsEnd: this.#convertTimestamp(domainLookupEndTime, originTime),
+ connectStart: this.#convertTimestamp(connectStartTime, originTime),
+ connectEnd: this.#convertTimestamp(connectEndTime, originTime),
+ tlsStart: this.#convertTimestamp(secureConnectionStartTime, originTime),
+ tlsEnd: this.#convertTimestamp(connectEndTime, originTime),
+ requestStart: this.#convertTimestamp(requestStartTime, originTime),
+ responseStart: this.#convertTimestamp(responseStartTime, originTime),
+ responseEnd: this.#convertTimestamp(responseEndTime, originTime),
+ };
+ }
+
+ /**
+ * 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.#channel.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..1fefd6df98
--- /dev/null
+++ b/remote/shared/listeners/NetworkListener.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/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ NetworkEventRecord:
+ "chrome://remote/content/shared/listeners/NetworkEventRecord.sys.mjs",
+ NetworkObserver:
+ "resource://devtools/shared/network-observer/NetworkObserver.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ EventEmitter: "resource://gre/modules/EventEmitter.jsm",
+});
+
+/**
+ * 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;
+ * ...
+ * };
+ * ```
+ *
+ * @emits 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,
+ });
+
+ this.#listening = true;
+ }
+
+ stopListening() {
+ if (!this.#listening) {
+ return;
+ }
+
+ this.#devtoolsNetworkObserver.destroy();
+ this.#devtoolsNetworkObserver = null;
+
+ this.#listening = false;
+ }
+
+ #ignoreChannelFunction = channel => {
+ // 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/test/browser/browser.ini b/remote/shared/listeners/test/browser/browser.ini
new file mode 100644
index 0000000000..1833cc1ded
--- /dev/null
+++ b/remote/shared/listeners/test/browser/browser.ini
@@ -0,0 +1,14 @@
+[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_NetworkListener.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..651475a9cc
--- /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",
+ "",
+ 33,
+ 27
+ );
+
+ // 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_NetworkListener.js b/remote/shared/listeners/test/browser/browser_NetworkListener.js
new file mode 100644
index 0000000000..d756c4ffac
--- /dev/null
+++ b/remote/shared/listeners/test/browser/browser_NetworkListener.js
@@ -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/. */
+
+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);
+});
+
+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/head.js b/remote/shared/listeners/test/browser/head.js
new file mode 100644
index 0000000000..39a40bfeda
--- /dev/null
+++ b/remote/shared/listeners/test/browser/head.js
@@ -0,0 +1,87 @@
+/* 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.
+ * @return {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.loadURI(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..4e7d5400ec
--- /dev/null
+++ b/remote/shared/messagehandler/Errors.sys.mjs
@@ -0,0 +1,90 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { RemoteError } from "chrome://remote/content/shared/RemoteError.sys.mjs";
+
+class MessageHandlerError extends RemoteError {
+ /**
+ * @param {(string|Error)=} x
+ * Optional string describing error situation or Error instance
+ * to propagate.
+ */
+ constructor(x) {
+ super(x);
+ this.name = this.constructor.name;
+ this.status = "message handler error";
+
+ // Error's ctor does not preserve x' stack
+ if (typeof x?.stack !== "undefined") {
+ this.stack = x.stack;
+ }
+ }
+
+ get isMessageHandlerError() {
+ return true;
+ }
+
+ /**
+ * @return {Object.<string, string>}
+ * JSON serialisation of error prototype.
+ */
+ toJSON() {
+ return {
+ error: this.status,
+ message: this.message || "",
+ stacktrace: this.stack || "",
+ };
+ }
+
+ /**
+ * Unmarshals a JSON error representation to the appropriate MessageHandler
+ * error type.
+ *
+ * @param {Object.<string, string>} json
+ * Error object.
+ *
+ * @return {Error}
+ * Error prototype.
+ */
+ static fromJSON(json) {
+ if (typeof json.error == "undefined") {
+ let s = JSON.stringify(json);
+ throw new TypeError("Undeserialisable error type: " + s);
+ }
+ if (!STATUSES.has(json.error)) {
+ throw new TypeError("Not of MessageHandlerError descent: " + json.error);
+ }
+
+ let cls = STATUSES.get(json.error);
+ let err = new cls();
+ if ("message" in json) {
+ err.message = json.message;
+ }
+ if ("stacktrace" in json) {
+ err.stack = json.stacktrace;
+ }
+ return err;
+ }
+}
+
+/**
+ * A command could not be handled by the message handler network.
+ */
+class UnsupportedCommandError extends MessageHandlerError {
+ constructor(message) {
+ super(message);
+ this.status = "unsupported message handler command";
+ }
+}
+
+const STATUSES = new Map([
+ ["message handler error", MessageHandlerError],
+ ["unsupported message handler command", UnsupportedCommandError],
+]);
+
+/** @namespace */
+export const error = {
+ MessageHandlerError,
+ UnsupportedCommandError,
+};
diff --git a/remote/shared/messagehandler/EventsDispatcher.sys.mjs b/remote/shared/messagehandler/EventsDispatcher.sys.mjs
new file mode 100644
index 0000000000..05542d3446
--- /dev/null
+++ b/remote/shared/messagehandler/EventsDispatcher.sys.mjs
@@ -0,0 +1,237 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ContextDescriptorType:
+ "chrome://remote/content/shared/messagehandler/MessageHandler.sys.mjs",
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+ SessionDataCategory:
+ "chrome://remote/content/shared/messagehandler/sessiondata/SessionData.sys.mjs",
+ SessionDataMethod:
+ "chrome://remote/content/shared/messagehandler/sessiondata/SessionData.sys.mjs",
+ TabManager: "chrome://remote/content/shared/TabManager.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
+
+/**
+ * Helper to listen to events which rely on SessionData.
+ * In order to support the EventsDispatcher, a module emitting events should
+ * subscribe and unsubscribe to those events based on SessionData updates
+ * and should use the "event" SessionData category.
+ */
+export class EventsDispatcher {
+ // The MessageHandler owning this EventsDispatcher.
+ #messageHandler;
+
+ /**
+ * @typedef {Object} EventListenerInfo
+ * @property {ContextDescriptor} contextDescriptor
+ * The ContextDescriptor to which those callbacks are associated
+ * @property {Set<Function>} callbacks
+ * The callbacks to trigger when an event matching the ContextDescriptor
+ * is received.
+ */
+
+ // Map from event name to map of strings (context keys) to EventListenerInfo.
+ #listenersByEventName;
+
+ /**
+ * Create a new EventsDispatcher instance.
+ *
+ * @param {MessageHandler} messageHandler
+ * The MessageHandler owning this EventsDispatcher.
+ */
+ constructor(messageHandler) {
+ this.#messageHandler = messageHandler;
+
+ this.#listenersByEventName = new Map();
+ }
+
+ destroy() {
+ for (const event of this.#listenersByEventName.keys()) {
+ this.#messageHandler.off(event, this.#onMessageHandlerEvent);
+ }
+
+ this.#listenersByEventName = null;
+ }
+
+ /**
+ * Stop listening for an event relying on SessionData and relayed by the
+ * message handler.
+ *
+ * @param {string} event
+ * Name of the event to unsubscribe from.
+ * @param {ContextDescriptor} contextDescriptor
+ * Context descriptor for this event.
+ * @param {function} callback
+ * Event listener callback.
+ * @return {Promise}
+ * Promise which resolves when the event fully unsubscribed, including
+ * propagating the necessary session data.
+ */
+ async off(event, contextDescriptor, callback) {
+ return this.update([{ event, contextDescriptor, callback, enable: false }]);
+ }
+
+ /**
+ * Listen for an event relying on SessionData and relayed by the message
+ * handler.
+ *
+ * @param {string} event
+ * Name of the event to subscribe to.
+ * @param {ContextDescriptor} contextDescriptor
+ * Context descriptor for this event.
+ * @param {function} callback
+ * Event listener callback.
+ * @return {Promise}
+ * Promise which resolves when the event fully subscribed to, including
+ * propagating the necessary session data.
+ */
+ async on(event, contextDescriptor, callback) {
+ return this.update([{ event, contextDescriptor, callback, enable: true }]);
+ }
+
+ /**
+ * An object that holds information about subscription/unsubscription
+ * of an event.
+ *
+ * @typedef Subscription
+ *
+ * @param {string} event
+ * Name of the event to subscribe/unsubscribe to.
+ * @param {ContextDescriptor} contextDescriptor
+ * Context descriptor for this event.
+ * @param {function} callback
+ * Event listener callback.
+ * @param {boolean} enable
+ * True, if we need to subscribe to an event.
+ * Otherwise false.
+ */
+
+ /**
+ * Start or stop listening to a list of events relying on SessionData
+ * and relayed by the message handler.
+ *
+ * @param {Array<Subscription>} subscriptions
+ * The list of information to subscribe/unsubscribe to.
+ *
+ * @return {Promise}
+ * Promise which resolves when the events fully subscribed/unsubscribed to,
+ * including propagating the necessary session data.
+ */
+ async update(subscriptions) {
+ const sessionDataItemUpdates = [];
+ subscriptions.forEach(({ event, contextDescriptor, callback, enable }) => {
+ if (enable) {
+ // Setup listeners.
+ if (!this.#listenersByEventName.has(event)) {
+ this.#listenersByEventName.set(event, new Map());
+ this.#messageHandler.on(event, this.#onMessageHandlerEvent);
+ }
+
+ const key = this.#getContextKey(contextDescriptor);
+ const listeners = this.#listenersByEventName.get(event);
+ if (listeners.has(key)) {
+ const { callbacks } = listeners.get(key);
+ callbacks.add(callback);
+ } else {
+ const callbacks = new Set([callback]);
+ listeners.set(key, { callbacks, contextDescriptor });
+
+ sessionDataItemUpdates.push({
+ ...this.#getSessionDataItem(event, contextDescriptor),
+ method: lazy.SessionDataMethod.Add,
+ });
+ }
+ } else {
+ // Remove listeners.
+ const listeners = this.#listenersByEventName.get(event);
+ if (!listeners) {
+ return;
+ }
+
+ const key = this.#getContextKey(contextDescriptor);
+ if (!listeners.has(key)) {
+ return;
+ }
+
+ const { callbacks } = listeners.get(key);
+ if (callbacks.has(callback)) {
+ callbacks.delete(callback);
+ if (callbacks.size === 0) {
+ listeners.delete(key);
+ if (listeners.size === 0) {
+ this.#messageHandler.off(event, this.#onMessageHandlerEvent);
+ this.#listenersByEventName.delete(event);
+ }
+
+ sessionDataItemUpdates.push({
+ ...this.#getSessionDataItem(event, contextDescriptor),
+ method: lazy.SessionDataMethod.Remove,
+ });
+ }
+ }
+ }
+ });
+
+ // Update all sessionData at once.
+ await this.#messageHandler.updateSessionData(sessionDataItemUpdates);
+ }
+
+ #getContextKey(contextDescriptor) {
+ const { id, type } = contextDescriptor;
+ return `${type}-${id}`;
+ }
+
+ #getSessionDataItem(event, contextDescriptor) {
+ const [moduleName] = event.split(".");
+ return {
+ moduleName,
+ category: lazy.SessionDataCategory.Event,
+ contextDescriptor,
+ values: [event],
+ };
+ }
+
+ #matchesContext(contextInfo, contextDescriptor) {
+ if (contextDescriptor.type === lazy.ContextDescriptorType.All) {
+ return true;
+ }
+
+ if (
+ contextDescriptor.type === lazy.ContextDescriptorType.TopBrowsingContext
+ ) {
+ const eventBrowsingContext = lazy.TabManager.getBrowsingContextById(
+ contextInfo.contextId
+ );
+ return eventBrowsingContext?.browserId === contextDescriptor.id;
+ }
+
+ return false;
+ }
+
+ #onMessageHandlerEvent = (name, event, contextInfo) => {
+ const listeners = this.#listenersByEventName.get(name);
+ for (const { callbacks, contextDescriptor } of listeners.values()) {
+ if (!this.#matchesContext(contextInfo, contextDescriptor)) {
+ continue;
+ }
+
+ for (const callback of callbacks) {
+ try {
+ callback(name, event);
+ } catch (e) {
+ lazy.logger.debug(
+ `Error while executing callback for ${name}: ${e.message}`
+ );
+ }
+ }
+ }
+ };
+}
diff --git a/remote/shared/messagehandler/MessageHandler.sys.mjs b/remote/shared/messagehandler/MessageHandler.sys.mjs
new file mode 100644
index 0000000000..f362cc55b2
--- /dev/null
+++ b/remote/shared/messagehandler/MessageHandler.sys.mjs
@@ -0,0 +1,349 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+import { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ error: "chrome://remote/content/shared/messagehandler/Errors.sys.mjs",
+ EventsDispatcher:
+ "chrome://remote/content/shared/messagehandler/EventsDispatcher.sys.mjs",
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+ ModuleCache:
+ "chrome://remote/content/shared/messagehandler/ModuleCache.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
+
+/**
+ * A ContextDescriptor object provides information to decide if a broadcast or
+ * a session data item should be applied to a specific MessageHandler context.
+ *
+ * @typedef {Object} ContextDescriptor
+ * @property {ContextDescriptorType} type
+ * The type of context
+ * @property {String=} id
+ * Unique id of a given context for the provided type.
+ * For ContextDescriptorType.All, id can be ommitted.
+ * For ContextDescriptorType.TopBrowsingContext, the id should be the
+ * browserId corresponding to a top-level browsing context.
+ */
+
+/**
+ * Enum of ContextDescriptor types.
+ *
+ * @enum {string}
+ */
+export const ContextDescriptorType = {
+ All: "All",
+ TopBrowsingContext: "TopBrowsingContext",
+};
+
+/**
+ * A ContextInfo identifies a given context that can be linked to a MessageHandler
+ * instance. It should be used to identify events coming from this context.
+ *
+ * It can either be provided by the MessageHandler itself, when the event is
+ * emitted from the context it relates to.
+ *
+ * Or it can be assembled manually, for instance when emitting an event which
+ * relates to a window global from the root layer (eg browsingContext.contextCreated).
+ *
+ * @typedef {Object} ContextInfo
+ * @property {String} contextId
+ * Unique id of the MessageHandler corresponding to this context.
+ * @property {String} type
+ * One of MessageHandler.type.
+ */
+
+/**
+ * MessageHandler instances are dedicated to handle both Commands and Events
+ * to enable automation and introspection for remote control protocols.
+ *
+ * MessageHandler instances are designed to form a network, where each instance
+ * should allow to inspect a specific context (eg. a BrowsingContext, a Worker,
+ * etc). Those instances might live in different processes and threads but
+ * should be linked together by the usage of a single sessionId, shared by all
+ * the instances of a single MessageHandler network.
+ *
+ * MessageHandler instances will be dynamically spawned depending on which
+ * Command or which Event needs to be processed and should therefore not be
+ * explicitly created by consumers, nor used directly.
+ *
+ * The only exception is the ROOT MessageHandler. This MessageHandler will be
+ * the entry point to send commands to the rest of the network. It will also
+ * emit all the relevant events captured by the network.
+ *
+ * However, even to create this ROOT MessageHandler, consumers should use the
+ * RootMessageHandlerRegistry. This singleton will ensure that MessageHandler
+ * instances are properly registered and can be retrieved based on a given
+ * session id as well as some other context information.
+ */
+export class MessageHandler extends EventEmitter {
+ #context;
+ #contextId;
+ #eventsDispatcher;
+ #moduleCache;
+ #sessionId;
+
+ /**
+ * Create a new MessageHandler instance.
+ *
+ * @param {String} sessionId
+ * ID of the session the handler is used for.
+ * @param {Object} context
+ * The context linked to this MessageHandler instance.
+ */
+ constructor(sessionId, context) {
+ super();
+
+ this.#moduleCache = new lazy.ModuleCache(this);
+
+ this.#sessionId = sessionId;
+ this.#context = context;
+ this.#contextId = this.constructor.getIdFromContext(context);
+ this.#eventsDispatcher = new lazy.EventsDispatcher(this);
+ }
+
+ get context() {
+ return this.#context;
+ }
+
+ get contextId() {
+ return this.#contextId;
+ }
+
+ get eventsDispatcher() {
+ return this.#eventsDispatcher;
+ }
+
+ get moduleCache() {
+ return this.#moduleCache;
+ }
+
+ get name() {
+ return [this.sessionId, this.constructor.type, this.contextId].join("-");
+ }
+
+ get sessionId() {
+ return this.#sessionId;
+ }
+
+ destroy() {
+ lazy.logger.trace(
+ `MessageHandler ${this.constructor.type} for session ${this.sessionId} is being destroyed`
+ );
+ this.#eventsDispatcher.destroy();
+ this.#moduleCache.destroy();
+
+ // At least the MessageHandlerRegistry will be expecting this event in order
+ // to remove the instance from the registry when destroyed.
+ this.emit("message-handler-destroyed", this);
+ }
+
+ /**
+ * Emit a message handler event.
+ *
+ * Such events should bubble up to the root of a MessageHandler network.
+ *
+ * @param {String} name
+ * Name of the event. Protocol level events should be of the
+ * form [module name].[event name].
+ * @param {Object} data
+ * The event's data.
+ * @param {ContextInfo=} contextInfo
+ * The event's context info, used to identify the origin of the event.
+ * If not provided, the context info of the current MessageHandler will be
+ * used.
+ */
+ emitEvent(name, data, contextInfo) {
+ // If no contextInfo field is provided on the event, extract it from the
+ // MessageHandler instance.
+ contextInfo = contextInfo || this.#getContextInfo();
+
+ // Events are emitted both under their own name for consumers listening to
+ // a specific and as `message-handler-event` for consumers which need to
+ // catch all events.
+ this.emit(name, data, contextInfo);
+ this.emit("message-handler-event", {
+ name,
+ contextInfo,
+ data,
+ sessionId: this.sessionId,
+ });
+ }
+
+ /**
+ * @typedef {Object} CommandDestination
+ * @property {String} type
+ * One of MessageHandler.type.
+ * @property {String=} id
+ * Unique context identifier. The format depends on the type.
+ * For WINDOW_GLOBAL destinations, this is a browsing context id.
+ * Optional, should only be provided if `contextDescriptor` is missing.
+ * @property {ContextDescriptor=} contextDescriptor
+ * Descriptor used to match several contexts, which will all receive the
+ * command.
+ * Optional, should only be provided if `id` is missing.
+ */
+
+ /**
+ * @typedef {Object} Command
+ * @property {String} commandName
+ * The name of the command to execute.
+ * @property {String} moduleName
+ * The name of the module.
+ * @property {Object} params
+ * Optional command parameters.
+ * @property {CommandDestination} destination
+ * The destination describing a debuggable context.
+ * @property {boolean=} retryOnAbort
+ * Optional. When true, commands will be retried upon AbortError, which
+ * can occur when the underlying JSWindowActor pair is destroyed.
+ * Defaults to `false`.
+ */
+
+ /**
+ * Retrieve all module classes matching the moduleName and destination.
+ * See `getAllModuleClasses` (ModuleCache.jsm) for more details.
+ *
+ * @param {String} moduleName
+ * The name of the module.
+ * @param {Destination} destination
+ * The destination.
+ * @return {Array.<class<Module>=>}
+ * An array of Module classes.
+ */
+ getAllModuleClasses(moduleName, destination) {
+ return this.#moduleCache.getAllModuleClasses(moduleName, destination);
+ }
+
+ /**
+ * Handle a command, either in one of the modules owned by this MessageHandler
+ * or in a another MessageHandler after forwarding the command.
+ *
+ * @param {Command} command
+ * The command that should be either handled in this layer or forwarded to
+ * the next layer leading to the destination.
+ * @return {Promise} A Promise that will resolve with the return value of the
+ * command once it has been executed.
+ */
+ handleCommand(command) {
+ const { moduleName, commandName, params, destination } = command;
+ lazy.logger.trace(
+ `Received command ${moduleName}.${commandName} for destination ${destination.type}`
+ );
+
+ if (!this.supportsCommand(moduleName, commandName, destination)) {
+ throw new lazy.error.UnsupportedCommandError(
+ `${moduleName}.${commandName} not supported for destination ${destination?.type}`
+ );
+ }
+
+ const module = this.#moduleCache.getModuleInstance(moduleName, destination);
+ if (module && module.supportsMethod(commandName)) {
+ return module[commandName](params, destination);
+ }
+
+ return this.forwardCommand(command);
+ }
+
+ toString() {
+ return `[object ${this.constructor.name} ${this.name}]`;
+ }
+
+ /**
+ * Apply the initial session data items provided to this MessageHandler on
+ * startup. Implementation is specific to each MessageHandler class.
+ *
+ * By default the implementation is a no-op.
+ *
+ * @param {Array<SessionDataItem>} sessionDataItems
+ * Initial session data items for this MessageHandler.
+ */
+ async applyInitialSessionDataItems(sessionDataItems) {}
+
+ /**
+ * Returns the module path corresponding to this MessageHandler class.
+ *
+ * Needs to be implemented in the sub class.
+ */
+ static get modulePath() {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * Returns the type corresponding to this MessageHandler class.
+ *
+ * Needs to be implemented in the sub class.
+ */
+ static get type() {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * Returns the id corresponding to a context compatible with this
+ * MessageHandler class.
+ *
+ * Needs to be implemented in the sub class.
+ */
+ static getIdFromContext(context) {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * Forward a command to other MessageHandlers.
+ *
+ * Needs to be implemented in the sub class.
+ */
+ forwardCommand(command) {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * Check if contextDescriptor matches the context linked
+ * to this MessageHandler instance.
+ *
+ * Needs to be implemented in the sub class.
+ */
+ matchesContext(contextDescriptor) {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * Check if the given command is supported in the module
+ * for the destination
+ *
+ * @param {String} moduleName
+ * The name of the module.
+ * @param {String} commandName
+ * The name of the command.
+ * @param {Destination} destination
+ * The destination.
+ * @return {Boolean}
+ * True if the command is supported.
+ */
+ supportsCommand(moduleName, commandName, destination) {
+ return this.getAllModuleClasses(moduleName, destination).some(cls =>
+ cls.supportsMethod(commandName)
+ );
+ }
+
+ /**
+ * Return the context information for this MessageHandler instance, which
+ * can be used to identify the origin of an event.
+ *
+ * @return {ContextInfo}
+ * The context information for this MessageHandler.
+ */
+ #getContextInfo() {
+ return {
+ contextId: this.contextId,
+ type: this.constructor.type,
+ };
+ }
+}
diff --git a/remote/shared/messagehandler/MessageHandlerRegistry.sys.mjs b/remote/shared/messagehandler/MessageHandlerRegistry.sys.mjs
new file mode 100644
index 0000000000..b6f1bb0fd1
--- /dev/null
+++ b/remote/shared/messagehandler/MessageHandlerRegistry.sys.mjs
@@ -0,0 +1,243 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+import { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+ readSessionData:
+ "chrome://remote/content/shared/messagehandler/sessiondata/SessionDataReader.sys.mjs",
+ RootMessageHandler:
+ "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs",
+ WindowGlobalMessageHandler:
+ "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
+
+/**
+ * Map of MessageHandler type to MessageHandler subclass.
+ */
+XPCOMUtils.defineLazyGetter(
+ lazy,
+ "MessageHandlerClasses",
+ () =>
+ new Map([
+ [lazy.RootMessageHandler.type, lazy.RootMessageHandler],
+ [lazy.WindowGlobalMessageHandler.type, lazy.WindowGlobalMessageHandler],
+ ])
+);
+
+/**
+ * Get the MessageHandler subclass corresponding to the provided type.
+
+ * @param {String} type
+ * MessageHandler type, one of MessageHandler.type.
+ * @return {Class}
+ * A MessageHandler subclass
+ * @throws {Error}
+ * Throws if no MessageHandler subclass is found for the provided type.
+ */
+export function getMessageHandlerClass(type) {
+ if (!lazy.MessageHandlerClasses.has(type)) {
+ throw new Error(`No MessageHandler class available for type "${type}"`);
+ }
+ return lazy.MessageHandlerClasses.get(type);
+}
+
+/**
+ * The MessageHandlerRegistry allows to create and retrieve MessageHandler
+ * instances for different session ids.
+ *
+ * A MessageHandlerRegistry instance is bound to a specific MessageHandler type
+ * and context. All MessageHandler instances created by the same registry will
+ * use the type and context of the registry, but each will be associated to a
+ * different session id.
+ *
+ * The registry is useful to retrieve the appropriate MessageHandler instance
+ * after crossing a technical boundary (eg process, thread...).
+ */
+export class MessageHandlerRegistry extends EventEmitter {
+ /*
+ * @param {String} type
+ * MessageHandler type, one of MessageHandler.type.
+ * @param {Object} context
+ * The context object, which depends on the type.
+ */
+ constructor(type, context) {
+ super();
+
+ this._messageHandlerClass = getMessageHandlerClass(type);
+ this._context = context;
+ this._type = type;
+
+ /**
+ * Map of session id to MessageHandler instance
+ */
+ this._messageHandlersMap = new Map();
+
+ this._onMessageHandlerDestroyed = this._onMessageHandlerDestroyed.bind(
+ this
+ );
+ this._onMessageHandlerEvent = this._onMessageHandlerEvent.bind(this);
+ }
+
+ /**
+ * Create all message handlers for the current context, based on the content
+ * of the session data.
+ * This should typically be called when the context is ready to be used and
+ * to receive/send commands.
+ */
+ createAllMessageHandlers() {
+ const data = lazy.readSessionData();
+ for (const [sessionId, sessionDataItems] of data) {
+ // Create a message handler for this context for each active message
+ // handler session.
+ // TODO: In the future, to support debugging use cases we might want to
+ // only create a message handler if there is relevant data.
+ // For automation scenarios, this is less critical.
+ this._createMessageHandler(sessionId, sessionDataItems);
+ }
+ }
+
+ destroy() {
+ this._messageHandlersMap.forEach(messageHandler => {
+ messageHandler.destroy();
+ });
+ }
+
+ /**
+ * Retrieve all MessageHandler instances held in this registry, for all
+ * session IDs.
+ *
+ * @return {Iterable.<MessageHandler>}
+ * Iterator of MessageHandler instances
+ */
+ getAllMessageHandlers() {
+ return this._messageHandlersMap.values();
+ }
+
+ /**
+ * Retrieve an existing MessageHandler instance matching the provided session
+ * id. Returns null if no MessageHandler was found.
+ *
+ * @param {String} sessionId
+ * ID of the session the handler is used for.
+ * @return {MessageHandler=}
+ * A MessageHandler instance, null if not found.
+ */
+ getExistingMessageHandler(sessionId) {
+ return this._messageHandlersMap.get(sessionId);
+ }
+
+ /**
+ * Retrieve an already registered MessageHandler instance matching the
+ * provided parameters.
+ *
+ * @param {String} sessionId
+ * ID of the session the handler is used for.
+ * @param {String} type
+ * MessageHandler type, one of MessageHandler.type.
+ * @param {Object=} context
+ * The context object, which depends on the type. Can be null for ROOT
+ * type MessageHandlers.
+ * @return {MessageHandler}
+ * A MessageHandler instance.
+ */
+ getOrCreateMessageHandler(sessionId) {
+ let messageHandler = this.getExistingMessageHandler(sessionId);
+ if (!messageHandler) {
+ messageHandler = this._createMessageHandler(sessionId);
+ }
+
+ return messageHandler;
+ }
+
+ /**
+ * Retrieve an already registered RootMessageHandler instance matching the
+ * provided sessionId.
+ *
+ * @param {String} sessionId
+ * ID of the session the handler is used for.
+ * @return {RootMessageHandler}
+ * A RootMessageHandler instance.
+ * @throws {Error}
+ * If no root MessageHandler can be found for the provided session id.
+ */
+ getRootMessageHandler(sessionId) {
+ const rootMessageHandler = this.getExistingMessageHandler(
+ sessionId,
+ lazy.RootMessageHandler.type
+ );
+ if (!rootMessageHandler) {
+ throw new Error(
+ `Unable to find a root MessageHandler for session id ${sessionId}`
+ );
+ }
+ return rootMessageHandler;
+ }
+
+ toString() {
+ return `[object ${this.constructor.name}]`;
+ }
+
+ /**
+ * Create a new MessageHandler instance.
+ *
+ * @param {String} sessionId
+ * ID of the session the handler will be used for.
+ * @param {Array<SessionDataItem>=} sessionDataItems
+ * Optional array of session data items to be applied automatically to the
+ * MessageHandler.
+ * @return {MessageHandler}
+ * A new MessageHandler instance.
+ */
+ _createMessageHandler(sessionId, sessionDataItems) {
+ const messageHandler = new this._messageHandlerClass(
+ sessionId,
+ this._context
+ );
+
+ messageHandler.on(
+ "message-handler-destroyed",
+ this._onMessageHandlerDestroyed
+ );
+ messageHandler.on("message-handler-event", this._onMessageHandlerEvent);
+
+ messageHandler.applyInitialSessionDataItems(sessionDataItems);
+
+ this._messageHandlersMap.set(sessionId, messageHandler);
+
+ lazy.logger.trace(
+ `Created MessageHandler ${this._type} for session ${sessionId}`
+ );
+
+ return messageHandler;
+ }
+
+ // Event handlers
+
+ _onMessageHandlerDestroyed(eventName, messageHandler) {
+ messageHandler.off(
+ "message-handler-destroyed",
+ this._onMessageHandlerDestroyed
+ );
+ messageHandler.off("message-handler-event", this._onMessageHandlerEvent);
+ this._messageHandlersMap.delete(messageHandler.sessionId);
+
+ lazy.logger.trace(
+ `Unregistered MessageHandler ${messageHandler.constructor.type} for session ${messageHandler.sessionId}`
+ );
+ }
+
+ _onMessageHandlerEvent(eventName, messageHandlerEvent) {
+ // The registry simply re-emits MessageHandler events so that consumers
+ // don't have to attach listeners to individual MessageHandler instances.
+ this.emit("message-handler-registry-event", messageHandlerEvent);
+ }
+}
diff --git a/remote/shared/messagehandler/Module.sys.mjs b/remote/shared/messagehandler/Module.sys.mjs
new file mode 100644
index 0000000000..32684ce696
--- /dev/null
+++ b/remote/shared/messagehandler/Module.sys.mjs
@@ -0,0 +1,134 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "disabledExperimentalAPI", () => {
+ return !Services.prefs.getBoolPref("remote.experimental.enabled");
+});
+
+export class Module {
+ #messageHandler;
+
+ /**
+ * Create a new module instance.
+ *
+ * @param {MessageHandler} messageHandler
+ * The MessageHandler instance which owns this Module instance.
+ */
+ constructor(messageHandler) {
+ this.#messageHandler = messageHandler;
+ }
+
+ /**
+ * Clean-up the module instance.
+ *
+ * It's required to be implemented in the sub class.
+ */
+ destroy() {
+ throw new Error("Not implemented");
+ }
+
+ /**
+ * Emit a message handler event.
+ *
+ * Such events should bubble up to the root of a MessageHandler network.
+ *
+ * @param {String} name
+ * Name of the event. Protocol level events should be of the
+ * form [module name].[event name].
+ * @param {Object} data
+ * The event's data.
+ * @param {ContextInfo=} contextInfo
+ * The event's context info, see MessageHandler:emitEvent. Optional.
+ */
+ emitEvent(name, data, contextInfo) {
+ this.messageHandler.emitEvent(name, data, contextInfo);
+ }
+
+ /**
+ * Intercept an event and modify the payload.
+ *
+ * It's required to be implemented in windowglobal-in-root modules.
+ *
+ * @param {string} name
+ * Name of the event.
+ * @param {Object} payload
+ * The event's payload.
+ * @returns {Object}
+ * The modified event payload.
+ */
+ interceptEvent(name, payload) {
+ throw new Error(
+ `Could not intercept event ${name}, interceptEvent is not implemented in windowglobal-in-root module`
+ );
+ }
+
+ /**
+ * Assert if experimental commands are enabled.
+ *
+ * @param {String} methodName
+ * Name of the command.
+ *
+ * @throws {UnknownCommandError}
+ * If experimental commands are disabled.
+ */
+ assertExperimentalCommandsEnabled(methodName) {
+ // TODO: 1778987. Move it to a BiDi specific place.
+ if (lazy.disabledExperimentalAPI) {
+ throw new lazy.error.UnknownCommandError(methodName);
+ }
+ }
+
+ /**
+ * Assert if experimental events are enabled.
+ *
+ * @param {string} moduleName
+ * Name of the module.
+ *
+ * @param {string} event
+ * Name of the event.
+ *
+ * @throws {InvalidArgumentError}
+ * If experimental events are disabled.
+ */
+ assertExperimentalEventsEnabled(moduleName, event) {
+ // TODO: 1778987. Move it to a BiDi specific place.
+ if (lazy.disabledExperimentalAPI) {
+ throw new lazy.error.InvalidArgumentError(
+ `Module ${moduleName} does not support event ${event}`
+ );
+ }
+ }
+
+ /**
+ * Instance shortcut for supportsMethod to avoid reaching the constructor for
+ * consumers which directly deal with an instance.
+ */
+ supportsMethod(methodName) {
+ return this.constructor.supportsMethod(methodName);
+ }
+
+ get messageHandler() {
+ return this.#messageHandler;
+ }
+
+ static get supportedEvents() {
+ return [];
+ }
+
+ static supportsEvent(event) {
+ return this.supportedEvents.includes(event);
+ }
+
+ static supportsMethod(methodName) {
+ return typeof this.prototype[methodName] === "function";
+ }
+}
diff --git a/remote/shared/messagehandler/ModuleCache.sys.mjs b/remote/shared/messagehandler/ModuleCache.sys.mjs
new file mode 100644
index 0000000000..177f5d1f2f
--- /dev/null
+++ b/remote/shared/messagehandler/ModuleCache.sys.mjs
@@ -0,0 +1,207 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ getMessageHandlerClass:
+ "chrome://remote/content/shared/messagehandler/MessageHandlerRegistry.sys.mjs",
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+});
+
+const protocols = {
+ bidi: {},
+ test: {},
+};
+// eslint-disable-next-line mozilla/lazy-getter-object-name
+ChromeUtils.defineESModuleGetters(protocols.bidi, {
+ // Additional protocols might use a different registry for their modules,
+ // in which case this will no longer be a constant but will instead depend on
+ // the protocol owning the MessageHandler. See Bug 1722464.
+ getModuleClass:
+ "chrome://remote/content/webdriver-bidi/modules/ModuleRegistry.sys.mjs",
+});
+// eslint-disable-next-line mozilla/lazy-getter-object-name
+ChromeUtils.defineESModuleGetters(protocols.test, {
+ getModuleClass:
+ "chrome://mochitests/content/browser/remote/shared/messagehandler/test/browser/resources/modules/ModuleRegistry.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
+
+/**
+ * ModuleCache instances are dedicated to lazily create and cache the instances
+ * of all the modules related to a specific MessageHandler instance.
+ *
+ * ModuleCache also implements the logic to resolve the path to the file for a
+ * given module, which depends both on the current MessageHandler context and on
+ * the expected destination.
+ *
+ * In order to implement module logic in any context, separate module files
+ * should be created for each situation. For instance, for a given module,
+ * - ${MODULES_FOLDER}/root/{ModuleName}.jsm contains the implementation for
+ * commands intended for the destination ROOT, and will be created for a ROOT
+ * MessageHandler only. Typically, they will run in the parent process.
+ * - ${MODULES_FOLDER}/windowglobal/{ModuleName}.jsm contains the implementation
+ * for commands intended for a WINDOW_GLOBAL destination, and will be created
+ * for a WINDOW_GLOBAL MessageHandler only. Those will usually run in a
+ * content process.
+ * - ${MODULES_FOLDER}/windowglobal-in-root/{ModuleName}.jsm also handles
+ * commands intended for a WINDOW_GLOBAL destination, but they will be created
+ * for the ROOT MessageHandler and will run in the parent process. This can be
+ * useful if some code has to be executed in the parent process, even though
+ * the final destination is a WINDOW_GLOBAL.
+ * - And so on, as more MessageHandler types get added, more combinations will
+ * follow based on the same pattern:
+ * - {contextName}/{ModuleName}.jsm
+ * - or {destinationType}-in-{currentType}/{ModuleName}.jsm
+ *
+ * All those implementations are optional. If a module cannot be found, based on
+ * the logic detailed above, the MessageHandler will assume that the command
+ * should simply be forwarded to the next layer of the network.
+ */
+export class ModuleCache {
+ /*
+ * @param {MessageHandler} messageHandler
+ * The MessageHandler instance which owns this ModuleCache instance.
+ */
+ constructor(messageHandler) {
+ this.messageHandler = messageHandler;
+ this._messageHandlerType = messageHandler.constructor.type;
+
+ // Use the module class from the WebDriverBiDi ModuleRegistry if we
+ // are not using test modules.
+ this._protocol = Services.prefs.getBoolPref(
+ "remote.messagehandler.modulecache.useBrowserTestRoot",
+ false
+ )
+ ? protocols.test
+ : protocols.bidi;
+
+ // Map of absolute module paths to module instances.
+ this._modules = new Map();
+ }
+
+ /**
+ * Destroy all instantiated modules.
+ */
+ destroy() {
+ this._modules.forEach(module => module?.destroy());
+ }
+
+ /**
+ * Retrieve all module classes matching the provided module name to reach the
+ * provided destination, from the current context.
+ *
+ * This corresponds to the path a command can take to reach its destination.
+ * A command's method must be implemented in one of the classes returned by
+ * getAllModuleClasses in order to be successfully handled.
+ *
+ * @param {String} moduleName
+ * The name of the module.
+ * @param {Destination} destination
+ * The destination.
+ * @return {Array.<class<Module>=>}
+ * An array of Module classes.
+ */
+ getAllModuleClasses(moduleName, destination) {
+ const destinationType = destination.type;
+ const folders = [
+ this._getModuleFolder(this._messageHandlerType, destinationType),
+ ];
+
+ // Bug 1733242: Extend the implementation of this method to handle workers.
+ // It assumes layers have at most one level of nesting, for instance
+ // "root -> windowglobal", but it wouldn't work for something such as
+ // "root -> windowglobal -> worker".
+ if (destinationType !== this._messageHandlerType) {
+ folders.push(this._getModuleFolder(destinationType, destinationType));
+ }
+
+ return folders
+ .map(folder => this._protocol.getModuleClass(moduleName, folder))
+ .filter(cls => !!cls);
+ }
+
+ /**
+ * Get a module instance corresponding to the provided moduleName and
+ * destination. If no existing module can be found in the cache, ModuleCache
+ * will attempt to import the module file and create a new instance, which
+ * will then be cached and returned for subsequent calls.
+ *
+ * @param {String} moduleName
+ * The name of the module which should implement the command.
+ * @param {CommandDestination} destination
+ * The destination of the command for which we need to instantiate a
+ * module. See MessageHandler.jsm for the CommandDestination typedef.
+ * @return {Object=}
+ * A module instance corresponding to the provided moduleName and
+ * destination, or null if it could not be instantiated.
+ */
+ getModuleInstance(moduleName, destination) {
+ const key = `${moduleName}-${destination.type}`;
+
+ if (this._modules.has(key)) {
+ // If there is already a cached instance (potentially null) for the
+ // module name + destination type pair, return it.
+ return this._modules.get(key);
+ }
+
+ const moduleFolder = this._getModuleFolder(
+ this._messageHandlerType,
+ destination.type
+ );
+ const ModuleClass = this._protocol.getModuleClass(moduleName, moduleFolder);
+
+ let module = null;
+ if (ModuleClass) {
+ module = new ModuleClass(this.messageHandler);
+ lazy.logger.trace(
+ `Module ${moduleFolder}/${moduleName}.jsm found for ${destination.type}`
+ );
+ } else {
+ lazy.logger.trace(
+ `Module ${moduleFolder}/${moduleName}.jsm not found for ${destination.type}`
+ );
+ }
+
+ this._modules.set(key, module);
+ return module;
+ }
+
+ /**
+ * Check if the given module exists for the destination.
+ *
+ * @param {String} moduleName
+ * The name of the module.
+ * @param {Destination} destination
+ * The destination.
+ * @returns {Boolean}
+ * True if the module exists.
+ */
+ hasModule(moduleName, destination) {
+ const classes = this.getAllModuleClasses(moduleName, destination);
+ return !!classes.length;
+ }
+
+ toString() {
+ return `[object ${this.constructor.name} ${this.messageHandler.name}]`;
+ }
+
+ _getModuleFolder(originType, destinationType) {
+ const originPath = lazy.getMessageHandlerClass(originType).modulePath;
+ if (originType === destinationType) {
+ // If the command is targeting the current type, the module is expected to
+ // be in eg "windowglobal/${moduleName}.jsm".
+ return originPath;
+ }
+ // If the command is targeting another type, the module is expected to
+ // be in a composed folder eg "windowglobal-in-root/${moduleName}.jsm".
+ const destinationPath = lazy.getMessageHandlerClass(destinationType)
+ .modulePath;
+ return `${destinationPath}-in-${originPath}`;
+ }
+}
diff --git a/remote/shared/messagehandler/RootMessageHandler.sys.mjs b/remote/shared/messagehandler/RootMessageHandler.sys.mjs
new file mode 100644
index 0000000000..cf64bb4502
--- /dev/null
+++ b/remote/shared/messagehandler/RootMessageHandler.sys.mjs
@@ -0,0 +1,154 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { MessageHandler } from "chrome://remote/content/shared/messagehandler/MessageHandler.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ FrameTransport:
+ "chrome://remote/content/shared/messagehandler/transports/FrameTransport.sys.mjs",
+ SessionData:
+ "chrome://remote/content/shared/messagehandler/sessiondata/SessionData.sys.mjs",
+ SessionDataMethod:
+ "chrome://remote/content/shared/messagehandler/sessiondata/SessionData.sys.mjs",
+ WindowGlobalMessageHandler:
+ "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs",
+});
+
+/**
+ * A RootMessageHandler is the root node of a MessageHandler network. It lives
+ * in the parent process. It can forward commands to MessageHandlers in other
+ * layers (at the moment WindowGlobalMessageHandlers in content processes).
+ */
+export class RootMessageHandler extends MessageHandler {
+ #frameTransport;
+ #sessionData;
+
+ /**
+ * Returns the RootMessageHandler module path.
+ *
+ * @return {String}
+ */
+ static get modulePath() {
+ return "root";
+ }
+
+ /**
+ * Returns the RootMessageHandler type.
+ *
+ * @return {String}
+ */
+ static get type() {
+ return "ROOT";
+ }
+
+ /**
+ * The ROOT MessageHandler is unique for a given MessageHandler network
+ * (ie for a given sessionId). Reuse the type as context id here.
+ */
+ static getIdFromContext(context) {
+ return RootMessageHandler.type;
+ }
+
+ /**
+ * Create a new RootMessageHandler instance.
+ *
+ * @param {String} sessionId
+ * ID of the session the handler is used for.
+ */
+ constructor(sessionId) {
+ super(sessionId, null);
+
+ this.#frameTransport = new lazy.FrameTransport(this);
+ this.#sessionData = new lazy.SessionData(this);
+ }
+
+ get sessionData() {
+ return this.#sessionData;
+ }
+
+ destroy() {
+ this.#sessionData.destroy();
+ super.destroy();
+ }
+
+ /**
+ * Add new session data items of a given module, category and
+ * contextDescriptor.
+ *
+ * Forwards the call to the SessionData instance owned by this
+ * RootMessageHandler and propagates the information via a command to existing
+ * MessageHandlers.
+ */
+ addSessionData(sessionData = {}) {
+ sessionData.method = lazy.SessionDataMethod.Add;
+ return this.updateSessionData([sessionData]);
+ }
+
+ /**
+ * Emit a public protocol event. This event will be sent over to the client.
+ *
+ * @param {String} name
+ * Name of the event. Protocol level events should be of the
+ * form [module name].[event name].
+ * @param {Object} data
+ * The event's data.
+ */
+ emitProtocolEvent(name, data) {
+ this.emit("message-handler-protocol-event", {
+ name,
+ data,
+ sessionId: this.sessionId,
+ });
+ }
+
+ /**
+ * Forward the provided command to WINDOW_GLOBAL MessageHandlers via the
+ * FrameTransport.
+ *
+ * @param {Command} command
+ * The command to forward. See type definition in MessageHandler.js
+ * @return {Promise}
+ * Returns a promise that resolves with the result of the command.
+ */
+ forwardCommand(command) {
+ switch (command.destination.type) {
+ case lazy.WindowGlobalMessageHandler.type:
+ return this.#frameTransport.forwardCommand(command);
+ default:
+ throw new Error(
+ `Cannot forward command to "${command.destination.type}" from "${this.constructor.type}".`
+ );
+ }
+ }
+
+ matchesContext() {
+ return true;
+ }
+
+ /**
+ * Remove session data items of a given module, category and
+ * contextDescriptor.
+ *
+ * Forwards the call to the SessionData instance owned by this
+ * RootMessageHandler and propagates the information via a command to existing
+ * MessageHandlers.
+ */
+ removeSessionData(sessionData = {}) {
+ sessionData.method = lazy.SessionDataMethod.Remove;
+ return this.updateSessionData([sessionData]);
+ }
+
+ /**
+ * Update session data items of a given module, category and
+ * contextDescriptor.
+ *
+ * Forwards the call to the SessionData instance owned by this
+ * RootMessageHandler.
+ */
+ async updateSessionData(sessionData = []) {
+ await this.#sessionData.updateSessionData(sessionData);
+ }
+}
diff --git a/remote/shared/messagehandler/RootMessageHandlerRegistry.sys.mjs b/remote/shared/messagehandler/RootMessageHandlerRegistry.sys.mjs
new file mode 100644
index 0000000000..09ac489182
--- /dev/null
+++ b/remote/shared/messagehandler/RootMessageHandlerRegistry.sys.mjs
@@ -0,0 +1,17 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { MessageHandlerRegistry } from "chrome://remote/content/shared/messagehandler/MessageHandlerRegistry.sys.mjs";
+
+import { RootMessageHandler } from "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs";
+
+/**
+ * In the parent process, only one Root MessageHandlerRegistry should ever be
+ * created. All consumers can safely use this singleton to retrieve the Root
+ * registry and from there either create or retrieve Root MessageHandler
+ * instances for a specific session.
+ */
+export var RootMessageHandlerRegistry = new MessageHandlerRegistry(
+ RootMessageHandler.type
+);
diff --git a/remote/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs b/remote/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs
new file mode 100644
index 0000000000..109bc81d1f
--- /dev/null
+++ b/remote/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs
@@ -0,0 +1,120 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import {
+ ContextDescriptorType,
+ MessageHandler,
+} from "chrome://remote/content/shared/messagehandler/MessageHandler.sys.mjs";
+
+/**
+ * A WindowGlobalMessageHandler is dedicated to debugging a single window
+ * global. It follows the lifecycle of the corresponding window global and will
+ * therefore not survive any navigation. This MessageHandler cannot forward
+ * commands further to other MessageHandlers and represents a leaf node in a
+ * MessageHandler network.
+ */
+export class WindowGlobalMessageHandler extends MessageHandler {
+ #innerWindowId;
+
+ constructor() {
+ super(...arguments);
+
+ this.#innerWindowId = this.context.window.windowGlobalChild.innerWindowId;
+ }
+
+ /**
+ * Returns the WindowGlobalMessageHandler module path.
+ *
+ * @return {String}
+ */
+ static get modulePath() {
+ return "windowglobal";
+ }
+
+ /**
+ * Returns the WindowGlobalMessageHandler type.
+ *
+ * @return {String}
+ */
+ static get type() {
+ return "WINDOW_GLOBAL";
+ }
+
+ /**
+ * For WINDOW_GLOBAL MessageHandlers, `context` is a BrowsingContext,
+ * and BrowsingContext.id can be used as the context id.
+ *
+ * @param {BrowsingContext} context
+ * WindowGlobalMessageHandler contexts are expected to be
+ * BrowsingContexts.
+ * @return {String}
+ * The browsing context id.
+ */
+ static getIdFromContext(context) {
+ return context.id;
+ }
+
+ get innerWindowId() {
+ return this.#innerWindowId;
+ }
+
+ get window() {
+ return this.context.window;
+ }
+
+ async applyInitialSessionDataItems(sessionDataItems) {
+ if (!Array.isArray(sessionDataItems)) {
+ return;
+ }
+
+ const destination = {
+ type: WindowGlobalMessageHandler.type,
+ };
+
+ const sessionDataPromises = sessionDataItems.map(sessionDataItem => {
+ const { moduleName, category, contextDescriptor } = sessionDataItem;
+ if (!this.matchesContext(contextDescriptor)) {
+ return Promise.resolve();
+ }
+
+ // Don't apply session data if the module is not present
+ // for the destination.
+ if (!this.moduleCache.hasModule(moduleName, destination)) {
+ return Promise.resolve();
+ }
+
+ return this.handleCommand({
+ moduleName,
+ commandName: "_applySessionData",
+ params: {
+ category,
+ sessionData: sessionDataItems,
+ },
+ destination,
+ });
+ });
+
+ await Promise.all(sessionDataPromises);
+
+ // With the session data applied the handler is now ready to be used.
+ this.emitEvent("window-global-handler-created", {
+ contextId: this.contextId,
+ innerWindowId: this.#innerWindowId,
+ });
+ }
+
+ forwardCommand(command) {
+ throw new Error(
+ `Cannot forward commands from a "WINDOW_GLOBAL" MessageHandler`
+ );
+ }
+
+ matchesContext(contextDescriptor) {
+ return (
+ contextDescriptor.type === ContextDescriptorType.All ||
+ (contextDescriptor.type === ContextDescriptorType.TopBrowsingContext &&
+ contextDescriptor.id === this.context.browserId)
+ );
+ }
+}
diff --git a/remote/shared/messagehandler/sessiondata/SessionData.sys.mjs b/remote/shared/messagehandler/sessiondata/SessionData.sys.mjs
new file mode 100644
index 0000000000..a74aed6db0
--- /dev/null
+++ b/remote/shared/messagehandler/sessiondata/SessionData.sys.mjs
@@ -0,0 +1,390 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ContextDescriptorType:
+ "chrome://remote/content/shared/messagehandler/MessageHandler.sys.mjs",
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+ RootMessageHandler:
+ "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs",
+ WindowGlobalMessageHandler:
+ "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
+
+/**
+ * @typedef {string} SessionDataCategory
+ **/
+
+/**
+ * Enum of session data categories.
+ *
+ * @readonly
+ * @enum {SessionDataCategory}
+ **/
+export const SessionDataCategory = {
+ Event: "event",
+};
+
+/**
+ * @typedef {string} SessionDataMethod
+ **/
+
+/**
+ * Enum of session data methods.
+ *
+ * @readonly
+ * @enum {SessionDataMethod}
+ **/
+export const SessionDataMethod = {
+ Add: "add",
+ Remove: "remove",
+};
+
+export const SESSION_DATA_SHARED_DATA_KEY = "MessageHandlerSessionData";
+
+// This is a map from session id to session data, which will be persisted and
+// propagated to all processes using Services' sharedData.
+// We have to store this as a unique object under a unique shared data key
+// because new MessageHandlers in other processes will need to access this data
+// without any notion of a specific session.
+// This is a singleton.
+const sessionDataMap = new Map();
+
+/**
+ * @typedef {Object} SessionDataItem
+ * @property {String} moduleName
+ * The name of the module responsible for this data item.
+ * @property {SessionDataCategory} category
+ * The category of data. The supported categories depend on the module.
+ * @property {(string|number|boolean)} value
+ * Value of the session data item.
+ * @property {ContextDescriptor} contextDescriptor
+ * ContextDescriptor to which this session data applies.
+ */
+
+/**
+ * @typedef SessionDataItemUpdate
+ * @property {SessionDataMethod} method
+ * The way sessionData is updated.
+ * @property {String} moduleName
+ * The name of the module responsible for this data item.
+ * @property {SessionDataCategory} category
+ * The category of data. The supported categories depend on the module.
+ * @property {Array<(string|number|boolean)>} values
+ * Values of the session data item update.
+ * @property {ContextDescriptor} contextDescriptor
+ * ContextDescriptor to which this session data applies.
+ */
+
+/**
+ * SessionData provides APIs to read and write the session data for a specific
+ * ROOT message handler. It holds the session data as a property and acts as the
+ * source of truth for this session data.
+ *
+ * The session data of a given message handler network should contain all the
+ * information that might be needed to setup new contexts, for instance a list
+ * of subscribed events, a list of breakpoints etc.
+ *
+ * The actual session data is an array of SessionDataItems. Example below:
+ * ```
+ * data: [
+ * {
+ * moduleName: "log",
+ * category: "event",
+ * value: "log.entryAdded",
+ * contextDescriptor: { type: "all" }
+ * },
+ * {
+ * moduleName: "browsingContext",
+ * category: "event",
+ * value: "browsingContext.contextCreated",
+ * contextDescriptor: { type: "browser-element", id: "7"}
+ * },
+ * {
+ * moduleName: "browsingContext",
+ * category: "event",
+ * value: "browsingContext.contextCreated",
+ * contextDescriptor: { type: "browser-element", id: "12"}
+ * },
+ * ]
+ * ```
+ *
+ * The session data will be persisted using Services.ppmm.sharedData, so that
+ * new contexts living in different processes can also access the information
+ * during their startup.
+ *
+ * This class should only be used from a ROOT MessageHandler, or from modules
+ * owned by a ROOT MessageHandler. Other MessageHandlers should rely on
+ * SessionDataReader's readSessionData to get read-only access to session data.
+ *
+ */
+export class SessionData {
+ constructor(messageHandler) {
+ if (messageHandler.constructor.type != lazy.RootMessageHandler.type) {
+ throw new Error(
+ "SessionData should only be used from a ROOT MessageHandler"
+ );
+ }
+
+ this._messageHandler = messageHandler;
+
+ /*
+ * The actual data for this session. This is an array of SessionDataItems.
+ */
+ this._data = [];
+ }
+
+ destroy() {
+ // Update the sessionDataMap singleton.
+ sessionDataMap.delete(this._messageHandler.sessionId);
+
+ // Update sharedData and flush to force consistency.
+ Services.ppmm.sharedData.set(SESSION_DATA_SHARED_DATA_KEY, sessionDataMap);
+ Services.ppmm.sharedData.flush();
+ }
+
+ /**
+ * Update session data items of a given module, category and
+ * contextDescriptor.
+ *
+ * A SessionDataItem will be added or removed for each value of each update
+ * in the provided array.
+ *
+ * Attempting to add a duplicate SessionDataItem or to remove an unknown
+ * SessionDataItem will be silently skipped (no-op).
+ *
+ * The data will be persisted across processes at the end of this method.
+ *
+ * @param {Array<SessionDataItemUpdate>} sessionDataItemUpdates
+ * Array of session data item updates.
+ *
+ * @return {Array<SessionDataItemUpdate>}
+ * The subset of session data item updates which want to be applied.
+ */
+ applySessionData(sessionDataItemUpdates = []) {
+ // The subset of session data item updates, which are cleaned up from
+ // dublicates and unknown items.
+ let updates = [];
+ for (const sessionDataItemUpdate of sessionDataItemUpdates) {
+ const {
+ category,
+ contextDescriptor,
+ method,
+ moduleName,
+ values,
+ } = sessionDataItemUpdate;
+ const updatedValues = [];
+ for (const value of values) {
+ const item = { moduleName, category, contextDescriptor, value };
+
+ if (method === SessionDataMethod.Add) {
+ const hasItem = this._findIndex(item) != -1;
+
+ if (!hasItem) {
+ this._data.push(item);
+ updatedValues.push(value);
+ } else {
+ lazy.logger.warn(
+ `Duplicated session data item was not added: ${JSON.stringify(
+ item
+ )}`
+ );
+ }
+ } else {
+ const itemIndex = this._findIndex(item);
+
+ if (itemIndex != -1) {
+ // The item was found in the session data, remove it.
+ this._data.splice(itemIndex, 1);
+ updatedValues.push(value);
+ } else {
+ lazy.logger.warn(
+ `Missing session data item was not removed: ${JSON.stringify(
+ item
+ )}`
+ );
+ }
+ }
+ }
+
+ if (updatedValues.length) {
+ updates.push({
+ ...sessionDataItemUpdate,
+ values: updatedValues,
+ });
+ }
+ }
+ // Persist the sessionDataMap.
+ this._persist();
+
+ return updates;
+ }
+
+ /**
+ * Retrieve the SessionDataItems for a given module and type.
+ *
+ * @param {String} moduleName
+ * The name of the module responsible for this data item.
+ * @param {String} category
+ * The session data category.
+ * @param {ContextDescriptor=} contextDescriptor
+ * Optional context descriptor, to retrieve only session data items added
+ * for a specific context descriptor.
+ * @return {Array<SessionDataItem>}
+ * Array of SessionDataItems for the provided module and type.
+ */
+ getSessionData(moduleName, category, contextDescriptor) {
+ return this._data.filter(
+ item =>
+ item.moduleName === moduleName &&
+ item.category === category &&
+ (!contextDescriptor ||
+ this._isSameContextDescriptor(
+ item.contextDescriptor,
+ contextDescriptor
+ ))
+ );
+ }
+
+ /**
+ * Update session data items of a given module, category and
+ * contextDescriptor and propagate the information
+ * via a command to existing MessageHandlers.
+ *
+ * @param {Array<SessionDataItemUpdate>} sessionDataItemUpdates
+ * Array of session data item updates.
+ */
+ async updateSessionData(sessionDataItemUpdates = []) {
+ const updates = this.applySessionData(sessionDataItemUpdates);
+
+ if (!updates.length) {
+ // Avoid unnecessary broadcast if no items were updated.
+ return;
+ }
+
+ // Create a Map with the structure moduleName -> category -> list of descriptors.
+ const structuredUpdates = new Map();
+ for (const { moduleName, category, contextDescriptor } of updates) {
+ if (!structuredUpdates.has(moduleName)) {
+ structuredUpdates.set(moduleName, new Map());
+ }
+ if (!structuredUpdates.get(moduleName).has(category)) {
+ structuredUpdates.get(moduleName).set(category, new Set());
+ }
+ const descriptors = structuredUpdates.get(moduleName).get(category);
+ // If there is at least one update for all contexts,
+ // keep only this descriptor in the list of descriptors
+ if (contextDescriptor.type === lazy.ContextDescriptorType.All) {
+ structuredUpdates
+ .get(moduleName)
+ .set(category, new Set([contextDescriptor]));
+ }
+ // Add an individual descriptor if there is no descriptor for all contexts.
+ else if (
+ descriptors.size !== 1 ||
+ Array.from(descriptors)[0]?.type !== lazy.ContextDescriptorType.All
+ ) {
+ descriptors.add(contextDescriptor);
+ }
+ }
+
+ const rootDestination = {
+ type: lazy.RootMessageHandler.type,
+ };
+ const sessionDataPromises = [];
+
+ for (const [moduleName, categories] of structuredUpdates.entries()) {
+ for (const [category, contextDescriptors] of categories.entries()) {
+ // Find sessionData for the category and the moduleName.
+ const relevantSessionData = this._data.filter(
+ item => item.category == category && item.moduleName === moduleName
+ );
+ for (const contextDescriptor of contextDescriptors.values()) {
+ const windowGlobalDestination = {
+ type: lazy.WindowGlobalMessageHandler.type,
+ contextDescriptor,
+ };
+
+ for (const destination of [
+ windowGlobalDestination,
+ rootDestination,
+ ]) {
+ // Only apply session data if the module is present for the destination.
+ if (
+ this._messageHandler.supportsCommand(
+ moduleName,
+ "_applySessionData",
+ destination
+ )
+ ) {
+ sessionDataPromises.push(
+ this._messageHandler
+ .handleCommand({
+ moduleName,
+ commandName: "_applySessionData",
+ params: {
+ sessionData: relevantSessionData,
+ category,
+ contextDescriptor,
+ },
+ destination,
+ })
+ ?.catch(reason =>
+ lazy.logger.error(
+ `_applySessionData for module: ${moduleName} failed, reason: ${reason}`
+ )
+ )
+ );
+ }
+ }
+ }
+ }
+ }
+
+ await Promise.allSettled(sessionDataPromises);
+ }
+
+ _isSameItem(item1, item2) {
+ const descriptor1 = item1.contextDescriptor;
+ const descriptor2 = item2.contextDescriptor;
+
+ return (
+ item1.moduleName === item2.moduleName &&
+ item1.category === item2.category &&
+ this._isSameContextDescriptor(descriptor1, descriptor2) &&
+ item1.value === item2.value
+ );
+ }
+
+ _isSameContextDescriptor(contextDescriptor1, contextDescriptor2) {
+ if (contextDescriptor1.type === lazy.ContextDescriptorType.All) {
+ // Ignore the id for type "all" since we made the id optional for this type.
+ return contextDescriptor1.type === contextDescriptor2.type;
+ }
+
+ return (
+ contextDescriptor1.type === contextDescriptor2.type &&
+ contextDescriptor1.id === contextDescriptor2.id
+ );
+ }
+
+ _findIndex(item) {
+ return this._data.findIndex(_item => this._isSameItem(item, _item));
+ }
+
+ _persist() {
+ // Update the sessionDataMap singleton.
+ sessionDataMap.set(this._messageHandler.sessionId, this._data);
+
+ // Update sharedData and flush to force consistency.
+ Services.ppmm.sharedData.set(SESSION_DATA_SHARED_DATA_KEY, sessionDataMap);
+ Services.ppmm.sharedData.flush();
+ }
+}
diff --git a/remote/shared/messagehandler/sessiondata/SessionDataReader.sys.mjs b/remote/shared/messagehandler/sessiondata/SessionDataReader.sys.mjs
new file mode 100644
index 0000000000..107ab82dc6
--- /dev/null
+++ b/remote/shared/messagehandler/sessiondata/SessionDataReader.sys.mjs
@@ -0,0 +1,29 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ SESSION_DATA_SHARED_DATA_KEY:
+ "chrome://remote/content/shared/messagehandler/sessiondata/SessionData.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "sharedData", () => {
+ const isInParent =
+ Services.appinfo.processType == Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT;
+
+ return isInParent ? Services.ppmm.sharedData : Services.cpmm.sharedData;
+});
+
+/**
+ * Returns a snapshot of the session data map, which is cloned from the
+ * sessionDataMap singleton of SessionData.jsm.
+ *
+ * @return {Map.<string, Array<SessionDataItem>>}
+ * Map of session id to arrays of SessionDataItems.
+ */
+export const readSessionData = () =>
+ lazy.sharedData.get(lazy.SESSION_DATA_SHARED_DATA_KEY) || new Map();
diff --git a/remote/shared/messagehandler/test/browser/broadcast/browser.ini b/remote/shared/messagehandler/test/browser/broadcast/browser.ini
new file mode 100644
index 0000000000..a2f989aaf0
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/broadcast/browser.ini
@@ -0,0 +1,17 @@
+[DEFAULT]
+tags = remote
+subsuite = remote
+support-files =
+ doc_messagehandler_broadcasting_xul.xhtml
+ head.js
+ !/remote/shared/messagehandler/test/browser/head.js
+ !/remote/shared/messagehandler/test/browser/resources/*
+prefs =
+ remote.messagehandler.modulecache.useBrowserTestRoot=true
+
+[browser_filter_top_browsing_context.js]
+[browser_only_content_process.js]
+[browser_two_tabs.js]
+[browser_two_tabs_with_params.js]
+[browser_two_windows.js]
+[browser_with_frames.js]
diff --git a/remote/shared/messagehandler/test/browser/broadcast/browser_filter_top_browsing_context.js b/remote/shared/messagehandler/test/browser/broadcast/browser_filter_top_browsing_context.js
new file mode 100644
index 0000000000..74bc971850
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/broadcast/browser_filter_top_browsing_context.js
@@ -0,0 +1,83 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const COM_TEST_PAGE = "https://example.com/document-builder.sjs?html=COM";
+const FRAME_TEST_PAGE = createTestMarkupWithFrames();
+
+add_task(async function test_broadcasting_filter_top_browsing_context() {
+ info("Navigate the initial tab to the COM test URL");
+ const tab1 = gBrowser.selectedTab;
+ await loadURL(tab1.linkedBrowser, COM_TEST_PAGE);
+ const browsingContext1 = tab1.linkedBrowser.browsingContext;
+
+ info("Open a second tab on the frame test URL");
+ const tab2 = await addTab(FRAME_TEST_PAGE);
+ const browsingContext2 = tab2.linkedBrowser.browsingContext;
+
+ const contextsForTab2 = tab2.linkedBrowser.browsingContext.getAllBrowsingContextsInSubtree();
+ is(
+ contextsForTab2.length,
+ 4,
+ "Frame test tab has 3 children contexts (4 in total)"
+ );
+
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-broadcasting_filter_top_browsing_context"
+ );
+
+ const broadcastValue1 = await sendBroadcastForTopBrowsingContext(
+ browsingContext1,
+ rootMessageHandler
+ );
+
+ ok(
+ Array.isArray(broadcastValue1),
+ "The broadcast returned an array of values"
+ );
+
+ is(broadcastValue1.length, 1, "The broadcast returned one value as expected");
+
+ ok(
+ broadcastValue1.includes("broadcast-" + browsingContext1.id),
+ "The broadcast returned the expected value from tab1"
+ );
+
+ const broadcastValue2 = await sendBroadcastForTopBrowsingContext(
+ browsingContext2,
+ rootMessageHandler
+ );
+
+ ok(
+ Array.isArray(broadcastValue2),
+ "The broadcast returned an array of values"
+ );
+
+ is(broadcastValue2.length, 4, "The broadcast returned 4 values as expected");
+
+ for (const context of contextsForTab2) {
+ ok(
+ broadcastValue2.includes("broadcast-" + context.id),
+ "The broadcast contains the value for browsing context " + context.id
+ );
+ }
+
+ rootMessageHandler.destroy();
+});
+
+function sendBroadcastForTopBrowsingContext(
+ topBrowsingContext,
+ rootMessageHandler
+) {
+ return sendTestBroadcastCommand(
+ "commandwindowglobalonly",
+ "testBroadcast",
+ {},
+ {
+ type: ContextDescriptorType.TopBrowsingContext,
+ id: topBrowsingContext.browserId,
+ },
+ rootMessageHandler
+ );
+}
diff --git a/remote/shared/messagehandler/test/browser/broadcast/browser_only_content_process.js b/remote/shared/messagehandler/test/browser/broadcast/browser_only_content_process.js
new file mode 100644
index 0000000000..d5090c701e
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/broadcast/browser_only_content_process.js
@@ -0,0 +1,46 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_broadcasting_only_content_process() {
+ info("Navigate the initial tab to the test URL");
+ const tab1 = gBrowser.selectedTab;
+ await loadURL(
+ tab1.linkedBrowser,
+ "https://example.com/document-builder.sjs?html=tab"
+ );
+ const browsingContext1 = tab1.linkedBrowser.browsingContext;
+
+ info("Open a new tab on a parent process about: page");
+ await addTab("about:robots");
+
+ info("Open a new tab on a XUL page");
+ await addTab(
+ getRootDirectory(gTestPath) + "doc_messagehandler_broadcasting_xul.xhtml"
+ );
+
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-broadcasting_only_content_process"
+ );
+ const broadcastValue = await sendTestBroadcastCommand(
+ "commandwindowglobalonly",
+ "testBroadcast",
+ {},
+ contextDescriptorAll,
+ rootMessageHandler
+ );
+
+ ok(
+ Array.isArray(broadcastValue),
+ "The broadcast returned an array of values"
+ );
+
+ is(broadcastValue.length, 1, "The broadcast returned 1 value as expected");
+ ok(
+ broadcastValue.includes("broadcast-" + browsingContext1.id),
+ "The broadcast returned the expected value from tab1"
+ );
+
+ rootMessageHandler.destroy();
+});
diff --git a/remote/shared/messagehandler/test/browser/broadcast/browser_two_tabs.js b/remote/shared/messagehandler/test/browser/broadcast/browser_two_tabs.js
new file mode 100644
index 0000000000..16b97e2a0a
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/broadcast/browser_two_tabs.js
@@ -0,0 +1,46 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_PAGE = "https://example.com/document-builder.sjs?html=tab";
+
+add_task(async function test_broadcasting_two_tabs_command() {
+ info("Navigate the initial tab to the test URL");
+ const tab1 = gBrowser.selectedTab;
+ await loadURL(tab1.linkedBrowser, TEST_PAGE);
+ const browsingContext1 = tab1.linkedBrowser.browsingContext;
+
+ info("Open a new tab on the same test URL");
+ const tab2 = await addTab(TEST_PAGE);
+ const browsingContext2 = tab2.linkedBrowser.browsingContext;
+
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-broadcasting_two_tabs_command"
+ );
+
+ const broadcastValue = await sendTestBroadcastCommand(
+ "commandwindowglobalonly",
+ "testBroadcast",
+ {},
+ contextDescriptorAll,
+ rootMessageHandler
+ );
+
+ ok(
+ Array.isArray(broadcastValue),
+ "The broadcast returned an array of values"
+ );
+
+ is(broadcastValue.length, 2, "The broadcast returned 2 values as expected");
+
+ ok(
+ broadcastValue.includes("broadcast-" + browsingContext1.id),
+ "The broadcast returned the expected value from tab1"
+ );
+ ok(
+ broadcastValue.includes("broadcast-" + browsingContext2.id),
+ "The broadcast returned the expected value from tab2"
+ );
+ rootMessageHandler.destroy();
+});
diff --git a/remote/shared/messagehandler/test/browser/broadcast/browser_two_tabs_with_params.js b/remote/shared/messagehandler/test/browser/broadcast/browser_two_tabs_with_params.js
new file mode 100644
index 0000000000..261b8c4cd6
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/broadcast/browser_two_tabs_with_params.js
@@ -0,0 +1,47 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_PAGE = "https://example.com/document-builder.sjs?html=tab";
+
+add_task(async function test_broadcasting_two_tabs_with_params_command() {
+ info("Navigate the initial tab to the test URL");
+ const tab1 = gBrowser.selectedTab;
+ await loadURL(tab1.linkedBrowser, TEST_PAGE);
+ const browsingContext1 = tab1.linkedBrowser.browsingContext;
+
+ info("Open a new tab on the same test URL");
+ const tab2 = await addTab(TEST_PAGE);
+ const browsingContext2 = tab2.linkedBrowser.browsingContext;
+
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-broadcasting_two_tabs_command"
+ );
+
+ const broadcastValue = await sendTestBroadcastCommand(
+ "commandwindowglobalonly",
+ "testBroadcastWithParameter",
+ {
+ value: "some-value",
+ },
+ contextDescriptorAll,
+ rootMessageHandler
+ );
+ ok(
+ Array.isArray(broadcastValue),
+ "The broadcast returned an array of values"
+ );
+
+ is(broadcastValue.length, 2, "The broadcast returned 2 values as expected");
+
+ ok(
+ broadcastValue.includes("broadcast-" + browsingContext1.id + "-some-value"),
+ "The broadcast returned the expected value from tab1"
+ );
+ ok(
+ broadcastValue.includes("broadcast-" + browsingContext2.id + "-some-value"),
+ "The broadcast returned the expected value from tab2"
+ );
+ rootMessageHandler.destroy();
+});
diff --git a/remote/shared/messagehandler/test/browser/broadcast/browser_two_windows.js b/remote/shared/messagehandler/test/browser/broadcast/browser_two_windows.js
new file mode 100644
index 0000000000..f59bebba69
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/broadcast/browser_two_windows.js
@@ -0,0 +1,47 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_PAGE = "https://example.com/document-builder.sjs?html=tab";
+
+add_task(async function test_broadcasting_two_windows_command() {
+ const window1Browser = gBrowser.selectedTab.linkedBrowser;
+ await loadURL(window1Browser, TEST_PAGE);
+ const browsingContext1 = window1Browser.browsingContext;
+
+ const window2 = await BrowserTestUtils.openNewBrowserWindow();
+ registerCleanupFunction(() => BrowserTestUtils.closeWindow(window2));
+
+ const window2Browser = window2.gBrowser.selectedBrowser;
+ await loadURL(window2Browser, TEST_PAGE);
+ const browsingContext2 = window2Browser.browsingContext;
+
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-broadcasting_two_windows_command"
+ );
+ const broadcastValue = await sendTestBroadcastCommand(
+ "commandwindowglobalonly",
+ "testBroadcast",
+ {},
+ contextDescriptorAll,
+ rootMessageHandler
+ );
+
+ ok(
+ Array.isArray(broadcastValue),
+ "The broadcast returned an array of values"
+ );
+ is(broadcastValue.length, 2, "The broadcast returned 2 values as expected");
+
+ ok(
+ broadcastValue.includes("broadcast-" + browsingContext1.id),
+ "The broadcast returned the expected value from tab1"
+ );
+ ok(
+ broadcastValue.includes("broadcast-" + browsingContext2.id),
+ "The broadcast returned the expected value from tab2"
+ );
+
+ rootMessageHandler.destroy();
+});
diff --git a/remote/shared/messagehandler/test/browser/broadcast/browser_with_frames.js b/remote/shared/messagehandler/test/browser/broadcast/browser_with_frames.js
new file mode 100644
index 0000000000..7cb23e3309
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/broadcast/browser_with_frames.js
@@ -0,0 +1,39 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_broadcasting_with_frames() {
+ info("Navigate the initial tab to the test URL");
+ const tab = gBrowser.selectedTab;
+ await loadURL(tab.linkedBrowser, createTestMarkupWithFrames());
+
+ const contexts = tab.linkedBrowser.browsingContext.getAllBrowsingContextsInSubtree();
+ is(contexts.length, 4, "Test tab has 3 children contexts (4 in total)");
+
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-broadcasting_with_frames"
+ );
+ const broadcastValue = await sendTestBroadcastCommand(
+ "commandwindowglobalonly",
+ "testBroadcast",
+ {},
+ contextDescriptorAll,
+ rootMessageHandler
+ );
+
+ ok(
+ Array.isArray(broadcastValue),
+ "The broadcast returned an array of values"
+ );
+ is(broadcastValue.length, 4, "The broadcast returned 4 values as expected");
+
+ for (const context of contexts) {
+ ok(
+ broadcastValue.includes("broadcast-" + context.id),
+ "The broadcast contains the value for browsing context " + context.id
+ );
+ }
+
+ rootMessageHandler.destroy();
+});
diff --git a/remote/shared/messagehandler/test/browser/broadcast/doc_messagehandler_broadcasting_xul.xhtml b/remote/shared/messagehandler/test/browser/broadcast/doc_messagehandler_broadcasting_xul.xhtml
new file mode 100644
index 0000000000..91f3503ac3
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/broadcast/doc_messagehandler_broadcasting_xul.xhtml
@@ -0,0 +1,3 @@
+<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+ <box id="box" style="background-color: red;">Test chrome broadcasting</box>
+</window>
diff --git a/remote/shared/messagehandler/test/browser/broadcast/head.js b/remote/shared/messagehandler/test/browser/broadcast/head.js
new file mode 100644
index 0000000000..7bbe96ae97
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/broadcast/head.js
@@ -0,0 +1,53 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* import-globals-from ../head.js */
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/remote/shared/messagehandler/test/browser/head.js",
+ this
+);
+
+/**
+ * Broadcast the provided method to WindowGlobal contexts on a MessageHandler
+ * network.
+ * Returns a promise which will resolve the result of the command broadcast.
+ *
+ * @param {String} module
+ * The name of the module implementing the command to broadcast.
+ * @param {String} command
+ * The name of the command to broadcast.
+ * @param {Object} params
+ * The parameters for the command.
+ * @param {ContextDescriptor} contextDescriptor
+ * The context descriptor to use for this broadcast
+ * @param {RootMessageHandler} rootMessageHandler
+ * The root of the MessageHandler network.
+ * @return {Promise.<Array>}
+ * Promise which resolves an array where each item is the result of the
+ * command handled by an individual context.
+ */
+function sendTestBroadcastCommand(
+ module,
+ command,
+ params,
+ contextDescriptor,
+ rootMessageHandler
+) {
+ const { WindowGlobalMessageHandler } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs"
+ );
+
+ info("Send a test broadcast command");
+ return rootMessageHandler.handleCommand({
+ moduleName: module,
+ commandName: command,
+ params,
+ destination: {
+ contextDescriptor,
+ type: WindowGlobalMessageHandler.type,
+ },
+ });
+}
diff --git a/remote/shared/messagehandler/test/browser/browser.ini b/remote/shared/messagehandler/test/browser/browser.ini
new file mode 100644
index 0000000000..00cd152370
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/browser.ini
@@ -0,0 +1,21 @@
+[DEFAULT]
+tags = remote
+subsuite = remote
+support-files =
+ head.js
+ resources/*
+prefs =
+ remote.messagehandler.modulecache.useBrowserTestRoot=true
+
+[browser_events_dispatcher.js]
+[browser_events_handler.js]
+[browser_events_module.js]
+[browser_frame_context_utils.js]
+[browser_handle_command_errors.js]
+[browser_handle_command_retry.js]
+[browser_handle_simple_command.js]
+[browser_registry.js]
+[browser_session_data.js]
+[browser_session_data_broadcast.js]
+[browser_session_data_browser_element.js]
+[browser_session_data_constructor_race.js]
diff --git a/remote/shared/messagehandler/test/browser/browser_events_dispatcher.js b/remote/shared/messagehandler/test/browser/browser_events_dispatcher.js
new file mode 100644
index 0000000000..4f7a3ea203
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/browser_events_dispatcher.js
@@ -0,0 +1,453 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { WindowGlobalMessageHandler } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs"
+);
+
+/**
+ * Check the basic behavior of on/off.
+ */
+add_task(async function test_add_remove_event_listener() {
+ const tab = await addTab("https://example.com/document-builder.sjs?html=tab");
+ const browsingContext = tab.linkedBrowser.browsingContext;
+ const contextDescriptor = {
+ type: ContextDescriptorType.TopBrowsingContext,
+ id: browsingContext.browserId,
+ };
+
+ const root = createRootMessageHandler("session-id-event");
+ const monitoringEvents = await setupEventMonitoring(root);
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(await isSubscribed(root, browsingContext), false);
+
+ info("Add an listener for eventemitter.testEvent");
+ const events = [];
+ const onEvent = (event, data) => events.push(data.text);
+ await root.eventsDispatcher.on(
+ "eventemitter.testEvent",
+ contextDescriptor,
+ onEvent
+ );
+ is(await isSubscribed(root, browsingContext), true);
+
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(events.length, 1);
+
+ info(
+ "Remove a listener for a callback not added before and check that the first one is still registered"
+ );
+ const anotherCallback = () => {};
+ await root.eventsDispatcher.off(
+ "eventemitter.testEvent",
+ contextDescriptor,
+ anotherCallback
+ );
+ is(await isSubscribed(root, browsingContext), true);
+
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(events.length, 2);
+
+ info("Remove the listener for eventemitter.testEvent");
+ await root.eventsDispatcher.off(
+ "eventemitter.testEvent",
+ contextDescriptor,
+ onEvent
+ );
+ is(await isSubscribed(root, browsingContext), false);
+
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(events.length, 2);
+
+ info("Add the listener for eventemitter.testEvent again");
+ await root.eventsDispatcher.on(
+ "eventemitter.testEvent",
+ contextDescriptor,
+ onEvent
+ );
+ is(await isSubscribed(root, browsingContext), true);
+
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(events.length, 3);
+
+ info("Remove the listener for eventemitter.testEvent");
+ await root.eventsDispatcher.off(
+ "eventemitter.testEvent",
+ contextDescriptor,
+ onEvent
+ );
+ is(await isSubscribed(root, browsingContext), false);
+
+ info("Remove the listener again to check the API will not throw");
+ await root.eventsDispatcher.off(
+ "eventemitter.testEvent",
+ contextDescriptor,
+ onEvent
+ );
+
+ root.destroy();
+ gBrowser.removeTab(tab);
+});
+
+/**
+ * Check that two callbacks can subscribe to the same event in the same context
+ * in parallel.
+ */
+add_task(async function test_two_callbacks() {
+ const tab = await addTab("https://example.com/document-builder.sjs?html=tab");
+ const browsingContext = tab.linkedBrowser.browsingContext;
+ const contextDescriptor = {
+ type: ContextDescriptorType.TopBrowsingContext,
+ id: browsingContext.browserId,
+ };
+
+ const root = createRootMessageHandler("session-id-event");
+ const monitoringEvents = await setupEventMonitoring(root);
+
+ info("Add an listener for eventemitter.testEvent");
+ const events = [];
+ const onEvent = (event, data) => events.push(data.text);
+ await root.eventsDispatcher.on(
+ "eventemitter.testEvent",
+ contextDescriptor,
+ onEvent
+ );
+
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(events.length, 1);
+
+ info("Add another listener for eventemitter.testEvent");
+ const otherevents = [];
+ const otherCallback = (event, data) => otherevents.push(data.text);
+ await root.eventsDispatcher.on(
+ "eventemitter.testEvent",
+ contextDescriptor,
+ otherCallback
+ );
+ is(await isSubscribed(root, browsingContext), true);
+
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(events.length, 2);
+ is(otherevents.length, 1);
+
+ info("Remove the other listener for eventemitter.testEvent");
+ await root.eventsDispatcher.off(
+ "eventemitter.testEvent",
+ contextDescriptor,
+ otherCallback
+ );
+ is(await isSubscribed(root, browsingContext), true);
+
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(events.length, 3);
+ is(otherevents.length, 1);
+
+ info("Remove the first listener for eventemitter.testEvent");
+ await root.eventsDispatcher.off(
+ "eventemitter.testEvent",
+ contextDescriptor,
+ onEvent
+ );
+ is(await isSubscribed(root, browsingContext), false);
+
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(events.length, 3);
+ is(otherevents.length, 1);
+
+ root.destroy();
+ gBrowser.removeTab(tab);
+});
+
+/**
+ * Check that two callbacks can subscribe to the same event in the two contexts.
+ */
+add_task(async function test_two_contexts() {
+ const tab1 = await addTab("https://example.com/document-builder.sjs?html=1");
+ const browsingContext1 = tab1.linkedBrowser.browsingContext;
+
+ const tab2 = await addTab("https://example.com/document-builder.sjs?html=2");
+ const browsingContext2 = tab2.linkedBrowser.browsingContext;
+
+ const contextDescriptor1 = {
+ type: ContextDescriptorType.TopBrowsingContext,
+ id: browsingContext1.browserId,
+ };
+ const contextDescriptor2 = {
+ type: ContextDescriptorType.TopBrowsingContext,
+ id: browsingContext2.browserId,
+ };
+
+ const root = createRootMessageHandler("session-id-event");
+
+ const monitoringEvents = await setupEventMonitoring(root);
+
+ const events1 = [];
+ const onEvent1 = (event, data) => events1.push(data.text);
+ await root.eventsDispatcher.on(
+ "eventemitter.testEvent",
+ contextDescriptor1,
+ onEvent1
+ );
+ is(await isSubscribed(root, browsingContext1), true);
+ is(await isSubscribed(root, browsingContext2), false);
+
+ const events2 = [];
+ const onEvent2 = (event, data) => events2.push(data.text);
+ await root.eventsDispatcher.on(
+ "eventemitter.testEvent",
+ contextDescriptor2,
+ onEvent2
+ );
+ is(await isSubscribed(root, browsingContext1), true);
+ is(await isSubscribed(root, browsingContext2), true);
+
+ await emitTestEvent(root, browsingContext1, monitoringEvents);
+ is(events1.length, 1);
+ is(events2.length, 0);
+
+ await emitTestEvent(root, browsingContext2, monitoringEvents);
+ is(events1.length, 1);
+ is(events2.length, 1);
+
+ await root.eventsDispatcher.off(
+ "eventemitter.testEvent",
+ contextDescriptor1,
+ onEvent1
+ );
+ is(await isSubscribed(root, browsingContext1), false);
+ is(await isSubscribed(root, browsingContext2), true);
+
+ // No event expected here since the module for browsingContext1 is no longer
+ // subscribed
+ await emitTestEvent(root, browsingContext1, monitoringEvents);
+ is(events1.length, 1);
+ is(events2.length, 1);
+
+ // Whereas the module for browsingContext2 is still subscribed
+ await emitTestEvent(root, browsingContext2, monitoringEvents);
+ is(events1.length, 1);
+ is(events2.length, 2);
+
+ await root.eventsDispatcher.off(
+ "eventemitter.testEvent",
+ contextDescriptor2,
+ onEvent2
+ );
+ is(await isSubscribed(root, browsingContext1), false);
+ is(await isSubscribed(root, browsingContext2), false);
+
+ await emitTestEvent(root, browsingContext1, monitoringEvents);
+ await emitTestEvent(root, browsingContext2, monitoringEvents);
+ is(events1.length, 1);
+ is(events2.length, 2);
+
+ root.destroy();
+ gBrowser.removeTab(tab2);
+ gBrowser.removeTab(tab1);
+});
+
+/**
+ * Check that adding and removing first listener for the specific context and then
+ * for the global context works as expected.
+ */
+add_task(
+ async function test_remove_context_event_listener_and_then_global_event_listener() {
+ const tab = await addTab(
+ "https://example.com/document-builder.sjs?html=tab"
+ );
+ const browsingContext = tab.linkedBrowser.browsingContext;
+ const contextDescriptor = {
+ type: ContextDescriptorType.TopBrowsingContext,
+ id: browsingContext.browserId,
+ };
+ const contextDescriptorAll = {
+ type: ContextDescriptorType.All,
+ };
+
+ const root = createRootMessageHandler("session-id-event");
+ const monitoringEvents = await setupEventMonitoring(root);
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(await isSubscribed(root, browsingContext), false);
+
+ info("Add an listener for eventemitter.testEvent");
+ const events = [];
+ const onEvent = (event, data) => events.push(data.text);
+ await root.eventsDispatcher.on(
+ "eventemitter.testEvent",
+ contextDescriptor,
+ onEvent
+ );
+ is(await isSubscribed(root, browsingContext), true);
+
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(events.length, 1);
+
+ info(
+ "Add another listener for eventemitter.testEvent, using global context"
+ );
+ const eventsAll = [];
+ const onEventAll = (event, data) => eventsAll.push(data.text);
+ await root.eventsDispatcher.on(
+ "eventemitter.testEvent",
+ contextDescriptorAll,
+ onEventAll
+ );
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(eventsAll.length, 1);
+ is(events.length, 2);
+
+ info("Remove the first listener for eventemitter.testEvent");
+ await root.eventsDispatcher.off(
+ "eventemitter.testEvent",
+ contextDescriptor,
+ onEvent
+ );
+
+ info("Check that we are still subscribed to eventemitter.testEvent");
+ is(await isSubscribed(root, browsingContext), true);
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(eventsAll.length, 2);
+ is(events.length, 2);
+
+ await root.eventsDispatcher.off(
+ "eventemitter.testEvent",
+ contextDescriptorAll,
+ onEventAll
+ );
+ is(await isSubscribed(root, browsingContext), false);
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(eventsAll.length, 2);
+ is(events.length, 2);
+
+ root.destroy();
+ gBrowser.removeTab(tab);
+ }
+);
+
+/**
+ * Check that adding and removing first listener for the global context and then
+ * for the specific context works as expected.
+ */
+add_task(
+ async function test_global_event_listener_and_then_remove_context_event_listener() {
+ const tab = await addTab(
+ "https://example.com/document-builder.sjs?html=tab"
+ );
+ const browsingContext = tab.linkedBrowser.browsingContext;
+ const contextDescriptor = {
+ type: ContextDescriptorType.TopBrowsingContext,
+ id: browsingContext.browserId,
+ };
+ const contextDescriptorAll = {
+ type: ContextDescriptorType.All,
+ };
+
+ const root = createRootMessageHandler("session-id-event");
+ const monitoringEvents = await setupEventMonitoring(root);
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(await isSubscribed(root, browsingContext), false);
+
+ info("Add an listener for eventemitter.testEvent");
+ const events = [];
+ const onEvent = (event, data) => events.push(data.text);
+ await root.eventsDispatcher.on(
+ "eventemitter.testEvent",
+ contextDescriptor,
+ onEvent
+ );
+ is(await isSubscribed(root, browsingContext), true);
+
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(events.length, 1);
+
+ info(
+ "Add another listener for eventemitter.testEvent, using global context"
+ );
+ const eventsAll = [];
+ const onEventAll = (event, data) => eventsAll.push(data.text);
+ await root.eventsDispatcher.on(
+ "eventemitter.testEvent",
+ contextDescriptorAll,
+ onEventAll
+ );
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(eventsAll.length, 1);
+ is(events.length, 2);
+
+ info("Remove the global listener for eventemitter.testEvent");
+ await root.eventsDispatcher.off(
+ "eventemitter.testEvent",
+ contextDescriptorAll,
+ onEventAll
+ );
+
+ info(
+ "Check that we are still subscribed to eventemitter.testEvent for the specific context"
+ );
+ is(await isSubscribed(root, browsingContext), true);
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(eventsAll.length, 1);
+ is(events.length, 3);
+
+ await root.eventsDispatcher.off(
+ "eventemitter.testEvent",
+ contextDescriptor,
+ onEvent
+ );
+
+ is(await isSubscribed(root, browsingContext), false);
+ await emitTestEvent(root, browsingContext, monitoringEvents);
+ is(eventsAll.length, 1);
+ is(events.length, 3);
+
+ root.destroy();
+ gBrowser.removeTab(tab);
+ }
+);
+
+async function setupEventMonitoring(root) {
+ const monitoringEvents = [];
+ const onMonitoringEvent = (event, data) => monitoringEvents.push(data.text);
+ root.on("eventemitter.monitoringEvent", onMonitoringEvent);
+
+ registerCleanupFunction(() =>
+ root.off("eventemitter.monitoringEvent", onMonitoringEvent)
+ );
+
+ return monitoringEvents;
+}
+
+async function emitTestEvent(root, browsingContext, monitoringEvents) {
+ const count = monitoringEvents.length;
+ info("Call eventemitter.emitTestEvent");
+ await root.handleCommand({
+ moduleName: "eventemitter",
+ commandName: "emitTestEvent",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContext.id,
+ },
+ });
+
+ // The monitoring event is always emitted, regardless of the status of the
+ // module. Wait for catching this event before resuming the assertions.
+ info("Wait for the monitoring event");
+ await BrowserTestUtils.waitForCondition(
+ () => monitoringEvents.length >= count + 1
+ );
+ is(monitoringEvents.length, count + 1);
+}
+
+function isSubscribed(root, browsingContext) {
+ info("Call eventemitter.isSubscribed");
+ return root.handleCommand({
+ moduleName: "eventemitter",
+ commandName: "isSubscribed",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContext.id,
+ },
+ });
+}
diff --git a/remote/shared/messagehandler/test/browser/browser_events_handler.js b/remote/shared/messagehandler/test/browser/browser_events_handler.js
new file mode 100644
index 0000000000..4898a5957b
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/browser_events_handler.js
@@ -0,0 +1,57 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Test that the window-global-handler-created event gets emitted for each
+ * individual frame's browsing context.
+ */
+add_task(async function test_windowGlobalHandlerCreated() {
+ const events = [];
+
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-event_with_frames"
+ );
+
+ info("Add a new session data item to get window global handlers created");
+ await rootMessageHandler.addSessionData({
+ moduleName: "command",
+ category: "browser_session_data_browser_element",
+ contextDescriptor: {
+ type: ContextDescriptorType.All,
+ },
+ values: [true],
+ });
+
+ const onEvent = (evtName, wrappedEvt) => {
+ if (wrappedEvt.name === "window-global-handler-created") {
+ console.info(`Received event for context ${wrappedEvt.data.contextId}`);
+ events.push(wrappedEvt.data);
+ }
+ };
+ rootMessageHandler.on("message-handler-event", onEvent);
+
+ info("Navigate the initial tab to the test URL");
+ const browser = gBrowser.selectedTab.linkedBrowser;
+ await loadURL(browser, createTestMarkupWithFrames());
+
+ const contexts = browser.browsingContext.getAllBrowsingContextsInSubtree();
+ is(contexts.length, 4, "Test tab has 3 children contexts (4 in total)");
+
+ // Wait for all the events
+ await TestUtils.waitForCondition(() => events.length >= 4);
+
+ for (const context of contexts) {
+ const contextEvents = events.filter(evt => {
+ return (
+ evt.contextId === context.id &&
+ evt.innerWindowId === context.currentWindowGlobal.innerWindowId
+ );
+ });
+ is(contextEvents.length, 1, `Found event for context ${context.id}`);
+ }
+
+ rootMessageHandler.off("message-handler-event", onEvent);
+ rootMessageHandler.destroy();
+});
diff --git a/remote/shared/messagehandler/test/browser/browser_events_module.js b/remote/shared/messagehandler/test/browser/browser_events_module.js
new file mode 100644
index 0000000000..4032559e9a
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/browser_events_module.js
@@ -0,0 +1,283 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { RootMessageHandlerRegistry } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/RootMessageHandlerRegistry.sys.mjs"
+);
+const { RootMessageHandler } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs"
+);
+const { WindowGlobalMessageHandler } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs"
+);
+
+/**
+ * Emit an event from a WindowGlobal module triggered by a specific command.
+ * Check that the event is emitted on the RootMessageHandler as well as on
+ * the parent process MessageHandlerRegistry.
+ */
+add_task(async function test_event() {
+ const tab = BrowserTestUtils.addTab(
+ gBrowser,
+ "https://example.com/document-builder.sjs?html=tab"
+ );
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ const browsingContext = tab.linkedBrowser.browsingContext;
+
+ const rootMessageHandler = createRootMessageHandler("session-id-event");
+
+ // Events are emitted both as generic message-handler-event events as well
+ // as under their own name. We expect to receive the event for both.
+ const onHandlerEvent = rootMessageHandler.once("message-handler-event");
+ const onNamedEvent = rootMessageHandler.once("event-from-window-global");
+ // MessageHandlerRegistry should forward all the message-handler-events.
+ const onRegistryEvent = RootMessageHandlerRegistry.once(
+ "message-handler-registry-event"
+ );
+
+ callTestEmitEvent(rootMessageHandler, browsingContext.id);
+
+ const messageHandlerEvent = await onHandlerEvent;
+ is(
+ messageHandlerEvent.name,
+ "event-from-window-global",
+ "Received event on the ROOT MessageHandler"
+ );
+ is(
+ messageHandlerEvent.data.text,
+ `event from ${browsingContext.id}`,
+ "Received the expected payload"
+ );
+
+ const namedEvent = await onNamedEvent;
+ is(
+ namedEvent.text,
+ `event from ${browsingContext.id}`,
+ "Received the expected payload"
+ );
+
+ const registryEvent = await onRegistryEvent;
+ is(
+ registryEvent,
+ messageHandlerEvent,
+ "The event forwarded by the MessageHandlerRegistry is identical to the MessageHandler event"
+ );
+
+ rootMessageHandler.destroy();
+ gBrowser.removeTab(tab);
+});
+
+/**
+ * Emit an event from a Root module triggered by a specific command.
+ * Check that the event is emitted on the RootMessageHandler.
+ */
+add_task(async function test_root_event() {
+ const rootMessageHandler = createRootMessageHandler("session-id-root_event");
+
+ // events are emitted both as generic message-handler-event events as
+ // well as under their own name. We expect to receive the event for both.
+ const onHandlerEvent = rootMessageHandler.once("message-handler-event");
+ const onNamedEvent = rootMessageHandler.once("event-from-root");
+
+ rootMessageHandler.handleCommand({
+ moduleName: "event",
+ commandName: "testEmitRootEvent",
+ destination: {
+ type: RootMessageHandler.type,
+ },
+ });
+
+ const { name, data } = await onHandlerEvent;
+ is(name, "event-from-root", "Received event on the ROOT MessageHandler");
+ is(data.text, "event from root", "Received the expected payload");
+
+ const namedEvent = await onNamedEvent;
+ is(namedEvent.text, "event from root", "Received the expected payload");
+
+ rootMessageHandler.destroy();
+});
+
+/**
+ * Emit an event from a windowglobal-in-root module triggered by a specific command.
+ * Check that the event is emitted on the RootMessageHandler.
+ */
+add_task(async function test_windowglobal_in_root_event() {
+ const tab = BrowserTestUtils.addTab(
+ gBrowser,
+ "https://example.com/document-builder.sjs?html=tab"
+ );
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ const browsingContext = tab.linkedBrowser.browsingContext;
+
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-windowglobal_in_root_event"
+ );
+
+ // events are emitted both as generic message-handler-event events as
+ // well as under their own name. We expect to receive the event for both.
+ const onHandlerEvent = rootMessageHandler.once("message-handler-event");
+ const onNamedEvent = rootMessageHandler.once(
+ "event-from-window-global-in-root"
+ );
+ rootMessageHandler.handleCommand({
+ moduleName: "event",
+ commandName: "testEmitWindowGlobalInRootEvent",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContext.id,
+ },
+ });
+
+ const { name, data } = await onHandlerEvent;
+ is(
+ name,
+ "event-from-window-global-in-root",
+ "Received event on the ROOT MessageHandler"
+ );
+ is(
+ data.text,
+ `windowglobal-in-root event for ${browsingContext.id}`,
+ "Received the expected payload"
+ );
+
+ const namedEvent = await onNamedEvent;
+ is(
+ namedEvent.text,
+ `windowglobal-in-root event for ${browsingContext.id}`,
+ "Received the expected payload"
+ );
+
+ rootMessageHandler.destroy();
+ gBrowser.removeTab(tab);
+});
+
+/**
+ * Emit an event from a windowglobal module, but from 2 different sessions.
+ * Check that the event is emitted by the corresponding RootMessageHandler as
+ * well as by the parent process MessageHandlerRegistry.
+ */
+add_task(async function test_event_multisession() {
+ const tab = BrowserTestUtils.addTab(
+ gBrowser,
+ "https://example.com/document-builder.sjs?html=tab"
+ );
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ const browsingContextId = tab.linkedBrowser.browsingContext.id;
+
+ const root1 = createRootMessageHandler("session-id-event_multisession-1");
+ let root1Events = 0;
+ const onRoot1Event = function(evtName, wrappedEvt) {
+ if (wrappedEvt.name === "event-from-window-global") {
+ root1Events++;
+ }
+ };
+ root1.on("message-handler-event", onRoot1Event);
+
+ const root2 = createRootMessageHandler("session-id-event_multisession-2");
+ let root2Events = 0;
+ const onRoot2Event = function(evtName, wrappedEvt) {
+ if (wrappedEvt.name === "event-from-window-global") {
+ root2Events++;
+ }
+ };
+ root2.on("message-handler-event", onRoot2Event);
+
+ let registryEvents = 0;
+ const onRegistryEvent = function(evtName, wrappedEvt) {
+ if (wrappedEvt.name === "event-from-window-global") {
+ registryEvents++;
+ }
+ };
+ RootMessageHandlerRegistry.on(
+ "message-handler-registry-event",
+ onRegistryEvent
+ );
+
+ callTestEmitEvent(root1, browsingContextId);
+ callTestEmitEvent(root2, browsingContextId);
+
+ info("Wait for root1 event to be received");
+ await TestUtils.waitForCondition(() => root1Events === 1);
+ info("Wait for root2 event to be received");
+ await TestUtils.waitForCondition(() => root2Events === 1);
+
+ await TestUtils.waitForTick();
+ is(root1Events, 1, "Session 1 only received 1 event");
+ is(root2Events, 1, "Session 2 only received 1 event");
+ is(
+ registryEvents,
+ 2,
+ "MessageHandlerRegistry forwarded events from both sessions"
+ );
+
+ root1.off("message-handler-event", onRoot1Event);
+ root2.off("message-handler-event", onRoot2Event);
+ RootMessageHandlerRegistry.off(
+ "message-handler-registry-event",
+ onRegistryEvent
+ );
+ root1.destroy();
+ root2.destroy();
+ gBrowser.removeTab(tab);
+});
+
+/**
+ * Test that events can be emitted from individual frame contexts and that
+ * events going through a shared content process MessageHandlerRegistry are not
+ * duplicated.
+ */
+add_task(async function test_event_with_frames() {
+ info("Navigate the initial tab to the test URL");
+ const tab = gBrowser.selectedTab;
+ await loadURL(tab.linkedBrowser, createTestMarkupWithFrames());
+
+ const contexts = tab.linkedBrowser.browsingContext.getAllBrowsingContextsInSubtree();
+ is(contexts.length, 4, "Test tab has 3 children contexts (4 in total)");
+
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-event_with_frames"
+ );
+
+ const rootEvents = [];
+ const onRootEvent = function(evtName, wrappedEvt) {
+ if (wrappedEvt.name === "event-from-window-global") {
+ rootEvents.push(wrappedEvt.data.text);
+ }
+ };
+ rootMessageHandler.on("message-handler-event", onRootEvent);
+
+ const namedEvents = [];
+ const onNamedEvent = (name, event) => namedEvents.push(event.text);
+ rootMessageHandler.on("event-from-window-global", onNamedEvent);
+
+ for (const context of contexts) {
+ callTestEmitEvent(rootMessageHandler, context.id);
+ info("Wait for root event to be received in both event arrays");
+ await TestUtils.waitForCondition(() =>
+ [namedEvents, rootEvents].every(events =>
+ events.includes(`event from ${context.id}`)
+ )
+ );
+ }
+
+ info("Wait for a bit and check that we did not receive duplicated events");
+ await TestUtils.waitForTick();
+ is(rootEvents.length, 4, "Only received 4 events");
+
+ rootMessageHandler.off("message-handler-event", onRootEvent);
+ rootMessageHandler.off("event-from-window-global", onNamedEvent);
+ rootMessageHandler.destroy();
+});
+
+function callTestEmitEvent(rootMessageHandler, browsingContextId) {
+ rootMessageHandler.handleCommand({
+ moduleName: "event",
+ commandName: "testEmitEvent",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContextId,
+ },
+ });
+}
diff --git a/remote/shared/messagehandler/test/browser/browser_frame_context_utils.js b/remote/shared/messagehandler/test/browser/browser_frame_context_utils.js
new file mode 100644
index 0000000000..eec9ce048f
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/browser_frame_context_utils.js
@@ -0,0 +1,95 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { isBrowsingContextCompatible } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/transports/FrameContextUtils.sys.mjs"
+);
+const TEST_COM_PAGE = "https://example.com/document-builder.sjs?html=com";
+const TEST_NET_PAGE = "https://example.net/document-builder.sjs?html=net";
+
+// Test helpers from FrameContextUtils in various processes.
+add_task(async function() {
+ const tab1 = BrowserTestUtils.addTab(gBrowser, TEST_COM_PAGE);
+ const contentBrowser1 = tab1.linkedBrowser;
+ await BrowserTestUtils.browserLoaded(contentBrowser1);
+ const browserId1 = contentBrowser1.browsingContext.browserId;
+
+ const tab2 = BrowserTestUtils.addTab(gBrowser, TEST_NET_PAGE);
+ const contentBrowser2 = tab2.linkedBrowser;
+ await BrowserTestUtils.browserLoaded(contentBrowser2);
+ const browserId2 = contentBrowser2.browsingContext.browserId;
+
+ const { extension, sidebarBrowser } = await installSidebarExtension();
+
+ const tab3 = BrowserTestUtils.addTab(
+ gBrowser,
+ `moz-extension://${extension.uuid}/tab.html`
+ );
+ const { bcId } = await extension.awaitMessage("tab-loaded");
+ const tabExtensionBrowser = BrowsingContext.get(bcId).top.embedderElement;
+
+ const parentBrowser1 = createParentBrowserElement(tab1, "content");
+ const parentBrowser2 = createParentBrowserElement(tab1, "chrome");
+
+ info("Check browsing context compatibility for content browser 1");
+ await checkBrowsingContextCompatible(contentBrowser1, undefined, true);
+ await checkBrowsingContextCompatible(contentBrowser1, browserId1, true);
+ await checkBrowsingContextCompatible(contentBrowser1, browserId2, false);
+
+ info("Check browsing context compatibility for content browser 2");
+ await checkBrowsingContextCompatible(contentBrowser2, undefined, true);
+ await checkBrowsingContextCompatible(contentBrowser2, browserId1, false);
+ await checkBrowsingContextCompatible(contentBrowser2, browserId2, true);
+
+ info("Check browsing context compatibility for parent browser 1");
+ await checkBrowsingContextCompatible(parentBrowser1, undefined, false);
+ await checkBrowsingContextCompatible(parentBrowser1, browserId1, false);
+ await checkBrowsingContextCompatible(parentBrowser1, browserId2, false);
+
+ info("Check browsing context compatibility for parent browser 2");
+ await checkBrowsingContextCompatible(parentBrowser2, undefined, false);
+ await checkBrowsingContextCompatible(parentBrowser2, browserId1, false);
+ await checkBrowsingContextCompatible(parentBrowser2, browserId2, false);
+
+ info("Check browsing context compatibility for extension");
+ await checkBrowsingContextCompatible(sidebarBrowser, undefined, false);
+ await checkBrowsingContextCompatible(sidebarBrowser, browserId1, false);
+ await checkBrowsingContextCompatible(sidebarBrowser, browserId2, false);
+
+ info("Check browsing context compatibility for extension viewed in a tab");
+ await checkBrowsingContextCompatible(tabExtensionBrowser, undefined, false);
+ await checkBrowsingContextCompatible(tabExtensionBrowser, browserId1, false);
+ await checkBrowsingContextCompatible(tabExtensionBrowser, browserId2, false);
+
+ gBrowser.removeTab(tab1);
+ gBrowser.removeTab(tab2);
+ gBrowser.removeTab(tab3);
+ await extension.unload();
+});
+
+async function checkBrowsingContextCompatible(browser, browserId, expected) {
+ const options = { browserId };
+ info("Check browsing context compatibility from the parent process");
+ is(isBrowsingContextCompatible(browser.browsingContext, options), expected);
+
+ info(
+ "Check browsing context compatibility from the browsing context's process"
+ );
+ await SpecialPowers.spawn(
+ browser,
+ [browserId, expected],
+ (_browserId, _expected) => {
+ const FrameContextUtils = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/transports/FrameContextUtils.sys.mjs"
+ );
+ is(
+ FrameContextUtils.isBrowsingContextCompatible(content.browsingContext, {
+ browserId: _browserId,
+ }),
+ _expected
+ );
+ }
+ );
+}
diff --git a/remote/shared/messagehandler/test/browser/browser_handle_command_errors.js b/remote/shared/messagehandler/test/browser/browser_handle_command_errors.js
new file mode 100644
index 0000000000..0a46255d89
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/browser_handle_command_errors.js
@@ -0,0 +1,221 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { RootMessageHandler } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs"
+);
+const { WindowGlobalMessageHandler } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs"
+);
+
+// Check that errors from WindowGlobal modules can be caught by the consumer
+// of the RootMessageHandler.
+add_task(async function test_module_error() {
+ const browsingContextId = gBrowser.selectedBrowser.browsingContext.id;
+
+ const rootMessageHandler = createRootMessageHandler("session-id-error");
+
+ info("Call a module method which will throw");
+ try {
+ await rootMessageHandler.handleCommand({
+ moduleName: "commandwindowglobalonly",
+ commandName: "testError",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContextId,
+ },
+ });
+ ok(false, "Error from window global module was not caught");
+ } catch (e) {
+ ok(true, "Error from window global module caught");
+ }
+
+ rootMessageHandler.destroy();
+});
+
+// Check that sending commands to incorrect destinations creates an error which
+// can be caught by the consumer of the RootMessageHandler.
+add_task(async function test_destination_error() {
+ const rootMessageHandler = createRootMessageHandler("session-id-error");
+
+ const fakeBrowsingContextId = -1;
+ ok(
+ !BrowsingContext.get(fakeBrowsingContextId),
+ "No browsing context matches fakeBrowsingContextId"
+ );
+
+ info("Call a valid module method, but on a non-existent browsing context id");
+ Assert.throws(
+ () =>
+ rootMessageHandler.handleCommand({
+ moduleName: "commandwindowglobalonly",
+ commandName: "testOnlyInWindowGlobal",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: fakeBrowsingContextId,
+ },
+ }),
+ err => err.message == `Unable to find a BrowsingContext for id -1`
+ );
+
+ rootMessageHandler.destroy();
+});
+
+add_task(async function test_invalid_module_error() {
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-missing_module"
+ );
+
+ info("Attempt to call a Root module which has a syntax error");
+ Assert.throws(
+ () =>
+ rootMessageHandler.handleCommand({
+ moduleName: "invalid",
+ commandName: "someMethod",
+ destination: {
+ type: RootMessageHandler.type,
+ },
+ }),
+ err =>
+ err.name === "SyntaxError" &&
+ err.message == "expected expression, got ';'"
+ );
+
+ rootMessageHandler.destroy();
+});
+
+add_task(async function test_missing_root_module_error() {
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-missing_module"
+ );
+
+ info("Attempt to call a Root module which doesn't exist");
+ Assert.throws(
+ () =>
+ rootMessageHandler.handleCommand({
+ moduleName: "missingmodule",
+ commandName: "someMethod",
+ destination: {
+ type: RootMessageHandler.type,
+ },
+ }),
+ err =>
+ err.name == "UnsupportedCommandError" &&
+ err.message ==
+ `missingmodule.someMethod not supported for destination ROOT`
+ );
+
+ rootMessageHandler.destroy();
+});
+
+add_task(async function test_missing_windowglobal_module_error() {
+ const browsingContextId = gBrowser.selectedBrowser.browsingContext.id;
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-missing_windowglobal_module"
+ );
+
+ info("Attempt to call a WindowGlobal module which doesn't exist");
+ Assert.throws(
+ () =>
+ rootMessageHandler.handleCommand({
+ moduleName: "missingmodule",
+ commandName: "someMethod",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContextId,
+ },
+ }),
+ err =>
+ err.name == "UnsupportedCommandError" &&
+ err.message ==
+ `missingmodule.someMethod not supported for destination WINDOW_GLOBAL`
+ );
+
+ rootMessageHandler.destroy();
+});
+
+add_task(async function test_missing_root_method_error() {
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-missing_root_method"
+ );
+
+ info("Attempt to call an invalid method on a Root module");
+ Assert.throws(
+ () =>
+ rootMessageHandler.handleCommand({
+ moduleName: "command",
+ commandName: "wrongMethod",
+ destination: {
+ type: RootMessageHandler.type,
+ },
+ }),
+ err =>
+ err.name == "UnsupportedCommandError" &&
+ err.message == `command.wrongMethod not supported for destination ROOT`
+ );
+
+ rootMessageHandler.destroy();
+});
+
+add_task(async function test_missing_windowglobal_method_error() {
+ const browsingContextId = gBrowser.selectedBrowser.browsingContext.id;
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-missing_windowglobal_method"
+ );
+
+ info("Attempt to call an invalid method on a WindowGlobal module");
+ Assert.throws(
+ () =>
+ rootMessageHandler.handleCommand({
+ moduleName: "commandwindowglobalonly",
+ commandName: "wrongMethod",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContextId,
+ },
+ }),
+ err =>
+ err.name == "UnsupportedCommandError" &&
+ err.message ==
+ `commandwindowglobalonly.wrongMethod not supported for destination WINDOW_GLOBAL`
+ );
+
+ rootMessageHandler.destroy();
+});
+
+/**
+ * This test checks that even if a command is rerouted to another command after
+ * the RootMessageHandler, we still check the new command and log a useful
+ * error message.
+ *
+ * This illustrates why it is important to perform the command check at each
+ * layer of the MessageHandler network.
+ */
+add_task(async function test_missing_intermediary_method_error() {
+ const browsingContextId = gBrowser.selectedBrowser.browsingContext.id;
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-missing_intermediary_method"
+ );
+
+ info(
+ "Call a (valid) command that relies on another (missing) command on a WindowGlobal module"
+ );
+ await Assert.rejects(
+ rootMessageHandler.handleCommand({
+ moduleName: "commandwindowglobalonly",
+ commandName: "testMissingIntermediaryMethod",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContextId,
+ },
+ }),
+ err =>
+ err.name == "UnsupportedCommandError" &&
+ err.message ==
+ `commandwindowglobalonly.missingMethod not supported for destination WINDOW_GLOBAL`
+ );
+
+ rootMessageHandler.destroy();
+});
diff --git a/remote/shared/messagehandler/test/browser/browser_handle_command_retry.js b/remote/shared/messagehandler/test/browser/browser_handle_command_retry.js
new file mode 100644
index 0000000000..1d26ee01ee
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/browser_handle_command_retry.js
@@ -0,0 +1,243 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { WindowGlobalMessageHandler } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs"
+);
+
+// We are forcing the actors to shutdown while queries are unresolved.
+const { PromiseTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PromiseTestUtils.sys.mjs"
+);
+PromiseTestUtils.allowMatchingRejectionsGlobally(
+ /Actor 'MessageHandlerFrame' destroyed before query 'MessageHandlerFrameParent:sendCommand' was resolved/
+);
+
+// The tests in this file assert the retry behavior for MessageHandler commands.
+// We call "blocked" commands from resources/modules/windowglobal/retry.jsm and
+// then trigger reload and navigations to simulate AbortErrors and force the
+// MessageHandler to retry the commands, when possible.
+
+// Test that without retry behavior, a pending command rejects when the
+// underlying JSWindowActor pair is destroyed.
+add_task(async function test_no_retry() {
+ const tab = BrowserTestUtils.addTab(
+ gBrowser,
+ "https://example.com/document-builder.sjs?html=tab"
+ );
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ const browsingContext = tab.linkedBrowser.browsingContext;
+
+ const rootMessageHandler = createRootMessageHandler("session-id-no-retry");
+
+ try {
+ info("Call a module method which will throw");
+ const onBlockedOneTime = rootMessageHandler.handleCommand({
+ moduleName: "retry",
+ commandName: "blockedOneTime",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContext.id,
+ },
+ });
+
+ // Reloading the tab will reject the pending query with an AbortError.
+ await BrowserTestUtils.reloadTab(tab);
+
+ try {
+ await onBlockedOneTime;
+ ok("false", "onBlockedOneTime should not have resolved");
+ } catch (e) {
+ is(
+ e.name,
+ "AbortError",
+ "Caught the expected abort error when reloading"
+ );
+ }
+ } finally {
+ await cleanup(rootMessageHandler, tab);
+ }
+});
+
+// Test various commands, which all need a different number of "retries" to
+// succeed. Check that they only resolve when the expected number of "retries"
+// was reached. For commands which require more "retries" than we allow, check
+// that we still fail with an AbortError once all the attempts are consumed.
+add_task(async function test_retry() {
+ const tab = BrowserTestUtils.addTab(
+ gBrowser,
+ "https://example.com/document-builder.sjs?html=tab"
+ );
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ const browsingContext = tab.linkedBrowser.browsingContext;
+
+ const rootMessageHandler = createRootMessageHandler("session-id-retry");
+
+ try {
+ // This command will return if called twice.
+ const onBlockedOneTime = rootMessageHandler.handleCommand({
+ moduleName: "retry",
+ commandName: "blockedOneTime",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContext.id,
+ },
+ params: {
+ foo: "bar",
+ },
+ retryOnAbort: true,
+ });
+
+ // This command will return if called three times.
+ const onBlockedTenTimes = rootMessageHandler.handleCommand({
+ moduleName: "retry",
+ commandName: "blockedTenTimes",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContext.id,
+ },
+ params: {
+ foo: "baz",
+ },
+ retryOnAbort: true,
+ });
+
+ // This command will return if called twelve times, which is greater than the
+ // maximum amount of retries allowed.
+ const onBlockedElevenTimes = rootMessageHandler.handleCommand({
+ moduleName: "retry",
+ commandName: "blockedElevenTimes",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContext.id,
+ },
+ retryOnAbort: true,
+ });
+
+ info("Reload one time");
+ await BrowserTestUtils.reloadTab(tab);
+
+ info("blockedOneTime should resolve on the first retry");
+ let { callsToCommand, foo } = await onBlockedOneTime;
+ is(
+ callsToCommand,
+ 2,
+ "The command was called twice (initial call + 1 retry)"
+ );
+ is(foo, "bar", "The parameter was sent when the command was retried");
+
+ // We already reloaded 1 time. Reload 9 more times to unblock blockedTenTimes.
+ for (let i = 2; i < 11; i++) {
+ info("blockedTenTimes/blockedElevenTimes should not have resolved yet");
+ ok(!(await hasPromiseResolved(onBlockedTenTimes)));
+ ok(!(await hasPromiseResolved(onBlockedElevenTimes)));
+
+ info(`Reload the tab (time: ${i})`);
+ await BrowserTestUtils.reloadTab(tab);
+ }
+
+ info("blockedTenTimes should resolve on the 10th reload");
+ ({ callsToCommand, foo } = await onBlockedTenTimes);
+ is(
+ callsToCommand,
+ 11,
+ "The command was called 11 times (initial call + 10 retry)"
+ );
+ is(foo, "baz", "The parameter was sent when the command was retried");
+
+ info("Reload one more time");
+ await BrowserTestUtils.reloadTab(tab);
+
+ try {
+ info(
+ "The call to blockedElevenTimes now exceeds the maximum attempts allowed"
+ );
+ await onBlockedElevenTimes;
+ ok("false", "blockedElevenTimes should not have resolved");
+ } catch (e) {
+ is(
+ e.name,
+ "AbortError",
+ "Caught the expected abort error when reloading"
+ );
+ }
+ } finally {
+ await cleanup(rootMessageHandler, tab);
+ }
+});
+
+// Test cross-group navigations to check that the retry mechanism will
+// transparently switch to the new Browsing Context created by the cross-group
+// navigation.
+add_task(async function test_retry_cross_group() {
+ const tab = BrowserTestUtils.addTab(
+ gBrowser,
+ "https://example.com/document-builder.sjs?html=COM" +
+ // Attach an unload listener to prevent the page from going into bfcache,
+ // so that pending queries will be rejected with an AbortError.
+ "<script type='text/javascript'>window.onunload = function() {};</script>"
+ );
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ const browsingContext = tab.linkedBrowser.browsingContext;
+
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-retry-cross-group"
+ );
+
+ try {
+ // This command hangs and only returns if the current domain is example.net.
+ // We send the command while on example.com, perform a series of reload and
+ // navigations, and the retry mechanism should allow onBlockedOnNetDomain to
+ // resolve.
+ const onBlockedOnNetDomain = rootMessageHandler.handleCommand({
+ moduleName: "retry",
+ commandName: "blockedOnNetDomain",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContext.id,
+ },
+ params: {
+ foo: "bar",
+ },
+ retryOnAbort: true,
+ });
+
+ info("Reload one time");
+ await BrowserTestUtils.reloadTab(tab);
+
+ info("blockedOnNetDomain should not have resolved yet");
+ ok(!(await hasPromiseResolved(onBlockedOnNetDomain)));
+
+ info(
+ "Navigate to example.net with COOP headers to destroy browsing context"
+ );
+ await loadURL(
+ tab.linkedBrowser,
+ "https://example.net/document-builder.sjs?headers=Cross-Origin-Opener-Policy:same-origin&html=NET"
+ );
+
+ info("blockedOnNetDomain should resolve now");
+ let { foo } = await onBlockedOnNetDomain;
+ is(foo, "bar", "The parameter was sent when the command was retried");
+ } finally {
+ await cleanup(rootMessageHandler, tab);
+ }
+});
+
+async function cleanup(rootMessageHandler, tab) {
+ const browsingContext = tab.linkedBrowser.browsingContext;
+ // Cleanup global JSM state in the test module.
+ await rootMessageHandler.handleCommand({
+ moduleName: "retry",
+ commandName: "cleanup",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContext.id,
+ },
+ });
+
+ rootMessageHandler.destroy();
+ gBrowser.removeTab(tab);
+}
diff --git a/remote/shared/messagehandler/test/browser/browser_handle_simple_command.js b/remote/shared/messagehandler/test/browser/browser_handle_simple_command.js
new file mode 100644
index 0000000000..a5024e5cce
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/browser_handle_simple_command.js
@@ -0,0 +1,206 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { RootMessageHandler } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs"
+);
+const { WindowGlobalMessageHandler } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs"
+);
+
+// Test calling methods only implemented in the root version of a module.
+add_task(async function test_rootModule_command() {
+ const rootMessageHandler = createRootMessageHandler("session-id-rootModule");
+ const rootValue = await rootMessageHandler.handleCommand({
+ moduleName: "command",
+ commandName: "testRootModule",
+ destination: {
+ type: RootMessageHandler.type,
+ },
+ });
+
+ is(
+ rootValue,
+ "root-value",
+ "Retrieved the expected value from testRootModule"
+ );
+
+ rootMessageHandler.destroy();
+});
+
+// Test calling methods only implemented in the windowglobal-in-root version of
+// a module.
+add_task(async function test_windowglobalInRootModule_command() {
+ const browsingContextId = gBrowser.selectedBrowser.browsingContext.id;
+
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-windowglobalInRootModule"
+ );
+ const interceptedValue = await rootMessageHandler.handleCommand({
+ moduleName: "command",
+ commandName: "testInterceptModule",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContextId,
+ },
+ });
+
+ is(
+ interceptedValue,
+ "intercepted-value",
+ "Retrieved the expected value from testInterceptModule"
+ );
+
+ rootMessageHandler.destroy();
+});
+
+// Test calling methods only implemented in the windowglobal version of a
+// module.
+add_task(async function test_windowglobalModule_command() {
+ const browsingContextId = gBrowser.selectedBrowser.browsingContext.id;
+
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-windowglobalModule"
+ );
+ const windowGlobalValue = await rootMessageHandler.handleCommand({
+ moduleName: "command",
+ commandName: "testWindowGlobalModule",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContextId,
+ },
+ });
+
+ is(
+ windowGlobalValue,
+ "windowglobal-value",
+ "Retrieved the expected value from testWindowGlobalModule"
+ );
+
+ rootMessageHandler.destroy();
+});
+
+// Test calling a method on a module which is only available in the "windowglobal"
+// folder. This will check that the MessageHandler/ModuleCache correctly moves
+// on to the next layer when no implementation can be found in the root layer.
+add_task(async function test_windowglobalOnlyModule_command() {
+ const browsingContextId = gBrowser.selectedBrowser.browsingContext.id;
+
+ const rootMessageHandler = createRootMessageHandler(
+ "session-id-windowglobalOnlyModule"
+ );
+ const windowGlobalOnlyValue = await rootMessageHandler.handleCommand({
+ moduleName: "commandwindowglobalonly",
+ commandName: "testOnlyInWindowGlobal",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContextId,
+ },
+ });
+
+ is(
+ windowGlobalOnlyValue,
+ "only-in-windowglobal",
+ "Retrieved the expected value from testOnlyInWindowGlobal"
+ );
+
+ rootMessageHandler.destroy();
+});
+
+// Try to create 2 sessions which will both set values in individual modules
+// via a command `testSetValue`, and then retrieve the values via another
+// command `testGetValue`.
+// This will ensure that different sessions use different module instances.
+add_task(async function test_multisession() {
+ const browsingContextId = gBrowser.selectedBrowser.browsingContext.id;
+
+ const rootMessageHandler1 = createRootMessageHandler(
+ "session-id-multisession-1"
+ );
+ const rootMessageHandler2 = createRootMessageHandler(
+ "session-id-multisession-2"
+ );
+
+ info("Set value for session 1");
+ await rootMessageHandler1.handleCommand({
+ moduleName: "command",
+ commandName: "testSetValue",
+ params: { value: "session1-value" },
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContextId,
+ },
+ });
+
+ info("Set value for session 2");
+ await rootMessageHandler2.handleCommand({
+ moduleName: "command",
+ commandName: "testSetValue",
+ params: { value: "session2-value" },
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContextId,
+ },
+ });
+
+ const session1Value = await rootMessageHandler1.handleCommand({
+ moduleName: "command",
+ commandName: "testGetValue",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContextId,
+ },
+ });
+
+ is(
+ session1Value,
+ "session1-value",
+ "Retrieved the expected value for session 1"
+ );
+
+ const session2Value = await rootMessageHandler2.handleCommand({
+ moduleName: "command",
+ commandName: "testGetValue",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContextId,
+ },
+ });
+
+ is(
+ session2Value,
+ "session2-value",
+ "Retrieved the expected value for session 2"
+ );
+
+ rootMessageHandler1.destroy();
+ rootMessageHandler2.destroy();
+});
+
+// Test calling a method from the windowglobal-in-root module which will
+// internally forward to the windowglobal module and will return a composite
+// result built both in parent and content process.
+add_task(async function test_forwarding_command() {
+ const browsingContextId = gBrowser.selectedBrowser.browsingContext.id;
+
+ const rootMessageHandler = createRootMessageHandler("session-id-forwarding");
+ const interceptAndForwardValue = await rootMessageHandler.handleCommand({
+ moduleName: "command",
+ commandName: "testInterceptAndForwardModule",
+ params: { id: "value" },
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContextId,
+ },
+ });
+
+ is(
+ interceptAndForwardValue,
+ "intercepted-and-forward+forward-to-windowglobal-value",
+ "Retrieved the expected value from testInterceptAndForwardModule"
+ );
+
+ rootMessageHandler.destroy();
+});
diff --git a/remote/shared/messagehandler/test/browser/browser_registry.js b/remote/shared/messagehandler/test/browser/browser_registry.js
new file mode 100644
index 0000000000..271597526e
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/browser_registry.js
@@ -0,0 +1,38 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { MessageHandlerRegistry } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/MessageHandlerRegistry.sys.mjs"
+);
+const { RootMessageHandler } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs"
+);
+
+add_task(async function test_messageHandlerRegistry_API() {
+ const sessionId = 1;
+ const type = RootMessageHandler.type;
+
+ const rootMessageHandlerRegistry = new MessageHandlerRegistry(type);
+
+ const rootMessageHandler = rootMessageHandlerRegistry.getOrCreateMessageHandler(
+ sessionId
+ );
+ ok(rootMessageHandler, "Valid ROOT MessageHandler created");
+
+ const contextId = rootMessageHandler.contextId;
+ ok(contextId, "ROOT MessageHandler has a valid contextId");
+
+ is(
+ rootMessageHandler,
+ rootMessageHandlerRegistry.getExistingMessageHandler(sessionId),
+ "ROOT MessageHandler can be retrieved from the registry"
+ );
+
+ rootMessageHandler.destroy();
+ ok(
+ !rootMessageHandlerRegistry.getExistingMessageHandler(sessionId),
+ "Destroyed ROOT MessageHandler is no longer returned by the Registry"
+ );
+});
diff --git a/remote/shared/messagehandler/test/browser/browser_session_data.js b/remote/shared/messagehandler/test/browser/browser_session_data.js
new file mode 100644
index 0000000000..f5ba9d585c
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/browser_session_data.js
@@ -0,0 +1,272 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { MessageHandlerRegistry } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/MessageHandlerRegistry.sys.mjs"
+);
+const { RootMessageHandler } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs"
+);
+const { SessionData } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/sessiondata/SessionData.sys.mjs"
+);
+
+const TEST_PAGE = "http://example.com/document-builder.sjs?html=tab";
+
+add_task(async function test_sessionData() {
+ info("Navigate the initial tab to the test URL");
+ const tab1 = gBrowser.selectedTab;
+ await loadURL(tab1.linkedBrowser, TEST_PAGE);
+
+ const sessionId = "sessionData-test";
+
+ const rootMessageHandlerRegistry = new MessageHandlerRegistry(
+ RootMessageHandler.type
+ );
+
+ const rootMessageHandler = rootMessageHandlerRegistry.getOrCreateMessageHandler(
+ sessionId
+ );
+ ok(rootMessageHandler, "Valid ROOT MessageHandler created");
+
+ const sessionData = rootMessageHandler.sessionData;
+ ok(
+ sessionData instanceof SessionData,
+ "ROOT MessageHandler has a valid sessionData"
+ );
+
+ let sessionDataSnapshot = await getSessionDataFromContent();
+ is(sessionDataSnapshot.size, 0, "session data is empty");
+
+ info("Store a string value in session data");
+ sessionData.updateSessionData([
+ {
+ method: "add",
+ moduleName: "fakemodule",
+ category: "testCategory",
+ contextDescriptor: contextDescriptorAll,
+ values: ["value-1"],
+ },
+ ]);
+
+ sessionDataSnapshot = await getSessionDataFromContent();
+ is(sessionDataSnapshot.size, 1, "session data contains 1 session");
+ ok(sessionDataSnapshot.has(sessionId));
+ let snapshot = sessionDataSnapshot.get(sessionId);
+ ok(Array.isArray(snapshot));
+ is(snapshot.length, 1);
+
+ const stringDataItem = snapshot[0];
+ checkSessionDataItem(
+ stringDataItem,
+ "fakemodule",
+ "testCategory",
+ ContextDescriptorType.All,
+ "value-1"
+ );
+
+ info("Store a number value in session data");
+ sessionData.updateSessionData([
+ {
+ method: "add",
+ moduleName: "fakemodule",
+ category: "testCategory",
+ contextDescriptor: contextDescriptorAll,
+ values: [12],
+ },
+ ]);
+ snapshot = (await getSessionDataFromContent()).get(sessionId);
+ is(snapshot.length, 2);
+
+ const numberDataItem = snapshot[1];
+ checkSessionDataItem(
+ numberDataItem,
+ "fakemodule",
+ "testCategory",
+ ContextDescriptorType.All,
+ 12
+ );
+
+ info("Store a boolean value in session data");
+ sessionData.updateSessionData([
+ {
+ method: "add",
+ moduleName: "fakemodule",
+ category: "testCategory",
+ contextDescriptor: contextDescriptorAll,
+ values: [true],
+ },
+ ]);
+ snapshot = (await getSessionDataFromContent()).get(sessionId);
+ is(snapshot.length, 3);
+
+ const boolDataItem = snapshot[2];
+ checkSessionDataItem(
+ boolDataItem,
+ "fakemodule",
+ "testCategory",
+ ContextDescriptorType.All,
+ true
+ );
+
+ info("Remove one value");
+ sessionData.updateSessionData([
+ {
+ method: "remove",
+ moduleName: "fakemodule",
+ category: "testCategory",
+ contextDescriptor: contextDescriptorAll,
+ values: [12],
+ },
+ ]);
+ snapshot = (await getSessionDataFromContent()).get(sessionId);
+ is(snapshot.length, 2);
+ checkSessionDataItem(
+ snapshot[0],
+ "fakemodule",
+ "testCategory",
+ ContextDescriptorType.All,
+ "value-1"
+ );
+ checkSessionDataItem(
+ snapshot[1],
+ "fakemodule",
+ "testCategory",
+ ContextDescriptorType.All,
+ true
+ );
+
+ info("Remove all values");
+ sessionData.updateSessionData([
+ {
+ method: "remove",
+ moduleName: "fakemodule",
+ category: "testCategory",
+ contextDescriptor: contextDescriptorAll,
+ values: ["value-1", true],
+ },
+ ]);
+ snapshot = (await getSessionDataFromContent()).get(sessionId);
+ is(snapshot.length, 0, "Session data is now empty");
+
+ info("Add another value before destroy");
+ sessionData.updateSessionData([
+ {
+ method: "add",
+ moduleName: "fakemodule",
+ category: "testCategory",
+ contextDescriptor: contextDescriptorAll,
+ values: ["value-2"],
+ },
+ ]);
+ snapshot = (await getSessionDataFromContent()).get(sessionId);
+ is(snapshot.length, 1);
+ checkSessionDataItem(
+ snapshot[0],
+ "fakemodule",
+ "testCategory",
+ ContextDescriptorType.All,
+ "value-2"
+ );
+
+ sessionData.destroy();
+ sessionDataSnapshot = await getSessionDataFromContent();
+ is(sessionDataSnapshot.size, 0, "session data should be empty again");
+});
+
+add_task(async function test_sessionDataRootOnlyModule() {
+ const sessionId = "sessionData-test-rootOnly";
+
+ const rootMessageHandler = createRootMessageHandler(sessionId);
+ ok(rootMessageHandler, "Valid ROOT MessageHandler created");
+
+ await BrowserTestUtils.loadURI(
+ gBrowser,
+ "https://example.com/document-builder.sjs?html=tab"
+ );
+
+ const windowGlobalCreated = rootMessageHandler.once("message-handler-event");
+
+ info("Test that adding SessionData items works the root module");
+ // Updating the session data on the root message handler should not cause
+ // failures for other message handlers if the module only exists for root.
+ await rootMessageHandler.addSessionData({
+ moduleName: "rootOnly",
+ category: "session_data_root_only",
+ contextDescriptor: {
+ type: ContextDescriptorType.All,
+ },
+ values: [true],
+ });
+
+ await windowGlobalCreated;
+ ok(true, "Window global has been initialized");
+
+ let sessionDataReceivedByRoot = await rootMessageHandler.handleCommand({
+ moduleName: "rootOnly",
+ commandName: "getSessionDataReceived",
+ destination: {
+ type: RootMessageHandler.type,
+ },
+ });
+
+ is(sessionDataReceivedByRoot.length, 1);
+ is(sessionDataReceivedByRoot[0].category, "session_data_root_only");
+ is(sessionDataReceivedByRoot[0].added.length, 1);
+ is(sessionDataReceivedByRoot[0].added[0], true);
+ is(
+ sessionDataReceivedByRoot[0].contextDescriptor.type,
+ ContextDescriptorType.All
+ );
+
+ info("Now test that removing items also works on the root module");
+ await rootMessageHandler.removeSessionData({
+ moduleName: "rootOnly",
+ category: "session_data_root_only",
+ contextDescriptor: {
+ type: ContextDescriptorType.All,
+ },
+ values: [true],
+ });
+
+ sessionDataReceivedByRoot = await rootMessageHandler.handleCommand({
+ moduleName: "rootOnly",
+ commandName: "getSessionDataReceived",
+ destination: {
+ type: RootMessageHandler.type,
+ },
+ });
+
+ is(sessionDataReceivedByRoot.length, 2);
+ is(sessionDataReceivedByRoot[1].category, "session_data_root_only");
+ is(sessionDataReceivedByRoot[1].removed.length, 1);
+ is(sessionDataReceivedByRoot[1].removed[0], true);
+ is(
+ sessionDataReceivedByRoot[1].contextDescriptor.type,
+ ContextDescriptorType.All
+ );
+
+ rootMessageHandler.destroy();
+});
+
+function checkSessionDataItem(item, moduleName, category, contextType, value) {
+ is(item.moduleName, moduleName, "Data item has the expected module name");
+ is(item.category, category, "Data item has the expected category");
+ is(
+ item.contextDescriptor.type,
+ contextType,
+ "Data item has the expected context type"
+ );
+ is(item.value, value, "Data item has the expected value");
+}
+
+function getSessionDataFromContent() {
+ return SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
+ const { readSessionData } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/sessiondata/SessionDataReader.sys.mjs"
+ );
+ return readSessionData();
+ });
+}
diff --git a/remote/shared/messagehandler/test/browser/browser_session_data_broadcast.js b/remote/shared/messagehandler/test/browser/browser_session_data_broadcast.js
new file mode 100644
index 0000000000..8cfafc16a1
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/browser_session_data_broadcast.js
@@ -0,0 +1,335 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { RootMessageHandler } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs"
+);
+
+const TEST_PAGE = "https://example.com/document-builder.sjs?html=tab";
+
+add_task(async function test_session_data_broadcast() {
+ const tab1 = gBrowser.selectedTab;
+ await loadURL(tab1.linkedBrowser, TEST_PAGE);
+ const browsingContext1 = tab1.linkedBrowser.browsingContext;
+
+ const root = createRootMessageHandler("session-id-event");
+
+ info("Add a new session data item, expect one return value");
+ const value1 = await addSessionData(root, ["text-1"]);
+ is(value1.length, 1);
+ is(value1[0].addedData, "text-1");
+ is(value1[0].removedData, "");
+ is(value1[0].sessionData, "text-1");
+ is(value1[0].contextId, browsingContext1.id);
+
+ info("Add two session data items, expect one return value with both items");
+ const value2 = await addSessionData(root, ["text-2", "text-3"]);
+ is(value2.length, 1);
+ is(value2[0].addedData, "text-2, text-3");
+ is(value2[0].removedData, "");
+ is(value2[0].sessionData, "text-1, text-2, text-3");
+ is(value2[0].contextId, browsingContext1.id);
+
+ info("Try to add an existing data item, expect no return value");
+ const value3 = await addSessionData(root, ["text-1"]);
+ is(value3.length, 0);
+
+ info("Add an existing and a new item, expect only the new item to return");
+ const value4 = await addSessionData(root, ["text-1", "text-4"]);
+ is(value4.length, 1);
+ is(value4[0].addedData, "text-4");
+ is(value4[0].removedData, "");
+ is(value4[0].sessionData, "text-1, text-2, text-3, text-4");
+ is(value4[0].contextId, browsingContext1.id);
+
+ info("Remove an item, expect only the new item to return");
+ const value5 = await removeSessionData(root, ["text-3"]);
+ is(value5.length, 1);
+ is(value5[0].addedData, "");
+ is(value5[0].removedData, "text-3");
+ is(value5[0].sessionData, "text-1, text-2, text-4");
+ is(value5[0].contextId, browsingContext1.id);
+
+ info("Open a new tab on the same test URL");
+ const tab2 = await addTab(TEST_PAGE);
+ const browsingContext2 = tab2.linkedBrowser.browsingContext;
+
+ info("Add a new session data item, check both contexts have the same data.");
+ const value6 = await addSessionData(root, ["text-5"]);
+ is(value6.length, 2);
+ is(value6[0].addedData, "text-5");
+ is(value6[0].removedData, "");
+ is(value6[0].sessionData, "text-1, text-2, text-4, text-5");
+ is(value6[0].contextId, browsingContext1.id);
+ is(value6[1].addedData, "text-5");
+ // "text-1, text-2, text-4" were added as initial session data and
+ // "text-5" was added afterwards.
+ is(value6[1].sessionData, "text-1, text-2, text-4, text-5");
+ is(value6[1].contextId, browsingContext2.id);
+
+ info("Remove a missing item, expect no return value");
+ const value7 = await removeSessionData(root, ["text-missing"]);
+ is(value7.length, 0);
+
+ info("Remove an existing and a missing item");
+ const value8 = await removeSessionData(root, ["text-2", "text-missing"]);
+ is(value8.length, 2);
+ is(value8[0].addedData, "");
+ is(value8[0].removedData, "text-2");
+ is(value8[0].sessionData, "text-1, text-4, text-5");
+ is(value8[0].contextId, browsingContext1.id);
+ is(value8[1].addedData, "");
+ is(value8[1].removedData, "text-2");
+ is(value8[1].sessionData, "text-1, text-4, text-5");
+ is(value8[1].contextId, browsingContext2.id);
+
+ info("Add multiple items at once");
+ const value9 = await updateSessionData(root, [
+ {
+ method: "add",
+ values: ["text-6"],
+ moduleName: "command",
+ category: "testCategory",
+ contextDescriptor: {
+ type: ContextDescriptorType.All,
+ },
+ },
+ {
+ method: "add",
+ values: ["text-7"],
+ moduleName: "command",
+ category: "testCategory",
+ contextDescriptor: {
+ type: ContextDescriptorType.TopBrowsingContext,
+ id: browsingContext2.browserId,
+ },
+ },
+ ]);
+ is(value9.length, 2);
+ is(value9[0].addedData, "text-6");
+ is(value9[0].removedData, "");
+ is(value9[0].sessionData, "text-1, text-4, text-5, text-6");
+ is(value9[0].contextId, browsingContext1.id);
+ is(value9[1].addedData, "text-6, text-7");
+ is(value9[1].removedData, "");
+ is(value9[1].sessionData, "text-1, text-4, text-5, text-6, text-7");
+ is(value9[1].contextId, browsingContext2.id);
+
+ info("Remove multiple items at once");
+ const value10 = await updateSessionData(root, [
+ {
+ method: "remove",
+ values: ["text-5"],
+ moduleName: "command",
+ category: "testCategory",
+ contextDescriptor: {
+ type: ContextDescriptorType.All,
+ },
+ },
+ {
+ method: "remove",
+ values: ["text-7"],
+ moduleName: "command",
+ category: "testCategory",
+ contextDescriptor: {
+ type: ContextDescriptorType.TopBrowsingContext,
+ id: browsingContext2.browserId,
+ },
+ },
+ ]);
+ is(value10.length, 2);
+ is(value10[0].addedData, "");
+ is(value10[0].removedData, "text-5");
+ is(value10[0].sessionData, "text-1, text-4, text-6");
+ is(value10[0].contextId, browsingContext1.id);
+ is(value10[1].addedData, "");
+ is(value10[1].removedData, "text-5, text-7");
+ is(value10[1].sessionData, "text-1, text-4, text-6");
+ is(value10[1].contextId, browsingContext2.id);
+
+ info("Add and remove at once");
+ const value11 = await updateSessionData(root, [
+ {
+ method: "add",
+ values: ["text-8"],
+ moduleName: "command",
+ category: "testCategory",
+ contextDescriptor: {
+ type: ContextDescriptorType.All,
+ },
+ },
+ {
+ method: "remove",
+ values: ["text-6"],
+ moduleName: "command",
+ category: "testCategory",
+ contextDescriptor: {
+ type: ContextDescriptorType.All,
+ },
+ },
+ ]);
+ is(value11.length, 2);
+ is(value11[0].addedData, "text-8");
+ is(value11[0].removedData, "text-6");
+ is(value11[0].sessionData, "text-1, text-4, text-8");
+ is(value11[0].contextId, browsingContext1.id);
+ is(value11[1].addedData, "text-8");
+ is(value11[1].removedData, "text-6");
+ is(value11[1].sessionData, "text-1, text-4, text-8");
+ is(value11[1].contextId, browsingContext2.id);
+
+ info(
+ "Add session data item to all contexts and remove this event for one context"
+ );
+ // Add first an event for one context.
+ const value12 = await updateSessionData(root, [
+ {
+ method: "add",
+ values: ["text-9"],
+ moduleName: "command",
+ category: "testCategory",
+ contextDescriptor: {
+ type: ContextDescriptorType.TopBrowsingContext,
+ id: browsingContext1.browserId,
+ },
+ },
+ ]);
+ is(value12.length, 1);
+ is(value12[0].addedData, "text-9");
+ is(value12[0].removedData, "");
+ is(value12[0].sessionData, "text-1, text-4, text-8, text-9");
+ is(value12[0].contextId, browsingContext1.id);
+
+ // Remove the item for one context and add the item for all contexts.
+ const value13 = await updateSessionData(root, [
+ {
+ method: "remove",
+ values: ["text-9"],
+ moduleName: "command",
+ category: "testCategory",
+ contextDescriptor: {
+ type: ContextDescriptorType.TopBrowsingContext,
+ id: browsingContext1.browserId,
+ },
+ },
+ {
+ method: "add",
+ values: ["text-9"],
+ moduleName: "command",
+ category: "testCategory",
+ contextDescriptor: {
+ type: ContextDescriptorType.All,
+ },
+ },
+ ]);
+ is(value13.length, 2);
+ is(value13[0].addedData, "");
+ // Make sure that nothing is removed.
+ is(value13[0].removedData, "");
+ is(value13[0].sessionData, "text-1, text-4, text-8, text-9");
+ is(value13[0].contextId, browsingContext1.id);
+ is(value13[1].addedData, "text-9");
+ is(value13[1].removedData, "");
+ is(value13[1].sessionData, "text-1, text-4, text-8, text-9");
+ is(value13[1].contextId, browsingContext2.id);
+
+ info(
+ "Remove the event, which has also an individual subscription, for all contexts."
+ );
+ const value14 = await updateSessionData(root, [
+ {
+ method: "add",
+ values: ["text-10"],
+ moduleName: "command",
+ category: "testCategory",
+ contextDescriptor: {
+ type: ContextDescriptorType.All,
+ },
+ },
+ {
+ method: "add",
+ values: ["text-10"],
+ moduleName: "command",
+ category: "testCategory",
+ contextDescriptor: {
+ type: ContextDescriptorType.TopBrowsingContext,
+ id: browsingContext1.browserId,
+ },
+ },
+ ]);
+ is(value14.length, 2);
+ is(value14[0].addedData, "text-10");
+ is(value14[0].removedData, "");
+ is(value14[0].sessionData, "text-1, text-4, text-8, text-9, text-10");
+ is(value14[0].contextId, browsingContext1.id);
+ is(value14[1].addedData, "text-10");
+ is(value14[1].removedData, "");
+ is(value14[1].sessionData, "text-1, text-4, text-8, text-9, text-10");
+ is(value14[1].contextId, browsingContext2.id);
+
+ const value15 = await updateSessionData(root, [
+ {
+ method: "remove",
+ values: ["text-10"],
+ moduleName: "command",
+ category: "testCategory",
+ contextDescriptor: {
+ type: ContextDescriptorType.All,
+ },
+ },
+ ]);
+
+ is(value15.length, 2);
+ is(value15[0].addedData, "");
+ // Make sure that nothing is removed for the first context
+ is(value15[0].removedData, "");
+ is(value15[0].sessionData, "text-1, text-4, text-8, text-9, text-10");
+ is(value15[0].contextId, browsingContext1.id);
+ is(value15[1].addedData, "");
+ is(value15[1].removedData, "text-10");
+ is(value15[1].sessionData, "text-1, text-4, text-8, text-9");
+ is(value15[1].contextId, browsingContext2.id);
+
+ root.destroy();
+
+ gBrowser.removeTab(tab2);
+});
+
+function addSessionData(rootMessageHandler, values) {
+ return rootMessageHandler.handleCommand({
+ moduleName: "command",
+ commandName: "testAddSessionData",
+ destination: {
+ type: RootMessageHandler.type,
+ },
+ params: {
+ values,
+ },
+ });
+}
+
+function removeSessionData(rootMessageHandler, values) {
+ return rootMessageHandler.handleCommand({
+ moduleName: "command",
+ commandName: "testRemoveSessionData",
+ destination: {
+ type: RootMessageHandler.type,
+ },
+ params: {
+ values,
+ },
+ });
+}
+
+function updateSessionData(rootMessageHandler, params) {
+ return rootMessageHandler.handleCommand({
+ moduleName: "command",
+ commandName: "testUpdateSessionData",
+ destination: {
+ type: RootMessageHandler.type,
+ },
+ params,
+ });
+}
diff --git a/remote/shared/messagehandler/test/browser/browser_session_data_browser_element.js b/remote/shared/messagehandler/test/browser/browser_session_data_browser_element.js
new file mode 100644
index 0000000000..62c51b29a8
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/browser_session_data_browser_element.js
@@ -0,0 +1,98 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_PAGE = "https://example.com/document-builder.sjs?html=tab";
+
+/**
+ * Check that message handlers are not created for parent process browser
+ * elements, even if they have the type="content" attribute (eg used for the
+ * DevTools toolbox), as well as for webextension contexts.
+ */
+add_task(async function test_session_data_broadcast() {
+ // Prepare:
+ // - one content tab
+ // - one browser type content
+ // - one browser type chrome
+ // - one sidebar webextension
+ // We only expect session data to be applied to the content tab
+ const tab1 = BrowserTestUtils.addTab(gBrowser, TEST_PAGE);
+ const contentBrowser1 = tab1.linkedBrowser;
+ await BrowserTestUtils.browserLoaded(contentBrowser1);
+ const parentBrowser1 = createParentBrowserElement(tab1, "content");
+ const parentBrowser2 = createParentBrowserElement(tab1, "chrome");
+ const {
+ extension: extension1,
+ sidebarBrowser: extSidebarBrowser1,
+ } = await installSidebarExtension();
+
+ const root = createRootMessageHandler("session-id-event");
+
+ // When the windowglobal command.jsm module applies the session data
+ // browser_session_data_browser_element, it will emit an event.
+ // Collect the events to detect which MessageHandlers have been started.
+ info("Watch events emitted when session data is applied");
+ const sessionDataEvents = [];
+ const onRootEvent = function(evtName, wrappedEvt) {
+ if (wrappedEvt.name === "received-session-data") {
+ sessionDataEvents.push(wrappedEvt.data.contextId);
+ }
+ };
+ root.on("message-handler-event", onRootEvent);
+
+ info("Add a new session data item, expect one return value");
+ await root.addSessionData({
+ moduleName: "command",
+ category: "browser_session_data_browser_element",
+ contextDescriptor: {
+ type: ContextDescriptorType.All,
+ },
+ values: [true],
+ });
+
+ function hasSessionData(browsingContext) {
+ return sessionDataEvents.includes(browsingContext.id);
+ }
+
+ info(
+ "Check that only the content tab window global received the session data"
+ );
+ is(hasSessionData(contentBrowser1.browsingContext), true);
+ is(hasSessionData(parentBrowser1.browsingContext), false);
+ is(hasSessionData(parentBrowser2.browsingContext), false);
+ is(hasSessionData(extSidebarBrowser1.browsingContext), false);
+
+ const tab2 = BrowserTestUtils.addTab(gBrowser, TEST_PAGE);
+ const contentBrowser2 = tab2.linkedBrowser;
+ await BrowserTestUtils.browserLoaded(contentBrowser2);
+ const parentBrowser3 = createParentBrowserElement(contentBrowser2, "content");
+ const parentBrowser4 = createParentBrowserElement(contentBrowser2, "chrome");
+
+ const {
+ extension: extension2,
+ sidebarBrowser: extSidebarBrowser2,
+ } = await installSidebarExtension();
+
+ info("Wait until the session data was applied to the new tab");
+ await TestUtils.waitForCondition(() =>
+ sessionDataEvents.includes(contentBrowser2.browsingContext.id)
+ );
+
+ info("Check that parent browser elements did not apply the session data");
+ is(hasSessionData(parentBrowser3.browsingContext), false);
+ is(hasSessionData(parentBrowser4.browsingContext), false);
+
+ info(
+ "Check that extension did not apply the session data, " +
+ extSidebarBrowser2.browsingContext.id
+ );
+ is(hasSessionData(extSidebarBrowser2.browsingContext), false);
+
+ root.destroy();
+
+ gBrowser.removeTab(tab1);
+ gBrowser.removeTab(tab2);
+ await extension1.unload();
+ await extension2.unload();
+});
diff --git a/remote/shared/messagehandler/test/browser/browser_session_data_constructor_race.js b/remote/shared/messagehandler/test/browser/browser_session_data_constructor_race.js
new file mode 100644
index 0000000000..e71c638ca3
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/browser_session_data_constructor_race.js
@@ -0,0 +1,54 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { WindowGlobalMessageHandler } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs"
+);
+
+const TEST_PAGE = "https://example.com/document-builder.sjs?html=tab";
+
+/**
+ * Check that modules created early for session data are still created with a
+ * fully initialized MessageHandler. See Bug 1743083.
+ */
+add_task(async function() {
+ const tab = BrowserTestUtils.addTab(gBrowser, TEST_PAGE);
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ const browsingContext = tab.linkedBrowser.browsingContext;
+
+ const root = createRootMessageHandler("session-id-event");
+
+ info("Add some session data for the command module");
+ await root.addSessionData({
+ moduleName: "command",
+ category: "testCategory",
+ contextDescriptor: contextDescriptorAll,
+ values: ["some-value"],
+ });
+
+ info("Reload the current tab to create new message handlers and modules");
+ await BrowserTestUtils.reloadTab(tab);
+
+ info(
+ "Check if the command module was created by the MessageHandler constructor"
+ );
+ const isCreatedByMessageHandlerConstructor = await root.handleCommand({
+ moduleName: "command",
+ commandName: "testIsCreatedByMessageHandlerConstructor",
+ destination: {
+ type: WindowGlobalMessageHandler.type,
+ id: browsingContext.id,
+ },
+ });
+
+ is(
+ isCreatedByMessageHandlerConstructor,
+ false,
+ "The command module from session data should not be created by the MessageHandler constructor"
+ );
+ root.destroy();
+
+ gBrowser.removeTab(tab);
+});
diff --git a/remote/shared/messagehandler/test/browser/head.js b/remote/shared/messagehandler/test/browser/head.js
new file mode 100644
index 0000000000..825db7afef
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/head.js
@@ -0,0 +1,186 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var { ContextDescriptorType } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/MessageHandler.sys.mjs"
+);
+
+var contextDescriptorAll = {
+ type: ContextDescriptorType.All,
+};
+
+function createRootMessageHandler(sessionId) {
+ const { RootMessageHandlerRegistry } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/RootMessageHandlerRegistry.sys.mjs"
+ );
+ return RootMessageHandlerRegistry.getOrCreateMessageHandler(sessionId);
+}
+
+/**
+ * Load the provided url in an existing browser.
+ * Returns a promise which will resolve when the page is loaded.
+ *
+ * @param {Browser} browser
+ * The browser element where the URL should be loaded.
+ * @param {String} url
+ * The URL to load in the new tab
+ */
+async function loadURL(browser, url) {
+ const loaded = BrowserTestUtils.browserLoaded(browser);
+ BrowserTestUtils.loadURI(browser, url);
+ return loaded;
+}
+
+/**
+ * Create a new foreground tab loading the provided url.
+ * Returns a promise which will resolve when the page is loaded.
+ *
+ * @param {String} url
+ * The URL to load in the new tab
+ */
+async function addTab(url) {
+ const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ registerCleanupFunction(() => {
+ gBrowser.removeTab(tab);
+ });
+ return tab;
+}
+
+/**
+ * Create inline markup for a simple iframe that can be used with
+ * document-builder.sjs. The iframe will be served under the provided domain.
+ *
+ * @param {String} domain
+ * A domain (eg "example.com"), compatible with build/pgo/server-locations.txt
+ */
+function createFrame(domain) {
+ return createFrameForUri(
+ `https://${domain}/document-builder.sjs?html=frame-${domain}`
+ );
+}
+
+function createFrameForUri(uri) {
+ return `<iframe src="${encodeURI(uri)}"></iframe>`;
+}
+
+/**
+ * Create a XUL browser element in the provided XUL tab, with the provided type.
+ *
+ * @param {xul:tab} tab
+ * The XUL tab in which the browser element should be inserted.
+ * @param {String} type
+ * The type attribute of the browser element, "chrome" or "content".
+ * @return {xul:browser}
+ * The created browser element.
+ */
+function createParentBrowserElement(tab, type) {
+ const parentBrowser = gBrowser.ownerDocument.createXULElement("browser");
+ parentBrowser.setAttribute("type", type);
+ const container = gBrowser.getBrowserContainer(tab.linkedBrowser);
+ container.appendChild(parentBrowser);
+
+ return parentBrowser;
+}
+
+// Create a test page with 2 iframes:
+// - one with a different eTLD+1 (example.com)
+// - one with a nested iframe on a different eTLD+1 (example.net)
+//
+// Overall the document structure should look like:
+//
+// html (example.org)
+// iframe (example.org)
+// iframe (example.net)
+// iframe(example.com)
+//
+// Which means we should have 4 browsing contexts in total.
+function createTestMarkupWithFrames() {
+ // Create the markup for an example.net frame nested in an example.com frame.
+ const NESTED_FRAME_MARKUP = createFrameForUri(
+ `https://example.org/document-builder.sjs?html=${createFrame(
+ "example.net"
+ )}`
+ );
+
+ // Combine the nested frame markup created above with an example.com frame.
+ const TEST_URI_MARKUP = `${NESTED_FRAME_MARKUP}${createFrame("example.com")}`;
+
+ // Create the test page URI on example.org.
+ return `https://example.org/document-builder.sjs?html=${encodeURI(
+ TEST_URI_MARKUP
+ )}`;
+}
+
+const hasPromiseResolved = async function(promise) {
+ let resolved = false;
+ promise.finally(() => (resolved = true));
+ // Make sure microtasks have time to run.
+ await new Promise(resolve => Services.tm.dispatchToMainThread(resolve));
+ return resolved;
+};
+
+/**
+ * Install a sidebar extension.
+ *
+ * @return {Object}
+ * Return value with two properties:
+ * - extension: test wrapper as returned by SpecialPowers.loadExtension.
+ * Make sure to explicitly call extension.unload() before the end of the test.
+ * - sidebarBrowser: the browser element containing the extension sidebar.
+ */
+async function installSidebarExtension() {
+ info("Load the test extension");
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ sidebar_action: {
+ default_panel: "sidebar.html",
+ },
+ },
+ useAddonManager: "temporary",
+
+ files: {
+ "sidebar.html": `
+ <!DOCTYPE html>
+ <html>
+ Test extension
+ <script src="sidebar.js"></script>
+ </html>
+ `,
+ "sidebar.js": function() {
+ const { browser } = this;
+ browser.test.sendMessage("sidebar-loaded", {
+ bcId: SpecialPowers.wrap(window).browsingContext.id,
+ });
+ },
+ "tab.html": `
+ <!DOCTYPE html>
+ <html>
+ Test extension (tab)
+ <script src="tab.js"></script>
+ </html>
+ `,
+ "tab.js": function() {
+ const { browser } = this;
+ browser.test.sendMessage("tab-loaded", {
+ bcId: SpecialPowers.wrap(window).browsingContext.id,
+ });
+ },
+ },
+ });
+
+ info("Wait for the extension to start");
+ await extension.startup();
+
+ info("Wait for the extension browsing context");
+ const { bcId } = await extension.awaitMessage("sidebar-loaded");
+ const sidebarBrowser = BrowsingContext.get(bcId).top.embedderElement;
+ ok(sidebarBrowser, "Got a browser element for the extension sidebar");
+
+ return {
+ extension,
+ sidebarBrowser,
+ };
+}
diff --git a/remote/shared/messagehandler/test/browser/resources/modules/ModuleRegistry.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/ModuleRegistry.sys.mjs
new file mode 100644
index 0000000000..e5d9c38719
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/resources/modules/ModuleRegistry.sys.mjs
@@ -0,0 +1,30 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Retrieve the WebDriver BiDi module class matching the provided module name
+ * and folder.
+ *
+ * @param {String} moduleName
+ * The name of the module to get the class for.
+ * @param {String} moduleFolder
+ * A valid folder name for modules.
+ * @return {Class=}
+ * The class corresponding to the module name and folder, null if no match
+ * was found.
+ * @throws {Error}
+ * If the provided module folder is unexpected.
+ **/
+export const getModuleClass = function(moduleName, moduleFolder) {
+ const root = `chrome://mochitests/content/browser/remote/shared/messagehandler/test/`;
+ const path = `${root}browser/resources/modules/${moduleFolder}/${moduleName}.sys.mjs`;
+ try {
+ return ChromeUtils.importESModule(path)[moduleName];
+ } catch (e) {
+ if (e.result == Cr.NS_ERROR_FILE_NOT_FOUND) {
+ return null;
+ }
+ throw e;
+ }
+};
diff --git a/remote/shared/messagehandler/test/browser/resources/modules/root/command.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/root/command.sys.mjs
new file mode 100644
index 0000000000..a67428ddfa
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/resources/modules/root/command.sys.mjs
@@ -0,0 +1,80 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { ContextDescriptorType } from "chrome://remote/content/shared/messagehandler/MessageHandler.sys.mjs";
+import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs";
+import { WindowGlobalMessageHandler } from "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs";
+
+class CommandModule extends Module {
+ destroy() {}
+
+ async #getSessionDataUpdateFromAllContexts() {
+ const updates = await this.messageHandler.handleCommand({
+ moduleName: "command",
+ commandName: "_getSessionDataUpdate",
+ destination: {
+ contextDescriptor: {
+ type: ContextDescriptorType.All,
+ },
+ type: WindowGlobalMessageHandler.type,
+ },
+ });
+
+ // Filter out null values, which indicate that no new session data was
+ // received by the windowglobal module since the last getSessionDataUpdate
+ // command.
+ return updates.filter(update => update != null);
+ }
+
+ /**
+ * Commands
+ */
+
+ async testAddSessionData(params) {
+ await this.messageHandler.addSessionData({
+ moduleName: "command",
+ category: "testCategory",
+ contextDescriptor: {
+ type: ContextDescriptorType.All,
+ },
+ values: params.values,
+ });
+
+ return this.#getSessionDataUpdateFromAllContexts();
+ }
+
+ async testRemoveSessionData(params) {
+ await this.messageHandler.removeSessionData({
+ moduleName: "command",
+ category: "testCategory",
+ contextDescriptor: {
+ type: ContextDescriptorType.All,
+ },
+ values: params.values,
+ });
+
+ return this.#getSessionDataUpdateFromAllContexts();
+ }
+
+ async testUpdateSessionData(params) {
+ await this.messageHandler.updateSessionData(params);
+ return this.#getSessionDataUpdateFromAllContexts();
+ }
+
+ testRootModule() {
+ return "root-value";
+ }
+
+ testMissingIntermediaryMethod(params, destination) {
+ // Spawn a new internal command, but with a commandName which doesn't match
+ // any method.
+ return this.messageHandler.handleCommand({
+ moduleName: "command",
+ commandName: "missingMethod",
+ destination,
+ });
+ }
+}
+
+export const command = CommandModule;
diff --git a/remote/shared/messagehandler/test/browser/resources/modules/root/event.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/root/event.sys.mjs
new file mode 100644
index 0000000000..e49437e80d
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/resources/modules/root/event.sys.mjs
@@ -0,0 +1,21 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs";
+
+class EventModule extends Module {
+ destroy() {}
+
+ /**
+ * Commands
+ */
+
+ testEmitRootEvent() {
+ this.emitEvent("event-from-root", {
+ text: "event from root",
+ });
+ }
+}
+
+export const event = EventModule;
diff --git a/remote/shared/messagehandler/test/browser/resources/modules/root/invalid.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/root/invalid.sys.mjs
new file mode 100644
index 0000000000..3b74769d06
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/resources/modules/root/invalid.sys.mjs
@@ -0,0 +1,4 @@
+// This module is meant to check error reporting when importing a module fails
+// due to an actual issue (syntax error etc...).
+
+SyntaxError(;
diff --git a/remote/shared/messagehandler/test/browser/resources/modules/root/rootOnly.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/root/rootOnly.sys.mjs
new file mode 100644
index 0000000000..0931a7ee8e
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/resources/modules/root/rootOnly.sys.mjs
@@ -0,0 +1,70 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { ContextDescriptorType } from "chrome://remote/content/shared/messagehandler/MessageHandler.sys.mjs";
+import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs";
+
+class RootOnlyModule extends Module {
+ #sessionDataReceived;
+ #subscribedEvents;
+
+ constructor(messageHandler) {
+ super(messageHandler);
+
+ this.#sessionDataReceived = [];
+ this.#subscribedEvents = new Set();
+ }
+
+ destroy() {}
+
+ /**
+ * Commands
+ */
+
+ getSessionDataReceived() {
+ return this.#sessionDataReceived;
+ }
+
+ testCommand(params = {}) {
+ return params;
+ }
+
+ _applySessionData(params) {
+ const added = [];
+ const removed = [];
+
+ const filteredSessionData = params.sessionData.filter(item =>
+ this.messageHandler.matchesContext(item.contextDescriptor)
+ );
+ for (const event of this.#subscribedEvents.values()) {
+ const hasSessionItem = filteredSessionData.some(
+ item => item.value === event
+ );
+ // If there are no session items for this context, we should unsubscribe from the event.
+ if (!hasSessionItem) {
+ this.#subscribedEvents.delete(event);
+ removed.push(event);
+ }
+ }
+
+ // Subscribe to all events, which have an item in SessionData
+ for (const { value } of filteredSessionData) {
+ if (!this.#subscribedEvents.has(value)) {
+ this.#subscribedEvents.add(value);
+ added.push(value);
+ }
+ }
+
+ this.#sessionDataReceived.push({
+ category: params.category,
+ added,
+ removed,
+ contextDescriptor: {
+ type: ContextDescriptorType.All,
+ },
+ });
+ }
+}
+
+export const rootOnly = RootOnlyModule;
diff --git a/remote/shared/messagehandler/test/browser/resources/modules/windowglobal-in-root/command.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal-in-root/command.sys.mjs
new file mode 100644
index 0000000000..f9a2e5d4eb
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal-in-root/command.sys.mjs
@@ -0,0 +1,28 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs";
+
+class CommandModule extends Module {
+ destroy() {}
+
+ /**
+ * Commands
+ */
+
+ testInterceptModule() {
+ return "intercepted-value";
+ }
+
+ async testInterceptAndForwardModule(params, destination) {
+ const windowGlobalValue = await this.messageHandler.handleCommand({
+ moduleName: "command",
+ commandName: "testForwardToWindowGlobal",
+ destination,
+ });
+ return "intercepted-and-forward+" + windowGlobalValue;
+ }
+}
+
+export const command = CommandModule;
diff --git a/remote/shared/messagehandler/test/browser/resources/modules/windowglobal-in-root/event.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal-in-root/event.sys.mjs
new file mode 100644
index 0000000000..2969e0a3e8
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal-in-root/event.sys.mjs
@@ -0,0 +1,31 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs";
+
+class EventModule extends Module {
+ destroy() {}
+
+ interceptEvent(name, payload) {
+ if (name === "event.testEventWithInterception") {
+ return {
+ ...payload,
+ additionalInformation: "information added through interception",
+ };
+ }
+ return payload;
+ }
+
+ /**
+ * Commands
+ */
+
+ testEmitWindowGlobalInRootEvent(params, destination) {
+ this.emitEvent("event-from-window-global-in-root", {
+ text: `windowglobal-in-root event for ${destination.id}`,
+ });
+ }
+}
+
+export const event = EventModule;
diff --git a/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/command.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/command.sys.mjs
new file mode 100644
index 0000000000..23655a464a
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/command.sys.mjs
@@ -0,0 +1,114 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs";
+
+class CommandModule extends Module {
+ constructor(messageHandler) {
+ super(messageHandler);
+ this._lastSessionDataUpdate = {};
+ this._subscribedEvents = new Set();
+ this._testCategorySessionData = [];
+
+ this._createdByMessageHandlerConstructor = this._isCreatedByMessageHandlerConstructor();
+ }
+ destroy() {}
+
+ /**
+ * Commands
+ */
+
+ _applySessionData(params) {
+ if (params.category === "testCategory") {
+ const added = [];
+ const removed = [];
+
+ const filteredSessionData = params.sessionData.filter(item =>
+ this.messageHandler.matchesContext(item.contextDescriptor)
+ );
+ for (const event of this._subscribedEvents.values()) {
+ const hasSessionItem = filteredSessionData.some(
+ item => item.value === event
+ );
+ // If there are no session items for this context, we should unsubscribe from the event.
+ if (!hasSessionItem) {
+ this._subscribedEvents.delete(event);
+ removed.push(event);
+ }
+ }
+
+ // Subscribe to all events, which have an item in SessionData
+ for (const { value } of filteredSessionData) {
+ if (!this._subscribedEvents.has(value)) {
+ this._subscribedEvents.add(value);
+ added.push(value);
+ }
+ }
+
+ this._testCategorySessionData = this._testCategorySessionData
+ .concat(added)
+ .filter(value => !removed.includes(value));
+
+ this._lastSessionDataUpdate = {
+ addedData: added.join(", "),
+ removedData: removed.join(", "),
+ sessionData: this._testCategorySessionData.join(", "),
+ contextId: this.messageHandler.contextId,
+ };
+ }
+
+ if (params.category === "browser_session_data_browser_element") {
+ this.emitEvent("received-session-data", {
+ contextId: this.messageHandler.contextId,
+ });
+ }
+
+ return {};
+ }
+
+ _getSessionDataUpdate(params) {
+ const lastUpdate = this._lastSessionDataUpdate;
+
+ // Each "lastUpdate" should only be returned once, so that the caller can
+ // assert when a SessionData update had no impact.
+ this._lastSessionDataUpdate = null;
+
+ return lastUpdate;
+ }
+
+ testWindowGlobalModule() {
+ return "windowglobal-value";
+ }
+
+ testSetValue(params) {
+ const { value } = params;
+
+ this._testValue = value;
+ }
+
+ testGetValue() {
+ return this._testValue;
+ }
+
+ testForwardToWindowGlobal() {
+ return "forward-to-windowglobal-value";
+ }
+
+ testIsCreatedByMessageHandlerConstructor() {
+ return this._createdByMessageHandlerConstructor;
+ }
+
+ _isCreatedByMessageHandlerConstructor() {
+ let caller = Components.stack.caller;
+ while (caller) {
+ if (caller.name === this.messageHandler.constructor.name) {
+ return true;
+ }
+ caller = caller.caller;
+ }
+ return false;
+ }
+}
+
+export const command = CommandModule;
diff --git a/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/commandwindowglobalonly.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/commandwindowglobalonly.sys.mjs
new file mode 100644
index 0000000000..1e4e6c1574
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/commandwindowglobalonly.sys.mjs
@@ -0,0 +1,41 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs";
+
+class CommandWindowGlobalOnlyModule extends Module {
+ destroy() {}
+
+ /**
+ * Commands
+ */
+
+ testOnlyInWindowGlobal() {
+ return "only-in-windowglobal";
+ }
+
+ testBroadcast() {
+ return `broadcast-${this.messageHandler.contextId}`;
+ }
+
+ testBroadcastWithParameter(params) {
+ return `broadcast-${this.messageHandler.contextId}-${params.value}`;
+ }
+
+ testError() {
+ throw new Error("error-from-module");
+ }
+
+ testMissingIntermediaryMethod(params, destination) {
+ // Spawn a new internal command, but with a commandName which doesn't match
+ // any method.
+ return this.messageHandler.handleCommand({
+ moduleName: "commandwindowglobalonly",
+ commandName: "missingMethod",
+ destination,
+ });
+ }
+}
+
+export const commandwindowglobalonly = CommandWindowGlobalOnlyModule;
diff --git a/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/event.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/event.sys.mjs
new file mode 100644
index 0000000000..5bb50cb83d
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/event.sys.mjs
@@ -0,0 +1,26 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs";
+
+class EventModule extends Module {
+ destroy() {}
+
+ /**
+ * Commands
+ */
+
+ testEmitEvent() {
+ // Emit a payload including the contextId to check which context emitted
+ // a specific event.
+ const text = `event from ${this.messageHandler.contextId}`;
+ this.emitEvent("event-from-window-global", { text });
+ }
+
+ testEmitEventWithInterception() {
+ this.emitEvent("event.testEventWithInterception", {});
+ }
+}
+
+export const event = EventModule;
diff --git a/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/eventemitter.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/eventemitter.sys.mjs
new file mode 100644
index 0000000000..c86954c5e0
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/eventemitter.sys.mjs
@@ -0,0 +1,81 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs";
+
+class EventEmitterModule extends Module {
+ #isSubscribed;
+ #subscribedEvents;
+
+ constructor(messageHandler) {
+ super(messageHandler);
+ this.#isSubscribed = false;
+ this.#subscribedEvents = new Set();
+ }
+
+ destroy() {}
+
+ /**
+ * Commands
+ */
+
+ emitTestEvent() {
+ if (this.#isSubscribed) {
+ const text = `event from ${this.messageHandler.contextId}`;
+ this.emitEvent("eventemitter.testEvent", { text });
+ }
+
+ // Emit another event consistently for monitoring during the test.
+ this.emitEvent("eventemitter.monitoringEvent", {});
+ }
+
+ isSubscribed() {
+ return this.#isSubscribed;
+ }
+
+ _applySessionData(params) {
+ const { category } = params;
+ if (category === "event") {
+ const filteredSessionData = params.sessionData.filter(item =>
+ this.messageHandler.matchesContext(item.contextDescriptor)
+ );
+ for (const event of this.#subscribedEvents.values()) {
+ const hasSessionItem = filteredSessionData.some(
+ item => item.value === event
+ );
+ // If there are no session items for this context, we should unsubscribe from the event.
+ if (!hasSessionItem) {
+ this.#unsubscribeEvent(event);
+ }
+ }
+
+ // Subscribe to all events, which have an item in SessionData
+ for (const { value } of filteredSessionData) {
+ this.#subscribeEvent(value);
+ }
+ }
+ }
+
+ #subscribeEvent(event) {
+ if (event === "eventemitter.testEvent") {
+ if (this.#isSubscribed) {
+ throw new Error("Already subscribed to eventemitter.testEvent");
+ }
+ this.#isSubscribed = true;
+ this.#subscribedEvents.add(event);
+ }
+ }
+
+ #unsubscribeEvent(event) {
+ if (event === "eventemitter.testEvent") {
+ if (!this.#isSubscribed) {
+ throw new Error("Not subscribed to eventemitter.testEvent");
+ }
+ this.#isSubscribed = false;
+ this.#subscribedEvents.delete(event);
+ }
+ }
+}
+
+export const eventemitter = EventEmitterModule;
diff --git a/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/eventnointercept.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/eventnointercept.sys.mjs
new file mode 100644
index 0000000000..48bbfbf951
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/eventnointercept.sys.mjs
@@ -0,0 +1,16 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs";
+
+class EventNoInterceptModule extends Module {
+ destroy() {}
+
+ testEvent() {
+ const text = `event no interception`;
+ this.emitEvent("eventnointercept.testEvent", { text });
+ }
+}
+
+export const eventnointercept = EventNoInterceptModule;
diff --git a/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/retry.sys.mjs b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/retry.sys.mjs
new file mode 100644
index 0000000000..f7b2279018
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/resources/modules/windowglobal/retry.sys.mjs
@@ -0,0 +1,84 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { Module } from "chrome://remote/content/shared/messagehandler/Module.sys.mjs";
+
+// Store counters in the JSM scope to persist them across reloads.
+let callsToBlockedOneTime = 0;
+let callsToBlockedTenTimes = 0;
+let callsToBlockedElevenTimes = 0;
+
+// This module provides various commands which all hang for various reasons.
+// The test is supposed to trigger the command and then destroy the
+// JSWindowActor pair by any mean (eg a navigation) in order to trigger an
+// AbortError and a retry.
+class RetryModule extends Module {
+ destroy() {}
+
+ /**
+ * Commands
+ */
+
+ // Resolves only if called while on the example.net domain.
+ async blockedOnNetDomain(params) {
+ // Note: we do not store a call counter here, because this is used for a
+ // cross-group navigation test, and the JSM will be loaded in different
+ // processes.
+ const uri = this.messageHandler.window.document.baseURI;
+ if (!uri.includes("example.net")) {
+ await new Promise(r => {});
+ }
+
+ return { ...params };
+ }
+
+ // Resolves only if called more than once.
+ async blockedOneTime(params) {
+ callsToBlockedOneTime++;
+ if (callsToBlockedOneTime < 2) {
+ await new Promise(r => {});
+ }
+
+ // Return:
+ // - params sent to the command to check that retries have correct params
+ // - the call counter
+ return { ...params, callsToCommand: callsToBlockedOneTime };
+ }
+
+ // Resolves only if called more than ten times (which is exactly the maximum
+ // of retry attempts).
+ async blockedTenTimes(params) {
+ callsToBlockedTenTimes++;
+ if (callsToBlockedTenTimes < 11) {
+ await new Promise(r => {});
+ }
+
+ // Return:
+ // - params sent to the command to check that retries have correct params
+ // - the call counter
+ return { ...params, callsToCommand: callsToBlockedTenTimes };
+ }
+
+ // Resolves only if called more than eleven times (which is greater than the
+ // maximum of retry attempts).
+ async blockedElevenTimes(params) {
+ callsToBlockedElevenTimes++;
+ if (callsToBlockedElevenTimes < 12) {
+ await new Promise(r => {});
+ }
+
+ // Return:
+ // - params sent to the command to check that retries have correct params
+ // - the call counter
+ return { ...params, callsToCommand: callsToBlockedElevenTimes };
+ }
+
+ cleanup() {
+ callsToBlockedOneTime = 0;
+ callsToBlockedTenTimes = 0;
+ callsToBlockedElevenTimes = 0;
+ }
+}
+
+export const retry = RetryModule;
diff --git a/remote/shared/messagehandler/test/browser/webdriver/browser.ini b/remote/shared/messagehandler/test/browser/webdriver/browser.ini
new file mode 100644
index 0000000000..a7ece66163
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/webdriver/browser.ini
@@ -0,0 +1,9 @@
+[DEFAULT]
+tags = remote
+subsuite = remote
+support-files =
+ !/remote/shared/messagehandler/test/browser/resources/*
+prefs =
+ remote.messagehandler.modulecache.useBrowserTestRoot=true
+
+[browser_session_execute_command_errors.js]
diff --git a/remote/shared/messagehandler/test/browser/webdriver/browser_session_execute_command_errors.js b/remote/shared/messagehandler/test/browser/webdriver/browser_session_execute_command_errors.js
new file mode 100644
index 0000000000..36a510bb29
--- /dev/null
+++ b/remote/shared/messagehandler/test/browser/webdriver/browser_session_execute_command_errors.js
@@ -0,0 +1,40 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { WebDriverSession } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/webdriver/Session.sys.mjs"
+);
+
+const { error } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/webdriver/Errors.sys.mjs"
+);
+
+add_task(async function test_execute_missing_command_error() {
+ const session = new WebDriverSession();
+
+ info("Attempt to execute an unknown protocol command");
+ await Assert.rejects(
+ session.execute("command", "missingCommand"),
+ err =>
+ err.name == "UnknownCommandError" &&
+ err.message == `command.missingCommand`
+ );
+});
+
+add_task(async function test_execute_missing_internal_command_error() {
+ const session = new WebDriverSession();
+
+ info(
+ "Attempt to execute a protocol command which relies on an unknown internal method"
+ );
+ await Assert.rejects(
+ session.execute("command", "testMissingIntermediaryMethod"),
+ err =>
+ err.name == "UnsupportedCommandError" &&
+ err.message ==
+ `command.missingMethod not supported for destination ROOT` &&
+ !error.isWebDriverError(err)
+ );
+});
diff --git a/remote/shared/messagehandler/test/xpcshell/test_Errors.js b/remote/shared/messagehandler/test/xpcshell/test_Errors.js
new file mode 100644
index 0000000000..33cfb5cfd7
--- /dev/null
+++ b/remote/shared/messagehandler/test/xpcshell/test_Errors.js
@@ -0,0 +1,99 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { error } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/Errors.sys.mjs"
+);
+
+// Note: this test file is similar to remote/shared/webdriver/test/xpcshell/test_Errors.js
+// because shared/webdriver/Errors.jsm and shared/messagehandler/Errors.jsm share
+// similar helpers.
+
+add_test(function test_toJSON() {
+ let e0 = new error.MessageHandlerError();
+ let e0s = e0.toJSON();
+ equal(e0s.error, "message handler error");
+ equal(e0s.message, "");
+
+ let e1 = new error.MessageHandlerError("a");
+ let e1s = e1.toJSON();
+ equal(e1s.message, e1.message);
+
+ let e2 = new error.UnsupportedCommandError("foo");
+ let e2s = e2.toJSON();
+ equal(e2.status, e2s.error);
+ equal(e2.message, e2s.message);
+
+ run_next_test();
+});
+
+add_test(function test_fromJSON() {
+ Assert.throws(
+ () => error.MessageHandlerError.fromJSON({ error: "foo" }),
+ /Not of MessageHandlerError descent/
+ );
+ Assert.throws(
+ () => error.MessageHandlerError.fromJSON({ error: "Error" }),
+ /Not of MessageHandlerError descent/
+ );
+ Assert.throws(
+ () => error.MessageHandlerError.fromJSON({}),
+ /Undeserialisable error type/
+ );
+ Assert.throws(
+ () => error.MessageHandlerError.fromJSON(undefined),
+ /TypeError/
+ );
+
+ let e1 = new error.MessageHandlerError("1");
+ let e1r = error.MessageHandlerError.fromJSON({
+ error: "message handler error",
+ message: "1",
+ });
+ ok(e1r instanceof error.MessageHandlerError);
+ equal(e1r.name, e1.name);
+ equal(e1r.status, e1.status);
+ equal(e1r.message, e1.message);
+
+ let e2 = new error.UnsupportedCommandError("foo");
+ let e2r = error.MessageHandlerError.fromJSON({
+ error: "unsupported message handler command",
+ message: "foo",
+ });
+ ok(e2r instanceof error.MessageHandlerError);
+ ok(e2r instanceof error.UnsupportedCommandError);
+ equal(e2r.name, e2.name);
+ equal(e2r.status, e2.status);
+ equal(e2r.message, e2.message);
+
+ // parity with toJSON
+ let e3 = new error.UnsupportedCommandError("foo");
+ let e3toJSON = e3.toJSON();
+ let e3fromJSON = error.MessageHandlerError.fromJSON(e3toJSON);
+ equal(e3toJSON.error, e3fromJSON.status);
+ equal(e3toJSON.message, e3fromJSON.message);
+ equal(e3toJSON.stacktrace, e3fromJSON.stack);
+
+ run_next_test();
+});
+
+add_test(function test_MessageHandlerError() {
+ let err = new error.MessageHandlerError("foo");
+ equal("MessageHandlerError", err.name);
+ equal("foo", err.message);
+ equal("message handler error", err.status);
+ ok(err instanceof error.MessageHandlerError);
+
+ run_next_test();
+});
+
+add_test(function test_UnsupportedCommandError() {
+ let e = new error.UnsupportedCommandError("foo");
+ equal("UnsupportedCommandError", e.name);
+ equal("foo", e.message);
+ equal("unsupported message handler command", e.status);
+ ok(e instanceof error.MessageHandlerError);
+
+ run_next_test();
+});
diff --git a/remote/shared/messagehandler/test/xpcshell/test_SessionData.js b/remote/shared/messagehandler/test/xpcshell/test_SessionData.js
new file mode 100644
index 0000000000..ef61ce27d4
--- /dev/null
+++ b/remote/shared/messagehandler/test/xpcshell/test_SessionData.js
@@ -0,0 +1,296 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { ContextDescriptorType } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/MessageHandler.sys.mjs"
+);
+const { RootMessageHandler } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs"
+);
+const { SessionData, SessionDataMethod } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/messagehandler/sessiondata/SessionData.sys.mjs"
+);
+
+add_task(async function test_sessionData() {
+ const sessionData = new SessionData(new RootMessageHandler("session-id-1"));
+ equal(sessionData.getSessionData("mod", "event").length, 0);
+
+ const globalContext = {
+ type: ContextDescriptorType.All,
+ };
+ const otherContext = { type: "other-type", id: "some-id" };
+
+ info("Add a first event for the global context");
+ let updatedItems = sessionData.applySessionData([
+ createUpdate(SessionDataMethod.Add, globalContext, ["first.event"]),
+ ]);
+ equal(updatedItems.length, 1, "One item updated");
+ equal(updatedItems[0].method, SessionDataMethod.Add, "One item added");
+ let updatedValues = updatedItems[0].values;
+ equal(updatedValues.length, 1, "One value added");
+ equal(updatedValues[0], "first.event", "Expected value was added");
+ checkEvents(sessionData.getSessionData("mod", "event"), [
+ {
+ value: "first.event",
+ contextDescriptor: globalContext,
+ },
+ ]);
+
+ info("Add the exact same data (same module, type, context, value)");
+ updatedItems = sessionData.applySessionData([
+ createUpdate(SessionDataMethod.Add, globalContext, ["first.event"]),
+ ]);
+ equal(updatedItems.length, 0, "No new item updated");
+ checkEvents(sessionData.getSessionData("mod", "event"), [
+ {
+ value: "first.event",
+ contextDescriptor: globalContext,
+ },
+ ]);
+
+ info("Add another context for the same event");
+ updatedItems = sessionData.applySessionData([
+ createUpdate(SessionDataMethod.Add, otherContext, ["first.event"]),
+ ]);
+ equal(updatedItems.length, 1, "One item updated");
+ equal(updatedItems[0].method, SessionDataMethod.Add, "One item added");
+ updatedValues = updatedItems[0].values;
+ equal(updatedValues.length, 1, "One value added");
+ equal(updatedValues[0], "first.event", "Expected value was added");
+ checkEvents(sessionData.getSessionData("mod", "event"), [
+ {
+ value: "first.event",
+ contextDescriptor: globalContext,
+ },
+ {
+ value: "first.event",
+ contextDescriptor: otherContext,
+ },
+ ]);
+
+ info("Add a second event for the global context");
+ updatedItems = sessionData.applySessionData([
+ createUpdate(SessionDataMethod.Add, globalContext, ["second.event"]),
+ ]);
+ equal(updatedItems.length, 1, "One item updated");
+ equal(updatedItems[0].method, SessionDataMethod.Add, "One item added");
+ updatedValues = updatedItems[0].values;
+ equal(updatedValues.length, 1, "One value added");
+ equal(updatedValues[0], "second.event", "Expected value was added");
+ checkEvents(sessionData.getSessionData("mod", "event"), [
+ {
+ value: "first.event",
+ contextDescriptor: globalContext,
+ },
+ {
+ value: "first.event",
+ contextDescriptor: otherContext,
+ },
+ {
+ value: "second.event",
+ contextDescriptor: globalContext,
+ },
+ ]);
+
+ info("Add two events for the global context");
+ updatedItems = sessionData.applySessionData([
+ createUpdate(SessionDataMethod.Add, globalContext, [
+ "third.event",
+ "fourth.event",
+ ]),
+ ]);
+ equal(updatedItems.length, 1, "One item updated");
+ equal(updatedItems[0].method, SessionDataMethod.Add, "One item added");
+ updatedValues = updatedItems[0].values;
+ equal(updatedValues.length, 2, "Two values added");
+ equal(updatedValues[0], "third.event", "Expected value was added");
+ equal(updatedValues[1], "fourth.event", "Expected value was added");
+ checkEvents(sessionData.getSessionData("mod", "event"), [
+ {
+ value: "first.event",
+ contextDescriptor: globalContext,
+ },
+ {
+ value: "first.event",
+ contextDescriptor: otherContext,
+ },
+ {
+ value: "second.event",
+ contextDescriptor: globalContext,
+ },
+ {
+ value: "third.event",
+ contextDescriptor: globalContext,
+ },
+ {
+ value: "fourth.event",
+ contextDescriptor: globalContext,
+ },
+ ]);
+
+ info("Remove the second, third and fourth events");
+ updatedItems = sessionData.applySessionData([
+ createUpdate(SessionDataMethod.Remove, globalContext, [
+ "second.event",
+ "third.event",
+ "fourth.event",
+ ]),
+ ]);
+ equal(updatedItems.length, 1, "One item updated");
+ equal(updatedItems[0].method, SessionDataMethod.Remove, "One item removed");
+ updatedValues = updatedItems[0].values;
+ equal(updatedValues.length, 3, "Three values removed");
+ equal(updatedValues[0], "second.event", "Expected value was removed");
+ equal(updatedValues[1], "third.event", "Expected value was removed");
+ equal(updatedValues[2], "fourth.event", "Expected value was removed");
+ checkEvents(sessionData.getSessionData("mod", "event"), [
+ {
+ value: "first.event",
+ contextDescriptor: globalContext,
+ },
+ {
+ value: "first.event",
+ contextDescriptor: otherContext,
+ },
+ ]);
+
+ info("Remove the global context from the first event");
+ updatedItems = sessionData.applySessionData([
+ createUpdate(SessionDataMethod.Remove, globalContext, ["first.event"]),
+ ]);
+ equal(updatedItems.length, 1, "One item updated");
+ equal(updatedItems[0].method, SessionDataMethod.Remove, "One item removed");
+ updatedValues = updatedItems[0].values;
+ equal(updatedValues.length, 1, "One value removed");
+ equal(updatedValues[0], "first.event", "Expected value was removed");
+ checkEvents(sessionData.getSessionData("mod", "event"), [
+ {
+ value: "first.event",
+ contextDescriptor: otherContext,
+ },
+ ]);
+
+ info("Remove the other context from the first event");
+ updatedItems = sessionData.applySessionData([
+ createUpdate(SessionDataMethod.Remove, otherContext, ["first.event"]),
+ ]);
+ equal(updatedItems.length, 1, "One item updated");
+ equal(updatedItems[0].method, SessionDataMethod.Remove, "One item removed");
+ updatedValues = updatedItems[0].values;
+ equal(updatedValues.length, 1, "One value removed");
+ equal(updatedValues[0], "first.event", "Expected value was removed");
+ checkEvents(sessionData.getSessionData("mod", "event"), []);
+
+ info("Add two events for different contexts");
+ updatedItems = sessionData.applySessionData([
+ createUpdate(SessionDataMethod.Add, otherContext, ["first.event"]),
+ createUpdate(SessionDataMethod.Add, globalContext, ["second.event"]),
+ ]);
+ equal(updatedItems.length, 2, "Two items updated");
+ equal(updatedItems[0].method, SessionDataMethod.Add, "First item added");
+ updatedValues = updatedItems[0].values;
+ equal(updatedValues.length, 1, "One value for first item added");
+ equal(updatedValues[0], "first.event", "Expected value first item was added");
+ equal(updatedItems[1].method, SessionDataMethod.Add, "Second item added");
+ updatedValues = updatedItems[1].values;
+ equal(updatedValues.length, 1, "One value for second item added");
+ equal(
+ updatedValues[0],
+ "second.event",
+ "Expected value second item was added"
+ );
+ checkEvents(sessionData.getSessionData("mod", "event"), [
+ {
+ value: "first.event",
+ contextDescriptor: otherContext,
+ },
+ {
+ value: "second.event",
+ contextDescriptor: globalContext,
+ },
+ ]);
+
+ info("Remove two events for different contexts");
+ updatedItems = sessionData.applySessionData([
+ createUpdate(SessionDataMethod.Remove, otherContext, ["first.event"]),
+ createUpdate(SessionDataMethod.Remove, globalContext, ["second.event"]),
+ ]);
+ equal(updatedItems.length, 2, "Two items updated");
+ equal(updatedItems[0].method, SessionDataMethod.Remove, "First item removed");
+ updatedValues = updatedItems[0].values;
+ equal(updatedValues.length, 1, "One value for first item removed");
+ equal(
+ updatedValues[0],
+ "first.event",
+ "Expected value first item was removed"
+ );
+ equal(
+ updatedItems[1].method,
+ SessionDataMethod.Remove,
+ "Second item removed"
+ );
+ updatedValues = updatedItems[1].values;
+ equal(updatedValues.length, 1, "One value for second item removed");
+ equal(
+ updatedValues[0],
+ "second.event",
+ "Expected value second item was removed"
+ );
+ checkEvents(sessionData.getSessionData("mod", "event"), []);
+
+ info("Add and remove event in different order");
+ updatedItems = sessionData.applySessionData([
+ createUpdate(SessionDataMethod.Remove, otherContext, ["first.event"]),
+ createUpdate(SessionDataMethod.Add, otherContext, ["first.event"]),
+ ]);
+ equal(updatedItems.length, 1, "One item updated");
+ equal(updatedItems[0].method, SessionDataMethod.Add, "One item added");
+ updatedValues = updatedItems[0].values;
+ equal(updatedValues.length, 1, "One value added");
+ equal(updatedValues[0], "first.event", "Expected value was added");
+ checkEvents(sessionData.getSessionData("mod", "event"), [
+ {
+ value: "first.event",
+ contextDescriptor: otherContext,
+ },
+ ]);
+
+ updatedItems = sessionData.applySessionData([
+ createUpdate(SessionDataMethod.Add, otherContext, ["first.event"]),
+ createUpdate(SessionDataMethod.Remove, otherContext, ["first.event"]),
+ ]);
+ equal(updatedItems.length, 1, "No item update");
+ equal(updatedItems[0].method, SessionDataMethod.Remove, "One item removed");
+ updatedValues = updatedItems[0].values;
+ equal(updatedValues.length, 1, "One value removed");
+ equal(updatedValues[0], "first.event", "Expected value was removed");
+ checkEvents(sessionData.getSessionData("mod", "event"), []);
+});
+
+function checkEvents(events, expectedEvents) {
+ // Check the arrays have the same size.
+ equal(events.length, expectedEvents.length);
+
+ // Check all the expectedEvents can be found in the events array.
+ for (const expected of expectedEvents) {
+ ok(
+ events.some(
+ event =>
+ expected.contextDescriptor.type === event.contextDescriptor.type &&
+ expected.contextDescriptor.id === event.contextDescriptor.id &&
+ expected.value == event.value
+ )
+ );
+ }
+}
+
+function createUpdate(method, contextDescriptor, values) {
+ return {
+ method,
+ moduleName: "mod",
+ category: "event",
+ contextDescriptor,
+ values,
+ };
+}
diff --git a/remote/shared/messagehandler/test/xpcshell/xpcshell.ini b/remote/shared/messagehandler/test/xpcshell/xpcshell.ini
new file mode 100644
index 0000000000..9199459e0a
--- /dev/null
+++ b/remote/shared/messagehandler/test/xpcshell/xpcshell.ini
@@ -0,0 +1,6 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+[test_Errors.js]
+[test_SessionData.js]
diff --git a/remote/shared/messagehandler/transports/FrameContextUtils.sys.mjs b/remote/shared/messagehandler/transports/FrameContextUtils.sys.mjs
new file mode 100644
index 0000000000..341e7918ee
--- /dev/null
+++ b/remote/shared/messagehandler/transports/FrameContextUtils.sys.mjs
@@ -0,0 +1,57 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+function isExtensionContext(browsingContext) {
+ let principal;
+ if (CanonicalBrowsingContext.isInstance(browsingContext)) {
+ principal = browsingContext.currentWindowGlobal.documentPrincipal;
+ } else {
+ principal = browsingContext.window.document.nodePrincipal;
+ }
+
+ // In practice, note that the principal will never be an expanded principal.
+ // The are only used for content scripts executed in a Sandbox, and do not
+ // have a browsing context on their own.
+ // But we still use this flag because there is no isAddonPrincipal flag.
+ return principal.isAddonOrExpandedAddonPrincipal;
+}
+
+function isParentProcess(browsingContext) {
+ if (CanonicalBrowsingContext.isInstance(browsingContext)) {
+ return browsingContext.currentWindowGlobal.osPid === -1;
+ }
+
+ // If `browsingContext` is not a `CanonicalBrowsingContext`, then we are
+ // necessarily in a content process page.
+ return false;
+}
+
+/**
+ * Check if the given browsing context is valid for the message handler
+ * to use.
+ *
+ * @param {BrowsingContext} browsingContext
+ * The browsing context to check.
+ * @param {Object=} options
+ * @param {String=} options.browserId
+ * The id of the browser to filter the browsing contexts by (optional).
+ * @return {Boolean}
+ * True if the browsing context is valid, false otherwise.
+ */
+export function isBrowsingContextCompatible(browsingContext, options = {}) {
+ const { browserId } = options;
+
+ // If a browserId was provided, skip browsing contexts which are not
+ // associated with this browserId.
+ if (browserId !== undefined && browsingContext.browserId !== browserId) {
+ return false;
+ }
+
+ // Skip:
+ // - extension contexts until we support debugging webextensions, see Bug 1755014.
+ // - privileged contexts until we support debugging Chrome context, see Bug 1713440.
+ return (
+ !isExtensionContext(browsingContext) && !isParentProcess(browsingContext)
+ );
+}
diff --git a/remote/shared/messagehandler/transports/FrameTransport.sys.mjs b/remote/shared/messagehandler/transports/FrameTransport.sys.mjs
new file mode 100644
index 0000000000..e2d48e00f4
--- /dev/null
+++ b/remote/shared/messagehandler/transports/FrameTransport.sys.mjs
@@ -0,0 +1,191 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ContextDescriptorType:
+ "chrome://remote/content/shared/messagehandler/MessageHandler.sys.mjs",
+ isBrowsingContextCompatible:
+ "chrome://remote/content/shared/messagehandler/transports/FrameContextUtils.sys.mjs",
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+ MessageHandlerFrameActor:
+ "chrome://remote/content/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameActor.sys.mjs",
+ TabManager: "chrome://remote/content/shared/TabManager.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
+
+const MAX_RETRY_ATTEMPTS = 10;
+
+/**
+ * FrameTransport is intended to be used from a ROOT MessageHandler to communicate
+ * with WINDOW_GLOBAL MessageHandlers via the MessageHandlerFrame JSWindow
+ * actors.
+ */
+export class FrameTransport {
+ /**
+ * @param {MessageHandler}
+ * The MessageHandler instance which owns this FrameTransport instance.
+ */
+ constructor(messageHandler) {
+ this._messageHandler = messageHandler;
+
+ // FrameTransport will rely on the MessageHandlerFrame JSWindow actors.
+ // Make sure they are registered when instanciating a FrameTransport.
+ lazy.MessageHandlerFrameActor.register();
+ }
+
+ /**
+ * Forward the provided command to WINDOW_GLOBAL MessageHandlers via the
+ * MessageHandlerFrame actors.
+ *
+ * @param {Command} command
+ * The command to forward. See type definition in MessageHandler.js
+ * @return {Promise}
+ * Returns a promise that resolves with the result of the command after
+ * being processed by WINDOW_GLOBAL MessageHandlers.
+ */
+ forwardCommand(command) {
+ if (command.destination.id && command.destination.contextDescriptor) {
+ throw new Error(
+ "Invalid command destination with both 'id' and 'contextDescriptor' properties"
+ );
+ }
+
+ // With an id given forward the command to only this specific destination.
+ if (command.destination.id) {
+ const browsingContext = BrowsingContext.get(command.destination.id);
+ if (!browsingContext) {
+ throw new Error(
+ "Unable to find a BrowsingContext for id " + command.destination.id
+ );
+ }
+ return this._sendCommandToBrowsingContext(command, browsingContext);
+ }
+
+ // ... otherwise broadcast to destinations matching the contextDescriptor.
+ if (command.destination.contextDescriptor) {
+ return this._broadcastCommand(command);
+ }
+
+ throw new Error(
+ "Unrecognized command destination, missing 'id' or 'contextDescriptor' properties"
+ );
+ }
+
+ _broadcastCommand(command) {
+ const { contextDescriptor } = command.destination;
+ const browsingContexts = this._getBrowsingContextsForDescriptor(
+ contextDescriptor
+ );
+
+ return Promise.all(
+ browsingContexts.map(async browsingContext => {
+ try {
+ return await this._sendCommandToBrowsingContext(
+ command,
+ browsingContext
+ );
+ } catch (e) {
+ console.error(
+ `Failed to broadcast a command to browsingContext ${browsingContext.id}`,
+ e
+ );
+ return null;
+ }
+ })
+ );
+ }
+
+ async _sendCommandToBrowsingContext(command, browsingContext) {
+ const name = `${command.moduleName}.${command.commandName}`;
+
+ // The browsing context might be destroyed by a navigation. Keep a reference
+ // to the webProgress, which will persist, and always use it to retrieve the
+ // currently valid browsing context.
+ const webProgress = browsingContext.webProgress;
+
+ const { retryOnAbort = false } = command;
+
+ let attempts = 0;
+ while (true) {
+ try {
+ return await webProgress.browsingContext.currentWindowGlobal
+ .getActor("MessageHandlerFrame")
+ .sendCommand(command, this._messageHandler.sessionId);
+ } catch (e) {
+ if (!retryOnAbort || e.name != "AbortError") {
+ // Only retry if the command supports retryOnAbort and when the
+ // JSWindowActor pair gets destroyed.
+ throw e;
+ }
+
+ if (++attempts > MAX_RETRY_ATTEMPTS) {
+ lazy.logger.trace(
+ `FrameTransport reached the limit of retry attempts (${MAX_RETRY_ATTEMPTS})` +
+ ` for command ${name} and browsing context ${webProgress.browsingContext.id}.`
+ );
+ throw e;
+ }
+
+ lazy.logger.trace(
+ `FrameTransport retrying command ${name} for ` +
+ `browsing context ${webProgress.browsingContext.id}, attempt: ${attempts}.`
+ );
+ await new Promise(resolve => Services.tm.dispatchToMainThread(resolve));
+ }
+ }
+ }
+
+ toString() {
+ return `[object ${this.constructor.name} ${this._messageHandler.name}]`;
+ }
+
+ _getBrowsingContextsForDescriptor(contextDescriptor) {
+ const { id, type } = contextDescriptor;
+
+ if (type === lazy.ContextDescriptorType.All) {
+ return this._getBrowsingContexts();
+ }
+
+ if (type === lazy.ContextDescriptorType.TopBrowsingContext) {
+ return this._getBrowsingContexts({ browserId: id });
+ }
+
+ // TODO: Handle other types of context descriptors.
+ throw new Error(
+ `Unsupported contextDescriptor type for broadcasting: ${type}`
+ );
+ }
+
+ /**
+ * Get all browsing contexts, optionally matching the provided options.
+ *
+ * @param {Object} options
+ * @param {String=} options.browserId
+ * The id of the browser to filter the browsing contexts by (optional).
+ * @return {Array<BrowsingContext>}
+ * The browsing contexts matching the provided options or all browsing contexts
+ * if no options are provided.
+ */
+ _getBrowsingContexts(options = {}) {
+ // extract browserId from options
+ const { browserId } = options;
+ let browsingContexts = [];
+
+ // Fetch all tab related browsing contexts for top-level windows.
+ for (const { browsingContext } of lazy.TabManager.browsers) {
+ if (lazy.isBrowsingContextCompatible(browsingContext, { browserId })) {
+ browsingContexts = browsingContexts.concat(
+ browsingContext.getAllBrowsingContextsInSubtree()
+ );
+ }
+ }
+
+ return browsingContexts;
+ }
+}
diff --git a/remote/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameActor.sys.mjs b/remote/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameActor.sys.mjs
new file mode 100644
index 0000000000..b0ec801098
--- /dev/null
+++ b/remote/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameActor.sys.mjs
@@ -0,0 +1,51 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ActorManagerParent: "resource://gre/modules/ActorManagerParent.sys.mjs",
+
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
+
+const FRAME_ACTOR_CONFIG = {
+ parent: {
+ esModuleURI:
+ "chrome://remote/content/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameParent.sys.mjs",
+ },
+ child: {
+ esModuleURI:
+ "chrome://remote/content/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameChild.sys.mjs",
+ events: {
+ DOMWindowCreated: {},
+ },
+ },
+ allFrames: true,
+ messageManagerGroups: ["browsers"],
+};
+
+/**
+ * MessageHandlerFrameActor exposes a simple registration helper to lazily
+ * register MessageHandlerFrame JSWindow actors.
+ */
+export const MessageHandlerFrameActor = {
+ registered: false,
+
+ register() {
+ if (this.registered) {
+ return;
+ }
+
+ lazy.ActorManagerParent.addJSWindowActors({
+ MessageHandlerFrame: FRAME_ACTOR_CONFIG,
+ });
+ this.registered = true;
+ lazy.logger.trace("Registered MessageHandlerFrame actors");
+ },
+};
diff --git a/remote/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameChild.sys.mjs b/remote/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameChild.sys.mjs
new file mode 100644
index 0000000000..4f0430df1b
--- /dev/null
+++ b/remote/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameChild.sys.mjs
@@ -0,0 +1,77 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ isBrowsingContextCompatible:
+ "chrome://remote/content/shared/messagehandler/transports/FrameContextUtils.sys.mjs",
+ MessageHandlerRegistry:
+ "chrome://remote/content/shared/messagehandler/MessageHandlerRegistry.sys.mjs",
+ WindowGlobalMessageHandler:
+ "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs",
+});
+
+/**
+ * Child actor for the MessageHandlerFrame JSWindowActor. The
+ * MessageHandlerFrame actor is used by FrameTransport to communicate between
+ * ROOT MessageHandlers and WINDOW_GLOBAL MessageHandlers.
+ */
+export class MessageHandlerFrameChild extends JSWindowActorChild {
+ actorCreated() {
+ this.type = lazy.WindowGlobalMessageHandler.type;
+ this.context = this.manager.browsingContext;
+
+ this._registry = new lazy.MessageHandlerRegistry(this.type, this.context);
+ this._onRegistryEvent = this._onRegistryEvent.bind(this);
+
+ // MessageHandlerFrameChild is responsible for forwarding events from
+ // WindowGlobalMessageHandler to the parent process.
+ // Such events are re-emitted on the MessageHandlerRegistry to avoid
+ // setting up listeners on individual MessageHandler instances.
+ this._registry.on("message-handler-registry-event", this._onRegistryEvent);
+ }
+
+ handleEvent({ type }) {
+ if (type == "DOMWindowCreated") {
+ if (lazy.isBrowsingContextCompatible(this.manager.browsingContext)) {
+ this._registry.createAllMessageHandlers();
+ }
+ }
+ }
+
+ async receiveMessage(message) {
+ if (message.name === "MessageHandlerFrameParent:sendCommand") {
+ const { sessionId, command } = message.data;
+ const messageHandler = this._registry.getOrCreateMessageHandler(
+ sessionId
+ );
+ try {
+ return await messageHandler.handleCommand(command);
+ } catch (e) {
+ if (e?.isRemoteError) {
+ return {
+ error: e.toJSON(),
+ isMessageHandlerError: e.isMessageHandlerError,
+ };
+ }
+ throw e;
+ }
+ }
+
+ return null;
+ }
+
+ _onRegistryEvent(eventName, wrappedEvent) {
+ this.sendAsyncMessage(
+ "MessageHandlerFrameChild:messageHandlerEvent",
+ wrappedEvent
+ );
+ }
+
+ didDestroy() {
+ this._registry.off("message-handler-registry-event", this._onRegistryEvent);
+ this._registry.destroy();
+ }
+}
diff --git a/remote/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameParent.sys.mjs b/remote/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameParent.sys.mjs
new file mode 100644
index 0000000000..9524fb08a5
--- /dev/null
+++ b/remote/shared/messagehandler/transports/js-window-actors/MessageHandlerFrameParent.sys.mjs
@@ -0,0 +1,99 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ error: "chrome://remote/content/shared/messagehandler/Errors.sys.mjs",
+ RootMessageHandlerRegistry:
+ "chrome://remote/content/shared/messagehandler/RootMessageHandlerRegistry.sys.mjs",
+ WindowGlobalMessageHandler:
+ "chrome://remote/content/shared/messagehandler/WindowGlobalMessageHandler.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "WebDriverError", () => {
+ return ChromeUtils.importESModule(
+ "chrome://remote/content/shared/webdriver/Errors.sys.mjs"
+ ).error.WebDriverError;
+});
+
+/**
+ * Parent actor for the MessageHandlerFrame JSWindowActor. The
+ * MessageHandlerFrame actor is used by FrameTransport to communicate between
+ * ROOT MessageHandlers and WINDOW_GLOBAL MessageHandlers.
+ */
+export class MessageHandlerFrameParent extends JSWindowActorParent {
+ async receiveMessage(message) {
+ switch (message.name) {
+ case "MessageHandlerFrameChild:messageHandlerEvent":
+ const { name, contextInfo, data, sessionId } = message.data;
+ const [moduleName] = name.split(".");
+
+ // Re-emit the event on the RootMessageHandler.
+ const messageHandler = lazy.RootMessageHandlerRegistry.getExistingMessageHandler(
+ sessionId
+ );
+ // TODO: getModuleInstance expects a CommandDestination in theory,
+ // but only uses the MessageHandler type in practice, see Bug 1776389.
+ const module = messageHandler.moduleCache.getModuleInstance(
+ moduleName,
+ { type: lazy.WindowGlobalMessageHandler.type }
+ );
+ let eventPayload = data;
+
+ // Modify an event payload if there is a special method in the targeted module.
+ // If present it can be found in windowglobal-in-root module.
+ if (module?.interceptEvent) {
+ eventPayload = await module.interceptEvent(name, data);
+
+ // Make sure that an event payload is returned.
+ if (!eventPayload) {
+ throw new Error(
+ `${moduleName}.interceptEvent doesn't return the event payload`
+ );
+ }
+ }
+ messageHandler.emitEvent(name, eventPayload, contextInfo);
+
+ break;
+ default:
+ throw new Error("Unsupported message:" + message.name);
+ }
+ }
+
+ /**
+ * Send a command to the corresponding MessageHandlerFrameChild actor via a
+ * JSWindowActor query.
+ *
+ * @param {Command} command
+ * The command to forward. See type definition in MessageHandler.js
+ * @param {String} sessionId
+ * ID of the session that sent the command.
+ * @return {Promise}
+ * Promise that will resolve with the result of query sent to the
+ * MessageHandlerFrameChild actor.
+ */
+ async sendCommand(command, sessionId) {
+ const result = await this.sendQuery(
+ "MessageHandlerFrameParent:sendCommand",
+ {
+ command,
+ sessionId,
+ }
+ );
+
+ if (result?.error) {
+ if (result.isMessageHandlerError) {
+ throw lazy.error.MessageHandlerError.fromJSON(result.error);
+ }
+
+ // TODO: Do not assume WebDriver is the session protocol, see Bug 1779026.
+ throw lazy.WebDriverError.fromJSON(result.error);
+ }
+
+ return result;
+ }
+}
diff --git a/remote/shared/moz.build b/remote/shared/moz.build
new file mode 100644
index 0000000000..56b1c2ff25
--- /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.ini",
+ "messagehandler/test/browser/broadcast/browser.ini",
+ "messagehandler/test/browser/browser.ini",
+ "messagehandler/test/browser/webdriver/browser.ini",
+ "test/browser/browser.ini",
+]
+
+XPCSHELL_TESTS_MANIFESTS += [
+ "messagehandler/test/xpcshell/xpcshell.ini",
+ "test/xpcshell/xpcshell.ini",
+ "webdriver/test/xpcshell/xpcshell.ini",
+]
diff --git a/remote/shared/test/browser/browser.ini b/remote/shared/test/browser/browser.ini
new file mode 100644
index 0000000000..af0889ca7f
--- /dev/null
+++ b/remote/shared/test/browser/browser.ini
@@ -0,0 +1,5 @@
+[DEFAULT]
+tags = remote
+subsuite = remote
+
+[browser_TabManager.js]
diff --git a/remote/shared/test/browser/browser_TabManager.js b/remote/shared/test/browser/browser_TabManager.js
new file mode 100644
index 0000000000..587b10fe51
--- /dev/null
+++ b/remote/shared/test/browser/browser_TabManager.js
@@ -0,0 +1,148 @@
+/* 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}`);
+ const loaded = BrowserTestUtils.browserLoaded(browser);
+ BrowserTestUtils.loadURI(browser, TEST_URL);
+ await loaded;
+
+ 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_getTabForBrowsingContext() {
+ const tab = await TabManager.addTab();
+ try {
+ const browser = tab.linkedBrowser;
+
+ info(`Navigate to ${TEST_URL}`);
+ const loaded = BrowserTestUtils.browserLoaded(browser);
+ BrowserTestUtils.loadURI(browser, TEST_URL);
+ await loaded;
+
+ 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/xpcshell/test_AppInfo.js b/remote/shared/test/xpcshell/test_AppInfo.js
new file mode 100644
index 0000000000..d97d0d0cbb
--- /dev/null
+++ b/remote/shared/test/xpcshell/test_AppInfo.js
@@ -0,0 +1,34 @@
+/* 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"
+);
+
+// Minimal xpcshell tests for AppInfo; Services.appinfo.* is not available
+
+add_test(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`
+ );
+ }
+
+ run_next_test();
+});
diff --git a/remote/shared/test/xpcshell/test_Format.js b/remote/shared/test/xpcshell/test_Format.js
new file mode 100644
index 0000000000..40c4afb60f
--- /dev/null
+++ b/remote/shared/test/xpcshell/test_Format.js
@@ -0,0 +1,119 @@
+/* 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_test(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}`
+ );
+
+ run_next_test();
+});
+
+add_test(function test_truncate_empty() {
+ equal(truncate``, "");
+ run_next_test();
+});
+
+add_test(function test_truncate_noFields() {
+ equal(truncate`foo bar`, "foo bar");
+ run_next_test();
+});
+
+add_test(function test_truncate_multipleFields() {
+ equal(truncate`${0}`, "0");
+ equal(truncate`${1}${2}${3}`, "123");
+ equal(truncate`a${1}b${2}c${3}`, "a1b2c3");
+ run_next_test();
+});
+
+add_test(function test_truncate_primitiveFields() {
+ equal(truncate`${123}`, "123");
+ equal(truncate`${true}`, "true");
+ equal(truncate`${null}`, "");
+ equal(truncate`${undefined}`, "");
+ run_next_test();
+});
+
+add_test(function test_truncate_string() {
+ equal(truncate`${"foo"}`, "foo");
+ equal(truncate`${"x".repeat(250)}`, "x".repeat(250));
+ equal(truncate`${"x".repeat(260)}`, `${HALF} ... ${HALF}`);
+ run_next_test();
+});
+
+add_test(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}`])
+ );
+
+ run_next_test();
+});
+
+add_test(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}`);
+
+ run_next_test();
+});
diff --git a/remote/shared/test/xpcshell/test_Navigate.js b/remote/shared/test/xpcshell/test_Navigate.js
new file mode 100644
index 0000000000..e85d71d307
--- /dev/null
+++ b/remote/shared/test/xpcshell/test_Navigate.js
@@ -0,0 +1,850 @@
+/* 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 {
+ 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");
+
+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_test(
+ 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");
+
+ run_next_test();
+ }
+);
+
+add_test(
+ 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");
+
+ run_next_test();
+ }
+);
+
+add_test(
+ 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");
+
+ run_next_test();
+ }
+);
+
+add_test(
+ 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");
+
+ run_next_test();
+ }
+);
+
+add_test(
+ 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();
+
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 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");
+
+ run_next_test();
+ }
+);
+
+add_test(
+ 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"
+ );
+
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 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");
+
+ run_next_test();
+ }
+);
+
+add_test(
+ 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");
+
+ run_next_test();
+ }
+);
+
+add_test(
+ 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");
+
+ run_next_test();
+ }
+);
+
+add_test(
+ 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");
+
+ run_next_test();
+ }
+);
+
+add_test(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");
+
+ run_next_test();
+});
+
+add_test(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");
+
+ run_next_test();
+});
+
+add_test(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
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 30));
+
+ ok(!(await hasPromiseResolved(navigated)), "Listener has not resolved yet");
+
+ await webProgress.sendStartState();
+ await webProgress.sendStopState();
+
+ ok(await hasPromiseResolved(navigated), "Listener has resolved");
+
+ run_next_test();
+});
+
+add_test(
+ 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
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 30));
+
+ ok(!(await hasPromiseResolved(navigated)), "Listener has not resolved yet");
+
+ await webProgress.sendStartState();
+ await webProgress.sendStopState();
+
+ ok(await hasPromiseResolved(navigated), "Listener has resolved");
+
+ run_next_test();
+ }
+);
+
+add_test(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);
+
+ run_next_test();
+});
+
+add_test(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"
+ );
+
+ run_next_test();
+});
+
+add_test(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"
+ );
+
+ run_next_test();
+});
+
+add_test(
+ 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"
+ );
+
+ run_next_test();
+ }
+);
+
+add_test(
+ 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"
+ );
+
+ run_next_test();
+ }
+);
+
+add_test(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");
+
+ run_next_test();
+});
+
+add_test(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"
+ );
+
+ run_next_test();
+});
+
+add_test(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"
+ );
+
+ run_next_test();
+});
+
+add_test(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");
+
+ run_next_test();
+});
+
+add_test(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");
+
+ run_next_test();
+});
+
+add_test(
+ 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");
+
+ run_next_test();
+ }
+);
diff --git a/remote/shared/test/xpcshell/test_RecommendedPreferences.js b/remote/shared/test/xpcshell/test_RecommendedPreferences.js
new file mode 100644
index 0000000000..d42482d1d8
--- /dev/null
+++ b/remote/shared/test/xpcshell/test_RecommendedPreferences.js
@@ -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 { RecommendedPreferences } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/RecommendedPreferences.sys.mjs"
+);
+
+const COMMON_PREF = "toolkit.startup.max_resumed_crashes";
+
+const MARIONETTE_PREF = "dom.disable_beforeunload";
+const MARIONETTE_RECOMMENDED_PREFS = new Map([[MARIONETTE_PREF, true]]);
+
+const CDP_PREF = "browser.contentblocking.features.standard";
+const CDP_RECOMMENDED_PREFS = new Map([
+ [CDP_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_RecommendedPreferences() {
+ info("Check initial values for the test preferences");
+ checkPreferences({ cdp: false, common: false, marionette: false });
+
+ checkPreferences({ cdp: false, common: false, marionette: false });
+
+ info("Apply recommended preferences for a marionette client");
+ RecommendedPreferences.applyPreferences(MARIONETTE_RECOMMENDED_PREFS);
+ checkPreferences({ cdp: false, common: true, marionette: true });
+
+ info("Apply recommended preferences for a cdp client");
+ RecommendedPreferences.applyPreferences(CDP_RECOMMENDED_PREFS);
+ checkPreferences({ cdp: true, common: true, marionette: true });
+
+ info("Restore marionette preferences");
+ RecommendedPreferences.restorePreferences(MARIONETTE_RECOMMENDED_PREFS);
+ checkPreferences({ cdp: true, common: true, marionette: false });
+
+ info("Restore cdp preferences");
+ RecommendedPreferences.restorePreferences(CDP_RECOMMENDED_PREFS);
+ checkPreferences({ cdp: false, common: true, marionette: false });
+
+ info("Restore all the altered preferences");
+ RecommendedPreferences.restoreAllPreferences();
+ checkPreferences({ cdp: false, common: false, marionette: false });
+
+ info("Attemps to restore again");
+ RecommendedPreferences.restoreAllPreferences();
+ checkPreferences({ cdp: false, common: false, marionette: false });
+
+ cleanup();
+});
+
+add_task(async function test_RecommendedPreferences_disabled() {
+ info("Disable RecommendedPreferences");
+ Services.prefs.setBoolPref("remote.prefs.recommended", false);
+
+ info("Check initial values for the test preferences");
+ checkPreferences({ cdp: false, common: false, marionette: false });
+
+ info("Recommended preferences are not applied, applyPreferences is a no-op");
+ RecommendedPreferences.applyPreferences(MARIONETTE_RECOMMENDED_PREFS);
+ checkPreferences({ cdp: false, common: false, marionette: false });
+
+ cleanup();
+});
+
+// Check that protocols can override common preferences.
+add_task(async function test_RecommendedPreferences_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({ cdp, common, marionette }) {
+ checkPreference(COMMON_PREF, { hasValue: common });
+ checkPreference(MARIONETTE_PREF, { hasValue: marionette });
+ checkPreference(CDP_PREF, { hasValue: cdp });
+}
+
+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..770f6b0758
--- /dev/null
+++ b/remote/shared/test/xpcshell/test_Sync.js
@@ -0,0 +1,393 @@
+/* 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");
+
+/**
+ * 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;
+ }
+}
+
+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);
+});
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/xpcshell.ini b/remote/shared/test/xpcshell/xpcshell.ini
new file mode 100644
index 0000000000..050b26ec20
--- /dev/null
+++ b/remote/shared/test/xpcshell/xpcshell.ini
@@ -0,0 +1,11 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+[test_AppInfo.js]
+[test_Format.js]
+[test_Navigate.js]
+[test_RecommendedPreferences.js]
+[test_Stack.js]
+[test_Sync.js]
+[test_TabManager.js]
diff --git a/remote/shared/webdriver/Assert.sys.mjs b/remote/shared/webdriver/Assert.sys.mjs
new file mode 100644
index 0000000000..ed0278308a
--- /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.
+ *
+ * @return {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.
+ *
+ * @return {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.
+ *
+ * @return {?}
+ * <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.
+ *
+ * @return {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.
+ *
+ * @return {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.
+ *
+ * @return {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.
+ *
+ * @return {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.
+ *
+ * @return {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.
+ *
+ * @return {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.
+ *
+ * @return {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.
+ *
+ * @return {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.
+ *
+ * @return {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.
+ *
+ * @return {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.
+ *
+ * @return {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.
+ *
+ * @return {?}
+ * 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.
+ *
+ * @return {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=} error
+ * Custom error type by its class.
+ *
+ * @return {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..7a1552746d
--- /dev/null
+++ b/remote/shared/webdriver/Capabilities.sys.mjs
@@ -0,0 +1,737 @@
+/* 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, {
+ Preferences: "resource://gre/modules/Preferences.sys.mjs",
+
+ 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",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "remoteAgent", () => {
+ return Cc["@mozilla.org/remote/agent;1"].createInstance(Ci.nsIRemoteAgent);
+});
+
+/** 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.
+ *
+ * @return {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":
+ lazy.Preferences.set("network.proxy.type", 4);
+ return true;
+
+ case "direct":
+ lazy.Preferences.set("network.proxy.type", 0);
+ return true;
+
+ case "manual":
+ lazy.Preferences.set("network.proxy.type", 1);
+
+ if (this.httpProxy) {
+ lazy.Preferences.set("network.proxy.http", this.httpProxy);
+ if (Number.isInteger(this.httpProxyPort)) {
+ lazy.Preferences.set("network.proxy.http_port", this.httpProxyPort);
+ }
+ }
+
+ if (this.sslProxy) {
+ lazy.Preferences.set("network.proxy.ssl", this.sslProxy);
+ if (Number.isInteger(this.sslProxyPort)) {
+ lazy.Preferences.set("network.proxy.ssl_port", this.sslProxyPort);
+ }
+ }
+
+ if (this.socksProxy) {
+ lazy.Preferences.set("network.proxy.socks", this.socksProxy);
+ if (Number.isInteger(this.socksProxyPort)) {
+ lazy.Preferences.set(
+ "network.proxy.socks_port",
+ this.socksProxyPort
+ );
+ }
+ if (this.socksVersion) {
+ lazy.Preferences.set(
+ "network.proxy.socks_version",
+ this.socksVersion
+ );
+ }
+ }
+
+ if (this.noProxy) {
+ lazy.Preferences.set(
+ "network.proxy.no_proxies_on",
+ this.noProxy.join(", ")
+ );
+ }
+ return true;
+
+ case "pac":
+ lazy.Preferences.set("network.proxy.type", 2);
+ lazy.Preferences.set(
+ "network.proxy.autoconfig_url",
+ this.proxyAutoconfigUrl
+ );
+ return true;
+
+ case "system":
+ lazy.Preferences.set("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;
+ }
+
+ /**
+ * @return {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:useNonSpecCompliantPointerOrigin", false],
+ ["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.
+ *
+ * @return {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.
+ *
+ * @return {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}"`
+ );
+
+ return Capabilities.match_(json);
+ }
+
+ // Matches capabilities as described by WebDriver.
+ static match_(json = {}) {
+ let matched = new Capabilities();
+
+ 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 "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":
+ lazy.assert.boolean(
+ v,
+ lazy.pprint`Expected ${k} to be boolean, got ${v}`
+ );
+ 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;
+ }
+
+ matched.set(k, v);
+ }
+
+ return matched;
+ }
+}
+
+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>";
+ }
+}
diff --git a/remote/shared/webdriver/Errors.sys.mjs b/remote/shared/webdriver/Errors.sys.mjs
new file mode 100644
index 0000000000..ddc88ae05f
--- /dev/null
+++ b/remote/shared/webdriver/Errors.sys.mjs
@@ -0,0 +1,559 @@
+/* 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",
+ "NoSuchShadowRootError",
+ "NoSuchWindowError",
+ "ScriptTimeoutError",
+ "SessionNotCreatedError",
+ "StaleElementReferenceError",
+ "TimeoutError",
+ "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.
+ * @return {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.
+ *
+ * @return {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.
+ *
+ * @return {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 {
+ /**
+ * @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 = "webdriver error";
+
+ // Error's ctor does not preserve x' stack
+ if (error.isError(x)) {
+ this.stack = x.stack;
+ }
+ }
+
+ /**
+ * @return {Object.<string, string>}
+ * JSON serialisation of error prototype.
+ */
+ toJSON() {
+ return {
+ error: this.status,
+ message: this.message || "",
+ stacktrace: this.stack || "",
+ };
+ }
+
+ /**
+ * Unmarshals a JSON error representation to the appropriate Marionette
+ * error type.
+ *
+ * @param {Object.<string, string>} json
+ * Error object.
+ *
+ * @return {Error}
+ * Error prototype.
+ */
+ static fromJSON(json) {
+ if (typeof json.error == "undefined") {
+ let s = JSON.stringify(json);
+ throw new TypeError("Undeserialisable error type: " + s);
+ }
+ if (!STATUSES.has(json.error)) {
+ throw new TypeError("Not of 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;
+ }
+ return err;
+ }
+}
+
+/** The Gecko a11y API indicates that the element is not accessible. */
+class ElementNotAccessibleError extends WebDriverError {
+ constructor(message) {
+ super(message);
+ 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 {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(obscuredEl = undefined, coords = undefined) {
+ let msg = "";
+ if (obscuredEl && coords) {
+ const doc = obscuredEl.ownerDocument;
+ const overlayingEl = doc.elementFromPoint(coords.x, coords.y);
+
+ switch (obscuredEl.style.pointerEvents) {
+ case "none":
+ msg =
+ lazy.pprint`Element ${obscuredEl} is not clickable ` +
+ `at point (${coords.x},${coords.y}) ` +
+ `because it does not have pointer events enabled, ` +
+ lazy.pprint`and element ${overlayingEl} ` +
+ `would receive the click instead`;
+ break;
+
+ default:
+ msg =
+ lazy.pprint`Element ${obscuredEl} is not clickable ` +
+ `at point (${coords.x},${coords.y}) ` +
+ lazy.pprint`because another element ${overlayingEl} ` +
+ `obscures it`;
+ break;
+ }
+ }
+
+ super(msg);
+ this.status = "element click intercepted";
+ }
+}
+
+/**
+ * A command could not be completed because the element is not pointer-
+ * or keyboard interactable.
+ */
+class ElementNotInteractableError extends WebDriverError {
+ constructor(message) {
+ super(message);
+ 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.
+ */
+class InsecureCertificateError extends WebDriverError {
+ constructor(message) {
+ super(message);
+ this.status = "insecure certificate";
+ }
+}
+
+/** The arguments passed to a command are either invalid or malformed. */
+class InvalidArgumentError extends WebDriverError {
+ constructor(message) {
+ super(message);
+ this.status = "invalid argument";
+ }
+}
+
+/**
+ * An illegal attempt was made to set a cookie under a different
+ * domain than the current page.
+ */
+class InvalidCookieDomainError extends WebDriverError {
+ constructor(message) {
+ super(message);
+ 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.
+ */
+class InvalidElementStateError extends WebDriverError {
+ constructor(message) {
+ super(message);
+ this.status = "invalid element state";
+ }
+}
+
+/** Argument was an invalid selector. */
+class InvalidSelectorError extends WebDriverError {
+ constructor(message) {
+ super(message);
+ 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.
+ */
+class InvalidSessionIDError extends WebDriverError {
+ constructor(message) {
+ super(message);
+ this.status = "invalid session id";
+ }
+}
+
+/** An error occurred whilst executing JavaScript supplied by the user. */
+class JavaScriptError extends WebDriverError {
+ constructor(x) {
+ super(x);
+ this.status = "javascript error";
+ }
+}
+
+/**
+ * The target for mouse interaction is not in the browser's viewport
+ * and cannot be brought into that viewport.
+ */
+class MoveTargetOutOfBoundsError extends WebDriverError {
+ constructor(message) {
+ super(message);
+ this.status = "move target out of bounds";
+ }
+}
+
+/**
+ * An attempt was made to operate on a modal dialog when one was
+ * not open.
+ */
+class NoSuchAlertError extends WebDriverError {
+ constructor(message) {
+ super(message);
+ this.status = "no such alert";
+ }
+}
+
+/**
+ * An element could not be located on the page using the given
+ * search parameters.
+ */
+class NoSuchElementError extends WebDriverError {
+ constructor(message) {
+ super(message);
+ this.status = "no such element";
+ }
+}
+
+/**
+ * A shadow root was not attached to the element.
+ */
+class NoSuchShadowRootError extends WebDriverError {
+ constructor(message) {
+ super(message);
+ this.status = "no such shadow root";
+ }
+}
+
+/**
+ * A shadow root is no longer attached to the document
+ */
+class DetachedShadowRootError extends WebDriverError {
+ constructor(message) {
+ super(message);
+ this.status = "detached shadow root";
+ }
+}
+
+/**
+ * A command to switch to a frame could not be satisfied because
+ * the frame could not be found.
+ */
+class NoSuchFrameError extends WebDriverError {
+ constructor(message) {
+ super(message);
+ this.status = "no such frame";
+ }
+}
+
+/**
+ * A command to switch to a window could not be satisfied because
+ * the window could not be found.
+ */
+class NoSuchWindowError extends WebDriverError {
+ constructor(message) {
+ super(message);
+ this.status = "no such window";
+ }
+}
+
+/** A script did not complete before its timeout expired. */
+class ScriptTimeoutError extends WebDriverError {
+ constructor(message) {
+ super(message);
+ this.status = "script timeout";
+ }
+}
+
+/** A new session could not be created. */
+class SessionNotCreatedError extends WebDriverError {
+ constructor(message) {
+ super(message);
+ this.status = "session not created";
+ }
+}
+
+/**
+ * A command failed because the referenced element is no longer
+ * attached to the DOM.
+ */
+class StaleElementReferenceError extends WebDriverError {
+ constructor(message) {
+ super(message);
+ this.status = "stale element reference";
+ }
+}
+
+/** An operation did not complete before its timeout expired. */
+class TimeoutError extends WebDriverError {
+ constructor(message) {
+ super(message);
+ this.status = "timeout";
+ }
+}
+
+/** A command to set a cookie's value could not be satisfied. */
+class UnableToSetCookieError extends WebDriverError {
+ constructor(message) {
+ super(message);
+ this.status = "unable to set cookie";
+ }
+}
+
+/** A modal dialog was open, blocking this operation. */
+class UnexpectedAlertOpenError extends WebDriverError {
+ constructor(message) {
+ super(message);
+ this.status = "unexpected alert open";
+ }
+}
+
+/**
+ * A command could not be executed because the remote end is not
+ * aware of it.
+ */
+class UnknownCommandError extends WebDriverError {
+ constructor(message) {
+ super(message);
+ this.status = "unknown command";
+ }
+}
+
+/**
+ * An unknown error occurred in the remote end while processing
+ * the command.
+ */
+class UnknownError extends WebDriverError {
+ constructor(message) {
+ super(message);
+ this.status = "unknown error";
+ }
+}
+
+/**
+ * Indicates that a command that should have executed properly
+ * cannot be supported for some reason.
+ */
+class UnsupportedOperationError extends WebDriverError {
+ constructor(message) {
+ super(message);
+ 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 shadow root", NoSuchShadowRootError],
+ ["no such window", NoSuchWindowError],
+ ["script timeout", ScriptTimeoutError],
+ ["session not created", SessionNotCreatedError],
+ ["stale element reference", StaleElementReferenceError],
+ ["timeout", TimeoutError],
+ ["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..5d2570686f
--- /dev/null
+++ b/remote/shared/webdriver/KeyData.sys.mjs
@@ -0,0 +1,340 @@
+/* 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 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: "", 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: "", key: "=" },
+ "\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: "OSLeft",
+ 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: "OSRight",
+ 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 = {};
+
+XPCOMUtils.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} key
+ * 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..a8de59cccf
--- /dev/null
+++ b/remote/shared/webdriver/NodeCache.sys.mjs
@@ -0,0 +1,134 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ContentDOMReference: "resource://gre/modules/ContentDOMReference.sys.mjs",
+
+ error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
+ pprint: "chrome://remote/content/shared/Format.sys.mjs",
+});
+
+/**
+ * The class provides a mapping between DOM nodes and unique element
+ * references by using `ContentDOMReference` identifiers.
+ */
+export class NodeCache {
+ #domRefs;
+ #sharedIds;
+
+ constructor() {
+ // ContentDOMReference id => shared unique id
+ this.#sharedIds = new Map();
+
+ // shared unique id => ContentDOMReference
+ this.#domRefs = new Map();
+ }
+
+ /**
+ * Get the number of elements in the cache.
+ */
+ get size() {
+ return this.#sharedIds.size;
+ }
+
+ /**
+ * Add a DOM element to the cache if not known yet.
+ *
+ * @param {Element} el
+ * The DOM Element to be added.
+ *
+ * @return {string}
+ * The shared id to uniquely identify the DOM element.
+ */
+ add(el) {
+ let domRef, sharedId;
+
+ try {
+ // Evaluation of code will take place in mutable sandboxes, which are
+ // created to waive xrays by default. As such DOM elements have to be
+ // unwaived before accessing the ownerGlobal if possible, which is
+ // needed by ContentDOMReference.
+ domRef = lazy.ContentDOMReference.get(Cu.unwaiveXrays(el));
+ } catch (e) {
+ throw new lazy.error.UnknownError(
+ lazy.pprint`Failed to create element reference for ${el}: ${e.message}`
+ );
+ }
+
+ if (this.#sharedIds.has(domRef.id)) {
+ // For already known elements retrieve the cached shared id.
+ sharedId = this.#sharedIds.get(domRef.id);
+ } else {
+ // For new elements generate a unique id without curly braces.
+ sharedId = Services.uuid
+ .generateUUID()
+ .toString()
+ .slice(1, -1);
+
+ this.#sharedIds.set(domRef.id, sharedId);
+ this.#domRefs.set(sharedId, domRef);
+ }
+
+ return sharedId;
+ }
+
+ /**
+ * Clears all known DOM elements.
+ *
+ * @param {Object=} options
+ * @param {boolean=} options.all
+ * Clear all references from any browsing context. Defaults to false.
+ * @param {BrowsingContext=} browsingContext
+ * Clear all references living in that browsing context.
+ */
+ clear(options = {}) {
+ const { all = false, browsingContext } = options;
+
+ if (all) {
+ this.#sharedIds.clear();
+ this.#domRefs.clear();
+ return;
+ }
+
+ if (browsingContext) {
+ for (const [sharedId, domRef] of this.#domRefs.entries()) {
+ if (domRef.browsingContextId === browsingContext.id) {
+ this.#sharedIds.delete(domRef.id);
+ this.#domRefs.delete(sharedId);
+ }
+ }
+ return;
+ }
+
+ throw new Error(`Requires "browsingContext" or "all" to be set.`);
+ }
+
+ /**
+ * Wrapper around ContentDOMReference.resolve with additional error handling
+ * specific to WebDriver.
+ *
+ * @param {string} sharedId
+ * The unique identifier for the DOM element.
+ *
+ * @return {Element|null}
+ * The DOM element that the unique identifier was generated for or
+ * `null` if the element does not exist anymore.
+ *
+ * @throws {NoSuchElementError}
+ * If the DOM element as represented by the unique WebElement reference
+ * <var>sharedId</var> isn't known.
+ */
+ resolve(sharedId) {
+ const domRef = this.#domRefs.get(sharedId);
+ if (domRef == undefined) {
+ throw new lazy.error.NoSuchElementError(
+ `Unknown element with id ${sharedId}`
+ );
+ }
+
+ return lazy.ContentDOMReference.resolve(domRef);
+ }
+}
diff --git a/remote/shared/webdriver/Session.sys.mjs b/remote/shared/webdriver/Session.sys.mjs
new file mode 100644
index 0000000000..f37c5e5e92
--- /dev/null
+++ b/remote/shared/webdriver/Session.sys.mjs
@@ -0,0 +1,348 @@
+/* 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, {
+ 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",
+ 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",
+ 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",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
+
+/**
+ * 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:useNonSpecCompliantPointerOrigin</code> (boolean)
+ * <dd>Use the not WebDriver conforming calculation of the pointer origin
+ * when the origin is an element, and the element center point is used.
+ *
+ * <dt><code>moz:webdriverClick</code> (boolean)
+ * <dd>Use a WebDriver conforming <i>WebDriver::ElementClick</i>.
+ * </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 = Services.uuid
+ .generateUUID()
+ .toString()
+ .slice(1, -1);
+
+ // 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);
+ }
+
+ lazy.registerProcessDataActor();
+ }
+
+ destroy() {
+ 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();
+ }
+
+ lazy.unregisterProcessDataActor();
+ }
+
+ 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"]);
+ }
+}
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..b7c6f8a857
--- /dev/null
+++ b/remote/shared/webdriver/process-actors/WebDriverProcessDataChild.sys.mjs
@@ -0,0 +1,95 @@
+/* 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",
+ NodeCache: "chrome://remote/content/shared/webdriver/NodeCache.sys.mjs",
+});
+
+XPCOMUtils.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=} 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..43c48fbec4
--- /dev/null
+++ b/remote/shared/webdriver/process-actors/WebDriverProcessDataParent.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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+});
+
+XPCOMUtils.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..81c0e3950a
--- /dev/null
+++ b/remote/shared/webdriver/test/xpcshell/head.js
@@ -0,0 +1,14 @@
+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_Assert.js b/remote/shared/webdriver/test/xpcshell/test_Assert.js
new file mode 100644
index 0000000000..a16ff77fb0
--- /dev/null
+++ b/remote/shared/webdriver/test/xpcshell/test_Assert.js
@@ -0,0 +1,215 @@
+/* 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-new-object */
+
+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_test(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/);
+
+ run_next_test();
+});
+
+add_test(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);
+
+ run_next_test();
+});
+
+add_test(function test_noUserPrompt() {
+ assert.noUserPrompt(null);
+ assert.noUserPrompt(undefined);
+ Assert.throws(() => assert.noUserPrompt({}), /UnexpectedAlertOpenError/);
+ Assert.throws(() => assert.noUserPrompt({}, "custom"), /custom/);
+
+ run_next_test();
+});
+
+add_test(function test_defined() {
+ assert.defined({});
+ Assert.throws(() => assert.defined(undefined), /InvalidArgumentError/);
+ Assert.throws(() => assert.noUserPrompt({}, "custom"), /custom/);
+
+ run_next_test();
+});
+
+add_test(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/);
+
+ run_next_test();
+});
+
+add_test(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/);
+
+ run_next_test();
+});
+
+add_test(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/);
+
+ run_next_test();
+});
+
+add_test(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/);
+
+ run_next_test();
+});
+
+add_test(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/);
+
+ run_next_test();
+});
+
+add_test(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/);
+
+ run_next_test();
+});
+
+add_test(function test_string() {
+ assert.string("foo");
+ assert.string(`bar`);
+ Assert.throws(() => assert.string(42), /InvalidArgumentError/);
+ Assert.throws(() => assert.string(42, "custom"), /custom/);
+
+ run_next_test();
+});
+
+add_test(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/);
+
+ run_next_test();
+});
+
+add_test(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/);
+
+ run_next_test();
+});
+
+add_test(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/);
+
+ run_next_test();
+});
+
+add_test(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/);
+
+ run_next_test();
+});
+
+add_test(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/);
+
+ run_next_test();
+});
+
+/* 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..4e655b63e2
--- /dev/null
+++ b/remote/shared/webdriver/test/xpcshell/test_Capabilities.js
@@ -0,0 +1,623 @@
+/* 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 { Preferences } = ChromeUtils.importESModule(
+ "resource://gre/modules/Preferences.sys.mjs"
+);
+
+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,
+ PageLoadStrategy,
+ Proxy,
+ Timeouts,
+ UnhandledPromptBehavior,
+} = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/webdriver/Capabilities.sys.mjs"
+);
+
+add_test(function test_Timeouts_ctor() {
+ let ts = new Timeouts();
+ equal(ts.implicit, 0);
+ equal(ts.pageLoad, 300000);
+ equal(ts.script, 30000);
+
+ run_next_test();
+});
+
+add_test(function test_Timeouts_toString() {
+ equal(new Timeouts().toString(), "[object Timeouts]");
+
+ run_next_test();
+});
+
+add_test(function test_Timeouts_toJSON() {
+ let ts = new Timeouts();
+ deepEqual(ts.toJSON(), { implicit: 0, pageLoad: 300000, script: 30000 });
+
+ run_next_test();
+});
+
+add_test(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);
+
+ run_next_test();
+});
+
+add_test(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");
+ }
+
+ run_next_test();
+});
+
+add_test(function test_Timeouts_fromJSON_invalid_types() {
+ for (let value of [null, [], {}, false, "10", 2.5]) {
+ Assert.throws(
+ () => Timeouts.fromJSON({ implicit: value }),
+ /InvalidArgumentError/
+ );
+ }
+
+ run_next_test();
+});
+
+add_test(function test_Timeouts_fromJSON_bounds() {
+ for (let value of [-1, Number.MAX_SAFE_INTEGER + 1]) {
+ Assert.throws(
+ () => Timeouts.fromJSON({ script: value }),
+ /InvalidArgumentError/
+ );
+ }
+
+ run_next_test();
+});
+
+add_test(function test_PageLoadStrategy() {
+ equal(PageLoadStrategy.None, "none");
+ equal(PageLoadStrategy.Eager, "eager");
+ equal(PageLoadStrategy.Normal, "normal");
+
+ run_next_test();
+});
+
+add_test(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);
+ }
+
+ run_next_test();
+});
+
+add_test(function test_Proxy_init() {
+ let p = new Proxy();
+
+ // no changed made, and 5 (system) is default
+ equal(p.init(), false);
+ equal(Preferences.get("network.proxy.type"), 5);
+
+ // pac
+ p.proxyType = "pac";
+ p.proxyAutoconfigUrl = "http://localhost:1234";
+ ok(p.init());
+
+ equal(Preferences.get("network.proxy.type"), 2);
+ equal(
+ Preferences.get("network.proxy.autoconfig_url"),
+ "http://localhost:1234"
+ );
+
+ // direct
+ p = new Proxy();
+ p.proxyType = "direct";
+ ok(p.init());
+ equal(Preferences.get("network.proxy.type"), 0);
+
+ // autodetect
+ p = new Proxy();
+ p.proxyType = "autodetect";
+ ok(p.init());
+ equal(Preferences.get("network.proxy.type"), 4);
+
+ // system
+ p = new Proxy();
+ p.proxyType = "system";
+ ok(p.init());
+ equal(Preferences.get("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(Preferences.get("network.proxy.type"), 1);
+ equal(Preferences.get("network.proxy.no_proxies_on"), "foo, bar");
+ equal(Preferences.get(`network.proxy.${proxy}`), "foo");
+ equal(Preferences.get(`network.proxy.${proxy}_port`), 42);
+ if (proxy === "socks") {
+ equal(Preferences.get(`network.proxy.${proxy}_version`), 4);
+ }
+ }
+
+ // empty no proxy should reset default exclustions
+ p = new Proxy();
+ p.proxyType = "manual";
+ p.noProxy = [];
+ ok(p.init());
+ equal(Preferences.get("network.proxy.no_proxies_on"), "");
+
+ run_next_test();
+});
+
+add_test(function test_Proxy_toString() {
+ equal(new Proxy().toString(), "[object Proxy]");
+
+ run_next_test();
+});
+
+add_test(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);
+
+ run_next_test();
+});
+
+add_test(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));
+
+ run_next_test();
+});
+
+add_test(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");
+
+ run_next_test();
+});
+
+add_test(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(false, caps.get("moz:useNonSpecCompliantPointerOrigin"));
+ equal(true, caps.get("moz:webdriverClick"));
+
+ run_next_test();
+});
+
+add_test(function test_Capabilities_toString() {
+ equal("[object Capabilities]", new Capabilities().toString());
+
+ run_next_test();
+});
+
+add_test(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:useNonSpecCompliantPointerOrigin"),
+ json["moz:useNonSpecCompliantPointerOrigin"]
+ );
+ equal(caps.get("moz:webdriverClick"), json["moz:webdriverClick"]);
+
+ run_next_test();
+});
+
+add_test(function test_Capabilities_fromJSON() {
+ const { fromJSON } = Capabilities;
+
+ // plain
+ for (let typ of [{}, null, undefined]) {
+ ok(fromJSON(typ).has("browserName"));
+ }
+ for (let typ of [true, 42, "foo", []]) {
+ Assert.throws(() => fromJSON(typ), /InvalidArgumentError/);
+ }
+
+ // matching
+ let caps = new Capabilities();
+
+ caps = fromJSON({ acceptInsecureCerts: true });
+ equal(true, caps.get("acceptInsecureCerts"));
+ caps = fromJSON({ acceptInsecureCerts: false });
+ equal(false, caps.get("acceptInsecureCerts"));
+ Assert.throws(
+ () => fromJSON({ acceptInsecureCerts: "foo" }),
+ /InvalidArgumentError/
+ );
+
+ for (let strategy of Object.values(PageLoadStrategy)) {
+ caps = fromJSON({ pageLoadStrategy: strategy });
+ equal(strategy, caps.get("pageLoadStrategy"));
+ }
+ Assert.throws(
+ () => fromJSON({ pageLoadStrategy: "foo" }),
+ /InvalidArgumentError/
+ );
+ Assert.throws(
+ () => fromJSON({ pageLoadStrategy: null }),
+ /InvalidArgumentError/
+ );
+
+ 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);
+
+ if (!AppInfo.isAndroid) {
+ caps = fromJSON({ setWindowRect: true });
+ equal(true, caps.get("setWindowRect"));
+ Assert.throws(
+ () => fromJSON({ setWindowRect: false }),
+ /InvalidArgumentError/
+ );
+ } else {
+ Assert.throws(
+ () => fromJSON({ setWindowRect: true }),
+ /InvalidArgumentError/
+ );
+ }
+
+ 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"));
+ Assert.throws(
+ () => fromJSON({ webSocketUrl: false }),
+ /InvalidArgumentError/
+ );
+ Assert.throws(
+ () => fromJSON({ webSocketUrl: "foo" }),
+ /InvalidArgumentError/
+ );
+
+ caps = fromJSON({ "moz:accessibilityChecks": true });
+ equal(true, caps.get("moz:accessibilityChecks"));
+ caps = fromJSON({ "moz:accessibilityChecks": false });
+ equal(false, caps.get("moz:accessibilityChecks"));
+ Assert.throws(
+ () => fromJSON({ "moz:accessibilityChecks": "foo" }),
+ /InvalidArgumentError/
+ );
+ Assert.throws(
+ () => fromJSON({ "moz:accessibilityChecks": 1 }),
+ /InvalidArgumentError/
+ );
+
+ // 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:useNonSpecCompliantPointerOrigin": false });
+ equal(false, caps.get("moz:useNonSpecCompliantPointerOrigin"));
+ caps = fromJSON({ "moz:useNonSpecCompliantPointerOrigin": true });
+ equal(true, caps.get("moz:useNonSpecCompliantPointerOrigin"));
+ Assert.throws(
+ () => fromJSON({ "moz:useNonSpecCompliantPointerOrigin": "foo" }),
+ /InvalidArgumentError/
+ );
+ Assert.throws(
+ () => fromJSON({ "moz:useNonSpecCompliantPointerOrigin": 1 }),
+ /InvalidArgumentError/
+ );
+
+ caps = fromJSON({ "moz:webdriverClick": true });
+ equal(true, caps.get("moz:webdriverClick"));
+ caps = fromJSON({ "moz:webdriverClick": false });
+ equal(false, caps.get("moz:webdriverClick"));
+ Assert.throws(
+ () => fromJSON({ "moz:webdriverClick": "foo" }),
+ /InvalidArgumentError/
+ );
+ Assert.throws(
+ () => fromJSON({ "moz:webdriverClick": 1 }),
+ /InvalidArgumentError/
+ );
+
+ run_next_test();
+});
+
+// use Proxy.toJSON to test marshal
+add_test(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());
+
+ run_next_test();
+});
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..1510198afe
--- /dev/null
+++ b/remote/shared/webdriver/test/xpcshell/test_Errors.js
@@ -0,0 +1,499 @@
+/* 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"
+);
+
+function notok(condition) {
+ ok(!condition);
+}
+
+add_test(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()));
+ ok(error.isError(new error.WebDriverError()));
+ ok(error.isError(new error.InvalidArgumentError()));
+
+ run_next_test();
+});
+
+add_test(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()));
+
+ ok(error.isWebDriverError(new error.WebDriverError()));
+ ok(error.isWebDriverError(new error.InvalidArgumentError()));
+ ok(error.isWebDriverError(new error.JavaScriptError()));
+
+ run_next_test();
+});
+
+add_test(function test_wrap() {
+ // webdriver-derived errors should not be wrapped
+ equal(error.wrap(new error.WebDriverError()).name, "WebDriverError");
+ ok(error.wrap(new error.WebDriverError()) instanceof error.WebDriverError);
+ equal(
+ error.wrap(new error.InvalidArgumentError()).name,
+ "InvalidArgumentError"
+ );
+ ok(
+ error.wrap(new error.InvalidArgumentError()) instanceof error.WebDriverError
+ );
+ ok(
+ error.wrap(new error.InvalidArgumentError()) instanceof
+ error.InvalidArgumentError
+ );
+
+ // JS errors should be wrapped in UnknownError
+ equal(error.wrap(new Error()).name, "UnknownError");
+ ok(error.wrap(new Error()) instanceof error.UnknownError);
+ equal(error.wrap(new EvalError()).name, "UnknownError");
+ equal(error.wrap(new InternalError()).name, "UnknownError");
+ equal(error.wrap(new RangeError()).name, "UnknownError");
+ equal(error.wrap(new ReferenceError()).name, "UnknownError");
+ equal(error.wrap(new SyntaxError()).name, "UnknownError");
+ equal(error.wrap(new TypeError()).name, "UnknownError");
+ equal(error.wrap(new URIError()).name, "UnknownError");
+
+ // wrapped JS errors should retain their type
+ // as part of the message field
+ equal(error.wrap(new error.WebDriverError("foo")).message, "foo");
+ equal(error.wrap(new TypeError("foo")).message, "TypeError: foo");
+
+ run_next_test();
+});
+
+add_test(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]);
+ equal(
+ "WebDriverError: foo",
+ error.stringify(new error.WebDriverError("foo")).split("\n")[0]
+ );
+ equal(
+ "InvalidArgumentError: foo",
+ error.stringify(new error.InvalidArgumentError("foo")).split("\n")[0]
+ );
+
+ run_next_test();
+});
+
+add_test(function test_stack() {
+ equal("string", typeof error.stack());
+ ok(error.stack().includes("test_stack"));
+ ok(!error.stack().includes("add_test"));
+
+ run_next_test();
+});
+
+add_test(function test_toJSON() {
+ let e0 = new error.WebDriverError();
+ let e0s = e0.toJSON();
+ equal(e0s.error, "webdriver error");
+ equal(e0s.message, "");
+ equal(e0s.stacktrace, e0.stack);
+
+ let e1 = new error.WebDriverError("a");
+ let e1s = e1.toJSON();
+ equal(e1s.message, e1.message);
+ equal(e1s.stacktrace, e1.stack);
+
+ let e2 = new error.JavaScriptError("foo");
+ let e2s = e2.toJSON();
+ equal(e2.status, e2s.error);
+ equal(e2.message, e2s.message);
+
+ run_next_test();
+});
+
+add_test(function test_fromJSON() {
+ Assert.throws(
+ () => error.WebDriverError.fromJSON({ error: "foo" }),
+ /Not of WebDriverError descent/
+ );
+ Assert.throws(
+ () => error.WebDriverError.fromJSON({ error: "Error" }),
+ /Not of WebDriverError descent/
+ );
+ Assert.throws(
+ () => error.WebDriverError.fromJSON({}),
+ /Undeserialisable error type/
+ );
+ Assert.throws(() => error.WebDriverError.fromJSON(undefined), /TypeError/);
+
+ // stacks will be different
+ let e1 = new error.WebDriverError("1");
+ let e1r = error.WebDriverError.fromJSON({
+ error: "webdriver error",
+ message: "1",
+ });
+ ok(e1r instanceof error.WebDriverError);
+ equal(e1r.name, e1.name);
+ equal(e1r.status, e1.status);
+ equal(e1r.message, e1.message);
+
+ // stacks will be different
+ let e2 = new error.InvalidArgumentError("2");
+ let e2r = error.WebDriverError.fromJSON({
+ error: "invalid argument",
+ message: "2",
+ });
+ ok(e2r instanceof error.WebDriverError);
+ ok(e2r instanceof error.InvalidArgumentError);
+ equal(e2r.name, e2.name);
+ equal(e2r.status, e2.status);
+ equal(e2r.message, e2.message);
+
+ // test stacks
+ let e3j = { error: "no such element", message: "3", stacktrace: "4" };
+ let e3r = error.WebDriverError.fromJSON(e3j);
+ ok(e3r instanceof error.WebDriverError);
+ ok(e3r instanceof error.NoSuchElementError);
+ equal(e3r.name, "NoSuchElementError");
+ equal(e3r.status, e3j.error);
+ equal(e3r.message, e3j.message);
+ equal(e3r.stack, e3j.stacktrace);
+
+ // parity with toJSON
+ let e4j = new error.JavaScriptError("foo").toJSON();
+ let e4 = error.WebDriverError.fromJSON(e4j);
+ equal(e4j.error, e4.status);
+ equal(e4j.message, e4.message);
+ equal(e4j.stacktrace, e4.stack);
+
+ run_next_test();
+});
+
+add_test(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);
+
+ run_next_test();
+});
+
+add_test(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);
+
+ run_next_test();
+});
+
+add_test(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(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(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
+ );
+
+ run_next_test();
+});
+
+add_test(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);
+
+ run_next_test();
+});
+
+add_test(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);
+
+ run_next_test();
+});
+
+add_test(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);
+
+ run_next_test();
+});
+
+add_test(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);
+
+ run_next_test();
+});
+
+add_test(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);
+
+ run_next_test();
+});
+
+add_test(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);
+
+ run_next_test();
+});
+
+add_test(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);
+
+ run_next_test();
+});
+
+add_test(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);
+
+ run_next_test();
+});
+
+add_test(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);
+
+ run_next_test();
+});
+
+add_test(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);
+
+ run_next_test();
+});
+
+add_test(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);
+
+ run_next_test();
+});
+
+add_test(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);
+
+ run_next_test();
+});
+
+add_test(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);
+
+ run_next_test();
+});
+
+add_test(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);
+
+ run_next_test();
+});
+
+add_test(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);
+
+ run_next_test();
+});
+
+add_test(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);
+
+ run_next_test();
+});
+
+add_test(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);
+
+ run_next_test();
+});
+
+add_test(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);
+
+ run_next_test();
+});
+
+add_test(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);
+
+ run_next_test();
+});
+
+add_test(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);
+
+ run_next_test();
+});
+
+add_test(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);
+
+ run_next_test();
+});
+
+add_test(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);
+
+ run_next_test();
+});
+
+add_test(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);
+
+ run_next_test();
+});
+
+add_test(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);
+
+ run_next_test();
+});
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..8111cd0bc7
--- /dev/null
+++ b/remote/shared/webdriver/test/xpcshell/test_NodeCache.js
@@ -0,0 +1,123 @@
+const { NodeCache } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/webdriver/NodeCache.sys.mjs"
+);
+
+const nodeCache = new NodeCache();
+
+const SVG_NS = "http://www.w3.org/2000/svg";
+
+const browser = Services.appShell.createWindowlessBrowser(false);
+
+const domEl = browser.document.createElement("div");
+browser.document.body.appendChild(domEl);
+
+const svgEl = browser.document.createElementNS(SVG_NS, "rect");
+browser.document.body.appendChild(svgEl);
+
+registerCleanupFunction(() => {
+ nodeCache.clear({ all: true });
+});
+
+add_test(function addElement() {
+ const domElRef = nodeCache.add(domEl);
+ equal(nodeCache.size, 1);
+
+ const domElRefOther = nodeCache.add(domEl);
+ equal(nodeCache.size, 1);
+ equal(domElRefOther, domElRef);
+
+ nodeCache.add(svgEl);
+ equal(nodeCache.size, 2);
+
+ run_next_test();
+});
+
+add_test(function addInvalidElement() {
+ Assert.throws(() => nodeCache.add("foo"), /UnknownError/);
+
+ run_next_test();
+});
+
+add_test(function clear() {
+ nodeCache.add(domEl);
+ nodeCache.add(svgEl);
+ equal(nodeCache.size, 2);
+
+ // Clear requires explicit arguments.
+ Assert.throws(() => nodeCache.clear(), /Error/);
+
+ // Clear references for a different browsing context
+ const browser2 = Services.appShell.createWindowlessBrowser(false);
+ let imgEl = browser2.document.createElement("img");
+ browser2.document.body.appendChild(imgEl);
+
+ nodeCache.add(imgEl);
+ nodeCache.clear({ browsingContext: browser.browsingContext });
+ equal(nodeCache.size, 1);
+
+ // Clear all references
+ nodeCache.add(domEl);
+ equal(nodeCache.size, 2);
+
+ nodeCache.clear({ all: true });
+ equal(nodeCache.size, 0);
+
+ run_next_test();
+});
+
+add_test(function resolveElement() {
+ const domElSharedId = nodeCache.add(domEl);
+ deepEqual(nodeCache.resolve(domElSharedId), domEl);
+
+ const svgElSharedId = nodeCache.add(svgEl);
+ deepEqual(nodeCache.resolve(svgElSharedId), svgEl);
+ deepEqual(nodeCache.resolve(domElSharedId), domEl);
+
+ run_next_test();
+});
+
+add_test(function resolveUnknownElement() {
+ Assert.throws(() => nodeCache.resolve("foo"), /NoSuchElementError/);
+
+ run_next_test();
+});
+
+add_test(function resolveElementNotAttachedToDOM() {
+ const imgEl = browser.document.createElement("img");
+
+ const imgElSharedId = nodeCache.add(imgEl);
+ deepEqual(nodeCache.resolve(imgElSharedId), imgEl);
+
+ run_next_test();
+});
+
+add_test(async function resolveElementRemoved() {
+ let imgEl = browser.document.createElement("img");
+ const imgElSharedId = nodeCache.add(imgEl);
+
+ // Delete element and force a garbage collection
+ imgEl = null;
+
+ await doGC();
+
+ const el = nodeCache.resolve(imgElSharedId);
+ deepEqual(el, null);
+
+ run_next_test();
+});
+
+add_test(function elementReferencesDifferentPerNodeCache() {
+ const sharedId = nodeCache.add(domEl);
+
+ const nodeCache2 = new NodeCache();
+ const sharedId2 = nodeCache2.add(domEl);
+
+ notEqual(sharedId, sharedId2);
+ equal(nodeCache.resolve(sharedId), nodeCache2.resolve(sharedId2));
+
+ Assert.throws(() => nodeCache.resolve(sharedId2), /NoSuchElementError/);
+
+ nodeCache2.clear({ all: true });
+
+ run_next_test();
+});
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..76ab59f82a
--- /dev/null
+++ b/remote/shared/webdriver/test/xpcshell/test_Session.js
@@ -0,0 +1,55 @@
+/* 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 { WebDriverSession } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/webdriver/Session.sys.mjs"
+);
+
+add_test(function test_WebDriverSession_ctor() {
+ const session = new WebDriverSession();
+
+ equal(typeof session.id, "string");
+ ok(session.capabilities instanceof Capabilities);
+
+ run_next_test();
+});
+
+add_test(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")
+ );
+
+ run_next_test();
+});
+
+add_test(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"));
+
+ run_next_test();
+});
diff --git a/remote/shared/webdriver/test/xpcshell/xpcshell.ini b/remote/shared/webdriver/test/xpcshell/xpcshell.ini
new file mode 100644
index 0000000000..6279d13325
--- /dev/null
+++ b/remote/shared/webdriver/test/xpcshell/xpcshell.ini
@@ -0,0 +1,12 @@
+# 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/.
+
+[DEFAULT]
+head = head.js
+
+[test_Assert.js]
+[test_Capabilities.js]
+[test_Errors.js]
+[test_NodeCache.js]
+[test_Session.js]