summaryrefslogtreecommitdiffstats
path: root/remote/shared/listeners
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /remote/shared/listeners
parentInitial commit. (diff)
downloadfirefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz
firefox-26a029d407be480d791972afb5975cf62c9360a6.zip
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'remote/shared/listeners')
-rw-r--r--remote/shared/listeners/BrowsingContextListener.sys.mjs122
-rw-r--r--remote/shared/listeners/ConsoleAPIListener.sys.mjs124
-rw-r--r--remote/shared/listeners/ConsoleListener.sys.mjs154
-rw-r--r--remote/shared/listeners/ContextualIdentityListener.sys.mjs85
-rw-r--r--remote/shared/listeners/LoadListener.sys.mjs103
-rw-r--r--remote/shared/listeners/NavigationListener.sys.mjs90
-rw-r--r--remote/shared/listeners/NetworkEventRecord.sys.mjs455
-rw-r--r--remote/shared/listeners/NetworkListener.sys.mjs109
-rw-r--r--remote/shared/listeners/PromptListener.sys.mjs285
-rw-r--r--remote/shared/listeners/test/browser/browser.toml21
-rw-r--r--remote/shared/listeners/test/browser/browser_BrowsingContextListener.js117
-rw-r--r--remote/shared/listeners/test/browser/browser_ConsoleAPIListener.js162
-rw-r--r--remote/shared/listeners/test/browser/browser_ConsoleAPIListener_cached_messages.js100
-rw-r--r--remote/shared/listeners/test/browser/browser_ConsoleListener.js148
-rw-r--r--remote/shared/listeners/test/browser/browser_ConsoleListener_cached_messages.js82
-rw-r--r--remote/shared/listeners/test/browser/browser_ContextualIdentityListener.js38
-rw-r--r--remote/shared/listeners/test/browser/browser_NetworkListener.js100
-rw-r--r--remote/shared/listeners/test/browser/browser_PromptListener.js173
-rw-r--r--remote/shared/listeners/test/browser/head.js89
19 files changed, 2557 insertions, 0 deletions
diff --git a/remote/shared/listeners/BrowsingContextListener.sys.mjs b/remote/shared/listeners/BrowsingContextListener.sys.mjs
new file mode 100644
index 0000000000..d4e3539ca9
--- /dev/null
+++ b/remote/shared/listeners/BrowsingContextListener.sys.mjs
@@ -0,0 +1,122 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs",
+});
+
+const OBSERVER_TOPIC_ATTACHED = "browsing-context-attached";
+const OBSERVER_TOPIC_DISCARDED = "browsing-context-discarded";
+
+const OBSERVER_TOPIC_SET_EMBEDDER = "browsing-context-did-set-embedder";
+
+/**
+ * The BrowsingContextListener can be used to listen for notifications coming
+ * from browsing contexts that get attached or discarded.
+ *
+ * Example:
+ * ```
+ * const listener = new BrowsingContextListener();
+ * listener.on("attached", onAttached);
+ * listener.startListening();
+ *
+ * const onAttached = (eventName, data = {}) => {
+ * const { browsingContext, why } = data;
+ * ...
+ * };
+ * ```
+ *
+ * @fires message
+ * The BrowsingContextListener emits "attached" and "discarded" events,
+ * with the following object as payload:
+ * - {BrowsingContext} browsingContext
+ * Browsing context the notification relates to.
+ * - {string} why
+ * Usually "attach" or "discard", but will contain "replace" if the
+ * browsing context gets replaced by a cross-group navigation.
+ */
+export class BrowsingContextListener {
+ #listening;
+ #topContextsToAttach;
+
+ /**
+ * Create a new BrowsingContextListener instance.
+ */
+ constructor() {
+ lazy.EventEmitter.decorate(this);
+
+ // A map that temporarily holds attached top-level browsing contexts until
+ // their embedder element is set, which is required to successfully
+ // retrieve a unique id for the content browser by the TabManager.
+ this.#topContextsToAttach = new Map();
+
+ this.#listening = false;
+ }
+
+ destroy() {
+ this.stopListening();
+ this.#topContextsToAttach = null;
+ }
+
+ observe(subject, topic, data) {
+ switch (topic) {
+ case OBSERVER_TOPIC_ATTACHED:
+ // Delay emitting the event for top-level browsing contexts until
+ // the embedder element has been set.
+ if (!subject.parent) {
+ this.#topContextsToAttach.set(subject, data);
+ return;
+ }
+
+ this.emit("attached", { browsingContext: subject, why: data });
+ break;
+
+ case OBSERVER_TOPIC_DISCARDED:
+ // Remove a recently attached top-level browsing context if it's
+ // immediately discarded.
+ if (this.#topContextsToAttach.has(subject)) {
+ this.#topContextsToAttach.delete(subject);
+ }
+
+ this.emit("discarded", { browsingContext: subject, why: data });
+ break;
+
+ case OBSERVER_TOPIC_SET_EMBEDDER:
+ const why = this.#topContextsToAttach.get(subject);
+ if (why !== undefined) {
+ this.emit("attached", { browsingContext: subject, why });
+ this.#topContextsToAttach.delete(subject);
+ }
+ break;
+ }
+ }
+
+ startListening() {
+ if (this.#listening) {
+ return;
+ }
+
+ Services.obs.addObserver(this, OBSERVER_TOPIC_ATTACHED);
+ Services.obs.addObserver(this, OBSERVER_TOPIC_DISCARDED);
+ Services.obs.addObserver(this, OBSERVER_TOPIC_SET_EMBEDDER);
+
+ this.#listening = true;
+ }
+
+ stopListening() {
+ if (!this.#listening) {
+ return;
+ }
+
+ Services.obs.removeObserver(this, OBSERVER_TOPIC_ATTACHED);
+ Services.obs.removeObserver(this, OBSERVER_TOPIC_DISCARDED);
+ Services.obs.removeObserver(this, OBSERVER_TOPIC_SET_EMBEDDER);
+
+ this.#topContextsToAttach.clear();
+
+ this.#listening = false;
+ }
+}
diff --git a/remote/shared/listeners/ConsoleAPIListener.sys.mjs b/remote/shared/listeners/ConsoleAPIListener.sys.mjs
new file mode 100644
index 0000000000..7f5c850945
--- /dev/null
+++ b/remote/shared/listeners/ConsoleAPIListener.sys.mjs
@@ -0,0 +1,124 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "ConsoleAPIStorage", () => {
+ return Cc["@mozilla.org/consoleAPI-storage;1"].getService(
+ Ci.nsIConsoleAPIStorage
+ );
+});
+
+/**
+ * The ConsoleAPIListener can be used to listen for messages coming from console
+ * API usage in a given windowGlobal, eg. console.log, console.error, ...
+ *
+ * Example:
+ * ```
+ * const listener = new ConsoleAPIListener(innerWindowId);
+ * listener.on("message", onConsoleAPIMessage);
+ * listener.startListening();
+ *
+ * const onConsoleAPIMessage = (eventName, data = {}) => {
+ * const { arguments: msgArguments, level, stacktrace, timeStamp } = data;
+ * ...
+ * };
+ * ```
+ *
+ * @fires message
+ * The ConsoleAPIListener emits "message" events, with the following object as
+ * payload:
+ * - {Array<Object>} arguments - Arguments as passed-in when the method was called.
+ * - {String} level - Importance, one of `info`, `warn`, `error`, `debug`, `trace`.
+ * - {Array<Object>} stacktrace - List of stack frames, starting from most recent.
+ * - {Number} timeStamp - Timestamp when the method was called.
+ */
+export class ConsoleAPIListener {
+ #emittedMessages;
+ #innerWindowId;
+ #listening;
+
+ /**
+ * Create a new ConsoleAPIListener instance.
+ *
+ * @param {number} innerWindowId
+ * The inner window id to filter the messages for.
+ */
+ constructor(innerWindowId) {
+ lazy.EventEmitter.decorate(this);
+
+ this.#emittedMessages = new Set();
+ this.#innerWindowId = innerWindowId;
+ this.#listening = false;
+ }
+
+ destroy() {
+ this.stopListening();
+ this.#emittedMessages = null;
+ }
+
+ startListening() {
+ if (this.#listening) {
+ return;
+ }
+
+ lazy.ConsoleAPIStorage.addLogEventListener(
+ this.#onConsoleAPIMessage,
+ Cc["@mozilla.org/systemprincipal;1"].createInstance(Ci.nsIPrincipal)
+ );
+
+ // Emit cached messages after registering the listener, to make sure we
+ // don't miss any message.
+ this.#emitCachedMessages();
+
+ this.#listening = true;
+ }
+
+ stopListening() {
+ if (!this.#listening) {
+ return;
+ }
+
+ lazy.ConsoleAPIStorage.removeLogEventListener(this.#onConsoleAPIMessage);
+ this.#listening = false;
+ }
+
+ #emitCachedMessages() {
+ const cachedMessages = lazy.ConsoleAPIStorage.getEvents(
+ this.#innerWindowId
+ );
+ for (const message of cachedMessages) {
+ this.#onConsoleAPIMessage(message);
+ }
+ }
+
+ #onConsoleAPIMessage = message => {
+ const messageObject = message.wrappedJSObject;
+
+ // Bail if this message was already emitted, useful to filter out cached
+ // messages already received by the consumer.
+ if (this.#emittedMessages.has(messageObject)) {
+ return;
+ }
+
+ this.#emittedMessages.add(messageObject);
+
+ if (messageObject.innerID !== this.#innerWindowId) {
+ // If the message doesn't match the innerWindowId of the current context
+ // ignore it.
+ return;
+ }
+
+ this.emit("message", {
+ arguments: messageObject.arguments,
+ level: messageObject.level,
+ stacktrace: messageObject.stacktrace,
+ timeStamp: messageObject.timeStamp,
+ });
+ };
+}
diff --git a/remote/shared/listeners/ConsoleListener.sys.mjs b/remote/shared/listeners/ConsoleListener.sys.mjs
new file mode 100644
index 0000000000..0344cf2be2
--- /dev/null
+++ b/remote/shared/listeners/ConsoleListener.sys.mjs
@@ -0,0 +1,154 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs",
+
+ getFramesFromStack: "chrome://remote/content/shared/Stack.sys.mjs",
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
+
+/**
+ * The ConsoleListener can be used to listen for console messages related to
+ * Javascript errors, certain warnings which all happen within a specific
+ * windowGlobal. Consumers can listen for the message types "error",
+ * "warn" and "info".
+ *
+ * Example:
+ * ```
+ * const onJavascriptError = (eventName, data = {}) => {
+ * const { level, message, stacktrace, timestamp } = data;
+ * ...
+ * };
+ *
+ * const listener = new ConsoleListener(innerWindowId);
+ * listener.on("error", onJavascriptError);
+ * listener.startListening();
+ * ...
+ * listener.stopListening();
+ * ```
+ *
+ * @fires message
+ * The ConsoleListener emits "error", "warn" and "info" events, with the
+ * following object as payload:
+ * - {String} level - Importance, one of `info`, `warn`, `error`,
+ * `debug`, `trace`.
+ * - {String} message - Actual message from the console entry.
+ * - {Array<StackFrame>} stacktrace - List of stack frames,
+ * starting from most recent.
+ * - {Number} timeStamp - Timestamp when the method was called.
+ */
+export class ConsoleListener {
+ #emittedMessages;
+ #innerWindowId;
+ #listening;
+
+ /**
+ * Create a new ConsoleListener instance.
+ *
+ * @param {number} innerWindowId
+ * The inner window id to filter the messages for.
+ */
+ constructor(innerWindowId) {
+ lazy.EventEmitter.decorate(this);
+
+ this.#emittedMessages = new Set();
+ this.#innerWindowId = innerWindowId;
+ this.#listening = false;
+ }
+
+ get listening() {
+ return this.#listening;
+ }
+
+ destroy() {
+ this.stopListening();
+ this.#emittedMessages = null;
+ }
+
+ startListening() {
+ if (this.#listening) {
+ return;
+ }
+
+ Services.console.registerListener(this.#onConsoleMessage);
+
+ // Emit cached messages after registering the listener, to make sure we
+ // don't miss any message.
+ this.#emitCachedMessages();
+
+ this.#listening = true;
+ }
+
+ stopListening() {
+ if (!this.#listening) {
+ return;
+ }
+
+ Services.console.unregisterListener(this.#onConsoleMessage);
+ this.#listening = false;
+ }
+
+ #emitCachedMessages() {
+ const cachedMessages = Services.console.getMessageArray() || [];
+
+ for (const message of cachedMessages) {
+ this.#onConsoleMessage(message);
+ }
+ }
+
+ #onConsoleMessage = message => {
+ if (!(message instanceof Ci.nsIScriptError)) {
+ // For now ignore basic nsIConsoleMessage instances, which are only
+ // relevant to Chrome code and do not have a valid window reference.
+ return;
+ }
+
+ // Bail if this message was already emitted, useful to filter out cached
+ // messages already received by the consumer.
+ if (this.#emittedMessages.has(message)) {
+ return;
+ }
+
+ this.#emittedMessages.add(message);
+
+ if (message.innerWindowID !== this.#innerWindowId) {
+ // If the message doesn't match the innerWindowId of the current context
+ // ignore it.
+ return;
+ }
+
+ const { errorFlag, warningFlag, infoFlag } = Ci.nsIScriptError;
+ let level;
+
+ if ((message.flags & warningFlag) == warningFlag) {
+ level = "warn";
+ } else if ((message.flags & infoFlag) == infoFlag) {
+ level = "info";
+ } else if ((message.flags & errorFlag) == errorFlag) {
+ level = "error";
+ } else {
+ lazy.logger.warn(
+ `Not able to process console message with unknown flags ${message.flags}`
+ );
+ return;
+ }
+
+ // Send event when actively listening.
+ this.emit(level, {
+ level,
+ message: message.errorMessage,
+ stacktrace: lazy.getFramesFromStack(message.stack),
+ timeStamp: message.timeStamp,
+ });
+ };
+
+ get QueryInterface() {
+ return ChromeUtils.generateQI(["nsIConsoleListener"]);
+ }
+}
diff --git a/remote/shared/listeners/ContextualIdentityListener.sys.mjs b/remote/shared/listeners/ContextualIdentityListener.sys.mjs
new file mode 100644
index 0000000000..d93b44ed77
--- /dev/null
+++ b/remote/shared/listeners/ContextualIdentityListener.sys.mjs
@@ -0,0 +1,85 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs",
+});
+
+const OBSERVER_TOPIC_CREATED = "contextual-identity-created";
+const OBSERVER_TOPIC_DELETED = "contextual-identity-deleted";
+
+/**
+ * The ContextualIdentityListener can be used to listen for notifications about
+ * contextual identities (containers) being created or deleted.
+ *
+ * Example:
+ * ```
+ * const listener = new ContextualIdentityListener();
+ * listener.on("created", onCreated);
+ * listener.startListening();
+ *
+ * const onCreated = (eventName, data = {}) => {
+ * const { identity } = data;
+ * ...
+ * };
+ * ```
+ *
+ * @fires message
+ * The ContextualIdentityListener emits "created" and "deleted" events,
+ * with the following object as payload:
+ * - {object} identity
+ * The contextual identity which was created or deleted.
+ */
+export class ContextualIdentityListener {
+ #listening;
+
+ /**
+ * Create a new BrowsingContextListener instance.
+ */
+ constructor() {
+ lazy.EventEmitter.decorate(this);
+
+ this.#listening = false;
+ }
+
+ destroy() {
+ this.stopListening();
+ }
+
+ observe(subject, topic, data) {
+ switch (topic) {
+ case OBSERVER_TOPIC_CREATED:
+ this.emit("created", { identity: subject.wrappedJSObject });
+ break;
+
+ case OBSERVER_TOPIC_DELETED:
+ this.emit("deleted", { identity: subject.wrappedJSObject });
+ break;
+ }
+ }
+
+ startListening() {
+ if (this.#listening) {
+ return;
+ }
+
+ Services.obs.addObserver(this, OBSERVER_TOPIC_CREATED);
+ Services.obs.addObserver(this, OBSERVER_TOPIC_DELETED);
+
+ this.#listening = true;
+ }
+
+ stopListening() {
+ if (!this.#listening) {
+ return;
+ }
+
+ Services.obs.removeObserver(this, OBSERVER_TOPIC_CREATED);
+ Services.obs.removeObserver(this, OBSERVER_TOPIC_DELETED);
+
+ this.#listening = false;
+ }
+}
diff --git a/remote/shared/listeners/LoadListener.sys.mjs b/remote/shared/listeners/LoadListener.sys.mjs
new file mode 100644
index 0000000000..cccfca7a90
--- /dev/null
+++ b/remote/shared/listeners/LoadListener.sys.mjs
@@ -0,0 +1,103 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs",
+});
+
+/**
+ * The LoadListener can be used to listen for load events.
+ *
+ * Example:
+ * ```
+ * const listener = new LoadListener();
+ * listener.on("DOMContentLoaded", onDOMContentLoaded);
+ * listener.startListening();
+ *
+ * const onDOMContentLoaded = (eventName, data = {}) => {
+ * const { target } = data;
+ * ...
+ * };
+ * ```
+ *
+ * @fires message
+ * The LoadListener emits "DOMContentLoaded" and "load" events,
+ * with the following object as payload:
+ * - {Document} target
+ * The target document.
+ */
+export class LoadListener {
+ #abortController;
+ #window;
+
+ /**
+ * Create a new LoadListener instance.
+ */
+ constructor(win) {
+ lazy.EventEmitter.decorate(this);
+
+ // Use an abort controller instead of removeEventListener because destroy
+ // might be called close to the window global destruction.
+ this.#abortController = null;
+
+ this.#window = win;
+ }
+
+ destroy() {
+ this.stopListening();
+ }
+
+ startListening() {
+ if (this.#abortController) {
+ return;
+ }
+
+ this.#abortController = new AbortController();
+
+ // Events are attached to the windowRoot instead of the regular window to
+ // avoid issues with document.open (Bug 1822772).
+ this.#window.windowRoot.addEventListener(
+ "DOMContentLoaded",
+ this.#onDOMContentLoaded,
+ {
+ capture: true,
+ mozSystemGroup: true,
+ signal: this.#abortController.signal,
+ }
+ );
+
+ this.#window.windowRoot.addEventListener("load", this.#onLoad, {
+ capture: true,
+ mozSystemGroup: true,
+ signal: this.#abortController.signal,
+ });
+ }
+
+ stopListening() {
+ if (!this.#abortController) {
+ return;
+ }
+
+ this.#abortController.abort();
+ this.#abortController = null;
+ }
+
+ #onDOMContentLoaded = event => {
+ // Check that this event was emitted for the relevant window, because events
+ // from inner frames can bubble to the windowRoot.
+ if (event.target.defaultView === this.#window) {
+ this.emit("DOMContentLoaded", { target: event.target });
+ }
+ };
+
+ #onLoad = event => {
+ // Check that this event was emitted for the relevant window, because events
+ // from inner frames can bubble to the windowRoot.
+ if (event.target.defaultView === this.#window) {
+ this.emit("load", { target: event.target });
+ }
+ };
+}
diff --git a/remote/shared/listeners/NavigationListener.sys.mjs b/remote/shared/listeners/NavigationListener.sys.mjs
new file mode 100644
index 0000000000..c911bb53f6
--- /dev/null
+++ b/remote/shared/listeners/NavigationListener.sys.mjs
@@ -0,0 +1,90 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs",
+});
+
+/**
+ * The NavigationListener simply wraps a NavigationManager instance and exposes
+ * it with a convenient listener API, more consistent with the rest of the
+ * remote codebase. NavigationManager is a singleton per session so it can't
+ * be instanciated for each and every consumer.
+ *
+ * Example:
+ * ```
+ * const onNavigationStarted = (eventName, data = {}) => {
+ * const { level, message, stacktrace, timestamp } = data;
+ * ...
+ * };
+ *
+ * const listener = new NavigationListener(this.messageHandler.navigationManager);
+ * listener.on("navigation-started", onNavigationStarted);
+ * listener.startListening();
+ * ...
+ * listener.stopListening();
+ * ```
+ *
+ * @fires message
+ * The NavigationListener emits "navigation-started", "location-changed" and
+ * "navigation-stopped" events, with the following object as payload:
+ * - {string} navigationId - The UUID for the navigation.
+ * - {string} navigableId - The UUID for the navigable.
+ * - {string} url - The target url for the navigation.
+ */
+export class NavigationListener {
+ #listening;
+ #navigationManager;
+
+ /**
+ * Create a new NavigationListener instance.
+ *
+ * @param {NavigationManager} navigationManager
+ * The underlying NavigationManager for this listener.
+ */
+ constructor(navigationManager) {
+ lazy.EventEmitter.decorate(this);
+
+ this.#listening = false;
+ this.#navigationManager = navigationManager;
+ }
+
+ get listening() {
+ return this.#listening;
+ }
+
+ destroy() {
+ this.stopListening();
+ }
+
+ startListening() {
+ if (this.#listening) {
+ return;
+ }
+
+ this.#navigationManager.on("navigation-started", this.#forwardEvent);
+ this.#navigationManager.on("navigation-stopped", this.#forwardEvent);
+ this.#navigationManager.on("location-changed", this.#forwardEvent);
+
+ this.#listening = true;
+ }
+
+ stopListening() {
+ if (!this.#listening) {
+ return;
+ }
+
+ this.#navigationManager.off("navigation-started", this.#forwardEvent);
+ this.#navigationManager.off("navigation-stopped", this.#forwardEvent);
+ this.#navigationManager.off("location-changed", this.#forwardEvent);
+
+ this.#listening = false;
+ }
+
+ #forwardEvent = (name, data) => {
+ this.emit(name, data);
+ };
+}
diff --git a/remote/shared/listeners/NetworkEventRecord.sys.mjs b/remote/shared/listeners/NetworkEventRecord.sys.mjs
new file mode 100644
index 0000000000..a41f3edd7d
--- /dev/null
+++ b/remote/shared/listeners/NetworkEventRecord.sys.mjs
@@ -0,0 +1,455 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ NetworkUtils:
+ "resource://devtools/shared/network-observer/NetworkUtils.sys.mjs",
+
+ TabManager: "chrome://remote/content/shared/TabManager.sys.mjs",
+});
+
+/**
+ * The NetworkEventRecord implements the interface expected from network event
+ * owners for consumers of the DevTools NetworkObserver.
+ *
+ * The NetworkEventRecord emits the before-request-sent event on behalf of the
+ * NetworkListener instance which created it.
+ */
+export class NetworkEventRecord {
+ #contextId;
+ #fromCache;
+ #isMainDocumentChannel;
+ #networkListener;
+ #redirectCount;
+ #requestChannel;
+ #requestData;
+ #requestId;
+ #responseChannel;
+ #responseData;
+ #wrappedChannel;
+
+ /**
+ *
+ * @param {object} networkEvent
+ * The initial network event information (see createNetworkEvent() in
+ * NetworkUtils.sys.mjs).
+ * @param {nsIChannel} channel
+ * The nsIChannel behind this network event.
+ * @param {NetworkListener} networkListener
+ * The NetworkListener which created this NetworkEventRecord.
+ */
+ constructor(networkEvent, channel, networkListener) {
+ this.#requestChannel = channel;
+ this.#responseChannel = null;
+
+ this.#fromCache = networkEvent.fromCache;
+ this.#isMainDocumentChannel = channel.isMainDocumentChannel;
+
+ this.#wrappedChannel = ChannelWrapper.get(channel);
+
+ this.#networkListener = networkListener;
+
+ // The context ids computed by TabManager have the lifecycle of a navigable
+ // and can be reused for all the events emitted from this record.
+ this.#contextId = this.#getContextId();
+
+ // The wrappedChannel id remains identical across redirects, whereas
+ // nsIChannel.channelId is different for each and every request.
+ this.#requestId = this.#wrappedChannel.id.toString();
+
+ const { cookies, headers } =
+ lazy.NetworkUtils.fetchRequestHeadersAndCookies(channel);
+
+ // See the RequestData type definition for the full list of properties that
+ // should be set on this object.
+ this.#requestData = {
+ bodySize: null,
+ cookies,
+ headers,
+ headersSize: networkEvent.rawHeaders ? networkEvent.rawHeaders.length : 0,
+ method: channel.requestMethod,
+ request: this.#requestId,
+ timings: {},
+ url: channel.URI.spec,
+ };
+
+ // See the ResponseData type definition for the full list of properties that
+ // should be set on this object.
+ this.#responseData = {
+ // encoded size (body)
+ bodySize: null,
+ content: {
+ // decoded size
+ size: null,
+ },
+ // encoded size (headers)
+ headersSize: null,
+ url: channel.URI.spec,
+ };
+
+ // NetworkObserver creates a network event when request headers have been
+ // parsed.
+ // According to the BiDi spec, we should emit beforeRequestSent when adding
+ // request headers, see https://whatpr.org/fetch/1540.html#http-network-or-cache-fetch
+ // step 8.17
+ // Bug 1802181: switch the NetworkObserver to an event-based API.
+ this.#emitBeforeRequestSent();
+
+ // If the request is already blocked, we will not receive further updates,
+ // emit a network.fetchError event immediately.
+ if (networkEvent.blockedReason) {
+ this.#emitFetchError();
+ }
+ }
+
+ /**
+ * Add network request POST data.
+ *
+ * Required API for a NetworkObserver event owner.
+ *
+ * @param {object} postData
+ * The request POST data.
+ */
+ addRequestPostData(postData) {
+ // Only the postData size is needed for RemoteAgent consumers.
+ this.#requestData.bodySize = postData.size;
+ }
+
+ /**
+ * Add the initial network response information.
+ *
+ * Required API for a NetworkObserver event owner.
+ *
+ *
+ * @param {object} options
+ * @param {nsIChannel} options.channel
+ * The channel.
+ * @param {boolean} options.fromCache
+ * @param {string} options.rawHeaders
+ */
+ addResponseStart(options) {
+ const { channel, fromCache, rawHeaders = "" } = options;
+ this.#responseChannel = channel;
+
+ const { headers } =
+ lazy.NetworkUtils.fetchResponseHeadersAndCookies(channel);
+
+ const headersSize = rawHeaders.length;
+ this.#responseData = {
+ ...this.#responseData,
+ bodySize: 0,
+ bytesReceived: headersSize,
+ fromCache: this.#fromCache || !!fromCache,
+ headers,
+ headersSize,
+ mimeType: this.#getMimeType(),
+ protocol: lazy.NetworkUtils.getProtocol(channel),
+ status: channel.responseStatus,
+ statusText: channel.responseStatusText,
+ };
+
+ // This should be triggered when all headers have been received, matching
+ // the WebDriverBiDi response started trigger in `4.6. HTTP-network fetch`
+ // from the fetch specification, based on the PR visible at
+ // https://github.com/whatwg/fetch/pull/1540
+ this.#emitResponseStarted();
+ }
+
+ /**
+ * Add connection security information.
+ *
+ * Required API for a NetworkObserver event owner.
+ *
+ * Not used for RemoteAgent.
+ *
+ * @param {object} info
+ * The object containing security information.
+ * @param {boolean} isRacing
+ * True if the corresponding channel raced the cache and network requests.
+ */
+ addSecurityInfo(info, isRacing) {}
+
+ /**
+ * Add network event timings.
+ *
+ * Required API for a NetworkObserver event owner.
+ *
+ * Not used for RemoteAgent.
+ *
+ * @param {number} total
+ * The total time for the request.
+ * @param {object} timings
+ * The har-like timings.
+ * @param {object} offsets
+ * The har-like timings, but as offset from the request start.
+ */
+ addEventTimings(total, timings, offsets) {}
+
+ /**
+ * Add response cache entry.
+ *
+ * Required API for a NetworkObserver event owner.
+ *
+ * Not used for RemoteAgent.
+ *
+ * @param {object} options
+ * An object which contains a single responseCache property.
+ */
+ addResponseCache(options) {}
+
+ /**
+ * Add response content.
+ *
+ * Required API for a NetworkObserver event owner.
+ *
+ * @param {object} response
+ * An object which represents the response content.
+ * @param {object} responseInfo
+ * Additional meta data about the response.
+ */
+ addResponseContent(response, responseInfo) {
+ // Update content-related sizes with the latest data from addResponseContent.
+ this.#responseData = {
+ ...this.#responseData,
+ bodySize: response.bodySize,
+ bytesReceived: response.transferredSize,
+ content: {
+ size: response.decodedBodySize,
+ },
+ };
+
+ if (responseInfo.blockedReason) {
+ this.#emitFetchError();
+ } else {
+ this.#emitResponseCompleted();
+ }
+ }
+
+ /**
+ * Add server timings.
+ *
+ * Required API for a NetworkObserver event owner.
+ *
+ * Not used for RemoteAgent.
+ *
+ * @param {Array} serverTimings
+ * The server timings.
+ */
+ addServerTimings(serverTimings) {}
+
+ /**
+ * Add service worker timings.
+ *
+ * Required API for a NetworkObserver event owner.
+ *
+ * Not used for RemoteAgent.
+ *
+ * @param {object} serviceWorkerTimings
+ * The server timings.
+ */
+ addServiceWorkerTimings(serviceWorkerTimings) {}
+
+ onAuthPrompt(authDetails, authCallbacks) {
+ this.#emitAuthRequired(authCallbacks);
+ }
+
+ /**
+ * Convert the provided request timing to a timing relative to the beginning
+ * of the request. All timings are numbers representing high definition
+ * timestamps.
+ *
+ * @param {number} timing
+ * High definition timestamp for a request timing relative from the time
+ * origin.
+ * @param {number} requestTime
+ * High definition timestamp for the request start time relative from the
+ * time origin.
+ * @returns {number}
+ * High definition timestamp for the request timing relative to the start
+ * time of the request, or 0 if the provided timing was 0.
+ */
+ #convertTimestamp(timing, requestTime) {
+ if (timing == 0) {
+ return 0;
+ }
+
+ return timing - requestTime;
+ }
+
+ #emitAuthRequired(authCallbacks) {
+ this.#updateDataFromTimedChannel();
+
+ this.#networkListener.emit("auth-required", {
+ authCallbacks,
+ contextId: this.#contextId,
+ isNavigationRequest: this.#isMainDocumentChannel,
+ redirectCount: this.#redirectCount,
+ requestChannel: this.#requestChannel,
+ requestData: this.#requestData,
+ responseChannel: this.#responseChannel,
+ responseData: this.#responseData,
+ timestamp: Date.now(),
+ });
+ }
+
+ #emitBeforeRequestSent() {
+ this.#updateDataFromTimedChannel();
+
+ this.#networkListener.emit("before-request-sent", {
+ contextId: this.#contextId,
+ isNavigationRequest: this.#isMainDocumentChannel,
+ redirectCount: this.#redirectCount,
+ requestChannel: this.#requestChannel,
+ requestData: this.#requestData,
+ timestamp: Date.now(),
+ });
+ }
+
+ #emitFetchError() {
+ this.#updateDataFromTimedChannel();
+
+ this.#networkListener.emit("fetch-error", {
+ contextId: this.#contextId,
+ // TODO: Update with a proper error text. Bug 1873037.
+ errorText: ChromeUtils.getXPCOMErrorName(this.#requestChannel.status),
+ isNavigationRequest: this.#isMainDocumentChannel,
+ redirectCount: this.#redirectCount,
+ requestChannel: this.#requestChannel,
+ requestData: this.#requestData,
+ timestamp: Date.now(),
+ });
+ }
+
+ #emitResponseCompleted() {
+ this.#updateDataFromTimedChannel();
+
+ this.#networkListener.emit("response-completed", {
+ contextId: this.#contextId,
+ isNavigationRequest: this.#isMainDocumentChannel,
+ redirectCount: this.#redirectCount,
+ requestChannel: this.#requestChannel,
+ requestData: this.#requestData,
+ responseChannel: this.#responseChannel,
+ responseData: this.#responseData,
+ timestamp: Date.now(),
+ });
+ }
+
+ #emitResponseStarted() {
+ this.#updateDataFromTimedChannel();
+
+ this.#networkListener.emit("response-started", {
+ contextId: this.#contextId,
+ isNavigationRequest: this.#isMainDocumentChannel,
+ redirectCount: this.#redirectCount,
+ requestChannel: this.#requestChannel,
+ requestData: this.#requestData,
+ responseChannel: this.#responseChannel,
+ responseData: this.#responseData,
+ timestamp: Date.now(),
+ });
+ }
+
+ #getBrowsingContext() {
+ const id = lazy.NetworkUtils.getChannelBrowsingContextID(
+ this.#requestChannel
+ );
+ return BrowsingContext.get(id);
+ }
+
+ /**
+ * Retrieve the navigable id for the current browsing context associated to
+ * the requests' channel. Network events are recorded in the parent process
+ * so we always expect to be able to use TabManager.getIdForBrowsingContext.
+ *
+ * @returns {string}
+ * The navigable id corresponding to the given browsing context.
+ */
+ #getContextId() {
+ return lazy.TabManager.getIdForBrowsingContext(this.#getBrowsingContext());
+ }
+
+ #getMimeType() {
+ // TODO: DevTools NetworkObserver is computing a similar value in
+ // addResponseContent, but uses an inconsistent implementation in
+ // addResponseStart. This approach can only be used as early as in
+ // addResponseHeaders. We should move this logic to the NetworkObserver and
+ // expose mimeType in addResponseStart. Bug 1809670.
+ let mimeType = "";
+
+ try {
+ mimeType = this.#wrappedChannel.contentType;
+ const contentCharset = this.#requestChannel.contentCharset;
+ if (contentCharset) {
+ mimeType += `;charset=${contentCharset}`;
+ }
+ } catch (e) {
+ // Ignore exceptions when reading contentType/contentCharset
+ }
+
+ return mimeType;
+ }
+
+ #getTimingsFromTimedChannel(timedChannel) {
+ const {
+ channelCreationTime,
+ redirectStartTime,
+ redirectEndTime,
+ dispatchFetchEventStartTime,
+ cacheReadStartTime,
+ domainLookupStartTime,
+ domainLookupEndTime,
+ connectStartTime,
+ connectEndTime,
+ secureConnectionStartTime,
+ requestStartTime,
+ responseStartTime,
+ responseEndTime,
+ } = timedChannel;
+
+ // fetchStart should be the post-redirect start time, which should be the
+ // first non-zero timing from: dispatchFetchEventStart, cacheReadStart and
+ // domainLookupStart. See https://www.w3.org/TR/navigation-timing-2/#processing-model
+ const fetchStartTime =
+ dispatchFetchEventStartTime ||
+ cacheReadStartTime ||
+ domainLookupStartTime;
+
+ // Bug 1805478: Per spec, the origin time should match Performance API's
+ // timeOrigin for the global which initiated the request. This is not
+ // available in the parent process, so for now we will use 0.
+ const timeOrigin = 0;
+
+ return {
+ timeOrigin,
+ requestTime: this.#convertTimestamp(channelCreationTime, timeOrigin),
+ redirectStart: this.#convertTimestamp(redirectStartTime, timeOrigin),
+ redirectEnd: this.#convertTimestamp(redirectEndTime, timeOrigin),
+ fetchStart: this.#convertTimestamp(fetchStartTime, timeOrigin),
+ dnsStart: this.#convertTimestamp(domainLookupStartTime, timeOrigin),
+ dnsEnd: this.#convertTimestamp(domainLookupEndTime, timeOrigin),
+ connectStart: this.#convertTimestamp(connectStartTime, timeOrigin),
+ connectEnd: this.#convertTimestamp(connectEndTime, timeOrigin),
+ tlsStart: this.#convertTimestamp(secureConnectionStartTime, timeOrigin),
+ tlsEnd: this.#convertTimestamp(connectEndTime, timeOrigin),
+ requestStart: this.#convertTimestamp(requestStartTime, timeOrigin),
+ responseStart: this.#convertTimestamp(responseStartTime, timeOrigin),
+ responseEnd: this.#convertTimestamp(responseEndTime, timeOrigin),
+ };
+ }
+
+ /**
+ * Update the timings and the redirect count from the nsITimedChannel
+ * corresponding to the current channel. This should be called before emitting
+ * any event from this class.
+ */
+ #updateDataFromTimedChannel() {
+ const timedChannel = this.#requestChannel.QueryInterface(
+ Ci.nsITimedChannel
+ );
+ this.#redirectCount = timedChannel.redirectCount;
+ this.#requestData.timings = this.#getTimingsFromTimedChannel(timedChannel);
+ }
+}
diff --git a/remote/shared/listeners/NetworkListener.sys.mjs b/remote/shared/listeners/NetworkListener.sys.mjs
new file mode 100644
index 0000000000..500d2005dc
--- /dev/null
+++ b/remote/shared/listeners/NetworkListener.sys.mjs
@@ -0,0 +1,109 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs",
+ NetworkObserver:
+ "resource://devtools/shared/network-observer/NetworkObserver.sys.mjs",
+
+ NetworkEventRecord:
+ "chrome://remote/content/shared/listeners/NetworkEventRecord.sys.mjs",
+});
+
+/**
+ * The NetworkListener listens to all network activity from the parent
+ * process.
+ *
+ * Example:
+ * ```
+ * const listener = new NetworkListener();
+ * listener.on("before-request-sent", onBeforeRequestSent);
+ * listener.startListening();
+ *
+ * const onBeforeRequestSent = (eventName, data = {}) => {
+ * const { cntextId, redirectCount, requestData, requestId, timestamp } = data;
+ * ...
+ * };
+ * ```
+ *
+ * @fires before-request-sent
+ * The NetworkListener emits "before-request-sent" events, with the
+ * following object as payload:
+ * - {number} browsingContextId - The browsing context id of the browsing
+ * context where this request was performed.
+ * - {number} redirectCount - The request's redirect count.
+ * - {RequestData} requestData - The request's data as expected by
+ * WebDriver BiDi.
+ * - {string} requestId - The id of the request, consistent across
+ * redirects.
+ * - {number} timestamp - Timestamp when the event was generated.
+ */
+export class NetworkListener {
+ #devtoolsNetworkObserver;
+ #listening;
+
+ constructor() {
+ lazy.EventEmitter.decorate(this);
+
+ this.#listening = false;
+ }
+
+ destroy() {
+ this.stopListening();
+ }
+
+ startListening() {
+ if (this.#listening) {
+ return;
+ }
+
+ this.#devtoolsNetworkObserver = new lazy.NetworkObserver({
+ ignoreChannelFunction: this.#ignoreChannelFunction,
+ onNetworkEvent: this.#onNetworkEvent,
+ });
+
+ // Enable the auth prompt listening to support the auth-required event and
+ // phase.
+ this.#devtoolsNetworkObserver.setAuthPromptListenerEnabled(true);
+
+ this.#listening = true;
+ }
+
+ stopListening() {
+ if (!this.#listening) {
+ return;
+ }
+
+ this.#devtoolsNetworkObserver.destroy();
+ this.#devtoolsNetworkObserver = null;
+
+ this.#listening = false;
+ }
+
+ #ignoreChannelFunction = channel => {
+ // Bug 1826210: Ignore file channels which don't support the same APIs as
+ // regular HTTP channels.
+ if (channel instanceof Ci.nsIFileChannel) {
+ return true;
+ }
+
+ // Ignore chrome-privileged or DevTools-initiated requests
+ if (
+ channel.loadInfo?.loadingDocument === null &&
+ (channel.loadInfo.loadingPrincipal ===
+ Services.scriptSecurityManager.getSystemPrincipal() ||
+ channel.loadInfo.isInDevToolsContext)
+ ) {
+ return true;
+ }
+
+ return false;
+ };
+
+ #onNetworkEvent = (networkEvent, channel) => {
+ return new lazy.NetworkEventRecord(networkEvent, channel, this);
+ };
+}
diff --git a/remote/shared/listeners/PromptListener.sys.mjs b/remote/shared/listeners/PromptListener.sys.mjs
new file mode 100644
index 0000000000..e04c766970
--- /dev/null
+++ b/remote/shared/listeners/PromptListener.sys.mjs
@@ -0,0 +1,285 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AppInfo: "chrome://remote/content/shared/AppInfo.sys.mjs",
+ EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs",
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+ modal: "chrome://remote/content/shared/Prompt.sys.mjs",
+ TabManager: "chrome://remote/content/shared/TabManager.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
+
+/**
+ * The PromptListener listens to the DialogObserver events.
+ *
+ * Example:
+ * ```
+ * const listener = new PromptListener();
+ * listener.on("opened", onPromptOpened);
+ * listener.startListening();
+ *
+ * const onPromptOpened = (eventName, data = {}) => {
+ * const { contentBrowser, prompt } = data;
+ * ...
+ * };
+ * ```
+ *
+ * @fires message
+ * The PromptListener emits "opened" events,
+ * with the following object as payload:
+ * - {XULBrowser} contentBrowser
+ * The <xul:browser> which hold the <var>prompt</var>.
+ * - {modal.Dialog} prompt
+ * Returns instance of the Dialog class.
+ *
+ * The PromptListener emits "closed" events,
+ * with the following object as payload:
+ * - {XULBrowser} contentBrowser
+ * The <xul:browser> which is the target of the event.
+ * - {object} detail
+ * {boolean=} detail.accepted
+ * Returns true if a user prompt was accepted
+ * and false if it was dismissed.
+ * {string=} detail.userText
+ * The user text specified in a prompt.
+ */
+export class PromptListener {
+ #curBrowserFn;
+ #listening;
+
+ constructor(curBrowserFn) {
+ lazy.EventEmitter.decorate(this);
+
+ // curBrowserFn is used only for Marionette (WebDriver classic).
+ this.#curBrowserFn = curBrowserFn;
+ this.#listening = false;
+ }
+
+ destroy() {
+ this.stopListening();
+ }
+
+ /**
+ * Waits for the prompt to be closed.
+ *
+ * @returns {Promise}
+ * Promise that resolves when the prompt is closed.
+ */
+ async dialogClosed() {
+ return new Promise(resolve => {
+ const dialogClosed = () => {
+ this.off("closed", dialogClosed);
+ resolve();
+ };
+
+ this.on("closed", dialogClosed);
+ });
+ }
+
+ /**
+ * Handles `DOMModalDialogClosed` events.
+ */
+ handleEvent(event) {
+ lazy.logger.trace(`Received event ${event.type}`);
+
+ const chromeWin = event.target.opener
+ ? event.target.opener.ownerGlobal
+ : event.target.ownerGlobal;
+ const curBrowser = this.#curBrowserFn && this.#curBrowserFn();
+
+ // For Marionette (WebDriver classic) we only care about events which come
+ // the currently selected browser.
+ if (curBrowser && chromeWin != curBrowser.window) {
+ return;
+ }
+
+ let contentBrowser;
+ if (lazy.AppInfo.isAndroid) {
+ const tabBrowser = lazy.TabManager.getTabBrowser(event.target);
+ // Since on Android we always have only one tab we can just check
+ // the selected tab.
+ const tab = tabBrowser.selectedTab;
+ contentBrowser = lazy.TabManager.getBrowserForTab(tab);
+ } else {
+ contentBrowser = event.target;
+ }
+
+ const detail = {};
+
+ // At the moment the event details are present for GeckoView and on desktop
+ // only for Services.prompt.MODAL_TYPE_CONTENT prompts.
+ if (event.detail) {
+ const { areLeaving, value } = event.detail;
+ // `areLeaving` returns undefined for alerts, for confirms and prompts
+ // it returns true if a user prompt was accepted and false if it was dismissed.
+ detail.accepted = areLeaving === undefined ? true : areLeaving;
+ if (value) {
+ detail.userText = value;
+ }
+ }
+
+ this.emit("closed", {
+ contentBrowser,
+ detail,
+ });
+ }
+
+ /**
+ * Observes the following notifications:
+ * `common-dialog-loaded` - when a modal dialog loaded on desktop,
+ * `domwindowopened` - when a new chrome window opened,
+ * `geckoview-prompt-show` - when a modal dialog opened on Android.
+ */
+ observe(subject, topic) {
+ lazy.logger.trace(`Received observer notification ${topic}`);
+
+ let curBrowser = this.#curBrowserFn && this.#curBrowserFn();
+ switch (topic) {
+ case "common-dialog-loaded":
+ if (curBrowser) {
+ if (
+ !this.#hasCommonDialog(
+ curBrowser.contentBrowser,
+ curBrowser.window,
+ subject
+ )
+ ) {
+ return;
+ }
+ } else {
+ const chromeWin = subject.opener
+ ? subject.opener.ownerGlobal
+ : subject.ownerGlobal;
+
+ for (const tab of lazy.TabManager.getTabsForWindow(chromeWin)) {
+ const contentBrowser = lazy.TabManager.getBrowserForTab(tab);
+ const window = lazy.TabManager.getWindowForTab(tab);
+
+ if (this.#hasCommonDialog(contentBrowser, window, subject)) {
+ curBrowser = {
+ contentBrowser,
+ window,
+ };
+
+ break;
+ }
+ }
+ }
+ this.emit("opened", {
+ contentBrowser: curBrowser.contentBrowser,
+ prompt: new lazy.modal.Dialog(() => curBrowser, subject),
+ });
+
+ break;
+
+ case "domwindowopened":
+ subject.addEventListener("DOMModalDialogClosed", this);
+ break;
+
+ case "geckoview-prompt-show":
+ for (let win of Services.wm.getEnumerator(null)) {
+ const prompt = win.prompts().find(item => item.id == subject.id);
+ if (prompt) {
+ const tabBrowser = lazy.TabManager.getTabBrowser(win);
+ // Since on Android we always have only one tab we can just check
+ // the selected tab.
+ const tab = tabBrowser.selectedTab;
+ const contentBrowser = lazy.TabManager.getBrowserForTab(tab);
+ const window = lazy.TabManager.getWindowForTab(tab);
+
+ // Do not send the event if the curBrowser is specified,
+ // and it's different from prompt browser.
+ if (curBrowser && contentBrowser !== curBrowser.contentBrowser) {
+ continue;
+ }
+
+ this.emit("opened", {
+ contentBrowser,
+ prompt: new lazy.modal.Dialog(
+ () => ({
+ contentBrowser,
+ window,
+ }),
+ prompt
+ ),
+ });
+ return;
+ }
+ }
+ break;
+ }
+ }
+
+ startListening() {
+ if (this.#listening) {
+ return;
+ }
+
+ this.#register();
+ this.#listening = true;
+ }
+
+ stopListening() {
+ if (!this.#listening) {
+ return;
+ }
+
+ this.#unregister();
+ this.#listening = false;
+ }
+
+ #hasCommonDialog(contentBrowser, window, prompt) {
+ const modalType = prompt.Dialog.args.modalType;
+ if (
+ modalType === Services.prompt.MODAL_TYPE_TAB ||
+ modalType === Services.prompt.MODAL_TYPE_CONTENT
+ ) {
+ // Find the container of the dialog in the parent document, and ensure
+ // it is a descendant of the same container as the content browser.
+ const container = contentBrowser.closest(".browserSidebarContainer");
+
+ return container.contains(prompt.docShell.chromeEventHandler);
+ }
+
+ return prompt.ownerGlobal == window || prompt.opener?.ownerGlobal == window;
+ }
+
+ #register() {
+ Services.obs.addObserver(this, "common-dialog-loaded");
+ Services.obs.addObserver(this, "domwindowopened");
+ Services.obs.addObserver(this, "geckoview-prompt-show");
+
+ // Register event listener and save already open prompts for all already open windows.
+ for (const win of Services.wm.getEnumerator(null)) {
+ win.addEventListener("DOMModalDialogClosed", this);
+ }
+ }
+
+ #unregister() {
+ const removeObserver = observerName => {
+ try {
+ Services.obs.removeObserver(this, observerName);
+ } catch (e) {
+ lazy.logger.debug(`Failed to remove observer "${observerName}"`);
+ }
+ };
+
+ for (const observerName of [
+ "common-dialog-loaded",
+ "domwindowopened",
+ "geckoview-prompt-show",
+ ]) {
+ removeObserver(observerName);
+ }
+
+ // Unregister event listener for all open windows
+ for (const win of Services.wm.getEnumerator(null)) {
+ win.removeEventListener("DOMModalDialogClosed", this);
+ }
+ }
+}
diff --git a/remote/shared/listeners/test/browser/browser.toml b/remote/shared/listeners/test/browser/browser.toml
new file mode 100644
index 0000000000..d462bf1e82
--- /dev/null
+++ b/remote/shared/listeners/test/browser/browser.toml
@@ -0,0 +1,21 @@
+[DEFAULT]
+tags = "remote"
+subsuite = "remote"
+support-files = ["head.js"]
+prefs = ["remote.messagehandler.modulecache.useBrowserTestRoot=true"]
+
+["browser_BrowsingContextListener.js"]
+
+["browser_ConsoleAPIListener.js"]
+
+["browser_ConsoleAPIListener_cached_messages.js"]
+
+["browser_ConsoleListener.js"]
+
+["browser_ConsoleListener_cached_messages.js"]
+
+["browser_ContextualIdentityListener.js"]
+
+["browser_NetworkListener.js"]
+
+["browser_PromptListener.js"]
diff --git a/remote/shared/listeners/test/browser/browser_BrowsingContextListener.js b/remote/shared/listeners/test/browser/browser_BrowsingContextListener.js
new file mode 100644
index 0000000000..9a08df7857
--- /dev/null
+++ b/remote/shared/listeners/test/browser/browser_BrowsingContextListener.js
@@ -0,0 +1,117 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { BrowsingContextListener } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/listeners/BrowsingContextListener.sys.mjs"
+);
+
+add_task(async function test_attachedOnNewTab() {
+ const listener = new BrowsingContextListener();
+ const attached = listener.once("attached");
+
+ listener.startListening();
+
+ const tab = BrowserTestUtils.addTab(gBrowser, "about:blank");
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+
+ const { browsingContext, why } = await attached;
+
+ is(
+ browsingContext.id,
+ tab.linkedBrowser.browsingContext.id,
+ "Received expected browsing context"
+ );
+ is(why, "attach", "Browsing context has been attached");
+
+ listener.stopListening();
+ gBrowser.removeTab(tab);
+});
+
+add_task(async function test_attachedValidEmbedderElement() {
+ const listener = new BrowsingContextListener();
+
+ let hasEmbedderElement = false;
+ listener.on(
+ "attached",
+ (evtName, { browsingContext }) => {
+ hasEmbedderElement = !!browsingContext.embedderElement;
+ },
+ { once: true }
+ );
+
+ listener.startListening();
+
+ const tab = BrowserTestUtils.addTab(gBrowser, "about:blank");
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+
+ ok(
+ hasEmbedderElement,
+ "Attached browsing context has a valid embedder element"
+ );
+
+ listener.stopListening();
+ gBrowser.removeTab(tab);
+});
+
+add_task(async function test_discardedOnCloseTab() {
+ const listener = new BrowsingContextListener();
+ const discarded = listener.once("discarded");
+
+ const tab = BrowserTestUtils.addTab(gBrowser, "about:blank");
+ const browsingContext = tab.linkedBrowser.browsingContext;
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+
+ listener.startListening();
+ gBrowser.removeTab(tab);
+ const { browsingContext: discardedBrowsingContext, why } = await discarded;
+
+ is(
+ discardedBrowsingContext.id,
+ browsingContext.id,
+ "Received expected browsing context"
+ );
+ is(why, "discard", "Browsing context has been discarded");
+
+ listener.stopListening();
+});
+
+add_task(async function test_replaceTopLevelOnNavigation() {
+ const listener = new BrowsingContextListener();
+ const attached = listener.once("attached");
+ const discarded = listener.once("discarded");
+
+ const tab = BrowserTestUtils.addTab(gBrowser, "about:blank");
+ const browsingContext = tab.linkedBrowser.browsingContext;
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+
+ listener.startListening();
+
+ await loadURL(tab.linkedBrowser, "about:mozilla");
+
+ const discardEvent = await discarded;
+ const attachEvent = await attached;
+
+ is(
+ discardEvent.browsingContext.id,
+ browsingContext.id,
+ "Received expected browsing context for discarded"
+ );
+ is(discardEvent.why, "replace", "Browsing context has been replaced");
+
+ is(
+ attachEvent.browsingContext.id,
+ tab.linkedBrowser.browsingContext.id,
+ "Received expected browsing context for attached"
+ );
+ is(discardEvent.why, "replace", "Browsing context has been replaced");
+
+ isnot(
+ discardEvent.browsingContext,
+ attachEvent.browsingContext,
+ "Got different browsing contexts"
+ );
+
+ listener.stopListening();
+ gBrowser.removeTab(gBrowser.selectedTab);
+});
diff --git a/remote/shared/listeners/test/browser/browser_ConsoleAPIListener.js b/remote/shared/listeners/test/browser/browser_ConsoleAPIListener.js
new file mode 100644
index 0000000000..ccff78c7a0
--- /dev/null
+++ b/remote/shared/listeners/test/browser/browser_ConsoleAPIListener.js
@@ -0,0 +1,162 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const TESTS = [
+ { method: "log", args: ["log1"] },
+ { method: "log", args: ["log2", "log3"] },
+ { method: "log", args: [[1, 2, 3], { someProperty: "someValue" }] },
+ { method: "warn", args: ["warn1"] },
+ { method: "error", args: ["error1"] },
+ { method: "info", args: ["info1"] },
+ { method: "debug", args: ["debug1"] },
+ { method: "trace", args: ["trace1"] },
+ { method: "assert", args: [false, "assert1"] },
+];
+
+const TEST_PAGE = "https://example.com/document-builder.sjs?html=tab";
+
+add_task(async function test_method_and_arguments() {
+ for (const { method, args } of TESTS) {
+ // Use a dedicated tab for each test to avoid cached messages.
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, TEST_PAGE);
+ await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+
+ info(`Test ConsoleApiListener for ${JSON.stringify({ method, args })}`);
+
+ const listenerId = await listenToConsoleAPIMessage();
+ await useConsoleInContent(method, args);
+ const {
+ arguments: msgArguments,
+ level,
+ timeStamp,
+ stacktrace,
+ } = await getConsoleAPIMessage(listenerId);
+
+ if (method == "assert") {
+ // console.assert() consumes first argument.
+ args.shift();
+ }
+
+ is(
+ msgArguments.length,
+ args.length,
+ "Message event has the expected number of arguments"
+ );
+ for (let i = 0; i < args.length; i++) {
+ Assert.deepEqual(
+ msgArguments[i],
+ args[i],
+ `Message event has the expected argument at index ${i}`
+ );
+ }
+ is(level, method, "Message event has the expected level");
+ ok(Number.isInteger(timeStamp), "Message event has a valid timestamp");
+
+ if (["assert", "error", "warn", "trace"].includes(method)) {
+ // Check stacktrace if method is allowed to contain one.
+ if (method === "warn") {
+ todo(
+ Array.isArray(stacktrace),
+ "stacktrace is of expected type Array (Bug 1744705)"
+ );
+ } else {
+ ok(Array.isArray(stacktrace), "stacktrace is of expected type Array");
+ Assert.greaterOrEqual(
+ stacktrace.length,
+ 1,
+ "stack trace contains at least one frame"
+ );
+ }
+ } else {
+ is(typeof stacktrace, "undefined", "stack trace is is not present");
+ }
+
+ gBrowser.removeTab(gBrowser.selectedTab);
+ }
+});
+
+add_task(async function test_stacktrace() {
+ const script = `
+ function foo() { console.error("cheese"); }
+ function bar() { foo(); }
+ bar();
+ `;
+
+ const listenerId = await listenToConsoleAPIMessage();
+ await createScriptNode(script);
+ const { stacktrace } = await getConsoleAPIMessage(listenerId);
+
+ ok(Array.isArray(stacktrace), "stacktrace is of expected type Array");
+
+ // First 3 frames are from the test script.
+ Assert.greaterOrEqual(
+ stacktrace.length,
+ 3,
+ "stack trace contains at least 3 frames"
+ );
+ checkStackFrame(stacktrace[0], "about:blank", "foo", 2, 30);
+ checkStackFrame(stacktrace[1], "about:blank", "bar", 3, 22);
+ checkStackFrame(stacktrace[2], "about:blank", "", 4, 5);
+});
+
+function useConsoleInContent(method, args) {
+ info(`Call console API: console.${method}("${args.join('", "')}");`);
+ return SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [method, args],
+ (_method, _args) => {
+ content.console[_method].apply(content.console, _args);
+ }
+ );
+}
+
+function listenToConsoleAPIMessage() {
+ info("Listen to a console api message in content");
+ return SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => {
+ const innerWindowId = content.windowGlobalChild.innerWindowId;
+ const { ConsoleAPIListener } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/listeners/ConsoleAPIListener.sys.mjs"
+ );
+ const consoleAPIListener = new ConsoleAPIListener(innerWindowId);
+ const onMessage = consoleAPIListener.once("message");
+ consoleAPIListener.startListening();
+
+ const listenerId = Math.random();
+ content[listenerId] = { consoleAPIListener, onMessage };
+ return listenerId;
+ });
+}
+
+function getConsoleAPIMessage(listenerId) {
+ info("Retrieve the message event captured for listener: " + listenerId);
+ return SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [listenerId],
+ async _listenerId => {
+ const { consoleAPIListener, onMessage } = content[_listenerId];
+ const message = await onMessage;
+
+ consoleAPIListener.destroy();
+
+ return message;
+ }
+ );
+}
+
+function checkStackFrame(
+ frame,
+ filename,
+ functionName,
+ lineNumber,
+ columnNumber
+) {
+ is(frame.filename, filename, "Received expected filename for frame");
+ is(
+ frame.functionName,
+ functionName,
+ "Received expected function name for frame"
+ );
+ is(frame.lineNumber, lineNumber, "Received expected line for frame");
+ is(frame.columnNumber, columnNumber, "Received expected column for frame");
+}
diff --git a/remote/shared/listeners/test/browser/browser_ConsoleAPIListener_cached_messages.js b/remote/shared/listeners/test/browser/browser_ConsoleAPIListener_cached_messages.js
new file mode 100644
index 0000000000..dae35a0b9a
--- /dev/null
+++ b/remote/shared/listeners/test/browser/browser_ConsoleAPIListener_cached_messages.js
@@ -0,0 +1,100 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const TEST_PAGE = "https://example.com/document-builder.sjs?html=tab";
+
+add_task(async function test_cached_messages() {
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, TEST_PAGE);
+ await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => {
+ const innerWindowId = content.windowGlobalChild.innerWindowId;
+ const { ConsoleAPIListener } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/listeners/ConsoleAPIListener.sys.mjs"
+ );
+
+ info("Log two messages before starting the ConsoleAPIListener");
+ content.console.log("message_1");
+ content.console.log("message_2");
+
+ const listener = new ConsoleAPIListener(innerWindowId);
+ const messages = [];
+
+ // We will keep the onMessage callback attached to the ConsoleAPIListener
+ // during the whole test to catch all the emitted events.
+ const onMessage = (evtName, message) => messages.push(message.arguments[0]);
+
+ listener.on("message", onMessage);
+ listener.startListening();
+
+ info("Wait until the 2 cached messages have been emitted");
+ await ContentTaskUtils.waitForCondition(() => messages.length == 2);
+ is(messages[0], "message_1");
+ is(messages[1], "message_2");
+
+ info("Stop listening and log another message");
+ listener.stopListening();
+ content.backup = { listener, messages, onMessage };
+ });
+
+ // Force a GC to check that old cached messages which have been garbage
+ // collected are not re-displayed.
+ await doGC();
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => {
+ const { listener, messages, onMessage } = content.backup;
+ content.console.log("message_3");
+
+ info("Start listening again and check the previous message is emitted");
+ listener.startListening();
+ await ContentTaskUtils.waitForCondition(() => messages.length == 3);
+ is(messages[2], "message_3");
+
+ info("Log another message and wait until it is emitted");
+ content.console.log("message_4");
+ await ContentTaskUtils.waitForCondition(() => messages.length == 4);
+ is(messages[3], "message_4");
+
+ listener.off("message", onMessage);
+ listener.destroy();
+
+ is(messages.length, 4, "Received 4 messages in total");
+ });
+
+ info("Reload the current tab and check only new messages are emitted");
+ await BrowserTestUtils.reloadTab(gBrowser.selectedTab);
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => {
+ const innerWindowId = content.windowGlobalChild.innerWindowId;
+ const { ConsoleAPIListener } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/listeners/ConsoleAPIListener.sys.mjs"
+ );
+
+ info("Log a message before creating the ConsoleAPIListener");
+ content.console.log("new_message_1");
+
+ const listener = new ConsoleAPIListener(innerWindowId);
+ const newMessages = [];
+ const onMessage = (evtName, message) =>
+ newMessages.push(message.arguments[0]);
+ listener.on("message", onMessage);
+
+ info("Start listening and wait for the cached message");
+ listener.startListening();
+ await ContentTaskUtils.waitForCondition(() => newMessages.length == 1);
+ is(newMessages[0], "new_message_1");
+
+ info("Log another message and wait until it is emitted");
+ content.console.log("new_message_2");
+ await ContentTaskUtils.waitForCondition(() => newMessages.length == 2);
+ is(newMessages[1], "new_message_2");
+
+ listener.off("message", onMessage);
+ listener.destroy();
+
+ is(newMessages.length, 2, "Received 2 messages in total");
+ });
+
+ gBrowser.removeTab(gBrowser.selectedTab);
+});
diff --git a/remote/shared/listeners/test/browser/browser_ConsoleListener.js b/remote/shared/listeners/test/browser/browser_ConsoleListener.js
new file mode 100644
index 0000000000..41936a6c0d
--- /dev/null
+++ b/remote/shared/listeners/test/browser/browser_ConsoleListener.js
@@ -0,0 +1,148 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+add_task(async function test_message_properties() {
+ const listenerId = await listenToConsoleMessage("error");
+ await logConsoleMessage({ message: "foo" });
+ const { level, message, timeStamp, stack } = await getConsoleMessage(
+ listenerId
+ );
+
+ is(level, "error", "Received expected log level");
+ is(message, "foo", "Received expected log message");
+ // Services.console.logMessage() doesn't include a stack.
+ is(stack, undefined, "No stack present");
+ is(typeof timeStamp, "number", "timestamp is of expected type number");
+
+ // Clear the console to avoid side effects with other tests in this file.
+ await clearConsole();
+});
+
+add_task(async function test_level() {
+ for (const level of ["error", "info", "warn"]) {
+ const listenerId = await listenToConsoleMessage(level);
+ await logConsoleMessage({ message: "foo", level });
+ const message = await getConsoleMessage(listenerId);
+
+ is(message.level, level, "Received expected log level");
+ }
+
+ // Clear the console to avoid side effects with other tests in this file.
+ await clearConsole();
+});
+
+add_task(async function test_stacktrace() {
+ const script = `
+ function foo() { throw new Error("cheese"); }
+ function bar() { foo(); }
+ bar();
+ `;
+
+ const listenerId = await listenToConsoleMessage("error");
+ await createScriptNode(script);
+ const { message, level, stacktrace } = await getConsoleMessage(listenerId);
+ is(level, "error", "Received expected log level");
+ is(message, "Error: cheese", "Received expected log message");
+ ok(Array.isArray(stacktrace), "frames is of expected type Array");
+ Assert.greaterOrEqual(stacktrace.length, 4, "Got at least 4 stack frames");
+
+ // First 3 stack frames are from the injected script and one more frame comes
+ // from head.js (chrome scope) where we inject the script.
+ checkStackFrame(stacktrace[0], "about:blank", "foo", 2, 28);
+ checkStackFrame(stacktrace[1], "about:blank", "bar", 3, 22);
+ checkStackFrame(stacktrace[2], "about:blank", "", 4, 5);
+ checkStackFrame(
+ stacktrace[3],
+ "chrome://mochitests/content/browser/remote/shared/listeners/test/browser/head.js",
+ "",
+ 34,
+ 29
+ );
+
+ // Clear the console to avoid side effects with other tests in this file.
+ await clearConsole();
+});
+
+function logConsoleMessage(options = {}) {
+ info(`Log console message ${options.message}`);
+ return SpecialPowers.spawn(gBrowser.selectedBrowser, [options], _options => {
+ const { level = "error" } = _options;
+
+ const levelToFlags = {
+ error: Ci.nsIScriptError.errorFlag,
+ info: Ci.nsIScriptError.infoFlag,
+ warn: Ci.nsIScriptError.warningFlag,
+ };
+
+ const scriptError = Cc["@mozilla.org/scripterror;1"].createInstance(
+ Ci.nsIScriptError
+ );
+ scriptError.initWithWindowID(
+ _options.message,
+ _options.sourceName || "sourceName",
+ null,
+ _options.lineNumber || 0,
+ _options.columnNumber || 0,
+ levelToFlags[level],
+ _options.category || "javascript",
+ content.windowGlobalChild.innerWindowId
+ );
+
+ Services.console.logMessage(scriptError);
+ });
+}
+
+function listenToConsoleMessage(level) {
+ info("Listen to a console message in content");
+ return SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [level],
+ async _level => {
+ const innerWindowId = content.windowGlobalChild.innerWindowId;
+ const { ConsoleListener } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/listeners/ConsoleListener.sys.mjs"
+ );
+ const consoleListener = new ConsoleListener(innerWindowId);
+ const onMessage = consoleListener.once(_level);
+ consoleListener.startListening();
+
+ const listenerId = Math.random();
+ content[listenerId] = { consoleListener, onMessage };
+ return listenerId;
+ }
+ );
+}
+
+function getConsoleMessage(listenerId) {
+ info("Retrieve the message event captured for listener: " + listenerId);
+ return SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [listenerId],
+ async _listenerId => {
+ const { consoleListener, onMessage } = content[_listenerId];
+ const message = await onMessage;
+
+ consoleListener.destroy();
+
+ return message;
+ }
+ );
+}
+
+function checkStackFrame(
+ frame,
+ filename,
+ functionName,
+ lineNumber,
+ columnNumber
+) {
+ is(frame.filename, filename, "Received expected filename for frame");
+ is(
+ frame.functionName,
+ functionName,
+ "Received expected function name for frame"
+ );
+ is(frame.lineNumber, lineNumber, "Received expected line for frame");
+ is(frame.columnNumber, columnNumber, "Received expected column for frame");
+}
diff --git a/remote/shared/listeners/test/browser/browser_ConsoleListener_cached_messages.js b/remote/shared/listeners/test/browser/browser_ConsoleListener_cached_messages.js
new file mode 100644
index 0000000000..1020aee661
--- /dev/null
+++ b/remote/shared/listeners/test/browser/browser_ConsoleListener_cached_messages.js
@@ -0,0 +1,82 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const TEST_PAGE =
+ "https://example.com/document-builder.sjs?html=<meta charset=utf8></meta>";
+
+add_task(async function test_cached_javascript_errors() {
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, TEST_PAGE);
+ await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+
+ await createScriptNode(`(() => {throw "error1"})()`);
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => {
+ const innerWindowId = content.windowGlobalChild.innerWindowId;
+ const { ConsoleListener } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/listeners/ConsoleListener.sys.mjs"
+ );
+
+ const listener = new ConsoleListener(innerWindowId);
+
+ const errors = [];
+ // Do not push the whole error object in the array. It would create a strong
+ // reference preventing from reproducing GC-related bugs.
+ const onError = (evtName, error) => errors.push(error.message);
+ listener.on("error", onError);
+
+ const waitForMessage = listener.once("error");
+ listener.startListening();
+ const error = await waitForMessage;
+ is(error.message, "uncaught exception: error1");
+ is(errors.length, 1);
+
+ listener.stopListening();
+ content.backup = { listener, errors, onError };
+ });
+
+ // Force a GC to check that old cached messages which have been garbage
+ // collected are not re-displayed.
+ await doGC();
+ await createScriptNode(`(() => {throw "error2"})()`);
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => {
+ const { listener, errors, onError } = content.backup;
+
+ const waitForMessage = listener.once("error");
+ listener.startListening();
+ const { message } = await waitForMessage;
+ is(message, "uncaught exception: error2");
+ is(errors.length, 2);
+
+ listener.off("error", onError);
+ listener.destroy();
+ });
+
+ info("Reload the current tab and check only new messages are emitted");
+ await BrowserTestUtils.reloadTab(gBrowser.selectedTab);
+
+ await createScriptNode(`(() => {throw "error3"})()`);
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => {
+ const innerWindowId = content.windowGlobalChild.innerWindowId;
+ const { ConsoleListener } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/listeners/ConsoleListener.sys.mjs"
+ );
+
+ const listener = new ConsoleListener(innerWindowId);
+
+ const errors = [];
+ const onError = (evtName, error) => errors.push(error.message);
+ listener.on("error", onError);
+
+ const waitForMessage = listener.once("error");
+ listener.startListening();
+ const error = await waitForMessage;
+ is(error.message, "uncaught exception: error3");
+ is(errors.length, 1);
+
+ listener.off("error", onError);
+ listener.destroy();
+ });
+
+ gBrowser.removeTab(gBrowser.selectedTab);
+});
diff --git a/remote/shared/listeners/test/browser/browser_ContextualIdentityListener.js b/remote/shared/listeners/test/browser/browser_ContextualIdentityListener.js
new file mode 100644
index 0000000000..df783a5688
--- /dev/null
+++ b/remote/shared/listeners/test/browser/browser_ContextualIdentityListener.js
@@ -0,0 +1,38 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { ContextualIdentityListener } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/listeners/ContextualIdentityListener.sys.mjs"
+);
+
+add_task(async function test_createdOnNewContextualIdentity() {
+ const listener = new ContextualIdentityListener();
+ const created = listener.once("created");
+
+ listener.startListening();
+
+ ContextualIdentityService.create("test_name");
+
+ const { identity } = await created;
+ is(identity.name, "test_name", "Received expected identity");
+
+ listener.stopListening();
+
+ ContextualIdentityService.remove(identity.userContextId);
+});
+
+add_task(async function test_deletedOnRemovedContextualIdentity() {
+ const listener = new ContextualIdentityListener();
+ const deleted = listener.once("deleted");
+
+ listener.startListening();
+
+ const testIdentity = ContextualIdentityService.create("test_name");
+ ContextualIdentityService.remove(testIdentity.userContextId);
+
+ const { identity } = await deleted;
+ is(identity.name, "test_name", "Received expected identity");
+
+ listener.stopListening();
+});
diff --git a/remote/shared/listeners/test/browser/browser_NetworkListener.js b/remote/shared/listeners/test/browser/browser_NetworkListener.js
new file mode 100644
index 0000000000..78865f6b80
--- /dev/null
+++ b/remote/shared/listeners/test/browser/browser_NetworkListener.js
@@ -0,0 +1,100 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { NetworkListener } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/listeners/NetworkListener.sys.mjs"
+);
+const { TabManager } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/TabManager.sys.mjs"
+);
+
+add_task(async function test_beforeRequestSent() {
+ const listener = new NetworkListener();
+ const events = [];
+ const onEvent = (name, data) => events.push(data);
+ listener.on("before-request-sent", onEvent);
+
+ const tab1 = BrowserTestUtils.addTab(
+ gBrowser,
+ "https://example.com/document-builder.sjs?html=tab"
+ );
+ await BrowserTestUtils.browserLoaded(tab1.linkedBrowser);
+ const contextId1 = TabManager.getIdForBrowser(tab1.linkedBrowser);
+
+ const tab2 = BrowserTestUtils.addTab(
+ gBrowser,
+ "https://example.com/document-builder.sjs?html=tab2"
+ );
+ await BrowserTestUtils.browserLoaded(tab2.linkedBrowser);
+ const contextId2 = TabManager.getIdForBrowser(tab2.linkedBrowser);
+
+ listener.startListening();
+
+ await fetch(tab1.linkedBrowser, "https://example.com/?1");
+ ok(events.length == 1, "One event was received");
+ assertNetworkEvent(events[0], contextId1, "https://example.com/?1");
+
+ info("Check that events are no longer emitted after calling stopListening");
+ listener.stopListening();
+ await fetch(tab1.linkedBrowser, "https://example.com/?2");
+ ok(events.length == 1, "No new event was received");
+
+ listener.startListening();
+ await fetch(tab1.linkedBrowser, "https://example.com/?3");
+ ok(events.length == 2, "A new event was received");
+ assertNetworkEvent(events[1], contextId1, "https://example.com/?3");
+
+ info("Check network event from the new tab");
+ await fetch(tab2.linkedBrowser, "https://example.com/?4");
+ ok(events.length == 3, "A new event was received");
+ assertNetworkEvent(events[2], contextId2, "https://example.com/?4");
+
+ gBrowser.removeTab(tab1);
+ gBrowser.removeTab(tab2);
+ listener.off("before-request-sent", onEvent);
+ listener.destroy();
+});
+
+add_task(async function test_beforeRequestSent_newTab() {
+ const listener = new NetworkListener();
+ const onBeforeRequestSent = listener.once("before-request-sent");
+ listener.startListening();
+
+ info("Check network event related to loading a new tab");
+ const tab = BrowserTestUtils.addTab(
+ gBrowser,
+ "https://example.com/document-builder.sjs?html=tab"
+ );
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ const contextId = TabManager.getIdForBrowser(tab.linkedBrowser);
+ const event = await onBeforeRequestSent;
+
+ assertNetworkEvent(
+ event,
+ contextId,
+ "https://example.com/document-builder.sjs?html=tab"
+ );
+ gBrowser.removeTab(tab);
+});
+
+add_task(async function test_fetchError() {
+ const listener = new NetworkListener();
+ const onFetchError = listener.once("fetch-error");
+ listener.startListening();
+
+ info("Check fetchError event when loading a new tab");
+ const tab = BrowserTestUtils.addTab(gBrowser, "https://not_a_valid_url/");
+ BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ const contextId = TabManager.getIdForBrowser(tab.linkedBrowser);
+ const event = await onFetchError;
+
+ assertNetworkEvent(event, contextId, "https://not_a_valid_url/");
+ is(event.errorText, "NS_ERROR_UNKNOWN_HOST");
+ gBrowser.removeTab(tab);
+});
+
+function assertNetworkEvent(event, expectedContextId, expectedUrl) {
+ is(event.contextId, expectedContextId, "Event has the expected context id");
+ is(event.requestData.url, expectedUrl, "Event has the expected url");
+}
diff --git a/remote/shared/listeners/test/browser/browser_PromptListener.js b/remote/shared/listeners/test/browser/browser_PromptListener.js
new file mode 100644
index 0000000000..0d3f23db3f
--- /dev/null
+++ b/remote/shared/listeners/test/browser/browser_PromptListener.js
@@ -0,0 +1,173 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { PromptListener } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/listeners/PromptListener.sys.mjs"
+);
+
+add_task(async function test_without_curBrowser() {
+ const listener = new PromptListener();
+ const opened = listener.once("opened");
+ const closed = listener.once("closed");
+
+ listener.startListening();
+
+ const dialogPromise = BrowserTestUtils.promiseAlertDialogOpen();
+ await createScriptNode(`setTimeout(() => window.confirm('test'))`);
+ const dialogWin = await dialogPromise;
+
+ const openedEvent = await opened;
+
+ is(openedEvent.prompt.window, dialogWin, "Received expected prompt");
+
+ dialogWin.document.querySelector("dialog").acceptDialog();
+
+ const closedEvent = await closed;
+
+ is(closedEvent.detail.accepted, true, "Received correct event details");
+
+ listener.destroy();
+});
+
+add_task(async function test_with_curBrowser() {
+ const listener = new PromptListener(() => ({
+ contentBrowser: gBrowser.selectedBrowser,
+ window,
+ }));
+ const opened = listener.once("opened");
+ const closed = listener.once("closed");
+
+ listener.startListening();
+
+ const dialogPromise = BrowserTestUtils.promiseAlertDialogOpen();
+ await createScriptNode(`setTimeout(() => window.confirm('test'))`);
+ const dialogWin = await dialogPromise;
+
+ const openedEvent = await opened;
+
+ is(openedEvent.prompt.window, dialogWin, "Received expected prompt");
+
+ dialogWin.document.querySelector("dialog").acceptDialog();
+
+ const closedEvent = await closed;
+
+ is(closedEvent.detail.accepted, true, "Received correct event details");
+
+ listener.destroy();
+});
+
+add_task(async function test_close_event_details() {
+ const listener = new PromptListener();
+ let closed = listener.once("closed");
+
+ listener.startListening();
+
+ let dialogPromise = BrowserTestUtils.promiseAlertDialogOpen();
+ await createScriptNode(`setTimeout(() => window.prompt('Enter your name:'))`);
+ let dialogWin = await dialogPromise;
+
+ dialogWin.document.getElementById("loginTextbox").value = "Test";
+ dialogWin.document.querySelector("dialog").acceptDialog();
+
+ let closedEvent = await closed;
+
+ is(
+ closedEvent.detail.accepted,
+ true,
+ "Received correct `accepted` value in event details"
+ );
+ is(
+ closedEvent.detail.userText,
+ "Test",
+ "Received correct `userText` value in event details"
+ );
+
+ closed = listener.once("closed");
+
+ dialogPromise = BrowserTestUtils.promiseAlertDialogOpen();
+ await createScriptNode(`setTimeout(() => window.prompt('Enter your name:'))`);
+ dialogWin = await dialogPromise;
+
+ dialogWin.document.getElementById("loginTextbox").value = "Test";
+ dialogWin.document.querySelector("dialog").cancelDialog();
+
+ closedEvent = await closed;
+
+ is(
+ closedEvent.detail.accepted,
+ false,
+ "Received correct `accepted` value in event details"
+ );
+ is(
+ closedEvent.detail.userText,
+ undefined,
+ "Received correct `userText` value in event details"
+ );
+
+ listener.destroy();
+});
+
+add_task(async function test_dialogClosed() {
+ const listener = new PromptListener();
+
+ listener.startListening();
+
+ let dialogPromise = BrowserTestUtils.promiseAlertDialogOpen();
+ await createScriptNode(`setTimeout(() => window.alert('test'))`);
+ let dialogWin = await dialogPromise;
+ let closed = listener.dialogClosed();
+
+ dialogWin.document.querySelector("dialog").acceptDialog();
+
+ await closed;
+
+ is(true, true, "Close promise got resolved");
+
+ dialogPromise = BrowserTestUtils.promiseAlertDialogOpen();
+ await createScriptNode(`setTimeout(() => window.alert('test'))`);
+ dialogWin = await dialogPromise;
+ closed = listener.dialogClosed();
+
+ dialogWin.document.querySelector("dialog").cancelDialog();
+
+ await closed;
+
+ is(true, true, "Close promise got resolved");
+
+ listener.destroy();
+});
+
+add_task(async function test_events_in_another_browser() {
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+ const selectedBrowser = win.gBrowser.selectedBrowser;
+ const listener = new PromptListener(() => ({
+ contentBrowser: selectedBrowser,
+ window: selectedBrowser.ownerGlobal,
+ }));
+ const events = [];
+ const onEvent = (name, data) => events.push(data);
+ listener.on("opened", onEvent);
+ listener.on("closed", onEvent);
+
+ listener.startListening();
+
+ const dialogPromise = BrowserTestUtils.promiseAlertDialogOpen();
+ await createScriptNode(`setTimeout(() => window.confirm('test'))`);
+ const dialogWin = await dialogPromise;
+
+ ok(events.length === 0, "No event was received");
+
+ dialogWin.document.querySelector("dialog").acceptDialog();
+
+ // Wait a bit to make sure that the event didn't come.
+ await new Promise(resolve => {
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ setTimeout(resolve, 500);
+ });
+
+ ok(events.length === 0, "No event was received");
+
+ listener.destroy();
+ await BrowserTestUtils.closeWindow(win);
+});
diff --git a/remote/shared/listeners/test/browser/head.js b/remote/shared/listeners/test/browser/head.js
new file mode 100644
index 0000000000..1691a6f59b
--- /dev/null
+++ b/remote/shared/listeners/test/browser/head.js
@@ -0,0 +1,89 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+async function clearConsole() {
+ for (const tab of gBrowser.tabs) {
+ await SpecialPowers.spawn(tab.linkedBrowser, [], () => {
+ Services.console.reset();
+ });
+ }
+ Services.console.reset();
+}
+
+/**
+ * Execute the provided script content by generating a dynamic script tag and
+ * inserting it in the page for the current selected browser.
+ *
+ * @param {string} script
+ * The script to execute.
+ * @returns {Promise}
+ * A promise that resolves when the script node was added and removed from
+ * the content page.
+ */
+function createScriptNode(script) {
+ return SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [script],
+ function (_script) {
+ var script = content.document.createElement("script");
+ script.append(content.document.createTextNode(_script));
+ content.document.body.append(script);
+ }
+ );
+}
+
+registerCleanupFunction(async () => {
+ await clearConsole();
+});
+
+async function doGC() {
+ // Run GC and CC a few times to make sure that as much as possible is freed.
+ const numCycles = 3;
+ for (let i = 0; i < numCycles; i++) {
+ Cu.forceGC();
+ Cu.forceCC();
+ await new Promise(resolve => Cu.schedulePreciseShrinkingGC(resolve));
+ }
+
+ const MemoryReporter = Cc[
+ "@mozilla.org/memory-reporter-manager;1"
+ ].getService(Ci.nsIMemoryReporterManager);
+ await new Promise(resolve => MemoryReporter.minimizeMemoryUsage(resolve));
+}
+
+/**
+ * Load the provided url in an existing browser.
+ * Returns a promise which will resolve when the page is loaded.
+ *
+ * @param {Browser} browser
+ * The browser element where the URL should be loaded.
+ * @param {string} url
+ * The URL to load.
+ */
+async function loadURL(browser, url) {
+ const loaded = BrowserTestUtils.browserLoaded(browser);
+ BrowserTestUtils.startLoadingURIString(browser, url);
+ return loaded;
+}
+
+/**
+ * Create a fetch request to `url` from the content page loaded in the provided
+ * `browser`.
+ *
+ *
+ * @param {Browser} browser
+ * The browser element where the fetch should be performed.
+ * @param {string} url
+ * The URL to fetch.
+ */
+function fetch(browser, url) {
+ return SpecialPowers.spawn(browser, [url], async _url => {
+ const response = await content.fetch(_url);
+ // Wait for response.text() to resolve as well to make sure the response
+ // has completed before returning.
+ await response.text();
+ });
+}