summaryrefslogtreecommitdiffstats
path: root/devtools/shared/resources
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/shared/resources')
-rw-r--r--devtools/shared/resources/legacy-listeners/cache-storage.js18
-rw-r--r--devtools/shared/resources/legacy-listeners/console-messages.js54
-rw-r--r--devtools/shared/resources/legacy-listeners/cookie.js18
-rw-r--r--devtools/shared/resources/legacy-listeners/css-changes.js30
-rw-r--r--devtools/shared/resources/legacy-listeners/css-messages.js66
-rw-r--r--devtools/shared/resources/legacy-listeners/error-messages.js64
-rw-r--r--devtools/shared/resources/legacy-listeners/extension-storage.js18
-rw-r--r--devtools/shared/resources/legacy-listeners/indexed-db.js19
-rw-r--r--devtools/shared/resources/legacy-listeners/local-storage.js18
-rw-r--r--devtools/shared/resources/legacy-listeners/moz.build24
-rw-r--r--devtools/shared/resources/legacy-listeners/network-event-stacktraces.js25
-rw-r--r--devtools/shared/resources/legacy-listeners/network-events.js151
-rw-r--r--devtools/shared/resources/legacy-listeners/platform-messages.js46
-rw-r--r--devtools/shared/resources/legacy-listeners/root-node.js63
-rw-r--r--devtools/shared/resources/legacy-listeners/session-storage.js18
-rw-r--r--devtools/shared/resources/legacy-listeners/source.js89
-rw-r--r--devtools/shared/resources/legacy-listeners/storage-utils.js97
-rw-r--r--devtools/shared/resources/legacy-listeners/stylesheet.js133
-rw-r--r--devtools/shared/resources/legacy-listeners/websocket.js62
-rw-r--r--devtools/shared/resources/legacy-target-watchers/legacy-processes-watcher.js73
-rw-r--r--devtools/shared/resources/legacy-target-watchers/legacy-serviceworkers-watcher.js269
-rw-r--r--devtools/shared/resources/legacy-target-watchers/legacy-sharedworkers-watcher.js21
-rw-r--r--devtools/shared/resources/legacy-target-watchers/legacy-workers-watcher.js225
-rw-r--r--devtools/shared/resources/legacy-target-watchers/moz.build10
-rw-r--r--devtools/shared/resources/moz.build17
-rw-r--r--devtools/shared/resources/resource-watcher.js925
-rw-r--r--devtools/shared/resources/target-list.js607
-rw-r--r--devtools/shared/resources/tests/.eslintrc.js6
-rw-r--r--devtools/shared/resources/tests/browser.ini64
-rw-r--r--devtools/shared/resources/tests/browser_browser_resources_console_messages.js90
-rw-r--r--devtools/shared/resources/tests/browser_resources_client_caching.js362
-rw-r--r--devtools/shared/resources/tests/browser_resources_console_messages.js460
-rw-r--r--devtools/shared/resources/tests/browser_resources_console_messages_workers.js246
-rw-r--r--devtools/shared/resources/tests/browser_resources_css_changes.js134
-rw-r--r--devtools/shared/resources/tests/browser_resources_css_messages.js202
-rw-r--r--devtools/shared/resources/tests/browser_resources_document_events.js143
-rw-r--r--devtools/shared/resources/tests/browser_resources_error_messages.js614
-rw-r--r--devtools/shared/resources/tests/browser_resources_getAllResources.js102
-rw-r--r--devtools/shared/resources/tests/browser_resources_network_event_stacktraces.js104
-rw-r--r--devtools/shared/resources/tests/browser_resources_network_events.js258
-rw-r--r--devtools/shared/resources/tests/browser_resources_platform_messages.js149
-rw-r--r--devtools/shared/resources/tests/browser_resources_root_node.js129
-rw-r--r--devtools/shared/resources/tests/browser_resources_several_resources.js120
-rw-r--r--devtools/shared/resources/tests/browser_resources_sources.js198
-rw-r--r--devtools/shared/resources/tests/browser_resources_stylesheets.js506
-rw-r--r--devtools/shared/resources/tests/browser_resources_target_destroy.js92
-rw-r--r--devtools/shared/resources/tests/browser_resources_target_resources_race.js77
-rw-r--r--devtools/shared/resources/tests/browser_resources_target_switching.js101
-rw-r--r--devtools/shared/resources/tests/browser_resources_websocket.js142
-rw-r--r--devtools/shared/resources/tests/browser_target_list_browser_workers.js195
-rw-r--r--devtools/shared/resources/tests/browser_target_list_frames.js168
-rw-r--r--devtools/shared/resources/tests/browser_target_list_getAllTargets.js108
-rw-r--r--devtools/shared/resources/tests/browser_target_list_preffedoff.js91
-rw-r--r--devtools/shared/resources/tests/browser_target_list_processes.js195
-rw-r--r--devtools/shared/resources/tests/browser_target_list_service_workers.js79
-rw-r--r--devtools/shared/resources/tests/browser_target_list_service_workers_navigation.js393
-rw-r--r--devtools/shared/resources/tests/browser_target_list_switchToTarget.js147
-rw-r--r--devtools/shared/resources/tests/browser_target_list_tab_workers.js330
-rw-r--r--devtools/shared/resources/tests/browser_target_list_watchTargets.js256
-rw-r--r--devtools/shared/resources/tests/early_console_document.html14
-rw-r--r--devtools/shared/resources/tests/fission_document.html47
-rw-r--r--devtools/shared/resources/tests/fission_iframe.html29
-rw-r--r--devtools/shared/resources/tests/head.js150
-rw-r--r--devtools/shared/resources/tests/network_document.html13
-rw-r--r--devtools/shared/resources/tests/service-worker-sources.js2
-rw-r--r--devtools/shared/resources/tests/sources.html22
-rw-r--r--devtools/shared/resources/tests/sources.js2
-rw-r--r--devtools/shared/resources/tests/style_document.css1
-rw-r--r--devtools/shared/resources/tests/style_document.html16
-rw-r--r--devtools/shared/resources/tests/style_iframe.css1
-rw-r--r--devtools/shared/resources/tests/style_iframe.html15
-rw-r--r--devtools/shared/resources/tests/test_service_worker.js11
-rw-r--r--devtools/shared/resources/tests/test_sw_page.html19
-rw-r--r--devtools/shared/resources/tests/test_sw_page_worker.js5
-rw-r--r--devtools/shared/resources/tests/test_worker.js15
-rw-r--r--devtools/shared/resources/tests/websocket_backend_wsh.py21
-rw-r--r--devtools/shared/resources/tests/websocket_frontend.html39
-rw-r--r--devtools/shared/resources/tests/worker-sources.js2
-rw-r--r--devtools/shared/resources/transformers/console-messages.js23
-rw-r--r--devtools/shared/resources/transformers/error-messages.js31
-rw-r--r--devtools/shared/resources/transformers/moz.build11
-rw-r--r--devtools/shared/resources/transformers/network-events.js16
-rw-r--r--devtools/shared/resources/transformers/storage-local-storage.js23
-rw-r--r--devtools/shared/resources/transformers/storage-session-storage.js23
84 files changed, 9972 insertions, 0 deletions
diff --git a/devtools/shared/resources/legacy-listeners/cache-storage.js b/devtools/shared/resources/legacy-listeners/cache-storage.js
new file mode 100644
index 0000000000..d800e87616
--- /dev/null
+++ b/devtools/shared/resources/legacy-listeners/cache-storage.js
@@ -0,0 +1,18 @@
+/* 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 {
+ ResourceWatcher,
+} = require("devtools/shared/resources/resource-watcher");
+
+const {
+ makeStorageLegacyListener,
+} = require("devtools/shared/resources/legacy-listeners/storage-utils");
+
+module.exports = makeStorageLegacyListener(
+ "Cache",
+ ResourceWatcher.TYPES.CACHE_STORAGE
+);
diff --git a/devtools/shared/resources/legacy-listeners/console-messages.js b/devtools/shared/resources/legacy-listeners/console-messages.js
new file mode 100644
index 0000000000..c784147154
--- /dev/null
+++ b/devtools/shared/resources/legacy-listeners/console-messages.js
@@ -0,0 +1,54 @@
+/* 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 {
+ ResourceWatcher,
+} = require("devtools/shared/resources/resource-watcher");
+
+module.exports = async function({ targetList, targetFront, onAvailable }) {
+ // Allow the top level target unconditionnally.
+ // Also allow frame, but only in content toolbox, i.e. still ignore them in
+ // the context of the browser toolbox as we inspect messages via the process
+ // targets
+ const listenForFrames = targetList.targetFront.isLocalTab;
+
+ // Allow workers when messages aren't dispatched to the main thread.
+ const listenForWorkers = !targetList.rootFront.traits
+ .workerConsoleApiMessagesDispatchedToMainThread;
+
+ const acceptTarget =
+ targetFront.isTopLevel ||
+ targetFront.targetType === targetList.TYPES.PROCESS ||
+ (targetFront.targetType === targetList.TYPES.FRAME && listenForFrames) ||
+ (targetFront.targetType === targetList.TYPES.WORKER && listenForWorkers);
+
+ if (!acceptTarget) {
+ return;
+ }
+
+ const webConsoleFront = await targetFront.getFront("console");
+ if (webConsoleFront.isDestroyed()) {
+ return;
+ }
+
+ // Request notifying about new messages
+ await webConsoleFront.startListeners(["ConsoleAPI"]);
+
+ // Fetch already existing messages
+ // /!\ The actor implementation requires to call startListeners(ConsoleAPI) first /!\
+ const { messages } = await webConsoleFront.getCachedMessages(["ConsoleAPI"]);
+
+ for (const message of messages) {
+ message.resourceType = ResourceWatcher.TYPES.CONSOLE_MESSAGE;
+ }
+ onAvailable(messages);
+
+ // Forward new message events
+ webConsoleFront.on("consoleAPICall", message => {
+ message.resourceType = ResourceWatcher.TYPES.CONSOLE_MESSAGE;
+ onAvailable([message]);
+ });
+};
diff --git a/devtools/shared/resources/legacy-listeners/cookie.js b/devtools/shared/resources/legacy-listeners/cookie.js
new file mode 100644
index 0000000000..8fe2201ab6
--- /dev/null
+++ b/devtools/shared/resources/legacy-listeners/cookie.js
@@ -0,0 +1,18 @@
+/* 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 {
+ ResourceWatcher,
+} = require("devtools/shared/resources/resource-watcher");
+
+const {
+ makeStorageLegacyListener,
+} = require("devtools/shared/resources/legacy-listeners/storage-utils");
+
+module.exports = makeStorageLegacyListener(
+ "cookies",
+ ResourceWatcher.TYPES.COOKIE
+);
diff --git a/devtools/shared/resources/legacy-listeners/css-changes.js b/devtools/shared/resources/legacy-listeners/css-changes.js
new file mode 100644
index 0000000000..f9bc0e9547
--- /dev/null
+++ b/devtools/shared/resources/legacy-listeners/css-changes.js
@@ -0,0 +1,30 @@
+/* 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 {
+ ResourceWatcher,
+} = require("devtools/shared/resources/resource-watcher");
+
+module.exports = async function({ targetFront, onAvailable }) {
+ if (!targetFront.hasActor("changes")) {
+ return;
+ }
+
+ const changesFront = await targetFront.getFront("changes");
+
+ // Get all changes collected up to this point by the ChangesActor on the server,
+ // then fire each change as "add-change".
+ const changes = await changesFront.allChanges();
+ await onAvailable(changes.map(change => toResource(change)));
+
+ changesFront.on("add-change", change => onAvailable([toResource(change)]));
+};
+
+function toResource(change) {
+ return Object.assign(change, {
+ resourceType: ResourceWatcher.TYPES.CSS_CHANGE,
+ });
+}
diff --git a/devtools/shared/resources/legacy-listeners/css-messages.js b/devtools/shared/resources/legacy-listeners/css-messages.js
new file mode 100644
index 0000000000..1f71ff6777
--- /dev/null
+++ b/devtools/shared/resources/legacy-listeners/css-messages.js
@@ -0,0 +1,66 @@
+/* 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 {
+ ResourceWatcher,
+} = require("devtools/shared/resources/resource-watcher");
+const { MESSAGE_CATEGORY } = require("devtools/shared/constants");
+
+module.exports = async function({ targetList, targetFront, onAvailable }) {
+ // Allow the top level target if the targetFront has an `ensureCSSErrorREportingEnabled`
+ // function. Also allow frame targets.
+ const isAllowed =
+ typeof targetFront.ensureCSSErrorReportingEnabled == "function" &&
+ (targetFront.isTopLevel ||
+ targetFront.targetType === targetList.TYPES.FRAME);
+
+ if (!isAllowed) {
+ return;
+ }
+
+ const webConsoleFront = await targetFront.getFront("console");
+ if (webConsoleFront.isDestroyed()) {
+ return;
+ }
+
+ // Request notifying about new CSS messages (they're emitted from the "PageError listener").
+ await webConsoleFront.startListeners(["PageError"]);
+
+ // Fetch already existing messages
+ // /!\ The actor implementation requires to call startListeners("PageError") first /!\
+ const { messages } = await webConsoleFront.getCachedMessages(["PageError"]);
+
+ const cachedMessages = [];
+ for (const message of messages) {
+ if (message.pageError?.category !== MESSAGE_CATEGORY.CSS_PARSER) {
+ continue;
+ }
+
+ message.resourceType = ResourceWatcher.TYPES.CSS_MESSAGE;
+ message.cssSelectors = message.pageError.cssSelectors;
+ delete message.pageError.cssSelectors;
+ cachedMessages.push(message);
+ }
+
+ onAvailable(cachedMessages);
+
+ // CSS Messages are emited fron the PageError listener, which also send regular errors
+ // that we need to filter out.
+ webConsoleFront.on("pageError", message => {
+ if (message.pageError.category !== MESSAGE_CATEGORY.CSS_PARSER) {
+ return;
+ }
+
+ message.resourceType = ResourceWatcher.TYPES.CSS_MESSAGE;
+ message.cssSelectors = message.pageError.cssSelectors;
+ delete message.pageError.cssSelectors;
+ onAvailable([message]);
+ });
+
+ // Calling ensureCSSErrorReportingEnabled will make the server parse the stylesheets to
+ // retrieve the warnings if the docShell wasn't already watching for CSS messages.
+ await targetFront.ensureCSSErrorReportingEnabled();
+};
diff --git a/devtools/shared/resources/legacy-listeners/error-messages.js b/devtools/shared/resources/legacy-listeners/error-messages.js
new file mode 100644
index 0000000000..cce1204b73
--- /dev/null
+++ b/devtools/shared/resources/legacy-listeners/error-messages.js
@@ -0,0 +1,64 @@
+/* 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 {
+ ResourceWatcher,
+} = require("devtools/shared/resources/resource-watcher");
+const { MESSAGE_CATEGORY } = require("devtools/shared/constants");
+
+module.exports = async function({ targetList, targetFront, onAvailable }) {
+ // Allow the top level target unconditionnally.
+ // Also allow frame, but only in content toolbox, i.e. still ignore them in
+ // the context of the browser toolbox as we inspect messages via the process
+ // targets
+ // Also ignore workers as they are not supported yet. (see bug 1592584)
+ const listenForFrames = targetList.targetFront.isLocalTab;
+ const isAllowed =
+ targetFront.isTopLevel ||
+ targetFront.targetType === targetList.TYPES.PROCESS ||
+ (targetFront.targetType === targetList.TYPES.FRAME && listenForFrames);
+
+ if (!isAllowed) {
+ return;
+ }
+
+ const webConsoleFront = await targetFront.getFront("console");
+ if (webConsoleFront.isDestroyed()) {
+ return;
+ }
+
+ // Request notifying about new messages. Here the "PageError" type start listening for
+ // both actual PageErrors (emitted as "pageError" events) as well as LogMessages (
+ // emitted as "logMessage" events). This function only set up the listener on the
+ // webConsoleFront for "pageError".
+ await webConsoleFront.startListeners(["PageError"]);
+
+ // Fetch already existing messages
+ // /!\ The actor implementation requires to call startListeners("PageError") first /!\
+ let { messages } = await webConsoleFront.getCachedMessages(["PageError"]);
+
+ // On server < v79, we're also getting CSS Messages that we need to filter out.
+ messages = messages.filter(
+ message => message.pageError.category !== MESSAGE_CATEGORY.CSS_PARSER
+ );
+
+ messages.forEach(message => {
+ message.resourceType = ResourceWatcher.TYPES.ERROR_MESSAGE;
+ });
+ // Cached messages don't have the same shape as live messages,
+ // so we need to transform them.
+ onAvailable(messages);
+
+ webConsoleFront.on("pageError", message => {
+ // On server < v79, we're getting CSS Messages that we need to filter out.
+ if (message.pageError.category === MESSAGE_CATEGORY.CSS_PARSER) {
+ return;
+ }
+
+ message.resourceType = ResourceWatcher.TYPES.ERROR_MESSAGE;
+ onAvailable([message]);
+ });
+};
diff --git a/devtools/shared/resources/legacy-listeners/extension-storage.js b/devtools/shared/resources/legacy-listeners/extension-storage.js
new file mode 100644
index 0000000000..6ba72fea79
--- /dev/null
+++ b/devtools/shared/resources/legacy-listeners/extension-storage.js
@@ -0,0 +1,18 @@
+/* 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 {
+ ResourceWatcher,
+} = require("devtools/shared/resources/resource-watcher");
+
+const {
+ makeStorageLegacyListener,
+} = require("devtools/shared/resources/legacy-listeners/storage-utils");
+
+module.exports = makeStorageLegacyListener(
+ "extensionStorage",
+ ResourceWatcher.TYPES.EXTENSION_STORAGE
+);
diff --git a/devtools/shared/resources/legacy-listeners/indexed-db.js b/devtools/shared/resources/legacy-listeners/indexed-db.js
new file mode 100644
index 0000000000..3d24fdcad9
--- /dev/null
+++ b/devtools/shared/resources/legacy-listeners/indexed-db.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 {
+ ResourceWatcher,
+} = require("devtools/shared/resources/resource-watcher");
+
+const {
+ // getFilteredStorageEvents,
+ makeStorageLegacyListener,
+} = require("devtools/shared/resources/legacy-listeners/storage-utils");
+
+module.exports = makeStorageLegacyListener(
+ "indexedDB",
+ ResourceWatcher.TYPES.INDEXED_DB
+);
diff --git a/devtools/shared/resources/legacy-listeners/local-storage.js b/devtools/shared/resources/legacy-listeners/local-storage.js
new file mode 100644
index 0000000000..727ebb54bc
--- /dev/null
+++ b/devtools/shared/resources/legacy-listeners/local-storage.js
@@ -0,0 +1,18 @@
+/* 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 {
+ ResourceWatcher,
+} = require("devtools/shared/resources/resource-watcher");
+
+const {
+ makeStorageLegacyListener,
+} = require("devtools/shared/resources/legacy-listeners/storage-utils");
+
+module.exports = makeStorageLegacyListener(
+ "localStorage",
+ ResourceWatcher.TYPES.LOCAL_STORAGE
+);
diff --git a/devtools/shared/resources/legacy-listeners/moz.build b/devtools/shared/resources/legacy-listeners/moz.build
new file mode 100644
index 0000000000..ca84256b66
--- /dev/null
+++ b/devtools/shared/resources/legacy-listeners/moz.build
@@ -0,0 +1,24 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DevToolsModules(
+ "cache-storage.js",
+ "console-messages.js",
+ "cookie.js",
+ "css-changes.js",
+ "css-messages.js",
+ "error-messages.js",
+ "extension-storage.js",
+ "indexed-db.js",
+ "local-storage.js",
+ "network-event-stacktraces.js",
+ "network-events.js",
+ "platform-messages.js",
+ "root-node.js",
+ "session-storage.js",
+ "source.js",
+ "storage-utils.js",
+ "stylesheet.js",
+ "websocket.js",
+)
diff --git a/devtools/shared/resources/legacy-listeners/network-event-stacktraces.js b/devtools/shared/resources/legacy-listeners/network-event-stacktraces.js
new file mode 100644
index 0000000000..ccf70053d5
--- /dev/null
+++ b/devtools/shared/resources/legacy-listeners/network-event-stacktraces.js
@@ -0,0 +1,25 @@
+/* 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 {
+ ResourceWatcher,
+} = require("devtools/shared/resources/resource-watcher");
+
+module.exports = async function({ targetList, targetFront, onAvailable }) {
+ function onNetworkEventStackTrace(packet) {
+ const actor = packet.eventActor;
+ onAvailable([
+ {
+ resourceType: ResourceWatcher.TYPES.NETWORK_EVENT_STACKTRACE,
+ resourceId: actor.channelId,
+ stacktraceAvailable: actor.cause.stacktraceAvailable,
+ lastFrame: actor.cause.lastFrame,
+ },
+ ]);
+ }
+ const webConsoleFront = await targetFront.getFront("console");
+ webConsoleFront.on("serverNetworkStackTrace", onNetworkEventStackTrace);
+};
diff --git a/devtools/shared/resources/legacy-listeners/network-events.js b/devtools/shared/resources/legacy-listeners/network-events.js
new file mode 100644
index 0000000000..c31139e488
--- /dev/null
+++ b/devtools/shared/resources/legacy-listeners/network-events.js
@@ -0,0 +1,151 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const {
+ ResourceWatcher,
+} = require("devtools/shared/resources/resource-watcher");
+
+module.exports = async function({
+ targetList,
+ targetFront,
+ onAvailable,
+ onUpdated,
+}) {
+ // Allow the top level target unconditionnally.
+ // Also allow frame, but only in content toolbox, i.e. still ignore them in
+ // the context of the browser toolbox as we inspect messages via the process
+ // targets
+ // Also ignore workers as they are not supported yet. (see bug 1592584)
+ const listenForFrames = targetList.targetFront.isLocalTab;
+ const isAllowed =
+ targetFront.isTopLevel ||
+ targetFront.targetType === targetList.TYPES.PROCESS ||
+ (targetFront.targetType === targetList.TYPES.FRAME && listenForFrames);
+
+ if (!isAllowed) {
+ return;
+ }
+
+ const webConsoleFront = await targetFront.getFront("console");
+ const resources = new Map();
+
+ function onNetworkEvent(packet) {
+ const actor = packet.eventActor;
+
+ resources.set(actor.actor, {
+ resourceId: actor.channelId,
+ resourceType: ResourceWatcher.TYPES.NETWORK_EVENT,
+ isBlocked: !!actor.blockedReason,
+ types: [],
+ resourceUpdates: {},
+ });
+
+ onAvailable([
+ {
+ resourceId: actor.channelId,
+ resourceType: ResourceWatcher.TYPES.NETWORK_EVENT,
+ timeStamp: actor.timeStamp,
+ actor: actor.actor,
+ startedDateTime: actor.startedDateTime,
+ url: actor.url,
+ method: actor.method,
+ isXHR: actor.isXHR,
+ cause: {
+ type: actor.cause.type,
+ loadingDocumentUri: actor.cause.loadingDocumentUri,
+ },
+ timings: {},
+ private: actor.private,
+ fromCache: actor.fromCache,
+ fromServiceWorker: actor.fromServiceWorker,
+ isThirdPartyTrackingResource: actor.isThirdPartyTrackingResource,
+ referrerPolicy: actor.referrerPolicy,
+ blockedReason: actor.blockedReason,
+ blockingExtension: actor.blockingExtension,
+ stacktraceResourceId:
+ actor.cause.type == "websocket"
+ ? actor.url.replace(/^http/, "ws")
+ : actor.channelId,
+ },
+ ]);
+ }
+
+ function onNetworkEventUpdate(packet) {
+ const resource = resources.get(packet.from);
+
+ if (!resource) {
+ return;
+ }
+
+ const {
+ types,
+ resourceUpdates,
+ resourceId,
+ resourceType,
+ isBlocked,
+ } = resource;
+
+ switch (packet.updateType) {
+ case "responseStart":
+ resourceUpdates.httpVersion = packet.response.httpVersion;
+ resourceUpdates.status = packet.response.status;
+ resourceUpdates.statusText = packet.response.statusText;
+ resourceUpdates.remoteAddress = packet.response.remoteAddress;
+ resourceUpdates.remotePort = packet.response.remotePort;
+ resourceUpdates.mimeType = packet.response.mimeType;
+ resourceUpdates.waitingTime = packet.response.waitingTime;
+ break;
+ case "responseContent":
+ resourceUpdates.contentSize = packet.contentSize;
+ resourceUpdates.transferredSize = packet.transferredSize;
+ resourceUpdates.mimeType = packet.mimeType;
+ resourceUpdates.blockingExtension = packet.blockingExtension;
+ resourceUpdates.blockedReason = packet.blockedReason;
+ break;
+ case "eventTimings":
+ resourceUpdates.totalTime = packet.totalTime;
+ break;
+ case "securityInfo":
+ resourceUpdates.securityState = packet.state;
+ resourceUpdates.isRacing = packet.isRacing;
+ break;
+ }
+
+ resourceUpdates[`${packet.updateType}Available`] = true;
+ types.push(packet.updateType);
+
+ if (isBlocked) {
+ // Blocked requests
+ if (
+ !types.includes("requestHeaders") ||
+ !types.includes("requestCookies")
+ ) {
+ return;
+ }
+ } else if (
+ // Un-blocked requests
+ !types.includes("requestHeaders") ||
+ !types.includes("requestCookies") ||
+ !types.includes("eventTimings") ||
+ !types.includes("responseContent")
+ ) {
+ return;
+ }
+
+ onUpdated([
+ {
+ resourceType,
+ resourceId,
+ resourceUpdates,
+ },
+ ]);
+ }
+
+ webConsoleFront.on("serverNetworkEvent", onNetworkEvent);
+ webConsoleFront.on("serverNetworkUpdateEvent", onNetworkEventUpdate);
+ // Start listening to network events
+ await webConsoleFront.startListeners(["NetworkActivity"]);
+};
diff --git a/devtools/shared/resources/legacy-listeners/platform-messages.js b/devtools/shared/resources/legacy-listeners/platform-messages.js
new file mode 100644
index 0000000000..725b6963b7
--- /dev/null
+++ b/devtools/shared/resources/legacy-listeners/platform-messages.js
@@ -0,0 +1,46 @@
+/* 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 {
+ ResourceWatcher,
+} = require("devtools/shared/resources/resource-watcher");
+
+module.exports = async function({ targetList, targetFront, onAvailable }) {
+ // Only allow the top level target and processes.
+ // Frames can be ignored as logMessage are never sent to them anyway.
+ // Also ignore workers as they are not supported yet. (see bug 1592584)
+ const isAllowed =
+ targetFront.isTopLevel ||
+ targetFront.targetType === targetList.TYPES.PROCESS;
+ if (!isAllowed) {
+ return;
+ }
+
+ const webConsoleFront = await targetFront.getFront("console");
+ if (webConsoleFront.isDestroyed()) {
+ return;
+ }
+
+ // Request notifying about new messages. Here the "PageError" type start listening for
+ // both actual PageErrors (emitted as "pageError" events) as well as LogMessages (
+ // emitted as "logMessage" events). This function only set up the listener on the
+ // webConsoleFront for "logMessage".
+ await webConsoleFront.startListeners(["PageError"]);
+
+ // Fetch already existing messages
+ // /!\ The actor implementation requires to call startListeners("PageError") first /!\
+ const { messages } = await webConsoleFront.getCachedMessages(["LogMessage"]);
+
+ for (const message of messages) {
+ message.resourceType = ResourceWatcher.TYPES.PLATFORM_MESSAGE;
+ }
+ onAvailable(messages);
+
+ webConsoleFront.on("logMessage", message => {
+ message.resourceType = ResourceWatcher.TYPES.PLATFORM_MESSAGE;
+ onAvailable([message]);
+ });
+};
diff --git a/devtools/shared/resources/legacy-listeners/root-node.js b/devtools/shared/resources/legacy-listeners/root-node.js
new file mode 100644
index 0000000000..f5274305b7
--- /dev/null
+++ b/devtools/shared/resources/legacy-listeners/root-node.js
@@ -0,0 +1,63 @@
+/* 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 {
+ ResourceWatcher,
+} = require("devtools/shared/resources/resource-watcher");
+
+module.exports = async function({ targetFront, onAvailable, onDestroyed }) {
+ // XXX: When watching root node for a non top-level target, this will also
+ // ensure the inspector & walker fronts for the target are initialized.
+ // This also implies that we call reparentRemoteFrame on the new walker, which
+ // will create the link between the parent frame NodeFront and the inner
+ // document NodeFront.
+ //
+ // This is not something that will work when the resource is moved to the
+ // server. When it becomes a server side resource, a RootNode would be emitted
+ // directly by the target actor.
+ //
+ // This probably means that the root node resource cannot remain a NodeFront.
+ // It should not be a front and the client should be responsible for
+ // retrieving the corresponding NodeFront.
+ //
+ // The other thing that we are missing with this patch is that we should only
+ // create inspector & walker fronts (and call reparentRemoteFrame) when we get
+ // a RootNode which is directly under an iframe node which is currently
+ // visible and tracked in the markup view.
+ //
+ // For instance, with the following markup:
+ // html
+ // body
+ // div
+ // iframe
+ // remote doc
+ //
+ // If the markup view only sees nodes down to `div`, then the client is not
+ // currently tracking the nodeFront for the `iframe`, and getting a new root
+ // node for the remote document should NOT force the iframe to be tracked on
+ // on the client.
+ //
+ // When we get a RootNode resource, we will need a way to check this before
+ // initializing & reparenting the walker.
+ //
+ if (!targetFront.getTrait("isBrowsingContext")) {
+ // The root-node resource is only available on browsing-context targets.
+ return;
+ }
+
+ const inspectorFront = await targetFront.getFront("inspector");
+ inspectorFront.walker.on("root-available", node => {
+ node.resourceType = ResourceWatcher.TYPES.ROOT_NODE;
+ return onAvailable([node]);
+ });
+
+ inspectorFront.walker.on("root-destroyed", node => {
+ node.resourceType = ResourceWatcher.TYPES.ROOT_NODE;
+ return onDestroyed([node]);
+ });
+
+ await inspectorFront.walker.watchRootNode();
+};
diff --git a/devtools/shared/resources/legacy-listeners/session-storage.js b/devtools/shared/resources/legacy-listeners/session-storage.js
new file mode 100644
index 0000000000..177f26d6b0
--- /dev/null
+++ b/devtools/shared/resources/legacy-listeners/session-storage.js
@@ -0,0 +1,18 @@
+/* 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 {
+ ResourceWatcher,
+} = require("devtools/shared/resources/resource-watcher");
+
+const {
+ makeStorageLegacyListener,
+} = require("devtools/shared/resources/legacy-listeners/storage-utils");
+
+module.exports = makeStorageLegacyListener(
+ "sessionStorage",
+ ResourceWatcher.TYPES.SESSION_STORAGE
+);
diff --git a/devtools/shared/resources/legacy-listeners/source.js b/devtools/shared/resources/legacy-listeners/source.js
new file mode 100644
index 0000000000..5e32287c42
--- /dev/null
+++ b/devtools/shared/resources/legacy-listeners/source.js
@@ -0,0 +1,89 @@
+/* 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 {
+ ResourceWatcher,
+} = require("devtools/shared/resources/resource-watcher");
+
+/**
+ * Emit SOURCE resources, which represents a Javascript source and has the following attributes set on "available":
+ *
+ * - introductionType {null|String}: A string indicating how this source code was introduced into the system.
+ * This will typically be set to "scriptElement", "eval", ...
+ * But this may have many other values:
+ * https://searchfox.org/mozilla-central/rev/ac142717cc067d875e83e4b1316f004f6e063a46/dom/script/ScriptLoader.cpp#2628-2639
+ * https://searchfox.org/mozilla-central/search?q=symbol:_ZN2JS14CompileOptions19setIntroductionTypeEPKc&redirect=false
+ * https://searchfox.org/mozilla-central/rev/ac142717cc067d875e83e4b1316f004f6e063a46/devtools/server/actors/source.js#160-169
+ * - sourceMapBaseURL {String}: Base URL where to look for a source map.
+ * This isn't the source map URL.
+ * - sourceMapURL {null|String}: URL of the source map, if there is one.
+ * - url {null|String}: URL of the source, if it relates to a particular URL.
+ * Evaled sources won't have any related URL.
+ * - isBlackBoxed {Boolean}: Specifying whether the source actor's 'black-boxed' flag is set.
+ * - extensionName {null|String}: If the source comes from an add-on, the add-on name.
+ */
+module.exports = async function({ targetList, targetFront, onAvailable }) {
+ const isBrowserToolbox = targetList.targetFront.isParentProcess;
+ const isNonTopLevelFrameTarget =
+ !targetFront.isTopLevel &&
+ targetFront.targetType === targetList.TYPES.FRAME;
+
+ if (isBrowserToolbox && isNonTopLevelFrameTarget) {
+ // In the BrowserToolbox, non-top-level frame targets are already
+ // debugged via content-process targets.
+ return;
+ }
+
+ const threadFront = await targetFront.getFront("thread");
+
+ // Use a list of all notified SourceFront as we don't have a newSource event for all sources
+ // but we sometime get sources notified both via newSource event *and* sources() method...
+ // We store actor ID instead of SourceFront as it appears that multiple SourceFront for the same
+ // actor are created...
+ const sourcesActorIDCache = new Set();
+
+ // Forward new sources (but also existing ones, see next comment)
+ threadFront.on("newSource", ({ source }) => {
+ if (sourcesActorIDCache.has(source.actor)) {
+ return;
+ }
+ sourcesActorIDCache.add(source.actor);
+ // source is a SourceActor's form, add the resourceType attribute on it
+ source.resourceType = ResourceWatcher.TYPES.SOURCE;
+ onAvailable([source]);
+ });
+
+ // Forward already existing sources
+ // Note that calling `sources()` will end up emitting `newSource` event for all existing sources.
+ // But not in some cases, for example, when the thread is already paused.
+ // (And yes, it means that already existing sources can be transfered twice over the wire)
+ //
+ // Also, browser_ext_devtools_inspectedWindow_targetSwitch.js creates many top level targets,
+ // for which the SourceMapURLService will fetch sources. But these targets are destroyed while
+ // the test is running and when they are, we purge all pending requests, including this one.
+ // So ignore any error if this request failed on destruction.
+ let sources;
+ try {
+ sources = await threadFront.sources();
+ } catch (e) {
+ if (threadFront.isDestroyed()) {
+ return;
+ }
+ throw e;
+ }
+
+ // Note that `sources()` doesn't encapsulate SourceFront into a `source` attribute
+ // while `newSource` event does.
+ sources = sources.filter(source => {
+ return !sourcesActorIDCache.has(source.actor);
+ });
+ for (const source of sources) {
+ sourcesActorIDCache.add(source.actor);
+ // source is a SourceActor's form, add the resourceType attribute on it
+ source.resourceType = ResourceWatcher.TYPES.SOURCE;
+ }
+ onAvailable(sources);
+};
diff --git a/devtools/shared/resources/legacy-listeners/storage-utils.js b/devtools/shared/resources/legacy-listeners/storage-utils.js
new file mode 100644
index 0000000000..c1b96755fd
--- /dev/null
+++ b/devtools/shared/resources/legacy-listeners/storage-utils.js
@@ -0,0 +1,97 @@
+/* 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";
+
+// Filters "stores-update" response to only include events for
+// the storage type we desire
+function getFilteredStorageEvents(updates, storageType) {
+ const filteredUpdate = Object.create(null);
+
+ // updateType will be "added", "changed", or "deleted"
+ for (const updateType in updates) {
+ if (updates[updateType][storageType]) {
+ if (!filteredUpdate[updateType]) {
+ filteredUpdate[updateType] = {};
+ }
+ filteredUpdate[updateType][storageType] =
+ updates[updateType][storageType];
+ }
+ }
+
+ return Object.keys(filteredUpdate).length > 0 ? filteredUpdate : null;
+}
+
+// This is a mixin that provides all shared cored between storage legacy
+// listeners
+function makeStorageLegacyListener(storageKey, storageType) {
+ return async function({
+ targetList,
+ targetType,
+ targetFront,
+ onAvailable,
+ onUpdated,
+ onDestroyed,
+ }) {
+ if (!targetFront.isTopLevel) {
+ return;
+ }
+
+ const storageFront = await targetFront.getFront("storage");
+ const storageTypes = await storageFront.listStores();
+
+ // Initialization
+ const storage = storageTypes[storageKey];
+
+ // extension storage might not be available
+ if (!storage) {
+ return;
+ }
+
+ storage.resourceType = storageType;
+ storage.resourceKey = storageKey;
+ // storage resources are singletons, and thus we can set their ID to their
+ // storage type
+ storage.resourceId = storageType;
+ onAvailable([storage]);
+
+ // Any item in the store gets updated
+ storageFront.on("stores-update", response => {
+ response = getFilteredStorageEvents(response, storageKey);
+ if (!response) {
+ return;
+ }
+ onUpdated([
+ {
+ resourceId: storageType,
+ resourceType: storageType,
+ resourceKey: storageKey,
+ changed: response.changed,
+ added: response.added,
+ deleted: response.deleted,
+ },
+ ]);
+ });
+
+ // When a store gets cleared
+ storageFront.on("stores-cleared", response => {
+ const cleared = response[storageKey];
+
+ if (!cleared) {
+ return;
+ }
+
+ onDestroyed([
+ {
+ resourceId: storageType,
+ resourceType: storageType,
+ resourceKey: storageKey,
+ clearedHostsOrPaths: cleared,
+ },
+ ]);
+ });
+ };
+}
+
+module.exports = { makeStorageLegacyListener };
diff --git a/devtools/shared/resources/legacy-listeners/stylesheet.js b/devtools/shared/resources/legacy-listeners/stylesheet.js
new file mode 100644
index 0000000000..d5c87ab2e9
--- /dev/null
+++ b/devtools/shared/resources/legacy-listeners/stylesheet.js
@@ -0,0 +1,133 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const {
+ ResourceWatcher,
+} = require("devtools/shared/resources/resource-watcher");
+
+module.exports = async function({ targetFront, onAvailable, onUpdated }) {
+ if (!targetFront.hasActor("styleSheets")) {
+ return;
+ }
+
+ const onStyleSheetAdded = async (styleSheet, isNew, fileName) => {
+ const onMediaRules = styleSheet.getMediaRules();
+ const resource = toResource(styleSheet, isNew, fileName);
+
+ let previousMediaRules = [];
+
+ function updateMediaRule(index, rule) {
+ onUpdated([
+ {
+ resourceType: resource.resourceType,
+ resourceId: resource.resourceId,
+ updateType: "matches-change",
+ nestedResourceUpdates: [
+ {
+ path: ["mediaRules", index],
+ value: rule,
+ },
+ ],
+ },
+ ]);
+ }
+
+ function addMatchesChangeListener(mediaRules) {
+ for (const rule of previousMediaRules) {
+ rule.destroy();
+ }
+
+ mediaRules.forEach((rule, index) => {
+ rule.on("matches-change", matches => updateMediaRule(index, rule));
+ });
+
+ previousMediaRules = mediaRules;
+ }
+
+ styleSheet.on("style-applied", (kind, styleSheetFront, cause) => {
+ onUpdated([
+ {
+ resourceType: resource.resourceType,
+ resourceId: resource.resourceId,
+ updateType: "style-applied",
+ event: {
+ cause,
+ kind,
+ },
+ },
+ ]);
+ });
+
+ styleSheet.on("property-change", (property, value) => {
+ onUpdated([
+ {
+ resourceType: resource.resourceType,
+ resourceId: resource.resourceId,
+ updateType: "property-change",
+ resourceUpdates: { [property]: value },
+ },
+ ]);
+ });
+
+ styleSheet.on("media-rules-changed", mediaRules => {
+ addMatchesChangeListener(mediaRules);
+ onUpdated([
+ {
+ resourceType: resource.resourceType,
+ resourceId: resource.resourceId,
+ updateType: "media-rules-changed",
+ resourceUpdates: { mediaRules },
+ },
+ ]);
+ });
+
+ try {
+ resource.mediaRules = await onMediaRules;
+ addMatchesChangeListener(resource.mediaRules);
+ } catch (e) {
+ // There are cases that the stylesheet front was destroyed already when/while calling
+ // methods of stylesheet.
+ console.warn("fetching media rules failed", e);
+ }
+
+ return resource;
+ };
+
+ const styleSheetsFront = await targetFront.getFront("stylesheets");
+ try {
+ const styleSheets = await styleSheetsFront.getStyleSheets();
+ onAvailable(
+ await Promise.all(
+ styleSheets.map(styleSheet =>
+ onStyleSheetAdded(styleSheet, false, null)
+ )
+ )
+ );
+
+ styleSheetsFront.on(
+ "stylesheet-added",
+ async (styleSheet, isNew, fileName) => {
+ onAvailable([await onStyleSheetAdded(styleSheet, isNew, fileName)]);
+ }
+ );
+ } catch (e) {
+ // There are cases that the stylesheet front was destroyed already when/while calling
+ // methods of stylesheet.
+ // Especially, since source map url service starts to watch the stylesheet resources
+ // lazily, the possibility will be extended.
+ console.warn("fetching stylesheets failed", e);
+ }
+};
+
+function toResource(styleSheet, isNew, fileName) {
+ Object.assign(styleSheet, {
+ resourceId: styleSheet.actorID,
+ resourceType: ResourceWatcher.TYPES.STYLESHEET,
+ isNew,
+ fileName,
+ });
+ return styleSheet;
+}
diff --git a/devtools/shared/resources/legacy-listeners/websocket.js b/devtools/shared/resources/legacy-listeners/websocket.js
new file mode 100644
index 0000000000..e070287c4c
--- /dev/null
+++ b/devtools/shared/resources/legacy-listeners/websocket.js
@@ -0,0 +1,62 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const {
+ ResourceWatcher,
+} = require("devtools/shared/resources/resource-watcher");
+
+module.exports = async function({ targetFront, onAvailable }) {
+ if (!targetFront.hasActor("webSocket")) {
+ return;
+ }
+
+ const webSocketFront = await targetFront.getFront("webSocket");
+ webSocketFront.startListening();
+
+ webSocketFront.on(
+ "webSocketOpened",
+ (httpChannelId, effectiveURI, protocols, extensions) => {
+ const resource = toResource("webSocketOpened", {
+ httpChannelId,
+ effectiveURI,
+ protocols,
+ extensions,
+ });
+ onAvailable([resource]);
+ }
+ );
+
+ webSocketFront.on(
+ "webSocketClosed",
+ (httpChannelId, wasClean, code, reason) => {
+ const resource = toResource("webSocketClosed", {
+ httpChannelId,
+ wasClean,
+ code,
+ reason,
+ });
+ onAvailable([resource]);
+ }
+ );
+
+ webSocketFront.on("frameReceived", (httpChannelId, data) => {
+ const resource = toResource("frameReceived", { httpChannelId, data });
+ onAvailable([resource]);
+ });
+
+ webSocketFront.on("frameSent", (httpChannelId, data) => {
+ const resource = toResource("frameSent", { httpChannelId, data });
+ onAvailable([resource]);
+ });
+};
+
+function toResource(wsMessageType, eventParams) {
+ return {
+ resourceType: ResourceWatcher.TYPES.WEBSOCKET,
+ wsMessageType,
+ ...eventParams,
+ };
+}
diff --git a/devtools/shared/resources/legacy-target-watchers/legacy-processes-watcher.js b/devtools/shared/resources/legacy-target-watchers/legacy-processes-watcher.js
new file mode 100644
index 0000000000..d798b72c54
--- /dev/null
+++ b/devtools/shared/resources/legacy-target-watchers/legacy-processes-watcher.js
@@ -0,0 +1,73 @@
+/* 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(targetList, onTargetAvailable, onTargetDestroyed) {
+ this.targetList = targetList;
+ this.rootFront = targetList.rootFront;
+ this.target = targetList.targetFront;
+
+ this.onTargetAvailable = onTargetAvailable;
+ this.onTargetDestroyed = onTargetDestroyed;
+
+ this.descriptors = new Set();
+ this._processListChanged = this._processListChanged.bind(this);
+ }
+
+ async _processListChanged() {
+ if (this.targetList.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/resources/legacy-target-watchers/legacy-serviceworkers-watcher.js b/devtools/shared/resources/legacy-target-watchers/legacy-serviceworkers-watcher.js
new file mode 100644
index 0000000000..2578ab020d
--- /dev/null
+++ b/devtools/shared/resources/legacy-target-watchers/legacy-serviceworkers-watcher.js
@@ -0,0 +1,269 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+// eslint-disable-next-line mozilla/reject-some-requires
+const { WorkersListener } = require("devtools/client/shared/workers-listener");
+
+const {
+ LegacyWorkersWatcher,
+} = require("devtools/shared/resources/legacy-target-watchers/legacy-workers-watcher");
+
+class LegacyServiceWorkersWatcher extends LegacyWorkersWatcher {
+ constructor(targetList, onTargetAvailable, onTargetDestroyed) {
+ super(targetList, onTargetAvailable, onTargetDestroyed);
+ this._registrations = [];
+ this._processTargets = new Set();
+
+ // 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._onNavigate = this._onNavigate.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.targetList.targetFront;
+
+ 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.target.isLocalTab) {
+ // Note that we rely on "navigate" 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".
+ this.target.on("navigate", this._onNavigate);
+ }
+
+ await super.listen();
+ }
+
+ // Override from LegacyWorkersWatcher.
+ unlisten() {
+ this._workersListener.removeListener(this._onRegistrationListChanged);
+
+ if (this.target.isLocalTab) {
+ this.target.off("navigate", this._onNavigate);
+ }
+
+ super.unlisten();
+ }
+
+ // Override from LegacyWorkersWatcher.
+ async _onProcessAvailable({ targetFront }) {
+ if (this.target.isLocalTab) {
+ // 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.
+ const origin = new URL(this.target.url).origin;
+ try {
+ // To support early breakpoint we need to setup the
+ // `pauseMatchingServiceWorkers` mechanism in each process.
+ await targetFront.pauseMatchingServiceWorkers({ 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.targetList.destroyServiceWorkersOnNavigation;
+ }
+
+ _onProcessDestroyed({ targetFront }) {
+ this._processTargets.delete(targetFront);
+ return super._onProcessDestroyed({ targetFront });
+ }
+
+ _onNavigate() {
+ const allServiceWorkerTargets = this._getAllServiceWorkerTargets();
+ const shouldDestroy = this._shouldDestroyTargetsOnNavigation();
+
+ for (const target of allServiceWorkerTargets) {
+ const isRegisteredBefore = this.targetList.isTargetRegistered(target);
+ if (shouldDestroy && isRegisteredBefore) {
+ this.onTargetDestroyed(target);
+ }
+
+ // Note: we call isTargetRegistered again because calls to
+ // onTargetDestroyed might have modified the list of registered targets.
+ const isRegisteredAfter = this.targetList.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.targetList.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.targetList.isTargetRegistered(target)) {
+ // Only emit onTargetDestroyed if it wasn't already done by
+ // onNavigate (ie the target is still tracked by TargetList)
+ 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.target.isParentProcess) {
+ // All registrations are valid for main process debugging.
+ return true;
+ }
+
+ if (!this.target.isLocalTab) {
+ // No support for service worker targets outside of main process & local
+ // 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 = new URL(this.target.url).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/resources/legacy-target-watchers/legacy-sharedworkers-watcher.js b/devtools/shared/resources/legacy-target-watchers/legacy-sharedworkers-watcher.js
new file mode 100644
index 0000000000..c1fa7c5130
--- /dev/null
+++ b/devtools/shared/resources/legacy-target-watchers/legacy-sharedworkers-watcher.js
@@ -0,0 +1,21 @@
+/* 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("devtools/shared/resources/legacy-target-watchers/legacy-workers-watcher");
+
+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/resources/legacy-target-watchers/legacy-workers-watcher.js b/devtools/shared/resources/legacy-target-watchers/legacy-workers-watcher.js
new file mode 100644
index 0000000000..0b0b07a897
--- /dev/null
+++ b/devtools/shared/resources/legacy-target-watchers/legacy-workers-watcher.js
@@ -0,0 +1,225 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+loader.lazyRequireGetter(
+ this,
+ "TargetList",
+ "devtools/shared/resources/target-list",
+ true
+);
+
+const {
+ LegacyProcessesWatcher,
+} = require("devtools/shared/resources/legacy-target-watchers/legacy-processes-watcher");
+
+class LegacyWorkersWatcher {
+ constructor(targetList, onTargetAvailable, onTargetDestroyed) {
+ this.targetList = targetList;
+ this.rootFront = targetList.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.targetList.isDestroyed()) {
+ return;
+ }
+
+ const { workers } = await front.listWorkers();
+
+ // 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.targetList.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.targetList.targetFront;
+
+ if (this.target.isParentProcess) {
+ await this.targetList.watchTargets(
+ [TargetList.TYPES.PROCESS],
+ this._onProcessAvailable,
+ 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.targetList,
+ 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, new Set());
+ this._workerListChangedListener = this._workerListChanged.bind(
+ this,
+ this.target
+ );
+ this.target.on("workerListChanged", this._workerListChangedListener);
+ await this._workerListChanged(this.target);
+ }
+
+ _getProcessTargets() {
+ return this.targetList.getAllTargets([TargetList.TYPES.PROCESS]);
+ }
+
+ unlisten() {
+ // Stop listening for new process targets.
+ if (this.target.isParentProcess) {
+ this.targetList.unwatchTargets(
+ [TargetList.TYPES.PROCESS],
+ this._onProcessAvailable,
+ 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 TargetList 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);
+ 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/resources/legacy-target-watchers/moz.build b/devtools/shared/resources/legacy-target-watchers/moz.build
new file mode 100644
index 0000000000..60fdd7ec22
--- /dev/null
+++ b/devtools/shared/resources/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/resources/moz.build b/devtools/shared/resources/moz.build
new file mode 100644
index 0000000000..5e709be68d
--- /dev/null
+++ b/devtools/shared/resources/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 += [
+ "legacy-listeners",
+ "legacy-target-watchers",
+ "transformers",
+]
+
+DevToolsModules(
+ "resource-watcher.js",
+ "target-list.js",
+)
+
+if CONFIG["MOZ_BUILD_APP"] != "mobile/android":
+ BROWSER_CHROME_MANIFESTS += ["tests/browser.ini"]
diff --git a/devtools/shared/resources/resource-watcher.js b/devtools/shared/resources/resource-watcher.js
new file mode 100644
index 0000000000..02c7f07dca
--- /dev/null
+++ b/devtools/shared/resources/resource-watcher.js
@@ -0,0 +1,925 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const { throttle } = require("devtools/shared/throttle");
+
+class ResourceWatcher {
+ /**
+ * This class helps retrieving existing and listening to resources.
+ * A resource is something that:
+ * - the target you are debugging exposes
+ * - can be created as early as the process/worker/page starts loading
+ * - can already exist, or will be created later on
+ * - doesn't require any user data to be fetched, only a type/category
+ *
+ * @param {TargetList} targetList
+ * A TargetList instance, which helps communicating to the backend
+ * in order to iterate and listen over the requested resources.
+ */
+
+ constructor(targetList) {
+ this.targetList = targetList;
+
+ this._onTargetAvailable = this._onTargetAvailable.bind(this);
+ this._onTargetDestroyed = this._onTargetDestroyed.bind(this);
+
+ this._onResourceAvailable = this._onResourceAvailable.bind(this);
+ this._onResourceDestroyed = this._onResourceDestroyed.bind(this);
+
+ // Array of all the currently registered watchers, which contains object with attributes:
+ // - {String} resources: list of all resource watched by this one watcher
+ // - {Function} onAvailable: watcher's function to call when a new resource is available
+ // - {Function} onUpdated: watcher's function to call when a resource has been updated
+ // - {Function} onDestroyed: watcher's function to call when a resource is destroyed
+ this._watchers = [];
+
+ // Cache for all resources by the order that the resource was taken.
+ this._cache = [];
+ this._listenerCount = new Map();
+
+ // WeakMap used to avoid starting a legacy listener twice for the same
+ // target + resource-type pair. Legacy listener creation can be subject to
+ // race conditions.
+ // Maps a target front to an array of resource types.
+ this._existingLegacyListeners = new WeakMap();
+
+ this._notifyWatchers = this._notifyWatchers.bind(this);
+ this._throttledNotifyWatchers = throttle(this._notifyWatchers, 100);
+ }
+
+ get watcherFront() {
+ return this.targetList.watcherFront;
+ }
+
+ /**
+ * Return all specified resources cached in this watcher.
+ *
+ * @param {String} resourceType
+ * @return {Array} resources cached in this watcher
+ */
+ getAllResources(resourceType) {
+ return this._cache.filter(r => r.resourceType === resourceType);
+ }
+
+ /**
+ * Return the specified resource cached in this watcher.
+ *
+ * @param {String} resourceType
+ * @param {String} resourceId
+ * @return {Object} resource cached in this watcher
+ */
+ getResourceById(resourceType, resourceId) {
+ return this._cache.find(
+ r => r.resourceType === resourceType && r.resourceId === resourceId
+ );
+ }
+
+ /**
+ * Request to start retrieving all already existing instances of given
+ * type of resources and also start watching for the one to be created after.
+ *
+ * @param {Array:string} resources
+ * List of all resources which should be fetched and observed.
+ * @param {Object} options
+ * - {Function} onAvailable: This attribute is mandatory.
+ * Function which will be called once per existing
+ * resource and each time a resource is created.
+ * - {Function} onUpdated: This attribute is optional.
+ * Function which will be called each time a resource,
+ * previously notified via onAvailable is updated.
+ * - {Function} onDestroyed: This attribute is optional.
+ * Function which will be called each time a resource in
+ * the remote target is destroyed.
+ * - {boolean} ignoreExistingResources:
+ * This attribute is optional. Default value is false.
+ * If set to true, onAvailable won't be called with
+ * existing resources.
+ */
+ async watchResources(resources, options) {
+ const {
+ onAvailable,
+ onUpdated,
+ onDestroyed,
+ ignoreExistingResources = false,
+ } = options;
+
+ if (typeof onAvailable !== "function") {
+ throw new Error(
+ "ResourceWatcher.watchResources expects an onAvailable function as argument"
+ );
+ }
+
+ // Bug 1675763: Watcher actor is not available in all situations yet.
+ if (!this._listenerRegistered && this.watcherFront) {
+ this._listenerRegistered = true;
+ // Resources watched from the parent process will be emitted on the Watcher Actor.
+ // So that we also have to listen for this event on it, in addition to all targets.
+ this.watcherFront.on(
+ "resource-available-form",
+ this._onResourceAvailable.bind(this, {
+ watcherFront: this.watcherFront,
+ })
+ );
+ this.watcherFront.on(
+ "resource-updated-form",
+ this._onResourceUpdated.bind(this, { watcherFront: this.watcherFront })
+ );
+ this.watcherFront.on(
+ "resource-destroyed-form",
+ this._onResourceDestroyed.bind(this, {
+ watcherFront: this.watcherFront,
+ })
+ );
+ }
+
+ // First ensuring enabling listening to targets.
+ // This will call onTargetAvailable for all already existing targets,
+ // as well as for the one created later.
+ // Do this *before* calling _startListening in order to register
+ // "resource-available" listener before requesting for the resources in _startListening.
+ await this._watchAllTargets();
+
+ for (const resource of resources) {
+ // If we are registering the first listener, so start listening from the server about
+ // this one resource.
+ if (!this._hasListenerForResource(resource)) {
+ await this._startListening(resource);
+ }
+ }
+
+ // The resource cache is immediately filled when receiving the sources, but they are
+ // emitted with a delay due to throttling. Since the cache can contain resources that
+ // will soon be emitted, we have to flush it before adding the new listeners.
+ // Otherwise forwardCacheResources might emit resources that will also be emitted by
+ // the next `_notifyWatchers` call done when calling `_startListening`, which will pull the
+ // "already existing" resources.
+ this._notifyWatchers();
+
+ // Register the watcher just after calling _startListening in order to avoid it being called
+ // for already existing resources, which will optionally be notified via _forwardCachedResources
+ this._watchers.push({
+ resources,
+ onAvailable,
+ onUpdated,
+ onDestroyed,
+ pendingEvents: [],
+ });
+
+ if (!ignoreExistingResources) {
+ await this._forwardCachedResources(resources, onAvailable);
+ }
+ }
+
+ /**
+ * Stop watching for given type of resources.
+ * See `watchResources` for the arguments as both methods receive the same.
+ * Note that `onUpdated` and `onDestroyed` attributes of `options` aren't used here.
+ * Only `onAvailable` attribute is looked up and we unregister all the other registered callbacks
+ * when a matching available callback is found.
+ */
+ unwatchResources(resources, options) {
+ const { onAvailable } = options;
+
+ if (typeof onAvailable !== "function") {
+ throw new Error(
+ "ResourceWatcher.unwatchResources expects an onAvailable function as argument"
+ );
+ }
+
+ const watchedResources = [];
+ for (const resource of resources) {
+ if (this._hasListenerForResource(resource)) {
+ watchedResources.push(resource);
+ }
+ }
+ // Unregister the callbacks from the _watchers registry
+ for (const watcherEntry of this._watchers) {
+ // onAvailable is the only mandatory argument which ends up being used to match
+ // the right watcher entry.
+ if (watcherEntry.onAvailable == onAvailable) {
+ // Remove all resources that we stop watching. We may still watch for some others.
+ watcherEntry.resources = watcherEntry.resources.filter(resourceType => {
+ return !resources.includes(resourceType);
+ });
+ }
+ }
+ this._watchers = this._watchers.filter(entry => {
+ // Remove entries entirely if it isn't watching for any resource type
+ return entry.resources.length > 0;
+ });
+
+ // Stop listening to all resources that no longer have any watcher callback
+ for (const resource of watchedResources) {
+ if (!this._hasListenerForResource(resource)) {
+ this._stopListening(resource);
+ }
+ }
+
+ // Stop watching for targets if we removed the last listener.
+ let listeners = 0;
+ for (const count of this._listenerCount.values()) {
+ listeners += count;
+ }
+ if (listeners <= 0) {
+ this._unwatchAllTargets();
+ }
+ }
+
+ /**
+ * Start watching for all already existing and future targets.
+ *
+ * We are using ALL_TYPES, but this won't force listening to all types.
+ * It will only listen for types which are defined by `TargetList.startListening`.
+ */
+ async _watchAllTargets() {
+ if (!this._watchTargetsPromise) {
+ this._watchTargetsPromise = this.targetList.watchTargets(
+ this.targetList.ALL_TYPES,
+ this._onTargetAvailable,
+ this._onTargetDestroyed
+ );
+ }
+ return this._watchTargetsPromise;
+ }
+
+ _unwatchAllTargets() {
+ if (!this._watchTargetsPromise) {
+ return;
+ }
+ this._watchTargetsPromise = null;
+ this.targetList.unwatchTargets(
+ this.targetList.ALL_TYPES,
+ this._onTargetAvailable,
+ this._onTargetDestroyed
+ );
+ }
+
+ /**
+ * Method called by the TargetList for each already existing or target which has just been created.
+ *
+ * @param {Front} targetFront
+ * The Front of the target that is available.
+ * This Front inherits from TargetMixin and is typically
+ * composed of a BrowsingContextTargetFront or ContentProcessTargetFront.
+ */
+ async _onTargetAvailable({ targetFront, isTargetSwitching }) {
+ const resources = [];
+ if (isTargetSwitching) {
+ this._onWillNavigate(targetFront);
+ // WatcherActor currently only watches additional frame targets and
+ // explicitely ignores top level one that may be created when navigating
+ // to a new process.
+ // In order to keep working resources that are being watched via the
+ // Watcher actor, we have to unregister and re-register the resource
+ // types. This will force calling `Resources.watchResources` on the new top
+ // level target.
+ for (const resourceType of Object.values(ResourceWatcher.TYPES)) {
+ // ...which has at least one listener...
+ if (!this._listenerCount.get(resourceType)) {
+ continue;
+ }
+ await this._stopListening(resourceType, { bypassListenerCount: true });
+ resources.push(resourceType);
+ }
+ }
+
+ if (targetFront.isDestroyed()) {
+ return;
+ }
+
+ targetFront.on("will-navigate", () => this._onWillNavigate(targetFront));
+
+ // If we are target switching, we already stop & start listening to all the
+ // currently monitored resources.
+ if (!isTargetSwitching) {
+ // For each resource type...
+ for (const resourceType of Object.values(ResourceWatcher.TYPES)) {
+ // ...which has at least one listener...
+ if (!this._listenerCount.get(resourceType)) {
+ continue;
+ }
+ // ...request existing resource and new one to come from this one target
+ // *but* only do that for backward compat, where we don't have the watcher API
+ // (See bug 1626647)
+ await this._watchResourcesForTarget(targetFront, resourceType);
+ }
+ }
+
+ // Compared to the TargetList and Watcher.watchTargets,
+ // We do call Watcher.watchResources, but the events are fired on the target.
+ // That's because the Watcher runs in the parent process/main thread, while resources
+ // are available from the target's process/thread.
+ targetFront.on(
+ "resource-available-form",
+ this._onResourceAvailable.bind(this, { targetFront })
+ );
+ targetFront.on(
+ "resource-updated-form",
+ this._onResourceUpdated.bind(this, { targetFront })
+ );
+ targetFront.on(
+ "resource-destroyed-form",
+ this._onResourceDestroyed.bind(this, { targetFront })
+ );
+
+ if (isTargetSwitching) {
+ for (const resourceType of resources) {
+ await this._startListening(resourceType, { bypassListenerCount: true });
+ }
+ }
+ }
+
+ /**
+ * Method called by the TargetList when a target has just been destroyed
+ * See _onTargetAvailable for arguments, they are the same.
+ */
+ _onTargetDestroyed({ targetFront }) {
+ // Clear the map of legacy listeners for this target.
+ this._existingLegacyListeners.set(targetFront, []);
+
+ //TODO: Is there a point in doing anything else?
+ //
+ // We could remove the available/destroyed event, but as the target is destroyed
+ // its listeners will be destroyed anyway.
+ }
+
+ /**
+ * Method called either by:
+ * - the backward compatibility code (LegacyListeners)
+ * - target actors RDP events
+ * whenever an already existing resource is being listed or when a new one
+ * has been created.
+ *
+ * @param {Object} source
+ * A dictionary object with only one of these two attributes:
+ * - targetFront: a Target Front, if the resource is watched from the target process or thread
+ * - watcherFront: a Watcher Front, if the resource is watched from the parent process
+ * @param {Array<json/Front>} resources
+ * Depending on the resource Type, it can be an Array composed of either JSON objects or Fronts,
+ * which describes the resource.
+ */
+ async _onResourceAvailable({ targetFront, watcherFront }, resources) {
+ for (let resource of resources) {
+ const { resourceType } = resource;
+
+ if (watcherFront) {
+ targetFront = await this._getTargetForWatcherResource(resource);
+ if (!targetFront) {
+ continue;
+ }
+ }
+
+ // Put the targetFront on the resource for easy retrieval.
+ // (Resources from the legacy listeners may already have the attribute set)
+ if (!resource.targetFront) {
+ resource.targetFront = targetFront;
+ }
+
+ if (ResourceTransformers[resourceType]) {
+ resource = ResourceTransformers[resourceType]({
+ resource,
+ targetList: this.targetList,
+ targetFront,
+ watcherFront: this.watcherFront,
+ });
+ }
+
+ this._queueResourceEvent("available", resourceType, resource);
+
+ this._cache.push(resource);
+ }
+ this._throttledNotifyWatchers();
+ }
+
+ /**
+ * Method called either by:
+ * - the backward compatibility code (LegacyListeners)
+ * - target actors RDP events
+ * Called everytime a resource is updated in the remote target.
+ *
+ * @param {Object} source
+ * Please see _onResourceAvailable for this parameter.
+ * @param {Array<Object>} updates
+ * Depending on the listener.
+ *
+ * Among the element in the array, the following attributes are given special handling.
+ * - resourceType {String}:
+ * The type of resource to be updated.
+ * - resourceId {String}:
+ * The id of resource to be updated.
+ * - resourceUpdates {Object}:
+ * If resourceUpdates is in the element, a cached resource specified by resourceType
+ * and resourceId is updated by Object.assign(cachedResource, resourceUpdates).
+ * - nestedResourceUpdates {Object}:
+ * If `nestedResourceUpdates` is passed, update one nested attribute with a new value
+ * This allows updating one attribute of an object stored in a resource's attribute,
+ * as well as adding new elements to arrays.
+ * `path` is an array mentioning all nested attribute to walk through.
+ * `value` is the new nested attribute value to set.
+ *
+ * And also, the element is passed to the listener as it is as โ€œupdateโ€ object.
+ * So if we don't want to update a cached resource but have information want to
+ * pass on to the listener, can pass it on using attributes other than the ones
+ * listed above.
+ * For example, if the element consists of like
+ * "{ resourceType:โ€ฆ resourceId:โ€ฆ, testValue: โ€œtestโ€, }โ€,
+ * the listener can receive the value as follows.
+ *
+ * onResourceUpdate({ update }) {
+ * console.log(update.testValue); // โ€œtestโ€ should be displayed
+ * }
+ */
+ async _onResourceUpdated({ targetFront, watcherFront }, updates) {
+ for (const update of updates) {
+ const {
+ resourceType,
+ resourceId,
+ resourceUpdates,
+ nestedResourceUpdates,
+ } = update;
+
+ if (!resourceId) {
+ console.warn(`Expected resource ${resourceType} to have a resourceId`);
+ }
+
+ const existingResource = this._cache.find(
+ cachedResource =>
+ cachedResource.resourceType === resourceType &&
+ cachedResource.resourceId === resourceId
+ );
+
+ if (!existingResource) {
+ continue;
+ }
+
+ if (resourceUpdates) {
+ Object.assign(existingResource, resourceUpdates);
+ }
+
+ if (nestedResourceUpdates) {
+ for (const { path, value } of nestedResourceUpdates) {
+ let target = existingResource;
+
+ for (let i = 0; i < path.length - 1; i++) {
+ target = target[path[i]];
+ }
+
+ target[path[path.length - 1]] = value;
+ }
+ }
+ this._queueResourceEvent("updated", resourceType, {
+ resource: existingResource,
+ update,
+ });
+ }
+ this._throttledNotifyWatchers();
+ }
+
+ /**
+ * Called everytime a resource is destroyed in the remote target.
+ * See _onResourceAvailable for the argument description.
+ */
+ async _onResourceDestroyed({ targetFront, watcherFront }, resources) {
+ for (const resource of resources) {
+ const { resourceType, resourceId } = resource;
+
+ let index = -1;
+ if (resourceId) {
+ index = this._cache.findIndex(
+ cachedResource =>
+ cachedResource.resourceType == resourceType &&
+ cachedResource.resourceId == resourceId
+ );
+ } else {
+ index = this._cache.indexOf(resource);
+ }
+ if (index >= 0) {
+ this._cache.splice(index, 1);
+ } else {
+ console.warn(
+ `Resource ${resourceId || ""} of ${resourceType} was not found.`
+ );
+ }
+
+ this._queueResourceEvent("destroyed", resourceType, resource);
+ }
+ this._throttledNotifyWatchers();
+ }
+
+ /**
+ * Check if there is at least one listener registered for the given resource type.
+ *
+ * @param {String} resourceType
+ * Watched resource type
+ */
+ _hasListenerForResource(resourceType) {
+ return this._watchers.some(({ resources }) => {
+ return resources.includes(resourceType);
+ });
+ }
+
+ _queueResourceEvent(callbackType, resourceType, update) {
+ for (const { resources, pendingEvents } of this._watchers) {
+ // This watcher doesn't listen to this type of resource
+ if (!resources.includes(resourceType)) {
+ continue;
+ }
+ // If we receive a new event of the same type, accumulate the new update in the last event
+ if (pendingEvents.length > 0) {
+ const lastEvent = pendingEvents[pendingEvents.length - 1];
+ if (lastEvent.callbackType == callbackType) {
+ lastEvent.updates.push(update);
+ continue;
+ }
+ }
+ // Otherwise, pile up a new event, which will force calling watcher
+ // callback a new time
+ pendingEvents.push({
+ callbackType,
+ updates: [update],
+ });
+ }
+ }
+
+ /**
+ * Flush the pending event and notify all the currently registered watchers
+ * about all the available, updated and destroyed events that have been accumulated in
+ * `_watchers`'s `pendingEvents` arrays.
+ */
+ _notifyWatchers() {
+ for (const watcherEntry of this._watchers) {
+ const {
+ onAvailable,
+ onUpdated,
+ onDestroyed,
+ pendingEvents,
+ } = watcherEntry;
+ // Immediately clear the buffer in order to avoid possible races, where an event listener
+ // would end up somehow adding a new throttled resource
+ watcherEntry.pendingEvents = [];
+
+ for (const { callbackType, updates } of pendingEvents) {
+ try {
+ if (callbackType == "available") {
+ onAvailable(updates);
+ } else if (callbackType == "updated" && onUpdated) {
+ onUpdated(updates);
+ } else if (callbackType == "destroyed" && onDestroyed) {
+ onDestroyed(updates);
+ }
+ } catch (e) {
+ console.error(
+ "Exception while calling a ResourceWatcher",
+ callbackType,
+ "callback",
+ ":",
+ e
+ );
+ }
+ }
+ }
+ }
+
+ // Compute the target front if the resource comes from the Watcher Actor.
+ // (`targetFront` will be null as the watcher is in the parent process
+ // and targets are in distinct processes)
+ _getTargetForWatcherResource(resource) {
+ const { browsingContextID, resourceType } = resource;
+
+ // Resource emitted from the Watcher Actor should all have a
+ // browsingContextID attribute
+ if (!browsingContextID) {
+ console.error(
+ `Resource of ${resourceType} is missing a browsingContextID attribute`
+ );
+ return null;
+ }
+ return this.watcherFront.getBrowsingContextTarget(browsingContextID);
+ }
+
+ _onWillNavigate(targetFront) {
+ if (targetFront.isTopLevel) {
+ this._cache = [];
+ return;
+ }
+
+ this._cache = this._cache.filter(
+ cachedResource => cachedResource.targetFront !== targetFront
+ );
+ }
+
+ /**
+ * Tells if the server supports listening to the given resource type
+ * via the watcher actor's watchResources method.
+ *
+ * @return {Boolean} True, if the server supports this type.
+ */
+ hasResourceWatcherSupport(resourceType) {
+ return this.watcherFront?.traits?.resources?.[resourceType];
+ }
+
+ /**
+ * Tells if the server supports listening to the given resource type
+ * via the watcher actor's watchResources method, and that, for a specific
+ * target.
+ *
+ * @return {Boolean} True, if the server supports this type.
+ */
+ _hasResourceWatcherSupportForTarget(resourceType, targetFront) {
+ // First check if the watcher supports this target type.
+ // If it doesn't, no resource type can be listened via the Watcher actor for this target.
+ if (!this.targetList.hasTargetWatcherSupport(targetFront.targetType)) {
+ return false;
+ }
+
+ return this.hasResourceWatcherSupport(resourceType);
+ }
+
+ /**
+ * Start listening for a given type of resource.
+ * For backward compatibility code, we register the legacy listeners on
+ * each individual target
+ *
+ * @param {String} resourceType
+ * One string of ResourceWatcher.TYPES, which designates the types of resources
+ * to be listened.
+ * @param {Object}
+ * - {Boolean} bypassListenerCount
+ * Pass true to avoid checking/updating the listenersCount map.
+ * Exclusively used when target switching, to stop & start listening
+ * to all resources.
+ */
+ async _startListening(resourceType, { bypassListenerCount = false } = {}) {
+ if (!bypassListenerCount) {
+ let listeners = this._listenerCount.get(resourceType) || 0;
+ listeners++;
+ this._listenerCount.set(resourceType, listeners);
+
+ if (listeners > 1) {
+ return;
+ }
+ }
+
+ // If the server supports the Watcher API and the Watcher supports
+ // this resource type, use this API
+ if (this.hasResourceWatcherSupport(resourceType)) {
+ await this.watcherFront.watchResources([resourceType]);
+
+ // Bug 1678385: In order to support watching for JS Source resource
+ // for service workers and parent process workers, which aren't supported yet
+ // by the watcher actor, we do not bail out here and allow to execute
+ // the legacy listener for these targets.
+ // Once bug 1608848 is fixed, we can remove this and always return.
+ // If this isn't fixed soon, we may add other resources we want to see
+ // being fetched from these targets.
+ const shouldRunLegacyListeners =
+ resourceType == ResourceWatcher.TYPES.SOURCE;
+ if (!shouldRunLegacyListeners) {
+ return;
+ }
+ }
+ // Otherwise, fallback on backward compat mode and use LegacyListeners.
+
+ // If this is the first listener for this type of resource,
+ // we should go through all the existing targets as onTargetAvailable
+ // has already been called for these existing targets.
+ const promises = [];
+ const targets = this.targetList.getAllTargets(this.targetList.ALL_TYPES);
+ for (const target of targets) {
+ promises.push(this._watchResourcesForTarget(target, resourceType));
+ }
+ await Promise.all(promises);
+ }
+
+ async _forwardCachedResources(resourceTypes, onAvailable) {
+ const cachedResources = this._cache.filter(resource =>
+ resourceTypes.includes(resource.resourceType)
+ );
+ if (cachedResources.length > 0) {
+ await onAvailable(cachedResources);
+ }
+ }
+
+ /**
+ * Call backward compatibility code from `LegacyListeners` in order to listen for a given
+ * type of resource from a given target.
+ */
+ _watchResourcesForTarget(targetFront, resourceType) {
+ if (this._hasResourceWatcherSupportForTarget(resourceType, targetFront)) {
+ // This resource / target pair should already be handled by the watcher,
+ // no need to start legacy listeners.
+ return Promise.resolve();
+ }
+
+ if (targetFront.isDestroyed()) {
+ return Promise.resolve();
+ }
+
+ const onAvailable = this._onResourceAvailable.bind(this, { targetFront });
+ const onUpdated = this._onResourceUpdated.bind(this, { targetFront });
+ const onDestroyed = this._onResourceDestroyed.bind(this, { targetFront });
+
+ if (!(resourceType in LegacyListeners)) {
+ throw new Error(`Missing legacy listener for ${resourceType}`);
+ }
+
+ const legacyListeners =
+ this._existingLegacyListeners.get(targetFront) || [];
+ if (legacyListeners.includes(resourceType)) {
+ console.error(
+ `Already started legacy listener for ${resourceType} on ${targetFront.actorID}`
+ );
+ return Promise.resolve();
+ }
+ this._existingLegacyListeners.set(
+ targetFront,
+ legacyListeners.concat(resourceType)
+ );
+
+ return LegacyListeners[resourceType]({
+ targetList: this.targetList,
+ targetFront,
+ onAvailable,
+ onDestroyed,
+ onUpdated,
+ });
+ }
+
+ /**
+ * Reverse of _startListening. Stop listening for a given type of resource.
+ * For backward compatibility, we unregister from each individual target.
+ *
+ * See _startListening for parameters description.
+ */
+ _stopListening(resourceType, { bypassListenerCount = false } = {}) {
+ if (!bypassListenerCount) {
+ let listeners = this._listenerCount.get(resourceType);
+ if (!listeners || listeners <= 0) {
+ throw new Error(
+ `Stopped listening for resource '${resourceType}' that isn't being listened to`
+ );
+ }
+ listeners--;
+ this._listenerCount.set(resourceType, listeners);
+ if (listeners > 0) {
+ return;
+ }
+ }
+
+ // Clear the cached resources of the type.
+ this._cache = this._cache.filter(
+ cachedResource => cachedResource.resourceType !== resourceType
+ );
+
+ // If the server supports the Watcher API and the Watcher supports
+ // this resource type, use this API
+ if (this.hasResourceWatcherSupport(resourceType)) {
+ if (!this.watcherFront.isDestroyed()) {
+ this.watcherFront.unwatchResources([resourceType]);
+ }
+
+ // See comment in `_startListening`
+ const shouldRunLegacyListeners =
+ resourceType == ResourceWatcher.TYPES.SOURCE;
+ if (!shouldRunLegacyListeners) {
+ return;
+ }
+ }
+ // Otherwise, fallback on backward compat mode and use LegacyListeners.
+
+ // If this was the last listener, we should stop watching these events from the actors
+ // and the actors should stop watching things from the platform
+ const targets = this.targetList.getAllTargets(this.targetList.ALL_TYPES);
+ for (const target of targets) {
+ this._unwatchResourcesForTarget(target, resourceType);
+ }
+ }
+
+ /**
+ * Backward compatibility code, reverse of _watchResourcesForTarget.
+ */
+ _unwatchResourcesForTarget(targetFront, resourceType) {
+ if (this._hasResourceWatcherSupportForTarget(resourceType, targetFront)) {
+ // This resource / target pair should already be handled by the watcher,
+ // no need to stop legacy listeners.
+ }
+ // Is there really a point in:
+ // - unregistering `onAvailable` RDP event callbacks from target-scoped actors?
+ // - calling `stopListeners()` as we are most likely closing the toolbox and destroying everything?
+ //
+ // It is important to keep this method synchronous and do as less as possible
+ // in the case of toolbox destroy.
+ //
+ // We are aware of one case where that might be useful.
+ // When a panel is disabled via the options panel, after it has been opened.
+ // Would that justify doing this? Is there another usecase?
+
+ // XXX: This is most likely only needed to avoid growing the Map infinitely.
+ // Unless in the "disabled panel" use case mentioned in the comment above,
+ // we should not see the same target actorID again.
+ const listeners = this._existingLegacyListeners.get(targetFront);
+ if (listeners && listeners.includes(resourceType)) {
+ const remainingListeners = listeners.filter(l => l !== resourceType);
+ this._existingLegacyListeners.set(targetFront, remainingListeners);
+ }
+ }
+}
+
+ResourceWatcher.TYPES = ResourceWatcher.prototype.TYPES = {
+ CONSOLE_MESSAGE: "console-message",
+ CSS_CHANGE: "css-change",
+ CSS_MESSAGE: "css-message",
+ ERROR_MESSAGE: "error-message",
+ PLATFORM_MESSAGE: "platform-message",
+ DOCUMENT_EVENT: "document-event",
+ ROOT_NODE: "root-node",
+ STYLESHEET: "stylesheet",
+ NETWORK_EVENT: "network-event",
+ WEBSOCKET: "websocket",
+ COOKIE: "cookie",
+ LOCAL_STORAGE: "local-storage",
+ SESSION_STORAGE: "session-storage",
+ CACHE_STORAGE: "Cache",
+ EXTENSION_STORAGE: "extension-storage",
+ INDEXED_DB: "indexed-db",
+ NETWORK_EVENT_STACKTRACE: "network-event-stacktrace",
+ SOURCE: "source",
+};
+module.exports = { ResourceWatcher, TYPES: ResourceWatcher.TYPES };
+
+// Backward compat code for each type of resource.
+// Each section added here should eventually be removed once the equivalent server
+// code is implement in Firefox, in its release channel.
+const LegacyListeners = {
+ [ResourceWatcher.TYPES
+ .CONSOLE_MESSAGE]: require("devtools/shared/resources/legacy-listeners/console-messages"),
+ [ResourceWatcher.TYPES
+ .CSS_CHANGE]: require("devtools/shared/resources/legacy-listeners/css-changes"),
+ [ResourceWatcher.TYPES
+ .CSS_MESSAGE]: require("devtools/shared/resources/legacy-listeners/css-messages"),
+ [ResourceWatcher.TYPES
+ .ERROR_MESSAGE]: require("devtools/shared/resources/legacy-listeners/error-messages"),
+ [ResourceWatcher.TYPES
+ .PLATFORM_MESSAGE]: require("devtools/shared/resources/legacy-listeners/platform-messages"),
+ async [ResourceWatcher.TYPES.DOCUMENT_EVENT]({
+ targetList,
+ targetFront,
+ onAvailable,
+ }) {
+ // DocumentEventsListener of webconsole handles only top level document.
+ if (!targetFront.isTopLevel) {
+ return;
+ }
+
+ const webConsoleFront = await targetFront.getFront("console");
+ webConsoleFront.on("documentEvent", event => {
+ event.resourceType = ResourceWatcher.TYPES.DOCUMENT_EVENT;
+ onAvailable([event]);
+ });
+ await webConsoleFront.startListeners(["DocumentEvents"]);
+ },
+ [ResourceWatcher.TYPES
+ .ROOT_NODE]: require("devtools/shared/resources/legacy-listeners/root-node"),
+ [ResourceWatcher.TYPES
+ .STYLESHEET]: require("devtools/shared/resources/legacy-listeners/stylesheet"),
+ [ResourceWatcher.TYPES
+ .NETWORK_EVENT]: require("devtools/shared/resources/legacy-listeners/network-events"),
+ [ResourceWatcher.TYPES
+ .WEBSOCKET]: require("devtools/shared/resources/legacy-listeners/websocket"),
+ [ResourceWatcher.TYPES
+ .COOKIE]: require("devtools/shared/resources/legacy-listeners/cookie"),
+ [ResourceWatcher.TYPES
+ .LOCAL_STORAGE]: require("devtools/shared/resources/legacy-listeners/local-storage"),
+ [ResourceWatcher.TYPES
+ .SESSION_STORAGE]: require("devtools/shared/resources/legacy-listeners/session-storage"),
+ [ResourceWatcher.TYPES
+ .CACHE_STORAGE]: require("devtools/shared/resources/legacy-listeners/cache-storage"),
+ [ResourceWatcher.TYPES
+ .EXTENSION_STORAGE]: require("devtools/shared/resources/legacy-listeners/extension-storage"),
+ [ResourceWatcher.TYPES
+ .INDEXED_DB]: require("devtools/shared/resources/legacy-listeners/indexed-db"),
+ [ResourceWatcher.TYPES
+ .NETWORK_EVENT_STACKTRACE]: require("devtools/shared/resources/legacy-listeners/network-event-stacktraces"),
+ [ResourceWatcher.TYPES
+ .SOURCE]: require("devtools/shared/resources/legacy-listeners/source"),
+};
+
+// Optional transformers for each type of resource.
+// Each module added here should be a function that will receive the resource, the target, โ€ฆ
+// and perform some transformation on the resource before it will be emitted.
+// This is a good place to handle backward compatibility and manual resource marshalling.
+const ResourceTransformers = {
+ [ResourceWatcher.TYPES
+ .CONSOLE_MESSAGE]: require("devtools/shared/resources/transformers/console-messages"),
+ [ResourceWatcher.TYPES
+ .ERROR_MESSAGE]: require("devtools/shared/resources/transformers/error-messages"),
+ [ResourceWatcher.TYPES
+ .LOCAL_STORAGE]: require("devtools/shared/resources/transformers/storage-local-storage.js"),
+ [ResourceWatcher.TYPES
+ .SESSION_STORAGE]: require("devtools/shared/resources/transformers/storage-session-storage.js"),
+ [ResourceWatcher.TYPES
+ .NETWORK_EVENT]: require("devtools/shared/resources/transformers/network-events"),
+};
diff --git a/devtools/shared/resources/target-list.js b/devtools/shared/resources/target-list.js
new file mode 100644
index 0000000000..a23b531364
--- /dev/null
+++ b/devtools/shared/resources/target-list.js
@@ -0,0 +1,607 @@
+/* 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 Services = require("Services");
+const EventEmitter = require("devtools/shared/event-emitter");
+
+const BROWSERTOOLBOX_FISSION_ENABLED = "devtools.browsertoolbox.fission";
+
+const {
+ LegacyProcessesWatcher,
+} = require("devtools/shared/resources/legacy-target-watchers/legacy-processes-watcher");
+const {
+ LegacyServiceWorkersWatcher,
+} = require("devtools/shared/resources/legacy-target-watchers/legacy-serviceworkers-watcher");
+const {
+ LegacySharedWorkersWatcher,
+} = require("devtools/shared/resources/legacy-target-watchers/legacy-sharedworkers-watcher");
+const {
+ LegacyWorkersWatcher,
+} = require("devtools/shared/resources/legacy-target-watchers/legacy-workers-watcher");
+
+// eslint-disable-next-line mozilla/reject-some-requires
+loader.lazyRequireGetter(
+ this,
+ "TargetFactory",
+ "devtools/client/framework/target",
+ true
+);
+
+class TargetList extends EventEmitter {
+ /**
+ * 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 {RootFront} rootFront
+ * The root front.
+ * @param {TargetFront} targetFront
+ * The top level target to debug. Note that in case of target switching,
+ * this may be replaced by a new one over time.
+ */
+ constructor(rootFront, targetFront) {
+ super();
+
+ this.rootFront = rootFront;
+
+ // Once we have descriptor for all targets we create a toolbox for,
+ // we should try to only pass the descriptor to the Toolbox constructor,
+ // and, only receive the root and descriptor front as an argument to TargetList.
+ // Bug 1573779, we only miss descriptors for workers.
+ this.descriptorFront = targetFront.descriptorFront;
+
+ // 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 = targetFront;
+ targetFront.setTargetType(this.getTargetType(targetFront));
+ targetFront.setIsTopLevel(true);
+
+ // 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?.isLocalTab) {
+ this.descriptorFront.on(
+ "remoteness-change",
+ this.onLocalTabRemotenessChange
+ );
+ }
+
+ // 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();
+
+ // Add the top-level target to debug to the list of targets.
+ this._targets.add(targetFront);
+
+ // Listeners for target creation and destruction
+ this._createListeners = new EventEmitter();
+ this._destroyListeners = new EventEmitter();
+
+ this._onTargetAvailable = this._onTargetAvailable.bind(this);
+ this._onTargetDestroyed = this._onTargetDestroyed.bind(this);
+
+ this.legacyImplementation = {
+ process: new LegacyProcessesWatcher(
+ this,
+ this._onTargetAvailable,
+ this._onTargetDestroyed
+ ),
+ worker: new LegacyWorkersWatcher(
+ this,
+ this._onTargetAvailable,
+ this._onTargetDestroyed
+ ),
+ shared_worker: new LegacySharedWorkersWatcher(
+ this,
+ this._onTargetAvailable,
+ this._onTargetDestroyed
+ ),
+ service_worker: new LegacyServiceWorkersWatcher(
+ this,
+ this._onTargetAvailable,
+ this._onTargetDestroyed
+ ),
+ };
+
+ // 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;
+ }
+
+ // 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, isTargetSwitching = false) {
+ 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 TargetList",
+ 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 TargetList constructor.
+ if (targetFront.isTopLevel) {
+ // First report that all existing targets are destroyed
+ for (const target of this._targets) {
+ // We only consider the top level target to be switched
+ const isDestroyedTargetSwitching = target == this.targetFront;
+ this._onTargetDestroyed(target, isDestroyedTargetSwitching);
+ }
+ // Stop listening to legacy listeners as we now have to listen
+ // on the new target.
+ this.stopListening({ onlyLegacy: true });
+
+ // Clear the cached target list
+ this._targets.clear();
+
+ // Update the reference to the memoized top level target
+ this.targetFront = targetFront;
+ }
+
+ // 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);
+ }
+
+ // 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) {
+ await this.startListening({ onlyLegacy: true });
+ }
+
+ // To be consumed by tests triggering frame navigations, spawning workers...
+ this.emitForTests("processed-available-target", targetFront);
+ }
+
+ _onTargetDestroyed(targetFront, isTargetSwitching = false) {
+ this._destroyListeners.emit(targetFront.targetType, {
+ targetFront,
+ isTargetSwitching,
+ });
+ this._targets.delete(targetFront);
+ }
+
+ _setListening(type, value) {
+ if (value) {
+ this._listenersStarted.add(type);
+ } else {
+ this._listenersStarted.delete(type);
+ }
+ }
+
+ _isListening(type) {
+ return this._listenersStarted.has(type);
+ }
+
+ hasTargetWatcherSupport(type) {
+ return !!this.watcherFront?.traits[type];
+ }
+
+ /**
+ * 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
+ * Dictionary object with `onlyLegacy` optional boolean.
+ * If true, we wouldn't register listener set on the Watcher Actor,
+ * but still register listeners set via Legacy Listeners.
+ */
+ async startListening({ onlyLegacy = false } = {}) {
+ // Cache the Watcher once for all, the first time we call `startListening()`.
+ // This `watcherFront` attribute may be then used in any function in TargetList or ResourceWatcher after this.
+ if (!this.watcherFront) {
+ // Bug 1675763: Watcher actor is not available in all situations yet.
+ const supportsWatcher = this.descriptorFront?.traits?.watcher;
+ if (supportsWatcher) {
+ this.watcherFront = await this.descriptorFront.getWatcher();
+ }
+ }
+
+ let types = [];
+ if (this.targetFront.isParentProcess) {
+ const fissionBrowserToolboxEnabled = Services.prefs.getBoolPref(
+ BROWSERTOOLBOX_FISSION_ENABLED
+ );
+ if (fissionBrowserToolboxEnabled) {
+ types = TargetList.ALL_TYPES;
+ }
+ } else if (this.targetFront.isLocalTab) {
+ types = [TargetList.TYPES.FRAME];
+ }
+ if (this.listenForWorkers && !types.includes(TargetList.TYPES.WORKER)) {
+ types.push(TargetList.TYPES.WORKER);
+ }
+ if (
+ this.listenForWorkers &&
+ !types.includes(TargetList.TYPES.SHARED_WORKER)
+ ) {
+ types.push(TargetList.TYPES.SHARED_WORKER);
+ }
+ if (
+ this.listenForServiceWorkers &&
+ !types.includes(TargetList.TYPES.SERVICE_WORKER)
+ ) {
+ types.push(TargetList.TYPES.SERVICE_WORKER);
+ }
+
+ // 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.
+
+ 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 (onlyLegacy) {
+ continue;
+ }
+ if (!this._startedListeningToWatcher) {
+ this._startedListeningToWatcher = true;
+ this.watcherFront.on("target-available", this._onTargetAvailable);
+ this.watcherFront.on("target-destroyed", this._onTargetDestroyed);
+ }
+ await this.watcherFront.watchTargets(type);
+ continue;
+ }
+ if (this.legacyImplementation[type]) {
+ await this.legacyImplementation[type].listen();
+ } else {
+ throw new Error(`Unsupported target type '${type}'`);
+ }
+ }
+ }
+
+ /**
+ * Stop listening for targets from the server
+ *
+ * @param Object options
+ * Dictionary object with `onlyLegacy` optional boolean.
+ * If true, we wouldn't unregister listener set on the Watcher Actor,
+ * but still unregister listeners set via Legacy Listeners.
+ */
+ stopListening({ onlyLegacy = false } = {}) {
+ for (const type of TargetList.ALL_TYPES) {
+ if (!this._isListening(type)) {
+ continue;
+ }
+ 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
+ if (!onlyLegacy) {
+ this.watcherFront.unwatchTargets(type);
+ }
+ continue;
+ }
+ if (this.legacyImplementation[type]) {
+ this.legacyImplementation[type].unlisten();
+ } else {
+ throw new Error(`Unsupported target type '${type}'`);
+ }
+ }
+ }
+
+ getTargetType(target) {
+ const { typeName } = target;
+ if (typeName == "browsingContextTarget") {
+ return TargetList.TYPES.FRAME;
+ }
+
+ if (
+ typeName == "contentProcessTarget" ||
+ typeName == "parentProcessTarget"
+ ) {
+ return TargetList.TYPES.PROCESS;
+ }
+
+ if (typeName == "workerDescriptor" || typeName == "workerTarget") {
+ if (target.isSharedWorker) {
+ return TargetList.TYPES.SHARED_WORKER;
+ }
+
+ if (target.isServiceWorker) {
+ return TargetList.TYPES.SERVICE_WORKER;
+ }
+
+ return TargetList.TYPES.WORKER;
+ }
+
+ throw new Error("Unsupported target typeName: " + typeName);
+ }
+
+ _matchTargetType(type, target) {
+ return type === target.targetType;
+ }
+
+ /**
+ * Listen for the creation and/or destruction of target fronts matching one of the provided types.
+ *
+ * @param {Array<String>} types
+ * The type of target to listen for. Constant of TargetList.TYPES.
+ * @param {Function} onAvailable
+ * Callback fired when a target has been just created or was already available.
+ * The function is called with the following arguments:
+ * - {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} onDestroy
+ * Callback fired in case of target front destruction.
+ * The function is called with the same arguments than onAvailable.
+ */
+ async watchTargets(types, onAvailable, onDestroy) {
+ if (typeof onAvailable != "function") {
+ throw new Error(
+ "TargetList.watchTargets expects a function as second argument"
+ );
+ }
+
+ // 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 (onDestroy) {
+ this._destroyListeners.on(type, onDestroy);
+ }
+ }
+
+ 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(types, onAvailable, onDestroy) {
+ if (typeof onAvailable != "function") {
+ throw new Error(
+ "TargetList.unwatchTargets expects a function as second argument"
+ );
+ }
+
+ for (const type of types) {
+ this._createListeners.off(type, onAvailable);
+ if (onDestroy) {
+ this._destroyListeners.off(type, onDestroy);
+ }
+ }
+ 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 TargetList.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;
+ }
+
+ /**
+ * For all the target fronts of a given type, retrieve all the target-scoped fronts of a given type.
+ *
+ * @param {String} targetType
+ * The type of target to iterate over. Constant of TargetList.TYPES.
+ * @param {String} frontType
+ * The type of target-scoped front to retrieve. It can be "inspector", "console", "thread",...
+ */
+ async getAllFronts(targetType, frontType) {
+ const fronts = [];
+ const targets = this.getAllTargets([targetType]);
+ for (const target of targets) {
+ const front = await target.getFront(frontType);
+ fronts.push(front);
+ }
+ return fronts;
+ }
+
+ /**
+ * This function is triggered by an event sent by the TabDescriptor when
+ * the tab navigates to a distinct process.
+ *
+ * @param TargetFront targetFront
+ * The BrowsingContextTargetFront instance that navigated to another process
+ */
+ async onLocalTabRemotenessChange(targetFront) {
+ // Cache the tab & client as this property will be nullified when the target is closed
+ const client = targetFront.client;
+ const localTab = targetFront.localTab;
+
+ // By default, we do close the DevToolsClient when the target is destroyed.
+ // This happens when we close the toolbox (Toolbox.destroy calls Target.destroy),
+ // or when the tab is closes, the server emits tabDetached and the target
+ // destroy itself.
+ // Here, in the context of the process switch, the current target will be destroyed
+ // due to a tabDetached event and a we will create a new one. But we want to reuse
+ // the same client.
+ targetFront.shouldCloseClient = false;
+
+ // Wait for the target to be destroyed so that TargetFactory clears its memoized target for this tab
+ await targetFront.once("target-destroyed");
+
+ // Fetch the new target from the existing client so that the new target uses the same client.
+ const newTarget = await TargetFactory.forTab(localTab, client);
+
+ this.switchToTarget(newTarget);
+ }
+
+ /**
+ * 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) {
+ newTarget.setIsTopLevel(true);
+
+ // Notify about this new target to creation listeners
+ await this._onTargetAvailable(newTarget, true);
+
+ this.emit("switched-target", newTarget);
+ }
+
+ isTargetRegistered(targetFront) {
+ return this._targets.has(targetFront);
+ }
+
+ isDestroyed() {
+ return this._isDestroyed;
+ }
+
+ destroy() {
+ this.stopListening();
+ this._createListeners.off();
+ this._destroyListeners.off();
+ this._isDestroyed = true;
+ }
+}
+
+/**
+ * All types of target:
+ */
+TargetList.TYPES = TargetList.prototype.TYPES = {
+ PROCESS: "process",
+ FRAME: "frame",
+ WORKER: "worker",
+ SHARED_WORKER: "shared_worker",
+ SERVICE_WORKER: "service_worker",
+};
+TargetList.ALL_TYPES = TargetList.prototype.ALL_TYPES = Object.values(
+ TargetList.TYPES
+);
+
+module.exports = { TargetList };
diff --git a/devtools/shared/resources/tests/.eslintrc.js b/devtools/shared/resources/tests/.eslintrc.js
new file mode 100644
index 0000000000..3d0bd99e1b
--- /dev/null
+++ b/devtools/shared/resources/tests/.eslintrc.js
@@ -0,0 +1,6 @@
+"use strict";
+
+module.exports = {
+ // Extend from the shared list of defined globals for mochitests.
+ extends: "../../../.eslintrc.mochitests.js",
+};
diff --git a/devtools/shared/resources/tests/browser.ini b/devtools/shared/resources/tests/browser.ini
new file mode 100644
index 0000000000..afbc008130
--- /dev/null
+++ b/devtools/shared/resources/tests/browser.ini
@@ -0,0 +1,64 @@
+[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/test-actor.js
+ head.js
+ network_document.html
+ early_console_document.html
+ fission_document.html
+ fission_iframe.html
+ service-worker-sources.js
+ sources.html
+ sources.js
+ style_document.css
+ style_document.html
+ style_iframe.css
+ style_iframe.html
+ test_service_worker.js
+ test_sw_page.html
+ test_sw_page_worker.js
+ test_worker.js
+ websocket_backend_wsh.py
+ websocket_frontend.html
+ worker-sources.js
+
+[browser_browser_resources_console_messages.js]
+[browser_resources_client_caching.js]
+[browser_resources_console_messages.js]
+[browser_resources_console_messages_workers.js]
+[browser_resources_css_changes.js]
+[browser_resources_css_messages.js]
+[browser_resources_document_events.js]
+[browser_resources_error_messages.js]
+[browser_resources_getAllResources.js]
+[browser_resources_network_event_stacktraces.js]
+[browser_resources_network_events.js]
+[browser_resources_platform_messages.js]
+[browser_resources_root_node.js]
+[browser_resources_several_resources.js]
+[browser_resources_sources.js]
+[browser_resources_stylesheets.js]
+skip-if = fission # Disable frequent fission intermittents Bug 1675020
+[browser_resources_target_destroy.js]
+[browser_resources_target_resources_race.js]
+[browser_resources_target_switching.js]
+[browser_resources_websocket.js]
+[browser_target_list_browser_workers.js]
+[browser_target_list_frames.js]
+[browser_target_list_getAllTargets.js]
+[browser_target_list_preffedoff.js]
+[browser_target_list_processes.js]
+[browser_target_list_service_workers.js]
+[browser_target_list_service_workers_navigation.js]
+skip-if = fission
+# There are several issues to test TargetList navigation scenarios with fission.
+# Without a toolbox linked to the target-list, the target list cannot switch
+# targets. The legacy worker watchers are also not designed to support target
+# switching, since they set this.target = targetList.targetFront just once in
+# their constructor.
+[browser_target_list_switchToTarget.js]
+[browser_target_list_tab_workers.js]
+[browser_target_list_watchTargets.js]
diff --git a/devtools/shared/resources/tests/browser_browser_resources_console_messages.js b/devtools/shared/resources/tests/browser_browser_resources_console_messages.js
new file mode 100644
index 0000000000..67bd5af635
--- /dev/null
+++ b/devtools/shared/resources/tests/browser_browser_resources_console_messages.js
@@ -0,0 +1,90 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the ResourceWatcher API around CONSOLE_MESSAGE for the whole browser
+
+const {
+ ResourceWatcher,
+} = require("devtools/shared/resources/resource-watcher");
+
+const TEST_URL = URL_ROOT_SSL + "early_console_document.html";
+
+add_task(async function() {
+ // Enable Multiprocess Browser Toolbox (it's still disabled for non-Nightly builds).
+ await pushPref("devtools.browsertoolbox.fission", true);
+
+ const {
+ client,
+ resourceWatcher,
+ targetList,
+ } = await initMultiProcessResourceWatcher();
+
+ info(
+ "Log some messages *before* calling ResourceWatcher.watchResources in order to " +
+ "assert the behavior of already existing messages."
+ );
+ console.log("foobar");
+
+ info("Wait for existing browser mochitest log");
+ await waitForNextResource(
+ resourceWatcher,
+ ResourceWatcher.TYPES.CONSOLE_MESSAGE,
+ {
+ ignoreExistingResources: false,
+ predicate({ message }) {
+ return message.arguments[0] === "foobar";
+ },
+ }
+ );
+ ok(true, "The existing log was retrieved");
+
+ // We can't use waitForNextResource here as we have to ensure
+ // waiting for watchResource resolution before doing the console log.
+ let resolveMochitestRuntimeLog;
+ const onMochitestRuntimeLog = new Promise(resolve => {
+ resolveMochitestRuntimeLog = resolve;
+ });
+ const onAvailable = resources => {
+ if (
+ resources.some(resource => resource.message.arguments[0] == "foobar2")
+ ) {
+ resourceWatcher.unwatchResources(
+ [ResourceWatcher.TYPES.CONSOLE_MESSAGE],
+ { onAvailable }
+ );
+ resolveMochitestRuntimeLog();
+ }
+ };
+ await resourceWatcher.watchResources(
+ [ResourceWatcher.TYPES.CONSOLE_MESSAGE],
+ {
+ ignoreExistingResources: true,
+ onAvailable,
+ }
+ );
+ console.log("foobar2");
+
+ info("Wait for runtime browser mochitest log");
+ await onMochitestRuntimeLog;
+ ok(true, "The runtime log was retrieved");
+
+ const onEarlyLog = waitForNextResource(
+ resourceWatcher,
+ ResourceWatcher.TYPES.CONSOLE_MESSAGE,
+ {
+ ignoreExistingResources: true,
+ predicate({ message }) {
+ return message.arguments[0] === "early-page-log";
+ },
+ }
+ );
+ await addTab(TEST_URL);
+ info("Wait for early page log");
+ await onEarlyLog;
+ ok(true, "The early page log was retrieved");
+
+ targetList.destroy();
+ await client.close();
+});
diff --git a/devtools/shared/resources/tests/browser_resources_client_caching.js b/devtools/shared/resources/tests/browser_resources_client_caching.js
new file mode 100644
index 0000000000..b758318a13
--- /dev/null
+++ b/devtools/shared/resources/tests/browser_resources_client_caching.js
@@ -0,0 +1,362 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the cache mechanism of the ResourceWatcher.
+
+const {
+ ResourceWatcher,
+} = require("devtools/shared/resources/resource-watcher");
+
+const TEST_URI = "data:text/html;charset=utf-8,Cache Test";
+
+add_task(async function() {
+ info("Test whether multiple listener can get same cached resources");
+
+ const tab = await addTab(TEST_URI);
+
+ const { client, resourceWatcher, targetList } = await initResourceWatcher(
+ tab
+ );
+
+ info("Add messages as existing resources");
+ const messages = ["a", "b", "c"];
+ await logMessages(tab.linkedBrowser, messages);
+
+ info("Register first listener");
+ const cachedResources1 = [];
+ await resourceWatcher.watchResources(
+ [ResourceWatcher.TYPES.CONSOLE_MESSAGE],
+ {
+ onAvailable: resources => cachedResources1.push(...resources),
+ }
+ );
+
+ info("Register second listener");
+ const cachedResources2 = [];
+ await resourceWatcher.watchResources(
+ [ResourceWatcher.TYPES.CONSOLE_MESSAGE],
+ {
+ onAvailable: resources => cachedResources2.push(...resources),
+ }
+ );
+
+ assertContents(cachedResources1, messages);
+ assertResources(cachedResources2, cachedResources1);
+
+ await targetList.destroy();
+ await client.close();
+});
+
+add_task(async function() {
+ info(
+ "Test whether the cache is reflecting existing resources and additional resources"
+ );
+
+ const tab = await addTab(TEST_URI);
+
+ const { client, resourceWatcher, targetList } = await initResourceWatcher(
+ tab
+ );
+
+ info("Add messages as existing resources");
+ const existingMessages = ["a", "b", "c"];
+ await logMessages(tab.linkedBrowser, existingMessages);
+
+ info("Register first listener to get all available resources");
+ const availableResources = [];
+ await resourceWatcher.watchResources(
+ [ResourceWatcher.TYPES.CONSOLE_MESSAGE],
+ {
+ onAvailable: resources => availableResources.push(...resources),
+ }
+ );
+
+ info("Add messages as additional resources");
+ const additionalMessages = ["d", "e"];
+ await logMessages(tab.linkedBrowser, additionalMessages);
+
+ info("Wait until onAvailable is called expected times");
+ const allMessages = [...existingMessages, ...additionalMessages];
+ await waitUntil(() => availableResources.length === allMessages.length);
+
+ info("Register second listener to get the cached resources");
+ const cachedResources = [];
+ await resourceWatcher.watchResources(
+ [ResourceWatcher.TYPES.CONSOLE_MESSAGE],
+ {
+ onAvailable: resources => cachedResources.push(...resources),
+ }
+ );
+
+ assertContents(availableResources, allMessages);
+ assertResources(cachedResources, availableResources);
+
+ await targetList.destroy();
+ await client.close();
+});
+
+add_task(async function() {
+ info("Test whether the cache is cleared when navigation");
+
+ const tab = await addTab(TEST_URI);
+
+ const { client, resourceWatcher, targetList } = await initResourceWatcher(
+ tab
+ );
+
+ info("Add messages as existing resources");
+ const existingMessages = ["a", "b", "c"];
+ await logMessages(tab.linkedBrowser, existingMessages);
+
+ info("Register first listener");
+ await resourceWatcher.watchResources(
+ [ResourceWatcher.TYPES.CONSOLE_MESSAGE],
+ {
+ onAvailable: () => {},
+ }
+ );
+
+ info("Reload the page");
+ const onReloaded = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+ gBrowser.reloadTab(tab);
+ await onReloaded;
+
+ info("Register second listener");
+ const cachedResources = [];
+ await resourceWatcher.watchResources(
+ [ResourceWatcher.TYPES.CONSOLE_MESSAGE],
+ {
+ onAvailable: resources => cachedResources.push(...resources),
+ }
+ );
+
+ is(cachedResources.length, 0, "The cache in ResourceWatcher is cleared");
+
+ await targetList.destroy();
+ await client.close();
+});
+
+add_task(async function() {
+ info("Test with multiple resource types");
+
+ const tab = await addTab(TEST_URI);
+
+ const { client, resourceWatcher, targetList } = await initResourceWatcher(
+ tab
+ );
+
+ info("Register first listener to get all available resources");
+ const availableResources = [];
+ await resourceWatcher.watchResources(
+ [
+ ResourceWatcher.TYPES.CONSOLE_MESSAGE,
+ ResourceWatcher.TYPES.ERROR_MESSAGE,
+ ],
+ {
+ onAvailable: resources => availableResources.push(...resources),
+ }
+ );
+
+ info("Add messages as console message");
+ const consoleMessages1 = ["a", "b", "c"];
+ await logMessages(tab.linkedBrowser, consoleMessages1);
+
+ info("Add message as error message");
+ const errorMessages = ["document.doTheImpossible();"];
+ await triggerErrors(tab.linkedBrowser, errorMessages);
+
+ info("Add messages as console message again");
+ const consoleMessages2 = ["d", "e"];
+ await logMessages(tab.linkedBrowser, consoleMessages2);
+
+ info("Wait until the getting all available resources");
+ const totalResourceCount =
+ consoleMessages1.length + errorMessages.length + consoleMessages2.length;
+ await waitUntil(() => {
+ return availableResources.length === totalResourceCount;
+ });
+
+ info("Register listener to get the cached resources");
+ const cachedResources = [];
+ await resourceWatcher.watchResources(
+ [
+ ResourceWatcher.TYPES.CONSOLE_MESSAGE,
+ ResourceWatcher.TYPES.ERROR_MESSAGE,
+ ],
+ {
+ onAvailable: resources => cachedResources.push(...resources),
+ }
+ );
+
+ assertResources(cachedResources, availableResources);
+
+ await targetList.destroy();
+ await client.close();
+});
+
+add_task(async function() {
+ info("Test multiple listeners with/without ignoreExistingResources");
+ await testIgnoreExistingResources(true);
+ await testIgnoreExistingResources(false);
+});
+
+async function testIgnoreExistingResources(isFirstListenerIgnoreExisting) {
+ const tab = await addTab(TEST_URI);
+
+ const { client, resourceWatcher, targetList } = await initResourceWatcher(
+ tab
+ );
+
+ info("Add messages as existing resources");
+ const existingMessages = ["a", "b", "c"];
+ await logMessages(tab.linkedBrowser, existingMessages);
+
+ info("Register first listener");
+ const cachedResources1 = [];
+ await resourceWatcher.watchResources(
+ [ResourceWatcher.TYPES.CONSOLE_MESSAGE],
+ {
+ onAvailable: resources => cachedResources1.push(...resources),
+ ignoreExistingResources: isFirstListenerIgnoreExisting,
+ }
+ );
+
+ info("Register second listener");
+ const cachedResources2 = [];
+ await resourceWatcher.watchResources(
+ [ResourceWatcher.TYPES.CONSOLE_MESSAGE],
+ {
+ onAvailable: resources => cachedResources2.push(...resources),
+ ignoreExistingResources: !isFirstListenerIgnoreExisting,
+ }
+ );
+
+ const cachedResourcesWithFlag = isFirstListenerIgnoreExisting
+ ? cachedResources1
+ : cachedResources2;
+ const cachedResourcesWithoutFlag = isFirstListenerIgnoreExisting
+ ? cachedResources2
+ : cachedResources1;
+
+ info("Check the existing resources both listeners got");
+ assertContents(cachedResourcesWithFlag, []);
+ assertContents(cachedResourcesWithoutFlag, existingMessages);
+
+ info("Add messages as additional resources");
+ const additionalMessages = ["d", "e"];
+ await logMessages(tab.linkedBrowser, additionalMessages);
+
+ info("Wait until onAvailable is called expected times");
+ await waitUntil(
+ () => cachedResourcesWithFlag.length === additionalMessages.length
+ );
+ const allMessages = [...existingMessages, ...additionalMessages];
+ await waitUntil(
+ () => cachedResourcesWithoutFlag.length === allMessages.length
+ );
+
+ info("Check the resources after adding messages");
+ assertContents(cachedResourcesWithFlag, additionalMessages);
+ assertContents(cachedResourcesWithoutFlag, allMessages);
+
+ await targetList.destroy();
+ await client.close();
+}
+
+add_task(async function() {
+ info("Test that onAvailable is not called with an empty resources array");
+
+ const tab = await addTab(TEST_URI);
+
+ const { client, resourceWatcher, targetList } = await initResourceWatcher(
+ tab
+ );
+
+ info("Register first listener to get all available resources");
+ const availableResources = [];
+ let onAvailableCallCount = 0;
+ const onAvailable = resources => {
+ ok(
+ resources.length > 0,
+ "onAvailable is called with a non empty resources array"
+ );
+ availableResources.push(...resources);
+ onAvailableCallCount++;
+ };
+
+ await resourceWatcher.watchResources(
+ [ResourceWatcher.TYPES.CONSOLE_MESSAGE],
+ { onAvailable }
+ );
+ is(availableResources.length, 0, "availableResources array is empty");
+ is(onAvailableCallCount, 0, "onAvailable was never called");
+
+ info("Add messages as console message");
+ await logMessages(tab.linkedBrowser, ["expected message"]);
+
+ await waitUntil(() => availableResources.length === 1);
+ is(availableResources.length, 1, "availableResources array has one item");
+ is(onAvailableCallCount, 1, "onAvailable was called only once");
+ is(
+ availableResources[0].message.arguments[0],
+ "expected message",
+ "onAvailable was called with the expected resource"
+ );
+
+ resourceWatcher.unwatchResources([ResourceWatcher.TYPES.CONSOLE_MESSAGE], {
+ onAvailable,
+ });
+ await targetList.destroy();
+ await client.close();
+});
+
+function assertContents(resources, expectedMessages) {
+ is(
+ resources.length,
+ expectedMessages.length,
+ "Number of the resources is correct"
+ );
+
+ for (let i = 0; i < expectedMessages.length; i++) {
+ const resource = resources[i];
+ const message = resource.message.arguments[0];
+ const expectedMessage = expectedMessages[i];
+ is(message, expectedMessage, `The ${i}th content is correct`);
+ }
+}
+
+function assertResources(resources, expectedResources) {
+ is(
+ resources.length,
+ expectedResources.length,
+ "Number of the resources is correct"
+ );
+
+ for (let i = 0; i < resources.length; i++) {
+ const resource = resources[i];
+ const expectedResource = expectedResources[i];
+ ok(resource === expectedResource, `The ${i}th resource is correct`);
+ }
+}
+
+function logMessages(browser, messages) {
+ return ContentTask.spawn(browser, { messages }, args => {
+ for (const message of args.messages) {
+ content.console.log(message);
+ }
+ });
+}
+
+async function triggerErrors(browser, errorScripts) {
+ for (const errorScript of errorScripts) {
+ await ContentTask.spawn(browser, errorScript, expr => {
+ const document = content.document;
+ const container = document.createElement("script");
+ document.body.appendChild(container);
+ container.textContent = expr;
+ container.remove();
+ });
+ }
+}
diff --git a/devtools/shared/resources/tests/browser_resources_console_messages.js b/devtools/shared/resources/tests/browser_resources_console_messages.js
new file mode 100644
index 0000000000..1be72427b1
--- /dev/null
+++ b/devtools/shared/resources/tests/browser_resources_console_messages.js
@@ -0,0 +1,460 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the ResourceWatcher API around CONSOLE_MESSAGE
+//
+// Reproduces assertions from: devtools/shared/webconsole/test/chrome/test_cached_messages.html
+// And now more. Once we remove the console actor's startListeners in favor of watcher class
+// We could remove that other old test.
+
+const {
+ ResourceWatcher,
+} = require("devtools/shared/resources/resource-watcher");
+
+const FISSION_TEST_URL = URL_ROOT_SSL + "fission_document.html";
+const IFRAME_URL = URL_ROOT_ORG_SSL + "fission_iframe.html";
+
+add_task(async function() {
+ info("Execute test in top level document");
+ await testTabConsoleMessagesResources(false);
+ await testTabConsoleMessagesResourcesWithIgnoreExistingResources(false);
+
+ info("Execute test in an iframe document, possibly remote with fission");
+ await testTabConsoleMessagesResources(true);
+ await testTabConsoleMessagesResourcesWithIgnoreExistingResources(true);
+});
+
+async function testTabConsoleMessagesResources(executeInIframe) {
+ const tab = await addTab(FISSION_TEST_URL);
+
+ const { client, resourceWatcher, targetList } = await initResourceWatcher(
+ tab
+ );
+
+ info(
+ "Log some messages *before* calling ResourceWatcher.watchResources in order to " +
+ "assert the behavior of already existing messages."
+ );
+ await logExistingMessages(tab.linkedBrowser, executeInIframe);
+
+ const targetDocumentUrl = executeInIframe ? IFRAME_URL : FISSION_TEST_URL;
+
+ let runtimeDoneResolve;
+ const expectedExistingCalls = getExpectedExistingConsoleCalls(
+ targetDocumentUrl
+ );
+ const expectedRuntimeCalls = getExpectedRuntimeConsoleCalls(
+ targetDocumentUrl
+ );
+ const onRuntimeDone = new Promise(resolve => (runtimeDoneResolve = resolve));
+ const onAvailable = resources => {
+ for (const resource of resources) {
+ if (resource.message.arguments?.[0] === "[WORKER] started") {
+ // XXX Ignore message from workers as we can't know when they're logged, and we
+ // have a dedicated test for them (browser_resources_console_messages_workers.js).
+ continue;
+ }
+
+ is(
+ resource.resourceType,
+ ResourceWatcher.TYPES.CONSOLE_MESSAGE,
+ "Received a message"
+ );
+ ok(resource.message, "message is wrapped into a message attribute");
+ const expected = (expectedExistingCalls.length > 0
+ ? expectedExistingCalls
+ : expectedRuntimeCalls
+ ).shift();
+ checkConsoleAPICall(resource.message, expected);
+ if (expectedRuntimeCalls.length == 0) {
+ runtimeDoneResolve();
+ }
+ }
+ };
+
+ await resourceWatcher.watchResources(
+ [ResourceWatcher.TYPES.CONSOLE_MESSAGE],
+ {
+ onAvailable,
+ }
+ );
+ is(
+ expectedExistingCalls.length,
+ 0,
+ "Got the expected number of existing messages"
+ );
+
+ info(
+ "Now log messages *after* the call to ResourceWatcher.watchResources and after having received all existing messages"
+ );
+ await logRuntimeMessages(tab.linkedBrowser, executeInIframe);
+
+ info("Waiting for all runtime messages");
+ await onRuntimeDone;
+
+ is(
+ expectedRuntimeCalls.length,
+ 0,
+ "Got the expected number of runtime messages"
+ );
+
+ targetList.destroy();
+ await client.close();
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async () => {
+ // registrationPromise is set by the test page.
+ const registration = await content.wrappedJSObject.registrationPromise;
+ registration.unregister();
+ });
+}
+
+async function testTabConsoleMessagesResourcesWithIgnoreExistingResources(
+ executeInIframe
+) {
+ info("Test ignoreExistingResources option for console messages");
+ const tab = await addTab(FISSION_TEST_URL);
+
+ const { client, resourceWatcher, targetList } = await initResourceWatcher(
+ tab
+ );
+
+ info(
+ "Check whether onAvailable will not be called with existing console messages"
+ );
+ await logExistingMessages(tab.linkedBrowser, executeInIframe);
+
+ const availableResources = [];
+ await resourceWatcher.watchResources(
+ [ResourceWatcher.TYPES.CONSOLE_MESSAGE],
+ {
+ onAvailable: resources => availableResources.push(...resources),
+ ignoreExistingResources: true,
+ }
+ );
+ is(
+ availableResources.length,
+ 0,
+ "onAvailable wasn't called for existing console messages"
+ );
+
+ info(
+ "Check whether onAvailable will be called with the future console messages"
+ );
+ await logRuntimeMessages(tab.linkedBrowser, executeInIframe);
+ const targetDocumentUrl = executeInIframe ? IFRAME_URL : FISSION_TEST_URL;
+ const expectedRuntimeConsoleCalls = getExpectedRuntimeConsoleCalls(
+ targetDocumentUrl
+ );
+ await waitUntil(
+ () => availableResources.length === expectedRuntimeConsoleCalls.length
+ );
+ const expectedTargetFront =
+ executeInIframe && isFissionEnabled()
+ ? targetList
+ .getAllTargets([targetList.TYPES.FRAME])
+ .find(target => target.url == IFRAME_URL)
+ : targetList.targetFront;
+ for (let i = 0; i < expectedRuntimeConsoleCalls.length; i++) {
+ const { message, targetFront } = availableResources[i];
+ is(
+ targetFront,
+ expectedTargetFront,
+ "The targetFront property is the expected one"
+ );
+ const expected = expectedRuntimeConsoleCalls[i];
+ checkConsoleAPICall(message, expected);
+ }
+
+ await targetList.destroy();
+ await client.close();
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async () => {
+ // registrationPromise is set by the test page.
+ const registration = await content.wrappedJSObject.registrationPromise;
+ registration.unregister();
+ });
+}
+
+async function logExistingMessages(browser, executeInIframe) {
+ let browsingContext = browser.browsingContext;
+ if (executeInIframe) {
+ browsingContext = await SpecialPowers.spawn(
+ browser,
+ [],
+ function frameScript() {
+ return content.document.querySelector("iframe").browsingContext;
+ }
+ );
+ }
+ return evalInBrowsingContext(browsingContext, function pageScript() {
+ console.log("foobarBaz-log", undefined);
+ console.info("foobarBaz-info", null);
+ console.warn("foobarBaz-warn", document.body);
+ });
+}
+
+/**
+ * Helper function similar to spawn, but instead of executing the script
+ * as a Frame Script, with privileges and including test harness in stacktraces,
+ * execute the script as a regular page script, without privileges and without any
+ * preceding stack.
+ *
+ * @param {BrowsingContext} The browsing context into which the script should be evaluated
+ * @param {Function|String} The JS to execute in the browsing context
+ *
+ * @return {Promise} Which resolves once the JS is done executing in the page
+ */
+function evalInBrowsingContext(browsingContext, script) {
+ return SpecialPowers.spawn(browsingContext, [String(script)], expr => {
+ const document = content.document;
+ const scriptEl = document.createElement("script");
+ document.body.appendChild(scriptEl);
+ // Force the immediate execution of the stringified JS function passed in `expr`
+ scriptEl.textContent = "new " + expr;
+ scriptEl.remove();
+ });
+}
+
+// For both existing and runtime messages, we execute console API
+// from a page script evaluated via evalInBrowsingContext.
+// Records here the function used to execute the script in the page.
+const EXPECTED_FUNCTION_NAME = "pageScript";
+
+const NUMBER_REGEX = /^\d+$/;
+
+function getExpectedExistingConsoleCalls(documentFilename) {
+ return [
+ {
+ level: "log",
+ filename: documentFilename,
+ functionName: EXPECTED_FUNCTION_NAME,
+ timeStamp: NUMBER_REGEX,
+ arguments: ["foobarBaz-log", { type: "undefined" }],
+ },
+ {
+ level: "info",
+ filename: documentFilename,
+ functionName: EXPECTED_FUNCTION_NAME,
+ timeStamp: NUMBER_REGEX,
+ arguments: ["foobarBaz-info", { type: "null" }],
+ },
+ {
+ level: "warn",
+ filename: documentFilename,
+ functionName: EXPECTED_FUNCTION_NAME,
+ timeStamp: NUMBER_REGEX,
+ arguments: ["foobarBaz-warn", { type: "object", actor: /[a-z]/ }],
+ },
+ ];
+}
+
+const longString = new Array(DevToolsServer.LONG_STRING_LENGTH + 2).join("a");
+function getExpectedRuntimeConsoleCalls(documentFilename) {
+ const defaultStackFrames = [
+ // This is the usage of "new " + expr from `evalInBrowsingContext`
+ {
+ filename: documentFilename,
+ lineNumber: 1,
+ columnNumber: NUMBER_REGEX,
+ },
+ ];
+
+ return [
+ {
+ level: "log",
+ filename: documentFilename,
+ functionName: EXPECTED_FUNCTION_NAME,
+ timeStamp: NUMBER_REGEX,
+ arguments: ["foobarBaz-log", { type: "undefined" }],
+ },
+ {
+ level: "log",
+ arguments: ["Float from not a number: NaN"],
+ },
+ {
+ level: "log",
+ arguments: ["Float from string: 1.200000"],
+ },
+ {
+ level: "log",
+ arguments: ["Float from number: 1.300000"],
+ },
+ {
+ level: "info",
+ filename: documentFilename,
+ functionName: EXPECTED_FUNCTION_NAME,
+ timeStamp: NUMBER_REGEX,
+ arguments: ["foobarBaz-info", { type: "null" }],
+ },
+ {
+ level: "warn",
+ filename: documentFilename,
+ functionName: EXPECTED_FUNCTION_NAME,
+ timeStamp: NUMBER_REGEX,
+ arguments: ["foobarBaz-warn", { type: "object", actor: /[a-z]/ }],
+ },
+ {
+ level: "debug",
+ filename: documentFilename,
+ functionName: EXPECTED_FUNCTION_NAME,
+ timeStamp: NUMBER_REGEX,
+ arguments: [{ type: "null" }],
+ },
+ {
+ level: "trace",
+ filename: documentFilename,
+ functionName: EXPECTED_FUNCTION_NAME,
+ timeStamp: NUMBER_REGEX,
+ stacktrace: [
+ {
+ filename: documentFilename,
+ functionName: EXPECTED_FUNCTION_NAME,
+ },
+ ...defaultStackFrames,
+ ],
+ },
+ {
+ level: "dir",
+ filename: documentFilename,
+ functionName: EXPECTED_FUNCTION_NAME,
+ timeStamp: NUMBER_REGEX,
+ arguments: [
+ {
+ type: "object",
+ actor: /[a-z]/,
+ class: "HTMLDocument",
+ },
+ {
+ type: "object",
+ actor: /[a-z]/,
+ class: "Location",
+ },
+ ],
+ },
+ {
+ level: "log",
+ filename: documentFilename,
+ functionName: EXPECTED_FUNCTION_NAME,
+ timeStamp: NUMBER_REGEX,
+ arguments: [
+ "foo",
+ {
+ type: "longString",
+ initial: longString.substring(
+ 0,
+ DevToolsServer.LONG_STRING_INITIAL_LENGTH
+ ),
+ length: longString.length,
+ actor: /[a-z]/,
+ },
+ ],
+ },
+ {
+ level: "error",
+ filename: documentFilename,
+ functionName: "fromAsmJS",
+ timeStamp: NUMBER_REGEX,
+ arguments: ["foobarBaz-asmjs-error", { type: "undefined" }],
+
+ stacktrace: [
+ {
+ filename: documentFilename,
+ functionName: "fromAsmJS",
+ },
+ {
+ filename: documentFilename,
+ functionName: "inAsmJS2",
+ },
+ {
+ filename: documentFilename,
+ functionName: "inAsmJS1",
+ },
+ {
+ filename: documentFilename,
+ functionName: EXPECTED_FUNCTION_NAME,
+ },
+ ...defaultStackFrames,
+ ],
+ },
+ {
+ level: "log",
+ filename: gTestPath,
+ functionName: "frameScript",
+ timeStamp: NUMBER_REGEX,
+ arguments: [
+ {
+ type: "object",
+ actor: /[a-z]/,
+ class: "Restricted",
+ },
+ ],
+ },
+ ];
+}
+
+async function logRuntimeMessages(browser, executeInIframe) {
+ let browsingContext = browser.browsingContext;
+ if (executeInIframe) {
+ browsingContext = await SpecialPowers.spawn(
+ browser,
+ [],
+ function frameScript() {
+ return content.document.querySelector("iframe").browsingContext;
+ }
+ );
+ }
+ // First inject LONG_STRING_LENGTH in global scope it order to easily use it after
+ await evalInBrowsingContext(
+ browsingContext,
+ `function () {window.LONG_STRING_LENGTH = ${DevToolsServer.LONG_STRING_LENGTH};}`
+ );
+ await evalInBrowsingContext(browsingContext, function pageScript() {
+ const _longString = new Array(window.LONG_STRING_LENGTH + 2).join("a");
+
+ console.log("foobarBaz-log", undefined);
+
+ console.log("Float from not a number: %f", "foo");
+ console.log("Float from string: %f", "1.2");
+ console.log("Float from number: %f", 1.3);
+
+ console.info("foobarBaz-info", null);
+ console.warn("foobarBaz-warn", document.documentElement);
+ console.debug(null);
+ console.trace();
+ console.dir(document, location);
+ console.log("foo", _longString);
+
+ function fromAsmJS() {
+ console.error("foobarBaz-asmjs-error", undefined);
+ }
+
+ (function(global, foreign) {
+ "use asm";
+ function inAsmJS2() {
+ foreign.fromAsmJS();
+ }
+ function inAsmJS1() {
+ inAsmJS2();
+ }
+ return inAsmJS1;
+ })(null, { fromAsmJS: fromAsmJS })();
+ });
+ await SpecialPowers.spawn(browsingContext, [], function frameScript() {
+ const sandbox = new Cu.Sandbox(null, { invisibleToDebugger: true });
+ const sandboxObj = sandbox.eval("new Object");
+ content.console.log(sandboxObj);
+ });
+}
+
+// Copied from devtools/shared/webconsole/test/chrome/common.js
+function checkConsoleAPICall(call, expected) {
+ is(
+ call.arguments?.length || 0,
+ expected.arguments?.length || 0,
+ "number of arguments"
+ );
+
+ checkObject(call, expected);
+}
diff --git a/devtools/shared/resources/tests/browser_resources_console_messages_workers.js b/devtools/shared/resources/tests/browser_resources_console_messages_workers.js
new file mode 100644
index 0000000000..a31388bc32
--- /dev/null
+++ b/devtools/shared/resources/tests/browser_resources_console_messages_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 ResourceWatcher API around CONSOLE_MESSAGE in workers
+
+const {
+ ResourceWatcher,
+} = require("devtools/shared/resources/resource-watcher");
+
+const FISSION_TEST_URL = URL_ROOT_SSL + "fission_document.html";
+const WORKER_FILE = "test_worker.js";
+const IFRAME_FILE = `${URL_ROOT_ORG_SSL}fission_iframe.html`;
+
+add_task(async function() {
+ // Set the following pref to false as it's the one that enables direct connection
+ // to the worker targets
+ await pushPref("dom.worker.console.dispatch_events_to_main_thread", false);
+
+ const tab = await addTab(FISSION_TEST_URL);
+
+ const { client, resourceWatcher, targetList } = await initResourceWatcher(
+ tab,
+ { listenForWorkers: true }
+ );
+
+ info("Wait for the workers (from the main page and the iframe) to be ready");
+ const targets = [];
+ await new Promise(resolve => {
+ const onAvailable = async ({ targetFront }) => {
+ targets.push(targetFront);
+ if (targets.length === 2) {
+ resolve();
+ }
+ };
+ targetList.watchTargets([targetList.TYPES.WORKER], onAvailable);
+ });
+
+ // The worker logs a message right when it starts, containing its location, so we can
+ // assert that we get the logs from the worker spawned in the content page and from the
+ // worker spawned in the iframe.
+ info("Check that we receive the cached messages");
+
+ const resources = [];
+ const onAvailable = innerResources => {
+ for (const resource of innerResources) {
+ // Ignore resources from non worker targets
+ if (!resource.targetFront.isWorkerTarget) {
+ continue;
+ }
+
+ resources.push(resource);
+ }
+ };
+
+ await resourceWatcher.watchResources(
+ [ResourceWatcher.TYPES.CONSOLE_MESSAGE],
+ {
+ onAvailable,
+ }
+ );
+
+ is(resources.length, 2, "Got the expected number of existing messages");
+ const startLogFromWorkerInMainPage = resources.find(
+ ({ message }) =>
+ message.arguments[1] === `${URL_ROOT_SSL}${WORKER_FILE}#simple-worker`
+ );
+ const startLogFromWorkerInIframe = resources.find(
+ ({ message }) =>
+ message.arguments[1] ===
+ `${URL_ROOT_ORG_SSL}${WORKER_FILE}#simple-worker-in-iframe`
+ );
+
+ checkStartWorkerLogMessage(
+ startLogFromWorkerInMainPage,
+ `${URL_ROOT_SSL}${WORKER_FILE}#simple-worker`
+ );
+ checkStartWorkerLogMessage(
+ startLogFromWorkerInIframe,
+ `${URL_ROOT_ORG_SSL}${WORKER_FILE}#simple-worker-in-iframe`
+ );
+ let messageCount = resources.length;
+
+ info(
+ "Now log messages *after* the call to ResourceWatcher.watchResources and after having received all existing messages"
+ );
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], () => {
+ content.wrappedJSObject.logMessageInWorker("live message from main page");
+
+ const iframe = content.document.querySelector("iframe");
+ SpecialPowers.spawn(iframe, [], () => {
+ content.wrappedJSObject.logMessageInWorker("live message from iframe");
+ });
+ });
+
+ // Wait until the 2 new logs are available
+ await waitUntil(() => resources.length === messageCount + 2);
+ const liveMessageFromWorkerInMainPage = resources.find(
+ ({ message }) => message.arguments[1] === "live message from main page"
+ );
+ const liveMessageFromWorkerInIframe = resources.find(
+ ({ message }) => message.arguments[1] === "live message from iframe"
+ );
+
+ checkLogInWorkerMessage(
+ liveMessageFromWorkerInMainPage,
+ "live message from main page"
+ );
+
+ checkLogInWorkerMessage(
+ liveMessageFromWorkerInIframe,
+ "live message from iframe"
+ );
+
+ // update the current number of resources received
+ messageCount = resources.length;
+
+ info("Now spawn new workers and log messages in main page and iframe");
+ await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [WORKER_FILE],
+ async workerUrl => {
+ const spawnedWorker = new content.Worker(`${workerUrl}#spawned-worker`);
+ spawnedWorker.postMessage({
+ type: "log-in-worker",
+ message: "live message in spawned worker from main page",
+ });
+
+ const iframe = content.document.querySelector("iframe");
+ SpecialPowers.spawn(iframe, [workerUrl], async innerWorkerUrl => {
+ const spawnedWorkerInIframe = new content.Worker(
+ `${innerWorkerUrl}#spawned-worker-in-iframe`
+ );
+ spawnedWorkerInIframe.postMessage({
+ type: "log-in-worker",
+ message: "live message in spawned worker from iframe",
+ });
+ });
+ }
+ );
+
+ info(
+ "Wait until the 4 new logs are available (the ones logged at worker creation + the ones from postMessage"
+ );
+ await waitUntil(
+ () => resources.length === messageCount + 4,
+ `Couldn't get the expected number of resources (expected ${messageCount +
+ 4}, got ${resources.length})`
+ );
+ const startLogFromSpawnedWorkerInMainPage = resources.find(
+ ({ message }) =>
+ message.arguments[1] === `${URL_ROOT_SSL}${WORKER_FILE}#spawned-worker`
+ );
+ const startLogFromSpawnedWorkerInIframe = resources.find(
+ ({ message }) =>
+ message.arguments[1] ===
+ `${URL_ROOT_ORG_SSL}${WORKER_FILE}#spawned-worker-in-iframe`
+ );
+ const liveMessageFromSpawnedWorkerInMainPage = resources.find(
+ ({ message }) =>
+ message.arguments[1] === "live message in spawned worker from main page"
+ );
+ const liveMessageFromSpawnedWorkerInIframe = resources.find(
+ ({ message }) =>
+ message.arguments[1] === "live message in spawned worker from iframe"
+ );
+
+ checkStartWorkerLogMessage(
+ startLogFromSpawnedWorkerInMainPage,
+ `${URL_ROOT_SSL}${WORKER_FILE}#spawned-worker`
+ );
+ checkStartWorkerLogMessage(
+ startLogFromSpawnedWorkerInIframe,
+ `${URL_ROOT_ORG_SSL}${WORKER_FILE}#spawned-worker-in-iframe`
+ );
+ checkLogInWorkerMessage(
+ liveMessageFromSpawnedWorkerInMainPage,
+ "live message in spawned worker from main page"
+ );
+ checkLogInWorkerMessage(
+ liveMessageFromSpawnedWorkerInIframe,
+ "live message in spawned worker from iframe"
+ );
+ // update the current number of resources received
+ messageCount = resources.length;
+
+ info(
+ "Add a remote iframe on the same origin we already have an iframe and check we get the messages"
+ );
+ await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [IFRAME_FILE],
+ async iframeUrl => {
+ const iframe = content.document.createElement("iframe");
+ iframe.src = `${iframeUrl}?hashSuffix=in-second-iframe`;
+ content.document.body.append(iframe);
+ }
+ );
+
+ info("Wait until the new log is available");
+ await waitUntil(
+ () => resources.length === messageCount + 1,
+ `Couldn't get the expected number of resources (expected ${messageCount +
+ 1}, got ${resources.length})`
+ );
+ const startLogFromWorkerInSecondIframe = resources[resources.length - 1];
+ checkStartWorkerLogMessage(
+ startLogFromWorkerInSecondIframe,
+ `${URL_ROOT_ORG_SSL}${WORKER_FILE}#simple-worker-in-second-iframe`
+ );
+
+ targetList.destroy();
+ await client.close();
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async () => {
+ // registrationPromise is set by the test page.
+ const registration = await content.wrappedJSObject.registrationPromise;
+ registration.unregister();
+ });
+});
+
+function checkStartWorkerLogMessage(resource, expectedUrl) {
+ const { message } = resource;
+ const [firstArg, secondArg, thirdArg] = message.arguments;
+ is(firstArg, "[WORKER] started", "Got the expected first argument");
+ is(secondArg, expectedUrl, "expected url was logged");
+ is(
+ thirdArg?._grip?.class,
+ "DedicatedWorkerGlobalScope",
+ "the global scope was logged as expected"
+ );
+}
+
+function checkLogInWorkerMessage(resource, expectedMessage) {
+ const { message } = resource;
+ const [firstArg, secondArg, thirdArg] = message.arguments;
+ is(firstArg, "[WORKER]", "Got the expected first argument");
+ is(secondArg, expectedMessage, "expected message was logged");
+ is(
+ thirdArg?._grip?.class,
+ "MessageEvent",
+ "the message event object was logged as expected"
+ );
+}
diff --git a/devtools/shared/resources/tests/browser_resources_css_changes.js b/devtools/shared/resources/tests/browser_resources_css_changes.js
new file mode 100644
index 0000000000..d81953e75e
--- /dev/null
+++ b/devtools/shared/resources/tests/browser_resources_css_changes.js
@@ -0,0 +1,134 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the ResourceWatcher API around CSS_CHANGE.
+
+const {
+ ResourceWatcher,
+} = require("devtools/shared/resources/resource-watcher");
+
+add_task(async function() {
+ // Open a test tab
+ const tab = await addTab(
+ "data:text/html,<body style='color: lime;'>CSS Changes</body>"
+ );
+
+ const { client, resourceWatcher, targetList } = await initResourceWatcher(
+ tab
+ );
+
+ // CSS_CHANGE watcher doesn't record modification made before watching,
+ // so we have to start watching before doing any DOM mutation.
+ await resourceWatcher.watchResources([ResourceWatcher.TYPES.CSS_CHANGE], {
+ onAvailable: () => {},
+ });
+
+ const { walker } = await targetList.targetFront.getFront("inspector");
+ const nodeList = await walker.querySelectorAll(walker.rootNode, "body");
+ const body = (await nodeList.items())[0];
+ const style = (
+ await body.inspectorFront.pageStyle.getApplied(body, {
+ skipPseudo: false,
+ })
+ )[0];
+
+ info(
+ "Check whether ResourceWatcher catches CSS change that fired before starting to watch"
+ );
+ await setProperty(style.rule, 0, "color", "black");
+
+ const availableResources = [];
+ await resourceWatcher.watchResources([ResourceWatcher.TYPES.CSS_CHANGE], {
+ onAvailable: resources => availableResources.push(...resources),
+ });
+ assertResource(
+ availableResources[0],
+ { index: 0, property: "color", value: "black" },
+ { index: 0, property: "color", value: "lime" }
+ );
+
+ info(
+ "Check whether ResourceWatcher catches CSS change after the property changed"
+ );
+ await setProperty(style.rule, 0, "background-color", "pink");
+ await waitUntil(() => availableResources.length === 2);
+ assertResource(
+ availableResources[1],
+ { index: 0, property: "background-color", value: "pink" },
+ { index: 0, property: "color", value: "black" }
+ );
+
+ info("Check whether ResourceWatcher catches CSS change of disabling");
+ await setPropertyEnabled(style.rule, 0, "background-color", false);
+ await waitUntil(() => availableResources.length === 3);
+ assertResource(availableResources[2], null, {
+ index: 0,
+ property: "background-color",
+ value: "pink",
+ });
+
+ info("Check whether ResourceWatcher catches CSS change of new property");
+ await createProperty(style.rule, 1, "font-size", "100px");
+ await waitUntil(() => availableResources.length === 4);
+ assertResource(
+ availableResources[3],
+ { index: 1, property: "font-size", value: "100px" },
+ null
+ );
+
+ info("Check whether ResourceWatcher sends all resources added in this test");
+ const existingResources = [];
+ await resourceWatcher.watchResources([ResourceWatcher.TYPES.CSS_CHANGE], {
+ onAvailable: resources => existingResources.push(...resources),
+ });
+ await waitUntil(() => existingResources.length === 4);
+ is(availableResources[0], existingResources[0], "1st resource is correct");
+ is(availableResources[1], existingResources[1], "2nd resource is correct");
+ is(availableResources[2], existingResources[2], "3rd resource is correct");
+ is(availableResources[3], existingResources[3], "4th resource is correct");
+
+ await targetList.destroy();
+ await client.close();
+});
+
+function assertResource(resource, expectedAddedChange, expectedRemovedChange) {
+ if (expectedAddedChange) {
+ is(resource.add.length, 1, "The number of added changes is correct");
+ assertChange(resource.add[0], expectedAddedChange);
+ } else {
+ is(resource.add, null, "There is no added changes");
+ }
+
+ if (expectedRemovedChange) {
+ is(resource.remove.length, 1, "The number of removed changes is correct");
+ assertChange(resource.remove[0], expectedRemovedChange);
+ } else {
+ is(resource.remove, null, "There is no removed changes");
+ }
+}
+
+function assertChange(change, expected) {
+ is(change.index, expected.index, "The index of change is correct");
+ is(change.property, expected.property, "The property of change is correct");
+ is(change.value, expected.value, "The value of change is correct");
+}
+
+async function setProperty(rule, index, property, value) {
+ const modifications = rule.startModifyingProperties({ isKnown: true });
+ modifications.setProperty(index, property, value, "");
+ await modifications.apply();
+}
+
+async function createProperty(rule, index, property, value) {
+ const modifications = rule.startModifyingProperties({ isKnown: true });
+ modifications.createProperty(index, property, value, "", true);
+ await modifications.apply();
+}
+
+async function setPropertyEnabled(rule, index, property, isEnabled) {
+ const modifications = rule.startModifyingProperties({ isKnown: true });
+ modifications.setPropertyEnabled(index, property, isEnabled);
+ await modifications.apply();
+}
diff --git a/devtools/shared/resources/tests/browser_resources_css_messages.js b/devtools/shared/resources/tests/browser_resources_css_messages.js
new file mode 100644
index 0000000000..0e82bacd57
--- /dev/null
+++ b/devtools/shared/resources/tests/browser_resources_css_messages.js
@@ -0,0 +1,202 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the ResourceWatcher API around CSS_MESSAGE
+// Reproduces the CSS message assertions from devtools/shared/webconsole/test/chrome/test_page_errors.html
+
+const {
+ ResourceWatcher,
+} = require("devtools/shared/resources/resource-watcher");
+const { MESSAGE_CATEGORY } = require("devtools/shared/constants");
+
+// Create a simple server so we have a nice sourceName in the resources packets.
+const httpServer = createTestHTTPServer();
+httpServer.registerPathHandler(`/test_css_messages.html`, (req, res) => {
+ res.setStatusLine(req.httpVersion, 200, "OK");
+ res.write(`<meta charset=utf8>
+ <style>
+ html {
+ color: bloup;
+ }
+ </style>Test CSS Messages`);
+});
+
+const TEST_URI = `http://localhost:${httpServer.identity.primaryPort}/test_css_messages.html`;
+
+add_task(async function() {
+ await testWatchingCssMessages();
+ await testWatchingCachedCssMessages();
+});
+
+async function testWatchingCssMessages() {
+ // Disable the preloaded process as it creates processes intermittently
+ // which forces the emission of RDP requests we aren't correctly waiting for.
+ await pushPref("dom.ipc.processPrelaunch.enabled", false);
+
+ // Open a test tab
+ const tab = await addTab(TEST_URI);
+
+ const { client, resourceWatcher, targetList } = await initResourceWatcher(
+ tab
+ );
+
+ const receivedMessages = [];
+ const { onAvailable, onAllMessagesReceived } = setupOnAvailableFunction(
+ targetList,
+ receivedMessages
+ );
+ await resourceWatcher.watchResources([ResourceWatcher.TYPES.CSS_MESSAGE], {
+ onAvailable,
+ });
+
+ info(
+ "Now log CSS warning *after* the call to ResourceWatcher.watchResources and after " +
+ "having received the existing message"
+ );
+ // We need to wait for the first CSS Warning as it is not a cached message; when we
+ // start watching, the `cssErrorReportingEnabled` is checked on the target docShell, and
+ // if it is false, we re-parse the stylesheets to get the messages.
+ await BrowserTestUtils.waitForCondition(() => receivedMessages.length === 1);
+
+ info("Trigger a CSS Warning");
+ triggerCSSWarning(tab);
+
+ info("Waiting for all expected CSS messages to be received");
+ await onAllMessagesReceived;
+ ok(true, "All the expected CSS messages were received");
+
+ Services.console.reset();
+ targetList.destroy();
+ await client.close();
+}
+
+async function testWatchingCachedCssMessages() {
+ // Disable the preloaded process as it creates processes intermittently
+ // which forces the emission of RDP requests we aren't correctly waiting for.
+ await pushPref("dom.ipc.processPrelaunch.enabled", false);
+
+ // Open a test tab
+ const tab = await addTab(TEST_URI);
+
+ // By default, the CSS Parser does not emit warnings at all, for performance matter.
+ // Since we actually want the Parser to emit those messages _before_ we start listening
+ // for CSS messages, we need to set the cssErrorReportingEnabled flag on the docShell.
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], function() {
+ content.docShell.cssErrorReportingEnabled = true;
+ });
+
+ // Setting the docShell flag only indicates to the Parser that from now on, it should
+ // emit warnings. But it does not automatically emit warnings for the existing CSS
+ // errors in the stylesheets. So here we reload the tab, which will make the Parser
+ // parse the stylesheets again, this time emitting warnings.
+ const loaded = BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ tab.linkedBrowser.reload();
+ // wait for the tab to be fully loaded
+ await loaded;
+ // and trigger more CSS warnings
+ await triggerCSSWarning(tab);
+
+ // At this point, all messages should be in the ConsoleService cache, and we can begin
+ // to watch and check that we do retrieve those messages.
+ const { client, resourceWatcher, targetList } = await initResourceWatcher(
+ tab
+ );
+
+ const receivedMessages = [];
+ const { onAvailable } = setupOnAvailableFunction(
+ targetList,
+ receivedMessages
+ );
+ await resourceWatcher.watchResources([ResourceWatcher.TYPES.CSS_MESSAGE], {
+ onAvailable,
+ });
+ is(receivedMessages.length, 3, "Cached messages were retrieved as expected");
+
+ Services.console.reset();
+ targetList.destroy();
+ await client.close();
+}
+
+function setupOnAvailableFunction(targetList, receivedMessages) {
+ // The expected messages are the CSS warnings:
+ // - one for the rule in the style element
+ // - two for the JS modified style we're doing in the test.
+ const expectedMessages = [
+ {
+ pageError: {
+ errorMessage: /Expected color but found โ€˜bloupโ€™/,
+ sourceName: /test_css_messages/,
+ category: MESSAGE_CATEGORY.CSS_PARSER,
+ timeStamp: /^\d+$/,
+ error: false,
+ warning: true,
+ },
+ cssSelectors: "html",
+ },
+ {
+ pageError: {
+ errorMessage: /Error in parsing value for โ€˜widthโ€™/,
+ sourceName: /test_css_messages/,
+ category: MESSAGE_CATEGORY.CSS_PARSER,
+ timeStamp: /^\d+$/,
+ error: false,
+ warning: true,
+ },
+ },
+ {
+ pageError: {
+ errorMessage: /Error in parsing value for โ€˜heightโ€™/,
+ sourceName: /test_css_messages/,
+ category: MESSAGE_CATEGORY.CSS_PARSER,
+ timeStamp: /^\d+$/,
+ error: false,
+ warning: true,
+ },
+ },
+ ];
+
+ let done;
+ const onAllMessagesReceived = new Promise(resolve => (done = resolve));
+ const onAvailable = resources => {
+ for (const resource of resources) {
+ const { pageError } = resource;
+
+ is(
+ resource.targetFront,
+ targetList.targetFront,
+ "The targetFront property is the expected one"
+ );
+
+ if (!pageError.sourceName.includes("test_css_messages")) {
+ info(`Ignore error from unknown source: "${pageError.sourceName}"`);
+ continue;
+ }
+
+ const index = receivedMessages.length;
+ receivedMessages.push(pageError);
+
+ info(
+ `checking received css message #${index}: ${pageError.errorMessage}`
+ );
+ ok(pageError, "The resource has a pageError attribute");
+ checkObject(resource, expectedMessages[index]);
+
+ if (receivedMessages.length == expectedMessages.length) {
+ done();
+ }
+ }
+ };
+ return { onAvailable, onAllMessagesReceived };
+}
+
+/**
+ * Sets invalid values for width and height on the document's body style attribute.
+ */
+function triggerCSSWarning(tab) {
+ return ContentTask.spawn(tab.linkedBrowser, null, function frameScript() {
+ content.document.body.style.width = "red";
+ content.document.body.style.height = "blue";
+ });
+}
diff --git a/devtools/shared/resources/tests/browser_resources_document_events.js b/devtools/shared/resources/tests/browser_resources_document_events.js
new file mode 100644
index 0000000000..bf6a66ed40
--- /dev/null
+++ b/devtools/shared/resources/tests/browser_resources_document_events.js
@@ -0,0 +1,143 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the ResourceWatcher API around DOCUMENT_EVENT
+
+const { TargetList } = require("devtools/shared/resources/target-list");
+const {
+ ResourceWatcher,
+} = require("devtools/shared/resources/resource-watcher");
+
+add_task(async function() {
+ await testDocumentEventResources();
+ await testDocumentEventResourcesWithIgnoreExistingResources();
+});
+
+async function testDocumentEventResources() {
+ info("Test ResourceWatcher for DOCUMENT_EVENT");
+
+ // Open a test tab
+ const tab = await addTab("data:text/html,Document Events");
+
+ // Create a TargetList for the test tab
+ const client = await createLocalClient();
+ const descriptor = await client.mainRoot.getTab({ tab });
+ const target = await descriptor.getTarget();
+ const targetList = new TargetList(client.mainRoot, target);
+ await targetList.startListening();
+
+ // Activate ResourceWatcher
+ const listener = new ResourceListener();
+ const resourceWatcher = new ResourceWatcher(targetList);
+
+ info(
+ "Check whether the document events are fired correctly even when the document was already loaded"
+ );
+ const onLoadingAtInit = listener.once("dom-loading");
+ const onInteractiveAtInit = listener.once("dom-interactive");
+ const onCompleteAtInit = listener.once("dom-complete");
+ await resourceWatcher.watchResources([ResourceWatcher.TYPES.DOCUMENT_EVENT], {
+ onAvailable: parameters => listener.dispatch(parameters),
+ });
+ await assertPromises(onLoadingAtInit, onInteractiveAtInit, onCompleteAtInit);
+ ok(
+ true,
+ "Document events are fired even when the document was already loaded"
+ );
+
+ info("Check whether the document events are fired correctly when reloading");
+ const onLoadingAtReloaded = listener.once("dom-loading");
+ const onInteractiveAtReloaded = listener.once("dom-interactive");
+ const onCompleteAtReloaded = listener.once("dom-complete");
+ gBrowser.reloadTab(tab);
+ await assertPromises(
+ onLoadingAtReloaded,
+ onInteractiveAtReloaded,
+ onCompleteAtReloaded
+ );
+ ok(true, "Document events are fired after reloading");
+
+ await targetList.destroy();
+ await client.close();
+}
+
+async function testDocumentEventResourcesWithIgnoreExistingResources() {
+ info("Test ignoreExistingResources option for DOCUMENT_EVENT");
+
+ const tab = await addTab("data:text/html,Document Events");
+
+ const { client, resourceWatcher, targetList } = await initResourceWatcher(
+ tab
+ );
+
+ info("Check whether the existing document events will not be fired");
+ const documentEvents = [];
+ await resourceWatcher.watchResources([ResourceWatcher.TYPES.DOCUMENT_EVENT], {
+ onAvailable: resources => documentEvents.push(...resources),
+ ignoreExistingResources: true,
+ });
+ is(documentEvents.length, 0, "Existing document events are not fired");
+
+ info("Check whether the future document events are fired");
+ gBrowser.reloadTab(tab);
+ info("Wait for dom-loading, dom-interactive and dom-complete events");
+ await waitUntil(() => documentEvents.length === 3);
+ assertEvents(...documentEvents);
+
+ await targetList.destroy();
+ await client.close();
+}
+
+async function assertPromises(onLoading, onInteractive, onComplete) {
+ const loadingEvent = await onLoading;
+ const interactiveEvent = await onInteractive;
+ const completeEvent = await onComplete;
+ assertEvents(loadingEvent, interactiveEvent, completeEvent);
+}
+
+function assertEvents(loadingEvent, interactiveEvent, completeEvent) {
+ is(
+ typeof loadingEvent.time,
+ "number",
+ "Type of time attribute for loading event is correct"
+ );
+ is(
+ typeof interactiveEvent.time,
+ "number",
+ "Type of time attribute for interactive event is correct"
+ );
+ is(
+ typeof completeEvent.time,
+ "number",
+ "Type of time attribute for complete event is correct"
+ );
+
+ ok(
+ loadingEvent.time < interactiveEvent.time,
+ "Timestamp for interactive event is greater than loading event"
+ );
+ ok(
+ interactiveEvent.time < completeEvent.time,
+ "Timestamp for complete event is greater than interactive event"
+ );
+}
+
+class ResourceListener {
+ _listeners = new Map();
+
+ dispatch(resources) {
+ for (const resource of resources) {
+ const resolve = this._listeners.get(resource.name);
+ if (resolve) {
+ resolve(resource);
+ this._listeners.delete(resource.name);
+ }
+ }
+ }
+
+ once(resourceName) {
+ return new Promise(r => this._listeners.set(resourceName, r));
+ }
+}
diff --git a/devtools/shared/resources/tests/browser_resources_error_messages.js b/devtools/shared/resources/tests/browser_resources_error_messages.js
new file mode 100644
index 0000000000..959a97e87e
--- /dev/null
+++ b/devtools/shared/resources/tests/browser_resources_error_messages.js
@@ -0,0 +1,614 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the ResourceWatcher API around ERROR_MESSAGE
+// Reproduces assertions from devtools/shared/webconsole/test/chrome/test_page_errors.html
+
+const {
+ ResourceWatcher,
+} = require("devtools/shared/resources/resource-watcher");
+
+// Create a simple server so we have a nice sourceName in the resources packets.
+const httpServer = createTestHTTPServer();
+httpServer.registerPathHandler(`/test_page_errors.html`, (req, res) => {
+ res.setStatusLine(req.httpVersion, 200, "OK");
+ res.write(`<meta charset=utf8>Test Error Messages`);
+});
+
+const TEST_URI = `http://localhost:${httpServer.identity.primaryPort}/test_page_errors.html`;
+
+add_task(async function() {
+ // Disable the preloaded process as it creates processes intermittently
+ // which forces the emission of RDP requests we aren't correctly waiting for.
+ await pushPref("dom.ipc.processPrelaunch.enabled", false);
+
+ await testErrorMessagesResources();
+ await testErrorMessagesResourcesWithIgnoreExistingResources();
+});
+
+async function testErrorMessagesResources() {
+ // Open a test tab
+ const tab = await addTab(TEST_URI);
+
+ const { client, resourceWatcher, targetList } = await initResourceWatcher(
+ tab
+ );
+
+ const receivedMessages = [];
+ // The expected messages are the errors, twice (once for cached messages, once for live messages)
+ const expectedMessages = Array.from(expectedPageErrors.values()).concat(
+ Array.from(expectedPageErrors.values())
+ );
+
+ info(
+ "Log some errors *before* calling ResourceWatcher.watchResources in order to assert" +
+ " the behavior of already existing messages."
+ );
+ await triggerErrors(tab);
+
+ let done;
+ const onAllErrorReceived = new Promise(resolve => (done = resolve));
+ const onAvailable = resources => {
+ for (const resource of resources) {
+ const { pageError } = resource;
+
+ is(
+ resource.targetFront,
+ targetList.targetFront,
+ "The targetFront property is the expected one"
+ );
+
+ if (!pageError.sourceName.includes("test_page_errors")) {
+ info(`Ignore error from unknown source: "${pageError.sourceName}"`);
+ continue;
+ }
+
+ const index = receivedMessages.length;
+ receivedMessages.push(pageError);
+
+ info(`checking received page error #${index}: ${pageError.errorMessage}`);
+ ok(pageError, "The resource has a pageError attribute");
+ checkPageErrorResource(pageError, expectedMessages[index]);
+
+ if (receivedMessages.length == expectedMessages.length) {
+ done();
+ }
+ }
+ };
+
+ await resourceWatcher.watchResources([ResourceWatcher.TYPES.ERROR_MESSAGE], {
+ onAvailable,
+ });
+
+ info(
+ "Now log errors *after* the call to ResourceWatcher.watchResources and after having" +
+ " received all existing messages"
+ );
+ await BrowserTestUtils.waitForCondition(
+ () => receivedMessages.length === expectedPageErrors.size
+ );
+ await triggerErrors(tab);
+
+ info("Waiting for all expected errors to be received");
+ await onAllErrorReceived;
+ ok(true, "All the expected errors were received");
+
+ Services.console.reset();
+ targetList.destroy();
+ await client.close();
+}
+
+async function testErrorMessagesResourcesWithIgnoreExistingResources() {
+ info("Test ignoreExistingResources option for ERROR_MESSAGE");
+ const tab = await addTab(TEST_URI);
+
+ const { client, resourceWatcher, targetList } = await initResourceWatcher(
+ tab
+ );
+
+ info(
+ "Check whether onAvailable will not be called with existing error messages"
+ );
+ await triggerErrors(tab);
+
+ const availableResources = [];
+ await resourceWatcher.watchResources([ResourceWatcher.TYPES.ERROR_MESSAGE], {
+ onAvailable: resources => availableResources.push(...resources),
+ ignoreExistingResources: true,
+ });
+ is(
+ availableResources.length,
+ 0,
+ "onAvailable wasn't called for existing error messages"
+ );
+
+ info(
+ "Check whether onAvailable will be called with the future error messages"
+ );
+ await triggerErrors(tab);
+
+ const expectedMessages = Array.from(expectedPageErrors.values());
+ await waitUntil(() => availableResources.length === expectedMessages.length);
+ for (let i = 0; i < expectedMessages.length; i++) {
+ const { pageError } = availableResources[i];
+ const expected = expectedMessages[i];
+ checkPageErrorResource(pageError, expected);
+ }
+
+ Services.console.reset();
+ await targetList.destroy();
+ await client.close();
+}
+
+/**
+ * Triggers all the errors in the content page.
+ */
+async function triggerErrors(tab) {
+ for (const [expression, expected] of expectedPageErrors.entries()) {
+ if (
+ !expected[noUncaughtException] &&
+ !Services.appinfo.browserTabsRemoteAutostart
+ ) {
+ expectUncaughtException();
+ }
+
+ await ContentTask.spawn(tab.linkedBrowser, expression, function frameScript(
+ expr
+ ) {
+ const document = content.document;
+ const scriptEl = document.createElement("script");
+ scriptEl.textContent = expr;
+ document.body.appendChild(scriptEl);
+ });
+
+ if (expected.isPromiseRejection) {
+ // Wait a bit after an uncaught promise rejection error, as they are not emitted
+ // right away.
+
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(res => setTimeout(res, 10));
+ }
+ }
+}
+
+function checkPageErrorResource(pageErrorResource, expected) {
+ // Let's remove test harness related frames in stacktrace
+ const clonedPageErrorResource = { ...pageErrorResource };
+ if (clonedPageErrorResource.stacktrace) {
+ const index = clonedPageErrorResource.stacktrace.findIndex(frame =>
+ frame.filename.startsWith("resource://testing-common/content-task.js")
+ );
+ if (index > -1) {
+ clonedPageErrorResource.stacktrace = clonedPageErrorResource.stacktrace.slice(
+ 0,
+ index
+ );
+ }
+ }
+ checkObject(clonedPageErrorResource, expected);
+}
+
+const noUncaughtException = Symbol();
+const NUMBER_REGEX = /^\d+$/;
+
+const mdnUrl = path =>
+ `https://developer.mozilla.org/${path}?utm_source=mozilla&utm_medium=firefox-console-errors&utm_campaign=default`;
+
+const expectedPageErrors = new Map([
+ [
+ "document.doTheImpossible();",
+ {
+ errorMessage: /doTheImpossible/,
+ errorMessageName: undefined,
+ sourceName: /test_page_errors/,
+ category: "content javascript",
+ timeStamp: NUMBER_REGEX,
+ error: true,
+ warning: false,
+ info: false,
+ lineText: "",
+ lineNumber: NUMBER_REGEX,
+ columnNumber: NUMBER_REGEX,
+ exceptionDocURL: mdnUrl(
+ "docs/Web/JavaScript/Reference/Errors/Not_a_function"
+ ),
+ innerWindowID: NUMBER_REGEX,
+ private: false,
+ stacktrace: [
+ {
+ filename: /test_page_errors\.html/,
+ lineNumber: 1,
+ columnNumber: 10,
+ functionName: null,
+ },
+ ],
+ notes: null,
+ chromeContext: false,
+ isPromiseRejection: false,
+ isForwardedFromContentProcess: false,
+ },
+ ],
+ [
+ "(42).toString(0);",
+ {
+ errorMessage: /radix/,
+ errorMessageName: "JSMSG_BAD_RADIX",
+ sourceName: /test_page_errors/,
+ category: "content javascript",
+ timeStamp: NUMBER_REGEX,
+ error: true,
+ warning: false,
+ info: false,
+ lineText: "",
+ lineNumber: NUMBER_REGEX,
+ columnNumber: NUMBER_REGEX,
+ exceptionDocURL: mdnUrl("docs/Web/JavaScript/Reference/Errors/Bad_radix"),
+ innerWindowID: NUMBER_REGEX,
+ private: false,
+ stacktrace: [
+ {
+ filename: /test_page_errors\.html/,
+ lineNumber: 1,
+ columnNumber: 6,
+ functionName: null,
+ },
+ ],
+ notes: null,
+ chromeContext: false,
+ isPromiseRejection: false,
+ isForwardedFromContentProcess: false,
+ },
+ ],
+ [
+ "'use strict'; (Object.freeze({name: 'Elsa', score: 157})).score = 0;",
+ {
+ errorMessage: /read.only/,
+ errorMessageName: "JSMSG_READ_ONLY",
+ sourceName: /test_page_errors/,
+ category: "content javascript",
+ timeStamp: NUMBER_REGEX,
+ error: true,
+ warning: false,
+ info: false,
+ lineText: "",
+ lineNumber: NUMBER_REGEX,
+ columnNumber: NUMBER_REGEX,
+ exceptionDocURL: mdnUrl("docs/Web/JavaScript/Reference/Errors/Read-only"),
+ innerWindowID: NUMBER_REGEX,
+ private: false,
+ stacktrace: [
+ {
+ filename: /test_page_errors\.html/,
+ lineNumber: 1,
+ columnNumber: 23,
+ functionName: null,
+ },
+ ],
+ notes: null,
+ chromeContext: false,
+ isPromiseRejection: false,
+ isForwardedFromContentProcess: false,
+ },
+ ],
+ [
+ "([]).length = -1",
+ {
+ errorMessage: /array length/,
+ errorMessageName: "JSMSG_BAD_ARRAY_LENGTH",
+ sourceName: /test_page_errors/,
+ category: "content javascript",
+ timeStamp: NUMBER_REGEX,
+ error: true,
+ warning: false,
+ info: false,
+ lineText: "",
+ lineNumber: NUMBER_REGEX,
+ columnNumber: NUMBER_REGEX,
+ exceptionDocURL: mdnUrl(
+ "docs/Web/JavaScript/Reference/Errors/Invalid_array_length"
+ ),
+ innerWindowID: NUMBER_REGEX,
+ private: false,
+ stacktrace: [
+ {
+ filename: /test_page_errors\.html/,
+ lineNumber: 1,
+ columnNumber: 2,
+ functionName: null,
+ },
+ ],
+ notes: null,
+ chromeContext: false,
+ isPromiseRejection: false,
+ isForwardedFromContentProcess: false,
+ },
+ ],
+ [
+ "'abc'.repeat(-1);",
+ {
+ errorMessage: /repeat count.*non-negative/,
+ errorMessageName: "JSMSG_NEGATIVE_REPETITION_COUNT",
+ sourceName: /test_page_errors/,
+ category: "content javascript",
+ timeStamp: NUMBER_REGEX,
+ error: true,
+ warning: false,
+ info: false,
+ lineText: "",
+ lineNumber: NUMBER_REGEX,
+ columnNumber: NUMBER_REGEX,
+ exceptionDocURL: mdnUrl(
+ "docs/Web/JavaScript/Reference/Errors/Negative_repetition_count"
+ ),
+ innerWindowID: NUMBER_REGEX,
+ private: false,
+ stacktrace: [
+ {
+ filename: "self-hosted",
+ sourceId: null,
+ lineNumber: NUMBER_REGEX,
+ columnNumber: NUMBER_REGEX,
+ functionName: "repeat",
+ },
+ {
+ filename: /test_page_errors\.html/,
+ lineNumber: 1,
+ columnNumber: 7,
+ functionName: null,
+ },
+ ],
+ notes: null,
+ chromeContext: false,
+ isPromiseRejection: false,
+ isForwardedFromContentProcess: false,
+ },
+ ],
+ [
+ "'a'.repeat(2e28);",
+ {
+ errorMessage: /repeat count.*less than infinity/,
+ errorMessageName: "JSMSG_RESULTING_STRING_TOO_LARGE",
+ sourceName: /test_page_errors/,
+ category: "content javascript",
+ timeStamp: NUMBER_REGEX,
+ error: true,
+ warning: false,
+ info: false,
+ lineText: "",
+ lineNumber: NUMBER_REGEX,
+ columnNumber: NUMBER_REGEX,
+ exceptionDocURL: mdnUrl(
+ "docs/Web/JavaScript/Reference/Errors/Resulting_string_too_large"
+ ),
+ innerWindowID: NUMBER_REGEX,
+ private: false,
+ stacktrace: [
+ {
+ filename: "self-hosted",
+ sourceId: null,
+ lineNumber: NUMBER_REGEX,
+ columnNumber: NUMBER_REGEX,
+ functionName: "repeat",
+ },
+ {
+ filename: /test_page_errors\.html/,
+ lineNumber: 1,
+ columnNumber: 5,
+ functionName: null,
+ },
+ ],
+ notes: null,
+ chromeContext: false,
+ isPromiseRejection: false,
+ isForwardedFromContentProcess: false,
+ },
+ ],
+ [
+ "77.1234.toExponential(-1);",
+ {
+ errorMessage: /out of range/,
+ errorMessageName: "JSMSG_PRECISION_RANGE",
+ sourceName: /test_page_errors/,
+ category: "content javascript",
+ timeStamp: NUMBER_REGEX,
+ error: true,
+ warning: false,
+ info: false,
+ lineText: "",
+ lineNumber: NUMBER_REGEX,
+ columnNumber: NUMBER_REGEX,
+ exceptionDocURL: mdnUrl(
+ "docs/Web/JavaScript/Reference/Errors/Precision_range"
+ ),
+ innerWindowID: NUMBER_REGEX,
+ private: false,
+ stacktrace: [
+ {
+ filename: /test_page_errors\.html/,
+ lineNumber: 1,
+ columnNumber: 9,
+ functionName: null,
+ },
+ ],
+ notes: null,
+ chromeContext: false,
+ isPromiseRejection: false,
+ isForwardedFromContentProcess: false,
+ },
+ ],
+ [
+ "function a() { return; 1 + 1; }",
+ {
+ errorMessage: /unreachable code/,
+ errorMessageName: "JSMSG_STMT_AFTER_RETURN",
+ sourceName: /test_page_errors/,
+ category: "content javascript",
+ timeStamp: NUMBER_REGEX,
+ error: false,
+ warning: true,
+ info: false,
+ sourceId: null,
+ lineText: "function a() { return; 1 + 1; }",
+ lineNumber: NUMBER_REGEX,
+ columnNumber: NUMBER_REGEX,
+ exceptionDocURL: mdnUrl(
+ "docs/Web/JavaScript/Reference/Errors/Stmt_after_return"
+ ),
+ innerWindowID: NUMBER_REGEX,
+ private: false,
+ stacktrace: null,
+ notes: null,
+ chromeContext: false,
+ isPromiseRejection: false,
+ isForwardedFromContentProcess: false,
+ },
+ ],
+ [
+ "{let a, a;}",
+ {
+ errorMessage: /redeclaration of/,
+ errorMessageName: "JSMSG_REDECLARED_VAR",
+ sourceName: /test_page_errors/,
+ category: "content javascript",
+ timeStamp: NUMBER_REGEX,
+ error: true,
+ warning: false,
+ info: false,
+ sourceId: null,
+ lineText: "{let a, a;}",
+ lineNumber: NUMBER_REGEX,
+ columnNumber: NUMBER_REGEX,
+ exceptionDocURL: mdnUrl(
+ "docs/Web/JavaScript/Reference/Errors/Redeclared_parameter"
+ ),
+ innerWindowID: NUMBER_REGEX,
+ private: false,
+ stacktrace: [],
+ chromeContext: false,
+ isPromiseRejection: false,
+ isForwardedFromContentProcess: false,
+ notes: [
+ {
+ messageBody: /Previously declared at line/,
+ frame: {
+ source: /test_page_errors/,
+ },
+ },
+ ],
+ },
+ ],
+ [
+ `var error = new TypeError("abc");
+ error.name = "MyError";
+ error.message = "here";
+ throw error`,
+ {
+ errorMessage: /MyError: here/,
+ errorMessageName: "",
+ sourceName: /test_page_errors/,
+ category: "content javascript",
+ timeStamp: NUMBER_REGEX,
+ error: true,
+ warning: false,
+ info: false,
+ lineText: "",
+ lineNumber: NUMBER_REGEX,
+ columnNumber: NUMBER_REGEX,
+ exceptionDocURL: mdnUrl("docs/Web/JavaScript/Reference/Errors/Read-only"),
+ innerWindowID: NUMBER_REGEX,
+ private: false,
+ stacktrace: [
+ {
+ filename: /test_page_errors\.html/,
+ lineNumber: 1,
+ columnNumber: 13,
+ functionName: null,
+ },
+ ],
+ notes: null,
+ chromeContext: false,
+ isPromiseRejection: false,
+ isForwardedFromContentProcess: false,
+ },
+ ],
+ [
+ "DOMTokenList.prototype.contains.call([])",
+ {
+ errorMessage: /does not implement interface/,
+ errorMessageName: "MSG_METHOD_THIS_DOES_NOT_IMPLEMENT_INTERFACE",
+ sourceName: /test_page_errors/,
+ category: "content javascript",
+ timeStamp: NUMBER_REGEX,
+ error: true,
+ warning: false,
+ info: false,
+ lineText: "",
+ lineNumber: NUMBER_REGEX,
+ columnNumber: NUMBER_REGEX,
+ exceptionDocURL: mdnUrl("docs/Web/JavaScript/Reference/Errors/Read-only"),
+ innerWindowID: NUMBER_REGEX,
+ private: false,
+ stacktrace: [
+ {
+ filename: /test_page_errors\.html/,
+ lineNumber: 1,
+ columnNumber: 33,
+ functionName: null,
+ },
+ ],
+ notes: null,
+ chromeContext: false,
+ isPromiseRejection: false,
+ isForwardedFromContentProcess: false,
+ },
+ ],
+ [
+ `
+ function promiseThrow() {
+ var error2 = new TypeError("abc");
+ error2.name = "MyPromiseError";
+ error2.message = "here2";
+ return Promise.reject(error2);
+ }
+ promiseThrow()`,
+ {
+ errorMessage: /MyPromiseError: here2/,
+ errorMessageName: "",
+ sourceName: /test_page_errors/,
+ category: "content javascript",
+ timeStamp: NUMBER_REGEX,
+ error: true,
+ warning: false,
+ info: false,
+ lineText: "",
+ lineNumber: NUMBER_REGEX,
+ columnNumber: NUMBER_REGEX,
+ exceptionDocURL: mdnUrl("docs/Web/JavaScript/Reference/Errors/Read-only"),
+ innerWindowID: NUMBER_REGEX,
+ private: false,
+ stacktrace: [
+ {
+ filename: /test_page_errors\.html/,
+ sourceId: null,
+ lineNumber: 6,
+ columnNumber: 24,
+ functionName: "promiseThrow",
+ },
+ {
+ filename: /test_page_errors\.html/,
+ sourceId: null,
+ lineNumber: 8,
+ columnNumber: 7,
+ functionName: null,
+ },
+ ],
+ notes: null,
+ chromeContext: false,
+ isPromiseRejection: true,
+ isForwardedFromContentProcess: false,
+ [noUncaughtException]: true,
+ },
+ ],
+]);
diff --git a/devtools/shared/resources/tests/browser_resources_getAllResources.js b/devtools/shared/resources/tests/browser_resources_getAllResources.js
new file mode 100644
index 0000000000..c22f6e0b52
--- /dev/null
+++ b/devtools/shared/resources/tests/browser_resources_getAllResources.js
@@ -0,0 +1,102 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test getAllResources function of the ResourceWatcher.
+
+const {
+ ResourceWatcher,
+} = require("devtools/shared/resources/resource-watcher");
+
+const TEST_URI = "data:text/html;charset=utf-8,getAllResources test";
+
+add_task(async function() {
+ const tab = await addTab(TEST_URI);
+
+ const { client, resourceWatcher, targetList } = await initResourceWatcher(
+ tab
+ );
+
+ info("Check the resources gotten from getAllResources at initial");
+ is(
+ resourceWatcher.getAllResources(ResourceWatcher.TYPES.CONSOLE_MESSAGE)
+ .length,
+ 0,
+ "There is no resources at initial"
+ );
+
+ info(
+ "Start to watch the available resources in order to compare with resources gotten from getAllResources"
+ );
+ const availableResources = [];
+ const onAvailable = resources => availableResources.push(...resources);
+ await resourceWatcher.watchResources(
+ [ResourceWatcher.TYPES.CONSOLE_MESSAGE],
+ { onAvailable }
+ );
+
+ info("Check the resources after some resources are available");
+ const messages = ["a", "b", "c"];
+ await logMessages(tab.linkedBrowser, messages);
+ await waitUntil(() => availableResources.length >= messages.length);
+ assertResources(
+ resourceWatcher.getAllResources(ResourceWatcher.TYPES.CONSOLE_MESSAGE),
+ availableResources
+ );
+ assertResources(
+ resourceWatcher.getAllResources(ResourceWatcher.TYPES.STYLESHEET),
+ []
+ );
+
+ info("Check the resources after reloading");
+ const onReloaded = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+ gBrowser.reloadTab(tab);
+ await onReloaded;
+ assertResources(
+ resourceWatcher.getAllResources(ResourceWatcher.TYPES.CONSOLE_MESSAGE),
+ []
+ );
+
+ info("Append some resources again to test unwatching");
+ await logMessages(tab.linkedBrowser, messages);
+ await waitUntil(
+ () =>
+ resourceWatcher.getAllResources(ResourceWatcher.TYPES.CONSOLE_MESSAGE)
+ .length === messages.length
+ );
+
+ info("Check the resources after unwatching");
+ resourceWatcher.unwatchResources([ResourceWatcher.TYPES.CONSOLE_MESSAGE], {
+ onAvailable,
+ });
+ assertResources(
+ resourceWatcher.getAllResources(ResourceWatcher.TYPES.CONSOLE_MESSAGE),
+ []
+ );
+
+ await targetList.destroy();
+ await client.close();
+});
+
+function assertResources(resources, expectedResources) {
+ is(
+ resources.length,
+ expectedResources.length,
+ "Number of the resources is correct"
+ );
+
+ for (let i = 0; i < resources.length; i++) {
+ const resource = resources[i];
+ const expectedResource = expectedResources[i];
+ ok(resource === expectedResource, `The ${i}th resource is correct`);
+ }
+}
+
+function logMessages(browser, messages) {
+ return ContentTask.spawn(browser, { messages }, args => {
+ for (const message of args.messages) {
+ content.console.log(message);
+ }
+ });
+}
diff --git a/devtools/shared/resources/tests/browser_resources_network_event_stacktraces.js b/devtools/shared/resources/tests/browser_resources_network_event_stacktraces.js
new file mode 100644
index 0000000000..23500b231c
--- /dev/null
+++ b/devtools/shared/resources/tests/browser_resources_network_event_stacktraces.js
@@ -0,0 +1,104 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the ResourceWatcher API around NETWORK_EVENT_STACKTRACE
+
+const {
+ ResourceWatcher,
+} = require("devtools/shared/resources/resource-watcher");
+
+const TEST_URI = `${URL_ROOT_SSL}network_document.html`;
+
+const REQUEST_STUB = {
+ code: `await fetch("/request_post_0.html", { method: "POST" });`,
+ expected: {
+ stacktraceAvailable: true,
+ lastFrame: {
+ filename:
+ "https://example.com/browser/devtools/shared/resources/tests/network_document.html",
+ lineNumber: 1,
+ columnNumber: 40,
+ functionName: "triggerRequest",
+ asyncCause: null,
+ },
+ },
+};
+
+add_task(async function() {
+ info("Test network stacktraces events");
+ const tab = await addTab(TEST_URI);
+ const { client, resourceWatcher, targetList } = await initResourceWatcher(
+ tab
+ );
+
+ const networkEvents = new Map();
+ const stackTraces = new Map();
+
+ function onResourceAvailable(resources) {
+ for (const resource of resources) {
+ if (
+ resource.resourceType === ResourceWatcher.TYPES.NETWORK_EVENT_STACKTRACE
+ ) {
+ ok(
+ !networkEvents.has(resource.resourceId),
+ "The network event does not exist"
+ );
+
+ is(
+ resource.stacktraceAvailable,
+ REQUEST_STUB.expected.stacktraceAvailable,
+ "The stacktrace is available"
+ );
+ is(
+ JSON.stringify(resource.lastFrame),
+ JSON.stringify(REQUEST_STUB.expected.lastFrame),
+ "The last frame of the stacktrace is available"
+ );
+
+ stackTraces.set(resource.resourceId, true);
+ return;
+ }
+
+ if (resource.resourceType === ResourceWatcher.TYPES.NETWORK_EVENT) {
+ ok(
+ stackTraces.has(resource.stacktraceResourceId),
+ "The stack trace does exists"
+ );
+
+ networkEvents.set(resource.resourceId, true);
+ }
+ }
+ }
+
+ function onResourceUpdated() {}
+
+ await resourceWatcher.watchResources(
+ [
+ ResourceWatcher.TYPES.NETWORK_EVENT_STACKTRACE,
+ ResourceWatcher.TYPES.NETWORK_EVENT,
+ ],
+ {
+ onAvailable: onResourceAvailable,
+ onUpdated: onResourceUpdated,
+ }
+ );
+
+ await triggerNetworkRequests(tab.linkedBrowser, [REQUEST_STUB.code]);
+
+ resourceWatcher.unwatchResources(
+ [
+ ResourceWatcher.TYPES.NETWORK_EVENT_STACKTRACE,
+ ResourceWatcher.TYPES.NETWORK_EVENT,
+ ],
+ {
+ onAvailable: onResourceAvailable,
+ onUpdated: onResourceUpdated,
+ }
+ );
+
+ await targetList.destroy();
+ await client.close();
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/devtools/shared/resources/tests/browser_resources_network_events.js b/devtools/shared/resources/tests/browser_resources_network_events.js
new file mode 100644
index 0000000000..81113779bf
--- /dev/null
+++ b/devtools/shared/resources/tests/browser_resources_network_events.js
@@ -0,0 +1,258 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the ResourceWatcher API around NETWORK_EVENT
+
+const {
+ ResourceWatcher,
+} = require("devtools/shared/resources/resource-watcher");
+
+const EXAMPLE_DOMAIN = "https://example.com/";
+const TEST_URI = `${URL_ROOT_SSL}network_document.html`;
+
+add_task(async function() {
+ info("Test network events");
+ await testNetworkEventResourcesWithExistingResources();
+ await testNetworkEventResourcesWithoutExistingResources();
+});
+
+async function testNetworkEventResourcesWithExistingResources() {
+ info(`Tests for network event resources with the existing resources`);
+ await testNetworkEventResources({
+ ignoreExistingResources: false,
+ // 1 available event fired, for the existing resource in the cache.
+ // 1 available event fired, when live request is created.
+ totalExpectedOnAvailableCounts: 2,
+ // 1 update events fired, when live request is updated.
+ totalExpectedOnUpdatedCounts: 1,
+ expectedResourcesOnAvailable: {
+ [`${EXAMPLE_DOMAIN}cached_post.html`]: {
+ resourceType: ResourceWatcher.TYPES.NETWORK_EVENT,
+ method: "POST",
+ },
+ [`${EXAMPLE_DOMAIN}live_get.html`]: {
+ resourceType: ResourceWatcher.TYPES.NETWORK_EVENT,
+ method: "GET",
+ },
+ },
+ expectedResourcesOnUpdated: {
+ [`${EXAMPLE_DOMAIN}live_get.html`]: {
+ resourceType: ResourceWatcher.TYPES.NETWORK_EVENT,
+ method: "GET",
+ },
+ },
+ });
+}
+
+async function testNetworkEventResourcesWithoutExistingResources() {
+ info(`Tests for network event resources without the existing resources`);
+ await testNetworkEventResources({
+ ignoreExistingResources: true,
+ // 1 available event fired, when live request is created.
+ totalExpectedOnAvailableCounts: 1,
+ // 1 update events fired, when live request is updated.
+ totalExpectedOnUpdatedCounts: 1,
+ expectedResourcesOnAvailable: {
+ [`${EXAMPLE_DOMAIN}live_get.html`]: {
+ resourceType: ResourceWatcher.TYPES.NETWORK_EVENT,
+ method: "GET",
+ },
+ },
+ expectedResourcesOnUpdated: {
+ [`${EXAMPLE_DOMAIN}live_get.html`]: {
+ resourceType: ResourceWatcher.TYPES.NETWORK_EVENT,
+ method: "GET",
+ },
+ },
+ });
+}
+
+async function testNetworkEventResources(options) {
+ const tab = await addTab(TEST_URI);
+ const { client, resourceWatcher, targetList } = await initResourceWatcher(
+ tab
+ );
+
+ info(
+ `Trigger some network requests *before* calling ResourceWatcher.watchResources
+ in order to assert the behavior of already existing network events.`
+ );
+
+ let onResourceAvailable = () => {};
+ let onResourceUpdated = () => {};
+
+ // Lets make sure there is already a network event resource in the cache.
+ const waitOnRequestForResourceWatcherCache = new Promise(resolve => {
+ onResourceAvailable = resources => {
+ for (const resource of resources) {
+ is(
+ resource.resourceType,
+ ResourceWatcher.TYPES.NETWORK_EVENT,
+ "Received a network event resource"
+ );
+ }
+ };
+
+ onResourceUpdated = updates => {
+ for (const { resource } of updates) {
+ is(
+ resource.resourceType,
+ ResourceWatcher.TYPES.NETWORK_EVENT,
+ "Received a network update event resource"
+ );
+ resolve();
+ }
+ };
+
+ resourceWatcher
+ .watchResources([ResourceWatcher.TYPES.NETWORK_EVENT], {
+ onAvailable: onResourceAvailable,
+ onUpdated: onResourceUpdated,
+ })
+ .then(() => {
+ // We can only trigger the requests once `watchResources` settles, otherwise the
+ // thread might be paused.
+ triggerNetworkRequests(tab.linkedBrowser, [cachedRequest]);
+ });
+ });
+
+ await waitOnRequestForResourceWatcherCache;
+
+ const actualResourcesOnAvailable = {};
+ const actualResourcesOnUpdated = {};
+
+ let {
+ totalExpectedOnAvailableCounts,
+ totalExpectedOnUpdatedCounts,
+ expectedResourcesOnAvailable,
+ expectedResourcesOnUpdated,
+
+ ignoreExistingResources,
+ } = options;
+
+ const waitForAllExpectedOnAvailableEvents = waitUntil(
+ () => totalExpectedOnAvailableCounts == 0
+ );
+ const waitForAllExpectedOnUpdatedEvents = waitUntil(
+ () => totalExpectedOnUpdatedCounts == 0
+ );
+
+ const onAvailable = resources => {
+ for (const resource of resources) {
+ is(
+ resource.resourceType,
+ ResourceWatcher.TYPES.NETWORK_EVENT,
+ "Received a network event resource"
+ );
+ actualResourcesOnAvailable[resource.url] = {
+ resourceId: resource.resourceId,
+ resourceType: resource.resourceType,
+ method: resource.method,
+ };
+ totalExpectedOnAvailableCounts--;
+ }
+ };
+
+ const onUpdated = updates => {
+ for (const { resource } of updates) {
+ is(
+ resource.resourceType,
+ ResourceWatcher.TYPES.NETWORK_EVENT,
+ "Received a network update event resource"
+ );
+ actualResourcesOnUpdated[resource.url] = {
+ resourceId: resource.resourceId,
+ resourceType: resource.resourceType,
+ method: resource.method,
+ };
+ totalExpectedOnUpdatedCounts--;
+ }
+ };
+
+ await resourceWatcher.watchResources([ResourceWatcher.TYPES.NETWORK_EVENT], {
+ onAvailable,
+ onUpdated,
+ ignoreExistingResources,
+ });
+
+ info(
+ `Trigger the rest of the requests *after* calling ResourceWatcher.watchResources
+ in order to assert the behavior of live network events.`
+ );
+ await triggerNetworkRequests(tab.linkedBrowser, [liveRequest]);
+
+ await Promise.all([
+ waitForAllExpectedOnAvailableEvents,
+ waitForAllExpectedOnUpdatedEvents,
+ ]);
+
+ info("Check the resources on available");
+ is(
+ Object.keys(actualResourcesOnAvailable).length,
+ Object.keys(expectedResourcesOnAvailable).length,
+ "Got the expected number of network events fired onAvailable"
+ );
+
+ // assert that the resourceId for the the available and updated events match
+ is(
+ actualResourcesOnAvailable[`${EXAMPLE_DOMAIN}live_get.html`].resourceId,
+ actualResourcesOnUpdated[`${EXAMPLE_DOMAIN}live_get.html`].resourceId,
+ "The resource id's are the same"
+ );
+
+ // assert the resources emitted when the network event is created
+ for (const key in expectedResourcesOnAvailable) {
+ const expected = expectedResourcesOnAvailable[key];
+ const actual = actualResourcesOnAvailable[key];
+ assertResources(actual, expected);
+ }
+
+ info("Check the resources on updated");
+
+ is(
+ Object.keys(actualResourcesOnUpdated).length,
+ Object.keys(expectedResourcesOnUpdated).length,
+ "Got the expected number of network events fired onUpdated"
+ );
+
+ // assert the resources emitted when the network event is updated
+ for (const key in expectedResourcesOnUpdated) {
+ const expected = expectedResourcesOnUpdated[key];
+ const actual = actualResourcesOnUpdated[key];
+ assertResources(actual, expected);
+ }
+
+ await resourceWatcher.unwatchResources(
+ [ResourceWatcher.TYPES.NETWORK_EVENT],
+ {
+ onAvailable,
+ onUpdated,
+ ignoreExistingResources,
+ }
+ );
+
+ await resourceWatcher.unwatchResources(
+ [ResourceWatcher.TYPES.NETWORK_EVENT],
+ {
+ onAvailable: onResourceAvailable,
+ onUpdated: onResourceUpdated,
+ }
+ );
+ await targetList.destroy();
+ await client.close();
+ BrowserTestUtils.removeTab(tab);
+}
+
+function assertResources(actual, expected) {
+ is(
+ actual.resourceType,
+ expected.resourceType,
+ "The resource type is correct"
+ );
+ is(actual.method, expected.method, "The method is correct");
+}
+
+const cachedRequest = `await fetch("/cached_post.html", { method: "POST" });`;
+const liveRequest = `await fetch("/live_get.html", { method: "GET" });`;
diff --git a/devtools/shared/resources/tests/browser_resources_platform_messages.js b/devtools/shared/resources/tests/browser_resources_platform_messages.js
new file mode 100644
index 0000000000..324b92f771
--- /dev/null
+++ b/devtools/shared/resources/tests/browser_resources_platform_messages.js
@@ -0,0 +1,149 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the ResourceWatcher API around PLATFORM_MESSAGE
+// Reproduces assertions from: devtools/shared/webconsole/test/chrome/test_nsiconsolemessage.html
+
+const {
+ ResourceWatcher,
+} = require("devtools/shared/resources/resource-watcher");
+
+add_task(async function() {
+ // Disable the preloaded process as it creates processes intermittently
+ // which forces the emission of RDP requests we aren't correctly waiting for.
+ await pushPref("dom.ipc.processPrelaunch.enabled", false);
+
+ await testPlatformMessagesResources();
+ await testPlatformMessagesResourcesWithIgnoreExistingResources();
+});
+
+async function testPlatformMessagesResources() {
+ const {
+ client,
+ resourceWatcher,
+ targetList,
+ } = await initMultiProcessResourceWatcher();
+
+ const expectedMessages = [
+ "This is a cached message",
+ "This is another cached message",
+ "This is a live message",
+ "This is another live message",
+ ];
+ const receivedMessages = [];
+
+ info(
+ "Log some messages *before* calling ResourceWatcher.watchResources in order to assert the behavior of already existing messages."
+ );
+ Services.console.logStringMessage(expectedMessages[0]);
+ Services.console.logStringMessage(expectedMessages[1]);
+
+ let done;
+ const onAllMessagesReceived = new Promise(resolve => (done = resolve));
+ const onAvailable = resources => {
+ for (const resource of resources) {
+ if (!expectedMessages.includes(resource.message)) {
+ continue;
+ }
+
+ is(
+ resource.targetFront,
+ targetList.targetFront,
+ "The targetFront property is the expected one"
+ );
+
+ receivedMessages.push(resource.message);
+ is(
+ resource.message,
+ expectedMessages[receivedMessages.length - 1],
+ `Received the expected ยซ${resource.message}ยป message, in the expected order`
+ );
+
+ ok(
+ resource.timeStamp.toString().match(/^\d+$/),
+ "The resource has a timeStamp property"
+ );
+
+ if (receivedMessages.length == expectedMessages.length) {
+ done();
+ }
+ }
+ };
+
+ await resourceWatcher.watchResources(
+ [ResourceWatcher.TYPES.PLATFORM_MESSAGE],
+ {
+ onAvailable,
+ }
+ );
+
+ info(
+ "Now log messages *after* the call to ResourceWatcher.watchResources and after having received all existing messages"
+ );
+ Services.console.logStringMessage(expectedMessages[2]);
+ Services.console.logStringMessage(expectedMessages[3]);
+
+ info("Waiting for all expected messages to be received");
+ await onAllMessagesReceived;
+ ok(true, "All the expected messages were received");
+
+ Services.console.reset();
+ targetList.destroy();
+ await client.close();
+}
+
+async function testPlatformMessagesResourcesWithIgnoreExistingResources() {
+ const {
+ client,
+ resourceWatcher,
+ targetList,
+ } = await initMultiProcessResourceWatcher();
+
+ info(
+ "Check whether onAvailable will not be called with existing platform messages"
+ );
+ const expectedMessages = ["This is 1st message", "This is 2nd message"];
+ Services.console.logStringMessage(expectedMessages[0]);
+ Services.console.logStringMessage(expectedMessages[1]);
+
+ const availableResources = [];
+ await resourceWatcher.watchResources(
+ [ResourceWatcher.TYPES.PLATFORM_MESSAGE],
+ {
+ onAvailable: resources => {
+ for (const resource of resources) {
+ if (!expectedMessages.includes(resource.message)) {
+ continue;
+ }
+
+ availableResources.push(resource);
+ }
+ },
+ ignoreExistingResources: true,
+ }
+ );
+ is(
+ availableResources.length,
+ 0,
+ "onAvailable wasn't called for existing platform messages"
+ );
+
+ info(
+ "Check whether onAvailable will be called with the future platform messages"
+ );
+ Services.console.logStringMessage(expectedMessages[0]);
+ Services.console.logStringMessage(expectedMessages[1]);
+
+ await waitUntil(() => availableResources.length === expectedMessages.length);
+ for (let i = 0; i < expectedMessages.length; i++) {
+ const { message } = availableResources[i];
+ const expected = expectedMessages[i];
+ is(message, expected, `Message[${i}] is correct`);
+ }
+
+ Services.console.reset();
+ await targetList.destroy();
+ await client.close();
+}
diff --git a/devtools/shared/resources/tests/browser_resources_root_node.js b/devtools/shared/resources/tests/browser_resources_root_node.js
new file mode 100644
index 0000000000..44c9b7347f
--- /dev/null
+++ b/devtools/shared/resources/tests/browser_resources_root_node.js
@@ -0,0 +1,129 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the ResourceWatcher API around ROOT_NODE
+
+const {
+ ResourceWatcher,
+} = require("devtools/shared/resources/resource-watcher");
+
+/**
+ * The original test still asserts some scenarios using several watchRootNode
+ * call sites, which is not something we intend to support at the moment in the
+ * resource watcher.
+ *
+ * Otherwise this test checks the basic behavior of the resource when reloading
+ * an empty page.
+ */
+add_task(async function() {
+ // Open a test tab
+ const tab = await addTab("data:text/html,Root Node tests");
+
+ const { client, resourceWatcher, targetList } = await initResourceWatcher(
+ tab
+ );
+
+ const browser = gBrowser.selectedBrowser;
+
+ info("Call watchResources([ROOT_NODE], ...)");
+ let onAvailableCounter = 0;
+ const onAvailable = resources => (onAvailableCounter += resources.length);
+ await resourceWatcher.watchResources([ResourceWatcher.TYPES.ROOT_NODE], {
+ onAvailable,
+ });
+
+ info("Wait until onAvailable has been called");
+ await waitUntil(() => onAvailableCounter === 1);
+ is(onAvailableCounter, 1, "onAvailable has been called 1 time");
+
+ info("Reload the selected browser");
+ browser.reload();
+
+ info(
+ "Wait until the watchResources([ROOT_NODE], ...) callback has been called"
+ );
+ await waitUntil(() => onAvailableCounter === 2);
+
+ is(onAvailableCounter, 2, "onAvailable has been called 2 times");
+
+ info("Call unwatchResources([ROOT_NODE], ...) for the onAvailable callback");
+ resourceWatcher.unwatchResources([ResourceWatcher.TYPES.ROOT_NODE], {
+ onAvailable,
+ });
+
+ info("Reload the selected browser");
+ const reloaded = BrowserTestUtils.browserLoaded(browser);
+ browser.reload();
+ await reloaded;
+
+ is(
+ onAvailableCounter,
+ 2,
+ "onAvailable was not called after calling unwatchResources"
+ );
+
+ // Cleanup
+ targetList.destroy();
+ await client.close();
+});
+
+/**
+ * Test that the watchRootNode API provides the expected node fronts.
+ */
+add_task(async function testRootNodeFrontIsCorrect() {
+ const tab = await addTab("data:text/html,<div id=div1>");
+
+ const { client, resourceWatcher, targetList } = await initResourceWatcher(
+ tab
+ );
+ const browser = gBrowser.selectedBrowser;
+
+ info("Call watchResources([ROOT_NODE], ...)");
+
+ let rootNodeResolve;
+ let rootNodePromise = new Promise(r => (rootNodeResolve = r));
+ const onAvailable = ([rootNodeFront]) => rootNodeResolve(rootNodeFront);
+ await resourceWatcher.watchResources([ResourceWatcher.TYPES.ROOT_NODE], {
+ onAvailable,
+ });
+
+ info("Wait until onAvailable has been called");
+ const root1 = await rootNodePromise;
+ ok(!!root1, "onAvailable has been called with a valid argument");
+ is(
+ root1.resourceType,
+ ResourceWatcher.TYPES.ROOT_NODE,
+ "The resource has the expected type"
+ );
+
+ info("Check we can query an expected node under the retrieved root");
+ const div1 = await root1.walkerFront.querySelector(root1, "div");
+ is(div1.getAttribute("id"), "div1", "Correct root node retrieved");
+
+ info("Reload the selected browser");
+ rootNodePromise = new Promise(r => (rootNodeResolve = r));
+ browser.reload();
+
+ const root2 = await rootNodePromise;
+ ok(
+ root1 !== root2,
+ "onAvailable has been called with a different node front after reload"
+ );
+
+ info("Navigate to another URL");
+ rootNodePromise = new Promise(r => (rootNodeResolve = r));
+ BrowserTestUtils.loadURI(browser, `data:text/html,<div id=div3>`);
+ const root3 = await rootNodePromise;
+ info("Check we can query an expected node under the retrieved root");
+ const div3 = await root3.walkerFront.querySelector(root3, "div");
+ is(div3.getAttribute("id"), "div3", "Correct root node retrieved");
+
+ // Cleanup
+ resourceWatcher.unwatchResources([ResourceWatcher.TYPES.ROOT_NODE], {
+ onAvailable,
+ });
+ targetList.destroy();
+ await client.close();
+});
diff --git a/devtools/shared/resources/tests/browser_resources_several_resources.js b/devtools/shared/resources/tests/browser_resources_several_resources.js
new file mode 100644
index 0000000000..b197eddf54
--- /dev/null
+++ b/devtools/shared/resources/tests/browser_resources_several_resources.js
@@ -0,0 +1,120 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const {
+ ResourceWatcher,
+} = require("devtools/shared/resources/resource-watcher");
+
+/**
+ * Check that the resource watcher is still properly watching for new targets
+ * after unwatching one resource, if there is still another watched resource.
+ */
+add_task(async function() {
+ // We will create a main process target list here in order to monitor
+ // resources from new tabs as they get created.
+ // devtools.browsertoolbox.fission should be true to monitor resources from
+ // remote browsers & frames.
+ await pushPref("devtools.browsertoolbox.fission", true);
+
+ // Open a test tab
+ const tab = await addTab("data:text/html,Root Node tests");
+
+ const {
+ client,
+ resourceWatcher,
+ targetList,
+ } = await initMultiProcessResourceWatcher();
+
+ const { CONSOLE_MESSAGE, ROOT_NODE } = ResourceWatcher.TYPES;
+
+ // We are only interested in console messages as a resource, the ROOT_NODE one
+ // is here to test the ResourceWatcher::unwatchResources API with several resources.
+ const receivedMessages = [];
+ const onAvailable = resources => {
+ for (const resource of resources) {
+ if (resource.resourceType === CONSOLE_MESSAGE) {
+ receivedMessages.push(resource);
+ }
+ }
+ };
+
+ info("Call watchResources([CONSOLE_MESSAGE, ROOT_NODE], ...)");
+ await resourceWatcher.watchResources([CONSOLE_MESSAGE, ROOT_NODE], {
+ onAvailable,
+ });
+
+ info("Use console.log in the content page");
+ logInTab(tab, "test from data-url");
+ info(
+ "Wait until onAvailable received the CONSOLE_MESSAGE resource emitted from the data-url tab"
+ );
+ await waitUntil(() =>
+ receivedMessages.find(
+ resource => resource.message.arguments[0] === "test from data-url"
+ )
+ );
+
+ // Check that the resource watcher captures resources from new targets.
+ info("Open a first tab on the example.com domain");
+ const comTab = await addTab(
+ "http://example.com/document-builder.sjs?html=com"
+ );
+ info("Use console.log in the example.com page");
+ logInTab(comTab, "test-from-example-com");
+ info(
+ "Wait until onAvailable received the CONSOLE_MESSAGE resource emitted from the example.com tab"
+ );
+ await waitUntil(() =>
+ receivedMessages.find(
+ resource => resource.message.arguments[0] === "test-from-example-com"
+ )
+ );
+
+ info("Stop watching ROOT_NODE resources");
+ await resourceWatcher.unwatchResources([ROOT_NODE], { onAvailable });
+
+ // Check that messages from new targets are still captured after calling
+ // unwatch for another resource.
+ info("Open a second tab on the example.net domain");
+ const netTab = await addTab(
+ "http://example.net/document-builder.sjs?html=net"
+ );
+ info("Use console.log in the example.net page");
+ logInTab(netTab, "test-from-example-net");
+ info(
+ "Wait until onAvailable received the CONSOLE_MESSAGE resource emitted from the example.net tab"
+ );
+ await waitUntil(() =>
+ receivedMessages.find(
+ resource => resource.message.arguments[0] === "test-from-example-net"
+ )
+ );
+
+ info("Stop watching CONSOLE_MESSAGE resources");
+ await resourceWatcher.unwatchResources([CONSOLE_MESSAGE], { onAvailable });
+ await logInTab(tab, "test-again");
+
+ // We don't have a specific event to wait for here, so allow some time for
+ // the message to be received.
+ await wait(1000);
+
+ is(
+ receivedMessages.find(
+ resource => resource.message.arguments[0] === "test-again"
+ ),
+ undefined,
+ "The resource watcher should not watch CONSOLE_MESSAGE anymore"
+ );
+
+ // Cleanup
+ targetList.destroy();
+ await client.close();
+});
+
+function logInTab(tab, message) {
+ return ContentTask.spawn(tab.linkedBrowser, message, function(_message) {
+ content.console.log(_message);
+ });
+}
diff --git a/devtools/shared/resources/tests/browser_resources_sources.js b/devtools/shared/resources/tests/browser_resources_sources.js
new file mode 100644
index 0000000000..68f533d713
--- /dev/null
+++ b/devtools/shared/resources/tests/browser_resources_sources.js
@@ -0,0 +1,198 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the ResourceWatcher API around SOURCE.
+
+const {
+ ResourceWatcher,
+} = require("devtools/shared/resources/resource-watcher");
+
+const TEST_URL = URL_ROOT_SSL + "sources.html";
+
+add_task(async function() {
+ const tab = await addTab(TEST_URL);
+
+ const htmlRequest = await fetch(TEST_URL);
+ const htmlContent = await htmlRequest.text();
+
+ const { client, resourceWatcher, targetList } = await initResourceWatcher(
+ tab
+ );
+
+ // Force the target list to cover workers
+ targetList.listenForWorkers = true;
+ targetList.listenForServiceWorkers = true;
+ await targetList.startListening();
+
+ const targets = [];
+ await targetList.watchTargets(targetList.ALL_TYPES, async function({
+ targetFront,
+ }) {
+ targets.push(targetFront);
+ });
+ is(targets.length, 3, "Got expected number of targets");
+
+ info("Check already available resources");
+ const availableResources = [];
+ await resourceWatcher.watchResources([ResourceWatcher.TYPES.SOURCE], {
+ onAvailable: resources => availableResources.push(...resources),
+ });
+
+ const expectedExistingResources = [
+ {
+ description: "independent js file",
+ sourceForm: {
+ introductionType: "scriptElement",
+ sourceMapBaseURL:
+ "https://example.com/browser/devtools/shared/resources/tests/sources.js",
+ url:
+ "https://example.com/browser/devtools/shared/resources/tests/sources.js",
+ isBlackBoxed: false,
+ sourceMapURL: null,
+ extensionName: null,
+ },
+ sourceContent: {
+ contentType: "text/javascript",
+ source: "/* eslint-disable */\nfunction scriptSource() {}\n",
+ },
+ },
+ {
+ description: "eval",
+ sourceForm: {
+ introductionType: "eval",
+ sourceMapBaseURL:
+ "https://example.com/browser/devtools/shared/resources/tests/sources.html",
+ url: null,
+ isBlackBoxed: false,
+ sourceMapURL: null,
+ extensionName: null,
+ },
+ sourceContent: {
+ contentType: "text/javascript",
+ source: "this.global = function evalFunction() {}",
+ },
+ },
+ {
+ description: "inline JS",
+ sourceForm: {
+ introductionType: "scriptElement",
+ sourceMapBaseURL:
+ "https://example.com/browser/devtools/shared/resources/tests/sources.html",
+ url:
+ "https://example.com/browser/devtools/shared/resources/tests/sources.html",
+ isBlackBoxed: false,
+ sourceMapURL: null,
+ extensionName: null,
+ },
+ sourceContent: {
+ contentType: "text/html",
+ source: htmlContent,
+ },
+ },
+ {
+ description: "worker script",
+ sourceForm: {
+ introductionType: undefined,
+ sourceMapBaseURL:
+ "https://example.com/browser/devtools/shared/resources/tests/worker-sources.js",
+ url:
+ "https://example.com/browser/devtools/shared/resources/tests/worker-sources.js",
+ isBlackBoxed: false,
+ sourceMapURL: null,
+ extensionName: null,
+ },
+ sourceContent: {
+ contentType: "text/javascript",
+ source: "/* eslint-disable */\nfunction workerSource() {}\n",
+ },
+ },
+ {
+ description: "service worker script",
+ sourceForm: {
+ introductionType: undefined,
+ sourceMapBaseURL:
+ "https://example.com/browser/devtools/shared/resources/tests/service-worker-sources.js",
+ url:
+ "https://example.com/browser/devtools/shared/resources/tests/service-worker-sources.js",
+ isBlackBoxed: false,
+ sourceMapURL: null,
+ extensionName: null,
+ },
+ sourceContent: {
+ contentType: "text/javascript",
+ source: "/* eslint-disable */\nfunction serviceWorkerSource() {}\n",
+ },
+ },
+ ];
+ await assertResources(availableResources, expectedExistingResources);
+
+ await targetList.stopListening();
+ await client.close();
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async () => {
+ // registrationPromise is set by the test page.
+ const registration = await content.wrappedJSObject.registrationPromise;
+ registration.unregister();
+ });
+});
+
+async function assertResources(resources, expected) {
+ is(
+ resources.length,
+ expected.length,
+ "Length of existing resources is correct at initial"
+ );
+ for (let i = 0; i < resources.length; i++) {
+ await assertResource(resources[i], expected);
+ }
+}
+
+async function assertResource(source, expected) {
+ info(`Checking resource "#${expected.description}"`);
+
+ is(
+ source.resourceType,
+ ResourceWatcher.TYPES.SOURCE,
+ "Resource type is correct"
+ );
+
+ const threadFront = await source.targetFront.getFront("thread");
+ // `source` is SourceActor's form()
+ // so try to instantiate the related SourceFront:
+ const sourceFront = threadFront.source(source);
+ // then fetch source content
+ const sourceContent = await sourceFront.source();
+
+ // Order of sources is random, so we have to find the best expected resource.
+ // The only unique attribute is the JS Source text content.
+ const matchingExpected = expected.find(res => {
+ return res.sourceContent.source == sourceContent.source;
+ });
+ ok(
+ matchingExpected,
+ `This source was expected with source content being "${sourceContent.source}"`
+ );
+ assertObject(
+ sourceContent,
+ matchingExpected.sourceContent,
+ matchingExpected.description
+ );
+
+ assertObject(
+ source,
+ matchingExpected.sourceForm,
+ matchingExpected.description
+ );
+}
+
+function assertObject(object, expected, description) {
+ for (const field in expected) {
+ is(
+ object[field],
+ expected[field],
+ `The value of ${field} is correct for "#${description}"`
+ );
+ }
+}
diff --git a/devtools/shared/resources/tests/browser_resources_stylesheets.js b/devtools/shared/resources/tests/browser_resources_stylesheets.js
new file mode 100644
index 0000000000..79735cae0e
--- /dev/null
+++ b/devtools/shared/resources/tests/browser_resources_stylesheets.js
@@ -0,0 +1,506 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the ResourceWatcher API around STYLESHEET.
+
+const {
+ ResourceWatcher,
+} = require("devtools/shared/resources/resource-watcher");
+
+const STYLE_TEST_URL = URL_ROOT_SSL + "style_document.html";
+
+const EXISTING_RESOURCES = [
+ {
+ styleText: "body { color: lime; }",
+ href: null,
+ nodeHref:
+ "https://example.com/browser/devtools/shared/resources/tests/style_document.html",
+ isNew: false,
+ disabled: false,
+ ruleCount: 1,
+ mediaRules: [],
+ },
+ {
+ styleText: "body { margin: 1px; }",
+ href:
+ "https://example.com/browser/devtools/shared/resources/tests/style_document.css",
+ nodeHref:
+ "https://example.com/browser/devtools/shared/resources/tests/style_document.html",
+ isNew: false,
+ disabled: false,
+ ruleCount: 1,
+ mediaRules: [],
+ },
+ {
+ styleText: "body { background-color: pink; }",
+ href: null,
+ nodeHref:
+ "https://example.org/browser/devtools/shared/resources/tests/style_iframe.html",
+ isNew: false,
+ disabled: false,
+ ruleCount: 1,
+ mediaRules: [],
+ },
+ {
+ styleText: "body { padding: 1px; }",
+ href:
+ "https://example.org/browser/devtools/shared/resources/tests/style_iframe.css",
+ nodeHref:
+ "https://example.org/browser/devtools/shared/resources/tests/style_iframe.html",
+ isNew: false,
+ disabled: false,
+ ruleCount: 1,
+ mediaRules: [],
+ },
+];
+
+const ADDITIONAL_RESOURCE = {
+ styleText:
+ "@media all { body { color: red; } } @media print { body { color: cyan; } } body { font-size: 10px; }",
+ href: null,
+ nodeHref:
+ "https://example.com/browser/devtools/shared/resources/tests/style_document.html",
+ isNew: false,
+ disabled: false,
+ ruleCount: 3,
+ mediaRules: [
+ {
+ conditionText: "all",
+ mediaText: "all",
+ matches: true,
+ line: 1,
+ column: 1,
+ },
+ {
+ conditionText: "print",
+ mediaText: "print",
+ matches: false,
+ line: 1,
+ column: 37,
+ },
+ ],
+};
+
+const ADDITIONAL_FROM_ACTOR_RESOURCE = {
+ styleText: "body { font-size: 10px; }",
+ href: null,
+ nodeHref:
+ "https://example.com/browser/devtools/shared/resources/tests/style_document.html",
+ isNew: true,
+ disabled: false,
+ ruleCount: 1,
+ mediaRules: [],
+};
+
+add_task(async function() {
+ await pushPref("devtools.testing.enableServerWatcherSupport", false);
+ await testResourceAvailableFeature();
+ await testResourceUpdateFeature();
+ await testNestedResourceUpdateFeature();
+
+ await pushPref("devtools.testing.enableServerWatcherSupport", true);
+ await testResourceAvailableFeature();
+ await testResourceUpdateFeature();
+ await testNestedResourceUpdateFeature();
+});
+
+async function testResourceAvailableFeature() {
+ info("Check resource available feature of the ResourceWatcher");
+
+ const tab = await addTab(STYLE_TEST_URL);
+
+ const { client, resourceWatcher, targetList } = await initResourceWatcher(
+ tab
+ );
+
+ info("Check whether ResourceWatcher gets existing stylesheet");
+ const availableResources = [];
+ await resourceWatcher.watchResources([ResourceWatcher.TYPES.STYLESHEET], {
+ onAvailable: resources => availableResources.push(...resources),
+ });
+
+ is(
+ availableResources.length,
+ EXISTING_RESOURCES.length,
+ "Length of existing resources is correct"
+ );
+ for (let i = 0; i < availableResources.length; i++) {
+ const availableResource = availableResources[i];
+ // We can not expect the resources to always be forwarded in the same order.
+ // See intermittent Bug 1655016.
+ const expectedResource = findMatchingExpectedResource(availableResource);
+ ok(expectedResource, "Found a matching expected resource for the resource");
+ await assertResource(availableResource, expectedResource);
+ }
+
+ info("Check whether ResourceWatcher gets additonal stylesheet");
+ await ContentTask.spawn(
+ tab.linkedBrowser,
+ ADDITIONAL_RESOURCE.styleText,
+ text => {
+ const document = content.document;
+ const stylesheet = document.createElement("style");
+ stylesheet.textContent = text;
+ document.body.appendChild(stylesheet);
+ }
+ );
+ await waitUntil(
+ () => availableResources.length === EXISTING_RESOURCES.length + 1
+ );
+ await assertResource(
+ availableResources[availableResources.length - 1],
+ ADDITIONAL_RESOURCE
+ );
+
+ info(
+ "Check whether ResourceWatcher gets additonal stylesheet which is added by DevTool"
+ );
+ const styleSheetsFront = await targetList.targetFront.getFront("stylesheets");
+ await styleSheetsFront.addStyleSheet(
+ ADDITIONAL_FROM_ACTOR_RESOURCE.styleText
+ );
+ await waitUntil(
+ () => availableResources.length === EXISTING_RESOURCES.length + 2
+ );
+ await assertResource(
+ availableResources[availableResources.length - 1],
+ ADDITIONAL_FROM_ACTOR_RESOURCE
+ );
+
+ await targetList.destroy();
+ await client.close();
+}
+
+async function testResourceUpdateFeature() {
+ info("Check resource update feature of the ResourceWatcher");
+
+ const tab = await addTab(STYLE_TEST_URL);
+
+ const { client, resourceWatcher, targetList } = await initResourceWatcher(
+ tab
+ );
+
+ info("Setup the watcher");
+ const availableResources = [];
+ const updates = [];
+ await resourceWatcher.watchResources([ResourceWatcher.TYPES.STYLESHEET], {
+ onAvailable: resources => availableResources.push(...resources),
+ onUpdated: newUpdates => updates.push(...newUpdates),
+ });
+ is(
+ availableResources.length,
+ EXISTING_RESOURCES.length,
+ "Length of existing resources is correct"
+ );
+
+ info("Check toggleDisabled function");
+ const resource = availableResources[0];
+ const styleSheetsFront = await resource.targetFront.getFront("stylesheets");
+ await styleSheetsFront.toggleDisabled(resource.resourceId);
+ await waitUntil(() => updates.length === 1);
+
+ // Check the content of the update object.
+ assertUpdate(updates[0].update, {
+ resourceId: resource.resourceId,
+ updateType: "property-change",
+ });
+ is(
+ updates[0].update.resourceUpdates.disabled,
+ true,
+ "resourceUpdates is correct"
+ );
+
+ // Check whether the cached resource is updated correctly.
+ is(
+ updates[0].resource.disabled,
+ true,
+ "cached resource is updated correctly"
+ );
+
+ // Check whether the actual stylesheet is updated correctly.
+ const styleSheetDisabled = await ContentTask.spawn(
+ tab.linkedBrowser,
+ null,
+ () => {
+ const document = content.document;
+ const stylesheet = document.styleSheets[0];
+ return stylesheet.disabled;
+ }
+ );
+ is(styleSheetDisabled, true, "actual stylesheet is is updated correctly");
+
+ info("Check update function");
+ const expectedMediaRules = [
+ {
+ conditionText: "screen",
+ mediaText: "screen",
+ matches: true,
+ },
+ {
+ conditionText: "print",
+ mediaText: "print",
+ matches: false,
+ },
+ ];
+
+ const updateCause = "updated-by-test";
+ await styleSheetsFront.update(
+ resource.resourceId,
+ "@media screen { color: red; } @media print { color: green; } body { color: cyan; }",
+ false,
+ updateCause
+ );
+ await waitUntil(() => updates.length === 4);
+
+ assertUpdate(updates[1].update, {
+ resourceId: resource.resourceId,
+ updateType: "property-change",
+ });
+ is(
+ updates[1].update.resourceUpdates.ruleCount,
+ 3,
+ "resourceUpdates is correct"
+ );
+ is(updates[1].resource.ruleCount, 3, "cached resource is updated correctly");
+
+ assertUpdate(updates[2].update, {
+ resourceId: resource.resourceId,
+ updateType: "style-applied",
+ event: {
+ cause: updateCause,
+ },
+ });
+ is(
+ updates[2].update.resourceUpdates,
+ undefined,
+ "resourceUpdates is correct"
+ );
+
+ assertUpdate(updates[3].update, {
+ resourceId: resource.resourceId,
+ updateType: "media-rules-changed",
+ });
+ assertMediaRules(
+ updates[3].update.resourceUpdates.mediaRules,
+ expectedMediaRules
+ );
+
+ // Check the actual page.
+ const styleSheetResult = await getStyleSheetResult(tab);
+
+ is(
+ styleSheetResult.ruleCount,
+ 3,
+ "ruleCount of actual stylesheet is updated correctly"
+ );
+ assertMediaRules(styleSheetResult.mediaRules, expectedMediaRules);
+
+ await targetList.destroy();
+ await client.close();
+}
+
+async function testNestedResourceUpdateFeature() {
+ info("Check nested resource update feature of the ResourceWatcher");
+
+ const tab = await addTab(STYLE_TEST_URL);
+
+ const {
+ outerWidth: originalWindowWidth,
+ outerHeight: originalWindowHeight,
+ } = tab.ownerGlobal;
+
+ registerCleanupFunction(() => {
+ tab.ownerGlobal.resizeTo(originalWindowWidth, originalWindowHeight);
+ });
+
+ const { client, resourceWatcher, targetList } = await initResourceWatcher(
+ tab
+ );
+
+ info("Setup the watcher");
+ const availableResources = [];
+ const updates = [];
+ await resourceWatcher.watchResources([ResourceWatcher.TYPES.STYLESHEET], {
+ onAvailable: resources => availableResources.push(...resources),
+ onUpdated: newUpdates => updates.push(...newUpdates),
+ });
+ is(
+ availableResources.length,
+ EXISTING_RESOURCES.length,
+ "Length of existing resources is correct"
+ );
+
+ info("Apply new media query");
+ // In order to avoid applying the media query (min-height: 400px).
+ tab.ownerGlobal.resizeTo(originalWindowWidth, 300);
+
+ const resource = availableResources[0];
+ const styleSheetsFront = await resource.targetFront.getFront("stylesheets");
+ await styleSheetsFront.update(
+ resource.resourceId,
+ "@media (min-height: 400px) { color: red; }",
+ false
+ );
+ await waitUntil(() => updates.length === 3);
+ is(resource.mediaRules[0].matches, false, "Media query is not matched yet");
+
+ info("Change window size to fire matches-change event");
+ tab.ownerGlobal.resizeTo(originalWindowWidth, 500);
+ await waitUntil(() => updates.length === 4);
+
+ // Check the update content.
+ const isServerWatcher = Services.prefs.getBoolPref(
+ "devtools.testing.enableServerWatcherSupport"
+ );
+ const targetUpdate = updates[3];
+ assertUpdate(targetUpdate.update, {
+ resourceId: resource.resourceId,
+ updateType: "matches-change",
+ });
+ ok(resource === targetUpdate.resource, "Update object has the same resource");
+
+ if (isServerWatcher) {
+ is(
+ JSON.stringify(targetUpdate.update.nestedResourceUpdates[0].path),
+ JSON.stringify(["mediaRules", 0, "matches"]),
+ "path of nestedResourceUpdates is correct"
+ );
+ is(
+ targetUpdate.update.nestedResourceUpdates[0].value,
+ true,
+ "value of nestedResourceUpdates is correct"
+ );
+ } else {
+ is(
+ JSON.stringify(targetUpdate.update.nestedResourceUpdates[0].path),
+ JSON.stringify(["mediaRules", 0]),
+ "path of nestedResourceUpdates is correct"
+ );
+ is(
+ targetUpdate.update.nestedResourceUpdates[0].value.matches,
+ true,
+ "value of nestedResourceUpdates is correct"
+ );
+ }
+
+ // Check the resource.
+ const expectedMediaRules = [
+ {
+ conditionText: "(min-height: 400px)",
+ mediaText: "(min-height: 400px)",
+ matches: true,
+ },
+ ];
+
+ assertMediaRules(targetUpdate.resource.mediaRules, expectedMediaRules);
+
+ // Check the actual page.
+ const styleSheetResult = await getStyleSheetResult(tab);
+ is(
+ styleSheetResult.ruleCount,
+ 1,
+ "ruleCount of actual stylesheet is updated correctly"
+ );
+ assertMediaRules(styleSheetResult.mediaRules, expectedMediaRules);
+
+ tab.ownerGlobal.resizeTo(originalWindowWidth, originalWindowHeight);
+
+ await targetList.destroy();
+ await client.close();
+}
+
+function findMatchingExpectedResource(resource) {
+ return EXISTING_RESOURCES.find(
+ expected =>
+ resource.href === expected.href && resource.nodeHref === expected.nodeHref
+ );
+}
+
+async function getStyleSheetResult(tab) {
+ const result = await ContentTask.spawn(tab.linkedBrowser, null, () => {
+ const document = content.document;
+ const stylesheet = document.styleSheets[0];
+ const ruleCount = stylesheet.cssRules.length;
+
+ const mediaRules = [];
+ for (const rule of stylesheet.cssRules) {
+ if (!rule.media) {
+ continue;
+ }
+
+ let matches = false;
+ try {
+ const mql = content.matchMedia(rule.media.mediaText);
+ matches = mql.matches;
+ } catch (e) {
+ // Ignored
+ }
+
+ mediaRules.push({
+ mediaText: rule.media.mediaText,
+ conditionText: rule.conditionText,
+ matches,
+ });
+ }
+
+ return { ruleCount, mediaRules };
+ });
+
+ return result;
+}
+
+function assertMediaRules(mediaRules, expected) {
+ is(mediaRules.length, expected.length, "Length of the mediaRules is correct");
+
+ for (let i = 0; i < mediaRules.length; i++) {
+ is(
+ mediaRules[i].conditionText,
+ expected[i].conditionText,
+ "conditionText is correct"
+ );
+ is(mediaRules[i].mediaText, expected[i].mediaText, "mediaText is correct");
+ is(mediaRules[i].matches, expected[i].matches, "matches is correct");
+
+ if (expected[i].line !== undefined) {
+ is(mediaRules[i].line, expected[i].line, "line is correct");
+ }
+
+ if (expected[i].column !== undefined) {
+ is(mediaRules[i].column, expected[i].column, "column is correct");
+ }
+ }
+}
+
+async function assertResource(resource, expected) {
+ is(
+ resource.resourceType,
+ ResourceWatcher.TYPES.STYLESHEET,
+ "Resource type is correct"
+ );
+ const styleSheetsFront = await resource.targetFront.getFront("stylesheets");
+ const styleText = (
+ await styleSheetsFront.getText(resource.resourceId)
+ ).str.trim();
+ is(styleText, expected.styleText, "Style text is correct");
+ is(resource.href, expected.href, "href is correct");
+ is(resource.nodeHref, expected.nodeHref, "nodeHref is correct");
+ is(resource.isNew, expected.isNew, "isNew is correct");
+ is(resource.disabled, expected.disabled, "disabled is correct");
+ is(resource.ruleCount, expected.ruleCount, "ruleCount is correct");
+ assertMediaRules(resource.mediaRules, expected.mediaRules);
+}
+
+function assertUpdate(update, expected) {
+ is(
+ update.resourceType,
+ ResourceWatcher.TYPES.STYLESHEET,
+ "Resource type is correct"
+ );
+ is(update.resourceId, expected.resourceId, "resourceId is correct");
+ is(update.updateType, expected.updateType, "updateType is correct");
+ if (expected.event?.cause) {
+ is(update.event?.cause, expected.event.cause, "cause is correct");
+ }
+}
diff --git a/devtools/shared/resources/tests/browser_resources_target_destroy.js b/devtools/shared/resources/tests/browser_resources_target_destroy.js
new file mode 100644
index 0000000000..9e993ef8cc
--- /dev/null
+++ b/devtools/shared/resources/tests/browser_resources_target_destroy.js
@@ -0,0 +1,92 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that the server ResourceWatcher are destroyed when the associated target actors
+// are destroyed.
+
+const {
+ ResourceWatcher,
+} = require("devtools/shared/resources/resource-watcher");
+
+add_task(async function() {
+ const tab = await addTab("data:text/html,Test");
+ const { client, resourceWatcher, targetList } = await initResourceWatcher(
+ tab
+ );
+
+ // Start watching for console messages. We don't care about messages here, only the
+ // registration/destroy mechanism, so we make onAvailable a no-op function.
+ await resourceWatcher.watchResources(
+ [ResourceWatcher.TYPES.CONSOLE_MESSAGE],
+ {
+ onAvailable: () => {},
+ }
+ );
+
+ info(
+ "Spawn a content task in order to be able to manipulate actors and resource watchers directly"
+ );
+ await ContentTask.spawn(tab.linkedBrowser, [], function() {
+ const { require } = ChromeUtils.import(
+ "resource://devtools/shared/Loader.jsm"
+ );
+ const {
+ TargetActorRegistry,
+ } = require("devtools/server/actors/targets/target-actor-registry.jsm");
+ const {
+ getResourceWatcher,
+ TYPES,
+ } = require("devtools/server/actors/resources/index");
+
+ // Retrieve the target actor instance and its watcher for console messages
+ const targetActor = TargetActorRegistry.getTargetActor(
+ content.browsingContext.browserId
+ );
+ const watcher = getResourceWatcher(targetActor, TYPES.CONSOLE_MESSAGE);
+
+ // Storing the target actor in the global so we can retrieve it later, even if it
+ // was destroyed
+ content._testTargetActor = targetActor;
+
+ is(!!watcher, true, "The console message resource watcher was created");
+ });
+
+ info("Close the client, which will destroy the target");
+ targetList.destroy();
+ await client.close();
+
+ info(
+ "Spawn a content task in order to run some assertions on actors and resource watchers directly"
+ );
+ await ContentTask.spawn(tab.linkedBrowser, [], function() {
+ const { require } = ChromeUtils.import(
+ "resource://devtools/shared/Loader.jsm"
+ );
+ const {
+ getResourceWatcher,
+ TYPES,
+ } = require("devtools/server/actors/resources/index");
+
+ ok(
+ content._testTargetActor && !content._testTargetActor.actorID,
+ "The target was destroyed when the client was closed"
+ );
+
+ // Retrieve the console message resource watcher
+ const watcher = getResourceWatcher(
+ content._testTargetActor,
+ TYPES.CONSOLE_MESSAGE
+ );
+
+ is(
+ !!watcher,
+ false,
+ "The console message resource watcher isn't registered anymore after the target was destroyed"
+ );
+
+ // Cleanup work variable
+ delete content._testTargetActor;
+ });
+});
diff --git a/devtools/shared/resources/tests/browser_resources_target_resources_race.js b/devtools/shared/resources/tests/browser_resources_target_resources_race.js
new file mode 100644
index 0000000000..c7e18f8ce1
--- /dev/null
+++ b/devtools/shared/resources/tests/browser_resources_target_resources_race.js
@@ -0,0 +1,77 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const {
+ ResourceWatcher,
+} = require("devtools/shared/resources/resource-watcher");
+
+/**
+ * Test initial target resources are correctly retrieved even when several calls
+ * to watchResources are made simultaneously.
+ *
+ * This checks a race condition which occurred when calling watchResources
+ * simultaneously. This made the "second" call to watchResources miss existing
+ * resources (in case those are emitted from the target instead of the watcher).
+ * See Bug 1663896.
+ */
+add_task(async function() {
+ // Disable the preloaded process as it creates processes intermittently
+ // which forces the emission of RDP requests we aren't correctly waiting for.
+ await pushPref("dom.ipc.processPrelaunch.enabled", false);
+
+ const {
+ client,
+ resourceWatcher,
+ targetList,
+ } = await initMultiProcessResourceWatcher();
+
+ const expectedPlatformMessage = "expectedMessage";
+
+ info("Log a message *before* calling ResourceWatcher.watchResources");
+ Services.console.logStringMessage(expectedPlatformMessage);
+
+ info("Call watchResources from 2 separate call sites consecutively");
+
+ // Empty onAvailable callback for CSS MESSAGES, we only want to check that
+ // the second resource we watch correctly provides existing resources.
+ const onCssMessageAvailable = resources => {};
+
+ // First call to watchResources.
+ // We do not await on `watchPromise1` here, in order to simulate simultaneous
+ // calls to watchResources (which could come from 2 separate modules in a real
+ // scenario).
+ const initialWatchPromise = resourceWatcher.watchResources(
+ [ResourceWatcher.TYPES.CSS_MESSAGE],
+ {
+ onAvailable: onCssMessageAvailable,
+ }
+ );
+
+ // `waitForNextResource` will trigger another call to `watchResources`.
+ const onMessageReceived = waitForNextResource(
+ resourceWatcher,
+ ResourceWatcher.TYPES.PLATFORM_MESSAGE,
+ {
+ ignoreExistingResources: false,
+ predicate: r => r.message === expectedPlatformMessage,
+ }
+ );
+
+ info("Waiting for the expected message to be received");
+ await onMessageReceived;
+ ok(true, "All the expected messages were received");
+
+ info("Wait for the other watchResources promise to finish");
+ await initialWatchPromise;
+
+ // Unwatch all resources.
+ resourceWatcher.unwatchResources([ResourceWatcher.TYPES.CSS_MESSAGE], {
+ onAvailable: onCssMessageAvailable,
+ });
+
+ Services.console.reset();
+ targetList.destroy();
+ await client.close();
+});
diff --git a/devtools/shared/resources/tests/browser_resources_target_switching.js b/devtools/shared/resources/tests/browser_resources_target_switching.js
new file mode 100644
index 0000000000..1388970dcd
--- /dev/null
+++ b/devtools/shared/resources/tests/browser_resources_target_switching.js
@@ -0,0 +1,101 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the behavior of ResourceWatcher when the top level target changes
+
+const {
+ ResourceWatcher,
+} = require("devtools/shared/resources/resource-watcher");
+const { CONSOLE_MESSAGE, SOURCE } = ResourceWatcher.TYPES;
+
+const TEST_URI =
+ "data:text/html;charset=utf-8,<script>console.log('foo');</script>";
+
+add_task(async function() {
+ const tab = await addTab(TEST_URI);
+
+ const { client, resourceWatcher, targetList } = await initResourceWatcher(
+ tab
+ );
+
+ info("Check the resources gotten from getAllResources at initial");
+ is(
+ resourceWatcher.getAllResources(CONSOLE_MESSAGE).length,
+ 0,
+ "There is no resources before calling watchResources"
+ );
+
+ info(
+ "Start to watch the available resources in order to compare with resources gotten from getAllResources"
+ );
+ const availableResources = [];
+ const onAvailable = resources => {
+ // Ignore message coming from shared worker started by previous tests and
+ // logging late a console message
+ resources
+ .filter(r => {
+ return !r.message.arguments[0].startsWith("[WORKER] started");
+ })
+ .map(r => availableResources.push(r));
+ };
+ await resourceWatcher.watchResources([CONSOLE_MESSAGE], { onAvailable });
+
+ is(availableResources.length, 1, "Got the page message");
+ is(
+ availableResources[0].message.arguments[0],
+ "foo",
+ "Got the expected page message"
+ );
+
+ // Register another listener before unregistering the console listener
+ // otherwise the resource watcher stop watching for targets
+ const onSourceAvailable = () => {};
+ await resourceWatcher.watchResources([SOURCE], {
+ onAvailable: onSourceAvailable,
+ });
+
+ info(
+ "Unregister the console listener and check that we no longer listen for console messages"
+ );
+ resourceWatcher.unwatchResources([CONSOLE_MESSAGE], {
+ onAvailable,
+ });
+
+ let onSwitched = targetList.once("switched-target");
+ info("Navigate to another process");
+ BrowserTestUtils.loadURI(gBrowser.selectedBrowser, "about:robots");
+ await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+ await onSwitched;
+
+ is(
+ availableResources.length,
+ 1,
+ "about:robots doesn't fire any new message, so we should have a new one"
+ );
+
+ info("Navigate back to data: URI");
+ onSwitched = targetList.once("switched-target");
+ BrowserTestUtils.loadURI(gBrowser.selectedBrowser, TEST_URI);
+ await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+ await onSwitched;
+
+ is(
+ availableResources.length,
+ 1,
+ "the data:URI fired a message, but we are no longer listening to it, so no new one should be notified"
+ );
+ is(
+ resourceWatcher.getAllResources(CONSOLE_MESSAGE).length,
+ 0,
+ "As we are no longer listening to CONSOLE message, we should not collect any"
+ );
+
+ resourceWatcher.unwatchResources([SOURCE], {
+ onAvailable: onSourceAvailable,
+ });
+
+ await targetList.destroy();
+ await client.close();
+});
diff --git a/devtools/shared/resources/tests/browser_resources_websocket.js b/devtools/shared/resources/tests/browser_resources_websocket.js
new file mode 100644
index 0000000000..2437836d79
--- /dev/null
+++ b/devtools/shared/resources/tests/browser_resources_websocket.js
@@ -0,0 +1,142 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the ResourceWatcher API around WEBSOCKET.
+
+const {
+ ResourceWatcher,
+} = require("devtools/shared/resources/resource-watcher");
+
+const TEST_URL = URL_ROOT + "websocket_frontend.html";
+const IS_NUMBER = "IS_NUMBER";
+
+add_task(async function() {
+ const tab = await addTab(TEST_URL);
+
+ const { client, resourceWatcher, targetList } = await initResourceWatcher(
+ tab
+ );
+
+ info("Check available resources at initial");
+ const availableResources = [];
+ await resourceWatcher.watchResources([ResourceWatcher.TYPES.WEBSOCKET], {
+ onAvailable: resources => availableResources.push(...resources),
+ });
+ is(
+ availableResources.length,
+ 0,
+ "Length of existing resources is correct at initial"
+ );
+
+ info("Check resource of opening websocket");
+ await ContentTask.spawn(tab.linkedBrowser, [], async () => {
+ await content.wrappedJSObject.openConnection();
+ });
+ await waitUntil(() => availableResources.length === 1);
+ const httpChannelId = availableResources[0].httpChannelId;
+ ok(httpChannelId, "httpChannelId is present in the resource");
+ assertResource(availableResources[0], {
+ wsMessageType: "webSocketOpened",
+ effectiveURI:
+ "ws://mochi.test:8888/browser/devtools/shared/resources/tests/websocket_backend",
+ extensions: "permessage-deflate",
+ protocols: "",
+ });
+
+ info("Check resource of sending/receiving the data via websocket");
+ await ContentTask.spawn(tab.linkedBrowser, [], async () => {
+ await content.wrappedJSObject.sendData("test");
+ });
+ await waitUntil(() => availableResources.length === 3);
+ assertResource(availableResources[1], {
+ wsMessageType: "frameSent",
+ httpChannelId,
+ data: {
+ type: "sent",
+ payload: "test",
+ },
+ });
+ assertResource(availableResources[2], {
+ wsMessageType: "frameReceived",
+ httpChannelId,
+ data: {
+ type: "received",
+ payload: "test",
+ },
+ });
+
+ info("Check resource of closing websocket");
+ await ContentTask.spawn(tab.linkedBrowser, [], async () => {
+ await content.wrappedJSObject.closeConnection();
+ });
+ await waitUntil(() => availableResources.length === 6);
+ assertResource(availableResources[3], {
+ wsMessageType: "frameSent",
+ httpChannelId,
+ data: {
+ type: "sent",
+ payload: "",
+ },
+ });
+ assertResource(availableResources[4], {
+ wsMessageType: "frameReceived",
+ httpChannelId,
+ data: {
+ type: "received",
+ payload: "",
+ },
+ });
+ assertResource(availableResources[5], {
+ wsMessageType: "webSocketClosed",
+ httpChannelId,
+ code: IS_NUMBER,
+ reason: "",
+ wasClean: true,
+ });
+
+ info("Check existing resources");
+ const existingResources = [];
+ await resourceWatcher.watchResources([ResourceWatcher.TYPES.WEBSOCKET], {
+ onAvailable: resources => existingResources.push(...resources),
+ });
+ is(
+ availableResources.length,
+ existingResources.length,
+ "Length of existing resources is correct"
+ );
+ for (let i = 0; i < availableResources.length; i++) {
+ const availableResource = availableResources[i];
+ const existingResource = existingResources[i];
+ ok(
+ availableResource === existingResource,
+ `The ${i}th resource is correct`
+ );
+ }
+
+ await targetList.destroy();
+ await client.close();
+});
+
+function assertResource(resource, expected) {
+ is(
+ resource.resourceType,
+ ResourceWatcher.TYPES.WEBSOCKET,
+ "Resource type is correct"
+ );
+
+ assertObject(resource, expected);
+}
+
+function assertObject(object, expected) {
+ for (const field in expected) {
+ if (typeof expected[field] === "object") {
+ assertObject(object[field], expected[field]);
+ } else if (expected[field] === IS_NUMBER) {
+ ok(!isNaN(object[field]), `The value of ${field} is number`);
+ } else {
+ is(object[field], expected[field], `The value of ${field} is correct`);
+ }
+ }
+}
diff --git a/devtools/shared/resources/tests/browser_target_list_browser_workers.js b/devtools/shared/resources/tests/browser_target_list_browser_workers.js
new file mode 100644
index 0000000000..fa85708165
--- /dev/null
+++ b/devtools/shared/resources/tests/browser_target_list_browser_workers.js
@@ -0,0 +1,195 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the TargetList API around workers
+
+const { TargetList } = require("devtools/shared/resources/target-list");
+const { TYPES } = TargetList;
+
+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 TargetList is almost disabled without it
+ await pushPref("devtools.browsertoolbox.fission", true);
+
+ // Disable the preloaded process as it creates processes intermittently
+ // which forces the emission of RDP requests we aren't correctly waiting for.
+ await pushPref("dom.ipc.processPrelaunch.enabled", false);
+
+ const client = await createLocalClient();
+ const mainRoot = client.mainRoot;
+ const tab = await addTab(FISSION_TEST_URL);
+
+ info("Test TargetList 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 targetDescriptor = await mainRoot.getMainProcess();
+ const target = await targetDescriptor.getTarget();
+ const targetList = new TargetList(mainRoot, target);
+ await targetList.startListening();
+
+ // Very naive sanity check against getAllTargets([workerType])
+ info("Check that getAllTargets returned the expected targets");
+ const workers = await targetList.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 targetList.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 targetList.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 targetList.getAllTargets([TYPES.WORKER]);
+ const sharedWorkers2 = await targetList.getAllTargets([TYPES.SHARED_WORKER]);
+ const serviceWorkers2 = await targetList.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 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 == target ? targetFront.isTopLevel : !targetFront.isTopLevel,
+ "isTopLevel property is correct"
+ );
+ targets.push(targetFront);
+ };
+ await targetList.watchTargets(
+ [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`
+ );
+ }
+
+ targetList.unwatchTargets(
+ [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;
+ }
+ targetList.unwatchTargets([TYPES.WORKER], onAvailable2);
+ resolve(targetFront);
+ };
+ targetList.watchTargets([TYPES.WORKER], 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"
+ );
+
+ const workers3 = await targetList.getAllTargets([TYPES.WORKER]);
+ const hasWorker2 = workers3.find(
+ ({ url }) => url == `${CHROME_WORKER_URL}#second`
+ );
+ ok(hasWorker2, "retrieve the target for tab via getAllTargets");
+
+ targetList.destroy();
+
+ info("Unregister service workers so they don't appear in other tests.");
+ await unregisterAllServiceWorkers(client);
+
+ await client.close();
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async () => {
+ // registrationPromise is set by the test page.
+ const registration = await content.wrappedJSObject.registrationPromise;
+ registration.unregister();
+ });
+});
+
+function sortFronts(f1, f2) {
+ return f1.actorID < f2.actorID;
+}
diff --git a/devtools/shared/resources/tests/browser_target_list_frames.js b/devtools/shared/resources/tests/browser_target_list_frames.js
new file mode 100644
index 0000000000..0549c67842
--- /dev/null
+++ b/devtools/shared/resources/tests/browser_target_list_frames.js
@@ -0,0 +1,168 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the TargetList API around frames
+
+const { TargetList } = require("devtools/shared/resources/target-list");
+
+const FISSION_TEST_URL = URL_ROOT_SSL + "/fission_document.html";
+
+add_task(async function() {
+ // Enabled fission prefs
+ await pushPref("devtools.browsertoolbox.fission", true);
+ // 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 client = await createLocalClient();
+ const mainRoot = client.mainRoot;
+
+ // Test fetching the frames from the main process target
+ await testBrowserFrames(mainRoot);
+
+ // Test fetching the frames from a tab target
+ await testTabFrames(mainRoot);
+
+ await client.close();
+});
+
+async function testBrowserFrames(mainRoot) {
+ info("Test TargetList against frames via the parent process target");
+
+ const targetDescriptor = await mainRoot.getMainProcess();
+ const target = await targetDescriptor.getTarget();
+ const targetList = new TargetList(mainRoot, target);
+ await targetList.startListening();
+
+ // Very naive sanity check against getAllTargets([frame])
+ const frames = await targetList.getAllTargets([TargetList.TYPES.FRAME]);
+ const hasBrowserDocument = frames.find(
+ frameTarget => frameTarget.url == window.location.href
+ );
+ ok(hasBrowserDocument, "retrieve the target for the browser document");
+
+ // Check that calling getAllTargets([frame]) return the same target instances
+ const frames2 = await targetList.getAllTargets([TargetList.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 onAvailable = ({ targetFront }) => {
+ is(
+ targetFront.targetType,
+ TargetList.TYPES.FRAME,
+ "We are only notified about frame targets"
+ );
+ ok(
+ targetFront == target ? targetFront.isTopLevel : !targetFront.isTopLevel,
+ "isTopLevel property is correct"
+ );
+ targets.push(targetFront);
+ };
+ await targetList.watchTargets([TargetList.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`
+ );
+ }
+ targetList.unwatchTargets([TargetList.TYPES.FRAME], onAvailable);
+
+ /* NOT READY YET, need to implement frame listening
+ // Open a new tab and see if the frame target is reported by watchTargets and getAllTargets
+ const tab = await addTab(TEST_URL);
+
+ is(targets.length, frames.length + 1, "Opening a tab reported a new frame");
+ is(targets[targets.length - 1].url, TEST_URL, "This frame target is about the new tab");
+
+ const frames3 = await targetList.getAllTargets([TargetList.TYPES.FRAME]);
+ const hasTabDocument = frames3.find(target => target.url == TEST_URL);
+ ok(hasTabDocument, "retrieve the target for tab via getAllTargets");
+ */
+
+ targetList.destroy();
+ await waitForAllTargetsToBeAttached(targetList);
+}
+
+async function testTabFrames(mainRoot) {
+ info("Test TargetList against frames via a tab target");
+
+ // Create a TargetList for a given test tab
+ const tab = await addTab(FISSION_TEST_URL);
+ const descriptor = await mainRoot.getTab({ tab });
+ const target = await descriptor.getTarget();
+ const targetList = new TargetList(mainRoot, target);
+
+ await targetList.startListening();
+
+ // Check that calling getAllTargets([frame]) return the same target instances
+ const frames = await targetList.getAllTargets([TargetList.TYPES.FRAME]);
+ // When fission is enabled, we also get the remote example.org iframe.
+ const expectedFramesCount = isFissionEnabled() ? 2 : 1;
+ is(
+ frames.length,
+ expectedFramesCount,
+ "retrieved only the top level document"
+ );
+
+ // Assert that watchTargets will call the create callback for all existing frames
+ const targets = [];
+ const onAvailable = ({ targetFront }) => {
+ is(
+ targetFront.targetType,
+ TargetList.TYPES.FRAME,
+ "We are only notified about frame targets"
+ );
+ ok(
+ targetFront == target ? targetFront.isTopLevel : !targetFront.isTopLevel,
+ "isTopLevel property is correct"
+ );
+ targets.push(targetFront);
+ };
+ await targetList.watchTargets([TargetList.TYPES.FRAME], onAvailable);
+ is(
+ targets.length,
+ frames.length,
+ "retrieved the same number of frames via watchTargets"
+ );
+
+ for (const frame of frames) {
+ ok(
+ targets.find(t => t === frame),
+ "frame " + frame.actorID + " target is the same via watchTargets"
+ );
+ }
+ targetList.unwatchTargets([TargetList.TYPES.FRAME], onAvailable);
+
+ targetList.destroy();
+ await waitForAllTargetsToBeAttached(targetList);
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async () => {
+ // registrationPromise is set by the test page.
+ const registration = await content.wrappedJSObject.registrationPromise;
+ registration.unregister();
+ });
+ BrowserTestUtils.removeTab(tab);
+}
diff --git a/devtools/shared/resources/tests/browser_target_list_getAllTargets.js b/devtools/shared/resources/tests/browser_target_list_getAllTargets.js
new file mode 100644
index 0000000000..85822a7ede
--- /dev/null
+++ b/devtools/shared/resources/tests/browser_target_list_getAllTargets.js
@@ -0,0 +1,108 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the TargetList API getAllTargets.
+
+const { TargetList } = require("devtools/shared/resources/target-list");
+const { ALL_TYPES, TYPES } = TargetList;
+
+const FISSION_TEST_URL = URL_ROOT_SSL + "fission_document.html";
+const CHROME_WORKER_URL = CHROME_URL_ROOT + "test_worker.js";
+
+add_task(async function() {
+ // Enabled devtools.browsertoolbox.fission to listen to all target types.
+ await pushPref("devtools.browsertoolbox.fission", true);
+
+ // 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 client = await createLocalClient();
+ const mainRoot = client.mainRoot;
+
+ 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 targetDescriptor = await mainRoot.getMainProcess();
+ const target = await targetDescriptor.getTarget();
+ const targetList = new TargetList(mainRoot, target);
+ await targetList.startListening();
+
+ info("Check getAllTargets will throw when providing invalid arguments");
+ Assert.throws(
+ () => targetList.getAllTargets(),
+ e => e.message === "getAllTargets expects a non-empty array of types"
+ );
+
+ Assert.throws(
+ () => targetList.getAllTargets([]),
+ e => e.message === "getAllTargets expects a non-empty array of types"
+ );
+
+ info("Check getAllTargets returns consistent results with several types");
+ const workerTargets = targetList.getAllTargets([TYPES.WORKER]);
+ const serviceWorkerTargets = targetList.getAllTargets([TYPES.SERVICE_WORKER]);
+ const sharedWorkerTargets = targetList.getAllTargets([TYPES.SHARED_WORKER]);
+ const processTargets = targetList.getAllTargets([TYPES.PROCESS]);
+ const frameTargets = targetList.getAllTargets([TYPES.FRAME]);
+
+ const allWorkerTargetsReference = [
+ ...workerTargets,
+ ...serviceWorkerTargets,
+ ...sharedWorkerTargets,
+ ];
+ const allWorkerTargets = targetList.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 = targetList.getAllTargets(ALL_TYPES);
+ is(
+ allTargets.length,
+ allTargetsReference.length,
+ "getAllTargets(TYPES.ALL_TYPES) returned the expected number of targets"
+ );
+
+ ok(
+ allTargets.every(t => allTargetsReference.includes(t)),
+ "getAllTargets(TYPES.ALL_TYPES) returned the expected targets"
+ );
+
+ targetList.destroy();
+
+ // Wait for all the targets to be fully attached so we don't have pending requests.
+ await waitForAllTargetsToBeAttached(targetList);
+
+ await client.close();
+ 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/resources/tests/browser_target_list_preffedoff.js b/devtools/shared/resources/tests/browser_target_list_preffedoff.js
new file mode 100644
index 0000000000..244ffe6c53
--- /dev/null
+++ b/devtools/shared/resources/tests/browser_target_list_preffedoff.js
@@ -0,0 +1,91 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the TargetList API when DevTools Fission preference is false
+
+const { TargetList } = require("devtools/shared/resources/target-list");
+
+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);
+
+ const client = await createLocalClient();
+ const mainRoot = client.mainRoot;
+ const targetDescriptor = await mainRoot.getMainProcess();
+ const mainProcess = await targetDescriptor.getTarget();
+
+ // Assert the limited behavior of this API with fission preffed off
+ await pushPref("devtools.browsertoolbox.fission", false);
+
+ // Test with Main process targets as top level target
+ await testPreffedOffMainProcess(mainRoot, mainProcess);
+
+ await client.close();
+});
+
+async function testPreffedOffMainProcess(mainRoot, mainProcess) {
+ info(
+ "Test TargetList when devtools's fission pref is false, via the parent process target"
+ );
+
+ const targetList = new TargetList(mainRoot, mainProcess);
+ await targetList.startListening();
+
+ // The API should only report the top level target,
+ // i.e. the Main process target, which is considered as frame
+ // and not as process.
+ const processes = await targetList.getAllTargets([TargetList.TYPES.PROCESS]);
+ is(
+ processes.length,
+ 0,
+ "We only get a frame target for the top level target"
+ );
+ const frames = await targetList.getAllTargets([TargetList.TYPES.FRAME]);
+ is(frames.length, 1, "We get only one frame when preffed-off");
+ is(
+ frames[0],
+ mainProcess,
+ "The target is the top level one via getAllTargets"
+ );
+
+ const processTargets = [];
+ const onProcessAvailable = ({ targetFront }) => {
+ processTargets.push(targetFront);
+ };
+ await targetList.watchTargets([TargetList.TYPES.PROCESS], onProcessAvailable);
+ is(processTargets.length, 0, "We get no process when preffed-off");
+ targetList.unwatchTargets([TargetList.TYPES.PROCESS], onProcessAvailable);
+
+ const frameTargets = [];
+ const onFrameAvailable = ({ targetFront }) => {
+ is(
+ targetFront.targetType,
+ TargetList.TYPES.FRAME,
+ "We are only notified about frame targets"
+ );
+ ok(
+ targetFront.isTopLevel,
+ "We are only notified about the top level target"
+ );
+ frameTargets.push(targetFront);
+ };
+ await targetList.watchTargets([TargetList.TYPES.FRAME], onFrameAvailable);
+ is(
+ frameTargets.length,
+ 1,
+ "We get one frame via watchTargets when preffed-off"
+ );
+ is(
+ frameTargets[0],
+ mainProcess,
+ "The target is the top level one via watchTargets"
+ );
+ targetList.unwatchTargets([TargetList.TYPES.FRAME], onFrameAvailable);
+
+ targetList.destroy();
+}
diff --git a/devtools/shared/resources/tests/browser_target_list_processes.js b/devtools/shared/resources/tests/browser_target_list_processes.js
new file mode 100644
index 0000000000..f3614b399d
--- /dev/null
+++ b/devtools/shared/resources/tests/browser_target_list_processes.js
@@ -0,0 +1,195 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the TargetList API around processes
+
+const { TargetList } = require("devtools/shared/resources/target-list");
+
+const TEST_URL =
+ "data:text/html;charset=utf-8," + encodeURIComponent(`<div id="test"></div>`);
+
+add_task(async function() {
+ // Enabled fission's pref as the TargetList is almost disabled without it
+ await pushPref("devtools.browsertoolbox.fission", true);
+ // 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 client = await createLocalClient();
+ const mainRoot = client.mainRoot;
+ const targetDescriptor = await mainRoot.getMainProcess();
+ const mainProcess = await targetDescriptor.getTarget();
+
+ const targetList = new TargetList(mainRoot, mainProcess);
+ await targetList.startListening();
+
+ await testProcesses(targetList, mainProcess);
+
+ await targetList.destroy();
+ // Wait for all the targets to be fully attached so we don't have pending requests.
+ await Promise.all(
+ targetList
+ .getAllTargets(targetList.ALL_TYPES)
+ .map(t => t.attachAndInitThread(targetList))
+ );
+
+ await client.close();
+});
+
+async function testProcesses(targetList, target) {
+ info("Test TargetList against processes");
+
+ // 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 targetList.getAllTargets([TargetList.TYPES.PROCESS]);
+ is(
+ processes.length,
+ originalProcessesCount,
+ "Get a target for all content processes"
+ );
+
+ const processes2 = await targetList.getAllTargets([TargetList.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 onAvailable = ({ targetFront }) => {
+ if (targets.has(targetFront)) {
+ ok(false, "The same target is notified multiple times via onAvailable");
+ }
+ is(
+ targetFront.targetType,
+ TargetList.TYPES.PROCESS,
+ "We are only notified about process targets"
+ );
+ ok(
+ targetFront == target ? 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,
+ TargetList.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 targetList.watchTargets(
+ [TargetList.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;
+ }
+ targetList.unwatchTargets([TargetList.TYPES.PROCESS], onAvailable2);
+ resolve(targetFront);
+ };
+ targetList.watchTargets([TargetList.TYPES.PROCESS], 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 onDestroyed is called for destroyed processes
+ const onProcessDestroyed = new Promise(resolve => {
+ const onAvailable3 = () => {};
+ const onDestroyed3 = ({ targetFront }) => {
+ resolve(targetFront);
+ targetList.unwatchTargets(
+ [TargetList.TYPES.PROCESS],
+ onAvailable3,
+ onDestroyed3
+ );
+ };
+ targetList.watchTargets(
+ [TargetList.TYPES.PROCESS],
+ onAvailable3,
+ 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"
+ );
+
+ targetList.unwatchTargets(
+ [TargetList.TYPES.PROCESS],
+ onAvailable,
+ onDestroyed
+ );
+
+ // Ensure that getAllTargets still works after the call to unwatchTargets
+ const processes3 = await targetList.getAllTargets([TargetList.TYPES.PROCESS]);
+ is(
+ processes3.length,
+ processCountAfterTabOpen - 1,
+ "getAllTargets reports a new target"
+ );
+}
diff --git a/devtools/shared/resources/tests/browser_target_list_service_workers.js b/devtools/shared/resources/tests/browser_target_list_service_workers.js
new file mode 100644
index 0000000000..7af04144b1
--- /dev/null
+++ b/devtools/shared/resources/tests/browser_target_list_service_workers.js
@@ -0,0 +1,79 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the TargetList API for service workers in content tabs.
+
+const { TargetList } = require("devtools/shared/resources/target-list");
+const { TYPES } = TargetList;
+
+const FISSION_TEST_URL = URL_ROOT_SSL + "fission_document.html";
+
+add_task(async function() {
+ // Enabled devtools.browsertoolbox.fission to listen to all target types.
+ await pushPref("devtools.browsertoolbox.fission", true);
+
+ // 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 client = await createLocalClient();
+ const mainRoot = client.mainRoot;
+
+ const tab = await addTab(FISSION_TEST_URL);
+
+ info("Create a target list for a tab target");
+ const descriptor = await mainRoot.getTab({ tab });
+ const target = await descriptor.getTarget();
+ const targetList = new TargetList(mainRoot, target);
+
+ // Enable Service Worker listening.
+ targetList.listenForServiceWorkers = true;
+ await targetList.startListening();
+
+ const serviceWorkerTargets = targetList.getAllTargets([TYPES.SERVICE_WORKER]);
+ is(serviceWorkerTargets.length, 1, "TargetList 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 targetList.watchTargets(
+ [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
+ targetList.destroy();
+
+ await client.waitForRequestsToSettle();
+
+ await client.close();
+});
diff --git a/devtools/shared/resources/tests/browser_target_list_service_workers_navigation.js b/devtools/shared/resources/tests/browser_target_list_service_workers_navigation.js
new file mode 100644
index 0000000000..dc9afdfe4c
--- /dev/null
+++ b/devtools/shared/resources/tests/browser_target_list_service_workers_navigation.js
@@ -0,0 +1,393 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the TargetList 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 { TargetList } = require("devtools/shared/resources/target-list");
+const { TYPES } = TargetList;
+
+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() {
+ const { client, mainRoot } = await setupServiceWorkerNavigationTest();
+
+ const tab = await addTab(COM_PAGE_URL);
+
+ const { hooks, targetList } = await watchServiceWorkerTargets({
+ mainRoot,
+ 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.loadURI(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");
+ const reloaded = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+ gBrowser.reloadTab(gBrowser.selectedTab);
+ await reloaded;
+ 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 page 1");
+ BrowserTestUtils.loadURI(gBrowser.selectedBrowser, COM_PAGE_URL);
+ 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
+ targetList.destroy();
+
+ await client.waitForRequestsToSettle();
+ await client.close();
+ 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() {
+ const { client, mainRoot } = await setupServiceWorkerNavigationTest();
+
+ const tab = await addTab(COM_PAGE_URL);
+
+ const { hooks, targetList } = await watchServiceWorkerTargets({
+ mainRoot,
+ 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.loadURI(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.loadURI(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
+ targetList.destroy();
+
+ await client.waitForRequestsToSettle();
+ await client.close();
+ await removeTab(tab);
+});
+
+/**
+ * In this test we load a service worker in a page prior to starting the
+ * TargetList. 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 TargetList.
+ *
+ * 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,
+}) {
+ const { client, mainRoot } = 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");
+ BrowserTestUtils.loadURI(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");
+ await waitForRegistrationReady(tab, ORG_PAGE_URL);
+
+ const { hooks, targetList } = await watchServiceWorkerTargets({
+ mainRoot,
+ 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");
+ BrowserTestUtils.loadURI(gBrowser.selectedBrowser, COM_PAGE_URL);
+ 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
+ targetList.destroy();
+
+ await client.waitForRequestsToSettle();
+ await client.close();
+ await removeTab(tab);
+}
+
+async function setupServiceWorkerNavigationTest() {
+ // Enabled devtools.browsertoolbox.fission to listen to all target types.
+ await pushPref("devtools.browsertoolbox.fission", true);
+
+ // 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 client = await createLocalClient();
+ const mainRoot = client.mainRoot;
+ return { client, mainRoot };
+}
+
+async function watchServiceWorkerTargets({
+ destroyServiceWorkersOnNavigation,
+ mainRoot,
+ tab,
+}) {
+ info("Create a target list for a tab target");
+ const descriptor = await mainRoot.getTab({ tab });
+ const target = await descriptor.getTarget();
+ const targetList = new TargetList(mainRoot, target);
+
+ // Enable Service Worker listening.
+ targetList.listenForServiceWorkers = true;
+ info(
+ "Set targetList.destroyServiceWorkersOnNavigation to " +
+ destroyServiceWorkersOnNavigation
+ );
+ targetList.destroyServiceWorkersOnNavigation = destroyServiceWorkersOnNavigation;
+ await targetList.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 targetList.watchTargets(
+ [TYPES.SERVICE_WORKER],
+ onAvailable,
+ onDestroyed
+ );
+
+ return { hooks, targetList };
+}
+
+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/resources/tests/browser_target_list_switchToTarget.js b/devtools/shared/resources/tests/browser_target_list_switchToTarget.js
new file mode 100644
index 0000000000..ea166e40fc
--- /dev/null
+++ b/devtools/shared/resources/tests/browser_target_list_switchToTarget.js
@@ -0,0 +1,147 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the TargetList API switchToTarget function
+
+const { TargetList } = require("devtools/shared/resources/target-list");
+
+add_task(async function() {
+ const client = await createLocalClient();
+
+ await testSwitchToTarget(client);
+
+ await client.close();
+});
+
+async function testSwitchToTarget(client) {
+ info("Test TargetList.switchToTarget method");
+
+ const { mainRoot } = client;
+ // 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 firstDescriptor = await mainRoot.getTab({ tab: gBrowser.selectedTab });
+ const firstTarget = await firstDescriptor.getTarget();
+
+ const targetList = new TargetList(mainRoot, firstTarget);
+
+ await targetList.startListening();
+
+ is(
+ targetList.targetFront,
+ firstTarget,
+ "The target list top level target is the main process one"
+ );
+
+ // 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>`
+ );
+ const secondDescriptor = await mainRoot.getTab({ tab: gBrowser.selectedTab });
+ const secondTarget = await secondDescriptor.getTarget();
+
+ const frameTargets = [];
+ let currentTarget = firstTarget;
+ const onFrameAvailable = ({ targetFront, isTargetSwitching }) => {
+ is(
+ targetFront.targetType,
+ TargetList.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,
+ TargetList.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 targetList.watchTargets(
+ [TargetList.TYPES.FRAME],
+ onFrameAvailable,
+ 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 targetList.switchToTarget(secondTarget);
+
+ is(
+ targetList.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"
+ );
+ }
+
+ targetList.destroy();
+
+ BrowserTestUtils.removeTab(firstTab);
+ BrowserTestUtils.removeTab(secondTab);
+}
diff --git a/devtools/shared/resources/tests/browser_target_list_tab_workers.js b/devtools/shared/resources/tests/browser_target_list_tab_workers.js
new file mode 100644
index 0000000000..e4e1a5a0dd
--- /dev/null
+++ b/devtools/shared/resources/tests/browser_target_list_tab_workers.js
@@ -0,0 +1,330 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the TargetList API around workers
+
+const { TargetList } = require("devtools/shared/resources/target-list");
+const { TYPES } = TargetList;
+
+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 IFRAME_WORKER_URL = 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);
+
+ const client = await createLocalClient();
+ const mainRoot = client.mainRoot;
+
+ // 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 TargetList against workers via a tab target");
+ const tab = await addTab(`${FISSION_TEST_URL}?&noServiceWorker`);
+
+ // Create a TargetList for the tab
+ const descriptor = await mainRoot.getTab({ tab });
+ const target = await descriptor.getTarget();
+
+ // Ensure attaching the target as BrowsingContextTargetActor.listWorkers
+ // assert that the target actor is attached.
+ // It isn't clear if this assertion is meaningful?
+ await target.attach();
+
+ const targetList = new TargetList(mainRoot, target);
+
+ // Workaround to allow listening for workers in the content toolbox
+ // without the fission preferences
+ targetList.listenForWorkers = true;
+
+ await targetList.startListening();
+
+ info("Check that getAllTargets only returns dedicated workers");
+ const workers = await targetList.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 => worker.url == `${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 onDestroy = 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 targetList.watchTargets(
+ [TYPES.WORKER, TYPES.SHARED_WORKER],
+ onAvailable,
+ onDestroy
+ );
+
+ // 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 TestUtils.waitForCondition(
+ () => targets.length === 4,
+ "Wait for the target list to notify us about the spawned worker"
+ );
+ const mainPageSpawnedWorkerTarget = targets.find(
+ innerTarget => innerTarget.url === `${WORKER_FILE}#spawned-worker`
+ );
+ ok(mainPageSpawnedWorkerTarget, "Retrieved spawned worker");
+ const iframeSpawnedWorkerTarget = targets.find(
+ innerTarget => innerTarget.url === `${WORKER_FILE}#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 TestUtils.waitForCondition(
+ () =>
+ 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;
+ tab.linkedBrowser.reload();
+
+ await TestUtils.waitForCondition(() => {
+ 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 TestUtils.waitForCondition(
+ () => 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 == `${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 TestUtils.waitForCondition(() => {
+ 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 TestUtils.waitForCondition(
+ () => 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_FILE}#simple-worker-in-created-iframe-1`
+ );
+ const secondSpawnedIframeWorkerTarget = targets.find(
+ worker => worker.url == `${WORKER_FILE}#simple-worker-in-created-iframe-2`
+ );
+ const firstSpawnedRemoteIframeWorkerTarget = targets.find(
+ worker =>
+ worker.url == `${WORKER_FILE}#simple-worker-in-created-remote-iframe-1`
+ );
+ const secondSpawnedRemoteIframeWorkerTarget = targets.find(
+ worker =>
+ worker.url == `${WORKER_FILE}#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.loadURI(
+ tab.linkedBrowser,
+ "data:text/html,<meta charset=utf8>Away"
+ );
+
+ await TestUtils.waitForCondition(
+ () => destroyedTargets.length === targets.length,
+ "Wait for all the targets to be reporeted 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"
+ );
+
+ targetList.unwatchTargets(
+ [TYPES.WORKER, TYPES.SHARED_WORKER],
+ onAvailable,
+ onDestroy
+ );
+ targetList.destroy();
+
+ info("Unregister service workers so they don't appear in other tests.");
+ await unregisterAllServiceWorkers(client);
+
+ BrowserTestUtils.removeTab(tab);
+ await client.close();
+});
diff --git a/devtools/shared/resources/tests/browser_target_list_watchTargets.js b/devtools/shared/resources/tests/browser_target_list_watchTargets.js
new file mode 100644
index 0000000000..68d0a381fd
--- /dev/null
+++ b/devtools/shared/resources/tests/browser_target_list_watchTargets.js
@@ -0,0 +1,256 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the TargetList's `watchTargets` function
+
+const { TargetList } = require("devtools/shared/resources/target-list");
+
+const TEST_URL =
+ "data:text/html;charset=utf-8," + encodeURIComponent(`<div id="test"></div>`);
+
+add_task(async function() {
+ // Enabled fission's pref as the TargetList is almost disabled without it
+ await pushPref("devtools.browsertoolbox.fission", true);
+ // 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 client = await createLocalClient();
+ const mainRoot = client.mainRoot;
+
+ await testWatchTargets(mainRoot);
+ await testContentProcessTarget(mainRoot);
+ await testThrowingInOnAvailable(mainRoot);
+
+ await client.close();
+});
+
+async function testWatchTargets(mainRoot) {
+ info("Test TargetList watchTargets function");
+
+ const targetDescriptor = await mainRoot.getMainProcess();
+ const target = await targetDescriptor.getTarget();
+ const targetList = new TargetList(mainRoot, target);
+
+ await targetList.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 onAvailable = ({ targetFront }) => {
+ if (targets.has(targetFront)) {
+ ok(false, "The same target is notified multiple times via onAvailable");
+ }
+ is(
+ targetFront.targetType,
+ TargetList.TYPES.PROCESS,
+ "We are only notified about process targets"
+ );
+ ok(
+ targetFront == target ? 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,
+ TargetList.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 targetList.watchTargets(
+ [TargetList.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;
+ }
+ targetList.unwatchTargets([TargetList.TYPES.PROCESS], onAvailable2);
+ resolve(targetFront);
+ };
+ targetList.watchTargets([TargetList.TYPES.PROCESS], 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);
+ targetList.unwatchTargets(
+ [TargetList.TYPES.PROCESS],
+ onAvailable3,
+ onDestroyed3
+ );
+ };
+ targetList.watchTargets(
+ [TargetList.TYPES.PROCESS],
+ onAvailable3,
+ 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"
+ );
+
+ targetList.unwatchTargets(
+ [TargetList.TYPES.PROCESS],
+ onAvailable,
+ onDestroyed
+ );
+
+ targetList.destroy();
+}
+
+async function testContentProcessTarget(mainRoot) {
+ info("Test TargetList watchTargets with a content process target");
+
+ const processes = await mainRoot.listProcesses();
+ const target = await processes[1].getTarget();
+ const targetList = new TargetList(mainRoot, target);
+
+ await targetList.startListening();
+
+ // Assert that watchTargets is only called for the top level content process target
+ // as listening for additional target is only enable for the parent process target.
+ // See bug 1593928.
+ const targets = new Set();
+ const onAvailable = ({ targetFront }) => {
+ if (targets.has(targetFront)) {
+ // This may fail if the top level target is reported by LegacyImplementation
+ // to TargetList and emits an available event for it.
+ ok(false, "The same target is notified multiple times via onAvailable");
+ }
+ is(
+ targetFront.targetType,
+ TargetList.TYPES.PROCESS,
+ "We are only notified about process targets"
+ );
+ is(targetFront, target, "This is the existing top level target");
+ ok(
+ targetFront.isTopLevel,
+ "We are only notified about the top level target"
+ );
+ targets.add(targetFront);
+ };
+ const onDestroyed = _ => {
+ ok(false, "onDestroyed should never be called in this test");
+ };
+ await targetList.watchTargets(
+ [TargetList.TYPES.PROCESS],
+ onAvailable,
+ onDestroyed
+ );
+
+ // This may fail if the top level target is reported by LegacyImplementation
+ // to TargetList and registers a duplicated entry
+ is(targets.size, 1, "We were only notified about the top level target");
+
+ targetList.destroy();
+}
+
+async function testThrowingInOnAvailable(mainRoot) {
+ info(
+ "Test TargetList watchTargets function when an exception is thrown in onAvailable callback"
+ );
+
+ const targetDescriptor = await mainRoot.getMainProcess();
+ const target = await targetDescriptor.getTarget();
+ const targetList = new TargetList(mainRoot, target);
+
+ await targetList.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 targetList.watchTargets([TargetList.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."
+ );
+
+ targetList.destroy();
+}
diff --git a/devtools/shared/resources/tests/early_console_document.html b/devtools/shared/resources/tests/early_console_document.html
new file mode 100644
index 0000000000..e4523dbdeb
--- /dev/null
+++ b/devtools/shared/resources/tests/early_console_document.html
@@ -0,0 +1,14 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf8">
+ <title>Test fission document</title>
+ <!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+ <script>
+ "use strict";
+ console.log("early-page-log");
+ </script>
+</head>
+<body></body>
+</html>
diff --git a/devtools/shared/resources/tests/fission_document.html b/devtools/shared/resources/tests/fission_document.html
new file mode 100644
index 0000000000..24f9704a76
--- /dev/null
+++ b/devtools/shared/resources/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/resources/tests/test_worker.js#simple-worker");
+
+ // eslint-disable-next-line no-unused-vars
+ const sharedWorker = new SharedWorker("https://example.com/browser/devtools/shared/resources/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/resources/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/resources/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/resources/tests/fission_iframe.html b/devtools/shared/resources/tests/fission_iframe.html
new file mode 100644
index 0000000000..deae49f833
--- /dev/null
+++ b/devtools/shared/resources/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/resources/tests/head.js b/devtools/shared/resources/tests/head.js
new file mode 100644
index 0000000000..988592f9a1
--- /dev/null
+++ b/devtools/shared/resources/tests/head.js
@@ -0,0 +1,150 @@
+/* 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"}] */
+/* import-globals-from ../../../client/shared/test/shared-head.js */
+
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/devtools/client/shared/test/shared-head.js",
+ this
+);
+
+const { DevToolsClient } = require("devtools/client/devtools-client");
+const { DevToolsServer } = require("devtools/server/devtools-server");
+
+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;
+}
+
+async function _initResourceWatcherFromDescriptor(
+ client,
+ descriptor,
+ { listenForWorkers = false } = {}
+) {
+ const { TargetList } = require("devtools/shared/resources/target-list");
+ const {
+ ResourceWatcher,
+ } = require("devtools/shared/resources/resource-watcher");
+
+ const target = await descriptor.getTarget();
+ const targetList = new TargetList(client.mainRoot, target);
+ if (listenForWorkers) {
+ targetList.listenForWorkers = true;
+ }
+ await targetList.startListening();
+
+ // Now create a ResourceWatcher
+ const resourceWatcher = new ResourceWatcher(targetList);
+
+ return { client, resourceWatcher, targetList };
+}
+
+/**
+ * Instantiate a ResourceWatcher for the given tab.
+ *
+ * @param {Tab} tab
+ * The browser frontend's tab to connect to.
+ * @param {Object} options
+ * @param {Boolean} options.listenForWorkers
+ * @return {Object} object
+ * @return {ResourceWatcher} object.client
+ * The underlying client instance.
+ * @return {DevToolsClient} object.client
+ * The underlying client instance.
+ * @return {DevToolsClient} object.targetList
+ * The underlying target list instance.
+ */
+async function initResourceWatcher(tab, options) {
+ const client = await createLocalClient();
+ const descriptor = await client.mainRoot.getTab({ tab });
+ return _initResourceWatcherFromDescriptor(client, descriptor, options);
+}
+
+/**
+ * Instantiate a multi-process ResourceWatcher, watching all type of targets.
+ *
+ * @return {Object} object
+ * @return {ResourceWatcher} object.client
+ * The underlying client instance.
+ * @return {DevToolsClient} object.client
+ * The underlying client instance.
+ * @return {DevToolsClient} object.targetList
+ * The underlying target list instance.
+ */
+async function initMultiProcessResourceWatcher() {
+ const client = await createLocalClient();
+ const descriptor = await client.mainRoot.getMainProcess();
+ return _initResourceWatcherFromDescriptor(client, descriptor);
+}
+
+// Copied from devtools/shared/webconsole/test/chrome/common.js
+function checkObject(object, expected) {
+ if (object && object.getGrip) {
+ object = object.getGrip();
+ }
+
+ for (const name of Object.keys(expected)) {
+ const expectedValue = expected[name];
+ const value = object[name];
+ checkValue(name, value, expectedValue);
+ }
+}
+
+function checkValue(name, value, expected) {
+ if (expected === null) {
+ is(value, null, `'${name}' is null`);
+ } else if (value === undefined) {
+ is(value, undefined, `'${name}' is undefined`);
+ } else if (value === null) {
+ is(value, expected, `'${name}' has expected value`);
+ } else if (
+ typeof expected == "string" ||
+ typeof expected == "number" ||
+ typeof expected == "boolean"
+ ) {
+ is(value, expected, "property '" + name + "'");
+ } else if (expected instanceof RegExp) {
+ ok(expected.test(value), name + ": " + expected + " matched " + value);
+ } else if (Array.isArray(expected)) {
+ info("checking array for property '" + name + "'");
+ ok(Array.isArray(value), `property '${name}' is an array`);
+
+ is(value.length, expected.length, "Array has expected length");
+ if (value.length !== expected.length) {
+ is(JSON.stringify(value, null, 2), JSON.stringify(expected, null, 2));
+ } else {
+ checkObject(value, expected);
+ }
+ } else if (typeof expected == "object") {
+ info("checking object for property '" + name + "'");
+ checkObject(value, expected);
+ }
+}
+
+async function triggerNetworkRequests(browser, commands) {
+ for (let i = 0; i < commands.length; i++) {
+ await SpecialPowers.spawn(browser, [commands[i]], async function(code) {
+ const script = content.document.createElement("script");
+ script.append(
+ content.document.createTextNode(
+ `async function triggerRequest() {${code}}`
+ )
+ );
+ content.document.body.append(script);
+ await content.wrappedJSObject.triggerRequest();
+ script.remove();
+ });
+ }
+}
diff --git a/devtools/shared/resources/tests/network_document.html b/devtools/shared/resources/tests/network_document.html
new file mode 100644
index 0000000000..5c4744cb0c
--- /dev/null
+++ b/devtools/shared/resources/tests/network_document.html
@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8">
+ <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
+ <meta http-equiv="Pragma" content="no-cache" />
+ <meta http-equiv="Expires" content="0" />
+ <title>Test for network events</title>
+ </head>
+ <body>
+ <p>Test for network events</p>
+ </body>
+</html>
diff --git a/devtools/shared/resources/tests/service-worker-sources.js b/devtools/shared/resources/tests/service-worker-sources.js
new file mode 100644
index 0000000000..614644ee5d
--- /dev/null
+++ b/devtools/shared/resources/tests/service-worker-sources.js
@@ -0,0 +1,2 @@
+/* eslint-disable */
+function serviceWorkerSource() {}
diff --git a/devtools/shared/resources/tests/sources.html b/devtools/shared/resources/tests/sources.html
new file mode 100644
index 0000000000..d765f25ef0
--- /dev/null
+++ b/devtools/shared/resources/tests/sources.html
@@ -0,0 +1,22 @@
+<!-- Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ -->
+<!doctype HTML>
+<html>
+ <head>
+ <meta charset="utf-8"/>
+ </head>
+ <body>
+ <script type="text/javascript">
+ "use strict";
+ /* eslint-disable */
+ function inlineSource() {}
+ // Assign it to a global in order to avoid it being GCed
+ eval("this.global = function evalFunction() {}");
+ // Assign the worker to a global variable in order to avoid
+ // having it be GCed.
+ this.worker = new Worker("worker-sources.js");
+ window.registrationPromise = navigator.serviceWorker.register("service-worker-sources.js");
+ </script>
+ <script src="sources.js"></script>
+ </body>
+</html>
diff --git a/devtools/shared/resources/tests/sources.js b/devtools/shared/resources/tests/sources.js
new file mode 100644
index 0000000000..7ae6c6272b
--- /dev/null
+++ b/devtools/shared/resources/tests/sources.js
@@ -0,0 +1,2 @@
+/* eslint-disable */
+function scriptSource() {}
diff --git a/devtools/shared/resources/tests/style_document.css b/devtools/shared/resources/tests/style_document.css
new file mode 100644
index 0000000000..aa54533924
--- /dev/null
+++ b/devtools/shared/resources/tests/style_document.css
@@ -0,0 +1 @@
+body { margin: 1px; }
diff --git a/devtools/shared/resources/tests/style_document.html b/devtools/shared/resources/tests/style_document.html
new file mode 100644
index 0000000000..1b5e97dfe7
--- /dev/null
+++ b/devtools/shared/resources/tests/style_document.html
@@ -0,0 +1,16 @@
+<!DOCTYPE HTML>
+<html>
+ <head>
+ <meta charset="utf8">
+ <title>Test style document</title>
+ <!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+ <style>
+ body { color: lime; }
+ </style>
+ <link href="style_document.css" rel="stylesheet" type="text/css"/>
+ </head>
+ <body>
+ <iframe src="https://example.org/browser/devtools/shared/resources/tests/style_iframe.html"></iframe>
+ </body>
+</html>
diff --git a/devtools/shared/resources/tests/style_iframe.css b/devtools/shared/resources/tests/style_iframe.css
new file mode 100644
index 0000000000..30e7ae802b
--- /dev/null
+++ b/devtools/shared/resources/tests/style_iframe.css
@@ -0,0 +1 @@
+body { padding: 1px; }
diff --git a/devtools/shared/resources/tests/style_iframe.html b/devtools/shared/resources/tests/style_iframe.html
new file mode 100644
index 0000000000..11ad9f785b
--- /dev/null
+++ b/devtools/shared/resources/tests/style_iframe.html
@@ -0,0 +1,15 @@
+<!DOCTYPE HTML>
+<html>
+ <head>
+ <meta charset="utf8">
+ <title>Test style iframe document</title>
+ <!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+ <style>
+ body { background-color: pink; }
+ </style>
+ <link href="style_iframe.css" rel="stylesheet" type="text/css"/>
+ </head>
+ <body>
+ </body>
+</html>
diff --git a/devtools/shared/resources/tests/test_service_worker.js b/devtools/shared/resources/tests/test_service_worker.js
new file mode 100644
index 0000000000..3b69a40dcd
--- /dev/null
+++ b/devtools/shared/resources/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/resources/tests/test_sw_page.html b/devtools/shared/resources/tests/test_sw_page.html
new file mode 100644
index 0000000000..38aad04259
--- /dev/null
+++ b/devtools/shared/resources/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/resources/tests/test_sw_page_worker.js b/devtools/shared/resources/tests/test_sw_page_worker.js
new file mode 100644
index 0000000000..29cda68560
--- /dev/null
+++ b/devtools/shared/resources/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/resources/tests/test_worker.js b/devtools/shared/resources/tests/test_worker.js
new file mode 100644
index 0000000000..873041fcf0
--- /dev/null
+++ b/devtools/shared/resources/tests/test_worker.js
@@ -0,0 +1,15 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+console.log("[WORKER] started", globalThis.location.toString(), globalThis);
+
+globalThis.onmessage = function(e) {
+ const { type, message } = e.data;
+
+ if (type === "log-in-worker") {
+ // Printing `e` so we can check that we have an object and not a stringified version
+ console.log("[WORKER]", message, e);
+ }
+};
diff --git a/devtools/shared/resources/tests/websocket_backend_wsh.py b/devtools/shared/resources/tests/websocket_backend_wsh.py
new file mode 100644
index 0000000000..564715114b
--- /dev/null
+++ b/devtools/shared/resources/tests/websocket_backend_wsh.py
@@ -0,0 +1,21 @@
+from __future__ import absolute_import
+from mod_pywebsocket import msgutil
+
+
+def web_socket_do_extra_handshake(request):
+ pass
+
+
+def web_socket_transfer_data(request):
+ while not request.client_terminated:
+ resp = msgutil.receive_message(request)
+ msgutil.send_message(request, resp)
+
+
+def web_socket_passive_closing_handshake(request):
+ # If we use `pass` here, the `payload` of `frameReceived` which will be happened
+ # of communication of closing will be `\u0003รจ`. In order to make the `payload`
+ # to be empty string, return code and reason explicitly.
+ code = None
+ reason = None
+ return code, reason
diff --git a/devtools/shared/resources/tests/websocket_frontend.html b/devtools/shared/resources/tests/websocket_frontend.html
new file mode 100644
index 0000000000..ce9b3b93bd
--- /dev/null
+++ b/devtools/shared/resources/tests/websocket_frontend.html
@@ -0,0 +1,39 @@
+<!-- Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ -->
+<!doctype HTML>
+<html>
+ <head>
+ <meta charset="utf-8"/>
+ </head>
+ <body>
+ <script type="text/javascript">
+ /* exported openConnection, closeConnection, sendData */
+ "use strict";
+
+ let webSocket;
+ function openConnection() {
+ return new Promise(resolve => {
+ webSocket = new WebSocket(
+ "ws://mochi.test:8888/browser/devtools/shared/resources/tests/websocket_backend"
+ );
+ webSocket.onopen = () => {
+ resolve();
+ };
+ });
+ }
+
+ function closeConnection() {
+ return new Promise(resolve => {
+ webSocket.onclose = () => {
+ resolve();
+ };
+ webSocket.close();
+ })
+ }
+
+ function sendData(payload) {
+ webSocket.send(payload);
+ }
+ </script>
+ </body>
+</html>
diff --git a/devtools/shared/resources/tests/worker-sources.js b/devtools/shared/resources/tests/worker-sources.js
new file mode 100644
index 0000000000..dcf2ed8031
--- /dev/null
+++ b/devtools/shared/resources/tests/worker-sources.js
@@ -0,0 +1,2 @@
+/* eslint-disable */
+function workerSource() {}
diff --git a/devtools/shared/resources/transformers/console-messages.js b/devtools/shared/resources/transformers/console-messages.js
new file mode 100644
index 0000000000..536c3e2b6e
--- /dev/null
+++ b/devtools/shared/resources/transformers/console-messages.js
@@ -0,0 +1,23 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+// eslint-disable-next-line mozilla/reject-some-requires
+loader.lazyRequireGetter(
+ this,
+ "getAdHocFrontOrPrimitiveGrip",
+ "devtools/client/fronts/object",
+ true
+);
+
+module.exports = function({ resource, targetFront }) {
+ if (Array.isArray(resource.message.arguments)) {
+ // We might need to create fronts for each of the message arguments.
+ resource.message.arguments = resource.message.arguments.map(arg =>
+ getAdHocFrontOrPrimitiveGrip(arg, targetFront)
+ );
+ }
+ return resource;
+};
diff --git a/devtools/shared/resources/transformers/error-messages.js b/devtools/shared/resources/transformers/error-messages.js
new file mode 100644
index 0000000000..8d6e2b821e
--- /dev/null
+++ b/devtools/shared/resources/transformers/error-messages.js
@@ -0,0 +1,31 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+// eslint-disable-next-line mozilla/reject-some-requires
+loader.lazyRequireGetter(
+ this,
+ "getAdHocFrontOrPrimitiveGrip",
+ "devtools/client/fronts/object",
+ true
+);
+
+module.exports = function({ resource, targetFront }) {
+ if (resource?.pageError?.errorMessage) {
+ resource.pageError.errorMessage = getAdHocFrontOrPrimitiveGrip(
+ resource.pageError.errorMessage,
+ targetFront
+ );
+ }
+
+ if (resource?.pageError?.exception) {
+ resource.pageError.exception = getAdHocFrontOrPrimitiveGrip(
+ resource.pageError.exception,
+ targetFront
+ );
+ }
+
+ return resource;
+};
diff --git a/devtools/shared/resources/transformers/moz.build b/devtools/shared/resources/transformers/moz.build
new file mode 100644
index 0000000000..0a124db7a8
--- /dev/null
+++ b/devtools/shared/resources/transformers/moz.build
@@ -0,0 +1,11 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DevToolsModules(
+ "console-messages.js",
+ "error-messages.js",
+ "network-events.js",
+ "storage-local-storage.js",
+ "storage-session-storage.js",
+)
diff --git a/devtools/shared/resources/transformers/network-events.js b/devtools/shared/resources/transformers/network-events.js
new file mode 100644
index 0000000000..55a0efea77
--- /dev/null
+++ b/devtools/shared/resources/transformers/network-events.js
@@ -0,0 +1,16 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const {
+ getUrlDetails,
+ // eslint-disable-next-line mozilla/reject-some-requires
+} = require("devtools/client/netmonitor/src/utils/request-utils");
+
+module.exports = function({ resource }) {
+ resource.urlDetails = getUrlDetails(resource.url);
+ resource.startedMs = Date.parse(resource.startedDateTime);
+ return resource;
+};
diff --git a/devtools/shared/resources/transformers/storage-local-storage.js b/devtools/shared/resources/transformers/storage-local-storage.js
new file mode 100644
index 0000000000..d9d78e1f10
--- /dev/null
+++ b/devtools/shared/resources/transformers/storage-local-storage.js
@@ -0,0 +1,23 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const {
+ TYPES: { LOCAL_STORAGE },
+} = require("devtools/shared/resources/resource-watcher");
+
+const { Front, types } = require("devtools/shared/protocol.js");
+
+module.exports = function({ resource, watcherFront, targetFront }) {
+ if (!(resource instanceof Front) && watcherFront) {
+ // instantiate front for local storage
+ resource = types.getType("localStorage").read(resource, targetFront);
+ resource.resourceType = LOCAL_STORAGE;
+ resource.resourceId = LOCAL_STORAGE;
+ resource.resourceKey = "localStorage";
+ }
+
+ return resource;
+};
diff --git a/devtools/shared/resources/transformers/storage-session-storage.js b/devtools/shared/resources/transformers/storage-session-storage.js
new file mode 100644
index 0000000000..2a346fbfaf
--- /dev/null
+++ b/devtools/shared/resources/transformers/storage-session-storage.js
@@ -0,0 +1,23 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const {
+ TYPES: { SESSION_STORAGE },
+} = require("devtools/shared/resources/resource-watcher");
+
+const { Front, types } = require("devtools/shared/protocol.js");
+
+module.exports = function({ resource, watcherFront, targetFront }) {
+ if (!(resource instanceof Front) && watcherFront) {
+ // instantiate front for session storage
+ resource = types.getType("sessionStorage").read(resource, targetFront);
+ resource.resourceType = SESSION_STORAGE;
+ resource.resourceId = SESSION_STORAGE;
+ resource.resourceKey = "sessionStorage";
+ }
+
+ return resource;
+};