summaryrefslogtreecommitdiffstats
path: root/devtools/shared/commands
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
parentInitial commit. (diff)
downloadfirefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.tar.xz
firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.zip
Adding upstream version 115.7.0esr.upstream/115.7.0esr
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/shared/commands')
-rw-r--r--devtools/shared/commands/README.md44
-rw-r--r--devtools/shared/commands/commands-factory.js245
-rw-r--r--devtools/shared/commands/index.js133
-rw-r--r--devtools/shared/commands/inspected-window/inspected-window-command.js145
-rw-r--r--devtools/shared/commands/inspected-window/moz.build10
-rw-r--r--devtools/shared/commands/inspected-window/tests/browser.ini20
-rw-r--r--devtools/shared/commands/inspected-window/tests/browser_webextension_inspected_window.js523
-rw-r--r--devtools/shared/commands/inspected-window/tests/browser_webextension_inspected_window_access.js315
-rw-r--r--devtools/shared/commands/inspected-window/tests/head.js12
-rw-r--r--devtools/shared/commands/inspected-window/tests/inspectedwindow-reload-target.sjs89
-rw-r--r--devtools/shared/commands/inspector/inspector-command.js483
-rw-r--r--devtools/shared/commands/inspector/moz.build10
-rw-r--r--devtools/shared/commands/inspector/tests/browser.ini15
-rw-r--r--devtools/shared/commands/inspector/tests/browser_inspector_command_findNodeFrontFromSelectors.js140
-rw-r--r--devtools/shared/commands/inspector/tests/browser_inspector_command_getNodeFrontSelectorsFromTopDocument.js119
-rw-r--r--devtools/shared/commands/inspector/tests/browser_inspector_command_getSuggestionsForQuery.js124
-rw-r--r--devtools/shared/commands/inspector/tests/browser_inspector_command_search.js98
-rw-r--r--devtools/shared/commands/inspector/tests/head.js14
-rw-r--r--devtools/shared/commands/moz.build20
-rw-r--r--devtools/shared/commands/network/moz.build10
-rw-r--r--devtools/shared/commands/network/network-command.js96
-rw-r--r--devtools/shared/commands/network/tests/browser.ini11
-rw-r--r--devtools/shared/commands/network/tests/browser_network_command_request_blocking.js61
-rw-r--r--devtools/shared/commands/network/tests/browser_network_command_sendHTTPRequest.js78
-rw-r--r--devtools/shared/commands/network/tests/head.js12
-rw-r--r--devtools/shared/commands/resource/legacy-listeners/console-messages.js59
-rw-r--r--devtools/shared/commands/resource/legacy-listeners/css-changes.js28
-rw-r--r--devtools/shared/commands/resource/legacy-listeners/error-messages.js62
-rw-r--r--devtools/shared/commands/resource/legacy-listeners/moz.build14
-rw-r--r--devtools/shared/commands/resource/legacy-listeners/platform-messages.js44
-rw-r--r--devtools/shared/commands/resource/legacy-listeners/reflow.js24
-rw-r--r--devtools/shared/commands/resource/legacy-listeners/root-node.js61
-rw-r--r--devtools/shared/commands/resource/legacy-listeners/source.js88
-rw-r--r--devtools/shared/commands/resource/legacy-listeners/thread-states.js81
-rw-r--r--devtools/shared/commands/resource/moz.build15
-rw-r--r--devtools/shared/commands/resource/resource-command.js1352
-rw-r--r--devtools/shared/commands/resource/tests/breakpoint_document.html21
-rw-r--r--devtools/shared/commands/resource/tests/browser.ini82
-rw-r--r--devtools/shared/commands/resource/tests/browser_browser_resources_console_messages.js87
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_clear_resources.js90
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_client_caching.js376
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_console_messages.js623
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_console_messages_navigation.js190
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_console_messages_workers.js257
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_css_changes.js151
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_css_messages.js210
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_document_events.js711
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_error_messages.js877
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_getAllResources.js124
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_invalid_api_usage.js76
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_last_private_context_exit.js94
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_network_event_stacktraces.js100
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_network_events.js316
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_network_events_cache.js236
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_network_events_navigation.js137
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_network_events_parent_process.js249
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_platform_messages.js158
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_reflows.js111
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_root_node.js125
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_scope_flag.js128
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_server_sent_events.js107
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_several_resources.js111
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_sources.js450
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_stylesheets.js557
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_stylesheets_import.js66
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_stylesheets_navigation.js257
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_stylesheets_nested_iframes.js34
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_target_destroy.js104
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_target_resources_race.js70
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_target_switching.js91
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_thread_states.js557
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_unwatch_early.js113
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_watch_unwatch_multiple.js88
-rw-r--r--devtools/shared/commands/resource/tests/browser_resources_websocket.js240
-rw-r--r--devtools/shared/commands/resource/tests/doc_console.html18
-rw-r--r--devtools/shared/commands/resource/tests/doc_console_iframe.html16
-rw-r--r--devtools/shared/commands/resource/tests/early_console_document.html14
-rw-r--r--devtools/shared/commands/resource/tests/empty.html11
-rw-r--r--devtools/shared/commands/resource/tests/fission_document.html23
-rw-r--r--devtools/shared/commands/resource/tests/fission_document_workers.html47
-rw-r--r--devtools/shared/commands/resource/tests/fission_iframe.html12
-rw-r--r--devtools/shared/commands/resource/tests/fission_iframe_workers.html29
-rw-r--r--devtools/shared/commands/resource/tests/head.js137
-rw-r--r--devtools/shared/commands/resource/tests/network_document.html13
-rw-r--r--devtools/shared/commands/resource/tests/network_document_navigation.html14
-rw-r--r--devtools/shared/commands/resource/tests/network_navigation.js1
-rw-r--r--devtools/shared/commands/resource/tests/service-worker-sources.js2
-rw-r--r--devtools/shared/commands/resource/tests/sources.html53
-rw-r--r--devtools/shared/commands/resource/tests/sources.js2
-rw-r--r--devtools/shared/commands/resource/tests/sse_backend.sjs8
-rw-r--r--devtools/shared/commands/resource/tests/sse_frontend.html31
-rw-r--r--devtools/shared/commands/resource/tests/sse_frontend_iframe.html29
-rw-r--r--devtools/shared/commands/resource/tests/style_document.css1
-rw-r--r--devtools/shared/commands/resource/tests/style_document.html22
-rw-r--r--devtools/shared/commands/resource/tests/style_iframe.css1
-rw-r--r--devtools/shared/commands/resource/tests/style_iframe.html15
-rw-r--r--devtools/shared/commands/resource/tests/stylesheets-nested-iframes.html27
-rw-r--r--devtools/shared/commands/resource/tests/test_image.pngbin0 -> 580 bytes
-rw-r--r--devtools/shared/commands/resource/tests/test_service_worker.js11
-rw-r--r--devtools/shared/commands/resource/tests/test_worker.js15
-rw-r--r--devtools/shared/commands/resource/tests/websocket_backend_wsh.py20
-rw-r--r--devtools/shared/commands/resource/tests/websocket_frontend.html45
-rw-r--r--devtools/shared/commands/resource/tests/websocket_frontend_iframe.html41
-rw-r--r--devtools/shared/commands/resource/tests/worker-sources.js2
-rw-r--r--devtools/shared/commands/resource/transformers/console-messages.js23
-rw-r--r--devtools/shared/commands/resource/transformers/error-messages.js31
-rw-r--r--devtools/shared/commands/resource/transformers/moz.build16
-rw-r--r--devtools/shared/commands/resource/transformers/network-events.js16
-rw-r--r--devtools/shared/commands/resource/transformers/storage-cache.js22
-rw-r--r--devtools/shared/commands/resource/transformers/storage-cookie.js26
-rw-r--r--devtools/shared/commands/resource/transformers/storage-extension.js26
-rw-r--r--devtools/shared/commands/resource/transformers/storage-indexed-db.js26
-rw-r--r--devtools/shared/commands/resource/transformers/storage-local-storage.js22
-rw-r--r--devtools/shared/commands/resource/transformers/storage-session-storage.js22
-rw-r--r--devtools/shared/commands/resource/transformers/thread-states.js32
-rw-r--r--devtools/shared/commands/root-resource/moz.build7
-rw-r--r--devtools/shared/commands/root-resource/root-resource-command.js348
-rw-r--r--devtools/shared/commands/script/moz.build10
-rw-r--r--devtools/shared/commands/script/script-command.js149
-rw-r--r--devtools/shared/commands/script/tests/browser.ini11
-rw-r--r--devtools/shared/commands/script/tests/browser_script_command_execute_basic.js1050
-rw-r--r--devtools/shared/commands/script/tests/browser_script_command_execute_document__proto__.js41
-rw-r--r--devtools/shared/commands/script/tests/browser_script_command_execute_last_result.js85
-rw-r--r--devtools/shared/commands/script/tests/browser_script_command_execute_throw.js75
-rw-r--r--devtools/shared/commands/script/tests/head.js51
-rw-r--r--devtools/shared/commands/target-configuration/moz.build10
-rw-r--r--devtools/shared/commands/target-configuration/target-configuration-command.js124
-rw-r--r--devtools/shared/commands/target-configuration/tests/browser.ini17
-rw-r--r--devtools/shared/commands/target-configuration/tests/browser_target_configuration_command.js79
-rw-r--r--devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_color_scheme.js183
-rw-r--r--devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_custom_user_agent.js309
-rw-r--r--devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_dppx.js187
-rw-r--r--devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_touch_events.js264
-rw-r--r--devtools/shared/commands/target-configuration/tests/head.js12
-rw-r--r--devtools/shared/commands/target-configuration/tests/target_configuration_test_doc.sjs101
-rw-r--r--devtools/shared/commands/target/actions/moz.build7
-rw-r--r--devtools/shared/commands/target/actions/targets.js33
-rw-r--r--devtools/shared/commands/target/legacy-target-watchers/legacy-processes-watcher.js72
-rw-r--r--devtools/shared/commands/target/legacy-target-watchers/legacy-serviceworkers-watcher.js316
-rw-r--r--devtools/shared/commands/target/legacy-target-watchers/legacy-sharedworkers-watcher.js19
-rw-r--r--devtools/shared/commands/target/legacy-target-watchers/legacy-workers-watcher.js238
-rw-r--r--devtools/shared/commands/target/legacy-target-watchers/moz.build10
-rw-r--r--devtools/shared/commands/target/moz.build17
-rw-r--r--devtools/shared/commands/target/reducers/moz.build7
-rw-r--r--devtools/shared/commands/target/reducers/targets.js70
-rw-r--r--devtools/shared/commands/target/selectors/moz.build7
-rw-r--r--devtools/shared/commands/target/selectors/targets.js20
-rw-r--r--devtools/shared/commands/target/target-command.js1173
-rw-r--r--devtools/shared/commands/target/tests/browser.ini48
-rw-r--r--devtools/shared/commands/target/tests/browser_target_command_bfcache.js499
-rw-r--r--devtools/shared/commands/target/tests/browser_target_command_browser_workers.js246
-rw-r--r--devtools/shared/commands/target/tests/browser_target_command_detach.js59
-rw-r--r--devtools/shared/commands/target/tests/browser_target_command_frames.js649
-rw-r--r--devtools/shared/commands/target/tests/browser_target_command_frames_popups.js168
-rw-r--r--devtools/shared/commands/target/tests/browser_target_command_frames_reload_server_side_targets.js104
-rw-r--r--devtools/shared/commands/target/tests/browser_target_command_getAllTargets.js119
-rw-r--r--devtools/shared/commands/target/tests/browser_target_command_invalid_api_usage.js78
-rw-r--r--devtools/shared/commands/target/tests/browser_target_command_processes.js242
-rw-r--r--devtools/shared/commands/target/tests/browser_target_command_reload.js115
-rw-r--r--devtools/shared/commands/target/tests/browser_target_command_scope_flag.js190
-rw-r--r--devtools/shared/commands/target/tests/browser_target_command_service_workers.js77
-rw-r--r--devtools/shared/commands/target/tests/browser_target_command_service_workers_navigation.js389
-rw-r--r--devtools/shared/commands/target/tests/browser_target_command_switchToTarget.js138
-rw-r--r--devtools/shared/commands/target/tests/browser_target_command_tab_workers.js322
-rw-r--r--devtools/shared/commands/target/tests/browser_target_command_tab_workers_bfcache_navigation.js134
-rw-r--r--devtools/shared/commands/target/tests/browser_target_command_various_descriptors.js283
-rw-r--r--devtools/shared/commands/target/tests/browser_target_command_watchTargets.js214
-rw-r--r--devtools/shared/commands/target/tests/browser_watcher_actor_getter_caching.js87
-rw-r--r--devtools/shared/commands/target/tests/fission_document.html47
-rw-r--r--devtools/shared/commands/target/tests/fission_iframe.html29
-rw-r--r--devtools/shared/commands/target/tests/head.js32
-rw-r--r--devtools/shared/commands/target/tests/incremental-js-value-script.sjs23
-rw-r--r--devtools/shared/commands/target/tests/simple_document.html12
-rw-r--r--devtools/shared/commands/target/tests/test_service_worker.js11
-rw-r--r--devtools/shared/commands/target/tests/test_sw_page.html19
-rw-r--r--devtools/shared/commands/target/tests/test_sw_page_worker.js5
-rw-r--r--devtools/shared/commands/target/tests/test_worker.js13
-rw-r--r--devtools/shared/commands/thread-configuration/moz.build7
-rw-r--r--devtools/shared/commands/thread-configuration/tests/browser.ini7
-rw-r--r--devtools/shared/commands/thread-configuration/tests/head.js12
-rw-r--r--devtools/shared/commands/thread-configuration/thread-configuration-command.js72
181 files changed, 23503 insertions, 0 deletions
diff --git a/devtools/shared/commands/README.md b/devtools/shared/commands/README.md
new file mode 100644
index 0000000000..3b05c765ab
--- /dev/null
+++ b/devtools/shared/commands/README.md
@@ -0,0 +1,44 @@
+# Commands
+
+Commands are singletons, which can be easily used by any frontend code.
+They are meant to be exposed widely to the frontend so that any code can easily call any of their methods.
+
+Commands classes expose static methods, which:
+* route to the right Front/Actor's method
+* handle backward compatibility
+* map to many target's actor if needed
+
+These classes are instantiated once per descriptor
+and may have inner state, emit events, fire callbacks,...
+
+A transient backward compat need, required by Fission refactorings will be to have some code checking a trait, and either:
+* call a single method on a parent process actor (like BreakpointListActor.setBreakpoint)
+* otherwise, call a method on each target's scoped actor (like ThreadActor.setBreakpoint, that, for each available target)
+
+Without such layer, we would have to put such code here and there in the frontend code.
+This will be harder to remove later, once we get rid of old pre-fission-refactoring codepaths.
+
+This layer already exists in some panels, but we are using slightly different names and practices:
+* Debugger uses "client" (devtools/client/debugger/src/client/) and "commands" (devtools/client/debugger/src/client/firefox/commands.js)
+ Debugger's commands already bundle the code to dispatch an action to many target's actor.
+ They also contain some backward compat code.
+ Today, we pass around a `client` object via thunkArgs, which is mapped to commands.js,
+ instead we could pass a debugger command object.
+* Network Monitor uses "connector" (devtools/client/netmonitor/src/connector)
+ Connectors also bundles backward compat and dispatch to many target's actor.
+ Today, we pass the `connector` to all middlewares from configureStore,
+ we could instead pass the netmonitor command object.
+* Web Console has:
+ * devtools/client/webconsole/actions/input.js:handleHelperResult(), where we have to put some code, which is a duplicate of Netmonitor Connector,
+ and could be shared via a netmonitor command class.
+* Inspector is probably the panel doing the most dispatch to many target's actor.
+ Codes using getAllInspectorFronts could all be migrated to an inspector command class:
+ https://searchfox.org/mozilla-central/search?q=symbol:%23getAllInspectorFronts&redirect=false
+ and simplify a bit the frontend.
+ It is also one panel, which still register listener to each target's inspector/walker fronts.
+ Because inspector isn't using resources.
+ But this work, registering listeners for each target might be done by such layer and translate the many actor's event into a unified one.
+
+Last, but not least, this layer may allow us to slowly get rid of protocol.js.
+Command classes aren't Fronts, nor are they particularly connected to protocol.js.
+If we make it so that all the Frontend code using Fronts uses Commands instead, we might more easily get away from protocol.js.
diff --git a/devtools/shared/commands/commands-factory.js b/devtools/shared/commands/commands-factory.js
new file mode 100644
index 0000000000..257f50ce2f
--- /dev/null
+++ b/devtools/shared/commands/commands-factory.js
@@ -0,0 +1,245 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const {
+ createCommandsDictionary,
+} = require("resource://devtools/shared/commands/index.js");
+const { DevToolsLoader } = ChromeUtils.importESModule(
+ "resource://devtools/shared/loader/Loader.sys.mjs"
+);
+loader.lazyRequireGetter(
+ this,
+ "DevToolsServer",
+ "resource://devtools/server/devtools-server.js",
+ true
+);
+// eslint-disable-next-line mozilla/reject-some-requires
+loader.lazyRequireGetter(
+ this,
+ "DevToolsClient",
+ "resource://devtools/client/devtools-client.js",
+ true
+);
+
+/**
+ * Functions for creating Commands for all debuggable contexts.
+ *
+ * All methods of this `CommandsFactory` object receive argument to describe to
+ * which particular context we want to debug. And all returns a new instance of `commands` object.
+ * Commands are implemented by modules defined in devtools/shared/commands.
+ */
+exports.CommandsFactory = {
+ /**
+ * Create commands for a given local tab.
+ *
+ * @param {Tab} tab: A local Firefox tab, running in this process.
+ * @param {Object} options
+ * @param {DevToolsClient} options.client: An optional DevToolsClient. If none is passed,
+ * a new one will be created.
+ * @param {DevToolsClient} options.isWebExtension: An optional boolean to flag commands
+ * that are created for the WebExtension codebase.
+ * @returns {Object} Commands
+ */
+ async forTab(tab, { client, isWebExtension } = {}) {
+ if (!client) {
+ client = await createLocalClient();
+ }
+
+ const descriptor = await client.mainRoot.getTab({ tab, isWebExtension });
+ descriptor.doNotAttachThreadActor = isWebExtension;
+ const commands = await createCommandsDictionary(descriptor);
+ return commands;
+ },
+
+ /**
+ * Chrome mochitest don't have access to any "tab",
+ * so that the only way to attach to a fake tab is call RootFront.getTab
+ * without any argument.
+ */
+ async forCurrentTabInChromeMochitest() {
+ const client = await createLocalClient();
+ const descriptor = await client.mainRoot.getTab();
+ const commands = await createCommandsDictionary(descriptor);
+ return commands;
+ },
+
+ /**
+ * Create commands for the main process.
+ *
+ * @param {Object} options
+ * @param {DevToolsClient} options.client: An optional DevToolsClient. If none is passed,
+ * a new one will be created.
+ * @returns {Object} Commands
+ */
+ async forMainProcess({ client } = {}) {
+ if (!client) {
+ client = await createLocalClient();
+ }
+
+ const descriptor = await client.mainRoot.getMainProcess();
+ const commands = await createCommandsDictionary(descriptor);
+ return commands;
+ },
+
+ /**
+ * Create commands for a given remote tab.
+ *
+ * Note that it can also be used for local tab, but isLocalTab attribute
+ * on commands.descriptorFront will be false.
+ *
+ * @param {Number} browserId: Identify which tab we should create commands for.
+ * @param {Object} options
+ * @param {DevToolsClient} options.client: An optional DevToolsClient. If none is passed,
+ * a new one will be created.
+ * @returns {Object} Commands
+ */
+ async forRemoteTab(browserId, { client } = {}) {
+ if (!client) {
+ client = await createLocalClient();
+ }
+
+ const descriptor = await client.mainRoot.getTab({ browserId });
+ const commands = await createCommandsDictionary(descriptor);
+ return commands;
+ },
+
+ /**
+ * Create commands for a given main process worker.
+ *
+ * @param {String} id: WorkerDebugger's id, which is a unique ID computed by the platform code.
+ * These ids are exposed via WorkerDescriptor's id attributes.
+ * WorkerDescriptors can be retrieved via MainFront.listAllWorkers()/listWorkers().
+ * @param {Object} options
+ * @param {DevToolsClient} options.client: An optional DevToolsClient. If none is passed,
+ * a new one will be created.
+ * @returns {Object} Commands
+ */
+ async forWorker(id, { client } = {}) {
+ if (!client) {
+ client = await createLocalClient();
+ }
+
+ const descriptor = await client.mainRoot.getWorker(id);
+ const commands = await createCommandsDictionary(descriptor);
+ return commands;
+ },
+
+ /**
+ * Create commands for a Web Extension.
+ *
+ * @param {String} id The Web Extension ID to debug.
+ * @param {Object} options
+ * @param {DevToolsClient} options.client: An optional DevToolsClient. If none is passed,
+ * a new one will be created.
+ * @returns {Object} Commands
+ */
+ async forAddon(id, { client } = {}) {
+ if (!client) {
+ client = await createLocalClient();
+ }
+
+ const descriptor = await client.mainRoot.getAddon({ id });
+ const commands = await createCommandsDictionary(descriptor);
+ return commands;
+ },
+
+ /**
+ * This method will spawn a special `DevToolsClient`
+ * which is meant to debug the same Firefox instance
+ * and especially be able to debug chrome code.
+ * The chrome code typically runs in the system principal.
+ * This principal is a singleton which is shared among most Firefox internal codebase
+ * (JSM, privileged html documents, JS-XPCOM,...)
+ * In order to be able to debug these script we need to connect to a special DevToolsServer
+ * that runs in a dedicated and distinct system principal which is different from
+ * the one shared with the rest of Firefox frontend codebase.
+ */
+ async spawnClientToDebugSystemPrincipal() {
+ // The Browser console ends up using the debugger in autocomplete.
+ // Because the debugger can't be running in the same compartment than its debuggee,
+ // we have to load the server in a dedicated Loader, flagged with
+ // `freshCompartment`, which will force it to be loaded in another compartment.
+ // We aren't using `invisibleToDebugger` in order to allow the Browser toolbox to
+ // debug the Browser console. This is fine as they will spawn distinct Loaders and
+ // so distinct `DevToolsServer` and actor modules.
+ const customLoader = new DevToolsLoader({
+ freshCompartment: true,
+ });
+ const { DevToolsServer: customDevToolsServer } = customLoader.require(
+ "resource://devtools/server/devtools-server.js"
+ );
+
+ customDevToolsServer.init();
+
+ // We want all the actors (root, browser and target-scoped) to be registered on the
+ // DevToolsServer. This is needed so the Browser Console can retrieve:
+ // - the console actors, which are target-scoped (See Bug 1416105)
+ // - the screenshotActor, which is browser-scoped (for the `:screenshot` command)
+ customDevToolsServer.registerAllActors();
+
+ customDevToolsServer.allowChromeProcess = true;
+
+ const client = new DevToolsClient(customDevToolsServer.connectPipe());
+ await client.connect();
+
+ return client;
+ },
+
+ /**
+ * One method to handle the whole setup sequence to connect to RDP backend for the Browser Console.
+ *
+ * This will instantiate a special DevTools module loader for the DevToolsServer.
+ * Then spawn a DevToolsClient to connect to it.
+ * Get a Main Process Descriptor from it.
+ * Finally spawn a commands object for this descriptor.
+ */
+ async forBrowserConsole() {
+ // The Browser console ends up using the debugger in autocomplete.
+ // Because the debugger can't be running in the same compartment than its debuggee,
+ // we have to load the server in a dedicated Loader and so spawn a special client
+ const client = await this.spawnClientToDebugSystemPrincipal();
+
+ const descriptor = await client.mainRoot.getMainProcess();
+
+ descriptor.doNotAttachThreadActor = true;
+
+ // Force fetching the first top level target right away.
+ await descriptor.getTarget();
+
+ const commands = await createCommandsDictionary(descriptor);
+ return commands;
+ },
+};
+
+async function createLocalClient() {
+ // Make sure the DevTools server is started.
+ ensureDevToolsServerInitialized();
+
+ // Create the client and connect it to the local server.
+ const client = new DevToolsClient(DevToolsServer.connectPipe());
+ await client.connect();
+
+ return client;
+}
+// Also expose this method for tests which would like to create a client
+// without involving commands. This would typically be tests against the Watcher actor
+// and requires to prevent having TargetCommand from running.
+// Or tests which are covering RootFront or global actor's fronts.
+exports.createLocalClientForTests = createLocalClient;
+
+function ensureDevToolsServerInitialized() {
+ // Since a remote protocol connection will be made, let's start the
+ // DevToolsServer here, once and for all tools.
+ DevToolsServer.init();
+
+ // Enable all the actors. We may not need all of them and registering
+ // only root and target might be enough
+ DevToolsServer.registerAllActors();
+
+ // Enable being able to get child process actors
+ // Same, this might not be useful
+ DevToolsServer.allowChromeProcess = true;
+}
diff --git a/devtools/shared/commands/index.js b/devtools/shared/commands/index.js
new file mode 100644
index 0000000000..94cf7717cb
--- /dev/null
+++ b/devtools/shared/commands/index.js
@@ -0,0 +1,133 @@
+/* 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";
+
+// List of all command modules
+// (please try to keep the list alphabetically sorted)
+/* eslint sort-keys: "error" */
+/* eslint-enable sort-keys */
+const Commands = {
+ inspectedWindowCommand:
+ "devtools/shared/commands/inspected-window/inspected-window-command",
+ inspectorCommand: "devtools/shared/commands/inspector/inspector-command",
+ networkCommand: "devtools/shared/commands/network/network-command",
+ resourceCommand: "devtools/shared/commands/resource/resource-command",
+ rootResourceCommand:
+ "devtools/shared/commands/root-resource/root-resource-command",
+ scriptCommand: "devtools/shared/commands/script/script-command",
+ targetCommand: "devtools/shared/commands/target/target-command",
+ targetConfigurationCommand:
+ "devtools/shared/commands/target-configuration/target-configuration-command",
+ threadConfigurationCommand:
+ "devtools/shared/commands/thread-configuration/thread-configuration-command",
+};
+/* eslint-disable sort-keys */
+
+/**
+ * For a given descriptor and its related Targets, already initialized,
+ * return the dictionary with all command instances.
+ * This dictionary is lazy and commands will be loaded and instanciated on-demand.
+ */
+async function createCommandsDictionary(descriptorFront) {
+ // Bug 1675763: Watcher actor is not available in all situations yet.
+ let watcherFront;
+ const supportsWatcher = descriptorFront.traits?.watcher;
+ if (supportsWatcher) {
+ watcherFront = await descriptorFront.getWatcher();
+ }
+ const { client } = descriptorFront;
+
+ const allInstantiatedCommands = new Set();
+
+ const dictionary = {
+ // Expose both client and descriptor for legacy codebases, or tests.
+ // But ideally only commands should interact with these two objects
+ client,
+ descriptorFront,
+ watcherFront,
+
+ // Expose for tests
+ waitForRequestsToSettle() {
+ return descriptorFront.client.waitForRequestsToSettle();
+ },
+
+ // Boolean flag to know if the DevtoolsClient should be closed
+ // when this commands happens to be destroyed.
+ // This is set by:
+ // * commands-from-url in case we are opening a toolbox
+ // with a dedicated DevToolsClient (mostly from about:debugging, when the client isn't "cached").
+ // * CommandsFactory, when we are connecting to a local tab and expect
+ // the client, toolbox and descriptor to all follow the same lifecycle.
+ shouldCloseClient: true,
+
+ /**
+ * Destroy the commands which will destroy:
+ * - all inner commands,
+ * - the related descriptor,
+ * - the related DevToolsClient (not always)
+ */
+ async destroy() {
+ descriptorFront.off("descriptor-destroyed", this.destroy);
+
+ // Destroy all inner command modules
+ for (const command of allInstantiatedCommands) {
+ if (typeof command.destroy == "function") {
+ command.destroy();
+ }
+ }
+ allInstantiatedCommands.clear();
+
+ // Destroy the descriptor front, and all its children fronts.
+ // Watcher, targets,...
+ //
+ // Note that DescriptorFront.destroy will be null because of Pool.destroy
+ // when this function is called while the descriptor front itself is being
+ // destroyed.
+ if (!descriptorFront.isDestroyed()) {
+ await descriptorFront.destroy();
+ }
+
+ // Close the DevToolsClient. Shutting down the connection
+ // to the debuggable context and its DevToolsServer.
+ //
+ // See shouldCloseClient jsdoc about this condition.
+ if (this.shouldCloseClient) {
+ await client.close();
+ }
+ },
+ };
+ dictionary.destroy = dictionary.destroy.bind(dictionary);
+
+ // Automatically destroy the commands object if the descriptor
+ // happens to be destroyed. Which means that the debuggable context
+ // is no longer debuggable.
+ descriptorFront.on("descriptor-destroyed", dictionary.destroy);
+
+ for (const name in Commands) {
+ loader.lazyGetter(dictionary, name, () => {
+ const Constructor = require(Commands[name]);
+ const command = new Constructor({
+ // Commands can use other commands
+ commands: dictionary,
+
+ // The context to inspect identified by this descriptor
+ descriptorFront,
+
+ // The front for the Watcher Actor, related to the given descriptor
+ // This is a key actor to watch for targets and resources and pull global actors running in the parent process
+ watcherFront,
+
+ // From here, we could pass DevToolsClient, or any useful protocol classes...
+ // so that we abstract where and how to fetch all necessary interfaces
+ // and avoid having to know that you might pull the client via descriptorFront.client
+ });
+ allInstantiatedCommands.add(command);
+ return command;
+ });
+ }
+
+ return dictionary;
+}
+exports.createCommandsDictionary = createCommandsDictionary;
diff --git a/devtools/shared/commands/inspected-window/inspected-window-command.js b/devtools/shared/commands/inspected-window/inspected-window-command.js
new file mode 100644
index 0000000000..0d15016ebd
--- /dev/null
+++ b/devtools/shared/commands/inspected-window/inspected-window-command.js
@@ -0,0 +1,145 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const {
+ getAdHocFrontOrPrimitiveGrip,
+ // eslint-disable-next-line mozilla/reject-some-requires
+} = require("resource://devtools/client/fronts/object.js");
+
+/**
+ * For now, this class is mostly a wrapper around webExtInspectedWindow actor.
+ */
+class InspectedWindowCommand {
+ constructor({ commands }) {
+ this.commands = commands;
+ }
+
+ /**
+ * Return a promise that resolves to the related target actor's front.
+ * The Web Extension inspected window actor.
+ *
+ * @return {Promise<WebExtensionInspectedWindowFront>}
+ */
+ getFront() {
+ return this.commands.targetCommand.targetFront.getFront(
+ "webExtensionInspectedWindow"
+ );
+ }
+
+ /**
+ * Evaluate the provided javascript code in a target window.
+ *
+ * @param {Object} webExtensionCallerInfo - The addonId and the url (the addon base url
+ * or the url of the actual caller filename and lineNumber) used to log useful
+ * debugging information in the produced error logs and eval stack trace.
+ * @param {String} expression - The expression to evaluate.
+ * @param {Object} options - An option object. Check the actor method definition to see
+ * what properties it can hold (minus the `consoleFront` property which is defined
+ * below).
+ * @param {WebConsoleFront} options.consoleFront - An optional webconsole front. When
+ * set, the result will be either a primitive, a LongStringFront or an
+ * ObjectFront, and the WebConsoleActor corresponding to the console front will
+ * be used to generate those, which is needed if we want to handle ObjectFronts
+ * on the client.
+ */
+ async eval(webExtensionCallerInfo, expression, options = {}) {
+ const { consoleFront } = options;
+
+ if (consoleFront) {
+ options.evalResultAsGrip = true;
+ options.toolboxConsoleActorID = consoleFront.actor;
+ delete options.consoleFront;
+ }
+
+ const front = await this.getFront();
+ const response = await front.eval(
+ webExtensionCallerInfo,
+ expression,
+ options
+ );
+
+ // If no consoleFront was provided, we can directly return the response.
+ if (!consoleFront) {
+ return response;
+ }
+
+ if (
+ !response.hasOwnProperty("exceptionInfo") &&
+ !response.hasOwnProperty("valueGrip")
+ ) {
+ throw new Error(
+ "Response does not have `exceptionInfo` or `valueGrip` property"
+ );
+ }
+
+ if (response.exceptionInfo) {
+ console.error(
+ response.exceptionInfo.description,
+ ...(response.exceptionInfo.details || [])
+ );
+ return response;
+ }
+
+ // On the server, the valueGrip is created from the toolbox webconsole actor.
+ // If we want since the ObjectFront connection is inherited from the parent front, we
+ // need to set the console front as the parent front.
+ return getAdHocFrontOrPrimitiveGrip(
+ response.valueGrip,
+ consoleFront || this
+ );
+ }
+
+ /**
+ * Reload the target tab, optionally bypass cache, customize the userAgent and/or
+ * inject a script in targeted document or any of its sub-frame.
+ *
+ * @param {WebExtensionCallerInfo} callerInfo
+ * the addonId and the url (the addon base url or the url of the actual caller
+ * filename and lineNumber) used to log useful debugging information in the
+ * produced error logs and eval stack trace.
+ * @param {Object} options
+ * @param {boolean|undefined} options.ignoreCache
+ * Enable/disable the cache bypass headers.
+ * @param {string|undefined} options.injectedScript
+ * Evaluate the provided javascript code in the top level and every sub-frame
+ * created during the page reload, before any other script in the page has been
+ * executed.
+ * @param {string|undefined} options.userAgent
+ * Customize the userAgent during the page reload.
+ * @returns {Promise} A promise that resolves once the page is done loading when userAgent
+ * or injectedScript option are passed. If those options are not provided, the
+ * Promise will resolve after the reload was initiated.
+ */
+ async reload(callerInfo, options = {}) {
+ if (this._reloadPending) {
+ return null;
+ }
+
+ this._reloadPending = true;
+
+ try {
+ // We always want to update the target configuration to set the user agent if one is
+ // passed, or to reset a potential existing override if userAgent isn't defined.
+ await this.commands.targetConfigurationCommand.updateConfiguration({
+ customUserAgent: options.userAgent,
+ });
+
+ const front = await this.getFront();
+ const result = await front.reload(callerInfo, options);
+ this._reloadPending = false;
+
+ return result;
+ } catch (e) {
+ this._reloadPending = false;
+ console.error(e);
+ return Promise.reject({
+ message: "An unexpected error occurred",
+ });
+ }
+ }
+}
+
+module.exports = InspectedWindowCommand;
diff --git a/devtools/shared/commands/inspected-window/moz.build b/devtools/shared/commands/inspected-window/moz.build
new file mode 100644
index 0000000000..2cf831d7b0
--- /dev/null
+++ b/devtools/shared/commands/inspected-window/moz.build
@@ -0,0 +1,10 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DevToolsModules(
+ "inspected-window-command.js",
+)
+
+if CONFIG["MOZ_BUILD_APP"] != "mobile/android":
+ BROWSER_CHROME_MANIFESTS += ["tests/browser.ini"]
diff --git a/devtools/shared/commands/inspected-window/tests/browser.ini b/devtools/shared/commands/inspected-window/tests/browser.ini
new file mode 100644
index 0000000000..d3b5ce5fbc
--- /dev/null
+++ b/devtools/shared/commands/inspected-window/tests/browser.ini
@@ -0,0 +1,20 @@
+[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
+ inspectedwindow-reload-target.sjs
+
+prefs =
+ # restrictedDomains must be set as early as possible, before the first use of
+ # the preference. browser_webextension_inspected_window_access.js relies on
+ # this pref to be set. We cannot use "prefs =" at the individual file, because
+ # another test in this manifest may already have resulted in browser startup.
+ extensions.webextensions.restrictedDomains=test2.example.com
+
+[browser_webextension_inspected_window.js]
+[browser_webextension_inspected_window_access.js]
+skip-if = http3 \ No newline at end of file
diff --git a/devtools/shared/commands/inspected-window/tests/browser_webextension_inspected_window.js b/devtools/shared/commands/inspected-window/tests/browser_webextension_inspected_window.js
new file mode 100644
index 0000000000..bf2b752e4d
--- /dev/null
+++ b/devtools/shared/commands/inspected-window/tests/browser_webextension_inspected_window.js
@@ -0,0 +1,523 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_RELOAD_URL = `${URL_ROOT_SSL}/inspectedwindow-reload-target.sjs`;
+
+async function setup(pageUrl) {
+ // Disable bfcache for Fission for now.
+ // If Fission is disabled, the pref is no-op.
+ await SpecialPowers.pushPrefEnv({
+ set: [["fission.bfcacheInParent", false]],
+ });
+
+ const extension = ExtensionTestUtils.loadExtension({
+ background() {
+ // This is just an empty extension used to ensure that the caller extension uuid
+ // actually exists.
+ },
+ });
+
+ await extension.startup();
+
+ const fakeExtCallerInfo = {
+ url: WebExtensionPolicy.getByID(extension.id).getURL(
+ "fake-caller-script.js"
+ ),
+ lineNumber: 1,
+ addonId: extension.id,
+ };
+
+ const tab = await addTab(pageUrl);
+
+ const commands = await CommandsFactory.forTab(tab, { isWebExtension: true });
+ await commands.targetCommand.startListening();
+
+ const webConsoleFront = await commands.targetCommand.targetFront.getFront(
+ "console"
+ );
+
+ return {
+ webConsoleFront,
+ commands,
+ extension,
+ fakeExtCallerInfo,
+ };
+}
+
+async function teardown({ commands, extension }) {
+ await commands.destroy();
+ gBrowser.removeCurrentTab();
+ await extension.unload();
+}
+
+function waitForNextTabNavigated(commands) {
+ const target = commands.targetCommand.targetFront;
+ return new Promise(resolve => {
+ target.on("tabNavigated", function tabNavigatedListener(pkt) {
+ if (pkt.state == "stop" && !pkt.isFrameSwitching) {
+ target.off("tabNavigated", tabNavigatedListener);
+ resolve();
+ }
+ });
+ });
+}
+
+// Script used as the injectedScript option in the inspectedWindow.reload tests.
+function injectedScript() {
+ if (!window.pageScriptExecutedFirst) {
+ window.addEventListener(
+ "DOMContentLoaded",
+ function () {
+ if (document.querySelector("pre")) {
+ document.querySelector("pre").textContent =
+ "injected script executed first";
+ }
+ },
+ { once: true }
+ );
+ }
+}
+
+// Script evaluated in the target tab, to collect the results of injectedScript
+// evaluation in the inspectedWindow.reload tests.
+function collectEvalResults() {
+ const results = [];
+ let iframeDoc = document;
+
+ while (iframeDoc) {
+ if (iframeDoc.querySelector("pre")) {
+ results.push(iframeDoc.querySelector("pre").textContent);
+ }
+ const iframe = iframeDoc.querySelector("iframe");
+ iframeDoc = iframe ? iframe.contentDocument : null;
+ }
+ return JSON.stringify(results);
+}
+
+add_task(async function test_successfull_inspectedWindowEval_result() {
+ const { commands, extension, fakeExtCallerInfo } = await setup(URL_ROOT_SSL);
+
+ const result = await commands.inspectedWindowCommand.eval(
+ fakeExtCallerInfo,
+ "window.location",
+ {}
+ );
+
+ ok(result.value, "Got a result from inspectedWindow eval");
+ is(
+ result.value.href,
+ URL_ROOT_SSL,
+ "Got the expected window.location.href property value"
+ );
+ is(
+ result.value.protocol,
+ "https:",
+ "Got the expected window.location.protocol property value"
+ );
+
+ await teardown({ commands, extension });
+});
+
+add_task(async function test_successfull_inspectedWindowEval_resultAsGrip() {
+ const { commands, extension, fakeExtCallerInfo, webConsoleFront } =
+ await setup(URL_ROOT_SSL);
+
+ let result = await commands.inspectedWindowCommand.eval(
+ fakeExtCallerInfo,
+ "window",
+ {
+ evalResultAsGrip: true,
+ toolboxConsoleActorID: webConsoleFront.actor,
+ }
+ );
+
+ ok(result.valueGrip, "Got a result from inspectedWindow eval");
+ ok(result.valueGrip.actor, "Got a object actor as expected");
+ is(result.valueGrip.type, "object", "Got a value grip of type object");
+ is(
+ result.valueGrip.class,
+ "Window",
+ "Got a value grip which is instanceof Location"
+ );
+
+ // Test invalid evalResultAsGrip request.
+ result = await commands.inspectedWindowCommand.eval(
+ fakeExtCallerInfo,
+ "window",
+ {
+ evalResultAsGrip: true,
+ }
+ );
+
+ ok(
+ !result.value && !result.valueGrip,
+ "Got a null result from the invalid inspectedWindow eval call"
+ );
+ ok(
+ result.exceptionInfo.isError,
+ "Got an API Error result from inspectedWindow eval"
+ );
+ ok(
+ !result.exceptionInfo.isException,
+ "An error isException is false as expected"
+ );
+ is(
+ result.exceptionInfo.code,
+ "E_PROTOCOLERROR",
+ "Got the expected 'code' property in the error result"
+ );
+ is(
+ result.exceptionInfo.description,
+ "Inspector protocol error: %s - %s",
+ "Got the expected 'description' property in the error result"
+ );
+ is(
+ result.exceptionInfo.details.length,
+ 2,
+ "The 'details' array property should contains 1 element"
+ );
+ is(
+ result.exceptionInfo.details[0],
+ "Unexpected invalid sidebar panel expression request",
+ "Got the expected content in the error results's details"
+ );
+ is(
+ result.exceptionInfo.details[1],
+ "missing toolboxConsoleActorID",
+ "Got the expected content in the error results's details"
+ );
+
+ await teardown({ commands, extension });
+});
+
+add_task(async function test_error_inspectedWindowEval_result() {
+ const { commands, extension, fakeExtCallerInfo } = await setup(URL_ROOT_SSL);
+
+ const result = await commands.inspectedWindowCommand.eval(
+ fakeExtCallerInfo,
+ "window",
+ {}
+ );
+
+ ok(!result.value, "Got a null result from inspectedWindow eval");
+ ok(
+ result.exceptionInfo.isError,
+ "Got an API Error result from inspectedWindow eval"
+ );
+ ok(
+ !result.exceptionInfo.isException,
+ "An error isException is false as expected"
+ );
+ is(
+ result.exceptionInfo.code,
+ "E_PROTOCOLERROR",
+ "Got the expected 'code' property in the error result"
+ );
+ is(
+ result.exceptionInfo.description,
+ "Inspector protocol error: %s",
+ "Got the expected 'description' property in the error result"
+ );
+ is(
+ result.exceptionInfo.details.length,
+ 1,
+ "The 'details' array property should contains 1 element"
+ );
+ ok(
+ result.exceptionInfo.details[0].includes("cyclic object value"),
+ "Got the expected content in the error results's details"
+ );
+
+ await teardown({ commands, extension });
+});
+
+add_task(async function test_exception_inspectedWindowEval_result() {
+ const { commands, extension, fakeExtCallerInfo } = await setup(URL_ROOT_SSL);
+
+ const result = await commands.inspectedWindowCommand.eval(
+ fakeExtCallerInfo,
+ "throw Error('fake eval error');",
+ {}
+ );
+
+ ok(result.exceptionInfo.isException, "Got an exception as expected");
+ ok(!result.value, "Got an undefined eval value");
+ ok(!result.exceptionInfo.isError, "An exception should not be isError=true");
+ ok(
+ result.exceptionInfo.value.includes("Error: fake eval error"),
+ "Got the expected exception message"
+ );
+
+ const expectedCallerInfo = `called from ${fakeExtCallerInfo.url}:${fakeExtCallerInfo.lineNumber}`;
+ ok(
+ result.exceptionInfo.value.includes(expectedCallerInfo),
+ "Got the expected caller info in the exception message"
+ );
+
+ const expectedStack = `eval code:1:7`;
+ ok(
+ result.exceptionInfo.value.includes(expectedStack),
+ "Got the expected stack trace in the exception message"
+ );
+
+ await teardown({ commands, extension });
+});
+
+add_task(async function test_exception_inspectedWindowReload() {
+ const { commands, extension, fakeExtCallerInfo } = await setup(
+ `${TEST_RELOAD_URL}?test=cache`
+ );
+
+ // Test reload with bypassCache=false.
+
+ const waitForNoBypassCacheReload = waitForNextTabNavigated(commands);
+ const reloadResult = await commands.inspectedWindowCommand.reload(
+ fakeExtCallerInfo,
+ {
+ ignoreCache: false,
+ }
+ );
+
+ ok(
+ !reloadResult,
+ "Got the expected undefined result from inspectedWindow reload"
+ );
+
+ await waitForNoBypassCacheReload;
+
+ const noBypassCacheEval = await commands.scriptCommand.execute(
+ "document.body.textContent"
+ );
+
+ is(
+ noBypassCacheEval.result,
+ "empty cache headers",
+ "Got the expected result with reload forceBypassCache=false"
+ );
+
+ // Test reload with bypassCache=true.
+
+ const waitForForceBypassCacheReload = waitForNextTabNavigated(commands);
+ await commands.inspectedWindowCommand.reload(fakeExtCallerInfo, {
+ ignoreCache: true,
+ });
+
+ await waitForForceBypassCacheReload;
+
+ const forceBypassCacheEval = await commands.scriptCommand.execute(
+ "document.body.textContent"
+ );
+
+ is(
+ forceBypassCacheEval.result,
+ "no-cache:no-cache",
+ "Got the expected result with reload forceBypassCache=true"
+ );
+
+ await teardown({ commands, extension });
+});
+
+add_task(async function test_exception_inspectedWindowReload_customUserAgent() {
+ const { commands, extension, fakeExtCallerInfo } = await setup(
+ `${TEST_RELOAD_URL}?test=user-agent`
+ );
+
+ // Test reload with custom userAgent.
+
+ const waitForCustomUserAgentReload = waitForNextTabNavigated(commands);
+ await commands.inspectedWindowCommand.reload(fakeExtCallerInfo, {
+ userAgent: "Customized User Agent",
+ });
+
+ await waitForCustomUserAgentReload;
+
+ const customUserAgentEval = await commands.scriptCommand.execute(
+ "document.body.textContent"
+ );
+
+ is(
+ customUserAgentEval.result,
+ "Customized User Agent",
+ "Got the expected result on reload with a customized userAgent"
+ );
+
+ // Test reload with no custom userAgent.
+
+ const waitForNoCustomUserAgentReload = waitForNextTabNavigated(commands);
+ await commands.inspectedWindowCommand.reload(fakeExtCallerInfo, {});
+
+ await waitForNoCustomUserAgentReload;
+
+ const noCustomUserAgentEval = await commands.scriptCommand.execute(
+ "document.body.textContent"
+ );
+
+ is(
+ noCustomUserAgentEval.result,
+ window.navigator.userAgent,
+ "Got the expected result with reload without a customized userAgent"
+ );
+
+ await teardown({ commands, extension });
+});
+
+add_task(async function test_exception_inspectedWindowReload_injectedScript() {
+ const { commands, extension, fakeExtCallerInfo } = await setup(
+ `${TEST_RELOAD_URL}?test=injected-script&frames=3`
+ );
+
+ // Test reload with an injectedScript.
+
+ const waitForInjectedScriptReload = waitForNextTabNavigated(commands);
+ await commands.inspectedWindowCommand.reload(fakeExtCallerInfo, {
+ injectedScript: `new ${injectedScript}`,
+ });
+ await waitForInjectedScriptReload;
+
+ const injectedScriptEval = await commands.scriptCommand.execute(
+ `(${collectEvalResults})()`
+ );
+
+ const expectedResult = new Array(5).fill("injected script executed first");
+
+ SimpleTest.isDeeply(
+ JSON.parse(injectedScriptEval.result),
+ expectedResult,
+ "Got the expected result on reload with an injected script"
+ );
+
+ // Test reload without an injectedScript.
+
+ const waitForNoInjectedScriptReload = waitForNextTabNavigated(commands);
+ await commands.inspectedWindowCommand.reload(fakeExtCallerInfo, {});
+ await waitForNoInjectedScriptReload;
+
+ const noInjectedScriptEval = await commands.scriptCommand.execute(
+ `(${collectEvalResults})()`
+ );
+
+ const newExpectedResult = new Array(5).fill("injected script NOT executed");
+
+ SimpleTest.isDeeply(
+ JSON.parse(noInjectedScriptEval.result),
+ newExpectedResult,
+ "Got the expected result on reload with no injected script"
+ );
+
+ await teardown({ commands, extension });
+});
+
+add_task(async function test_exception_inspectedWindowReload_multiple_calls() {
+ const { commands, extension, fakeExtCallerInfo } = await setup(
+ `${TEST_RELOAD_URL}?test=user-agent`
+ );
+
+ // Test reload with custom userAgent three times (and then
+ // check that only the first one has affected the page reload.
+
+ const waitForCustomUserAgentReload = waitForNextTabNavigated(commands);
+
+ commands.inspectedWindowCommand.reload(fakeExtCallerInfo, {
+ userAgent: "Customized User Agent 1",
+ });
+ commands.inspectedWindowCommand.reload(fakeExtCallerInfo, {
+ userAgent: "Customized User Agent 2",
+ });
+
+ await waitForCustomUserAgentReload;
+
+ const customUserAgentEval = await commands.scriptCommand.execute(
+ "document.body.textContent"
+ );
+
+ is(
+ customUserAgentEval.result,
+ "Customized User Agent 1",
+ "Got the expected result on reload with a customized userAgent"
+ );
+
+ // Test reload with no custom userAgent.
+
+ const waitForNoCustomUserAgentReload = waitForNextTabNavigated(commands);
+ await commands.inspectedWindowCommand.reload(fakeExtCallerInfo, {});
+
+ await waitForNoCustomUserAgentReload;
+
+ const noCustomUserAgentEval = await commands.scriptCommand.execute(
+ "document.body.textContent"
+ );
+
+ is(
+ noCustomUserAgentEval.result,
+ window.navigator.userAgent,
+ "Got the expected result with reload without a customized userAgent"
+ );
+
+ await teardown({ commands, extension });
+});
+
+add_task(async function test_exception_inspectedWindowReload_stopped() {
+ const { commands, extension, fakeExtCallerInfo } = await setup(
+ `${TEST_RELOAD_URL}?test=injected-script&frames=3`
+ );
+
+ // Test reload on a page that calls window.stop() immediately during the page loading
+
+ const waitForPageLoad = waitForNextTabNavigated(commands);
+ await commands.inspectedWindowCommand.eval(
+ fakeExtCallerInfo,
+ "window.location += '&stop=windowStop'"
+ );
+
+ info("Load a webpage that calls 'window.stop()' while is still loading");
+ await waitForPageLoad;
+
+ info("Starting a reload with an injectedScript");
+ const waitForInjectedScriptReload = waitForNextTabNavigated(commands);
+ await commands.inspectedWindowCommand.reload(fakeExtCallerInfo, {
+ injectedScript: `new ${injectedScript}`,
+ });
+ await waitForInjectedScriptReload;
+
+ const injectedScriptEval = await commands.scriptCommand.execute(
+ `(${collectEvalResults})()`
+ );
+
+ // The page should have stopped during the reload and only one injected script
+ // is expected.
+ const expectedResult = new Array(1).fill("injected script executed first");
+
+ SimpleTest.isDeeply(
+ JSON.parse(injectedScriptEval.result),
+ expectedResult,
+ "The injected script has been executed on the 'stopped' page reload"
+ );
+
+ // Reload again with no options.
+
+ info("Reload the tab again without any reload options");
+ const waitForNoInjectedScriptReload = waitForNextTabNavigated(commands);
+ await commands.inspectedWindowCommand.reload(fakeExtCallerInfo, {});
+ await waitForNoInjectedScriptReload;
+
+ const noInjectedScriptEval = await commands.scriptCommand.execute(
+ `(${collectEvalResults})()`
+ );
+
+ // The page should have stopped during the reload and no injected script should
+ // have been executed during this second reload (or it would mean that the previous
+ // customized reload was still pending and has wrongly affected the second reload)
+ const newExpectedResult = new Array(1).fill("injected script NOT executed");
+
+ SimpleTest.isDeeply(
+ JSON.parse(noInjectedScriptEval.result),
+ newExpectedResult,
+ "No injectedScript should have been evaluated during the second reload"
+ );
+
+ await teardown({ commands, extension });
+});
+
+// TODO: check eval with $0 binding once implemented (Bug 1300590)
diff --git a/devtools/shared/commands/inspected-window/tests/browser_webextension_inspected_window_access.js b/devtools/shared/commands/inspected-window/tests/browser_webextension_inspected_window_access.js
new file mode 100644
index 0000000000..3b32bb0aaa
--- /dev/null
+++ b/devtools/shared/commands/inspected-window/tests/browser_webextension_inspected_window_access.js
@@ -0,0 +1,315 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+async function run_inspectedWindow_eval({ tab, codeToEval, extension }) {
+ const fakeExtCallerInfo = {
+ url: `moz-extension://${extension.uuid}/another/fake-caller-script.js`,
+ lineNumber: 1,
+ addonId: extension.id,
+ };
+ const commands = await CommandsFactory.forTab(tab, { isWebExtension: true });
+ await commands.targetCommand.startListening();
+ const result = await commands.inspectedWindowCommand.eval(
+ fakeExtCallerInfo,
+ codeToEval,
+ {}
+ );
+ await commands.destroy();
+ return result;
+}
+
+async function openAboutBlankTabWithExtensionOrigin(extension) {
+ const tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ `moz-extension://${extension.uuid}/manifest.json`
+ );
+ const loaded = BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ await ContentTask.spawn(tab.linkedBrowser, null, () => {
+ // about:blank inherits the principal when opened from content.
+ content.wrappedJSObject.location.assign("about:blank");
+ });
+ await loaded;
+ // Sanity checks:
+ is(tab.linkedBrowser.currentURI.spec, "about:blank", "expected tab");
+ is(
+ tab.linkedBrowser.contentPrincipal.originNoSuffix,
+ `moz-extension://${extension.uuid}`,
+ "about:blank should be at the extension origin"
+ );
+ return tab;
+}
+
+async function checkEvalResult({
+ extension,
+ description,
+ url,
+ createTab = () => BrowserTestUtils.openNewForegroundTab(gBrowser, url),
+ expectedResult,
+}) {
+ const tab = await createTab();
+ is(tab.linkedBrowser.currentURI.spec, url, "Sanity check: tab URL");
+ const result = await run_inspectedWindow_eval({
+ tab,
+ codeToEval: "'code executed at ' + location.href",
+ extension,
+ });
+ BrowserTestUtils.removeTab(tab);
+ SimpleTest.isDeeply(
+ result,
+ expectedResult,
+ `eval result for devtools.inspectedWindow.eval at ${url} (${description})`
+ );
+}
+
+async function checkEvalAllowed({ extension, description, url, createTab }) {
+ info(`checkEvalAllowed: ${description} (at URL: ${url})`);
+ await checkEvalResult({
+ extension,
+ description,
+ url,
+ createTab,
+ expectedResult: { value: `code executed at ${url}` },
+ });
+}
+async function checkEvalDenied({ extension, description, url, createTab }) {
+ info(`checkEvalDenied: ${description} (at URL: ${url})`);
+ await checkEvalResult({
+ extension,
+ description,
+ url,
+ createTab,
+ expectedResult: {
+ exceptionInfo: {
+ isError: true,
+ code: "E_PROTOCOLERROR",
+ details: [
+ "This extension is not allowed on the current inspected window origin",
+ ],
+ description: "Inspector protocol error: %s",
+ },
+ },
+ });
+}
+
+add_task(async function test_eval_at_http() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.security.https_first", false]],
+ });
+
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ const httpUrl = "http://example.com/";
+
+ // When running with --use-http3-server, http:-URLs cannot be loaded.
+ try {
+ await fetch(httpUrl);
+ } catch {
+ info("Skipping test_eval_at_http because http:-URL cannot be loaded");
+ return;
+ }
+
+ const extension = ExtensionTestUtils.loadExtension({});
+ await extension.startup();
+
+ await checkEvalAllowed({
+ extension,
+ description: "http:-URL",
+ url: httpUrl,
+ });
+ await extension.unload();
+
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function test_eval_at_https() {
+ const extension = ExtensionTestUtils.loadExtension({});
+ await extension.startup();
+
+ const privilegedExtension = ExtensionTestUtils.loadExtension({
+ isPrivileged: true,
+ });
+ await privilegedExtension.startup();
+
+ await checkEvalAllowed({
+ extension,
+ description: "https:-URL",
+ url: "https://example.com/",
+ });
+
+ await checkEvalDenied({
+ extension,
+ description: "a restricted domain",
+ // Domain in extensions.webextensions.restrictedDomains by browser.toml.
+ url: "https://test2.example.com/",
+ });
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["extensions.quarantinedDomains.list", "example.com"]],
+ });
+
+ await checkEvalDenied({
+ extension,
+ description: "a quarantined domain",
+ url: "https://example.com/",
+ });
+
+ await checkEvalAllowed({
+ extension: privilegedExtension,
+ description: "a quarantined domain",
+ url: "https://example.com/",
+ });
+
+ await SpecialPowers.popPrefEnv();
+
+ await extension.unload();
+ await privilegedExtension.unload();
+});
+
+add_task(async function test_eval_at_sandboxed_page() {
+ const extension = ExtensionTestUtils.loadExtension({});
+ await extension.startup();
+
+ await checkEvalAllowed({
+ extension,
+ description: "page with CSP sandbox",
+ url: "https://example.com/document-builder.sjs?headers=Content-Security-Policy:sandbox&html=x",
+ });
+ await checkEvalDenied({
+ extension,
+ description: "restricted domain with CSP sandbox",
+ url: "https://test2.example.com/document-builder.sjs?headers=Content-Security-Policy:sandbox&html=x",
+ });
+
+ await extension.unload();
+});
+
+add_task(async function test_eval_at_own_extension_origin_allowed() {
+ const extension = ExtensionTestUtils.loadExtension({
+ background() {
+ // eslint-disable-next-line no-undef
+ browser.test.sendMessage(
+ "blob_url",
+ URL.createObjectURL(new Blob(["blob: here", { type: "text/html" }]))
+ );
+ },
+ files: {
+ "mozext.html": `<!DOCTYPE html>moz-extension: here`,
+ },
+ });
+ await extension.startup();
+ const blobUrl = await extension.awaitMessage("blob_url");
+
+ await checkEvalAllowed({
+ extension,
+ description: "moz-extension:-URL from own extension",
+ url: `moz-extension://${extension.uuid}/mozext.html`,
+ });
+ await checkEvalAllowed({
+ extension,
+ description: "blob:-URL from own extension",
+ url: blobUrl,
+ });
+ await checkEvalAllowed({
+ extension,
+ description: "about:blank with origin from own extension",
+ url: "about:blank",
+ createTab: () => openAboutBlankTabWithExtensionOrigin(extension),
+ });
+
+ await extension.unload();
+});
+
+add_task(async function test_eval_at_other_extension_denied() {
+ // The extension for which we simulate devtools_page, chosen as caller of
+ // devtools.inspectedWindow.eval API calls.
+ const extension = ExtensionTestUtils.loadExtension({});
+ await extension.startup();
+
+ // The other extension, that |extension| should not be able to access:
+ const otherExt = ExtensionTestUtils.loadExtension({
+ background() {
+ // eslint-disable-next-line no-undef
+ browser.test.sendMessage(
+ "blob_url",
+ URL.createObjectURL(new Blob(["blob: here", { type: "text/html" }]))
+ );
+ },
+ files: {
+ "mozext.html": `<!DOCTYPE html>moz-extension: here`,
+ },
+ });
+ await otherExt.startup();
+ const otherExtBlobUrl = await otherExt.awaitMessage("blob_url");
+
+ await checkEvalDenied({
+ extension,
+ description: "moz-extension:-URL from another extension",
+ url: `moz-extension://${otherExt.uuid}/mozext.html`,
+ });
+ await checkEvalDenied({
+ extension,
+ description: "blob:-URL from another extension",
+ url: otherExtBlobUrl,
+ });
+ await checkEvalDenied({
+ extension,
+ description: "about:blank with origin from another extension",
+ url: "about:blank",
+ createTab: () => openAboutBlankTabWithExtensionOrigin(otherExt),
+ });
+
+ await otherExt.unload();
+ await extension.unload();
+});
+
+add_task(async function test_eval_at_about() {
+ const extension = ExtensionTestUtils.loadExtension({});
+ await extension.startup();
+ await checkEvalAllowed({
+ extension,
+ description: "about:blank (null principal)",
+ url: "about:blank",
+ });
+ await checkEvalDenied({
+ extension,
+ description: "about:addons (system principal)",
+ url: "about:addons",
+ });
+ await checkEvalDenied({
+ extension,
+ description: "about:robots (about page)",
+ url: "about:robots",
+ });
+ await extension.unload();
+});
+
+add_task(async function test_eval_at_file() {
+ // FYI: There is also an equivalent test case with a full end-to-end test at:
+ // browser/components/extensions/test/browser/browser_ext_devtools_inspectedWindow_eval_file.js
+
+ const extension = ExtensionTestUtils.loadExtension({});
+ await extension.startup();
+
+ // A dummy file URL that can be loaded in a tab.
+ const fileUrl =
+ "file://" +
+ getTestFilePath("browser_webextension_inspected_window_access.js");
+
+ // checkEvalAllowed test helper cannot be used, because the file:-URL may
+ // redirect elsewhere, so the comparison with the full URL fails.
+ const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, fileUrl);
+ const result = await run_inspectedWindow_eval({
+ tab,
+ codeToEval: "'code executed at ' + location.protocol",
+ extension,
+ });
+ BrowserTestUtils.removeTab(tab);
+ SimpleTest.isDeeply(
+ result,
+ { value: "code executed at file:" },
+ `eval result for devtools.inspectedWindow.eval at ${fileUrl}`
+ );
+
+ await extension.unload();
+});
diff --git a/devtools/shared/commands/inspected-window/tests/head.js b/devtools/shared/commands/inspected-window/tests/head.js
new file mode 100644
index 0000000000..ce65b3d827
--- /dev/null
+++ b/devtools/shared/commands/inspected-window/tests/head.js
@@ -0,0 +1,12 @@
+/* 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
+);
diff --git a/devtools/shared/commands/inspected-window/tests/inspectedwindow-reload-target.sjs b/devtools/shared/commands/inspected-window/tests/inspectedwindow-reload-target.sjs
new file mode 100644
index 0000000000..2ec17a9222
--- /dev/null
+++ b/devtools/shared/commands/inspected-window/tests/inspectedwindow-reload-target.sjs
@@ -0,0 +1,89 @@
+"use strict";
+
+Cu.importGlobalProperties(["URLSearchParams"]);
+
+function handleRequest(request, response) {
+ const params = new URLSearchParams(request.queryString);
+
+ switch (params.get("test")) {
+ case "cache":
+ handleCacheTestRequest(request, response);
+ break;
+
+ case "user-agent":
+ handleUserAgentTestRequest(request, response);
+ break;
+
+ case "injected-script":
+ handleInjectedScriptTestRequest(request, response, params);
+ break;
+ }
+}
+
+function handleCacheTestRequest(request, response) {
+ response.setHeader("Content-Type", "text/plain; charset=UTF-8", false);
+
+ if (request.hasHeader("pragma") && request.hasHeader("cache-control")) {
+ response.write(
+ `${request.getHeader("pragma")}:${request.getHeader("cache-control")}`
+ );
+ } else {
+ response.write("empty cache headers");
+ }
+}
+
+function handleUserAgentTestRequest(request, response) {
+ response.setHeader("Content-Type", "text/plain; charset=UTF-8", false);
+
+ if (request.hasHeader("user-agent")) {
+ response.write(request.getHeader("user-agent"));
+ } else {
+ response.write("no user agent header");
+ }
+}
+
+function handleInjectedScriptTestRequest(request, response, params) {
+ response.setHeader("Content-Type", "text/html; charset=UTF-8", false);
+
+ const frames = parseInt(params.get("frames"), 10);
+ let content = "";
+
+ if (frames > 0) {
+ // Output an iframe in seamless mode, so that there is an higher chance that in case
+ // of test failures we get a screenshot where the nested iframes are all visible.
+ content = `<iframe seamless src="?test=injected-script&frames=${
+ frames - 1
+ }"></iframe>`;
+ } else {
+ // Output an about:srcdoc frame to be sure that inspectedWindow.eval is able to
+ // evaluate js code into it.
+ const srcdoc = `
+ <pre>injected script NOT executed</pre>
+ <script>window.pageScriptExecutedFirst = true</script>
+ `;
+ content = `<iframe style="height: 30px;" srcdoc="${srcdoc}"></iframe>`;
+ }
+
+ if (params.get("stop") == "windowStop") {
+ content = "<script>window.stop();</script>" + content;
+ }
+
+ response.write(`<!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset="utf-8">
+ <style>
+ iframe { width: 100%; height: ${frames * 150}px; }
+ </style>
+ </head>
+ <body>
+ <h1>IFRAME ${frames}</h1>
+ <pre>injected script NOT executed</pre>
+ <script>
+ window.pageScriptExecutedFirst = true;
+ </script>
+ ${content}
+ </body>
+ </html>
+ `);
+}
diff --git a/devtools/shared/commands/inspector/inspector-command.js b/devtools/shared/commands/inspector/inspector-command.js
new file mode 100644
index 0000000000..a8c4edd6c1
--- /dev/null
+++ b/devtools/shared/commands/inspector/inspector-command.js
@@ -0,0 +1,483 @@
+/* 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";
+
+loader.lazyRequireGetter(
+ this,
+ "getTargetBrowsers",
+ "resource://devtools/shared/compatibility/compatibility-user-settings.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "TARGET_BROWSER_PREF",
+ "resource://devtools/shared/compatibility/constants.js",
+ true
+);
+
+class InspectorCommand {
+ constructor({ commands }) {
+ this.commands = commands;
+ }
+
+ #cssDeclarationBlockIssuesQueuedDomRulesDeclarations = [];
+ #cssDeclarationBlockIssuesPendingTimeoutPromise;
+ #cssDeclarationBlockIssuesTargetBrowsersPromise;
+
+ /**
+ * Return the list of all current target's inspector fronts
+ *
+ * @return {Promise<Array<InspectorFront>>}
+ */
+ async getAllInspectorFronts() {
+ return this.commands.targetCommand.getAllFronts(
+ [this.commands.targetCommand.TYPES.FRAME],
+ "inspector"
+ );
+ }
+
+ /**
+ * Search the document for the given string and return all the results.
+ *
+ * @param {Object} walkerFront
+ * @param {String} query
+ * The string to search for.
+ * @param {Object} options
+ * {Boolean} options.reverse - search backwards
+ * @returns {Array} The list of search results
+ */
+ async walkerSearch(walkerFront, query, options = {}) {
+ const result = await walkerFront.search(query, options);
+ return result.list.items();
+ }
+
+ /**
+ * Incrementally search the top-level document and sub frames for a given string.
+ * Only one result is sent back at a time. Calling the
+ * method again with the same query will send the next result.
+ * If a new query which does not match the current one all is reset and new search
+ * is kicked off.
+ *
+ * @param {String} query
+ * The string / selector searched for
+ * @param {Object} options
+ * {Boolean} reverse - determines if the search is done backwards
+ * @returns {Object} res
+ * {String} res.type
+ * {String} res.query - The string / selector searched for
+ * {Object} res.node - the current node
+ * {Number} res.resultsIndex - The index of the current node
+ * {Number} res.resultsLength - The total number of results found.
+ */
+ async findNextNode(query, { reverse } = {}) {
+ const inspectors = await this.getAllInspectorFronts();
+ const nodes = await Promise.all(
+ inspectors.map(({ walker }) =>
+ this.walkerSearch(walker, query, { reverse })
+ )
+ );
+ const results = nodes.flat();
+
+ // If the search query changes
+ if (this._searchQuery !== query) {
+ this._searchQuery = query;
+ this._currentIndex = -1;
+ }
+
+ if (!results.length) {
+ return null;
+ }
+
+ this._currentIndex = reverse
+ ? this._currentIndex - 1
+ : this._currentIndex + 1;
+
+ if (this._currentIndex >= results.length) {
+ this._currentIndex = 0;
+ }
+ if (this._currentIndex < 0) {
+ this._currentIndex = results.length - 1;
+ }
+
+ return {
+ node: results[this._currentIndex],
+ resultsIndex: this._currentIndex,
+ resultsLength: results.length,
+ };
+ }
+
+ /**
+ * Returns a list of matching results for CSS selector autocompletion.
+ *
+ * @param {String} query
+ * The selector query being completed
+ * @param {String} firstPart
+ * The exact token being completed out of the query
+ * @param {String} state
+ * One of "pseudo", "id", "tag", "class", "null"
+ * @return {Array<string>} suggestions
+ * The list of suggested CSS selectors
+ */
+ async getSuggestionsForQuery(query, firstPart, state) {
+ // Get all inspectors where we want suggestions from.
+ const inspectors = await this.getAllInspectorFronts();
+
+ const mergedSuggestions = [];
+ // Get all of the suggestions.
+ await Promise.all(
+ inspectors.map(async ({ walker }) => {
+ const { suggestions } = await walker.getSuggestionsForQuery(
+ query,
+ firstPart,
+ state
+ );
+ for (const [suggestion, count, type] of suggestions) {
+ // Merge any already existing suggestion with the new one, by incrementing the count
+ // which is the second element of the array.
+ const existing = mergedSuggestions.find(
+ ([s, , t]) => s == suggestion && t == type
+ );
+ if (existing) {
+ existing[1] += count;
+ } else {
+ mergedSuggestions.push([suggestion, count, type]);
+ }
+ }
+ })
+ );
+
+ // Descending sort the list by count, i.e. second element of the arrays
+ return sortSuggestions(mergedSuggestions);
+ }
+
+ /**
+ * Find a nodeFront from an array of selectors. The last item of the array is the selector
+ * for the element in its owner document, and the previous items are selectors to iframes
+ * that lead to the frame where the searched node lives in.
+ *
+ * For example, with the following markup
+ * <html>
+ * <iframe id="level-1" src="…">
+ * <iframe id="level-2" src="…">
+ * <h1>Waldo</h1>
+ * </iframe>
+ * </iframe>
+ *
+ * If you want to retrieve the `<h1>` nodeFront, `selectors` would be:
+ * [
+ * "#level-1",
+ * "#level-2",
+ * "h1",
+ * ]
+ *
+ * @param {Array} selectors
+ * An array of CSS selectors to find the target accessible object.
+ * Several selectors can be needed if the element is nested in frames
+ * and not directly in the root document.
+ * @param {Integer} timeoutInMs
+ * The maximum number of ms the function should run (defaults to 5000).
+ * If it exceeds this, the returned promise will resolve with `null`.
+ * @return {Promise<NodeFront|null>} a promise that resolves when the node front is found
+ * for selection using inspector tools. It resolves with the deepest frame document
+ * that could be retrieved when the "final" nodeFront couldn't be found in the page.
+ * It resolves with `null` when the function runs for more than timeoutInMs.
+ */
+ async findNodeFrontFromSelectors(nodeSelectors, timeoutInMs = 5000) {
+ if (
+ !nodeSelectors ||
+ !Array.isArray(nodeSelectors) ||
+ nodeSelectors.length === 0
+ ) {
+ console.warn(
+ "findNodeFrontFromSelectors expect a non-empty array but got",
+ nodeSelectors
+ );
+ return null;
+ }
+
+ const { walker } = await this.commands.targetCommand.targetFront.getFront(
+ "inspector"
+ );
+ const querySelectors = async nodeFront => {
+ const selector = nodeSelectors.shift();
+ if (!selector) {
+ return nodeFront;
+ }
+ nodeFront = await nodeFront.walkerFront.querySelector(
+ nodeFront,
+ selector
+ );
+ // It's possible the containing iframe isn't available by the time
+ // walkerFront.querySelector is called, which causes the re-selected node to be
+ // unavailable. There also isn't a way for us to know when all iframes on the page
+ // have been created after a reload. Because of this, we should should bail here.
+ if (!nodeFront) {
+ return null;
+ }
+
+ if (nodeSelectors.length) {
+ if (!nodeFront.isShadowHost) {
+ await this.#waitForFrameLoad(nodeFront);
+ }
+
+ const { nodes } = await walker.children(nodeFront);
+
+ // If there are remaining selectors to process, they will target a document or a
+ // document-fragment under the current node. Whether the element is a frame or
+ // a web component, it can only contain one document/document-fragment, so just
+ // select the first one available.
+ nodeFront = nodes.find(node => {
+ const { nodeType } = node;
+ return (
+ nodeType === Node.DOCUMENT_FRAGMENT_NODE ||
+ nodeType === Node.DOCUMENT_NODE
+ );
+ });
+
+ // The iframe selector might have matched an element which is not an
+ // iframe in the new page (or an iframe with no document?). In this
+ // case, bail out and fallback to the root body element.
+ if (!nodeFront) {
+ return null;
+ }
+ }
+ const childrenNodeFront = await querySelectors(nodeFront);
+ return childrenNodeFront || nodeFront;
+ };
+ const rootNodeFront = await walker.getRootNode();
+
+ // Since this is only used for re-setting a selection after a page reloads, we can
+ // put a timeout, in case there's an iframe that would take too much time to load,
+ // and prevent the markup view to be populated.
+ const onTimeout = new Promise(res => setTimeout(res, timeoutInMs)).then(
+ () => null
+ );
+ const onQuerySelectors = querySelectors(rootNodeFront);
+ return Promise.race([onTimeout, onQuerySelectors]);
+ }
+
+ /**
+ * Wait for the given NodeFront child document to be loaded.
+ *
+ * @param {NodeFront} A nodeFront representing a frame
+ */
+ async #waitForFrameLoad(nodeFront) {
+ const domLoadingPromises = [];
+
+ // if the flag isn't true, we don't know for sure if the iframe will be remote
+ // or not; when the nodeFront was created, the iframe might still have been loading
+ // and in such case, its associated window can be an initial document.
+ // Luckily, once EFT is enabled everywhere we can remove this call and only wait
+ // for the associated target.
+ if (!nodeFront.useChildTargetToFetchChildren) {
+ domLoadingPromises.push(nodeFront.waitForFrameLoad());
+ }
+
+ const { onResource: onDomInteractiveResource } =
+ await this.commands.resourceCommand.waitForNextResource(
+ this.commands.resourceCommand.TYPES.DOCUMENT_EVENT,
+ {
+ // We might be in a case where the children document is already loaded (i.e. we
+ // would already have received the dom-interactive resource), so it's important
+ // to _not_ ignore existing resource.
+ predicate: resource =>
+ resource.name == "dom-interactive" &&
+ resource.targetFront !== nodeFront.targetFront &&
+ resource.targetFront.browsingContextID ==
+ nodeFront.browsingContextID,
+ }
+ );
+ const newTargetResolveValue = Symbol();
+ domLoadingPromises.push(
+ onDomInteractiveResource.then(() => newTargetResolveValue)
+ );
+
+ // Here we wait for any promise to resolve first. `waitForFrameLoad` might throw
+ // (if the iframe does end up being remote), so we don't want to use `Promise.race`.
+ const loadResult = await Promise.any(domLoadingPromises);
+
+ // The Node may have `useChildTargetToFetchChildren` set to false because the
+ // child document was still loading when fetching its form. But it may happen that
+ // the Node ends up being a remote iframe.
+ // When this happen we will try to call `waitForFrameLoad` which will throw, but
+ // we will be notified about the new target.
+ // This is the special edge case we are trying to handle here.
+ // We want WalkerFront.children to consider this as an iframe with a dedicated target.
+ if (loadResult == newTargetResolveValue) {
+ nodeFront._form.useChildTargetToFetchChildren = true;
+ }
+ }
+
+ /**
+ * Get the full array of selectors from the topmost document, going through
+ * iframes.
+ * For example, given the following markup:
+ *
+ * <html>
+ * <body>
+ * <iframe src="...">
+ * <html>
+ * <body>
+ * <h1 id="sub-document-title">Title of sub document</h1>
+ * </body>
+ * </html>
+ * </iframe>
+ * </body>
+ * </html>
+ *
+ * If this function is called with the NodeFront for the h1#sub-document-title element,
+ * it will return something like: ["body > iframe", "#sub-document-title"]
+ *
+ * @param {NodeFront} nodeFront: The nodefront to get the selectors for
+ * @returns {Promise<Array<String>>} A promise that resolves with an array of selectors (strings)
+ */
+ async getNodeFrontSelectorsFromTopDocument(nodeFront) {
+ const selectors = [];
+
+ let currentNode = nodeFront;
+ while (currentNode) {
+ // Get the selector for the node inside its document
+ const selector = await currentNode.getUniqueSelector();
+ selectors.unshift(selector);
+
+ // Retrieve the node's document/shadowRoot nodeFront so we can get its parent
+ // (so if we're in an iframe, we'll get the <iframe> node front, and if we're in a
+ // shadow dom document, we'll get the host).
+ const rootNode = currentNode.getOwnerRootNodeFront();
+ currentNode = rootNode?.parentOrHost();
+ }
+
+ return selectors;
+ }
+
+ #updateTargetBrowsersCache = async () => {
+ this.#cssDeclarationBlockIssuesTargetBrowsersPromise = getTargetBrowsers();
+ };
+
+ /**
+ * Get compatibility issues for given domRule declarations
+ *
+ * @param {Array<Object>} domRuleDeclarations
+ * @param {string} domRuleDeclarations[].name: Declaration name
+ * @param {string} domRuleDeclarations[].value: Declaration value
+ * @returns {Promise<Array<Object>>}
+ */
+ async getCSSDeclarationBlockIssues(domRuleDeclarations) {
+ const resultIndex =
+ this.#cssDeclarationBlockIssuesQueuedDomRulesDeclarations.length;
+ this.#cssDeclarationBlockIssuesQueuedDomRulesDeclarations.push(
+ domRuleDeclarations
+ );
+
+ // We're getting the target browsers from RemoteSettings, which can take some time.
+ // We cache the target browsers to avoid bad performance.
+ if (!this.#cssDeclarationBlockIssuesTargetBrowsersPromise) {
+ this.#updateTargetBrowsersCache();
+ // Update the target browsers cache when the pref in which we store the compat
+ // panel settings is updated.
+ Services.prefs.addObserver(
+ TARGET_BROWSER_PREF,
+ this.#updateTargetBrowsersCache
+ );
+ }
+
+ // This can be a hot path if the rules view has a lot of rules displayed.
+ // Here we wait before sending the RDP request so we can collect all the domRule declarations
+ // of "concurrent" calls, and only send a single RDP request.
+ if (!this.#cssDeclarationBlockIssuesPendingTimeoutPromise) {
+ // Wait before sending the RDP request so all "concurrent" calls can be handle
+ // in a single RDP request.
+ this.#cssDeclarationBlockIssuesPendingTimeoutPromise = new Promise(
+ resolve => {
+ setTimeout(() => {
+ this.#cssDeclarationBlockIssuesPendingTimeoutPromise = null;
+ this.#batchedGetCSSDeclarationBlockIssues().then(data =>
+ resolve(data)
+ );
+ }, 50);
+ }
+ );
+ }
+
+ const results = await this.#cssDeclarationBlockIssuesPendingTimeoutPromise;
+ return results?.[resultIndex] || [];
+ }
+
+ /**
+ * Get compatibility issues for all queued domRules declarations
+ * @returns {Promise<Array<Array<Object>>>}
+ */
+ #batchedGetCSSDeclarationBlockIssues = async () => {
+ const declarations = Array.from(
+ this.#cssDeclarationBlockIssuesQueuedDomRulesDeclarations
+ );
+ this.#cssDeclarationBlockIssuesQueuedDomRulesDeclarations = [];
+
+ const { targetFront } = this.commands.targetCommand;
+ try {
+ // The server method isn't dependent on the target (it computes the values from the
+ // declarations we send, which are just property names and values), so we can always
+ // use the top-level target front.
+ const inspectorFront = await targetFront.getFront("inspector");
+
+ const [compatibilityFront, targetBrowsers] = await Promise.all([
+ inspectorFront.getCompatibilityFront(),
+ this.#cssDeclarationBlockIssuesTargetBrowsersPromise,
+ ]);
+
+ const data = await compatibilityFront.getCSSDeclarationBlockIssues(
+ declarations,
+ targetBrowsers
+ );
+ return data;
+ } catch (e) {
+ if (this.destroyed || targetFront.isDestroyed()) {
+ return [];
+ }
+ throw e;
+ }
+ };
+
+ destroy() {
+ Services.prefs.removeObserver(
+ TARGET_BROWSER_PREF,
+ this.#updateTargetBrowsersCache
+ );
+ this.destroyed = true;
+ }
+}
+
+// This is a fork of the server sort:
+// https://searchfox.org/mozilla-central/rev/46a67b8656ac12b5c180e47bc4055f713d73983b/devtools/server/actors/inspector/walker.js#1447
+function sortSuggestions(suggestions) {
+ const sorted = suggestions.sort((a, b) => {
+ // Computed a sortable string with first the inverted count, then the name
+ let sortA = 10000 - a[1] + a[0];
+ let sortB = 10000 - b[1] + b[0];
+
+ // Prefixing ids, classes and tags, to group results
+ const firstA = a[0].substring(0, 1);
+ const firstB = b[0].substring(0, 1);
+
+ const getSortKeyPrefix = firstLetter => {
+ if (firstLetter === "#") {
+ return "2";
+ }
+ if (firstLetter === ".") {
+ return "1";
+ }
+ return "0";
+ };
+
+ sortA = getSortKeyPrefix(firstA) + sortA;
+ sortB = getSortKeyPrefix(firstB) + sortB;
+
+ // String compare
+ return sortA.localeCompare(sortB);
+ });
+ return sorted.slice(0, 25);
+}
+
+module.exports = InspectorCommand;
diff --git a/devtools/shared/commands/inspector/moz.build b/devtools/shared/commands/inspector/moz.build
new file mode 100644
index 0000000000..d9ef593b8d
--- /dev/null
+++ b/devtools/shared/commands/inspector/moz.build
@@ -0,0 +1,10 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DevToolsModules(
+ "inspector-command.js",
+)
+
+if CONFIG["MOZ_BUILD_APP"] != "mobile/android":
+ BROWSER_CHROME_MANIFESTS += ["tests/browser.ini"]
diff --git a/devtools/shared/commands/inspector/tests/browser.ini b/devtools/shared/commands/inspector/tests/browser.ini
new file mode 100644
index 0000000000..cd3e3117cf
--- /dev/null
+++ b/devtools/shared/commands/inspector/tests/browser.ini
@@ -0,0 +1,15 @@
+[DEFAULT]
+tags = devtools
+subsuite = devtools
+support-files =
+ !/devtools/client/inspector/test/shared-head.js
+ !/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
+
+[browser_inspector_command_findNodeFrontFromSelectors.js]
+skip-if = http3 # Bug 1829298
+[browser_inspector_command_getNodeFrontSelectorsFromTopDocument.js]
+[browser_inspector_command_getSuggestionsForQuery.js]
+[browser_inspector_command_search.js]
diff --git a/devtools/shared/commands/inspector/tests/browser_inspector_command_findNodeFrontFromSelectors.js b/devtools/shared/commands/inspector/tests/browser_inspector_command_findNodeFrontFromSelectors.js
new file mode 100644
index 0000000000..7991421c8d
--- /dev/null
+++ b/devtools/shared/commands/inspector/tests/browser_inspector_command_findNodeFrontFromSelectors.js
@@ -0,0 +1,140 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async () => {
+ // Build a simple test page with a remote iframe, using two distinct origins .com and .org
+ const iframeOrgHtml = encodeURIComponent(
+ `<h2 id="in-iframe">in org - same origin</h2>`
+ );
+ const iframeComHtml = encodeURIComponent(`<h3>in com - remote</h3>`);
+ const html = encodeURIComponent(
+ `<main class="foo bar">
+ <button id="child">Click</button>
+ </main>
+ <!-- adding delay to both iframe so we can check we handle loading document has expected -->
+ <iframe id="iframe-org" src="https://example.org/document-builder.sjs?delay=3000&html=${iframeOrgHtml}"></iframe>
+ <iframe id="iframe-com" src="https://example.com/document-builder.sjs?delay=6000&html=${iframeComHtml}"></iframe>`
+ );
+ const tab = await addTab(
+ "https://example.org/document-builder.sjs?html=" + html,
+ { waitForLoad: false }
+ );
+
+ const commands = await CommandsFactory.forTab(tab);
+ await commands.targetCommand.startListening();
+
+ info("Check that it returns null when no params are passed");
+ let nodeFront = await commands.inspectorCommand.findNodeFrontFromSelectors();
+ is(
+ nodeFront,
+ null,
+ `findNodeFrontFromSelectors returns null when no param is passed`
+ );
+
+ info("Check that it returns null when a string is passed");
+ nodeFront = await commands.inspectorCommand.findNodeFrontFromSelectors(
+ "body main"
+ );
+ is(
+ nodeFront,
+ null,
+ `findNodeFrontFromSelectors returns null when passed a string`
+ );
+
+ info("Check it returns null when an empty array is passed");
+ nodeFront = await commands.inspectorCommand.findNodeFrontFromSelectors([]);
+ is(
+ nodeFront,
+ null,
+ `findNodeFrontFromSelectors returns null when passed an empty array`
+ );
+
+ info("Check that passing a selector for a non-matching element returns null");
+ nodeFront = await commands.inspectorCommand.findNodeFrontFromSelectors([
+ "h1",
+ ]);
+ is(
+ nodeFront,
+ null,
+ "findNodeFrontFromSelectors returns null as there's no <h1> element in the page"
+ );
+
+ info("Check passing a selector for an element in the top document");
+ nodeFront = await commands.inspectorCommand.findNodeFrontFromSelectors([
+ "button",
+ ]);
+ is(
+ nodeFront.typeName,
+ "domnode",
+ "findNodeFrontFromSelectors returns a nodeFront"
+ );
+ is(
+ nodeFront.displayName,
+ "button",
+ "findNodeFrontFromSelectors returned the appropriate nodeFront"
+ );
+
+ info("Check passing a selector for an element in a same origin iframe");
+ nodeFront = await commands.inspectorCommand.findNodeFrontFromSelectors([
+ "#iframe-org",
+ "#in-iframe",
+ ]);
+ is(
+ nodeFront.displayName,
+ "h2",
+ "findNodeFrontFromSelectors returned the appropriate nodeFront"
+ );
+
+ info("Check passing a selector for an element in a cross origin iframe");
+ nodeFront = await commands.inspectorCommand.findNodeFrontFromSelectors([
+ "#iframe-com",
+ "h3",
+ ]);
+ is(
+ nodeFront.displayName,
+ "h3",
+ "findNodeFrontFromSelectors returned the appropriate nodeFront"
+ );
+
+ info(
+ "Check passing a selector for an non-existing element in an existing iframe"
+ );
+ nodeFront = await commands.inspectorCommand.findNodeFrontFromSelectors([
+ "iframe",
+ "#non-existant-id",
+ ]);
+ is(
+ nodeFront.displayName,
+ "#document",
+ "findNodeFrontFromSelectors returned the last matching iframe document if the children selector isn't found"
+ );
+ is(
+ nodeFront.parentNode().displayName,
+ "iframe",
+ "findNodeFrontFromSelectors returned the last matching iframe document if the children selector isn't found"
+ );
+
+ info("Check that timeout does work");
+ // Reload the page so we'll have the iframe loading (for 3s) and we can check that
+ // putting a smaller timeout will result in the function returning null.
+ // we need to wait until it's fully processed to avoid pending promises.
+ const onNewTargetProcessed = commands.targetCommand.once(
+ "processed-available-target"
+ );
+ await reloadBrowser({ waitForLoad: false });
+ await onNewTargetProcessed;
+ nodeFront = await commands.inspectorCommand.findNodeFrontFromSelectors(
+ ["#iframe-org", "#in-iframe"],
+ // timeout in ms (smaller than 3s)
+ 100
+ );
+ is(
+ nodeFront,
+ null,
+ "findNodeFrontFromSelectors timed out and returned null, as expected"
+ );
+
+ await commands.destroy();
+});
diff --git a/devtools/shared/commands/inspector/tests/browser_inspector_command_getNodeFrontSelectorsFromTopDocument.js b/devtools/shared/commands/inspector/tests/browser_inspector_command_getNodeFrontSelectorsFromTopDocument.js
new file mode 100644
index 0000000000..3e5abcddd0
--- /dev/null
+++ b/devtools/shared/commands/inspector/tests/browser_inspector_command_getNodeFrontSelectorsFromTopDocument.js
@@ -0,0 +1,119 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Testing getNodeFrontSelectorsFromTopDocument
+
+add_task(async () => {
+ const html = `
+ <html>
+ <head>
+ <meta charset="utf8">
+ <title>Test</title>
+ </head>
+ <body>
+ <header>
+ <span>hello</span>
+ <span>world</span>
+ </header>
+ <main>
+ <iframe src="data:text/html,${encodeURIComponent(
+ "<html><body><h2 class='frame-child'>foo</h2></body></html>"
+ )}"></iframe>
+ </main>
+ <footer></footer>
+
+ <test-component>
+ <div slot="slot1" id="el1">content</div>
+ </test-component>
+ <script>
+ 'use strict';
+
+ customElements.define('test-component', class extends HTMLElement {
+ constructor() {
+ super();
+ const shadowRoot = this.attachShadow({mode: 'open'});
+ shadowRoot.innerHTML = '<slot class="slot-class" name="slot1"></slot>';
+ }
+ });
+ </script>
+ </body>
+ </html>`;
+
+ const tab = await addTab("data:text/html," + encodeURIComponent(html));
+ const commands = await CommandsFactory.forTab(tab);
+ await commands.targetCommand.startListening();
+
+ const walker = (
+ await commands.targetCommand.targetFront.getFront("inspector")
+ ).walker;
+
+ const checkSelectors = (...args) =>
+ checkSelectorsFromTopDocumentForNode(commands, ...args);
+
+ let node = await getNodeFrontInFrames(["meta"], { walker });
+ await checkSelectors(
+ node,
+ ["head > meta:nth-child(1)"],
+ "Got expected selectors for the top-level meta node"
+ );
+
+ node = await getNodeFrontInFrames(["body"], { walker });
+ await checkSelectors(
+ node,
+ ["body"],
+ "Got expected selectors for the top-level body node"
+ );
+
+ node = await getNodeFrontInFrames(["header > span"], { walker });
+ await checkSelectors(
+ node,
+ ["body > header:nth-child(1) > span:nth-child(1)"],
+ "Got expected selectors for the top-level span node"
+ );
+
+ node = await getNodeFrontInFrames(["iframe"], { walker });
+ await checkSelectors(
+ node,
+ ["body > main:nth-child(2) > iframe:nth-child(1)"],
+ "Got expected selectors for the iframe node"
+ );
+
+ node = await getNodeFrontInFrames(["iframe", "body"], { walker });
+ await checkSelectors(
+ node,
+ ["body > main:nth-child(2) > iframe:nth-child(1)", "body"],
+ "Got expected selectors for the iframe body node"
+ );
+
+ const hostFront = await getNodeFront("test-component", { walker });
+ const { nodes } = await walker.children(hostFront);
+ const shadowRoot = nodes.find(hostNode => hostNode.isShadowRoot);
+ node = await walker.querySelector(shadowRoot, ".slot-class");
+
+ await checkSelectors(
+ node,
+ ["body > test-component:nth-child(4)", ".slot-class"],
+ "Got expected selectors for the shadow dom node"
+ );
+
+ await commands.destroy();
+});
+
+async function checkSelectorsFromTopDocumentForNode(
+ commands,
+ nodeFront,
+ expectedSelectors,
+ assertionText
+) {
+ const selectors =
+ await commands.inspectorCommand.getNodeFrontSelectorsFromTopDocument(
+ nodeFront
+ );
+ is(
+ JSON.stringify(selectors),
+ JSON.stringify(expectedSelectors),
+ assertionText
+ );
+}
diff --git a/devtools/shared/commands/inspector/tests/browser_inspector_command_getSuggestionsForQuery.js b/devtools/shared/commands/inspector/tests/browser_inspector_command_getSuggestionsForQuery.js
new file mode 100644
index 0000000000..e7b765b1d0
--- /dev/null
+++ b/devtools/shared/commands/inspector/tests/browser_inspector_command_getSuggestionsForQuery.js
@@ -0,0 +1,124 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async () => {
+ // Build a test page with a remote iframe, using two distinct origins .com and .org
+ const iframeHtml = encodeURIComponent(`<div id="iframe"></div>`);
+ const html = encodeURIComponent(
+ `<div class="foo bar">
+ <div id="child"></div>
+ </div>
+ <iframe src="https://example.org/document-builder.sjs?html=${iframeHtml}"></iframe>`
+ );
+ const tab = await addTab(
+ "https://example.com/document-builder.sjs?html=" + html
+ );
+
+ const commands = await CommandsFactory.forTab(tab);
+ await commands.targetCommand.startListening();
+
+ info(
+ "Suggestions for 'di' with tag search, will match the two <div> elements in top document and the one in the iframe"
+ );
+ await assertSuggestion(
+ commands,
+ { query: "", firstPart: "di", state: "tag" },
+ [
+ {
+ suggestion: "div",
+ count: 3, // Matches the two <div> in the top document and the one in the iframe
+ type: "tag",
+ },
+ ]
+ );
+
+ info(
+ "Suggestions for 'ifram' with id search, will only match the <div> within the iframe"
+ );
+ await assertSuggestion(
+ commands,
+ { query: "", firstPart: "ifram", state: "id" },
+ [
+ {
+ suggestion: "#iframe",
+ count: 1,
+ type: "id",
+ },
+ ]
+ );
+
+ info(
+ "Suggestions for 'fo' with tag search, will match the class of the top <div> element"
+ );
+ await assertSuggestion(
+ commands,
+ { query: "", firstPart: "fo", state: "tag" },
+ [
+ {
+ suggestion: ".foo",
+ count: 1,
+ type: "class",
+ },
+ ]
+ );
+
+ info(
+ "Suggestions for classes, based on div elements, will match the two classes of top <div> element"
+ );
+ await assertSuggestion(
+ commands,
+ { query: "div", firstPart: "", state: "class" },
+ [
+ {
+ suggestion: ".bar",
+ count: 1,
+ type: "class",
+ },
+ {
+ suggestion: ".foo",
+ count: 1,
+ type: "class",
+ },
+ ]
+ );
+
+ info("Suggestion for non-existent tag names will return no suggestion");
+ await assertSuggestion(
+ commands,
+ { query: "", firstPart: "marquee", state: "tag" },
+ []
+ );
+
+ await commands.destroy();
+});
+
+async function assertSuggestion(
+ commands,
+ { query, firstPart, state },
+ expectedSuggestions
+) {
+ const suggestions = await commands.inspectorCommand.getSuggestionsForQuery(
+ query,
+ firstPart,
+ state
+ );
+ is(
+ suggestions.length,
+ expectedSuggestions.length,
+ "Got the expected number of suggestions"
+ );
+ for (let i = 0; i < expectedSuggestions.length; i++) {
+ info(` ## Asserting suggestion #${i}:`);
+ const expectedSuggestion = expectedSuggestions[i];
+ const [suggestion, count, type] = suggestions[i];
+ is(
+ suggestion,
+ expectedSuggestion.suggestion,
+ "The suggested string is valid"
+ );
+ is(count, expectedSuggestion.count, "The number of matches is valid");
+ is(type, expectedSuggestion.type, "The type of match is valid");
+ }
+}
diff --git a/devtools/shared/commands/inspector/tests/browser_inspector_command_search.js b/devtools/shared/commands/inspector/tests/browser_inspector_command_search.js
new file mode 100644
index 0000000000..d7d25d3ce6
--- /dev/null
+++ b/devtools/shared/commands/inspector/tests/browser_inspector_command_search.js
@@ -0,0 +1,98 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Testing basic inspector search
+
+add_task(async () => {
+ const html = `<div>
+ <div>
+ <p>This is the paragraph node down in the tree</p>
+ </div>
+ <div class="child"></div>
+ <div class="child"></div>
+ <iframe src="data:text/html,${encodeURIComponent(
+ "<html><body><div class='frame-child'>foo</div></body></html>"
+ )}">
+ </iframe>
+ </div>`;
+
+ const tab = await addTab("data:text/html," + encodeURIComponent(html));
+
+ const commands = await CommandsFactory.forTab(tab);
+ await commands.targetCommand.startListening();
+
+ info("Search using text");
+ await searchAndAssert(
+ commands,
+ { query: "paragraph", reverse: false },
+ { resultsLength: 1, resultsIndex: 0 }
+ );
+
+ info("Search using class selector");
+ info(" > Get first result ");
+ await searchAndAssert(
+ commands,
+ { query: ".child", reverse: false },
+ { resultsLength: 2, resultsIndex: 0 }
+ );
+
+ info(" > Get next result ");
+ await searchAndAssert(
+ commands,
+ { query: ".child", reverse: false },
+ { resultsLength: 2, resultsIndex: 1 }
+ );
+
+ info("Search using el selector with reverse option");
+ info(" > Get first result ");
+ await searchAndAssert(
+ commands,
+ { query: "div", reverse: true },
+ { resultsLength: 6, resultsIndex: 5 }
+ );
+
+ info(" > Get next result ");
+ await searchAndAssert(
+ commands,
+ { query: "div", reverse: true },
+ { resultsLength: 6, resultsIndex: 4 }
+ );
+
+ info("Search for foo in remote frame");
+ await searchAndAssert(
+ commands,
+ { query: ".frame-child", reverse: false },
+ { resultsLength: 1, resultsIndex: 0 }
+ );
+
+ await commands.destroy();
+});
+/**
+ * Does an inspector search to find the next node and assert the results
+ *
+ * @param {Object} commands
+ * @param {Object} options
+ * options.query - search query
+ * options.reverse - search in reverse
+ * @param {Object} expected
+ * Holds the expected values
+ */
+async function searchAndAssert(commands, { query, reverse }, expected) {
+ const response = await commands.inspectorCommand.findNextNode(query, {
+ reverse,
+ });
+
+ is(
+ response.resultsLength,
+ expected.resultsLength,
+ "Got the expected no of results"
+ );
+
+ is(
+ response.resultsIndex,
+ expected.resultsIndex,
+ "Got the expected currently selected node index"
+ );
+}
diff --git a/devtools/shared/commands/inspector/tests/head.js b/devtools/shared/commands/inspector/tests/head.js
new file mode 100644
index 0000000000..73d9798446
--- /dev/null
+++ b/devtools/shared/commands/inspector/tests/head.js
@@ -0,0 +1,14 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/devtools/client/shared/test/shared-head.js",
+ this
+);
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/devtools/client/inspector/test/shared-head.js",
+ this
+);
diff --git a/devtools/shared/commands/moz.build b/devtools/shared/commands/moz.build
new file mode 100644
index 0000000000..bcd8a14810
--- /dev/null
+++ b/devtools/shared/commands/moz.build
@@ -0,0 +1,20 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DIRS += [
+ "inspected-window",
+ "inspector",
+ "network",
+ "resource",
+ "root-resource",
+ "script",
+ "target",
+ "target-configuration",
+ "thread-configuration",
+]
+
+DevToolsModules(
+ "commands-factory.js",
+ "index.js",
+)
diff --git a/devtools/shared/commands/network/moz.build b/devtools/shared/commands/network/moz.build
new file mode 100644
index 0000000000..e765e5ac76
--- /dev/null
+++ b/devtools/shared/commands/network/moz.build
@@ -0,0 +1,10 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DevToolsModules(
+ "network-command.js",
+)
+
+if CONFIG["MOZ_BUILD_APP"] != "mobile/android":
+ BROWSER_CHROME_MANIFESTS += ["tests/browser.ini"]
diff --git a/devtools/shared/commands/network/network-command.js b/devtools/shared/commands/network/network-command.js
new file mode 100644
index 0000000000..44cdf4e759
--- /dev/null
+++ b/devtools/shared/commands/network/network-command.js
@@ -0,0 +1,96 @@
+/* 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";
+
+class NetworkCommand {
+ /**
+ * This class helps listen, inspect and control network requests.
+ *
+ * @param {DescriptorFront} descriptorFront
+ * The context to inspect identified by this descriptor.
+ * @param {WatcherFront} watcherFront
+ * If available, a reference to the related Watcher Front.
+ * @param {Object} commands
+ * The commands object with all interfaces defined from devtools/shared/commands/
+ */
+ constructor({ descriptorFront, watcherFront, commands }) {
+ this.commands = commands;
+ this.descriptorFront = descriptorFront;
+ this.watcherFront = watcherFront;
+ }
+
+ /**
+ * Send a HTTP request data payload
+ *
+ * @param {object} data data payload would like to sent to backend
+ */
+ async sendHTTPRequest(data) {
+ // By default use the top-level target, but we might at some point
+ // allow using another target.
+ const networkContentFront =
+ await this.commands.targetCommand.targetFront.getFront("networkContent");
+ const { channelId } = await networkContentFront.sendHTTPRequest(data);
+ return { channelId };
+ }
+
+ /*
+ * Get the list of blocked URL filters.
+ *
+ * A URL filter is a RegExp string so that one filter can match many URLs.
+ * It can be an absolute URL to match only one precise request:
+ * http://mozilla.org/index.html
+ * Or just a string which would match all URL containing this string:
+ * mozilla
+ * Or a RegExp to match various types of URLs:
+ * http://*mozilla.org/*.css
+ *
+ * @return {Array}
+ * List of all currently blocked URL filters.
+ */
+ async getBlockedUrls() {
+ const networkParentFront = await this.watcherFront.getNetworkParentActor();
+ return networkParentFront.getBlockedUrls();
+ }
+
+ /**
+ * Updates the list of blocked URL filters.
+ *
+ * @param {Array} urls
+ * An array of URL filter strings.
+ * See getBlockedUrls for definition of URL filters.
+ */
+ async setBlockedUrls(urls) {
+ const networkParentFront = await this.watcherFront.getNetworkParentActor();
+ return networkParentFront.setBlockedUrls(urls);
+ }
+
+ /**
+ * Block only one additional URL filter
+ *
+ * @param {String} url
+ * URL filter to block.
+ * See getBlockedUrls for definition of URL filters.
+ */
+ async blockRequestForUrl(url) {
+ const networkParentFront = await this.watcherFront.getNetworkParentActor();
+ return networkParentFront.blockRequest({ url });
+ }
+
+ /**
+ * Stop blocking only one specific URL filter
+ *
+ * @param {String} url
+ * URL filter to unblock.
+ * See getBlockedUrls for definition of URL filters.
+ */
+ async unblockRequestForUrl(url) {
+ const networkParentFront = await this.watcherFront.getNetworkParentActor();
+ return networkParentFront.unblockRequest({ url });
+ }
+
+ destroy() {}
+}
+
+module.exports = NetworkCommand;
diff --git a/devtools/shared/commands/network/tests/browser.ini b/devtools/shared/commands/network/tests/browser.ini
new file mode 100644
index 0000000000..ce1860f0f0
--- /dev/null
+++ b/devtools/shared/commands/network/tests/browser.ini
@@ -0,0 +1,11 @@
+[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
+
+[browser_network_command_request_blocking.js]
+[browser_network_command_sendHTTPRequest.js]
diff --git a/devtools/shared/commands/network/tests/browser_network_command_request_blocking.js b/devtools/shared/commands/network/tests/browser_network_command_request_blocking.js
new file mode 100644
index 0000000000..dd1167fa9b
--- /dev/null
+++ b/devtools/shared/commands/network/tests/browser_network_command_request_blocking.js
@@ -0,0 +1,61 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the NetworkCommand API around request blocking
+
+add_task(async function () {
+ info("Test NetworkCommand request blocking");
+ const tab = await addTab("data:text/html,foo");
+ const commands = await CommandsFactory.forTab(tab);
+ const networkCommand = commands.networkCommand;
+ const resourceCommand = commands.resourceCommand;
+
+ // Usage of request blocking APIs requires to listen to NETWORK_EVENT.
+ await resourceCommand.watchResources([resourceCommand.TYPES.NETWORK_EVENT], {
+ onAvailable: () => {},
+ });
+
+ let blockedUrls = await networkCommand.getBlockedUrls();
+ Assert.deepEqual(
+ blockedUrls,
+ [],
+ "The list of blocked URLs is originaly empty"
+ );
+
+ await networkCommand.blockRequestForUrl("https://foo.com");
+ blockedUrls = await networkCommand.getBlockedUrls();
+ Assert.deepEqual(
+ blockedUrls,
+ ["https://foo.com"],
+ "The freshly added blocked URL is reported as blocked"
+ );
+
+ // We pass "url filters" which can be only part of a URL string
+ await networkCommand.blockRequestForUrl("bar");
+ blockedUrls = await networkCommand.getBlockedUrls();
+ Assert.deepEqual(
+ blockedUrls,
+ ["https://foo.com", "bar"],
+ "The second blocked URL is also reported as blocked"
+ );
+
+ await networkCommand.setBlockedUrls(["https://mozilla.org"]);
+ blockedUrls = await networkCommand.getBlockedUrls();
+ Assert.deepEqual(
+ blockedUrls,
+ ["https://mozilla.org"],
+ "setBlockedUrls replace the whole list of blocked URLs"
+ );
+
+ await networkCommand.unblockRequestForUrl("https://mozilla.org");
+ blockedUrls = await networkCommand.getBlockedUrls();
+ Assert.deepEqual(
+ blockedUrls,
+ [],
+ "The unblocked URL disappear from the list of blocked URLs"
+ );
+
+ await commands.destroy();
+});
diff --git a/devtools/shared/commands/network/tests/browser_network_command_sendHTTPRequest.js b/devtools/shared/commands/network/tests/browser_network_command_sendHTTPRequest.js
new file mode 100644
index 0000000000..1d84a8a668
--- /dev/null
+++ b/devtools/shared/commands/network/tests/browser_network_command_sendHTTPRequest.js
@@ -0,0 +1,78 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the NetworkCommand's sendHTTPRequest
+
+add_task(async function () {
+ info("Test NetworkCommand.sendHTTPRequest");
+ const tab = await addTab("data:text/html,foo");
+ const commands = await CommandsFactory.forTab(tab);
+
+ // We have to ensure TargetCommand is initialized to have access to the top level target
+ // from NetworkCommand.sendHTTPRequest
+ await commands.targetCommand.startListening();
+
+ const { networkCommand } = commands;
+
+ const httpServer = createTestHTTPServer();
+ const onRequest = new Promise(resolve => {
+ httpServer.registerPathHandler(
+ "/http-request.html",
+ (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.write("Response body");
+ resolve(request);
+ }
+ );
+ });
+ const url = `http://localhost:${httpServer.identity.primaryPort}/http-request.html`;
+
+ info("Call NetworkCommand.sendHTTPRequest");
+ const { resourceCommand } = commands;
+ const { onResource } = await resourceCommand.waitForNextResource(
+ resourceCommand.TYPES.NETWORK_EVENT
+ );
+ const { channelId } = await networkCommand.sendHTTPRequest({
+ url,
+ method: "POST",
+ headers: [{ name: "Request", value: "Header" }],
+ body: "Hello",
+ cause: {
+ loadingDocumentUri: "https://example.com",
+ stacktraceAvailable: true,
+ type: "xhr",
+ },
+ });
+ ok(channelId, "Received a channel id in response");
+ const resource = await onResource;
+ is(
+ resource.resourceId,
+ channelId,
+ "NETWORK_EVENT resource channelId is the same as the one returned by sendHTTPRequest"
+ );
+
+ const request = await onRequest;
+ is(request.method, "POST", "Request method is correct");
+ is(request.getHeader("Request"), "Header", "The custom header was passed");
+ is(fetchRequestBody(request), "Hello", "The request POST's body is correct");
+
+ await commands.destroy();
+});
+
+const BinaryInputStream = Components.Constructor(
+ "@mozilla.org/binaryinputstream;1",
+ "nsIBinaryInputStream",
+ "setInputStream"
+);
+
+function fetchRequestBody(request) {
+ let body = "";
+ const bodyStream = new BinaryInputStream(request.bodyInputStream);
+ let avail = 0;
+ while ((avail = bodyStream.available()) > 0) {
+ body += String.fromCharCode.apply(String, bodyStream.readByteArray(avail));
+ }
+ return body;
+}
diff --git a/devtools/shared/commands/network/tests/head.js b/devtools/shared/commands/network/tests/head.js
new file mode 100644
index 0000000000..ce65b3d827
--- /dev/null
+++ b/devtools/shared/commands/network/tests/head.js
@@ -0,0 +1,12 @@
+/* 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
+);
diff --git a/devtools/shared/commands/resource/legacy-listeners/console-messages.js b/devtools/shared/commands/resource/legacy-listeners/console-messages.js
new file mode 100644
index 0000000000..ae3f81b4df
--- /dev/null
+++ b/devtools/shared/commands/resource/legacy-listeners/console-messages.js
@@ -0,0 +1,59 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js");
+
+module.exports = async function ({ targetCommand, targetFront, onAvailable }) {
+ // Allow the top level target unconditionnally.
+ // Also allow frame, but only in content toolbox, i.e. still ignore them in
+ // the context of the browser toolbox as we inspect messages via the process
+ // targets
+ const listenForFrames = targetCommand.descriptorFront.isTabDescriptor;
+
+ // Allow workers when messages aren't dispatched to the main thread.
+ const listenForWorkers =
+ !targetCommand.rootFront.traits
+ .workerConsoleApiMessagesDispatchedToMainThread;
+
+ const acceptTarget =
+ targetFront.isTopLevel ||
+ targetFront.targetType === targetCommand.TYPES.PROCESS ||
+ (targetFront.targetType === targetCommand.TYPES.FRAME && listenForFrames) ||
+ (targetFront.targetType === targetCommand.TYPES.WORKER && listenForWorkers);
+
+ if (!acceptTarget) {
+ return;
+ }
+
+ const webConsoleFront = await targetFront.getFront("console");
+ if (webConsoleFront.isDestroyed()) {
+ return;
+ }
+
+ // Request notifying about new messages
+ await webConsoleFront.startListeners(["ConsoleAPI"]);
+
+ // Fetch already existing messages
+ // /!\ The actor implementation requires to call startListeners(ConsoleAPI) first /!\
+ const { messages } = await webConsoleFront.getCachedMessages(["ConsoleAPI"]);
+
+ for (const message of messages) {
+ message.resourceType = ResourceCommand.TYPES.CONSOLE_MESSAGE;
+ }
+ onAvailable(messages);
+
+ // Forward new message events
+ webConsoleFront.on("consoleAPICall", message => {
+ // Ignore console messages that are cloned from the content process
+ // (they aren't relevant to toolboxes still using legacy listeners)
+ if (message.clonedFromContentProcess) {
+ return;
+ }
+
+ message.resourceType = ResourceCommand.TYPES.CONSOLE_MESSAGE;
+ onAvailable([message]);
+ });
+};
diff --git a/devtools/shared/commands/resource/legacy-listeners/css-changes.js b/devtools/shared/commands/resource/legacy-listeners/css-changes.js
new file mode 100644
index 0000000000..e9f3e17075
--- /dev/null
+++ b/devtools/shared/commands/resource/legacy-listeners/css-changes.js
@@ -0,0 +1,28 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js");
+
+module.exports = async function ({ targetFront, onAvailable }) {
+ if (!targetFront.hasActor("changes")) {
+ return;
+ }
+
+ const changesFront = await targetFront.getFront("changes");
+
+ // Get all changes collected up to this point by the ChangesActor on the server,
+ // then fire each change as "add-change".
+ const changes = await changesFront.allChanges();
+ await onAvailable(changes.map(change => toResource(change)));
+
+ changesFront.on("add-change", change => onAvailable([toResource(change)]));
+};
+
+function toResource(change) {
+ return Object.assign(change, {
+ resourceType: ResourceCommand.TYPES.CSS_CHANGE,
+ });
+}
diff --git a/devtools/shared/commands/resource/legacy-listeners/error-messages.js b/devtools/shared/commands/resource/legacy-listeners/error-messages.js
new file mode 100644
index 0000000000..5ba898c917
--- /dev/null
+++ b/devtools/shared/commands/resource/legacy-listeners/error-messages.js
@@ -0,0 +1,62 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js");
+const { MESSAGE_CATEGORY } = require("resource://devtools/shared/constants.js");
+
+module.exports = async function ({ targetCommand, targetFront, onAvailable }) {
+ // Allow the top level target unconditionnally.
+ // Also allow frame, but only in content toolbox, i.e. still ignore them in
+ // the context of the browser toolbox as we inspect messages via the process
+ // targets
+ // Also ignore workers as they are not supported yet. (see bug 1592584)
+ const listenForFrames = targetCommand.descriptorFront.isTabDescriptor;
+ const isAllowed =
+ targetFront.isTopLevel ||
+ targetFront.targetType === targetCommand.TYPES.PROCESS ||
+ (targetFront.targetType === targetCommand.TYPES.FRAME && listenForFrames);
+
+ if (!isAllowed) {
+ return;
+ }
+
+ const webConsoleFront = await targetFront.getFront("console");
+ if (webConsoleFront.isDestroyed()) {
+ return;
+ }
+
+ // Request notifying about new messages. Here the "PageError" type start listening for
+ // both actual PageErrors (emitted as "pageError" events) as well as LogMessages (
+ // emitted as "logMessage" events). This function only set up the listener on the
+ // webConsoleFront for "pageError".
+ await webConsoleFront.startListeners(["PageError"]);
+
+ // Fetch already existing messages
+ // /!\ The actor implementation requires to call startListeners("PageError") first /!\
+ let { messages } = await webConsoleFront.getCachedMessages(["PageError"]);
+
+ // On server < v79, we're also getting CSS Messages that we need to filter out.
+ messages = messages.filter(
+ message => message.pageError.category !== MESSAGE_CATEGORY.CSS_PARSER
+ );
+
+ messages.forEach(message => {
+ message.resourceType = ResourceCommand.TYPES.ERROR_MESSAGE;
+ });
+ // Cached messages don't have the same shape as live messages,
+ // so we need to transform them.
+ onAvailable(messages);
+
+ webConsoleFront.on("pageError", message => {
+ // On server < v79, we're getting CSS Messages that we need to filter out.
+ if (message.pageError.category === MESSAGE_CATEGORY.CSS_PARSER) {
+ return;
+ }
+
+ message.resourceType = ResourceCommand.TYPES.ERROR_MESSAGE;
+ onAvailable([message]);
+ });
+};
diff --git a/devtools/shared/commands/resource/legacy-listeners/moz.build b/devtools/shared/commands/resource/legacy-listeners/moz.build
new file mode 100644
index 0000000000..6ffb469891
--- /dev/null
+++ b/devtools/shared/commands/resource/legacy-listeners/moz.build
@@ -0,0 +1,14 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DevToolsModules(
+ "console-messages.js",
+ "css-changes.js",
+ "error-messages.js",
+ "platform-messages.js",
+ "reflow.js",
+ "root-node.js",
+ "source.js",
+ "thread-states.js",
+)
diff --git a/devtools/shared/commands/resource/legacy-listeners/platform-messages.js b/devtools/shared/commands/resource/legacy-listeners/platform-messages.js
new file mode 100644
index 0000000000..729696275e
--- /dev/null
+++ b/devtools/shared/commands/resource/legacy-listeners/platform-messages.js
@@ -0,0 +1,44 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js");
+
+module.exports = async function ({ targetCommand, targetFront, onAvailable }) {
+ // Only allow the top level target and processes.
+ // Frames can be ignored as logMessage are never sent to them anyway.
+ // Also ignore workers as they are not supported yet. (see bug 1592584)
+ const isAllowed =
+ targetFront.isTopLevel ||
+ targetFront.targetType === targetCommand.TYPES.PROCESS;
+ if (!isAllowed) {
+ return;
+ }
+
+ const webConsoleFront = await targetFront.getFront("console");
+ if (webConsoleFront.isDestroyed()) {
+ return;
+ }
+
+ // Request notifying about new messages. Here the "PageError" type start listening for
+ // both actual PageErrors (emitted as "pageError" events) as well as LogMessages (
+ // emitted as "logMessage" events). This function only set up the listener on the
+ // webConsoleFront for "logMessage".
+ await webConsoleFront.startListeners(["PageError"]);
+
+ // Fetch already existing messages
+ // /!\ The actor implementation requires to call startListeners("PageError") first /!\
+ const { messages } = await webConsoleFront.getCachedMessages(["LogMessage"]);
+
+ for (const message of messages) {
+ message.resourceType = ResourceCommand.TYPES.PLATFORM_MESSAGE;
+ }
+ onAvailable(messages);
+
+ webConsoleFront.on("logMessage", message => {
+ message.resourceType = ResourceCommand.TYPES.PLATFORM_MESSAGE;
+ onAvailable([message]);
+ });
+};
diff --git a/devtools/shared/commands/resource/legacy-listeners/reflow.js b/devtools/shared/commands/resource/legacy-listeners/reflow.js
new file mode 100644
index 0000000000..63802f510d
--- /dev/null
+++ b/devtools/shared/commands/resource/legacy-listeners/reflow.js
@@ -0,0 +1,24 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js");
+
+module.exports = async function ({ targetFront, onAvailable }) {
+ if (!targetFront.getTrait("isBrowsingContext")) {
+ // The reflows only work with BrowsingContext targets
+ return;
+ }
+ const reflowFront = await targetFront.getFront("reflow");
+ reflowFront.on("reflows", reflows =>
+ onAvailable([
+ {
+ resourceType: ResourceCommand.TYPES.REFLOW,
+ reflows,
+ },
+ ])
+ );
+ await reflowFront.start();
+};
diff --git a/devtools/shared/commands/resource/legacy-listeners/root-node.js b/devtools/shared/commands/resource/legacy-listeners/root-node.js
new file mode 100644
index 0000000000..6fa2bcbf22
--- /dev/null
+++ b/devtools/shared/commands/resource/legacy-listeners/root-node.js
@@ -0,0 +1,61 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js");
+
+module.exports = async function ({ targetFront, onAvailable, onDestroyed }) {
+ // XXX: When watching root node for a non top-level target, this will also
+ // ensure the inspector & walker fronts for the target are initialized.
+ // This also implies that we call reparentRemoteFrame on the new walker, which
+ // will create the link between the parent frame NodeFront and the inner
+ // document NodeFront.
+ //
+ // This is not something that will work when the resource is moved to the
+ // server. When it becomes a server side resource, a RootNode would be emitted
+ // directly by the target actor.
+ //
+ // This probably means that the root node resource cannot remain a NodeFront.
+ // It should not be a front and the client should be responsible for
+ // retrieving the corresponding NodeFront.
+ //
+ // The other thing that we are missing with this patch is that we should only
+ // create inspector & walker fronts (and call reparentRemoteFrame) when we get
+ // a RootNode which is directly under an iframe node which is currently
+ // visible and tracked in the markup view.
+ //
+ // For instance, with the following markup:
+ // html
+ // body
+ // div
+ // iframe
+ // remote doc
+ //
+ // If the markup view only sees nodes down to `div`, then the client is not
+ // currently tracking the nodeFront for the `iframe`, and getting a new root
+ // node for the remote document should NOT force the iframe to be tracked on
+ // on the client.
+ //
+ // When we get a RootNode resource, we will need a way to check this before
+ // initializing & reparenting the walker.
+ //
+ if (!targetFront.getTrait("isBrowsingContext")) {
+ // The root-node resource is only available on browsing-context targets.
+ return;
+ }
+
+ const inspectorFront = await targetFront.getFront("inspector");
+ inspectorFront.walker.on("root-available", node => {
+ node.resourceType = ResourceCommand.TYPES.ROOT_NODE;
+ return onAvailable([node]);
+ });
+
+ inspectorFront.walker.on("root-destroyed", node => {
+ node.resourceType = ResourceCommand.TYPES.ROOT_NODE;
+ return onDestroyed([node]);
+ });
+
+ await inspectorFront.walker.watchRootNode();
+};
diff --git a/devtools/shared/commands/resource/legacy-listeners/source.js b/devtools/shared/commands/resource/legacy-listeners/source.js
new file mode 100644
index 0000000000..45ee62f70f
--- /dev/null
+++ b/devtools/shared/commands/resource/legacy-listeners/source.js
@@ -0,0 +1,88 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js");
+
+/**
+ * Emit SOURCE resources, which represents a Javascript source and has the following attributes set on "available":
+ *
+ * - introductionType {null|String}: A string indicating how this source code was introduced into the system.
+ * This will typically be set to "scriptElement", "eval", ...
+ * But this may have many other values:
+ * https://searchfox.org/mozilla-central/rev/ac142717cc067d875e83e4b1316f004f6e063a46/dom/script/ScriptLoader.cpp#2628-2639
+ * https://searchfox.org/mozilla-central/search?q=symbol:_ZN2JS14CompileOptions19setIntroductionTypeEPKc&redirect=false
+ * https://searchfox.org/mozilla-central/rev/ac142717cc067d875e83e4b1316f004f6e063a46/devtools/server/actors/source.js#160-169
+ * - sourceMapBaseURL {String}: Base URL where to look for a source map.
+ * This isn't the source map URL.
+ * - sourceMapURL {null|String}: URL of the source map, if there is one.
+ * - url {null|String}: URL of the source, if it relates to a particular URL.
+ * Evaled sources won't have any related URL.
+ * - isBlackBoxed {Boolean}: Specifying whether the source actor's 'black-boxed' flag is set.
+ * - extensionName {null|String}: If the source comes from an add-on, the add-on name.
+ */
+module.exports = async function ({ targetCommand, targetFront, onAvailable }) {
+ const isBrowserToolbox =
+ targetCommand.descriptorFront.isBrowserProcessDescriptor;
+ const isNonTopLevelFrameTarget =
+ !targetFront.isTopLevel &&
+ targetFront.targetType === targetCommand.TYPES.FRAME;
+
+ if (isBrowserToolbox && isNonTopLevelFrameTarget) {
+ // In the BrowserToolbox, non-top-level frame targets are already
+ // debugged via content-process targets.
+ return;
+ }
+
+ const threadFront = await targetFront.getFront("thread");
+
+ // Use a list of all notified SourceFront as we don't have a newSource event for all sources
+ // but we sometime get sources notified both via newSource event *and* sources() method...
+ // We store actor ID instead of SourceFront as it appears that multiple SourceFront for the same
+ // actor are created...
+ const sourcesActorIDCache = new Set();
+
+ // Forward new sources (but also existing ones, see next comment)
+ threadFront.on("newSource", ({ source }) => {
+ if (sourcesActorIDCache.has(source.actor)) {
+ return;
+ }
+ sourcesActorIDCache.add(source.actor);
+ // source is a SourceActor's form, add the resourceType attribute on it
+ source.resourceType = ResourceCommand.TYPES.SOURCE;
+ onAvailable([source]);
+ });
+
+ // Forward already existing sources
+ // Note that calling `sources()` will end up emitting `newSource` event for all existing sources.
+ // But not in some cases, for example, when the thread is already paused.
+ // (And yes, it means that already existing sources can be transfered twice over the wire)
+ //
+ // Also, browser_ext_devtools_inspectedWindow_targetSwitch.js creates many top level targets,
+ // for which the SourceMapURLService will fetch sources. But these targets are destroyed while
+ // the test is running and when they are, we purge all pending requests, including this one.
+ // So ignore any error if this request failed on destruction.
+ let sources;
+ try {
+ sources = await threadFront.sources();
+ } catch (e) {
+ if (threadFront.isDestroyed()) {
+ return;
+ }
+ throw e;
+ }
+
+ // Note that `sources()` doesn't encapsulate SourceFront into a `source` attribute
+ // while `newSource` event does.
+ sources = sources.filter(source => {
+ return !sourcesActorIDCache.has(source.actor);
+ });
+ for (const source of sources) {
+ sourcesActorIDCache.add(source.actor);
+ // source is a SourceActor's form, add the resourceType attribute on it
+ source.resourceType = ResourceCommand.TYPES.SOURCE;
+ }
+ onAvailable(sources);
+};
diff --git a/devtools/shared/commands/resource/legacy-listeners/thread-states.js b/devtools/shared/commands/resource/legacy-listeners/thread-states.js
new file mode 100644
index 0000000000..42c922072a
--- /dev/null
+++ b/devtools/shared/commands/resource/legacy-listeners/thread-states.js
@@ -0,0 +1,81 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js");
+
+module.exports = async function ({ targetCommand, targetFront, onAvailable }) {
+ const isBrowserToolbox =
+ targetCommand.descriptorFront.isBrowserProcessDescriptor;
+ const isNonTopLevelFrameTarget =
+ !targetFront.isTopLevel &&
+ targetFront.targetType === targetCommand.TYPES.FRAME;
+
+ if (isBrowserToolbox && isNonTopLevelFrameTarget) {
+ // In the BrowserToolbox, non-top-level frame targets are already
+ // debugged via content-process targets.
+ return;
+ }
+
+ // Wait for the thread actor to be attached, otherwise getFront(thread) will throw for worker targets
+ // This is because worker target are still kind of descriptors and are only resolved into real target
+ // after being attached. And the thread actor ID is only retrieved and available after being attached.
+ await targetFront.onThreadAttached;
+
+ if (targetFront.isDestroyed()) {
+ return;
+ }
+ const threadFront = await targetFront.getFront("thread");
+
+ let isInterrupted = false;
+ const onPausedPacket = packet => {
+ // If paused by an explicit interrupt, which are generated by the
+ // slow script dialog and internal events such as setting
+ // breakpoints, ignore the event.
+ const { why } = packet;
+ if (why.type === "interrupted" && !why.onNext) {
+ isInterrupted = true;
+ return;
+ }
+
+ // Ignore attached events because they are not useful to the user.
+ if (why.type == "alreadyPaused" || why.type == "attached") {
+ return;
+ }
+
+ onAvailable([
+ {
+ resourceType: ResourceCommand.TYPES.THREAD_STATE,
+ state: "paused",
+ why,
+ frame: packet.frame,
+ },
+ ]);
+ };
+ threadFront.on("paused", onPausedPacket);
+
+ threadFront.on("resumed", packet => {
+ // NOTE: the client suppresses resumed events while interrupted
+ // to prevent unintentional behavior.
+ // see [client docs](devtools/client/debugger/src/client/README.md#interrupted) for more information.
+ if (isInterrupted) {
+ isInterrupted = false;
+ return;
+ }
+
+ onAvailable([
+ {
+ resourceType: ResourceCommand.TYPES.THREAD_STATE,
+ state: "resumed",
+ },
+ ]);
+ });
+
+ // Notify about already paused thread
+ const pausedPacket = threadFront.getLastPausePacket();
+ if (pausedPacket) {
+ onPausedPacket(pausedPacket);
+ }
+};
diff --git a/devtools/shared/commands/resource/moz.build b/devtools/shared/commands/resource/moz.build
new file mode 100644
index 0000000000..24112de5c1
--- /dev/null
+++ b/devtools/shared/commands/resource/moz.build
@@ -0,0 +1,15 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DIRS += [
+ "legacy-listeners",
+ "transformers",
+]
+
+DevToolsModules(
+ "resource-command.js",
+)
+
+if CONFIG["MOZ_BUILD_APP"] != "mobile/android":
+ BROWSER_CHROME_MANIFESTS += ["tests/browser.ini"]
diff --git a/devtools/shared/commands/resource/resource-command.js b/devtools/shared/commands/resource/resource-command.js
new file mode 100644
index 0000000000..1eb9dd40ae
--- /dev/null
+++ b/devtools/shared/commands/resource/resource-command.js
@@ -0,0 +1,1352 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const { throttle } = require("resource://devtools/shared/throttle.js");
+
+let gLastResourceId = 0;
+
+function cacheKey(resourceType, resourceId) {
+ return `${resourceType}:${resourceId}`;
+}
+
+class ResourceCommand {
+ /**
+ * This class helps retrieving existing and listening to resources.
+ * A resource is something that:
+ * - the target you are debugging exposes
+ * - can be created as early as the process/worker/page starts loading
+ * - can already exist, or will be created later on
+ * - doesn't require any user data to be fetched, only a type/category
+ *
+ * @param object commands
+ * The commands object with all interfaces defined from devtools/shared/commands/
+ */
+ constructor({ commands }) {
+ this.targetCommand = commands.targetCommand;
+
+ this._onTargetAvailable = this._onTargetAvailable.bind(this);
+ this._onTargetDestroyed = this._onTargetDestroyed.bind(this);
+
+ this._onResourceAvailable = this._onResourceAvailable.bind(this);
+ this._onResourceDestroyed = this._onResourceDestroyed.bind(this);
+
+ // Array of all the currently registered watchers, which contains object with attributes:
+ // - {String} resources: list of all resource watched by this one watcher
+ // - {Function} onAvailable: watcher's function to call when a new resource is available
+ // - {Function} onUpdated: watcher's function to call when a resource has been updated
+ // - {Function} onDestroyed: watcher's function to call when a resource is destroyed
+ this._watchers = [];
+
+ // Set of watchers currently going through watchResources, only used to handle
+ // early calls to unwatchResources. Using a Set instead of an array for easier
+ // delete operations.
+ this._pendingWatchers = new Set();
+
+ // Caches for all resources by the order that the resource was taken.
+ this._cache = new Map();
+ this._listenedResources = new Set();
+
+ // WeakMap used to avoid starting a legacy listener twice for the same
+ // target + resource-type pair. Legacy listener creation can be subject to
+ // race conditions.
+ // Maps a target front to an array of resource types.
+ this._existingLegacyListeners = new WeakMap();
+ this._processingExistingResources = new Set();
+
+ // List of targetFront event listener unregistration functions keyed by target front.
+ // These are called when unwatching resources, so if a consumer starts watching resources again,
+ // we don't have listeners registered twice.
+ this._offTargetFrontListeners = new Map();
+
+ this._notifyWatchers = this._notifyWatchers.bind(this);
+ this._throttledNotifyWatchers = throttle(this._notifyWatchers, 100);
+ }
+
+ get watcherFront() {
+ return this.targetCommand.watcherFront;
+ }
+
+ addResourceToCache(resource) {
+ const { resourceId, resourceType } = resource;
+ this._cache.set(cacheKey(resourceType, resourceId), resource);
+ }
+
+ /**
+ * Clear all the resources related to specifed resource types.
+ * Should also trigger clearing of the caches that exists on the related
+ * serverside resource watchers.
+ *
+ * @param {Array:string} resourceTypes
+ * A list of all the resource types whose
+ * resources shouled be cleared.
+ */
+ async clearResources(resourceTypes) {
+ if (!Array.isArray(resourceTypes)) {
+ throw new Error("clearResources expects a list of resources types");
+ }
+ // Clear the cached resources of the type.
+ for (const [key, resource] of this._cache) {
+ if (resourceTypes.includes(resource.resourceType)) {
+ // NOTE: To anyone paranoid like me, yes it is okay to delete from a Map while iterating it.
+ this._cache.delete(key);
+ }
+ }
+
+ const resourcesToClear = resourceTypes.filter(resourceType =>
+ this.hasResourceCommandSupport(resourceType)
+ );
+ if (resourcesToClear.length) {
+ this.watcherFront.clearResources(resourcesToClear);
+ }
+ }
+ /**
+ * Return all specified resources cached in this watcher.
+ *
+ * @param {String} resourceType
+ * @return {Array} resources cached in this watcher
+ */
+ getAllResources(resourceType) {
+ const result = [];
+ for (const resource of this._cache.values()) {
+ if (resource.resourceType === resourceType) {
+ result.push(resource);
+ }
+ }
+ return result;
+ }
+
+ /**
+ * Return the specified resource cached in this watcher.
+ *
+ * @param {String} resourceType
+ * @param {String} resourceId
+ * @return {Object} resource cached in this watcher
+ */
+ getResourceById(resourceType, resourceId) {
+ return this._cache.get(cacheKey(resourceType, resourceId));
+ }
+
+ /**
+ * Request to start retrieving all already existing instances of given
+ * type of resources and also start watching for the one to be created after.
+ *
+ * @param {Array:string} resources
+ * List of all resources which should be fetched and observed.
+ * @param {Object} options
+ * - {Function} onAvailable: This attribute is mandatory.
+ * Function which will be called with an array of resources
+ * each time resource(s) are created.
+ * A second dictionary argument with `areExistingResources` boolean
+ * attribute helps knowing if that's live resources, or some coming
+ * from ResourceCommand cache.
+ * - {Function} onUpdated: This attribute is optional.
+ * Function which will be called with an array of updates resources
+ * each time resource(s) are updated.
+ * These resources were previously notified via onAvailable.
+ * - {Function} onDestroyed: This attribute is optional.
+ * Function which will be called with an array of deleted resources
+ * each time resource(s) are destroyed.
+ * - {boolean} ignoreExistingResources:
+ * This attribute is optional. Default value is false.
+ * If set to true, onAvailable won't be called with
+ * existing resources.
+ */
+ async watchResources(resources, options) {
+ const {
+ onAvailable,
+ onUpdated,
+ onDestroyed,
+ ignoreExistingResources = false,
+ } = options;
+
+ if (typeof onAvailable !== "function") {
+ throw new Error(
+ "ResourceCommand.watchResources expects an onAvailable function as argument"
+ );
+ }
+
+ for (const type of resources) {
+ if (!this._isValidResourceType(type)) {
+ throw new Error(
+ `ResourceCommand.watchResources invoked with an unknown type: "${type}"`
+ );
+ }
+ }
+
+ // Pending watchers are used in unwatchResources to remove watchers which
+ // are not fully registered yet. Store `onAvailable` which is the unique key
+ // for a watcher, as well as the resources array, so that unwatchResources
+ // can update the array if we stop watching a specific resource.
+ const pendingWatcher = {
+ resources,
+ onAvailable,
+ };
+ this._pendingWatchers.add(pendingWatcher);
+
+ // Bug 1675763: Watcher actor is not available in all situations yet.
+ if (!this._listenerRegistered && this.watcherFront) {
+ this._listenerRegistered = true;
+ // Resources watched from the parent process will be emitted on the Watcher Actor.
+ // So that we also have to listen for this event on it, in addition to all targets.
+ this.watcherFront.on(
+ "resource-available-form",
+ this._onResourceAvailable.bind(this, {
+ watcherFront: this.watcherFront,
+ })
+ );
+ this.watcherFront.on(
+ "resource-updated-form",
+ this._onResourceUpdated.bind(this, { watcherFront: this.watcherFront })
+ );
+ this.watcherFront.on(
+ "resource-destroyed-form",
+ this._onResourceDestroyed.bind(this, {
+ watcherFront: this.watcherFront,
+ })
+ );
+ }
+
+ const promises = [];
+ for (const resource of resources) {
+ promises.push(this._startListening(resource));
+ }
+ await Promise.all(promises);
+
+ // The resource cache is immediately filled when receiving the sources, but they are
+ // emitted with a delay due to throttling. Since the cache can contain resources that
+ // will soon be emitted, we have to flush it before adding the new listeners.
+ // Otherwise _forwardExistingResources might emit resources that will also be emitted by
+ // the next `_notifyWatchers` call done when calling `_startListening`, which will pull the
+ // "already existing" resources.
+ this._notifyWatchers();
+
+ // Update the _pendingWatchers set before adding the watcher to _watchers.
+ this._pendingWatchers.delete(pendingWatcher);
+
+ // If unwatchResources was called in the meantime, use pendingWatcher's
+ // resources to get the updated list of watched resources.
+ const watchedResources = pendingWatcher.resources;
+
+ // If no resource needs to be watched anymore, do not add an empty watcher
+ // to _watchers, and do not notify about cached resources.
+ if (!watchedResources.length) {
+ return;
+ }
+
+ // Register the watcher just after calling _startListening in order to avoid it being called
+ // for already existing resources, which will optionally be notified via _forwardExistingResources
+ this._watchers.push({
+ resources: watchedResources,
+ onAvailable,
+ onUpdated,
+ onDestroyed,
+ pendingEvents: [],
+ });
+
+ if (!ignoreExistingResources) {
+ await this._forwardExistingResources(watchedResources, onAvailable);
+ }
+ }
+
+ /**
+ * Stop watching for given type of resources.
+ * See `watchResources` for the arguments as both methods receive the same.
+ * Note that `onUpdated` and `onDestroyed` attributes of `options` aren't used here.
+ * Only `onAvailable` attribute is looked up and we unregister all the other registered callbacks
+ * when a matching available callback is found.
+ */
+ unwatchResources(resources, options) {
+ const { onAvailable } = options;
+
+ if (typeof onAvailable !== "function") {
+ throw new Error(
+ "ResourceCommand.unwatchResources expects an onAvailable function as argument"
+ );
+ }
+
+ for (const type of resources) {
+ if (!this._isValidResourceType(type)) {
+ throw new Error(
+ `ResourceCommand.unwatchResources invoked with an unknown type: "${type}"`
+ );
+ }
+ }
+
+ // Unregister the callbacks from the watchers registries.
+ // Check _watchers for the fully initialized watchers, as well as
+ // `_pendingWatchers` for new watchers still being created by `watchResources`
+ const allWatchers = [...this._watchers, ...this._pendingWatchers];
+ for (const watcherEntry of allWatchers) {
+ // onAvailable is the only mandatory argument which ends up being used to match
+ // the right watcher entry.
+ if (watcherEntry.onAvailable == onAvailable) {
+ // Remove all resources that we stop watching. We may still watch for some others.
+ watcherEntry.resources = watcherEntry.resources.filter(resourceType => {
+ return !resources.includes(resourceType);
+ });
+ }
+ }
+ this._watchers = this._watchers.filter(entry => {
+ // Remove entries entirely if it isn't watching for any resource type
+ return !!entry.resources.length;
+ });
+
+ // Stop listening to all resources for which we removed the last watcher
+ for (const resource of resources) {
+ const isResourceWatched = allWatchers.some(watcherEntry =>
+ watcherEntry.resources.includes(resource)
+ );
+
+ // Also check in _listenedResources as we may call unwatchResources
+ // for resources that we haven't started watching for.
+ if (!isResourceWatched && this._listenedResources.has(resource)) {
+ this._stopListening(resource);
+ }
+ }
+
+ // Stop watching for targets if we removed the last listener.
+ if (this._listenedResources.size == 0) {
+ this._unwatchAllTargets();
+ }
+ }
+
+ /**
+ * Wait for a single resource of the provided resourceType.
+ *
+ * @param {String} resourceType
+ * One of ResourceCommand.TYPES, type of the expected resource.
+ * @param {Object} additional options
+ * - {Boolean} ignoreExistingResources: ignore existing resources or not.
+ * - {Function} predicate: if provided, will wait until a resource makes
+ * predicate(resource) return true.
+ * @return {Promise<Object>}
+ * Return a promise which resolves once we fully settle the resource listener.
+ * You should await for its resolution before doing the action which may fire
+ * your resource.
+ * This promise will expose an object with `onResource` attribute,
+ * itself being a promise, which will resolve once a matching resource is received.
+ */
+ async waitForNextResource(
+ resourceType,
+ { ignoreExistingResources = false, predicate } = {}
+ ) {
+ // If no predicate was provided, convert to boolean to avoid resolving for
+ // empty `resources` arrays.
+ predicate = predicate || (resource => !!resource);
+
+ let resolve;
+ const promise = new Promise(r => (resolve = r));
+ const onAvailable = async resources => {
+ const matchingResource = resources.find(resource => predicate(resource));
+ if (matchingResource) {
+ this.unwatchResources([resourceType], { onAvailable });
+ resolve(matchingResource);
+ }
+ };
+
+ await this.watchResources([resourceType], {
+ ignoreExistingResources,
+ onAvailable,
+ });
+ return { onResource: promise };
+ }
+
+ /**
+ * Check if there are any watchers for the specified resource.
+ *
+ * @param {String} resourceType
+ * One of ResourceCommand.TYPES
+ * @return {Boolean}
+ * If the resources type is beibg watched.
+ */
+ isResourceWatched(resourceType) {
+ return this._listenedResources.has(resourceType);
+ }
+
+ /**
+ * Start watching for all already existing and future targets.
+ *
+ * We are using ALL_TYPES, but this won't force listening to all types.
+ * It will only listen for types which are defined by `TargetCommand.startListening`.
+ */
+ async _watchAllTargets() {
+ if (!this._watchTargetsPromise) {
+ // If this is the very first listener registered, of all kind of resource types:
+ // * we want to start observing targets via TargetCommand
+ // * _onTargetAvailable will be called for each already existing targets and the next one to come
+ this._watchTargetsPromise = this.targetCommand.watchTargets({
+ types: this.targetCommand.ALL_TYPES,
+ onAvailable: this._onTargetAvailable,
+ onDestroyed: this._onTargetDestroyed,
+ });
+ }
+ return this._watchTargetsPromise;
+ }
+
+ _unwatchAllTargets() {
+ if (!this._watchTargetsPromise) {
+ return;
+ }
+
+ for (const offList of this._offTargetFrontListeners.values()) {
+ offList.forEach(off => off());
+ }
+ this._offTargetFrontListeners.clear();
+
+ this._watchTargetsPromise = null;
+ this.targetCommand.unwatchTargets({
+ types: this.targetCommand.ALL_TYPES,
+ onAvailable: this._onTargetAvailable,
+ onDestroyed: this._onTargetDestroyed,
+ });
+ }
+
+ /**
+ * For a given resource type, start the legacy listeners for all already existing targets.
+ * Do that only if we have to. If this resourceType requires legacy listeners.
+ */
+ async _startLegacyListenersForExistingTargets(resourceType) {
+ // If we were already listening to targets, we want to start the legacy listeners
+ // for all already existing targets.
+ const shouldRunLegacyListeners =
+ !this.hasResourceCommandSupport(resourceType) ||
+ this._shouldRunLegacyListenerEvenWithWatcherSupport(resourceType);
+ if (shouldRunLegacyListeners) {
+ const promises = [];
+ const targets = this.targetCommand.getAllTargets(
+ this.targetCommand.ALL_TYPES
+ );
+ for (const targetFront of targets) {
+ // We disable warning in case we already registered the legacy listener for this target
+ // as this code may race with the call from onTargetAvailable if we end up having multiple
+ // calls to _startListening in parallel.
+ promises.push(
+ this._watchResourcesForTarget({
+ targetFront,
+ resourceType,
+ disableWarning: true,
+ })
+ );
+ }
+ await Promise.all(promises);
+ }
+ }
+
+ /**
+ * Method called by the TargetCommand for each already existing or target which has just been created.
+ *
+ * @param {Object} arg
+ * @param {Front} arg.targetFront
+ * The Front of the target that is available.
+ * This Front inherits from TargetMixin and is typically
+ * composed of a WindowGlobalTargetFront or ContentProcessTargetFront.
+ * @param {Boolean} arg.isTargetSwitching
+ * true when the new target was created because of a target switching.
+ */
+ async _onTargetAvailable({ targetFront, isTargetSwitching }) {
+ const resources = [];
+ if (isTargetSwitching) {
+ // WatcherActor currently only watches additional frame targets and
+ // explicitely ignores top level one that may be created when navigating
+ // to a new process.
+ // In order to keep working resources that are being watched via the
+ // Watcher actor, we have to unregister and re-register the resource
+ // types. This will force calling `Resources.watchResources` on the new top
+ // level target.
+ for (const resourceType of Object.values(ResourceCommand.TYPES)) {
+ // ...which has at least one listener...
+ if (!this._listenedResources.has(resourceType)) {
+ continue;
+ }
+
+ if (this._shouldRestartListenerOnTargetSwitching(resourceType)) {
+ this._stopListening(resourceType, {
+ bypassListenerCount: true,
+ });
+ resources.push(resourceType);
+ }
+ }
+ }
+
+ if (targetFront.isDestroyed()) {
+ return;
+ }
+
+ // If we are target switching, we already stop & start listening to all the
+ // currently monitored resources.
+ if (!isTargetSwitching) {
+ // For each resource type...
+ for (const resourceType of Object.values(ResourceCommand.TYPES)) {
+ // ...which has at least one listener...
+ if (!this._listenedResources.has(resourceType)) {
+ continue;
+ }
+ // ...request existing resource and new one to come from this one target
+ // *but* only do that for backward compat, where we don't have the watcher API
+ // (See bug 1626647)
+ await this._watchResourcesForTarget({ targetFront, resourceType });
+ }
+ }
+
+ // Compared to the TargetCommand and Watcher.watchTargets,
+ // We do call Watcher.watchResources, but the events are fired on the target.
+ // That's because the Watcher runs in the parent process/main thread, while resources
+ // are available from the target's process/thread.
+ const offResourceAvailable = targetFront.on(
+ "resource-available-form",
+ this._onResourceAvailable.bind(this, { targetFront })
+ );
+ const offResourceUpdated = targetFront.on(
+ "resource-updated-form",
+ this._onResourceUpdated.bind(this, { targetFront })
+ );
+ const offResourceDestroyed = targetFront.on(
+ "resource-destroyed-form",
+ this._onResourceDestroyed.bind(this, { targetFront })
+ );
+
+ const offList = this._offTargetFrontListeners.get(targetFront) || [];
+ offList.push(
+ offResourceAvailable,
+ offResourceUpdated,
+ offResourceDestroyed
+ );
+
+ if (isTargetSwitching) {
+ await Promise.all(
+ resources.map(resourceType =>
+ this._startListening(resourceType, {
+ bypassListenerCount: true,
+ })
+ )
+ );
+ }
+
+ // DOCUMENT_EVENT's will-navigate should replace target actor's will-navigate event,
+ // but only for targets provided by the watcher actor.
+ // Emit a fake DOCUMENT_EVENT's "will-navigate" out of target actor's will-navigate
+ // until watcher actor is supported by all descriptors (bug 1675763).
+ if (!this.targetCommand.hasTargetWatcherSupport()) {
+ const offWillNavigate = targetFront.on(
+ "will-navigate",
+ ({ url, isFrameSwitching }) => {
+ targetFront.emit("resource-available-form", [
+ {
+ resourceType: this.TYPES.DOCUMENT_EVENT,
+ name: "will-navigate",
+ time: Date.now(), // will-navigate was not passing any timestamp
+ isFrameSwitching,
+ newURI: url,
+ },
+ ]);
+ }
+ );
+ offList.push(offWillNavigate);
+ }
+
+ this._offTargetFrontListeners.set(targetFront, offList);
+ }
+
+ _shouldRestartListenerOnTargetSwitching(resourceType) {
+ // Note that we aren't using isServerTargetSwitchingEnabled, nor checking the
+ // server side target switching preference as we may have server side targets
+ // even when this is false/disabled.
+ // This will happen for bfcache navigations, even with server side targets disabled.
+ // `followWindowGlobalLifeCycle` will be false for the first top level target
+ // and only become true when doing a bfcache navigation.
+ // (only server side targets follow the WindowGlobal lifecycle)
+ // When server side targets are enabled, this will always be true.
+ const isServerSideTarget =
+ this.targetCommand.targetFront.targetForm.followWindowGlobalLifeCycle;
+ if (isServerSideTarget) {
+ // For top-level targets created from the server, only restart legacy
+ // listeners.
+ return !this.hasResourceCommandSupport(resourceType);
+ }
+
+ // For top-level targets created from the client we should always restart
+ // listeners.
+ return true;
+ }
+
+ /**
+ * Method called by the TargetCommand when a target has just been destroyed
+ * @param {Object} arg
+ * @param {Front} arg.targetFront
+ * The Front of the target that was destroyed
+ * @param {Boolean} arg.isModeSwitching
+ * true when this is called as the result of a change to the devtools.browsertoolbox.scope pref.
+ */
+ _onTargetDestroyed({ targetFront, isModeSwitching }) {
+ // Clear the map of legacy listeners for this target.
+ this._existingLegacyListeners.set(targetFront, []);
+ this._offTargetFrontListeners.delete(targetFront);
+
+ // Purge the cache from any resource related to the destroyed target.
+ // Top level BrowsingContext target will be purge via DOCUMENT_EVENT will-navigate events.
+ // If we were to clean resources from target-destroyed, we will clear resources
+ // happening between will-navigate and target-destroyed. Typically the navigation request
+ // At the moment, isModeSwitching can only be true when targetFront.isTopLevel isn't true,
+ // so we don't need to add a specific check for isModeSwitching.
+ if (!targetFront.isTopLevel || !targetFront.isBrowsingContext) {
+ for (const [key, resource] of this._cache) {
+ if (resource.targetFront === targetFront) {
+ // NOTE: To anyone paranoid like me, yes it is okay to delete from a Map while iterating it.
+ this._cache.delete(key);
+ }
+ }
+ }
+
+ // Purge "available" pendingEvents for resources from the destroyed target when switching
+ // mode as we want to ignore those.
+ if (isModeSwitching) {
+ for (const watcherEntry of this._watchers) {
+ for (const pendingEvent of watcherEntry.pendingEvents) {
+ if (pendingEvent.callbackType == "available") {
+ pendingEvent.updates = pendingEvent.updates.filter(
+ update => update.targetFront !== targetFront
+ );
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Method called either by:
+ * - the backward compatibility code (LegacyListeners)
+ * - target actors RDP events
+ * whenever an already existing resource is being listed or when a new one
+ * has been created.
+ *
+ * @param {Object} source
+ * A dictionary object with only one of these two attributes:
+ * - targetFront: a Target Front, if the resource is watched from the target process or thread
+ * - watcherFront: a Watcher Front, if the resource is watched from the parent process
+ * @param {Array<json/Front>} resources
+ * Depending on the resource Type, it can be an Array composed of either JSON objects or Fronts,
+ * which describes the resource.
+ */
+ async _onResourceAvailable({ targetFront, watcherFront }, resources) {
+ let includesDocumentEventWillNavigate = false;
+ let includesDocumentEventDomLoading = false;
+ for (let resource of resources) {
+ const { resourceType } = resource;
+
+ if (watcherFront) {
+ targetFront = await this._getTargetForWatcherResource(resource);
+ // When we receive resources from the Watcher actor,
+ // there is no guarantee that the target front is fully initialized.
+ // The Target Front is initialized by the TargetCommand, by calling TargetFront.attachAndInitThread.
+ // We have to wait for its completion as resources watchers are expecting it to be completed.
+ //
+ // But when navigating, we may receive resources packets for a destroyed target.
+ // Or, in the context of the browser toolbox, they may not relate to any target.
+ if (targetFront) {
+ await targetFront.initialized;
+ }
+ }
+
+ // isAlreadyExistingResource indicates that the resources already existed before
+ // the resource command started watching for this type of resource.
+ resource.isAlreadyExistingResource =
+ this._processingExistingResources.has(resourceType);
+
+ // Put the targetFront on the resource for easy retrieval.
+ // (Resources from the legacy listeners may already have the attribute set)
+ if (!resource.targetFront) {
+ resource.targetFront = targetFront;
+ }
+
+ if (ResourceTransformers[resourceType]) {
+ resource = ResourceTransformers[resourceType]({
+ resource,
+ targetCommand: this.targetCommand,
+ targetFront,
+ watcherFront: this.watcherFront,
+ });
+ }
+
+ if (!resource.resourceId) {
+ resource.resourceId = `auto:${++gLastResourceId}`;
+ }
+
+ // Only consider top level document, and ignore remote iframes top document
+ const isWillNavigate =
+ resourceType == ResourceCommand.TYPES.DOCUMENT_EVENT &&
+ resource.name == "will-navigate";
+ if (isWillNavigate && resource.targetFront.isTopLevel) {
+ includesDocumentEventWillNavigate = true;
+ this._onWillNavigate(resource.targetFront);
+ }
+
+ if (
+ resourceType == ResourceCommand.TYPES.DOCUMENT_EVENT &&
+ resource.name == "dom-loading" &&
+ resource.targetFront.isTopLevel
+ ) {
+ includesDocumentEventDomLoading = true;
+ }
+
+ this._queueResourceEvent("available", resourceType, resource);
+
+ // Avoid storing will-navigate resource and consider it as a transcient resource.
+ // We do that to prevent leaking this resource (and its target) on navigation.
+ // We do clear the cache in _onWillNavigate, that we call a few lines before this.
+ if (!isWillNavigate) {
+ this.addResourceToCache(resource);
+ }
+ }
+
+ // If we receive the DOCUMENT_EVENT for:
+ // - will-navigate
+ // - dom-loading + we're using the service worker legacy listener
+ // then flush immediately the resources to notify about the navigation sooner than later.
+ // (this is especially useful for tests, even if they should probably avoid depending on this...)
+ if (
+ includesDocumentEventWillNavigate ||
+ (includesDocumentEventDomLoading &&
+ !this.targetCommand.hasTargetWatcherSupport("service_worker"))
+ ) {
+ this._notifyWatchers();
+ } else {
+ this._throttledNotifyWatchers();
+ }
+ }
+
+ /**
+ * Method called either by:
+ * - the backward compatibility code (LegacyListeners)
+ * - target actors RDP events
+ * Called everytime a resource is updated in the remote target.
+ *
+ * @param {Object} source
+ * Please see _onResourceAvailable for this parameter.
+ * @param {Array<Object>} updates
+ * Depending on the listener.
+ *
+ * Among the element in the array, the following attributes are given special handling.
+ * - resourceType {String}:
+ * The type of resource to be updated.
+ * - resourceId {String}:
+ * The id of resource to be updated.
+ * - resourceUpdates {Object}:
+ * If resourceUpdates is in the element, a cached resource specified by resourceType
+ * and resourceId is updated by Object.assign(cachedResource, resourceUpdates).
+ * - nestedResourceUpdates {Object}:
+ * If `nestedResourceUpdates` is passed, update one nested attribute with a new value
+ * This allows updating one attribute of an object stored in a resource's attribute,
+ * as well as adding new elements to arrays.
+ * `path` is an array mentioning all nested attribute to walk through.
+ * `value` is the new nested attribute value to set.
+ *
+ * And also, the element is passed to the listener as it is as “update” object.
+ * So if we don't want to update a cached resource but have information want to
+ * pass on to the listener, can pass it on using attributes other than the ones
+ * listed above.
+ * For example, if the element consists of like
+ * "{ resourceType:… resourceId:…, testValue: “test”, }”,
+ * the listener can receive the value as follows.
+ *
+ * onResourceUpdate({ update }) {
+ * console.log(update.testValue); // “test” should be displayed
+ * }
+ */
+ async _onResourceUpdated({ targetFront, watcherFront }, updates) {
+ for (const update of updates) {
+ const {
+ resourceType,
+ resourceId,
+ resourceUpdates,
+ nestedResourceUpdates,
+ } = update;
+
+ if (!resourceId) {
+ console.warn(`Expected resource ${resourceType} to have a resourceId`);
+ }
+
+ // See _onResourceAvailable()
+ // We also need to wait for the related targetFront to be initialized
+ // otherwise we would notify about the udpate *before* the available
+ // and the resource won't be in _cache.
+ if (watcherFront) {
+ targetFront = await this._getTargetForWatcherResource(update);
+ // When we receive the navigation request, the target front has already been
+ // destroyed, but this is fine. The cached resource has the reference to
+ // the (destroyed) target front and it is fully initialized.
+ if (targetFront) {
+ await targetFront.initialized;
+ }
+ }
+
+ const existingResource = this._cache.get(
+ cacheKey(resourceType, resourceId)
+ );
+ if (!existingResource) {
+ continue;
+ }
+
+ if (resourceUpdates) {
+ Object.assign(existingResource, resourceUpdates);
+ }
+
+ if (nestedResourceUpdates) {
+ for (const { path, value } of nestedResourceUpdates) {
+ let target = existingResource;
+
+ for (let i = 0; i < path.length - 1; i++) {
+ target = target[path[i]];
+ }
+
+ target[path[path.length - 1]] = value;
+ }
+ }
+ this._queueResourceEvent("updated", resourceType, {
+ resource: existingResource,
+ update,
+ });
+ }
+ this._throttledNotifyWatchers();
+ }
+
+ /**
+ * Called everytime a resource is destroyed in the remote target.
+ * See _onResourceAvailable for the argument description.
+ */
+ async _onResourceDestroyed({ targetFront, watcherFront }, resources) {
+ for (const resource of resources) {
+ const { resourceType, resourceId } = resource;
+ this._cache.delete(cacheKey(resourceType, resourceId));
+ this._queueResourceEvent("destroyed", resourceType, resource);
+ }
+ this._throttledNotifyWatchers();
+ }
+
+ _queueResourceEvent(callbackType, resourceType, update) {
+ for (const { resources, pendingEvents } of this._watchers) {
+ // This watcher doesn't listen to this type of resource
+ if (!resources.includes(resourceType)) {
+ continue;
+ }
+ // If we receive a new event of the same type, accumulate the new update in the last event
+ if (pendingEvents.length) {
+ const lastEvent = pendingEvents[pendingEvents.length - 1];
+ if (lastEvent.callbackType == callbackType) {
+ lastEvent.updates.push(update);
+ continue;
+ }
+ }
+ // Otherwise, pile up a new event, which will force calling watcher
+ // callback a new time
+ pendingEvents.push({
+ callbackType,
+ updates: [update],
+ });
+ }
+ }
+
+ /**
+ * Flush the pending event and notify all the currently registered watchers
+ * about all the available, updated and destroyed events that have been accumulated in
+ * `_watchers`'s `pendingEvents` arrays.
+ */
+ _notifyWatchers() {
+ for (const watcherEntry of this._watchers) {
+ const { onAvailable, onUpdated, onDestroyed, pendingEvents } =
+ watcherEntry;
+ // Immediately clear the buffer in order to avoid possible races, where an event listener
+ // would end up somehow adding a new throttled resource
+ watcherEntry.pendingEvents = [];
+
+ for (const { callbackType, updates } of pendingEvents) {
+ try {
+ if (callbackType == "available") {
+ onAvailable(updates, { areExistingResources: false });
+ } else if (callbackType == "updated" && onUpdated) {
+ onUpdated(updates);
+ } else if (callbackType == "destroyed" && onDestroyed) {
+ onDestroyed(updates);
+ }
+ } catch (e) {
+ console.error(
+ "Exception while calling a ResourceCommand",
+ callbackType,
+ "callback",
+ ":",
+ e
+ );
+ }
+ }
+ }
+ }
+
+ // Compute the target front if the resource comes from the Watcher Actor.
+ // (`targetFront` will be null as the watcher is in the parent process
+ // and targets are in distinct processes)
+ _getTargetForWatcherResource(resource) {
+ const { browsingContextID, innerWindowId, resourceType } = resource;
+
+ // Some privileged resources aren't related to any BrowsingContext
+ // and so aren't bound to any Target Front.
+ // Server watchers should pass an explicit "-1" value in order to prevent
+ // silently ignoring an undefined browsingContextID attribute.
+ if (browsingContextID == -1) {
+ return null;
+ }
+
+ if (innerWindowId && this.targetCommand.isServerTargetSwitchingEnabled()) {
+ return this.watcherFront.getWindowGlobalTargetByInnerWindowId(
+ innerWindowId
+ );
+ } else if (browsingContextID) {
+ return this.watcherFront.getWindowGlobalTarget(browsingContextID);
+ }
+ console.error(
+ `Resource of ${resourceType} is missing a browsingContextID or innerWindowId attribute`
+ );
+ return null;
+ }
+
+ _onWillNavigate(targetFront) {
+ // Special case for toolboxes debugging a document,
+ // purge the cache entirely when we start navigating to a new document.
+ // Other toolboxes and additional target for remote iframes or content process
+ // will be purge from onTargetDestroyed.
+
+ // NOTE: we could `clear` the cache here, but technically if anything is
+ // currently iterating over resources provided by getAllResources, that
+ // would interfere with their iteration. We just assign a new Map here to
+ // leave those iterators as is.
+ this._cache = new Map();
+ }
+
+ /**
+ * Tells if the server supports listening to the given resource type
+ * via the watcher actor's watchResources method.
+ *
+ * @return {Boolean} True, if the server supports this type.
+ */
+ hasResourceCommandSupport(resourceType) {
+ return this.watcherFront?.traits?.resources?.[resourceType];
+ }
+
+ /**
+ * Tells if the server supports listening to the given resource type
+ * via the watcher actor's watchResources method, and that, for a specific
+ * target.
+ *
+ * @return {Boolean} True, if the server supports this type.
+ */
+ _hasResourceCommandSupportForTarget(resourceType, targetFront) {
+ // First check if the watcher supports this target type.
+ // If it doesn't, no resource type can be listened via the Watcher actor for this target.
+ if (!this.targetCommand.hasTargetWatcherSupport(targetFront.targetType)) {
+ return false;
+ }
+
+ return this.hasResourceCommandSupport(resourceType);
+ }
+
+ _isValidResourceType(type) {
+ return this.ALL_TYPES.includes(type);
+ }
+
+ /**
+ * Start listening for a given type of resource.
+ * For backward compatibility code, we register the legacy listeners on
+ * each individual target
+ *
+ * @param {String} resourceType
+ * One string of ResourceCommand.TYPES, which designates the types of resources
+ * to be listened.
+ * @param {Object}
+ * - {Boolean} bypassListenerCount
+ * Pass true to avoid checking/updating the listenersCount map.
+ * Exclusively used when target switching, to stop & start listening
+ * to all resources.
+ */
+ async _startListening(resourceType, { bypassListenerCount = false } = {}) {
+ if (!bypassListenerCount) {
+ if (this._listenedResources.has(resourceType)) {
+ return;
+ }
+ this._listenedResources.add(resourceType);
+ }
+
+ this._processingExistingResources.add(resourceType);
+
+ // Ensuring enabling listening to targets.
+ // This will be a no-op expect for the very first call to `_startListening`,
+ // where it is going to call `onTargetAvailable` for all already existing targets,
+ // as well as for those who will be created later.
+ //
+ // Do this *before* calling WatcherActor.watchResources in order to register "resource-available"
+ // listeners on targets before these events start being emitted.
+ await this._watchAllTargets(resourceType);
+
+ // When we are calling _startListening for the first time, _watchAllTargets
+ // will register legacylistener when it will call onTargetAvailable for all existing targets.
+ // But for any next calls to _startListening, _watchAllTargets will be a no-op,
+ // and nothing will start legacy listener for each already registered targets.
+ await this._startLegacyListenersForExistingTargets(resourceType);
+
+ // If the server supports the Watcher API and the Watcher supports
+ // this resource type, use this API
+ if (this.hasResourceCommandSupport(resourceType)) {
+ await this.watcherFront.watchResources([resourceType]);
+ }
+ this._processingExistingResources.delete(resourceType);
+ }
+
+ /**
+ * Return true if the resource should be watched via legacy listener,
+ * even when watcher supports this resource type.
+ *
+ * Bug 1678385: In order to support watching for JS Source resource
+ * for service workers and parent process workers, which aren't supported yet
+ * by the watcher actor, we do not bail out here and allow to execute
+ * the legacy listener for these targets.
+ * Once bug 1608848 is fixed, we can remove this and never trigger
+ * the legacy listeners codepath for these resource types.
+ *
+ * If this isn't fixed soon, we may add other resources we want to see
+ * being fetched from these targets.
+ */
+ _shouldRunLegacyListenerEvenWithWatcherSupport(resourceType) {
+ return WORKER_RESOURCE_TYPES.includes(resourceType);
+ }
+
+ async _forwardExistingResources(resourceTypes, onAvailable) {
+ const existingResources = [];
+ for (const resource of this._cache.values()) {
+ if (resourceTypes.includes(resource.resourceType)) {
+ existingResources.push(resource);
+ }
+ }
+ if (existingResources.length) {
+ await onAvailable(existingResources, { areExistingResources: true });
+ }
+ }
+
+ /**
+ * Call backward compatibility code from `LegacyListeners` in order to listen for a given
+ * type of resource from a given target.
+ */
+ async _watchResourcesForTarget({
+ targetFront,
+ resourceType,
+ disableWarning = false,
+ }) {
+ if (this._hasResourceCommandSupportForTarget(resourceType, targetFront)) {
+ // This resource / target pair should already be handled by the watcher,
+ // no need to start legacy listeners.
+ return;
+ }
+
+ // All workers target types are still not supported by the watcher
+ // so that we have to spawn legacy listener for all their resources.
+ // But some resources are irrelevant to workers, like network events.
+ // And we removed the related legacy listener as they are no longer used.
+ if (
+ targetFront.targetType.endsWith("worker") &&
+ !WORKER_RESOURCE_TYPES.includes(resourceType)
+ ) {
+ return;
+ }
+
+ if (targetFront.isDestroyed()) {
+ return;
+ }
+
+ const onAvailable = this._onResourceAvailable.bind(this, { targetFront });
+ const onUpdated = this._onResourceUpdated.bind(this, { targetFront });
+ const onDestroyed = this._onResourceDestroyed.bind(this, { targetFront });
+
+ if (!(resourceType in LegacyListeners)) {
+ throw new Error(`Missing legacy listener for ${resourceType}`);
+ }
+
+ const legacyListeners =
+ this._existingLegacyListeners.get(targetFront) || [];
+ if (legacyListeners.includes(resourceType)) {
+ if (!disableWarning) {
+ console.warn(
+ `Already started legacy listener for ${resourceType} on ${targetFront.actorID}`
+ );
+ }
+ return;
+ }
+ this._existingLegacyListeners.set(
+ targetFront,
+ legacyListeners.concat(resourceType)
+ );
+
+ try {
+ await LegacyListeners[resourceType]({
+ targetCommand: this.targetCommand,
+ targetFront,
+ onAvailable,
+ onDestroyed,
+ onUpdated,
+ });
+ } catch (e) {
+ // Swallow the error to avoid breaking calls to watchResources which will
+ // loop on all existing targets to create legacy listeners.
+ // If a legacy listener fails to handle a target for some reason, we
+ // should still try to process other targets as much as possible.
+ // See Bug 1687645.
+ console.error(
+ `Failed to start [${resourceType}] legacy listener for target ${targetFront.actorID}`,
+ e
+ );
+ }
+ }
+
+ /**
+ * Reverse of _startListening. Stop listening for a given type of resource.
+ * For backward compatibility, we unregister from each individual target.
+ *
+ * See _startListening for parameters description.
+ */
+ _stopListening(resourceType, { bypassListenerCount = false } = {}) {
+ if (!bypassListenerCount) {
+ if (!this._listenedResources.has(resourceType)) {
+ throw new Error(
+ `Stopped listening for resource '${resourceType}' that isn't being listened to`
+ );
+ }
+ this._listenedResources.delete(resourceType);
+ }
+
+ // Clear the cached resources of the type.
+ for (const [key, resource] of this._cache) {
+ if (resource.resourceType == resourceType) {
+ // NOTE: To anyone paranoid like me, yes it is okay to delete from a Map while iterating it.
+ this._cache.delete(key);
+ }
+ }
+
+ // If the server supports the Watcher API and the Watcher supports
+ // this resource type, use this API
+ if (this.hasResourceCommandSupport(resourceType)) {
+ if (!this.watcherFront.isDestroyed()) {
+ this.watcherFront.unwatchResources([resourceType]);
+ }
+
+ const shouldRunLegacyListeners =
+ this._shouldRunLegacyListenerEvenWithWatcherSupport(resourceType);
+ if (!shouldRunLegacyListeners) {
+ return;
+ }
+ }
+ // Otherwise, fallback on backward compat mode and use LegacyListeners.
+
+ // If this was the last listener, we should stop watching these events from the actors
+ // and the actors should stop watching things from the platform
+ const targets = this.targetCommand.getAllTargets(
+ this.targetCommand.ALL_TYPES
+ );
+ for (const target of targets) {
+ this._unwatchResourcesForTarget(target, resourceType);
+ }
+ }
+
+ /**
+ * Backward compatibility code, reverse of _watchResourcesForTarget.
+ */
+ _unwatchResourcesForTarget(targetFront, resourceType) {
+ if (this._hasResourceCommandSupportForTarget(resourceType, targetFront)) {
+ // This resource / target pair should already be handled by the watcher,
+ // no need to stop legacy listeners.
+ }
+ // Is there really a point in:
+ // - unregistering `onAvailable` RDP event callbacks from target-scoped actors?
+ // - calling `stopListeners()` as we are most likely closing the toolbox and destroying everything?
+ //
+ // It is important to keep this method synchronous and do as less as possible
+ // in the case of toolbox destroy.
+ //
+ // We are aware of one case where that might be useful.
+ // When a panel is disabled via the options panel, after it has been opened.
+ // Would that justify doing this? Is there another usecase?
+
+ // XXX: This is most likely only needed to avoid growing the Map infinitely.
+ // Unless in the "disabled panel" use case mentioned in the comment above,
+ // we should not see the same target actorID again.
+ const listeners = this._existingLegacyListeners.get(targetFront);
+ if (listeners && listeners.includes(resourceType)) {
+ const remainingListeners = listeners.filter(l => l !== resourceType);
+ this._existingLegacyListeners.set(targetFront, remainingListeners);
+ }
+ }
+}
+
+ResourceCommand.TYPES = ResourceCommand.prototype.TYPES = {
+ CONSOLE_MESSAGE: "console-message",
+ CSS_CHANGE: "css-change",
+ CSS_MESSAGE: "css-message",
+ ERROR_MESSAGE: "error-message",
+ PLATFORM_MESSAGE: "platform-message",
+ DOCUMENT_EVENT: "document-event",
+ ROOT_NODE: "root-node",
+ STYLESHEET: "stylesheet",
+ NETWORK_EVENT: "network-event",
+ WEBSOCKET: "websocket",
+ COOKIE: "cookies",
+ LOCAL_STORAGE: "local-storage",
+ SESSION_STORAGE: "session-storage",
+ CACHE_STORAGE: "Cache",
+ EXTENSION_STORAGE: "extension-storage",
+ INDEXED_DB: "indexed-db",
+ NETWORK_EVENT_STACKTRACE: "network-event-stacktrace",
+ REFLOW: "reflow",
+ SOURCE: "source",
+ THREAD_STATE: "thread-state",
+ TRACING_STATE: "tracing-state",
+ SERVER_SENT_EVENT: "server-sent-event",
+ LAST_PRIVATE_CONTEXT_EXIT: "last-private-context-exit",
+};
+ResourceCommand.ALL_TYPES = ResourceCommand.prototype.ALL_TYPES = Object.values(
+ ResourceCommand.TYPES
+);
+module.exports = ResourceCommand;
+
+// This is the list of resource types supported by workers.
+// We need such list to know when forcing to run the legacy listeners
+// and when to avoid try to spawn some unsupported ones for workers.
+const WORKER_RESOURCE_TYPES = [
+ ResourceCommand.TYPES.CONSOLE_MESSAGE,
+ ResourceCommand.TYPES.ERROR_MESSAGE,
+ ResourceCommand.TYPES.SOURCE,
+ ResourceCommand.TYPES.THREAD_STATE,
+];
+
+// Backward compat code for each type of resource.
+// Each section added here should eventually be removed once the equivalent server
+// code is implement in Firefox, in its release channel.
+const LegacyListeners = {
+ async [ResourceCommand.TYPES.DOCUMENT_EVENT]({
+ targetCommand,
+ targetFront,
+ onAvailable,
+ }) {
+ // DocumentEventsListener of webconsole handles only top level document.
+ if (!targetFront.isTopLevel) {
+ return;
+ }
+
+ const webConsoleFront = await targetFront.getFront("console");
+ webConsoleFront.on("documentEvent", event => {
+ event.resourceType = ResourceCommand.TYPES.DOCUMENT_EVENT;
+ onAvailable([event]);
+ });
+ await webConsoleFront.startListeners(["DocumentEvents"]);
+ },
+};
+loader.lazyRequireGetter(
+ LegacyListeners,
+ ResourceCommand.TYPES.CONSOLE_MESSAGE,
+ "resource://devtools/shared/commands/resource/legacy-listeners/console-messages.js"
+);
+loader.lazyRequireGetter(
+ LegacyListeners,
+ ResourceCommand.TYPES.CSS_CHANGE,
+ "resource://devtools/shared/commands/resource/legacy-listeners/css-changes.js"
+);
+loader.lazyRequireGetter(
+ LegacyListeners,
+ ResourceCommand.TYPES.CSS_MESSAGE,
+ "resource://devtools/shared/commands/resource/legacy-listeners/css-messages.js"
+);
+loader.lazyRequireGetter(
+ LegacyListeners,
+ ResourceCommand.TYPES.ERROR_MESSAGE,
+ "resource://devtools/shared/commands/resource/legacy-listeners/error-messages.js"
+);
+loader.lazyRequireGetter(
+ LegacyListeners,
+ ResourceCommand.TYPES.PLATFORM_MESSAGE,
+ "resource://devtools/shared/commands/resource/legacy-listeners/platform-messages.js"
+);
+loader.lazyRequireGetter(
+ LegacyListeners,
+ ResourceCommand.TYPES.ROOT_NODE,
+ "resource://devtools/shared/commands/resource/legacy-listeners/root-node.js"
+);
+
+loader.lazyRequireGetter(
+ LegacyListeners,
+ ResourceCommand.TYPES.SOURCE,
+ "resource://devtools/shared/commands/resource/legacy-listeners/source.js"
+);
+loader.lazyRequireGetter(
+ LegacyListeners,
+ ResourceCommand.TYPES.THREAD_STATE,
+ "resource://devtools/shared/commands/resource/legacy-listeners/thread-states.js"
+);
+
+loader.lazyRequireGetter(
+ LegacyListeners,
+ ResourceCommand.TYPES.REFLOW,
+ "resource://devtools/shared/commands/resource/legacy-listeners/reflow.js"
+);
+
+// Optional transformers for each type of resource.
+// Each module added here should be a function that will receive the resource, the target, …
+// and perform some transformation on the resource before it will be emitted.
+// This is a good place to handle backward compatibility and manual resource marshalling.
+const ResourceTransformers = {};
+
+loader.lazyRequireGetter(
+ ResourceTransformers,
+ ResourceCommand.TYPES.CONSOLE_MESSAGE,
+ "resource://devtools/shared/commands/resource/transformers/console-messages.js"
+);
+loader.lazyRequireGetter(
+ ResourceTransformers,
+ ResourceCommand.TYPES.ERROR_MESSAGE,
+ "resource://devtools/shared/commands/resource/transformers/error-messages.js"
+);
+loader.lazyRequireGetter(
+ ResourceTransformers,
+ ResourceCommand.TYPES.CACHE_STORAGE,
+ "resource://devtools/shared/commands/resource/transformers/storage-cache.js"
+);
+loader.lazyRequireGetter(
+ ResourceTransformers,
+ ResourceCommand.TYPES.COOKIE,
+ "resource://devtools/shared/commands/resource/transformers/storage-cookie.js"
+);
+loader.lazyRequireGetter(
+ ResourceTransformers,
+ ResourceCommand.TYPES.EXTENSION_STORAGE,
+ "resource://devtools/shared/commands/resource/transformers/storage-extension.js"
+);
+loader.lazyRequireGetter(
+ ResourceTransformers,
+ ResourceCommand.TYPES.INDEXED_DB,
+ "resource://devtools/shared/commands/resource/transformers/storage-indexed-db.js"
+);
+loader.lazyRequireGetter(
+ ResourceTransformers,
+ ResourceCommand.TYPES.LOCAL_STORAGE,
+ "resource://devtools/shared/commands/resource/transformers/storage-local-storage.js"
+);
+loader.lazyRequireGetter(
+ ResourceTransformers,
+ ResourceCommand.TYPES.SESSION_STORAGE,
+ "resource://devtools/shared/commands/resource/transformers/storage-session-storage.js"
+);
+loader.lazyRequireGetter(
+ ResourceTransformers,
+ ResourceCommand.TYPES.NETWORK_EVENT,
+ "resource://devtools/shared/commands/resource/transformers/network-events.js"
+);
+loader.lazyRequireGetter(
+ ResourceTransformers,
+ ResourceCommand.TYPES.THREAD_STATE,
+ "resource://devtools/shared/commands/resource/transformers/thread-states.js"
+);
diff --git a/devtools/shared/commands/resource/tests/breakpoint_document.html b/devtools/shared/commands/resource/tests/breakpoint_document.html
new file mode 100644
index 0000000000..2291094646
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/breakpoint_document.html
@@ -0,0 +1,21 @@
+<!DOCTYPE HTML>
+<html>
+ <head>
+ <meta charset="utf8">
+ <title>Test breakpoint document</title>
+ <!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+ </head>
+ <body>
+ <script>
+ "use strict";
+ /* eslint-disable */
+ function testFunction() {
+ console.log("test Function ran");
+ }
+ function runDebuggerStatement() {
+ debugger;
+ }
+ </script>
+ </body>
+</html>
diff --git a/devtools/shared/commands/resource/tests/browser.ini b/devtools/shared/commands/resource/tests/browser.ini
new file mode 100644
index 0000000000..6c89b01a69
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/browser.ini
@@ -0,0 +1,82 @@
+[DEFAULT]
+tags = devtools
+subsuite = devtools
+support-files =
+ !/devtools/client/shared/test/shared-head.js
+ !/devtools/client/shared/test/telemetry-test-helpers.js
+ !/devtools/client/shared/test/highlighter-test-actor.js
+ head.js
+ breakpoint_document.html
+ doc_console.html
+ doc_console_iframe.html
+ empty.html
+ network_document.html
+ network_document_navigation.html
+ network_navigation.js
+ early_console_document.html
+ fission_document.html
+ fission_document_workers.html
+ fission_iframe.html
+ fission_iframe_workers.html
+ service-worker-sources.js
+ sources.html
+ sources.js
+ sse_backend.sjs
+ sse_frontend_iframe.html
+ sse_frontend.html
+ style_document.css
+ style_document.html
+ style_iframe.css
+ style_iframe.html
+ stylesheets-nested-iframes.html
+ test_image.png
+ test_service_worker.js
+ test_worker.js
+ websocket_backend_wsh.py
+ websocket_frontend_iframe.html
+ websocket_frontend.html
+ worker-sources.js
+
+[browser_browser_resources_console_messages.js]
+[browser_resources_clear_resources.js]
+[browser_resources_client_caching.js]
+[browser_resources_console_messages.js]
+[browser_resources_console_messages_navigation.js]
+[browser_resources_console_messages_workers.js]
+[browser_resources_css_changes.js]
+[browser_resources_css_messages.js]
+[browser_resources_document_events.js]
+skip-if =
+ win10_2004 # Bug 1723573
+ os == 'linux' && bits == 64 # Bug 1715878
+[browser_resources_error_messages.js]
+[browser_resources_getAllResources.js]
+[browser_resources_invalid_api_usage.js]
+[browser_resources_last_private_context_exit.js]
+[browser_resources_network_event_stacktraces.js]
+[browser_resources_network_events.js]
+[browser_resources_network_events_cache.js]
+[browser_resources_network_events_navigation.js]
+[browser_resources_network_events_parent_process.js]
+[browser_resources_platform_messages.js]
+[browser_resources_reflows.js]
+[browser_resources_root_node.js]
+[browser_resources_scope_flag.js]
+[browser_resources_server_sent_events.js]
+[browser_resources_several_resources.js]
+[browser_resources_sources.js]
+skip-if =
+ os == "linux" && bits == 64 # Bug 1744565
+ win10_2004 && !debug # Bug 1744565
+[browser_resources_stylesheets.js]
+[browser_resources_stylesheets_import.js]
+[browser_resources_stylesheets_navigation.js]
+[browser_resources_stylesheets_nested_iframes.js]
+[browser_resources_target_destroy.js]
+[browser_resources_target_resources_race.js]
+[browser_resources_target_switching.js]
+[browser_resources_thread_states.js]
+[browser_resources_unwatch_early.js]
+[browser_resources_watch_unwatch_multiple.js]
+[browser_resources_websocket.js]
+skip-if = http3 # Bug 1829298
diff --git a/devtools/shared/commands/resource/tests/browser_browser_resources_console_messages.js b/devtools/shared/commands/resource/tests/browser_browser_resources_console_messages.js
new file mode 100644
index 0000000000..1c6c776e64
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/browser_browser_resources_console_messages.js
@@ -0,0 +1,87 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the ResourceCommand API around CONSOLE_MESSAGE for the whole browser
+
+const TEST_URL = URL_ROOT_SSL + "early_console_document.html";
+
+add_task(async function () {
+ // Enable Multiprocess Browser Toolbox.
+ await pushPref("devtools.browsertoolbox.scope", "everything");
+
+ const { client, resourceCommand, targetCommand } =
+ await initMultiProcessResourceCommand();
+
+ const d = Date.now();
+ const CACHED_MESSAGE_TEXT = `cached-${d}`;
+ const LIVE_MESSAGE_TEXT = `live-${d}`;
+
+ info(
+ "Log some messages *before* calling ResourceCommand.watchResources in order to " +
+ "assert the behavior of already existing messages."
+ );
+ console.log(CACHED_MESSAGE_TEXT);
+
+ info("Wait for existing browser mochitest log");
+ const { onResource } = await resourceCommand.waitForNextResource(
+ resourceCommand.TYPES.CONSOLE_MESSAGE,
+ {
+ ignoreExistingResources: false,
+ predicate({ message }) {
+ return message.arguments[0] === CACHED_MESSAGE_TEXT;
+ },
+ }
+ );
+ const existingMsg = await onResource;
+ ok(existingMsg, "The existing log was retrieved");
+ is(
+ existingMsg.isAlreadyExistingResource,
+ true,
+ "isAlreadyExistingResource is true for the existing message"
+ );
+
+ const { onResource: onMochitestRuntimeLog } =
+ await resourceCommand.waitForNextResource(
+ resourceCommand.TYPES.CONSOLE_MESSAGE,
+ {
+ ignoreExistingResources: false,
+ predicate({ message }) {
+ return message.arguments[0] === LIVE_MESSAGE_TEXT;
+ },
+ }
+ );
+ console.log(LIVE_MESSAGE_TEXT);
+
+ info("Wait for runtime browser mochitest log");
+ const runtimeLogResource = await onMochitestRuntimeLog;
+ ok(runtimeLogResource, "The runtime log was retrieved");
+ is(
+ runtimeLogResource.isAlreadyExistingResource,
+ false,
+ "isAlreadyExistingResource is false for the runtime message"
+ );
+
+ const { onResource: onEarlyLog } = await resourceCommand.waitForNextResource(
+ resourceCommand.TYPES.CONSOLE_MESSAGE,
+ {
+ ignoreExistingResources: true,
+ predicate({ message }) {
+ return message.arguments[0] === "early-page-log";
+ },
+ }
+ );
+ await addTab(TEST_URL);
+ info("Wait for early page log");
+ const earlyResource = await onEarlyLog;
+ ok(earlyResource, "The early page log was retrieved");
+ is(
+ earlyResource.isAlreadyExistingResource,
+ false,
+ "isAlreadyExistingResource is false for the early message"
+ );
+
+ targetCommand.destroy();
+ await client.close();
+});
diff --git a/devtools/shared/commands/resource/tests/browser_resources_clear_resources.js b/devtools/shared/commands/resource/tests/browser_resources_clear_resources.js
new file mode 100644
index 0000000000..44068cb141
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/browser_resources_clear_resources.js
@@ -0,0 +1,90 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the clearResources function of the ResourceCommand
+
+add_task(async () => {
+ const tab = await addTab(`${URL_ROOT_SSL}empty.html`);
+ const { client, resourceCommand, targetCommand } = await initResourceCommand(
+ tab
+ );
+
+ info("Assert the initial no of resources");
+ assertNoOfResources(resourceCommand, 0, 0);
+
+ const onAvailable = () => {};
+ const onUpdated = () => {};
+
+ await resourceCommand.watchResources(
+ [
+ resourceCommand.TYPES.CONSOLE_MESSAGE,
+ resourceCommand.TYPES.NETWORK_EVENT,
+ ],
+ { onAvailable, onUpdated }
+ );
+
+ info("Log some messages");
+ await logConsoleMessages(tab.linkedBrowser, ["log1", "log2", "log3"]);
+
+ info("Trigger some network requests");
+ const EXAMPLE_DOMAIN = "https://example.com/";
+ await triggerNetworkRequests(tab.linkedBrowser, [
+ `await fetch("${EXAMPLE_DOMAIN}/request1.html", { method: "GET" });`,
+ `await fetch("${EXAMPLE_DOMAIN}/request2.html", { method: "GET" });`,
+ ]);
+
+ assertNoOfResources(resourceCommand, 3, 2);
+
+ info("Clear the network event resources");
+ await resourceCommand.clearResources([resourceCommand.TYPES.NETWORK_EVENT]);
+ assertNoOfResources(resourceCommand, 3, 0);
+
+ info("Clear the console message resources");
+ await resourceCommand.clearResources([resourceCommand.TYPES.CONSOLE_MESSAGE]);
+ assertNoOfResources(resourceCommand, 0, 0);
+
+ resourceCommand.unwatchResources(
+ [
+ resourceCommand.TYPES.CONSOLE_MESSAGE,
+ resourceCommand.TYPES.NETWORK_EVENT,
+ ],
+ { onAvailable, onUpdated, ignoreExistingResources: true }
+ );
+
+ targetCommand.destroy();
+ await client.close();
+});
+
+function assertNoOfResources(
+ resourceCommand,
+ expectedNoOfConsoleMessageResources,
+ expectedNoOfNetworkEventResources
+) {
+ const actualNoOfConsoleMessageResources = resourceCommand.getAllResources(
+ resourceCommand.TYPES.CONSOLE_MESSAGE
+ ).length;
+ is(
+ actualNoOfConsoleMessageResources,
+ expectedNoOfConsoleMessageResources,
+ `There are ${actualNoOfConsoleMessageResources} console messages resources`
+ );
+
+ const actualNoOfNetworkEventResources = resourceCommand.getAllResources(
+ resourceCommand.TYPES.NETWORK_EVENT
+ ).length;
+ is(
+ actualNoOfNetworkEventResources,
+ expectedNoOfNetworkEventResources,
+ `There are ${actualNoOfNetworkEventResources} network event resources`
+ );
+}
+
+function logConsoleMessages(browser, messages) {
+ return SpecialPowers.spawn(browser, [messages], innerMessages => {
+ for (const message of innerMessages) {
+ content.console.log(message);
+ }
+ });
+}
diff --git a/devtools/shared/commands/resource/tests/browser_resources_client_caching.js b/devtools/shared/commands/resource/tests/browser_resources_client_caching.js
new file mode 100644
index 0000000000..a10c74e298
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/browser_resources_client_caching.js
@@ -0,0 +1,376 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the cache mechanism of the ResourceCommand.
+
+const TEST_URI = "data:text/html;charset=utf-8,<!DOCTYPE html>Cache Test";
+
+add_task(async function () {
+ info("Test whether multiple listener can get same cached resources");
+
+ const tab = await addTab(TEST_URI);
+
+ const { client, resourceCommand, targetCommand } = await initResourceCommand(
+ tab
+ );
+
+ info("Add messages as existing resources");
+ const messages = ["a", "b", "c"];
+ await logMessages(tab.linkedBrowser, messages);
+
+ info("Register first listener");
+ const cachedResources1 = [];
+ await resourceCommand.watchResources(
+ [resourceCommand.TYPES.CONSOLE_MESSAGE],
+ {
+ onAvailable(resources, { areExistingResources }) {
+ ok(areExistingResources, "All resources are already existing ones");
+ cachedResources1.push(...resources);
+ },
+ }
+ );
+
+ info("Register second listener");
+ const cachedResources2 = [];
+ await resourceCommand.watchResources(
+ [resourceCommand.TYPES.CONSOLE_MESSAGE],
+ {
+ onAvailable(resources, { areExistingResources }) {
+ ok(areExistingResources, "All resources are already existing ones");
+ cachedResources2.push(...resources);
+ },
+ }
+ );
+
+ assertContents(cachedResources1, messages);
+ assertResources(cachedResources2, cachedResources1);
+
+ targetCommand.destroy();
+ await client.close();
+});
+
+add_task(async function () {
+ info(
+ "Test whether the cache is reflecting existing resources and additional resources"
+ );
+
+ const tab = await addTab(TEST_URI);
+
+ const { client, resourceCommand, targetCommand } = await initResourceCommand(
+ tab
+ );
+
+ info("Add messages as existing resources");
+ const existingMessages = ["a", "b", "c"];
+ await logMessages(tab.linkedBrowser, existingMessages);
+
+ info("Register first listener to get all available resources");
+ const availableResources = [];
+ // We first get notified about existing resources
+ let shouldBeExistingResources = true;
+ await resourceCommand.watchResources(
+ [resourceCommand.TYPES.CONSOLE_MESSAGE],
+ {
+ onAvailable(resources, { areExistingResources }) {
+ is(
+ areExistingResources,
+ shouldBeExistingResources,
+ "areExistingResources flag is correct"
+ );
+ availableResources.push(...resources);
+ },
+ }
+ );
+ // Then, we are notified about, new, live ones
+ shouldBeExistingResources = false;
+
+ info("Add messages as additional resources");
+ const additionalMessages = ["d", "e"];
+ await logMessages(tab.linkedBrowser, additionalMessages);
+
+ info("Wait until onAvailable is called expected times");
+ const allMessages = [...existingMessages, ...additionalMessages];
+ await waitUntil(() => availableResources.length === allMessages.length);
+
+ info("Register second listener to get the cached resources");
+ const cachedResources = [];
+ await resourceCommand.watchResources(
+ [resourceCommand.TYPES.CONSOLE_MESSAGE],
+ {
+ onAvailable(resources, { areExistingResources }) {
+ ok(areExistingResources, "All resources are already existing ones");
+ cachedResources.push(...resources);
+ },
+ }
+ );
+
+ assertContents(availableResources, allMessages);
+ assertResources(cachedResources, availableResources);
+
+ targetCommand.destroy();
+ await client.close();
+});
+
+add_task(async function () {
+ info("Test whether the cache is cleared when navigation");
+
+ const tab = await addTab(TEST_URI);
+
+ const { client, resourceCommand, targetCommand } = await initResourceCommand(
+ tab
+ );
+
+ info("Add messages as existing resources");
+ const existingMessages = ["a", "b", "c"];
+ await logMessages(tab.linkedBrowser, existingMessages);
+
+ info("Register first listener");
+ await resourceCommand.watchResources(
+ [resourceCommand.TYPES.CONSOLE_MESSAGE],
+ {
+ onAvailable: () => {},
+ }
+ );
+
+ info("Reload the page");
+ await BrowserTestUtils.reloadTab(tab);
+
+ info("Register second listener");
+ const cachedResources = [];
+ await resourceCommand.watchResources(
+ [resourceCommand.TYPES.CONSOLE_MESSAGE],
+ {
+ onAvailable: resources => cachedResources.push(...resources),
+ }
+ );
+
+ is(cachedResources.length, 0, "The cache in ResourceCommand is cleared");
+
+ targetCommand.destroy();
+ await client.close();
+});
+
+add_task(async function () {
+ info("Test with multiple resource types");
+
+ const tab = await addTab(TEST_URI);
+
+ const { client, resourceCommand, targetCommand } = await initResourceCommand(
+ tab
+ );
+
+ info("Register first listener to get all available resources");
+ const availableResources = [];
+ await resourceCommand.watchResources(
+ [
+ resourceCommand.TYPES.CONSOLE_MESSAGE,
+ resourceCommand.TYPES.ERROR_MESSAGE,
+ ],
+ {
+ onAvailable: resources => availableResources.push(...resources),
+ }
+ );
+
+ info("Add messages as console message");
+ const consoleMessages1 = ["a", "b", "c"];
+ await logMessages(tab.linkedBrowser, consoleMessages1);
+
+ info("Add message as error message");
+ const errorMessages = ["document.doTheImpossible();"];
+ await triggerErrors(tab.linkedBrowser, errorMessages);
+
+ info("Add messages as console message again");
+ const consoleMessages2 = ["d", "e"];
+ await logMessages(tab.linkedBrowser, consoleMessages2);
+
+ info("Wait until the getting all available resources");
+ const totalResourceCount =
+ consoleMessages1.length + errorMessages.length + consoleMessages2.length;
+ await waitUntil(() => {
+ return availableResources.length === totalResourceCount;
+ });
+
+ info("Register listener to get the cached resources");
+ const cachedResources = [];
+ await resourceCommand.watchResources(
+ [
+ resourceCommand.TYPES.CONSOLE_MESSAGE,
+ resourceCommand.TYPES.ERROR_MESSAGE,
+ ],
+ {
+ onAvailable: resources => cachedResources.push(...resources),
+ }
+ );
+
+ assertResources(cachedResources, availableResources);
+
+ targetCommand.destroy();
+ await client.close();
+});
+
+add_task(async function () {
+ info("Test multiple listeners with/without ignoreExistingResources");
+ await testIgnoreExistingResources(true);
+ await testIgnoreExistingResources(false);
+});
+
+async function testIgnoreExistingResources(isFirstListenerIgnoreExisting) {
+ const tab = await addTab(TEST_URI);
+
+ const { client, resourceCommand, targetCommand } = await initResourceCommand(
+ tab
+ );
+
+ info("Add messages as existing resources");
+ const existingMessages = ["a", "b", "c"];
+ await logMessages(tab.linkedBrowser, existingMessages);
+
+ info("Register first listener");
+ const cachedResources1 = [];
+ await resourceCommand.watchResources(
+ [resourceCommand.TYPES.CONSOLE_MESSAGE],
+ {
+ onAvailable: resources => cachedResources1.push(...resources),
+ ignoreExistingResources: isFirstListenerIgnoreExisting,
+ }
+ );
+
+ info("Register second listener");
+ const cachedResources2 = [];
+ await resourceCommand.watchResources(
+ [resourceCommand.TYPES.CONSOLE_MESSAGE],
+ {
+ onAvailable: resources => cachedResources2.push(...resources),
+ ignoreExistingResources: !isFirstListenerIgnoreExisting,
+ }
+ );
+
+ const cachedResourcesWithFlag = isFirstListenerIgnoreExisting
+ ? cachedResources1
+ : cachedResources2;
+ const cachedResourcesWithoutFlag = isFirstListenerIgnoreExisting
+ ? cachedResources2
+ : cachedResources1;
+
+ info("Check the existing resources both listeners got");
+ assertContents(cachedResourcesWithFlag, []);
+ assertContents(cachedResourcesWithoutFlag, existingMessages);
+
+ info("Add messages as additional resources");
+ const additionalMessages = ["d", "e"];
+ await logMessages(tab.linkedBrowser, additionalMessages);
+
+ info("Wait until onAvailable is called expected times");
+ await waitUntil(
+ () => cachedResourcesWithFlag.length === additionalMessages.length
+ );
+ const allMessages = [...existingMessages, ...additionalMessages];
+ await waitUntil(
+ () => cachedResourcesWithoutFlag.length === allMessages.length
+ );
+
+ info("Check the resources after adding messages");
+ assertContents(cachedResourcesWithFlag, additionalMessages);
+ assertContents(cachedResourcesWithoutFlag, allMessages);
+
+ targetCommand.destroy();
+ await client.close();
+}
+
+add_task(async function () {
+ info("Test that onAvailable is not called with an empty resources array");
+
+ const tab = await addTab(TEST_URI);
+
+ const { client, resourceCommand, targetCommand } = await initResourceCommand(
+ tab
+ );
+
+ info("Register first listener to get all available resources");
+ const availableResources = [];
+ let onAvailableCallCount = 0;
+ const onAvailable = resources => {
+ ok(
+ !!resources.length,
+ "onAvailable is called with a non empty resources array"
+ );
+ availableResources.push(...resources);
+ onAvailableCallCount++;
+ };
+
+ await resourceCommand.watchResources(
+ [resourceCommand.TYPES.CONSOLE_MESSAGE],
+ { onAvailable }
+ );
+ is(availableResources.length, 0, "availableResources array is empty");
+ is(onAvailableCallCount, 0, "onAvailable was never called");
+
+ info("Add messages as console message");
+ await logMessages(tab.linkedBrowser, ["expected message"]);
+
+ await waitUntil(() => availableResources.length === 1);
+ is(availableResources.length, 1, "availableResources array has one item");
+ is(onAvailableCallCount, 1, "onAvailable was called only once");
+ is(
+ availableResources[0].message.arguments[0],
+ "expected message",
+ "onAvailable was called with the expected resource"
+ );
+
+ resourceCommand.unwatchResources([resourceCommand.TYPES.CONSOLE_MESSAGE], {
+ onAvailable,
+ });
+ targetCommand.destroy();
+ await client.close();
+});
+
+function assertContents(resources, expectedMessages) {
+ is(
+ resources.length,
+ expectedMessages.length,
+ "Number of the resources is correct"
+ );
+
+ for (let i = 0; i < expectedMessages.length; i++) {
+ const resource = resources[i];
+ const message = resource.message.arguments[0];
+ const expectedMessage = expectedMessages[i];
+ is(message, expectedMessage, `The ${i}th content is correct`);
+ }
+}
+
+function assertResources(resources, expectedResources) {
+ is(
+ resources.length,
+ expectedResources.length,
+ "Number of the resources is correct"
+ );
+
+ for (let i = 0; i < resources.length; i++) {
+ const resource = resources[i];
+ const expectedResource = expectedResources[i];
+ ok(resource === expectedResource, `The ${i}th resource is correct`);
+ }
+}
+
+function logMessages(browser, messages) {
+ return ContentTask.spawn(browser, { messages }, args => {
+ for (const message of args.messages) {
+ content.console.log(message);
+ }
+ });
+}
+
+async function triggerErrors(browser, errorScripts) {
+ for (const errorScript of errorScripts) {
+ await ContentTask.spawn(browser, errorScript, expr => {
+ const document = content.document;
+ const container = document.createElement("script");
+ document.body.appendChild(container);
+ container.textContent = expr;
+ container.remove();
+ });
+ }
+}
diff --git a/devtools/shared/commands/resource/tests/browser_resources_console_messages.js b/devtools/shared/commands/resource/tests/browser_resources_console_messages.js
new file mode 100644
index 0000000000..6f02cd5a77
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/browser_resources_console_messages.js
@@ -0,0 +1,623 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the ResourceCommand API around CONSOLE_MESSAGE
+//
+// Reproduces assertions from: devtools/shared/webconsole/test/chrome/test_cached_messages.html
+// And now more. Once we remove the console actor's startListeners in favor of watcher class
+// We could remove that other old test.
+
+const FISSION_TEST_URL = URL_ROOT_SSL + "fission_document.html";
+const IFRAME_URL = URL_ROOT_ORG_SSL + "fission_iframe.html";
+
+add_task(async function () {
+ info("Execute test in top level document");
+ await testTabConsoleMessagesResources(false);
+ await testTabConsoleMessagesResourcesWithIgnoreExistingResources(false);
+
+ info("Execute test in an iframe document, possibly remote with fission");
+ await testTabConsoleMessagesResources(true);
+ await testTabConsoleMessagesResourcesWithIgnoreExistingResources(true);
+});
+
+async function testTabConsoleMessagesResources(executeInIframe) {
+ const tab = await addTab(FISSION_TEST_URL);
+
+ const { client, resourceCommand, targetCommand } = await initResourceCommand(
+ tab
+ );
+
+ info(
+ "Log some messages *before* calling ResourceCommand.watchResources in order to " +
+ "assert the behavior of already existing messages."
+ );
+ await logExistingMessages(tab.linkedBrowser, executeInIframe);
+
+ const targetDocumentUrl = executeInIframe ? IFRAME_URL : FISSION_TEST_URL;
+
+ let runtimeDoneResolve;
+ const expectedExistingCalls =
+ getExpectedExistingConsoleCalls(targetDocumentUrl);
+ const expectedRuntimeCalls =
+ getExpectedRuntimeConsoleCalls(targetDocumentUrl);
+ const onRuntimeDone = new Promise(resolve => (runtimeDoneResolve = resolve));
+ const onAvailable = resources => {
+ for (const resource of resources) {
+ is(
+ resource.resourceType,
+ resourceCommand.TYPES.CONSOLE_MESSAGE,
+ "Received a message"
+ );
+ ok(resource.message, "message is wrapped into a message attribute");
+ const isCachedMessage = !!expectedExistingCalls.length;
+ const expected = (
+ isCachedMessage ? expectedExistingCalls : expectedRuntimeCalls
+ ).shift();
+ checkConsoleAPICall(resource.message, expected);
+ is(
+ resource.isAlreadyExistingResource,
+ isCachedMessage,
+ "isAlreadyExistingResource has the expected value"
+ );
+
+ if (!expectedRuntimeCalls.length) {
+ runtimeDoneResolve();
+ }
+ }
+ };
+
+ await resourceCommand.watchResources(
+ [resourceCommand.TYPES.CONSOLE_MESSAGE],
+ {
+ onAvailable,
+ }
+ );
+ is(
+ expectedExistingCalls.length,
+ 0,
+ "Got the expected number of existing messages"
+ );
+
+ info(
+ "Now log messages *after* the call to ResourceCommand.watchResources and after having received all existing messages"
+ );
+ await logRuntimeMessages(tab.linkedBrowser, executeInIframe);
+
+ info("Waiting for all runtime messages");
+ await onRuntimeDone;
+
+ is(
+ expectedRuntimeCalls.length,
+ 0,
+ "Got the expected number of runtime messages"
+ );
+
+ targetCommand.destroy();
+ await client.close();
+}
+
+async function testTabConsoleMessagesResourcesWithIgnoreExistingResources(
+ executeInIframe
+) {
+ info("Test ignoreExistingResources option for console messages");
+ const tab = await addTab(FISSION_TEST_URL);
+
+ const { client, resourceCommand, targetCommand } = await initResourceCommand(
+ tab
+ );
+
+ info(
+ "Check whether onAvailable will not be called with existing console messages"
+ );
+ await logExistingMessages(tab.linkedBrowser, executeInIframe);
+
+ const availableResources = [];
+ await resourceCommand.watchResources(
+ [resourceCommand.TYPES.CONSOLE_MESSAGE],
+ {
+ onAvailable: resources => availableResources.push(...resources),
+ ignoreExistingResources: true,
+ }
+ );
+ is(
+ availableResources.length,
+ 0,
+ "onAvailable wasn't called for existing console messages"
+ );
+
+ info(
+ "Check whether onAvailable will be called with the future console messages"
+ );
+ await logRuntimeMessages(tab.linkedBrowser, executeInIframe);
+ const targetDocumentUrl = executeInIframe ? IFRAME_URL : FISSION_TEST_URL;
+ const expectedRuntimeConsoleCalls =
+ getExpectedRuntimeConsoleCalls(targetDocumentUrl);
+ await waitUntil(
+ () => availableResources.length === expectedRuntimeConsoleCalls.length
+ );
+ const expectedTargetFront =
+ executeInIframe && (isFissionEnabled() || isEveryFrameTargetEnabled())
+ ? targetCommand
+ .getAllTargets([targetCommand.TYPES.FRAME])
+ .find(target => target.url == IFRAME_URL)
+ : targetCommand.targetFront;
+ for (let i = 0; i < expectedRuntimeConsoleCalls.length; i++) {
+ const resource = availableResources[i];
+ const { message, targetFront } = resource;
+ is(
+ targetFront,
+ expectedTargetFront,
+ "The targetFront property is the expected one"
+ );
+ const expected = expectedRuntimeConsoleCalls[i];
+ checkConsoleAPICall(message, expected);
+ is(
+ resource.isAlreadyExistingResource,
+ false,
+ "isAlreadyExistingResource is false since we're ignoring existing resources"
+ );
+ }
+
+ targetCommand.destroy();
+ await client.close();
+}
+
+async function logExistingMessages(browser, executeInIframe) {
+ let browsingContext = browser.browsingContext;
+ if (executeInIframe) {
+ browsingContext = await SpecialPowers.spawn(
+ browser,
+ [],
+ function frameScript() {
+ return content.document.querySelector("iframe").browsingContext;
+ }
+ );
+ }
+ return evalInBrowsingContext(browsingContext, function pageScript() {
+ console.log("foobarBaz-log", undefined);
+ console.info("foobarBaz-info", null);
+ console.warn("foobarBaz-warn", document.body);
+ });
+}
+
+/**
+ * Helper function similar to spawn, but instead of executing the script
+ * as a Frame Script, with privileges and including test harness in stacktraces,
+ * execute the script as a regular page script, without privileges and without any
+ * preceding stack.
+ *
+ * @param {BrowsingContext} The browsing context into which the script should be evaluated
+ * @param {Function|String} The JS to execute in the browsing context
+ *
+ * @return {Promise} Which resolves once the JS is done executing in the page
+ */
+function evalInBrowsingContext(browsingContext, script) {
+ return SpecialPowers.spawn(browsingContext, [String(script)], expr => {
+ const document = content.document;
+ const scriptEl = document.createElement("script");
+ document.body.appendChild(scriptEl);
+ // Force the immediate execution of the stringified JS function passed in `expr`
+ scriptEl.textContent = "new " + expr;
+ scriptEl.remove();
+ });
+}
+
+// For both existing and runtime messages, we execute console API
+// from a page script evaluated via evalInBrowsingContext.
+// Records here the function used to execute the script in the page.
+const EXPECTED_FUNCTION_NAME = "pageScript";
+
+const NUMBER_REGEX = /^\d+$/;
+// timeStamp are the result of a number in microsecond divided by 1000.
+// so we can't expect a precise number of decimals, or even if there would
+// be decimals at all.
+const FRACTIONAL_NUMBER_REGEX = /^\d+(\.\d{1,3})?$/;
+
+function getExpectedExistingConsoleCalls(documentFilename) {
+ const defaultProperties = {
+ filename: documentFilename,
+ columnNumber: NUMBER_REGEX,
+ lineNumber: NUMBER_REGEX,
+ timeStamp: FRACTIONAL_NUMBER_REGEX,
+ innerWindowID: NUMBER_REGEX,
+ chromeContext: undefined,
+ counter: undefined,
+ prefix: undefined,
+ private: undefined,
+ stacktrace: undefined,
+ styles: undefined,
+ timer: undefined,
+ };
+
+ return [
+ {
+ ...defaultProperties,
+ level: "log",
+ arguments: ["foobarBaz-log", { type: "undefined" }],
+ },
+ {
+ ...defaultProperties,
+ level: "info",
+ arguments: ["foobarBaz-info", { type: "null" }],
+ },
+ {
+ ...defaultProperties,
+ level: "warn",
+ arguments: ["foobarBaz-warn", { type: "object", actor: /[a-z]/ }],
+ },
+ ];
+}
+
+const longString = new Array(DevToolsServer.LONG_STRING_LENGTH + 2).join("a");
+function getExpectedRuntimeConsoleCalls(documentFilename) {
+ const defaultStackFrames = [
+ // This is the usage of "new " + expr from `evalInBrowsingContext`
+ {
+ filename: documentFilename,
+ lineNumber: NUMBER_REGEX,
+ columnNumber: NUMBER_REGEX,
+ },
+ ];
+
+ const defaultProperties = {
+ filename: documentFilename,
+ columnNumber: NUMBER_REGEX,
+ lineNumber: NUMBER_REGEX,
+ timeStamp: FRACTIONAL_NUMBER_REGEX,
+ innerWindowID: NUMBER_REGEX,
+ chromeContext: undefined,
+ counter: undefined,
+ prefix: undefined,
+ private: undefined,
+ stacktrace: undefined,
+ styles: undefined,
+ timer: undefined,
+ };
+
+ return [
+ {
+ ...defaultProperties,
+ level: "log",
+ arguments: ["foobarBaz-log", { type: "undefined" }],
+ },
+ {
+ ...defaultProperties,
+ level: "log",
+ arguments: ["Float from not a number: NaN"],
+ },
+ {
+ ...defaultProperties,
+ level: "log",
+ arguments: ["Float from string: 1.200000"],
+ },
+ {
+ ...defaultProperties,
+ level: "log",
+ arguments: ["Float from number: 1.300000"],
+ },
+ {
+ ...defaultProperties,
+ level: "log",
+ arguments: ["BigInt 123 and 456"],
+ },
+ {
+ ...defaultProperties,
+ level: "log",
+ arguments: ["message with ", "style"],
+ styles: ["color: blue;", "background: red; font-size: 2em;"],
+ },
+ {
+ ...defaultProperties,
+ level: "info",
+ arguments: ["foobarBaz-info", { type: "null" }],
+ },
+ {
+ ...defaultProperties,
+ level: "warn",
+ arguments: ["foobarBaz-warn", { type: "object", actor: /[a-z]/ }],
+ },
+ {
+ ...defaultProperties,
+ level: "debug",
+ arguments: [{ type: "null" }],
+ },
+ {
+ ...defaultProperties,
+ level: "trace",
+ stacktrace: [
+ {
+ filename: documentFilename,
+ functionName: EXPECTED_FUNCTION_NAME,
+ },
+ ...defaultStackFrames,
+ ],
+ },
+ {
+ ...defaultProperties,
+ level: "dir",
+ arguments: [
+ {
+ type: "object",
+ actor: /[a-z]/,
+ class: "HTMLDocument",
+ },
+ {
+ type: "object",
+ actor: /[a-z]/,
+ class: "Location",
+ },
+ ],
+ },
+ {
+ ...defaultProperties,
+ level: "log",
+ arguments: [
+ "foo",
+ {
+ type: "longString",
+ initial: longString.substring(
+ 0,
+ DevToolsServer.LONG_STRING_INITIAL_LENGTH
+ ),
+ length: longString.length,
+ actor: /[a-z]/,
+ },
+ ],
+ },
+ {
+ ...defaultProperties,
+ level: "count",
+ arguments: ["myCounter"],
+ counter: {
+ count: 1,
+ label: "myCounter",
+ },
+ },
+ {
+ ...defaultProperties,
+ level: "count",
+ arguments: ["myCounter"],
+ counter: {
+ count: 2,
+ label: "myCounter",
+ },
+ },
+ {
+ ...defaultProperties,
+ level: "count",
+ arguments: ["default"],
+ counter: {
+ count: 1,
+ label: "default",
+ },
+ },
+ {
+ ...defaultProperties,
+ level: "countReset",
+ arguments: ["myCounter"],
+ counter: {
+ count: 0,
+ label: "myCounter",
+ },
+ },
+ {
+ ...defaultProperties,
+ level: "countReset",
+ arguments: ["unknownCounter"],
+ counter: {
+ error: "counterDoesntExist",
+ label: "unknownCounter",
+ },
+ },
+ {
+ ...defaultProperties,
+ level: "time",
+ arguments: ["myTimer"],
+ timer: {
+ name: "myTimer",
+ },
+ },
+ {
+ ...defaultProperties,
+ level: "time",
+ arguments: ["myTimer"],
+ timer: {
+ name: "myTimer",
+ error: "timerAlreadyExists",
+ },
+ },
+ {
+ ...defaultProperties,
+ level: "timeLog",
+ arguments: ["myTimer"],
+ timer: {
+ name: "myTimer",
+ duration: NUMBER_REGEX,
+ },
+ },
+ {
+ ...defaultProperties,
+ level: "timeEnd",
+ arguments: ["myTimer"],
+ timer: {
+ name: "myTimer",
+ duration: NUMBER_REGEX,
+ },
+ },
+ {
+ ...defaultProperties,
+ level: "time",
+ arguments: ["default"],
+ timer: {
+ name: "default",
+ },
+ },
+ {
+ ...defaultProperties,
+ level: "timeLog",
+ arguments: ["default"],
+ timer: {
+ name: "default",
+ duration: NUMBER_REGEX,
+ },
+ },
+ {
+ ...defaultProperties,
+ level: "timeEnd",
+ arguments: ["default"],
+ timer: {
+ name: "default",
+ duration: NUMBER_REGEX,
+ },
+ },
+ {
+ ...defaultProperties,
+ level: "timeLog",
+ arguments: ["unknownTimer"],
+ timer: {
+ name: "unknownTimer",
+ error: "timerDoesntExist",
+ },
+ },
+ {
+ ...defaultProperties,
+ level: "timeEnd",
+ arguments: ["unknownTimer"],
+ timer: {
+ name: "unknownTimer",
+ error: "timerDoesntExist",
+ },
+ },
+ {
+ ...defaultProperties,
+ level: "error",
+ arguments: ["foobarBaz-asmjs-error", { type: "undefined" }],
+
+ stacktrace: [
+ {
+ filename: documentFilename,
+ functionName: "fromAsmJS",
+ },
+ {
+ filename: documentFilename,
+ functionName: "inAsmJS2",
+ },
+ {
+ filename: documentFilename,
+ functionName: "inAsmJS1",
+ },
+ {
+ filename: documentFilename,
+ functionName: EXPECTED_FUNCTION_NAME,
+ },
+ ...defaultStackFrames,
+ ],
+ },
+ {
+ ...defaultProperties,
+ level: "log",
+ filename:
+ "chrome://mochitests/content/browser/devtools/shared/commands/resource/tests/browser_resources_console_messages.js",
+ arguments: [
+ {
+ type: "object",
+ actor: /[a-z]/,
+ class: "Restricted",
+ },
+ ],
+ chromeContext: true,
+ },
+ ];
+}
+
+async function logRuntimeMessages(browser, executeInIframe) {
+ let browsingContext = browser.browsingContext;
+ if (executeInIframe) {
+ browsingContext = await SpecialPowers.spawn(
+ browser,
+ [],
+ function frameScript() {
+ return content.document.querySelector("iframe").browsingContext;
+ }
+ );
+ }
+ // First inject LONG_STRING_LENGTH in global scope it order to easily use it after
+ await evalInBrowsingContext(
+ browsingContext,
+ `function () {window.LONG_STRING_LENGTH = ${DevToolsServer.LONG_STRING_LENGTH};}`
+ );
+ await evalInBrowsingContext(browsingContext, function pageScript() {
+ const _longString = new Array(window.LONG_STRING_LENGTH + 2).join("a");
+
+ console.log("foobarBaz-log", undefined);
+
+ console.log("Float from not a number: %f", "foo");
+ console.log("Float from string: %f", "1.2");
+ console.log("Float from number: %f", 1.3);
+ console.log("BigInt %d and %i", 123n, 456n);
+ console.log(
+ "%cmessage with %cstyle",
+ "color: blue;",
+ "background: red; font-size: 2em;"
+ );
+
+ console.info("foobarBaz-info", null);
+ console.warn("foobarBaz-warn", document.documentElement);
+ console.debug(null);
+ console.trace();
+ console.dir(document, location);
+ console.log("foo", _longString);
+
+ console.count("myCounter");
+ console.count("myCounter");
+ console.count();
+ console.countReset("myCounter");
+ // will cause warnings because unknownCounter doesn't exist
+ console.countReset("unknownCounter");
+
+ console.time("myTimer");
+ // will cause warning because myTimer already exist
+ console.time("myTimer");
+ console.timeLog("myTimer");
+ console.timeEnd("myTimer");
+ console.time();
+ console.timeLog();
+ console.timeEnd();
+ // // will cause warnings because unknownTimer doesn't exist
+ console.timeLog("unknownTimer");
+ console.timeEnd("unknownTimer");
+
+ function fromAsmJS() {
+ console.error("foobarBaz-asmjs-error", undefined);
+ }
+
+ (function (global, foreign) {
+ "use asm";
+ function inAsmJS2() {
+ foreign.fromAsmJS();
+ }
+ function inAsmJS1() {
+ inAsmJS2();
+ }
+ return inAsmJS1;
+ })(null, { fromAsmJS })();
+ });
+ await SpecialPowers.spawn(browsingContext, [], function frameScript() {
+ const sandbox = new Cu.Sandbox(null, { invisibleToDebugger: true });
+ const sandboxObj = sandbox.eval("new Object");
+ content.console.log(sandboxObj);
+ });
+}
+
+// Copied from devtools/shared/webconsole/test/chrome/common.js
+function checkConsoleAPICall(call, expected) {
+ is(
+ call.arguments?.length || 0,
+ expected.arguments?.length || 0,
+ "number of arguments"
+ );
+
+ checkObject(call, expected);
+}
diff --git a/devtools/shared/commands/resource/tests/browser_resources_console_messages_navigation.js b/devtools/shared/commands/resource/tests/browser_resources_console_messages_navigation.js
new file mode 100644
index 0000000000..1d476e9f52
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/browser_resources_console_messages_navigation.js
@@ -0,0 +1,190 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the resource command API around CONSOLE_MESSAGE when navigating
+// tab and inner iframes to distinct origin/processes.
+
+const TEST_URL = URL_ROOT_COM_SSL + "doc_console.html";
+const TEST_IFRAME_URL = URL_ROOT_ORG_SSL + "doc_console_iframe.html";
+const TEST_DOMAIN = "https://example.org";
+add_task(async function () {
+ const START_URL = "data:text/html;charset=utf-8,foo";
+ const tab = await addTab(START_URL);
+
+ const { client, resourceCommand, targetCommand } = await initResourceCommand(
+ tab
+ );
+
+ await testCrossProcessTabNavigation(tab.linkedBrowser, resourceCommand);
+ await testCrossProcessIframeNavigation(tab.linkedBrowser, resourceCommand);
+
+ targetCommand.destroy();
+ await client.close();
+ BrowserTestUtils.removeTab(tab);
+});
+
+async function testCrossProcessTabNavigation(browser, resourceCommand) {
+ info(
+ "Navigate the top level document from data: URI to a https document including remote iframes"
+ );
+
+ let doneResolve;
+ const messages = [];
+ const onConsoleLogsComplete = new Promise(resolve => (doneResolve = resolve));
+
+ const onAvailable = resources => {
+ messages.push(...resources);
+ if (messages.length == 2) {
+ doneResolve();
+ }
+ };
+
+ await resourceCommand.watchResources(
+ [resourceCommand.TYPES.CONSOLE_MESSAGE],
+ { onAvailable }
+ );
+
+ const onLoaded = BrowserTestUtils.browserLoaded(browser);
+ await BrowserTestUtils.loadURIString(browser, TEST_URL);
+ await onLoaded;
+
+ info("Wait for log message");
+ await onConsoleLogsComplete;
+
+ // messages are coming from different targets so the order isn't guaranteed
+ const topLevelMessageResource = messages.find(resource =>
+ resource.message.filename.startsWith(URL_ROOT_COM_SSL)
+ );
+ const iframeMessage = messages.find(resource =>
+ resource.message.filename.startsWith("data:")
+ );
+
+ assertConsoleMessage(resourceCommand, topLevelMessageResource, {
+ targetFront: resourceCommand.targetCommand.targetFront,
+ messageText: "top-level document log",
+ });
+ assertConsoleMessage(resourceCommand, iframeMessage, {
+ targetFront: isEveryFrameTargetEnabled
+ ? resourceCommand.targetCommand
+ .getAllTargets([resourceCommand.targetCommand.TYPES.FRAME])
+ .find(t => t.url.startsWith("data:"))
+ : resourceCommand.targetCommand.targetFront,
+ messageText: "data url data log",
+ });
+
+ resourceCommand.unwatchResources([resourceCommand.TYPES.CONSOLE_MESSAGE], {
+ onAvailable,
+ });
+}
+
+async function testCrossProcessIframeNavigation(browser, resourceCommand) {
+ info("Navigate an inner iframe from data: URI to a https remote URL");
+
+ let doneResolve;
+ const messages = [];
+ const onConsoleLogsComplete = new Promise(resolve => (doneResolve = resolve));
+
+ const onAvailable = resources => {
+ messages.push(
+ ...resources.filter(
+ r => !r.message.arguments[0].startsWith("[WORKER] started")
+ )
+ );
+ if (messages.length == 3) {
+ doneResolve();
+ }
+ };
+
+ await resourceCommand.watchResources(
+ [resourceCommand.TYPES.CONSOLE_MESSAGE],
+ {
+ onAvailable,
+ }
+ );
+
+ // messages are coming from different targets so the order isn't guaranteed
+ const topLevelMessageResource = messages.find(resource =>
+ resource.message.arguments[0].startsWith("top-level")
+ );
+ const dataUrlMessageResource = messages.find(resource =>
+ resource.message.arguments[0].startsWith("data url")
+ );
+
+ // Assert cached messages from the previous top document
+ assertConsoleMessage(resourceCommand, topLevelMessageResource, {
+ messageText: "top-level document log",
+ });
+ assertConsoleMessage(resourceCommand, dataUrlMessageResource, {
+ messageText: "data url data log",
+ });
+
+ // Navigate the iframe to another origin/process
+ await SpecialPowers.spawn(browser, [TEST_IFRAME_URL], function (iframeUrl) {
+ const iframe = content.document.querySelector("iframe");
+ iframe.src = iframeUrl;
+ });
+
+ info("Wait for log message");
+ await onConsoleLogsComplete;
+
+ // iframeTarget will be different if Fission is on or off
+ const iframeTarget = await getIframeTargetFront(
+ resourceCommand.targetCommand
+ );
+
+ const iframeMessageResource = messages.find(resource =>
+ resource.message.arguments[0].endsWith("iframe log")
+ );
+ assertConsoleMessage(resourceCommand, iframeMessageResource, {
+ messageText: `${TEST_DOMAIN} iframe log`,
+ targetFront: iframeTarget,
+ });
+
+ resourceCommand.unwatchResources([resourceCommand.TYPES.CONSOLE_MESSAGE], {
+ onAvailable,
+ });
+}
+
+function assertConsoleMessage(resourceCommand, messageResource, expected) {
+ is(
+ messageResource.resourceType,
+ resourceCommand.TYPES.CONSOLE_MESSAGE,
+ "Resource is a console message"
+ );
+ ok(messageResource.message, "message is wrapped into a message attribute");
+ if (expected.targetFront) {
+ is(
+ messageResource.targetFront,
+ expected.targetFront,
+ "Message has the correct target front"
+ );
+ }
+ is(
+ messageResource.message.arguments[0],
+ expected.messageText,
+ "The correct type of message"
+ );
+}
+
+async function getIframeTargetFront(targetCommand) {
+ // If Fission/EFT is enabled, the iframe will have a dedicated target,
+ // otherwise it will be debuggable via the top level target.
+ if (!isFissionEnabled() && !isEveryFrameTargetEnabled()) {
+ return targetCommand.targetFront;
+ }
+ const frameTargets = targetCommand.getAllTargets([targetCommand.TYPES.FRAME]);
+ const browsingContextID = await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ () => {
+ return content.document.querySelector("iframe").browsingContext.id;
+ }
+ );
+ const iframeTarget = frameTargets.find(target => {
+ return target.browsingContextID == browsingContextID;
+ });
+ ok(iframeTarget, "Found the iframe target front");
+ return iframeTarget;
+}
diff --git a/devtools/shared/commands/resource/tests/browser_resources_console_messages_workers.js b/devtools/shared/commands/resource/tests/browser_resources_console_messages_workers.js
new file mode 100644
index 0000000000..4b10f1d2e4
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/browser_resources_console_messages_workers.js
@@ -0,0 +1,257 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the ResourceCommand API around CONSOLE_MESSAGE in workers
+
+const FISSION_TEST_URL = URL_ROOT_SSL + "fission_document_workers.html";
+const WORKER_FILE = "test_worker.js";
+const IFRAME_FILE = `${URL_ROOT_ORG_SSL}fission_iframe_workers.html`;
+
+add_task(async function () {
+ // Set the following pref to false as it's the one that enables direct connection
+ // to the worker targets
+ await pushPref("dom.worker.console.dispatch_events_to_main_thread", false);
+
+ const tab = await addTab(FISSION_TEST_URL);
+
+ const { client, resourceCommand, targetCommand } = await initResourceCommand(
+ tab,
+ { listenForWorkers: true }
+ );
+
+ info("Wait for the workers (from the main page and the iframe) to be ready");
+ const targets = [];
+ await new Promise(resolve => {
+ const onAvailable = async ({ targetFront }) => {
+ targets.push(targetFront);
+ if (targets.length === 2) {
+ resolve();
+ }
+ };
+ targetCommand.watchTargets({
+ types: [targetCommand.TYPES.WORKER],
+ onAvailable,
+ });
+ });
+
+ // The worker logs a message right when it starts, containing its location, so we can
+ // assert that we get the logs from the worker spawned in the content page and from the
+ // worker spawned in the iframe.
+ info("Check that we receive the cached messages");
+
+ const resources = [];
+ const onAvailable = innerResources => {
+ for (const resource of innerResources) {
+ // Ignore resources from non worker targets
+ if (!resource.targetFront.isWorkerTarget) {
+ continue;
+ }
+
+ resources.push(resource);
+ }
+ };
+
+ await resourceCommand.watchResources(
+ [resourceCommand.TYPES.CONSOLE_MESSAGE],
+ {
+ onAvailable,
+ }
+ );
+
+ is(resources.length, 2, "Got the expected number of existing messages");
+ const startLogFromWorkerInMainPage = resources.find(
+ ({ message }) =>
+ message.arguments[1] === `${URL_ROOT_SSL}${WORKER_FILE}#simple-worker`
+ );
+ const startLogFromWorkerInIframe = resources.find(
+ ({ message }) =>
+ message.arguments[1] ===
+ `${URL_ROOT_ORG_SSL}${WORKER_FILE}#simple-worker-in-iframe`
+ );
+
+ checkStartWorkerLogMessage(startLogFromWorkerInMainPage, {
+ expectedUrl: `${URL_ROOT_SSL}${WORKER_FILE}#simple-worker`,
+ isAlreadyExistingResource: true,
+ });
+ checkStartWorkerLogMessage(startLogFromWorkerInIframe, {
+ expectedUrl: `${URL_ROOT_ORG_SSL}${WORKER_FILE}#simple-worker-in-iframe`,
+ isAlreadyExistingResource: true,
+ });
+ let messageCount = resources.length;
+
+ info(
+ "Now log messages *after* the call to ResourceCommand.watchResources and after having received all existing messages"
+ );
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], () => {
+ content.wrappedJSObject.logMessageInWorker("live message from main page");
+
+ const iframe = content.document.querySelector("iframe");
+ SpecialPowers.spawn(iframe, [], () => {
+ content.wrappedJSObject.logMessageInWorker("live message from iframe");
+ });
+ });
+
+ // Wait until the 2 new logs are available
+ await waitUntil(() => resources.length === messageCount + 2);
+ const liveMessageFromWorkerInMainPage = resources.find(
+ ({ message }) => message.arguments[1] === "live message from main page"
+ );
+ const liveMessageFromWorkerInIframe = resources.find(
+ ({ message }) => message.arguments[1] === "live message from iframe"
+ );
+
+ checkLogInWorkerMessage(
+ liveMessageFromWorkerInMainPage,
+ "live message from main page"
+ );
+
+ checkLogInWorkerMessage(
+ liveMessageFromWorkerInIframe,
+ "live message from iframe"
+ );
+
+ // update the current number of resources received
+ messageCount = resources.length;
+
+ info("Now spawn new workers and log messages in main page and iframe");
+ await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [WORKER_FILE],
+ async workerUrl => {
+ const spawnedWorker = new content.Worker(`${workerUrl}#spawned-worker`);
+ spawnedWorker.postMessage({
+ type: "log-in-worker",
+ message: "live message in spawned worker from main page",
+ });
+
+ const iframe = content.document.querySelector("iframe");
+ SpecialPowers.spawn(iframe, [workerUrl], async innerWorkerUrl => {
+ const spawnedWorkerInIframe = new content.Worker(
+ `${innerWorkerUrl}#spawned-worker-in-iframe`
+ );
+ spawnedWorkerInIframe.postMessage({
+ type: "log-in-worker",
+ message: "live message in spawned worker from iframe",
+ });
+ });
+ }
+ );
+
+ info(
+ "Wait until the 4 new logs are available (the ones logged at worker creation + the ones from postMessage"
+ );
+ await waitUntil(
+ () => resources.length === messageCount + 4,
+ `Couldn't get the expected number of resources (expected ${
+ messageCount + 4
+ }, got ${resources.length})`
+ );
+ const startLogFromSpawnedWorkerInMainPage = resources.find(
+ ({ message }) =>
+ message.arguments[1] === `${URL_ROOT_SSL}${WORKER_FILE}#spawned-worker`
+ );
+ const startLogFromSpawnedWorkerInIframe = resources.find(
+ ({ message }) =>
+ message.arguments[1] ===
+ `${URL_ROOT_ORG_SSL}${WORKER_FILE}#spawned-worker-in-iframe`
+ );
+ const liveMessageFromSpawnedWorkerInMainPage = resources.find(
+ ({ message }) =>
+ message.arguments[1] === "live message in spawned worker from main page"
+ );
+ const liveMessageFromSpawnedWorkerInIframe = resources.find(
+ ({ message }) =>
+ message.arguments[1] === "live message in spawned worker from iframe"
+ );
+
+ checkStartWorkerLogMessage(startLogFromSpawnedWorkerInMainPage, {
+ expectedUrl: `${URL_ROOT_SSL}${WORKER_FILE}#spawned-worker`,
+ });
+ checkStartWorkerLogMessage(startLogFromSpawnedWorkerInIframe, {
+ expectedUrl: `${URL_ROOT_ORG_SSL}${WORKER_FILE}#spawned-worker-in-iframe`,
+ });
+ checkLogInWorkerMessage(
+ liveMessageFromSpawnedWorkerInMainPage,
+ "live message in spawned worker from main page"
+ );
+ checkLogInWorkerMessage(
+ liveMessageFromSpawnedWorkerInIframe,
+ "live message in spawned worker from iframe"
+ );
+ // update the current number of resources received
+ messageCount = resources.length;
+
+ info(
+ "Add a remote iframe on the same origin we already have an iframe and check we get the messages"
+ );
+ await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [IFRAME_FILE],
+ async iframeUrl => {
+ const iframe = content.document.createElement("iframe");
+ iframe.src = `${iframeUrl}?hashSuffix=in-second-iframe`;
+ content.document.body.append(iframe);
+ }
+ );
+
+ info("Wait until the new log is available");
+ await waitUntil(
+ () => resources.length === messageCount + 1,
+ `Couldn't get the expected number of resources (expected ${
+ messageCount + 1
+ }, got ${resources.length})`
+ );
+ const startLogFromWorkerInSecondIframe = resources[resources.length - 1];
+ checkStartWorkerLogMessage(startLogFromWorkerInSecondIframe, {
+ expectedUrl: `${URL_ROOT_ORG_SSL}${WORKER_FILE}#simple-worker-in-second-iframe`,
+ });
+
+ targetCommand.destroy();
+ await client.close();
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async () => {
+ // registrationPromise is set by the test page.
+ const registration = await content.wrappedJSObject.registrationPromise;
+ registration.unregister();
+ });
+});
+
+function checkStartWorkerLogMessage(
+ resource,
+ { expectedUrl, isAlreadyExistingResource = false }
+) {
+ const { message } = resource;
+ const [firstArg, secondArg, thirdArg] = message.arguments;
+ is(firstArg, "[WORKER] started", "Got the expected first argument");
+ is(secondArg, expectedUrl, "expected url was logged");
+ is(
+ thirdArg?._grip?.class,
+ "DedicatedWorkerGlobalScope",
+ "the global scope was logged as expected"
+ );
+ is(
+ resource.isAlreadyExistingResource,
+ isAlreadyExistingResource,
+ "Resource has expected value for isAlreadyExistingResource"
+ );
+}
+
+function checkLogInWorkerMessage(resource, expectedMessage) {
+ const { message } = resource;
+ const [firstArg, secondArg, thirdArg] = message.arguments;
+ is(firstArg, "[WORKER]", "Got the expected first argument");
+ is(secondArg, expectedMessage, "expected message was logged");
+ is(
+ thirdArg?._grip?.class,
+ "MessageEvent",
+ "the message event object was logged as expected"
+ );
+ is(
+ resource.isAlreadyExistingResource,
+ false,
+ "Resource has expected value for isAlreadyExistingResource"
+ );
+}
diff --git a/devtools/shared/commands/resource/tests/browser_resources_css_changes.js b/devtools/shared/commands/resource/tests/browser_resources_css_changes.js
new file mode 100644
index 0000000000..22b11a8186
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/browser_resources_css_changes.js
@@ -0,0 +1,151 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the ResourceCommand API around CSS_CHANGE.
+
+add_task(async function () {
+ // Open a test tab
+ const tab = await addTab(
+ "data:text/html,<body style='color: lime;'>CSS Changes</body>"
+ );
+
+ const { client, resourceCommand, targetCommand } = await initResourceCommand(
+ tab
+ );
+
+ // CSS_CHANGE watcher doesn't record modification made before watching,
+ // so we have to start watching before doing any DOM mutation.
+ await resourceCommand.watchResources([resourceCommand.TYPES.CSS_CHANGE], {
+ onAvailable: () => {},
+ });
+
+ const { walker } = await targetCommand.targetFront.getFront("inspector");
+ const nodeList = await walker.querySelectorAll(walker.rootNode, "body");
+ const body = (await nodeList.items())[0];
+ const style = (
+ await body.inspectorFront.pageStyle.getApplied(body, {
+ skipPseudo: false,
+ })
+ )[0];
+
+ info(
+ "Check whether ResourceCommand catches CSS change that fired before starting to watch"
+ );
+ await setProperty(style.rule, 0, "color", "black");
+
+ const availableResources = [];
+ await resourceCommand.watchResources([resourceCommand.TYPES.CSS_CHANGE], {
+ onAvailable: resources => availableResources.push(...resources),
+ });
+ assertResource(
+ availableResources[0],
+ { index: 0, property: "color", value: "black" },
+ { index: 0, property: "color", value: "lime" }
+ );
+
+ info(
+ "Check whether ResourceCommand catches CSS changes after the property was renamed and updated"
+ );
+
+ // RuleRewriter:apply will not support a simultaneous rename + setProperty.
+ // Doing so would send inconsistent arguments to StyleRuleActor:setRuleText,
+ // the CSS text for the rule will not match the list of modifications, which
+ // would desynchronize the Changes view. Thankfully this scenario should not
+ // happen when using the UI to update the rules.
+ await renameProperty(style.rule, 0, "color", "background-color");
+ await waitUntil(() => availableResources.length === 2);
+ assertResource(
+ availableResources[1],
+ { index: 0, property: "background-color", value: "black" },
+ { index: 0, property: "color", value: "black" }
+ );
+
+ await setProperty(style.rule, 0, "background-color", "pink");
+ await waitUntil(() => availableResources.length === 3);
+ assertResource(
+ availableResources[2],
+ { index: 0, property: "background-color", value: "pink" },
+ { index: 0, property: "background-color", value: "black" }
+ );
+
+ info("Check whether ResourceCommand catches CSS change of disabling");
+ await setPropertyEnabled(style.rule, 0, "background-color", false);
+ await waitUntil(() => availableResources.length === 4);
+ assertResource(availableResources[3], null, {
+ index: 0,
+ property: "background-color",
+ value: "pink",
+ });
+
+ info("Check whether ResourceCommand catches CSS change of new property");
+ await createProperty(style.rule, 1, "font-size", "100px");
+ await waitUntil(() => availableResources.length === 5);
+ assertResource(
+ availableResources[4],
+ { index: 1, property: "font-size", value: "100px" },
+ null
+ );
+
+ info("Check whether ResourceCommand sends all resources added in this test");
+ const existingResources = [];
+ await resourceCommand.watchResources([resourceCommand.TYPES.CSS_CHANGE], {
+ onAvailable: resources => existingResources.push(...resources),
+ });
+ await waitUntil(() => existingResources.length === 5);
+ is(availableResources[0], existingResources[0], "1st resource is correct");
+ is(availableResources[1], existingResources[1], "2nd resource is correct");
+ is(availableResources[2], existingResources[2], "3rd resource is correct");
+ is(availableResources[3], existingResources[3], "4th resource is correct");
+ is(availableResources[4], existingResources[4], "4th resource is correct");
+
+ targetCommand.destroy();
+ await client.close();
+});
+
+function assertResource(resource, expectedAddedChange, expectedRemovedChange) {
+ if (expectedAddedChange) {
+ is(resource.add.length, 1, "The number of added changes is correct");
+ assertChange(resource.add[0], expectedAddedChange);
+ } else {
+ is(resource.add, null, "There is no added changes");
+ }
+
+ if (expectedRemovedChange) {
+ is(resource.remove.length, 1, "The number of removed changes is correct");
+ assertChange(resource.remove[0], expectedRemovedChange);
+ } else {
+ is(resource.remove, null, "There is no removed changes");
+ }
+}
+
+function assertChange(change, expected) {
+ is(change.index, expected.index, "The index of change is correct");
+ is(change.property, expected.property, "The property of change is correct");
+ is(change.value, expected.value, "The value of change is correct");
+}
+
+async function setProperty(rule, index, property, value) {
+ const modifications = rule.startModifyingProperties({ isKnown: true });
+ modifications.setProperty(index, property, value, "");
+ await modifications.apply();
+}
+
+async function renameProperty(rule, index, oldName, newName, value) {
+ const modifications = rule.startModifyingProperties({ isKnown: true });
+ modifications.renameProperty(index, oldName, newName);
+ await modifications.apply();
+}
+
+async function createProperty(rule, index, property, value) {
+ const modifications = rule.startModifyingProperties({ isKnown: true });
+ modifications.createProperty(index, property, value, "", true);
+ await modifications.apply();
+}
+
+async function setPropertyEnabled(rule, index, property, isEnabled) {
+ const modifications = rule.startModifyingProperties({ isKnown: true });
+ modifications.setPropertyEnabled(index, property, isEnabled);
+ await modifications.apply();
+}
diff --git a/devtools/shared/commands/resource/tests/browser_resources_css_messages.js b/devtools/shared/commands/resource/tests/browser_resources_css_messages.js
new file mode 100644
index 0000000000..2146904bb0
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/browser_resources_css_messages.js
@@ -0,0 +1,210 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the ResourceCommand API around CSS_MESSAGE
+// Reproduces the CSS message assertions from devtools/shared/webconsole/test/chrome/test_page_errors.html
+
+const { MESSAGE_CATEGORY } = require("resource://devtools/shared/constants.js");
+
+// Create a simple server so we have a nice sourceName in the resources packets.
+const httpServer = createTestHTTPServer();
+httpServer.registerPathHandler(`/test_css_messages.html`, (req, res) => {
+ res.setStatusLine(req.httpVersion, 200, "OK");
+ res.write(`<meta charset=utf8>
+ <style>
+ html {
+ color: bloup;
+ }
+ </style>Test CSS Messages`);
+});
+
+const TEST_URI = `http://localhost:${httpServer.identity.primaryPort}/test_css_messages.html`;
+
+add_task(async function () {
+ await testWatchingCssMessages();
+ await testWatchingCachedCssMessages();
+});
+
+async function testWatchingCssMessages() {
+ // Disable the preloaded process as it creates processes intermittently
+ // which forces the emission of RDP requests we aren't correctly waiting for.
+ await pushPref("dom.ipc.processPrelaunch.enabled", false);
+
+ // Open a test tab
+ const tab = await addTab(TEST_URI);
+
+ const { client, resourceCommand, targetCommand } = await initResourceCommand(
+ tab
+ );
+
+ const receivedMessages = [];
+ const { onAvailable, onAllMessagesReceived } = setupOnAvailableFunction(
+ targetCommand,
+ receivedMessages,
+ false
+ );
+ await resourceCommand.watchResources([resourceCommand.TYPES.CSS_MESSAGE], {
+ onAvailable,
+ });
+
+ info(
+ "Now log CSS warning *after* the call to ResourceCommand.watchResources and after " +
+ "having received the existing message"
+ );
+ // We need to wait for the first CSS Warning as it is not a cached message; when we
+ // start watching, the `cssErrorReportingEnabled` is checked on the target docShell, and
+ // if it is false, we re-parse the stylesheets to get the messages.
+ await BrowserTestUtils.waitForCondition(() => receivedMessages.length === 1);
+
+ info("Trigger a CSS Warning");
+ triggerCSSWarning(tab);
+
+ info("Waiting for all expected CSS messages to be received");
+ await onAllMessagesReceived;
+ ok(true, "All the expected CSS messages were received");
+
+ Services.console.reset();
+ targetCommand.destroy();
+ await client.close();
+}
+
+async function testWatchingCachedCssMessages() {
+ // Disable the preloaded process as it creates processes intermittently
+ // which forces the emission of RDP requests we aren't correctly waiting for.
+ await pushPref("dom.ipc.processPrelaunch.enabled", false);
+
+ // Open a test tab
+ const tab = await addTab(TEST_URI);
+
+ // By default, the CSS Parser does not emit warnings at all, for performance matter.
+ // Since we actually want the Parser to emit those messages _before_ we start listening
+ // for CSS messages, we need to set the cssErrorReportingEnabled flag on the docShell.
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () {
+ content.docShell.cssErrorReportingEnabled = true;
+ });
+
+ // Setting the docShell flag only indicates to the Parser that from now on, it should
+ // emit warnings. But it does not automatically emit warnings for the existing CSS
+ // errors in the stylesheets. So here we reload the tab, which will make the Parser
+ // parse the stylesheets again, this time emitting warnings.
+ await reloadBrowser();
+ // and trigger more CSS warnings
+ await triggerCSSWarning(tab);
+
+ // At this point, all messages should be in the ConsoleService cache, and we can begin
+ // to watch and check that we do retrieve those messages.
+ const { client, resourceCommand, targetCommand } = await initResourceCommand(
+ tab
+ );
+
+ const receivedMessages = [];
+ const { onAvailable } = setupOnAvailableFunction(
+ targetCommand,
+ receivedMessages,
+ true
+ );
+ await resourceCommand.watchResources([resourceCommand.TYPES.CSS_MESSAGE], {
+ onAvailable,
+ });
+ is(receivedMessages.length, 3, "Cached messages were retrieved as expected");
+
+ Services.console.reset();
+ targetCommand.destroy();
+ await client.close();
+}
+
+function setupOnAvailableFunction(
+ targetCommand,
+ receivedMessages,
+ isAlreadyExistingResource
+) {
+ // timeStamp are the result of a number in microsecond divided by 1000.
+ // so we can't expect a precise number of decimals, or even if there would
+ // be decimals at all.
+ const FRACTIONAL_NUMBER_REGEX = /^\d+(\.\d{1,3})?$/;
+
+ // The expected messages are the CSS warnings:
+ // - one for the rule in the style element
+ // - two for the JS modified style we're doing in the test.
+ const expectedMessages = [
+ {
+ pageError: {
+ errorMessage: /Expected color but found ‘bloup’/,
+ sourceName: /test_css_messages/,
+ category: MESSAGE_CATEGORY.CSS_PARSER,
+ timeStamp: FRACTIONAL_NUMBER_REGEX,
+ error: false,
+ warning: true,
+ },
+ cssSelectors: "html",
+ isAlreadyExistingResource,
+ },
+ {
+ pageError: {
+ errorMessage: /Error in parsing value for ‘width’/,
+ sourceName: /test_css_messages/,
+ category: MESSAGE_CATEGORY.CSS_PARSER,
+ timeStamp: FRACTIONAL_NUMBER_REGEX,
+ error: false,
+ warning: true,
+ },
+ isAlreadyExistingResource,
+ },
+ {
+ pageError: {
+ errorMessage: /Error in parsing value for ‘height’/,
+ sourceName: /test_css_messages/,
+ category: MESSAGE_CATEGORY.CSS_PARSER,
+ timeStamp: FRACTIONAL_NUMBER_REGEX,
+ error: false,
+ warning: true,
+ },
+ isAlreadyExistingResource,
+ },
+ ];
+
+ let done;
+ const onAllMessagesReceived = new Promise(resolve => (done = resolve));
+ const onAvailable = resources => {
+ for (const resource of resources) {
+ const { pageError } = resource;
+
+ is(
+ resource.targetFront,
+ targetCommand.targetFront,
+ "The targetFront property is the expected one"
+ );
+
+ if (!pageError.sourceName.includes("test_css_messages")) {
+ info(`Ignore error from unknown source: "${pageError.sourceName}"`);
+ continue;
+ }
+
+ const index = receivedMessages.length;
+ receivedMessages.push(resource);
+
+ info(
+ `checking received css message #${index}: ${pageError.errorMessage}`
+ );
+ ok(pageError, "The resource has a pageError attribute");
+ checkObject(resource, expectedMessages[index]);
+
+ if (receivedMessages.length == expectedMessages.length) {
+ done();
+ }
+ }
+ };
+ return { onAvailable, onAllMessagesReceived };
+}
+
+/**
+ * Sets invalid values for width and height on the document's body style attribute.
+ */
+function triggerCSSWarning(tab) {
+ return ContentTask.spawn(tab.linkedBrowser, null, function frameScript() {
+ content.document.body.style.width = "red";
+ content.document.body.style.height = "blue";
+ });
+}
diff --git a/devtools/shared/commands/resource/tests/browser_resources_document_events.js b/devtools/shared/commands/resource/tests/browser_resources_document_events.js
new file mode 100644
index 0000000000..2bd70b9272
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/browser_resources_document_events.js
@@ -0,0 +1,711 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the ResourceCommand API around DOCUMENT_EVENT
+
+add_task(async function () {
+ await testDocumentEventResources();
+ await testDocumentEventResourcesWithIgnoreExistingResources();
+ await testDomCompleteWithOverloadedConsole();
+ await testIframeNavigation();
+ await testBfCacheNavigation();
+ await testDomCompleteWithWindowStop();
+ await testCrossOriginNavigation();
+});
+
+async function testDocumentEventResources() {
+ info("Test ResourceCommand for DOCUMENT_EVENT");
+
+ // Open a test tab
+ const title = "DocumentEventsTitle";
+ const url = `data:text/html,<title>${title}</title>Document Events`;
+ const tab = await addTab(url);
+
+ const listener = new ResourceListener();
+ const { commands } = await initResourceCommand(tab);
+
+ info(
+ "Check whether the document events are fired correctly even when the document was already loaded"
+ );
+ const onLoadingAtInit = listener.once("dom-loading");
+ const onInteractiveAtInit = listener.once("dom-interactive");
+ const onCompleteAtInit = listener.once("dom-complete");
+ await commands.resourceCommand.watchResources(
+ [commands.resourceCommand.TYPES.DOCUMENT_EVENT],
+ {
+ onAvailable: parameters => listener.dispatch(parameters),
+ }
+ );
+ await assertPromises(
+ commands,
+ // targetBeforeNavigation is only used when there is a will-navigate and a navigate, but there is none here
+ null,
+ // As we started watching on an already loaded document, and no navigation happened since we called watchResources,
+ // we don't have any will-navigate event
+ null,
+ onLoadingAtInit,
+ onInteractiveAtInit,
+ onCompleteAtInit
+ );
+ ok(
+ true,
+ "Document events are fired even when the document was already loaded"
+ );
+ let domLoadingResource = await onLoadingAtInit;
+
+ is(
+ domLoadingResource.url,
+ url,
+ `resource ${domLoadingResource.name} has expected url`
+ );
+ is(
+ domLoadingResource.title,
+ undefined,
+ `resource ${domLoadingResource.name} does not have a title property`
+ );
+
+ let domInteractiveResource = await onInteractiveAtInit;
+ is(
+ domInteractiveResource.url,
+ url,
+ `resource ${domInteractiveResource.name} has expected url`
+ );
+ is(
+ domInteractiveResource.title,
+ title,
+ `resource ${domInteractiveResource.name} has expected title`
+ );
+ let domCompleteResource = await onCompleteAtInit;
+ is(
+ domCompleteResource.url,
+ undefined,
+ `resource ${domCompleteResource.name} does not have a url property`
+ );
+ is(
+ domCompleteResource.title,
+ undefined,
+ `resource ${domCompleteResource.name} does not have a title property`
+ );
+
+ info("Check whether the document events are fired correctly when reloading");
+ const onWillNavigate = listener.once("will-navigate");
+ const onLoadingAtReloaded = listener.once("dom-loading");
+ const onInteractiveAtReloaded = listener.once("dom-interactive");
+ const onCompleteAtReloaded = listener.once("dom-complete");
+ const targetBeforeNavigation = commands.targetCommand.targetFront;
+ gBrowser.reloadTab(tab);
+ await assertPromises(
+ commands,
+ targetBeforeNavigation,
+ onWillNavigate,
+ onLoadingAtReloaded,
+ onInteractiveAtReloaded,
+ onCompleteAtReloaded
+ );
+ ok(true, "Document events are fired after reloading");
+
+ domLoadingResource = await onLoadingAtReloaded;
+ is(
+ domLoadingResource.url,
+ url,
+ `resource ${domLoadingResource.name} has expected url after reloading`
+ );
+ is(
+ domLoadingResource.title,
+ undefined,
+ `resource ${domLoadingResource.name} does not have a title property after reloading`
+ );
+
+ domInteractiveResource = await onInteractiveAtInit;
+ is(
+ domInteractiveResource.url,
+ url,
+ `resource ${domInteractiveResource.name} has url property after reloading`
+ );
+ is(
+ domInteractiveResource.title,
+ title,
+ `resource ${domInteractiveResource.name} has expected title after reloading`
+ );
+ domCompleteResource = await onCompleteAtInit;
+ is(
+ domCompleteResource.url,
+ undefined,
+ `resource ${domCompleteResource.name} does not have a url property after reloading`
+ );
+ is(
+ domCompleteResource.title,
+ undefined,
+ `resource ${domCompleteResource.name} does not have a title property after reloading`
+ );
+
+ await commands.destroy();
+}
+
+async function testDocumentEventResourcesWithIgnoreExistingResources() {
+ info("Test ignoreExistingResources option for DOCUMENT_EVENT");
+
+ const tab = await addTab("data:text/html,Document Events");
+
+ const { commands } = await initResourceCommand(tab);
+
+ info("Check whether the existing document events will not be fired");
+ const documentEvents = [];
+ await commands.resourceCommand.watchResources(
+ [commands.resourceCommand.TYPES.DOCUMENT_EVENT],
+ {
+ onAvailable: resources => documentEvents.push(...resources),
+ ignoreExistingResources: true,
+ }
+ );
+ is(documentEvents.length, 0, "Existing document events are not fired");
+
+ info("Check whether the future document events are fired");
+ const targetBeforeNavigation = commands.targetCommand.targetFront;
+ gBrowser.reloadTab(tab);
+ info(
+ "Wait for will-navigate, dom-loading, dom-interactive and dom-complete events"
+ );
+ await waitFor(() => documentEvents.length === 4);
+ assertEvents({ commands, targetBeforeNavigation, documentEvents });
+
+ await commands.destroy();
+}
+
+async function testIframeNavigation() {
+ info("Test iframe navigations for DOCUMENT_EVENT");
+
+ const tab = await addTab(
+ 'https://example.com/document-builder.sjs?html=<iframe src="https://example.net/document-builder.sjs?html=net"></iframe>'
+ );
+ const secondPageUrl = "https://example.org/document-builder.sjs?html=org";
+
+ const { commands } = await initResourceCommand(tab);
+
+ let documentEvents = [];
+ await commands.resourceCommand.watchResources(
+ [commands.resourceCommand.TYPES.DOCUMENT_EVENT],
+ {
+ onAvailable: resources => documentEvents.push(...resources),
+ }
+ );
+ let iframeTarget;
+ if (isFissionEnabled() || isEveryFrameTargetEnabled()) {
+ is(
+ documentEvents.length,
+ 6,
+ "With fission/EFT, we get two targets and two sets of events: dom-loading, dom-interactive, dom-complete"
+ );
+ [, iframeTarget] = await commands.targetCommand.getAllTargets([
+ commands.targetCommand.TYPES.FRAME,
+ ]);
+ // Filter out each target events as their order to be random between the two targets
+ const topTargetEvents = documentEvents.filter(
+ r => r.targetFront == commands.targetCommand.targetFront
+ );
+ const iframeTargetEvents = documentEvents.filter(
+ r => r.targetFront != commands.targetCommand.targetFront
+ );
+ assertEvents({
+ commands,
+ documentEvents: [null /* no will-navigate */, ...topTargetEvents],
+ });
+ assertEvents({
+ commands,
+ documentEvents: [null /* no will-navigate */, ...iframeTargetEvents],
+ expectedTargetFront: iframeTarget,
+ });
+ } else {
+ assertEvents({
+ commands,
+ documentEvents: [null /* no will-navigate */, ...documentEvents],
+ });
+ }
+
+ info("Navigate the iframe to another process (if fission is enabled)");
+ documentEvents = [];
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [secondPageUrl],
+ function (url) {
+ const iframe = content.document.querySelector("iframe");
+ iframe.src = url;
+ }
+ );
+
+ // We are switching to a new target only when fission is enabled...
+ if (isFissionEnabled() || isEveryFrameTargetEnabled()) {
+ await waitFor(() => documentEvents.length >= 3);
+ is(
+ documentEvents.length,
+ 3,
+ "With fission/EFT, we switch to a new target and get: dom-loading, dom-interactive, dom-complete (but no will-navigate as that's only for the top BrowsingContext)"
+ );
+ const [, newIframeTarget] = await commands.targetCommand.getAllTargets([
+ commands.targetCommand.TYPES.FRAME,
+ ]);
+ assertEvents({
+ commands,
+ targetBeforeNavigation: iframeTarget,
+ documentEvents: [null /* no will-navigate */, ...documentEvents],
+ expectedTargetFront: newIframeTarget,
+ expectedNewURI: secondPageUrl,
+ });
+ } else {
+ // Wait for some time in order to let a chance to receive some unexpected events
+ await wait(250);
+ is(
+ documentEvents.length,
+ 0,
+ "If fission is disabled, we navigate within the same process, we get no new target and no new resource"
+ );
+ }
+
+ await commands.destroy();
+}
+
+function isBfCacheInParentEnabled() {
+ return (
+ Services.appinfo.sessionHistoryInParent &&
+ Services.prefs.getBoolPref("fission.bfcacheInParent", false)
+ );
+}
+
+async function testBfCacheNavigation() {
+ info("Test bfcache navigations for DOCUMENT_EVENT");
+
+ info("Open a first document and navigate to a second one");
+ const firstLocation = "data:text/html,<title>first</title>first page";
+ const secondLocation = "data:text/html,<title>second</title>second page";
+ const tab = await addTab(firstLocation);
+ const onLoaded = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+ BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, secondLocation);
+ await onLoaded;
+
+ const { commands } = await initResourceCommand(tab);
+
+ const documentEvents = [];
+ await commands.resourceCommand.watchResources(
+ [commands.resourceCommand.TYPES.DOCUMENT_EVENT],
+ {
+ onAvailable: resources => {
+ documentEvents.push(...resources);
+ },
+ ignoreExistingResources: true,
+ }
+ );
+ // Wait for some time for extra safety
+ await wait(250);
+ is(documentEvents.length, 0, "Existing document events are not fired");
+
+ info("Navigate back to the first page");
+ const onSwitched = commands.targetCommand.once("switched-target");
+ const targetBeforeNavigation = commands.targetCommand.targetFront;
+ gBrowser.goBack();
+
+ // We are switching to a new target only when fission/EFT is enabled...
+ if (
+ (isFissionEnabled() || isEveryFrameTargetEnabled()) &&
+ isBfCacheInParentEnabled()
+ ) {
+ await onSwitched;
+ }
+
+ info(
+ "Wait for will-navigate, dom-loading, dom-interactive and dom-complete events"
+ );
+ await waitFor(() => documentEvents.length >= 4);
+ /* Ignore will-navigate timestamp as all other DOCUMENT_EVENTS will be set at the original load date,
+ which is when we loaded from the network, and not when we loaded from bfcache */
+ assertEvents({
+ commands,
+ targetBeforeNavigation,
+ documentEvents,
+ ignoreWillNavigateTimestamp: true,
+ });
+
+ // Wait for some time in order to let a chance to have duplicated dom-loading events
+ await wait(250);
+
+ is(
+ documentEvents.length,
+ 4,
+ "There is no duplicated event and only the 4 expected DOCUMENT_EVENT states"
+ );
+ const [willNavigateEvent, loadingEvent, interactiveEvent, completeEvent] =
+ documentEvents;
+
+ is(
+ willNavigateEvent.name,
+ "will-navigate",
+ "The first DOCUMENT_EVENT is will-navigate"
+ );
+ is(
+ loadingEvent.name,
+ "dom-loading",
+ "The second DOCUMENT_EVENT is dom-loading"
+ );
+ is(
+ interactiveEvent.name,
+ "dom-interactive",
+ "The third DOCUMENT_EVENT is dom-interactive"
+ );
+ is(
+ completeEvent.name,
+ "dom-complete",
+ "The fourth DOCUMENT_EVENT is dom-complete"
+ );
+
+ is(
+ loadingEvent.url,
+ firstLocation,
+ `resource ${loadingEvent.name} has expected url after navigation back`
+ );
+ is(
+ loadingEvent.title,
+ undefined,
+ `resource ${loadingEvent.name} does not have a title property after navigating back`
+ );
+
+ is(
+ interactiveEvent.url,
+ firstLocation,
+ `resource ${interactiveEvent.name} has expected url property after navigating back`
+ );
+ is(
+ interactiveEvent.title,
+ "first",
+ `resource ${interactiveEvent.name} has expected title after navigating back`
+ );
+
+ is(
+ completeEvent.url,
+ undefined,
+ `resource ${completeEvent.name} does not have a url property after navigating back`
+ );
+ is(
+ completeEvent.title,
+ undefined,
+ `resource ${completeEvent.name} does not have a title property after navigating back`
+ );
+
+ await commands.destroy();
+}
+
+async function testCrossOriginNavigation() {
+ info("Test cross origin navigations for DOCUMENT_EVENT");
+
+ const tab = await addTab("https://example.com/document-builder.sjs?html=com");
+
+ const { commands } = await initResourceCommand(tab);
+
+ const documentEvents = [];
+ await commands.resourceCommand.watchResources(
+ [commands.resourceCommand.TYPES.DOCUMENT_EVENT],
+ {
+ onAvailable: resources => documentEvents.push(...resources),
+ ignoreExistingResources: true,
+ }
+ );
+ // Wait for some time for extra safety
+ await wait(250);
+ is(documentEvents.length, 0, "Existing document events are not fired");
+
+ info("Navigate to another process");
+ const onSwitched = commands.targetCommand.once("switched-target");
+ const netUrl =
+ "https://example.net/document-builder.sjs?html=<head><title>titleNet</title></head>net";
+ const onLoaded = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+ const targetBeforeNavigation = commands.targetCommand.targetFront;
+ BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, netUrl);
+ await onLoaded;
+
+ // We are switching to a new target only when fission is enabled...
+ if (isFissionEnabled() || isEveryFrameTargetEnabled()) {
+ await onSwitched;
+ }
+
+ info(
+ "Wait for will-navigate, dom-loading, dom-interactive and dom-complete events"
+ );
+ await waitFor(() => documentEvents.length >= 4);
+ assertEvents({ commands, targetBeforeNavigation, documentEvents });
+
+ // Wait for some time in order to let a chance to have duplicated dom-loading events
+ await wait(250);
+
+ is(
+ documentEvents.length,
+ 4,
+ "There is no duplicated event and only the 4 expected DOCUMENT_EVENT states"
+ );
+ const [willNavigateEvent, loadingEvent, interactiveEvent, completeEvent] =
+ documentEvents;
+
+ is(
+ willNavigateEvent.name,
+ "will-navigate",
+ "The first DOCUMENT_EVENT is will-navigate"
+ );
+ is(
+ loadingEvent.name,
+ "dom-loading",
+ "The second DOCUMENT_EVENT is dom-loading"
+ );
+ is(
+ interactiveEvent.name,
+ "dom-interactive",
+ "The third DOCUMENT_EVENT is dom-interactive"
+ );
+ is(
+ completeEvent.name,
+ "dom-complete",
+ "The fourth DOCUMENT_EVENT is dom-complete"
+ );
+
+ is(
+ loadingEvent.url,
+ encodeURI(netUrl),
+ `resource ${loadingEvent.name} has expected url after reloading`
+ );
+ is(
+ loadingEvent.title,
+ undefined,
+ `resource ${loadingEvent.name} does not have a title property after reloading`
+ );
+
+ is(
+ interactiveEvent.url,
+ encodeURI(netUrl),
+ `resource ${interactiveEvent.name} has expected url property after reloading`
+ );
+ is(
+ interactiveEvent.title,
+ "titleNet",
+ `resource ${interactiveEvent.name} has expected title after reloading`
+ );
+
+ is(
+ completeEvent.url,
+ undefined,
+ `resource ${completeEvent.name} does not have a url property after reloading`
+ );
+ is(
+ completeEvent.title,
+ undefined,
+ `resource ${completeEvent.name} does not have a title property after reloading`
+ );
+
+ await commands.destroy();
+}
+
+async function testDomCompleteWithOverloadedConsole() {
+ info("Test dom-complete with an overloaded console object");
+
+ const tab = await addTab(
+ "data:text/html,<script>window.console = {};</script>"
+ );
+
+ const { client, resourceCommand, targetCommand } = await initResourceCommand(
+ tab
+ );
+
+ info("Check that all DOCUMENT_EVENTS are fired for the already loaded page");
+ const documentEvents = [];
+ await resourceCommand.watchResources([resourceCommand.TYPES.DOCUMENT_EVENT], {
+ onAvailable: resources => documentEvents.push(...resources),
+ });
+ is(documentEvents.length, 3, "Existing document events are fired");
+
+ const domComplete = documentEvents[2];
+ is(domComplete.name, "dom-complete", "the last resource is the dom-complete");
+ is(
+ domComplete.hasNativeConsoleAPI,
+ false,
+ "the console object is reported to be overloaded"
+ );
+
+ targetCommand.destroy();
+ await client.close();
+}
+
+async function testDomCompleteWithWindowStop() {
+ info("Test dom-complete with a page calling window.stop()");
+
+ const tab = await addTab("data:text/html,foo");
+
+ const { commands, client, resourceCommand, targetCommand } =
+ await initResourceCommand(tab);
+
+ info("Check that all DOCUMENT_EVENTS are fired for the already loaded page");
+ let documentEvents = [];
+ await resourceCommand.watchResources([resourceCommand.TYPES.DOCUMENT_EVENT], {
+ onAvailable: resources => documentEvents.push(...resources),
+ });
+ is(documentEvents.length, 3, "Existing document events are fired");
+ documentEvents = [];
+
+ const html = `<!DOCTYPE html><html>
+ <head>
+ <title>stopped page</title>
+ <script>window.stop();</script>
+ </head>
+ <body>Page content that shouldn't be displayed</body>
+</html>`;
+ const secondLocation = "data:text/html," + encodeURIComponent(html);
+ const targetBeforeNavigation = commands.targetCommand.targetFront;
+ BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, secondLocation);
+ info(
+ "Wait for will-navigate, dom-loading, dom-interactive and dom-complete events"
+ );
+ await waitFor(() => documentEvents.length === 4);
+
+ assertEvents({ commands, targetBeforeNavigation, documentEvents });
+
+ targetCommand.destroy();
+ await client.close();
+}
+
+async function assertPromises(
+ commands,
+ targetBeforeNavigation,
+ onWillNavigate,
+ onLoading,
+ onInteractive,
+ onComplete
+) {
+ const willNavigateEvent = await onWillNavigate;
+ const loadingEvent = await onLoading;
+ const interactiveEvent = await onInteractive;
+ const completeEvent = await onComplete;
+ assertEvents({
+ commands,
+ targetBeforeNavigation,
+ documentEvents: [
+ willNavigateEvent,
+ loadingEvent,
+ interactiveEvent,
+ completeEvent,
+ ],
+ });
+}
+
+function assertEvents({
+ commands,
+ targetBeforeNavigation,
+ documentEvents,
+ expectedTargetFront = commands.targetCommand.targetFront,
+ expectedNewURI = gBrowser.selectedBrowser.currentURI.spec,
+ ignoreWillNavigateTimestamp = false,
+}) {
+ const [willNavigateEvent, loadingEvent, interactiveEvent, completeEvent] =
+ documentEvents;
+ if (willNavigateEvent) {
+ is(willNavigateEvent.name, "will-navigate", "Received the will-navigate");
+ is(
+ willNavigateEvent.newURI,
+ expectedNewURI,
+ "will-navigate newURI is set to the current tab new location"
+ );
+ }
+ is(
+ loadingEvent.name,
+ "dom-loading",
+ "loading received in the exepected order"
+ );
+ is(
+ interactiveEvent.name,
+ "dom-interactive",
+ "interactive received in the expected order"
+ );
+ is(completeEvent.name, "dom-complete", "complete received last");
+
+ if (willNavigateEvent) {
+ is(
+ typeof willNavigateEvent.time,
+ "number",
+ `Type of time attribute for will-navigate event is correct (${willNavigateEvent.time})`
+ );
+ }
+ is(
+ typeof loadingEvent.time,
+ "number",
+ `Type of time attribute for loading event is correct (${loadingEvent.time})`
+ );
+ is(
+ typeof interactiveEvent.time,
+ "number",
+ `Type of time attribute for interactive event is correct (${interactiveEvent.time})`
+ );
+ is(
+ typeof completeEvent.time,
+ "number",
+ `Type of time attribute for complete event is correct (${completeEvent.time})`
+ );
+
+ if (willNavigateEvent && !ignoreWillNavigateTimestamp) {
+ ok(
+ willNavigateEvent.time <= loadingEvent.time,
+ `Timestamp for dom-loading event is greater than will-navigate event (${willNavigateEvent.time} <= ${loadingEvent.time})`
+ );
+ }
+ ok(
+ loadingEvent.time <= interactiveEvent.time,
+ `Timestamp for interactive event is greater than loading event (${loadingEvent.time} <= ${interactiveEvent.time})`
+ );
+ ok(
+ interactiveEvent.time <= completeEvent.time,
+ `Timestamp for complete event is greater than interactive event (${interactiveEvent.time} <= ${completeEvent.time}).`
+ );
+
+ if (willNavigateEvent) {
+ // If we switched to a new target, this target will be different from currentTargetFront.
+ // This only happen if we navigate to another process or if server target switching is enabled.
+ is(
+ willNavigateEvent.targetFront,
+ targetBeforeNavigation,
+ "will-navigate target was the one before the navigation"
+ );
+ }
+ is(
+ loadingEvent.targetFront,
+ expectedTargetFront,
+ "loading target is the expected one"
+ );
+ is(
+ interactiveEvent.targetFront,
+ expectedTargetFront,
+ "interactive target is the expected one"
+ );
+ is(
+ completeEvent.targetFront,
+ expectedTargetFront,
+ "complete target is the expected one"
+ );
+
+ is(
+ completeEvent.hasNativeConsoleAPI,
+ true,
+ "None of the tests (except the dedicated one) overload the console object"
+ );
+}
+
+class ResourceListener {
+ _listeners = new Map();
+
+ dispatch(resources) {
+ for (const resource of resources) {
+ const resolve = this._listeners.get(resource.name);
+ if (resolve) {
+ resolve(resource);
+ this._listeners.delete(resource.name);
+ }
+ }
+ }
+
+ once(resourceName) {
+ return new Promise(r => this._listeners.set(resourceName, r));
+ }
+}
diff --git a/devtools/shared/commands/resource/tests/browser_resources_error_messages.js b/devtools/shared/commands/resource/tests/browser_resources_error_messages.js
new file mode 100644
index 0000000000..6f94266e4c
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/browser_resources_error_messages.js
@@ -0,0 +1,877 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the ResourceCommand API around ERROR_MESSAGE
+// Reproduces assertions from devtools/shared/webconsole/test/chrome/test_page_errors.html
+
+// Create a simple server so we have a nice sourceName in the resources packets.
+const httpServer = createTestHTTPServer();
+httpServer.registerPathHandler(`/test_page_errors.html`, (req, res) => {
+ res.setStatusLine(req.httpVersion, 200, "OK");
+ res.write(`<!DOCTYPE html><meta charset=utf8>Test Error Messages`);
+});
+
+const TEST_URI = `http://localhost:${httpServer.identity.primaryPort}/test_page_errors.html`;
+
+add_task(async function () {
+ // Disable the preloaded process as it creates processes intermittently
+ // which forces the emission of RDP requests we aren't correctly waiting for.
+ await pushPref("dom.ipc.processPrelaunch.enabled", false);
+
+ await testErrorMessagesResources();
+ await testErrorMessagesResourcesWithIgnoreExistingResources();
+});
+
+async function testErrorMessagesResources() {
+ // Open a test tab
+ const tab = await addTab(TEST_URI);
+
+ const { client, resourceCommand, targetCommand } = await initResourceCommand(
+ tab
+ );
+
+ const receivedMessages = [];
+ // The expected messages are the errors, twice (once for cached messages, once for live messages)
+ const expectedMessages = Array.from(expectedPageErrors.values()).concat(
+ Array.from(expectedPageErrors.values())
+ );
+
+ info(
+ "Log some errors *before* calling ResourceCommand.watchResources in order to assert" +
+ " the behavior of already existing messages."
+ );
+ await triggerErrors(tab);
+
+ let done;
+ const onAllErrorReceived = new Promise(resolve => (done = resolve));
+ const onAvailable = resources => {
+ for (const resource of resources) {
+ const { pageError } = resource;
+
+ is(
+ resource.targetFront,
+ targetCommand.targetFront,
+ "The targetFront property is the expected one"
+ );
+
+ if (!pageError.sourceName.includes("test_page_errors")) {
+ info(`Ignore error from unknown source: "${pageError.sourceName}"`);
+ continue;
+ }
+
+ const index = receivedMessages.length;
+ receivedMessages.push(resource);
+
+ const isAlreadyExistingResource =
+ receivedMessages.length <= expectedPageErrors.size;
+ is(
+ resource.isAlreadyExistingResource,
+ isAlreadyExistingResource,
+ "isAlreadyExistingResource has expected value"
+ );
+
+ info(`checking received page error #${index}: ${pageError.errorMessage}`);
+ ok(pageError, "The resource has a pageError attribute");
+ checkPageErrorResource(pageError, expectedMessages[index]);
+
+ if (receivedMessages.length == expectedMessages.length) {
+ done();
+ }
+ }
+ };
+
+ await resourceCommand.watchResources([resourceCommand.TYPES.ERROR_MESSAGE], {
+ onAvailable,
+ });
+
+ await BrowserTestUtils.waitForCondition(
+ () => receivedMessages.length === expectedPageErrors.size
+ );
+
+ info(
+ "Now log errors *after* the call to ResourceCommand.watchResources and after having" +
+ " received all existing messages"
+ );
+ await triggerErrors(tab);
+
+ info("Waiting for all expected errors to be received");
+ await onAllErrorReceived;
+ ok(true, "All the expected errors were received");
+
+ Services.console.reset();
+ targetCommand.destroy();
+ await client.close();
+}
+
+async function testErrorMessagesResourcesWithIgnoreExistingResources() {
+ info("Test ignoreExistingResources option for ERROR_MESSAGE");
+ const tab = await addTab(TEST_URI);
+
+ const { client, resourceCommand, targetCommand } = await initResourceCommand(
+ tab
+ );
+
+ info(
+ "Check whether onAvailable will not be called with existing error messages"
+ );
+ await triggerErrors(tab);
+
+ const availableResources = [];
+ await resourceCommand.watchResources([resourceCommand.TYPES.ERROR_MESSAGE], {
+ onAvailable: resources => availableResources.push(...resources),
+ ignoreExistingResources: true,
+ });
+ is(
+ availableResources.length,
+ 0,
+ "onAvailable wasn't called for existing error messages"
+ );
+
+ info(
+ "Check whether onAvailable will be called with the future error messages"
+ );
+ await triggerErrors(tab);
+
+ const expectedMessages = Array.from(expectedPageErrors.values());
+ await waitUntil(() => availableResources.length === expectedMessages.length);
+ for (let i = 0; i < expectedMessages.length; i++) {
+ const resource = availableResources[i];
+ const { pageError } = resource;
+ const expected = expectedMessages[i];
+ checkPageErrorResource(pageError, expected);
+ is(
+ resource.isAlreadyExistingResource,
+ false,
+ "isAlreadyExistingResource is set to false for live messages"
+ );
+ }
+
+ Services.console.reset();
+ targetCommand.destroy();
+ await client.close();
+}
+
+/**
+ * Triggers all the errors in the content page.
+ */
+async function triggerErrors(tab) {
+ for (const [expression, expected] of expectedPageErrors.entries()) {
+ if (
+ !expected[noUncaughtException] &&
+ !Services.appinfo.browserTabsRemoteAutostart
+ ) {
+ expectUncaughtException();
+ }
+
+ await ContentTask.spawn(
+ tab.linkedBrowser,
+ expression,
+ function frameScript(expr) {
+ const document = content.document;
+ const scriptEl = document.createElement("script");
+ scriptEl.textContent = expr;
+ document.body.appendChild(scriptEl);
+ }
+ );
+
+ if (expected.isPromiseRejection) {
+ // Wait a bit after an uncaught promise rejection error, as they are not emitted
+ // right away.
+
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(res => setTimeout(res, 10));
+ }
+ }
+}
+
+function checkPageErrorResource(pageErrorResource, expected) {
+ // Let's remove test harness related frames in stacktrace
+ const clonedPageErrorResource = { ...pageErrorResource };
+ if (clonedPageErrorResource.stacktrace) {
+ const index = clonedPageErrorResource.stacktrace.findIndex(frame =>
+ frame.filename.startsWith("resource://testing-common/content-task.js")
+ );
+ if (index > -1) {
+ clonedPageErrorResource.stacktrace =
+ clonedPageErrorResource.stacktrace.slice(0, index);
+ }
+ }
+ checkObject(clonedPageErrorResource, expected);
+}
+
+const noUncaughtException = Symbol();
+const NUMBER_REGEX = /^\d+$/;
+// timeStamp are the result of a number in microsecond divided by 1000.
+// so we can't expect a precise number of decimals, or even if there would
+// be decimals at all.
+const FRACTIONAL_NUMBER_REGEX = /^\d+(\.\d{1,3})?$/;
+
+const mdnUrl = path =>
+ `https://developer.mozilla.org/${path}?utm_source=mozilla&utm_medium=firefox-console-errors&utm_campaign=default`;
+
+const expectedPageErrors = new Map([
+ [
+ "document.doTheImpossible();",
+ {
+ errorMessage: /doTheImpossible/,
+ errorMessageName: "JSMSG_NOT_FUNCTION",
+ sourceName: /test_page_errors/,
+ category: "content javascript",
+ timeStamp: FRACTIONAL_NUMBER_REGEX,
+ error: true,
+ warning: false,
+ info: false,
+ lineText: "",
+ lineNumber: NUMBER_REGEX,
+ columnNumber: NUMBER_REGEX,
+ exceptionDocURL: mdnUrl(
+ "docs/Web/JavaScript/Reference/Errors/Not_a_function"
+ ),
+ innerWindowID: NUMBER_REGEX,
+ private: false,
+ stacktrace: [
+ {
+ filename: /test_page_errors\.html/,
+ lineNumber: 1,
+ columnNumber: 10,
+ functionName: null,
+ },
+ ],
+ notes: null,
+ chromeContext: false,
+ isPromiseRejection: false,
+ isForwardedFromContentProcess: false,
+ },
+ ],
+ [
+ "(42).toString(0);",
+ {
+ errorMessage: /radix/,
+ errorMessageName: "JSMSG_BAD_RADIX",
+ sourceName: /test_page_errors/,
+ category: "content javascript",
+ timeStamp: FRACTIONAL_NUMBER_REGEX,
+ error: true,
+ warning: false,
+ info: false,
+ lineText: "",
+ lineNumber: NUMBER_REGEX,
+ columnNumber: NUMBER_REGEX,
+ exceptionDocURL: mdnUrl("docs/Web/JavaScript/Reference/Errors/Bad_radix"),
+ innerWindowID: NUMBER_REGEX,
+ private: false,
+ stacktrace: [
+ {
+ filename: /test_page_errors\.html/,
+ lineNumber: 1,
+ columnNumber: 6,
+ functionName: null,
+ },
+ ],
+ notes: null,
+ chromeContext: false,
+ isPromiseRejection: false,
+ isForwardedFromContentProcess: false,
+ },
+ ],
+ [
+ "'use strict'; (Object.freeze({name: 'Elsa', score: 157})).score = 0;",
+ {
+ errorMessage: /read.only/,
+ errorMessageName: "JSMSG_READ_ONLY",
+ sourceName: /test_page_errors/,
+ category: "content javascript",
+ timeStamp: FRACTIONAL_NUMBER_REGEX,
+ error: true,
+ warning: false,
+ info: false,
+ lineText: "",
+ lineNumber: NUMBER_REGEX,
+ columnNumber: NUMBER_REGEX,
+ exceptionDocURL: mdnUrl("docs/Web/JavaScript/Reference/Errors/Read-only"),
+ innerWindowID: NUMBER_REGEX,
+ private: false,
+ stacktrace: [
+ {
+ filename: /test_page_errors\.html/,
+ lineNumber: 1,
+ columnNumber: 23,
+ functionName: null,
+ },
+ ],
+ notes: null,
+ chromeContext: false,
+ isPromiseRejection: false,
+ isForwardedFromContentProcess: false,
+ },
+ ],
+ [
+ "([]).length = -1",
+ {
+ errorMessage: /array length/,
+ errorMessageName: "JSMSG_BAD_ARRAY_LENGTH",
+ sourceName: /test_page_errors/,
+ category: "content javascript",
+ timeStamp: FRACTIONAL_NUMBER_REGEX,
+ error: true,
+ warning: false,
+ info: false,
+ lineText: "",
+ lineNumber: NUMBER_REGEX,
+ columnNumber: NUMBER_REGEX,
+ exceptionDocURL: mdnUrl(
+ "docs/Web/JavaScript/Reference/Errors/Invalid_array_length"
+ ),
+ innerWindowID: NUMBER_REGEX,
+ private: false,
+ stacktrace: [
+ {
+ filename: /test_page_errors\.html/,
+ lineNumber: 1,
+ columnNumber: 2,
+ functionName: null,
+ },
+ ],
+ notes: null,
+ chromeContext: false,
+ isPromiseRejection: false,
+ isForwardedFromContentProcess: false,
+ },
+ ],
+ [
+ "'abc'.repeat(-1);",
+ {
+ errorMessage: /repeat count.*non-negative/,
+ errorMessageName: "JSMSG_NEGATIVE_REPETITION_COUNT",
+ sourceName: /test_page_errors/,
+ category: "content javascript",
+ timeStamp: FRACTIONAL_NUMBER_REGEX,
+ error: true,
+ warning: false,
+ info: false,
+ lineText: "",
+ lineNumber: NUMBER_REGEX,
+ columnNumber: NUMBER_REGEX,
+ exceptionDocURL: mdnUrl(
+ "docs/Web/JavaScript/Reference/Errors/Negative_repetition_count"
+ ),
+ innerWindowID: NUMBER_REGEX,
+ private: false,
+ stacktrace: [
+ {
+ filename: "self-hosted",
+ sourceId: null,
+ lineNumber: NUMBER_REGEX,
+ columnNumber: NUMBER_REGEX,
+ functionName: "repeat",
+ },
+ {
+ filename: /test_page_errors\.html/,
+ lineNumber: 1,
+ columnNumber: 7,
+ functionName: null,
+ },
+ ],
+ notes: null,
+ chromeContext: false,
+ isPromiseRejection: false,
+ isForwardedFromContentProcess: false,
+ },
+ ],
+ [
+ "'a'.repeat(2e28);",
+ {
+ errorMessage: /repeat count.*less than infinity/,
+ errorMessageName: "JSMSG_RESULTING_STRING_TOO_LARGE",
+ sourceName: /test_page_errors/,
+ category: "content javascript",
+ timeStamp: FRACTIONAL_NUMBER_REGEX,
+ error: true,
+ warning: false,
+ info: false,
+ lineText: "",
+ lineNumber: NUMBER_REGEX,
+ columnNumber: NUMBER_REGEX,
+ exceptionDocURL: mdnUrl(
+ "docs/Web/JavaScript/Reference/Errors/Resulting_string_too_large"
+ ),
+ innerWindowID: NUMBER_REGEX,
+ private: false,
+ stacktrace: [
+ {
+ filename: "self-hosted",
+ sourceId: null,
+ lineNumber: NUMBER_REGEX,
+ columnNumber: NUMBER_REGEX,
+ functionName: "repeat",
+ },
+ {
+ filename: /test_page_errors\.html/,
+ lineNumber: 1,
+ columnNumber: 5,
+ functionName: null,
+ },
+ ],
+ notes: null,
+ chromeContext: false,
+ isPromiseRejection: false,
+ isForwardedFromContentProcess: false,
+ },
+ ],
+ [
+ "77.1234.toExponential(-1);",
+ {
+ errorMessage: /out of range/,
+ errorMessageName: "JSMSG_PRECISION_RANGE",
+ sourceName: /test_page_errors/,
+ category: "content javascript",
+ timeStamp: FRACTIONAL_NUMBER_REGEX,
+ error: true,
+ warning: false,
+ info: false,
+ lineText: "",
+ lineNumber: NUMBER_REGEX,
+ columnNumber: NUMBER_REGEX,
+ exceptionDocURL: mdnUrl(
+ "docs/Web/JavaScript/Reference/Errors/Precision_range"
+ ),
+ innerWindowID: NUMBER_REGEX,
+ private: false,
+ stacktrace: [
+ {
+ filename: /test_page_errors\.html/,
+ lineNumber: 1,
+ columnNumber: 9,
+ functionName: null,
+ },
+ ],
+ notes: null,
+ chromeContext: false,
+ isPromiseRejection: false,
+ isForwardedFromContentProcess: false,
+ },
+ ],
+ [
+ "function a() { return; 1 + 1; }",
+ {
+ errorMessage: /unreachable code/,
+ errorMessageName: "JSMSG_STMT_AFTER_RETURN",
+ sourceName: /test_page_errors/,
+ category: "content javascript",
+ timeStamp: FRACTIONAL_NUMBER_REGEX,
+ error: false,
+ warning: true,
+ info: false,
+ sourceId: null,
+ lineText: "function a() { return; 1 + 1; }",
+ lineNumber: NUMBER_REGEX,
+ columnNumber: NUMBER_REGEX,
+ exceptionDocURL: mdnUrl(
+ "docs/Web/JavaScript/Reference/Errors/Stmt_after_return"
+ ),
+ innerWindowID: NUMBER_REGEX,
+ private: false,
+ stacktrace: null,
+ notes: null,
+ chromeContext: false,
+ isPromiseRejection: false,
+ isForwardedFromContentProcess: false,
+ },
+ ],
+ [
+ "{let a, a;}",
+ {
+ errorMessage: /redeclaration of/,
+ errorMessageName: "JSMSG_REDECLARED_VAR",
+ sourceName: /test_page_errors/,
+ category: "content javascript",
+ timeStamp: FRACTIONAL_NUMBER_REGEX,
+ error: true,
+ warning: false,
+ info: false,
+ sourceId: null,
+ lineText: "{let a, a;}",
+ lineNumber: NUMBER_REGEX,
+ columnNumber: NUMBER_REGEX,
+ exceptionDocURL: mdnUrl(
+ "docs/Web/JavaScript/Reference/Errors/Redeclared_parameter"
+ ),
+ innerWindowID: NUMBER_REGEX,
+ private: false,
+ stacktrace: [],
+ chromeContext: false,
+ isPromiseRejection: false,
+ isForwardedFromContentProcess: false,
+ notes: [
+ {
+ messageBody: /Previously declared at line/,
+ frame: {
+ source: /test_page_errors/,
+ },
+ },
+ ],
+ },
+ ],
+ [
+ `var error = new TypeError("abc");
+ error.name = "MyError";
+ error.message = "here";
+ throw error`,
+ {
+ errorMessage: /MyError: here/,
+ errorMessageName: "",
+ sourceName: /test_page_errors/,
+ category: "content javascript",
+ timeStamp: FRACTIONAL_NUMBER_REGEX,
+ error: true,
+ warning: false,
+ info: false,
+ lineText: "",
+ lineNumber: NUMBER_REGEX,
+ columnNumber: NUMBER_REGEX,
+ exceptionDocURL: undefined,
+ innerWindowID: NUMBER_REGEX,
+ private: false,
+ stacktrace: [
+ {
+ filename: /test_page_errors\.html/,
+ lineNumber: 1,
+ columnNumber: 13,
+ functionName: null,
+ },
+ ],
+ notes: null,
+ chromeContext: false,
+ isPromiseRejection: false,
+ isForwardedFromContentProcess: false,
+ },
+ ],
+ [
+ "DOMTokenList.prototype.contains.call([])",
+ {
+ errorMessage: /does not implement interface/,
+ errorMessageName: "MSG_METHOD_THIS_DOES_NOT_IMPLEMENT_INTERFACE",
+ sourceName: /test_page_errors/,
+ category: "content javascript",
+ timeStamp: FRACTIONAL_NUMBER_REGEX,
+ error: true,
+ warning: false,
+ info: false,
+ lineText: "",
+ lineNumber: NUMBER_REGEX,
+ columnNumber: NUMBER_REGEX,
+ exceptionDocURL: undefined,
+ innerWindowID: NUMBER_REGEX,
+ private: false,
+ stacktrace: [
+ {
+ filename: /test_page_errors\.html/,
+ lineNumber: 1,
+ columnNumber: 33,
+ functionName: null,
+ },
+ ],
+ notes: null,
+ chromeContext: false,
+ isPromiseRejection: false,
+ isForwardedFromContentProcess: false,
+ },
+ ],
+ [
+ `
+ function promiseThrow() {
+ var error2 = new TypeError("abc");
+ error2.name = "MyPromiseError";
+ error2.message = "here2";
+ return Promise.reject(error2);
+ }
+ promiseThrow()`,
+ {
+ errorMessage: /MyPromiseError: here2/,
+ errorMessageName: "",
+ sourceName: /test_page_errors/,
+ category: "content javascript",
+ timeStamp: FRACTIONAL_NUMBER_REGEX,
+ error: true,
+ warning: false,
+ info: false,
+ lineText: "",
+ lineNumber: NUMBER_REGEX,
+ columnNumber: NUMBER_REGEX,
+ exceptionDocURL: undefined,
+ innerWindowID: NUMBER_REGEX,
+ private: false,
+ stacktrace: [
+ {
+ filename: /test_page_errors\.html/,
+ sourceId: null,
+ lineNumber: 6,
+ columnNumber: 24,
+ functionName: "promiseThrow",
+ },
+ {
+ filename: /test_page_errors\.html/,
+ sourceId: null,
+ lineNumber: 8,
+ columnNumber: 7,
+ functionName: null,
+ },
+ ],
+ notes: null,
+ chromeContext: false,
+ isPromiseRejection: true,
+ isForwardedFromContentProcess: false,
+ [noUncaughtException]: true,
+ },
+ ],
+ [
+ // Error with a cause
+ `var originalError = new TypeError("abc");
+ var error = new Error("something went wrong", { cause: originalError })
+ throw error`,
+ {
+ errorMessage: /Error: something went wrong/,
+ errorMessageName: "",
+ sourceName: /test_page_errors/,
+ category: "content javascript",
+ timeStamp: FRACTIONAL_NUMBER_REGEX,
+ error: true,
+ warning: false,
+ info: false,
+ lineText: "",
+ lineNumber: NUMBER_REGEX,
+ columnNumber: NUMBER_REGEX,
+ innerWindowID: NUMBER_REGEX,
+ private: false,
+ stacktrace: [
+ {
+ filename: /test_page_errors\.html/,
+ lineNumber: 2,
+ columnNumber: 19,
+ functionName: null,
+ },
+ ],
+ exception: {
+ preview: {
+ cause: {
+ class: "TypeError",
+ preview: {
+ message: "abc",
+ },
+ },
+ },
+ },
+ notes: null,
+ chromeContext: false,
+ isPromiseRejection: false,
+ isForwardedFromContentProcess: false,
+ },
+ ],
+ [
+ // Error with a cause chain
+ `var a = new Error("err-a");
+ var b = new Error("err-b", { cause: a });
+ var c = new Error("err-c", { cause: b });
+ var d = new Error("err-d", { cause: c });
+ throw d`,
+ {
+ errorMessage: /Error: err-d/,
+ errorMessageName: "",
+ sourceName: /test_page_errors/,
+ category: "content javascript",
+ timeStamp: FRACTIONAL_NUMBER_REGEX,
+ error: true,
+ warning: false,
+ info: false,
+ lineText: "",
+ lineNumber: NUMBER_REGEX,
+ columnNumber: NUMBER_REGEX,
+ innerWindowID: NUMBER_REGEX,
+ private: false,
+ stacktrace: [
+ {
+ filename: /test_page_errors\.html/,
+ lineNumber: 4,
+ columnNumber: 14,
+ functionName: null,
+ },
+ ],
+ exception: {
+ preview: {
+ cause: {
+ class: "Error",
+ preview: {
+ message: "err-c",
+ cause: {
+ class: "Error",
+ preview: {
+ message: "err-b",
+ cause: {
+ class: "Error",
+ preview: {
+ message: "err-a",
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ notes: null,
+ chromeContext: false,
+ isPromiseRejection: false,
+ isForwardedFromContentProcess: false,
+ },
+ ],
+ [
+ // Error with a null cause
+ `throw new Error("something went wrong", { cause: null })`,
+ {
+ errorMessage: /Error: something went wrong/,
+ errorMessageName: "",
+ sourceName: /test_page_errors/,
+ category: "content javascript",
+ timeStamp: FRACTIONAL_NUMBER_REGEX,
+ error: true,
+ warning: false,
+ info: false,
+ lineText: "",
+ lineNumber: NUMBER_REGEX,
+ columnNumber: NUMBER_REGEX,
+ innerWindowID: NUMBER_REGEX,
+ private: false,
+ stacktrace: [
+ {
+ filename: /test_page_errors\.html/,
+ lineNumber: 1,
+ columnNumber: 7,
+ functionName: null,
+ },
+ ],
+ exception: {
+ preview: {
+ cause: {
+ type: "null",
+ },
+ },
+ },
+ notes: null,
+ chromeContext: false,
+ isPromiseRejection: false,
+ isForwardedFromContentProcess: false,
+ },
+ ],
+ [
+ // Error with an undefined cause
+ `throw new Error("something went wrong", { cause: undefined })`,
+ {
+ errorMessage: /Error: something went wrong/,
+ errorMessageName: "",
+ sourceName: /test_page_errors/,
+ category: "content javascript",
+ timeStamp: FRACTIONAL_NUMBER_REGEX,
+ error: true,
+ warning: false,
+ info: false,
+ lineText: "",
+ lineNumber: NUMBER_REGEX,
+ columnNumber: NUMBER_REGEX,
+ innerWindowID: NUMBER_REGEX,
+ private: false,
+ stacktrace: [
+ {
+ filename: /test_page_errors\.html/,
+ lineNumber: 1,
+ columnNumber: 7,
+ functionName: null,
+ },
+ ],
+ exception: {
+ preview: {
+ cause: {
+ type: "undefined",
+ },
+ },
+ },
+ notes: null,
+ chromeContext: false,
+ isPromiseRejection: false,
+ isForwardedFromContentProcess: false,
+ },
+ ],
+ [
+ // Error with a number cause
+ `throw new Error("something went wrong", { cause: 0 })`,
+ {
+ errorMessage: /Error: something went wrong/,
+ errorMessageName: "",
+ sourceName: /test_page_errors/,
+ category: "content javascript",
+ timeStamp: FRACTIONAL_NUMBER_REGEX,
+ error: true,
+ warning: false,
+ info: false,
+ lineText: "",
+ lineNumber: NUMBER_REGEX,
+ columnNumber: NUMBER_REGEX,
+ innerWindowID: NUMBER_REGEX,
+ private: false,
+ stacktrace: [
+ {
+ filename: /test_page_errors\.html/,
+ lineNumber: 1,
+ columnNumber: 7,
+ functionName: null,
+ },
+ ],
+ exception: {
+ preview: {
+ cause: 0,
+ },
+ },
+ notes: null,
+ chromeContext: false,
+ isPromiseRejection: false,
+ isForwardedFromContentProcess: false,
+ },
+ ],
+ [
+ // Error with a string cause
+ `throw new Error("something went wrong", { cause: "ooops" })`,
+ {
+ errorMessage: /Error: something went wrong/,
+ errorMessageName: "",
+ sourceName: /test_page_errors/,
+ category: "content javascript",
+ timeStamp: FRACTIONAL_NUMBER_REGEX,
+ error: true,
+ warning: false,
+ info: false,
+ lineText: "",
+ lineNumber: NUMBER_REGEX,
+ columnNumber: NUMBER_REGEX,
+ innerWindowID: NUMBER_REGEX,
+ private: false,
+ stacktrace: [
+ {
+ filename: /test_page_errors\.html/,
+ lineNumber: 1,
+ columnNumber: 7,
+ functionName: null,
+ },
+ ],
+ exception: {
+ preview: {
+ cause: "ooops",
+ },
+ },
+ notes: null,
+ chromeContext: false,
+ isPromiseRejection: false,
+ isForwardedFromContentProcess: false,
+ },
+ ],
+]);
diff --git a/devtools/shared/commands/resource/tests/browser_resources_getAllResources.js b/devtools/shared/commands/resource/tests/browser_resources_getAllResources.js
new file mode 100644
index 0000000000..5ddc033663
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/browser_resources_getAllResources.js
@@ -0,0 +1,124 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test getAllResources function of the ResourceCommand.
+
+const TEST_URI = "data:text/html;charset=utf-8,getAllResources test";
+
+add_task(async function () {
+ const tab = await addTab(TEST_URI);
+
+ const { client, resourceCommand, targetCommand } = await initResourceCommand(
+ tab
+ );
+
+ info("Check the resources gotten from getAllResources at initial");
+ is(
+ resourceCommand.getAllResources(resourceCommand.TYPES.CONSOLE_MESSAGE)
+ .length,
+ 0,
+ "There is no resources at initial"
+ );
+
+ info(
+ "Start to watch the available resources in order to compare with resources gotten from getAllResources"
+ );
+ const availableResources = [];
+ const onAvailable = resources => availableResources.push(...resources);
+ await resourceCommand.watchResources(
+ [resourceCommand.TYPES.CONSOLE_MESSAGE],
+ { onAvailable }
+ );
+
+ info("Check the resources after some resources are available");
+ const messages = ["a", "b", "c"];
+ await logMessages(tab.linkedBrowser, messages);
+
+ try {
+ await waitFor(() => availableResources.length === messages.length);
+ } catch (e) {
+ ok(
+ false,
+ `Didn't receive the expected number of resources. Got ${
+ availableResources.length
+ }, expected ${messages.length} - ${availableResources
+ .map(r => r.message.arguments[0])
+ .join(" - ")}`
+ );
+ }
+
+ assertResources(
+ resourceCommand.getAllResources(resourceCommand.TYPES.CONSOLE_MESSAGE),
+ availableResources
+ );
+ assertResources(
+ resourceCommand.getAllResources(resourceCommand.TYPES.STYLESHEET),
+ []
+ );
+
+ info("Check the resources after reloading");
+ await BrowserTestUtils.reloadTab(tab);
+ assertResources(
+ resourceCommand.getAllResources(resourceCommand.TYPES.CONSOLE_MESSAGE),
+ []
+ );
+
+ info("Append some resources again to test unwatching");
+ const newMessages = ["d", "e", "f"];
+ await logMessages(tab.linkedBrowser, messages);
+ try {
+ await waitFor(
+ () =>
+ resourceCommand.getAllResources(resourceCommand.TYPES.CONSOLE_MESSAGE)
+ .length === newMessages.length
+ );
+ } catch (e) {
+ const resources = resourceCommand.getAllResources(
+ resourceCommand.TYPES.CONSOLE_MESSAGE
+ );
+ ok(
+ false,
+ `Didn't receive the expected number of resources. Got ${
+ resources.length
+ }, expected ${messages.length} - ${resources
+ .map(r => r.message.arguments.join(" | "))
+ .join(" - ")}`
+ );
+ }
+
+ info("Check the resources after unwatching");
+ resourceCommand.unwatchResources([resourceCommand.TYPES.CONSOLE_MESSAGE], {
+ onAvailable,
+ });
+ assertResources(
+ resourceCommand.getAllResources(resourceCommand.TYPES.CONSOLE_MESSAGE),
+ []
+ );
+
+ targetCommand.destroy();
+ await client.close();
+});
+
+function assertResources(resources, expectedResources) {
+ is(
+ resources.length,
+ expectedResources.length,
+ "Number of the resources is correct"
+ );
+
+ for (let i = 0; i < resources.length; i++) {
+ const resource = resources[i];
+ const expectedResource = expectedResources[i];
+ ok(resource === expectedResource, `The ${i}th resource is correct`);
+ }
+}
+
+function logMessages(browser, messages) {
+ return SpecialPowers.spawn(browser, [messages], innerMessages => {
+ for (const message of innerMessages) {
+ content.console.log(message);
+ }
+ });
+}
diff --git a/devtools/shared/commands/resource/tests/browser_resources_invalid_api_usage.js b/devtools/shared/commands/resource/tests/browser_resources_invalid_api_usage.js
new file mode 100644
index 0000000000..8a1d809f04
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/browser_resources_invalid_api_usage.js
@@ -0,0 +1,76 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test watch/unwatchResources throw when provided with invalid types.
+
+const TEST_URI = "data:text/html;charset=utf-8,invalid api usage test";
+
+add_task(async function () {
+ const tab = await addTab(TEST_URI);
+
+ const { client, resourceCommand, targetCommand } = await initResourceCommand(
+ tab
+ );
+
+ const onAvailable = function () {};
+
+ await Assert.rejects(
+ resourceCommand.watchResources([null], { onAvailable }),
+ /ResourceCommand\.watchResources invoked with an unknown type/,
+ "watchResources should throw for null type"
+ );
+
+ await Assert.rejects(
+ resourceCommand.watchResources([undefined], { onAvailable }),
+ /ResourceCommand\.watchResources invoked with an unknown type/,
+ "watchResources should throw for undefined type"
+ );
+
+ await Assert.rejects(
+ resourceCommand.watchResources(["NOT_A_RESOURCE"], { onAvailable }),
+ /ResourceCommand\.watchResources invoked with an unknown type/,
+ "watchResources should throw for unknown type"
+ );
+
+ await Assert.rejects(
+ resourceCommand.watchResources(
+ [resourceCommand.TYPES.CONSOLE_MESSAGE, "NOT_A_RESOURCE"],
+ { onAvailable }
+ ),
+ /ResourceCommand\.watchResources invoked with an unknown type/,
+ "watchResources should throw for unknown type mixed with a correct type"
+ );
+
+ await Assert.throws(
+ () => resourceCommand.unwatchResources([null], { onAvailable }),
+ /ResourceCommand\.unwatchResources invoked with an unknown type/,
+ "unwatchResources should throw for null type"
+ );
+
+ await Assert.throws(
+ () => resourceCommand.unwatchResources([undefined], { onAvailable }),
+ /ResourceCommand\.unwatchResources invoked with an unknown type/,
+ "unwatchResources should throw for undefined type"
+ );
+
+ await Assert.throws(
+ () => resourceCommand.unwatchResources(["NOT_A_RESOURCE"], { onAvailable }),
+ /ResourceCommand\.unwatchResources invoked with an unknown type/,
+ "unwatchResources should throw for unknown type"
+ );
+
+ await Assert.throws(
+ () =>
+ resourceCommand.unwatchResources(
+ [resourceCommand.TYPES.CONSOLE_MESSAGE, "NOT_A_RESOURCE"],
+ { onAvailable }
+ ),
+ /ResourceCommand\.unwatchResources invoked with an unknown type/,
+ "unwatchResources should throw for unknown type mixed with a correct type"
+ );
+
+ targetCommand.destroy();
+ await client.close();
+});
diff --git a/devtools/shared/commands/resource/tests/browser_resources_last_private_context_exit.js b/devtools/shared/commands/resource/tests/browser_resources_last_private_context_exit.js
new file mode 100644
index 0000000000..1e2d894be3
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/browser_resources_last_private_context_exit.js
@@ -0,0 +1,94 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Verify that LAST_PRIVATE_CONTEXT_EXIT fires when closing the last opened private window
+
+"use strict";
+
+const NON_PRIVATE_TEST_URI =
+ "data:text/html;charset=utf8,<!DOCTYPE html>Not private";
+const PRIVATE_TEST_URI = `data:text/html;charset=utf8,<!DOCTYPE html>Test in private windows`;
+
+add_task(async function () {
+ await pushPref("devtools.browsertoolbox.scope", "everything");
+ const { commands } = await initMultiProcessResourceCommand();
+ const { resourceCommand } = commands;
+
+ const availableResources = [];
+ await resourceCommand.watchResources(
+ [resourceCommand.TYPES.LAST_PRIVATE_CONTEXT_EXIT],
+ {
+ onAvailable(resources) {
+ availableResources.push(resources);
+ },
+ }
+ );
+ is(
+ availableResources.length,
+ 0,
+ "We do not get any LAST_PRIVATE_CONTEXT_EXIT after initialization"
+ );
+
+ await addTab(NON_PRIVATE_TEST_URI);
+
+ info("Open a new private window and select the new tab opened in it");
+ const privateWindow = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+ ok(PrivateBrowsingUtils.isWindowPrivate(privateWindow), "window is private");
+ const privateBrowser = privateWindow.gBrowser;
+ privateBrowser.selectedTab = BrowserTestUtils.addTab(
+ privateBrowser,
+ PRIVATE_TEST_URI
+ );
+
+ info("private tab opened");
+ ok(
+ PrivateBrowsingUtils.isBrowserPrivate(privateBrowser.selectedBrowser),
+ "tab window is private"
+ );
+
+ info("Open a second tab in the private window");
+ await addTab(PRIVATE_TEST_URI, { window: privateWindow });
+
+ // Let a chance to an unexpected async event to be fired
+ await wait(1000);
+
+ is(
+ availableResources.length,
+ 0,
+ "We do not get any LAST_PRIVATE_CONTEXT_EXIT when opening a private window"
+ );
+
+ info("Open a second private browsing window");
+ const secondPrivateWindow = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+
+ info("Close the second private window");
+ secondPrivateWindow.BrowserTryToCloseWindow();
+
+ // Let a chance to an unexpected async event to be fired
+ await wait(1000);
+
+ is(
+ availableResources.length,
+ 0,
+ "We do not get any LAST_PRIVATE_CONTEXT_EXIT when closing the second private window only"
+ );
+
+ info(
+ "close the private window and check if LAST_PRIVATE_CONTEXT_EXIT resource is sent"
+ );
+ privateWindow.BrowserTryToCloseWindow();
+
+ info("Wait for LAST_PRIVATE_CONTEXT_EXIT");
+ await waitFor(() => availableResources.length == 1);
+ is(
+ availableResources.length,
+ 1,
+ "We get one LAST_PRIVATE_CONTEXT_EXIT when closing the last opened private window"
+ );
+
+ await commands.destroy();
+});
diff --git a/devtools/shared/commands/resource/tests/browser_resources_network_event_stacktraces.js b/devtools/shared/commands/resource/tests/browser_resources_network_event_stacktraces.js
new file mode 100644
index 0000000000..2200fcad9c
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/browser_resources_network_event_stacktraces.js
@@ -0,0 +1,100 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the ResourceCommand API around NETWORK_EVENT_STACKTRACE
+
+const TEST_URI = `${URL_ROOT_SSL}network_document.html`;
+
+const REQUEST_STUB = {
+ code: `await fetch("/request_post_0.html", { method: "POST" });`,
+ expected: {
+ stacktraceAvailable: true,
+ lastFrame: {
+ filename:
+ "https://example.com/browser/devtools/shared/commands/resource/tests/network_document.html",
+ lineNumber: 1,
+ columnNumber: 40,
+ functionName: "triggerRequest",
+ asyncCause: null,
+ },
+ },
+};
+
+add_task(async function () {
+ info("Test network stacktraces events");
+ const tab = await addTab(TEST_URI);
+ const { client, resourceCommand, targetCommand } = await initResourceCommand(
+ tab
+ );
+
+ const networkEvents = new Map();
+ const stackTraces = new Map();
+
+ function onResourceAvailable(resources) {
+ for (const resource of resources) {
+ if (
+ resource.resourceType === resourceCommand.TYPES.NETWORK_EVENT_STACKTRACE
+ ) {
+ ok(
+ !networkEvents.has(resource.resourceId),
+ "The network event does not exist"
+ );
+
+ is(
+ resource.stacktraceAvailable,
+ REQUEST_STUB.expected.stacktraceAvailable,
+ "The stacktrace is available"
+ );
+ is(
+ JSON.stringify(resource.lastFrame),
+ JSON.stringify(REQUEST_STUB.expected.lastFrame),
+ "The last frame of the stacktrace is available"
+ );
+
+ stackTraces.set(resource.resourceId, true);
+ return;
+ }
+
+ if (resource.resourceType === resourceCommand.TYPES.NETWORK_EVENT) {
+ ok(
+ stackTraces.has(resource.stacktraceResourceId),
+ "The stack trace does exists"
+ );
+
+ networkEvents.set(resource.resourceId, true);
+ }
+ }
+ }
+
+ function onResourceUpdated() {}
+
+ await resourceCommand.watchResources(
+ [
+ resourceCommand.TYPES.NETWORK_EVENT_STACKTRACE,
+ resourceCommand.TYPES.NETWORK_EVENT,
+ ],
+ {
+ onAvailable: onResourceAvailable,
+ onUpdated: onResourceUpdated,
+ }
+ );
+
+ await triggerNetworkRequests(tab.linkedBrowser, [REQUEST_STUB.code]);
+
+ resourceCommand.unwatchResources(
+ [
+ resourceCommand.TYPES.NETWORK_EVENT_STACKTRACE,
+ resourceCommand.TYPES.NETWORK_EVENT,
+ ],
+ {
+ onAvailable: onResourceAvailable,
+ onUpdated: onResourceUpdated,
+ }
+ );
+
+ targetCommand.destroy();
+ await client.close();
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/devtools/shared/commands/resource/tests/browser_resources_network_events.js b/devtools/shared/commands/resource/tests/browser_resources_network_events.js
new file mode 100644
index 0000000000..bd84b81e09
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/browser_resources_network_events.js
@@ -0,0 +1,316 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the ResourceCommand API around NETWORK_EVENT
+
+const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js");
+
+// We are borrowing tests from the netmonitor frontend
+const NETMONITOR_TEST_FOLDER =
+ "https://example.com/browser/devtools/client/netmonitor/test/";
+const CSP_URL = `${NETMONITOR_TEST_FOLDER}html_csp-test-page.html`;
+const JS_CSP_URL = `${NETMONITOR_TEST_FOLDER}js_websocket-worker-test.js`;
+const CSS_CSP_URL = `${NETMONITOR_TEST_FOLDER}internal-loaded.css`;
+
+const CSP_BLOCKED_REASON_CODE = 4000;
+
+add_task(async function testContentProcessRequests() {
+ info(`Tests for NETWORK_EVENT resources fired from the content process`);
+
+ const expectedAvailable = [
+ {
+ url: CSP_URL,
+ method: "GET",
+ isNavigationRequest: true,
+ chromeContext: false,
+ },
+ {
+ url: JS_CSP_URL,
+ method: "GET",
+ blockedReason: CSP_BLOCKED_REASON_CODE,
+ isNavigationRequest: false,
+ chromeContext: false,
+ },
+ {
+ url: CSS_CSP_URL,
+ method: "GET",
+ blockedReason: CSP_BLOCKED_REASON_CODE,
+ isNavigationRequest: false,
+ chromeContext: false,
+ },
+ ];
+ const expectedUpdated = [
+ {
+ url: CSP_URL,
+ method: "GET",
+ isNavigationRequest: true,
+ chromeContext: false,
+ },
+ {
+ url: JS_CSP_URL,
+ method: "GET",
+ blockedReason: CSP_BLOCKED_REASON_CODE,
+ isNavigationRequest: false,
+ chromeContext: false,
+ },
+ {
+ url: CSS_CSP_URL,
+ method: "GET",
+ blockedReason: CSP_BLOCKED_REASON_CODE,
+ isNavigationRequest: false,
+ chromeContext: false,
+ },
+ ];
+
+ await assertNetworkResourcesOnPage(
+ CSP_URL,
+ expectedAvailable,
+ expectedUpdated
+ );
+});
+
+add_task(async function testCanceledRequest() {
+ info(`Tests for NETWORK_EVENT resources with a canceled request`);
+
+ // Do a XHR request that we cancel against a slow loading page
+ const requestUrl =
+ "https://example.org/document-builder.sjs?delay=1000&html=foo";
+ const html =
+ "<!DOCTYPE html><script>(" +
+ function (xhrUrl) {
+ const xhr = new XMLHttpRequest();
+ xhr.open("GET", xhrUrl);
+ xhr.send(null);
+ } +
+ ")(" +
+ JSON.stringify(requestUrl) +
+ ")</script>";
+ const pageUrl =
+ "https://example.org/document-builder.sjs?html=" + encodeURIComponent(html);
+
+ const expectedAvailable = [
+ {
+ url: pageUrl,
+ method: "GET",
+ isNavigationRequest: true,
+ chromeContext: false,
+ },
+ {
+ url: requestUrl,
+ method: "GET",
+ isNavigationRequest: false,
+ blockedReason: "NS_BINDING_ABORTED",
+ chromeContext: false,
+ },
+ ];
+ const expectedUpdated = [
+ {
+ url: pageUrl,
+ method: "GET",
+ isNavigationRequest: true,
+ chromeContext: false,
+ },
+ {
+ url: requestUrl,
+ method: "GET",
+ isNavigationRequest: false,
+ blockedReason: "NS_BINDING_ABORTED",
+ chromeContext: false,
+ },
+ ];
+
+ // Register a one-off listener to cancel the XHR request
+ // Using XMLHttpRequest's abort() method from the content process
+ // isn't reliable and would introduce many race condition in the test.
+ // Canceling the request via nsIRequest.cancel privileged method,
+ // from the parent process is much more reliable.
+ const observer = {
+ QueryInterface: ChromeUtils.generateQI(["nsIObserver"]),
+ observe(subject, topic, data) {
+ subject = subject.QueryInterface(Ci.nsIHttpChannel);
+ if (subject.URI.spec == requestUrl) {
+ subject.cancel(Cr.NS_BINDING_ABORTED);
+ Services.obs.removeObserver(observer, "http-on-modify-request");
+ }
+ },
+ };
+ Services.obs.addObserver(observer, "http-on-modify-request");
+
+ await assertNetworkResourcesOnPage(
+ pageUrl,
+ expectedAvailable,
+ expectedUpdated
+ );
+});
+
+add_task(async function testIframeRequest() {
+ info(`Tests for NETWORK_EVENT resources with an iframe`);
+
+ // Do a XHR request that we cancel against a slow loading page
+ const iframeRequestUrl =
+ "https://example.org/document-builder.sjs?html=iframe-request";
+ const iframeHtml = `iframe<script>fetch("${iframeRequestUrl}")</script>`;
+ const iframeUrl =
+ "https://example.org/document-builder.sjs?html=" +
+ encodeURIComponent(iframeHtml);
+ const html = `top-document<iframe src="${iframeUrl}"></iframe>`;
+ const pageUrl =
+ "https://example.org/document-builder.sjs?html=" + encodeURIComponent(html);
+
+ const expectedAvailable = [
+ {
+ url: pageUrl,
+ method: "GET",
+ chromeContext: false,
+ isNavigationRequest: true,
+ // The top level navigation request relates to the previous top level target.
+ // Unfortunately, it is hard to test because it is racy.
+ // The target front might be destroyed and `targetFront.url` will be null.
+ // Or not just yet and be equal to "about:blank".
+ },
+ {
+ url: iframeUrl,
+ method: "GET",
+ isNavigationRequest: false,
+ targetFrontUrl: pageUrl,
+ chromeContext: false,
+ },
+ {
+ url: iframeRequestUrl,
+ method: "GET",
+ isNavigationRequest: false,
+ targetFrontUrl: iframeUrl,
+ chromeContext: false,
+ },
+ ];
+ const expectedUpdated = [
+ {
+ url: pageUrl,
+ method: "GET",
+ isNavigationRequest: true,
+ chromeContext: false,
+ },
+ {
+ url: iframeUrl,
+ method: "GET",
+ isNavigationRequest: false,
+ chromeContext: false,
+ },
+ {
+ url: iframeRequestUrl,
+ method: "GET",
+ isNavigationRequest: false,
+ chromeContext: false,
+ },
+ ];
+
+ await assertNetworkResourcesOnPage(
+ pageUrl,
+ expectedAvailable,
+ expectedUpdated
+ );
+});
+
+async function assertNetworkResourcesOnPage(
+ url,
+ expectedAvailable,
+ expectedUpdated
+) {
+ // First open a blank document to avoid spawning any request
+ const tab = await addTab("about:blank");
+
+ const commands = await CommandsFactory.forTab(tab);
+ await commands.targetCommand.startListening();
+ const { resourceCommand } = commands;
+
+ const onAvailable = resources => {
+ for (const resource of resources) {
+ // Immediately assert the resource, as the same resource object
+ // will be notified to onUpdated and so if we assert it later
+ // we will not highlight attributes that aren't set yet from onAvailable.
+ const idx = expectedAvailable.findIndex(e => e.url === resource.url);
+ ok(
+ idx != -1,
+ "Found a matching available notification for: " + resource.url
+ );
+ // Remove the match from the list in case there is many requests with the same url
+ const [expected] = expectedAvailable.splice(idx, 1);
+
+ assertResources(resource, expected);
+ }
+ };
+ const onUpdated = updates => {
+ for (const { resource } of updates) {
+ const idx = expectedUpdated.findIndex(e => e.url === resource.url);
+ ok(
+ idx != -1,
+ "Found a matching updated notification for: " + resource.url
+ );
+ // Remove the match from the list in case there is many requests with the same url
+ const [expected] = expectedUpdated.splice(idx, 1);
+
+ assertResources(resource, expected);
+ }
+ };
+
+ // Start observing for network events before loading the test page
+ await resourceCommand.watchResources([resourceCommand.TYPES.NETWORK_EVENT], {
+ onAvailable,
+ onUpdated,
+ });
+
+ // Load the test page that fires network requests
+ const onLoaded = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+ BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, url);
+ await onLoaded;
+
+ // Make sure we processed all the expected request updates
+ await waitFor(
+ () => !expectedAvailable.length,
+ "Wait for all expected available notifications"
+ );
+ await waitFor(
+ () => !expectedUpdated.length,
+ "Wait for all expected updated notifications"
+ );
+
+ resourceCommand.unwatchResources([resourceCommand.TYPES.NETWORK_EVENT], {
+ onAvailable,
+ onUpdated,
+ });
+
+ await commands.destroy();
+
+ BrowserTestUtils.removeTab(tab);
+}
+
+function assertResources(actual, expected) {
+ is(
+ actual.resourceType,
+ ResourceCommand.TYPES.NETWORK_EVENT,
+ "The resource type is correct"
+ );
+ is(
+ typeof actual.innerWindowId,
+ "number",
+ "All requests have an innerWindowId attribute"
+ );
+ ok(
+ actual.targetFront.isTargetFront,
+ "All requests have a targetFront attribute"
+ );
+
+ for (const name in expected) {
+ if (name == "targetFrontUrl") {
+ is(
+ actual.targetFront.url,
+ expected[name],
+ "The request matches the right target front"
+ );
+ } else {
+ is(actual[name], expected[name], `The '${name}' attribute is correct`);
+ }
+ }
+}
diff --git a/devtools/shared/commands/resource/tests/browser_resources_network_events_cache.js b/devtools/shared/commands/resource/tests/browser_resources_network_events_cache.js
new file mode 100644
index 0000000000..6708ef19e1
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/browser_resources_network_events_cache.js
@@ -0,0 +1,236 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the ResourceCommand API internal cache / ignoreExistingResources around NETWORK_EVENT
+
+const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js");
+
+const EXAMPLE_DOMAIN = "https://example.com/";
+const TEST_URI = `${URL_ROOT_SSL}network_document.html`;
+
+add_task(async function () {
+ info("Test basic NETWORK_EVENT resources against ResourceCommand cache");
+ await testNetworkEventResourcesWithExistingResources();
+ await testNetworkEventResourcesWithoutExistingResources();
+});
+
+async function testNetworkEventResourcesWithExistingResources() {
+ info(`Tests for network event resources with the existing resources`);
+ await testNetworkEventResourcesWithCachedRequest({
+ ignoreExistingResources: false,
+ // 1 available event fired, for the existing resource in the cache.
+ // 1 available event fired, when live request is created.
+ expectedResourcesOnAvailable: {
+ [`${EXAMPLE_DOMAIN}cached_post.html`]: {
+ resourceType: ResourceCommand.TYPES.NETWORK_EVENT,
+ method: "POST",
+ isNavigationRequest: false,
+ },
+ [`${EXAMPLE_DOMAIN}live_get.html`]: {
+ resourceType: ResourceCommand.TYPES.NETWORK_EVENT,
+ method: "GET",
+ isNavigationRequest: false,
+ },
+ },
+ // 1 update events fired, when live request is updated.
+ expectedResourcesOnUpdated: {
+ [`${EXAMPLE_DOMAIN}live_get.html`]: {
+ resourceType: ResourceCommand.TYPES.NETWORK_EVENT,
+ method: "GET",
+ },
+ },
+ });
+}
+
+async function testNetworkEventResourcesWithoutExistingResources() {
+ info(`Tests for network event resources without the existing resources`);
+ await testNetworkEventResourcesWithCachedRequest({
+ ignoreExistingResources: true,
+ // 1 available event fired, when live request is created.
+ expectedResourcesOnAvailable: {
+ [`${EXAMPLE_DOMAIN}live_get.html`]: {
+ resourceType: ResourceCommand.TYPES.NETWORK_EVENT,
+ method: "GET",
+ isNavigationRequest: false,
+ },
+ },
+ // 1 update events fired, when live request is updated.
+ expectedResourcesOnUpdated: {
+ [`${EXAMPLE_DOMAIN}live_get.html`]: {
+ resourceType: ResourceCommand.TYPES.NETWORK_EVENT,
+ method: "GET",
+ },
+ },
+ });
+}
+
+/**
+ * This test helper is slightly complex as we workaround the fact
+ * that the server is not able to record network request done in the past.
+ * Because of that we have to start observer requests via ResourceCommand.watchResources
+ * before doing a request, and, before doing the actual call to watchResources
+ * we want to assert the behavior of.
+ */
+async function testNetworkEventResourcesWithCachedRequest(options) {
+ const tab = await addTab(TEST_URI);
+ const commands = await CommandsFactory.forTab(tab);
+ await commands.targetCommand.startListening();
+
+ const { resourceCommand } = commands;
+
+ info(
+ `Trigger some network requests *before* calling ResourceCommand.watchResources
+ in order to assert the behavior of already existing network events.`
+ );
+
+ // Register a first empty listener in order to ensure populating ResourceCommand
+ // internal cache of NETWORK_EVENT's. We can't retrieved past network requests
+ // when calling server's `watchResources`.
+ let resolveCachedRequestAvailable;
+ const onCachedRequestAvailable = new Promise(
+ r => (resolveCachedRequestAvailable = r)
+ );
+ const onAvailableToPopulateInternalCache = () => {};
+ const onUpdatedToPopulateInternalCache = resolveCachedRequestAvailable;
+ await resourceCommand.watchResources([resourceCommand.TYPES.NETWORK_EVENT], {
+ ignoreExistingResources: true,
+ onAvailable: onAvailableToPopulateInternalCache,
+ onUpdated: onUpdatedToPopulateInternalCache,
+ });
+
+ // We can only trigger the requests once `watchResources` settles,
+ // otherwise we might miss some events and they won't be present in the cache
+ const cachedRequest = `await fetch("/cached_post.html", { method: "POST" });`;
+ await triggerNetworkRequests(tab.linkedBrowser, [cachedRequest]);
+
+ // We have to ensure that ResourceCommand processed the Resource for this first
+ // cached request before calling watchResource a second time and report it.
+ // Wait for the updated notification to avoid receiving it during the next call
+ // to watchResources.
+ await onCachedRequestAvailable;
+
+ const actualResourcesOnAvailable = {};
+ const actualResourcesOnUpdated = {};
+
+ const {
+ expectedResourcesOnAvailable,
+ expectedResourcesOnUpdated,
+
+ ignoreExistingResources,
+ } = options;
+
+ const onAvailable = resources => {
+ for (const resource of resources) {
+ is(
+ resource.resourceType,
+ resourceCommand.TYPES.NETWORK_EVENT,
+ "Received a network event resource"
+ );
+ actualResourcesOnAvailable[resource.url] = resource;
+ }
+ };
+
+ const onUpdated = updates => {
+ for (const { resource } of updates) {
+ is(
+ resource.resourceType,
+ resourceCommand.TYPES.NETWORK_EVENT,
+ "Received a network update event resource"
+ );
+ actualResourcesOnUpdated[resource.url] = resource;
+ }
+ };
+
+ await resourceCommand.watchResources([resourceCommand.TYPES.NETWORK_EVENT], {
+ onAvailable,
+ onUpdated,
+ ignoreExistingResources,
+ });
+
+ info(
+ `Trigger the rest of the requests *after* calling ResourceCommand.watchResources
+ in order to assert the behavior of live network events.`
+ );
+ const liveRequest = `await fetch("/live_get.html", { method: "GET" });`;
+ await triggerNetworkRequests(tab.linkedBrowser, [liveRequest]);
+
+ info("Check the resources on available");
+
+ await waitUntil(
+ () =>
+ Object.keys(actualResourcesOnAvailable).length ==
+ Object.keys(expectedResourcesOnAvailable).length
+ );
+
+ is(
+ Object.keys(actualResourcesOnAvailable).length,
+ Object.keys(expectedResourcesOnAvailable).length,
+ "Got the expected number of network events fired onAvailable"
+ );
+
+ // assert the resources emitted when the network event is created
+ for (const key in expectedResourcesOnAvailable) {
+ const expected = expectedResourcesOnAvailable[key];
+ const actual = actualResourcesOnAvailable[key];
+ assertResources(actual, expected);
+ }
+
+ info("Check the resources on updated");
+
+ await waitUntil(
+ () =>
+ Object.keys(actualResourcesOnUpdated).length ==
+ Object.keys(expectedResourcesOnUpdated).length
+ );
+
+ is(
+ Object.keys(actualResourcesOnUpdated).length,
+ Object.keys(expectedResourcesOnUpdated).length,
+ "Got the expected number of network events fired onUpdated"
+ );
+
+ // assert the resources emitted when the network event is updated
+ for (const key in expectedResourcesOnUpdated) {
+ const expected = expectedResourcesOnUpdated[key];
+ const actual = actualResourcesOnUpdated[key];
+ assertResources(actual, expected);
+ // assert that the resourceId for the the available and updated events match
+ is(
+ actual.resourceId,
+ actualResourcesOnAvailable[key].resourceId,
+ `Available and update resource ids for ${key} are the same`
+ );
+ }
+
+ resourceCommand.unwatchResources([resourceCommand.TYPES.NETWORK_EVENT], {
+ onAvailable,
+ onUpdated,
+ ignoreExistingResources,
+ });
+
+ resourceCommand.unwatchResources([resourceCommand.TYPES.NETWORK_EVENT], {
+ onAvailable: onAvailableToPopulateInternalCache,
+ });
+
+ await commands.destroy();
+
+ BrowserTestUtils.removeTab(tab);
+}
+
+function assertResources(actual, expected) {
+ is(
+ actual.resourceType,
+ expected.resourceType,
+ "The resource type is correct"
+ );
+ is(actual.method, expected.method, "The method is correct");
+ if ("isNavigationRequest" in expected) {
+ is(
+ actual.isNavigationRequest,
+ expected.isNavigationRequest,
+ "The isNavigationRequest attribute is correct"
+ );
+ }
+}
diff --git a/devtools/shared/commands/resource/tests/browser_resources_network_events_navigation.js b/devtools/shared/commands/resource/tests/browser_resources_network_events_navigation.js
new file mode 100644
index 0000000000..44028318a2
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/browser_resources_network_events_navigation.js
@@ -0,0 +1,137 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the ResourceCommand API around NETWORK_EVENT when navigating
+
+const TEST_URI = `${URL_ROOT_SSL}network_document_navigation.html`;
+const JS_URI = TEST_URI.replace(
+ "network_document_navigation.html",
+ "network_navigation.js"
+);
+
+add_task(async () => {
+ const tab = await addTab(TEST_URI);
+ const commands = await CommandsFactory.forTab(tab);
+ await commands.targetCommand.startListening();
+ const { resourceCommand } = commands;
+
+ const receivedResources = [];
+ const onAvailable = resources => {
+ for (const resource of resources) {
+ is(
+ resource.resourceType,
+ resourceCommand.TYPES.NETWORK_EVENT,
+ "Received a network event resource"
+ );
+ receivedResources.push(resource);
+ }
+ };
+ const onUpdated = updates => {
+ for (const { resource } of updates) {
+ is(
+ resource.resourceType,
+ resourceCommand.TYPES.NETWORK_EVENT,
+ "Received a network update event resource"
+ );
+ }
+ };
+
+ await resourceCommand.watchResources([resourceCommand.TYPES.NETWORK_EVENT], {
+ ignoreExistingResources: true,
+ onAvailable,
+ onUpdated,
+ });
+
+ await reloadBrowser();
+
+ await waitFor(() => receivedResources.length == 2);
+
+ const navigationRequest = receivedResources[0];
+ is(
+ navigationRequest.url,
+ TEST_URI,
+ "The first resource is for the navigation request"
+ );
+
+ const jsRequest = receivedResources[1];
+ is(jsRequest.url, JS_URI, "The second resource is for the javascript file");
+
+ async function getResponseContent(networkEvent) {
+ const packet = {
+ to: networkEvent.actor,
+ type: "getResponseContent",
+ };
+ const response = await commands.client.request(packet);
+ return response.content.text;
+ }
+
+ const HTML_CONTENT = await (await fetch(TEST_URI)).text();
+ const JS_CONTENT = await (await fetch(JS_URI)).text();
+
+ const htmlContent = await getResponseContent(navigationRequest);
+ is(htmlContent, HTML_CONTENT);
+ const jsContent = await getResponseContent(jsRequest);
+ is(jsContent, JS_CONTENT);
+
+ await reloadBrowser();
+
+ await waitFor(() => receivedResources.length == 4);
+
+ try {
+ await getResponseContent(navigationRequest);
+ ok(false, "Shouldn't work");
+ } catch (e) {
+ is(
+ e.error,
+ "noSuchActor",
+ "Without persist, we can't fetch previous document network data"
+ );
+ }
+
+ try {
+ await getResponseContent(jsRequest);
+ ok(false, "Shouldn't work");
+ } catch (e) {
+ is(
+ e.error,
+ "noSuchActor",
+ "Without persist, we can't fetch previous document network data"
+ );
+ }
+
+ const navigationRequest2 = receivedResources[2];
+ const jsRequest2 = receivedResources[3];
+ info("But we can fetch data for the last/new document");
+ const htmlContent2 = await getResponseContent(navigationRequest2);
+ is(htmlContent2, HTML_CONTENT);
+ const jsContent2 = await getResponseContent(jsRequest2);
+ is(jsContent2, JS_CONTENT);
+
+ info("Enable persist");
+ const networkParentFront =
+ await commands.watcherFront.getNetworkParentActor();
+ await networkParentFront.setPersist(true);
+
+ await reloadBrowser();
+
+ await waitFor(() => receivedResources.length == 6);
+
+ info("With persist, we can fetch previous document network data");
+ const htmlContent3 = await getResponseContent(navigationRequest2);
+ is(htmlContent3, HTML_CONTENT);
+ const jsContent3 = await getResponseContent(jsRequest2);
+ is(jsContent3, JS_CONTENT);
+
+ await resourceCommand.unwatchResources(
+ [resourceCommand.TYPES.NETWORK_EVENT],
+ {
+ onAvailable,
+ onUpdated,
+ }
+ );
+
+ await commands.destroy();
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/devtools/shared/commands/resource/tests/browser_resources_network_events_parent_process.js b/devtools/shared/commands/resource/tests/browser_resources_network_events_parent_process.js
new file mode 100644
index 0000000000..adf1e1ec52
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/browser_resources_network_events_parent_process.js
@@ -0,0 +1,249 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * !! AFTER MOVING OR RENAMING THIS METHOD, UPDATE `EXPECTED` CONSTANTS BELOW !!
+ */
+const createParentProcessRequests = async () => {
+ info("Do some requests from the parent process");
+ // The line:column for `fetch` should be EXPECTED_REQUEST_LINE_1/COL_1
+ await fetch(FETCH_URI);
+
+ const img = new Image();
+ const onLoad = new Promise(r => img.addEventListener("load", r));
+ // The line:column for `img` below should be EXPECTED_REQUEST_LINE_2/COL_2
+ img.src = IMAGE_URI;
+ await onLoad;
+};
+
+const EXPECTED_METHOD_NAME = "createParentProcessRequests";
+const EXPECTED_REQUEST_LINE_1 = 12;
+const EXPECTED_REQUEST_COL_1 = 9;
+const EXPECTED_REQUEST_LINE_2 = 17;
+const EXPECTED_REQUEST_COL_2 = 3;
+
+// Test the ResourceCommand API around NETWORK_EVENT for the parent process
+
+const FETCH_URI = "https://example.com/document-builder.sjs?html=foo";
+// The img.src request gets cached regardless of `devtools.cache.disabled`.
+// Add a random parameter to the request to bypass the cache.
+const uuid = `${Date.now()}-${Math.random()}`;
+const IMAGE_URI = URL_ROOT_SSL + "test_image.png?" + uuid;
+
+add_task(async function testParentProcessRequests() {
+ // The test expects the main process commands instance to receive resources
+ // for content process requests.
+ await pushPref("devtools.browsertoolbox.scope", "everything");
+
+ const commands = await CommandsFactory.forMainProcess();
+ await commands.targetCommand.startListening();
+ const { resourceCommand } = commands;
+
+ const receivedNetworkEvents = [];
+ const receivedStacktraces = [];
+ const onAvailable = resources => {
+ for (const resource of resources) {
+ if (resource.resourceType == resourceCommand.TYPES.NETWORK_EVENT) {
+ receivedNetworkEvents.push(resource);
+ } else if (
+ resource.resourceType == resourceCommand.TYPES.NETWORK_EVENT_STACKTRACE
+ ) {
+ receivedStacktraces.push(resource);
+ }
+ }
+ };
+ const onUpdated = updates => {
+ for (const { resource } of updates) {
+ is(
+ resource.resourceType,
+ resourceCommand.TYPES.NETWORK_EVENT,
+ "Received a network update event resource"
+ );
+ }
+ };
+
+ await resourceCommand.watchResources(
+ [
+ resourceCommand.TYPES.NETWORK_EVENT,
+ resourceCommand.TYPES.NETWORK_EVENT_STACKTRACE,
+ ],
+ {
+ ignoreExistingResources: true,
+ onAvailable,
+ onUpdated,
+ }
+ );
+
+ await createParentProcessRequests();
+
+ const img2 = new Image();
+ img2.src = IMAGE_URI;
+
+ info("Wait for the network events");
+ await waitFor(() => receivedNetworkEvents.length == 3);
+ info("Wait for the network events stack traces");
+ // Note that we aren't getting any stacktrace for the second cached request
+ await waitFor(() => receivedStacktraces.length == 2);
+
+ info("Assert the fetch request");
+ const fetchRequest = receivedNetworkEvents[0];
+ is(
+ fetchRequest.url,
+ FETCH_URI,
+ "The first resource is for the fetch request"
+ );
+ ok(fetchRequest.chromeContext, "The fetch request is privileged");
+
+ const fetchStacktrace = receivedStacktraces[0].lastFrame;
+ is(receivedStacktraces[0].resourceId, fetchRequest.stacktraceResourceId);
+ is(fetchStacktrace.filename, gTestPath);
+ is(fetchStacktrace.lineNumber, EXPECTED_REQUEST_LINE_1);
+ is(fetchStacktrace.columnNumber, EXPECTED_REQUEST_COL_1);
+ is(fetchStacktrace.functionName, EXPECTED_METHOD_NAME);
+ is(fetchStacktrace.asyncCause, null);
+
+ async function getResponseContent(networkEvent) {
+ const packet = {
+ to: networkEvent.actor,
+ type: "getResponseContent",
+ };
+ const response = await commands.client.request(packet);
+ return response.content.text;
+ }
+
+ const fetchContent = await getResponseContent(fetchRequest);
+ is(fetchContent, "foo");
+
+ info("Assert the first image request");
+ const firstImageRequest = receivedNetworkEvents[1];
+ is(
+ firstImageRequest.url,
+ IMAGE_URI,
+ "The second resource is for the first image request"
+ );
+ ok(!firstImageRequest.fromCache, "The first image request isn't cached");
+ ok(firstImageRequest.chromeContext, "The first image request is privileged");
+
+ const firstImageStacktrace = receivedStacktraces[1].lastFrame;
+ is(receivedStacktraces[1].resourceId, firstImageRequest.stacktraceResourceId);
+ is(firstImageStacktrace.filename, gTestPath);
+ is(firstImageStacktrace.lineNumber, EXPECTED_REQUEST_LINE_2);
+ is(firstImageStacktrace.columnNumber, EXPECTED_REQUEST_COL_2);
+ is(firstImageStacktrace.functionName, EXPECTED_METHOD_NAME);
+ is(firstImageStacktrace.asyncCause, null);
+
+ info("Assert the second image request");
+ const secondImageRequest = receivedNetworkEvents[2];
+ is(
+ secondImageRequest.url,
+ IMAGE_URI,
+ "The third resource is for the second image request"
+ );
+ ok(secondImageRequest.fromCache, "The second image request is cached");
+ ok(
+ secondImageRequest.chromeContext,
+ "The second image request is privileged"
+ );
+
+ info(
+ "Open a content page to ensure we also receive request from content processes"
+ );
+ const pageUrl = "https://example.org/document-builder.sjs?html=foo";
+ const requestUrl = "https://example.org/document-builder.sjs?html=bar";
+ const tab = await addTab(pageUrl);
+
+ await waitFor(() => receivedNetworkEvents.length == 4);
+ const tabRequest = receivedNetworkEvents[3];
+ is(tabRequest.url, pageUrl, "The 4th resource is for the tab request");
+ ok(!tabRequest.chromeContext, "The 4th request is content");
+
+ info(
+ "Also spawn a privileged request from the content process, not bound to any WindowGlobal"
+ );
+ await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [requestUrl],
+ async function (uri) {
+ const { NetUtil } = ChromeUtils.import(
+ "resource://gre/modules/NetUtil.jsm"
+ );
+ const channel = NetUtil.newChannel({
+ uri,
+ loadUsingSystemPrincipal: true,
+ });
+ channel.open();
+ }
+ );
+ await removeTab(tab);
+
+ await waitFor(() => receivedNetworkEvents.length == 5);
+ const privilegedContentRequest = receivedNetworkEvents[4];
+ is(
+ privilegedContentRequest.url,
+ requestUrl,
+ "The 5th resource is for the privileged content process request"
+ );
+ ok(privilegedContentRequest.chromeContext, "The 5th request is privileged");
+
+ info("Now focus only on parent process resources");
+ await pushPref("devtools.browsertoolbox.scope", "parent-process");
+
+ info(
+ "Retrigger the two last requests. The tab document request and a privileged request. Both happening in the tab's content process."
+ );
+ const secondTab = await addTab(pageUrl);
+ await SpecialPowers.spawn(
+ secondTab.linkedBrowser,
+ [requestUrl],
+ async function (uri) {
+ const { NetUtil } = ChromeUtils.import(
+ "resource://gre/modules/NetUtil.jsm"
+ );
+ const channel = NetUtil.newChannel({
+ uri,
+ loadUsingSystemPrincipal: true,
+ });
+ channel.open();
+ }
+ );
+
+ await waitFor(() => receivedNetworkEvents.length == 6);
+
+ // nsIHttpChannel doesn't expose any attribute allowing to identify
+ // privileged requests done in content processes.
+ // Thus, preventing us from filtering them out correctly.
+ // Ideally, we would need some new attribute to know from which (content) process
+ // any channel originates from.
+ info(
+ "For now, we are still notified about the privileged content process request"
+ );
+ const secondPrivilegedContentRequest = receivedNetworkEvents[5];
+ is(
+ secondPrivilegedContentRequest.url,
+ requestUrl,
+ "The 6th resource is for the second privileged content process request"
+ );
+ ok(privilegedContentRequest.chromeContext, "The 6th request is privileged");
+
+ // Let some time to receive the tab request if that's not correctly filtered out
+ await wait(1000);
+ is(
+ receivedNetworkEvents.length,
+ 6,
+ "But we don't receive the request for the tab request"
+ );
+
+ await removeTab(secondTab);
+
+ await resourceCommand.unwatchResources(
+ [resourceCommand.TYPES.NETWORK_EVENT],
+ {
+ onAvailable,
+ onUpdated,
+ }
+ );
+
+ await commands.destroy();
+});
diff --git a/devtools/shared/commands/resource/tests/browser_resources_platform_messages.js b/devtools/shared/commands/resource/tests/browser_resources_platform_messages.js
new file mode 100644
index 0000000000..4e74a97e38
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/browser_resources_platform_messages.js
@@ -0,0 +1,158 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the ResourceCommand API around PLATFORM_MESSAGE
+// Reproduces assertions from: devtools/shared/webconsole/test/chrome/test_nsiconsolemessage.html
+
+add_task(async function () {
+ // Disable the preloaded process as it creates processes intermittently
+ // which forces the emission of RDP requests we aren't correctly waiting for.
+ await pushPref("dom.ipc.processPrelaunch.enabled", false);
+
+ await testPlatformMessagesResources();
+ await testPlatformMessagesResourcesWithIgnoreExistingResources();
+});
+
+async function testPlatformMessagesResources() {
+ const { client, resourceCommand, targetCommand } =
+ await initMultiProcessResourceCommand();
+
+ const cachedMessages = [
+ "This is a cached message",
+ "This is another cached message",
+ ];
+ const liveMessages = [
+ "This is a live message",
+ "This is another live message",
+ ];
+ const expectedMessages = [...cachedMessages, ...liveMessages];
+ const receivedMessages = [];
+
+ info(
+ "Log some messages *before* calling ResourceCommand.watchResources in order to assert the behavior of already existing messages."
+ );
+ Services.console.logStringMessage(expectedMessages[0]);
+ Services.console.logStringMessage(expectedMessages[1]);
+
+ let done;
+ const onAllMessagesReceived = new Promise(resolve => (done = resolve));
+ const onAvailable = resources => {
+ for (const resource of resources) {
+ if (!expectedMessages.includes(resource.message)) {
+ continue;
+ }
+
+ is(
+ resource.targetFront,
+ targetCommand.targetFront,
+ "The targetFront property is the expected one"
+ );
+
+ receivedMessages.push(resource.message);
+ is(
+ resource.message,
+ expectedMessages[receivedMessages.length - 1],
+ `Received the expected «${resource.message}» message, in the expected order`
+ );
+
+ // timeStamp are the result of a number in microsecond divided by 1000.
+ // so we can't expect a precise number of decimals, or even if there would
+ // be decimals at all.
+ ok(
+ resource.timeStamp.toString().match(/^\d+(\.\d{1,3})?$/),
+ `The resource has a timeStamp property ${resource.timeStamp}`
+ );
+
+ const isCachedMessage = receivedMessages.length <= cachedMessages.length;
+ is(
+ resource.isAlreadyExistingResource,
+ isCachedMessage,
+ "isAlreadyExistingResource has the expected value"
+ );
+
+ if (receivedMessages.length == expectedMessages.length) {
+ done();
+ }
+ }
+ };
+
+ await resourceCommand.watchResources(
+ [resourceCommand.TYPES.PLATFORM_MESSAGE],
+ {
+ onAvailable,
+ }
+ );
+
+ info(
+ "Now log messages *after* the call to ResourceCommand.watchResources and after having received all existing messages"
+ );
+ Services.console.logStringMessage(expectedMessages[2]);
+ Services.console.logStringMessage(expectedMessages[3]);
+
+ info("Waiting for all expected messages to be received");
+ await onAllMessagesReceived;
+ ok(true, "All the expected messages were received");
+
+ Services.console.reset();
+ targetCommand.destroy();
+ await client.close();
+}
+
+async function testPlatformMessagesResourcesWithIgnoreExistingResources() {
+ const { client, resourceCommand, targetCommand } =
+ await initMultiProcessResourceCommand();
+
+ info(
+ "Check whether onAvailable will not be called with existing platform messages"
+ );
+ const expectedMessages = ["This is 1st message", "This is 2nd message"];
+ Services.console.logStringMessage(expectedMessages[0]);
+ Services.console.logStringMessage(expectedMessages[1]);
+
+ const availableResources = [];
+ await resourceCommand.watchResources(
+ [resourceCommand.TYPES.PLATFORM_MESSAGE],
+ {
+ onAvailable: resources => {
+ for (const resource of resources) {
+ if (!expectedMessages.includes(resource.message)) {
+ continue;
+ }
+
+ availableResources.push(resource);
+ }
+ },
+ ignoreExistingResources: true,
+ }
+ );
+ is(
+ availableResources.length,
+ 0,
+ "onAvailable wasn't called for existing platform messages"
+ );
+
+ info(
+ "Check whether onAvailable will be called with the future platform messages"
+ );
+ Services.console.logStringMessage(expectedMessages[0]);
+ Services.console.logStringMessage(expectedMessages[1]);
+
+ await waitUntil(() => availableResources.length === expectedMessages.length);
+ for (let i = 0; i < expectedMessages.length; i++) {
+ const resource = availableResources[i];
+ const { message } = resource;
+ const expected = expectedMessages[i];
+ is(message, expected, `Message[${i}] is correct`);
+ is(
+ resource.isAlreadyExistingResource,
+ false,
+ "isAlreadyExistingResource is false since we ignore existing resources"
+ );
+ }
+
+ Services.console.reset();
+ targetCommand.destroy();
+ await client.close();
+}
diff --git a/devtools/shared/commands/resource/tests/browser_resources_reflows.js b/devtools/shared/commands/resource/tests/browser_resources_reflows.js
new file mode 100644
index 0000000000..e4e7b57f13
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/browser_resources_reflows.js
@@ -0,0 +1,111 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the ResourceCommand API for reflows
+
+const {
+ TYPES,
+} = require("resource://devtools/shared/commands/resource/resource-command.js");
+
+add_task(async function () {
+ const tab = await addTab(
+ "https://example.com/document-builder.sjs?html=<h1>Test reflow resources</h1>"
+ );
+
+ const { client, resourceCommand, targetCommand } = await initResourceCommand(
+ tab
+ );
+
+ const resources = [];
+ const onAvailable = _resources => {
+ resources.push(..._resources);
+ };
+ await resourceCommand.watchResources([TYPES.REFLOW], {
+ onAvailable,
+ });
+
+ is(resources.length, 0, "No reflow resource were sent initially");
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], () => {
+ const el = content.document.createElement("div");
+ el.textContent = "1";
+ content.document.body.appendChild(el);
+ });
+
+ await waitFor(() => resources.length === 1);
+ checkReflowResource(resources[0]);
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], () => {
+ const el = content.document.querySelector("div");
+ el.style.display = "inline-grid";
+ });
+
+ await waitFor(() => resources.length === 2);
+ ok(
+ true,
+ "A reflow resource is sent when the display property of an element is modified"
+ );
+ checkReflowResource(resources.at(-1));
+
+ info("Check that adding an iframe does emit a reflow");
+ const iframeBC = await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [],
+ async () => {
+ const el = content.document.createElement("iframe");
+ const onIframeLoaded = new Promise(resolve =>
+ el.addEventListener("load", resolve, { once: true })
+ );
+ content.document.body.appendChild(el);
+ el.src =
+ "https://example.org/document-builder.sjs?html=<h2>remote iframe</h2>";
+ await onIframeLoaded;
+ return el.browsingContext;
+ }
+ );
+
+ await waitFor(() => resources.length === 3);
+ ok(true, "A reflow resource was received when adding a remote iframe");
+ checkReflowResource(resources.at(-1));
+
+ info("Check that we receive reflow resources for the remote iframe");
+ await SpecialPowers.spawn(iframeBC, [], () => {
+ const el = content.document.createElement("section");
+ el.textContent = "remote org iframe";
+ el.style.display = "grid";
+ content.document.body.appendChild(el);
+ });
+
+ await waitFor(() => resources.length === 4);
+ if (isFissionEnabled()) {
+ ok(
+ resources.at(-1).targetFront.url.includes("example.org"),
+ "The reflow resource is linked to the remote target"
+ );
+ }
+ checkReflowResource(resources.at(-1));
+
+ targetCommand.destroy();
+ await client.close();
+});
+
+function checkReflowResource(resource) {
+ is(
+ resource.resourceType,
+ TYPES.REFLOW,
+ "The resource has the expected resourceType"
+ );
+
+ ok(Array.isArray(resource.reflows), "the `reflows` property is an array");
+ for (const reflow of resource.reflows) {
+ is(
+ Number.isFinite(reflow.start),
+ true,
+ "reflow start property is a number"
+ );
+ is(Number.isFinite(reflow.end), true, "reflow end property is a number");
+ ok(reflow.end >= reflow.start, "end is greater than start");
+ }
+}
diff --git a/devtools/shared/commands/resource/tests/browser_resources_root_node.js b/devtools/shared/commands/resource/tests/browser_resources_root_node.js
new file mode 100644
index 0000000000..440d6e6215
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/browser_resources_root_node.js
@@ -0,0 +1,125 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the ResourceCommand API around ROOT_NODE
+
+/**
+ * The original test still asserts some scenarios using several watchRootNode
+ * call sites, which is not something we intend to support at the moment in the
+ * resource command.
+ *
+ * Otherwise this test checks the basic behavior of the resource when reloading
+ * an empty page.
+ */
+add_task(async function () {
+ // Open a test tab
+ const tab = await addTab("data:text/html,Root Node tests");
+
+ const { client, resourceCommand, targetCommand } = await initResourceCommand(
+ tab
+ );
+
+ const browser = gBrowser.selectedBrowser;
+
+ info("Call watchResources([ROOT_NODE], ...)");
+ let onAvailableCounter = 0;
+ const onAvailable = resources => (onAvailableCounter += resources.length);
+ await resourceCommand.watchResources([resourceCommand.TYPES.ROOT_NODE], {
+ onAvailable,
+ });
+
+ info("Wait until onAvailable has been called");
+ await waitUntil(() => onAvailableCounter === 1);
+ is(onAvailableCounter, 1, "onAvailable has been called 1 time");
+
+ info("Reload the selected browser");
+ browser.reload();
+
+ info(
+ "Wait until the watchResources([ROOT_NODE], ...) callback has been called"
+ );
+ await waitUntil(() => onAvailableCounter === 2);
+
+ is(onAvailableCounter, 2, "onAvailable has been called 2 times");
+
+ info("Call unwatchResources([ROOT_NODE], ...) for the onAvailable callback");
+ resourceCommand.unwatchResources([resourceCommand.TYPES.ROOT_NODE], {
+ onAvailable,
+ });
+
+ info("Reload the selected browser");
+ const reloaded = BrowserTestUtils.browserLoaded(browser);
+ browser.reload();
+ await reloaded;
+
+ is(
+ onAvailableCounter,
+ 2,
+ "onAvailable was not called after calling unwatchResources"
+ );
+
+ // Cleanup
+ targetCommand.destroy();
+ await client.close();
+});
+
+/**
+ * Test that the watchRootNode API provides the expected node fronts.
+ */
+add_task(async function testRootNodeFrontIsCorrect() {
+ const tab = await addTab("data:text/html,<div id=div1>");
+
+ const { client, resourceCommand, targetCommand } = await initResourceCommand(
+ tab
+ );
+ const browser = gBrowser.selectedBrowser;
+
+ info("Call watchResources([ROOT_NODE], ...)");
+
+ let rootNodeResolve;
+ let rootNodePromise = new Promise(r => (rootNodeResolve = r));
+ const onAvailable = ([rootNodeFront]) => rootNodeResolve(rootNodeFront);
+ await resourceCommand.watchResources([resourceCommand.TYPES.ROOT_NODE], {
+ onAvailable,
+ });
+
+ info("Wait until onAvailable has been called");
+ const root1 = await rootNodePromise;
+ ok(!!root1, "onAvailable has been called with a valid argument");
+ is(
+ root1.resourceType,
+ resourceCommand.TYPES.ROOT_NODE,
+ "The resource has the expected type"
+ );
+
+ info("Check we can query an expected node under the retrieved root");
+ const div1 = await root1.walkerFront.querySelector(root1, "div");
+ is(div1.getAttribute("id"), "div1", "Correct root node retrieved");
+
+ info("Reload the selected browser");
+ rootNodePromise = new Promise(r => (rootNodeResolve = r));
+ browser.reload();
+
+ const root2 = await rootNodePromise;
+ ok(
+ root1 !== root2,
+ "onAvailable has been called with a different node front after reload"
+ );
+
+ info("Navigate to another URL");
+ rootNodePromise = new Promise(r => (rootNodeResolve = r));
+ BrowserTestUtils.loadURIString(browser, `data:text/html,<div id=div3>`);
+ const root3 = await rootNodePromise;
+ info("Check we can query an expected node under the retrieved root");
+ const div3 = await root3.walkerFront.querySelector(root3, "div");
+ is(div3.getAttribute("id"), "div3", "Correct root node retrieved");
+
+ // Cleanup
+ resourceCommand.unwatchResources([resourceCommand.TYPES.ROOT_NODE], {
+ onAvailable,
+ });
+ targetCommand.destroy();
+ await client.close();
+});
diff --git a/devtools/shared/commands/resource/tests/browser_resources_scope_flag.js b/devtools/shared/commands/resource/tests/browser_resources_scope_flag.js
new file mode 100644
index 0000000000..8537daf161
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/browser_resources_scope_flag.js
@@ -0,0 +1,128 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that the ResourceCommand clears its pending events for resources emitted from
+// target destroyed when devtools.browsertoolbox.scope is updated.
+
+const TEST_URL =
+ "data:text/html;charset=utf-8," + encodeURIComponent(`<div id="test"></div>`);
+
+add_task(async function () {
+ // Do not run this test when both fission and EFT is disabled as it changes
+ // the number of targets
+ if (!isFissionEnabled() && !isEveryFrameTargetEnabled()) {
+ ok(true, "Don't go further is both Fission and EFT are disabled");
+ return;
+ }
+
+ // Disable the preloaded process as it gets created lazily and may interfere
+ // with process count assertions
+ await pushPref("dom.ipc.processPrelaunch.enabled", false);
+ // This preference helps destroying the content process when we close the tab
+ await pushPref("dom.ipc.keepProcessesAlive.web", 1);
+
+ // Start with multiprocess debugging enabled
+ await pushPref("devtools.browsertoolbox.scope", "everything");
+
+ const commands = await CommandsFactory.forMainProcess();
+ const targetCommand = commands.targetCommand;
+ await targetCommand.startListening();
+ const { TYPES } = targetCommand;
+
+ const targets = new Set();
+ const onAvailable = async ({ targetFront }) => {
+ targets.add(targetFront);
+ };
+ const onDestroyed = () => {};
+ await targetCommand.watchTargets({
+ types: [TYPES.PROCESS, TYPES.FRAME],
+ onAvailable,
+ onDestroyed,
+ });
+
+ info("Open a tab in a new content process");
+ const firstTab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ url: TEST_URL,
+ forceNewProcess: true,
+ });
+
+ const newTabInnerWindowId =
+ firstTab.linkedBrowser.browsingContext.currentWindowGlobal.innerWindowId;
+
+ info("Wait for the tab window global target");
+ const windowGlobalTarget = await waitFor(() =>
+ [...targets].find(
+ target =>
+ target.targetType == TYPES.FRAME &&
+ target.innerWindowId == newTabInnerWindowId
+ )
+ );
+
+ let gotTabResource = false;
+ const onResourceAvailable = resources => {
+ for (const resource of resources) {
+ if (resource.targetFront == windowGlobalTarget) {
+ gotTabResource = true;
+
+ if (resource.targetFront.isDestroyed()) {
+ ok(
+ false,
+ "we shouldn't get resources for the target that was destroyed when switching mode"
+ );
+ }
+ }
+ }
+ };
+
+ info("Start listening for resources");
+ await commands.resourceCommand.watchResources(
+ [commands.resourceCommand.TYPES.CONSOLE_MESSAGE],
+ {
+ onAvailable: onResourceAvailable,
+ ignoreExistingResources: true,
+ }
+ );
+
+ // Emit logs every ms to fill up the resourceCommand resource queue (pendingEvents)
+ const intervalId = await SpecialPowers.spawn(
+ firstTab.linkedBrowser,
+ [],
+ () => {
+ let counter = 0;
+ return content.wrappedJSObject.setInterval(() => {
+ counter++;
+ content.wrappedJSObject.console.log("STREAM_" + counter);
+ }, 1);
+ }
+ );
+
+ info("Wait until we get the first resource");
+ await waitFor(() => gotTabResource);
+
+ info("Disable multiprocess debugging");
+ await pushPref("devtools.browsertoolbox.scope", "parent-process");
+
+ info("Wait for the tab target to be destroyed");
+ await waitFor(() => windowGlobalTarget.isDestroyed());
+
+ info("Wait for a bit so any throttled action would have the time to occur");
+ await wait(1000);
+
+ // Stop listening for resources
+ await commands.resourceCommand.unwatchResources(
+ [commands.resourceCommand.TYPES.CONSOLE_MESSAGE],
+ {
+ onAvailable: onResourceAvailable,
+ }
+ );
+ // And stop the interval
+ await SpecialPowers.spawn(firstTab.linkedBrowser, [intervalId], id => {
+ content.wrappedJSObject.clearInterval(id);
+ });
+
+ targetCommand.destroy();
+ await commands.destroy();
+});
diff --git a/devtools/shared/commands/resource/tests/browser_resources_server_sent_events.js b/devtools/shared/commands/resource/tests/browser_resources_server_sent_events.js
new file mode 100644
index 0000000000..dab6c8d8cc
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/browser_resources_server_sent_events.js
@@ -0,0 +1,107 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the ResourceCommand API around SERVER SENT EVENTS.
+
+const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js");
+
+const targets = {
+ TOP_LEVEL_DOCUMENT: "top-level-document",
+ IN_PROCESS_IFRAME: "in-process-frame",
+ OUT_PROCESS_IFRAME: "out-process-frame",
+};
+
+add_task(async function () {
+ info("Testing the top-level document");
+ await testServerSentEventResources(targets.TOP_LEVEL_DOCUMENT);
+ info("Testing the in-process iframe");
+ await testServerSentEventResources(targets.IN_PROCESS_IFRAME);
+ info("Testing the out-of-process iframe");
+ await testServerSentEventResources(targets.OUT_PROCESS_IFRAME);
+});
+
+async function testServerSentEventResources(target) {
+ const tab = await addTab(URL_ROOT_SSL + "sse_frontend.html");
+
+ const { client, resourceCommand, targetCommand } = await initResourceCommand(
+ tab
+ );
+
+ const availableResources = [];
+
+ function onResourceAvailable(resources) {
+ availableResources.push(...resources);
+ }
+
+ await resourceCommand.watchResources(
+ [resourceCommand.TYPES.SERVER_SENT_EVENT],
+ { onAvailable: onResourceAvailable }
+ );
+
+ openConnectionInContext(tab, target);
+
+ info("Check available resources");
+ // We expect only 2 resources
+ await waitUntil(() => availableResources.length === 2);
+
+ info("Check resource details");
+ // To make sure the channel id are the same
+ const httpChannelId = availableResources[0].httpChannelId;
+
+ ok(httpChannelId, "The channel id is set");
+ is(typeof httpChannelId, "number", "The channel id is a number");
+
+ assertResource(availableResources[0], {
+ messageType: "eventReceived",
+ httpChannelId,
+ data: {
+ payload: "Why so serious?",
+ eventName: "message",
+ lastEventId: "",
+ retry: 5000,
+ },
+ });
+
+ assertResource(availableResources[1], {
+ messageType: "eventSourceConnectionClosed",
+ httpChannelId,
+ });
+
+ await resourceCommand.unwatchResources(
+ [resourceCommand.TYPES.SERVER_SENT_EVENT],
+ { onAvailable: onResourceAvailable }
+ );
+
+ await targetCommand.destroy();
+ await client.close();
+ BrowserTestUtils.removeTab(tab);
+}
+
+function assertResource(resource, expected) {
+ is(
+ resource.resourceType,
+ ResourceCommand.TYPES.SERVER_SENT_EVENT,
+ "Resource type is correct"
+ );
+
+ checkObject(resource, expected);
+}
+
+async function openConnectionInContext(tab, target) {
+ let browsingContext = tab.linkedBrowser.browsingContext;
+ if (target !== targets.TOP_LEVEL_DOCUMENT) {
+ browsingContext = await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [target],
+ async _target => {
+ const iframe = content.document.getElementById(_target);
+ return iframe.browsingContext;
+ }
+ );
+ }
+ await SpecialPowers.spawn(browsingContext, [], async () => {
+ await content.wrappedJSObject.openConnection();
+ });
+}
diff --git a/devtools/shared/commands/resource/tests/browser_resources_several_resources.js b/devtools/shared/commands/resource/tests/browser_resources_several_resources.js
new file mode 100644
index 0000000000..c1a151e562
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/browser_resources_several_resources.js
@@ -0,0 +1,111 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Check that the resource command is still properly watching for new targets
+ * after unwatching one resource, if there is still another watched resource.
+ */
+add_task(async function () {
+ // We will create a main process target list here in order to monitor
+ // resources from new tabs as they get created.
+ await pushPref("devtools.browsertoolbox.scope", "everything");
+
+ // Open a test tab
+ const tab = await addTab("data:text/html,Root Node tests");
+
+ const { client, resourceCommand, targetCommand } =
+ await initMultiProcessResourceCommand();
+
+ const { CONSOLE_MESSAGE, ROOT_NODE } = resourceCommand.TYPES;
+
+ // We are only interested in console messages as a resource, the ROOT_NODE one
+ // is here to test the ResourceCommand::unwatchResources API with several resources.
+ const receivedMessages = [];
+ const onAvailable = resources => {
+ for (const resource of resources) {
+ if (resource.resourceType === CONSOLE_MESSAGE) {
+ receivedMessages.push(resource);
+ }
+ }
+ };
+
+ info("Call watchResources([CONSOLE_MESSAGE, ROOT_NODE], ...)");
+ await resourceCommand.watchResources([CONSOLE_MESSAGE, ROOT_NODE], {
+ onAvailable,
+ });
+
+ info("Use console.log in the content page");
+ logInTab(tab, "test from data-url");
+ info(
+ "Wait until onAvailable received the CONSOLE_MESSAGE resource emitted from the data-url tab"
+ );
+ await waitUntil(() =>
+ receivedMessages.find(
+ resource => resource.message.arguments[0] === "test from data-url"
+ )
+ );
+
+ // Check that the resource command captures resources from new targets.
+ info("Open a first tab on the example.com domain");
+ const comTab = await addTab(
+ "https://example.com/document-builder.sjs?html=com"
+ );
+ info("Use console.log in the example.com page");
+ logInTab(comTab, "test-from-example-com");
+ info(
+ "Wait until onAvailable received the CONSOLE_MESSAGE resource emitted from the example.com tab"
+ );
+ await waitUntil(() =>
+ receivedMessages.find(
+ resource => resource.message.arguments[0] === "test-from-example-com"
+ )
+ );
+
+ info("Stop watching ROOT_NODE resources");
+ await resourceCommand.unwatchResources([ROOT_NODE], { onAvailable });
+
+ // Check that messages from new targets are still captured after calling
+ // unwatch for another resource.
+ info("Open a second tab on the example.org domain");
+ const orgTab = await addTab(
+ "https://example.org/document-builder.sjs?html=org"
+ );
+ info("Use console.log in the example.org page");
+ logInTab(orgTab, "test-from-example-org");
+ info(
+ "Wait until onAvailable received the CONSOLE_MESSAGE resource emitted from the example.org tab"
+ );
+ await waitUntil(() =>
+ receivedMessages.find(
+ resource => resource.message.arguments[0] === "test-from-example-org"
+ )
+ );
+
+ info("Stop watching CONSOLE_MESSAGE resources");
+ await resourceCommand.unwatchResources([CONSOLE_MESSAGE], { onAvailable });
+ await logInTab(tab, "test-again");
+
+ // We don't have a specific event to wait for here, so allow some time for
+ // the message to be received.
+ await wait(1000);
+
+ is(
+ receivedMessages.find(
+ resource => resource.message.arguments[0] === "test-again"
+ ),
+ undefined,
+ "The resource command should not watch CONSOLE_MESSAGE anymore"
+ );
+
+ // Cleanup
+ targetCommand.destroy();
+ await client.close();
+});
+
+function logInTab(tab, message) {
+ return ContentTask.spawn(tab.linkedBrowser, message, function (_message) {
+ content.console.log(_message);
+ });
+}
diff --git a/devtools/shared/commands/resource/tests/browser_resources_sources.js b/devtools/shared/commands/resource/tests/browser_resources_sources.js
new file mode 100644
index 0000000000..2495b2bb2a
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/browser_resources_sources.js
@@ -0,0 +1,450 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the ResourceCommand API around SOURCE.
+//
+// We cover each Spidermonkey Debugger Source's `introductionType`:
+// https://searchfox.org/mozilla-central/rev/4c184ca81b28f1ccffbfd08f465709b95bcb4aa1/js/src/doc/Debugger/Debugger.Source.md#172-213
+//
+// And especially cover sources being GC-ed before DevTools are opened
+// which are later recreated by `ThreadActor.resurrectSource`.
+
+const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js");
+
+const TEST_URL = URL_ROOT_SSL + "sources.html";
+
+const TEST_JS_URL = URL_ROOT_SSL + "sources.js";
+const TEST_WORKER_URL = URL_ROOT_SSL + "worker-sources.js";
+const TEST_SW_URL = URL_ROOT_SSL + "service-worker-sources.js";
+
+async function getExpectedResources(ignoreUnresurrectedSources = false) {
+ const htmlRequest = await fetch(TEST_URL);
+ const htmlContent = await htmlRequest.text();
+
+ // First list sources that aren't GC-ed, or that the thread actor is able to resurrect
+ const expectedSources = [
+ {
+ description: "eval",
+ sourceForm: {
+ introductionType: "eval",
+ sourceMapBaseURL: TEST_URL,
+ url: null,
+ isBlackBoxed: false,
+ sourceMapURL: null,
+ extensionName: null,
+ isInlineSource: false,
+ },
+ sourceContent: {
+ contentType: "text/javascript",
+ source: "this.global = function evalFunction() {}",
+ },
+ },
+ {
+ description: "new Function()",
+ sourceForm: {
+ introductionType: "Function",
+ sourceMapBaseURL: TEST_URL,
+ url: null,
+ isBlackBoxed: false,
+ sourceMapURL: null,
+ extensionName: null,
+ isInlineSource: false,
+ },
+ sourceContent: {
+ contentType: "text/javascript",
+ source: "function anonymous(\n) {\nreturn 42;\n}",
+ },
+ },
+ {
+ description: "Event Handler",
+ sourceForm: {
+ introductionType: "eventHandler",
+ sourceMapBaseURL: TEST_URL,
+ url: null,
+ isBlackBoxed: false,
+ sourceMapURL: null,
+ extensionName: null,
+ isInlineSource: false,
+ },
+ sourceContent: {
+ contentType: "text/javascript",
+ source: "console.log('link')",
+ },
+ },
+ {
+ description: "inline JS inserted at runtime",
+ sourceForm: {
+ introductionType: "scriptElement", // This is an injectedScript at SpiderMonkey level, but is translated into scriptElement by SourceActor.form()
+ sourceMapBaseURL: TEST_URL,
+ url: null,
+ isBlackBoxed: false,
+ sourceMapURL: null,
+ extensionName: null,
+ isInlineSource: false,
+ },
+ sourceContent: {
+ contentType: "text/javascript",
+ source: "console.log('inline-script')",
+ },
+ },
+ {
+ description: "inline JS",
+ sourceForm: {
+ introductionType: "scriptElement", // This is an inlineScript at SpiderMonkey level, but is translated into scriptElement by SourceActor.form()
+ sourceMapBaseURL: TEST_URL,
+ url: TEST_URL,
+ isBlackBoxed: false,
+ sourceMapURL: null,
+ extensionName: null,
+ isInlineSource: true,
+ },
+ sourceContent: {
+ contentType: "text/html",
+ source: htmlContent,
+ },
+ },
+ {
+ description: "worker script",
+ sourceForm: {
+ introductionType: undefined,
+ sourceMapBaseURL: TEST_WORKER_URL,
+ url: TEST_WORKER_URL,
+ isBlackBoxed: false,
+ sourceMapURL: null,
+ extensionName: null,
+ isInlineSource: false,
+ },
+ sourceContent: {
+ contentType: "text/javascript",
+ source: "/* eslint-disable */\nfunction workerSource() {}\n",
+ },
+ },
+ {
+ description: "service worker script",
+ sourceForm: {
+ introductionType: undefined,
+ sourceMapBaseURL: TEST_SW_URL,
+ url: TEST_SW_URL,
+ isBlackBoxed: false,
+ sourceMapURL: null,
+ extensionName: null,
+ isInlineSource: false,
+ },
+ sourceContent: {
+ contentType: "text/javascript",
+ source: "/* eslint-disable */\nfunction serviceWorkerSource() {}\n",
+ },
+ },
+ {
+ description: "independent js file",
+ sourceForm: {
+ introductionType: "scriptElement", // This is an srcScript at SpiderMonkey level, but is translated into scriptElement by SourceActor.form()
+ sourceMapBaseURL: TEST_JS_URL,
+ url: TEST_JS_URL,
+ isBlackBoxed: false,
+ sourceMapURL: null,
+ extensionName: null,
+ isInlineSource: false,
+ },
+ sourceContent: {
+ contentType: "text/javascript",
+ source: "/* eslint-disable */\nfunction scriptSource() {}\n",
+ },
+ },
+ ];
+
+ // Now list the sources that could be GC-ed for which the thread actor isn't able to resurrect.
+ // This is the sources that we can't assert when we fetch sources after the page is already loaded.
+ const unresurrectedSources = [
+ {
+ description: "DOM Timer",
+ sourceForm: {
+ introductionType: "domTimer",
+ sourceMapBaseURL: TEST_URL,
+ url: null,
+ isBlackBoxed: false,
+ sourceMapURL: null,
+ extensionName: null,
+ isInlineSource: false,
+ },
+ sourceContent: {
+ contentType: "text/javascript",
+ /* the domTimer is prefixed by many empty lines in order to be positioned at the same line
+ as in the HTML file where setTimeout is called.
+ This is probably done by SourceActor.actualText().
+ So the array size here, should be updated to match the line number of setTimeout call */
+ source: new Array(39).join("\n") + `console.log("timeout")`,
+ },
+ },
+ {
+ description: "javascript URL",
+ sourceForm: {
+ introductionType: "javascriptURL",
+ sourceMapBaseURL: isEveryFrameTargetEnabled()
+ ? "about:blank"
+ : TEST_URL,
+ url: null,
+ isBlackBoxed: false,
+ sourceMapURL: null,
+ extensionName: null,
+ isInlineSource: false,
+ },
+ sourceContent: {
+ contentType: "text/javascript",
+ source: "666",
+ },
+ },
+ {
+ description: "srcdoc attribute on iframes #1",
+ sourceForm: {
+ introductionType: "scriptElement",
+ // We do not assert url/sourceMapBaseURL as it includes the Debugger.Source.id
+ // which is random
+ isBlackBoxed: false,
+ sourceMapURL: null,
+ extensionName: null,
+ isInlineSource: false,
+ },
+ sourceContent: {
+ contentType: "text/javascript",
+ source: "console.log('srcdoc')",
+ },
+ },
+ {
+ description: "srcdoc attribute on iframes #2",
+ sourceForm: {
+ introductionType: "scriptElement",
+ // We do not assert url/sourceMapBaseURL as it includes the Debugger.Source.id
+ // which is random
+ isBlackBoxed: false,
+ sourceMapURL: null,
+ extensionName: null,
+ isInlineSource: false,
+ },
+ sourceContent: {
+ contentType: "text/javascript",
+ source: "console.log('srcdoc 2')",
+ },
+ },
+ ];
+
+ if (ignoreUnresurrectedSources) {
+ return expectedSources;
+ }
+ return expectedSources.concat(unresurrectedSources);
+}
+
+add_task(async function testSourcesOnload() {
+ // Load an blank document first, in order to load the test page only once we already
+ // started watching for sources
+ const tab = await addTab("about:blank");
+
+ const commands = await CommandsFactory.forTab(tab);
+ const { targetCommand, resourceCommand } = commands;
+
+ // Force the target list to cover workers and debug all the targets
+ targetCommand.listenForWorkers = true;
+ targetCommand.listenForServiceWorkers = true;
+ await targetCommand.startListening();
+
+ info("Check already available resources");
+ const availableResources = [];
+ await resourceCommand.watchResources([resourceCommand.TYPES.SOURCE], {
+ onAvailable: resources => availableResources.push(...resources),
+ });
+
+ await BrowserTestUtils.loadURIString(tab.linkedBrowser, TEST_URL);
+
+ // Some sources may be created after the document is done loading (like eventHandler usecase)
+ // so we may be received *after* watchResource resolved
+ const expectedResources = await getExpectedResources();
+ await waitFor(
+ () => availableResources.length >= expectedResources.length,
+ "Got all the sources"
+ );
+
+ await assertResources(availableResources, expectedResources);
+
+ await commands.destroy();
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async () => {
+ // registrationPromise is set by the test page.
+ const registration = await content.wrappedJSObject.registrationPromise;
+ registration.unregister();
+ });
+});
+
+add_task(async function testGarbagedCollectedSources() {
+ info(
+ "Assert SOURCES on an already loaded page with some sources that have been GC-ed"
+ );
+ const tab = await addTab(TEST_URL);
+
+ info("Force some GC to free some sources");
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async () => {
+ Cu.forceGC();
+ Cu.forceCC();
+ });
+
+ const commands = await CommandsFactory.forTab(tab);
+ const { targetCommand, resourceCommand } = commands;
+
+ // Force the target list to cover workers and debug all the targets
+ targetCommand.listenForWorkers = true;
+ targetCommand.listenForServiceWorkers = true;
+ await targetCommand.startListening();
+
+ info("Check already available resources");
+ const availableResources = [];
+ await resourceCommand.watchResources([resourceCommand.TYPES.SOURCE], {
+ onAvailable: resources => availableResources.push(...resources),
+ });
+
+ // Some sources may be created after the document is done loading (like eventHandler usecase)
+ // so we may be received *after* watchResource resolved
+ const expectedResources = await getExpectedResources(true);
+ await waitFor(
+ () => availableResources.length >= expectedResources.length,
+ "Got all the sources"
+ );
+
+ await assertResources(availableResources, expectedResources);
+
+ await commands.destroy();
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async () => {
+ // registrationPromise is set by the test page.
+ const registration = await content.wrappedJSObject.registrationPromise;
+ registration.unregister();
+ });
+});
+
+/**
+ * Assert that evaluating sources for a new global, in the parent process
+ * using the shared system principal will spawn SOURCE resources.
+ *
+ * For this we use a special `commands` which replicate what browser console
+ * and toolbox use.
+ */
+add_task(async function testParentProcessPrivilegedSources() {
+ // Use a custom loader + server + client in order to spawn the server
+ // in a distinct system compartment, so that it can see the system compartment
+ // sandbox we are about to create in this test
+ const client = await CommandsFactory.spawnClientToDebugSystemPrincipal();
+
+ const commands = await CommandsFactory.forMainProcess({ client });
+ await commands.targetCommand.startListening();
+ const { resourceCommand } = commands;
+
+ info("Check already available resources");
+ const availableResources = [];
+ await resourceCommand.watchResources([resourceCommand.TYPES.SOURCE], {
+ onAvailable: resources => availableResources.push(...resources),
+ });
+ ok(
+ !!availableResources.length,
+ "We get many sources reported from a multiprocess command"
+ );
+
+ // Clear the list of sources
+ availableResources.length = 0;
+
+ // Force the creation of a new privileged source
+ const systemPrincipal = Cc["@mozilla.org/systemprincipal;1"].createInstance(
+ Ci.nsIPrincipal
+ );
+ const sandbox = Cu.Sandbox(systemPrincipal);
+ Cu.evalInSandbox("function foo() {}", sandbox, null, "http://foo.com");
+
+ info("Wait for the sandbox source");
+ await waitFor(() => {
+ return availableResources.some(
+ resource => resource.url == "http://foo.com/"
+ );
+ });
+
+ const expectedResources = [
+ {
+ description: "privileged sandbox script",
+ sourceForm: {
+ introductionType: undefined,
+ sourceMapBaseURL: "http://foo.com/",
+ url: "http://foo.com/",
+ isBlackBoxed: false,
+ sourceMapURL: null,
+ extensionName: null,
+ isInlineSource: false,
+ },
+ sourceContent: {
+ contentType: "text/javascript",
+ source: "function foo() {}",
+ },
+ },
+ ];
+ const matchingResource = availableResources.filter(resource =>
+ resource.url.includes("http://foo.com")
+ );
+ await assertResources(matchingResource, expectedResources);
+
+ await commands.destroy();
+});
+
+async function assertResources(resources, expected) {
+ is(
+ resources.length,
+ expected.length,
+ "Length of existing resources is correct at initial"
+ );
+ for (let i = 0; i < resources.length; i++) {
+ await assertResource(resources[i], expected);
+ }
+}
+
+async function assertResource(source, expected) {
+ is(
+ source.resourceType,
+ ResourceCommand.TYPES.SOURCE,
+ "Resource type is correct"
+ );
+
+ const threadFront = await source.targetFront.getFront("thread");
+ // `source` is SourceActor's form()
+ // so try to instantiate the related SourceFront:
+ const sourceFront = threadFront.source(source);
+ // then fetch source content
+ const sourceContent = await sourceFront.source();
+
+ // Order of sources is random, so we have to find the best expected resource.
+ // The only unique attribute is the JS Source text content.
+ const matchingExpected = expected.find(res => {
+ return res.sourceContent.source == sourceContent.source;
+ });
+ ok(
+ matchingExpected,
+ `This source was expected with source content being "${sourceContent.source}"`
+ );
+ info(`Found "#${matchingExpected.description}"`);
+ assertObject(
+ sourceContent,
+ matchingExpected.sourceContent,
+ matchingExpected.description
+ );
+
+ assertObject(
+ source,
+ matchingExpected.sourceForm,
+ matchingExpected.description
+ );
+}
+
+function assertObject(object, expected, description) {
+ for (const field in expected) {
+ is(
+ object[field],
+ expected[field],
+ `The value of ${field} is correct for "#${description}"`
+ );
+ }
+}
diff --git a/devtools/shared/commands/resource/tests/browser_resources_stylesheets.js b/devtools/shared/commands/resource/tests/browser_resources_stylesheets.js
new file mode 100644
index 0000000000..d246155f21
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/browser_resources_stylesheets.js
@@ -0,0 +1,557 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the ResourceCommand API around STYLESHEET.
+
+const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js");
+
+const STYLE_TEST_URL = URL_ROOT_SSL + "style_document.html";
+
+const EXISTING_RESOURCES = [
+ {
+ styleText: "body { color: lime; }",
+ href: null,
+ nodeHref:
+ "https://example.com/browser/devtools/shared/commands/resource/tests/style_document.html",
+ isNew: false,
+ disabled: false,
+ constructed: false,
+ ruleCount: 1,
+ atRules: [],
+ },
+ {
+ styleText: "body { margin: 1px; }",
+ href: "https://example.com/browser/devtools/shared/commands/resource/tests/style_document.css",
+ nodeHref:
+ "https://example.com/browser/devtools/shared/commands/resource/tests/style_document.html",
+ isNew: false,
+ disabled: false,
+ constructed: false,
+ ruleCount: 1,
+ atRules: [],
+ },
+ {
+ styleText: "",
+ href: null,
+ nodeHref: null,
+ isNew: false,
+ disabled: false,
+ constructed: true,
+ ruleCount: 1,
+ atRules: [],
+ },
+ {
+ styleText: "body { background-color: pink; }",
+ href: null,
+ nodeHref:
+ "https://example.org/browser/devtools/shared/commands/resource/tests/style_iframe.html",
+ isNew: false,
+ disabled: false,
+ constructed: false,
+ ruleCount: 1,
+ atRules: [],
+ },
+ {
+ styleText: "body { padding: 1px; }",
+ href: "https://example.org/browser/devtools/shared/commands/resource/tests/style_iframe.css",
+ nodeHref:
+ "https://example.org/browser/devtools/shared/commands/resource/tests/style_iframe.html",
+ isNew: false,
+ disabled: false,
+ constructed: false,
+ ruleCount: 1,
+ atRules: [],
+ },
+];
+
+const ADDITIONAL_INLINE_RESOURCE = {
+ styleText:
+ "@media all { body { color: red; } } @media print { body { color: cyan; } } body { font-size: 10px; }",
+ href: null,
+ nodeHref:
+ "https://example.com/browser/devtools/shared/commands/resource/tests/style_document.html",
+ isNew: false,
+ disabled: false,
+ constructed: false,
+ ruleCount: 3,
+ atRules: [
+ {
+ conditionText: "all",
+ mediaText: "all",
+ matches: true,
+ line: 1,
+ column: 1,
+ },
+ {
+ conditionText: "print",
+ mediaText: "print",
+ matches: false,
+ line: 1,
+ column: 37,
+ },
+ ],
+};
+
+const ADDITIONAL_CONSTRUCTED_RESOURCE = {
+ styleText: "",
+ href: null,
+ nodeHref: null,
+ isNew: false,
+ disabled: false,
+ constructed: true,
+ ruleCount: 2,
+ atRules: [],
+};
+
+const ADDITIONAL_FROM_ACTOR_RESOURCE = {
+ styleText: "body { font-size: 10px; }",
+ href: null,
+ nodeHref:
+ "https://example.com/browser/devtools/shared/commands/resource/tests/style_document.html",
+ isNew: true,
+ disabled: false,
+ constructed: false,
+ ruleCount: 1,
+ atRules: [],
+};
+
+add_task(async function () {
+ await testResourceAvailableFeature();
+ await testResourceUpdateFeature();
+ await testNestedResourceUpdateFeature();
+});
+
+async function testResourceAvailableFeature() {
+ info("Check resource available feature of the ResourceCommand");
+
+ const tab = await addTab(STYLE_TEST_URL);
+ let resourceTimingEntryCounts = await getResourceTimingCount(tab);
+ is(
+ resourceTimingEntryCounts,
+ 2,
+ "Should have two entires for resource timing"
+ );
+
+ const { client, resourceCommand, targetCommand } = await initResourceCommand(
+ tab
+ );
+
+ info("Check whether ResourceCommand gets existing stylesheet");
+ const availableResources = [];
+ await resourceCommand.watchResources([resourceCommand.TYPES.STYLESHEET], {
+ onAvailable: resources => availableResources.push(...resources),
+ });
+
+ is(
+ availableResources.length,
+ EXISTING_RESOURCES.length,
+ "Length of existing resources is correct"
+ );
+ for (let i = 0; i < availableResources.length; i++) {
+ const availableResource = availableResources[i];
+ // We can not expect the resources to always be forwarded in the same order.
+ // See intermittent Bug 1655016.
+ const expectedResource = findMatchingExpectedResource(availableResource);
+ ok(expectedResource, "Found a matching expected resource for the resource");
+ await assertResource(availableResource, expectedResource);
+ }
+
+ resourceTimingEntryCounts = await getResourceTimingCount(tab);
+ is(
+ resourceTimingEntryCounts,
+ 2,
+ "Should still have two entires for resource timing after devtools APIs have been triggered"
+ );
+
+ info("Check whether ResourceCommand gets additonal stylesheet");
+ await ContentTask.spawn(
+ tab.linkedBrowser,
+ ADDITIONAL_INLINE_RESOURCE.styleText,
+ text => {
+ const document = content.document;
+ const stylesheet = document.createElement("style");
+ stylesheet.textContent = text;
+ document.body.appendChild(stylesheet);
+ }
+ );
+ await waitUntil(
+ () => availableResources.length === EXISTING_RESOURCES.length + 1
+ );
+ await assertResource(
+ availableResources[availableResources.length - 1],
+ ADDITIONAL_INLINE_RESOURCE
+ );
+
+ info("Check whether ResourceCommand gets additonal constructed stylesheet");
+ await ContentTask.spawn(tab.linkedBrowser, null, () => {
+ const document = content.document;
+ const s = new content.CSSStyleSheet();
+ // We use the different number of rules to meaningfully differentiate
+ // between constructed stylesheets.
+ s.replaceSync("foo { color: red } bar { color: blue }");
+ // TODO(bug 1751346): wrappedJSObject should be unnecessary.
+ document.wrappedJSObject.adoptedStyleSheets.push(s);
+ });
+ await waitUntil(
+ () => availableResources.length === EXISTING_RESOURCES.length + 2
+ );
+ await assertResource(
+ availableResources[availableResources.length - 1],
+ ADDITIONAL_CONSTRUCTED_RESOURCE
+ );
+
+ info(
+ "Check whether ResourceCommand gets additonal stylesheet which is added by DevTools"
+ );
+ const styleSheetsFront = await targetCommand.targetFront.getFront(
+ "stylesheets"
+ );
+ await styleSheetsFront.addStyleSheet(
+ ADDITIONAL_FROM_ACTOR_RESOURCE.styleText
+ );
+ await waitUntil(
+ () => availableResources.length === EXISTING_RESOURCES.length + 3
+ );
+ await assertResource(
+ availableResources[availableResources.length - 1],
+ ADDITIONAL_FROM_ACTOR_RESOURCE
+ );
+
+ targetCommand.destroy();
+ await client.close();
+}
+
+async function testResourceUpdateFeature() {
+ info("Check resource update feature of the ResourceCommand");
+
+ const tab = await addTab(STYLE_TEST_URL);
+
+ const { client, resourceCommand, targetCommand } = await initResourceCommand(
+ tab
+ );
+
+ info("Setup the watcher");
+ const availableResources = [];
+ const updates = [];
+ await resourceCommand.watchResources([resourceCommand.TYPES.STYLESHEET], {
+ onAvailable: resources => availableResources.push(...resources),
+ onUpdated: newUpdates => updates.push(...newUpdates),
+ });
+ is(
+ availableResources.length,
+ EXISTING_RESOURCES.length,
+ "Length of existing resources is correct"
+ );
+ is(updates.length, 0, "there's no update yet");
+
+ info("Check toggleDisabled function");
+ // Retrieve the stylesheet of the top-level target
+ const resource = availableResources.find(
+ innerResource => innerResource.targetFront.isTopLevel
+ );
+ const styleSheetsFront = await resource.targetFront.getFront("stylesheets");
+ await styleSheetsFront.toggleDisabled(resource.resourceId);
+ await waitUntil(() => updates.length === 1);
+
+ // Check the content of the update object.
+ assertUpdate(updates[0].update, {
+ resourceId: resource.resourceId,
+ updateType: "property-change",
+ });
+ is(
+ updates[0].update.resourceUpdates.disabled,
+ true,
+ "resourceUpdates is correct"
+ );
+
+ // Check whether the cached resource is updated correctly.
+ is(
+ updates[0].resource.disabled,
+ true,
+ "cached resource is updated correctly"
+ );
+
+ // Check whether the actual stylesheet is updated correctly.
+ const styleSheetDisabled = await ContentTask.spawn(
+ tab.linkedBrowser,
+ null,
+ () => {
+ const document = content.document;
+ const stylesheet = document.styleSheets[0];
+ return stylesheet.disabled;
+ }
+ );
+ is(styleSheetDisabled, true, "actual stylesheet was updated correctly");
+
+ info("Check update function");
+ const expectedAtRules = [
+ {
+ conditionText: "screen",
+ mediaText: "screen",
+ matches: true,
+ },
+ {
+ conditionText: "print",
+ mediaText: "print",
+ matches: false,
+ },
+ ];
+
+ const updateCause = "updated-by-test";
+ await styleSheetsFront.update(
+ resource.resourceId,
+ "@media screen { color: red; } @media print { color: green; } body { color: cyan; }",
+ false,
+ updateCause
+ );
+ await waitUntil(() => updates.length === 4);
+
+ assertUpdate(updates[1].update, {
+ resourceId: resource.resourceId,
+ updateType: "property-change",
+ });
+ is(
+ updates[1].update.resourceUpdates.ruleCount,
+ 3,
+ "resourceUpdates is correct"
+ );
+ is(updates[1].resource.ruleCount, 3, "cached resource is updated correctly");
+
+ assertUpdate(updates[2].update, {
+ resourceId: resource.resourceId,
+ updateType: "style-applied",
+ event: {
+ cause: updateCause,
+ },
+ });
+ is(
+ updates[2].update.resourceUpdates,
+ undefined,
+ "resourceUpdates is correct"
+ );
+
+ assertUpdate(updates[3].update, {
+ resourceId: resource.resourceId,
+ updateType: "at-rules-changed",
+ });
+ assertAtRules(updates[3].update.resourceUpdates.atRules, expectedAtRules);
+
+ // Check the actual page.
+ const styleSheetResult = await getStyleSheetResult(tab);
+
+ is(
+ styleSheetResult.ruleCount,
+ 3,
+ "ruleCount of actual stylesheet is updated correctly"
+ );
+ assertAtRules(styleSheetResult.atRules, expectedAtRules);
+
+ targetCommand.destroy();
+ await client.close();
+}
+
+async function testNestedResourceUpdateFeature() {
+ info("Check nested resource update feature of the ResourceCommand");
+
+ const tab = await addTab(STYLE_TEST_URL);
+
+ const { outerWidth: originalWindowWidth, outerHeight: originalWindowHeight } =
+ tab.ownerGlobal;
+
+ registerCleanupFunction(() => {
+ tab.ownerGlobal.resizeTo(originalWindowWidth, originalWindowHeight);
+ });
+
+ const { client, resourceCommand, targetCommand } = await initResourceCommand(
+ tab
+ );
+
+ info("Setup the watcher");
+ const availableResources = [];
+ const updates = [];
+ await resourceCommand.watchResources([resourceCommand.TYPES.STYLESHEET], {
+ onAvailable: resources => availableResources.push(...resources),
+ onUpdated: newUpdates => updates.push(...newUpdates),
+ });
+ is(
+ availableResources.length,
+ EXISTING_RESOURCES.length,
+ "Length of existing resources is correct"
+ );
+
+ info("Apply new media query");
+ // In order to avoid applying the media query (min-height: 400px).
+ if (originalWindowHeight !== 300) {
+ await new Promise(resolve => {
+ tab.ownerGlobal.addEventListener("resize", resolve, { once: true });
+ tab.ownerGlobal.resizeTo(originalWindowWidth, 300);
+ });
+ }
+
+ // Retrieve the stylesheet of the top-level target
+ const resource = availableResources.find(
+ innerResource => innerResource.targetFront.isTopLevel
+ );
+ const styleSheetsFront = await resource.targetFront.getFront("stylesheets");
+ await styleSheetsFront.update(
+ resource.resourceId,
+ "@media (min-height: 400px) { color: red; }",
+ false
+ );
+ await waitUntil(() => updates.length === 3);
+ is(resource.atRules[0].matches, false, "Media query is not matched yet");
+
+ info("Change window size to fire matches-change event");
+ tab.ownerGlobal.resizeTo(originalWindowWidth, 500);
+ await waitUntil(() => updates.length === 4);
+
+ // Check the update content.
+ const targetUpdate = updates[3];
+ assertUpdate(targetUpdate.update, {
+ resourceId: resource.resourceId,
+ updateType: "matches-change",
+ });
+ ok(resource === targetUpdate.resource, "Update object has the same resource");
+
+ is(
+ JSON.stringify(targetUpdate.update.nestedResourceUpdates[0].path),
+ JSON.stringify(["atRules", 0, "matches"]),
+ "path of nestedResourceUpdates is correct"
+ );
+ is(
+ targetUpdate.update.nestedResourceUpdates[0].value,
+ true,
+ "value of nestedResourceUpdates is correct"
+ );
+
+ // Check the resource.
+ const expectedAtRules = [
+ {
+ conditionText: "(min-height: 400px)",
+ mediaText: "(min-height: 400px)",
+ matches: true,
+ },
+ ];
+
+ assertAtRules(targetUpdate.resource.atRules, expectedAtRules);
+
+ // Check the actual page.
+ const styleSheetResult = await getStyleSheetResult(tab);
+ is(
+ styleSheetResult.ruleCount,
+ 1,
+ "ruleCount of actual stylesheet is updated correctly"
+ );
+ assertAtRules(styleSheetResult.atRules, expectedAtRules);
+
+ tab.ownerGlobal.resizeTo(originalWindowWidth, originalWindowHeight);
+
+ targetCommand.destroy();
+ await client.close();
+}
+
+function findMatchingExpectedResource(resource) {
+ return EXISTING_RESOURCES.find(
+ expected =>
+ resource.href === expected.href &&
+ resource.nodeHref === expected.nodeHref &&
+ resource.ruleCount === expected.ruleCount &&
+ resource.constructed == expected.constructed
+ );
+}
+
+async function getStyleSheetResult(tab) {
+ const result = await ContentTask.spawn(tab.linkedBrowser, null, () => {
+ const document = content.document;
+ const stylesheet = document.styleSheets[0];
+ const ruleCount = stylesheet.cssRules.length;
+
+ const atRules = [];
+ for (const rule of stylesheet.cssRules) {
+ if (!rule.media) {
+ continue;
+ }
+
+ let matches = false;
+ try {
+ const mql = content.matchMedia(rule.media.mediaText);
+ matches = mql.matches;
+ } catch (e) {
+ // Ignored
+ }
+
+ atRules.push({
+ mediaText: rule.media.mediaText,
+ conditionText: rule.conditionText,
+ matches,
+ });
+ }
+
+ return { ruleCount, atRules };
+ });
+
+ return result;
+}
+
+function assertAtRules(atRules, expected) {
+ is(atRules.length, expected.length, "Length of the atRules is correct");
+
+ for (let i = 0; i < atRules.length; i++) {
+ is(
+ atRules[i].conditionText,
+ expected[i].conditionText,
+ "conditionText is correct"
+ );
+ is(atRules[i].mediaText, expected[i].mediaText, "mediaText is correct");
+ is(atRules[i].matches, expected[i].matches, "matches is correct");
+
+ if (expected[i].line !== undefined) {
+ is(atRules[i].line, expected[i].line, "line is correct");
+ }
+
+ if (expected[i].column !== undefined) {
+ is(atRules[i].column, expected[i].column, "column is correct");
+ }
+ }
+}
+
+async function assertResource(resource, expected) {
+ is(
+ resource.resourceType,
+ ResourceCommand.TYPES.STYLESHEET,
+ "Resource type is correct"
+ );
+ const styleSheetsFront = await resource.targetFront.getFront("stylesheets");
+ const styleText = (
+ await styleSheetsFront.getText(resource.resourceId)
+ ).str.trim();
+ is(styleText, expected.styleText, "Style text is correct");
+ is(resource.href, expected.href, "href is correct");
+ is(resource.nodeHref, expected.nodeHref, "nodeHref is correct");
+ is(resource.isNew, expected.isNew, "isNew is correct");
+ is(resource.disabled, expected.disabled, "disabled is correct");
+ is(resource.constructed, expected.constructed, "constructed is correct");
+ is(resource.ruleCount, expected.ruleCount, "ruleCount is correct");
+ assertAtRules(resource.atRules, expected.atRules);
+}
+
+function assertUpdate(update, expected) {
+ is(
+ update.resourceType,
+ ResourceCommand.TYPES.STYLESHEET,
+ "Resource type is correct"
+ );
+ is(update.resourceId, expected.resourceId, "resourceId is correct");
+ is(update.updateType, expected.updateType, "updateType is correct");
+ if (expected.event?.cause) {
+ is(update.event?.cause, expected.event.cause, "cause is correct");
+ }
+}
+
+function getResourceTimingCount(tab) {
+ return ContentTask.spawn(tab.linkedBrowser, [], () => {
+ return content.performance.getEntriesByType("resource").length;
+ });
+}
diff --git a/devtools/shared/commands/resource/tests/browser_resources_stylesheets_import.js b/devtools/shared/commands/resource/tests/browser_resources_stylesheets_import.js
new file mode 100644
index 0000000000..a6b06a7613
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/browser_resources_stylesheets_import.js
@@ -0,0 +1,66 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the ResourceCommand API for imported STYLESHEET + iframe.
+
+const styleSheetText = `
+@import "${URL_ROOT_ORG_SSL}/style_document.css";
+body { background-color: tomato; }`;
+
+const IFRAME_URL = `https://example.org/document-builder.sjs?html=${encodeURIComponent(`
+ <style>${styleSheetText}</style>
+ <h1>iframe</h1>
+`)}`;
+
+const TEST_URL = `https://example.org/document-builder.sjs?html=
+ <h1>import stylesheet test</h1>
+ <iframe src="${encodeURIComponent(IFRAME_URL)}"></iframe>`;
+
+add_task(async function () {
+ info("Check resource available feature of the ResourceCommand");
+
+ const tab = await addTab(TEST_URL);
+
+ const { client, resourceCommand, targetCommand } = await initResourceCommand(
+ tab
+ );
+
+ info("Check whether ResourceCommand gets existing stylesheet");
+ const availableResources = [];
+ await resourceCommand.watchResources([resourceCommand.TYPES.STYLESHEET], {
+ onAvailable: resources => availableResources.push(...resources),
+ });
+
+ await waitFor(() => availableResources.length === 2);
+ ok(true, "We're getting the expected stylesheets");
+
+ const styleNodeStyleSheet = availableResources.find(
+ resource => resource.nodeHref
+ );
+ const importedStyleSheet = availableResources.find(
+ resource => resource !== styleNodeStyleSheet
+ );
+
+ is(
+ await getStyleSheetText(styleNodeStyleSheet),
+ styleSheetText.trim(),
+ "Got expected text for the <style> stylesheet"
+ );
+
+ is(
+ await getStyleSheetText(importedStyleSheet),
+ `body { margin: 1px; }`,
+ "Got expected text for the imported stylesheet"
+ );
+
+ targetCommand.destroy();
+ await client.close();
+});
+
+async function getStyleSheetText(resource) {
+ const styleSheetsFront = await resource.targetFront.getFront("stylesheets");
+ const styleText = await styleSheetsFront.getText(resource.resourceId);
+ return styleText.str.trim();
+}
diff --git a/devtools/shared/commands/resource/tests/browser_resources_stylesheets_navigation.js b/devtools/shared/commands/resource/tests/browser_resources_stylesheets_navigation.js
new file mode 100644
index 0000000000..cfe4076ac4
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/browser_resources_stylesheets_navigation.js
@@ -0,0 +1,257 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the ResourceCommand API around STYLESHEET and navigation (reloading, creation of new browsing context, …)
+
+const ORG_DOC_BUILDER = "https://example.org/document-builder.sjs";
+const COM_DOC_BUILDER = "https://example.com/document-builder.sjs";
+
+// Since the order of resources is not guaranteed, we put a number in the title attribute
+// of the <style> elements so we can sort them in a way that makes it easier for us to assert.
+let currentStyleTitle = 0;
+
+const TEST_URI =
+ `${ORG_DOC_BUILDER}?html=1<h1>top-level example.org</h1>` +
+ `<style title="${currentStyleTitle++}">.top-level-org{}</style>` +
+ `<iframe id="same-origin-1" src="${ORG_DOC_BUILDER}?html=<h2>example.org 1</h2><style title=${currentStyleTitle++}>.frame-org-1{}</style>"></iframe>` +
+ `<iframe id="same-origin-2" src="${ORG_DOC_BUILDER}?html=<h2>example.org 2</h2><style title=${currentStyleTitle++}>.frame-org-2{}</style>"></iframe>` +
+ `<iframe id="remote-origin-1" src="${COM_DOC_BUILDER}?html=<h2>example.com 1</h2><style title=${currentStyleTitle++}>.frame-com-1{}</style>"></iframe>` +
+ `<iframe id="remote-origin-2" src="${COM_DOC_BUILDER}?html=<h2>example.com 2</h2><style title=${currentStyleTitle++}>.frame-com-2{}</style>"></iframe>`;
+
+const COOP_HEADERS = "Cross-Origin-Opener-Policy:same-origin";
+const TEST_URI_NEW_BROWSING_CONTEXT =
+ `${ORG_DOC_BUILDER}?headers=${COOP_HEADERS}` +
+ `&html=<h1>top-level example.org</div>` +
+ `<style>.top-level-org-new-bc{}</style>`;
+
+add_task(async function () {
+ info(
+ "Open a new tab and check that styleSheetChangeEventsEnabled is false by default"
+ );
+ const tab = await addTab(TEST_URI);
+
+ is(
+ await getDocumentStyleSheetChangeEventsEnabled(tab.linkedBrowser),
+ false,
+ `styleSheetChangeEventsEnabled is false at the beginning`
+ );
+
+ const { client, resourceCommand, targetCommand } = await initResourceCommand(
+ tab
+ );
+
+ let availableResources = [];
+ await resourceCommand.watchResources([resourceCommand.TYPES.STYLESHEET], {
+ onAvailable: resources => {
+ availableResources.push(...resources);
+ },
+ });
+
+ info("Wait for all the stylesheets resources of main document and iframes");
+ await waitFor(() => availableResources.length === 5);
+ is(availableResources.length, 5, "Retrieved the expected stylesheets");
+
+ // the order of the resources is not guaranteed.
+ sortResourcesByExpectedOrder(availableResources);
+ await assertResource(availableResources[0], {
+ styleText: `.top-level-org{}`,
+ });
+ await assertResource(availableResources[1], {
+ styleText: `.frame-org-1{}`,
+ });
+ await assertResource(availableResources[2], {
+ styleText: `.frame-org-2{}`,
+ });
+ await assertResource(availableResources[3], {
+ styleText: `.frame-com-1{}`,
+ });
+ await assertResource(availableResources[4], {
+ styleText: `.frame-com-2{}`,
+ });
+
+ // clear availableResources so it's easier to test
+ availableResources = [];
+
+ is(
+ await getDocumentStyleSheetChangeEventsEnabled(tab.linkedBrowser),
+ true,
+ `styleSheetChangeEventsEnabled is true after watching stylesheets`
+ );
+
+ info("Navigate a remote frame to a different page");
+ const iframeNewUrl =
+ `https://example.com/document-builder.sjs?` +
+ `html=<h2>example.com new bc</h2><style title=6>.frame-com-new-bc{}</style>`;
+ await SpecialPowers.spawn(tab.linkedBrowser, [iframeNewUrl], url => {
+ const { browsingContext } =
+ content.document.querySelector("#remote-origin-2");
+ return SpecialPowers.spawn(browsingContext, [url], innerUrl => {
+ content.document.location = innerUrl;
+ });
+ });
+ await waitFor(() => availableResources.length == 1);
+ ok(true, "We're notified about the iframe new document stylesheet");
+ await assertResource(availableResources[0], {
+ styleText: `.frame-com-new-bc{}`,
+ });
+ const iframeNewBrowsingContext = await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [],
+ () => content.document.querySelector("#remote-origin-2").browsingContext
+ );
+
+ is(
+ await getDocumentStyleSheetChangeEventsEnabled(iframeNewBrowsingContext),
+ true,
+ `styleSheetChangeEventsEnabled is still true after navigating the iframe`
+ );
+
+ // clear availableResources so it's easier to test
+ availableResources = [];
+
+ info("Check that styleSheetChangeEventsEnabled persist after reloading");
+ await reloadBrowser();
+
+ // ⚠️ When EFT is disabled, we're only getting the stylesheets for the top-level document
+ // and the remote frames; the same-origin iframes stylesheets are missing.
+ const expectedStylesheetResources = isEveryFrameTargetEnabled() ? 5 : 3;
+ info(
+ "Wait until we're notified about all the stylesheets (top-level document + iframe)"
+ );
+ await waitFor(
+ () => availableResources.length === expectedStylesheetResources
+ );
+ is(
+ availableResources.length,
+ expectedStylesheetResources,
+ "Retrieved the expected stylesheets after the page was reloaded"
+ );
+
+ // the order of the resources is not guaranteed.
+ sortResourcesByExpectedOrder(availableResources);
+ await assertResource(availableResources[0], {
+ styleText: `.top-level-org{}`,
+ });
+ if (isEveryFrameTargetEnabled()) {
+ await assertResource(availableResources[1], {
+ styleText: `.frame-org-1{}`,
+ });
+ await assertResource(availableResources[2], {
+ styleText: `.frame-org-2{}`,
+ });
+ await assertResource(availableResources[3], {
+ styleText: `.frame-com-1{}`,
+ });
+ await assertResource(availableResources[4], {
+ styleText: `.frame-com-new-bc{}`,
+ });
+ } else {
+ await assertResource(availableResources[1], {
+ styleText: `.frame-com-1{}`,
+ });
+ await assertResource(availableResources[2], {
+ styleText: `.frame-com-new-bc{}`,
+ });
+ }
+
+ is(
+ await getDocumentStyleSheetChangeEventsEnabled(tab.linkedBrowser),
+ true,
+ `styleSheetChangeEventsEnabled is still true on the top level document after reloading`
+ );
+
+ if (isEveryFrameTargetEnabled()) {
+ const bc = await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [],
+ () => content.document.querySelector("#same-origin-1").browsingContext
+ );
+ is(
+ await getDocumentStyleSheetChangeEventsEnabled(bc),
+ true,
+ `styleSheetChangeEventsEnabled is still true on the iframe after reloading`
+ );
+ }
+
+ // clear availableResources so it's easier to test
+ availableResources = [];
+
+ info(
+ "Check that styleSheetChangeEventsEnabled persist when navigating to a page that creates a new browsing context"
+ );
+ const previousBrowsingContextId = tab.linkedBrowser.browsingContext.id;
+ const onLoaded = BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ await BrowserTestUtils.loadURIString(
+ tab.linkedBrowser,
+ TEST_URI_NEW_BROWSING_CONTEXT
+ );
+ await onLoaded;
+
+ isnot(
+ tab.linkedBrowser.browsingContext.id,
+ previousBrowsingContextId,
+ "A new browsing context was created"
+ );
+
+ info("Wait to get the stylesheet for the new document");
+ await waitFor(() => availableResources.length === 1);
+ ok(true, "We received the stylesheet for the new document");
+ await assertResource(availableResources[0], {
+ styleText: `.top-level-org-new-bc{}`,
+ });
+ is(
+ await getDocumentStyleSheetChangeEventsEnabled(tab.linkedBrowser),
+ true,
+ `styleSheetChangeEventsEnabled is still true after navigating to a new browsing context`
+ );
+
+ targetCommand.destroy();
+ await client.close();
+});
+
+/**
+ * Returns the value of the browser/browsingContext document `styleSheetChangeEventsEnabled`
+ * property.
+ *
+ * @param {Browser|BrowsingContext} browserOrBrowsingContext: The browser element or a
+ * browsing context.
+ * @returns {Promise<Boolean>}
+ */
+function getDocumentStyleSheetChangeEventsEnabled(browserOrBrowsingContext) {
+ return SpecialPowers.spawn(browserOrBrowsingContext, [], () => {
+ return content.document.styleSheetChangeEventsEnabled;
+ });
+}
+
+/**
+ * Sort the passed array of stylesheet resources.
+ *
+ * Since the order of resources are not guaranteed, the <style> elements we use in this test
+ * have a "title" attribute that represent their expected order so we can sort them in
+ * a way that makes it easier for us to assert.
+ *
+ * @param {Array<Object>} resources: Array of stylesheet resources
+ */
+function sortResourcesByExpectedOrder(resources) {
+ resources.sort((a, b) => {
+ return Number(a.title) > Number(b.title);
+ });
+}
+
+/**
+ * Check that the resources have the expected text
+ *
+ * @param {Array<Object>} resources: Array of stylesheet resources
+ * @param {Array<Object>} expected: Array of object of the following shape:
+ * @param {Object} expected[]
+ * @param {Object} expected[].styleText: Expected text content of the stylesheet
+ */
+async function assertResource(resource, expected) {
+ const styleSheetsFront = await resource.targetFront.getFront("stylesheets");
+ const styleText = (
+ await styleSheetsFront.getText(resource.resourceId)
+ ).str.trim();
+ is(styleText, expected.styleText, "Style text is correct");
+}
diff --git a/devtools/shared/commands/resource/tests/browser_resources_stylesheets_nested_iframes.js b/devtools/shared/commands/resource/tests/browser_resources_stylesheets_nested_iframes.js
new file mode 100644
index 0000000000..0b13f75ab9
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/browser_resources_stylesheets_nested_iframes.js
@@ -0,0 +1,34 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that stylesheets are retrieved even if an iframe does not have a content document.
+
+const TEST_URI = URL_ROOT_SSL + "stylesheets-nested-iframes.html";
+
+add_task(async function () {
+ const tab = await addTab(TEST_URI);
+
+ const { client, resourceCommand, targetCommand } = await initResourceCommand(
+ tab
+ );
+
+ info("Check whether ResourceCommand gets existing stylesheet");
+ const availableResources = [];
+ await resourceCommand.watchResources([resourceCommand.TYPES.STYLESHEET], {
+ onAvailable: resources => availableResources.push(...resources),
+ });
+
+ // Bug 285395 limits the number of nested iframes to 10, and we have one stylesheet per document.
+ await waitFor(() => availableResources.length >= 10);
+
+ is(
+ availableResources.length,
+ 10,
+ "Got the expected number of stylesheets, even with documentless iframes"
+ );
+
+ targetCommand.destroy();
+ await client.close();
+});
diff --git a/devtools/shared/commands/resource/tests/browser_resources_target_destroy.js b/devtools/shared/commands/resource/tests/browser_resources_target_destroy.js
new file mode 100644
index 0000000000..fa7813d26e
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/browser_resources_target_destroy.js
@@ -0,0 +1,104 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that the server ResourceCommand are destroyed when the associated target actors
+// are destroyed.
+
+add_task(async function () {
+ const tab = await addTab("data:text/html,Test");
+ const { client, resourceCommand, targetCommand } = await initResourceCommand(
+ tab
+ );
+
+ // Start watching for console messages. We don't care about messages here, only the
+ // registration/destroy mechanism, so we make onAvailable a no-op function.
+ await resourceCommand.watchResources(
+ [resourceCommand.TYPES.CONSOLE_MESSAGE],
+ {
+ onAvailable: () => {},
+ }
+ );
+
+ info(
+ "Spawn a content task in order to be able to manipulate actors and resource watchers directly"
+ );
+ const connectionPrefix = targetCommand.watcherFront.actorID.replace(
+ /watcher\d+$/,
+ ""
+ );
+ await ContentTask.spawn(
+ tab.linkedBrowser,
+ [connectionPrefix],
+ function (_connectionPrefix) {
+ const { require } = ChromeUtils.importESModule(
+ "resource://devtools/shared/loader/Loader.sys.mjs"
+ );
+ const { TargetActorRegistry } = ChromeUtils.importESModule(
+ "resource://devtools/server/actors/targets/target-actor-registry.sys.mjs"
+ );
+ const {
+ getResourceWatcher,
+ TYPES,
+ } = require("resource://devtools/server/actors/resources/index.js");
+
+ // Retrieve the target actor instance and its watcher for console messages
+ const targetActor = TargetActorRegistry.getTargetActors(
+ {
+ type: "browser-element",
+ browserId: content.browsingContext.browserId,
+ },
+ _connectionPrefix
+ ).find(actor => actor.isTopLevelTarget);
+ ok(
+ targetActor,
+ "Got the top level target actor from the content process"
+ );
+ const watcher = getResourceWatcher(targetActor, TYPES.CONSOLE_MESSAGE);
+
+ // Storing the target actor in the global so we can retrieve it later, even if it
+ // was destroyed
+ content._testTargetActor = targetActor;
+
+ is(!!watcher, true, "The console message resource watcher was created");
+ }
+ );
+
+ info("Close the client, which will destroy the target");
+ targetCommand.destroy();
+ await client.close();
+
+ info(
+ "Spawn a content task in order to run some assertions on actors and resource watchers directly"
+ );
+ await ContentTask.spawn(tab.linkedBrowser, [], function () {
+ const { require } = ChromeUtils.importESModule(
+ "resource://devtools/shared/loader/Loader.sys.mjs"
+ );
+ const {
+ getResourceWatcher,
+ TYPES,
+ } = require("resource://devtools/server/actors/resources/index.js");
+
+ ok(
+ content._testTargetActor && !content._testTargetActor.actorID,
+ "The target was destroyed when the client was closed"
+ );
+
+ // Retrieve the console message resource watcher
+ const watcher = getResourceWatcher(
+ content._testTargetActor,
+ TYPES.CONSOLE_MESSAGE
+ );
+
+ is(
+ !!watcher,
+ false,
+ "The console message resource watcher isn't registered anymore after the target was destroyed"
+ );
+
+ // Cleanup work variable
+ delete content._testTargetActor;
+ });
+});
diff --git a/devtools/shared/commands/resource/tests/browser_resources_target_resources_race.js b/devtools/shared/commands/resource/tests/browser_resources_target_resources_race.js
new file mode 100644
index 0000000000..557d14380a
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/browser_resources_target_resources_race.js
@@ -0,0 +1,70 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Test initial target resources are correctly retrieved even when several calls
+ * to watchResources are made simultaneously.
+ *
+ * This checks a race condition which occurred when calling watchResources
+ * simultaneously. This made the "second" call to watchResources miss existing
+ * resources (in case those are emitted from the target instead of the watcher).
+ * See Bug 1663896.
+ */
+add_task(async function () {
+ // Disable the preloaded process as it creates processes intermittently
+ // which forces the emission of RDP requests we aren't correctly waiting for.
+ await pushPref("dom.ipc.processPrelaunch.enabled", false);
+
+ const { client, resourceCommand, targetCommand } =
+ await initMultiProcessResourceCommand();
+
+ const expectedPlatformMessage = "expectedMessage";
+
+ info("Log a message *before* calling ResourceCommand.watchResources");
+ Services.console.logStringMessage(expectedPlatformMessage);
+
+ info("Call watchResources from 2 separate call sites consecutively");
+
+ // Empty onAvailable callback for CSS MESSAGES, we only want to check that
+ // the second resource we watch correctly provides existing resources.
+ const onCssMessageAvailable = resources => {};
+
+ // First call to watchResources.
+ // We do not await on `watchPromise1` here, in order to simulate simultaneous
+ // calls to watchResources (which could come from 2 separate modules in a real
+ // scenario).
+ const initialWatchPromise = resourceCommand.watchResources(
+ [resourceCommand.TYPES.CSS_MESSAGE],
+ {
+ onAvailable: onCssMessageAvailable,
+ }
+ );
+
+ // `waitForNextResource` will trigger another call to `watchResources`.
+ const { onResource: onMessageReceived } =
+ await resourceCommand.waitForNextResource(
+ resourceCommand.TYPES.PLATFORM_MESSAGE,
+ {
+ ignoreExistingResources: false,
+ predicate: r => r.message === expectedPlatformMessage,
+ }
+ );
+
+ info("Waiting for the expected message to be received");
+ await onMessageReceived;
+ ok(true, "All the expected messages were received");
+
+ info("Wait for the other watchResources promise to finish");
+ await initialWatchPromise;
+
+ // Unwatch all resources.
+ resourceCommand.unwatchResources([resourceCommand.TYPES.CSS_MESSAGE], {
+ onAvailable: onCssMessageAvailable,
+ });
+
+ Services.console.reset();
+ targetCommand.destroy();
+ await client.close();
+});
diff --git a/devtools/shared/commands/resource/tests/browser_resources_target_switching.js b/devtools/shared/commands/resource/tests/browser_resources_target_switching.js
new file mode 100644
index 0000000000..fbc928f125
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/browser_resources_target_switching.js
@@ -0,0 +1,91 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the behavior of ResourceCommand when the top level target changes
+
+const TEST_URI =
+ "data:text/html;charset=utf-8,<script>console.log('foo');</script>";
+
+add_task(async function () {
+ const tab = await addTab(TEST_URI);
+
+ const { client, resourceCommand, targetCommand } = await initResourceCommand(
+ tab
+ );
+ const { CONSOLE_MESSAGE, SOURCE } = resourceCommand.TYPES;
+
+ info("Check the resources gotten from getAllResources at initial");
+ is(
+ resourceCommand.getAllResources(CONSOLE_MESSAGE).length,
+ 0,
+ "There is no resources before calling watchResources"
+ );
+
+ info(
+ "Start to watch the available resources in order to compare with resources gotten from getAllResources"
+ );
+ const availableResources = [];
+ const onAvailable = resources => {
+ availableResources.push(...resources);
+ };
+ await resourceCommand.watchResources([CONSOLE_MESSAGE], { onAvailable });
+
+ is(availableResources.length, 1, "Got the page message");
+ is(
+ availableResources[0].message.arguments[0],
+ "foo",
+ "Got the expected page message"
+ );
+
+ // Register another listener before unregistering the console listener
+ // otherwise the resource command stop watching for targets
+ const onSourceAvailable = () => {};
+ await resourceCommand.watchResources([SOURCE], {
+ onAvailable: onSourceAvailable,
+ });
+
+ info(
+ "Unregister the console listener and check that we no longer listen for console messages"
+ );
+ resourceCommand.unwatchResources([CONSOLE_MESSAGE], {
+ onAvailable,
+ });
+
+ let onSwitched = targetCommand.once("switched-target");
+ info("Navigate to another process");
+ BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, "about:robots");
+ await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+ await onSwitched;
+
+ is(
+ availableResources.length,
+ 1,
+ "about:robots doesn't fire any new message, so we should have a new one"
+ );
+
+ info("Navigate back to data: URI");
+ onSwitched = targetCommand.once("switched-target");
+ BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, TEST_URI);
+ await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+ await onSwitched;
+
+ is(
+ availableResources.length,
+ 1,
+ "the data:URI fired a message, but we are no longer listening to it, so no new one should be notified"
+ );
+ is(
+ resourceCommand.getAllResources(CONSOLE_MESSAGE).length,
+ 0,
+ "As we are no longer listening to CONSOLE message, we should not collect any"
+ );
+
+ resourceCommand.unwatchResources([SOURCE], {
+ onAvailable: onSourceAvailable,
+ });
+
+ targetCommand.destroy();
+ await client.close();
+});
diff --git a/devtools/shared/commands/resource/tests/browser_resources_thread_states.js b/devtools/shared/commands/resource/tests/browser_resources_thread_states.js
new file mode 100644
index 0000000000..f915bb14d0
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/browser_resources_thread_states.js
@@ -0,0 +1,557 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the ResourceCommand API around THREAD_STATE
+
+const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js");
+
+const BREAKPOINT_TEST_URL = URL_ROOT_SSL + "breakpoint_document.html";
+const REMOTE_IFRAME_URL =
+ "https://example.org/document-builder.sjs?html=" +
+ encodeURIComponent("<script>debugger;</script>");
+
+add_task(async function () {
+ // Check hitting the "debugger;" statement before and after calling
+ // watchResource(THREAD_TYPES). Both should break. First will
+ // be a cached resource and second will be a live one.
+ await checkBreakpointBeforeWatchResources();
+ await checkBreakpointAfterWatchResources();
+
+ // Check setting a real breakpoint on a given line
+ await checkRealBreakpoint();
+
+ // Check the "pause on exception" setting
+ await checkPauseOnException();
+
+ // Check an edge case where spamming setBreakpoints calls causes issues
+ await checkSetBeforeWatch();
+
+ // Check debugger statement for (remote) iframes
+ await checkDebuggerStatementInIframes();
+});
+
+async function checkBreakpointBeforeWatchResources() {
+ info(
+ "Check whether ResourceCommand gets existing breakpoint, being hit before calling watchResources"
+ );
+
+ const tab = await addTab(BREAKPOINT_TEST_URL);
+
+ const { client, resourceCommand, targetCommand } = await initResourceCommand(
+ tab
+ );
+
+ // Ensure that the target front is initialized early from TargetCommand.onTargetAvailable
+ // By the time `initResourceCommand` resolves, it should already be initialized.
+ info(
+ "Verify that TargetFront's initialized is resolved after having calling attachAndInitThread"
+ );
+ await targetCommand.targetFront.initialized;
+
+ info("Run the 'debugger' statement");
+ // Note that we do not wait for the resolution of spawn as it will be paused
+ ContentTask.spawn(tab.linkedBrowser, null, () => {
+ content.window.wrappedJSObject.runDebuggerStatement();
+ });
+
+ info("Call watchResources");
+ const availableResources = [];
+ await resourceCommand.watchResources([resourceCommand.TYPES.THREAD_STATE], {
+ onAvailable: resources => availableResources.push(...resources),
+ });
+
+ is(
+ availableResources.length,
+ 1,
+ "Got the THREAD_STATE's related to the debugger statement"
+ );
+ const threadState = availableResources.pop();
+
+ assertPausedResource(threadState, {
+ state: "paused",
+ why: {
+ type: "debuggerStatement",
+ },
+ frame: {
+ type: "call",
+ asyncCause: null,
+ state: "on-stack",
+ // this: object actor's form referring to `this` variable
+ displayName: "runDebuggerStatement",
+ // arguments: []
+ where: {
+ line: 17,
+ column: 6,
+ },
+ },
+ });
+
+ const { threadFront } = targetCommand.targetFront;
+ await threadFront.resume();
+
+ await waitFor(
+ () => availableResources.length == 1,
+ "Wait until we receive the resumed event"
+ );
+
+ const resumed = availableResources.pop();
+
+ assertResumedResource(resumed);
+
+ targetCommand.destroy();
+ await client.close();
+}
+
+async function checkBreakpointAfterWatchResources() {
+ info(
+ "Check whether ResourceCommand gets breakpoint hit after calling watchResources"
+ );
+
+ const tab = await addTab(BREAKPOINT_TEST_URL);
+
+ const { client, resourceCommand, targetCommand } = await initResourceCommand(
+ tab
+ );
+
+ info("Call watchResources");
+ const availableResources = [];
+ await resourceCommand.watchResources([resourceCommand.TYPES.THREAD_STATE], {
+ onAvailable: resources => availableResources.push(...resources),
+ });
+
+ is(
+ availableResources.length,
+ 0,
+ "Got no THREAD_STATE when calling watchResources"
+ );
+
+ info("Run the 'debugger' statement");
+ // Note that we do not wait for the resolution of spawn as it will be paused
+ ContentTask.spawn(tab.linkedBrowser, null, () => {
+ content.window.wrappedJSObject.runDebuggerStatement();
+ });
+
+ await waitFor(
+ () => availableResources.length == 1,
+ "Got the THREAD_STATE related to the debugger statement"
+ );
+ const threadState = availableResources.pop();
+
+ assertPausedResource(threadState, {
+ state: "paused",
+ why: {
+ type: "debuggerStatement",
+ },
+ frame: {
+ type: "call",
+ asyncCause: null,
+ state: "on-stack",
+ // this: object actor's form referring to `this` variable
+ displayName: "runDebuggerStatement",
+ // arguments: []
+ where: {
+ line: 17,
+ column: 6,
+ },
+ },
+ });
+
+ // treadFront is created and attached while calling watchResources
+ const { threadFront } = targetCommand.targetFront;
+
+ await threadFront.resume();
+
+ await waitFor(
+ () => availableResources.length == 1,
+ "Wait until we receive the resumed event"
+ );
+
+ const resumed = availableResources.pop();
+
+ assertResumedResource(resumed);
+
+ targetCommand.destroy();
+ await client.close();
+}
+
+async function checkRealBreakpoint() {
+ info(
+ "Check whether ResourceCommand gets breakpoint set via the thread Front (instead of just debugger statements)"
+ );
+
+ const tab = await addTab(BREAKPOINT_TEST_URL);
+
+ const { client, resourceCommand, targetCommand } = await initResourceCommand(
+ tab
+ );
+
+ info("Call watchResources");
+ const availableResources = [];
+ await resourceCommand.watchResources([resourceCommand.TYPES.THREAD_STATE], {
+ onAvailable: resources => availableResources.push(...resources),
+ });
+
+ is(
+ availableResources.length,
+ 0,
+ "Got no THREAD_STATE when calling watchResources"
+ );
+
+ // treadFront is created and attached while calling watchResources
+ const { threadFront } = targetCommand.targetFront;
+
+ // We have to call `sources` request, otherwise the Thread Actor
+ // doesn't start watching for sources, and ignore the setBreakpoint call
+ // as it doesn't have any source registered.
+ await threadFront.getSources();
+
+ await threadFront.setBreakpoint(
+ { sourceUrl: BREAKPOINT_TEST_URL, line: 14 },
+ {}
+ );
+
+ info("Run the test function where we set a breakpoint");
+ // Note that we do not wait for the resolution of spawn as it will be paused
+ ContentTask.spawn(tab.linkedBrowser, null, () => {
+ content.window.wrappedJSObject.testFunction();
+ });
+
+ await waitFor(
+ () => availableResources.length == 1,
+ "Got the THREAD_STATE related to the debugger statement"
+ );
+ const threadState = availableResources.pop();
+
+ assertPausedResource(threadState, {
+ state: "paused",
+ why: {
+ type: "breakpoint",
+ },
+ frame: {
+ type: "call",
+ asyncCause: null,
+ state: "on-stack",
+ // this: object actor's form referring to `this` variable
+ displayName: "testFunction",
+ // arguments: []
+ where: {
+ line: 14,
+ column: 6,
+ },
+ },
+ });
+
+ await threadFront.resume();
+
+ await waitFor(
+ () => availableResources.length == 1,
+ "Wait until we receive the resumed event"
+ );
+
+ const resumed = availableResources.pop();
+
+ assertResumedResource(resumed);
+
+ targetCommand.destroy();
+ await client.close();
+}
+
+async function checkPauseOnException() {
+ info(
+ "Check whether ResourceCommand gets breakpoint for exception (when explicitly requested)"
+ );
+
+ const tab = await addTab(
+ "data:text/html,<meta charset=utf8><script>a.b.c.d</script>"
+ );
+
+ const { commands, resourceCommand, targetCommand } =
+ await initResourceCommand(tab);
+
+ info("Call watchResources");
+ const availableResources = [];
+ await resourceCommand.watchResources([resourceCommand.TYPES.THREAD_STATE], {
+ onAvailable: resources => availableResources.push(...resources),
+ });
+
+ is(
+ availableResources.length,
+ 0,
+ "Got no THREAD_STATE when calling watchResources"
+ );
+
+ await commands.threadConfigurationCommand.updateConfiguration({
+ pauseOnExceptions: true,
+ });
+
+ info("Reload the page, in order to trigger exception on load");
+ const reloaded = reloadBrowser();
+
+ await waitFor(
+ () => availableResources.length == 1,
+ "Got the THREAD_STATE related to the debugger statement"
+ );
+ const threadState = availableResources.pop();
+
+ assertPausedResource(threadState, {
+ state: "paused",
+ why: {
+ type: "exception",
+ },
+ frame: {
+ type: "global",
+ asyncCause: null,
+ state: "on-stack",
+ // this: object actor's form referring to `this` variable
+ displayName: "(global)",
+ // arguments: []
+ where: {
+ line: 1,
+ column: 27,
+ },
+ },
+ });
+
+ const { threadFront } = targetCommand.targetFront;
+ await threadFront.resume();
+ info("Wait for page to finish reloading after resume");
+ await reloaded;
+
+ await waitFor(
+ () => availableResources.length == 1,
+ "Wait until we receive the resumed event"
+ );
+
+ const resumed = availableResources.pop();
+
+ assertResumedResource(resumed);
+
+ targetCommand.destroy();
+ await commands.destroy();
+}
+
+async function checkSetBeforeWatch() {
+ info(
+ "Verify bug 1683139 - D103068, where setting a breakpoint before watching for thread state, avoid receiving the paused state"
+ );
+
+ const tab = await addTab(BREAKPOINT_TEST_URL);
+
+ const { client, resourceCommand, targetCommand } = await initResourceCommand(
+ tab
+ );
+
+ // Instantiate the thread front in order to be able to set a breakpoint before watching for thread state
+ info("Attach the top level thread actor");
+ await targetCommand.targetFront.attachAndInitThread(targetCommand);
+ const { threadFront } = targetCommand.targetFront;
+
+ // We have to call `sources` request, otherwise the Thread Actor
+ // doesn't start watching for sources, and ignore the setBreakpoint call
+ // as it doesn't have any source registered.
+ await threadFront.getSources();
+
+ // Set the breakpoint before trying to hit it
+ await threadFront.setBreakpoint(
+ { sourceUrl: BREAKPOINT_TEST_URL, line: 14, column: 6 },
+ {}
+ );
+
+ info("Run the test function where we set a breakpoint");
+ // Note that we do not wait for the resolution of spawn as it will be paused
+ ContentTask.spawn(tab.linkedBrowser, null, () => {
+ content.window.wrappedJSObject.testFunction();
+ });
+
+ // bug 1683139 - D103068. Re-setting the breakpoint just before watching for thread state
+ // prevented to receive the paused state change.
+ await threadFront.setBreakpoint(
+ { sourceUrl: BREAKPOINT_TEST_URL, line: 14, column: 6 },
+ {}
+ );
+
+ info("Call watchResources");
+ const availableResources = [];
+ await resourceCommand.watchResources([resourceCommand.TYPES.THREAD_STATE], {
+ onAvailable: resources => availableResources.push(...resources),
+ });
+
+ await waitFor(
+ () => availableResources.length == 1,
+ "Got the THREAD_STATE related to the debugger statement"
+ );
+ const threadState = availableResources.pop();
+
+ assertPausedResource(threadState, {
+ state: "paused",
+ why: {
+ type: "breakpoint",
+ },
+ frame: {
+ type: "call",
+ asyncCause: null,
+ state: "on-stack",
+ // this: object actor's form referring to `this` variable
+ displayName: "testFunction",
+ // arguments: []
+ where: {
+ line: 14,
+ column: 6,
+ },
+ },
+ });
+
+ await threadFront.resume();
+
+ await waitFor(
+ () => availableResources.length == 1,
+ "Wait until we receive the resumed event"
+ );
+
+ const resumed = availableResources.pop();
+
+ assertResumedResource(resumed);
+
+ targetCommand.destroy();
+ await client.close();
+}
+
+async function checkDebuggerStatementInIframes() {
+ info("Check whether ResourceCommand gets breakpoint for (remote) iframes");
+
+ const tab = await addTab(BREAKPOINT_TEST_URL);
+
+ const { client, resourceCommand, targetCommand } = await initResourceCommand(
+ tab
+ );
+
+ info("Call watchResources");
+ const availableResources = [];
+ await resourceCommand.watchResources([resourceCommand.TYPES.THREAD_STATE], {
+ onAvailable: resources => availableResources.push(...resources),
+ });
+
+ is(
+ availableResources.length,
+ 0,
+ "Got no THREAD_STATE when calling watchResources"
+ );
+
+ info("Inject the iframe with an inline 'debugger' statement");
+ // Note that we do not wait for the resolution of spawn as it will be paused
+ SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [REMOTE_IFRAME_URL],
+ async function (url) {
+ const iframe = content.document.createElement("iframe");
+ iframe.src = url;
+ content.document.body.appendChild(iframe);
+ }
+ );
+
+ await waitFor(
+ () => availableResources.length == 1,
+ "Got the THREAD_STATE related to the iframe's debugger statement"
+ );
+ const threadState = availableResources.pop();
+
+ assertPausedResource(threadState, {
+ state: "paused",
+ why: {
+ type: "debuggerStatement",
+ },
+ frame: {
+ type: "global",
+ asyncCause: null,
+ state: "on-stack",
+ // this: object actor's form referring to `this` variable
+ displayName: "(global)",
+ // arguments: []
+ where: {
+ line: 1,
+ column: 8,
+ },
+ },
+ });
+
+ const iframeTarget = threadState.targetFront;
+ if (isFissionEnabled() || isEveryFrameTargetEnabled()) {
+ is(
+ iframeTarget.url,
+ REMOTE_IFRAME_URL,
+ "With fission/EFT, the pause is from the iframe's target"
+ );
+ } else {
+ is(
+ iframeTarget,
+ targetCommand.targetFront,
+ "Without fission/EFT, the pause is from the top level target"
+ );
+ }
+ const { threadFront } = iframeTarget;
+
+ await threadFront.resume();
+
+ await waitFor(
+ () => availableResources.length == 1,
+ "Wait until we receive the resumed event"
+ );
+
+ const resumed = availableResources.pop();
+
+ assertResumedResource(resumed);
+
+ targetCommand.destroy();
+ await client.close();
+}
+
+async function assertPausedResource(resource, expected) {
+ is(
+ resource.resourceType,
+ ResourceCommand.TYPES.THREAD_STATE,
+ "Resource type is correct"
+ );
+ is(resource.state, "paused", "state attribute is correct");
+ is(resource.why.type, expected.why.type, "why.type attribute is correct");
+ is(
+ resource.frame.type,
+ expected.frame.type,
+ "frame.type attribute is correct"
+ );
+ is(
+ resource.frame.asyncCause,
+ expected.frame.asyncCause,
+ "frame.asyncCause attribute is correct"
+ );
+ is(
+ resource.frame.state,
+ expected.frame.state,
+ "frame.state attribute is correct"
+ );
+ is(
+ resource.frame.displayName,
+ expected.frame.displayName,
+ "frame.displayName attribute is correct"
+ );
+ is(
+ resource.frame.where.line,
+ expected.frame.where.line,
+ "frame.where.line attribute is correct"
+ );
+ is(
+ resource.frame.where.column,
+ expected.frame.where.column,
+ "frame.where.column attribute is correct"
+ );
+}
+
+async function assertResumedResource(resource) {
+ is(
+ resource.resourceType,
+ ResourceCommand.TYPES.THREAD_STATE,
+ "Resource type is correct"
+ );
+ is(resource.state, "resumed", "state attribute is correct");
+}
diff --git a/devtools/shared/commands/resource/tests/browser_resources_unwatch_early.js b/devtools/shared/commands/resource/tests/browser_resources_unwatch_early.js
new file mode 100644
index 0000000000..e3890cf970
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/browser_resources_unwatch_early.js
@@ -0,0 +1,113 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that calling unwatchResources before watchResources could resolve still
+// removes watcher entries correctly.
+
+const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js");
+
+const TEST_URI = "data:text/html;charset=utf-8,";
+
+add_task(async function () {
+ const tab = await addTab(TEST_URI);
+
+ const { client, resourceCommand, targetCommand } = await initResourceCommand(
+ tab
+ );
+ const { CONSOLE_MESSAGE, ROOT_NODE } = resourceCommand.TYPES;
+
+ info("Use console.log in the content page");
+ await logInTab(tab, "msg-1");
+
+ info("Call watchResources with various configurations");
+
+ // Watcher 1 only watches for CONSOLE_MESSAGE.
+ // For this call site, unwatchResource will be called before onAvailable has
+ // resolved.
+ const messages1 = [];
+ const onAvailable1 = createMessageCallback(messages1);
+ const onWatcher1Ready = resourceCommand.watchResources([CONSOLE_MESSAGE], {
+ onAvailable: onAvailable1,
+ });
+ resourceCommand.unwatchResources([CONSOLE_MESSAGE], {
+ onAvailable: onAvailable1,
+ });
+
+ info(
+ "Calling unwatchResources for an already unregistered callback should be a no-op"
+ );
+ // and more importantly, it should not throw
+ resourceCommand.unwatchResources([CONSOLE_MESSAGE], {
+ onAvailable: onAvailable1,
+ });
+
+ // Watcher 2 watches for CONSOLE_MESSAGE & another resource (ROOT_NODE).
+ // Again unwatchResource will be called before onAvailable has resolved.
+ // But unwatchResource is only called for CONSOLE_MESSAGE, not for ROOT_NODE.
+ const messages2 = [];
+ const onAvailable2 = createMessageCallback(messages2);
+ const onWatcher2Ready = resourceCommand.watchResources(
+ [CONSOLE_MESSAGE, ROOT_NODE],
+ {
+ onAvailable: onAvailable2,
+ }
+ );
+ resourceCommand.unwatchResources([CONSOLE_MESSAGE], {
+ onAvailable: onAvailable2,
+ });
+
+ // Watcher 3 watches for CONSOLE_MESSAGE, but we will not call unwatchResource
+ // explicitly for it before the end of test. Used as a reference.
+ const messages3 = [];
+ const onAvailable3 = createMessageCallback(messages3);
+ const onWatcher3Ready = resourceCommand.watchResources([CONSOLE_MESSAGE], {
+ onAvailable: onAvailable3,
+ });
+
+ info("Call unwatchResources for CONSOLE_MESSAGE on watcher 1 & 2");
+
+ info("Wait for all watchers `watchResources` to resolve");
+ await Promise.all([onWatcher1Ready, onWatcher2Ready, onWatcher3Ready]);
+ ok(!hasMessage(messages1, "msg-1"), "Watcher 1 did not receive msg-1");
+ ok(!hasMessage(messages2, "msg-1"), "Watcher 2 did not receive msg-1");
+ ok(hasMessage(messages3, "msg-1"), "Watcher 3 received msg-1");
+
+ info("Log a new message");
+ await logInTab(tab, "msg-2");
+
+ info("Wait until watcher 3 received the new message");
+ await waitUntil(() => hasMessage(messages3, "msg-2"));
+
+ ok(!hasMessage(messages1, "msg-2"), "Watcher 1 did not receive msg-2");
+ ok(!hasMessage(messages2, "msg-2"), "Watcher 2 did not receive msg-2");
+
+ targetCommand.destroy();
+ await client.close();
+});
+
+function logInTab(tab, message) {
+ return ContentTask.spawn(tab.linkedBrowser, message, function (_message) {
+ content.console.log(_message);
+ });
+}
+
+function hasMessage(messageResources, text) {
+ return messageResources.find(
+ resource => resource.message.arguments[0] === text
+ );
+}
+
+// All resource command callbacks share the same pattern here: they add all
+// console message resources to a provided `messages` array.
+function createMessageCallback(messages) {
+ const { CONSOLE_MESSAGE } = ResourceCommand.TYPES;
+ return async resources => {
+ for (const resource of resources) {
+ if (resource.resourceType === CONSOLE_MESSAGE) {
+ messages.push(resource);
+ }
+ }
+ };
+}
diff --git a/devtools/shared/commands/resource/tests/browser_resources_watch_unwatch_multiple.js b/devtools/shared/commands/resource/tests/browser_resources_watch_unwatch_multiple.js
new file mode 100644
index 0000000000..cc45e7bf7f
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/browser_resources_watch_unwatch_multiple.js
@@ -0,0 +1,88 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Check that watching/unwatching multiple times works as expected
+
+add_task(async function () {
+ const TEST_URL = "data:text/html;charset=utf-8,<!DOCTYPE html>foo";
+ const tab = await addTab(TEST_URL);
+
+ const { client, resourceCommand, targetCommand } = await initResourceCommand(
+ tab
+ );
+
+ let resources = [];
+ const onAvailable = _resources => {
+ resources.push(..._resources);
+ };
+
+ info("Watch for error messages resources");
+ await resourceCommand.watchResources([resourceCommand.TYPES.ERROR_MESSAGE], {
+ onAvailable,
+ });
+
+ ok(
+ resourceCommand.isResourceWatched(resourceCommand.TYPES.ERROR_MESSAGE),
+ "The error message resource is currently been watched."
+ );
+
+ is(
+ resources.length,
+ 0,
+ "no resources were received after the first watchResources call"
+ );
+
+ info("Trigger an error in the page");
+ await ContentTask.spawn(tab.linkedBrowser, [], function frameScript() {
+ const document = content.document;
+ const scriptEl = document.createElement("script");
+ scriptEl.textContent = `document.unknownFunction()`;
+ document.body.appendChild(scriptEl);
+ });
+
+ await waitFor(() => resources.length === 1);
+ const EXPECTED_ERROR_MESSAGE =
+ "TypeError: document.unknownFunction is not a function";
+ is(
+ resources[0].pageError.errorMessage,
+ EXPECTED_ERROR_MESSAGE,
+ "The resource was received"
+ );
+
+ info("Unwatching resources…");
+ resourceCommand.unwatchResources([resourceCommand.TYPES.ERROR_MESSAGE], {
+ onAvailable,
+ });
+
+ ok(
+ !resourceCommand.isResourceWatched(resourceCommand.TYPES.ERROR_MESSAGE),
+ "The error message resource is no longer been watched."
+ );
+ // clearing resources
+ resources = [];
+
+ info("…and watching again");
+ await resourceCommand.watchResources([resourceCommand.TYPES.ERROR_MESSAGE], {
+ onAvailable,
+ });
+
+ ok(
+ resourceCommand.isResourceWatched(resourceCommand.TYPES.ERROR_MESSAGE),
+ "The error message resource is been watched again."
+ );
+ is(
+ resources.length,
+ 1,
+ "we retrieve the expected number of existing resources"
+ );
+ is(
+ resources[0].pageError.errorMessage,
+ EXPECTED_ERROR_MESSAGE,
+ "The resource is the expected one"
+ );
+
+ targetCommand.destroy();
+ await client.close();
+});
diff --git a/devtools/shared/commands/resource/tests/browser_resources_websocket.js b/devtools/shared/commands/resource/tests/browser_resources_websocket.js
new file mode 100644
index 0000000000..6e73fa7b2e
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/browser_resources_websocket.js
@@ -0,0 +1,240 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the ResourceCommand API around WEBSOCKET.
+
+const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js");
+
+const IS_NUMBER = "IS_NUMBER";
+const SHOULD_EXIST = "SHOULD_EXIST";
+
+const targets = {
+ TOP_LEVEL_DOCUMENT: "top-level-document",
+ IN_PROCESS_IFRAME: "in-process-frame",
+ OUT_PROCESS_IFRAME: "out-process-frame",
+};
+
+add_task(async function () {
+ info("Testing the top-level document");
+ await testWebsocketResources(targets.TOP_LEVEL_DOCUMENT);
+ info("Testing the in-process iframe");
+ await testWebsocketResources(targets.IN_PROCESS_IFRAME);
+ info("Testing the out-of-process iframe");
+ await testWebsocketResources(targets.OUT_PROCESS_IFRAME);
+});
+
+async function testWebsocketResources(target) {
+ const tab = await addTab(URL_ROOT_SSL + "websocket_frontend.html");
+ const { client, resourceCommand, targetCommand } = await initResourceCommand(
+ tab
+ );
+
+ const availableResources = [];
+ function onResourceAvailable(resources) {
+ availableResources.push(...resources);
+ }
+
+ await resourceCommand.watchResources([resourceCommand.TYPES.WEBSOCKET], {
+ onAvailable: onResourceAvailable,
+ });
+
+ info("Check available resources at initial");
+ is(
+ availableResources.length,
+ 0,
+ "Length of existing resources is correct at initial"
+ );
+
+ info("Check resource of opening websocket");
+ await executeFunctionInContext(tab, target, "openConnection");
+
+ await waitUntil(() => availableResources.length === 1);
+
+ const httpChannelId = availableResources[0].httpChannelId;
+
+ ok(httpChannelId, "httpChannelId is present in the resource");
+
+ assertResource(availableResources[0], {
+ wsMessageType: "webSocketOpened",
+ effectiveURI:
+ "wss://example.com/browser/devtools/shared/commands/resource/tests/websocket_backend",
+ extensions: "permessage-deflate",
+ protocols: "",
+ });
+
+ info("Check resource of sending/receiving the data via websocket");
+ await executeFunctionInContext(tab, target, "sendData", "test");
+
+ await waitUntil(() => availableResources.length === 3);
+
+ assertResource(availableResources[1], {
+ wsMessageType: "frameSent",
+ httpChannelId,
+ data: {
+ type: "sent",
+ payload: "test",
+ timeStamp: SHOULD_EXIST,
+ finBit: SHOULD_EXIST,
+ rsvBit1: SHOULD_EXIST,
+ rsvBit2: SHOULD_EXIST,
+ rsvBit3: SHOULD_EXIST,
+ opCode: SHOULD_EXIST,
+ mask: SHOULD_EXIST,
+ maskBit: SHOULD_EXIST,
+ },
+ });
+ assertResource(availableResources[2], {
+ wsMessageType: "frameReceived",
+ httpChannelId,
+ data: {
+ type: "received",
+ payload: "test",
+ timeStamp: SHOULD_EXIST,
+ finBit: SHOULD_EXIST,
+ rsvBit1: SHOULD_EXIST,
+ rsvBit2: SHOULD_EXIST,
+ rsvBit3: SHOULD_EXIST,
+ opCode: SHOULD_EXIST,
+ mask: SHOULD_EXIST,
+ maskBit: SHOULD_EXIST,
+ },
+ });
+
+ info("Check resource of closing websocket");
+ await executeFunctionInContext(tab, target, "closeConnection");
+
+ await waitUntil(() => availableResources.length === 6);
+ assertResource(availableResources[3], {
+ wsMessageType: "frameSent",
+ httpChannelId,
+ data: {
+ type: "sent",
+ payload: "",
+ timeStamp: SHOULD_EXIST,
+ finBit: SHOULD_EXIST,
+ rsvBit1: SHOULD_EXIST,
+ rsvBit2: SHOULD_EXIST,
+ rsvBit3: SHOULD_EXIST,
+ opCode: SHOULD_EXIST,
+ mask: SHOULD_EXIST,
+ maskBit: SHOULD_EXIST,
+ },
+ });
+ assertResource(availableResources[4], {
+ wsMessageType: "frameReceived",
+ httpChannelId,
+ data: {
+ type: "received",
+ payload: "",
+ timeStamp: SHOULD_EXIST,
+ finBit: SHOULD_EXIST,
+ rsvBit1: SHOULD_EXIST,
+ rsvBit2: SHOULD_EXIST,
+ rsvBit3: SHOULD_EXIST,
+ opCode: SHOULD_EXIST,
+ mask: SHOULD_EXIST,
+ maskBit: SHOULD_EXIST,
+ },
+ });
+ assertResource(availableResources[5], {
+ wsMessageType: "webSocketClosed",
+ httpChannelId,
+ code: IS_NUMBER,
+ reason: "",
+ wasClean: true,
+ });
+
+ info("Check existing resources");
+ const existingResources = [];
+
+ function onExsistingResourceAvailable(resources) {
+ existingResources.push(...resources);
+ }
+
+ await resourceCommand.watchResources([resourceCommand.TYPES.WEBSOCKET], {
+ onAvailable: onExsistingResourceAvailable,
+ });
+
+ is(
+ availableResources.length,
+ existingResources.length,
+ "Length of existing resources is correct"
+ );
+
+ for (let i = 0; i < availableResources.length; i++) {
+ ok(
+ availableResources[i] === existingResources[i],
+ `The ${i}th resource is correct`
+ );
+ }
+
+ await resourceCommand.unwatchResources([resourceCommand.TYPES.WEBSOCKET], {
+ onAvailable: onResourceAvailable,
+ });
+
+ await resourceCommand.unwatchResources([resourceCommand.TYPES.WEBSOCKET], {
+ onAvailable: onExsistingResourceAvailable,
+ });
+
+ targetCommand.destroy();
+ await client.close();
+ BrowserTestUtils.removeTab(tab);
+}
+
+/**
+ * Execute global functions defined in the correct
+ * target (top-level-window or frames) contexts.
+ *
+ * @param {object} tab The current window tab
+ * @param {string} target A string identify if we want to test the top level document or iframes
+ * @param {string} funcName The name of the global function which needs to be called.
+ * @param {*} funcArgs The arguments to pass to the global function
+ */
+async function executeFunctionInContext(tab, target, funcName, ...funcArgs) {
+ let browsingContext = tab.linkedBrowser.browsingContext;
+ // If the target is an iframe get its window global
+ if (target !== targets.TOP_LEVEL_DOCUMENT) {
+ browsingContext = await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [target],
+ async _target => {
+ const iframe = content.document.getElementById(_target);
+ return iframe.browsingContext;
+ }
+ );
+ }
+
+ return SpecialPowers.spawn(
+ browsingContext,
+ [funcName, funcArgs],
+ async (_funcName, _funcArgs) => {
+ await content.wrappedJSObject[_funcName](..._funcArgs);
+ }
+ );
+}
+
+function assertResource(resource, expected) {
+ is(
+ resource.resourceType,
+ ResourceCommand.TYPES.WEBSOCKET,
+ "Resource type is correct"
+ );
+
+ assertObject(resource, expected);
+}
+
+function assertObject(object, expected) {
+ for (const field in expected) {
+ if (typeof expected[field] === "object") {
+ assertObject(object[field], expected[field]);
+ } else if (expected[field] === SHOULD_EXIST) {
+ ok(object[field] !== undefined, `The value of ${field} exists`);
+ } else if (expected[field] === IS_NUMBER) {
+ ok(!isNaN(object[field]), `The value of ${field} is number`);
+ } else {
+ is(object[field], expected[field], `The value of ${field} is correct`);
+ }
+ }
+}
diff --git a/devtools/shared/commands/resource/tests/doc_console.html b/devtools/shared/commands/resource/tests/doc_console.html
new file mode 100644
index 0000000000..ee883cf47d
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/doc_console.html
@@ -0,0 +1,18 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf8">
+ <title>Test document for console</title>
+ <!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+</head>
+ <body>
+ <p>Test document for console</p>
+
+ <iframe src="data:text/html;charset=utf-8,foo<script>console.log('data url data log')</script>"></iframe>
+ <script>
+ "use strict";
+ console.log("top-level document log");
+ </script>
+ </body>
+</html>
diff --git a/devtools/shared/commands/resource/tests/doc_console_iframe.html b/devtools/shared/commands/resource/tests/doc_console_iframe.html
new file mode 100644
index 0000000000..e088dff4e5
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/doc_console_iframe.html
@@ -0,0 +1,16 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf8">
+ <title>Test fission iframe document</title>
+ <!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+</head>
+ <body>
+ <p>remote iframe</p>
+ <script>
+ "use strict";
+ console.log(`${document.location.origin} iframe log`);
+ </script>
+ </body>
+</html>
diff --git a/devtools/shared/commands/resource/tests/early_console_document.html b/devtools/shared/commands/resource/tests/early_console_document.html
new file mode 100644
index 0000000000..e4523dbdeb
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/early_console_document.html
@@ -0,0 +1,14 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf8">
+ <title>Test fission document</title>
+ <!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+ <script>
+ "use strict";
+ console.log("early-page-log");
+ </script>
+</head>
+<body></body>
+</html>
diff --git a/devtools/shared/commands/resource/tests/empty.html b/devtools/shared/commands/resource/tests/empty.html
new file mode 100644
index 0000000000..195b296bfe
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/empty.html
@@ -0,0 +1,11 @@
+<!-- Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ -->
+
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="UTF-8">
+ <title>Empty page (No network requests)</title>
+</head>
+<body></body>
+</html>
diff --git a/devtools/shared/commands/resource/tests/fission_document.html b/devtools/shared/commands/resource/tests/fission_document.html
new file mode 100644
index 0000000000..222f92d999
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/fission_document.html
@@ -0,0 +1,23 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf8">
+ <title>Test fission document</title>
+ <!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+</head>
+<body>
+<p>Test fission iframe</p>
+
+<script>
+ "use strict";
+ const iframe = document.createElement("iframe");
+ let iframeUrl = `https://example.org/browser/devtools/shared/commands/resource/tests/fission_iframe.html`;
+ if (document.location.search) {
+ iframeUrl += `?${new URLSearchParams(document.location.search)}`;
+ }
+ iframe.src = iframeUrl;
+ document.body.append(iframe);
+</script>
+</body>
+</html>
diff --git a/devtools/shared/commands/resource/tests/fission_document_workers.html b/devtools/shared/commands/resource/tests/fission_document_workers.html
new file mode 100644
index 0000000000..bbbe3e8bf8
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/fission_document_workers.html
@@ -0,0 +1,47 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf8">
+ <title>Test fission document</title>
+ <!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+ <script>
+ "use strict";
+
+ const params = new URLSearchParams(document.location.search);
+
+ // eslint-disable-next-line no-unused-vars
+ const worker = new Worker("https://example.com/browser/devtools/shared/commands/resource/tests/test_worker.js#simple-worker");
+
+ // eslint-disable-next-line no-unused-vars
+ const sharedWorker = new SharedWorker("https://example.com/browser/devtools/shared/commands/resource/tests/test_worker.js#shared-worker");
+
+ if (!params.has("noServiceWorker")) {
+ // Expose a reference to the registration so that tests can unregister it.
+ window.registrationPromise = navigator.serviceWorker.register("https://example.com/browser/devtools/shared/commands/resource/tests/test_service_worker.js#service-worker");
+ }
+
+ /* exported logMessageInWorker */
+ function logMessageInWorker(message) {
+ worker.postMessage({
+ type: "log-in-worker",
+ message,
+ });
+ }
+ </script>
+</head>
+<body>
+<p>Test fission iframe</p>
+
+<script>
+ "use strict";
+ const iframe = document.createElement("iframe");
+ let iframeUrl = `https://example.org/browser/devtools/shared/commands/resource/tests/fission_iframe_workers.html`;
+ if (document.location.search) {
+ iframeUrl += `?${new URLSearchParams(document.location.search)}`;
+ }
+ iframe.src = iframeUrl;
+ document.body.append(iframe);
+</script>
+</body>
+</html>
diff --git a/devtools/shared/commands/resource/tests/fission_iframe.html b/devtools/shared/commands/resource/tests/fission_iframe.html
new file mode 100644
index 0000000000..f674321102
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/fission_iframe.html
@@ -0,0 +1,12 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf8">
+ <title>Test fission iframe document</title>
+ <!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+</head>
+<body>
+<p>remote iframe</p>
+</body>
+</html>
diff --git a/devtools/shared/commands/resource/tests/fission_iframe_workers.html b/devtools/shared/commands/resource/tests/fission_iframe_workers.html
new file mode 100644
index 0000000000..deae49f833
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/fission_iframe_workers.html
@@ -0,0 +1,29 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf8">
+ <title>Test fission iframe document</title>
+ <!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+ <script>
+ "use strict";
+ const params = new URLSearchParams(document.location.search);
+ const hashSuffix = params.get("hashSuffix") || "in-iframe";
+ // eslint-disable-next-line no-unused-vars
+ const worker = new Worker("test_worker.js#simple-worker-" + hashSuffix);
+ // eslint-disable-next-line no-unused-vars
+ const sharedWorker = new SharedWorker("test_worker.js#shared-worker-" + hashSuffix);
+
+ /* exported logMessageInWorker */
+ function logMessageInWorker(message) {
+ worker.postMessage({
+ type: "log-in-worker",
+ message,
+ });
+ }
+ </script>
+</head>
+<body>
+<p>remote iframe</p>
+</body>
+</html>
diff --git a/devtools/shared/commands/resource/tests/head.js b/devtools/shared/commands/resource/tests/head.js
new file mode 100644
index 0000000000..d05c2e0d3d
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/head.js
@@ -0,0 +1,137 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* eslint no-unused-vars: [2, {"vars": "local"}] */
+
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/devtools/client/shared/test/shared-head.js",
+ this
+);
+
+const {
+ DevToolsClient,
+} = require("resource://devtools/client/devtools-client.js");
+const {
+ DevToolsServer,
+} = require("resource://devtools/server/devtools-server.js");
+
+async function _initResourceCommandFromCommands(
+ commands,
+ { listenForWorkers = false } = {}
+) {
+ const targetCommand = commands.targetCommand;
+ if (listenForWorkers) {
+ targetCommand.listenForWorkers = true;
+ }
+ await targetCommand.startListening();
+
+ //Bug 1709065: Stop exporting resourceCommand and use commands.resourceCommand
+ //And rename all these methods
+ return {
+ client: commands.client,
+ commands,
+ resourceCommand: commands.resourceCommand,
+ targetCommand,
+ };
+}
+
+/**
+ * Instantiate a ResourceCommand for the given tab.
+ *
+ * @param {Tab} tab
+ * The browser frontend's tab to connect to.
+ * @param {Object} options
+ * @param {Boolean} options.listenForWorkers
+ * @return {Object} object
+ * @return {ResourceCommand} object.resourceCommand
+ * The underlying resource command interface.
+ * @return {Object} object.commands
+ * The commands object defined by modules from devtools/shared/commands.
+ * @return {DevToolsClient} object.client
+ * The underlying client instance.
+ * @return {TargetCommand} object.targetCommand
+ * The underlying target list instance.
+ */
+async function initResourceCommand(tab, options) {
+ const commands = await CommandsFactory.forTab(tab);
+ return _initResourceCommandFromCommands(commands, options);
+}
+
+/**
+ * Instantiate a multi-process ResourceCommand, watching all type of targets.
+ *
+ * @return {Object} object
+ * @return {ResourceCommand} object.resourceCommand
+ * The underlying resource command interface.
+ * @return {Object} object.commands
+ * The commands object defined by modules from devtools/shared/commands.
+ * @return {DevToolsClient} object.client
+ * The underlying client instance.
+ * @return {DevToolsClient} object.targetCommand
+ * The underlying target list instance.
+ */
+async function initMultiProcessResourceCommand() {
+ const commands = await CommandsFactory.forMainProcess();
+ return _initResourceCommandFromCommands(commands);
+}
+
+// Copied from devtools/shared/webconsole/test/chrome/common.js
+function checkObject(object, expected) {
+ if (object && object.getGrip) {
+ object = object.getGrip();
+ }
+
+ for (const name of Object.keys(expected)) {
+ const expectedValue = expected[name];
+ const value = object[name];
+ checkValue(name, value, expectedValue);
+ }
+}
+
+function checkValue(name, value, expected) {
+ if (expected === null) {
+ is(value, null, `'${name}' is null`);
+ } else if (expected === undefined) {
+ is(value, expected, `'${name}' is undefined`);
+ } else if (
+ typeof expected == "string" ||
+ typeof expected == "number" ||
+ typeof expected == "boolean"
+ ) {
+ is(value, expected, "property '" + name + "'");
+ } else if (expected instanceof RegExp) {
+ ok(expected.test(value), name + ": " + expected + " matched " + value);
+ } else if (Array.isArray(expected)) {
+ info("checking array for property '" + name + "'");
+ ok(Array.isArray(value), `property '${name}' is an array`);
+
+ is(value.length, expected.length, "Array has expected length");
+ if (value.length !== expected.length) {
+ is(JSON.stringify(value, null, 2), JSON.stringify(expected, null, 2));
+ } else {
+ checkObject(value, expected);
+ }
+ } else if (typeof expected == "object") {
+ info("checking object for property '" + name + "'");
+ checkObject(value, expected);
+ }
+}
+
+async function triggerNetworkRequests(browser, commands) {
+ for (let i = 0; i < commands.length; i++) {
+ await SpecialPowers.spawn(browser, [commands[i]], async function (code) {
+ const script = content.document.createElement("script");
+ script.append(
+ content.document.createTextNode(
+ `async function triggerRequest() {${code}}`
+ )
+ );
+ content.document.body.append(script);
+ await content.wrappedJSObject.triggerRequest();
+ script.remove();
+ });
+ }
+}
diff --git a/devtools/shared/commands/resource/tests/network_document.html b/devtools/shared/commands/resource/tests/network_document.html
new file mode 100644
index 0000000000..5c4744cb0c
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/network_document.html
@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8">
+ <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
+ <meta http-equiv="Pragma" content="no-cache" />
+ <meta http-equiv="Expires" content="0" />
+ <title>Test for network events</title>
+ </head>
+ <body>
+ <p>Test for network events</p>
+ </body>
+</html>
diff --git a/devtools/shared/commands/resource/tests/network_document_navigation.html b/devtools/shared/commands/resource/tests/network_document_navigation.html
new file mode 100644
index 0000000000..c4ec651c05
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/network_document_navigation.html
@@ -0,0 +1,14 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8">
+ <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
+ <meta http-equiv="Pragma" content="no-cache" />
+ <meta http-equiv="Expires" content="0" />
+ <title>Test for network events</title>
+ </head>
+ <body>
+ <p>Test for network events</p>
+ <script src="network_navigation.js" />
+ </body>
+</html>
diff --git a/devtools/shared/commands/resource/tests/network_navigation.js b/devtools/shared/commands/resource/tests/network_navigation.js
new file mode 100644
index 0000000000..6004b69d3c
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/network_navigation.js
@@ -0,0 +1 @@
+// empty script loaded by network_document_navigation.html
diff --git a/devtools/shared/commands/resource/tests/service-worker-sources.js b/devtools/shared/commands/resource/tests/service-worker-sources.js
new file mode 100644
index 0000000000..614644ee5d
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/service-worker-sources.js
@@ -0,0 +1,2 @@
+/* eslint-disable */
+function serviceWorkerSource() {}
diff --git a/devtools/shared/commands/resource/tests/sources.html b/devtools/shared/commands/resource/tests/sources.html
new file mode 100644
index 0000000000..9e1ad67d85
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/sources.html
@@ -0,0 +1,53 @@
+<!-- Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ -->
+<!doctype HTML>
+<html>
+ <head>
+ <meta charset="utf-8"/>
+ </head>
+ <body>
+ <!-- introductionType=eventHandler -->
+ <div onclick="console.log('link')">link</div>
+
+ <!-- introductionType=inlineScript mapped to scriptElement -->
+ <script type="text/javascript">
+ "use strict";
+ /* eslint-disable */
+ function inlineSource() {}
+
+ // introductionType=eval
+ // Assign it to a global in order to avoid it being GCed
+ eval("this.global = function evalFunction() {}");
+
+ // introductionType=Function
+ // Also assign to a global to avoid being GCed
+ this.global2 = new Function("return 42;");
+
+ // introductionType=injectedScript mapped to scriptElement
+ const script = document.createElement("script");
+ script.textContent = "console.log('inline-script')";
+ document.documentElement.appendChild(script);
+
+ // introductionType=Worker, but ends up being null on SourceActor's form
+ // Assign the worker to a global variable in order to avoid
+ // having it be GCed.
+ this.worker = new Worker("worker-sources.js");
+
+ window.registrationPromise = navigator.serviceWorker.register("service-worker-sources.js");
+
+ // introductionType=domTimer
+ setTimeout(`console.log("timeout")`, 0);
+
+ // introductionType=eventHandler
+ window.addEventListener("DOMContentLoaded", () => {
+ document.querySelector("div[onclick]").click();
+ });
+ </script>
+ <!-- introductionType=srcScript mapped to scriptElement -->
+ <script src="sources.js"></script>
+ <!-- introductionType=javascriptURL -->
+ <iframe src="javascript:666"></iframe>
+ <!-- srcdoc attribute on iframes -->
+ <iframe srcdoc="<script>console.log('srcdoc')</script> <script>console.log('srcdoc 2')</script>"></iframe>
+ </body>
+</html>
diff --git a/devtools/shared/commands/resource/tests/sources.js b/devtools/shared/commands/resource/tests/sources.js
new file mode 100644
index 0000000000..7ae6c6272b
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/sources.js
@@ -0,0 +1,2 @@
+/* eslint-disable */
+function scriptSource() {}
diff --git a/devtools/shared/commands/resource/tests/sse_backend.sjs b/devtools/shared/commands/resource/tests/sse_backend.sjs
new file mode 100644
index 0000000000..777520577a
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/sse_backend.sjs
@@ -0,0 +1,8 @@
+"use strict";
+
+function handleRequest(request, response) {
+ response.processAsync();
+ response.setHeader("Content-Type", "text/event-stream");
+ response.write("data: Why so serious?\n\n");
+ response.finish();
+}
diff --git a/devtools/shared/commands/resource/tests/sse_frontend.html b/devtools/shared/commands/resource/tests/sse_frontend.html
new file mode 100644
index 0000000000..3bdddbc5bc
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/sse_frontend.html
@@ -0,0 +1,31 @@
+<!-- Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ -->
+<!doctype HTML>
+<html>
+ <head>
+ <meta charset="utf-8"/>
+ <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
+ <meta http-equiv="Pragma" content="no-cache" />
+ <meta http-equiv="Expires" content="0" />
+ <title>SSE Inspection Test Page</title>
+ </head>
+ <body>
+ <h1>SSE Inspection Test Page</h1>
+ <script type="text/javascript">
+ "use strict";
+
+ /* exported openConnection */
+ function openConnection() {
+ return new Promise(resolve => {
+ const es = new EventSource("sse_backend.sjs");
+ es.onmessage = function (e) {
+ es.close();
+ resolve();
+ };
+ });
+ }
+ </script>
+ <iframe id="in-process-frame" src="https://example.com/browser/devtools/shared/commands/resource/tests/sse_frontend_iframe.html"> </iframe>
+ <iframe id="out-process-frame" src="https://example.org/browser/devtools/shared/commands/resource/tests/sse_frontend_iframe.html"></iframe>
+ </body>
+</html>
diff --git a/devtools/shared/commands/resource/tests/sse_frontend_iframe.html b/devtools/shared/commands/resource/tests/sse_frontend_iframe.html
new file mode 100644
index 0000000000..477dca013d
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/sse_frontend_iframe.html
@@ -0,0 +1,29 @@
+<!-- Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ -->
+<!doctype HTML>
+<html>
+ <head>
+ <meta charset="utf-8"/>
+ <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
+ <meta http-equiv="Pragma" content="no-cache" />
+ <meta http-equiv="Expires" content="0" />
+ <title>SSE Inspection Test Page in iframe</title>
+ </head>
+ <body>
+ <h1>SSE Inspection Test Page in Iframe</h1>
+ <script type="text/javascript">
+ "use strict";
+
+ /* exported openConnection */
+ function openConnection() {
+ return new Promise(resolve => {
+ const es = new EventSource("sse_backend.sjs");
+ es.onmessage = function (e) {
+ es.close();
+ resolve();
+ };
+ });
+ }
+ </script>
+ </body>
+</html>
diff --git a/devtools/shared/commands/resource/tests/style_document.css b/devtools/shared/commands/resource/tests/style_document.css
new file mode 100644
index 0000000000..aa54533924
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/style_document.css
@@ -0,0 +1 @@
+body { margin: 1px; }
diff --git a/devtools/shared/commands/resource/tests/style_document.html b/devtools/shared/commands/resource/tests/style_document.html
new file mode 100644
index 0000000000..deaf6c4248
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/style_document.html
@@ -0,0 +1,22 @@
+<!DOCTYPE HTML>
+<html>
+ <head>
+ <meta charset="utf8">
+ <title>Test style document</title>
+ <!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+ <style>
+ body { color: lime; }
+ </style>
+ <link href="style_document.css" rel="stylesheet">
+ <script>
+ "use strict";
+ const s = new CSSStyleSheet();
+ s.replaceSync("body { background-color: blue }");
+ document.adoptedStyleSheets.push(s);
+ </script>
+ </head>
+ <body>
+ <iframe src="https://example.org/browser/devtools/shared/commands/resource/tests/style_iframe.html"></iframe>
+ </body>
+</html>
diff --git a/devtools/shared/commands/resource/tests/style_iframe.css b/devtools/shared/commands/resource/tests/style_iframe.css
new file mode 100644
index 0000000000..30e7ae802b
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/style_iframe.css
@@ -0,0 +1 @@
+body { padding: 1px; }
diff --git a/devtools/shared/commands/resource/tests/style_iframe.html b/devtools/shared/commands/resource/tests/style_iframe.html
new file mode 100644
index 0000000000..11ad9f785b
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/style_iframe.html
@@ -0,0 +1,15 @@
+<!DOCTYPE HTML>
+<html>
+ <head>
+ <meta charset="utf8">
+ <title>Test style iframe document</title>
+ <!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+ <style>
+ body { background-color: pink; }
+ </style>
+ <link href="style_iframe.css" rel="stylesheet" type="text/css"/>
+ </head>
+ <body>
+ </body>
+</html>
diff --git a/devtools/shared/commands/resource/tests/stylesheets-nested-iframes.html b/devtools/shared/commands/resource/tests/stylesheets-nested-iframes.html
new file mode 100644
index 0000000000..eb6c371867
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/stylesheets-nested-iframes.html
@@ -0,0 +1,27 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>StyleSheetsActor iframe test</title>
+ <style>
+ p {
+ padding: 1em;
+ }
+ </style>
+</head>
+<body>
+ <p>A test page with nested iframes</p>
+ <iframe></iframe>
+ <script type="application/javascript">
+ "use strict";
+
+ const iframe = document.querySelector("iframe");
+ let i = parseInt(location.href.split("?")[1], 10) || 1;
+
+ // The frame can't have the same src URL as any of its ancestors.
+ // This will not infinitely recurse because a frame won't get a content
+ // document once it's nested deeply enough.
+ iframe.src = location.href.split("?")[0] + "?" + (++i);
+ </script>
+</body>
+</html>
diff --git a/devtools/shared/commands/resource/tests/test_image.png b/devtools/shared/commands/resource/tests/test_image.png
new file mode 100644
index 0000000000..769c636340
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/test_image.png
Binary files differ
diff --git a/devtools/shared/commands/resource/tests/test_service_worker.js b/devtools/shared/commands/resource/tests/test_service_worker.js
new file mode 100644
index 0000000000..aabc3fda0f
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/test_service_worker.js
@@ -0,0 +1,11 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// We don't need any computation in the worker,
+// but at least register a fetch listener so that
+// we force instantiating the SW when loading the page.
+self.onfetch = function (event) {
+ // do nothing.
+};
diff --git a/devtools/shared/commands/resource/tests/test_worker.js b/devtools/shared/commands/resource/tests/test_worker.js
new file mode 100644
index 0000000000..60ccc6d52b
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/test_worker.js
@@ -0,0 +1,15 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+console.log("[WORKER] started", globalThis.location.toString(), globalThis);
+
+globalThis.onmessage = function (e) {
+ const { type, message } = e.data;
+
+ if (type === "log-in-worker") {
+ // Printing `e` so we can check that we have an object and not a stringified version
+ console.log("[WORKER]", message, e);
+ }
+};
diff --git a/devtools/shared/commands/resource/tests/websocket_backend_wsh.py b/devtools/shared/commands/resource/tests/websocket_backend_wsh.py
new file mode 100644
index 0000000000..170f15fe6c
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/websocket_backend_wsh.py
@@ -0,0 +1,20 @@
+from mod_pywebsocket import msgutil
+
+
+def web_socket_do_extra_handshake(request):
+ pass
+
+
+def web_socket_transfer_data(request):
+ while not request.client_terminated:
+ resp = msgutil.receive_message(request)
+ msgutil.send_message(request, resp)
+
+
+def web_socket_passive_closing_handshake(request):
+ # If we use `pass` here, the `payload` of `frameReceived` which will be happened
+ # of communication of closing will be `\u0003è`. In order to make the `payload`
+ # to be empty string, return code and reason explicitly.
+ code = None
+ reason = None
+ return code, reason
diff --git a/devtools/shared/commands/resource/tests/websocket_frontend.html b/devtools/shared/commands/resource/tests/websocket_frontend.html
new file mode 100644
index 0000000000..7efe11f9eb
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/websocket_frontend.html
@@ -0,0 +1,45 @@
+<!-- Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ -->
+<!doctype HTML>
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <title>Websocket Inspection Test Page</title>
+ </head>
+ <body>
+ <h1>Websocket Inspection Test Page</h1>
+ <script type="text/javascript">
+ /* exported openConnection, closeConnection, sendData */
+ "use strict";
+
+ let webSocket;
+ function openConnection() {
+ return new Promise(resolve => {
+ webSocket = new WebSocket(
+ "wss://example.com/browser/devtools/shared/commands/resource/tests/websocket_backend"
+ );
+ webSocket.onopen = () => {
+ resolve();
+ };
+ });
+ }
+
+ function closeConnection() {
+ return new Promise(resolve => {
+ webSocket.onclose = () => {
+ resolve();
+ };
+ webSocket.close();
+ })
+ }
+
+ function sendData(payload) {
+ webSocket.send(payload);
+ }
+ </script>
+ <iframe id="in-process-frame"
+ src="https://example.com/browser/devtools/shared/commands/resource/tests/websocket_frontend_iframe.html"></iframe>
+ <iframe id="out-process-frame"
+ src="https://example.org/browser/devtools/shared/commands/resource/tests/websocket_frontend_iframe.html"></iframe>
+ </body>
+</html>
diff --git a/devtools/shared/commands/resource/tests/websocket_frontend_iframe.html b/devtools/shared/commands/resource/tests/websocket_frontend_iframe.html
new file mode 100644
index 0000000000..e18576f911
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/websocket_frontend_iframe.html
@@ -0,0 +1,41 @@
+<!-- Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ -->
+<!doctype HTML>
+<html>
+ <head>
+ <meta charset="utf-8"/>
+ <title>Websocket Inspection Test Page</title>
+ </head>
+ <body>
+ <h1>Websocket Inspection Test Page</h1>
+ <script type="text/javascript">
+ /* exported openConnection, closeConnection, sendData */
+ "use strict";
+
+ let webSocket;
+ function openConnection() {
+ return new Promise(resolve => {
+ webSocket = new WebSocket(
+ "wss://example.com/browser/devtools/shared/commands/resource/tests/websocket_backend"
+ );
+ webSocket.onopen = () => {
+ resolve();
+ };
+ });
+ }
+
+ function closeConnection() {
+ return new Promise(resolve => {
+ webSocket.onclose = () => {
+ resolve();
+ };
+ webSocket.close();
+ })
+ }
+
+ function sendData(payload) {
+ webSocket.send(payload);
+ }
+ </script>
+ </body>
+</html>
diff --git a/devtools/shared/commands/resource/tests/worker-sources.js b/devtools/shared/commands/resource/tests/worker-sources.js
new file mode 100644
index 0000000000..dcf2ed8031
--- /dev/null
+++ b/devtools/shared/commands/resource/tests/worker-sources.js
@@ -0,0 +1,2 @@
+/* eslint-disable */
+function workerSource() {}
diff --git a/devtools/shared/commands/resource/transformers/console-messages.js b/devtools/shared/commands/resource/transformers/console-messages.js
new file mode 100644
index 0000000000..9c8ca51f04
--- /dev/null
+++ b/devtools/shared/commands/resource/transformers/console-messages.js
@@ -0,0 +1,23 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+// eslint-disable-next-line mozilla/reject-some-requires
+loader.lazyRequireGetter(
+ this,
+ "getAdHocFrontOrPrimitiveGrip",
+ "resource://devtools/client/fronts/object.js",
+ true
+);
+
+module.exports = function ({ resource, targetFront }) {
+ if (Array.isArray(resource.message.arguments)) {
+ // We might need to create fronts for each of the message arguments.
+ resource.message.arguments = resource.message.arguments.map(arg =>
+ getAdHocFrontOrPrimitiveGrip(arg, targetFront)
+ );
+ }
+ return resource;
+};
diff --git a/devtools/shared/commands/resource/transformers/error-messages.js b/devtools/shared/commands/resource/transformers/error-messages.js
new file mode 100644
index 0000000000..2b71f5b7ca
--- /dev/null
+++ b/devtools/shared/commands/resource/transformers/error-messages.js
@@ -0,0 +1,31 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+// eslint-disable-next-line mozilla/reject-some-requires
+loader.lazyRequireGetter(
+ this,
+ "getAdHocFrontOrPrimitiveGrip",
+ "resource://devtools/client/fronts/object.js",
+ true
+);
+
+module.exports = function ({ resource, targetFront }) {
+ if (resource?.pageError?.errorMessage) {
+ resource.pageError.errorMessage = getAdHocFrontOrPrimitiveGrip(
+ resource.pageError.errorMessage,
+ targetFront
+ );
+ }
+
+ if (resource?.pageError?.exception) {
+ resource.pageError.exception = getAdHocFrontOrPrimitiveGrip(
+ resource.pageError.exception,
+ targetFront
+ );
+ }
+
+ return resource;
+};
diff --git a/devtools/shared/commands/resource/transformers/moz.build b/devtools/shared/commands/resource/transformers/moz.build
new file mode 100644
index 0000000000..5b0b94853a
--- /dev/null
+++ b/devtools/shared/commands/resource/transformers/moz.build
@@ -0,0 +1,16 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DevToolsModules(
+ "console-messages.js",
+ "error-messages.js",
+ "network-events.js",
+ "storage-cache.js",
+ "storage-cookie.js",
+ "storage-extension.js",
+ "storage-indexed-db.js",
+ "storage-local-storage.js",
+ "storage-session-storage.js",
+ "thread-states.js",
+)
diff --git a/devtools/shared/commands/resource/transformers/network-events.js b/devtools/shared/commands/resource/transformers/network-events.js
new file mode 100644
index 0000000000..d7f757d706
--- /dev/null
+++ b/devtools/shared/commands/resource/transformers/network-events.js
@@ -0,0 +1,16 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const {
+ getUrlDetails,
+ // eslint-disable-next-line mozilla/reject-some-requires
+} = require("resource://devtools/client/netmonitor/src/utils/request-utils.js");
+
+module.exports = function ({ resource }) {
+ resource.urlDetails = getUrlDetails(resource.url);
+ resource.startedMs = Date.parse(resource.startedDateTime);
+ return resource;
+};
diff --git a/devtools/shared/commands/resource/transformers/storage-cache.js b/devtools/shared/commands/resource/transformers/storage-cache.js
new file mode 100644
index 0000000000..245d892041
--- /dev/null
+++ b/devtools/shared/commands/resource/transformers/storage-cache.js
@@ -0,0 +1,22 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const {
+ TYPES: { CACHE_STORAGE },
+} = require("resource://devtools/shared/commands/resource/resource-command.js");
+
+const { Front, types } = require("resource://devtools/shared/protocol.js");
+
+module.exports = function ({ resource, watcherFront, targetFront }) {
+ if (!(resource instanceof Front) && watcherFront) {
+ // instantiate front for local storage
+ resource = types.getType("Cache").read(resource, targetFront);
+ resource.resourceType = CACHE_STORAGE;
+ resource.resourceKey = "Cache";
+ }
+
+ return resource;
+};
diff --git a/devtools/shared/commands/resource/transformers/storage-cookie.js b/devtools/shared/commands/resource/transformers/storage-cookie.js
new file mode 100644
index 0000000000..23e221672b
--- /dev/null
+++ b/devtools/shared/commands/resource/transformers/storage-cookie.js
@@ -0,0 +1,26 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const {
+ TYPES: { COOKIE },
+} = require("resource://devtools/shared/commands/resource/resource-command.js");
+
+const { Front, types } = require("resource://devtools/shared/protocol.js");
+
+module.exports = function ({ resource, watcherFront, targetFront }) {
+ if (!(resource instanceof Front) && watcherFront) {
+ const { innerWindowId } = resource;
+
+ // it's safe to instantiate the front now, so we do it.
+ resource = types.getType("cookies").read(resource, targetFront);
+ resource.resourceType = COOKIE;
+ resource.resourceId = `${COOKIE}-${targetFront.browsingContextID}`;
+ resource.resourceKey = "cookies";
+ resource.innerWindowId = innerWindowId;
+ }
+
+ return resource;
+};
diff --git a/devtools/shared/commands/resource/transformers/storage-extension.js b/devtools/shared/commands/resource/transformers/storage-extension.js
new file mode 100644
index 0000000000..3e40bdd6d0
--- /dev/null
+++ b/devtools/shared/commands/resource/transformers/storage-extension.js
@@ -0,0 +1,26 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const {
+ TYPES: { EXTENSION_STORAGE },
+} = require("resource://devtools/shared/commands/resource/resource-command.js");
+
+const { Front, types } = require("resource://devtools/shared/protocol.js");
+
+module.exports = function ({ resource, watcherFront, targetFront }) {
+ if (!(resource instanceof Front) && watcherFront) {
+ const { innerWindowId } = resource;
+
+ // it's safe to instantiate the front now, so we do it.
+ resource = types.getType("extensionStorage").read(resource, targetFront);
+ resource.resourceType = EXTENSION_STORAGE;
+ resource.resourceId = `${EXTENSION_STORAGE}-${targetFront.browsingContextID}`;
+ resource.resourceKey = "extensionStorage";
+ resource.innerWindowId = innerWindowId;
+ }
+
+ return resource;
+};
diff --git a/devtools/shared/commands/resource/transformers/storage-indexed-db.js b/devtools/shared/commands/resource/transformers/storage-indexed-db.js
new file mode 100644
index 0000000000..8021719070
--- /dev/null
+++ b/devtools/shared/commands/resource/transformers/storage-indexed-db.js
@@ -0,0 +1,26 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const {
+ TYPES: { INDEXED_DB },
+} = require("resource://devtools/shared/commands/resource/resource-command.js");
+
+const { Front, types } = require("resource://devtools/shared/protocol.js");
+
+module.exports = function ({ resource, watcherFront, targetFront }) {
+ if (!(resource instanceof Front) && watcherFront) {
+ const { innerWindowId } = resource;
+
+ // it's safe to instantiate the front now, so we do it.
+ resource = types.getType("indexedDB").read(resource, targetFront);
+ resource.resourceType = INDEXED_DB;
+ resource.resourceId = `${INDEXED_DB}-${targetFront.browsingContextID}`;
+ resource.resourceKey = "indexedDB";
+ resource.innerWindowId = innerWindowId;
+ }
+
+ return resource;
+};
diff --git a/devtools/shared/commands/resource/transformers/storage-local-storage.js b/devtools/shared/commands/resource/transformers/storage-local-storage.js
new file mode 100644
index 0000000000..13488723f3
--- /dev/null
+++ b/devtools/shared/commands/resource/transformers/storage-local-storage.js
@@ -0,0 +1,22 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const {
+ TYPES: { LOCAL_STORAGE },
+} = require("resource://devtools/shared/commands/resource/resource-command.js");
+
+const { Front, types } = require("resource://devtools/shared/protocol.js");
+
+module.exports = function ({ resource, watcherFront, targetFront }) {
+ if (!(resource instanceof Front) && watcherFront) {
+ // instantiate front for local storage
+ resource = types.getType("localStorage").read(resource, targetFront);
+ resource.resourceType = LOCAL_STORAGE;
+ resource.resourceKey = "localStorage";
+ }
+
+ return resource;
+};
diff --git a/devtools/shared/commands/resource/transformers/storage-session-storage.js b/devtools/shared/commands/resource/transformers/storage-session-storage.js
new file mode 100644
index 0000000000..ab9f1361c8
--- /dev/null
+++ b/devtools/shared/commands/resource/transformers/storage-session-storage.js
@@ -0,0 +1,22 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const {
+ TYPES: { SESSION_STORAGE },
+} = require("resource://devtools/shared/commands/resource/resource-command.js");
+
+const { Front, types } = require("resource://devtools/shared/protocol.js");
+
+module.exports = function ({ resource, watcherFront, targetFront }) {
+ if (!(resource instanceof Front) && watcherFront) {
+ // instantiate front for session storage
+ resource = types.getType("sessionStorage").read(resource, targetFront);
+ resource.resourceType = SESSION_STORAGE;
+ resource.resourceKey = "sessionStorage";
+ }
+
+ return resource;
+};
diff --git a/devtools/shared/commands/resource/transformers/thread-states.js b/devtools/shared/commands/resource/transformers/thread-states.js
new file mode 100644
index 0000000000..1564585b36
--- /dev/null
+++ b/devtools/shared/commands/resource/transformers/thread-states.js
@@ -0,0 +1,32 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const { Front, types } = require("resource://devtools/shared/protocol.js");
+
+module.exports = function ({ resource, watcherFront, targetFront }) {
+ // only "paused" have a frame attribute, and legacy listeners are already passing a FrameFront
+ if (resource.frame && !(resource.frame instanceof Front)) {
+ // Use ThreadFront as parent as debugger's commands.js expects FrameFront to be children
+ // of the ThreadFront.
+ resource.frame = types
+ .getType("frame")
+ .read(resource.frame, targetFront.threadFront);
+ }
+
+ // If we are using server side request (i.e. watcherFront is defined)
+ // Fake paused and resumed events as the thread front depends on them.
+ // We can't emit "EventEmitter" events, as ThreadFront uses `Front.before`
+ // to listen for paused and resumed. ("before" is part of protocol.js Front and not part of EventEmitter)
+ if (watcherFront) {
+ if (resource.state == "paused") {
+ targetFront.threadFront._beforePaused(resource);
+ } else if (resource.state == "resumed") {
+ targetFront.threadFront._beforeResumed(resource);
+ }
+ }
+
+ return resource;
+};
diff --git a/devtools/shared/commands/root-resource/moz.build b/devtools/shared/commands/root-resource/moz.build
new file mode 100644
index 0000000000..2bf7204d1f
--- /dev/null
+++ b/devtools/shared/commands/root-resource/moz.build
@@ -0,0 +1,7 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DevToolsModules(
+ "root-resource-command.js",
+)
diff --git a/devtools/shared/commands/root-resource/root-resource-command.js b/devtools/shared/commands/root-resource/root-resource-command.js
new file mode 100644
index 0000000000..1071d1bcb1
--- /dev/null
+++ b/devtools/shared/commands/root-resource/root-resource-command.js
@@ -0,0 +1,348 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const { throttle } = require("resource://devtools/shared/throttle.js");
+
+class RootResourceCommand {
+ /**
+ * This class helps retrieving existing and listening to "root" resources.
+ *
+ * This is a fork of ResourceCommand, but specific to context-less
+ * resources which can be listened to right away when connecting to the RDP server.
+ *
+ * The main difference in term of implementation is that:
+ * - we receive a root front as constructor argument (instead of `commands` object)
+ * - we only listen for RDP events on the Root actor (instead of watcher and target actors)
+ * - there is no legacy listener support
+ * - there is no resource transformers
+ * - there is a lot of logic around targets that is removed here.
+ *
+ * See ResourceCommand for comments and jsdoc.
+ *
+ * TODO Bug 1758530 - Investigate sharing code with ResourceCommand instead of forking.
+ *
+ * @param object commands
+ * The commands object with all interfaces defined from devtools/shared/commands/
+ * @param object rootFront
+ * Front for the Root actor.
+ */
+ constructor({ commands, rootFront }) {
+ this.rootFront = rootFront ? rootFront : commands.client.mainRoot;
+
+ this._onResourceAvailable = this._onResourceAvailable.bind(this);
+ this._onResourceDestroyed = this._onResourceDestroyed.bind(this);
+
+ this._watchers = [];
+
+ this._pendingWatchers = new Set();
+
+ this._cache = [];
+ this._listenedResources = new Set();
+
+ this._processingExistingResources = new Set();
+
+ this._notifyWatchers = this._notifyWatchers.bind(this);
+ this._throttledNotifyWatchers = throttle(this._notifyWatchers, 100);
+ }
+
+ getAllResources(resourceType) {
+ return this._cache.filter(r => r.resourceType === resourceType);
+ }
+
+ getResourceById(resourceType, resourceId) {
+ return this._cache.find(
+ r => r.resourceType === resourceType && r.resourceId === resourceId
+ );
+ }
+
+ async watchResources(resources, options) {
+ const {
+ onAvailable,
+ onUpdated,
+ onDestroyed,
+ ignoreExistingResources = false,
+ } = options;
+
+ if (typeof onAvailable !== "function") {
+ throw new Error(
+ "RootResourceCommand.watchResources expects an onAvailable function as argument"
+ );
+ }
+
+ for (const type of resources) {
+ if (!this._isValidResourceType(type)) {
+ throw new Error(
+ `RootResourceCommand.watchResources invoked with an unknown type: "${type}"`
+ );
+ }
+ }
+
+ const pendingWatcher = {
+ resources,
+ onAvailable,
+ };
+ this._pendingWatchers.add(pendingWatcher);
+
+ if (!this._listenerRegistered) {
+ this._listenerRegistered = true;
+ this.rootFront.on("resource-available-form", this._onResourceAvailable);
+ this.rootFront.on("resource-destroyed-form", this._onResourceDestroyed);
+ }
+
+ const promises = [];
+ for (const resource of resources) {
+ promises.push(this._startListening(resource));
+ }
+ await Promise.all(promises);
+
+ this._notifyWatchers();
+
+ this._pendingWatchers.delete(pendingWatcher);
+
+ const watchedResources = pendingWatcher.resources;
+
+ if (!watchedResources.length) {
+ return;
+ }
+
+ this._watchers.push({
+ resources: watchedResources,
+ onAvailable,
+ onUpdated,
+ onDestroyed,
+ pendingEvents: [],
+ });
+
+ if (!ignoreExistingResources) {
+ await this._forwardExistingResources(watchedResources, onAvailable);
+ }
+ }
+
+ unwatchResources(resources, options) {
+ const { onAvailable } = options;
+
+ if (typeof onAvailable !== "function") {
+ throw new Error(
+ "RootResourceCommand.unwatchResources expects an onAvailable function as argument"
+ );
+ }
+
+ for (const type of resources) {
+ if (!this._isValidResourceType(type)) {
+ throw new Error(
+ `RootResourceCommand.unwatchResources invoked with an unknown type: "${type}"`
+ );
+ }
+ }
+
+ const allWatchers = [...this._watchers, ...this._pendingWatchers];
+ for (const watcherEntry of allWatchers) {
+ if (watcherEntry.onAvailable == onAvailable) {
+ watcherEntry.resources = watcherEntry.resources.filter(resourceType => {
+ return !resources.includes(resourceType);
+ });
+ }
+ }
+ this._watchers = this._watchers.filter(entry => {
+ return !!entry.resources.length;
+ });
+
+ for (const resource of resources) {
+ const isResourceWatched = allWatchers.some(watcherEntry =>
+ watcherEntry.resources.includes(resource)
+ );
+
+ if (!isResourceWatched && this._listenedResources.has(resource)) {
+ this._stopListening(resource);
+ }
+ }
+ }
+
+ clearResources(resourceTypes) {
+ if (!Array.isArray(resourceTypes)) {
+ throw new Error("clearResources expects an array of resource types");
+ }
+ // Clear the cached resources of the type.
+ this._cache = this._cache.filter(
+ cachedResource => !resourceTypes.includes(cachedResource.resourceType)
+ );
+
+ if (resourceTypes.length) {
+ this.rootFront.clearResources(resourceTypes);
+ }
+ }
+
+ async waitForNextResource(
+ resourceType,
+ { ignoreExistingResources = false, predicate } = {}
+ ) {
+ predicate = predicate || (resource => !!resource);
+
+ let resolve;
+ const promise = new Promise(r => (resolve = r));
+ const onAvailable = async resources => {
+ const matchingResource = resources.find(resource => predicate(resource));
+ if (matchingResource) {
+ this.unwatchResources([resourceType], { onAvailable });
+ resolve(matchingResource);
+ }
+ };
+
+ await this.watchResources([resourceType], {
+ ignoreExistingResources,
+ onAvailable,
+ });
+ return { onResource: promise };
+ }
+
+ async _onResourceAvailable(resources) {
+ for (const resource of resources) {
+ const { resourceType } = resource;
+
+ resource.isAlreadyExistingResource =
+ this._processingExistingResources.has(resourceType);
+
+ this._queueResourceEvent("available", resourceType, resource);
+
+ this._cache.push(resource);
+ }
+
+ this._throttledNotifyWatchers();
+ }
+
+ async _onResourceDestroyed(resources) {
+ for (const resource of resources) {
+ const { resourceType, resourceId } = resource;
+
+ let index = -1;
+ if (resourceId) {
+ index = this._cache.findIndex(
+ cachedResource =>
+ cachedResource.resourceType == resourceType &&
+ cachedResource.resourceId == resourceId
+ );
+ } else {
+ index = this._cache.indexOf(resource);
+ }
+ if (index >= 0) {
+ this._cache.splice(index, 1);
+ } else {
+ console.warn(
+ `Resource ${resourceId || ""} of ${resourceType} was not found.`
+ );
+ }
+
+ this._queueResourceEvent("destroyed", resourceType, resource);
+ }
+ this._throttledNotifyWatchers();
+ }
+
+ _queueResourceEvent(callbackType, resourceType, update) {
+ for (const { resources, pendingEvents } of this._watchers) {
+ if (!resources.includes(resourceType)) {
+ continue;
+ }
+ if (pendingEvents.length) {
+ const lastEvent = pendingEvents[pendingEvents.length - 1];
+ if (lastEvent.callbackType == callbackType) {
+ lastEvent.updates.push(update);
+ continue;
+ }
+ }
+ pendingEvents.push({
+ callbackType,
+ updates: [update],
+ });
+ }
+ }
+
+ _notifyWatchers() {
+ for (const watcherEntry of this._watchers) {
+ const { onAvailable, onDestroyed, pendingEvents } = watcherEntry;
+ watcherEntry.pendingEvents = [];
+
+ for (const { callbackType, updates } of pendingEvents) {
+ try {
+ if (callbackType == "available") {
+ onAvailable(updates, { areExistingResources: false });
+ } else if (callbackType == "destroyed" && onDestroyed) {
+ onDestroyed(updates);
+ }
+ } catch (e) {
+ console.error(
+ "Exception while calling a RootResourceCommand",
+ callbackType,
+ "callback",
+ ":",
+ e
+ );
+ }
+ }
+ }
+ }
+
+ _isValidResourceType(type) {
+ return this.ALL_TYPES.includes(type);
+ }
+
+ async _startListening(resourceType) {
+ if (this._listenedResources.has(resourceType)) {
+ return;
+ }
+ this._listenedResources.add(resourceType);
+
+ this._processingExistingResources.add(resourceType);
+
+ // For now, if the server doesn't support the resource type
+ // act as if we were listening, but do nothing.
+ // Calling watchResources/unwatchResources will work fine,
+ // but no resource will be notified.
+ if (this.rootFront.traits.resources?.[resourceType]) {
+ await this.rootFront.watchResources([resourceType]);
+ } else {
+ console.warn(
+ `Ignored watchRequest, resourceType "${resourceType}" not found in rootFront.traits.resources`
+ );
+ }
+ this._processingExistingResources.delete(resourceType);
+ }
+
+ async _forwardExistingResources(resourceTypes, onAvailable) {
+ const existingResources = this._cache.filter(resource =>
+ resourceTypes.includes(resource.resourceType)
+ );
+ if (existingResources.length) {
+ await onAvailable(existingResources, { areExistingResources: true });
+ }
+ }
+
+ _stopListening(resourceType) {
+ if (!this._listenedResources.has(resourceType)) {
+ throw new Error(
+ `Stopped listening for resource '${resourceType}' that isn't being listened to`
+ );
+ }
+ this._listenedResources.delete(resourceType);
+
+ this._cache = this._cache.filter(
+ cachedResource => cachedResource.resourceType !== resourceType
+ );
+
+ if (
+ !this.rootFront.isDestroyed() &&
+ this.rootFront.traits.resources?.[resourceType]
+ ) {
+ this.rootFront.unwatchResources([resourceType]);
+ }
+ }
+}
+
+RootResourceCommand.TYPES = RootResourceCommand.prototype.TYPES = {
+ EXTENSIONS_BGSCRIPT_STATUS: "extensions-backgroundscript-status",
+};
+RootResourceCommand.ALL_TYPES = RootResourceCommand.prototype.ALL_TYPES =
+ Object.values(RootResourceCommand.TYPES);
+module.exports = RootResourceCommand;
diff --git a/devtools/shared/commands/script/moz.build b/devtools/shared/commands/script/moz.build
new file mode 100644
index 0000000000..2387c7e63b
--- /dev/null
+++ b/devtools/shared/commands/script/moz.build
@@ -0,0 +1,10 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DevToolsModules(
+ "script-command.js",
+)
+
+if CONFIG["MOZ_BUILD_APP"] != "mobile/android":
+ BROWSER_CHROME_MANIFESTS += ["tests/browser.ini"]
diff --git a/devtools/shared/commands/script/script-command.js b/devtools/shared/commands/script/script-command.js
new file mode 100644
index 0000000000..93917944d5
--- /dev/null
+++ b/devtools/shared/commands/script/script-command.js
@@ -0,0 +1,149 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const {
+ getAdHocFrontOrPrimitiveGrip,
+ // eslint-disable-next-line mozilla/reject-some-requires
+} = require("resource://devtools/client/fronts/object.js");
+
+class ScriptCommand {
+ constructor({ commands }) {
+ this._commands = commands;
+ }
+
+ /**
+ * Execute a JavaScript expression.
+ *
+ * @param {String} expression: The code you want to evaluate.
+ * @param {Object} options: Options for evaluation:
+ * @param {Object} options.frameActor: a FrameActor ID. The actor holds a reference to
+ * a Debugger.Frame. This option allows you to evaluate the string in the frame
+ * of the given FrameActor.
+ * @param {String} options.url: the url to evaluate the script as. Defaults to "debugger eval code".
+ * @param {TargetFront} options.selectedTargetFront: When passed, the expression will be
+ * evaluated in the context of the target (as opposed to the default, top-level one).
+ * @param {String} options.selectedNodeActor: A NodeActor ID that may be used by helper
+ * functions that can reference the currently selected node in the Inspector, like $0.
+ * @param {String} options.selectedObjectActor: the actorID of a given objectActor.
+ * This is used by context menu entries to get a reference to an object, in order
+ * to perform some operation on it (copy it, store it as a global variable, …).
+ * @param {Number} options.innerWindowID: An optional window id to be used for the evaluation,
+ * instead of the regular webConsoleActor.evalWindow.
+ * This is used by functions that may want to evaluate in a different window (for
+ * example a non-remote iframe), like getting the elements of a given document.
+ * @param {object} options.mapped: An optional object indicating if the original expression
+ * entered by the users have been modified
+ * @param {boolean} options.mapped.await: true if the expression was a top-level await
+ * expression that was wrapped in an async-iife
+ *
+ * @return {Promise}: A promise that resolves with the response.
+ */
+ async execute(expression, options = {}) {
+ const {
+ selectedObjectActor,
+ selectedNodeActor,
+ frameActor,
+ selectedTargetFront,
+ } = options;
+
+ // Retrieve the right WebConsole front that relates either to (by order of priority):
+ // - the currently selected target in the context selector
+ // (selectedTargetFront argument),
+ // - the object picked in the console (when using store as global) (selectedObjectActor),
+ // - the currently selected Node in the inspector (selectedNodeActor),
+ // - the currently selected frame in the debugger (when paused) (frameActor),
+ // - the currently selected target in the iframe dropdown
+ // (selectedTargetFront from the TargetCommand)
+ let targetFront = this._commands.targetCommand.selectedTargetFront;
+
+ const selectedActor =
+ selectedObjectActor || selectedNodeActor || frameActor;
+
+ if (selectedTargetFront) {
+ targetFront = selectedTargetFront;
+ } else if (selectedActor) {
+ const selectedFront = this._commands.client.getFrontByID(selectedActor);
+ if (selectedFront) {
+ targetFront = selectedFront.targetFront;
+ }
+ }
+
+ const consoleFront = await targetFront.getFront("console");
+
+ // We call `evaluateJSAsync` RDP request, which immediately returns a simple `resultID`,
+ // for which we later receive a related `evaluationResult` RDP event, with the same resultID.
+ // The evaluation result will be contained in this RDP event.
+ let resultID;
+ const response = await new Promise(resolve => {
+ const offEvaluationResult = consoleFront.on(
+ "evaluationResult",
+ async packet => {
+ // In some cases, the evaluationResult event can be received before the call to
+ // evaluationJSAsync completes. So make sure to wait for the corresponding promise
+ // before handling the evaluationResult event.
+ await onEvaluateJSAsync;
+
+ if (packet.resultID === resultID) {
+ resolve(packet);
+ offEvaluationResult();
+ }
+ }
+ );
+
+ const onEvaluateJSAsync = consoleFront
+ .evaluateJSAsync({
+ text: expression,
+ eager: options.eager,
+ frameActor,
+ innerWindowID: options.innerWindowID,
+ mapped: options.mapped,
+ selectedNodeActor,
+ selectedObjectActor,
+ url: options.url,
+ })
+ .then(packet => {
+ resultID = packet.resultID;
+ });
+ });
+
+ // `response` is the packet sent via `evaluationResult` RDP event.
+ if (response.error) {
+ throw response;
+ }
+
+ if (response.result) {
+ response.result = getAdHocFrontOrPrimitiveGrip(
+ response.result,
+ consoleFront
+ );
+ }
+
+ if (response.helperResult?.object) {
+ response.helperResult.object = getAdHocFrontOrPrimitiveGrip(
+ response.helperResult.object,
+ consoleFront
+ );
+ }
+
+ if (response.exception) {
+ response.exception = getAdHocFrontOrPrimitiveGrip(
+ response.exception,
+ consoleFront
+ );
+ }
+
+ if (response.exceptionMessage) {
+ response.exceptionMessage = getAdHocFrontOrPrimitiveGrip(
+ response.exceptionMessage,
+ consoleFront
+ );
+ }
+
+ return response;
+ }
+}
+
+module.exports = ScriptCommand;
diff --git a/devtools/shared/commands/script/tests/browser.ini b/devtools/shared/commands/script/tests/browser.ini
new file mode 100644
index 0000000000..949c54f760
--- /dev/null
+++ b/devtools/shared/commands/script/tests/browser.ini
@@ -0,0 +1,11 @@
+[DEFAULT]
+tags = devtools
+subsuite = devtools
+support-files =
+ !/devtools/client/shared/test/shared-head.js
+ head.js
+
+[browser_script_command_execute_basic.js]
+[browser_script_command_execute_document__proto__.js]
+[browser_script_command_execute_last_result.js]
+[browser_script_command_execute_throw.js]
diff --git a/devtools/shared/commands/script/tests/browser_script_command_execute_basic.js b/devtools/shared/commands/script/tests/browser_script_command_execute_basic.js
new file mode 100644
index 0000000000..e63f55a338
--- /dev/null
+++ b/devtools/shared/commands/script/tests/browser_script_command_execute_basic.js
@@ -0,0 +1,1050 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Testing basic expression evaluation
+const {
+ MAX_AUTOCOMPLETE_ATTEMPTS,
+ MAX_AUTOCOMPLETIONS,
+} = require("resource://devtools/shared/webconsole/js-property-provider.js");
+const {
+ DevToolsServer,
+} = require("resource://devtools/server/devtools-server.js");
+
+add_task(async () => {
+ const tab = await addTab(`data:text/html;charset=utf-8,
+ <!DOCTYPE html>
+ <html dir="ltr" class="class1">
+ <head><title>Testcase</title></head>
+ <script>
+ window.foobarObject = Object.create(
+ null,
+ Object.getOwnPropertyDescriptors({
+ foo: 1,
+ foobar: 2,
+ foobaz: 3,
+ omg: 4,
+ omgfoo: 5,
+ strfoo: "foobarz",
+ omgstr: "foobarz" + "abb".repeat(${DevToolsServer.LONG_STRING_LENGTH} * 2),
+ })
+ );
+
+ window.largeObject1 = Object.create(null);
+ for (let i = 0; i < ${MAX_AUTOCOMPLETE_ATTEMPTS} + 1; i++) {
+ window.largeObject1["a" + i] = i;
+ }
+
+ window.largeObject2 = Object.create(null);
+ for (let i = 0; i < ${MAX_AUTOCOMPLETIONS} * 2; i++) {
+ window.largeObject2["a" + i] = i;
+ }
+
+ var originalExec = RegExp.prototype.exec;
+
+ var promptIterable = { [Symbol.iterator]() { return { next: prompt } } };
+
+ function aliasedTest() {
+ const aliased = "ALIASED";
+ return [0].map(() => aliased)[0];
+ }
+
+ var testMap = new Map([[1, 1], [2, 2], [3, 3], [4, 4]]);
+ var testSet = new Set([1, 2, 3, 4, 5]);
+ var testProxy = new Proxy({}, { getPrototypeOf: prompt });
+ var testArray = [1,2,3];
+ var testInt8Array = new Int8Array([1, 2, 3]);
+ var testArrayBuffer = testInt8Array.buffer;
+ var testDataView = new DataView(testArrayBuffer, 2);
+
+ var testCanvasContext = document.createElement("canvas").getContext("2d");
+
+ var objWithNativeGetter = {};
+ Object.defineProperty(objWithNativeGetter, "print", { get: print });
+ Object.defineProperty(objWithNativeGetter, "Element", { get: Element });
+ Object.defineProperty(objWithNativeGetter, "setAttribute", { get: Element.prototype.setAttribute });
+ Object.defineProperty(objWithNativeGetter, "setClassName", { get: Object.getOwnPropertyDescriptor(Element.prototype, "className").set });
+ Object.defineProperty(objWithNativeGetter, "requestPermission", { get: Notification.requestPermission });
+
+ async function testAsync() { return 10; }
+ async function testAsyncAwait() { await 1; return 10; }
+ async function * testAsyncGen() { return 10; }
+ async function * testAsyncGenAwait() { await 1; return 10; }
+
+ function testFunc() {}
+
+ var testLocale = new Intl.Locale("de-latn-de-u-ca-gregory-co-phonebk-hc-h23-kf-true-kn-false-nu-latn");
+ </script>
+ <body id="body1" class="class2"><h1>Body text</h1></body>
+ </html>`);
+
+ const commands = await CommandsFactory.forTab(tab);
+ await commands.targetCommand.startListening();
+
+ await doSimpleEval(commands);
+ await doWindowEval(commands);
+ await doEvalWithException(commands);
+ await doEvalWithHelper(commands);
+ await doEvalString(commands);
+ await doEvalLongString(commands);
+ await doEvalWithBinding(commands);
+ await forceLexicalInit(commands);
+ await doSimpleEagerEval(commands);
+ await doEagerEvalWithSideEffect(commands);
+ await doEagerEvalWithSideEffectIterator(commands);
+ await doEagerEvalWithSideEffectMonkeyPatched(commands);
+ await doEagerEvalESGetters(commands);
+ await doEagerEvalDOMGetters(commands);
+ await doEagerEvalOtherNativeGetters(commands);
+ await doEagerEvalAsyncFunctions(commands);
+
+ await commands.destroy();
+});
+
+async function doSimpleEval(commands) {
+ info("test eval '2+2'");
+ const response = await commands.scriptCommand.execute("2+2");
+ checkObject(response, {
+ input: "2+2",
+ result: 4,
+ });
+
+ ok(!response.exception, "no eval exception");
+ ok(!response.helperResult, "no helper result");
+}
+
+async function doWindowEval(commands) {
+ info("test eval 'document'");
+ const response = await commands.scriptCommand.execute("document");
+ checkObject(response, {
+ input: "document",
+ result: {
+ type: "object",
+ class: "HTMLDocument",
+ actor: /[a-z]/,
+ },
+ });
+
+ ok(!response.exception, "no eval exception");
+ ok(!response.helperResult, "no helper result");
+}
+
+async function doEvalWithException(commands) {
+ info("test eval with exception");
+ const response = await commands.scriptCommand.execute(
+ "window.doTheImpossible()"
+ );
+ checkObject(response, {
+ input: "window.doTheImpossible()",
+ result: {
+ type: "undefined",
+ },
+ exceptionMessage: /doTheImpossible/,
+ });
+
+ ok(response.exception, "js eval exception");
+ ok(!response.helperResult, "no helper result");
+}
+
+async function doEvalWithHelper(commands) {
+ info("test eval with helper");
+ const response = await commands.scriptCommand.execute("clear()");
+ checkObject(response, {
+ input: "clear()",
+ result: {
+ type: "undefined",
+ },
+ helperResult: { type: "clearOutput" },
+ });
+
+ ok(!response.exception, "no eval exception");
+}
+
+async function doEvalString(commands) {
+ const response = await commands.scriptCommand.execute(
+ "window.foobarObject.strfoo"
+ );
+
+ checkObject(response, {
+ input: "window.foobarObject.strfoo",
+ result: "foobarz",
+ });
+}
+
+async function doEvalLongString(commands) {
+ const response = await commands.scriptCommand.execute(
+ "window.foobarObject.omgstr"
+ );
+
+ const str = await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ function () {
+ return content.wrappedJSObject.foobarObject.omgstr;
+ }
+ );
+
+ const initial = str.substring(0, DevToolsServer.LONG_STRING_INITIAL_LENGTH);
+
+ checkObject(response, {
+ input: "window.foobarObject.omgstr",
+ result: {
+ type: "longString",
+ initial,
+ length: str.length,
+ },
+ });
+}
+
+async function doEvalWithBinding(commands) {
+ const response = await commands.scriptCommand.execute("document;");
+ const documentActor = response.result.actorID;
+
+ info("running a command with _self as document using selectedObjectActor");
+ const selectedObjectSame = await commands.scriptCommand.execute(
+ "_self === document",
+ {
+ selectedObjectActor: documentActor,
+ }
+ );
+ checkObject(selectedObjectSame, {
+ result: true,
+ });
+}
+
+async function forceLexicalInit(commands) {
+ info("test that failed let/const bindings are initialized to undefined");
+
+ const testData = [
+ {
+ stmt: "let foopie = wubbalubadubdub",
+ vars: ["foopie"],
+ },
+ {
+ stmt: "let {z, w={n}=null} = {}",
+ vars: ["z", "w"],
+ },
+ {
+ stmt: "let [a, b, c] = null",
+ vars: ["a", "b", "c"],
+ },
+ {
+ stmt: "const nein1 = rofl, nein2 = copter",
+ vars: ["nein1", "nein2"],
+ },
+ {
+ stmt: "const {ha} = null",
+ vars: ["ha"],
+ },
+ {
+ stmt: "const [haw=[lame]=null] = []",
+ vars: ["haw"],
+ },
+ {
+ stmt: "const [rawr, wat=[lame]=null] = []",
+ vars: ["rawr", "haw"],
+ },
+ {
+ stmt: "let {zzz: xyz=99, zwz: wb} = nexistepas()",
+ vars: ["xyz", "wb"],
+ },
+ {
+ stmt: "let {c3pdoh=101} = null",
+ vars: ["c3pdoh"],
+ },
+ {
+ stmt: "const {...x} = x",
+ vars: ["x"],
+ },
+ {
+ stmt: "const {xx,yy,...rest} = null",
+ vars: ["xx", "yy", "rest"],
+ },
+ ];
+
+ for (const data of testData) {
+ const response = await commands.scriptCommand.execute(data.stmt);
+ checkObject(response, {
+ input: data.stmt,
+ result: { type: "undefined" },
+ });
+ ok(response.exception, "expected exception");
+ for (const varName of data.vars) {
+ const response2 = await commands.scriptCommand.execute(varName);
+ checkObject(response2, {
+ input: varName,
+ result: { type: "undefined" },
+ });
+ ok(!response2.exception, "unexpected exception");
+ }
+ }
+}
+
+async function doSimpleEagerEval(commands) {
+ const testData = [
+ {
+ code: "2+2",
+ result: 4,
+ },
+ {
+ code: "(x => x * 2)(3)",
+ result: 6,
+ },
+ {
+ code: "[1, 2, 3].map(x => x * 2).join()",
+ result: "2,4,6",
+ },
+ {
+ code: `"abc".match(/a./)[0]`,
+ result: "ab",
+ },
+ {
+ code: "aliasedTest()",
+ result: "ALIASED",
+ },
+ {
+ code: "testArray.concat([4,5]).join()",
+ result: "1,2,3,4,5",
+ },
+ {
+ code: "testArray.entries().toString()",
+ result: "[object Array Iterator]",
+ },
+ {
+ code: "testArray.keys().toString()",
+ result: "[object Array Iterator]",
+ },
+ {
+ code: "testArray.values().toString()",
+ result: "[object Array Iterator]",
+ },
+ {
+ code: "testArray.every(x => x < 100)",
+ result: true,
+ },
+ {
+ code: "testArray.some(x => x > 1)",
+ result: true,
+ },
+ {
+ code: "testArray.filter(x => x % 2 == 0).join()",
+ result: "2",
+ },
+ {
+ code: "testArray.find(x => x % 2 == 0)",
+ result: 2,
+ },
+ {
+ code: "testArray.findIndex(x => x % 2 == 0)",
+ result: 1,
+ },
+ {
+ code: "[testArray].flat().join()",
+ result: "1,2,3",
+ },
+ {
+ code: "[testArray].flatMap(x => x).join()",
+ result: "1,2,3",
+ },
+ {
+ code: "testArray.forEach(x => x); testArray.join()",
+ result: "1,2,3",
+ },
+ {
+ code: "testArray.includes(1)",
+ result: true,
+ },
+ {
+ code: "testArray.lastIndexOf(1)",
+ result: 0,
+ },
+ {
+ code: "testArray.map(x => x + 1).join()",
+ result: "2,3,4",
+ },
+ {
+ code: "testArray.reduce((acc,x) => acc + x, 0)",
+ result: 6,
+ },
+ {
+ code: "testArray.reduceRight((acc,x) => acc + x, 0)",
+ result: 6,
+ },
+ {
+ code: "testArray.slice(0,1).join()",
+ result: "1",
+ },
+ {
+ code: "testArray.toReversed().join()",
+ result: "3,2,1",
+ },
+ {
+ code: "testArray.toSorted().join()",
+ result: "1,2,3",
+ },
+ {
+ code: "testArray.toSpliced(0,1).join()",
+ result: "2,3",
+ },
+ {
+ code: "testArray.with(1, 'b').join()",
+ result: "1,b,3",
+ },
+
+ {
+ code: "testInt8Array.entries().toString()",
+ result: "[object Array Iterator]",
+ },
+ {
+ code: "testInt8Array.keys().toString()",
+ result: "[object Array Iterator]",
+ },
+ {
+ code: "testInt8Array.values().toString()",
+ result: "[object Array Iterator]",
+ },
+ {
+ code: "testInt8Array.every(x => x < 100)",
+ result: true,
+ },
+ {
+ code: "testInt8Array.some(x => x > 1)",
+ result: true,
+ },
+ {
+ code: "testInt8Array.filter(x => x % 2 == 0).join()",
+ result: "2",
+ },
+ {
+ code: "testInt8Array.find(x => x % 2 == 0)",
+ result: 2,
+ },
+ {
+ code: "testInt8Array.findIndex(x => x % 2 == 0)",
+ result: 1,
+ },
+ {
+ code: "testInt8Array.forEach(x => x); testInt8Array.join()",
+ result: "1,2,3",
+ },
+ {
+ code: "testInt8Array.includes(1)",
+ result: true,
+ },
+ {
+ code: "testInt8Array.lastIndexOf(1)",
+ result: 0,
+ },
+ {
+ code: "testInt8Array.map(x => x + 1).join()",
+ result: "2,3,4",
+ },
+ {
+ code: "testInt8Array.reduce((acc,x) => acc + x, 0)",
+ result: 6,
+ },
+ {
+ code: "testInt8Array.reduceRight((acc,x) => acc + x, 0)",
+ result: 6,
+ },
+ {
+ code: "testInt8Array.slice(0,1).join()",
+ result: "1",
+ },
+ {
+ code: "testInt8Array.toReversed().join()",
+ skip:
+ typeof Reflect.getPrototypeOf(Int8Array).prototype.toReversed !==
+ "function",
+ result: "3,2,1",
+ },
+ {
+ code: "testInt8Array.toSorted().join()",
+ skip:
+ typeof Reflect.getPrototypeOf(Int8Array).prototype.toSorted !==
+ "function",
+ result: "1,2,3",
+ },
+ {
+ code: "testInt8Array.with(1, 0).join()",
+ skip:
+ typeof Reflect.getPrototypeOf(Int8Array).prototype.with !== "function",
+ result: "1,0,3",
+ },
+ ];
+
+ for (const { code, result, skip } of testData) {
+ if (skip) {
+ info(`Skipping evaluation of ${code}`);
+ continue;
+ }
+
+ info(`Evaluating: ${code}`);
+ const response = await commands.scriptCommand.execute(code, {
+ eager: true,
+ });
+ checkObject(response, {
+ input: code,
+ result,
+ });
+
+ ok(!response.exception, "no eval exception");
+ ok(!response.helperResult, "no helper result");
+ }
+}
+
+async function doEagerEvalWithSideEffect(commands) {
+ const testData = [
+ // Modify environment.
+ "var a = 10; a;",
+
+ // Directly call a funtion with side effect.
+ "prompt();",
+
+ // Call a funtion with side effect inside a scripted function.
+ "(() => { prompt(); })()",
+
+ // Call a funtion with side effect from self-hosted JS function.
+ "[1, 2, 3].map(prompt)",
+
+ // Call a function with Function.prototype.call.
+ "Function.prototype.call.bind(Function.prototype.call)(prompt);",
+
+ // Call a function with Function.prototype.apply.
+ "Function.prototype.apply.bind(Function.prototype.apply)(prompt);",
+
+ // Indirectly call a function with Function.prototype.apply.
+ "Reflect.apply(prompt, null, []);",
+ "'aaaaaaaa'.replace(/(a)(a)(a)(a)(a)(a)(a)(a)/, prompt)",
+
+ // Indirect call on obj[Symbol.iterator]().next.
+ "Array.from(promptIterable)",
+ ];
+
+ for (const code of testData) {
+ const response = await commands.scriptCommand.execute(code, {
+ eager: true,
+ });
+ checkObject(response, {
+ input: code,
+ result: { type: "undefined" },
+ });
+
+ ok(!response.exception, "no eval exception");
+ ok(!response.helperResult, "no helper result");
+ }
+}
+
+async function doEagerEvalWithSideEffectIterator(commands) {
+ // Indirect call on %ArrayIterator%.prototype.next,
+
+ // Create an iterable object that reuses iterator across multiple call.
+ let response = await commands.scriptCommand.execute(`
+var arr = [1, 2, 3];
+var iterator = arr[Symbol.iterator]();
+var iterable = { [Symbol.iterator]() { return iterator; } };
+"ok";
+`);
+ checkObject(response, {
+ result: "ok",
+ });
+ ok(!response.exception, "no eval exception");
+ ok(!response.helperResult, "no helper result");
+
+ const testData = [
+ "Array.from(iterable)",
+ "new Map(iterable)",
+ "new Set(iterable)",
+ ];
+
+ for (const code of testData) {
+ response = await commands.scriptCommand.execute(code, {
+ eager: true,
+ });
+ checkObject(response, {
+ input: code,
+ result: { type: "undefined" },
+ });
+
+ ok(!response.exception, "no eval exception");
+ ok(!response.helperResult, "no helper result");
+ }
+
+ // Verify the iterator's internal state isn't modified.
+ response = await commands.scriptCommand.execute(`[...iterator].join(",")`);
+ checkObject(response, {
+ result: "1,2,3",
+ });
+ ok(!response.exception, "no eval exception");
+ ok(!response.helperResult, "no helper result");
+}
+
+async function doEagerEvalWithSideEffectMonkeyPatched(commands) {
+ // Patch the built-in function without eager evaluation.
+ let response = await commands.scriptCommand.execute(
+ `RegExp.prototype.exec = prompt; "patched"`
+ );
+ checkObject(response, {
+ result: "patched",
+ });
+ ok(!response.exception, "no eval exception");
+ ok(!response.helperResult, "no helper result");
+
+ // Test eager evaluation, where the patched built-in is called internally.
+ // This should be aborted.
+ const code = `"abc".match(/a./)[0]`;
+ response = await commands.scriptCommand.execute(code, { eager: true });
+ checkObject(response, {
+ input: code,
+ result: { type: "undefined" },
+ });
+
+ ok(!response.exception, "no eval exception");
+ ok(!response.helperResult, "no helper result");
+
+ // Undo the patch without eager evaluation.
+ response = await commands.scriptCommand.execute(
+ `RegExp.prototype.exec = originalExec; "unpatched"`
+ );
+ checkObject(response, {
+ result: "unpatched",
+ });
+ ok(!response.exception, "no eval exception");
+ ok(!response.helperResult, "no helper result");
+
+ // Test eager evaluation again, without the patch.
+ // This should be evaluated.
+ response = await commands.scriptCommand.execute(code, { eager: true });
+ checkObject(response, {
+ input: code,
+ result: "ab",
+ });
+}
+
+async function doEagerEvalESGetters(commands) {
+ // [code, expectedResult]
+ const testData = [
+ // ArrayBuffer
+ ["testArrayBuffer.byteLength", 3],
+
+ // DataView
+ ["testDataView.buffer === testArrayBuffer", true],
+ ["testDataView.byteLength", 1],
+ ["testDataView.byteOffset", 2],
+
+ // Error
+ ["typeof new Error().stack", "string"],
+
+ // Function
+ ["typeof testFunc.arguments", "object"],
+ ["typeof testFunc.caller", "object"],
+
+ // Intl.Locale
+ ["testLocale.baseName", "de-Latn-DE"],
+ ["testLocale.calendar", "gregory"],
+ ["testLocale.caseFirst", ""],
+ ["testLocale.collation", "phonebk"],
+ ["testLocale.hourCycle", "h23"],
+ ["testLocale.numeric", false],
+ ["testLocale.numberingSystem", "latn"],
+ ["testLocale.language", "de"],
+ ["testLocale.script", "Latn"],
+ ["testLocale.region", "DE"],
+
+ // Map
+ ["testMap.size", 4],
+
+ // RegExp
+ ["/a/.dotAll", false],
+ ["/a/giy.flags", "giy"],
+ ["/a/g.global", true],
+ ["/a/g.hasIndices", false],
+ ["/a/g.ignoreCase", false],
+ ["/a/g.multiline", false],
+ ["/a/g.source", "a"],
+ ["/a/g.sticky", false],
+ ["/a/g.unicode", false],
+
+ // Set
+ ["testSet.size", 5],
+
+ // Symbol
+ ["Symbol.iterator.description", "Symbol.iterator"],
+
+ // TypedArray
+ ["testInt8Array.buffer === testArrayBuffer", true],
+ ["testInt8Array.byteLength", 3],
+ ["testInt8Array.byteOffset", 0],
+ ["testInt8Array.length", 3],
+ ["testInt8Array[Symbol.toStringTag]", "Int8Array"],
+ ];
+
+ for (const [code, expectedResult] of testData) {
+ const response = await commands.scriptCommand.execute(code, {
+ eager: true,
+ });
+ checkObject(
+ response,
+ {
+ input: code,
+ result: expectedResult,
+ },
+ code
+ );
+
+ ok(!response.exception, "no eval exception");
+ ok(!response.helperResult, "no helper result");
+ }
+
+ // Test RegExp static properties.
+ // Run preparation code here to avoid interference with other tests,
+ // given RegExp static properties are global state.
+ const regexpPreparationCode = `
+/b(c)(d)(e)(f)(g)(h)(i)(j)(k)l/.test("abcdefghijklm")
+`;
+
+ const prepResponse = await commands.scriptCommand.execute(
+ regexpPreparationCode
+ );
+ checkObject(prepResponse, {
+ input: regexpPreparationCode,
+ result: true,
+ });
+
+ ok(!prepResponse.exception, "no eval exception");
+ ok(!prepResponse.helperResult, "no helper result");
+
+ const testDataRegExp = [
+ // RegExp static
+ ["RegExp.input", "abcdefghijklm"],
+ ["RegExp.lastMatch", "bcdefghijkl"],
+ ["RegExp.lastParen", "k"],
+ ["RegExp.leftContext", "a"],
+ ["RegExp.rightContext", "m"],
+ ["RegExp.$1", "c"],
+ ["RegExp.$2", "d"],
+ ["RegExp.$3", "e"],
+ ["RegExp.$4", "f"],
+ ["RegExp.$5", "g"],
+ ["RegExp.$6", "h"],
+ ["RegExp.$7", "i"],
+ ["RegExp.$8", "j"],
+ ["RegExp.$9", "k"],
+ ["RegExp.$_", "abcdefghijklm"], // input
+ ["RegExp['$&']", "bcdefghijkl"], // lastMatch
+ ["RegExp['$+']", "k"], // lastParen
+ ["RegExp['$`']", "a"], // leftContext
+ ["RegExp[`$'`]", "m"], // rightContext
+ ];
+
+ for (const [code, expectedResult] of testDataRegExp) {
+ const response = await commands.scriptCommand.execute(code, {
+ eager: true,
+ });
+ checkObject(
+ response,
+ {
+ input: code,
+ result: expectedResult,
+ },
+ code
+ );
+
+ ok(!response.exception, "no eval exception");
+ ok(!response.helperResult, "no helper result");
+ }
+
+ const testDataWithSideEffect = [
+ // get Object.prototype.__proto__
+ //
+ // This can invoke Proxy getPrototypeOf handler, which can be any native
+ // function, and debugger cannot hook the call.
+ `[].__proto__`,
+ `testProxy.__proto__`,
+ ];
+
+ for (const code of testDataWithSideEffect) {
+ const response = await commands.scriptCommand.execute(code, {
+ eager: true,
+ });
+ checkObject(
+ response,
+ {
+ input: code,
+ result: { type: "undefined" },
+ },
+ code
+ );
+
+ ok(!response.exception, "no eval exception");
+ ok(!response.helperResult, "no helper result");
+ }
+}
+
+async function doEagerEvalDOMGetters(commands) {
+ // Getters explicitly marked no-side-effect.
+ //
+ // [code, expectedResult]
+ const testDataExplicit = [
+ // DOMTokenList
+ ["document.documentElement.classList.length", 1],
+ ["document.documentElement.classList.value", "class1"],
+
+ // Document
+ ["document.URL.startsWith('data:')", true],
+ ["document.documentURI.startsWith('data:')", true],
+ ["document.compatMode", "CSS1Compat"],
+ ["document.characterSet", "UTF-8"],
+ ["document.charset", "UTF-8"],
+ ["document.inputEncoding", "UTF-8"],
+ ["document.contentType", "text/html"],
+ ["document.doctype.constructor.name", "DocumentType"],
+ ["document.documentElement.constructor.name", "HTMLHtmlElement"],
+ ["document.title", "Testcase"],
+ ["document.dir", "ltr"],
+ ["document.body.constructor.name", "HTMLBodyElement"],
+ ["document.head.constructor.name", "HTMLHeadElement"],
+ ["document.images.constructor.name", "HTMLCollection"],
+ ["document.embeds.constructor.name", "HTMLCollection"],
+ ["document.plugins.constructor.name", "HTMLCollection"],
+ ["document.links.constructor.name", "HTMLCollection"],
+ ["document.forms.constructor.name", "HTMLCollection"],
+ ["document.scripts.constructor.name", "HTMLCollection"],
+ ["document.defaultView === window", true],
+ ["typeof document.currentScript", "object"],
+ ["document.anchors.constructor.name", "HTMLCollection"],
+ ["document.applets.constructor.name", "HTMLCollection"],
+ ["document.all.constructor.name", "HTMLAllCollection"],
+ ["document.styleSheetSets.constructor.name", "DOMStringList"],
+ ["typeof document.featurePolicy", "undefined"],
+ ["typeof document.blockedNodeByClassifierCount", "undefined"],
+ ["typeof document.blockedNodesByClassifier", "undefined"],
+ ["typeof document.permDelegateHandler", "undefined"],
+ ["document.children.constructor.name", "HTMLCollection"],
+ ["document.firstElementChild === document.documentElement", true],
+ ["document.lastElementChild === document.documentElement", true],
+ ["document.childElementCount", 1],
+ ["document.location.href.startsWith('data:')", true],
+
+ // Element
+ ["document.body.namespaceURI", "http://www.w3.org/1999/xhtml"],
+ ["document.body.prefix === null", true],
+ ["document.body.localName", "body"],
+ ["document.body.tagName", "BODY"],
+ ["document.body.id", "body1"],
+ ["document.body.className", "class2"],
+ ["document.body.classList.constructor.name", "DOMTokenList"],
+ ["document.body.part.constructor.name", "DOMTokenList"],
+ ["document.body.attributes.constructor.name", "NamedNodeMap"],
+ ["document.body.innerHTML.includes('Body text')", true],
+ ["document.body.outerHTML.includes('Body text')", true],
+ ["document.body.previousElementSibling !== null", true],
+ ["document.body.nextElementSibling === null", true],
+ ["document.body.children.constructor.name", "HTMLCollection"],
+ ["document.body.firstElementChild !== null", true],
+ ["document.body.lastElementChild !== null", true],
+ ["document.body.childElementCount", 1],
+
+ // Node
+ ["document.body.nodeType === Node.ELEMENT_NODE", true],
+ ["document.body.nodeName", "BODY"],
+ ["document.body.baseURI.startsWith('data:')", true],
+ ["document.body.isConnected", true],
+ ["document.body.ownerDocument === document", true],
+ ["document.body.parentNode === document.documentElement", true],
+ ["document.body.parentElement === document.documentElement", true],
+ ["document.body.childNodes.constructor.name", "NodeList"],
+ ["document.body.firstChild !== null", true],
+ ["document.body.lastChild !== null", true],
+ ["document.body.previousSibling !== null", true],
+ ["document.body.nextSibling === null", true],
+ ["document.body.nodeValue === null", true],
+ ["document.body.textContent.includes('Body text')", true],
+ ["typeof document.body.flattenedTreeParentNode", "undefined"],
+ ["typeof document.body.isNativeAnonymous", "undefined"],
+ ["typeof document.body.containingShadowRoot", "undefined"],
+ ["typeof document.body.accessibleNode", "undefined"],
+
+ // Performance
+ ["performance.timeOrigin > 0", true],
+ ["performance.timing.constructor.name", "PerformanceTiming"],
+ ["performance.navigation.constructor.name", "PerformanceNavigation"],
+ ["performance.eventCounts.constructor.name", "EventCounts"],
+
+ // window
+ ["window.window === window", true],
+ ["window.self === window", true],
+ ["window.document.constructor.name", "HTMLDocument"],
+ ["window.performance.constructor.name", "Performance"],
+ ["typeof window.browsingContext", "undefined"],
+ ["typeof window.windowUtils", "undefined"],
+ ["typeof window.windowGlobalChild", "undefined"],
+ ["window.visualViewport.constructor.name", "VisualViewport"],
+ ["typeof window.caches", "undefined"],
+ ["window.location.href.startsWith('data:')", true],
+ ];
+ if (typeof Scheduler === "function") {
+ // Scheduler is behind a pref.
+ testDataExplicit.push(["window.scheduler.constructor.name", "Scheduler"]);
+ }
+
+ for (const [code, expectedResult] of testDataExplicit) {
+ const response = await commands.scriptCommand.execute(code, {
+ eager: true,
+ });
+ checkObject(
+ response,
+ {
+ input: code,
+ result: expectedResult,
+ },
+ code
+ );
+
+ ok(!response.exception, "no eval exception");
+ ok(!response.helperResult, "no helper result");
+ }
+
+ // Getters not-explicitly marked no-side-effect.
+ // All DOM getters are considered no-side-effect in eager evaluation context.
+ const testDataImplicit = [
+ // NOTE: This is not an exhaustive list.
+ // Document
+ [`document.implementation.constructor.name`, "DOMImplementation"],
+ [`typeof document.domain`, "string"],
+ [`typeof document.referrer`, "string"],
+ [`typeof document.cookie`, "string"],
+ [`typeof document.lastModified`, "string"],
+ [`typeof document.readyState`, "string"],
+ [`typeof document.designMode`, "string"],
+ [`typeof document.onbeforescriptexecute`, "object"],
+ [`typeof document.onafterscriptexecute`, "object"],
+
+ // Element
+ [`typeof document.documentElement.scrollTop`, "number"],
+ [`typeof document.documentElement.scrollLeft`, "number"],
+ [`typeof document.documentElement.scrollWidth`, "number"],
+ [`typeof document.documentElement.scrollHeight`, "number"],
+
+ // Performance
+ [`typeof performance.onresourcetimingbufferfull`, "object"],
+
+ // window
+ [`typeof window.name`, "string"],
+ [`window.history.constructor.name`, "History"],
+ [`window.customElements.constructor.name`, "CustomElementRegistry"],
+ [`window.locationbar.constructor.name`, "BarProp"],
+ [`window.menubar.constructor.name`, "BarProp"],
+ [`typeof window.status`, "string"],
+ [`window.closed`, false],
+
+ // CanvasRenderingContext2D / CanvasCompositing
+ [`testCanvasContext.globalAlpha`, 1],
+ ];
+
+ for (const [code, expectedResult] of testDataImplicit) {
+ const response = await commands.scriptCommand.execute(code, {
+ eager: true,
+ });
+ checkObject(
+ response,
+ {
+ input: code,
+ result: expectedResult,
+ },
+ code
+ );
+
+ ok(!response.exception, "no eval exception");
+ ok(!response.helperResult, "no helper result");
+ }
+}
+
+async function doEagerEvalOtherNativeGetters(commands) {
+ // DOM getter functions are allowed to be eagerly-evaluated.
+ // Test the situation where non-DOM-getter function is called by accessing
+ // getter.
+ //
+ // "being a DOM getter" is tested by checking if the native function has
+ // JSJitInfo and it's marked as getter.
+ const testData = [
+ // Has no JitInfo.
+ "objWithNativeGetter.print",
+ "objWithNativeGetter.Element",
+
+ // Not marked as getter, but method.
+ "objWithNativeGetter.getAttribute",
+
+ // Not marked as getter, but setter.
+ "objWithNativeGetter.setClassName",
+
+ // Not marked as getter, but static method.
+ "objWithNativeGetter.requestPermission",
+ ];
+
+ for (const code of testData) {
+ const response = await commands.scriptCommand.execute(code, {
+ eager: true,
+ });
+ checkObject(
+ response,
+ {
+ input: code,
+ result: { type: "undefined" },
+ },
+ code
+ );
+
+ ok(!response.exception, "no eval exception");
+ ok(!response.helperResult, "no helper result");
+ }
+}
+
+async function doEagerEvalAsyncFunctions(commands) {
+ // [code, expectedResult]
+ const testData = [["typeof testAsync()", "object"]];
+
+ for (const [code, expectedResult] of testData) {
+ const response = await commands.scriptCommand.execute(code, {
+ eager: true,
+ });
+ checkObject(
+ response,
+ {
+ input: code,
+ result: expectedResult,
+ },
+ code
+ );
+
+ ok(!response.exception, "no eval exception");
+ ok(!response.helperResult, "no helper result");
+ }
+
+ const testDataWithSideEffect = [
+ // await is effectful
+ "testAsyncAwait()",
+
+ // initial yield is effectful
+ "testAsyncGen()",
+ "testAsyncGenAwait()",
+ ];
+
+ for (const code of testDataWithSideEffect) {
+ const response = await commands.scriptCommand.execute(code, {
+ eager: true,
+ });
+ checkObject(
+ response,
+ {
+ input: code,
+ result: { type: "undefined" },
+ },
+ code
+ );
+
+ ok(!response.exception, "no eval exception");
+ ok(!response.helperResult, "no helper result");
+ }
+}
diff --git a/devtools/shared/commands/script/tests/browser_script_command_execute_document__proto__.js b/devtools/shared/commands/script/tests/browser_script_command_execute_document__proto__.js
new file mode 100644
index 0000000000..28f56ebac3
--- /dev/null
+++ b/devtools/shared/commands/script/tests/browser_script_command_execute_document__proto__.js
@@ -0,0 +1,41 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Testing evaluating document.__proto__
+
+add_task(async () => {
+ const tab = await addTab(
+ `data:text/html;charset=utf-8,Test evaluating document.__proto__`
+ );
+ const commands = await CommandsFactory.forTab(tab);
+ await commands.targetCommand.startListening();
+
+ const evaluationResponse = await commands.scriptCommand.execute(
+ "document.__proto__"
+ );
+ checkObject(evaluationResponse, {
+ input: "document.__proto__",
+ result: {
+ type: "object",
+ actor: /[a-z]/,
+ },
+ });
+
+ ok(!evaluationResponse.exception, "no eval exception");
+ ok(!evaluationResponse.helperResult, "no helper result");
+
+ const response = await evaluationResponse.result.getPrototypeAndProperties();
+ ok(!response.error, "no response error");
+
+ const props = response.ownProperties;
+ ok(props, "response properties available");
+
+ const expectedProps = Object.getOwnPropertyNames(
+ Object.getPrototypeOf(document)
+ );
+ checkObject(Object.keys(props), expectedProps, "Same own properties.");
+
+ await commands.destroy();
+});
diff --git a/devtools/shared/commands/script/tests/browser_script_command_execute_last_result.js b/devtools/shared/commands/script/tests/browser_script_command_execute_last_result.js
new file mode 100644
index 0000000000..aebdaeb168
--- /dev/null
+++ b/devtools/shared/commands/script/tests/browser_script_command_execute_last_result.js
@@ -0,0 +1,85 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Testing that last evaluation result can be accessed with `$_`
+
+add_task(async () => {
+ const tab = await addTab(`data:text/html;charset=utf-8,`);
+
+ const commands = await CommandsFactory.forTab(tab);
+ await commands.targetCommand.startListening();
+
+ info("$_ returns undefined if nothing has evaluated yet");
+ let response = await commands.scriptCommand.execute("$_");
+ basicResultCheck(response, "$_", { type: "undefined" });
+
+ info("$_ returns last value and performs basic arithmetic");
+ response = await commands.scriptCommand.execute("2+2");
+ basicResultCheck(response, "2+2", 4);
+
+ response = await commands.scriptCommand.execute("$_");
+ basicResultCheck(response, "$_", 4);
+
+ response = await commands.scriptCommand.execute("$_ + 2");
+ basicResultCheck(response, "$_ + 2", 6);
+
+ response = await commands.scriptCommand.execute("$_ + 4");
+ basicResultCheck(response, "$_ + 4", 10);
+
+ info("$_ has correct references to objects");
+ response = await commands.scriptCommand.execute("var foo = {bar:1}; foo;");
+ basicResultCheck(response, "var foo = {bar:1}; foo;", {
+ type: "object",
+ class: "Object",
+ actor: /[a-z]/,
+ });
+ checkObject(response.result.getGrip().preview.ownProperties, {
+ bar: {
+ value: 1,
+ },
+ });
+
+ response = await commands.scriptCommand.execute("$_");
+ basicResultCheck(response, "$_", {
+ type: "object",
+ class: "Object",
+ actor: /[a-z]/,
+ });
+ checkObject(response.result.getGrip().preview.ownProperties, {
+ bar: {
+ value: 1,
+ },
+ });
+
+ info(
+ "Update a property value and check that evaluating $_ returns the expected object instance"
+ );
+ await ContentTask.spawn(gBrowser.selectedBrowser, null, () => {
+ content.wrappedJSObject.foo.bar = "updated_value";
+ });
+
+ response = await commands.scriptCommand.execute("$_");
+ basicResultCheck(response, "$_", {
+ type: "object",
+ class: "Object",
+ actor: /[a-z]/,
+ });
+ checkObject(response.result.getGrip().preview.ownProperties, {
+ bar: {
+ value: "updated_value",
+ },
+ });
+
+ await commands.destroy();
+});
+
+function basicResultCheck(response, input, output) {
+ checkObject(response, {
+ input,
+ result: output,
+ });
+ ok(!response.exception, "no eval exception");
+ ok(!response.helperResult, "no helper result");
+}
diff --git a/devtools/shared/commands/script/tests/browser_script_command_execute_throw.js b/devtools/shared/commands/script/tests/browser_script_command_execute_throw.js
new file mode 100644
index 0000000000..8680193ecb
--- /dev/null
+++ b/devtools/shared/commands/script/tests/browser_script_command_execute_throw.js
@@ -0,0 +1,75 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Testing evaluating thowing expressions
+const {
+ DevToolsServer,
+} = require("resource://devtools/server/devtools-server.js");
+
+add_task(async () => {
+ const tab = await addTab(`data:text/html;charset=utf-8,Test throw`);
+ const commands = await CommandsFactory.forTab(tab);
+ await commands.targetCommand.startListening();
+
+ const falsyValues = [
+ "-0",
+ "null",
+ "undefined",
+ "Infinity",
+ "-Infinity",
+ "NaN",
+ ];
+ for (const value of falsyValues) {
+ const response = await commands.scriptCommand.execute(`throw ${value};`);
+ is(
+ response.exception.type,
+ value,
+ `Got the expected value for response.exception.type when throwing "${value}"`
+ );
+ }
+
+ const identityTestValues = [false, 0];
+ for (const value of identityTestValues) {
+ const response = await commands.scriptCommand.execute(`throw ${value};`);
+ is(
+ response.exception,
+ value,
+ `Got the expected value for response.exception when throwing "${value}"`
+ );
+ }
+
+ const symbolTestValues = [
+ ["Symbol.iterator", "Symbol(Symbol.iterator)"],
+ ["Symbol('foo')", "Symbol(foo)"],
+ ["Symbol()", "Symbol()"],
+ ];
+ for (const [expr, message] of symbolTestValues) {
+ const response = await commands.scriptCommand.execute(`throw ${expr};`);
+ is(
+ response.exceptionMessage,
+ message,
+ `Got the expected value for response.exceptionMessage when throwing "${expr}"`
+ );
+ }
+
+ const longString = Array(DevToolsServer.LONG_STRING_LENGTH + 1).join("a"),
+ shortedString = longString.substring(
+ 0,
+ DevToolsServer.LONG_STRING_INITIAL_LENGTH
+ );
+ const response = await commands.scriptCommand.execute(
+ "throw '" + longString + "';"
+ );
+ is(
+ response.exception.initial,
+ shortedString,
+ "Got the expected value for exception.initial when throwing a longString"
+ );
+ is(
+ response.exceptionMessage.initial,
+ shortedString,
+ "Got the expected value for exceptionMessage.initial when throwing a longString"
+ );
+});
diff --git a/devtools/shared/commands/script/tests/head.js b/devtools/shared/commands/script/tests/head.js
new file mode 100644
index 0000000000..50635e4502
--- /dev/null
+++ b/devtools/shared/commands/script/tests/head.js
@@ -0,0 +1,51 @@
+/* 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";
+
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/devtools/client/shared/test/shared-head.js",
+ this
+);
+
+function checkObject(object, expected, message) {
+ 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, message);
+ }
+}
+
+function checkValue(name, value, expected, message) {
+ if (message) {
+ message = ` for '${message}'`;
+ }
+
+ if (expected === null) {
+ is(value, null, `'${name}' is null${message}`);
+ } else if (expected === undefined) {
+ is(value, expected, `'${name}' is undefined${message}`);
+ } else if (
+ typeof expected == "string" ||
+ typeof expected == "number" ||
+ typeof expected == "boolean"
+ ) {
+ is(value, expected, "property '" + name + "'" + message);
+ } else if (expected instanceof RegExp) {
+ ok(
+ expected.test(value),
+ name + ": " + expected + " matched " + value + message
+ );
+ } else if (Array.isArray(expected)) {
+ info("checking array for property '" + name + "'" + message);
+ checkObject(value, expected, message);
+ } else if (typeof expected == "object") {
+ info("checking object for property '" + name + "'" + message);
+ checkObject(value, expected, message);
+ }
+}
diff --git a/devtools/shared/commands/target-configuration/moz.build b/devtools/shared/commands/target-configuration/moz.build
new file mode 100644
index 0000000000..5d497983f0
--- /dev/null
+++ b/devtools/shared/commands/target-configuration/moz.build
@@ -0,0 +1,10 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DevToolsModules(
+ "target-configuration-command.js",
+)
+
+if CONFIG["MOZ_BUILD_APP"] != "mobile/android":
+ BROWSER_CHROME_MANIFESTS += ["tests/browser.ini"]
diff --git a/devtools/shared/commands/target-configuration/target-configuration-command.js b/devtools/shared/commands/target-configuration/target-configuration-command.js
new file mode 100644
index 0000000000..28e717cea2
--- /dev/null
+++ b/devtools/shared/commands/target-configuration/target-configuration-command.js
@@ -0,0 +1,124 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * The TargetConfigurationCommand should be used to populate the DevTools server
+ * with settings read from the client side but which impact the server.
+ * For instance, "disable cache" is a feature toggled via DevTools UI (client),
+ * but which should be communicated to the targets (server).
+ *
+ * See the TargetConfigurationActor for a list of supported configuration options.
+ */
+class TargetConfigurationCommand {
+ constructor({ commands, watcherFront }) {
+ this._commands = commands;
+ this._watcherFront = watcherFront;
+ }
+
+ /**
+ * Return a promise that resolves to the related target configuration actor's front.
+ *
+ * @return {Promise<TargetConfigurationFront>}
+ */
+ async getFront() {
+ const front = await this._watcherFront.getTargetConfigurationActor();
+
+ if (!this._configuration) {
+ // Retrieve initial data from the front
+ this._configuration = front.initialConfiguration;
+ }
+
+ return front;
+ }
+
+ _hasTargetWatcherSupport() {
+ return this._commands.targetCommand.hasTargetWatcherSupport();
+ }
+
+ /**
+ * Retrieve the current map of configuration options pushed to the server.
+ */
+ get configuration() {
+ return this._configuration || {};
+ }
+
+ async updateConfiguration(configuration) {
+ if (this._hasTargetWatcherSupport()) {
+ const front = await this.getFront();
+ const updatedConfiguration = await front.updateConfiguration(
+ configuration
+ );
+ // Update the client-side copy of the DevTools configuration
+ this._configuration = updatedConfiguration;
+ } else {
+ await this._commands.targetCommand.targetFront.reconfigure({
+ options: configuration,
+ });
+ }
+ }
+
+ async isJavascriptEnabled() {
+ // If we don't have target watcher support, we can't get this value, so just
+ // fall back to true. Only content tab targets can update javascriptEnabled
+ // and all should have watcher support.
+ if (!this._hasTargetWatcherSupport()) {
+ return true;
+ }
+
+ const front = await this.getFront();
+ return front.isJavascriptEnabled();
+ }
+
+ /**
+ * Reports if the given configuration key is supported by the server.
+ * If the debugged context doesn't support the watcher actor,
+ * we won't be using the target configuration actor and report all keys
+ * as not supported.
+ *
+ * @param {Object} configurationKey
+ * Name of the configuration you would like to set.
+ * @return {Promise<Boolean>} True, if this configuration can be set via this API.
+ */
+ async supports(configurationKey) {
+ if (!this._hasTargetWatcherSupport()) {
+ return false;
+ }
+ const front = await this.getFront();
+ return !!front.traits.supportedOptions[configurationKey];
+ }
+
+ /**
+ * Change orientation type and angle (that can be accessed through screen.orientation in
+ * the content page) and simulates the "orientationchange" event when the device screen
+ * was rotated.
+ * Note that this will only be effective if the Responsive Design Mode is enabled.
+ *
+ * @param {Object} options
+ * @param {String} options.type: The orientation type of the rotated device.
+ * @param {Number} options.angle: The rotated angle of the device.
+ * @param {Boolean} options.isViewportRotated: Whether or not screen orientation change
+ * is a result of rotating the viewport. If true, an "orientationchange"
+ * event will be dispatched in the content window.
+ */
+ async simulateScreenOrientationChange({ type, angle, isViewportRotated }) {
+ // We need to call the method on the parent process
+ await this.updateConfiguration({
+ rdmPaneOrientation: { type, angle },
+ });
+
+ // Don't dispatch the "orientationchange" event if orientation change is a result
+ // of switching to a new device, location change, or opening RDM.
+ if (!isViewportRotated) {
+ return;
+ }
+
+ const responsiveFront =
+ await this._commands.targetCommand.targetFront.getFront("responsive");
+ await responsiveFront.dispatchOrientationChangeEvent();
+ }
+}
+
+module.exports = TargetConfigurationCommand;
diff --git a/devtools/shared/commands/target-configuration/tests/browser.ini b/devtools/shared/commands/target-configuration/tests/browser.ini
new file mode 100644
index 0000000000..358934001e
--- /dev/null
+++ b/devtools/shared/commands/target-configuration/tests/browser.ini
@@ -0,0 +1,17 @@
+[DEFAULT]
+tags = devtools
+subsuite = devtools
+support-files =
+ !/devtools/client/shared/test/shared-head.js
+ target_configuration_test_doc.sjs
+ head.js
+
+[browser_target_configuration_command_color_scheme.js]
+skip-if = http3 # Bug 1829298
+[browser_target_configuration_command_custom_user_agent.js]
+skip-if = http3 # Bug 1829298
+[browser_target_configuration_command_dppx.js]
+skip-if = http3 # Bug 1829298
+[browser_target_configuration_command_touch_events.js]
+skip-if = http3 # Bug 1829298
+[browser_target_configuration_command.js]
diff --git a/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command.js b/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command.js
new file mode 100644
index 0000000000..84ba79f46c
--- /dev/null
+++ b/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command.js
@@ -0,0 +1,79 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the watcher's target-configuration actor API.
+
+add_task(async function () {
+ info("Setup the test page with workers of all types");
+ const tab = await addTab("data:text/html;charset=utf-8,Configuration actor");
+
+ info("Create a target list for a tab target");
+ const commands = await CommandsFactory.forTab(tab);
+
+ const targetConfigurationCommand = commands.targetConfigurationCommand;
+ const targetCommand = commands.targetCommand;
+ await targetCommand.startListening();
+
+ compareOptions(
+ targetConfigurationCommand.configuration,
+ {},
+ "Initial configuration is empty"
+ );
+
+ await targetConfigurationCommand.updateConfiguration({
+ cacheDisabled: true,
+ });
+ compareOptions(
+ targetConfigurationCommand.configuration,
+ { cacheDisabled: true },
+ "Option cacheDisabled was set"
+ );
+
+ await targetConfigurationCommand.updateConfiguration({
+ javascriptEnabled: false,
+ });
+ compareOptions(
+ targetConfigurationCommand.configuration,
+ { cacheDisabled: true, javascriptEnabled: false },
+ "Option javascriptEnabled was set"
+ );
+
+ await targetConfigurationCommand.updateConfiguration({
+ cacheDisabled: false,
+ });
+ compareOptions(
+ targetConfigurationCommand.configuration,
+ { cacheDisabled: false, javascriptEnabled: false },
+ "Option cacheDisabled was updated"
+ );
+
+ await targetConfigurationCommand.updateConfiguration({
+ colorSchemeSimulation: "dark",
+ });
+ compareOptions(
+ targetConfigurationCommand.configuration,
+ {
+ cacheDisabled: false,
+ colorSchemeSimulation: "dark",
+ javascriptEnabled: false,
+ },
+ "Option colorSchemeSimulation was set, with a string value"
+ );
+
+ targetCommand.destroy();
+ await commands.destroy();
+});
+
+function compareOptions(options, expected, message) {
+ is(
+ Object.keys(options).length,
+ Object.keys(expected).length,
+ message + " (wrong number of options)"
+ );
+
+ for (const key of Object.keys(expected)) {
+ is(options[key], expected[key], message + ` (wrong value for ${key})`);
+ }
+}
diff --git a/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_color_scheme.js b/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_color_scheme.js
new file mode 100644
index 0000000000..ccbfec93e6
--- /dev/null
+++ b/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_color_scheme.js
@@ -0,0 +1,183 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test color scheme simulation.
+const TEST_DOCUMENT = "target_configuration_test_doc.sjs";
+const TEST_URI = URL_ROOT_COM_SSL + TEST_DOCUMENT;
+
+add_task(async function () {
+ info("Setup the test page with workers of all types");
+ const tab = await addTab(TEST_URI);
+
+ is(
+ await topLevelDocumentMatchPrefersDarkColorSchemeMediaAtStartup(),
+ false,
+ "The dark mode simulation wasn't enabled in the content page when it loaded"
+ );
+ is(
+ await topLevelDocumentMatchPrefersDarkColorSchemeMedia(),
+ false,
+ "The dark mode simulation isn't enabled in the content page by default"
+ );
+ is(
+ await iframeDocumentMatchPrefersDarkColorSchemeMediaAtStartup(),
+ false,
+ "The dark mode simulation wasn't enabled in the remote iframe when it loaded"
+ );
+ is(
+ await iframeDocumentMatchPrefersDarkColorSchemeMedia(),
+ false,
+ "The dark mode simulation isn't enabled in the remote iframe by default"
+ );
+
+ info("Create a target list for a tab target");
+ const commands = await CommandsFactory.forTab(tab);
+
+ const targetConfigurationCommand = commands.targetConfigurationCommand;
+ const targetCommand = commands.targetCommand;
+ await targetCommand.startListening();
+
+ info("Update configuration to enable dark mode simulation");
+ await targetConfigurationCommand.updateConfiguration({
+ colorSchemeSimulation: "dark",
+ });
+ is(
+ await topLevelDocumentMatchPrefersDarkColorSchemeMedia(),
+ true,
+ "The dark mode simulation is enabled after updating the configuration"
+ );
+ is(
+ await iframeDocumentMatchPrefersDarkColorSchemeMedia(),
+ true,
+ "The dark mode simulation is enabled in the remote iframe after updating the configuration"
+ );
+
+ info("Reload the page");
+ await BrowserTestUtils.reloadTab(tab, /* includeSubFrames */ true);
+
+ is(
+ await topLevelDocumentMatchPrefersDarkColorSchemeMediaAtStartup(),
+ true,
+ "The dark mode simulation was enabled in the content page when it loaded after reloading"
+ );
+ is(
+ await topLevelDocumentMatchPrefersDarkColorSchemeMedia(),
+ true,
+ "The dark mode simulation is enabled in the content page after reloading"
+ );
+ is(
+ await iframeDocumentMatchPrefersDarkColorSchemeMediaAtStartup(),
+ true,
+ "The dark mode simulation was enabled in the remote iframe when it loaded after reloading"
+ );
+ is(
+ await iframeDocumentMatchPrefersDarkColorSchemeMedia(),
+ true,
+ "The dark mode simulation is enabled in the remote iframe after reloading"
+ );
+
+ const previousBrowsingContextId = gBrowser.selectedBrowser.browsingContext.id;
+ info(
+ "Check that navigating to a page that forces the creation of a new browsing context keep the simulation enabled"
+ );
+
+ const onPageLoaded = BrowserTestUtils.browserLoaded(
+ gBrowser.selectedBrowser,
+ /* includeSubFrames */ true
+ );
+ BrowserTestUtils.loadURIString(
+ gBrowser.selectedBrowser,
+ URL_ROOT_ORG_SSL + TEST_DOCUMENT + "?crossOriginIsolated=true"
+ );
+ await onPageLoaded;
+
+ isnot(
+ gBrowser.selectedBrowser.browsingContext.id,
+ previousBrowsingContextId,
+ "A new browsing context was created"
+ );
+
+ is(
+ await topLevelDocumentMatchPrefersDarkColorSchemeMediaAtStartup(),
+ true,
+ "The dark mode simulation was enabled in the content page when it loaded after navigating to a new browsing context"
+ );
+ is(
+ await topLevelDocumentMatchPrefersDarkColorSchemeMedia(),
+ true,
+ "The dark mode simulation is enabled in the content page after navigating to a new browsing context"
+ );
+ is(
+ await iframeDocumentMatchPrefersDarkColorSchemeMediaAtStartup(),
+ true,
+ "The dark mode simulation was enabled in the remote iframe when it loaded after navigating to a new browsing context"
+ );
+ is(
+ await iframeDocumentMatchPrefersDarkColorSchemeMedia(),
+ true,
+ "The dark mode simulation is enabled in the remote iframe after navigating to a new browsing context"
+ );
+
+ targetCommand.destroy();
+ await commands.destroy();
+
+ is(
+ await topLevelDocumentMatchPrefersDarkColorSchemeMedia(),
+ false,
+ "The dark mode simulation is disabled in the content page after destroying the commands"
+ );
+ is(
+ await iframeDocumentMatchPrefersDarkColorSchemeMedia(),
+ false,
+ "The dark mode simulation is disabled in the remote iframe after destroying the commands"
+ );
+});
+
+function matchPrefersDarkColorSchemeMedia(browserOrBrowsingContext) {
+ return SpecialPowers.spawn(
+ browserOrBrowsingContext,
+ [],
+ () => content.matchMedia("(prefers-color-scheme: dark)").matches
+ );
+}
+
+function matchPrefersDarkColorSchemeMediaAtStartup(browserOrBrowsingContext) {
+ return SpecialPowers.spawn(
+ browserOrBrowsingContext,
+ [],
+ () => content.wrappedJSObject.initialMatchesPrefersDarkColorScheme
+ );
+}
+
+function topLevelDocumentMatchPrefersDarkColorSchemeMedia() {
+ return matchPrefersDarkColorSchemeMedia(gBrowser.selectedBrowser);
+}
+
+function topLevelDocumentMatchPrefersDarkColorSchemeMediaAtStartup() {
+ return matchPrefersDarkColorSchemeMediaAtStartup(gBrowser.selectedBrowser);
+}
+
+function getIframeBrowsingContext() {
+ return SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ // Ensure we've rendered the iframe so that the prefers-color-scheme
+ // value propagated from the embedder is up-to-date.
+ await new Promise(resolve => {
+ content.requestAnimationFrame(() =>
+ content.requestAnimationFrame(resolve)
+ );
+ });
+ return content.document.querySelector("iframe").browsingContext;
+ });
+}
+
+async function iframeDocumentMatchPrefersDarkColorSchemeMedia() {
+ const iframeBC = await getIframeBrowsingContext();
+ return matchPrefersDarkColorSchemeMedia(iframeBC);
+}
+
+async function iframeDocumentMatchPrefersDarkColorSchemeMediaAtStartup() {
+ const iframeBC = await getIframeBrowsingContext();
+ return matchPrefersDarkColorSchemeMediaAtStartup(iframeBC);
+}
diff --git a/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_custom_user_agent.js b/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_custom_user_agent.js
new file mode 100644
index 0000000000..3ed0f8e142
--- /dev/null
+++ b/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_custom_user_agent.js
@@ -0,0 +1,309 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test setting custom user agent.
+const TEST_DOCUMENT = "target_configuration_test_doc.sjs";
+const TEST_URI = URL_ROOT_COM_SSL + TEST_DOCUMENT;
+
+add_task(async function () {
+ const tab = await addTab(TEST_URI);
+
+ info("Create commands for the tab");
+ const commands = await CommandsFactory.forTab(tab);
+
+ const targetConfigurationCommand = commands.targetConfigurationCommand;
+ const targetCommand = commands.targetCommand;
+ await targetCommand.startListening();
+
+ const initialUserAgent = await getTopLevelUserAgent();
+
+ info("Update configuration to change user agent");
+ const CUSTOM_USER_AGENT = "<MY_BORING_CUSTOM_USER_AGENT>";
+
+ await targetConfigurationCommand.updateConfiguration({
+ customUserAgent: CUSTOM_USER_AGENT,
+ });
+
+ is(
+ await getTopLevelUserAgent(),
+ CUSTOM_USER_AGENT,
+ "The user agent is properly set on the top level document after updating the configuration"
+ );
+ is(
+ await getUserAgentForTopLevelRequest(commands),
+ CUSTOM_USER_AGENT,
+ "The custom user agent is used when retrieving resources on the top level document"
+ );
+
+ is(
+ await getIframeUserAgent(),
+ CUSTOM_USER_AGENT,
+ "The user agent is properly set on the iframe after updating the configuration"
+ );
+ is(
+ await getUserAgentForIframeRequest(commands),
+ CUSTOM_USER_AGENT,
+ "The custom user agent is used when retrieving resources on the iframe"
+ );
+
+ info("Reload the page");
+ await BrowserTestUtils.reloadTab(tab, /* includeSubFrames */ true);
+
+ is(
+ await getTopLevelDocumentUserAgentAtStartup(),
+ CUSTOM_USER_AGENT,
+ "The custom user agent was set in the content page when it loaded after reloading"
+ );
+ is(
+ await getTopLevelUserAgent(),
+ CUSTOM_USER_AGENT,
+ "The custom user agent is set in the content page after reloading"
+ );
+ is(
+ await getUserAgentForTopLevelRequest(commands),
+ CUSTOM_USER_AGENT,
+ "The custom user agent is used when retrieving resources after reloading"
+ );
+ is(
+ await getIframeUserAgentAtStartup(),
+ CUSTOM_USER_AGENT,
+ "The custom user agent was set in the remote iframe when it loaded after reloading"
+ );
+ is(
+ await getIframeUserAgent(),
+ CUSTOM_USER_AGENT,
+ "The custom user agent is set in the remote iframe after reloading"
+ );
+ is(
+ await getUserAgentForIframeRequest(commands),
+ CUSTOM_USER_AGENT,
+ "The custom user agent is used when retrieving resources in the remote iframe after reloading"
+ );
+
+ const previousBrowsingContextId = gBrowser.selectedBrowser.browsingContext.id;
+ info(
+ "Check that navigating to a page that forces the creation of a new browsing context keep the simulation enabled"
+ );
+
+ const onPageLoaded = BrowserTestUtils.browserLoaded(
+ gBrowser.selectedBrowser,
+ /* includeSubFrames */ true
+ );
+ BrowserTestUtils.loadURIString(
+ gBrowser.selectedBrowser,
+ URL_ROOT_ORG_SSL + TEST_DOCUMENT + "?crossOriginIsolated=true"
+ );
+ await onPageLoaded;
+
+ isnot(
+ gBrowser.selectedBrowser.browsingContext.id,
+ previousBrowsingContextId,
+ "A new browsing context was created"
+ );
+
+ is(
+ await getTopLevelDocumentUserAgentAtStartup(),
+ CUSTOM_USER_AGENT,
+ "The custom user agent was set in the content page when it loaded after navigating to a new browsing context"
+ );
+ is(
+ await getTopLevelUserAgent(),
+ CUSTOM_USER_AGENT,
+ "The custom user agent is set in the content page after navigating to a new browsing context"
+ );
+ is(
+ await getUserAgentForTopLevelRequest(commands),
+ CUSTOM_USER_AGENT,
+ "The custom user agent is used when retrieving resources after navigating to a new browsing context"
+ );
+ is(
+ await getIframeUserAgentAtStartup(),
+ CUSTOM_USER_AGENT,
+ "The custom user agent was set in the remote iframe when it loaded after navigating to a new browsing context"
+ );
+ is(
+ await getIframeUserAgent(),
+ CUSTOM_USER_AGENT,
+ "The custom user agent is set in the remote iframe after navigating to a new browsing context"
+ );
+ is(
+ await getUserAgentForTopLevelRequest(commands),
+ CUSTOM_USER_AGENT,
+ "The custom user agent is used when retrieving resources in the remote iframes after navigating to a new browsing context"
+ );
+
+ info(
+ "Create another commands instance and check that destroying it won't reset the user agent"
+ );
+ const otherCommands = await CommandsFactory.forTab(tab);
+ const otherTargetConfigurationCommand =
+ otherCommands.targetConfigurationCommand;
+ const otherTargetCommand = otherCommands.targetCommand;
+ await otherTargetCommand.startListening();
+ // wait for the target to be fully attached to avoid pending connection to the server
+ await otherTargetCommand.watchTargets({
+ types: [otherTargetCommand.TYPES.FRAME],
+ onAvailable: () => {},
+ });
+
+ // Let's update the configuration with this commands instance to make sure we hit the TargetConfigurationActor
+ await otherTargetConfigurationCommand.updateConfiguration({
+ colorSchemeSimulation: "dark",
+ });
+
+ otherTargetCommand.destroy();
+ await otherCommands.destroy();
+
+ is(
+ await getTopLevelUserAgent(),
+ CUSTOM_USER_AGENT,
+ "The custom user agent is still set on the page after destroying another commands instance"
+ );
+
+ info(
+ "Check that destroying the commands we set the user agent in will reset the user agent"
+ );
+ targetCommand.destroy();
+ await commands.destroy();
+
+ // XXX: This is needed at the moment since Navigator.cpp retrieve the UserAgent from the
+ // headers (when there's no custom user agent). And here, since we reloaded the page once
+ // we set the custom user agent, the header was set accordingly and still holds the custom
+ // user agent value. This should be fixed by Bug 1705326.
+ is(
+ await getTopLevelUserAgent(),
+ CUSTOM_USER_AGENT,
+ "The custom user agent is still set on the page after destroying the first commands instance. Bug 1705326 will fix that and make it equal to `initialUserAgent`"
+ );
+
+ await BrowserTestUtils.reloadTab(tab, /* includeSubFrames */ true);
+ is(
+ await getTopLevelUserAgent(),
+ initialUserAgent,
+ "The user agent was reset in the content page after destroying the commands"
+ );
+ is(
+ await getIframeUserAgent(),
+ initialUserAgent,
+ "The user agent was reset in the remote iframe after destroying the commands"
+ );
+
+ // We need commands to retrieve the headers of the network request, and
+ // all those we created so far were destroyed; let's create new ones.
+ const newCommands = await CommandsFactory.forTab(tab);
+ await newCommands.targetCommand.startListening();
+ is(
+ await getUserAgentForTopLevelRequest(newCommands),
+ initialUserAgent,
+ "The initial user agent is used when retrieving resources after destroying the commands"
+ );
+ is(
+ await getUserAgentForIframeRequest(newCommands),
+ initialUserAgent,
+ "The initial user agent is used when retrieving resources on the remote iframe after destroying the commands"
+ );
+});
+
+function getUserAgent(browserOrBrowsingContext) {
+ return SpecialPowers.spawn(browserOrBrowsingContext, [], () => {
+ return content.navigator.userAgent;
+ });
+}
+
+function getUserAgentAtStartup(browserOrBrowsingContext) {
+ return SpecialPowers.spawn(
+ browserOrBrowsingContext,
+ [],
+ () => content.wrappedJSObject.initialUserAgent
+ );
+}
+
+function getTopLevelUserAgent() {
+ return getUserAgent(gBrowser.selectedBrowser);
+}
+
+function getTopLevelDocumentUserAgentAtStartup() {
+ return getUserAgentAtStartup(gBrowser.selectedBrowser);
+}
+
+function getIframeBrowsingContext() {
+ return SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ () => content.document.querySelector("iframe").browsingContext
+ );
+}
+
+async function getIframeUserAgent() {
+ const iframeBC = await getIframeBrowsingContext();
+ return getUserAgent(iframeBC);
+}
+
+async function getIframeUserAgentAtStartup() {
+ const iframeBC = await getIframeBrowsingContext();
+ return getUserAgentAtStartup(iframeBC);
+}
+
+async function getRequestUserAgent(commands, browserOrBrowsingContext) {
+ const url = `unknown?${Date.now()}`;
+
+ // Wait for the resource and its headers to be available
+ const onAvailable = () => {};
+ let onUpdated;
+
+ const onResource = new Promise(resolve => {
+ onUpdated = updates => {
+ for (const { resource } of updates) {
+ if (resource.url.includes(url) && resource.requestHeadersAvailable) {
+ resolve(resource);
+ }
+ }
+ };
+
+ commands.resourceCommand.watchResources(
+ [commands.resourceCommand.TYPES.NETWORK_EVENT],
+ {
+ onAvailable,
+ onUpdated,
+ ignoreExistingResources: true,
+ }
+ );
+ });
+
+ info(`Fetch ${url}`);
+ SpecialPowers.spawn(browserOrBrowsingContext, [url], innerUrl => {
+ content.fetch(`./${innerUrl}`);
+ });
+ info("waiting for matching resource…");
+ const networkResource = await onResource;
+
+ info("…got resource, retrieve headers");
+ const packet = {
+ to: networkResource.actor,
+ type: "getRequestHeaders",
+ };
+
+ const { headers } = await commands.client.request(packet);
+
+ commands.resourceCommand.unwatchResources(
+ [commands.resourceCommand.TYPES.NETWORK_EVENT],
+ {
+ onAvailable,
+ onUpdated,
+ ignoreExistingResources: true,
+ }
+ );
+
+ return headers.find(header => header.name == "User-Agent")?.value;
+}
+
+async function getUserAgentForTopLevelRequest(commands) {
+ return getRequestUserAgent(commands, gBrowser.selectedBrowser);
+}
+
+async function getUserAgentForIframeRequest(commands) {
+ const iframeBC = await getIframeBrowsingContext();
+ return getRequestUserAgent(commands, iframeBC);
+}
diff --git a/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_dppx.js b/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_dppx.js
new file mode 100644
index 0000000000..aa007e937b
--- /dev/null
+++ b/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_dppx.js
@@ -0,0 +1,187 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test device pixel ratio override.
+const TEST_DOCUMENT = "target_configuration_test_doc.sjs";
+const TEST_URI = URL_ROOT_COM_SSL + TEST_DOCUMENT;
+
+add_task(async function () {
+ const tab = await addTab(TEST_URI);
+
+ info("Create commands for the tab");
+ const commands = await CommandsFactory.forTab(tab);
+
+ const targetConfigurationCommand = commands.targetConfigurationCommand;
+ const targetCommand = commands.targetCommand;
+ await targetCommand.startListening();
+
+ const originalDpr = await getTopLevelDocumentDevicePixelRatio();
+
+ info("Update configuration to change device pixel ratio");
+ const CUSTOM_DPR = 5.5;
+
+ await targetConfigurationCommand.updateConfiguration({
+ overrideDPPX: CUSTOM_DPR,
+ });
+
+ is(
+ await getTopLevelDocumentDevicePixelRatio(),
+ CUSTOM_DPR,
+ "The ratio is properly set on the top level document after updating the configuration"
+ );
+ is(
+ await getIframeDocumentDevicePixelRatio(),
+ CUSTOM_DPR,
+ "The ratio is properly set on the iframe after updating the configuration"
+ );
+
+ info("Reload the page");
+ await BrowserTestUtils.reloadTab(tab, /* includeSubFrames */ true);
+
+ is(
+ await getTopLevelDocumentDevicePixelRatioAtStartup(),
+ CUSTOM_DPR,
+ "The custom ratio was set in the content page when it loaded after reloading"
+ );
+ is(
+ await getTopLevelDocumentDevicePixelRatio(),
+ CUSTOM_DPR,
+ "The custom ratio is set in the content page after reloading"
+ );
+ is(
+ await getIframeDocumentDevicePixelRatioAtStartup(),
+ CUSTOM_DPR,
+ "The custom ratio was set in the remote iframe when it loaded after reloading"
+ );
+ is(
+ await getIframeDocumentDevicePixelRatio(),
+ CUSTOM_DPR,
+ "The custom ratio is set in the remote iframe after reloading"
+ );
+
+ const previousBrowsingContextId = gBrowser.selectedBrowser.browsingContext.id;
+ info(
+ "Check that navigating to a page that forces the creation of a new browsing context keep the simulation enabled"
+ );
+
+ const onPageLoaded = BrowserTestUtils.browserLoaded(
+ gBrowser.selectedBrowser,
+ /* includeSubFrames */ true
+ );
+ BrowserTestUtils.loadURIString(
+ gBrowser.selectedBrowser,
+ URL_ROOT_ORG_SSL + TEST_DOCUMENT + "?crossOriginIsolated=true"
+ );
+ await onPageLoaded;
+
+ isnot(
+ gBrowser.selectedBrowser.browsingContext.id,
+ previousBrowsingContextId,
+ "A new browsing context was created"
+ );
+
+ is(
+ await getTopLevelDocumentDevicePixelRatioAtStartup(),
+ CUSTOM_DPR,
+ "The custom ratio was set in the content page when it loaded after navigating to a new browsing context"
+ );
+ is(
+ await getTopLevelDocumentDevicePixelRatio(),
+ CUSTOM_DPR,
+ "The custom ratio is set in the content page after navigating to a new browsing context"
+ );
+ is(
+ await getIframeDocumentDevicePixelRatioAtStartup(),
+ CUSTOM_DPR,
+ "The custom ratio was set in the remote iframe when it loaded after navigating to a new browsing context"
+ );
+ is(
+ await getIframeDocumentDevicePixelRatio(),
+ CUSTOM_DPR,
+ "The custom ratio is set in the remote iframe after navigating to a new browsing context"
+ );
+
+ info(
+ "Create another commands instance and check that destroying it won't reset the ratio"
+ );
+ const otherCommands = await CommandsFactory.forTab(tab);
+ const otherTargetConfigurationCommand =
+ otherCommands.targetConfigurationCommand;
+ const otherTargetCommand = otherCommands.targetCommand;
+ await otherTargetCommand.startListening();
+
+ // Let's update the configuration with this commands instance to make sure we hit the TargetConfigurationActor
+ await otherTargetConfigurationCommand.updateConfiguration({
+ colorSchemeSimulation: "dark",
+ });
+
+ otherTargetCommand.destroy();
+ await otherCommands.destroy();
+
+ is(
+ await getTopLevelDocumentDevicePixelRatio(),
+ CUSTOM_DPR,
+ "The custom ratio is still set on the page after destroying another commands instance"
+ );
+
+ info(
+ "Check that destroying the commands we overrode the ratio in will reset the page ratio"
+ );
+ targetCommand.destroy();
+ await commands.destroy();
+
+ is(
+ await getTopLevelDocumentDevicePixelRatio(),
+ originalDpr,
+ "The ratio was reset in the content page after destroying the commands"
+ );
+ is(
+ await getIframeDocumentDevicePixelRatio(),
+ originalDpr,
+ "The ratio was reset in the remote iframe after destroying the commands"
+ );
+});
+
+function getDevicePixelRatio(browserOrBrowsingContext) {
+ return SpecialPowers.spawn(
+ browserOrBrowsingContext,
+ [],
+ () => content.browsingContext.top.overrideDPPX || content.devicePixelRatio
+ );
+}
+
+function getDevicePixelRatioAtStartup(browserOrBrowsingContext) {
+ return SpecialPowers.spawn(
+ browserOrBrowsingContext,
+ [],
+ () => content.wrappedJSObject.initialDevicePixelRatio
+ );
+}
+
+function getTopLevelDocumentDevicePixelRatio() {
+ return getDevicePixelRatio(gBrowser.selectedBrowser);
+}
+
+function getTopLevelDocumentDevicePixelRatioAtStartup() {
+ return getDevicePixelRatioAtStartup(gBrowser.selectedBrowser);
+}
+
+function getIframeBrowsingContext() {
+ return SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ () => content.document.querySelector("iframe").browsingContext
+ );
+}
+
+async function getIframeDocumentDevicePixelRatio() {
+ const iframeBC = await getIframeBrowsingContext();
+ return getDevicePixelRatio(iframeBC);
+}
+
+async function getIframeDocumentDevicePixelRatioAtStartup() {
+ const iframeBC = await getIframeBrowsingContext();
+ return getDevicePixelRatioAtStartup(iframeBC);
+}
diff --git a/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_touch_events.js b/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_touch_events.js
new file mode 100644
index 0000000000..55a0d198ce
--- /dev/null
+++ b/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_touch_events.js
@@ -0,0 +1,264 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test touch event simulation.
+const TEST_DOCUMENT = "target_configuration_test_doc.sjs";
+const TEST_URI = URL_ROOT_COM_SSL + TEST_DOCUMENT;
+
+add_task(async function () {
+ // Disable click hold and double tap zooming as it might interfere with the test
+ await pushPref("ui.click_hold_context_menus", false);
+ await pushPref("apz.allow_double_tap_zooming", false);
+
+ const tab = await addTab(TEST_URI);
+
+ info("Create commands for the tab");
+ const commands = await CommandsFactory.forTab(tab);
+
+ const targetConfigurationCommand = commands.targetConfigurationCommand;
+ const targetCommand = commands.targetCommand;
+ await targetCommand.startListening();
+
+ info("Touch simulation is disabled at the beginning");
+ await checkTopLevelDocumentTouchSimulation({ enabled: false });
+ await checkIframeTouchSimulation({
+ enabled: false,
+ });
+
+ info("Enable touch simulation");
+ await targetConfigurationCommand.updateConfiguration({
+ touchEventsOverride: "enabled",
+ });
+ await checkTopLevelDocumentTouchSimulation({ enabled: true });
+ await checkIframeTouchSimulation({
+ enabled: true,
+ });
+
+ info("Reload the page");
+ await BrowserTestUtils.reloadTab(tab, /* includeSubFrames */ true);
+
+ is(
+ await topLevelDocumentMatchesCoarsePointerAtStartup(),
+ true,
+ "The touch simulation was enabled in the content page when it loaded after reloading"
+ );
+ await checkTopLevelDocumentTouchSimulation({ enabled: true });
+
+ is(
+ await iframeMatchesCoarsePointerAtStartup(),
+ true,
+ "The touch simulation was enabled in the iframe when it loaded after reloading"
+ );
+ await checkIframeTouchSimulation({
+ enabled: true,
+ });
+
+ info(
+ "Create another commands instance and check that destroying it won't reset the touch simulation"
+ );
+ const otherCommands = await CommandsFactory.forTab(tab);
+ const otherTargetConfigurationCommand =
+ otherCommands.targetConfigurationCommand;
+ const otherTargetCommand = otherCommands.targetCommand;
+
+ await otherTargetCommand.startListening();
+ // Watch targets so we wait for server communication to settle (e.g. attach calls), as
+ // this could cause intermittent failures.
+ await otherTargetCommand.watchTargets({
+ types: [otherTargetCommand.TYPES.FRAME],
+ onAvailable: () => {},
+ });
+
+ // Let's update the configuration with this commands instance to make sure we hit the TargetConfigurationActor
+ await otherTargetConfigurationCommand.updateConfiguration({
+ colorSchemeSimulation: "dark",
+ });
+
+ otherTargetCommand.destroy();
+ await otherCommands.destroy();
+
+ await checkTopLevelDocumentTouchSimulation({ enabled: true });
+ await checkIframeTouchSimulation({
+ enabled: true,
+ });
+
+ const previousBrowsingContextId = gBrowser.selectedBrowser.browsingContext.id;
+ info(
+ "Check that navigating to a page that forces the creation of a new browsing context keep the simulation enabled"
+ );
+
+ const onBrowserLoaded = BrowserTestUtils.browserLoaded(
+ gBrowser.selectedBrowser,
+ true
+ );
+ BrowserTestUtils.loadURIString(
+ gBrowser.selectedBrowser,
+ URL_ROOT_ORG_SSL + TEST_DOCUMENT + "?crossOriginIsolated=true"
+ );
+ await onBrowserLoaded;
+
+ isnot(
+ gBrowser.selectedBrowser.browsingContext.id,
+ previousBrowsingContextId,
+ "A new browsing context was created"
+ );
+
+ is(
+ await topLevelDocumentMatchesCoarsePointerAtStartup(),
+ true,
+ "The touch simulation was enabled in the content page when it loaded after navigating to a new browsing context"
+ );
+ await checkTopLevelDocumentTouchSimulation({
+ enabled: true,
+ });
+
+ is(
+ await iframeMatchesCoarsePointerAtStartup(),
+ true,
+ "The touch simulation was enabled in the iframe when it loaded after navigating to a new browsing context"
+ );
+ await checkIframeTouchSimulation({
+ enabled: true,
+ });
+
+ info(
+ "Check that destroying the commands we enabled the simulation in will disable the simulation"
+ );
+ targetCommand.destroy();
+ await commands.destroy();
+
+ await checkTopLevelDocumentTouchSimulation({ enabled: false });
+ await checkIframeTouchSimulation({
+ enabled: false,
+ });
+});
+
+function matchesCoarsePointer(browserOrBrowsingContext) {
+ return SpecialPowers.spawn(
+ browserOrBrowsingContext,
+ [],
+ () => content.matchMedia("(pointer: coarse)").matches
+ );
+}
+
+function matchesCoarsePointerAtStartup(browserOrBrowsingContext) {
+ return SpecialPowers.spawn(
+ browserOrBrowsingContext,
+ [],
+ () => content.wrappedJSObject.initialMatchesCoarsePointer
+ );
+}
+
+async function isTouchEventEmitted(browserOrBrowsingContext) {
+ const onTimeout = wait(1000).then(() => "TIMEOUT");
+ const onTouchEvent = SpecialPowers.spawn(
+ browserOrBrowsingContext,
+ [],
+ async () => {
+ content.touchStartController = new content.AbortController();
+ const el = content.document.querySelector("button");
+
+ let gotTouchEndEvent = false;
+
+ const promise = new Promise(resolve => {
+ el.addEventListener(
+ "touchend",
+ () => {
+ gotTouchEndEvent = true;
+ resolve();
+ },
+ {
+ signal: content.touchStartController.signal,
+ once: true,
+ }
+ );
+ });
+
+ // For some reason, it might happen that the event is properly registered and transformed
+ // in the touch simulator, but not received by the event listener we set up just before.
+ // So here let's try to "tap" 3 times to give us more chance to catch the event.
+ for (let i = 0; i < 3; i++) {
+ if (gotTouchEndEvent) {
+ break;
+ }
+
+ // Simulate a "tap" with mousedown and then mouseup.
+ EventUtils.synthesizeMouseAtCenter(
+ el,
+ { type: "mousedown", isSynthesized: false },
+ content
+ );
+
+ await new Promise(res => content.setTimeout(res, 10));
+ EventUtils.synthesizeMouseAtCenter(
+ el,
+ { type: "mouseup", isSynthesized: false },
+ content
+ );
+ await new Promise(res => content.setTimeout(res, 50));
+ }
+
+ return promise;
+ }
+ );
+
+ const result = await Promise.race([onTimeout, onTouchEvent]);
+
+ // Remove the event listener
+ await SpecialPowers.spawn(browserOrBrowsingContext, [], () => {
+ content.touchStartController.abort();
+ delete content.touchStartController;
+ });
+
+ return result !== "TIMEOUT";
+}
+
+async function checkTopLevelDocumentTouchSimulation({ enabled }) {
+ is(
+ await matchesCoarsePointer(gBrowser.selectedBrowser),
+ enabled,
+ `The touch simulation is ${
+ enabled ? "enabled" : "disabled"
+ } on the top level document`
+ );
+
+ is(
+ await isTouchEventEmitted(gBrowser.selectedBrowser),
+ enabled,
+ `touch events are ${enabled ? "" : "not "}emitted on the top level document`
+ );
+}
+
+function topLevelDocumentMatchesCoarsePointerAtStartup() {
+ return matchesCoarsePointerAtStartup(gBrowser.selectedBrowser);
+}
+
+function getIframeBrowsingContext() {
+ return SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ () => content.document.querySelector("iframe").browsingContext
+ );
+}
+
+async function checkIframeTouchSimulation({ enabled }) {
+ const iframeBC = await getIframeBrowsingContext();
+ is(
+ await matchesCoarsePointer(iframeBC),
+ enabled,
+ `The touch simulation is ${enabled ? "enabled" : "disabled"} on the iframe`
+ );
+
+ is(
+ await isTouchEventEmitted(iframeBC),
+ enabled,
+ `touch events are ${enabled ? "" : "not "}emitted on the iframe`
+ );
+}
+
+async function iframeMatchesCoarsePointerAtStartup() {
+ const iframeBC = await getIframeBrowsingContext();
+ return matchesCoarsePointerAtStartup(iframeBC);
+}
diff --git a/devtools/shared/commands/target-configuration/tests/head.js b/devtools/shared/commands/target-configuration/tests/head.js
new file mode 100644
index 0000000000..ce65b3d827
--- /dev/null
+++ b/devtools/shared/commands/target-configuration/tests/head.js
@@ -0,0 +1,12 @@
+/* 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
+);
diff --git a/devtools/shared/commands/target-configuration/tests/target_configuration_test_doc.sjs b/devtools/shared/commands/target-configuration/tests/target_configuration_test_doc.sjs
new file mode 100644
index 0000000000..a10b67c2b9
--- /dev/null
+++ b/devtools/shared/commands/target-configuration/tests/target_configuration_test_doc.sjs
@@ -0,0 +1,101 @@
+"use strict";
+
+function handleRequest(request, response) {
+ response.setHeader("Content-Type", "html", false);
+
+ // Check the params and set the cross-origin-opener policy headers if needed
+ Cu.importGlobalProperties(["URLSearchParams"]);
+ const query = new URLSearchParams(request.queryString);
+ if (query.get("crossOriginIsolated") === "true") {
+ response.setHeader("Cross-Origin-Opener-Policy", "same-origin", false);
+ }
+
+ // We always want the iframe to have a different host from the top-level document.
+ const iframeHost =
+ request.host === "example.com" ? "example.org" : "example.com";
+ const iframeOrigin = `${request.scheme}://${iframeHost}`;
+
+ const IFRAME_HTML = `
+ <!doctype html>
+ <html>
+ <head>
+ <meta charset=utf8>
+ <script>
+ globalThis.initialMatchesPrefersDarkColorScheme =
+ window.matchMedia("(prefers-color-scheme: dark)").matches;
+ globalThis.initialMatchesCoarsePointer =
+ window.matchMedia("(pointer: coarse)").matches;
+ globalThis.initialDevicePixelRatio = window.devicePixelRatio;
+ globalThis.initialUserAgent = navigator.userAgent;
+ </script>
+ <style>
+ html { background: cyan;}
+
+ button {
+ font-size: 2em;
+ padding-inline: 1em;
+ }
+
+ @media (prefers-color-scheme: dark) {
+ html {background: darkred;}
+ }
+
+ </style>
+ </head>
+ <body>
+ <h1>Iframe</h1>
+ <button>Target</button>
+ </body>
+ </html>`;
+
+ const HTML = `
+ <!doctype html>
+ <html>
+ <head>
+ <meta charset=utf8>
+ <title>test</title>
+ <script type="application/javascript">
+ "use strict";
+
+ /*
+ * Store the result of dark color-scheme match very early in the document loading process
+ * so we can assert in tests that the simulation starts early enough.
+ */
+ globalThis.initialMatchesPrefersDarkColorScheme =
+ window.matchMedia("(prefers-color-scheme: dark)").matches;
+ globalThis.initialMatchesCoarsePointer =
+ window.matchMedia("(pointer: coarse)").matches;
+ globalThis.initialDevicePixelRatio = window.devicePixelRatio
+ globalThis.initialUserAgent = navigator.userAgent;
+
+
+ </script>
+ <style>
+ iframe {
+ display: block;
+ margin-top: 1em;
+ }
+
+ button {
+ font-size: 2em;
+ padding-inline: 1em;
+ }
+
+ @media (prefers-color-scheme: dark) {
+ html {
+ background-color: darkblue;
+ }
+ }
+ </style>
+ </head>
+ <body>
+ <h1>Test color-scheme simulation</h1>
+ <button>Target</button>
+ <iframe src='${iframeOrigin}/document-builder.sjs?html=${encodeURI(
+ IFRAME_HTML
+ )}'></iframe>
+ </body>
+ </html>`;
+
+ response.write(HTML);
+}
diff --git a/devtools/shared/commands/target/actions/moz.build b/devtools/shared/commands/target/actions/moz.build
new file mode 100644
index 0000000000..e9429c1200
--- /dev/null
+++ b/devtools/shared/commands/target/actions/moz.build
@@ -0,0 +1,7 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DevToolsModules(
+ "targets.js",
+)
diff --git a/devtools/shared/commands/target/actions/targets.js b/devtools/shared/commands/target/actions/targets.js
new file mode 100644
index 0000000000..577e5fedd3
--- /dev/null
+++ b/devtools/shared/commands/target/actions/targets.js
@@ -0,0 +1,33 @@
+/* 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";
+
+function registerTarget(targetFront) {
+ return { type: "REGISTER_TARGET", targetFront };
+}
+
+function unregisterTarget(targetFront) {
+ return { type: "UNREGISTER_TARGET", targetFront };
+}
+
+/**
+ *
+ * @param {String} targetActorID: The actorID of the target we want to select.
+ */
+function selectTarget(targetActorID) {
+ return function ({ dispatch, getState }) {
+ dispatch({ type: "SELECT_TARGET", targetActorID });
+ };
+}
+
+function refreshTargets() {
+ return { type: "REFRESH_TARGETS" };
+}
+
+module.exports = {
+ registerTarget,
+ unregisterTarget,
+ selectTarget,
+ refreshTargets,
+};
diff --git a/devtools/shared/commands/target/legacy-target-watchers/legacy-processes-watcher.js b/devtools/shared/commands/target/legacy-target-watchers/legacy-processes-watcher.js
new file mode 100644
index 0000000000..e0c5b18d51
--- /dev/null
+++ b/devtools/shared/commands/target/legacy-target-watchers/legacy-processes-watcher.js
@@ -0,0 +1,72 @@
+/* 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";
+
+class LegacyProcessesWatcher {
+ constructor(targetCommand, onTargetAvailable, onTargetDestroyed) {
+ this.targetCommand = targetCommand;
+ this.rootFront = targetCommand.rootFront;
+
+ this.onTargetAvailable = onTargetAvailable;
+ this.onTargetDestroyed = onTargetDestroyed;
+
+ this.descriptors = new Set();
+ this._processListChanged = this._processListChanged.bind(this);
+ }
+
+ async _processListChanged() {
+ if (this.targetCommand.isDestroyed()) {
+ return;
+ }
+
+ const processes = await this.rootFront.listProcesses();
+ // Process the new list to detect the ones being destroyed
+ // Force destroyed the descriptor as well as the target
+ for (const descriptor of this.descriptors) {
+ if (!processes.includes(descriptor)) {
+ // Manually call onTargetDestroyed listeners in order to
+ // ensure calling them *before* destroying the descriptor.
+ // Otherwise the descriptor will automatically destroy the target
+ // and may not fire the contentProcessTarget's destroy event.
+ const target = descriptor.getCachedTarget();
+ if (target) {
+ this.onTargetDestroyed(target);
+ }
+
+ descriptor.destroy();
+ this.descriptors.delete(descriptor);
+ }
+ }
+
+ const promises = processes
+ .filter(descriptor => !this.descriptors.has(descriptor))
+ .map(async descriptor => {
+ // Add the new process descriptors to the local list
+ this.descriptors.add(descriptor);
+ const target = await descriptor.getTarget();
+ if (!target) {
+ console.error(
+ "Wasn't able to retrieve the target for",
+ descriptor.actorID
+ );
+ return;
+ }
+ await this.onTargetAvailable(target);
+ });
+
+ await Promise.all(promises);
+ }
+
+ async listen() {
+ this.rootFront.on("processListChanged", this._processListChanged);
+ await this._processListChanged();
+ }
+
+ unlisten() {
+ this.rootFront.off("processListChanged", this._processListChanged);
+ }
+}
+
+module.exports = LegacyProcessesWatcher;
diff --git a/devtools/shared/commands/target/legacy-target-watchers/legacy-serviceworkers-watcher.js b/devtools/shared/commands/target/legacy-target-watchers/legacy-serviceworkers-watcher.js
new file mode 100644
index 0000000000..adaeb9def4
--- /dev/null
+++ b/devtools/shared/commands/target/legacy-target-watchers/legacy-serviceworkers-watcher.js
@@ -0,0 +1,316 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const {
+ WorkersListener,
+ // eslint-disable-next-line mozilla/reject-some-requires
+} = require("resource://devtools/client/shared/workers-listener.js");
+
+const LegacyWorkersWatcher = require("resource://devtools/shared/commands/target/legacy-target-watchers/legacy-workers-watcher.js");
+
+class LegacyServiceWorkersWatcher extends LegacyWorkersWatcher {
+ // Holds the current target URL object
+ #currentTargetURL;
+
+ constructor(targetCommand, onTargetAvailable, onTargetDestroyed, commands) {
+ super(targetCommand, onTargetAvailable, onTargetDestroyed);
+ this._registrations = [];
+ this._processTargets = new Set();
+ this.commands = commands;
+
+ // We need to listen for registration changes at least in order to properly
+ // filter service workers by domain when debugging a local tab.
+ //
+ // A WorkerTarget instance has a url property, but it points to the url of
+ // the script, whereas the url property of the ServiceWorkerRegistration
+ // points to the URL controlled by the service worker.
+ //
+ // Historically we have been matching the service worker registration URL
+ // to match service workers for local tab tools (app panel & debugger).
+ // Maybe here we could have some more info on the actual worker.
+ this._workersListener = new WorkersListener(this.rootFront, {
+ registrationsOnly: true,
+ });
+
+ // Note that this is called much more often than when a registration
+ // is created or destroyed. WorkersListener notifies of anything that
+ // potentially impacted workers.
+ // I use it as a shortcut in this first patch. Listening to rootFront's
+ // "serviceWorkerRegistrationListChanged" should be enough to be notified
+ // about registrations. And if we need to also update the
+ // "debuggerServiceWorkerStatus" from here, then we would have to
+ // also listen to "registration-changed" one each registration.
+ this._onRegistrationListChanged =
+ this._onRegistrationListChanged.bind(this);
+ this._onDocumentEvent = this._onDocumentEvent.bind(this);
+
+ // Flag used from the parent class to listen to process targets.
+ // Decision tree is complicated, keep all logic in the parent methods.
+ this._isServiceWorkerWatcher = true;
+ }
+
+ /**
+ * Override from LegacyWorkersWatcher.
+ *
+ * We record all valid service worker targets (ie workers that match a service
+ * worker registration), but we will only notify about the ones which match
+ * the current domain.
+ */
+ _recordWorkerTarget(workerTarget) {
+ return !!this._getRegistrationForWorkerTarget(workerTarget);
+ }
+
+ // Override from LegacyWorkersWatcher.
+ _supportWorkerTarget(workerTarget) {
+ if (!workerTarget.isServiceWorker) {
+ return false;
+ }
+
+ const registration = this._getRegistrationForWorkerTarget(workerTarget);
+ return registration && this._isRegistrationValidForTarget(registration);
+ }
+
+ // Override from LegacyWorkersWatcher.
+ async listen() {
+ // Listen to the current target front.
+ this.target = this.targetCommand.targetFront;
+
+ if (this.targetCommand.descriptorFront.isTabDescriptor) {
+ this.#currentTargetURL = new URL(this.targetCommand.targetFront.url);
+ }
+
+ this._workersListener.addListener(this._onRegistrationListChanged);
+
+ // Fetch the registrations before calling listen, since service workers
+ // might already be available and will need to be compared with the existing
+ // registrations.
+ await this._onRegistrationListChanged();
+
+ if (this.targetCommand.descriptorFront.isTabDescriptor) {
+ await this.commands.resourceCommand.watchResources(
+ [this.commands.resourceCommand.TYPES.DOCUMENT_EVENT],
+ {
+ onAvailable: this._onDocumentEvent,
+ ignoreExistingResources: true,
+ }
+ );
+ }
+
+ await super.listen();
+ }
+
+ // Override from LegacyWorkersWatcher.
+ unlisten(...args) {
+ this._workersListener.removeListener(this._onRegistrationListChanged);
+
+ if (this.targetCommand.descriptorFront.isTabDescriptor) {
+ this.commands.resourceCommand.unwatchResources(
+ [this.commands.resourceCommand.TYPES.DOCUMENT_EVENT],
+ {
+ onAvailable: this._onDocumentEvent,
+ }
+ );
+ }
+
+ super.unlisten(...args);
+ }
+
+ // Override from LegacyWorkersWatcher.
+ async _onProcessAvailable({ targetFront }) {
+ if (this.targetCommand.descriptorFront.isTabDescriptor) {
+ // XXX: This has been ported straight from the current debugger
+ // implementation. Since pauseMatchingServiceWorkers expects an origin
+ // to filter matching workers, it only makes sense when we are debugging
+ // a tab. However in theory, parent process debugging could pause all
+ // service workers without matching anything.
+ try {
+ // To support early breakpoint we need to setup the
+ // `pauseMatchingServiceWorkers` mechanism in each process.
+ await targetFront.pauseMatchingServiceWorkers({
+ origin: this.#currentTargetURL.origin,
+ });
+ } catch (e) {
+ if (targetFront.actorID) {
+ throw e;
+ } else {
+ console.warn(
+ "Process target destroyed while calling pauseMatchingServiceWorkers"
+ );
+ }
+ }
+ }
+
+ this._processTargets.add(targetFront);
+ return super._onProcessAvailable({ targetFront });
+ }
+
+ _shouldDestroyTargetsOnNavigation() {
+ return !!this.targetCommand.destroyServiceWorkersOnNavigation;
+ }
+
+ _onProcessDestroyed({ targetFront }) {
+ this._processTargets.delete(targetFront);
+ return super._onProcessDestroyed({ targetFront });
+ }
+
+ _onDocumentEvent(resources) {
+ for (const resource of resources) {
+ if (
+ resource.resourceType !==
+ this.commands.resourceCommand.TYPES.DOCUMENT_EVENT
+ ) {
+ continue;
+ }
+
+ if (resource.name === "will-navigate") {
+ // We rely on will-navigate as the onTargetAvailable for the top-level frame can
+ // happen after the onTargetAvailable for processes (handled in _onProcessAvailable),
+ // where we need the origin we navigate to.
+ this.#currentTargetURL = new URL(resource.newURI);
+ continue;
+ }
+
+ // Note that we rely on "dom-loading" rather than "will-navigate" because the
+ // destroyed/available callbacks should be triggered after the Debugger
+ // has cleaned up its reducers, which happens on "will-navigate".
+ // On the other end, "dom-complete", which is a better mapping of "navigate", is
+ // happening too late (because of resources being throttled), and would cause failures
+ // in test (like browser_target_command_service_workers_navigation.js), as the new worker
+ // target would already be registered at this point, and seen as something that would
+ // need to be destroyed.
+ if (resource.name === "dom-loading") {
+ const allServiceWorkerTargets = this._getAllServiceWorkerTargets();
+ const shouldDestroy = this._shouldDestroyTargetsOnNavigation();
+
+ for (const target of allServiceWorkerTargets) {
+ const isRegisteredBefore =
+ this.targetCommand.isTargetRegistered(target);
+ if (shouldDestroy && isRegisteredBefore) {
+ // Instruct the target command to notify about the worker target destruction
+ // but do not destroy the front as we want to keep using it.
+ // We will notify about it again via onTargetAvailable.
+ this.onTargetDestroyed(target, { shouldDestroyTargetFront: false });
+ }
+
+ // Note: we call isTargetRegistered again because calls to
+ // onTargetDestroyed might have modified the list of registered targets.
+ const isRegisteredAfter =
+ this.targetCommand.isTargetRegistered(target);
+ const isValidTarget = this._supportWorkerTarget(target);
+ if (isValidTarget && !isRegisteredAfter) {
+ // If the target is still valid for the current top target, call
+ // onTargetAvailable as well.
+ this.onTargetAvailable(target);
+ }
+ }
+ }
+ }
+ }
+
+ async _onRegistrationListChanged() {
+ if (this.targetCommand.isDestroyed()) {
+ return;
+ }
+
+ await this._updateRegistrations();
+
+ // Everything after this point is not strictly necessary for sw support
+ // in the target list, but it makes the behavior closer to the previous
+ // listAllWorkers/WorkersListener pair.
+ const allServiceWorkerTargets = this._getAllServiceWorkerTargets();
+ for (const target of allServiceWorkerTargets) {
+ const hasRegistration = this._getRegistrationForWorkerTarget(target);
+ if (!hasRegistration) {
+ // XXX: At this point the worker target is not really destroyed, but
+ // historically, listAllWorkers* APIs stopped returning worker targets
+ // if worker registrations are no longer available.
+ if (this.targetCommand.isTargetRegistered(target)) {
+ // Only emit onTargetDestroyed if it wasn't already done by
+ // onNavigate (ie the target is still tracked by TargetCommand)
+ this.onTargetDestroyed(target);
+ }
+ // Here we only care about service workers which no longer match *any*
+ // registration. The worker will be completely destroyed soon, remove
+ // it from the legacy worker watcher internal targetsByProcess Maps.
+ this._removeTargetReferences(target);
+ }
+ }
+ }
+
+ // Delete the provided worker target from the internal targetsByProcess Maps.
+ _removeTargetReferences(target) {
+ const allProcessTargets = this._getProcessTargets().filter(t =>
+ this.targetsByProcess.get(t)
+ );
+
+ for (const processTarget of allProcessTargets) {
+ this.targetsByProcess.get(processTarget).delete(target);
+ }
+ }
+
+ async _updateRegistrations() {
+ const { registrations } =
+ await this.rootFront.listServiceWorkerRegistrations();
+
+ this._registrations = registrations;
+ }
+
+ _getRegistrationForWorkerTarget(workerTarget) {
+ return this._registrations.find(r => {
+ return (
+ r.evaluatingWorker?.id === workerTarget.id ||
+ r.activeWorker?.id === workerTarget.id ||
+ r.installingWorker?.id === workerTarget.id ||
+ r.waitingWorker?.id === workerTarget.id
+ );
+ });
+ }
+
+ _getProcessTargets() {
+ return [...this._processTargets];
+ }
+
+ // Flatten all service worker targets in all processes.
+ _getAllServiceWorkerTargets() {
+ const allProcessTargets = this._getProcessTargets().filter(target =>
+ this.targetsByProcess.get(target)
+ );
+
+ const serviceWorkerTargets = [];
+ for (const target of allProcessTargets) {
+ serviceWorkerTargets.push(...this.targetsByProcess.get(target));
+ }
+ return serviceWorkerTargets;
+ }
+
+ // Check if the registration is relevant for the current target, ie
+ // corresponds to the same domain.
+ _isRegistrationValidForTarget(registration) {
+ if (this.targetCommand.descriptorFront.isBrowserProcessDescriptor) {
+ // All registrations are valid for main process debugging.
+ return true;
+ }
+
+ if (!this.targetCommand.descriptorFront.isTabDescriptor) {
+ // No support for service worker targets outside of main process &
+ // tab debugging.
+ return false;
+ }
+
+ // For local tabs, we match ServiceWorkerRegistrations and the target
+ // if they share the same hostname for their "url" properties.
+ const targetDomain = this.#currentTargetURL.hostname;
+ try {
+ const registrationDomain = new URL(registration.url).hostname;
+ return registrationDomain === targetDomain;
+ } catch (e) {
+ // XXX: Some registrations have an empty URL.
+ return false;
+ }
+ }
+}
+
+module.exports = LegacyServiceWorkersWatcher;
diff --git a/devtools/shared/commands/target/legacy-target-watchers/legacy-sharedworkers-watcher.js b/devtools/shared/commands/target/legacy-target-watchers/legacy-sharedworkers-watcher.js
new file mode 100644
index 0000000000..b248e6aef7
--- /dev/null
+++ b/devtools/shared/commands/target/legacy-target-watchers/legacy-sharedworkers-watcher.js
@@ -0,0 +1,19 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const LegacyWorkersWatcher = require("resource://devtools/shared/commands/target/legacy-target-watchers/legacy-workers-watcher.js");
+
+class LegacySharedWorkersWatcher extends LegacyWorkersWatcher {
+ // Flag used from the parent class to listen to process targets.
+ // Decision tree is complicated, keep all logic in the parent methods.
+ _isSharedWorkerWatcher = true;
+
+ _supportWorkerTarget(workerTarget) {
+ return workerTarget.isSharedWorker;
+ }
+}
+
+module.exports = LegacySharedWorkersWatcher;
diff --git a/devtools/shared/commands/target/legacy-target-watchers/legacy-workers-watcher.js b/devtools/shared/commands/target/legacy-target-watchers/legacy-workers-watcher.js
new file mode 100644
index 0000000000..0baa14757b
--- /dev/null
+++ b/devtools/shared/commands/target/legacy-target-watchers/legacy-workers-watcher.js
@@ -0,0 +1,238 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const LegacyProcessesWatcher = require("resource://devtools/shared/commands/target/legacy-target-watchers/legacy-processes-watcher.js");
+
+class LegacyWorkersWatcher {
+ constructor(targetCommand, onTargetAvailable, onTargetDestroyed) {
+ this.targetCommand = targetCommand;
+ this.rootFront = targetCommand.rootFront;
+
+ this.onTargetAvailable = onTargetAvailable;
+ this.onTargetDestroyed = onTargetDestroyed;
+
+ this.targetsByProcess = new WeakMap();
+ this.targetsListeners = new WeakMap();
+
+ this._onProcessAvailable = this._onProcessAvailable.bind(this);
+ this._onProcessDestroyed = this._onProcessDestroyed.bind(this);
+ }
+
+ async _onProcessAvailable({ targetFront }) {
+ this.targetsByProcess.set(targetFront, new Set());
+ // Listen for worker which will be created later
+ const listener = this._workerListChanged.bind(this, targetFront);
+ this.targetsListeners.set(targetFront, listener);
+
+ // If this is the browser toolbox, we have to listen from the RootFront
+ // (see comment in _workerListChanged)
+ const front = targetFront.isParentProcess ? this.rootFront : targetFront;
+ front.on("workerListChanged", listener);
+
+ // We also need to process the already existing workers
+ await this._workerListChanged(targetFront);
+ }
+
+ async _onProcessDestroyed({ targetFront }) {
+ const existingTargets = this.targetsByProcess.get(targetFront);
+
+ // Process the new list to detect the ones being destroyed
+ // Force destroying the targets
+ for (const target of existingTargets) {
+ this.onTargetDestroyed(target);
+
+ target.destroy();
+ existingTargets.delete(target);
+ }
+ this.targetsByProcess.delete(targetFront);
+ this.targetsListeners.delete(targetFront);
+ }
+
+ _supportWorkerTarget(workerTarget) {
+ // subprocess workers are ignored because they take several seconds to
+ // attach to when opening the browser toolbox. See bug 1594597.
+ // When attaching we get the following error:
+ // JavaScript error: resource://devtools/server/startup/worker.js,
+ // line 37: NetworkError: WorkerDebuggerGlobalScope.loadSubScript: Failed to load worker script at resource://devtools/shared/worker/loader.js (nsresult = 0x805e0006)
+ return (
+ workerTarget.isDedicatedWorker &&
+ !workerTarget.url.startsWith(
+ "resource://gre/modules/subprocess/subprocess_worker"
+ )
+ );
+ }
+
+ async _workerListChanged(targetFront) {
+ // If we're in the Browser Toolbox, query workers from the Root Front instead of the
+ // ParentProcessTarget as the ParentProcess Target filters out the workers to only
+ // show the one from the top level window, whereas we expect the one from all the
+ // windows, and also the window-less ones.
+ // TODO: For Content Toolbox, expose SW of the page, maybe optionally?
+ const front = targetFront.isParentProcess ? this.rootFront : targetFront;
+ if (!front || front.isDestroyed() || this.targetCommand.isDestroyed()) {
+ return;
+ }
+
+ let workers;
+ try {
+ ({ workers } = await front.listWorkers());
+ } catch (e) {
+ // Workers may be added/removed at anytime so that listWorkers request
+ // can be spawn during a toolbox destroy sequence and easily fail
+ if (front.isDestroyed()) {
+ return;
+ }
+ throw e;
+ }
+
+ // Fetch the list of already existing worker targets for this process target front.
+ const existingTargets = this.targetsByProcess.get(targetFront);
+ if (!existingTargets) {
+ // unlisten was called while processing the workerListChanged callback.
+ return;
+ }
+
+ // Process the new list to detect the ones being destroyed
+ // Force destroying the targets
+ for (const target of existingTargets) {
+ if (!workers.includes(target)) {
+ this.onTargetDestroyed(target);
+
+ target.destroy();
+ existingTargets.delete(target);
+ }
+ }
+
+ const promises = workers.map(workerTarget =>
+ this._processNewWorkerTarget(workerTarget, existingTargets)
+ );
+ await Promise.all(promises);
+ }
+
+ // This is overloaded for Service Workers, which records all SW targets,
+ // but only notify about a subset of them.
+ _recordWorkerTarget(workerTarget) {
+ return this._supportWorkerTarget(workerTarget);
+ }
+
+ async _processNewWorkerTarget(workerTarget, existingTargets) {
+ if (
+ !this._recordWorkerTarget(workerTarget) ||
+ existingTargets.has(workerTarget) ||
+ this.targetCommand.isDestroyed()
+ ) {
+ return;
+ }
+
+ // Add the new worker targets to the local list
+ existingTargets.add(workerTarget);
+
+ if (this._supportWorkerTarget(workerTarget)) {
+ await this.onTargetAvailable(workerTarget);
+ }
+ }
+
+ async listen() {
+ // Listen to the current target front.
+ this.target = this.targetCommand.targetFront;
+
+ if (this.target.isParentProcess) {
+ await this.targetCommand.watchTargets({
+ types: [this.targetCommand.TYPES.PROCESS],
+ onAvailable: this._onProcessAvailable,
+ onDestroyed: this._onProcessDestroyed,
+ });
+
+ // The ParentProcessTarget front is considered to be a FRAME instead of a PROCESS.
+ // So process it manually here.
+ await this._onProcessAvailable({ targetFront: this.target });
+ return;
+ }
+
+ if (this._isSharedWorkerWatcher) {
+ // Here we're not in the browser toolbox, and SharedWorker targets are not supported
+ // in regular toolbox (See Bug 1607778)
+ return;
+ }
+
+ if (this._isServiceWorkerWatcher) {
+ this._legacyProcessesWatcher = new LegacyProcessesWatcher(
+ this.targetCommand,
+ async targetFront => {
+ // Service workers only live in content processes.
+ if (!targetFront.isParentProcess) {
+ await this._onProcessAvailable({ targetFront });
+ }
+ },
+ targetFront => {
+ if (!targetFront.isParentProcess) {
+ this._onProcessDestroyed({ targetFront });
+ }
+ }
+ );
+ await this._legacyProcessesWatcher.listen();
+ return;
+ }
+
+ // Here, we're handling Dedicated Workers in content toolbox.
+ this.targetsByProcess.set(
+ this.target,
+ this.targetsByProcess.get(this.target) || new Set()
+ );
+ this._workerListChangedListener = this._workerListChanged.bind(
+ this,
+ this.target
+ );
+ this.target.on("workerListChanged", this._workerListChangedListener);
+ await this._workerListChanged(this.target);
+ }
+
+ _getProcessTargets() {
+ return this.targetCommand.getAllTargets([this.targetCommand.TYPES.PROCESS]);
+ }
+
+ unlisten({ isTargetSwitching } = {}) {
+ // Stop listening for new process targets.
+ if (this.target.isParentProcess) {
+ this.targetCommand.unwatchTargets({
+ types: [this.targetCommand.TYPES.PROCESS],
+ onAvailable: this._onProcessAvailable,
+ onDestroyed: this._onProcessDestroyed,
+ });
+ } else if (this._isServiceWorkerWatcher) {
+ this._legacyProcessesWatcher.unlisten();
+ }
+
+ // Cleanup the targetsByProcess/targetsListeners maps, and unsubscribe from
+ // all targetFronts. Process target fronts are either stored locally when
+ // watching service workers for the content toolbox, or can be retrieved via
+ // the TargetCommand API otherwise (see _getProcessTargets implementations).
+ if (this.target.isParentProcess || this._isServiceWorkerWatcher) {
+ for (const targetFront of this._getProcessTargets()) {
+ const listener = this.targetsListeners.get(targetFront);
+ targetFront.off("workerListChanged", listener);
+
+ // When unlisten is called from a target switch and service workers targets are not
+ // destroyed on navigation, we don't want to remove the targets from targetsByProcess
+ if (
+ !isTargetSwitching ||
+ !this._isServiceWorkerWatcher ||
+ this.targetCommand.destroyServiceWorkersOnNavigation
+ ) {
+ this.targetsByProcess.delete(targetFront);
+ }
+ this.targetsListeners.delete(targetFront);
+ }
+ } else {
+ this.target.off("workerListChanged", this._workerListChangedListener);
+ delete this._workerListChangedListener;
+ this.targetsByProcess.delete(this.target);
+ this.targetsListeners.delete(this.target);
+ }
+ }
+}
+
+module.exports = LegacyWorkersWatcher;
diff --git a/devtools/shared/commands/target/legacy-target-watchers/moz.build b/devtools/shared/commands/target/legacy-target-watchers/moz.build
new file mode 100644
index 0000000000..60fdd7ec22
--- /dev/null
+++ b/devtools/shared/commands/target/legacy-target-watchers/moz.build
@@ -0,0 +1,10 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DevToolsModules(
+ "legacy-processes-watcher.js",
+ "legacy-serviceworkers-watcher.js",
+ "legacy-sharedworkers-watcher.js",
+ "legacy-workers-watcher.js",
+)
diff --git a/devtools/shared/commands/target/moz.build b/devtools/shared/commands/target/moz.build
new file mode 100644
index 0000000000..c23940d7ed
--- /dev/null
+++ b/devtools/shared/commands/target/moz.build
@@ -0,0 +1,17 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DIRS += [
+ "actions",
+ "legacy-target-watchers",
+ "reducers",
+ "selectors",
+]
+
+DevToolsModules(
+ "target-command.js",
+)
+
+if CONFIG["MOZ_BUILD_APP"] != "mobile/android":
+ BROWSER_CHROME_MANIFESTS += ["tests/browser.ini"]
diff --git a/devtools/shared/commands/target/reducers/moz.build b/devtools/shared/commands/target/reducers/moz.build
new file mode 100644
index 0000000000..e9429c1200
--- /dev/null
+++ b/devtools/shared/commands/target/reducers/moz.build
@@ -0,0 +1,7 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DevToolsModules(
+ "targets.js",
+)
diff --git a/devtools/shared/commands/target/reducers/targets.js b/devtools/shared/commands/target/reducers/targets.js
new file mode 100644
index 0000000000..2e93ddd7f0
--- /dev/null
+++ b/devtools/shared/commands/target/reducers/targets.js
@@ -0,0 +1,70 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
+"use strict";
+
+const initialReducerState = {
+ // Array of targetFront
+ targets: [],
+ // The selected targetFront instance
+ selected: null,
+ // timestamp of the last time a target was updated (i.e. url/title was updated).
+ // This is used by the EvaluationContextSelector component to re-render the list of
+ // targets when the list itself did not change (no addition/removal)
+ lastTargetRefresh: Date.now(),
+};
+
+function update(state = initialReducerState, action) {
+ switch (action.type) {
+ case "SELECT_TARGET": {
+ const { targetActorID } = action;
+
+ if (state.selected?.actorID === targetActorID) {
+ return state;
+ }
+
+ const selectedTarget = state.targets.find(
+ target => target.actorID === targetActorID
+ );
+
+ // It's possible that the target reducer is missing a target
+ // e.g. workers, remote iframes, etc. (Bug 1594754)
+ if (!selectedTarget) {
+ return state;
+ }
+
+ return { ...state, selected: selectedTarget };
+ }
+
+ case "REGISTER_TARGET": {
+ return {
+ ...state,
+ targets: [...state.targets, action.targetFront],
+ };
+ }
+
+ case "REFRESH_TARGETS": {
+ // The data _in_ targetFront was updated, so we only need to mutate the state,
+ // while keeping the same values.
+ return {
+ ...state,
+ lastTargetRefresh: Date.now(),
+ };
+ }
+
+ case "UNREGISTER_TARGET": {
+ const targets = state.targets.filter(
+ target => target !== action.targetFront
+ );
+
+ let { selected } = state;
+ if (selected === action.targetFront) {
+ selected = null;
+ }
+
+ return { ...state, targets, selected };
+ }
+ }
+ return state;
+}
+module.exports = update;
diff --git a/devtools/shared/commands/target/selectors/moz.build b/devtools/shared/commands/target/selectors/moz.build
new file mode 100644
index 0000000000..e9429c1200
--- /dev/null
+++ b/devtools/shared/commands/target/selectors/moz.build
@@ -0,0 +1,7 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DevToolsModules(
+ "targets.js",
+)
diff --git a/devtools/shared/commands/target/selectors/targets.js b/devtools/shared/commands/target/selectors/targets.js
new file mode 100644
index 0000000000..95da81bbba
--- /dev/null
+++ b/devtools/shared/commands/target/selectors/targets.js
@@ -0,0 +1,20 @@
+/* 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";
+
+function getToolboxTargets(state) {
+ return state.targets;
+}
+
+function getSelectedTarget(state) {
+ return state.selected;
+}
+
+function getLastTargetRefresh(state) {
+ return state.lastTargetRefresh;
+}
+
+exports.getToolboxTargets = getToolboxTargets;
+exports.getSelectedTarget = getSelectedTarget;
+exports.getLastTargetRefresh = getLastTargetRefresh;
diff --git a/devtools/shared/commands/target/target-command.js b/devtools/shared/commands/target/target-command.js
new file mode 100644
index 0000000000..28e70c9f4b
--- /dev/null
+++ b/devtools/shared/commands/target/target-command.js
@@ -0,0 +1,1173 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EventEmitter = require("resource://devtools/shared/event-emitter.js");
+
+const BROWSERTOOLBOX_SCOPE_PREF = "devtools.browsertoolbox.scope";
+// Possible values of the previous pref:
+const BROWSERTOOLBOX_SCOPE_EVERYTHING = "everything";
+const BROWSERTOOLBOX_SCOPE_PARENTPROCESS = "parent-process";
+
+// eslint-disable-next-line mozilla/reject-some-requires
+const createStore = require("resource://devtools/client/shared/redux/create-store.js");
+const reducer = require("resource://devtools/shared/commands/target/reducers/targets.js");
+
+loader.lazyRequireGetter(
+ this,
+ ["refreshTargets", "registerTarget", "unregisterTarget"],
+ "resource://devtools/shared/commands/target/actions/targets.js",
+ true
+);
+
+class TargetCommand extends EventEmitter {
+ #selectedTargetFront;
+ /**
+ * This class helps managing, iterating over and listening for Targets.
+ *
+ * It exposes:
+ * - the top level target, typically the main process target for the browser toolbox
+ * or the browsing context target for a regular web toolbox
+ * - target of remoted iframe, in case Fission is enabled and some <iframe>
+ * are running in a distinct process
+ * - target switching. If the top level target changes for a new one,
+ * all the targets are going to be declared as destroyed and the new ones
+ * will be notified to the user of this API.
+ *
+ * @fires target-tread-wrong-order-on-resume : An event that is emitted when resuming
+ * the thread throws with the "wrongOrder" error.
+ *
+ * @param {DescriptorFront} descriptorFront
+ * The context to inspector identified by this descriptor.
+ * @param {WatcherFront} watcherFront
+ * If available, a reference to the related Watcher Front.
+ * @param {Object} commands
+ * The commands object with all interfaces defined from devtools/shared/commands/
+ */
+ constructor({ descriptorFront, watcherFront, commands }) {
+ super();
+
+ this.commands = commands;
+ this.descriptorFront = descriptorFront;
+ this.watcherFront = watcherFront;
+ this.rootFront = descriptorFront.client.mainRoot;
+
+ this.store = createStore(reducer);
+ // Name of the store used when calling createProvider.
+ this.storeId = "target-store";
+
+ this._updateBrowserToolboxScope =
+ this._updateBrowserToolboxScope.bind(this);
+
+ Services.prefs.addObserver(
+ BROWSERTOOLBOX_SCOPE_PREF,
+ this._updateBrowserToolboxScope
+ );
+ // Until Watcher actor notify about new top level target when navigating to another process
+ // we have to manually switch to a new target from the client side
+ this.onLocalTabRemotenessChange =
+ this.onLocalTabRemotenessChange.bind(this);
+ if (this.descriptorFront.isTabDescriptor) {
+ this.descriptorFront.on(
+ "remoteness-change",
+ this.onLocalTabRemotenessChange
+ );
+ }
+
+ if (this.isServerTargetSwitchingEnabled()) {
+ // XXX: Will only be used for local tab server side target switching if
+ // the first target is generated from the server.
+ this._onFirstTarget = new Promise(r => (this._resolveOnFirstTarget = r));
+ }
+
+ // Reports if we have at least one listener for the given target type
+ this._listenersStarted = new Set();
+
+ // List of all the target fronts
+ this._targets = new Set();
+ // {Map<Function, Set<targetFront>>} A Map keyed by `onAvailable` function passed to
+ // `watchTargets`, whose initial value is a Set of the existing target fronts at the
+ // time watchTargets is called.
+ this._pendingWatchTargetInitialization = new Map();
+
+ // Listeners for target creation, destruction and selection
+ this._createListeners = new EventEmitter();
+ this._destroyListeners = new EventEmitter();
+ this._selectListeners = new EventEmitter();
+
+ this._onTargetAvailable = this._onTargetAvailable.bind(this);
+ this._onTargetDestroyed = this._onTargetDestroyed.bind(this);
+ this._onTargetSelected = this._onTargetSelected.bind(this);
+ // Bug 1675763: Watcher actor is not available in all situations yet.
+ if (this.watcherFront) {
+ this.watcherFront.on("target-available", this._onTargetAvailable);
+ this.watcherFront.on("target-destroyed", this._onTargetDestroyed);
+ }
+
+ this.legacyImplementation = {};
+
+ // Public flag to allow listening for workers even if the fission pref is off
+ // This allows listening for workers in the content toolbox outside of fission contexts
+ // For now, this is only toggled by tests.
+ this.listenForWorkers =
+ this.rootFront.traits.workerConsoleApiMessagesDispatchedToMainThread ===
+ false;
+ this.listenForServiceWorkers = false;
+ this.destroyServiceWorkersOnNavigation = false;
+
+ // Tells us if we received the first top level target.
+ // If target switching is done on:
+ // * client side, this is done from startListening => _createFirstTarget
+ // and pull from the Descriptor front.
+ // * server side, this is also done from startListening,
+ // but we wait for the watcher actor to notify us about it
+ // via target-available-form avent.
+ this._gotFirstTopLevelTarget = false;
+ this._onResourceAvailable = this._onResourceAvailable.bind(this);
+ }
+
+ get selectedTargetFront() {
+ return this.#selectedTargetFront || this.targetFront;
+ }
+
+ /**
+ * Called fired when BROWSERTOOLBOX_SCOPE_PREF pref changes.
+ * This will enable/disable the full multiprocess debugging.
+ * When enabled we will watch for content process targets and debug all the processes.
+ * When disabled we will only watch for FRAME and WORKER and restrict ourself to parent process resources.
+ */
+ _updateBrowserToolboxScope() {
+ const browserToolboxScope = Services.prefs.getCharPref(
+ BROWSERTOOLBOX_SCOPE_PREF
+ );
+ if (browserToolboxScope == BROWSERTOOLBOX_SCOPE_EVERYTHING) {
+ // Force listening to new additional target types
+ this.startListening();
+ } else if (browserToolboxScope == BROWSERTOOLBOX_SCOPE_PARENTPROCESS) {
+ const disabledTargetTypes = [
+ TargetCommand.TYPES.FRAME,
+ TargetCommand.TYPES.PROCESS,
+ ];
+ // Force unwatching for additional targets types
+ // (we keep listening to workers)
+ // The related targets will be destroyed by the server
+ // and reported as destroyed to the frontend.
+ for (const type of disabledTargetTypes) {
+ this.stopListeningForType(type, {
+ isTargetSwitching: false,
+ isModeSwitching: true,
+ });
+ }
+ }
+ }
+
+ // Called whenever a new Target front is available.
+ // Either because a target was already available as we started calling startListening
+ // or if it has just been created
+ async _onTargetAvailable(targetFront) {
+ // We put the `commands` on the targetFront so it can be retrieved from any front easily.
+ // Without this, protocol.js fronts won't have any easy access to it.
+ // Ideally, Fronts would all be migrated to commands and we would no longer need this hack.
+ targetFront.commands = this.commands;
+
+ // If the new target is a top level target, we are target switching.
+ // Target-switching is only triggered for "local-tab" browsing-context
+ // targets which should always have the topLevelTarget flag initialized
+ // on the server.
+ const isTargetSwitching = targetFront.isTopLevel;
+ const isFirstTarget =
+ targetFront.isTopLevel && !this._gotFirstTopLevelTarget;
+
+ if (this._targets.has(targetFront)) {
+ // The top level target front can be reported via listProcesses in the
+ // case of the BrowserToolbox. For any other target, log an error if it is
+ // already registered.
+ if (targetFront != this.targetFront) {
+ console.error(
+ "Target is already registered in the TargetCommand",
+ targetFront.actorID
+ );
+ }
+ return;
+ }
+
+ if (this.isDestroyed() || targetFront.isDestroyedOrBeingDestroyed()) {
+ return;
+ }
+
+ // Handle top level target switching
+ // Note that, for now, `_onTargetAvailable` isn't called for the *initial* top level target.
+ // i.e. the one that is passed to TargetCommand constructor.
+ if (targetFront.isTopLevel) {
+ // First report that all existing targets are destroyed
+ if (!isFirstTarget) {
+ this._destroyExistingTargetsOnTargetSwitching();
+ }
+
+ // Update the reference to the memoized top level target
+ this.targetFront = targetFront;
+ this.descriptorFront.setTarget(targetFront);
+ this.#selectedTargetFront = null;
+
+ if (isFirstTarget && this.isServerTargetSwitchingEnabled()) {
+ this._gotFirstTopLevelTarget = true;
+ this._resolveOnFirstTarget();
+ }
+ }
+
+ // Map the descriptor typeName to a target type.
+ const targetType = this.getTargetType(targetFront);
+ targetFront.setTargetType(targetType);
+
+ this._targets.add(targetFront);
+ try {
+ await targetFront.attachAndInitThread(this);
+ } catch (e) {
+ console.error("Error when attaching target:", e);
+ this._targets.delete(targetFront);
+ return;
+ }
+
+ for (const targetFrontsSet of this._pendingWatchTargetInitialization.values()) {
+ targetFrontsSet.delete(targetFront);
+ }
+
+ if (this.isDestroyed() || targetFront.isDestroyedOrBeingDestroyed()) {
+ return;
+ }
+
+ this.store.dispatch(registerTarget(targetFront));
+
+ // Then, once the target is attached, notify the target front creation listeners
+ await this._createListeners.emitAsync(targetType, {
+ targetFront,
+ isTargetSwitching,
+ });
+
+ // Re-register the listeners as the top level target changed
+ // and some targets are fetched from it
+ if (targetFront.isTopLevel && !isFirstTarget) {
+ await this.startListening({ isTargetSwitching: true });
+ }
+
+ // These two events are used by tests using the production codepath (i.e. disabling flags.testing)
+ // To be consumed by tests triggering frame navigations, spawning workers...
+ this.emit("processed-available-target", targetFront);
+
+ if (isTargetSwitching) {
+ this.emit("switched-target", targetFront);
+ }
+ }
+
+ _destroyExistingTargetsOnTargetSwitching() {
+ const destroyedTargets = [];
+ for (const target of this._targets) {
+ // We only consider the top level target to be switched
+ const isDestroyedTargetSwitching = target == this.targetFront;
+ const isServiceWorker = target.targetType === this.TYPES.SERVICE_WORKER;
+ const isPopup = target.targetForm.isPopup;
+
+ // Never destroy the popup targets when the top level target is destroyed
+ // as the popup follow a different lifecycle.
+ // Also avoid destroying service worker targets for similar reason,
+ // unless this.destroyServiceWorkersOnNavigation is true.
+ if (
+ !isPopup &&
+ (!isServiceWorker || this.destroyServiceWorkersOnNavigation)
+ ) {
+ this._onTargetDestroyed(target, {
+ isTargetSwitching: isDestroyedTargetSwitching,
+ // Do not destroy service worker front as we may want to keep using it.
+ shouldDestroyTargetFront: !isServiceWorker,
+ });
+ destroyedTargets.push(target);
+ }
+ }
+
+ // Stop listening to legacy listeners as we now have to listen
+ // on the new target.
+ this.stopListening({ isTargetSwitching: true });
+
+ // Remove destroyed target from the cached target list. We don't simply clear the
+ // Map as SW targets might not have been destroyed (i.e. when destroyServiceWorkersOnNavigation
+ // is set to false).
+ for (const target of destroyedTargets) {
+ this._targets.delete(target);
+ }
+ }
+
+ /**
+ * Function fired everytime a target is destroyed.
+ *
+ * This is called either:
+ * - via target-destroyed event fired by the WatcherFront,
+ * event which is a simple translation of the target-destroyed-form emitted by the WatcherActor.
+ * Watcher Actor emits this is various condition when the debugged target is meant to be destroyed:
+ * - the related target context is destroyed (tab closed, worker shut down, content process destroyed, ...),
+ * - when the DevToolsServerConnection used on the server side to communicate to the client is closed.
+
+ * - by TargetCommand._onTargetAvailable, when a top level target switching happens and all previously
+ * registered target fronts should be destroyed.
+
+ * - by the legacy Targets listeners, calling this method directly.
+ * This usecase is meant to be removed someday when all target targets are supported by the Watcher.
+ * (bug 1687459)
+ *
+ * @param {TargetFront} targetFront
+ * The target that just got destroyed.
+ * @param {Object} options
+ * @param {Boolean} [options.isTargetSwitching]
+ * To be set to true when this is about the top level target which is being replaced
+ * by a new one.
+ * The passed target should be still the one store in TargetCommand.targetFront
+ * and will be replaced via a call to onTargetAvailable with a new target front.
+ * @param {Boolean} [options.isModeSwitching]
+ * To be set to true when the target was destroyed was called as the result of a
+ * change to the devtools.browsertoolbox.scope pref.
+ * @param {Boolean} [options.shouldDestroyTargetFront]
+ * By default, the passed target front will be destroyed. But in some cases like
+ * legacy listeners for service workers we want to keep the front alive.
+ */
+ _onTargetDestroyed(
+ targetFront,
+ {
+ isModeSwitching = false,
+ isTargetSwitching = false,
+ shouldDestroyTargetFront = true,
+ } = {}
+ ) {
+ // The watcher actor may notify us about the destruction of the top level target.
+ // But second argument to this method, isTargetSwitching is only passed from the frontend.
+ // So automatically toggle the isTargetSwitching flag for server side destructions
+ // only if that's about the existing top level target.
+ if (targetFront == this.targetFront) {
+ isTargetSwitching = true;
+ }
+ this._destroyListeners.emit(targetFront.targetType, {
+ targetFront,
+ isTargetSwitching,
+ isModeSwitching,
+ });
+ this._targets.delete(targetFront);
+
+ this.store.dispatch(unregisterTarget(targetFront));
+
+ // If the destroyed target was the selected one, we need to do some cleanup
+ if (this.#selectedTargetFront == targetFront) {
+ // If we're doing a targetSwitch, simply nullify #selectedTargetFront
+ if (isTargetSwitching) {
+ this.#selectedTargetFront = null;
+ } else {
+ // Otherwise we want to select the top level target
+ this.selectTarget(this.targetFront);
+ }
+ }
+
+ if (shouldDestroyTargetFront) {
+ // When calling targetFront.destroy(), we will first call TargetFrontMixin.destroy,
+ // which will try to call `detach` RDP method.
+ // Unfortunately, this request will never complete in some cases like bfcache navigations.
+ // Because of that, the target front will never be completely destroy as it will prevent
+ // calling super.destroy and Front.destroy.
+ // Workaround that by manually calling Front class destroy method:
+ targetFront.baseFrontClassDestroy();
+
+ targetFront.destroy();
+
+ // Delete the attribute we set from _onTargetAvailable so that we avoid leaking commands
+ // if any target front is leaked.
+ delete targetFront.commands;
+ }
+ }
+
+ /**
+ *
+ * @param {TargetFront} targetFront
+ */
+ async _onTargetSelected(targetFront) {
+ if (this.#selectedTargetFront == targetFront) {
+ // Target is already selected, we can bail out.
+ return;
+ }
+
+ this.#selectedTargetFront = targetFront;
+ const targetType = this.getTargetType(targetFront);
+ await this._selectListeners.emitAsync(targetType, {
+ targetFront,
+ });
+ }
+
+ _setListening(type, value) {
+ if (value) {
+ this._listenersStarted.add(type);
+ } else {
+ this._listenersStarted.delete(type);
+ }
+ }
+
+ _isListening(type) {
+ return this._listenersStarted.has(type);
+ }
+
+ /**
+ * Check if the watcher is currently supported.
+ *
+ * When no typeOrTrait is provided, we will only check that the watcher is
+ * available.
+ *
+ * When a typeOrTrait is provided, we will check for an explicit trait on the
+ * watcherFront that indicates either that:
+ * - a target type is supported
+ * - or that a custom trait is true
+ *
+ * @param {String} [targetTypeOrTrait]
+ * Optional target type or trait.
+ * @return {Boolean} true if the watcher is available and supports the
+ * optional targetTypeOrTrait
+ */
+ hasTargetWatcherSupport(targetTypeOrTrait) {
+ if (targetTypeOrTrait) {
+ // Target types are also exposed as traits, where resource types are
+ // exposed under traits.resources (cf hasResourceWatcherSupport
+ // implementation).
+ return !!this.watcherFront?.traits[targetTypeOrTrait];
+ }
+
+ return !!this.watcherFront;
+ }
+
+ /**
+ * Start listening for targets from the server
+ *
+ * Interact with the actors in order to start listening for new types of targets.
+ * This will fire the _onTargetAvailable function for all already-existing targets,
+ * as well as the next one to be created. It will also call _onTargetDestroyed
+ * everytime a target is reported as destroyed by the actors.
+ * By the time this function resolves, all the already-existing targets will be
+ * reported to _onTargetAvailable.
+ *
+ * @param Object options
+ * @param Boolean options.isTargetSwitching
+ * Set to true when this is called while a target switching happens. In such case,
+ * we won't register listener set on the Watcher Actor, but still register listeners
+ * set via Legacy Listeners.
+ */
+ async startListening({ isTargetSwitching = false } = {}) {
+ // The first time we call this method, we pull the current top level target from the descriptor
+ if (
+ !this.isServerTargetSwitchingEnabled() &&
+ !this._gotFirstTopLevelTarget
+ ) {
+ await this._createFirstTarget();
+ }
+
+ // If no pref are set to true, nor is listenForWorkers set to true,
+ // we won't listen for any additional target. Only the top level target
+ // will be managed. We may still do target-switching.
+ const types = this._computeTargetTypes();
+
+ for (const type of types) {
+ if (this._isListening(type)) {
+ continue;
+ }
+ this._setListening(type, true);
+
+ // Only a few top level targets support the watcher actor at the moment (see WatcherActor
+ // traits in the _form method). Bug 1675763 tracks watcher actor support for all targets.
+ if (this.hasTargetWatcherSupport(type)) {
+ // When we switch to a new top level target, we don't have to stop and restart
+ // Watcher listener as it is independant from the top level target.
+ // This isn't the case for some Legacy Listeners, which fetch targets from the top level target
+ if (!isTargetSwitching) {
+ await this.watcherFront.watchTargets(type);
+ }
+ } else if (LegacyTargetWatchers[type]) {
+ // Instantiate the legacy listener only once for each TargetCommand, and reuse it if we stop and restart listening
+ if (!this.legacyImplementation[type]) {
+ this.legacyImplementation[type] = new LegacyTargetWatchers[type](
+ this,
+ this._onTargetAvailable,
+ this._onTargetDestroyed,
+ this.commands
+ );
+ }
+ await this.legacyImplementation[type].listen();
+ } else {
+ throw new Error(`Unsupported target type '${type}'`);
+ }
+ }
+
+ if (!this._watchingDocumentEvent && !this.isDestroyed()) {
+ // We want to watch DOCUMENT_EVENT in order to update the url and title of target fronts,
+ // as the initial value that is set in them might be erroneous (if the target was
+ // created so early that the document url is still pointing to about:blank and the
+ // html hasn't be parsed yet, so we can't know the <title> content).
+
+ this._watchingDocumentEvent = true;
+ await this.commands.resourceCommand.watchResources(
+ [this.commands.resourceCommand.TYPES.DOCUMENT_EVENT],
+ {
+ onAvailable: this._onResourceAvailable,
+ }
+ );
+ }
+
+ if (this.isServerTargetSwitchingEnabled()) {
+ await this._onFirstTarget;
+ }
+ }
+
+ async _createFirstTarget() {
+ // Note that this is a public attribute, used outside of this class
+ // and helps knowing what is the current top level target we debug.
+ this.targetFront = await this.descriptorFront.getTarget();
+ this.targetFront.setTargetType(this.getTargetType(this.targetFront));
+ this.targetFront.setIsTopLevel(true);
+ this._gotFirstTopLevelTarget = true;
+
+ // See _onTargetAvailable. As this target isn't going through that method
+ // we have to replicate doing that here.
+ this.targetFront.commands = this.commands;
+
+ // Add the top-level target to the list of targets.
+ this._targets.add(this.targetFront);
+ this.store.dispatch(registerTarget(this.targetFront));
+ }
+
+ _computeTargetTypes() {
+ let types = [];
+
+ // We also check for watcher support as some xpcshell tests uses legacy APIs and don't support frames.
+ if (
+ this.descriptorFront.isTabDescriptor &&
+ this.hasTargetWatcherSupport(TargetCommand.TYPES.FRAME)
+ ) {
+ types = [TargetCommand.TYPES.FRAME];
+ } else if (this.descriptorFront.isBrowserProcessDescriptor) {
+ const browserToolboxScope = Services.prefs.getCharPref(
+ BROWSERTOOLBOX_SCOPE_PREF
+ );
+ if (browserToolboxScope == BROWSERTOOLBOX_SCOPE_EVERYTHING) {
+ types = TargetCommand.ALL_TYPES;
+ }
+ }
+ if (this.listenForWorkers && !types.includes(TargetCommand.TYPES.WORKER)) {
+ types.push(TargetCommand.TYPES.WORKER);
+ }
+ if (
+ this.listenForWorkers &&
+ !types.includes(TargetCommand.TYPES.SHARED_WORKER)
+ ) {
+ types.push(TargetCommand.TYPES.SHARED_WORKER);
+ }
+ if (
+ this.listenForServiceWorkers &&
+ !types.includes(TargetCommand.TYPES.SERVICE_WORKER)
+ ) {
+ types.push(TargetCommand.TYPES.SERVICE_WORKER);
+ }
+
+ return types;
+ }
+
+ /**
+ * Stop listening for targets from the server
+ *
+ * @param Object options
+ * @param Boolean options.isTargetSwitching
+ * Set to true when this is called while a target switching happens. In such case,
+ * we won't unregister listener set on the Watcher Actor, but still unregister
+ * listeners set via Legacy Listeners.
+ */
+ stopListening({ isTargetSwitching = false } = {}) {
+ // As DOCUMENT_EVENT isn't using legacy listener,
+ // there is no need to stop and restart it in case of target switching.
+ if (this._watchingDocumentEvent && !isTargetSwitching) {
+ this.commands.resourceCommand.unwatchResources(
+ [this.commands.resourceCommand.TYPES.DOCUMENT_EVENT],
+ {
+ onAvailable: this._onResourceAvailable,
+ }
+ );
+ this._watchingDocumentEvent = false;
+ }
+
+ for (const type of TargetCommand.ALL_TYPES) {
+ this.stopListeningForType(type, { isTargetSwitching });
+ }
+ }
+
+ /**
+ * Stop listening for targets of a given type from the server
+ *
+ * @param String type
+ * target type we want to stop listening for
+ * @param Object options
+ * @param Boolean options.isTargetSwitching
+ * Set to true when this is called while a target switching happens. In such case,
+ * we won't unregister listener set on the Watcher Actor, but still unregister
+ * listeners set via Legacy Listeners.
+ * @param Boolean options.isModeSwitching
+ * Set to true when this is called as the result of a change to the
+ * devtools.browsertoolbox.scope pref.
+ */
+ stopListeningForType(type, { isTargetSwitching, isModeSwitching }) {
+ if (!this._isListening(type)) {
+ return;
+ }
+ this._setListening(type, false);
+
+ // Only a few top level targets support the watcher actor at the moment (see WatcherActor
+ // traits in the _form method). Bug 1675763 tracks watcher actor support for all targets.
+ if (this.hasTargetWatcherSupport(type)) {
+ // When we switch to a new top level target, we don't have to stop and restart
+ // Watcher listener as it is independant from the top level target.
+ // This isn't the case for some Legacy Listeners, which fetch targets from the top level target
+ // Also, TargetCommand.destroy may be called after the client is closed.
+ // So avoid calling the RDP method in that situation.
+ if (!isTargetSwitching && !this.watcherFront.isDestroyed()) {
+ this.watcherFront.unwatchTargets(type, { isModeSwitching });
+ }
+ } else if (this.legacyImplementation[type]) {
+ this.legacyImplementation[type].unlisten({
+ isTargetSwitching,
+ isModeSwitching,
+ });
+ } else {
+ throw new Error(`Unsupported target type '${type}'`);
+ }
+ }
+
+ getTargetType(target) {
+ const { typeName } = target;
+ if (typeName == "windowGlobalTarget") {
+ return TargetCommand.TYPES.FRAME;
+ }
+
+ if (
+ typeName == "contentProcessTarget" ||
+ typeName == "parentProcessTarget"
+ ) {
+ return TargetCommand.TYPES.PROCESS;
+ }
+
+ if (typeName == "workerDescriptor" || typeName == "workerTarget") {
+ if (target.isSharedWorker) {
+ return TargetCommand.TYPES.SHARED_WORKER;
+ }
+
+ if (target.isServiceWorker) {
+ return TargetCommand.TYPES.SERVICE_WORKER;
+ }
+
+ return TargetCommand.TYPES.WORKER;
+ }
+
+ throw new Error("Unsupported target typeName: " + typeName);
+ }
+
+ _matchTargetType(type, target) {
+ return type === target.targetType;
+ }
+
+ _onResourceAvailable(resources) {
+ for (const resource of resources) {
+ if (
+ resource.resourceType ===
+ this.commands.resourceCommand.TYPES.DOCUMENT_EVENT
+ ) {
+ const { targetFront } = resource;
+ if (resource.title !== undefined && targetFront?.setTitle) {
+ targetFront.setTitle(resource.title);
+ }
+ if (resource.url !== undefined && targetFront?.setUrl) {
+ targetFront.setUrl(resource.url);
+ }
+ if (
+ !resource.isFrameSwitching &&
+ // `url` is set on the targetFront when we receive dom-loading, and `title` when
+ // `dom-interactive` is received. Here we're only updating the window title in
+ // the "newer" event.
+ resource.name === "dom-interactive"
+ ) {
+ // We just updated the targetFront title and url, force a refresh
+ // so that the EvaluationContext selector update them.
+ this.store.dispatch(refreshTargets());
+ }
+ }
+ }
+ }
+
+ /**
+ * Listen for the creation and/or destruction of target fronts matching one of the provided types.
+ *
+ * @param {Object} options
+ * @param {Array<String>} options.types
+ * The type of target to listen for. Constant of TargetCommand.TYPES.
+ * @param {Function} options.onAvailable
+ * Mandatory callback fired when a target has been just created or was already available.
+ * The function is called with a single object argument containing the following properties:
+ * - {TargetFront} targetFront: The target Front
+ * - {Boolean} isTargetSwitching: Is this target relates to a navigation and
+ * this replaced a previously available target, this flag will be true
+ * @param {Function} options.onDestroyed
+ * Optional callback fired in case of target front destruction.
+ * The function is called with the same arguments than onAvailable.
+ * @param {Function} options.onSelected
+ * Optional callback fired when a given target is selected from the iframe picker
+ * The function is called with a single object argument containing the following properties:
+ * - {TargetFront} targetFront: The target Front
+ */
+ async watchTargets(options = {}) {
+ const availableOptions = [
+ "types",
+ "onAvailable",
+ "onDestroyed",
+ "onSelected",
+ ];
+ const unsupportedKeys = Object.keys(options).filter(
+ key => !availableOptions.includes(key)
+ );
+ if (unsupportedKeys.length) {
+ throw new Error(
+ `TargetCommand.watchTargets does not expect the following options: ${unsupportedKeys.join(
+ ", "
+ )}`
+ );
+ }
+
+ const { types, onAvailable, onDestroyed, onSelected } = options;
+ if (typeof onAvailable != "function") {
+ throw new Error(
+ "TargetCommand.watchTargets expects a function for the onAvailable option"
+ );
+ }
+
+ for (const type of types) {
+ if (!this._isValidTargetType(type)) {
+ throw new Error(
+ `TargetCommand.watchTargets invoked with an unknown type: "${type}"`
+ );
+ }
+ }
+
+ // Notify about already existing target of these types
+ const targetFronts = [...this._targets].filter(targetFront =>
+ types.includes(targetFront.targetType)
+ );
+ this._pendingWatchTargetInitialization.set(
+ onAvailable,
+ new Set(targetFronts)
+ );
+ const promises = targetFronts.map(async targetFront => {
+ // Attach the targets that aren't attached yet (e.g. the initial top-level target),
+ // and wait for the other ones to be fully attached.
+ try {
+ await targetFront.attachAndInitThread(this);
+ } catch (e) {
+ console.error("Error when attaching target:", e);
+ return;
+ }
+
+ // It can happen that onAvailable was already called with this targetFront at
+ // this time (via _onTargetAvailable). If that's the case, we don't want to call
+ // onAvailable a second time.
+ if (
+ this._pendingWatchTargetInitialization &&
+ this._pendingWatchTargetInitialization.has(onAvailable) &&
+ !this._pendingWatchTargetInitialization
+ .get(onAvailable)
+ .has(targetFront)
+ ) {
+ return;
+ }
+
+ try {
+ // Ensure waiting for eventual async create listeners
+ // which may setup things regarding the existing targets
+ // and listen callsite may care about the full initialization
+ await onAvailable({
+ targetFront,
+ isTargetSwitching: false,
+ });
+ } catch (e) {
+ // Prevent throwing when onAvailable handler throws on one target
+ // so that it can try to register the other targets
+ console.error(
+ "Exception when calling onAvailable handler",
+ e.message,
+ e
+ );
+ }
+ });
+
+ for (const type of types) {
+ this._createListeners.on(type, onAvailable);
+ if (onDestroyed) {
+ this._destroyListeners.on(type, onDestroyed);
+ }
+ if (onSelected) {
+ this._selectListeners.on(type, onSelected);
+ }
+ }
+
+ await Promise.all(promises);
+ this._pendingWatchTargetInitialization.delete(onAvailable);
+ }
+
+ /**
+ * Stop listening for the creation and/or destruction of a given type of target fronts.
+ * See `watchTargets()` for documentation of the arguments.
+ */
+ unwatchTargets(options = {}) {
+ const availableOptions = [
+ "types",
+ "onAvailable",
+ "onDestroyed",
+ "onSelected",
+ ];
+ const unsupportedKeys = Object.keys(options).filter(
+ key => !availableOptions.includes(key)
+ );
+ if (unsupportedKeys.length) {
+ throw new Error(
+ `TargetCommand.unwatchTargets does not expect the following options: ${unsupportedKeys.join(
+ ", "
+ )}`
+ );
+ }
+
+ const { types, onAvailable, onDestroyed, onSelected } = options;
+ if (typeof onAvailable != "function") {
+ throw new Error(
+ "TargetCommand.unwatchTargets expects a function for the onAvailable option"
+ );
+ }
+
+ for (const type of types) {
+ if (!this._isValidTargetType(type)) {
+ throw new Error(
+ `TargetCommand.unwatchTargets invoked with an unknown type: "${type}"`
+ );
+ }
+
+ this._createListeners.off(type, onAvailable);
+ if (onDestroyed) {
+ this._destroyListeners.off(type, onDestroyed);
+ }
+ if (onSelected) {
+ this._selectListeners.off(type, onSelected);
+ }
+ }
+ this._pendingWatchTargetInitialization.delete(onAvailable);
+ }
+
+ /**
+ * Retrieve all the current target fronts of a given type.
+ *
+ * @param {Array<String>} types
+ * The types of target to retrieve. Array of TargetCommand.TYPES
+ * @return {Array<TargetFront>} Array of target fronts matching any of the
+ * provided types.
+ */
+ getAllTargets(types) {
+ if (!types?.length) {
+ throw new Error("getAllTargets expects a non-empty array of types");
+ }
+
+ const targets = [...this._targets].filter(target =>
+ types.some(type => this._matchTargetType(type, target))
+ );
+
+ return targets;
+ }
+
+ /**
+ * Retrieve all the target fronts in the selected target tree (including the selected
+ * target itself).
+ *
+ * @param {Array<String>} types
+ * The types of target to retrieve. Array of TargetCommand.TYPES
+ * @return {Promise<Array<TargetFront>>} Promise that resolves to an array of target fronts.
+ */
+ async getAllTargetsInSelectedTargetTree(types) {
+ const allTargets = this.getAllTargets(types);
+ if (this.isTopLevelTargetSelected()) {
+ return allTargets;
+ }
+
+ const targets = [this.selectedTargetFront];
+ for (const target of allTargets) {
+ const isInSelectedTree = await target.isTargetAnAncestor(
+ this.selectedTargetFront
+ );
+
+ if (isInSelectedTree) {
+ targets.push(target);
+ }
+ }
+ return targets;
+ }
+
+ /**
+ * For all the target fronts of given types, retrieve all the target-scoped fronts of the given types.
+ *
+ * @param {Array<String>} targetTypes
+ * The types of target to iterate over. Constant of TargetCommand.TYPES.
+ * @param {String} frontType
+ * The type of target-scoped front to retrieve. It can be "inspector", "console", "thread",...
+ * @param {Object} options
+ * @param {Boolean} options.onlyInSelectedTargetTree
+ * Set to true to only get the fronts for targets who are in the "targets tree"
+ * of the selected target.
+ */
+ async getAllFronts(
+ targetTypes,
+ frontType,
+ { onlyInSelectedTargetTree = false } = {}
+ ) {
+ if (!Array.isArray(targetTypes) || !targetTypes?.length) {
+ throw new Error("getAllFronts expects a non-empty array of target types");
+ }
+ const promises = [];
+ const targets = !onlyInSelectedTargetTree
+ ? this.getAllTargets(targetTypes)
+ : await this.getAllTargetsInSelectedTargetTree(targetTypes);
+ for (const target of targets) {
+ // For still-attaching worker targets, the thread or console front may not yet be available,
+ // whereas TargetMixin.getFront will throw if the actorID isn't available in targetForm.
+ // Also ignore destroyed targets. For some reason the previous methods fetching targets
+ // can sometime return destroyed targets.
+ if (
+ (frontType == "thread" && !target.targetForm.threadActor) ||
+ (frontType == "console" && !target.targetForm.consoleActor) ||
+ target.isDestroyed()
+ ) {
+ continue;
+ }
+
+ promises.push(target.getFront(frontType));
+ }
+ return Promise.all(promises);
+ }
+
+ /**
+ * This function is triggered by an event sent by the TabDescriptor when
+ * the tab navigates to a distinct process.
+ *
+ * @param TargetFront targetFront
+ * The WindowGlobalTargetFront instance that navigated to another process
+ */
+ async onLocalTabRemotenessChange(targetFront) {
+ if (this.isServerTargetSwitchingEnabled()) {
+ // For server-side target switching, everything will be handled by the
+ // _onTargetAvailable callback.
+ return;
+ }
+
+ // TabDescriptor may emit the event with a null targetFront, interpret that as if the previous target
+ // has already been destroyed
+ if (targetFront) {
+ // Wait for the target to be destroyed so that LocalTabCommandsFactory clears its memoized target for this tab
+ await targetFront.once("target-destroyed");
+ }
+
+ // Fetch the new target from the descriptor.
+ const newTarget = await this.descriptorFront.getTarget();
+
+ // If a navigation happens while we try to get the target for the page that triggered
+ // the remoteness change, `getTarget` will return null. In such case, we'll get the
+ // "next" target through onTargetAvailable so it's safe to bail here.
+ if (!newTarget) {
+ console.warn(
+ `Couldn't get the target for descriptor ${this.descriptorFront.actorID}`
+ );
+ return;
+ }
+
+ this.switchToTarget(newTarget);
+ }
+
+ /**
+ * Reload the current top level target.
+ * This only works for targets inheriting from WindowGlobalTarget.
+ *
+ * @param {Boolean} bypassCache
+ * If true, the reload will be forced to bypass any cache.
+ */
+ async reloadTopLevelTarget(bypassCache = false) {
+ if (!this.descriptorFront.traits.supportsReloadDescriptor) {
+ throw new Error("The top level target doesn't support being reloaded");
+ }
+
+ // Wait for the next DOCUMENT_EVENT's dom-complete event
+ // Wait for waitForNextResource completion before reloading, otherwise we might miss the dom-complete event.
+ // This can happen if `ResourceCommand.watchResources` made by `waitForNextResource` is still pending
+ // while the reload already started and finished loading the document early.
+ const { onResource: onReloaded } =
+ await this.commands.resourceCommand.waitForNextResource(
+ this.commands.resourceCommand.TYPES.DOCUMENT_EVENT,
+ {
+ ignoreExistingResources: true,
+ predicate(resource) {
+ return resource.name == "dom-complete";
+ },
+ }
+ );
+
+ await this.descriptorFront.reloadDescriptor({ bypassCache });
+
+ await onReloaded;
+ }
+
+ /**
+ * Called when the top level target is replaced by a new one.
+ * Typically when we navigate to another domain which requires to be loaded in a distinct process.
+ *
+ * @param {TargetFront} newTarget
+ * The new top level target to debug.
+ */
+ async switchToTarget(newTarget) {
+ // Notify about this new target to creation listeners
+ // _onTargetAvailable will also destroy all previous target before notifying about this new one.
+ await this._onTargetAvailable(newTarget);
+ }
+
+ /**
+ * Called when the user selects a frame in the iframe picker.
+ *
+ * @param {WindowGlobalTargetFront} targetFront
+ * The target front we want the toolbox to focus on.
+ */
+ selectTarget(targetFront) {
+ return this._onTargetSelected(targetFront);
+ }
+
+ /**
+ * Returns true if the top-level frame is the selected one
+ *
+ * @returns {Boolean}
+ */
+ isTopLevelTargetSelected() {
+ return this.selectedTargetFront === this.targetFront;
+ }
+
+ /**
+ * Returns true if a non top-level frame is the selected one in the iframe picker.
+ *
+ * @returns {Boolean}
+ */
+ isNonTopLevelTargetSelected() {
+ return this.selectedTargetFront !== this.targetFront;
+ }
+
+ isTargetRegistered(targetFront) {
+ return this._targets.has(targetFront);
+ }
+
+ getParentTarget(targetFront) {
+ // Note that there are edgecases:
+ // * Until bug 1741927 is fixed and we remove non-EFT codepath entirely,
+ // we may receive a `parentInnerWindowId` that doesn't relate to any target.
+ // This happens when the parent document of the targetFront is a document loaded in the
+ // same process as its parent document. In such scenario, and only when EFT is disabled,
+ // we won't instantiate a target for the parent document of the targetFront.
+ // * `parentInnerWindowId` could be null in some case like for tabs in the MBT
+ // we should report the top level target as parent. That's what `getParentWindowGlobalTarget` does.
+ // Once we can stop using getParentWindowGlobalTarget for the other edgecase we will be able to
+ // replace it with such fallback: `return this.targetFront;`.
+ // browser_target_command_frames.js will help you get things right.
+ const { parentInnerWindowId } = targetFront.targetForm;
+ if (parentInnerWindowId) {
+ const targets = this.getAllTargets([TargetCommand.TYPES.FRAME]);
+ const parent = targets.find(
+ target => target.innerWindowId == parentInnerWindowId
+ );
+ // Until EFT is the only codepath supported (bug 1741927), we will fallback to `getParentWindowGlobalTarget`
+ // as we may not have a target if the parent is an iframe running in the same process as its parent.
+ if (parent) {
+ return parent;
+ }
+ }
+
+ // Note that all callsites which care about FRAME additional target
+ // should all have a toolbox using the watcher actor.
+ // It should be: MBT, regular tab toolbox and web extension.
+ // The others which still don't support watcher don't spawn FRAME targets:
+ // browser content toolbox and service workers.
+
+ return this.watcherFront.getParentWindowGlobalTarget(
+ targetFront.browsingContextID
+ );
+ }
+
+ isDestroyed() {
+ return this._isDestroyed;
+ }
+
+ isServerTargetSwitchingEnabled() {
+ if (this.descriptorFront.isServerTargetSwitchingEnabled) {
+ return this.descriptorFront.isServerTargetSwitchingEnabled();
+ }
+ return false;
+ }
+
+ _isValidTargetType(type) {
+ return this.ALL_TYPES.includes(type);
+ }
+
+ destroy() {
+ this.stopListening();
+ this._createListeners.off();
+ this._destroyListeners.off();
+ this._selectListeners.off();
+
+ this.#selectedTargetFront = null;
+ this._isDestroyed = true;
+
+ Services.prefs.removeObserver(
+ BROWSERTOOLBOX_SCOPE_PREF,
+ this._updateBrowserToolboxScope
+ );
+ }
+}
+
+/**
+ * All types of target:
+ */
+TargetCommand.TYPES = TargetCommand.prototype.TYPES = {
+ PROCESS: "process",
+ FRAME: "frame",
+ WORKER: "worker",
+ SHARED_WORKER: "shared_worker",
+ SERVICE_WORKER: "service_worker",
+};
+TargetCommand.ALL_TYPES = TargetCommand.prototype.ALL_TYPES = Object.values(
+ TargetCommand.TYPES
+);
+
+const LegacyTargetWatchers = {};
+loader.lazyRequireGetter(
+ LegacyTargetWatchers,
+ TargetCommand.TYPES.PROCESS,
+ "resource://devtools/shared/commands/target/legacy-target-watchers/legacy-processes-watcher.js"
+);
+loader.lazyRequireGetter(
+ LegacyTargetWatchers,
+ TargetCommand.TYPES.WORKER,
+ "resource://devtools/shared/commands/target/legacy-target-watchers/legacy-workers-watcher.js"
+);
+loader.lazyRequireGetter(
+ LegacyTargetWatchers,
+ TargetCommand.TYPES.SHARED_WORKER,
+ "resource://devtools/shared/commands/target/legacy-target-watchers/legacy-sharedworkers-watcher.js"
+);
+loader.lazyRequireGetter(
+ LegacyTargetWatchers,
+ TargetCommand.TYPES.SERVICE_WORKER,
+ "resource://devtools/shared/commands/target/legacy-target-watchers/legacy-serviceworkers-watcher.js"
+);
+
+module.exports = TargetCommand;
diff --git a/devtools/shared/commands/target/tests/browser.ini b/devtools/shared/commands/target/tests/browser.ini
new file mode 100644
index 0000000000..b28e700de0
--- /dev/null
+++ b/devtools/shared/commands/target/tests/browser.ini
@@ -0,0 +1,48 @@
+[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
+ simple_document.html
+ incremental-js-value-script.sjs
+ fission_document.html
+ fission_iframe.html
+ test_service_worker.js
+ test_sw_page.html
+ test_sw_page_worker.js
+ test_worker.js
+
+[browser_target_command_bfcache.js]
+[browser_target_command_browser_workers.js]
+[browser_target_command_detach.js]
+[browser_target_command_frames_popups.js]
+skip-if =
+ win10_2004 && debug && fission && socketprocess_networking # high frequency intermittent
+[browser_target_command_frames_reload_server_side_targets.js]
+skip-if = !fission
+[browser_target_command_frames.js]
+[browser_target_command_getAllTargets.js]
+[browser_target_command_invalid_api_usage.js]
+[browser_target_command_scope_flag.js]
+[browser_target_command_processes.js]
+[browser_target_command_reload.js]
+[browser_target_command_service_workers.js]
+skip-if =
+ http3 # Bug 1781324
+[browser_target_command_service_workers_navigation.js]
+skip-if =
+ os == "linux" && bits == 64 # Bug 1726270
+ os == "win" && bits == 64 # Bug 1726270
+ os == "mac" && fission # Bug 1726270
+[browser_target_command_switchToTarget.js]
+[browser_target_command_tab_workers.js]
+[browser_target_command_tab_workers_bfcache_navigation.js]
+skip-if = debug # Bug 1721859
+[browser_target_command_various_descriptors.js]
+skip-if =
+ os == "linux" && bits == 64 && !debug #Bug 1701056
+[browser_target_command_watchTargets.js]
+[browser_watcher_actor_getter_caching.js]
diff --git a/devtools/shared/commands/target/tests/browser_target_command_bfcache.js b/devtools/shared/commands/target/tests/browser_target_command_bfcache.js
new file mode 100644
index 0000000000..a5deeb9260
--- /dev/null
+++ b/devtools/shared/commands/target/tests/browser_target_command_bfcache.js
@@ -0,0 +1,499 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the TargetCommand API when bfcache navigations happen
+
+const TEST_COM_URL = URL_ROOT_SSL + "simple_document.html";
+
+add_task(async function () {
+ // 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);
+
+ info("## Test with bfcache in parent DISABLED");
+ await pushPref("fission.bfcacheInParent", false);
+ await testTopLevelNavigations(false);
+ await testIframeNavigations(false);
+ await testTopLevelNavigationsOnDocumentWithIframe(false);
+
+ // bfcacheInParent only works if sessionHistoryInParent is enable
+ // so only test it if both settings are enabled.
+ // (it looks like sessionHistoryInParent is enabled by default when fission is enabled)
+ if (Services.appinfo.sessionHistoryInParent) {
+ info("## Test with bfcache in parent ENABLED");
+ await pushPref("fission.bfcacheInParent", true);
+ await testTopLevelNavigations(true);
+ await testIframeNavigations(true);
+ await testTopLevelNavigationsOnDocumentWithIframe(true);
+ }
+});
+
+async function testTopLevelNavigations(bfcacheInParent) {
+ info(" # Test TOP LEVEL navigations");
+ // Create a TargetCommand for a given test tab
+ const tab = await addTab(TEST_COM_URL);
+ const commands = await CommandsFactory.forTab(tab);
+ const targetCommand = commands.targetCommand;
+ const { TYPES } = targetCommand;
+
+ await targetCommand.startListening();
+
+ // Assert that watchTargets will call the create callback for all existing frames
+ const targets = [];
+ const onAvailable = async ({ targetFront }) => {
+ is(
+ targetFront.targetType,
+ TYPES.FRAME,
+ "We are only notified about frame targets"
+ );
+ ok(targetFront.isTopLevel, "all targets of this test are top level");
+ targets.push(targetFront);
+ };
+ const destroyedTargets = [];
+ const onDestroyed = async ({ targetFront }) => {
+ is(
+ targetFront.targetType,
+ TYPES.FRAME,
+ "We are only notified about frame targets"
+ );
+ ok(targetFront.isTopLevel, "all targets of this test are top level");
+ destroyedTargets.push(targetFront);
+ };
+
+ await targetCommand.watchTargets({
+ types: [TYPES.FRAME],
+ onAvailable,
+ onDestroyed,
+ });
+ is(targets.length, 1, "retrieved only the top level target");
+ is(targets[0], targetCommand.targetFront, "the target is the top level one");
+ is(
+ destroyedTargets.length,
+ 0,
+ "We get no destruction when calling watchTargets"
+ );
+ ok(
+ targets[0].targetForm.followWindowGlobalLifeCycle,
+ "the first server side target follows the WindowGlobal lifecycle, when server target switching is enabled"
+ );
+
+ // Navigate to the same page with query params
+ info("Load the second page");
+ const secondPageUrl = TEST_COM_URL + "?second-load";
+ const previousBrowsingContextID = gBrowser.selectedBrowser.browsingContext.id;
+ ok(
+ previousBrowsingContextID,
+ "Fetch the tab's browsing context id before navigation"
+ );
+ const onLoaded = BrowserTestUtils.browserLoaded(
+ gBrowser.selectedBrowser,
+ false,
+ secondPageUrl
+ );
+ BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, secondPageUrl);
+ await onLoaded;
+
+ // Assert BrowsingContext changes as it impact the behavior of targets
+ if (bfcacheInParent) {
+ isnot(
+ previousBrowsingContextID,
+ gBrowser.selectedBrowser.browsingContext.id,
+ "When bfcacheInParent is enabled, same-origin navigations spawn new BrowsingContext"
+ );
+ } else {
+ is(
+ previousBrowsingContextID,
+ gBrowser.selectedBrowser.browsingContext.id,
+ "When bfcacheInParent is disabled, same-origin navigations re-use the same BrowsingContext"
+ );
+ }
+
+ // Same-origin navigations also spawn a new top level target
+ await waitFor(
+ () => targets.length == 2,
+ "wait for the next top level target"
+ );
+ is(
+ targets[1],
+ targetCommand.targetFront,
+ "the second target is the top level one"
+ );
+ // As targetFront.url isn't reliable and might be about:blank,
+ // try to assert that we got the right target via other means.
+ // outerWindowID should change when navigating to another process,
+ // while it would stay equal for in-process navigations.
+ is(
+ targets[1].outerWindowID,
+ gBrowser.selectedBrowser.outerWindowID,
+ "the second target is for the second page"
+ );
+ ok(
+ targets[1].targetForm.followWindowGlobalLifeCycle,
+ "the new server side target follows the WindowGlobal lifecycle"
+ );
+ ok(targets[0].isDestroyed(), "the first target is destroyed");
+ is(destroyedTargets.length, 1, "We get one target being destroyed...");
+ is(destroyedTargets[0], targets[0], "...and that's the first one");
+
+ // Go back to the first page, this should be a bfcache navigation, and,
+ // we should get a new target
+ info("Go back to the first page");
+ gBrowser.selectedBrowser.goBack();
+
+ await waitFor(
+ () => targets.length == 3,
+ "wait for the next top level target"
+ );
+ is(
+ targets[2],
+ targetCommand.targetFront,
+ "the third target is the top level one"
+ );
+ // Here as this is revived from cache, the url should always be correct
+ is(targets[2].url, TEST_COM_URL, "the third target is for the first url");
+ ok(
+ targets[2].targetForm.followWindowGlobalLifeCycle,
+ "the third target for bfcache navigations is following the WindowGlobal lifecycle"
+ );
+ ok(targets[1].isDestroyed(), "the second target is destroyed");
+ is(
+ destroyedTargets.length,
+ 2,
+ "We get one additional target being destroyed..."
+ );
+ is(destroyedTargets[1], targets[1], "...and that's the second one");
+
+ // Wait for full attach in order to having breaking any pending requests
+ // when navigating to another page and switching to new process and target.
+ await waitForAllTargetsToBeAttached(targetCommand);
+
+ // Go forward and resurect the second page, this should also be a bfcache navigation, and,
+ // get a new target.
+ info("Go forward to the second page");
+
+ // When a new target will be created, we need to wait until it's fully processed
+ // to avoid pending promises.
+ const onNewTargetProcessed = bfcacheInParent
+ ? new Promise(resolve => {
+ targetCommand.on(
+ "processed-available-target",
+ function onProcessedAvailableTarget(targetFront) {
+ if (targetFront === targets[3]) {
+ resolve();
+ targetCommand.off(
+ "processed-available-target",
+ onProcessedAvailableTarget
+ );
+ }
+ }
+ );
+ })
+ : null;
+
+ gBrowser.selectedBrowser.goForward();
+
+ await waitFor(
+ () => targets.length == 4,
+ "wait for the next top level target"
+ );
+ is(
+ targets[3],
+ targetCommand.targetFront,
+ "the 4th target is the top level one"
+ );
+ // Same here, as the document is revived from the cache, the url should always be correct
+ is(targets[3].url, secondPageUrl, "the 4th target is for the second url");
+ ok(
+ targets[3].targetForm.followWindowGlobalLifeCycle,
+ "the 4th target for bfcache navigations is following the WindowGlobal lifecycle"
+ );
+ ok(targets[2].isDestroyed(), "the third target is destroyed");
+ is(
+ destroyedTargets.length,
+ 3,
+ "We get one additional target being destroyed..."
+ );
+ is(destroyedTargets[2], targets[2], "...and that's the third one");
+
+ // Wait for full attach in order to having breaking any pending requests
+ // when navigating to another page and switching to new process and target.
+ await waitForAllTargetsToBeAttached(targetCommand);
+ await onNewTargetProcessed;
+
+ await waitForAllTargetsToBeAttached(targetCommand);
+
+ targetCommand.unwatchTargets({ types: [TYPES.FRAME], onAvailable });
+
+ BrowserTestUtils.removeTab(tab);
+
+ await commands.destroy();
+}
+
+async function testTopLevelNavigationsOnDocumentWithIframe(bfcacheInParent) {
+ info(" # Test TOP LEVEL navigations on document with iframe");
+ // Create a TargetCommand for a given test tab
+ const tab =
+ await addTab(`https://example.com/document-builder.sjs?id=top&html=
+ <h1>Top level</h1>
+ <iframe src="${encodeURIComponent(
+ "https://example.com/document-builder.sjs?id=iframe&html=<h2>In iframe</h2>"
+ )}">
+ </iframe>`);
+ const getLocationIdParam = url =>
+ new URLSearchParams(new URL(url).search).get("id");
+
+ const commands = await CommandsFactory.forTab(tab);
+ const targetCommand = commands.targetCommand;
+ const { TYPES } = targetCommand;
+
+ await targetCommand.startListening();
+
+ // Assert that watchTargets will call the create callback for all existing frames
+ const targets = [];
+ const onAvailable = async ({ targetFront }) => {
+ is(
+ targetFront.targetType,
+ TYPES.FRAME,
+ "We are only notified about frame targets"
+ );
+ targets.push(targetFront);
+ };
+ const destroyedTargets = [];
+ const onDestroyed = async ({ targetFront }) => {
+ is(
+ targetFront.targetType,
+ TYPES.FRAME,
+ "We are only notified about frame targets"
+ );
+ destroyedTargets.push(targetFront);
+ };
+
+ await targetCommand.watchTargets({
+ types: [TYPES.FRAME],
+ onAvailable,
+ onDestroyed,
+ });
+
+ if (isEveryFrameTargetEnabled()) {
+ is(
+ targets.length,
+ 2,
+ "retrieved targets for top level and iframe documents"
+ );
+ is(
+ targets[0],
+ targetCommand.targetFront,
+ "the target is the top level one"
+ );
+ is(
+ getLocationIdParam(targets[1].url),
+ "iframe",
+ "the second target is the iframe one"
+ );
+ } else {
+ is(targets.length, 1, "retrieved only the top level target");
+ is(
+ targets[0],
+ targetCommand.targetFront,
+ "the target is the top level one"
+ );
+ }
+
+ is(
+ destroyedTargets.length,
+ 0,
+ "We get no destruction when calling watchTargets"
+ );
+
+ info("Navigate to a new page");
+ let targetCountBeforeNavigation = targets.length;
+ const secondPageUrl = `https://example.com/document-builder.sjs?html=second`;
+ const onLoaded = BrowserTestUtils.browserLoaded(
+ gBrowser.selectedBrowser,
+ false,
+ secondPageUrl
+ );
+ BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, secondPageUrl);
+ await onLoaded;
+
+ // Same-origin navigations also spawn a new top level target
+ await waitFor(
+ () => targets.length == targetCountBeforeNavigation + 1,
+ "wait for the next top level target"
+ );
+ is(
+ targets.at(-1),
+ targetCommand.targetFront,
+ "the new target is the top level one"
+ );
+
+ ok(targets[0].isDestroyed(), "the first target is destroyed");
+ if (isEveryFrameTargetEnabled()) {
+ ok(targets[1].isDestroyed(), "the second target is destroyed");
+ is(destroyedTargets.length, 2, "The two targets were destroyed");
+ } else {
+ is(destroyedTargets.length, 1, "Only one target was destroyed");
+ }
+
+ // Go back to the first page, this should be a bfcache navigation, and,
+ // we should get a new target (or 2 if EFT is enabled)
+ targetCountBeforeNavigation = targets.length;
+ info("Go back to the first page");
+ gBrowser.selectedBrowser.goBack();
+
+ await waitFor(
+ () =>
+ targets.length ===
+ targetCountBeforeNavigation + (isEveryFrameTargetEnabled() ? 2 : 1),
+ "wait for the next top level target"
+ );
+
+ if (isEveryFrameTargetEnabled()) {
+ await waitFor(() => targets.at(-2).url && targets.at(-1).url);
+ is(
+ getLocationIdParam(targets.at(-2).url),
+ "top",
+ "the first new target is for the top document…"
+ );
+ is(
+ getLocationIdParam(targets.at(-1).url),
+ "iframe",
+ "…and the second one is for the iframe"
+ );
+ } else {
+ is(
+ getLocationIdParam(targets.at(-1).url),
+ "top",
+ "the new target is for the first url"
+ );
+ }
+
+ ok(
+ targets[targetCountBeforeNavigation - 1].isDestroyed(),
+ "the target for the second page is destroyed"
+ );
+ is(
+ destroyedTargets.length,
+ targetCountBeforeNavigation,
+ "We get one additional target being destroyed…"
+ );
+ is(
+ destroyedTargets.at(-1),
+ targets[targetCountBeforeNavigation - 1],
+ "…and that's the second page one"
+ );
+
+ await waitForAllTargetsToBeAttached(targetCommand);
+
+ targetCommand.unwatchTargets({
+ types: [TYPES.FRAME],
+ onAvailable,
+ onDestroyed,
+ });
+
+ BrowserTestUtils.removeTab(tab);
+
+ await commands.destroy();
+}
+
+async function testIframeNavigations() {
+ info(" # Test IFRAME navigations");
+ // Create a TargetCommand for a given test tab
+ const tab = await addTab(
+ `http://example.org/document-builder.sjs?html=<iframe src="${TEST_COM_URL}"></iframe>`
+ );
+ const commands = await CommandsFactory.forTab(tab);
+ const targetCommand = commands.targetCommand;
+ const { TYPES } = targetCommand;
+
+ await targetCommand.startListening();
+
+ // Assert that watchTargets will call the create callback for all existing frames
+ const targets = [];
+ const onAvailable = async ({ targetFront }) => {
+ is(
+ targetFront.targetType,
+ TYPES.FRAME,
+ "We are only notified about frame targets"
+ );
+ targets.push(targetFront);
+ };
+ await targetCommand.watchTargets({ types: [TYPES.FRAME], onAvailable });
+
+ // When fission/EFT is off, there isn't much to test for iframes as they are debugged
+ // when the unique top level target
+ if (!isFissionEnabled() && !isEveryFrameTargetEnabled()) {
+ is(
+ targets.length,
+ 1,
+ "Without fission/EFT, there is only the top level target"
+ );
+ await commands.destroy();
+ return;
+ }
+ is(targets.length, 2, "retrieved the top level and the iframe targets");
+ is(
+ targets[0],
+ targetCommand.targetFront,
+ "the first target is the top level one"
+ );
+ is(targets[1].url, TEST_COM_URL, "the second target is the iframe one");
+
+ // Navigate to the same page with query params
+ info("Load the second page");
+ const secondPageUrl = TEST_COM_URL + "?second-load";
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [secondPageUrl],
+ function (url) {
+ const iframe = content.document.querySelector("iframe");
+ iframe.src = url;
+ }
+ );
+
+ await waitFor(() => targets.length == 3, "wait for the next target");
+ is(targets[2].url, secondPageUrl, "the second target is for the second url");
+ ok(targets[1].isDestroyed(), "the first target is destroyed");
+
+ // Go back to the first page, this should be a bfcache navigation, and,
+ // we should get a new target
+ info("Go back to the first page");
+ const iframeBrowsingContext = await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ function () {
+ const iframe = content.document.querySelector("iframe");
+ return iframe.browsingContext;
+ }
+ );
+ await SpecialPowers.spawn(iframeBrowsingContext, [], function () {
+ content.history.back();
+ });
+
+ await waitFor(() => targets.length == 4, "wait for the next target");
+ is(targets[3].url, TEST_COM_URL, "the third target is for the first url");
+ ok(targets[2].isDestroyed(), "the second target is destroyed");
+
+ // Go forward and resurect the second page, this should also be a bfcache navigation, and,
+ // get a new target.
+ info("Go forward to the second page");
+ await SpecialPowers.spawn(iframeBrowsingContext, [], function () {
+ content.history.forward();
+ });
+
+ await waitFor(() => targets.length == 5, "wait for the next target");
+ is(targets[4].url, secondPageUrl, "the 4th target is for the second url");
+ ok(targets[3].isDestroyed(), "the third target is destroyed");
+
+ targetCommand.unwatchTargets({ types: [TYPES.FRAME], onAvailable });
+
+ await waitForAllTargetsToBeAttached(targetCommand);
+
+ BrowserTestUtils.removeTab(tab);
+
+ await commands.destroy();
+}
diff --git a/devtools/shared/commands/target/tests/browser_target_command_browser_workers.js b/devtools/shared/commands/target/tests/browser_target_command_browser_workers.js
new file mode 100644
index 0000000000..181cfa2614
--- /dev/null
+++ b/devtools/shared/commands/target/tests/browser_target_command_browser_workers.js
@@ -0,0 +1,246 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the TargetCommand API around workers
+
+const FISSION_TEST_URL = URL_ROOT_SSL + "fission_document.html";
+const WORKER_FILE = "test_worker.js";
+const CHROME_WORKER_URL = CHROME_URL_ROOT + WORKER_FILE;
+const SERVICE_WORKER_URL = URL_ROOT_SSL + "test_service_worker.js";
+
+add_task(async function () {
+ // Enabled fission's pref as the TargetCommand is almost disabled without it
+ await pushPref("devtools.browsertoolbox.scope", "everything");
+
+ // 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 tab = await addTab(FISSION_TEST_URL);
+
+ info("Test TargetCommand against workers via the parent process target");
+
+ // Instantiate a worker in the parent process
+ // eslint-disable-next-line no-unused-vars
+ const worker = new Worker(CHROME_WORKER_URL + "#simple-worker");
+ // eslint-disable-next-line no-unused-vars
+ const sharedWorker = new SharedWorker(CHROME_WORKER_URL + "#shared-worker");
+
+ const commands = await CommandsFactory.forMainProcess();
+ const targetCommand = commands.targetCommand;
+ const { TYPES } = targetCommand;
+ await targetCommand.startListening();
+
+ // Very naive sanity check against getAllTargets([workerType])
+ info("Check that getAllTargets returned the expected targets");
+ const workers = await targetCommand.getAllTargets([TYPES.WORKER]);
+ const hasWorker = workers.find(workerTarget => {
+ return workerTarget.url == CHROME_WORKER_URL + "#simple-worker";
+ });
+ ok(hasWorker, "retrieve the target for the worker");
+
+ const sharedWorkers = await targetCommand.getAllTargets([
+ TYPES.SHARED_WORKER,
+ ]);
+ const hasSharedWorker = sharedWorkers.find(workerTarget => {
+ return workerTarget.url == CHROME_WORKER_URL + "#shared-worker";
+ });
+ ok(hasSharedWorker, "retrieve the target for the shared worker");
+
+ const serviceWorkers = await targetCommand.getAllTargets([
+ TYPES.SERVICE_WORKER,
+ ]);
+ const hasServiceWorker = serviceWorkers.find(workerTarget => {
+ return workerTarget.url == SERVICE_WORKER_URL;
+ });
+ ok(hasServiceWorker, "retrieve the target for the service worker");
+
+ info(
+ "Check that calling getAllTargets again return the same target instances"
+ );
+ const workers2 = await targetCommand.getAllTargets([TYPES.WORKER]);
+ const sharedWorkers2 = await targetCommand.getAllTargets([
+ TYPES.SHARED_WORKER,
+ ]);
+ const serviceWorkers2 = await targetCommand.getAllTargets([
+ TYPES.SERVICE_WORKER,
+ ]);
+ is(workers2.length, workers.length, "retrieved the same number of workers");
+ is(
+ sharedWorkers2.length,
+ sharedWorkers.length,
+ "retrieved the same number of shared workers"
+ );
+ is(
+ serviceWorkers2.length,
+ serviceWorkers.length,
+ "retrieved the same number of service workers"
+ );
+
+ workers.sort(sortFronts);
+ workers2.sort(sortFronts);
+ sharedWorkers.sort(sortFronts);
+ sharedWorkers2.sort(sortFronts);
+ serviceWorkers.sort(sortFronts);
+ serviceWorkers2.sort(sortFronts);
+
+ for (let i = 0; i < workers.length; i++) {
+ is(workers[i], workers2[i], `worker ${i} targets are the same`);
+ }
+ for (let i = 0; i < sharedWorkers2.length; i++) {
+ is(
+ sharedWorkers[i],
+ sharedWorkers2[i],
+ `shared worker ${i} targets are the same`
+ );
+ }
+ for (let i = 0; i < serviceWorkers2.length; i++) {
+ is(
+ serviceWorkers[i],
+ serviceWorkers2[i],
+ `service worker ${i} targets are the same`
+ );
+ }
+
+ info(
+ "Check that watchTargets will call the create callback for all existing workers"
+ );
+ const targets = [];
+ const topLevelTarget = await commands.targetCommand.targetFront;
+ const onAvailable = async ({ targetFront }) => {
+ ok(
+ targetFront.targetType === TYPES.WORKER ||
+ targetFront.targetType === TYPES.SHARED_WORKER ||
+ targetFront.targetType === TYPES.SERVICE_WORKER,
+ "We are only notified about worker targets"
+ );
+ ok(
+ targetFront == topLevelTarget
+ ? targetFront.isTopLevel
+ : !targetFront.isTopLevel,
+ "isTopLevel property is correct"
+ );
+ targets.push(targetFront);
+ };
+ await targetCommand.watchTargets({
+ types: [TYPES.WORKER, TYPES.SHARED_WORKER, TYPES.SERVICE_WORKER],
+ onAvailable,
+ });
+ is(
+ targets.length,
+ workers.length + sharedWorkers.length + serviceWorkers.length,
+ "retrieved the same number of workers via watchTargets"
+ );
+
+ targets.sort(sortFronts);
+ const allWorkers = workers
+ .concat(sharedWorkers, serviceWorkers)
+ .sort(sortFronts);
+
+ for (let i = 0; i < allWorkers.length; i++) {
+ is(
+ allWorkers[i],
+ targets[i],
+ `worker ${i} targets are the same via watchTargets`
+ );
+ }
+
+ targetCommand.unwatchTargets({
+ types: [TYPES.WORKER, TYPES.SHARED_WORKER, TYPES.SERVICE_WORKER],
+ onAvailable,
+ });
+
+ // Create a new worker and see if the worker target is reported
+ const onWorkerCreated = new Promise(resolve => {
+ const onAvailable2 = async ({ targetFront }) => {
+ if (targets.includes(targetFront)) {
+ return;
+ }
+ targetCommand.unwatchTargets({
+ types: [TYPES.WORKER],
+ onAvailable: onAvailable2,
+ });
+ resolve(targetFront);
+ };
+ targetCommand.watchTargets({
+ types: [TYPES.WORKER],
+ onAvailable: onAvailable2,
+ });
+ });
+ // eslint-disable-next-line no-unused-vars
+ const worker2 = new Worker(CHROME_WORKER_URL + "#second");
+ info("Wait for the second worker to be created");
+ const workerTarget = await onWorkerCreated;
+
+ is(
+ workerTarget.url,
+ CHROME_WORKER_URL + "#second",
+ "This worker target is about the new worker"
+ );
+ is(
+ workerTarget.name,
+ "test_worker.js#second",
+ "The worker target has the expected name"
+ );
+
+ const workers3 = await targetCommand.getAllTargets([TYPES.WORKER]);
+ const hasWorker2 = workers3.find(
+ ({ url }) => url == `${CHROME_WORKER_URL}#second`
+ );
+ ok(hasWorker2, "retrieve the target for tab via getAllTargets");
+
+ info(
+ "Check that terminating the worker does trigger the onDestroyed callback"
+ );
+ const onWorkerDestroyed = new Promise(resolve => {
+ const emptyFn = () => {};
+ const onDestroyed = ({ targetFront }) => {
+ targetCommand.unwatchTargets({
+ types: [TYPES.WORKER],
+ onAvailable: emptyFn,
+ onDestroyed,
+ });
+ resolve(targetFront);
+ };
+
+ targetCommand.watchTargets({
+ types: [TYPES.WORKER],
+ onAvailable: emptyFn,
+ onDestroyed,
+ });
+ });
+ worker2.terminate();
+ const workerTargetFront = await onWorkerDestroyed;
+ ok(true, "onDestroyed was called when the worker was terminated");
+
+ workerTargetFront.isTopLevel;
+ ok(
+ true,
+ "isTopLevel can be called on the target front after onDestroyed was called"
+ );
+
+ workerTargetFront.name;
+ ok(
+ true,
+ "name can be accessed on the target front after onDestroyed was called"
+ );
+
+ targetCommand.destroy();
+
+ info("Unregister service workers so they don't appear in other tests.");
+ await unregisterAllServiceWorkers(commands.client);
+
+ await commands.destroy();
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async () => {
+ // registrationPromise is set by the test page.
+ const registration = await content.wrappedJSObject.registrationPromise;
+ registration.unregister();
+ });
+});
+
+function sortFronts(f1, f2) {
+ return f1.actorID < f2.actorID;
+}
diff --git a/devtools/shared/commands/target/tests/browser_target_command_detach.js b/devtools/shared/commands/target/tests/browser_target_command_detach.js
new file mode 100644
index 0000000000..a0056cd7a5
--- /dev/null
+++ b/devtools/shared/commands/target/tests/browser_target_command_detach.js
@@ -0,0 +1,59 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the TargetCommand's when detaching the top target
+//
+// Do this with the "remote tab" codepath, which will avoid
+// destroying the DevToolsClient when the target is destroyed.
+// Otherwise, with "local tab", the client is closed and everything is destroy
+// on both client and server side.
+
+const TEST_URL = "data:text/html,test-page";
+
+add_task(async function () {
+ info(" ### Test detaching the top target");
+
+ // Create a TargetCommand for a given test tab
+ const tab = await addTab(TEST_URL);
+
+ info("Create a first commands, which will destroy its top target");
+ const commands = await CommandsFactory.forRemoteTab(
+ tab.linkedBrowser.browserId
+ );
+ const targetCommand = commands.targetCommand;
+
+ // We have to start listening in order to ensure having a targetFront available
+ await targetCommand.startListening();
+
+ info("Call any target front method, to ensure it works fine");
+ await targetCommand.targetFront.focus();
+
+ // Destroying the target front should end up calling "WindowGlobalTargetActor.detach"
+ // which should destroy the target on the server side
+ await targetCommand.targetFront.destroy();
+
+ info(
+ "Now create a second commands after destroy, to see if we can spawn a new, functional target"
+ );
+ const secondCommands = await CommandsFactory.forRemoteTab(
+ tab.linkedBrowser.browserId,
+ {
+ client: commands.client,
+ }
+ );
+ const secondTargetCommand = secondCommands.targetCommand;
+
+ // We have to start listening in order to ensure having a targetFront available
+ await secondTargetCommand.startListening();
+
+ info("Call any target front method, to ensure it works fine");
+ await secondTargetCommand.targetFront.focus();
+
+ BrowserTestUtils.removeTab(tab);
+
+ info("Close the two commands");
+ await commands.destroy();
+ await secondCommands.destroy();
+});
diff --git a/devtools/shared/commands/target/tests/browser_target_command_frames.js b/devtools/shared/commands/target/tests/browser_target_command_frames.js
new file mode 100644
index 0000000000..bfa297801a
--- /dev/null
+++ b/devtools/shared/commands/target/tests/browser_target_command_frames.js
@@ -0,0 +1,649 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the TargetCommand API around frames
+
+const FISSION_TEST_URL = URL_ROOT_SSL + "fission_document.html";
+const IFRAME_URL = URL_ROOT_ORG_SSL + "fission_iframe.html";
+const SECOND_PAGE_URL = "https://example.org/document-builder.sjs?html=org";
+
+const PID_REGEXP = /^\d+$/;
+
+add_task(async function () {
+ // Disable bfcache for Fission for now.
+ // If Fission is disabled, the pref is no-op.
+ await SpecialPowers.pushPrefEnv({
+ set: [["fission.bfcacheInParent", false]],
+ });
+
+ // Enabled fission prefs
+ await pushPref("devtools.browsertoolbox.scope", "everything");
+ // 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);
+
+ // Test fetching the frames from the main process descriptor
+ await testBrowserFrames();
+
+ // Test fetching the frames from a tab descriptor
+ await testTabFrames();
+
+ // Test what happens with documents running in the parent process
+ await testOpeningOnParentProcessDocument();
+ await testNavigationToParentProcessDocument();
+
+ // Test what happens with about:blank documents
+ await testOpeningOnAboutBlankDocument();
+ await testNavigationToAboutBlankDocument();
+
+ await testNestedIframes();
+});
+
+async function testOpeningOnParentProcessDocument() {
+ info("Test opening against a parent process document");
+ const tab = await addTab("about:robots");
+ is(
+ tab.linkedBrowser.browsingContext.currentWindowGlobal.osPid,
+ -1,
+ "The tab is loaded in the parent process"
+ );
+
+ const commands = await CommandsFactory.forTab(tab);
+ const targetCommand = commands.targetCommand;
+ await targetCommand.startListening();
+
+ const frames = await targetCommand.getAllTargets([targetCommand.TYPES.FRAME]);
+ is(frames.length, 1);
+ is(frames[0].url, "about:robots", "target url is correct");
+ is(
+ frames[0],
+ targetCommand.targetFront,
+ "the target is the current top level one"
+ );
+
+ await commands.destroy();
+}
+
+async function testNavigationToParentProcessDocument() {
+ info("Test navigating to parent process document");
+ const firstLocation = "data:text/html,foo";
+ const secondLocation = "about:robots";
+
+ const tab = await addTab(firstLocation);
+ const commands = await CommandsFactory.forTab(tab);
+ const targetCommand = commands.targetCommand;
+ // When the first top level target is created from the server,
+ // `startListening` emits a spurious switched-target event
+ // which isn't necessarily emited before it resolves.
+ // So ensure waiting for it, otherwise we may resolve too eagerly
+ // in our expected listener.
+ const onSwitchedTarget1 = targetCommand.once("switched-target");
+ await targetCommand.startListening();
+ info("wait for first top level target");
+ await onSwitchedTarget1;
+
+ const firstTarget = targetCommand.targetFront;
+ is(firstTarget.url, firstLocation, "first target url is correct");
+
+ info("Navigate to a parent process page");
+ const onSwitchedTarget = targetCommand.once("switched-target");
+ const browser = tab.linkedBrowser;
+ const onLoaded = BrowserTestUtils.browserLoaded(browser);
+ await BrowserTestUtils.loadURIString(browser, secondLocation);
+ await onLoaded;
+ is(
+ browser.browsingContext.currentWindowGlobal.osPid,
+ -1,
+ "The tab is loaded in the parent process"
+ );
+
+ await onSwitchedTarget;
+ isnot(targetCommand.targetFront, firstTarget, "got a new target");
+
+ // Check that calling getAllTargets([frame]) return the same target instances
+ const frames = await targetCommand.getAllTargets([targetCommand.TYPES.FRAME]);
+ is(frames.length, 1);
+ is(frames[0].url, secondLocation, "second target url is correct");
+ is(
+ frames[0],
+ targetCommand.targetFront,
+ "second target is the current top level one"
+ );
+
+ await commands.destroy();
+}
+
+async function testOpeningOnAboutBlankDocument() {
+ info("Test opening against about:blank document");
+ const tab = await addTab("about:blank");
+
+ const commands = await CommandsFactory.forTab(tab);
+ const targetCommand = commands.targetCommand;
+ await targetCommand.startListening();
+
+ const frames = await targetCommand.getAllTargets([targetCommand.TYPES.FRAME]);
+ is(frames.length, 1);
+ is(frames[0].url, "about:blank", "target url is correct");
+ is(
+ frames[0],
+ targetCommand.targetFront,
+ "the target is the current top level one"
+ );
+
+ await commands.destroy();
+}
+
+async function testNavigationToAboutBlankDocument() {
+ info("Test navigating to about:blank");
+ const firstLocation = "data:text/html,foo";
+ const secondLocation = "about:blank";
+
+ const tab = await addTab(firstLocation);
+ const commands = await CommandsFactory.forTab(tab);
+ const targetCommand = commands.targetCommand;
+ // When the first top level target is created from the server,
+ // `startListening` emits a spurious switched-target event
+ // which isn't necessarily emited before it resolves.
+ // So ensure waiting for it, otherwise we may resolve too eagerly
+ // in our expected listener.
+ const onSwitchedTarget1 = targetCommand.once("switched-target");
+ await targetCommand.startListening();
+ info("wait for first top level target");
+ await onSwitchedTarget1;
+
+ const firstTarget = targetCommand.targetFront;
+ is(firstTarget.url, firstLocation, "first target url is correct");
+
+ info("Navigate to about:blank page");
+ const onSwitchedTarget = targetCommand.once("switched-target");
+ const browser = tab.linkedBrowser;
+ const onLoaded = BrowserTestUtils.browserLoaded(browser);
+ await BrowserTestUtils.loadURIString(browser, secondLocation);
+ await onLoaded;
+
+ await onSwitchedTarget;
+ isnot(targetCommand.targetFront, firstTarget, "got a new target");
+
+ // Check that calling getAllTargets([frame]) return the same target instances
+ const frames = await targetCommand.getAllTargets([targetCommand.TYPES.FRAME]);
+ is(frames.length, 1);
+ is(frames[0].url, secondLocation, "second target url is correct");
+ is(
+ frames[0],
+ targetCommand.targetFront,
+ "second target is the current top level one"
+ );
+
+ await commands.destroy();
+}
+
+async function testBrowserFrames() {
+ info("Test TargetCommand against frames via the parent process target");
+
+ const aboutBlankTab = await addTab("about:blank");
+
+ const commands = await CommandsFactory.forMainProcess();
+ const targetCommand = commands.targetCommand;
+ const { TYPES } = targetCommand;
+ await targetCommand.startListening();
+
+ // Very naive sanity check against getAllTargets([frame])
+ const frames = await targetCommand.getAllTargets([TYPES.FRAME]);
+ const hasBrowserDocument = frames.find(
+ frameTarget => frameTarget.url == window.location.href
+ );
+ ok(hasBrowserDocument, "retrieve the target for the browser document");
+
+ const hasAboutBlankDocument = frames.find(
+ frameTarget =>
+ frameTarget.browsingContextID ==
+ aboutBlankTab.linkedBrowser.browsingContext.id
+ );
+ ok(hasAboutBlankDocument, "retrieve the target for the about:blank tab");
+
+ // Check that calling getAllTargets([frame]) return the same target instances
+ const frames2 = await targetCommand.getAllTargets([TYPES.FRAME]);
+ is(frames2.length, frames.length, "retrieved the same number of frames");
+
+ function sortFronts(f1, f2) {
+ return f1.actorID < f2.actorID;
+ }
+ frames.sort(sortFronts);
+ frames2.sort(sortFronts);
+ for (let i = 0; i < frames.length; i++) {
+ is(frames[i], frames2[i], `frame ${i} targets are the same`);
+ }
+
+ // Assert that watchTargets will call the create callback for all existing frames
+ const targets = [];
+ const topLevelTarget = targetCommand.targetFront;
+
+ const noParentTarget = await topLevelTarget.getParentTarget();
+ is(noParentTarget, null, "The top level target has no parent target");
+
+ const onAvailable = ({ targetFront }) => {
+ is(
+ targetFront.targetType,
+ TYPES.FRAME,
+ "We are only notified about frame targets"
+ );
+ ok(
+ targetFront == topLevelTarget
+ ? targetFront.isTopLevel
+ : !targetFront.isTopLevel,
+ "isTopLevel property is correct"
+ );
+ ok(
+ PID_REGEXP.test(targetFront.processID),
+ `Target has processID of expected shape (${targetFront.processID})`
+ );
+ targets.push(targetFront);
+ };
+ await targetCommand.watchTargets({ types: [TYPES.FRAME], onAvailable });
+ is(
+ targets.length,
+ frames.length,
+ "retrieved the same number of frames via watchTargets"
+ );
+
+ frames.sort(sortFronts);
+ targets.sort(sortFronts);
+ for (let i = 0; i < frames.length; i++) {
+ is(
+ frames[i],
+ targets[i],
+ `frame ${i} targets are the same via watchTargets`
+ );
+ }
+
+ async function addTabAndAssertNewTarget(url) {
+ const previousTargetCount = targets.length;
+ const tab = await addTab(url);
+ await waitFor(
+ () => targets.length == previousTargetCount + 1,
+ "Wait for all expected targets after tab opening"
+ );
+ is(
+ targets.length,
+ previousTargetCount + 1,
+ "Opening a tab reported a new frame"
+ );
+ const newTabTarget = targets.at(-1);
+ is(newTabTarget.url, url, "This frame target is about the new tab");
+ // Internaly, the tab, which uses a <browser type='content'> element is considered detached from their owner document
+ // and so the target is having a null parentInnerWindowId. But the framework will attach all non-top-level targets
+ // as children of the top level.
+ const tabParentTarget = await newTabTarget.getParentTarget();
+ is(
+ tabParentTarget,
+ targetCommand.targetFront,
+ "tab's WindowGlobal/BrowsingContext is detached and has no parent, but we report them as children of the top level target"
+ );
+
+ const frames3 = await targetCommand.getAllTargets([TYPES.FRAME]);
+ const hasTabDocument = frames3.find(target => target.url == url);
+ ok(hasTabDocument, "retrieve the target for tab via getAllTargets");
+
+ return tab;
+ }
+
+ info("Open a tab loaded in content process");
+ await addTabAndAssertNewTarget("data:text/html,content-process-page");
+
+ info("Open a tab loaded in the parent process");
+ const parentProcessTab = await addTabAndAssertNewTarget("about:robots");
+ is(
+ parentProcessTab.linkedBrowser.browsingContext.currentWindowGlobal.osPid,
+ -1,
+ "The tab is loaded in the parent process"
+ );
+
+ info("Open a new content window via window.open");
+ info("First open a tab on .org domain");
+ const tabUrl = "https://example.org/document-builder.sjs?html=org";
+ await addTabAndAssertNewTarget(tabUrl);
+ const previousTargetCount = targets.length;
+
+ info("Then open a popup on .com domain");
+ const popupUrl = "https://example.com/document-builder.sjs?html=com";
+ const onPopupOpened = BrowserTestUtils.waitForNewTab(gBrowser, popupUrl);
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [popupUrl], async url => {
+ content.window.open(url, "_blank");
+ });
+ await onPopupOpened;
+
+ await waitFor(
+ () => targets.length == previousTargetCount + 1,
+ "Wait for all expected targets after window.open()"
+ );
+ is(
+ targets.length,
+ previousTargetCount + 1,
+ "Opening a new content window reported a new frame"
+ );
+ is(
+ targets.at(-1).url,
+ popupUrl,
+ "This frame target is about the new content window"
+ );
+
+ // About:blank are a bit special because we ignore a transcient about:blank
+ // document when navigating to another process. But we should not ignore
+ // tabs, loading a real, final about:blank document.
+ info("Open a tab with about:blank");
+ await addTabAndAssertNewTarget("about:blank");
+
+ // Until we start spawning target for all WindowGlobals,
+ // including the one running in the same process as their parent,
+ // we won't create dedicated target for new top level windows.
+ // Instead, these document will be debugged via the ParentProcessTargetActor.
+ info("Open a top level chrome window");
+ const expectedTargets = targets.length;
+ const chromeWindow = Services.ww.openWindow(
+ null,
+ "about:robots",
+ "_blank",
+ "chrome",
+ null
+ );
+ await wait(250);
+ is(
+ targets.length,
+ expectedTargets,
+ "New top level window shouldn't spawn new target"
+ );
+ chromeWindow.close();
+
+ targetCommand.unwatchTargets({ types: [TYPES.FRAME], onAvailable });
+
+ targetCommand.destroy();
+ await waitForAllTargetsToBeAttached(targetCommand);
+
+ await commands.destroy();
+}
+
+async function testTabFrames(mainRoot) {
+ info("Test TargetCommand against frames via a tab target");
+
+ // Create a TargetCommand for a given test tab
+ const tab = await addTab(FISSION_TEST_URL);
+ const commands = await CommandsFactory.forTab(tab);
+ const targetCommand = commands.targetCommand;
+ const { TYPES } = targetCommand;
+
+ await targetCommand.startListening();
+
+ // Check that calling getAllTargets([frame]) return the same target instances
+ const frames = await targetCommand.getAllTargets([TYPES.FRAME]);
+ // When fission is enabled, we also get the remote example.org iframe.
+ const expectedFramesCount =
+ isFissionEnabled() || isEveryFrameTargetEnabled() ? 2 : 1;
+ is(
+ frames.length,
+ expectedFramesCount,
+ "retrieved the expected number of targets"
+ );
+
+ // Assert that watchTargets will call the create callback for all existing frames
+ const targets = [];
+ const destroyedTargets = [];
+ const topLevelTarget = targetCommand.targetFront;
+ const onAvailable = ({ targetFront, isTargetSwitching }) => {
+ is(
+ targetFront.targetType,
+ TYPES.FRAME,
+ "We are only notified about frame targets"
+ );
+ ok(
+ PID_REGEXP.test(targetFront.processID),
+ `Target has processID of expected shape (${targetFront.processID})`
+ );
+ targets.push({ targetFront, isTargetSwitching });
+ };
+ const onDestroyed = ({ targetFront, isTargetSwitching }) => {
+ is(
+ targetFront.targetType,
+ TYPES.FRAME,
+ "We are only notified about frame targets"
+ );
+ ok(
+ targetFront == topLevelTarget
+ ? targetFront.isTopLevel
+ : !targetFront.isTopLevel,
+ "isTopLevel property is correct"
+ );
+ destroyedTargets.push({ targetFront, isTargetSwitching });
+ };
+ await targetCommand.watchTargets({
+ types: [TYPES.FRAME],
+ onAvailable,
+ onDestroyed,
+ });
+ is(
+ targets.length,
+ frames.length,
+ "retrieved the same number of frames via watchTargets"
+ );
+ is(destroyedTargets.length, 0, "Should be no destroyed target initialy");
+
+ for (const frame of frames) {
+ ok(
+ targets.find(({ targetFront }) => targetFront === frame),
+ "frame " + frame.actorID + " target is the same via watchTargets"
+ );
+ }
+ is(
+ targets[0].targetFront.url,
+ FISSION_TEST_URL,
+ "First target should be the top document one"
+ );
+ is(
+ targets[0].targetFront.isTopLevel,
+ true,
+ "First target is a top level one"
+ );
+ is(
+ !targets[0].isTargetSwitching,
+ true,
+ "First target is not considered as a target switching"
+ );
+ const noParentTarget = await targets[0].targetFront.getParentTarget();
+ is(noParentTarget, null, "The top level target has no parent target");
+
+ if (isFissionEnabled() || isEveryFrameTargetEnabled()) {
+ is(
+ targets[1].targetFront.url,
+ IFRAME_URL,
+ "Second target should be the iframe one"
+ );
+ is(
+ !targets[1].targetFront.isTopLevel,
+ true,
+ "Iframe target isn't top level"
+ );
+ is(
+ !targets[1].isTargetSwitching,
+ true,
+ "Iframe target isn't a target swich"
+ );
+ const parentTarget = await targets[1].targetFront.getParentTarget();
+ is(
+ parentTarget,
+ targets[0].targetFront,
+ "The parent target for the iframe is the top level target"
+ );
+ }
+
+ // Before navigating to another process, ensure cleaning up everything from the first page
+ await waitForAllTargetsToBeAttached(targetCommand);
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async () => {
+ // registrationPromise is set by the test page.
+ const registration = await content.wrappedJSObject.registrationPromise;
+ registration.unregister();
+ });
+
+ info("Navigate to another domain and process (if fission is enabled)");
+ // When a new target will be created, we need to wait until it's fully processed
+ // to avoid pending promises.
+ const onNewTargetProcessed = targetCommand.once("processed-available-target");
+
+ const browser = tab.linkedBrowser;
+ const onLoaded = BrowserTestUtils.browserLoaded(browser);
+ await BrowserTestUtils.loadURIString(browser, SECOND_PAGE_URL);
+ await onLoaded;
+
+ if (isFissionEnabled() || isEveryFrameTargetEnabled()) {
+ const afterNavigationFramesCount = 3;
+ await waitFor(
+ () => targets.length == afterNavigationFramesCount,
+ "Wait for all expected targets after navigation"
+ );
+ is(
+ targets.length,
+ afterNavigationFramesCount,
+ "retrieved all targets after navigation"
+ );
+ // As targetFront.url isn't reliable and might be about:blank,
+ // try to assert that we got the right target via other means.
+ // outerWindowID should change when navigating to another process,
+ // while it would stay equal for in-process navigations.
+ is(
+ targets[2].targetFront.outerWindowID,
+ browser.outerWindowID,
+ "The new target should be the newly loaded document"
+ );
+ is(
+ targets[2].isTargetSwitching,
+ true,
+ "and should be flagged as a target switching"
+ );
+
+ is(
+ destroyedTargets.length,
+ 2,
+ "The two existing targets should be destroyed"
+ );
+ is(
+ destroyedTargets[0].targetFront,
+ targets[1].targetFront,
+ "The first destroyed should be the iframe one"
+ );
+ is(
+ destroyedTargets[0].isTargetSwitching,
+ false,
+ "the target destruction is not flagged as target switching for iframes"
+ );
+ is(
+ destroyedTargets[1].targetFront,
+ targets[0].targetFront,
+ "The second destroyed should be the previous top level one (because it is delayed to be fired *after* will-navigate)"
+ );
+ is(
+ destroyedTargets[1].isTargetSwitching,
+ true,
+ "the target destruction is flagged as target switching"
+ );
+ } else {
+ await waitFor(
+ () => targets.length == 2,
+ "Wait for all expected targets after navigation"
+ );
+ is(
+ destroyedTargets.length,
+ 1,
+ "with JSWindowActor based target, the top level target is destroyed"
+ );
+ is(
+ targetCommand.targetFront,
+ targets[1].targetFront,
+ "we got a new target"
+ );
+ ok(
+ !targetCommand.targetFront.isDestroyed(),
+ "that target is not destroyed"
+ );
+ ok(
+ targets[0].targetFront.isDestroyed(),
+ "but the previous one is destroyed"
+ );
+ }
+
+ await onNewTargetProcessed;
+
+ targetCommand.unwatchTargets({ types: [TYPES.FRAME], onAvailable });
+
+ targetCommand.destroy();
+
+ BrowserTestUtils.removeTab(tab);
+
+ await commands.destroy();
+}
+
+async function testNestedIframes() {
+ info("Test TargetCommand against nested frames");
+
+ const nestedIframeUrl = `https://example.com/document-builder.sjs?html=${encodeURIComponent(
+ "<title>second</title><h3>second level iframe</h3>"
+ )}&delay=500`;
+
+ const testUrl = `data:text/html;charset=utf-8,
+ <h1>Top-level</h1>
+ <iframe id=first-level
+ src='data:text/html;charset=utf-8,${encodeURIComponent(
+ `<title>first</title><h2>first level iframe</h2><iframe id=second-level src="${nestedIframeUrl}"></iframe>`
+ )}'
+ ></iframe>`;
+
+ // Create a TargetCommand for a given test tab
+ const tab = await addTab(testUrl);
+ const commands = await CommandsFactory.forTab(tab);
+ const targetCommand = commands.targetCommand;
+ const { TYPES } = targetCommand;
+
+ await targetCommand.startListening();
+
+ // Check that calling getAllTargets([frame]) return the same target instances
+ const frames = await targetCommand.getAllTargets([TYPES.FRAME]);
+
+ is(frames[0], targetCommand.targetFront, "First target is the top level one");
+ const topParent = await frames[0].getParentTarget();
+ is(topParent, null, "Top level target has no parent");
+
+ if (isEveryFrameTargetEnabled()) {
+ const firstIframeTarget = frames.find(target => target.title == "first");
+ ok(
+ firstIframeTarget,
+ "With EFT, got the target for the first level iframe"
+ );
+ const firstParent = await firstIframeTarget.getParentTarget();
+ is(
+ firstParent,
+ targetCommand.targetFront,
+ "With EFT, first level has top level target as parent"
+ );
+
+ const secondIframeTarget = frames.find(target => target.title == "second");
+ ok(secondIframeTarget, "Got the target for the second level iframe");
+ const secondParent = await secondIframeTarget.getParentTarget();
+ is(
+ secondParent,
+ firstIframeTarget,
+ "With EFT, second level has the first level target as parent"
+ );
+ } else if (isFissionEnabled()) {
+ const secondIframeTarget = frames.find(target => target.title == "second");
+ ok(secondIframeTarget, "Got the target for the second level iframe");
+ const secondParent = await secondIframeTarget.getParentTarget();
+ is(
+ secondParent,
+ targetCommand.targetFront,
+ "With fission, second level has top level target as parent"
+ );
+ }
+
+ await commands.destroy();
+}
diff --git a/devtools/shared/commands/target/tests/browser_target_command_frames_popups.js b/devtools/shared/commands/target/tests/browser_target_command_frames_popups.js
new file mode 100644
index 0000000000..68f7244671
--- /dev/null
+++ b/devtools/shared/commands/target/tests/browser_target_command_frames_popups.js
@@ -0,0 +1,168 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that we create targets for popups
+
+const TEST_URL = "https://example.org/document-builder.sjs?html=main page";
+const POPUP_URL = "https://example.com/document-builder.sjs?html=popup";
+const POPUP_SECOND_URL =
+ "https://example.com/document-builder.sjs?html=popup-navigated";
+
+add_task(async function () {
+ await pushPref("devtools.popups.debug", true);
+ // We expect to create a target for a same-process iframe
+ // in the test against window.open to load a document in an iframe.
+ await pushPref("devtools.every-frame-target.enabled", true);
+
+ // Create a TargetCommand for a given test tab
+ const tab = await addTab(TEST_URL);
+ const commands = await CommandsFactory.forTab(tab);
+ const targetCommand = commands.targetCommand;
+ const { TYPES } = targetCommand;
+
+ await targetCommand.startListening();
+
+ // Assert that watchTargets will call the create callback for all existing frames
+ const targets = [];
+ const destroyedTargets = [];
+ const onAvailable = ({ targetFront }) => {
+ targets.push(targetFront);
+ };
+ const onDestroyed = ({ targetFront }) => {
+ destroyedTargets.push(targetFront);
+ };
+ await targetCommand.watchTargets({
+ types: [TYPES.FRAME],
+ onAvailable,
+ onDestroyed,
+ });
+
+ is(targets.length, 1, "At first, we only get one target");
+ is(
+ targets[0],
+ targetCommand.targetFront,
+ "And this target is the top level one"
+ );
+
+ info("Open a popup");
+ const firstPopupBrowsingContext = await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [POPUP_URL],
+ url => {
+ const win = content.open(url);
+ return win.browsingContext;
+ }
+ );
+
+ await waitFor(() => targets.length === 2);
+ ok(true, "We are notified about the first popup's target");
+
+ is(
+ targets[1].browsingContextID,
+ firstPopupBrowsingContext.id,
+ "the new target is for the popup"
+ );
+ is(targets[1].url, POPUP_URL, "the new target has the right url");
+
+ info("Navigate the popup to a second location");
+ await SpecialPowers.spawn(
+ firstPopupBrowsingContext,
+ [POPUP_SECOND_URL],
+ url => {
+ content.location.href = url;
+ }
+ );
+
+ await waitFor(() => targets.length === 3);
+ ok(true, "We are notified about the new location popup's target");
+
+ await waitFor(() => destroyedTargets.length === 1);
+ ok(true, "The first popup's target is destroyed");
+ is(
+ destroyedTargets[0],
+ targets[1],
+ "The destroyed target is the popup's one"
+ );
+
+ is(
+ targets[2].browsingContextID,
+ firstPopupBrowsingContext.id,
+ "the new location target is for the popup"
+ );
+ is(
+ targets[2].url,
+ POPUP_SECOND_URL,
+ "the new location target has the right url"
+ );
+
+ info("Close the popup");
+ await SpecialPowers.spawn(firstPopupBrowsingContext, [], () => {
+ content.close();
+ });
+
+ await waitFor(() => destroyedTargets.length === 2);
+ ok(true, "The popup's target is destroyed");
+ is(
+ destroyedTargets[1],
+ targets[2],
+ "The destroyed target is the popup's one"
+ );
+
+ info("Open a about:blank popup");
+ const aboutBlankPopupBrowsingContext = await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [],
+ () => {
+ const win = content.open("about:blank");
+ return win.browsingContext;
+ }
+ );
+
+ await waitFor(() => targets.length === 4);
+ ok(true, "We are notified about the about:blank popup's target");
+
+ is(
+ targets[3].browsingContextID,
+ aboutBlankPopupBrowsingContext.id,
+ "the new target is for the popup"
+ );
+ is(targets[3].url, "about:blank", "the new target has the right url");
+
+ info("Select the original tab and reload it");
+ gBrowser.selectedTab = tab;
+ await BrowserTestUtils.reloadTab(tab);
+
+ await waitFor(() => targets.length === 5);
+ is(targets[4], targetCommand.targetFront, "We get a new top level target");
+ ok(!targets[3].isDestroyed(), "The about:blank popup target is still alive");
+
+ info("Call about:blank popup method to ensure it really is functional");
+ await targets[3].logInPage("foo");
+
+ info(
+ "Ensure that iframe using window.open to load their document aren't considered as popups"
+ );
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => {
+ const iframe = content.document.createElement("iframe");
+ iframe.setAttribute("name", "test-iframe");
+ content.document.documentElement.appendChild(iframe);
+ content.open("data:text/html,iframe", "test-iframe");
+ });
+ await waitFor(() => targets.length === 6);
+ is(
+ targets[5].targetForm.isPopup,
+ false,
+ "The iframe target isn't considered as a popup"
+ );
+
+ targetCommand.unwatchTargets({
+ types: [TYPES.FRAME],
+ onAvailable,
+ onDestroyed,
+ });
+ targetCommand.destroy();
+ BrowserTestUtils.removeTab(tab);
+ await commands.destroy();
+});
diff --git a/devtools/shared/commands/target/tests/browser_target_command_frames_reload_server_side_targets.js b/devtools/shared/commands/target/tests/browser_target_command_frames_reload_server_side_targets.js
new file mode 100644
index 0000000000..d05ff5a962
--- /dev/null
+++ b/devtools/shared/commands/target/tests/browser_target_command_frames_reload_server_side_targets.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 framework handles reloading a document with multiple remote frames (See Bug 1724909).
+
+const REMOTE_ORIGIN = "https://example.com/";
+const REMOTE_IFRAME_URL_1 =
+ REMOTE_ORIGIN + "/document-builder.sjs?html=first_remote_iframe";
+const REMOTE_IFRAME_URL_2 =
+ REMOTE_ORIGIN + "/document-builder.sjs?html=second_remote_iframe";
+const TEST_URL =
+ "https://example.org/document-builder.sjs?html=org" +
+ `<iframe src=${REMOTE_IFRAME_URL_1}></iframe>` +
+ `<iframe src=${REMOTE_IFRAME_URL_2}></iframe>`;
+
+add_task(async function () {
+ // Create a TargetCommand for a given test tab
+ const tab = await addTab(TEST_URL);
+ const commands = await CommandsFactory.forTab(tab);
+ const targetCommand = commands.targetCommand;
+ const { TYPES } = targetCommand;
+
+ await targetCommand.startListening();
+
+ // Assert that watchTargets will call the create callback for all existing frames
+ const targets = [];
+ const destroyedTargets = [];
+ const onAvailable = ({ targetFront }) => {
+ targets.push(targetFront);
+ };
+ const onDestroyed = ({ targetFront }) => {
+ destroyedTargets.push(targetFront);
+ };
+ await targetCommand.watchTargets({
+ types: [TYPES.FRAME],
+ onAvailable,
+ onDestroyed,
+ });
+
+ await waitFor(() => targets.length === 3);
+ ok(
+ true,
+ "We are notified about the top-level document and the 2 remote iframes"
+ );
+
+ info("Reload the page");
+ // When a new target will be created, we need to wait until it's fully processed
+ // to avoid pending promises.
+ const onNewTargetProcessed = targetCommand.once("processed-available-target");
+ gBrowser.reloadTab(tab);
+ await onNewTargetProcessed;
+
+ await waitFor(() => targets.length === 6 && destroyedTargets.length === 3);
+
+ // Get the previous targets in a dedicated array and remove them from `targets`
+ const previousTargets = targets.splice(0, 3);
+ ok(
+ previousTargets.every(targetFront => targetFront.isDestroyed()),
+ "The previous targets are all destroyed"
+ );
+ ok(
+ targets.every(targetFront => !targetFront.isDestroyed()),
+ "The new targets are not destroyed"
+ );
+
+ info("Reload one of the iframe");
+ SpecialPowers.spawn(tab.linkedBrowser, [], () => {
+ const iframeEl = content.document.querySelector("iframe");
+ SpecialPowers.spawn(iframeEl.browsingContext, [], () => {
+ content.document.location.reload();
+ });
+ });
+ await waitFor(
+ () =>
+ targets.length + previousTargets.length === 7 &&
+ destroyedTargets.length === 4
+ );
+ const iframeTarget = targets.find(t => t === destroyedTargets.at(-1));
+ ok(iframeTarget, "Got the iframe target that got destroyed");
+ for (const target of targets) {
+ if (target == iframeTarget) {
+ ok(
+ target.isDestroyed(),
+ "The iframe target we navigated from is destroyed"
+ );
+ } else {
+ ok(
+ !target.isDestroyed(),
+ `Target ${target.actorID}|${target.url} isn't destroyed`
+ );
+ }
+ }
+
+ targetCommand.unwatchTargets({
+ types: [TYPES.FRAME],
+ onAvailable,
+ onDestroyed,
+ });
+ targetCommand.destroy();
+ BrowserTestUtils.removeTab(tab);
+ await commands.destroy();
+});
diff --git a/devtools/shared/commands/target/tests/browser_target_command_getAllTargets.js b/devtools/shared/commands/target/tests/browser_target_command_getAllTargets.js
new file mode 100644
index 0000000000..a7d5e51b3c
--- /dev/null
+++ b/devtools/shared/commands/target/tests/browser_target_command_getAllTargets.js
@@ -0,0 +1,119 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the TargetCommand API getAllTargets.
+
+const FISSION_TEST_URL = URL_ROOT_SSL + "fission_document.html";
+const CHROME_WORKER_URL = CHROME_URL_ROOT + "test_worker.js";
+
+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);
+
+ info("Setup the test page with workers of all types");
+
+ const tab = await addTab(FISSION_TEST_URL);
+
+ // Instantiate a worker in the parent process
+ // eslint-disable-next-line no-unused-vars
+ const worker = new Worker(CHROME_WORKER_URL + "#simple-worker");
+ // eslint-disable-next-line no-unused-vars
+ const sharedWorker = new SharedWorker(CHROME_WORKER_URL + "#shared-worker");
+
+ info("Create a target list for the main process target");
+ const commands = await CommandsFactory.forMainProcess();
+ const targetCommand = commands.targetCommand;
+ const { TYPES } = targetCommand;
+ await targetCommand.startListening();
+
+ info("Check getAllTargets will throw when providing invalid arguments");
+ Assert.throws(
+ () => targetCommand.getAllTargets(),
+ e => e.message === "getAllTargets expects a non-empty array of types"
+ );
+
+ Assert.throws(
+ () => targetCommand.getAllTargets([]),
+ e => e.message === "getAllTargets expects a non-empty array of types"
+ );
+
+ info("Check getAllTargets returns consistent results with several types");
+ const workerTargets = targetCommand.getAllTargets([TYPES.WORKER]);
+ const serviceWorkerTargets = targetCommand.getAllTargets([
+ TYPES.SERVICE_WORKER,
+ ]);
+ const sharedWorkerTargets = targetCommand.getAllTargets([
+ TYPES.SHARED_WORKER,
+ ]);
+ const processTargets = targetCommand.getAllTargets([TYPES.PROCESS]);
+ const frameTargets = targetCommand.getAllTargets([TYPES.FRAME]);
+
+ const allWorkerTargetsReference = [
+ ...workerTargets,
+ ...serviceWorkerTargets,
+ ...sharedWorkerTargets,
+ ];
+ const allWorkerTargets = targetCommand.getAllTargets([
+ TYPES.WORKER,
+ TYPES.SERVICE_WORKER,
+ TYPES.SHARED_WORKER,
+ ]);
+
+ is(
+ allWorkerTargets.length,
+ allWorkerTargetsReference.length,
+ "getAllTargets([worker, service, shared]) returned the expected number of targets"
+ );
+
+ ok(
+ allWorkerTargets.every(t => allWorkerTargetsReference.includes(t)),
+ "getAllTargets([worker, service, shared]) returned the expected targets"
+ );
+
+ const allTargetsReference = [
+ ...allWorkerTargets,
+ ...processTargets,
+ ...frameTargets,
+ ];
+ const allTargets = targetCommand.getAllTargets(targetCommand.ALL_TYPES);
+ is(
+ allTargets.length,
+ allTargetsReference.length,
+ "getAllTargets(ALL_TYPES) returned the expected number of targets"
+ );
+
+ ok(
+ allTargets.every(t => allTargetsReference.includes(t)),
+ "getAllTargets(ALL_TYPES) returned the expected targets"
+ );
+
+ for (const target of allTargets) {
+ is(
+ target.commands,
+ commands,
+ "Each target front has a `commands` attribute - " + target
+ );
+ }
+
+ // Wait for all the targets to be fully attached so we don't have pending requests.
+ await waitForAllTargetsToBeAttached(targetCommand);
+
+ ok(
+ !targetCommand.isDestroyed(),
+ "TargetCommand isn't destroyed before calling commands.destroy()"
+ );
+ await commands.destroy();
+ ok(
+ targetCommand.isDestroyed(),
+ "TargetCommand is destroyed after calling commands.destroy()"
+ );
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async () => {
+ // registrationPromise is set by the test page.
+ const registration = await content.wrappedJSObject.registrationPromise;
+ registration.unregister();
+ });
+});
diff --git a/devtools/shared/commands/target/tests/browser_target_command_invalid_api_usage.js b/devtools/shared/commands/target/tests/browser_target_command_invalid_api_usage.js
new file mode 100644
index 0000000000..dbdaae7f05
--- /dev/null
+++ b/devtools/shared/commands/target/tests/browser_target_command_invalid_api_usage.js
@@ -0,0 +1,78 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test watch/unwatchTargets throw when provided with invalid types.
+
+const TEST_URL = "data:text/html;charset=utf-8,invalid api usage test";
+
+add_task(async function () {
+ info("Setup the test page with workers of all types");
+ const tab = await addTab(TEST_URL);
+
+ info("Create a target list for a tab target");
+ const commands = await CommandsFactory.forTab(tab);
+ const targetCommand = commands.targetCommand;
+
+ const onAvailable = function () {};
+
+ await Assert.rejects(
+ targetCommand.watchTargets({ types: [null], onAvailable }),
+ /TargetCommand.watchTargets invoked with an unknown type/,
+ "watchTargets should throw for null type"
+ );
+
+ await Assert.rejects(
+ targetCommand.watchTargets({ types: [undefined], onAvailable }),
+ /TargetCommand.watchTargets invoked with an unknown type/,
+ "watchTargets should throw for undefined type"
+ );
+
+ await Assert.rejects(
+ targetCommand.watchTargets({ types: ["NOT_A_TARGET"], onAvailable }),
+ /TargetCommand.watchTargets invoked with an unknown type/,
+ "watchTargets should throw for unknown type"
+ );
+
+ await Assert.rejects(
+ targetCommand.watchTargets({
+ types: [targetCommand.TYPES.FRAME, "NOT_A_TARGET"],
+ onAvailable,
+ }),
+ /TargetCommand.watchTargets invoked with an unknown type/,
+ "watchTargets should throw for unknown type mixed with a correct type"
+ );
+
+ Assert.throws(
+ () => targetCommand.unwatchTargets({ types: [null], onAvailable }),
+ /TargetCommand.unwatchTargets invoked with an unknown type/,
+ "unwatchTargets should throw for null type"
+ );
+
+ Assert.throws(
+ () => targetCommand.unwatchTargets({ types: [undefined], onAvailable }),
+ /TargetCommand.unwatchTargets invoked with an unknown type/,
+ "unwatchTargets should throw for undefined type"
+ );
+
+ Assert.throws(
+ () =>
+ targetCommand.unwatchTargets({ types: ["NOT_A_TARGET"], onAvailable }),
+ /TargetCommand.unwatchTargets invoked with an unknown type/,
+ "unwatchTargets should throw for unknown type"
+ );
+
+ Assert.throws(
+ () =>
+ targetCommand.unwatchTargets({
+ types: [targetCommand.TYPES.CONSOLE_MESSAGE, "NOT_A_TARGET"],
+ onAvailable,
+ }),
+ /TargetCommand.unwatchTargets invoked with an unknown type/,
+ "unwatchTargets should throw for unknown type mixed with a correct type"
+ );
+
+ BrowserTestUtils.removeTab(tab);
+ await commands.destroy();
+});
diff --git a/devtools/shared/commands/target/tests/browser_target_command_processes.js b/devtools/shared/commands/target/tests/browser_target_command_processes.js
new file mode 100644
index 0000000000..d9e7ec65a9
--- /dev/null
+++ b/devtools/shared/commands/target/tests/browser_target_command_processes.js
@@ -0,0 +1,242 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the TargetCommand API around processes
+
+const TEST_URL =
+ "data:text/html;charset=utf-8," + encodeURIComponent(`<div id="test"></div>`);
+
+add_task(async function () {
+ // Enabled fission's pref as the TargetCommand is almost disabled without it
+ await pushPref("devtools.browsertoolbox.scope", "everything");
+ // 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);
+
+ const commands = await CommandsFactory.forMainProcess();
+ const targetCommand = commands.targetCommand;
+ await targetCommand.startListening();
+
+ await testProcesses(targetCommand, targetCommand.targetFront);
+
+ targetCommand.destroy();
+ // Wait for all the targets to be fully attached so we don't have pending requests.
+ await Promise.all(
+ targetCommand.getAllTargets(targetCommand.ALL_TYPES).map(t => t.initialized)
+ );
+
+ await commands.destroy();
+});
+
+add_task(async function () {
+ const commands = await CommandsFactory.forMainProcess();
+ const targetCommand = commands.targetCommand;
+ await targetCommand.startListening();
+
+ const created = [];
+ const destroyed = [];
+ const onAvailable = ({ targetFront }) => {
+ created.push(targetFront);
+ };
+ const onDestroyed = ({ targetFront }) => {
+ destroyed.push(targetFront);
+ };
+ await targetCommand.watchTargets({
+ types: [targetCommand.TYPES.PROCESS],
+ onAvailable,
+ onDestroyed,
+ });
+ ok(created.length > 1, "We get many content process targets");
+
+ targetCommand.stopListening();
+
+ await waitFor(
+ () => created.length == destroyed.length,
+ "Wait for the destruction of all content process targets when calling stopListening"
+ );
+ is(
+ created.length,
+ destroyed.length,
+ "Got notification of destruction for all previously reported targets"
+ );
+
+ targetCommand.destroy();
+ // Wait for all the targets to be fully attached so we don't have pending requests.
+ await Promise.all(
+ targetCommand.getAllTargets(targetCommand.ALL_TYPES).map(t => t.initialized)
+ );
+
+ await commands.destroy();
+});
+
+async function testProcesses(targetCommand, target) {
+ info("Test TargetCommand against processes");
+ const { TYPES } = targetCommand;
+
+ // Note that ppmm also includes the parent process, which is considered as a frame rather than a process
+ const originalProcessesCount = Services.ppmm.childCount - 1;
+ const processes = await targetCommand.getAllTargets([TYPES.PROCESS]);
+ is(
+ processes.length,
+ originalProcessesCount,
+ "Get a target for all content processes"
+ );
+
+ const processes2 = await targetCommand.getAllTargets([TYPES.PROCESS]);
+ is(
+ processes2.length,
+ originalProcessesCount,
+ "retrieved the same number of processes"
+ );
+ function sortFronts(f1, f2) {
+ return f1.actorID < f2.actorID;
+ }
+ processes.sort(sortFronts);
+ processes2.sort(sortFronts);
+ for (let i = 0; i < processes.length; i++) {
+ is(processes[i], processes2[i], `process ${i} targets are the same`);
+ }
+
+ // Assert that watchTargets will call the create callback for all existing frames
+ const targets = new Set();
+
+ const pidRegExp = /^\d+$/;
+
+ const onAvailable = ({ targetFront }) => {
+ if (targets.has(targetFront)) {
+ ok(false, "The same target is notified multiple times via onAvailable");
+ }
+ is(
+ targetFront.targetType,
+ TYPES.PROCESS,
+ "We are only notified about process targets"
+ );
+ ok(
+ targetFront == target ? targetFront.isTopLevel : !targetFront.isTopLevel,
+ "isTopLevel property is correct"
+ );
+ ok(
+ pidRegExp.test(targetFront.processID),
+ `Target has processID of expected shape (${targetFront.processID})`
+ );
+ targets.add(targetFront);
+ };
+ const onDestroyed = ({ targetFront }) => {
+ if (!targets.has(targetFront)) {
+ ok(
+ false,
+ "A target is declared destroyed via onDestroy without being notified via onAvailable"
+ );
+ }
+ is(
+ targetFront.targetType,
+ TYPES.PROCESS,
+ "We are only notified about process targets"
+ );
+ ok(
+ !targetFront.isTopLevel,
+ "We are never notified about the top level target destruction"
+ );
+ targets.delete(targetFront);
+ };
+ await targetCommand.watchTargets({
+ types: [TYPES.PROCESS],
+ onAvailable,
+ onDestroyed,
+ });
+ is(
+ targets.size,
+ originalProcessesCount,
+ "retrieved the same number of processes via watchTargets"
+ );
+ for (let i = 0; i < processes.length; i++) {
+ ok(
+ targets.has(processes[i]),
+ `process ${i} targets are the same via watchTargets`
+ );
+ }
+
+ const previousTargets = new Set(targets);
+ // Assert that onAvailable is called for processes created *after* the call to watchTargets
+ const onProcessCreated = new Promise(resolve => {
+ const onAvailable2 = ({ targetFront }) => {
+ if (previousTargets.has(targetFront)) {
+ return;
+ }
+ targetCommand.unwatchTargets({
+ types: [TYPES.PROCESS],
+ onAvailable: onAvailable2,
+ });
+ resolve(targetFront);
+ };
+ targetCommand.watchTargets({
+ types: [TYPES.PROCESS],
+ onAvailable: onAvailable2,
+ });
+ });
+ const tab1 = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ url: TEST_URL,
+ forceNewProcess: true,
+ });
+ const createdTarget = await onProcessCreated;
+ // For some reason, creating a new tab purges processes created from previous tests
+ // so it is not reasonable to assert the size of `targets` as it may be lower than expected.
+ ok(targets.has(createdTarget), "The new tab process is in the list");
+
+ const processCountAfterTabOpen = targets.size;
+
+ // Assert that onDestroy is called for destroyed processes
+ const onProcessDestroyed = new Promise(resolve => {
+ const onAvailable3 = () => {};
+ const onDestroyed3 = ({ targetFront }) => {
+ resolve(targetFront);
+ targetCommand.unwatchTargets({
+ types: [TYPES.PROCESS],
+ onAvailable: onAvailable3,
+ onDestroyed: onDestroyed3,
+ });
+ };
+ targetCommand.watchTargets({
+ types: [TYPES.PROCESS],
+ onAvailable: onAvailable3,
+ onDestroyed: onDestroyed3,
+ });
+ });
+
+ BrowserTestUtils.removeTab(tab1);
+
+ const destroyedTarget = await onProcessDestroyed;
+ is(
+ targets.size,
+ processCountAfterTabOpen - 1,
+ "The closed tab's process has been reported as destroyed"
+ );
+ ok(
+ !targets.has(destroyedTarget),
+ "The destroyed target is no longer in the list"
+ );
+ is(
+ destroyedTarget,
+ createdTarget,
+ "The destroyed target is the one that has been reported as created"
+ );
+
+ targetCommand.unwatchTargets({
+ types: [TYPES.PROCESS],
+ onAvailable,
+ onDestroyed,
+ });
+
+ // Ensure that getAllTargets still works after the call to unwatchTargets
+ const processes3 = await targetCommand.getAllTargets([TYPES.PROCESS]);
+ is(
+ processes3.length,
+ processCountAfterTabOpen - 1,
+ "getAllTargets reports a new target"
+ );
+}
diff --git a/devtools/shared/commands/target/tests/browser_target_command_reload.js b/devtools/shared/commands/target/tests/browser_target_command_reload.js
new file mode 100644
index 0000000000..9d8cacd23d
--- /dev/null
+++ b/devtools/shared/commands/target/tests/browser_target_command_reload.js
@@ -0,0 +1,115 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the TargetCommand's reload method
+//
+// Note that we reload against main process,
+// but this is hard/impossible to test as it reloads the test script itself
+// and so stops its execution.
+
+// Load a page with a JS script that change its value everytime we load it
+// (that's to see if the reload loads from cache or not)
+const TEST_URL = URL_ROOT + "incremental-js-value-script.sjs";
+
+add_task(async function () {
+ info(" ### Test reloading a Tab");
+
+ // Create a TargetCommand for a given test tab
+ const tab = await addTab(TEST_URL);
+ const commands = await CommandsFactory.forTab(tab);
+ const targetCommand = commands.targetCommand;
+
+ // We have to start listening in order to ensure having a targetFront available
+ await targetCommand.startListening();
+
+ const firstJSValue = await getContentVariable();
+ is(firstJSValue, "1", "Got an initial value for the JS variable");
+
+ const onReloaded = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+ await targetCommand.reloadTopLevelTarget();
+ info("Wait for the tab to be reloaded");
+ await onReloaded;
+
+ const secondJSValue = await getContentVariable();
+ is(
+ secondJSValue,
+ "1",
+ "The first reload didn't bypass the cache, so the JS Script is the same and we got the same value"
+ );
+
+ const onSecondReloaded = BrowserTestUtils.browserLoaded(
+ gBrowser.selectedBrowser
+ );
+ await targetCommand.reloadTopLevelTarget(true);
+ info("Wait for the tab to be reloaded");
+ await onSecondReloaded;
+
+ // The value is 3 and not 2, because we got a HTTP request, but it returned 304 and the browser fetched his cached content
+ const thirdJSValue = await getContentVariable();
+ is(
+ thirdJSValue,
+ "3",
+ "The second reload did bypass the cache, so the JS Script is different and we got a new value"
+ );
+
+ BrowserTestUtils.removeTab(tab);
+
+ await commands.destroy();
+});
+
+add_task(async function () {
+ info(" ### Test reloading an Add-on");
+
+ const extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ background() {
+ const { browser } = this;
+ browser.test.log("background script executed");
+ },
+ });
+
+ await extension.startup();
+
+ const commands = await CommandsFactory.forAddon(extension.id);
+ const targetCommand = commands.targetCommand;
+
+ // We have to start listening in order to ensure having a targetFront available
+ await targetCommand.startListening();
+
+ const { onResource: onReloaded } =
+ await commands.resourceCommand.waitForNextResource(
+ commands.resourceCommand.TYPES.DOCUMENT_EVENT,
+ {
+ ignoreExistingResources: true,
+ predicate(resource) {
+ return resource.name == "dom-loading";
+ },
+ }
+ );
+
+ const backgroundPageURL = targetCommand.targetFront.url;
+ ok(backgroundPageURL, "Got the background page URL");
+ await targetCommand.reloadTopLevelTarget();
+
+ info("Wait for next dom-loading DOCUMENT_EVENT");
+ const event = await onReloaded;
+
+ // If we get about:blank here, it most likely means we receive notification
+ // for the previous background page being unload and navigating to about:blank
+ is(
+ event.url,
+ backgroundPageURL,
+ "We received the DOCUMENT_EVENT's for the expected document: the new background page."
+ );
+
+ await commands.destroy();
+
+ await extension.unload();
+});
+function getContentVariable() {
+ return SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () {
+ return content.wrappedJSObject.jsValue;
+ });
+}
diff --git a/devtools/shared/commands/target/tests/browser_target_command_scope_flag.js b/devtools/shared/commands/target/tests/browser_target_command_scope_flag.js
new file mode 100644
index 0000000000..f07a6aaac3
--- /dev/null
+++ b/devtools/shared/commands/target/tests/browser_target_command_scope_flag.js
@@ -0,0 +1,190 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the TargetCommand API with changes made to devtools.browsertoolbox.scope
+
+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()) {
+ 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);
+
+ // First test 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 destroyedTargetIsModeSwitchingMap = new Map();
+ const onAvailable = async ({ targetFront }) => {
+ targets.add(targetFront);
+ };
+ const onDestroyed = ({ targetFront, isModeSwitching }) => {
+ destroyedTargetIsModeSwitchingMap.set(targetFront, isModeSwitching);
+ targets.delete(targetFront);
+ };
+ await targetCommand.watchTargets({
+ types: [TYPES.PROCESS, TYPES.FRAME],
+ onAvailable,
+ onDestroyed,
+ });
+ ok(targets.size > 1, "We get many targets");
+
+ info("Open a tab in a new content process");
+ await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ url: TEST_URL,
+ forceNewProcess: true,
+ });
+
+ const newTabProcessID =
+ gBrowser.selectedTab.linkedBrowser.browsingContext.currentWindowGlobal
+ .osPid;
+ const newTabInnerWindowId =
+ gBrowser.selectedTab.linkedBrowser.browsingContext.currentWindowGlobal
+ .innerWindowId;
+
+ info("Wait for the tab content process target");
+ const processTarget = await waitFor(() =>
+ [...targets].find(
+ target =>
+ target.targetType == TYPES.PROCESS &&
+ target.processID == newTabProcessID
+ )
+ );
+
+ info("Wait for the tab window global target");
+ const windowGlobalTarget = await waitFor(() =>
+ [...targets].find(
+ target =>
+ target.targetType == TYPES.FRAME &&
+ target.innerWindowId == newTabInnerWindowId
+ )
+ );
+
+ let multiprocessTargetCount = targets.size;
+
+ info("Disable multiprocess debugging");
+ await pushPref("devtools.browsertoolbox.scope", "parent-process");
+
+ info("Wait for all targets but top level and workers to be destroyed");
+ await waitFor(() =>
+ [...targets].every(
+ target =>
+ target == targetCommand.targetFront || target.targetType == TYPES.WORKER
+ )
+ );
+
+ ok(processTarget.isDestroyed(), "The process target is destroyed");
+ ok(
+ destroyedTargetIsModeSwitchingMap.get(processTarget),
+ "isModeSwitching was passed to onTargetDestroyed and is true for the process target"
+ );
+ ok(windowGlobalTarget.isDestroyed(), "The window global target is destroyed");
+ ok(
+ destroyedTargetIsModeSwitchingMap.get(windowGlobalTarget),
+ "isModeSwitching was passed to onTargetDestroyed and is true for the window global target"
+ );
+
+ info("Open a second tab in a new content process");
+ const parentProcessTargetCount = targets.size;
+ await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ url: TEST_URL,
+ forceNewProcess: true,
+ });
+
+ await wait(1000);
+ is(
+ parentProcessTargetCount,
+ targets.size,
+ "The new tab process should be ignored and no target be created"
+ );
+
+ info("Re-enable multiprocess debugging");
+ await pushPref("devtools.browsertoolbox.scope", "everything");
+
+ // The second tab relates to one content process target and one window global target
+ multiprocessTargetCount += 2;
+
+ await waitFor(
+ () => targets.size == multiprocessTargetCount,
+ "Wait for all targets we used to have before disable multiprocess debugging"
+ );
+
+ info("Wait for the tab content process target to be available again");
+ ok(
+ [...targets].some(
+ target =>
+ target.targetType == TYPES.PROCESS &&
+ target.processID == newTabProcessID
+ ),
+ "We have the tab content process target"
+ );
+
+ info("Wait for the tab window global target to be available again");
+ ok(
+ [...targets].some(
+ target =>
+ target.targetType == TYPES.FRAME &&
+ target.innerWindowId == newTabInnerWindowId
+ ),
+ "We have the tab window global target"
+ );
+
+ info("Open a third tab in a new content process");
+ await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ url: TEST_URL,
+ forceNewProcess: true,
+ });
+
+ const thirdTabProcessID =
+ gBrowser.selectedTab.linkedBrowser.browsingContext.currentWindowGlobal
+ .osPid;
+ const thirdTabInnerWindowId =
+ gBrowser.selectedTab.linkedBrowser.browsingContext.currentWindowGlobal
+ .innerWindowId;
+
+ info("Wait for the third tab content process target");
+ await waitFor(() =>
+ [...targets].find(
+ target =>
+ target.targetType == TYPES.PROCESS &&
+ target.processID == thirdTabProcessID
+ )
+ );
+
+ info("Wait for the third tab window global target");
+ await waitFor(() =>
+ [...targets].find(
+ target =>
+ target.targetType == TYPES.FRAME &&
+ target.innerWindowId == thirdTabInnerWindowId
+ )
+ );
+
+ targetCommand.destroy();
+
+ // Wait for all the targets to be fully attached so we don't have pending requests.
+ await Promise.all(
+ targetCommand.getAllTargets(targetCommand.ALL_TYPES).map(t => t.initialized)
+ );
+
+ await commands.destroy();
+});
diff --git a/devtools/shared/commands/target/tests/browser_target_command_service_workers.js b/devtools/shared/commands/target/tests/browser_target_command_service_workers.js
new file mode 100644
index 0000000000..d71401fd8c
--- /dev/null
+++ b/devtools/shared/commands/target/tests/browser_target_command_service_workers.js
@@ -0,0 +1,77 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the TargetCommand API for service workers in content tabs.
+
+const FISSION_TEST_URL = URL_ROOT_SSL + "fission_document.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);
+
+ info("Setup the test page with workers of all types");
+
+ const tab = await addTab(FISSION_TEST_URL);
+
+ info("Create a target list for a tab target");
+ const commands = await CommandsFactory.forTab(tab);
+ const targetCommand = commands.targetCommand;
+ const { TYPES } = targetCommand;
+
+ // Enable Service Worker listening.
+ targetCommand.listenForServiceWorkers = true;
+ await targetCommand.startListening();
+
+ const serviceWorkerTargets = targetCommand.getAllTargets([
+ TYPES.SERVICE_WORKER,
+ ]);
+ is(
+ serviceWorkerTargets.length,
+ 1,
+ "TargetCommmand has 1 service worker target"
+ );
+
+ info("Check that the onAvailable is done when watchTargets resolves");
+ const targets = [];
+ const onAvailable = async ({ targetFront }) => {
+ // Wait for one second here to check that watch targets waits for
+ // the onAvailable callbacks correctly.
+ await wait(1000);
+ targets.push(targetFront);
+ };
+ const onDestroyed = ({ targetFront }) =>
+ targets.splice(targets.indexOf(targetFront), 1);
+
+ await targetCommand.watchTargets({
+ types: [TYPES.SERVICE_WORKER],
+ onAvailable,
+ onDestroyed,
+ });
+
+ // We expect onAvailable to have been called one time, for the only service
+ // worker target available in the test page.
+ is(targets.length, 1, "onAvailable has resolved");
+ is(
+ targets[0],
+ serviceWorkerTargets[0],
+ "onAvailable was called with the expected service worker target"
+ );
+
+ info("Unregister the worker and wait until onDestroyed is called.");
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async () => {
+ // registrationPromise is set by the test page.
+ const registration = await content.wrappedJSObject.registrationPromise;
+ registration.unregister();
+ });
+ await waitUntil(() => targets.length === 0);
+
+ // Stop listening to avoid worker related requests
+ targetCommand.destroy();
+
+ await commands.waitForRequestsToSettle();
+
+ await commands.destroy();
+});
diff --git a/devtools/shared/commands/target/tests/browser_target_command_service_workers_navigation.js b/devtools/shared/commands/target/tests/browser_target_command_service_workers_navigation.js
new file mode 100644
index 0000000000..caf95f11c2
--- /dev/null
+++ b/devtools/shared/commands/target/tests/browser_target_command_service_workers_navigation.js
@@ -0,0 +1,389 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the TargetCommand API for service workers when navigating in content tabs.
+// When the top level target navigates, we manually call onTargetAvailable for
+// service workers which now match the page domain. We assert that the callbacks
+// will be called the expected number of times here.
+
+const COM_PAGE_URL = URL_ROOT_SSL + "test_sw_page.html";
+const COM_WORKER_URL = URL_ROOT_SSL + "test_sw_page_worker.js";
+const ORG_PAGE_URL = URL_ROOT_ORG_SSL + "test_sw_page.html";
+const ORG_WORKER_URL = URL_ROOT_ORG_SSL + "test_sw_page_worker.js";
+
+/**
+ * This test will navigate between two pages, both controlled by different
+ * service workers.
+ *
+ * The steps will be:
+ * - navigate to .com page
+ * - create target list
+ * - navigate to .org page
+ * - reload .org page
+ * - unregister .org worker
+ * - navigate back to .com page
+ * - unregister .com worker
+ *
+ * First we test this with destroyServiceWorkersOnNavigation = false.
+ * In this case we expect the following calls:
+ * - navigate to .com page
+ * - create target list
+ * - onAvailable should be called for the .com worker
+ * - navigate to .org page
+ * - onAvailable should be called for the .org worker
+ * - reload .org page
+ * - nothing should happen
+ * - unregister .org worker
+ * - onDestroyed should be called for the .org worker
+ * - navigate back to .com page
+ * - nothing should happen
+ * - unregister .com worker
+ * - onDestroyed should be called for the .com worker
+ */
+add_task(async function test_NavigationBetweenTwoDomains_NoDestroy() {
+ await setupServiceWorkerNavigationTest();
+
+ const tab = await addTab(COM_PAGE_URL);
+
+ const { hooks, commands, targetCommand } = await watchServiceWorkerTargets({
+ tab,
+ destroyServiceWorkersOnNavigation: false,
+ });
+
+ // We expect onAvailable to have been called one time, for the only service
+ // worker target available in the test page.
+ await checkHooks(hooks, {
+ available: 1,
+ destroyed: 0,
+ targets: [COM_WORKER_URL],
+ });
+
+ info("Go to .org page, wait for onAvailable to be called");
+ BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, ORG_PAGE_URL);
+ await checkHooks(hooks, {
+ available: 2,
+ destroyed: 0,
+ targets: [COM_WORKER_URL, ORG_WORKER_URL],
+ });
+
+ info("Reload .org page, onAvailable and onDestroyed should not be called");
+ await BrowserTestUtils.reloadTab(gBrowser.selectedTab);
+ await checkHooks(hooks, {
+ available: 2,
+ destroyed: 0,
+ targets: [COM_WORKER_URL, ORG_WORKER_URL],
+ });
+
+ info("Unregister .org service worker and wait until onDestroyed is called.");
+ await unregisterServiceWorker(tab, ORG_PAGE_URL);
+ await checkHooks(hooks, {
+ available: 2,
+ destroyed: 1,
+ targets: [COM_WORKER_URL],
+ });
+
+ info("Go back to .com page");
+ const onBrowserLoaded = BrowserTestUtils.browserLoaded(
+ gBrowser.selectedBrowser
+ );
+ BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, COM_PAGE_URL);
+ await onBrowserLoaded;
+ await checkHooks(hooks, {
+ available: 2,
+ destroyed: 1,
+ targets: [COM_WORKER_URL],
+ });
+
+ info("Unregister .com service worker and wait until onDestroyed is called.");
+ await unregisterServiceWorker(tab, COM_PAGE_URL);
+ await checkHooks(hooks, { available: 2, destroyed: 2, targets: [] });
+
+ // Stop listening to avoid worker related requests
+ targetCommand.destroy();
+
+ await commands.waitForRequestsToSettle();
+ await commands.destroy();
+ await removeTab(tab);
+});
+
+/**
+ * Same scenario as test_NavigationBetweenTwoDomains_NoDestroy, but this time
+ * with destroyServiceWorkersOnNavigation set to true.
+ *
+ * In this case we expect the following calls:
+ * - navigate to .com page
+ * - create target list
+ * - onAvailable should be called for the .com worker
+ * - navigate to .org page
+ * - onDestroyed should be called for the .com worker
+ * - onAvailable should be called for the .org worker
+ * - reload .org page
+ * - onDestroyed & onAvailable should be called for the .org worker
+ * - unregister .org worker
+ * - onDestroyed should be called for the .org worker
+ * - navigate back to .com page
+ * - onAvailable should be called for the .com worker
+ * - unregister .com worker
+ * - onDestroyed should be called for the .com worker
+ */
+add_task(async function test_NavigationBetweenTwoDomains_WithDestroy() {
+ await setupServiceWorkerNavigationTest();
+
+ const tab = await addTab(COM_PAGE_URL);
+
+ const { hooks, commands, targetCommand } = await watchServiceWorkerTargets({
+ tab,
+ destroyServiceWorkersOnNavigation: true,
+ });
+
+ // We expect onAvailable to have been called one time, for the only service
+ // worker target available in the test page.
+ await checkHooks(hooks, {
+ available: 1,
+ destroyed: 0,
+ targets: [COM_WORKER_URL],
+ });
+
+ info("Go to .org page, wait for onAvailable to be called");
+ BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, ORG_PAGE_URL);
+ await checkHooks(hooks, {
+ available: 2,
+ destroyed: 1,
+ targets: [ORG_WORKER_URL],
+ });
+
+ info("Reload .org page, onAvailable and onDestroyed should be called");
+ gBrowser.reloadTab(gBrowser.selectedTab);
+ await checkHooks(hooks, {
+ available: 3,
+ destroyed: 2,
+ targets: [ORG_WORKER_URL],
+ });
+
+ info("Unregister .org service worker and wait until onDestroyed is called.");
+ await unregisterServiceWorker(tab, ORG_PAGE_URL);
+ await checkHooks(hooks, { available: 3, destroyed: 3, targets: [] });
+
+ info("Go back to page 1, wait for onDestroyed and onAvailable to be called");
+ BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, COM_PAGE_URL);
+ await checkHooks(hooks, {
+ available: 4,
+ destroyed: 3,
+ targets: [COM_WORKER_URL],
+ });
+
+ info("Unregister .com service worker and wait until onDestroyed is called.");
+ await unregisterServiceWorker(tab, COM_PAGE_URL);
+ await checkHooks(hooks, { available: 4, destroyed: 4, targets: [] });
+
+ // Stop listening to avoid worker related requests
+ targetCommand.destroy();
+
+ await commands.waitForRequestsToSettle();
+ await commands.destroy();
+ await removeTab(tab);
+});
+
+/**
+ * In this test we load a service worker in a page prior to starting the
+ * TargetCommand. We start the target list on another page, and then we go back to
+ * the first page. We want to check that we are correctly notified about the
+ * worker that was spawned before TargetCommand.
+ *
+ * Steps:
+ * - navigate to .com page
+ * - navigate to .org page
+ * - create target list
+ * - unregister .org worker
+ * - navigate back to .com page
+ * - unregister .com worker
+ *
+ * The expected calls are the same whether destroyServiceWorkersOnNavigation is
+ * true or false.
+ *
+ * Expected calls:
+ * - navigate to .com page
+ * - navigate to .org page
+ * - create target list
+ * - onAvailable is called for the .org worker
+ * - unregister .org worker
+ * - onDestroyed is called for the .org worker
+ * - navigate back to .com page
+ * - onAvailable is called for the .com worker
+ * - unregister .com worker
+ * - onDestroyed is called for the .com worker
+ */
+add_task(async function test_NavigationToPageWithExistingWorker_NoDestroy() {
+ await testNavigationToPageWithExistingWorker({
+ destroyServiceWorkersOnNavigation: false,
+ });
+});
+
+add_task(async function test_NavigationToPageWithExistingWorker_WithDestroy() {
+ await testNavigationToPageWithExistingWorker({
+ destroyServiceWorkersOnNavigation: true,
+ });
+});
+
+async function testNavigationToPageWithExistingWorker({
+ destroyServiceWorkersOnNavigation,
+}) {
+ await setupServiceWorkerNavigationTest();
+
+ const tab = await addTab(COM_PAGE_URL);
+
+ info("Wait until the service worker registration is registered");
+ await waitForRegistrationReady(tab, COM_PAGE_URL);
+
+ info("Navigate to another page");
+ let onBrowserLoaded = BrowserTestUtils.browserLoaded(
+ gBrowser.selectedBrowser
+ );
+ BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, ORG_PAGE_URL);
+
+ // Avoid TV failures, where target list still starts thinking that the
+ // current domain is .com .
+ info("Wait until we have fully navigated to the .org page");
+ // wait for the browser to be loaded otherwise the task spawned in waitForRegistrationReady
+ // might be destroyed (when it still belongs to the previous content process)
+ await onBrowserLoaded;
+ await waitForRegistrationReady(tab, ORG_PAGE_URL);
+
+ const { hooks, commands, targetCommand } = await watchServiceWorkerTargets({
+ tab,
+ destroyServiceWorkersOnNavigation,
+ });
+
+ // We expect onAvailable to have been called one time, for the only service
+ // worker target available in the test page.
+ await checkHooks(hooks, {
+ available: 1,
+ destroyed: 0,
+ targets: [ORG_WORKER_URL],
+ });
+
+ info("Unregister .org service worker and wait until onDestroyed is called.");
+ await unregisterServiceWorker(tab, ORG_PAGE_URL);
+ await checkHooks(hooks, { available: 1, destroyed: 1, targets: [] });
+
+ info("Go back .com page, wait for onAvailable to be called");
+ onBrowserLoaded = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+ BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, COM_PAGE_URL);
+ await onBrowserLoaded;
+
+ await checkHooks(hooks, {
+ available: 2,
+ destroyed: 1,
+ targets: [COM_WORKER_URL],
+ });
+
+ info("Unregister .com service worker and wait until onDestroyed is called.");
+ await unregisterServiceWorker(tab, COM_PAGE_URL);
+ await checkHooks(hooks, { available: 2, destroyed: 2, targets: [] });
+
+ // Stop listening to avoid worker related requests
+ targetCommand.destroy();
+
+ await commands.waitForRequestsToSettle();
+ await commands.destroy();
+ await removeTab(tab);
+}
+
+async function setupServiceWorkerNavigationTest() {
+ // 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);
+}
+
+async function watchServiceWorkerTargets({
+ destroyServiceWorkersOnNavigation,
+ tab,
+}) {
+ info("Create a target list for a tab target");
+ const commands = await CommandsFactory.forTab(tab);
+ const targetCommand = commands.targetCommand;
+
+ // Enable Service Worker listening.
+ targetCommand.listenForServiceWorkers = true;
+ info(
+ "Set targetCommand.destroyServiceWorkersOnNavigation to " +
+ destroyServiceWorkersOnNavigation
+ );
+ targetCommand.destroyServiceWorkersOnNavigation =
+ destroyServiceWorkersOnNavigation;
+ await targetCommand.startListening();
+
+ // Setup onAvailable & onDestroyed callbacks so that we can check how many
+ // times they are called and with which targetFront.
+ const hooks = {
+ availableCount: 0,
+ destroyedCount: 0,
+ targets: [],
+ };
+
+ const onAvailable = async ({ targetFront }) => {
+ hooks.availableCount++;
+ hooks.targets.push(targetFront);
+ };
+
+ const onDestroyed = ({ targetFront }) => {
+ hooks.destroyedCount++;
+ hooks.targets.splice(hooks.targets.indexOf(targetFront), 1);
+ };
+
+ await targetCommand.watchTargets({
+ types: [targetCommand.TYPES.SERVICE_WORKER],
+ onAvailable,
+ onDestroyed,
+ });
+
+ return { hooks, commands, targetCommand };
+}
+
+async function unregisterServiceWorker(tab, expectedPageUrl) {
+ await waitForRegistrationReady(tab, expectedPageUrl);
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async () => {
+ // registrationPromise is set by the test page.
+ const registration = await content.wrappedJSObject.registrationPromise;
+ registration.unregister();
+ });
+}
+
+/**
+ * Wait until the expected URL is loaded and win.registration has resolved.
+ */
+async function waitForRegistrationReady(tab, expectedPageUrl) {
+ await asyncWaitUntil(() =>
+ SpecialPowers.spawn(tab.linkedBrowser, [expectedPageUrl], function (_url) {
+ try {
+ const win = content.wrappedJSObject;
+ const isExpectedUrl = win.location.href === _url;
+ const hasRegistration = !!win.registrationPromise;
+ return isExpectedUrl && hasRegistration;
+ } catch (e) {
+ return false;
+ }
+ })
+ );
+}
+
+/**
+ * Assert helper for the `hooks` object, updated by the onAvailable and
+ * onDestroyed callbacks. Assert that the callbacks have been called the
+ * expected number of times, with the expected targets.
+ */
+async function checkHooks(hooks, { available, destroyed, targets }) {
+ info(`Wait for availableCount=${available} and destroyedCount=${destroyed}`);
+ await waitUntil(
+ () => hooks.availableCount == available && hooks.destroyedCount == destroyed
+ );
+ is(hooks.availableCount, available, "onAvailable was called as expected");
+ is(hooks.destroyedCount, destroyed, "onDestroyed was called as expected");
+
+ is(hooks.targets.length, targets.length, "Expected number of targets");
+ targets.forEach((url, i) => {
+ is(hooks.targets[i].url, url, `SW target ${i} has the expected url`);
+ });
+}
diff --git a/devtools/shared/commands/target/tests/browser_target_command_switchToTarget.js b/devtools/shared/commands/target/tests/browser_target_command_switchToTarget.js
new file mode 100644
index 0000000000..04646117a9
--- /dev/null
+++ b/devtools/shared/commands/target/tests/browser_target_command_switchToTarget.js
@@ -0,0 +1,138 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the TargetCommand API switchToTarget function
+
+add_task(async function testSwitchToTarget() {
+ info("Test TargetCommand.switchToTarget method");
+
+ // Create a first target to switch from, a new tab with an iframe
+ const firstTab = await addTab(
+ `data:text/html,<iframe src="data:text/html,foo"></iframe>`
+ );
+ const commands = await CommandsFactory.forTab(firstTab);
+ const targetCommand = commands.targetCommand;
+ const { TYPES } = targetCommand;
+ await targetCommand.startListening();
+
+ // Create a second target to switch to, a new tab with an iframe
+ const secondTab = await addTab(
+ `data:text/html,<iframe src="data:text/html,bar"></iframe>`
+ );
+ // We have to spawn a new distinct `commands` object for this new tab,
+ // but we will otherwise consider the first one as the main one.
+ // From this second one, we will only retrieve a new target.
+ const secondCommands = await CommandsFactory.forTab(secondTab, {
+ client: commands.client,
+ });
+ await secondCommands.targetCommand.startListening();
+ const secondTarget = secondCommands.targetCommand.targetFront;
+
+ const frameTargets = [];
+ const firstTarget = targetCommand.targetFront;
+ let currentTarget = targetCommand.targetFront;
+ const onFrameAvailable = ({ targetFront, isTargetSwitching }) => {
+ is(
+ targetFront.targetType,
+ TYPES.FRAME,
+ "We are only notified about frame targets"
+ );
+ ok(
+ targetFront == currentTarget
+ ? targetFront.isTopLevel
+ : !targetFront.isTopLevel,
+ "isTopLevel property is correct"
+ );
+ if (targetFront.isTopLevel) {
+ // When calling watchTargets, this will be false, but it will be true when calling switchToTarget
+ is(
+ isTargetSwitching,
+ currentTarget == secondTarget,
+ "target switching boolean is correct"
+ );
+ } else {
+ ok(!isTargetSwitching, "for now, only top level target can be switched");
+ }
+ frameTargets.push(targetFront);
+ };
+ const destroyedTargets = [];
+ const onFrameDestroyed = ({ targetFront, isTargetSwitching }) => {
+ is(
+ targetFront.targetType,
+ TYPES.FRAME,
+ "target-destroyed: We are only notified about frame targets"
+ );
+ ok(
+ targetFront == firstTarget
+ ? targetFront.isTopLevel
+ : !targetFront.isTopLevel,
+ "target-destroyed: isTopLevel property is correct"
+ );
+ if (targetFront.isTopLevel) {
+ is(
+ isTargetSwitching,
+ true,
+ "target-destroyed: target switching boolean is correct"
+ );
+ } else {
+ ok(
+ !isTargetSwitching,
+ "target-destroyed: for now, only top level target can be switched"
+ );
+ }
+ destroyedTargets.push(targetFront);
+ };
+ await targetCommand.watchTargets({
+ types: [TYPES.FRAME],
+ onAvailable: onFrameAvailable,
+ onDestroyed: onFrameDestroyed,
+ });
+
+ // Save the original list of targets
+ const createdTargets = [...frameTargets];
+ // Clear the recorded target list of all existing targets
+ frameTargets.length = 0;
+
+ currentTarget = secondTarget;
+ await targetCommand.switchToTarget(secondTarget);
+
+ is(
+ targetCommand.targetFront,
+ currentTarget,
+ "After the switch, the top level target has been updated"
+ );
+ // Because JS Window Actor API isn't used yet, FrameDescriptor.getTarget returns null
+ // And there is no target being created for the iframe, yet.
+ // As soon as bug 1565200 is resolved, this should return two frames, including the iframe.
+ is(
+ frameTargets.length,
+ 1,
+ "We get the report of the top level iframe when switching to the new target"
+ );
+ is(frameTargets[0], currentTarget);
+ //is(frameTargets[1].url, "data:text/html,foo");
+
+ // Ensure that all the targets reported before the call to switchToTarget
+ // are reported as destroyed while calling switchToTarget.
+ is(
+ destroyedTargets.length,
+ createdTargets.length,
+ "All targets original reported are destroyed"
+ );
+ for (const newTarget of createdTargets) {
+ ok(
+ destroyedTargets.includes(newTarget),
+ "Each originally target is reported as destroyed"
+ );
+ }
+
+ targetCommand.destroy();
+
+ await commands.destroy();
+ await secondCommands.destroy();
+
+ BrowserTestUtils.removeTab(firstTab);
+ BrowserTestUtils.removeTab(secondTab);
+});
diff --git a/devtools/shared/commands/target/tests/browser_target_command_tab_workers.js b/devtools/shared/commands/target/tests/browser_target_command_tab_workers.js
new file mode 100644
index 0000000000..24710879ae
--- /dev/null
+++ b/devtools/shared/commands/target/tests/browser_target_command_tab_workers.js
@@ -0,0 +1,322 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the TargetCommand API around workers
+
+const FISSION_TEST_URL = URL_ROOT_SSL + "fission_document.html";
+const IFRAME_FILE = "fission_iframe.html";
+const REMOTE_IFRAME_URL = URL_ROOT_ORG_SSL + IFRAME_FILE;
+const IFRAME_URL = URL_ROOT_SSL + IFRAME_FILE;
+const WORKER_FILE = "test_worker.js";
+const WORKER_URL = URL_ROOT_SSL + WORKER_FILE;
+const REMOTE_IFRAME_WORKER_URL = URL_ROOT_ORG_SSL + WORKER_FILE;
+
+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);
+
+ // The WorkerDebuggerManager#getWorkerDebuggerEnumerator method we're using to retrieve
+ // workers loops through _all_ the workers in the process, which means it goes over workers
+ // from other tabs as well. Here we add a few tabs that are not going to be used in the
+ // test, just to check that their workers won't be retrieved by getAllTargets/watchTargets.
+ await addTab(`${FISSION_TEST_URL}?id=first-untargetted-tab&noServiceWorker`);
+ await addTab(`${FISSION_TEST_URL}?id=second-untargetted-tab&noServiceWorker`);
+
+ info("Test TargetCommand against workers via a tab target");
+ const tab = await addTab(`${FISSION_TEST_URL}?&noServiceWorker`);
+
+ // Create a TargetCommand for the tab
+ const commands = await CommandsFactory.forTab(tab);
+ const targetCommand = commands.targetCommand;
+
+ // Workaround to allow listening for workers in the content toolbox
+ // without the fission preferences
+ targetCommand.listenForWorkers = true;
+
+ await commands.targetCommand.startListening();
+
+ const { TYPES } = targetCommand;
+
+ info("Check that getAllTargets only returns dedicated workers");
+ const workers = await targetCommand.getAllTargets([
+ TYPES.WORKER,
+ TYPES.SHARED_WORKER,
+ ]);
+
+ // XXX: This should be modified in Bug 1607778, where we plan to add support for shared workers.
+ is(workers.length, 2, "Retrieved two worker…");
+ const mainPageWorker = workers.find(
+ worker => worker.url == `${WORKER_URL}#simple-worker`
+ );
+ const iframeWorker = workers.find(worker => {
+ return worker.url == `${REMOTE_IFRAME_WORKER_URL}#simple-worker-in-iframe`;
+ });
+ ok(mainPageWorker, "…the dedicated worker on the main page");
+ ok(iframeWorker, "…and the dedicated worker on the iframe");
+
+ info(
+ "Assert that watchTargets will call the create callback for existing dedicated workers"
+ );
+ const targets = [];
+ const destroyedTargets = [];
+ const onAvailable = async ({ targetFront }) => {
+ info(`onAvailable called for ${targetFront.url}`);
+ is(
+ targetFront.targetType,
+ TYPES.WORKER,
+ "We are only notified about worker targets"
+ );
+ ok(!targetFront.isTopLevel, "The workers are never top level");
+ targets.push(targetFront);
+ info(`Handled ${targets.length} targets\n`);
+ };
+ const onDestroyed = async ({ targetFront }) => {
+ is(
+ targetFront.targetType,
+ TYPES.WORKER,
+ "We are only notified about worker targets"
+ );
+ ok(!targetFront.isTopLevel, "The workers are never top level");
+ destroyedTargets.push(targetFront);
+ };
+
+ await targetCommand.watchTargets({
+ types: [TYPES.WORKER, TYPES.SHARED_WORKER],
+ onAvailable,
+ onDestroyed,
+ });
+
+ // XXX: This should be modified in Bug 1607778, where we plan to add support for shared workers.
+ info("Check that watched targets return the same fronts as getAllTargets");
+ is(targets.length, 2, "watcheTargets retrieved 2 worker…");
+ const mainPageWorkerTarget = targets.find(t => t === mainPageWorker);
+ const iframeWorkerTarget = targets.find(t => t === iframeWorker);
+
+ ok(
+ mainPageWorkerTarget,
+ "…the dedicated worker in main page, which is the same front we received from getAllTargets"
+ );
+ ok(
+ iframeWorkerTarget,
+ "…the dedicated worker in iframe, which is the same front we received from getAllTargets"
+ );
+
+ info("Spawn workers in main page and iframe");
+ await SpecialPowers.spawn(tab.linkedBrowser, [WORKER_FILE], workerUrl => {
+ // Put the worker on the global so we can access it later
+ content.spawnedWorker = new content.Worker(`${workerUrl}#spawned-worker`);
+ const iframe = content.document.querySelector("iframe");
+ SpecialPowers.spawn(iframe, [workerUrl], innerWorkerUrl => {
+ // Put the worker on the global so we can access it later
+ content.spawnedWorker = new content.Worker(
+ `${innerWorkerUrl}#spawned-worker-in-iframe`
+ );
+ });
+ });
+
+ await waitFor(
+ () => targets.length === 4,
+ "Wait for the target list to notify us about the spawned worker"
+ );
+ const mainPageSpawnedWorkerTarget = targets.find(
+ innerTarget => innerTarget.url == `${WORKER_URL}#spawned-worker`
+ );
+ ok(mainPageSpawnedWorkerTarget, "Retrieved spawned worker");
+ const iframeSpawnedWorkerTarget = targets.find(
+ innerTarget =>
+ innerTarget.url == `${REMOTE_IFRAME_WORKER_URL}#spawned-worker-in-iframe`
+ );
+ ok(iframeSpawnedWorkerTarget, "Retrieved spawned worker in iframe");
+
+ await wait(100);
+
+ info(
+ "Check that the target list calls onDestroy when a worker is terminated"
+ );
+ await SpecialPowers.spawn(tab.linkedBrowser, [], () => {
+ content.spawnedWorker.terminate();
+ content.spawnedWorker = null;
+
+ SpecialPowers.spawn(content.document.querySelector("iframe"), [], () => {
+ content.spawnedWorker.terminate();
+ content.spawnedWorker = null;
+ });
+ });
+ await waitFor(
+ () =>
+ destroyedTargets.includes(mainPageSpawnedWorkerTarget) &&
+ destroyedTargets.includes(iframeSpawnedWorkerTarget),
+ "Wait for the target list to notify us about the terminated workers"
+ );
+
+ ok(
+ true,
+ "The target list handled the terminated workers (from the main page and the iframe)"
+ );
+
+ info(
+ "Check that reloading the page will notify about the terminated worker and the new existing one"
+ );
+ const targetsCountBeforeReload = targets.length;
+ await reloadBrowser();
+
+ await waitFor(() => {
+ return (
+ destroyedTargets.includes(mainPageWorkerTarget) &&
+ destroyedTargets.includes(iframeWorkerTarget)
+ );
+ }, `Wait for the target list to notify us about the terminated workers when reloading`);
+ ok(
+ true,
+ "The target list notified us about all the expected workers being destroyed when reloading"
+ );
+
+ await waitFor(
+ () => targets.length === targetsCountBeforeReload + 2,
+ "Wait for the target list to notify us about the new workers after reloading"
+ );
+
+ const mainPageWorkerTargetAfterReload = targets.find(
+ t => t !== mainPageWorkerTarget && t.url == `${WORKER_URL}#simple-worker`
+ );
+ const iframeWorkerTargetAfterReload = targets.find(
+ t =>
+ t !== iframeWorkerTarget &&
+ t.url == `${REMOTE_IFRAME_WORKER_URL}#simple-worker-in-iframe`
+ );
+
+ ok(
+ mainPageWorkerTargetAfterReload,
+ "The target list handled the worker created once the page navigated"
+ );
+ ok(
+ iframeWorkerTargetAfterReload,
+ "The target list handled the worker created in the iframe once the page navigated"
+ );
+
+ const targetCount = targets.length;
+
+ info(
+ "Check that when removing an iframe we're notified about its workers being terminated"
+ );
+ await SpecialPowers.spawn(tab.linkedBrowser, [], () => {
+ content.document.querySelector("iframe").remove();
+ });
+ await waitFor(() => {
+ return destroyedTargets.includes(iframeWorkerTargetAfterReload);
+ }, `Wait for the target list to notify us about the terminated workers when removing an iframe`);
+
+ info("Check that target list handles adding iframes with workers");
+ const iframeUrl = `${IFRAME_URL}?noServiceWorker=true&hashSuffix=in-created-iframe`;
+ const remoteIframeUrl = `${REMOTE_IFRAME_URL}?noServiceWorker=true&hashSuffix=in-created-remote-iframe`;
+
+ await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [iframeUrl, remoteIframeUrl],
+ (url, remoteUrl) => {
+ const firstIframe = content.document.createElement("iframe");
+ content.document.body.append(firstIframe);
+ firstIframe.src = url + "-1";
+
+ const secondIframe = content.document.createElement("iframe");
+ content.document.body.append(secondIframe);
+ secondIframe.src = url + "-2";
+
+ const firstRemoteIframe = content.document.createElement("iframe");
+ content.document.body.append(firstRemoteIframe);
+ firstRemoteIframe.src = remoteUrl + "-1";
+
+ const secondRemoteIframe = content.document.createElement("iframe");
+ content.document.body.append(secondRemoteIframe);
+ secondRemoteIframe.src = remoteUrl + "-2";
+ }
+ );
+
+ // It's important to check the length of `targets` here to ensure we don't get unwanted
+ // worker targets.
+ await waitFor(
+ () => targets.length === targetCount + 4,
+ "Wait for the target list to notify us about the workers in the new iframes"
+ );
+ const firstSpawnedIframeWorkerTarget = targets.find(
+ worker => worker.url == `${WORKER_URL}#simple-worker-in-created-iframe-1`
+ );
+ const secondSpawnedIframeWorkerTarget = targets.find(
+ worker => worker.url == `${WORKER_URL}#simple-worker-in-created-iframe-2`
+ );
+ const firstSpawnedRemoteIframeWorkerTarget = targets.find(
+ worker =>
+ worker.url ==
+ `${REMOTE_IFRAME_WORKER_URL}#simple-worker-in-created-remote-iframe-1`
+ );
+ const secondSpawnedRemoteIframeWorkerTarget = targets.find(
+ worker =>
+ worker.url ==
+ `${REMOTE_IFRAME_WORKER_URL}#simple-worker-in-created-remote-iframe-2`
+ );
+
+ ok(
+ firstSpawnedIframeWorkerTarget,
+ "The target list handled the worker in the first new same-origin iframe"
+ );
+ ok(
+ secondSpawnedIframeWorkerTarget,
+ "The target list handled the worker in the second new same-origin iframe"
+ );
+ ok(
+ firstSpawnedRemoteIframeWorkerTarget,
+ "The target list handled the worker in the first new remote iframe"
+ );
+ ok(
+ secondSpawnedRemoteIframeWorkerTarget,
+ "The target list handled the worker in the second new remote iframe"
+ );
+
+ info("Check that navigating away does destroy all targets");
+ BrowserTestUtils.loadURIString(
+ tab.linkedBrowser,
+ "data:text/html,<meta charset=utf8>Away"
+ );
+
+ await waitFor(
+ () => destroyedTargets.length === targets.length,
+ "Wait for all the targets to be reported as destroyed"
+ );
+
+ ok(
+ destroyedTargets.includes(mainPageWorkerTargetAfterReload),
+ "main page worker target was destroyed"
+ );
+ ok(
+ destroyedTargets.includes(firstSpawnedIframeWorkerTarget),
+ "first spawned same-origin iframe worker target was destroyed"
+ );
+ ok(
+ destroyedTargets.includes(secondSpawnedIframeWorkerTarget),
+ "second spawned same-origin iframe worker target was destroyed"
+ );
+ ok(
+ destroyedTargets.includes(firstSpawnedRemoteIframeWorkerTarget),
+ "first spawned remote iframe worker target was destroyed"
+ );
+ ok(
+ destroyedTargets.includes(secondSpawnedRemoteIframeWorkerTarget),
+ "second spawned remote iframe worker target was destroyed"
+ );
+
+ targetCommand.unwatchTargets({
+ types: [TYPES.WORKER, TYPES.SHARED_WORKER],
+ onAvailable,
+ onDestroyed,
+ });
+ targetCommand.destroy();
+
+ info("Unregister service workers so they don't appear in other tests.");
+ await unregisterAllServiceWorkers(commands.client);
+
+ BrowserTestUtils.removeTab(tab);
+ await commands.destroy();
+});
diff --git a/devtools/shared/commands/target/tests/browser_target_command_tab_workers_bfcache_navigation.js b/devtools/shared/commands/target/tests/browser_target_command_tab_workers_bfcache_navigation.js
new file mode 100644
index 0000000000..0f1ea45a08
--- /dev/null
+++ b/devtools/shared/commands/target/tests/browser_target_command_tab_workers_bfcache_navigation.js
@@ -0,0 +1,134 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test WORKER targets when doing history navigations (BF Cache)
+//
+// Use a distinct file as this test currently hits a DEBUG assertion
+// https://searchfox.org/mozilla-central/rev/352b525ab841278cd9b3098343f655ef85933544/dom/workers/WorkerPrivate.cpp#5218
+// and so is running only on OPT builds.
+
+const FISSION_TEST_URL = URL_ROOT_SSL + "fission_document.html";
+const WORKER_FILE = "test_worker.js";
+const WORKER_URL = URL_ROOT_SSL + WORKER_FILE;
+const IFRAME_WORKER_URL = URL_ROOT_ORG_SSL + WORKER_FILE;
+
+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);
+
+ // The WorkerDebuggerManager#getWorkerDebuggerEnumerator method we're using to retrieve
+ // workers loops through _all_ the workers in the process, which means it goes over workers
+ // from other tabs as well. Here we add a few tabs that are not going to be used in the
+ // test, just to check that their workers won't be retrieved by getAllTargets/watchTargets.
+ await addTab(`${FISSION_TEST_URL}?id=first-untargetted-tab&noServiceWorker`);
+ await addTab(`${FISSION_TEST_URL}?id=second-untargetted-tab&noServiceWorker`);
+
+ info("Test bfcache navigations");
+ const tab = await addTab(`${FISSION_TEST_URL}?&noServiceWorker`);
+
+ // Create a TargetCommand for the tab
+ const commands = await CommandsFactory.forTab(tab);
+ const targetCommand = commands.targetCommand;
+
+ // Workaround to allow listening for workers in the content toolbox
+ // without the fission preferences
+ targetCommand.listenForWorkers = true;
+
+ await targetCommand.startListening();
+
+ const { TYPES } = targetCommand;
+
+ info(
+ "Assert that watchTargets will call the onAvailable callback for existing dedicated workers"
+ );
+ const targets = [];
+ const destroyedTargets = [];
+ const onAvailable = async ({ targetFront }) => {
+ info(`onAvailable called for ${targetFront.url}`);
+ is(
+ targetFront.targetType,
+ TYPES.WORKER,
+ "We are only notified about worker targets"
+ );
+ ok(!targetFront.isTopLevel, "The workers are never top level");
+ targets.push(targetFront);
+ info(`Handled ${targets.length} new targets`);
+ };
+ const onDestroyed = async ({ targetFront }) => {
+ is(
+ targetFront.targetType,
+ TYPES.WORKER,
+ "We are only notified about worker targets"
+ );
+ ok(!targetFront.isTopLevel, "The workers are never top level");
+ destroyedTargets.push(targetFront);
+ };
+
+ await targetCommand.watchTargets({
+ types: [TYPES.WORKER, TYPES.SHARED_WORKER],
+ onAvailable,
+ onDestroyed,
+ });
+
+ is(targets.length, 2, "watchTargets retrieved 2 workers…");
+ const mainPageWorkerTarget = targets.find(
+ worker => worker.url == `${WORKER_URL}#simple-worker`
+ );
+ const iframeWorkerTarget = targets.find(
+ worker => worker.url == `${IFRAME_WORKER_URL}#simple-worker-in-iframe`
+ );
+
+ ok(
+ mainPageWorkerTarget,
+ "…the dedicated worker in main page, which is the same front we received from getAllTargets"
+ );
+ ok(
+ iframeWorkerTarget,
+ "…the dedicated worker in iframe, which is the same front we received from getAllTargets"
+ );
+
+ info("Check that navigating away does destroy all targets");
+ const onBrowserLoaded = BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ BrowserTestUtils.loadURIString(
+ tab.linkedBrowser,
+ "data:text/html,<meta charset=utf8>Away"
+ );
+ await onBrowserLoaded;
+
+ await waitFor(
+ () => destroyedTargets.length === 2,
+ "Wait for all the targets to be reported as destroyed"
+ );
+
+ info("Navigate back to the first page");
+ gBrowser.goBack();
+
+ await waitFor(
+ () => targets.length === 4,
+ "Wait for the target list to notify us about the first page workers, restored from the BF Cache"
+ );
+
+ const mainPageWorkerTargetAfterGoingBack = targets.find(
+ t => t !== mainPageWorkerTarget && t.url == `${WORKER_URL}#simple-worker`
+ );
+ const iframeWorkerTargetAfterGoingBack = targets.find(
+ t =>
+ t !== iframeWorkerTarget &&
+ t.url == `${IFRAME_WORKER_URL}#simple-worker-in-iframe`
+ );
+
+ ok(
+ mainPageWorkerTargetAfterGoingBack,
+ "The target list handled the worker created from the BF Cache"
+ );
+ ok(
+ iframeWorkerTargetAfterGoingBack,
+ "The target list handled the worker created in the iframe from the BF Cache"
+ );
+
+ targetCommand.destroy();
+ await commands.destroy();
+});
diff --git a/devtools/shared/commands/target/tests/browser_target_command_various_descriptors.js b/devtools/shared/commands/target/tests/browser_target_command_various_descriptors.js
new file mode 100644
index 0000000000..b61faf8f6e
--- /dev/null
+++ b/devtools/shared/commands/target/tests/browser_target_command_various_descriptors.js
@@ -0,0 +1,283 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the TargetCommand API with all possible descriptors
+
+const TEST_URL = "https://example.org/document-builder.sjs?html=org";
+const SECOND_TEST_URL = "https://example.com/document-builder.sjs?html=org";
+const CHROME_WORKER_URL = CHROME_URL_ROOT + "test_worker.js";
+
+const DESCRIPTOR_TYPES = require("resource://devtools/client/fronts/descriptors/descriptor-types.js");
+
+add_task(async function () {
+ // Enabled fission prefs
+ await pushPref("devtools.browsertoolbox.scope", "everything");
+ // 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);
+
+ await testLocalTab();
+ await testRemoteTab();
+ await testParentProcess();
+ await testWorker();
+ await testWebExtension();
+});
+
+async function testParentProcess() {
+ info("Test TargetCommand against parent process descriptor");
+
+ const commands = await CommandsFactory.forMainProcess();
+ const { descriptorFront } = commands;
+
+ is(
+ descriptorFront.descriptorType,
+ DESCRIPTOR_TYPES.PROCESS,
+ "The descriptor type is correct"
+ );
+ is(
+ descriptorFront.isParentProcessDescriptor,
+ true,
+ "Descriptor front isParentProcessDescriptor is correct"
+ );
+ is(
+ descriptorFront.isProcessDescriptor,
+ true,
+ "Descriptor front isProcessDescriptor is correct"
+ );
+
+ const targetCommand = commands.targetCommand;
+ await targetCommand.startListening();
+
+ const targets = await targetCommand.getAllTargets(targetCommand.ALL_TYPES);
+ ok(
+ targets.length > 1,
+ "We get many targets when debugging the parent process"
+ );
+ const targetFront = targets[0];
+ is(targetFront, targetCommand.targetFront, "The first is the top level one");
+ is(
+ targetFront.targetType,
+ targetCommand.TYPES.FRAME,
+ "the parent process target is of frame type, because it inherits from WindowGlobalTargetActor"
+ );
+ is(targetFront.isTopLevel, true, "This is flagged as top level");
+
+ targetCommand.destroy();
+ await waitForAllTargetsToBeAttached(targetCommand);
+
+ await commands.destroy();
+}
+
+async function testLocalTab() {
+ info("Test TargetCommand against local tab descriptor (via getTab({ tab }))");
+
+ const tab = await addTab(TEST_URL);
+ const commands = await CommandsFactory.forTab(tab);
+ const { descriptorFront } = commands;
+ is(
+ descriptorFront.descriptorType,
+ DESCRIPTOR_TYPES.TAB,
+ "The descriptor type is correct"
+ );
+ is(
+ descriptorFront.isTabDescriptor,
+ true,
+ "Descriptor front isTabDescriptor is correct"
+ );
+
+ const targetCommand = commands.targetCommand;
+ await targetCommand.startListening();
+
+ const targets = await targetCommand.getAllTargets(targetCommand.ALL_TYPES);
+ is(targets.length, 1, "Got a unique target");
+ const targetFront = targets[0];
+ is(targetFront, targetCommand.targetFront, "The first is the top level one");
+ is(
+ targetFront.targetType,
+ targetCommand.TYPES.FRAME,
+ "the tab target is of frame type"
+ );
+ is(targetFront.isTopLevel, true, "This is flagged as top level");
+
+ targetCommand.destroy();
+
+ BrowserTestUtils.removeTab(tab);
+
+ await commands.destroy();
+}
+
+async function testRemoteTab() {
+ info(
+ "Test TargetCommand against remote tab descriptor (via getTab({ browserId }))"
+ );
+
+ const tab = await addTab(TEST_URL);
+ const commands = await CommandsFactory.forRemoteTab(
+ tab.linkedBrowser.browserId
+ );
+ const { descriptorFront } = commands;
+ is(
+ descriptorFront.descriptorType,
+ DESCRIPTOR_TYPES.TAB,
+ "The descriptor type is correct"
+ );
+ is(
+ descriptorFront.isTabDescriptor,
+ true,
+ "Descriptor front isTabDescriptor is correct"
+ );
+
+ const targetCommand = commands.targetCommand;
+ await targetCommand.startListening();
+
+ const targets = await targetCommand.getAllTargets(targetCommand.ALL_TYPES);
+ is(targets.length, 1, "Got a unique target");
+ const targetFront = targets[0];
+ is(
+ targetFront,
+ targetCommand.targetFront,
+ "TargetCommand top target is the same as the first target"
+ );
+ is(
+ targetFront.targetType,
+ targetCommand.TYPES.FRAME,
+ "the tab target is of frame type"
+ );
+ is(targetFront.isTopLevel, true, "This is flagged as top level");
+
+ const browser = tab.linkedBrowser;
+ const onLoaded = BrowserTestUtils.browserLoaded(browser);
+ await BrowserTestUtils.loadURIString(browser, SECOND_TEST_URL);
+ await onLoaded;
+
+ info("Wait for the new target");
+ await waitFor(() => targetCommand.targetFront != targetFront);
+ isnot(
+ targetCommand.targetFront,
+ targetFront,
+ "The top level target changes on navigation"
+ );
+ ok(
+ !targetCommand.targetFront.isDestroyed(),
+ "The new target isn't destroyed"
+ );
+ ok(targetFront.isDestroyed(), "While the previous target is destroyed");
+
+ targetCommand.destroy();
+
+ BrowserTestUtils.removeTab(tab);
+
+ await commands.destroy();
+}
+
+async function testWebExtension() {
+ info("Test TargetCommand against webextension descriptor");
+
+ const extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ manifest: {
+ name: "Sample extension",
+ },
+ });
+
+ await extension.startup();
+
+ const commands = await CommandsFactory.forAddon(extension.id);
+ const { descriptorFront } = commands;
+ is(
+ descriptorFront.descriptorType,
+ DESCRIPTOR_TYPES.EXTENSION,
+ "The descriptor type is correct"
+ );
+ is(
+ descriptorFront.isWebExtensionDescriptor,
+ true,
+ "Descriptor front isWebExtensionDescriptor is correct"
+ );
+
+ const targetCommand = commands.targetCommand;
+ await targetCommand.startListening();
+
+ const targets = await targetCommand.getAllTargets(targetCommand.ALL_TYPES);
+ is(targets.length, 1, "Got a unique target");
+ const targetFront = targets[0];
+ is(targetFront, targetCommand.targetFront, "The first is the top level one");
+ is(
+ targetFront.targetType,
+ targetCommand.TYPES.FRAME,
+ "the web extension target is of frame type, because it inherits from WindowGlobalTargetActor"
+ );
+ is(targetFront.isTopLevel, true, "This is flagged as top level");
+
+ targetCommand.destroy();
+
+ await extension.unload();
+
+ await commands.destroy();
+}
+
+// CommandsFactory expect the worker id, which is computed from the nsIWorkerDebugger.id attribute
+function getNextWorkerDebuggerId() {
+ return new Promise(resolve => {
+ const wdm = Cc[
+ "@mozilla.org/dom/workers/workerdebuggermanager;1"
+ ].createInstance(Ci.nsIWorkerDebuggerManager);
+ const listener = {
+ onRegister(dbg) {
+ wdm.removeListener(listener);
+ resolve(dbg.id);
+ },
+ };
+ wdm.addListener(listener);
+ });
+}
+async function testWorker() {
+ info("Test TargetCommand against worker descriptor");
+
+ const workerUrl = CHROME_WORKER_URL + "#descriptor";
+ const onNextWorker = getNextWorkerDebuggerId();
+ const worker = new Worker(workerUrl);
+ const workerId = await onNextWorker;
+ ok(workerId, "Found the worker Debugger ID");
+
+ const commands = await CommandsFactory.forWorker(workerId);
+ const { descriptorFront } = commands;
+ is(
+ descriptorFront.descriptorType,
+ DESCRIPTOR_TYPES.WORKER,
+ "The descriptor type is correct"
+ );
+ is(
+ descriptorFront.isWorkerDescriptor,
+ true,
+ "Descriptor front isWorkerDescriptor is correct"
+ );
+
+ const targetCommand = commands.targetCommand;
+ await targetCommand.startListening();
+
+ const targets = await targetCommand.getAllTargets(targetCommand.ALL_TYPES);
+ is(targets.length, 1, "Got a unique target");
+ const targetFront = targets[0];
+ is(targetFront, targetCommand.targetFront, "The first is the top level one");
+ is(
+ targetFront.targetType,
+ targetCommand.TYPES.WORKER,
+ "the worker target is of worker type"
+ );
+ is(targetFront.isTopLevel, true, "This is flagged as top level");
+
+ targetCommand.destroy();
+
+ // Calling CommandsFactory.forWorker, will call RootFront.getWorker
+ // which will spawn lots of worker legacy code, firing lots of requests,
+ // which may still be pending
+ await commands.waitForRequestsToSettle();
+
+ await commands.destroy();
+ worker.terminate();
+}
diff --git a/devtools/shared/commands/target/tests/browser_target_command_watchTargets.js b/devtools/shared/commands/target/tests/browser_target_command_watchTargets.js
new file mode 100644
index 0000000000..516780be01
--- /dev/null
+++ b/devtools/shared/commands/target/tests/browser_target_command_watchTargets.js
@@ -0,0 +1,214 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the TargetCommand's `watchTargets` function
+
+const TEST_URL =
+ "data:text/html;charset=utf-8," + encodeURIComponent(`<div id="test"></div>`);
+
+add_task(async function () {
+ // Enabled fission's pref as the TargetCommand is almost disabled without it
+ await pushPref("devtools.browsertoolbox.scope", "everything");
+ // 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);
+
+ await testWatchTargets();
+ await testThrowingInOnAvailable();
+});
+
+async function testWatchTargets() {
+ info("Test TargetCommand watchTargets function");
+
+ const commands = await CommandsFactory.forMainProcess();
+ const targetCommand = commands.targetCommand;
+ const { TYPES } = targetCommand;
+
+ await targetCommand.startListening();
+
+ // Note that ppmm also includes the parent process, which is considered as a frame rather than a process
+ const originalProcessesCount = Services.ppmm.childCount - 1;
+
+ info(
+ "Check that onAvailable is called for processes already created *before* the call to watchTargets"
+ );
+ const targets = new Set();
+ const topLevelTarget = targetCommand.targetFront;
+ const onAvailable = ({ targetFront }) => {
+ if (targets.has(targetFront)) {
+ ok(false, "The same target is notified multiple times via onAvailable");
+ }
+ is(
+ targetFront.targetType,
+ TYPES.PROCESS,
+ "We are only notified about process targets"
+ );
+ ok(
+ targetFront == topLevelTarget
+ ? targetFront.isTopLevel
+ : !targetFront.isTopLevel,
+ "isTopLevel property is correct"
+ );
+ targets.add(targetFront);
+ };
+ const onDestroyed = ({ targetFront }) => {
+ if (!targets.has(targetFront)) {
+ ok(
+ false,
+ "A target is declared destroyed via onDestroyed without being notified via onAvailable"
+ );
+ }
+ is(
+ targetFront.targetType,
+ TYPES.PROCESS,
+ "We are only notified about process targets"
+ );
+ ok(
+ !targetFront.isTopLevel,
+ "We are not notified about the top level target destruction"
+ );
+ targets.delete(targetFront);
+ };
+ await targetCommand.watchTargets({
+ types: [TYPES.PROCESS],
+ onAvailable,
+ onDestroyed,
+ });
+ is(
+ targets.size,
+ originalProcessesCount,
+ "retrieved the expected number of processes via watchTargets"
+ );
+ // Start from 1 in order to ignore the parent process target, which is considered as a frame rather than a process
+ for (let i = 1; i < Services.ppmm.childCount; i++) {
+ const process = Services.ppmm.getChildAt(i);
+ const hasTargetWithSamePID = [...targets].find(
+ processTarget => processTarget.targetForm.processID == process.osPid
+ );
+ ok(
+ hasTargetWithSamePID,
+ `Process with PID ${process.osPid} has been reported via onAvailable`
+ );
+ }
+
+ info(
+ "Check that onAvailable is called for processes created *after* the call to watchTargets"
+ );
+ const previousTargets = new Set(targets);
+ const onProcessCreated = new Promise(resolve => {
+ const onAvailable2 = ({ targetFront }) => {
+ if (previousTargets.has(targetFront)) {
+ return;
+ }
+ targetCommand.unwatchTargets({
+ types: [TYPES.PROCESS],
+ onAvailable: onAvailable2,
+ });
+ resolve(targetFront);
+ };
+ targetCommand.watchTargets({
+ types: [TYPES.PROCESS],
+ onAvailable: onAvailable2,
+ });
+ });
+ const tab1 = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ url: TEST_URL,
+ forceNewProcess: true,
+ });
+ const createdTarget = await onProcessCreated;
+
+ // For some reason, creating a new tab purges processes created from previous tests
+ // so it is not reasonable to assert the side of `targets` as it may be lower than expected.
+ ok(targets.has(createdTarget), "The new tab process is in the list");
+
+ const processCountAfterTabOpen = targets.size;
+
+ // Assert that onDestroyed is called for destroyed processes
+ const onProcessDestroyed = new Promise(resolve => {
+ const onAvailable3 = () => {};
+ const onDestroyed3 = ({ targetFront }) => {
+ resolve(targetFront);
+ targetCommand.unwatchTargets({
+ types: [TYPES.PROCESS],
+ onAvailable: onAvailable3,
+ onDestroyed: onDestroyed3,
+ });
+ };
+ targetCommand.watchTargets({
+ types: [TYPES.PROCESS],
+ onAvailable: onAvailable3,
+ onDestroyed: onDestroyed3,
+ });
+ });
+
+ BrowserTestUtils.removeTab(tab1);
+
+ const destroyedTarget = await onProcessDestroyed;
+ is(
+ targets.size,
+ processCountAfterTabOpen - 1,
+ "The closed tab's process has been reported as destroyed"
+ );
+ ok(
+ !targets.has(destroyedTarget),
+ "The destroyed target is no longer in the list"
+ );
+ is(
+ destroyedTarget,
+ createdTarget,
+ "The destroyed target is the one that has been reported as created"
+ );
+
+ targetCommand.unwatchTargets({
+ types: [TYPES.PROCESS],
+ onAvailable,
+ onDestroyed,
+ });
+
+ targetCommand.destroy();
+
+ await commands.destroy();
+}
+
+async function testThrowingInOnAvailable() {
+ info(
+ "Test TargetCommand watchTargets function when an exception is thrown in onAvailable callback"
+ );
+
+ const commands = await CommandsFactory.forMainProcess();
+ const targetCommand = commands.targetCommand;
+ const { TYPES } = targetCommand;
+
+ await targetCommand.startListening();
+
+ // Note that ppmm also includes the parent process, which is considered as a frame rather than a process
+ const originalProcessesCount = Services.ppmm.childCount - 1;
+
+ info(
+ "Check that onAvailable is called for processes already created *before* the call to watchTargets"
+ );
+ const targets = new Set();
+ let thrown = false;
+ const onAvailable = ({ targetFront }) => {
+ if (!thrown) {
+ thrown = true;
+ throw new Error("Force an exception when processing the first target");
+ }
+ targets.add(targetFront);
+ };
+ await targetCommand.watchTargets({ types: [TYPES.PROCESS], onAvailable });
+ is(
+ targets.size,
+ originalProcessesCount - 1,
+ "retrieved the expected number of processes via onAvailable. All but the first one where we have thrown."
+ );
+
+ targetCommand.destroy();
+
+ await commands.destroy();
+}
diff --git a/devtools/shared/commands/target/tests/browser_watcher_actor_getter_caching.js b/devtools/shared/commands/target/tests/browser_watcher_actor_getter_caching.js
new file mode 100644
index 0000000000..6dd99d243b
--- /dev/null
+++ b/devtools/shared/commands/target/tests/browser_watcher_actor_getter_caching.js
@@ -0,0 +1,87 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that watcher front/actor APIs do not lead to create duplicate actors.
+
+const TEST_URL = "data:text/html;charset=utf-8,Actor caching test";
+
+add_task(async function () {
+ info("Setup the test page with workers of all types");
+ const tab = await addTab(TEST_URL);
+
+ info("Create a target list for a tab target");
+ const commands = await CommandsFactory.forTab(tab);
+ const targetCommand = commands.targetCommand;
+ await targetCommand.startListening();
+
+ const { watcherFront } = targetCommand;
+ ok(watcherFront, "A watcherFront is available on targetCommand");
+
+ info("Check that getNetworkParentActor does not create duplicate actors");
+ testActorGetter(
+ watcherFront,
+ () => watcherFront.getNetworkParentActor(),
+ "networkParent"
+ );
+
+ info("Check that getBreakpointListActor does not create duplicate actors");
+ testActorGetter(
+ watcherFront,
+ () => watcherFront.getBreakpointListActor(),
+ "breakpoint-list"
+ );
+
+ info(
+ "Check that getTargetConfigurationActor does not create duplicate actors"
+ );
+ testActorGetter(
+ watcherFront,
+ () => watcherFront.getTargetConfigurationActor(),
+ "target-configuration"
+ );
+
+ info(
+ "Check that getThreadConfigurationActor does not create duplicate actors"
+ );
+ testActorGetter(
+ watcherFront,
+ () => watcherFront.getThreadConfigurationActor(),
+ "thread-configuration"
+ );
+
+ targetCommand.destroy();
+ await commands.waitForRequestsToSettle();
+ await commands.destroy();
+});
+
+/**
+ * Check that calling an actor getter method on the watcher front leads to the
+ * creation of at most 1 actor.
+ */
+async function testActorGetter(watcherFront, actorGetterFn, typeName) {
+ checkPoolChildrenSize(watcherFront, typeName, 0);
+
+ const actor = await actorGetterFn();
+ checkPoolChildrenSize(watcherFront, typeName, 1);
+
+ const otherActor = await actorGetterFn();
+ is(actor, otherActor, "Returned the same actor for " + typeName);
+
+ checkPoolChildrenSize(watcherFront, typeName, 1);
+}
+
+/**
+ * Assert that a given parent pool has the expected number of children for
+ * a given typeName.
+ */
+function checkPoolChildrenSize(parentPool, typeName, expected) {
+ const children = [...parentPool.poolChildren()];
+ const childrenByType = children.filter(pool => pool.typeName === typeName);
+ is(
+ childrenByType.length,
+ expected,
+ `${parentPool.actorID} should have ${expected} children of type ${typeName}`
+ );
+}
diff --git a/devtools/shared/commands/target/tests/fission_document.html b/devtools/shared/commands/target/tests/fission_document.html
new file mode 100644
index 0000000000..62afe347e3
--- /dev/null
+++ b/devtools/shared/commands/target/tests/fission_document.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/target/tests/test_worker.js#simple-worker");
+
+ // eslint-disable-next-line no-unused-vars
+ const sharedWorker = new SharedWorker("https://example.com/browser/devtools/shared/commands/target/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/target/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/target/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/target/tests/fission_iframe.html b/devtools/shared/commands/target/tests/fission_iframe.html
new file mode 100644
index 0000000000..deae49f833
--- /dev/null
+++ b/devtools/shared/commands/target/tests/fission_iframe.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/target/tests/head.js b/devtools/shared/commands/target/tests/head.js
new file mode 100644
index 0000000000..ecb3fc1828
--- /dev/null
+++ b/devtools/shared/commands/target/tests/head.js
@@ -0,0 +1,32 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* 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 createLocalClient() {
+ // Instantiate a minimal server
+ DevToolsServer.init();
+ DevToolsServer.allowChromeProcess = true;
+ if (!DevToolsServer.createRootActor) {
+ DevToolsServer.registerAllActors();
+ }
+ const transport = DevToolsServer.connectPipe();
+ const client = new DevToolsClient(transport);
+ await client.connect();
+ return client;
+}
diff --git a/devtools/shared/commands/target/tests/incremental-js-value-script.sjs b/devtools/shared/commands/target/tests/incremental-js-value-script.sjs
new file mode 100644
index 0000000000..a612a3cb59
--- /dev/null
+++ b/devtools/shared/commands/target/tests/incremental-js-value-script.sjs
@@ -0,0 +1,23 @@
+"use strict";
+
+function handleRequest(request, response) {
+ const Etag = '"4d881ab-b03-435f0a0f9ef00"';
+ const IfNoneMatch = request.hasHeader("If-None-Match")
+ ? request.getHeader("If-None-Match")
+ : "";
+
+ const counter = getState("cache-counter") || 1;
+ const page = "<script>var jsValue = '" + counter + "';</script>" + counter;
+
+ setState("cache-counter", "" + (parseInt(counter, 10) + 1));
+
+ response.setHeader("Etag", Etag, false);
+
+ if (IfNoneMatch === Etag) {
+ response.setStatusLine(request.httpVersion, "304", "Not Modified");
+ } else {
+ response.setHeader("Content-Type", "text/html; charset=utf-8", false);
+ response.setHeader("Content-Length", page.length + "", false);
+ response.write(page);
+ }
+}
diff --git a/devtools/shared/commands/target/tests/simple_document.html b/devtools/shared/commands/target/tests/simple_document.html
new file mode 100644
index 0000000000..d6a449e489
--- /dev/null
+++ b/devtools/shared/commands/target/tests/simple_document.html
@@ -0,0 +1,12 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf8">
+ <title>Test empty document</title>
+ <!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+</head>
+<body>
+<p>Test empty document</p>
+</body>
+</html>
diff --git a/devtools/shared/commands/target/tests/test_service_worker.js b/devtools/shared/commands/target/tests/test_service_worker.js
new file mode 100644
index 0000000000..aabc3fda0f
--- /dev/null
+++ b/devtools/shared/commands/target/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/target/tests/test_sw_page.html b/devtools/shared/commands/target/tests/test_sw_page.html
new file mode 100644
index 0000000000..38aad04259
--- /dev/null
+++ b/devtools/shared/commands/target/tests/test_sw_page.html
@@ -0,0 +1,19 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf8">
+ <title>Test sw page</title>
+ <!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+</head>
+<body>
+<p>Test sw page</p>
+
+<script>
+"use strict";
+
+// Expose a reference to the registration so that tests can unregister it.
+window.registrationPromise = navigator.serviceWorker.register("test_sw_page_worker.js");
+</script>
+</body>
+</html>
diff --git a/devtools/shared/commands/target/tests/test_sw_page_worker.js b/devtools/shared/commands/target/tests/test_sw_page_worker.js
new file mode 100644
index 0000000000..29cda68560
--- /dev/null
+++ b/devtools/shared/commands/target/tests/test_sw_page_worker.js
@@ -0,0 +1,5 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// We don't need any computation in the worker,
+// just it to be alive
diff --git a/devtools/shared/commands/target/tests/test_worker.js b/devtools/shared/commands/target/tests/test_worker.js
new file mode 100644
index 0000000000..ce3dd39cea
--- /dev/null
+++ b/devtools/shared/commands/target/tests/test_worker.js
@@ -0,0 +1,13 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+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/thread-configuration/moz.build b/devtools/shared/commands/thread-configuration/moz.build
new file mode 100644
index 0000000000..28e8e0ffc4
--- /dev/null
+++ b/devtools/shared/commands/thread-configuration/moz.build
@@ -0,0 +1,7 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DevToolsModules(
+ "thread-configuration-command.js",
+)
diff --git a/devtools/shared/commands/thread-configuration/tests/browser.ini b/devtools/shared/commands/thread-configuration/tests/browser.ini
new file mode 100644
index 0000000000..d918fce245
--- /dev/null
+++ b/devtools/shared/commands/thread-configuration/tests/browser.ini
@@ -0,0 +1,7 @@
+[DEFAULT]
+tags = devtools
+subsuite = devtools
+support-files =
+ !/devtools/client/shared/test/shared-head.js
+ head.js
+
diff --git a/devtools/shared/commands/thread-configuration/tests/head.js b/devtools/shared/commands/thread-configuration/tests/head.js
new file mode 100644
index 0000000000..ce65b3d827
--- /dev/null
+++ b/devtools/shared/commands/thread-configuration/tests/head.js
@@ -0,0 +1,12 @@
+/* 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
+);
diff --git a/devtools/shared/commands/thread-configuration/thread-configuration-command.js b/devtools/shared/commands/thread-configuration/thread-configuration-command.js
new file mode 100644
index 0000000000..0db1c2a285
--- /dev/null
+++ b/devtools/shared/commands/thread-configuration/thread-configuration-command.js
@@ -0,0 +1,72 @@
+/* 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";
+
+/**
+ * The ThreadConfigurationCommand should be used to maintain thread settings
+ * sent from the client for the thread actor.
+ *
+ * See the ThreadConfigurationActor for a list of supported configuration options.
+ */
+class ThreadConfigurationCommand {
+ constructor({ commands, watcherFront }) {
+ this._commands = commands;
+ this._watcherFront = watcherFront;
+ }
+
+ /**
+ * Return a promise that resolves to the related thread configuration actor's front.
+ *
+ * @return {Promise<ThreadConfigurationFront>}
+ */
+ async getThreadConfigurationFront() {
+ const front = await this._watcherFront.getThreadConfigurationActor();
+ return front;
+ }
+
+ async updateConfiguration(configuration) {
+ if (this._commands.targetCommand.hasTargetWatcherSupport()) {
+ // Remove thread options that are not currently supported by
+ // the thread configuration actor.
+ const filteredConfiguration = Object.fromEntries(
+ Object.entries(configuration).filter(
+ ([key, value]) => !["breakpoints", "eventBreakpoints"].includes(key)
+ )
+ );
+
+ const threadConfigurationFront = await this.getThreadConfigurationFront();
+ const updatedConfiguration =
+ await threadConfigurationFront.updateConfiguration(
+ filteredConfiguration
+ );
+ this._configuration = updatedConfiguration;
+ }
+
+ let threadFronts = await this._commands.targetCommand.getAllFronts(
+ this._commands.targetCommand.ALL_TYPES,
+ "thread"
+ );
+
+ // Lets always call reconfigure for all the target types that do not
+ // have target watcher support yet. e.g In the browser, even
+ // though `hasTargetWatcherSupport()` is true, only
+ // FRAME and CONTENT PROCESS targets use watcher actors,
+ // WORKER targets are supported via the legacy listerners.
+ threadFronts = threadFronts.filter(
+ threadFront =>
+ !this._commands.targetCommand.hasTargetWatcherSupport(
+ threadFront.targetFront.targetType
+ )
+ );
+
+ // Ignore threads that fail to be configured.
+ // Some workers may be destroying and `reconfigure` would be rejected.
+ await Promise.allSettled(
+ threadFronts.map(threadFront => threadFront.reconfigure(configuration))
+ );
+ }
+}
+
+module.exports = ThreadConfigurationCommand;