summaryrefslogtreecommitdiffstats
path: root/devtools/shared/commands/target
diff options
context:
space:
mode:
Diffstat (limited to '')
-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
52 files changed, 7627 insertions, 0 deletions
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);
+ }
+};