diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /remote/shared/listeners | |
parent | Initial commit. (diff) | |
download | firefox-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')
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(); + }); +} |