diff options
Diffstat (limited to 'remote/shared/listeners')
14 files changed, 1771 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..b7334e3d4f --- /dev/null +++ b/remote/shared/listeners/BrowsingContextListener.sys.mjs @@ -0,0 +1,121 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs", +}); + +const OBSERVER_TOPIC_ATTACHED = "browsing-context-attached"; +const OBSERVER_TOPIC_DISCARDED = "browsing-context-discarded"; + +const OBSERVER_TOPIC_SET_EMBEDDER = "browsing-context-did-set-embedder"; + +/** + * The BrowsingContextListener can be used to listen for notifications coming + * from browsing contexts that get attached or discarded. + * + * Example: + * ``` + * const listener = new BrowsingContextListener(); + * listener.on("attached", onAttached); + * listener.startListening(); + * + * const onAttached = (eventName, data = {}) => { + * const { browsingContext, why } = data; + * ... + * }; + * ``` + * + * @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(); + } + + 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..2c8d4d047c --- /dev/null +++ b/remote/shared/listeners/ConsoleAPIListener.sys.mjs @@ -0,0 +1,126 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(lazy, "ConsoleAPIStorage", () => { + return Cc["@mozilla.org/consoleAPI-storage;1"].getService( + Ci.nsIConsoleAPIStorage + ); +}); + +/** + * The ConsoleAPIListener can be used to listen for messages coming from console + * API usage in a given windowGlobal, eg. console.log, console.error, ... + * + * Example: + * ``` + * const listener = new ConsoleAPIListener(innerWindowId); + * listener.on("message", onConsoleAPIMessage); + * listener.startListening(); + * + * const onConsoleAPIMessage = (eventName, data = {}) => { + * const { arguments: msgArguments, level, stacktrace, timeStamp } = data; + * ... + * }; + * ``` + * + * @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 ConsolerListener instance. + * + * @param {number} innerWindowId + * The inner window id to filter the messages for. + */ + constructor(innerWindowId) { + lazy.EventEmitter.decorate(this); + + this.#emittedMessages = new Set(); + this.#innerWindowId = innerWindowId; + this.#listening = false; + } + + destroy() { + this.stopListening(); + this.#emittedMessages = null; + } + + startListening() { + if (this.#listening) { + return; + } + + lazy.ConsoleAPIStorage.addLogEventListener( + this.#onConsoleAPIMessage, + Cc["@mozilla.org/systemprincipal;1"].createInstance(Ci.nsIPrincipal) + ); + + // Emit cached messages after registering the listener, to make sure we + // don't miss any message. + this.#emitCachedMessages(); + + this.#listening = true; + } + + stopListening() { + if (!this.#listening) { + return; + } + + lazy.ConsoleAPIStorage.removeLogEventListener(this.#onConsoleAPIMessage); + this.#listening = false; + } + + #emitCachedMessages() { + const cachedMessages = lazy.ConsoleAPIStorage.getEvents( + this.#innerWindowId + ); + for (const message of cachedMessages) { + this.#onConsoleAPIMessage(message); + } + } + + #onConsoleAPIMessage = message => { + const messageObject = message.wrappedJSObject; + + // Bail if this message was already emitted, useful to filter out cached + // messages already received by the consumer. + if (this.#emittedMessages.has(messageObject)) { + return; + } + + this.#emittedMessages.add(messageObject); + + if (messageObject.innerID !== this.#innerWindowId) { + // If the message doesn't match the innerWindowId of the current context + // ignore it. + return; + } + + this.emit("message", { + arguments: messageObject.arguments, + level: messageObject.level, + stacktrace: messageObject.stacktrace, + timeStamp: messageObject.timeStamp, + }); + }; +} diff --git a/remote/shared/listeners/ConsoleListener.sys.mjs b/remote/shared/listeners/ConsoleListener.sys.mjs new file mode 100644 index 0000000000..aef41692c0 --- /dev/null +++ b/remote/shared/listeners/ConsoleListener.sys.mjs @@ -0,0 +1,156 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs", + + getFramesFromStack: "chrome://remote/content/shared/Stack.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +/** + * The ConsoleListener can be used to listen for console messages related to + * Javascript errors, certain warnings which all happen within a specific + * windowGlobal. Consumers can listen for the message types "error", + * "warn" and "info". + * + * Example: + * ``` + * const onJavascriptError = (eventName, data = {}) => { + * const { level, message, stacktrace, timestamp } = data; + * ... + * }; + * + * const listener = new ConsoleListener(innerWindowId); + * listener.on("error", onJavascriptError); + * listener.startListening(); + * ... + * listener.stopListening(); + * ``` + * + * @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 ConsolerListener instance. + * + * @param {number} innerWindowId + * The inner window id to filter the messages for. + */ + constructor(innerWindowId) { + lazy.EventEmitter.decorate(this); + + this.#emittedMessages = new Set(); + this.#innerWindowId = innerWindowId; + this.#listening = false; + } + + get listening() { + return this.#listening; + } + + destroy() { + this.stopListening(); + this.#emittedMessages = null; + } + + startListening() { + if (this.#listening) { + return; + } + + Services.console.registerListener(this.#onConsoleMessage); + + // Emit cached messages after registering the listener, to make sure we + // don't miss any message. + this.#emitCachedMessages(); + + this.#listening = true; + } + + stopListening() { + if (!this.#listening) { + return; + } + + Services.console.unregisterListener(this.#onConsoleMessage); + this.#listening = false; + } + + #emitCachedMessages() { + const cachedMessages = Services.console.getMessageArray() || []; + + for (const message of cachedMessages) { + this.#onConsoleMessage(message); + } + } + + #onConsoleMessage = message => { + if (!(message instanceof Ci.nsIScriptError)) { + // For now ignore basic nsIConsoleMessage instances, which are only + // relevant to Chrome code and do not have a valid window reference. + return; + } + + // Bail if this message was already emitted, useful to filter out cached + // messages already received by the consumer. + if (this.#emittedMessages.has(message)) { + return; + } + + this.#emittedMessages.add(message); + + if (message.innerWindowID !== this.#innerWindowId) { + // If the message doesn't match the innerWindowId of the current context + // ignore it. + return; + } + + const { errorFlag, warningFlag, infoFlag } = Ci.nsIScriptError; + let level; + + if ((message.flags & warningFlag) == warningFlag) { + level = "warn"; + } else if ((message.flags & infoFlag) == infoFlag) { + level = "info"; + } else if ((message.flags & errorFlag) == errorFlag) { + level = "error"; + } else { + lazy.logger.warn( + `Not able to process console message with unknown flags ${message.flags}` + ); + return; + } + + // Send event when actively listening. + this.emit(level, { + level, + message: message.errorMessage, + stacktrace: lazy.getFramesFromStack(message.stack), + timeStamp: message.timeStamp, + }); + }; + + get QueryInterface() { + return ChromeUtils.generateQI(["nsIConsoleListener"]); + } +} diff --git a/remote/shared/listeners/LoadListener.sys.mjs b/remote/shared/listeners/LoadListener.sys.mjs new file mode 100644 index 0000000000..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/NetworkEventRecord.sys.mjs b/remote/shared/listeners/NetworkEventRecord.sys.mjs new file mode 100644 index 0000000000..cc3e0d1001 --- /dev/null +++ b/remote/shared/listeners/NetworkEventRecord.sys.mjs @@ -0,0 +1,370 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + NetworkUtils: + "resource://devtools/shared/network-observer/NetworkUtils.sys.mjs", + + TabManager: "chrome://remote/content/shared/TabManager.sys.mjs", +}); + +/** + * The NetworkEventRecord implements the interface expected from network event + * owners for consumers of the DevTools NetworkObserver. + * + * The NetworkEventRecord emits the before-request-sent event on behalf of the + * NetworkListener instance which created it. + */ +export class NetworkEventRecord { + #channel; + #fromCache; + #networkListener; + #redirectCount; + #requestData; + #requestId; + #responseData; + #wrappedChannel; + + /** + * + * @param {object} networkEvent + * The initial network event information (see createNetworkEvent() in + * NetworkUtils.sys.mjs). + * @param {nsIChannel} channel + * The nsIChannel behind this network event. + * @param {NetworkListener} networkListener + * The NetworkListener which created this NetworkEventRecord. + */ + constructor(networkEvent, channel, networkListener) { + this.#channel = channel; + this.#fromCache = networkEvent.fromCache; + + this.#wrappedChannel = ChannelWrapper.get(channel); + + this.#networkListener = networkListener; + + // The wrappedChannel id remains identical across redirects, whereas + // nsIChannel.channelId is different for each and every request. + this.#requestId = this.#wrappedChannel.id.toString(); + + 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(); + } + + /** + * 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; + 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. + * @param {Array} serverTimings + * The server timings. + */ + addEventTimings(total, timings, offsets, serverTimings) {} + + /** + * Add response cache entry. + * + * Required API for a NetworkObserver event owner. + * + * Not used for RemoteAgent. + * + * @param {object} options + * An object which contains a single responseCache property. + */ + addResponseCache(options) {} + + /** + * Add response content. + * + * Required API for a NetworkObserver event owner. + * + * @param {object} response + * An object which represents the response content. + * @param {object} responseInfo + * Additional meta data about the response. + */ + addResponseContent(response, responseInfo) { + // Update content-related sizes with the latest data from addResponseContent. + this.#responseData = { + ...this.#responseData, + bodySize: response.bodySize, + bytesReceived: response.transferredSize, + content: { + size: response.decodedBodySize, + }, + }; + + this.#emitResponseCompleted(); + } + + /** + * Add server timings. + * + * Required API for a NetworkObserver event owner. + * + * Not used for RemoteAgent. + * + * @param {Array} serverTimings + * The server timings. + */ + addServerTimings(serverTimings) {} + + #emitBeforeRequestSent() { + this.#updateDataFromTimedChannel(); + + this.#networkListener.emit("before-request-sent", { + contextId: this.#getContextId(), + redirectCount: this.#redirectCount, + requestData: this.#requestData, + timestamp: Date.now(), + }); + } + + #emitResponseCompleted() { + this.#updateDataFromTimedChannel(); + + this.#networkListener.emit("response-completed", { + contextId: this.#getContextId(), + redirectCount: this.#redirectCount, + requestData: this.#requestData, + responseData: this.#responseData, + timestamp: Date.now(), + }); + } + + #emitResponseStarted() { + this.#updateDataFromTimedChannel(); + + this.#networkListener.emit("response-started", { + contextId: this.#getContextId(), + redirectCount: this.#redirectCount, + requestData: this.#requestData, + responseData: this.#responseData, + timestamp: Date.now(), + }); + } + + /** + * Convert the provided request timing to a timing relative to the beginning + * of the request. All timings are numbers representing high definition + * timestamps. + * + * @param {number} timing + * High definition timestamp for a request timing relative from the time + * origin. + * @param {number} requestTime + * High definition timestamp for the request start time relative from the + * time origin. + * @returns {number} + * High definition timestamp for the request timing relative to the start + * time of the request, or 0 if the provided timing was 0. + */ + #convertTimestamp(timing, requestTime) { + if (timing == 0) { + return 0; + } + + return timing - requestTime; + } + + /** + * Retrieve the context id corresponding to the current channel, this could + * change dynamically during a cross group navigation for an iframe, so this + * should always be retrieved dynamically. + */ + #getContextId() { + const id = lazy.NetworkUtils.getChannelBrowsingContextID(this.#channel); + const browsingContext = BrowsingContext.get(id); + return lazy.TabManager.getIdForBrowsingContext(browsingContext); + } + + #getMimeType() { + // TODO: DevTools NetworkObserver is computing a similar value in + // addResponseContent, but uses an inconsistent implementation in + // addResponseStart. This approach can only be used as early as in + // addResponseHeaders. We should move this logic to the NetworkObserver and + // expose mimeType in addResponseStart. Bug 1809670. + let mimeType = ""; + + try { + mimeType = this.#wrappedChannel.contentType; + const contentCharset = this.#channel.contentCharset; + if (contentCharset) { + mimeType += `;charset=${contentCharset}`; + } + } catch (e) { + // Ignore exceptions when reading contentType/contentCharset + } + + return mimeType; + } + + #getTimingsFromTimedChannel(timedChannel) { + const { + channelCreationTime, + redirectStartTime, + redirectEndTime, + dispatchFetchEventStartTime, + cacheReadStartTime, + domainLookupStartTime, + domainLookupEndTime, + connectStartTime, + connectEndTime, + secureConnectionStartTime, + requestStartTime, + responseStartTime, + responseEndTime, + } = timedChannel; + + // fetchStart should be the post-redirect start time, which should be the + // first non-zero timing from: dispatchFetchEventStart, cacheReadStart and + // domainLookupStart. See https://www.w3.org/TR/navigation-timing-2/#processing-model + const fetchStartTime = + dispatchFetchEventStartTime || + cacheReadStartTime || + domainLookupStartTime; + + // Bug 1805478: Per spec, the origin time should match Performance API's + // originTime for the global which initiated the request. This is not + // available in the parent process, so for now we will use 0. + const originTime = 0; + + return { + originTime, + requestTime: this.#convertTimestamp(channelCreationTime, originTime), + redirectStart: this.#convertTimestamp(redirectStartTime, originTime), + redirectEnd: this.#convertTimestamp(redirectEndTime, originTime), + fetchStart: this.#convertTimestamp(fetchStartTime, originTime), + dnsStart: this.#convertTimestamp(domainLookupStartTime, originTime), + dnsEnd: this.#convertTimestamp(domainLookupEndTime, originTime), + connectStart: this.#convertTimestamp(connectStartTime, originTime), + connectEnd: this.#convertTimestamp(connectEndTime, originTime), + tlsStart: this.#convertTimestamp(secureConnectionStartTime, originTime), + tlsEnd: this.#convertTimestamp(connectEndTime, originTime), + requestStart: this.#convertTimestamp(requestStartTime, originTime), + responseStart: this.#convertTimestamp(responseStartTime, originTime), + responseEnd: this.#convertTimestamp(responseEndTime, originTime), + }; + } + + /** + * Update the timings and the redirect count from the nsITimedChannel + * corresponding to the current channel. This should be called before emitting + * any event from this class. + */ + #updateDataFromTimedChannel() { + const timedChannel = this.#channel.QueryInterface(Ci.nsITimedChannel); + this.#redirectCount = timedChannel.redirectCount; + this.#requestData.timings = this.#getTimingsFromTimedChannel(timedChannel); + } +} diff --git a/remote/shared/listeners/NetworkListener.sys.mjs b/remote/shared/listeners/NetworkListener.sys.mjs new file mode 100644 index 0000000000..d4d24f155e --- /dev/null +++ b/remote/shared/listeners/NetworkListener.sys.mjs @@ -0,0 +1,99 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +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, + }); + + this.#listening = true; + } + + stopListening() { + if (!this.#listening) { + return; + } + + this.#devtoolsNetworkObserver.destroy(); + this.#devtoolsNetworkObserver = null; + + this.#listening = false; + } + + #ignoreChannelFunction = channel => { + // Ignore chrome-privileged or DevTools-initiated requests + if ( + channel.loadInfo?.loadingDocument === null && + (channel.loadInfo.loadingPrincipal === + Services.scriptSecurityManager.getSystemPrincipal() || + channel.loadInfo.isInDevToolsContext) + ) { + return true; + } + + return false; + }; + + #onNetworkEvent = (networkEvent, channel) => { + return new lazy.NetworkEventRecord(networkEvent, channel, this); + }; +} diff --git a/remote/shared/listeners/test/browser/browser.ini b/remote/shared/listeners/test/browser/browser.ini new file mode 100644 index 0000000000..1833cc1ded --- /dev/null +++ b/remote/shared/listeners/test/browser/browser.ini @@ -0,0 +1,14 @@ +[DEFAULT] +tags = remote +subsuite = remote +support-files = + head.js +prefs = + remote.messagehandler.modulecache.useBrowserTestRoot=true + +[browser_BrowsingContextListener.js] +[browser_ConsoleAPIListener.js] +[browser_ConsoleAPIListener_cached_messages.js] +[browser_ConsoleListener.js] +[browser_ConsoleListener_cached_messages.js] +[browser_NetworkListener.js] diff --git a/remote/shared/listeners/test/browser/browser_BrowsingContextListener.js b/remote/shared/listeners/test/browser/browser_BrowsingContextListener.js new file mode 100644 index 0000000000..9a08df7857 --- /dev/null +++ b/remote/shared/listeners/test/browser/browser_BrowsingContextListener.js @@ -0,0 +1,117 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const { BrowsingContextListener } = ChromeUtils.importESModule( + "chrome://remote/content/shared/listeners/BrowsingContextListener.sys.mjs" +); + +add_task(async function test_attachedOnNewTab() { + const listener = new BrowsingContextListener(); + const attached = listener.once("attached"); + + listener.startListening(); + + const tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + + const { browsingContext, why } = await attached; + + is( + browsingContext.id, + tab.linkedBrowser.browsingContext.id, + "Received expected browsing context" + ); + is(why, "attach", "Browsing context has been attached"); + + listener.stopListening(); + gBrowser.removeTab(tab); +}); + +add_task(async function test_attachedValidEmbedderElement() { + const listener = new BrowsingContextListener(); + + let hasEmbedderElement = false; + listener.on( + "attached", + (evtName, { browsingContext }) => { + hasEmbedderElement = !!browsingContext.embedderElement; + }, + { once: true } + ); + + listener.startListening(); + + const tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + + ok( + hasEmbedderElement, + "Attached browsing context has a valid embedder element" + ); + + listener.stopListening(); + gBrowser.removeTab(tab); +}); + +add_task(async function test_discardedOnCloseTab() { + const listener = new BrowsingContextListener(); + const discarded = listener.once("discarded"); + + const tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + const browsingContext = tab.linkedBrowser.browsingContext; + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + + listener.startListening(); + gBrowser.removeTab(tab); + const { browsingContext: discardedBrowsingContext, why } = await discarded; + + is( + discardedBrowsingContext.id, + browsingContext.id, + "Received expected browsing context" + ); + is(why, "discard", "Browsing context has been discarded"); + + listener.stopListening(); +}); + +add_task(async function test_replaceTopLevelOnNavigation() { + const listener = new BrowsingContextListener(); + const attached = listener.once("attached"); + const discarded = listener.once("discarded"); + + const tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + const browsingContext = tab.linkedBrowser.browsingContext; + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + + listener.startListening(); + + await loadURL(tab.linkedBrowser, "about:mozilla"); + + const discardEvent = await discarded; + const attachEvent = await attached; + + is( + discardEvent.browsingContext.id, + browsingContext.id, + "Received expected browsing context for discarded" + ); + is(discardEvent.why, "replace", "Browsing context has been replaced"); + + is( + attachEvent.browsingContext.id, + tab.linkedBrowser.browsingContext.id, + "Received expected browsing context for attached" + ); + is(discardEvent.why, "replace", "Browsing context has been replaced"); + + isnot( + discardEvent.browsingContext, + attachEvent.browsingContext, + "Got different browsing contexts" + ); + + listener.stopListening(); + gBrowser.removeTab(gBrowser.selectedTab); +}); diff --git a/remote/shared/listeners/test/browser/browser_ConsoleAPIListener.js b/remote/shared/listeners/test/browser/browser_ConsoleAPIListener.js new file mode 100644 index 0000000000..ccff78c7a0 --- /dev/null +++ b/remote/shared/listeners/test/browser/browser_ConsoleAPIListener.js @@ -0,0 +1,162 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const TESTS = [ + { method: "log", args: ["log1"] }, + { method: "log", args: ["log2", "log3"] }, + { method: "log", args: [[1, 2, 3], { someProperty: "someValue" }] }, + { method: "warn", args: ["warn1"] }, + { method: "error", args: ["error1"] }, + { method: "info", args: ["info1"] }, + { method: "debug", args: ["debug1"] }, + { method: "trace", args: ["trace1"] }, + { method: "assert", args: [false, "assert1"] }, +]; + +const TEST_PAGE = "https://example.com/document-builder.sjs?html=tab"; + +add_task(async function test_method_and_arguments() { + for (const { method, args } of TESTS) { + // Use a dedicated tab for each test to avoid cached messages. + gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, TEST_PAGE); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + + info(`Test ConsoleApiListener for ${JSON.stringify({ method, args })}`); + + const listenerId = await listenToConsoleAPIMessage(); + await useConsoleInContent(method, args); + const { + arguments: msgArguments, + level, + timeStamp, + stacktrace, + } = await getConsoleAPIMessage(listenerId); + + if (method == "assert") { + // console.assert() consumes first argument. + args.shift(); + } + + is( + msgArguments.length, + args.length, + "Message event has the expected number of arguments" + ); + for (let i = 0; i < args.length; i++) { + Assert.deepEqual( + msgArguments[i], + args[i], + `Message event has the expected argument at index ${i}` + ); + } + is(level, method, "Message event has the expected level"); + ok(Number.isInteger(timeStamp), "Message event has a valid timestamp"); + + if (["assert", "error", "warn", "trace"].includes(method)) { + // Check stacktrace if method is allowed to contain one. + if (method === "warn") { + todo( + Array.isArray(stacktrace), + "stacktrace is of expected type Array (Bug 1744705)" + ); + } else { + ok(Array.isArray(stacktrace), "stacktrace is of expected type Array"); + Assert.greaterOrEqual( + stacktrace.length, + 1, + "stack trace contains at least one frame" + ); + } + } else { + is(typeof stacktrace, "undefined", "stack trace is is not present"); + } + + gBrowser.removeTab(gBrowser.selectedTab); + } +}); + +add_task(async function test_stacktrace() { + const script = ` + function foo() { console.error("cheese"); } + function bar() { foo(); } + bar(); + `; + + const listenerId = await listenToConsoleAPIMessage(); + await createScriptNode(script); + const { stacktrace } = await getConsoleAPIMessage(listenerId); + + ok(Array.isArray(stacktrace), "stacktrace is of expected type Array"); + + // First 3 frames are from the test script. + Assert.greaterOrEqual( + stacktrace.length, + 3, + "stack trace contains at least 3 frames" + ); + checkStackFrame(stacktrace[0], "about:blank", "foo", 2, 30); + checkStackFrame(stacktrace[1], "about:blank", "bar", 3, 22); + checkStackFrame(stacktrace[2], "about:blank", "", 4, 5); +}); + +function useConsoleInContent(method, args) { + info(`Call console API: console.${method}("${args.join('", "')}");`); + return SpecialPowers.spawn( + gBrowser.selectedBrowser, + [method, args], + (_method, _args) => { + content.console[_method].apply(content.console, _args); + } + ); +} + +function listenToConsoleAPIMessage() { + info("Listen to a console api message in content"); + return SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => { + const innerWindowId = content.windowGlobalChild.innerWindowId; + const { ConsoleAPIListener } = ChromeUtils.importESModule( + "chrome://remote/content/shared/listeners/ConsoleAPIListener.sys.mjs" + ); + const consoleAPIListener = new ConsoleAPIListener(innerWindowId); + const onMessage = consoleAPIListener.once("message"); + consoleAPIListener.startListening(); + + const listenerId = Math.random(); + content[listenerId] = { consoleAPIListener, onMessage }; + return listenerId; + }); +} + +function getConsoleAPIMessage(listenerId) { + info("Retrieve the message event captured for listener: " + listenerId); + return SpecialPowers.spawn( + gBrowser.selectedBrowser, + [listenerId], + async _listenerId => { + const { consoleAPIListener, onMessage } = content[_listenerId]; + const message = await onMessage; + + consoleAPIListener.destroy(); + + return message; + } + ); +} + +function checkStackFrame( + frame, + filename, + functionName, + lineNumber, + columnNumber +) { + is(frame.filename, filename, "Received expected filename for frame"); + is( + frame.functionName, + functionName, + "Received expected function name for frame" + ); + is(frame.lineNumber, lineNumber, "Received expected line for frame"); + is(frame.columnNumber, columnNumber, "Received expected column for frame"); +} diff --git a/remote/shared/listeners/test/browser/browser_ConsoleAPIListener_cached_messages.js b/remote/shared/listeners/test/browser/browser_ConsoleAPIListener_cached_messages.js new file mode 100644 index 0000000000..dae35a0b9a --- /dev/null +++ b/remote/shared/listeners/test/browser/browser_ConsoleAPIListener_cached_messages.js @@ -0,0 +1,100 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const TEST_PAGE = "https://example.com/document-builder.sjs?html=tab"; + +add_task(async function test_cached_messages() { + gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, TEST_PAGE); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => { + const innerWindowId = content.windowGlobalChild.innerWindowId; + const { ConsoleAPIListener } = ChromeUtils.importESModule( + "chrome://remote/content/shared/listeners/ConsoleAPIListener.sys.mjs" + ); + + info("Log two messages before starting the ConsoleAPIListener"); + content.console.log("message_1"); + content.console.log("message_2"); + + const listener = new ConsoleAPIListener(innerWindowId); + const messages = []; + + // We will keep the onMessage callback attached to the ConsoleAPIListener + // during the whole test to catch all the emitted events. + const onMessage = (evtName, message) => messages.push(message.arguments[0]); + + listener.on("message", onMessage); + listener.startListening(); + + info("Wait until the 2 cached messages have been emitted"); + await ContentTaskUtils.waitForCondition(() => messages.length == 2); + is(messages[0], "message_1"); + is(messages[1], "message_2"); + + info("Stop listening and log another message"); + listener.stopListening(); + content.backup = { listener, messages, onMessage }; + }); + + // Force a GC to check that old cached messages which have been garbage + // collected are not re-displayed. + await doGC(); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => { + const { listener, messages, onMessage } = content.backup; + content.console.log("message_3"); + + info("Start listening again and check the previous message is emitted"); + listener.startListening(); + await ContentTaskUtils.waitForCondition(() => messages.length == 3); + is(messages[2], "message_3"); + + info("Log another message and wait until it is emitted"); + content.console.log("message_4"); + await ContentTaskUtils.waitForCondition(() => messages.length == 4); + is(messages[3], "message_4"); + + listener.off("message", onMessage); + listener.destroy(); + + is(messages.length, 4, "Received 4 messages in total"); + }); + + info("Reload the current tab and check only new messages are emitted"); + await BrowserTestUtils.reloadTab(gBrowser.selectedTab); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => { + const innerWindowId = content.windowGlobalChild.innerWindowId; + const { ConsoleAPIListener } = ChromeUtils.importESModule( + "chrome://remote/content/shared/listeners/ConsoleAPIListener.sys.mjs" + ); + + info("Log a message before creating the ConsoleAPIListener"); + content.console.log("new_message_1"); + + const listener = new ConsoleAPIListener(innerWindowId); + const newMessages = []; + const onMessage = (evtName, message) => + newMessages.push(message.arguments[0]); + listener.on("message", onMessage); + + info("Start listening and wait for the cached message"); + listener.startListening(); + await ContentTaskUtils.waitForCondition(() => newMessages.length == 1); + is(newMessages[0], "new_message_1"); + + info("Log another message and wait until it is emitted"); + content.console.log("new_message_2"); + await ContentTaskUtils.waitForCondition(() => newMessages.length == 2); + is(newMessages[1], "new_message_2"); + + listener.off("message", onMessage); + listener.destroy(); + + is(newMessages.length, 2, "Received 2 messages in total"); + }); + + gBrowser.removeTab(gBrowser.selectedTab); +}); diff --git a/remote/shared/listeners/test/browser/browser_ConsoleListener.js b/remote/shared/listeners/test/browser/browser_ConsoleListener.js new file mode 100644 index 0000000000..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_NetworkListener.js b/remote/shared/listeners/test/browser/browser_NetworkListener.js new file mode 100644 index 0000000000..d756c4ffac --- /dev/null +++ b/remote/shared/listeners/test/browser/browser_NetworkListener.js @@ -0,0 +1,84 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const { NetworkListener } = ChromeUtils.importESModule( + "chrome://remote/content/shared/listeners/NetworkListener.sys.mjs" +); +const { TabManager } = ChromeUtils.importESModule( + "chrome://remote/content/shared/TabManager.sys.mjs" +); + +add_task(async function test_beforeRequestSent() { + const listener = new NetworkListener(); + const events = []; + const onEvent = (name, data) => events.push(data); + listener.on("before-request-sent", onEvent); + + const tab1 = BrowserTestUtils.addTab( + gBrowser, + "https://example.com/document-builder.sjs?html=tab" + ); + await BrowserTestUtils.browserLoaded(tab1.linkedBrowser); + const contextId1 = TabManager.getIdForBrowser(tab1.linkedBrowser); + + const tab2 = BrowserTestUtils.addTab( + gBrowser, + "https://example.com/document-builder.sjs?html=tab2" + ); + await BrowserTestUtils.browserLoaded(tab2.linkedBrowser); + const contextId2 = TabManager.getIdForBrowser(tab2.linkedBrowser); + + listener.startListening(); + + await fetch(tab1.linkedBrowser, "https://example.com/?1"); + ok(events.length == 1, "One event was received"); + assertNetworkEvent(events[0], contextId1, "https://example.com/?1"); + + info("Check that events are no longer emitted after calling stopListening"); + listener.stopListening(); + await fetch(tab1.linkedBrowser, "https://example.com/?2"); + ok(events.length == 1, "No new event was received"); + + listener.startListening(); + await fetch(tab1.linkedBrowser, "https://example.com/?3"); + ok(events.length == 2, "A new event was received"); + assertNetworkEvent(events[1], contextId1, "https://example.com/?3"); + + info("Check network event from the new tab"); + await fetch(tab2.linkedBrowser, "https://example.com/?4"); + ok(events.length == 3, "A new event was received"); + assertNetworkEvent(events[2], contextId2, "https://example.com/?4"); + + gBrowser.removeTab(tab1); + gBrowser.removeTab(tab2); + listener.off("before-request-sent", onEvent); + listener.destroy(); +}); + +add_task(async function test_beforeRequestSent_newTab() { + const listener = new NetworkListener(); + const onBeforeRequestSent = listener.once("before-request-sent"); + listener.startListening(); + + info("Check network event related to loading a new tab"); + const tab = BrowserTestUtils.addTab( + gBrowser, + "https://example.com/document-builder.sjs?html=tab" + ); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + const contextId = TabManager.getIdForBrowser(tab.linkedBrowser); + const event = await onBeforeRequestSent; + + assertNetworkEvent( + event, + contextId, + "https://example.com/document-builder.sjs?html=tab" + ); + gBrowser.removeTab(tab); +}); + +function assertNetworkEvent(event, expectedContextId, expectedUrl) { + is(event.contextId, expectedContextId, "Event has the expected context id"); + is(event.requestData.url, expectedUrl, "Event has the expected url"); +} diff --git a/remote/shared/listeners/test/browser/head.js b/remote/shared/listeners/test/browser/head.js new file mode 100644 index 0000000000..77b18515f3 --- /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.loadURIString(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(); + }); +} |