diff options
Diffstat (limited to 'devtools/shared/commands/resource')
92 files changed, 11855 insertions, 0 deletions
diff --git a/devtools/shared/commands/resource/legacy-listeners/console-messages.js b/devtools/shared/commands/resource/legacy-listeners/console-messages.js new file mode 100644 index 0000000000..ae3f81b4df --- /dev/null +++ b/devtools/shared/commands/resource/legacy-listeners/console-messages.js @@ -0,0 +1,59 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js"); + +module.exports = async function ({ targetCommand, targetFront, onAvailable }) { + // Allow the top level target unconditionnally. + // Also allow frame, but only in content toolbox, i.e. still ignore them in + // the context of the browser toolbox as we inspect messages via the process + // targets + const listenForFrames = targetCommand.descriptorFront.isTabDescriptor; + + // Allow workers when messages aren't dispatched to the main thread. + const listenForWorkers = + !targetCommand.rootFront.traits + .workerConsoleApiMessagesDispatchedToMainThread; + + const acceptTarget = + targetFront.isTopLevel || + targetFront.targetType === targetCommand.TYPES.PROCESS || + (targetFront.targetType === targetCommand.TYPES.FRAME && listenForFrames) || + (targetFront.targetType === targetCommand.TYPES.WORKER && listenForWorkers); + + if (!acceptTarget) { + return; + } + + const webConsoleFront = await targetFront.getFront("console"); + if (webConsoleFront.isDestroyed()) { + return; + } + + // Request notifying about new messages + await webConsoleFront.startListeners(["ConsoleAPI"]); + + // Fetch already existing messages + // /!\ The actor implementation requires to call startListeners(ConsoleAPI) first /!\ + const { messages } = await webConsoleFront.getCachedMessages(["ConsoleAPI"]); + + for (const message of messages) { + message.resourceType = ResourceCommand.TYPES.CONSOLE_MESSAGE; + } + onAvailable(messages); + + // Forward new message events + webConsoleFront.on("consoleAPICall", message => { + // Ignore console messages that are cloned from the content process + // (they aren't relevant to toolboxes still using legacy listeners) + if (message.clonedFromContentProcess) { + return; + } + + message.resourceType = ResourceCommand.TYPES.CONSOLE_MESSAGE; + onAvailable([message]); + }); +}; diff --git a/devtools/shared/commands/resource/legacy-listeners/css-changes.js b/devtools/shared/commands/resource/legacy-listeners/css-changes.js new file mode 100644 index 0000000000..e9f3e17075 --- /dev/null +++ b/devtools/shared/commands/resource/legacy-listeners/css-changes.js @@ -0,0 +1,28 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js"); + +module.exports = async function ({ targetFront, onAvailable }) { + if (!targetFront.hasActor("changes")) { + return; + } + + const changesFront = await targetFront.getFront("changes"); + + // Get all changes collected up to this point by the ChangesActor on the server, + // then fire each change as "add-change". + const changes = await changesFront.allChanges(); + await onAvailable(changes.map(change => toResource(change))); + + changesFront.on("add-change", change => onAvailable([toResource(change)])); +}; + +function toResource(change) { + return Object.assign(change, { + resourceType: ResourceCommand.TYPES.CSS_CHANGE, + }); +} diff --git a/devtools/shared/commands/resource/legacy-listeners/error-messages.js b/devtools/shared/commands/resource/legacy-listeners/error-messages.js new file mode 100644 index 0000000000..5ba898c917 --- /dev/null +++ b/devtools/shared/commands/resource/legacy-listeners/error-messages.js @@ -0,0 +1,62 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js"); +const { MESSAGE_CATEGORY } = require("resource://devtools/shared/constants.js"); + +module.exports = async function ({ targetCommand, targetFront, onAvailable }) { + // Allow the top level target unconditionnally. + // Also allow frame, but only in content toolbox, i.e. still ignore them in + // the context of the browser toolbox as we inspect messages via the process + // targets + // Also ignore workers as they are not supported yet. (see bug 1592584) + const listenForFrames = targetCommand.descriptorFront.isTabDescriptor; + const isAllowed = + targetFront.isTopLevel || + targetFront.targetType === targetCommand.TYPES.PROCESS || + (targetFront.targetType === targetCommand.TYPES.FRAME && listenForFrames); + + if (!isAllowed) { + return; + } + + const webConsoleFront = await targetFront.getFront("console"); + if (webConsoleFront.isDestroyed()) { + return; + } + + // Request notifying about new messages. Here the "PageError" type start listening for + // both actual PageErrors (emitted as "pageError" events) as well as LogMessages ( + // emitted as "logMessage" events). This function only set up the listener on the + // webConsoleFront for "pageError". + await webConsoleFront.startListeners(["PageError"]); + + // Fetch already existing messages + // /!\ The actor implementation requires to call startListeners("PageError") first /!\ + let { messages } = await webConsoleFront.getCachedMessages(["PageError"]); + + // On server < v79, we're also getting CSS Messages that we need to filter out. + messages = messages.filter( + message => message.pageError.category !== MESSAGE_CATEGORY.CSS_PARSER + ); + + messages.forEach(message => { + message.resourceType = ResourceCommand.TYPES.ERROR_MESSAGE; + }); + // Cached messages don't have the same shape as live messages, + // so we need to transform them. + onAvailable(messages); + + webConsoleFront.on("pageError", message => { + // On server < v79, we're getting CSS Messages that we need to filter out. + if (message.pageError.category === MESSAGE_CATEGORY.CSS_PARSER) { + return; + } + + message.resourceType = ResourceCommand.TYPES.ERROR_MESSAGE; + onAvailable([message]); + }); +}; diff --git a/devtools/shared/commands/resource/legacy-listeners/moz.build b/devtools/shared/commands/resource/legacy-listeners/moz.build new file mode 100644 index 0000000000..6ffb469891 --- /dev/null +++ b/devtools/shared/commands/resource/legacy-listeners/moz.build @@ -0,0 +1,14 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "console-messages.js", + "css-changes.js", + "error-messages.js", + "platform-messages.js", + "reflow.js", + "root-node.js", + "source.js", + "thread-states.js", +) diff --git a/devtools/shared/commands/resource/legacy-listeners/platform-messages.js b/devtools/shared/commands/resource/legacy-listeners/platform-messages.js new file mode 100644 index 0000000000..729696275e --- /dev/null +++ b/devtools/shared/commands/resource/legacy-listeners/platform-messages.js @@ -0,0 +1,44 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js"); + +module.exports = async function ({ targetCommand, targetFront, onAvailable }) { + // Only allow the top level target and processes. + // Frames can be ignored as logMessage are never sent to them anyway. + // Also ignore workers as they are not supported yet. (see bug 1592584) + const isAllowed = + targetFront.isTopLevel || + targetFront.targetType === targetCommand.TYPES.PROCESS; + if (!isAllowed) { + return; + } + + const webConsoleFront = await targetFront.getFront("console"); + if (webConsoleFront.isDestroyed()) { + return; + } + + // Request notifying about new messages. Here the "PageError" type start listening for + // both actual PageErrors (emitted as "pageError" events) as well as LogMessages ( + // emitted as "logMessage" events). This function only set up the listener on the + // webConsoleFront for "logMessage". + await webConsoleFront.startListeners(["PageError"]); + + // Fetch already existing messages + // /!\ The actor implementation requires to call startListeners("PageError") first /!\ + const { messages } = await webConsoleFront.getCachedMessages(["LogMessage"]); + + for (const message of messages) { + message.resourceType = ResourceCommand.TYPES.PLATFORM_MESSAGE; + } + onAvailable(messages); + + webConsoleFront.on("logMessage", message => { + message.resourceType = ResourceCommand.TYPES.PLATFORM_MESSAGE; + onAvailable([message]); + }); +}; diff --git a/devtools/shared/commands/resource/legacy-listeners/reflow.js b/devtools/shared/commands/resource/legacy-listeners/reflow.js new file mode 100644 index 0000000000..63802f510d --- /dev/null +++ b/devtools/shared/commands/resource/legacy-listeners/reflow.js @@ -0,0 +1,24 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js"); + +module.exports = async function ({ targetFront, onAvailable }) { + if (!targetFront.getTrait("isBrowsingContext")) { + // The reflows only work with BrowsingContext targets + return; + } + const reflowFront = await targetFront.getFront("reflow"); + reflowFront.on("reflows", reflows => + onAvailable([ + { + resourceType: ResourceCommand.TYPES.REFLOW, + reflows, + }, + ]) + ); + await reflowFront.start(); +}; diff --git a/devtools/shared/commands/resource/legacy-listeners/root-node.js b/devtools/shared/commands/resource/legacy-listeners/root-node.js new file mode 100644 index 0000000000..6fa2bcbf22 --- /dev/null +++ b/devtools/shared/commands/resource/legacy-listeners/root-node.js @@ -0,0 +1,61 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js"); + +module.exports = async function ({ targetFront, onAvailable, onDestroyed }) { + // XXX: When watching root node for a non top-level target, this will also + // ensure the inspector & walker fronts for the target are initialized. + // This also implies that we call reparentRemoteFrame on the new walker, which + // will create the link between the parent frame NodeFront and the inner + // document NodeFront. + // + // This is not something that will work when the resource is moved to the + // server. When it becomes a server side resource, a RootNode would be emitted + // directly by the target actor. + // + // This probably means that the root node resource cannot remain a NodeFront. + // It should not be a front and the client should be responsible for + // retrieving the corresponding NodeFront. + // + // The other thing that we are missing with this patch is that we should only + // create inspector & walker fronts (and call reparentRemoteFrame) when we get + // a RootNode which is directly under an iframe node which is currently + // visible and tracked in the markup view. + // + // For instance, with the following markup: + // html + // body + // div + // iframe + // remote doc + // + // If the markup view only sees nodes down to `div`, then the client is not + // currently tracking the nodeFront for the `iframe`, and getting a new root + // node for the remote document should NOT force the iframe to be tracked on + // on the client. + // + // When we get a RootNode resource, we will need a way to check this before + // initializing & reparenting the walker. + // + if (!targetFront.getTrait("isBrowsingContext")) { + // The root-node resource is only available on browsing-context targets. + return; + } + + const inspectorFront = await targetFront.getFront("inspector"); + inspectorFront.walker.on("root-available", node => { + node.resourceType = ResourceCommand.TYPES.ROOT_NODE; + return onAvailable([node]); + }); + + inspectorFront.walker.on("root-destroyed", node => { + node.resourceType = ResourceCommand.TYPES.ROOT_NODE; + return onDestroyed([node]); + }); + + await inspectorFront.walker.watchRootNode(); +}; diff --git a/devtools/shared/commands/resource/legacy-listeners/source.js b/devtools/shared/commands/resource/legacy-listeners/source.js new file mode 100644 index 0000000000..45ee62f70f --- /dev/null +++ b/devtools/shared/commands/resource/legacy-listeners/source.js @@ -0,0 +1,88 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js"); + +/** + * Emit SOURCE resources, which represents a Javascript source and has the following attributes set on "available": + * + * - introductionType {null|String}: A string indicating how this source code was introduced into the system. + * This will typically be set to "scriptElement", "eval", ... + * But this may have many other values: + * https://searchfox.org/mozilla-central/rev/ac142717cc067d875e83e4b1316f004f6e063a46/dom/script/ScriptLoader.cpp#2628-2639 + * https://searchfox.org/mozilla-central/search?q=symbol:_ZN2JS14CompileOptions19setIntroductionTypeEPKc&redirect=false + * https://searchfox.org/mozilla-central/rev/ac142717cc067d875e83e4b1316f004f6e063a46/devtools/server/actors/source.js#160-169 + * - sourceMapBaseURL {String}: Base URL where to look for a source map. + * This isn't the source map URL. + * - sourceMapURL {null|String}: URL of the source map, if there is one. + * - url {null|String}: URL of the source, if it relates to a particular URL. + * Evaled sources won't have any related URL. + * - isBlackBoxed {Boolean}: Specifying whether the source actor's 'black-boxed' flag is set. + * - extensionName {null|String}: If the source comes from an add-on, the add-on name. + */ +module.exports = async function ({ targetCommand, targetFront, onAvailable }) { + const isBrowserToolbox = + targetCommand.descriptorFront.isBrowserProcessDescriptor; + const isNonTopLevelFrameTarget = + !targetFront.isTopLevel && + targetFront.targetType === targetCommand.TYPES.FRAME; + + if (isBrowserToolbox && isNonTopLevelFrameTarget) { + // In the BrowserToolbox, non-top-level frame targets are already + // debugged via content-process targets. + return; + } + + const threadFront = await targetFront.getFront("thread"); + + // Use a list of all notified SourceFront as we don't have a newSource event for all sources + // but we sometime get sources notified both via newSource event *and* sources() method... + // We store actor ID instead of SourceFront as it appears that multiple SourceFront for the same + // actor are created... + const sourcesActorIDCache = new Set(); + + // Forward new sources (but also existing ones, see next comment) + threadFront.on("newSource", ({ source }) => { + if (sourcesActorIDCache.has(source.actor)) { + return; + } + sourcesActorIDCache.add(source.actor); + // source is a SourceActor's form, add the resourceType attribute on it + source.resourceType = ResourceCommand.TYPES.SOURCE; + onAvailable([source]); + }); + + // Forward already existing sources + // Note that calling `sources()` will end up emitting `newSource` event for all existing sources. + // But not in some cases, for example, when the thread is already paused. + // (And yes, it means that already existing sources can be transfered twice over the wire) + // + // Also, browser_ext_devtools_inspectedWindow_targetSwitch.js creates many top level targets, + // for which the SourceMapURLService will fetch sources. But these targets are destroyed while + // the test is running and when they are, we purge all pending requests, including this one. + // So ignore any error if this request failed on destruction. + let sources; + try { + sources = await threadFront.sources(); + } catch (e) { + if (threadFront.isDestroyed()) { + return; + } + throw e; + } + + // Note that `sources()` doesn't encapsulate SourceFront into a `source` attribute + // while `newSource` event does. + sources = sources.filter(source => { + return !sourcesActorIDCache.has(source.actor); + }); + for (const source of sources) { + sourcesActorIDCache.add(source.actor); + // source is a SourceActor's form, add the resourceType attribute on it + source.resourceType = ResourceCommand.TYPES.SOURCE; + } + onAvailable(sources); +}; diff --git a/devtools/shared/commands/resource/legacy-listeners/thread-states.js b/devtools/shared/commands/resource/legacy-listeners/thread-states.js new file mode 100644 index 0000000000..42c922072a --- /dev/null +++ b/devtools/shared/commands/resource/legacy-listeners/thread-states.js @@ -0,0 +1,81 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js"); + +module.exports = async function ({ targetCommand, targetFront, onAvailable }) { + const isBrowserToolbox = + targetCommand.descriptorFront.isBrowserProcessDescriptor; + const isNonTopLevelFrameTarget = + !targetFront.isTopLevel && + targetFront.targetType === targetCommand.TYPES.FRAME; + + if (isBrowserToolbox && isNonTopLevelFrameTarget) { + // In the BrowserToolbox, non-top-level frame targets are already + // debugged via content-process targets. + return; + } + + // Wait for the thread actor to be attached, otherwise getFront(thread) will throw for worker targets + // This is because worker target are still kind of descriptors and are only resolved into real target + // after being attached. And the thread actor ID is only retrieved and available after being attached. + await targetFront.onThreadAttached; + + if (targetFront.isDestroyed()) { + return; + } + const threadFront = await targetFront.getFront("thread"); + + let isInterrupted = false; + const onPausedPacket = packet => { + // If paused by an explicit interrupt, which are generated by the + // slow script dialog and internal events such as setting + // breakpoints, ignore the event. + const { why } = packet; + if (why.type === "interrupted" && !why.onNext) { + isInterrupted = true; + return; + } + + // Ignore attached events because they are not useful to the user. + if (why.type == "alreadyPaused" || why.type == "attached") { + return; + } + + onAvailable([ + { + resourceType: ResourceCommand.TYPES.THREAD_STATE, + state: "paused", + why, + frame: packet.frame, + }, + ]); + }; + threadFront.on("paused", onPausedPacket); + + threadFront.on("resumed", packet => { + // NOTE: the client suppresses resumed events while interrupted + // to prevent unintentional behavior. + // see [client docs](devtools/client/debugger/src/client/README.md#interrupted) for more information. + if (isInterrupted) { + isInterrupted = false; + return; + } + + onAvailable([ + { + resourceType: ResourceCommand.TYPES.THREAD_STATE, + state: "resumed", + }, + ]); + }); + + // Notify about already paused thread + const pausedPacket = threadFront.getLastPausePacket(); + if (pausedPacket) { + onPausedPacket(pausedPacket); + } +}; diff --git a/devtools/shared/commands/resource/moz.build b/devtools/shared/commands/resource/moz.build new file mode 100644 index 0000000000..190589df4b --- /dev/null +++ b/devtools/shared/commands/resource/moz.build @@ -0,0 +1,15 @@ +# 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/. + +DIRS += [ + "legacy-listeners", + "transformers", +] + +DevToolsModules( + "resource-command.js", +) + +if CONFIG["MOZ_BUILD_APP"] != "mobile/android": + BROWSER_CHROME_MANIFESTS += ["tests/browser.toml"] diff --git a/devtools/shared/commands/resource/resource-command.js b/devtools/shared/commands/resource/resource-command.js new file mode 100644 index 0000000000..c45dc6a584 --- /dev/null +++ b/devtools/shared/commands/resource/resource-command.js @@ -0,0 +1,1367 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { throttle } = require("resource://devtools/shared/throttle.js"); + +let gLastResourceId = 0; + +function cacheKey(resourceType, resourceId) { + return `${resourceType}:${resourceId}`; +} + +class ResourceCommand { + /** + * This class helps retrieving existing and listening to resources. + * A resource is something that: + * - the target you are debugging exposes + * - can be created as early as the process/worker/page starts loading + * - can already exist, or will be created later on + * - doesn't require any user data to be fetched, only a type/category + * + * @param object commands + * The commands object with all interfaces defined from devtools/shared/commands/ + */ + constructor({ commands }) { + this.targetCommand = commands.targetCommand; + + // Public attribute set by tests to disable throttling + this.throttlingDisabled = false; + + this._onTargetAvailable = this._onTargetAvailable.bind(this); + this._onTargetDestroyed = this._onTargetDestroyed.bind(this); + + this._onResourceAvailable = this._onResourceAvailable.bind(this); + this._onResourceDestroyed = this._onResourceDestroyed.bind(this); + + // Array of all the currently registered watchers, which contains object with attributes: + // - {String} resources: list of all resource watched by this one watcher + // - {Function} onAvailable: watcher's function to call when a new resource is available + // - {Function} onUpdated: watcher's function to call when a resource has been updated + // - {Function} onDestroyed: watcher's function to call when a resource is destroyed + this._watchers = []; + + // Set of watchers currently going through watchResources, only used to handle + // early calls to unwatchResources. Using a Set instead of an array for easier + // delete operations. + this._pendingWatchers = new Set(); + + // Caches for all resources by the order that the resource was taken. + this._cache = new Map(); + this._listenedResources = new Set(); + + // WeakMap used to avoid starting a legacy listener twice for the same + // target + resource-type pair. Legacy listener creation can be subject to + // race conditions. + // Maps a target front to an array of resource types. + this._existingLegacyListeners = new WeakMap(); + this._processingExistingResources = new Set(); + + // List of targetFront event listener unregistration functions keyed by target front. + // These are called when unwatching resources, so if a consumer starts watching resources again, + // we don't have listeners registered twice. + this._offTargetFrontListeners = new Map(); + + this._notifyWatchers = this._notifyWatchers.bind(this); + this._throttledNotifyWatchers = throttle(this._notifyWatchers, 100); + } + + get watcherFront() { + return this.targetCommand.watcherFront; + } + + addResourceToCache(resource) { + const { resourceId, resourceType } = resource; + this._cache.set(cacheKey(resourceType, resourceId), resource); + } + + /** + * Clear all the resources related to specifed resource types. + * Should also trigger clearing of the caches that exists on the related + * serverside resource watchers. + * + * @param {Array:string} resourceTypes + * A list of all the resource types whose + * resources shouled be cleared. + */ + async clearResources(resourceTypes) { + if (!Array.isArray(resourceTypes)) { + throw new Error("clearResources expects a list of resources types"); + } + // Clear the cached resources of the type. + for (const [key, resource] of this._cache) { + if (resourceTypes.includes(resource.resourceType)) { + // NOTE: To anyone paranoid like me, yes it is okay to delete from a Map while iterating it. + this._cache.delete(key); + } + } + + const resourcesToClear = resourceTypes.filter(resourceType => + this.hasResourceCommandSupport(resourceType) + ); + if (resourcesToClear.length) { + this.watcherFront.clearResources(resourcesToClear); + } + } + /** + * Return all specified resources cached in this watcher. + * + * @param {String} resourceType + * @return {Array} resources cached in this watcher + */ + getAllResources(resourceType) { + const result = []; + for (const resource of this._cache.values()) { + if (resource.resourceType === resourceType) { + result.push(resource); + } + } + return result; + } + + /** + * Return the specified resource cached in this watcher. + * + * @param {String} resourceType + * @param {String} resourceId + * @return {Object} resource cached in this watcher + */ + getResourceById(resourceType, resourceId) { + return this._cache.get(cacheKey(resourceType, resourceId)); + } + + /** + * Request to start retrieving all already existing instances of given + * type of resources and also start watching for the one to be created after. + * + * @param {Array:string} resources + * List of all resources which should be fetched and observed. + * @param {Object} options + * - {Function} onAvailable: This attribute is mandatory. + * Function which will be called with an array of resources + * each time resource(s) are created. + * A second dictionary argument with `areExistingResources` boolean + * attribute helps knowing if that's live resources, or some coming + * from ResourceCommand cache. + * - {Function} onUpdated: This attribute is optional. + * Function which will be called with an array of updates resources + * each time resource(s) are updated. + * These resources were previously notified via onAvailable. + * - {Function} onDestroyed: This attribute is optional. + * Function which will be called with an array of deleted resources + * each time resource(s) are destroyed. + * - {boolean} ignoreExistingResources: + * This attribute is optional. Default value is false. + * If set to true, onAvailable won't be called with + * existing resources. + */ + async watchResources(resources, options) { + const { + onAvailable, + onUpdated, + onDestroyed, + ignoreExistingResources = false, + } = options; + + if (typeof onAvailable !== "function") { + throw new Error( + "ResourceCommand.watchResources expects an onAvailable function as argument" + ); + } + + for (const type of resources) { + if (!this._isValidResourceType(type)) { + throw new Error( + `ResourceCommand.watchResources invoked with an unknown type: "${type}"` + ); + } + } + + // Pending watchers are used in unwatchResources to remove watchers which + // are not fully registered yet. Store `onAvailable` which is the unique key + // for a watcher, as well as the resources array, so that unwatchResources + // can update the array if we stop watching a specific resource. + const pendingWatcher = { + resources, + onAvailable, + }; + this._pendingWatchers.add(pendingWatcher); + + // Bug 1675763: Watcher actor is not available in all situations yet. + if (!this._listenerRegistered && this.watcherFront) { + this._listenerRegistered = true; + // Resources watched from the parent process will be emitted on the Watcher Actor. + // So that we also have to listen for this event on it, in addition to all targets. + this.watcherFront.on( + "resource-available-form", + this._onResourceAvailable.bind(this, { + watcherFront: this.watcherFront, + }) + ); + this.watcherFront.on( + "resource-updated-form", + this._onResourceUpdated.bind(this, { watcherFront: this.watcherFront }) + ); + this.watcherFront.on( + "resource-destroyed-form", + this._onResourceDestroyed.bind(this, { + watcherFront: this.watcherFront, + }) + ); + } + + const promises = []; + for (const resource of resources) { + promises.push(this._startListening(resource)); + } + await Promise.all(promises); + + // The resource cache is immediately filled when receiving the sources, but they are + // emitted with a delay due to throttling. Since the cache can contain resources that + // will soon be emitted, we have to flush it before adding the new listeners. + // Otherwise _forwardExistingResources might emit resources that will also be emitted by + // the next `_notifyWatchers` call done when calling `_startListening`, which will pull the + // "already existing" resources. + this._notifyWatchers(); + + // Update the _pendingWatchers set before adding the watcher to _watchers. + this._pendingWatchers.delete(pendingWatcher); + + // If unwatchResources was called in the meantime, use pendingWatcher's + // resources to get the updated list of watched resources. + const watchedResources = pendingWatcher.resources; + + // If no resource needs to be watched anymore, do not add an empty watcher + // to _watchers, and do not notify about cached resources. + if (!watchedResources.length) { + return; + } + + // Register the watcher just after calling _startListening in order to avoid it being called + // for already existing resources, which will optionally be notified via _forwardExistingResources + this._watchers.push({ + resources: watchedResources, + onAvailable, + onUpdated, + onDestroyed, + pendingEvents: [], + }); + + if (!ignoreExistingResources) { + await this._forwardExistingResources(watchedResources, onAvailable); + } + } + + /** + * Stop watching for given type of resources. + * See `watchResources` for the arguments as both methods receive the same. + * Note that `onUpdated` and `onDestroyed` attributes of `options` aren't used here. + * Only `onAvailable` attribute is looked up and we unregister all the other registered callbacks + * when a matching available callback is found. + */ + unwatchResources(resources, options) { + const { onAvailable } = options; + + if (typeof onAvailable !== "function") { + throw new Error( + "ResourceCommand.unwatchResources expects an onAvailable function as argument" + ); + } + + for (const type of resources) { + if (!this._isValidResourceType(type)) { + throw new Error( + `ResourceCommand.unwatchResources invoked with an unknown type: "${type}"` + ); + } + } + + // Unregister the callbacks from the watchers registries. + // Check _watchers for the fully initialized watchers, as well as + // `_pendingWatchers` for new watchers still being created by `watchResources` + const allWatchers = [...this._watchers, ...this._pendingWatchers]; + for (const watcherEntry of allWatchers) { + // onAvailable is the only mandatory argument which ends up being used to match + // the right watcher entry. + if (watcherEntry.onAvailable == onAvailable) { + // Remove all resources that we stop watching. We may still watch for some others. + watcherEntry.resources = watcherEntry.resources.filter(resourceType => { + return !resources.includes(resourceType); + }); + } + } + this._watchers = this._watchers.filter(entry => { + // Remove entries entirely if it isn't watching for any resource type + return !!entry.resources.length; + }); + + // Stop listening to all resources for which we removed the last watcher + for (const resource of resources) { + const isResourceWatched = allWatchers.some(watcherEntry => + watcherEntry.resources.includes(resource) + ); + + // Also check in _listenedResources as we may call unwatchResources + // for resources that we haven't started watching for. + if (!isResourceWatched && this._listenedResources.has(resource)) { + this._stopListening(resource); + } + } + + // Stop watching for targets if we removed the last listener. + if (this._listenedResources.size == 0) { + this._unwatchAllTargets(); + } + } + + /** + * Wait for a single resource of the provided resourceType. + * + * @param {String} resourceType + * One of ResourceCommand.TYPES, type of the expected resource. + * @param {Object} additional options + * - {Boolean} ignoreExistingResources: ignore existing resources or not. + * - {Function} predicate: if provided, will wait until a resource makes + * predicate(resource) return true. + * @return {Promise<Object>} + * Return a promise which resolves once we fully settle the resource listener. + * You should await for its resolution before doing the action which may fire + * your resource. + * This promise will expose an object with `onResource` attribute, + * itself being a promise, which will resolve once a matching resource is received. + */ + async waitForNextResource( + resourceType, + { ignoreExistingResources = false, predicate } = {} + ) { + // If no predicate was provided, convert to boolean to avoid resolving for + // empty `resources` arrays. + predicate = predicate || (resource => !!resource); + + let resolve; + const promise = new Promise(r => (resolve = r)); + const onAvailable = async resources => { + const matchingResource = resources.find(resource => predicate(resource)); + if (matchingResource) { + this.unwatchResources([resourceType], { onAvailable }); + resolve(matchingResource); + } + }; + + await this.watchResources([resourceType], { + ignoreExistingResources, + onAvailable, + }); + return { onResource: promise }; + } + + /** + * Check if there are any watchers for the specified resource. + * + * @param {String} resourceType + * One of ResourceCommand.TYPES + * @return {Boolean} + * If the resources type is beibg watched. + */ + isResourceWatched(resourceType) { + return this._listenedResources.has(resourceType); + } + + /** + * Start watching for all already existing and future targets. + * + * We are using ALL_TYPES, but this won't force listening to all types. + * It will only listen for types which are defined by `TargetCommand.startListening`. + */ + async _watchAllTargets() { + if (!this._watchTargetsPromise) { + // If this is the very first listener registered, of all kind of resource types: + // * we want to start observing targets via TargetCommand + // * _onTargetAvailable will be called for each already existing targets and the next one to come + this._watchTargetsPromise = this.targetCommand.watchTargets({ + types: this.targetCommand.ALL_TYPES, + onAvailable: this._onTargetAvailable, + onDestroyed: this._onTargetDestroyed, + }); + } + return this._watchTargetsPromise; + } + + _unwatchAllTargets() { + if (!this._watchTargetsPromise) { + return; + } + + for (const offList of this._offTargetFrontListeners.values()) { + offList.forEach(off => off()); + } + this._offTargetFrontListeners.clear(); + + this._watchTargetsPromise = null; + this.targetCommand.unwatchTargets({ + types: this.targetCommand.ALL_TYPES, + onAvailable: this._onTargetAvailable, + onDestroyed: this._onTargetDestroyed, + }); + } + + /** + * For a given resource type, start the legacy listeners for all already existing targets. + * Do that only if we have to. If this resourceType requires legacy listeners. + */ + async _startLegacyListenersForExistingTargets(resourceType) { + // If we were already listening to targets, we want to start the legacy listeners + // for all already existing targets. + // + // Only try instantiating the legacy listener, if this resource type: + // - has legacy listener implementation + // (new resource types may not be supported by old runtime and just not be received without breaking anything) + // - isn't supported by the server, or, the target type requires the a legacy listener implementation. + const shouldRunLegacyListeners = + resourceType in LegacyListeners && + (!this.hasResourceCommandSupport(resourceType) || + this._shouldRunLegacyListenerEvenWithWatcherSupport(resourceType)); + if (shouldRunLegacyListeners) { + const promises = []; + const targets = this.targetCommand.getAllTargets( + this.targetCommand.ALL_TYPES + ); + for (const targetFront of targets) { + // We disable warning in case we already registered the legacy listener for this target + // as this code may race with the call from onTargetAvailable if we end up having multiple + // calls to _startListening in parallel. + promises.push( + this._watchResourcesForTarget({ + targetFront, + resourceType, + disableWarning: true, + }) + ); + } + await Promise.all(promises); + } + } + + /** + * Method called by the TargetCommand for each already existing or target which has just been created. + * + * @param {Object} arg + * @param {Front} arg.targetFront + * The Front of the target that is available. + * This Front inherits from TargetMixin and is typically + * composed of a WindowGlobalTargetFront or ContentProcessTargetFront. + * @param {Boolean} arg.isTargetSwitching + * true when the new target was created because of a target switching. + */ + async _onTargetAvailable({ targetFront, isTargetSwitching }) { + const resources = []; + if (isTargetSwitching) { + // WatcherActor currently only watches additional frame targets and + // explicitely ignores top level one that may be created when navigating + // to a new process. + // In order to keep working resources that are being watched via the + // Watcher actor, we have to unregister and re-register the resource + // types. This will force calling `Resources.watchResources` on the new top + // level target. + for (const resourceType of Object.values(ResourceCommand.TYPES)) { + // ...which has at least one listener... + if (!this._listenedResources.has(resourceType)) { + continue; + } + + if (this._shouldRestartListenerOnTargetSwitching(resourceType)) { + this._stopListening(resourceType, { + bypassListenerCount: true, + }); + resources.push(resourceType); + } + } + } + + if (targetFront.isDestroyed()) { + return; + } + + // If we are target switching, we already stop & start listening to all the + // currently monitored resources. + if (!isTargetSwitching) { + // For each resource type... + for (const resourceType of Object.values(ResourceCommand.TYPES)) { + // ...which has at least one listener... + if (!this._listenedResources.has(resourceType)) { + continue; + } + // ...request existing resource and new one to come from this one target + // *but* only do that for backward compat, where we don't have the watcher API + // (See bug 1626647) + await this._watchResourcesForTarget({ targetFront, resourceType }); + } + } + + // Compared to the TargetCommand and Watcher.watchTargets, + // We do call Watcher.watchResources, but the events are fired on the target. + // That's because the Watcher runs in the parent process/main thread, while resources + // are available from the target's process/thread. + const offResourceAvailable = targetFront.on( + "resource-available-form", + this._onResourceAvailable.bind(this, { targetFront }) + ); + const offResourceUpdated = targetFront.on( + "resource-updated-form", + this._onResourceUpdated.bind(this, { targetFront }) + ); + const offResourceDestroyed = targetFront.on( + "resource-destroyed-form", + this._onResourceDestroyed.bind(this, { targetFront }) + ); + + const offList = this._offTargetFrontListeners.get(targetFront) || []; + offList.push( + offResourceAvailable, + offResourceUpdated, + offResourceDestroyed + ); + + if (isTargetSwitching) { + await Promise.all( + resources.map(resourceType => + this._startListening(resourceType, { + bypassListenerCount: true, + }) + ) + ); + } + + // DOCUMENT_EVENT's will-navigate should replace target actor's will-navigate event, + // but only for targets provided by the watcher actor. + // Emit a fake DOCUMENT_EVENT's "will-navigate" out of target actor's will-navigate + // until watcher actor is supported by all descriptors (bug 1675763). + if (!this.targetCommand.hasTargetWatcherSupport()) { + const offWillNavigate = targetFront.on( + "will-navigate", + ({ url, isFrameSwitching }) => { + targetFront.emit("resource-available-form", [ + { + resourceType: this.TYPES.DOCUMENT_EVENT, + name: "will-navigate", + time: Date.now(), // will-navigate was not passing any timestamp + isFrameSwitching, + newURI: url, + }, + ]); + } + ); + offList.push(offWillNavigate); + } + + this._offTargetFrontListeners.set(targetFront, offList); + } + + _shouldRestartListenerOnTargetSwitching(resourceType) { + // Note that we aren't using isServerTargetSwitchingEnabled, nor checking the + // server side target switching preference as we may have server side targets + // even when this is false/disabled. + // This will happen for bfcache navigations, even with server side targets disabled. + // `followWindowGlobalLifeCycle` will be false for the first top level target + // and only become true when doing a bfcache navigation. + // (only server side targets follow the WindowGlobal lifecycle) + // When server side targets are enabled, this will always be true. + const isServerSideTarget = + this.targetCommand.targetFront.targetForm.followWindowGlobalLifeCycle; + if (isServerSideTarget) { + // For top-level targets created from the server, only restart legacy + // listeners. + return !this.hasResourceCommandSupport(resourceType); + } + + // For top-level targets created from the client we should always restart + // listeners. + return true; + } + + /** + * Method called by the TargetCommand when a target has just been destroyed + * @param {Object} arg + * @param {Front} arg.targetFront + * The Front of the target that was destroyed + * @param {Boolean} arg.isModeSwitching + * true when this is called as the result of a change to the devtools.browsertoolbox.scope pref. + */ + _onTargetDestroyed({ targetFront, isModeSwitching }) { + // Clear the map of legacy listeners for this target. + this._existingLegacyListeners.set(targetFront, []); + this._offTargetFrontListeners.delete(targetFront); + + // Purge the cache from any resource related to the destroyed target. + // Top level BrowsingContext target will be purge via DOCUMENT_EVENT will-navigate events. + // If we were to clean resources from target-destroyed, we will clear resources + // happening between will-navigate and target-destroyed. Typically the navigation request + // At the moment, isModeSwitching can only be true when targetFront.isTopLevel isn't true, + // so we don't need to add a specific check for isModeSwitching. + if (!targetFront.isTopLevel || !targetFront.isBrowsingContext) { + for (const [key, resource] of this._cache) { + if (resource.targetFront === targetFront) { + // NOTE: To anyone paranoid like me, yes it is okay to delete from a Map while iterating it. + this._cache.delete(key); + } + } + } + + // Purge "available" pendingEvents for resources from the destroyed target when switching + // mode as we want to ignore those. + if (isModeSwitching) { + for (const watcherEntry of this._watchers) { + for (const pendingEvent of watcherEntry.pendingEvents) { + if (pendingEvent.callbackType == "available") { + pendingEvent.updates = pendingEvent.updates.filter( + update => update.targetFront !== targetFront + ); + } + } + } + } + } + + /** + * Method called either by: + * - the backward compatibility code (LegacyListeners) + * - target actors RDP events + * whenever an already existing resource is being listed or when a new one + * has been created. + * + * @param {Object} source + * A dictionary object with only one of these two attributes: + * - targetFront: a Target Front, if the resource is watched from the target process or thread + * - watcherFront: a Watcher Front, if the resource is watched from the parent process + * @param {Array<json/Front>} resources + * Depending on the resource Type, it can be an Array composed of either JSON objects or Fronts, + * which describes the resource. + */ + async _onResourceAvailable({ targetFront, watcherFront }, resources) { + let includesDocumentEventWillNavigate = false; + let includesDocumentEventDomLoading = false; + for (let resource of resources) { + const { resourceType } = resource; + + if (watcherFront) { + targetFront = await this._getTargetForWatcherResource(resource); + // When we receive resources from the Watcher actor, + // there is no guarantee that the target front is fully initialized. + // The Target Front is initialized by the TargetCommand, by calling TargetFront.attachAndInitThread. + // We have to wait for its completion as resources watchers are expecting it to be completed. + // + // But when navigating, we may receive resources packets for a destroyed target. + // Or, in the context of the browser toolbox, they may not relate to any target. + if (targetFront) { + await targetFront.initialized; + } + } + + // isAlreadyExistingResource indicates that the resources already existed before + // the resource command started watching for this type of resource. + resource.isAlreadyExistingResource = + this._processingExistingResources.has(resourceType); + + // Put the targetFront on the resource for easy retrieval. + // (Resources from the legacy listeners may already have the attribute set) + if (!resource.targetFront) { + resource.targetFront = targetFront; + } + + if (ResourceTransformers[resourceType]) { + resource = ResourceTransformers[resourceType]({ + resource, + targetCommand: this.targetCommand, + targetFront, + watcherFront: this.watcherFront, + }); + } + + if (!resource.resourceId) { + resource.resourceId = `auto:${++gLastResourceId}`; + } + + // Only consider top level document, and ignore remote iframes top document + const isWillNavigate = + resourceType == ResourceCommand.TYPES.DOCUMENT_EVENT && + resource.name == "will-navigate"; + if (isWillNavigate && resource.targetFront.isTopLevel) { + includesDocumentEventWillNavigate = true; + this._onWillNavigate(resource.targetFront); + } + + if ( + resourceType == ResourceCommand.TYPES.DOCUMENT_EVENT && + resource.name == "dom-loading" && + resource.targetFront.isTopLevel + ) { + includesDocumentEventDomLoading = true; + } + + this._queueResourceEvent("available", resourceType, resource); + + // Avoid storing will-navigate resource and consider it as a transcient resource. + // We do that to prevent leaking this resource (and its target) on navigation. + // We do clear the cache in _onWillNavigate, that we call a few lines before this. + if (!isWillNavigate) { + this.addResourceToCache(resource); + } + } + + // If we receive the DOCUMENT_EVENT for: + // - will-navigate + // - dom-loading + we're using the service worker legacy listener + // then flush immediately the resources to notify about the navigation sooner than later. + // (this is especially useful for tests, even if they should probably avoid depending on this...) + if ( + includesDocumentEventWillNavigate || + (includesDocumentEventDomLoading && + !this.targetCommand.hasTargetWatcherSupport("service_worker")) || + this.throttlingDisabled + ) { + this._notifyWatchers(); + } else { + this._throttledNotifyWatchers(); + } + } + + /** + * Method called either by: + * - the backward compatibility code (LegacyListeners) + * - target actors RDP events + * Called everytime a resource is updated in the remote target. + * + * @param {Object} source + * Please see _onResourceAvailable for this parameter. + * @param {Array<Object>} updates + * Depending on the listener. + * + * Among the element in the array, the following attributes are given special handling. + * - resourceType {String}: + * The type of resource to be updated. + * - resourceId {String}: + * The id of resource to be updated. + * - resourceUpdates {Object}: + * If resourceUpdates is in the element, a cached resource specified by resourceType + * and resourceId is updated by Object.assign(cachedResource, resourceUpdates). + * - nestedResourceUpdates {Object}: + * If `nestedResourceUpdates` is passed, update one nested attribute with a new value + * This allows updating one attribute of an object stored in a resource's attribute, + * as well as adding new elements to arrays. + * `path` is an array mentioning all nested attribute to walk through. + * `value` is the new nested attribute value to set. + * + * And also, the element is passed to the listener as it is as โupdateโ object. + * So if we don't want to update a cached resource but have information want to + * pass on to the listener, can pass it on using attributes other than the ones + * listed above. + * For example, if the element consists of like + * "{ resourceType:โฆ resourceId:โฆ, testValue: โtestโ, }โ, + * the listener can receive the value as follows. + * + * onResourceUpdate({ update }) { + * console.log(update.testValue); // โtestโ should be displayed + * } + */ + async _onResourceUpdated({ targetFront, watcherFront }, updates) { + for (const update of updates) { + const { + resourceType, + resourceId, + resourceUpdates, + nestedResourceUpdates, + } = update; + + if (!resourceId) { + console.warn(`Expected resource ${resourceType} to have a resourceId`); + } + + // See _onResourceAvailable() + // We also need to wait for the related targetFront to be initialized + // otherwise we would notify about the udpate *before* the available + // and the resource won't be in _cache. + if (watcherFront) { + targetFront = await this._getTargetForWatcherResource(update); + // When we receive the navigation request, the target front has already been + // destroyed, but this is fine. The cached resource has the reference to + // the (destroyed) target front and it is fully initialized. + if (targetFront) { + await targetFront.initialized; + } + } + + const existingResource = this._cache.get( + cacheKey(resourceType, resourceId) + ); + if (!existingResource) { + continue; + } + + if (resourceUpdates) { + Object.assign(existingResource, resourceUpdates); + } + + if (nestedResourceUpdates) { + for (const { path, value } of nestedResourceUpdates) { + let target = existingResource; + + for (let i = 0; i < path.length - 1; i++) { + target = target[path[i]]; + } + + target[path[path.length - 1]] = value; + } + } + this._queueResourceEvent("updated", resourceType, { + resource: existingResource, + update, + }); + } + this._throttledNotifyWatchers(); + } + + /** + * Called everytime a resource is destroyed in the remote target. + * See _onResourceAvailable for the argument description. + */ + async _onResourceDestroyed({ targetFront, watcherFront }, resources) { + for (const resource of resources) { + const { resourceType, resourceId } = resource; + this._cache.delete(cacheKey(resourceType, resourceId)); + if (!resource.targetFront) { + resource.targetFront = targetFront; + } + this._queueResourceEvent("destroyed", resourceType, resource); + } + this._throttledNotifyWatchers(); + } + + _queueResourceEvent(callbackType, resourceType, update) { + for (const { resources, pendingEvents } of this._watchers) { + // This watcher doesn't listen to this type of resource + if (!resources.includes(resourceType)) { + continue; + } + // If we receive a new event of the same type, accumulate the new update in the last event + if (pendingEvents.length) { + const lastEvent = pendingEvents[pendingEvents.length - 1]; + if (lastEvent.callbackType == callbackType) { + lastEvent.updates.push(update); + continue; + } + } + // Otherwise, pile up a new event, which will force calling watcher + // callback a new time + pendingEvents.push({ + callbackType, + updates: [update], + }); + } + } + + /** + * Flush the pending event and notify all the currently registered watchers + * about all the available, updated and destroyed events that have been accumulated in + * `_watchers`'s `pendingEvents` arrays. + */ + _notifyWatchers() { + for (const watcherEntry of this._watchers) { + const { onAvailable, onUpdated, onDestroyed, pendingEvents } = + watcherEntry; + // Immediately clear the buffer in order to avoid possible races, where an event listener + // would end up somehow adding a new throttled resource + watcherEntry.pendingEvents = []; + + for (const { callbackType, updates } of pendingEvents) { + try { + if (callbackType == "available") { + onAvailable(updates, { areExistingResources: false }); + } else if (callbackType == "updated" && onUpdated) { + onUpdated(updates); + } else if (callbackType == "destroyed" && onDestroyed) { + onDestroyed(updates); + } + } catch (e) { + console.error( + "Exception while calling a ResourceCommand", + callbackType, + "callback", + ":", + e + ); + } + } + } + } + + // Compute the target front if the resource comes from the Watcher Actor. + // (`targetFront` will be null as the watcher is in the parent process + // and targets are in distinct processes) + _getTargetForWatcherResource(resource) { + const { browsingContextID, innerWindowId, resourceType } = resource; + + // Some privileged resources aren't related to any BrowsingContext + // and so aren't bound to any Target Front. + // Server watchers should pass an explicit "-1" value in order to prevent + // silently ignoring an undefined browsingContextID attribute. + if (browsingContextID == -1) { + return null; + } + + if (innerWindowId && this.targetCommand.isServerTargetSwitchingEnabled()) { + return this.watcherFront.getWindowGlobalTargetByInnerWindowId( + innerWindowId + ); + } else if (browsingContextID) { + return this.watcherFront.getWindowGlobalTarget(browsingContextID); + } + console.error( + `Resource of ${resourceType} is missing a browsingContextID or innerWindowId attribute` + ); + return null; + } + + _onWillNavigate(targetFront) { + // Special case for toolboxes debugging a document, + // purge the cache entirely when we start navigating to a new document. + // Other toolboxes and additional target for remote iframes or content process + // will be purge from onTargetDestroyed. + + // NOTE: we could `clear` the cache here, but technically if anything is + // currently iterating over resources provided by getAllResources, that + // would interfere with their iteration. We just assign a new Map here to + // leave those iterators as is. + this._cache = new Map(); + } + + /** + * Tells if the server supports listening to the given resource type + * via the watcher actor's watchResources method. + * + * @return {Boolean} True, if the server supports this type. + */ + hasResourceCommandSupport(resourceType) { + return this.watcherFront?.traits?.resources?.[resourceType]; + } + + /** + * Tells if the server supports listening to the given resource type + * via the watcher actor's watchResources method, and that, for a specific + * target. + * + * @return {Boolean} True, if the server supports this type. + */ + _hasResourceCommandSupportForTarget(resourceType, targetFront) { + // First check if the watcher supports this target type. + // If it doesn't, no resource type can be listened via the Watcher actor for this target. + if (!this.targetCommand.hasTargetWatcherSupport(targetFront.targetType)) { + return false; + } + + return this.hasResourceCommandSupport(resourceType); + } + + _isValidResourceType(type) { + return this.ALL_TYPES.includes(type); + } + + /** + * Start listening for a given type of resource. + * For backward compatibility code, we register the legacy listeners on + * each individual target + * + * @param {String} resourceType + * One string of ResourceCommand.TYPES, which designates the types of resources + * to be listened. + * @param {Object} + * - {Boolean} bypassListenerCount + * Pass true to avoid checking/updating the listenersCount map. + * Exclusively used when target switching, to stop & start listening + * to all resources. + */ + async _startListening(resourceType, { bypassListenerCount = false } = {}) { + if (!bypassListenerCount) { + if (this._listenedResources.has(resourceType)) { + return; + } + this._listenedResources.add(resourceType); + } + + this._processingExistingResources.add(resourceType); + + // Ensuring enabling listening to targets. + // This will be a no-op expect for the very first call to `_startListening`, + // where it is going to call `onTargetAvailable` for all already existing targets, + // as well as for those who will be created later. + // + // Do this *before* calling WatcherActor.watchResources in order to register "resource-available" + // listeners on targets before these events start being emitted. + await this._watchAllTargets(resourceType); + + // When we are calling _startListening for the first time, _watchAllTargets + // will register legacylistener when it will call onTargetAvailable for all existing targets. + // But for any next calls to _startListening, _watchAllTargets will be a no-op, + // and nothing will start legacy listener for each already registered targets. + await this._startLegacyListenersForExistingTargets(resourceType); + + // If the server supports the Watcher API and the Watcher supports + // this resource type, use this API + if (this.hasResourceCommandSupport(resourceType)) { + await this.watcherFront.watchResources([resourceType]); + } + this._processingExistingResources.delete(resourceType); + } + + /** + * Return true if the resource should be watched via legacy listener, + * even when watcher supports this resource type. + * + * Bug 1678385: In order to support watching for JS Source resource + * for service workers and parent process workers, which aren't supported yet + * by the watcher actor, we do not bail out here and allow to execute + * the legacy listener for these targets. + * Once bug 1608848 is fixed, we can remove this and never trigger + * the legacy listeners codepath for these resource types. + * + * If this isn't fixed soon, we may add other resources we want to see + * being fetched from these targets. + */ + _shouldRunLegacyListenerEvenWithWatcherSupport(resourceType) { + return WORKER_RESOURCE_TYPES.includes(resourceType); + } + + async _forwardExistingResources(resourceTypes, onAvailable) { + const existingResources = []; + for (const resource of this._cache.values()) { + if (resourceTypes.includes(resource.resourceType)) { + existingResources.push(resource); + } + } + if (existingResources.length) { + await onAvailable(existingResources, { areExistingResources: true }); + } + } + + /** + * Call backward compatibility code from `LegacyListeners` in order to listen for a given + * type of resource from a given target. + */ + async _watchResourcesForTarget({ + targetFront, + resourceType, + disableWarning = false, + }) { + if (this._hasResourceCommandSupportForTarget(resourceType, targetFront)) { + // This resource / target pair should already be handled by the watcher, + // no need to start legacy listeners. + return; + } + + // All workers target types are still not supported by the watcher + // so that we have to spawn legacy listener for all their resources. + // But some resources are irrelevant to workers, like network events. + // And we removed the related legacy listener as they are no longer used. + if ( + targetFront.targetType.endsWith("worker") && + !WORKER_RESOURCE_TYPES.includes(resourceType) + ) { + return; + } + + if (targetFront.isDestroyed()) { + return; + } + + const onAvailable = this._onResourceAvailable.bind(this, { targetFront }); + const onUpdated = this._onResourceUpdated.bind(this, { targetFront }); + const onDestroyed = this._onResourceDestroyed.bind(this, { targetFront }); + + if (!(resourceType in LegacyListeners)) { + throw new Error(`Missing legacy listener for ${resourceType}`); + } + + const legacyListeners = + this._existingLegacyListeners.get(targetFront) || []; + if (legacyListeners.includes(resourceType)) { + if (!disableWarning) { + console.warn( + `Already started legacy listener for ${resourceType} on ${targetFront.actorID}` + ); + } + return; + } + this._existingLegacyListeners.set( + targetFront, + legacyListeners.concat(resourceType) + ); + + try { + await LegacyListeners[resourceType]({ + targetCommand: this.targetCommand, + targetFront, + onAvailable, + onDestroyed, + onUpdated, + }); + } catch (e) { + // Swallow the error to avoid breaking calls to watchResources which will + // loop on all existing targets to create legacy listeners. + // If a legacy listener fails to handle a target for some reason, we + // should still try to process other targets as much as possible. + // See Bug 1687645. + console.error( + `Failed to start [${resourceType}] legacy listener for target ${targetFront.actorID}`, + e + ); + } + } + + /** + * Reverse of _startListening. Stop listening for a given type of resource. + * For backward compatibility, we unregister from each individual target. + * + * See _startListening for parameters description. + */ + _stopListening(resourceType, { bypassListenerCount = false } = {}) { + if (!bypassListenerCount) { + if (!this._listenedResources.has(resourceType)) { + throw new Error( + `Stopped listening for resource '${resourceType}' that isn't being listened to` + ); + } + this._listenedResources.delete(resourceType); + } + + // Clear the cached resources of the type. + for (const [key, resource] of this._cache) { + if (resource.resourceType == resourceType) { + // NOTE: To anyone paranoid like me, yes it is okay to delete from a Map while iterating it. + this._cache.delete(key); + } + } + + // If the server supports the Watcher API and the Watcher supports + // this resource type, use this API + if (this.hasResourceCommandSupport(resourceType)) { + if (!this.watcherFront.isDestroyed()) { + this.watcherFront.unwatchResources([resourceType]); + } + + const shouldRunLegacyListeners = + this._shouldRunLegacyListenerEvenWithWatcherSupport(resourceType); + if (!shouldRunLegacyListeners) { + return; + } + } + // Otherwise, fallback on backward compat mode and use LegacyListeners. + + // If this was the last listener, we should stop watching these events from the actors + // and the actors should stop watching things from the platform + const targets = this.targetCommand.getAllTargets( + this.targetCommand.ALL_TYPES + ); + for (const target of targets) { + this._unwatchResourcesForTarget(target, resourceType); + } + } + + /** + * Backward compatibility code, reverse of _watchResourcesForTarget. + */ + _unwatchResourcesForTarget(targetFront, resourceType) { + if (this._hasResourceCommandSupportForTarget(resourceType, targetFront)) { + // This resource / target pair should already be handled by the watcher, + // no need to stop legacy listeners. + } + // Is there really a point in: + // - unregistering `onAvailable` RDP event callbacks from target-scoped actors? + // - calling `stopListeners()` as we are most likely closing the toolbox and destroying everything? + // + // It is important to keep this method synchronous and do as less as possible + // in the case of toolbox destroy. + // + // We are aware of one case where that might be useful. + // When a panel is disabled via the options panel, after it has been opened. + // Would that justify doing this? Is there another usecase? + + // XXX: This is most likely only needed to avoid growing the Map infinitely. + // Unless in the "disabled panel" use case mentioned in the comment above, + // we should not see the same target actorID again. + const listeners = this._existingLegacyListeners.get(targetFront); + if (listeners && listeners.includes(resourceType)) { + const remainingListeners = listeners.filter(l => l !== resourceType); + this._existingLegacyListeners.set(targetFront, remainingListeners); + } + } +} + +ResourceCommand.TYPES = ResourceCommand.prototype.TYPES = { + CONSOLE_MESSAGE: "console-message", + CSS_CHANGE: "css-change", + CSS_MESSAGE: "css-message", + CSS_REGISTERED_PROPERTIES: "css-registered-properties", + ERROR_MESSAGE: "error-message", + PLATFORM_MESSAGE: "platform-message", + DOCUMENT_EVENT: "document-event", + ROOT_NODE: "root-node", + STYLESHEET: "stylesheet", + NETWORK_EVENT: "network-event", + WEBSOCKET: "websocket", + COOKIE: "cookies", + LOCAL_STORAGE: "local-storage", + SESSION_STORAGE: "session-storage", + CACHE_STORAGE: "Cache", + EXTENSION_STORAGE: "extension-storage", + INDEXED_DB: "indexed-db", + NETWORK_EVENT_STACKTRACE: "network-event-stacktrace", + REFLOW: "reflow", + SOURCE: "source", + THREAD_STATE: "thread-state", + JSTRACER_TRACE: "jstracer-trace", + JSTRACER_STATE: "jstracer-state", + SERVER_SENT_EVENT: "server-sent-event", + LAST_PRIVATE_CONTEXT_EXIT: "last-private-context-exit", +}; +ResourceCommand.ALL_TYPES = ResourceCommand.prototype.ALL_TYPES = Object.values( + ResourceCommand.TYPES +); +module.exports = ResourceCommand; + +// This is the list of resource types supported by workers. +// We need such list to know when forcing to run the legacy listeners +// and when to avoid try to spawn some unsupported ones for workers. +const WORKER_RESOURCE_TYPES = [ + ResourceCommand.TYPES.CONSOLE_MESSAGE, + ResourceCommand.TYPES.ERROR_MESSAGE, + ResourceCommand.TYPES.SOURCE, + ResourceCommand.TYPES.THREAD_STATE, +]; + +// Backward compat code for each type of resource. +// Each section added here should eventually be removed once the equivalent server +// code is implement in Firefox, in its release channel. +const LegacyListeners = { + async [ResourceCommand.TYPES.DOCUMENT_EVENT]({ + targetCommand, + targetFront, + onAvailable, + }) { + // DocumentEventsListener of webconsole handles only top level document. + if (!targetFront.isTopLevel) { + return; + } + + const webConsoleFront = await targetFront.getFront("console"); + webConsoleFront.on("documentEvent", event => { + event.resourceType = ResourceCommand.TYPES.DOCUMENT_EVENT; + onAvailable([event]); + }); + await webConsoleFront.startListeners(["DocumentEvents"]); + }, +}; +loader.lazyRequireGetter( + LegacyListeners, + ResourceCommand.TYPES.CONSOLE_MESSAGE, + "resource://devtools/shared/commands/resource/legacy-listeners/console-messages.js" +); +loader.lazyRequireGetter( + LegacyListeners, + ResourceCommand.TYPES.CSS_CHANGE, + "resource://devtools/shared/commands/resource/legacy-listeners/css-changes.js" +); +loader.lazyRequireGetter( + LegacyListeners, + ResourceCommand.TYPES.CSS_MESSAGE, + "resource://devtools/shared/commands/resource/legacy-listeners/css-messages.js" +); +loader.lazyRequireGetter( + LegacyListeners, + ResourceCommand.TYPES.ERROR_MESSAGE, + "resource://devtools/shared/commands/resource/legacy-listeners/error-messages.js" +); +loader.lazyRequireGetter( + LegacyListeners, + ResourceCommand.TYPES.PLATFORM_MESSAGE, + "resource://devtools/shared/commands/resource/legacy-listeners/platform-messages.js" +); +loader.lazyRequireGetter( + LegacyListeners, + ResourceCommand.TYPES.ROOT_NODE, + "resource://devtools/shared/commands/resource/legacy-listeners/root-node.js" +); + +loader.lazyRequireGetter( + LegacyListeners, + ResourceCommand.TYPES.SOURCE, + "resource://devtools/shared/commands/resource/legacy-listeners/source.js" +); +loader.lazyRequireGetter( + LegacyListeners, + ResourceCommand.TYPES.THREAD_STATE, + "resource://devtools/shared/commands/resource/legacy-listeners/thread-states.js" +); + +loader.lazyRequireGetter( + LegacyListeners, + ResourceCommand.TYPES.REFLOW, + "resource://devtools/shared/commands/resource/legacy-listeners/reflow.js" +); + +// Optional transformers for each type of resource. +// Each module added here should be a function that will receive the resource, the target, โฆ +// and perform some transformation on the resource before it will be emitted. +// This is a good place to handle backward compatibility and manual resource marshalling. +const ResourceTransformers = {}; + +loader.lazyRequireGetter( + ResourceTransformers, + ResourceCommand.TYPES.CONSOLE_MESSAGE, + "resource://devtools/shared/commands/resource/transformers/console-messages.js" +); +loader.lazyRequireGetter( + ResourceTransformers, + ResourceCommand.TYPES.ERROR_MESSAGE, + "resource://devtools/shared/commands/resource/transformers/error-messages.js" +); +loader.lazyRequireGetter( + ResourceTransformers, + ResourceCommand.TYPES.CACHE_STORAGE, + "resource://devtools/shared/commands/resource/transformers/storage-cache.js" +); +loader.lazyRequireGetter( + ResourceTransformers, + ResourceCommand.TYPES.COOKIE, + "resource://devtools/shared/commands/resource/transformers/storage-cookie.js" +); +loader.lazyRequireGetter( + ResourceTransformers, + ResourceCommand.TYPES.EXTENSION_STORAGE, + "resource://devtools/shared/commands/resource/transformers/storage-extension.js" +); +loader.lazyRequireGetter( + ResourceTransformers, + ResourceCommand.TYPES.INDEXED_DB, + "resource://devtools/shared/commands/resource/transformers/storage-indexed-db.js" +); +loader.lazyRequireGetter( + ResourceTransformers, + ResourceCommand.TYPES.LOCAL_STORAGE, + "resource://devtools/shared/commands/resource/transformers/storage-local-storage.js" +); +loader.lazyRequireGetter( + ResourceTransformers, + ResourceCommand.TYPES.SESSION_STORAGE, + "resource://devtools/shared/commands/resource/transformers/storage-session-storage.js" +); +loader.lazyRequireGetter( + ResourceTransformers, + ResourceCommand.TYPES.NETWORK_EVENT, + "resource://devtools/shared/commands/resource/transformers/network-events.js" +); +loader.lazyRequireGetter( + ResourceTransformers, + ResourceCommand.TYPES.THREAD_STATE, + "resource://devtools/shared/commands/resource/transformers/thread-states.js" +); diff --git a/devtools/shared/commands/resource/tests/breakpoint_document.html b/devtools/shared/commands/resource/tests/breakpoint_document.html new file mode 100644 index 0000000000..2291094646 --- /dev/null +++ b/devtools/shared/commands/resource/tests/breakpoint_document.html @@ -0,0 +1,21 @@ +<!DOCTYPE HTML> +<html> + <head> + <meta charset="utf8"> + <title>Test breakpoint document</title> + <!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> + </head> + <body> + <script> + "use strict"; + /* eslint-disable */ + function testFunction() { + console.log("test Function ran"); + } + function runDebuggerStatement() { + debugger; + } + </script> + </body> +</html> diff --git a/devtools/shared/commands/resource/tests/browser.toml b/devtools/shared/commands/resource/tests/browser.toml new file mode 100644 index 0000000000..def009b710 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser.toml @@ -0,0 +1,128 @@ +[DEFAULT] +tags = "devtools" +subsuite = "devtools" +support-files = [ + "!/devtools/client/shared/test/shared-head.js", + "!/devtools/client/shared/test/telemetry-test-helpers.js", + "!/devtools/client/shared/test/highlighter-test-actor.js", + "head.js", + "breakpoint_document.html", + "doc_console.html", + "doc_console_iframe.html", + "empty.html", + "network_document.html", + "network_document_navigation.html", + "network_navigation.js", + "early_console_document.html", + "fission_document.html", + "fission_document_workers.html", + "fission_iframe.html", + "fission_iframe_workers.html", + "service-worker-sources.js", + "sources.html", + "sources.js", + "sse_backend.sjs", + "sse_frontend_iframe.html", + "sse_frontend.html", + "style_document.css", + "style_document.html", + "style_iframe.css", + "style_iframe.html", + "stylesheets-nested-iframes.html", + "test_image.png", + "test_service_worker.js", + "test_worker.js", + "websocket_backend_wsh.py", + "websocket_frontend_iframe.html", + "websocket_frontend.html", + "worker-sources.js", +] + +["browser_browser_resources_console_messages.js"] + +["browser_resources_clear_resources.js"] + +["browser_resources_client_caching.js"] + +["browser_resources_console_messages.js"] + +["browser_resources_console_messages_navigation.js"] + +["browser_resources_console_messages_workers.js"] + +["browser_resources_css_changes.js"] + +["browser_resources_css_messages.js"] + +["browser_resources_css_registered_properties.js"] +skip-if = ["!fission"] + +["browser_resources_document_events.js"] +skip-if = [ + "os == 'linux' && bits == 64", # Bug 1715878 +] + +["browser_resources_error_messages.js"] + +["browser_resources_getAllResources.js"] + +["browser_resources_invalid_api_usage.js"] + +["browser_resources_last_private_context_exit.js"] + +["browser_resources_network_event_stacktraces.js"] + +["browser_resources_network_events.js"] + +["browser_resources_network_events_cache.js"] + +["browser_resources_network_events_navigation.js"] + +["browser_resources_network_events_parent_process.js"] + +["browser_resources_platform_messages.js"] + +["browser_resources_reflows.js"] + +["browser_resources_root_node.js"] + +["browser_resources_scope_flag.js"] + +["browser_resources_server_sent_events.js"] + +["browser_resources_several_resources.js"] + +["browser_resources_sources.js"] +skip-if = [ + "os == 'linux' && bits == 64", # Bug 1744565 + "win11_2009", # Bug 1767772 + "apple_catalina", # Bug 1767772 +] + +["browser_resources_stylesheets.js"] + +["browser_resources_stylesheets_header.js"] + +["browser_resources_stylesheets_import.js"] + +["browser_resources_stylesheets_navigation.js"] + +["browser_resources_stylesheets_nested_iframes.js"] + +["browser_resources_target_destroy.js"] + +["browser_resources_target_resources_race.js"] + +["browser_resources_target_switching.js"] + +["browser_resources_thread_states.js"] + +["browser_resources_unwatch_early.js"] + +["browser_resources_watch_unwatch_multiple.js"] + +["browser_resources_websocket.js"] +skip-if = [ + "http3", # Bug 1829298 + "http2", +] diff --git a/devtools/shared/commands/resource/tests/browser_browser_resources_console_messages.js b/devtools/shared/commands/resource/tests/browser_browser_resources_console_messages.js new file mode 100644 index 0000000000..1c6c776e64 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_browser_resources_console_messages.js @@ -0,0 +1,87 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around CONSOLE_MESSAGE for the whole browser + +const TEST_URL = URL_ROOT_SSL + "early_console_document.html"; + +add_task(async function () { + // Enable Multiprocess Browser Toolbox. + await pushPref("devtools.browsertoolbox.scope", "everything"); + + const { client, resourceCommand, targetCommand } = + await initMultiProcessResourceCommand(); + + const d = Date.now(); + const CACHED_MESSAGE_TEXT = `cached-${d}`; + const LIVE_MESSAGE_TEXT = `live-${d}`; + + info( + "Log some messages *before* calling ResourceCommand.watchResources in order to " + + "assert the behavior of already existing messages." + ); + console.log(CACHED_MESSAGE_TEXT); + + info("Wait for existing browser mochitest log"); + const { onResource } = await resourceCommand.waitForNextResource( + resourceCommand.TYPES.CONSOLE_MESSAGE, + { + ignoreExistingResources: false, + predicate({ message }) { + return message.arguments[0] === CACHED_MESSAGE_TEXT; + }, + } + ); + const existingMsg = await onResource; + ok(existingMsg, "The existing log was retrieved"); + is( + existingMsg.isAlreadyExistingResource, + true, + "isAlreadyExistingResource is true for the existing message" + ); + + const { onResource: onMochitestRuntimeLog } = + await resourceCommand.waitForNextResource( + resourceCommand.TYPES.CONSOLE_MESSAGE, + { + ignoreExistingResources: false, + predicate({ message }) { + return message.arguments[0] === LIVE_MESSAGE_TEXT; + }, + } + ); + console.log(LIVE_MESSAGE_TEXT); + + info("Wait for runtime browser mochitest log"); + const runtimeLogResource = await onMochitestRuntimeLog; + ok(runtimeLogResource, "The runtime log was retrieved"); + is( + runtimeLogResource.isAlreadyExistingResource, + false, + "isAlreadyExistingResource is false for the runtime message" + ); + + const { onResource: onEarlyLog } = await resourceCommand.waitForNextResource( + resourceCommand.TYPES.CONSOLE_MESSAGE, + { + ignoreExistingResources: true, + predicate({ message }) { + return message.arguments[0] === "early-page-log"; + }, + } + ); + await addTab(TEST_URL); + info("Wait for early page log"); + const earlyResource = await onEarlyLog; + ok(earlyResource, "The early page log was retrieved"); + is( + earlyResource.isAlreadyExistingResource, + false, + "isAlreadyExistingResource is false for the early message" + ); + + targetCommand.destroy(); + await client.close(); +}); diff --git a/devtools/shared/commands/resource/tests/browser_resources_clear_resources.js b/devtools/shared/commands/resource/tests/browser_resources_clear_resources.js new file mode 100644 index 0000000000..44068cb141 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_clear_resources.js @@ -0,0 +1,90 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the clearResources function of the ResourceCommand + +add_task(async () => { + const tab = await addTab(`${URL_ROOT_SSL}empty.html`); + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Assert the initial no of resources"); + assertNoOfResources(resourceCommand, 0, 0); + + const onAvailable = () => {}; + const onUpdated = () => {}; + + await resourceCommand.watchResources( + [ + resourceCommand.TYPES.CONSOLE_MESSAGE, + resourceCommand.TYPES.NETWORK_EVENT, + ], + { onAvailable, onUpdated } + ); + + info("Log some messages"); + await logConsoleMessages(tab.linkedBrowser, ["log1", "log2", "log3"]); + + info("Trigger some network requests"); + const EXAMPLE_DOMAIN = "https://example.com/"; + await triggerNetworkRequests(tab.linkedBrowser, [ + `await fetch("${EXAMPLE_DOMAIN}/request1.html", { method: "GET" });`, + `await fetch("${EXAMPLE_DOMAIN}/request2.html", { method: "GET" });`, + ]); + + assertNoOfResources(resourceCommand, 3, 2); + + info("Clear the network event resources"); + await resourceCommand.clearResources([resourceCommand.TYPES.NETWORK_EVENT]); + assertNoOfResources(resourceCommand, 3, 0); + + info("Clear the console message resources"); + await resourceCommand.clearResources([resourceCommand.TYPES.CONSOLE_MESSAGE]); + assertNoOfResources(resourceCommand, 0, 0); + + resourceCommand.unwatchResources( + [ + resourceCommand.TYPES.CONSOLE_MESSAGE, + resourceCommand.TYPES.NETWORK_EVENT, + ], + { onAvailable, onUpdated, ignoreExistingResources: true } + ); + + targetCommand.destroy(); + await client.close(); +}); + +function assertNoOfResources( + resourceCommand, + expectedNoOfConsoleMessageResources, + expectedNoOfNetworkEventResources +) { + const actualNoOfConsoleMessageResources = resourceCommand.getAllResources( + resourceCommand.TYPES.CONSOLE_MESSAGE + ).length; + is( + actualNoOfConsoleMessageResources, + expectedNoOfConsoleMessageResources, + `There are ${actualNoOfConsoleMessageResources} console messages resources` + ); + + const actualNoOfNetworkEventResources = resourceCommand.getAllResources( + resourceCommand.TYPES.NETWORK_EVENT + ).length; + is( + actualNoOfNetworkEventResources, + expectedNoOfNetworkEventResources, + `There are ${actualNoOfNetworkEventResources} network event resources` + ); +} + +function logConsoleMessages(browser, messages) { + return SpecialPowers.spawn(browser, [messages], innerMessages => { + for (const message of innerMessages) { + content.console.log(message); + } + }); +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_client_caching.js b/devtools/shared/commands/resource/tests/browser_resources_client_caching.js new file mode 100644 index 0000000000..ae398f73cc --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_client_caching.js @@ -0,0 +1,380 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the cache mechanism of the ResourceCommand. + +const TEST_URI = "data:text/html;charset=utf-8,<!DOCTYPE html>Cache Test"; + +add_task(async function () { + info("Test whether multiple listener can get same cached resources"); + + const tab = await addTab(TEST_URI); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Add messages as existing resources"); + const messages = ["a", "b", "c"]; + await logMessages(tab.linkedBrowser, messages); + + info("Register first listener"); + const cachedResources1 = []; + await resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { + onAvailable(resources, { areExistingResources }) { + ok(areExistingResources, "All resources are already existing ones"); + cachedResources1.push(...resources); + }, + } + ); + + info("Register second listener"); + const cachedResources2 = []; + await resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { + onAvailable(resources, { areExistingResources }) { + ok(areExistingResources, "All resources are already existing ones"); + cachedResources2.push(...resources); + }, + } + ); + + assertContents(cachedResources1, messages); + assertResources(cachedResources2, cachedResources1); + + targetCommand.destroy(); + await client.close(); +}); + +add_task(async function () { + info( + "Test whether the cache is reflecting existing resources and additional resources" + ); + + const tab = await addTab(TEST_URI); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Add messages as existing resources"); + const existingMessages = ["a", "b", "c"]; + await logMessages(tab.linkedBrowser, existingMessages); + + info("Register first listener to get all available resources"); + const availableResources = []; + // We first get notified about existing resources + let shouldBeExistingResources = true; + await resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { + onAvailable(resources, { areExistingResources }) { + is( + areExistingResources, + shouldBeExistingResources, + "areExistingResources flag is correct" + ); + availableResources.push(...resources); + }, + } + ); + // Then, we are notified about, new, live ones + shouldBeExistingResources = false; + + info("Add messages as additional resources"); + const additionalMessages = ["d", "e"]; + await logMessages(tab.linkedBrowser, additionalMessages); + + info("Wait until onAvailable is called expected times"); + const allMessages = [...existingMessages, ...additionalMessages]; + await waitUntil(() => availableResources.length === allMessages.length); + + info("Register second listener to get the cached resources"); + const cachedResources = []; + await resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { + onAvailable(resources, { areExistingResources }) { + ok(areExistingResources, "All resources are already existing ones"); + cachedResources.push(...resources); + }, + } + ); + + assertContents(availableResources, allMessages); + assertResources(cachedResources, availableResources); + + targetCommand.destroy(); + await client.close(); +}); + +add_task(async function () { + info("Test whether the cache is cleared when navigation"); + + const tab = await addTab(TEST_URI); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Add messages as existing resources"); + const existingMessages = ["a", "b", "c"]; + await logMessages(tab.linkedBrowser, existingMessages); + + info("Register first listener"); + await resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { + onAvailable: () => {}, + } + ); + + info("Reload the page"); + await BrowserTestUtils.reloadTab(tab); + + info("Register second listener"); + const cachedResources = []; + await resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { + onAvailable: resources => cachedResources.push(...resources), + } + ); + + is(cachedResources.length, 0, "The cache in ResourceCommand is cleared"); + + targetCommand.destroy(); + await client.close(); +}); + +add_task(async function () { + info("Test with multiple resource types"); + + const tab = await addTab(TEST_URI); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Register first listener to get all available resources"); + const availableResources = []; + await resourceCommand.watchResources( + [ + resourceCommand.TYPES.CONSOLE_MESSAGE, + resourceCommand.TYPES.ERROR_MESSAGE, + ], + { + onAvailable: resources => availableResources.push(...resources), + } + ); + + info("Add messages as console message"); + const consoleMessages1 = ["a", "b", "c"]; + await logMessages(tab.linkedBrowser, consoleMessages1); + + info("Add message as error message"); + const errorMessages = ["document.doTheImpossible();"]; + await triggerErrors(tab.linkedBrowser, errorMessages); + + info("Add messages as console message again"); + const consoleMessages2 = ["d", "e"]; + await logMessages(tab.linkedBrowser, consoleMessages2); + + info("Wait until the getting all available resources"); + const totalResourceCount = + consoleMessages1.length + errorMessages.length + consoleMessages2.length; + await waitUntil(() => { + return availableResources.length === totalResourceCount; + }); + + info("Register listener to get the cached resources"); + const cachedResources = []; + await resourceCommand.watchResources( + [ + resourceCommand.TYPES.CONSOLE_MESSAGE, + resourceCommand.TYPES.ERROR_MESSAGE, + ], + { + onAvailable: resources => cachedResources.push(...resources), + } + ); + + assertResources(cachedResources, availableResources); + + targetCommand.destroy(); + await client.close(); +}); + +add_task(async function () { + info("Test multiple listeners with/without ignoreExistingResources"); + await testIgnoreExistingResources(true); + await testIgnoreExistingResources(false); +}); + +async function testIgnoreExistingResources(isFirstListenerIgnoreExisting) { + const tab = await addTab(TEST_URI); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Add messages as existing resources"); + const existingMessages = ["a", "b", "c"]; + await logMessages(tab.linkedBrowser, existingMessages); + + info("Register first listener"); + const cachedResources1 = []; + await resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { + onAvailable: resources => cachedResources1.push(...resources), + ignoreExistingResources: isFirstListenerIgnoreExisting, + } + ); + + info("Register second listener"); + const cachedResources2 = []; + await resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { + onAvailable: resources => cachedResources2.push(...resources), + ignoreExistingResources: !isFirstListenerIgnoreExisting, + } + ); + + const cachedResourcesWithFlag = isFirstListenerIgnoreExisting + ? cachedResources1 + : cachedResources2; + const cachedResourcesWithoutFlag = isFirstListenerIgnoreExisting + ? cachedResources2 + : cachedResources1; + + info("Check the existing resources both listeners got"); + assertContents(cachedResourcesWithFlag, []); + assertContents(cachedResourcesWithoutFlag, existingMessages); + + info("Add messages as additional resources"); + const additionalMessages = ["d", "e"]; + await logMessages(tab.linkedBrowser, additionalMessages); + + info("Wait until onAvailable is called expected times"); + await waitUntil( + () => cachedResourcesWithFlag.length === additionalMessages.length + ); + const allMessages = [...existingMessages, ...additionalMessages]; + await waitUntil( + () => cachedResourcesWithoutFlag.length === allMessages.length + ); + + info("Check the resources after adding messages"); + assertContents(cachedResourcesWithFlag, additionalMessages); + assertContents(cachedResourcesWithoutFlag, allMessages); + + targetCommand.destroy(); + await client.close(); +} + +add_task(async function () { + info("Test that onAvailable is not called with an empty resources array"); + + const tab = await addTab(TEST_URI); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Register first listener to get all available resources"); + const availableResources = []; + let onAvailableCallCount = 0; + const onAvailable = resources => { + ok( + !!resources.length, + "onAvailable is called with a non empty resources array" + ); + availableResources.push(...resources); + onAvailableCallCount++; + }; + + await resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { onAvailable } + ); + is(availableResources.length, 0, "availableResources array is empty"); + is(onAvailableCallCount, 0, "onAvailable was never called"); + + info("Add messages as console message"); + await logMessages(tab.linkedBrowser, ["expected message"]); + + await waitUntil(() => availableResources.length === 1); + is(availableResources.length, 1, "availableResources array has one item"); + is(onAvailableCallCount, 1, "onAvailable was called only once"); + is( + availableResources[0].message.arguments[0], + "expected message", + "onAvailable was called with the expected resource" + ); + + resourceCommand.unwatchResources([resourceCommand.TYPES.CONSOLE_MESSAGE], { + onAvailable, + }); + targetCommand.destroy(); + await client.close(); +}); + +function assertContents(resources, expectedMessages) { + is( + resources.length, + expectedMessages.length, + "Number of the resources is correct" + ); + + for (let i = 0; i < expectedMessages.length; i++) { + const resource = resources[i]; + const message = resource.message.arguments[0]; + const expectedMessage = expectedMessages[i]; + is(message, expectedMessage, `The ${i}th content is correct`); + } +} + +function assertResources(resources, expectedResources) { + is( + resources.length, + expectedResources.length, + "Number of the resources is correct" + ); + + for (let i = 0; i < resources.length; i++) { + const resource = resources[i]; + const expectedResource = expectedResources[i]; + Assert.strictEqual( + resource, + expectedResource, + `The ${i}th resource is correct` + ); + } +} + +function logMessages(browser, messages) { + return ContentTask.spawn(browser, { messages }, args => { + for (const message of args.messages) { + content.console.log(message); + } + }); +} + +async function triggerErrors(browser, errorScripts) { + for (const errorScript of errorScripts) { + await ContentTask.spawn(browser, errorScript, expr => { + const document = content.document; + const container = document.createElement("script"); + document.body.appendChild(container); + container.textContent = expr; + container.remove(); + }); + } +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_console_messages.js b/devtools/shared/commands/resource/tests/browser_resources_console_messages.js new file mode 100644 index 0000000000..6f02cd5a77 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_console_messages.js @@ -0,0 +1,623 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around CONSOLE_MESSAGE +// +// Reproduces assertions from: devtools/shared/webconsole/test/chrome/test_cached_messages.html +// And now more. Once we remove the console actor's startListeners in favor of watcher class +// We could remove that other old test. + +const FISSION_TEST_URL = URL_ROOT_SSL + "fission_document.html"; +const IFRAME_URL = URL_ROOT_ORG_SSL + "fission_iframe.html"; + +add_task(async function () { + info("Execute test in top level document"); + await testTabConsoleMessagesResources(false); + await testTabConsoleMessagesResourcesWithIgnoreExistingResources(false); + + info("Execute test in an iframe document, possibly remote with fission"); + await testTabConsoleMessagesResources(true); + await testTabConsoleMessagesResourcesWithIgnoreExistingResources(true); +}); + +async function testTabConsoleMessagesResources(executeInIframe) { + const tab = await addTab(FISSION_TEST_URL); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info( + "Log some messages *before* calling ResourceCommand.watchResources in order to " + + "assert the behavior of already existing messages." + ); + await logExistingMessages(tab.linkedBrowser, executeInIframe); + + const targetDocumentUrl = executeInIframe ? IFRAME_URL : FISSION_TEST_URL; + + let runtimeDoneResolve; + const expectedExistingCalls = + getExpectedExistingConsoleCalls(targetDocumentUrl); + const expectedRuntimeCalls = + getExpectedRuntimeConsoleCalls(targetDocumentUrl); + const onRuntimeDone = new Promise(resolve => (runtimeDoneResolve = resolve)); + const onAvailable = resources => { + for (const resource of resources) { + is( + resource.resourceType, + resourceCommand.TYPES.CONSOLE_MESSAGE, + "Received a message" + ); + ok(resource.message, "message is wrapped into a message attribute"); + const isCachedMessage = !!expectedExistingCalls.length; + const expected = ( + isCachedMessage ? expectedExistingCalls : expectedRuntimeCalls + ).shift(); + checkConsoleAPICall(resource.message, expected); + is( + resource.isAlreadyExistingResource, + isCachedMessage, + "isAlreadyExistingResource has the expected value" + ); + + if (!expectedRuntimeCalls.length) { + runtimeDoneResolve(); + } + } + }; + + await resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { + onAvailable, + } + ); + is( + expectedExistingCalls.length, + 0, + "Got the expected number of existing messages" + ); + + info( + "Now log messages *after* the call to ResourceCommand.watchResources and after having received all existing messages" + ); + await logRuntimeMessages(tab.linkedBrowser, executeInIframe); + + info("Waiting for all runtime messages"); + await onRuntimeDone; + + is( + expectedRuntimeCalls.length, + 0, + "Got the expected number of runtime messages" + ); + + targetCommand.destroy(); + await client.close(); +} + +async function testTabConsoleMessagesResourcesWithIgnoreExistingResources( + executeInIframe +) { + info("Test ignoreExistingResources option for console messages"); + const tab = await addTab(FISSION_TEST_URL); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info( + "Check whether onAvailable will not be called with existing console messages" + ); + await logExistingMessages(tab.linkedBrowser, executeInIframe); + + const availableResources = []; + await resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { + onAvailable: resources => availableResources.push(...resources), + ignoreExistingResources: true, + } + ); + is( + availableResources.length, + 0, + "onAvailable wasn't called for existing console messages" + ); + + info( + "Check whether onAvailable will be called with the future console messages" + ); + await logRuntimeMessages(tab.linkedBrowser, executeInIframe); + const targetDocumentUrl = executeInIframe ? IFRAME_URL : FISSION_TEST_URL; + const expectedRuntimeConsoleCalls = + getExpectedRuntimeConsoleCalls(targetDocumentUrl); + await waitUntil( + () => availableResources.length === expectedRuntimeConsoleCalls.length + ); + const expectedTargetFront = + executeInIframe && (isFissionEnabled() || isEveryFrameTargetEnabled()) + ? targetCommand + .getAllTargets([targetCommand.TYPES.FRAME]) + .find(target => target.url == IFRAME_URL) + : targetCommand.targetFront; + for (let i = 0; i < expectedRuntimeConsoleCalls.length; i++) { + const resource = availableResources[i]; + const { message, targetFront } = resource; + is( + targetFront, + expectedTargetFront, + "The targetFront property is the expected one" + ); + const expected = expectedRuntimeConsoleCalls[i]; + checkConsoleAPICall(message, expected); + is( + resource.isAlreadyExistingResource, + false, + "isAlreadyExistingResource is false since we're ignoring existing resources" + ); + } + + targetCommand.destroy(); + await client.close(); +} + +async function logExistingMessages(browser, executeInIframe) { + let browsingContext = browser.browsingContext; + if (executeInIframe) { + browsingContext = await SpecialPowers.spawn( + browser, + [], + function frameScript() { + return content.document.querySelector("iframe").browsingContext; + } + ); + } + return evalInBrowsingContext(browsingContext, function pageScript() { + console.log("foobarBaz-log", undefined); + console.info("foobarBaz-info", null); + console.warn("foobarBaz-warn", document.body); + }); +} + +/** + * Helper function similar to spawn, but instead of executing the script + * as a Frame Script, with privileges and including test harness in stacktraces, + * execute the script as a regular page script, without privileges and without any + * preceding stack. + * + * @param {BrowsingContext} The browsing context into which the script should be evaluated + * @param {Function|String} The JS to execute in the browsing context + * + * @return {Promise} Which resolves once the JS is done executing in the page + */ +function evalInBrowsingContext(browsingContext, script) { + return SpecialPowers.spawn(browsingContext, [String(script)], expr => { + const document = content.document; + const scriptEl = document.createElement("script"); + document.body.appendChild(scriptEl); + // Force the immediate execution of the stringified JS function passed in `expr` + scriptEl.textContent = "new " + expr; + scriptEl.remove(); + }); +} + +// For both existing and runtime messages, we execute console API +// from a page script evaluated via evalInBrowsingContext. +// Records here the function used to execute the script in the page. +const EXPECTED_FUNCTION_NAME = "pageScript"; + +const NUMBER_REGEX = /^\d+$/; +// timeStamp are the result of a number in microsecond divided by 1000. +// so we can't expect a precise number of decimals, or even if there would +// be decimals at all. +const FRACTIONAL_NUMBER_REGEX = /^\d+(\.\d{1,3})?$/; + +function getExpectedExistingConsoleCalls(documentFilename) { + const defaultProperties = { + filename: documentFilename, + columnNumber: NUMBER_REGEX, + lineNumber: NUMBER_REGEX, + timeStamp: FRACTIONAL_NUMBER_REGEX, + innerWindowID: NUMBER_REGEX, + chromeContext: undefined, + counter: undefined, + prefix: undefined, + private: undefined, + stacktrace: undefined, + styles: undefined, + timer: undefined, + }; + + return [ + { + ...defaultProperties, + level: "log", + arguments: ["foobarBaz-log", { type: "undefined" }], + }, + { + ...defaultProperties, + level: "info", + arguments: ["foobarBaz-info", { type: "null" }], + }, + { + ...defaultProperties, + level: "warn", + arguments: ["foobarBaz-warn", { type: "object", actor: /[a-z]/ }], + }, + ]; +} + +const longString = new Array(DevToolsServer.LONG_STRING_LENGTH + 2).join("a"); +function getExpectedRuntimeConsoleCalls(documentFilename) { + const defaultStackFrames = [ + // This is the usage of "new " + expr from `evalInBrowsingContext` + { + filename: documentFilename, + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + }, + ]; + + const defaultProperties = { + filename: documentFilename, + columnNumber: NUMBER_REGEX, + lineNumber: NUMBER_REGEX, + timeStamp: FRACTIONAL_NUMBER_REGEX, + innerWindowID: NUMBER_REGEX, + chromeContext: undefined, + counter: undefined, + prefix: undefined, + private: undefined, + stacktrace: undefined, + styles: undefined, + timer: undefined, + }; + + return [ + { + ...defaultProperties, + level: "log", + arguments: ["foobarBaz-log", { type: "undefined" }], + }, + { + ...defaultProperties, + level: "log", + arguments: ["Float from not a number: NaN"], + }, + { + ...defaultProperties, + level: "log", + arguments: ["Float from string: 1.200000"], + }, + { + ...defaultProperties, + level: "log", + arguments: ["Float from number: 1.300000"], + }, + { + ...defaultProperties, + level: "log", + arguments: ["BigInt 123 and 456"], + }, + { + ...defaultProperties, + level: "log", + arguments: ["message with ", "style"], + styles: ["color: blue;", "background: red; font-size: 2em;"], + }, + { + ...defaultProperties, + level: "info", + arguments: ["foobarBaz-info", { type: "null" }], + }, + { + ...defaultProperties, + level: "warn", + arguments: ["foobarBaz-warn", { type: "object", actor: /[a-z]/ }], + }, + { + ...defaultProperties, + level: "debug", + arguments: [{ type: "null" }], + }, + { + ...defaultProperties, + level: "trace", + stacktrace: [ + { + filename: documentFilename, + functionName: EXPECTED_FUNCTION_NAME, + }, + ...defaultStackFrames, + ], + }, + { + ...defaultProperties, + level: "dir", + arguments: [ + { + type: "object", + actor: /[a-z]/, + class: "HTMLDocument", + }, + { + type: "object", + actor: /[a-z]/, + class: "Location", + }, + ], + }, + { + ...defaultProperties, + level: "log", + arguments: [ + "foo", + { + type: "longString", + initial: longString.substring( + 0, + DevToolsServer.LONG_STRING_INITIAL_LENGTH + ), + length: longString.length, + actor: /[a-z]/, + }, + ], + }, + { + ...defaultProperties, + level: "count", + arguments: ["myCounter"], + counter: { + count: 1, + label: "myCounter", + }, + }, + { + ...defaultProperties, + level: "count", + arguments: ["myCounter"], + counter: { + count: 2, + label: "myCounter", + }, + }, + { + ...defaultProperties, + level: "count", + arguments: ["default"], + counter: { + count: 1, + label: "default", + }, + }, + { + ...defaultProperties, + level: "countReset", + arguments: ["myCounter"], + counter: { + count: 0, + label: "myCounter", + }, + }, + { + ...defaultProperties, + level: "countReset", + arguments: ["unknownCounter"], + counter: { + error: "counterDoesntExist", + label: "unknownCounter", + }, + }, + { + ...defaultProperties, + level: "time", + arguments: ["myTimer"], + timer: { + name: "myTimer", + }, + }, + { + ...defaultProperties, + level: "time", + arguments: ["myTimer"], + timer: { + name: "myTimer", + error: "timerAlreadyExists", + }, + }, + { + ...defaultProperties, + level: "timeLog", + arguments: ["myTimer"], + timer: { + name: "myTimer", + duration: NUMBER_REGEX, + }, + }, + { + ...defaultProperties, + level: "timeEnd", + arguments: ["myTimer"], + timer: { + name: "myTimer", + duration: NUMBER_REGEX, + }, + }, + { + ...defaultProperties, + level: "time", + arguments: ["default"], + timer: { + name: "default", + }, + }, + { + ...defaultProperties, + level: "timeLog", + arguments: ["default"], + timer: { + name: "default", + duration: NUMBER_REGEX, + }, + }, + { + ...defaultProperties, + level: "timeEnd", + arguments: ["default"], + timer: { + name: "default", + duration: NUMBER_REGEX, + }, + }, + { + ...defaultProperties, + level: "timeLog", + arguments: ["unknownTimer"], + timer: { + name: "unknownTimer", + error: "timerDoesntExist", + }, + }, + { + ...defaultProperties, + level: "timeEnd", + arguments: ["unknownTimer"], + timer: { + name: "unknownTimer", + error: "timerDoesntExist", + }, + }, + { + ...defaultProperties, + level: "error", + arguments: ["foobarBaz-asmjs-error", { type: "undefined" }], + + stacktrace: [ + { + filename: documentFilename, + functionName: "fromAsmJS", + }, + { + filename: documentFilename, + functionName: "inAsmJS2", + }, + { + filename: documentFilename, + functionName: "inAsmJS1", + }, + { + filename: documentFilename, + functionName: EXPECTED_FUNCTION_NAME, + }, + ...defaultStackFrames, + ], + }, + { + ...defaultProperties, + level: "log", + filename: + "chrome://mochitests/content/browser/devtools/shared/commands/resource/tests/browser_resources_console_messages.js", + arguments: [ + { + type: "object", + actor: /[a-z]/, + class: "Restricted", + }, + ], + chromeContext: true, + }, + ]; +} + +async function logRuntimeMessages(browser, executeInIframe) { + let browsingContext = browser.browsingContext; + if (executeInIframe) { + browsingContext = await SpecialPowers.spawn( + browser, + [], + function frameScript() { + return content.document.querySelector("iframe").browsingContext; + } + ); + } + // First inject LONG_STRING_LENGTH in global scope it order to easily use it after + await evalInBrowsingContext( + browsingContext, + `function () {window.LONG_STRING_LENGTH = ${DevToolsServer.LONG_STRING_LENGTH};}` + ); + await evalInBrowsingContext(browsingContext, function pageScript() { + const _longString = new Array(window.LONG_STRING_LENGTH + 2).join("a"); + + console.log("foobarBaz-log", undefined); + + console.log("Float from not a number: %f", "foo"); + console.log("Float from string: %f", "1.2"); + console.log("Float from number: %f", 1.3); + console.log("BigInt %d and %i", 123n, 456n); + console.log( + "%cmessage with %cstyle", + "color: blue;", + "background: red; font-size: 2em;" + ); + + console.info("foobarBaz-info", null); + console.warn("foobarBaz-warn", document.documentElement); + console.debug(null); + console.trace(); + console.dir(document, location); + console.log("foo", _longString); + + console.count("myCounter"); + console.count("myCounter"); + console.count(); + console.countReset("myCounter"); + // will cause warnings because unknownCounter doesn't exist + console.countReset("unknownCounter"); + + console.time("myTimer"); + // will cause warning because myTimer already exist + console.time("myTimer"); + console.timeLog("myTimer"); + console.timeEnd("myTimer"); + console.time(); + console.timeLog(); + console.timeEnd(); + // // will cause warnings because unknownTimer doesn't exist + console.timeLog("unknownTimer"); + console.timeEnd("unknownTimer"); + + function fromAsmJS() { + console.error("foobarBaz-asmjs-error", undefined); + } + + (function (global, foreign) { + "use asm"; + function inAsmJS2() { + foreign.fromAsmJS(); + } + function inAsmJS1() { + inAsmJS2(); + } + return inAsmJS1; + })(null, { fromAsmJS })(); + }); + await SpecialPowers.spawn(browsingContext, [], function frameScript() { + const sandbox = new Cu.Sandbox(null, { invisibleToDebugger: true }); + const sandboxObj = sandbox.eval("new Object"); + content.console.log(sandboxObj); + }); +} + +// Copied from devtools/shared/webconsole/test/chrome/common.js +function checkConsoleAPICall(call, expected) { + is( + call.arguments?.length || 0, + expected.arguments?.length || 0, + "number of arguments" + ); + + checkObject(call, expected); +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_console_messages_navigation.js b/devtools/shared/commands/resource/tests/browser_resources_console_messages_navigation.js new file mode 100644 index 0000000000..3d6fc697da --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_console_messages_navigation.js @@ -0,0 +1,190 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the resource command API around CONSOLE_MESSAGE when navigating +// tab and inner iframes to distinct origin/processes. + +const TEST_URL = URL_ROOT_COM_SSL + "doc_console.html"; +const TEST_IFRAME_URL = URL_ROOT_ORG_SSL + "doc_console_iframe.html"; +const TEST_DOMAIN = "https://example.org"; +add_task(async function () { + const START_URL = "data:text/html;charset=utf-8,foo"; + const tab = await addTab(START_URL); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + await testCrossProcessTabNavigation(tab.linkedBrowser, resourceCommand); + await testCrossProcessIframeNavigation(tab.linkedBrowser, resourceCommand); + + targetCommand.destroy(); + await client.close(); + BrowserTestUtils.removeTab(tab); +}); + +async function testCrossProcessTabNavigation(browser, resourceCommand) { + info( + "Navigate the top level document from data: URI to a https document including remote iframes" + ); + + let doneResolve; + const messages = []; + const onConsoleLogsComplete = new Promise(resolve => (doneResolve = resolve)); + + const onAvailable = resources => { + messages.push(...resources); + if (messages.length == 2) { + doneResolve(); + } + }; + + await resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { onAvailable } + ); + + const onLoaded = BrowserTestUtils.browserLoaded(browser); + BrowserTestUtils.startLoadingURIString(browser, TEST_URL); + await onLoaded; + + info("Wait for log message"); + await onConsoleLogsComplete; + + // messages are coming from different targets so the order isn't guaranteed + const topLevelMessageResource = messages.find(resource => + resource.message.filename.startsWith(URL_ROOT_COM_SSL) + ); + const iframeMessage = messages.find(resource => + resource.message.filename.startsWith("data:") + ); + + assertConsoleMessage(resourceCommand, topLevelMessageResource, { + targetFront: resourceCommand.targetCommand.targetFront, + messageText: "top-level document log", + }); + assertConsoleMessage(resourceCommand, iframeMessage, { + targetFront: isEveryFrameTargetEnabled + ? resourceCommand.targetCommand + .getAllTargets([resourceCommand.targetCommand.TYPES.FRAME]) + .find(t => t.url.startsWith("data:")) + : resourceCommand.targetCommand.targetFront, + messageText: "data url data log", + }); + + resourceCommand.unwatchResources([resourceCommand.TYPES.CONSOLE_MESSAGE], { + onAvailable, + }); +} + +async function testCrossProcessIframeNavigation(browser, resourceCommand) { + info("Navigate an inner iframe from data: URI to a https remote URL"); + + let doneResolve; + const messages = []; + const onConsoleLogsComplete = new Promise(resolve => (doneResolve = resolve)); + + const onAvailable = resources => { + messages.push( + ...resources.filter( + r => !r.message.arguments[0].startsWith("[WORKER] started") + ) + ); + if (messages.length == 3) { + doneResolve(); + } + }; + + await resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { + onAvailable, + } + ); + + // messages are coming from different targets so the order isn't guaranteed + const topLevelMessageResource = messages.find(resource => + resource.message.arguments[0].startsWith("top-level") + ); + const dataUrlMessageResource = messages.find(resource => + resource.message.arguments[0].startsWith("data url") + ); + + // Assert cached messages from the previous top document + assertConsoleMessage(resourceCommand, topLevelMessageResource, { + messageText: "top-level document log", + }); + assertConsoleMessage(resourceCommand, dataUrlMessageResource, { + messageText: "data url data log", + }); + + // Navigate the iframe to another origin/process + await SpecialPowers.spawn(browser, [TEST_IFRAME_URL], function (iframeUrl) { + const iframe = content.document.querySelector("iframe"); + iframe.src = iframeUrl; + }); + + info("Wait for log message"); + await onConsoleLogsComplete; + + // iframeTarget will be different if Fission is on or off + const iframeTarget = await getIframeTargetFront( + resourceCommand.targetCommand + ); + + const iframeMessageResource = messages.find(resource => + resource.message.arguments[0].endsWith("iframe log") + ); + assertConsoleMessage(resourceCommand, iframeMessageResource, { + messageText: `${TEST_DOMAIN} iframe log`, + targetFront: iframeTarget, + }); + + resourceCommand.unwatchResources([resourceCommand.TYPES.CONSOLE_MESSAGE], { + onAvailable, + }); +} + +function assertConsoleMessage(resourceCommand, messageResource, expected) { + is( + messageResource.resourceType, + resourceCommand.TYPES.CONSOLE_MESSAGE, + "Resource is a console message" + ); + ok(messageResource.message, "message is wrapped into a message attribute"); + if (expected.targetFront) { + is( + messageResource.targetFront, + expected.targetFront, + "Message has the correct target front" + ); + } + is( + messageResource.message.arguments[0], + expected.messageText, + "The correct type of message" + ); +} + +async function getIframeTargetFront(targetCommand) { + // If Fission/EFT is enabled, the iframe will have a dedicated target, + // otherwise it will be debuggable via the top level target. + if (!isFissionEnabled() && !isEveryFrameTargetEnabled()) { + return targetCommand.targetFront; + } + const frameTargets = targetCommand.getAllTargets([targetCommand.TYPES.FRAME]); + const browsingContextID = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + () => { + return content.document.querySelector("iframe").browsingContext.id; + } + ); + const iframeTarget = frameTargets.find(target => { + return target.browsingContextID == browsingContextID; + }); + ok(iframeTarget, "Found the iframe target front"); + return iframeTarget; +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_console_messages_workers.js b/devtools/shared/commands/resource/tests/browser_resources_console_messages_workers.js new file mode 100644 index 0000000000..4b10f1d2e4 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_console_messages_workers.js @@ -0,0 +1,257 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around CONSOLE_MESSAGE in workers + +const FISSION_TEST_URL = URL_ROOT_SSL + "fission_document_workers.html"; +const WORKER_FILE = "test_worker.js"; +const IFRAME_FILE = `${URL_ROOT_ORG_SSL}fission_iframe_workers.html`; + +add_task(async function () { + // Set the following pref to false as it's the one that enables direct connection + // to the worker targets + await pushPref("dom.worker.console.dispatch_events_to_main_thread", false); + + const tab = await addTab(FISSION_TEST_URL); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab, + { listenForWorkers: true } + ); + + info("Wait for the workers (from the main page and the iframe) to be ready"); + const targets = []; + await new Promise(resolve => { + const onAvailable = async ({ targetFront }) => { + targets.push(targetFront); + if (targets.length === 2) { + resolve(); + } + }; + targetCommand.watchTargets({ + types: [targetCommand.TYPES.WORKER], + onAvailable, + }); + }); + + // The worker logs a message right when it starts, containing its location, so we can + // assert that we get the logs from the worker spawned in the content page and from the + // worker spawned in the iframe. + info("Check that we receive the cached messages"); + + const resources = []; + const onAvailable = innerResources => { + for (const resource of innerResources) { + // Ignore resources from non worker targets + if (!resource.targetFront.isWorkerTarget) { + continue; + } + + resources.push(resource); + } + }; + + await resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { + onAvailable, + } + ); + + is(resources.length, 2, "Got the expected number of existing messages"); + const startLogFromWorkerInMainPage = resources.find( + ({ message }) => + message.arguments[1] === `${URL_ROOT_SSL}${WORKER_FILE}#simple-worker` + ); + const startLogFromWorkerInIframe = resources.find( + ({ message }) => + message.arguments[1] === + `${URL_ROOT_ORG_SSL}${WORKER_FILE}#simple-worker-in-iframe` + ); + + checkStartWorkerLogMessage(startLogFromWorkerInMainPage, { + expectedUrl: `${URL_ROOT_SSL}${WORKER_FILE}#simple-worker`, + isAlreadyExistingResource: true, + }); + checkStartWorkerLogMessage(startLogFromWorkerInIframe, { + expectedUrl: `${URL_ROOT_ORG_SSL}${WORKER_FILE}#simple-worker-in-iframe`, + isAlreadyExistingResource: true, + }); + let messageCount = resources.length; + + info( + "Now log messages *after* the call to ResourceCommand.watchResources and after having received all existing messages" + ); + + await SpecialPowers.spawn(tab.linkedBrowser, [], () => { + content.wrappedJSObject.logMessageInWorker("live message from main page"); + + const iframe = content.document.querySelector("iframe"); + SpecialPowers.spawn(iframe, [], () => { + content.wrappedJSObject.logMessageInWorker("live message from iframe"); + }); + }); + + // Wait until the 2 new logs are available + await waitUntil(() => resources.length === messageCount + 2); + const liveMessageFromWorkerInMainPage = resources.find( + ({ message }) => message.arguments[1] === "live message from main page" + ); + const liveMessageFromWorkerInIframe = resources.find( + ({ message }) => message.arguments[1] === "live message from iframe" + ); + + checkLogInWorkerMessage( + liveMessageFromWorkerInMainPage, + "live message from main page" + ); + + checkLogInWorkerMessage( + liveMessageFromWorkerInIframe, + "live message from iframe" + ); + + // update the current number of resources received + messageCount = resources.length; + + info("Now spawn new workers and log messages in main page and iframe"); + await SpecialPowers.spawn( + tab.linkedBrowser, + [WORKER_FILE], + async workerUrl => { + const spawnedWorker = new content.Worker(`${workerUrl}#spawned-worker`); + spawnedWorker.postMessage({ + type: "log-in-worker", + message: "live message in spawned worker from main page", + }); + + const iframe = content.document.querySelector("iframe"); + SpecialPowers.spawn(iframe, [workerUrl], async innerWorkerUrl => { + const spawnedWorkerInIframe = new content.Worker( + `${innerWorkerUrl}#spawned-worker-in-iframe` + ); + spawnedWorkerInIframe.postMessage({ + type: "log-in-worker", + message: "live message in spawned worker from iframe", + }); + }); + } + ); + + info( + "Wait until the 4 new logs are available (the ones logged at worker creation + the ones from postMessage" + ); + await waitUntil( + () => resources.length === messageCount + 4, + `Couldn't get the expected number of resources (expected ${ + messageCount + 4 + }, got ${resources.length})` + ); + const startLogFromSpawnedWorkerInMainPage = resources.find( + ({ message }) => + message.arguments[1] === `${URL_ROOT_SSL}${WORKER_FILE}#spawned-worker` + ); + const startLogFromSpawnedWorkerInIframe = resources.find( + ({ message }) => + message.arguments[1] === + `${URL_ROOT_ORG_SSL}${WORKER_FILE}#spawned-worker-in-iframe` + ); + const liveMessageFromSpawnedWorkerInMainPage = resources.find( + ({ message }) => + message.arguments[1] === "live message in spawned worker from main page" + ); + const liveMessageFromSpawnedWorkerInIframe = resources.find( + ({ message }) => + message.arguments[1] === "live message in spawned worker from iframe" + ); + + checkStartWorkerLogMessage(startLogFromSpawnedWorkerInMainPage, { + expectedUrl: `${URL_ROOT_SSL}${WORKER_FILE}#spawned-worker`, + }); + checkStartWorkerLogMessage(startLogFromSpawnedWorkerInIframe, { + expectedUrl: `${URL_ROOT_ORG_SSL}${WORKER_FILE}#spawned-worker-in-iframe`, + }); + checkLogInWorkerMessage( + liveMessageFromSpawnedWorkerInMainPage, + "live message in spawned worker from main page" + ); + checkLogInWorkerMessage( + liveMessageFromSpawnedWorkerInIframe, + "live message in spawned worker from iframe" + ); + // update the current number of resources received + messageCount = resources.length; + + info( + "Add a remote iframe on the same origin we already have an iframe and check we get the messages" + ); + await SpecialPowers.spawn( + tab.linkedBrowser, + [IFRAME_FILE], + async iframeUrl => { + const iframe = content.document.createElement("iframe"); + iframe.src = `${iframeUrl}?hashSuffix=in-second-iframe`; + content.document.body.append(iframe); + } + ); + + info("Wait until the new log is available"); + await waitUntil( + () => resources.length === messageCount + 1, + `Couldn't get the expected number of resources (expected ${ + messageCount + 1 + }, got ${resources.length})` + ); + const startLogFromWorkerInSecondIframe = resources[resources.length - 1]; + checkStartWorkerLogMessage(startLogFromWorkerInSecondIframe, { + expectedUrl: `${URL_ROOT_ORG_SSL}${WORKER_FILE}#simple-worker-in-second-iframe`, + }); + + targetCommand.destroy(); + await client.close(); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async () => { + // registrationPromise is set by the test page. + const registration = await content.wrappedJSObject.registrationPromise; + registration.unregister(); + }); +}); + +function checkStartWorkerLogMessage( + resource, + { expectedUrl, isAlreadyExistingResource = false } +) { + const { message } = resource; + const [firstArg, secondArg, thirdArg] = message.arguments; + is(firstArg, "[WORKER] started", "Got the expected first argument"); + is(secondArg, expectedUrl, "expected url was logged"); + is( + thirdArg?._grip?.class, + "DedicatedWorkerGlobalScope", + "the global scope was logged as expected" + ); + is( + resource.isAlreadyExistingResource, + isAlreadyExistingResource, + "Resource has expected value for isAlreadyExistingResource" + ); +} + +function checkLogInWorkerMessage(resource, expectedMessage) { + const { message } = resource; + const [firstArg, secondArg, thirdArg] = message.arguments; + is(firstArg, "[WORKER]", "Got the expected first argument"); + is(secondArg, expectedMessage, "expected message was logged"); + is( + thirdArg?._grip?.class, + "MessageEvent", + "the message event object was logged as expected" + ); + is( + resource.isAlreadyExistingResource, + false, + "Resource has expected value for isAlreadyExistingResource" + ); +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_css_changes.js b/devtools/shared/commands/resource/tests/browser_resources_css_changes.js new file mode 100644 index 0000000000..22b11a8186 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_css_changes.js @@ -0,0 +1,151 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around CSS_CHANGE. + +add_task(async function () { + // Open a test tab + const tab = await addTab( + "data:text/html,<body style='color: lime;'>CSS Changes</body>" + ); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + // CSS_CHANGE watcher doesn't record modification made before watching, + // so we have to start watching before doing any DOM mutation. + await resourceCommand.watchResources([resourceCommand.TYPES.CSS_CHANGE], { + onAvailable: () => {}, + }); + + const { walker } = await targetCommand.targetFront.getFront("inspector"); + const nodeList = await walker.querySelectorAll(walker.rootNode, "body"); + const body = (await nodeList.items())[0]; + const style = ( + await body.inspectorFront.pageStyle.getApplied(body, { + skipPseudo: false, + }) + )[0]; + + info( + "Check whether ResourceCommand catches CSS change that fired before starting to watch" + ); + await setProperty(style.rule, 0, "color", "black"); + + const availableResources = []; + await resourceCommand.watchResources([resourceCommand.TYPES.CSS_CHANGE], { + onAvailable: resources => availableResources.push(...resources), + }); + assertResource( + availableResources[0], + { index: 0, property: "color", value: "black" }, + { index: 0, property: "color", value: "lime" } + ); + + info( + "Check whether ResourceCommand catches CSS changes after the property was renamed and updated" + ); + + // RuleRewriter:apply will not support a simultaneous rename + setProperty. + // Doing so would send inconsistent arguments to StyleRuleActor:setRuleText, + // the CSS text for the rule will not match the list of modifications, which + // would desynchronize the Changes view. Thankfully this scenario should not + // happen when using the UI to update the rules. + await renameProperty(style.rule, 0, "color", "background-color"); + await waitUntil(() => availableResources.length === 2); + assertResource( + availableResources[1], + { index: 0, property: "background-color", value: "black" }, + { index: 0, property: "color", value: "black" } + ); + + await setProperty(style.rule, 0, "background-color", "pink"); + await waitUntil(() => availableResources.length === 3); + assertResource( + availableResources[2], + { index: 0, property: "background-color", value: "pink" }, + { index: 0, property: "background-color", value: "black" } + ); + + info("Check whether ResourceCommand catches CSS change of disabling"); + await setPropertyEnabled(style.rule, 0, "background-color", false); + await waitUntil(() => availableResources.length === 4); + assertResource(availableResources[3], null, { + index: 0, + property: "background-color", + value: "pink", + }); + + info("Check whether ResourceCommand catches CSS change of new property"); + await createProperty(style.rule, 1, "font-size", "100px"); + await waitUntil(() => availableResources.length === 5); + assertResource( + availableResources[4], + { index: 1, property: "font-size", value: "100px" }, + null + ); + + info("Check whether ResourceCommand sends all resources added in this test"); + const existingResources = []; + await resourceCommand.watchResources([resourceCommand.TYPES.CSS_CHANGE], { + onAvailable: resources => existingResources.push(...resources), + }); + await waitUntil(() => existingResources.length === 5); + is(availableResources[0], existingResources[0], "1st resource is correct"); + is(availableResources[1], existingResources[1], "2nd resource is correct"); + is(availableResources[2], existingResources[2], "3rd resource is correct"); + is(availableResources[3], existingResources[3], "4th resource is correct"); + is(availableResources[4], existingResources[4], "4th resource is correct"); + + targetCommand.destroy(); + await client.close(); +}); + +function assertResource(resource, expectedAddedChange, expectedRemovedChange) { + if (expectedAddedChange) { + is(resource.add.length, 1, "The number of added changes is correct"); + assertChange(resource.add[0], expectedAddedChange); + } else { + is(resource.add, null, "There is no added changes"); + } + + if (expectedRemovedChange) { + is(resource.remove.length, 1, "The number of removed changes is correct"); + assertChange(resource.remove[0], expectedRemovedChange); + } else { + is(resource.remove, null, "There is no removed changes"); + } +} + +function assertChange(change, expected) { + is(change.index, expected.index, "The index of change is correct"); + is(change.property, expected.property, "The property of change is correct"); + is(change.value, expected.value, "The value of change is correct"); +} + +async function setProperty(rule, index, property, value) { + const modifications = rule.startModifyingProperties({ isKnown: true }); + modifications.setProperty(index, property, value, ""); + await modifications.apply(); +} + +async function renameProperty(rule, index, oldName, newName, value) { + const modifications = rule.startModifyingProperties({ isKnown: true }); + modifications.renameProperty(index, oldName, newName); + await modifications.apply(); +} + +async function createProperty(rule, index, property, value) { + const modifications = rule.startModifyingProperties({ isKnown: true }); + modifications.createProperty(index, property, value, "", true); + await modifications.apply(); +} + +async function setPropertyEnabled(rule, index, property, isEnabled) { + const modifications = rule.startModifyingProperties({ isKnown: true }); + modifications.setPropertyEnabled(index, property, isEnabled); + await modifications.apply(); +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_css_messages.js b/devtools/shared/commands/resource/tests/browser_resources_css_messages.js new file mode 100644 index 0000000000..1b4b56cd4f --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_css_messages.js @@ -0,0 +1,212 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around CSS_MESSAGE +// Reproduces the CSS message assertions from devtools/shared/webconsole/test/chrome/test_page_errors.html + +const { MESSAGE_CATEGORY } = require("resource://devtools/shared/constants.js"); + +// Create a simple server so we have a nice sourceName in the resources packets. +const httpServer = createTestHTTPServer(); +httpServer.registerPathHandler(`/test_css_messages.html`, (req, res) => { + res.setStatusLine(req.httpVersion, 200, "OK"); + res.write(`<meta charset=utf8> + <style> + html { + body { + color: bloup; + } + } + </style>Test CSS Messages`); +}); + +const TEST_URI = `http://localhost:${httpServer.identity.primaryPort}/test_css_messages.html`; + +add_task(async function () { + await testWatchingCssMessages(); + await testWatchingCachedCssMessages(); +}); + +async function testWatchingCssMessages() { + // Disable the preloaded process as it creates processes intermittently + // which forces the emission of RDP requests we aren't correctly waiting for. + await pushPref("dom.ipc.processPrelaunch.enabled", false); + + // Open a test tab + const tab = await addTab(TEST_URI); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + const receivedMessages = []; + const { onAvailable, onAllMessagesReceived } = setupOnAvailableFunction( + targetCommand, + receivedMessages, + false + ); + await resourceCommand.watchResources([resourceCommand.TYPES.CSS_MESSAGE], { + onAvailable, + }); + + info( + "Now log CSS warning *after* the call to ResourceCommand.watchResources and after " + + "having received the existing message" + ); + // We need to wait for the first CSS Warning as it is not a cached message; when we + // start watching, the `cssErrorReportingEnabled` is checked on the target docShell, and + // if it is false, we re-parse the stylesheets to get the messages. + await BrowserTestUtils.waitForCondition(() => receivedMessages.length === 1); + + info("Trigger a CSS Warning"); + triggerCSSWarning(tab); + + info("Waiting for all expected CSS messages to be received"); + await onAllMessagesReceived; + ok(true, "All the expected CSS messages were received"); + + Services.console.reset(); + targetCommand.destroy(); + await client.close(); +} + +async function testWatchingCachedCssMessages() { + // Disable the preloaded process as it creates processes intermittently + // which forces the emission of RDP requests we aren't correctly waiting for. + await pushPref("dom.ipc.processPrelaunch.enabled", false); + + // Open a test tab + const tab = await addTab(TEST_URI); + + // By default, the CSS Parser does not emit warnings at all, for performance matter. + // Since we actually want the Parser to emit those messages _before_ we start listening + // for CSS messages, we need to set the cssErrorReportingEnabled flag on the docShell. + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () { + content.docShell.cssErrorReportingEnabled = true; + }); + + // Setting the docShell flag only indicates to the Parser that from now on, it should + // emit warnings. But it does not automatically emit warnings for the existing CSS + // errors in the stylesheets. So here we reload the tab, which will make the Parser + // parse the stylesheets again, this time emitting warnings. + await reloadBrowser(); + // and trigger more CSS warnings + await triggerCSSWarning(tab); + + // At this point, all messages should be in the ConsoleService cache, and we can begin + // to watch and check that we do retrieve those messages. + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + const receivedMessages = []; + const { onAvailable } = setupOnAvailableFunction( + targetCommand, + receivedMessages, + true + ); + await resourceCommand.watchResources([resourceCommand.TYPES.CSS_MESSAGE], { + onAvailable, + }); + is(receivedMessages.length, 3, "Cached messages were retrieved as expected"); + + Services.console.reset(); + targetCommand.destroy(); + await client.close(); +} + +function setupOnAvailableFunction( + targetCommand, + receivedMessages, + isAlreadyExistingResource +) { + // timeStamp are the result of a number in microsecond divided by 1000. + // so we can't expect a precise number of decimals, or even if there would + // be decimals at all. + const FRACTIONAL_NUMBER_REGEX = /^\d+(\.\d{1,3})?$/; + + // The expected messages are the CSS warnings: + // - one for the rule in the style element + // - two for the JS modified style we're doing in the test. + const expectedMessages = [ + { + pageError: { + errorMessage: /Expected color but found โbloupโ/, + sourceName: /test_css_messages/, + category: MESSAGE_CATEGORY.CSS_PARSER, + timeStamp: FRACTIONAL_NUMBER_REGEX, + error: false, + warning: true, + }, + cssSelectors: ":is(html) body", + isAlreadyExistingResource, + }, + { + pageError: { + errorMessage: /Error in parsing value for โwidthโ/, + sourceName: /test_css_messages/, + category: MESSAGE_CATEGORY.CSS_PARSER, + timeStamp: FRACTIONAL_NUMBER_REGEX, + error: false, + warning: true, + }, + isAlreadyExistingResource, + }, + { + pageError: { + errorMessage: /Error in parsing value for โheightโ/, + sourceName: /test_css_messages/, + category: MESSAGE_CATEGORY.CSS_PARSER, + timeStamp: FRACTIONAL_NUMBER_REGEX, + error: false, + warning: true, + }, + isAlreadyExistingResource, + }, + ]; + + let done; + const onAllMessagesReceived = new Promise(resolve => (done = resolve)); + const onAvailable = resources => { + for (const resource of resources) { + const { pageError } = resource; + + is( + resource.targetFront, + targetCommand.targetFront, + "The targetFront property is the expected one" + ); + + if (!pageError.sourceName.includes("test_css_messages")) { + info(`Ignore error from unknown source: "${pageError.sourceName}"`); + continue; + } + + const index = receivedMessages.length; + receivedMessages.push(resource); + + info( + `checking received css message #${index}: ${pageError.errorMessage}` + ); + ok(pageError, "The resource has a pageError attribute"); + checkObject(resource, expectedMessages[index]); + + if (receivedMessages.length == expectedMessages.length) { + done(); + } + } + }; + return { onAvailable, onAllMessagesReceived }; +} + +/** + * Sets invalid values for width and height on the document's body style attribute. + */ +function triggerCSSWarning(tab) { + return ContentTask.spawn(tab.linkedBrowser, null, function frameScript() { + content.document.body.style.width = "red"; + content.document.body.style.height = "blue"; + }); +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_css_registered_properties.js b/devtools/shared/commands/resource/tests/browser_resources_css_registered_properties.js new file mode 100644 index 0000000000..1429b55167 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_css_registered_properties.js @@ -0,0 +1,384 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around CSS_REGISTERED_PROPERTIES. + +const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js"); + +const IFRAME_URL = `https://example.org/document-builder.sjs?html=${encodeURIComponent(` + <style> + @property --css-a { + syntax: "<color>"; + inherits: true; + initial-value: gold; + } + </style> + <script> + CSS.registerProperty({ + name: "--js-a", + syntax: "<length>", + inherits: true, + initialValue: "20px" + }); + </script> + <h1>iframe</h1> +`)}`; + +const TEST_URL = `https://example.org/document-builder.sjs?html= + <head> + <style> + @property --css-a { + syntax: "*"; + inherits: false; + } + + @property --css-b { + syntax: "<color>"; + inherits: true; + initial-value: tomato; + } + </style> + <script> + CSS.registerProperty({ + name: "--js-a", + syntax: "*", + inherits: false, + }); + CSS.registerProperty({ + name: "--js-b", + syntax: "<length>", + inherits: true, + initialValue: "10px" + }); + </script> + </head> + <h1>CSS_REGISTERED_PROPERTIES</h1> + <iframe src="${encodeURIComponent(IFRAME_URL)}"></iframe>`; + +add_task(async function () { + await pushPref("layout.css.properties-and-values.enabled", true); + const tab = await addTab(TEST_URL); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + // Wait for targets + await targetCommand.startListening(); + const targets = []; + const onAvailable = ({ targetFront }) => targets.push(targetFront); + await targetCommand.watchTargets({ + types: [targetCommand.TYPES.FRAME], + onAvailable, + }); + await waitFor(() => targets.length === 2); + const [topLevelTarget, iframeTarget] = targets.sort((a, b) => + a.isTopLevel ? -1 : 1 + ); + + // Watching for new stylesheets shouldn't be + const stylesheets = []; + await resourceCommand.watchResources([resourceCommand.TYPES.STYLESHEET], { + onAvailable: resources => stylesheets.push(...resources), + ignoreExistingResources: true, + }); + + info("Check that we get existing registered properties"); + const availableResources = []; + const updatedResources = []; + const destroyedResources = []; + await resourceCommand.watchResources( + [resourceCommand.TYPES.CSS_REGISTERED_PROPERTIES], + { + onAvailable: resources => availableResources.push(...resources), + onUpdated: resources => updatedResources.push(...resources), + onDestroyed: resources => destroyedResources.push(...resources), + } + ); + + is( + availableResources.length, + 6, + "The 6 existing registered properties where retrieved" + ); + + // Sort resources so we get them alphabetically ordered by their name, with the ones for + // the top level target displayed first. + availableResources.sort((a, b) => { + if (a.targetFront !== b.targetFront) { + return a.targetFront.isTopLevel ? -1 : 1; + } + return a.name < b.name ? -1 : 1; + }); + + assertResource(availableResources[0], { + name: "--css-a", + syntax: "*", + inherits: false, + initialValue: null, + fromJS: false, + targetFront: topLevelTarget, + }); + assertResource(availableResources[1], { + name: "--css-b", + syntax: "<color>", + inherits: true, + initialValue: "tomato", + fromJS: false, + targetFront: topLevelTarget, + }); + assertResource(availableResources[2], { + name: "--js-a", + syntax: "*", + inherits: false, + initialValue: null, + fromJS: true, + targetFront: topLevelTarget, + }); + assertResource(availableResources[3], { + name: "--js-b", + syntax: "<length>", + inherits: true, + initialValue: "10px", + fromJS: true, + targetFront: topLevelTarget, + }); + assertResource(availableResources[4], { + name: "--css-a", + syntax: "<color>", + inherits: true, + initialValue: "gold", + fromJS: false, + targetFront: iframeTarget, + }); + assertResource(availableResources[5], { + name: "--js-a", + syntax: "<length>", + inherits: true, + initialValue: "20px", + fromJS: true, + targetFront: iframeTarget, + }); + + info("Check that we didn't get notified about existing stylesheets"); + // wait a bit so we'd have the time to be notified about stylesheet resources + await wait(500); + is( + stylesheets.length, + 0, + "Watching for registered properties does not notify about existing stylesheets resources" + ); + + info("Check that we get properties from new stylesheets"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + const s = content.document.createElement("style"); + s.textContent = ` + @property --css-c { + syntax: "<custom-ident>"; + inherits: true; + initial-value: custom; + } + + @property --css-d { + syntax: "big | bigger"; + inherits: true; + initial-value: big; + } + `; + content.document.head.append(s); + }); + + info("Wait for registered properties to be available"); + await waitFor(() => availableResources.length === 8); + ok(true, "Got notified about 2 new registered properties"); + assertResource(availableResources[6], { + name: "--css-c", + syntax: "<custom-ident>", + inherits: true, + initialValue: "custom", + fromJS: false, + targetFront: topLevelTarget, + }); + assertResource(availableResources[7], { + name: "--css-d", + syntax: "big | bigger", + inherits: true, + initialValue: "big", + fromJS: false, + targetFront: topLevelTarget, + }); + + info("Wait to be notified about the new stylesheet"); + await waitFor(() => stylesheets.length === 1); + ok(true, "we do get notified about stylesheets"); + + info( + "Check that we get notified about properties registered via CSS.registerProperty" + ); + await SpecialPowers.spawn(tab.linkedBrowser, [], () => { + content.CSS.registerProperty({ + name: "--js-c", + syntax: "*", + inherits: false, + initialValue: 42, + }); + content.CSS.registerProperty({ + name: "--js-d", + syntax: "<color>#", + inherits: true, + initialValue: "blue,cyan", + }); + }); + + await waitFor(() => availableResources.length === 10); + ok(true, "Got notified about 2 new registered properties"); + assertResource(availableResources[8], { + name: "--js-c", + syntax: "*", + inherits: false, + initialValue: "42", + fromJS: true, + targetFront: topLevelTarget, + }); + assertResource(availableResources[9], { + name: "--js-d", + syntax: "<color>#", + inherits: true, + initialValue: "blue,cyan", + fromJS: true, + targetFront: topLevelTarget, + }); + + info( + "Check that we get notified about properties registered via CSS.registerProperty in iframe" + ); + const iframeBrowsingContext = await SpecialPowers.spawn( + tab.linkedBrowser, + [], + () => content.document.querySelector("iframe").browsingContext + ); + + await SpecialPowers.spawn(iframeBrowsingContext, [], () => { + content.CSS.registerProperty({ + name: "--js-iframe", + syntax: "<color>#", + inherits: true, + initialValue: "red,salmon", + }); + }); + + await waitFor(() => availableResources.length === 11); + ok(true, "Got notified about 2 new registered properties"); + assertResource(availableResources[10], { + name: "--js-iframe", + syntax: "<color>#", + inherits: true, + initialValue: "red,salmon", + fromJS: true, + targetFront: iframeTarget, + }); + + info( + "Check that we get notified about destroyed properties when removing stylesheet" + ); + // sanity check + is(destroyedResources.length, 0, "No destroyed resources yet"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + content.document.querySelector("style").remove(); + }); + await waitFor(() => destroyedResources.length == 2); + ok(true, "We got notified about destroyed resources"); + destroyedResources.sort((a, b) => a < b); + is( + destroyedResources[0].resourceType, + ResourceCommand.TYPES.CSS_REGISTERED_PROPERTIES, + "resource type is correct" + ); + is( + destroyedResources[0].resourceId, + `${topLevelTarget.actorID}:css-registered-property:--css-a`, + "expected css property was destroyed" + ); + is( + destroyedResources[1].resourceType, + ResourceCommand.TYPES.CSS_REGISTERED_PROPERTIES, + "resource type is correct" + ); + is( + destroyedResources[1].resourceId, + `${topLevelTarget.actorID}:css-registered-property:--css-b`, + "expected css property was destroyed" + ); + + info( + "Check that we get notified about updated properties when modifying stylesheet" + ); + is(updatedResources.length, 0, "No updated resources yet"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + content.document.querySelector("style").textContent = ` + /* not updated */ + @property --css-c { + syntax: "<custom-ident>"; + inherits: true; + initial-value: custom; + } + + @property --css-d { + syntax: "big | bigger"; + inherits: true; + /* only change initial value (was big) */ + initial-value: bigger; + } + + /* add a new property */ + @property --css-e { + syntax: "<color>"; + inherits: false; + initial-value: green; + } + `; + }); + await waitFor(() => updatedResources.length === 1); + ok(true, "One property was updated"); + assertResource(updatedResources[0].resource, { + name: "--css-d", + syntax: "big | bigger", + inherits: true, + initialValue: "bigger", + fromJS: false, + targetFront: topLevelTarget, + }); + + await waitFor(() => availableResources.length === 12); + ok(true, "We got notified about the new property"); + assertResource(availableResources.at(-1), { + name: "--css-e", + syntax: "<color>", + inherits: false, + initialValue: "green", + fromJS: false, + targetFront: topLevelTarget, + }); + + await client.close(); +}); + +async function assertResource(resource, expected) { + is( + resource.resourceType, + ResourceCommand.TYPES.CSS_REGISTERED_PROPERTIES, + "Resource type is correct" + ); + is(resource.name, expected.name, "name is correct"); + is(resource.syntax, expected.syntax, "syntax is correct"); + is(resource.inherits, expected.inherits, "inherits is correct"); + is(resource.initialValue, expected.initialValue, "initialValue is correct"); + is(resource.fromJS, expected.fromJS, "fromJS is correct"); + is( + resource.targetFront, + expected.targetFront, + "resource is associated with expected target" + ); +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_document_events.js b/devtools/shared/commands/resource/tests/browser_resources_document_events.js new file mode 100644 index 0000000000..4692cba1ed --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_document_events.js @@ -0,0 +1,720 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around DOCUMENT_EVENT + +add_task(async function () { + await testDocumentEventResources(); + await testDocumentEventResourcesWithIgnoreExistingResources(); + await testDomCompleteWithOverloadedConsole(); + await testIframeNavigation(); + await testBfCacheNavigation(); + await testDomCompleteWithWindowStop(); + await testCrossOriginNavigation(); +}); + +async function testDocumentEventResources() { + info("Test ResourceCommand for DOCUMENT_EVENT"); + + // Open a test tab + const title = "DocumentEventsTitle"; + const url = `data:text/html,<title>${title}</title>Document Events`; + const tab = await addTab(url); + + const listener = new ResourceListener(); + const { commands } = await initResourceCommand(tab); + + info( + "Check whether the document events are fired correctly even when the document was already loaded" + ); + const onLoadingAtInit = listener.once("dom-loading"); + const onInteractiveAtInit = listener.once("dom-interactive"); + const onCompleteAtInit = listener.once("dom-complete"); + await commands.resourceCommand.watchResources( + [commands.resourceCommand.TYPES.DOCUMENT_EVENT], + { + onAvailable: parameters => listener.dispatch(parameters), + } + ); + await assertPromises( + commands, + // targetBeforeNavigation is only used when there is a will-navigate and a navigate, but there is none here + null, + // As we started watching on an already loaded document, and no navigation happened since we called watchResources, + // we don't have any will-navigate event + null, + onLoadingAtInit, + onInteractiveAtInit, + onCompleteAtInit + ); + ok( + true, + "Document events are fired even when the document was already loaded" + ); + let domLoadingResource = await onLoadingAtInit; + + is( + domLoadingResource.url, + url, + `resource ${domLoadingResource.name} has expected url` + ); + is( + domLoadingResource.title, + undefined, + `resource ${domLoadingResource.name} does not have a title property` + ); + + let domInteractiveResource = await onInteractiveAtInit; + is( + domInteractiveResource.url, + url, + `resource ${domInteractiveResource.name} has expected url` + ); + is( + domInteractiveResource.title, + title, + `resource ${domInteractiveResource.name} has expected title` + ); + let domCompleteResource = await onCompleteAtInit; + is( + domCompleteResource.url, + undefined, + `resource ${domCompleteResource.name} does not have a url property` + ); + is( + domCompleteResource.title, + undefined, + `resource ${domCompleteResource.name} does not have a title property` + ); + + info("Check whether the document events are fired correctly when reloading"); + const onWillNavigate = listener.once("will-navigate"); + const onLoadingAtReloaded = listener.once("dom-loading"); + const onInteractiveAtReloaded = listener.once("dom-interactive"); + const onCompleteAtReloaded = listener.once("dom-complete"); + const targetBeforeNavigation = commands.targetCommand.targetFront; + gBrowser.reloadTab(tab); + await assertPromises( + commands, + targetBeforeNavigation, + onWillNavigate, + onLoadingAtReloaded, + onInteractiveAtReloaded, + onCompleteAtReloaded + ); + ok(true, "Document events are fired after reloading"); + + domLoadingResource = await onLoadingAtReloaded; + is( + domLoadingResource.url, + url, + `resource ${domLoadingResource.name} has expected url after reloading` + ); + is( + domLoadingResource.title, + undefined, + `resource ${domLoadingResource.name} does not have a title property after reloading` + ); + + domInteractiveResource = await onInteractiveAtInit; + is( + domInteractiveResource.url, + url, + `resource ${domInteractiveResource.name} has url property after reloading` + ); + is( + domInteractiveResource.title, + title, + `resource ${domInteractiveResource.name} has expected title after reloading` + ); + domCompleteResource = await onCompleteAtInit; + is( + domCompleteResource.url, + undefined, + `resource ${domCompleteResource.name} does not have a url property after reloading` + ); + is( + domCompleteResource.title, + undefined, + `resource ${domCompleteResource.name} does not have a title property after reloading` + ); + + await commands.destroy(); +} + +async function testDocumentEventResourcesWithIgnoreExistingResources() { + info("Test ignoreExistingResources option for DOCUMENT_EVENT"); + + const tab = await addTab("data:text/html,Document Events"); + + const { commands } = await initResourceCommand(tab); + + info("Check whether the existing document events will not be fired"); + const documentEvents = []; + await commands.resourceCommand.watchResources( + [commands.resourceCommand.TYPES.DOCUMENT_EVENT], + { + onAvailable: resources => documentEvents.push(...resources), + ignoreExistingResources: true, + } + ); + is(documentEvents.length, 0, "Existing document events are not fired"); + + info("Check whether the future document events are fired"); + const targetBeforeNavigation = commands.targetCommand.targetFront; + gBrowser.reloadTab(tab); + info( + "Wait for will-navigate, dom-loading, dom-interactive and dom-complete events" + ); + await waitFor(() => documentEvents.length === 4); + assertEvents({ commands, targetBeforeNavigation, documentEvents }); + + await commands.destroy(); +} + +async function testIframeNavigation() { + info("Test iframe navigations for DOCUMENT_EVENT"); + + const tab = await addTab( + 'https://example.com/document-builder.sjs?html=<iframe src="https://example.net/document-builder.sjs?html=net"></iframe>' + ); + const secondPageUrl = "https://example.org/document-builder.sjs?html=org"; + + const { commands } = await initResourceCommand(tab); + + let documentEvents = []; + await commands.resourceCommand.watchResources( + [commands.resourceCommand.TYPES.DOCUMENT_EVENT], + { + onAvailable: resources => documentEvents.push(...resources), + } + ); + let iframeTarget; + if (isFissionEnabled() || isEveryFrameTargetEnabled()) { + is( + documentEvents.length, + 6, + "With fission/EFT, we get two targets and two sets of events: dom-loading, dom-interactive, dom-complete" + ); + [, iframeTarget] = await commands.targetCommand.getAllTargets([ + commands.targetCommand.TYPES.FRAME, + ]); + // Filter out each target events as their order to be random between the two targets + const topTargetEvents = documentEvents.filter( + r => r.targetFront == commands.targetCommand.targetFront + ); + const iframeTargetEvents = documentEvents.filter( + r => r.targetFront != commands.targetCommand.targetFront + ); + assertEvents({ + commands, + documentEvents: [null /* no will-navigate */, ...topTargetEvents], + }); + assertEvents({ + commands, + documentEvents: [null /* no will-navigate */, ...iframeTargetEvents], + expectedTargetFront: iframeTarget, + }); + } else { + assertEvents({ + commands, + documentEvents: [null /* no will-navigate */, ...documentEvents], + }); + } + + info("Navigate the iframe to another process (if fission is enabled)"); + documentEvents = []; + await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [secondPageUrl], + function (url) { + const iframe = content.document.querySelector("iframe"); + iframe.src = url; + } + ); + + // We are switching to a new target only when fission is enabled... + if (isFissionEnabled() || isEveryFrameTargetEnabled()) { + await waitFor(() => documentEvents.length >= 3); + is( + documentEvents.length, + 3, + "With fission/EFT, we switch to a new target and get: dom-loading, dom-interactive, dom-complete (but no will-navigate as that's only for the top BrowsingContext)" + ); + const [, newIframeTarget] = await commands.targetCommand.getAllTargets([ + commands.targetCommand.TYPES.FRAME, + ]); + assertEvents({ + commands, + targetBeforeNavigation: iframeTarget, + documentEvents: [null /* no will-navigate */, ...documentEvents], + expectedTargetFront: newIframeTarget, + expectedNewURI: secondPageUrl, + }); + } else { + // Wait for some time in order to let a chance to receive some unexpected events + await wait(250); + is( + documentEvents.length, + 0, + "If fission is disabled, we navigate within the same process, we get no new target and no new resource" + ); + } + + await commands.destroy(); +} + +function isBfCacheInParentEnabled() { + return ( + Services.appinfo.sessionHistoryInParent && + Services.prefs.getBoolPref("fission.bfcacheInParent", false) + ); +} + +async function testBfCacheNavigation() { + info("Test bfcache navigations for DOCUMENT_EVENT"); + + info("Open a first document and navigate to a second one"); + const firstLocation = "data:text/html,<title>first</title>first page"; + const secondLocation = "data:text/html,<title>second</title>second page"; + const tab = await addTab(firstLocation); + const onLoaded = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + BrowserTestUtils.startLoadingURIString( + gBrowser.selectedBrowser, + secondLocation + ); + await onLoaded; + + const { commands } = await initResourceCommand(tab); + + const documentEvents = []; + await commands.resourceCommand.watchResources( + [commands.resourceCommand.TYPES.DOCUMENT_EVENT], + { + onAvailable: resources => { + documentEvents.push(...resources); + }, + ignoreExistingResources: true, + } + ); + // Wait for some time for extra safety + await wait(250); + is(documentEvents.length, 0, "Existing document events are not fired"); + + info("Navigate back to the first page"); + const onSwitched = commands.targetCommand.once("switched-target"); + const targetBeforeNavigation = commands.targetCommand.targetFront; + gBrowser.goBack(); + + // We are switching to a new target only when fission/EFT is enabled... + if ( + (isFissionEnabled() || isEveryFrameTargetEnabled()) && + isBfCacheInParentEnabled() + ) { + await onSwitched; + } + + info( + "Wait for will-navigate, dom-loading, dom-interactive and dom-complete events" + ); + await waitFor(() => documentEvents.length >= 4); + /* Ignore will-navigate timestamp as all other DOCUMENT_EVENTS will be set at the original load date, + which is when we loaded from the network, and not when we loaded from bfcache */ + assertEvents({ + commands, + targetBeforeNavigation, + documentEvents, + ignoreWillNavigateTimestamp: true, + }); + + // Wait for some time in order to let a chance to have duplicated dom-loading events + await wait(250); + + is( + documentEvents.length, + 4, + "There is no duplicated event and only the 4 expected DOCUMENT_EVENT states" + ); + const [willNavigateEvent, loadingEvent, interactiveEvent, completeEvent] = + documentEvents; + + is( + willNavigateEvent.name, + "will-navigate", + "The first DOCUMENT_EVENT is will-navigate" + ); + is( + loadingEvent.name, + "dom-loading", + "The second DOCUMENT_EVENT is dom-loading" + ); + is( + interactiveEvent.name, + "dom-interactive", + "The third DOCUMENT_EVENT is dom-interactive" + ); + is( + completeEvent.name, + "dom-complete", + "The fourth DOCUMENT_EVENT is dom-complete" + ); + + is( + loadingEvent.url, + firstLocation, + `resource ${loadingEvent.name} has expected url after navigation back` + ); + is( + loadingEvent.title, + undefined, + `resource ${loadingEvent.name} does not have a title property after navigating back` + ); + + is( + interactiveEvent.url, + firstLocation, + `resource ${interactiveEvent.name} has expected url property after navigating back` + ); + is( + interactiveEvent.title, + "first", + `resource ${interactiveEvent.name} has expected title after navigating back` + ); + + is( + completeEvent.url, + undefined, + `resource ${completeEvent.name} does not have a url property after navigating back` + ); + is( + completeEvent.title, + undefined, + `resource ${completeEvent.name} does not have a title property after navigating back` + ); + + await commands.destroy(); +} + +async function testCrossOriginNavigation() { + info("Test cross origin navigations for DOCUMENT_EVENT"); + + const tab = await addTab("https://example.com/document-builder.sjs?html=com"); + + const { commands } = await initResourceCommand(tab); + + const documentEvents = []; + await commands.resourceCommand.watchResources( + [commands.resourceCommand.TYPES.DOCUMENT_EVENT], + { + onAvailable: resources => documentEvents.push(...resources), + ignoreExistingResources: true, + } + ); + // Wait for some time for extra safety + await wait(250); + is(documentEvents.length, 0, "Existing document events are not fired"); + + info("Navigate to another process"); + const onSwitched = commands.targetCommand.once("switched-target"); + const netUrl = + "https://example.net/document-builder.sjs?html=<head><title>titleNet</title></head>net"; + const onLoaded = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + const targetBeforeNavigation = commands.targetCommand.targetFront; + BrowserTestUtils.startLoadingURIString(gBrowser.selectedBrowser, netUrl); + await onLoaded; + + // We are switching to a new target only when fission is enabled... + if (isFissionEnabled() || isEveryFrameTargetEnabled()) { + await onSwitched; + } + + info( + "Wait for will-navigate, dom-loading, dom-interactive and dom-complete events" + ); + await waitFor(() => documentEvents.length >= 4); + assertEvents({ commands, targetBeforeNavigation, documentEvents }); + + // Wait for some time in order to let a chance to have duplicated dom-loading events + await wait(250); + + is( + documentEvents.length, + 4, + "There is no duplicated event and only the 4 expected DOCUMENT_EVENT states" + ); + const [willNavigateEvent, loadingEvent, interactiveEvent, completeEvent] = + documentEvents; + + is( + willNavigateEvent.name, + "will-navigate", + "The first DOCUMENT_EVENT is will-navigate" + ); + is( + loadingEvent.name, + "dom-loading", + "The second DOCUMENT_EVENT is dom-loading" + ); + is( + interactiveEvent.name, + "dom-interactive", + "The third DOCUMENT_EVENT is dom-interactive" + ); + is( + completeEvent.name, + "dom-complete", + "The fourth DOCUMENT_EVENT is dom-complete" + ); + + is( + loadingEvent.url, + encodeURI(netUrl), + `resource ${loadingEvent.name} has expected url after reloading` + ); + is( + loadingEvent.title, + undefined, + `resource ${loadingEvent.name} does not have a title property after reloading` + ); + + is( + interactiveEvent.url, + encodeURI(netUrl), + `resource ${interactiveEvent.name} has expected url property after reloading` + ); + is( + interactiveEvent.title, + "titleNet", + `resource ${interactiveEvent.name} has expected title after reloading` + ); + + is( + completeEvent.url, + undefined, + `resource ${completeEvent.name} does not have a url property after reloading` + ); + is( + completeEvent.title, + undefined, + `resource ${completeEvent.name} does not have a title property after reloading` + ); + + await commands.destroy(); +} + +async function testDomCompleteWithOverloadedConsole() { + info("Test dom-complete with an overloaded console object"); + + const tab = await addTab( + "data:text/html,<script>window.console = {};</script>" + ); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Check that all DOCUMENT_EVENTS are fired for the already loaded page"); + const documentEvents = []; + await resourceCommand.watchResources([resourceCommand.TYPES.DOCUMENT_EVENT], { + onAvailable: resources => documentEvents.push(...resources), + }); + is(documentEvents.length, 3, "Existing document events are fired"); + + const domComplete = documentEvents[2]; + is(domComplete.name, "dom-complete", "the last resource is the dom-complete"); + is( + domComplete.hasNativeConsoleAPI, + false, + "the console object is reported to be overloaded" + ); + + targetCommand.destroy(); + await client.close(); +} + +async function testDomCompleteWithWindowStop() { + info("Test dom-complete with a page calling window.stop()"); + + const tab = await addTab("data:text/html,foo"); + + const { commands, client, resourceCommand, targetCommand } = + await initResourceCommand(tab); + + info("Check that all DOCUMENT_EVENTS are fired for the already loaded page"); + let documentEvents = []; + await resourceCommand.watchResources([resourceCommand.TYPES.DOCUMENT_EVENT], { + onAvailable: resources => documentEvents.push(...resources), + }); + is(documentEvents.length, 3, "Existing document events are fired"); + documentEvents = []; + + const html = `<!DOCTYPE html><html> + <head> + <title>stopped page</title> + <script>window.stop();</script> + </head> + <body>Page content that shouldn't be displayed</body> +</html>`; + const secondLocation = "data:text/html," + encodeURIComponent(html); + const targetBeforeNavigation = commands.targetCommand.targetFront; + BrowserTestUtils.startLoadingURIString( + gBrowser.selectedBrowser, + secondLocation + ); + info( + "Wait for will-navigate, dom-loading, dom-interactive and dom-complete events" + ); + await waitFor(() => documentEvents.length === 4); + + assertEvents({ commands, targetBeforeNavigation, documentEvents }); + + targetCommand.destroy(); + await client.close(); +} + +async function assertPromises( + commands, + targetBeforeNavigation, + onWillNavigate, + onLoading, + onInteractive, + onComplete +) { + const willNavigateEvent = await onWillNavigate; + const loadingEvent = await onLoading; + const interactiveEvent = await onInteractive; + const completeEvent = await onComplete; + assertEvents({ + commands, + targetBeforeNavigation, + documentEvents: [ + willNavigateEvent, + loadingEvent, + interactiveEvent, + completeEvent, + ], + }); +} + +function assertEvents({ + commands, + targetBeforeNavigation, + documentEvents, + expectedTargetFront = commands.targetCommand.targetFront, + expectedNewURI = gBrowser.selectedBrowser.currentURI.spec, + ignoreWillNavigateTimestamp = false, +}) { + const [willNavigateEvent, loadingEvent, interactiveEvent, completeEvent] = + documentEvents; + if (willNavigateEvent) { + is(willNavigateEvent.name, "will-navigate", "Received the will-navigate"); + is( + willNavigateEvent.newURI, + expectedNewURI, + "will-navigate newURI is set to the current tab new location" + ); + } + is( + loadingEvent.name, + "dom-loading", + "loading received in the exepected order" + ); + is( + interactiveEvent.name, + "dom-interactive", + "interactive received in the expected order" + ); + is(completeEvent.name, "dom-complete", "complete received last"); + + if (willNavigateEvent) { + is( + typeof willNavigateEvent.time, + "number", + `Type of time attribute for will-navigate event is correct (${willNavigateEvent.time})` + ); + } + is( + typeof loadingEvent.time, + "number", + `Type of time attribute for loading event is correct (${loadingEvent.time})` + ); + is( + typeof interactiveEvent.time, + "number", + `Type of time attribute for interactive event is correct (${interactiveEvent.time})` + ); + is( + typeof completeEvent.time, + "number", + `Type of time attribute for complete event is correct (${completeEvent.time})` + ); + + if (willNavigateEvent && !ignoreWillNavigateTimestamp) { + Assert.lessOrEqual( + willNavigateEvent.time, + loadingEvent.time, + `Timestamp for dom-loading event is greater than will-navigate event (${willNavigateEvent.time} <= ${loadingEvent.time})` + ); + } + Assert.lessOrEqual( + loadingEvent.time, + interactiveEvent.time, + `Timestamp for interactive event is greater than loading event (${loadingEvent.time} <= ${interactiveEvent.time})` + ); + Assert.lessOrEqual( + interactiveEvent.time, + completeEvent.time, + `Timestamp for complete event is greater than interactive event (${interactiveEvent.time} <= ${completeEvent.time}).` + ); + + if (willNavigateEvent) { + // If we switched to a new target, this target will be different from currentTargetFront. + // This only happen if we navigate to another process or if server target switching is enabled. + is( + willNavigateEvent.targetFront, + targetBeforeNavigation, + "will-navigate target was the one before the navigation" + ); + } + is( + loadingEvent.targetFront, + expectedTargetFront, + "loading target is the expected one" + ); + is( + interactiveEvent.targetFront, + expectedTargetFront, + "interactive target is the expected one" + ); + is( + completeEvent.targetFront, + expectedTargetFront, + "complete target is the expected one" + ); + + is( + completeEvent.hasNativeConsoleAPI, + true, + "None of the tests (except the dedicated one) overload the console object" + ); +} + +class ResourceListener { + _listeners = new Map(); + + dispatch(resources) { + for (const resource of resources) { + const resolve = this._listeners.get(resource.name); + if (resolve) { + resolve(resource); + this._listeners.delete(resource.name); + } + } + } + + once(resourceName) { + return new Promise(r => this._listeners.set(resourceName, r)); + } +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_error_messages.js b/devtools/shared/commands/resource/tests/browser_resources_error_messages.js new file mode 100644 index 0000000000..6f94266e4c --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_error_messages.js @@ -0,0 +1,877 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around ERROR_MESSAGE +// Reproduces assertions from devtools/shared/webconsole/test/chrome/test_page_errors.html + +// Create a simple server so we have a nice sourceName in the resources packets. +const httpServer = createTestHTTPServer(); +httpServer.registerPathHandler(`/test_page_errors.html`, (req, res) => { + res.setStatusLine(req.httpVersion, 200, "OK"); + res.write(`<!DOCTYPE html><meta charset=utf8>Test Error Messages`); +}); + +const TEST_URI = `http://localhost:${httpServer.identity.primaryPort}/test_page_errors.html`; + +add_task(async function () { + // Disable the preloaded process as it creates processes intermittently + // which forces the emission of RDP requests we aren't correctly waiting for. + await pushPref("dom.ipc.processPrelaunch.enabled", false); + + await testErrorMessagesResources(); + await testErrorMessagesResourcesWithIgnoreExistingResources(); +}); + +async function testErrorMessagesResources() { + // Open a test tab + const tab = await addTab(TEST_URI); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + const receivedMessages = []; + // The expected messages are the errors, twice (once for cached messages, once for live messages) + const expectedMessages = Array.from(expectedPageErrors.values()).concat( + Array.from(expectedPageErrors.values()) + ); + + info( + "Log some errors *before* calling ResourceCommand.watchResources in order to assert" + + " the behavior of already existing messages." + ); + await triggerErrors(tab); + + let done; + const onAllErrorReceived = new Promise(resolve => (done = resolve)); + const onAvailable = resources => { + for (const resource of resources) { + const { pageError } = resource; + + is( + resource.targetFront, + targetCommand.targetFront, + "The targetFront property is the expected one" + ); + + if (!pageError.sourceName.includes("test_page_errors")) { + info(`Ignore error from unknown source: "${pageError.sourceName}"`); + continue; + } + + const index = receivedMessages.length; + receivedMessages.push(resource); + + const isAlreadyExistingResource = + receivedMessages.length <= expectedPageErrors.size; + is( + resource.isAlreadyExistingResource, + isAlreadyExistingResource, + "isAlreadyExistingResource has expected value" + ); + + info(`checking received page error #${index}: ${pageError.errorMessage}`); + ok(pageError, "The resource has a pageError attribute"); + checkPageErrorResource(pageError, expectedMessages[index]); + + if (receivedMessages.length == expectedMessages.length) { + done(); + } + } + }; + + await resourceCommand.watchResources([resourceCommand.TYPES.ERROR_MESSAGE], { + onAvailable, + }); + + await BrowserTestUtils.waitForCondition( + () => receivedMessages.length === expectedPageErrors.size + ); + + info( + "Now log errors *after* the call to ResourceCommand.watchResources and after having" + + " received all existing messages" + ); + await triggerErrors(tab); + + info("Waiting for all expected errors to be received"); + await onAllErrorReceived; + ok(true, "All the expected errors were received"); + + Services.console.reset(); + targetCommand.destroy(); + await client.close(); +} + +async function testErrorMessagesResourcesWithIgnoreExistingResources() { + info("Test ignoreExistingResources option for ERROR_MESSAGE"); + const tab = await addTab(TEST_URI); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info( + "Check whether onAvailable will not be called with existing error messages" + ); + await triggerErrors(tab); + + const availableResources = []; + await resourceCommand.watchResources([resourceCommand.TYPES.ERROR_MESSAGE], { + onAvailable: resources => availableResources.push(...resources), + ignoreExistingResources: true, + }); + is( + availableResources.length, + 0, + "onAvailable wasn't called for existing error messages" + ); + + info( + "Check whether onAvailable will be called with the future error messages" + ); + await triggerErrors(tab); + + const expectedMessages = Array.from(expectedPageErrors.values()); + await waitUntil(() => availableResources.length === expectedMessages.length); + for (let i = 0; i < expectedMessages.length; i++) { + const resource = availableResources[i]; + const { pageError } = resource; + const expected = expectedMessages[i]; + checkPageErrorResource(pageError, expected); + is( + resource.isAlreadyExistingResource, + false, + "isAlreadyExistingResource is set to false for live messages" + ); + } + + Services.console.reset(); + targetCommand.destroy(); + await client.close(); +} + +/** + * Triggers all the errors in the content page. + */ +async function triggerErrors(tab) { + for (const [expression, expected] of expectedPageErrors.entries()) { + if ( + !expected[noUncaughtException] && + !Services.appinfo.browserTabsRemoteAutostart + ) { + expectUncaughtException(); + } + + await ContentTask.spawn( + tab.linkedBrowser, + expression, + function frameScript(expr) { + const document = content.document; + const scriptEl = document.createElement("script"); + scriptEl.textContent = expr; + document.body.appendChild(scriptEl); + } + ); + + if (expected.isPromiseRejection) { + // Wait a bit after an uncaught promise rejection error, as they are not emitted + // right away. + + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(res => setTimeout(res, 10)); + } + } +} + +function checkPageErrorResource(pageErrorResource, expected) { + // Let's remove test harness related frames in stacktrace + const clonedPageErrorResource = { ...pageErrorResource }; + if (clonedPageErrorResource.stacktrace) { + const index = clonedPageErrorResource.stacktrace.findIndex(frame => + frame.filename.startsWith("resource://testing-common/content-task.js") + ); + if (index > -1) { + clonedPageErrorResource.stacktrace = + clonedPageErrorResource.stacktrace.slice(0, index); + } + } + checkObject(clonedPageErrorResource, expected); +} + +const noUncaughtException = Symbol(); +const NUMBER_REGEX = /^\d+$/; +// timeStamp are the result of a number in microsecond divided by 1000. +// so we can't expect a precise number of decimals, or even if there would +// be decimals at all. +const FRACTIONAL_NUMBER_REGEX = /^\d+(\.\d{1,3})?$/; + +const mdnUrl = path => + `https://developer.mozilla.org/${path}?utm_source=mozilla&utm_medium=firefox-console-errors&utm_campaign=default`; + +const expectedPageErrors = new Map([ + [ + "document.doTheImpossible();", + { + errorMessage: /doTheImpossible/, + errorMessageName: "JSMSG_NOT_FUNCTION", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: FRACTIONAL_NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + exceptionDocURL: mdnUrl( + "docs/Web/JavaScript/Reference/Errors/Not_a_function" + ), + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: /test_page_errors\.html/, + lineNumber: 1, + columnNumber: 10, + functionName: null, + }, + ], + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], + [ + "(42).toString(0);", + { + errorMessage: /radix/, + errorMessageName: "JSMSG_BAD_RADIX", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: FRACTIONAL_NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + exceptionDocURL: mdnUrl("docs/Web/JavaScript/Reference/Errors/Bad_radix"), + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: /test_page_errors\.html/, + lineNumber: 1, + columnNumber: 6, + functionName: null, + }, + ], + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], + [ + "'use strict'; (Object.freeze({name: 'Elsa', score: 157})).score = 0;", + { + errorMessage: /read.only/, + errorMessageName: "JSMSG_READ_ONLY", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: FRACTIONAL_NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + exceptionDocURL: mdnUrl("docs/Web/JavaScript/Reference/Errors/Read-only"), + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: /test_page_errors\.html/, + lineNumber: 1, + columnNumber: 23, + functionName: null, + }, + ], + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], + [ + "([]).length = -1", + { + errorMessage: /array length/, + errorMessageName: "JSMSG_BAD_ARRAY_LENGTH", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: FRACTIONAL_NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + exceptionDocURL: mdnUrl( + "docs/Web/JavaScript/Reference/Errors/Invalid_array_length" + ), + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: /test_page_errors\.html/, + lineNumber: 1, + columnNumber: 2, + functionName: null, + }, + ], + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], + [ + "'abc'.repeat(-1);", + { + errorMessage: /repeat count.*non-negative/, + errorMessageName: "JSMSG_NEGATIVE_REPETITION_COUNT", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: FRACTIONAL_NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + exceptionDocURL: mdnUrl( + "docs/Web/JavaScript/Reference/Errors/Negative_repetition_count" + ), + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: "self-hosted", + sourceId: null, + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + functionName: "repeat", + }, + { + filename: /test_page_errors\.html/, + lineNumber: 1, + columnNumber: 7, + functionName: null, + }, + ], + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], + [ + "'a'.repeat(2e28);", + { + errorMessage: /repeat count.*less than infinity/, + errorMessageName: "JSMSG_RESULTING_STRING_TOO_LARGE", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: FRACTIONAL_NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + exceptionDocURL: mdnUrl( + "docs/Web/JavaScript/Reference/Errors/Resulting_string_too_large" + ), + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: "self-hosted", + sourceId: null, + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + functionName: "repeat", + }, + { + filename: /test_page_errors\.html/, + lineNumber: 1, + columnNumber: 5, + functionName: null, + }, + ], + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], + [ + "77.1234.toExponential(-1);", + { + errorMessage: /out of range/, + errorMessageName: "JSMSG_PRECISION_RANGE", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: FRACTIONAL_NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + exceptionDocURL: mdnUrl( + "docs/Web/JavaScript/Reference/Errors/Precision_range" + ), + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: /test_page_errors\.html/, + lineNumber: 1, + columnNumber: 9, + functionName: null, + }, + ], + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], + [ + "function a() { return; 1 + 1; }", + { + errorMessage: /unreachable code/, + errorMessageName: "JSMSG_STMT_AFTER_RETURN", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: FRACTIONAL_NUMBER_REGEX, + error: false, + warning: true, + info: false, + sourceId: null, + lineText: "function a() { return; 1 + 1; }", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + exceptionDocURL: mdnUrl( + "docs/Web/JavaScript/Reference/Errors/Stmt_after_return" + ), + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: null, + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], + [ + "{let a, a;}", + { + errorMessage: /redeclaration of/, + errorMessageName: "JSMSG_REDECLARED_VAR", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: FRACTIONAL_NUMBER_REGEX, + error: true, + warning: false, + info: false, + sourceId: null, + lineText: "{let a, a;}", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + exceptionDocURL: mdnUrl( + "docs/Web/JavaScript/Reference/Errors/Redeclared_parameter" + ), + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [], + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + notes: [ + { + messageBody: /Previously declared at line/, + frame: { + source: /test_page_errors/, + }, + }, + ], + }, + ], + [ + `var error = new TypeError("abc"); + error.name = "MyError"; + error.message = "here"; + throw error`, + { + errorMessage: /MyError: here/, + errorMessageName: "", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: FRACTIONAL_NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + exceptionDocURL: undefined, + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: /test_page_errors\.html/, + lineNumber: 1, + columnNumber: 13, + functionName: null, + }, + ], + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], + [ + "DOMTokenList.prototype.contains.call([])", + { + errorMessage: /does not implement interface/, + errorMessageName: "MSG_METHOD_THIS_DOES_NOT_IMPLEMENT_INTERFACE", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: FRACTIONAL_NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + exceptionDocURL: undefined, + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: /test_page_errors\.html/, + lineNumber: 1, + columnNumber: 33, + functionName: null, + }, + ], + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], + [ + ` + function promiseThrow() { + var error2 = new TypeError("abc"); + error2.name = "MyPromiseError"; + error2.message = "here2"; + return Promise.reject(error2); + } + promiseThrow()`, + { + errorMessage: /MyPromiseError: here2/, + errorMessageName: "", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: FRACTIONAL_NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + exceptionDocURL: undefined, + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: /test_page_errors\.html/, + sourceId: null, + lineNumber: 6, + columnNumber: 24, + functionName: "promiseThrow", + }, + { + filename: /test_page_errors\.html/, + sourceId: null, + lineNumber: 8, + columnNumber: 7, + functionName: null, + }, + ], + notes: null, + chromeContext: false, + isPromiseRejection: true, + isForwardedFromContentProcess: false, + [noUncaughtException]: true, + }, + ], + [ + // Error with a cause + `var originalError = new TypeError("abc"); + var error = new Error("something went wrong", { cause: originalError }) + throw error`, + { + errorMessage: /Error: something went wrong/, + errorMessageName: "", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: FRACTIONAL_NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: /test_page_errors\.html/, + lineNumber: 2, + columnNumber: 19, + functionName: null, + }, + ], + exception: { + preview: { + cause: { + class: "TypeError", + preview: { + message: "abc", + }, + }, + }, + }, + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], + [ + // Error with a cause chain + `var a = new Error("err-a"); + var b = new Error("err-b", { cause: a }); + var c = new Error("err-c", { cause: b }); + var d = new Error("err-d", { cause: c }); + throw d`, + { + errorMessage: /Error: err-d/, + errorMessageName: "", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: FRACTIONAL_NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: /test_page_errors\.html/, + lineNumber: 4, + columnNumber: 14, + functionName: null, + }, + ], + exception: { + preview: { + cause: { + class: "Error", + preview: { + message: "err-c", + cause: { + class: "Error", + preview: { + message: "err-b", + cause: { + class: "Error", + preview: { + message: "err-a", + }, + }, + }, + }, + }, + }, + }, + }, + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], + [ + // Error with a null cause + `throw new Error("something went wrong", { cause: null })`, + { + errorMessage: /Error: something went wrong/, + errorMessageName: "", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: FRACTIONAL_NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: /test_page_errors\.html/, + lineNumber: 1, + columnNumber: 7, + functionName: null, + }, + ], + exception: { + preview: { + cause: { + type: "null", + }, + }, + }, + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], + [ + // Error with an undefined cause + `throw new Error("something went wrong", { cause: undefined })`, + { + errorMessage: /Error: something went wrong/, + errorMessageName: "", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: FRACTIONAL_NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: /test_page_errors\.html/, + lineNumber: 1, + columnNumber: 7, + functionName: null, + }, + ], + exception: { + preview: { + cause: { + type: "undefined", + }, + }, + }, + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], + [ + // Error with a number cause + `throw new Error("something went wrong", { cause: 0 })`, + { + errorMessage: /Error: something went wrong/, + errorMessageName: "", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: FRACTIONAL_NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: /test_page_errors\.html/, + lineNumber: 1, + columnNumber: 7, + functionName: null, + }, + ], + exception: { + preview: { + cause: 0, + }, + }, + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], + [ + // Error with a string cause + `throw new Error("something went wrong", { cause: "ooops" })`, + { + errorMessage: /Error: something went wrong/, + errorMessageName: "", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: FRACTIONAL_NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: /test_page_errors\.html/, + lineNumber: 1, + columnNumber: 7, + functionName: null, + }, + ], + exception: { + preview: { + cause: "ooops", + }, + }, + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], +]); diff --git a/devtools/shared/commands/resource/tests/browser_resources_getAllResources.js b/devtools/shared/commands/resource/tests/browser_resources_getAllResources.js new file mode 100644 index 0000000000..10bc8390d9 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_getAllResources.js @@ -0,0 +1,128 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test getAllResources function of the ResourceCommand. + +const TEST_URI = "data:text/html;charset=utf-8,getAllResources test"; + +add_task(async function () { + const tab = await addTab(TEST_URI); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Check the resources gotten from getAllResources at initial"); + is( + resourceCommand.getAllResources(resourceCommand.TYPES.CONSOLE_MESSAGE) + .length, + 0, + "There is no resources at initial" + ); + + info( + "Start to watch the available resources in order to compare with resources gotten from getAllResources" + ); + const availableResources = []; + const onAvailable = resources => availableResources.push(...resources); + await resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { onAvailable } + ); + + info("Check the resources after some resources are available"); + const messages = ["a", "b", "c"]; + await logMessages(tab.linkedBrowser, messages); + + try { + await waitFor(() => availableResources.length === messages.length); + } catch (e) { + ok( + false, + `Didn't receive the expected number of resources. Got ${ + availableResources.length + }, expected ${messages.length} - ${availableResources + .map(r => r.message.arguments[0]) + .join(" - ")}` + ); + } + + assertResources( + resourceCommand.getAllResources(resourceCommand.TYPES.CONSOLE_MESSAGE), + availableResources + ); + assertResources( + resourceCommand.getAllResources(resourceCommand.TYPES.STYLESHEET), + [] + ); + + info("Check the resources after reloading"); + await BrowserTestUtils.reloadTab(tab); + assertResources( + resourceCommand.getAllResources(resourceCommand.TYPES.CONSOLE_MESSAGE), + [] + ); + + info("Append some resources again to test unwatching"); + const newMessages = ["d", "e", "f"]; + await logMessages(tab.linkedBrowser, messages); + try { + await waitFor( + () => + resourceCommand.getAllResources(resourceCommand.TYPES.CONSOLE_MESSAGE) + .length === newMessages.length + ); + } catch (e) { + const resources = resourceCommand.getAllResources( + resourceCommand.TYPES.CONSOLE_MESSAGE + ); + ok( + false, + `Didn't receive the expected number of resources. Got ${ + resources.length + }, expected ${messages.length} - ${resources + .map(r => r.message.arguments.join(" | ")) + .join(" - ")}` + ); + } + + info("Check the resources after unwatching"); + resourceCommand.unwatchResources([resourceCommand.TYPES.CONSOLE_MESSAGE], { + onAvailable, + }); + assertResources( + resourceCommand.getAllResources(resourceCommand.TYPES.CONSOLE_MESSAGE), + [] + ); + + targetCommand.destroy(); + await client.close(); +}); + +function assertResources(resources, expectedResources) { + is( + resources.length, + expectedResources.length, + "Number of the resources is correct" + ); + + for (let i = 0; i < resources.length; i++) { + const resource = resources[i]; + const expectedResource = expectedResources[i]; + Assert.strictEqual( + resource, + expectedResource, + `The ${i}th resource is correct` + ); + } +} + +function logMessages(browser, messages) { + return SpecialPowers.spawn(browser, [messages], innerMessages => { + for (const message of innerMessages) { + content.console.log(message); + } + }); +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_invalid_api_usage.js b/devtools/shared/commands/resource/tests/browser_resources_invalid_api_usage.js new file mode 100644 index 0000000000..8a1d809f04 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_invalid_api_usage.js @@ -0,0 +1,76 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test watch/unwatchResources throw when provided with invalid types. + +const TEST_URI = "data:text/html;charset=utf-8,invalid api usage test"; + +add_task(async function () { + const tab = await addTab(TEST_URI); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + const onAvailable = function () {}; + + await Assert.rejects( + resourceCommand.watchResources([null], { onAvailable }), + /ResourceCommand\.watchResources invoked with an unknown type/, + "watchResources should throw for null type" + ); + + await Assert.rejects( + resourceCommand.watchResources([undefined], { onAvailable }), + /ResourceCommand\.watchResources invoked with an unknown type/, + "watchResources should throw for undefined type" + ); + + await Assert.rejects( + resourceCommand.watchResources(["NOT_A_RESOURCE"], { onAvailable }), + /ResourceCommand\.watchResources invoked with an unknown type/, + "watchResources should throw for unknown type" + ); + + await Assert.rejects( + resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE, "NOT_A_RESOURCE"], + { onAvailable } + ), + /ResourceCommand\.watchResources invoked with an unknown type/, + "watchResources should throw for unknown type mixed with a correct type" + ); + + await Assert.throws( + () => resourceCommand.unwatchResources([null], { onAvailable }), + /ResourceCommand\.unwatchResources invoked with an unknown type/, + "unwatchResources should throw for null type" + ); + + await Assert.throws( + () => resourceCommand.unwatchResources([undefined], { onAvailable }), + /ResourceCommand\.unwatchResources invoked with an unknown type/, + "unwatchResources should throw for undefined type" + ); + + await Assert.throws( + () => resourceCommand.unwatchResources(["NOT_A_RESOURCE"], { onAvailable }), + /ResourceCommand\.unwatchResources invoked with an unknown type/, + "unwatchResources should throw for unknown type" + ); + + await Assert.throws( + () => + resourceCommand.unwatchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE, "NOT_A_RESOURCE"], + { onAvailable } + ), + /ResourceCommand\.unwatchResources invoked with an unknown type/, + "unwatchResources should throw for unknown type mixed with a correct type" + ); + + targetCommand.destroy(); + await client.close(); +}); diff --git a/devtools/shared/commands/resource/tests/browser_resources_last_private_context_exit.js b/devtools/shared/commands/resource/tests/browser_resources_last_private_context_exit.js new file mode 100644 index 0000000000..1e2d894be3 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_last_private_context_exit.js @@ -0,0 +1,94 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Verify that LAST_PRIVATE_CONTEXT_EXIT fires when closing the last opened private window + +"use strict"; + +const NON_PRIVATE_TEST_URI = + "data:text/html;charset=utf8,<!DOCTYPE html>Not private"; +const PRIVATE_TEST_URI = `data:text/html;charset=utf8,<!DOCTYPE html>Test in private windows`; + +add_task(async function () { + await pushPref("devtools.browsertoolbox.scope", "everything"); + const { commands } = await initMultiProcessResourceCommand(); + const { resourceCommand } = commands; + + const availableResources = []; + await resourceCommand.watchResources( + [resourceCommand.TYPES.LAST_PRIVATE_CONTEXT_EXIT], + { + onAvailable(resources) { + availableResources.push(resources); + }, + } + ); + is( + availableResources.length, + 0, + "We do not get any LAST_PRIVATE_CONTEXT_EXIT after initialization" + ); + + await addTab(NON_PRIVATE_TEST_URI); + + info("Open a new private window and select the new tab opened in it"); + const privateWindow = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + ok(PrivateBrowsingUtils.isWindowPrivate(privateWindow), "window is private"); + const privateBrowser = privateWindow.gBrowser; + privateBrowser.selectedTab = BrowserTestUtils.addTab( + privateBrowser, + PRIVATE_TEST_URI + ); + + info("private tab opened"); + ok( + PrivateBrowsingUtils.isBrowserPrivate(privateBrowser.selectedBrowser), + "tab window is private" + ); + + info("Open a second tab in the private window"); + await addTab(PRIVATE_TEST_URI, { window: privateWindow }); + + // Let a chance to an unexpected async event to be fired + await wait(1000); + + is( + availableResources.length, + 0, + "We do not get any LAST_PRIVATE_CONTEXT_EXIT when opening a private window" + ); + + info("Open a second private browsing window"); + const secondPrivateWindow = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + + info("Close the second private window"); + secondPrivateWindow.BrowserTryToCloseWindow(); + + // Let a chance to an unexpected async event to be fired + await wait(1000); + + is( + availableResources.length, + 0, + "We do not get any LAST_PRIVATE_CONTEXT_EXIT when closing the second private window only" + ); + + info( + "close the private window and check if LAST_PRIVATE_CONTEXT_EXIT resource is sent" + ); + privateWindow.BrowserTryToCloseWindow(); + + info("Wait for LAST_PRIVATE_CONTEXT_EXIT"); + await waitFor(() => availableResources.length == 1); + is( + availableResources.length, + 1, + "We get one LAST_PRIVATE_CONTEXT_EXIT when closing the last opened private window" + ); + + await commands.destroy(); +}); diff --git a/devtools/shared/commands/resource/tests/browser_resources_network_event_stacktraces.js b/devtools/shared/commands/resource/tests/browser_resources_network_event_stacktraces.js new file mode 100644 index 0000000000..2200fcad9c --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_network_event_stacktraces.js @@ -0,0 +1,100 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around NETWORK_EVENT_STACKTRACE + +const TEST_URI = `${URL_ROOT_SSL}network_document.html`; + +const REQUEST_STUB = { + code: `await fetch("/request_post_0.html", { method: "POST" });`, + expected: { + stacktraceAvailable: true, + lastFrame: { + filename: + "https://example.com/browser/devtools/shared/commands/resource/tests/network_document.html", + lineNumber: 1, + columnNumber: 40, + functionName: "triggerRequest", + asyncCause: null, + }, + }, +}; + +add_task(async function () { + info("Test network stacktraces events"); + const tab = await addTab(TEST_URI); + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + const networkEvents = new Map(); + const stackTraces = new Map(); + + function onResourceAvailable(resources) { + for (const resource of resources) { + if ( + resource.resourceType === resourceCommand.TYPES.NETWORK_EVENT_STACKTRACE + ) { + ok( + !networkEvents.has(resource.resourceId), + "The network event does not exist" + ); + + is( + resource.stacktraceAvailable, + REQUEST_STUB.expected.stacktraceAvailable, + "The stacktrace is available" + ); + is( + JSON.stringify(resource.lastFrame), + JSON.stringify(REQUEST_STUB.expected.lastFrame), + "The last frame of the stacktrace is available" + ); + + stackTraces.set(resource.resourceId, true); + return; + } + + if (resource.resourceType === resourceCommand.TYPES.NETWORK_EVENT) { + ok( + stackTraces.has(resource.stacktraceResourceId), + "The stack trace does exists" + ); + + networkEvents.set(resource.resourceId, true); + } + } + } + + function onResourceUpdated() {} + + await resourceCommand.watchResources( + [ + resourceCommand.TYPES.NETWORK_EVENT_STACKTRACE, + resourceCommand.TYPES.NETWORK_EVENT, + ], + { + onAvailable: onResourceAvailable, + onUpdated: onResourceUpdated, + } + ); + + await triggerNetworkRequests(tab.linkedBrowser, [REQUEST_STUB.code]); + + resourceCommand.unwatchResources( + [ + resourceCommand.TYPES.NETWORK_EVENT_STACKTRACE, + resourceCommand.TYPES.NETWORK_EVENT, + ], + { + onAvailable: onResourceAvailable, + onUpdated: onResourceUpdated, + } + ); + + targetCommand.destroy(); + await client.close(); + BrowserTestUtils.removeTab(tab); +}); diff --git a/devtools/shared/commands/resource/tests/browser_resources_network_events.js b/devtools/shared/commands/resource/tests/browser_resources_network_events.js new file mode 100644 index 0000000000..da355fd023 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_network_events.js @@ -0,0 +1,318 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around NETWORK_EVENT + +const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js"); + +// We are borrowing tests from the netmonitor frontend +const NETMONITOR_TEST_FOLDER = + "https://example.com/browser/devtools/client/netmonitor/test/"; +const CSP_URL = `${NETMONITOR_TEST_FOLDER}html_csp-test-page.html`; +const JS_CSP_URL = `${NETMONITOR_TEST_FOLDER}js_websocket-worker-test.js`; +const CSS_CSP_URL = `${NETMONITOR_TEST_FOLDER}internal-loaded.css`; + +const CSP_BLOCKED_REASON_CODE = 4000; + +add_task(async function testContentProcessRequests() { + info(`Tests for NETWORK_EVENT resources fired from the content process`); + + const expectedAvailable = [ + { + url: CSP_URL, + method: "GET", + isNavigationRequest: true, + chromeContext: false, + }, + { + url: JS_CSP_URL, + method: "GET", + blockedReason: CSP_BLOCKED_REASON_CODE, + isNavigationRequest: false, + chromeContext: false, + }, + { + url: CSS_CSP_URL, + method: "GET", + blockedReason: CSP_BLOCKED_REASON_CODE, + isNavigationRequest: false, + chromeContext: false, + }, + ]; + const expectedUpdated = [ + { + url: CSP_URL, + method: "GET", + isNavigationRequest: true, + chromeContext: false, + }, + { + url: JS_CSP_URL, + method: "GET", + blockedReason: CSP_BLOCKED_REASON_CODE, + isNavigationRequest: false, + chromeContext: false, + }, + { + url: CSS_CSP_URL, + method: "GET", + blockedReason: CSP_BLOCKED_REASON_CODE, + isNavigationRequest: false, + chromeContext: false, + }, + ]; + + await assertNetworkResourcesOnPage( + CSP_URL, + expectedAvailable, + expectedUpdated + ); +}); + +add_task(async function testCanceledRequest() { + info(`Tests for NETWORK_EVENT resources with a canceled request`); + + // Do a XHR request that we cancel against a slow loading page + const requestUrl = + "https://example.org/document-builder.sjs?delay=1000&html=foo"; + const html = + "<!DOCTYPE html><script>(" + + function (xhrUrl) { + const xhr = new XMLHttpRequest(); + xhr.open("GET", xhrUrl); + xhr.send(null); + } + + ")(" + + JSON.stringify(requestUrl) + + ")</script>"; + const pageUrl = + "https://example.org/document-builder.sjs?html=" + encodeURIComponent(html); + + const expectedAvailable = [ + { + url: pageUrl, + method: "GET", + isNavigationRequest: true, + chromeContext: false, + }, + { + url: requestUrl, + method: "GET", + isNavigationRequest: false, + blockedReason: "NS_BINDING_ABORTED", + chromeContext: false, + }, + ]; + const expectedUpdated = [ + { + url: pageUrl, + method: "GET", + isNavigationRequest: true, + chromeContext: false, + }, + { + url: requestUrl, + method: "GET", + isNavigationRequest: false, + blockedReason: "NS_BINDING_ABORTED", + chromeContext: false, + }, + ]; + + // Register a one-off listener to cancel the XHR request + // Using XMLHttpRequest's abort() method from the content process + // isn't reliable and would introduce many race condition in the test. + // Canceling the request via nsIRequest.cancel privileged method, + // from the parent process is much more reliable. + const observer = { + QueryInterface: ChromeUtils.generateQI(["nsIObserver"]), + observe(subject, topic, data) { + subject = subject.QueryInterface(Ci.nsIHttpChannel); + if (subject.URI.spec == requestUrl) { + subject.cancel(Cr.NS_BINDING_ABORTED); + Services.obs.removeObserver(observer, "http-on-modify-request"); + } + }, + }; + Services.obs.addObserver(observer, "http-on-modify-request"); + + await assertNetworkResourcesOnPage( + pageUrl, + expectedAvailable, + expectedUpdated + ); +}); + +add_task(async function testIframeRequest() { + info(`Tests for NETWORK_EVENT resources with an iframe`); + + // Do a XHR request that we cancel against a slow loading page + const iframeRequestUrl = + "https://example.org/document-builder.sjs?html=iframe-request"; + const iframeHtml = `iframe<script>fetch("${iframeRequestUrl}")</script>`; + const iframeUrl = + "https://example.org/document-builder.sjs?html=" + + encodeURIComponent(iframeHtml); + const html = `top-document<iframe src="${iframeUrl}"></iframe>`; + const pageUrl = + "https://example.org/document-builder.sjs?html=" + encodeURIComponent(html); + + const expectedAvailable = [ + { + url: pageUrl, + method: "GET", + chromeContext: false, + isNavigationRequest: true, + // The top level navigation request relates to the previous top level target. + // Unfortunately, it is hard to test because it is racy. + // The target front might be destroyed and `targetFront.url` will be null. + // Or not just yet and be equal to "about:blank". + }, + { + url: iframeUrl, + method: "GET", + isNavigationRequest: false, + targetFrontUrl: pageUrl, + chromeContext: false, + }, + { + url: iframeRequestUrl, + method: "GET", + isNavigationRequest: false, + targetFrontUrl: iframeUrl, + chromeContext: false, + }, + ]; + const expectedUpdated = [ + { + url: pageUrl, + method: "GET", + isNavigationRequest: true, + chromeContext: false, + }, + { + url: iframeUrl, + method: "GET", + isNavigationRequest: false, + chromeContext: false, + }, + { + url: iframeRequestUrl, + method: "GET", + isNavigationRequest: false, + chromeContext: false, + }, + ]; + + await assertNetworkResourcesOnPage( + pageUrl, + expectedAvailable, + expectedUpdated + ); +}); + +async function assertNetworkResourcesOnPage( + url, + expectedAvailable, + expectedUpdated +) { + // First open a blank document to avoid spawning any request + const tab = await addTab("about:blank"); + + const commands = await CommandsFactory.forTab(tab); + await commands.targetCommand.startListening(); + const { resourceCommand } = commands; + + const onAvailable = resources => { + for (const resource of resources) { + // Immediately assert the resource, as the same resource object + // will be notified to onUpdated and so if we assert it later + // we will not highlight attributes that aren't set yet from onAvailable. + const idx = expectedAvailable.findIndex(e => e.url === resource.url); + Assert.notEqual( + idx, + -1, + "Found a matching available notification for: " + resource.url + ); + // Remove the match from the list in case there is many requests with the same url + const [expected] = expectedAvailable.splice(idx, 1); + + assertResources(resource, expected); + } + }; + const onUpdated = updates => { + for (const { resource } of updates) { + const idx = expectedUpdated.findIndex(e => e.url === resource.url); + Assert.notEqual( + idx, + -1, + "Found a matching updated notification for: " + resource.url + ); + // Remove the match from the list in case there is many requests with the same url + const [expected] = expectedUpdated.splice(idx, 1); + + assertResources(resource, expected); + } + }; + + // Start observing for network events before loading the test page + await resourceCommand.watchResources([resourceCommand.TYPES.NETWORK_EVENT], { + onAvailable, + onUpdated, + }); + + // Load the test page that fires network requests + const onLoaded = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + BrowserTestUtils.startLoadingURIString(gBrowser.selectedBrowser, url); + await onLoaded; + + // Make sure we processed all the expected request updates + await waitFor( + () => !expectedAvailable.length, + "Wait for all expected available notifications" + ); + await waitFor( + () => !expectedUpdated.length, + "Wait for all expected updated notifications" + ); + + resourceCommand.unwatchResources([resourceCommand.TYPES.NETWORK_EVENT], { + onAvailable, + onUpdated, + }); + + await commands.destroy(); + + BrowserTestUtils.removeTab(tab); +} + +function assertResources(actual, expected) { + is( + actual.resourceType, + ResourceCommand.TYPES.NETWORK_EVENT, + "The resource type is correct" + ); + is( + typeof actual.innerWindowId, + "number", + "All requests have an innerWindowId attribute" + ); + ok( + actual.targetFront.isTargetFront, + "All requests have a targetFront attribute" + ); + + for (const name in expected) { + if (name == "targetFrontUrl") { + is( + actual.targetFront.url, + expected[name], + "The request matches the right target front" + ); + } else { + is(actual[name], expected[name], `The '${name}' attribute is correct`); + } + } +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_network_events_cache.js b/devtools/shared/commands/resource/tests/browser_resources_network_events_cache.js new file mode 100644 index 0000000000..6708ef19e1 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_network_events_cache.js @@ -0,0 +1,236 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API internal cache / ignoreExistingResources around NETWORK_EVENT + +const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js"); + +const EXAMPLE_DOMAIN = "https://example.com/"; +const TEST_URI = `${URL_ROOT_SSL}network_document.html`; + +add_task(async function () { + info("Test basic NETWORK_EVENT resources against ResourceCommand cache"); + await testNetworkEventResourcesWithExistingResources(); + await testNetworkEventResourcesWithoutExistingResources(); +}); + +async function testNetworkEventResourcesWithExistingResources() { + info(`Tests for network event resources with the existing resources`); + await testNetworkEventResourcesWithCachedRequest({ + ignoreExistingResources: false, + // 1 available event fired, for the existing resource in the cache. + // 1 available event fired, when live request is created. + expectedResourcesOnAvailable: { + [`${EXAMPLE_DOMAIN}cached_post.html`]: { + resourceType: ResourceCommand.TYPES.NETWORK_EVENT, + method: "POST", + isNavigationRequest: false, + }, + [`${EXAMPLE_DOMAIN}live_get.html`]: { + resourceType: ResourceCommand.TYPES.NETWORK_EVENT, + method: "GET", + isNavigationRequest: false, + }, + }, + // 1 update events fired, when live request is updated. + expectedResourcesOnUpdated: { + [`${EXAMPLE_DOMAIN}live_get.html`]: { + resourceType: ResourceCommand.TYPES.NETWORK_EVENT, + method: "GET", + }, + }, + }); +} + +async function testNetworkEventResourcesWithoutExistingResources() { + info(`Tests for network event resources without the existing resources`); + await testNetworkEventResourcesWithCachedRequest({ + ignoreExistingResources: true, + // 1 available event fired, when live request is created. + expectedResourcesOnAvailable: { + [`${EXAMPLE_DOMAIN}live_get.html`]: { + resourceType: ResourceCommand.TYPES.NETWORK_EVENT, + method: "GET", + isNavigationRequest: false, + }, + }, + // 1 update events fired, when live request is updated. + expectedResourcesOnUpdated: { + [`${EXAMPLE_DOMAIN}live_get.html`]: { + resourceType: ResourceCommand.TYPES.NETWORK_EVENT, + method: "GET", + }, + }, + }); +} + +/** + * This test helper is slightly complex as we workaround the fact + * that the server is not able to record network request done in the past. + * Because of that we have to start observer requests via ResourceCommand.watchResources + * before doing a request, and, before doing the actual call to watchResources + * we want to assert the behavior of. + */ +async function testNetworkEventResourcesWithCachedRequest(options) { + const tab = await addTab(TEST_URI); + const commands = await CommandsFactory.forTab(tab); + await commands.targetCommand.startListening(); + + const { resourceCommand } = commands; + + info( + `Trigger some network requests *before* calling ResourceCommand.watchResources + in order to assert the behavior of already existing network events.` + ); + + // Register a first empty listener in order to ensure populating ResourceCommand + // internal cache of NETWORK_EVENT's. We can't retrieved past network requests + // when calling server's `watchResources`. + let resolveCachedRequestAvailable; + const onCachedRequestAvailable = new Promise( + r => (resolveCachedRequestAvailable = r) + ); + const onAvailableToPopulateInternalCache = () => {}; + const onUpdatedToPopulateInternalCache = resolveCachedRequestAvailable; + await resourceCommand.watchResources([resourceCommand.TYPES.NETWORK_EVENT], { + ignoreExistingResources: true, + onAvailable: onAvailableToPopulateInternalCache, + onUpdated: onUpdatedToPopulateInternalCache, + }); + + // We can only trigger the requests once `watchResources` settles, + // otherwise we might miss some events and they won't be present in the cache + const cachedRequest = `await fetch("/cached_post.html", { method: "POST" });`; + await triggerNetworkRequests(tab.linkedBrowser, [cachedRequest]); + + // We have to ensure that ResourceCommand processed the Resource for this first + // cached request before calling watchResource a second time and report it. + // Wait for the updated notification to avoid receiving it during the next call + // to watchResources. + await onCachedRequestAvailable; + + const actualResourcesOnAvailable = {}; + const actualResourcesOnUpdated = {}; + + const { + expectedResourcesOnAvailable, + expectedResourcesOnUpdated, + + ignoreExistingResources, + } = options; + + const onAvailable = resources => { + for (const resource of resources) { + is( + resource.resourceType, + resourceCommand.TYPES.NETWORK_EVENT, + "Received a network event resource" + ); + actualResourcesOnAvailable[resource.url] = resource; + } + }; + + const onUpdated = updates => { + for (const { resource } of updates) { + is( + resource.resourceType, + resourceCommand.TYPES.NETWORK_EVENT, + "Received a network update event resource" + ); + actualResourcesOnUpdated[resource.url] = resource; + } + }; + + await resourceCommand.watchResources([resourceCommand.TYPES.NETWORK_EVENT], { + onAvailable, + onUpdated, + ignoreExistingResources, + }); + + info( + `Trigger the rest of the requests *after* calling ResourceCommand.watchResources + in order to assert the behavior of live network events.` + ); + const liveRequest = `await fetch("/live_get.html", { method: "GET" });`; + await triggerNetworkRequests(tab.linkedBrowser, [liveRequest]); + + info("Check the resources on available"); + + await waitUntil( + () => + Object.keys(actualResourcesOnAvailable).length == + Object.keys(expectedResourcesOnAvailable).length + ); + + is( + Object.keys(actualResourcesOnAvailable).length, + Object.keys(expectedResourcesOnAvailable).length, + "Got the expected number of network events fired onAvailable" + ); + + // assert the resources emitted when the network event is created + for (const key in expectedResourcesOnAvailable) { + const expected = expectedResourcesOnAvailable[key]; + const actual = actualResourcesOnAvailable[key]; + assertResources(actual, expected); + } + + info("Check the resources on updated"); + + await waitUntil( + () => + Object.keys(actualResourcesOnUpdated).length == + Object.keys(expectedResourcesOnUpdated).length + ); + + is( + Object.keys(actualResourcesOnUpdated).length, + Object.keys(expectedResourcesOnUpdated).length, + "Got the expected number of network events fired onUpdated" + ); + + // assert the resources emitted when the network event is updated + for (const key in expectedResourcesOnUpdated) { + const expected = expectedResourcesOnUpdated[key]; + const actual = actualResourcesOnUpdated[key]; + assertResources(actual, expected); + // assert that the resourceId for the the available and updated events match + is( + actual.resourceId, + actualResourcesOnAvailable[key].resourceId, + `Available and update resource ids for ${key} are the same` + ); + } + + resourceCommand.unwatchResources([resourceCommand.TYPES.NETWORK_EVENT], { + onAvailable, + onUpdated, + ignoreExistingResources, + }); + + resourceCommand.unwatchResources([resourceCommand.TYPES.NETWORK_EVENT], { + onAvailable: onAvailableToPopulateInternalCache, + }); + + await commands.destroy(); + + BrowserTestUtils.removeTab(tab); +} + +function assertResources(actual, expected) { + is( + actual.resourceType, + expected.resourceType, + "The resource type is correct" + ); + is(actual.method, expected.method, "The method is correct"); + if ("isNavigationRequest" in expected) { + is( + actual.isNavigationRequest, + expected.isNavigationRequest, + "The isNavigationRequest attribute is correct" + ); + } +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_network_events_navigation.js b/devtools/shared/commands/resource/tests/browser_resources_network_events_navigation.js new file mode 100644 index 0000000000..44028318a2 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_network_events_navigation.js @@ -0,0 +1,137 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around NETWORK_EVENT when navigating + +const TEST_URI = `${URL_ROOT_SSL}network_document_navigation.html`; +const JS_URI = TEST_URI.replace( + "network_document_navigation.html", + "network_navigation.js" +); + +add_task(async () => { + const tab = await addTab(TEST_URI); + const commands = await CommandsFactory.forTab(tab); + await commands.targetCommand.startListening(); + const { resourceCommand } = commands; + + const receivedResources = []; + const onAvailable = resources => { + for (const resource of resources) { + is( + resource.resourceType, + resourceCommand.TYPES.NETWORK_EVENT, + "Received a network event resource" + ); + receivedResources.push(resource); + } + }; + const onUpdated = updates => { + for (const { resource } of updates) { + is( + resource.resourceType, + resourceCommand.TYPES.NETWORK_EVENT, + "Received a network update event resource" + ); + } + }; + + await resourceCommand.watchResources([resourceCommand.TYPES.NETWORK_EVENT], { + ignoreExistingResources: true, + onAvailable, + onUpdated, + }); + + await reloadBrowser(); + + await waitFor(() => receivedResources.length == 2); + + const navigationRequest = receivedResources[0]; + is( + navigationRequest.url, + TEST_URI, + "The first resource is for the navigation request" + ); + + const jsRequest = receivedResources[1]; + is(jsRequest.url, JS_URI, "The second resource is for the javascript file"); + + async function getResponseContent(networkEvent) { + const packet = { + to: networkEvent.actor, + type: "getResponseContent", + }; + const response = await commands.client.request(packet); + return response.content.text; + } + + const HTML_CONTENT = await (await fetch(TEST_URI)).text(); + const JS_CONTENT = await (await fetch(JS_URI)).text(); + + const htmlContent = await getResponseContent(navigationRequest); + is(htmlContent, HTML_CONTENT); + const jsContent = await getResponseContent(jsRequest); + is(jsContent, JS_CONTENT); + + await reloadBrowser(); + + await waitFor(() => receivedResources.length == 4); + + try { + await getResponseContent(navigationRequest); + ok(false, "Shouldn't work"); + } catch (e) { + is( + e.error, + "noSuchActor", + "Without persist, we can't fetch previous document network data" + ); + } + + try { + await getResponseContent(jsRequest); + ok(false, "Shouldn't work"); + } catch (e) { + is( + e.error, + "noSuchActor", + "Without persist, we can't fetch previous document network data" + ); + } + + const navigationRequest2 = receivedResources[2]; + const jsRequest2 = receivedResources[3]; + info("But we can fetch data for the last/new document"); + const htmlContent2 = await getResponseContent(navigationRequest2); + is(htmlContent2, HTML_CONTENT); + const jsContent2 = await getResponseContent(jsRequest2); + is(jsContent2, JS_CONTENT); + + info("Enable persist"); + const networkParentFront = + await commands.watcherFront.getNetworkParentActor(); + await networkParentFront.setPersist(true); + + await reloadBrowser(); + + await waitFor(() => receivedResources.length == 6); + + info("With persist, we can fetch previous document network data"); + const htmlContent3 = await getResponseContent(navigationRequest2); + is(htmlContent3, HTML_CONTENT); + const jsContent3 = await getResponseContent(jsRequest2); + is(jsContent3, JS_CONTENT); + + await resourceCommand.unwatchResources( + [resourceCommand.TYPES.NETWORK_EVENT], + { + onAvailable, + onUpdated, + } + ); + + await commands.destroy(); + BrowserTestUtils.removeTab(tab); +}); diff --git a/devtools/shared/commands/resource/tests/browser_resources_network_events_parent_process.js b/devtools/shared/commands/resource/tests/browser_resources_network_events_parent_process.js new file mode 100644 index 0000000000..c5b3e436db --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_network_events_parent_process.js @@ -0,0 +1,249 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * !! AFTER MOVING OR RENAMING THIS METHOD, UPDATE `EXPECTED` CONSTANTS BELOW !! + */ +const createParentProcessRequests = async () => { + info("Do some requests from the parent process"); + // The line:column for `fetch` should be EXPECTED_REQUEST_LINE_1/COL_1 + await fetch(FETCH_URI); + + const img = new Image(); + const onLoad = new Promise(r => img.addEventListener("load", r)); + // The line:column for `img` below should be EXPECTED_REQUEST_LINE_2/COL_2 + img.src = IMAGE_URI; + await onLoad; +}; + +const EXPECTED_METHOD_NAME = "createParentProcessRequests"; +const EXPECTED_REQUEST_LINE_1 = 12; +const EXPECTED_REQUEST_COL_1 = 9; +const EXPECTED_REQUEST_LINE_2 = 17; +const EXPECTED_REQUEST_COL_2 = 3; + +// Test the ResourceCommand API around NETWORK_EVENT for the parent process + +const FETCH_URI = "https://example.com/document-builder.sjs?html=foo"; +// The img.src request gets cached regardless of `devtools.cache.disabled`. +// Add a random parameter to the request to bypass the cache. +const uuid = `${Date.now()}-${Math.random()}`; +const IMAGE_URI = URL_ROOT_SSL + "test_image.png?" + uuid; + +add_task(async function testParentProcessRequests() { + // The test expects the main process commands instance to receive resources + // for content process requests. + await pushPref("devtools.browsertoolbox.scope", "everything"); + + const commands = await CommandsFactory.forMainProcess(); + await commands.targetCommand.startListening(); + const { resourceCommand } = commands; + + const receivedNetworkEvents = []; + const receivedStacktraces = []; + const onAvailable = resources => { + for (const resource of resources) { + if (resource.resourceType == resourceCommand.TYPES.NETWORK_EVENT) { + receivedNetworkEvents.push(resource); + } else if ( + resource.resourceType == resourceCommand.TYPES.NETWORK_EVENT_STACKTRACE + ) { + receivedStacktraces.push(resource); + } + } + }; + const onUpdated = updates => { + for (const { resource } of updates) { + is( + resource.resourceType, + resourceCommand.TYPES.NETWORK_EVENT, + "Received a network update event resource" + ); + } + }; + + await resourceCommand.watchResources( + [ + resourceCommand.TYPES.NETWORK_EVENT, + resourceCommand.TYPES.NETWORK_EVENT_STACKTRACE, + ], + { + ignoreExistingResources: true, + onAvailable, + onUpdated, + } + ); + + await createParentProcessRequests(); + + const img2 = new Image(); + img2.src = IMAGE_URI; + + info("Wait for the network events"); + await waitFor(() => receivedNetworkEvents.length == 3); + info("Wait for the network events stack traces"); + // Note that we aren't getting any stacktrace for the second cached request + await waitFor(() => receivedStacktraces.length == 2); + + info("Assert the fetch request"); + const fetchRequest = receivedNetworkEvents[0]; + is( + fetchRequest.url, + FETCH_URI, + "The first resource is for the fetch request" + ); + ok(fetchRequest.chromeContext, "The fetch request is privileged"); + + const fetchStacktrace = receivedStacktraces[0].lastFrame; + is(receivedStacktraces[0].resourceId, fetchRequest.stacktraceResourceId); + is(fetchStacktrace.filename, gTestPath); + is(fetchStacktrace.lineNumber, EXPECTED_REQUEST_LINE_1); + is(fetchStacktrace.columnNumber, EXPECTED_REQUEST_COL_1); + is(fetchStacktrace.functionName, EXPECTED_METHOD_NAME); + is(fetchStacktrace.asyncCause, null); + + async function getResponseContent(networkEvent) { + const packet = { + to: networkEvent.actor, + type: "getResponseContent", + }; + const response = await commands.client.request(packet); + return response.content.text; + } + + const fetchContent = await getResponseContent(fetchRequest); + is(fetchContent, "foo"); + + info("Assert the first image request"); + const firstImageRequest = receivedNetworkEvents[1]; + is( + firstImageRequest.url, + IMAGE_URI, + "The second resource is for the first image request" + ); + ok(!firstImageRequest.fromCache, "The first image request isn't cached"); + ok(firstImageRequest.chromeContext, "The first image request is privileged"); + + const firstImageStacktrace = receivedStacktraces[1].lastFrame; + is(receivedStacktraces[1].resourceId, firstImageRequest.stacktraceResourceId); + is(firstImageStacktrace.filename, gTestPath); + is(firstImageStacktrace.lineNumber, EXPECTED_REQUEST_LINE_2); + is(firstImageStacktrace.columnNumber, EXPECTED_REQUEST_COL_2); + is(firstImageStacktrace.functionName, EXPECTED_METHOD_NAME); + is(firstImageStacktrace.asyncCause, null); + + info("Assert the second image request"); + const secondImageRequest = receivedNetworkEvents[2]; + is( + secondImageRequest.url, + IMAGE_URI, + "The third resource is for the second image request" + ); + ok(secondImageRequest.fromCache, "The second image request is cached"); + ok( + secondImageRequest.chromeContext, + "The second image request is privileged" + ); + + info( + "Open a content page to ensure we also receive request from content processes" + ); + const pageUrl = "https://example.org/document-builder.sjs?html=foo"; + const requestUrl = "https://example.org/document-builder.sjs?html=bar"; + const tab = await addTab(pageUrl); + + await waitFor(() => receivedNetworkEvents.length == 4); + const tabRequest = receivedNetworkEvents[3]; + is(tabRequest.url, pageUrl, "The 4th resource is for the tab request"); + ok(!tabRequest.chromeContext, "The 4th request is content"); + + info( + "Also spawn a privileged request from the content process, not bound to any WindowGlobal" + ); + await SpecialPowers.spawn( + tab.linkedBrowser, + [requestUrl], + async function (uri) { + const { NetUtil } = ChromeUtils.importESModule( + "resource://gre/modules/NetUtil.sys.mjs" + ); + const channel = NetUtil.newChannel({ + uri, + loadUsingSystemPrincipal: true, + }); + channel.open(); + } + ); + await removeTab(tab); + + await waitFor(() => receivedNetworkEvents.length == 5); + const privilegedContentRequest = receivedNetworkEvents[4]; + is( + privilegedContentRequest.url, + requestUrl, + "The 5th resource is for the privileged content process request" + ); + ok(privilegedContentRequest.chromeContext, "The 5th request is privileged"); + + info("Now focus only on parent process resources"); + await pushPref("devtools.browsertoolbox.scope", "parent-process"); + + info( + "Retrigger the two last requests. The tab document request and a privileged request. Both happening in the tab's content process." + ); + const secondTab = await addTab(pageUrl); + await SpecialPowers.spawn( + secondTab.linkedBrowser, + [requestUrl], + async function (uri) { + const { NetUtil } = ChromeUtils.importESModule( + "resource://gre/modules/NetUtil.sys.mjs" + ); + const channel = NetUtil.newChannel({ + uri, + loadUsingSystemPrincipal: true, + }); + channel.open(); + } + ); + + await waitFor(() => receivedNetworkEvents.length == 6); + + // nsIHttpChannel doesn't expose any attribute allowing to identify + // privileged requests done in content processes. + // Thus, preventing us from filtering them out correctly. + // Ideally, we would need some new attribute to know from which (content) process + // any channel originates from. + info( + "For now, we are still notified about the privileged content process request" + ); + const secondPrivilegedContentRequest = receivedNetworkEvents[5]; + is( + secondPrivilegedContentRequest.url, + requestUrl, + "The 6th resource is for the second privileged content process request" + ); + ok(privilegedContentRequest.chromeContext, "The 6th request is privileged"); + + // Let some time to receive the tab request if that's not correctly filtered out + await wait(1000); + is( + receivedNetworkEvents.length, + 6, + "But we don't receive the request for the tab request" + ); + + await removeTab(secondTab); + + await resourceCommand.unwatchResources( + [resourceCommand.TYPES.NETWORK_EVENT], + { + onAvailable, + onUpdated, + } + ); + + await commands.destroy(); +}); diff --git a/devtools/shared/commands/resource/tests/browser_resources_platform_messages.js b/devtools/shared/commands/resource/tests/browser_resources_platform_messages.js new file mode 100644 index 0000000000..4e74a97e38 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_platform_messages.js @@ -0,0 +1,158 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around PLATFORM_MESSAGE +// Reproduces assertions from: devtools/shared/webconsole/test/chrome/test_nsiconsolemessage.html + +add_task(async function () { + // Disable the preloaded process as it creates processes intermittently + // which forces the emission of RDP requests we aren't correctly waiting for. + await pushPref("dom.ipc.processPrelaunch.enabled", false); + + await testPlatformMessagesResources(); + await testPlatformMessagesResourcesWithIgnoreExistingResources(); +}); + +async function testPlatformMessagesResources() { + const { client, resourceCommand, targetCommand } = + await initMultiProcessResourceCommand(); + + const cachedMessages = [ + "This is a cached message", + "This is another cached message", + ]; + const liveMessages = [ + "This is a live message", + "This is another live message", + ]; + const expectedMessages = [...cachedMessages, ...liveMessages]; + const receivedMessages = []; + + info( + "Log some messages *before* calling ResourceCommand.watchResources in order to assert the behavior of already existing messages." + ); + Services.console.logStringMessage(expectedMessages[0]); + Services.console.logStringMessage(expectedMessages[1]); + + let done; + const onAllMessagesReceived = new Promise(resolve => (done = resolve)); + const onAvailable = resources => { + for (const resource of resources) { + if (!expectedMessages.includes(resource.message)) { + continue; + } + + is( + resource.targetFront, + targetCommand.targetFront, + "The targetFront property is the expected one" + ); + + receivedMessages.push(resource.message); + is( + resource.message, + expectedMessages[receivedMessages.length - 1], + `Received the expected ยซ${resource.message}ยป message, in the expected order` + ); + + // timeStamp are the result of a number in microsecond divided by 1000. + // so we can't expect a precise number of decimals, or even if there would + // be decimals at all. + ok( + resource.timeStamp.toString().match(/^\d+(\.\d{1,3})?$/), + `The resource has a timeStamp property ${resource.timeStamp}` + ); + + const isCachedMessage = receivedMessages.length <= cachedMessages.length; + is( + resource.isAlreadyExistingResource, + isCachedMessage, + "isAlreadyExistingResource has the expected value" + ); + + if (receivedMessages.length == expectedMessages.length) { + done(); + } + } + }; + + await resourceCommand.watchResources( + [resourceCommand.TYPES.PLATFORM_MESSAGE], + { + onAvailable, + } + ); + + info( + "Now log messages *after* the call to ResourceCommand.watchResources and after having received all existing messages" + ); + Services.console.logStringMessage(expectedMessages[2]); + Services.console.logStringMessage(expectedMessages[3]); + + info("Waiting for all expected messages to be received"); + await onAllMessagesReceived; + ok(true, "All the expected messages were received"); + + Services.console.reset(); + targetCommand.destroy(); + await client.close(); +} + +async function testPlatformMessagesResourcesWithIgnoreExistingResources() { + const { client, resourceCommand, targetCommand } = + await initMultiProcessResourceCommand(); + + info( + "Check whether onAvailable will not be called with existing platform messages" + ); + const expectedMessages = ["This is 1st message", "This is 2nd message"]; + Services.console.logStringMessage(expectedMessages[0]); + Services.console.logStringMessage(expectedMessages[1]); + + const availableResources = []; + await resourceCommand.watchResources( + [resourceCommand.TYPES.PLATFORM_MESSAGE], + { + onAvailable: resources => { + for (const resource of resources) { + if (!expectedMessages.includes(resource.message)) { + continue; + } + + availableResources.push(resource); + } + }, + ignoreExistingResources: true, + } + ); + is( + availableResources.length, + 0, + "onAvailable wasn't called for existing platform messages" + ); + + info( + "Check whether onAvailable will be called with the future platform messages" + ); + Services.console.logStringMessage(expectedMessages[0]); + Services.console.logStringMessage(expectedMessages[1]); + + await waitUntil(() => availableResources.length === expectedMessages.length); + for (let i = 0; i < expectedMessages.length; i++) { + const resource = availableResources[i]; + const { message } = resource; + const expected = expectedMessages[i]; + is(message, expected, `Message[${i}] is correct`); + is( + resource.isAlreadyExistingResource, + false, + "isAlreadyExistingResource is false since we ignore existing resources" + ); + } + + Services.console.reset(); + targetCommand.destroy(); + await client.close(); +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_reflows.js b/devtools/shared/commands/resource/tests/browser_resources_reflows.js new file mode 100644 index 0000000000..70242c826a --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_reflows.js @@ -0,0 +1,115 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API for reflows + +const { + TYPES, +} = require("resource://devtools/shared/commands/resource/resource-command.js"); + +add_task(async function () { + const tab = await addTab( + "https://example.com/document-builder.sjs?html=<h1>Test reflow resources</h1>" + ); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + const resources = []; + const onAvailable = _resources => { + resources.push(..._resources); + }; + await resourceCommand.watchResources([TYPES.REFLOW], { + onAvailable, + }); + + is(resources.length, 0, "No reflow resource were sent initially"); + + await SpecialPowers.spawn(tab.linkedBrowser, [], () => { + const el = content.document.createElement("div"); + el.textContent = "1"; + content.document.body.appendChild(el); + }); + + await waitFor(() => resources.length === 1); + checkReflowResource(resources[0]); + + await SpecialPowers.spawn(tab.linkedBrowser, [], () => { + const el = content.document.querySelector("div"); + el.style.display = "inline-grid"; + }); + + await waitFor(() => resources.length === 2); + ok( + true, + "A reflow resource is sent when the display property of an element is modified" + ); + checkReflowResource(resources.at(-1)); + + info("Check that adding an iframe does emit a reflow"); + const iframeBC = await SpecialPowers.spawn( + tab.linkedBrowser, + [], + async () => { + const el = content.document.createElement("iframe"); + const onIframeLoaded = new Promise(resolve => + el.addEventListener("load", resolve, { once: true }) + ); + content.document.body.appendChild(el); + el.src = + "https://example.org/document-builder.sjs?html=<h2>remote iframe</h2>"; + await onIframeLoaded; + return el.browsingContext; + } + ); + + await waitFor(() => resources.length === 3); + ok(true, "A reflow resource was received when adding a remote iframe"); + checkReflowResource(resources.at(-1)); + + info("Check that we receive reflow resources for the remote iframe"); + await SpecialPowers.spawn(iframeBC, [], () => { + const el = content.document.createElement("section"); + el.textContent = "remote org iframe"; + el.style.display = "grid"; + content.document.body.appendChild(el); + }); + + await waitFor(() => resources.length === 4); + if (isFissionEnabled()) { + ok( + resources.at(-1).targetFront.url.includes("example.org"), + "The reflow resource is linked to the remote target" + ); + } + checkReflowResource(resources.at(-1)); + + targetCommand.destroy(); + await client.close(); +}); + +function checkReflowResource(resource) { + is( + resource.resourceType, + TYPES.REFLOW, + "The resource has the expected resourceType" + ); + + ok(Array.isArray(resource.reflows), "the `reflows` property is an array"); + for (const reflow of resource.reflows) { + is( + Number.isFinite(reflow.start), + true, + "reflow start property is a number" + ); + is(Number.isFinite(reflow.end), true, "reflow end property is a number"); + Assert.greaterOrEqual( + reflow.end, + reflow.start, + "end is greater than start" + ); + } +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_root_node.js b/devtools/shared/commands/resource/tests/browser_resources_root_node.js new file mode 100644 index 0000000000..67ef5efd90 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_root_node.js @@ -0,0 +1,129 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around ROOT_NODE + +/** + * The original test still asserts some scenarios using several watchRootNode + * call sites, which is not something we intend to support at the moment in the + * resource command. + * + * Otherwise this test checks the basic behavior of the resource when reloading + * an empty page. + */ +add_task(async function () { + // Open a test tab + const tab = await addTab("data:text/html,Root Node tests"); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + const browser = gBrowser.selectedBrowser; + + info("Call watchResources([ROOT_NODE], ...)"); + let onAvailableCounter = 0; + const onAvailable = resources => (onAvailableCounter += resources.length); + await resourceCommand.watchResources([resourceCommand.TYPES.ROOT_NODE], { + onAvailable, + }); + + info("Wait until onAvailable has been called"); + await waitUntil(() => onAvailableCounter === 1); + is(onAvailableCounter, 1, "onAvailable has been called 1 time"); + + info("Reload the selected browser"); + browser.reload(); + + info( + "Wait until the watchResources([ROOT_NODE], ...) callback has been called" + ); + await waitUntil(() => onAvailableCounter === 2); + + is(onAvailableCounter, 2, "onAvailable has been called 2 times"); + + info("Call unwatchResources([ROOT_NODE], ...) for the onAvailable callback"); + resourceCommand.unwatchResources([resourceCommand.TYPES.ROOT_NODE], { + onAvailable, + }); + + info("Reload the selected browser"); + const reloaded = BrowserTestUtils.browserLoaded(browser); + browser.reload(); + await reloaded; + + is( + onAvailableCounter, + 2, + "onAvailable was not called after calling unwatchResources" + ); + + // Cleanup + targetCommand.destroy(); + await client.close(); +}); + +/** + * Test that the watchRootNode API provides the expected node fronts. + */ +add_task(async function testRootNodeFrontIsCorrect() { + const tab = await addTab("data:text/html,<div id=div1>"); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + const browser = gBrowser.selectedBrowser; + + info("Call watchResources([ROOT_NODE], ...)"); + + let rootNodeResolve; + let rootNodePromise = new Promise(r => (rootNodeResolve = r)); + const onAvailable = ([rootNodeFront]) => rootNodeResolve(rootNodeFront); + await resourceCommand.watchResources([resourceCommand.TYPES.ROOT_NODE], { + onAvailable, + }); + + info("Wait until onAvailable has been called"); + const root1 = await rootNodePromise; + ok(!!root1, "onAvailable has been called with a valid argument"); + is( + root1.resourceType, + resourceCommand.TYPES.ROOT_NODE, + "The resource has the expected type" + ); + + info("Check we can query an expected node under the retrieved root"); + const div1 = await root1.walkerFront.querySelector(root1, "div"); + is(div1.getAttribute("id"), "div1", "Correct root node retrieved"); + + info("Reload the selected browser"); + rootNodePromise = new Promise(r => (rootNodeResolve = r)); + browser.reload(); + + const root2 = await rootNodePromise; + Assert.notStrictEqual( + root1, + root2, + "onAvailable has been called with a different node front after reload" + ); + + info("Navigate to another URL"); + rootNodePromise = new Promise(r => (rootNodeResolve = r)); + BrowserTestUtils.startLoadingURIString( + browser, + `data:text/html,<div id=div3>` + ); + const root3 = await rootNodePromise; + info("Check we can query an expected node under the retrieved root"); + const div3 = await root3.walkerFront.querySelector(root3, "div"); + is(div3.getAttribute("id"), "div3", "Correct root node retrieved"); + + // Cleanup + resourceCommand.unwatchResources([resourceCommand.TYPES.ROOT_NODE], { + onAvailable, + }); + targetCommand.destroy(); + await client.close(); +}); diff --git a/devtools/shared/commands/resource/tests/browser_resources_scope_flag.js b/devtools/shared/commands/resource/tests/browser_resources_scope_flag.js new file mode 100644 index 0000000000..8537daf161 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_scope_flag.js @@ -0,0 +1,128 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the ResourceCommand clears its pending events for resources emitted from +// target destroyed when devtools.browsertoolbox.scope is updated. + +const TEST_URL = + "data:text/html;charset=utf-8," + encodeURIComponent(`<div id="test"></div>`); + +add_task(async function () { + // Do not run this test when both fission and EFT is disabled as it changes + // the number of targets + if (!isFissionEnabled() && !isEveryFrameTargetEnabled()) { + ok(true, "Don't go further is both Fission and EFT are disabled"); + return; + } + + // Disable the preloaded process as it gets created lazily and may interfere + // with process count assertions + await pushPref("dom.ipc.processPrelaunch.enabled", false); + // This preference helps destroying the content process when we close the tab + await pushPref("dom.ipc.keepProcessesAlive.web", 1); + + // Start with multiprocess debugging enabled + await pushPref("devtools.browsertoolbox.scope", "everything"); + + const commands = await CommandsFactory.forMainProcess(); + const targetCommand = commands.targetCommand; + await targetCommand.startListening(); + const { TYPES } = targetCommand; + + const targets = new Set(); + const onAvailable = async ({ targetFront }) => { + targets.add(targetFront); + }; + const onDestroyed = () => {}; + await targetCommand.watchTargets({ + types: [TYPES.PROCESS, TYPES.FRAME], + onAvailable, + onDestroyed, + }); + + info("Open a tab in a new content process"); + const firstTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: TEST_URL, + forceNewProcess: true, + }); + + const newTabInnerWindowId = + firstTab.linkedBrowser.browsingContext.currentWindowGlobal.innerWindowId; + + info("Wait for the tab window global target"); + const windowGlobalTarget = await waitFor(() => + [...targets].find( + target => + target.targetType == TYPES.FRAME && + target.innerWindowId == newTabInnerWindowId + ) + ); + + let gotTabResource = false; + const onResourceAvailable = resources => { + for (const resource of resources) { + if (resource.targetFront == windowGlobalTarget) { + gotTabResource = true; + + if (resource.targetFront.isDestroyed()) { + ok( + false, + "we shouldn't get resources for the target that was destroyed when switching mode" + ); + } + } + } + }; + + info("Start listening for resources"); + await commands.resourceCommand.watchResources( + [commands.resourceCommand.TYPES.CONSOLE_MESSAGE], + { + onAvailable: onResourceAvailable, + ignoreExistingResources: true, + } + ); + + // Emit logs every ms to fill up the resourceCommand resource queue (pendingEvents) + const intervalId = await SpecialPowers.spawn( + firstTab.linkedBrowser, + [], + () => { + let counter = 0; + return content.wrappedJSObject.setInterval(() => { + counter++; + content.wrappedJSObject.console.log("STREAM_" + counter); + }, 1); + } + ); + + info("Wait until we get the first resource"); + await waitFor(() => gotTabResource); + + info("Disable multiprocess debugging"); + await pushPref("devtools.browsertoolbox.scope", "parent-process"); + + info("Wait for the tab target to be destroyed"); + await waitFor(() => windowGlobalTarget.isDestroyed()); + + info("Wait for a bit so any throttled action would have the time to occur"); + await wait(1000); + + // Stop listening for resources + await commands.resourceCommand.unwatchResources( + [commands.resourceCommand.TYPES.CONSOLE_MESSAGE], + { + onAvailable: onResourceAvailable, + } + ); + // And stop the interval + await SpecialPowers.spawn(firstTab.linkedBrowser, [intervalId], id => { + content.wrappedJSObject.clearInterval(id); + }); + + targetCommand.destroy(); + await commands.destroy(); +}); diff --git a/devtools/shared/commands/resource/tests/browser_resources_server_sent_events.js b/devtools/shared/commands/resource/tests/browser_resources_server_sent_events.js new file mode 100644 index 0000000000..dab6c8d8cc --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_server_sent_events.js @@ -0,0 +1,107 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around SERVER SENT EVENTS. + +const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js"); + +const targets = { + TOP_LEVEL_DOCUMENT: "top-level-document", + IN_PROCESS_IFRAME: "in-process-frame", + OUT_PROCESS_IFRAME: "out-process-frame", +}; + +add_task(async function () { + info("Testing the top-level document"); + await testServerSentEventResources(targets.TOP_LEVEL_DOCUMENT); + info("Testing the in-process iframe"); + await testServerSentEventResources(targets.IN_PROCESS_IFRAME); + info("Testing the out-of-process iframe"); + await testServerSentEventResources(targets.OUT_PROCESS_IFRAME); +}); + +async function testServerSentEventResources(target) { + const tab = await addTab(URL_ROOT_SSL + "sse_frontend.html"); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + const availableResources = []; + + function onResourceAvailable(resources) { + availableResources.push(...resources); + } + + await resourceCommand.watchResources( + [resourceCommand.TYPES.SERVER_SENT_EVENT], + { onAvailable: onResourceAvailable } + ); + + openConnectionInContext(tab, target); + + info("Check available resources"); + // We expect only 2 resources + await waitUntil(() => availableResources.length === 2); + + info("Check resource details"); + // To make sure the channel id are the same + const httpChannelId = availableResources[0].httpChannelId; + + ok(httpChannelId, "The channel id is set"); + is(typeof httpChannelId, "number", "The channel id is a number"); + + assertResource(availableResources[0], { + messageType: "eventReceived", + httpChannelId, + data: { + payload: "Why so serious?", + eventName: "message", + lastEventId: "", + retry: 5000, + }, + }); + + assertResource(availableResources[1], { + messageType: "eventSourceConnectionClosed", + httpChannelId, + }); + + await resourceCommand.unwatchResources( + [resourceCommand.TYPES.SERVER_SENT_EVENT], + { onAvailable: onResourceAvailable } + ); + + await targetCommand.destroy(); + await client.close(); + BrowserTestUtils.removeTab(tab); +} + +function assertResource(resource, expected) { + is( + resource.resourceType, + ResourceCommand.TYPES.SERVER_SENT_EVENT, + "Resource type is correct" + ); + + checkObject(resource, expected); +} + +async function openConnectionInContext(tab, target) { + let browsingContext = tab.linkedBrowser.browsingContext; + if (target !== targets.TOP_LEVEL_DOCUMENT) { + browsingContext = await SpecialPowers.spawn( + tab.linkedBrowser, + [target], + async _target => { + const iframe = content.document.getElementById(_target); + return iframe.browsingContext; + } + ); + } + await SpecialPowers.spawn(browsingContext, [], async () => { + await content.wrappedJSObject.openConnection(); + }); +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_several_resources.js b/devtools/shared/commands/resource/tests/browser_resources_several_resources.js new file mode 100644 index 0000000000..c1a151e562 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_several_resources.js @@ -0,0 +1,111 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Check that the resource command is still properly watching for new targets + * after unwatching one resource, if there is still another watched resource. + */ +add_task(async function () { + // We will create a main process target list here in order to monitor + // resources from new tabs as they get created. + await pushPref("devtools.browsertoolbox.scope", "everything"); + + // Open a test tab + const tab = await addTab("data:text/html,Root Node tests"); + + const { client, resourceCommand, targetCommand } = + await initMultiProcessResourceCommand(); + + const { CONSOLE_MESSAGE, ROOT_NODE } = resourceCommand.TYPES; + + // We are only interested in console messages as a resource, the ROOT_NODE one + // is here to test the ResourceCommand::unwatchResources API with several resources. + const receivedMessages = []; + const onAvailable = resources => { + for (const resource of resources) { + if (resource.resourceType === CONSOLE_MESSAGE) { + receivedMessages.push(resource); + } + } + }; + + info("Call watchResources([CONSOLE_MESSAGE, ROOT_NODE], ...)"); + await resourceCommand.watchResources([CONSOLE_MESSAGE, ROOT_NODE], { + onAvailable, + }); + + info("Use console.log in the content page"); + logInTab(tab, "test from data-url"); + info( + "Wait until onAvailable received the CONSOLE_MESSAGE resource emitted from the data-url tab" + ); + await waitUntil(() => + receivedMessages.find( + resource => resource.message.arguments[0] === "test from data-url" + ) + ); + + // Check that the resource command captures resources from new targets. + info("Open a first tab on the example.com domain"); + const comTab = await addTab( + "https://example.com/document-builder.sjs?html=com" + ); + info("Use console.log in the example.com page"); + logInTab(comTab, "test-from-example-com"); + info( + "Wait until onAvailable received the CONSOLE_MESSAGE resource emitted from the example.com tab" + ); + await waitUntil(() => + receivedMessages.find( + resource => resource.message.arguments[0] === "test-from-example-com" + ) + ); + + info("Stop watching ROOT_NODE resources"); + await resourceCommand.unwatchResources([ROOT_NODE], { onAvailable }); + + // Check that messages from new targets are still captured after calling + // unwatch for another resource. + info("Open a second tab on the example.org domain"); + const orgTab = await addTab( + "https://example.org/document-builder.sjs?html=org" + ); + info("Use console.log in the example.org page"); + logInTab(orgTab, "test-from-example-org"); + info( + "Wait until onAvailable received the CONSOLE_MESSAGE resource emitted from the example.org tab" + ); + await waitUntil(() => + receivedMessages.find( + resource => resource.message.arguments[0] === "test-from-example-org" + ) + ); + + info("Stop watching CONSOLE_MESSAGE resources"); + await resourceCommand.unwatchResources([CONSOLE_MESSAGE], { onAvailable }); + await logInTab(tab, "test-again"); + + // We don't have a specific event to wait for here, so allow some time for + // the message to be received. + await wait(1000); + + is( + receivedMessages.find( + resource => resource.message.arguments[0] === "test-again" + ), + undefined, + "The resource command should not watch CONSOLE_MESSAGE anymore" + ); + + // Cleanup + targetCommand.destroy(); + await client.close(); +}); + +function logInTab(tab, message) { + return ContentTask.spawn(tab.linkedBrowser, message, function (_message) { + content.console.log(_message); + }); +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_sources.js b/devtools/shared/commands/resource/tests/browser_resources_sources.js new file mode 100644 index 0000000000..767f45283a --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_sources.js @@ -0,0 +1,456 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around SOURCE. +// +// We cover each Spidermonkey Debugger Source's `introductionType`: +// https://searchfox.org/mozilla-central/rev/4c184ca81b28f1ccffbfd08f465709b95bcb4aa1/js/src/doc/Debugger/Debugger.Source.md#172-213 +// +// And especially cover sources being GC-ed before DevTools are opened +// which are later recreated by `ThreadActor.resurrectSource`. + +const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js"); + +const TEST_URL = URL_ROOT_SSL + "sources.html"; + +const TEST_JS_URL = URL_ROOT_SSL + "sources.js"; +const TEST_WORKER_URL = URL_ROOT_SSL + "worker-sources.js"; +const TEST_SW_URL = URL_ROOT_SSL + "service-worker-sources.js"; + +async function getExpectedResources(ignoreUnresurrectedSources = false) { + const htmlRequest = await fetch(TEST_URL); + const htmlContent = await htmlRequest.text(); + + // First list sources that aren't GC-ed, or that the thread actor is able to resurrect + const expectedSources = [ + { + description: "eval", + sourceForm: { + introductionType: "eval", + sourceMapBaseURL: TEST_URL, + url: null, + isBlackBoxed: false, + sourceMapURL: null, + extensionName: null, + isInlineSource: false, + }, + sourceContent: { + contentType: "text/javascript", + source: "this.global = function evalFunction() {}", + }, + }, + { + description: "new Function()", + sourceForm: { + introductionType: "Function", + sourceMapBaseURL: TEST_URL, + url: null, + isBlackBoxed: false, + sourceMapURL: null, + extensionName: null, + isInlineSource: false, + }, + sourceContent: { + contentType: "text/javascript", + source: "function anonymous(\n) {\nreturn 42;\n}", + }, + }, + { + description: "Event Handler", + sourceForm: { + introductionType: "eventHandler", + sourceMapBaseURL: TEST_URL, + url: null, + isBlackBoxed: false, + sourceMapURL: null, + extensionName: null, + isInlineSource: false, + }, + sourceContent: { + contentType: "text/javascript", + source: "console.log('link')", + }, + }, + { + description: "inline JS inserted at runtime", + sourceForm: { + introductionType: "scriptElement", // This is an injectedScript at SpiderMonkey level, but is translated into scriptElement by SourceActor.form() + sourceMapBaseURL: TEST_URL, + url: null, + isBlackBoxed: false, + sourceMapURL: null, + extensionName: null, + isInlineSource: false, + }, + sourceContent: { + contentType: "text/javascript", + source: "console.log('inline-script')", + }, + }, + { + description: "inline JS", + sourceForm: { + introductionType: "scriptElement", // This is an inlineScript at SpiderMonkey level, but is translated into scriptElement by SourceActor.form() + sourceMapBaseURL: TEST_URL, + url: TEST_URL, + isBlackBoxed: false, + sourceMapURL: null, + extensionName: null, + isInlineSource: true, + }, + sourceContent: { + contentType: "text/html", + source: htmlContent, + }, + }, + { + description: "worker script", + sourceForm: { + introductionType: undefined, + sourceMapBaseURL: TEST_WORKER_URL, + url: TEST_WORKER_URL, + isBlackBoxed: false, + sourceMapURL: null, + extensionName: null, + isInlineSource: false, + }, + sourceContent: { + contentType: "text/javascript", + source: "/* eslint-disable */\nfunction workerSource() {}\n", + }, + }, + { + description: "service worker script", + sourceForm: { + introductionType: undefined, + sourceMapBaseURL: TEST_SW_URL, + url: TEST_SW_URL, + isBlackBoxed: false, + sourceMapURL: null, + extensionName: null, + isInlineSource: false, + }, + sourceContent: { + contentType: "text/javascript", + source: "/* eslint-disable */\nfunction serviceWorkerSource() {}\n", + }, + }, + { + description: "independent js file", + sourceForm: { + introductionType: "scriptElement", // This is an srcScript at SpiderMonkey level, but is translated into scriptElement by SourceActor.form() + sourceMapBaseURL: TEST_JS_URL, + url: TEST_JS_URL, + isBlackBoxed: false, + sourceMapURL: null, + extensionName: null, + isInlineSource: false, + }, + sourceContent: { + contentType: "text/javascript", + source: "/* eslint-disable */\nfunction scriptSource() {}\n", + }, + }, + ]; + + // Now list the sources that could be GC-ed for which the thread actor isn't able to resurrect. + // This is the sources that we can't assert when we fetch sources after the page is already loaded. + const unresurrectedSources = [ + { + description: "DOM Timer", + sourceForm: { + introductionType: "domTimer", + sourceMapBaseURL: TEST_URL, + url: null, + isBlackBoxed: false, + sourceMapURL: null, + extensionName: null, + isInlineSource: false, + }, + sourceContent: { + contentType: "text/javascript", + /* the domTimer is prefixed by many empty lines in order to be positioned at the same line + as in the HTML file where setTimeout is called. + This is probably done by SourceActor.actualText(). + So the array size here, should be updated to match the line number of setTimeout call */ + source: new Array(39).join("\n") + `console.log("timeout")`, + }, + }, + { + description: "javascript URL", + sourceForm: { + introductionType: "javascriptURL", + sourceMapBaseURL: isEveryFrameTargetEnabled() + ? "about:blank" + : TEST_URL, + url: null, + isBlackBoxed: false, + sourceMapURL: null, + extensionName: null, + isInlineSource: false, + }, + sourceContent: { + contentType: "text/javascript", + source: "666", + }, + }, + { + description: "srcdoc attribute on iframes #1", + sourceForm: { + introductionType: "scriptElement", + // We do not assert url/sourceMapBaseURL as it includes the Debugger.Source.id + // which is random + isBlackBoxed: false, + sourceMapURL: null, + extensionName: null, + isInlineSource: false, + }, + sourceContent: { + contentType: "text/javascript", + source: "console.log('srcdoc')", + }, + }, + { + description: "srcdoc attribute on iframes #2", + sourceForm: { + introductionType: "scriptElement", + // We do not assert url/sourceMapBaseURL as it includes the Debugger.Source.id + // which is random + isBlackBoxed: false, + sourceMapURL: null, + extensionName: null, + isInlineSource: false, + }, + sourceContent: { + contentType: "text/javascript", + source: "console.log('srcdoc 2')", + }, + }, + ]; + + if (ignoreUnresurrectedSources) { + return expectedSources; + } + return expectedSources.concat(unresurrectedSources); +} + +add_task(async function testSourcesOnload() { + // Load an blank document first, in order to load the test page only once we already + // started watching for sources + const tab = await addTab("about:blank"); + + const commands = await CommandsFactory.forTab(tab); + const { targetCommand, resourceCommand } = commands; + + // Force the target list to cover workers and debug all the targets + targetCommand.listenForWorkers = true; + targetCommand.listenForServiceWorkers = true; + await targetCommand.startListening(); + + info("Check already available resources"); + const availableResources = []; + await resourceCommand.watchResources([resourceCommand.TYPES.SOURCE], { + onAvailable: resources => availableResources.push(...resources), + }); + + const promiseLoad = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + TEST_URL + ); + BrowserTestUtils.startLoadingURIString(tab.linkedBrowser, TEST_URL); + await promiseLoad; + + // Some sources may be created after the document is done loading (like eventHandler usecase) + // so we may be received *after* watchResource resolved + const expectedResources = await getExpectedResources(); + await waitFor( + () => availableResources.length >= expectedResources.length, + "Got all the sources" + ); + + await assertResources(availableResources, expectedResources); + + await commands.destroy(); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async () => { + // registrationPromise is set by the test page. + const registration = await content.wrappedJSObject.registrationPromise; + registration.unregister(); + }); +}); + +add_task(async function testGarbagedCollectedSources() { + info( + "Assert SOURCES on an already loaded page with some sources that have been GC-ed" + ); + const tab = await addTab(TEST_URL); + + info("Force some GC to free some sources"); + await SpecialPowers.spawn(tab.linkedBrowser, [], async () => { + Cu.forceGC(); + Cu.forceCC(); + }); + + const commands = await CommandsFactory.forTab(tab); + const { targetCommand, resourceCommand } = commands; + + // Force the target list to cover workers and debug all the targets + targetCommand.listenForWorkers = true; + targetCommand.listenForServiceWorkers = true; + await targetCommand.startListening(); + + info("Check already available resources"); + const availableResources = []; + await resourceCommand.watchResources([resourceCommand.TYPES.SOURCE], { + onAvailable: resources => availableResources.push(...resources), + }); + + // Some sources may be created after the document is done loading (like eventHandler usecase) + // so we may be received *after* watchResource resolved + const expectedResources = await getExpectedResources(true); + await waitFor( + () => availableResources.length >= expectedResources.length, + "Got all the sources" + ); + + await assertResources(availableResources, expectedResources); + + await commands.destroy(); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async () => { + // registrationPromise is set by the test page. + const registration = await content.wrappedJSObject.registrationPromise; + registration.unregister(); + }); +}); + +/** + * Assert that evaluating sources for a new global, in the parent process + * using the shared system principal will spawn SOURCE resources. + * + * For this we use a special `commands` which replicate what browser console + * and toolbox use. + */ +add_task(async function testParentProcessPrivilegedSources() { + // Use a custom loader + server + client in order to spawn the server + // in a distinct system compartment, so that it can see the system compartment + // sandbox we are about to create in this test + const client = await CommandsFactory.spawnClientToDebugSystemPrincipal(); + + const commands = await CommandsFactory.forMainProcess({ client }); + await commands.targetCommand.startListening(); + const { resourceCommand } = commands; + + info("Check already available resources"); + const availableResources = []; + await resourceCommand.watchResources([resourceCommand.TYPES.SOURCE], { + onAvailable: resources => availableResources.push(...resources), + }); + ok( + !!availableResources.length, + "We get many sources reported from a multiprocess command" + ); + + // Clear the list of sources + availableResources.length = 0; + + // Force the creation of a new privileged source + const systemPrincipal = Cc["@mozilla.org/systemprincipal;1"].createInstance( + Ci.nsIPrincipal + ); + const sandbox = Cu.Sandbox(systemPrincipal); + Cu.evalInSandbox("function foo() {}", sandbox, null, "http://foo.com"); + + info("Wait for the sandbox source"); + await waitFor(() => { + return availableResources.some( + resource => resource.url == "http://foo.com/" + ); + }); + + const expectedResources = [ + { + description: "privileged sandbox script", + sourceForm: { + introductionType: undefined, + sourceMapBaseURL: "http://foo.com/", + url: "http://foo.com/", + isBlackBoxed: false, + sourceMapURL: null, + extensionName: null, + isInlineSource: false, + }, + sourceContent: { + contentType: "text/javascript", + source: "function foo() {}", + }, + }, + ]; + const matchingResource = availableResources.filter(resource => + resource.url.includes("http://foo.com") + ); + await assertResources(matchingResource, expectedResources); + + await commands.destroy(); +}); + +async function assertResources(resources, expected) { + is( + resources.length, + expected.length, + "Length of existing resources is correct at initial" + ); + for (let i = 0; i < resources.length; i++) { + await assertResource(resources[i], expected); + } +} + +async function assertResource(source, expected) { + is( + source.resourceType, + ResourceCommand.TYPES.SOURCE, + "Resource type is correct" + ); + + const threadFront = await source.targetFront.getFront("thread"); + // `source` is SourceActor's form() + // so try to instantiate the related SourceFront: + const sourceFront = threadFront.source(source); + // then fetch source content + const sourceContent = await sourceFront.source(); + + // Order of sources is random, so we have to find the best expected resource. + // The only unique attribute is the JS Source text content. + const matchingExpected = expected.find(res => { + return res.sourceContent.source == sourceContent.source; + }); + ok( + matchingExpected, + `This source was expected with source content being "${sourceContent.source}"` + ); + info(`Found "#${matchingExpected.description}"`); + assertObject( + sourceContent, + matchingExpected.sourceContent, + matchingExpected.description + ); + + assertObject( + source, + matchingExpected.sourceForm, + matchingExpected.description + ); +} + +function assertObject(object, expected, description) { + for (const field in expected) { + is( + object[field], + expected[field], + `The value of ${field} is correct for "#${description}"` + ); + } +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_stylesheets.js b/devtools/shared/commands/resource/tests/browser_resources_stylesheets.js new file mode 100644 index 0000000000..ec81e8118d --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_stylesheets.js @@ -0,0 +1,713 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around STYLESHEET. + +const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js"); + +const STYLE_TEST_URL = URL_ROOT_SSL + "style_document.html"; + +const EXISTING_RESOURCES = [ + { + styleText: "body { color: lime; }", + href: null, + nodeHref: + "https://example.com/browser/devtools/shared/commands/resource/tests/style_document.html", + isNew: false, + disabled: false, + constructed: false, + ruleCount: 1, + atRules: [], + }, + { + styleText: "body { margin: 1px; }", + href: "https://example.com/browser/devtools/shared/commands/resource/tests/style_document.css", + nodeHref: + "https://example.com/browser/devtools/shared/commands/resource/tests/style_document.html", + isNew: false, + disabled: false, + constructed: false, + ruleCount: 1, + atRules: [], + }, + { + styleText: "", + href: null, + nodeHref: null, + isNew: false, + disabled: false, + constructed: true, + ruleCount: 1, + atRules: [], + }, + { + styleText: "body { background-color: pink; }", + href: null, + nodeHref: + "https://example.org/browser/devtools/shared/commands/resource/tests/style_iframe.html", + isNew: false, + disabled: false, + constructed: false, + ruleCount: 1, + atRules: [], + }, + { + styleText: "body { padding: 1px; }", + href: "https://example.org/browser/devtools/shared/commands/resource/tests/style_iframe.css", + nodeHref: + "https://example.org/browser/devtools/shared/commands/resource/tests/style_iframe.html", + isNew: false, + disabled: false, + constructed: false, + ruleCount: 1, + atRules: [], + }, +]; + +const ADDITIONAL_INLINE_RESOURCE = { + styleText: + "@media all { body { color: red; } } @media print { body { color: cyan; } } body { font-size: 10px; }", + href: null, + nodeHref: + "https://example.com/browser/devtools/shared/commands/resource/tests/style_document.html", + isNew: false, + disabled: false, + constructed: false, + ruleCount: 5, + atRules: [ + { + type: "media", + conditionText: "all", + matches: true, + line: 1, + column: 1, + }, + { + type: "media", + conditionText: "print", + matches: false, + line: 1, + column: 37, + }, + ], +}; + +const ADDITIONAL_CONSTRUCTED_RESOURCE = { + styleText: "", + href: null, + nodeHref: null, + isNew: false, + disabled: false, + constructed: true, + ruleCount: 2, + atRules: [], +}; + +const ADDITIONAL_FROM_ACTOR_RESOURCE = { + styleText: "body { font-size: 10px; }", + href: null, + nodeHref: + "https://example.com/browser/devtools/shared/commands/resource/tests/style_document.html", + isNew: true, + disabled: false, + constructed: false, + ruleCount: 1, + atRules: [], +}; + +add_task(async function () { + await testResourceAvailableDestroyedFeature(); + await testResourceUpdateFeature(); + await testNestedResourceUpdateFeature(); +}); + +function pushAvailableResource(availableResources) { + // TODO(bug 1826538): Find a better way of dealing with these. + return function (resources) { + for (const resource of resources) { + if (resource.href?.startsWith("resource://")) { + continue; + } + availableResources.push(resource); + } + }; +} + +async function testResourceAvailableDestroyedFeature() { + info("Check resource available feature of the ResourceCommand"); + + const tab = await addTab(STYLE_TEST_URL); + let resourceTimingEntryCounts = await getResourceTimingCount(tab); + is( + resourceTimingEntryCounts, + 2, + "Should have two entires for resource timing" + ); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Check whether ResourceCommand gets existing stylesheet"); + const availableResources = []; + const destroyedResources = []; + await resourceCommand.watchResources([resourceCommand.TYPES.STYLESHEET], { + onAvailable: pushAvailableResource(availableResources), + onDestroyed: resources => destroyedResources.push(...resources), + }); + + is( + availableResources.length, + EXISTING_RESOURCES.length, + "Length of existing resources is correct" + ); + for (let i = 0; i < availableResources.length; i++) { + const availableResource = availableResources[i]; + // We can not expect the resources to always be forwarded in the same order. + // See intermittent Bug 1655016. + const expectedResource = findMatchingExpectedResource(availableResource); + ok(expectedResource, "Found a matching expected resource for the resource"); + await assertResource(availableResource, expectedResource); + } + + resourceTimingEntryCounts = await getResourceTimingCount(tab); + is( + resourceTimingEntryCounts, + 2, + "Should still have two entires for resource timing after devtools APIs have been triggered" + ); + + info("Check whether ResourceCommand gets additonal stylesheet"); + await ContentTask.spawn( + tab.linkedBrowser, + ADDITIONAL_INLINE_RESOURCE.styleText, + text => { + const document = content.document; + const stylesheet = document.createElement("style"); + stylesheet.id = "inline-from-test"; + stylesheet.textContent = text; + document.body.appendChild(stylesheet); + } + ); + await waitUntil( + () => availableResources.length === EXISTING_RESOURCES.length + 1 + ); + await assertResource( + availableResources[availableResources.length - 1], + ADDITIONAL_INLINE_RESOURCE + ); + + info("Check whether ResourceCommand gets additonal constructed stylesheet"); + await ContentTask.spawn(tab.linkedBrowser, null, () => { + const document = content.document; + const s = new content.CSSStyleSheet(); + // We use the different number of rules to meaningfully differentiate + // between constructed stylesheets. + s.replaceSync("foo { color: red } bar { color: blue }"); + // TODO(bug 1751346): wrappedJSObject should be unnecessary. + document.wrappedJSObject.adoptedStyleSheets.push(s); + }); + await waitUntil( + () => availableResources.length === EXISTING_RESOURCES.length + 2 + ); + await assertResource( + availableResources[availableResources.length - 1], + ADDITIONAL_CONSTRUCTED_RESOURCE + ); + + info( + "Check whether ResourceCommand gets additonal stylesheet which is added by DevTools" + ); + const styleSheetsFront = await targetCommand.targetFront.getFront( + "stylesheets" + ); + await styleSheetsFront.addStyleSheet( + ADDITIONAL_FROM_ACTOR_RESOURCE.styleText + ); + await waitUntil( + () => availableResources.length === EXISTING_RESOURCES.length + 3 + ); + await assertResource( + availableResources[availableResources.length - 1], + ADDITIONAL_FROM_ACTOR_RESOURCE + ); + + info("Check resource destroyed feature of the ResourceCommand"); + is(destroyedResources.length, 0, "There was no removed stylesheets yet"); + + info("Remove inline stylesheet added in the test"); + await ContentTask.spawn(tab.linkedBrowser, null, () => { + content.document.querySelector("#inline-from-test").remove(); + }); + await waitUntil(() => destroyedResources.length === 1); + assertDestroyed(destroyedResources[0], { + resourceId: availableResources.at(-3).resourceId, + }); + + info("Remove existing top-level inline stylesheet"); + await ContentTask.spawn(tab.linkedBrowser, null, () => { + content.document.querySelector("style").remove(); + }); + await waitUntil(() => destroyedResources.length === 2); + assertDestroyed(destroyedResources[1], { + resourceId: availableResources.find( + resource => + findMatchingExpectedResource(resource) === EXISTING_RESOURCES[0] + ).resourceId, + }); + + info("Remove existing top-level <link> stylesheet"); + await ContentTask.spawn(tab.linkedBrowser, null, () => { + content.document.querySelector("link").remove(); + }); + await waitUntil(() => destroyedResources.length === 3); + assertDestroyed(destroyedResources[2], { + resourceId: availableResources.find( + resource => + findMatchingExpectedResource(resource) === EXISTING_RESOURCES[1] + ).resourceId, + }); + + info("Remove existing iframe inline stylesheet"); + const iframeBrowsingContext = await SpecialPowers.spawn( + tab.linkedBrowser, + [], + () => content.document.querySelector("iframe").browsingContext + ); + + await SpecialPowers.spawn(iframeBrowsingContext, [], () => { + content.document.querySelector("style").remove(); + }); + await waitUntil(() => destroyedResources.length === 4); + assertDestroyed(destroyedResources[3], { + resourceId: availableResources.find( + resource => + findMatchingExpectedResource(resource) === EXISTING_RESOURCES[3] + ).resourceId, + }); + + info("Remove existing iframe <link> stylesheet"); + await SpecialPowers.spawn(iframeBrowsingContext, [], () => { + content.document.querySelector("link").remove(); + }); + await waitUntil(() => destroyedResources.length === 5); + assertDestroyed(destroyedResources[4], { + resourceId: availableResources.find( + resource => + findMatchingExpectedResource(resource) === EXISTING_RESOURCES[4] + ).resourceId, + }); + + targetCommand.destroy(); + await client.close(); +} + +async function testResourceUpdateFeature() { + info("Check resource update feature of the ResourceCommand"); + + const tab = await addTab(STYLE_TEST_URL); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Setup the watcher"); + const availableResources = []; + const updates = []; + await resourceCommand.watchResources([resourceCommand.TYPES.STYLESHEET], { + onAvailable: pushAvailableResource(availableResources), + onUpdated: newUpdates => updates.push(...newUpdates), + }); + is( + availableResources.length, + EXISTING_RESOURCES.length, + "Length of existing resources is correct" + ); + is(updates.length, 0, "there's no update yet"); + + info("Check toggleDisabled function"); + // Retrieve the stylesheet of the top-level target + const resource = availableResources.find( + innerResource => innerResource.targetFront.isTopLevel + ); + const styleSheetsFront = await resource.targetFront.getFront("stylesheets"); + await styleSheetsFront.toggleDisabled(resource.resourceId); + await waitUntil(() => updates.length === 1); + + // Check the content of the update object. + assertUpdate(updates[0].update, { + resourceId: resource.resourceId, + updateType: "property-change", + }); + is( + updates[0].update.resourceUpdates.disabled, + true, + "resourceUpdates is correct" + ); + + // Check whether the cached resource is updated correctly. + is( + updates[0].resource.disabled, + true, + "cached resource is updated correctly" + ); + + // Check whether the actual stylesheet is updated correctly. + const styleSheetDisabled = await ContentTask.spawn( + tab.linkedBrowser, + null, + () => { + const document = content.document; + const stylesheet = document.styleSheets[0]; + return stylesheet.disabled; + } + ); + is(styleSheetDisabled, true, "actual stylesheet was updated correctly"); + + info("Check update function"); + const expectedAtRules = [ + { + type: "media", + conditionText: "screen", + matches: true, + }, + { + type: "media", + conditionText: "print", + matches: false, + }, + ]; + + const updateCause = "updated-by-test"; + await styleSheetsFront.update( + resource.resourceId, + "@media screen { color: red; } @media print { color: green; } body { color: cyan; }", + false, + updateCause + ); + await waitUntil(() => updates.length === 4); + + assertUpdate(updates[1].update, { + resourceId: resource.resourceId, + updateType: "property-change", + }); + is( + updates[1].update.resourceUpdates.ruleCount, + 3, + "resourceUpdates is correct" + ); + is(updates[1].resource.ruleCount, 3, "cached resource is updated correctly"); + + assertUpdate(updates[2].update, { + resourceId: resource.resourceId, + updateType: "style-applied", + event: { + cause: updateCause, + }, + }); + is( + updates[2].update.resourceUpdates, + undefined, + "resourceUpdates is correct" + ); + + assertUpdate(updates[3].update, { + resourceId: resource.resourceId, + updateType: "at-rules-changed", + }); + assertAtRules(updates[3].update.resourceUpdates.atRules, expectedAtRules); + + // Check the actual page. + const styleSheetResult = await getStyleSheetResult(tab); + + is( + styleSheetResult.ruleCount, + 3, + "ruleCount of actual stylesheet is updated correctly" + ); + assertAtRules(styleSheetResult.atRules, expectedAtRules); + + targetCommand.destroy(); + await client.close(); +} + +async function testNestedResourceUpdateFeature() { + info("Check nested resource update feature of the ResourceCommand"); + + const tab = await addTab(STYLE_TEST_URL); + + const { outerWidth: originalWindowWidth, outerHeight: originalWindowHeight } = + tab.ownerGlobal; + + registerCleanupFunction(() => { + tab.ownerGlobal.resizeTo(originalWindowWidth, originalWindowHeight); + }); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Setup the watcher"); + const availableResources = []; + const updates = []; + await resourceCommand.watchResources([resourceCommand.TYPES.STYLESHEET], { + onAvailable: pushAvailableResource(availableResources), + onUpdated: newUpdates => updates.push(...newUpdates), + }); + is( + availableResources.length, + EXISTING_RESOURCES.length, + "Length of existing resources is correct" + ); + + info("Apply new media query"); + // In order to avoid applying the media query (min-height: 400px). + if (originalWindowHeight !== 300) { + await new Promise(resolve => { + tab.ownerGlobal.addEventListener("resize", resolve, { once: true }); + tab.ownerGlobal.resizeTo(originalWindowWidth, 300); + }); + } + + // Retrieve the stylesheet of the top-level target + const resource = availableResources.find( + innerResource => innerResource.targetFront.isTopLevel + ); + const styleSheetsFront = await resource.targetFront.getFront("stylesheets"); + await styleSheetsFront.update( + resource.resourceId, + `@media (min-height: 400px) { + html { + color: red; + } + @layer myLayer { + @supports (container-type) { + :root { + color: gold; + container: root inline-size; + } + + @container root (width > 10px) { + body { + color: gold; + } + } + } + } + }`, + false + ); + await waitUntil(() => updates.length === 3); + is( + updates.at(-1).resource.ruleCount, + 7, + "Resource in update has expected ruleCount" + ); + + is(resource.atRules[0].matches, false, "Media query is not matched yet"); + + info("Change window size to fire matches-change event"); + tab.ownerGlobal.resizeTo(originalWindowWidth, 500); + await waitUntil(() => updates.length === 4); + + // Check the update content. + const targetUpdate = updates[3]; + assertUpdate(targetUpdate.update, { + resourceId: resource.resourceId, + updateType: "matches-change", + }); + Assert.strictEqual( + resource, + targetUpdate.resource, + "Update object has the same resource" + ); + + is( + JSON.stringify(targetUpdate.update.nestedResourceUpdates[0].path), + JSON.stringify(["atRules", 0, "matches"]), + "path of nestedResourceUpdates is correct" + ); + is( + targetUpdate.update.nestedResourceUpdates[0].value, + true, + "value of nestedResourceUpdates is correct" + ); + + // Check the resource. + const expectedAtRules = [ + { + type: "media", + conditionText: "(min-height: 400px)", + matches: true, + }, + { + type: "layer", + layerName: "myLayer", + }, + { + type: "support", + conditionText: "(container-type)", + }, + { + type: "container", + conditionText: "root (width > 10px)", + }, + ]; + + assertAtRules(targetUpdate.resource.atRules, expectedAtRules); + + // Check the actual page. + const styleSheetResult = await getStyleSheetResult(tab); + is( + styleSheetResult.ruleCount, + 7, + "ruleCount of actual stylesheet is updated correctly" + ); + assertAtRules(styleSheetResult.atRules, expectedAtRules); + + tab.ownerGlobal.resizeTo(originalWindowWidth, originalWindowHeight); + + targetCommand.destroy(); + await client.close(); +} + +function findMatchingExpectedResource(resource) { + return EXISTING_RESOURCES.find( + expected => + resource.href === expected.href && + resource.nodeHref === expected.nodeHref && + resource.ruleCount === expected.ruleCount && + resource.constructed == expected.constructed + ); +} + +async function getStyleSheetResult(tab) { + const result = await ContentTask.spawn(tab.linkedBrowser, null, () => { + const document = content.document; + const stylesheet = document.styleSheets[0]; + let ruleCount = 0; + const atRules = []; + + const traverseRules = ruleList => { + for (const rule of ruleList) { + ruleCount++; + + if (rule.media) { + let matches = false; + try { + const mql = content.matchMedia(rule.media.mediaText); + matches = mql.matches; + } catch (e) { + // Ignored + } + + atRules.push({ + type: "media", + conditionText: rule.conditionText, + matches, + }); + } else if (rule instanceof content.CSSContainerRule) { + atRules.push({ + type: "container", + conditionText: rule.conditionText, + }); + } else if (rule instanceof content.CSSLayerBlockRule) { + atRules.push({ type: "layer", layerName: rule.name }); + } else if (rule instanceof content.CSSSupportsRule) { + atRules.push({ + type: "support", + conditionText: rule.conditionText, + }); + } + + if (rule.cssRules) { + traverseRules(rule.cssRules); + } + } + }; + traverseRules(stylesheet.cssRules); + + return { ruleCount, atRules }; + }); + + return result; +} + +function assertAtRules(atRules, expectedAtRules) { + is( + atRules.length, + expectedAtRules.length, + "Length of the atRules is correct" + ); + + for (let i = 0; i < atRules.length; i++) { + const atRule = atRules[i]; + const expected = expectedAtRules[i]; + is(atRule.type, expected.type, "at-rule is of expected type"); + is( + atRules[i].conditionText, + expected.conditionText, + "conditionText is correct" + ); + if (expected.type === "media") { + is(atRule.matches, expected.matches, "matches is correct"); + } else if (expected.type === "layer") { + is(atRule.layerName, expected.layerName, "layerName is correct"); + } + + if (expected.line !== undefined) { + is(atRule.line, expected.line, "line is correct"); + } + + if (expected.column !== undefined) { + is(atRule.column, expected.column, "column is correct"); + } + } +} + +async function assertResource(resource, expected) { + is( + resource.resourceType, + ResourceCommand.TYPES.STYLESHEET, + "Resource type is correct" + ); + const styleText = (await getStyleSheetResourceText(resource)).trim(); + is(styleText, expected.styleText, "Style text is correct"); + is(resource.href, expected.href, "href is correct"); + is(resource.nodeHref, expected.nodeHref, "nodeHref is correct"); + is(resource.isNew, expected.isNew, "isNew is correct"); + is(resource.disabled, expected.disabled, "disabled is correct"); + is(resource.constructed, expected.constructed, "constructed is correct"); + is(resource.ruleCount, expected.ruleCount, "ruleCount is correct"); + assertAtRules(resource.atRules, expected.atRules); +} + +function assertUpdate(update, expected) { + is( + update.resourceType, + ResourceCommand.TYPES.STYLESHEET, + "Resource type is correct" + ); + is(update.resourceId, expected.resourceId, "resourceId is correct"); + is(update.updateType, expected.updateType, "updateType is correct"); + if (expected.event?.cause) { + is(update.event?.cause, expected.event.cause, "cause is correct"); + } +} + +function assertDestroyed(resource, expected) { + is( + resource.resourceType, + ResourceCommand.TYPES.STYLESHEET, + "Resource type is correct" + ); + is(resource.resourceId, expected.resourceId, "resourceId is correct"); +} + +function getResourceTimingCount(tab) { + return ContentTask.spawn(tab.linkedBrowser, [], () => { + return content.performance.getEntriesByType("resource").length; + }); +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_stylesheets_header.js b/devtools/shared/commands/resource/tests/browser_resources_stylesheets_header.js new file mode 100644 index 0000000000..29263d887b --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_stylesheets_header.js @@ -0,0 +1,82 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that we do get the appropriate stylesheet content when the stylesheet is only +// served based on the Accept: text/css header + +add_task(async function () { + const httpServer = createTestHTTPServer(); + + httpServer.registerContentType("html", "text/html"); + + httpServer.registerPathHandler("/index.html", function (request, response) { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write(` +<!DOCTYPE html> +<meta charset="utf-8"> +<title>Test stylesheet</title> +<link href="/test/" rel="stylesheet" type="text/css"/> +<script src="/test/"></script> +<h1>Hello</h1> + `); + }); + + let resourceUrlCalls = 0; + // The /test/ URL should be called: + // - once by the content page to load the <link> + // - once by the content page to load the <script> + // - once by DevTools to fetch the stylesheet text + // (we could probably optimize this so we only call once) + const expectedResourceUrlCalls = 3; + + const styleSheetText = `body { background-color: tomato; }`; + httpServer.registerPathHandler("/test/", function (request, response) { + resourceUrlCalls++; + response.setStatusLine(request.httpVersion, 200, "OK"); + + if (request.getHeader("Accept").startsWith("text/css")) { + response.setHeader("Content-Type", "text/css", false); + response.write(styleSheetText); + return; + } + response.setHeader("Content-Type", "application/javascript", false); + response.write(`/* NOT A STYLESHEET */`); + }); + const port = httpServer.identity.primaryPort; + const TEST_URL = `http://localhost:${port}/index.html`; + + info("Check resource available feature of the ResourceCommand"); + const tab = await addTab(TEST_URL); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Check whether ResourceCommand gets existing stylesheet"); + const availableResources = []; + await resourceCommand.watchResources([resourceCommand.TYPES.STYLESHEET], { + onAvailable: resources => availableResources.push(...resources), + }); + is( + availableResources.length, + 1, + "We have the expected number of stylesheets" + ); + + is( + await getStyleSheetResourceText(availableResources[0]), + styleSheetText, + "Got expected text for the stylesheet" + ); + + is( + resourceUrlCalls, + expectedResourceUrlCalls, + "The /test URL was called the number of time we expected" + ); + + targetCommand.destroy(); + await client.close(); +}); diff --git a/devtools/shared/commands/resource/tests/browser_resources_stylesheets_import.js b/devtools/shared/commands/resource/tests/browser_resources_stylesheets_import.js new file mode 100644 index 0000000000..c58a5162e0 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_stylesheets_import.js @@ -0,0 +1,60 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API for imported STYLESHEET + iframe. + +const styleSheetText = ` +@import "${URL_ROOT_ORG_SSL}/style_document.css"; +body { background-color: tomato; }`; + +const IFRAME_URL = `https://example.org/document-builder.sjs?html=${encodeURIComponent(` + <style>${styleSheetText}</style> + <h1>iframe</h1> +`)}`; + +const TEST_URL = `https://example.org/document-builder.sjs?html= + <h1>import stylesheet test</h1> + <iframe src="${encodeURIComponent(IFRAME_URL)}"></iframe>`; + +add_task(async function () { + info("Check resource available feature of the ResourceCommand"); + + const tab = await addTab(TEST_URL); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Check whether ResourceCommand gets existing stylesheet"); + const availableResources = []; + await resourceCommand.watchResources([resourceCommand.TYPES.STYLESHEET], { + onAvailable: resources => availableResources.push(...resources), + }); + + await waitFor(() => availableResources.length === 2); + ok(true, "We're getting the expected stylesheets"); + + const styleNodeStyleSheet = availableResources.find( + resource => resource.nodeHref + ); + const importedStyleSheet = availableResources.find( + resource => resource !== styleNodeStyleSheet + ); + + is( + await getStyleSheetResourceText(styleNodeStyleSheet), + styleSheetText, + "Got expected text for the <style> stylesheet" + ); + + is( + (await getStyleSheetResourceText(importedStyleSheet)).trim(), + `body { margin: 1px; }`, + "Got expected text for the imported stylesheet" + ); + + targetCommand.destroy(); + await client.close(); +}); diff --git a/devtools/shared/commands/resource/tests/browser_resources_stylesheets_navigation.js b/devtools/shared/commands/resource/tests/browser_resources_stylesheets_navigation.js new file mode 100644 index 0000000000..1ee8913bda --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_stylesheets_navigation.js @@ -0,0 +1,254 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around STYLESHEET and navigation (reloading, creation of new browsing context, โฆ) + +const ORG_DOC_BUILDER = "https://example.org/document-builder.sjs"; +const COM_DOC_BUILDER = "https://example.com/document-builder.sjs"; + +// Since the order of resources is not guaranteed, we put a number in the title attribute +// of the <style> elements so we can sort them in a way that makes it easier for us to assert. +let currentStyleTitle = 0; + +const TEST_URI = + `${ORG_DOC_BUILDER}?html=1<h1>top-level example.org</h1>` + + `<style title="${currentStyleTitle++}">.top-level-org{}</style>` + + `<iframe id="same-origin-1" src="${ORG_DOC_BUILDER}?html=<h2>example.org 1</h2><style title=${currentStyleTitle++}>.frame-org-1{}</style>"></iframe>` + + `<iframe id="same-origin-2" src="${ORG_DOC_BUILDER}?html=<h2>example.org 2</h2><style title=${currentStyleTitle++}>.frame-org-2{}</style>"></iframe>` + + `<iframe id="remote-origin-1" src="${COM_DOC_BUILDER}?html=<h2>example.com 1</h2><style title=${currentStyleTitle++}>.frame-com-1{}</style>"></iframe>` + + `<iframe id="remote-origin-2" src="${COM_DOC_BUILDER}?html=<h2>example.com 2</h2><style title=${currentStyleTitle++}>.frame-com-2{}</style>"></iframe>`; + +const COOP_HEADERS = "Cross-Origin-Opener-Policy:same-origin"; +const TEST_URI_NEW_BROWSING_CONTEXT = + `${ORG_DOC_BUILDER}?headers=${COOP_HEADERS}` + + `&html=<h1>top-level example.org</div>` + + `<style>.top-level-org-new-bc{}</style>`; + +add_task(async function () { + info( + "Open a new tab and check that styleSheetChangeEventsEnabled is false by default" + ); + const tab = await addTab(TEST_URI); + + is( + await getDocumentStyleSheetChangeEventsEnabled(tab.linkedBrowser), + false, + `styleSheetChangeEventsEnabled is false at the beginning` + ); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + let availableResources = []; + await resourceCommand.watchResources([resourceCommand.TYPES.STYLESHEET], { + onAvailable: resources => { + availableResources.push(...resources); + }, + }); + + info("Wait for all the stylesheets resources of main document and iframes"); + await waitFor(() => availableResources.length === 5); + is(availableResources.length, 5, "Retrieved the expected stylesheets"); + + // the order of the resources is not guaranteed. + sortResourcesByExpectedOrder(availableResources); + await assertResource(availableResources[0], { + styleText: `.top-level-org{}`, + }); + await assertResource(availableResources[1], { + styleText: `.frame-org-1{}`, + }); + await assertResource(availableResources[2], { + styleText: `.frame-org-2{}`, + }); + await assertResource(availableResources[3], { + styleText: `.frame-com-1{}`, + }); + await assertResource(availableResources[4], { + styleText: `.frame-com-2{}`, + }); + + // clear availableResources so it's easier to test + availableResources = []; + + is( + await getDocumentStyleSheetChangeEventsEnabled(tab.linkedBrowser), + true, + `styleSheetChangeEventsEnabled is true after watching stylesheets` + ); + + info("Navigate a remote frame to a different page"); + const iframeNewUrl = + `https://example.com/document-builder.sjs?` + + `html=<h2>example.com new bc</h2><style title=6>.frame-com-new-bc{}</style>`; + await SpecialPowers.spawn(tab.linkedBrowser, [iframeNewUrl], url => { + const { browsingContext } = + content.document.querySelector("#remote-origin-2"); + return SpecialPowers.spawn(browsingContext, [url], innerUrl => { + content.document.location = innerUrl; + }); + }); + await waitFor(() => availableResources.length == 1); + ok(true, "We're notified about the iframe new document stylesheet"); + await assertResource(availableResources[0], { + styleText: `.frame-com-new-bc{}`, + }); + const iframeNewBrowsingContext = await SpecialPowers.spawn( + tab.linkedBrowser, + [], + () => content.document.querySelector("#remote-origin-2").browsingContext + ); + + is( + await getDocumentStyleSheetChangeEventsEnabled(iframeNewBrowsingContext), + true, + `styleSheetChangeEventsEnabled is still true after navigating the iframe` + ); + + // clear availableResources so it's easier to test + availableResources = []; + + info("Check that styleSheetChangeEventsEnabled persist after reloading"); + await reloadBrowser(); + + // โ ๏ธ When EFT is disabled, we're only getting the stylesheets for the top-level document + // and the remote frames; the same-origin iframes stylesheets are missing. + const expectedStylesheetResources = isEveryFrameTargetEnabled() ? 5 : 3; + info( + "Wait until we're notified about all the stylesheets (top-level document + iframe)" + ); + await waitFor( + () => availableResources.length === expectedStylesheetResources + ); + is( + availableResources.length, + expectedStylesheetResources, + "Retrieved the expected stylesheets after the page was reloaded" + ); + + // the order of the resources is not guaranteed. + sortResourcesByExpectedOrder(availableResources); + await assertResource(availableResources[0], { + styleText: `.top-level-org{}`, + }); + if (isEveryFrameTargetEnabled()) { + await assertResource(availableResources[1], { + styleText: `.frame-org-1{}`, + }); + await assertResource(availableResources[2], { + styleText: `.frame-org-2{}`, + }); + await assertResource(availableResources[3], { + styleText: `.frame-com-1{}`, + }); + await assertResource(availableResources[4], { + styleText: `.frame-com-new-bc{}`, + }); + } else { + await assertResource(availableResources[1], { + styleText: `.frame-com-1{}`, + }); + await assertResource(availableResources[2], { + styleText: `.frame-com-new-bc{}`, + }); + } + + is( + await getDocumentStyleSheetChangeEventsEnabled(tab.linkedBrowser), + true, + `styleSheetChangeEventsEnabled is still true on the top level document after reloading` + ); + + if (isEveryFrameTargetEnabled()) { + const bc = await SpecialPowers.spawn( + tab.linkedBrowser, + [], + () => content.document.querySelector("#same-origin-1").browsingContext + ); + is( + await getDocumentStyleSheetChangeEventsEnabled(bc), + true, + `styleSheetChangeEventsEnabled is still true on the iframe after reloading` + ); + } + + // clear availableResources so it's easier to test + availableResources = []; + + info( + "Check that styleSheetChangeEventsEnabled persist when navigating to a page that creates a new browsing context" + ); + const previousBrowsingContextId = tab.linkedBrowser.browsingContext.id; + const onLoaded = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + BrowserTestUtils.startLoadingURIString( + tab.linkedBrowser, + TEST_URI_NEW_BROWSING_CONTEXT + ); + await onLoaded; + + isnot( + tab.linkedBrowser.browsingContext.id, + previousBrowsingContextId, + "A new browsing context was created" + ); + + info("Wait to get the stylesheet for the new document"); + await waitFor(() => availableResources.length === 1); + ok(true, "We received the stylesheet for the new document"); + await assertResource(availableResources[0], { + styleText: `.top-level-org-new-bc{}`, + }); + is( + await getDocumentStyleSheetChangeEventsEnabled(tab.linkedBrowser), + true, + `styleSheetChangeEventsEnabled is still true after navigating to a new browsing context` + ); + + targetCommand.destroy(); + await client.close(); +}); + +/** + * Returns the value of the browser/browsingContext document `styleSheetChangeEventsEnabled` + * property. + * + * @param {Browser|BrowsingContext} browserOrBrowsingContext: The browser element or a + * browsing context. + * @returns {Promise<Boolean>} + */ +function getDocumentStyleSheetChangeEventsEnabled(browserOrBrowsingContext) { + return SpecialPowers.spawn(browserOrBrowsingContext, [], () => { + return content.document.styleSheetChangeEventsEnabled; + }); +} + +/** + * Sort the passed array of stylesheet resources. + * + * Since the order of resources are not guaranteed, the <style> elements we use in this test + * have a "title" attribute that represent their expected order so we can sort them in + * a way that makes it easier for us to assert. + * + * @param {Array<Object>} resources: Array of stylesheet resources + */ +function sortResourcesByExpectedOrder(resources) { + resources.sort((a, b) => { + return Number(a.title) > Number(b.title); + }); +} + +/** + * Check that the resources have the expected text + * + * @param {Array<Object>} resources: Array of stylesheet resources + * @param {Array<Object>} expected: Array of object of the following shape: + * @param {Object} expected[] + * @param {Object} expected[].styleText: Expected text content of the stylesheet + */ +async function assertResource(resource, expected) { + const styleText = (await getStyleSheetResourceText(resource)).trim(); + is(styleText, expected.styleText, "Style text is correct"); +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_stylesheets_nested_iframes.js b/devtools/shared/commands/resource/tests/browser_resources_stylesheets_nested_iframes.js new file mode 100644 index 0000000000..0b13f75ab9 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_stylesheets_nested_iframes.js @@ -0,0 +1,34 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that stylesheets are retrieved even if an iframe does not have a content document. + +const TEST_URI = URL_ROOT_SSL + "stylesheets-nested-iframes.html"; + +add_task(async function () { + const tab = await addTab(TEST_URI); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Check whether ResourceCommand gets existing stylesheet"); + const availableResources = []; + await resourceCommand.watchResources([resourceCommand.TYPES.STYLESHEET], { + onAvailable: resources => availableResources.push(...resources), + }); + + // Bug 285395 limits the number of nested iframes to 10, and we have one stylesheet per document. + await waitFor(() => availableResources.length >= 10); + + is( + availableResources.length, + 10, + "Got the expected number of stylesheets, even with documentless iframes" + ); + + targetCommand.destroy(); + await client.close(); +}); diff --git a/devtools/shared/commands/resource/tests/browser_resources_target_destroy.js b/devtools/shared/commands/resource/tests/browser_resources_target_destroy.js new file mode 100644 index 0000000000..fa7813d26e --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_target_destroy.js @@ -0,0 +1,104 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the server ResourceCommand are destroyed when the associated target actors +// are destroyed. + +add_task(async function () { + const tab = await addTab("data:text/html,Test"); + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + // Start watching for console messages. We don't care about messages here, only the + // registration/destroy mechanism, so we make onAvailable a no-op function. + await resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { + onAvailable: () => {}, + } + ); + + info( + "Spawn a content task in order to be able to manipulate actors and resource watchers directly" + ); + const connectionPrefix = targetCommand.watcherFront.actorID.replace( + /watcher\d+$/, + "" + ); + await ContentTask.spawn( + tab.linkedBrowser, + [connectionPrefix], + function (_connectionPrefix) { + const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" + ); + const { TargetActorRegistry } = ChromeUtils.importESModule( + "resource://devtools/server/actors/targets/target-actor-registry.sys.mjs" + ); + const { + getResourceWatcher, + TYPES, + } = require("resource://devtools/server/actors/resources/index.js"); + + // Retrieve the target actor instance and its watcher for console messages + const targetActor = TargetActorRegistry.getTargetActors( + { + type: "browser-element", + browserId: content.browsingContext.browserId, + }, + _connectionPrefix + ).find(actor => actor.isTopLevelTarget); + ok( + targetActor, + "Got the top level target actor from the content process" + ); + const watcher = getResourceWatcher(targetActor, TYPES.CONSOLE_MESSAGE); + + // Storing the target actor in the global so we can retrieve it later, even if it + // was destroyed + content._testTargetActor = targetActor; + + is(!!watcher, true, "The console message resource watcher was created"); + } + ); + + info("Close the client, which will destroy the target"); + targetCommand.destroy(); + await client.close(); + + info( + "Spawn a content task in order to run some assertions on actors and resource watchers directly" + ); + await ContentTask.spawn(tab.linkedBrowser, [], function () { + const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" + ); + const { + getResourceWatcher, + TYPES, + } = require("resource://devtools/server/actors/resources/index.js"); + + ok( + content._testTargetActor && !content._testTargetActor.actorID, + "The target was destroyed when the client was closed" + ); + + // Retrieve the console message resource watcher + const watcher = getResourceWatcher( + content._testTargetActor, + TYPES.CONSOLE_MESSAGE + ); + + is( + !!watcher, + false, + "The console message resource watcher isn't registered anymore after the target was destroyed" + ); + + // Cleanup work variable + delete content._testTargetActor; + }); +}); diff --git a/devtools/shared/commands/resource/tests/browser_resources_target_resources_race.js b/devtools/shared/commands/resource/tests/browser_resources_target_resources_race.js new file mode 100644 index 0000000000..557d14380a --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_target_resources_race.js @@ -0,0 +1,70 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Test initial target resources are correctly retrieved even when several calls + * to watchResources are made simultaneously. + * + * This checks a race condition which occurred when calling watchResources + * simultaneously. This made the "second" call to watchResources miss existing + * resources (in case those are emitted from the target instead of the watcher). + * See Bug 1663896. + */ +add_task(async function () { + // Disable the preloaded process as it creates processes intermittently + // which forces the emission of RDP requests we aren't correctly waiting for. + await pushPref("dom.ipc.processPrelaunch.enabled", false); + + const { client, resourceCommand, targetCommand } = + await initMultiProcessResourceCommand(); + + const expectedPlatformMessage = "expectedMessage"; + + info("Log a message *before* calling ResourceCommand.watchResources"); + Services.console.logStringMessage(expectedPlatformMessage); + + info("Call watchResources from 2 separate call sites consecutively"); + + // Empty onAvailable callback for CSS MESSAGES, we only want to check that + // the second resource we watch correctly provides existing resources. + const onCssMessageAvailable = resources => {}; + + // First call to watchResources. + // We do not await on `watchPromise1` here, in order to simulate simultaneous + // calls to watchResources (which could come from 2 separate modules in a real + // scenario). + const initialWatchPromise = resourceCommand.watchResources( + [resourceCommand.TYPES.CSS_MESSAGE], + { + onAvailable: onCssMessageAvailable, + } + ); + + // `waitForNextResource` will trigger another call to `watchResources`. + const { onResource: onMessageReceived } = + await resourceCommand.waitForNextResource( + resourceCommand.TYPES.PLATFORM_MESSAGE, + { + ignoreExistingResources: false, + predicate: r => r.message === expectedPlatformMessage, + } + ); + + info("Waiting for the expected message to be received"); + await onMessageReceived; + ok(true, "All the expected messages were received"); + + info("Wait for the other watchResources promise to finish"); + await initialWatchPromise; + + // Unwatch all resources. + resourceCommand.unwatchResources([resourceCommand.TYPES.CSS_MESSAGE], { + onAvailable: onCssMessageAvailable, + }); + + Services.console.reset(); + targetCommand.destroy(); + await client.close(); +}); diff --git a/devtools/shared/commands/resource/tests/browser_resources_target_switching.js b/devtools/shared/commands/resource/tests/browser_resources_target_switching.js new file mode 100644 index 0000000000..4551fec778 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_target_switching.js @@ -0,0 +1,94 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the behavior of ResourceCommand when the top level target changes + +const TEST_URI = + "data:text/html;charset=utf-8,<script>console.log('foo');</script>"; + +add_task(async function () { + const tab = await addTab(TEST_URI); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + const { CONSOLE_MESSAGE, SOURCE } = resourceCommand.TYPES; + + info("Check the resources gotten from getAllResources at initial"); + is( + resourceCommand.getAllResources(CONSOLE_MESSAGE).length, + 0, + "There is no resources before calling watchResources" + ); + + info( + "Start to watch the available resources in order to compare with resources gotten from getAllResources" + ); + const availableResources = []; + const onAvailable = resources => { + availableResources.push(...resources); + }; + await resourceCommand.watchResources([CONSOLE_MESSAGE], { onAvailable }); + + is(availableResources.length, 1, "Got the page message"); + is( + availableResources[0].message.arguments[0], + "foo", + "Got the expected page message" + ); + + // Register another listener before unregistering the console listener + // otherwise the resource command stop watching for targets + const onSourceAvailable = () => {}; + await resourceCommand.watchResources([SOURCE], { + onAvailable: onSourceAvailable, + }); + + info( + "Unregister the console listener and check that we no longer listen for console messages" + ); + resourceCommand.unwatchResources([CONSOLE_MESSAGE], { + onAvailable, + }); + + let onSwitched = targetCommand.once("switched-target"); + info("Navigate to another process"); + BrowserTestUtils.startLoadingURIString( + gBrowser.selectedBrowser, + "about:robots" + ); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + await onSwitched; + + is( + availableResources.length, + 1, + "about:robots doesn't fire any new message, so we should have a new one" + ); + + info("Navigate back to data: URI"); + onSwitched = targetCommand.once("switched-target"); + BrowserTestUtils.startLoadingURIString(gBrowser.selectedBrowser, TEST_URI); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + await onSwitched; + + is( + availableResources.length, + 1, + "the data:URI fired a message, but we are no longer listening to it, so no new one should be notified" + ); + is( + resourceCommand.getAllResources(CONSOLE_MESSAGE).length, + 0, + "As we are no longer listening to CONSOLE message, we should not collect any" + ); + + resourceCommand.unwatchResources([SOURCE], { + onAvailable: onSourceAvailable, + }); + + targetCommand.destroy(); + await client.close(); +}); diff --git a/devtools/shared/commands/resource/tests/browser_resources_thread_states.js b/devtools/shared/commands/resource/tests/browser_resources_thread_states.js new file mode 100644 index 0000000000..f915bb14d0 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_thread_states.js @@ -0,0 +1,557 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around THREAD_STATE + +const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js"); + +const BREAKPOINT_TEST_URL = URL_ROOT_SSL + "breakpoint_document.html"; +const REMOTE_IFRAME_URL = + "https://example.org/document-builder.sjs?html=" + + encodeURIComponent("<script>debugger;</script>"); + +add_task(async function () { + // Check hitting the "debugger;" statement before and after calling + // watchResource(THREAD_TYPES). Both should break. First will + // be a cached resource and second will be a live one. + await checkBreakpointBeforeWatchResources(); + await checkBreakpointAfterWatchResources(); + + // Check setting a real breakpoint on a given line + await checkRealBreakpoint(); + + // Check the "pause on exception" setting + await checkPauseOnException(); + + // Check an edge case where spamming setBreakpoints calls causes issues + await checkSetBeforeWatch(); + + // Check debugger statement for (remote) iframes + await checkDebuggerStatementInIframes(); +}); + +async function checkBreakpointBeforeWatchResources() { + info( + "Check whether ResourceCommand gets existing breakpoint, being hit before calling watchResources" + ); + + const tab = await addTab(BREAKPOINT_TEST_URL); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + // Ensure that the target front is initialized early from TargetCommand.onTargetAvailable + // By the time `initResourceCommand` resolves, it should already be initialized. + info( + "Verify that TargetFront's initialized is resolved after having calling attachAndInitThread" + ); + await targetCommand.targetFront.initialized; + + info("Run the 'debugger' statement"); + // Note that we do not wait for the resolution of spawn as it will be paused + ContentTask.spawn(tab.linkedBrowser, null, () => { + content.window.wrappedJSObject.runDebuggerStatement(); + }); + + info("Call watchResources"); + const availableResources = []; + await resourceCommand.watchResources([resourceCommand.TYPES.THREAD_STATE], { + onAvailable: resources => availableResources.push(...resources), + }); + + is( + availableResources.length, + 1, + "Got the THREAD_STATE's related to the debugger statement" + ); + const threadState = availableResources.pop(); + + assertPausedResource(threadState, { + state: "paused", + why: { + type: "debuggerStatement", + }, + frame: { + type: "call", + asyncCause: null, + state: "on-stack", + // this: object actor's form referring to `this` variable + displayName: "runDebuggerStatement", + // arguments: [] + where: { + line: 17, + column: 6, + }, + }, + }); + + const { threadFront } = targetCommand.targetFront; + await threadFront.resume(); + + await waitFor( + () => availableResources.length == 1, + "Wait until we receive the resumed event" + ); + + const resumed = availableResources.pop(); + + assertResumedResource(resumed); + + targetCommand.destroy(); + await client.close(); +} + +async function checkBreakpointAfterWatchResources() { + info( + "Check whether ResourceCommand gets breakpoint hit after calling watchResources" + ); + + const tab = await addTab(BREAKPOINT_TEST_URL); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Call watchResources"); + const availableResources = []; + await resourceCommand.watchResources([resourceCommand.TYPES.THREAD_STATE], { + onAvailable: resources => availableResources.push(...resources), + }); + + is( + availableResources.length, + 0, + "Got no THREAD_STATE when calling watchResources" + ); + + info("Run the 'debugger' statement"); + // Note that we do not wait for the resolution of spawn as it will be paused + ContentTask.spawn(tab.linkedBrowser, null, () => { + content.window.wrappedJSObject.runDebuggerStatement(); + }); + + await waitFor( + () => availableResources.length == 1, + "Got the THREAD_STATE related to the debugger statement" + ); + const threadState = availableResources.pop(); + + assertPausedResource(threadState, { + state: "paused", + why: { + type: "debuggerStatement", + }, + frame: { + type: "call", + asyncCause: null, + state: "on-stack", + // this: object actor's form referring to `this` variable + displayName: "runDebuggerStatement", + // arguments: [] + where: { + line: 17, + column: 6, + }, + }, + }); + + // treadFront is created and attached while calling watchResources + const { threadFront } = targetCommand.targetFront; + + await threadFront.resume(); + + await waitFor( + () => availableResources.length == 1, + "Wait until we receive the resumed event" + ); + + const resumed = availableResources.pop(); + + assertResumedResource(resumed); + + targetCommand.destroy(); + await client.close(); +} + +async function checkRealBreakpoint() { + info( + "Check whether ResourceCommand gets breakpoint set via the thread Front (instead of just debugger statements)" + ); + + const tab = await addTab(BREAKPOINT_TEST_URL); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Call watchResources"); + const availableResources = []; + await resourceCommand.watchResources([resourceCommand.TYPES.THREAD_STATE], { + onAvailable: resources => availableResources.push(...resources), + }); + + is( + availableResources.length, + 0, + "Got no THREAD_STATE when calling watchResources" + ); + + // treadFront is created and attached while calling watchResources + const { threadFront } = targetCommand.targetFront; + + // We have to call `sources` request, otherwise the Thread Actor + // doesn't start watching for sources, and ignore the setBreakpoint call + // as it doesn't have any source registered. + await threadFront.getSources(); + + await threadFront.setBreakpoint( + { sourceUrl: BREAKPOINT_TEST_URL, line: 14 }, + {} + ); + + info("Run the test function where we set a breakpoint"); + // Note that we do not wait for the resolution of spawn as it will be paused + ContentTask.spawn(tab.linkedBrowser, null, () => { + content.window.wrappedJSObject.testFunction(); + }); + + await waitFor( + () => availableResources.length == 1, + "Got the THREAD_STATE related to the debugger statement" + ); + const threadState = availableResources.pop(); + + assertPausedResource(threadState, { + state: "paused", + why: { + type: "breakpoint", + }, + frame: { + type: "call", + asyncCause: null, + state: "on-stack", + // this: object actor's form referring to `this` variable + displayName: "testFunction", + // arguments: [] + where: { + line: 14, + column: 6, + }, + }, + }); + + await threadFront.resume(); + + await waitFor( + () => availableResources.length == 1, + "Wait until we receive the resumed event" + ); + + const resumed = availableResources.pop(); + + assertResumedResource(resumed); + + targetCommand.destroy(); + await client.close(); +} + +async function checkPauseOnException() { + info( + "Check whether ResourceCommand gets breakpoint for exception (when explicitly requested)" + ); + + const tab = await addTab( + "data:text/html,<meta charset=utf8><script>a.b.c.d</script>" + ); + + const { commands, resourceCommand, targetCommand } = + await initResourceCommand(tab); + + info("Call watchResources"); + const availableResources = []; + await resourceCommand.watchResources([resourceCommand.TYPES.THREAD_STATE], { + onAvailable: resources => availableResources.push(...resources), + }); + + is( + availableResources.length, + 0, + "Got no THREAD_STATE when calling watchResources" + ); + + await commands.threadConfigurationCommand.updateConfiguration({ + pauseOnExceptions: true, + }); + + info("Reload the page, in order to trigger exception on load"); + const reloaded = reloadBrowser(); + + await waitFor( + () => availableResources.length == 1, + "Got the THREAD_STATE related to the debugger statement" + ); + const threadState = availableResources.pop(); + + assertPausedResource(threadState, { + state: "paused", + why: { + type: "exception", + }, + frame: { + type: "global", + asyncCause: null, + state: "on-stack", + // this: object actor's form referring to `this` variable + displayName: "(global)", + // arguments: [] + where: { + line: 1, + column: 27, + }, + }, + }); + + const { threadFront } = targetCommand.targetFront; + await threadFront.resume(); + info("Wait for page to finish reloading after resume"); + await reloaded; + + await waitFor( + () => availableResources.length == 1, + "Wait until we receive the resumed event" + ); + + const resumed = availableResources.pop(); + + assertResumedResource(resumed); + + targetCommand.destroy(); + await commands.destroy(); +} + +async function checkSetBeforeWatch() { + info( + "Verify bug 1683139 - D103068, where setting a breakpoint before watching for thread state, avoid receiving the paused state" + ); + + const tab = await addTab(BREAKPOINT_TEST_URL); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + // Instantiate the thread front in order to be able to set a breakpoint before watching for thread state + info("Attach the top level thread actor"); + await targetCommand.targetFront.attachAndInitThread(targetCommand); + const { threadFront } = targetCommand.targetFront; + + // We have to call `sources` request, otherwise the Thread Actor + // doesn't start watching for sources, and ignore the setBreakpoint call + // as it doesn't have any source registered. + await threadFront.getSources(); + + // Set the breakpoint before trying to hit it + await threadFront.setBreakpoint( + { sourceUrl: BREAKPOINT_TEST_URL, line: 14, column: 6 }, + {} + ); + + info("Run the test function where we set a breakpoint"); + // Note that we do not wait for the resolution of spawn as it will be paused + ContentTask.spawn(tab.linkedBrowser, null, () => { + content.window.wrappedJSObject.testFunction(); + }); + + // bug 1683139 - D103068. Re-setting the breakpoint just before watching for thread state + // prevented to receive the paused state change. + await threadFront.setBreakpoint( + { sourceUrl: BREAKPOINT_TEST_URL, line: 14, column: 6 }, + {} + ); + + info("Call watchResources"); + const availableResources = []; + await resourceCommand.watchResources([resourceCommand.TYPES.THREAD_STATE], { + onAvailable: resources => availableResources.push(...resources), + }); + + await waitFor( + () => availableResources.length == 1, + "Got the THREAD_STATE related to the debugger statement" + ); + const threadState = availableResources.pop(); + + assertPausedResource(threadState, { + state: "paused", + why: { + type: "breakpoint", + }, + frame: { + type: "call", + asyncCause: null, + state: "on-stack", + // this: object actor's form referring to `this` variable + displayName: "testFunction", + // arguments: [] + where: { + line: 14, + column: 6, + }, + }, + }); + + await threadFront.resume(); + + await waitFor( + () => availableResources.length == 1, + "Wait until we receive the resumed event" + ); + + const resumed = availableResources.pop(); + + assertResumedResource(resumed); + + targetCommand.destroy(); + await client.close(); +} + +async function checkDebuggerStatementInIframes() { + info("Check whether ResourceCommand gets breakpoint for (remote) iframes"); + + const tab = await addTab(BREAKPOINT_TEST_URL); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Call watchResources"); + const availableResources = []; + await resourceCommand.watchResources([resourceCommand.TYPES.THREAD_STATE], { + onAvailable: resources => availableResources.push(...resources), + }); + + is( + availableResources.length, + 0, + "Got no THREAD_STATE when calling watchResources" + ); + + info("Inject the iframe with an inline 'debugger' statement"); + // Note that we do not wait for the resolution of spawn as it will be paused + SpecialPowers.spawn( + gBrowser.selectedBrowser, + [REMOTE_IFRAME_URL], + async function (url) { + const iframe = content.document.createElement("iframe"); + iframe.src = url; + content.document.body.appendChild(iframe); + } + ); + + await waitFor( + () => availableResources.length == 1, + "Got the THREAD_STATE related to the iframe's debugger statement" + ); + const threadState = availableResources.pop(); + + assertPausedResource(threadState, { + state: "paused", + why: { + type: "debuggerStatement", + }, + frame: { + type: "global", + asyncCause: null, + state: "on-stack", + // this: object actor's form referring to `this` variable + displayName: "(global)", + // arguments: [] + where: { + line: 1, + column: 8, + }, + }, + }); + + const iframeTarget = threadState.targetFront; + if (isFissionEnabled() || isEveryFrameTargetEnabled()) { + is( + iframeTarget.url, + REMOTE_IFRAME_URL, + "With fission/EFT, the pause is from the iframe's target" + ); + } else { + is( + iframeTarget, + targetCommand.targetFront, + "Without fission/EFT, the pause is from the top level target" + ); + } + const { threadFront } = iframeTarget; + + await threadFront.resume(); + + await waitFor( + () => availableResources.length == 1, + "Wait until we receive the resumed event" + ); + + const resumed = availableResources.pop(); + + assertResumedResource(resumed); + + targetCommand.destroy(); + await client.close(); +} + +async function assertPausedResource(resource, expected) { + is( + resource.resourceType, + ResourceCommand.TYPES.THREAD_STATE, + "Resource type is correct" + ); + is(resource.state, "paused", "state attribute is correct"); + is(resource.why.type, expected.why.type, "why.type attribute is correct"); + is( + resource.frame.type, + expected.frame.type, + "frame.type attribute is correct" + ); + is( + resource.frame.asyncCause, + expected.frame.asyncCause, + "frame.asyncCause attribute is correct" + ); + is( + resource.frame.state, + expected.frame.state, + "frame.state attribute is correct" + ); + is( + resource.frame.displayName, + expected.frame.displayName, + "frame.displayName attribute is correct" + ); + is( + resource.frame.where.line, + expected.frame.where.line, + "frame.where.line attribute is correct" + ); + is( + resource.frame.where.column, + expected.frame.where.column, + "frame.where.column attribute is correct" + ); +} + +async function assertResumedResource(resource) { + is( + resource.resourceType, + ResourceCommand.TYPES.THREAD_STATE, + "Resource type is correct" + ); + is(resource.state, "resumed", "state attribute is correct"); +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_unwatch_early.js b/devtools/shared/commands/resource/tests/browser_resources_unwatch_early.js new file mode 100644 index 0000000000..e3890cf970 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_unwatch_early.js @@ -0,0 +1,113 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that calling unwatchResources before watchResources could resolve still +// removes watcher entries correctly. + +const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js"); + +const TEST_URI = "data:text/html;charset=utf-8,"; + +add_task(async function () { + const tab = await addTab(TEST_URI); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + const { CONSOLE_MESSAGE, ROOT_NODE } = resourceCommand.TYPES; + + info("Use console.log in the content page"); + await logInTab(tab, "msg-1"); + + info("Call watchResources with various configurations"); + + // Watcher 1 only watches for CONSOLE_MESSAGE. + // For this call site, unwatchResource will be called before onAvailable has + // resolved. + const messages1 = []; + const onAvailable1 = createMessageCallback(messages1); + const onWatcher1Ready = resourceCommand.watchResources([CONSOLE_MESSAGE], { + onAvailable: onAvailable1, + }); + resourceCommand.unwatchResources([CONSOLE_MESSAGE], { + onAvailable: onAvailable1, + }); + + info( + "Calling unwatchResources for an already unregistered callback should be a no-op" + ); + // and more importantly, it should not throw + resourceCommand.unwatchResources([CONSOLE_MESSAGE], { + onAvailable: onAvailable1, + }); + + // Watcher 2 watches for CONSOLE_MESSAGE & another resource (ROOT_NODE). + // Again unwatchResource will be called before onAvailable has resolved. + // But unwatchResource is only called for CONSOLE_MESSAGE, not for ROOT_NODE. + const messages2 = []; + const onAvailable2 = createMessageCallback(messages2); + const onWatcher2Ready = resourceCommand.watchResources( + [CONSOLE_MESSAGE, ROOT_NODE], + { + onAvailable: onAvailable2, + } + ); + resourceCommand.unwatchResources([CONSOLE_MESSAGE], { + onAvailable: onAvailable2, + }); + + // Watcher 3 watches for CONSOLE_MESSAGE, but we will not call unwatchResource + // explicitly for it before the end of test. Used as a reference. + const messages3 = []; + const onAvailable3 = createMessageCallback(messages3); + const onWatcher3Ready = resourceCommand.watchResources([CONSOLE_MESSAGE], { + onAvailable: onAvailable3, + }); + + info("Call unwatchResources for CONSOLE_MESSAGE on watcher 1 & 2"); + + info("Wait for all watchers `watchResources` to resolve"); + await Promise.all([onWatcher1Ready, onWatcher2Ready, onWatcher3Ready]); + ok(!hasMessage(messages1, "msg-1"), "Watcher 1 did not receive msg-1"); + ok(!hasMessage(messages2, "msg-1"), "Watcher 2 did not receive msg-1"); + ok(hasMessage(messages3, "msg-1"), "Watcher 3 received msg-1"); + + info("Log a new message"); + await logInTab(tab, "msg-2"); + + info("Wait until watcher 3 received the new message"); + await waitUntil(() => hasMessage(messages3, "msg-2")); + + ok(!hasMessage(messages1, "msg-2"), "Watcher 1 did not receive msg-2"); + ok(!hasMessage(messages2, "msg-2"), "Watcher 2 did not receive msg-2"); + + targetCommand.destroy(); + await client.close(); +}); + +function logInTab(tab, message) { + return ContentTask.spawn(tab.linkedBrowser, message, function (_message) { + content.console.log(_message); + }); +} + +function hasMessage(messageResources, text) { + return messageResources.find( + resource => resource.message.arguments[0] === text + ); +} + +// All resource command callbacks share the same pattern here: they add all +// console message resources to a provided `messages` array. +function createMessageCallback(messages) { + const { CONSOLE_MESSAGE } = ResourceCommand.TYPES; + return async resources => { + for (const resource of resources) { + if (resource.resourceType === CONSOLE_MESSAGE) { + messages.push(resource); + } + } + }; +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_watch_unwatch_multiple.js b/devtools/shared/commands/resource/tests/browser_resources_watch_unwatch_multiple.js new file mode 100644 index 0000000000..cc45e7bf7f --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_watch_unwatch_multiple.js @@ -0,0 +1,88 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Check that watching/unwatching multiple times works as expected + +add_task(async function () { + const TEST_URL = "data:text/html;charset=utf-8,<!DOCTYPE html>foo"; + const tab = await addTab(TEST_URL); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + let resources = []; + const onAvailable = _resources => { + resources.push(..._resources); + }; + + info("Watch for error messages resources"); + await resourceCommand.watchResources([resourceCommand.TYPES.ERROR_MESSAGE], { + onAvailable, + }); + + ok( + resourceCommand.isResourceWatched(resourceCommand.TYPES.ERROR_MESSAGE), + "The error message resource is currently been watched." + ); + + is( + resources.length, + 0, + "no resources were received after the first watchResources call" + ); + + info("Trigger an error in the page"); + await ContentTask.spawn(tab.linkedBrowser, [], function frameScript() { + const document = content.document; + const scriptEl = document.createElement("script"); + scriptEl.textContent = `document.unknownFunction()`; + document.body.appendChild(scriptEl); + }); + + await waitFor(() => resources.length === 1); + const EXPECTED_ERROR_MESSAGE = + "TypeError: document.unknownFunction is not a function"; + is( + resources[0].pageError.errorMessage, + EXPECTED_ERROR_MESSAGE, + "The resource was received" + ); + + info("Unwatching resourcesโฆ"); + resourceCommand.unwatchResources([resourceCommand.TYPES.ERROR_MESSAGE], { + onAvailable, + }); + + ok( + !resourceCommand.isResourceWatched(resourceCommand.TYPES.ERROR_MESSAGE), + "The error message resource is no longer been watched." + ); + // clearing resources + resources = []; + + info("โฆand watching again"); + await resourceCommand.watchResources([resourceCommand.TYPES.ERROR_MESSAGE], { + onAvailable, + }); + + ok( + resourceCommand.isResourceWatched(resourceCommand.TYPES.ERROR_MESSAGE), + "The error message resource is been watched again." + ); + is( + resources.length, + 1, + "we retrieve the expected number of existing resources" + ); + is( + resources[0].pageError.errorMessage, + EXPECTED_ERROR_MESSAGE, + "The resource is the expected one" + ); + + targetCommand.destroy(); + await client.close(); +}); diff --git a/devtools/shared/commands/resource/tests/browser_resources_websocket.js b/devtools/shared/commands/resource/tests/browser_resources_websocket.js new file mode 100644 index 0000000000..601620bc59 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_websocket.js @@ -0,0 +1,245 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around WEBSOCKET. + +const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js"); + +const IS_NUMBER = "IS_NUMBER"; +const SHOULD_EXIST = "SHOULD_EXIST"; + +const targets = { + TOP_LEVEL_DOCUMENT: "top-level-document", + IN_PROCESS_IFRAME: "in-process-frame", + OUT_PROCESS_IFRAME: "out-process-frame", +}; + +add_task(async function () { + info("Testing the top-level document"); + await testWebsocketResources(targets.TOP_LEVEL_DOCUMENT); + info("Testing the in-process iframe"); + await testWebsocketResources(targets.IN_PROCESS_IFRAME); + info("Testing the out-of-process iframe"); + await testWebsocketResources(targets.OUT_PROCESS_IFRAME); +}); + +async function testWebsocketResources(target) { + const tab = await addTab(URL_ROOT_SSL + "websocket_frontend.html"); + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + const availableResources = []; + function onResourceAvailable(resources) { + availableResources.push(...resources); + } + + await resourceCommand.watchResources([resourceCommand.TYPES.WEBSOCKET], { + onAvailable: onResourceAvailable, + }); + + info("Check available resources at initial"); + is( + availableResources.length, + 0, + "Length of existing resources is correct at initial" + ); + + info("Check resource of opening websocket"); + await executeFunctionInContext(tab, target, "openConnection"); + + await waitUntil(() => availableResources.length === 1); + + const httpChannelId = availableResources[0].httpChannelId; + + ok(httpChannelId, "httpChannelId is present in the resource"); + + assertResource(availableResources[0], { + wsMessageType: "webSocketOpened", + effectiveURI: + "wss://example.com/browser/devtools/shared/commands/resource/tests/websocket_backend", + extensions: "permessage-deflate", + protocols: "", + }); + + info("Check resource of sending/receiving the data via websocket"); + await executeFunctionInContext(tab, target, "sendData", "test"); + + await waitUntil(() => availableResources.length === 3); + + assertResource(availableResources[1], { + wsMessageType: "frameSent", + httpChannelId, + data: { + type: "sent", + payload: "test", + timeStamp: SHOULD_EXIST, + finBit: SHOULD_EXIST, + rsvBit1: SHOULD_EXIST, + rsvBit2: SHOULD_EXIST, + rsvBit3: SHOULD_EXIST, + opCode: SHOULD_EXIST, + mask: SHOULD_EXIST, + maskBit: SHOULD_EXIST, + }, + }); + assertResource(availableResources[2], { + wsMessageType: "frameReceived", + httpChannelId, + data: { + type: "received", + payload: "test", + timeStamp: SHOULD_EXIST, + finBit: SHOULD_EXIST, + rsvBit1: SHOULD_EXIST, + rsvBit2: SHOULD_EXIST, + rsvBit3: SHOULD_EXIST, + opCode: SHOULD_EXIST, + mask: SHOULD_EXIST, + maskBit: SHOULD_EXIST, + }, + }); + + info("Check resource of closing websocket"); + await executeFunctionInContext(tab, target, "closeConnection"); + + await waitUntil(() => availableResources.length === 6); + assertResource(availableResources[3], { + wsMessageType: "frameSent", + httpChannelId, + data: { + type: "sent", + payload: "", + timeStamp: SHOULD_EXIST, + finBit: SHOULD_EXIST, + rsvBit1: SHOULD_EXIST, + rsvBit2: SHOULD_EXIST, + rsvBit3: SHOULD_EXIST, + opCode: SHOULD_EXIST, + mask: SHOULD_EXIST, + maskBit: SHOULD_EXIST, + }, + }); + assertResource(availableResources[4], { + wsMessageType: "frameReceived", + httpChannelId, + data: { + type: "received", + payload: "", + timeStamp: SHOULD_EXIST, + finBit: SHOULD_EXIST, + rsvBit1: SHOULD_EXIST, + rsvBit2: SHOULD_EXIST, + rsvBit3: SHOULD_EXIST, + opCode: SHOULD_EXIST, + mask: SHOULD_EXIST, + maskBit: SHOULD_EXIST, + }, + }); + assertResource(availableResources[5], { + wsMessageType: "webSocketClosed", + httpChannelId, + code: IS_NUMBER, + reason: "", + wasClean: true, + }); + + info("Check existing resources"); + const existingResources = []; + + function onExsistingResourceAvailable(resources) { + existingResources.push(...resources); + } + + await resourceCommand.watchResources([resourceCommand.TYPES.WEBSOCKET], { + onAvailable: onExsistingResourceAvailable, + }); + + is( + availableResources.length, + existingResources.length, + "Length of existing resources is correct" + ); + + for (let i = 0; i < availableResources.length; i++) { + Assert.strictEqual( + availableResources[i], + existingResources[i], + `The ${i}th resource is correct` + ); + } + + await resourceCommand.unwatchResources([resourceCommand.TYPES.WEBSOCKET], { + onAvailable: onResourceAvailable, + }); + + await resourceCommand.unwatchResources([resourceCommand.TYPES.WEBSOCKET], { + onAvailable: onExsistingResourceAvailable, + }); + + targetCommand.destroy(); + await client.close(); + BrowserTestUtils.removeTab(tab); +} + +/** + * Execute global functions defined in the correct + * target (top-level-window or frames) contexts. + * + * @param {object} tab The current window tab + * @param {string} target A string identify if we want to test the top level document or iframes + * @param {string} funcName The name of the global function which needs to be called. + * @param {*} funcArgs The arguments to pass to the global function + */ +async function executeFunctionInContext(tab, target, funcName, ...funcArgs) { + let browsingContext = tab.linkedBrowser.browsingContext; + // If the target is an iframe get its window global + if (target !== targets.TOP_LEVEL_DOCUMENT) { + browsingContext = await SpecialPowers.spawn( + tab.linkedBrowser, + [target], + async _target => { + const iframe = content.document.getElementById(_target); + return iframe.browsingContext; + } + ); + } + + return SpecialPowers.spawn( + browsingContext, + [funcName, funcArgs], + async (_funcName, _funcArgs) => { + await content.wrappedJSObject[_funcName](..._funcArgs); + } + ); +} + +function assertResource(resource, expected) { + is( + resource.resourceType, + ResourceCommand.TYPES.WEBSOCKET, + "Resource type is correct" + ); + + assertObject(resource, expected); +} + +function assertObject(object, expected) { + for (const field in expected) { + if (typeof expected[field] === "object") { + assertObject(object[field], expected[field]); + } else if (expected[field] === SHOULD_EXIST) { + Assert.notStrictEqual( + object[field], + undefined, + `The value of ${field} exists` + ); + } else if (expected[field] === IS_NUMBER) { + ok(!isNaN(object[field]), `The value of ${field} is number`); + } else { + is(object[field], expected[field], `The value of ${field} is correct`); + } + } +} diff --git a/devtools/shared/commands/resource/tests/doc_console.html b/devtools/shared/commands/resource/tests/doc_console.html new file mode 100644 index 0000000000..ee883cf47d --- /dev/null +++ b/devtools/shared/commands/resource/tests/doc_console.html @@ -0,0 +1,18 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf8"> + <title>Test document for console</title> + <!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> +</head> + <body> + <p>Test document for console</p> + + <iframe src="data:text/html;charset=utf-8,foo<script>console.log('data url data log')</script>"></iframe> + <script> + "use strict"; + console.log("top-level document log"); + </script> + </body> +</html> diff --git a/devtools/shared/commands/resource/tests/doc_console_iframe.html b/devtools/shared/commands/resource/tests/doc_console_iframe.html new file mode 100644 index 0000000000..e088dff4e5 --- /dev/null +++ b/devtools/shared/commands/resource/tests/doc_console_iframe.html @@ -0,0 +1,16 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf8"> + <title>Test fission iframe document</title> + <!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> +</head> + <body> + <p>remote iframe</p> + <script> + "use strict"; + console.log(`${document.location.origin} iframe log`); + </script> + </body> +</html> diff --git a/devtools/shared/commands/resource/tests/early_console_document.html b/devtools/shared/commands/resource/tests/early_console_document.html new file mode 100644 index 0000000000..e4523dbdeb --- /dev/null +++ b/devtools/shared/commands/resource/tests/early_console_document.html @@ -0,0 +1,14 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf8"> + <title>Test fission document</title> + <!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> + <script> + "use strict"; + console.log("early-page-log"); + </script> +</head> +<body></body> +</html> diff --git a/devtools/shared/commands/resource/tests/empty.html b/devtools/shared/commands/resource/tests/empty.html new file mode 100644 index 0000000000..195b296bfe --- /dev/null +++ b/devtools/shared/commands/resource/tests/empty.html @@ -0,0 +1,11 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> + +<!DOCTYPE HTML> +<html> +<head> + <meta charset="UTF-8"> + <title>Empty page (No network requests)</title> +</head> +<body></body> +</html> diff --git a/devtools/shared/commands/resource/tests/fission_document.html b/devtools/shared/commands/resource/tests/fission_document.html new file mode 100644 index 0000000000..222f92d999 --- /dev/null +++ b/devtools/shared/commands/resource/tests/fission_document.html @@ -0,0 +1,23 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf8"> + <title>Test fission document</title> + <!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> +</head> +<body> +<p>Test fission iframe</p> + +<script> + "use strict"; + const iframe = document.createElement("iframe"); + let iframeUrl = `https://example.org/browser/devtools/shared/commands/resource/tests/fission_iframe.html`; + if (document.location.search) { + iframeUrl += `?${new URLSearchParams(document.location.search)}`; + } + iframe.src = iframeUrl; + document.body.append(iframe); +</script> +</body> +</html> diff --git a/devtools/shared/commands/resource/tests/fission_document_workers.html b/devtools/shared/commands/resource/tests/fission_document_workers.html new file mode 100644 index 0000000000..bbbe3e8bf8 --- /dev/null +++ b/devtools/shared/commands/resource/tests/fission_document_workers.html @@ -0,0 +1,47 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf8"> + <title>Test fission document</title> + <!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> + <script> + "use strict"; + + const params = new URLSearchParams(document.location.search); + + // eslint-disable-next-line no-unused-vars + const worker = new Worker("https://example.com/browser/devtools/shared/commands/resource/tests/test_worker.js#simple-worker"); + + // eslint-disable-next-line no-unused-vars + const sharedWorker = new SharedWorker("https://example.com/browser/devtools/shared/commands/resource/tests/test_worker.js#shared-worker"); + + if (!params.has("noServiceWorker")) { + // Expose a reference to the registration so that tests can unregister it. + window.registrationPromise = navigator.serviceWorker.register("https://example.com/browser/devtools/shared/commands/resource/tests/test_service_worker.js#service-worker"); + } + + /* exported logMessageInWorker */ + function logMessageInWorker(message) { + worker.postMessage({ + type: "log-in-worker", + message, + }); + } + </script> +</head> +<body> +<p>Test fission iframe</p> + +<script> + "use strict"; + const iframe = document.createElement("iframe"); + let iframeUrl = `https://example.org/browser/devtools/shared/commands/resource/tests/fission_iframe_workers.html`; + if (document.location.search) { + iframeUrl += `?${new URLSearchParams(document.location.search)}`; + } + iframe.src = iframeUrl; + document.body.append(iframe); +</script> +</body> +</html> diff --git a/devtools/shared/commands/resource/tests/fission_iframe.html b/devtools/shared/commands/resource/tests/fission_iframe.html new file mode 100644 index 0000000000..f674321102 --- /dev/null +++ b/devtools/shared/commands/resource/tests/fission_iframe.html @@ -0,0 +1,12 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf8"> + <title>Test fission iframe document</title> + <!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> +</head> +<body> +<p>remote iframe</p> +</body> +</html> diff --git a/devtools/shared/commands/resource/tests/fission_iframe_workers.html b/devtools/shared/commands/resource/tests/fission_iframe_workers.html new file mode 100644 index 0000000000..deae49f833 --- /dev/null +++ b/devtools/shared/commands/resource/tests/fission_iframe_workers.html @@ -0,0 +1,29 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf8"> + <title>Test fission iframe document</title> + <!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> + <script> + "use strict"; + const params = new URLSearchParams(document.location.search); + const hashSuffix = params.get("hashSuffix") || "in-iframe"; + // eslint-disable-next-line no-unused-vars + const worker = new Worker("test_worker.js#simple-worker-" + hashSuffix); + // eslint-disable-next-line no-unused-vars + const sharedWorker = new SharedWorker("test_worker.js#shared-worker-" + hashSuffix); + + /* exported logMessageInWorker */ + function logMessageInWorker(message) { + worker.postMessage({ + type: "log-in-worker", + message, + }); + } + </script> +</head> +<body> +<p>remote iframe</p> +</body> +</html> diff --git a/devtools/shared/commands/resource/tests/head.js b/devtools/shared/commands/resource/tests/head.js new file mode 100644 index 0000000000..5cee383070 --- /dev/null +++ b/devtools/shared/commands/resource/tests/head.js @@ -0,0 +1,151 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/* eslint no-unused-vars: [2, {"vars": "local"}] */ + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/shared/test/shared-head.js", + this +); + +const { + DevToolsClient, +} = require("resource://devtools/client/devtools-client.js"); +const { + DevToolsServer, +} = require("resource://devtools/server/devtools-server.js"); + +async function _initResourceCommandFromCommands( + commands, + { listenForWorkers = false } = {} +) { + const targetCommand = commands.targetCommand; + if (listenForWorkers) { + targetCommand.listenForWorkers = true; + } + await targetCommand.startListening(); + + //Bug 1709065: Stop exporting resourceCommand and use commands.resourceCommand + //And rename all these methods + return { + client: commands.client, + commands, + resourceCommand: commands.resourceCommand, + targetCommand, + }; +} + +/** + * Instantiate a ResourceCommand for the given tab. + * + * @param {Tab} tab + * The browser frontend's tab to connect to. + * @param {Object} options + * @param {Boolean} options.listenForWorkers + * @return {Object} object + * @return {ResourceCommand} object.resourceCommand + * The underlying resource command interface. + * @return {Object} object.commands + * The commands object defined by modules from devtools/shared/commands. + * @return {DevToolsClient} object.client + * The underlying client instance. + * @return {TargetCommand} object.targetCommand + * The underlying target list instance. + */ +async function initResourceCommand(tab, options) { + const commands = await CommandsFactory.forTab(tab); + return _initResourceCommandFromCommands(commands, options); +} + +/** + * Instantiate a multi-process ResourceCommand, watching all type of targets. + * + * @return {Object} object + * @return {ResourceCommand} object.resourceCommand + * The underlying resource command interface. + * @return {Object} object.commands + * The commands object defined by modules from devtools/shared/commands. + * @return {DevToolsClient} object.client + * The underlying client instance. + * @return {DevToolsClient} object.targetCommand + * The underlying target list instance. + */ +async function initMultiProcessResourceCommand() { + const commands = await CommandsFactory.forMainProcess(); + return _initResourceCommandFromCommands(commands); +} + +// Copied from devtools/shared/webconsole/test/chrome/common.js +function checkObject(object, expected) { + if (object && object.getGrip) { + object = object.getGrip(); + } + + for (const name of Object.keys(expected)) { + const expectedValue = expected[name]; + const value = object[name]; + checkValue(name, value, expectedValue); + } +} + +function checkValue(name, value, expected) { + if (expected === null) { + is(value, null, `'${name}' is null`); + } else if (expected === undefined) { + is(value, expected, `'${name}' is undefined`); + } else if ( + typeof expected == "string" || + typeof expected == "number" || + typeof expected == "boolean" + ) { + is(value, expected, "property '" + name + "'"); + } else if (expected instanceof RegExp) { + ok(expected.test(value), name + ": " + expected + " matched " + value); + } else if (Array.isArray(expected)) { + info("checking array for property '" + name + "'"); + ok(Array.isArray(value), `property '${name}' is an array`); + + is(value.length, expected.length, "Array has expected length"); + if (value.length !== expected.length) { + is(JSON.stringify(value, null, 2), JSON.stringify(expected, null, 2)); + } else { + checkObject(value, expected); + } + } else if (typeof expected == "object") { + info("checking object for property '" + name + "'"); + checkObject(value, expected); + } +} + +async function triggerNetworkRequests(browser, commands) { + for (let i = 0; i < commands.length; i++) { + await SpecialPowers.spawn(browser, [commands[i]], async function (code) { + const script = content.document.createElement("script"); + script.append( + content.document.createTextNode( + `async function triggerRequest() {${code}}` + ) + ); + content.document.body.append(script); + await content.wrappedJSObject.triggerRequest(); + script.remove(); + }); + } +} + +/** + * Get the stylesheet text for a given stylesheet resource. + * + * @param {Object} styleSheetResource + * @returns Promise<String> + */ +async function getStyleSheetResourceText(styleSheetResource) { + const styleSheetsFront = await styleSheetResource.targetFront.getFront( + "stylesheets" + ); + const res = await styleSheetsFront.getText(styleSheetResource.resourceId); + return res.string(); +} diff --git a/devtools/shared/commands/resource/tests/network_document.html b/devtools/shared/commands/resource/tests/network_document.html new file mode 100644 index 0000000000..5c4744cb0c --- /dev/null +++ b/devtools/shared/commands/resource/tests/network_document.html @@ -0,0 +1,13 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="utf-8"> + <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" /> + <meta http-equiv="Pragma" content="no-cache" /> + <meta http-equiv="Expires" content="0" /> + <title>Test for network events</title> + </head> + <body> + <p>Test for network events</p> + </body> +</html> diff --git a/devtools/shared/commands/resource/tests/network_document_navigation.html b/devtools/shared/commands/resource/tests/network_document_navigation.html new file mode 100644 index 0000000000..c4ec651c05 --- /dev/null +++ b/devtools/shared/commands/resource/tests/network_document_navigation.html @@ -0,0 +1,14 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="utf-8"> + <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" /> + <meta http-equiv="Pragma" content="no-cache" /> + <meta http-equiv="Expires" content="0" /> + <title>Test for network events</title> + </head> + <body> + <p>Test for network events</p> + <script src="network_navigation.js" /> + </body> +</html> diff --git a/devtools/shared/commands/resource/tests/network_navigation.js b/devtools/shared/commands/resource/tests/network_navigation.js new file mode 100644 index 0000000000..6004b69d3c --- /dev/null +++ b/devtools/shared/commands/resource/tests/network_navigation.js @@ -0,0 +1 @@ +// empty script loaded by network_document_navigation.html diff --git a/devtools/shared/commands/resource/tests/service-worker-sources.js b/devtools/shared/commands/resource/tests/service-worker-sources.js new file mode 100644 index 0000000000..614644ee5d --- /dev/null +++ b/devtools/shared/commands/resource/tests/service-worker-sources.js @@ -0,0 +1,2 @@ +/* eslint-disable */ +function serviceWorkerSource() {} diff --git a/devtools/shared/commands/resource/tests/sources.html b/devtools/shared/commands/resource/tests/sources.html new file mode 100644 index 0000000000..9e1ad67d85 --- /dev/null +++ b/devtools/shared/commands/resource/tests/sources.html @@ -0,0 +1,53 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype HTML> +<html> + <head> + <meta charset="utf-8"/> + </head> + <body> + <!-- introductionType=eventHandler --> + <div onclick="console.log('link')">link</div> + + <!-- introductionType=inlineScript mapped to scriptElement --> + <script type="text/javascript"> + "use strict"; + /* eslint-disable */ + function inlineSource() {} + + // introductionType=eval + // Assign it to a global in order to avoid it being GCed + eval("this.global = function evalFunction() {}"); + + // introductionType=Function + // Also assign to a global to avoid being GCed + this.global2 = new Function("return 42;"); + + // introductionType=injectedScript mapped to scriptElement + const script = document.createElement("script"); + script.textContent = "console.log('inline-script')"; + document.documentElement.appendChild(script); + + // introductionType=Worker, but ends up being null on SourceActor's form + // Assign the worker to a global variable in order to avoid + // having it be GCed. + this.worker = new Worker("worker-sources.js"); + + window.registrationPromise = navigator.serviceWorker.register("service-worker-sources.js"); + + // introductionType=domTimer + setTimeout(`console.log("timeout")`, 0); + + // introductionType=eventHandler + window.addEventListener("DOMContentLoaded", () => { + document.querySelector("div[onclick]").click(); + }); + </script> + <!-- introductionType=srcScript mapped to scriptElement --> + <script src="sources.js"></script> + <!-- introductionType=javascriptURL --> + <iframe src="javascript:666"></iframe> + <!-- srcdoc attribute on iframes --> + <iframe srcdoc="<script>console.log('srcdoc')</script> <script>console.log('srcdoc 2')</script>"></iframe> + </body> +</html> diff --git a/devtools/shared/commands/resource/tests/sources.js b/devtools/shared/commands/resource/tests/sources.js new file mode 100644 index 0000000000..7ae6c6272b --- /dev/null +++ b/devtools/shared/commands/resource/tests/sources.js @@ -0,0 +1,2 @@ +/* eslint-disable */ +function scriptSource() {} diff --git a/devtools/shared/commands/resource/tests/sse_backend.sjs b/devtools/shared/commands/resource/tests/sse_backend.sjs new file mode 100644 index 0000000000..777520577a --- /dev/null +++ b/devtools/shared/commands/resource/tests/sse_backend.sjs @@ -0,0 +1,8 @@ +"use strict"; + +function handleRequest(request, response) { + response.processAsync(); + response.setHeader("Content-Type", "text/event-stream"); + response.write("data: Why so serious?\n\n"); + response.finish(); +} diff --git a/devtools/shared/commands/resource/tests/sse_frontend.html b/devtools/shared/commands/resource/tests/sse_frontend.html new file mode 100644 index 0000000000..3bdddbc5bc --- /dev/null +++ b/devtools/shared/commands/resource/tests/sse_frontend.html @@ -0,0 +1,31 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype HTML> +<html> + <head> + <meta charset="utf-8"/> + <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" /> + <meta http-equiv="Pragma" content="no-cache" /> + <meta http-equiv="Expires" content="0" /> + <title>SSE Inspection Test Page</title> + </head> + <body> + <h1>SSE Inspection Test Page</h1> + <script type="text/javascript"> + "use strict"; + + /* exported openConnection */ + function openConnection() { + return new Promise(resolve => { + const es = new EventSource("sse_backend.sjs"); + es.onmessage = function (e) { + es.close(); + resolve(); + }; + }); + } + </script> + <iframe id="in-process-frame" src="https://example.com/browser/devtools/shared/commands/resource/tests/sse_frontend_iframe.html"> </iframe> + <iframe id="out-process-frame" src="https://example.org/browser/devtools/shared/commands/resource/tests/sse_frontend_iframe.html"></iframe> + </body> +</html> diff --git a/devtools/shared/commands/resource/tests/sse_frontend_iframe.html b/devtools/shared/commands/resource/tests/sse_frontend_iframe.html new file mode 100644 index 0000000000..477dca013d --- /dev/null +++ b/devtools/shared/commands/resource/tests/sse_frontend_iframe.html @@ -0,0 +1,29 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype HTML> +<html> + <head> + <meta charset="utf-8"/> + <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" /> + <meta http-equiv="Pragma" content="no-cache" /> + <meta http-equiv="Expires" content="0" /> + <title>SSE Inspection Test Page in iframe</title> + </head> + <body> + <h1>SSE Inspection Test Page in Iframe</h1> + <script type="text/javascript"> + "use strict"; + + /* exported openConnection */ + function openConnection() { + return new Promise(resolve => { + const es = new EventSource("sse_backend.sjs"); + es.onmessage = function (e) { + es.close(); + resolve(); + }; + }); + } + </script> + </body> +</html> diff --git a/devtools/shared/commands/resource/tests/style_document.css b/devtools/shared/commands/resource/tests/style_document.css new file mode 100644 index 0000000000..aa54533924 --- /dev/null +++ b/devtools/shared/commands/resource/tests/style_document.css @@ -0,0 +1 @@ +body { margin: 1px; } diff --git a/devtools/shared/commands/resource/tests/style_document.html b/devtools/shared/commands/resource/tests/style_document.html new file mode 100644 index 0000000000..deaf6c4248 --- /dev/null +++ b/devtools/shared/commands/resource/tests/style_document.html @@ -0,0 +1,22 @@ +<!DOCTYPE HTML> +<html> + <head> + <meta charset="utf8"> + <title>Test style document</title> + <!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> + <style> + body { color: lime; } + </style> + <link href="style_document.css" rel="stylesheet"> + <script> + "use strict"; + const s = new CSSStyleSheet(); + s.replaceSync("body { background-color: blue }"); + document.adoptedStyleSheets.push(s); + </script> + </head> + <body> + <iframe src="https://example.org/browser/devtools/shared/commands/resource/tests/style_iframe.html"></iframe> + </body> +</html> diff --git a/devtools/shared/commands/resource/tests/style_iframe.css b/devtools/shared/commands/resource/tests/style_iframe.css new file mode 100644 index 0000000000..30e7ae802b --- /dev/null +++ b/devtools/shared/commands/resource/tests/style_iframe.css @@ -0,0 +1 @@ +body { padding: 1px; } diff --git a/devtools/shared/commands/resource/tests/style_iframe.html b/devtools/shared/commands/resource/tests/style_iframe.html new file mode 100644 index 0000000000..11ad9f785b --- /dev/null +++ b/devtools/shared/commands/resource/tests/style_iframe.html @@ -0,0 +1,15 @@ +<!DOCTYPE HTML> +<html> + <head> + <meta charset="utf8"> + <title>Test style iframe document</title> + <!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> + <style> + body { background-color: pink; } + </style> + <link href="style_iframe.css" rel="stylesheet" type="text/css"/> + </head> + <body> + </body> +</html> diff --git a/devtools/shared/commands/resource/tests/stylesheets-nested-iframes.html b/devtools/shared/commands/resource/tests/stylesheets-nested-iframes.html new file mode 100644 index 0000000000..eb6c371867 --- /dev/null +++ b/devtools/shared/commands/resource/tests/stylesheets-nested-iframes.html @@ -0,0 +1,27 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>StyleSheetsActor iframe test</title> + <style> + p { + padding: 1em; + } + </style> +</head> +<body> + <p>A test page with nested iframes</p> + <iframe></iframe> + <script type="application/javascript"> + "use strict"; + + const iframe = document.querySelector("iframe"); + let i = parseInt(location.href.split("?")[1], 10) || 1; + + // The frame can't have the same src URL as any of its ancestors. + // This will not infinitely recurse because a frame won't get a content + // document once it's nested deeply enough. + iframe.src = location.href.split("?")[0] + "?" + (++i); + </script> +</body> +</html> diff --git a/devtools/shared/commands/resource/tests/test_image.png b/devtools/shared/commands/resource/tests/test_image.png Binary files differnew file mode 100644 index 0000000000..769c636340 --- /dev/null +++ b/devtools/shared/commands/resource/tests/test_image.png diff --git a/devtools/shared/commands/resource/tests/test_service_worker.js b/devtools/shared/commands/resource/tests/test_service_worker.js new file mode 100644 index 0000000000..aabc3fda0f --- /dev/null +++ b/devtools/shared/commands/resource/tests/test_service_worker.js @@ -0,0 +1,11 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// We don't need any computation in the worker, +// but at least register a fetch listener so that +// we force instantiating the SW when loading the page. +self.onfetch = function (event) { + // do nothing. +}; diff --git a/devtools/shared/commands/resource/tests/test_worker.js b/devtools/shared/commands/resource/tests/test_worker.js new file mode 100644 index 0000000000..60ccc6d52b --- /dev/null +++ b/devtools/shared/commands/resource/tests/test_worker.js @@ -0,0 +1,15 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +console.log("[WORKER] started", globalThis.location.toString(), globalThis); + +globalThis.onmessage = function (e) { + const { type, message } = e.data; + + if (type === "log-in-worker") { + // Printing `e` so we can check that we have an object and not a stringified version + console.log("[WORKER]", message, e); + } +}; diff --git a/devtools/shared/commands/resource/tests/websocket_backend_wsh.py b/devtools/shared/commands/resource/tests/websocket_backend_wsh.py new file mode 100644 index 0000000000..170f15fe6c --- /dev/null +++ b/devtools/shared/commands/resource/tests/websocket_backend_wsh.py @@ -0,0 +1,20 @@ +from mod_pywebsocket import msgutil + + +def web_socket_do_extra_handshake(request): + pass + + +def web_socket_transfer_data(request): + while not request.client_terminated: + resp = msgutil.receive_message(request) + msgutil.send_message(request, resp) + + +def web_socket_passive_closing_handshake(request): + # If we use `pass` here, the `payload` of `frameReceived` which will be happened + # of communication of closing will be `\u0003รจ`. In order to make the `payload` + # to be empty string, return code and reason explicitly. + code = None + reason = None + return code, reason diff --git a/devtools/shared/commands/resource/tests/websocket_frontend.html b/devtools/shared/commands/resource/tests/websocket_frontend.html new file mode 100644 index 0000000000..7efe11f9eb --- /dev/null +++ b/devtools/shared/commands/resource/tests/websocket_frontend.html @@ -0,0 +1,45 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype HTML> +<html> + <head> + <meta charset="utf-8" /> + <title>Websocket Inspection Test Page</title> + </head> + <body> + <h1>Websocket Inspection Test Page</h1> + <script type="text/javascript"> + /* exported openConnection, closeConnection, sendData */ + "use strict"; + + let webSocket; + function openConnection() { + return new Promise(resolve => { + webSocket = new WebSocket( + "wss://example.com/browser/devtools/shared/commands/resource/tests/websocket_backend" + ); + webSocket.onopen = () => { + resolve(); + }; + }); + } + + function closeConnection() { + return new Promise(resolve => { + webSocket.onclose = () => { + resolve(); + }; + webSocket.close(); + }) + } + + function sendData(payload) { + webSocket.send(payload); + } + </script> + <iframe id="in-process-frame" + src="https://example.com/browser/devtools/shared/commands/resource/tests/websocket_frontend_iframe.html"></iframe> + <iframe id="out-process-frame" + src="https://example.org/browser/devtools/shared/commands/resource/tests/websocket_frontend_iframe.html"></iframe> + </body> +</html> diff --git a/devtools/shared/commands/resource/tests/websocket_frontend_iframe.html b/devtools/shared/commands/resource/tests/websocket_frontend_iframe.html new file mode 100644 index 0000000000..e18576f911 --- /dev/null +++ b/devtools/shared/commands/resource/tests/websocket_frontend_iframe.html @@ -0,0 +1,41 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype HTML> +<html> + <head> + <meta charset="utf-8"/> + <title>Websocket Inspection Test Page</title> + </head> + <body> + <h1>Websocket Inspection Test Page</h1> + <script type="text/javascript"> + /* exported openConnection, closeConnection, sendData */ + "use strict"; + + let webSocket; + function openConnection() { + return new Promise(resolve => { + webSocket = new WebSocket( + "wss://example.com/browser/devtools/shared/commands/resource/tests/websocket_backend" + ); + webSocket.onopen = () => { + resolve(); + }; + }); + } + + function closeConnection() { + return new Promise(resolve => { + webSocket.onclose = () => { + resolve(); + }; + webSocket.close(); + }) + } + + function sendData(payload) { + webSocket.send(payload); + } + </script> + </body> +</html> diff --git a/devtools/shared/commands/resource/tests/worker-sources.js b/devtools/shared/commands/resource/tests/worker-sources.js new file mode 100644 index 0000000000..dcf2ed8031 --- /dev/null +++ b/devtools/shared/commands/resource/tests/worker-sources.js @@ -0,0 +1,2 @@ +/* eslint-disable */ +function workerSource() {} diff --git a/devtools/shared/commands/resource/transformers/console-messages.js b/devtools/shared/commands/resource/transformers/console-messages.js new file mode 100644 index 0000000000..9c8ca51f04 --- /dev/null +++ b/devtools/shared/commands/resource/transformers/console-messages.js @@ -0,0 +1,23 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// eslint-disable-next-line mozilla/reject-some-requires +loader.lazyRequireGetter( + this, + "getAdHocFrontOrPrimitiveGrip", + "resource://devtools/client/fronts/object.js", + true +); + +module.exports = function ({ resource, targetFront }) { + if (Array.isArray(resource.message.arguments)) { + // We might need to create fronts for each of the message arguments. + resource.message.arguments = resource.message.arguments.map(arg => + getAdHocFrontOrPrimitiveGrip(arg, targetFront) + ); + } + return resource; +}; diff --git a/devtools/shared/commands/resource/transformers/error-messages.js b/devtools/shared/commands/resource/transformers/error-messages.js new file mode 100644 index 0000000000..2b71f5b7ca --- /dev/null +++ b/devtools/shared/commands/resource/transformers/error-messages.js @@ -0,0 +1,31 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// eslint-disable-next-line mozilla/reject-some-requires +loader.lazyRequireGetter( + this, + "getAdHocFrontOrPrimitiveGrip", + "resource://devtools/client/fronts/object.js", + true +); + +module.exports = function ({ resource, targetFront }) { + if (resource?.pageError?.errorMessage) { + resource.pageError.errorMessage = getAdHocFrontOrPrimitiveGrip( + resource.pageError.errorMessage, + targetFront + ); + } + + if (resource?.pageError?.exception) { + resource.pageError.exception = getAdHocFrontOrPrimitiveGrip( + resource.pageError.exception, + targetFront + ); + } + + return resource; +}; diff --git a/devtools/shared/commands/resource/transformers/moz.build b/devtools/shared/commands/resource/transformers/moz.build new file mode 100644 index 0000000000..5b0b94853a --- /dev/null +++ b/devtools/shared/commands/resource/transformers/moz.build @@ -0,0 +1,16 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "console-messages.js", + "error-messages.js", + "network-events.js", + "storage-cache.js", + "storage-cookie.js", + "storage-extension.js", + "storage-indexed-db.js", + "storage-local-storage.js", + "storage-session-storage.js", + "thread-states.js", +) diff --git a/devtools/shared/commands/resource/transformers/network-events.js b/devtools/shared/commands/resource/transformers/network-events.js new file mode 100644 index 0000000000..d7f757d706 --- /dev/null +++ b/devtools/shared/commands/resource/transformers/network-events.js @@ -0,0 +1,16 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + getUrlDetails, + // eslint-disable-next-line mozilla/reject-some-requires +} = require("resource://devtools/client/netmonitor/src/utils/request-utils.js"); + +module.exports = function ({ resource }) { + resource.urlDetails = getUrlDetails(resource.url); + resource.startedMs = Date.parse(resource.startedDateTime); + return resource; +}; diff --git a/devtools/shared/commands/resource/transformers/storage-cache.js b/devtools/shared/commands/resource/transformers/storage-cache.js new file mode 100644 index 0000000000..245d892041 --- /dev/null +++ b/devtools/shared/commands/resource/transformers/storage-cache.js @@ -0,0 +1,22 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + TYPES: { CACHE_STORAGE }, +} = require("resource://devtools/shared/commands/resource/resource-command.js"); + +const { Front, types } = require("resource://devtools/shared/protocol.js"); + +module.exports = function ({ resource, watcherFront, targetFront }) { + if (!(resource instanceof Front) && watcherFront) { + // instantiate front for local storage + resource = types.getType("Cache").read(resource, targetFront); + resource.resourceType = CACHE_STORAGE; + resource.resourceKey = "Cache"; + } + + return resource; +}; diff --git a/devtools/shared/commands/resource/transformers/storage-cookie.js b/devtools/shared/commands/resource/transformers/storage-cookie.js new file mode 100644 index 0000000000..23e221672b --- /dev/null +++ b/devtools/shared/commands/resource/transformers/storage-cookie.js @@ -0,0 +1,26 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + TYPES: { COOKIE }, +} = require("resource://devtools/shared/commands/resource/resource-command.js"); + +const { Front, types } = require("resource://devtools/shared/protocol.js"); + +module.exports = function ({ resource, watcherFront, targetFront }) { + if (!(resource instanceof Front) && watcherFront) { + const { innerWindowId } = resource; + + // it's safe to instantiate the front now, so we do it. + resource = types.getType("cookies").read(resource, targetFront); + resource.resourceType = COOKIE; + resource.resourceId = `${COOKIE}-${targetFront.browsingContextID}`; + resource.resourceKey = "cookies"; + resource.innerWindowId = innerWindowId; + } + + return resource; +}; diff --git a/devtools/shared/commands/resource/transformers/storage-extension.js b/devtools/shared/commands/resource/transformers/storage-extension.js new file mode 100644 index 0000000000..3e40bdd6d0 --- /dev/null +++ b/devtools/shared/commands/resource/transformers/storage-extension.js @@ -0,0 +1,26 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + TYPES: { EXTENSION_STORAGE }, +} = require("resource://devtools/shared/commands/resource/resource-command.js"); + +const { Front, types } = require("resource://devtools/shared/protocol.js"); + +module.exports = function ({ resource, watcherFront, targetFront }) { + if (!(resource instanceof Front) && watcherFront) { + const { innerWindowId } = resource; + + // it's safe to instantiate the front now, so we do it. + resource = types.getType("extensionStorage").read(resource, targetFront); + resource.resourceType = EXTENSION_STORAGE; + resource.resourceId = `${EXTENSION_STORAGE}-${targetFront.browsingContextID}`; + resource.resourceKey = "extensionStorage"; + resource.innerWindowId = innerWindowId; + } + + return resource; +}; diff --git a/devtools/shared/commands/resource/transformers/storage-indexed-db.js b/devtools/shared/commands/resource/transformers/storage-indexed-db.js new file mode 100644 index 0000000000..8021719070 --- /dev/null +++ b/devtools/shared/commands/resource/transformers/storage-indexed-db.js @@ -0,0 +1,26 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + TYPES: { INDEXED_DB }, +} = require("resource://devtools/shared/commands/resource/resource-command.js"); + +const { Front, types } = require("resource://devtools/shared/protocol.js"); + +module.exports = function ({ resource, watcherFront, targetFront }) { + if (!(resource instanceof Front) && watcherFront) { + const { innerWindowId } = resource; + + // it's safe to instantiate the front now, so we do it. + resource = types.getType("indexedDB").read(resource, targetFront); + resource.resourceType = INDEXED_DB; + resource.resourceId = `${INDEXED_DB}-${targetFront.browsingContextID}`; + resource.resourceKey = "indexedDB"; + resource.innerWindowId = innerWindowId; + } + + return resource; +}; diff --git a/devtools/shared/commands/resource/transformers/storage-local-storage.js b/devtools/shared/commands/resource/transformers/storage-local-storage.js new file mode 100644 index 0000000000..13488723f3 --- /dev/null +++ b/devtools/shared/commands/resource/transformers/storage-local-storage.js @@ -0,0 +1,22 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + TYPES: { LOCAL_STORAGE }, +} = require("resource://devtools/shared/commands/resource/resource-command.js"); + +const { Front, types } = require("resource://devtools/shared/protocol.js"); + +module.exports = function ({ resource, watcherFront, targetFront }) { + if (!(resource instanceof Front) && watcherFront) { + // instantiate front for local storage + resource = types.getType("localStorage").read(resource, targetFront); + resource.resourceType = LOCAL_STORAGE; + resource.resourceKey = "localStorage"; + } + + return resource; +}; diff --git a/devtools/shared/commands/resource/transformers/storage-session-storage.js b/devtools/shared/commands/resource/transformers/storage-session-storage.js new file mode 100644 index 0000000000..ab9f1361c8 --- /dev/null +++ b/devtools/shared/commands/resource/transformers/storage-session-storage.js @@ -0,0 +1,22 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + TYPES: { SESSION_STORAGE }, +} = require("resource://devtools/shared/commands/resource/resource-command.js"); + +const { Front, types } = require("resource://devtools/shared/protocol.js"); + +module.exports = function ({ resource, watcherFront, targetFront }) { + if (!(resource instanceof Front) && watcherFront) { + // instantiate front for session storage + resource = types.getType("sessionStorage").read(resource, targetFront); + resource.resourceType = SESSION_STORAGE; + resource.resourceKey = "sessionStorage"; + } + + return resource; +}; diff --git a/devtools/shared/commands/resource/transformers/thread-states.js b/devtools/shared/commands/resource/transformers/thread-states.js new file mode 100644 index 0000000000..1564585b36 --- /dev/null +++ b/devtools/shared/commands/resource/transformers/thread-states.js @@ -0,0 +1,32 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { Front, types } = require("resource://devtools/shared/protocol.js"); + +module.exports = function ({ resource, watcherFront, targetFront }) { + // only "paused" have a frame attribute, and legacy listeners are already passing a FrameFront + if (resource.frame && !(resource.frame instanceof Front)) { + // Use ThreadFront as parent as debugger's commands.js expects FrameFront to be children + // of the ThreadFront. + resource.frame = types + .getType("frame") + .read(resource.frame, targetFront.threadFront); + } + + // If we are using server side request (i.e. watcherFront is defined) + // Fake paused and resumed events as the thread front depends on them. + // We can't emit "EventEmitter" events, as ThreadFront uses `Front.before` + // to listen for paused and resumed. ("before" is part of protocol.js Front and not part of EventEmitter) + if (watcherFront) { + if (resource.state == "paused") { + targetFront.threadFront._beforePaused(resource); + } else if (resource.state == "resumed") { + targetFront.threadFront._beforeResumed(resource); + } + } + + return resource; +}; |