summaryrefslogtreecommitdiffstats
path: root/devtools/shared/commands/resource/tests
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 19:33:14 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 19:33:14 +0000
commit36d22d82aa202bb199967e9512281e9a53db42c9 (patch)
tree105e8c98ddea1c1e4784a60a5a6410fa416be2de /devtools/shared/commands/resource/tests
parentInitial commit. (diff)
downloadfirefox-esr-upstream.tar.xz
firefox-esr-upstream.zip
Adding upstream version 115.7.0esr.upstream/115.7.0esrupstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
-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
68 files changed, 9034 insertions, 0 deletions
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() {}