summaryrefslogtreecommitdiffstats
path: root/devtools/shared/commands/resource
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/shared/commands/resource')
-rw-r--r--devtools/shared/commands/resource/legacy-listeners/console-messages.js59
-rw-r--r--devtools/shared/commands/resource/legacy-listeners/css-changes.js28
-rw-r--r--devtools/shared/commands/resource/legacy-listeners/error-messages.js62
-rw-r--r--devtools/shared/commands/resource/legacy-listeners/moz.build14
-rw-r--r--devtools/shared/commands/resource/legacy-listeners/platform-messages.js44
-rw-r--r--devtools/shared/commands/resource/legacy-listeners/reflow.js24
-rw-r--r--devtools/shared/commands/resource/legacy-listeners/root-node.js61
-rw-r--r--devtools/shared/commands/resource/legacy-listeners/source.js88
-rw-r--r--devtools/shared/commands/resource/legacy-listeners/thread-states.js81
-rw-r--r--devtools/shared/commands/resource/moz.build15
-rw-r--r--devtools/shared/commands/resource/resource-command.js1352
-rw-r--r--devtools/shared/commands/resource/tests/breakpoint_document.html21
-rw-r--r--devtools/shared/commands/resource/tests/browser.ini82
-rw-r--r--devtools/shared/commands/resource/tests/browser_browser_resources_console_messages.js87
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_clear_resources.js90
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_client_caching.js376
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_console_messages.js623
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_console_messages_navigation.js190
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_console_messages_workers.js257
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_css_changes.js151
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_css_messages.js210
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_document_events.js711
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_error_messages.js877
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_getAllResources.js124
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_invalid_api_usage.js76
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_last_private_context_exit.js94
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_network_event_stacktraces.js100
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_network_events.js316
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_network_events_cache.js236
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_network_events_navigation.js137
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_network_events_parent_process.js249
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_platform_messages.js158
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_reflows.js111
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_root_node.js125
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_scope_flag.js128
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_server_sent_events.js107
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_several_resources.js111
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_sources.js450
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_stylesheets.js557
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_stylesheets_import.js66
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_stylesheets_navigation.js257
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_stylesheets_nested_iframes.js34
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_target_destroy.js104
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_target_resources_race.js70
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_target_switching.js91
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_thread_states.js557
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_unwatch_early.js113
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_watch_unwatch_multiple.js88
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_websocket.js240
-rw-r--r--devtools/shared/commands/resource/tests/doc_console.html18
-rw-r--r--devtools/shared/commands/resource/tests/doc_console_iframe.html16
-rw-r--r--devtools/shared/commands/resource/tests/early_console_document.html14
-rw-r--r--devtools/shared/commands/resource/tests/empty.html11
-rw-r--r--devtools/shared/commands/resource/tests/fission_document.html23
-rw-r--r--devtools/shared/commands/resource/tests/fission_document_workers.html47
-rw-r--r--devtools/shared/commands/resource/tests/fission_iframe.html12
-rw-r--r--devtools/shared/commands/resource/tests/fission_iframe_workers.html29
-rw-r--r--devtools/shared/commands/resource/tests/head.js137
-rw-r--r--devtools/shared/commands/resource/tests/network_document.html13
-rw-r--r--devtools/shared/commands/resource/tests/network_document_navigation.html14
-rw-r--r--devtools/shared/commands/resource/tests/network_navigation.js1
-rw-r--r--devtools/shared/commands/resource/tests/service-worker-sources.js2
-rw-r--r--devtools/shared/commands/resource/tests/sources.html53
-rw-r--r--devtools/shared/commands/resource/tests/sources.js2
-rw-r--r--devtools/shared/commands/resource/tests/sse_backend.sjs8
-rw-r--r--devtools/shared/commands/resource/tests/sse_frontend.html31
-rw-r--r--devtools/shared/commands/resource/tests/sse_frontend_iframe.html29
-rw-r--r--devtools/shared/commands/resource/tests/style_document.css1
-rw-r--r--devtools/shared/commands/resource/tests/style_document.html22
-rw-r--r--devtools/shared/commands/resource/tests/style_iframe.css1
-rw-r--r--devtools/shared/commands/resource/tests/style_iframe.html15
-rw-r--r--devtools/shared/commands/resource/tests/stylesheets-nested-iframes.html27
-rw-r--r--devtools/shared/commands/resource/tests/test_image.pngbin0 -> 580 bytes
-rw-r--r--devtools/shared/commands/resource/tests/test_service_worker.js11
-rw-r--r--devtools/shared/commands/resource/tests/test_worker.js15
-rw-r--r--devtools/shared/commands/resource/tests/websocket_backend_wsh.py20
-rw-r--r--devtools/shared/commands/resource/tests/websocket_frontend.html45
-rw-r--r--devtools/shared/commands/resource/tests/websocket_frontend_iframe.html41
-rw-r--r--devtools/shared/commands/resource/tests/worker-sources.js2
-rw-r--r--devtools/shared/commands/resource/transformers/console-messages.js23
-rw-r--r--devtools/shared/commands/resource/transformers/error-messages.js31
-rw-r--r--devtools/shared/commands/resource/transformers/moz.build16
-rw-r--r--devtools/shared/commands/resource/transformers/network-events.js16
-rw-r--r--devtools/shared/commands/resource/transformers/storage-cache.js22
-rw-r--r--devtools/shared/commands/resource/transformers/storage-cookie.js26
-rw-r--r--devtools/shared/commands/resource/transformers/storage-extension.js26
-rw-r--r--devtools/shared/commands/resource/transformers/storage-indexed-db.js26
-rw-r--r--devtools/shared/commands/resource/transformers/storage-local-storage.js22
-rw-r--r--devtools/shared/commands/resource/transformers/storage-session-storage.js22
-rw-r--r--devtools/shared/commands/resource/transformers/thread-states.js32
90 files changed, 11124 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..24112de5c1
--- /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.ini"]
diff --git a/devtools/shared/commands/resource/resource-command.js b/devtools/shared/commands/resource/resource-command.js
new file mode 100644
index 0000000000..1eb9dd40ae
--- /dev/null
+++ b/devtools/shared/commands/resource/resource-command.js
@@ -0,0 +1,1352 @@
+/* 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;
+
+ 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.
+ const shouldRunLegacyListeners =
+ !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._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));
+ 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",
+ 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",
+ TRACING_STATE: "tracing-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.ini b/devtools/shared/commands/resource/tests/browser.ini
new file mode 100644
index 0000000000..6c89b01a69
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/browser.ini
@@ -0,0 +1,82 @@
+[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_document_events.js]
+skip-if =
+ win10_2004 # Bug 1723573
+ 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
+ win10_2004 && !debug # Bug 1744565
+[browser_resources_stylesheets.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
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..a10c74e298
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/browser_resources_client_caching.js
@@ -0,0 +1,376 @@
+/* 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];
+ ok(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..1d476e9f52
--- /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);
+ await BrowserTestUtils.loadURIString(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..2146904bb0
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/browser_resources_css_messages.js
@@ -0,0 +1,210 @@
+/* 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 {
+ 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: "html",
+ 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_document_events.js b/devtools/shared/commands/resource/tests/browser_resources_document_events.js
new file mode 100644
index 0000000000..2bd70b9272
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/browser_resources_document_events.js
@@ -0,0 +1,711 @@
+/* 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.loadURIString(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.loadURIString(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.loadURIString(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) {
+ ok(
+ willNavigateEvent.time <= loadingEvent.time,
+ `Timestamp for dom-loading event is greater than will-navigate event (${willNavigateEvent.time} <= ${loadingEvent.time})`
+ );
+ }
+ ok(
+ loadingEvent.time <= interactiveEvent.time,
+ `Timestamp for interactive event is greater than loading event (${loadingEvent.time} <= ${interactiveEvent.time})`
+ );
+ ok(
+ 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..5ddc033663
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/browser_resources_getAllResources.js
@@ -0,0 +1,124 @@
+/* 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];
+ ok(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..bd84b81e09
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/browser_resources_network_events.js
@@ -0,0 +1,316 @@
+/* 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);
+ ok(
+ 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);
+ ok(
+ 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.loadURIString(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..adf1e1ec52
--- /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.import(
+ "resource://gre/modules/NetUtil.jsm"
+ );
+ 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.import(
+ "resource://gre/modules/NetUtil.jsm"
+ );
+ 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..e4e7b57f13
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/browser_resources_reflows.js
@@ -0,0 +1,111 @@
+/* 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");
+ ok(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..440d6e6215
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/browser_resources_root_node.js
@@ -0,0 +1,125 @@
+/* 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;
+ ok(
+ 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.loadURIString(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..2495b2bb2a
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/browser_resources_sources.js
@@ -0,0 +1,450 @@
+/* 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),
+ });
+
+ await BrowserTestUtils.loadURIString(tab.linkedBrowser, TEST_URL);
+
+ // 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..d246155f21
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/browser_resources_stylesheets.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 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: 3,
+ atRules: [
+ {
+ conditionText: "all",
+ mediaText: "all",
+ matches: true,
+ line: 1,
+ column: 1,
+ },
+ {
+ conditionText: "print",
+ mediaText: "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 testResourceAvailableFeature();
+ await testResourceUpdateFeature();
+ await testNestedResourceUpdateFeature();
+});
+
+async function testResourceAvailableFeature() {
+ 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 = [];
+ await resourceCommand.watchResources([resourceCommand.TYPES.STYLESHEET], {
+ onAvailable: resources => availableResources.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.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
+ );
+
+ 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: resources => availableResources.push(...resources),
+ 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 = [
+ {
+ conditionText: "screen",
+ mediaText: "screen",
+ matches: true,
+ },
+ {
+ conditionText: "print",
+ mediaText: "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: resources => availableResources.push(...resources),
+ 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) { color: red; }",
+ false
+ );
+ await waitUntil(() => updates.length === 3);
+ 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",
+ });
+ ok(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 = [
+ {
+ conditionText: "(min-height: 400px)",
+ mediaText: "(min-height: 400px)",
+ matches: true,
+ },
+ ];
+
+ assertAtRules(targetUpdate.resource.atRules, expectedAtRules);
+
+ // Check the actual page.
+ const styleSheetResult = await getStyleSheetResult(tab);
+ is(
+ styleSheetResult.ruleCount,
+ 1,
+ "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];
+ const ruleCount = stylesheet.cssRules.length;
+
+ const atRules = [];
+ for (const rule of stylesheet.cssRules) {
+ if (!rule.media) {
+ continue;
+ }
+
+ let matches = false;
+ try {
+ const mql = content.matchMedia(rule.media.mediaText);
+ matches = mql.matches;
+ } catch (e) {
+ // Ignored
+ }
+
+ atRules.push({
+ mediaText: rule.media.mediaText,
+ conditionText: rule.conditionText,
+ matches,
+ });
+ }
+
+ return { ruleCount, atRules };
+ });
+
+ return result;
+}
+
+function assertAtRules(atRules, expected) {
+ is(atRules.length, expected.length, "Length of the atRules is correct");
+
+ for (let i = 0; i < atRules.length; i++) {
+ is(
+ atRules[i].conditionText,
+ expected[i].conditionText,
+ "conditionText is correct"
+ );
+ is(atRules[i].mediaText, expected[i].mediaText, "mediaText is correct");
+ is(atRules[i].matches, expected[i].matches, "matches is correct");
+
+ if (expected[i].line !== undefined) {
+ is(atRules[i].line, expected[i].line, "line is correct");
+ }
+
+ if (expected[i].column !== undefined) {
+ is(atRules[i].column, expected[i].column, "column is correct");
+ }
+ }
+}
+
+async function assertResource(resource, expected) {
+ is(
+ resource.resourceType,
+ ResourceCommand.TYPES.STYLESHEET,
+ "Resource type is correct"
+ );
+ const styleSheetsFront = await resource.targetFront.getFront("stylesheets");
+ const styleText = (
+ await styleSheetsFront.getText(resource.resourceId)
+ ).str.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 getResourceTimingCount(tab) {
+ return ContentTask.spawn(tab.linkedBrowser, [], () => {
+ return content.performance.getEntriesByType("resource").length;
+ });
+}
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..a6b06a7613
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/browser_resources_stylesheets_import.js
@@ -0,0 +1,66 @@
+/* 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 getStyleSheetText(styleNodeStyleSheet),
+ styleSheetText.trim(),
+ "Got expected text for the <style> stylesheet"
+ );
+
+ is(
+ await getStyleSheetText(importedStyleSheet),
+ `body { margin: 1px; }`,
+ "Got expected text for the imported stylesheet"
+ );
+
+ targetCommand.destroy();
+ await client.close();
+});
+
+async function getStyleSheetText(resource) {
+ const styleSheetsFront = await resource.targetFront.getFront("stylesheets");
+ const styleText = await styleSheetsFront.getText(resource.resourceId);
+ return styleText.str.trim();
+}
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..cfe4076ac4
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/browser_resources_stylesheets_navigation.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 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);
+ await BrowserTestUtils.loadURIString(
+ 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 styleSheetsFront = await resource.targetFront.getFront("stylesheets");
+ const styleText = (
+ await styleSheetsFront.getText(resource.resourceId)
+ ).str.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..fbc928f125
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/browser_resources_target_switching.js
@@ -0,0 +1,91 @@
+/* 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.loadURIString(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.loadURIString(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..6e73fa7b2e
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/browser_resources_websocket.js
@@ -0,0 +1,240 @@
+/* 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++) {
+ ok(
+ 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) {
+ ok(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..d05c2e0d3d
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/head.js
@@ -0,0 +1,137 @@
+/* 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();
+ });
+ }
+}
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
new file mode 100644
index 0000000000..769c636340
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/test_image.png
Binary files differ
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;
+};